diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 865cbd6d..77ada5e9 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -13,37 +13,19 @@ jobs: steps: - uses: actions/checkout@v3 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 18 - name: Setup pnpm - uses: pnpm/action-setup@v2.2.4 + uses: pnpm/action-setup@v4 with: version: latest - run_install: false - name: Cache id: cache uses: actions/cache@v3 with: path: testSets key: ${{ runner.os }}-test-sets - - run: pnpm build-cli + - run: pnpm build:cli + - run: pnpm i - run: pnpm test -- --sets-dir testSets --sync-dir syncfiles --set cc2lp1 --set CC2LP1V - desktop-artifact: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Use Node.js - uses: actions/setup-node@v3 - with: - node-version: 18 - - name: Setup pnpm - uses: pnpm/action-setup@v2.2.4 - with: - version: latest - run_install: false - - run: pnpm build-desktop - - uses: actions/upload-artifact@v3 - with: - name: NotCC desktop - path: desktopPlayer/artifacts diff --git a/.gitignore b/.gitignore index 5c791769..94b791c2 100644 --- a/.gitignore +++ b/.gitignore @@ -107,8 +107,9 @@ original.bmp *.pb.* -desktopPlayer/bin -desktopPlayer/.tmp -desktopPlayer/artifacts -desktopPlayer/NotCC.app/Contents/MacOS -desktopPlayer/NotCC.zip +# Neutralino browser binaries +gamePlayer/bin +gamePlayer/neuDist +gamePlayer/artifacts +gamePlayer/NotCC.zip +.tmp diff --git a/.prettierrc b/.prettierrc index 41889ed6..4e6e3cdd 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "arrowParens": "avoid", "semi": false, "singleQuote": false, - "trailingComma": "es5" + "trailingComma": "es5", + "plugins": ["prettier-plugin-tailwindcss"] } diff --git a/LICENSE b/LICENSE index 13343eb6..53d1f3d0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,675 @@ -MIT License - -Copyright (c) 2020 Lubomir - -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 GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. + diff --git a/cli/package.json b/cli/package.json deleted file mode 100644 index 300273d9..00000000 --- a/cli/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@notcc/cli", - "version": "0.1.0", - "description": "A CLI for verifying NotCC solutions!", - "main": "./dist/index.js", - "scripts": { - "build": "tsc", - "dev": "tsc --watch" - }, - "type": "module", - "bin": { - "notcc": "./dist/index.js" - }, - "author": { - "name": "G lander" - }, - "license": "MIT", - "dependencies": { - "@notcc/logic": "workspace:^", - "@types/progress": "^2.0.6", - "ini": "^4.1.1", - "picocolors": "^1.0.0", - "progress": "^2.0.3", - "prompts": "^2.4.2", - "yargs": "^17.7.2" - }, - "devDependencies": { - "@types/ini": "^1.3.32", - "@types/node": "^15.14.9", - "@types/prompts": "^2.4.7", - "@types/yargs": "^17.0.29", - "typescript": "^4.9.5" - } -} diff --git a/cli/src/commands/c2gShell.ts b/cli/src/commands/c2gShell.ts deleted file mode 100644 index 22939fba..00000000 --- a/cli/src/commands/c2gShell.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { ScriptRunner, C2G_NOTCC_VERSION, ScriptInterrupt } from "@notcc/logic" -import { errorAndExit } from "../helpers.js" -import { createInterface } from "readline" -import fs from "fs" -import { resolve } from "path" -import pc from "picocolors" -import { Argv } from "yargs" - -const inf = createInterface({ input: process.stdin, output: process.stdout }) -function question(prompt: string): Promise { - return new Promise(res => { - inf.question(prompt, ans => res(ans)) - }) -} - -function resolveInterrupt(runner: ScriptRunner, interrupt: ScriptInterrupt) { - if (interrupt.type === "chain") { - const c2gPath = resolve( - process.cwd(), - interrupt.path.replace(/^~/, process.env.HOME ?? "") - ) - - if (!fs.existsSync(c2gPath)) { - errorAndExit("A chain path must lead to a file!") - } - const c2gData = fs.readFileSync(c2gPath, "latin1") - runner.handleChainInterrupt(c2gData) - return - } else if (interrupt.type === "script") { - console.log(interrupt.text) - runner.scriptInterrupt = null - return - } else if (interrupt.type === "map") { - console.warn("`map` interrupts aren't supported in the shell.") - runner.handleMapInterrupt({ type: "skip" }) - return - } -} - -async function startC2GShell(): Promise { - console.log( - pc.green(`NotCC C2G Shell version ${pc.bold(`${C2G_NOTCC_VERSION}`)}`) - ) - const runner = new ScriptRunner('game "C2G shell"') - runner.executeLine() - - // eslint-disable-next-line no-constant-condition - while (true) { - const newScriptLines: string[] = [] - let scriptLine: string - let firstLine = true - do { - scriptLine = await question(firstLine ? "> " : ". ") - newScriptLines.push(scriptLine.replace(/\\$/, "")) - firstLine = false - } while (scriptLine.endsWith("\\")) - runner.scriptLines.push(...newScriptLines) - runner.generateLabels() - let interrupt: ScriptInterrupt | null - - while ((interrupt = runner.executeUntilInterrupt())) { - resolveInterrupt(runner, interrupt) - } - } -} - -export default (yargs: Argv): Argv => - yargs.command("c2g", "Launches a C2G shell", startC2GShell) diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts deleted file mode 100644 index 877cd0b9..00000000 --- a/cli/src/commands/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import c2gShell from "./c2gShell.js" -import statLevel from "./statLevel.js" -import verifyLevels from "./verifyLevels.js" - -export default [c2gShell, statLevel, verifyLevels] diff --git a/cli/src/commands/statLevel.ts b/cli/src/commands/statLevel.ts deleted file mode 100644 index 70b47903..00000000 --- a/cli/src/commands/statLevel.ts +++ /dev/null @@ -1,46 +0,0 @@ -import fs from "fs" -import { parseC2M } from "@notcc/logic" -import { ArgumentsCamelCase, Argv } from "yargs" - -interface Options { - files: string[] -} - -export function statFile(args: ArgumentsCamelCase): void { - for (const filePath of args.files) { - // This weird uint8 loop is because node is dumb - const dataBuffer = new Uint8Array(fs.readFileSync(filePath, null)).buffer - - const data = parseC2M(dataBuffer) - console.log( - `${filePath}: -CC2 level, ${data.name ? `named '${data.name}'` : "unnamed"} -Level size: ${data.width}x${data.height}, time limit: ${ - data.timeLimit === 0 ? "untimed" : data.timeLimit + "s" - }${ - data.associatedSolution - ? `, has inline solution (${data.associatedSolution.steps?.[0].length} steps)` - : "" - } -Has a ${data.camera.width}x${data.camera.height} camera, blobs have ${ - data.blobMode ?? 1 - } seed${data.blobMode && data.blobMode !== 1 ? "s" : ""}` - ) - break - } -} - -export default (yargs: Argv): Argv => - yargs.command( - "stat ", - "Gets stats about a level", - yargs => - yargs - .usage("Usage: $0 stat ") - .positional("files", { - describe: "The files to read", - coerce: files => (files instanceof Array ? files : [files]), - }) - .demandOption("files"), - statFile - ) diff --git a/cli/src/commands/verifyLevels.ts b/cli/src/commands/verifyLevels.ts deleted file mode 100644 index eacdc040..00000000 --- a/cli/src/commands/verifyLevels.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { exit } from "process" -import { resolveLevelPath } from "../helpers.js" -import { MessageChannel, MessagePort, Worker } from "worker_threads" -import { join, dirname } from "path" -import os from "os" -import pc from "picocolors" -import ProgressBar from "progress" -import { ArgumentsCamelCase, Argv } from "yargs" -import { decode } from "ini" -import { readFile } from "fs/promises" -import { fileURLToPath } from "url" -import { protobuf } from "@notcc/logic" - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -const levelOutcomes = ["success", "noInput", "badInput", "error"] as const - -type LevelOutcome = (typeof levelOutcomes)[number] - -interface WorkerLevelMessage { - levelName: string - outcome: LevelOutcome - desc?: string - type: "level" - glitches: protobuf.IGlitchInfo[] -} - -export type WorkerMessage = WorkerLevelMessage - -export interface ParentNewLevelMessage { - type: "new level" - levelPath: string -} - -export interface ParentEndMessage { - type: "end" -} - -export interface ParentInitialMessage { - type: "init" - port: MessagePort - firstLevelPath: string -} - -interface OutcomeStyle { - color: "red" | "green" | "yellow" - desc: string -} - -interface WorkerData { - worker: Worker - promise: Promise -} - -const outcomeStyles: Record = { - success: { color: "green", desc: "Success" }, - badInput: { color: "red", desc: "Solution killed player" }, - noInput: { color: "red", desc: "Ran out of input" }, - error: { color: "yellow", desc: "Failed to run level" }, -} - -interface Options { - verbose: boolean - ci: boolean - files: string[] - sync?: string -} - -abstract class VerifyStats { - constructor(public total: number) {} - success = 0 - roundedPercent(num: number): number { - return Math.round((num / this.total) * 1000) / 10 - } - abstract getTextStats(): string - abstract addLevel( - msg: WorkerLevelMessage - ): [meesage: string, verboseOnly: boolean] - - getExitCode(): number { - const failureN = this.total - this.success - const exitCode = failureN === 0 ? 0 : 1 + ((failureN - 1) % 255) - return exitCode - } -} - -function glitchToString(glitch: protobuf.IGlitchInfo): string { - const glitchLocation = glitch.location - ? `(${glitch.location?.x}, ${glitch.location?.y})` - : "" - const glitchName = glitch.glitchKind - ? protobuf.GlitchInfo.KnownGlitches[glitch.glitchKind] - : "" - const glitchSpecifier = - glitch.glitchKind === protobuf.GlitchInfo.KnownGlitches.DESPAWN - ? glitch.specifier === 1 - ? "replace" - : "delete" - : "" - return `${glitchLocation} ${glitchName} ${glitchSpecifier}`.trim() -} - -class VerifyStatsBasic extends VerifyStats { - success = 0 - badInput = 0 - noInput = 0 - error = 0 - getTextStats(): string { - return `${this.total} Total -${pc.green(`${this.success} (${this.roundedPercent(this.success)}%)`)} ✅ -${pc.red(`${this.badInput} (${this.roundedPercent(this.badInput)}%)`)} ❌ -${pc.blue(`${this.noInput} (${this.roundedPercent(this.noInput)}%)`)} 💤 -${pc.yellow(`${this.error} (${this.roundedPercent(this.error)}%)`)} 💥` - } - addLevel(msg: WorkerLevelMessage): [string, boolean] { - const style = outcomeStyles[msg.outcome] - const outcomeMessage = pc[style.color]( - `${msg.levelName} - ${style.desc}${msg.desc ? ` (${msg.desc})` : ""}` - ) - - const glitchMessage = pc.yellow( - msg.glitches - .map(glitch => `\n${msg.levelName} - ${glitchToString(glitch)}`) - .join("") - ) - - this[msg.outcome] += 1 - return [ - outcomeMessage + glitchMessage, - msg.outcome === "success" && msg.glitches.length === 0, - ] - } -} - -function arraysEqual(aArr: T[], bArr: T[]): boolean { - if (aArr.length === 0 || bArr.length === 0) { - return aArr.length === bArr.length - } - return aArr.every((a, i) => a === bArr[i]) -} - -export interface SyncfileConstraints { - outcome: LevelOutcome - glitches?: string[] -} - -export type Syncfile = { - _default: SyncfileConstraints -} & Partial> - -class VerifyStatsSync extends VerifyStats { - success = 0 - mismatch = 0 - constructor(total: number, public sync: Syncfile) { - super(total) - } - getTextStats(): string { - return `${this.total} Total -${pc.green(`${this.success} (${this.roundedPercent(this.success)}%)`)} ✅ -${pc.red(`${this.mismatch} (${this.roundedPercent(this.mismatch)}%)`)} ❌` - } - getConstraintsForLevel(level: string): SyncfileConstraints { - if (level in this.sync) return this.sync[level]! - return this.sync._default - } - addLevel(msg: WorkerLevelMessage): [string, boolean] { - const mismatchReasons: string[] = [] - const constraints = this.getConstraintsForLevel(msg.levelName) - - if (constraints.outcome !== msg.outcome) { - mismatchReasons.push( - `Expected ${constraints.outcome}, got ${msg.outcome}.` - ) - } - - const glitchStrings = msg.glitches.map(glitch => glitchToString(glitch)) - - const despawnsMatch = arraysEqual( - glitchStrings ?? [], - constraints.glitches ?? [] - ) - if (!despawnsMatch) { - mismatchReasons.push( - `Expected [${(constraints.glitches ?? []).join(", ")}], got [${( - glitchStrings ?? [] - ).join(", ")}]` - ) - } - - const mismatch = mismatchReasons.length > 0 - - const style = outcomeStyles[mismatch ? "badInput" : "success"] - if (!mismatch) { - this.success += 1 - return [pc[style.color](`${msg.levelName} - Success`), true] - } - - this.mismatch += 1 - return [ - pc[style.color]( - mismatchReasons.map(reason => `${msg.levelName} - ${reason}`).join("\n") - ), - false, - ] - } -} - -interface VerifyOutputs { - logMessage(message: string): void - levelComplete(msg: WorkerLevelMessage): void -} - -function makeBarOutput(stats: VerifyStats, verbose: boolean): VerifyOutputs { - const bar = new ProgressBar( - ":bar :current/:total (:percent) :rate lvl/s", - stats.total - ) - - return { - levelComplete(msg) { - const [message, verboseOnly] = stats.addLevel(msg) - if (!verboseOnly || verbose) { - bar.interrupt(message) - } - bar.tick() - }, - logMessage(message) { - bar.interrupt(message) - }, - } -} - -/** - * The test output will log numbers every TEST_OUTPUT_INTERVAL level completions. - */ -const CI_OUTPUT_INTERVAL = 100 - -function makeCiOutput(stats: VerifyStats, verbose: boolean): VerifyOutputs { - let totalComplete = 0 - return { - levelComplete(msg: WorkerLevelMessage) { - totalComplete += 1 - const [message] = stats.addLevel(msg) - if (verbose) { - console.log(message) - } - if (totalComplete % CI_OUTPUT_INTERVAL === 0) { - console.log(stats.getTextStats()) - } - }, - logMessage(message) { - console.log(message) - }, - } -} - -export async function verifyLevelFiles( - args: ArgumentsCamelCase -): Promise { - const files = resolveLevelPath(...args.files).filter(val => - val.toLowerCase().endsWith(".c2m") - ) - const filesN = files.length - - let syncfile: Syncfile | undefined - - if (args.sync) { - const syncData = await readFile(args.sync, "utf-8") - syncfile = decode(syncData) as Syncfile - } - - const stats = syncfile - ? new VerifyStatsSync(filesN, syncfile) - : new VerifyStatsBasic(filesN) - const output = args.ci - ? makeCiOutput(stats, args.verbose) - : makeBarOutput(stats, args.verbose) - - const workers: WorkerData[] = [] - - const workersN = Math.min(filesN, os.cpus().length) - - console.log(`Creating ${workersN} workers...`) - for (let i = 0; i < workersN; i++) { - const { port1, port2 } = new MessageChannel() - const worker = new Worker(join(__dirname, "./verifyLevelsThread.js")) - // eslint-disable-next-line @typescript-eslint/no-empty-function - let endWait: () => void = () => {} - const promise = new Promise(res => { - endWait = res - }) - - workers.push({ worker, promise }) - worker.postMessage( - { - type: "init", - firstLevelPath: files.shift() as string, - port: port1, - } satisfies ParentInitialMessage, - [port1] - ) - port2.on("message", (msg: WorkerMessage) => { - output.levelComplete(msg) - - const nextLevel = files.shift() - if (nextLevel === undefined) { - worker.postMessage({ type: "end" } satisfies ParentEndMessage) - endWait() - } else { - worker.postMessage({ - type: "new level", - levelPath: nextLevel, - } satisfies ParentNewLevelMessage) - } - }) - } - - const stillWaiting = Promise.all(workers.map(val => val.promise)) - - await stillWaiting - - console.log(stats.getTextStats()) - - exit(stats.getExitCode()) -} - -export default (yargs: Argv) => - yargs.command( - "verify ", - "Verifies that a level's solution works", - yargs => - yargs - .positional("files", { - describe: "The files to be tested", - type: "string", - coerce: files => (files instanceof Array ? files : [files]), - }) - .demandOption("files") - .option("verbose", { - describe: - "If set, prints successful levels too, instead of just the failed ones.", - }) - .option("ci", { - describe: - "Whenever to log intermediate stats in a non-TTY compatible manner", - type: "boolean", - }) - .option("sync", { - description: "Path to syncfiles to use", - type: "string", - }) - .usage("notcc verify "), - verifyLevelFiles - ) diff --git a/cli/src/commands/verifyLevelsThread.ts b/cli/src/commands/verifyLevelsThread.ts deleted file mode 100644 index 52f75c7c..00000000 --- a/cli/src/commands/verifyLevelsThread.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - createLevelFromData, - GameState, - parseC2M, - protobuf, - SolutionInfoInputProvider, -} from "@notcc/logic" -import { parentPort } from "worker_threads" -import fs from "fs" -import type { - WorkerMessage, - ParentInitialMessage, - ParentNewLevelMessage, - ParentEndMessage, -} from "./verifyLevels.js" - -if (!parentPort) throw new Error() - -let waitForMessageResolver: (() => void) | undefined - -const queuedMesssages: any[] = [] - -parentPort.on("message", val => { - queuedMesssages.push(val) - if (waitForMessageResolver !== undefined) { - waitForMessageResolver() - } -}) - -function waitForMessage(): Promise { - if (queuedMesssages.length === 0) { - return new Promise(res => { - waitForMessageResolver = () => { - res(queuedMesssages.shift()) - } - }) - } - return Promise.resolve(queuedMesssages.shift()) -} - -;(async () => { - const response = (await waitForMessage()) as ParentInitialMessage - const sendMessage = (message: WorkerMessage) => - response.port.postMessage(message) - - let levelPath: string | null = response.firstLevelPath - - // eslint-disable-next-line no-constant-condition - while (true) { - let levelName: string | undefined - let glitches: protobuf.IGlitchInfo[] = [] - try { - const levelBuffer = fs.readFileSync(levelPath, null) - - const levelData = parseC2M(new Uint8Array(levelBuffer).buffer) - levelName = levelData.name || "???" - const level = createLevelFromData(levelData) - glitches = level.glitches - - if (!levelData?.associatedSolution || !levelData.associatedSolution.steps) - throw new Error("Level has no baked solution!") - - level.inputProvider = new SolutionInfoInputProvider( - levelData.associatedSolution - ) - - while ( - level.gameState === GameState.PLAYING && - !level.inputProvider.outOfInput(level) - ) { - level.tick() - } - sendMessage({ - type: "level", - levelName, - outcome: - level.gameState === GameState.WON - ? "success" - : level.gameState !== GameState.PLAYING - ? "badInput" - : "noInput", - glitches, - }) - } catch (err) { - sendMessage({ - type: "level", - levelName: levelName || levelPath, - outcome: "error", - desc: (err as Error).message, - glitches, - }) - } - const response = (await waitForMessage()) as - | ParentNewLevelMessage - | ParentEndMessage - - if (response.type === "end") { - process.exit(0) - } else { - levelPath = response.levelPath - } - } -})() diff --git a/cli/src/helpers.ts b/cli/src/helpers.ts deleted file mode 100644 index 1f751980..00000000 --- a/cli/src/helpers.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fs from "fs" -import path from "path" -import { exit } from "process" - -export function getFilesRecursive(dir: string): string[] { - const files: string[] = [] - fs.readdirSync(dir).forEach(file => { - const absolute = path.join(dir, file) - if (fs.statSync(absolute).isDirectory()) - files.push(...getFilesRecursive(absolute)) - else files.push(absolute) - }) - return files -} - -export function resolveLevelPath(...levelPaths: string[]): string[] { - const values: string[] = [] - for (const levelPath of levelPaths) { - const inputPath = path.resolve(process.cwd(), levelPath) - if (fs.statSync(inputPath).isDirectory()) - values.push( - ...getFilesRecursive(inputPath).map(levelPath => - path.resolve(inputPath, levelPath) - ) - ) - else values.push(inputPath) - } - return values -} - -export function errorAndExit(errorName = "", errorCode = 1): void { - if (errorName) console.error(errorName) - exit(errorCode) -} diff --git a/cli/src/index.ts b/cli/src/index.ts deleted file mode 100644 index 240bd185..00000000 --- a/cli/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env node -import yargsF from "yargs/yargs" -import { hideBin } from "yargs/helpers" -import commands from "./commands/index.js" - -let yargs = yargsF(hideBin(process.argv)) - -for (const func of commands) yargs = func(yargs) - -yargs.strictCommands().demandCommand().completion().recommendCommands().parse() diff --git a/cli/tsconfig.json b/cli/tsconfig.json deleted file mode 100644 index 52b47ece..00000000 --- a/cli/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "esModuleInterop": true, - "target": "ES2016", - "module": "ESNext", - "moduleResolution": "NodeNext", - "resolveJsonModule": true, - "sourceMap": true, - "strict": true, - "outDir": "dist", - "lib": [] - } -} diff --git a/desktopPlayer/package.json b/desktopPlayer/package.json deleted file mode 100644 index ed382e91..00000000 --- a/desktopPlayer/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@notcc/desktop-player", - "version": "1.0.0", - "description": "The desktop runner of the web game player.", - "scripts": { - "build": "zx makeDists.mjs", - "build-zip": "pnpm build && rm -f NotCC.zip && cd artifacts && zip -r ../NotCC.zip .", - "dev": "neu run", - "prepublish": "neu update" - }, - "dependencies": { - "@neutralinojs/neu": "^9.8.0", - "zx": "^7.2.3" - } -} diff --git a/desktopPlayer/webDist b/desktopPlayer/webDist deleted file mode 120000 index b2924b5a..00000000 --- a/desktopPlayer/webDist +++ /dev/null @@ -1 +0,0 @@ -../gamePlayer/dist \ No newline at end of file diff --git a/desktopPlayer/NotCC.app/Contents/Info.plist b/gamePlayer/NotCC.app/Contents/Info.plist similarity index 93% rename from desktopPlayer/NotCC.app/Contents/Info.plist rename to gamePlayer/NotCC.app/Contents/Info.plist index 3a6d43a8..47621c58 100644 --- a/desktopPlayer/NotCC.app/Contents/Info.plist +++ b/gamePlayer/NotCC.app/Contents/Info.plist @@ -13,11 +13,11 @@ CFBundleIdentifier club.glander.notcc-player CFBundleVersion - 1.0 + 0.0.200 CFBundleGetInfoString NotCC by G lander & co. CFBundleShortVersionString - 1.0 + 2.0 CFBundleIconFile icon.icns diff --git a/desktopPlayer/NotCC.app/Contents/Resources/icon.icns b/gamePlayer/NotCC.app/Contents/Resources/icon.icns similarity index 100% rename from desktopPlayer/NotCC.app/Contents/Resources/icon.icns rename to gamePlayer/NotCC.app/Contents/Resources/icon.icns diff --git a/gamePlayer/index.html b/gamePlayer/index.html index 69da5e5f..200891ab 100644 --- a/gamePlayer/index.html +++ b/gamePlayer/index.html @@ -3,8 +3,7 @@ - - NotCC + - + + + + NotCC - - - - - -
-
Level list
-
- - - - - - - - - - - -
#LevelBest timeBest score
-
-
- -
-
-
- -
-
Score report generator
-
Fetching scoreboard data...
-
- Something went wrong: -
-
- -
-
scores:
-
-
(NotCC git-%VITE_VERSION%)
-
-
-
- - -
-
-
- -
-
Alert!
-
Alert body
-
-
-
- - - -
-
All attempts
-
-
- - -
-
-
- -
-
Theme select
-
- - - - - -
-
- - -
-
-
- - -
-
Tileset select
-
- - - - - - - - - - - - - - - -
SelectedPreviewInfo
- -
-
-
- - -
-
-
- -
-
Settings
-
-

Visuals

- - - - - - - CGA17000 - - -

Glitches

- - -

- There are a couple of glitches which are not allowed in competitive - play, which are called non-legal glitches. If this option is - enabled, an attempt to use a non-legal glitch will fail the level. -

- - -

- Simultaneous character movement is a non-legal glitch where two - characters can be moved at the same by pressing the character switch - and a direction key at the same time. If this option is enabled, - pressing the character switch key will let go of all other input - keys, preventing the simultaneous character movement glitch. -

-

Score

- - -

- The - https://scores.bitbusters.club - user ID. Required for score report generation.
How to obtain: - Look for the number in your player page URL. For example, If your - user page is at https://scores.bitbusters.club/players/75, your - optimizer ID is 75. -

-

ExaCC

- - -

- The integer part of the time left can be rounded in different ways, - depending on the wanted interpretation. Avaliable rounding modes: -

-
    -
  • - floor — Equivalent to the amount of time until the game is lost - via time out. Represents how the game internally tracks time left, - but the shown value must be ceiled in one's head to know the - in-game time. -
  • -
  • - floor + 1 — Resembles how time works in Lynx and mostly matches - in-game time, but is inaccurate when the decimal is .00. -
  • -
  • - ceil — Always matches in-game time, but no longer always - decreases. Displayed time will jump from x.05 to (x-1).00 to - (x-1).95. -
  • -
-
-
- - -
-
-
- -
-
About NotCC
-
-

NotCC

-

- NotCC is an - open-source - accurate CC2 emulator.

- Made by - G lander. -

-

Thanks to:

-
    -
  • - The Chip's Challenge community, residing at - The Bit Busters Club. Also, more specifically: -
  • -
  • - eevee, for creating - the first CC2 emulator, - Lexy's Labyrinth, which NotCC heavily borrowed (and still borrows) from. -
  • -
  • - Markus O., - Bacorn, - and - Sickly, for creating and maintaining SuperCC, the optimization tool - ExaCC is heavily inspired by. -
  • -
  • - Zrax, for - creating a very helpful suite of CC tools, appropriately called - CCTools. -
  • -
  • - Anders Kaseorg and Kawaiiprincess, for creating and porting to CC2 - (respectively) the bundled Tile World tileset. -
  • -
  • - Sharpeye, for finding a bug with ExaCC auto-scaling and being one of the - first people to use ExaCC for optimization. -
  • -
  • - Tyler Sontag, for creating the very, very helpful resident Discord - bot, - Gliderbot. -
  • -
  • - IHNN, for providing details and feedback on non-legal glitches and - their prevention. -
  • -
-

- Last change: %VITE_LAST_COMMIT_INFO%.
- Built at %VITE_BUILD_TIME%. -

-
-
- -
-
-
- -
-
Non-free set
-
-

- The set you are trying to load is non-free, and cannot be legally - accessed from the Gliderbot set repository. If you don't have it, - you can - butt it on Steam - here. -

-

- If you have a copy of the set, load it into NotCC with the Load - directory option below. -

-
-
- - -
-
-
-
-

Loading, standy...

-
-
-
- -
-
-

You won!!

-
- - - -
-
-
-

You died...

-

You ran out of time...

-
- -
-
-
-

Paused

-
-

Did you know?

- You can submit fun facts for this box by contacting ʐ̈ MANNNNNNN - via the - Chip's Challenge Bit Busters Club Discord Server. -
-
- -
-
-
-

LEVEL NAME

-
-
-

Stop! You've violated the law!

-
- The following glitch has occured: - -
-
- -
-
-
-

Congratulations!

-
- You have finished this set. You can try getting better scores, or - go for another set! -
-
- - -
-
-
-
-
-
Chips:
- 12 -
Time left:
- 120s -
Bonus points:
- 4200 -
- -
-
-
-
-
- -
- Importing route... -
-
-
-
-
-
Chips:
- 12 -
Time left:
- 120s -
Bonus points:
- 4200 -
Total score:
- 0 -
- -
-
-
- -
-
-
-
-
- -

NotCC

-

A scoreboard-legal Chip's Challenge 2® emulator.

-
-
-

Load external files:

- - -
- -
    -
    - - + +
    + diff --git a/desktopPlayer/makeDists.mjs b/gamePlayer/makeDists.mjs similarity index 88% rename from desktopPlayer/makeDists.mjs rename to gamePlayer/makeDists.mjs index bf3817d8..3ee77cf8 100755 --- a/desktopPlayer/makeDists.mjs +++ b/gamePlayer/makeDists.mjs @@ -5,9 +5,8 @@ import "zx/globals" const distributionName = "notcc-player" -const binariesDir = path.join("dist", distributionName) +const binariesDir = path.join("neuDist", distributionName) const resourcesNeuPath = path.join(binariesDir, "resources.neu") -const macBinPath = path.join("NotCC.app", "Contents", "MacOS") $.verbose = false @@ -21,15 +20,6 @@ if (!(await fs.exists(binariesDir))) { process.exit(2) } -console.log("Making Mac package") - -await fs.copy( - path.join(binariesDir, `${distributionName}-mac_universal`), - path.join(macBinPath, distributionName) -) -await fs.chmod(path.join(macBinPath, distributionName), 0o755) -await fs.copy(resourcesNeuPath, path.join(macBinPath, "resources.neu")) - console.log("Making artifacts directory") await fs.remove("artifacts") @@ -48,3 +38,14 @@ for (const [src, dest] of toCopyBinaries) { await fs.chmod(path.join("artifacts", `${distributionName}-linux`), 0o755) await fs.copy("NotCC.app", path.join("artifacts", "NotCC.app")) + +console.log("Making Mac package") +const macBinPath = path.join("artifacts", "NotCC.app", "Contents", "MacOS") +await fs.mkdirp(macBinPath) + +await fs.copy( + path.join(binariesDir, `${distributionName}-mac_universal`), + path.join(macBinPath, distributionName) +) +await fs.chmod(path.join(macBinPath, distributionName), 0o755) +await fs.copy(resourcesNeuPath, path.join(macBinPath, "resources.neu")) diff --git a/gamePlayer/makeWebDownloads.mjs b/gamePlayer/makeWebDownloads.mjs new file mode 100755 index 00000000..a837544a --- /dev/null +++ b/gamePlayer/makeWebDownloads.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env zx +import "zx/globals" + +const version = JSON.parse(await fs.readFile("./package.json", "utf-8")).version +await fs.copyFile("./NotCC.zip", `./dist/notcc-desktop-v${version}.zip`) +await fs.copyFile( + "./neuDist/notcc-player/resources.neu", + `./dist/desktop-resources.neu` +) +const desktopUpdateData = { + version: parseInt(process.env.VITE_DESKTOP_VERSION), + versionName: version, + resourcesUrl: `https://glander.club/notcc/prewrite/desktop-resources.neu`, +} +await fs.writeJSON("./dist/desktop-update.json", desktopUpdateData) diff --git a/desktopPlayer/neutralino.config.json b/gamePlayer/neutralino.config.json similarity index 65% rename from desktopPlayer/neutralino.config.json rename to gamePlayer/neutralino.config.json index b5d51d93..90d6630a 100644 --- a/desktopPlayer/neutralino.config.json +++ b/gamePlayer/neutralino.config.json @@ -1,8 +1,8 @@ { "applicationId": "notcc-player.glander.club", - "version": "1.0.0", + "version": "2.0.0", "defaultMode": "window", - "documentRoot": "/webDist/", + "documentRoot": "/dist/", "url": "/", "enableServer": true, "enableNativeAPI": true, @@ -12,13 +12,15 @@ "os.showSaveDialog", "os.showFolderDialog", "filesystem.*", - "computer.getKernelInfo", - "debug.log" + "os.execCommand", + "debug.log", + "app.killProcess", + "window.center" ], "modes": { "window": { "title": "NotCC", - "icon": "/webDist/iconDesktop.png", + "icon": "/dist/iconDesktop.png", "enableInspector": false, "resizable": true, "exitProcessOnClose": true @@ -26,8 +28,9 @@ }, "cli": { "binaryName": "notcc-player", - "resourcesPath": "/webDist/", - "binaryVersion": "4.10.0", + "distributionPath": "/neuDist", + "resourcesPath": "/dist/", + "binaryVersion": "5.3.0", "clientVersion": "3.8.2" } } diff --git a/gamePlayer/package.json b/gamePlayer/package.json index e3b396ea..60592256 100644 --- a/gamePlayer/package.json +++ b/gamePlayer/package.json @@ -1,42 +1,61 @@ { "name": "@notcc/player", "private": true, - "version": "0.2.0", + "version": "2.0.0", "scripts": { - "dev": "vite --force", - "build": "tsc && vite build --force", - "build-desktop": "VITE_BUILD_TYPE=desktop pnpm build", - "preview": "vite preview" + "dev": "vite", + "build:ssg": "SSG=true vite build -c vite.config.js --ssr src/main-ssg.tsx --outDir dist/ssg", + "build": "rm -rf dist && tsc && pnpm build:ssg && vite build && rm -r dist/ssg && pnpm make-sw", + "build:neu": "export VITE_DESKTOP_VERSION;: \"${VITE_DESKTOP_VERSION:=$(date +%s)}\"; VITE_BUILD_PLATFORM=desktop pnpm build && zx makeDists.mjs", + "build:neu-zip": "pnpm build:neu && rm -f NotCC.zip && cd artifacts && zip -r ../NotCC.zip .", + "build:full": "export VITE_DESKTOP_VERSION=$(date +%s); pnpm build:neu-zip && rm -r dist && pnpm build && zx makeWebDownloads.mjs", + "dev:neu": "neu run", + "prepublish": "neu update", + "make-sw": "sed \"s\\\\__SW_FILES\\\\[$(cd dist; find . | grep -E '.*(js|css|png|svg|html|webmanifest|svg|wasm|ogg)' | sort | uniq | sed 's/.*/\"\\0\",/' | tr '\\n' ' ')]\\\\g\" < ./src/sw.js > ./dist/sw.js" }, "repository": { "type": "git", "url": "git+https://github.com/TheGLander/NotCC.git" }, "author": "G lander", - "license": "MIT", + "license": "GPL-3.0-or-later", "bugs": { "url": "https://github.com/TheGLander/NotCC/issues" }, "homepage": "https://github.com/TheGLander/NotCC#readme", + "type": "module", "devDependencies": { - "@neutralinojs/lib": "^3.12.0", + "@neutralinojs/lib": "^5.6.0", + "@neutralinojs/neu": "^11.5.0", "@notcc/logic": "workspace:^", - "@types/clone": "^2.1.3", - "@types/is-hotkey": "^0.1.8", - "@types/path-browserify": "^1.0.1", - "base64-js": "^1.5.1", - "dialog-polyfill": "^0.5.6", - "fast-printf": "^1.6.9", + "@preact/preset-vite": "^2.10.2", + "@types/is-hotkey": "^0.1.10", + "@types/node": "^15.14.9", + "@types/path-browserify": "^1.0.3", + "autoprefixer": "^10.4.21", + "fast-printf": "^1.6.10", "fflate": "^0.7.4", + "idb-keyval": "^6.2.2", "is-hotkey": "^0.2.0", - "less": "^4.2.0", + "jotai": "^2.13.1", + "jotai-effect": "^1.1.6", "lz-string": "^1.5.0", "path-browserify": "^1.0.1", - "typescript": "^4.9.5", - "vite": "^4.5.0" + "postcss": "^8.5.6", + "preact": "^10.27.0", + "preact-render-to-string": "^6.5.13", + "react": "npm:@preact/compat@^18.3.1", + "react-dom": "npm:@preact/compat@^18.3.1", + "react-draggable": "^4.5.0", + "react-responsive": "^9.0.2", + "suspend-react": "^0.1.3", + "tailwind-merge": "^2.6.0", + "tailwindcss": "^3.4.17", + "typescript": "^5.9.2", + "vite": "^4.5.14" }, - "type": "module", "dependencies": { - "clone": "^2.1.2" + "@dagrejs/dagre": "^1.1.5", + "react-error-boundary": "^4.1.2" } } diff --git a/gamePlayer/postcss.config.js b/gamePlayer/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/gamePlayer/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/gamePlayer/public/defoSfx/_gong.wav b/gamePlayer/public/defoSfx/_gong.wav deleted file mode 100644 index 44e3d006..00000000 Binary files a/gamePlayer/public/defoSfx/_gong.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/block push.wav b/gamePlayer/public/defoSfx/block push.wav deleted file mode 100644 index 9fe4f793..00000000 Binary files a/gamePlayer/public/defoSfx/block push.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/bump.wav b/gamePlayer/public/defoSfx/bump.wav deleted file mode 100644 index 5d8a5df5..00000000 Binary files a/gamePlayer/public/defoSfx/bump.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/button press.wav b/gamePlayer/public/defoSfx/button press.wav deleted file mode 100644 index 89ed085a..00000000 Binary files a/gamePlayer/public/defoSfx/button press.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/dirt clear.wav b/gamePlayer/public/defoSfx/dirt clear.wav deleted file mode 100644 index 2548d3c1..00000000 Binary files a/gamePlayer/public/defoSfx/dirt clear.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/door unlock.wav b/gamePlayer/public/defoSfx/door unlock.wav deleted file mode 100644 index f9ea41dd..00000000 Binary files a/gamePlayer/public/defoSfx/door unlock.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/explosion.wav b/gamePlayer/public/defoSfx/explosion.wav deleted file mode 100644 index 576fc292..00000000 Binary files a/gamePlayer/public/defoSfx/explosion.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/fire step.wav b/gamePlayer/public/defoSfx/fire step.wav deleted file mode 100644 index 5c2c77a0..00000000 Binary files a/gamePlayer/public/defoSfx/fire step.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/force floor.wav b/gamePlayer/public/defoSfx/force floor.wav deleted file mode 100644 index 274e8c61..00000000 Binary files a/gamePlayer/public/defoSfx/force floor.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/ice slide.wav b/gamePlayer/public/defoSfx/ice slide.wav deleted file mode 100644 index 78bc56c8..00000000 Binary files a/gamePlayer/public/defoSfx/ice slide.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/item get.wav b/gamePlayer/public/defoSfx/item get.wav deleted file mode 100644 index 89ed085a..00000000 Binary files a/gamePlayer/public/defoSfx/item get.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/recessed wall.wav b/gamePlayer/public/defoSfx/recessed wall.wav deleted file mode 100644 index 61af49aa..00000000 Binary files a/gamePlayer/public/defoSfx/recessed wall.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/robbed.wav b/gamePlayer/public/defoSfx/robbed.wav deleted file mode 100644 index 10af352e..00000000 Binary files a/gamePlayer/public/defoSfx/robbed.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/slide step.wav b/gamePlayer/public/defoSfx/slide step.wav deleted file mode 100644 index 223562b0..00000000 Binary files a/gamePlayer/public/defoSfx/slide step.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/socket unlock.wav b/gamePlayer/public/defoSfx/socket unlock.wav deleted file mode 100644 index 05468a71..00000000 Binary files a/gamePlayer/public/defoSfx/socket unlock.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/splash.wav b/gamePlayer/public/defoSfx/splash.wav deleted file mode 100644 index 6961db52..00000000 Binary files a/gamePlayer/public/defoSfx/splash.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/teleport.wav b/gamePlayer/public/defoSfx/teleport.wav deleted file mode 100644 index 2991cb7a..00000000 Binary files a/gamePlayer/public/defoSfx/teleport.wav and /dev/null differ diff --git a/gamePlayer/public/defoSfx/water step.wav b/gamePlayer/public/defoSfx/water step.wav deleted file mode 100644 index 1d93a584..00000000 Binary files a/gamePlayer/public/defoSfx/water step.wav and /dev/null differ diff --git a/gamePlayer/public/iconBigAlt.png b/gamePlayer/public/iconBigAlt.png new file mode 100644 index 00000000..46d56a5f Binary files /dev/null and b/gamePlayer/public/iconBigAlt.png differ diff --git a/gamePlayer/public/iconHuge.png b/gamePlayer/public/iconHuge.png new file mode 100644 index 00000000..b279dc53 Binary files /dev/null and b/gamePlayer/public/iconHuge.png differ diff --git a/gamePlayer/public/iconMaskable.png b/gamePlayer/public/iconMaskable.png new file mode 100644 index 00000000..69608988 Binary files /dev/null and b/gamePlayer/public/iconMaskable.png differ diff --git a/gamePlayer/public/manifest.webmanifest b/gamePlayer/public/manifest.webmanifest new file mode 100644 index 00000000..d7a0a4fd --- /dev/null +++ b/gamePlayer/public/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "NotCC", + "short_name": "NotCC", + "start_url": "./", + "display": "standalone", + "background_color": "#2a5ccd", + "theme_color": "#1e3a8a", + "lang": "en", + "scope": "./", + "icons": [ + { "src": "./iconBig.png", "sizes": "144x144", "type": "image/png" }, + { + "src": "./iconMaskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { "src": "./iconHuge.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/gamePlayer/public/defoSfx/sounds.md b/gamePlayer/public/sfx/defo/README.md similarity index 88% rename from gamePlayer/public/defoSfx/sounds.md rename to gamePlayer/public/sfx/defo/README.md index 88cf9f15..fbfad9a3 100644 --- a/gamePlayer/public/defoSfx/sounds.md +++ b/gamePlayer/public/sfx/defo/README.md @@ -1,8 +1,8 @@ # DefoSFX -## SFX lists +The original NotCC v1 sound effects. Made with sfxr. -### Common sounds +## Base sounds | CC2 name | Usage | Link | | --------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -28,43 +28,8 @@ | teleport-male | Chip win | | | teleport-female | Melinda win | | -### Unused CC2 sounds - -| CC2 name | Notes | -| -------- | ------------------------------------------------------- | -| slide1 | | -| BLOOP | | -| BEEP | Exit sound without player voice; same as teleport sound | -| exit | - -### Additional sounds (not in CC2) +## Additional sounds (not in CC2) | Name | Usage | Link | | ---- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Gong | Timeout | [Link](https://jfxr.frozenfractal.com/#%7B%22_version%22%3A1%2C%22_name%22%3A%22Gong%22%2C%22_locked%22%3A%5B%5D%2C%22sampleRate%22%3A44100%2C%22attack%22%3A0%2C%22sustain%22%3A0%2C%22sustainPunch%22%3A0%2C%22decay%22%3A3.64%2C%22tremoloDepth%22%3A0%2C%22tremoloFrequency%22%3A10%2C%22frequency%22%3A7900%2C%22frequencySweep%22%3A-5600%2C%22frequencyDeltaSweep%22%3A-1000%2C%22repeatFrequency%22%3A99.80000000000001%2C%22frequencyJump1Onset%22%3A15%2C%22frequencyJump1Amount%22%3A-80%2C%22frequencyJump2Onset%22%3A65%2C%22frequencyJump2Amount%22%3A90%2C%22harmonics%22%3A0%2C%22harmonicsFalloff%22%3A0.5%2C%22waveform%22%3A%22breaker%22%2C%22interpolateNoise%22%3Atrue%2C%22vibratoDepth%22%3A690%2C%22vibratoFrequency%22%3A557%2C%22squareDuty%22%3A50%2C%22squareDutySweep%22%3A0%2C%22flangerOffset%22%3A42%2C%22flangerOffsetSweep%22%3A0%2C%22bitCrush%22%3A16%2C%22bitCrushSweep%22%3A0%2C%22lowPassCutoff%22%3A200%2C%22lowPassCutoffSweep%22%3A0%2C%22highPassCutoff%22%3A0%2C%22highPassCutoffSweep%22%3A9500%2C%22compression%22%3A1%2C%22normalization%22%3Atrue%2C%22amplification%22%3A100%7D) | - -## SFX usage - -| CC2 name | Internal name | When it happens | -| --------------- | --------------- | --------------------------------------------------------------------------------------------------------------------- | -| newwall | "recessed wall" | Exiting recessed wall | -| burn | "explosion" | Explosion animation spawn | -| splash | "splash" | Water animation spawn | -| teleport | "teleport" | TP completelyJoined | -| thief | "robbed" | Thief completelyJoined | -| dirt | "dirt clear" | Dirt completelyJoined | -| button | "button press" | Button completelyJoined | -| push | "block push" | Block pushed | -| force | "force floor" | Repeating. Played when on FF | -| wall | "player bump" | Visual bumped state false -> true | -| water | "water step" | Water completelyJoined with boots | -| ice | "slide step" | Ice, FF completelyJoined with boots. Note: Holding a bonking direction on FF results in the SFX constantly restarting | -| slide | "ice slide" | Repeating. Played when _leaving_ ice. What?? | -| fire | "fire step" | Fire completelyJoined with boots | -| get | "item get" | (Player) item completelyJoined | -| socket | "socket unlock" | Socket completelyJoined | -| door | "door unlock" | Door completelyJoined | -| teleport-male | "chip win" | Player win | -| teleport-female | "melinda win" | Player win | -| BummerM | "chip death" | Player death. Note: plays after burn/splash. Delay seemingly hardcoded. | -| BummerF | "melinda death" | Player death. Note: plays after burn/splash. Delay seemingly hardcoded. | diff --git a/gamePlayer/public/sfx/defo/_gong.ogg b/gamePlayer/public/sfx/defo/_gong.ogg new file mode 100644 index 00000000..2d4b26b0 Binary files /dev/null and b/gamePlayer/public/sfx/defo/_gong.ogg differ diff --git a/gamePlayer/public/sfx/defo/burn.ogg b/gamePlayer/public/sfx/defo/burn.ogg new file mode 100644 index 00000000..263509c9 Binary files /dev/null and b/gamePlayer/public/sfx/defo/burn.ogg differ diff --git a/gamePlayer/public/sfx/defo/button.ogg b/gamePlayer/public/sfx/defo/button.ogg new file mode 100644 index 00000000..bf04062a Binary files /dev/null and b/gamePlayer/public/sfx/defo/button.ogg differ diff --git a/gamePlayer/public/sfx/defo/dirt.ogg b/gamePlayer/public/sfx/defo/dirt.ogg new file mode 100644 index 00000000..8c454147 Binary files /dev/null and b/gamePlayer/public/sfx/defo/dirt.ogg differ diff --git a/gamePlayer/public/sfx/defo/door.ogg b/gamePlayer/public/sfx/defo/door.ogg new file mode 100644 index 00000000..bfcd22e7 Binary files /dev/null and b/gamePlayer/public/sfx/defo/door.ogg differ diff --git a/gamePlayer/public/sfx/defo/fire.ogg b/gamePlayer/public/sfx/defo/fire.ogg new file mode 100644 index 00000000..98e0baa3 Binary files /dev/null and b/gamePlayer/public/sfx/defo/fire.ogg differ diff --git a/gamePlayer/public/sfx/defo/force.ogg b/gamePlayer/public/sfx/defo/force.ogg new file mode 100644 index 00000000..652bb3eb Binary files /dev/null and b/gamePlayer/public/sfx/defo/force.ogg differ diff --git a/gamePlayer/public/sfx/defo/get.ogg b/gamePlayer/public/sfx/defo/get.ogg new file mode 100644 index 00000000..bf04062a Binary files /dev/null and b/gamePlayer/public/sfx/defo/get.ogg differ diff --git a/gamePlayer/public/sfx/defo/ice.ogg b/gamePlayer/public/sfx/defo/ice.ogg new file mode 100644 index 00000000..9336cd14 Binary files /dev/null and b/gamePlayer/public/sfx/defo/ice.ogg differ diff --git a/gamePlayer/public/sfx/defo/newwall.ogg b/gamePlayer/public/sfx/defo/newwall.ogg new file mode 100644 index 00000000..edf84725 Binary files /dev/null and b/gamePlayer/public/sfx/defo/newwall.ogg differ diff --git a/gamePlayer/public/sfx/defo/push.ogg b/gamePlayer/public/sfx/defo/push.ogg new file mode 100644 index 00000000..05ca0acb Binary files /dev/null and b/gamePlayer/public/sfx/defo/push.ogg differ diff --git a/gamePlayer/public/sfx/defo/slide.ogg b/gamePlayer/public/sfx/defo/slide.ogg new file mode 100644 index 00000000..689edc34 Binary files /dev/null and b/gamePlayer/public/sfx/defo/slide.ogg differ diff --git a/gamePlayer/public/sfx/defo/socket.ogg b/gamePlayer/public/sfx/defo/socket.ogg new file mode 100644 index 00000000..9070eb5e Binary files /dev/null and b/gamePlayer/public/sfx/defo/socket.ogg differ diff --git a/gamePlayer/public/sfx/defo/splash.ogg b/gamePlayer/public/sfx/defo/splash.ogg new file mode 100644 index 00000000..949157bc Binary files /dev/null and b/gamePlayer/public/sfx/defo/splash.ogg differ diff --git a/gamePlayer/public/sfx/defo/teleport.ogg b/gamePlayer/public/sfx/defo/teleport.ogg new file mode 100644 index 00000000..256de174 Binary files /dev/null and b/gamePlayer/public/sfx/defo/teleport.ogg differ diff --git a/gamePlayer/public/sfx/defo/thief.ogg b/gamePlayer/public/sfx/defo/thief.ogg new file mode 100644 index 00000000..45a9d9d4 Binary files /dev/null and b/gamePlayer/public/sfx/defo/thief.ogg differ diff --git a/gamePlayer/public/sfx/defo/wall.ogg b/gamePlayer/public/sfx/defo/wall.ogg new file mode 100644 index 00000000..3e862c06 Binary files /dev/null and b/gamePlayer/public/sfx/defo/wall.ogg differ diff --git a/gamePlayer/public/sfx/defo/water.ogg b/gamePlayer/public/sfx/defo/water.ogg new file mode 100644 index 00000000..604d9f82 Binary files /dev/null and b/gamePlayer/public/sfx/defo/water.ogg differ diff --git a/gamePlayer/public/sfx/newily/BummerF.ogg b/gamePlayer/public/sfx/newily/BummerF.ogg new file mode 100644 index 00000000..fc8ee91d Binary files /dev/null and b/gamePlayer/public/sfx/newily/BummerF.ogg differ diff --git a/gamePlayer/public/sfx/newily/BummerM.ogg b/gamePlayer/public/sfx/newily/BummerM.ogg new file mode 100644 index 00000000..ec33fb37 Binary files /dev/null and b/gamePlayer/public/sfx/newily/BummerM.ogg differ diff --git a/gamePlayer/public/sfx/newily/README.md b/gamePlayer/public/sfx/newily/README.md new file mode 100644 index 00000000..dbe5a0ed --- /dev/null +++ b/gamePlayer/public/sfx/newily/README.md @@ -0,0 +1,28 @@ +# NewilySFX + +The new NotCC v2/Prewrite sounds! Made with Jummbox. + +| CC2 name | Usage | Link | +| --------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| force (old) | Force floor | [Link](https://jummb.us/#j6N0aFF%20sliden310s1k0l00e00t3Wa7g00j07r1O_U00000000i0o321T7v0pu07f011b600q0R020va1h500600Oa4d000HU70U0000000000h0IaE2c0bm16T1v0puf1f0110200q0D010v3000Oa5ad070A1F0B0Q1845Pe354R0000E4c0bj61362463aT7v0pu33f010m600q050Oaad080HYw004000030000h0I4E0c0T4v0puf0f1a0q050Oa1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00E0c0b800p1BkNYj92hFkGlaBiFkGlaBiFkGlaBiFkGg00000)| +| force | Force floor | [Link](https://jummb.us/#j6N0bforce-floorn100s0k0l00e00t2ma7g00j07r1O_U00i0o3T7v0pu07f0000qwJ0213427a002c0000Oa0d060HUU0U70U007U000h6I2E1c03db4p19IcZ7N6300) | +| newwall | Exiting recessed wall | | +| push | Pushing block | | +| teleport | Teleport | | +| thief | Thief steal | [Link](https://jummb.us/#j6N05stealn110s0k0l00e00t2ma2g03j07r1O_U0000i0o3T7v0pu21f030j61552n800q050Oaad070H_SJ5JJFAAAkAAAh8IcE0c0T2v0pu02f020w6184010qE_011a5000330000Oa02d430w8E2c0a81ib4h4h4gp1m0acEEj54GGh603hF5Ug6K0) | +| wall | Bumped | [Link](https://jummb.us/#j6N04bumpn110s0k0l00e00t2ma2g00j07r1O_U0000i0o3T7v0pu21f030j61552n800q050Oaad070H_SJ5JJFAAAkAAAh8IcE0c0T2v0pu02f021840u6010qE_021a52kc002c0330000Oa02d210w8E3c0a81ic1mb4gp1e0acEAe9F5Ug6K0) | +| water | Water walk | | +| burn | Explosion death | [Link](https://jummb.us/#j6N04Boomn110s0k0l00e00t3da7g00j07r1O_U0000i0o1T1v0pu01f0000q060Oa7d080A1F4B3Q217cPe433R0000E3c061a6287bT2v0pu02f020q82da010q0R032l80n415600100Oa4d000w1E2c01ib1b0gp1d0b4Ke8N2d1Vu0) | +| ice | Ice walking | [Link](https://jummb.us/#j6N0cSlide%20stepn310s0k0l00e00t38a7g00j07r1O_U00000000i0o321T7v0pu07f011b600q0R020va1h500600Oa4d000HU70U0000000000h0IaE2c0bm16T1v0puf1f0110200q0D010v3000Oa5ad070A1F0B0Q1845Pe354R0000E4c0bj61362463aT7v0pu33f010m600q050Oaad080HYw004000030000h0I4E0c0T4v0puf0f1a0q050Oa1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00E0c0b800p1ckNsizQ400000) | +| splash | Water death | | +| dirt | Dirt clear | [Link](https://jummb.us/#j6N0cDirt%20clearn110s0k0l00e00t2ma7g00j07r1O_U0000i0o5T7v0pu26f021842ua00q050Oaad070H_-CSQBKRKRJJJJh0IbE0c0T2v0pu02f0326c0m829c010q040Oad000w1E2c01300b4gp1a0ajubS7BU0) | +| button | Button press | [Link](https://jummb.us/#j6N05pluckn110s0k0l00e00t2ma2g00j07r1O_U0000i0o3T7v0pu21f030j61552n800q050Oaad070H_SJ5JJFAAAkAAAh8IcE0c0T4v0pu04f021a72m9010qx52700Oa024z6636ji8k8k4jSBKSJJAArriiiiii07JCABrzrrrrrrrO0YrksBJAHq99br005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00E1c016b4gp1b0bb54WP74w0) | +| get | Item get | | +| slide | Ice slide | [Link](https://jummb.us/#j6N0bIcy%20sliden110s0k0l00e00t2ma7g00j07r1O_U0000i0o3T9v0mu09f0000q0A010p5000Oad4a0w2h0MMMMMIIIIAAAAttttkkkkhhhh888844441111000044449999ggggssssEEEEMMMME3c0b80805T2v0pu02f0000q0R072e02d02k01b22j02h02a06L10k932c22i616632c22i616632c22i616632c22i616632c22i616632c22i6166000Oa3d040w4E0c0b4gp180apWfEM0) | +| fire | Fire walk | | +| door | Door open | | +| socket | Socket open | | +| BummerM | Chip death | [Link](https://jummb.us/#j6N0hplayer-bubble%202n120s0k0l00e00t2ma5g00j07r1O_U000000i0o3T0v0pu10f0000qg410Oad040w2h0E0c0T4v0pu04f032ea2i9234010qLL021752fa00290c4c5bfa1300Oa1453z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00E2c09ib6T2v0pu02f020w618400qEB011a500030Oa2d530w8E1c0b6b0hp1u0bmomBrbmdahecMazyg5i8Vz4GzLM0) | +| BummerF | Melinda death | ^ | +| teleport-male | Chip win | [Link](https://jummb.us/#j6N0bNotWin!%201n100s1k2l00e00t2Ga7g00j07r1O_U00i0o3T0v0Du00f020w40r6010qxR042hd2od0te2k0003630Oa2f3d940w8h0E0c0b4p1pIczN6Mxm6bGj5wC1BB-LuFUm0) | +| teleport-female | Melinda win | [Link](https://jummb.us/#j6N0bNotWin!%202n100s1k3l00e00t2ma7g00j07r2O_U00i0o3T0v0Du00f020w40r6010qxR042hd2od0te2k0003630Oa2f3d940w8h0E0c0b4p1lIcnjAAm6go1mmlWJrDzo0) | diff --git a/gamePlayer/public/sfx/newily/bump.ogg b/gamePlayer/public/sfx/newily/bump.ogg new file mode 100644 index 00000000..f555b6d2 Binary files /dev/null and b/gamePlayer/public/sfx/newily/bump.ogg differ diff --git a/gamePlayer/public/sfx/newily/burn.ogg b/gamePlayer/public/sfx/newily/burn.ogg new file mode 100644 index 00000000..350cc8a5 Binary files /dev/null and b/gamePlayer/public/sfx/newily/burn.ogg differ diff --git a/gamePlayer/public/sfx/newily/button.ogg b/gamePlayer/public/sfx/newily/button.ogg new file mode 100644 index 00000000..b96bac96 Binary files /dev/null and b/gamePlayer/public/sfx/newily/button.ogg differ diff --git a/gamePlayer/public/sfx/newily/dirt-old.ogg b/gamePlayer/public/sfx/newily/dirt-old.ogg new file mode 100644 index 00000000..5059371d Binary files /dev/null and b/gamePlayer/public/sfx/newily/dirt-old.ogg differ diff --git a/gamePlayer/public/sfx/newily/dirt.ogg b/gamePlayer/public/sfx/newily/dirt.ogg new file mode 100644 index 00000000..2f1ea3f7 Binary files /dev/null and b/gamePlayer/public/sfx/newily/dirt.ogg differ diff --git a/gamePlayer/public/sfx/newily/force.ogg b/gamePlayer/public/sfx/newily/force.ogg new file mode 100644 index 00000000..03bfe6af Binary files /dev/null and b/gamePlayer/public/sfx/newily/force.ogg differ diff --git a/gamePlayer/public/sfx/newily/ice.ogg b/gamePlayer/public/sfx/newily/ice.ogg new file mode 100644 index 00000000..cb908b98 Binary files /dev/null and b/gamePlayer/public/sfx/newily/ice.ogg differ diff --git a/gamePlayer/public/sfx/newily/slide.ogg b/gamePlayer/public/sfx/newily/slide.ogg new file mode 100644 index 00000000..b4e0a3b9 Binary files /dev/null and b/gamePlayer/public/sfx/newily/slide.ogg differ diff --git a/gamePlayer/public/sfx/newily/teleport-female.ogg b/gamePlayer/public/sfx/newily/teleport-female.ogg new file mode 100644 index 00000000..7f055e2d Binary files /dev/null and b/gamePlayer/public/sfx/newily/teleport-female.ogg differ diff --git a/gamePlayer/public/sfx/newily/teleport-male.ogg b/gamePlayer/public/sfx/newily/teleport-male.ogg new file mode 100644 index 00000000..9d9512e3 Binary files /dev/null and b/gamePlayer/public/sfx/newily/teleport-male.ogg differ diff --git a/gamePlayer/public/sfx/newily/thief.ogg b/gamePlayer/public/sfx/newily/thief.ogg new file mode 100644 index 00000000..8b01f1f3 Binary files /dev/null and b/gamePlayer/public/sfx/newily/thief.ogg differ diff --git a/gamePlayer/public/sfx/newily/wall.ogg b/gamePlayer/public/sfx/newily/wall.ogg new file mode 100644 index 00000000..ca7e444f Binary files /dev/null and b/gamePlayer/public/sfx/newily/wall.ogg differ diff --git a/gamePlayer/public/sfx/sounds.md b/gamePlayer/public/sfx/sounds.md new file mode 100644 index 00000000..0fb15c83 --- /dev/null +++ b/gamePlayer/public/sfx/sounds.md @@ -0,0 +1,62 @@ +# SFX lists + +## Common sounds + +| CC2 name | Usage | +| --------------- | --------------------- | +| force | Force floor | +| newwall | Exiting recessed wall | +| push | Pushing block | +| teleport | Teleport | +| thief | Thief steal | +| wall | Bumped | +| water | Water walk | +| burn | Explosion death | +| ice | Ice walking | +| splash | Water death | +| dirt | Dirt clear | +| button | Button press | +| get | Item get | +| slide | Ice slide | +| fire | Fire walk | +| door | Door open | +| socket | Socket open | +| BummerM | Chip death | +| BummerF | Melinda death | +| teleport-male | Chip win | +| teleport-female | Melinda win | + +## Unused CC2 sounds + +| CC2 name | Notes | +| -------- | ------------------------------------------------------- | +| slide1 | | +| BLOOP | | +| BEEP | Exit sound without player voice; same as teleport sound | +| exit | + +## SFX usage + +| CC2 name | Old internal name | When it happens | +| --------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------- | +| newwall | "recessed wall" | Exiting recessed wall | +| burn | "explosion" | Explosion animation spawn | +| splash | "splash" | Water animation spawn | +| teleport | "teleport" | TP completelyJoined | +| thief | "robbed" | Thief completelyJoined | +| dirt | "dirt clear" | Dirt completelyJoined | +| button | "button press" | Button completelyJoined | +| push | "block push" | Block pushed | +| force | "force floor" | Repeating. Played when on FF | +| wall | "player bump" | Visual bumped state false -> true | +| water | "water step" | Water completelyJoined with boots | +| ice | "slide step" | Ice, FF completelyJoined with boots. Note: Holding a bonking direction on FF results in the SFX constantly restarting | +| slide | "ice slide" | Repeating. Played when _leaving_ ice. What?? | +| fire | "fire step" | Fire completelyJoined with boots | +| get | "item get" | (Player) item completelyJoined | +| socket | "socket unlock" | Socket completelyJoined | +| door | "door unlock" | Door completelyJoined | +| teleport-male | "chip win" | Player win | +| teleport-female | "melinda win" | Player win | +| BummerM | "chip death" | Player death. Note: plays after burn/splash. Delay seemingly hardcoded. | +| BummerF | "melinda death" | Player death. Note: plays after burn/splash. Delay seemingly hardcoded. | diff --git a/gamePlayer/public/sfx/tworld/BummerF.ogg b/gamePlayer/public/sfx/tworld/BummerF.ogg new file mode 100644 index 00000000..41b98afd Binary files /dev/null and b/gamePlayer/public/sfx/tworld/BummerF.ogg differ diff --git a/gamePlayer/public/sfx/tworld/BummerM.ogg b/gamePlayer/public/sfx/tworld/BummerM.ogg new file mode 100644 index 00000000..b5e3b8b6 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/BummerM.ogg differ diff --git a/gamePlayer/public/sfx/tworld/burn.ogg b/gamePlayer/public/sfx/tworld/burn.ogg new file mode 100644 index 00000000..f56a6f08 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/burn.ogg differ diff --git a/gamePlayer/public/sfx/tworld/button.ogg b/gamePlayer/public/sfx/tworld/button.ogg new file mode 100644 index 00000000..6aab00ef Binary files /dev/null and b/gamePlayer/public/sfx/tworld/button.ogg differ diff --git a/gamePlayer/public/sfx/tworld/dirt.ogg b/gamePlayer/public/sfx/tworld/dirt.ogg new file mode 100644 index 00000000..d2159c11 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/dirt.ogg differ diff --git a/gamePlayer/public/sfx/tworld/door.ogg b/gamePlayer/public/sfx/tworld/door.ogg new file mode 100644 index 00000000..62389614 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/door.ogg differ diff --git a/gamePlayer/public/sfx/tworld/fire.ogg b/gamePlayer/public/sfx/tworld/fire.ogg new file mode 100644 index 00000000..1439a997 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/fire.ogg differ diff --git a/gamePlayer/public/sfx/tworld/force.ogg b/gamePlayer/public/sfx/tworld/force.ogg new file mode 100644 index 00000000..44a22cbb Binary files /dev/null and b/gamePlayer/public/sfx/tworld/force.ogg differ diff --git a/gamePlayer/public/sfx/tworld/get.ogg b/gamePlayer/public/sfx/tworld/get.ogg new file mode 100644 index 00000000..0b636fff Binary files /dev/null and b/gamePlayer/public/sfx/tworld/get.ogg differ diff --git a/gamePlayer/public/sfx/tworld/ice.ogg b/gamePlayer/public/sfx/tworld/ice.ogg new file mode 100644 index 00000000..06f3b4a5 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/ice.ogg differ diff --git a/gamePlayer/public/sfx/tworld/newwall.ogg b/gamePlayer/public/sfx/tworld/newwall.ogg new file mode 100644 index 00000000..b7480b4b Binary files /dev/null and b/gamePlayer/public/sfx/tworld/newwall.ogg differ diff --git a/gamePlayer/public/sfx/tworld/push.ogg b/gamePlayer/public/sfx/tworld/push.ogg new file mode 100644 index 00000000..732dde48 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/push.ogg differ diff --git a/gamePlayer/public/sfx/tworld/slide.ogg b/gamePlayer/public/sfx/tworld/slide.ogg new file mode 100644 index 00000000..95a801ff Binary files /dev/null and b/gamePlayer/public/sfx/tworld/slide.ogg differ diff --git a/gamePlayer/public/sfx/tworld/socket.ogg b/gamePlayer/public/sfx/tworld/socket.ogg new file mode 100644 index 00000000..edb47280 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/socket.ogg differ diff --git a/gamePlayer/public/sfx/tworld/splash.ogg b/gamePlayer/public/sfx/tworld/splash.ogg new file mode 100644 index 00000000..f2018489 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/splash.ogg differ diff --git a/gamePlayer/public/sfx/tworld/teleport-female.ogg b/gamePlayer/public/sfx/tworld/teleport-female.ogg new file mode 100644 index 00000000..5a3a4d53 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/teleport-female.ogg differ diff --git a/gamePlayer/public/sfx/tworld/teleport-male.ogg b/gamePlayer/public/sfx/tworld/teleport-male.ogg new file mode 100644 index 00000000..3a294ed2 Binary files /dev/null and b/gamePlayer/public/sfx/tworld/teleport-male.ogg differ diff --git a/gamePlayer/public/sfx/tworld/teleport.ogg b/gamePlayer/public/sfx/tworld/teleport.ogg new file mode 100644 index 00000000..ba7d424b Binary files /dev/null and b/gamePlayer/public/sfx/tworld/teleport.ogg differ diff --git a/gamePlayer/public/sfx/tworld/thief.ogg b/gamePlayer/public/sfx/tworld/thief.ogg new file mode 100644 index 00000000..bffd26bd Binary files /dev/null and b/gamePlayer/public/sfx/tworld/thief.ogg differ diff --git a/gamePlayer/public/sfx/tworld/wall.ogg b/gamePlayer/public/sfx/tworld/wall.ogg new file mode 100644 index 00000000..f347e11d Binary files /dev/null and b/gamePlayer/public/sfx/tworld/wall.ogg differ diff --git a/gamePlayer/public/sfx/tworld/water.ogg b/gamePlayer/public/sfx/tworld/water.ogg new file mode 100644 index 00000000..34678e7b Binary files /dev/null and b/gamePlayer/public/sfx/tworld/water.ogg differ diff --git a/gamePlayer/public/tabIcons/clock.svg b/gamePlayer/public/tabIcons/clock.svg deleted file mode 100644 index c3f56646..00000000 --- a/gamePlayer/public/tabIcons/clock.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/gamePlayer/public/tabIcons/floppy.svg b/gamePlayer/public/tabIcons/floppy.svg deleted file mode 100644 index ef4f85e7..00000000 --- a/gamePlayer/public/tabIcons/floppy.svg +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/gamePlayer/public/tabIcons/info.svg b/gamePlayer/public/tabIcons/info.svg deleted file mode 100644 index b71ca451..00000000 --- a/gamePlayer/public/tabIcons/info.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/gamePlayer/public/tabIcons/leaf.svg b/gamePlayer/public/tabIcons/leaf.svg deleted file mode 100644 index c0177575..00000000 --- a/gamePlayer/public/tabIcons/leaf.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/gamePlayer/public/tabIcons/level.svg b/gamePlayer/public/tabIcons/level.svg deleted file mode 100644 index 8a7fa754..00000000 --- a/gamePlayer/public/tabIcons/level.svg +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/gamePlayer/public/tabIcons/tools.svg b/gamePlayer/public/tabIcons/tools.svg deleted file mode 100644 index 324f9d51..00000000 --- a/gamePlayer/public/tabIcons/tools.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/gamePlayer/src/NCCTK.less b/gamePlayer/src/NCCTK.less deleted file mode 100644 index 8502ea1c..00000000 --- a/gamePlayer/src/NCCTK.less +++ /dev/null @@ -1,859 +0,0 @@ -.colorCalculations { - --general-bg: hsl(var(--theme-color-huesat) 20%); - --secondary-bg: hsl(var(--theme-color-huesat) 13%); - --page-bg-brightness: 40%; - --page-bg-darker-color: hsl( - var(--theme-color-huesat) calc(var(--page-bg-brightness) * 100 / 125) - ); - --page-bg-lighter-color: hsl( - var(--theme-color-huesat) calc(var(--page-bg-brightness) * 125 / 100) - ); - --background-gradient: linear-gradient( - to top left, - var(--page-bg-darker-color), - var(--page-bg-lighter-color) - ); - color: var(--text-color); -} - -body { - font-family: sans-serif; - --theme-color-huesat: 212deg 70%; - .colorCalculations(); - --text-color: hsl(0, 0%, 85%); - --text-color-disabled: hsl(0, 0%, 40%); - --icon-color: var(--text-color); - --timer-bg: #000000; - --time-unknown: hsl(210, 100%, 50%); - --time-better: hsl(120, 100%, 25%); - --time-worse: hsl(0, 100%, 40%); - --standard-tile-size: 32px; - &.gameBody { - margin: 0; - display: flex; - flex-direction: row; - width: 100vw; - height: 100vh; - background: var(--background-gradient); - overflow: none; - } -} - -// TODO Come up with a better color for links -a { - color: var(--text-color); -} - -// Non-firefox custom scrollbar - -::-webkit-scrollbar { - background: var(--general-bg); - width: 0.5em; -} -::-webkit-scrollbar-thumb { - background: var(--secondary-bg); -} - -// Firefox scrollbar - -body { - scrollbar-color: var(--secondary-bg) transparent; - scrollbar-width: 0.5em; -} - -.container { - border-radius: 5px; - background: var(--general-bg); - padding: 0.6em 0.75em; -} - -dialog { - .container(); - padding: 0; - border-width: 1px; - color: var(--text-color); - &[open] { - display: flex; - flex-direction: column; - } - min-width: 33vw; - max-width: 75vw; - max-height: 75vh; - &::backdrop { - background-color: #0008; - } - header, - footer { - padding: 0.5em; - background: var(--secondary-bg); - } - section { - overflow: auto; - } - footer { - display: flex; - justify-content: flex-end; - gap: 0.5em; - button { - height: 2em; - } - } - // Prevent a REALLY annoying layout problem where a dialog doesn't get - // overrflow set correctly due to the extra form layer - form[method="dialog"] { - display: contents; - } -} - -.tableBorderRadius(@radius) { - &:first-child { - border-top-left-radius: @radius; - border-bottom-left-radius: @radius; - } - &:last-child { - border-top-right-radius: @radius; - border-bottom-right-radius: @radius; - } -} - -table { - width: 100%; - border-spacing: 0; - - thead { - position: sticky; - top: 0; - height: 2.3em; - background: var(--general-bg); - th { - border-bottom: solid 1px white; - .tableBorderRadius(5px); - } - } - tbody { - tr { - &:hover { - background: var(--secondary-bg); - } - cursor: pointer; - td { - input[type="radio"] { - display: block; - margin-left: auto; - margin-right: auto; - } - padding: 0.5em; - &.levelN { - text-align: right; - } - .tableBorderRadius(5px); - } - } - } - tfoot { - position: sticky; - bottom: 0; - height: 2.3em; - background: var(--general-bg); - td { - border-top: solid 1px white; - .tableBorderRadius(5px); - border-bottom-right-radius: 0px !important; - border-bottom-left-radius: 0px !important; - } - } -} - -#addTilesetButton { - margin-right: 1em; - margin-left: auto; - margin-top: 0.5em; - margin-bottom: 0.5em; - display: block; -} - -.removeTilesetButton { - display: block; -} - -.tsetPreviewCanvas { - width: calc(5 * var(--standard-tile-size)); -} - -dialog.textDialog section { - padding-left: 1em; - padding-right: 1em; - padding-top: 0.25em; - padding-bottom: 0.25em; - .small { - font-size: 0.8em; - } -} - -#aboutDialog section { - ul li { - margin-top: 0.5em; - } -} - -#themeSelectorDialog .preferences { - margin-top: 1em; -} - -.expandTriangle { - display: inline-block; - &::before { - content: "▶"; - } - &.open::before { - content: "▼"; - } -} - -#allAttemptsDialog section { - display: flex; - flex-direction: column; - > * { - padding: 1em; - cursor: pointer; - &:hover, - &:focus-visible { - background: var(--secondary-bg); - } - .firstLine, - &.failed { - display: flex; - gap: 1em; - flex-direction: row; - .expandTriangle { - margin-right: -0.5em; - } - .time { - margin-left: auto; - font-size: 0.75em; - align-self: center; - } - } - &.successful .extraInfo { - padding-top: 0.5em; - display: none; - &.showExtra { - display: block; - } - } - } - &:empty::before { - padding-left: 1em; - padding-top: 0.5em; - padding-bottom: 0.5em; - content: "No attempts yet!"; - } -} - -#scoreReportGenDialog { - section { - display: none; - } - &[stage="default"] section.default-stage { - display: initial; - } - &[stage="loading"] section.loading-stage { - display: initial; - } - &[stage="eror"] section.error-stage { - display: initial; - } - #reportText { - display: flex; - flex-direction: column; - font-family: monospace; - .disabled { - text-decoration: line-through; - } - #linesPoint { - display: contents; - input[type="checkbox"] { - margin-right: 0.5em; - } - } - } -} - -.preferences { - display: grid; - grid: auto-flow / auto auto; - gap: 0.5em; - margin-right: 1em; - margin-bottom: 1em; - h3, - p { - grid-column-end: span 2; - margin-top: 0.3em; - margin-bottom: 0.3em; - &:first-child { - margin-top: 0.75em; - } - } - p, - ul { - font-size: 0.8em; - } - p { - width: 70%; - } -} - -button.themeButton { - .colorCalculations(); - padding: 0.2em; - .themeCircle { - width: 1.3em; - height: 1.3em; - border-radius: 50%; - background: var(--background-gradient); - border: solid 0.25em var(--text-color); - } -} - -.closedPage { - display: none !important; -} - -button { - --button-high-color: hsl(var(--theme-color-huesat) 40%); - --button-low-color: hsl(var(--theme-color-huesat) 33%); - color: var(--text-color); - padding: 0 0.4em; - border-width: 2px; - border-style: solid; - border-color: var(--button-high-color) var(--button-low-color) - var(--button-low-color) var(--button-high-color); - border-radius: 2px; - background-image: linear-gradient( - to bottom, - var(--button-high-color), - var(--button-low-color) - ); - &:enabled { - &:hover { - --button-high-color: hsl(var(--theme-color-huesat) 45%); - --button-low-color: hsl(var(--theme-color-huesat) 38%); - } - &:active { - --button-high-color: hsl(var(--theme-color-huesat) 30%); - --button-low-color: hsl(var(--theme-color-huesat) 23%); - } - } - &:disabled { - --button-high-color: hsl(var(--theme-color-huesat) 20%); - --button-low-color: hsl(var(--theme-color-huesat) 13%); - color: var(--text-color-disabled); - } - user-select: none; -} - -/* Tooltip */ - -.tooltipRoot { - width: fit-content; - transform-origin: 10px 10px; - display: flex; - opacity: 0.9; - animation: openTooltip 0.75s cubic-bezier(0.2, 0, 0.2, 1) forwards; - position: absolute; - left: 110%; - top: 35%; - z-index: 100; - user-select: none; - - .tooltipBox { - .container(); - flex-shrink: 0; - backdrop-filter: blur(3px); - } - .tooltipTriangle { - width: 0; - height: 0; - backdrop-filter: blur(3px); - - display: inline-block; - border: solid 10px transparent; - border-right: solid 10px var(--general-bg); - position: relative; - top: 5px; - margin-right: -1px; - } -} - -@keyframes openTooltip { - from { - transform: scale(40%); - opacity: 0; - } - - 50% { - opacity: 0.9; - } - - 100% { - opacity: 0.9; - transform: scale(100%); - } -} -@keyframes closeTooltip { - from { - opacity: 0.9; - transform: scale(100%); - } - - 50% { - opacity: 0; - } - - to { - opacity: 0; - transform: scale(40%); - } -} -.buttonTooltipBox { - display: flex; - flex-direction: column; - row-gap: 0.5em; - - hr { - margin: 0; - } - - .buttonTooltipRow { - display: flex; - flex-direction: row; - padding: 0.2em 0.5em; - border-radius: 3px; - &:focus-visible { - outline: none; - } - &:not([data-disabled]) { - cursor: pointer; - &:hover, - &:focus-visible { - background: var(--secondary-bg); - } - } - &[data-disabled] { - color: var(--text-color-disabled); - } - - .buttonTooltipKey { - padding-left: 2em; - margin-left: auto; - padding-bottom: 0.2em; - } - } -} -/* Copied wholesale from https://github.com/eevee/lexys-labyrinth/ */ -kbd { - padding: 0 0.25em; - border: 1px solid currentColor; - border-radius: 0.25em; - box-shadow: 0 2px 0 currentColor; - text-align: center; - text-transform: uppercase; - width: fit-content; -} - -/* The sidebar */ - -.sidebar { - border-radius: 0px; - padding: 0; - width: 8em; - height: 100%; - display: flex; - flex-direction: column; - .sidebarButton { - margin: 1em; - display: flex; - position: relative; - img, - .levelIconContainer { - width: 80%; - user-select: none; - margin: auto; - display: block; - cursor: pointer; - } - // The level button, which has a dynamic number in it - .levelIconContainer { - position: relative; - img { - width: 100%; - } - #levelIconText { - position: absolute; - top: 25%; - height: 35%; - left: 12%; - width: 75%; - font-size: 2em; - color: var(--icon-color); - text-align: center; - user-select: none; - pointer-events: none; - line-height: 1.2; - } - } - } - - .bottomButton { - margin-top: auto; - } -} - -#loadingPage { - width: fit-content; - height: fit-content; - margin: auto; - p { - font-style: italic; - } -} - -/* The game player */ - -.playerPage { - --base-tile-size: 32px; - --tile-scale: 2; - --tile-size: calc(var(--base-tile-size) * var(--tile-scale)); - --quarter-tile: calc(var(--tile-size) / 4); - .stats { - grid-area: stats; - display: grid; - grid-template-columns: auto auto; - grid-template-rows: repeat(auto-fill, 1em); - gap: var(--quarter-tile); - padding: var(--quarter-tile); - padding-bottom: 0; - font-size: 1.2em; - white-space: nowrap; - output { - text-align: right; - } - } - .viewportArea { - grid-area: viewport; - position: relative; - width: calc(var(--tile-size) * var(--level-camera-width)); - height: calc(var(--tile-size) * var(--level-camera-height)); - } - .viewportCanvas { - width: 100%; - height: 100%; - } - .inventoryCanvas { - grid-area: inventory; - margin: auto; - width: calc(var(--tile-size) * 4); - height: calc(var(--tile-size) * 2); - background-color: azure; - } -} - -#levelPlayerPage { - padding: var(--quarter-tile); - border-radius: 5px; - margin: auto; - display: grid; - gap: var(--quarter-tile); - grid-template: - "viewport stats" min-content - "viewport inventory" min-content - "viewport hint" 1fr - / min-content min-content; - &.solutionPlayback { - background: repeating-linear-gradient( - 45deg, - transparent 0%, - transparent 10%, - #ff0b 10%, - #ff0b 20% - ), - var(--general-bg); - } - #hintBox { - grid-area: hint; - background-color: var(--secondary-bg); - padding: 0.5em 0.5em; - } -} - -canvas { - image-rendering: optimizeSpeed; - image-rendering: -moz-crisp-edges; - image-rendering: -webkit-crisp-edges; - image-rendering: pixelated; - image-rendering: crisp-edges; -} - -/* Viewport overlay */ - -#levelViewportOverlay { - width: 100%; - height: 100%; - position: absolute; - top: 0; - display: grid; - gap: 0px 0px; - grid-template: - ". . ." var(--tile-size) - ". top ." var(--tile-size) - ". . ." 0.5fr - ". middle ." 1fr - ". . ." 0.5fr - ". bottom ." calc(var(--tile-size) * 1.5) - ". . ." calc(var(--tile-size) * 0.75) - / 0.15fr 2.7fr 0.15fr; - h3.deathOnly, - h3.timeoutOnly { - display: none; - } - &[data-game-state="won"] { - background-image: radial-gradient(#4406, #660d); - #overlayWinContents { - display: contents; - } - } - &[data-game-state="timeout"] { - background-image: radial-gradient(#0084, #0088); - #overlayLoseContents { - display: contents; - h3.timeoutOnly { - display: initial; - } - } - } - &[data-game-state="death"] { - background-image: radial-gradient(#1004, #2008); - #overlayLoseContents { - display: contents; - h3.deathOnly { - display: initial; - } - } - } - &[data-paused] { - background: var(--general-bg); - #overlayPauseContents { - display: contents; - .container { - background: var(--secondary-bg); - } - h4 { - margin-top: 0.3em; - margin-bottom: 0.7em; - } - } - } - &[data-preplay] { - background-image: radial-gradient(#0002, #0004); - #overlayPreplayContents { - display: contents; - } - } - &[data-nonlegal] { - background-image: repeating-conic-gradient(#0005 0%, #0008 5%, #0005 10%), - radial-gradient(transparent, black); - #overlayNonlegalContents { - display: contents; - } - } - &[data-gz] { - background-image: radial-gradient(transparent, var(--general-bg) 70%), - repeating-radial-gradient(#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00 30%); - background-position-x: 50%; - background-position-y: 80%; - background-size: 200% 200%; - - #overlayGzContents { - display: contents; - } - } - > * { - display: none; - h3 { - grid-area: top; - text-align: center; - font-size: 300%; - margin: 0; - text-shadow: black 1px 0px 10px; - } - .statsContainer { - grid-area: middle; - padding: var(--quarter-tile); - &.small { - width: fit-content; - height: fit-content; - justify-self: center; - } - } - .buttonContainer { - box-sizing: border-box; - justify-content: stretch; - grid-area: bottom; - gap: var(--quarter-tile); - padding: var(--quarter-tile); - height: 100%; - display: flex; - flex-direction: row; - button { - flex: 1; - font-size: 120%; - } - } - } -} - -#exaPlayerPage { - display: grid; - margin: auto; - gap: var(--quarter-tile); - grid-template: - "viewport info" auto - "viewport moves" 1fr - / min-content min-content; - .infoContainer { - grid-area: info; - display: flex; - flex-direction: row; - gap: var(--tile-size); - width: calc(16 * var(--tile-size)); - box-sizing: border-box; - padding: calc(2 * var(--quarter-tile)); - } - .viewportContainer { - grid-area: viewport; - padding: calc(2 * var(--quarter-tile)); - .viewportArea { - display: flex; - justify-content: center; - align-items: center; - .blockedMessage { - display: none; - position: absolute; - margin: auto; - &.show { - display: block; - } - } - } - } - .movesContainer { - height: 0; - min-height: 100%; - box-sizing: border-box; - .movesArea { - .container(); - overflow-y: auto; - width: 100%; - height: 100%; - resize: none; - border: 0; - padding: 0; - background-color: var(--secondary-bg); - font-family: monospace; - overflow-wrap: anywhere; - line-break: anywhere; - } - grid-area: moves; - } -} - -/* Set selector (stub) */ - -#setSelectorPage { - overflow-y: auto; - flex: 1; - padding: 1em; - display: flex; - flex-direction: column; - align-items: center; - header { - width: fit-content; - margin-top: 1em; - margin-bottom: 1em; - display: grid; - grid: - "image title " - "image tagline"; - gap: 0 1em; - img { - grid-area: image; - } - h1, - p { - margin: 0; - height: 1em; - line-height: 0.9; - } - h1 { - grid-area: title; - font-weight: normal; - font-size: 8em; - } - p { - grid-area: tagline; - justify-self: center; - font-size: 1.07em; - } - } - - #fileLoaderBar { - width: 100%; - align-self: center; - box-sizing: border-box; - display: grid; - grid: - "text text" min-content - "file dirs" 1fr - / 1fr 1fr; - p { - grid-area: text; - margin: 0; - margin-bottom: 0.25em; - font-size: 1.5em; - } - button { - height: 4em; - } - gap: 0 0.5em; - } - #setList { - display: grid; - justify-content: center; - width: 90%; - gap: 1em; - grid: auto-flow / repeat( - auto-fit, - calc(var(--standard-tile-size) * 10 + 0.75em * 2) - ); - li { - list-style: none; - height: fit-content; - .setThumbnail { - width: calc(var(--standard-tile-size) * var(--camera-width)); - height: calc(var(--standard-tile-size) * var(--camera-height)); - margin: auto; - canvas, - img { - width: 100%; - height: 100%; - object-fit: contain; - } - } - cursor: pointer; - &:hover, - &:focus-visible { - background: var(--secondary-bg); - } - } - } -} diff --git a/gamePlayer/src/allAttemptsDialog.ts b/gamePlayer/src/allAttemptsDialog.ts deleted file mode 100644 index d214c782..00000000 --- a/gamePlayer/src/allAttemptsDialog.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { metricsFromAttempt, protoTimeToMs } from "@notcc/logic" -import { protobuf } from "@notcc/logic" -import { Pager } from "./pager" -import { instanciateTemplate } from "./utils" - -const allAttemptsDialog = - document.querySelector("#allAttemptsDialog")! -// const verifyAllButton = -// allAttemptsDialog.querySelector("#verifyAllButton")! -const successulTemplate = allAttemptsDialog.querySelector( - "#successfulAttemptTemplate" -)! -const failedTemplate = allAttemptsDialog.querySelector( - "#failedAttemptTemplate" -)! - -function glitchToString(glitch: protobuf.IGlitchInfo): string { - return `${ - protobuf.GlitchInfo.KnownGlitches[glitch.glitchKind!] - } (${glitch.location!.x!}, ${glitch.location!.y!}) at ${Math.ceil( - protoTimeToMs(glitch.happensAt!) / 1000 - )}s` -} - -export function openAllAttemptsDialog(pager: Pager): void { - const set = pager.loadedSet - if (set === null) return - const attempts = set.seenLevels[set.currentLevel].levelInfo.attempts ?? [] - const root = allAttemptsDialog.querySelector("section")! - // Nuke all current data - root.textContent = "" - for (const attempt of attempts) { - const isSuccessful = !!attempt.solution - const attEl = instanciateTemplate( - isSuccessful ? successulTemplate : failedTemplate - ) - const startTimeEl = attEl.querySelector(".startTime"), - endTimeEl = attEl.querySelector(".endTime"), - metricsEl = attEl.querySelector(".metrics"), - failReasonEl = attEl.querySelector(".failReason"), - expandTriangleEl = attEl.querySelector(".expandTriangle"), - replayButton = attEl.querySelector(".replayButton"), - extraInfoEl = attEl.querySelector(".extraInfo"), - realTimeEl = attEl.querySelector(".realTime"), - glitchListEl = attEl.querySelector(".glitchList") - - if (startTimeEl && attempt.attemptStart) { - const startTime = new Date(protoTimeToMs(attempt.attemptStart)) - startTimeEl.textContent = startTime.toLocaleString() || "???" - } - if (endTimeEl && attempt.attemptStart && attempt.attemptLength) { - const endTime = new Date( - protoTimeToMs(attempt.attemptStart) + - protoTimeToMs(attempt.attemptLength) - ) - endTimeEl.textContent = endTime.toLocaleString() || "???" - } - if (metricsEl && attempt.solution?.outcome) { - const metrics = metricsFromAttempt( - set.currentLevel, - attempt.solution?.outcome - ) - metricsEl.textContent = `${Math.ceil(metrics.timeLeft)}s / ${ - metrics.points - }pts` - if (realTimeEl) { - realTimeEl.textContent = metrics.realTime.toFixed(2) - } - } - if (expandTriangleEl && extraInfoEl) { - attEl.addEventListener("click", () => { - const showExtra = expandTriangleEl.classList.toggle("open") - extraInfoEl.classList.toggle("showExtra", showExtra) - }) - } - if (replayButton) { - if (attempt.solution) { - replayButton.addEventListener("click", ev => { - ev.preventDefault() - ev.stopPropagation() - pager.loadSolution(attempt.solution!) - allAttemptsDialog.close() - }) - } else { - replayButton.disabled = true - } - } - if (failReasonEl && attempt.failReason) { - failReasonEl.textContent = attempt.failReason - } - if (glitchListEl && attempt.solution?.usedGlitches) { - for (const glitch of attempt.solution.usedGlitches) { - const li = document.createElement("li") - li.textContent = glitchToString(glitch) - glitchListEl.appendChild(li) - } - } - - attEl.tabIndex = 0 - root.appendChild(attEl) - } - allAttemptsDialog.showModal() -} diff --git a/gamePlayer/src/app.tsx b/gamePlayer/src/app.tsx new file mode 100644 index 00000000..d72fc42a --- /dev/null +++ b/gamePlayer/src/app.tsx @@ -0,0 +1,119 @@ +import "./index.css" +import { Sidebar } from "./components/Sidebar" +import { makeThemeCssVars, colorSchemeAtom } from "./themeHelper" +import { Router, embedModeAtom, embedReadyAtom } from "./routing" +import { PromptComponent, Prompts, showPromptGs } from "./prompts" +import { useAtomValue } from "jotai" +import { useEffect, useState } from "preact/hooks" +import { twJoin } from "tailwind-merge" +import { desktopPlatform, isDesktop, useJotaiFn } from "./helpers" +import { Dialog } from "./components/Dialog" +import { ToastDisplay } from "./toast" +import { ErrorBox } from "./components/ErrorBox" +import { playEnabledAtom } from "./preferences" +import { DownloadPage } from "./pages/DownloadPage" + +const ErrorPrompt = + (err: Error): PromptComponent => + props => ( + {}]]} + onResolve={props.onResolve} + > + {" "} + It appears something went wrong and an error has occured in NotCC! + + Please report this error by{" "} + + making a GitHub issue + {" "} + or{" "} + + mentioning it in the Chip's Challenge Bit Buster Club Discord Server + + , thanks! + + ) + +async function launchSw() { + if (!navigator.serviceWorker) return + await navigator.serviceWorker.register("./sw.js") +} + +export function App() { + const colorScheme = useAtomValue(colorSchemeAtom) + const embedMode = useAtomValue(embedModeAtom) + const embedReady = useAtomValue(embedReadyAtom) + const showPrompt = useJotaiFn(showPromptGs) + // PWA auto-update + useEffect(() => { + if (isDesktop() || location.hostname == "localhost") return + launchSw() + }, []) + // Embed mode communication + useEffect(() => { + if (!embedReady) return + top?.postMessage( + { width: document.body.scrollWidth, height: document.body.scrollHeight }, + "*" + ) + }, [embedReady]) + // Error handling, handles both normal and promise error events + useEffect(() => { + const listener = (ev: ErrorEvent | PromiseRejectionEvent) => { + // return + let errorMsg: Error + if ("reason" in ev) { + errorMsg = ev.reason + } else { + errorMsg = ev.error + } + if (errorMsg === undefined) { + errorMsg = new Error("Caught weird error type, please check logs") + } + showPrompt(ErrorPrompt(errorMsg)) + } + window.addEventListener("error", listener) + window.addEventListener("unhandledrejection", listener) + return () => { + window.removeEventListener("error", listener) + window.removeEventListener("unhandledrejection", listener) + } + }, []) + // By default, the NotCC page is just the download page, + let playEnabled = useAtomValue(playEnabledAtom) || isDesktop() + // Dumb hack: (p)React *really* doesn't like when the SSG is substantially different, so + // force `playEnabled` to be false for the intial hydration, uugh + useEffect(() => { + setForcePlayDisabled(false) + }, []) + const [forcePlayDisabled, setForcePlayDisabled] = useState(true) + if (forcePlayDisabled) { + playEnabled = false + } + + return ( +
    + + {!embedMode && playEnabled && } +
    + {playEnabled ? : } +
    + +
    + ) +} diff --git a/gamePlayer/src/artSetSpecials.ts b/gamePlayer/src/artSetSpecials.ts deleted file mode 100644 index a5836c9e..00000000 --- a/gamePlayer/src/artSetSpecials.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { - Actor, - Animation, - BonusFlag, - CloneMachine, - CounterGate, - CustomFloor, - CustomWall, - Direction, - DirectionalBlock, - FlameJet, - InvisibleWall, - LitTNT, - Playable, - Railroad, - Rover, - ThinWall, - Tile, - Trap, - VoodooTile, - WireOverlapMode, -} from "@notcc/logic" -import { registerSpecialFunction, registerStateFunction } from "./const" -import { Art, Frame, Position, Size, SpecialArt, ctxToDir } from "./renderer" - -function bitfieldToDirs(bitfield: number): Direction[] { - const directions: Direction[] = [] - for (let dir = Direction.UP; dir <= Direction.LEFT; dir += 1) { - if ((bitfield & (1 << dir)) !== 0) { - directions.push(dir) - } - } - return directions -} - -function getPlayableState(actor: Playable): string { - const inWater = actor.tile.findActor(actor => actor.hasTag("water")) - if (inWater) return "water" - if (actor.playerBonked || actor.isPushing) return "bump" - return "normal" -} - -registerStateFunction("chip", getPlayableState) -registerStateFunction("melinda", getPlayableState) -registerStateFunction("invisibleWall", actor => - actor.animationLeft > 0 ? "touched" : "default" -) -registerStateFunction("bonusFlag", actor => actor.customData) -registerStateFunction("customWall", actor => actor.customData) -registerStateFunction("customFloor", actor => actor.customData) -registerStateFunction("tntLit", actor => - Math.floor((actor.lifeLeft / 253) * 4).toString() -) -registerStateFunction("flameJet", actor => actor.customData) - -interface FreeformWiresSpecialArt extends SpecialArt { - base: Frame - overlap: Frame - overlapCross: Frame -} - -registerSpecialFunction( - "freeform wires", - function (ctx, art) { - const spArt = art as FreeformWiresSpecialArt - const pos = Array.isArray(ctx.actor) - ? ctx.actor - : ctx.actor instanceof Tile - ? ctx.actor.position - : ctx.actor.tile.position - const wires = Array.isArray(ctx.actor) ? 0 : ctx.actor.wires - const wireTunnels = Array.isArray(ctx.actor) ? 0 : ctx.actor.wireTunnels - this.tileBlit(ctx, pos, spArt.base) - // If we don't have anything else to draw, don't draw the overlay - // TODO Wire tunnels are drawn on top of everything else, so maybe they - // don't cause the base to be drawn? - if (wires === 0 && wireTunnels === 0) { - return - } - if (ctx.actor.level.hideWires) { - return - } - const crossWires = - (ctx.actor.wireOverlapMode === WireOverlapMode.CROSS && - ctx.actor.wires === 0b1111) || - ctx.actor.wireOverlapMode === WireOverlapMode.ALWAYS_CROSS - if (crossWires) { - this.drawWireBase( - ctx, - pos, - wires & 0b0101, - (ctx.actor.poweredWires & 0b0101) !== 0 - ) - this.drawWireBase( - ctx, - pos, - wires & 0b1010, - (ctx.actor.poweredWires & 0b1010) !== 0 - ) - } else { - this.drawWireBase(ctx, pos, wires, ctx.actor.poweredWires !== 0) - } - this.tileBlit(ctx, pos, crossWires ? spArt.overlapCross : spArt.overlap) - this.drawCompositionalSides( - ctx, - pos, - this.tileset.art.wireTunnel, - 0.25, - bitfieldToDirs(wireTunnels) - ) - } -) -interface ArrowsSpecialArt extends SpecialArt { - UP: Frame - RIGHT: Frame - DOWN: Frame - LEFT: Frame - CENTER: Frame -} - -registerSpecialFunction( - "arrows", - function (ctx, art) { - const spArt = art as ArrowsSpecialArt - const pos = this.getPosition(ctx) - const directions = - "legalDirections" in ctx.actor - ? ctx.actor.legalDirections - : ctx.actor.cloneArrows - this.drawCompositionalSides(ctx, pos, spArt, 0.25, directions) - this.tileBlit(ctx, [pos[0] + 0.25, pos[1] + 0.25], spArt.CENTER, [0.5, 0.5]) - } -) - -interface ScrollingSpecialArt extends SpecialArt { - duration: number - UP: [Frame, Frame] - DOWN: [Frame, Frame] - RIGHT: [Frame, Frame] - LEFT: [Frame, Frame] -} - -registerSpecialFunction("scrolling", function (ctx, art) { - const spArt = art as ScrollingSpecialArt - const offsetMult = (ctx.ticks / spArt.duration) % 1 - const baseFrames = spArt[ctxToDir(ctx)] - const offset: Frame = [ - baseFrames[1][0] - baseFrames[0][0], - baseFrames[1][1] - baseFrames[0][1], - ] - const frame: Frame = [ - baseFrames[0][0] + offset[0] * offsetMult, - baseFrames[0][1] + offset[1] * offsetMult, - ] - const pos = this.getPosition(ctx) - this.tileBlit(ctx, pos, frame) -}) - -interface FuseSpecialArt extends SpecialArt { - duration: number - frames: Frame[] -} - -registerSpecialFunction("fuse", function (ctx, art) { - const spArt = art as FuseSpecialArt - const frameN = Math.floor( - spArt.frames.length * ((ctx.ticks / spArt.duration) % 1) - ) - const pos = this.getPosition(ctx) - this.tileBlit(ctx, [pos[0] + 0.5, pos[1]], spArt.frames[frameN], [0.5, 0.5]) -}) - -interface PerspectiveSpecialArt extends SpecialArt { - somethingUnderneathOnly?: boolean - default: Art - revealed: Art -} - -registerSpecialFunction("perspective", function (ctx, art) { - const spArt = art as PerspectiveSpecialArt - let perspective = ctx.actor.level.getPerspective() - if (perspective && spArt.somethingUnderneathOnly) { - perspective = - !!ctx.actor.tile.findActor(actor => actor.layer < ctx.actor.layer) || - ctx.actor.tile.wires !== 0 - } - this.drawArt(ctx, perspective ? spArt.revealed : spArt.default) -}) - -// TODO letters - -interface ThinWallsSpecialArt extends SpecialArt { - UP: Frame - RIGHT: Frame - DOWN: Frame - LEFT: Frame -} -registerSpecialFunction("thin walls", function (ctx, art) { - const spArt = art as ThinWallsSpecialArt - const pos = this.getPosition(ctx) - - this.drawCompositionalSides( - ctx, - pos, - spArt, - 0.5, - bitfieldToDirs(ctx.actor.allowedDirections) - ) -}) - -registerStateFunction("thinWall", actor => - actor.hasTag("canopy") ? "canopy" : "nothing" -) - -registerStateFunction("blueWall", actor => actor.customData) -registerStateFunction("greenWall", actor => - actor.customData === "fake" && - actor.tile.findActor(iterActor => iterActor.layer > actor.layer) - ? "stepped" - : actor.customData -) -registerStateFunction("toggleWall", actor => actor.customData) -registerStateFunction("holdWall", actor => actor.customData) -registerStateFunction("trap", actor => (actor.isOpen ? "open" : "closed")) -registerStateFunction("teleportRed", actor => - !actor.wired || actor.poweredWires !== 0 ? "on" : "off" -) - -// Note: We also check for `wires` here, unlike in the logic. -// This is intentional, this discrepency is also in CC2 -registerStateFunction("transmogrifier", actor => - actor.wires !== 0 && actor.wired && !actor.poweredWires ? "off" : "on" -) -registerStateFunction("toggleSwitch", actor => actor.customData) - -interface StretchSpecialArt extends SpecialArt { - idle: Art - vertical: Frame[] - horizontal: Frame[] -} - -registerSpecialFunction("stretch", function (ctx, art) { - const spArt = art as StretchSpecialArt - if (ctx.actor.cooldown === 0) { - this.drawArt(ctx, spArt.idle) - return - } - // Use the base position, not the visual, the frames themselves provide the offset - const pos = ctx.actor.tile.position - let frames: Frame[] - let framesReversed: boolean - const dir = ctx.actor.direction - let offset: Position = [0, 0] - let cropSize: Size - if (dir === Direction.UP) { - frames = spArt.vertical - framesReversed = true - cropSize = [1, 2] - } else if (dir === Direction.RIGHT) { - frames = spArt.horizontal - framesReversed = false - offset = [-1, 0] - cropSize = [2, 1] - } else if (dir === Direction.DOWN) { - frames = spArt.vertical - framesReversed = false - offset = [0, -1] - cropSize = [1, 2] - } else { - // Direction.LEFT - frames = spArt.horizontal - framesReversed = true - cropSize = [2, 1] - } - let progress = 1 - ctx.actor.cooldown / ctx.actor.currentMoveSpeed! - if (framesReversed) { - progress = 1 - progress - } - const frame = frames[Math.floor(progress * frames.length)] - this.tileBlit(ctx, [pos[0] + offset[0], pos[1] + offset[1]], frame, cropSize) -}) - -registerSpecialFunction("voodoo", function (ctx) { - if (ctx.actor.tileOffset === null) return - const pos = this.getPosition(ctx) - const frame: Frame = [ - ctx.actor.tileOffset % 0x10, - Math.floor(ctx.actor.tileOffset / 0x10), - ] - this.tileBlit(ctx, pos, frame) -}) - -interface RailroadSpecialArt extends SpecialArt { - toggleMark: Frame - wood: Record - rail: Record - toggleRail: Record -} - -registerSpecialFunction("railroad", function (ctx, art) { - const spArt = art as RailroadSpecialArt - const pos = this.getPosition(ctx) - for (const dir of ctx.actor.baseRedirects) { - this.tileBlit(ctx, pos, spArt.wood[dir]) - } - for (const dir of ctx.actor.baseRedirects) { - if (ctx.actor.isSwitch) { - if (dir === ctx.actor.activeTrack) continue - this.tileBlit(ctx, pos, spArt.toggleRail[dir]) - } else { - this.tileBlit(ctx, pos, spArt.rail[dir]) - } - } - if ( - ctx.actor.isSwitch && - ctx.actor.baseRedirects.includes(ctx.actor.activeTrack) - ) { - this.tileBlit(ctx, pos, spArt.rail[ctx.actor.activeTrack]) - } - if (ctx.actor.isSwitch) { - this.tileBlit(ctx, pos, spArt.toggleMark) - } -}) - -registerStateFunction("rover", actor => actor.emulatedMonster) - -interface RoverAntennaSpecialArt extends SpecialArt { - UP: Frame - RIGHT: Frame - DOWN: Frame - LEFT: Frame -} - -registerSpecialFunction("rover antenna", function (ctx, art) { - const spArt = art as RoverAntennaSpecialArt - const pos = this.getPosition(ctx) - const frame = spArt[ctxToDir(ctx)] - this.tileBlit(ctx, [pos[0] + 0.25, pos[1] + 0.25], frame, [0.5, 0.5]) -}) - -registerSpecialFunction("letters", function (ctx) { - const pos = this.getPosition(ctx) - // A space doesn't render anything - if (ctx.actor.customData === " ") return - const frame = this.tileset.art.letters[ctx.actor.customData] - this.tileBlit(ctx, [pos[0] + 0.25, pos[1] + 0.25], frame, [0.5, 0.5]) -}) - -registerStateFunction("greenBomb", actor => actor.customData) - -interface CounterSpecialArt extends SpecialArt { - 0: Frame - 1: Frame - 2: Frame - 3: Frame - 4: Frame - 5: Frame - 6: Frame - 7: Frame - 8: Frame - 9: Frame - "-": Frame - "": Frame -} - -registerSpecialFunction("counter", function (ctx, art) { - const spArt = art as CounterSpecialArt - const pos = this.getPosition(ctx) - this.tileBlit( - ctx, - [pos[0] + 0.125, pos[1]], - spArt[ctx.actor.memory as unknown as "0"], - [0.75, 1] - ) -}) - -interface LogicGateSpecialArt extends SpecialArt { - UP: Frame - RIGHT: Frame - DOWN: Frame - LEFT: Frame -} - -function rotateWires(wires: number, dir: Direction): number { - return ((wires << dir) | (wires >> (4 - dir))) & 0b1111 -} - -registerSpecialFunction("logic gate", function (ctx, art) { - if (ctx.actor.level.hideWires) { - this.drawFloor(ctx, ctx.actor.tile) - return - } - const spArt = art as LogicGateSpecialArt - const pos = this.getPosition(ctx) - const poweredWires = ctx.actor.wires & ctx.actor.poweredWires - // Figure out which wires correspond to the which gate parts. - const gateHead = rotateWires(0b0001, ctx.actor.direction) - const gateRight = rotateWires(0b0010, ctx.actor.direction) - const gateBack = rotateWires(0b0100, ctx.actor.direction) - const gateLeft = rotateWires(0b1000, ctx.actor.direction) - - // Blit left and right as if they are also connected to the back, - // to have the bends in some tilesets - // Draw the left side first, the right one has control over the middle - this.drawWireBase( - ctx, - pos, - gateLeft | gateBack, - (gateLeft & poweredWires) !== 0 - ) - this.drawWireBase( - ctx, - pos, - gateRight | gateBack, - (gateRight & poweredWires) !== 0 - ) - - // And last, draw the output - this.drawWireBase(ctx, pos, gateHead, (poweredWires & gateHead) !== 0) - // Now, just draw the base - this.tileBlit(ctx, pos, spArt[ctxToDir(ctx)]) -}) - -function animationStateFunction(actor: Animation): string { - return Math.floor( - (1 - actor.animationCooldown / actor.animationLength) * 4 - ).toString() -} - -registerStateFunction("splashAnim", animationStateFunction) -registerStateFunction("explosionAnim", animationStateFunction) diff --git a/gamePlayer/src/components/AboutDialog.tsx b/gamePlayer/src/components/AboutDialog.tsx new file mode 100644 index 00000000..6c600214 --- /dev/null +++ b/gamePlayer/src/components/AboutDialog.tsx @@ -0,0 +1,134 @@ +import { PromptComponent } from "@/prompts" +import { Dialog } from "./Dialog" + +export const AboutPrompt: PromptComponent = props => { + return ( + {}]]} + onResolve={props.onResolve} + > +
    +

    NotCC

    +

    + NotCC is an accurate,{" "} + + open-source + {" "} + + Chip's Challenge 2 + {" "} + and{" "} + + Chip's Challenge 1 (Steam) + {" "} + emulator. +

    +

    +
    + NotCC is primarily made by{" "} + + G lander + + . +

    +

    Thanks to:

    +
      +
    • + The Chip's Challenge community, residing at{" "} + + the Bit Busters Club + + . Also, more specifically: +
    • +
    • + + eevee + + , for creating the first CC2 emulator,{" "} + + Lexy's Labyrinth + + , which NotCC heavily borrowed (and still borrows) from. +
    • +
    • + Markus O.,{" "} + + Bacorn + + , and{" "} + + Sickly + + , for creating and maintaining SuperCC, the first real optimization + tool, which heavily inspired the original ExaCC. +
    • +
    • + + Zrax + + , for creating a very helpful suite of CC tools, appropriately + called{" "} + + CCTools + + . +
    • +
    • + Anders Kaseorg and Kawaiiprincess, for creating and porting to CC2 + (respectively) the bundled Tile World tileset. +
    • +
    • + + Sharpeye + + , for finding a bug with ExaCC auto-scaling and being one of the + first people to use ExaCC for optimization. +
    • +
    • + + Tyler Sontag + + , for creating the very, very helpful resident Discord bot,{" "} + + Gliderbot + + . +
    • +
    • + + IHNN + + , for providing details and feedback on non-legal glitches and their + prevention. +
    • +
    • + ChosenID, for creating the extremely powerful{" "} + + Melinda Vicinity Searcher + + , which ExaCC's graph mode is influenced and inspired by, and + ExaCC's tree mode is "inspired" by. +
    • +
    +

    + Last change: {import.meta.env.VITE_LAST_COMMIT_INFO}. +
    + Built at {import.meta.env.VITE_BUILD_TIME}. +

    +
    +
    + ) +} diff --git a/gamePlayer/src/components/DesktopUpdater.tsx b/gamePlayer/src/components/DesktopUpdater.tsx new file mode 100644 index 00000000..b54b95ae --- /dev/null +++ b/gamePlayer/src/components/DesktopUpdater.tsx @@ -0,0 +1,52 @@ +import { usePromise } from "@/helpers" +import { + downloadUpdateInfo, + installUpdate, + shouldUpdateTo, +} from "@/desktopUpdate" +import { Throbber } from "./Throbber" +import { useCallback, useMemo, useState } from "preact/hooks" +import { ProgressBar } from "./ProgressBar" +import { twJoin } from "tailwind-merge" + +export function DesktopUpdater() { + const updateInfo = usePromise(() => downloadUpdateInfo(), []) + const shouldUpdate = useMemo( + () => updateInfo.state === "done" && shouldUpdateTo(updateInfo.value), + [updateInfo] + ) + const [progress, setProgress] = useState(0) + const [installing, setInstalling] = useState(false) + const goInstallUpdate = useCallback(() => { + if (updateInfo.state !== "done") return + setInstalling(true) + return installUpdate(updateInfo.value, setProgress).finally(() => + setInstalling(false) + ) + }, [updateInfo]) + return ( +
    +
    Version {import.meta.env.VITE_VERSION}
    + {updateInfo.state === "working" ? ( + + ) : updateInfo.state === "error" ? ( +
    Failed to download update info
    + ) : !shouldUpdate ? ( +
    Up to date
    + ) : installing ? ( + <> +
    Downloading v{updateInfo.value.versionName}...
    +
    + +
    + + ) : ( + <> +
    Version {updateInfo.value.versionName} available
    + {updateInfo.value.notice &&
    {updateInfo.value.notice}
    } + + + )} +
    + ) +} diff --git a/gamePlayer/src/components/Dialog.tsx b/gamePlayer/src/components/Dialog.tsx new file mode 100644 index 00000000..14b335d7 --- /dev/null +++ b/gamePlayer/src/components/Dialog.tsx @@ -0,0 +1,67 @@ +import { applyRef } from "@/helpers" +import { ComponentChildren, Ref } from "preact" +import { ReactNode, forwardRef, useRef } from "preact/compat" +import Draggable from "react-draggable" +import { twJoin } from "tailwind-merge" + +export const Dialog = forwardRef(function ( + props: { + header: ReactNode + children: ComponentChildren + notModal?: boolean + buttons: [string, () => void | Promise][] + onResolve?: () => void + onClose?: () => void + }, + ref: Ref +) { + const normalSubmitRef = useRef(false) + return ( + + { + if (props.notModal) { + refVal?.show() + } else { + refVal?.showModal() + } + applyRef(ref, refVal) + }} + onClose={ev => { + ev.preventDefault() + if (props.onClose) { + props.onClose() + return + } + if (normalSubmitRef.current) return + props.onResolve?.() + }} + > +
    + {props.header} +
    +
    {props.children}
    +
    + {props.buttons.map(([label, action]) => ( + + ))} +
    +
    +
    + ) +}) diff --git a/gamePlayer/src/components/DumbLevelPlayer.tsx b/gamePlayer/src/components/DumbLevelPlayer.tsx new file mode 100644 index 00000000..59b806f7 --- /dev/null +++ b/gamePlayer/src/components/DumbLevelPlayer.tsx @@ -0,0 +1,1077 @@ +import { + AttemptTracker, + InputProvider, + LevelSet, + Level, + GameState, + applyLevelModifiers, + winInterruptResponseFromLevel, + LevelModifiers, + DETERMINISTIC_BLOB_MOD, + SolutionMetrics, + calculateLevelPoints, + findBestMetrics, + KeyInputs, + KEY_INPUTS, + filterSimulChar, +} from "@notcc/logic" +import { CameraType, GameRenderer } from "./GameRenderer" +import { useAtomValue, useSetAtom } from "jotai" +import { tilesetAtom } from "@/components/PreferencesPrompt/TilesetsPrompt" +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "preact/hooks" +import { + CompensatingIntervalTimer, + applyRef, + canvasToBin, + formatTimeLeft, + sleep, + useJotaiFn, +} from "@/helpers" +import { embedReadyAtom, embedModeAtom, pageAtom, levelNAtom } from "@/routing" +import { MobileControls } from "./MobileControls" +import { + InputBuffer, + InputControls, + InputSource, + RepeatState, + inputConfigAtom, + releaseLevelInputs, + setLevelInputs, + useInputCollector, +} from "@/inputs" +import { twJoin, twMerge } from "tailwind-merge" +import { ComponentChildren, Ref, VNode } from "preact" +import { useMediaQuery } from "react-responsive" +import { Inventory } from "./Inventory" +import { + LevelData, + borrowLevelSetGs, + getGlobalLevelModifiersGs, + importantSetAtom, + levelWinInterruptResponseAtom, +} from "@/levelData" +import { goToNextLevelGs } from "@/levelData" +import { trivia } from "@/trivia" +import { LevelControls } from "./Sidebar" +import { + TIMELINE_DEFAULT_IDX, + TIMELINE_PLAYBACK_SPEEDS, + Timeline, + TimelineBox, + TimelineHead, +} from "./Timeline" +import { sfxAtom } from "./PreferencesPrompt/SfxPrompt" +import { protobuf } from "@notcc/logic" +import { + CrashMessage, + NonlegalMessage, + isGlitchKindNonlegal, +} from "./NonLegalMessage" +import { ExplGrade, Grade } from "./Grade" +import { + ReportGrade, + getReportGradesForMetrics, + setScoresAtom, +} from "@/scoresApi" +import { LevelListPrompt, showTimeFractionInMetricsAtom } from "./LevelList" +import { showPromptGs } from "@/prompts" +import { Ht } from "./Ht" +import { PORTRAIT_QUERY } from "@/../tailwind.config" +import { showSavePrompt } from "@/fs" +import { makeFullMapImage } from "./GameRenderer/renderer" + +// A TW unit is 0.25rem +export function twUnit(tw: number): number { + if (!globalThis.window) return Infinity + const rem = parseFloat(getComputedStyle(document.body).fontSize) + return rem * tw * 0.25 +} + +export interface AutoScaleConfig { + tileSize: number + cameraType: CameraType + tilePadding?: [number, number] + twPadding?: [number, number] + safetyCoefficient?: number + subSteps?: number +} + +export function calcScale(args: AutoScaleConfig) { + const sidebar = document.querySelector("#sidebar") + if (!sidebar) return 1 + const sidebarRect = sidebar.getBoundingClientRect() + const availableSize = document.body.getBoundingClientRect() + if (sidebarRect.width > sidebarRect.height) { + availableSize.height -= sidebarRect.height + } else { + availableSize.width -= sidebarRect.width + } + availableSize.width -= twUnit(args.twPadding?.[0] ?? 0) + availableSize.height -= twUnit(args.twPadding?.[1] ?? 0) + availableSize.width *= args.safetyCoefficient ?? 0.97 + availableSize.height *= args.safetyCoefficient ?? 0.97 + + const xTiles = args.cameraType.width + (args.tilePadding?.[0] ?? 0) + const yTiles = args.cameraType.height + (args.tilePadding?.[1] ?? 0) + + const xScale = availableSize.width / (xTiles * args.tileSize) + const yScale = availableSize.height / (yTiles * args.tileSize) + + let subSteps = args.subSteps ?? 1 + subSteps *= window.devicePixelRatio + + const scale = Math.floor(Math.min(xScale, yScale) * subSteps) / subSteps + return scale +} + +export function useAutoScale(args: AutoScaleConfig) { + const [scale, setScale] = useState(1) + function resize() { + setScale(calcScale(args)) + } + useLayoutEffect(() => { + resize() + window.addEventListener("resize", resize) + return () => { + window.removeEventListener("resize", resize) + } + }, [args]) + return scale +} + +type PlayerState = + | "pregame" + | "play" + | "pause" + | "dead" + | "timeout" + | "win" + | "gz" + | "nonlegal" + | "crash" + +type CoverButton = [string, null | (() => void)] + +function Cover(props: { + class: string + buttons: CoverButton[] + focusedButton?: string + children: ComponentChildren +}) { + const focusedRef = useRef(null) + useEffect(() => { + //@ts-ignore Exists only in firefox, looks like declarations don't have it yet + focusedRef.current?.focus({ focusVisible: true }) + }, [focusedRef]) + return ( +
    + {props.children} + {props.buttons.length !== 0 && ( +
    + {props.buttons.map(([name, callback]) => ( + + ))} +
    + )} +
    + ) +} + +function PregameCover(props: { + level: Level + set?: LevelSet + mobile?: boolean + onStart: () => void +}) { + return ( + + {props.set && ( + + {props.set.gameTitle()} #{props.set.currentLevel}: + + )} +

    + {props.level.metadata.title ?? "UNNAMED"} +

    + + by {props.level.metadata.author ?? "???"} + +
    + ) +} + +function PauseCover(props: { onUnpause: () => void }) { + const [triviaIdx] = useState(() => Math.floor(Math.random() * trivia.length)) + return ( + +
    +

    + Paused +

    +
    +
    + Did you know? +
    + {trivia[triviaIdx]} + {/* For the guaranteed space */} + + + {triviaIdx + 1}/{trivia.length} + +
    +
    +
    + ) +} + +function LoseCover(props: { timeout: boolean; onRestart: () => void }) { + return ( + +

    + {props.timeout ? "Ran out of time" : "You lost..."} +

    +
    + ) +} + +function NonlegalCover(props: { + glitch: protobuf.IGlitchInfo + onRestart: () => void +}) { + return ( + +
    +

    Stop! You've violated the law!

    +
    + +
    +
    +
    + ) +} + +function CrashCover(props: { + glitch: protobuf.IGlitchInfo + onRestart: () => void +}) { + return ( + +
    +

    Game crash

    +
    + +
    +
    +
    + ) +} + +interface LevelStatsBoxProps { + metrics: SolutionMetrics + bestMetrics?: SolutionMetrics + grades?: Record<"time" | "score", ReportGrade> + levelN: number + totalMetrics?: SolutionMetrics + showFraction: boolean + showScore: boolean +} + +function LevelStatsBox(props: LevelStatsBoxProps) { + const formatTime: (val: number) => string = props.showFraction + ? time => formatTimeLeft(time, false) + : time => Math.ceil(time / 60).toString() + const bonusPoints = + props.metrics.score - + calculateLevelPoints(props.levelN, Math.ceil(props.metrics.timeLeft / 60)) + return ( +
    +
    +
    Metric
    +
    This run
    +
    Best run
    + {props.grades && ( +
    + Grade + +
    + )} +
    Time
    +
    + {formatTime(props.metrics.timeLeft)}s +
    +
    + {props.bestMetrics + ? `${formatTime(props.bestMetrics.timeLeft)}s` + : "—"} +
    + {props.grades && ( +
    + +
    + )} + {props.showScore && ( + <> +
    Score
    +
    +
    +
    base score
    +
    {props.levelN * 500}pts
    +
    time score
    +
    + {Math.ceil(props.metrics.timeLeft / 60) * 10}pts +
    + {bonusPoints !== 0 && ( + <> +
    bonus score
    +
    {bonusPoints}pts
    + + )} +
    +
    {props.metrics.score}pts
    +
    +
    + {props.bestMetrics ? `${props.bestMetrics.score}pts` : "—"} +
    + {props.grades && ( +
    + +
    + )} + + )} +
    + {props.totalMetrics && ( +
    +
    + Total set time: {Math.ceil(props.totalMetrics.timeLeft / 60)}s +
    +
    Total set score: {props.totalMetrics.score}pts
    +
    + )} +
    + ) +} + +function WinCover(props: { + onNextLevel: () => void + onRestart: () => void + onSetSelector: () => void + onLevelList: () => void + singleLevel: boolean + + levelStats: LevelStatsBoxProps +}) { + const buttons: CoverButton[] = props.singleLevel + ? [ + ["Restart", props.onRestart], + ["Back to set selector", props.onSetSelector], + ["Explode Jupiter", null], + ] + : [ + ["Level list", props.onLevelList], + ["Next level", props.onNextLevel], + ["Explode Jupiter", null], + ] + return ( + +
    +

    You won!

    + +
    +
    + ) +} + +type HintRefFunc = (hint: string | null) => void + +function NormalHintDisplay({ hintRef }: { hintRef: Ref }) { + const hintRefRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el) { + applyRef(hintRef, () => {}) + } else { + applyRef(hintRef, hint => { + el.innerText = hint ?? "" + }) + } + }, + [hintRef] + ) + return ( +
    +
    +
    + ) +} + +function MobileHintCover({ hintRef }: { hintRef: Ref }) { + const hintBoxRef = useRef(null) + const hintPlaceRef = useRef(null) + const [hidden, setHidden] = useState(false) + const hintApplier = useCallback((hint: string | null) => { + const hintBox = hintBoxRef.current + const hintPlace = hintPlaceRef.current + if (!hint) { + if (hintBox) { + hintBox.style.display = "none" + } + } else { + if (hintBox) { + hintBox.style.display = "flex" + } + if (hintPlace) { + hintPlace.innerText = hint + } + } + }, []) + useLayoutEffect(() => { + applyRef(hintRef, hintApplier) + }, [hintApplier]) + return ( +
    +
    + + +
    +
    + ) +} + +export function DumbLevelPlayer(props: { + level: LevelData + levelSet?: LevelSet + controlsRef?: Ref + filterSimulChar?: boolean + endOnNonlegalGlitch?: boolean + speedMult?: number + levelFinished?: () => void + ignoreBonusFlags?: boolean +}) { + const tileset = useAtomValue(tilesetAtom)! + const sfx = useAtomValue(sfxAtom) + useEffect(() => { + return () => sfx?.stopAllSfx() + }, [sfx]) + // if (!tileset) return
    No tileset loaded.
    + + const [playerState, setPlayerState] = useState("pregame" as PlayerState) + const isPregameRef = useRef(false) + useLayoutEffect(() => { + isPregameRef.current = playerState === "pregame" + }, [playerState]) + + useEffect(() => { + if (playerState === "pause") sfx?.pause() + else sfx?.unpause() + }, [playerState, sfx]) + + // Inputs & LevelState + + const [level, setLevel] = useState(() => props.level.initLevel()) + const mobileControls = "ontouchstart" in window + const baseInputConfig = useAtomValue(inputConfigAtom) + const inputConfig = useMemo(() => { + if (!mobileControls) return baseInputConfig + const config = structuredClone(baseInputConfig) + config.seats[0].mappings.unshift({ + enabled: true, + source: "touch", + codes: KEY_INPUTS, + }) + return config + }, [baseInputConfig, mobileControls]) + + const mobileControlInputsRef = useRef() + const extraSources = useMemo(() => { + if (!mobileControls) return [] + return [ + (controls => { + mobileControlInputsRef.current = controls + return () => {} + }) satisfies InputSource, + ] + }, [mobileControls]) + + const inputBuffer = useMemo(() => new InputBuffer(), [level]) + const inputCallback = useCallback( + (input: KeyInputs, player: number, state: RepeatState) => { + if (player !== 0) return + if (isPregameRef.current) { + beginLevelAttempt() + isPregameRef.current = false + } + inputBuffer.feedEvent(input, state) + }, + [inputBuffer] + ) + useInputCollector(inputConfig, inputCallback, extraSources) + + const playerSeat = useMemo(() => level.playerSeats[0], [level]) + + const getGlobalLevelModifiers = useJotaiFn(getGlobalLevelModifiersGs) + const resetLevel = useCallback( + (modifiers?: LevelModifiers) => { + sfx?.stopAllSfx() + setWinInterruptResponse(null) + setAttempt(null) + setReplay(null) + const level = props.level.initLevel() + applyLevelModifiers( + level, + modifiers ?? { + ...(getGlobalLevelModifiers() ?? {}), + blobMod: level.metadata.rngBlobDeterministic + ? DETERMINISTIC_BLOB_MOD + : Math.floor(Math.random() * 0x100), + } + ) + // @ts-ignore + globalThis.NotCC.player = { level } + setLevel(level) + setPlayerState("pregame") + processedPostLevelStuffRef.current = false + if (props.levelSet) { + setBestMetricsBeforeAttempt( + findBestMetrics(props.levelSet.currentLevelRecord().levelInfo) + ) + } + possibleActionsRef.current?.( + level.playerSeats[0].getPossibleActions(level) + ) + return level + }, + [sfx, props.level, props.levelSet] + ) + useLayoutEffect(() => void resetLevel(), [props.level]) + + const renderInventoryRef = useRef<() => void>(null) + + const timeLeftRef = useRef(null) + const chipsLeftRef = useRef(null) + const bonusPointsRef = useRef(null) + const hintRef = useRef(null) + const updateLevelMetrics = useCallback(() => { + timeLeftRef.current!.innerText = `${Math.ceil(level.timeLeft / 60)}s` + chipsLeftRef.current!.innerText = level.chipsLeft.toString() + bonusPointsRef.current!.innerText = `${level.bonusPoints}pts` + hintRef.current?.(playerSeat.displayedHint) + }, [level]) + useLayoutEffect(() => { + updateLevelMetrics() + }, [updateLevelMetrics]) + const hintRefAppl = useCallback( + (ref: HintRefFunc | null) => { + ref?.(playerSeat.displayedHint) + applyRef(hintRef, ref) + }, + [playerSeat] + ) + + // Attempt tracking + const [attempt, setAttempt] = useState(null) + const beginLevelAttempt = useCallback(() => { + setPlayerState("play") + setAttempt( + new AttemptTracker( + 1, + level.rngBlob, + level.randomForceFloorDirection, + props.levelSet?.currentLevelRecord().levelInfo.scriptState ?? undefined + ) + ) + }, [props.levelSet, level]) + const borrowLevelSet = useJotaiFn(borrowLevelSetGs) + const submitLevelAttempt = useCallback(() => { + if (embedMode || !attempt) return + const att = attempt.endAttempt(level) + borrowLevelSet(lSet => { + lSet.logAttemptInfo(att) + setAttempt(null) + }) + }, [attempt, borrowLevelSet]) + const setWinInterruptResponse = useSetAtom(levelWinInterruptResponseAtom) + + // Ticking + const levelN = useAtomValue(levelNAtom) + // Ughh React's inability to immediately noticed changed state is very annoying + const processedPostLevelStuffRef = useRef(false) + + const possibleActionsRef = useRef<(actions: KeyInputs) => void>(null) + + const tickLevel = useCallback(() => { + if (replay) { + level.setProviderInputs(replay) + } else { + if (props.filterSimulChar) { + inputBuffer.inputs = filterSimulChar(inputBuffer.inputs) + } + setLevelInputs(level, [inputBuffer]) + } + if (level.gameState === GameState.PLAYING) { + attempt?.recordAttemptStep(level) + } + level.tick() + possibleActionsRef.current?.(playerSeat.getPossibleActions(level)) + releaseLevelInputs(level, [inputBuffer]) + sfx?.processSfxField(level.sfx) + if (level.gameState === GameState.PLAYING) { + renderInventoryRef.current?.() + if (replay) { + setSolutionLevelProgress(replay.inputProgress(level.subticksPassed())) + } + updateLevelMetrics() + if (props.endOnNonlegalGlitch) { + for (const glitch of level.glitches) { + if (isGlitchKindNonlegal(glitch.glitchKind)) { + setCaughtGlitch(glitch.toGlitchInfo()) + setPlayerState("nonlegal") + } + } + } + } else if (playerState === "play") { + if (processedPostLevelStuffRef.current) return + processedPostLevelStuffRef.current = true + submitLevelAttempt() + if (level.gameState !== GameState.CRASH) { + props.levelFinished?.() + } + + if (level.gameState === GameState.WON) { + setPlayerState("win") + setWinInterruptResponse(winInterruptResponseFromLevel(level)) + setSolutionMetrics({ + // FIXME: Wrong but idc rn + realTime: 0, + score: calculateLevelPoints( + levelN!, + Math.ceil(level.timeLeft / 60), + level.bonusPoints + ), + timeLeft: level.timeLeft, + }) + } else if (level.gameState === GameState.DEATH) { + setPlayerState("dead") + } else if (level.gameState === GameState.TIMEOUT) { + setPlayerState("timeout") + } else if (level.gameState === GameState.CRASH) { + setCaughtGlitch( + [...level.glitches].find(gl => gl.isCrashing())?.toGlitchInfo() ?? + null + ) + setPlayerState("crash") + } + } + }, [ + level, + inputBuffer, + submitLevelAttempt, + playerState, + attempt, + props.endOnNonlegalGlitch, + props.levelFinished, + props.filterSimulChar, + levelN, + ]) + + // Report embed ready + const embedMode = useAtomValue(embedModeAtom) + const setEmbedReady = useSetAtom(embedReadyAtom) + useEffect(() => { + if (!embedMode) return + setEmbedReady(true) + }, [embedMode]) + + // Pregame + useEffect(() => { + return () => { + setWinInterruptResponse(null) + } + }, []) + // Replay + const [replay, setReplay] = useState(null) + const [solutionIsPlaying, setSolutionIsPlaying] = useState(true) + const [solutionSpeedIdx, setSolutionSpeedIdx] = useState(TIMELINE_DEFAULT_IDX) + const [solutionLevelProgress, setSolutionLevelProgress] = useState(0) + const [solutionJumpProgress, setSolutionJumpProgress] = useState< + number | null + >(null) + const solutionJumpTo = useCallback( + async (progress: number) => { + if (!replay) return + let lvl = level + setSolutionLevelProgress(progress) + if (progress < replay.inputProgress(lvl.subticksPassed())) { + lvl = props.level.initLevel() + setLevel(lvl) + } + const WAIT_PERIOD = 20 * 40 + while (replay.inputProgress(lvl.subticksPassed()) < progress) { + lvl.tick() + lvl.tick() + lvl.setProviderInputs(replay) + lvl.tick() + if (lvl.currentTick % WAIT_PERIOD === 0) { + setSolutionJumpProgress(replay.inputProgress(lvl.subticksPassed())) + await sleep(0) + } + } + setSolutionJumpProgress(null) + }, + [level, replay] + ) + const autoTick = + playerState === "play" || playerState === "dead" || playerState === "win" + const tickTimer = useRef(null) + const tickLevelRef = useRef(tickLevel) + const rescheduleTimer = useCallback(() => { + if (!autoTick || (replay && !solutionIsPlaying)) { + tickTimer.current?.cancel() + tickTimer.current = null + return + } + tickLevelRef.current = tickLevel + let timePeriod = 1 / 60 + if (replay) { + timePeriod /= TIMELINE_PLAYBACK_SPEEDS[solutionSpeedIdx] + } + if (props.speedMult) { + timePeriod /= props.speedMult + } + if (tickTimer.current) { + tickTimer.current.adjust(timePeriod) + } else { + tickTimer.current = new CompensatingIntervalTimer( + () => tickLevelRef.current(), + timePeriod + ) + } + }, [ + autoTick, + replay, + solutionIsPlaying, + solutionSpeedIdx, + tickLevel, + props.speedMult, + ]) + + useLayoutEffect(() => { + rescheduleTimer() + }, [rescheduleTimer]) + useEffect(() => { + return () => { + tickTimer.current?.cancel() + tickTimer.current = null + } + }, []) + + const portraitLayout = useMediaQuery({ + query: PORTRAIT_QUERY, + }) + + const cameraType = useMemo( + () => ({ + width: level.metadata.cameraWidth, + height: level.metadata.cameraHeight, + }), + [level] + ) + + const scaleArgs = useMemo( + () => ({ + cameraType, + tileSize: tileset.tileSize, + twPadding: portraitLayout ? [4, replay ? 14 : 6] : [6, replay ? 12 : 4], + tilePadding: portraitLayout ? [0, 2] : [4, 0], + }), + [cameraType, tileset, portraitLayout, replay] + ) + + // Level stats + + const setScores = useAtomValue(setScoresAtom) + const importantSet = useAtomValue(importantSetAtom) + const levelScores = useMemo( + () => + setScores?.result === "resolve" && + setScores.value.find(lvl => lvl.level === props.levelSet?.currentLevel), + [props.levelSet?.currentLevel] + ) + const [solutionMetrics, setSolutionMetrics] = + useState(null) + const [bestMetricsBeforeAttempt, setBestMetricsBeforeAttempt] = + useState(null) + + const showTimeFraction = useAtomValue(showTimeFractionInMetricsAtom) + + const winStats = useMemo(() => { + if (!solutionMetrics) return null + const stats: LevelStatsBoxProps = { + metrics: solutionMetrics, + levelN: levelN!, + showFraction: showTimeFraction, + showScore: importantSet?.scoreboardHasScores ?? true, + } + if (bestMetricsBeforeAttempt) { + stats.bestMetrics = bestMetricsBeforeAttempt + } + if (props.levelSet) { + stats.totalMetrics = props.levelSet.totalMetrics() + } + if (levelScores) { + stats.grades = getReportGradesForMetrics( + { + score: Math.max(stats.bestMetrics?.score ?? 0, solutionMetrics.score), + timeLeft: Math.max( + stats.bestMetrics?.timeLeft ?? 0, + solutionMetrics.timeLeft + ), + realTime: Math.min( + stats.bestMetrics?.realTime ?? Infinity, + solutionMetrics.realTime + ), + }, + levelScores + ) + } + return stats + }, [solutionMetrics, props.levelSet, levelN]) + + // GUI stuff + const scale = useAutoScale(scaleArgs) + const goToNextLevel = useJotaiFn(goToNextLevelGs) + const setPage = useSetAtom(pageAtom) + const [caughtGlitch, setCaughtGlitch] = useState( + null + ) + const showPrompt = useJotaiFn(showPromptGs) + + let cover: VNode | null + if (playerState === "pregame") { + cover = ( + + ) + } else if (playerState === "dead" || playerState === "timeout") { + cover = ( + + ) + } else if (playerState === "win") { + cover = ( + setPage("")} + onLevelList={() => showPrompt(LevelListPrompt)} + onRestart={resetLevel} + singleLevel={!props.levelSet} + levelStats={winStats!} + /> + ) + } else if (playerState === "pause") { + cover = setPlayerState("play")} /> + } else if (playerState === "nonlegal") { + cover = + } else if (playerState === "crash") { + cover = + } else if (playerState === "play" && portraitLayout) { + cover = + } else { + cover = null + } + + const saveMapScreenshot = useCallback(async () => { + const mapCanvas = makeFullMapImage(level, tileset) + const mapImage = await canvasToBin(mapCanvas) + showSavePrompt(mapImage, "Save full map image", { + filters: [{ name: "Image file", extensions: ["png"] }], + defaultPath: `${level.metadata.title ?? "UNTITLED"} map.png`, + }) + }, [level, tileset]) + + useEffect(() => { + const controls: LevelControls = { + restart() { + resetLevel() + }, + async playInputs(ip) { + const level = resetLevel() + applyLevelModifiers(level, ip.levelModifiers()) + setSolutionSpeedIdx(TIMELINE_DEFAULT_IDX) + setReplay(ip) + setPlayerState("play") + }, + + pause: + playerState === "pause" + ? () => setPlayerState("play") + : playerState === "play" + ? () => setPlayerState("pause") + : undefined, + saveMapScreenshot, + } + applyRef(props.controlsRef, controls) + return () => { + applyRef(props.controlsRef, null) + } + }, [props.controlsRef, playerState, saveMapScreenshot]) + + return ( +
    +
    +
    + +
    {cover}
    +
    + {replay && ( + + + {solutionJumpProgress !== null && ( + + )} + + + + )} +
    +
    +
    +
    + Chips left: +
    +
    +
    + Time left: +
    +
    +
    + Bonus points: +
    +
    +
    + {playerSeat && ( +
    + +
    + )} + +
    + {mobileControls && ( +
    + +
    + )} +
    + ) +} diff --git a/gamePlayer/src/components/ErrorBox.tsx b/gamePlayer/src/components/ErrorBox.tsx new file mode 100644 index 00000000..34491a46 --- /dev/null +++ b/gamePlayer/src/components/ErrorBox.tsx @@ -0,0 +1,12 @@ +import { memo } from "preact/compat" + +export const ErrorBox = memo(({ error: err }: { error: Error }) => ( +
    + {err.name}: {err.message} +
    + {err.stack && `Stack trace: ${err.stack}`} +
    +)) diff --git a/gamePlayer/src/components/Expl.tsx b/gamePlayer/src/components/Expl.tsx new file mode 100644 index 00000000..2ffbb4f4 --- /dev/null +++ b/gamePlayer/src/components/Expl.tsx @@ -0,0 +1,46 @@ +import { useJotaiFn } from "@/helpers" +import { PromptComponent, showPromptGs } from "@/prompts" +import { ComponentChildren } from "preact" +import { HTMLAttributes } from "preact/compat" +import { Dialog } from "./Dialog" + +interface ExplDialogProps { + children: ComponentChildren + title?: ComponentChildren +} + +function ExplButton(props: HTMLAttributes) { + return ( +
    + ? +
    + ) +} + +const ExplPrompt = + (props: ExplDialogProps): PromptComponent => + pProps => ( + Expl{props.title && <>: {props.title}}} + buttons={[["Ok", () => {}]]} + onResolve={pProps.onResolve} + > +
    {props.children}
    +
    + ) + +export function Expl(props: ExplDialogProps) { + const showPrompt = useJotaiFn(showPromptGs) + return ( + { + ev.preventDefault() + ev.stopPropagation() + showPrompt(ExplPrompt(props)) + }} + /> + ) +} diff --git a/gamePlayer/src/components/GameRenderer/artSetSpecials.ts b/gamePlayer/src/components/GameRenderer/artSetSpecials.ts new file mode 100644 index 00000000..bf28fbb2 --- /dev/null +++ b/gamePlayer/src/components/GameRenderer/artSetSpecials.ts @@ -0,0 +1,613 @@ +import { Actor, BasicTile, Direction, Level } from "@notcc/logic" +import { + Art, + ArtContext, + Frame, + Position, + Renderer, + Size, + SpecialArt, + actorToDir, +} from "./renderer" + +export const stateFuncs: Record< + string, + (actor: Actor | BasicTile, level: Level) => string +> = {} + +export function registerStateFunction( + id: string, + func: (actor: T, level: Level) => string +): void { + stateFuncs[id] = func as (typeof stateFuncs)[string] +} + +export const specialFuncs: Record< + string, + ( + this: Renderer, + ctx: ArtContext, + level: Level, + actor: Actor | BasicTile, + art: SpecialArt + ) => void +> = {} + +export function registerSpecialFunction< + T extends BasicTile | Actor = BasicTile, +>( + id: string, + func: ( + this: Renderer, + ctx: ArtContext, + level: Level, + actor: T, + art: SpecialArt + ) => void +): void { + specialFuncs[id] = func as (typeof specialFuncs)[string] +} + +function getPlayableState(actor: Actor): string { + // const inWater = actor.tile.findActor(actor => actor.hasTag("water")) + // if (inWater) return "water" + if (actor.customData & 0x2n) return "bump" + return "normal" +} + +registerStateFunction("chip", getPlayableState) +registerStateFunction("melinda", getPlayableState) + +function animationStateFunction(actor: Actor): string { + return (4n - (actor.customData + 3n) / 4n).toString() +} + +registerStateFunction("splashAnim", animationStateFunction) +registerStateFunction("explosionAnim", animationStateFunction) + +interface PerspectiveSpecialArt extends SpecialArt { + somethingUnderneathOnly?: boolean + default: Art + revealed: Art +} + +registerSpecialFunction( + "perspective", + function (ctx, level, actor, art) { + const spArt = art as PerspectiveSpecialArt + let perspective = this.hasPerspective() + if (perspective && spArt.somethingUnderneathOnly) { + const pos = actor.position + const tile = level.getCell(pos[0], pos[1]) + perspective = !!( + tile.itemMod || + tile.item || + tile.terrain!.type.name !== "floor" || + tile.terrain!.customData !== 0n + ) + } + this.drawArt(ctx, actor, perspective ? spArt.revealed : spArt.default) + } +) +registerStateFunction( + "iceCorner", + // @ts-ignore This is blatantly incorrect + actor => Direction[actor.customData] +) +registerStateFunction( + "forceFloor", + // @ts-ignore This is blatantly incorrect + actor => Direction[actor.customData] +) +registerStateFunction( + "swivel", + // @ts-ignore This is blatantly incorrect + actor => Direction[actor.customData] +) + +interface ScrollingSpecialArt extends SpecialArt { + duration: number + UP: [Frame, Frame] + DOWN: [Frame, Frame] + RIGHT: [Frame, Frame] + LEFT: [Frame, Frame] +} + +registerSpecialFunction( + "scrolling", + function (ctx, _level, actor, art) { + const spArt = art as ScrollingSpecialArt + const offsetMult = (ctx.ticks / spArt.duration) % 1 + // @ts-ignore This is blatantly incorrect + const baseFrames = spArt[Direction[actor.customData] as "UP"] + const offset: Frame = [ + baseFrames[1][0] - baseFrames[0][0], + baseFrames[1][1] - baseFrames[0][1], + ] + const frame: Frame = [ + baseFrames[0][0] + offset[0] * offsetMult, + baseFrames[0][1] + offset[1] * offsetMult, + ] + this.tileBlit(ctx, [0, 0], frame) + } +) + +registerStateFunction("invisibleWall", (actor, level) => + actor.customData - BigInt(level.subticksPassed()) > 0 ? "touched" : "default" +) +registerStateFunction( + "bonusFlag", + actor => + (actor.customData & 0x8000n ? "x" : "") + + (actor.customData & 0x7fffn).toString() +) +function mapCustomTile(data: bigint) { + return { 0: "green", 1: "pink", 2: "yellow", 3: "blue" }[ + (data % 4n).toString() + ] as string +} + +registerStateFunction("customWall", actor => + mapCustomTile(actor.customData) +) +registerStateFunction("customFloor", actor => + mapCustomTile(actor.customData) +) +registerStateFunction("dynamiteLit", actor => + Math.floor((Number(actor.customData) / 256) * 4).toString() +) +registerStateFunction("flameJet", actor => + actor.customData & 0x1n ? "on" : "off" +) +// +interface FreeformWiresSpecialArt extends SpecialArt { + base: Frame + overlap: Frame + overlapCross: Frame +} + +registerSpecialFunction( + "freeform wires", + function (ctx, level, tile, art) { + const spArt = art as FreeformWiresSpecialArt + const wires = tile.customData & 0x0fn + const wireTunnels = (tile.customData & 0xf0n) >> 4n + this.tileBlit(ctx, [0, 0], spArt.base) + // If we don't have anything else to draw, don't draw the overlay + // TODO: Wire tunnels are drawn on top of everything else, so maybe they + // don't cause the base to be drawn? + if (wires === 0n && wireTunnels === 0n) { + return + } + if (level.metadata.wiresHidden) { + return + } + const poweredWires = tile.getCell().poweredWires + const crossWires = wires === 0xfn + if (crossWires) { + this.drawWireBase( + ctx, + [0, 0], + wires & 0b0101n, + (poweredWires & 0b0101) !== 0 + ) + this.drawWireBase( + ctx, + [0, 0], + wires & 0b1010n, + (poweredWires & 0b1010) !== 0 + ) + } else { + this.drawWireBase(ctx, [0, 0], wires, poweredWires !== 0) + } + this.tileBlit(ctx, [0, 0], crossWires ? spArt.overlapCross : spArt.overlap) + this.drawCompositionalSides( + ctx, + [0, 0], + this.tileset.art.wireTunnel, + 0.25, + wireTunnels + ) + } +) +interface ArrowsSpecialArt extends SpecialArt { + UP: Frame + RIGHT: Frame + DOWN: Frame + LEFT: Frame + CENTER: Frame +} + +registerSpecialFunction( + "arrows", + function (ctx, _level, tile, art) { + const spArt = art as ArrowsSpecialArt + this.drawCompositionalSides(ctx, [0, 0], spArt, 0.25, tile.customData) + this.tileBlit(ctx, [0.25, 0.25], spArt.CENTER, [0.5, 0.5]) + } +) +interface FuseSpecialArt extends SpecialArt { + duration: number + frames: Frame[] +} + +registerSpecialFunction("fuse", function (ctx, _level, _tile, art) { + const spArt = art as FuseSpecialArt + const frameN = Math.floor( + spArt.frames.length * ((ctx.ticks / spArt.duration) % 1) + ) + this.tileBlit(ctx, [0.5, 0], spArt.frames[frameN], [0.5, 0.5]) +}) + +interface ThinWallsSpecialArt extends SpecialArt { + UP: Frame + RIGHT: Frame + DOWN: Frame + LEFT: Frame +} +registerSpecialFunction( + "thin walls", + function (ctx, _level, tile, art) { + const spArt = art as ThinWallsSpecialArt + this.drawCompositionalSides(ctx, [0, 0], spArt, 0.5, tile.customData) + } +) +// +registerStateFunction("thinWall", tile => + tile.customData & 0x10n ? "canopy" : "nothing" +) +// +registerStateFunction("blueWall", actor => + actor.customData & 0x10000n ? "real" : "fake" +) +registerStateFunction("greenWall", actor => { + const cell = actor.getCell() + return actor.customData + ? "real" + : cell.item || cell.actor + ? "stepped" + : "fake" +}) +registerStateFunction("toggleWall", (actor, level) => + !!actor.customData != level.toggleWallInverted ? "on" : "off" +) +registerStateFunction("holdWall", actor => + actor.customData ? "on" : "off" +) +registerStateFunction("trap", actor => + actor.customData & 1n ? "open" : "closed" +) +registerStateFunction("teleportRed", tile => { + const cell = tile.getCell() + return !(cell.isWired && tile.customData & 0xfn) || cell.poweredWires !== 0 + ? "on" + : "off" +}) +registerStateFunction("transmogrifier", tile => { + const cell = tile.getCell() + return !(cell.isWired && tile.customData & 0xfn) || cell.poweredWires !== 0 + ? "on" + : "off" +}) +registerStateFunction("toggleSwitch", actor => + actor.customData & 0x10n ? "on" : "off" +) +// +interface StretchSpecialArt extends SpecialArt { + idle: Art + vertical: Frame[] + horizontal: Frame[] +} + +registerSpecialFunction("stretch", function (ctx, _level, actor, art) { + const spArt = art as StretchSpecialArt + if (actor.moveProgress === 0) { + this.drawArt(ctx, actor, spArt.idle) + return + } + const pos = [0, 0] + // Have to manually undo the visual offset which is applied by default + const builtinOffset = actor.getVisualOffset() + pos[0] -= builtinOffset[0] + pos[1] -= builtinOffset[1] + let frames: Frame[] + let framesReversed: boolean + const dir = actor.direction + let offset: Position = [0, 0] + let cropSize: Size + if (dir === Direction.UP) { + frames = spArt.vertical + framesReversed = true + cropSize = [1, 2] + } else if (dir === Direction.RIGHT) { + frames = spArt.horizontal + framesReversed = false + offset = [-1, 0] + cropSize = [2, 1] + } else if (dir === Direction.DOWN) { + frames = spArt.vertical + framesReversed = false + offset = [0, -1] + cropSize = [1, 2] + } else { + // Direction.LEFT + frames = spArt.horizontal + framesReversed = true + cropSize = [2, 1] + } + let progress = actor.moveProgress / actor.moveLength + if (framesReversed) { + progress = 1 - progress + } + const frame = frames[Math.floor(progress * frames.length)] + this.tileBlit(ctx, [pos[0] + offset[0], pos[1] + offset[1]], frame, cropSize) +}) +// +// registerSpecialFunction("voodoo", function (ctx, actor) { +// if (actor.tileOffset === null) return +// const pos = actor.getVisualPosition() +// const frame: Frame = [ +// actor.tileOffset % 0x10, +// Math.floor(actor.tileOffset / 0x10), +// ] +// this.tileBlit(ctx, pos, frame) +// }) +// +interface RailroadSpecialArt extends SpecialArt { + toggleMark: Frame + wood: Record + rail: Record + toggleRail: Record +} + +const RailroadFlags = { + TRACK_UR: 0x01n, + TRACK_RD: 0x02n, + TRACK_DL: 0x04n, + TRACK_LU: 0x08n, + TRACK_LR: 0x10n, + TRACK_UD: 0x20n, + TRACK_MASK: 0x3fn, + TRACK_SWITCH: 0x40n, + ACTIVE_TRACK_MASK: 0xf00n, + ENTERED_DIR_MASK: 0xf000n, +} + +function* railroadTracks(customData: bigint) { + for (let trackIdx = 0n; trackIdx < 6n; trackIdx += 1n) { + if (customData & (1n << trackIdx)) yield trackIdx + } +} + +registerSpecialFunction( + "railroad", + function (ctx, _level, tile, art) { + const spArt = art as RailroadSpecialArt + const isSwitch = (tile.customData & RailroadFlags.TRACK_SWITCH) != 0n + const activeTrackIdx = + (tile.customData & RailroadFlags.ACTIVE_TRACK_MASK) >> 8n + const activeTrack = 1n << activeTrackIdx + for (const dir of railroadTracks(tile.customData)) { + this.tileBlit(ctx, [0, 0], spArt.wood[dir as unknown as number]) + } + for (const dir of railroadTracks(tile.customData)) { + if (isSwitch) { + if (dir === activeTrackIdx) continue + this.tileBlit(ctx, [0, 0], spArt.toggleRail[dir as unknown as number]) + } else { + this.tileBlit(ctx, [0, 0], spArt.rail[dir as unknown as number]) + } + } + if (isSwitch && activeTrack & tile.customData) { + this.tileBlit( + ctx, + [0, 0], + spArt.rail[activeTrackIdx as unknown as number] + ) + } + if (isSwitch) { + this.tileBlit(ctx, [0, 0], spArt.toggleMark) + } + } +) +// +const roverEmulationPattern = [ + "teethRed", + "glider", + "ant", + "ball", + "teethBlue", + "fireball", + "centipede", + "walker", +] +registerStateFunction( + "rover", + actor => + roverEmulationPattern[ + ((actor.customData & 0xff00n) >> 8n) as unknown as number + ] +) +// +interface RoverAntennaSpecialArt extends SpecialArt { + UP: Frame + RIGHT: Frame + DOWN: Frame + LEFT: Frame +} + +registerSpecialFunction( + "rover antenna", + function (ctx, _level, actor, art) { + const spArt = art as RoverAntennaSpecialArt + const frame = spArt[actorToDir(actor)] + this.tileBlit(ctx, [0.25, 0.25], frame, [0.5, 0.5]) + } +) +// +registerSpecialFunction("letters", function (ctx, _level, actor) { + let letter: string + if (actor.customData >= 0x20n) { + letter = String.fromCharCode(Number(actor.customData)) + } else { + letter = Direction[(actor.customData - 0x1bn).toString() as "1"] + } + // A space doesn't render anything + if (letter === " ") return + const frame = this.tileset.art.letters[letter] + this.tileBlit(ctx, [0.25, 0.25], frame, [0.5, 0.5]) +}) +// +registerStateFunction("greenBomb", (actor, level) => + !!actor.customData != level.toggleWallInverted ? "echip" : "bomb" +) +// +// interface CounterSpecialArt extends SpecialArt { +// 0: Frame +// 1: Frame +// 2: Frame +// 3: Frame +// 4: Frame +// 5: Frame +// 6: Frame +// 7: Frame +// 8: Frame +// 9: Frame +// "-": Frame +// "": Frame +// } +// +// registerSpecialFunction("counter", function (ctx, actor, art) { +// const spArt = art as CounterSpecialArt +// const pos = actor.getVisualPosition() +// this.tileBlit( +// ctx, +// [pos[0] + 0.125, pos[1]], +// spArt[actor.memory as unknown as "0"], +// [0.75, 1] +// ) +// }) +// +type LogicGateArtEntry = + | { + type?: "normal" | "not" + UP: Frame + RIGHT: Frame + DOWN: Frame + LEFT: Frame + } + | { type: "counter"; bottom: Frame; [arg: number]: Frame } + +interface LogicGateSpecialArt extends SpecialArt { + types: Record + base: Frame +} + +// NOTE: This differs from the `tiles.c` implementation, left/right are mirrored here so that we don't have to mirror the logic gate direction separately +function rotateWireBitsMirrored(bits: bigint, dir: Direction): bigint { + if (dir == Direction.UP) return bits + if (dir == Direction.LEFT) + return ((bits >> 1n) & 0b0111n) | ((bits << 3n) & 0b1000n) + if (dir == Direction.DOWN) + return ((bits >> 2n) & 0b0011n) | ((bits << 2n) & 0b1100n) + if (dir == Direction.RIGHT) + return ((bits << 1n) & 0b1110n) | ((bits >> 3n) & 0b0001n) + return 0n +} + +function getLogicGateDirection(bits: bigint) { + const wireBits = bits & 0xfn + // Three-wire gates (AND, OR, XOR, NOR, latch, latch mirror) + if (wireBits == 0b1011n) return Direction.UP + if (wireBits == 0b0111n) return Direction.RIGHT + if (wireBits == 0b1110n) return Direction.DOWN + if (wireBits == 0b1101n) return Direction.LEFT + // Two-wire gate (NOT) + if (bits == 0b00101n) return Direction.UP + if (bits == 0b01010n) return Direction.RIGHT + if (bits == 0b10101n) return Direction.DOWN + if (bits == 0b11010n) return Direction.LEFT + // Four-wire gate (counter) + if (wireBits == 0b1111n) return Direction.UP + return Direction.NONE +} +function getLogicGateType(bits: bigint): string | null { + const wireBits = bits & 0xfn + if (wireBits == 0b1010n || wireBits == 0b0101n) return "not" + if (wireBits == 0b1111n) return "counter" + const specifier = (bits & 0x70n) >> 4n + const threeWireLogicGates = [ + "or", + "and", + "nand", + "xor", + "latch", + "latchMirror", + ] + const gate = threeWireLogicGates[Number(specifier)] + if (gate !== undefined) return gate + return null +} + +registerSpecialFunction( + "logic gate", + function (ctx, level, tile, art) { + const spArt = art as LogicGateSpecialArt + if (level.metadata.wiresHidden) { + this.tileBlit(ctx, [0, 0], spArt.base) + return + } + const gateType = getLogicGateType(tile.customData) + if (gateType === null) return + + const direction = getLogicGateDirection(tile.customData) + const cell = tile.getCell() + const poweredWires = tile.customData & 0xfn & BigInt(cell.poweredWires) + + const ent = spArt.types[gateType] + + if (ent.type === "counter") { + this.drawWireBase(ctx, [0, 0], 0xfn, false) + this.drawWireBase(ctx, [0, 0], poweredWires, true) + this.tileBlit(ctx, [0, 0], ent.bottom) + const value = (tile.customData & 0xf0n) >> 4n + this.tileBlit(ctx, [0.125, 0], ent[Number(value)], [0.75, 1]) + return + } + + if (ent.type === "not") { + this.tileBlit(ctx, [0, 0], spArt.base) + this.drawWireBase(ctx, [0, 0], tile.customData & 0xfn, false) + this.drawWireBase(ctx, [0, 0], poweredWires, true) + this.tileBlit(ctx, [0, 0], ent[Direction[direction] as "UP"]) + return + } + + // Figure out which wires correspond to the which gate parts. + const gateHead = rotateWireBitsMirrored(0b0001n, direction) + const gateRight = rotateWireBitsMirrored(0b0010n, direction) + const gateBack = rotateWireBitsMirrored(0b0100n, direction) + const gateLeft = rotateWireBitsMirrored(0b1000n, direction) + + // Blit left and right as if they are also connected to the back, + // to have the bends in some tilesets + // Draw the left side first, the right one has control over the middle + this.drawWireBase( + ctx, + [0, 0], + gateLeft | gateBack, + (gateLeft & poweredWires) !== 0n + ) + this.drawWireBase( + ctx, + [0, 0], + gateRight | gateBack, + (gateRight & poweredWires) !== 0n + ) + + // And last, draw the output + this.drawWireBase(ctx, [0, 0], gateHead, (poweredWires & gateHead) !== 0n) + // Now, just draw the base + this.tileBlit(ctx, [0, 0], ent[Direction[direction] as "UP"]) + } +) diff --git a/gamePlayer/src/cc2ArtSet.ts b/gamePlayer/src/components/GameRenderer/cc2ArtSet.ts similarity index 89% rename from gamePlayer/src/cc2ArtSet.ts rename to gamePlayer/src/components/GameRenderer/cc2ArtSet.ts index c2eeee92..55d7d8df 100644 --- a/gamePlayer/src/cc2ArtSet.ts +++ b/gamePlayer/src/components/GameRenderer/cc2ArtSet.ts @@ -1,13 +1,6 @@ import { ArtSet, frange } from "./renderer" export const cc2ArtSet: ArtSet = { - floor: { - type: "special", - specialType: "freeform wires", - base: [0, 2], - overlap: [8, 26], - overlapCross: [10, 26], - }, currentPlayerMarker: [6, 6], wireBase: [0, 2], wire: [ @@ -91,6 +84,13 @@ export const cc2ArtSet: ArtSet = { LEFT: [15.5, 31], }, artMap: { + floor: { + type: "special", + specialType: "freeform wires", + base: [0, 2], + overlap: [8, 26], + overlapCross: [10, 26], + }, chip: { type: "state", @@ -214,7 +214,7 @@ export const cc2ArtSet: ArtSet = { }, ice: [10, 1], iceCorner: { - type: "directic", + type: "state", UP: [13, 1], RIGHT: [11, 1], DOWN: [12, 1], @@ -290,7 +290,7 @@ export const cc2ArtSet: ArtSet = { 1000: [12, 2], 100: [13, 2], 10: [14, 2], - "*2": [15, 2], + x2: [15, 2], }, customFloor: { type: "state", @@ -308,8 +308,8 @@ export const cc2ArtSet: ArtSet = { }, echip: [11, 3], echipPlus: [11, 3], - tnt: [0, 4], - tntLit: { + dynamite: [0, 4], + dynamiteLit: { type: "state", 0: [0, 4], 1: [1, 4], @@ -352,7 +352,7 @@ export const cc2ArtSet: ArtSet = { DOWN: frange([6, 12], [8, 12]), LEFT: frange([9, 12], [11, 12]), }, - foil: [12, 12], + steelFoil: [12, 12], turtle: { type: "overlay", bottom: { @@ -629,7 +629,7 @@ export const cc2ArtSet: ArtSet = { buttonPurple: { type: "wires", top: [12, 6] }, buttonBlack: { type: "wires", top: [13, 6] }, buttonOrange: [14, 6], - complexButtonYellow: [15, 6], + buttonYellow: [15, 6], buttonGray: [11, 9], teethRed: { @@ -665,7 +665,7 @@ export const cc2ArtSet: ArtSet = { type: "overlay", bottom: [13, 11], top: { - type: "directic", + type: "state", UP: [11, 11], RIGHT: [12, 11], DOWN: [9, 11], @@ -741,7 +741,7 @@ export const cc2ArtSet: ArtSet = { }, }, noSign: [14, 5], - directionalBlock: { + frameBlock: { type: "overlay", bottom: [15, 5], top: { @@ -766,28 +766,28 @@ export const cc2ArtSet: ArtSet = { type: "special", specialType: "railroad", wood: { - UR: [0, 30], - DR: [1, 30], - DL: [2, 30], - UL: [3, 30], - LR: [4, 30], - UD: [5, 30], + 0: [0, 30], + 1: [1, 30], + 2: [2, 30], + 3: [3, 30], + 4: [4, 30], + 5: [5, 30], }, rail: { - UR: [13, 30], - DR: [14, 30], - DL: [15, 30], - UL: [0, 31], - LR: [1, 31], - UD: [2, 31], + 0: [13, 30], + 1: [14, 30], + 2: [15, 30], + 3: [0, 31], + 4: [1, 31], + 5: [2, 31], }, toggleRail: { - UR: [7, 30], - DR: [8, 30], - DL: [9, 30], - UL: [10, 30], - LR: [11, 30], - UD: [12, 30], + 0: [7, 30], + 1: [8, 30], + 2: [9, 30], + 3: [10, 30], + 4: [11, 30], + 5: [12, 30], }, toggleMark: [6, 30], }, @@ -874,15 +874,49 @@ export const cc2ArtSet: ArtSet = { }, timePenalty: [15, 11], timeBonus: [15, 14], - timeToggle: [14, 14], - counterGate: { - type: "wires", - top: { - type: "overlay", - bottom: [14, 26], - top: { - type: "special", - specialType: "counter", + stopwatch: [14, 14], + logicGate: { + type: "special", + specialType: "logic gate", + base: [0, 2], + types: { + not: { + type: "not", + UP: [0, 25], + RIGHT: [1, 25], + DOWN: [2, 25], + LEFT: [3, 25], + }, + and: { + UP: [4, 25], + RIGHT: [5, 25], + DOWN: [6, 25], + LEFT: [7, 25], + }, + or: { UP: [8, 25], RIGHT: [9, 25], DOWN: [10, 25], LEFT: [11, 25] }, + xor: { UP: [12, 25], RIGHT: [13, 25], DOWN: [14, 25], LEFT: [15, 25] }, + latch: { + UP: [0, 26], + RIGHT: [1, 26], + DOWN: [2, 26], + LEFT: [3, 26], + }, + latchMirror: { + UP: [8, 21], + RIGHT: [9, 21], + DOWN: [10, 21], + LEFT: [11, 21], + }, + nand: { + UP: [4, 26], + RIGHT: [5, 26], + DOWN: [6, 26], + LEFT: [7, 26], + }, + counter: { + type: "counter", + bottom: [14, 26], + 0: [0, 3], 1: [0.75, 3], 2: [1.5, 3], @@ -898,63 +932,5 @@ export const cc2ArtSet: ArtSet = { }, }, }, - notGate: { - type: "wires", - top: { - type: "directic", - UP: [0, 25], - RIGHT: [1, 25], - DOWN: [2, 25], - LEFT: [3, 25], - }, - }, - andGate: { - type: "special", - specialType: "logic gate", - UP: [4, 25], - RIGHT: [5, 25], - DOWN: [6, 25], - LEFT: [7, 25], - }, - orGate: { - type: "special", - specialType: "logic gate", - UP: [8, 25], - RIGHT: [9, 25], - DOWN: [10, 25], - LEFT: [11, 25], - }, - xorGate: { - type: "special", - specialType: "logic gate", - UP: [12, 25], - RIGHT: [13, 25], - DOWN: [14, 25], - LEFT: [15, 25], - }, - latchGate: { - type: "special", - specialType: "logic gate", - UP: [0, 26], - RIGHT: [1, 26], - DOWN: [2, 26], - LEFT: [3, 26], - }, - latchGateMirror: { - type: "special", - specialType: "logic gate", - UP: [8, 21], - RIGHT: [9, 21], - DOWN: [10, 21], - LEFT: [11, 21], - }, - nandGate: { - type: "special", - specialType: "logic gate", - UP: [4, 26], - RIGHT: [5, 26], - DOWN: [6, 26], - LEFT: [7, 26], - }, }, } diff --git a/gamePlayer/src/components/GameRenderer/index.tsx b/gamePlayer/src/components/GameRenderer/index.tsx new file mode 100644 index 00000000..7f2695a2 --- /dev/null +++ b/gamePlayer/src/components/GameRenderer/index.tsx @@ -0,0 +1,121 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "preact/hooks" +import { Renderer, Tileset } from "./renderer" +import { Level, PlayerSeat } from "@notcc/logic" +import { Ref as RefG } from "preact" +import { AnimationTimer, applyRef } from "@/helpers" +import { twJoin } from "tailwind-merge" +import { CanvasHTMLAttributes } from "preact/compat" + +export interface CameraType { + width: number + height: number +} + +export interface GameRendererProps + extends CanvasHTMLAttributes { + tileset: Tileset + level: Level | { current: Level } + tileScale?: number + class?: string + autoDraw?: boolean + renderRef?: RefG<(() => void) | null | undefined> + rendererRef?: RefG + cameraType: CameraType + playerSeat: PlayerSeat + forcePerspective?: boolean +} + +export function GameRenderer(props: GameRendererProps) { + const { + tileset, + level, + tileScale, + autoDraw, + renderRef, + rendererRef, + cameraType, + playerSeat, + forcePerspective, + ...canvasProps + } = props + const [canvas, setCanvas] = useState(null) + + const renderer = useMemo(() => new Renderer(props.tileset), []) + useEffect(() => { + applyRef(rendererRef, renderer) + }, [renderer, rendererRef]) + const ctx = useMemo( + () => canvas?.getContext("2d", { alpha: false }), + [canvas] + ) + useLayoutEffect(() => { + renderer.level = + "current" in props.level ? props.level.current : props.level + renderer.cameraSize = props.cameraType + renderer.playerSeat = props.playerSeat + renderer.tileset = props.tileset + renderer.forcePerspective = props.forcePerspective ?? false + if (canvas) { + renderer.updateTileSize(canvas) + renderer.frame(ctx!) + } + }, [ + props.tileset, + props.cameraType, + props.level, + props.playerSeat, + props.forcePerspective, + canvas, + ]) + + useLayoutEffect(() => { + if (!props.autoDraw || !ctx) return + let lastTick = -1 + const timer = new AnimationTimer(() => { + const curTick = + renderer.level!.currentTick * 3 + renderer.level!.currentSubtick + if (lastTick === curTick) return + renderer.frame(ctx!) + lastTick = curTick + }) + return () => timer.cancel() + }, [ctx, props.autoDraw]) + const render = useCallback(() => { + if ("current" in props.level) { + renderer.level = props.level.current + } + if (!ctx) return + renderer.frame(ctx) + }, [ctx, renderer, props.level]) + + useLayoutEffect(() => { + if (!props.renderRef) return + applyRef(props.renderRef, render) + }, [render, props.renderRef]) + + return ( + setCanvas(canvas)} + class={twJoin("[image-rendering:pixelated]", props.class)} + style={{ + width: `${ + props.tileset.tileSize * + props.cameraType.width * + (props.tileScale ?? 1) + }px`, + height: `${ + props.tileset.tileSize * + props.cameraType.height * + (props.tileScale ?? 1) + }px`, + }} + > + ) +} diff --git a/gamePlayer/src/components/GameRenderer/renderer.ts b/gamePlayer/src/components/GameRenderer/renderer.ts new file mode 100644 index 00000000..4c203c4d --- /dev/null +++ b/gamePlayer/src/components/GameRenderer/renderer.ts @@ -0,0 +1,466 @@ +import { + Direction, + // Wires, + Layer, + BasicTile, + PlayerSeat, +} from "@notcc/logic" +import { Actor, Level } from "@notcc/logic" +// import { Layer } from "@notcc/logic" +import { specialFuncs, stateFuncs } from "./artSetSpecials" +import type { CameraType } from "./index" + +export type HTMLImage = HTMLImageElement | HTMLCanvasElement + +export function removeBackground(image: HTMLImage): HTMLImage { + const ctx = document + .createElement("canvas") + .getContext("2d", { willReadFrequently: true }) + if (!ctx) throw new Error("Couldn't create tileset canvas") + ;[ctx.canvas.width, ctx.canvas.height] = [image.width, image.height] + ctx.drawImage(image, 0, 0) + const rawData = ctx.getImageData(0, 0, image.width, image.height) + const maskColor = rawData.data.slice(0, 4) + for (let i = 0; i < rawData.data.length; i += 4) + if ( + rawData.data[i] === maskColor[0] && + rawData.data[i + 1] === maskColor[1] && + rawData.data[i + 2] === maskColor[2] && + rawData.data[i + 3] === maskColor[3] + ) + rawData.data[i + 3] = 0 + + ctx.putImageData(rawData, 0, 0) + return ctx.canvas +} + +export type Frame = [x: number, y: number] +export type Position = [x: number, y: number] +export type Size = [w: number, h: number] + +export function frange(a: Frame, b: Frame): Frame[] { + const frames: Frame[] = [] + const lengthX = b[0] - a[0] + if (a[1] !== b[1]) throw new Error("Can't use `frange` over vertical frames.") + + for (let xi = 0; xi <= Math.abs(lengthX); xi++) { + const x = a[0] + (lengthX > 0 ? xi : -xi) + frames.push([x, a[1]]) + } + return frames +} + +type StaticArt = Frame + +/** Directic is a portmanteau of directional and static */ +interface DirecticArt { + type: "directic" + UP: Frame + RIGHT: Frame + DOWN: Frame + LEFT: Frame +} +interface AnimatedArt { + type: "animated" + duration: number | "steps" + baseFrame?: number + randomizedFrame?: boolean + frames: Frame[] +} +interface DirectionalArt { + type: "directional" + duration: number | "steps" + baseFrame?: number + randomizedFrame?: boolean + UP: Frame[] + RIGHT: Frame[] + DOWN: Frame[] + LEFT: Frame[] +} +interface OverlayArt { + type: "overlay" + bottom: Art + top: Art +} +interface WiresArt { + type: "wires" + base?: Frame + top: Art + alwaysShowTop?: boolean +} +type StateArt = { type: "state" } & { [state: string]: string | Art } + +type SpecialArtVal = + | Art + | Frame[] + | string + | undefined + | boolean + | number + | { [arg: string]: SpecialArtVal } + +export type SpecialArt = { + type: "special" + specialType: string +} & { + [arg: string]: SpecialArtVal +} +export type Art = + | StaticArt + | DirecticArt + | AnimatedArt + | DirectionalArt + | OverlayArt + | WiresArt + | StateArt + | SpecialArt + | null + +export interface ArtSet { + currentPlayerMarker: Frame + wireBase: Frame + wire: [StaticArt, StaticArt] + wireTunnel: DirecticArt + letters: Record + artMap: Record +} + +export interface Tileset { + image: HTMLImage + art: ArtSet + wireWidth: number + tileSize: number +} + +function clamp(value: number, min: number, max: number): number { + if (value < min) return min + if (value > max) return max + return value +} + +export interface ArtContext { + ctx: CanvasRenderingContext2D + ticks: number + offset: Position + tilePos: Position +} + +type DirectionString = "UP" | "RIGHT" | "DOWN" | "LEFT" +export function actorToDir(actor: Actor): DirectionString { + return Direction[actor.direction] as "UP" +} + +function pseudoRandom(a: number, b: number) { + return (1 + ((Math.sin(a * 12.9898 + b * 78.233) * 43758.6453) % 1)) % 1 +} + +export class Renderer { + cameraPosition: Position = [0, 0] + level: Level | null = null + playerSeat: PlayerSeat | null = null + cameraSize: CameraType | null = null + forcePerspective = false + hasPerspective(): boolean { + return this.playerSeat?.hasPerspective() || this.forcePerspective + } + + constructor(public tileset: Tileset) {} + updateTileSize(canvas: HTMLCanvasElement): void { + if (!this.level || !this.cameraSize) + throw new Error("Can't update the tile size without a level!") + canvas.width = this.cameraSize.width * this.tileset.tileSize + canvas.height = this.cameraSize.height * this.tileset.tileSize + } + tileBlit( + { ctx, offset }: ArtContext, + pos: Position, + frame: Frame, + size: Size = [1, 1] + ): void { + const tileSize = this.tileset.tileSize + ctx.drawImage( + this.tileset.image, + Math.round(frame[0] * tileSize), + Math.round(frame[1] * tileSize), + size[0] * tileSize, + size[1] * tileSize, + Math.round((pos[0] + offset[0]) * tileSize), + Math.round((pos[1] + offset[1]) * tileSize), + size[0] * tileSize, + size[1] * tileSize + ) + } + drawWireBase( + ctx: ArtContext, + pos: Position, + wires: bigint, + state: boolean + ): void { + const frame = this.tileset.art.wire[state ? 1 : 0] + const radius = this.tileset.wireWidth / 2 + const cropStart: Position = [0.5 - radius, 0.5 - radius] + const cropEnd: Position = [0.5 + radius, 0.5 + radius] + if (wires & 0x1n) { + cropStart[1] = 0 + } + if (wires & 0x2n) { + cropEnd[0] = 1 + } + if (wires & 0x4n) { + cropEnd[1] = 1 + } + if (wires & 0x8n) { + cropStart[0] = 0 + } + const cropSize: Size = [ + cropEnd[0] - cropStart[0], + cropEnd[1] - cropStart[1], + ] + this.tileBlit( + ctx, + [pos[0] + cropStart[0], pos[1] + cropStart[1]], + [frame[0] + cropStart[0], frame[1] + cropStart[1]], + cropSize + ) + } + /** + * Generalized logic of drawing directional block and clone machine arrows + * @param width The length from the side of the tile to crop to get the + * required tile + */ + drawCompositionalSides( + ctx: ArtContext, + pos: Position, + art: Record, + width: number, + drawnDirections: bigint + ): void { + for ( + let direction = Direction.UP; + direction <= Direction.LEFT; + direction += 1 + ) { + if (!(drawnDirections & (1n << BigInt(direction - 1)))) continue + const offset = + direction === Direction.RIGHT + ? [1 - width, 0] + : direction === Direction.DOWN + ? [0, 1 - width] + : [0, 0] + this.tileBlit( + ctx, + [pos[0] + offset[0], pos[1] + offset[1]], + art[Direction[direction] as DirectionString], + direction === Direction.UP || direction === Direction.DOWN + ? [1, width] + : [width, 1] + ) + } + } + drawStatic(ctx: ArtContext, _actor: Actor | BasicTile, art: StaticArt): void { + this.tileBlit(ctx, [0, 0], art) + } + drawDirectic(ctx: ArtContext, actor: Actor, art: DirecticArt): void { + this.drawArt(ctx, actor, art[actorToDir(actor)]) + } + drawAnimated( + ctx: ArtContext, + actor: Actor | BasicTile, + art: AnimatedArt | DirectionalArt + ): void { + const frames = + art.type === "animated" ? art.frames : art[actorToDir(actor as Actor)] + const duration = art.duration + let framesProgress: number + + if (typeof duration === "number") { + framesProgress = (ctx.ticks / duration) % 1 + } else if (!(actor instanceof Actor)) { + throw new Error(`"steps" frame length used w/ BasicTile`) + } else if (actor.moveProgress !== 0) { + framesProgress = actor.moveProgress / actor.moveLength + } else { + framesProgress = (art.baseFrame ?? 0) / frames.length + } + if (art.randomizedFrame) { + framesProgress += pseudoRandom(ctx.tilePos[0], ctx.tilePos[1]) + framesProgress %= 1 + } + this.drawStatic( + ctx, + actor, + frames[Math.floor(framesProgress * frames.length)] + ) + } + drawOverlay( + ctx: ArtContext, + actor: Actor | BasicTile, + art: OverlayArt + ): void { + this.drawArt(ctx, actor, art.bottom) + this.drawArt(ctx, actor, art.top) + } + drawWires(ctx: ArtContext, actor: BasicTile, art: WiresArt): void { + this.tileBlit(ctx, [0, 0], this.tileset.art.wireBase) + if (this.level!.metadata.wiresHidden && !art.alwaysShowTop) return + if (!this.level!.metadata.wiresHidden) { + const cell = actor.getCell() + const poweredWires = BigInt(cell.poweredWires) + this.drawWireBase(ctx, [0, 0], actor.customData & 0xfn, false) + this.drawWireBase( + ctx, + [0, 0], + poweredWires & actor.customData & 0xfn, + true + ) + } + this.drawArt(ctx, actor, art.top) + } + drawState(ctx: ArtContext, actor: Actor | BasicTile, art: StateArt): void { + const stateFunc = stateFuncs[actor.type.name!] + if (stateFunc === undefined) { + console.warn(`No state function for actor ${actor.type.name}.`) + return + } + + const state = stateFunc(actor, this.level!) + const newArt = art[state] as Art + if (newArt === undefined) { + console.warn(`Unexpected state ${state} for actor ${actor.type.name}.`) + return + } + + this.drawArt(ctx, actor, newArt) + } + drawSpecial(ctx: ArtContext, tile: Actor | BasicTile, art: SpecialArt): void { + const specialFunc = specialFuncs[art.specialType] + if (specialFunc == undefined) { + console.warn( + `No special draw function for specialType ${art.specialType}.` + ) + return + } + + specialFunc.call(this, ctx, this.level!, tile, art) + } + drawArt(ctx: ArtContext, tile: Actor | BasicTile, art: Art): void { + if (!art) return + if (Array.isArray(art)) { + this.drawStatic(ctx, tile, art) + } else if (art.type === "directic") { + if (!(tile instanceof Actor)) + throw new Error("directic art can only be used with actors") + this.drawDirectic(ctx, tile, art) + } else if (art.type === "animated" || art.type === "directional") { + if (art.type === "directional" && !(tile instanceof Actor)) + throw new Error("directional art can only be used with actors") + this.drawAnimated(ctx, tile, art) + } else if (art.type === "overlay") { + this.drawOverlay(ctx, tile, art) + } else if (art.type === "wires") { + this.drawWires(ctx, tile as BasicTile, art) + } else if (art.type === "state") { + this.drawState(ctx, tile, art) + } else if (art.type === "special") { + this.drawSpecial(ctx, tile, art) + } + } + drawTile(ctxSession: ArtContext, tile: Actor | BasicTile): void { + const art = this.tileset.art.artMap[tile.type.name!] + if (art === undefined) { + console.warn(`No art for actor ${tile.type.name}.`) + return + } + if ( + this.level!.playersLeft > this.level!.playerSeats.length && + this.playerSeat?.actor?._ptr == tile._ptr + ) { + this.tileBlit(ctxSession, [0, 0], this.tileset.art.currentPlayerMarker) + } + this.drawArt(ctxSession, tile, art) + } + updateCameraPosition(): void { + if (!this.level || !this.playerSeat || !this.cameraSize) { + return + } + const actor = this.playerSeat.actor + if (!actor) return + const playerPos = actor.position + const playerOffset = actor.getVisualOffset() + playerPos[0] += playerOffset[0] + playerPos[1] += playerOffset[1] + + // Note: the opposite of what you'd expect, since `visualPosition` gives + // absolute positions, so we need to recenter by subtracting the camera + // position, but ArtContext adds offsets, so we need to negate + this.cameraPosition = [ + -clamp( + playerPos[0] + 0.5, + this.cameraSize.width / 2, + this.level.width - this.cameraSize.width / 2 + ) + + this.cameraSize.width / 2, + + -clamp( + playerPos[1] + 0.5, + this.cameraSize.height / 2, + this.level.height - this.cameraSize.height / 2 + ) + + this.cameraSize.height / 2, + ] + } + frame(ctx: CanvasRenderingContext2D): void { + if (!this.level || !this.cameraSize) return + this.updateCameraPosition() + const session: ArtContext = { + ctx, + offset: [0, 0], + ticks: Math.max(0, this.level.subticksPassed()), + tilePos: [0, 0], + } + for (let layer = Layer.TERRAIN; layer >= Layer.SPECIAL; layer -= 1) { + for (let xi = -1; xi <= this.cameraSize.width + 1; xi++) { + for (let yi = -1; yi <= this.cameraSize.height + 1; yi++) { + const x = Math.floor(xi - this.cameraPosition[0]) + const y = Math.floor(yi - this.cameraPosition[1]) + if (x < 0 || y < 0 || x >= this.level.width || y >= this.level.height) + continue + session.offset = [ + this.cameraPosition[0] + x, + this.cameraPosition[1] + y, + ] + session.tilePos = [x, y] + const cell = this.level.getCell(x, y) + if (cell.terrain && layer === Layer.TERRAIN) + this.drawTile(session, cell.terrain) + else if (cell.item && layer === Layer.ITEM) + this.drawTile(session, cell.item) + else if (cell.itemMod && layer === Layer.ITEM_MOD) + this.drawTile(session, cell.itemMod) + else if (cell.actor && layer === Layer.ACTOR) { + const offset = cell.actor.getVisualOffset() + session.offset[0] += offset[0] + session.offset[1] += offset[1] + this.drawTile(session, cell.actor) + } else if (cell.special && layer === Layer.SPECIAL) + this.drawTile(session, cell.special) + } + } + } + } +} + +export function makeFullMapImage( + level: Level, + tileset: Tileset +): HTMLCanvasElement { + const canvas = document.createElement("canvas") + const ctx = canvas.getContext("2d")! + const renderer = new Renderer(tileset) + renderer.level = level + renderer.cameraSize = { width: level.width, height: level.height } + renderer.forcePerspective = true + renderer.updateTileSize(canvas) + renderer.frame(ctx) + return canvas +} diff --git a/gamePlayer/src/components/Grade.tsx b/gamePlayer/src/components/Grade.tsx new file mode 100644 index 00000000..28e81c02 --- /dev/null +++ b/gamePlayer/src/components/Grade.tsx @@ -0,0 +1,111 @@ +import { ComponentChild, ComponentChildren } from "preact" +import { Expl } from "./Expl" +import { ReportGrade } from "@/scoresApi" + +function Bi(props: { children?: ComponentChildren }) { + return ( + + {props.children} + + ) +} +function Sm(props: { children?: ComponentChildren }) { + return {props.children} +} + +const gradeMap: Record = { + "better than bold": [ + B+, + + Bold+ + , + ], + "bold confirm": [ + BC, + + BoldConf + , + ], + "partial confirm": [ + PC, + + PartConf + , + ], + bold: [ + B, + + Bold + , + ], + "better than public": [ + "P+", + <> + Public+ + , + ], + public: [ + "P", + <> + Public + , + ], + solved: [ + "S", + <> + Solved + , + ], + unsolved: [ + "U", + <> + Unsolved + , + ], +} + +export function ExplGrade() { + return ( + +
    +
    Grade
    +
    Meaning
    + +
    + Better than bold. You've achieved a higher score than what is on the + scoreboards! Report it and be part of Chips history! +
    + +
    + Bold Confirm. You're the second person to achieve this score, thus{" "} + confirming the unconfirmed score. +
    + +
    + Partial Confirm. You've achieved a score higher than the highest + confirmed score, but lower than the unconfirmed score, thus confirming + that the unconfirmed score is at least partially real. +
    + +
    Bold. Same as highest known/reported score for this level.
    + +
    + Better than public. This score is better than the score of the public + route, but is less than bold. +
    + +
    Public. This score matches the public route score.
    + +
    Solved. This score is worse than the public route score.
    + +
    Unsolved. This level has not been solved yet.
    +
    +
    + ) +} + +export function Grade(props: { grade: ReportGrade; short?: boolean }) { + return ( + {gradeMap[props.grade][props.short ? 0 : 1]} + ) +} diff --git a/gamePlayer/src/components/Ht.tsx b/gamePlayer/src/components/Ht.tsx new file mode 100644 index 00000000..a855a8bb --- /dev/null +++ b/gamePlayer/src/components/Ht.tsx @@ -0,0 +1,35 @@ +import { preferenceAtom } from "@/preferences" +import { atom, useAtomValue } from "jotai" +import { ComponentChildren } from "preact" + +export type HaikuMode = "on" | "auto" | "off" + +export const haikuModePreferenceAtom = preferenceAtom<"on" | "auto" | "off">( + "haikuMode", + "auto" +) +export const haikuModeAtom = atom((get, _set) => { + const haikuMode = get(haikuModePreferenceAtom) + if (haikuMode === "auto") { + const date = new Date() + return date.getMonth() == 3 && date.getDate() === 1 + } + return haikuMode === "on" +}) + +export function Ht(props: { haiku: string; children: ComponentChildren }) { + const haikuMode = useAtomValue(haikuModeAtom) + if (haikuMode) { + return ( + <> + {props.haiku.split("/").map((str, idx) => ( + <> + {idx === 0 ||
    } + {str} + + ))} + + ) + } + return <>{haikuMode ? props.haiku : props.children} +} diff --git a/gamePlayer/src/components/Inventory.tsx b/gamePlayer/src/components/Inventory.tsx new file mode 100644 index 00000000..93dee989 --- /dev/null +++ b/gamePlayer/src/components/Inventory.tsx @@ -0,0 +1,119 @@ +import { Inventory as InventoryI, PlayerSeat, TileType } from "@notcc/logic" +import { Frame, Tileset } from "./GameRenderer/renderer" +import { Ref } from "preact" +import { useCallback, useEffect, useMemo, useState } from "preact/hooks" +import { applyRef } from "@/helpers" + +export function Inventory(props: { + inventory: InventoryI | PlayerSeat + cc1Boots?: boolean + tileScale: number + tileset: Tileset + renderRef?: Ref<(() => void) | undefined | null> +}) { + const [canvas, setCanvas] = useState(null) + const ctx = useMemo(() => canvas?.getContext("2d"), [canvas]) + const tileSize = props.tileset.tileSize * props.tileScale + const sTileSize = props.tileset.tileSize + const render = useCallback(() => { + if (!ctx || !canvas) return + + function drawFloor(x: number, y: number) { + // TODO Don't tie this to the default ArtSet + const floorFrame = props.tileset.art.artMap.floor as { base: Frame } + ctx!.drawImage( + props.tileset.image, + floorFrame.base[0] * sTileSize, + floorFrame.base[1] * sTileSize, + sTileSize, + sTileSize, + x * sTileSize, + y * sTileSize, + sTileSize, + sTileSize + ) + } + function drawItem(id: string, x: number, y: number) { + let art = props.tileset.art.artMap[id]! + if (!(art instanceof Array)) { + if (art.type !== "animated") { + console.warn(`Art for ${id} too complex for inventory.`) + return + } + art = art.frames[art.baseFrame ?? 0] + } + ctx!.drawImage( + props.tileset.image, + art[0] * sTileSize, + art[1] * sTileSize, + sTileSize, + sTileSize, + x * sTileSize, + y * sTileSize, + sTileSize, + sTileSize + ) + } + const inv = + props.inventory instanceof PlayerSeat + ? props.inventory.actor?.inventory + : props.inventory + if (!inv) return + + canvas.width = 4 * sTileSize + canvas.style.width = `${4 * tileSize}px` + canvas.height = 2 * sTileSize + canvas.style.height = `${2 * tileSize}px` + + function drawItemTile(idx: number, item: TileType | null) { + drawFloor(idx, 0) + if (item) { + drawItem(item.name!, idx, 0) + } + } + drawItemTile(0, inv.item1) + drawItemTile(1, inv.item2) + drawItemTile(2, inv.item3) + drawItemTile(3, inv.item4) + function drawKey(idx: number, keyId: string, keyN: number) { + drawFloor(idx, 1) + if (!keyN || keyN < 1) return + + drawItem(keyId, idx, 1) + if (keyN > 1) { + const digitFrame = props.tileset.art.letters[keyN > 9 ? "+" : keyN] + ctx!.drawImage( + props.tileset.image, + digitFrame[0] * sTileSize, + digitFrame[1] * sTileSize, + sTileSize / 2, + sTileSize / 2, + (idx + 0.5) * sTileSize, + sTileSize * 1.5, + sTileSize / 2, + sTileSize / 2 + ) + } + } + drawKey(0, "keyRed", inv.keysRed) + drawKey(1, "keyBlue", inv.keysBlue) + drawKey(2, "keyYellow", inv.keysYellow) + drawKey(3, "keyGreen", inv.keysGreen) + }, [props.tileset, props.cc1Boots, props.tileScale, ctx, props.inventory]) + useEffect(() => { + render() + }, [render]) + + useEffect(() => { + if (props.renderRef) { + applyRef(props.renderRef, render) + } + }, [props.renderRef, render]) + + return ( + setCanvas(ref)} + > + ) +} diff --git a/gamePlayer/src/components/LevelList.tsx b/gamePlayer/src/components/LevelList.tsx new file mode 100644 index 00000000..6740b791 --- /dev/null +++ b/gamePlayer/src/components/LevelList.tsx @@ -0,0 +1,236 @@ +import { + goToLevelNGs, + goToNextLevelGs, + levelSetAtom, + levelSetChangedAtom, + setIntermissionAtom, +} from "@/levelData" +import { PromptComponent, showPromptGs } from "@/prompts" +import { + FullC2GLevelSet, + SolutionMetrics, + findBestMetrics, + protobuf, +} from "@notcc/logic" +import { useAtomValue, useSetAtom } from "jotai" +import { Dialog } from "./Dialog" +import { useCallback, useMemo } from "preact/hooks" +import { formatTimeLeft, useJotaiFn } from "@/helpers" +import { twJoin } from "tailwind-merge" +import { preferenceAtom } from "@/preferences" +import { + ReportGrade, + getReportGradesForMetrics, + setPlayerScoresAtom, + setScoresAtom, +} from "@/scoresApi" +import { Grade } from "./Grade" +import { ReportGeneratorPrompt } from "./ReportGenerator" + +export const showTimeFractionInMetricsAtom = preferenceAtom( + "showTimeFractionInMetrics", + false +) + +interface LevelListLevel { + metrics: SolutionMetrics | null + info: protobuf.ILevelInfo +} + +export const LevelListPrompt: PromptComponent = pProps => { + const levelSet = useAtomValue(levelSetAtom) + useAtomValue(levelSetChangedAtom) + const levels = useMemo(() => { + return ( + levelSet?.listLevels().map(lvl => ({ + info: lvl.levelInfo, + metrics: findBestMetrics(lvl.levelInfo), + })) ?? [] + ) + }, [levelSet]) + const levelsBeaten = useMemo( + () => levels.reduce((acc, val) => acc + (val.metrics ? 1 : 0), 0), + [levels] + ) + const totalTime = useMemo( + () => + levels.reduce( + (acc, val) => + acc + + (val.metrics?.timeLeft ? Math.ceil(val.metrics.timeLeft / 60) : 0), + 0 + ), + [] + ) + const totalScore = useMemo( + () => levels.reduce((acc, val) => acc + (val.metrics?.score ?? 0), 0), + [] + ) + + const setScores = useAtomValue(setScoresAtom) + const setPlayerScores = useAtomValue(setPlayerScoresAtom) + + const showPrompt = useJotaiFn(showPromptGs) + + const showGrades = setScores && setPlayerScores + + // const _setIsLinear = levelSet instanceof LinearLevelSet + const setIsIncomplete = + levelSet instanceof FullC2GLevelSet && !levelSet.hasReahedPostgame + const goToLevelN = useJotaiFn(goToLevelNGs) + const goToNextLevel = useJotaiFn(goToNextLevelGs) + const showFraction = useAtomValue(showTimeFractionInMetricsAtom) + + interface SetPseudoLevelProps { + type: "prologue" | "epilogue" + levelInfo: protobuf.ILevelInfo + // Need to pass idx so that we can open the level *after* this one for epilogue intermissions + levelIdx: number + } + const setSetIntermission = useSetAtom(setIntermissionAtom) + + const GRID_ROW = "bg-theme-900 col-span-full grid grid-cols-subgrid px-4" + function ScriptPseudoLevel(props: SetPseudoLevelProps) { + const showIntermission = useCallback(async () => { + if (props.type === "epilogue") { + const nextLevel = levels[props.levelIdx + 1] + if (nextLevel === undefined) { + // FIXME: hack: go to the last level and force the intermission to get the post-level state + await goToLevelN(props.levelInfo.levelNumber!) + await goToNextLevel() + } else { + await goToLevelN(nextLevel.info.levelNumber!) + } + } else { + await goToLevelN(props.levelInfo.levelNumber!) + } + + setSetIntermission({ + type: props.type, + text: (props.type === "prologue" + ? props.levelInfo.prologueText + : props.levelInfo.epilogueText)!, + }) + pProps.onResolve() + }, [props]) + + return ( +
    +
    📜︎
    +
    Intermission
    +
    + ) + } + return ( + void showPrompt(ReportGeneratorPrompt)], + ["Close", () => pProps.onResolve()], + ]} + onClose={pProps.onResolve} + > + {/*

    {levelSet?.gameTitle()}

    */} + {/*
    */} +
    +
    +
    #
    +
    Title
    +
    Best time
    +
    Best score
    +
    + {levels.map(({ info, metrics }, idx) => { + const scoresLevel = + setScores?.result === "resolve" && + setScores.value.find(lvl => lvl.level === info.levelNumber) + const grades = + metrics && scoresLevel + ? getReportGradesForMetrics(metrics, scoresLevel) + : { + time: "unsolved" as ReportGrade, + score: "unsolved" as ReportGrade, + } + return ( + <> + {info.prologueText && ( + + )} +
    { + await goToLevelN(info.levelNumber!) + pProps.onResolve() + }} + > +
    {info.levelNumber}
    +
    {info.title}
    +
    + {metrics?.timeLeft != null + ? showFraction + ? formatTimeLeft(metrics.timeLeft, false) + : Math.ceil(metrics.timeLeft / 60) + : "—"} + {showGrades && } +
    +
    + {metrics?.score != null ? metrics.score : "—"} + {showGrades && } +
    +
    + {info.epilogueText && ( + + )} + + ) + })} + {setIsIncomplete && ( +
    + Set list is incomplete; try going forwards from the last level +
    + )} +
    +
    + {levelsBeaten}/{levels.length} solved +
    +
    {totalTime}
    +
    {totalScore}
    +
    +
    +
    + ) +} diff --git a/gamePlayer/src/components/MobileControls/cycle-active.svg b/gamePlayer/src/components/MobileControls/cycle-active.svg new file mode 100644 index 00000000..922e3da1 --- /dev/null +++ b/gamePlayer/src/components/MobileControls/cycle-active.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gamePlayer/src/components/MobileControls/cycle-inactive.svg b/gamePlayer/src/components/MobileControls/cycle-inactive.svg new file mode 100644 index 00000000..3f2bd238 --- /dev/null +++ b/gamePlayer/src/components/MobileControls/cycle-inactive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/gamePlayer/src/components/MobileControls/dpad.svg b/gamePlayer/src/components/MobileControls/dpad.svg new file mode 100644 index 00000000..5d024d1c --- /dev/null +++ b/gamePlayer/src/components/MobileControls/dpad.svg @@ -0,0 +1,3 @@ + + + diff --git a/gamePlayer/src/components/MobileControls/drop-active.svg b/gamePlayer/src/components/MobileControls/drop-active.svg new file mode 100644 index 00000000..1108f9ec --- /dev/null +++ b/gamePlayer/src/components/MobileControls/drop-active.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + diff --git a/gamePlayer/src/components/MobileControls/drop-inactive.svg b/gamePlayer/src/components/MobileControls/drop-inactive.svg new file mode 100644 index 00000000..cee2fc95 --- /dev/null +++ b/gamePlayer/src/components/MobileControls/drop-inactive.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/gamePlayer/src/components/MobileControls/index.tsx b/gamePlayer/src/components/MobileControls/index.tsx new file mode 100644 index 00000000..44828c14 --- /dev/null +++ b/gamePlayer/src/components/MobileControls/index.tsx @@ -0,0 +1,135 @@ +import dpadImage from "./dpad.svg" +import { InputControls } from "@/inputs" +import { KEY_INPUTS, KeyInputs } from "@notcc/logic" +import { RefObject, memo } from "preact/compat" +import { useEffect, useRef } from "preact/hooks" +import dropActiveImage from "./drop-active.svg" +import dropInactiveImage from "./drop-inactive.svg" +import cycleActiveImage from "./cycle-active.svg" +import cycleInactiveImage from "./cycle-inactive.svg" +import switchActiveImage from "./switch-active.svg" +import switchInactiveImage from "./switch-inactive.svg" +import { applyRef } from "@/helpers" + +const DIRECTIONS = ["up", "right", "down", "left"] +export const MOBILE_SOURCE_ID = "touch" + +export const MobileControls = memo(function MobileControls(props: { + inputsRef: RefObject + possibleActionsRef: RefObject<(actions: KeyInputs) => void> +}) { + const inputsRef = useRef([false, false, false, false]) + function updateDpadDeltas(inputs: [boolean, boolean, boolean, boolean]) { + const oldInputs = inputsRef.current + for (const [idx, dir] of DIRECTIONS.entries()) { + if (inputs[idx] && !oldInputs[idx]) + props.inputsRef.current?.on({ source: MOBILE_SOURCE_ID, code: dir }) + + if (!inputs[idx] && oldInputs[idx]) + props.inputsRef.current?.off({ source: MOBILE_SOURCE_ID, code: dir }) + } + inputsRef.current = inputs + } + + const dpadRef = useRef(null) + function handleDpadClick(ev: MouseEvent | TouchEvent) { + if (!dpadRef.current) return + ev.preventDefault() + let x: number, y: number + if (ev instanceof MouseEvent) { + if (!ev.buttons) return + x = ev.offsetX + y = ev.offsetY + } else { + const dpadBox = dpadRef.current.getBoundingClientRect() + const touches = Array.from(ev.targetTouches) + const averageTouch = touches + .map(touch => [touch.pageX - dpadBox.left, touch.pageY - dpadBox.top]) + .reduce((acc, val) => [acc[0] + val[0], acc[1] + val[1]], [0, 0]) + x = averageTouch[0] / touches.length + y = averageTouch[1] / touches.length + } + x /= dpadRef.current.width + y /= dpadRef.current.height + updateDpadDeltas([y < 1 / 3, x > 2 / 3, y > 2 / 3, x < 1 / 3]) + } + function handleDpadRelease() { + updateDpadDeltas([false, false, false, false]) + } + + const secondaryRefs = { + drop: useRef(null), + cycle: useRef(null), + switch: useRef(null), + } + function updatePossibleActions(actions: KeyInputs) { + if (secondaryRefs.drop.current) { + secondaryRefs.drop.current.src = + actions & KEY_INPUTS.dropItem ? dropActiveImage : dropInactiveImage + } + if (secondaryRefs.cycle.current) { + secondaryRefs.cycle.current.src = + actions & KEY_INPUTS.cycleItems ? cycleActiveImage : cycleInactiveImage + } + if (secondaryRefs.switch.current) { + secondaryRefs.switch.current.src = + actions & KEY_INPUTS.switchPlayer + ? switchActiveImage + : switchInactiveImage + } + } + useEffect(() => { + applyRef(props.possibleActionsRef, updatePossibleActions) + }, [props.possibleActionsRef]) + + function handleSecondaryDown( + code: "dropItem" | "cycleItems" | "switchPlayer" + ) { + return () => props.inputsRef.current?.on({ source: MOBILE_SOURCE_ID, code }) + } + function handleSecondaryUp(code: "dropItem" | "cycleItems" | "switchPlayer") { + return () => + props.inputsRef.current?.off({ source: MOBILE_SOURCE_ID, code }) + } + + return ( +
    + + + + +
    + ) +}) diff --git a/gamePlayer/src/components/MobileControls/switch-active.svg b/gamePlayer/src/components/MobileControls/switch-active.svg new file mode 100644 index 00000000..3d6a4e8d --- /dev/null +++ b/gamePlayer/src/components/MobileControls/switch-active.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gamePlayer/src/components/MobileControls/switch-inactive.svg b/gamePlayer/src/components/MobileControls/switch-inactive.svg new file mode 100644 index 00000000..1519d277 --- /dev/null +++ b/gamePlayer/src/components/MobileControls/switch-inactive.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/gamePlayer/src/components/NonLegalMessage.tsx b/gamePlayer/src/components/NonLegalMessage.tsx new file mode 100644 index 00000000..7b5d0c1c --- /dev/null +++ b/gamePlayer/src/components/NonLegalMessage.tsx @@ -0,0 +1,101 @@ +import { protobuf } from "@notcc/logic" +import { Expl } from "./Expl" + +const KnownGlitches = protobuf.GlitchInfo.KnownGlitches + +interface GlitchText { + name: string + prevent?: string +} + +export const glitchNames: Record< + protobuf.GlitchInfo.KnownGlitches, + GlitchText +> = { + [KnownGlitches.INVALID]: { name: "???" }, + [KnownGlitches.DESPAWN]: { name: "Despawn" }, + [KnownGlitches.SIMULTANEOUS_CHARACTER_MOVEMENT]: { + name: "Simultaneous character movement", + prevent: + "Don't hold down any movement keys while switching players. Alternatively, if you're using the normal level player, enable \"Prevent simultaneous character movement\" in preferences.", + }, + [KnownGlitches.DYNAMITE_EXPLOSION_SNEAKING]: { + name: "Dynamite explosion sneaking", + prevent: "Don't move inwards into a dynamite's explosion ring.", + }, + [KnownGlitches.DROP_BY_DESPAWNED]: { + name: "Despawned item dropping", + prevent: + "Don't let any players or monsters drop items when despawned. This may be part of the level's design.", + }, + [KnownGlitches.BLUE_TELEPORT_INFINITE_LOOP]: { + name: "Invalid blue teleport destination travel", + prevent: + "Don't step in the unwired teleporters close to the top-left of the level. Contact the designer of the level about this, as this behavior is most likely unintetional.", + }, +} + +const nonlegalGlitches: protobuf.GlitchInfo.KnownGlitches[] = [ + KnownGlitches.SIMULTANEOUS_CHARACTER_MOVEMENT, + KnownGlitches.DYNAMITE_EXPLOSION_SNEAKING, +] + +const WIKI_URL = "https://wiki.bitbusters.club/" + +export function isGlitchKindNonlegal( + glitchKind: protobuf.GlitchInfo.KnownGlitches +): boolean { + return nonlegalGlitches.includes(glitchKind) +} + +export function NonlegalMessage(props: { glitch: protobuf.IGlitchInfo }) { + const glitchText = glitchNames[props.glitch.glitchKind!] + return ( + <> + {glitchText.name}, a non-scoreboard-legal glitch + + a glitch which is considered by the community to not be allowed for + scored playthroughs + + , has occured. Level solutions which utilize nonlegal glitches are stopped + prematurely to prevent confusion as to which solutions are + scoreboard-legal. This behavior can be changed in preferences. + {glitchText.prevent && ( + <> +
    +
    + To prevent the glitch from occuring, do the following:{" "} + {glitchText.prevent} + + )} +
    +
    + + Read more about the glitch here. + + + ) +} + +export function CrashMessage(props: { glitch: protobuf.IGlitchInfo }) { + const glitchText = glitchNames[props.glitch.glitchKind!] + return ( + <> + {glitchText.name}, a game-crashing glitch, has occured. + In the real game, this would have caused the program to close. + {glitchText.prevent && ( + <> +
    +
    + To prevent this glitch from occuring, do the following:{" "} + {glitchText.prevent} + + )} +
    +
    + + Read more about the glitch here. + + + ) +} diff --git a/gamePlayer/src/components/PreferencesPrompt/Gallery.tsx b/gamePlayer/src/components/PreferencesPrompt/Gallery.tsx new file mode 100644 index 00000000..2944d6bb --- /dev/null +++ b/gamePlayer/src/components/PreferencesPrompt/Gallery.tsx @@ -0,0 +1,80 @@ +import { FC, Suspense } from "preact/compat" +import { twJoin } from "tailwind-merge" + +function GalleryItemC(props: { + selected: boolean + onClick?: () => void + id: string + desc?: string + onRemove?: () => void + Preview: FC<{ id: string }> +}) { + return ( +
    + + {props.desc && {props.desc}} + {props.onRemove && ( + + )} +
    + ) +} + +export interface GalleryItem { + id: string + desc?: string +} + +export interface GalleryProps { + chosenItem: string + onChooseItem: (id: string) => void + onRemoveItem: (id: string) => void + defaultItems: GalleryItem[] + customItems: GalleryItem[] + Preview: FC<{ id: string }> +} + +export function Gallery(props: GalleryProps) { + return ( + +
    + {props.defaultItems.map(item => ( + props.onChooseItem(item.id)} + id={item.id} + desc={item.desc} + Preview={props.Preview} + /> + ))} + {props.customItems.map(item => ( + props.onChooseItem(item.id)} + id={item.id} + desc={item.desc} + Preview={props.Preview} + onRemove={() => props.onRemoveItem(item.id)} + /> + ))} +
    +
    + ) +} diff --git a/gamePlayer/src/components/PreferencesPrompt/SfxPrompt.tsx b/gamePlayer/src/components/PreferencesPrompt/SfxPrompt.tsx new file mode 100644 index 00000000..dc7f02a5 --- /dev/null +++ b/gamePlayer/src/components/PreferencesPrompt/SfxPrompt.tsx @@ -0,0 +1,165 @@ +import { isPreloading, preferenceAtom } from "@/preferences" +import { makeHttpFileLoader, makeBufferMapFileLoader } from "@/setLoading" +import { AudioSfxManager } from "@/sfx" +import { atom, useAtom } from "jotai" +import { atomEffect } from "jotai-effect" +import { Gallery, GalleryItem } from "./Gallery" +import { + readFile, + remove, + writeFile, + showLoadPrompt, + showDirectoryPrompt, +} from "@/fs" +import { suspend } from "suspend-react" +import { useCallback, useState } from "preact/hooks" +import { PromptComponent, showPromptGs } from "@/prompts" +import { unzipAsync, useJotaiFn, zipAsync } from "@/helpers" +import { Dialog } from "../Dialog" +import { PrefDisplayProps } from "." +import { SfxBit } from "@notcc/logic" + +export const DEFAULT_SFXSET = "defo" + +export const sfxAtom = atom(null) +export const sfxIdAtom = preferenceAtom("sfxset", DEFAULT_SFXSET) +export const sfxSyncAtom = atomEffect((get, set) => { + void get(sfxIdAtom) + if (isPreloading(get)) return + getSfxSet(get(sfxIdAtom)).then(val => set(sfxAtom, val)) +}) + +export async function getSfxSet(id: string) { + const sfxMan = new AudioSfxManager() + if (id === "silence") { + } else if (id === "defo" || id === "tworld") { + await sfxMan.loadSfx(makeHttpFileLoader(`./sfx/${id}/`)) + } else { + const sfxZip = await readFile(`/sfx/${id}.zip`) + await sfxMan.loadSfx(makeBufferMapFileLoader(await unzipAsync(sfxZip))) + } + return sfxMan +} + +const DEFAULT_SFXS: GalleryItem[] = [ + { + id: "silence", + }, + { id: "defo", desc: "Bleepy-bloopy SFX. Made with jfxr" }, + { id: "tworld", desc: "The sound effects from Tile World! Wow!" }, +] + +function SfxPreview(props: { id: string }) { + const sfxSet = suspend(() => getSfxSet(props.id), ["sfx preview" + props.id]) + const playRandomSfx = useCallback( + (ev: MouseEvent) => { + ev.stopPropagation() + const availSfx = Object.keys(sfxSet.audioBuffers) + const chosenSfx = availSfx[Math.floor(Math.random() * availSfx.length)] + sfxSet.playOnce(parseInt(chosenSfx) as SfxBit) + }, + [sfxSet] + ) + return ( +
    + + {props.id.startsWith("custom") ? "custom" : props.id} + + +
    + ) +} + +export const customSfxAtom = atom([]) + +export const SfxPrompt = + (currentSfx: string): PromptComponent => + pProps => { + const [chosenSfx, setChosenSfx] = useState(currentSfx) + const [customSfx, setCustomSfx] = useAtom(customSfxAtom) + async function addSfx(sfxZip: ArrayBufferLike) { + const id = `custom-${Date.now()}` + await writeFile(`/sfx/${id}.zip`, sfxZip) + setCustomSfx(arr => arr.concat(id)) + } + async function addSfxZip() { + const sfxZip = await showLoadPrompt("Load SFX zip", { + filters: [{ name: "SFX Zip", extensions: ["zip"] }], + }) + if (!sfxZip?.[0]) return + addSfx(await sfxZip[0].arrayBuffer()) + } + async function addSfxDir() { + const sfxDir = await showDirectoryPrompt("Load SFX directory") + if (!sfxDir) return + const files: Record = {} + for (const file of sfxDir) { + if (file.webkitRelativePath.split("/").length > 2) { + throw new Error("Loaded directory must only contain (audio) files") + } + files[file.name] = new Uint8Array(await file.arrayBuffer()) + } + + const sfxZip = (await zipAsync(files)).buffer + addSfx(sfxZip) + } + const removeSfx = useCallback( + async (id: string) => { + if (chosenSfx === id) { + setChosenSfx(DEFAULT_SFXSET) + } + await remove(`/sfx/${id}.zip`) + setCustomSfx(arr => { + arr.splice(arr.indexOf(id), 1) + return Array.from(arr) + }) + }, + [chosenSfx, customSfx] + ) + return ( + pProps.onResolve(chosenSfx)], + ["Cancel", () => pProps.onResolve(currentSfx)], + ]} + onClose={() => pProps.onResolve(currentSfx)} + > + ({ id }))} + Preview={SfxPreview} + onRemoveItem={removeSfx} + onChooseItem={id => setChosenSfx(id)} + /> + + + + + + ) + } + +export function SfxPrefDisplay({ + set, + value, + inputId, +}: PrefDisplayProps) { + const showPrompt = useJotaiFn(showPromptGs) + return ( + + {value.startsWith("custom") ? "custom" : value}{" "} + + + ) +} diff --git a/gamePlayer/src/components/PreferencesPrompt/TilesetsPrompt.tsx b/gamePlayer/src/components/PreferencesPrompt/TilesetsPrompt.tsx new file mode 100644 index 00000000..16c25dd1 --- /dev/null +++ b/gamePlayer/src/components/PreferencesPrompt/TilesetsPrompt.tsx @@ -0,0 +1,206 @@ +import { PromptComponent, showPromptGs } from "@/prompts" +import { Dialog } from "../Dialog" +import { useCallback, useMemo, useState } from "preact/hooks" +import { Tileset, removeBackground } from "../GameRenderer/renderer" +import { CameraType, GameRenderer } from "../GameRenderer" +import tilesetLevelPath from "./tilesetPreview.c2m" +import { parseC2M } from "@notcc/logic" +import { cc2ArtSet } from "../GameRenderer/cc2ArtSet" +import cga16Image from "@/tilesets/cga16.png" +import tworldImage from "@/tilesets/tworld.png" +import tauriImage from "@/tilesets/tauri.png" +import { + canvasToBin, + fetchImage, + makeImagefromBlob, + readImage, + reencodeImage, + useJotaiFn, +} from "@/helpers" +import { atom, useAtom, useAtomValue } from "jotai" +import { isPreloading, preferenceAtom } from "@/preferences" +import { readFile, remove, writeFile, showLoadPrompt } from "@/fs" +import { suspend } from "suspend-react" +import { PrefDisplayProps } from "." +import { atomEffect } from "jotai-effect" +import { Gallery, GalleryItem } from "./Gallery" + +const PRIMARY_TILE_SIZE = 32 +export const tilesetAtom = atom(null) +export const DEFAULT_TILESET = "tauri" + +export const tilesetIdAtom = preferenceAtom("tileset", DEFAULT_TILESET) +export const tilesetSyncAtom = atomEffect((get, set) => { + void get(tilesetIdAtom) + if (isPreloading(get)) return + getTileset(get(tilesetIdAtom)).then(val => set(tilesetAtom, val)) +}) + +const tilesetLevelAtom = atom(async () => + parseC2M(await (await fetch(tilesetLevelPath)).arrayBuffer()) +) +const tilesetLevelCameraType: CameraType = { width: 5, height: 5 } + +export async function getTileset(id: string): Promise { + if (id === "cga16") + return { + image: await fetchImage(cga16Image), + art: cc2ArtSet, + tileSize: 8, + wireWidth: 2 / 8, + } + if (id === "tworld") + return { + image: await fetchImage(tworldImage), + art: cc2ArtSet, + tileSize: 32, + wireWidth: 2 / 32, + } + if (id === "tauri") + return { + image: await fetchImage(tauriImage), + art: cc2ArtSet, + tileSize: 16, + wireWidth: 2 / 16, + } + + const img = removeBackground( + await readImage(await readFile(`./tilesets/${id}.png`)) + ) + const tileSize = img.width / 16 + return { + image: img, + wireWidth: 2 / 32, + tileSize, + art: cc2ArtSet, + } +} + +function TilesetPreview(props: { id: string }) { + const levelData = useAtomValue(tilesetLevelAtom) + const level = useMemo(() => { + const lvl = levelData.clone() + return lvl + }, [levelData]) + const tileset = suspend( + () => getTileset(props.id), + ["tset preview" + props.id] + ) + return ( + <> + + + {props.id.startsWith("custom") ? "custom" : props.id} + + + ) +} + +const DEFAULT_TSETS: GalleryItem[] = [ + { + id: "tauri", + desc: "Tauri and their friend Radi try to collect chips and reach swirly exits! Will they do it? It's up to the person reading this description!", + }, + { + id: "cga16", + desc: "This is how this game might have looked like if it were released in the 1980s. Not to be confused with the 4-color CC1 tileset also named CGA.", + }, + { + id: "tworld", + desc: "The Tile World tileset, with CC2 additions! Incomplete.", + }, +] + +export const customTsetsAtom = atom([]) + +export const TilesetsPrompt = + (currentTset: string): PromptComponent => + pProps => { + const [chosenTset, setChosenTset] = useState(currentTset) + const [customTsets, setCustomTsets] = useAtom(customTsetsAtom) + async function addTset() { + const imageFiles = await showLoadPrompt("Load tileset image", { + filters: [{ name: "Image file", extensions: ["bmp", "png"] }], + }) + if (!imageFiles?.[0]) return + let img = await makeImagefromBlob(imageFiles[0]) + if (img.width % 8 !== 0 || img.height !== img.width * 2) { + throw new Error( + "Invalid tileset image proportions. Are you sure this a CC2 tileset?" + ) + } + const id = `custom-${Date.now()}` + await writeFile( + `/tilesets/${id}.png`, + await canvasToBin(reencodeImage(img)) + ) + setCustomTsets(arr => arr.concat(id)) + } + const removeTset = useCallback( + async (id: string) => { + if (chosenTset === id) { + setChosenTset(DEFAULT_TILESET) + } + await remove(`/tilesets/${id}.png`) + setCustomTsets(arr => { + arr.splice(arr.indexOf(id), 1) + return Array.from(arr) + }) + }, + [chosenTset, customTsets] + ) + return ( + pProps.onResolve(chosenTset)], + ["Cancel", () => pProps.onResolve(currentTset)], + ]} + onClose={() => pProps.onResolve(currentTset)} + > + ({ id }))} + Preview={TilesetPreview} + onRemoveItem={removeTset} + onChooseItem={id => setChosenTset(id)} + /> + + More tilesets can be + found on{" "} + + the forums + + + + ) + } +export function TilesetPrefDisplay({ + set, + value, + inputId, +}: PrefDisplayProps) { + const showPrompt = useJotaiFn(showPromptGs) + return ( + + {value.startsWith("custom") ? "custom" : value}{" "} + + + ) +} diff --git a/gamePlayer/src/components/PreferencesPrompt/index.tsx b/gamePlayer/src/components/PreferencesPrompt/index.tsx new file mode 100644 index 00000000..92c0e562 --- /dev/null +++ b/gamePlayer/src/components/PreferencesPrompt/index.tsx @@ -0,0 +1,298 @@ +import { PromptComponent } from "@/prompts" +import { Dialog } from "../Dialog" +import { ThemeColor, colorSchemeAtom, listThemeColors } from "@/themeHelper" +import { useId, useMemo } from "preact/hooks" +import { + PrimitiveAtom, + WritableAtom, + atom, + useAtom, + useAtomValue, + useStore, +} from "jotai" +import { FC } from "preact/compat" +import { + DEFAULT_VALUE, + getTruePreferenceAtom, + resetDissmissablePreferencesGs as resetDismissablePreferencesGs, +} from "@/preferences" +import { TilesetPrefDisplay, tilesetIdAtom } from "./TilesetsPrompt" +import { SfxPrefDisplay, sfxIdAtom } from "./SfxPrompt" +import { Expl } from "../Expl" +import { + exaComplainAboutNonlegalGlitches, + filterSimulCharExaAtom, +} from "@/pages/ExaPlayerPage" +import { + endOnNonlegalGlitchAtom, + filterSimulCharPlayAtom, +} from "@/pages/LevelPlayerPage" +import { + ShowEpilogueMode, + preloadFilesFromDirectoryPromptAtom, + showEpilogueAtom, +} from "@/levelData" +import { showTimeFractionInMetricsAtom } from "../LevelList" +import { useJotaiFn, usePromise } from "@/helpers" +import { getPlayerSummary, optimizerIdAtom } from "@/scoresApi" +import { VNode } from "preact" +import { HaikuMode, haikuModePreferenceAtom } from "../Ht" + +export type PrefDisplayProps = P & { + set: (val: T) => void + value: T + inputId: string +} + +function ColorSchemePrefDisplay({ + set, + value, + inputId, +}: PrefDisplayProps) { + return ( + + ) +} + +function BinaryDisplayPref({ value, set, inputId }: PrefDisplayProps) { + return ( + + { + set(ev.currentTarget.checked) + }} + /> + + ) +} +function EpiloguePref({ + value, + set, + inputId, +}: PrefDisplayProps) { + return ( + + ) +} + +function OptimizerIdPref({ + value, + set, + inputId, +}: PrefDisplayProps) { + const playerInfoRes = usePromise( + () => (value === null ? Promise.resolve(null) : getPlayerSummary(value)), + [value] + ) + return ( +
    + { + set( + ev.currentTarget.value === "" + ? null + : parseInt(ev.currentTarget.value) + ) + }} + />{" "} + + {playerInfoRes.state === "working" + ? "Loading..." + : playerInfoRes.state === "error" + ? "Failed to load" + : playerInfoRes.value?.player} + +
    + ) +} + +function HaikuModePref({ value, set, inputId }: PrefDisplayProps) { + return ( + + ) +} + +interface PrefProps { + Display: FC & P> + atom: WritableAtom + label: string + expl?: VNode | string + props?: P +} + +export const PreferencesPrompt: PromptComponent = ({ onResolve }) => { + const { get, set } = useStore() + const prefAtoms = new Map, PrimitiveAtom>() + function Pref(props: PrefProps) { + const trueAtom = useMemo( + () => getTruePreferenceAtom(props.atom), + [props.atom] + ) + const defaultValue = useAtomValue(trueAtom!) as T + const defaultedDefaultValue = useAtomValue(props.atom) + const fauxAtom = useMemo(() => atom(defaultValue), [defaultValue]) + prefAtoms.set(trueAtom!, fauxAtom) + const [val, setVal] = useAtom(fauxAtom) + const inputId = useId() + return ( + <> + + + + ) + } + const savePrefs = () => { + for (const [trueAtom, fauxAtom] of prefAtoms) { + set(trueAtom, get(fauxAtom)) + } + } + const resetDismissablePreferences = useJotaiFn(resetDismissablePreferencesGs) + + return ( + savePrefs()], + ["Discard", () => {}], + ]} + onResolve={onResolve} + > +
    +

    Visuals

    + + +

    Audio

    + +

    Play

    + + +

    ExaCC

    + + +

    Miscellaneous

    + + + + The{" "} + + https://scores.bitbusters.club + {" "} + user ID. Required for score report generation. +

    + How to obtain: Look for the number in your player page URL. For + example, If your user page is at + https://scores.bitbusters.club/players/75, your optimizer ID is + 75. +

    + + } + /> + + +
    + +
    +
    +
    + ) +} diff --git a/gamePlayer/src/levels/tilesetPreview.c2m b/gamePlayer/src/components/PreferencesPrompt/tilesetPreview.c2m similarity index 100% rename from gamePlayer/src/levels/tilesetPreview.c2m rename to gamePlayer/src/components/PreferencesPrompt/tilesetPreview.c2m diff --git a/gamePlayer/src/components/Preloader.tsx b/gamePlayer/src/components/Preloader.tsx new file mode 100644 index 00000000..b6408b34 --- /dev/null +++ b/gamePlayer/src/components/Preloader.tsx @@ -0,0 +1,91 @@ +import { useStore } from "jotai" +import { useEffect, useState } from "preact/compat" +import { initNotCCFs, isFile, readDir, readJson } from "@/fs" +import { + allPreferencesAtom, + preloadFinishedAtom, + syncAllowed_thisisstupid, +} from "@/preferences" +import { + tilesetIdAtom, + tilesetAtom, + getTileset, + customTsetsAtom, + DEFAULT_TILESET, +} from "./PreferencesPrompt/TilesetsPrompt" +import { + DEFAULT_SFXSET, + customSfxAtom, + getSfxSet, + sfxAtom, + sfxIdAtom, +} from "./PreferencesPrompt/SfxPrompt" +import { Throbber } from "./Throbber" +import { initWasm } from "@notcc/logic" +import { updateVariablesFromHashGs } from "@/routing" + +export function Preloader(props: { preloadComplete?: () => void }) { + const { get, set } = useStore() + const [loadingStage, setLoadingStage] = useState("javascript") + async function prepareAssets() { + setLoadingStage("game logic") + await initWasm() + setLoadingStage("user data") + await initNotCCFs() + let prefs: any = {} + try { + if (await isFile("preferences.json")) { + prefs = await readJson("preferences.json") + } + } catch (err) { + console.error(`Couldn't load preferences: ${err}`) + } + set(allPreferencesAtom, prefs) + + setLoadingStage("tileset") + set( + customTsetsAtom, + (await readDir("/tilesets")).map(v => v.split(".").slice(0, -1).join(".")) + ) + set( + tilesetAtom, + await getTileset(get(tilesetIdAtom)).catch(() => + getTileset(DEFAULT_TILESET) + ) + ) + setLoadingStage("sfx") + set( + customSfxAtom, + (await readDir("/sfx")).map(v => v.split(".").slice(0, -1).join(".")) + ) + set( + sfxAtom, + await getSfxSet(get(sfxIdAtom)).catch(() => getSfxSet(DEFAULT_SFXSET)) + ) + set(preloadFinishedAtom, true) + setTimeout(() => (syncAllowed_thisisstupid.val = true), 0) + updateVariablesFromHashGs(get, set) + } + useEffect(() => { + if (!globalThis.window) return + prepareAssets().then(() => props.preloadComplete?.()) + }, []) + return ( +
    +
    + Preparing... +
    + Loading {loadingStage} +
    +
    + +
    + +
    +
    + ) +} diff --git a/gamePlayer/src/components/ProgressBar.tsx b/gamePlayer/src/components/ProgressBar.tsx new file mode 100644 index 00000000..4ef175ce --- /dev/null +++ b/gamePlayer/src/components/ProgressBar.tsx @@ -0,0 +1,10 @@ +export function ProgressBar(props: { progress: number }) { + return ( +
    +
    +
    + ) +} diff --git a/gamePlayer/src/components/ReportGenerator.tsx b/gamePlayer/src/components/ReportGenerator.tsx new file mode 100644 index 00000000..a7777b8b --- /dev/null +++ b/gamePlayer/src/components/ReportGenerator.tsx @@ -0,0 +1,265 @@ +import { PromptComponent } from "@/prompts" +import { + ApiPackLevel, + ApiPackLevelAttribute, + ApiPackReport, + MetricGrades, + ReportGrade, + getLevelAttribute, + getMetricsForPlayerReports, + getPlayerPackDetails, + getReportGradesForMetrics, + optimizerIdAtom, + setScoresAtom, +} from "@/scoresApi" +import { Dialog } from "./Dialog" +import { useAtomValue } from "jotai" +import { importantSetAtom, levelSetAtom } from "@/levelData" +import { useCallback, useEffect, useMemo, useState } from "preact/hooks" +import { SolutionMetrics, findBestMetrics, protobuf } from "@notcc/logic" +import { usePromise } from "@/helpers" + +interface ReportMetric { + value: number + grade: ReportGrade + // The extra value for P+ and B+ grades + gradeImprovement?: number + alreadyReported: boolean +} + +function formatReportMetric( + metric: ReportMetric, + type: "time" | "score" +): string { + let gradeLetter: string | undefined + if (metric.grade === "better than bold") { + gradeLetter = `b+${metric.gradeImprovement ?? 0}` + } else if (metric.grade === "bold confirm") { + // If this exact metric value was already reported, it cannot be a bold confirm, since that + // requires a different player to achieve the same time + gradeLetter = metric.alreadyReported ? "b" : "bc" + } else if (metric.grade === "partial confirm") { + gradeLetter = "pc" + } else if (metric.grade === "bold") { + gradeLetter = "b" + } else if (metric.grade === "better than public") { + gradeLetter = `p+${metric.gradeImprovement ?? 0}` + } else if (metric.grade === "public") { + gradeLetter = "p" + } + return `${metric.value}${type === "time" ? "s" : "pts"}${gradeLetter ? ` (${gradeLetter})` : ""}` +} + +function formatReportLine(line: ReportLine, timeOnly: boolean): string { + return `#${line.levelN} (${line.title}): ${formatReportMetric(line.time, "time")}${timeOnly ? "" : ` / ${formatReportMetric(line.score, "score")}`}\n` +} + +interface ReportLine { + lineIncluded: boolean + levelN: number + title: string + time: ReportMetric + score: ReportMetric + hasImprovements: boolean +} + +function getGradeImprovement( + grade: ReportGrade, + value: number, + attr?: ApiPackLevelAttribute +): number | undefined { + if (grade === "better than bold") + return value - (attr?.attribs.highest_reported ?? 0) + if (grade === "better than public") + return value - (attr?.attribs.highest_public ?? 0) + return undefined +} + +function makeReportLine( + levelInfo: protobuf.ILevelInfo, + levelScores?: ApiPackLevel, + playerScores?: ApiPackReport[] +): ReportLine | null { + const localMetrics = findBestMetrics(levelInfo) + if (!localMetrics) return null + const reportedMetrics = getMetricsForPlayerReports(playerScores ?? []) + const maxMetrics: SolutionMetrics = { + timeLeft: Math.max(localMetrics.timeLeft, reportedMetrics.timeLeft ?? 0), + score: Math.max(localMetrics.score, reportedMetrics.score ?? 0), + realTime: Math.min( + localMetrics.realTime, + reportedMetrics.realTime ?? Infinity + ), + } + const grades: MetricGrades = !levelScores + ? { time: "solved", score: "solved" } + : getReportGradesForMetrics(maxMetrics, levelScores) + + const maxTimeLeftS = Math.ceil(maxMetrics.timeLeft / 60) + + return { + levelN: levelInfo.levelNumber!, + title: levelInfo.title! ?? "untitled level", + time: { + value: maxTimeLeftS, + grade: grades.time, + alreadyReported: + reportedMetrics.timeLeft !== undefined && + Math.ceil(reportedMetrics.timeLeft / 60) >= + Math.ceil(localMetrics.timeLeft / 60), + gradeImprovement: getGradeImprovement( + grades.time, + maxTimeLeftS, + levelScores && getLevelAttribute("time", levelScores) + ), + }, + score: { + value: maxMetrics.score, + grade: grades.score, + alreadyReported: + reportedMetrics.score !== undefined && + reportedMetrics.score >= localMetrics.score, + gradeImprovement: getGradeImprovement( + grades.score, + maxMetrics.score, + levelScores && getLevelAttribute("score", levelScores) + ), + }, + lineIncluded: true, + hasImprovements: + (reportedMetrics.score === undefined || + localMetrics.score > reportedMetrics.score) && + (reportedMetrics.timeLeft === undefined || + Math.ceil(localMetrics.timeLeft / 60) >= + Math.ceil(reportedMetrics.timeLeft / 60)), + } +} + +function ReportLineC(props: { line: ReportLine; showTimeOnly: boolean }) { + // FIXME: This whole component is really anti-React, but the correct way to do this + // (I guess have an `onIncludedToggled` prop which etc. etc.) is really dumb + const [, setChanged] = useState(false) + return ( +
    + +
    + ) +} + +export const ReportGeneratorPrompt: PromptComponent = pProps => { + const setScores = useAtomValue(setScoresAtom) + const levelSet = useAtomValue(levelSetAtom) + const importantSet = useAtomValue(importantSetAtom) + + const optimizerId = useAtomValue(optimizerIdAtom) + const playerScoresRes = usePromise( + () => + optimizerId && importantSet + ? getPlayerPackDetails(optimizerId, importantSet.setIdent) + : Promise.resolve(null), + [optimizerId, importantSet] + ) + + useEffect(() => { + if (!levelSet) { + pProps.onResolve() + } + }, []) + + const reportPrologue = useMemo( + () => `${levelSet?.gameTitle()} scores:\n\n`, + [levelSet] + ) + const reportEpilogue = useMemo( + () => + `\n(NotCC v${import.meta.env.VITE_VERSION} ${import.meta.env.VITE_GIT_COMMIT})`, + [] + ) + const lines = useMemo(() => { + if (!levelSet) return null + return levelSet + .listLevels() + .map(level => + makeReportLine( + level.levelInfo, + !setScores || setScores.result === "reject" + ? undefined + : setScores.value.find( + scoresLevel => scoresLevel.level === level.levelInfo.levelNumber + ), + playerScoresRes.state === "done" + ? playerScoresRes.value?.scores.levels[level.levelInfo.levelNumber!] + : undefined + ) + ) + .filter((line): line is ReportLine => !!line) + }, [levelSet, setScores, playerScoresRes.state]) + + const showTimeOnly = !(importantSet?.scoreboardHasScores ?? true) + + const [improvementsOnly, setImprovementsOnly] = useState(true) + + const copyReportToClipboard = useCallback(() => { + const fullReport = + reportPrologue + + lines + ?.filter(line => line.lineIncluded) + ?.filter(line => !improvementsOnly || line.hasImprovements) + ?.map(line => formatReportLine(line, showTimeOnly)) + .join("") + + reportEpilogue + navigator.clipboard.writeText(fullReport) + }, [reportPrologue, lines, reportEpilogue, improvementsOnly]) + + const havePlayerScores = + playerScoresRes.state === "done" && !!playerScoresRes.value + + return ( + +
    +
    + {!setScores + ? "Not an official set" + : `Official set, ${optimizerId === null ? "no optimizer ID" : playerScoresRes.state === "working" ? "loading optimizer data" : playerScoresRes.state === "error" ? "failed to load player data" : `player is ${playerScoresRes.value!.player}`}`} +
    + +
    + {reportPrologue} + {lines + ?.filter(line => !improvementsOnly || line.hasImprovements) + ?.map(line => ( + + ))} + {reportEpilogue} +
    +
    +
    + ) +} diff --git a/gamePlayer/src/components/SetsGrid.tsx b/gamePlayer/src/components/SetsGrid.tsx new file mode 100644 index 00000000..127ed079 --- /dev/null +++ b/gamePlayer/src/components/SetsGrid.tsx @@ -0,0 +1,547 @@ +import { getBBClubSetReleased, getBBClubSetUpdated } from "@/setsApi" +import { useCallback, useEffect, useMemo, useState } from "preact/hooks" +import { CameraType, GameRenderer } from "./GameRenderer" +import { suspend } from "suspend-react" +import { tilesetAtom } from "./PreferencesPrompt/TilesetsPrompt" +import { Suspense, memo } from "preact/compat" +import { ErrorBoundary, FallbackProps } from "react-error-boundary" +import { aiGather, useJotaiFn, usePromise } from "@/helpers" +import { Expl } from "./Expl" +import { LevelData, loadSetSave, setLevelSetGs } from "@/levelData" +import { ScriptMetadata } from "@notcc/logic" +import { Throbber } from "./Throbber" +import { twJoin } from "tailwind-merge" +import { PromptComponent, showPromptGs } from "@/prompts" +import { Dialog } from "./Dialog" +import { ErrorBox } from "./ErrorBox" +import { + ItemLevelSet, + SetIdentExpl, + announceLocalSetsChangedGs, + downloadAndOverwriteBBClubSetGs, + downloadBBClubSetGs, + findAllLocalSets, + getSetMetadata, + localSetsChangedAtom, + removeLocalSet, + useBBClubSetsPromise, +} from "@/setManagement" +import { useAtomValue } from "jotai" +import { ProgressBar } from "./ProgressBar" + +// XXX: Make this dynamic? +const PREVIEW_TILESET_SIZE = 32 +const EXPECTED_CAMERA_SIZE = 10 + +const PREVIEW_FULL_SIZE = PREVIEW_TILESET_SIZE * EXPECTED_CAMERA_SIZE + +type SortingMode = "alphabetical" | "newest" + +interface SetSortingOptions { + mode: SortingMode + reverseMode: boolean + localFirst: boolean + // sortLocalByLastPlayed: boolean + query?: string +} + +const alphaSort = (setA: ItemLevelSet, setB: ItemLevelSet) => { + const a = setA.setName.toLowerCase() + const b = setB.setName.toLowerCase() + return a < b ? -1 : a > b ? 1 : 0 +} +const dateSort = (setA: ItemLevelSet, setB: ItemLevelSet) => { + const a = setA.bbClubSet?.set.last_updated + const b = setB.bbClubSet?.set.last_updated + if (!a || !b) return 0 + return new Date(b).valueOf() - new Date(a).valueOf() +} + +function sortSets( + inSets: ItemLevelSet[], + options: SetSortingOptions +): ItemLevelSet[] { + let sets = inSets.concat() + sets.sort(options.mode === "alphabetical" ? alphaSort : dateSort) + if (options.reverseMode) { + sets.reverse() + } + if (options.localFirst) { + sets.sort((a, b) => { + return (a.localSet ? 0 : 1) - (b.localSet ? 0 : 1) + }) + } + if (options.query) { + const query = options.query.toLowerCase() + // Search for the string in both setName and setIdent, since common abbriviations (eg. "CC2" or "CC2LP1") are used in the set ident + sets = sets.filter( + set => + set.setName.toLowerCase().includes(query) || + set.setIdent.toLowerCase().includes(query) + ) + } + return sets +} + +function SetItemPreview(props: { set: ItemLevelSet }) { + const previewLevelData = suspend( + () => getSetPreviewLevel(props.set), + [props.set] + ) + const tileset = useAtomValue(tilesetAtom) + const previewLevel = useMemo( + () => previewLevelData?.initLevel(), + [previewLevelData] + ) + const cameraType: CameraType | undefined = useMemo( + () => + previewLevel && { + width: previewLevel.metadata.cameraWidth, + height: previewLevel.metadata.cameraHeight, + }, + [previewLevel] + ) + const playerSeat = useMemo( + () => previewLevel && previewLevel.playerSeats[0], + [previewLevel] + ) + if (!tileset || !previewLevel) return <> + return ( + + ) +} + +function SetItemPreviewError(props: FallbackProps) { + const error = props.error as Error + return ( + <> + Failed to get preview{" "} + + Got the following error when downloading the preview: + + + + ) +} + +const RemoveSetPrompt: PromptComponent< + "cancel" | "remove solutions" | "keep solutions" +> = pProps => ( + pProps.onResolve("keep solutions"), + ], + // TODO: ["Delete everything", () => pProps.onResolve("remove solutions")], + ["Cancel", () => pProps.onResolve("cancel")], + ]} + onClose={() => pProps.onResolve("cancel")} + > + Are you sure you want to delete this set? Additionally, do you want to keep + or delete the existing solutions for this set? + +) + +const SetInfoPrompt = + (set: ItemLevelSet, setMeta: ScriptMetadata | null): PromptComponent => + pProps => { + const announceLocalSetsChanged = useJotaiFn(announceLocalSetsChangedGs) + const showPrompt = useJotaiFn(showPromptGs) + const deleteSet = useCallback(async () => { + const promptRes = await showPrompt(RemoveSetPrompt) + if (promptRes === "cancel") return + // TODO: Remove solutions + await removeLocalSet(set, true) + announceLocalSetsChanged() + pProps.onResolve() + }, []) + const bbClubSet = set.bbClubSet?.set + return ( + +
    +
    + + }> + + + +
    + +
    + + Set name{" "} + + The set name, as specified in the main C2G script + + + {set.setName} + + Set ident + + {set.setIdent} + + Set has metadata{" "} + + Sets may have additional metadata, as specified by the author.{" "} + + See more about script metadata + + + + + {set.localSet + ? setMeta?.anyMetadataSpecified + ? "Yes" + : "No" + : "Unknown"} + + {setMeta?.by && ( + <> + By + {setMeta.by} + + )} + {setMeta?.description && ( + <> + Description + {setMeta.description} + + )} + {setMeta?.difficulty && ( + <> + Difficulty + {setMeta.difficulty}/5 + + )} + {bbClubSet && ( + <> + Set uploaded to bb.club + {getBBClubSetReleased(bbClubSet).toLocaleString()} + Set last updated on bb.club + {getBBClubSetUpdated(bbClubSet).toLocaleString()} + bb.club set ID + {bbClubSet.id} + Level count (from bb.club) + {bbClubSet.level_count} + {bbClubSet.description && ( + <> + bb.club description + {bbClubSet.description} + + )} + + )} +
    + {set.localSet && ( + + )} +
    +
    +
    +
    + ) + } + +async function getSetPreviewLevel(set: ItemLevelSet): Promise { + // If this is a local set, show the last played level! + if (set.localSet) { + const { set: setInst } = await loadSetSave(await set.localSet.loadData()) + const rec = await setInst.initialLevel() + return new LevelData(rec && (await setInst.loadLevelData(rec)).levelData) + } + if (set.bbClubSet) { + const preview = await set.bbClubSet.repo.getSetPreview(set.bbClubSet.set.id) + if (!preview) throw new Error("This set has no preview") + return preview + } + // TODO: if (set.builtinSet) + throw new Error("Can't generate level preview, no valid source") +} + +const SetItem = memo( + (props: { set: ItemLevelSet; showDisambiguation?: boolean }) => { + const { bbClubSet, localSet: localSetData, localBBClubSet } = props.set + + const localSetMetadataRes = usePromise( + async () => getSetMetadata(props.set), + [localSetData] + ) + const localSetMetadata = + localSetMetadataRes.state === "done" ? localSetMetadataRes.value : null + + const [isDownloading, setIsDownloading] = useState(false) + const [progress, setProgress] = useState(0) + + const downloadAndOverwriteBBClubSet = useJotaiFn( + downloadAndOverwriteBBClubSetGs + ) + const downloadBBClubSet = useJotaiFn(downloadBBClubSetGs) + + const userDownloadSet = useCallback(async () => { + setIsDownloading(true) + await downloadBBClubSet(props.set, setProgress) + setIsDownloading(false) + }, [props.set]) + + const updateSet = useCallback(async () => { + if (!localSetData) return + await removeLocalSet(props.set, false) + setIsDownloading(true) + // We just removed the same set, so we don't need to check if we're overwriting anything + await downloadAndOverwriteBBClubSet(props.set, setProgress) + setIsDownloading(false) + }, [localSetData]) + + const setLevelSet = useJotaiFn(setLevelSetGs) + const playSet = useCallback(async () => { + if (!localSetData) return + setLevelSet(await localSetData.loadData(), props.set.setIdent) + }, [localSetData, setLevelSet]) + + const isOutOfDate = useMemo( + () => + bbClubSet && + localBBClubSet && + getBBClubSetUpdated(bbClubSet.set).getTime() > + localBBClubSet.lastUpdated, + [bbClubSet, localBBClubSet] + ) + const showPrompt = useJotaiFn(showPromptGs) + const showSetInfo = useCallback(() => { + showPrompt(SetInfoPrompt(props.set, localSetMetadata ?? null)) + }, [props.set, localSetMetadata]) + + return ( +
    +
    + + }> + + + +
    +
    + {props.set.setName} + {props.showDisambiguation && ( + ({props.set.setIdent}) + )} +
    + {localSetMetadata?.by &&
    By {localSetMetadata.by}
    } + {bbClubSet && ( +
    + {bbClubSet.set.level_count} level + {bbClubSet.set.level_count !== 1 && "s"} +
    + )} + {bbClubSet && ( +
    + Uploaded {new Date(bbClubSet.set.release_date).toLocaleDateString()} + {bbClubSet.set.release_date !== bbClubSet.set.last_updated && ( + <> + , last updated{" "} + {new Date(bbClubSet.set.last_updated).toLocaleDateString()} + + )} +
    + )} + {localSetMetadata?.difficulty && ( +
    Difficulty: {localSetMetadata.difficulty} / 5
    + )} + {localSetMetadata?.description && ( +
    {localSetMetadata.description}
    + )} +
    + {!localSetData || props.set.localBBClubSet ? "bb.club" : "local"} +
    +
    + +
    + {!isDownloading && ( +
    + {localSetData && ( + + )} + {isOutOfDate && ( + + )} + {bbClubSet && !localSetData && ( + + )} + +
    + )} +
    + ) + } +) + +function useErrorRethrow(err: Error | null | undefined) { + useEffect(() => { + if (err) throw err + }, [err]) +} + +export function SetsGrid() { + const localSetsChanged = useAtomValue(localSetsChangedAtom) + const localSetsRes = usePromise( + () => aiGather(findAllLocalSets()), + [localSetsChanged] + ) + // Failing to load local sets is really bad, we probably messed something up, show an error message + useErrorRethrow(localSetsRes.state === "error" ? localSetsRes.error : null) + + const setsPromise = useBBClubSetsPromise() + const bbClubSetsRes = usePromise(() => setsPromise, [setsPromise]) + // On the other hand, failing to load bb.club stuff isn't a huge issue - we may be offline or bb.club might just happen to be down + + const sets: ItemLevelSet[] = useMemo(() => { + const sets: ItemLevelSet[] = [] + if (localSetsRes.state === "done") { + sets.push(...localSetsRes.value) + } + if (bbClubSetsRes.state === "done") { + for (const bbClubSet of bbClubSetsRes.value) { + const localSet = sets.find( + lSet => lSet.localBBClubSet?.id === bbClubSet.bbClubSet!.set.id + ) + // If there's a local set that's supposed to auto-update with a bb.club set, just add more info to that local set + if (localSet) { + localSet.bbClubSet = bbClubSet.bbClubSet! + localSet.setKey = `bb.club-${bbClubSet.bbClubSet!.set.id}` + } else { + sets.push(bbClubSet) + } + } + } + return sets + }, [bbClubSetsRes.state, localSetsRes.state]) + + // If there are multiple sets with the same name, we should show the set idents for those sets + const setsToDisambiguate = useMemo(() => { + const setNames = new Set() + const duplicateSets = new Set() + for (const set of sets) { + if (setNames.has(set.setName)) { + duplicateSets.add(set.setName) + } + setNames.add(set.setName) + } + return duplicateSets + }, [sets]) + + const [query, setQuery] = useState("") + const [localFirst, setLocalFirst] = useState(true) + const [sortingMode, setSortingMode] = useState("newest") + const sortedSets = useMemo( + () => + sortSets(sets, { + query, + mode: sortingMode, + localFirst, + reverseMode: false, + }), + [sets, query, sortingMode, localFirst] + ) + + const allDoneLoading = + bbClubSetsRes.state !== "working" && localSetsRes.state !== "working" + return ( +
    +
    + setQuery(ev.currentTarget.value)} + /> + + +
    + {bbClubSetsRes.state === "error" && ( +
    +
    + + Failed to load bb.club sets{" "} + + Failed to load bb.club sets due to the following error: + + This may indicate that you're offline, bb.club is down, or that + there is a NotCC bug. + + + +
    +
    + )} + {!allDoneLoading && ( +
    +
    + Loading following set data: + {localSetsRes.state === "working" && local sets} + {bbClubSetsRes.state === "working" && bb.club sets} +
    + +
    +
    +
    + )} + {sortedSets.map(set => ( + + ))} +
    + ) +} diff --git a/gamePlayer/src/components/Sidebar/backfeedPruning.png b/gamePlayer/src/components/Sidebar/backfeedPruning.png new file mode 100644 index 00000000..3dc900c7 Binary files /dev/null and b/gamePlayer/src/components/Sidebar/backfeedPruning.png differ diff --git a/gamePlayer/src/components/Sidebar/index.tsx b/gamePlayer/src/components/Sidebar/index.tsx new file mode 100644 index 00000000..23df1afc --- /dev/null +++ b/gamePlayer/src/components/Sidebar/index.tsx @@ -0,0 +1,721 @@ +import { ComponentChildren, ComponentProps, Ref, createContext } from "preact" +import leafIcon from "./tabIcons/leaf.svg" +import levelIcon from "./tabIcons/level.svg" +import floppyIcon from "./tabIcons/floppy.svg" +import clockIcon from "./tabIcons/clock.svg" +import toolsIcon from "./tabIcons/tools.svg" +import infoIcon from "./tabIcons/info.svg" +import { + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "preact/hooks" +import { forwardRef } from "preact/compat" +import { twJoin } from "tailwind-merge" +import { useMediaQuery } from "react-responsive" +import { Getter, Setter, atom, useAtomValue, useStore } from "jotai" +import { levelSetIdentAtom, pageAtom } from "@/routing" +import { showPromptGs } from "@/prompts" +import { AboutPrompt } from "../AboutDialog" +import { applyRef, formatTimeLeft, keypressIsFocused } from "@/helpers" +import { PreferencesPrompt } from "../PreferencesPrompt" +import isHotkey from "is-hotkey" +import { openExaCC, toggleExaCC } from "@/pages/ExaPlayerPage/OpenExaPrompt" +import { + goToNextLevelGs, + goToPreviousLevelGs, + levelAtom, + levelSetAtom, + useSwrLevel, +} from "@/levelData" +import { Expl } from "../Expl" +import backfeedPruningImg from "./backfeedPruning.png" +import { + InputProvider, + ReplayInputProvider, + Route, + RouteFileInputProvider, + SolutionInfoInputProvider, + calculateLevelPoints, + protoTimeToMs, +} from "@notcc/logic" +import { protobuf } from "@notcc/logic" +import { RRLevel, RRRoute, getRRLevel, setRRRoutesAtom } from "@/railroad" +import { Toast, addToastGs, removeToastGs } from "@/toast" +import { showLoadPrompt } from "@/fs" +import { LevelListPrompt } from "../LevelList" +import { unwrap } from "jotai/utils" +import { Ht } from "../Ht" +import { MOBILE_QUERY, PORTRAIT_QUERY } from "../../../tailwind.config" + +export interface LevelControls { + restart?(): void + pause?(): void + saveMapScreenshot?(): void + playInputs?(ip: InputProvider): void + exa?: { + undo(): void + redo(): void + save?(): void + export(): void + purgeBackfeed?(): void + cameraControls(): void + tileInspector(): void + levelModifiersControls(): void + } +} + +export const levelControlsAtom = atom({}) + +interface SidebarAction { + label: ComponentChildren + expl?: ComponentChildren + shortcut?: string + disabled?: boolean + onTrigger?: (get: Getter, set: Setter) => void +} + +const SidebarActionContext = createContext([]) + +function ChooserButton(props: SidebarAction) { + const { get, set } = useStore() + const sidebarActions = useContext(SidebarActionContext) + useEffect(() => { + sidebarActions.push(props) + return () => { + sidebarActions.splice(sidebarActions.indexOf(props), 1) + } + }, [props]) + const isDisabled = props.disabled || !props.onTrigger + return ( +
    { + if (!isDisabled) { + props.onTrigger?.(get, set) + } + }} + > +
    + {props.label} + {props.expl && {props.expl}} +
    + {props.shortcut && ( +
    + {props.shortcut} +
    + )} +
    + ) +} + +function useSidebarChooserAnim( + open: boolean +): { + ref: Ref + closingAnim: boolean + endClosingAnim: () => void + shouldRender: boolean +} { + const [wasOpen, setWasOpen] = useState(false) + if (!wasOpen && open) { + setWasOpen(true) + } + + const ref = useRef(null) + const [closingAnim, setClosingAnim] = useState(false) + + useLayoutEffect(() => { + if (wasOpen && !open) { + setClosingAnim(true) + // REFLOW the main div so that the new animation plays + void ref.current?.offsetHeight + } + }, [wasOpen, open]) + function endClosingAnim() { + if (closingAnim) { + setWasOpen(false) + setClosingAnim(false) + } + } + return { ref, closingAnim, endClosingAnim, shouldRender: open || closingAnim } +} + +const SidebarTooltip = forwardRef< + HTMLDialogElement, + ComponentProps<"dialog"> & { reverse?: boolean } +>(function SidebarTooltip(props, fref) { + const { endClosingAnim, closingAnim, ref, shouldRender } = + useSidebarChooserAnim(!!props.open) + + return ( +
    +
    + +
    + ) +}) + +const SidebarDrawer = forwardRef>( + function SidebarDrawer(props, fref) { + const { endClosingAnim, closingAnim, ref, shouldRender } = + useSidebarChooserAnim(!!props.open) + + return ( + { + applyRef(ref, dialog) + applyRef(fref, dialog) + }} + onAnimationEnd={endClosingAnim} + class={twJoin( + "box fixed bottom-20 left-0 right-0 z-10 mx-auto w-screen rounded-b-none border-b-0 shadow-none [transform-origin:0_100%] landscape:bottom-0 landscape:left-20 landscape:w-[calc(100vw_-_theme(spacing.20))]", + props.open && "animate-drawer-open", + closingAnim && "animate-drawer-close", + !shouldRender && "hidden" + )} + /> + ) + } +) + +function SidebarButton(props: { + icon: string + children: ComponentChildren + addMargin?: boolean + reverse?: boolean +}) { + const [tooltipOpened, setTooltipOpened] = useState(false) + const onDialogMount = (dialog: HTMLDialogElement | null) => { + if (tooltipOpened && dialog) { + dialog.focus() + } + } + const isMobile = useMediaQuery({ + query: MOBILE_QUERY, + }) + const isPortrait = useMediaQuery({ query: PORTRAIT_QUERY }) + + const SidebarChooser = isMobile || isPortrait ? SidebarDrawer : SidebarTooltip + + return ( +
    + { + setTooltipOpened(true) + }} + /> + { + if ( + ev.relatedTarget && + (ev.target as HTMLElement).contains(ev.relatedTarget as HTMLElement) + ) + return + setTooltipOpened(false) + }} + ref={onDialogMount} + reverse={props.reverse} + > +
    { + if ( + (ev.target as HTMLElement).classList.contains("closes-tooltip") + ) { + setTooltipOpened(false) + } + }} + > + {props.children} +
    +
    +
    + ) +} + +interface SidebarReplayable { + name: string + metric: string + ip: LoadableInputProvider +} + +export type LoadableInputProvider = + | InputProvider + | (() => Promise) + +function getAttemptSolutions( + attempts: protobuf.IAttemptInfo[], + currentLevel: number +) { + const sols = [] + let bestTime: protobuf.ISolutionInfo | null = null + let bestScore: protobuf.ISolutionInfo | null = null + let bestScoreVal = -Infinity + let last: protobuf.ISolutionInfo | null = null + for (const attempt of attempts) { + const sol = attempt.solution + if (!sol) continue + last = sol + if ( + sol.outcome?.timeLeft != undefined && + (!bestTime || + protoTimeToMs(sol.outcome.timeLeft) > + protoTimeToMs(bestTime.outcome!.timeLeft!)) + ) { + bestTime = sol + } + if ( + sol.outcome?.bonusScore != undefined && + sol.outcome?.timeLeft != undefined + ) { + const score = calculateLevelPoints( + currentLevel, + Math.ceil(protoTimeToMs(sol.outcome.timeLeft) / 1000), + sol.outcome.bonusScore + ) + if (!bestScore || score > bestScoreVal) { + bestScore = sol + bestScoreVal = score + } + } + } + function makeMetrics(sol: protobuf.ISolutionInfo) { + const score = calculateLevelPoints( + currentLevel, + Math.ceil(protoTimeToMs(sol.outcome!.timeLeft!) / 1000), + sol.outcome!.bonusScore! + ) + return `${Math.ceil( + protoTimeToMs(sol.outcome!.timeLeft!) / 1000 + )}s / ${score}pts` + } + if (bestTime || bestScore) { + if (bestTime === bestScore) { + sols.push({ + name: "Best", + metric: makeMetrics(bestTime!), + ip: new SolutionInfoInputProvider(bestTime!), + }) + } else { + sols.push({ + name: "Best time", + metric: makeMetrics(bestTime!), + ip: new SolutionInfoInputProvider(bestTime!), + }) + sols.push({ + name: "Best score", + metric: makeMetrics(bestScore!), + ip: new SolutionInfoInputProvider(bestScore!), + }) + } + } + if (last && last !== bestTime && last !== bestScore) { + sols.push({ + name: "Last", + metric: makeMetrics(last), + ip: new SolutionInfoInputProvider(last), + }) + } + return sols +} + +function ReplayableTooltipList(props: { + replayables: SidebarReplayable[] + controls: LevelControls +}) { + return ( + <> + {props.replayables.map(sol => ( + { + props.controls.playInputs?.( + typeof sol.ip === "function" ? await sol.ip() : sol.ip + ) + }} + /> + ))} + + ) +} + +function SolutionsTooltipList(props: { controls: LevelControls }) { + const lSet = useAtomValue(levelSetAtom) + const level = useSwrLevel() + if (!level || !props.controls.playInputs) + return
    N/A
    + const sols: SidebarReplayable[] = [] + if (level.replay) { + const replayLength = Math.round(level.replay.inputs.length / 20) + sols.push({ + name: "Built-in", + metric: `${Math.floor(replayLength / 60)}:${(replayLength % 60) + .toString() + .padStart(2, "0")}`, + ip: new ReplayInputProvider(level.replay), + }) + } + const attempts = lSet?.currentLevelRecord().levelInfo.attempts + if (attempts) { + sols.push(...getAttemptSolutions(attempts, lSet.currentLevel)) + } + if (sols.length === 0) return
    None
    + return +} + +function getRRRoutes( + routes: RRLevel[], + levelN: number, + packId: string, + get?: Getter, + set?: Setter +): SidebarReplayable[] { + const rrLevel = routes.find(lvl => lvl.levelN === levelN) + if (!rrLevel || !rrLevel.mainlineTimeRoute || !rrLevel.mainlineScoreRoute) + return [] + function makeMetrics(route: RRRoute) { + return `${formatTimeLeft(Math.round(route.timeLeft * 60))}s / ${route.points}pts` + } + function fetchIp(route: RRRoute) { + return async () => { + const toast: Toast = { title: "Fetching Railroad route..." } + if (get && set) { + addToastGs(get, set, toast) + } + const level = await getRRLevel(packId, levelN) + if (get && set) { + removeToastGs(get, set, toast) + } + return new RouteFileInputProvider( + level.routes.find(route2 => route2.id === route.id)!.moves! + ) + } + } + const tRoute = rrLevel.routes.find( + route => route.id === rrLevel.mainlineTimeRoute + )! + const sRoute = rrLevel.routes.find( + route => route.id === rrLevel.mainlineScoreRoute + )! + if (tRoute === sRoute) { + return [ + { + name: "Railroad", + metric: makeMetrics(tRoute), + ip: fetchIp(tRoute), + }, + ] + } + return [ + { name: "Railroad time", metric: makeMetrics(tRoute), ip: fetchIp(tRoute) }, + { + name: "Railroad score", + metric: makeMetrics(sRoute), + ip: fetchIp(sRoute), + }, + ] +} + +function RoutesTooltipList(props: { controls: LevelControls }) { + const levelSet = useAtomValue(levelSetAtom) + const rrRoutes = useAtomValue(setRRRoutesAtom) + const setIdent = useAtomValue(levelSetIdentAtom) + const { get, set } = useStore() + const routes: SidebarReplayable[] = [] + if (levelSet && rrRoutes?.result === "resolve") { + routes.push( + ...getRRRoutes(rrRoutes.value, levelSet.currentLevel, setIdent!, get, set) + ) + } + return ( + + ) +} + +async function importRoute(controls: LevelControls) { + const routeFiles = await showLoadPrompt("Load routefile", { + filters: [ + { name: "Routefile", extensions: ["route"] }, + { name: "MS/Lynx route to transcribe", extensions: ["json"] }, + ], + }) + const routeFile = routeFiles?.[0] + if (!routeFile) return + const route: Route = JSON.parse(await routeFile.text()) + if (!route.Rule) return + controls.playInputs?.(new RouteFileInputProvider(route)) +} + +const wrappedLevelAtom = unwrap(levelAtom) + +export function Sidebar() { + const sidebarActions: SidebarAction[] = useRef([]).current + const levelControls = useAtomValue(levelControlsAtom) + const { get, set } = useStore() + const hasSet = !!get(levelSetAtom) + useEffect(() => { + const listener = (ev: KeyboardEvent) => { + if (keypressIsFocused(ev)) return + for (const action of sidebarActions) { + if (!action.shortcut) continue + if (!isHotkey(action.shortcut)(ev)) continue + if (action.disabled || !action.onTrigger) continue + ev.preventDefault() + action.onTrigger(get, set) + } + } + document.addEventListener("keydown", listener) + return () => { + document.removeEventListener("keydown", listener) + } + }, [levelControls]) + const hasLevel = !!useSwrLevel() + return ( + + + + ) +} diff --git a/gamePlayer/src/components/Sidebar/tabIcons/clock.svg b/gamePlayer/src/components/Sidebar/tabIcons/clock.svg new file mode 100644 index 00000000..598af681 --- /dev/null +++ b/gamePlayer/src/components/Sidebar/tabIcons/clock.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gamePlayer/src/components/Sidebar/tabIcons/floppy.svg b/gamePlayer/src/components/Sidebar/tabIcons/floppy.svg new file mode 100644 index 00000000..d17bef0e --- /dev/null +++ b/gamePlayer/src/components/Sidebar/tabIcons/floppy.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gamePlayer/src/components/Sidebar/tabIcons/info.svg b/gamePlayer/src/components/Sidebar/tabIcons/info.svg new file mode 100644 index 00000000..e5723903 --- /dev/null +++ b/gamePlayer/src/components/Sidebar/tabIcons/info.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/gamePlayer/src/components/Sidebar/tabIcons/leaf.svg b/gamePlayer/src/components/Sidebar/tabIcons/leaf.svg new file mode 100644 index 00000000..ea246cf0 --- /dev/null +++ b/gamePlayer/src/components/Sidebar/tabIcons/leaf.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/gamePlayer/src/components/Sidebar/tabIcons/level.svg b/gamePlayer/src/components/Sidebar/tabIcons/level.svg new file mode 100644 index 00000000..95e27b09 --- /dev/null +++ b/gamePlayer/src/components/Sidebar/tabIcons/level.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gamePlayer/src/components/Sidebar/tabIcons/tools.svg b/gamePlayer/src/components/Sidebar/tabIcons/tools.svg new file mode 100644 index 00000000..20085cad --- /dev/null +++ b/gamePlayer/src/components/Sidebar/tabIcons/tools.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/gamePlayer/src/components/Throbber.tsx b/gamePlayer/src/components/Throbber.tsx new file mode 100644 index 00000000..2d97e586 --- /dev/null +++ b/gamePlayer/src/components/Throbber.tsx @@ -0,0 +1,174 @@ +import { useAtomValue } from "jotai" +import { tilesetAtom } from "./PreferencesPrompt/TilesetsPrompt" +import { Art, Frame, Tileset } from "./GameRenderer/renderer" +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "preact/hooks" +import { AnimationTimer, CompensatingIntervalTimer } from "@/helpers" +import { Direction } from "@notcc/logic" +import spinnerImg from "@/spinner.gif" + +function PlainThrobber() { + return ( + + ) +} + +function renderArt( + ctx: CanvasRenderingContext2D, + tileset: Tileset, + art: Art, + extraInfo: { + tick: number + direction: Direction + state: (art: Record) => string + } +) { + if (!art) { + } else if (Array.isArray(art)) { + const tsize = tileset.tileSize + ctx.drawImage( + tileset.image, + art[0] * tsize, + art[1] * tsize, + tsize, + tsize, + 0, + 0, + tsize, + tsize + ) + } else if (art.type === "directic") { + renderArt( + ctx, + tileset, + art[Direction[extraInfo.direction] as "UP"], + extraInfo + ) + } else if (art.type === "animated" || art.type === "directional") { + let frames: Frame[] + if (art.type === "directional") { + frames = art[Direction[extraInfo.direction] as "UP"] + } else { + frames = art.frames + } + const duration = art.duration === "steps" ? 60 / 5 : art.duration + const frame = + frames[Math.floor(frames.length * ((extraInfo.tick / duration) % 1))] + renderArt(ctx, tileset, frame, extraInfo) + } else if (art.type === "overlay") { + renderArt(ctx, tileset, art.bottom, extraInfo) + renderArt(ctx, tileset, art.top, extraInfo) + } else if (art.type === "state") { + renderArt(ctx, tileset, art[extraInfo.state(art)] as Art, extraInfo) + } else { + throw new Error("Not implemented") + } +} + +function TileThrobber(props: { + art: Exclude + tileset: Tileset +}) { + const [canvas, setCanvas] = useState(null) + const ctx = useMemo(() => canvas?.getContext("2d"), [canvas]) + + const direction = useMemo( + () => 1 + Math.floor(Math.random() * 4), + [props.art] + ) + const [state, setState] = useState(null) + + const timePassedRef = useRef(0) + const lastCalledRef = useRef(performance.now()) + useLayoutEffect(() => { + setState(null) + }, [props.art]) + + const render = useCallback(() => { + const now = performance.now() + timePassedRef.current += now - lastCalledRef.current + lastCalledRef.current = now + const curSubtick = Math.floor((timePassedRef.current / 1000) * 60) + if (!ctx) return + ctx.clearRect(0, 0, props.tileset.tileSize, props.tileset.tileSize) + renderArt(ctx, props.tileset, props.art, { + tick: curSubtick, + direction, + state(art) { + if (state) return state + const states = Object.keys(art) + states.splice(states.indexOf("type"), 1) + const newState = states[Math.floor(Math.random() * states.length)] + setState(newState) + return newState + }, + }) + }, [ctx, direction, props.tileset, props.art, state]) + useEffect(() => { + const timer = new AnimationTimer(render) + return () => timer.cancel() + }, [render]) + + return ( + + ) +} + +const ALLOWED_THROBBER_ACTORS = [ + "chip", + "melinda", + "centipede", + "ant", + "glider", + "mirrorChip", + "mirrorMelinda", + "water", + "teethRed", + "teethBlue", + "tankBlue", + "tankYellow", + "toggleWall", + "holdWall", + "forceFloorRandom", + "fire", + "exit", +] + +function getRandomActor() { + return ALLOWED_THROBBER_ACTORS[ + Math.floor(Math.random() * ALLOWED_THROBBER_ACTORS.length) + ] +} + +export function Throbber() { + const tileset = useAtomValue(tilesetAtom) + const [chosenTile, setChosenTile] = useState(getRandomActor) + useEffect(() => { + if (!tileset) return + const timer = new CompensatingIntervalTimer(() => { + setChosenTile(getRandomActor()) + }, 2) + return () => { + timer.cancel() + } + }, [tileset]) + if (!tileset) return + const tileArt = tileset.art.artMap[chosenTile] + if (Array.isArray(tileArt) || tileArt === null) return + return +} diff --git a/gamePlayer/src/components/Timeline.tsx b/gamePlayer/src/components/Timeline.tsx new file mode 100644 index 00000000..9f91dd92 --- /dev/null +++ b/gamePlayer/src/components/Timeline.tsx @@ -0,0 +1,80 @@ +import { ComponentChildren, ComponentProps } from "preact" +import { TargetedEvent } from "preact/compat" +import { useCallback } from "preact/hooks" +import { twMerge } from "tailwind-merge" + +export const TIMELINE_PLAYBACK_SPEEDS = [0.05, 0.2, 0.75, 1, 1.25, 2, 5] + +export function Timeline(props: { + onScrub?: (progress: number) => void + children?: ComponentChildren +}) { + const onScrub = useCallback( + (ev: TargetedEvent) => { + if (!(ev.buttons & 1)) return + const clientLeft = ev.currentTarget.getBoundingClientRect().left + if (ev.clientX < clientLeft) return + const posFrac = (ev.clientX - clientLeft) / ev.currentTarget.offsetWidth + props.onScrub?.(posFrac) + }, + [props.onScrub] + ) + return ( +
    +
    +
    {props.children}
    +
    + ) +} + +export function TimelineHead( + props: ComponentProps<"div"> & { progress: number } +) { + return ( +
    + ) +} + +export const TIMELINE_DEFAULT_IDX = 3 + +export function TimelineBox(props: { + children?: ComponentChildren + playing: boolean + onSetPlaying: (val: boolean) => void + speedIdx: number + onSetSpeed: (speedIdx: number) => void +}) { + return ( +
    + + props.onSetSpeed(parseInt(ev.currentTarget.value))} + /> + {props.children} +
    + ) +} diff --git a/gamePlayer/src/const.ts b/gamePlayer/src/const.ts deleted file mode 100644 index 84f7dd71..00000000 --- a/gamePlayer/src/const.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Renderer, ArtContext, SpecialArt } from "./renderer" -import { Actor } from "@notcc/logic" -import { Page } from "./pager" - -export const stateFuncs: Record string> = {} - -export function registerStateFunction( - id: string, - func: (actor: T) => string -): void { - stateFuncs[id] = func as (typeof stateFuncs)[string] -} - -export const specialFuncs: Record< - string, - (this: Renderer, ctx: ArtContext, art: SpecialArt) => void -> = {} - -export function registerSpecialFunction( - id: string, - func: ( - this: Renderer, - ctx: ArtContext & { actor: T }, - art: SpecialArt - ) => void -): void { - specialFuncs[id] = func as (typeof specialFuncs)[string] -} - -export const pages: Record = {} -export function registerPage(page: Page): void { - if (page.pagePath !== null) { - pages[page.pagePath] = page - } -} diff --git a/gamePlayer/src/desktopUpdate.ts b/gamePlayer/src/desktopUpdate.ts new file mode 100644 index 00000000..51979105 --- /dev/null +++ b/gamePlayer/src/desktopUpdate.ts @@ -0,0 +1,89 @@ +import * as neutralino from "@neutralinojs/lib" +import { progressiveBodyDownload } from "./helpers" +import * as path from "path" + +const UPDATE_INFO_URL = + "https://glander.club/notcc/prewrite/desktop-update.json" + +const desktopVersion = parseInt(import.meta.env.VITE_DESKTOP_VERSION) +declare const NL_PATH: string + +async function downloadInstallResources( + url: string, + reportProgress?: (prog: number) => void +) { + const res = await fetch(url) + if (!res.ok) { + throw new Error("Failed to download update") + } + // Use raw Neutralino API to write to absolute path + const updateFilePath = path.join(NL_PATH, ".tmp-downloaded-resources.neu") + const baseFilePath = path.join(NL_PATH, "resources.neu") + await neutralino.filesystem.writeBinaryFile( + updateFilePath, + await progressiveBodyDownload(res, reportProgress) + ) + await neutralino.filesystem.move(updateFilePath, baseFilePath) + console.log(`Copied ${updateFilePath} to ${baseFilePath}`) +} + +export type UpdateInfo = { + version?: number + versionName?: string + resourcesUrl?: string + preInstall?: string + insteadInstall?: string + postInstall?: string + notice?: string +} + +function runServerCode( + code: string, + reportProgress?: (prog: number) => void +): void | Promise { + return new Function( + "desktopVersion", + "neutralino", + "path", + "reportProgress", + code + )(desktopVersion, neutralino, path, reportProgress) +} + +declare const NL_ARGS: string[] + +export async function installUpdate( + info: UpdateInfo, + reportProgress?: (prog: number) => void +): Promise { + if (!info.insteadInstall && !info.resourcesUrl) + throw new Error("Install info is missing resource URL") + if (info.preInstall) { + await runServerCode(info.preInstall, reportProgress) + } + if (info.insteadInstall) { + await runServerCode(info.insteadInstall, reportProgress) + } else { + await downloadInstallResources(info.resourcesUrl!, reportProgress) + } + if (info.postInstall) { + await runServerCode(info.postInstall, reportProgress) + } else { + // If only we had `exec`.. + await neutralino.os.execCommand( + NL_ARGS.map(v => (v.includes(" ") ? `"${v}"` : v)).join(" "), + { background: true } + ) + await neutralino.app.killProcess() + } +} + +export async function downloadUpdateInfo(): Promise { + const res = await fetch(UPDATE_INFO_URL) + if (!res.ok) throw new Error("Failed to download update info") + return await res.json() +} + +export function shouldUpdateTo(info: UpdateInfo): boolean { + return !!info.version && info.version > desktopVersion +} diff --git a/gamePlayer/src/extraTypes.d.ts b/gamePlayer/src/extra-types.d.ts similarity index 66% rename from gamePlayer/src/extraTypes.d.ts rename to gamePlayer/src/extra-types.d.ts index a1ef6b8e..22bf9560 100644 --- a/gamePlayer/src/extraTypes.d.ts +++ b/gamePlayer/src/extra-types.d.ts @@ -1,3 +1,4 @@ +/// declare module "*.c2m" { const content: string export default content diff --git a/gamePlayer/src/fileLoaders.ts b/gamePlayer/src/fileLoaders.ts deleted file mode 100644 index 084880b1..00000000 --- a/gamePlayer/src/fileLoaders.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { LevelSetLoaderFunction } from "@notcc/logic" -import { AsyncUnzipOptions, Unzipped, unzip, unzipSync } from "fflate" -import { join, normalize } from "path-browserify" - -function getFilePath(file: File): string { - return file.webkitRelativePath ?? file.name -} - -function unzipAsync( - zipData: ArrayBuffer, - options?: AsyncUnzipOptions -): Promise { - return new Promise((res, rej) => { - unzip(new Uint8Array(zipData), options ?? {}, (err, data) => { - if (err) { - rej(err) - } else { - res(data) - } - }) - }) -} - -async function unzipFileAsync( - zipData: ArrayBuffer, - fileName: string -): Promise { - const unzipped = await unzipAsync(zipData, { - filter: zipInfo => zipInfo.name.toLowerCase() === fileName.toLowerCase(), - }) - const unzippedData = Object.values(unzipped) - if (unzippedData.length < 1) - throw new Error(`No such file ${fileName} in the zip archive.`) - return unzippedData[0] -} - -export function buildZipIndex(zipData: ArrayBuffer): string[] { - const filePaths: string[] = [] - // Use the filter property to collect info about the files, but we don't care - // about the contents, for now - unzipSync(new Uint8Array(zipData), { - filter: zipInfo => { - filePaths.push(zipInfo.name.toLowerCase()) - return false - }, - }) - return filePaths -} - -export function makeZipFileLoader( - zipData: ArrayBuffer -): LevelSetLoaderFunction { - // This is Latin-1 - const decoder = new TextDecoder("iso-8859-1") - return async (path: string, binary: boolean) => { - const fileData = await unzipFileAsync(zipData, path.toLowerCase()) - if (binary) return fileData.buffer - return decoder.decode(fileData) - } -} - -export function makeFileListFileLoader( - fileList: File[] -): LevelSetLoaderFunction { - // This is Latin-1 - const decoder = new TextDecoder("iso-8859-1") - const files: Record = {} - for (const file of fileList) { - files[getFilePath(file).toLowerCase()] = file - } - return async (path: string, binary: boolean) => { - const fileData = await files[path.toLowerCase()].arrayBuffer() - if (binary) return fileData - return decoder.decode(fileData) - } -} - -export function makeHttpFileLoader(url: string): LevelSetLoaderFunction { - return async (path: string, binary: boolean) => { - const fileData = await fetch(`${url}${path}`) - if (binary) return await fileData.arrayBuffer() - return await fileData.text() - } -} - -export function buildFileListIndex(fileList: File[]): string[] { - return fileList.map(file => getFilePath(file)) -} - -export function makeLoaderWithPrefix( - prefix: string, - loader: LevelSetLoaderFunction -): LevelSetLoaderFunction { - return (path: string, binary: boolean) => { - const joinedPath = normalize(join(prefix, path)) - return loader(joinedPath, binary) - } -} diff --git a/gamePlayer/src/configPath.ts b/gamePlayer/src/fs/configPath.ts similarity index 100% rename from gamePlayer/src/configPath.ts rename to gamePlayer/src/fs/configPath.ts diff --git a/gamePlayer/src/fs/idb.ts b/gamePlayer/src/fs/idb.ts new file mode 100644 index 00000000..30bbc9e9 --- /dev/null +++ b/gamePlayer/src/fs/idb.ts @@ -0,0 +1,209 @@ +import { os } from "@neutralinojs/lib" +import { UseStore, createStore, del, get, set, update } from "idb-keyval" +import { join, parse } from "path" + +// Use an IndexedDB for the filesystem and for save/load prompts + +const store = !globalThis.window + ? (null as unknown as UseStore) + : createStore("notcc", "fs") + +function normalizeRootPath(path: string): string { + path = join("/", path) + if (path.endsWith("/") && path !== "/") { + path = path.slice(0, -1) + } + return path +} + +async function assertValidPath(path: string): Promise { + const parsedPath = parse(path) + const dir = await get(parsedPath.dir, store) + if (!dir) + throw new Error(`failed to access ${path}, no ${parsedPath.dir} directory`) +} + +async function addDirEnt(path: string): Promise { + const parsedPath = parse(path) + if (parsedPath.base === "") return + await update( + parsedPath.dir, + ents => { + if (!ents.includes(parsedPath.base)) { + ents.push(parsedPath.base) + } + return ents + }, + store + ) +} + +async function removeDirEnt(path: string): Promise { + const parsedPath = parse(path) + const dir = await get(parsedPath.dir, store) + if (!dir) + throw new Error(`failed to remove ${path}, no ${parsedPath.dir} directory`) + await update( + parsedPath.dir, + ents => { + if (ents.includes(parsedPath.base)) { + ents.splice(ents.indexOf(parsedPath.base), 1) + } + return ents + }, + store + ) +} + +export async function readFile(path: string): Promise { + path = normalizeRootPath(path) + await assertValidPath(path) + const result = await get(path, store) + if (!result) throw new Error(`${path}: no such file`) + if (result instanceof Array) throw new Error(`${path}: is a directory`) + return result +} + +export async function writeFile( + path: string, + data: ArrayBufferLike +): Promise { + path = normalizeRootPath(path) + await assertValidPath(path) + if (await isDir(path)) throw new Error(`${path}: is a directory`) + await addDirEnt(path) + await set(path, data, store) +} + +export async function remove(path: string): Promise { + path = join("/", path) + await assertValidPath(path) + await removeDirEnt(path) + await del(path, store) +} + +export async function isFile(path: string): Promise { + path = normalizeRootPath(path) + await assertValidPath(path) + const result = await get(path, store) + return !!result && result instanceof ArrayBuffer +} + +export async function readDir(path: string): Promise { + path = normalizeRootPath(path) + await assertValidPath(path) + const result = await get(path, store) + if (!result) throw new Error(`${path}: no such directory`) + if (result instanceof ArrayBuffer) throw new Error(`${path}: is a file`) + return result +} + +export async function makeDir(path: string): Promise { + path = normalizeRootPath(path) + if (await isFile(path)) throw new Error(`${path}: is a file`) + if (await isDir(path)) return + await addDirEnt(path) + await set(path, [], store) +} + +export async function isDir(path: string): Promise { + path = normalizeRootPath(path) + await assertValidPath(path) + const result = await get(path, store) + return !!result && result instanceof Array +} + +export async function exists(path: string): Promise { + path = normalizeRootPath(path) + const ent = await get(path, store) + return ent !== undefined +} + +async function moveInternal(source: string, dest: string) { + if (await isFile(source)) { + const content = await readFile(source) + await remove(source) + await writeFile(dest, content) + return + } + await makeDir(dest) + + for (const item of await readDir(source)) { + await moveInternal(join(source, item), join(dest, item)) + } + + await remove(source) +} + +export async function move(source: string, dest: string): Promise { + source = normalizeRootPath(source) + await assertValidPath(source) + + dest = normalizeRootPath(dest) + const destParent = parse(dest).dir + await assertValidPath(destParent) + await moveInternal(source, dest) +} + +export async function initFilesystem(): Promise { + if (!(await exists("/"))) { + await set("/", [], store) + } +} + +function showInputPrompt(fileLoader: HTMLInputElement): Promise { + return new Promise(res => { + fileLoader.addEventListener("change", () => { + if (fileLoader.files === null || fileLoader.files.length === 0) { + res(null) + } else { + res(Array.from(fileLoader.files)) + } + fileLoader.remove() + }) + fileLoader.click() + }) +} + +export function showLoadPrompt( + _title?: string, + options?: os.OpenDialogOptions +): Promise { + const fileLoader = document.createElement("input") + fileLoader.type = "file" + if (options?.filters !== undefined) { + fileLoader.accept = options.filters + .reduce((acc, ent) => acc.concat(ent.extensions), []) + .map(ext => `.${ext}`) + .join(",") + } + fileLoader.multiple = !!options?.multiSelections + return showInputPrompt(fileLoader) +} + +export function showDirectoryPrompt( + _title?: string, + _options?: os.FolderDialogOptions +): Promise { + const fileLoader = document.createElement("input") + fileLoader.type = "file" + fileLoader.webkitdirectory = true + return showInputPrompt(fileLoader) +} + +export function showSavePrompt( + fileData: ArrayBuffer, + _title?: string, + options?: os.SaveDialogOptions +) { + const blob = new Blob([fileData], { type: "application/octet-stream" }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + if (options?.defaultPath !== undefined) { + anchor.download = parse(options.defaultPath).base + } + anchor.href = url + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) +} diff --git a/gamePlayer/src/fs/index.ts b/gamePlayer/src/fs/index.ts new file mode 100644 index 00000000..91080a6c --- /dev/null +++ b/gamePlayer/src/fs/index.ts @@ -0,0 +1,137 @@ +import { isDesktop, zipAsync } from "@/helpers" +import { AsyncZippable } from "fflate" +import * as idbFs from "./idb" +import * as neuFs from "./neutralino" +import { join, normalize } from "path" + +const exportMod = isDesktop() ? neuFs : idbFs + +export const readFile = exportMod.readFile +export const writeFile = exportMod.writeFile +export const remove = exportMod.remove +export const makeDir = exportMod.makeDir +export const readDir = exportMod.readDir +export const isDir = exportMod.isDir +export const isFile = exportMod.isFile +export const showLoadPrompt = exportMod.showLoadPrompt +export const showDirectoryPrompt = exportMod.showDirectoryPrompt +export const showSavePrompt = exportMod.showSavePrompt +export const exists = exportMod.exists +export const move = exportMod.move + +export async function readJson(path: string): Promise { + const buf = await readFile(path) + return JSON.parse(new TextDecoder("utf-8").decode(buf)) +} +export async function writeJson(path: string, val: T): Promise { + const buf = new TextEncoder().encode(JSON.stringify(val)) + return writeFile(path, buf.buffer) +} + +export async function recusiveRemove(path: string): Promise { + if (await isFile(path)) { + await remove(path) + return + } + const dirEnts = await readDir(path) + for (const dirEnt of dirEnts) { + const entPath = join(path, dirEnt) + if (await isDir(entPath)) { + await recusiveRemove(entPath) + } else { + await remove(entPath) + } + } + await remove(path) +} + +async function buildFsTree(dir: string = "."): Promise { + const tree: AsyncZippable = {} + for (const leaf of await readDir(dir)) { + const leafPath = join(dir, leaf) + if (await isFile(leafPath)) { + tree[leaf] = new Uint8Array(await readFile(leafPath)) + } else { + tree[leaf] = await buildFsTree(leafPath) + } + } + return tree +} +export async function findAllFiles( + dir: string, + postfixPath = "." +): Promise { + const files: string[] = [] + for (const leaf of await readDir(join(dir, postfixPath))) { + const leafPath = join(dir, postfixPath, leaf) + if (await isFile(leafPath)) { + files.push(join(postfixPath, leaf)) + } else { + files.push(...(await findAllFiles(dir, leaf))) + } + } + return files +} + +export async function makeFsZip(): Promise { + return await zipAsync(await buildFsTree()) +} + +export async function makeDirP(dir: string): Promise { + dir = normalize(dir) + let fullPath = "" + for (const dirPart of dir.split("/")) { + fullPath += dirPart + "/" + if (!(await exists(fullPath))) { + await makeDir(fullPath) + } else if (await isFile(fullPath)) { + throw new Error(`Can't makeDirP in ${fullPath} - it's a file`) + } + } +} + +export async function initNotCCFs(): Promise { + await exportMod.initFilesystem() + await makeDir(".") + await makeDir("solutions") + await makeDir("solutions/default") + await makeDir("routes") + await makeDir("tilesets") + await makeDir("sfx") + await makeDir("sets") + await makeDir("cache") +} + +export interface CaseResolverNode { + value: string + children?: Record +} + +export class CaseResolver implements CaseResolverNode { + value = "/" + children?: Record + async resolve(path: string): Promise { + let node: CaseResolverNode = this + let realPath = "" + for (const item of normalize("/" + path) + .slice(1) + .split("/")) { + if (!node.children) { + if (!(await isDir(realPath))) + throw new Error(`"${realPath}" is not a directory`) + node.children = Object.fromEntries( + (await readDir(realPath)).map(dir => [ + dir.toLowerCase(), + { value: dir }, + ]) + ) + } + const canonicalItem = item.toLowerCase() + if (!(canonicalItem in node.children)) + throw new Error(`"${realPath}" doesn't contain "${item}"`) + node = node.children[canonicalItem] + realPath = join(realPath, node.value) + } + return realPath + } +} diff --git a/gamePlayer/src/fs/neutralino.ts b/gamePlayer/src/fs/neutralino.ts new file mode 100644 index 00000000..69f4397b --- /dev/null +++ b/gamePlayer/src/fs/neutralino.ts @@ -0,0 +1,177 @@ +import { + filesystem, + init as neuInit, + os, + window as win, +} from "@neutralinojs/lib" +import { basename, dirname, join, parse } from "path" +import { applicationConfigPath } from "./configPath" +import { desktopPlatform } from "@/helpers" + +/** + * Uuugh, Neutralino depends on a couple of global variables prefixed with NL_ + * to be present to function. Problem is, the variables can only be gotten by + * the server serving a __neutralino_globals.js file. We don't want it to be + * served if it's is a web build, but there's no mechanism to include/exclude + * script tags at build time. So, download the globals file, parse it for + * global-ish statements, and add the globals manually instead. Epic hack. + */ +const globalVarRegex = /var (NL_\w+)=([^;]+);/g +async function loadNeuGlobalVariables(): Promise { + const globalsResponse = await fetch("__neutralino_globals.js") + if (!globalsResponse.ok) + throw new Error("Failed to download Neutralino global variables") + const globalsText = await globalsResponse.text() + let match: RegExpExecArray | null + while ((match = globalVarRegex.exec(globalsText))) { + const key = match[1] + const valString = match[2] + // I don't want to build a JS parser here, so let's just eval it. + const val = new Function(`return ${valString}`)() + ;(globalThis as any)[key] = val + } +} + +async function dirExists(path: string): Promise { + try { + await filesystem.readDirectory(path) + } catch (err) { + return false + } + return true +} + +async function getPath(pathName: string) { + let path = join(await applicationConfigPath("NotCC"), pathName) + return path +} + +export async function initFilesystem(): Promise { + await loadNeuGlobalVariables() + neuInit() + if (desktopPlatform() === "windows") win.center() +} + +export async function readFile(path: string): Promise { + return await filesystem.readBinaryFile(await getPath(path)) +} + +export async function writeFile( + path: string, + data: ArrayBufferLike +): Promise { + await filesystem.writeBinaryFile(await getPath(path), data as ArrayBuffer) +} + +export async function remove(path: string): Promise { + await filesystem.remove(await getPath(path)) +} + +export async function makeDir(path: string): Promise { + const truePath = await getPath(path) + if (!(await dirExists(truePath))) { + // Dumbest hack on the planet, native `createDirectory` doesn't work on Windows for some reason + if (desktopPlatform() === "windows") { + await os.execCommand( + `mkdir "${basename(truePath).replaceAll(/["^]/g, " ")}"`, + { cwd: dirname(truePath) } + ) + } else { + await filesystem.createDirectory(truePath) + } + } +} + +export async function isDir(path: string): Promise { + const truePath = await getPath(path) + const stat = await filesystem.getStats(truePath) + return stat.isDirectory +} + +export async function isFile(path: string): Promise { + const truePath = await getPath(path) + const stat = await filesystem.getStats(truePath) + return stat.isFile +} + +export async function readDir(path: string): Promise { + const ents = await filesystem.readDirectory(await getPath(path)) + return ents.map(ent => ent.entry).filter(ent => ent !== "." && ent !== "..") +} + +export async function exists(path: string): Promise { + const truePath = await getPath(path) + return await filesystem + .getStats(truePath) + .then(() => true) + .catch(err => err.code !== "NE_FS_NOPATHE") +} + +export async function move(source: string, dest: string): Promise { + await filesystem.move(await getPath(source), await getPath(dest)) +} + +export async function showLoadPrompt( + title?: string, + options?: os.OpenDialogOptions +): Promise { + const fileNames = await os.showOpenDialog(title, options) + // Have to re-focus on the document because the native prompts apparently remove all focus? + document.body.focus() + if (fileNames.length === 0) return null + const files: File[] = [] + for (const fileName of fileNames) { + const stat = await filesystem.getStats(fileName) + const bin = await filesystem.readBinaryFile(fileName) + files.push( + new File([bin], basename(fileName), { + lastModified: stat.modifiedAt, + }) + ) + } + return files +} + +async function scanDirectory(dirPath: string, prefix: string): Promise { + const entries = await filesystem.readDirectory(dirPath) + const files: File[] = [] + for (const ent of entries) { + if (ent.entry === "." || ent.entry === "..") continue + const filePath = join(dirPath, ent.entry) + const prefixPath = join(prefix, ent.entry) + if (ent.type === "FILE") { + const stat = await filesystem.getStats(filePath) + const bin = await filesystem.readBinaryFile(filePath) + const file = new File([bin], ent.entry, { lastModified: stat.modifiedAt }) + // Define the property explicitly on the `file`, since the underlying `File.prototype.webkitRelativePath` + // getter (which assigning with `=` uses) doesn't allow writing. ugh + Object.defineProperty(file, "webkitRelativePath", { value: prefixPath }) + files.push(file) + } else { + files.push(...(await scanDirectory(filePath, prefixPath))) + } + } + return files +} + +export async function showDirectoryPrompt( + title?: string, + options?: os.FolderDialogOptions +): Promise { + const dirName = await os.showFolderDialog(title, options) + document.body.focus() + if (dirName === "") return null + return await scanDirectory(dirName, parse(dirName).base) +} + +export async function showSavePrompt( + fileData: ArrayBufferLike, + title?: string, + options?: os.SaveDialogOptions +): Promise { + const savePath = await os.showSaveDialog(title, options) + document.body.focus() + if (savePath === "") return false + await filesystem.writeBinaryFile(savePath, fileData as ArrayBuffer) + return true +} diff --git a/gamePlayer/src/gliderbotSets.ts b/gamePlayer/src/gliderbotSets.ts deleted file mode 100644 index 510b268d..00000000 --- a/gamePlayer/src/gliderbotSets.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { - findScriptName, - LevelSetLoaderFunction, - parseScriptMetadata, - ScriptMetadata, -} from "@notcc/logic" -import { basename, join } from "path-browserify" -import { makeHttpFileLoader } from "./fileLoaders" - -const censoredSetNames: string[] = ["CC1STEAM", "steamcc1", "cc1cropped", "cc2"] - -// Some important sets (CC2LP1, CCLP ports) don't have set metadata as of writing, -// so inject our own metadata to put them near the top -const subsituteSetMetadata: Record = { - cc2lp1: { - title: "Chips Challenge 2 Level Pack 1", - by: "The Community", - description: - "Chip's Challenge 2 Level Pack 1 is the first community level pack for Chip's Challenge 2. It contains 200 levels created by and voted on by fans. Read about it at https://bitbusters.club/cc2lp1", - difficulty: 4, - listingPriority: "top", - }, - "cclp1-cc2": { - title: "Chips Challenge Level Pack 1 (Steam)", - by: "The Community", - difficulty: 3, - description: - "Chip's Challenge Level Pack 1 is a beginner-friendly level pack for Chip's Challenge. This is the port of CCLP1 to the Steam ruleset. May be incomplete.", - listingPriority: "top", - }, - "CCLP4-CC2": { - title: "Chips Challenge Level Pack 4 (Steam)", - by: "The Community", - difficulty: 4, - description: - "Chip's Challenge Level Pack 4 is the community's fourth level pack for Chip's Challenge. This is the port of CCLP4 to the Steam ruleset. May be incomplete.", - listingPriority: "top", - }, -} - -const listingRegex = /.+<\/a>\s+(.+:..)/g -const gliderbotWebsite = "https://bitbusters.club/gliderbot/sets/cc2/" - -export interface GliderbotSet { - metadata: ScriptMetadata - previewImage: string | null - mainScript: string - rootDirectory: string - ident: string - lastChanged: Date - loaderFunction: LevelSetLoaderFunction -} - -function getMetadataPriority(set: GliderbotSet): number { - let priority = 0 - if (set.metadata.listingPriority === "top") priority += 100 - if (set.metadata.listingPriority === "bottom") priority -= 100 - // Unlisted sets should be sorted out by getGbSets - if (set.metadata.description !== undefined) priority += 2 - else if (set.previewImage !== null) priority += 1 - return priority -} - -export function metadataComparator(a: GliderbotSet, b: GliderbotSet): number { - return getMetadataPriority(a) - getMetadataPriority(b) -} - -class NginxNode { - lastEdited?: Date - constructor( - public parent: NginxDirectory | null = null, - public name: string - ) {} - getPath(): string { - if (!this.parent) return this.name - return join(this.parent.getPath(), this.name) - } -} - -class NginxFile extends NginxNode { - async download(binary: false): Promise - async download(binary: true): Promise - async download(binary: boolean): Promise { - const fileRes = await fetch(`${gliderbotWebsite}/${this.getPath()}`) - if (binary) { - return await fileRes.arrayBuffer() - } else { - return await fileRes.text() - } - } -} - -class NginxDirectory extends NginxNode { - constructor( - parent: NginxDirectory | null, - name: string, - public contents: Record = {} - ) { - super(parent, name) - } - findNode(pathStr: string): NginxNode { - const parsedPath = /^(.+)\/(.*)/.exec(pathStr) - const dirName = parsedPath?.[1] ?? null - const nodeName = parsedPath?.[2] ?? pathStr - if (dirName === null || dirName[1] === ".") { - const node = this.contents[nodeName.toLowerCase()] - if (node === undefined) - throw new Error( - `No such file or directory ${nodeName} in ${this.getPath()}` - ) - // We're looking for a node in this directory - return node - } - const node = this.contents[`${dirName.toLowerCase()}/`] - if (node === undefined) - throw new Error(`No such directory ${dirName} in ${this.getPath()}`) - if (!(node instanceof NginxDirectory)) - throw new Error(`${dirName} in ${this.getPath()} is not a directory.`) - return node.findNode(nodeName) - } -} - -export function makeNginxHttpFileLoader( - url: string, - dir: NginxDirectory -): LevelSetLoaderFunction { - const httpLoader = makeHttpFileLoader(url) - return (path: string, binary: boolean) => { - // Note that `path` and `node.getPath()` won't always be the same - `path` might have the wrong casitivy - // `findNode` correctly resolves it, and so `node.getPath()` will always have the correct casitivy - const node = dir.findNode(path) - const correctPath = node.getPath() - return httpLoader(correctPath, binary) - } -} - -async function scanNginxIndex( - dirPath: string, - parent?: NginxDirectory -): Promise { - const indexRes = await fetch( - `${gliderbotWebsite}/${parent ? join(parent.getPath(), dirPath) : dirPath}` - ) - if (!indexRes.ok) throw new Error(indexRes.statusText) - const pageData = await indexRes.text() - const directory = new NginxDirectory(parent ?? null, basename(dirPath)) - const childPromises: Promise[] = [] - // eslint-disable-next-line no-constant-condition - while (true) { - const match = listingRegex.exec(pageData) - if (!match) break - const entryName = decodeURIComponent(match[1]) - const lastEdited = new Date(match[2]) - if (entryName.endsWith("/")) { - if (censoredSetNames.includes(entryName.slice(0, -1))) continue - // This is a directory - childPromises.push( - scanNginxIndex(entryName, directory).then(ent => { - ent.lastEdited = lastEdited - directory.contents[entryName.toLowerCase()] = ent - }) - ) - } else { - const file = new NginxFile(directory, entryName) - file.lastEdited = lastEdited - directory.contents[entryName.toLowerCase()] = file - } - } - await Promise.all(childPromises) - return directory -} - -async function findGbSet(dir: NginxDirectory): Promise { - for (const file of Object.values(dir.contents)) { - if (!(file instanceof NginxFile)) continue - if (!file.name.endsWith(".c2g")) continue - const scriptText = await file.download(false) - const scriptTitle = findScriptName(scriptText) - // A c2g file without a title. Could possibly be a `chain`-able helper script - if (!scriptTitle) continue - const metadata = - subsituteSetMetadata[dir.name] ?? parseScriptMetadata(scriptText) - return { - mainScript: file.name, - metadata, - previewImage: - "preview.png" in dir.contents ? dir.contents["preview.png"].name : null, - lastChanged: dir.lastEdited!, - rootDirectory: `${gliderbotWebsite}${dir.getPath()}/`, - ident: dir.name, - loaderFunction: makeNginxHttpFileLoader(gliderbotWebsite, dir), - } - } - return null -} - -export async function getGbSets(): Promise { - const rootIndex = await scanNginxIndex(".") - const setPromises: Promise[] = [] - for (const setDir of Object.values(rootIndex.contents)) { - if (!(setDir instanceof NginxDirectory)) continue - setPromises.push(findGbSet(setDir)) - } - return (await Promise.all(setPromises)).filter( - (set): set is GliderbotSet => - set !== null && set.metadata.listingPriority !== "unlisted" - ) -} - -export async function lookupGbSet(name: string): Promise { - if (censoredSetNames.includes(name)) return null - const localIndex = await scanNginxIndex(name) - return await findGbSet(localIndex) -} diff --git a/gamePlayer/src/helpers.ts b/gamePlayer/src/helpers.ts new file mode 100644 index 00000000..42f6a9c9 --- /dev/null +++ b/gamePlayer/src/helpers.ts @@ -0,0 +1,521 @@ +import { + AsyncUnzipOptions, + AsyncZippable, + Unzipped, + unzip, + unzlib, + zip, + zlib, +} from "fflate" +import { Getter, Setter, useStore } from "jotai" +import { Ref } from "preact" +import { useCallback, useEffect, useRef, useState } from "preact/hooks" + +export type EffectFn = (get: Getter, set: Setter) => void | (() => void) +type AnyFunction = (...args: any[]) => any + +export function ignorantAtomEffectHook(effectFn: EffectFn) { + return () => { + const { get, set } = useStore() + useEffect(() => effectFn(get, set), []) + } +} + +export function unzlibAsync(buf: Uint8Array): Promise { + return new Promise((res, rej) => { + unzlib(buf, (err, data) => { + if (err) rej(err) + else res(data) + }) + }) +} +export function zlibAsync(buf: Uint8Array): Promise { + return new Promise((res, rej) => { + zlib(buf, (err, data) => { + if (err) rej(err) + else res(data) + }) + }) +} +export function zipAsync(data: AsyncZippable): Promise { + return new Promise((res, rej) => { + zip(data, (err, data) => { + if (err) rej(err) + else res(data) + }) + }) +} +export function uzipAsync(data: Uint8Array): Promise { + return new Promise((res, rej) => { + unzip(data, (err, data) => { + if (err) rej(err) + else res(data) + }) + }) +} +export function latin1ToBuffer(str: string): Uint8Array { + return Uint8Array.from(str, c => c.charCodeAt(0)) +} +export function bufferToLatin1(bytes: ArrayBufferLike): string { + return Array.from(new Uint8Array(bytes), byte => + String.fromCharCode(byte) + ).join("") +} +export function decodeBase64(str: string): Uint8Array { + return latin1ToBuffer(atob(str.replace(/-/g, "+").replace(/_/g, "/"))) +} +export function encodeBase64(bytes: ArrayBufferLike): string { + return btoa(bufferToLatin1(bytes)).replace(/\+/g, "-").replace(/\//g, "_") +} + +export function applyRef(ref: Ref | undefined, val: T | null): void { + if (typeof ref === "function") ref(val) + else if (ref) ref.current = val +} + +export function readImage(buf: ArrayBuffer): Promise { + const blob = new Blob([buf]) + return makeImagefromBlob(blob) +} + +export async function makeImagefromBlob( + imageBlob: Blob +): Promise { + const url = URL.createObjectURL(imageBlob) + return fetchImage(url).finally(() => URL.revokeObjectURL(url)) +} + +export function canvasToBin(img: HTMLCanvasElement) { + return new Promise(res => { + img.toBlob(blob => { + res(blob!.arrayBuffer()) + }, "image/png") + }) +} + +export function fetchImage(link: string): Promise { + return new Promise((res, rej) => { + const img = new Image() + img.addEventListener("load", () => res(img)) + img.addEventListener("error", err => rej(err.error)) + img.src = link + }) +} + +export function reencodeImage(image: HTMLImageElement): HTMLCanvasElement { + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const ctx = canvas.getContext("2d")! + ctx.drawImage(image, 0, 0) + return canvas +} + +export class TimeoutTimer { + id: number + constructor(callback: AnyFunction, time: number) { + this.id = setTimeout(callback, time * 1000) + } + cancel(): void { + clearTimeout(this.id) + } +} + +export class CompensatingIntervalTimer { + id: number + timeToProcess: number = 0 + lastCallTime: number = performance.now() + constructor( + public callback: AnyFunction, + public time: number + ) { + this.nextCall = this.nextCall.bind(this) + this.id = setInterval(this.nextCall, time * 1000) as unknown as number + } + nextCall(): void { + const time = performance.now() + const dt = time - this.lastCallTime + this.lastCallTime = time + this.timeToProcess += dt / 1000 + while (this.timeToProcess > 0) { + this.callback() + this.timeToProcess -= this.time + } + } + adjust(newTime: number) { + clearInterval(this.id) + this.time = newTime + this.id = setInterval(this.nextCall, newTime * 1000) as unknown as number + } + cancel(): void { + clearInterval(this.id) + } +} + +export class AnimationTimer { + id: number + constructor(public callback: AnyFunction) { + this.nextCall = this.nextCall.bind(this) + this.id = requestAnimationFrame(this.nextCall) + } + nextCall(): void { + this.id = requestAnimationFrame(this.nextCall) + this.callback() + } + cancel(): void { + cancelAnimationFrame(this.id) + } +} + +export function useJotaiFn( + fn: (get: Getter, set: Setter, ...args: A) => R +): (...args: A) => R { + const { get, set } = useStore() + return (...args) => fn(get, set, ...args) +} + +export function sleep(s: number): Promise { + return new Promise(res => { + setTimeout(() => res(), s * 1000) + }) +} + +export function formatTimeLeft(timeLeft: number, padding = false) { + let sign = "" + if (timeLeft < 0) { + timeLeft = -timeLeft + sign = "-" + } + const subtickStr = [padding ? " " : "", "⅓", "⅔"] + const subtick = timeLeft % 3 + const int = Math.ceil(timeLeft / 60) + let decimal = (Math.floor((timeLeft / 3) % 20) * 5) + .toString() + .padStart(2, "0") + if (decimal === "00" && subtick === 0) { + decimal = "100" + } + return `${sign}${int}.${decimal}${ + decimal === "100" ? "" : subtickStr[subtick] + }` +} + +export function formatTicks(timeLeft: number) { + let sign = "" + if (timeLeft < 0) { + timeLeft = -timeLeft + sign = "-" + } + const subtickStr = ["", "⅓", "⅔"] + const subtick = timeLeft % 3 + const int = Math.floor(timeLeft / 60) + let decimal = (Math.floor((timeLeft / 3) % 20) * 5) + .toString() + .padStart(2, "0") + return `${sign}${int}.${decimal}${subtickStr[subtick]}` +} + +export function isDesktop(): boolean { + return import.meta.env.VITE_BUILD_PLATFORM === "desktop" +} + +export function desktopPlatform(): null | "windows" | "linux" | "darwin" { + return isDesktop() ? (globalThis as any).NL_OS?.toLowerCase() ?? null : null +} + +let ssg = false + +export function setSSG(val: boolean) { + ssg = val +} + +export function isSSG(): boolean { + return ssg +} + +// Semaphore to limit eg. concurrent requests to N at a time +export class BasicSemaphore { + releaseQueue: (() => void)[] = [] + locks: number = 0 + constructor(public limit: number) {} + enter(): Promise { + // If this semaphore has any capacity left, use one of the locks + if (this.locks < this.limit) { + this.locks += 1 + return Promise.resolve() + } + // Otherwise, wait until another user has left + return new Promise(res => { + this.releaseQueue.push(res) + }) + } + leave(): void { + if (this.locks <= 0) + throw new Error("Left a semaphore despite there being no locks left") + if (this.locks === this.limit) { + // We're making new capacity, let a waiting user through if there are any + const releaseFn = this.releaseQueue.shift() + if (releaseFn) { + releaseFn() + return + } + } + this.locks -= 1 + } +} + +export async function resErrorToString(res: Response): Promise { + return `${res.status} ${res.statusText}: ${await res.text()}` +} + +const NO_PROMISE_VALUE = Symbol() + +type UsePromise = ( + | { state: "working" } + | { state: "done"; value: T } + | { state: "error"; error: Error } +) & { repeat(): void } + +export function usePromise( + maker: () => Promise, + deps: unknown[] +): UsePromise { + const [value, setValue] = useState( + NO_PROMISE_VALUE + ) + const [error, setError] = useState(undefined) + const ignoreResultRef = useRef(false) + const doPromise = useCallback(() => { + setError(undefined) + setValue(NO_PROMISE_VALUE) + maker() + .then(val => { + if (ignoreResultRef.current) return + setValue(val) + }) + .catch(err => { + if (ignoreResultRef.current) return + setError(err) + }) + }, [maker]) + useEffect(() => { + doPromise() + }, deps) + useEffect( + () => () => { + ignoreResultRef.current = true + }, + [] + ) + return { + value: value === NO_PROMISE_VALUE ? undefined : value, + error, + state: value !== NO_PROMISE_VALUE ? "done" : error ? "error" : "working", + repeat: doPromise, + } as UsePromise +} + +export async function aiGather(ai: AsyncIterable): Promise { + const items = [] + for await (const item of ai) { + items.push(item) + } + return items +} +export async function aiFind( + ai: AsyncIterable, + func: (item: T, idx: number) => boolean +): Promise { + let idx = 0 + for await (const item of ai) { + if (func(item, idx)) return item + idx += 1 + } + return null +} +export async function* aiFilter( + ai: AsyncIterable, + func: (item: T, idx: number) => boolean +): AsyncIterableIterator { + let idx = 0 + for await (const item of ai) { + if (func(item, idx)) yield item + idx += 1 + } +} +export function formatBytes(bytes: number) { + let suffix: string = "bytes" + let suffixDiv: number = 1 + if (bytes > 1024 ** 3) { + suffix = "gibibytes" + suffixDiv = 1024 ** 3 + } else if (bytes > 1024 ** 2) { + suffix = "mebibytes" + suffixDiv = 1024 ** 2 + } else if (bytes > 1024) { + suffix = "kibibytes" + suffixDiv = 1024 + } + return `${(bytes / suffixDiv).toFixed(3)} ${suffix}` +} + +// Lol + +// Max-heap +export class PriorityQueue { + items: T[] = [] + priorities: number[] = [] + get size(): number { + return this.items.length + } + swap(aIdx: number, bIdx: number) { + const aItem = this.items[aIdx] + this.items[aIdx] = this.items[bIdx] + this.items[bIdx] = aItem + const aPriority = this.priorities[aIdx] + this.priorities[aIdx] = this.priorities[bIdx] + this.priorities[bIdx] = aPriority + } + push(item: T, priority: number): void { + const newLen = this.items.push(item) + this.priorities.push(priority) + this.siftUp(newLen - 1) + } + pop() { + if (this.size === 1) { + this.priorities.pop() + return this.items.pop() + } + const item = this.items[0] + this.items[0] = this.items.pop()! + this.priorities[0] = this.priorities.pop()! + this.siftDown(0) + return item + } + siftDown(idx: number) { + const thisPrio = this.priorities[idx] + + const leftIdx = 2 * idx + 1 + // No children to sift with + if (leftIdx >= this.size) return + const leftPrio = this.priorities[leftIdx] + + const rightIdx = 2 * idx + 2 + if (rightIdx >= this.size) { + // Only one child to sift with + if (thisPrio < leftPrio) { + this.swap(idx, leftIdx) + // And no further children, yay! + } + return + } + const rightPrio = this.priorities[rightIdx] + + if (thisPrio >= leftPrio && thisPrio >= rightPrio) return + + if (leftPrio > rightPrio) { + this.swap(idx, leftIdx) + this.siftDown(leftIdx) + } else { + this.swap(idx, rightIdx) + this.siftDown(rightIdx) + } + } + siftUp(idx: number) { + if (idx === 0) return + const thisPrio = this.priorities[idx] + const parentIdx = ((idx - 1) / 2) | 0 + const parentPrio = this.priorities[parentIdx] + if (thisPrio > parentPrio) { + this.swap(idx, parentIdx) + this.siftUp(parentIdx) + } + } + adjust(pred: (v: T) => boolean, newPriority: number) { + const idx = this.items.findIndex(pred) + if (idx === -1) throw new Error("Predicate did not find item in heap") + this.priorities[idx] = newPriority + this.siftUp(idx) + } +} + +export function keypressIsFocused(ev: KeyboardEvent) { + return ( + !!document.querySelector("dialog.modal[open]") || + (ev.target as HTMLElement).tagName === "INPUT" + ) +} + +export function unzipAsync( + zipData: ArrayBuffer, + options?: AsyncUnzipOptions +): Promise { + return new Promise((res, rej) => { + unzip(new Uint8Array(zipData), options ?? {}, (err, data) => { + if (err) { + rej(err) + } else { + res(data) + } + }) + }) +} + +export type Falliable = + | { result: "resolve"; value: T } + | { result: "reject"; error: any } + +export function falliable(p: Promise): Promise> { + return p + .then(v => ({ result: "resolve" as const, value: v })) + .catch(err => ({ result: "reject", error: err })) +} + +export function iterMapFind( + iter: Iterable, + f: (v: T, idx: number) => R | null +): R | null { + let idx = 0 + for (const item of iter) { + const res = f(item, idx) + idx += 1 + if (res == null) continue + return res + } + return null +} + +export function dedup(arr: T[]): T[] { + const vals = new Set() + return arr.filter(v => { + if (vals.has(v)) return false + vals.add(v) + return true + }) +} + +export async function progressiveBodyDownload( + res: Response, + reportProgress?: (progress: number) => void +): Promise { + const reader = res.body?.getReader() + if (!reader) throw new Error("Failed to get reader") + const bodyLengthStr = res.headers.get("Content-Length") + if (!bodyLengthStr) throw new Error("Failed to get content length header") + const bodyLength = parseInt(bodyLengthStr) + if (isNaN(bodyLength)) + throw new Error("Failed to parse content length header") + const data = new Uint8Array(bodyLength) + let offset = 0 + reportProgress?.(0) + while (true) { + const { done, value } = await reader.read() + if (done) break + data.set(value, offset) + offset += value.byteLength + reportProgress?.(offset / bodyLength) + } + if (offset < bodyLength) + throw new Error("Failed to download set: body too short") + return data.buffer +} diff --git a/gamePlayer/src/index.css b/gamePlayer/src/index.css new file mode 100644 index 00000000..e82de67f --- /dev/null +++ b/gamePlayer/src/index.css @@ -0,0 +1,66 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + width: 100vw; + height: 100vh; + } + body, + dialog { + @apply text-neutral-100; + } + #app { + display: contents; + } +} + +@layer components { + ::backdrop { + background: #0008; + } + #app-root { + accent-color: theme(colors.theme.600); + scrollbar-color: theme(colors.theme.600) transparent; + } + a { + @apply underline; + } + .box { + @apply bg-theme-900 border-theme-800 rounded-md border-2 p-2 shadow-lg; + } + button, + button[type="submit"] { + @apply cursor-auto select-none appearance-none border-2 border-b-[--low-color] border-l-[--high-color] border-r-[--low-color] border-t-[--high-color] bg-gradient-to-b from-[--high-color] to-[--low-color] px-2; + --high-color: theme(colors.theme.600); + --low-color: theme(colors.theme.700); + } + button:enabled:hover, + button[type="submit"]:enabled:hover { + --high-color: theme(colors.theme.500); + --low-color: theme(colors.theme.600); + } + button:enabled:active, + button[type="submit"]:enabled:active { + --high-color: theme(colors.theme.700); + --low-color: theme(colors.theme.800); + } + button:disabled, + button[type="submit"]:disabled { + @apply text-neutral-500; + --high-color: theme(colors.theme.800); + --low-color: theme(colors.theme.900); + } + select, + input[type="number"], + input[type="text"] { + @apply bg-theme-950 px-[2px] py-[1px]; + } + .disable-select-appearance select { + appearance: none; + } + input[type="text"]::placeholder { + @apply text-neutral-400; + } +} diff --git a/gamePlayer/src/index.ts b/gamePlayer/src/index.ts deleted file mode 100644 index 466e2556..00000000 --- a/gamePlayer/src/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - onLevelDecisionTick, - createLevelFromData, - onLevelAfterTick, - onLevelStart, -} from "@notcc/logic" -import { Direction } from "@notcc/logic" -import { parseC2M } from "@notcc/logic" -import { actorDB, keyNameList } from "@notcc/logic" -import { parseNCCS, writeNCCS } from "@notcc/logic" -import { ScriptRunner } from "@notcc/logic" -import { Actor } from "@notcc/logic" -import { Item } from "@notcc/logic" - -import "@notcc/logic" - -import { Pager } from "./pager" -import { generateShortcutListener, generateTabButtons } from "./sidebar" - -import { loadSetInfo, saveSetInfo } from "./saveData" -import { KeyListener } from "./utils" -import { openTilesetSelectortDialog } from "./tilesets" -import { showAlert } from "./simpleDialogs" -import { openNotccUrl } from "./pages/loading" - -import "dialog-polyfill/dialog-polyfill.css" -import dialogPolyfill from "dialog-polyfill" - -for (const dialog of Array.from(document.querySelectorAll("dialog"))) { - dialogPolyfill.registerDialog(dialog) -} - -import "./NCCTK.less" - -const pager = new Pager() - -window.addEventListener("popstate", () => { - openNotccUrl(pager) -}) - -generateTabButtons(pager) -new KeyListener(generateShortcutListener(pager)) - -function errorHandler(ev: ErrorEvent | PromiseRejectionEvent) { - let errorInfoText: string - if (ev instanceof ErrorEvent) { - errorInfoText = `${ev.message} -at ${ev.lineno}:${ev.colno} -in ${ev.filename}` - } else { - errorInfoText = `Promise rejected! Reason: ${ev.reason}` - } - - showAlert(`Yikes! Something went wrong... -Error info: ${errorInfoText}`) -} - -window.addEventListener("error", errorHandler) -window.addEventListener("unhandledrejection", errorHandler) - -// We export it like this so the global values are always updated -const exportObject = { - /* get level(): LevelState { - return setPlayer.pulseManager.level - }, */ - parseC2M, - parseNCCS, - writeNCCS, - ScriptRunner, - Direction, - actorDB, - //setPlayer, - keyNameList, - onLevelDecisionTick, - onLevelAfterTick, - onLevelStart, - createLevelFromData, - Actor, - Item, - //setColorScheme, - pager, - loadSolution: loadSetInfo, - saveSolution: saveSetInfo, - openTilesetSelectortDialog, -} - -;(globalThis as any).NotCC = exportObject diff --git a/gamePlayer/src/inputs.tsx b/gamePlayer/src/inputs.tsx new file mode 100644 index 00000000..88c933c7 --- /dev/null +++ b/gamePlayer/src/inputs.tsx @@ -0,0 +1,274 @@ +import { KEY_INPUTS, KeyInputs, Level } from "@notcc/logic" +import { TimeoutTimer, dedup, iterMapFind, keypressIsFocused } from "./helpers" +import { useLayoutEffect } from "preact/hooks" +import { preferenceAtom } from "./preferences" + +export interface Input { + source: string + code: string +} + +function inputEq(a: Input, b: Input) { + return a.code === b.code && a.source === b.source +} + +export type RepeatState = "released" | "held" | "repeated" + +export type InputSource = (controls: InputControls) => () => void + +export interface InputControls { + on(input: Input): boolean + off(input: Input): boolean +} + +export class InputRepeater { + repeatTimer: TimeoutTimer | null = null + repeatInput: Input | null = null + unsubFuncs: (() => void)[] = [] + keyRepeatDelay = 0.25 + + on(input: Input): boolean { + this.repeatTimer?.cancel() + if (this.repeatInput) { + this.listener(this.repeatInput, "held") + this.repeatInput = null + } + const timer = new TimeoutTimer(() => { + if (this.repeatTimer !== timer) return + this.repeatInput = input + this.listener(this.repeatInput, "repeated") + }, this.keyRepeatDelay) + this.repeatTimer = timer + return this.listener(input, "held") + } + off(input: Input): boolean { + this.repeatTimer?.cancel() + this.repeatTimer = null + if (this.repeatInput && !inputEq(this.repeatInput, input)) { + this.listener(this.repeatInput, "held") + } + this.repeatInput = null + return this.listener(input, "released") + } + constructor(public listener: (input: Input, state: RepeatState) => boolean) {} +} + +export class InputBuffer { + inputs: KeyInputs = 0 + repeatedInputs: KeyInputs = 0 + constructor() {} + feedEvent(input: KeyInputs, state: RepeatState) { + if (state === "repeated") { + this.repeatedInputs |= input + this.inputs |= input + } else if (state === "held") { + this.repeatedInputs &= ~input + this.inputs |= input + } else { + this.repeatedInputs &= ~input + this.inputs &= ~input + } + } + releaseInputs(keys: number) { + this.inputs &= ~(keys & ~this.repeatedInputs) + } + getInputs(): KeyInputs { + return this.inputs + } +} + +export interface InputMapping { + label?: string + enabled: boolean + source: string + codes: Record +} + +export interface SeatConfig { + mappings: InputMapping[] +} + +export interface InputConfig { + seats: SeatConfig[] +} + +export const DEFAULT_INPUT_CONFIG: InputConfig = { + seats: [ + { + mappings: [ + { + label: "NotCC", + enabled: true, + source: "keyboard", + codes: { + ArrowUp: KEY_INPUTS.up, + ArrowRight: KEY_INPUTS.right, + ArrowDown: KEY_INPUTS.down, + ArrowLeft: KEY_INPUTS.left, + KeyZ: KEY_INPUTS.dropItem, + KeyX: KEY_INPUTS.cycleItems, + KeyC: KEY_INPUTS.switchPlayer, + Space: 0, + }, + }, + { + label: "CC2", + enabled: true, + source: "keyboard", + codes: { + KeyW: KEY_INPUTS.up, + KeyA: KEY_INPUTS.left, + KeyS: KEY_INPUTS.down, + KeyD: KEY_INPUTS.right, + KeyQ: KEY_INPUTS.dropItem, + KeyE: KEY_INPUTS.cycleItems, + KeyC: KEY_INPUTS.switchPlayer, + }, + }, + ], + }, + ], +} + +export const inputConfigAtom = preferenceAtom("inputs", DEFAULT_INPUT_CONFIG) + +class InputsDemux { + seatsAssigned: boolean[] + mappedInputs: Record> = {} + constructor(public config: InputConfig) { + this.seatsAssigned = config.seats.map(() => false) + } + matchPlayerInput(input: Input): [input: KeyInputs, playerN: number] | null { + if (!(input.source in this.mappedInputs)) { + this.mappedInputs[input.source] = {} + } + const codesMap = this.mappedInputs[input.source] + if (input.code in codesMap) { + return codesMap[input.code] + } + const seatMap: [InputMapping, number] | null = iterMapFind( + this.config.seats.filter((_, idx) => !this.seatsAssigned[idx]), + (v, idx) => { + const mapping = v.mappings.find( + m => m.enabled && m.source === input.source && input.code in m.codes + ) + return mapping ? [mapping, idx] : null + } + ) + if (!seatMap) { + codesMap[input.code] = null + return null + } + const seatIdx = seatMap[1] + this.seatsAssigned[seatIdx] = true + for (const [code, input] of Object.entries(seatMap[0].codes)) { + codesMap[code] = [input, seatIdx] + } + return codesMap[input.code] + } +} + +const INPUT_SOURCES: Record = { + keyboard(controls) { + const onHandler = (ev: KeyboardEvent) => { + if (ev.repeat || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) + return + if (keypressIsFocused(ev)) return + const accepted = controls.on({ source: "keyboard", code: ev.code }) + if (accepted) { + ev.preventDefault() + ev.stopImmediatePropagation() + } + } + const offHandler = (ev: KeyboardEvent) => { + if (ev.repeat || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) + return + if (keypressIsFocused(ev)) return + const accepted = controls.off({ source: "keyboard", code: ev.code }) + if (accepted) { + ev.preventDefault() + ev.stopImmediatePropagation() + } + } + document.addEventListener("keydown", onHandler) + document.addEventListener("keyup", offHandler) + return () => { + document.removeEventListener("keydown", onHandler) + document.removeEventListener("keyup", offHandler) + } + }, +} + +export type InputCallback = ( + input: KeyInputs, + player: number, + state: RepeatState +) => void + +export function makeInputCollector( + config: InputConfig, + callback: InputCallback, + extraSources: InputSource[] = [] +): () => void { + // input sources -> input repeater -> anyInput and demux -> buffer (done manually) + // Need to do this in reverse to connect things together + + const demux = new InputsDemux(config) + + function feedDemux(input: Input, state: RepeatState): boolean { + const playerInput = demux.matchPlayerInput(input) + if (playerInput === null) return false + callback(...playerInput, state) + return true + } + + const repeater = new InputRepeater(feedDemux) + + const inputSourceStrings: string[] = dedup( + config.seats.flatMap(s => s.mappings.map(m => m.source)) + ) + const unsubscribeFuncs = inputSourceStrings.map(sourceStr => { + const source = INPUT_SOURCES[sourceStr] + if (source) { + return source(repeater) + } else { + return () => {} + } + }) + unsubscribeFuncs.push(...extraSources.map(source => source(repeater))) + + function unsubcribe() { + for (const unsub of unsubscribeFuncs) { + unsub() + } + } + + return unsubcribe +} + +export function setLevelInputs(level: Level, buffers: InputBuffer[]) { + for (const [idx, buffer] of buffers.entries()) { + if (idx >= level.playerSeats.length) break + const seat = level.playerSeats[idx] + seat.inputs = buffer.getInputs() + } +} + +export function releaseLevelInputs(level: Level, buffers: InputBuffer[]) { + for (const [idx, buffer] of buffers.entries()) { + if (idx >= level.playerSeats.length) break + const seat = level.playerSeats[idx] + buffer.releaseInputs(seat.releasedInputs) + } +} + +export function useInputCollector( + inputConfig: InputConfig, + callback: InputCallback, + extraSources: InputSource[] = [] +) { + useLayoutEffect( + () => makeInputCollector(inputConfig, callback, extraSources), + [inputConfig, callback, extraSources] + ) +} diff --git a/gamePlayer/src/levelData.tsx b/gamePlayer/src/levelData.tsx new file mode 100644 index 00000000..077f32c9 --- /dev/null +++ b/gamePlayer/src/levelData.tsx @@ -0,0 +1,651 @@ +import { Getter, Setter, atom, useAtomValue, useSetAtom } from "jotai" +import { + Level, + LevelSet, + LevelSetData, + constructSimplestLevelSet, + findScriptName, + parseC2M, + parseNCCS, + parseScriptMetadata, + writeNCCS, + protobuf, + getC2GGameModifiers, + C2GGameModifiers, + MapInterruptWinResponse, +} from "@notcc/logic" +import { + CUSTOM_LEVEL_SET_IDENT, + CUSTOM_SET_SET_IDENT, + levelNAtom, + levelSetIdentAtom, + pageAtom, + pageNameAtom, + preventImmediateHashUpdateAtom, + searchParamsAtom, +} from "./routing" +import { loadable, unwrap } from "jotai/utils" +import { Dialog } from "./components/Dialog" +import { useCallback, useRef } from "preact/hooks" +import { + PromptComponent, + hidePrompt, + showAlertGs, + showPromptGs, +} from "./prompts" +import { decodeBase64, formatBytes, unzlibAsync, useJotaiFn } from "./helpers" +import { + IMPORTANT_SETS, + ImportantSetInfo, + buildFileListIndex, + findEntryFilePath, + makeBufferMapFileLoader, + makeBufferMapFromFileList, + makeFileListFileLoader, + makeLoaderWithPrefix, +} from "./setLoading" +import { basename, dirname } from "path-browserify" +import { readFile, writeFile, showLoadPrompt, showDirectoryPrompt } from "./fs" +import { atomEffect } from "jotai-effect" +import { preferenceAtom, preloadFinishedAtom } from "./preferences" +import { parse } from "path" +import { + ItemLevelSet, + downloadBBClubSetGs, + fetchBBClubSets, + findLocalSet, + saveFilesLocallyGs, +} from "./setManagement" +import { BB_CLUB_SETS_URL } from "./setsApi" +import { Toast, addToastGs, adjustToastGs, removeToastGs } from "./toast" + +export class LevelData { + constructor(private level: Level) {} + initLevel() { + return this.level.clone() + } + // XXX: Kind of a hack, maybe remove this somehow?? + get replay() { + return this.level.builtinReplay + } +} + +export const levelAtom = atom | null>(null) + +const levelLoadableAtom = loadable(levelAtom) +export function useSwrLevel(): LevelData | null { + const levelState = useAtomValue(levelLoadableAtom) + const lastLevel = useRef(null) + if (levelState.state === "hasError") return null + if (levelState.state === "hasData") { + lastLevel.current = levelState.data + } + return lastLevel.current +} + +const levelSetAtomWrapped = atom | null>(null) +export const levelSetAtom = unwrap(levelSetAtomWrapped) +// HACK: This is updated each time `levelSetAtom` is modified and the result of which should be saved (eg. after navigating to or beating a level). +export const levelSetChangedAtom = atom(Symbol()) + +export async function borrowLevelSetGs( + get: Getter, + set: Setter, + func: (set: LevelSet) => void | Promise +) { + const lset = get(levelSetAtom) + if (!lset) return + await func(lset) + set(levelSetAtom, lset) + set(levelNAtom, lset.currentLevel) + set(levelSetChangedAtom, Symbol()) +} + +export async function goToLevelNGs(get: Getter, set: Setter, levelN: number) { + set(levelNAtom, levelN) + set(setIntermissionAtom, null) + await borrowLevelSetGs(get, set, async lSet => { + const rec = await lSet.goToLevel(levelN) + set(levelWinInterruptResponseAtom, null) + set( + levelAtom, + rec && new LevelData((await lSet.loadLevelData(rec)).levelData) + ) + }) +} + +export const levelWinInterruptResponseAtom = atom | null>(null) + +export type ShowEpilogueMode = "never" | "unseen" | "always" + +export const showEpilogueAtom = preferenceAtom( + "showEpilogue", + "unseen" +) +export function shouldShowEpilogueGs( + get: Getter, + _set: Setter, + level: protobuf.ILevelInfo +) { + const mode = get(showEpilogueAtom) + if (mode === "never") return false + if (mode === "always") return true + return !level.sawEpilogue +} + +export interface SetIntermission { + type: "prologue" | "epilogue" + text: string[] +} + +export const setIntermissionAtom = atom(null) + +export const setIntermissionRemoveAtom = atomEffect((get, set) => { + const page = get(pageAtom) + if (!page?.showsIntermissions) { + set(setIntermissionAtom, null) + } +}) + +export function showSetIntermissionGs( + get: Getter, + set: Setter, + intermission: SetIntermission +) { + const page = get(pageAtom) + if (page?.showsIntermissions) { + set(setIntermissionAtom, intermission) + } +} + +export async function goToNextLevelGs(get: Getter, set: Setter) { + const intermission = get(setIntermissionAtom) + if (intermission) { + set(setIntermissionAtom, null) + return + } + await borrowLevelSetGs(get, set, async lSet => { + const currentRec = lSet.currentLevelRecord() + const modifiers = getC2GGameModifiers( + currentRec.levelInfo.scriptState ?? {} + ) + if ( + !modifiers.autoPlayReplay && + currentRec.levelInfo.epilogueText && + shouldShowEpilogueGs(get, set, currentRec.levelInfo) + ) { + showSetIntermissionGs(get, set, { + type: "epilogue", + text: currentRec.levelInfo.epilogueText, + }) + currentRec.levelInfo.sawEpilogue = true + } + const winResponse = get(levelWinInterruptResponseAtom) + const rec = await lSet.nextLevel( + winResponse + ? { ...winResponse, totalScore: lSet.totalMetrics().score } + : { type: "skip" } + ) + set(levelWinInterruptResponseAtom, null) + if (lSet.inPostGame) { + // TODO display this somehow, like in classic NotCC or LL + await lSet.previousLevel() + return + } + if (!rec) return + set( + levelAtom, + rec && new LevelData((await lSet.loadLevelData(rec)).levelData) + ) + }) +} + +export async function goToPreviousLevelGs(get: Getter, set: Setter) { + await borrowLevelSetGs(get, set, async lSet => { + const rec = await lSet.previousLevel() + if (!rec) return + set(setIntermissionAtom, null) + set(levelWinInterruptResponseAtom, null) + set( + levelAtom, + rec && new LevelData((await lSet.loadLevelData(rec)).levelData) + ) + }) +} + +export async function loadSetSave( + setData: LevelSetData +): Promise<{ set: LevelSet; firstLoad: boolean }> { + let { loaderFunction, scriptFile } = setData + const filePrefix = dirname(scriptFile) + // If the zip file has the entry script in a subdirectory instead of the zip + // root, prefix all file paths with the entry file + if (filePrefix !== ".") { + loaderFunction = makeLoaderWithPrefix(filePrefix, loaderFunction) + scriptFile = basename(scriptFile) + } + + const scriptData = (await loaderFunction(scriptFile, false)) as string + const scriptTitle = findScriptName(scriptData)! + + const setInfo = await readFile(`./solutions/default/${scriptTitle}.nccs`) + .then(buf => parseNCCS(buf)) + .catch(() => null) + + return { + set: await constructSimplestLevelSet( + { + scriptFile, + loaderFunction, + }, + setInfo ?? undefined + ), + firstLoad: setInfo === null, + } +} + +export const levelSetAutosaveAtom = atomEffect((get, _set) => { + const lSet = get(levelSetAtom) + get(levelSetChangedAtom) + if (!lSet) return + void writeFile( + `./solutions/default/${lSet.scriptTitle()}.nccs`, + writeNCCS(lSet.toSetInfo()).buffer + ) +}) + +export async function setLevelSetGs( + get: Getter, + set: Setter, + setData: LevelSetData, + ident?: string +) { + const { set: lset, firstLoad } = await loadSetSave(setData) + const record = await lset.loadLevelData(await lset.initialLevel()) + set(levelAtom, new LevelData(record.levelData)) + set(levelSetAtom, lset) + set(levelNAtom, lset.currentLevel) + const importantIdent = IMPORTANT_SETS.find( + iset => iset.setName === lset.gameTitle() + )?.setIdent + set(levelSetIdentAtom, ident ?? importantIdent ?? CUSTOM_SET_SET_IDENT) + if (!get(pageAtom)?.isLevelPlayer) { + set(pageAtom, "play") + } + if (get(pageAtom)?.showsIntermissions ?? false) { + if ( + record.levelInfo.prologueText && + get(showEpilogueAtom) !== "never" && + firstLoad + ) { + showSetIntermissionGs(get, set, { + type: "prologue", + text: record.levelInfo.prologueText, + }) + } + } +} + +export function setIndividualLevelGs( + get: Getter, + set: Setter, + level: Promise +) { + set(levelSetAtom, null) + set(levelSetIdentAtom, CUSTOM_LEVEL_SET_IDENT) + set(levelAtom, level) + set(levelNAtom, 1) + if (!get(pageAtom)?.isLevelPlayer) { + set(pageAtom, "play") + } +} + +export async function showFileLevelPrompt(): Promise { + const files = await showLoadPrompt("Load level file", { + filters: [{ name: "CC2 level file", extensions: ["c2m"] }], + }) + + return ( + files?.[0]?.arrayBuffer().then(buf => new LevelData(parseC2M(buf))) ?? null + ) +} + +export async function promptFile(): Promise<{ + level: LevelData + buffer: ArrayBuffer +} | null> { + const files = await showLoadPrompt("Load set or level file", { + filters: [ + { name: "CC2 level file", extensions: ["c2m"] }, + // TODO: { name: "Set ZIP", extensions: ["zip"] }, + ], + }) + const file = files?.[0] + if (!file) return null + const levelBuffer = await file.arrayBuffer() + const level = parseC2M(levelBuffer) + return { level: new LevelData(level), buffer: levelBuffer } +} + +export interface LevelSetDir { + setData: LevelSetData + setFiles: File[] + setIdent: string +} + +export const preloadFilesFromDirectoryPromptAtom = preferenceAtom( + "preloadFilesFromDirectoryPrompt", + false +) + +export async function promptDir( + preloadFiles: boolean +): Promise { + const files = await showDirectoryPrompt("Load set directory") + if (!files) return null + let setData: LevelSetData + try { + const fileIndex = buildFileListIndex(files) + const loader = preloadFiles + ? makeBufferMapFileLoader(await makeBufferMapFromFileList(files)) + : makeFileListFileLoader(files) + setData = await findEntryFilePath(loader, fileIndex) + } catch { + return null + } + const filePath = parse(files[0].webkitRelativePath) + const fileBase = filePath.base.split("/")[0] + return { setData, setFiles: files, setIdent: fileBase } +} + +export const LoadLevelPrompt: PromptComponent = function ({ + onResolve, +}) { + const setPage = useSetAtom(pageAtom) + return ( + { + setPage("") + onResolve(null) + }, + ], + [ + "Load file", + async () => { + const level = await showFileLevelPrompt() + if (!level) return + onResolve(level) + }, + ], + ]} + > + The URL doesn't provide the level data required to load the level. Please + provide the level file or go back to the set selector. + + ) +} + +const DownloadSetPrompt = + (set: ItemLevelSet): PromptComponent<"download" | "cancel"> => + pProps => { + return ( + pProps.onResolve("download")], + ["Back to set selector", () => pProps.onResolve("cancel")], + ]} + > + You're attempting to open a link to the set "{set.setName}", which was + not found locally, but is available on the bb.club online set + repository. Do you want to download the set? It will take{" "} + {formatBytes(set.bbClubSet!.set.file_size)} in storage space. + + ) + } + +const LoadNonfreeSetPrompt = + (importantSet: ImportantSetInfo): PromptComponent => + pProps => { + const setPage = useSetAtom(pageAtom) + const showAlert = useJotaiFn(showAlertGs) + const preloadFilesFromDirectoryPrompt = useAtomValue( + preloadFilesFromDirectoryPromptAtom + ) + const askForNonfreeSet = useCallback(async () => { + const setStuff = await promptDir(preloadFilesFromDirectoryPrompt) + if (!setStuff) return + const setTitle = parseScriptMetadata( + (await setStuff.setData.loaderFunction( + setStuff.setData.scriptFile, + false + )) as string + ).title + if (setTitle !== importantSet.setName) { + await showAlert( + `Invalid set name for non-free set. Expected "${importantSet.setName}", got "${setTitle}".`, + "Invalid set name" + ) + return + } + pProps.onResolve(setStuff) + }, []) + return ( + { + setPage("") + pProps.onResolve(null) + }, + ], + ]} + > + You're attempting to open a link to the set "{importantSet.setName}", + which is non-free, and thus cannot be downloaded from bb.club or + included as part of NotCC. You can{" "} + + {importantSet.acquireInfo!.term} Steam here + {" "} + and load the set into NotCC. +
    + ) + } + +const UnnamedCustomSetPrompt: PromptComponent = pProps => { + const setPage = useSetAtom(pageAtom) + return ( + setPage("")]]} + onResolve={pProps.onResolve} + > + The link you're attempting to open specifies an unnamed custom set, and + cannot be resolved. + + ) +} + +const resolveHashLevelPromptIdent = Symbol() + +export async function resolveHashLevelGs(get: Getter, set: Setter) { + if (!get(preloadFinishedAtom)) return + const levelSetIdent = get(levelSetIdentAtom) + const levelN = get(levelNAtom) + const searchParams = get(searchParamsAtom) + hidePrompt(get, set, resolveHashLevelPromptIdent) + // Open the level encoded in ?level if we're given one + if (searchParams.level) { + let buf = decodeBase64(searchParams.level) + // Detect if we're given zlib-compressed data, since raw C2M can be kinda large sometimes + if (buf[0] == 0x78) { + buf = await unzlibAsync(buf) + } + if (get(pageNameAtom) === "") { + set(pageAtom, "play") + } + set(levelAtom, Promise.resolve(new LevelData(parseC2M(buf.buffer)))) + set(levelSetIdentAtom, CUSTOM_LEVEL_SET_IDENT) + if (levelN === null) { + set(levelNAtom, 1) + } + set(preventImmediateHashUpdateAtom, false) + } else if (levelSetIdent === null || levelN === null) { + set(levelSetIdentAtom, null) + set(levelNAtom, null) + } else if (levelSetIdent === CUSTOM_LEVEL_SET_IDENT) { + const newLevel = await showPromptGs( + get, + set, + LoadLevelPrompt, + resolveHashLevelPromptIdent + ) + if (!newLevel) return + set(levelAtom, Promise.resolve(newLevel)) + } else if (levelSetIdent === CUSTOM_SET_SET_IDENT) { + await showPromptGs( + get, + set, + UnnamedCustomSetPrompt, + resolveHashLevelPromptIdent + ) + } else { + async function openSet(setData: LevelSetData, newSetIdent?: string) { + await setLevelSetGs(get, set, setData, newSetIdent ?? levelSetIdent!) + const lset = get(levelSetAtom)! + if (!lset.canGoToLevel(levelN!)) { + await showAlertGs( + get, + set, + <> + The level URL provides a level number, but this set could not be + reduced to a simple level list, and could not be automatically + navigated to the specified level. This might mean this set uses + advanced scripting and the level numbers might be non-linear. + , + "Couldn't load level from level number" + ) + } else { + await goToLevelNGs(get, set, levelN!) + } + } + + // Local set + const localSet = await findLocalSet(levelSetIdent) + if (localSet) { + await openSet(await localSet.localSet!.loadData()) + return + } + // bb.club set + const sets = await fetchBBClubSets(BB_CLUB_SETS_URL) + const bbClubSet = sets.find(set => set.setIdent === levelSetIdent) + if (bbClubSet) { + const promptRes = await showPromptGs( + get, + set, + DownloadSetPrompt(bbClubSet) + ) + if (promptRes === "cancel") { + set(pageAtom, "") + return + } + const downloadProgressToast: Toast = { title: "Downloading set (0%)" } + addToastGs(get, set, downloadProgressToast) + const setDownloaded = await downloadBBClubSetGs( + get, + set, + bbClubSet, + progress => { + downloadProgressToast.title = `Downloading set (${Math.round(progress * 100)}%)` + adjustToastGs(get, set) + } + ) + removeToastGs(get, set, downloadProgressToast) + if (!setDownloaded) return + const localSet = await findLocalSet(levelSetIdent) + if (!localSet) + throw new Error("Failed to find set right after downloading it") + await openSet(await localSet.localSet!.loadData()) + return + } + // Non-free set + const importantSet = IMPORTANT_SETS.find( + set => set.setIdent === levelSetIdent + ) + + if (importantSet?.acquireInfo) { + const promptRes = await showPromptGs( + get, + set, + LoadNonfreeSetPrompt(importantSet) + ) + if (promptRes === null) return + const saveRes = await saveFilesLocallyGs( + get, + set, + promptRes.setFiles, + importantSet.setName + ) + if (!saveRes) { + set(pageAtom, "") + return + } + const localSetItem = await findLocalSet(importantSet.setIdent) + if (!localSetItem) + throw new Error("Failed to load set right after saving it") + // The set ident might have changed, since loading an important set always forces the "correct" set ident regardless of the dir name + await openSet( + await localSetItem.localSet!.loadData(), + localSetItem.setIdent + ) + return + } + // TODO: Built-in set + + // Give up + await showAlertGs( + get, + set, + `Set ident "${levelSetIdent}" is unrecognized and thus cannot be loaded. This may happen if there's a typo in the set ident, a bb.club set was removed, or your internet connection is down.`, + "Unrecognized set ident" + ) + set(pageAtom, "") + } +} + +export function getGlobalLevelModifiersGs( + get: Getter, + _set?: Setter +): C2GGameModifiers { + get(levelSetChangedAtom) + const levelSet = get(levelSetAtom) + const levelScriptState = levelSet?.currentLevelRecord().levelInfo.scriptState + return { + autoPlayReplay: false, + autoNext: false, + noPopups: false, + noBonusCollection: false, + ...(levelScriptState ? getC2GGameModifiers(levelScriptState) : {}), + } +} + +export const globalC2GGameModifiersAtom = atom( + (get: Getter) => getGlobalLevelModifiersGs(get), + () => {} +) + +export const importantSetAtom = atom((get, _set) => { + const lSet = get(levelSetAtom) + if (!lSet) return null + return IMPORTANT_SETS.find(iSet => iSet.setName === lSet.gameTitle()) ?? null +}) diff --git a/gamePlayer/src/levelList.ts b/gamePlayer/src/levelList.ts deleted file mode 100644 index f216e335..00000000 --- a/gamePlayer/src/levelList.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { findBestMetrics } from "@notcc/logic" -import { Pager } from "./pager" -import { openScoreReportGenDialog } from "./reportGenerator" -import { makeTd, resetListeners } from "./utils" - -const levelListDialog = - document.querySelector("#levelListDialog")! -export function openLevelListDialog(pager: Pager): void { - const set = pager.loadedSet - if (set === null) return - resetListeners(levelListDialog) - const sortedLevels = Object.values(set.seenLevels) - .map(record => record.levelInfo) - .sort((a, b) => (a.levelNumber ?? 0) - (b.levelNumber ?? 0)) - const tableBody = levelListDialog.querySelector("tbody")! - // Nuke all current data - tableBody.textContent = "" - for (const levelRecord of sortedLevels) { - const row = document.createElement("tr") - const levelN = levelRecord.levelNumber ?? 0 - const metrics = findBestMetrics(levelRecord) - row.appendChild(makeTd(levelN.toString(), "levelN")) - row.appendChild(makeTd(levelRecord.title ?? "[An untitled level]")) - row.appendChild( - makeTd( - metrics.timeLeft === undefined ? "-" : Math.ceil(metrics.timeLeft) + "s" - ) - ) - row.appendChild( - makeTd(metrics.points === undefined ? "-" : metrics.points.toString()) - ) - row.addEventListener("click", async () => { - await pager.goToLevel(levelN) - await pager.reloadLevel() - levelListDialog.close() - }) - row.tabIndex = 0 - tableBody.appendChild(row) - } - const generateReportButton = document.querySelector( - "#generateReportButton" - )! - generateReportButton.addEventListener("click", () => { - openScoreReportGenDialog(pager) - }) - levelListDialog.showModal() -} diff --git a/gamePlayer/src/levelLoading.ts b/gamePlayer/src/levelLoading.ts deleted file mode 100644 index dd7f0577..00000000 --- a/gamePlayer/src/levelLoading.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - LevelData, - LevelSet, - LevelSetLoaderFunction, - findScriptName, -} from "@notcc/logic" -import { levelPlayerPage } from "./pages/levelPlayer" -import { Pager } from "./pager" -import { basename, dirname } from "path-browserify" -import { - buildFileListIndex, - makeFileListFileLoader, - makeLoaderWithPrefix, -} from "./fileLoaders" -import { loadSetInfo, showDirectotyPrompt } from "./saveData" -import { getNonFreeSetId } from "./pages/loading" -interface DirEntry { - path: string - data: string -} - -export async function findEntryFilePath( - loaderFunction: LevelSetLoaderFunction, - fileIndex: string[] -): Promise { - // Use `loaderFunction` and `rootIndex` to figure out which files are entry - // scripts (have the header closed string) - const c2gFileNames = fileIndex.filter(path => path.endsWith(".c2g")) - const c2gDirEntPromises = c2gFileNames.map>(async path => { - const scriptData = (await loaderFunction(path, false)) as string - return { path, data: scriptData } - }) - const maybeC2gFiles = await Promise.all(c2gDirEntPromises) - const c2gFiles = maybeC2gFiles.filter( - ent => findScriptName(ent.data) !== null - ) - - if (c2gFiles.length > 1) { - c2gFiles.sort((a, b) => a.path.length - b.path.length) - - console.warn( - "There appear to be multiple entry script files. Picking the one with the shortest path..." - ) - } - if (c2gFiles.length < 1) - throw new Error( - "This ZIP archive doesn't contain a script. Are you sure this is the correct file?" - ) - return c2gFiles[0].path -} - -export function openLevel(pager: Pager, level: LevelData): void { - pager.loadedLevel = level - pager.loadedSet = null - pager.loadedSetIdent = null - pager.updateShownLevelNumber() - pager.openPage(levelPlayerPage) -} -export async function loadSet( - pager: Pager, - loaderFunction: LevelSetLoaderFunction, - scriptFile: string, - noOpenPage: boolean = false -): Promise { - const filePrefix = dirname(scriptFile) - // If the zip file has the entry script in a subdirectory instead of the zip - // root, prefix all file paths with the entry file - if (filePrefix !== ".") { - loaderFunction = makeLoaderWithPrefix(filePrefix, loaderFunction) - scriptFile = basename(scriptFile) - } - - const scriptData = (await loaderFunction(scriptFile, false)) as string - const scriptTitle = findScriptName(scriptData)! - - const setInfo = await loadSetInfo(scriptTitle).catch(() => null) - - let set: LevelSet - - if (setInfo !== null) { - set = await LevelSet.constructAsync(setInfo, loaderFunction) - } else { - set = await LevelSet.constructAsync(scriptFile, loaderFunction) - } - - const nonFreeSetId = getNonFreeSetId(set.scriptRunner.state.scriptTitle!) - if (nonFreeSetId !== null) { - pager.loadedSetIdent = nonFreeSetId - } - - pager.loadedSet = set - const record = await set.getCurrentRecord() - pager.loadedLevel = record.levelData! - - // Oh, this set doesn't have levels... - if (pager.loadedLevel === null) - throw new Error( - "This set doesn't have levels, or the saved set info is broken." - ) - - if (!noOpenPage) { - pager.openPage(levelPlayerPage) - pager.updateShownLevelNumber() - } -} - -export async function loadDirSet(): Promise<[LevelSetLoaderFunction, string]> { - const files = await showDirectotyPrompt("Load levelset directory") - const fileLoader = makeFileListFileLoader(files) - const scriptPath = await findEntryFilePath( - fileLoader, - buildFileListIndex(files) - ) - return [fileLoader, scriptPath] -} diff --git a/gamePlayer/src/levels/NotCC.c2m b/gamePlayer/src/levels/NotCC.c2m deleted file mode 100644 index 9c547f5a..00000000 Binary files a/gamePlayer/src/levels/NotCC.c2m and /dev/null differ diff --git a/gamePlayer/src/main-ssg.tsx b/gamePlayer/src/main-ssg.tsx new file mode 100644 index 00000000..0c9a17c5 --- /dev/null +++ b/gamePlayer/src/main-ssg.tsx @@ -0,0 +1,8 @@ +import { renderToString } from "preact-render-to-string" +import { App } from "./app" +import { setSSG } from "./helpers" + +export function renderSsgString(): string { + setSSG(true) + return renderToString() +} diff --git a/gamePlayer/src/main.tsx b/gamePlayer/src/main.tsx new file mode 100644 index 00000000..5e5f4ccd --- /dev/null +++ b/gamePlayer/src/main.tsx @@ -0,0 +1,10 @@ +import { hydrate } from "preact" +import { App } from "./app" +import * as fs from "@/fs" + +hydrate(, document.querySelector("#app")!) + +// @ts-ignore +globalThis.NotCC = { + fs, +} diff --git a/gamePlayer/src/pager.ts b/gamePlayer/src/pager.ts deleted file mode 100644 index 3bfdcdff..00000000 --- a/gamePlayer/src/pager.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { LevelData, LevelSet, MapInterruptResponse } from "@notcc/logic" -import { loadingPage } from "./pages/loading" -import { Tileset } from "./renderer" -import { setSidebarLevelN } from "./sidebar" -import { protobuf } from "@notcc/logic" -import { loadSettings, saveSetInfo, saveSettings } from "./saveData" -import { Settings, defaultSettings } from "./settings" -import clone from "clone" -import { ThemeColors, applyTheme } from "./themes" -import { updatePagerTileset } from "./tilesets" - -export interface Page { - pageId: string - pagePath: string | null - requiresLoaded: "none" | "set" | "level" - setupInitialized?: boolean - setupPage?: (pager: Pager, page: HTMLElement) => void - open?: (pager: Pager, page: HTMLElement) => void - close?: (pager: Pager, page: HTMLElement) => void - updateTileset?: (pager: Pager) => void - showInterlude?: (pager: Pager, text: string) => Promise - showGz?: (pager: Pager) => void - loadLevel?: (page: Pager) => void - loadSolution?: (pager: Pager, sol: protobuf.ISolutionInfo) => Promise - updateSettings?: (pager: Pager) => void - setNavigationInfo?: ( - pager: Pager, - subpage: string, - queryParams: Record - ) => void -} - -export class Pager { - currentPage!: Page - loadedSet: LevelSet | null = null - loadedSetIdent: string | null = null - loadedLevel: LevelData | null = null - tileset: Tileset | null = null - settings: Settings = clone(defaultSettings) - constructor() { - this._initPage(loadingPage) - } - _initPage(page: Page): void { - if (page.requiresLoaded === "set" && !this.loadedSet) - throw new Error("Page requires a set to be loaded before opening it.") - if (page.requiresLoaded === "level" && !this.loadedLevel) - throw new Error("Page requires a level to be loaded before opening it.") - const pageElement = document.getElementById(page.pageId) - if (!pageElement) { - throw new Error(`Can't find the page element for "${page.pageId}".`) - } - pageElement.classList.remove("closedPage") - if (!page.setupInitialized) { - page.setupPage?.(this, pageElement) - page.setupInitialized = true - } - this.currentPage = page - if (page !== loadingPage) { - this.updatePageUrl() - } - page.open?.(this, pageElement) - } - openPage(newPage: Page): void { - const oldPageElement = document.getElementById(this.currentPage.pageId)! - this.currentPage.close?.(this, oldPageElement) - oldPageElement.classList.add("closedPage") - this._initPage(newPage) - } - getLevelNumber(): number | "not in set" | "not in level" { - if (this.loadedSet) return this.loadedSet.currentLevel - if (this.loadedLevel) return "not in set" - return "not in level" - } - updateShownLevelNumber(): void { - const levelN = this.getLevelNumber() - let levelText: string - if (typeof levelN === "number") { - levelText = levelN.toString() - } else if (levelN === "not in set") { - levelText = "X" - } else { - levelText = "?" - } - setSidebarLevelN(levelText) - } - async loadNextLevel(action: MapInterruptResponse): Promise { - if (!this.loadedSet) - throw new Error("Can't load the next level of a set without a set.") - const currentRecord = this.loadedSet.seenLevels[this.loadedSet.currentLevel] - - this.loadedSet.lastLevelResult = action - const newRecord = await this.loadedSet.getNextRecord() - // TODO Only show unique text - if (currentRecord && currentRecord.levelInfo.epilogueText) { - await this.currentPage.showInterlude?.( - this, - currentRecord.levelInfo.epilogueText - ) - } - if (newRecord && newRecord.levelInfo.prologueText) { - await this.currentPage.showInterlude?.( - this, - newRecord.levelInfo.prologueText - ) - } - this.loadedLevel = newRecord - ? newRecord.levelData! - : currentRecord.levelData! - this.updateShownLevelNumber() - if (!newRecord) { - this.currentPage.showGz?.(this) - } else { - await this.writeSaveData() - } - } - async loadPreviousLevel(): Promise { - if (!this.loadedSet) - throw new Error("Can't load the previous level of a set without a set.") - - const newRecord = await this.loadedSet.getPreviousRecord() - // This is the first level of the set - if (!newRecord) { - return - } - - this.loadedLevel = newRecord.levelData! - this.updateShownLevelNumber() - await this.writeSaveData() - } - async goToLevel(newLevelN: number): Promise { - if (!this.loadedSet) - throw new Error("Can't load the previous level of a set without a set.") - - const newRecord = await this.loadedSet.goToLevel(newLevelN) - // This is the first level of the set - if (!newRecord) { - return - } - - this.loadedLevel = newRecord.levelData! - this.updateShownLevelNumber() - await this.writeSaveData() - } - /** - * Resets the current level, complete with rerunning the script - */ - async resetLevel(): Promise { - if (this.loadedSet) { - await this.loadNextLevel({ type: "retry" }) - } - await this.reloadLevel() - } - /** - * Reload level by asking the current page to re-make the `loadedLevel`. - */ - async reloadLevel(): Promise { - this.currentPage.loadLevel?.(this) - } - saveAttempt(attempt: protobuf.IAttemptInfo): void | Promise { - if (!this.loadedSet) return - const levelInfo = - this.loadedSet.seenLevels[this.loadedSet.currentLevel]?.levelInfo - if (!levelInfo) - throw new Error("The current level doesn't have a level record, somehow.") - - levelInfo.attempts ??= [] - levelInfo.attempts.push(attempt) - return this.writeSaveData() - } - async writeSaveData(): Promise { - if (!this.loadedSet) return - const scriptState = this.loadedSet.scriptRunner.state - const scriptTitle = scriptState.scriptTitle - if (!scriptTitle) - throw new Error("The loaded set does not have an identifier set.") - - await saveSetInfo(this.loadedSet.toSetInfo(), scriptTitle) - } - async loadSolution(sol: protobuf.ISolutionInfo): Promise { - if (!this.currentPage.loadSolution) - throw new Error("Current page doesn't support solution playback.") - await this.currentPage.loadSolution(this, sol) - } - setTheme(theme: ThemeColors): void { - applyTheme(document.body, theme) - } - updateTheme(): void { - this.setTheme(this.settings.mainTheme) - } - async reloadSettings(): Promise { - this.updateTheme() - await updatePagerTileset(this) - this.currentPage.updateSettings?.(this) - } - async saveSettings(newSettings: Settings): Promise { - this.settings = newSettings - await saveSettings(this.settings) - await this.reloadSettings() - } - async loadSettings(): Promise { - this.settings = await loadSettings() - this.settings = { ...defaultSettings, ...this.settings } - this.reloadSettings() - } - updatingPageUrl = false - determinePageUrl(subpage: string, queryParams: Record): URL { - const newUrl = new URL(location.toString()) - const page = this.currentPage - if (page.pagePath === null) { - newUrl.hash = "" - newUrl.search = "" - return newUrl - } - let hash = `#/${page.pagePath}` - if (page.requiresLoaded === "set" || page.requiresLoaded === "level") { - let setName = this.loadedSetIdent - if (setName === null) { - setName = this.loadedSet !== null ? "*prompt-set" : "*prompt-level" - } - hash += `/${setName}` - } - if (page.requiresLoaded === "level") { - hash += `/${this.loadedSet?.currentLevel ?? 1}` - } - if (subpage !== "") { - hash += `/${subpage}` - } - newUrl.hash = hash - if (Object.keys(queryParams).length !== 0) { - newUrl.search = `?${new URLSearchParams(queryParams)}` - } else { - newUrl.search = "" - } - return newUrl - } - updatePageUrl( - subpage: string = "", - queryParams: Record = {} - ) { - const newLocation = this.determinePageUrl(subpage, queryParams) - this.updatingPageUrl = true - history.pushState(null, "", newLocation) - this.updatingPageUrl = false - } -} diff --git a/gamePlayer/src/pages/DownloadPage.tsx b/gamePlayer/src/pages/DownloadPage.tsx new file mode 100644 index 00000000..f2d7ed09 --- /dev/null +++ b/gamePlayer/src/pages/DownloadPage.tsx @@ -0,0 +1,77 @@ +import { useSetAtom } from "jotai" +import { Header } from "./SetSelectorPage" +import { playEnabledAtom } from "@/preferences" +import { isSSG } from "@/helpers" + +export function DownloadPage() { + const setPlayEnabled = useSetAtom(playEnabledAtom) + return ( +
    +
    +
    +

    + NotCC is an{" "} + + open-source and scoreboard-legal Chip's Challenge® 2 emulator. It's + very cool. Screenshots and more copywriting to come. +

    +

    + + Chip's Challenge is a registered trademark of Bridgestone Multimedia + Group LLC. NotCC is not affiliated with, endorsed or sponsored by + Bridgestone Multimedia Group LLC. + +

    +
    {" "} +
    +
    + + Download NotCC to play it on Linux, macOS, or Windows computer! + + + + +

    + Since NotCC is a Progressive Web App, you can also install NotCC on + your phone by{" "} + + following these instructions + + . +

    +
    +
    + + You can also play NotCC right here on this website, on desktop or + mobile! + + + + If you open a set or level in the web version, you can copy the link + to share the level or set! + +
    +
    +
    + ) +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/GraphView.tsx b/gamePlayer/src/pages/ExaPlayerPage/GraphView.tsx new file mode 100644 index 00000000..ca91fcd3 --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/GraphView.tsx @@ -0,0 +1,582 @@ +import { GameState, KeyInputs, generateSecondaryChars } from "@notcc/logic" +import { + ConnPtr, + GraphModel, + GraphMoveSequence, + MovePtr, + Node, +} from "./models/graph" +import { graphlib, layout } from "@dagrejs/dagre" +import { twJoin, twMerge } from "tailwind-merge" +import { twUnit } from "@/components/DumbLevelPlayer" +import { VNode } from "preact" +import { useCallback, useState } from "preact/hooks" +import { HTMLAttributes } from "preact/compat" +import { Timeline, TimelineHead } from "@/components/Timeline" +import { formatTicks } from "@/helpers" + +interface GraphViewProps { + model: GraphModel + inputs: KeyInputs + updateLevel: () => void +} + +// function DebugGraphView(props: GraphViewProps) { +// return ( +// <> +// {Array.from(props.model.nodeHashMap.values()).map(node => ( +//
    { +// props.model.goTo(node) +// props.updateLevel() +// }} +// > +// {node === props.model.current && "> "} +// {node === props.model.rootNode ? "r" : node.loose ? "l" : "m "}{" "} +// {(node.hash >>> 0).toString(16)}: {node.outConns.size === 0 && "none"} +// {Array.from(node.outConns.entries()) +// .map( +// ([node, seqs]) => +// ` to ${(node.hash >>> 0).toString(16)}: ${seqs +// .map(seq => seq.displayMoves.join("")) +// .join()}` +// ) +// .join(";")} +//
    +// ))} +// +// ) +// } + +const EDGE_RADIUS = twUnit(0.75) +const NODE_RADIUS = twUnit(4) +const OUTLINE_WIDTH = twUnit(0.75) +const PADDING = twUnit(1) + OUTLINE_WIDTH + +function makeGraph(model: GraphModel) { + const graph = new graphlib.Graph() + graph.setGraph({ + // nodesep: twUnit(8), + // edgesep: twUnit(8), + // ranksep: twUnit(16), + // marginx: PADDING, + // marginy: PADDING, + ranker: "tight-tree", + }) + for (const node of model.nodeHashMap.values()) { + graph.setNode(node.getHashName(), { + label: node.getHashName(), + width: NODE_RADIUS, + height: NODE_RADIUS, + }) + } + for (const node of model.nodeHashMap.values()) { + for (const [tNode, conns] of node.outConns.entries()) { + graph.setEdge(node.getHashName(), tNode.getHashName(), { + node, + conns, + }) + } + } + return graph +} + +function SvgView(props: GraphViewProps) { + const graph = makeGraph(props.model) + layout(graph) + const minPos = [Infinity, Infinity] + const maxPos = [-Infinity, -Infinity] + for (const id of graph.nodes()) { + const point = graph.node(id) + if (point.x - NODE_RADIUS < minPos[0]) { + minPos[0] = point.x - NODE_RADIUS + } + if (point.x + NODE_RADIUS > maxPos[0]) { + maxPos[0] = point.x + NODE_RADIUS + } + if (point.y - NODE_RADIUS < minPos[1]) { + minPos[1] = point.y - NODE_RADIUS + } + if (point.y + NODE_RADIUS > maxPos[1]) { + maxPos[1] = point.y + NODE_RADIUS + } + } + + for (const id of graph.edges()) { + const edge = graph.edge(id) + for (const point of edge.points) { + if (point.x - EDGE_RADIUS < minPos[0]) { + minPos[0] = point.x - EDGE_RADIUS + } else if (point.x + EDGE_RADIUS > maxPos[0]) { + maxPos[0] = point.x + EDGE_RADIUS + } + if (point.y - EDGE_RADIUS < minPos[1]) { + minPos[1] = point.y - EDGE_RADIUS + } else if (point.y + EDGE_RADIUS > maxPos[1]) { + maxPos[1] = point.y + EDGE_RADIUS + } + } + } + const marginLeft = -minPos[0] + PADDING + const marginTop = -minPos[1] + PADDING + const lGraph = graph.graph() + const gWidth = Math.max(lGraph.width!, maxPos[0]) + marginLeft + PADDING + const gHeight = Math.max(lGraph.height!, maxPos[1]) + marginTop + PADDING + + return ( + + + + + + + + + + + + + + + {graph.edges().map(id => { + const edge = graph.edge(id) + // const node = edge.node as Node + const conns = edge.conns as GraphMoveSequence[] + const strokePath = `M${edge.points[0].x + marginLeft},${ + edge.points[0].y + marginTop + }L${edge.points + .slice(1) + .map(p => `${p.x + marginLeft},${p.y + marginTop}`) + .join(",")}` + const isCurrent = + "m" in props.model.current && conns.includes(props.model.current.m) + const isRoute = props.model.constructedRoute.some(ptr => + conns.includes(ptr.m) + ) + return ( + <> + {isCurrent && ( + ref?.scrollIntoView()} + markerEnd="url(#arrow-hl)" + /> + )} + 1 ? "6 3" : undefined} + class={twJoin( + "fill-none", + isRoute ? "stroke-theme-600" : "stroke-neutral-300" + )} + markerEnd={`url(#arrow${isRoute ? "-route" : ""})`} + /> + + ) + })} + + + {Array.from(props.model.nodeHashMap.values()).map(node => { + const gNode = graph.node(node.getHashName()) + return ( + { + props.model.goTo(node) + props.updateLevel() + }} + ref={ref => + node === props.model.current ? ref?.scrollIntoView() : undefined + } + /> + ) + })} + + + ) +} +const twClasses = `from-zinc-500 to-zinc-500 bg-zinc-500 fill-zinc-500 +from-theme-400 to-theme-400 bg-theme-400 fill-theme-400 +from-cyan-400 to-cyan-400 bg-cyan-400 fill-cyan-400 +from-theme-600 to-theme-600 bg-theme-600 fill-theme-600` +void twClasses + +function getNodeColor(model: GraphModel, node: Node) { + return node === model.rootNode + ? "zinc-500" + : node.loose + ? "theme-400" + : node.level.gameState === GameState.WON + ? "cyan-400" + : "theme-600" +} + +const MOVE_CURSOR_CLASS = + "bg-theme-700 text-theme-200 whitespace-break-spaces rounded-sm font-mono" + +export function MovesList(props: { + offset: number + composeOverlay: KeyInputs + moves: string[] +}) { + const { moves, offset, composeOverlay } = props + const composeText = generateSecondaryChars(composeOverlay) + let futureMoves: VNode + if (offset === moves.length) { + futureMoves = {composeText} + } else if (!composeText) { + futureMoves = ( + <> + {moves[offset]} + {moves.slice(offset + 1).join("")} + + ) + } else { + const movesStr = moves + .slice(offset) + .join("") + .slice(composeText.length + 1) + futureMoves = ( + <> + {composeText} + {movesStr} + + ) + } + return ( + + {offset !== -1 && moves.slice(0, offset).join("")} + {futureMoves} + + ) +} + +export function Infobox(props: GraphViewProps) { + const model = props.model + const composeText = generateSecondaryChars(props.inputs) + if ("m" in model.current || model.current.loose) { + let seq: GraphMoveSequence + let offset: number + if ("m" in model.current) { + seq = model.current.m + offset = model.current.o + } else { + seq = model.current.getLooseMoveSeq() + offset = seq.tickLen + } + return ( + <> + {"m" in model.current ? "Edge" : "Loose node"} +
    + + + ) + } + return ( + <> + {model.current === model.rootNode + ? "Root node" + : model.current.level.gameState === GameState.WON + ? "Winning node" + : "Node"} +
    + Dists: root {formatTicks(model.current.rootDistance)}s /{" "} + {model.current.winDistance === undefined + ? "not won" + : `win ${formatTicks(model.current.winDistance)}s`} +
    + Edges:{" "} + {Array.from(model.current.outConns.values()) + .flat() + .map((seq, i) => ( + <> + {i !== 0 && ", "} + + {seq.tickLen <= 16 + ? seq.displayMoves.join("") + : seq.displayMoves.slice(0, 16).join("") + "…"} + + + ))} + {composeText && ( + <> + {model.current.outConns.size !== 0 && ", "} + {composeText} + + )} + {!composeText && model.current.outConns.size === 0 && "none"} + + ) +} + +export function ConstructionNode( + props: HTMLAttributes & { node: Node; model: GraphModel } +) { + const nodeColor = getNodeColor(props.model, props.node) + const leftColor = + props.node.inNodes.length > 1 + ? "var(--tw-gradient-from)" + : "var(--tw-gradient-to)" + const rightColor = + props.node.outNodes.length > 1 + ? "var(--tw-gradient-from)" + : "var(--tw-gradient-to)" + + return ( +
    + ) +} + +export function GraphScrollBar(props: { + model: GraphModel + updateLevel: () => void +}) { + const tickSum = props.model.constructedRoute.reduce( + (acc, val) => acc + val.m.tickLen, + 0 + ) + const nodeEnts: [number, ConnPtr | null, VNode][] = [] + let seenTicks = 0 + let curTicks: number | null = null + for (const ptr of props.model.constructedRoute) { + if ( + ptr.n === props.model.current || + ("m" in props.model.current && props.model.current.m === ptr.m) + ) { + curTicks = + seenTicks + ("o" in props.model.current ? props.model.current.o : 0) + } + nodeEnts.push([ + seenTicks + ptr.m.tickLen, + ptr, + , + ]) + seenTicks += ptr.m.tickLen + } + nodeEnts.push([ + Infinity, + null, + , + ]) + if (curTicks === null) { + curTicks = tickSum + } + const onScrub = (progress: number) => { + const posIdx = Math.round(progress * tickSum) + const ent = nodeEnts.find(ent => posIdx < ent[0])! + let pos: MovePtr | Node + const tickPos = ent[0] - (ent[1]?.m.tickLen ?? 0) + if (ent[1] === null) { + pos = props.model.constructionLastNode() + } else if (tickPos === posIdx) { + pos = ent[1].n + } else { + pos = { n: ent[1].n, m: ent[1].m, o: posIdx - tickPos } + } + props.model.goTo(pos) + props.updateLevel() + } + return ( + + {nodeEnts.map(nodeEnt => nodeEnt[2])} + + + ) +} + +function ConstrPart( + props: GraphViewProps & { + ptr: ConnPtr + offset: number + inFuture: boolean + inPast: boolean + } +) { + const goToNode = useCallback(() => { + props.model.goTo(props.ptr.n) + props.updateLevel() + }, [props.model, props.ptr, props.updateLevel]) + return ( + + + {props.ptr.m.displayMoves[0]} + + + {props.inPast || props.inFuture ? ( + props.ptr.m.displayMoves.slice(1) + ) : ( + + )} + + ) +} + +function ConstructionView(props: GraphViewProps) { + const constrParts: VNode[] = [] + const curNode = + props.model.current instanceof Node + ? props.model.current + : props.model.current.n + let constrIdx = props.model.constructedRoute.findIndex( + ptr => ptr.n === curNode + ) + if (constrIdx === -1) { + constrIdx = props.model.constructedRoute.length + } + let idx = 0 + for (const ptr of props.model.constructedRoute) { + constrParts.push( + constrIdx} + inPast={idx < constrIdx} + offset={ + idx < constrIdx + ? ptr.m.tickLen + : idx === constrIdx + ? !(props.model.current instanceof Node) + ? props.model.current.o + : 0 + : 0 + } + /> + ) + idx += 1 + } + const lastNode = props.model.constructionLastNode() + constrParts.push( + + { + props.model.goTo(lastNode) + props.updateLevel() + }} + node={lastNode} + model={props.model} + /> + + ) + return ( + + {constrParts} + + ) +} + +export function GraphView(props: GraphViewProps) { + const [view, setView] = useState<"construction" | "graph">("construction") + return ( +
    +
    + +
    + {view === "construction" ? ( +
    + +
    + ) : ( + <> +
    + +
    +
    + +
    + + )} +
    + ) +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/OpenExaPrompt.tsx b/gamePlayer/src/pages/ExaPlayerPage/OpenExaPrompt.tsx new file mode 100644 index 00000000..f06a4ac5 --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/OpenExaPrompt.tsx @@ -0,0 +1,409 @@ +import { Dialog } from "@/components/Dialog" +import { PromptComponent, showAlertGs, showPromptGs } from "@/prompts" +import { HashSettings, Route } from "@notcc/logic" +import { Getter, Setter, useAtomValue } from "jotai" +import { useCallback, useEffect, useState } from "preact/hooks" +import { LevelData, levelAtom, levelSetAtom } from "@/levelData" +import { + levelNAtom, + levelSetIdentAtom, + pageAtom, + pageNameAtom, +} from "@/routing" +import { preferenceAtom } from "@/preferences" +import { Expl } from "@/components/Expl" +import type { AnyProjectSave, ExaProj, RouteLocation } from "./exaProj" +import { useJotaiFn, usePromise } from "@/helpers" +import { basename, join } from "path" +import { exists, readDir, readJson, showLoadPrompt } from "@/fs" + +export type ExaNewEvent = { + type: "new" + model: MoveModel + hashSettings?: HashSettings +} + +export type ExaOpenEvent = { type: "open"; save: AnyProjectSave; path?: string } + +export type ExaInitEvent = ExaNewEvent | ExaOpenEvent + +export type MoveModel = "linear" | "tree" | "graph" + +export type FoundProjectFile = { + path: string +} & ({ isRoute: true; contents: Route } | { isRoute: false; contents: ExaProj }) + +export async function findRouteFiles( + location: RouteLocation +): Promise { + if (!(await exists(join("/routes", location.setIdent)))) return [] + const files: FoundProjectFile[] = [] + for (const file of await readDir(join("/routes", location.setIdent))) { + const filePath = join("/routes", location.setIdent, file) + if (!file.startsWith(location.levelN.toString())) continue + if (location.path && filePath !== location.path) continue + // @ts-ignore + const routeFile: FoundProjectFile = { + path: filePath, + isRoute: file.endsWith(".route"), + contents: await readJson(filePath), + } + files.push(routeFile) + } + return files +} + +export function getModelTypeFromSave(save: AnyProjectSave) { + if ("Moves" in save) return "route" + return "graph" +} + +function HashSettingsInput(props: { + settings: HashSettings + disabled?: boolean + onChange?: (settings: HashSettings) => void +}) { + function Input(props2: { + name: string + id: HashSettings + desc: string + disabled?: boolean + }) { + return ( +
    + + {props2.desc} +
    + ) + } + return ( +
    + + + If set to anything other than none, tick parity will be taken account + when calculating the hash. Only use if the selected monster is in the + level and affects routing. + +
    + + + +
    + ) +} + +export const DEFAULT_HASH_SETTINGS: HashSettings = + HashSettings.IGNORE_TEETH_PARITY | + HashSettings.IGNORE_MIMIC_PARITY | + HashSettings.IGNORE_BLOCK_ORDER + +function NewProject(props: { + onSubmit: (ev: ExaNewEvent & { byDefault: boolean }) => void +}) { + const [moveModel, setMoveModel] = useState("linear") + const [hashSettings, setHashSettings] = useState( + DEFAULT_HASH_SETTINGS + ) + const [useByDefault, setUseByDefault] = useState(false) + const createNewModel = (ev: Event) => { + ev.preventDefault() + props.onSubmit({ + type: "new", + model: moveModel, + hashSettings, + byDefault: useByDefault, + }) + } + return ( +
    +

    New

    + + Select ExaCC move model: + + The move model determines how the inputs will be tracked. More complex + move models make routing easier, but are harder to grasp and require + more system resources. + + +
    { + setMoveModel( + //@ts-ignore + ev.currentTarget.parentElement.elements.namedItem("moveMode").value + ) + }} + > + + + The SuperCC experience. There is one move sequence, adding inputs over + existing moves overwrites them. + +
    + + + The MVS experience. Adding inputs over existing moves creates a new + branch containing the input and all previous moves. + +
    + + + Move sequences are treated as bridges between level states, new inputs + creating and merging branches as necessary. Different level states + with effectively equal positions can be manually tied to be treated as + the same level state. + +
    +

    + Hash settings + + Graph mode deems two level states identical if their hashes are equal. + By ignoring some of the level state when calculating the hash, two + level states which have minor, unimportant differences will be + considered as the same state. + +

    +
    + + +
    + + +
    + ) +} +function OpenProject(props: { + onSubmit: (ev: ExaOpenEvent) => void + setLocation?: RouteLocation +}) { + const loadedProjectsRes = usePromise( + () => + props.setLocation + ? findRouteFiles(props.setLocation) + : Promise.resolve(null), + [props.setLocation] + ) + useEffect(() => { + if (loadedProjectsRes.state === "error") { + console.error(loadedProjectsRes.error) + } + }, [loadedProjectsRes.state]) + const showAlert = useJotaiFn(showAlertGs) + const importProject = useCallback(async () => { + const res = await showLoadPrompt("Load project", { + filters: [ + { name: "ExaCC project", extensions: ["exaproj"] }, + { name: "Routefile", extensions: ["route"] }, + ], + }) + if (!res) return + const importedFile: AnyProjectSave = JSON.parse(await res[0].text()) + if (!("Moves" in importedFile) && !("Model" in importedFile)) { + showAlert( + <> + Loaded file doesn't appear to be a Routefile or ExaProject, are you + sure you loaded the correct file? + , + "Invalid file" + ) + return + } + props.onSubmit({ type: "open", save: importedFile }) + }, []) + return ( +
    +

    Open

    + {props.setLocation ? ( +
    + Existing projects for {props.setLocation.setName} # + {props.setLocation.levelN}: +
    + ) : ( +
    No saved projects for level outside of a set
    + )} +
    + {loadedProjectsRes.state !== "done" || + !loadedProjectsRes.value || + loadedProjectsRes.value.length === 0 ? ( +
    + {loadedProjectsRes.state === "error" + ? "Failed to load local project files" + : loadedProjectsRes.state === "working" + ? "Loading..." + : "No project files found"} +
    + ) : ( + <> + {loadedProjectsRes.value.map(proj => ( +
    + props.onSubmit({ + type: "open", + save: proj.contents, + path: proj.path, + }) + } + > + {getModelTypeFromSave(proj.contents) === "route" + ? "Route" + : "Graph"}{" "} + - {basename(proj.path)} +
    + ))} + + )} +
    +
    + +
    +
    + ) +} + +export const OpenExaPrompt: PromptComponent< + (ExaInitEvent & { byDefault?: boolean }) | null +> = props => { + const levelSet = useAtomValue(levelSetAtom) + const levelN = useAtomValue(levelNAtom) + const setIdent = useAtomValue(levelSetIdentAtom) + return ( + props.onResolve(null)]]} + onClose={() => props.onResolve(null)} + > +
    + + +
    +
    + ) +} + +export const exaToggleConfig = preferenceAtom( + "exaToggleConfig", + null +) + +export async function toggleExaCC(get: Getter, set: Setter) { + const pageName = get(pageNameAtom) + if (pageName === "exa") { + set(pageAtom, "play") + } else { + const levelData = await get(levelAtom) + if (!levelData) return + await openExaCC(get, set, levelData) + } +} + +export async function openExaCC( + get: Getter, + set: Setter, + levelData: LevelData, + useDefaultConfig = true +): Promise { + let config: ExaInitEvent | null = null + if (useDefaultConfig) { + config = get(exaToggleConfig) + } + if (!config) { + const openEv = await showPromptGs(get, set, OpenExaPrompt) + if (openEv?.byDefault && openEv.type === "new") { + set(exaToggleConfig, openEv) + } + config = openEv + } + if (!config) return false + const realIndex = await import("./exaPlayer") + realIndex.openExaCCReal(get, set, config, levelData) + return true +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/exaPlayer.tsx b/gamePlayer/src/pages/ExaPlayerPage/exaPlayer.tsx new file mode 100644 index 00000000..8e360446 --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/exaPlayer.tsx @@ -0,0 +1,1045 @@ +import { CameraType, GameRenderer } from "@/components/GameRenderer" +import { Getter, Setter, atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import { LinearModel } from "./models/linear" +import { GraphModel } from "./models/graph" +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "preact/hooks" +import { + GameState, + KeyInputs, + calculateLevelPoints, + protobuf, + Level, + KEY_INPUTS, + BasicTile, + Actor, + Direction, + SlidingState, + protoTimeToMs, + Inventory as InventoryT, + LevelModifiers, + Route, + RouteDirection, + ItemIndex, + InputProvider, + DETERMINISTIC_BLOB_MOD, + filterSimulChar, +} from "@notcc/logic" +import { tilesetAtom } from "@/components/PreferencesPrompt/TilesetsPrompt" +import { + CompensatingIntervalTimer, + TimeoutTimer, + formatTimeLeft, + useJotaiFn, +} from "@/helpers" +import { ExaNewEvent, ExaInitEvent } from "./OpenExaPrompt" +import { Inventory } from "@/components/Inventory" +import { + GraphScrollBar as GraphTimelineView, + GraphView, + MovesList, +} from "./GraphView" +import { levelNAtom, levelSetIdentAtom, pageAtom } from "@/routing" +import { + LevelData, + getGlobalLevelModifiersGs, + levelSetAtom, + useSwrLevel, +} from "@/levelData" +import { + exaComplainAboutNonlegalGlitches, + filterSimulCharExaAtom, + modelAtom, +} from "." +import { calcScale } from "@/components/DumbLevelPlayer" +import { PromptComponent, showPromptGs } from "@/prompts" +import { Dialog } from "@/components/Dialog" +import { levelControlsAtom } from "@/components/Sidebar" +import { + TIMELINE_DEFAULT_IDX, + TIMELINE_PLAYBACK_SPEEDS, + Timeline, + TimelineBox, + TimelineHead, +} from "@/components/Timeline" +import { Toast, addToastGs, adjustToastGs, removeToastGs } from "@/toast" +import { Renderer, Tileset } from "@/components/GameRenderer/renderer" +import { + NonlegalMessage, + isGlitchKindNonlegal, +} from "@/components/NonLegalMessage" +import { + RouteLocation, + findModelSavePath, + makeModel, + makeModelSave, + modelFromSave, +} from "./exaProj" +import { makeDirP, showSavePrompt, writeJson } from "@/fs" +import { basename, join } from "path" +import { Expl } from "@/components/Expl" +import { dismissablePreferenceAtom } from "@/preferences" +import { RepeatState, inputConfigAtom, useInputCollector } from "@/inputs" + +const modelConfigAtom = atom(null) +type Model = LinearModel | GraphModel + +function getDefaultLevelModifiersGs(get: Getter, set: Setter): LevelModifiers { + return { + ...getGlobalLevelModifiersGs(get, set), + blobMod: DETERMINISTIC_BLOB_MOD, + randomForceFloorDirection: Direction.UP, + } +} + +export function openExaCCReal( + get: Getter, + set: Setter, + openEv: ExaInitEvent, + levelData: LevelData +) { + let modifiers: LevelModifiers + let model: Model + let config: ExaNewEvent + if (openEv.type === "open") { + const loadRes = modelFromSave(levelData, openEv.save) + modifiers = loadRes.modifiers + model = loadRes.model + config = loadRes.config + set(projectSavePathAtom, openEv.path ?? null) + } else { + modifiers = getDefaultLevelModifiersGs(get, set) + model = makeModel(levelData, openEv, modifiers) + config = openEv + set(projectSavePathAtom, null) + } + set(exaLevelModifiersAtom, modifiers) + set(modelConfigAtom, config) + set(pageAtom, "exa") + set(modelAtom, model) +} + +// function useModel() { +// const [model, setModel] = useAtom(modelAtom) +// return (fn: (model: LinearModel | GraphModel) => void) => { +// fn(model!) +// setModel(model) +// } +// } + +function LinearView(props: { model: LinearModel; inputs: KeyInputs }) { + return ( +
    + +
    + ) +} + +const CameraUtil: PromptComponent = pProps => { + const [cameraType, setCameraType] = useAtom(cameraTypeAtom) + const [tileScale, setTileScale] = useAtom(tileScaleAtom) + const model = useAtomValue(modelAtom) + const tileset = useAtomValue(tilesetAtom) + useLayoutEffect(() => { + if (!model) { + pProps.onResolve() + } + }, [model]) + if (!model) return <> + return ( + {}]]} + onResolve={pProps.onResolve} + > +
    + Camera size:{" "} + + setCameraType({ + ...cameraType, + width: parseInt(ev.currentTarget.value), + }) + } + /> + {" x "} + + setCameraType({ + ...cameraType, + height: parseInt(ev.currentTarget.value), + }) + } + /> +
    +
    + Tile scale:{" "} + setTileScale(parseFloat(ev.currentTarget.value))} + /> +
    + +
    + ) +} + +const hoveredTileAtom = atom(null) + +function getBasicTileDesc(tile: BasicTile) { + return `${tile.type.name} (${tile.customData.toString(16)})` +} +function getInventoryDesc(inv: InventoryT) { + const itemN = inv.item4 + ? 4 + : inv.item3 + ? 3 + : inv.item2 + ? 2 + : inv.item1 + ? 1 + : 0 + let str = "[" + if (itemN >= 1) str += `${inv.item1!.name}, ` + if (itemN >= 2) str += `${inv.item2!.name}, ` + if (itemN >= 3) str += `${inv.item3!.name}, ` + if (itemN >= 4) str += `${inv.item4!.name}, ` + str += "]" + str += ` r${inv.keysRed} b${inv.keysBlue} y${inv.keysYellow} g${inv.keysGreen}` + return str +} +function getActorDesc(level: Level, actor: Actor) { + return `${actor.type.name} (${actor.customData}) IDX ${actor.actorListIdx(level)} ${Direction[actor.direction]}${ + actor.pendingDecision !== Direction.NONE + ? ` pending ${ + Direction[actor.pendingDecision] + } (${actor.pendingDecisionLockedIn ? "locked" : "unlocked"})` + : "" + }${ + actor.moveProgress !== 0 + ? ` moving ${actor.moveProgress}/${actor.moveLength}` + : "" + }${actor.slidingState !== SlidingState.NONE ? ` sliding ${SlidingState[actor.slidingState]}` : ""}${ + actor.bonked ? " bonked" : "" + }${actor.frozen ? " frozen" : ""}${actor.pulling ? " pulling" : ""}${ + actor.pulled ? " pulled" : "" + }${actor.pushing ? " pushing" : ""} INV ${getInventoryDesc(actor.inventory)}` +} + +const TileInspector: PromptComponent = pProps => { + const model = useAtomValue(modelAtom) + const hoveredTile = useAtomValue(hoveredTileAtom) + const cell = + model && hoveredTile && model.level.getCell(hoveredTile[0], hoveredTile[1]) + return ( + {}]]} + onResolve={pProps.onResolve} + > +
    + Hovered tile{" "} + {hoveredTile === null + ? "none" + : `(${hoveredTile[0]}, ${hoveredTile[1]}):`} +
    +
    + {cell?.actor && `Actor: ${getActorDesc(model!.level, cell.actor)}\n`} + {cell?.special && `Special: ${getBasicTileDesc(cell.special)}\n`} + {cell?.itemMod && `Item mod: ${getBasicTileDesc(cell.itemMod)}\n`} + {cell?.item && `Item: ${getBasicTileDesc(cell.item)}\n`} + {cell?.terrain && `Terrain: ${getBasicTileDesc(cell.terrain)}\n`} +
    +
    + ) +} + +function computeDefaultCamera( + level: Level, + tileset: Tileset +): [CameraType, number] { + const camera: CameraType = { + width: Math.min(32, level.width), + height: Math.min(32, level.height), + } + let scale = calcScale({ + tileSize: tileset.tileSize, + cameraType: camera, + twPadding: [1 + 2 + 2 + 2 + 2 + 64 + 2 + 1, 1 + 2 + 2 + 2 + 6 + 2 + 1], + tilePadding: [4, 0], + subSteps: 4, + }) + if (scale < 1) { + scale = 0.5 + } + return [camera, scale] +} + +const cameraTypeAtom = atom({ width: 10, height: 10 }) +const tileScaleAtom = atom(1) + +function LinearTimelineView(props: { + model: LinearModel + updateLevel: () => void +}) { + return ( + { + if (props.model.moveSeq.tickLen === 0) return + props.model.goTo(Math.round(progress * props.model.moveSeq.tickLen)) + props.updateLevel() + }} + > + + + ) +} + +const NonlegalPrompt = + (props: { + glitch: protobuf.IGlitchInfo + undo: () => void + }): PromptComponent => + pProps => { + return ( + {}], + ["Undo", props.undo], + ]} + > + + + ) + } + +function getLevelStats(levelN: number, model: Model) { + const timeLeft = model.timeLeft() + return { + timeLeft, + chipsLeft: model.level.chipsLeft, + bonusPoints: model.level.bonusPoints, + totalPoints: calculateLevelPoints( + levelN, + Math.ceil(timeLeft / 60), + model.level.bonusPoints + ), + } +} + +const confirmNewExaModifiersPromptDismissedAtom = dismissablePreferenceAtom( + "confirmNewExaModifiersPromptDismissed" +) + +const ConfirmNewModifiersPrompt: PromptComponent = pProps => { + const setPromptDismissed = useSetAtom( + confirmNewExaModifiersPromptDismissedAtom + ) + return ( + pProps.onResolve(false)], + ["Apply", () => pProps.onResolve(true)], + ]} + onClose={() => pProps.onResolve(false)} + > +
    + Warning: to apply the new level modifiers, the level will be reset and + the current project will be re-imported on the new level. If any + sequence of moves in the project prematurely wins or loses the level, + some of the moves might be lost. +
    +
    + +
    +
    + ) +} + +const IncompatibleReplayModifiersPrompt: PromptComponent< + "cancel" | "reset" | "overlay" +> = pProps => ( + pProps.onResolve("cancel")], + ["Reset project", () => pProps.onResolve("reset")], + ["Overlay over project anyway", () => pProps.onResolve("overlay")], + ]} + onClose={() => pProps.onResolve("cancel")} + > + The replay you attempted to load has incompatible level modifiers with the + current project. To load the replay, the current project must be reset. + Otherwise, the replay may fail if it depends on the specific modifiers. + +) + +const exaLevelModifiersAtom = atom(null) + +const DIRECTION_PRETTY_NAMES: Record = { + [Direction.NONE]: "None", + [Direction.UP]: "Up", + [Direction.RIGHT]: "Right", + [Direction.DOWN]: "Down", + [Direction.LEFT]: "Left", +} + +const ModifiersControl: PromptComponent = pProps => { + const [model, setModel] = useAtom(modelAtom) + const level = model?.level + const blobLimit = !level?.metadata + ? 0 + : level.metadata.rngBlobDeterministic + ? 1 + : level.metadata.rngBlob4Pat + ? 4 + : 256 + + const [levelModifiers, setLevelModifiers] = useAtom(exaLevelModifiersAtom) + + const [rffDirection, setRffDirection] = useState( + levelModifiers?.randomForceFloorDirection ?? Direction.UP + ) + const [blobMod, setBlobMod] = useState( + levelModifiers?.blobMod ?? DETERMINISTIC_BLOB_MOD + ) + const bypassConfirmDialog = useAtomValue( + confirmNewExaModifiersPromptDismissedAtom + ) + const showPrompt = useJotaiFn(showPromptGs) + + const levelData = useSwrLevel() + const modelConfig = useAtomValue(modelConfigAtom) + const applyNewModifiers = useCallback(async () => { + if (!modelConfig || !levelData) return + if (!bypassConfirmDialog) { + const agreedToContinue = await showPrompt(ConfirmNewModifiersPrompt) + if (!agreedToContinue) return + } + const newModifiers: LevelModifiers = { + ...levelModifiers, + randomForceFloorDirection: rffDirection, + blobMod, + } + const newModel = makeModel(levelData, modelConfig, newModifiers) + // @ts-ignore + newModel.transcribeFromOther(model) + setLevelModifiers(newModifiers) + setModel(newModel) + }, [model, levelModifiers, rffDirection, blobMod, bypassConfirmDialog]) + + return ( + pProps.onResolve()} + buttons={[ + ["Close", () => pProps.onResolve()], + ["Apply", () => applyNewModifiers()], + ]} + > +
    +
    +
    + RFF directionthe initial random force floor direction +
    +
    + Blob modthe initial randomness value for blobs +
    +
    Current
    +
    + {level && + levelModifiers && + DIRECTION_PRETTY_NAMES[ + levelModifiers.randomForceFloorDirection ?? Direction.UP + ]} +
    +
    + {level && + levelModifiers && + (blobLimit === 1 + ? "N/A (one seed)" + : levelModifiers.blobMod !== undefined && + levelModifiers.blobMod % blobLimit)} +
    +
    New
    +
    + +
    +
    + {blobLimit === 1 ? ( + "N/A (one seed)" + ) : ( + { + setBlobMod(parseInt(ev.currentTarget.value)) + }} + /> + )} +
    +
    +
    + ) +} + +function areLevelModifiersEqual(a: LevelModifiers, b: LevelModifiers) { + if ( + (a.blobMod ?? DETERMINISTIC_BLOB_MOD) !== + (b.blobMod ?? DETERMINISTIC_BLOB_MOD) + ) + return false + if ( + (a.randomForceFloorDirection ?? Direction.UP) !== + (b.randomForceFloorDirection ?? Direction.UP) + ) + return false + if (a.timeLeft !== b.timeLeft) return false + if ( + (a.inventoryKeys?.red ?? 0) !== (b.inventoryKeys?.red ?? 0) || + (a.inventoryKeys?.green ?? 0) !== (b.inventoryKeys?.green ?? 0) || + (a.inventoryKeys?.blue ?? 0) !== (b.inventoryKeys?.blue ?? 0) || + (a.inventoryKeys?.yellow ?? 0) !== (b.inventoryKeys?.yellow ?? 0) + ) + return false + if ((a.playableEnterN ?? 0) !== (b.playableEnterN ?? 0)) return false + const aTools = a.inventoryTools ?? [ + ItemIndex.Nothing, + ItemIndex.Nothing, + ItemIndex.Nothing, + ItemIndex.Nothing, + ] + const bTools = b.inventoryTools ?? [ + ItemIndex.Nothing, + ItemIndex.Nothing, + ItemIndex.Nothing, + ItemIndex.Nothing, + ] + if (aTools.some((aItem, idx) => aItem !== bTools[idx])) return false + return true +} + +function importInputsToModel( + model: Model, + ip: InputProvider, + reportProgress?: (progress: number) => void +) { + const UPDATE_PERIOD = 600 + // Graph model might mess with the level's currentTick/Subtick if it detects a redundancy, so we need to maintain a separate linear currentSubtick + let linearCurrentSubtick = 0 + while (!ip.outOfInput(linearCurrentSubtick)) { + if (model.level.gameState !== GameState.PLAYING) break + const moveLength = model.addInput(ip.getInput(linearCurrentSubtick, 0)) + if (linearCurrentSubtick % UPDATE_PERIOD === 0) { + reportProgress?.(ip.inputProgress(linearCurrentSubtick)) + } + linearCurrentSubtick += moveLength + } +} + +const projectSavePathAtom = atom(null) + +export function RealExaPlayerPage() { + const [modelM, setModel] = useAtom(modelAtom) + const aLevel = useSwrLevel() + const [modelConfig, setModelConfig] = useAtom(modelConfigAtom) + + const model = modelM! + useEffect(() => { + // @ts-ignore + globalThis.NotCC.exa = { model } + }, [model]) + const playerSeat = model.level.playerSeats[0] + const levelN = useAtomValue(levelNAtom)! + const setControls = useSetAtom(levelControlsAtom) + // Sidebar and router comms, level state + function purgeBackfeed() { + if (!(model instanceof GraphModel)) return + for (const ptr of model.findBackfeedConns()) { + ptr.n.removeConnection(ptr.m) + } + model.buildReferences() + updateLevel() + } + const complainAboutNonlegal = useAtomValue(exaComplainAboutNonlegalGlitches) + const filterSimulCharEnabled = useAtomValue(filterSimulCharExaAtom) + const checkForNonlegalGlitches = useCallback( + (lastCheck: number) => { + if (!complainAboutNonlegal) return + const nonlegalGlitches = [...model.level.glitches].filter( + gl => + isGlitchKindNonlegal(gl.glitchKind) && + protoTimeToMs(gl.toGlitchInfo().happensAt!) > lastCheck + ) + if (nonlegalGlitches.length > 0) { + showPrompt( + NonlegalPrompt({ + glitch: nonlegalGlitches[0].toGlitchInfo(), + undo: () => { + model.undo() + updateLevel() + }, + }) + ) + } + }, + [model, complainAboutNonlegal] + ) + const [levelModifiers, setLevelModifiers] = useAtom(exaLevelModifiersAtom) + const levelSet = useAtomValue(levelSetAtom) + const levelSetIdent = useAtomValue(levelSetIdentAtom) + const [projectSavePath, setProjectSavePath] = useAtom(projectSavePathAtom) + const getDefaultLevelModifiers = useJotaiFn(getDefaultLevelModifiersGs) + + const lastLevelRef = useRef(aLevel) + + useEffect(() => { + if (!aLevel || !modelConfig || !levelModifiers) return + if (aLevel === lastLevelRef.current) { + lastLevelRef.current = aLevel + return + } + lastLevelRef.current = aLevel + const modifiers = getDefaultLevelModifiers() + const model = makeModel(aLevel, modelConfig, modifiers) + setLevelModifiers(modifiers) + setModel(model) + }, [aLevel, modelConfig]) + + const showPrompt = useJotaiFn(showPromptGs) + const addToast = useJotaiFn(addToastGs) + const removeToast = useJotaiFn(removeToastGs) + const adjustToast = useJotaiFn(adjustToastGs) + useEffect(() => { + setControls({ + restart() { + model.resetLevel() + updateLevel() + }, + async playInputs(ip) { + if (!aLevel || !modelConfig) return + let thisModel = model + const ipModifiers = ip.levelModifiers() + if (!areLevelModifiersEqual(levelModifiers ?? {}, ipModifiers)) { + let actuallyResetModel = true + if (!model.isBlank()) { + const userAgreedToReset = await showPrompt( + IncompatibleReplayModifiersPrompt + ) + if (userAgreedToReset === "cancel") return + actuallyResetModel = userAgreedToReset === "reset" + } + if (actuallyResetModel) { + thisModel = makeModel(aLevel, modelConfig, ipModifiers) + setModel(thisModel) + setLevelModifiers(ipModifiers) + updateLevel() + } + } + const toast: Toast = { title: "Importing route (0%)" } + addToast(toast) + thisModel.resetLevel() + importInputsToModel(thisModel, ip, progress => { + updateLevel() + toast.title = `Importing route (${Math.floor(progress * 100)}%)` + adjustToast() + }) + removeToast(toast) + updateLevel() + }, + exa: { + undo: () => { + model.undo() + updateLevel() + }, + redo: () => { + const curTime = model.level.msecsPassed() + model.redo() + checkForNonlegalGlitches(curTime) + updateLevel() + }, + purgeBackfeed: model instanceof GraphModel ? purgeBackfeed : undefined, + cameraControls() { + showPrompt(CameraUtil) + }, + tileInspector() { + showPrompt(TileInspector) + }, + levelModifiersControls() { + showPrompt(ModifiersControl) + }, + save: + (levelSet ?? undefined) && + (async () => { + const routeLocation: RouteLocation = { + levelN, + setIdent: levelSetIdent!, + setName: levelSet!.gameTitle(), + path: projectSavePath ?? undefined, + } + + const savePath = await findModelSavePath( + model.level.metadata.title ?? "UNKNOWN", + model instanceof LinearModel, + routeLocation + ) + await makeDirP(join(savePath, "..")) + await writeJson( + savePath, + makeModelSave(model, levelModifiers ?? {}, routeLocation) + ) + addToast({ + title: !projectSavePath + ? `Saved as ${basename(savePath)}` + : "Saved", + autoHideAfter: 2, + }) + setProjectSavePath(savePath) + }), + async export() { + const moves = model.getSelectedMoveSequence() + const levelStats = getLevelStats(levelN, model) + const route: Route = { + Moves: moves.join(""), + Blobmod: levelModifiers?.blobMod, + "Initial Slide": + levelModifiers?.randomForceFloorDirection === undefined + ? undefined + : (Direction[ + levelModifiers.randomForceFloorDirection + ] as RouteDirection), + Rule: "Steam", + ExportApp: "ExaCC v2.0", + For: { + Set: levelSet?.gameTitle(), + LevelName: model.level.metadata.title ?? undefined, + LevelNumber: levelSet ? levelN : undefined, + }, + } + showSavePrompt( + new TextEncoder().encode(JSON.stringify(route)).buffer, + "Save route export", + { + defaultPath: `./${route.For!.LevelName?.replace("/", " ") ?? "Unknown"} ${formatTimeLeft(levelStats.timeLeft, false)}s ${levelStats.totalPoints}pts.route`, + } + ) + }, + }, + }) + }, [model, levelModifiers, checkForNonlegalGlitches, projectSavePath]) + useEffect(() => { + return () => { + setModel(null) + setProjectSavePath(null) + setLevelModifiers(null) + setModelConfig(null) + setControls({}) + } + }, []) + // Rendering + const renderRef1 = useRef<() => void>() + const renderRef2 = useRef<() => void>() + function render() { + renderRef1.current?.() + renderRef2.current?.() + } + useLayoutEffect(() => { + if (!model) return + render() + }, [model]) + const levelRef = useMemo( + () => ({ + get current() { + return model.level + }, + set current(_level) {}, + }), + [model] + ) + + const [, setDummyState] = useState(false) + + function rerunComponent() { + setDummyState(ds => !ds) + } + + function updateLevel() { + inputsRef.current = 0 + rerunComponent() + render() + } + const tileset = useAtomValue(tilesetAtom) + + const [cameraType, setCameraType] = useAtom(cameraTypeAtom) + const [tileScale, setTileScale] = useAtom(tileScaleAtom) + + useLayoutEffect(() => { + if (!tileset) return + // Guess a good default tile scale, and let the user adjust + const [camera, scale] = computeDefaultCamera(model.level, tileset) + setCameraType(camera) + setTileScale(scale) + }, [model, tileset]) + + // Inputs + const addInput = useCallback( + (input: KeyInputs) => { + if (!model) return + if (model.level.gameState !== GameState.PLAYING) return + if (!model.isCurrentlyAlignedToMove()) { + model.redo() + updateLevel() + return + } + const curTime = model.level.msecsPassed() + model.addInput(input) + checkForNonlegalGlitches(curTime) + updateLevel() + }, + [model, checkForNonlegalGlitches] + ) + const inputsRef = useRef(0) + const inputTimerRef = useRef() + + const finaliseInput = useCallback(() => { + inputTimerRef.current?.cancel() + inputTimerRef.current = undefined + if (filterSimulCharEnabled) { + inputsRef.current = filterSimulChar(inputsRef.current) + } + try { + addInput(inputsRef.current) + } finally { + inputsRef.current = 0 + } + }, [addInput]) + + const inputCallback = useCallback( + (rawInput: KeyInputs, player: number, keyState: RepeatState) => { + if (player !== 0 || keyState !== "held") return + const input = rawInput & model.playerSeat.getPossibleActions(model.level) + if (rawInput === 0) { + // Explicit wait + finaliseInput() + } else if (rawInput & KEY_INPUTS.directional) { + // Directional input, wait a moment for diagonal inputs + inputsRef.current |= input + if (!inputTimerRef.current) { + inputTimerRef.current = new TimeoutTimer(finaliseInput, 0.05) + } + } else { + // Secondary input + if (inputsRef.current & input) { + inputsRef.current &= ~input + } else { + inputsRef.current |= input + } + } + rerunComponent() + }, + [model, finaliseInput] + ) + + const inputConfig = useAtomValue(inputConfigAtom) + useInputCollector(inputConfig, inputCallback) + + // Scrollbar, scrub and playback + const [playing, setPlaying] = useState(false) + const [speedIdx, setSpeedIdx] = useState(TIMELINE_DEFAULT_IDX) + const timerRef = useRef(null) + function stepLevel() { + if (model.isAtEnd()) { + if (model.level.currentSubtick !== 1) { + model.level.tick() + updateLevel() + return + } + setPlaying(false) + return + } + model.step() + updateLevel() + } + useLayoutEffect(() => { + if (!playing) { + timerRef.current?.cancel() + timerRef.current = null + return + } + timerRef.current = new CompensatingIntervalTimer( + stepLevel, + 1 / (60 * TIMELINE_PLAYBACK_SPEEDS[speedIdx]) + ) + }, [playing]) + useLayoutEffect(() => { + if (!timerRef.current) return + timerRef.current.adjust(1 / (60 * TIMELINE_PLAYBACK_SPEEDS[speedIdx])) + }, [speedIdx]) + useEffect(() => { + return () => { + timerRef.current?.cancel() + timerRef.current = null + } + }, []) + const rendererRef = useRef(null) + const tilePosFromCanvasCoords = useCallback( + (coords: [number, number]): [number, number] | null => { + const renderer = rendererRef.current + const level = renderer?.level + if (!renderer || !level) return null + const tileSize = renderer.tileset.tileSize * tileScale + const cameraOffset = renderer.cameraPosition + const coordsTiled = [coords[0] / tileSize, coords[1] / tileSize] + const pos = [ + coordsTiled[0] - cameraOffset[0], + coordsTiled[1] - cameraOffset[1], + ] + if (pos[0] >= level?.width || pos[1] >= level.height) return null + return [Math.floor(pos[0]), Math.floor(pos[1])] + }, + [tileScale] + ) + const setHoveredTile = useSetAtom(hoveredTileAtom) + const levelStats = getLevelStats(levelN, model) + return ( +
    +
    +
    + { + setHoveredTile(tilePosFromCanvasCoords([ev.offsetX, ev.offsetY])) + }} + onMouseMove={ev => { + setHoveredTile(tilePosFromCanvasCoords([ev.offsetX, ev.offsetY])) + }} + onMouseOut={() => setHoveredTile(null)} + forcePerspective + /> +
    +
    + setPlaying(v)} + onSetSpeed={v => setSpeedIdx(v)} + > + {model instanceof LinearModel && ( + + )} + {model instanceof GraphModel && ( + + )} + +
    +
    +
    + +
    +
    +
    Time left:
    +
    + {formatTimeLeft(levelStats.timeLeft, true)}s +
    +
    Chips left:
    +
    {levelStats.chipsLeft}
    +
    Bonus points:
    +
    {levelStats.bonusPoints}
    +
    Total points:
    +
    {levelStats.totalPoints}
    +
    +
    +
    +
    + {model instanceof LinearModel && ( + // We make sure the component is always rerendered when ref changes, don't worry + + )} + {model instanceof GraphModel && ( + + )} +
    +
    +
    +
    + ) +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/exaProj.ts b/gamePlayer/src/pages/ExaPlayerPage/exaProj.ts new file mode 100644 index 00000000..d5de7c6f --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/exaProj.ts @@ -0,0 +1,156 @@ +import { + Direction, + LevelModifiers, + Route, + RouteDirection, + RouteFor, + applyLevelModifiers, + splitRouteCharString, +} from "@notcc/logic" +import { GraphModel, SerializedGraph } from "./models/graph" +import { exists } from "@/fs" +import { join } from "path" +import { LinearModel } from "./models/linear" +import { LevelData } from "@/levelData" +import { DEFAULT_HASH_SETTINGS, ExaNewEvent } from "./OpenExaPrompt" + +export type Model = LinearModel | GraphModel + +export interface ExaProj { + For?: RouteFor + ExportApp?: string + Blobmod?: number + "Initial Slide"?: RouteDirection + Model: { Type: "graph"; Contents: SerializedGraph } +} + +export type AnyProjectSave = Route | ExaProj + +export interface RouteLocation { + setIdent: string + setName: string + levelN: number + path?: string +} + +export function makeModelSave( + model: Model, + modifiers: LevelModifiers, + location?: RouteLocation +): AnyProjectSave { + let fileContents: AnyProjectSave + let levelName: string | null + if (model instanceof LinearModel) { + const initLevel = model.moveSeq.findSnapshot(0)!.level + levelName = initLevel.metadata.title + fileContents = { + Moves: model.moveSeq.moves.join(""), + Rule: "Steam", + } + } else { + levelName = model.rootNode.level.metadata.title + fileContents = { Model: { Type: "graph", Contents: model.serialize() } } + } + + fileContents.Blobmod = modifiers.blobMod + fileContents["Initial Slide"] = + modifiers.randomForceFloorDirection === undefined + ? undefined + : (Direction[modifiers.randomForceFloorDirection] as RouteDirection) + + fileContents.For = { + LevelName: levelName ?? undefined, + } + if (location) { + fileContents.For.Set = location.setName + fileContents.For.LevelNumber = location.levelN + } + fileContents.ExportApp = `ExaCC v2.0` + + return fileContents +} + +export async function findModelSavePath( + levelName: string, + isRoute: boolean, + location: RouteLocation +): Promise { + let filePath: string + if (location.path) { + filePath = location.path + } else { + let fileQualifier = 0 + do { + filePath = join( + "/routes", + location.setIdent, + `${location.levelN} - ${levelName}${fileQualifier !== 0 ? ` ${fileQualifier}` : ""}.${isRoute ? "route" : "exaproj"}` + ) + fileQualifier += 1 + } while (await exists(filePath)) + } + return filePath +} + +export function levelModifiersFromSave(save: AnyProjectSave): LevelModifiers { + return { + randomForceFloorDirection: + save["Initial Slide"] && Direction[save["Initial Slide"]], + blobMod: save["Blobmod"], + } +} + +export function modelFromSave( + levelData: LevelData, + save: AnyProjectSave +): { model: Model; modifiers: LevelModifiers; config: ExaNewEvent } { + const level = levelData.initLevel() + const modifiers = levelModifiersFromSave(save) + applyLevelModifiers(level, modifiers) + level.tick() + level.tick() + + let model: Model + let config: ExaNewEvent + + if ("Moves" in save) { + model = new LinearModel(level) + model.loadMoves(splitRouteCharString(save.Moves)) + config = { type: "new", model: "linear" } + } else if (save.Model.Type === "graph") { + model = new GraphModel(level, save.Model.Contents.hashSettings) + model.loadSerialized(save.Model.Contents) + config = { + type: "new", + model: "graph", + hashSettings: save.Model.Contents.hashSettings, + } + } else { + throw new Error("Unrecognized routefile type!") + } + + return { model, modifiers, config } +} + +export function makeModel( + levelData: LevelData, + conf: ExaNewEvent, + modifiers: LevelModifiers +): Model { + const level = levelData.initLevel() + if (modifiers) { + applyLevelModifiers(level, modifiers) + } + level.tick() + level.tick() + + let model: Model + if (conf.model === "linear") { + model = new LinearModel(level) + } else if (conf.model === "graph") { + model = new GraphModel(level, conf.hashSettings ?? DEFAULT_HASH_SETTINGS) + } else { + throw new Error("Unsupported model :(") + } + return model +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/index.tsx b/gamePlayer/src/pages/ExaPlayerPage/index.tsx new file mode 100644 index 00000000..07b8df9b --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/index.tsx @@ -0,0 +1,50 @@ +import { Suspense, lazy, useEffect, useRef } from "preact/compat" +import type { LinearModel } from "./models/linear" +import type { GraphModel } from "./models/graph" +import { atom, useAtomValue, useSetAtom } from "jotai" +import { useJotaiFn } from "@/helpers" +import { openExaCC as openExaCCgs } from "./OpenExaPrompt" +import { preferenceAtom } from "@/preferences" +import { useSwrLevel } from "@/levelData" +import { pageAtom } from "@/routing" + +export const modelAtom = atom(null) +export const exaComplainAboutNonlegalGlitches = preferenceAtom( + "exaPreventNonlegalGlitches", + false +) +export const filterSimulCharExaAtom = preferenceAtom("filterSimulCharExa", true) + +const RealExaPlayerPage = lazy(() => + import("./exaPlayer").then(mod => mod.RealExaPlayerPage) +) + +function ExaPromptShower() { + const levelData = useSwrLevel() + const promptShown = useRef(false) + const openExaCC = useJotaiFn(openExaCCgs) + const setPage = useSetAtom(pageAtom) + useEffect(() => { + if (promptShown.current) return + if (!levelData) return + promptShown.current = true + openExaCC(levelData).then(opened => { + if (!opened) { + setPage("") + } + }) + }, [levelData]) + return <> +} + +export function ExaPlayerPage() { + const model = useAtomValue(modelAtom) + if (model === null) { + return + } + return ( + Loading™...
    }> + + + ) +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/models/graph.ts b/gamePlayer/src/pages/ExaPlayerPage/models/graph.ts new file mode 100644 index 00000000..1106e485 --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/models/graph.ts @@ -0,0 +1,890 @@ +import { + GameState, + KeyInputs, + Level, + PlayerSeat, + charToKeyInput, + HashSettings, + RouteFileInputProvider, + splitRouteCharString, +} from "@notcc/logic" +import { MoveSeqenceInterval, MoveSequence, Snapshot } from "./linear" +import { PriorityQueue } from "@/helpers" + +export interface SerializedConnection { + moves: string + target: number +} + +export interface SerializedNode { + connections: Record +} + +export interface SerializedConstrutionPath { + from: number + conn: number +} + +export interface SerializedGraph { + rootNode: number + hashSettings: HashSettings + construction: SerializedConstrutionPath[] + nodes: Record +} + +// Welp. ExaCC graph mode. This is gonna be confusing. +// In this mode, all routes stem from the root node, with nodes being specific level states, and edges (referred here as connections) being sequences of moves connecting them. +// The model tries to minimize the number of nodes for readability and performance reasons, so not all level states are automatically made into nodes. More details in the actual model class + +// Same as `MoveSequence`, but additionally tracks hashes for each player move +export class GraphMoveSequence extends MoveSequence { + hashes: (number | null)[] = [] + snapshotOffset = 1 + constructor(public hashSettings: HashSettings) { + super() + } + add(input: KeyInputs, level: Level, seat: PlayerSeat): number { + const lastTickLen = this.tickLen + const moveLength = super.add(input, level, seat) + const nullsN = this.tickLen - lastTickLen - 1 + for (let i = 0; i < nullsN; i += 1) { + this.hashes.push(null) + } + this.hashes.push(level.hash(this.hashSettings)) + return moveLength + } + get lastHash() { + return this.hashes[this.tickLen - 1]! + } + trim(interval: MoveSeqenceInterval): void { + super.trim(interval) + this.hashes.splice(...interval) + } + merge(other: this): void { + super.merge(other) + this.hashes.push(...other.hashes) + } +} + +// The Node class represents a single level state achievable from the root node by following a sequence of moves. It tracks its inputs, outputs, and distance to the closest win and root nodes +export class Node { + level: Level + // Ehh who cares about multiseat + get playerSeat() { + return this.level.playerSeats[0] + } + hash: number + // XXX: Do we need to have this on every node? + hashSettings: HashSettings + // Distance to closest win node. Tracked by using incremental Dijkstra's + winDistance?: number + // XXX: Replace with `ConnPtr`? + winTargetNode?: Node + winTargetSeq?: GraphMoveSequence + // Same as above, but for the closest root node + rootDistance: number = 0 + rootTargetNode?: Node + rootTargetSeq?: GraphMoveSequence + constructor(node: Node) + constructor(level: Level, hashSettings: HashSettings) + constructor(level: Level | Node, hashSettings?: HashSettings) { + if (level instanceof Node) { + this.hash = level.hash + this.level = level.level + this.hashSettings = level.hashSettings + } else { + this.hashSettings = hashSettings! + this.hash = level.hash(hashSettings!) + this.level = level + } + } + // If there are multiple moveSeqs from a single node to this one, that node appears here multiple times + inNodes: Node[] = [] + // A node may be connected to another node with multiple move sequences at once, the shortes moveSeq is typically considered when checkign win/root dists and the like. + outConns: Map = new Map() + // Like with `inNodes`, if there are multiple sequences connecting two nodes, the connected child node appears multiple times here + get outNodes(): Node[] { + const nodes: Node[] = [] + for (const [node, seqs] of Array.from(this.outConns.entries())) { + for (const _ of seqs) { + nodes.push(node) + } + } + return nodes + } + // A node is "loose" if it only has one parent and no children, meaning it was made by adding a new input onto a node. + // This is important when adding new inputs onto this note, since then we can just append the new input onto the + // sequence connecting this node and its parent, instead of making a new child node from this node and then dissolving this node. + get loose(): boolean { + return ( + this.inNodes.length === 1 && + this.outConns.size === 0 && + this.level.gameState !== GameState.WON + ) + } + // A node is dissolvable if it only has one in and one out node, in which case this node is redundant and should probably + // be "dissolved" into the two sequences its in between, making one larger sequence instead with connects this node's parent directly to its grandchild + get dissolvable(): boolean { + return this.inNodes.length === 1 && this.outNodes.length === 1 + } + + // Uugh. Most of graph mode assumes we're always aligned to x:1 subticks on nodes and snapshots, but it's possible to win on any subtick, + // so if we win on a non-:1 subtick, we must apply this offset to all distances from/to this node. + getWinSubtickOffset() { + if (this.level.gameState !== GameState.WON) return 0 + return this.level.currentSubtick - 1 + } + newChild(inputs: GraphMoveSequence): Node { + const child = new Node(this) + child.inNodes.push(this) + this.outConns.set(child, [inputs]) + child.hash = inputs.lastHash + child.rootDistance = this.rootDistance + inputs.tickLen * 3 + child.rootTargetNode = this + child.rootTargetSeq = inputs + return child + } + findShortestParentConns(): ConnPtr[] { + return ( + Array.from(this.inNodes) + // Remove multiple copies of the parent node, which will happen if we have multiple connections + .filter((node, i, arr) => arr.indexOf(node) === i) + .map(node => { + const connArr = node.outConns.get(this)! + const shortestSeq = connArr.reduce( + (acc, val) => (val.tickLen < acc.tickLen ? val : acc), + connArr[0] + ) + return { n: node, m: shortestSeq } + }) + ) + } + findShortestChildConns(): ConnPtr[] { + return Array.from(this.outConns).map(([node, seqs]) => { + const shortestSeq = seqs.reduce( + (acc, val) => (val.tickLen < acc.tickLen ? val : acc), + seqs[0] + ) + return { n: node, m: shortestSeq } + }) + } + cascadeWinDist() { + if (this.winDistance === undefined) return + const toCascade = new PriorityQueue() + toCascade.push(this, -this.winDistance) + while (toCascade.size > 0) { + const node = toCascade.pop()! + const conns = node.findShortestParentConns() + for (const conn of conns) { + const newDist = + node.winDistance! + node.getWinSubtickOffset() + conn.m.tickLen * 3 + if (conn.n.winDistance !== undefined && newDist > conn.n.winDistance) { + continue + } + conn.n.winDistance = newDist + conn.n.winTargetNode = node + conn.n.winTargetSeq = conn.m + toCascade.push(conn.n, -newDist) + } + } + } + cascadeRootDist() { + const toCascade = new PriorityQueue() + toCascade.push(this, -this.rootDistance) + while (toCascade.size > 0) { + const node = toCascade.pop()! + const conns = node.findShortestChildConns() + for (const conn of conns) { + const newDist = + node.rootDistance + conn.m.tickLen * 3 + conn.n.getWinSubtickOffset() + if (newDist > conn.n.rootDistance) { + continue + } + conn.n.rootDistance = newDist + conn.n.rootTargetNode = node + conn.n.rootTargetSeq = conn.m + toCascade.push(conn.n, -newDist) + } + } + } + + // Moves all connections from this node to `oldNode` to `newNode`. Generally only used when + // `oldNode` and `newNode` represent the same state, and `oldNode` is a loose node and should be the one to go + moveConnections(newNode: Node, oldNode: Node) { + if (newNode === oldNode) return + let newSeqs = this.outConns.get(newNode) + if (!newSeqs) { + newSeqs = [] + this.outConns.set(newNode, newSeqs) + } + const oldSeqs = this.outConns.get(oldNode)! + newSeqs.push(...oldSeqs) + this.outConns.delete(oldNode) + for (const _ of oldSeqs) { + oldNode.inNodes.splice(oldNode.inNodes.indexOf(this), 1) + newNode.inNodes.push(this) + } + newNode.cascadeWinDist() + this.cascadeRootDist() + } + findConnectedNode(seq: GraphMoveSequence): Node | undefined { + return Array.from(this.outConns.entries()).find(([, seqs]) => + seqs.includes(seq) + )?.[0] + } + removeConnection(seq: GraphMoveSequence): void { + const [endNode, seqs] = Array.from(this.outConns.entries()).find( + ([, seqs]) => seqs.includes(seq) + )! + seqs.splice(seqs.indexOf(seq), 1) + if (seqs.length === 0) { + this.outConns.delete(endNode) + } + endNode.inNodes.splice(endNode.inNodes.indexOf(this), 1) + } + // For a `seq` that's on this node, split it into two sequences `seq1` and `seq2` at the tick offset `offset`, with a new node `node` in the middle. This operation is the opposite of dissolving a node + insertNodeOnSeq( + seq: GraphMoveSequence, + offset: number + ): { node: Node; seq1: GraphMoveSequence; seq2: GraphMoveSequence } { + const [endNode] = Array.from(this.outConns.entries()).find(([, seqs]) => + seqs.includes(seq) + )! + this.removeConnection(seq) + const seq2 = seq.clone() + // `trim` removes the moves in the interval, so `seq` will become the sequence between `this` and `midNode` + seq.trim([offset, seq.tickLen]) + seq2.trim([0, offset]) + let midNode: Node + // XXX: Is this edge case useful? + if (seq.lastHash === endNode.hash) { + midNode = endNode + const conns = this.outConns.get(endNode) ?? [] + conns.push(seq) + this.outConns.set(endNode, conns) + midNode.inNodes.push(this) + } else { + midNode = this.newChild(seq) + midNode.level = this.level.clone() + seq.applyToLevel(midNode.level, midNode.playerSeat) + } + midNode.outConns.set(endNode, [seq2]) + endNode.inNodes.push(midNode) + endNode.cascadeWinDist() + this.cascadeRootDist() + return { node: midNode, seq1: seq, seq2 } + } + getLooseMoveSeq(): GraphMoveSequence { + if (this.inNodes.length > 1) { + throw new Error("Node has multiple move sequences") + } + return this.inNodes[0].outConns.get(this)![0] + } + dissolveNode(): void { + if (!this.dissolvable) throw new Error("Can't dissolve undissolvable node") + const parent: Node = this.inNodes[0] + const child: Node | undefined = this.outNodes[0] + const seq1 = parent.outConns.get(this)![0] + const seq2 = this.outConns.get(child)?.[0] + parent.outConns.delete(this) + this.inNodes.pop() + this.outConns.clear() + child.inNodes.splice(child.inNodes.indexOf(this), 1) + if (seq2) { + const seqs = parent.outConns.get(child) ?? [] + parent.outConns.set(child, seqs) + seq1.merge(seq2) + seqs.push(seq1) + } + } + getHashName(): string { + return (this.hash >>> 0).toString(16) + } + *outConnsAsPtr(): IterableIterator { + for (const [node, conns] of this.outConns) { + for (const conn of conns) { + yield { n: node, m: conn } + } + } + } +} + +// A small thing describing a specific move sequence +export interface ConnPtr { + // the parent `n`ode + n: Node + // the `m`ove sequence + m: GraphMoveSequence +} + +// A small thing describing a specific move index on a move sequence on a node +export interface MovePtr extends ConnPtr { + // `o`ffset + o: number +} + +function uniqueNumberMapper() { + const map = new Map() + return (val: T) => { + let num = map.get(val) + if (num === undefined) { + num = map.size + map.set(val, num) + } + return num + } +} + +export class GraphModel { + initialTimeLeft: number + rootNode: Node + current: MovePtr | Node + constructedRoute: ConnPtr[] = [] + nodeHashMap: Map = new Map() + hashMap: Map = new Map() + get playerSeat() { + return this.level.playerSeats[0] + } + constructor( + public level: Level, + public hashSettings: HashSettings + ) { + this.initialTimeLeft = level.timeLeft + level.timeLeft = 0 + this.rootNode = this.current = new Node(level, hashSettings) + this.nodeHashMap.set(this.rootNode.hash, this.rootNode) + } + addInput(input: KeyInputs, forceNewNode?: boolean): number { + if (this.level.gameState !== GameState.PLAYING) return 0 + let node: Node, moveSeq: GraphMoveSequence, parent: Node, moveLength: number + if (!(this.current instanceof Node)) { + moveSeq = new GraphMoveSequence(this.hashSettings) + moveLength = moveSeq.add(input, this.level, this.playerSeat) + const curMoveSeq = this.current.m.moves.slice(this.current.o) + if (moveSeq.moves.every((move, i) => curMoveSeq[i] === move)) { + if (this.current.o + moveSeq.tickLen === this.current.m.tickLen) { + this.current = this.nodeHashMap.get(moveSeq.lastHash)! + this.level = this.current.level + } else { + this.current.o += moveSeq.tickLen + } + this.cleanConstruction() + return moveLength + } + const constrIdx = this.constructedRoute.findIndex( + conn => conn.n === (this.current as MovePtr).n + ) + const { node: parent2, seq1 } = this.insertNodeOnSeq(this.current) + this.constructedRoute.splice(constrIdx) + this.constructedRoute.push({ n: this.current.n, m: seq1 }) + this.constructedRoute.push({ n: parent2, m: moveSeq }) + parent = parent2 + node = parent.newChild(moveSeq) + node.level = this.level + this.current = node + } else if ( + !this.current.loose || + this.current === this.rootNode || + forceNewNode + ) { + moveSeq = new GraphMoveSequence(this.hashSettings) + this.level = this.level.clone() + moveLength = moveSeq.add(input, this.level, this.playerSeat) + const constrIdx = this.getConstructionIdx() + for (const [node, conns] of this.current.outConns) { + for (const conn of conns) { + if (moveSeq.moves.every((move, i) => move === conn.moves[i])) { + if (this.constructedRoute[constrIdx]?.m !== conn) { + this.constructedRoute.splice(constrIdx) + this.constructedRoute.push({ n: this.current, m: conn }) + } + if (conn.tickLen === moveSeq.tickLen) { + this.current = node + this.level = node.level + this.cleanConstruction() + } else { + this.current = { n: this.current, m: conn, o: moveSeq.tickLen } + this.constructionAutoComplete(node) + } + return moveLength + } + } + } + this.constructedRoute.splice(constrIdx) + parent = this.current + node = parent.newChild(moveSeq) + node.level = this.level + this.current = node + this.constructedRoute.push({ n: parent, m: moveSeq }) + } else { + node = this.current + parent = node.inNodes[0] + moveSeq = node.getLooseMoveSeq() + this.nodeHashMap.delete(node.hash) + this.hashMap.set(node.hash, { + n: parent, + m: moveSeq, + o: moveSeq.tickLen, + }) + moveLength = moveSeq.add(input, this.level, this.playerSeat) + node.hash = moveSeq.lastHash + node.rootDistance = parent.rootDistance + moveSeq.tickLen * 3 + } + const newHash = moveSeq.lastHash + const nodeMergee = this.nodeHashMap.get(newHash) + const moveMergee = this.hashMap.get(newHash) + if (nodeMergee) { + parent.moveConnections(nodeMergee, node) + this.current = nodeMergee + } else if (moveMergee) { + const { node: midNode } = this.insertNodeOnSeq(moveMergee) + parent.moveConnections(midNode, node) + this.current = midNode + } else { + this.nodeHashMap.set(node.hash, node) + if (node.level.gameState === GameState.WON) { + node.winDistance = 0 + node.rootDistance += node.getWinSubtickOffset() + node.cascadeWinDist() + } + } + this.cleanConstruction() + return moveLength + } + // Like the `Node` method, but corrects `nodeHashMap`/`hashMap`/sequence state + insertNodeOnSeq(pos: MovePtr) { + const res = pos.n.insertNodeOnSeq(pos.m, pos.o) + const { node: midNode, seq1, seq2 } = res + this.nodeHashMap.set(midNode.hash, midNode) + this.hashMap.delete(midNode.hash) + + for (const hash of seq2.hashes) { + if (hash === null) continue + const ent = this.hashMap.get(hash) + if (!ent) continue + ent.n = midNode + ent.m = seq2 + ent.o -= seq1.tickLen + } + return res + } + cleanConstruction() { + const lastNode = this.constructionLastNode() + const redundantNodeIdx = this.constructedRoute.findIndex( + ptr => ptr.n === lastNode + ) + if (redundantNodeIdx !== -1) { + this.constructedRoute.splice(redundantNodeIdx) + } + if (this.current === lastNode) { + this.constructionAutoComplete(lastNode) + } + } + constructionLastNode() { + if (this.constructedRoute.length === 0) { + return this.current as Node + } else { + const lastPtr = this.constructedRoute[this.constructedRoute.length - 1] + return lastPtr.n.findConnectedNode(lastPtr.m)! + } + } + getConstructionIdx() { + const node = this.current instanceof Node ? this.current : this.current.n + let constrIdx = this.constructedRoute.findIndex(conn => conn.n === node) + if (constrIdx === -1) { + constrIdx = this.constructedRoute.length + } + return constrIdx + } + constructionAutoComplete(node: Node): void { + if (node.winDistance !== undefined) { + while (node.winTargetSeq) { + this.constructedRoute.push({ n: node, m: node.winTargetSeq! }) + if (!node.winTargetNode) break + node = node.winTargetNode + } + } else { + // I dunno, pick a random one? + while (node.outConns.size > 0) { + const lastNode = this.constructionLastNode() + const conns = Array.from(node.outConns.entries()) + .flatMap(v => v[1].map<[Node, GraphMoveSequence]>(seq => [v[0], seq])) + .filter( + v => + !this.constructedRoute.some(conn => conn.n === v[0]) && + v[0] !== lastNode + ) + if (conns.length === 0) break + const conn = conns[0] + this.constructedRoute.push({ n: node, m: conn[1] }) + node = conn[0] + } + } + } + undo(into?: GraphMoveSequence) { + let toGoTo: Node | MovePtr + if (!(this.current instanceof Node)) { + toGoTo = { ...this.current } + toGoTo.o = this.current.m.userMoves + .slice(0, this.current.o) + .lastIndexOf(true) + } else { + let srcNode: Node + if (!into) { + const constrIdx = this.getConstructionIdx() + if (this.constructedRoute.length === 0 || constrIdx === 0) return + const lastConn = this.constructedRoute[constrIdx - 1] + srcNode = lastConn.n + into = lastConn.m + } else { + srcNode = + this.current.inNodes.length === 1 + ? this.current.inNodes[0] + : this.current.inNodes + .map(val => Array.from(val.outConns.entries())) + .flat(1) + .find(([, conns]) => conns.includes(into!))![0] + } + toGoTo = { + n: srcNode, + m: into, + o: into.userMoves.lastIndexOf(true), + } + } + if (toGoTo.o === 0) { + toGoTo = toGoTo.n + } + this.goTo(toGoTo) + } + redo(into?: GraphMoveSequence) { + let lastO: number + if (!(this.current instanceof Node)) { + lastO = this.current.o + this.current.o = this.current.m.userMoves.indexOf( + true, + this.current.o + 1 + ) + } else { + lastO = 0 + const constrIdx = this.getConstructionIdx() + if (!into) { + if (this.constructedRoute.length === 0) return + if (constrIdx === this.constructedRoute.length) return + into = this.constructedRoute[constrIdx].m + } + if (!into) throw new Error(`into is required for multi-out nodes!`) + if (constrIdx === this.constructedRoute.length) { + this.constructedRoute.push({ n: this.current, m: into }) + } + this.current = { + n: this.current, + m: into, + o: into.userMoves.indexOf(true, 1), + } + if (this.current.o !== -1) { + this.level = this.level.clone() + } + } + if (this.current.o === -1) { + this.current = this.current.n.findConnectedNode(this.current.m)! + this.level = this.current.level + } else { + this.current.m.applyToLevel(this.level, this.playerSeat, [ + lastO, + this.current.o, + ]) + } + this.cleanConstruction() + } + + goTo(pos: MovePtr | Node): void { + let node = pos instanceof Node ? pos : pos.n + const lastNode = this.constructionLastNode() + const toAppend: ConnPtr[] = [] + let pathFound = false + while (true) { + if (node === lastNode) { + pathFound = true + break + } + for (let idx = this.constructedRoute.length - 1; idx >= 0; idx -= 1) { + const ptr = this.constructedRoute[idx] + if (ptr.n === node) { + if (toAppend.length !== 0) { + this.constructedRoute.splice(idx) + } + pathFound = true + break + } + } + if (pathFound) { + break + } + if (node.rootTargetNode === undefined) break + toAppend.push({ n: node.rootTargetNode!, m: node.rootTargetSeq! }) + node = node.rootTargetNode! + } + toAppend.reverse() + if (!pathFound) { + this.constructedRoute = toAppend + } else { + this.constructedRoute.push(...toAppend) + } + + this.current = pos + this.cleanConstruction() + if (pos instanceof Node) { + this.level = pos.level + return + } + const closestSnapshot: Snapshot = pos.m.findSnapshot(pos.o) ?? { + level: pos.n.level, + tick: 0, + } + this.level = closestSnapshot.level.clone() + pos.m.applyToLevel(this.level, this.playerSeat, [ + closestSnapshot.tick, + pos.o, + ]) + } + resetLevel() { + this.goTo(this.rootNode) + } + buildReferences() { + this.nodeHashMap.clear() + this.hashMap.clear() + this.rootNode.inNodes = [] + const nodesToVisit: Node[] = [this.rootNode] + const visitedNodes = new WeakSet() + while (nodesToVisit.length > 0) { + const node = nodesToVisit.shift()! + visitedNodes.add(node) + this.nodeHashMap.set(node.hash, node) + for (const [tNode, conns] of node.outConns.entries()) { + if (!visitedNodes.has(tNode)) { + nodesToVisit.push(tNode) + tNode.inNodes = [] + } + for (const conn of conns) { + tNode.inNodes.push(node) + let moveOffset = conn.userMoves.indexOf(true, 1) + while (moveOffset !== -1) { + this.hashMap.set(conn.hashes[moveOffset - 1]!, { + n: node, + m: conn, + o: moveOffset, + }) + moveOffset = conn.userMoves.indexOf(true, moveOffset + 1) + } + } + } + } + } + findBackfeedConns(): ConnPtr[] { + const nodesToVisit: [Node, Node[]][] = [[this.rootNode, []]] + const visited: WeakSet = new WeakSet() + visited.add(this.rootNode) + const backConns: ConnPtr[] = [] + while (nodesToVisit.length > 0) { + const [node, parents] = nodesToVisit.shift()! + for (const [tNode, conns] of node.outConns.entries()) { + if (parents.includes(tNode) || node === tNode) { + for (const conn of conns) { + backConns.push({ n: node, m: conn }) + } + } else { + if (!visited.has(tNode)) { + nodesToVisit.push([tNode, parents.concat(node)]) + visited.add(tNode) + } + } + } + } + return backConns + } + isAlignedToMove(pos: MovePtr | Node): boolean { + if (pos instanceof Node) return true + return pos.m.userMoves[pos.o] + } + isCurrentlyAlignedToMove(): boolean { + return this.isAlignedToMove(this.current) + } + isAtEnd() { + return this.constructionLastNode() === this.current + } + step() { + if (this.level.currentSubtick !== 1) { + this.level.tick() + return + } + if (this.current instanceof Node) { + const ptr = this.constructedRoute.find(v => v.n === this.current) + if (!ptr) { + // We're at construction's end + if (this.current !== this.constructionLastNode()) + throw new Error("Expected to be at construction's end") + return + } + this.current = { n: ptr.n, m: ptr.m, o: 0 } + this.level = this.level.clone() + } + this.playerSeat.inputs = charToKeyInput( + this.current.m.moves[this.current.o] + ) + this.level.tick() + this.current.o += 1 + if (this.current.o === this.current.m.tickLen) { + const constrIdx = this.getConstructionIdx() + this.current = + this.constructedRoute[constrIdx + 1]?.n ?? this.constructionLastNode() + } + } + serialize(): SerializedGraph { + const nodeNum = uniqueNumberMapper() + const connNum = uniqueNumberMapper() + return { + rootNode: nodeNum(this.rootNode), + hashSettings: this.hashSettings, + nodes: Object.fromEntries( + [...this.nodeHashMap.entries()].map<[number, SerializedNode]>( + ([, node]) => [ + nodeNum(node), + { + connections: Object.fromEntries( + [...node.outConnsAsPtr()].map<[number, SerializedConnection]>( + val => [ + connNum(val.m), + { moves: val.m.moves.join(""), target: nodeNum(val.n) }, + ] + ) + ), + }, + ] + ) + ), + construction: this.constructedRoute.map(val => ({ + from: nodeNum(val.n), + conn: connNum(val.m), + })), + } + } + loadSerialized( + graph: SerializedGraph, + reportProgress?: (progress: number) => void + ) { + const totalTicks = Object.values(graph.nodes).reduce( + (acc, node) => + acc + + Object.values(node.connections).reduce( + // Technically inaccurate because of p/c/s, but good enough + (acc, conn) => acc + conn.moves.length, + 0 + ), + 0 + ) + let processedTicks = 0 + + const nodesToLoad = [graph.rootNode] + const nodeNumMap: Record = { + [graph.rootNode]: this.rootNode, + } + const loadedNodes = new Set() + // Try to map each serialized connection into a moveseq (for construction), + // but we can null it out if we fail + let connNumMap: Record | null = {} + function findConnectingSequence( + srcPos: Node | MovePtr, + destPos: Node | MovePtr, + ip: RouteFileInputProvider + ): ConnPtr | null { + // Quite conservative here, technically we could map *any* state of `destPos` + // (even when it results in partial or multiple seqs, or a loop) but who cares, + // most loads will be from a blank graph where it'll always perfectly match + if (!(destPos instanceof Node)) return null + if (!(srcPos instanceof Node)) return null + if (!destPos.inNodes.includes(srcPos)) return null + const seq = srcPos.outConns + .get(destPos)! + .find(seq => seq.moves[0] === ip.moves[0])! + return { n: srcPos, m: seq } + } + + while (nodesToLoad.length > 0) { + const nodeNum = nodesToLoad.pop()! + loadedNodes.add(nodeNum) + + const serializedNode = graph.nodes[nodeNum] + const pos = nodeNumMap[nodeNum] + for (const [connNum, conn] of Object.entries( + serializedNode.connections + )) { + this.goTo(pos) + const ip = new RouteFileInputProvider(splitRouteCharString(conn.moves)) + let tick = 0 + while (!ip.outOfInput(tick * 3)) { + const ticksAfterThisInput = + this.addInput(ip.getInput(tick * 3, 0), tick === 0) / 3 + // This can only happen then node is prematurely won or lost, either way, no reason to add + // more inputs + if (ticksAfterThisInput === 0) break + tick += ticksAfterThisInput + processedTicks += ticksAfterThisInput + if (processedTicks % 100 === 0) { + reportProgress?.(processedTicks / totalTicks) + } + } + // Adjust to account for p/c/s being counted as separate ticks in `totalTicks` + processedTicks += conn.moves.length - tick + + if (connNumMap !== null) { + const conn = findConnectingSequence(pos, this.current, ip) + if (!conn) { + connNumMap = null + } else { + connNumMap[connNum as any as number] = conn + } + } + + if (!(this.current instanceof Node)) { + this.current = this.insertNodeOnSeq(this.current).node + } + nodeNumMap[conn.target] = this.current + if (!loadedNodes.has(conn.target)) { + nodesToLoad.push(conn.target) + } + } + } + if (connNumMap) { + this.constructedRoute = graph.construction.map( + conn => connNumMap![conn.conn] + ) + this.cleanConstruction() + } + } + getSelectedMoveSequence(): string[] { + return this.constructedRoute.reduce( + (acc, val) => acc.concat(val.m.moves), + [] + ) + } + timeLeft(): number { + let distFromRoot: number + if (this.current instanceof Node) { + distFromRoot = this.current.rootDistance + if (this.current.level.gameState === GameState.WON) { + // This doesn't correctly emulate cases where a playable dies on a winning node, + // since in those cases you actually *don't* lose a subtick, but it doesn't matter too much + distFromRoot += 1 + } + } else { + distFromRoot = this.current.n.rootDistance + this.current.o * 3 + } + return Math.max(0, this.initialTimeLeft - distFromRoot) + } + transcribeFromOther(old: this, reportProgress?: (progress: number) => void) { + this.loadSerialized(old.serialize(), reportProgress) + } + isBlank(): boolean { + return this.nodeHashMap.size === 1 + } +} diff --git a/gamePlayer/src/pages/ExaPlayerPage/models/linear.ts b/gamePlayer/src/pages/ExaPlayerPage/models/linear.ts new file mode 100644 index 00000000..778cd669 --- /dev/null +++ b/gamePlayer/src/pages/ExaPlayerPage/models/linear.ts @@ -0,0 +1,235 @@ +import { + GameState, + KeyInputs, + Level, + PlayerSeat, + charToKeyInput, + keyInputToChar, +} from "@notcc/logic" + +export function tickLevel(level: Level) { + level.tick() + if (level.gameState === GameState.WON) return + level.tick() + // @ts-ignore Typescript bug: level.tick actually mutates level.gameState lol + if (level.gameState === GameState.WON) return + level.tick() +} + +export const SNAPSHOT_PERIOD = 50 +export interface Snapshot { + level: Level + tick: number +} + +export type MoveSeqenceInterval = [startIn: number, endEx: number] + +export class MoveSequence { + moves: string[] = [] + displayMoves: string[] = [] + userMoves: boolean[] = [] + snapshots: Snapshot[] = [] + snapshotOffset: number = 0 + + get tickLen(): number { + return this.moves.length + } + applyToLevel( + level: Level, + seat: PlayerSeat, + interval: MoveSeqenceInterval = [0, this.moves.length] + ) { + for (const move of this.moves.slice(interval[0], interval[1])) { + seat.inputs = charToKeyInput(move) + tickLevel(level) + if (level.gameState !== GameState.PLAYING) return + } + } + _add_tickLevel(input: KeyInputs, level: Level, seat: PlayerSeat) { + if ((this.tickLen + this.snapshotOffset) % SNAPSHOT_PERIOD === 0) { + this.snapshots.push({ tick: this.tickLen, level: level.clone() }) + } + seat.inputs = input + tickLevel(level) + } + /** + * @returns The amount of subticks passed between this input and the next time the player has agency + * */ + add(input: KeyInputs, level: Level, seat: PlayerSeat): number { + const ogInput = input + const inputsToPush: string[] = [] + let char = keyInputToChar(input, false) + let firstTick = true + do { + this._add_tickLevel(input, level, seat) + inputsToPush.push(char) + this.moves.push(char) + this.userMoves.push(firstTick) + input = 0 + char = "-" + firstTick = false + } while ( + level.gameState === GameState.PLAYING && + seat.actor && + seat.actor.moveProgress != 0 + ) + if (inputsToPush.length === 4 && !inputsToPush[0].endsWith("-")) { + this.displayMoves.push(keyInputToChar(ogInput, true), "", "", "") + } else { + this.displayMoves.push(...inputsToPush) + } + return inputsToPush.length * 3 + } + trim(interval: MoveSeqenceInterval) { + this.moves.splice(...interval) + this.displayMoves.splice(...interval) + this.userMoves.splice(...interval) + this.snapshots = this.snapshots + .filter(snap => !(snap.tick >= interval[0] && snap.tick < interval[1])) + .map(snap => ({ + ...snap, + tick: + snap.tick >= interval[1] + ? snap.tick - (interval[1] - interval[0]) + : snap.tick, + })) + } + clone(): this { + const thisSnapshots = this.snapshots + //@ts-ignore We'll reattach it shortly + delete this.snapshots + const cloned = structuredClone(this) + // Reattach prototype + Object.setPrototypeOf(cloned, Object.getPrototypeOf(this)) + this.snapshots = thisSnapshots + cloned.snapshots = thisSnapshots.map(snap => ({ + ...snap, + level: snap.level.clone(), + })) + return cloned + } + merge(other: this) { + const otherOffset = this.tickLen + this.moves.push(...other.moves) + this.displayMoves.push(...other.displayMoves) + this.userMoves.push(...other.userMoves) + for (const oSnapshot of other.snapshots) { + this.snapshots.push({ ...oSnapshot, tick: oSnapshot.tick + otherOffset }) + } + } + findSnapshot(tickFromStartOfSeq: number): Snapshot | undefined { + return this.snapshots.findLast( + snapshot => snapshot.tick <= tickFromStartOfSeq + ) + } +} + +export class LinearModel { + moveSeq = new MoveSequence() + offset = 0 + get playerSeat() { + return this.level.playerSeats[0] + } + constructor(public level: Level) {} + addInput(inputs: KeyInputs): number { + if (this.level.gameState !== GameState.PLAYING) return 0 + let moveLength: number + if (this.offset !== this.moveSeq.tickLen) { + const newSeq = new MoveSequence() + newSeq.snapshotOffset = this.offset + moveLength = newSeq.add(inputs, this.level, this.playerSeat) + if ( + newSeq.moves.every( + (m, idx) => m === this.moveSeq.moves[idx + this.offset] + ) + ) { + this.offset += newSeq.tickLen + } else { + this.moveSeq.trim([this.offset, Infinity]) + this.moveSeq.merge(newSeq) + this.offset = this.moveSeq.tickLen + } + } else { + moveLength = this.moveSeq.add(inputs, this.level, this.playerSeat) + this.offset = this.moveSeq.tickLen + } + return moveLength + } + loadMoves(moves: string[], reportProgress?: (progress: number) => void) { + this.resetLevel() + let lastReport = 0 + while ( + this.offset < moves.length && + this.level.gameState === GameState.PLAYING + ) { + this.addInput(charToKeyInput(moves[this.offset])) + if (this.offset - lastReport > 100) { + reportProgress?.(this.offset / moves.length) + lastReport = this.offset + } + } + } + undo() { + if (this.offset === 0) return + this.offset = this.moveSeq.userMoves.slice(0, this.offset).lastIndexOf(true) + this.goTo(this.offset) + } + redo() { + const lastOffset = this.offset + const newOffset = this.moveSeq.userMoves + .slice(this.offset + 1) + .indexOf(true) + if (newOffset === -1) { + this.offset = this.moveSeq.tickLen + } else { + this.offset += newOffset + 1 + } + this.moveSeq.applyToLevel(this.level, this.playerSeat, [ + lastOffset, + this.offset, + ]) + } + goTo(pos: number): void { + if (this.moveSeq.tickLen === 0) return + this.offset = pos + // since `snapshotOffset` here will be 0, there'll always be a matching snapshot for any requested tick + const snapshot = this.moveSeq.findSnapshot(pos)! + + this.level = snapshot.level.clone() + this.moveSeq.applyToLevel(this.level, this.playerSeat, [snapshot.tick, pos]) + } + resetLevel() { + this.goTo(0) + } + isAlignedToMove(pos: number): boolean { + return this.moveSeq.userMoves[pos] || this.offset === this.moveSeq.tickLen + } + isCurrentlyAlignedToMove(): boolean { + return this.isAlignedToMove(this.offset) + } + isAtEnd() { + return this.offset === this.moveSeq.tickLen + } + step() { + if (this.level.currentSubtick !== 1) { + this.level.tick() + return + } + if (this.offset === this.moveSeq.tickLen) return + this.playerSeat.inputs = charToKeyInput(this.moveSeq.moves[this.offset]) + this.offset += 1 + this.level.tick() + } + getSelectedMoveSequence(): string[] { + return this.moveSeq.moves + } + timeLeft() { + return this.level.timeLeft + } + transcribeFromOther(old: this, reportProgress?: (progress: number) => void) { + this.loadMoves(old.moveSeq.moves, reportProgress) + } + isBlank() { + return this.moveSeq.tickLen === 0 + } +} diff --git a/gamePlayer/src/pages/LevelPlayerPage.tsx b/gamePlayer/src/pages/LevelPlayerPage.tsx new file mode 100644 index 00000000..63449095 --- /dev/null +++ b/gamePlayer/src/pages/LevelPlayerPage.tsx @@ -0,0 +1,152 @@ +import { + AutoScaleConfig, + DumbLevelPlayer, + useAutoScale, +} from "@/components/DumbLevelPlayer" +import { + SetIntermission, + globalC2GGameModifiersAtom, + goToNextLevelGs, + levelSetAtom, + setIntermissionAtom, + useSwrLevel, +} from "../levelData" +import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { LevelControls, levelControlsAtom } from "@/components/Sidebar" +import { preferenceAtom } from "@/preferences" +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks" +import { ReplayInputProvider } from "@notcc/logic" +import { useJotaiFn } from "@/helpers" +import { tilesetAtom } from "@/components/PreferencesPrompt/TilesetsPrompt" + +export const endOnNonlegalGlitchAtom = preferenceAtom( + "endOnNonlegalGlitch", + true +) + +export const filterSimulCharPlayAtom = preferenceAtom( + "filterSimulCharPlay", + true +) + +function SetIntermissionComponent({ + intermission, +}: { + intermission: SetIntermission +}) { + // Use the level player's scaling algorithm to roughly match the size of the intermission box + // with the basic level player + const tileset = useAtomValue(tilesetAtom) + const tileSize = tileset?.tileSize ?? 32 + const scaleArgs = useMemo( + () => ({ + cameraType: { width: 10, height: 10 }, + tileSize: tileSize, + twPadding: [2, 10], + }), + [tileset, tileSize] + ) + const scale = useAutoScale(scaleArgs) + const textboxSize = tileSize * scale * 10 + const [interruptPage, setInterruptPage] = useState(0) + const setSetIntermission = useSetAtom(setIntermissionAtom) + return ( +
    +
    +

    Set intermission

    +
    + {intermission.text[interruptPage].trim()} +
    +
    + + + +
    +
    +
    + ) +} + +export function LevelPlayerPage() { + const level = useSwrLevel() + const set = useAtomValue(levelSetAtom) + const gameModifiers = useAtomValue(globalC2GGameModifiersAtom) + const endOnNonlegalGlitch = useAtomValue(endOnNonlegalGlitchAtom) + const filterSimulChar = useAtomValue(filterSimulCharPlayAtom) + const [controls, setControls] = useAtom(levelControlsAtom) + + const lastLevelRef = useRef(level) + const tryApplyC2GReplay = useCallback( + (controls: LevelControls) => { + if (lastLevelRef.current === level) return + lastLevelRef.current = level + if (!gameModifiers.autoPlayReplay || !level?.replay) return + + controls.playInputs?.(new ReplayInputProvider(level.replay)) + }, + [gameModifiers, level] + ) + useEffect(() => { + if (controls) { + tryApplyC2GReplay(controls) + } + }, [controls, tryApplyC2GReplay]) + + const goToNextLevel = useJotaiFn(goToNextLevelGs) + const tryAdvanceC2GAutoAdvance = useCallback(() => { + if (gameModifiers.autoNext) { + goToNextLevel() + } + }, [gameModifiers]) + const controlsRefCallback = useCallback( + (controls: LevelControls | null) => { + setControls(controls ?? {}) + if (controls) { + tryApplyC2GReplay(controls) + } + }, + [tryApplyC2GReplay, level] + ) + + const setIntermission = useAtomValue(setIntermissionAtom) + + if (setIntermission) { + return + } + + if (level === null) { + return
    Fetching level data...
    + } + return ( +
    + +
    + ) +} diff --git a/gamePlayer/src/pages/SetSelectorPage.tsx b/gamePlayer/src/pages/SetSelectorPage.tsx new file mode 100644 index 00000000..9d941a56 --- /dev/null +++ b/gamePlayer/src/pages/SetSelectorPage.tsx @@ -0,0 +1,230 @@ +import { useAtom, useAtomValue, useSetAtom } from "jotai" +import { searchParamsAtom } from "@/routing" +import { encodeBase64, isDesktop, useJotaiFn, zlibAsync } from "@/helpers" +import prewriteIcon from "../prewrite.png" +import { + dismissablePreferenceAtom, + playEnabledAtom, + preferenceAtom, +} from "@/preferences" +import { SetsGrid } from "@/components/SetsGrid" +import { findScriptName } from "@notcc/logic" +import { + promptFile, + promptDir, + setLevelSetGs, + setIndividualLevelGs, + preloadFilesFromDirectoryPromptAtom, +} from "@/levelData" +import { saveFilesLocallyGs } from "@/setManagement" +import { Ht } from "@/components/Ht" +import { DesktopUpdater } from "@/components/DesktopUpdater" + +function BackToMainPage() { + const setPlayEnabled = useSetAtom(playEnabledAtom) + return ( +
    + +
    + ) +} + +export function Header() { + return ( +
    + +
    +

    + NotCC +

    +

    + + A scoreboard-legal Chip's Challenge 2® emulator. + +

    +
    +
    + ) +} + +export const embedLevelInfoAtom = preferenceAtom("embedLevelInfoInUrl", false) +export const storeSetsLocallyAtom = preferenceAtom("storeSetsLocally", true) + +function UploadBox() { + const [embedLevelInfo, setEmbedLevelInfo] = useAtom(embedLevelInfoAtom) + const [storeSetsLocally, setStoreSetsLocally] = useAtom(storeSetsLocallyAtom) + const saveFilesLocally = useJotaiFn(saveFilesLocallyGs) + const setLevelSet = useJotaiFn(setLevelSetGs) + const setIndividualLevel = useJotaiFn(setIndividualLevelGs) + const preloadFilesFromDirectoryPrompt = useAtomValue( + preloadFilesFromDirectoryPromptAtom + ) + + const setSearchParams = useSetAtom(searchParamsAtom) + return ( +
    +

    + Load external files: +

    +
    + + +
    +
    + {!isDesktop() && ( + + )} + +
    +
    + ) +} + +const alphaHeaderClosedAtom = dismissablePreferenceAtom("alphaHeaderClosed") + +function AlphaHeader() { + const setAlphaHeaderClosed = useSetAtom(alphaHeaderClosedAtom) + return ( +
    +

    + + + NotCC alpha! + + +

    + +

    + Welcome to the Prewrite Alpha! This is a currently in-development + version of NotCC, being rewritten from scratch. Here are some cool + things I intend to add with the (p)rewrite: +

    +
      +
    • Mobile support
    • +
    • Less janky UI, including loading indicators
    • +
    • NCCS and settings import and export
    • +
    • + A completely new ExaCC experience: graph and tree (trie?) modes, + route timeline, camera and RNG controls, etc +
    • +
    • + Ability to locally save levelsets (including CC1 Steam and CC2, say + goodbye to the annoying non-free dialog!!) +
    • +
    • External SFX, and maybe an update to the existing set
    • +
    • + (External) Music! While NotCC doesn't have its own soundtrack to + use, I intend to add support for user-provided music anyway +
    • +
    • Embed support for the bb.club wiki
    • +
    +
    + +
    + ) +} + +function DesktopWipHeader() { + return ( +
    +

    NotCC Desktop!

    +

    + Hello! This is the desktop version of NotCC, and is still very much a + work in progress! Please report any bugs or annoyances you come across! +

    +
    + ) +} + +export function SetSelectorPage() { + const alphaHeaderClosed = useAtomValue(alphaHeaderClosedAtom) + return ( +
    +
    + {!isDesktop() && } + {isDesktop() && } + {isDesktop() && } + {!alphaHeaderClosed && } + + +
    + ) +} diff --git a/gamePlayer/src/pages/basePlayer.ts b/gamePlayer/src/pages/basePlayer.ts deleted file mode 100644 index f0daf785..00000000 --- a/gamePlayer/src/pages/basePlayer.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { - actorDB, - createLevelFromData, - GameState, - Item, - KeyInputs, - Layer, - LevelState, -} from "@notcc/logic" -import { Pager } from "../pager" -import { Renderer } from "../renderer" -import { protobuf } from "@notcc/logic" - -// TODO Smart TV inputs -// TODO Customizable inputs in general -export const keyToInputMap: Record = { - ArrowUp: "up", - ArrowRight: "right", - ArrowDown: "down", - ArrowLeft: "left", - KeyZ: "drop", - KeyX: "rotateInv", - KeyC: "switchPlayable", -} - -export function isValidKey(code: string): boolean { - return code in keyToInputMap -} - -export function isValidStartKey(code: string): boolean { - return isValidKey(code) || code === "Space" -} - -export interface TextOutputs { - chips: HTMLElement - time: HTMLElement - bonusPoints: HTMLElement -} - -const KnownGlitches = protobuf.GlitchInfo.KnownGlitches -export const glitchNames: Record = { - [KnownGlitches.INVALID]: "???", - [KnownGlitches.DESPAWN]: "Despawn", - [KnownGlitches.SIMULTANEOUS_CHARACTER_MOVEMENT]: - "Simultaneous character movement", - [KnownGlitches.DYNAMITE_EXPLOSION_SNEAKING]: "Dynamite explosion sneaking", -} - -export const nonLegalGlitches: protobuf.GlitchInfo.KnownGlitches[] = [ - KnownGlitches.SIMULTANEOUS_CHARACTER_MOVEMENT, - KnownGlitches.DYNAMITE_EXPLOSION_SNEAKING, -] - -export const playerPageBase = { - requiresLoaded: "level" as const, - basePage: null as HTMLElement | null, - textOutputs: null as TextOutputs | null, - renderer: null as Renderer | null, - viewportArea: null as HTMLElement | null, - setupPage(pager: Pager, page: HTMLElement): void { - if (pager.tileset === null) throw new Error("Tileset required") - this.basePage = page - const viewportCanvas = - page.querySelector(".viewportCanvas")! - const inventoryCanvas = - page.querySelector(".inventoryCanvas")! - this.renderer = new Renderer(pager.tileset, viewportCanvas, inventoryCanvas) - this.textOutputs = { - chips: page.querySelector(".chipsText")!, - time: page.querySelector(".timeLeftText")!, - bonusPoints: page.querySelector(".bonusPointsText")!, - } - if ( - !this.textOutputs.chips || - !this.textOutputs.time || - !this.textOutputs.bonusPoints - ) - throw new Error("Could not find the text output elements.") - this.viewportArea = page.querySelector(".viewportArea") - window.addEventListener("resize", () => { - this.updateTileScale() - }) - }, - currentLevel: null as LevelState | null, - loadLevel(pager: Pager): void { - pager.updatePageUrl() - const level = pager.loadedLevel - if (!level) throw new Error("No level to load") - if (!this.renderer) throw new Error("No renderer set") - - this.currentLevel = createLevelFromData(level) - this.initScriptLevelState(pager) - this.renderer.level = this.currentLevel - this.renderer.cameraSize = this.currentLevel.cameraType - // Internal viewport size (unaffected by scale, but depends on the camera size) - this.renderer.updateTileSize() - // Tile scale, automatically make things bigger if the page size allows - this.updateTileScale() - // External viewport camera size, affected by eg. the legal player overlays - this.updateViewportCameraSize() - this.updateTextOutputs() - }, - initScriptLevelState(pager: Pager) { - const state = pager.loadedSet?.scriptRunner.getMapInitState() - const level = this.currentLevel - if (!state || !level) return - if (state.timeLeft !== undefined) { - level.timeLeft = state.timeLeft * 60 - } - let player = level.selectedPlayable - if (state.playableEnterN !== undefined) { - player = level.playables[state.playableEnterN] - for (const removedPlayer of level.playables) { - if (removedPlayer !== player) { - removedPlayer.destroy(null, null) - } - } - player.respawn() - level.gameState = GameState.PLAYING - } - function giveItem(id: string) { - if (!player) return - const tile = level!.field[0][0] - const ogItem = tile[Layer.ITEM] - ogItem?.despawn() - const item = new actorDB[id](level!, [0, 0]) as Item - item.pickup(player) - ogItem?.respawn() - } - if (!player) return - for (const [color, n] of Object.entries(state.inventoryKeys ?? {})) { - const id = `key${color[0].toUpperCase()}${color.substring(1)}` - giveItem(id) - const ent = player.inventory.keys[id] - ent.amount = n - } - for (const id of state.inventoryTools ?? []) { - giveItem(id!) - } - }, - extraTileScale: [0, 0] as [number, number], - determineTileScale(): number { - if (!this.renderer || !this.renderer.cameraSize) - throw new Error("Can't determine the tile scale without the renderer.") - - const bodySize = document.body.getBoundingClientRect() - let availableWidth = bodySize.width, - // eslint-disable-next-line prefer-const - availableHeight = bodySize.height - - const tileSize = this.renderer.tileset.tileSize - - const sidebarWidth = document - .querySelector(".sidebar")! - .getBoundingClientRect().width - - availableWidth -= sidebarWidth - - const playerTWidth = - this.renderer.cameraSize.width + this.extraTileScale[0], - playerTHeight = this.renderer.cameraSize.height + this.extraTileScale[1] - const playerBaseWidth = playerTWidth * tileSize, - playerBaseHeight = playerTHeight * tileSize - - let scale = Math.min( - availableWidth / playerBaseWidth, - availableHeight / playerBaseHeight - ) - scale *= 0.95 - if (scale < 0.25) { - // If we can't fit the camera at *quarter scale*, just do whatever fits - } else if (scale < 1) { - // Snap to nearest quarter if we can't fit the camera - scale = scale - (scale % 0.25) - } else { - scale = Math.floor(scale) - } - return scale - }, - updateTileScale(): void { - const page = this.basePage - page!.style.setProperty( - "--tile-scale", - this.determineTileScale().toString() - ) - this.isRenderDirty = true - this.updateRender() - }, - updateViewportCameraSize(): void { - if (!this.viewportArea) throw new Error("Viewport missing") - if (!this.currentLevel) throw new Error("Current level missing") - this.viewportArea.style.setProperty( - "--level-camera-width", - this.renderer!.cameraSize!.width.toString() - ) - this.viewportArea.style.setProperty( - "--level-camera-height", - this.renderer!.cameraSize!.height.toString() - ) - }, - updateTextOutputs(): void { - if (!this.textOutputs) return - this.textOutputs.chips.textContent = this.currentLevel!.chipsLeft.toString() - this.textOutputs.bonusPoints.textContent = - this.currentLevel!.bonusPoints.toString() - const currentTime = this.currentLevel!.timeLeft - this.textOutputs.time.textContent = `${ - this.currentLevel!.timeFrozen ? "❄" : "" - }${Math.ceil(currentTime / 60)}s` - }, - getInput(): KeyInputs { - throw new Error("Sorry for the antipattern, but please implement this!") - }, - updateLogic(): void { - const level = this.currentLevel - if (level === null) - throw new Error("Can't update the level without a level.") - level.gameInput = this.getInput() - level.tick() - this.updateTextOutputs() - }, - isRenderDirty: false, - updateRender(): void { - if (!this.isRenderDirty) return - this.isRenderDirty = false - this.renderer!.frame() - }, - preventNonLegalGlitches: true, - preventSimultaneousMovement: true, - updateSettings(pager: Pager): void { - if (!pager.tileset) - throw new Error("Can't update the tileset without a tileset.") - if (!this.renderer) - throw new Error("Can't update the tileset without a renderer.") - const page = this.basePage - if (!page) - throw new Error("Can't update the tileset wihout being opened first.") - - this.renderer.tileset = pager.tileset - this.renderer.updateTileSize() - page.style.setProperty( - "--base-tile-size", - `${pager.tileset.tileSize.toString()}px` - ) - this.updateTileScale() - this.preventNonLegalGlitches = pager.settings.preventNonLegalGlitches - this.preventSimultaneousMovement = - pager.settings.preventSimultaneousMovement - }, -} diff --git a/gamePlayer/src/pages/exaPlayer.ts b/gamePlayer/src/pages/exaPlayer.ts deleted file mode 100644 index 1b08dda4..00000000 --- a/gamePlayer/src/pages/exaPlayer.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { - calculateLevelPoints, - GameState, - InputProvider, - KeyInputs, - keyInputToChar, - LevelState, - makeEmptyInputs, - Route, - RouteFileInputProvider, - secondaryActions, - protobuf, - SolutionInfoInputProvider, - Direction, -} from "@notcc/logic" -import clone from "clone" -import { Pager } from "../pager" -import { showAlert } from "../simpleDialogs" -import { showLoadPrompt, showSavePrompt } from "../saveData" -import { KeyListener, sleep, TimeoutTimer } from "../utils" -import { isValidStartKey, keyToInputMap, playerPageBase } from "./basePlayer" -import { registerPage } from "../const" -import { getRRRoutes, identifyRRPack } from "../railroad" -import { ExaIntegerTimeRounding } from "../settings" - -// Wait for a tick for diagonal inputs -const AUTO_DIAGONALS_TIMEOUT = 1 / 20 - -// Make a snapshot every second -const LEVEL_SNAPSHOT_PERIOD = 60 - -interface LevelSnapshot { - level: LevelState -} - -// TODO move this to @notcc/logic maybe? -function cloneLevel(level: LevelState): LevelState { - // Don't clone the static level data - // TODO Maybe don't always have a copy of the whole level map in the level state? - // What's it doing there, anyways? - const levelData = level.levelData - delete level.levelData - const inputProvider = level.inputProvider - delete level.inputProvider - const newLevel = clone(level, true) - newLevel.levelData = levelData - newLevel.inputProvider = inputProvider - level.levelData = levelData - level.inputProvider = inputProvider - return newLevel -} - -const integerFormatters: Record< - ExaIntegerTimeRounding, - (time: number) => number -> = { - floor: time => Math.floor(time), - "floor + 1": time => Math.floor(time) + 1, - ceil: time => Math.ceil(time), -} - -const subtickStrings = ["", "⅓", "⅔"] - -export const exaPlayerPage = { - ...playerPageBase, - pagePath: "exa", - pageId: "exaPlayerPage", - recordedMovesArea: null as HTMLSpanElement | null, - composingPreviewArea: null as HTMLSpanElement | null, - levelN: -1, - setupPage(pager: Pager, page: HTMLElement): void { - playerPageBase.setupPage.call(this, pager, page) - this.recordedMovesArea = - page.querySelector(".recordedMoves") - this.composingPreviewArea = - page.querySelector(".composingPreview") - this.totalScoreText = - page.querySelector(".totalScoreText") - this.blockedMessageDiv = - page.querySelector(".blockedMessage") - }, - loadLevel(pager: Pager, initIp?: InputProvider): void { - playerPageBase.loadLevel.call(this, pager) - const level = this.currentLevel - if (level === null) - throw new Error("The player page base didn't set the level correctly") - level.forcedPerspective = true - this.renderer!.cameraSize = { - width: Math.min(level.width, 32), - height: Math.min(level.height, 32), - screens: 1, - } - this.recordedMoves = [] - this.visualMoves = [] - this.areMovesPlayerInput = [] - const localIp = new RouteFileInputProvider(this.recordedMoves) - level.inputProvider = initIp ?? localIp - while (level.subtick !== 1) { - level.tick() - } - level.inputProvider = localIp - this.updateTextOutputs() - this.snapshots = [ - { - level: cloneLevel(this.currentLevel!), - }, - ] - this.levelN = pager.loadedSet?.currentLevel ?? 0 - this.renderer!.updateTileSize() - // Tile scale, automatically make things bigger if the page size allows - this.updateTileScale() - // External viewport camera size, affected by eg. the legal player overlays - this.updateViewportCameraSize() - // Advance the game by two subtics, so that we can input immediately - this.updateRender() - this.updateRecordedMovesArea() - this.updateTextOutputs() - }, - updateRender() { - this.isRenderDirty = true - playerPageBase.updateRender.call(this) - }, - doTick(level: LevelState): void { - level.tick() - if (level.gameState === GameState.WON) return - level.tick() - // @ts-ignore Typescript bug: level.tick actually mutates level.gameState lol - if (level.gameState === GameState.WON) return - level.tick() - }, - getRouteTicks(): number { - return ( - this.currentLevel!.currentTick + - (this.currentLevel!.subtick === 2 ? 1 : 0) - ) - }, - updateRecordedMovesArea(): void { - this.recordedMovesArea!.textContent = this.visualMoves - .slice(0, this.getRouteTicks()) - .join("") - }, - totalScoreText: null as HTMLOutputElement | null, - updateTextOutputs(): void { - playerPageBase.updateTextOutputs.call(this) - const time = this.currentLevel!.timeLeft - const integerFormatter = integerFormatters[this.integerTimeRounding] - const timeFrozen = this.currentLevel!.timeFrozen ? "❄" : "" - const timeInteger = integerFormatter(time / 60) - let timeDecimal = (Math.floor((time % 60) / 3) * 5) - .toString() - .padStart(2, "0") - const timeSubtick = time % 3 - if ( - this.integerTimeRounding === "ceil" && - timeDecimal === "00" && - timeSubtick === 0 - ) { - timeDecimal = "100" - } - this.textOutputs!.time.textContent = `${timeFrozen}${timeInteger}.${timeDecimal}${subtickStrings[timeSubtick]}s` - - this.totalScoreText!.textContent = calculateLevelPoints( - this.levelN, - Math.ceil(time / 60), - this.currentLevel!.bonusPoints - ).toString() - }, - applyInput(): void { - const level = this.currentLevel! - do { - this.doTick(level) - this.autoAddSnapshot() - } while ( - level.gameState === GameState.PLAYING && - level.selectedPlayable!.cooldown > 0 - ) - }, - // An alternative version of `updateLogic` which operates on ticks instead of subticks - // We don't use the native `updateLogic`. - appendInput(input: KeyInputs): void { - const level = this.currentLevel! - const couldMoveFirstTick = level.selectedPlayable!.getCanMove() - - this.cropToMovePosition() - - const moves: string[] = [] - const addMove = (char: string) => { - this.recordedMoves.push(char) - moves.push(char) - } - - addMove( - couldMoveFirstTick - ? keyInputToChar(input, false) - : keyInputToChar(input, false, true) + "-" - ) - let ticksApplied = 0 - - do { - if (ticksApplied > 0) { - addMove("-") - } - this.doTick(level) - ticksApplied += 1 - this.autoAddSnapshot() - } while ( - level.gameState === GameState.PLAYING && - level.selectedPlayable!.cooldown > 0 - ) - if (moves.length === 4 && couldMoveFirstTick) { - this.visualMoves.push(keyInputToChar(input, true), "", "", "") - } else { - this.visualMoves.push(...moves) - } - this.areMovesPlayerInput.push( - true, - ...new Array(moves.length - 1).fill(false) - ) - this.updateRender() - }, - // Automatically skip in time until *something* can be done - autoSkip(): void { - if (this.isBlocked) return - const level = this.currentLevel! - while ( - level.gameState === GameState.PLAYING && - !level.selectedPlayable!.canDoAnything() - ) { - this.appendInput(makeEmptyInputs()) - } - this.updateRecordedMovesArea() - this.updateTextOutputs() - this.updateRender() - }, - snapshots: [] as LevelSnapshot[], - autoAddSnapshot(): void { - const level = this.currentLevel - if (level === null) throw new Error("Current level must be set") - const currentTime = level!.currentTick * 3 + level!.subtick - const lastSnapshot = this.snapshots[this.snapshots.length - 1] - const lastSnapshotTime = - lastSnapshot.level.currentTick * 3 + lastSnapshot.level.subtick - - if (currentTime - lastSnapshotTime < LEVEL_SNAPSHOT_PERIOD) return - this.snapshots.push({ - level: cloneLevel(level), - }) - }, - seekTo(newPosition: number, snapToMove = true): void { - if (this.isBlocked) return - let targetPosition: number - if (snapToMove) { - targetPosition = this.areMovesPlayerInput.lastIndexOf(true, newPosition) - } else { - targetPosition = newPosition - } - // There will always be the snapshot of the initial level, so don't worry about the non-null assertion - const closestSnapshot = [...this.snapshots] - .reverse() - .find(snap => snap.level.currentTick <= targetPosition)! - this.currentLevel = cloneLevel(closestSnapshot.level) - const level = this.currentLevel - this.renderer!.level = this.currentLevel - while (targetPosition > level.currentTick) { - this.doTick(level) - } - this.updateRecordedMovesArea() - this.updateRender() - this.updateTextOutputs() - }, - undo(): void { - if (this.isBlocked) return - const level = this.currentLevel! - if (level.currentTick <= 0) return - this.seekTo(this.getRouteTicks() - 1) - }, - redo(): void { - if (this.isBlocked) return - const level = this.currentLevel - if (level === null) throw new Error("Current level required") - if (level.currentTick >= this.recordedMoves.length) return - this.applyInput() - this.updateRecordedMovesArea() - this.updateTextOutputs() - this.updateRender() - }, - // TODO Use a single struct instead Python-esqe billion arrays? - recordedMoves: [] as string[], - visualMoves: [] as string[], - areMovesPlayerInput: [] as boolean[], - cropToMovePosition(): void { - const movePos = this.getRouteTicks() - this.recordedMoves.splice(movePos) - this.visualMoves.splice(movePos) - this.areMovesPlayerInput.splice(movePos) - this.snapshots = this.snapshots.filter( - snap => snap.level.currentTick <= movePos - ) - }, - isBlocked: false, - blockedMessageDiv: null as HTMLDivElement | null, - setIsBlocked(blocked: boolean) { - this.isBlocked = blocked - this.blockedMessageDiv?.classList.toggle("show", blocked) - }, - async transcribeInputs(ip: InputProvider) { - this.setIsBlocked(true) - try { - const level = this.currentLevel! - let moveCount = 0 - while (!ip.outOfInput(level)) { - this.appendInput(ip.getInput(level)) - if (level.gameState !== GameState.PLAYING) break - moveCount += 1 - if (moveCount % 100 === 0) { - this.updateRecordedMovesArea() - this.updateRender() - this.updateTextOutputs() - // Have a breather every 100 moves - await sleep(0) - } - } - } finally { - this.setIsBlocked(false) - } - this.updateRecordedMovesArea() - this.updateRender() - this.updateTextOutputs() - }, - async loadSolution(pager: Pager, sol: protobuf.ISolutionInfo) { - const ip = new SolutionInfoInputProvider(sol) - this.loadLevel(pager, ip) - await this.transcribeInputs(ip) - }, - async importRoute(pager: Pager): Promise { - const file = ( - await showLoadPrompt("Import route", { - filters: [{ extensions: ["json", "route"], name: "Route file" }], - }) - )[0] - const routeData = await file.text() - const route: Route = JSON.parse(routeData) - if (route.Rule === undefined) { - showAlert("This doesn't seem like a route file") - return - } - if (route.Rule === "LYNX" || route.Rule === "MS") { - await showAlert( - "Warning: Adapting a Lynx or MS route to Steam. Best effort, so don't expect it to work..." - ) - } else if (route.Rule !== "STEAM") { - showAlert("Unknown ruleset") - return - } - const ip = new RouteFileInputProvider(route) - this.loadLevel(pager, ip) - // TODO compare route.For metadata - await this.transcribeInputs(ip) - }, - async exportRoute(pager: Pager): Promise { - const level = this.snapshots[0].level - const levelN = pager.getLevelNumber() - const levelTitle = pager.loadedLevel!.name - if (levelN === "not in level") throw new Error("Can't be happening") - const route: Route = { - Rule: "STEAM", - Encode: "UTF-8", - Moves: this.recordedMoves.join(""), - ExportApp: "ExaCC", - For: - levelN === "not in set" - ? { LevelName: levelTitle } - : { - LevelName: levelTitle, - LevelNumber: levelN, - Set: pager.loadedSet!.scriptRunner.state.scriptTitle!, - }, - Blobmod: level.blobPrngValue, - // When importing and exporting, convert RFF direction to be a string enum value, - // to keep compat with SuperCC - "Initial Slide": Direction[ - this.snapshots[0].level.randomForceFloorDirection - ] as unknown as Direction, - } - const routeString = JSON.stringify(route) - const routeBin = new TextEncoder().encode(routeString) - await showSavePrompt(routeBin, "Save route", { - filters: [{ extensions: ["route"], name: "Route file" }], - defaultPath: `./${levelTitle}.route`, - }) - }, - currentInput: makeEmptyInputs(), - keyListener: null as KeyListener | null, - autoDiagonalsTimer: null as TimeoutTimer | null, - updateCompositingPreview(): void { - this.composingPreviewArea!.textContent = keyInputToChar( - this.currentInput, - false, - true - ) - }, - commitCurrentInput(): void { - if (this.isBlocked) return - this.autoDiagonalsTimer = null - this.appendInput(this.currentInput) - this.updateRecordedMovesArea() - this.updateRender() - this.updateTextOutputs() - this.currentInput = makeEmptyInputs() - this.updateCompositingPreview() - }, - setupKeyListener(): void { - this.keyListener = new KeyListener(ev => { - if (this.isBlocked) return - if (!isValidStartKey(ev.code)) return - if (this.currentLevel?.gameState !== GameState.PLAYING) return - let inputType = keyToInputMap[ev.code] - if (inputType in this.currentInput) { - inputType = inputType as keyof KeyInputs - // Holding a cardinal direction should always move in that direction, so thus we shouldn't be able - // to flip if that input is actually gonna be a part of the keyinputs. - if ( - inputType === "up" || - inputType === "right" || - inputType === "down" || - inputType === "left" - ) { - this.currentInput[inputType] = true - } else { - this.currentInput[inputType] = !this.currentInput[inputType] - } - } - if ( - !secondaryActions.includes(inputType) && - this.autoDiagonalsTimer === null - ) { - this.autoDiagonalsTimer = new TimeoutTimer( - () => this.commitCurrentInput(), - AUTO_DIAGONALS_TIMEOUT - ) - } - this.updateCompositingPreview() - }) - }, - open(pager: Pager): void { - if (!pager.loadedLevel) - throw new Error("Cannot open the level player page with a level to play.") - this.loadLevel(pager) - this.updateSettings(pager) - this.updateRender() - this.setupKeyListener() - }, - integerTimeRounding: "ceil" as ExaIntegerTimeRounding, - updateSettings(pager: Pager): void { - playerPageBase.updateSettings.call(this, pager) - this.integerTimeRounding = pager.settings.exaIntegerTimeRounding - this.updateTextOutputs() - }, - close(): void { - this.keyListener?.remove() - this.keyListener = null - this.autoDiagonalsTimer?.cancel() - this.autoDiagonalsTimer = null - }, - extraTileScale: [ - 0.5 + // Padding - // Camera - 0.5 + // Padding - 0.25 + // Gap - 16, // Stats - 0.5 + // Padding - // Camera - 0.5, // Padding - ] as [number, number], - async setNavigationInfo( - pager: Pager, - _subpage: string, - queryParams: Record - ) { - const solutionId = queryParams["load-solution"] - if (!solutionId) return - const setName = pager.loadedSet?.scriptRunner.state.scriptTitle! - const packName = setName ? identifyRRPack(setName) : null - const level = pager.loadedLevel! - - let ip: InputProvider | undefined - - if (solutionId === "builtin") { - ip = - level.associatedSolution && - new SolutionInfoInputProvider(level.associatedSolution) - } else if (packName !== null && solutionId.startsWith("railroad-")) { - const railroadId = solutionId.slice("railroad-".length) - const levels = await getRRRoutes(packName) - const rrRoute = levels - .find(lvl => lvl.title.toLowerCase() === level.name?.toLowerCase()) - ?.routes.find(route => route.id === railroadId) - if (rrRoute) { - ip = new RouteFileInputProvider(rrRoute.moves) - } - } - - if (ip) { - this.loadLevel(pager, ip) - await this.transcribeInputs(ip) - } - }, -} - -registerPage(exaPlayerPage) diff --git a/gamePlayer/src/pages/levelPlayer.ts b/gamePlayer/src/pages/levelPlayer.ts deleted file mode 100644 index 75b4a3da..00000000 --- a/gamePlayer/src/pages/levelPlayer.ts +++ /dev/null @@ -1,409 +0,0 @@ -import { - AttemptTracker, - GameState, - KeyInputs, - ScriptLegalInventoryTool, - protobuf, - SolutionInfoInputProvider, -} from "@notcc/logic" -import { Pager } from "../pager" -import { - AnimationTimer, - IntervalTimer, - KeyListener, - setAttributeExistence, - AutoRepeatKeyListener, - AutoRepeatKeyState, - CompensatingIntervalTimer, -} from "../utils" -import { setSelectorPage } from "./setSelector" -import { AudioSfxManager } from "../sfx" -import { - isValidKey, - isValidStartKey, - keyToInputMap, - playerPageBase, - glitchNames, - nonLegalGlitches, -} from "./basePlayer" -import { - makeChoiceDialog, - showAlert, - waitForDialogSubmit, -} from "../simpleDialogs" -import { registerPage } from "../const" - -interface OverlayButtons { - restart: HTMLElement - nonLegalRestart: HTMLElement - nextLevel: HTMLElement - scores: HTMLElement - explodeJupiter: HTMLElement - unpause: HTMLElement - gzLeveList: HTMLElement - gzSetSelector: HTMLElement -} - -export const levelPlayerPage = { - ...playerPageBase, - pagePath: "play", - pageId: "levelPlayerPage", - keyListener: null as AutoRepeatKeyListener | null, - // Binding HTML stuff - overlayButtons: null as OverlayButtons | null, - gameOverlay: null as HTMLElement | null, - overlayLevelName: null as HTMLElement | null, - viewportArea: null as HTMLElement | null, - hintBox: null as HTMLElement | null, - nonLegalGlitchName: null as HTMLElement | null, - setupPage(pager: Pager, page: HTMLElement): void { - playerPageBase.setupPage.call(this, pager, page) - this.basePage = page - this.overlayButtons = { - restart: page.querySelector("#restartButton")!, - nonLegalRestart: page.querySelector("#nonLegalRestartButton")!, - explodeJupiter: page.querySelector("#explodeJupiterButton")!, - nextLevel: page.querySelector("#nextLevelButton")!, - scores: page.querySelector("#scoresButton")!, - unpause: page.querySelector("#unpauseButton")!, - gzLeveList: page.querySelector("#gzLevelListButton")!, - gzSetSelector: page.querySelector("#gzSetSelectorButton")!, - } - - if ( - !this.overlayButtons.scores || - !this.overlayButtons.explodeJupiter || - !this.overlayButtons.restart || - !this.overlayButtons.nonLegalRestart || - !this.overlayButtons.nextLevel || - !this.overlayButtons.unpause || - !this.overlayButtons.gzLeveList || - !this.overlayButtons.gzSetSelector - ) - throw new Error("Could not find the completion button elements.") - this.overlayButtons.nextLevel.addEventListener("click", () => { - this.openNextLevel(pager) - }) - this.overlayButtons.restart.addEventListener("click", async () => { - pager.resetLevel() - }) - this.overlayButtons.nonLegalRestart.addEventListener("click", async () => { - pager.resetLevel() - }) - this.overlayButtons.unpause.addEventListener("click", async () => { - this.togglePaused() - }) - this.gameOverlay = page.querySelector("#levelViewportOverlay")! - this.overlayLevelName = page.querySelector("#overlayLevelName") - this.hintBox = page.querySelector("#hintBox") - this.nonLegalGlitchName = page.querySelector( - "#nonLegalGlitchName" - ) - this.submitAttempt = this.submitAttemptUnbound.bind(this, pager) - this.sfxManager = new AudioSfxManager() - // TODO Pre-fetch sfx and sfx customization - this.sfxManager.fetchDefaultSounds("./defoSfx") - }, - // Setting up level state and the game state machine - // Load -> - // -> Preplay -> - // -> Play .. (one of:) - // -> Pause -> Play - // -> Win -> Load or Preplay - // -> Lose -> Preplay - gameState: GameState.PLAYING, - isPaused: false, - isPreplay: false, - isGz: false, - isNonLegal: false, - preplayKeyListener: null as KeyListener | null, - sfxManager: null as AudioSfxManager | null, - loadLevel(pager: Pager): void { - playerPageBase.loadLevel.call(this, pager) - this.basePage!.classList.remove("solutionPlayback") - if (pager.loadedSet?.inPostGame) return - if (this.renderer === null || this.currentLevel === null) - throw new Error( - "Looks like the base player page didn't set the level correctly." - ) - - this.currentLevel.sfxManager = this.sfxManager - this.sfxManager?.stopAllSfx() - this.gameState = GameState.PLAYING - this.isPaused = false - this.isGz = false - this.isPreplay = true - this.isNonLegal = false - this.updateOverlayState() - this.preplayKeyListener?.remove() - this.preplayKeyListener = new KeyListener((ev: KeyboardEvent) => { - if (isValidStartKey(ev.code)) { - ev.preventDefault() - ev.stopPropagation() - this.endPreplay() - } - }) - if (this.overlayLevelName) { - const levelN = pager.getLevelNumber() - this.overlayLevelName.textContent = `${ - levelN !== "not in set" ? `#${levelN}: ` : "" - }${pager.loadedLevel!.name ?? "Unnamed level"}` - } - this.attemptTracker = new AttemptTracker( - this.currentLevel.blobPrngValue, - this.currentLevel.randomForceFloorDirection, - pager.loadedSet?.scriptRunner.state - ) - this.currentLevel.onGlitch = glitch => { - if (!this.preventNonLegalGlitches) return - if (glitch.glitchKind && nonLegalGlitches.includes(glitch.glitchKind)) { - this.isNonLegal = true - this.nonLegalGlitchName!.textContent = glitchNames[glitch.glitchKind] - this.updateOverlayState() - this.findCurrentMainButton()?.focus() - } - } - }, - updateOverlayState(): void { - this.gameOverlay!.setAttribute( - "data-game-state", - GameState[this.gameState].toLowerCase() - ) - setAttributeExistence(this.gameOverlay!, "data-paused", this.isPaused) - setAttributeExistence(this.gameOverlay!, "data-preplay", this.isPreplay) - setAttributeExistence(this.gameOverlay!, "data-gz", this.isGz) - setAttributeExistence(this.gameOverlay!, "data-nonlegal", this.isNonLegal) - }, - endPreplay(): void { - this.isPreplay = false - this.updateOverlayState() - this.preplayKeyListener?.remove() - this.preplayKeyListener = null - }, - togglePaused(): void { - if ( - this.gameState !== GameState.PLAYING || - this.isPreplay || - this.isGz || - this.isNonLegal - ) - return - - this.isPaused = !this.isPaused - this.updateOverlayState() - }, - // Transition from win - - async openNextLevel(pager: Pager): Promise { - if (!pager.loadedSet) { - await showAlert("Congratulations on clearing the level!") - pager.openPage(setSelectorPage) - return - } - const level = this.currentLevel! - const playable = level.selectedPlayable! - let exitN = 0 - let exitFound = false - for (const tile of level.tiles(false)) { - const hasExit = !!tile.findActor(actor => actor.hasTag("exit")) - if (!hasExit) continue - exitN += 1 - if (playable.tile === tile) { - exitFound = true - break - } - } - if (!exitFound) { - console.warn("Level won, but the player isn't on an exit tile??") - exitN = 0 - } - const keys = playable.inventory.keys - await pager.loadNextLevel({ - type: "win", - inventoryKeys: { - blue: keys.blueKey?.amount ?? 0, - green: keys.greenKey?.amount ?? 0, - red: keys.redKey?.amount ?? 0, - yellow: keys.yellowKey?.amount ?? 0, - }, - lastExitGender: playable.hasTag("melinda") ? "female" : "male", - timeLeft: level.timeLeft, - lastExitN: exitN, - inventoryTools: playable.inventory.items.map( - item => item.id as ScriptLegalInventoryTool - ), - totalScore: 0, // TODO Track this - }) - - if (!pager.loadedLevel) return - this.loadLevel(pager) - }, - startPostPlay(state: GameState): void { - this.gameState = state - this.updateOverlayState() - this.findCurrentMainButton()?.focus() - }, - findCurrentMainButton(): HTMLButtonElement | null { - if (!this.gameOverlay) - throw new Error("The game overlay must be set to find the main button.") - - return ( - Array.from( - this.gameOverlay.querySelectorAll(".mainButton") - ).find(button => button.getBoundingClientRect().height !== 0) ?? null - ) - }, - // Managing the live level state - attemptTracker: null as AttemptTracker | null, - submitAttemptUnbound(pager: Pager): void { - if (!this.attemptTracker) return - const level = this.currentLevel! - pager.saveAttempt(this.attemptTracker.endAttempt(level)) - }, - submitAttempt: null as (() => void) | null, - updateTextOutputs(): void { - if (this.gameState !== GameState.PLAYING) return - playerPageBase.updateTextOutputs.call(this) - this.hintBox!.textContent = this.currentLevel!.getHint() ?? "" - }, - heldKeys: { - up: AutoRepeatKeyState.RELEASED, - right: AutoRepeatKeyState.RELEASED, - down: AutoRepeatKeyState.RELEASED, - left: AutoRepeatKeyState.RELEASED, - drop: AutoRepeatKeyState.RELEASED, - rotateInv: AutoRepeatKeyState.RELEASED, - switchPlayable: AutoRepeatKeyState.RELEASED, - } as Record, - updateReleases(): void { - for (const [key, shouldRelease] of Object.entries( - this.currentLevel!.releasedKeys - )) { - const inputKey = key as keyof KeyInputs - if (!shouldRelease) continue - if (this.heldKeys[inputKey] === AutoRepeatKeyState.HELD) { - this.heldKeys[inputKey] = AutoRepeatKeyState.RELEASED - } - } - }, - inputListener(code: string, state: AutoRepeatKeyState): void { - if (!isValidKey(code)) return - const keyInput = keyToInputMap[code] - this.heldKeys[keyInput] = state - }, - getInput(): KeyInputs { - const keyInputs: Partial = {} - for (const inputType of Object.values(keyToInputMap)) { - if ( - this.preventSimultaneousMovement && - inputType === "switchPlayable" && - this.heldKeys[inputType] - ) { - return { - up: false, - right: false, - down: false, - left: false, - drop: false, - rotateInv: false, - switchPlayable: true, - } - } - keyInputs[inputType] = !!this.heldKeys[inputType] - } - return keyInputs as KeyInputs - }, - updateLogic(): void { - const level = this.currentLevel - if (!level) throw new Error("Cannot update the level without a level.") - if ( - this.gameState === GameState.TIMEOUT || - this.isPaused || - this.isPreplay || - this.isGz || - this.isNonLegal - ) - return - playerPageBase.updateLogic.call(this) - this.isRenderDirty = true - this.attemptTracker?.recordAttemptStep(level.gameInput) - this.updateReleases() - if ( - this.gameState === GameState.PLAYING && - level.gameState !== GameState.PLAYING - ) { - this.startPostPlay(level.gameState) - this.submitAttempt?.() - } - }, - logicTimer: null as IntervalTimer | null, - renderTimer: null as AnimationTimer | null, - open(pager: Pager): void { - if (!pager.loadedLevel) - throw new Error("Cannot open the level player page with a level to play.") - this.loadLevel(pager) - this.updateSettings(pager) - this.updateTextOutputs() - this.logicTimer = new CompensatingIntervalTimer( - this.updateLogic.bind(this), - 1 / 60 - ) - this.renderTimer = new AnimationTimer(this.updateRender.bind(this)) - this.keyListener = new AutoRepeatKeyListener(this.inputListener.bind(this)) - }, - close(): void { - if (this.logicTimer) { - this.logicTimer.cancel() - this.logicTimer = null - } - if (this.renderTimer) { - this.renderTimer.cancel() - this.renderTimer = null - } - this.preplayKeyListener?.remove() - this.preplayKeyListener = null - this.currentLevel = null - this.keyListener?.remove() - this.keyListener = null - }, - async showInterlude(_pager: Pager, text: string): Promise { - const dialog = makeChoiceDialog(text, [["next", "Next"]], "Story") - dialog.showModal() - const listener = new KeyListener(ev => { - if (ev.code === "KeyN" && ev.shiftKey) { - dialog.querySelector("button")!.click() - } - }) - listener.listenInModals = true - await waitForDialogSubmit(dialog) - listener.remove() - }, - showGz(): void { - this.isGz = true - this.isPaused = false - this.isPreplay = false - this.isNonLegal = false - this.gameState = GameState.PLAYING - this.updateOverlayState() - }, - async loadSolution(pager: Pager, sol: protobuf.ISolutionInfo): Promise { - this.loadLevel(pager) - this.attemptTracker = null - this.currentLevel!.onGlitch = null - this.currentLevel!.inputProvider = new SolutionInfoInputProvider(sol) - this.basePage!.classList.add("solutionPlayback") - this.endPreplay() - }, - extraTileScale: [ - 0.25 + // Padding - // Camera - 0.25 + // Gap - 4 + // Inventory - 0.25, // Padding - 0.25 + // Padding - // Camera - 0.25, // Padding - ] as [number, number], -} - -registerPage(levelPlayerPage) diff --git a/gamePlayer/src/pages/loading.ts b/gamePlayer/src/pages/loading.ts deleted file mode 100644 index 55800bc3..00000000 --- a/gamePlayer/src/pages/loading.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { pages, registerPage } from "../const" -import { lookupGbSet } from "../gliderbotSets" -import { Pager } from "../pager" -import { initSaveData } from "../saveData" -import { updatePagerTileset } from "../tilesets" -import { setSelectorPage } from "./setSelector" -import { loadDirSet, loadSet } from "../levelLoading" -import { findScriptName, LevelSetLoaderFunction } from "@notcc/logic" -import { waitForDialogSubmit } from "../simpleDialogs" - -function queryParamsToObj(query: string): Record { - return Object.fromEntries( - // TypeScript has inaccurate typings here for some reason?? - new URLSearchParams(query) as unknown as Iterable<[string, string]> - ) -} - -export async function openNotccUrl(pager: Pager): Promise { - if (pager.updatingPageUrl) return - const notccLocation = new URL("http://fake.notcc.path") - try { - notccLocation.href = `http://fake.notcc.path/${location.hash.slice(1)}` - } catch {} - - let [pageName, ...subpageParts] = notccLocation.pathname.split("/").slice(2) - const queryParams = { - ...queryParamsToObj(notccLocation.search), - ...queryParamsToObj(location.search), - } - - let pageToOpen = pages[pageName] ?? setSelectorPage - - if ( - pageToOpen.requiresLoaded === "set" || - pageToOpen.requiresLoaded === "level" - ) { - const setName = subpageParts.splice(0, 1)[0] - if (setName === undefined) - throw new Error( - "URI must specify set to use. Try using something like eg. /play/cc2lp1/1, instead of /play" - ) - if (setName in nonFreeSets) { - const setLoadingInfo = await showNonFreeDialog(setName) - if (setLoadingInfo === null) { - pageToOpen = setSelectorPage - } else { - pager.loadedSetIdent = setName - await loadSet(pager, setLoadingInfo[0], setLoadingInfo[1], true) - } - } else { - const gbSet = await lookupGbSet(setName) - if (gbSet === null) - throw new Error(`Gliderbot set with name "${setName}" not found`) - pager.loadedSetIdent = setName - await loadSet(pager, gbSet.loaderFunction, gbSet.mainScript, true) - } - } - - if (pageToOpen.requiresLoaded === "level") { - const levelNStr = subpageParts.splice(0, 1)[0] - if (levelNStr === undefined) - throw new Error( - "URI must specify level number to use. Try using something like eg. /play/cc2lp1/1, instead of /play/cc2lp1" - ) - let levelN = parseInt(levelNStr, 10) - - const set = pager.loadedSet! - - while (set.currentLevel < levelN) { - set.lastLevelResult = { type: "skip" } - await set.getNextRecord() - } - await set.goToLevel(levelN) - pager.loadedLevel = (await set.getCurrentRecord()).levelData! - } - - pager.openPage(pageToOpen) - pageToOpen.setNavigationInfo?.(pager, subpageParts.join("/"), queryParams) -} - -interface NonFreeSet { - scriptName: string - steamLink: string - acquisitionTerm: string -} - -export const nonFreeSets: Record = { - cc1: { - scriptName: "Chips Challenge", - steamLink: "https://store.steampowered.com/app/346850/Chips_Challenge_1", - acquisitionTerm: "download it for free from", - }, - cc2: { - scriptName: "Chips Challenge 2", - steamLink: "https://store.steampowered.com/app/348300/Chips_Challenge_2", - acquisitionTerm: "buy it on", - }, -} - -export function getNonFreeSetId(scriptName: string): string | null { - return ( - Object.entries(nonFreeSets).find( - ([_, val]) => val.scriptName === scriptName - )?.[0] ?? null - ) -} - -const nonFreeSetDialog = - document.querySelector("#nonFreeSetDialog")! - -async function showNonFreeDialog( - setName: string -): Promise<[LevelSetLoaderFunction, string] | null> { - const setInfo = nonFreeSets[setName] - const steamLink = - nonFreeSetDialog.querySelector("#nonFreeSteamLink")! - const acquisitionTerm = nonFreeSetDialog.querySelector( - "#nonFreeSetAcquisitionTerm" - )! - steamLink.href = setInfo.steamLink - acquisitionTerm.innerText = setInfo.acquisitionTerm - nonFreeSetDialog.showModal() - const response = (await waitForDialogSubmit(nonFreeSetDialog, false)) as - | "load" - | "cancel" - if (response === "cancel") return null - const [loader, scriptPath] = await loadDirSet() - const scriptName = findScriptName((await loader(scriptPath, false)) as string) - if (scriptName !== setInfo.scriptName) { - throw new Error( - `Incorrect set provided: Expected set name "${setInfo.scriptName}", got "${scriptName}"` - ) - } - return [loader, scriptPath] -} - -export const loadingPage = { - pageId: "loadingPage", - pagePath: null, - requiresLoaded: "none" as const, - async open(pager: Pager): Promise { - await initSaveData() - try { - await pager.loadSettings() - } catch { - // Didn't load settings. Fine if this is the first time we're opening the game - } - - await updatePagerTileset(pager) - await openNotccUrl(pager) - }, -} - -registerPage(loadingPage) diff --git a/gamePlayer/src/pages/setSelector.ts b/gamePlayer/src/pages/setSelector.ts deleted file mode 100644 index 93ae5098..00000000 --- a/gamePlayer/src/pages/setSelector.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { - LevelSet, - createLevelFromData, - findScriptName, - parseC2M, -} from "@notcc/logic" -import { Pager } from "../pager" -import stubLevel from "../levels/NotCC.c2m" -import { buildZipIndex, makeZipFileLoader } from "../fileLoaders" -import { - findEntryFilePath, - loadDirSet, - loadSet, - openLevel, -} from "../levelLoading" -import { getGbSets, metadataComparator } from "../gliderbotSets" -import { GliderbotSet } from "../gliderbotSets" -import { HTMLImage, Renderer, Tileset } from "../renderer" -import { - Comparator, - decodeBase64, - fetchImage, - instanciateTemplate, - mergeComparators, - unzlibAsync, -} from "../utils" -import { showLoadPrompt } from "../saveData" -import { showAlert } from "../simpleDialogs" -import { registerPage } from "../const" - -async function makeLevelSetPreview( - tileset: Tileset, - set: GliderbotSet -): Promise { - const levelSet = await LevelSet.constructAsync( - set.mainScript, - set.loaderFunction - ) - const levelRecord = await levelSet.getNextRecord() - if (!levelRecord) return null - const levelData = levelRecord.levelData! - const level = createLevelFromData(levelData) - const canvas = document.createElement("canvas") - const renderer = new Renderer(tileset, canvas) - renderer.viewportCanvas = canvas - renderer.level = level - renderer.cameraSize = level.cameraType - renderer.updateTileSize() - renderer.frame() - return canvas -} - -async function getSetThumbnail( - tileset: Tileset, - set: GliderbotSet -): Promise { - const thumbnailType = set.metadata.thumbnail - if (thumbnailType === undefined || thumbnailType === "image") { - try { - if (set.previewImage === null) throw new Error("No preview.png file") - const imageUrl = `${set.rootDirectory}/${set.previewImage}` - const image = await fetchImage(imageUrl) - return image - } catch (err) { - if (thumbnailType === "image") { - console.error("Failed to fetch image preview") - console.error(err) - return null - } - } - } - try { - const setPreview = await makeLevelSetPreview(tileset, set) - return setPreview - } catch (err) { - console.error("Failed to create a levelset preview.") - console.error(err) - return null - } -} - -const sortMethods = ["Last update", "Alphabetical"] as const -type SortMethod = (typeof sortMethods)[number] - -const sortMethodComparators: Record> = { - "Last update"(a, b) { - return +a.lastChanged - +b.lastChanged - }, - Alphabetical(a, b) { - return a.metadata.title.localeCompare(b.metadata.title) - }, -} - -export const setSelectorPage = { - pageId: "setSelectorPage", - pagePath: null, - requiresLoaded: "none" as const, - - async loadStubLevel(pager: Pager): Promise { - const levelBin = await (await fetch(stubLevel)).arrayBuffer() - const level = parseC2M(levelBin) - openLevel(pager, level) - }, - - async loadZip(pager: Pager, data: Uint8Array): Promise { - const filePaths = buildZipIndex(data) - const loader = makeZipFileLoader(data) - return loadSet(pager, loader, await findEntryFilePath(loader, filePaths)) - }, - async loadFile(pager: Pager, fileData: ArrayBuffer): Promise { - const magicString = Array.from(new Uint8Array(fileData).slice(0, 4), num => - String.fromCharCode(num) - ).join("") - // File types which aren't accepted by the file input (DATs, raw C2Ms) are - // here so that the Drag 'n Drop loader can use this. - if (magicString === "CC2M") { - const level = parseC2M(fileData) - openLevel(pager, level) - return - } else if ( - // ZIP - magicString === "PK\u{3}\u{4}" - ) { - await this.loadZip(pager, new Uint8Array(fileData)) - return - } else if ( - // DAT - magicString === "\xAC\xAA\x02\x00" || - magicString === "\xAC\xAA\x02\x01" || - magicString === "\xAC\xAA\x03\x00" || - magicString === "\xAC\xAA\x03\x01" - ) { - // TODO Proper simpleDialogs - showAlert("DAT files aren't supported, for now.") - return - } else { - const decoder = new TextDecoder("iso-8859-1") - const fileText: string = decoder.decode(fileData) - - if ( - // Explain how to use C2Gs - findScriptName(fileText) !== null - ) { - showAlert("You need to load the whole set, not just the C2G file.") - return - } - } - }, - setListEl: null as HTMLUListElement | null, - setLiTemlpate: null as HTMLTemplateElement | null, - setupPage(pager: Pager, page: HTMLElement): void { - const loadFileButton = page.querySelector("#loadFile")! - loadFileButton.addEventListener("click", async () => { - const files = await showLoadPrompt("Load level file", { - filters: [ - { name: "C2M level file", extensions: ["c2m"] }, - { name: "ZIP levelset archive", extensions: ["zip"] }, - ], - }) - const file = files[0] - const arrayBuffer = await file.arrayBuffer() - this.loadFile(pager, arrayBuffer) - }) - const loadDirectoryButton = - page.querySelector("#loadDirectory")! - loadDirectoryButton.addEventListener("click", async () => { - const [loader, path] = await loadDirSet() - await loadSet(pager, loader, path) - }) - this.setListEl = page.querySelector("#setList") - this.setLiTemlpate = - page.querySelector("#setLiTemplate") - if (!this.setListEl || !this.setLiTemlpate) { - throw new Error("Can't find a required document element.") - } - this.generateSetLis(pager) - }, - open(pager: Pager): void { - pager.loadedLevel = null - pager.loadedSet = null - pager.loadedSetIdent = null - pager.updateShownLevelNumber() - }, - async setNavigationInfo( - pager: Pager, - _subpage: string, - queryParams: Record - ) { - const levelDataBased = queryParams.level - - if (!levelDataBased) return - - let levelData = decodeBase64(levelDataBased) - - if (levelData[0] === 0x78) { - levelData = await unzlibAsync(levelData, { consume: true }) - } - - this.loadFile(pager, levelData.buffer) - }, - gbSets: null as GliderbotSet[] | null, - buildingGbSetLis: false, - gbSetLis: new Map(), - async makeSetLi(pager: Pager, set: GliderbotSet): Promise { - const tileset = pager.tileset - const setLi = instanciateTemplate(this.setLiTemlpate!) - const setThumbnailContainer = - setLi.querySelector(".setThumbnail")! - const thumbnail = await getSetThumbnail(tileset!, set) - if (thumbnail !== null) { - // Uugh a hack to make sure that thumbnails aren't bigger than the standard size - const width = - thumbnail instanceof HTMLImageElement - ? thumbnail.naturalWidth - : thumbnail.width - const height = - thumbnail instanceof HTMLImageElement - ? thumbnail.naturalHeight - : thumbnail.height - let cameraWidth = width / tileset!.tileSize - let cameraHeight = height / tileset!.tileSize - if (cameraWidth > 10) cameraWidth = 10 - if (cameraHeight > 10) cameraHeight = 10 - setThumbnailContainer.style.setProperty( - "--camera-width", - cameraWidth.toString() - ) - setThumbnailContainer.style.setProperty( - "--camera-height", - cameraHeight.toString() - ) - setThumbnailContainer.appendChild(thumbnail) - } else setThumbnailContainer.remove() - const meta = set.metadata - function addStringFact(className: string, value: string | undefined): void { - const el = setLi.querySelector(`.${className}`)! - if (value === undefined) { - el.remove() - } else { - const inputEl = el.querySelector("span")! - inputEl.textContent = value - } - } - addStringFact("setName", meta.title) - addStringFact("setBy", meta.by) - // TODO Use stars or something for this instead of a number - addStringFact("setDifficulty", meta.difficulty?.toString()) - addStringFact("setDescription", meta.description) - setLi.addEventListener("click", () => { - pager.loadedSetIdent = set.ident - loadSet(pager, set.loaderFunction, set.mainScript) - }) - - return setLi - }, - - async buildSetLis(pager: Pager): Promise { - if (this.gbSets === null) { - const sets = await getGbSets() - this.gbSets = sets - } - if (!this.setListEl) - throw new Error("Can't build a set list without the set list element") - if (!this.setLiTemlpate) - throw new Error( - "Can't build a set list without the set list item template" - ) - - this.buildingGbSetLis = true - this.gbSetLis.clear() - - return Promise.allSettled( - this.gbSets.map(set => - this.makeSetLi(pager, set).then(li => this.gbSetLis.set(set, li)) - ) - ).then(() => { - this.buildingGbSetLis = false - }) - }, - sortSetLis(sortMethod: SortMethod): GliderbotSet[] { - if (this.gbSets === null) return [] - const sets = this.gbSets.slice() - // Always prioritize sets with metadata filled in - sets.sort( - mergeComparators(metadataComparator, sortMethodComparators[sortMethod]) - ) - // We want to show the sets considered to be the best near the top, so we need to reverse the array - return sets.reverse() - }, - showSetLis(): void { - if (this.buildingGbSetLis) - throw new Error( - "Can't show the set list since the list items are still being generated. Race condition?" - ) - if (this.setListEl === null) - throw new Error( - "Can't add list items since the set list element is unset." - ) - const sets = this.sortSetLis("Last update") - for (const li of Array.from(this.setListEl.children)) { - li.remove() - } - for (const set of sets) { - const li = this.gbSetLis.get(set) - if (li === undefined) continue - this.setListEl.appendChild(li) - } - }, - async generateSetLis(pager: Pager): Promise { - await this.buildSetLis(pager) - this.showSetLis() - }, - updateSettings(pager: Pager): void { - if (this.gbSets !== null) { - this.generateSetLis(pager) - } - }, -} - -registerPage(setSelectorPage) diff --git a/gamePlayer/src/preferences.ts b/gamePlayer/src/preferences.ts new file mode 100644 index 00000000..4e0f1477 --- /dev/null +++ b/gamePlayer/src/preferences.ts @@ -0,0 +1,104 @@ +import { Getter, PrimitiveAtom, Setter, WritableAtom, atom } from "jotai" +import { atomEffect } from "jotai-effect" +import { writeJson } from "@/fs" +import { isDesktop } from "./helpers" + +export const DEFAULT_VALUE = Symbol() + +const preferenceAtoms: Record< + string, + [PrimitiveAtom, PrimitiveAtom] +> = {} + +export function getTruePreferenceAtom( + atom: WritableAtom +): PrimitiveAtom | undefined { + return Object.values(preferenceAtoms).find( + newAtom => newAtom[1] === atom + )?.[0] +} + +export function preferenceAtom( + key: string, + defaultValue: T +): PrimitiveAtom { + if (preferenceAtoms[key]) return preferenceAtoms[key][1] + const prefAtom = atom(DEFAULT_VALUE) + const defaultPrefAtom = atom( + get => { + const val = get(prefAtom) + return val === DEFAULT_VALUE ? defaultValue : val + }, + (_get, set, val: T | typeof DEFAULT_VALUE) => set(prefAtom, val) + ) + preferenceAtoms[key] = [prefAtom, defaultPrefAtom] + return defaultPrefAtom as PrimitiveAtom +} + +const dismissablePreferenceAtoms: PrimitiveAtom[] = [] + +export function dismissablePreferenceAtom(key: string): PrimitiveAtom { + const atom = preferenceAtom(key, false) + dismissablePreferenceAtoms.push(atom) + return atom +} + +export function resetDissmissablePreferencesGs(_get: Getter, set: Setter) { + for (const atom of dismissablePreferenceAtoms) { + set(getTruePreferenceAtom(atom)!, DEFAULT_VALUE) + } +} + +export const allPreferencesAtom = atom( + get => + Object.fromEntries( + Object.entries(preferenceAtoms) + .map<[string, any]>(([key, atom]) => [key, get(atom[0])]) + .filter(ent => ent[1] !== DEFAULT_VALUE) + ), + (_get, set, allPrefs: Record) => { + for (const [key, prefAtom] of Object.entries(preferenceAtoms)) { + set(prefAtom[0], key in allPrefs ? allPrefs[key] : DEFAULT_VALUE) + } + } +) + +export const preloadFinishedAtom = atom(false) +export const syncAllowed_thisisstupid = { val: false } + +export function isPreloading(get: Getter) { + return !get(preloadFinishedAtom) || !syncAllowed_thisisstupid.val +} +export const preferenceWritingAtom = atomEffect((get, _set) => { + void get(allPreferencesAtom) + if (isPreloading(get)) return + const prefs = get(allPreferencesAtom) + writeJson("preferences.json", prefs) +}) + +export function localStorageAtom( + key: string, + defaultValue: T +): PrimitiveAtom { + let atomValue + try { + const readValue = globalThis.localStorage && localStorage.getItem(key) + if (readValue) { + atomValue = JSON.parse(readValue) + localStorage.setItem(key, atomValue) + } else { + atomValue = defaultValue + } + } catch {} + + return atom(atomValue, function (this: PrimitiveAtom, _get, set, val) { + globalThis.localStorage?.setItem(key, JSON.stringify(val)) + set(this, val) + }) +} + +export const playEnabledAtom = localStorageAtom( + "NotCC play enabled", + // No downloads page on desktop! + isDesktop() +) diff --git a/gamePlayer/src/prewrite.png b/gamePlayer/src/prewrite.png new file mode 100644 index 00000000..de6c747d Binary files /dev/null and b/gamePlayer/src/prewrite.png differ diff --git a/gamePlayer/src/prompts.tsx b/gamePlayer/src/prompts.tsx new file mode 100644 index 00000000..2778222d --- /dev/null +++ b/gamePlayer/src/prompts.tsx @@ -0,0 +1,105 @@ +import { Getter, Setter, atom, useAtomValue } from "jotai" +import { ComponentChildren, ComponentType } from "preact" +import { ReactNode } from "preact/compat" +import { Dialog } from "./components/Dialog" + +interface Prompt { + el: ReactNode + promise: Promise + ident?: unknown +} + +export type PromptComponent = ComponentType<{ + onResolve: (val: R) => void + onReject: (err: unknown) => void +}> + +const promptsAtom = atom[]>([]) + +export function showPromptGs( + get: (atom: typeof promptsAtom) => Prompt[], + set: (atom: typeof promptsAtom, val: Prompt[]) => void, + Prompt: PromptComponent, + ident?: unknown +): Promise { + let el: ReactNode + let prompt: Prompt + let removed = false + function removePrompt() { + if (removed) return + removed = true + const prompts = get(promptsAtom).concat() + prompts.splice(prompts.indexOf(prompt), 1) + set(promptsAtom, prompts) + } + if (ident) { + const prompts = get(promptsAtom).concat() + const idx = prompts.findIndex( + prompt => prompt.ident && prompt.ident === ident + ) + if (idx !== -1) { + prompts.splice(idx, 1) + set(promptsAtom, prompts) + } + } + const promise = new Promise((res, rej) => { + el = ( + { + res(val) + removePrompt() + }} + onReject={val => { + rej(val) + removePrompt() + }} + /> + ) + }) + prompt = { el, promise, ident } + set(promptsAtom, get(promptsAtom).concat(prompt)) + return promise +} + +export async function showAlertGs( + get: Getter, + set: Setter, + body: ComponentChildren, + title?: string +): Promise { + await showPromptGs(get, set, pProps => ( + pProps.onResolve()]]} + > + {body} + + )) +} + +export function hidePrompt( + get: (atom: typeof promptsAtom) => Prompt[], + set: (atom: typeof promptsAtom, val: Prompt[]) => void, + ident: unknown +): void { + const prompts = get(promptsAtom).concat() + const idx = prompts.findIndex( + prompt => prompt.ident && prompt.ident === ident + ) + if (idx !== -1) { + prompts.splice(idx, 1) + set(promptsAtom, prompts) + } +} + +export function Prompts() { + const prompts = useAtomValue(promptsAtom) + return ( +
    + {prompts + .concat() + .reverse() + .map(prompt => prompt.el)} +
    + ) +} diff --git a/gamePlayer/src/railroad.ts b/gamePlayer/src/railroad.ts index 160dd22d..8e9f91e7 100644 --- a/gamePlayer/src/railroad.ts +++ b/gamePlayer/src/railroad.ts @@ -1,8 +1,13 @@ import { Route } from "@notcc/logic" +import { atom } from "jotai" +import { atomEffect } from "jotai-effect" +import { unwrap } from "jotai/utils" +import { importantSetAtom } from "./levelData" +import { Falliable, falliable } from "./helpers" export interface RRRoute { id: string - moves: Route + moves?: Route absoluteTime: number timeLeft: number points: number @@ -18,21 +23,42 @@ export interface RRLevel { levelN: number boldTime: number boldScore: number - mainlineTimeRoute: string - mainlineScoreRoute: string + mainlineTimeRoute?: string + mainlineScoreRoute?: string } -export async function getRRRoutes(pack: string): Promise { - const res = await fetch(`https://glander.club/railroad/packs/${pack}`) +export async function getRRRoutes( + pack: string, + noMoves = false +): Promise { + const res = await fetch( + `https://glander.club/railroad/packs/${pack}${noMoves ? "?noMoves" : ""}` + ) return await res.json() } -export function identifyRRPack(setName: string): string | null { - return ( - { - "Chips Challenge": "cc1", - "Chips Challenge 2": "cc2", - "Chips Challenge 2 Level Pack 1": "cc2lp1", - }[setName] ?? null +export async function getRRLevel( + pack: string, + levelN: number, + noMoves = false +): Promise { + const res = await fetch( + `https://glander.club/railroad/packs/${pack}/${levelN}/${ + noMoves ? "?noMoves" : "" + }` ) + return await res.json() } + +export const setRRRoutesAtom = unwrap( + atom> | null>(null) +) + +export const rrRoutesSyncAtom = atomEffect((get, set) => { + const importantSet = get(importantSetAtom) + if (!importantSet) { + set(setRRRoutesAtom, null) + } else { + set(setRRRoutesAtom, falliable(getRRRoutes(importantSet.setIdent, true))) + } +}) diff --git a/gamePlayer/src/renderer.ts b/gamePlayer/src/renderer.ts deleted file mode 100644 index 2ed18b08..00000000 --- a/gamePlayer/src/renderer.ts +++ /dev/null @@ -1,535 +0,0 @@ -import { - CameraType, - cc1BootNameList, - Direction, - Item, - Tile, - Wires, -} from "@notcc/logic" -import { LevelState } from "@notcc/logic" -import { Actor } from "@notcc/logic" -import { Layer } from "@notcc/logic" -import { keyNameList } from "@notcc/logic" -import { DirectionString } from "@notcc/logic" -import { specialFuncs, stateFuncs } from "./const" -import "./artSetSpecials" - -export type HTMLImage = HTMLImageElement | HTMLCanvasElement - -export function removeBackground(image: HTMLImage): HTMLImage { - const ctx = document - .createElement("canvas") - .getContext("2d", { willReadFrequently: true }) - if (!ctx) throw new Error("Couldn't create tileset canvas") - ;[ctx.canvas.width, ctx.canvas.height] = [image.width, image.height] - ctx.drawImage(image, 0, 0) - const rawData = ctx.getImageData(0, 0, image.width, image.height) - const maskColor = rawData.data.slice(0, 4) - for (let i = 0; i < rawData.data.length; i += 4) - if ( - rawData.data[i] === maskColor[0] && - rawData.data[i + 1] === maskColor[1] && - rawData.data[i + 2] === maskColor[2] && - rawData.data[i + 3] === maskColor[3] - ) - rawData.data[i + 3] = 0 - - ctx.putImageData(rawData, 0, 0) - return ctx.canvas -} - -export type Frame = [x: number, y: number] -export type Position = [x: number, y: number] -export type Size = [w: number, h: number] - -export function frange(a: Frame, b: Frame): Frame[] { - const frames: Frame[] = [] - const lengthX = b[0] - a[0] - if (a[1] !== b[1]) throw new Error("Can't use `frange` over vertical frames.") - - for (let xi = 0; xi <= Math.abs(lengthX); xi++) { - const x = a[0] + (lengthX > 0 ? xi : -xi) - frames.push([x, a[1]]) - } - return frames -} - -type StaticArt = Frame - -/** Directic is a portmanteau of directional and static */ -interface DirecticArt { - type: "directic" - UP: Frame - RIGHT: Frame - DOWN: Frame - LEFT: Frame -} -interface AnimatedArt { - type: "animated" - duration: number | "steps" - baseFrame?: number - randomizedFrame?: boolean - frames: Frame[] -} -interface DirectionalArt { - type: "directional" - duration: number | "steps" - baseFrame?: number - randomizedFrame?: boolean - UP: Frame[] - RIGHT: Frame[] - DOWN: Frame[] - LEFT: Frame[] -} -interface OverlayArt { - type: "overlay" - bottom: Art - top: Art -} -interface WiresArt { - type: "wires" - base?: Frame - top: Art - alwaysShowTop?: boolean -} -type StateArt = { type: "state" } & { [state: string]: string | Art } - -export type SpecialArt = { - type: "special" - specialType: string -} & { - [arg: string]: - | Art - | Frame[] - | string - | undefined - | boolean - | number - | Record -} -export type Art = - | StaticArt - | DirecticArt - | AnimatedArt - | DirectionalArt - | OverlayArt - | WiresArt - | StateArt - | SpecialArt - | null - -export interface ArtSet { - floor: Art - currentPlayerMarker: Frame - wireBase: Frame - wire: [StaticArt, StaticArt] - wireTunnel: DirecticArt - letters: Record - artMap: Record -} - -export interface Tileset { - image: HTMLImage - art: ArtSet - wireWidth: number - tileSize: number -} - -function clamp(value: number, min: number, max: number): number { - if (value < min) return min - if (value > max) return max - return value -} - -export interface ArtContext { - ctx: CanvasRenderingContext2D - tileSize: number - actor: Actor - ticks: number - offset: Position - noOffset?: boolean -} -export type ArtSessionContext = Omit - -export function ctxToDir(ctx: ArtContext): DirectionString { - return Direction[ctx.actor.direction] as DirectionString -} - -export class Renderer { - ctx: CanvasRenderingContext2D - itemCtx: CanvasRenderingContext2D | null = null - cameraPosition: Position = [0, 0] - level: LevelState | null = null - cameraSize: CameraType | null = null - - constructor( - public tileset: Tileset, - public viewportCanvas: HTMLCanvasElement, - public itemCanvas?: HTMLCanvasElement - ) { - // Get the viewport draw context - const ctx = viewportCanvas.getContext("2d", { - alpha: true, - }) as CanvasRenderingContext2D - if (ctx === null) - throw new Error("The viewport canvas is already being used!") - this.ctx = ctx - - if (this.itemCanvas) { - // Also get the item draw context, if we are allowed - const itemCtx = this.itemCanvas.getContext("2d", { - alpha: true, - }) as CanvasRenderingContext2D - if (itemCtx === null) - throw new Error("The item canvas is already being used!") - this.itemCtx = itemCtx - } - } - updateTileSize(): void { - if (!this.level || !this.cameraSize) - throw new Error("Can't update the tile size without a level!") - this.viewportCanvas.width = this.cameraSize.width * this.tileset.tileSize - this.viewportCanvas.height = this.cameraSize.height * this.tileset.tileSize - } - tileBlit( - { tileSize, ctx, offset }: ArtSessionContext, - pos: Position, - frame: Frame, - size: Size = [1, 1] - ): void { - ctx.drawImage( - this.tileset.image, - frame[0] * tileSize, - frame[1] * tileSize, - size[0] * tileSize, - size[1] * tileSize, - Math.floor((pos[0] + offset[0]) * tileSize), - Math.floor((pos[1] + offset[1]) * tileSize), - size[0] * tileSize, - size[1] * tileSize - ) - } - drawWireBase( - ctx: ArtContext, - pos: Position, - wires: Wires, - state: boolean - ): void { - const frame = this.tileset.art.wire[state ? 1 : 0] - const radius = this.tileset.wireWidth / 2 - const cropStart: Position = [0.5 - radius, 0.5 - radius] - const cropEnd: Position = [0.5 + radius, 0.5 + radius] - if (wires & Wires.UP) { - cropStart[1] = 0 - } - if (wires & Wires.RIGHT) { - cropEnd[0] = 1 - } - if (wires & Wires.DOWN) { - cropEnd[1] = 1 - } - if (wires & Wires.LEFT) { - cropStart[0] = 0 - } - const cropSize: Size = [ - cropEnd[0] - cropStart[0], - cropEnd[1] - cropStart[1], - ] - this.tileBlit( - ctx, - [pos[0] + cropStart[0], pos[1] + cropStart[1]], - [frame[0] + cropStart[0], frame[1] + cropStart[1]], - cropSize - ) - } - /** - * Generalized logic of drawing directional block and clone machine arrows - * @param width The length from the side of the tile to crop to get the - * required tile - */ - drawCompositionalSides( - ctx: ArtContext, - pos: Position, - art: Record, - width: number, - drawnDirections: Direction[] - ): void { - for (const direction of drawnDirections) { - const offset = - direction === Direction.RIGHT - ? [1 - width, 0] - : direction === Direction.DOWN - ? [0, 1 - width] - : [0, 0] - this.tileBlit( - ctx, - [pos[0] + offset[0], pos[1] + offset[1]], - art[Direction[direction] as DirectionString], - direction === Direction.UP || direction === Direction.DOWN - ? [1, width] - : [width, 1] - ) - } - } - getPosition(ctx: ArtContext): Position { - return ctx.noOffset ? [0, 0] : ctx.actor.getVisualPosition() - } - drawStatic(ctx: ArtContext, art: StaticArt): void { - this.tileBlit(ctx, this.getPosition(ctx), art) - } - drawDirectic(ctx: ArtContext, art: DirecticArt): void { - this.drawArt(ctx, art[ctxToDir(ctx)]) - } - drawAnimated(ctx: ArtContext, art: AnimatedArt | DirectionalArt): void { - const frames = art.type === "animated" ? art.frames : art[ctxToDir(ctx)] - const duration = art.duration - let frameN: number - const actor = ctx.actor - - if (typeof duration === "number") { - frameN = Math.floor(frames.length * ((ctx.ticks / duration) % 1)) - } else if (actor.cooldown !== 0) { - frameN = Math.floor( - frames.length * (1 - actor.cooldown / actor.currentMoveSpeed!) - ) - } else { - frameN = art.baseFrame || 0 - } - // TODO `art.randomizedFrame` - this.drawStatic(ctx, frames[frameN]) - } - drawOverlay(ctx: ArtContext, art: OverlayArt): void { - this.drawArt(ctx, art.bottom) - this.drawArt(ctx, art.top) - } - drawWires(ctx: ArtContext, art: WiresArt): void { - const pos = this.getPosition(ctx) - this.tileBlit(ctx, pos, this.tileset.art.wireBase) - if (ctx.actor.level.hideWires && !art.alwaysShowTop) return - if (!ctx.actor.level.hideWires) { - this.drawWireBase(ctx, pos, ctx.actor.wires, false) - this.drawWireBase( - ctx, - pos, - ctx.actor.poweredWires & ctx.actor.wires, - true - ) - } - this.drawArt(ctx, art.top) - } - drawState(ctx: ArtContext, art: StateArt): void { - const stateFunc = stateFuncs[ctx.actor.id] - if (stateFunc === undefined) { - console.warn(`No state function for actor ${ctx.actor.id ?? "floor"}.`) - return - } - - const state = stateFunc(ctx.actor) - const newArt = art[state] as Art - if (newArt === undefined) { - console.warn( - `Unexpected state ${state} for actor ${ctx.actor.id ?? "floor"}.` - ) - return - } - - this.drawArt(ctx, newArt) - } - drawSpecial(ctx: ArtContext, art: SpecialArt): void { - const specialFunc = specialFuncs[art.specialType] - if (specialFunc == undefined) { - console.warn( - `No special draw function for specialType ${art.specialType}.` - ) - return - } - - specialFunc.call(this, ctx, art) - } - drawArt(ctx: ArtContext, art: Art): void { - if (!art) return - if (Array.isArray(art)) { - this.drawStatic(ctx, art) - } else if (art.type === "directic") { - this.drawDirectic(ctx, art) - } else if (art.type === "animated" || art.type === "directional") { - this.drawAnimated(ctx, art) - } else if (art.type === "overlay") { - this.drawOverlay(ctx, art) - } else if (art.type === "wires") { - this.drawWires(ctx, art) - } else if (art.type === "state") { - this.drawState(ctx, art) - } else if (art.type === "special") { - this.drawSpecial(ctx, art) - } - } - drawFloor(ctx: ArtSessionContext, tile: Tile | Position): void { - this.drawArt( - // Warning: This is a really stupid hack. This can only work for - // special-type rendering which handles this kind of hackery - { ...ctx, actor: tile as unknown as Actor }, - this.tileset.art.floor - ) - } - drawActor(ctxSession: ArtSessionContext, actor: Actor): void { - const art = this.tileset.art.artMap[actor.id] - if (art === undefined) { - console.warn(`No art for actor ${actor.id}.`) - return - } - const ctx = { ...ctxSession, actor } - if ( - actor.level.playablesLeft > 1 && - actor === actor.level.selectedPlayable - ) { - const pos = this.getPosition(ctx) - this.tileBlit(ctx, pos, this.tileset.art.currentPlayerMarker) - } - this.drawArt(ctx, art) - } - _drawOrderedItems( - session: ArtSessionContext, - list: string[], - items: Item[], - yOffset: number, - amounts?: number[], - discardNonRegistered = false - ): void { - let nonRegisteredOffset = list.length - for (const [i, item] of items.entries()) { - let index = list.indexOf(item.id) - if (index === -1) { - if (discardNonRegistered) continue - index = nonRegisteredOffset - nonRegisteredOffset += 1 - } - if (amounts && amounts[i] === 0) continue - const ctx: ArtSessionContext = { ...session, offset: [index, yOffset] } - this.drawActor(ctx, item) - if (amounts !== undefined) { - let amount: number | string = amounts[i] - if (amount === 1 || amount > 127) { - continue - } else if (amount > 9) { - amount = "+" - } else { - amount = amount.toString() - } - this.tileBlit( - ctx, - [0.5, 0.5], - this.tileset.art.letters[amount], - [0.5, 0.5] - ) - } - } - } - updateItems(): void { - if (!this.level) - throw new Error("Can't update the inventory without a level!") - if (!this.itemCanvas || !this.itemCtx || !this.level.selectedPlayable) - return - const tileSize = this.tileset.tileSize - const player = this.level.selectedPlayable - const expectedWidth = player.inventory.itemMax * tileSize - const expectedHeight = 2 * tileSize - const session: ArtSessionContext = { - ctx: this.itemCtx, - ticks: 0, - tileSize, - offset: [0, 0], - noOffset: true, - } - - for (let index = 0; index < player.inventory.itemMax * 2; index++) { - const x = index % player.inventory.itemMax - const y = Math.floor(index / player.inventory.itemMax) - this.drawFloor(session, [x, y]) - } - if ( - this.itemCanvas.width !== expectedWidth || - this.itemCanvas.height !== expectedHeight - ) { - this.itemCanvas.width = expectedWidth - this.itemCanvas.height = expectedHeight - } - if (this.level.cc1Boots) { - this._drawOrderedItems( - session, - cc1BootNameList, - player.inventory.items, - 0, - undefined, - true - ) - } else { - for (const [i, item] of player.inventory.items.entries()) { - this.drawActor({ ...session, offset: [i, 0] }, item) - } - } - const keys = Object.values(player.inventory.keys).map(ent => ent.type) - const keyAmounts = Object.values(player.inventory.keys).map( - ent => ent.amount - ) - this._drawOrderedItems(session, keyNameList, keys, 1, keyAmounts) - } - updateCameraPosition(): void { - if (!this.level) { - throw new Error("There's no camera without a level!") - } - if (!this.level.selectedPlayable || !this.cameraSize) { - this.cameraPosition = [0, 0] - return - } - const playerPos = this.level.selectedPlayable.getVisualPosition() - // Note: the opposite of what you'd expect, since `visualPosition` gives - // absolute positions, so we need to recenter by subtracting the camera - // position, but ArtSessionContext adds offsets, so we need to negate - this.cameraPosition = [ - -( - clamp( - playerPos[0] + 0.5, - this.cameraSize.width / 2, - this.level.width - this.cameraSize.width / 2 - ) - - this.cameraSize.width / 2 - ), - -( - clamp( - playerPos[1] + 0.5, - this.cameraSize.height / 2, - this.level.height - this.cameraSize.height / 2 - ) - - this.cameraSize.height / 2 - ), - ] - } - frame(): void { - if (!this.level || !this.cameraSize) return - this.updateCameraPosition() - const session: ArtSessionContext = { - ctx: this.ctx, - offset: this.cameraPosition, - ticks: this.level.currentTick * 3 + this.level.subtick, - tileSize: this.tileset.tileSize, - } - for (let layer = Layer.STATIONARY; layer <= Layer.SPECIAL; layer++) { - for (let xi = -1; xi <= this.cameraSize.width + 1; xi++) { - for (let yi = -1; yi <= this.cameraSize.height + 1; yi++) { - const x = Math.floor(xi - this.cameraPosition[0]) - const y = Math.floor(yi - this.cameraPosition[1]) - const tile = this.level.field[x]?.[y] - if (!tile) continue - if (layer === Layer.STATIONARY && !tile.hasLayer(Layer.STATIONARY)) { - // If there's nothing on the terrain level, draw floor - this.drawFloor(session, tile) - } else if (tile.hasLayer(layer)) { - this.drawActor(session, tile[layer]!) - } - } - } - } - this.updateItems() - } -} diff --git a/gamePlayer/src/reportGenerator.ts b/gamePlayer/src/reportGenerator.ts deleted file mode 100644 index 1991821c..00000000 --- a/gamePlayer/src/reportGenerator.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { findBestMetrics, SolutionMetrics } from "@notcc/logic" -import { Pager } from "./pager" -import { instanciateTemplate, resetListeners } from "./utils" -import { - ApiAttributeIdentifier, - ApiPackLevelAttribute, - ApiRRPackLevelAttribute, - getPlayerPackDetails, - tryGetRRPackLevels, -} from "./scoresApi" - -const scoreReportGenDialog = document.querySelector( - "#scoreReportGenDialog" -)! - -const reportLineTemplate = - scoreReportGenDialog.querySelector( - "#reportLineTemplate" - )! - -interface ReportLevel { - levelN: number - levelName: string - metrics: Partial - reportedMetrics: Partial - boldMetrics: Partial - confirmedMetrics: Partial -} - -interface ReportEntry { - text: string - enabled: boolean -} - -type ReportMode = "cc1" | "cc2" - -const setNameToScoreboardName: Partial> = { - "Chips Challenge": ["cc1", "cc1"], - "Chips Challenge 2": ["cc2", "cc2"], - "Chips Challenge 2 Level Pack 1": ["cc2", "cc2lp1"], -} - -function determineMetricReportType( - level: ReportLevel, - metric: keyof SolutionMetrics -): string | null { - const userMetric = level.metrics[metric] - if (userMetric === undefined) throw new Error("No user metric") - const reportedMetric = level.reportedMetrics[metric] - const boldMetric = level.boldMetrics[metric] - const confirmedMetric = level.confirmedMetrics[metric] - // The bold metric should exist as long as there's at least one report for the level - // So, if we don't find one, it's probably the first report for level! Fun stuff. - if (boldMetric === undefined) return null - if (userMetric > boldMetric) return `b+${userMetric - boldMetric}` - if (userMetric === boldMetric) - return confirmedMetric === undefined || boldMetric === confirmedMetric - ? "b" - : "bc" - if (confirmedMetric !== undefined && userMetric > confirmedMetric) return "pc" - if (reportedMetric !== undefined && userMetric > reportedMetric) - return `+${userMetric - reportedMetric}` - return null -} - -function makeReportText(level: ReportLevel, mode: ReportMode): string { - let reportText = `#${level.levelN} (${level.levelName}): ` - - const timeRepType = determineMetricReportType(level, "timeLeft") - - reportText += `${Math.max( - level.metrics.timeLeft ?? -Infinity, - level.reportedMetrics.timeLeft ?? -Infinity - )}s` - - if (timeRepType) { - reportText += ` (${timeRepType})` - } - - if (mode === "cc2") { - const pointsRepType = determineMetricReportType(level, "points") - reportText += ` | ${Math.max( - level.metrics.points ?? -Infinity, - level.reportedMetrics.points ?? -Infinity - )}pts` - if (pointsRepType) { - reportText += ` (${pointsRepType})` - } - } - return reportText -} - -function generateReportLines( - entries: ReportEntry[], - showDisabled: boolean -): void { - const reportText = scoreReportGenDialog.querySelector("#linesPoint")! - // Nuke all current data - reportText.textContent = "" - for (const entry of entries) { - if (!showDisabled && !entry.enabled) continue - const reportLine = instanciateTemplate(reportLineTemplate) - const checkbox = reportLine.querySelector("input")! - checkbox.checked = entry.enabled - reportLine.classList.toggle("disabled", !entry.enabled) - checkbox.addEventListener("change", () => { - entry.enabled = checkbox.checked - generateReportLines(entries, showDisabled) - }) - const textSpan = reportLine.querySelector("span")! - textSpan.textContent = entry.text - reportText.appendChild(reportLine) - } -} - -export function getMetricsFromAttrs( - attrs: T[], - tranformer: (attr: T) => number -): Partial { - const reportedMetrics: Partial = {} - const timeMetric = attrs.find( - report => report.metric === "time" && report.rule_type === "steam" - ) - if (timeMetric) { - reportedMetrics.timeLeft = tranformer(timeMetric) - } - const scoreMetric = attrs.find( - report => report.metric === "score" && report.rule_type === "steam" - ) - if (scoreMetric) { - reportedMetrics.points = tranformer(scoreMetric) - } - return reportedMetrics -} - -export async function openScoreReportGenDialog(pager: Pager): Promise { - const set = pager.loadedSet - - if ( - !pager.settings.optimizerId || - set === null || - !set.scriptRunner.state.scriptTitle || - !(set.scriptRunner.state.scriptTitle in setNameToScoreboardName) - ) - return - resetListeners(scoreReportGenDialog) - - const [reportMode, setName] = - setNameToScoreboardName[set.scriptRunner.state.scriptTitle]! - let reportsInfo - let setInfo - - scoreReportGenDialog.setAttribute("stage", "loading") - - scoreReportGenDialog.showModal() - - try { - reportsInfo = await getPlayerPackDetails( - pager.settings.optimizerId, - setName - ) - setInfo = await tryGetRRPackLevels(setName) - } catch (err) { - scoreReportGenDialog.setAttribute("stage", "error") - scoreReportGenDialog.querySelector(".errorField")!.textContent = ( - err as Error - ).message - return - } - - const sortedLevels = Object.values(set.seenLevels) - .map(record => record.levelInfo) - .sort((a, b) => (a.levelNumber ?? 0) - (b.levelNumber ?? 0)) - - const entries: ReportEntry[] = [] - - for (const levelRecord of sortedLevels) { - const levelN = levelRecord.levelNumber! - - const reportedAttrs = reportsInfo.scores.levels[levelN.toString()] ?? [] - const setAttrs = setInfo[levelN - 1].level_attribs - - const metrics = findBestMetrics(levelRecord) - if (metrics.timeLeft !== undefined) { - metrics.timeLeft = Math.ceil(metrics.timeLeft) - } - const reportedMetrics = getMetricsFromAttrs( - reportedAttrs, - attr => attr.reported_value - ) - - let betterMetric = false - - for (const [metricName, userMetric] of Object.entries(metrics)) { - // Real time isn't generally reported - if (metricName === "realTime") continue - if ( - reportedMetrics[metricName as keyof SolutionMetrics] === undefined || - reportedMetrics[metricName as keyof SolutionMetrics]! < userMetric - ) { - betterMetric = true - break - } - } - - if (!betterMetric) continue - - const boldMetrics = getMetricsFromAttrs< - ApiRRPackLevelAttribute | ApiPackLevelAttribute - >(setAttrs, attr => attr.attribs.highest_reported) - - let confirmedMetrics: Partial = {} - if ("highest_confirmed" in setAttrs[0].attribs) { - confirmedMetrics = getMetricsFromAttrs( - setAttrs as ApiRRPackLevelAttribute[], - attr => attr.attribs.highest_confirmed - ) - } - - const level: ReportLevel = { - levelN, - levelName: levelRecord.title!, - reportedMetrics, - metrics, - boldMetrics, - confirmedMetrics, - } - - entries.push({ enabled: true, text: makeReportText(level, reportMode) }) - } - - generateReportLines(entries, true) - const setNameEl = scoreReportGenDialog.querySelector(".setName") - if (setNameEl) { - setNameEl.textContent = setName.toUpperCase() - if (reportMode === "cc1") { - setNameEl.textContent += " (Steam)" - } - } - - const reportText = - scoreReportGenDialog.querySelector("#reportText")! - const copyReportButton = - scoreReportGenDialog.querySelector("#copyReport") - copyReportButton?.addEventListener("click", () => { - // Stupid, but it works! - generateReportLines(entries, false) - navigator.clipboard.writeText(reportText.innerText) - generateReportLines(entries, true) - }) - - scoreReportGenDialog.setAttribute("stage", "default") -} diff --git a/gamePlayer/src/routing.tsx b/gamePlayer/src/routing.tsx new file mode 100644 index 00000000..2d82cf6f --- /dev/null +++ b/gamePlayer/src/routing.tsx @@ -0,0 +1,223 @@ +import { Getter, Setter, atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import { useEffect, useState } from "preact/hooks" +import { SetSelectorPage } from "./pages/SetSelectorPage" +import { FunctionComponent } from "preact" +import { Preloader } from "./components/Preloader" +import { LevelPlayerPage } from "./pages/LevelPlayerPage" +import { + levelAtom, + levelSetAtom, + levelSetAutosaveAtom, + resolveHashLevelGs, + setIntermissionRemoveAtom, +} from "./levelData" +import { EffectFn, ignorantAtomEffectHook } from "./helpers" +import { preferenceWritingAtom } from "./preferences" +import { atomEffect } from "jotai-effect" +import { ExaPlayerPage } from "./pages/ExaPlayerPage" +import { tilesetSyncAtom } from "./components/PreferencesPrompt/TilesetsPrompt" +import { levelControlsAtom } from "./components/Sidebar" +import { sfxSyncAtom } from "./components/PreferencesPrompt/SfxPrompt" +import { rrRoutesSyncAtom } from "./railroad" +import { setScoresSyncAtom } from "./scoresApi" + +function searchParamsToObj(query: string): SearchParams { + return Object.fromEntries(new URLSearchParams(query)) +} + +type SearchParams = { [P in string]?: string } + +interface HashLocation { + pagePath: string[] + searchParams: SearchParams +} + +function parseHashLocation(): HashLocation { + const notccLocation = new URL("http://fake.notcc.path") + try { + notccLocation.href = `http://fake.notcc.path/${location.hash.slice(1)}` + } catch {} + + let pagePath = notccLocation.pathname.split("/").slice(2) + const searchParams = { + ...searchParamsToObj(notccLocation.search), + ...searchParamsToObj(location.search), + } + const hashLoc: HashLocation = { pagePath, searchParams } + if (location.search !== "") { + const newLoc = new URL(location.href) + newLoc.search = "" + newLoc.hash = makeHashFromLoc(hashLoc) + history.replaceState(null, "", newLoc) + } + return hashLoc +} + +function makeHashFromLoc(hashLoc: HashLocation): string { + if ( + hashLoc.pagePath.length === 0 && + Object.keys(hashLoc.searchParams).length === 0 + ) { + return "" + } + return `#/${hashLoc.pagePath.join("/")}${ + Object.keys(hashLoc.searchParams).length === 0 + ? "" + : // Bad TS types + `?${new URLSearchParams( + hashLoc.searchParams as Record + )}` + }` +} + +function applyHashLocation(hashLoc: HashLocation): void { + const newLoc = new URL(location.href) + newLoc.hash = makeHashFromLoc(hashLoc) + history.pushState(null, "", newLoc) +} +interface Page { + component: FunctionComponent + requiresLevel?: boolean + isLevelPlayer?: boolean + showsIntermissions?: boolean +} + +const pages: Partial> = { + "": { component: SetSelectorPage }, + play: { + component: LevelPlayerPage, + requiresLevel: true, + isLevelPlayer: true, + showsIntermissions: true, + }, + exa: { + component: ExaPlayerPage, + requiresLevel: true, + isLevelPlayer: true, + }, +} + +export const CUSTOM_LEVEL_SET_IDENT = "*custom-level" +export const CUSTOM_SET_SET_IDENT = "*custom-set" + +export const nullablePageNameAtom = atom(null) +export const pageNameAtom = atom( + get => get(nullablePageNameAtom) ?? "", + (_get, set, val: string) => set(nullablePageNameAtom, val) +) +export const levelNAtom = atom(null) +export const levelSetIdentAtom = atom(null) +export const searchParamsAtom = atom({}) + +export const pageAtom = atom( + get => pages[get(pageNameAtom)] ?? null, + (_get, set, pageName) => set(pageNameAtom, pageName) +) +export const preventImmediateHashUpdateAtom = atom(false) + +export function updateVariablesFromHashGs(get: Getter, set: Setter) { + const hashLoc = parseHashLocation() + + const pageName = hashLoc.pagePath[0] + set( + nullablePageNameAtom, + pageName === "" || pageName === undefined ? null : pageName + ) + + const page = pages[pageName] + set(searchParamsAtom, hashLoc.searchParams) + if (page?.requiresLevel) { + set(levelSetIdentAtom, hashLoc.pagePath[1]) + set(levelNAtom, parseInt(hashLoc.pagePath[2])) + } else { + set(levelSetIdentAtom, null) + set(levelNAtom, null) + } + + set(preventImmediateHashUpdateAtom, true) + resolveHashLevelGs(get, set) +} + +const hashToInternalLocationSyncEffect: EffectFn = (get, set) => { + const listener = () => { + updateVariablesFromHashGs(get, set) + } + window.addEventListener("hashchange", listener) + return () => window.removeEventListener("hashchange", listener) +} + +const internalToHashLocationSyncEffect: EffectFn = (get, set) => { + const levelN = get(levelNAtom) + const levelSetIdent = get(levelSetIdentAtom) + const pageName = get(pageNameAtom) + const searchParams = get(searchParamsAtom) + + if (get(preventImmediateHashUpdateAtom)) { + set(preventImmediateHashUpdateAtom, false) + return + } + + applyHashLocation({ + pagePath: [ + pageName === "" ? null : pageName, + levelSetIdent, + levelN !== null ? levelN.toString() : null, + ].filter((part): part is string => part !== null), + searchParams, + }) +} + +const discardUselessLevelDataEffect: EffectFn = (get, set) => { + const nullablePageName = get(nullablePageNameAtom) + const page = get(pageAtom) + if (nullablePageName !== null && !page?.isLevelPlayer) { + set(levelSetIdentAtom, null) + set(levelNAtom, null) + set(levelAtom, null) + set(levelSetAtom, null) + set(levelControlsAtom, {}) + const searchParams = get(searchParamsAtom) + delete searchParams.level + set(searchParamsAtom, searchParams) + } +} + +function PageNotFound(props: { pageName: string }) { + return
    Page "{props.pageName}" doesn't exist.
    +} + +export const embedModeAtom = atom(get => !!get(searchParamsAtom).embed) +export const embedReadyAtom = atom(false) + +const routerEffectAtom = atomEffect((get, set) => { + discardUselessLevelDataEffect(get, set) + internalToHashLocationSyncEffect(get, set) +}) + +export function Router() { + const [preloadComplete, setPreloadComplete] = useState(false) + const pageName = useAtomValue(pageNameAtom) + const setPreventImmediateHashUpdate = useSetAtom( + preventImmediateHashUpdateAtom + ) + useEffect(() => { + // Prevent internalToHashLocationSyncAtom from writing to the hash on mount + setPreventImmediateHashUpdate(true) + }, []) + ignorantAtomEffectHook(hashToInternalLocationSyncEffect)() + useAtom(routerEffectAtom) + useAtom(preferenceWritingAtom) + useAtom(levelSetAutosaveAtom) + useAtom(tilesetSyncAtom) + useAtom(sfxSyncAtom) + useAtom(rrRoutesSyncAtom) + useAtom(setIntermissionRemoveAtom) + useAtom(setScoresSyncAtom) + if (!preloadComplete) + return setPreloadComplete(true)} /> + + const page = pages[pageName] + if (page === undefined) return + const Page = page.component + return +} diff --git a/gamePlayer/src/saveData.localStorage.ts b/gamePlayer/src/saveData.localStorage.ts deleted file mode 100644 index b035f4e7..00000000 --- a/gamePlayer/src/saveData.localStorage.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { protobuf } from "@notcc/logic" -import { compressToUTF16, decompressFromUTF16 } from "lz-string" -import { Settings } from "./settings" -import { ExternalTilesetMetadata } from "./tilesets" -import { fetchImage, reencodeImage } from "./utils" -import type { os } from "@neutralinojs/lib" - -function makeFilePrefix(type: string) { - return `NotCC ${type}` -} - -const base64EncodedUI8Prefix = "\x7F\x00B64\x00\x7F" -const uriImagePrefix = "\x7F\x00URIIMG\x00\x7F" - -function encodeBinaryBase64(bin: Uint8Array): string { - return btoa(String.fromCharCode.apply(null, Array.from(bin))) -} - -function decodeBinaryBase64(b64: string): Uint8Array { - return Uint8Array.from(atob(b64), char => char.charCodeAt(0)) -} - -function jsonStringifyExtended(data: any): string { - return JSON.stringify(data, (_key, val) => { - if (val instanceof Uint8Array) { - return `${base64EncodedUI8Prefix}${encodeBinaryBase64(val)}` - } - if (val instanceof HTMLImageElement) { - // Re-encode the image to also be saved - val = reencodeImage(val) - } - if (val instanceof HTMLCanvasElement) { - return `${uriImagePrefix}${val.toDataURL()}` - } - return val - }) -} - -async function walkObjectAsync( - obj: object, - transform: (val: any) => Promise -): Promise { - if (Array.isArray(obj)) { - return Promise.all(obj.map(item => walkObjectAsync(item, transform))) - } - if (typeof obj === "object" && obj !== null) { - const newObj: any = {} - for (const [key, val] of Object.entries(obj)) { - newObj[key] = await walkObjectAsync(val, transform) - } - return newObj - } - return transform(obj) -} - -function jsonParseExtended(data: string): Promise { - const vanillaData = JSON.parse(data) - return walkObjectAsync(vanillaData, async val => { - if (typeof val === "string") { - if (val.startsWith(base64EncodedUI8Prefix)) { - return decodeBinaryBase64(val.slice(base64EncodedUI8Prefix.length)) - } - if (val.startsWith(uriImagePrefix)) { - return await fetchImage(val.slice(uriImagePrefix.length)) - } - } - return val - }) -} - -export function initSaveData(): Promise { - return Promise.resolve() -} - -export async function saveSetInfo( - solution: protobuf.ISetInfo, - fileName: string -): Promise { - localStorage.setItem( - `${makeFilePrefix("solution")}: ${fileName}`, - compressToUTF16(jsonStringifyExtended(solution)) - ) -} - -export async function loadSetInfo( - fileName: string -): Promise { - const compressedData = localStorage.getItem( - `${makeFilePrefix("solution")}: ${fileName}` - ) - if (!compressedData) throw new Error(`File not fould: ${fileName}`) - return await jsonParseExtended(decompressFromUTF16(compressedData)) -} - -export async function saveSettings(settings: Settings): Promise { - localStorage.setItem(makeFilePrefix("settings"), JSON.stringify(settings)) -} - -export async function loadSettings(): Promise { - const settings = localStorage.getItem(makeFilePrefix("settings")) - if (!settings) throw new Error("Settings file not found") - return JSON.parse(settings) -} - -export async function saveTileset( - tileset: ExternalTilesetMetadata -): Promise { - localStorage.setItem( - `${makeFilePrefix("tileset")}: ${tileset.identifier}`, - jsonStringifyExtended(tileset) - ) -} - -export async function loadTileset( - identifier: string -): Promise { - const tilesetData = localStorage.getItem( - `${makeFilePrefix("tileset")}: ${identifier}` - ) - if (tilesetData === null) throw new Error("Tileset not found") - - const data: ExternalTilesetMetadata = await jsonParseExtended(tilesetData) - if ("imageData" in data) { - // We moved keys, so update legacy tilesets - data.image = reencodeImage(await fetchImage(data.imageData as string)) - } - return data -} - -export async function loadAllTilesets(): Promise { - const tsets: ExternalTilesetMetadata[] = [] - for (const recordName in localStorage) { - const tsetPrefix = `${makeFilePrefix("tileset")}: ` - if (!recordName.startsWith(tsetPrefix)) continue - const tset = await loadTileset(recordName.slice(tsetPrefix.length)) - tsets.push(tset) - } - return tsets -} - -export async function removeTileset(identifier: string): Promise { - const deleteSuccess = - delete localStorage[`${makeFilePrefix("tileset")}: ${identifier}`] - if (!deleteSuccess) throw new Error("Couldn't delete file.") -} - -export async function showLoadPrompt( - _title?: string, - options?: os.OpenDialogOptions -): Promise { - const fileLoader = document.createElement("input") - fileLoader.type = "file" - if (options?.filters !== undefined) { - fileLoader.accept = options.filters - .map(filter => filter.extensions.map(ext => `.${ext}`).join(",")) - .join(",") - } - fileLoader.multiple = !!options?.multiSelections - return new Promise((res, rej) => { - fileLoader.addEventListener("change", () => { - if (fileLoader.files === null || fileLoader.files.length === 0) { - rej(new Error("No files specified")) - } else { - res(Array.from(fileLoader.files)) - } - fileLoader.remove() - }) - fileLoader.click() - }) -} - -export async function showDirectoryPrompt(): Promise { - const fileLoader = document.createElement("input") - fileLoader.type = "file" - fileLoader.webkitdirectory = true - return new Promise((res, rej) => { - fileLoader.addEventListener("change", () => { - if (fileLoader.files === null || fileLoader.files.length === 0) { - rej(new Error("No directory specified")) - } else { - res(Array.from(fileLoader.files)) - } - fileLoader.remove() - }) - fileLoader.click() - }) -} - -export async function showSavePrompt( - fileData: ArrayBuffer, - _title?: string, - option?: os.SaveDialogOptions -): Promise { - const blob = new Blob([fileData], { type: "application/octet-stream" }) - const url = URL.createObjectURL(blob) - const anchor = document.createElement("a") - if (option?.defaultPath !== undefined) { - anchor.download = option.defaultPath.slice(2) - } - anchor.href = url - anchor.click() - anchor.remove() - URL.revokeObjectURL(url) -} diff --git a/gamePlayer/src/saveData.neutralino.ts b/gamePlayer/src/saveData.neutralino.ts deleted file mode 100644 index 4985c8e4..00000000 --- a/gamePlayer/src/saveData.neutralino.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { parseNCCS, protobuf, writeNCCS } from "@notcc/logic" -import { Settings } from "./settings" -import { ExternalTilesetMetadata } from "./tilesets" -import { filesystem, init as neuInit, os } from "@neutralinojs/lib" -import path from "path-browserify" -import { fetchImage, reencodeImage } from "./utils" -import { applicationConfigPath } from "./configPath" - -/** - * Uuugh, Neutralino depends on a couple of global variables prefixed with NL_ - * to be present to function. Problem is, the variables can only be gotten by - * the server serving a __neutralino_globals.js file. We don't want it to be - * served if it's is a web build, but there's no mechanism to include/exclude - * script tags at build time. So, download the globals file, parse it for - * global-ish statements, and add the globals manually instead. Epic hack. - */ -const globalVarRegex = /var (NL_\w+)=([^;]+);/g -async function loadNeuGlobalVariables(): Promise { - const globalsResponse = await fetch("__neutralino_globals.js") - const globalsText = await globalsResponse.text() - let match: RegExpExecArray | null - while ((match = globalVarRegex.exec(globalsText))) { - const key = match[1] - const valString = match[2] - // I don't want to build a JS parser here, so let's just eval it. - const val = new Function(`return ${valString}`)() - ;(globalThis as any)[key] = val - } -} - -async function dirExists(path: string): Promise { - try { - await filesystem.readDirectory(path) - } catch (err) { - return false - } - return true -} - -async function getPath(pathName: string) { - return path.join(await applicationConfigPath("NotCC"), pathName) -} - -const SET_INFO_DIRECTORY = "solutions" -const SETTINGS_FILE = "settings.json" -const TILESETS_DIRECTORY = "tilesets" - -async function assertDirExists(path: string): Promise { - const truePath = await getPath(path) - if (!(await dirExists(truePath))) { - await filesystem.createDirectory(truePath) - } -} - -export async function initSaveData(): Promise { - await loadNeuGlobalVariables() - neuInit() - await assertDirExists(".") - await assertDirExists(SET_INFO_DIRECTORY) - await assertDirExists(TILESETS_DIRECTORY) -} - -export async function saveSetInfo( - solution: protobuf.ISetInfo, - fileName: string -): Promise { - await filesystem.writeBinaryFile( - path.join(await getPath(SET_INFO_DIRECTORY), `${fileName}.nccs`), - writeNCCS(solution) - ) -} - -export async function loadSetInfo( - fileName: string -): Promise { - return parseNCCS( - await filesystem.readBinaryFile( - path.join(await getPath(SET_INFO_DIRECTORY), `${fileName}.nccs`) - ) - ) -} - -export async function saveSettings(settings: Settings): Promise { - await filesystem.writeFile( - await getPath(SETTINGS_FILE), - JSON.stringify(settings) - ) -} - -export async function loadSettings(): Promise { - return JSON.parse(await filesystem.readFile(await getPath(SETTINGS_FILE))) -} - -function canvasToBlob(canvas: HTMLCanvasElement, type: string): Promise { - return new Promise((res, rej) => { - canvas.toBlob(blob => { - if (blob === null) { - rej(new Error("Failed to create a blob from a canvas")) - return - } - res(blob) - }, type) - }) -} -async function bufferToCanvas( - buffer: ArrayBuffer, - type: string -): Promise { - const blob = new Blob([buffer], { type }) - const objectUrl = URL.createObjectURL(blob) - return reencodeImage(await fetchImage(objectUrl)) -} - -export async function saveTileset( - tileset: ExternalTilesetMetadata -): Promise { - const tilesetsDir = await getPath(TILESETS_DIRECTORY) - const image = await canvasToBlob(tileset.image, "image/png") - await filesystem.writeBinaryFile( - path.join(tilesetsDir, `${tileset.identifier}.png`), - await image.arrayBuffer() - ) - await filesystem.writeFile( - path.join(tilesetsDir, `${tileset.identifier}.json`), - JSON.stringify(tileset, (key, val) => (key === "image" ? undefined : val)) - ) -} - -export async function loadTileset( - identifier: string -): Promise { - const tilesetsDir = await getPath(TILESETS_DIRECTORY) - const metadata: ExternalTilesetMetadata = JSON.parse( - await filesystem.readFile(path.join(tilesetsDir, `${identifier}.json`)) - ) - // The `metadata.image` is currently undefined, so actually load the - // extraneous image file - const image = await bufferToCanvas( - await filesystem.readBinaryFile( - path.join(tilesetsDir, `${identifier}.png`) - ), - "image/png" - ) - metadata.image = image - - return metadata -} - -export async function loadAllTilesets(): Promise { - const tilesetsDir = await getPath(TILESETS_DIRECTORY) - const tsets: ExternalTilesetMetadata[] = [] - for (const record of await filesystem.readDirectory(tilesetsDir)) { - if (record.type === "DIRECTORY") continue - if (!record.entry.endsWith(".json")) continue - const tset = await loadTileset(record.entry.slice(0, -5)) - tsets.push(tset) - } - return tsets -} - -export async function removeTileset(identifier: string): Promise { - const tilesetsDir = await getPath(TILESETS_DIRECTORY) - await filesystem.removeFile(path.join(tilesetsDir, `${identifier}.json`)) - await filesystem.removeFile(path.join(tilesetsDir, `${identifier}.png`)) -} - -export async function showLoadPrompt( - title?: string, - options?: os.OpenDialogOptions -): Promise { - const fileNames = await os.showOpenDialog(title, options) - const files: File[] = [] - for (const fileName of fileNames) { - const stat = await filesystem.getStats(fileName) - const bin = await filesystem.readBinaryFile(fileName) - files.push( - new File([bin], path.basename(fileName), { - lastModified: stat.modifiedAt, - }) - ) - } - return files -} - -async function scanDirectory(dirPath: string, prefix: string): Promise { - const entries = await filesystem.readDirectory(dirPath) - const files: File[] = [] - for (const ent of entries) { - if (ent.entry === "." || ent.entry === "..") continue - const filePath = path.join(dirPath, ent.entry) - const prefixPath = path.join(prefix, ent.entry) - if (ent.type === "FILE") { - const stat = await filesystem.getStats(filePath) - const bin = await filesystem.readBinaryFile(filePath) - const file = new File([bin], ent.entry, { lastModified: stat.modifiedAt }) - // Define the property explicitly on the `file`, since the underlying `File.prototype.webkitRelativePath` - // getter (which assigning with `=` uses) doesn't allow writing. ugh - Object.defineProperty(file, "webkitRelativePath", { value: prefixPath }) - files.push(file) - } else { - files.push(...(await scanDirectory(filePath, prefixPath))) - } - } - return files -} - -export async function showDirectoryPrompt( - title?: string, - options?: os.FolderDialogOptions -): Promise { - const dirName = await os.showFolderDialog(title, options) - return await scanDirectory(dirName, "") -} - -export async function showSavePrompt( - fileData: ArrayBuffer, - title?: string, - options?: os.SaveDialogOptions -): Promise { - const savePath = await os.showSaveDialog(title, options) - if (savePath === "") throw new Error("Save path not provided") - await filesystem.writeBinaryFile(savePath, fileData) -} diff --git a/gamePlayer/src/saveData.ts b/gamePlayer/src/saveData.ts deleted file mode 100644 index a398a6ad..00000000 --- a/gamePlayer/src/saveData.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as lsSave from "./saveData.localStorage" -import * as neuSave from "./saveData.neutralino" -import { isDesktop } from "./utils" - -// Decide which save data method to use. Kinda hacky, but what are you gonna do? - -export const initSaveData = isDesktop() - ? neuSave.initSaveData - : lsSave.initSaveData -export const saveSetInfo = isDesktop() - ? neuSave.saveSetInfo - : lsSave.saveSetInfo -export const loadSetInfo = isDesktop() - ? neuSave.loadSetInfo - : lsSave.loadSetInfo -export const saveSettings = isDesktop() - ? neuSave.saveSettings - : lsSave.saveSettings -export const loadSettings = isDesktop() - ? neuSave.loadSettings - : lsSave.loadSettings -export const saveTileset = isDesktop() - ? neuSave.saveTileset - : lsSave.saveTileset -export const loadTileset = isDesktop() - ? neuSave.loadTileset - : lsSave.loadTileset -export const loadAllTilesets = isDesktop() - ? neuSave.loadAllTilesets - : lsSave.loadAllTilesets -export const removeTileset = isDesktop() - ? neuSave.removeTileset - : lsSave.removeTileset -export const showLoadPrompt = isDesktop() - ? neuSave.showLoadPrompt - : lsSave.showLoadPrompt -export const showDirectotyPrompt = isDesktop() - ? neuSave.showDirectoryPrompt - : lsSave.showDirectoryPrompt -export const showSavePrompt = isDesktop() - ? neuSave.showSavePrompt - : lsSave.showSavePrompt diff --git a/gamePlayer/src/scoresApi.ts b/gamePlayer/src/scoresApi.ts index 46239dc4..0fe57858 100644 --- a/gamePlayer/src/scoresApi.ts +++ b/gamePlayer/src/scoresApi.ts @@ -1,7 +1,16 @@ +import { atom } from "jotai" +import { unwrap } from "jotai/utils" +import { preferenceAtom } from "./preferences" +import { atomEffect } from "jotai-effect" +import { importantSetAtom } from "./levelData" +import { SolutionMetrics } from "@notcc/logic" +import { Falliable, falliable } from "./helpers" + // /players export interface ApiPlayerGeneric { player_id: number player: string + country?: string } // /players/[id] @@ -24,7 +33,9 @@ export interface ApiDesignedLevelsSummary { level_count: number } -export async function getPlayerSummary(id: number): Promise { +export async function getPlayerSummary( + id: number +): Promise { const res = await fetch(`https://api.bitbusters.club/players/${id}`) return await res.json() } @@ -54,12 +65,6 @@ export interface ApiPackReport extends ApiAttributeIdentifier { date_reported: string } -export interface ApiDesignedLevel { - level: number - level_name: string - wiki_article: string -} - export async function getPlayerPackDetails( id: number, pack: string @@ -68,6 +73,8 @@ export async function getPlayerPackDetails( return await res.json() } +// /packs/[pack]/levels + export interface ApiPackLevel { level: number name: string @@ -88,7 +95,9 @@ export interface ApiPackLevel { export interface ApiPackLevelAttribute extends ApiAttributeIdentifier { attribs: { melinda: number - highest_reported: number + highest_reported?: number + highest_public?: number + highest_confirmed?: number casual_diff: number exec_diff: number luck_diff: number @@ -101,27 +110,116 @@ export async function getPackLevels(pack: string): Promise { return await res.json() } -export interface ApiRRPackLevel { - level_attribs: ApiRRPackLevelAttribute[] -} - -export interface ApiRRPackLevelAttribute extends ApiAttributeIdentifier { - attribs: { - highest_reported: number - highest_confirmed: number +export const optimizerIdAtom = preferenceAtom( + "optimizerId", + null +) + +export const setScoresAtom = unwrap( + atom> | null>(null) +) +export const setPlayerScoresAtom = unwrap( + atom> | null>(null) +) + +export const setScoresSyncAtom = atomEffect((get, set) => { + const importantSet = get(importantSetAtom) + set(setScoresAtom, null) + set(setPlayerScoresAtom, null) + + if (!importantSet) return + set(setScoresAtom, falliable(getPackLevels(importantSet.setIdent))) + const optimizerId = get(optimizerIdAtom) + + if (optimizerId === null) return + set( + setPlayerScoresAtom, + falliable(getPlayerPackDetails(optimizerId, importantSet.setIdent)) + ) +}) + +export type ReportGrade = + | "better than bold" + | "bold confirm" + | "partial confirm" + | "bold" + | "better than public" + | "public" + | "solved" + | "unsolved" + +export function getReportGradeForValue( + value: number, + level: ApiPackLevelAttribute +): ReportGrade { + const { + highest_reported: highestReported, + highest_confirmed: highestConfirmed, + highest_public: highestPublic, + } = level.attribs + if (highestReported === undefined || value > highestReported) + return "better than bold" + if (highestConfirmed === undefined) + throw new Error( + "If there's a reported score, there should also be a confirmed score. Score server error?" + ) + if (value > highestConfirmed) + return value === highestReported ? "bold confirm" : "partial confirm" + if (value === highestReported) return "bold" + if (highestPublic === undefined || value > highestPublic) + return "better than public" + if (value === highestPublic) return "public" + return "solved" +} + +export function getLevelAttribute( + metric: "time" | "score", + level: T +): T extends ApiPackLevel ? ApiPackLevelAttribute : ApiPackReport { + return ( + ("level_attribs" in level + ? level.level_attribs + : level) as ApiPackLevelAttribute[] + ).find(attr => attr.rule_type === "steam" && attr.metric === metric) as any +} + +export type MetricGrades = Record<"time" | "score", ReportGrade> + +export function getReportGradesForMetrics( + metrics: SolutionMetrics, + level: ApiPackLevel +): MetricGrades { + const timeAttr = getLevelAttribute("time", level) + if (!timeAttr) throw new Error("Scores level is missing Steam time attribute") + + const timeGrade = getReportGradeForValue( + Math.ceil(metrics.timeLeft / 60), + timeAttr + ) + // If there's no score leaderboard (eg. CC1 Steam), assume no levels have flags and time === score, grade-wise + const scoreAttr = getLevelAttribute("score", level) + if (!scoreAttr) { + return { + time: timeGrade, + score: timeGrade, + } + } + return { + time: timeGrade, + score: getReportGradeForValue(metrics.score, scoreAttr), } } -export async function getRRPackLevels(pack: string): Promise { - const res = await fetch(`https://glander.club/railroad/bolds/${pack}`) - return await res.json() -} - -export function tryGetRRPackLevels( - pack: string -): Promise { - return getRRPackLevels(pack).catch(err => { - console.error(err) - return getPackLevels(pack) - }) +export function getMetricsForPlayerReports( + reports: ApiPackReport[] +): Partial { + const timeLeftS = reports.find( + val => val.metric === "time" && val.rule_type === "steam" + )?.reported_value + return { + timeLeft: timeLeftS === undefined ? timeLeftS : timeLeftS * 60, + score: reports.find( + val => val.metric === "score" && val.rule_type === "steam" + )?.reported_value, + } } diff --git a/gamePlayer/src/setLoading.ts b/gamePlayer/src/setLoading.ts new file mode 100644 index 00000000..85f4b78b --- /dev/null +++ b/gamePlayer/src/setLoading.ts @@ -0,0 +1,180 @@ +import { + LevelSetData, + LevelSetLoaderFunction, + findScriptName, +} from "@notcc/logic" +import { Unzipped } from "fflate" +import { join, normalize } from "path-browserify" +import { CaseResolver, findAllFiles, readFile } from "./fs" +import { unzipAsync } from "./helpers" + +function getFilePath(file: File): string { + return file.webkitRelativePath ?? file.name +} + +export function makeBufferMapFileLoader( + zipFiles: Unzipped +): LevelSetLoaderFunction { + const zipFilesLowercase = Object.fromEntries( + Object.entries(zipFiles).map(([path, data]) => [path.toLowerCase(), data]) + ) + + // This is Latin-1 + const decoder = new TextDecoder("iso-8859-1") + return (async (path: string, binary: boolean) => { + const fileData = zipFilesLowercase[path.toLowerCase()] + if (!fileData) throw new Error(`File ${path} not found`) + if (binary) return fileData.buffer + return decoder.decode(fileData) + }) as LevelSetLoaderFunction +} + +export async function makeSetDataFromZip( + zipData: ArrayBuffer +): Promise { + const zipFiles = await unzipAsync(zipData) + const loaderFunction = makeBufferMapFileLoader(zipFiles) + return findEntryFilePath(loaderFunction, Object.keys(zipFiles)) +} + +export function makeFileListFileLoader( + fileList: File[] +): LevelSetLoaderFunction { + // This is Latin-1 + const decoder = new TextDecoder("iso-8859-1") + const files: Record = {} + for (const file of fileList) { + files[getFilePath(file).toLowerCase()] = file + } + return (async (path: string, binary: boolean) => { + const fileData = await files[path.toLowerCase()].arrayBuffer() + if (binary) return fileData + return decoder.decode(fileData) + }) as LevelSetLoaderFunction +} + +export async function makeBufferMapFromFileList( + fileList: File[] +): Promise { + return Object.fromEntries( + await Promise.all( + fileList.map>(async file => [ + getFilePath(file).toLowerCase(), + new Uint8Array(await file.arrayBuffer()), + ]) + ) + ) +} + +export function makeHttpFileLoader(url: string): LevelSetLoaderFunction { + return (async (path: string, binary: boolean) => { + const fileData = await fetch(`${url}${path}`) + if (!fileData.ok) + throw new Error( + `Could not load ${path}: ${fileData.status} ${ + fileData.statusText + }, ${await fileData.text()}` + ) + if (binary) return await fileData.arrayBuffer() + return await fileData.text() + }) as LevelSetLoaderFunction +} + +export function buildFileListIndex(fileList: File[]): string[] { + return fileList.map(file => getFilePath(file)) +} + +export function makeLoaderWithPrefix( + prefix: string, + loader: LevelSetLoaderFunction +): LevelSetLoaderFunction { + return ((path: string, binary: boolean) => { + const joinedPath = normalize(join(prefix, path)) + return loader(joinedPath, binary) + }) as LevelSetLoaderFunction +} + +interface DirEntry { + path: string + data: string +} + +export async function findEntryFilePath( + loaderFunction: LevelSetLoaderFunction, + fileIndex: string[] +): Promise { + // Use `loaderFunction` and `rootIndex` to figure out which files are entry + // scripts (have the header closed string) + const c2gFileNames = fileIndex.filter(path => path.endsWith(".c2g")) + const c2gDirEntPromises = c2gFileNames.map>(async path => { + const scriptData = (await loaderFunction(path, false)) as string + return { path, data: scriptData } + }) + const maybeC2gFiles = await Promise.all(c2gDirEntPromises) + const c2gFiles = maybeC2gFiles.filter( + ent => findScriptName(ent.data) !== null + ) + + if (c2gFiles.length > 1) { + c2gFiles.sort((a, b) => a.path.length - b.path.length) + + console.warn( + "There appear to be multiple entry script files. Picking the one with the shortest path..." + ) + } + if (c2gFiles.length < 1) + throw new Error( + `Given file source doesn't appear to contain a main script, searched through these files: ${fileIndex.join(", ")}` + ) + return { loaderFunction, scriptFile: c2gFiles[0].path } +} + +export function makeFsFileLoader(basePath: string): LevelSetLoaderFunction { + const decoder = new TextDecoder("utf-8") + const caseResolver = new CaseResolver() + return (async (path: string, binary: boolean) => { + const data = await readFile( + await caseResolver.resolve(join(basePath, normalize("/" + path))) + ) + return binary ? data : decoder.decode(data) + }) as LevelSetLoaderFunction +} + +export async function makeSetDataFromFsPath( + basePath: string +): Promise { + return findEntryFilePath( + makeFsFileLoader(basePath), + await findAllFiles(basePath) + ) +} + +export interface ImportantSetInfo { + setIdent: string + setName: string + acquireInfo?: { url: string; term: string } + scoreboardHasScores?: boolean +} +export const IMPORTANT_SETS: ImportantSetInfo[] = [ + { + setIdent: "cc1", + scoreboardHasScores: false, + setName: "Chips Challenge", + acquireInfo: { + url: "https://store.steampowered.com/app/346850/Chips_Challenge_1", + term: "download it for free from", + }, + }, + { + setIdent: "cc2", + setName: "Chips Challenge 2", + acquireInfo: { + url: "https://store.steampowered.com/app/348300/Chips_Challenge_2", + term: "buy it on", + }, + }, + { + setIdent: "cc2lp1", + setName: "Chips Challenge 2 Level Pack 1", + }, +] diff --git a/gamePlayer/src/setManagement.tsx b/gamePlayer/src/setManagement.tsx new file mode 100644 index 00000000..8868ceea --- /dev/null +++ b/gamePlayer/src/setManagement.tsx @@ -0,0 +1,421 @@ +import { Getter, Setter, atom } from "jotai" +import { + IMPORTANT_SETS, + makeSetDataFromFsPath, + makeSetDataFromZip, +} from "./setLoading" +import { + exists, + isDir, + isFile, + makeDirP, + move, + readDir, + readFile, + readJson, + recusiveRemove, + writeFile, + writeJson, +} from "./fs" +import { + BBClubSet, + BBClubSetsRepository, + BB_CLUB_SETS_URL, + getBBClubSetUpdated, +} from "./setsApi" +import { LevelSetData, ScriptMetadata, parseScriptMetadata } from "@notcc/logic" +import { + join as joinPath, + normalize as normalizePath, + parse as parsePath, +} from "path" +import { PromptComponent, showPromptGs } from "./prompts" +import { Dialog } from "./components/Dialog" +import { Expl } from "./components/Expl" +import { aiFilter, aiGather } from "./helpers" +import { useMemo } from "preact/hooks" + +export interface LocalBBClubLinkData { + setIdent: string + id: number + /** + * A Unix timestamp + */ + lastUpdated: number +} + +interface BBClubSetData { + set: BBClubSet + repo: BBClubSetsRepository +} + +interface BuiltinSetData { + link: string + name: string +} +interface LocalSetData { + loadData: () => Promise + path: string +} + +export interface ItemLevelSet { + setName: string + setIdent: string + setKey: string + bbClubSet?: BBClubSetData + localSet?: LocalSetData + localBBClubSet?: LocalBBClubLinkData + builtinSet?: BuiltinSetData + // hidden: boolean + // lastPlayed?: Date +} + +export const localSetsChangedAtom = atom(Symbol()) +export function announceLocalSetsChangedGs(_get: Getter, set: Setter) { + set(localSetsChangedAtom, Symbol()) +} + +const BB_CLUB_SETS_LINK_FILE = "/sets/bb-club-sets.json" +type BbClubLinks = Record + +async function getLocalSetData(path: string): Promise { + if (await isDir(path)) return makeSetDataFromFsPath(path) + else if (path.endsWith(".zip")) + return makeSetDataFromZip(await readFile(path)) + else { + throw new Error("Can't load local set") + } +} + +export async function findLocalSet( + setIdent: string +): Promise { + const dirPath = joinPath("/sets", setIdent) + const hasDir = (await exists(dirPath)) && (await isDir(dirPath)) + const zipPath = joinPath("/sets", `${setIdent}.zip`) + const hasZip = (await exists(zipPath)) && (await isFile(zipPath)) + + if (hasDir && hasZip) + throw new Error(`Local set ${setIdent} has both a zip file and a directory`) + else if (!hasDir && !hasZip) return null + const setPath = hasDir ? dirPath : zipPath + + const setData = await getLocalSetData(setPath) + + const setMetadata = parseScriptMetadata( + (await setData.loaderFunction(setData.scriptFile, false)) as string + ) + return { + setName: setMetadata.title, + setIdent, + setKey: `local-${setIdent}`, + localSet: { + path: setPath, + loadData: () => getLocalSetData(setPath), + }, + } +} + +export async function removeLocalSet( + set: ItemLevelSet, + removeBbClubLink = true +): Promise { + if (!set.localSet) throw new Error("Cannot remove a non-local set!") + if (removeBbClubLink && (await exists(BB_CLUB_SETS_LINK_FILE))) { + const bbClubLinks: BbClubLinks = await readJson(BB_CLUB_SETS_LINK_FILE) + delete bbClubLinks[set.setIdent] + await writeJson(BB_CLUB_SETS_LINK_FILE, bbClubLinks) + } + await recusiveRemove(set.localSet.path) +} + +export async function* findAllLocalSets(): AsyncIterableIterator { + const bbClubSetLink: BbClubLinks = (await exists(BB_CLUB_SETS_LINK_FILE)) + ? await readJson(BB_CLUB_SETS_LINK_FILE) + : {} + for (let dirEnt of await readDir("/sets")) { + // The set ident won't have the zip extension + if (dirEnt.endsWith(".zip")) dirEnt = dirEnt.slice(0, -4) + + const localSet = await findLocalSet(dirEnt) + // If we can't find a set in that entry, it's probably not actually a set + if (!localSet) continue + + const bbClubLink = bbClubSetLink[localSet.setIdent] + if (bbClubLink) { + localSet.localBBClubSet = bbClubLink + } + yield localSet + } +} + +async function saveBBClubSetLocally( + set: ItemLevelSet, + zip: ArrayBuffer +): Promise { + const bbclubSet = set.bbClubSet?.set + if (!bbclubSet) throw new Error("Trying to save a non-bb.club set") + const setPath = `/sets/${bbclubSet.pack_name}.zip` + await writeFile(setPath, zip) + let localBBClubSets: Record = {} + if (await exists(BB_CLUB_SETS_LINK_FILE)) { + localBBClubSets = await readJson(BB_CLUB_SETS_LINK_FILE) + } + const localBBClubLink: LocalBBClubLinkData = { + id: bbclubSet.id, + setIdent: bbclubSet.pack_name, + lastUpdated: getBBClubSetUpdated(bbclubSet).getTime(), + } + + localBBClubSets[bbclubSet.pack_name] = localBBClubLink + set.localBBClubSet = localBBClubLink + set.localSet = { + loadData: () => getLocalSetData(setPath), + path: setPath, + } + await writeJson(BB_CLUB_SETS_LINK_FILE, localBBClubSets) +} + +export async function downloadAndOverwriteBBClubSetGs( + get: Getter, + set: Setter, + levelSet: ItemLevelSet, + reportProgress?: (progress: number) => void +) { + if (!levelSet.bbClubSet) + throw new Error("Can't download a set item which isn't from bb.club") + const zip = await levelSet.bbClubSet.repo.downloadSet( + levelSet.bbClubSet.set.id, + reportProgress + ) + await saveBBClubSetLocally(levelSet, zip) + announceLocalSetsChangedGs(get, set) +} + +export async function downloadBBClubSetGs( + get: Getter, + set: Setter, + levelSet: ItemLevelSet, + reportProgress?: (progress: number) => void +) { + const setIsFine = await findLocalSetConflictsGs( + get, + set, + levelSet.setIdent, + levelSet.setName + ) + if (!setIsFine) return false + await downloadAndOverwriteBBClubSetGs(get, set, levelSet, reportProgress) + return true +} + +export async function getSetMetadata( + set: ItemLevelSet +): Promise { + if (!set.localSet) return null + const setData = await set.localSet.loadData() + const entryScript = (await setData.loaderFunction( + setData.scriptFile, + false + )) as string + return parseScriptMetadata(entryScript) +} + +const MAX_LOCAL_SET_NAME_SUBSTITUTES = 10000 + +async function findSubstituteLocalName( + replacementName: string +): Promise { + if (!(await findLocalSet(replacementName))) return replacementName + let suffixNum = 2 + while ( + suffixNum < MAX_LOCAL_SET_NAME_SUBSTITUTES && + (await findLocalSet(replacementName + suffixNum)) + ) + suffixNum += 1 + if (suffixNum === MAX_LOCAL_SET_NAME_SUBSTITUTES) + throw new Error( + "Okay, what are you doing. Why do you have 10000 sets formatted specifically to break NotCC. Stop." + ) + return replacementName + suffixNum +} + +export async function moveLocalSet(set: ItemLevelSet, newIdent: string) { + if (!set.localSet) throw new Error("Cannot move a non-local set") + if (await isDir(set.localSet.path)) { + // This is a dir, no ext + await move(set.localSet.path, `/sets/${newIdent}`) + } else { + // This is a zip, append .zip + await move(set.localSet.path, `/sets/${newIdent}.zip`) + } + // Change the local-bb.club reference + if (set.localBBClubSet) { + const bbClubLinks: BbClubLinks = await readJson(BB_CLUB_SETS_LINK_FILE) + bbClubLinks[newIdent] = bbClubLinks[set.setIdent] + delete bbClubLinks[set.setIdent] + await writeJson(BB_CLUB_SETS_LINK_FILE, bbClubLinks) + } +} + +export const SetIdentExpl = () => ( + + A set identifier is a unique name of the set which NotCC uses for the set's + directory/file name. + +) + +const SameIdentSetExistsPrompt = + ( + newSetName: string, + oldSet: ItemLevelSet + ): PromptComponent<"overwrite" | "rename local" | "cancel"> => + pProps => { + return ( + pProps.onResolve("overwrite")], + ["Rename local", () => pProps.onResolve("rename local")], + ["Cancel", () => pProps.onResolve("cancel")], + ]} + onClose={() => pProps.onResolve("cancel")} + > + The set you're trying to add, "{newSetName}", has the same set ident{" "} + as the local set "{oldSet.setName}", which has been + previously loaded from a local file or directory. To add the new set, + you must do one of the following: +
      +
    • + Overwrite - if the added set is an newer version of the local set, + you can replace the local set with the new set. +
    • +
    • + Rename local - if the local set is unrelated to the new set and you + wish to keep both sets, the local set's ident can be changed to + allow both to be stored. +
    • +
    +
    + ) + } + +const SameTitleSetExistsPrompt = + (newSetName: string): PromptComponent<"continue" | "overwrite" | "cancel"> => + pProps => { + return ( + pProps.onResolve("continue")], + ["Overwrite", () => pProps.onResolve("overwrite")], + ["Cancel", () => pProps.onResolve("cancel")], + ]} + onClose={() => pProps.onResolve("cancel")} + > + It appears that a set with the title of "{newSetName}" already exists. + Multiple sets with the same title will use the same save file, which is + generally undersirable behavior. If this set is a new version of the + local set, you can overwrite this set. + + ) + } + +export async function findLocalSetConflictsGs( + get: Getter, + set: Setter, + newIdent: string, + newSetName: string +): Promise { + const sameIdentSet = await findLocalSet(newIdent) + if (sameIdentSet) { + const promptRes = await showPromptGs( + get, + set, + SameIdentSetExistsPrompt(newSetName, sameIdentSet) + ) + if (promptRes === "cancel") return false + else if (promptRes === "overwrite") { + await removeLocalSet(sameIdentSet) + } else if (promptRes === "rename local") { + const newName = await findSubstituteLocalName( + sameIdentSet.setIdent + "-local" + ) + await moveLocalSet(sameIdentSet, newName) + announceLocalSetsChangedGs(get, set) + } + } + const sameTitleSets = await aiGather( + aiFilter(findAllLocalSets(), set => set.setName === newSetName) + ) + if (sameTitleSets.length > 0) { + const promptRes = await showPromptGs( + get, + set, + SameTitleSetExistsPrompt(newSetName) + ) + if (promptRes === "cancel") return false + else if (promptRes === "overwrite") { + for (const set of sameTitleSets) { + await removeLocalSet(set) + } + announceLocalSetsChangedGs(get, set) + } else if (promptRes === "continue") { + } + } + return true +} + +async function saveFilesAtDir(files: File[], dir: string) { + for (const file of files) { + let pathStr = normalizePath(file.webkitRelativePath) + // Have to strip out the top dir, since that's just the directory the files were in when they were uploaded + // XXX: What if someone load the root directory (or a whole Windows drive), wouldn't there be no dir then? + pathStr = pathStr.split("/").slice(1).join("/") + + const path = parsePath(pathStr) + await makeDirP(joinPath(dir, path.dir)) + await writeFile(joinPath(dir, pathStr), await file.arrayBuffer()) + } +} + +export async function saveFilesLocallyGs( + get: Getter, + set: Setter, + files: File[], + setTitle: string +): Promise<{ setIdent: string } | null> { + let setIdent = files[0].webkitRelativePath.split("/")[0] + // Okay this is cursed but bear with me: if this is actually an important set (as identified by + // the set name), move it to the "correct" set ident so that links + // like "/play/cc1/123" work even if you loaded CC1 as CC1STEAM or whatever + const importantSet = IMPORTANT_SETS.find(set => set.setName === setTitle) + if (importantSet) { + setIdent = importantSet.setIdent + } + + const setIsFine = await findLocalSetConflictsGs(get, set, setIdent, setTitle) + if (!setIsFine) return null + await saveFilesAtDir(files, `/sets/${setIdent}`) + + announceLocalSetsChangedGs(get, set) + return { setIdent } +} + +export async function fetchBBClubSets(url: string): Promise { + const repo = new BBClubSetsRepository(url) + await repo.loadInitCache() + if (!navigator.onLine) throw new Error("Browser appears to be offline") + return (await repo.getSets()).map(set => ({ + setName: set.display_name ?? set.pack_name, + setIdent: set.pack_name, + setKey: `bb.club-${set.id}`, + bbClubSet: { repo, set }, + })) +} + +export function useBBClubSetsPromise() { + // TODO: Allow changing the URL + const repoPromise = useMemo(() => fetchBBClubSets(BB_CLUB_SETS_URL), []) + return repoPromise +} diff --git a/gamePlayer/src/setsApi.ts b/gamePlayer/src/setsApi.ts new file mode 100644 index 00000000..9a204d76 --- /dev/null +++ b/gamePlayer/src/setsApi.ts @@ -0,0 +1,160 @@ +import { parseC2M } from "@notcc/logic" +import { + BasicSemaphore, + progressiveBodyDownload, + resErrorToString, +} from "./helpers" +import { LevelData } from "./levelData" +import { exists, readFile, readJson, remove, writeFile, writeJson } from "./fs" + +export interface BBClubSet { + id: number + pack_name: string + display_name: string + game: "CC1" | "CC2" + pack_type: string + level_count: number + description: string | null + release_date: string + last_updated: string + file_name: string + file_size: number + download_url: string +} +export function getBBClubSetReleased(set: BBClubSet) { + return new Date(set.release_date + "Z") +} +export function getBBClubSetUpdated(set: BBClubSet) { + return new Date(set.last_updated + "Z") +} + +export interface BBClubLevel { + level: number + name: string | null + designer: string | null + time_limit: number + chips_required: number + total_chips: number + width: number + height: number + hint: string | null + download_url: string | null +} + +export const BB_CLUB_SETS_URL = "https://api.bitbusters.club/custom-packs" +const BB_CLUB_SETS_CACHE_PATH = "cache/bb-club-sets.json" + +function getPreviewFilePath(set: string) { + return `cache/bb-club-preview-${set}.c2m` +} + +export class BBClubSetsRepository { + fetchSemaphore = new BasicSemaphore(5) + sets: BBClubSet[] | null = null + constructor(public apiUrl: string) {} + async loadInitCache(): Promise { + if (await exists(BB_CLUB_SETS_CACHE_PATH)) { + this.sets = await readJson(BB_CLUB_SETS_CACHE_PATH) + } + } + async setCache(sets: BBClubSet[]): Promise { + this.sets = sets + await writeJson(BB_CLUB_SETS_CACHE_PATH, sets) + } + getUrl(path: string) { + return `${this.apiUrl}${path}` + } + async fetch(path: string): Promise { + await this.fetchSemaphore.enter() + const res = await fetch(this.getUrl(path)) + if (!res.ok) { + this.fetchSemaphore.leave() + throw new Error( + `Failed to fetch from the sets API: ${await resErrorToString(res)}` + ) + } + const json = await res.json() + this.fetchSemaphore.leave() + return json + } + // Fetches the sets raw, without any cache invalidation logic + async _getSetsRaw(): Promise { + return ((await this.fetch("/cc2")) as BBClubSet[]).filter( + // Filter out sets without a download link, since we can't do anything with those + set => set.download_url !== null + ) + } + async getSets(): Promise { + const oldSets = this.sets + const newSets = await this._getSetsRaw() + // Compare the old and new set listings, and refetch previews for all new and updated sets + // (user-updatable sets are handled separately) + const changedSets = newSets.filter(newSet => { + const oldSet = oldSets?.find(oldSet => oldSet.id === newSet.id) + return ( + !oldSet || new Date(newSet.last_updated) > new Date(oldSet.last_updated) + ) + }) + // Remove outdated preview files + for (const set of changedSets) { + const previewFile = getPreviewFilePath(set.pack_name) + if (await exists(previewFile)) { + await remove(previewFile) + } + } + // Old commit the new sets listing after removing the previews, so that we can be sure that all the previews are up to date even if we crash mid-fetch + await this.setCache(newSets) + return newSets + } + async getSet(id: number): Promise { + const sets = this.sets ?? (await this.getSets()) + const set = sets.find(set => set.id === id) + if (!set) throw new Error(`No set with id ${id}`) + return set + } + async getSetPreview(id: number): Promise { + // If we have the preview file cached already, don't download it again + const set = await this.getSet(id) + const previewFile = getPreviewFilePath(set.pack_name) + if (set && (await exists(previewFile!))) { + const file = await readFile(previewFile!) + return new LevelData(parseC2M(file)) + } + const levels: BBClubLevel[] = await this.fetch(`/cc2/${id}/levels`) + const firstLevelUrl = levels[0].download_url + // Should never happen, since sets without a `download_url` were filtered out earlier + if (firstLevelUrl === null) return null + const res = await fetch(firstLevelUrl) + if (!res.ok) + throw new Error( + `Failed to download first level: ${await resErrorToString(res)}` + ) + const levelBuf = await res.arrayBuffer() + if (levelBuf.byteLength === 0) + throw new Error( + "Failed to download set preview: server sent empty response" + ) + await writeFile(previewFile, levelBuf) + return new LevelData(parseC2M(levelBuf)) + } + /** + * @returns The levelset in a Zip archive + * */ + async downloadSet( + id: number, + reportProgress?: (progress: number) => void + ): Promise { + // No caching, if this is called, the user knows what they want + const set = await this.getSet(id) + await this.fetchSemaphore.enter() + const res = await fetch(set.download_url) + if (!res.ok) { + this.fetchSemaphore.leave() + throw new Error(`Failed to download set: ${await resErrorToString(res)}`) + } + return progressiveBodyDownload(res, reportProgress).catch(err => { + this.fetchSemaphore.leave() + throw err + }) + } +} diff --git a/gamePlayer/src/settings.ts b/gamePlayer/src/settings.ts deleted file mode 100644 index 0ad6ec4e..00000000 --- a/gamePlayer/src/settings.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Pager } from "./pager" -import clone from "clone" -import { ThemeColors, applyTheme, openThemeSelectorDialog } from "./themes" -import { resetListeners } from "./utils" -import { - getTilesetMetadataFromIdentifier, - openTilesetSelectortDialog, -} from "./tilesets" -import { getPlayerSummary } from "./scoresApi" - -export type SetListPreviewLevel = "title" | "level preview" -export type ExaIntegerTimeRounding = "floor" | "floor + 1" | "ceil" - -export interface Settings { - mainTheme: ThemeColors - tileset: string - preventNonLegalGlitches: boolean - preventSimultaneousMovement: boolean - optimizerId?: number - exaIntegerTimeRounding: ExaIntegerTimeRounding -} - -export const defaultSettings: Settings = { - mainTheme: { hue: 212, saturation: 80 }, - tileset: "cga16", - preventNonLegalGlitches: true, - preventSimultaneousMovement: true, - exaIntegerTimeRounding: "ceil", -} - -const settingsDialog = - document.querySelector("#settingsDialog")! - -export function openSettingsDialog(pager: Pager): void { - resetListeners(settingsDialog) - const newSettings = clone(pager.settings) - function makeSettingsPreference( - id: string, - event: string, - call: (el: T) => void | Promise, - apply: (el: T) => void - ): void { - const el = settingsDialog.querySelector(`#${id}`)! - el.addEventListener(event, async () => { - await call(el) - apply(el) - }) - apply(el) - } - - makeSettingsPreference( - "mainTheme", - "click", - () => - openThemeSelectorDialog(newSettings.mainTheme, pager).then(color => { - if (color !== null) { - newSettings.mainTheme = color - } - }), - el => applyTheme(el, newSettings.mainTheme) - ) - - const currentTilesetText = settingsDialog.querySelector( - "#currentTilesetText" - )! - makeSettingsPreference( - "currentTileset", - "click", - () => - openTilesetSelectortDialog(newSettings.tileset).then(tset => { - if (tset !== null) { - newSettings.tileset = tset - } - }), - async () => { - const tsetMeta = await getTilesetMetadataFromIdentifier( - newSettings.tileset - ) - currentTilesetText.textContent = tsetMeta?.title ?? "Unknown tileset" - } - ) - - makeSettingsPreference( - "preventNonLegalGlitches", - "change", - el => { - newSettings.preventNonLegalGlitches = el.checked - }, - el => { - el.checked = newSettings.preventNonLegalGlitches - } - ) - - makeSettingsPreference( - "preventSimulMovement", - "change", - el => { - newSettings.preventSimultaneousMovement = el.checked - }, - el => { - el.checked = newSettings.preventSimultaneousMovement - } - ) - const currentUsername = settingsDialog.querySelector("#currentUsername")! - makeSettingsPreference( - "optimizerId", - "change", - el => { - if (el.value !== "") { - newSettings.optimizerId = parseInt(el.value) - } else { - delete newSettings.optimizerId - } - }, - el => { - if (newSettings.optimizerId === undefined) { - el.value = "" - currentUsername.textContent = "" - } else { - el.value = newSettings.optimizerId.toString() - currentUsername.textContent = "..." - getPlayerSummary(newSettings.optimizerId) - .then(info => { - currentUsername.textContent = info.player - }) - .catch(() => { - currentUsername.textContent = "???" - }) - } - } - ) - makeSettingsPreference( - "integerTimeRounding", - "change", - el => { - newSettings.exaIntegerTimeRounding = el.value as ExaIntegerTimeRounding - }, - el => { - el.value = newSettings.exaIntegerTimeRounding - } - ) - - const closeListener = () => { - if (settingsDialog.returnValue === "ok") { - pager.saveSettings(newSettings) - } - settingsDialog.removeEventListener("close", closeListener) - } - - settingsDialog.addEventListener("close", closeListener) - - settingsDialog.showModal() -} diff --git a/gamePlayer/src/sfx.ts b/gamePlayer/src/sfx.ts index b2576527..22253d76 100644 --- a/gamePlayer/src/sfx.ts +++ b/gamePlayer/src/sfx.ts @@ -1,52 +1,68 @@ -import { SfxManager } from "@notcc/logic" -const standardSfx = [ - "recessed wall", - "explosion", - "splash", - "teleport", - "robbed", - "dirt clear", - "button press", - "block push", - "force floor", - "bump", - "water step", - "slide step", - "ice slide", - "fire step", - "item get", - "socket unlock", - "door unlock", - // TODO Win, loss SFX -] +import { + LevelSetLoaderFunction, + SFX_BITS_CONTINUOUS, + SfxBit, +} from "@notcc/logic" +export const SFX_FILENAME_MAP = { + [SfxBit.RECESSED_WALL]: "newwall", + [SfxBit.EXPLOSION]: "burn", + [SfxBit.SPLASH]: "splash", + [SfxBit.TELEPORT]: "teleport", + [SfxBit.THIEF]: "thief", + [SfxBit.DIRT_CLEAR]: "dirt", + [SfxBit.BUTTON_PRESS]: "button", + [SfxBit.BLOCK_PUSH]: "push", + [SfxBit.FORCE_FLOOR_SLIDE]: "force", + [SfxBit.PLAYER_BONK]: "wall", + [SfxBit.WATER_STEP]: "water", + [SfxBit.SLIDE_STEP]: "ice", + [SfxBit.ICE_SLIDE]: "slide", + [SfxBit.FIRE_STEP]: "fire", + [SfxBit.ITEM_PICKUP]: "get", + [SfxBit.SOCKET_UNLOCK]: "socket", + [SfxBit.DOOR_UNLOCK]: "door", + [SfxBit.CHIP_WIN]: "teleport-male", + [SfxBit.MELINDA_WIN]: "teleport-female", + [SfxBit.CHIP_DEATH]: "BummerM", + [SfxBit.MELINDA_DEATH]: "BummerF", +} -export class AudioSfxManager implements SfxManager { +async function tryGetSfxFile( + loader: LevelSetLoaderFunction, + sfxName: string +): Promise { + for (const ext of ["ogg", "wav", "WAV"]) { + try { + return (await loader(`${sfxName}.${ext}`, true)) as ArrayBuffer + } catch { + continue + } + } + return null +} + +export class AudioSfxManager { ctx = new AudioContext() - audioBuffers: Record = {} - playingNodes: Record = {} - async fetchDefaultSounds(url: string): Promise { + audioBuffers: Partial> = {} + playingNodes: Partial> = {} + async loadSfx(loader: LevelSetLoaderFunction): Promise { + this.stopAllSfx() + this.audioBuffers = {} let anySfxLoaded = false - for (const sfxName of standardSfx) { - try { - const res = await fetch(`${url}/${sfxName}.wav`) - if (!res.ok) throw new Error(`Failed to fetch: ${res.statusText}`) - const buffer = await res.arrayBuffer() - const audioBuffer = await this.ctx.decodeAudioData(buffer) - this.audioBuffers[sfxName] = audioBuffer - anySfxLoaded = true - } catch (err) { - console.error(`Couldn't load standard sound effect ${sfxName}: ${err}`) - } + for (const [internalName, fileName] of Object.entries(SFX_FILENAME_MAP)) { + const buffer = await tryGetSfxFile(loader, fileName) + if (!buffer) continue + const audioBuffer = await this.ctx.decodeAudioData(buffer) + this.audioBuffers[parseInt(internalName) as SfxBit] = audioBuffer + anySfxLoaded = true + } + if (!anySfxLoaded) { + throw new Error("Couldn't load any sfx") } - if (!anySfxLoaded) - throw new Error("Couldn't load any standard sfx from url.") } - getSfxNode(sfx: string): AudioBufferSourceNode | null { + getSfxNode(sfx: SfxBit): AudioBufferSourceNode | null { const audioBuffer = this.audioBuffers[sfx] if (audioBuffer === undefined) { - if (!standardSfx.includes(sfx)) { - console.warn(`Unknown sfx: ${sfx}`) - } return null } const node = new AudioBufferSourceNode(this.ctx) @@ -55,14 +71,14 @@ export class AudioSfxManager implements SfxManager { this.playingNodes[sfx] = node return node } - stopSfx(sfx: string): void { + stopSfx(sfx: SfxBit): void { const node = this.playingNodes[sfx] if (node === undefined) return node.stop() node.disconnect() delete this.playingNodes[sfx] } - playOnce(sfx: string): void { + playOnce(sfx: SfxBit): void { this.stopSfx(sfx) const node = this.getSfxNode(sfx) if (node === null) return @@ -73,19 +89,40 @@ export class AudioSfxManager implements SfxManager { }) node.start() } - playContinuous(sfx: string): void { + playContinuous(sfx: SfxBit): void { if (this.playingNodes[sfx] !== undefined) return const node = this.getSfxNode(sfx) if (node === null) return node.loop = true node.start() } - stopContinuous(sfx: string): void { - this.stopSfx(sfx) + isSfxPlaying(sfx: SfxBit): boolean { + return sfx in this.playingNodes } - stopAllSfx(): void { - for (const sfxName of Object.keys(this.playingNodes)) { - this.stopSfx(sfxName) + stopAllSfx() { + for (const node of Object.values(this.playingNodes)) { + node.stop() + node.disconnect() } + this.playingNodes = {} + } + processSfxField(field: number): void { + for (let bit = SfxBit.FIRST; bit <= SfxBit.LAST; bit <<= 1) { + const runSfx = bit & field + const isContinuous = bit & SFX_BITS_CONTINUOUS + if (!isContinuous) { + if (runSfx) this.playOnce(bit) + } else { + const wasRunning = this.isSfxPlaying(bit) + if (wasRunning && !runSfx) this.stopSfx(bit) + else if (!wasRunning && runSfx) this.playContinuous(bit) + } + } + } + pause() { + return this.ctx.suspend() + } + unpause() { + return this.ctx.resume() } } diff --git a/gamePlayer/src/sidebar.ts b/gamePlayer/src/sidebar.ts deleted file mode 100644 index e0003622..00000000 --- a/gamePlayer/src/sidebar.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { Page, Pager } from "./pager" -import isHotkey, { parseHotkey } from "is-hotkey" -import { setSelectorPage } from "./pages/setSelector" -import { levelPlayerPage } from "./pages/levelPlayer" -import { openLevelListDialog } from "./levelList" -import { openSettingsDialog } from "./settings" -import { instanciateTemplate } from "./utils" -import { exaPlayerPage } from "./pages/exaPlayer" -import { openAllAttemptsDialog } from "./allAttemptsDialog" -import { generateSolutionTooltipEntries } from "./solutionTooltip" - -interface TooltipEntry { - name: string - shortcut: string | null - action?(pager: Pager): void - enabledPages?: Page[] -} - -export type BasicTooltipEntry = TooltipEntry | "breakline" - -type TooltipEntryGenerator = (pager: Pager) => BasicTooltipEntry[] - -type TooltipEntries = (BasicTooltipEntry | TooltipEntryGenerator)[] - -const playerPages = [levelPlayerPage, exaPlayerPage] - -const aboutDialog = document.querySelector("#aboutDialog") - -function openAboutDialog(): void { - aboutDialog?.showModal() -} - -export const tooltipGroups: Record = { - selector: [ - { - name: "Set selector", - shortcut: "esc", - action(pager: Pager): void { - pager.openPage(setSelectorPage) - }, - }, - ], - level: [ - { - name: "Reset level", - shortcut: "shift+r", - action(pager: Pager): void { - pager.resetLevel() - }, - enabledPages: playerPages, - }, - { - name: "Pause", - shortcut: "p", - action(pager: Pager): void { - if (pager.currentPage === levelPlayerPage) { - const page = pager.currentPage as typeof levelPlayerPage - page.togglePaused() - } - }, - enabledPages: [levelPlayerPage], - }, - "breakline", - { - name: "Previous level", - shortcut: "shift+p", - async action(pager: Pager): Promise { - await pager.loadPreviousLevel() - pager.reloadLevel() - }, - enabledPages: playerPages, - }, - { - name: "Next level", - shortcut: "shift+n", - async action(pager: Pager): Promise { - await pager.loadNextLevel({ type: "skip" }) - pager.reloadLevel() - }, - enabledPages: playerPages, - }, - { - name: "Level list", - shortcut: "shift+s", - action(pager: Pager): void { - if (pager.loadedSet) { - openLevelListDialog(pager) - } - }, - enabledPages: playerPages, - }, - ], - solution: [ - generateSolutionTooltipEntries, - "breakline", - { - name: "All attempts", - shortcut: "shift+a", - action(pager: Pager): void { - openAllAttemptsDialog(pager) - }, - }, - ], - optimization: [ - { - name: "Toggle ExaCC", - shortcut: "shift+x", - action(pager: Pager): void { - if (pager.currentPage !== exaPlayerPage) { - pager.openPage(exaPlayerPage) - } else { - pager.openPage(levelPlayerPage) - } - }, - enabledPages: playerPages, - }, - { - name: "Auto skip", - shortcut: "a", - action(pager: Pager): void { - if (pager.currentPage !== exaPlayerPage) return - exaPlayerPage.autoSkip() - }, - enabledPages: [exaPlayerPage], - }, - { - name: "Undo", - shortcut: "Backspace", - action(pager: Pager): void { - if (pager.currentPage !== exaPlayerPage) return - exaPlayerPage.undo() - }, - enabledPages: [exaPlayerPage], - }, - { - name: "Redo", - shortcut: "Enter", - action(pager: Pager): void { - if (pager.currentPage !== exaPlayerPage) return - exaPlayerPage.redo() - }, - enabledPages: [exaPlayerPage], - }, - { - name: "Import route", - shortcut: "shift+i", - action(pager: Pager): void { - if (pager.currentPage !== exaPlayerPage) return - exaPlayerPage.importRoute(pager) - }, - enabledPages: [exaPlayerPage], - }, - { - name: "Export route", - shortcut: "shift+e", - action(pager: Pager): void { - if (pager.currentPage !== exaPlayerPage) return - exaPlayerPage.exportRoute(pager) - }, - enabledPages: [exaPlayerPage], - }, - ], - settings: [ - { - name: "Settings", - shortcut: "shift+c", - action(pager: Pager): void { - openSettingsDialog(pager) - }, - }, - ], - about: [ - { - name: "About", - shortcut: null, - action(_pager: Pager): void { - openAboutDialog() - }, - }, - ], -} - -function isTooltipEntryDisabled( - pager: Pager, - tooltipEntry: TooltipEntry -): boolean { - return ( - tooltipEntry.action === undefined || - (tooltipEntry.enabledPages !== undefined && - !tooltipEntry.enabledPages.includes(pager.currentPage)) - ) -} - -const tooltipTemplate = - document.querySelector("#tooltipTemplate")! - -export function openTooltip( - pager: Pager, - tooltipContents: TooltipEntries, - at: HTMLElement -): void { - if (tooltipContents.length === 0) return - const tooltipRoot = instanciateTemplate(tooltipTemplate) - const tooltipInsertionPoint = - tooltipRoot.querySelector(".buttonTooltipBox")! - - tooltipInsertionPoint.tabIndex = 0 - - let firstRow: HTMLElement | undefined - - function closeTooltip(): void { - tooltipRoot.style.animation = `closeTooltip 0.4s ease-in` - tooltipRoot.addEventListener("animationend", () => { - tooltipRoot.remove() - }) - } - const basicTooltipEntries = tooltipContents - .map(ent => (typeof ent === "function" ? ent(pager) : [ent])) - .reduce((acc, ent) => acc.concat(...ent), []) - - for (const tooltipEntry of basicTooltipEntries) { - if (tooltipEntry === "breakline") { - tooltipInsertionPoint.appendChild(document.createElement("hr")) - continue - } - const tooltipRow = document.createElement("div") - tooltipRow.classList.add("buttonTooltipRow") - - if (isTooltipEntryDisabled(pager, tooltipEntry)) { - tooltipRow.dataset.disabled = "" - } else { - tooltipRow.tabIndex = 0 - - tooltipRow.addEventListener("click", () => { - tooltipEntry.action?.(pager) - closeTooltip() - }) - if (firstRow === undefined) { - firstRow = tooltipRow - } - } - - const tooltipName = document.createElement("div") - tooltipName.classList.add("buttonTooltipItem") - tooltipName.textContent = tooltipEntry.name - tooltipRow.appendChild(tooltipName) - - if (tooltipEntry.shortcut !== null) { - const tooltipShortcut = document.createElement("div") - tooltipShortcut.classList.add("buttonTooltipKey") - - // eslint-disable-next-line no-inner-declarations - function appendKey(key: string): void { - const keyElement = document.createElement("kbd") - keyElement.textContent = key - tooltipShortcut.appendChild(keyElement) - const spaceElement = document.createTextNode(" ") - tooltipShortcut.appendChild(spaceElement) - } - - const shortcutData = parseHotkey(tooltipEntry.shortcut, { byKey: true }) - - if (shortcutData.ctrlKey) appendKey("Ctrl") - if (shortcutData.metaKey) appendKey("⌘") - if (shortcutData.shiftKey) appendKey("⇧") - if (shortcutData.altKey) appendKey("Alt") - appendKey(shortcutData.key!) - - tooltipRow.appendChild(tooltipShortcut) - } - tooltipInsertionPoint.appendChild(tooltipRow) - } - tooltipRoot.addEventListener("focusout", ev => { - const isChildFocused = tooltipRoot.contains(ev.relatedTarget as Node) - if (isChildFocused) return - closeTooltip() - }) - at.appendChild(tooltipRoot) - - if (firstRow) { - firstRow.focus() - } else { - tooltipInsertionPoint.focus() - } - - tooltipRoot.style.animation = `openTooltip 0.2s ease-out` -} - -export function generateTabButtons(pager: Pager): void { - const sidebar = document.querySelector("nav.sidebar")! - for (const [tabName, tabEntries] of Object.entries(tooltipGroups)) { - const tab = sidebar.querySelector(`#${tabName}Tab`)! - const tabButton = tab.querySelector("img")! - const handler = () => { - openTooltip(pager, tabEntries, tab) - } - tabButton.addEventListener("click", handler) - tabButton.addEventListener("keydown", ev => { - if (ev.code === "Enter" || ev.code === "Space") { - handler() - } - }) - } -} - -export function generateShortcutListener( - pager: Pager -): (ev: KeyboardEvent) => void { - const allTooltipEntries = Object.values(tooltipGroups) - .flat() - .filter((val): val is TooltipEntry => val !== "breakline") - const checkerFunctions: ((ev: KeyboardEvent) => void)[] = [] - for (const entry of allTooltipEntries) { - if (!entry.shortcut || !entry.action) continue - const verifyFunction = isHotkey(entry.shortcut) - checkerFunctions.push(ev => { - if (!verifyFunction(ev)) return - if (isTooltipEntryDisabled(pager, entry)) return - ev.preventDefault() - ev.stopPropagation() - entry.action!(pager) - }) - } - return ev => { - for (const checker of checkerFunctions) { - checker(ev) - } - } -} - -export function setSidebarLevelN(num: string): void { - const levelIconText = document.querySelector("#levelIconText") - if (levelIconText) { - levelIconText.textContent = num - } -} diff --git a/gamePlayer/src/simpleDialogs.ts b/gamePlayer/src/simpleDialogs.ts deleted file mode 100644 index 78dc663d..00000000 --- a/gamePlayer/src/simpleDialogs.ts +++ /dev/null @@ -1,60 +0,0 @@ -const simpleDialog = document.querySelector("#simpleDialog")! - -export async function showAlert(body: string, title?: string): Promise { - await showChoice(body, [["ok", "Ok"]], title) -} - -export function makeChoiceDialog( - body: string, - buttons: [key: string, text: string][], - title?: string -): HTMLDialogElement { - const dialog = simpleDialog.cloneNode(true) as HTMLDialogElement - document.body.appendChild(dialog) - - const headerEl = dialog.querySelector("header")! - const sectionEl = dialog.querySelector("section")! - const footerEl = dialog.querySelector("footer")! - - if (title !== undefined) { - headerEl.textContent = title - } - sectionEl.innerHTML = body.replace("\n", "
    ") - - for (const [key, text] of buttons) { - const button = document.createElement("button") - button.textContent = text - button.value = key - button.type = "submit" - footerEl.appendChild(button) - } - return dialog -} - -export function waitForDialogSubmit( - dialog: HTMLDialogElement, - removeOnSubmit: boolean = true -): Promise { - return new Promise(res => { - const closeListener = () => { - res(dialog.returnValue) - - if (removeOnSubmit) { - dialog.remove() - } else { - dialog.removeEventListener("close", closeListener) - } - } - dialog.addEventListener("close", closeListener) - }) -} - -export function showChoice( - body: string, - buttons: [key: I, text: string][], - title?: string -): Promise { - const dialog = makeChoiceDialog(body, buttons, title) - dialog.showModal() - return waitForDialogSubmit(dialog) as Promise -} diff --git a/gamePlayer/src/solutionTooltip.ts b/gamePlayer/src/solutionTooltip.ts deleted file mode 100644 index 219f19c9..00000000 --- a/gamePlayer/src/solutionTooltip.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { calculateLevelPoints, protobuf } from "@notcc/logic" -import { Pager } from "./pager" -import { BasicTooltipEntry } from "./sidebar" - -function protoToNum(dur: protobuf.google.protobuf.IDuration): number { - return ( - ((dur.seconds as number) ?? 0) + - (dur.nanos ? (dur.nanos as number) / 1_000_000 : 0) - ) -} - -interface TTSolutionEntry { - title: string - solution: protobuf.ISolutionInfo - showMetric: "time" | "score" | "time/score" | "custom" - customMetric?: string -} - -function getSolEntryMetric( - lvlN: number | undefined, - ent: TTSolutionEntry -): string { - if (ent.showMetric === "custom") return ent.customMetric! - if (ent.showMetric === "time") { - return `${Math.ceil(protoToNum(ent.solution.outcome!.timeLeft!))}s` - } - const score = calculateLevelPoints( - lvlN ?? 0, - Math.ceil(protoToNum(ent.solution.outcome!.timeLeft!)), - ent.solution.outcome!.bonusScore! - ) - if (ent.showMetric === "score") return `${score}pts` - const time = Math.ceil(protoToNum(ent.solution.outcome!.timeLeft!)) - return `${time}s / ${score}pts` -} - -function findAbsTime(sol: protobuf.ISolutionInfo): number { - const steps = sol.steps?.[0] - return steps!.reduce((acc, val, i) => (i % 2 === 1 ? acc + val : acc), 0) / 60 -} - -function findAttemptEnts( - lvlN: number, - attempts: protobuf.IAttemptInfo[] -): TTSolutionEntry[] { - const solutions: protobuf.ISolutionInfo[] = attempts - .filter(att => !att.failReason && att.solution) - .sort((a, b) => protoToNum(a.attemptStart!) - protoToNum(b.attemptStart!)) - .map(att => att.solution!) - - const bestTime = solutions.reduce( - (acc, val) => (val.outcome!.timeLeft! > acc.outcome!.timeLeft! ? val : acc), - solutions[0] - ) - const bestScore = solutions.reduce((acc, val) => { - const valScore = calculateLevelPoints( - lvlN, - Math.ceil(protoToNum(val.outcome!.timeLeft!)), - val.outcome!.bonusScore! - ) - const accScore = calculateLevelPoints( - lvlN, - Math.ceil(protoToNum(acc.outcome!.timeLeft!)), - acc.outcome!.bonusScore! - ) - return valScore > accScore ? val : acc - }, solutions[0]) - - const ents: TTSolutionEntry[] = [] - - if (bestTime === bestScore) { - ents.push({ title: "Best", solution: bestTime, showMetric: "time/score" }) - } else { - ents.push( - { title: "Best time", solution: bestTime, showMetric: "time" }, - { title: "Best score", solution: bestScore, showMetric: "score" } - ) - } - - const lastSol = solutions[solutions.length - 1] - - if (lastSol !== bestTime && lastSol !== bestScore) { - ents.push({ - title: "Last solution", - solution: lastSol, - showMetric: "time/score", - }) - } - - return ents.filter(tt => tt.solution !== undefined) -} - -export function generateSolutionTooltipEntries( - pager: Pager -): BasicTooltipEntry[] { - if (!pager.loadedLevel) return [{ name: "No level loaded.", shortcut: null }] - - const shownSolutions: TTSolutionEntry[] = [] - - const builtinSolution = pager.loadedLevel?.associatedSolution - if (builtinSolution) { - shownSolutions.push({ - title: "Built-in", - solution: builtinSolution, - showMetric: "custom", - customMetric: `~${Math.ceil(findAbsTime(builtinSolution))}rs`, - }) - } - - const lvlN = pager.loadedSet?.currentLevel - - if (pager.loadedSet) { - const levelRecord = pager.loadedSet.seenLevels[pager.loadedSet.currentLevel] - const attempts = levelRecord.levelInfo.attempts - - if (attempts) { - shownSolutions.push(...findAttemptEnts(lvlN!, attempts)) - } - } - - if (shownSolutions.length === 0) - return [{ name: "No solutions found.", shortcut: null }] - - return shownSolutions.map(solEntry => ({ - name: `${solEntry.title} - ${getSolEntryMetric(lvlN, solEntry)}`, - shortcut: null, - action() { - pager.loadSolution(solEntry.solution) - }, - })) -} diff --git a/gamePlayer/src/spinner.gif b/gamePlayer/src/spinner.gif new file mode 100644 index 00000000..3818cd42 Binary files /dev/null and b/gamePlayer/src/spinner.gif differ diff --git a/gamePlayer/src/sw.js b/gamePlayer/src/sw.js new file mode 100644 index 00000000..a5c4586e --- /dev/null +++ b/gamePlayer/src/sw.js @@ -0,0 +1,68 @@ +const urls = __SW_FILES + .concat(".") + .map(ent => new URL(ent, self.location.href).href) + +const MAIN_CACHE = "v1" + +function until(f) { + return ev => { + ev.waitUntil(f(ev)) + } +} +function respond(f) { + return ev => { + ev.respondWith(f(ev)) + } +} + +let fetchCache = null +async function getCache() { + if (!fetchCache) { + fetchCache = await caches.open(MAIN_CACHE) + } + return fetchCache +} + +// Download all files when installed +self.addEventListener( + "install", + until(async () => { + self.skipWaiting() + if (navigator.onLine) { + await (await getCache()).addAll(urls.map(url => new Request(url))) + } + }) +) + +// Cleanup - remove entries that aren't present in new manifest +self.addEventListener( + "activate", + until(async () => { + const cache = await getCache() + for (const req of await cache.keys()) { + if (urls.includes(req.url)) continue + await cache.delete(req) + } + await clients.claim() + }) +) + +self.addEventListener( + "fetch", + respond(async ev => { + const url = new URL(ev.request.url) + if (!urls.includes(url.href)) + return fetch(ev.request).catch(() => Response.error()) + + if (navigator.onLine) { + const res = await fetch(ev.request).catch(() => null) + if (res?.ok) { + await (await getCache()).put(ev.request, res.clone()) + return res + } + } + const res = await (await getCache()).match(ev.request) + if (res?.ok) return res + return Response.error() + }) +) diff --git a/gamePlayer/src/themeHelper.ts b/gamePlayer/src/themeHelper.ts new file mode 100644 index 00000000..9d76d309 --- /dev/null +++ b/gamePlayer/src/themeHelper.ts @@ -0,0 +1,44 @@ +import colors from "tailwindcss/colors" +import { preferenceAtom } from "./preferences" + +export const colorSchemeAtom = preferenceAtom("colorScheme", "cyan") + +const badColors = [ + "inherit", + "transparent", + "black", + "white", + "current", + "lightBlue", + "warmGray", + "coolGray", + "trueGray", + "blueGray", +] as const + +export type ThemeColor = keyof Omit +function hexToChannels(hex: string): string { + return hex + .slice(1) + .split(/(?<=^(?:.{2}|.{4}))/g) + .map(channel => parseInt(channel, 16)) + .join(" ") +} + +export function listThemeColors(): ThemeColor[] { + return Object.keys(colors).filter( + (color): color is ThemeColor => + !(badColors as readonly string[]).includes(color) + ) +} + +export function makeThemeCssVars( + colorName: ThemeColor +): Record { + return Object.fromEntries( + Object.entries(colors[colorName]).map(([colorShade, color]) => [ + `--theme-${colorShade}`, + hexToChannels(color), + ]) + ) +} diff --git a/gamePlayer/src/themes.ts b/gamePlayer/src/themes.ts deleted file mode 100644 index d259acee..00000000 --- a/gamePlayer/src/themes.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Pager } from "./pager" -import { resetListeners } from "./utils" - -export interface ThemeColors { - hue: number - saturation: number -} - -export function applyTheme(el: HTMLElement, theme: ThemeColors): void { - el.style.setProperty( - "--theme-color-huesat", - `${theme.hue}deg ${theme.saturation}%` - ) -} - -const themeSelectorDialog = document.querySelector( - "#themeSelectorDialog" -)! - -export function openThemeSelectorDialog( - defaultTheme: ThemeColors, - pager?: Pager -): Promise { - return new Promise(res => { - resetListeners(themeSelectorDialog) - const hueSlider = - themeSelectorDialog.querySelector("#themeHue")! - const saturationSlider = - themeSelectorDialog.querySelector("#themeSaturation")! - hueSlider.value = defaultTheme.hue.toString() - saturationSlider.value = defaultTheme.saturation.toString() - - function makeTheme(): ThemeColors { - return { - hue: parseInt(hueSlider.value, 10), - saturation: parseInt(saturationSlider.value, 10), - } - } - function updateTheme(): void { - const theme = makeTheme() - pager?.setTheme(theme) - } - updateTheme() - - hueSlider.addEventListener("change", updateTheme) - saturationSlider.addEventListener("change", updateTheme) - - const closeListener = () => { - res(themeSelectorDialog.returnValue === "ok" ? makeTheme() : null) - pager?.updateTheme() - themeSelectorDialog.removeEventListener("close", closeListener) - } - - themeSelectorDialog.addEventListener("close", closeListener) - - themeSelectorDialog.showModal() - }) -} diff --git a/gamePlayer/src/tilesets/index.ts b/gamePlayer/src/tilesets/index.ts deleted file mode 100644 index a9e68ff3..00000000 --- a/gamePlayer/src/tilesets/index.ts +++ /dev/null @@ -1,262 +0,0 @@ -import tworldTileset from "./tworld.png" -import cga16Tileset from "./cga16.png" -import previewLevel from "../levels/tilesetPreview.c2m" -import { - instanciateTemplate, - makeImagefromBlob, - fetchImage, - makeTd, - reencodeImage, - resetListeners, -} from "../utils" - -import { HTMLImage, Renderer, Tileset, removeBackground } from "../renderer" -import { - loadAllTilesets, - removeTileset, - saveTileset, - showLoadPrompt, -} from "../saveData" -import { cc2ArtSet } from "../cc2ArtSet" -import { createLevelFromData, parseC2M } from "@notcc/logic" -import { Pager } from "../pager" -import { defaultSettings } from "../settings" -import { showAlert } from "../simpleDialogs" - -export interface SourcelessTilesetMetadata { - identifier: string - title: string - description: string - credits: string - wireWidth: number - tileSize: number -} - -export interface BuiltinTilesetMetadata extends SourcelessTilesetMetadata { - type: "built-in" - link: string -} - -export interface ExternalTilesetMetadata extends SourcelessTilesetMetadata { - type: "external" - image: HTMLCanvasElement -} - -export type TilesetMetadata = BuiltinTilesetMetadata | ExternalTilesetMetadata - -const builtInTilesets: BuiltinTilesetMetadata[] = [ - { - type: "built-in", - identifier: "tworld", - title: "Tile World", - description: - "The tileset from Tile World, with CC2 tiles! (Note: some CC2 tiles have devart)", - credits: - "Anders Kaseorg (Original tileset), Kawaiiprincess (CC2 Adaptation), G lander (Additonal art and renders)", - tileSize: 32, - wireWidth: 2 / 32, - link: tworldTileset, - }, - { - type: "built-in", - identifier: "cga16", - title: "CGA (16 colors)", - description: `This is how this game might have looked like if it were released in the 1980s. - Not to be confused with the 4-color CC1 tileset also named CGA.`, - credits: "G lander", - tileSize: 8, - wireWidth: 2 / 8, - link: cga16Tileset, - }, -] - -export async function fetchTileset(tset: TilesetMetadata): Promise { - if (tset.type === "built-in") { - return await fetchImage(tset.link) - } else { - return tset.image - } -} - -async function getAllTilesets(): Promise { - return (builtInTilesets as TilesetMetadata[]).concat(await loadAllTilesets()) -} - -export async function makeTilesetFromMetadata( - tset: TilesetMetadata -): Promise { - let tsetImage = await fetchTileset(tset) - tsetImage = removeBackground(tsetImage) - return { - // TODO Add custom framemaps - art: cc2ArtSet, - image: tsetImage, - tileSize: tset.tileSize, - wireWidth: tset.wireWidth, - } -} - -export async function getTilesetMetadataFromIdentifier( - identifier: string -): Promise { - return ( - (builtInTilesets as TilesetMetadata[]) - .concat(await loadAllTilesets()) - .find(meta => meta.identifier === identifier) ?? null - ) -} - -export async function updatePagerTileset(pager: Pager): Promise { - let tilesetMeta = await getTilesetMetadataFromIdentifier( - pager.settings.tileset - ) - if (tilesetMeta === null) { - // Uh oh, the current tileset doesn't exist - // Try again with the default one - tilesetMeta = await getTilesetMetadataFromIdentifier( - defaultSettings.tileset - ) - if (tilesetMeta === null) { - // Welp. I guess something is really wrong. - throw new Error("Can't find any tileset metadata") - } - } - - const tileset = await makeTilesetFromMetadata(tilesetMeta) - pager.tileset = tileset -} - -const tilesetSelectDialog = document.querySelector( - "#tilesetSelectorDialog" -)! - -const tsetInfoTemplate = document.querySelector( - "#tilesetInfoTemplate" -)! - -function makeTsetInfo(tset: TilesetMetadata): HTMLSpanElement { - const tsetInfo = instanciateTemplate(tsetInfoTemplate) - - // eslint-disable-next-line no-inner-declarations - function assingTsetInfo(key: string, val: string): void { - tsetInfo.querySelector(`#tset${key}`)!.textContent = val - } - assingTsetInfo("Title", tset.title) - assingTsetInfo("Description", tset.description) - assingTsetInfo("Credits", tset.credits) - assingTsetInfo("TileSize", `${tset.tileSize}px`) - assingTsetInfo("WireWidth", `${tset.wireWidth * tset.tileSize}px`) - return tsetInfo -} - -async function makeTsetPreview(tsetMeta: TilesetMetadata) { - const tset = await makeTilesetFromMetadata(tsetMeta) - const canvas = document.createElement("canvas") - canvas.classList.add("pixelCanvas") - canvas.classList.add("tsetPreviewCanvas") - const renderer = new Renderer(tset, canvas) - const levelBuffer = await (await fetch(previewLevel)).arrayBuffer() - const levelData = parseC2M(levelBuffer) - const level = createLevelFromData(levelData) - level.forcedPerspective = true - renderer.level = level - renderer.cameraSize = { width: 5, height: 5, screens: 1 } - renderer.updateTileSize() - renderer.frame() - return canvas -} - -async function promptCustomTilesetImage(): Promise { - const files = await showLoadPrompt("Load tileset image", { - filters: [{ name: "Image", extensions: ["jpg", "png", "bmp"] }], - }) - const file = files[0] - const image = await makeImagefromBlob(file) - if (image.naturalHeight !== image.naturalWidth * 2) { - showAlert("This doesn't seem like a CC2 tileset.") - throw new Error("This doesn't seem like a CC2 tileset") - } - return image -} - -async function saveImageAsTileset(image: HTMLImageElement): Promise { - const tileSize = image.naturalWidth / 16 - const nowTime = Date.now() - // TODO Somehow determine the wire width?? - const tset: ExternalTilesetMetadata = { - type: "external", - identifier: `custom ${nowTime}`, - title: "A custom tileset", - description: "This is a custom tileset", - credits: "Unknown", - tileSize, - wireWidth: 2 / 32, - image: reencodeImage(image), - } - await saveTileset(tset) -} - -export async function openTilesetSelectortDialog( - currentTileset: string -): Promise { - resetListeners(tilesetSelectDialog) - const tableBody = tilesetSelectDialog.querySelector("tbody")! - - async function makeTilesetList(): Promise { - const allTilesets = await getAllTilesets() - // Nuke all current data - tableBody.textContent = "" - for (const tset of allTilesets) { - const row = document.createElement("tr") - const radioButton = document.createElement("input") - radioButton.tabIndex = 0 - radioButton.type = "radio" - radioButton.name = "tileset" - radioButton.value = tset.identifier - if (currentTileset === radioButton.value) { - radioButton.checked = true - } - row.appendChild(makeTd(radioButton)) - row.appendChild(makeTd(await makeTsetPreview(tset))) - row.appendChild(makeTd(makeTsetInfo(tset))) - if (tset.type === "external") { - const removeButton = document.createElement("button") - removeButton.classList.add("removeTilesetButton") - removeButton.textContent = "❌" - removeButton.type = "button" - removeButton.addEventListener("click", () => { - removeTileset(tset.identifier).then(makeTilesetList) - }) - row.appendChild(makeTd(removeButton)) - } - tableBody.appendChild(row) - row.addEventListener("click", () => { - radioButton.click() - }) - } - } - - await makeTilesetList() - - const addButton = - tilesetSelectDialog.querySelector("#addTilesetButton")! - addButton.addEventListener("click", () => { - promptCustomTilesetImage() - .then(image => saveImageAsTileset(image)) - .then(makeTilesetList) - }) - - return new Promise(res => { - const closeListener = () => { - const dialogForm = tilesetSelectDialog.querySelector("form")! - const tilesetSelection = dialogForm.elements.namedItem( - "tileset" - ) as RadioNodeList - res(tilesetSelection.value === "" ? null : tilesetSelection.value) - tilesetSelectDialog.removeEventListener("close", closeListener) - } - - tilesetSelectDialog.addEventListener("close", closeListener) - tilesetSelectDialog.showModal() - }) -} diff --git a/gamePlayer/src/tilesets/sources/README.md b/gamePlayer/src/tilesets/sources/README.md index b2c012fc..96960eb7 100644 --- a/gamePlayer/src/tilesets/sources/README.md +++ b/gamePlayer/src/tilesets/sources/README.md @@ -13,3 +13,7 @@ Renders are made and inserted into the tileset image file manually. ## CGA16 cga16.ase is the Aseprite file which is used to generate the CGA16 image file. + +## Tauri + +tauri.ase is the Aseprite image file for the Tauri tileset. diff --git a/gamePlayer/src/tilesets/sources/tauri.ase b/gamePlayer/src/tilesets/sources/tauri.ase new file mode 100644 index 00000000..75986728 Binary files /dev/null and b/gamePlayer/src/tilesets/sources/tauri.ase differ diff --git a/gamePlayer/src/tilesets/tauri.png b/gamePlayer/src/tilesets/tauri.png new file mode 100644 index 00000000..fb18bd19 Binary files /dev/null and b/gamePlayer/src/tilesets/tauri.png differ diff --git a/gamePlayer/src/toast.tsx b/gamePlayer/src/toast.tsx new file mode 100644 index 00000000..3d8e0bf6 --- /dev/null +++ b/gamePlayer/src/toast.tsx @@ -0,0 +1,60 @@ +import { Getter, Setter, atom, useAtomValue } from "jotai" +import { useJotaiFn } from "./helpers" +export interface Toast { + title: string + autoHideAfter?: number + keyIdent?: symbol +} + +const toastAtom = atom([]) + +export function addToastGs(get: Getter, set: Setter, toast: Toast) { + const toasts = get(toastAtom) + const curToastIdx = + toast.keyIdent === undefined + ? -1 + : toasts.findIndex(tst => tst.keyIdent === toast.keyIdent) + if (curToastIdx !== -1) { + toasts.splice(curToastIdx, 1, toast) + } else { + toasts.push(toast) + } + set(toastAtom, toasts.concat()) +} + +export function adjustToastGs(get: Getter, set: Setter) { + set(toastAtom, get(toastAtom).concat()) +} + +export function removeToastGs(get: Getter, set: Setter, ident: Toast | symbol) { + const toasts = get(toastAtom) + let idx: number + if (typeof ident === "symbol") { + idx = toasts.findIndex(tst => tst.keyIdent === ident) + } else { + idx = toasts.findIndex(tst => tst === ident) + } + if (idx !== -1) { + toasts.splice(idx, 1) + set(toastAtom, toasts.concat()) + } +} + +export function ToastDisplay() { + const toases = useAtomValue(toastAtom) + const removeToast = useJotaiFn(removeToastGs) + return ( +
    + {toases.map(toast => ( +
    { + removeToast(toast) + }} + > + {toast.title} +
    + ))} +
    + ) +} diff --git a/gamePlayer/src/trivia.tsx b/gamePlayer/src/trivia.tsx new file mode 100644 index 00000000..110fd487 --- /dev/null +++ b/gamePlayer/src/trivia.tsx @@ -0,0 +1,107 @@ +import { Expl } from "./components/Expl" + +export const trivia = [ + // NotCC + <> + NotCC has built-in help! If you see a good job! button, press + it to learn about the option by it. + , + <> + ExaCC is named after{" "} + SuperCC/SuCC, an + optimization tool for MS and Lynx rulesets. + , + <> + There are two tilesets called CGA:{" "} + + the 4-color CC1 tileset made by Sickly + + , and the 16-color CC2 tileset made by G lander specifically for NotCC. + , + <> + NotCC's game logic, called libnotcc, is written in C and is separate from + this GUI. If you want, you can make your own interface based on libnotcc! + , + // CC2 mechanics + <> + CC2 has its own weird programming language called{" "} + C2G. + , + <> + In CC2, teeth always start moving on the 4th tick, while in MS and Lynx this + varies between level attempts. + , + <> + There are multiple glitches which are not allowed in scoreboard reports. One + of them is a variation of the{" "} + + {" "} + despawning and respawning glitch + + , in which a player or monster is "moved" from one level to another. + , + <> + There are multiple glitches which are not allowed in scoreboard reports. One + of them is{" "} + + explosion sneaking + + , where a player can move at a specific moment to not die when being near + exploding dynamite. + , + <> + There are multiple glitches which are not allowed in scoreboard reports. One + of them is{" "} + + simultaneous character movement + + , where multiple player characters can be moved at once, by either + keymashing or utilizing key repeat. + , + // Community + <> + You can submit your level scores to{" "} + the community scoreboard to be + tracked and compared against other people's scores. + , + <> + There is a website for submitting CC2 level routes called{" "} + Railroad. It is the CC2 version + of the public TWS. + , + <> + There is another CC2 emulator called{" "} + Lexy's Labyrinth! Unlike + NotCC, it has a built-in level editor and custom elements, however it isn't + scoreboard legal. + , + <> + There is an optimization tool called{" "} + + Melinda Vicinity Searcher + + ! It has advanced support for computer-based solution search, and it even + inspired ExaCC's tree and graph models! + , + <> + If you ever wanted to design your own CC2 levels, try{" "} + CC2Edit! It is a fully featured CC2 + level editor with playtesting support. + , + <> + On January 30th 2024, a chipster by the name of Sharpeye acquired record + (bold) times for all CC1 levels in the Steam ruleset. Gz, Sharpeye! + , + <> + You can submit your own trivia! Join the{" "} + Bit Busters Club Discord Server and + tell us your suggestion in #dev-projects. + , + <> + Multiple NotCC features, like ExaCC's timeline and custom sound effects were + suggested by community members. So, if you have any ideas on how to improve + NotCC, submit them to{" "} + GitHub or the{" "} + Bit Busters Club Discord Server! + , +] diff --git a/gamePlayer/src/utils.ts b/gamePlayer/src/utils.ts deleted file mode 100644 index 43ca47ea..00000000 --- a/gamePlayer/src/utils.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { AsyncGunzipOptions, unzlib } from "fflate" - -type AnyFunction = (...args: any[]) => any - -export class TimeoutTimer { - id: number - constructor(callback: AnyFunction, time: number) { - this.id = setTimeout(callback, time * 1000) - } - cancel(): void { - clearTimeout(this.id) - } -} - -export class IntervalTimer { - id: number - constructor(callback: AnyFunction, time: number) { - this.id = setInterval(callback, time * 1000) - } - cancel(): void { - clearInterval(this.id) - } -} - -export class TimeoutIntervalTimer { - id: number - constructor( - public callback: AnyFunction, - public time: number - ) { - this.nextCall = this.nextCall.bind(this) - this.id = setTimeout(this.nextCall, time * 1000) - } - nextCall(): void { - this.id = setTimeout(this.nextCall, this.time * 1000) - this.callback() - } - cancel(): void { - clearTimeout(this.id) - } -} - -export class CompensatingIntervalTimer { - id: number - timeToProcess: number = 0 - lastCallTime: number = performance.now() - constructor( - public callback: AnyFunction, - public time: number - ) { - this.nextCall = this.nextCall.bind(this) - this.id = setInterval(this.nextCall, time * 1000) - } - nextCall(): void { - const time = performance.now() - const dt = time - this.lastCallTime - this.lastCallTime = time - this.timeToProcess += dt / 1000 - while (this.timeToProcess > 0) { - this.callback() - this.timeToProcess -= this.time - } - } - cancel(): void { - clearInterval(this.id) - } -} - -export class AnimationTimer { - id: number - constructor(public callback: AnyFunction) { - this.nextCall = this.nextCall.bind(this) - this.id = requestAnimationFrame(this.nextCall) - } - nextCall(): void { - this.id = requestAnimationFrame(this.nextCall) - this.callback() - } - cancel(): void { - cancelAnimationFrame(this.id) - } -} - -function isModalPresent(): boolean { - return !!document.querySelector("dialog[open]") -} - -export class KeyListener { - removed = false - listenInModals = false - onListener(ev: KeyboardEvent): void { - if (isModalPresent() && !this.listenInModals) return - this.userOn(ev) - } - offListener(ev: KeyboardEvent): void { - if (isModalPresent() && !this.listenInModals) return - // The off listener is only set up if `userOff` is present, so we don't - // need a check for undefined here - this.userOff!(ev) - } - constructor( - public userOn: (ev: KeyboardEvent) => void, - public userOff?: (ev: KeyboardEvent) => void - ) { - this.onListener = this.onListener.bind(this) - document.addEventListener("keydown", this.onListener) - if (userOff) { - this.offListener = this.offListener.bind(this) - document.addEventListener("keyup", this.offListener) - } - } - remove(): void { - if (this.removed) - throw new Error("This key listener has already been removed.") - this.removed = true - document.removeEventListener("keydown", this.onListener) - if (this.userOff) { - document.removeEventListener("keyup", this.offListener) - } - } -} - -export enum AutoRepeatKeyState { - RELEASED, - HELD, - REPEATED, -} - -const KEY_REPEAT_DELAY = 0.25 - -// This is really unfortunate, but the internals are different enough that extending -// doesn't make sense - -export class AutoRepeatKeyListener { - removed = false - keyTimers: Partial> = {} - onListener(ev: KeyboardEvent): void { - if (isModalPresent()) return - if (this.keyTimers[ev.code] !== undefined) return - this.userListener(ev.code, AutoRepeatKeyState.HELD) - this.keyTimers[ev.code] = new TimeoutTimer(() => { - this.keyTimers[ev.code] = "repeating" - this.userListener(ev.code, AutoRepeatKeyState.REPEATED) - }, KEY_REPEAT_DELAY) - } - offListener(ev: KeyboardEvent): void { - if (isModalPresent()) return - const state = this.keyTimers[ev.code] - if (typeof state === "object") { - state.cancel() - } - delete this.keyTimers[ev.code] - this.userListener(ev.code, AutoRepeatKeyState.RELEASED) - } - constructor( - public userListener: (key: string, state: AutoRepeatKeyState) => void - ) { - this.onListener = this.onListener.bind(this) - this.offListener = this.offListener.bind(this) - document.addEventListener("keydown", this.onListener) - document.addEventListener("keyup", this.offListener) - } - remove(): void { - if (this.removed) - throw new Error("This key listener has already been removed.") - this.removed = true - document.removeEventListener("keydown", this.onListener) - document.removeEventListener("keyup", this.offListener) - } -} - -/** - * A hack to remove all event listeners for an HTMLElement's children. Also - * stops the current animation and all references to the children. - */ -export function resetListeners(el: HTMLElement): void { - // eslint-disable-next-line no-self-assign - el.innerHTML = el.innerHTML -} - -export function instanciateTemplate( - template: HTMLTemplateElement -): T { - const fragment = template.content.cloneNode(true) as DocumentFragment - return fragment.firstElementChild! as T -} - -export function makeTd( - contents: string | HTMLElement, - className?: string -): HTMLTableCellElement { - const td = document.createElement("td") - if (typeof contents === "string") { - td.textContent = contents - } else { - td.appendChild(contents) - } - if (className !== undefined) { - td.className = className - } - return td -} - -export async function makeImagefromBlob( - imageBlob: Blob -): Promise { - const url = URL.createObjectURL(imageBlob) - return fetchImage(url).finally(() => URL.revokeObjectURL(url)) -} - -export function fetchImage(link: string): Promise { - return new Promise((res, rej) => { - const img = new Image() - img.addEventListener("load", () => res(img)) - img.addEventListener("error", err => rej(err.error)) - img.src = link - }) -} - -export function reencodeImage(image: HTMLImageElement): HTMLCanvasElement { - const canvas = document.createElement("canvas") - canvas.width = image.naturalWidth - canvas.height = image.naturalHeight - const ctx = canvas.getContext("2d")! - ctx.drawImage(image, 0, 0) - return canvas -} - -export function isDesktop(): boolean { - return import.meta.env.VITE_BUILD_TYPE === "desktop" -} - -export type Comparator = (a: T, b: T) => number - -export function mergeComparators( - one: Comparator, - two: Comparator -): Comparator { - return (a, b) => { - const firstCompare = one(a, b) - if (firstCompare !== 0) return firstCompare - return two(a, b) - } -} - -export function setAttributeExistence( - node: HTMLElement, - attrName: string, - exists: boolean -): void { - if (exists) { - node.setAttribute(attrName, "") - } else { - node.removeAttribute(attrName) - } -} - -export function sleep(time: number): Promise { - return new Promise(res => { - setTimeout(() => { - res() - }, time * 1000) - }) -} - -export function decodeBase64(encoded: string) { - return Uint8Array.from( - atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), - char => char.charCodeAt(0) - ) -} - -export function unzlibAsync( - file: Uint8Array, - opts?: AsyncGunzipOptions -): Promise { - return new Promise((res, rej) => { - unzlib(file, opts ?? {}, (err, data) => { - if (err) { - rej(err) - return - } - res(data) - }) - }) -} diff --git a/gamePlayer/tailwind.config.ts b/gamePlayer/tailwind.config.ts new file mode 100644 index 00000000..8d85c5a0 --- /dev/null +++ b/gamePlayer/tailwind.config.ts @@ -0,0 +1,63 @@ +export const MOBILE_QUERY = "(width < 800px) or (height < 600px)" +export const DESKTOP_QUERY = "(width >= 800px) and (height >= 600px)" +export const LANDSCAPE_QUERY = "(aspect-ratio: 1) or (min-aspect-ratio: 1)" +export const PORTRAIT_QUERY = "(max-aspect-ratio: 1)" + +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + screens: { + mobile: { raw: MOBILE_QUERY }, + desktop: { raw: DESKTOP_QUERY }, + landscape: { raw: LANDSCAPE_QUERY }, + portrait: { raw: PORTRAIT_QUERY }, + }, + extend: { + backgroundImage: { + "radial-gradient": "radial-gradient(var(--tw-gradient-stops))", + "repeating-conic-gradient": + "repeating-conic-gradient(var(--tw-gradient-stops))", + }, + colors: { + theme: Object.fromEntries( + [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].map( + colorShade => [ + colorShade, + `rgb(var(--theme-${colorShade}) / )`, + ] + ) + ), + }, + animation: { + "tooltip-open": "0.1s ease-in-out tooltip-reveal", + "tooltip-close": "0.1s ease-in-out tooltip-reveal reverse", + "drawer-open": "0.05s ease-in-out drawer-reveal", + "drawer-close": "0.05s ease-in-out drawer-reveal reverse", + }, + keyframes: { + "tooltip-reveal": { + from: { + transform: "scale(0.4)", + opacity: "0", + }, + to: { + transform: "scale(1)", + opacity: "1", + }, + }, + "drawer-reveal": { + from: { + opacity: 0.7, + transform: "scaleY(0%)", + }, + to: { + opacity: 1, + transform: "scaleY(100%)", + }, + }, + }, + }, + }, + plugins: [], +} diff --git a/gamePlayer/tsconfig.json b/gamePlayer/tsconfig.json index e9813ecf..039b07b7 100644 --- a/gamePlayer/tsconfig.json +++ b/gamePlayer/tsconfig.json @@ -1,20 +1,30 @@ { "compilerOptions": { - "target": "ESNext", + "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ESNext", "DOM"], - "types": ["vite/client"], - "moduleResolution": "Node", - "strict": true, - "strictNullChecks": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, - "esModuleInterop": true, "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "preact", + + /* Linting */ + "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noImplicitReturns": true + "noFallthroughCasesInSwitch": true, + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"], + "@/*": ["./src/*"] + } }, "include": ["src"] } diff --git a/gamePlayer/vite.config.ts b/gamePlayer/vite.config.ts index 791f544a..485f2205 100644 --- a/gamePlayer/vite.config.ts +++ b/gamePlayer/vite.config.ts @@ -1,6 +1,10 @@ -import { defineConfig } from "vite" -import { join } from "path" import { execSync } from "child_process" +import { join } from "path" +import { AliasOptions, PluginOption, defineConfig } from "vite" +import preact from "@preact/preset-vite" +import { readFile, writeFile, readdir } from "fs/promises" +import jotaiDebugLabel from "jotai/babel/plugin-debug-label" +import jotaiReactRefresh from "jotai/babel/plugin-react-refresh" process.env["VITE_LAST_COMMIT_INFO"] = execSync( `git log -1 --format="%s (%h) at %cI"` @@ -8,20 +12,50 @@ process.env["VITE_LAST_COMMIT_INFO"] = execSync( .toString("utf-8") .trim() -process.env["VITE_VERSION"] = execSync('git log -1 --format="%h"') +process.env["VITE_GIT_COMMIT"] = execSync('git log -1 --format="%h"') .toString("utf-8") .trim() +process.env["VITE_VERSION"] = JSON.parse( + await readFile("./package.json", "utf-8") +).version + process.env["VITE_BUILD_TIME"] = new Date().toISOString() +const SSG_PLACEHOLDER_STRING = "" +const SSG_FAKE_ASSET_PATH = "/FAKE_ASSET_PATH_TEMP_TO_REPLACE" + +function ssg(): PluginOption { + return { + name: "notcc-ssg", + async transformIndexHtml(html) { + // The useless `.slice` is here to stop Typescript from resolving the path + // at build time, which will create a silly error when it's not present + const mainModule = await import("./dist/ssg/main-ssg.js".slice()) + const prerenderedHtml = mainModule.renderSsgString() + return html + .replace(SSG_PLACEHOLDER_STRING, prerenderedHtml) + .replaceAll(SSG_FAKE_ASSET_PATH, ".") + }, + } +} + +const prodBuild = !process.env.SSG && process.env.NODE_ENV === "production" + export default defineConfig({ - build: { - sourcemap: true, - }, - base: "./", + plugins: [ + preact({ babel: { plugins: [jotaiDebugLabel, jotaiReactRefresh] } }), + prodBuild && ssg(), + ], + base: process.env.SSG ? SSG_FAKE_ASSET_PATH : "./", assetsInclude: ["**/*.c2m"], resolve: { - alias: { path: join(process.cwd(), "node_modules/path-browserify") }, + alias: { + path: join(process.cwd(), "node_modules/path-browserify"), + "@": "/src", + }, }, + ssr: process.env.SSG ? { noExternal: new RegExp("", "g") } : {}, esbuild: { sourcemap: true }, + build: { sourcemap: true, emptyOutDir: !prodBuild }, }) diff --git a/libnotcc-bind/.gitignore b/libnotcc-bind/.gitignore new file mode 100644 index 00000000..662a7ef4 --- /dev/null +++ b/libnotcc-bind/.gitignore @@ -0,0 +1,3 @@ +dist/ +src/*.wasm +src/*.map diff --git a/logic/package.json b/libnotcc-bind/package.json similarity index 58% rename from logic/package.json rename to libnotcc-bind/package.json index ef658cc2..fcb8fcd5 100644 --- a/logic/package.json +++ b/libnotcc-bind/package.json @@ -1,6 +1,6 @@ { "name": "@notcc/logic", - "version": "1.1.7", + "version": "2.0.0", "description": "Game logic for NotCC, a Chip's Challenge 2® emulator", "main": "./dist/index.js", "module": "./dist/index.js", @@ -8,13 +8,11 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build-proto": "pbjs -t static-module --force-number -w es6 src/parsers/nccs.proto -o src/parsers/nccs.pb.js && pnpm build-proto-types", - "build-proto-types": "pbjs -t static-module --force-number -w es6 src/parsers/nccs.proto -o tmp_nccs.js && pbts -o src/parsers/nccs.pb.d.ts tmp_nccs.js && pnpm make-proto-hacks;rm tmp_nccs.js", - "make-proto-hacks": "sed -i '/import Long = require(\"long\");/d' ./src/parsers/nccs.pb.d.ts && sed -i 's:import\\ \\*\\ as\\ \\$protobuf\\ from\\ \\\"protobufjs/minimal\\\"\\;:import\\ \\$protobuf\\ from\\ \\\"protobufjs/minimal.js\\\"\\;:' ./src/parsers/nccs.pb.js", - "build-src": "tsc", + "build:src": "tsc", + "build:proto": "pbjs -t static-module --force-number -w es6 src/nonbind/nccs.proto -o src/nonbind/nccs.pb.js && pbts -o src/nonbind/nccs.pb.d.ts src/nonbind/nccs.pb.js && pnpm make-proto-hacks", + "make-proto-hacks": "sed -i '/import Long = require(\"long\");/d' ./src/nonbind/nccs.pb.d.ts && sed -i 's:import\\ \\*\\ as\\ \\$protobuf\\ from\\ \\\"protobufjs/minimal\\\"\\;:import\\ \\$protobuf\\ from\\ \\\"protobufjs/minimal.js\\\"\\;:' ./src/nonbind/nccs.pb.js", "dev": "tsc --watch --declaration", - "copy-proto-files": "cp ./src/parsers/*.pb.* ./dist/parsers", - "build": "rm -rf dist && pnpm build-proto && pnpm build-src && pnpm copy-proto-files", + "build": "pnpm build:proto && pnpm build:src && cp ./src/nonbind/nccs.pb.js ./src/nonbind/nccs.pb.d.ts ./dist/nonbind && cp ../libnotcc/build/libnotcc.so ./dist/libnotcc.wasm", "prepublish": "pnpm build" }, "repository": { @@ -29,7 +27,7 @@ "homepage": "https://github.com/TheGLander/NotCC#readme", "devDependencies": { "@types/clone": "^2.1.3", - "@types/node": "^15.14.9", + "@types/node": "^20.14.2", "protobufjs-cli": "^1.1.2", "typescript": "^4.9.5" }, diff --git a/libnotcc-bind/src/actor.ts b/libnotcc-bind/src/actor.ts new file mode 100644 index 00000000..54f0f3df --- /dev/null +++ b/libnotcc-bind/src/actor.ts @@ -0,0 +1,175 @@ +import { TileType } from "./cell.js" +import { Level } from "./level.js" +import { wasmFuncs } from "./module.js" +import { Struct } from "./struct.js" + +export enum ItemIndex { + Nothing = 0, + ForceBoots = 1, + IceBoots = 2, + FireBoots = 3, + WaterBoots = 4, + Dynamite = 5, + Helmet = 6, + DirtBoots = 7, + LightningBolt = 8, + BowlingBall = 9, + YellowTeleport = 10, + RailroadSign = 11, + SteelFoil = 12, + SecretEye = 13, + Bribe = 14, + SpeedBoots = 15, + Hook = 16, +} + +export type InventoryTools = [ItemIndex, ItemIndex, ItemIndex, ItemIndex] + +export class Inventory extends Struct { + get item1() { + const ptr = wasmFuncs.Inventory_get_item1(this._ptr) + if (ptr === 0) return null + return new TileType(ptr) + } + get item2() { + const ptr = wasmFuncs.Inventory_get_item2(this._ptr) + if (ptr === 0) return null + return new TileType(ptr) + } + get item3() { + const ptr = wasmFuncs.Inventory_get_item3(this._ptr) + if (ptr === 0) return null + return new TileType(ptr) + } + get item4() { + const ptr = wasmFuncs.Inventory_get_item4(this._ptr) + if (ptr === 0) return null + return new TileType(ptr) + } + setItems(items: InventoryTools) { + wasmFuncs.Inventory_set_items( + this._ptr, + items[0], + items[1], + items[2], + items[3] + ) + } + getItems(): InventoryTools { + return [ + this.item1?.itemIndex ?? ItemIndex.Nothing, + this.item2?.itemIndex ?? ItemIndex.Nothing, + this.item3?.itemIndex ?? ItemIndex.Nothing, + this.item4?.itemIndex ?? ItemIndex.Nothing, + ] + } + get keysRed(): number { + return wasmFuncs.Inventory_get_keys_red(this._ptr) + } + set keysRed(val: number) { + wasmFuncs.Inventory_set_keys_red(val) + } + get keysGreen(): number { + return wasmFuncs.Inventory_get_keys_green(this._ptr) + } + set keysGreen(val: number) { + wasmFuncs.Inventory_set_keys_green(val) + } + get keysBlue(): number { + return wasmFuncs.Inventory_get_keys_blue(this._ptr) + } + set keysBlue(val: number) { + wasmFuncs.Inventory_set_keys_blue(val) + } + get keysYellow(): number { + return wasmFuncs.Inventory_get_keys_yellow(this._ptr) + } + set keysYellow(val: number) { + wasmFuncs.Inventory_set_keys_yellow(val) + } +} + +export enum Direction { + NONE = 0, + UP = 1, + RIGHT = 2, + DOWN = 3, + LEFT = 4, +} + +export enum SlidingState { + NONE = 0, + WEAK = 1, + STRONG = 2, +} + +export class Actor extends Struct { + get type() { + return new TileType(wasmFuncs.Actor_get_type(this._ptr)) + } + get customData(): bigint { + return wasmFuncs.Actor_get_custom_data(this._ptr) + } + set customData(val: bigint) { + wasmFuncs.Actor_set_custom_data(this._ptr, val) + } + get inventory() { + return new Inventory(wasmFuncs.Actor_get_inventory_ptr(this._ptr)) + } + get position(): [number, number] { + const pos = wasmFuncs.Actor_get_position_xy(this._ptr) + return [pos & 0xff, pos >> 8] + } + get direction(): Direction { + return wasmFuncs.Actor_get_direction(this._ptr) + } + get pendingDecision(): Direction { + return wasmFuncs.Actor_get_pending_decision(this._ptr) + } + get pendingDecisionLockedIn(): boolean { + return wasmFuncs.Actor_get_pending_move_locked_in(this._ptr) + } + get moveProgress(): number { + return wasmFuncs.Actor_get_move_progress(this._ptr) + } + get moveLength(): number { + return wasmFuncs.Actor_get_move_length(this._ptr) + } + getVisualOffset(): [number, number] { + if (this.moveProgress === 0) return [0, 0] + const offset = 1 - this.moveProgress / this.moveLength + const pos: [number, number] = [0, 0] + const dir = this.direction + if (dir === Direction.UP) { + pos[1] += offset + } else if (dir === Direction.RIGHT) { + pos[0] -= offset + } else if (dir === Direction.DOWN) { + pos[1] -= offset + } else if (dir === Direction.LEFT) { + pos[0] += offset + } + return pos + } + get slidingState(): SlidingState { + return wasmFuncs.Actor_get_sliding_state(this._ptr) + } + get bonked(): boolean { + return !!wasmFuncs.Actor_get_bonked(this._ptr) + } + get frozen(): boolean { + return !!wasmFuncs.Actor_get_frozen(this._ptr) + } + get pulled(): boolean { + return !!wasmFuncs.Actor_get_pulled(this._ptr) + } + get pulling(): boolean { + return !!wasmFuncs.Actor_get_pulling(this._ptr) + } + get pushing(): boolean { + return !!wasmFuncs.Actor_get_pushing(this._ptr) + } + actorListIdx(level: Level) { + return wasmFuncs.Actor_get_actor_list_idx(this._ptr, level._ptr) + } +} diff --git a/libnotcc-bind/src/cell.ts b/libnotcc-bind/src/cell.ts new file mode 100644 index 00000000..a3237830 --- /dev/null +++ b/libnotcc-bind/src/cell.ts @@ -0,0 +1,67 @@ +import { Actor, ItemIndex } from "./actor.js" +import { wasmFuncs } from "./module.js" +import { Struct, getStringAt, getWasmReader } from "./struct.js" + +export class TileType extends Struct { + get name() { + return getStringAt(this.getPtr(0)) + } + get itemIndex(): ItemIndex { + return wasmFuncs.TileType_get_item_index(this._ptr) + } +} + +export class BasicTile extends Struct { + get type() { + return new TileType(wasmFuncs.BasicTile_get_type(this._ptr)) + } + get customData(): bigint { + return wasmFuncs.BasicTile_get_custom_data(this._ptr) + } + getCell(): Cell { + // FIXME: AAA + return new Cell(wasmFuncs.BasicTile_get_cell(this._ptr, 4)) + } +} + +export enum Layer { + SPECIAL = 0, + ACTOR = 1, + ITEM_MOD = 2, + ITEM = 3, + TERRAIN = 4, +} + +export class Cell extends Struct { + get special() { + const ptr = wasmFuncs.Cell_get_layer(this._ptr, Layer.SPECIAL) + if (getWasmReader().getUint32(ptr) === 0) return null + return new BasicTile(ptr) + } + get actor() { + const ptr = wasmFuncs.Cell_get_actor(this._ptr) + if (ptr === 0) return null + return new Actor(ptr) + } + get itemMod() { + const ptr = wasmFuncs.Cell_get_layer(this._ptr, Layer.ITEM_MOD) + if (getWasmReader().getUint32(ptr) === 0) return null + return new BasicTile(ptr) + } + get item() { + const ptr = wasmFuncs.Cell_get_layer(this._ptr, Layer.ITEM) + if (getWasmReader().getUint32(ptr) === 0) return null + return new BasicTile(ptr) + } + get terrain() { + const ptr = wasmFuncs.Cell_get_layer(this._ptr, Layer.TERRAIN) + if (getWasmReader().getUint32(ptr) === 0) return null + return new BasicTile(ptr) + } + get poweredWires(): number { + return wasmFuncs.Cell_get_powered_wires(this._ptr) + } + get isWired(): boolean { + return !!wasmFuncs.Cell_get_is_wired(this._ptr) + } +} diff --git a/libnotcc-bind/src/index.ts b/libnotcc-bind/src/index.ts new file mode 100644 index 00000000..e801028f --- /dev/null +++ b/libnotcc-bind/src/index.ts @@ -0,0 +1,10 @@ +export * from "./actor.js" +export * from "./cell.js" +export * from "./level.js" +export * from "./struct.js" +export * from "./module.js" +export * from "./nonbind/c2g.js" +export * from "./nonbind/nccs.js" +export * from "./nonbind/inputs.js" +export * from "./nonbind/levelset.js" +export * from "./nonbind/attemptTracker.js" diff --git a/libnotcc-bind/src/level.ts b/libnotcc-bind/src/level.ts new file mode 100644 index 00000000..738f2332 --- /dev/null +++ b/libnotcc-bind/src/level.ts @@ -0,0 +1,422 @@ +import { Actor, Direction } from "./actor.js" +import { Cell } from "./cell.js" +import { InputProvider, Inventory, KeyInputs, msToProtoTime } from "./index.js" +import { getModuleInstance, wasmFuncs } from "./module.js" +import { GlitchInfo, IGlitchInfo } from "./nonbind/nccs.pb.js" +import { + CVector, + getStringAt, + getWasmReader, + makeAccessorClassObj, + Struct, +} from "./struct.js" +export class PlayerSeat extends Struct { + get actor() { + const ptr = wasmFuncs.PlayerSeat_get_actor(this._ptr) + if (ptr === 0) return null + return new Actor(ptr) + } + get inputs(): KeyInputs { + return wasmFuncs.PlayerSeat_get_inputs(this._ptr) + } + set inputs(val: KeyInputs) { + wasmFuncs.PlayerSeat_set_inputs(this._ptr, val) + } + get releasedInputs(): KeyInputs { + return wasmFuncs.PlayerSeat_get_released_inputs(this._ptr) + } + set releasedInputs(val: KeyInputs) { + wasmFuncs.PlayerSeat_set_released_inputs(this._ptr, val) + } + get displayedHint() { + return getStringAt(wasmFuncs.PlayerSeat_get_displayed_hint(this._ptr)) + } + hasPerspective(): boolean { + return !!wasmFuncs.PlayerSeat_has_perspective(this._ptr) + } + getPossibleActions(level: Level): KeyInputs { + return wasmFuncs.PlayerSeat_get_possible_actions(this._ptr, level._ptr) + } +} +export class LevelMetadata extends Struct { + get title() { + return getStringAt(wasmFuncs.LevelMetadata_get_title(this._ptr)) + } + get author() { + return getStringAt(wasmFuncs.LevelMetadata_get_author(this._ptr)) + } + get defaultHint() { + return getStringAt(wasmFuncs.LevelMetadata_get_default_hint(this._ptr)) + } + get cameraWidth(): number { + return wasmFuncs.LevelMetadata_get_camera_width(this._ptr) + } + get cameraHeight(): number { + return wasmFuncs.LevelMetadata_get_camera_height(this._ptr) + } + get cc1Boots(): boolean { + return !!wasmFuncs.LevelMetadata_get_cc1_boots(this._ptr) + } + set cc1Boots(val: boolean) { + wasmFuncs.LevelMetadata_set_cc1_boots(this._ptr, val) + } + get wiresHidden(): boolean { + return !!wasmFuncs.LevelMetadata_get_wires_hidden(this._ptr) + } + set wiresHidden(val: boolean) { + wasmFuncs.LevelMetadata_set_wires_hidden(this._ptr, val) + } + get c2gCommand() { + return getStringAt(wasmFuncs.LevelMetadata_get_c2g_command(this._ptr)) + } + get rngBlob4Pat() { + return !!wasmFuncs.LevelMetadata_get_rng_blob_4pat(this._ptr) + } + get rngBlobDeterministic() { + return !!wasmFuncs.LevelMetadata_get_rng_blob_deterministic(this._ptr) + } +} + +export class VectorUint8 extends CVector { + getItemSize(): number { + return 1 + } + instantiateItem(ptr: number): number { + return getWasmReader().getUint8(ptr) + } +} + +export class Replay extends Struct { + get randomForceFloorDirection(): number { + return wasmFuncs.Replay_get_rff_direction(this._ptr) + } + set randomForceFloorDirection(val: number) { + wasmFuncs.Replay_set_rff_direction(this._ptr, val) + } + get rngBlob(): number { + return wasmFuncs.Replay_get_rng_blob(this._ptr) + } + set rngBlob(val: number) { + wasmFuncs.Replay_set_rng_blob(this._ptr, val) + } + get inputs(): VectorUint8 { + return new VectorUint8(wasmFuncs.Replay_get_inputs_ptr(this._ptr)) + } +} + +export enum HashSettings { + IGNORE_BLOCK_ORDER = 1 << 0, + IGNORE_PLAYER_DIRECTION = 1 << 1, + // TODO: IGNORE_PLAYER_BUMP = 1 << 2, + IGNORE_MIMIC_PARITY = 1 << 3, + IGNORE_TEETH_PARITY = 1 << 4, +} + +export class VectorPlayerSeat extends CVector { + getItemSize(): number { + return wasmFuncs._libnotcc_bind_PlayerSeat_size() + } + instantiateItem(ptr: number): PlayerSeat { + return new PlayerSeat(ptr) + } +} + +export class Glitch extends Struct { + get glitchKind(): GlitchInfo.KnownGlitches { + return wasmFuncs.Glitch_get_glitch_kind(this._ptr) + } + get location(): [number, number] { + const loc: number = wasmFuncs.Glitch_get_location_xy(this._ptr) + return [loc & 0xff, loc >> 8] + } + get specifier(): number { + return wasmFuncs.Glitch_get_specifier(this._ptr) + } + get happensAt(): bigint { + return wasmFuncs.Glitch_get_happens_at(this._ptr) + } + isCrashing(): boolean { + return !!wasmFuncs.Glitch_is_crashing(this._ptr) + } + toGlitchInfo(): IGlitchInfo { + const pos = this.location + return { + glitchKind: this.glitchKind, + location: { x: pos[0], y: pos[1] }, + happensAt: msToProtoTime(Number((this.happensAt * 1000n) / 60n)), + specifier: this.specifier, + } + } +} + +export class VectorGlitch extends CVector { + getItemSize(): number { + return wasmFuncs._libnotcc_bind_Glitch_size() + } + instantiateItem(ptr: number): Glitch { + return new Glitch(ptr) + } +} + +export class ActorList { + get length(): number { + return this.level.actor_n + } + [idx: number]: Actor + *[Symbol.iterator]() { + const length = this.length + for (let idx = 0; idx < length; idx += 1) { + yield this[idx] + } + } + getItem(idx: number): Actor { + return new Actor(getWasmReader().getUint32(this.level.actors_ptr + idx * 4)) + } + constructor(public level: Level) { + return makeAccessorClassObj(this) + } + getItemSize(): number { + return wasmFuncs._libnotcc_bind_Actor_size() + } + instantiateItem(ptr: number): Actor { + return new Actor(ptr) + } +} + +export enum SfxBit { + FIRST = 1, + RECESSED_WALL = 1 << 0, + EXPLOSION = 1 << 1, + SPLASH = 1 << 2, + TELEPORT = 1 << 3, + THIEF = 1 << 4, + DIRT_CLEAR = 1 << 5, + BUTTON_PRESS = 1 << 6, + BLOCK_PUSH = 1 << 7, + FORCE_FLOOR_SLIDE = 1 << 8, + PLAYER_BONK = 1 << 9, + WATER_STEP = 1 << 10, + SLIDE_STEP = 1 << 11, + ICE_SLIDE = 1 << 12, + FIRE_STEP = 1 << 13, + ITEM_PICKUP = 1 << 14, + SOCKET_UNLOCK = 1 << 15, + DOOR_UNLOCK = 1 << 16, + CHIP_WIN = 1 << 17, + MELINDA_WIN = 1 << 18, + CHIP_DEATH = 1 << 19, + MELINDA_DEATH = 1 << 20, + LAST = MELINDA_DEATH, +} + +export const SFX_BITS_CONTINUOUS = SfxBit.FORCE_FLOOR_SLIDE | SfxBit.ICE_SLIDE + +export class LastPlayerInfo extends Struct { + get inventory() { + return new Inventory(wasmFuncs.LastPlayerInfo_get_inventory_ptr(this._ptr)) + } + get exitN(): number { + return wasmFuncs.LastPlayerInfo_get_exit_n(this._ptr) + } + get isMale(): boolean { + return wasmFuncs.LastPlayerInfo_get_is_male(this._ptr) + } +} + +export const DETERMINISTIC_BLOB_MOD = 0x55 + +export class Level extends Struct { + static unalloc(ptr: number) { + wasmFuncs.Level_uninit(ptr) + } + // Basic + get width(): number { + return wasmFuncs.Level_get_width(this._ptr) + } + get height(): number { + return wasmFuncs.Level_get_height(this._ptr) + } + get currentTick(): number { + return wasmFuncs.Level_get_current_tick(this._ptr) + } + get currentSubtick(): number { + return wasmFuncs.Level_get_current_subtick(this._ptr) + } + get gameState(): GameState { + return wasmFuncs.Level_get_game_state(this._ptr) + } + get metadata(): LevelMetadata { + return new LevelMetadata(wasmFuncs.Level_get_metadata_ptr(this._ptr)) + } + get builtinReplay() { + const ptr = wasmFuncs.Level_get_builtin_replay(this._ptr) + if (ptr === 0) return null + return new Replay(ptr) + } + get actors_ptr(): number { + return wasmFuncs.Level_get_actors(this._ptr) + } + get actor_n(): number { + return wasmFuncs.Level_get_actors_n(this._ptr) + } + get actors(): ActorList { + return new ActorList(this) + } + get lastWonPlayerInfo() { + return new LastPlayerInfo( + wasmFuncs.Level_get_last_won_player_info_ptr(this._ptr) + ) + } + get ignoreBonusFlags(): boolean { + return !!wasmFuncs.Level_get_ignore_bonus_flags(this._ptr) + } + set ignoreBonusFlags(val: boolean) { + wasmFuncs.Level_set_ignore_bonus_flags(this._ptr, val) + } + setProviderInputs(ip: InputProvider) { + const seats = this.playerSeats + for (let idx = 0; idx < seats.length; idx += 1) { + seats[idx].inputs = ip.getInput(this.subticksPassed(), idx) + } + } + tick() { + wasmFuncs.Level_tick(this._ptr) + } + getCell(x: number, y: number): Cell { + const cell = new Cell(wasmFuncs.Level_get_cell_xy(this._ptr, x, y)) + return cell + } + get glitches(): VectorGlitch { + return new VectorGlitch(wasmFuncs.Level_get_glitches_ptr(this._ptr)) + } + // Player + get playerSeats(): VectorPlayerSeat { + return new VectorPlayerSeat(wasmFuncs.Level_get_player_seats_ptr(this._ptr)) + } + get playersLeft(): number { + return wasmFuncs.Level_get_players_left(this._ptr) + } + // Metrics + get timeLeft(): number { + return wasmFuncs.Level_get_time_left(this._ptr) + } + set timeLeft(val: number) { + wasmFuncs.Level_set_time_left(this._ptr, val) + } + get timeStopped() { + return !!wasmFuncs.Level_get_time_stopped(this._ptr) + } + get chipsLeft(): number { + return wasmFuncs.Level_get_chips_left(this._ptr) + } + get bonusPoints() { + return wasmFuncs.Level_get_bonus_points(this._ptr) + } + // Rng + get rng1(): number { + return wasmFuncs.Level_get_rng1(this._ptr) + } + get rng2(): number { + return wasmFuncs.Level_get_rng2(this._ptr) + } + get rngBlob(): number { + return wasmFuncs.Level_get_rng_blob(this._ptr) + } + set rngBlob(val: number) { + wasmFuncs.Level_set_rng_blob(this._ptr, val) + } + // Global state + get randomForceFloorDirection(): Direction { + return wasmFuncs.Level_get_rff_direction(this._ptr) + } + set randomForceFloorDirection(val: Direction) { + wasmFuncs.Level_set_rff_direction(this._ptr, val) + } + get toggleWallInverted(): boolean { + return !!wasmFuncs.Level_get_toggle_wall_inverted(this._ptr) + } + set toggleWallInverted(val: boolean) { + wasmFuncs.Level_set_toggle_wall_inverted(this._ptr, val) + } + clone() { + return new Level(wasmFuncs.Level_clone(this._ptr)) + } + subticksPassed() { + return this.currentTick * 3 + this.currentSubtick + } + msecsPassed() { + return this.subticksPassed() * (1000 / 60) + } + hash(settings: HashSettings) { + return wasmFuncs.Level_hash(this._ptr, settings) + } + totalByteSize(): number { + return wasmFuncs.Level_total_size(this._ptr) + } + get sfx(): number { + return Number(wasmFuncs.Level_get_sfx(this._ptr)) + } + erase(actor: Actor) { + wasmFuncs.Actor_erase(actor._ptr, this._ptr) + } +} + +export enum GameState { + PLAYING, + DEATH, + TIMEOUT, + WON, + CRASH, +} + +export class CResult extends Struct { + static alloc() { + const res = this.allocStruct(8) + return res + } + static unalloc(ptr: number): void { + if (!getWasmReader().getInt32(ptr)) { + wasmFuncs.free(ptr + 4) + } + } + get success() { + return this.getBool(0) + } + get error() { + if (this.success) return null + return getStringAt(this.getPtr(4)) + } + get value() { + if (!this.success) return null + return this.getPtr(4) + } +} +function copyBuffer(buff: ArrayBufferLike) { + const dataPtr = wasmFuncs.malloc(buff.byteLength) + if (dataPtr === 0) throw new Error("Failed to malloc ") + const memBuf = new Uint8Array(getWasmReader().buffer) + memBuf.set(new Uint8Array(buff), dataPtr) + return dataPtr +} +export function parseC2M(buff: ArrayBufferLike) { + const dataPtr = copyBuffer(buff) + const levelRes = CResult.alloc() + wasmFuncs.parse_c2m(levelRes._ptr, dataPtr, buff.byteLength) + wasmFuncs.free(dataPtr) + const err = levelRes.error + const valPtr = levelRes.value + levelRes.free() + if (err) throw new Error(err) + return new Level(valPtr!) +} +export function parseC2MMeta(buff: ArrayBufferLike) { + const dataPtr = copyBuffer(buff) + const levelRes = CResult.alloc() + wasmFuncs.parse_c2m_meta(levelRes._ptr, dataPtr, buff.byteLength) + wasmFuncs.free(dataPtr) + const err = levelRes.error + const valPtr = levelRes.value + levelRes.free() + if (err) throw new Error(err) + return new LevelMetadata(valPtr!) +} diff --git a/libnotcc-bind/src/module.ts b/libnotcc-bind/src/module.ts new file mode 100644 index 00000000..cf4a4a61 --- /dev/null +++ b/libnotcc-bind/src/module.ts @@ -0,0 +1,32 @@ +let moduleInstance: WebAssembly.Instance | null = null +export function getModuleInstance() { + if (!moduleInstance) { + throw new Error( + "`initWasm` must be called before the rest of the library can be used!" + ) + } + return moduleInstance +} +export const wasmFuncs: Record = {} +let wasmInitted = false + +export async function initWasm(): Promise { + if (wasmInitted) return + let instSource: WebAssembly.WebAssemblyInstantiatedSource + // Use different Wasm download method for Nodejs and bundlers + if (typeof Buffer !== "undefined") { + // Node: Just read the file using the FS lib + // The weird `+ "s"` is to prevent Vite from complaining about Node modules in the browser + const fs = await import(/* @vite-ignore */ "node:fs/promise" + "s") + const data = await fs.readFile(new URL("./libnotcc.wasm", import.meta.url)) + instSource = await WebAssembly.instantiate(data) + } else { + // Vite: use ?url imports to get a URL to `fetch` + const url = await import("./libnotcc.wasm?url") + instSource = await WebAssembly.instantiateStreaming(fetch(url.default)) + } + moduleInstance = instSource.instance + Object.setPrototypeOf(wasmFuncs, instSource.instance.exports) + wasmFuncs.__wasm_call_ctors() + wasmInitted = true +} diff --git a/libnotcc-bind/src/nonbind/attemptTracker.ts b/libnotcc-bind/src/nonbind/attemptTracker.ts new file mode 100644 index 00000000..db055644 --- /dev/null +++ b/libnotcc-bind/src/nonbind/attemptTracker.ts @@ -0,0 +1,144 @@ +import { KeyInputs } from "./inputs.js" +import { GameState, Level } from "../level.js" +import { protobuf } from "./nccs.js" +import { Direction } from "../actor.js" + +// The two interfaces are structually equivalent, so just output both! +export function msToProtoTime( + ms: number +): protobuf.google.protobuf.ITimestamp | protobuf.google.protobuf.IDuration { + const seconds = Math.floor(ms / 1000) + const micros = ms - seconds * 1000 + return { + seconds, + nanos: micros * 1000, + } +} + +export function protoTimeToMs( + protoTime: + | protobuf.google.protobuf.ITimestamp + | protobuf.google.protobuf.IDuration +): number { + return (protoTime.seconds ?? 0) * 1000 + (protoTime.nanos ?? 0) / 1000 +} + +export function protoTimeToSubticks( + protoTime: + | protobuf.google.protobuf.ITimestamp + | protobuf.google.protobuf.IDuration +): number { + return ( + (protoTime.seconds ?? 0) * 60 + + Math.round((protoTime.nanos ?? 0) / 1000 / (1000 / 60)) + ) +} + +export class StepRecorder { + currentStep = -1 + + attemptSteps: Uint8Array = new Uint8Array(100) + constructor() {} + reallocateStepArray(): void { + let newLength = this.attemptSteps.length * 1.5 + // Makes sure we always have space to save the last time amount + if (newLength % 2 === 1) newLength += 1 + const newArr = new Uint8Array(newLength) + newArr.set(this.attemptSteps) + this.attemptSteps = newArr + } + recordAttemptStep(input: KeyInputs): void { + if (this.currentStep === -1) { + this.attemptSteps[0] = input + this.attemptSteps[1] = 1 + this.currentStep += 1 + return + } + let stepPos = this.currentStep * 2 + const lastStep = this.attemptSteps[stepPos] + if (this.attemptSteps[stepPos + 1] >= 0xfa || input !== lastStep) { + this.currentStep += 1 + stepPos += 2 + if (stepPos >= this.attemptSteps.length) { + this.reallocateStepArray() + } + this.attemptSteps[stepPos] = input + } + + this.attemptSteps[stepPos + 1] += 1 + } + finalizeSteps() { + return this.attemptSteps.slice(0, this.currentStep * 2 + 1) + } +} + +export class AttemptTracker { + currentAttempt: protobuf.IAttemptInfo + attemptStartTime: number = Date.now() + stepRecorders: StepRecorder[] + constructor( + playerN: number, + blobMod: number, + randomForceFloorDirection: Direction, + scriptState?: protobuf.IScriptState + ) { + this.stepRecorders = [] + for (let idx = 0; idx < playerN; idx += 1) { + this.stepRecorders.push(new StepRecorder()) + } + this.currentAttempt = { + attemptStart: msToProtoTime(Date.now()), + solution: { + levelState: { + randomForceFloorDirection: + randomForceFloorDirection as 0 as protobuf.ProtoDirection, + cc2Data: { blobModifier: blobMod, scriptState }, + }, + }, + } + } + recordAttemptStep(level: Level) { + const seats = level.playerSeats + for (let idx = 0; idx < seats.length; idx += 1) { + this.stepRecorders[idx].recordAttemptStep(seats[idx].inputs) + } + } + endAttempt(level: Level): protobuf.IAttemptInfo { + if ( + !this.currentAttempt || + !this.currentAttempt.solution || + this.attemptStartTime === undefined + ) + throw new Error("The attempt must start before it can end.") + this.currentAttempt.attemptLength = msToProtoTime( + Date.now() - this.attemptStartTime + ) + if (level.gameState !== GameState.WON) { + // If we didn't win, scrap the solution info + delete this.currentAttempt.solution + } + if (level.gameState === GameState.PLAYING) { + // Noop when the attempt is ended prematurely + } else if (level.gameState === GameState.TIMEOUT) { + this.currentAttempt.failReason = "time" + } else if (level.gameState === GameState.DEATH) { + // FIXME: Add `Level.death_reason` + // this.currentAttempt.failReason = level.selectedPlayable?.deathReason + } else { + this.currentAttempt.solution!.outcome = { + bonusScore: level.bonusPoints, + timeLeft: msToProtoTime(level.timeLeft * (1000 / 60)), + absoluteTime: msToProtoTime(level.msecsPassed()), + } + this.currentAttempt.solution!.steps = this.stepRecorders.map(rec => + rec.finalizeSteps() + ) + this.currentAttempt.solution!.usedGlitches = Array.from( + level.glitches, + glitch => glitch.toGlitchInfo() + ) + } + + return this.currentAttempt + } +} diff --git a/logic/src/parsers/c2g.ts b/libnotcc-bind/src/nonbind/c2g.ts similarity index 73% rename from logic/src/parsers/c2g.ts rename to libnotcc-bind/src/nonbind/c2g.ts index 9df1ff41..0e51be05 100644 --- a/logic/src/parsers/c2g.ts +++ b/libnotcc-bind/src/nonbind/c2g.ts @@ -1,6 +1,9 @@ import { printf } from "fast-printf" import { join } from "path" -import { IScriptState } from "./nccs.pb.js" +import { ILevelInfo, IScriptState } from "./nccs.pb.js" +import type { LevelSetData } from "./levelset.js" +import { InventoryTools, ItemIndex, Level, parseC2MMeta } from "../index.js" +import clone from "clone" export const C2G_NOTCC_VERSION = "1.0-NotCC" @@ -272,7 +275,7 @@ const scriptDirectiveFunctions: Record< ? { repeating: isRepeating, path: joinPath(this.state.fsPosition ?? "", str), - } + } : { repeating: isRepeating, id: str } return "consume token" } else { @@ -292,7 +295,9 @@ const scriptDirectiveFunctions: Record< if (this.state.currentLine >= this.scriptLines.length) { break } - const line = this.tokenizeLine(this.state.currentLine) + const line = Array.from( + tokenizeLine(this.scriptLines[this.state.currentLine]) + ) if (finalString === "" && line[0]?.type !== "string") { // If the first line after the `script` is empty, return prematery console.warn("The first line of a script must be a string.") @@ -402,53 +407,36 @@ export interface ScriptMusic { repeating: boolean } -export const scriptLegalInventoryTools = [ - null, - "bootForceFloor", - "bootIce", - "bootFire", - "bootWater", - "tnt", - "helmet", - "bootDirt", - "lightningBolt", - "bowlingBall", - "teleportYellow", - "railroadSign", - "foil", - "secretEye", - "bribe", - "bootSpeed", - "hook", -] as const - -export type ScriptLegalInventoryTool = - (typeof scriptLegalInventoryTools)[number] - export type InventoryKeys = Record<"red" | "green" | "blue" | "yellow", number> +export type MapInterruptWinResponse = { + type: "win" + totalScore: number + lastExitGender: "male" | "female" + lastExitN: number + inventoryTools: InventoryTools + inventoryKeys: InventoryKeys + timeLeft: number +} + export type MapInterruptResponse = - | { - type: "win" - totalScore: number - lastExitGender: "male" | "female" - lastExitN: number - inventoryTools: ScriptLegalInventoryTool[] - inventoryKeys: InventoryKeys - timeLeft: number - } + | MapInterruptWinResponse | { type: "retry" } | { type: "skip" } -export interface MapInitState { +export interface C2GLevelModifiers { playableEnterN?: number - inventoryTools?: ScriptLegalInventoryTool[] + inventoryTools?: InventoryTools inventoryKeys?: InventoryKeys timeLeft?: number + noBonusCollection?: boolean +} + +export interface C2GGameModifiers extends C2GLevelModifiers { autoNext: boolean - noBonusCollection: boolean autoPlayReplay: boolean noPopups: boolean + speedMultiplier?: number } function stringToValue(str: string): number { @@ -461,6 +449,83 @@ function stringToValue(str: string): number { export const MAX_LINES_UNTIL_TERMINATION = 1_000_000_000 +export function* tokenizeLine(line: string): Generator { + let linePos = 0 + while (line[linePos] !== undefined) { + // Strings + if (line[linePos] === '"') { + let stringValue = "" + linePos++ + const leakyValue = line.slice(linePos, linePos + 4) + // Lines are not only terminated by closing "'s, but also by newlines. + while (line[linePos] !== '"' && line[linePos] !== undefined) { + stringValue += line[linePos] + linePos++ + } + yield { + type: "string", + closed: line[linePos] === '"', + value: stringValue, + leakyValue, + } + // Consume the closing " + if (line[linePos] === '"') linePos++ + continue + } + // Comments + if (line[linePos] === ";") { + yield { type: "comment", value: line.slice(linePos + 1) } + break + } + // Labels + if (line[linePos] === "#") { + let labelValue = "" + linePos++ + const leakyValue = line.slice(linePos, linePos + 4) + while (line[linePos] !== undefined && line[linePos] !== " ") { + labelValue += line[linePos] + linePos++ + } + yield { type: "label", value: labelValue, leakyValue } + continue + } + // Integer + if (!isNaN(parseInt(line[linePos], 10))) { + // Vanilla `parseInt` works in this case + const intValue = parseInt(line.slice(linePos), 10) + yield { type: "number", value: intValue } + linePos += intValue.toString().length + continue + } + // Operator + { + const operator = scriptOperators.find(val => + line.slice(linePos).startsWith(val) + ) + if (operator !== undefined) { + const leakyValue = line.slice(linePos, linePos + 4) + yield { type: "operator", value: operator, leakyValue } + linePos += operator.length + continue + } + } + // Keyword + { + const keyword = scriptKeywords.find(val => + line.slice(linePos).toLowerCase().startsWith(val) + ) + if (keyword !== undefined) { + const leakyValue = line.slice(linePos, linePos + 4) + yield { type: "keyword", value: keyword, leakyValue } + linePos += keyword.length + continue + } + } + // Nothing matched, just move on. + linePos++ + } +} + export class ScriptRunner { labels: Record = {} // The current filesystem position, changed by `chdir`. @@ -495,9 +560,9 @@ export class ScriptRunner { loadScript(script: string, scriptPath?: string, requireTitle = false): void { this.scriptLines = script.split(/\n\r?/g) if (requireTitle) { - const scriptTitleToken = this.tokenizeLine(0).find( - (token): token is StringToken => token.type === "string" - ) + const scriptTitleToken = Array.from( + tokenizeLine(this.scriptLines[0]) + ).find((token): token is StringToken => token.type === "string") if (!scriptTitleToken || !scriptTitleToken.closed) throw new Error( "The first line of the script must contain a closed string describing the title." @@ -510,94 +575,15 @@ export class ScriptRunner { this.generateLabels() } generateLabels(): void { - for (let lineN = 0; lineN < this.scriptLines.length; lineN++) { - const tokens = this.tokenizeLine(lineN) - const labelToken = tokens[0] + for (let lineN = 0; lineN < this.scriptLines.length; lineN += 1) { + const tokens = tokenizeLine(this.scriptLines[lineN]) + const labelToken = tokens.next().value if (labelToken?.type !== "label") continue // The first label in the script is the canonical one if (this.labels[labelToken.value]) continue this.labels[labelToken.value] = lineN } } - tokenizeLine(lineN: number): Token[] { - const line = this.scriptLines[lineN] - const tokens: Token[] = [] - let linePos = 0 - while (line[linePos] !== undefined) { - // Strings - if (line[linePos] === '"') { - let stringValue = "" - linePos++ - const leakyValue = line.slice(linePos, linePos + 4) - // Lines are not only terminated by closing "'s, but also by newlines. - while (line[linePos] !== '"' && line[linePos] !== undefined) { - stringValue += line[linePos] - linePos++ - } - tokens.push({ - type: "string", - closed: line[linePos] === '"', - value: stringValue, - leakyValue, - }) - // Consume the closing " - if (line[linePos] === '"') linePos++ - continue - } - // Comments - if (line[linePos] === ";") { - tokens.push({ type: "comment", value: line.slice(linePos + 1) }) - break - } - // Labels - if (line[linePos] === "#") { - let labelValue = "" - linePos++ - const leakyValue = line.slice(linePos, linePos + 4) - while (line[linePos] !== undefined && line[linePos] !== " ") { - labelValue += line[linePos] - linePos++ - } - tokens.push({ type: "label", value: labelValue, leakyValue }) - continue - } - // Integer - if (!isNaN(parseInt(line[linePos], 10))) { - // Vanilla `parseInt` works in this case - const intValue = parseInt(line.slice(linePos), 10) - tokens.push({ type: "number", value: intValue }) - linePos += intValue.toString().length - continue - } - // Operator - { - const operator = scriptOperators.find(val => - line.slice(linePos).startsWith(val) - ) - if (operator !== undefined) { - const leakyValue = line.slice(linePos, linePos + 4) - tokens.push({ type: "operator", value: operator, leakyValue }) - linePos += operator.length - continue - } - } - // Keyword - { - const keyword = scriptKeywords.find(val => - line.slice(linePos).toLowerCase().startsWith(val) - ) - if (keyword !== undefined) { - const leakyValue = line.slice(linePos, linePos + 4) - tokens.push({ type: "keyword", value: keyword, leakyValue }) - linePos += keyword.length - continue - } - } - // Nothing matched, just move on. - linePos++ - } - return tokens - } getTokenValue(token: Token): number { // Comments have explicitly the integer value 0. This is demonstrated by the following code: // ``` @@ -693,8 +679,8 @@ export class ScriptRunner { if (this.state.currentLine >= this.scriptLines.length) throw new Error("The end of the script has already been reached.") - const line = this.tokenizeLine(this.state.currentLine) - this.executeTokens(line, true) + const line = tokenizeLine(this.scriptLines[this.state.currentLine]) + this.executeTokens(Array.from(line), true) this.state.currentLine += 1 } @@ -759,14 +745,8 @@ export class ScriptRunner { keys.green * 0x1000000 const tools = interruptData.inventoryTools - function getToolId(id: ScriptLegalInventoryTool): number { - return scriptLegalInventoryTools.indexOf(id) - } this.state.variables.tools = - getToolId(tools[0]) + - getToolId(tools[1]) * 0x100 + - getToolId(tools[2]) * 0x10000 + - getToolId(tools[3]) * 0x1000000 + tools[0] + tools[1] * 0x100 + tools[2] * 0x10000 + tools[3] * 0x1000000 this.state.variables.gender = scriptConstants[interruptData.lastExitGender] this.state.variables.exit = interruptData.lastExitN this.state.variables.tleft = Math.imul(interruptData.timeLeft / 60, 1) @@ -781,48 +761,69 @@ export class ScriptRunner { this.loadScript(fileData, this.scriptInterrupt.path) this.scriptInterrupt = null } - getMapInitState(): MapInitState { - const state: MapInitState = { - autoNext: false, - autoPlayReplay: false, - noBonusCollection: false, - noPopups: false, - } - const vars = this.state.variables - if (!vars) return state - if (vars.enter && vars.enter > 0) { - state.playableEnterN = vars.enter - 1 - } - if (!vars.flags) return state - state.autoNext = (vars.flags & scriptConstants.continue) !== 0 - state.autoPlayReplay = (vars.flags & scriptConstants.replay) !== 0 - state.noBonusCollection = (vars.flags & scriptConstants.no_bonus) !== 0 - state.noPopups = (vars.flags & scriptConstants.silent) !== 0 - if (vars.flags & scriptConstants.ktime) { - state.timeLeft = vars.tleft ?? 0 - } - if (vars.flags & scriptConstants.ktools) { - const keys = vars.keys ?? 0 - state.inventoryKeys = { - red: keys & 0xff, - blue: (keys >>> 8) & 0xff, - yellow: (keys >>> 16) & 0xff, - green: (keys >>> 24) & 0xff, - } - const tools = vars.tools ?? 0 - const toolIdx = [ - tools & 0xff, - (tools >>> 8) & 0xff, - (tools >>> 16) & 0xff, - (tools >>> 24) & 0xff, - ] - .map(item => item % 0x11) - .filter(item => item !== 0) - state.inventoryTools = toolIdx.map( - item => scriptLegalInventoryTools[item] - ) +} + +export function getC2GGameModifiers( + scriptState: IScriptState +): C2GGameModifiers { + const state: C2GGameModifiers = { + autoNext: false, + autoPlayReplay: false, + noBonusCollection: false, + noPopups: false, + } + const vars = scriptState.variables + if (!vars) return state + if (vars.enter && vars.enter > 0) { + state.playableEnterN = vars.enter - 1 + } + if (vars.speed && vars.speed > 0) { + state.speedMultiplier = vars.speed + } + if (!vars.flags) return state + state.autoNext = (vars.flags & scriptConstants.continue) !== 0 + state.autoPlayReplay = (vars.flags & scriptConstants.replay) !== 0 + state.noBonusCollection = (vars.flags & scriptConstants.no_bonus) !== 0 + state.noPopups = (vars.flags & scriptConstants.silent) !== 0 + if (vars.flags & scriptConstants.ktime) { + state.timeLeft = vars.tleft ?? 0 + } + if (vars.flags & scriptConstants.ktools) { + const keys = vars.keys ?? 0 + state.inventoryKeys = { + red: keys & 0xff, + blue: (keys >>> 8) & 0xff, + yellow: (keys >>> 16) & 0xff, + green: (keys >>> 24) & 0xff, } - return state + const tools = vars.tools ?? 0 + state.inventoryTools = [ + (tools & 0xff) % 0x11, + ((tools >>> 8) & 0xff) % 0x11, + ((tools >>> 16) & 0xff) % 0x11, + ((tools >>> 24) & 0xff) % 0x11, + ] + } + return state +} + +export function winInterruptResponseFromLevel( + level: Level +): Omit { + const lastPlayerInfo = level.lastWonPlayerInfo + const inventory = lastPlayerInfo.inventory + return { + type: "win", + timeLeft: Math.ceil(level.timeLeft / 60), + inventoryKeys: { + red: inventory.keysRed, + green: inventory.keysGreen, + blue: inventory.keysBlue, + yellow: inventory.keysYellow, + }, + inventoryTools: inventory.getItems(), + lastExitGender: lastPlayerInfo.isMale ? "male" : "female", + lastExitN: lastPlayerInfo.exitN, } } @@ -841,6 +842,7 @@ export interface ScriptMetadata { difficulty?: number thumbnail?: "first level" | "image" | "none" listingPriority?: "top" | "bottom" | "unlisted" + anyMetadataSpecified: boolean } export function parseScriptMetadata(text: string): ScriptMetadata { @@ -893,5 +895,121 @@ export function parseScriptMetadata(text: string): ScriptMetadata { by: rawScriptMeta["by"], description: rawScriptMeta["description"], difficulty, + anyMetadataSpecified: Object.keys(rawScriptMeta).length > 0, + } +} + +export const scriptInnatelyNonLinearTokens: string[] = [ + "rand", + "goto", + "chain", + "do", + "line", +] + +export const scriptUserControlledVariables: string[] = [ + "exit", + "gender", + "score", + "keys", + "tools", + "tleft", +] + +// Lol +function arrayBinarySearch( + arr: T[], + item: T, + valueMap: (v: T) => number +): { idx: number; found: boolean } { + const itemValue = valueMap(item) + let lowIdx = 0 + let highIdx = arr.length + while (lowIdx !== highIdx) { + let compIdx = Math.floor((lowIdx + highIdx) / 2) + const compValue = valueMap(arr[compIdx]) + if (itemValue > compValue) { + lowIdx = compIdx + 1 + } else if (itemValue < compValue) { + highIdx = compIdx + } else { + return { idx: compIdx, found: true } + } + } + return { idx: lowIdx, found: false } +} + +export async function makeLinearLevels( + setData: LevelSetData +): Promise { + let inPrelude = true + const levels: ILevelInfo[] = [] + let lastLevel: ILevelInfo | undefined + let initialPrologue: string[] = [] + const script = new ScriptRunner( + await setData.loaderFunction(setData.scriptFile, false) + ) + while ((script.state.currentLine ?? 0) < script.scriptLines.length) { + let tokens = tokenizeLine(script.scriptLines[script.state.currentLine ?? 0]) + let lineHasAssignment = false + for (const token of tokens) { + if ( + token.type === "keyword" && + (scriptInnatelyNonLinearTokens.includes(token.value) || + (!inPrelude && scriptUserControlledVariables.includes(token.value))) + ) + return null + if (token.type === "operator" && token.value === "=") { + lineHasAssignment = true + } + if ( + lineHasAssignment && + token.type === "keyword" && + token.value === "map" + ) { + return null + } + } + script.executeLine() + const interrupt = script.scriptInterrupt + if (interrupt?.type === "chain") + throw new Error( + "Script shouldn't be able to chain, chain keyword should've caused linearization to fail" + ) + else if (interrupt?.type === "script") { + if (lastLevel) { + lastLevel.epilogueText ??= [] + lastLevel.epilogueText.push(interrupt.text) + } else { + initialPrologue.push(interrupt.text) + } + } else if (interrupt?.type === "map") { + inPrelude = false + const levelMeta = parseC2MMeta( + await setData.loaderFunction(interrupt.path, true) + ) + if (levelMeta.c2gCommand) return null + const level: ILevelInfo = { + title: levelMeta.title, + prologueText: + initialPrologue.length !== 0 ? initialPrologue : undefined, + attempts: [], + levelNumber: script.state.variables?.level ?? 1, + scriptState: clone(script.state), + levelFilePath: interrupt.path, + } + initialPrologue = [] + lastLevel = level + const { idx: levelIdx, found: levelExists } = arrayBinarySearch( + levels, + level, + lvl => lvl.levelNumber! + ) + if (levelExists) return null + levels.splice(levelIdx, 0, level) + script.handleMapInterrupt({ type: "skip" }) + } + script.scriptInterrupt = null } + return levels } diff --git a/libnotcc-bind/src/nonbind/inputs.ts b/libnotcc-bind/src/nonbind/inputs.ts new file mode 100644 index 00000000..feeb7185 --- /dev/null +++ b/libnotcc-bind/src/nonbind/inputs.ts @@ -0,0 +1,307 @@ +import { Direction } from "../actor.js" +import { Level, Replay } from "../level.js" +import { C2GLevelModifiers, getC2GGameModifiers } from "./c2g.js" +import { ISolutionInfo } from "./nccs.pb.js" + +export type KeyInputs = number +export const KEY_INPUTS = { + up: 1 << 0, + right: 1 << 1, + down: 1 << 2, + left: 1 << 3, + directional: 0b1111, + dropItem: 1 << 4, + cycleItems: 1 << 5, + switchPlayer: 1 << 6, +} + +export function filterSimulChar(input: KeyInputs): KeyInputs { + if (input & KEY_INPUTS.switchPlayer) return KEY_INPUTS.switchPlayer + return input +} + +export interface LevelModifiers extends C2GLevelModifiers { + randomForceFloorDirection?: Direction + blobMod?: number +} + +export abstract class InputProvider { + abstract getInput(curSubtick: number, seatIdx: number): KeyInputs + abstract levelModifiers(): LevelModifiers + abstract getLength(): number + outOfInput(curSubtick: number): boolean { + return curSubtick >= this.getLength() + } + inputProgress(curSubtick: number): number { + return Math.min(1, curSubtick / this.getLength()) + } +} + +export function makeSimpleInputs(comp: Uint8Array): Uint8Array { + const uncomp: number[] = [] + for (let compIndex = 0; compIndex <= comp.length; compIndex += 2) { + const input = comp[compIndex] + const length = comp[compIndex + 1] + for (let i = 0; i < length; i += 1) { + uncomp.push(input) + } + } + if (comp.length % 2 !== 0) { + uncomp.push(comp[comp.length - 1]) + uncomp.push(comp[comp.length - 1]) + uncomp.push(comp[comp.length - 1]) + } + + return new Uint8Array(uncomp.filter((_, i) => i % 3 === 2)) +} + +function subtickToTick(subtick: number) { + return (subtick / 3) | 0 +} + +export class SolutionInfoInputProvider extends InputProvider { + inputs: Uint8Array[] + constructor(public solution: ISolutionInfo) { + super() + this.inputs = solution.steps!.map(buf => makeSimpleInputs(buf)) + } + getInput(curSubtick: number, seatIdx: number): KeyInputs { + return this.inputs[seatIdx][subtickToTick(curSubtick)] + } + levelModifiers(): LevelModifiers { + const levelState = this.solution.levelState + if (!levelState) return {} + + const levelInit: LevelModifiers = levelState.cc2Data?.scriptState + ? getC2GGameModifiers(levelState.cc2Data.scriptState) + : {} + + if (typeof levelState.randomForceFloorDirection === "number") { + levelInit.randomForceFloorDirection = + levelState.randomForceFloorDirection as unknown as Direction + } + const blobMod = levelState.cc2Data?.blobModifier + if (typeof blobMod === "number") { + levelInit.blobMod = blobMod + } + return levelInit + } + getLength(): number { + return this.inputs[0].length + } +} + +export interface RouteFor { + Set?: string + LevelName?: string + LevelNumber?: number +} + +export type RouteDirection = "UP" | "RIGHT" | "DOWN" | "LEFT" + +export interface Route { + Moves: string + Rule: string + Encode?: "UTF-8" + "Initial Slide"?: RouteDirection + /** + * Not the same as "Seed", as Blobmod only affects blobs and nothing else, unlilke the seed in TW, which affects all randomness + */ + Blobmod?: number + // Unused in CC2 + Step?: never + Seed?: never + // NotCC-invented metadata + For?: RouteFor + ExportApp?: string +} + +const charToKeyInputMap: Record = { + u: KEY_INPUTS.up, + r: KEY_INPUTS.right, + d: KEY_INPUTS.down, + l: KEY_INPUTS.left, + p: KEY_INPUTS.dropItem, + c: KEY_INPUTS.cycleItems, + s: KEY_INPUTS.switchPlayer, + "↗": KEY_INPUTS.up | KEY_INPUTS.right, + "↘": KEY_INPUTS.right | KEY_INPUTS.down, + "↙": KEY_INPUTS.down | KEY_INPUTS.left, + "↖": KEY_INPUTS.left | KEY_INPUTS.up, +} + +export function areKeyInputsMoving(input: KeyInputs): boolean { + return ( + (input & + (KEY_INPUTS.up | + KEY_INPUTS.right | + KEY_INPUTS.down | + KEY_INPUTS.left)) !== + 0 + ) +} + +export function generateSecondaryChars(input: KeyInputs) { + let char = "" + if (input & KEY_INPUTS.dropItem) char += "p" + if (input & KEY_INPUTS.cycleItems) char += "c" + if (input & KEY_INPUTS.switchPlayer) char += "s" + return char +} + +function binTest(val: number, mask: number): boolean { + return (val & mask) == mask +} + +export function keyInputToChar(input: KeyInputs, uppercase: boolean): string { + let char = generateSecondaryChars(input) + if (binTest(input, KEY_INPUTS.up | KEY_INPUTS.right)) + char += uppercase ? "⇗" : "↗" + else if (binTest(input, KEY_INPUTS.right | KEY_INPUTS.down)) + char += uppercase ? "⇘" : "↘" + else if (binTest(input, KEY_INPUTS.down | KEY_INPUTS.left)) + char += uppercase ? "⇙" : "↙" + else if (binTest(input, KEY_INPUTS.left | KEY_INPUTS.up)) + char += uppercase ? "⇖" : "↖" + else if (input & KEY_INPUTS.up) char += uppercase ? "U" : "u" + else if (input & KEY_INPUTS.right) char += uppercase ? "R" : "r" + else if (input & KEY_INPUTS.down) char += uppercase ? "D" : "d" + else if (input & KEY_INPUTS.left) char += uppercase ? "L" : "l" + else char += "-" + return char +} + +export function charToKeyInput(char: string): KeyInputs { + let input = 0 + for (const modChar of char) { + let keyInputs = charToKeyInputMap[modChar] + input |= keyInputs + } + return input +} + +export function splitRouteCharString(charString: string): string[] { + return charString.split(/(?= this.moves.length) return 0 + return charToKeyInput(this.moves[subtickToTick(curSubtick)]) + } + levelModifiers(): LevelModifiers { + const levelMods: LevelModifiers = {} + if (!this.route) return levelMods + + if (this.route["Initial Slide"] !== undefined) { + levelMods.randomForceFloorDirection = + Direction[this.route["Initial Slide"]] + } + if (this.route.Blobmod !== undefined) { + levelMods.blobMod = this.route.Blobmod + } + return levelMods + } + getLength(): number { + return this.moves.length * 3 + } +} + +export class ReplayInputProvider extends InputProvider { + bonusTicks = 3600 + constructor(public replay: Replay) { + super() + replay._assert_live() + } + levelModifiers(): LevelModifiers { + return { + randomForceFloorDirection: this.replay + .randomForceFloorDirection as Direction, + blobMod: this.replay.rngBlob, + } + } + getInput(curSubtick: number, seatIdx: number): number { + if (seatIdx !== 0) throw new Error("C2M replays don't support multiseat") + let currentTick = Math.min( + subtickToTick(curSubtick), + this.replay.inputs.length - 1 + ) + return this.replay.inputs[currentTick] + } + getLength(): number { + return this.replay.inputs.length * 3 + } + outOfInput(curSubtick: number): boolean { + return curSubtick > this.getLength() + this.bonusTicks + } +} + +export function applyLevelModifiers(level: Level, modifiers: LevelModifiers) { + if (modifiers.randomForceFloorDirection !== undefined) { + level.randomForceFloorDirection = modifiers.randomForceFloorDirection + } + if (modifiers.blobMod !== undefined) { + level.rngBlob = modifiers.blobMod + } + if (modifiers.timeLeft !== undefined) { + level.timeLeft = modifiers.timeLeft * 60 + } + if (modifiers.noBonusCollection) { + level.ignoreBonusFlags = true + } + let playerIdx = 0 + // Have to go in reading order + for (let y = 0; y < level.height; y += 1) { + for (let x = 0; x < level.width; x += 1) { + const actor = level.getCell(x, y).actor + if ( + !actor || + !(actor.type.name === "chip" || actor.type.name === "melinda") + ) + continue + // Yes, we start the actual player indexing at `1`, `0` means we don't use `enter` at all + playerIdx += 1 + if (modifiers.inventoryTools) { + actor.inventory.setItems(modifiers.inventoryTools) + } + if (modifiers.inventoryKeys) { + actor.inventory.keysRed = modifiers.inventoryKeys.red + actor.inventory.keysGreen = modifiers.inventoryKeys.green + actor.inventory.keysBlue = modifiers.inventoryKeys.blue + actor.inventory.keysYellow = modifiers.inventoryKeys.yellow + } + if (modifiers.playableEnterN && modifiers.playableEnterN !== playerIdx) { + level.erase(actor) + } + } + } +} diff --git a/logic/src/levelset.ts b/libnotcc-bind/src/nonbind/levelset.ts similarity index 52% rename from logic/src/levelset.ts rename to libnotcc-bind/src/nonbind/levelset.ts index a4bbf6c7..b854c129 100644 --- a/logic/src/levelset.ts +++ b/libnotcc-bind/src/nonbind/levelset.ts @@ -1,23 +1,26 @@ import clone from "clone" -import { protoTimeToMs } from "./attemptTracker.js" +import { protoTimeToMs, protoTimeToSubticks } from "./attemptTracker.js" import { ScriptRunner, MapInterruptResponse, ScriptInterrupt, -} from "./parsers/c2g.js" -import { LevelData, parseC2M } from "./parsers/c2m.js" + makeLinearLevels, +} from "./c2g.js" +import { Level, LevelMetadata, parseC2M, parseC2MMeta } from "../level.js" import { IAttemptInfo, ILevelInfo, ISetInfo, ISolutionOutcomeInfo, -} from "./parsers/nccs.pb.js" +} from "./nccs.pb.js" export interface LevelSetRecord { - levelData?: LevelData + levelData?: Level levelInfo: ILevelInfo } +export type LevelSetRecordFull = Required + export function calculateLevelPoints( levelN: number, timeLeft: number, @@ -28,69 +31,126 @@ export function calculateLevelPoints( export interface SolutionMetrics { timeLeft: number - points: number + score: number realTime: number } -function snapNumber(num: number, period: number): number { - return num - (num % period) -} - export function metricsFromAttempt( levelN: number, outcome: ISolutionOutcomeInfo ): SolutionMetrics { if (!outcome.timeLeft || !outcome.absoluteTime) throw new Error("Incomplete attempt info") - const timeLeft = snapNumber(protoTimeToMs(outcome.timeLeft) / 1000, 1 / 60) + const timeLeft = protoTimeToSubticks(outcome.timeLeft) const points = calculateLevelPoints( levelN, - Math.ceil(timeLeft), + Math.ceil(timeLeft / 60), outcome.bonusScore ?? 0 ) - const realTime = snapNumber( - protoTimeToMs(outcome.absoluteTime) / 1000, - 1 / 60 - ) - return { timeLeft, points, realTime } + const realTime = protoTimeToMs(outcome.absoluteTime) / 1000 + + return { timeLeft, score: points, realTime } } -export function findBestMetrics(info: ILevelInfo): Partial { +export function findBestMetrics(info: ILevelInfo): SolutionMetrics | null { if (typeof info.levelNumber !== "number") throw new Error("Incomplete level info") const metrics: Partial = {} - if (!info.attempts || info.attempts.length === 0) return metrics + if (!info.attempts || info.attempts.length === 0) return null const levelN = info.levelNumber for (const attempt of info.attempts) { if (!attempt.solution?.outcome) continue - const { realTime, points, timeLeft } = metricsFromAttempt( - levelN, - attempt.solution.outcome - ) + const { + realTime, + score: points, + timeLeft, + } = metricsFromAttempt(levelN, attempt.solution.outcome) if (metrics.timeLeft === undefined || metrics.timeLeft < timeLeft) { metrics.timeLeft = timeLeft } - if (metrics.points === undefined || metrics.points < points) { - metrics.points = points + if (metrics.score === undefined || metrics.score < points) { + metrics.score = points } if (metrics.realTime === undefined || metrics.realTime > realTime) { metrics.realTime = realTime } } - return metrics + // If we went over at least one successful attempt, we definitely populated + // all fields. + return "timeLeft" in metrics ? (metrics as SolutionMetrics) : null } -export type LevelSetLoaderFunction = ( +export type LevelSetLoaderFunction = (( path: string, - binary: boolean -) => Promise + binary: false +) => Promise) & + ((path: string, binary: true) => Promise) & + ((path: string, binary: boolean) => Promise) + +export interface LevelSetData { + loaderFunction: LevelSetLoaderFunction + scriptFile: string +} + +export abstract class LevelSet { + currentLevel: number = 1 + inPostGame = false + // Why do abstract classes need constructors, again? + constructor(public loaderFunction: LevelSetLoaderFunction) {} + abstract gameTitle(): string + abstract scriptTitle(): string + abstract initialLevel(): Promise + abstract currentLevelRecord(): LevelSetRecord + abstract previousLevel(): Promise + abstract nextLevel(type: MapInterruptResponse): Promise + abstract toSetInfo(): ISetInfo + logAttemptInfo(attempt: IAttemptInfo): void { + const level = this.currentLevelRecord() + if (!level) + throw new Error("This set appears to be on a non-existent level.") + level.levelInfo.attempts ??= [] + level.levelInfo.attempts.push(attempt) + } + abstract goToLevel(n: number): Promise + abstract canGoToLevel(n: number): boolean + abstract listLevels(): LevelSetRecord[] + async loadLevelData(record: LevelSetRecord): Promise { + if (record.levelData) return record as LevelSetRecordFull + + const levelPath = record.levelInfo.levelFilePath + if (!levelPath) throw new Error("The level does not have a path specified.") + + const levelBuffer = await this.loaderFunction(levelPath, true) + record.levelData = parseC2M(levelBuffer) + // Emulate CC1 Steam having CC1 boots always enabled + if (this.gameTitle() === "Chips Challenge") { + record.levelData.metadata.cc1Boots = true + } + return record as LevelSetRecordFull + } + totalMetrics(): SolutionMetrics { + return this.listLevels() + .map(rec => findBestMetrics(rec.levelInfo)) + .reduce( + (acc, val) => + val === null + ? acc + : { + realTime: acc.realTime + val.realTime, + timeLeft: acc.timeLeft + val.timeLeft, + score: acc.score + val.score, + }, + { score: 0, timeLeft: 0, realTime: 0 } + ) + } +} -export class LevelSet { +export class FullC2GLevelSet extends LevelSet { seenLevels: Record scriptRunner: ScriptRunner currentLevel: number - inPostGame = false + hasReahedPostgame = false constructor( mainScriptPath: string, scriptData: string, @@ -104,8 +164,10 @@ export class LevelSet { constructor( setDataOrMainScriptPath: ISetInfo | string, scriptData: string, - public loaderFunction: LevelSetLoaderFunction + loaderFunction: LevelSetLoaderFunction ) { + super(loaderFunction) + this.loaderFunction = loaderFunction if (typeof setDataOrMainScriptPath === "string") { // This is a new level set const mainScriptPath = setDataOrMainScriptPath @@ -126,6 +188,7 @@ export class LevelSet { throw new Error("Given set data is missing essential properties.") this.currentLevel = setData.currentLevel + this.hasReahedPostgame = !!setData.hasReachedPostgame this.seenLevels = Object.fromEntries( setData.levels.map<[number, LevelSetRecord]>(lvl => { if (typeof lvl.levelNumber !== "number") @@ -155,7 +218,11 @@ export class LevelSet { } } static async constructAsync( - setData: ISetInfo, + saveDataOrMainScriptPath: string | ISetInfo, + loaderFunction: LevelSetLoaderFunction + ): Promise + static async constructAsync( + saveData: ISetInfo, loaderFunction: LevelSetLoaderFunction ): Promise static async constructAsync( @@ -163,17 +230,17 @@ export class LevelSet { loaderFunction: LevelSetLoaderFunction ): Promise static async constructAsync( - setDataOrMainScriptPath: string | ISetInfo, + saveDataOrMainScriptPath: string | ISetInfo, loaderFunction: LevelSetLoaderFunction ): Promise { let scriptPath: string - if (typeof setDataOrMainScriptPath === "string") { - scriptPath = setDataOrMainScriptPath + if (typeof saveDataOrMainScriptPath === "string") { + scriptPath = saveDataOrMainScriptPath } else { - const levelN = setDataOrMainScriptPath.currentLevel + const levelN = saveDataOrMainScriptPath.currentLevel if (typeof levelN !== "number") throw new Error("The set data must have a current level set.") - const levelData = setDataOrMainScriptPath.levels?.find( + const levelData = saveDataOrMainScriptPath.levels?.find( lvl => typeof lvl.levelNumber === "number" && lvl.levelNumber === levelN ) const setScriptPath = levelData?.scriptState?.scriptPath @@ -182,27 +249,23 @@ export class LevelSet { scriptPath = setScriptPath } - const scriptData = (await loaderFunction(scriptPath, false)) as string + const scriptData = await loaderFunction(scriptPath, false) return new this( // This is false, but Typescript for some reason doesn't like passing a // union here? - setDataOrMainScriptPath as string, + scriptPath, scriptData, loaderFunction ) } - lastLevelResult?: MapInterruptResponse - async getNextRecord(): Promise { + async nextLevel(res: MapInterruptResponse): Promise { if (this.inPostGame) return null if (this.scriptRunner.scriptInterrupt) { - if (!this.lastLevelResult) - throw new Error("An action for the current map must be set.") - this.scriptRunner.handleMapInterrupt(this.lastLevelResult) + this.scriptRunner.handleMapInterrupt(res) } const lastLevel = this.seenLevels[this.currentLevel]?.levelInfo - let prologue: string | undefined = "" - let lastEpilogue: string | undefined = "" + const accumulatedIntermisssionText: string[] = [] let interrupt: ScriptInterrupt | null @@ -210,18 +273,11 @@ export class LevelSet { interrupt = this.scriptRunner.executeUntilInterrupt() // Handle interrupts if (interrupt?.type === "script") { - if (!lastLevel) { - prologue += interrupt.text - } else { - lastEpilogue += interrupt.text - } + accumulatedIntermisssionText.push(interrupt.text) this.scriptRunner.scriptInterrupt = null } else if (interrupt?.type === "chain") { // Chain interrupt - const newFile = (await this.loaderFunction( - interrupt.path, - false - )) as string + const newFile = await this.loaderFunction(interrupt.path, false) this.scriptRunner.handleChainInterrupt(newFile) } } while (interrupt && interrupt.type !== "map") @@ -231,6 +287,7 @@ export class LevelSet { | null if (recordInterrupt === null) { this.inPostGame = true + this.hasReahedPostgame = true return null } @@ -240,12 +297,12 @@ export class LevelSet { const existingRecord = this.seenLevels[levelN] if (lastLevel) { - lastLevel.epilogueText = lastEpilogue + lastLevel.epilogueText = accumulatedIntermisssionText } const record: LevelSetRecord = { levelInfo: { - prologueText: prologue, + prologueText: lastLevel ? undefined : accumulatedIntermisssionText, scriptState: clone(this.scriptRunner.state), levelNumber: levelN, attempts: existingRecord?.levelInfo.attempts, @@ -254,43 +311,21 @@ export class LevelSet { } this.seenLevels[levelN] = record - await this.verifyLevelDataAvailability( - levelN, - existingRecord?.levelInfo.levelFilePath === recordInterrupt.path - ? existingRecord?.levelData - : undefined - ) - - record.levelInfo.title = record.levelData!.name + record.levelInfo.title = (await this.fetchLevelMetadata(levelN)).title + // TODO: Handle [COM] also return record } - async verifyLevelDataAvailability( - levelN: number, - levelData?: LevelData - ): Promise { + async fetchLevelMetadata(levelN: number): Promise { const levelRecord = this.seenLevels[levelN] if (!levelRecord) throw new Error(`No level ${levelN} exists.`) - if (levelRecord.levelData) return - if (levelData) { - levelRecord.levelData = levelData - return - } - const levelPath = levelRecord.levelInfo.levelFilePath - if (!levelPath) throw new Error("The level does not have a path specified.") - const levelBuffer = (await this.loaderFunction( - levelPath, - true - )) as ArrayBuffer - levelRecord.levelData = parseC2M(levelBuffer) - // Emulate CC1 Steam having CC1 boots always enabled - if (this.scriptRunner.state.scriptTitle === "Chips Challenge") { - levelRecord.levelData.cc1Boots = true - } + const levelBuffer = await this.loaderFunction(levelPath, true) + return parseC2MMeta(levelBuffer) } + toSetInfo(): ISetInfo { const currentScriptState = this.seenLevels[this.currentLevel].levelInfo.scriptState @@ -300,13 +335,14 @@ export class LevelSet { setName: currentScriptState?.scriptTitle, levels: Object.values(this.seenLevels).map(lvl => lvl.levelInfo), currentLevel: this.currentLevel, + hasReachedPostgame: this.hasReahedPostgame, } } /** * Backtracks to the last level number before the current one. * @returns `null` is returned if there's no previous level */ - async getPreviousRecord(): Promise { + async previousLevel(): Promise { let newLevelN: number if (this.inPostGame) { // If we're in postgame, return to the last level (technically, the @@ -326,8 +362,13 @@ export class LevelSet { if (!newLevelN) return null return this.goToLevel(newLevelN) } + canGoToLevel(levelN: number): boolean { + return levelN in this.seenLevels + } async goToLevel(newLevelN: number): Promise { const newRecord = this.seenLevels[newLevelN] + if (!newRecord) + throw new Error(`Level set hasn't seen level ${newLevelN} yet.`) const scriptLevelN = newRecord.levelInfo.scriptState?.variables?.level ?? 0 if (scriptLevelN !== newLevelN) @@ -353,10 +394,7 @@ export class LevelSet { // Reload the script file if it has changed if (oldScriptPath !== scriptPath) { - const scriptData = (await this.loaderFunction( - scriptPath, - false - )) as string + const scriptData = await this.loaderFunction(scriptPath, false) this.scriptRunner.loadScript(scriptData, scriptPath) } @@ -366,25 +404,25 @@ export class LevelSet { path: filePath, } - await this.verifyLevelDataAvailability(newLevelN) - - return newRecord + return this.loadLevelData(newRecord) } /** * Figures out the level record this levelset is currently at and returns it. * Should only be used right after constructing this set. Reloading the * current record after restarting should be done by passing the retry - * `map` interrupt to `getNextLevel` instead. + * `map` interrupt to `nextLevel` instead. */ - async getCurrentRecord(): Promise { + async initialLevel(): Promise { if (Object.keys(this.seenLevels).length === 0) { // If we have seen no levels, this must be a new set, so just open the // first level - const record = await this.getNextRecord() + // (the map resolution type doesn't matter because we actually didn't have a map + // to respond to) + const record = await this.nextLevel({ type: "skip" }) if (record === null) { throw new Error("This set appears to have no levels.") } - return record + return this.loadLevelData(record) } else { // This set already has data. Just verify that the level data exists and // return the `currentLevel` level record. @@ -393,17 +431,117 @@ export class LevelSet { // Try loading the level before this one. This may be an incorrectly // written save in postgame. (Have to get the next record first, to // detect if we're in postgame) - const nextRecord = await this.getNextRecord() + const nextRecord = await this.nextLevel({ type: "skip" }) if (nextRecord === null && this.inPostGame) { - record = await this.getPreviousRecord() + record = await this.previousLevel() } if (record === null) throw new Error( "This set appears to currently be on an non-existent level." ) } - await this.verifyLevelDataAvailability(this.currentLevel) - return record + return this.loadLevelData(record) + } + } + gameTitle(): string { + return this.scriptRunner.state.gameTitle! + } + scriptTitle(): string { + return this.scriptRunner.state.scriptTitle! + } + listLevels(): LevelSetRecord[] { + return Object.entries(this.seenLevels) + .sort(([a], [b]) => parseInt(a) - parseInt(b)) + .map(([, rec]) => rec) + } + currentLevelRecord() { + return this.seenLevels[this.currentLevel] + } +} + +export class LinearLevelSet extends LevelSet { + constructor( + public levels: ILevelInfo[], + loaderFunction: LevelSetLoaderFunction, + setData?: ISetInfo + ) { + super(loaderFunction) + if (setData) { + if (setData.ruleset !== "Steam" || setData.setType !== "C2G") + throw new Error("LevelSet only represents sets based on C2G scripts.") + if ( + typeof setData.currentLevel !== "number" || + setData.levels == undefined + ) + throw new Error("Given set data is missing essential properties.") + this.currentLevel = setData.currentLevel + for (const loadedLevel of setData.levels) { + const localLevel = this.levels.find( + lvl => + lvl.levelFilePath === loadedLevel.levelFilePath || + (lvl.levelNumber == loadedLevel.levelNumber && + lvl.title === loadedLevel.title) + ) + if (!localLevel) continue + localLevel.attempts = loadedLevel.attempts + } + } + } + findLevelForLevelN(n: number): ILevelInfo | undefined { + return this.levels.find(level => level.levelNumber === n) + } + canGoToLevel(n: number) { + return !!this.findLevelForLevelN(n) + } + currentLevelRecord() { + return { levelInfo: this.findLevelForLevelN(this.currentLevel)! } + } + gameTitle() { + return this.levels[0]!.scriptState!.gameTitle! + } + scriptTitle() { + return this.levels[0]!.scriptState!.scriptTitle! + } + goToLevel(n: number) { + if (!this.canGoToLevel(n)) throw new Error(`No level #${n} exists`) + this.currentLevel = n + return Promise.resolve(this.currentLevelRecord()) + } + initialLevel() { + return Promise.resolve(this.currentLevelRecord()) + } + listLevels() { + return this.levels.map(level => ({ levelInfo: level })) + } + nextLevel() { + if (!this.canGoToLevel(this.currentLevel + 1)) return Promise.resolve(null) + return this.goToLevel(this.currentLevel + 1) + } + previousLevel() { + if (!this.canGoToLevel(this.currentLevel - 1)) return Promise.resolve(null) + return this.goToLevel(this.currentLevel - 1) + } + toSetInfo(): ISetInfo { + return { + ruleset: "Steam", + setType: "C2G", + setName: this.gameTitle(), + currentLevel: this.currentLevel, + levels: this.levels, } } } + +export async function constructSimplestLevelSet( + setData: LevelSetData, + save?: ISetInfo +): Promise { + const linearLevels = await makeLinearLevels(setData) + if (linearLevels) { + return new LinearLevelSet(linearLevels, setData.loaderFunction, save) + } + return await FullC2GLevelSet.constructAsync( + save ? save : setData.scriptFile, + setData.loaderFunction + ) +} diff --git a/logic/src/parsers/nccs.proto b/libnotcc-bind/src/nonbind/nccs.proto similarity index 91% rename from logic/src/parsers/nccs.proto rename to libnotcc-bind/src/nonbind/nccs.proto index e8732f2b..c290561b 100644 --- a/logic/src/parsers/nccs.proto +++ b/libnotcc-bind/src/nonbind/nccs.proto @@ -15,6 +15,7 @@ message SetInfo { string set_name = 3; repeated LevelInfo levels = 4; int32 current_level = 5; + bool has_reached_postgame = 16; } message LevelInfo { @@ -23,9 +24,11 @@ message LevelInfo { ScriptState script_state = 3; repeated AttemptInfo attempts = 4; string level_file_path = 5; + string password = 6; - string prologue_text = 16; - string epilogue_text = 17; + repeated string prologue_text = 16; + repeated string epilogue_text = 17; + bool saw_epilogue = 18; } message ScriptState { @@ -136,10 +139,11 @@ message GlitchInfo { DESPAWN = 1; DYNAMITE_EXPLOSION_SNEAKING = 3; SIMULTANEOUS_CHARACTER_MOVEMENT = 6; - reserved "CROSS_LEVEL_DESPAWN", - "BLUE_TP_LOGIC_GATE_SHENANIGANS", - "CROSS_LEVEL_BLUE_TP_LOGIC_GATE_SHENANIGANS"; + reserved "CROSS_LEVEL_DESPAWN", "BLUE_TP_LOGIC_GATE_SHENANIGANS", + "CROSS_LEVEL_BLUE_TP_LOGIC_GATE_SHENANIGANS"; reserved 2, 4, 5; + DROP_BY_DESPAWNED = 7; + BLUE_TELEPORT_INFINITE_LOOP = 8; } KnownGlitches glitch_kind = 1; TilePositionInfo location = 2; diff --git a/libnotcc-bind/src/nonbind/nccs.ts b/libnotcc-bind/src/nonbind/nccs.ts new file mode 100644 index 00000000..57630bdd --- /dev/null +++ b/libnotcc-bind/src/nonbind/nccs.ts @@ -0,0 +1,53 @@ +import { ISetInfo, SetInfo } from "./nccs.pb.js" + +export * as protobuf from "./nccs.pb.js" + +const MAGIC_STRING = "NCCS" +const NCCS_VERSION = "1.0" + +export function writeNCCS(saveData: ISetInfo): Uint8Array { + const wireData = SetInfo.encode(saveData).finish() + + // The magic string + version + const headerData = Uint8Array.from( + `${MAGIC_STRING}todo${NCCS_VERSION}\u{0}`, + char => char.charCodeAt(0) + ) + const headerView = new DataView(headerData.buffer) + // Write the length of the version string before the string (including the null character) + headerView.setUint32(NCCS_VERSION.length + 1, 4, true) + + const finalData = new Uint8Array(wireData.length + headerData.length) + finalData.set(headerData) + finalData.set(wireData, headerData.byteLength) + + return finalData +} + +const MAGIC_STRING_AS_U32 = + MAGIC_STRING.charCodeAt(0) * 0x1 + + MAGIC_STRING.charCodeAt(1) * 0x100 + + MAGIC_STRING.charCodeAt(2) * 0x10000 + + MAGIC_STRING.charCodeAt(3) * 0x1000000 + +export function parseNCCS(data: ArrayBuffer): ISetInfo { + const view = new DataView(data) + const magicText = view.getUint32(0, true) + if (magicText !== MAGIC_STRING_AS_U32) throw new Error("Missing magic string") + + const versionLength = view.getUint32(4, true) + const versionString = new TextDecoder("utf-8").decode( + data.slice(8, 8 + versionLength) + ) + const [major] = versionString.split(".").map(segment => parseInt(segment, 10)) + if (major < 1) + throw new Error("Pre-release versions of NCCS aren't supported") + if (major > 1) + throw new Error( + `NCCS too new - parser version ${NCCS_VERSION}, file version ${versionString}` + ) + const setInfo = SetInfo.decode( + new Uint8Array(view.buffer.slice(8 + versionLength)) + ) + return setInfo.toJSON() +} diff --git a/libnotcc-bind/src/struct.ts b/libnotcc-bind/src/struct.ts new file mode 100644 index 00000000..012880f3 --- /dev/null +++ b/libnotcc-bind/src/struct.ts @@ -0,0 +1,167 @@ +import { getModuleInstance, wasmFuncs } from "./module.js" + +let _reader: DataView | null = null + +export function getWasmReader() { + if (_reader === null || _reader.buffer.byteLength === 0) { + _reader = new DataView( + (getModuleInstance().exports.memory as WebAssembly.Memory).buffer + ) + } + return _reader +} + +const decoder = new TextDecoder("utf-8") + +export function getStringAt(ptr: number): string | null { + if (ptr === 0) return null + const bytes: number[] = [] + while (true) { + const byte = getWasmReader().getUint8(ptr) + if (byte === 0) break + bytes.push(byte) + ptr += 1 + } + return decoder.decode(new Uint8Array(bytes)) +} + +export const PTR_SIZE = 4 +export const U64_SIZE = 8 +export const I64_SIZE = 8 +export const U32_SIZE = 4 +export const I32_SIZE = 4 +export const U8_SIZE = 1 +export const I8_SIZE = 1 +export const BOOL_SIZE = 1 + +export class Struct { + static finReg = new FinalizationRegistry<[number, (ptr: number) => void]>( + ([ptr, unalloc]) => { + unalloc(ptr) + wasmFuncs.free(ptr) + } + ) + _ptr: number + _live = true + _owned = false + static size = 0 + static allocStruct(size: number): T { + const ptr = wasmFuncs.malloc(size) + const struct = new this(ptr) + struct.own() + return struct as T + } + _assert_live() { + if (!this._live) throw new Error("Trying to access dead object") + } + own() { + this._assert_live() + if (this._owned) throw new Error("Trying to reown owned object") + this._owned = true + const proto = Object.getPrototypeOf(this).constructor + Struct.finReg.register(this, [this._ptr, proto.unalloc], this) + } + static unalloc(ptr: number): void {} + free() { + this._assert_live() + if (!this._owned) throw new Error("Trying to free borrowed object") + this._live = false + Struct.finReg.unregister(this) + const proto = Object.getPrototypeOf(this).constructor + proto.unalloc?.(this._ptr) + wasmFuncs.free(this._ptr) + } + constructor(ptr: number) { + this._ptr = ptr + } + protected getBool(offset: number): boolean { + this._assert_live() + return !!getWasmReader().getUint8(this._ptr + offset) + } + protected getU8(offset: number): number { + this._assert_live() + return getWasmReader().getUint8(this._ptr + offset) + } + protected getI8(offset: number): number { + this._assert_live() + return getWasmReader().getInt8(this._ptr + offset) + } + protected getU32(offset: number): number { + this._assert_live() + return getWasmReader().getUint32(this._ptr + offset, true) + } + protected getU64(offset: number): BigInt { + this._assert_live() + return getWasmReader().getBigUint64(this._ptr + offset, true) + } + protected getPtr(offset: number): number { + this._assert_live() + return this.getU32(offset) + } + protected getI32(offset: number): number { + this._assert_live() + return getWasmReader().getInt32(this._ptr + offset, true) + } + protected setBool(offset: number, val: boolean): void { + this._assert_live() + getWasmReader().setUint8(this._ptr + offset, val ? 1 : 0) + } + protected setU8(offset: number, val: number): void { + this._assert_live() + getWasmReader().setUint8(this._ptr + offset, val) + } + protected setI8(offset: number, val: number): void { + this._assert_live() + getWasmReader().setInt8(this._ptr + offset, val) + } + protected setU32(offset: number, val: number): void { + this._assert_live() + getWasmReader().setUint32(this._ptr + offset, val, true) + } + protected setI32(offset: number, val: number): void { + this._assert_live() + getWasmReader().setInt32(this._ptr + offset, val, true) + } +} + +export function makeAccessorClassObj< + V, + T extends { + [idx: number]: V + getItem(idx: number): V + length: number + }, +>(inst: T): typeof inst { + return new Proxy(inst, { + get(target, p, receiver) { + if (typeof p === "symbol") return Reflect.get(target, p, receiver) + const pInt = parseInt(p) + if (isNaN(pInt) || pInt < 0 || pInt >= target.length) + return Reflect.get(target, p, receiver) + return target.getItem(pInt) + }, + }) +} + +export abstract class CVector extends Struct { + get length(): number { + return wasmFuncs.Vector_any_get_length(this._ptr) + } + getItem(idx: number): T { + return this.instantiateItem( + wasmFuncs.Vector_any_get_ptr(this._ptr, this.getItemSize(), idx) + ) + } + [idx: number]: T + constructor(_ptr: number) { + super(_ptr) + return makeAccessorClassObj(this) + } + abstract getItemSize(): number + abstract instantiateItem(ptr: number): T + *[Symbol.iterator]() { + for (let idx = 0; idx < this.length; idx += 1) { + yield this[idx] + } + } +} diff --git a/libnotcc-bind/src/wasm.d.ts b/libnotcc-bind/src/wasm.d.ts new file mode 100644 index 00000000..87efca36 --- /dev/null +++ b/libnotcc-bind/src/wasm.d.ts @@ -0,0 +1,4 @@ +declare module "*.wasm?url" { + const module: string + export default module +} diff --git a/logic/tsconfig.json b/libnotcc-bind/tsconfig.json similarity index 81% rename from logic/tsconfig.json rename to libnotcc-bind/tsconfig.json index d93cc37b..99972d62 100644 --- a/logic/tsconfig.json +++ b/libnotcc-bind/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "esModuleInterop": true, - "target": "ES2019", + "target": "ES2021", "module": "ESNext", "moduleResolution": "NodeNext", "resolveJsonModule": true, @@ -9,8 +9,7 @@ "sourceMap": true, "strict": true, "outDir": "dist", - "allowJs": true, - "lib": ["ES2019.Object"] + "allowJs": true }, "buildOptions": { "incremental": true diff --git a/libnotcc/.gitignore b/libnotcc/.gitignore new file mode 100644 index 00000000..d83aab4d --- /dev/null +++ b/libnotcc/.gitignore @@ -0,0 +1,2 @@ +build/ +native-build/ diff --git a/libnotcc/CMakeLists.txt b/libnotcc/CMakeLists.txt new file mode 100644 index 00000000..2617cf7d --- /dev/null +++ b/libnotcc/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.5) + +project(notcc LANGUAGES C) + +set(notcc_src) +list(APPEND notcc_src + src/logic.c + src/tiles.c + src/c2m.c + src/hash.c + src/misc.c + src/wires.c +) + +if ("${CMAKE_SYSTEM_NAME}" STREQUAL "WebAssembly") + add_compile_definitions(PAGE_SIZE=4096) + list(APPEND notcc_src + wasm-std/src/dlmalloc.c + wasm-std/src/std.c + wasm-std/src/printf.c + ) +else() +add_executable(notcc-cli ${notcc_src} src/main-cli.c) +endif() + +add_library(notcc SHARED ${notcc_src}) + diff --git a/libnotcc/README.md b/libnotcc/README.md new file mode 100644 index 00000000..603f19b0 --- /dev/null +++ b/libnotcc/README.md @@ -0,0 +1,11 @@ +# `libnotcc` + +The C library which contains all game logic code. Written in C for speed and memory effeciency. + +You need CMake, make, clang, Python to build this. + +## Build + +```sh +mkdir +``` diff --git a/libnotcc/build-native.sh b/libnotcc/build-native.sh new file mode 100755 index 00000000..547724ee --- /dev/null +++ b/libnotcc/build-native.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +if [ "$1" == "debug" ]; then + args=-DCMAKE_BUILD_TYPE=Debug +elif [ "$1" == "release" ];then + args=-DCMAKE_BUILD_TYPE=Release +elif [ "$1" == "optdebug" ]; then + args=-DCMAKE_BUILD_TYPE=RelWithDebInfo +fi +set -e +rm -rf native-build +mkdir native-build +cmake -S . -B native-build $args +cmake --build native-build diff --git a/libnotcc/build-wasm.sh b/libnotcc/build-wasm.sh new file mode 100755 index 00000000..2243a186 --- /dev/null +++ b/libnotcc/build-wasm.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +if [ "$1" == "debug" ]; then + args=-DCMAKE_BUILD_TYPE=Debug +elif [ "$1" == "release" ];then + args=-DCMAKE_BUILD_TYPE=Release +elif [ "$1" == "optdebug" ]; then + args=-DCMAKE_BUILD_TYPE=RelWithDebInfo +fi +set -e +rm -rf build +mkdir build +cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=./wasm32.cmake $args +cmake --build build diff --git a/libnotcc/include/accessors/declare.h b/libnotcc/include/accessors/declare.h new file mode 100644 index 00000000..caff5313 --- /dev/null +++ b/libnotcc/include/accessors/declare.h @@ -0,0 +1,9 @@ +#undef _libnotcc_accessor +#undef _libnotcc_accessor_bits + +#define _libnotcc_accessor(stru, prop, type) \ + type stru##_get_##prop(stru* self); \ + void stru##_set_##prop(stru* self, type val); + +#define _libnotcc_accessor_bits(stru, prop, type, bits) \ + _libnotcc_accessor(stru, prop, type) diff --git a/libnotcc/include/accessors/define.h b/libnotcc/include/accessors/define.h new file mode 100644 index 00000000..5ae4d0c2 --- /dev/null +++ b/libnotcc/include/accessors/define.h @@ -0,0 +1,13 @@ +#undef _libnotcc_accessor +#undef _libnotcc_accessor_bits + +#define _libnotcc_accessor(stru, prop, type) \ + type stru##_get_##prop(stru* self) { \ + return self->prop; \ + }; \ + void stru##_set_##prop(stru* self, type val) { \ + self->prop = val; \ + }; + +#define _libnotcc_accessor_bits(stru, prop, type, bits) \ + _libnotcc_accessor(stru, prop, type) diff --git a/libnotcc/include/accessors/struct.h b/libnotcc/include/accessors/struct.h new file mode 100644 index 00000000..3c32726a --- /dev/null +++ b/libnotcc/include/accessors/struct.h @@ -0,0 +1,5 @@ +#undef _libnotcc_accessor +#undef _libnotcc_accessor_bits + +#define _libnotcc_accessor(stru, prop, type) type prop; +#define _libnotcc_accessor_bits(stru, prop, type, bits) type prop : bits; diff --git a/libnotcc/include/c2m.h b/libnotcc/include/c2m.h new file mode 100644 index 00000000..e5553bc0 --- /dev/null +++ b/libnotcc/include/c2m.h @@ -0,0 +1,9 @@ +#include "logic.h" +#include "misc.h" +typedef Level* LevelPtr; +typedef LevelMetadata* LevelMetadataPtr; +DEFINE_RESULT(LevelPtr); +DEFINE_RESULT(LevelMetadataPtr); + +Result_LevelPtr parse_c2m(void* data, size_t data_len); +Result_LevelMetadataPtr parse_c2m_meta(void* data, size_t data_len); diff --git a/libnotcc/include/logic.h b/libnotcc/include/logic.h new file mode 100644 index 00000000..719a96b8 --- /dev/null +++ b/libnotcc/include/logic.h @@ -0,0 +1,514 @@ +#ifndef _libnotcc_logic_h +#define _libnotcc_logic_h +#include +#include +#include "accessors/declare.h" +#include "misc.h" + +typedef struct TileType TileType; +typedef struct ActorType ActorType; +typedef struct Level Level; +typedef struct LevelMetadata LevelMetadata; +typedef struct Cell Cell; +typedef struct Actor Actor; +typedef struct BasicTile BasicTile; +typedef struct Inventory Inventory; +typedef struct PlayerSeat PlayerSeat; +typedef struct Replay Replay; +typedef struct Glitch Glitch; +typedef struct LastPlayerInfo LastPlayerInfo; + +typedef enum Direction { + DIRECTION_NONE = 0, + DIRECTION_UP = 1, + DIRECTION_RIGHT = 2, + DIRECTION_DOWN = 3, + DIRECTION_LEFT = 4 +} Direction; + +typedef struct Position { + uint8_t x; + uint8_t y; +} Position; +typedef struct PositionF { + float x; + float y; +} PositionF; +size_t Position_to_offset(Position pos, size_t pitch); +Position Position_from_offset(size_t offset, size_t pitch); + +typedef enum SlidingState { + SLIDING_NONE, + SLIDING_WEAK, + SLIDING_STRONG +} SlidingState; + +typedef struct WireNetworkMember { + Position pos; + uint8_t wires; +} WireNetworkMember; + +DECLARE_VECTOR(WireNetworkMember); +DECLARE_VECTOR(Position); + +typedef struct WireNetwork { + Vector_WireNetworkMember members; + Vector_WireNetworkMember emitters; + bool force_power_this_subtick; +} WireNetwork; + +// 16 uint8_t's. C is kinda dumb here, you can return arbitrary structs but not +// arrays? Weird +typedef struct Uint8_16 { + uint8_t val[16]; +} Uint8_16; + +typedef enum ItemIndex { + ITEM_INDEX_FORCE_BOOTS = 1, + ITEM_INDEX_ICE_BOOTS = 2, + ITEM_INDEX_FIRE_BOOTS = 3, + ITEM_INDEX_WATER_BOOTS = 4, + ITEM_INDEX_DYNAMITE = 5, + ITEM_INDEX_HELMET = 6, + ITEM_INDEX_DIRT_BOOTS = 7, + ITEM_INDEX_LIGHTNING_BOLT = 8, + ITEM_INDEX_BOWLING_BALL = 9, + ITEM_INDEX_YELLOW_TP = 10, + ITEM_INDEX_RR_SIGN = 11, + ITEM_INDEX_STEEL_FOIL = 12, + ITEM_INDEX_SECRET_EYE = 13, + ITEM_INDEX_BRIBE = 14, + ITEM_INDEX_SPEED_BOOTS = 15, + ITEM_INDEX_HOOK = 16 +} ItemIndex; + +#define _libnotcc_accessors_Inventory \ + _libnotcc_accessor(Inventory, counters, Uint8_16); \ + _libnotcc_accessor(Inventory, item1, const TileType*); \ + _libnotcc_accessor(Inventory, item2, const TileType*); \ + _libnotcc_accessor(Inventory, item3, const TileType*); \ + _libnotcc_accessor(Inventory, item4, const TileType*); \ + _libnotcc_accessor(Inventory, keys_red, uint8_t); \ + _libnotcc_accessor(Inventory, keys_green, uint8_t); \ + _libnotcc_accessor(Inventory, keys_blue, uint8_t); \ + _libnotcc_accessor(Inventory, keys_yellow, uint8_t); +const TileType** Inventory_get_rightmost_item(Inventory* self); +const TileType** Inventory_get_item_by_idx(Inventory* self, uint8_t idx); +const TileType* Inventory_shift_right(Inventory* self); +const TileType* Inventory_remove_item(Inventory* self, uint8_t idx); +void Inventory_decrement_counter(Inventory* self, uint8_t iidx); +void Inventory_increment_counter(Inventory* self, uint8_t iidx); +void Inventory_set_items(Inventory* self, + ItemIndex item1, + ItemIndex item2, + ItemIndex item3, + ItemIndex item4); +_libnotcc_accessors_Inventory; + +Actor* Actor_new(Level* level, + const ActorType* type, + Position pos, + Direction direction); +bool Actor_check_collision(Actor* self, Level* level, Direction* direction); +bool Actor_move_to(Actor* self, Level* level, Direction direction); +bool Actor_push_to(Actor* self, Level* level, Direction direction); +bool Actor_is_moving(const Actor* self); +bool Actor_is_gone(const Actor* self); +void Actor_do_decision(Actor* self, Level* level); +void Actor_do_player_decision(Actor* self, Level* level); +void Actor_do_decided_move(Actor* self, Level* level); +void Actor_do_cooldown(Actor* self, Level* level); +void Actor_do_idle(Actor* self, Level* level); +void Actor_transform_into(Actor* self, const ActorType* new_type); +void Actor_destroy(Actor* self, Level* level, const ActorType* anim_type); +void Actor_erase(Actor* self, Level* level); +bool Actor_pickup_item(Actor* self, Level* level, BasicTile* item); +bool Actor_drop_item(Actor* self, Level* level); +void Actor_place_item_on_tile(Actor* self, + Level* level, + const TileType* item_type); +uint16_t Actor_get_position_xy(Actor* self); +Inventory* Actor_get_inventory_ptr(Actor* self); +void Player_do_decision(Actor* self, Level* level); +Direction Player_get_last_decision(Actor* self); +void Actor_enter_tile(Actor* self, Level* level); +PositionF Actor_get_visual_position(const Actor* self); +uint32_t Actor_get_actor_list_idx(const Actor* self, const Level* level); + +#define _libnotcc_accessors_Actor \ + _libnotcc_accessor(Actor, type, const ActorType*); \ + _libnotcc_accessor(Actor, custom_data, uint64_t); \ + _libnotcc_accessor(Actor, inventory, Inventory); \ + _libnotcc_accessor(Actor, position, Position); \ + _libnotcc_accessor(Actor, direction, Direction); \ + _libnotcc_accessor_bits(Actor, move_decision, Direction, 3); \ + _libnotcc_accessor_bits(Actor, pending_decision, Direction, 3); \ + _libnotcc_accessor_bits(Actor, pending_move_locked_in, bool, 1); \ + _libnotcc_accessor(Actor, move_progress, uint8_t); \ + _libnotcc_accessor(Actor, move_length, uint8_t); \ + _libnotcc_accessor_bits(Actor, sliding_state, SlidingState, 2); \ + _libnotcc_accessor_bits(Actor, bonked, bool, 1); \ + _libnotcc_accessor_bits(Actor, frozen, bool, 1); \ + _libnotcc_accessor_bits(Actor, pulled, bool, 1); \ + _libnotcc_accessor_bits(Actor, pulling, bool, 1); \ + _libnotcc_accessor_bits(Actor, pushing, bool, 1); \ + _libnotcc_accessor_bits(Actor, is_being_pushed, bool, 1); + +_libnotcc_accessors_Actor; + +bool BasicTile_impedes(BasicTile* self, + Level* level, + Actor* other, + Direction direction); +void BasicTile_transform_into(BasicTile* self, const TileType* new_type); +void BasicTile_erase(BasicTile* self); +bool TileType_can_be_dropped(const TileType** self, + Level* level, + Actor* dropper, + int8_t layer_to_ignore); + +#define _libnotcc_accessors_BasicTile \ + _libnotcc_accessor(BasicTile, type, const TileType*); \ + _libnotcc_accessor(BasicTile, custom_data, uint64_t); + +_libnotcc_accessors_BasicTile; + +#define get_wires(t) (t->custom_data >> 0) & 0xf +#define get_powered_wires(t) (t->custom_data >> 4) & 0xf +#define get_powering_wires(t) (t->custom_data >> 8) & 0xf +#define set_wires(t, v) t->custom_data = (t->custom_data & ~0x00f) | v +#define set_powered_wires(t, v) \ + t->custom_data = (t->custom_data & ~0x0f0) | (v << 4) +#define set_powering_wires(t, v) \ + t->custom_data = (t->custom_data & ~0xf00) | (v << 8) + +typedef enum Layer { + LAYER_SPECIAL, + LAYER_ACTOR, + LAYER_ITEM_MOD, + LAYER_ITEM, + LAYER_TERRAIN +} Layer; + +typedef enum GameState { + GAMESTATE_PLAYING = 0, + GAMESTATE_DEAD = 1, + GAMESTATE_TIMEOUT = 2, + GAMESTATE_WON = 3, + GAMESTATE_CRASH = 4, +} GameState; + +BasicTile* Cell_get_layer(Cell* self, Layer layer); +Actor* Cell_get_actor(Cell* self); +void Cell_set_actor(Cell* self, Actor* actor); +void Cell_place_actor(Cell* self, Level* level, Actor* actor); +uint8_t Cell_get_powered_wires(Cell* self); +void Cell_set_powered_wires(Cell* self, uint8_t val); +BasicTile* Cell_get_layer(Cell* self, Layer layer); +bool Cell_get_is_wired(Cell* self); +void Cell_set_is_wired(Cell* self, bool val); +Cell* BasicTile_get_cell(const BasicTile* tile, Layer layer); + +typedef enum WireType { + WIRES_NONE, + WIRES_READ, + WIRES_UNCONNECTED, + WIRES_CROSS, + WIRES_ALWAYS_CROSS, + WIRES_EVERYWHERE +} WireType; + +typedef struct ActorType { + char* name; + void (*init)(Actor* self, Level* level); + void (*on_bonk)(Actor* self, Level* level, BasicTile* other); + void (*on_bump_actor)(Actor* self, Level* level, Actor* other); + void (*on_bumped_by)(Actor* self, Level* level, Actor* other); + void (*decide_movement)(Actor* self, Level* level, Direction* directions); + bool (*can_be_pushed)(Actor* self, + Level* level, + Actor* other, + Direction direction, + bool pulling); + bool (*impedes)(Actor* self, Level* level, Actor* other, Direction direction); + void (*on_redirect)(Actor* self, Level* level, uint8_t turn); + uint64_t flags; + uint8_t move_duration; +} ActorType; + +typedef struct TileType { + char* name; + Layer layer; + void (*init)(BasicTile* self, Level* level, Cell* cell); + void (*on_bumped_by)(BasicTile* self, + Level* level, + Actor* other, + Direction direction); + void (*on_idle)(BasicTile* self, Level* level, Actor* other); + bool (*impedes)(BasicTile* self, + Level* level, + Actor* other, + Direction direction); + Direction (*redirect_exit)(BasicTile* self, + Level* level, + Actor* other, + Direction direction); + void (*actor_destroyed)(BasicTile* self, Level* level); + bool (*overrides_item_layer)(BasicTile* self, Level* level, BasicTile* other); + uint64_t impedes_mask; + uint64_t flags; + uint8_t item_index; + WireType wire_type; + uint8_t (*give_power)(BasicTile* self, Level* level); + void (*receive_power)(BasicTile* self, Level* level, uint8_t powered_wires); + void (*on_wire_high)(BasicTile* self, Level* level, bool real); + void (*on_wire_low)(BasicTile* self, Level* level, bool real); + uint8_t (*modify_move_duration)(BasicTile* self, + Level* level, + Actor* other, + uint8_t move_duration); + void (*actor_left)(BasicTile* self, + Level* level, + Actor* other, + Direction direction); + void (*actor_joined)(BasicTile* self, + Level* level, + Actor* other, + Direction direction); + void (*actor_completely_joined)(BasicTile* self, Level* level, Actor* other); +} TileType; + +ItemIndex TileType_get_item_index(TileType const* tile); + +enum PlayerInputFlags { + PLAYER_INPUT_UP = 1 << 0, + PLAYER_INPUT_RIGHT = 1 << 1, + PLAYER_INPUT_DOWN = 1 << 2, + PLAYER_INPUT_LEFT = 1 << 3, + PLAYER_INPUT_DIRECTIONAL = 0b1111, + PLAYER_INPUT_DROP_ITEM = 1 << 4, + PLAYER_INPUT_CYCLE_ITEMS = 1 << 5, + PLAYER_INPUT_SWITCH_PLAYERS = 1 << 6 +}; + +typedef uint8_t PlayerInputs; + +#define _libnotcc_accessors_PlayerSeat \ + _libnotcc_accessor(PlayerSeat, actor, Actor*); \ + _libnotcc_accessor(PlayerSeat, displayed_hint, char*); \ + _libnotcc_accessor(PlayerSeat, inputs, PlayerInputs); \ + _libnotcc_accessor(PlayerSeat, released_inputs, PlayerInputs); + +void PlayerSeat_get_movement_directions(PlayerSeat* self, Direction dirs[2]); +bool PlayerSeat_has_perspective(const PlayerSeat* self); +uint8_t PlayerSeat_get_possible_actions(PlayerSeat const* self, + Level const* level); +_libnotcc_accessors_PlayerSeat; + +void LevelMetadata_init(LevelMetadata* self); +void LevelMetadata_uninit(LevelMetadata* self); + +typedef char* CharPtr; +DECLARE_VECTOR(CharPtr); +#define _libnotcc_accessors_LevelMetadata \ + _libnotcc_accessor(LevelMetadata, title, char*); \ + _libnotcc_accessor(LevelMetadata, author, char*); \ + _libnotcc_accessor(LevelMetadata, default_hint, char*); \ + _libnotcc_accessor(LevelMetadata, hints, Vector_CharPtr); \ + _libnotcc_accessor(LevelMetadata, c2g_command, char*); \ + _libnotcc_accessor(LevelMetadata, jetlife_interval, int32_t); \ + _libnotcc_accessor(LevelMetadata, rng_blob_4pat, bool); \ + _libnotcc_accessor(LevelMetadata, rng_blob_deterministic, bool); \ + _libnotcc_accessor(LevelMetadata, player_n, uint32_t); \ + _libnotcc_accessor(LevelMetadata, camera_width, uint8_t); \ + _libnotcc_accessor(LevelMetadata, camera_height, uint8_t); \ + _libnotcc_accessor(LevelMetadata, wires_hidden, bool); \ + _libnotcc_accessor(LevelMetadata, timer, uint16_t); \ + _libnotcc_accessor(LevelMetadata, cc1_boots, bool); + +_libnotcc_accessors_LevelMetadata; + +void Level_init_basic(Level* self); +void Level_init_players(Level* self, uint32_t players_n); +void Level_init(Level* self, uint8_t width, uint8_t height, uint32_t players_n); +void Level_uninit(Level* self); +uint8_t Level_rng(Level* self); +uint8_t Level_blobmod(Level* self); +void Level_apply_blue_button(Level* self); +void Level_tick(Level* self); +Cell* Level_get_cell(Level* self, Position pos); +Cell* Level_get_cell_xy(Level* self, uint8_t x, uint8_t y); +Actor* Level_find_next_player(Level* self, Actor* player); +PlayerSeat* Level_find_player_seat(Level* self, const Actor* player); +LevelMetadata* Level_get_metadata_ptr(Level* self); +Level* Level_clone(const Level* self); +enum HashSettings { + HASH_SETTINGS_IGNORE_BLOCK_ORDER = 1 << 0, + HASH_SETTINGS_IGNORE_PLAYER_DIRECTION = 1 << 1, + // TODO: HASH_SETTINGS_IGNORE_PLAYER_BUMP = 1 << 2, + HASH_SETTINGS_IGNORE_MIMIC_PARITY = 1 << 3, + HASH_SETTINGS_IGNORE_TEETH_PARITY = 1 << 4, +}; +int32_t Level_hash(const Level* self, uint32_t settings); +size_t Level_total_size(const Level* self); +Cell* Level_search_reading_order(Level* self, + Cell* base, + bool reverse, + bool (*match_func)(void* ctx, + Level* level, + Cell* cell), + void* ctx); +Cell* Level_search_taxicab(Level* self, + Cell* base, + bool (*match_func)(void* ctx, + Level* level, + Cell* cell), + void* ctx); +Cell* Level_search_taxicab_at_dist(Level* self, + Position base_pos, + uint8_t dist, + bool (*match_func)(void* ctx, + Level* level, + Cell* cell), + void* ctx); +Position Level_pos_from_cell(const Level* self, const Cell* cell); +void Level_initialize_tiles(Level* self); +Actor* Level_find_closest_player(Level* self, Position from); +bool Level_check_position_inbounds(const Level* self, + Position pos, + Direction dir, + bool wrap); +Position Level_get_neighbor(Level* self, Position pos, Direction dir); +void Level_init_wires(Level* self); +void Level_do_wire_propagation(Level* self); +void Level_do_wire_notification(Level* self); +void Level_do_jetlife(Level* self); +void Level_add_glitch(Level* self, Glitch glitch); +enum SfxBit { + SFX_RECESSED_WALL = 1 << 0, + SFX_EXPLOSION = 1 << 1, + SFX_SPLASH = 1 << 2, + SFX_TELEPORT = 1 << 3, + SFX_THIEF = 1 << 4, + SFX_DIRT_CLEAR = 1 << 5, + SFX_BUTTON_PRESS = 1 << 6, + SFX_BLOCK_PUSH = 1 << 7, + SFX_FORCE_FLOOR_SLIDE = 1 << 8, + SFX_PLAYER_BONK = 1 << 9, + SFX_WATER_STEP = 1 << 10, + SFX_SLIDE_STEP = 1 << 11, + SFX_ICE_SLIDE = 1 << 12, + SFX_FIRE_STEP = 1 << 13, + SFX_ITEM_PICKUP = 1 << 14, + SFX_SOCKET_UNLOCK = 1 << 15, + SFX_DOOR_UNLOCK = 1 << 16, + SFX_CHIP_WIN = 1 << 17, + SFX_MELINDA_WIN = 1 << 18, + SFX_CHIP_DEATH = 1 << 19, + SFX_MELINDA_DEATH = 1 << 20, + + SFX_CONTINUOUS = SFX_FORCE_FLOOR_SLIDE | SFX_ICE_SLIDE, +}; + +void Level_add_sfx(Level* self, uint64_t sfx); + +DECLARE_VECTOR(PlayerSeat); + +Vector_PlayerSeat* Level_get_player_seats_ptr(Level* self); +LastPlayerInfo* Level_get_last_won_player_info_ptr(Level* self); + +DECLARE_VECTOR(WireNetwork); +DECLARE_VECTOR(Glitch); + +#define _libnotcc_accessors_LastPlayerInfo \ + _libnotcc_accessor(LastPlayerInfo, inventory, Inventory); \ + _libnotcc_accessor(LastPlayerInfo, exit_n, uint32_t); \ + _libnotcc_accessor(LastPlayerInfo, is_male, bool); + +Inventory* LastPlayerInfo_get_inventory_ptr(LastPlayerInfo* self); + +_libnotcc_accessors_LastPlayerInfo; + +Vector_Glitch* Level_get_glitches_ptr(Level* self); + +#define _libnotcc_accessors_Level \ + /* Basic */ \ + _libnotcc_accessor(Level, width, uint8_t); \ + _libnotcc_accessor(Level, height, uint8_t); \ + _libnotcc_accessor(Level, actors, Actor**); \ + _libnotcc_accessor(Level, actors_n, uint32_t); \ + _libnotcc_accessor(Level, actors_allocated_n, uint32_t); \ + _libnotcc_accessor(Level, current_tick, uint32_t); \ + _libnotcc_accessor(Level, current_subtick, int8_t); \ + _libnotcc_accessor(Level, game_state, GameState); \ + _libnotcc_accessor(Level, ignore_bonus_flags, bool); \ + _libnotcc_accessor(Level, metadata, LevelMetadata); \ + _libnotcc_accessor(Level, builtin_replay, Replay*); \ + _libnotcc_accessor(Level, glitches, Vector_Glitch); \ + _libnotcc_accessor(Level, last_won_player_info, LastPlayerInfo); \ + /* Player */ \ + _libnotcc_accessor(Level, player_seats, Vector_PlayerSeat); \ + _libnotcc_accessor(Level, players_left, uint32_t); \ + /* Metrics */ \ + _libnotcc_accessor(Level, time_left, int32_t); \ + _libnotcc_accessor(Level, time_stopped, bool); \ + _libnotcc_accessor(Level, chips_left, int32_t); \ + _libnotcc_accessor(Level, bonus_points, int32_t); \ + /* Rng */ \ + _libnotcc_accessor(Level, rng1, uint8_t); \ + _libnotcc_accessor(Level, rng2, uint8_t); \ + _libnotcc_accessor(Level, rng_blob, uint8_t); \ + /* Global state */ \ + _libnotcc_accessor(Level, rff_direction, Direction); \ + _libnotcc_accessor(Level, green_button_pressed, bool); \ + _libnotcc_accessor(Level, toggle_wall_inverted, bool); \ + _libnotcc_accessor(Level, blue_button_pressed, bool); \ + _libnotcc_accessor(Level, yellow_button_pressed, Direction); \ + _libnotcc_accessor(Level, sfx, uint64_t); \ + /* Wires */ \ + _libnotcc_accessor(Level, wire_consumers, Vector_Position); \ + _libnotcc_accessor(Level, wire_networks, Vector_WireNetwork); + +_libnotcc_accessors_Level; + +DECLARE_VECTOR(PlayerInputs); + +#define _libnotcc_accessors_Replay \ + _libnotcc_accessor(Replay, rff_direction, Direction); \ + _libnotcc_accessor(Replay, rng_blob, uint8_t); \ + _libnotcc_accessor(Replay, inputs, Vector_PlayerInputs); + +Vector_PlayerInputs* Replay_get_inputs_ptr(Replay* self); + +_libnotcc_accessors_Replay; + +typedef enum GlitchKind { + GLITCH_TYPE_INVALID = 0, + GLITCH_TYPE_DESPAWN = 1, + GLITCH_TYPE_DYNAMITE_EXPLOSION_SNEAKING = 3, + GLITCH_TYPE_SIMULTANEOUS_CHARACTER_MOVEMENT = 6, + GLITCH_TYPE_DROP_BY_DESPAWNED = 7, + GLITCH_TYPE_BLUE_TELEPORT_INFINITE_LOOP = 8, +} GlitchKind; + +typedef enum GlitchSpecifier { + GLITCH_SPECIFIER_DESPAWN_REPLACE = 1, + GLITCH_SPECIFIER_DESPAWN_REMOVE = 2, +} GlitchSpecifier; + +#define _libnotcc_accessors_Glitch \ + _libnotcc_accessor(Glitch, glitch_kind, GlitchKind); \ + _libnotcc_accessor(Glitch, location, Position); \ + _libnotcc_accessor(Glitch, specifier, int32_t); \ + _libnotcc_accessor(Glitch, happens_at, uint64_t); + +_libnotcc_accessors_Glitch; +uint16_t Glitch_get_location_xy(const Glitch* self); +bool Glitch_is_crashing(const Glitch* self); + +int8_t compare_wire_membs_in_reading_order(const void* ctx, + const WireNetworkMember* memb); +int8_t compare_pos_in_reading_order(const Position* left, + const Position* right); + +#endif diff --git a/libnotcc/include/misc.h b/libnotcc/include/misc.h new file mode 100644 index 00000000..b068d2b6 --- /dev/null +++ b/libnotcc/include/misc.h @@ -0,0 +1,76 @@ +#ifndef _libnotcc_misc_h +#define _libnotcc_misc_h +#include +#include + +#define DEFINE_RESULT(T) \ + typedef struct Result_##T { \ + bool success; \ + union { \ + T value; \ + char* error; \ + }; \ + } Result_##T; + +typedef struct Result_void { + bool success; + char* error; +} Result_void; + +// raw throw -- please only use with manually allocated strings or for +// forwarding other error strings +#define res_throwr(msg) \ + do { \ + res.success = false; \ + res.error = msg; \ + return res; \ + } while (false); + +// static string throw +#define res_throws(msg) res_throwr(strdup(msg)) +char* stringf(const char* msg, ...) + __attribute__((__format__(__printf__, 1, 2))); + +// printf-formatted throw +#define res_throwf(msg, ...) res_throwr(stringf(msg, __VA_ARGS__)); + +// perr-style throw +#define res_throwe(msg, ...) \ + res_throwf(msg ": %s" __VA_OPT__(, ) __VA_ARGS__, strerror(errno)); +#define res_return(...) \ + do { \ + res.success = true; \ + __VA_OPT__(res.value = __VA_ARGS__;) \ + return res; \ + } while (false); + +#define DECLARE_VECTOR(T) DECLARE_VECTOR_W_MOD(T, ) +#define DECLARE_VECTOR_W_MOD(T, MOD) \ + typedef struct Vector_##T { \ + size_t length; \ + size_t capacity; \ + T* items; \ + } Vector_##T; \ + MOD Vector_##T Vector_##T##_init(size_t init_capacity); \ + MOD void Vector_##T##_uninit(Vector_##T* self); \ + MOD Vector_##T Vector_##T##_clone(const Vector_##T* self); \ + MOD void Vector_##T##_push(Vector_##T* self, T item); \ + MOD T Vector_##T##_pop(Vector_##T* self); \ + MOD T* Vector_##T##_get_ptr(const Vector_##T* self, size_t idx); \ + MOD T Vector_##T##_get(const Vector_##T* self, size_t idx); \ + MOD void Vector_##T##_set(Vector_##T* self, size_t idx, T item); \ + MOD void Vector_##T##_shrink_to_fit(Vector_##T* self); \ + MOD void Vector_##T##_sort(Vector_##T* self, \ + int comp(const void*, const void*)); \ + MOD T* Vector_##T##_search(const Vector_##T* self, \ + bool match(void*, const T*), void* ctx); \ + MOD T* Vector_##T##_binary_search(const Vector_##T* self, \ + int8_t comp(void*, const T*), void* ctx); +typedef struct Vector_any { + size_t length; + size_t capacity; + void* items; +} Vector_any; +size_t Vector_any_get_length(const Vector_any* self); +void* Vector_any_get_ptr(const Vector_any* self, size_t size, size_t idx); +#endif diff --git a/libnotcc/src/accessors b/libnotcc/src/accessors new file mode 120000 index 00000000..b9fff147 --- /dev/null +++ b/libnotcc/src/accessors @@ -0,0 +1 @@ +../include/accessors \ No newline at end of file diff --git a/libnotcc/src/c2m.c b/libnotcc/src/c2m.c new file mode 100644 index 00000000..ba38821a --- /dev/null +++ b/libnotcc/src/c2m.c @@ -0,0 +1,732 @@ +#include "c2m.h" +#include +#include +#include +#include +#include +#include "logic.h" +#include "tiles.h" + +DEFINE_VECTOR(CharPtr); +DEFINE_VECTOR(PlayerInputs); + +static uint16_t read_uint16_le(uint8_t* data) { + return data[0] + (data[1] << 8); +} +static uint32_t read_uint32_le(uint8_t* data) { + return data[0] + (data[1] << 8) + (data[2] << 16) + (data[3] << 24); +} + +typedef struct SectionData { + char name[4]; + void* data; + uint32_t len; +} SectionData; + +DEFINE_RESULT(SectionData); + +static Result_SectionData unpack_section(SectionData section) { + Result_SectionData res; +#define new_section (res.value) + if (section.len < 2) + res_throws("Packed data too short"); + memcpy(new_section.name, section.name, 4); + + size_t data_left = section.len; + uint8_t* data = section.data; + + uint16_t new_length = read_uint16_le(data); + data += 2; + new_section.len = new_length; + + size_t new_data_left = new_length; + uint8_t* new_data = xmalloc(new_length); + new_section.data = new_data; + + while (data_left > 0 && new_data_left > 0) { + uint8_t len = *data; + data += 1; + data_left -= 1; + if (len < 0x80) { + if (len > data_left) { + free(new_section.data); + res_throws("Non-reference block spans beyond end of packed data"); + } + if (len > new_data_left) { + free(new_section.data); + res_throws("Compressed data larger than specified"); + } + uint8_t bytes_to_copy = len; + memcpy(new_data, data, len); + data += len; + data_left -= len; + new_data += len; + new_data_left -= len; + } else { + len -= 0x80; + if (new_data_left == 0) { + free(new_section.data); + res_throws("Reference block spans beyond end of packed data"); + } + + if (len > new_data_left) { + free(new_section.data); + res_throws("Compressed data larger than specified"); + } + uint8_t offset = *data; + if (offset > (new_length - new_data_left)) { + free(new_section.data); + res_throws("Reference block refers to data before beginning of buffer"); + } + // XXX: What if `offset` is 0? + for (uint8_t pos = 0; pos < len; pos += 1) { + new_data[pos] = new_data[pos - offset]; + } + data += 1; + data_left -= 1; + new_data += len; + new_data_left -= len; + } + } + memset(new_data, 0, new_data_left); + + res_return(new_section); +} + +static void parse_optn(LevelMetadata* meta, SectionData* section) { + uint8_t* data = section->data; + size_t len = section->len; + if (len < 2) + return; + meta->timer = read_uint16_le(data); + if (len < 3) + return; + data += 2; + if (*data == 1) { + meta->camera_width = 9; + meta->camera_height = 9; + } else { + meta->camera_width = 10; + meta->camera_height = 10; + } + meta->player_n = *data == 2 ? 2 : 1; + if (len < 23) + return; + data += 20; + meta->wires_hidden = *data != 0; + if (len < 24) + return; + data += 1; + meta->cc1_boots = *data != 0; + if (len < 25) + return; + data += 1; + // The user will have to pick a random number themselves + meta->rng_blob_deterministic = *data == 0; + meta->rng_blob_4pat = *data == 1; +} + +static void parse_note(LevelMetadata* meta, SectionData* section) { + const char* str = section->data; + if (str[section->len - 1] != '\0' || strlen(str) != (section->len - 1)) + return; +#define str_left() (section->len - (ptrdiff_t)(str - (char*)section->data)) +#define assert_not_at_end() \ + if (*str == '\0') \ + return; + while (*str != '\0') { + if (str_left() > 6 && !memcmp(str, "[CLUE]", 6)) { + str += 6; + // [CLUE] sections are parsed weirdly. For example: + // ``` + // [CLUE]a + // b[c + // [CLUE] + // ha + // ``` + // will result in only one hint, "b" + // Evertyhing between the [CLUE] and next newline (\r, not \n!) is + // discarded, and the section can only be terminated by a [ + while (*str != '\r') { + assert_not_at_end(); + str += 1; + } + str += 1; + assert_not_at_end(); + if (*str == '\n') + str += 1; + const char* clue_start = str; + while (*str != '[') { + assert_not_at_end(); + str += 1; + } + size_t clue_size = str - clue_start; + // Too lazy to write `strndupz_latin1_to_utf8` + char* clue_str_latin1 = strndupz(clue_start, clue_size); + char* clue_str = strdupz_latin1_to_utf8(clue_str_latin1); + free(clue_str_latin1); + Vector_CharPtr_push(&meta->hints, clue_str); + } else if (str_left() > 5 && !memcmp(str, "[COM]", 5)) { + str += 5; + // `7 level =[COM]1 keys =[COM]ktools flags = 1 tools =` + // will execute everything after the first [COM] + if (meta->c2g_command != NULL) + continue; + meta->c2g_command = strdupz_latin1_to_utf8(str); + } else if (str_left() > 9 && !memcmp(str, "[JETLIFE]", 9)) { + str += 9; + meta->jetlife_interval = atol(str); + } else { + str += 1; + } + }; + Vector_CharPtr_shrink_to_fit(&meta->hints); +} +#undef str_left +#undef assert_not_at_end + +#define remap_cc2_input(val) \ + (((val & 0x01) ? PLAYER_INPUT_DROP_ITEM : 0) | \ + ((val & 0x02) ? PLAYER_INPUT_DOWN : 0) | \ + ((val & 0x04) ? PLAYER_INPUT_LEFT : 0) | \ + ((val & 0x08) ? PLAYER_INPUT_RIGHT : 0) | \ + ((val & 0x10) ? PLAYER_INPUT_UP : 0) | \ + ((val & 0x20) ? PLAYER_INPUT_SWITCH_PLAYERS : 0) | \ + ((val & 0x40) ? PLAYER_INPUT_CYCLE_ITEMS : 0)) + +static void parse_rpl(Level* level, SectionData* section) { + const uint8_t* data = section->data; + size_t data_left = section->len; + if (data_left < 3) + return; + Replay* replay = xmalloc(sizeof(Replay)); + replay->rff_direction = dir_from_cc2(data[1] % 4); + replay->rng_blob = data[2]; + data += 3; + data_left -= 3; + // Convert input/length pairs into a simple one-input-per-tick array + Vector_PlayerInputs inputs_buf = Vector_PlayerInputs_init(500); + + // We only need one input every tick, not subtick, so mimic how `Level_tick` + // tracks the subtick to only record movement subtick inputs + int8_t subtick = -1; + PlayerInputs input = 0; + while (data_left >= 2) { + uint8_t input_len = data[0]; + if (input_len == 0xff) + break; + while (input_len > 0) { + input_len -= 1; + subtick += 1; + subtick %= 3; + if (subtick == 2) { + Vector_PlayerInputs_push(&inputs_buf, input); + } + } + input = remap_cc2_input(data[1]); + data += 2; + data_left -= 2; + } + // Hold the last input until the end of time + Vector_PlayerInputs_push(&inputs_buf, input); + Vector_PlayerInputs_shrink_to_fit(&inputs_buf); + replay->inputs = inputs_buf; + level->builtin_replay = replay; +}; +#undef data_left +#undef check_realloc + +typedef struct C2MDef { + enum { BASIC, BASIC_READ_MOD, ACTOR, SPECIAL } def_type; + const void* ptr; + uint64_t preset_custom; +} C2MDef; + +enum { + TERRAIN_MOD8, + TERRAIN_MOD16, + TERRAIN_MOD32, + FRAME_BLOCK, + THIN_WALL, + POWER_BUTTON_ON, + POWER_BUTTON_OFF, + LOGIC_GATE +}; + +static const C2MDef c2m_tiles[] = { + // 0x00 + {BASIC, &FLOOR_tile}, + {BASIC_READ_MOD, &FLOOR_tile}, + {BASIC, &WALL_tile}, + {BASIC, &ICE_tile}, + {BASIC, &ICE_CORNER_tile, DIRECTION_DOWN}, + {BASIC, &ICE_CORNER_tile, DIRECTION_LEFT}, + {BASIC, &ICE_CORNER_tile, DIRECTION_UP}, + {BASIC, &ICE_CORNER_tile, DIRECTION_RIGHT}, + {BASIC_READ_MOD, &WATER_tile}, + {BASIC_READ_MOD, &FIRE_tile}, + {BASIC, &FORCE_FLOOR_tile, DIRECTION_UP}, + {BASIC, &FORCE_FLOOR_tile, DIRECTION_RIGHT}, + {BASIC, &FORCE_FLOOR_tile, DIRECTION_DOWN}, + {BASIC, &FORCE_FLOOR_tile, DIRECTION_LEFT}, + {BASIC, &TOGGLE_WALL_tile, true}, + {BASIC, &TOGGLE_WALL_tile, false}, + // 0x10 + {BASIC_READ_MOD, &TELEPORT_RED_tile}, + {BASIC_READ_MOD, &TELEPORT_BLUE_tile}, + {BASIC, &TELEPORT_YELLOW_tile}, + {BASIC, &TELEPORT_GREEN_tile}, + {BASIC, &EXIT_tile}, + {BASIC_READ_MOD, &SLIME_tile}, + {ACTOR, &CHIP_actor}, + {ACTOR, &DIRT_BLOCK_actor}, + {ACTOR, &WALKER_actor}, + {ACTOR, &GLIDER_actor}, + {ACTOR, &ICE_BLOCK_actor}, + {BASIC, &THIN_WALL_tile, 0b0100}, + {BASIC, &THIN_WALL_tile, 0b0010}, + {BASIC, &THIN_WALL_tile, 0b0110}, + {BASIC, &GRAVEL_tile}, + {BASIC, &BUTTON_GREEN_tile}, + // 0x20 + {BASIC, &BUTTON_BLUE_tile}, + {ACTOR, &BLUE_TANK_actor}, + {BASIC_READ_MOD, &DOOR_RED_tile}, + {BASIC_READ_MOD, &DOOR_BLUE_tile}, + {BASIC_READ_MOD, &DOOR_YELLOW_tile}, + {BASIC_READ_MOD, &DOOR_GREEN_tile}, + {BASIC, &KEY_RED_tile}, + {BASIC, &KEY_BLUE_tile}, + {BASIC, &KEY_YELLOW_tile}, + {BASIC, &KEY_GREEN_tile}, + {BASIC, &ECHIP_tile, false}, // Required chip + {BASIC, &ECHIP_tile, true}, // Extra chip + {BASIC_READ_MOD, &ECHIP_GATE_tile}, + {BASIC, &POPUP_WALL_tile}, + {BASIC, &APPEARING_WALL_tile}, + {BASIC, &INVISIBLE_WALL_tile}, + // 0x30 + {BASIC, &BLUE_WALL_tile, BLUE_WALL_REAL}, + {BASIC_READ_MOD, &BLUE_WALL_tile}, + {BASIC_READ_MOD, &DIRT_tile}, + {ACTOR, &ANT_actor}, + {ACTOR, &CENTIPEDE_actor}, + {ACTOR, &BALL_actor}, + {ACTOR, &BLOB_actor}, + {ACTOR, &TEETH_RED_actor}, + {ACTOR, &FIREBALL_actor}, + {BASIC, &BUTTON_RED_tile}, + {BASIC, &BUTTON_BROWN_tile}, + {BASIC, &ICE_BOOTS_tile}, + {BASIC, &FORCE_BOOTS_tile}, + {BASIC, &FIRE_BOOTS_tile}, + {BASIC, &WATER_BOOTS_tile}, + {BASIC, &THIEF_TOOL_tile}, + // 0x40 + {BASIC, &BOMB_tile}, + {BASIC, &TRAP_tile, + 1}, // Open trap, trap's LSD of custom_data signifies if open + {BASIC, &TRAP_tile}, + {BASIC, &CLONE_MACHINE_tile}, // XXX: CC1 Clone machine, is anything // + // different between it and the normal kind? + {BASIC_READ_MOD, &CLONE_MACHINE_tile}, + {BASIC, &HINT_tile}, + {BASIC, &FORCE_FLOOR_RANDOM_tile}, + {BASIC, &BUTTON_GRAY_tile}, + {BASIC, &SWIVEL_tile, DIRECTION_DOWN}, + {BASIC, &SWIVEL_tile, DIRECTION_LEFT}, + {BASIC, &SWIVEL_tile, DIRECTION_UP}, + {BASIC, &SWIVEL_tile, DIRECTION_RIGHT}, + {BASIC, &TIME_BONUS_tile}, + {BASIC, &STOPWATCH_tile}, + {BASIC_READ_MOD, &TRANSMOGRIFIER_tile}, + {BASIC_READ_MOD, &RAILROAD_tile}, + // 0x50 + {BASIC_READ_MOD, &STEEL_WALL_tile}, + {BASIC, &DYNAMITE_tile}, + {BASIC, &HELMET_tile}, + {ACTOR, 0}, // Illegal actor: Direction (Mr. 53) + {BASIC, 0}, // Unused + {BASIC, 0}, // Unused + {ACTOR, &MELINDA_actor}, + {ACTOR, &TEETH_BLUE_actor}, + {ACTOR, &EXPLOSION_actor}, + {BASIC, &DIRT_BOOTS_tile}, + {BASIC, &NO_MELINDA_SIGN_tile}, + {BASIC, &NO_CHIP_SIGN_tile}, + {SPECIAL, NULL, LOGIC_GATE}, + {ACTOR, 0}, // Illegal actor: Wire + {BASIC_READ_MOD, &BUTTON_PURPLE_tile}, + {BASIC, &FLAME_JET_tile, false}, + // 0x60 + {BASIC, &FLAME_JET_tile, true}, + {BASIC, &BUTTON_ORANGE_tile}, + {BASIC, &LIGHTNING_BOLT_tile}, + {ACTOR, &YELLOW_TANK_actor}, + {BASIC, &BUTTON_YELLOW_tile}, + {ACTOR, &MIRROR_CHIP_actor}, + {ACTOR, &MIRROR_MELINDA_actor}, + {BASIC, 0}, // Unused + {BASIC, &BOWLING_BALL_tile}, + {ACTOR, &ROVER_actor}, + {BASIC, &TIME_PENALTY_tile}, + {BASIC_READ_MOD, &CUSTOM_FLOOR_tile}, + {BASIC, 0}, // Unused + {SPECIAL, NULL, THIN_WALL}, + {BASIC, 0}, // Unused + {BASIC, &RR_SIGN_tile}, + // 0x70 + {BASIC_READ_MOD, &CUSTOM_WALL_tile}, + {BASIC_READ_MOD, &LETTER_FLOOR_tile}, + {BASIC, &HOLD_WALL_tile, false}, + {BASIC, &HOLD_WALL_tile, true}, + {BASIC, 0}, // Unused + {BASIC, 0}, // Unused + {SPECIAL, NULL, TERRAIN_MOD8}, + {SPECIAL, NULL, TERRAIN_MOD16}, + {SPECIAL, NULL, TERRAIN_MOD32}, + {ACTOR, 0}, // Illegal actor: Unwire + {BASIC, &BONUS_FLAG_tile, 10}, + {BASIC, &BONUS_FLAG_tile, 100}, + {BASIC, &BONUS_FLAG_tile, 1000}, + {BASIC, &GREEN_WALL_tile, true}, + {BASIC, &GREEN_WALL_tile, false}, + {BASIC, &NO_SIGN_tile}, + // 0x80 + {BASIC, &BONUS_FLAG_tile, 0x8002}, // 2x pts flag + {SPECIAL, NULL, FRAME_BLOCK}, + {ACTOR, &FLOOR_MIMIC_actor}, + {BASIC, &GREEN_BOMB_tile, false}, + {BASIC, &GREEN_BOMB_tile, true}, + {BASIC, 0}, // Unused + {BASIC, 0}, // Unused + {SPECIAL, &BUTTON_BLACK_tile, POWER_BUTTON_ON}, + {SPECIAL, &TOGGLE_SWITCH_tile, POWER_BUTTON_OFF}, + {SPECIAL, &TOGGLE_SWITCH_tile, POWER_BUTTON_ON}, + {BASIC, &THIEF_KEY_tile}, + {ACTOR, &GHOST_actor}, + {BASIC, &STEEL_FOIL_tile}, + {BASIC, &TURTLE_tile}, + {BASIC, &SECRET_EYE_tile}, + {BASIC, &BRIBE_tile}, + // 0x90 + {BASIC, &SPEED_BOOTS_tile}, + {BASIC, 0}, // Unused + {BASIC, &HOOK_tile}, +}; + +static const uint64_t logic_gate_custom_data[] = { + // 0x0 + LOGIC_GATE_NOT_UP, + LOGIC_GATE_NOT_RIGHT, + LOGIC_GATE_NOT_DOWN, + LOGIC_GATE_NOT_LEFT, + LOGIC_GATE_AND_UP, + LOGIC_GATE_AND_RIGHT, + LOGIC_GATE_AND_DOWN, + LOGIC_GATE_AND_LEFT, + LOGIC_GATE_OR_UP, + LOGIC_GATE_OR_RIGHT, + LOGIC_GATE_OR_DOWN, + LOGIC_GATE_OR_LEFT, + LOGIC_GATE_XOR_UP, + LOGIC_GATE_XOR_RIGHT, + LOGIC_GATE_XOR_DOWN, + LOGIC_GATE_XOR_LEFT, + // 0x10 + LOGIC_GATE_LATCH_UP, + LOGIC_GATE_LATCH_RIGHT, + LOGIC_GATE_LATCH_DOWN, + LOGIC_GATE_LATCH_LEFT, + LOGIC_GATE_NAND_UP, + LOGIC_GATE_NAND_RIGHT, + LOGIC_GATE_NAND_DOWN, + LOGIC_GATE_NAND_LEFT, +}; + +static const uint64_t logic_gate_counter_data[] = { + LOGIC_GATE_COUNTER_0, LOGIC_GATE_COUNTER_1, LOGIC_GATE_COUNTER_2, + LOGIC_GATE_COUNTER_3, LOGIC_GATE_COUNTER_4, LOGIC_GATE_COUNTER_5, + LOGIC_GATE_COUNTER_6, LOGIC_GATE_COUNTER_7, LOGIC_GATE_COUNTER_8, + LOGIC_GATE_COUNTER_9, +}; + +static const uint64_t logic_gate_latch_mirror_data[] = { + LOGIC_GATE_LATCH_MIRROR_UP, LOGIC_GATE_LATCH_MIRROR_RIGHT, + LOGIC_GATE_LATCH_MIRROR_DOWN, LOGIC_GATE_LATCH_MIRROR_LEFT}; + +static Result_void parse_map(Level* level, SectionData* section) { + Result_void res; + uint8_t* data = section->data; + uint16_t tiles_placed = 0; + uint8_t width = 0; +#define assert_data_avail(...) \ + if (data - (uint8_t*)section->data __VA_OPT__(-1 +) __VA_ARGS__ >= \ + section->len) \ + res_throwf("Ran out of map data on tile (%d, %d)", tiles_placed % width, \ + tiles_placed / width); + assert_data_avail(); + width = *data; + data += 1; + assert_data_avail(); + uint8_t height = *data; + data += 1; + level->width = width; + level->height = height; + Cell* cells = calloc(width * height, sizeof(Cell)); + level->map = cells; + uint32_t mod = 0; + Cell* cell = cells; + uint32_t hint_idx = 0; + while (tiles_placed < width * height) { + assert_data_avail(); + uint8_t tile_id = *data; + data += 1; + if (tile_id >= lengthof(c2m_tiles)) { + res_throwf("Out-of-range tile %x", tile_id); + } + C2MDef def = c2m_tiles[tile_id]; + if (def.def_type != SPECIAL && def.ptr == NULL) { + res_throwf("Unimplemented tile %x", tile_id); + } + if (def.def_type == SPECIAL) { + if (def.preset_custom == TERRAIN_MOD8) { + assert_data_avail(); + mod = *data; + data += 1; + } else if (def.preset_custom == TERRAIN_MOD16) { + assert_data_avail(2); + mod = read_uint16_le(data); + data += 2; + } else if (def.preset_custom == TERRAIN_MOD32) { + assert_data_avail(4); + mod = read_uint32_le(data); + data += 4; + } else if (def.preset_custom == THIN_WALL) { + BasicTile* tile = Cell_get_layer(cell, LAYER_SPECIAL); + tile->type = &THIN_WALL_tile; + assert_data_avail(); + tile->custom_data = *data; + data += 1; + } else if (def.preset_custom == FRAME_BLOCK) { + Position pos = {tiles_placed % width, tiles_placed / width}; + assert_data_avail(2); + Actor* actor = + Actor_new(level, &FRAME_BLOCK_actor, pos, dir_from_cc2(*data)); + actor->custom_data = data[1]; + data += 2; + } else if (def.preset_custom == POWER_BUTTON_ON) { + BasicTile* tile = Cell_get_layer(cell, LAYER_TERRAIN); + tile->type = def.ptr; + tile->custom_data = mod | 0x30; + tiles_placed += 1; + cell += 1; + mod = 0; + } else if (def.preset_custom == POWER_BUTTON_OFF) { + BasicTile* tile = Cell_get_layer(cell, LAYER_TERRAIN); + tile->type = def.ptr; + tile->custom_data = mod & ~0x30; + tiles_placed += 1; + cell += 1; + mod = 0; + } else if (def.preset_custom == LOGIC_GATE) { + BasicTile* tile = Cell_get_layer(cell, LAYER_TERRAIN); + tile->type = &LOGIC_GATE_tile; + if (mod < 0x18) { + tile->custom_data = logic_gate_custom_data[mod]; + } else if (mod >= 0x1e && mod <= 0x27) { + tile->custom_data = logic_gate_counter_data[mod - 0x1e]; + } else if (mod >= 0x40 && mod <= 0x43) { + tile->custom_data = logic_gate_latch_mirror_data[mod - 0x40]; + } else { + // TODO: Voodoo tile + tile->type = &FLOOR_tile; + } + tiles_placed += 1; + cell += 1; + mod = 0; + + } else { + res_throws("Internal: invalid custom preset type"); + } + } else if (def.def_type == BASIC || def.def_type == BASIC_READ_MOD) { + const TileType* type = def.ptr; + BasicTile* tile = Cell_get_layer(cell, type->layer); + tile->type = type; + tile->custom_data = + def.def_type == BASIC_READ_MOD ? mod : def.preset_custom; + if ((type == &ECHIP_tile && def.preset_custom == 0) || + (type == &GREEN_BOMB_tile)) { + level->chips_left += 1; + } + if (type == &HINT_tile) { + tile->custom_data = hint_idx; + hint_idx += 1; + } + if (type->layer == LAYER_TERRAIN) { + tiles_placed += 1; + cell += 1; + mod = 0; + } + } else if (def.def_type == ACTOR) { + const ActorType* type = def.ptr; + Position pos = {tiles_placed % width, tiles_placed / width}; + assert_data_avail(); + Actor* actor = Actor_new(level, type, pos, dir_from_cc2(*data % 4)); + data += 1; + if (type->flags & ACTOR_FLAGS_REAL_PLAYER) { + if (level->players_left < level->player_seats.length) { + PlayerSeat* seat = &level->player_seats.items[level->players_left]; + seat->actor = actor; + } + level->players_left += 1; + } + } + } + Level_init_wires(level); + Level_initialize_tiles(level); + res_return(); +} + +typedef union C2MInternalUnion { + Level* level; + LevelMetadata* meta; +} C2MInternalUnion; + +static Result_SectionData parse_section(uint8_t** data, size_t* data_left) { + Result_SectionData res; +#define section (res.value) + if (*data_left < 8) + res_throws("Section goes beyond end of file"); + *data_left -= 8; + memcpy(section.name, *data, 4); + *data += 4; + section.len = read_uint32_le(*data); + *data += 4; + if (*data_left < section.len) + res_throws("Section goes beyond end of file"); + section.data = *data; + *data += section.len; + *data_left -= section.len; + + res_return(section); +#undef section +} + +static Result_void parse_c2m_internal(uint8_t* data, + size_t data_len, + C2MInternalUnion uni, + bool meta_only) { + Result_void res; + Result_SectionData section_res; +#define section (section_res.value) + + LevelMetadata* meta = meta_only ? uni.meta : &uni.level->metadata; + Level* level = meta_only ? NULL : uni.level; + + size_t data_left = data_len; + + section_res = parse_section(&data, &data_left); + if (!section_res.success) + res_throwr(section_res.error); + + if (memcmp(section.name, "CC2M", 4)) + res_throws("Missing CC2M header"); + // TODO: Care about this? + uint64_t c2m_version = atol(section.data); + + while (true) { + if (data_left == 0) { + res_throws("C2M doesn't have END section"); + } + section_res = parse_section(&data, &data_left); + if (!section_res.success) + res_throwr(section_res.error); +#define match_section(str) !memcmp(section.name, str, 4) + if (match_section("TITL")) { + char* title_latin1 = strndupz(section.data, section.len); + char* title = strdupz_latin1_to_utf8(title_latin1); + free(title_latin1); + meta->title = title; + } else if (match_section("AUTH")) { + char* author_latin1 = strndupz(section.data, section.len); + char* author = strdupz_latin1_to_utf8(author_latin1); + free(author_latin1); + meta->author = author; + } else if (match_section("CLUE")) { + char* clue_latin1 = strndupz(section.data, section.len); + char* clue = strdupz_latin1_to_utf8(clue_latin1); + free(clue_latin1); + meta->default_hint = clue; + } else if (match_section("NOTE")) { + parse_note(meta, §ion); + } else if (match_section("OPTN")) { + parse_optn(meta, §ion); + if (!meta_only) { + level->time_left = level->metadata.timer * 60; + Level_init_players(level, level->metadata.player_n); + } + } else if (match_section("END ")) { + break; + } else if (meta_only) { + continue; + } else if (match_section("PACK")) { + section_res = unpack_section(section); + if (!section_res.success) + res_throwr(section_res.error); + Result_void res2 = parse_map(level, §ion); + free(section.data); + if (!res2.success) { + res_throwr(res2.error); + } + } else if (match_section("MAP ")) { + Result_void res2 = parse_map(level, §ion); + if (!res2.success) { + res_throwr(res2.error); + } + } else if (match_section("PRPL")) { + section_res = unpack_section(section); + if (!section_res.success) + res_throwr(section_res.error); + parse_rpl(level, §ion); + free(section.data); + } else if (match_section("REPL")) { + parse_rpl(level, §ion); + } else { + // If this is an unknown section, do nothing. + } + } + + res_return(); +#undef section +#undef match_section +} + +Result_LevelPtr parse_c2m(void* data, size_t data_len) { + assert(data != NULL); + Result_LevelPtr res; + Level* level = xmalloc(sizeof(Level)); + Level_init_basic(level); + Result_void int_res = + parse_c2m_internal(data, data_len, (C2MInternalUnion)level, false); + if (!int_res.success) { + Level_uninit(level); + free(level); + res_throwr(int_res.error); + } + res_return(level); +}; +Result_LevelMetadataPtr parse_c2m_meta(void* data, size_t data_len) { + assert(data != NULL); + Result_LevelMetadataPtr res; + LevelMetadata* meta = xmalloc(sizeof(LevelMetadata)); + LevelMetadata_init(meta); + Result_void int_res = + parse_c2m_internal(data, data_len, (C2MInternalUnion)meta, true); + if (!int_res.success) { + LevelMetadata_uninit(meta); + free(meta); + res_throwr(int_res.error); + } + res_return(meta); +}; diff --git a/libnotcc/src/c2m.h b/libnotcc/src/c2m.h new file mode 120000 index 00000000..78448a0f --- /dev/null +++ b/libnotcc/src/c2m.h @@ -0,0 +1 @@ +../include/c2m.h \ No newline at end of file diff --git a/libnotcc/src/hash.c b/libnotcc/src/hash.c new file mode 100644 index 00000000..74b5a211 --- /dev/null +++ b/libnotcc/src/hash.c @@ -0,0 +1,154 @@ +#include +#include "logic.h" +#include "tiles.h" +static uint32_t crctab[256] = { + 0x00000000, 0x09073096, 0x120e612c, 0x1b0951ba, 0xff6dc419, 0xf66af48f, + 0xed63a535, 0xe46495a3, 0xfedb8832, 0xf7dcb8a4, 0xecd5e91e, 0xe5d2d988, + 0x01b64c2b, 0x08b17cbd, 0x13b82d07, 0x1abf1d91, 0xfdb71064, 0xf4b020f2, + 0xefb97148, 0xe6be41de, 0x02dad47d, 0x0bdde4eb, 0x10d4b551, 0x19d385c7, + 0x036c9856, 0x0a6ba8c0, 0x1162f97a, 0x1865c9ec, 0xfc015c4f, 0xf5066cd9, + 0xee0f3d63, 0xe7080df5, 0xfb6e20c8, 0xf269105e, 0xe96041e4, 0xe0677172, + 0x0403e4d1, 0x0d04d447, 0x160d85fd, 0x1f0ab56b, 0x05b5a8fa, 0x0cb2986c, + 0x17bbc9d6, 0x1ebcf940, 0xfad86ce3, 0xf3df5c75, 0xe8d60dcf, 0xe1d13d59, + 0x06d930ac, 0x0fde003a, 0x14d75180, 0x1dd06116, 0xf9b4f4b5, 0xf0b3c423, + 0xebba9599, 0xe2bda50f, 0xf802b89e, 0xf1058808, 0xea0cd9b2, 0xe30be924, + 0x076f7c87, 0x0e684c11, 0x15611dab, 0x1c662d3d, 0xf6dc4190, 0xffdb7106, + 0xe4d220bc, 0xedd5102a, 0x09b18589, 0x00b6b51f, 0x1bbfe4a5, 0x12b8d433, + 0x0807c9a2, 0x0100f934, 0x1a09a88e, 0x130e9818, 0xf76a0dbb, 0xfe6d3d2d, + 0xe5646c97, 0xec635c01, 0x0b6b51f4, 0x026c6162, 0x196530d8, 0x1062004e, + 0xf40695ed, 0xfd01a57b, 0xe608f4c1, 0xef0fc457, 0xf5b0d9c6, 0xfcb7e950, + 0xe7beb8ea, 0xeeb9887c, 0x0add1ddf, 0x03da2d49, 0x18d37cf3, 0x11d44c65, + 0x0db26158, 0x04b551ce, 0x1fbc0074, 0x16bb30e2, 0xf2dfa541, 0xfbd895d7, + 0xe0d1c46d, 0xe9d6f4fb, 0xf369e96a, 0xfa6ed9fc, 0xe1678846, 0xe860b8d0, + 0x0c042d73, 0x05031de5, 0x1e0a4c5f, 0x170d7cc9, 0xf005713c, 0xf90241aa, + 0xe20b1010, 0xeb0c2086, 0x0f68b525, 0x066f85b3, 0x1d66d409, 0x1461e49f, + 0x0edef90e, 0x07d9c998, 0x1cd09822, 0x15d7a8b4, 0xf1b33d17, 0xf8b40d81, + 0xe3bd5c3b, 0xeaba6cad, 0xedb88320, 0xe4bfb3b6, 0xffb6e20c, 0xf6b1d29a, + 0x12d54739, 0x1bd277af, 0x00db2615, 0x09dc1683, 0x13630b12, 0x1a643b84, + 0x016d6a3e, 0x086a5aa8, 0xec0ecf0b, 0xe509ff9d, 0xfe00ae27, 0xf7079eb1, + 0x100f9344, 0x1908a3d2, 0x0201f268, 0x0b06c2fe, 0xef62575d, 0xe66567cb, + 0xfd6c3671, 0xf46b06e7, 0xeed41b76, 0xe7d32be0, 0xfcda7a5a, 0xf5dd4acc, + 0x11b9df6f, 0x18beeff9, 0x03b7be43, 0x0ab08ed5, 0x16d6a3e8, 0x1fd1937e, + 0x04d8c2c4, 0x0ddff252, 0xe9bb67f1, 0xe0bc5767, 0xfbb506dd, 0xf2b2364b, + 0xe80d2bda, 0xe10a1b4c, 0xfa034af6, 0xf3047a60, 0x1760efc3, 0x1e67df55, + 0x056e8eef, 0x0c69be79, 0xeb61b38c, 0xe266831a, 0xf96fd2a0, 0xf068e236, + 0x140c7795, 0x1d0b4703, 0x060216b9, 0x0f05262f, 0x15ba3bbe, 0x1cbd0b28, + 0x07b45a92, 0x0eb36a04, 0xead7ffa7, 0xe3d0cf31, 0xf8d99e8b, 0xf1deae1d, + 0x1b64c2b0, 0x1263f226, 0x096aa39c, 0x006d930a, 0xe40906a9, 0xed0e363f, + 0xf6076785, 0xff005713, 0xe5bf4a82, 0xecb87a14, 0xf7b12bae, 0xfeb61b38, + 0x1ad28e9b, 0x13d5be0d, 0x08dcefb7, 0x01dbdf21, 0xe6d3d2d4, 0xefd4e242, + 0xf4ddb3f8, 0xfdda836e, 0x19be16cd, 0x10b9265b, 0x0bb077e1, 0x02b74777, + 0x18085ae6, 0x110f6a70, 0x0a063bca, 0x03010b5c, 0xe7659eff, 0xee62ae69, + 0xf56bffd3, 0xfc6ccf45, 0xe00ae278, 0xe90dd2ee, 0xf2048354, 0xfb03b3c2, + 0x1f672661, 0x166016f7, 0x0d69474d, 0x046e77db, 0x1ed16a4a, 0x17d65adc, + 0x0cdf0b66, 0x05d83bf0, 0xe1bcae53, 0xe8bb9ec5, 0xf3b2cf7f, 0xfab5ffe9, + 0x1dbdf21c, 0x14bac28a, 0x0fb39330, 0x06b4a3a6, 0xe2d03605, 0xebd70693, + 0xf0de5729, 0xf9d967bf, 0xe3667a2e, 0xea614ab8, 0xf1681b02, 0xf86f2b94, + 0x1c0bbe37, 0x150c8ea1, 0x0e05df1b, 0x0702ef8d, +}; + +typedef int32_t crc32_t; + +static crc32_t crc_feed8(crc32_t crc, uint8_t val) { + return (crc >> 8) ^ (crctab[(crc ^ val) & 0xff]); +} +static crc32_t crc_feed16(crc32_t crc, uint16_t val) { + return crc_feed8(crc_feed8(crc, val & 0xff), val >> 8); +} +static crc32_t crc_feed32(crc32_t crc, uint32_t val) { + return crc_feed16(crc_feed16(crc, val & 0xffff), val >> 16); +} +static crc32_t crc_feed64(crc32_t crc, uint64_t val) { + return crc_feed32(crc_feed32(crc, val & 0xffffffff), val >> 32); +} +#define feed_hash8(val) hash = crc_feed8(hash, val); +#define feed_hash16(val) hash = crc_feed16(hash, val); +#define feed_hash32(val) hash = crc_feed32(hash, val); +#define feed_hash64(val) hash = crc_feed64(hash, val); + +static crc32_t Inventory_hash(const Inventory* self, crc32_t hash) { + for (uint8_t idx = 0; idx < 16; idx += 1) { + feed_hash8(self->counters.val[idx]); + } + feed_hash64((uintptr_t)self->item1); + feed_hash64((uintptr_t)self->item2); + feed_hash64((uintptr_t)self->item3); + feed_hash64((uintptr_t)self->item4); + feed_hash8(self->keys_red); + feed_hash8(self->keys_blue); + feed_hash8(self->keys_green); + feed_hash8(self->keys_yellow); + return hash; +} + +inline static bool Actor_should_direction_be_hashed(const Actor* self, + uint32_t settings) { + if (self->sliding_state != SLIDING_NONE || Actor_is_moving(self)) + return true; + if (has_flag(self, ACTOR_FLAGS_BLOCK)) + return false; + if (has_flag(self, ACTOR_FLAGS_REAL_PLAYER) && + (settings & HASH_SETTINGS_IGNORE_PLAYER_DIRECTION)) + return false; + return true; +} + +static crc32_t Actor_hash(const Actor* self, + const Level* level, + crc32_t hash, + uint32_t settings) { + if (self == NULL) + return hash; + feed_hash64((uintptr_t)self->type); + feed_hash64(self->custom_data); + feed_hash8(self->pulled); + feed_hash8(self->pulling); + feed_hash8(self->pushing); + feed_hash8(self->frozen); + feed_hash8(self->move_progress); + hash = Inventory_hash(&self->inventory, hash); + feed_hash8(self->pending_decision); + if (Actor_should_direction_be_hashed(self, settings)) { + feed_hash8(self->direction); + } + if (!((settings & HASH_SETTINGS_IGNORE_BLOCK_ORDER) && + (self->type->flags & ACTOR_FLAGS_BLOCK))) { + feed_hash32(Actor_get_actor_list_idx(self, level)); + } + + return hash; +} + +int32_t Level_hash(const Level* self, uint32_t settings) { + crc32_t hash = ~0; + feed_hash32(self->chips_left); + feed_hash32(self->bonus_points); + feed_hash32(self->time_left); + feed_hash8(self->rng1); + feed_hash8(self->rng2); + feed_hash8(self->rng_blob); + feed_hash8(self->time_stopped); + feed_hash8(self->rff_direction); + feed_hash32(self->players_left); + if (!(settings & HASH_SETTINGS_IGNORE_MIMIC_PARITY)) { + feed_hash8(self->current_tick % 16); + } else if (!(settings & HASH_SETTINGS_IGNORE_TEETH_PARITY)) { + feed_hash8(self->current_tick % 8); + } + for (uint32_t idx = 0; idx < self->width * self->height; idx += 1) { + Cell* cell = self->map + idx; + // TODO: Canonicalize tile type IDs to work across builds + feed_hash64((uintptr_t)cell->special.type); + feed_hash64(cell->special.custom_data); + hash = Actor_hash(cell->actor, self, hash, settings); + feed_hash64((uintptr_t)cell->item_mod.type); + feed_hash64(cell->item_mod.custom_data); + feed_hash64((uintptr_t)cell->item.type); + feed_hash64(cell->item.custom_data); + feed_hash64((uintptr_t)cell->terrain.type); + feed_hash64(cell->terrain.custom_data); + feed_hash8(cell->was_powered); + feed_hash8(cell->powered_wires); + } + return ~hash; +} diff --git a/libnotcc/src/logic.c b/libnotcc/src/logic.c new file mode 100644 index 00000000..b184cd9b --- /dev/null +++ b/libnotcc/src/logic.c @@ -0,0 +1,1528 @@ +#include "logic.h" +#include +#include +#include +#include +#include "accessors/define.h" +#include "assert.h" +#include "misc.h" +#include "tiles.h" +DEFINE_VECTOR(PlayerSeat); +_libnotcc_accessors_Inventory; +void Inventory_increment_counter(Inventory* self, uint8_t iidx) { + if (self->counters.val[iidx - 1] == 255) { + self->counters.val[iidx - 1] = 0; + } else { + self->counters.val[iidx - 1] += 1; + } +} +void Inventory_decrement_counter(Inventory* self, uint8_t iidx) { + if (self->counters.val[iidx - 1] == 0) { + self->counters.val[iidx - 1] = 255; + } else { + self->counters.val[iidx - 1] -= 1; + } +} +const TileType* Inventory_remove_item(Inventory* self, uint8_t idx) { + const TileType* item = NULL; + if (idx <= 0) { + if (idx == 0) { + item = self->item1; + } + self->item1 = self->item2; + } + if (idx <= 1) { + if (idx == 1) { + item = self->item2; + } + self->item2 = self->item3; + } + if (idx <= 2) { + if (idx == 2) { + item = self->item3; + } + self->item3 = self->item4; + } + if (idx == 3) { + item = self->item4; + } + self->item4 = NULL; + return item; +} +const TileType** Inventory_get_rightmost_item(Inventory* self) { + if (self->item4) + return &self->item4; + if (self->item3) + return &self->item3; + if (self->item2) + return &self->item2; + if (self->item1) + return &self->item1; + return NULL; +} +const TileType* Inventory_shift_right(Inventory* self) { + const TileType* item = self->item4; + self->item4 = self->item3; + self->item3 = self->item2; + self->item2 = self->item1; + self->item1 = NULL; + return item; +} +const TileType** Inventory_get_item_by_idx(Inventory* self, uint8_t idx) { + if (idx == 0) + return &self->item1; + if (idx == 1) + return &self->item2; + if (idx == 2) + return &self->item3; + if (idx == 3) + return &self->item4; + assert(idx < 4); + return NULL; +} + +static TileType const* const CANONICAL_ITEMS[] = { + NULL, + &FORCE_BOOTS_tile, + &ICE_BOOTS_tile, + &FIRE_BOOTS_tile, + &WATER_BOOTS_tile, + &DYNAMITE_tile, + &HELMET_tile, + &DIRT_BOOTS_tile, + &LIGHTNING_BOLT_tile, + &BOWLING_BALL_tile, + &TELEPORT_YELLOW_tile, + &RR_SIGN_tile, + &STEEL_FOIL_tile, + &SECRET_EYE_tile, + &BRIBE_tile, + &SPEED_BOOTS_tile, + &HOOK_tile, +}; + +void Inventory_set_items(Inventory* self, + ItemIndex item1, + ItemIndex item2, + ItemIndex item3, + ItemIndex item4) { + self->counters = (Uint8_16){}; + ItemIndex items_to_set[] = {item1, item2, item3, item4}; + for (size_t idx = 0; idx < lengthof(items_to_set); idx += 1) { + ItemIndex item = items_to_set[idx]; + *Inventory_get_item_by_idx(self, idx) = CANONICAL_ITEMS[item]; + if (item != 0) { + Inventory_increment_counter(self, item); + } + } +}; + +ItemIndex TileType_get_item_index(TileType const* tile) { + return tile == NULL ? 0 : tile->item_index; +}; + +size_t Position_to_offset(Position pos, size_t pitch) { + return pos.x + pos.y * pitch; +} +Position Position_from_offset(size_t offset, size_t pitch) { + return (Position){offset % pitch, offset / pitch}; +} + +_libnotcc_accessors_LastPlayerInfo; + +LastPlayerInfo* Level_get_last_won_player_info_ptr(Level* self) { + return &self->last_won_player_info; +} + +Inventory* LastPlayerInfo_get_inventory_ptr(LastPlayerInfo* self) { + return &self->inventory; +} + +_libnotcc_accessors_LevelMetadata; + +void LevelMetadata_init(LevelMetadata* self) { + *self = (LevelMetadata){.rng_blob_4pat = true, + .player_n = 1, + .camera_width = 10, + .camera_height = 10}; +} + +void LevelMetadata_uninit(LevelMetadata* self) { + free(self->title); + free(self->author); + free(self->default_hint); + for_vector(CharPtr*, hint_ptr, &self->hints) { + free(*hint_ptr); + } + Vector_CharPtr_uninit(&self->hints); + free(self->c2g_command); +} + +LevelMetadata LevelMetadata_clone(const LevelMetadata* self) { + LevelMetadata new_meta; + memcpy(&new_meta, self, sizeof(LevelMetadata)); + new_meta.title = strdupz(self->title); + new_meta.author = strdupz(self->author); + new_meta.default_hint = strdupz(self->default_hint); + new_meta.hints = Vector_CharPtr_clone(&self->hints); + for_vector(CharPtr*, hint, &new_meta.hints) { + *hint = strdupz(*hint); + } + return new_meta; +} + +_libnotcc_accessors_Level; + +Position Level_get_neighbor(Level* self, Position pos, Direction dir) { + uint8_t pitch = self->width; + size_t position_offset = Position_to_offset(pos, pitch); + if (dir == DIRECTION_UP) + position_offset -= pitch; + if (dir == DIRECTION_RIGHT) + position_offset += 1; + if (dir == DIRECTION_DOWN) + position_offset += pitch; + if (dir == DIRECTION_LEFT) + position_offset -= 1; + return Position_from_offset(position_offset, pitch); +} + +bool Level_check_position_inbounds(const Level* self, + Position pos, + Direction dir, + bool wrap) { + if (dir == DIRECTION_UP && pos.y == 0) + return false; + if (!wrap && dir == DIRECTION_RIGHT && pos.x == self->width - 1) + return false; + if (dir == DIRECTION_RIGHT && pos.x == self->width - 1 && + pos.y == self->height - 1) + return false; + if (dir == DIRECTION_DOWN && pos.y == self->height - 1) + return false; + if (!wrap && dir == DIRECTION_LEFT && pos.x == 0) + return false; + if (dir == DIRECTION_LEFT && pos.x == 0 && pos.y == 0) + return false; + return true; +} + +Cell* Level_get_cell(Level* self, Position pos) { + uint16_t position_offset = Position_to_offset(pos, self->width); + if (position_offset >= self->width * self->height) + return NULL; + return self->map + position_offset; +} + +void Level_init_basic(Level* self) { + // Basic + *self = (Level){ + .current_subtick = -1, .rng_blob = 0x55, .rff_direction = DIRECTION_UP}; + LevelMetadata_init(&self->metadata); +} +void Level_init_players(Level* self, uint32_t players_n) { + self->player_seats = Vector_PlayerSeat_init(players_n); + for (size_t idx = 0; idx < players_n; idx += 1) { + Vector_PlayerSeat_push(&self->player_seats, (PlayerSeat){}); + } +} +void Level_init(Level* self, + uint8_t width, + uint8_t height, + uint32_t players_n) { + Level_init_basic(self); + Level_init_players(self, players_n); + self->width = width; + self->height = height; + uint16_t tile_count = width * height; + self->map = calloc(tile_count, sizeof(Cell)); + for (uint16_t i = 0; i < tile_count; i += 1) { + self->map[i].terrain.type = &FLOOR_tile; + } +} +void Level_uninit(Level* self) { + if (self == NULL) + return; + for (size_t idx = 0; idx < self->actors_allocated_n; idx += 1) { + free(self->actors[idx]); + } + free(self->actors); + free(self->map); + LevelMetadata_uninit(&self->metadata); + if (self->builtin_replay) { + Vector_PlayerInputs_uninit(&self->builtin_replay->inputs); + free(self->builtin_replay); + } + Vector_PlayerSeat_uninit(&self->player_seats); + for_vector(WireNetwork*, wire_network, &self->wire_networks) { + Vector_WireNetworkMember_uninit(&wire_network->members); + Vector_WireNetworkMember_uninit(&wire_network->emitters); + } + Vector_WireNetwork_uninit(&self->wire_networks); + Vector_Position_uninit(&self->wire_consumers); +} +size_t Level_total_size(const Level* self) { + return sizeof(Level) + + self->actors_allocated_n * (sizeof(Actor) + sizeof(Actor*)) + + self->width * self->height * sizeof(Cell) + + self->player_seats.capacity * sizeof(PlayerSeat); +} +uint8_t Level_rng(Level* self) { + int16_t n = (self->rng1 >> 2) - self->rng1; + if (!(self->rng1 & 0x02)) + n -= 1; + self->rng1 = (self->rng1 >> 1) | (self->rng2 & 0x80); + self->rng2 = (self->rng2 << 1) | (n & 0x1); + return self->rng1 ^ self->rng2; +} +uint8_t Level_blobmod(Level* self) { + if (self->metadata.rng_blob_4pat) { + self->rng_blob = (self->rng_blob + 1) % 4; + } else { + uint16_t mod = self->rng_blob * 2; + if (mod < 255) + mod ^= 0x1d; + self->rng_blob = mod & 0xff; + } + return self->rng_blob; +} + +void Level_realloc_actors(Level* self) { + if (self->actors_allocated_n == 0) { + free(self->actors); + self->actors = NULL; + return; + } + self->actors = + xrealloc(self->actors, self->actors_allocated_n * sizeof(Actor*)); +} + +void Level_compact_actor_array(Level* self) { + if (self->actors_allocated_n == self->actors_n) + return; + Actor** actors = self->actors; + Actor** actors_free = self->actors; + uint32_t actors_seen = 0; + while (actors_seen < self->actors_allocated_n) { + if ((*actors)->type == NULL) { + free(*actors); + actors += 1; + actors_seen += 1; + continue; + } + if (actors != actors_free) { + *actors_free = *actors; + } + actors_free += 1; + actors += 1; + actors_seen += 1; + } + self->actors_allocated_n = self->actors_n; + Level_realloc_actors(self); +} + +Actor* Level_find_next_player(Level* self, Actor* player) { + Actor** player_actors_ptr = self->actors; + while (*player_actors_ptr != player) { + player_actors_ptr += 1; + } + Actor** search_position = player_actors_ptr - 1; + // Search between `player` and start of actor list + while (search_position >= self->actors) { + if ((has_flag(*search_position, ACTOR_FLAGS_REAL_PLAYER)) && + Level_find_player_seat(self, *search_position) == NULL) { + return *search_position; + } + search_position -= 1; + } + // Search between end of actor list and `player` + search_position = &self->actors[self->actors_allocated_n - 1]; + while (search_position > player_actors_ptr) { + if ((has_flag(*search_position, ACTOR_FLAGS_REAL_PLAYER)) && + Level_find_player_seat(self, *search_position) == NULL) { + return *search_position; + } + search_position -= 1; + } + return NULL; +} + +PlayerSeat* Level_find_player_seat(Level* self, const Actor* player) { + if (compiler_expect_prob(self->player_seats.length == 1, true, .99)) { + PlayerSeat* seat = &self->player_seats.items[0]; + return seat->actor == player ? seat : NULL; + } + for_vector(PlayerSeat*, seat, &self->player_seats) { + if (seat->actor == player) { + return seat; + } + } + return NULL; +} + +Vector_PlayerSeat* Level_get_player_seats_ptr(Level* self) { + return &self->player_seats; +} + +Level* Level_clone(const Level* self) { + Level* new_level = xmalloc(sizeof(Level)); + // Copy over all fields and modify the ones that aren't trivially copied + memcpy(new_level, self, sizeof(Level)); + // `map` + size_t map_size = self->width * self->height * sizeof(Cell); + new_level->map = xmalloc(map_size); + memcpy(new_level->map, self->map, map_size); + new_level->player_seats = Vector_PlayerSeat_clone(&self->player_seats); + // `actors` + new_level->actors = xmalloc(self->actors_allocated_n * sizeof(Actor*)); + for (size_t idx = 0; idx < new_level->actors_allocated_n; idx += 1) { + const Actor* old_actor = self->actors[idx]; + Actor* actor = xmalloc(sizeof(Actor)); + memcpy(actor, old_actor, sizeof(Actor)); + new_level->actors[idx] = actor; + Cell* cell = Level_get_cell(new_level, old_actor->position); + // Not guaranteed to be true, actor could be despawned + if (cell->actor == old_actor) { + cell->actor = actor; + } + if (has_flag(old_actor, ACTOR_FLAGS_REAL_PLAYER)) { + PlayerSeat* seat = Level_find_player_seat(new_level, old_actor); + if (seat) { + seat->actor = actor; + } + } + } + new_level->metadata = LevelMetadata_clone(&self->metadata); + new_level->wire_consumers = Vector_Position_clone(&self->wire_consumers); + new_level->wire_networks = Vector_WireNetwork_clone(&self->wire_networks); + for_vector(WireNetwork*, network, &new_level->wire_networks) { + network->members = Vector_WireNetworkMember_clone(&network->members); + network->emitters = Vector_WireNetworkMember_clone(&network->emitters); + } + new_level->glitches = Vector_Glitch_clone(&self->glitches); + return new_level; +} + +Cell* Level_get_cell_xy(Level* self, uint8_t x, uint8_t y) { + return &self->map[Position_to_offset((Position){x, y}, self->width)]; +} + +LevelMetadata* Level_get_metadata_ptr(Level* self) { + return &self->metadata; +} +Position Level_pos_from_cell(const Level* self, const Cell* cell) { + assert(&self->map[0] <= cell && + cell <= &self->map[self->width * self->height - 1]); + size_t idx = cell - self->map; + return (Position){.x = idx % self->width, .y = idx / self->width}; +} +Cell* Level_search_reading_order(Level* self, + Cell* base, + bool reverse, + bool (*match_func)(void* ctx, + Level* level, + Cell* cell), + void* ctx) { + int8_t direction = reverse ? -1 : 1; + size_t base_idx = + Position_to_offset(Level_pos_from_cell(self, base), self->width); + size_t tiles_n = self->width * self->height; + ptrdiff_t idx = base_idx + direction; + while (0 <= idx && idx < tiles_n) { + Cell* cell = &self->map[idx]; + if (match_func(ctx, self, cell)) + return cell; + idx += direction; + } + idx = reverse ? tiles_n - 1 : 0; + while (idx != base_idx) { + Cell* cell = &self->map[idx]; + if (match_func(ctx, self, cell)) + return cell; + idx += direction; + } + return NULL; +} + +// Lol +#define max(a, b) ((a) > (b) ? (a) : (b)) + +Cell* Level_search_taxicab(Level* self, + Cell* base, + bool (*match_func)(void* ctx, + Level* level, + Cell* cell), + void* ctx) { + uint8_t max_dist = max(self->width, self->height) + 1; + Position base_pos = Level_pos_from_cell(self, base); + + for (uint8_t dist = 1; dist <= max_dist; dist += 1) { + Cell* found_cell = + Level_search_taxicab_at_dist(self, base_pos, dist, match_func, ctx); + if (found_cell) + return found_cell; + } + return NULL; +} + +Cell* Level_search_taxicab_at_dist(Level* self, + Position base_pos, + uint8_t dist, + bool (*match_func)(void* ctx, + Level* level, + Cell* cell), + void* ctx) { + int8_t x = base_pos.x + dist; + int8_t y = base_pos.y; + // The stages: 1. go UL (cells 1-3) 2. go DL (cells 3-5) 3. go DR (cells + // 5-7) 4. go UR (cells 7-1) (basically, go counterclockwise starting from + // the rightmost cell) At `dist`=2, the checked cells will be: + + // # # 3 # # + // # 4 # 2 # + // 5 # x # 1 + // # 6 # 8 # + // # # 7 # # + for (uint8_t stage = 0; stage < 4; stage += 1) { + for (uint8_t i = 0; i < dist; i += 1) { + if (0 <= x && x < self->width && 0 <= y && y < self->height) { + Cell* cell = Level_get_cell(self, (Position){x, y}); + if (match_func(ctx, self, cell)) + return cell; + } + x += stage > 1 ? 1 : -1; + y += stage == 0 || stage == 3 ? -1 : 1; + } + } + return NULL; +} + +static bool jetlife_get_power_state(const BasicTile* tile, + bool tile_before_current) { + if (tile->type == &FIRE_tile) + return true; + if (tile->type == &FLAME_JET_tile) { + // We use the second bit of flame jet's to specify if this jet was on last + // subtick We can ignore the bit of all tiles after us (because we haven't + // gotten to them yet this loop), and we can be sure all previous jets' + // bit was set this loop (because we already updated them) + return tile_before_current ? (tile->custom_data & 2) + : (tile->custom_data & 1); + } + return false; +} + +static bool jetlife_power_at_offset(const Level* self, + Position base, + int8_t dx, + int8_t dy) { + uint8_t x; + if (dx == 0) + x = base.x; + else if (dx == 1) + x = base.x + 1; + else if (dx == -1) + x = base.x + self->width - 1; + else + compiler_expect(false, true); + x %= self->width; + uint8_t y; + if (dy == 0) + y = base.y; + else if (dy == 1) + y = base.y + 1; + else if (dy == -1) + y = base.y + self->height - 1; + else + compiler_expect(false, true); + y %= self->height; + Cell* cell = &self->map[y * self->width + x]; + Position pos = {x, y}; + bool is_checked_before_us = compare_pos_in_reading_order(&base, &pos) > 0; + return jetlife_get_power_state(&cell->terrain, is_checked_before_us); +} + +void Level_do_jetlife(Level* self) { + for (uint8_t y = 0; y < self->height; y += 1) { + for (uint8_t x = 0; x < self->width; x += 1) { + Cell* cell = &self->map[y * self->width + x]; + BasicTile* jet = &cell->terrain; + if (jet->type != &FLAME_JET_tile) + continue; + bool was_state = jet->custom_data & 1; + Position pos = {x, y}; + uint8_t neighbors = 0; + neighbors += jetlife_power_at_offset(self, pos, -1, -1); + neighbors += jetlife_power_at_offset(self, pos, -1, 0); + neighbors += jetlife_power_at_offset(self, pos, -1, 1); + neighbors += jetlife_power_at_offset(self, pos, 0, -1); + neighbors += jetlife_power_at_offset(self, pos, 0, 1); + neighbors += jetlife_power_at_offset(self, pos, 1, -1); + neighbors += jetlife_power_at_offset(self, pos, 1, 0); + neighbors += jetlife_power_at_offset(self, pos, 1, 1); + bool new_state = neighbors == 3 || (neighbors == 2 && was_state); + jet->custom_data = (was_state << 1) | new_state; + } + } +} + +_libnotcc_accessors_PlayerSeat; + +bool PlayerSeat_has_perspective(const PlayerSeat* self) { + return has_item_counter(self->actor->inventory, ITEM_INDEX_SECRET_EYE); +} + +BasicTile* Cell_get_layer(Cell* self, Layer layer) { + if (layer == LAYER_SPECIAL) + return &self->special; + if (layer == LAYER_ITEM_MOD) + return &self->item_mod; + if (layer == LAYER_ITEM) + return &self->item; + if (layer == LAYER_TERRAIN) + return &self->terrain; + return NULL; +} + +Actor* Cell_get_actor(Cell* self) { + return self->actor; +} +void Cell_set_actor(Cell* self, Actor* actor) { + self->actor = actor; +} +[[clang::always_inline]] void Cell_place_actor(Cell* self, + Level* level, + Actor* actor) { + assert(actor != NULL); + if (self->actor != NULL && self->actor != actor) { + Level_add_glitch(level, + (Glitch){.glitch_kind = GLITCH_TYPE_DESPAWN, + .location = Level_pos_from_cell(level, self), + .specifier = GLITCH_SPECIFIER_DESPAWN_REPLACE}); + } + self->actor = actor; +} +[[clang::always_inline]] void Cell_remove_actor(Cell* self, + Level* level, + Actor* actor) { + if (self->actor != NULL && self->actor != actor) { + Level_add_glitch(level, + (Glitch){.glitch_kind = GLITCH_TYPE_DESPAWN, + .location = Level_pos_from_cell(level, self), + .specifier = GLITCH_SPECIFIER_DESPAWN_REMOVE}); + } + + self->actor = NULL; +} +uint8_t Cell_get_powered_wires(Cell* self) { + return self->powered_wires; +} +void Cell_set_powered_wires(Cell* self, uint8_t val) { + self->powered_wires = val; +} +bool Cell_get_is_wired(Cell* self) { + return self->is_wired; +} +void Cell_set_is_wired(Cell* self, bool val) { + self->is_wired = val; +} + +Vector_PlayerInputs* Replay_get_inputs_ptr(Replay* self) { + return &self->inputs; +} + +[[clang::always_inline]] Cell* BasicTile_get_cell(const BasicTile* tile, + Layer layer) { + assert(layer != LAYER_ACTOR); + size_t offset; + if (layer == LAYER_SPECIAL) + offset = offsetof(Cell, special); + else if (layer == LAYER_ITEM_MOD) + offset = offsetof(Cell, item_mod); + else if (layer == LAYER_ITEM) + offset = offsetof(Cell, item); + else if (layer == LAYER_TERRAIN) + offset = offsetof(Cell, terrain); + else + return NULL; + return (Cell*)((void*)tile - offset); +} + +_libnotcc_accessors_BasicTile; + +bool BasicTile_impedes(BasicTile* self, + Level* level, + Actor* other, + Direction direction) { + if (self->type->on_bumped_by) + self->type->on_bumped_by(self, level, other, direction); + if (has_flag(other, self->type->impedes_mask)) { + if (other->type->on_bonk) + other->type->on_bonk(other, level, self); + + return true; + } + if (self->type->impedes && + self->type->impedes(self, level, other, direction)) { + if (other->type->on_bonk) + other->type->on_bonk(other, level, self); + + return true; + } + return false; +} +void BasicTile_erase(BasicTile* self) { + if (self->type->layer == LAYER_TERRAIN) { + self->type = &FLOOR_tile; + } else { + self->type = NULL; + } +} + +void BasicTile_transform_into(BasicTile* self, const TileType* new_type) { + self->type = new_type; +} + +_libnotcc_accessors_Actor; + +bool Actor_is_moving(const Actor* actor) { + return actor->move_progress > 0; +} + +bool Actor_is_gone(const Actor* actor) { + return !actor->type || has_flag(actor, ACTOR_FLAGS_ANIMATION); +} + +Actor* Actor_new(Level* level, + const ActorType* type, + Position position, + Direction direction) { + Actor* self = xmalloc(sizeof(Actor)); + level->actors_n += 1; + level->actors_allocated_n += 1; + Level_realloc_actors(level); + level->actors[level->actors_allocated_n - 1] = self; + *self = (Actor){.type = type, .position = position, .direction = direction}; + Cell* cell = Level_get_cell(level, position); + Cell_place_actor(cell, level, self); + if (self->type->init) + self->type->init(self, level); + return self; +} + +#define NOTIFY_LAYER(_btile, _func, _level, ...) \ + if (_btile.type && _btile.type->_func) \ + _btile.type->_func(&_btile, _level __VA_OPT__(, ) __VA_ARGS__); + +#define NOTIFY_ITEM_LAYER(_cell, _func, _level, ...) \ + if (_cell->item.type && \ + !(_cell->item_mod.type && _cell->item_mod.type->overrides_item_layer && \ + _cell->item_mod.type->overrides_item_layer(&_cell->item_mod, _level, \ + &_cell->item)) && \ + _cell->item.type->_func) \ + _cell->item.type->_func(&_cell->item, _level __VA_OPT__(, ) __VA_ARGS__); + +#define NOTIFY_ALL_LAYERS(_cell, _func, _level, ...) \ + NOTIFY_LAYER(_cell->special, _func, _level __VA_OPT__(, ) __VA_ARGS__); \ + NOTIFY_LAYER(_cell->item_mod, _func, _level __VA_OPT__(, ) __VA_ARGS__); \ + NOTIFY_ITEM_LAYER(_cell, _func, _level __VA_OPT__(, ) __VA_ARGS__); \ + NOTIFY_LAYER(_cell->terrain, _func, _level __VA_OPT__(, ) __VA_ARGS__); +void Actor_do_idle(Actor* self, Level* level) { + Cell* cell = Level_get_cell(level, self->position); + NOTIFY_ALL_LAYERS(cell, on_idle, level, self); +} + +bool Level_is_movement_subtick(Level const* level) { + return level->current_subtick == 2; +} + +void Level_apply_tank_buttons(Level* self) { + if (!self->blue_button_pressed && + self->yellow_button_pressed == DIRECTION_NONE) + return; + for (size_t idx = 0; idx < self->actors_allocated_n; idx += 1) { + Actor* actor = self->actors[idx]; + if (actor->type == &BLUE_TANK_actor && self->blue_button_pressed) { + if (actor->custom_data & BLUE_TANK_ROTATE) { + actor->custom_data &= ~BLUE_TANK_ROTATE; + } else { + actor->custom_data |= BLUE_TANK_ROTATE; + } + } else if (actor->type == &YELLOW_TANK_actor && + self->yellow_button_pressed) { + actor->custom_data = self->yellow_button_pressed; + } + } + self->blue_button_pressed = false; + self->yellow_button_pressed = DIRECTION_NONE; +} + +void Level_tick(Level* self) { + // Clear all previously-released inputs + for_vector(PlayerSeat*, seat, &self->player_seats) { + seat->released_inputs = 0; + seat->displayed_hint = NULL; + } + // Clean out the SFX. The continuous SFX are re-set each subtick, so there's + // no point in keeping them + self->sfx = 0; + self->current_subtick += 1; + if (self->current_subtick == 3) { + self->current_tick += 1; + self->current_subtick = 0; + } + if (self->time_left > 0 && !self->time_stopped) { + self->time_left -= 1; + } + int32_t jetlife_interval = self->metadata.jetlife_interval; + if (jetlife_interval != 0) { + uint32_t subticks = self->current_tick * 3 + self->current_subtick; + // If `jetlife_interval` is negative, it only triggers on the first subtick + if (subticks == 0 || + (jetlife_interval > 0 && subticks % jetlife_interval == 0)) { + Level_do_jetlife(self); + } + } + Level_do_wire_notification(self); + for (int32_t idx = self->actors_allocated_n - 1; idx >= 0; idx -= 1) { + Actor* actor = self->actors[idx]; + if (actor->type == NULL) + continue; + Actor_do_decision(actor, self); + } + for (int32_t idx = self->actors_allocated_n - 1; idx >= 0; idx -= 1) { + Actor* actor = self->actors[idx]; + if (Actor_is_gone(actor)) + continue; + if (Actor_is_moving(actor)) { + Actor_do_cooldown(actor, self); + } else { + Actor_do_decided_move(actor, self); + } + if (!Actor_is_moving(actor)) { + Actor_do_idle(actor, self); + } + } + Level_do_wire_propagation(self); + if (self->players_left == 0 && self->game_state != GAMESTATE_CRASH) { + if (self->game_state == GAMESTATE_PLAYING && !self->time_stopped && + self->time_left > 0) { + self->time_left -= 1; + } + self->game_state = GAMESTATE_WON; + } else if (self->time_left == 1 && !self->time_stopped && + self->game_state != GAMESTATE_CRASH) { + self->time_left -= 1; + self->game_state = GAMESTATE_TIMEOUT; + } + // Do post-tick global state cleanup + if (self->green_button_pressed) { + self->toggle_wall_inverted = !self->toggle_wall_inverted; + self->green_button_pressed = false; + } + Level_apply_tank_buttons(self); + Level_compact_actor_array(self); +} + +void Level_initialize_tiles(Level* self) { + size_t tiles_n = self->width * self->height; + for (size_t idx = 0; idx < tiles_n; idx += 1) { + Cell* cell = &self->map[idx]; + // XXX: Both ActorType and TileType having an `init` which are called at + // different times in the level lifecycle is confusing, maybe rename one of + // them? + NOTIFY_ALL_LAYERS(cell, init, self, cell); + } +} + +_libnotcc_accessors_Replay; + +void Animation_do_decision(Actor* self, Level* level) { + self->custom_data -= 1; + if (self->custom_data == 0) { + Actor_erase(self, level); + } +} + +void Actor_do_decision(Actor* self, Level* level) { + self->pushing = false; + self->bonked = false; + if (has_flag(self, ACTOR_FLAGS_ANIMATION)) { + Animation_do_decision(self, level); + return; + } + if (has_flag(self, ACTOR_FLAGS_REAL_PLAYER)) { + Player_do_decision(self, level); + return; + } + if (Actor_is_moving(self) || self->frozen) + return; + if (self->pending_decision) { + self->move_decision = self->pending_decision; + self->pending_decision = DIRECTION_NONE; + self->pending_move_locked_in = true; + return; + } + if (self->sliding_state != SLIDING_NONE) { + self->move_decision = self->direction; + return; + } + self->move_decision = DIRECTION_NONE; + if (!Level_is_movement_subtick(level) && + !has_flag(self, ACTOR_FLAGS_DECIDES_EVERY_SUBTICK)) + return; + Direction directions[4] = {DIRECTION_NONE, DIRECTION_NONE, DIRECTION_NONE, + DIRECTION_NONE}; + if (self->type->decide_movement) { + self->type->decide_movement(self, level, directions); + } + for (uint8_t idx = 0; idx < 4; idx += 1) { + Direction dir = directions[idx]; + if (dir == DIRECTION_NONE) + return; + self->move_decision = dir; + self->direction = dir; + // XXX: Is the `dir` redirected by the collision check before it's set as + // the decision, or not? I can't come up with a way to check + if (Actor_check_collision(self, level, &dir)) { + return; + } + } +} + +void Actor_do_decided_move(Actor* self, Level* level) { + if (self->move_decision == DIRECTION_NONE) { + self->pulled = false; + return; + } + self->pending_decision = DIRECTION_NONE; + self->pending_move_locked_in = false; + Actor_move_to(self, level, self->move_decision); + self->pulled = false; +} + +uint8_t Actor_get_move_speed(Actor* self, Level* level, Cell* cell) { + uint8_t move_speed = + self->type->move_duration == 0 ? 12 : self->type->move_duration; + BasicTile* terrain = &cell->terrain; + if (has_item_counter(self->inventory, ITEM_INDEX_SPEED_BOOTS)) { + move_speed = move_speed / 2; + } else if (terrain->type->modify_move_duration) { + move_speed = + terrain->type->modify_move_duration(terrain, level, self, move_speed); + } + return move_speed; +} + +static void notify_actor_left(Actor* self, + Level* level, + Direction direction, + Cell* old_cell) { + NOTIFY_LAYER(old_cell->special, actor_left, level, self, direction); + if (old_cell->actor) + return; + NOTIFY_LAYER(old_cell->item_mod, actor_left, level, self, direction); + if (old_cell->actor) + return; + NOTIFY_ITEM_LAYER(old_cell, actor_left, level, self, direction); + if (old_cell->actor) + return; + Cell_remove_actor(old_cell, level, self); + NOTIFY_LAYER(old_cell->terrain, actor_left, level, self, direction); +} + +bool Actor_move_to(Actor* self, Level* level, Direction direction) { + if (Actor_is_moving(self)) + return false; + if (has_flag(self, ACTOR_FLAGS_ANIMATION) || self->frozen) + return false; + bool can_move = Actor_check_collision(self, level, &direction); + self->direction = direction; + self->bonked = !can_move; + if (!can_move) + return false; + self->pending_decision = DIRECTION_NONE; + self->move_decision = DIRECTION_NONE; + Position new_pos = Level_get_neighbor(level, self->position, direction); + Cell* old_cell = Level_get_cell(level, self->position); + Cell* new_cell = Level_get_cell(level, new_pos); + self->sliding_state = SLIDING_NONE; + self->move_progress = 1; + self->move_length = Actor_get_move_speed(self, level, new_cell); + self->position = new_pos; + Cell_place_actor(new_cell, level, self); + // Intentional ordering: don't report actors that are erased that were created + // *due* to the actor leaving. This is how dynamite always works, so + // generating a glitch every time a dynamite is dropped would be kinda dumb + Cell_remove_actor(old_cell, level, self); + notify_actor_left(self, level, direction, old_cell); + + NOTIFY_ALL_LAYERS(new_cell, actor_joined, level, self, direction); + + return true; +} + +void Actor_enter_tile(Actor* self, Level* level) { + Cell* cell = Level_get_cell(level, self->position); + NOTIFY_ALL_LAYERS(cell, actor_completely_joined, level, self); +}; + +void Actor_do_cooldown(Actor* self, Level* level) { + self->move_decision = DIRECTION_NONE; + self->pulled = false; + self->move_progress += 1; + if (self->move_progress == self->move_length) { + if (self->pending_decision != DIRECTION_NONE) { + self->pending_move_locked_in = true; + } + Actor_enter_tile(self, level); + self->move_progress = 0; + } +} + +bool Actor_push_to(Actor* self, Level* level, Direction direction) { + // Anti-recursion check + if (self->is_being_pushed) { + return false; + } + if (self->frozen) + return false; + if (self->pending_move_locked_in) + return false; + if (self->sliding_state) { + self->pending_decision = self->move_decision = direction; + return false; + } + self->is_being_pushed = true; + // I don't think it matters if the `direction` is redirected here + if (Actor_is_moving(self) || + !Actor_check_collision(self, level, &direction)) { + self->is_being_pushed = false; + return false; + } + Actor_move_to(self, level, direction); + self->is_being_pushed = false; + return true; +} + +bool Actor_check_collision(Actor* self, Level* level, Direction* direction) { + assert(direction != DIRECTION_NONE); + Cell* this_cell = Level_get_cell(level, self->position); + Direction redir_dir; +#define CHECK_REDIRECT(layer) \ + if (this_cell->layer.type && this_cell->layer.type->redirect_exit) { \ + redir_dir = this_cell->layer.type->redirect_exit(&this_cell->layer, level, \ + self, *direction); \ + if (redir_dir == DIRECTION_NONE) { \ + if (self->type->on_bonk) { \ + self->type->on_bonk(self, level, &this_cell->layer); \ + }; \ + return false; \ + }; \ + *direction = redir_dir; \ + } + CHECK_REDIRECT(special); + CHECK_REDIRECT(item_mod); + CHECK_REDIRECT(item); + CHECK_REDIRECT(terrain); + + if (!Level_check_position_inbounds(level, self->position, *direction, + false)) { + if (self->type->on_bonk) { + self->type->on_bonk(self, level, NULL); + } + return false; + } + Position new_pos = Level_get_neighbor(level, self->position, *direction); + Cell* cell = Level_get_cell(level, new_pos); + // if `cell->actor != self`, we're a despawned actor trying to move +#define CHECK_LAYER(layer) \ + if (cell->layer.type && self->type && \ + BasicTile_impedes(&cell->layer, level, self, *direction)) \ + return false; + CHECK_LAYER(special); + CHECK_LAYER(item_mod); + CHECK_LAYER(terrain); + if (cell->actor) { + Actor* other = cell->actor; + if (other->type && other->type->on_bumped_by) + other->type->on_bumped_by(other, level, self); + if (other->type && self->type && self->type->on_bump_actor) + self->type->on_bump_actor(self, level, other); + if (self->type && other->type && has_flag(self, ACTOR_FLAGS_CAN_PUSH) && + other->type->can_be_pushed && + other->type->can_be_pushed(other, level, self, *direction, false)) { + if (!Actor_push_to(other, level, *direction)) { + return false; + } else { + self->pushing = true; + // Yes, player mimics emit push SFX too + if (has_flag(self, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_BLOCK_PUSH); + } + } + } else if (other->type) + return false; + } else if (cell->item.type) { + BasicTile* item_mod = &cell->item_mod; + BasicTile* item = &cell->item; + if (!item_mod->type || !item_mod->type->overrides_item_layer || + !item_mod->type->overrides_item_layer(item_mod, level, item)) { + if (BasicTile_impedes(item, level, self, *direction)) { + return false; + } + } + } + if (has_item_counter(self->inventory, ITEM_INDEX_HOOK)) { + if (!Level_check_position_inbounds(level, self->position, back(*direction), + true)) + return true; + Cell* back_tile = Level_get_cell( + level, Level_get_neighbor(level, self->position, back(*direction))); + Actor* pulled = back_tile->actor; + if (!pulled) + return true; + bool was_pulled = pulled->pulled; + pulled->pulled = true; + self->pulling = true; + // if (pulled->pending_move_locked_in && was_pulled) + // return true; + if (Actor_is_moving(pulled)) + return false; + if (!has_flag(pulled, ACTOR_FLAGS_BLOCK) || + (pulled->type->can_be_pushed && + !pulled->type->can_be_pushed(pulled, level, self, *direction, true))) + return true; + pulled->direction = *direction; + if (pulled->frozen) + return true; + pulled->pending_decision = *direction; + pulled->move_decision = *direction; + } + return true; +#undef CHECK_REDIRECT +#undef CHECK_LAYER +} + +void Actor_transform_into(Actor* self, const ActorType* new_type) { + self->type = new_type; +} + +void Actor_destroy(Actor* self, Level* level, const ActorType* anim_type) { + if (has_flag(self, ACTOR_FLAGS_REAL_PLAYER)) { + if (level->game_state != GAMESTATE_CRASH) { + level->game_state = GAMESTATE_DEAD; + } + Level_add_sfx(level, has_flag(self, ACTOR_FLAGS_MELINDA) ? SFX_MELINDA_DEATH + : SFX_CHIP_DEATH); + PlayerSeat* seat = Level_find_player_seat(level, self); + if (seat) { + seat->actor = NULL; + } + } + Actor_transform_into(self, anim_type); + if (anim_type != NULL && self->type->init) { + // XXX: I don't know, shouldn't transformed actors always be reinitalized as + // the new actor type? + self->type->init(self, level); + } + Cell* cell = Level_get_cell(level, self->position); + NOTIFY_ALL_LAYERS(cell, actor_destroyed, level); +} + +void Actor_erase(Actor* self, Level* level) { + Cell* cell = Level_get_cell(level, self->position); + Cell_remove_actor(cell, level, self); + // The allocation for the actor itself will be freed in + // `Level_compact_actor_array` + self->type = NULL; + level->actors_n -= 1; +} + +#define has_input(seat, bit) \ + ((seat->inputs & bit) != 0 && (seat->released_inputs & bit) == 0) + +void PlayerSeat_get_movement_directions(PlayerSeat* self, Direction dirs[2]) { + if ((has_input(self, PLAYER_INPUT_UP) && + has_input(self, PLAYER_INPUT_DOWN)) || + (has_input(self, PLAYER_INPUT_LEFT) && + has_input(self, PLAYER_INPUT_RIGHT))) { + return; + } + if (has_input(self, PLAYER_INPUT_UP)) { + dirs[0] = DIRECTION_UP; + } + if (has_input(self, PLAYER_INPUT_RIGHT)) { + dirs[1] = DIRECTION_RIGHT; + } + if (has_input(self, PLAYER_INPUT_DOWN)) { + dirs[0] = DIRECTION_DOWN; + } + if (has_input(self, PLAYER_INPUT_LEFT)) { + dirs[1] = DIRECTION_LEFT; + } +} + +uint16_t Actor_get_position_xy(Actor* self) { + return (uint16_t)self->position.x + ((uint16_t)self->position.y << 8); +} + +Inventory* Actor_get_inventory_ptr(Actor* self) { + return &self->inventory; +} + +uint32_t Actor_get_actor_list_idx(const Actor* self, const Level* level) { + for (uint32_t idx = 0; idx < level->actors_allocated_n; idx += 1) { + if (level->actors[level->actors_allocated_n - idx - 1] == self) + return idx; + } + assert(!"Actor wasn't found in level's actor list"); + return 0; +} + +static void Player_calculate_sliding_sfx(Actor* self, Level* level) { + Cell* cell = Level_get_cell(level, self->position); + bool is_on_ff = has_flag(&cell->terrain, ACTOR_FLAGS_FORCE_FLOOR) && + !has_item_counter(self->inventory, ITEM_INDEX_FORCE_BOOTS); + bool is_on_ice = has_flag(&cell->terrain, ACTOR_FLAGS_ICE) && + !has_item_counter(self->inventory, ITEM_INDEX_ICE_BOOTS); + if (is_on_ff) { + Level_add_sfx(level, SFX_FORCE_FLOOR_SLIDE); + } + + if (!Actor_is_moving(self) && !is_on_ice) { + self->custom_data &= ~PLAYER_WAS_ON_ICE; + } + + if (self->custom_data & PLAYER_WAS_ON_ICE) { + Level_add_sfx(level, SFX_ICE_SLIDE); + } +} + +uint8_t PlayerSeat_get_possible_actions(PlayerSeat const* self, + Level const* level) { + if (!self) + return 0; + Actor const* actor = self->actor; + bool can_move = (actor->sliding_state == SLIDING_NONE || + (actor->sliding_state == SLIDING_WEAK && + (actor->custom_data & PLAYER_HAS_OVERRIDE) != 0)); + // You can't swap players in multiseat if there isn't an extra player, + // and `players_left` is counted at the start of the level based on the map, + // so you can't swap to cloned or cross-level despawed players if there's only + // one "original" player left + bool can_switch = level->players_left > level->player_seats.length; + // Cycling shifts all items to the right and moves the rightmost item to the + // beginning This only does anything if there are any non-rightmost items, + // thus something in the non-1 slot (emptyness counts as an item for the + // purposes of cycling) + bool can_cycle = actor->inventory.item2 || actor->inventory.item3 || + actor->inventory.item4; + bool can_drop = !level->metadata.cc1_boots && can_move && + (actor->inventory.item1 || can_cycle); + return can_move * PLAYER_INPUT_DIRECTIONAL | + can_switch * PLAYER_INPUT_SWITCH_PLAYERS | + can_cycle * PLAYER_INPUT_CYCLE_ITEMS | + can_drop * PLAYER_INPUT_DROP_ITEM; +} + +void Player_do_decision(Actor* self, Level* level) { + PlayerSeat* seat = Level_find_player_seat(level, self); + // It sucks having to do tile-related SFX stuff here, but SFX has to be + // recalculated each subtick (even when we're moving), so... + if (seat != NULL) { + Player_calculate_sliding_sfx(self, level); + } + + if (Actor_is_moving(self) || self->frozen) + return; + + uint8_t possible_actions = + !(seat && + // Subtick -1 is effectively a movement subtick + (level->current_subtick == -1 || Level_is_movement_subtick(level))) + ? 0 + : PlayerSeat_get_possible_actions(seat, level); + + bool character_switched = false; + +#define can_do_action(action) \ + ((possible_actions & action) && has_input(seat, action)) + + if (can_do_action(PLAYER_INPUT_SWITCH_PLAYERS)) { + seat->actor = Level_find_next_player(level, self); + assert(seat->actor != NULL && seat->actor != self); + character_switched = true; + seat->released_inputs |= PLAYER_INPUT_SWITCH_PLAYERS; + } + + if (can_do_action(PLAYER_INPUT_CYCLE_ITEMS)) { + const TileType** last_item_ptr = + Inventory_get_rightmost_item(&self->inventory); + if (last_item_ptr) { + const TileType* last_item = *last_item_ptr; + *last_item_ptr = NULL; + Inventory_shift_right(&self->inventory); + self->inventory.item1 = last_item; + } + seat->released_inputs |= PLAYER_INPUT_CYCLE_ITEMS; + } + + if (can_do_action(PLAYER_INPUT_DROP_ITEM)) { + Actor_drop_item(self, level); + seat->released_inputs |= PLAYER_INPUT_DROP_ITEM; + } + +#undef can_do_action + + bool bonked = false; + bool was_bonking = self->custom_data & PLAYER_IS_VISUALLY_BONKING; + if (Level_is_movement_subtick(level)) { + self->custom_data &= ~PLAYER_IS_VISUALLY_BONKING; + } + // `dirs[0]` is the vertical direction (if set), `dirs[1]` is the horizontal + // dir (if set) + Direction dirs[2] = {DIRECTION_NONE, DIRECTION_NONE}; + if (seat != NULL) { + PlayerSeat_get_movement_directions(seat, dirs); + } + + self->move_decision = DIRECTION_NONE; + if (!(possible_actions & PLAYER_INPUT_DIRECTIONAL) || + (dirs[0] == DIRECTION_NONE && dirs[1] == DIRECTION_NONE)) { + // We either cannot move or we didn't input anything. If we're sliding, + // start moving in our current direction (and get override powers if we're + // weak-sliding) + if (self->sliding_state != SLIDING_NONE) { + self->move_decision = self->direction; + if (self->sliding_state == SLIDING_WEAK && + Level_is_movement_subtick(level)) { + self->custom_data |= PLAYER_HAS_OVERRIDE; + } + } + } else { + if (character_switched) { + Level_add_glitch( + level, + (Glitch){.glitch_kind = GLITCH_TYPE_SIMULTANEOUS_CHARACTER_MOVEMENT, + .location = self->position}); + } + Direction checked_dir; + if (dirs[0] == DIRECTION_NONE || dirs[1] == DIRECTION_NONE) { + Direction chosen_dir = dirs[0] == DIRECTION_NONE ? dirs[1] : dirs[0]; + checked_dir = chosen_dir; + bonked = !Actor_check_collision(self, level, &checked_dir); + self->move_decision = chosen_dir; + } else { + checked_dir = dirs[0]; + bool can_vert = Actor_check_collision(self, level, &checked_dir); + checked_dir = dirs[1]; + bool can_horiz = Actor_check_collision(self, level, &checked_dir); + if (can_horiz && !can_vert) { + self->move_decision = dirs[1]; + } else if (!can_horiz && can_vert) { + self->move_decision = dirs[0]; + } else { + bonked = !can_horiz; + if (bonked) { + // If both dirs are blocked, always prefer horizontal movement + self->move_decision = dirs[1]; + } else { + // Use vert if it's the current direction, horiz otherwise (this is + // what's called a Steam slap) + if (dirs[0] == self->direction) { + self->move_decision = dirs[0]; + } else { + self->move_decision = dirs[1]; + } + } + } + } + self->custom_data &= ~PLAYER_HAS_OVERRIDE; + self->custom_data |= + bonked && self->sliding_state == SLIDING_WEAK ? PLAYER_HAS_OVERRIDE : 0; + + // Weird quirk: If you're on a force floor, you aren't visually bonking + Cell* cell = Level_get_cell(level, self->position); + bool is_on_ff = has_flag(&cell->terrain, ACTOR_FLAGS_FORCE_FLOOR) && + !has_item_counter(self->inventory, ITEM_INDEX_FORCE_BOOTS); + self->custom_data |= + (bonked && !is_on_ff) || self->pushing ? PLAYER_IS_VISUALLY_BONKING : 0; + if (!was_bonking && bonked) { + Level_add_sfx(level, SFX_PLAYER_BONK); + } + } +#undef release_input +} + +Direction Player_get_last_decision(Actor* self) { + if (self->move_decision == DIRECTION_NONE) { + // We haven't decided yet, so return whatever direction we last had if we + // are moving or were trying to move + return Actor_is_moving(self) || + (self->custom_data & PLAYER_IS_VISUALLY_BONKING) || + self->sliding_state + ? self->direction + : DIRECTION_NONE; + } + return self->move_decision; +} + +bool Actor_pickup_item(Actor* self, Level* level, BasicTile* item) { + if (!TileType_can_be_dropped(&self->inventory.item4, level, self, + item->type->layer)) + return false; + const TileType* dropped_item = Inventory_shift_right(&self->inventory); + self->inventory.item1 = item->type; + Inventory_increment_counter(&self->inventory, item->type->item_index); + BasicTile_erase(item); + if (dropped_item != NULL) { + Actor_place_item_on_tile(self, level, dropped_item); + } + if (has_flag(self, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_ITEM_PICKUP); + } + return true; +} + +void Actor_place_item_on_tile(Actor* self, + Level* level, + const TileType* item_type) { + Inventory_decrement_counter(&self->inventory, item_type->item_index); + Cell* cell = Level_get_cell(level, self->position); + BasicTile* item_layer = Cell_get_layer(cell, item_type->layer); + // No item despawns here! + assert(item_layer->type == NULL || item_layer->type == &FLOOR_tile); + if (item_type == &BOWLING_BALL_tile) { + // We already emitted a rolling bowling ball, so don't duplicate the item + return; + } + // Dropping an item while despawned crashes the game + if (cell->actor != self) { + Level_add_glitch(level, + (Glitch){.glitch_kind = GLITCH_TYPE_DROP_BY_DESPAWNED, + .location = self->position}); + } + item_layer->type = item_type; +} + +bool Actor_drop_item(Actor* self, Level* level) { + const TileType** item_ptr = Inventory_get_rightmost_item(&self->inventory); + if (item_ptr == NULL || *item_ptr == NULL || + !TileType_can_be_dropped(item_ptr, level, self, -1)) + return false; + const TileType* item = *item_ptr; + *item_ptr = NULL; + Actor_place_item_on_tile(self, level, item); + return true; +} + +bool TileType_can_be_dropped(const TileType** self, + Level* level, + Actor* dropper, + int8_t layer_to_ignore) { + if (*self == NULL) + return true; + Cell* cell = Level_get_cell(level, dropper->position); + const TileType* occupant_type = Cell_get_layer(cell, (*self)->layer)->type; + if ((*self)->layer != layer_to_ignore && occupant_type != NULL && + occupant_type != &FLOOR_tile) + return false; + // Yes, we have to place the rolling bowling ball *now*, while verifying if + // the item can be dropped. If we have four items (last is bowling ball), and + // we try to pick up a fifth item, but emitting the bowling ball fails (due + // to immediate collision on its way out from the dropper's cell), we *don't + // pick the item up, even though the failed bowling ball deploy still removes + // the bowling ball item from the dropper's inventory and thus gives room for + // the new item*. + if ((*self) == &BOWLING_BALL_tile) { + // Temporarily despawn the player to move out the bowling ball actor + Cell_remove_actor(cell, level, dropper); + Actor* bowling_ball = Actor_new(level, &BOWLING_BALL_ROLLING_actor, + dropper->position, dropper->direction); + // HACK: The JustStartedRolling Bit™ (for black buttons, see + // BUTTON_BLACK_actor_left) + bowling_ball->custom_data |= 1; + bool moved = Actor_move_to(bowling_ball, level, bowling_ball->direction); + bowling_ball->custom_data &= ~1; + if (!moved) { + // Use ourselves up, even if failed + Inventory_decrement_counter(&dropper->inventory, (*self)->item_index); + *self = NULL; + // We failed to move. Immediately erase the explosion anim so the dropper + // doesn't get despawned by it later + // TODO: This doesn't play an explosion SFX, so I guess we'll have to hack + // around that, even though a bowling ball colliding usually should make + // an explosion sound. ughh + Actor_erase(bowling_ball, level); + Cell_place_actor(cell, level, dropper); + return false; + } + Cell_place_actor(cell, level, dropper); + } + return true; +} + +PositionF Actor_get_visual_position(const Actor* self) { + PositionF pos = {.x = self->position.x, .y = self->position.y}; + if (self->move_progress == 0) + return pos; + float offset = 1. - (float)self->move_progress / (float)self->move_length; + if (self->direction == DIRECTION_UP) + pos.y += offset; + if (self->direction == DIRECTION_RIGHT) + pos.x -= offset; + if (self->direction == DIRECTION_DOWN) + pos.y -= offset; + if (self->direction == DIRECTION_LEFT) + pos.x += offset; + return pos; +} + +Actor* Level_find_closest_player(Level* self, Position from) { + if (compiler_expect_prob(self->player_seats.length == 1, true, .99)) { + return self->player_seats.items[0].actor; + } + Actor* player = NULL; + float best_dist = 0; + for_vector(PlayerSeat*, seat, &self->player_seats) { + if (!seat->actor) + continue; + PositionF pos = Actor_get_visual_position(seat->actor); + // Taxicab distance, not Euclidean + float distance = fabsf(pos.x - from.x) + fabsf(pos.y - from.y); + if (!player || distance <= best_dist) { + player = seat->actor; + best_dist = distance; + } + } + return player; +} + +_libnotcc_accessors_Glitch; +uint16_t Glitch_get_location_xy(const Glitch* self) { + return self->location.y * 0x100 + self->location.x; +} + +DEFINE_VECTOR(Glitch); + +void Level_add_glitch(Level* self, Glitch glitch) { + glitch.happens_at = self->current_tick * 3 + self->current_subtick; + Vector_Glitch_push(&self->glitches, glitch); + if (Glitch_is_crashing(&glitch)) { + self->game_state = GAMESTATE_CRASH; + } +} + +bool Glitch_is_crashing(const Glitch* self) { + return self->glitch_kind == GLITCH_TYPE_DROP_BY_DESPAWNED || + self->glitch_kind == GLITCH_TYPE_BLUE_TELEPORT_INFINITE_LOOP; +} + +Vector_Glitch* Level_get_glitches_ptr(Level* self) { + return &self->glitches; +} + +void Level_add_sfx(Level* self, uint64_t sfx) { + self->sfx |= sfx; +} diff --git a/libnotcc/src/logic.h b/libnotcc/src/logic.h new file mode 100644 index 00000000..1472b58b --- /dev/null +++ b/libnotcc/src/logic.h @@ -0,0 +1,86 @@ +#ifndef _libnotcc_logic_h +#include "../include/logic.h" +#include "accessors/struct.h" + +#define right(dir) ((dir) % 4 + 1) +#define back(dir) (((dir) + 1) % 4 + 1) +#define left(dir) (((dir) + 2) % 4 + 1) +#define turn_of(from, to) \ + (right((from)) == (to) ? 1 \ + : left((from)) == (to) ? 3 \ + : back((from)) == (to) ? 2 \ + : 0) +#define right_n(dir, n) (((dir) + (n) - 1) % 4 + 1) +#define dir_from_cc2(dir) ((dir) + 1) +#define dir_to_cc2(dir) ((dir) - 1) +#define mirror_vert(dir) \ + ((dir) == DIRECTION_LEFT ? DIRECTION_RIGHT \ + : (dir) == DIRECTION_RIGHT ? DIRECTION_LEFT \ + : (dir)) +#define has_flag(actor, flag) \ + ((actor) && (actor)->type && ((actor)->type->flags & (flag))) + +typedef struct Inventory { + _libnotcc_accessors_Inventory +} Inventory; + +#define has_item_generic(inv, itype) \ + (inv.item1 == itype || inv.item2 == itype || inv.item3 == itype || \ + inv.item4 == itype) +#define has_item_counter(inv, index) (inv.counters.val[index - 1] > 0) + +typedef struct Actor { + _libnotcc_accessors_Actor +} Actor; + +typedef struct BasicTile { + _libnotcc_accessors_BasicTile +} BasicTile; + +typedef struct PlayerSeat { + _libnotcc_accessors_PlayerSeat +} PlayerSeat; + +typedef struct LevelMetadata { + _libnotcc_accessors_LevelMetadata +} LevelMetadata; + +typedef struct LastPlayerInfo { + _libnotcc_accessors_LastPlayerInfo +} LastPlayerInfo; + +enum { + POWERED_WIRE_UP = 0x1, + POWERED_WIRE_RIGHT = 0x2, + POWERED_WIRE_DOWN = 0x4, + POWERED_WIRE_LEFT = 0x8, + POWERED_WIRE_ANY = 0xf, + POWERED_WIRE_WAS_POWERED = 0x10, +}; + +typedef struct Cell { + BasicTile special; + Actor* actor; + BasicTile item_mod; + BasicTile item; + BasicTile terrain; + uint8_t powered_wires : 4; + uint8_t _intratick_powering_wires : 4; + bool _intratick_last_wire_tick_parity : 1; + bool was_powered : 1; + bool is_wired : 1; +} Cell; + +typedef struct Level { + Cell* map; + _libnotcc_accessors_Level +} Level; + +typedef struct Replay { + _libnotcc_accessors_Replay +} Replay; + +typedef struct Glitch { + _libnotcc_accessors_Glitch +} Glitch; +#endif diff --git a/libnotcc/src/main-cli.c b/libnotcc/src/main-cli.c new file mode 100644 index 00000000..b4ab5883 --- /dev/null +++ b/libnotcc/src/main-cli.c @@ -0,0 +1,759 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "c2m.h" +#include "logic.h" +#include "stdbool.h" + +typedef enum Outcome { + OUTCOME_SUCCESS = 0, + OUTCOME_BADINPUT = 1, + OUTCOME_NOINPUT = 2, + OUTCOME_ERROR = 3 +} Outcome; + +const char* outcome_names[] = {"success", "badInput", "noInput", "error"}; +#define RED_ESCAPE "\x1b[31m" +#define GREEN_ESCAPE "\x1b[92m" +#define CYAN_ESCAPE "\x1b[36m" +#define RESET_ESCAPE "\x1b[0m" +#define CLEAR_LINES_ESCAPE(n) "\x1b[" #n "F\x1b[2K" + +typedef struct SyncfileEntry { + char* level_name; + Outcome expected_outcome; + Vector_Glitch expected_glitches; +} SyncfileEntry; + +typedef struct Syncfile { + SyncfileEntry default_entry; + SyncfileEntry* entries; + size_t entries_len; +} Syncfile; + +const SyncfileEntry* Syncfile_get_entry(const Syncfile* self, + const char* level_name) { + for (size_t idx = 0; idx < self->entries_len; idx += 1) { + if (!strcmp(level_name, self->entries[idx].level_name)) + return &self->entries[idx]; + } + return &self->default_entry; +} + +void Syncfile_free(Syncfile* self) { + free(self->default_entry.level_name); + for (size_t idx = 0; idx < self->entries_len; idx += 1) { + free(self->entries[idx].level_name); + Vector_Glitch_uninit(&self->entries[idx].expected_glitches); + } + free(self->entries); + free(self); +} + +char* ranged_str_copy(const char* start, const char* end) { + char* str = xmalloc(end - start + 1); + memcpy(str, start, end - start); + str[end - start] = 0; + return str; +} + +int match_outcome_str(const char* str) { + if (!strcmp(str, "success")) + return OUTCOME_SUCCESS; + if (!strcmp(str, "badInput")) + return OUTCOME_BADINPUT; + if (!strcmp(str, "noInput")) + return OUTCOME_NOINPUT; + if (!strcmp(str, "error")) + return OUTCOME_ERROR; + return -1; +} + +typedef Syncfile* SyncfilePtr; +DEFINE_RESULT(SyncfilePtr); + +DEFINE_RESULT(Glitch); + +typedef struct GlitchKindNameEnt { + const char* key; + GlitchKind val; +} GlitchKindNameEnt; + +static const GlitchKindNameEnt glitch_kind_names[] = { + {"DESPAWN", GLITCH_TYPE_DESPAWN}, + {"SIMULTANEOUS_CHARACTER_MOVEMENT", + GLITCH_TYPE_SIMULTANEOUS_CHARACTER_MOVEMENT}, + {"DYNAMITE_EXPLOSION_SNEAKING", GLITCH_TYPE_DYNAMITE_EXPLOSION_SNEAKING}, + {"DROP_BY_DESPAWNED", GLITCH_TYPE_DROP_BY_DESPAWNED}, + {"BLUE_TELEPORT_INFINITE_LOOP", GLITCH_TYPE_BLUE_TELEPORT_INFINITE_LOOP}}; + +Result_Glitch parse_glitch_str(const char* str) { + Result_Glitch res; + Glitch glitch = {}; + int x; + int y; + char glitch_kind_str[51]; + char glitch_specifier_str[51]; + int scanf_ret = sscanf(str, " (%d, %d) %50s %50s", &x, &y, glitch_kind_str, + glitch_specifier_str); + if (scanf_ret < 3) + res_throws("Failed to read glitch string"); + glitch.location = (Position){x, y}; + for (size_t idx = 0; idx < lengthof(glitch_kind_names); idx += 1) { + GlitchKindNameEnt ent = glitch_kind_names[idx]; + if (!strcmp(glitch_kind_str, ent.key)) { + glitch.glitch_kind = ent.val; + break; + } + } + if (glitch.glitch_kind == GLITCH_TYPE_INVALID) + res_throwf("Unknown glitch type \"%s\"", glitch_kind_str); + if (glitch.glitch_kind == GLITCH_TYPE_DESPAWN) { + if (scanf_ret != 4) + res_throws("Despawn glitches need a specifier"); + if (!strcmp(glitch_specifier_str, "replace")) + glitch.specifier = GLITCH_SPECIFIER_DESPAWN_REPLACE; + else if (!strcmp(glitch_specifier_str, "delete")) + glitch.specifier = GLITCH_SPECIFIER_DESPAWN_REMOVE; + else + res_throws("Invalid despawn specifier"); + } + + res_return(glitch); +} + +Result_SyncfilePtr Syncfile_parse(const char* str) { + // A somewhat hacky ini parser. Should be fine? + Syncfile* sync = xmalloc(sizeof(Syncfile)); + const char* str_start = str; +#define str_pos (size_t)(str - str_start) + *sync = (Syncfile){ + .default_entry = (SyncfileEntry){.level_name = NULL, + .expected_outcome = OUTCOME_SUCCESS}, + .entries = NULL, + .entries_len = 0}; + Result_SyncfilePtr res; +#define skip_str_while(expr, fail_on_null) \ + while ((expr) && *str != '\0') \ + str += 1; \ + if (*str == '\0') { \ + if (fail_on_null) { \ + Syncfile_free(sync); \ + res_throws("Unexpected end of file"); \ + } else { \ + res_return(sync); \ + } \ + } + const char* capture_start; + char* capture_res; + // ""inline"" function to extract a string between the cursor and first + // character to not match `expr` +#define capture_str_while(expr) \ + capture_start = str; \ + skip_str_while(expr, true); \ + capture_res = ranged_str_copy(capture_start, str); + + SyncfileEntry* current_entry = NULL; + while (*str != '\0') { + if (*str == '[') { + // Header + str += 1; + capture_str_while(*str != ']'); + if (!strcmp(capture_res, "_default")) { + current_entry = &sync->default_entry; + free(capture_res); + } else { + sync->entries_len += 1; + sync->entries = + xrealloc(sync->entries, sync->entries_len * sizeof(SyncfileEntry)); + current_entry = &sync->entries[sync->entries_len - 1]; + *current_entry = (SyncfileEntry){ + .level_name = capture_res, + .expected_outcome = OUTCOME_SUCCESS, + }; + } + skip_str_while(*str != '\n', false); + } else if (isalnum(*str)) { + // Key-val + capture_str_while(isalnum(*str)); + char* key = capture_res; + bool is_array = false; + if (str[0] == '[' && str[1] == ']') { + is_array = true; + str += 2; + } + skip_str_while(isblank(*str), true); + if (*str != '=') { + Syncfile_free(sync); + res_throwf("char %zd: Expected = after key", str_pos); + } + str += 1; + skip_str_while(isblank(*str), true); + // NOTE: This will throw with "Unexpected end of file" if there's a null + // here, but it *should* be fine. Don't wanna copy `capture_str_while` + // with no null check here (or add an arg to it), so whatever + capture_str_while(*str != '\n'); + char* val = capture_res; + if (!strcmp(key, "outcome")) { + free(key); + int outcome = match_outcome_str(val); + if (outcome == -1) { + Syncfile_free(sync); + char* err = stringf("Invalid outcome string \"%s\"", val); + free(val); + res_throwr(err); + } + free(val); + current_entry->expected_outcome = outcome; + } else if (!strcmp(key, "glitches")) { + free(key); + Result_Glitch glitch_res = parse_glitch_str(val); + if (!glitch_res.success) { + Syncfile_free(sync); + char* err = stringf("Invalid glitch string \"%s\" - %s", val, + glitch_res.error); + free(val); + free(glitch_res.error); + res_throwr(err); + } + Vector_Glitch_push(¤t_entry->expected_glitches, glitch_res.value); + free(val); + } else { + free(val); + char* err = stringf("Unexpected key \"%s\"", key); + free(key); + Syncfile_free(sync); + res_throwr(err); + } + + } else if (*str == '#') { + // Comment + skip_str_while(*str != '\n', false); + } else { + str += 1; + } + } + res_return(sync); +#undef skip_str_while +#undef capture_str_while +} + +typedef struct Buffer { + void* data; + size_t length; +} Buffer; +DEFINE_RESULT(Buffer); + +Result_Buffer Buffer_read_file(const char* path) { + Result_Buffer res; + FILE* file = fopen(path, "rb"); + if (!file) { + res_throwe("Failed to open %s", path); + } + fseek(file, 0, SEEK_END); + long file_len = ftell(file); + fseek(file, 0, SEEK_SET); + void* buf = xmalloc(file_len); + fread(buf, 1, file_len, file); + fclose(file); + res.value = (Buffer){.data = buf, .length = file_len}; + res_return(res.value); +} + +void Level_verify(Level* self) { + PlayerSeat* seat = &self->player_seats.items[0]; + Replay* replay = self->builtin_replay; + self->rng_blob = replay->rng_blob; + self->rff_direction = replay->rff_direction; + if (!replay) + return; + uint16_t bonus_ticks = 60 * 20; + Level_tick(self); + Level_tick(self); + while (self->game_state == GAMESTATE_PLAYING && bonus_ticks > 0) { + if (self->current_tick >= replay->inputs.length) { + bonus_ticks -= 1; + seat->inputs = replay->inputs.items[replay->inputs.length - 1]; + } else { + seat->inputs = replay->inputs.items[self->current_tick]; + } + Level_tick(self); + Level_tick(self); + Level_tick(self); + } +} + +Result_SyncfilePtr get_syncfile(const char* path) { + Syncfile* syncfile; + Result_SyncfilePtr res; + if (path) { + Result_Buffer res_buf = Buffer_read_file(path); + if (!res_buf.success) { + res_throwr(res_buf.error); + }; + char* str_buf = xmalloc(res_buf.value.length + 1); + memcpy(str_buf, res_buf.value.data, res_buf.value.length); + str_buf[res_buf.value.length] = 0; + free(res_buf.value.data); + + res = Syncfile_parse(str_buf); + free(str_buf); + return res; + } else { + // A basic default syncfile + syncfile = xmalloc(sizeof(Syncfile)); + syncfile->default_entry = (SyncfileEntry){ + .level_name = NULL, .expected_outcome = OUTCOME_SUCCESS}; + syncfile->entries = NULL; + syncfile->entries_len = 0; + res_return(syncfile); + } +} + +typedef struct FileList { + size_t files_n; + char** files; +} FileList; +DEFINE_RESULT(FileList); + +void FileList_push(FileList* self, char* file) { + self->files_n += 1; + self->files = xrealloc(self->files, self->files_n * sizeof(FileList)); + self->files[self->files_n - 1] = file; +} +void FileList_append(FileList* self, FileList* other) { + for (size_t idx = 0; idx < other->files_n; idx += 1) { + FileList_push(self, other->files[idx]); + } +} +void FileList_free(FileList* self) { + for (size_t idx = 0; idx < self->files_n; idx += 1) { + free(self->files[idx]); + } + free(self->files); +} + +char* join_path(const char* a, const char* b) { + size_t a_len = strlen(a); + size_t b_len = strlen(b); + size_t name_len = a_len + 1 + b_len + 1; + char* ab = xmalloc(name_len); + memcpy(ab, a, a_len); + ab[a_len] = '/'; + memcpy(ab + a_len + 1, b, b_len + 1); + return ab; +} + +Result_FileList expand_file_list(FileList input) { + Result_FileList res; +#define list res.value + list.files = NULL; + list.files_n = 0; + + struct stat stat_res; + for (size_t idx = 0; idx < input.files_n; idx += 1) { + const char* file = input.files[idx]; + int err = stat(file, &stat_res); + if (err) { + FileList_free(&list); + res_throwe("Failed to stat %s", file); + } + if (S_ISREG(stat_res.st_mode)) { + size_t file_len = strlen(file); + if (!strcasecmp(&file[file_len - 4], ".c2m")) { + FileList_push(&list, strdupz(file)); + } + } else if (S_ISDIR(stat_res.st_mode)) { + DIR* dir = opendir(file); + struct dirent* ent; + while ((ent = readdir(dir)) != NULL) { + if (!strcmp(ent->d_name, ".") || !strcmp(ent->d_name, "..")) + continue; + char* name = join_path(file, ent->d_name); + Result_FileList res2 = + expand_file_list((FileList){.files = &name, .files_n = 1}); + free(name); + if (!res2.success) { + closedir(dir); + FileList_free(&list); + res_throwr(res2.error); + } + FileList_append(&list, &res2.value); + free(res2.value.files); + } + closedir(dir); + } else { + FileList_free(&list); + res_throws("Encountered weird file type"); + } + } + res_return(list); +#undef list +}; + +typedef struct OutcomeReport { + char* title; + Outcome outcome; + char* error_desc; + Vector_Glitch glitches; +} OutcomeReport; + +typedef struct ThreadGlobals { + FileList* file_list; + mtx_t* levels_left_mtx; + size_t* levels_left; + OutcomeReport* outcome_report; + bool* outcome_report_set; + mtx_t* outcome_report_mtx; + cnd_t* outcome_report_nonempty_cnd; + cnd_t* outcome_report_nonfull_cnd; +} ThreadGlobals; + +OutcomeReport verify_level(const char* file_path) { + OutcomeReport report = {.error_desc = NULL, .title = NULL}; + Result_Buffer res1 = Buffer_read_file(file_path); + if (!res1.success) { + report.outcome = OUTCOME_ERROR; + report.error_desc = res1.error; + return report; + } + Result_LevelPtr res2 = parse_c2m(res1.value.data, res1.value.length); + if (!res2.success) { + // Try to parse metadata-only for the level name + Result_LevelMetadataPtr res3 = + parse_c2m_meta(res1.value.data, res1.value.length); + free(res1.value.data); + if (res3.success) { + report.title = strdupz(res3.value->title); + LevelMetadata_uninit(res3.value); + free(res3.value); + } else { + // Use the original error + free(res3.error); + } + report.outcome = OUTCOME_ERROR; + report.error_desc = res2.error; + return report; + } + free(res1.value.data); + Level* level = res2.value; + report.title = strdupz(level->metadata.title); + if (!level->builtin_replay) { + report.outcome = OUTCOME_ERROR; + report.error_desc = strdupz("No built-in replay"); + Level_uninit(level); + free(level); + return report; + } + Level_verify(level); + report.glitches = Vector_Glitch_clone(&level->glitches); + if (level->game_state == GAMESTATE_WON) { + report.outcome = OUTCOME_SUCCESS; + } else if (level->game_state == GAMESTATE_PLAYING) { + report.outcome = OUTCOME_NOINPUT; + } else { + report.outcome = OUTCOME_BADINPUT; + } + Level_uninit(level); + free(level); + return report; +} + +bool glitch_vectors_equal(const Vector_Glitch* restrict left, + const Vector_Glitch* restrict right) { + if (left->length != right->length) + return false; + for (size_t idx = 0; idx < left->length; idx += 1) { + Glitch* restrict left_g = &left->items[idx]; + Glitch* restrict right_g = &right->items[idx]; + if (left_g->glitch_kind != right_g->glitch_kind) + return false; + // Ignore `happens_at`, since it isn't specified in syncfiles + if (left_g->location.x != right_g->location.x || + left_g->location.y != right_g->location.y) + return false; + if (left_g->specifier != right_g->specifier) + return false; + } + return true; +} + +int level_thread(void* globals_v) { + ThreadGlobals* globals = globals_v; + while (true) { + // Acquire next level file to verify + mtx_lock(globals->levels_left_mtx); + if (*globals->levels_left == 0) { + mtx_unlock(globals->levels_left_mtx); + break; + } + size_t file_idx = *globals->levels_left - 1; + *globals->levels_left -= 1; + char* level_file = globals->file_list->files[file_idx]; + globals->file_list->files[file_idx] = NULL; + mtx_unlock(globals->levels_left_mtx); + // printf("%s working\n", level_file); + OutcomeReport report = verify_level(level_file); + // printf("%s done\n", level_file); + free(level_file); + // Submit the result + mtx_lock(globals->outcome_report_mtx); + while (*globals->outcome_report_set) { + cnd_wait(globals->outcome_report_nonfull_cnd, + globals->outcome_report_mtx); + } + assert(*globals->outcome_report_set == false); + *globals->outcome_report = report; + *globals->outcome_report_set = true; + cnd_signal(globals->outcome_report_nonempty_cnd); + mtx_unlock(globals->outcome_report_mtx); + } + return 0; +} + +#define MAX_THREADS_N 16 + +const char* const help_message = + "notcc-cli - verify Chip's Challenge 2 level solutions\n" + "USAGE: notcc-cli [-vh] [-s syncfile.sync] [-j ] [files or " + "dirs ...]\n\n" + "Given list of files and directories is recursively expanded and filtered " + "for files ending in the C2M extension. By default, the built-in level " + "replays are verified.\n\n" + "A syncfile, if supplied, specifies the expected outcome for each " + "solution. See the NotCC syncfiles directory for examples. By default, all " + "levels are expected to succeed with no non-legal glitches.\n"; + +void complain_about_wrong_outcome(const OutcomeReport* report, + Outcome expected_outcome) { + char* outcome_str; + if (report->outcome == OUTCOME_ERROR) { + assert(report->error_desc != NULL); + outcome_str = + stringf("%s (%s)", outcome_names[report->outcome], report->error_desc); + free(report->error_desc); + } else { + assert(report->error_desc == NULL); + // We don't ever modify this if `report.outcome != OUTCOME_ERROR`, so + // discarding `const` is fine here + outcome_str = (char*)outcome_names[report->outcome]; + } + printf(RED_ESCAPE "%s - expected outcome %s, got %s\n" RESET_ESCAPE, + report->title, outcome_names[expected_outcome], outcome_str); + if (report->outcome == OUTCOME_ERROR) { + free(outcome_str); + } +} + +char* make_glitch_str(const Vector_Glitch* glitches) { + char* str = strdup("["); + for_vector(Glitch*, glitch, glitches) { + const char* glitch_name = NULL; + + for (size_t idx = 0; idx < lengthof(glitch_kind_names); idx += 1) { + GlitchKindNameEnt ent = glitch_kind_names[idx]; + if (glitch->glitch_kind == ent.val) { + glitch_name = ent.key; + break; + } + } + if (glitch_name == NULL) + glitch_name = "UNKNOWN_GLITCH_TYPE"; + + const char* glitch_specifier = ""; + if (glitch->glitch_kind == GLITCH_TYPE_DESPAWN) { + if (glitch->specifier == GLITCH_SPECIFIER_DESPAWN_REPLACE) + glitch_specifier = " replace"; + else if (glitch->specifier == GLITCH_SPECIFIER_DESPAWN_REMOVE) + glitch_specifier = " delete"; + } + + char* glitch_str = + stringf("(%d, %d) %s%s, ", glitch->location.x, glitch->location.y, + glitch_name, glitch_specifier); + str = realloc(str, strlen(str) + strlen(glitch_str) + 1); + strcat(str, glitch_str); + free(glitch_str); + } + str = realloc(str, strlen(str) + 2); + strcat(str, "]"); + return str; +} +void complain_about_wrong_glitches(const OutcomeReport* report, + const Vector_Glitch* expected_glitches) { + char* expected_glitches_str = make_glitch_str(expected_glitches); + char* got_glitches_str = make_glitch_str(&report->glitches); + printf(RED_ESCAPE "%s - expected glitches %s, got %s\n" RESET_ESCAPE, + report->title, expected_glitches_str, got_glitches_str); + free(expected_glitches_str); + free(got_glitches_str); +} + +int main(int argc, char* argv[]) { + // argc = 2; + // argv = (char*[]){"", "/home/glander/wired tp recorded.c2m"}; +#define error_and_exit(msg_alloc, msg) \ + do { \ + fprintf(stderr, "%s\n", msg); \ + if (msg_alloc) \ + free(msg); \ + return 1; \ + } while (false); + int opt; + bool verbose = false; + char* syncfile_path = NULL; + size_t max_threads = MAX_THREADS_N; + while ((opt = getopt(argc, argv, "vhs:j:")) != -1) { + switch (opt) { + case 'h': + fputs(help_message, stdout); + free(syncfile_path); + return 0; + case 'v': + verbose = true; + break; + case 's': + syncfile_path = strdupz(optarg); + break; + case 'j': + max_threads = atol(optarg); + break; + default: + free(syncfile_path); + return 1; + break; + } + } + if (optind >= argc) { + free(syncfile_path); + fputs(help_message, stderr); + error_and_exit(false, "Must supply at least one positional argument"); + } + // Get filelist + size_t files_buf_size = (argc - optind) * sizeof(char*); + char** files = xmalloc(files_buf_size); + memcpy(files, &argv[optind], files_buf_size); + FileList initial_list = {.files = files, .files_n = (argc - optind)}; + Result_FileList res1 = expand_file_list(initial_list); + free(files); + if (!res1.success) { + free(syncfile_path); + error_and_exit(true, res1.error); + } + FileList list = res1.value; + if (list.files_n == 0) { + free(syncfile_path); + FileList_free(&list); + error_and_exit(false, "No C2M files supplied"); + } + // for (size_t idx = 0; idx < list.files_n; idx += 1) { + // puts(list.files[idx]); + // } + + // Get syncfile + Result_SyncfilePtr res2 = get_syncfile(syncfile_path); + free(syncfile_path); + if (!res2.success) { + FileList_free(&list); + error_and_exit(true, res2.error); + } + Syncfile* syncfile = res2.value; + size_t levels_left = list.files_n; + mtx_t levels_left_mtx; + mtx_init(&levels_left_mtx, mtx_plain); + mtx_t outcome_report_mtx; + mtx_init(&outcome_report_mtx, mtx_plain); + cnd_t outcome_report_nonempty_cnd; + cnd_init(&outcome_report_nonempty_cnd); + cnd_t outcome_report_nonfull_cnd; + cnd_init(&outcome_report_nonfull_cnd); + OutcomeReport shared_report; + bool shared_report_set = false; + ThreadGlobals globals = { + .file_list = &list, + .levels_left = &levels_left, + .levels_left_mtx = &levels_left_mtx, + .outcome_report = &shared_report, + .outcome_report_set = &shared_report_set, + .outcome_report_mtx = &outcome_report_mtx, + .outcome_report_nonempty_cnd = &outcome_report_nonempty_cnd, + .outcome_report_nonfull_cnd = &outcome_report_nonfull_cnd}; + size_t thread_count = list.files_n > max_threads ? max_threads : list.files_n; + thrd_t* threads = xmalloc(sizeof(thrd_t) * thread_count); + memset(threads, 0, sizeof(thrd_t) * thread_count); + for (size_t idx = 0; idx < thread_count; idx += 1) { + thrd_create(&threads[idx], level_thread, &globals); + } + size_t levels_passed = 0; + size_t levels_failed = 0; + size_t levels_verified_total = 0; +#define print_stats() \ + printf(GREEN_ESCAPE "Pass: %zd (%.1f%%)\n" RESET_ESCAPE, levels_passed, \ + levels_passed * 100. / levels_verified_total); \ + printf(RED_ESCAPE "Fail: %zd (%.1f%%)\n" RESET_ESCAPE, levels_failed, \ + levels_failed * 100. / levels_verified_total); \ + printf(CYAN_ESCAPE "Verified: %zd\n" RESET_ESCAPE, levels_verified_total); + + while (true) { + mtx_lock(&outcome_report_mtx); + while (!shared_report_set) { + cnd_wait(&outcome_report_nonempty_cnd, &outcome_report_mtx); + } + // Copy the report so that it can be analyzed without locking the report + // mutex for the whole check + OutcomeReport report = shared_report; + shared_report_set = false; + cnd_signal(&outcome_report_nonfull_cnd); + mtx_unlock(&outcome_report_mtx); + levels_verified_total += 1; + if (report.outcome == OUTCOME_ERROR && report.title == NULL) { + assert(report.error_desc != NULL); + printf(RED_ESCAPE "Reading error: %s\n" RESET_ESCAPE, report.error_desc); + free(report.error_desc); + levels_failed += 1; + } else { + const SyncfileEntry* entry = Syncfile_get_entry(syncfile, report.title); + bool glitches_equal = + glitch_vectors_equal(&report.glitches, &entry->expected_glitches); + if (entry->expected_outcome != report.outcome || !glitches_equal) { + levels_failed += 1; + if (entry->expected_outcome != report.outcome) { + complain_about_wrong_outcome(&report, entry->expected_outcome); + } + if (!glitches_equal) { + complain_about_wrong_glitches(&report, &entry->expected_glitches); + } + } else { + if (verbose) { + printf(GREEN_ESCAPE "%s - %s\n" RESET_ESCAPE, report.title, + outcome_names[entry->expected_outcome]); + } + levels_passed += 1; + } + free(report.title); + Vector_Glitch_uninit(&report.glitches); + } + print_stats(); + printf(CLEAR_LINES_ESCAPE(3)); + if (levels_verified_total == list.files_n) { + break; + } + } + for (size_t idx = 0; idx < thread_count; idx += 1) { + thrd_join(threads[idx], NULL); + } + free(threads); + print_stats(); + + Syncfile_free(syncfile); + FileList_free(&list); + return levels_failed == 0 ? 0 : 1; +} diff --git a/libnotcc/src/misc.c b/libnotcc/src/misc.c new file mode 100644 index 00000000..1795f85c --- /dev/null +++ b/libnotcc/src/misc.c @@ -0,0 +1,141 @@ +#include "misc.h" +#include +#include +#include +// `sprintf` but just measures and returns a string by itself +char* stringf(const char* msg, ...) { + va_list list1; + va_list list2; + va_start(list1, msg); + va_copy(list2, list1); + int string_size = vsnprintf(NULL, 0, msg, list1) + 1; + va_end(list1); + char* formatted_msg = xmalloc(string_size); + vsnprintf(formatted_msg, string_size, msg, list2); + va_end(list2); + return formatted_msg; +}; +// Same as `strdup`, but handles NULL +char* strdupz(const char* str) { + if (str == NULL) + return NULL; + size_t len = strlen(str); + char* new_str = xmalloc(len + 1); + memcpy(new_str, str, len + 1); + return new_str; +} +// Same as `strndup`, but handles NULL +char* strndupz(const char* str, size_t max_size) { + if (str == NULL) + return NULL; + size_t len = strnlen(str, max_size); + char* new_str = xmalloc(len + 1); + memcpy(new_str, str, len); + new_str[len] = 0; + return new_str; +} + +// `strdupz` but interprets the input as Latin-1 and outputs UTF-8 +char* strdupz_latin1_to_utf8(char const* str) { + if (str == NULL) + return NULL; + size_t new_len = 0; + // UTF-8 uses 1 byte to encode codepoints <=127 and 2 for >127 + for (char const* cur_char = str; *cur_char != '\0'; cur_char += 1) { + new_len += (unsigned char)*cur_char > 127 ? 2 : 1; + } + char* new_str = xmalloc(new_len + 1); + size_t new_idx = 0; + for (char const* cur_char = str; *cur_char != '\0'; cur_char += 1) { + unsigned char the_char = (unsigned char)*cur_char; + if (the_char > 127) { + // UTF-8 2-byte encoding: 110abcde 10fghiij + new_str[new_idx] = 0b11000000 | (the_char >> 6); + new_str[new_idx + 1] = 0b10000000 | (the_char & 0b00111111); + new_idx += 2; + } else { + // UTF-8 1-byte encoding: 0abcdefg + new_str[new_idx] = the_char; + new_idx += 1; + } + } + new_str[new_idx] = '\0'; + return new_str; +} + +// `malloc`, but aborts on failure +void* xmalloc(size_t size) { + void* ptr = malloc(size); + if (ptr == NULL && size != 0) { + abort(); + } + return ptr; +} +// `realloc`, but aborts on failure and handles zero size correctly +void* xrealloc(void* old_ptr, size_t size) { + if (size == 0) { + if (old_ptr != NULL) { + free(old_ptr); + } + return NULL; + } + void* ptr = realloc(old_ptr, size); + if (ptr == NULL) { + abort(); + } + return ptr; +} + +// Vector_any Vector_any_init(size_t capacity, size_t item_size) { +// void* items = xmalloc(capacity * item_size); +// return (Vector_any){.capacity = capacity, .length = 0, .items = items}; +// } +// void Vector_any_uninit(Vector_any* self) { +// free(self->items); +// } +size_t Vector_any_get_length(const Vector_any* self) { + return self->length; +} +void* Vector_any_get_ptr(const Vector_any* self, size_t item_size, size_t idx) { + return &self->items[idx * item_size]; +}; +// void Vector_any_shrink_to_fit(Vector_any* self, size_t item_size) { +// self->capacity = self->length; +// self->items = xrealloc(self->items, item_size * self->capacity); +// }; +// void Vector_any_sort(Vector_any* self, +// size_t item_size, +// int (*comp)(const void*, const void*)) { +// qsort(self->items, self->length, item_size, comp); +// }; +// void* Vector_any_search(const Vector_any* self, +// size_t item_size, +// bool (*match)(void*, const void*), +// void* ctx) { +// for (size_t idx = 0; idx < self->length; idx += 1) { +// void* item = self->items + idx * item_size; +// if (match(ctx, item)) +// return item; +// }; +// return NULL; +// }; +// void* Vector_any_binary_search(const Vector_any* self, +// size_t item_size, +// int8_t (*comp)(void*, const void*), +// void* ctx) { +// size_t left_idx = 0; +// size_t right_idx = self->length; +// while (left_idx != right_idx) { +// size_t item_idx = (left_idx + right_idx) / 2; +// void* item = self->items + item_idx * item_size; +// int8_t comp_res = comp(ctx, item); +// if (comp_res == 0) +// return item; +// if (comp_res < 0) { +// left_idx = item_idx + 1; +// } else { +// right_idx = item_idx; +// } +// } +// return NULL; +// }; diff --git a/libnotcc/src/misc.h b/libnotcc/src/misc.h new file mode 100644 index 00000000..e380c6ad --- /dev/null +++ b/libnotcc/src/misc.h @@ -0,0 +1,132 @@ +#include +#include +#include "../include/misc.h" + +#if defined(__has_attribute) && __has_attribute(__malloc__) +#define attr_malloc __attribute__((__malloc__)) +#else +#define attr_malloc +#endif +#if defined(__has_attribute) && __has_attribute(__alloc_size__) +#define attr_alloc_size(params) __attribute__((__alloc_size__ params)) +#else +#define attr_alloc_size +#endif + +#if defined(__has_builtin) && \ + __has_builtin(__builtin_expect_with_probability) && \ + __has_builtin(__builtin_expect) +#define compiler_expect_prob(a, b, prob) \ + __builtin_expect_with_probability(a, b, prob) +#define compiler_expect(a, b) __builtin_expect(a, b) +#else +#define compiler_expect_prob(a, b, prob) (a) +#define compiler_expect(a, b) (a) +#endif + +#define lengthof(arr) (sizeof(arr) / sizeof(arr[0])) + +char* strdupz(const char* str) attr_malloc; +char* strndupz(const char* str, size_t max_size) attr_malloc; + +char* strdupz_latin1_to_utf8(const char* str) attr_malloc; + +void* xmalloc(size_t size) attr_malloc attr_alloc_size((1)); +void* xrealloc(void* old_ptr, size_t size) attr_alloc_size((2)); + +#define DEFINE_VECTOR(T) DEFINE_VECTOR_W_MOD(T, ) +#define DEFINE_VECTOR_STATIC(T) \ + DECLARE_VECTOR_W_MOD(T, static); \ + DEFINE_VECTOR_W_MOD(T, static); + +#define DEFINE_VECTOR_W_MOD(T, MOD) \ + size_t _libnotcc_bind_##T##_size() { \ + return sizeof(T); \ + }; \ + MOD Vector_##T Vector_##T##_init(size_t init_capacity) { \ + T* items = xmalloc(init_capacity * sizeof(T)); \ + return (Vector_##T){ \ + .length = 0, .capacity = init_capacity, .items = items}; \ + }; \ + MOD void Vector_##T##_uninit(Vector_##T* self) { \ + free(self->items); \ + }; \ + MOD Vector_##T Vector_##T##_clone(const Vector_##T* self) { \ + Vector_##T new_self = {.length = self->length, \ + .capacity = self->capacity}; \ + new_self.items = xmalloc(new_self.capacity * sizeof(T)); \ + if (self->items != NULL) { \ + memcpy(new_self.items, self->items, new_self.capacity * sizeof(T)); \ + } \ + return new_self; \ + }; \ + MOD void Vector_##T##_push(Vector_##T* self, T item) { \ + if (self->length == self->capacity) { \ + size_t new_capacity = self->capacity == 0 ? 3 : self->capacity * 2; \ + self->items = xrealloc(self->items, sizeof(T) * new_capacity); \ + self->capacity = new_capacity; \ + } \ + self->items[self->length] = item; \ + self->length += 1; \ + }; \ + MOD T Vector_##T##_pop(Vector_##T* self) { \ + if (self->length == 0) \ + abort(); \ + T item = self->items[self->length - 1]; \ + self->length -= 1; \ + return item; \ + }; \ + MOD T* Vector_##T##_get_ptr(const Vector_##T* self, size_t idx) { \ + if (idx >= self->length) \ + abort(); \ + return &self->items[idx]; \ + }; \ + MOD T Vector_##T##_get(const Vector_##T* self, size_t idx) { \ + return *Vector_##T##_get_ptr(self, idx); \ + }; \ + MOD void Vector_##T##_set(Vector_##T* self, size_t idx, T item) { \ + if (idx >= self->length) \ + abort(); \ + self->items[idx] = item; \ + }; \ + MOD void Vector_##T##_shrink_to_fit(Vector_##T* self) { \ + self->capacity = self->length; \ + self->items = xrealloc(self->items, sizeof(T) * self->capacity); \ + }; \ + MOD void Vector_##T##_sort(Vector_##T* self, \ + int (*comp)(const void*, const void*)) { \ + if (self->length < 2 || self->items == NULL) \ + return; \ + qsort(self->items, self->length, sizeof(T), comp); \ + }; \ + MOD T* Vector_##T##_search(const Vector_##T* self, \ + bool (*match)(void*, const T*), void* ctx) { \ + for_vector(T*, item, self) { \ + if (match(ctx, item)) \ + return item; \ + }; \ + return NULL; \ + }; \ + MOD T* Vector_##T##_binary_search( \ + const Vector_##T* self, int8_t (*comp)(void*, const T*), void* ctx) { \ + if (self->items == NULL) \ + return NULL; \ + size_t left_idx = 0; \ + size_t right_idx = self->length; \ + while (left_idx != right_idx) { \ + size_t item_idx = (left_idx + right_idx) / 2; \ + int8_t comp_res = comp(ctx, &self->items[item_idx]); \ + if (comp_res == 0) \ + return &self->items[item_idx]; \ + if (comp_res < 0) { \ + left_idx = item_idx + 1; \ + } else { \ + right_idx = item_idx; \ + } \ + } \ + return NULL; \ + }; + +#define for_vector(type_ptr, var, vec) \ + for (type_ptr var = (vec)->items == NULL ? NULL : &(vec)->items[0]; \ + var != NULL && var - &(vec)->items[0] < (vec)->length; var += 1) diff --git a/libnotcc/src/tiles.c b/libnotcc/src/tiles.c new file mode 100644 index 00000000..366ca38c --- /dev/null +++ b/libnotcc/src/tiles.c @@ -0,0 +1,3048 @@ +#include "tiles.h" +#include +#include +#include +#include "logic.h" +#include "misc.h" + +// Terrain +// + +int8_t compare_wire_membs_in_reading_order_unconst( + void* ctx, + const WireNetworkMember* val) { + return compare_wire_membs_in_reading_order(ctx, val); +} + +static WireNetwork* tile_find_network(Level* level, + Position pos, + uint8_t wires, + WireNetworkMember** out_memb) { + for_vector(WireNetwork*, network, &level->wire_networks) { + WireNetworkMember* found_memb = Vector_WireNetworkMember_binary_search( + &network->members, compare_wire_membs_in_reading_order_unconst, + (void*)&pos); + if (!found_memb) + continue; + if (!(found_memb->wires & wires)) + continue; + if (out_memb) { + *out_memb = found_memb; + } + return network; + } + return NULL; +} + +#define is_ghost(actor) has_flag(actor, ACTOR_FLAGS_GHOST) +#define get_dir_bit(dir) (1 << dir_to_cc2(dir)) + +static bool impedes_non_ghost(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + return !is_ghost(actor); +} + +static void FLOOR_on_idle(BasicTile* self, Level* level, Actor* actor) { + if (compiler_expect_prob(!self->custom_data, true, .99)) + return; + if (compiler_expect_prob( + !has_item_counter(actor->inventory, ITEM_INDEX_LIGHTNING_BOLT), true, + .99)) + return; + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + if (!cell->is_wired) + return; + Position pos = Level_pos_from_cell(level, cell); + if ((self->custom_data & 0xf) == 0xf) { + WireNetwork* network1 = tile_find_network(level, pos, 0b0101, NULL); + WireNetwork* network2 = tile_find_network(level, pos, 0b1010, NULL); + // `network1 == network2` may be true if both wires are connected at another + // tile + assert(network1 != NULL); + assert(network2 != NULL); + network1->force_power_this_subtick = true; + network2->force_power_this_subtick = true; + } else { + WireNetwork* network = tile_find_network(level, pos, 0xf, NULL); + assert(network != NULL); + network->force_power_this_subtick = true; + } +} + +// FLOOR: `custom_data` indicates wires +const TileType FLOOR_tile = {.name = "floor", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_CROSS, + .on_idle = FLOOR_on_idle}; + +static void WALL_on_bumped_by(BasicTile* self, + Level* level, + Actor* other, + Direction _dir) { + if (has_item_counter(other->inventory, ITEM_INDEX_STEEL_FOIL)) { + BasicTile_transform_into(self, &STEEL_WALL_tile); + } +} + +const TileType WALL_tile = {.name = "wall", + .layer = LAYER_TERRAIN, + .impedes = impedes_non_ghost, + .on_bumped_by = WALL_on_bumped_by}; + +// STEEL_WALL: `custom_data` indicates wires +const TileType STEEL_WALL_tile = {.name = "steelWall", + .layer = LAYER_TERRAIN, + .flags = ACTOR_FLAGS_DYNAMITE_IMMUNE, + .impedes_mask = ~0, + .wire_type = WIRES_CROSS}; + +static uint8_t ice_modify_move_duration(BasicTile* self, + Level* level, + Actor* actor, + uint8_t move_duration) { + if (is_ghost(actor) || has_flag(actor, ACTOR_FLAGS_MELINDA)) + return move_duration; + if (has_item_counter(actor->inventory, ITEM_INDEX_ICE_BOOTS)) + return move_duration; + return move_duration / 2; +} + +static uint8_t force_modify_move_duration(BasicTile* self, + Level* level, + Actor* actor, + uint8_t move_duration) { + if (is_ghost(actor)) + return move_duration; + if (has_item_counter(actor->inventory, ITEM_INDEX_FORCE_BOOTS)) + return move_duration; + return move_duration / 2; +} + +static void ice_on_join(BasicTile* self, + Level* level, + Actor* other, + Direction _direction) { + if (!is_ghost(other) && + (has_item_counter(other->inventory, ITEM_INDEX_ICE_BOOTS) || + has_flag(other, ACTOR_FLAGS_MELINDA))) + return; + other->sliding_state = SLIDING_STRONG; +} + +static void force_on_join(BasicTile* self, + Level* level, + Actor* other, + Direction _direction) { + if (is_ghost(other)) + return; + if (has_item_counter(other->inventory, ITEM_INDEX_FORCE_BOOTS)) + return; + other->sliding_state = SLIDING_WEAK; + // Play the sfx if we're *an* active player + PlayerSeat* seat = Level_find_player_seat(level, other); + if (seat) { + Level_add_sfx(level, SFX_FORCE_FLOOR_SLIDE); + } +} + +static void ice_on_complete_join(BasicTile* self, Level* level, Actor* other) { + if (is_ghost(other)) + return; + // Cancel the slidingstate if the actor has an ice boot now + if (has_item_counter(other->inventory, ITEM_INDEX_ICE_BOOTS) || + has_flag(other, ACTOR_FLAGS_MELINDA)) { + other->sliding_state = SLIDING_NONE; + if (has_flag(other, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_SLIDE_STEP); + } + } else { + // If we're a real player, start the sliding SFX and give the player the ice + // tag so that the SFX continues until the actor is on a non-ice tile + if (has_flag(other, ACTOR_FLAGS_REAL_PLAYER)) { + Level_add_sfx(level, SFX_ICE_SLIDE); + other->custom_data |= PLAYER_WAS_ON_ICE; + } + } +} + +static void force_on_complete_join(BasicTile* self, + Level* level, + Actor* other) { + // Cancel the slidingstate if the actor has an ice boot now + if (has_item_counter(other->inventory, ITEM_INDEX_FORCE_BOOTS)) { + other->sliding_state = SLIDING_NONE; + if (has_flag(other, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_SLIDE_STEP); + } + } +} + +static void ICE_on_idle(BasicTile* self, Level* level, Actor* actor) { + if (has_item_counter(actor->inventory, ITEM_INDEX_ICE_BOOTS)) + return; + if (!actor->bonked) + return; + if (has_flag(actor, ACTOR_FLAGS_MELINDA)) { + Actor_enter_tile(actor, level); + return; + } + actor->sliding_state = SLIDING_STRONG; + actor->direction = back(actor->direction); + Actor_move_to(actor, level, actor->direction); +} + +// ICE_CORNER: `custom_data` indicates direction of the corner +static void ICE_CORNER_on_idle(BasicTile* self, Level* level, Actor* actor) { + if (has_item_counter(actor->inventory, ITEM_INDEX_ICE_BOOTS)) + return; + if (has_flag(actor, ACTOR_FLAGS_MELINDA)) { + if (actor->bonked) + Actor_enter_tile(actor, level); + return; + } + if (is_ghost(actor)) { + ICE_on_idle(self, level, actor); + return; + } + if (actor->bonked) { + actor->sliding_state = SLIDING_STRONG; + actor->direction = back(actor->direction); + } + // I don't know how this works. sorry + actor->direction = + right_n(self->custom_data, 7 + self->custom_data - actor->direction); + if (actor->bonked) { + Actor_move_to(actor, level, actor->direction); + } +} +// NOTE: `direction` means no impede, `DIRECTION_NONE` means impede, so take +// note that ICE_CORNER_redirect_exit is the opposite of ICE_CORNER_impedes +static Direction ICE_CORNER_redirect_exit(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (is_ghost(actor)) + return direction; + return direction == self->custom_data || direction == right(self->custom_data) + ? DIRECTION_NONE + : direction; +} +static bool ICE_CORNER_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (is_ghost(actor)) + return false; + return direction == back(self->custom_data) || + direction == left(self->custom_data); +} + +const TileType ICE_tile = {.name = "ice", + .layer = LAYER_TERRAIN, + .flags = ACTOR_FLAGS_ICE, + .actor_joined = ice_on_join, + .on_idle = ICE_on_idle, + .modify_move_duration = ice_modify_move_duration, + .actor_completely_joined = ice_on_complete_join}; + +const TileType ICE_CORNER_tile = { + + .name = "iceCorner", + .layer = LAYER_TERRAIN, + .flags = ACTOR_FLAGS_ICE, + .actor_joined = ice_on_join, + .on_idle = ICE_CORNER_on_idle, + .modify_move_duration = ice_modify_move_duration, + .actor_completely_joined = ice_on_complete_join, + .redirect_exit = ICE_CORNER_redirect_exit, + .impedes = ICE_CORNER_impedes}; + +// FORCE_FLOOR: `custom-data` indicated FF direction +static void force_on_idle(BasicTile* self, + Level* level, + Actor* actor, + Direction (*get_dir)(BasicTile* self, Level* level)) { + if (Actor_is_gone(actor)) + return; + if (actor->bonked) { + Actor_enter_tile(actor, level); + } + if (is_ghost(actor)) + return; + if (has_item_counter(actor->inventory, ITEM_INDEX_FORCE_BOOTS)) + return; + actor->sliding_state = SLIDING_WEAK; + actor->direction = get_dir(self, level); + if (actor->bonked) { + Actor_move_to(actor, level, actor->direction); + } +} + +// Is this dumb? Would copying the same code twice be any better? +static Direction force_floor_idle_dir(BasicTile* self, Level* level) { + return (Direction)self->custom_data; +} +static Direction force_floor_random_idle_dir(BasicTile* self, Level* level) { + Direction dir = level->rff_direction; + level->rff_direction = right(level->rff_direction); + return dir; +} + +static void FORCE_FLOOR_on_idle(BasicTile* self, Level* level, Actor* actor) { + force_on_idle(self, level, actor, force_floor_idle_dir); +} + +static void FORCE_FLOOR_RANDOM_on_idle(BasicTile* self, + Level* level, + Actor* actor) { + force_on_idle(self, level, actor, force_floor_random_idle_dir); +} + +static void FORCE_FLOOR_on_wire_high(BasicTile* self, + Level* level, + bool _real) { + self->custom_data = back(self->custom_data); +} + +const TileType FORCE_FLOOR_tile = { + .name = "forceFloor", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .flags = ACTOR_FLAGS_FORCE_FLOOR, + .on_wire_high = FORCE_FLOOR_on_wire_high, + .on_idle = FORCE_FLOOR_on_idle, + .actor_joined = force_on_join, + .actor_completely_joined = force_on_complete_join, + .modify_move_duration = force_modify_move_duration}; + +const TileType FORCE_FLOOR_RANDOM_tile = { + .name = "forceFloorRandom", + .layer = LAYER_TERRAIN, + .flags = ACTOR_FLAGS_FORCE_FLOOR, + .on_idle = FORCE_FLOOR_RANDOM_on_idle, + .actor_joined = force_on_join, + .actor_completely_joined = force_on_complete_join, + .modify_move_duration = force_modify_move_duration}; + +static void WATER_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (actor->type == &GLIDER_actor || + has_item_counter(actor->inventory, ITEM_INDEX_WATER_BOOTS)) { + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_WATER_STEP); + } + return; + } + if (is_ghost(actor)) + return; + if (actor->type == &DIRT_BLOCK_actor) { + BasicTile_transform_into(self, &DIRT_tile); + } + if (actor->type == &ICE_BLOCK_actor) { + BasicTile_transform_into(self, &ICE_tile); + } + if (actor->type == &FRAME_BLOCK_actor) { + BasicTile_transform_into(self, &FLOOR_tile); + } + Actor_destroy(actor, level, &SPLASH_actor); +} + +static bool WATER_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + return is_ghost(actor) && + !has_item_counter(actor->inventory, ITEM_INDEX_WATER_BOOTS); +} + +const TileType WATER_tile = { + .name = "water", + .layer = LAYER_TERRAIN, + .impedes = WATER_impedes, + .actor_completely_joined = WATER_actor_completely_joined}; + +static void FIRE_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (is_ghost(actor)) { + if (has_item_counter(actor->inventory, ITEM_INDEX_FIRE_BOOTS)) { + BasicTile_erase(self); + } + return; + } + if (actor->type == &DIRT_BLOCK_actor || actor->type == &FIREBALL_actor) + return; + if (has_item_counter(actor->inventory, ITEM_INDEX_FIRE_BOOTS)) { + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_FIRE_STEP); + } + return; + }; + if (actor->type == &ICE_BLOCK_actor) { + BasicTile_transform_into(self, &WATER_tile); + Actor_destroy(actor, level, &EXPLOSION_actor); + return; + } + Actor_destroy(actor, level, &EXPLOSION_actor); +} + +const TileType FIRE_tile = { + + .name = "fire", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_AVOIDS_FIRE, + .actor_completely_joined = FIRE_actor_completely_joined, +}; + +// FLAME_JET: lowest bit of `custom_data` indicates if the jet is on + +static void flip_state_on_wire_change(BasicTile* self, + Level* level, + bool _real) { + bool was_on = self->custom_data & 1; + self->custom_data &= ~1; + self->custom_data |= was_on ? 0 : 1; +} + +static void FLAME_JET_actor_idle(BasicTile* self, Level* level, Actor* actor) { + if (has_item_counter(actor->inventory, ITEM_INDEX_FIRE_BOOTS) || + actor->type == &FIREBALL_actor || actor->type == &DIRT_BLOCK_actor) + return; + if (!(self->custom_data & 1)) + return; + Actor_destroy(actor, level, &EXPLOSION_actor); +} +const TileType FLAME_JET_tile = {.name = "flameJet", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .on_wire_high = flip_state_on_wire_change, + .on_idle = FLAME_JET_actor_idle}; + +// TOGGLE_WALL: `custom_data` indicates if this is a wall +static bool TOGGLE_WALL_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + if (is_ghost(actor)) + return false; + return (bool)self->custom_data != level->toggle_wall_inverted; +} +const TileType TOGGLE_WALL_tile = {.name = "toggleWall", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .on_wire_high = flip_state_on_wire_change, + .impedes = TOGGLE_WALL_impedes}; + +static bool HOLD_WALL_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + if (is_ghost(actor)) + return false; + return self->custom_data; +} + +const TileType HOLD_WALL_tile = {.name = "holdWall", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .on_wire_high = flip_state_on_wire_change, + .on_wire_low = flip_state_on_wire_change, + .impedes = HOLD_WALL_impedes}; + +static bool search_for_type(void* type_void, Level* level, Cell* cell) { + TileType* type = type_void; + return Cell_get_layer(cell, type->layer)->type == type; +} + +// What's really annoying about the teleports is that they have very similar +// code, but are different enough to be annoying to generalize teleporting to a +// single function, aagh + +static void weak_teleport_actor_joined(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + actor->sliding_state = SLIDING_WEAK; +} + +enum { TP_ACTOR_JUST_JOINED = 0x100 }; + +static void teleport_set_just_joined(BasicTile* self, + Level* level, + Actor* actor) { + self->custom_data |= TP_ACTOR_JUST_JOINED; + Level_add_sfx(level, SFX_TELEPORT); +} + +static void teleport_red_do_teleport(BasicTile* self, + Level* level, + Actor* actor) { + Cell* this_cell = Level_get_cell(level, actor->position); + actor->sliding_state = SLIDING_WEAK; + if ((self->custom_data & 0xf) && this_cell->is_wired && + !this_cell->powered_wires) + return; + Cell* init_cell = Level_get_cell(level, actor->position); + Cell* next_cell = init_cell; + Cell* last_cell = next_cell; + while (true) { + next_cell = Level_search_reading_order( + level, next_cell, false, search_for_type, (void*)&TELEPORT_RED_tile); + // No other teleports (left) in the level, nothing to do here + if (next_cell == NULL) + return; + // We're back where we started, give up + // NOTE: Red teleports fail before trying all directions on themselves, + // green teleports try all dirs on themselves before failing + if (next_cell == init_cell) + break; + // Ignore teleports which are already busy with an actor on top of them + if (next_cell->actor != NULL) + continue; + // Unpowered red TP + if (next_cell->is_wired && !next_cell->powered_wires && + (next_cell->terrain.custom_data & 0xf)) + continue; + last_cell->actor = NULL; + last_cell = next_cell; + // Move the actor to the next potential tile + next_cell->actor = actor; + actor->position = Level_pos_from_cell(level, next_cell); + for (uint8_t dir_offset = 0; dir_offset < 4; dir_offset += 1) { + Direction dir = right_n(actor->direction, dir_offset); + if (Actor_check_collision(actor, level, &dir)) { + actor->direction = dir; + return; + } + } + } + last_cell->actor = NULL; + next_cell->actor = actor; + actor->position = Level_pos_from_cell(level, next_cell); +} + +static void teleport_actor_idle(BasicTile* self, + Level* level, + Actor* actor, + void (*teleport_actor_fn)(BasicTile* self, + Level* level, + Actor* actor)) { + if (self->custom_data & TP_ACTOR_JUST_JOINED) { + self->custom_data &= ~TP_ACTOR_JUST_JOINED; + teleport_actor_fn(self, level, actor); + return; + } + if (!actor->bonked) + return; + actor->sliding_state = SLIDING_NONE; + if (actor->type == &BOWLING_BALL_ROLLING_actor) { + // I don't know + Actor_destroy(actor, level, &EXPLOSION_actor); + } +} +static void TELEPORT_RED_actor_idle(BasicTile* self, + Level* level, + Actor* actor) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + // If this is a player, give them a free override + if (has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) { + actor->custom_data |= PLAYER_HAS_OVERRIDE; + } + if (cell->is_wired && (self->custom_data & 0xf) && !cell->powered_wires) { + // If an actor joined us and we're powered off, don't try to teleport the + // actor when we do get powered on + self->custom_data &= ~TP_ACTOR_JUST_JOINED; + return; + } + teleport_actor_idle(self, level, actor, teleport_red_do_teleport); +} + +const TileType TELEPORT_RED_tile = { + .name = "teleportRed", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_UNCONNECTED, + .actor_joined = weak_teleport_actor_joined, + .actor_completely_joined = teleport_set_just_joined, + .on_idle = TELEPORT_RED_actor_idle}; + +static void strong_teleport_actor_joined(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + actor->sliding_state = SLIDING_STRONG; +} + +static bool blue_tp_is_target_in_an_earlier_network_than_this_pos( + Level* level, + Position this_pos, + Position target_pos) { + for_vector(WireNetwork*, network, &level->wire_networks) { + // If the first member (the earliest one in reading order, since we iterated + // on cells in reading order when tracing, and thus if there could have been + // any cell before us in reading order also part of our network, the network + // would've been traced from that cell and not us) is later in RO than us, + // this network comes completely after us in RO, which we have to skip + if (compare_pos_in_reading_order(&network->members.items[0].pos, + &this_pos) > 0) + return false; + + // By the way, all network members are also sorted in reading order after + // the fact, just to speed this (and other member lookups) up, but the + // first-member-is-first-in-RO-and-earliest-among-all-future-networks + // property holds regardless of us sorting it + WireNetworkMember* found_memb = Vector_WireNetworkMember_binary_search( + &network->members, compare_wire_membs_in_reading_order_unconst, + (void*)&target_pos); + if (found_memb) + return true; + } + // This function should only be called when the cell at `target_pos` is + // `is_wired`, and thus must be an a wire network, all of which we have now + // iterated over + assert(false); + return false; +} + +// WTD: Wired Teleportation Destination (wired blue TPs and logic gates) + +static uint8_t logic_gate_get_output_wire(const BasicTile* self) { + uint8_t wires = self->custom_data & 0xf; + if (wires == 0b1011) + return 0b0001; + if (wires == 0b0111) + return 0b0010; + if (wires == 0b1110) + return 0b0100; + if (wires == 0b1101) + return 0b1000; + if (wires == 0b1111) + return 0b1000; + bool not_gate_bottom_left = self->custom_data & 0b10000; + if (wires == 0b0101) + return not_gate_bottom_left ? 0b0100 : 0b0001; + if (wires == 0b1010) + return not_gate_bottom_left ? 0b1000 : 0b0010; + assert(false); + return 0; +} + +enum { LOGIC_GATE_IS_BUSY = 0x800 }; + +static Actor* logic_gate_find_actor(const BasicTile* self, Level* level) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Position this_pos = Level_pos_from_cell(level, cell); + for (size_t idx = 0; idx < level->actors_allocated_n; idx += 1) { + Actor* actor = level->actors[idx]; + if (actor->position.x != this_pos.x || actor->position.y != this_pos.y) + continue; + if (actor->frozen) + return actor; + } + return NULL; +} + +static bool wtd_is_wtd(const BasicTile* self) { + return self->type == &TELEPORT_BLUE_tile || self->type == &LOGIC_GATE_tile; +} +static bool wtd_is_busy(BasicTile* self, Level* level) { + if (self->type == &TELEPORT_BLUE_tile) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + return cell->actor; + } else if (self->type == &LOGIC_GATE_tile) { + return self->custom_data & LOGIC_GATE_IS_BUSY; + } + // This function is only supposed to be for WTDs + assert(false); + return true; +} +static void wtd_remove_actor(BasicTile* self, Level* level, Actor* actor) { + if (self->type == &TELEPORT_BLUE_tile) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + assert(cell->actor == actor); + cell->actor = NULL; + return; + } else if (self->type == &LOGIC_GATE_tile) { + actor->frozen = false; + self->custom_data &= ~LOGIC_GATE_IS_BUSY; + return; + } + assert(false); +} +static void wtd_add_actor(BasicTile* self, Level* level, Actor* actor) { + if (self->type == &TELEPORT_BLUE_tile) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + assert(cell->actor == NULL); + cell->actor = actor; + actor->position = Level_pos_from_cell(level, cell); + actor->sliding_state = SLIDING_STRONG; + return; + } else if (self->type == &LOGIC_GATE_tile) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + actor->frozen = true; + actor->position = Level_pos_from_cell(level, cell); + self->custom_data |= LOGIC_GATE_IS_BUSY; + return; + } + assert(false); +} + +static bool wtd_can_host(BasicTile* self, Level* level, Actor* actor) { + if (self->type == &TELEPORT_BLUE_tile) { + // FIXME: This collision check ignores pulls in notcc.js, but Pullcrap had a + // desync regarding teleports and pulling actors, so maybe not disabling + // pulling is right? + + // Giving a reference to a real direction doesn't matter, there's not going + // to be a railroad on the cell + return Actor_check_collision(actor, level, &actor->direction); + } else if (self->type == &LOGIC_GATE_tile) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + uint8_t out_wire = logic_gate_get_output_wire(self); + return cell->powered_wires & out_wire; + } + assert(false); + return false; +} + +// For wired blue TPs and logic gates +static void wtd_do_teleport(BasicTile* self, + Level* level, + WireNetwork* tp_network, + WireNetworkMember* memb, + Actor* actor) { + BasicTile* last_wtd = self; + + assert(tp_network != NULL); + assert(memb != NULL); + assert(memb >= &tp_network->members.items[0]); + size_t network_size = tp_network->members.length; + size_t starting_idx = memb - tp_network->members.items; + for (size_t network_memb_offset_idx = 1; + network_memb_offset_idx < network_size; network_memb_offset_idx += 1) { + size_t idx = + (network_size + starting_idx - network_memb_offset_idx) % network_size; + Cell* memb_cell = Level_get_cell(level, tp_network->members.items[idx].pos); + BasicTile* new_wtd = &memb_cell->terrain; + if (!wtd_is_wtd(new_wtd) || wtd_is_busy(new_wtd, level)) + continue; + wtd_remove_actor(last_wtd, level, actor); + last_wtd = new_wtd; + wtd_add_actor(new_wtd, level, actor); + if (wtd_can_host(new_wtd, level, actor)) { + return; + } + } + // We didn't find any valid exit, just go through us again + wtd_remove_actor(last_wtd, level, actor); + wtd_add_actor(self, level, actor); +} + +static void blue_tp_do_unwired_tp(BasicTile* self, Level* level, Actor* actor) { + Cell* this_cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Cell* new_cell = this_cell; + Position this_pos = Level_pos_from_cell(level, new_cell); + Cell* last_cell = new_cell; + Position last_unwired_pos = this_pos; + Position last_unwired_prewrapped_pos; + bool found_wrap_pos = false; + bool crash_if_tp_into_wired = false; + while (true) { + new_cell = Level_search_reading_order( + level, new_cell, true, search_for_type, (void*)&TELEPORT_BLUE_tile); + // No other teleports (left) in the level, nothing to do here + if (new_cell == NULL) + return; + Position new_pos = Level_pos_from_cell(level, new_cell); + if (!found_wrap_pos) { + if (compare_pos_in_reading_order(&new_pos, &last_unwired_pos) > 0) { + last_unwired_prewrapped_pos = last_unwired_pos; + found_wrap_pos = true; + crash_if_tp_into_wired = true; + } + if (!new_cell->is_wired) { + last_unwired_pos = new_pos; + } + } + // We're back where we started, give up + if (new_cell == this_cell) + break; + BasicTile* teleport = &new_cell->terrain; + compiler_expect(teleport->type == &TELEPORT_BLUE_tile, true); + // Ignore teleports which are already busy with an actor on top of them + if (wtd_is_busy(teleport, level)) + continue; + if (new_cell->is_wired) { + if (!crash_if_tp_into_wired) + continue; + crash_if_tp_into_wired = false; + if (blue_tp_is_target_in_an_earlier_network_than_this_pos( + level, last_unwired_prewrapped_pos, new_pos)) { + crash_if_tp_into_wired = true; + continue; + } + // Very dumb behavior in CC2 here, just crash. + Level_add_glitch( + level, + (Glitch){.glitch_kind = GLITCH_TYPE_BLUE_TELEPORT_INFINITE_LOOP, + .location = new_pos}); + return; + } + crash_if_tp_into_wired = false; + // Move the actor to the next potential tile + compiler_expect(last_cell->terrain.type == &TELEPORT_BLUE_tile, true); + wtd_remove_actor(&last_cell->terrain, level, actor); + last_cell = new_cell; + wtd_add_actor(teleport, level, actor); + // If this is a valid exit tile, leave the actor on it + if (wtd_can_host(teleport, level, actor)) + return; + } + // We couldn't find any other place to put the actor, add it back to ourselves + wtd_remove_actor(&last_cell->terrain, level, actor); + wtd_add_actor(self, level, actor); +} + +static void teleport_blue_do_teleport(BasicTile* self, + Level* level, + Actor* actor) { + Cell* this_cell = BasicTile_get_cell(self, LAYER_TERRAIN); + if (this_cell->is_wired) { + Position this_pos = Level_pos_from_cell(level, this_cell); + WireNetworkMember* network_memb = NULL; + // XXX: Maybe do this at prepare-time, or at least cache the network index + // once we look it up? + WireNetwork* network = + tile_find_network(level, this_pos, 0xf, &network_memb); + wtd_do_teleport(self, level, network, network_memb, actor); + } else { + blue_tp_do_unwired_tp(self, level, actor); + } +} + +static void TELEPORT_BLUE_actor_idle(BasicTile* self, + Level* level, + Actor* actor) { + teleport_actor_idle(self, level, actor, teleport_blue_do_teleport); +} + +const TileType TELEPORT_BLUE_tile = { + .name = "teleportBlue", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_EVERYWHERE, + .actor_joined = strong_teleport_actor_joined, + .actor_completely_joined = teleport_set_just_joined, + .on_idle = TELEPORT_BLUE_actor_idle}; + +static void teleport_green_do_teleport(BasicTile* self, + Level* level, + Actor* actor) { + Cell* this_cell = Level_get_cell(level, actor->position); + size_t green_tp_n = 0; + // Count all green TPs I guess? + Cell* next_cell = Level_search_reading_order( + level, this_cell, false, search_for_type, (void*)&TELEPORT_GREEN_tile); + if (next_cell == NULL) { + // This is the only green TP. Don't bother + return; + } + while (next_cell != this_cell) { + green_tp_n += 1; + next_cell = Level_search_reading_order( + level, next_cell, false, search_for_type, (void*)&TELEPORT_GREEN_tile); + } + uint8_t teleport_cells_until_target = Level_rng(level) % green_tp_n; + Direction exit_dir = dir_from_cc2(Level_rng(level) % 4); + this_cell->actor = NULL; + while (true) { + next_cell = Level_search_reading_order( + level, next_cell, false, search_for_type, (void*)&TELEPORT_GREEN_tile); + if (next_cell->actor) + continue; + if (teleport_cells_until_target > 0) { + teleport_cells_until_target -= 1; + continue; + } + next_cell->actor = actor; + actor->position = Level_pos_from_cell(level, next_cell); + if (next_cell == this_cell) { + // We've come back to our original cell, give up + return; + } + for (uint8_t dir_offset = 0; dir_offset < 4; dir_offset += 1) { + Direction dir = right_n(exit_dir, dir_offset); + if (Actor_check_collision(actor, level, &dir)) { + actor->direction = dir; + return; + } + } + next_cell->actor = NULL; + } +} + +static void TELEPORT_GREEN_actor_idle(BasicTile* self, + Level* level, + Actor* actor) { + teleport_actor_idle(self, level, actor, teleport_green_do_teleport); +} + +const TileType TELEPORT_GREEN_tile = { + .name = "teleportGreen", + .layer = LAYER_TERRAIN, + .actor_joined = strong_teleport_actor_joined, + .actor_completely_joined = teleport_set_just_joined, + .on_idle = TELEPORT_GREEN_actor_idle}; + +enum { YELLOW_TP_IS_ONLY_TP_IN_LEVEL = 0x1000 }; + +static void teleport_yellow_init(BasicTile* self, Level* level, Cell* cell) { + Cell* other_tp_cell = Level_search_reading_order( + level, cell, true, search_for_type, (void*)&TELEPORT_YELLOW_tile); + if (!other_tp_cell) { + self->custom_data |= YELLOW_TP_IS_ONLY_TP_IN_LEVEL; + } +} + +static void teleport_yellow_do_teleport(BasicTile* self, + Level* level, + Actor* actor) { + if (has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) { + actor->custom_data |= PLAYER_HAS_OVERRIDE; + } + actor->sliding_state = SLIDING_WEAK; + Cell* this_cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Cell* next_cell = this_cell; + this_cell->actor = NULL; + while (true) { + next_cell = Level_search_reading_order( + level, next_cell, true, search_for_type, (void*)&TELEPORT_YELLOW_tile); + if (next_cell == NULL) + break; + if (next_cell->actor && next_cell != this_cell) + continue; + next_cell->actor = actor; + actor->position = Level_pos_from_cell(level, next_cell); + if (next_cell == this_cell) + break; + if (Actor_check_collision(actor, level, &actor->direction)) { + return; + } + next_cell->actor = NULL; + } + this_cell->actor = actor; + // Don't give us to the player if we're the only yellow teleport in the whole + // level + if ((self->custom_data & YELLOW_TP_IS_ONLY_TP_IN_LEVEL)) + return; + if (has_flag(actor, ACTOR_FLAGS_IGNORES_ITEMS)) + return; + // Can't be picked up if there's a no sign on us + if (this_cell->item_mod.type && + this_cell->item_mod.type->overrides_item_layer(&this_cell->item_mod, + level, self)) + return; + bool picked_up = Actor_pickup_item(actor, level, self); + if (picked_up) { + actor->sliding_state = SLIDING_NONE; + } +} + +static void TELEPORT_YELLOW_actor_idle(BasicTile* self, + Level* level, + Actor* actor) { + teleport_actor_idle(self, level, actor, teleport_yellow_do_teleport); +} + +const TileType TELEPORT_YELLOW_tile = { + .name = "teleportYellow", + .layer = LAYER_TERRAIN, + .flags = ACTOR_FLAGS_ITEM, + .actor_joined = weak_teleport_actor_joined, + .actor_completely_joined = teleport_set_just_joined, + .on_idle = TELEPORT_YELLOW_actor_idle, + .item_index = ITEM_INDEX_YELLOW_TP}; + +static void SLIME_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (is_ghost(actor) || actor->type == &BLOB_actor) + return; + if (actor->type == &DIRT_BLOCK_actor || actor->type == &ICE_BLOCK_actor) { + BasicTile_erase(self); + return; + }; + Actor_destroy(actor, level, &SPLASH_actor); +} +static void SLIME_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + if (actor->type != &BLOB_actor) + return; + Cell* new_cell = Level_get_cell(level, actor->position); + if (new_cell->terrain.type == &FLOOR_tile) { + BasicTile_transform_into(&new_cell->terrain, &SLIME_tile); + } +} +const TileType SLIME_tile = { + .name = "slime", + .layer = LAYER_TERRAIN, + .actor_completely_joined = SLIME_actor_completely_joined, + .actor_left = SLIME_actor_left}; + +static bool filth_block_melinda(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + return has_flag(actor, ACTOR_FLAGS_MELINDA) && + !has_item_counter(actor->inventory, ITEM_INDEX_DIRT_BOOTS); +} + +const TileType GRAVEL_tile = {.name = "gravel", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_AVOIDS_GRAVEL, + .impedes = filth_block_melinda}; +static void DIRT_actor_completely_joined(BasicTile* self, + Level* level, + Actor* other) { + if (is_ghost(other) && + !has_item_counter(other->inventory, ITEM_INDEX_DIRT_BOOTS)) + return; + BasicTile_erase(self); + Level_add_sfx(level, SFX_DIRT_CLEAR); +} + +const TileType DIRT_tile = { + .name = "dirt", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .impedes = filth_block_melinda, + .flags = 0, + .actor_completely_joined = DIRT_actor_completely_joined}; + +// TRAP: rightmost bit of `custom_data` indicates open/closed state, other bits +// are for the open request count + +enum { TRAP_OPENED = 1, TRAP_OPEN_REQUESTS = ~1 }; + +static inline bool is_controlled_by_trap(Actor* actor) { + return !has_flag(actor, ACTOR_FLAGS_GHOST | ACTOR_FLAGS_REAL_PLAYER); +} + +static void trap_increment_opens(BasicTile* self, Level* level, Cell* cell) { + if (self->type != &TRAP_tile) + return; + self->custom_data += 2; + if ((self->custom_data & TRAP_OPENED) == 0) { + self->custom_data |= TRAP_OPENED; + if (cell->actor && !is_ghost(cell->actor)) { + cell->actor->frozen = false; + if (level->current_subtick != -1) { + Actor_move_to(cell->actor, level, cell->actor->direction); + } + } + } +} +static void trap_decrement_opens(BasicTile* self, Level* level, Cell* cell) { + if (self->type != &TRAP_tile) + return; + if ((self->custom_data & TRAP_OPEN_REQUESTS) == 0) + return; + self->custom_data -= 2; + if ((self->custom_data & TRAP_OPEN_REQUESTS) == 0) { + self->custom_data = 0; + if (cell->actor && is_controlled_by_trap(cell->actor)) { + cell->actor->frozen = true; + } + } +} +static void trap_control_actor(BasicTile* self, Level* level, Actor* actor) { + if (!is_controlled_by_trap(actor) || (self->custom_data & TRAP_OPENED)) + return; + actor->frozen = true; +} +static void TRAP_init(BasicTile* self, Level* level, Cell* cell) { + if (cell->actor) { + trap_control_actor(self, level, cell->actor); + } +} +static void TRAP_receive_power(BasicTile* self, Level* level, uint8_t power) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + if (!cell->is_wired) + return; + // NOTE: We intentionally don't respect open requests OR try to eject the + // actor here if we're now open + self->custom_data &= ~TRAP_OPENED; + self->custom_data |= power ? TRAP_OPENED : 0; + if (cell->actor && is_controlled_by_trap(cell->actor)) { + cell->actor->frozen = !power; + } +} + +static Direction TRAP_redirect_exit(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (!(self->custom_data & TRAP_OPENED) && !is_ghost(actor)) + return DIRECTION_NONE; + return direction; +} + +const TileType TRAP_tile = {.name = "trap", + .layer = LAYER_TERRAIN, + .actor_completely_joined = trap_control_actor, + .receive_power = TRAP_receive_power, + .init = TRAP_init, + .redirect_exit = TRAP_redirect_exit, + .wire_type = WIRES_READ}; + +static void clone_machine_control_actor(BasicTile* self, + Level* level, + Actor* actor) { + actor->frozen = true; +} +static void CLONE_MACHINE_init(BasicTile* self, Level* level, Cell* cell) { + if (cell->actor) { + clone_machine_control_actor(self, level, cell->actor); + } +} +static bool clone_machine_try_dir(Actor* actor, Level* level, Direction dir) { + return Actor_check_collision(actor, level, &dir) && + Actor_move_to(actor, level, dir); +} +static void clone_machine_trigger(BasicTile* self, + Level* level, + bool try_all_dirs) { + if (self->type != &CLONE_MACHINE_tile) + return; + Actor* actor = BasicTile_get_cell(self, LAYER_TERRAIN)->actor; + // Someone triggered an empty clone machine + if (actor == NULL) + return; + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Position pos = Level_pos_from_cell(level, cell); + /* if (pos.x == 43 && pos.y == 15 && actor->type == + * &BOWLING_BALL_ROLLING_actor) { */ + /* printf("gi %d %d\n", pos.x, pos.y); */ + /* } */ + if (Actor_is_moving(actor)) + return; + actor->frozen = false; + Direction og_actor_dir = actor->direction; + Actor* new_actor = NULL; + Position this_pos = actor->position; + if (!actor) + return; +#define release_actor() \ + new_actor = Actor_new(level, actor->type, this_pos, actor->direction); + + actor->sliding_state = SLIDING_STRONG; + if (clone_machine_try_dir(actor, level, og_actor_dir)) { + release_actor() + } else if (try_all_dirs) { + if (clone_machine_try_dir(actor, level, right(og_actor_dir))) { + release_actor() + } else if (clone_machine_try_dir(actor, level, back(og_actor_dir))) { + release_actor() + } else if (clone_machine_try_dir(actor, level, left(og_actor_dir))) { + release_actor() + } else { + actor->direction = og_actor_dir; + actor->sliding_state = SLIDING_NONE; + } + } else { + actor->sliding_state = SLIDING_NONE; + } + if (new_actor) { + new_actor->frozen = true; + // XXX: Is this always true? Any other data to be inherited? + new_actor->custom_data = actor->custom_data; + } else { + actor->frozen = true; + } +#undef release_actor +} + +const TileType CLONE_MACHINE_tile = { + + .name = "cloneMachine", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .actor_completely_joined = clone_machine_control_actor, + .init = CLONE_MACHINE_init, + .on_wire_high = clone_machine_trigger, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER | ACTOR_FLAGS_REAL_PLAYER}; + +static void EXIT_actor_completely_joined(BasicTile* self, + Level* level, + Actor* other) { + if (!has_flag(other, ACTOR_FLAGS_REAL_PLAYER)) + return; + level->players_left -= 1; + PlayerSeat* seat = Level_find_player_seat(level, other); + // If this player is selected, switch to a different one. If the player was + // not select (eg. they slid into the win tile) don't try to change the + // current player + if (seat) { + seat->actor = Level_find_next_player(level, other); + } + Actor_erase(other, level); + Level_add_sfx(level, has_flag(other, ACTOR_FLAGS_MELINDA) ? SFX_MELINDA_WIN + : SFX_CHIP_WIN); + + Position this_pos = other->position; + + uint32_t exit_n = 1; + uint32_t exit_tile_offset = Position_to_offset(this_pos, level->width); + + for (uint32_t offset = 0; offset < exit_tile_offset; offset += 1) { + Cell const* cell = &level->map[offset]; + if (cell->terrain.type == self->type) { + exit_n += 1; + } + } + + level->last_won_player_info = + (LastPlayerInfo){.exit_n = exit_n, + .inventory = other->inventory, + .is_male = has_flag(other, ACTOR_FLAGS_CHIP)}; +} +const TileType EXIT_tile = { + .name = "exit", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = EXIT_actor_completely_joined}; + +#define MAKE_DOOR(var_name, capital, simple, reuse_flag) \ + static bool var_name##_impedes(BasicTile* self, Level* level, Actor* other, \ + Direction direction) { \ + if (is_ghost(other)) \ + return false; \ + return other->inventory.keys_##simple == 0; \ + }; \ + static void var_name##_actor_completely_joined(BasicTile* self, \ + Level* level, Actor* other) { \ + if (other->inventory.keys_##simple == 0) \ + return; \ + BasicTile_erase(self); \ + if (!(other->type->flags & reuse_flag) && \ + other->inventory.keys_##simple > 0) { \ + other->inventory.keys_##simple -= 1; \ + } \ + if (has_flag(other, ACTOR_FLAGS_PLAYER)) { \ + Level_add_sfx(level, SFX_DOOR_UNLOCK); \ + } \ + }; \ + const TileType var_name##_tile = { \ + \ + .name = "door" #capital, \ + .layer = LAYER_TERRAIN, \ + .impedes = var_name##_impedes, \ + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, \ + .actor_completely_joined = var_name##_actor_completely_joined}; + +MAKE_DOOR(DOOR_RED, Red, red, 0); +MAKE_DOOR(DOOR_BLUE, Blue, blue, 0); +MAKE_DOOR(DOOR_YELLOW, Yellow, yellow, ACTOR_FLAGS_MELINDA); +MAKE_DOOR(DOOR_GREEN, Green, green, ACTOR_FLAGS_CHIP); + +static void BUTTON_GREEN_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + level->green_button_pressed = !level->green_button_pressed; + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType BUTTON_GREEN_tile = { + .name = "buttonGreen", + .layer = LAYER_TERRAIN, + .actor_completely_joined = BUTTON_GREEN_actor_completely_joined}; + +static void BUTTON_BLUE_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + level->blue_button_pressed = !level->blue_button_pressed; + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType BUTTON_BLUE_tile = { + .name = "buttonBlue", + .layer = LAYER_TERRAIN, + .actor_completely_joined = BUTTON_BLUE_actor_completely_joined}; + +static void BUTTON_YELLOW_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + level->yellow_button_pressed = actor->direction; + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType BUTTON_YELLOW_tile = { + .name = "buttonYellow", + .layer = LAYER_TERRAIN, + .actor_completely_joined = BUTTON_YELLOW_actor_completely_joined}; + +#define NO_CONNECTED_TILE 0xffffffff +static Cell* get_connected_cell(BasicTile* self, Level* level) { + if (self->custom_data == NO_CONNECTED_TILE) + return NULL; + return Level_get_cell(level, + Position_from_offset(self->custom_data, level->width)); +} +static Cell* connect_to_tile_generic(BasicTile* self, + Level* level, + Cell* connected_cell) { + if (!connected_cell) { + self->custom_data = NO_CONNECTED_TILE; + return NULL; + }; + self->custom_data = Position_to_offset( + Level_pos_from_cell(level, connected_cell), level->width); + return connected_cell; +} +static Cell* connect_to_tile_reading_order(BasicTile* self, + Level* level, + Cell* cell, + const TileType* type) { + Cell* connected_cell = Level_search_reading_order( + level, cell, false, search_for_type, (void*)type); + return connect_to_tile_generic(self, level, connected_cell); +} +static Cell* connect_to_tile_taxicab(BasicTile* self, + Level* level, + Cell* cell, + const TileType* type) { + Cell* connected_cell = + Level_search_taxicab(level, cell, search_for_type, (void*)type); + return connect_to_tile_generic(self, level, connected_cell); +} + +// BUTTON_BROWN, BUTTON_RED, BUTTON_ORANGE: `custom_data` is the offset to the +// linked tile +static void BUTTON_BROWN_init(BasicTile* self, Level* level, Cell* cell) { + Cell* trap_cell = + connect_to_tile_reading_order(self, level, cell, &TRAP_tile); + if (trap_cell && cell->actor) { + trap_increment_opens(&trap_cell->terrain, level, trap_cell); + } +} +static void BUTTON_BROWN_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + Cell* trap_cell = get_connected_cell(self, level); + if (trap_cell == NULL) + return; + trap_increment_opens(&trap_cell->terrain, level, trap_cell); + Level_add_sfx(level, SFX_BUTTON_PRESS); +} +static void button_brown_close_trap(BasicTile* self, Level* level) { + Cell* trap_cell = get_connected_cell(self, level); + if (trap_cell == NULL) + return; + trap_decrement_opens(&trap_cell->terrain, level, trap_cell); +} + +static void BUTTON_BROWN_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction _direction) { + button_brown_close_trap(self, level); +} + +const TileType BUTTON_BROWN_tile = { + + .name = "buttonBrown", + .layer = LAYER_TERRAIN, + .init = BUTTON_BROWN_init, + .actor_completely_joined = BUTTON_BROWN_actor_completely_joined, + .actor_left = BUTTON_BROWN_actor_left, + .actor_destroyed = button_brown_close_trap}; + +static void BUTTON_RED_init(BasicTile* self, Level* level, Cell* cell) { + connect_to_tile_reading_order(self, level, cell, &CLONE_MACHINE_tile); +} +static void BUTTON_RED_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + Cell* clone_machine_cell = get_connected_cell(self, level); + if (!clone_machine_cell) + return; + clone_machine_trigger(&clone_machine_cell->terrain, level, false); + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType BUTTON_RED_tile = { + .name = "buttonRed", + .layer = LAYER_TERRAIN, + .init = BUTTON_RED_init, + .actor_completely_joined = BUTTON_RED_actor_completely_joined}; + +static void BUTTON_ORANGE_init(BasicTile* self, Level* level, Cell* cell) { + connect_to_tile_taxicab(self, level, cell, &FLAME_JET_tile); +} +static void button_orange_toggle(BasicTile* self, Level* level) { + Cell* jet_cell = get_connected_cell(self, level); + if (!jet_cell || jet_cell->terrain.type != &FLAME_JET_tile) + return; + jet_cell->terrain.custom_data = !jet_cell->terrain.custom_data; + Level_add_sfx(level, SFX_BUTTON_PRESS); +} +static void BUTTON_ORANGE_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + button_orange_toggle(self, level); +} +static void BUTTON_ORANGE_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + button_orange_toggle(self, level); +} + +const TileType BUTTON_ORANGE_tile = { + .name = "buttonOrange", + .layer = LAYER_TERRAIN, + .init = BUTTON_ORANGE_init, + .actor_completely_joined = BUTTON_ORANGE_actor_completely_joined, + .actor_left = BUTTON_ORANGE_actor_left}; + +enum { BUTTON_PURPLE_SHOULD_POWER = 0b10000 }; + +// BUTTON_PURPLE: lowest four bits indicate wires, 5th bit indicates if there +// was an actor on us this subtick +static void BUTTON_PURPLE_on_idle(BasicTile* self, Level* level, Actor* actor) { + self->custom_data |= BUTTON_PURPLE_SHOULD_POWER; +} +static uint8_t BUTTON_PURPLE_give_power(BasicTile* self, Level* level) { + bool should_power = self->custom_data & BUTTON_PURPLE_SHOULD_POWER; + self->custom_data &= ~BUTTON_PURPLE_SHOULD_POWER; + return should_power ? 0xf : 0; +} +static void BUTTON_PURPLE_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType BUTTON_PURPLE_tile = { + .name = "buttonPurple", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_UNCONNECTED, + .on_idle = BUTTON_PURPLE_on_idle, + .actor_completely_joined = BUTTON_PURPLE_actor_completely_joined, + .give_power = BUTTON_PURPLE_give_power}; + +// BUTTON_BLACK, TOGGLE_SWITCH: Lowest 4 bits indicate wires, 5th indicates if +// there should be pwoer (modulo the actor being destroyed), 6th indicates if +// the 5th bit was set last subtick, which is what power emission actually +// depends on +enum { + POWER_BUTTON_SHOULD_BE_POWERED = 1 << 4, + POWER_BUTTON_WILL_BE_POWERED = 1 << 5 +}; + +static uint8_t power_button_give_power(BasicTile* self, Level* level) { + bool should_power = self->custom_data & POWER_BUTTON_WILL_BE_POWERED; + self->custom_data &= ~POWER_BUTTON_WILL_BE_POWERED; + self->custom_data |= (self->custom_data & POWER_BUTTON_SHOULD_BE_POWERED) + ? POWER_BUTTON_WILL_BE_POWERED + : 0; + return should_power ? 0xf : 0; +} + +static void BUTTON_BLACK_init(BasicTile* self, Level* level, Cell* cell) { + if (cell->actor) { + self->custom_data &= ~POWER_BUTTON_SHOULD_BE_POWERED; + } +} + +static void BUTTON_BLACK_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + self->custom_data &= ~POWER_BUTTON_SHOULD_BE_POWERED; + Level_add_sfx(level, SFX_BUTTON_PRESS); +} +static void BUTTON_BLACK_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + // Yes, we play the sfx even if the button doesn't change + Level_add_sfx(level, SFX_BUTTON_PRESS); + // Hack: If a bowling ball was rolled from this tile, don't become unpressed + // because an actor will be on us again in just a moment + if (actor->type == &BOWLING_BALL_ROLLING_actor && actor->custom_data & 1) + return; + self->custom_data |= POWER_BUTTON_SHOULD_BE_POWERED; +} +const TileType BUTTON_BLACK_tile = { + .name = "buttonBlack", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_ALWAYS_CROSS, + .init = BUTTON_BLACK_init, + .give_power = power_button_give_power, + .actor_completely_joined = BUTTON_BLACK_actor_completely_joined, + .actor_left = BUTTON_BLACK_actor_left}; + +static void TOGGLE_SWITCH_actor_completely_joined(BasicTile* self, + Level* level, + Actor* cell) { + if (self->custom_data & POWER_BUTTON_SHOULD_BE_POWERED) { + self->custom_data &= ~POWER_BUTTON_SHOULD_BE_POWERED; + } else { + self->custom_data |= POWER_BUTTON_SHOULD_BE_POWERED; + } + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType TOGGLE_SWITCH_tile = { + .name = "toggleSwitch", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_UNCONNECTED, + .give_power = power_button_give_power, + .actor_completely_joined = TOGGLE_SWITCH_actor_completely_joined, +}; + +static void BUTTON_GRAY_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + Cell* this_cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Position this_pos = Level_pos_from_cell(level, this_cell); + for (int8_t dy = -2; dy <= 2; dy += 1) { + if (this_pos.y < -dy || this_pos.y >= level->height - dy) + continue; + for (int8_t dx = -2; dx <= 2; dx += 1) { + if (this_pos.x < -dx || this_pos.x >= level->width - dx) + continue; + Position pos = {this_pos.x + dx, this_pos.y + dy}; + Cell* cell = Level_get_cell(level, pos); + if (cell->terrain.type->on_wire_high) { + cell->terrain.type->on_wire_high(&cell->terrain, level, false); + } + } + } + Level_add_sfx(level, SFX_BUTTON_PRESS); +} + +const TileType BUTTON_GRAY_tile = { + .name = "buttonGray", + .layer = LAYER_TERRAIN, + .actor_completely_joined = BUTTON_GRAY_actor_completely_joined}; + +static bool ECHIP_GATE_impedes(BasicTile* self, + Level* level, + Actor* other, + Direction direction) { + if (is_ghost(other)) + return false; + return level->chips_left > 0; +} + +static void ECHIP_GATE_actor_completely_joined(BasicTile* self, + Level* level, + Actor* other) { + if (level->chips_left > 0) + return; + BasicTile_erase(self); + Level_add_sfx(level, SFX_SOCKET_UNLOCK); +} + +const TileType ECHIP_GATE_tile = { + + .name = "echipGate", + .layer = LAYER_TERRAIN, + .impedes = ECHIP_GATE_impedes, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = ECHIP_GATE_actor_completely_joined, + .flags = ACTOR_FLAGS_DYNAMITE_IMMUNE}; + +// HINT: `custom_data` is the hint index + +static char* hint_get_hint(BasicTile* self, Level* level) { + // If there is no hint for this hint tile, show the default hint + if (self->custom_data >= level->metadata.hints.length) { + return level->metadata.default_hint; + } + return level->metadata.hints.items[self->custom_data]; +} + +static void HINT_actor_idle(BasicTile* self, Level* level, Actor* actor) { + if (!has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) + return; + PlayerSeat* seat = Level_find_player_seat(level, actor); + if (!seat) + return; + seat->displayed_hint = hint_get_hint(self, level); +} + +const TileType HINT_tile = {.name = "hint", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .on_idle = HINT_actor_idle}; + +static void POPUP_WALL_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (is_ghost(actor)) + return; + BasicTile_transform_into(self, &WALL_tile); + Level_add_sfx(level, SFX_RECESSED_WALL); +} + +const TileType POPUP_WALL_tile = {.name = "popupWall", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_left = POPUP_WALL_actor_left}; + +static void APPEARING_WALL_on_bumped_by(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (has_flag(actor, ACTOR_FLAGS_REVEALS_HIDDEN)) { + BasicTile_transform_into(self, &WALL_tile); + } + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_PLAYER_BONK); + } +} +const TileType APPEARING_WALL_tile = { + .name = "appearingWall", + .layer = LAYER_TERRAIN, + .impedes = impedes_non_ghost, + .on_bumped_by = APPEARING_WALL_on_bumped_by}; + +// INVISIBLE_WALL: `custom_data` specifies until which tick to display the +// revealed wall sprite + +static void INVISIBLE_WALL_on_bumped_by(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (has_flag(actor, ACTOR_FLAGS_REVEALS_HIDDEN)) { + self->custom_data = + level->current_tick * 3 + level->current_subtick + 1 + 12; + } +} +const TileType INVISIBLE_WALL_tile = { + .name = "invisibleWall", + .layer = LAYER_TERRAIN, + .impedes = impedes_non_ghost, + .on_bumped_by = INVISIBLE_WALL_on_bumped_by}; + +static void BLUE_WALL_on_bumped_by(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (has_flag(actor, ACTOR_FLAGS_REVEALS_HIDDEN)) { + BasicTile_transform_into( + self, self->custom_data & BLUE_WALL_REAL ? &WALL_tile : &FLOOR_tile); + } + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_PLAYER_BONK); + } +} + +const TileType BLUE_WALL_tile = {.name = "blueWall", + .layer = LAYER_TERRAIN, + .impedes = impedes_non_ghost, + .on_bumped_by = BLUE_WALL_on_bumped_by}; + +static bool GREEN_WALL_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + if (is_ghost(actor)) + return false; + return self->custom_data || has_flag(actor, ACTOR_FLAGS_BLOCK); +} + +const TileType GREEN_WALL_tile = {.name = "greenWall", + .layer = LAYER_TERRAIN, + .impedes = GREEN_WALL_impedes}; + +static bool thief_has_bribe(Actor* actor) { + if (!has_item_counter(actor->inventory, ITEM_INDEX_BRIBE)) + return false; + for (uint8_t idx = 0; idx < 4; idx += 1) { + const TileType** item_type = + Inventory_get_item_by_idx(&actor->inventory, idx); + if (*item_type != &BRIBE_tile) + continue; + Inventory_remove_item(&actor->inventory, idx); + Inventory_decrement_counter(&actor->inventory, ITEM_INDEX_BRIBE); + return true; + } + // Possible only when using the shadow inventory glitch + return true; +} + +static void THIEF_TOOL_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (thief_has_bribe(actor)) + return; + if (!has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) + return; + Level_add_sfx(level, SFX_THIEF); + actor->inventory.item1 = NULL; + actor->inventory.item2 = NULL; + actor->inventory.item3 = NULL; + actor->inventory.item4 = NULL; + actor->inventory.counters = (Uint8_16){}; + level->bonus_points /= 2; +} +const TileType THIEF_TOOL_tile = { + .name = "thiefTool", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = THIEF_TOOL_actor_completely_joined}; + +static void THIEF_KEY_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (thief_has_bribe(actor)) + return; + if (!has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) + return; + Level_add_sfx(level, SFX_THIEF); + actor->inventory.keys_red = 0; + actor->inventory.keys_green = 0; + actor->inventory.keys_blue = 0; + actor->inventory.keys_yellow = 0; + level->bonus_points /= 2; +} +const TileType THIEF_KEY_tile = { + .name = "thiefKey", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = THIEF_KEY_actor_completely_joined}; + +static void TURTLE_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (actor->type == &GLIDER_actor || is_ghost(actor)) + return; + BasicTile_transform_into(self, &WATER_tile); + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Actor_new(level, &SPLASH_actor, Level_pos_from_cell(level, cell), + DIRECTION_UP); +} + +const TileType TURTLE_tile = {.name = "turtle", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_AVOIDS_TURTLE, + .actor_left = TURTLE_actor_left}; + +const TileType CUSTOM_FLOOR_tile = {.name = "customFloor", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_GHOST}; +const TileType CUSTOM_WALL_tile = {.name = "customWall", + .layer = LAYER_TERRAIN, + .impedes_mask = ~0}; + +const TileType LETTER_FLOOR_tile = {.name = "letterTile", + .layer = LAYER_TERRAIN}; + +// SWIVEL: `custom_data` indicates current direction (UP is UP/RIGHT, and so on) + +static bool SWIVEL_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (is_ghost(actor)) + return false; + return dir == back(self->custom_data) || dir == left(self->custom_data); +} +static void SWIVEL_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (is_ghost(actor)) + return; + Direction self_dir = (Direction)self->custom_data; + if (dir == self_dir) { + self->custom_data = right(self->custom_data); + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_DOOR_UNLOCK); + } + } else if (dir == right(self_dir)) { + self->custom_data = left(self->custom_data); + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_DOOR_UNLOCK); + } + } +} +static void SWIVEL_on_wire_high(BasicTile* self, Level* level, bool _real) { + self->custom_data = right(self->custom_data); +} +const TileType SWIVEL_tile = {.name = "swivel", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .on_wire_high = SWIVEL_on_wire_high, + .impedes = SWIVEL_impedes, + .actor_left = SWIVEL_actor_left}; + +const TileType NO_CHIP_SIGN_tile = {.name = "noChipSign", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_CHIP}; +const TileType NO_MELINDA_SIGN_tile = {.name = "noMelindaSign", + .layer = LAYER_TERRAIN, + .impedes_mask = ACTOR_FLAGS_MELINDA}; + +typedef struct TransmogEntry { + const ActorType* key; + const ActorType* val; +} TransmogEntry; + +// A simple key-val array instead of a hashmap or whatever. Sue me. +static const TransmogEntry transmog_entries[] = { + // Chip-Melinda + {&CHIP_actor, &MELINDA_actor}, + {&MELINDA_actor, &CHIP_actor}, + {&MIRROR_CHIP_actor, &MIRROR_MELINDA_actor}, + {&MIRROR_MELINDA_actor, &MIRROR_CHIP_actor}, + // Dirt block-ice block + {&DIRT_BLOCK_actor, &ICE_BLOCK_actor}, + {&ICE_BLOCK_actor, &DIRT_BLOCK_actor}, + // Ball-walker + {&BALL_actor, &WALKER_actor}, + {&WALKER_actor, &BALL_actor}, + // Fireball-ant-glider-centipede + {&FIREBALL_actor, &ANT_actor}, + {&ANT_actor, &GLIDER_actor}, + {&GLIDER_actor, &CENTIPEDE_actor}, + {&CENTIPEDE_actor, &FIREBALL_actor}, + // Blue-yellow tank + {&BLUE_TANK_actor, &YELLOW_TANK_actor}, + {&YELLOW_TANK_actor, &BLUE_TANK_actor}, + // Red-blue teeth + {&TEETH_RED_actor, &TEETH_BLUE_actor}, + {&TEETH_BLUE_actor, &TEETH_RED_actor}}; + +static const ActorType* const blob_transmog_options[] = { + &GLIDER_actor, &CENTIPEDE_actor, &FIREBALL_actor, + &ANT_actor, &WALKER_actor, &BALL_actor, + &TEETH_RED_actor, &BLUE_TANK_actor, &TEETH_BLUE_actor}; + +static const ActorType* get_transmogrified_type(const ActorType* type, + Level* level) { + if (!type) + return NULL; + if (type == &BLOB_actor) { + return blob_transmog_options[Level_rng(level) % + lengthof(blob_transmog_options)]; + } + for (size_t idx = 0; idx < lengthof(transmog_entries); idx += 1) { + const TransmogEntry* ent = &transmog_entries[idx]; + if (ent->key == type) + return ent->val; + } + return NULL; +} + +static void TRANSMOGRIFIER_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + if (cell->is_wired && !cell->powered_wires) + return; + const ActorType* new_type = get_transmogrified_type(actor->type, level); + if (!new_type) + return; + // This keeps `custom_data`, important for eg. a yellow tank transforming into + // a blue tank and doing a (weird) move + Actor_transform_into(actor, new_type); + // Yeah, transmog plays the teleport sfx + Level_add_sfx(level, SFX_TELEPORT); +} + +const TileType TRANSMOGRIFIER_tile = { + .name = "transmogrifier", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .actor_completely_joined = TRANSMOGRIFIER_actor_completely_joined}; + +// RAILROAD: `custom_data` indicates-- Oh boy. Just go check tiles.h for the +// RAILROAD_* enum +static void RAILROAD_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + self->custom_data = (self->custom_data & ~RAILROAD_ENTERED_DIR_MASK) | + (dir_to_cc2(actor->direction) << 12); +} +static uint8_t rr_get_relevant_tracks(Direction dir) { + assert(dir != DIRECTION_NONE); + if (dir == DIRECTION_UP) + return RAILROAD_TRACK_UR | RAILROAD_TRACK_LU | RAILROAD_TRACK_UD; + if (dir == DIRECTION_RIGHT) + return RAILROAD_TRACK_UR | RAILROAD_TRACK_RD | RAILROAD_TRACK_LR; + if (dir == DIRECTION_DOWN) + return RAILROAD_TRACK_RD | RAILROAD_TRACK_DL | RAILROAD_TRACK_UD; + if (dir == DIRECTION_LEFT) + return RAILROAD_TRACK_DL | RAILROAD_TRACK_LU | RAILROAD_TRACK_LR; + return 0; +} +static uint8_t rr_get_active_track_idx(uint64_t custom_data) { + return (custom_data & RAILROAD_ACTIVE_TRACK_MASK) >> 8; +} +static uint8_t rr_get_available_tracks(uint64_t custom_data) { + if (custom_data & RAILROAD_TRACK_SWITCH) { + return (custom_data & RAILROAD_TRACK_MASK) & + (1 << rr_get_active_track_idx(custom_data)); + } + return custom_data & RAILROAD_TRACK_MASK; +} +static uint8_t rr_get_entered_track(uint64_t custom_data) { + return dir_from_cc2((custom_data & RAILROAD_ENTERED_DIR_MASK) >> 12); +} +inline static bool rr_check_direction(BasicTile* self, + Level* level, + Actor* actor, + Direction base_dir, + uint8_t turn) { + Direction dir = right_n(base_dir, turn); + Direction entered_dir = rr_get_entered_track(self->custom_data); + if (dir == back(entered_dir) && !has_flag(actor, ACTOR_FLAGS_BLOCK)) + return false; + uint8_t valid_exits = rr_get_relevant_tracks(back(entered_dir)) & + rr_get_available_tracks(self->custom_data); + uint8_t dir_exits = rr_get_relevant_tracks(dir); + if (!(valid_exits & dir_exits)) + return false; + if (actor->type->on_redirect) { + actor->type->on_redirect(actor, level, turn); + } + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + if (cell->special.type == &THIN_WALL_tile && + cell->special.custom_data & get_dir_bit(dir)) + return false; + return true; +} +static Direction RAILROAD_redirect_exit(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (is_ghost(actor)) + return dir; + if (has_item_counter(actor->inventory, ITEM_INDEX_RR_SIGN)) + return dir; + if (rr_check_direction(self, level, actor, dir, 0)) + return dir; + if (rr_check_direction(self, level, actor, dir, 1)) + return right(dir); + if (rr_check_direction(self, level, actor, dir, 3)) + return left(dir); + if (rr_check_direction(self, level, actor, dir, 2)) + return back(dir); + return DIRECTION_NONE; +} + +static bool RAILROAD_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (is_ghost(actor)) + return false; + if (has_item_counter(actor->inventory, ITEM_INDEX_RR_SIGN)) + return false; + uint8_t enter_rails = rr_get_relevant_tracks(back(dir)); + return !(enter_rails & rr_get_available_tracks(self->custom_data)); +} +enum { TRACKS_N = 6 }; + +static void rr_toggle_to_next_track(BasicTile* self) { + uint8_t tracks = self->custom_data & RAILROAD_TRACK_MASK; + // Find the next track + uint8_t current_active_idx = rr_get_active_track_idx(self->custom_data); + for (uint8_t offset = 1; offset < TRACKS_N; offset += 1) { + uint8_t track_idx = (current_active_idx + offset) % TRACKS_N; + uint8_t track_bit = 1 << track_idx; + if (track_bit & tracks) { + self->custom_data = + (self->custom_data & ~RAILROAD_ACTIVE_TRACK_MASK) | (track_idx << 8); + return; + } + } +} +static void RAILROAD_on_wire_high(BasicTile* self, Level* _level, bool _real) { + rr_toggle_to_next_track(self); +} + +static void RAILROAD_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (is_ghost(actor)) + return; + if (!(self->custom_data & RAILROAD_TRACK_SWITCH)) + return; + // The rail that we "followed" when we entered and exited this railroad tile + uint8_t enter_exit_rail = + rr_get_relevant_tracks(dir) & + rr_get_relevant_tracks(back(rr_get_entered_track(self->custom_data))); + // Note that if we exited the opposite direction we entered (possible with + // blocks and anything with an RR sign), `enter_exit_rail` would have *three* + // rails. For example: if you have a switching railroad with RD, DL, and UD + // tracks, and go (with an RR sign) up onto and down off the tile repeatedly, + // it will cycle between all three rails + if (enter_exit_rail & rr_get_available_tracks(self->custom_data)) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + if (cell->is_wired) + return; + rr_toggle_to_next_track(self); + } +} + +const TileType RAILROAD_tile = { + .name = "railroad", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_READ, + .on_wire_high = RAILROAD_on_wire_high, + .actor_completely_joined = RAILROAD_actor_completely_joined, + .impedes = RAILROAD_impedes, + .redirect_exit = RAILROAD_redirect_exit, + .actor_left = RAILROAD_actor_left}; + +enum { LOGIC_GATE_STATE_BITMASK = 0x7ff }; + +static uint8_t normalize_wire_bits(uint8_t bits, Direction self_dir) { + assert(self_dir != DIRECTION_NONE); + if (self_dir == DIRECTION_UP) + return bits; + if (self_dir == DIRECTION_RIGHT) + return ((bits >> 1) & 0b0111) | ((bits << 3) & 0b1000); + if (self_dir == DIRECTION_DOWN) + return ((bits >> 2) & 0b0011) | ((bits << 2) & 0b1100); + if (self_dir == DIRECTION_LEFT) + return ((bits << 1) & 0b1110) | ((bits >> 3) & 0b0001); + assert(false); + return 0; +} + +static Direction logic_gate_get_direction(const BasicTile* self) { + uint8_t wire_bits = self->custom_data & 0b1111; + // Three-wire gates (AND, OR, XOR, NOR, latch, latch mirror) + if (wire_bits == 0b1011) + return DIRECTION_UP; + if (wire_bits == 0b0111) + return DIRECTION_RIGHT; + if (wire_bits == 0b1110) + return DIRECTION_DOWN; + if (wire_bits == 0b1101) + return DIRECTION_LEFT; + uint8_t logic_gate_wire_state = self->custom_data & LOGIC_GATE_STATE_BITMASK; + // Two-wire gate (NOT) + if (logic_gate_wire_state == LOGIC_GATE_NOT_UP) + return DIRECTION_UP; + if (logic_gate_wire_state == LOGIC_GATE_NOT_RIGHT) + return DIRECTION_RIGHT; + if (logic_gate_wire_state == LOGIC_GATE_NOT_DOWN) + return DIRECTION_DOWN; + if (logic_gate_wire_state == LOGIC_GATE_NOT_LEFT) + return DIRECTION_LEFT; + // Four-wire gate (counter) + if (wire_bits == 0b1111) + return DIRECTION_UP; + return DIRECTION_NONE; +} + +static uint8_t LOGIC_GATE_give_power(BasicTile* self, Level* level) { + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Direction this_dir = logic_gate_get_direction(self); + uint8_t powered = normalize_wire_bits(cell->powered_wires, this_dir); + uint8_t powering = 0; + uint8_t wires = self->custom_data & 0xf; + if (wires == 0b0101 || wires == 0b1010) { + // NOT gate + powering = powered & 0b0100 ? 0 : 0b0001; + } else if (wires == 0b1111) { + // counter gate + uint8_t value = (self->custom_data & 0xf0) >> 4; + bool had_add = (self->custom_data & 0x100) >> 8; + bool is_add = powered & 0b0010; + bool had_sub = (self->custom_data & 0x200) >> 9; + bool is_sub = powered & 0b0100; + bool underflow = (self->custom_data & 0x400) >> 10; + bool do_add = !had_add && is_add; + bool do_sub = !had_sub && is_sub; + if (do_add && do_sub) { + underflow = false; + } else if (do_add) { + underflow = false; + value += 1; + if (value == 10) { + value = 0; + powering |= 0b1000; + } + } else if (do_sub) { + underflow = value == 0; + if (value == 0) { + value = 9; + } else { + value -= 1; + } + } + if (underflow) { + powering |= 0b0001; + } + self->custom_data &= ~LOGIC_GATE_STATE_BITMASK; + self->custom_data |= (underflow << 10) | (is_sub << 9) | (is_add << 8) | + (value << 4) | 0b1111; + + } else { + // Three-wire gates + uint8_t type_specifier = + (self->custom_data & LOGIC_GATE_SPECIFIER_BITMASK) >> 4; + uint8_t input_wires = powered & 0b1010; + if (type_specifier == LOGIC_GATE_SPECIFIER_OR) { + powering = input_wires ? 0b0001 : 0; + } else if (type_specifier == LOGIC_GATE_SPECIFIER_AND) { + powering = input_wires == 0b1010 ? 0b0001 : 0; + } else if (type_specifier == LOGIC_GATE_SPECIFIER_NAND) { + powering = input_wires == 0b1010 ? 0 : 0b0001; + } else if (type_specifier == LOGIC_GATE_SPECIFIER_XOR) { + powering = input_wires == 0b1000 || input_wires == 0b0010 ? 0b0001 : 0; + } else if (type_specifier == LOGIC_GATE_SPECIFIER_LATCH || + type_specifier == LOGIC_GATE_SPECIFIER_LATCH_MIRROR) { + bool is_mirrored = type_specifier == LOGIC_GATE_SPECIFIER_LATCH_MIRROR; + bool memory = self->custom_data & 0x80; + bool write = powered & (is_mirrored ? 0b1000 : 0b0010); + bool written_value = powered & (is_mirrored ? 0b0010 : 0b1000); + if (write) { + memory = written_value; + self->custom_data &= ~0x80; + self->custom_data |= written_value ? 0x80 : 0; + } + powering = memory ? 0b0001 : 0; + + } else { + assert(false && "Invalid logic gate type"); + } + } + return normalize_wire_bits(powering, mirror_vert(this_dir)); +} + +static void LOGIC_GATE_on_idle(BasicTile* self, Level* level, Actor* actor) { + if (!(self->custom_data & LOGIC_GATE_IS_BUSY)) + return; + if (!actor->frozen) + return; + Cell* cell = BasicTile_get_cell(self, LAYER_TERRAIN); + Position this_pos = Level_pos_from_cell(level, cell); + uint8_t out_wire = logic_gate_get_output_wire(self); + WireNetworkMember* memb; + WireNetwork* network = tile_find_network(level, this_pos, out_wire, &memb); + wtd_do_teleport(self, level, network, memb, actor); +} + +const TileType LOGIC_GATE_tile = {.name = "logicGate", + .layer = LAYER_TERRAIN, + .wire_type = WIRES_UNCONNECTED, + .flags = ACTOR_FLAGS_DYNAMITE_IMMUNE, + .on_idle = LOGIC_GATE_on_idle, + .give_power = LOGIC_GATE_give_power}; + +// Actors + +static void kill_player(Actor* self, Level* level, Actor* other) { + if (!has_flag(other, ACTOR_FLAGS_REAL_PLAYER)) + return; + if (has_item_counter(other->inventory, ITEM_INDEX_HELMET) || + has_item_counter(self->inventory, ITEM_INDEX_HELMET)) + return; + if (self->pulled && other->pulling) + return; + Actor_destroy(other, level, &EXPLOSION_actor); +} + +static void player_die_on_monster_bump(Actor* self, + Level* level, + Actor* other) { + if (has_flag(other, ACTOR_FLAGS_KILLS_PLAYER) && + !has_flag(other, ACTOR_FLAGS_BLOCK)) { + kill_player(other, level, self); + } +} + +const ActorType CHIP_actor = { + .name = "chip", + .flags = ACTOR_FLAGS_REAL_PLAYER | ACTOR_FLAGS_CHIP | + ACTOR_FLAGS_PICKS_UP_ITEMS | ACTOR_FLAGS_CAN_PUSH | + ACTOR_FLAGS_REVEALS_HIDDEN, + .on_bump_actor = player_die_on_monster_bump}; + +const ActorType MELINDA_actor = { + .name = "melinda", + .flags = ACTOR_FLAGS_REAL_PLAYER | ACTOR_FLAGS_MELINDA | + ACTOR_FLAGS_PICKS_UP_ITEMS | ACTOR_FLAGS_CAN_PUSH | + ACTOR_FLAGS_REVEALS_HIDDEN, + .on_bump_actor = player_die_on_monster_bump}; + +static void CENTIPEDE_decide(Actor* self, + Level* level, + Direction directions[4]) { + directions[0] = right(self->direction); + directions[1] = self->direction; + directions[2] = left(self->direction); + directions[3] = back(self->direction); +} + +#define ACTOR_FLAGS_CC1_MONSTER \ + ACTOR_FLAGS_BASIC_MONSTER | ACTOR_FLAGS_KILLS_PLAYER | \ + ACTOR_FLAGS_AVOIDS_GRAVEL | ACTOR_FLAGS_AVOIDS_FIRE + +const ActorType CENTIPEDE_actor = {.name = "centipede", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = CENTIPEDE_decide, + .on_bump_actor = kill_player}; + +static void GLIDER_decide(Actor* self, Level* level, Direction directions[4]) { + directions[0] = self->direction; + directions[1] = left(self->direction); + directions[2] = right(self->direction); + directions[3] = back(self->direction); +} + +const ActorType GLIDER_actor = {.name = "glider", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = GLIDER_decide, + .on_bump_actor = kill_player}; + +static void ANT_decide(Actor* self, Level* level, Direction directions[4]) { + directions[0] = left(self->direction); + directions[1] = self->direction; + directions[2] = right(self->direction); + directions[3] = back(self->direction); +} + +const ActorType ANT_actor = { + .name = "ant", + .flags = ACTOR_FLAGS_CC1_MONSTER | ACTOR_FLAGS_AVOIDS_CANOPY, + .decide_movement = ANT_decide, + .on_bump_actor = kill_player}; + +static void BALL_decide_movement(Actor* self, Level* level, Direction dirs[4]) { + dirs[0] = self->direction; + dirs[1] = back(self->direction); +} + +const ActorType BALL_actor = {.name = "ball", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = BALL_decide_movement, + .on_bump_actor = kill_player}; + +static void FIREBALL_decide_movement(Actor* self, + Level* level, + Direction directions[4]) { + directions[0] = self->direction; + directions[1] = right(self->direction); + directions[2] = left(self->direction); + directions[3] = back(self->direction); +} + +const ActorType FIREBALL_actor = { + .name = "fireball", + .flags = (ACTOR_FLAGS_CC1_MONSTER | ACTOR_FLAGS_AVOIDS_TURTLE) & + ~ACTOR_FLAGS_AVOIDS_FIRE, + .decide_movement = FIREBALL_decide_movement, + .on_bump_actor = kill_player}; + +static void WALKER_decide_movement(Actor* self, + Level* level, + Direction directions[4]) { + Direction checked_dir = self->direction; + if (Actor_check_collision(self, level, &checked_dir)) { + self->move_decision = self->direction; + return; + } + directions[0] = + dir_from_cc2((dir_to_cc2(self->direction) + Level_rng(level)) % 4); +} + +const ActorType WALKER_actor = {.name = "walker", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = WALKER_decide_movement, + .on_bump_actor = kill_player}; + +static void BLOB_decide_movement(Actor* self, + Level* level, + Direction directions[4]) { + self->move_decision = + dir_from_cc2((Level_rng(level) + Level_blobmod(level)) % 4); +} + +const ActorType BLOB_actor = {.name = "blob", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = BLOB_decide_movement, + .on_bump_actor = kill_player, + .move_duration = 24}; + +static void get_pursuit_dirs(Position source, + PositionF target, + bool reverse, + Direction dirs[2]) { + float dx = target.x - (float)source.x; + float dy = target.y - (float)source.y; + if (reverse) { + dx *= -1; + dy *= -1; + } + Direction x_dir = dx > 0 ? DIRECTION_RIGHT + : dx < 0 ? DIRECTION_LEFT + : DIRECTION_NONE; + Direction y_dir = dy > 0 ? DIRECTION_DOWN + : dy < 0 ? DIRECTION_UP + : DIRECTION_NONE; + if (x_dir != DIRECTION_NONE && y_dir != DIRECTION_NONE) { + // When we have two available directions, go with the one that's further + // away, and pick the vertical one if they match + if (fabsf(dx) > fabsf(dy)) { + dirs[0] = x_dir; + dirs[1] = y_dir; + } else { + dirs[0] = y_dir; + dirs[1] = x_dir; + } + } else if (x_dir != DIRECTION_NONE) { + dirs[0] = x_dir; + } else { + dirs[0] = y_dir; + } +} + +static void TEETH_RED_decide_movement(Actor* self, + Level* level, + Direction directions[4]) { + if ((level->current_tick + 5) % 8 >= 4) + return; + Actor* player = Level_find_closest_player(level, self->position); + if (!player) + return; + get_pursuit_dirs(self->position, Actor_get_visual_position(player), + has_flag(player, ACTOR_FLAGS_MELINDA), directions); +} + +const ActorType TEETH_RED_actor = {.name = "teethRed", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = TEETH_RED_decide_movement, + .on_bump_actor = kill_player}; + +// Lol nice copypaste +static void TEETH_BLUE_decide_movement(Actor* self, + Level* level, + Direction directions[4]) { + if ((level->current_tick + 5) % 8 >= 4) + return; + Actor* player = Level_find_closest_player(level, self->position); + if (!player) + return; + get_pursuit_dirs(self->position, Actor_get_visual_position(player), + has_flag(player, ACTOR_FLAGS_CHIP), directions); +} + +const ActorType TEETH_BLUE_actor = { + .name = "teethBlue", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = TEETH_BLUE_decide_movement, + .on_bump_actor = kill_player}; + +// Lol nice copypaste +// Lol nice copypaste +static void FLOOR_MIMIC_decide_movement(Actor* self, + Level* level, + Direction directions[4]) { + if ((level->current_tick + 5) % 16 >= 4) + return; + Actor* player = Level_find_closest_player(level, self->position); + if (!player) + return; + get_pursuit_dirs(self->position, Actor_get_visual_position(player), false, + directions); +} + +const ActorType FLOOR_MIMIC_actor = { + .name = "floorMimic", + .flags = ACTOR_FLAGS_CC1_MONSTER, + .decide_movement = FLOOR_MIMIC_decide_movement, + .on_bump_actor = kill_player}; + +// ROVER: least significant byte specifies moves until the next monster is +// emulated, second least byte significant specifies the currently emulated +// monster +static const ActorType* const rover_emulated_monsters[] = { + &TEETH_RED_actor, &GLIDER_actor, &ANT_actor, &BALL_actor, + &TEETH_BLUE_actor, &FIREBALL_actor, &CENTIPEDE_actor, &WALKER_actor}; + +static void ROVER_init(Actor* self, Level* level) { + self->custom_data = 32; +} + +static void ROVER_decide_movement(Actor* self, + Level* level, + Direction dirs[4]) { + uint8_t current_monster_idx = (self->custom_data & 0xff00) >> 8; + uint8_t moves_until_next_emu = self->custom_data & 0xff; + moves_until_next_emu -= 1; + if (moves_until_next_emu == 0) { + current_monster_idx = + (current_monster_idx + 1) % lengthof(rover_emulated_monsters); + moves_until_next_emu = 32; + } + self->custom_data = (current_monster_idx << 8) + moves_until_next_emu; + const ActorType* emu_type = rover_emulated_monsters[current_monster_idx]; + emu_type->decide_movement(self, level, dirs); +} + +const ActorType ROVER_actor = { + .name = "rover", + .flags = ACTOR_FLAGS_PICKS_UP_ITEMS | ACTOR_FLAGS_KILLS_PLAYER | + ACTOR_FLAGS_CAN_PUSH | ACTOR_FLAGS_AVOIDS_CANOPY | + ACTOR_FLAGS_AVOIDS_FIRE, + .init = ROVER_init, + .decide_movement = ROVER_decide_movement, + .on_bump_actor = kill_player, + .move_duration = 24}; + +static bool DIRT_BLOCK_can_be_pushed(Actor* self, + Level* level, + Actor* other, + Direction dir, + bool pulling) { + // Dirt block can always be pulled + if (pulling) + return true; + return !has_flag(other, ACTOR_FLAGS_BLOCK) || + other->type == &FRAME_BLOCK_actor; +}; + +const ActorType DIRT_BLOCK_actor = {.name = "dirtBlock", + .flags = ACTOR_FLAGS_BLOCK | + ACTOR_FLAGS_BASIC_MONSTER | + ACTOR_FLAGS_KILLS_PLAYER, + .can_be_pushed = DIRT_BLOCK_can_be_pushed, + .on_bump_actor = kill_player}; + +static bool cc2_block_can_be_pushed(Actor* self, + Level* level, + Actor* other, + Direction _dir, + bool is_being_pulled) { + // Can always be pulled + if (is_being_pulled) + return true; + // Weird quirk: we can't be pushed by a block if we're sliding + if (self->sliding_state && has_flag(other, ACTOR_FLAGS_BLOCK)) + return false; + return true; +} + +static void ICE_BLOCK_on_bumped_by(Actor* self, Level* level, Actor* other) { + Cell* cell = Level_get_cell(level, self->position); + if (other->type == &FIREBALL_actor && (cell->terrain.type == &FLOOR_tile || + cell->terrain.type == &WATER_tile)) { + BasicTile_transform_into(&cell->terrain, &WATER_tile); + Actor_destroy(self, level, &SPLASH_actor); + } +} + +const ActorType ICE_BLOCK_actor = { + .name = "iceBlock", + .flags = ACTOR_FLAGS_BLOCK | ACTOR_FLAGS_IGNORES_ITEMS | + ACTOR_FLAGS_CAN_PUSH | ACTOR_FLAGS_KILLS_PLAYER | + ACTOR_FLAGS_REVEALS_HIDDEN, + .can_be_pushed = cc2_block_can_be_pushed, + .on_bump_actor = kill_player, + .on_bumped_by = ICE_BLOCK_on_bumped_by}; + +static bool FRAME_BLOCK_can_be_pushed(Actor* self, + Level* level, + Actor* other, + Direction dir, + bool is_being_pulled) { + if (!(self->custom_data & get_dir_bit(dir))) + return false; + return cc2_block_can_be_pushed(self, level, other, dir, is_being_pulled); +} +static uint8_t rotate_arrows_right(uint8_t arrows) { + return ((arrows & 0x7) << 1) | ((arrows & 0x8) ? 1 : 0); +} + +static void FRAME_BLOCK_on_redirect(Actor* self, Level* level, uint8_t turn) { + while (turn > 0) { + self->custom_data = rotate_arrows_right(self->custom_data); + turn -= 1; + } +} + +const ActorType FRAME_BLOCK_actor = { + .name = "frameBlock", + .flags = ACTOR_FLAGS_BLOCK | ACTOR_FLAGS_IGNORES_ITEMS | + ACTOR_FLAGS_CAN_PUSH | ACTOR_FLAGS_KILLS_PLAYER | + ACTOR_FLAGS_REVEALS_HIDDEN, + .can_be_pushed = FRAME_BLOCK_can_be_pushed, + .on_bump_actor = kill_player, + .on_redirect = FRAME_BLOCK_on_redirect, +}; + +// BLUE_TANK: `custom_data` is BLUE_TANK_ROTATE if it's to rotate +static void BLUE_TANK_decide_movement(Actor* self, + Level* level, + Direction dirs[4]) { + if (self->custom_data != 0) { + dirs[0] = back(self->direction); + self->custom_data = 0; + } else { + dirs[0] = self->direction; + } +} + +const ActorType BLUE_TANK_actor = {.name = "tankBlue", + .decide_movement = BLUE_TANK_decide_movement, + .flags = ACTOR_FLAGS_CC1_MONSTER, + .on_bump_actor = kill_player}; + +// YELLOW_TANK: `custom_data` is what direction to move in, BLUE_TANK_ROTATE if +// it's to rotate like a blue tank + +static void YELLOW_TANK_decide_movement(Actor* self, + Level* level, + Direction dirs[4]) { + Direction dir = DIRECTION_NONE; + if (self->custom_data & BLUE_TANK_ROTATE) { + dir = self->direction; + } else if (self->custom_data != DIRECTION_NONE) { + dir = (Direction)self->custom_data; + } + if (dir) { + // Do the check manually so that we don't try to do the last tried direction + // at move time + Direction checked_dir = dir; + if (Actor_check_collision(self, level, &checked_dir)) { + self->move_decision = dir; + self->direction = dir; + } + } + self->custom_data = 0; +} + +const ActorType YELLOW_TANK_actor = { + .name = "tankYellow", + .decide_movement = YELLOW_TANK_decide_movement, + .flags = (ACTOR_FLAGS_CC1_MONSTER & ~ACTOR_FLAGS_AVOIDS_FIRE) | + ACTOR_FLAGS_CAN_PUSH, + .on_bump_actor = kill_player}; + +static void animation_init(Actor* self, Level* level) { + self->custom_data = 16; + Level_add_sfx(level, + self->type == &SPLASH_actor ? SFX_SPLASH : SFX_EXPLOSION); +} +static void animation_on_bumped_by(Actor* self, Level* level, Actor* other) { + if (!has_flag(other, ACTOR_FLAGS_REAL_PLAYER)) { + Actor_erase(self, level); + } +} + +const ActorType SPLASH_actor = {.name = "splashAnim", + .flags = ACTOR_FLAGS_ANIMATION, + .on_bumped_by = animation_on_bumped_by, + .init = animation_init}; +const ActorType EXPLOSION_actor = {.name = "explosionAnim", + .flags = ACTOR_FLAGS_ANIMATION, + .on_bumped_by = animation_on_bumped_by, + .init = animation_init}; + +static bool dynamite_lit_nuke_tile(void* ctx, Level* level, Cell* cell) { + Position self_pos = *(Position*)ctx; + Position tile_pos = Level_pos_from_cell(level, cell); + int8_t dx = self_pos.x - tile_pos.x; + int8_t dy = self_pos.y - tile_pos.y; + // Edge cell + if (dx == 3 || dx == -3 || dy == 3 || dy == -3) + return false; + if (cell->special.type == &THIN_WALL_tile && + cell->special.custom_data & THIN_WALL_HAS_CANOPY) { + BasicTile_erase(&cell->special); + // Even if it's protected under a canopy, always destroy any (!) lit + // dynamite + if (cell->actor && cell->actor->type == &DYNAMITE_LIT_actor) { + Actor_destroy(cell->actor, level, &EXPLOSION_actor); + } + return false; + } + if (cell->special.type) + BasicTile_erase(&cell->special); + if (cell->item.type) + BasicTile_erase(&cell->item); + if (cell->item_mod.type) + BasicTile_erase(&cell->item_mod); + if (cell->actor) { + bool was_ice_block = cell->actor->type == &ICE_BLOCK_actor; + Actor_destroy(cell->actor, level, &EXPLOSION_actor); + if (cell->terrain.type == &FLOOR_tile) { + // Note that this keeps the original tile's `custom_data` + BasicTile_transform_into(&cell->terrain, + was_ice_block ? &WATER_tile : &FIRE_tile); + } + return false; + } + if (cell->terrain.type != &FLOOR_tile && + !has_flag(&cell->terrain, ACTOR_FLAGS_DYNAMITE_IMMUNE)) { + BasicTile_erase(&cell->terrain); + // We also have to manually unset the `custom_data` in case we're destroying + // something with `custom_data` which would now make the new floor appear as + // if it has wires + cell->terrain.custom_data = 0; + Actor_new(level, &EXPLOSION_actor, tile_pos, DIRECTION_UP); + } + return false; +} + +static void DYNAMITE_LIT_decide_movement(Actor* self, + Level* level, + Direction _directions[4]) { + // Weird dynamite behavior: always try to respawn ourselves. + Cell* cell = Level_get_cell(level, self->position); + Cell_place_actor(cell, level, self); + // XXX: does this happen before or after the explosion of other tiles? (could + // potentially affect what happens to a lit dynamite with 0 cooldown) + self->custom_data -= 1; + // # 3 3 3 # + // 3 2 1 2 3 + // 2 1 3 1 2 + // 3 2 1 2 3 + // # 3 2 3 # + if (self->custom_data == 2) { + // Kind of a hack: search_taxicab_at_dist is meant for searching, not + // iteration + Level_search_taxicab_at_dist(level, self->position, 1, + dynamite_lit_nuke_tile, &self->position); + } else if (self->custom_data == 1) { + Level_search_taxicab_at_dist(level, self->position, 2, + dynamite_lit_nuke_tile, &self->position); + + } else if (self->custom_data == 0) { + // Pass out position to exclude the edge cell which dynamite doesn't explode + Level_search_taxicab_at_dist(level, self->position, 3, + dynamite_lit_nuke_tile, &self->position); + dynamite_lit_nuke_tile(&self->position, level, + Level_get_cell(level, self->position)); + assert(self->type == &EXPLOSION_actor); + } +} +static void DYNAMITE_LIT_init(Actor* self, Level* level) { + self->custom_data = 256; +} + +const ActorType DYNAMITE_LIT_actor = { + .name = "dynamiteLit", + .flags = ACTOR_FLAGS_BASIC_MONSTER | ACTOR_FLAGS_KILLS_PLAYER | + ACTOR_FLAGS_DECIDES_EVERY_SUBTICK, + .init = DYNAMITE_LIT_init, + .decide_movement = DYNAMITE_LIT_decide_movement, + .on_bump_actor = kill_player}; + +static void mirror_player_decide_movement(Actor* self, + Level* level, + Direction dirs[4]) { + Actor* player = Level_find_closest_player(level, self->position); + if (!player || !has_flag(player, (self->type->flags & ACTOR_FLAGS_PLAYER))) + return; + self->move_decision = Player_get_last_decision(player); +} + +const ActorType MIRROR_CHIP_actor = { + .name = "mirrorChip", + .flags = ACTOR_FLAGS_CHIP | ACTOR_FLAGS_PICKS_UP_ITEMS | + ACTOR_FLAGS_CAN_PUSH | ACTOR_FLAGS_REVEALS_HIDDEN | + ACTOR_FLAGS_KILLS_PLAYER, + .on_bump_actor = kill_player, + .decide_movement = mirror_player_decide_movement}; + +const ActorType MIRROR_MELINDA_actor = { + .name = "mirrorMelinda", + .flags = ACTOR_FLAGS_MELINDA | ACTOR_FLAGS_PICKS_UP_ITEMS | + ACTOR_FLAGS_CAN_PUSH | ACTOR_FLAGS_REVEALS_HIDDEN | + ACTOR_FLAGS_KILLS_PLAYER, + .on_bump_actor = kill_player, + .decide_movement = mirror_player_decide_movement}; + +static void bowling_ball_kill_whatever(Actor* self, + Level* level, + Actor* other) { + // Don't die if we're bumped by an animation + if (has_flag(other, ACTOR_FLAGS_ANIMATION)) + return; + Actor_destroy(self, level, &EXPLOSION_actor); + Actor_destroy(other, level, &EXPLOSION_actor); +} +static void bowling_ball_kill_self(Actor* self, + Level* level, + BasicTile* _tile) { + if (self->sliding_state) + return; + Actor_destroy(self, level, &EXPLOSION_actor); +} + +static void BOWLING_BALL_ROLLING_decide_movement(Actor* self, + Level* level, + Direction dirs[4]) { + dirs[0] = self->direction; +} + +const ActorType BOWLING_BALL_ROLLING_actor = { + .name = "bowlingBallRolling", + .flags = ACTOR_FLAGS_PICKS_UP_ITEMS | ACTOR_FLAGS_REVEALS_HIDDEN | + ACTOR_FLAGS_KILLS_PLAYER, + .on_bump_actor = bowling_ball_kill_whatever, + .on_bumped_by = bowling_ball_kill_whatever, + .on_bonk = bowling_ball_kill_self, + .decide_movement = BOWLING_BALL_ROLLING_decide_movement}; + +const ActorType GHOST_actor = { + .name = "ghost", + .flags = ACTOR_FLAGS_PICKS_UP_ITEMS | ACTOR_FLAGS_GHOST | + ACTOR_FLAGS_KILLS_PLAYER | ACTOR_FLAGS_AVOIDS_TURTLE, + .decide_movement = GLIDER_decide, + .on_bump_actor = kill_player, +}; + +// Items +#define MAKE_KEY(var_name, capital, simple, impedes, collect_condition) \ + static void var_name##_actor_completely_joined(BasicTile* self, \ + Level* level, Actor* other) { \ + if (other->type->flags & ACTOR_FLAGS_IGNORES_ITEMS) \ + return; \ + if (!(collect_condition)) \ + return; \ + BasicTile_erase(self); \ + Level_add_sfx(level, SFX_ITEM_PICKUP); \ + if (other->inventory.keys_##simple == 255) { \ + other->inventory.keys_##simple = 0; \ + } else { \ + other->inventory.keys_##simple += 1; \ + } \ + } \ + const TileType var_name##_tile = { \ + .name = "key" #capital, \ + .layer = LAYER_ITEM, \ + .flags = ACTOR_FLAGS_ITEM, \ + .impedes_mask = impedes, \ + .actor_completely_joined = var_name##_actor_completely_joined}; + +MAKE_KEY(KEY_RED, Red, red, 0, (other->type->flags & ACTOR_FLAGS_PLAYER)); +MAKE_KEY(KEY_BLUE, Blue, blue, 0, true); +MAKE_KEY(KEY_YELLOW, Yellow, yellow, ACTOR_FLAGS_BASIC_MONSTER, true); +MAKE_KEY(KEY_GREEN, Green, green, ACTOR_FLAGS_BASIC_MONSTER, true); +#define IS_KEY(item) \ + (item->type == &KEY_RED_tile || item->type == &KEY_GREEN_tile || \ + item->type == &KEY_BLUE_tile || item->type == &KEY_YELLOW_tile) +static uint8_t get_key_count(Inventory* inv, const TileType* key_type) { + if (key_type == &KEY_RED_tile) + return inv->keys_red; + if (key_type == &KEY_GREEN_tile) + return inv->keys_green; + if (key_type == &KEY_BLUE_tile) + return inv->keys_blue; + if (key_type == &KEY_YELLOW_tile) + return inv->keys_yellow; + return 0; +} + +static void ECHIP_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (!(actor->type->flags & ACTOR_FLAGS_REAL_PLAYER)) + return; + if (level->chips_left > 0) { + level->chips_left -= 1; + } + Level_add_sfx(level, SFX_ITEM_PICKUP); + BasicTile_erase(self); +} + +const TileType ECHIP_tile = { + .name = "echip", + .layer = LAYER_ITEM, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = ECHIP_actor_completely_joined}; + +static void TIME_BONUS_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (!has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) + return; + level->time_left += 600; + Level_add_sfx(level, SFX_ITEM_PICKUP); + BasicTile_erase(self); +} + +const TileType TIME_BONUS_tile = { + .name = "timeBonus", + .layer = LAYER_ITEM, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = TIME_BONUS_actor_completely_joined}; + +static void TIME_PENALTY_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (!has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) + return; + if (level->time_left <= 600) { + level->time_left = 1; + } else { + level->time_left -= 600; + } + Level_add_sfx(level, SFX_ITEM_PICKUP); + BasicTile_erase(self); +} + +const TileType TIME_PENALTY_tile = { + .name = "timePenalty", + .layer = LAYER_ITEM, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = TIME_PENALTY_actor_completely_joined}; + +static void STOPWATCH_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (!has_flag(actor, ACTOR_FLAGS_PLAYER)) + return; + Level_add_sfx(level, SFX_ITEM_PICKUP); + level->time_stopped = !level->time_stopped; +} + +const TileType STOPWATCH_tile = { + .name = "stopwatch", + .layer = LAYER_ITEM, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = STOPWATCH_actor_completely_joined}; + +static void generic_item_pickup(BasicTile* self, Level* level, Actor* other) { + if (other->type->flags & ACTOR_FLAGS_IGNORES_ITEMS) + return; + uint8_t item_index = self->type->item_index; + Inventory* inv = &other->inventory; + if (level->metadata.cc1_boots && item_index > ITEM_INDEX_WATER_BOOTS) + return; + + if (level->metadata.cc1_boots) { + Inventory_increment_counter(inv, item_index); + // NOTE: The discrepancy between the item indecies and the item slot numbers + // here is intentional, the CC1 boot order is ice/force/fire/water, but the + // item index (as used in C2G's `tools` variable) goes force/ice/fire/water + if (item_index == ITEM_INDEX_ICE_BOOTS) { + inv->item1 = self->type; + } else if (item_index == ITEM_INDEX_FORCE_BOOTS) { + inv->item2 = self->type; + } else if (item_index == ITEM_INDEX_FIRE_BOOTS) { + inv->item3 = self->type; + } else { + inv->item4 = self->type; + } + BasicTile_erase(self); + return; + } + Actor_pickup_item(other, level, self); +} + +#define MAKE_GENERIC_ITEM(var_name, str_name, item_index_v) \ + const TileType var_name##_tile = { \ + \ + .name = str_name, \ + .layer = LAYER_ITEM, \ + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, \ + .flags = ACTOR_FLAGS_ITEM, \ + .item_index = item_index_v, \ + .actor_completely_joined = generic_item_pickup, \ + }; + +MAKE_GENERIC_ITEM(FORCE_BOOTS, "bootForceFloor", ITEM_INDEX_FORCE_BOOTS); +MAKE_GENERIC_ITEM(ICE_BOOTS, "bootIce", ITEM_INDEX_ICE_BOOTS); +MAKE_GENERIC_ITEM(FIRE_BOOTS, "bootFire", ITEM_INDEX_FIRE_BOOTS); +MAKE_GENERIC_ITEM(WATER_BOOTS, "bootWater", ITEM_INDEX_WATER_BOOTS); +MAKE_GENERIC_ITEM(DIRT_BOOTS, "bootDirt", ITEM_INDEX_DIRT_BOOTS); +MAKE_GENERIC_ITEM(STEEL_FOIL, "steelFoil", ITEM_INDEX_STEEL_FOIL); +MAKE_GENERIC_ITEM(RR_SIGN, "railroadSign", ITEM_INDEX_RR_SIGN); +MAKE_GENERIC_ITEM(BRIBE, "bribe", ITEM_INDEX_BRIBE); +MAKE_GENERIC_ITEM(SPEED_BOOTS, "bootSpeed", ITEM_INDEX_SPEED_BOOTS); +MAKE_GENERIC_ITEM(SECRET_EYE, "secretEye", ITEM_INDEX_SECRET_EYE); +MAKE_GENERIC_ITEM(HELMET, "helmet", ITEM_INDEX_HELMET); +MAKE_GENERIC_ITEM(LIGHTNING_BOLT, "lightningBolt", ITEM_INDEX_LIGHTNING_BOLT); +MAKE_GENERIC_ITEM(BOWLING_BALL, "bowlingBall", ITEM_INDEX_BOWLING_BALL); +MAKE_GENERIC_ITEM(HOOK, "hook", ITEM_INDEX_HOOK); + +static void DYNAMITE_actor_left(BasicTile* self, + Level* level, + Actor* actor, + Direction dir) { + if (!has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) + return; + Cell* this_cell = BasicTile_get_cell(self, LAYER_ITEM); + // We will be despawned immediately after this because the player will + // null-out their after notifying us + Actor* dynamite = Actor_new(level, &DYNAMITE_LIT_actor, + Level_pos_from_cell(level, this_cell), dir); + dynamite->inventory = actor->inventory; + BasicTile_erase(self); +} + +const TileType DYNAMITE_tile = { + + .name = "dynamite", + .layer = LAYER_ITEM, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .flags = ACTOR_FLAGS_ITEM, + .item_index = ITEM_INDEX_DYNAMITE, + .actor_completely_joined = generic_item_pickup, + .actor_left = DYNAMITE_actor_left}; + +// BONUS_FLAG: `custom_data` indicates points earned, if the 0x8000 bit is set, +// multiply instead of add +static void BONUS_FLAG_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (is_ghost(actor) || has_flag(actor, ACTOR_FLAGS_IGNORES_ITEMS) || + level->ignore_bonus_flags) + return; + if (has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) { + if (self->custom_data & 0x8000) { + level->bonus_points *= self->custom_data & 0x7fff; + } else { + level->bonus_points += self->custom_data; + } + } + if (has_flag(actor, ACTOR_FLAGS_PLAYER)) { + Level_add_sfx(level, SFX_ITEM_PICKUP); + } + BasicTile_erase(self); +} + +const TileType BONUS_FLAG_tile = { + .name = "bonusFlag", + .layer = LAYER_ITEM, + .impedes_mask = ACTOR_FLAGS_BASIC_MONSTER, + .actor_completely_joined = BONUS_FLAG_actor_completely_joined}; + +// Misc +static Direction THIN_WALL_redirect_exit(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + assert(direction != DIRECTION_NONE); + if (is_ghost(actor)) + return direction; + if (self->custom_data & get_dir_bit(direction)) + return DIRECTION_NONE; + return direction; +} +static bool THIN_WALL_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction direction) { + assert(direction != DIRECTION_NONE); + if (is_ghost(actor)) + return false; + if (actor->type == &BLOB_actor && + (self->custom_data & THIN_WALL_HAS_CANOPY)) { + Cell* blob_tile = Level_get_cell(level, actor->position); + BasicTile* blob_special = &blob_tile->special; + if (blob_special->type == &THIN_WALL_tile && + (blob_special->custom_data & THIN_WALL_HAS_CANOPY)) + return true; + } + if ((self->custom_data & THIN_WALL_HAS_CANOPY) && + has_flag(actor, ACTOR_FLAGS_AVOIDS_CANOPY)) + return true; + if (self->custom_data & get_dir_bit(back(direction))) + return true; + return false; +} +const TileType THIN_WALL_tile = {.name = "thinWall", + .layer = LAYER_SPECIAL, + .redirect_exit = THIN_WALL_redirect_exit, + .impedes = THIN_WALL_impedes}; + +static void bomb_actor_interacts(BasicTile* self, Level* level, Actor* actor) { + if (is_ghost(actor)) + return; + Actor_destroy(actor, level, &EXPLOSION_actor); + BasicTile_erase(self); +} +const TileType BOMB_tile = {.name = "bomb", + .layer = LAYER_ITEM, + .on_idle = bomb_actor_interacts, + .actor_completely_joined = bomb_actor_interacts}; + +static bool NO_SIGN_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + Cell* cell = BasicTile_get_cell(self, LAYER_ITEM_MOD); + BasicTile* item = &cell->item; + if (!item->type || !has_flag(item, ACTOR_FLAGS_ITEM)) + return false; + if (item->type->item_index) { + return has_item_counter(actor->inventory, item->type->item_index); + } else if (IS_KEY(item)) { + return get_key_count(&actor->inventory, item->type) > 0; + } else { + return has_item_generic(actor->inventory, item->type); + } +} + +static bool NO_SIGN_overrides_item_layer(BasicTile* self, + Level* level, + BasicTile* item) { + // TODO: Ghost nonsense? + if (level->metadata.cc1_boots) + return false; + return item->type && has_flag(item, ACTOR_FLAGS_ITEM); +} + +const TileType NO_SIGN_tile = { + .name = "noSign", + .layer = LAYER_ITEM_MOD, + .impedes = NO_SIGN_impedes, + .overrides_item_layer = NO_SIGN_overrides_item_layer}; + +static bool green_bomb_is_chip(BasicTile* self, Level* level) { + return (bool)self->custom_data != level->toggle_wall_inverted; +} + +static bool GREEN_BOMB_impedes(BasicTile* self, + Level* level, + Actor* actor, + Direction _dir) { + return green_bomb_is_chip(self, level) + ? has_flag(actor, ACTOR_FLAGS_BASIC_MONSTER) + : false; +} + +static void GREEN_BOMB_actor_completely_joined(BasicTile* self, + Level* level, + Actor* actor) { + if (is_ghost(actor)) + return; + if (green_bomb_is_chip(self, level)) { + if (has_flag(actor, ACTOR_FLAGS_REAL_PLAYER)) { + if (level->chips_left > 0) { + level->chips_left -= 1; + } + Level_add_sfx(level, SFX_ITEM_PICKUP); + BasicTile_erase(self); + } + } else { + Actor_destroy(actor, level, &EXPLOSION_actor); + BasicTile_erase(self); + } +} + +const TileType GREEN_BOMB_tile = { + .name = "greenBomb", + .layer = LAYER_ITEM, + .impedes = GREEN_BOMB_impedes, + .actor_completely_joined = GREEN_BOMB_actor_completely_joined}; diff --git a/libnotcc/src/tiles.h b/libnotcc/src/tiles.h new file mode 100644 index 00000000..09a9c529 --- /dev/null +++ b/libnotcc/src/tiles.h @@ -0,0 +1,218 @@ +#ifndef _libnotcc_tiles_h +#define _libnotcc_tiles_h +#include "logic.h" + +enum ActorFlags { + ACTOR_FLAGS_BASIC_MONSTER = 1 << 0, + ACTOR_FLAGS_AVOIDS_FIRE = 1 << 1, + ACTOR_FLAGS_AVOIDS_CANOPY = 1 << 2, + ACTOR_FLAGS_CHIP = 1 << 3, + ACTOR_FLAGS_MELINDA = 1 << 4, + ACTOR_FLAGS_PLAYER = ACTOR_FLAGS_CHIP | ACTOR_FLAGS_MELINDA, + ACTOR_FLAGS_PICKS_UP_ITEMS = 1 << 5, + ACTOR_FLAGS_IGNORES_ITEMS = 1 << 6, + ACTOR_FLAGS_REAL_PLAYER = 1 << 7, + ACTOR_FLAGS_FORCE_FLOOR = 1 << 8, + ACTOR_FLAGS_BLOCK = 1 << 9, + ACTOR_FLAGS_GHOST = 1 << 10, + ACTOR_FLAGS_DYNAMITE_IMMUNE = 1 << 11, + ACTOR_FLAGS_ITEM = 1 << 12, + ACTOR_FLAGS_AVOIDS_GRAVEL = 1 << 13, + ACTOR_FLAGS_CAN_PUSH = 1 << 14, + ACTOR_FLAGS_ANIMATION = 1 << 15, + ACTOR_FLAGS_KILLS_PLAYER = 1 << 16, + ACTOR_FLAGS_REVEALS_HIDDEN = 1 << 17, + ACTOR_FLAGS_AVOIDS_TURTLE = 1 << 18, + ACTOR_FLAGS_DECIDES_EVERY_SUBTICK = 1 << 19, + ACTOR_FLAGS_ICE = 1 << 20, +}; + +enum { + PLAYER_HAS_OVERRIDE = 1, + PLAYER_IS_VISUALLY_BONKING = 2, + PLAYER_WAS_ON_ICE = 4 +}; + +extern const TileType FLOOR_tile; +extern const TileType WALL_tile; +extern const ActorType CHIP_actor; +extern const ActorType MELINDA_actor; +extern const ActorType CENTIPEDE_actor; +extern const TileType EXIT_tile; +extern const TileType LIGHTNING_tile; +extern const ActorType SPLASH_actor; +extern const ActorType EXPLOSION_actor; + +extern const TileType ICE_tile; +extern const TileType ICE_CORNER_tile; +enum { THIN_WALL_HAS_CANOPY = 0x10 }; +extern const TileType THIN_WALL_tile; +extern const TileType WATER_tile; +extern const TileType FIRE_tile; +extern const TileType FORCE_FLOOR_tile; +extern const TileType TOGGLE_WALL_tile; +extern const TileType TELEPORT_RED_tile; +extern const TileType TELEPORT_BLUE_tile; +extern const TileType TELEPORT_GREEN_tile; +extern const TileType TELEPORT_YELLOW_tile; +extern const TileType SLIME_tile; +extern const ActorType DIRT_BLOCK_actor; +extern const ActorType WALKER_actor; +extern const ActorType BLOB_actor; +extern const ActorType TEETH_RED_actor; +extern const ActorType TEETH_BLUE_actor; +extern const ActorType FLOOR_MIMIC_actor; +extern const ActorType GLIDER_actor; +extern const ActorType ICE_BLOCK_actor; +extern const TileType GRAVEL_tile; +extern const TileType BUTTON_GREEN_tile; +extern const TileType BUTTON_BLUE_tile; +extern const TileType DOOR_RED_tile; +extern const TileType DOOR_BLUE_tile; +extern const TileType DOOR_YELLOW_tile; +extern const TileType DOOR_GREEN_tile; +extern const TileType KEY_RED_tile; +extern const TileType KEY_BLUE_tile; +extern const TileType KEY_YELLOW_tile; +extern const TileType KEY_GREEN_tile; +enum { BLUE_TANK_ROTATE = 8 }; +extern const ActorType BLUE_TANK_actor; +extern const ActorType ANT_actor; +extern const TileType ECHIP_tile; +extern const TileType ECHIP_GATE_tile; +extern const TileType HINT_tile; +extern const TileType DIRT_tile; +extern const TileType ICE_BOOTS_tile; +extern const TileType FORCE_BOOTS_tile; +extern const TileType FIRE_BOOTS_tile; +extern const TileType WATER_BOOTS_tile; +extern const TileType DIRT_BOOTS_tile; +extern const ActorType FIREBALL_actor; +extern const ActorType BALL_actor; +extern const TileType BUTTON_BROWN_tile; +extern const TileType BUTTON_RED_tile; +extern const TileType CLONE_MACHINE_tile; +extern const TileType TRAP_tile; +extern const TileType BOMB_tile; +extern const TileType POPUP_WALL_tile; +enum { BLUE_WALL_REAL = 0x10000 }; +extern const TileType BLUE_WALL_tile; +extern const TileType GREEN_WALL_tile; +extern const TileType INVISIBLE_WALL_tile; +extern const TileType APPEARING_WALL_tile; +extern const TileType THIEF_TOOL_tile; +extern const TileType THIEF_KEY_tile; +extern const TileType FORCE_FLOOR_RANDOM_tile; +extern const TileType BONUS_FLAG_tile; +extern const TileType TURTLE_tile; +extern const TileType CUSTOM_WALL_tile; +extern const TileType CUSTOM_FLOOR_tile; +extern const TileType NO_SIGN_tile; +extern const TileType LETTER_FLOOR_tile; +extern const TileType SWIVEL_tile; +extern const TileType GREEN_BOMB_tile; +extern const TileType FLAME_JET_tile; +extern const TileType BUTTON_ORANGE_tile; +extern const TileType DYNAMITE_tile; +extern const ActorType DYNAMITE_LIT_actor; +extern const TileType STEEL_WALL_tile; +extern const TileType STEEL_FOIL_tile; +extern const TileType RR_SIGN_tile; +extern const TileType HELMET_tile; +extern const TileType BRIBE_tile; +extern const TileType SPEED_BOOTS_tile; +extern const TileType SECRET_EYE_tile; +extern const TileType NO_CHIP_SIGN_tile; +extern const TileType NO_MELINDA_SIGN_tile; +extern const ActorType YELLOW_TANK_actor; +extern const TileType BUTTON_YELLOW_tile; +extern const TileType TRANSMOGRIFIER_tile; +extern const ActorType FRAME_BLOCK_actor; +extern const TileType TIME_BONUS_tile; +extern const TileType TIME_PENALTY_tile; +extern const TileType STOPWATCH_tile; +extern const ActorType ROVER_actor; +extern const ActorType MIRROR_CHIP_actor; +extern const ActorType MIRROR_MELINDA_actor; +extern const TileType BOWLING_BALL_tile; +extern const ActorType BOWLING_BALL_ROLLING_actor; +extern const TileType HOOK_tile; +extern const TileType LIGHTNING_BOLT_tile; +extern const ActorType GHOST_actor; +enum { + RAILROAD_TRACK_UR = 0x01, + RAILROAD_TRACK_RD = 0x02, + RAILROAD_TRACK_DL = 0x04, + RAILROAD_TRACK_LU = 0x08, + RAILROAD_TRACK_LR = 0x10, + RAILROAD_TRACK_UD = 0x20, + RAILROAD_TRACK_MASK = 0x3f, + RAILROAD_TRACK_SWITCH = 0x40, + RAILROAD_ACTIVE_TRACK_MASK = 0xf00, + RAILROAD_ENTERED_DIR_MASK = 0xf000, +}; +extern const TileType RAILROAD_tile; +extern const TileType BUTTON_PURPLE_tile; +extern const TileType BUTTON_BLACK_tile; +extern const TileType BUTTON_GRAY_tile; +extern const TileType HOLD_WALL_tile; +extern const TileType TOGGLE_SWITCH_tile; +#define LGT(a, b) 0b##a##b +enum LogicGateTypes { + LOGIC_GATE_NOT_UP = LGT(0, 0101), + LOGIC_GATE_NOT_RIGHT = LGT(0, 1010), + LOGIC_GATE_NOT_DOWN = LGT(1, 0101), + LOGIC_GATE_NOT_LEFT = LGT(1, 1010), + + LOGIC_GATE_SPECIFIER_OR = 0b000, + LOGIC_GATE_OR_UP = LGT(000, 1011), + LOGIC_GATE_OR_RIGHT = LGT(000, 0111), + LOGIC_GATE_OR_DOWN = LGT(000, 1110), + LOGIC_GATE_OR_LEFT = LGT(000, 1101), + + LOGIC_GATE_SPECIFIER_AND = 0b001, + LOGIC_GATE_AND_UP = LGT(001, 1011), + LOGIC_GATE_AND_RIGHT = LGT(001, 0111), + LOGIC_GATE_AND_DOWN = LGT(001, 1110), + LOGIC_GATE_AND_LEFT = LGT(001, 1101), + + LOGIC_GATE_SPECIFIER_NAND = 0b010, + LOGIC_GATE_NAND_UP = LGT(010, 1011), + LOGIC_GATE_NAND_RIGHT = LGT(010, 0111), + LOGIC_GATE_NAND_DOWN = LGT(010, 1110), + LOGIC_GATE_NAND_LEFT = LGT(010, 1101), + + LOGIC_GATE_SPECIFIER_XOR = 0b011, + LOGIC_GATE_XOR_UP = LGT(011, 1011), + LOGIC_GATE_XOR_RIGHT = LGT(011, 0111), + LOGIC_GATE_XOR_DOWN = LGT(011, 1110), + LOGIC_GATE_XOR_LEFT = LGT(011, 1101), + + LOGIC_GATE_SPECIFIER_LATCH = 0b100, + LOGIC_GATE_LATCH_UP = LGT(100, 1011), + LOGIC_GATE_LATCH_RIGHT = LGT(100, 0111), + LOGIC_GATE_LATCH_DOWN = LGT(100, 1110), + LOGIC_GATE_LATCH_LEFT = LGT(100, 1101), + + LOGIC_GATE_SPECIFIER_LATCH_MIRROR = 0b101, + LOGIC_GATE_LATCH_MIRROR_UP = LGT(101, 1011), + LOGIC_GATE_LATCH_MIRROR_RIGHT = LGT(101, 0111), + LOGIC_GATE_LATCH_MIRROR_DOWN = LGT(101, 1110), + LOGIC_GATE_LATCH_MIRROR_LEFT = LGT(101, 1101), + + LOGIC_GATE_SPECIFIER_BITMASK = 0b01110000, + + LOGIC_GATE_COUNTER_0 = LGT(0000, 1111), + LOGIC_GATE_COUNTER_1 = LGT(0001, 1111), + LOGIC_GATE_COUNTER_2 = LGT(0010, 1111), + LOGIC_GATE_COUNTER_3 = LGT(0011, 1111), + LOGIC_GATE_COUNTER_4 = LGT(0100, 1111), + LOGIC_GATE_COUNTER_5 = LGT(0101, 1111), + LOGIC_GATE_COUNTER_6 = LGT(0110, 1111), + LOGIC_GATE_COUNTER_7 = LGT(0111, 1111), + LOGIC_GATE_COUNTER_8 = LGT(1000, 1111), + LOGIC_GATE_COUNTER_9 = LGT(1001, 1111), +}; +#undef LGT +extern const TileType LOGIC_GATE_tile; +#endif diff --git a/libnotcc/src/wires.c b/libnotcc/src/wires.c new file mode 100644 index 00000000..b12768e5 --- /dev/null +++ b/libnotcc/src/wires.c @@ -0,0 +1,338 @@ +#include "logic.h" +#include "misc.h" +#include "tiles.h" + +DEFINE_VECTOR(WireNetworkMember); +DEFINE_VECTOR(WireNetwork); +DEFINE_VECTOR(Position); + +static bool match_pos(void* target_pos_v, const Position* pos) { + Position* target_pos = target_pos_v; + return pos->x == target_pos->x && pos->y == target_pos->y; +} +static bool match_member_pos(void* target_pos_v, + const WireNetworkMember* member) { + return match_pos(target_pos_v, &member->pos); +} + +WireNetworkMember* Vector_WireNetworkMember_get_member( + Vector_WireNetworkMember* self, + Position pos) { + WireNetworkMember* memb = + Vector_WireNetworkMember_search(self, match_member_pos, (void*)&pos); + if (memb) + return memb; + Vector_WireNetworkMember_push(self, + (WireNetworkMember){.pos = pos, .wires = 0}); + return &self->items[self->length - 1]; +} +typedef struct ToTrace { + Position pos; + Direction dir; + bool allow_tunnel; +} ToTrace; + +uint8_t BasicTile_get_wire_tunnels(const BasicTile* self) { + return self->type == &FLOOR_tile ? (self->custom_data & 0xf0) >> 4 : 0; +} +static inline uint8_t dir_to_wire(Direction dir) { + return 1 << dir_to_cc2(dir); +} + +uint8_t BasicTile_get_connected_wires(const BasicTile* self, + Direction checking_from, + bool allow_tunnels) { + WireType wire_type = self->type->wire_type; + uint8_t wires = self->custom_data & 0xf; + uint8_t wire_tunnels = BasicTile_get_wire_tunnels(self); + uint8_t exposed_wires = allow_tunnels ? wires : wires & ~wire_tunnels; + uint8_t connecting_wire = dir_to_wire(checking_from); + if (wire_type == WIRES_NONE) + return 0; + if (wire_type == WIRES_READ) + return connecting_wire; + if (!(exposed_wires & connecting_wire)) + return 0; + if (wire_type == WIRES_UNCONNECTED) + return connecting_wire; + if (wire_type == WIRES_CROSS && wires != 0xf) + return wires; + if (wire_type == WIRES_ALWAYS_CROSS || + (wire_type == WIRES_CROSS && wires == 0xf)) + return (connecting_wire & 0b0101 ? 0b0101 : 0b1010) & wires; + if (wire_type == WIRES_EVERYWHERE) + return wires; + // Shouldn't ever happen + return 0; +} + +Position Level_find_matching_wire_tunnel(Level* self, + Position init_pos, + Direction init_dir) { + uint8_t nested_level = 0; + uint8_t open_tunnel = dir_to_wire(init_dir); + uint8_t close_tunnel = dir_to_wire(back(init_dir)); + Position pos = init_pos; + while (Level_check_position_inbounds(self, pos, init_dir, false)) { + pos = Level_get_neighbor(self, pos, init_dir); + const BasicTile* terrain = &Level_get_cell(self, pos)->terrain; + uint8_t tunnels = BasicTile_get_wire_tunnels(terrain); + if (tunnels & close_tunnel) { + if (nested_level == 0) + return pos; + nested_level -= 1; + } + if (tunnels & open_tunnel) { + nested_level += 1; + } + } + return (Position){255, 255}; +} + +DEFINE_VECTOR_STATIC(ToTrace); + +int8_t compare_pos_in_reading_order(const Position* left, + const Position* right) { + if (left->y > right->y) + return 1; + if (left->y < right->y) + return -1; + if (left->x > right->x) + return 1; + if (left->x < right->x) + return -1; + return 0; +} + +int8_t compare_wire_membs_in_reading_order(const void* target_pos_v, + const WireNetworkMember* memb) { + Position target_pos = *(Position*)target_pos_v; + Position pos = memb->pos; + if (pos.y > target_pos.y) + return 1; + else if (pos.y < target_pos.y) + return -1; + if (pos.x > target_pos.x) + return 1; + else if (pos.x < target_pos.x) + return -1; + return 0; +} +static int compare_membs_in_reading_order_qsort(const void* left_v, + const void* right_v) { + return compare_wire_membs_in_reading_order((const WireNetworkMember*)right_v, + left_v); +} + +WireNetwork Level_trace_wires_in_dir(Level* self, + Position init_pos, + Direction init_dir, + Vector_Position* wire_consumers) { + WireNetwork network = {}; + Vector_ToTrace to_trace_vec = Vector_ToTrace_init(4); + Vector_ToTrace_push(&to_trace_vec, (ToTrace){init_pos, back(init_dir), true}); + bool initial_trace = true; + while (to_trace_vec.length > 0) { + ToTrace tracing = Vector_ToTrace_pop(&to_trace_vec); + Cell* cell = Level_get_cell(self, tracing.pos); + const BasicTile* terrain = &cell->terrain; + uint8_t wires = BasicTile_get_connected_wires(terrain, back(tracing.dir), + tracing.allow_tunnel); + if (wires == 0) { + if (initial_trace) { + Vector_ToTrace_uninit(&to_trace_vec); + return network; + } + continue; + } + // HACK: If we're WIRES_READ, don't actually create a network unless the + // neighbor is a real (read: non-WIRES_READ) wired thing + if (initial_trace && terrain->type->wire_type == WIRES_READ) { + bool failed_to_exist = false; + if (!Level_check_position_inbounds(self, tracing.pos, back(tracing.dir), + true)) { + failed_to_exist = true; + } else { + Position neigh_pos = + Level_get_neighbor(self, tracing.pos, back(tracing.dir)); + Cell* neigh_cell = Level_get_cell(self, neigh_pos); + BasicTile* neigh = &neigh_cell->terrain; + if (neigh->type->wire_type == WIRES_NONE || + neigh->type->wire_type == WIRES_READ || + !BasicTile_get_connected_wires(neigh, tracing.dir, false)) { + failed_to_exist = true; + } + } + if (failed_to_exist) { + Vector_ToTrace_uninit(&to_trace_vec); + return network; + } + } + initial_trace = false; + cell->is_wired = true; + + WireNetworkMember* memb = + Vector_WireNetworkMember_get_member(&network.members, tracing.pos); + memb->wires |= wires; + + if (terrain->type->give_power) { + WireNetworkMember* emit_memb = + Vector_WireNetworkMember_get_member(&network.emitters, tracing.pos); + emit_memb->wires |= wires; + } + + if ((terrain->type->on_wire_high || terrain->type->on_wire_low || + terrain->type->receive_power) && + wire_consumers && + !Vector_Position_search(wire_consumers, match_pos, + (void*)&tracing.pos)) { + Vector_Position_push(wire_consumers, tracing.pos); + } + + uint8_t tunnels = BasicTile_get_wire_tunnels(terrain); + for (Direction dir = DIRECTION_UP; dir <= DIRECTION_LEFT; dir += 1) { + uint8_t dir_wire = dir_to_wire(dir); + if (!(dir_wire & wires)) + continue; + bool is_tunnel = dir_wire & tunnels; + bool has_neighbor = false; + Position neigh; + if (is_tunnel) { + neigh = Level_find_matching_wire_tunnel(self, tracing.pos, dir); + has_neighbor = neigh.x != 255; + } else { + has_neighbor = + Level_check_position_inbounds(self, tracing.pos, dir, true); + if (has_neighbor) { + neigh = Level_get_neighbor(self, tracing.pos, dir); + } + } + if (!has_neighbor) + continue; + WireNetworkMember* neigh_memb = Vector_WireNetworkMember_search( + &network.members, match_member_pos, (void*)&neigh); + if (neigh_memb && (neigh_memb->wires & dir_to_wire(back(dir)))) + continue; + Vector_ToTrace_push(&to_trace_vec, (ToTrace){neigh, dir, is_tunnel}); + } + } + Vector_ToTrace_uninit(&to_trace_vec); + Vector_WireNetworkMember_shrink_to_fit(&network.members); + Vector_WireNetworkMember_sort(&network.members, + compare_membs_in_reading_order_qsort); + Vector_WireNetworkMember_shrink_to_fit(&network.emitters); + return network; +} + +bool match_member_wire(void* target_memb_v, const WireNetworkMember* memb) { + WireNetworkMember* target_memb = target_memb_v; + return match_pos(&target_memb->pos, &memb->pos) && + (memb->wires & target_memb->wires); +} +bool match_network_member_wire(void* target_memb_v, + const WireNetwork* network) { + return !!Vector_WireNetworkMember_search(&network->members, match_member_wire, + target_memb_v); +} + +static int compare_pos_in_reverse_reading_order(const void* left_v, + const void* right_v) { + const Position* left = left_v; + const Position* right = right_v; + return -compare_pos_in_reading_order(left, right); +} + +void Level_init_wires(Level* self) { + self->wire_consumers = Vector_Position_init(30); + self->wire_networks = Vector_WireNetwork_init(5); + for (uint8_t y = 0; y < self->height; y += 1) { + for (uint8_t x = 0; x < self->width; x += 1) { + const BasicTile* terrain = + &Level_get_cell(self, (Position){x, y})->terrain; + if (terrain->type->wire_type == WIRES_NONE) + continue; + for (Direction dir = DIRECTION_UP; dir <= DIRECTION_LEFT; dir += 1) { + WireNetworkMember target_memb = {.pos = {x, y}, + .wires = dir_to_wire(dir)}; + void* existing_network = Vector_WireNetwork_search( + &self->wire_networks, match_network_member_wire, + (void*)&target_memb); + if (existing_network) + continue; + WireNetwork network = Level_trace_wires_in_dir( + self, target_memb.pos, dir, &self->wire_consumers); + if (network.members.length == 0) + continue; + Vector_WireNetwork_push(&self->wire_networks, network); + } + } + } + Vector_Position_shrink_to_fit(&self->wire_consumers); + Vector_Position_sort(&self->wire_consumers, + compare_pos_in_reverse_reading_order); + Vector_WireNetwork_shrink_to_fit(&self->wire_networks); +} + +void Level_do_wire_notification(Level* self) { + for_vector(Position*, output_pos, &self->wire_consumers) { + Cell* cell = Level_get_cell(self, *output_pos); + BasicTile* terrain = &cell->terrain; + if (terrain->type->receive_power) { + terrain->type->receive_power(terrain, self, cell->powered_wires); + } + + if (terrain->type->on_wire_high && cell->powered_wires && + !cell->was_powered) { + terrain->type->on_wire_high(terrain, self, true); + } + + if (terrain->type->on_wire_low && !cell->powered_wires && + cell->was_powered) { + terrain->type->on_wire_low(terrain, self, true); + } + } +} + +static inline void Cell_recalculate_power(Cell* self, Level* level) { + if (self->terrain.type->give_power) { + self->_intratick_powering_wires = + self->terrain.type->give_power(&self->terrain, level); + } else { + self->_intratick_powering_wires = 0; + } + self->was_powered = self->powered_wires; + self->powered_wires = 0; +} + +void Level_do_wire_propagation(Level* self) { + bool wire_tick_parity = (1 + self->current_subtick + self->current_tick) % 2; + + for_vector(WireNetwork*, network, &self->wire_networks) { + bool is_powered = false; + if (network->force_power_this_subtick) { + network->force_power_this_subtick = false; + is_powered = true; + } else { + for_vector(WireNetworkMember*, memb, &network->emitters) { + Cell* cell = Level_get_cell(self, memb->pos); + if (cell->_intratick_last_wire_tick_parity != wire_tick_parity) { + cell->_intratick_last_wire_tick_parity = wire_tick_parity; + Cell_recalculate_power(cell, self); + } + if (cell->_intratick_powering_wires & memb->wires) { + is_powered = true; + break; + } + } + } + for_vector(WireNetworkMember*, memb, &network->members) { + Cell* cell = Level_get_cell(self, memb->pos); + if (cell->_intratick_last_wire_tick_parity != wire_tick_parity) { + cell->_intratick_last_wire_tick_parity = wire_tick_parity; + Cell_recalculate_power(cell, self); + } + if (is_powered) + cell->powered_wires |= memb->wires; + } + } +} diff --git a/libnotcc/wasm-remove-shared-ld.sh b/libnotcc/wasm-remove-shared-ld.sh new file mode 100755 index 00000000..96adcf49 --- /dev/null +++ b/libnotcc/wasm-remove-shared-ld.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +# Strip the `-shared` argument since we don't want a shared library, but CMake doesn't have the concept of a non-shared non-archived library, so tell it we're making a shared library, but actually tell the linker to not do that +arguments=( $@ ) +exec wasm-ld ${arguments[@]/-shared} diff --git a/libnotcc/wasm-std/include/assert.h b/libnotcc/wasm-std/include/assert.h new file mode 100644 index 00000000..d55aba78 --- /dev/null +++ b/libnotcc/wasm-std/include/assert.h @@ -0,0 +1,8 @@ +#include +void _wasmstd_assert(_Bool assertion); +#ifdef NDEBUG +#define assert(...) +#else +#define assert _wasmstd_assert +#endif +#define static_assert _Static_assert diff --git a/libnotcc/wasm-std/include/errno.h b/libnotcc/wasm-std/include/errno.h new file mode 100644 index 00000000..d7c1d286 --- /dev/null +++ b/libnotcc/wasm-std/include/errno.h @@ -0,0 +1,4 @@ +#define EINVAL 22 +#define ENOMEM 12 + +static int errno = 0; diff --git a/libnotcc/wasm-std/include/inttypes.h b/libnotcc/wasm-std/include/inttypes.h new file mode 100644 index 00000000..4ec6ed84 --- /dev/null +++ b/libnotcc/wasm-std/include/inttypes.h @@ -0,0 +1,8 @@ +#include "stdint.h" + +typedef uint8_t uint_fast8_t; +typedef int8_t int_fast8_t; +typedef uint16_t uint_fast16_t; +typedef int16_t int_fast16_t; +typedef uint64_t uint_fast64_t; +typedef int64_t int_fast64_t; diff --git a/libnotcc/wasm-std/include/math.h b/libnotcc/wasm-std/include/math.h new file mode 100644 index 00000000..cc665e95 --- /dev/null +++ b/libnotcc/wasm-std/include/math.h @@ -0,0 +1,2 @@ +extern float fabsf(float v); +#define INFINITY __builtin_inff() diff --git a/libnotcc/wasm-std/include/stdarg.h b/libnotcc/wasm-std/include/stdarg.h new file mode 100644 index 00000000..efdd1f9b --- /dev/null +++ b/libnotcc/wasm-std/include/stdarg.h @@ -0,0 +1,4 @@ +#define va_start(v, l) __builtin_va_start(v, l) +#define va_end(v) __builtin_va_end(v) +#define va_arg(v, l) __builtin_va_arg(v, l) +#define va_copy(d, s) __builtin_va_copy(d, s) diff --git a/libnotcc/wasm-std/include/stdint.h b/libnotcc/wasm-std/include/stdint.h new file mode 100644 index 00000000..6d8d1ca8 --- /dev/null +++ b/libnotcc/wasm-std/include/stdint.h @@ -0,0 +1,10 @@ +typedef signed char int8_t; +typedef unsigned char uint8_t; +typedef signed short int int16_t; +typedef unsigned short int uint16_t; +typedef signed int int32_t; +typedef unsigned int uint32_t; +typedef signed long long int int64_t; +typedef unsigned long long int uint64_t; +typedef unsigned long int uintptr_t; +typedef long int intptr_t; diff --git a/libnotcc/wasm-std/include/stdio.h b/libnotcc/wasm-std/include/stdio.h new file mode 100644 index 00000000..8f5a2056 --- /dev/null +++ b/libnotcc/wasm-std/include/stdio.h @@ -0,0 +1,12 @@ +#include +#include +extern int snprintf(char* __restrict __s, + size_t __maxlen, + const char* __restrict __format, + ...) __attribute__((__format__(__printf__, 3, 4))); + +extern int vsnprintf(char* __restrict __s, + size_t __maxlen, + const char* __restrict __format, + va_list __arg) + __attribute__((__format__(__printf__, 3, 0))); diff --git a/libnotcc/wasm-std/include/stdlib.h b/libnotcc/wasm-std/include/stdlib.h new file mode 100644 index 00000000..ed60b9b9 --- /dev/null +++ b/libnotcc/wasm-std/include/stdlib.h @@ -0,0 +1,15 @@ +#define NULL ((void*)0) +// typedef unsigned int size_t; +typedef __SIZE_TYPE__ size_t; +#define SIZE_MAX (4294967295UL) + +extern void* malloc(size_t size); +extern void* calloc(size_t num, size_t size); +extern void* realloc(void* ptr, size_t size); +extern void free(void* ptr); +extern long atol(const char* str); +[[noreturn]] extern void abort(); +extern void qsort(void* ptr, + size_t count, + size_t size, + int (*comp)(const void*, const void*)); diff --git a/libnotcc/wasm-std/include/string.h b/libnotcc/wasm-std/include/string.h new file mode 100644 index 00000000..cda3e1ef --- /dev/null +++ b/libnotcc/wasm-std/include/string.h @@ -0,0 +1,11 @@ +#include +extern void* memmove(void* dest, const void* src, size_t count); +extern size_t strlen(const char* str); +extern size_t strnlen(const char* str, size_t max_size); +extern char* strdup(const char* str); +extern char* strndup(const char* str, size_t max_size); +extern int memcmp(const void* __s1, const void* __s2, size_t __n); +extern void* memset(void* dest, int ch, size_t count); +extern void* memcpy(void* __restrict dest, + const void* __restrict src, + size_t count); diff --git a/libnotcc/wasm-std/include/unistd.h b/libnotcc/wasm-std/include/unistd.h new file mode 100644 index 00000000..8fb210ff --- /dev/null +++ b/libnotcc/wasm-std/include/unistd.h @@ -0,0 +1,4 @@ +#include +#include + +extern void* sbrk(intptr_t __delta); diff --git a/libnotcc/wasm-std/src/dlmalloc.c b/libnotcc/wasm-std/src/dlmalloc.c new file mode 100644 index 00000000..a44d51bd --- /dev/null +++ b/libnotcc/wasm-std/src/dlmalloc.c @@ -0,0 +1,6302 @@ +#include +#define LACKS_SYS_TYPES_H +#define LACKS_SYS_PARAM_H +#define LACKS_TIME_H +#define HAVE_MMAP 0 +#define UNSIGNED_MORECORE 1 +#define NO_MALLOC_STATS 1 +#define ABORT __builtin_unreachable() +#define USE_LOCKS 0 +#define MALLOC_ALIGNMENT (__alignof__(max_align_t)) + +/* +Copyright 2023 Doug Lea + +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. + +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. + +* Version 2.8.6 Wed Aug 29 06:57:58 2012 Doug Lea + Re-licensed 25 Sep 2023 with MIT-0 replacing obsolete CC0 + See https://opensource.org/license/mit-0/ + +* Quickstart + + This library is all in one file to simplify the most common usage: + ftp it, compile it (-O3), and link it into another program. All of + the compile-time options default to reasonable values for use on + most platforms. You might later want to step through various + compile-time and dynamic tuning options. + + For convenience, an include file for code using this malloc is at: + ftp://gee.cs.oswego.edu/pub/misc/malloc-2.8.6.h + You don't really need this .h file unless you call functions not + defined in your system include files. The .h file contains only the + excerpts from this file needed for using this malloc on ANSI C/C++ + systems, so long as you haven't changed compile-time options about + naming and tuning parameters. If you do, then you can create your + own malloc.h that does include all settings by cutting at the point + indicated below. Note that you may already by default be using a C + library containing a malloc that is based on some version of this + malloc (for example in linux). You might still want to use the one + in this file to customize settings or to avoid overheads associated + with library versions. + +* Vital statistics: + + Supported pointer/size_t representation: 4 or 8 bytes + size_t MUST be an unsigned type of the same width as + pointers. (If you are using an ancient system that declares + size_t as a signed type, or need it to be a different width + than pointers, you can use a previous release of this malloc + (e.g. 2.7.2) supporting these.) + + Alignment: 8 bytes (minimum) + This suffices for nearly all current machines and C compilers. + However, you can define MALLOC_ALIGNMENT to be wider than this + if necessary (up to 128bytes), at the expense of using more space. + + Minimum overhead per allocated chunk: 4 or 8 bytes (if 4byte sizes) + 8 or 16 bytes (if 8byte sizes) + Each malloced chunk has a hidden word of overhead holding size + and status information, and additional cross-check word + if FOOTERS is defined. + + Minimum allocated size: 4-byte ptrs: 16 bytes (including overhead) + 8-byte ptrs: 32 bytes (including overhead) + + Even a request for zero bytes (i.e., malloc(0)) returns a + pointer to something of the minimum allocatable size. + The maximum overhead wastage (i.e., number of extra bytes + allocated than were requested in malloc) is less than or equal + to the minimum size, except for requests >= mmap_threshold that + are serviced via mmap(), where the worst case wastage is about + 32 bytes plus the remainder from a system page (the minimal + mmap unit); typically 4096 or 8192 bytes. + + Security: static-safe; optionally more or less + The "security" of malloc refers to the ability of malicious + code to accentuate the effects of errors (for example, freeing + space that is not currently malloc'ed or overwriting past the + ends of chunks) in code that calls malloc. This malloc + guarantees not to modify any memory locations below the base of + heap, i.e., static variables, even in the presence of usage + errors. The routines additionally detect most improper frees + and reallocs. All this holds as long as the static bookkeeping + for malloc itself is not corrupted by some other means. This + is only one aspect of security -- these checks do not, and + cannot, detect all possible programming errors. + + If FOOTERS is defined nonzero, then each allocated chunk + carries an additional check word to verify that it was malloced + from its space. These check words are the same within each + execution of a program using malloc, but differ across + executions, so externally crafted fake chunks cannot be + freed. This improves security by rejecting frees/reallocs that + could corrupt heap memory, in addition to the checks preventing + writes to statics that are always on. This may further improve + security at the expense of time and space overhead. (Note that + FOOTERS may also be worth using with MSPACES.) + + By default detected errors cause the program to abort (calling + "abort()"). You can override this to instead proceed past + errors by defining PROCEED_ON_ERROR. In this case, a bad free + has no effect, and a malloc that encounters a bad address + caused by user overwrites will ignore the bad address by + dropping pointers and indices to all known memory. This may + be appropriate for programs that should continue if at all + possible in the face of programming errors, although they may + run out of memory because dropped memory is never reclaimed. + + If you don't like either of these options, you can define + CORRUPTION_ERROR_ACTION and USAGE_ERROR_ACTION to do anything + else. And if if you are sure that your program using malloc has + no errors or vulnerabilities, you can define INSECURE to 1, + which might (or might not) provide a small performance improvement. + + It is also possible to limit the maximum total allocatable + space, using malloc_set_footprint_limit. This is not + designed as a security feature in itself (calls to set limits + are not screened or privileged), but may be useful as one + aspect of a secure implementation. + + Thread-safety: NOT thread-safe unless USE_LOCKS defined non-zero + When USE_LOCKS is defined, each public call to malloc, free, + etc is surrounded with a lock. By default, this uses a plain + pthread mutex, win32 critical section, or a spin-lock if if + available for the platform and not disabled by setting + USE_SPIN_LOCKS=0. However, if USE_RECURSIVE_LOCKS is defined, + recursive versions are used instead (which are not required for + base functionality but may be needed in layered extensions). + Using a global lock is not especially fast, and can be a major + bottleneck. It is designed only to provide minimal protection + in concurrent environments, and to provide a basis for + extensions. If you are using malloc in a concurrent program, + consider instead using nedmalloc + (http://www.nedprod.com/programs/portable/nedmalloc/) or + ptmalloc (See http://www.malloc.de), which are derived from + versions of this malloc. + + System requirements: Any combination of MORECORE and/or MMAP/MUNMAP + This malloc can use unix sbrk or any emulation (invoked using + the CALL_MORECORE macro) and/or mmap/munmap or any emulation + (invoked using CALL_MMAP/CALL_MUNMAP) to get and release system + memory. On most unix systems, it tends to work best if both + MORECORE and MMAP are enabled. On Win32, it uses emulations + based on VirtualAlloc. It also uses common C library functions + like memset. + + Compliance: I believe it is compliant with the Single Unix Specification + (See http://www.unix.org). Also SVID/XPG, ANSI C, and probably + others as well. + +* Overview of algorithms + + This is not the fastest, most space-conserving, most portable, or + most tunable malloc ever written. However it is among the fastest + while also being among the most space-conserving, portable and + tunable. Consistent balance across these factors results in a good + general-purpose allocator for malloc-intensive programs. + + In most ways, this malloc is a best-fit allocator. Generally, it + chooses the best-fitting existing chunk for a request, with ties + broken in approximately least-recently-used order. (This strategy + normally maintains low fragmentation.) However, for requests less + than 256bytes, it deviates from best-fit when there is not an + exactly fitting available chunk by preferring to use space adjacent + to that used for the previous small request, as well as by breaking + ties in approximately most-recently-used order. (These enhance + locality of series of small allocations.) And for very large requests + (>= 256Kb by default), it relies on system memory mapping + facilities, if supported. (This helps avoid carrying around and + possibly fragmenting memory used only for large chunks.) + + All operations (except malloc_stats and mallinfo) have execution + times that are bounded by a constant factor of the number of bits in + a size_t, not counting any clearing in calloc or copying in realloc, + or actions surrounding MORECORE and MMAP that have times + proportional to the number of non-contiguous regions returned by + system allocation routines, which is often just 1. In real-time + applications, you can optionally suppress segment traversals using + NO_SEGMENT_TRAVERSAL, which assures bounded execution even when + system allocators return non-contiguous spaces, at the typical + expense of carrying around more memory and increased fragmentation. + + The implementation is not very modular and seriously overuses + macros. Perhaps someday all C compilers will do as good a job + inlining modular code as can now be done by brute-force expansion, + but now, enough of them seem not to. + + Some compilers issue a lot of warnings about code that is + dead/unreachable only on some platforms, and also about intentional + uses of negation on unsigned types. All known cases of each can be + ignored. + + For a longer but out of date high-level description, see + http://gee.cs.oswego.edu/dl/html/malloc.html + +* MSPACES + If MSPACES is defined, then in addition to malloc, free, etc., + this file also defines mspace_malloc, mspace_free, etc. These + are versions of malloc routines that take an "mspace" argument + obtained using create_mspace, to control all internal bookkeeping. + If ONLY_MSPACES is defined, only these versions are compiled. + So if you would like to use this allocator for only some allocations, + and your system malloc for others, you can compile with + ONLY_MSPACES and then do something like... + static mspace mymspace = create_mspace(0,0); // for example + #define mymalloc(bytes) mspace_malloc(mymspace, bytes) + + (Note: If you only need one instance of an mspace, you can instead + use "USE_DL_PREFIX" to relabel the global malloc.) + + You can similarly create thread-local allocators by storing + mspaces as thread-locals. For example: + static __thread mspace tlms = 0; + void* tlmalloc(size_t bytes) { + if (tlms == 0) tlms = create_mspace(0, 0); + return mspace_malloc(tlms, bytes); + } + void tlfree(void* mem) { mspace_free(tlms, mem); } + + Unless FOOTERS is defined, each mspace is completely independent. + You cannot allocate from one and free to another (although + conformance is only weakly checked, so usage errors are not always + caught). If FOOTERS is defined, then each chunk carries around a tag + indicating its originating mspace, and frees are directed to their + originating spaces. Normally, this requires use of locks. + + ------------------------- Compile-time options --------------------------- + +Be careful in setting #define values for numerical constants of type +size_t. On some systems, literal values are not automatically extended +to size_t precision unless they are explicitly casted. You can also +use the symbolic values MAX_SIZE_T, SIZE_T_ONE, etc below. + +WIN32 default: defined if _WIN32 defined + Defining WIN32 sets up defaults for MS environment and compilers. + Otherwise defaults are for unix. Beware that there seem to be some + cases where this malloc might not be a pure drop-in replacement for + Win32 malloc: Random-looking failures from Win32 GDI API's (eg; + SetDIBits()) may be due to bugs in some video driver implementations + when pixel buffers are malloc()ed, and the region spans more than + one VirtualAlloc()ed region. Because dlmalloc uses a small (64Kb) + default granularity, pixel buffers may straddle virtual allocation + regions more often than when using the Microsoft allocator. You can + avoid this by using VirtualAlloc() and VirtualFree() for all pixel + buffers rather than using malloc(). If this is not possible, + recompile this malloc with a larger DEFAULT_GRANULARITY. Note: + in cases where MSC and gcc (cygwin) are known to differ on WIN32, + conditions use _MSC_VER to distinguish them. + +DLMALLOC_EXPORT default: extern + Defines how public APIs are declared. If you want to export via a + Windows DLL, you might define this as + #define DLMALLOC_EXPORT extern __declspec(dllexport) + If you want a POSIX ELF shared object, you might use + #define DLMALLOC_EXPORT extern __attribute__((visibility("default"))) + +MALLOC_ALIGNMENT default: (size_t)(2 * sizeof(void *)) + Controls the minimum alignment for malloc'ed chunks. It must be a + power of two and at least 8, even on machines for which smaller + alignments would suffice. It may be defined as larger than this + though. Note however that code and data structures are optimized for + the case of 8-byte alignment. + +MSPACES default: 0 (false) + If true, compile in support for independent allocation spaces. + This is only supported if HAVE_MMAP is true. + +ONLY_MSPACES default: 0 (false) + If true, only compile in mspace versions, not regular versions. + +USE_LOCKS default: 0 (false) + Causes each call to each public routine to be surrounded with + pthread or WIN32 mutex lock/unlock. (If set true, this can be + overridden on a per-mspace basis for mspace versions.) If set to a + non-zero value other than 1, locks are used, but their + implementation is left out, so lock functions must be supplied manually, + as described below. + +USE_SPIN_LOCKS default: 1 iff USE_LOCKS and spin locks available + If true, uses custom spin locks for locking. This is currently + supported only gcc >= 4.1, older gccs on x86 platforms, and recent + MS compilers. Otherwise, posix locks or win32 critical sections are + used. + +USE_RECURSIVE_LOCKS default: not defined + If defined nonzero, uses recursive (aka reentrant) locks, otherwise + uses plain mutexes. This is not required for malloc proper, but may + be needed for layered allocators such as nedmalloc. + +LOCK_AT_FORK default: not defined + If defined nonzero, performs pthread_atfork upon initialization + to initialize child lock while holding parent lock. The implementation + assumes that pthread locks (not custom locks) are being used. In other + cases, you may need to customize the implementation. + +FOOTERS default: 0 + If true, provide extra checking and dispatching by placing + information in the footers of allocated chunks. This adds + space and time overhead. + +INSECURE default: 0 + If true, omit checks for usage errors and heap space overwrites. + +USE_DL_PREFIX default: NOT defined + Causes compiler to prefix all public routines with the string 'dl'. + This can be useful when you only want to use this malloc in one part + of a program, using your regular system malloc elsewhere. + +MALLOC_INSPECT_ALL default: NOT defined + If defined, compiles malloc_inspect_all and mspace_inspect_all, that + perform traversal of all heap space. Unless access to these + functions is otherwise restricted, you probably do not want to + include them in secure implementations. + +ABORT default: defined as abort() + Defines how to abort on failed checks. On most systems, a failed + check cannot die with an "assert" or even print an informative + message, because the underlying print routines in turn call malloc, + which will fail again. Generally, the best policy is to simply call + abort(). It's not very useful to do more than this because many + errors due to overwriting will show up as address faults (null, odd + addresses etc) rather than malloc-triggered checks, so will also + abort. Also, most compilers know that abort() does not return, so + can better optimize code conditionally calling it. + +PROCEED_ON_ERROR default: defined as 0 (false) + Controls whether detected bad addresses cause them to bypassed + rather than aborting. If set, detected bad arguments to free and + realloc are ignored. And all bookkeeping information is zeroed out + upon a detected overwrite of freed heap space, thus losing the + ability to ever return it from malloc again, but enabling the + application to proceed. If PROCEED_ON_ERROR is defined, the + static variable malloc_corruption_error_count is compiled in + and can be examined to see if errors have occurred. This option + generates slower code than the default abort policy. + +DEBUG default: NOT defined + The DEBUG setting is mainly intended for people trying to modify + this code or diagnose problems when porting to new platforms. + However, it may also be able to better isolate user errors than just + using runtime checks. The assertions in the check routines spell + out in more detail the assumptions and invariants underlying the + algorithms. The checking is fairly extensive, and will slow down + execution noticeably. Calling malloc_stats or mallinfo with DEBUG + set will attempt to check every non-mmapped allocated and free chunk + in the course of computing the summaries. + +ABORT_ON_ASSERT_FAILURE default: defined as 1 (true) + Debugging assertion failures can be nearly impossible if your + version of the assert macro causes malloc to be called, which will + lead to a cascade of further failures, blowing the runtime stack. + ABORT_ON_ASSERT_FAILURE cause assertions failures to call abort(), + which will usually make debugging easier. + +MALLOC_FAILURE_ACTION default: sets errno to ENOMEM, or no-op on win32 + The action to take before "return 0" when malloc fails to be able to + return memory because there is none available. + +HAVE_MORECORE default: 1 (true) unless win32 or ONLY_MSPACES + True if this system supports sbrk or an emulation of it. + +MORECORE default: sbrk + The name of the sbrk-style system routine to call to obtain more + memory. See below for guidance on writing custom MORECORE + functions. The type of the argument to sbrk/MORECORE varies across + systems. It cannot be size_t, because it supports negative + arguments, so it is normally the signed type of the same width as + size_t (sometimes declared as "intptr_t"). It doesn't much matter + though. Internally, we only call it with arguments less than half + the max value of a size_t, which should work across all reasonable + possibilities, although sometimes generating compiler warnings. + +MORECORE_CONTIGUOUS default: 1 (true) if HAVE_MORECORE + If true, take advantage of fact that consecutive calls to MORECORE + with positive arguments always return contiguous increasing + addresses. This is true of unix sbrk. It does not hurt too much to + set it true anyway, since malloc copes with non-contiguities. + Setting it false when definitely non-contiguous saves time + and possibly wasted space it would take to discover this though. + +MORECORE_CANNOT_TRIM default: NOT defined + True if MORECORE cannot release space back to the system when given + negative arguments. This is generally necessary only if you are + using a hand-crafted MORECORE function that cannot handle negative + arguments. + +NO_SEGMENT_TRAVERSAL default: 0 + If non-zero, suppresses traversals of memory segments + returned by either MORECORE or CALL_MMAP. This disables + merging of segments that are contiguous, and selectively + releasing them to the OS if unused, but bounds execution times. + +HAVE_MMAP default: 1 (true) + True if this system supports mmap or an emulation of it. If so, and + HAVE_MORECORE is not true, MMAP is used for all system + allocation. If set and HAVE_MORECORE is true as well, MMAP is + primarily used to directly allocate very large blocks. It is also + used as a backup strategy in cases where MORECORE fails to provide + space from system. Note: A single call to MUNMAP is assumed to be + able to unmap memory that may have be allocated using multiple calls + to MMAP, so long as they are adjacent. + +HAVE_MREMAP default: 1 on linux, else 0 + If true realloc() uses mremap() to re-allocate large blocks and + extend or shrink allocation spaces. + +MMAP_CLEARS default: 1 except on WINCE. + True if mmap clears memory so calloc doesn't need to. This is true + for standard unix mmap using /dev/zero and on WIN32 except for WINCE. + +USE_BUILTIN_FFS default: 0 (i.e., not used) + Causes malloc to use the builtin ffs() function to compute indices. + Some compilers may recognize and intrinsify ffs to be faster than the + supplied C version. Also, the case of x86 using gcc is special-cased + to an asm instruction, so is already as fast as it can be, and so + this setting has no effect. Similarly for Win32 under recent MS compilers. + (On most x86s, the asm version is only slightly faster than the C version.) + +malloc_getpagesize default: derive from system includes, or 4096. + The system page size. To the extent possible, this malloc manages + memory from the system in page-size units. This may be (and + usually is) a function rather than a constant. This is ignored + if WIN32, where page size is determined using getSystemInfo during + initialization. + +USE_DEV_RANDOM default: 0 (i.e., not used) + Causes malloc to use /dev/random to initialize secure magic seed for + stamping footers. Otherwise, the current time is used. + +NO_MALLINFO default: 0 + If defined, don't compile "mallinfo". This can be a simple way + of dealing with mismatches between system declarations and + those in this file. + +MALLINFO_FIELD_TYPE default: size_t + The type of the fields in the mallinfo struct. This was originally + defined as "int" in SVID etc, but is more usefully defined as + size_t. The value is used only if HAVE_USR_INCLUDE_MALLOC_H is not set + +NO_MALLOC_STATS default: 0 + If defined, don't compile "malloc_stats". This avoids calls to + fprintf and bringing in stdio dependencies you might not want. + +REALLOC_ZERO_BYTES_FREES default: not defined + This should be set if a call to realloc with zero bytes should + be the same as a call to free. Some people think it should. Otherwise, + since this malloc returns a unique pointer for malloc(0), so does + realloc(p, 0). + +LACKS_UNISTD_H, LACKS_FCNTL_H, LACKS_SYS_PARAM_H, LACKS_SYS_MMAN_H +LACKS_STRINGS_H, LACKS_STRING_H, LACKS_SYS_TYPES_H, LACKS_ERRNO_H +LACKS_STDLIB_H LACKS_SCHED_H LACKS_TIME_H default: NOT defined unless on WIN32 + Define these if your system does not have these header files. + You might need to manually insert some of the declarations they provide. + +DEFAULT_GRANULARITY default: page size if MORECORE_CONTIGUOUS, + system_info.dwAllocationGranularity in WIN32, + otherwise 64K. + Also settable using mallopt(M_GRANULARITY, x) + The unit for allocating and deallocating memory from the system. On + most systems with contiguous MORECORE, there is no reason to + make this more than a page. However, systems with MMAP tend to + either require or encourage larger granularities. You can increase + this value to prevent system allocation functions to be called so + often, especially if they are slow. The value must be at least one + page and must be a power of two. Setting to 0 causes initialization + to either page size or win32 region size. (Note: In previous + versions of malloc, the equivalent of this option was called + "TOP_PAD") + +DEFAULT_TRIM_THRESHOLD default: 2MB + Also settable using mallopt(M_TRIM_THRESHOLD, x) + The maximum amount of unused top-most memory to keep before + releasing via malloc_trim in free(). Automatic trimming is mainly + useful in long-lived programs using contiguous MORECORE. Because + trimming via sbrk can be slow on some systems, and can sometimes be + wasteful (in cases where programs immediately afterward allocate + more large chunks) the value should be high enough so that your + overall system performance would improve by releasing this much + memory. As a rough guide, you might set to a value close to the + average size of a process (program) running on your system. + Releasing this much memory would allow such a process to run in + memory. Generally, it is worth tuning trim thresholds when a + program undergoes phases where several large chunks are allocated + and released in ways that can reuse each other's storage, perhaps + mixed with phases where there are no such chunks at all. The trim + value must be greater than page size to have any useful effect. To + disable trimming completely, you can set to MAX_SIZE_T. Note that the trick + some people use of mallocing a huge space and then freeing it at + program startup, in an attempt to reserve system memory, doesn't + have the intended effect under automatic trimming, since that memory + will immediately be returned to the system. + +DEFAULT_MMAP_THRESHOLD default: 256K + Also settable using mallopt(M_MMAP_THRESHOLD, x) + The request size threshold for using MMAP to directly service a + request. Requests of at least this size that cannot be allocated + using already-existing space will be serviced via mmap. (If enough + normal freed space already exists it is used instead.) Using mmap + segregates relatively large chunks of memory so that they can be + individually obtained and released from the host system. A request + serviced through mmap is never reused by any other request (at least + not directly; the system may just so happen to remap successive + requests to the same locations). Segregating space in this way has + the benefits that: Mmapped space can always be individually released + back to the system, which helps keep the system level memory demands + of a long-lived program low. Also, mapped memory doesn't become + `locked' between other chunks, as can happen with normally allocated + chunks, which means that even trimming via malloc_trim would not + release them. However, it has the disadvantage that the space + cannot be reclaimed, consolidated, and then used to service later + requests, as happens with normal chunks. The advantages of mmap + nearly always outweigh disadvantages for "large" chunks, but the + value of "large" may vary across systems. The default is an + empirically derived value that works well in most systems. You can + disable mmap by setting to MAX_SIZE_T. + +MAX_RELEASE_CHECK_RATE default: 4095 unless not HAVE_MMAP + The number of consolidated frees between checks to release + unused segments when freeing. When using non-contiguous segments, + especially with multiple mspaces, checking only for topmost space + doesn't always suffice to trigger trimming. To compensate for this, + free() will, with a period of MAX_RELEASE_CHECK_RATE (or the + current number of segments, if greater) try to release unused + segments to the OS when freeing chunks that result in + consolidation. The best value for this parameter is a compromise + between slowing down frees with relatively costly checks that + rarely trigger versus holding on to unused memory. To effectively + disable, set to MAX_SIZE_T. This may lead to a very slight speed + improvement at the expense of carrying around more memory. +*/ + +/* Version identifier to allow people to support multiple versions */ +#ifndef DLMALLOC_VERSION +#define DLMALLOC_VERSION 20806 +#endif /* DLMALLOC_VERSION */ + +#ifndef DLMALLOC_EXPORT +#define DLMALLOC_EXPORT extern +#endif + +#ifndef WIN32 +#ifdef _WIN32 +#define WIN32 1 +#endif /* _WIN32 */ +#ifdef _WIN32_WCE +#define LACKS_FCNTL_H +#define WIN32 1 +#endif /* _WIN32_WCE */ +#endif /* WIN32 */ +#ifdef WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#define HAVE_MMAP 1 +#define HAVE_MORECORE 0 +#define LACKS_UNISTD_H +#define LACKS_SYS_PARAM_H +#define LACKS_SYS_MMAN_H +#define LACKS_STRING_H +#define LACKS_STRINGS_H +#define LACKS_SYS_TYPES_H +#define LACKS_ERRNO_H +#define LACKS_SCHED_H +#ifndef MALLOC_FAILURE_ACTION +#define MALLOC_FAILURE_ACTION +#endif /* MALLOC_FAILURE_ACTION */ +#ifndef MMAP_CLEARS +#ifdef _WIN32_WCE /* WINCE reportedly does not clear */ +#define MMAP_CLEARS 0 +#else +#define MMAP_CLEARS 1 +#endif /* _WIN32_WCE */ +#endif /*MMAP_CLEARS */ +#endif /* WIN32 */ + +#if defined(DARWIN) || defined(_DARWIN) +/* Mac OSX docs advise not to use sbrk; it seems better to use mmap */ +#ifndef HAVE_MORECORE +#define HAVE_MORECORE 0 +#define HAVE_MMAP 1 +/* OSX allocators provide 16 byte alignment */ +#ifndef MALLOC_ALIGNMENT +#define MALLOC_ALIGNMENT ((size_t)16U) +#endif +#endif /* HAVE_MORECORE */ +#endif /* DARWIN */ + +#ifndef LACKS_SYS_TYPES_H +#include /* For size_t */ +#endif /* LACKS_SYS_TYPES_H */ + +/* The maximum possible size_t value has all bits set */ +#define MAX_SIZE_T (~(size_t)0) + +#ifndef USE_LOCKS /* ensure true if spin or recursive locks set */ +#define USE_LOCKS ((defined(USE_SPIN_LOCKS) && USE_SPIN_LOCKS != 0) || \ + (defined(USE_RECURSIVE_LOCKS) && USE_RECURSIVE_LOCKS != 0)) +#endif /* USE_LOCKS */ + +#if USE_LOCKS /* Spin locks for gcc >= 4.1, older gcc on x86, MSC >= 1310 */ +#if ((defined(__GNUC__) && \ + ((__GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 1)) || \ + defined(__i386__) || defined(__x86_64__))) || \ + (defined(_MSC_VER) && _MSC_VER>=1310)) +#ifndef USE_SPIN_LOCKS +#define USE_SPIN_LOCKS 1 +#endif /* USE_SPIN_LOCKS */ +#elif USE_SPIN_LOCKS +#error "USE_SPIN_LOCKS defined without implementation" +#endif /* ... locks available... */ +#elif !defined(USE_SPIN_LOCKS) +#define USE_SPIN_LOCKS 0 +#endif /* USE_LOCKS */ + +#ifndef ONLY_MSPACES +#define ONLY_MSPACES 0 +#endif /* ONLY_MSPACES */ +#ifndef MSPACES +#if ONLY_MSPACES +#define MSPACES 1 +#else /* ONLY_MSPACES */ +#define MSPACES 0 +#endif /* ONLY_MSPACES */ +#endif /* MSPACES */ +#ifndef MALLOC_ALIGNMENT +#define MALLOC_ALIGNMENT ((size_t)(2 * sizeof(void *))) +#endif /* MALLOC_ALIGNMENT */ +#ifndef FOOTERS +#define FOOTERS 0 +#endif /* FOOTERS */ +#ifndef ABORT +#define ABORT abort() +#endif /* ABORT */ +#ifndef ABORT_ON_ASSERT_FAILURE +#define ABORT_ON_ASSERT_FAILURE 1 +#endif /* ABORT_ON_ASSERT_FAILURE */ +#ifndef PROCEED_ON_ERROR +#define PROCEED_ON_ERROR 0 +#endif /* PROCEED_ON_ERROR */ + +#ifndef INSECURE +#define INSECURE 0 +#endif /* INSECURE */ +#ifndef MALLOC_INSPECT_ALL +#define MALLOC_INSPECT_ALL 0 +#endif /* MALLOC_INSPECT_ALL */ +#ifndef HAVE_MMAP +#define HAVE_MMAP 1 +#endif /* HAVE_MMAP */ +#ifndef MMAP_CLEARS +#define MMAP_CLEARS 1 +#endif /* MMAP_CLEARS */ +#ifndef HAVE_MREMAP +#ifdef linux +#define HAVE_MREMAP 1 +#define _GNU_SOURCE /* Turns on mremap() definition */ +#else /* linux */ +#define HAVE_MREMAP 0 +#endif /* linux */ +#endif /* HAVE_MREMAP */ +#ifndef MALLOC_FAILURE_ACTION +#define MALLOC_FAILURE_ACTION errno = ENOMEM; +#endif /* MALLOC_FAILURE_ACTION */ +#ifndef HAVE_MORECORE +#if ONLY_MSPACES +#define HAVE_MORECORE 0 +#else /* ONLY_MSPACES */ +#define HAVE_MORECORE 1 +#endif /* ONLY_MSPACES */ +#endif /* HAVE_MORECORE */ +#if !HAVE_MORECORE +#define MORECORE_CONTIGUOUS 0 +#else /* !HAVE_MORECORE */ +#define MORECORE_DEFAULT sbrk +#ifndef MORECORE_CONTIGUOUS +#define MORECORE_CONTIGUOUS 1 +#endif /* MORECORE_CONTIGUOUS */ +#endif /* HAVE_MORECORE */ +#ifndef DEFAULT_GRANULARITY +#if (MORECORE_CONTIGUOUS || defined(WIN32)) +#define DEFAULT_GRANULARITY (0) /* 0 means to compute in init_mparams */ +#else /* MORECORE_CONTIGUOUS */ +#define DEFAULT_GRANULARITY ((size_t)64U * (size_t)1024U) +#endif /* MORECORE_CONTIGUOUS */ +#endif /* DEFAULT_GRANULARITY */ +#ifndef DEFAULT_TRIM_THRESHOLD +#ifndef MORECORE_CANNOT_TRIM +#define DEFAULT_TRIM_THRESHOLD ((size_t)2U * (size_t)1024U * (size_t)1024U) +#else /* MORECORE_CANNOT_TRIM */ +#define DEFAULT_TRIM_THRESHOLD MAX_SIZE_T +#endif /* MORECORE_CANNOT_TRIM */ +#endif /* DEFAULT_TRIM_THRESHOLD */ +#ifndef DEFAULT_MMAP_THRESHOLD +#if HAVE_MMAP +#define DEFAULT_MMAP_THRESHOLD ((size_t)256U * (size_t)1024U) +#else /* HAVE_MMAP */ +#define DEFAULT_MMAP_THRESHOLD MAX_SIZE_T +#endif /* HAVE_MMAP */ +#endif /* DEFAULT_MMAP_THRESHOLD */ +#ifndef MAX_RELEASE_CHECK_RATE +#if HAVE_MMAP +#define MAX_RELEASE_CHECK_RATE 4095 +#else +#define MAX_RELEASE_CHECK_RATE MAX_SIZE_T +#endif /* HAVE_MMAP */ +#endif /* MAX_RELEASE_CHECK_RATE */ +#ifndef USE_BUILTIN_FFS +#define USE_BUILTIN_FFS 0 +#endif /* USE_BUILTIN_FFS */ +#ifndef USE_DEV_RANDOM +#define USE_DEV_RANDOM 0 +#endif /* USE_DEV_RANDOM */ +#ifndef NO_MALLINFO +#define NO_MALLINFO 0 +#endif /* NO_MALLINFO */ +#ifndef MALLINFO_FIELD_TYPE +#define MALLINFO_FIELD_TYPE size_t +#endif /* MALLINFO_FIELD_TYPE */ +#ifndef NO_MALLOC_STATS +#define NO_MALLOC_STATS 0 +#endif /* NO_MALLOC_STATS */ +#ifndef NO_SEGMENT_TRAVERSAL +#define NO_SEGMENT_TRAVERSAL 0 +#endif /* NO_SEGMENT_TRAVERSAL */ + +/* + mallopt tuning options. SVID/XPG defines four standard parameter + numbers for mallopt, normally defined in malloc.h. None of these + are used in this malloc, so setting them has no effect. But this + malloc does support the following options. +*/ + +#define M_TRIM_THRESHOLD (-1) +#define M_GRANULARITY (-2) +#define M_MMAP_THRESHOLD (-3) + +/* ------------------------ Mallinfo declarations ------------------------ */ + +#if !NO_MALLINFO +/* + This version of malloc supports the standard SVID/XPG mallinfo + routine that returns a struct containing usage properties and + statistics. It should work on any system that has a + /usr/include/malloc.h defining struct mallinfo. The main + declaration needed is the mallinfo struct that is returned (by-copy) + by mallinfo(). The malloinfo struct contains a bunch of fields that + are not even meaningful in this version of malloc. These fields are + are instead filled by mallinfo() with other numbers that might be of + interest. + + HAVE_USR_INCLUDE_MALLOC_H should be set if you have a + /usr/include/malloc.h file that includes a declaration of struct + mallinfo. If so, it is included; else a compliant version is + declared below. These must be precisely the same for mallinfo() to + work. The original SVID version of this struct, defined on most + systems with mallinfo, declares all fields as ints. But some others + define as unsigned long. If your system defines the fields using a + type of different width than listed here, you MUST #include your + system version and #define HAVE_USR_INCLUDE_MALLOC_H. +*/ + +/* #define HAVE_USR_INCLUDE_MALLOC_H */ + +#ifdef HAVE_USR_INCLUDE_MALLOC_H +#include "/usr/include/malloc.h" +#else /* HAVE_USR_INCLUDE_MALLOC_H */ +#ifndef STRUCT_MALLINFO_DECLARED +/* HP-UX (and others?) redefines mallinfo unless _STRUCT_MALLINFO is defined */ +#define _STRUCT_MALLINFO +#define STRUCT_MALLINFO_DECLARED 1 +struct mallinfo { + MALLINFO_FIELD_TYPE arena; /* non-mmapped space allocated from system */ + MALLINFO_FIELD_TYPE ordblks; /* number of free chunks */ + MALLINFO_FIELD_TYPE smblks; /* always 0 */ + MALLINFO_FIELD_TYPE hblks; /* always 0 */ + MALLINFO_FIELD_TYPE hblkhd; /* space in mmapped regions */ + MALLINFO_FIELD_TYPE usmblks; /* maximum total allocated space */ + MALLINFO_FIELD_TYPE fsmblks; /* always 0 */ + MALLINFO_FIELD_TYPE uordblks; /* total allocated space */ + MALLINFO_FIELD_TYPE fordblks; /* total free space */ + MALLINFO_FIELD_TYPE keepcost; /* releasable (via malloc_trim) space */ +}; +#endif /* STRUCT_MALLINFO_DECLARED */ +#endif /* HAVE_USR_INCLUDE_MALLOC_H */ +#endif /* NO_MALLINFO */ + +/* + Try to persuade compilers to inline. The most critical functions for + inlining are defined as macros, so these aren't used for them. +*/ + +#ifndef FORCEINLINE + #if defined(__GNUC__) +#define FORCEINLINE __inline __attribute__ ((always_inline)) + #elif defined(_MSC_VER) + #define FORCEINLINE __forceinline + #endif +#endif +#ifndef NOINLINE + #if defined(__GNUC__) + #define NOINLINE __attribute__ ((noinline)) + #elif defined(_MSC_VER) + #define NOINLINE __declspec(noinline) + #else + #define NOINLINE + #endif +#endif + +#ifdef __cplusplus +extern "C" { +#ifndef FORCEINLINE + #define FORCEINLINE inline +#endif +#endif /* __cplusplus */ +#ifndef FORCEINLINE + #define FORCEINLINE +#endif + +#if !ONLY_MSPACES + +/* ------------------- Declarations of public routines ------------------- */ + +#ifndef USE_DL_PREFIX +#define dlcalloc calloc +#define dlfree free +#define dlmalloc malloc +#define dlmemalign memalign +#define dlposix_memalign posix_memalign +#define dlrealloc realloc +#define dlrealloc_in_place realloc_in_place +#define dlvalloc valloc +#define dlpvalloc pvalloc +#define dlmallinfo mallinfo +#define dlmallopt mallopt +#define dlmalloc_trim malloc_trim +#define dlmalloc_stats malloc_stats +#define dlmalloc_usable_size malloc_usable_size +#define dlmalloc_footprint malloc_footprint +#define dlmalloc_max_footprint malloc_max_footprint +#define dlmalloc_footprint_limit malloc_footprint_limit +#define dlmalloc_set_footprint_limit malloc_set_footprint_limit +#define dlmalloc_inspect_all malloc_inspect_all +#define dlindependent_calloc independent_calloc +#define dlindependent_comalloc independent_comalloc +#define dlbulk_free bulk_free +#endif /* USE_DL_PREFIX */ + +/* + malloc(size_t n) + Returns a pointer to a newly allocated chunk of at least n bytes, or + null if no space is available, in which case errno is set to ENOMEM + on ANSI C systems. + + If n is zero, malloc returns a minimum-sized chunk. (The minimum + size is 16 bytes on most 32bit systems, and 32 bytes on 64bit + systems.) Note that size_t is an unsigned type, so calls with + arguments that would be negative if signed are interpreted as + requests for huge amounts of space, which will often fail. The + maximum supported value of n differs across systems, but is in all + cases less than the maximum representable value of a size_t. +*/ +DLMALLOC_EXPORT void* dlmalloc(size_t); + +/* + free(void* p) + Releases the chunk of memory pointed to by p, that had been previously + allocated using malloc or a related routine such as realloc. + It has no effect if p is null. If p was not malloced or already + freed, free(p) will by default cause the current program to abort. +*/ +DLMALLOC_EXPORT void dlfree(void*); + +/* + calloc(size_t n_elements, size_t element_size); + Returns a pointer to n_elements * element_size bytes, with all locations + set to zero. +*/ +DLMALLOC_EXPORT void* dlcalloc(size_t, size_t); + +/* + realloc(void* p, size_t n) + Returns a pointer to a chunk of size n that contains the same data + as does chunk p up to the minimum of (n, p's size) bytes, or null + if no space is available. + + The returned pointer may or may not be the same as p. The algorithm + prefers extending p in most cases when possible, otherwise it + employs the equivalent of a malloc-copy-free sequence. + + If p is null, realloc is equivalent to malloc. + + If space is not available, realloc returns null, errno is set (if on + ANSI) and p is NOT freed. + + if n is for fewer bytes than already held by p, the newly unused + space is lopped off and freed if possible. realloc with a size + argument of zero (re)allocates a minimum-sized chunk. + + The old unix realloc convention of allowing the last-free'd chunk + to be used as an argument to realloc is not supported. +*/ +DLMALLOC_EXPORT void* dlrealloc(void*, size_t); + +/* + realloc_in_place(void* p, size_t n) + Resizes the space allocated for p to size n, only if this can be + done without moving p (i.e., only if there is adjacent space + available if n is greater than p's current allocated size, or n is + less than or equal to p's size). This may be used instead of plain + realloc if an alternative allocation strategy is needed upon failure + to expand space; for example, reallocation of a buffer that must be + memory-aligned or cleared. You can use realloc_in_place to trigger + these alternatives only when needed. + + Returns p if successful; otherwise null. +*/ +DLMALLOC_EXPORT void* dlrealloc_in_place(void*, size_t); + +/* + memalign(size_t alignment, size_t n); + Returns a pointer to a newly allocated chunk of n bytes, aligned + in accord with the alignment argument. + + The alignment argument should be a power of two. If the argument is + not a power of two, the nearest greater power is used. + 8-byte alignment is guaranteed by normal malloc calls, so don't + bother calling memalign with an argument of 8 or less. + + Overreliance on memalign is a sure way to fragment space. +*/ +DLMALLOC_EXPORT void* dlmemalign(size_t, size_t); + +/* + int posix_memalign(void** pp, size_t alignment, size_t n); + Allocates a chunk of n bytes, aligned in accord with the alignment + argument. Differs from memalign only in that it (1) assigns the + allocated memory to *pp rather than returning it, (2) fails and + returns EINVAL if the alignment is not a power of two (3) fails and + returns ENOMEM if memory cannot be allocated. +*/ +DLMALLOC_EXPORT int dlposix_memalign(void**, size_t, size_t); + +/* + valloc(size_t n); + Equivalent to memalign(pagesize, n), where pagesize is the page + size of the system. If the pagesize is unknown, 4096 is used. +*/ +DLMALLOC_EXPORT void* dlvalloc(size_t); + +/* + mallopt(int parameter_number, int parameter_value) + Sets tunable parameters The format is to provide a + (parameter-number, parameter-value) pair. mallopt then sets the + corresponding parameter to the argument value if it can (i.e., so + long as the value is meaningful), and returns 1 if successful else + 0. To workaround the fact that mallopt is specified to use int, + not size_t parameters, the value -1 is specially treated as the + maximum unsigned size_t value. + + SVID/XPG/ANSI defines four standard param numbers for mallopt, + normally defined in malloc.h. None of these are use in this malloc, + so setting them has no effect. But this malloc also supports other + options in mallopt. See below for details. Briefly, supported + parameters are as follows (listed defaults are for "typical" + configurations). + + Symbol param # default allowed param values + M_TRIM_THRESHOLD -1 2*1024*1024 any (-1 disables) + M_GRANULARITY -2 page size any power of 2 >= page size + M_MMAP_THRESHOLD -3 256*1024 any (or 0 if no MMAP support) +*/ +DLMALLOC_EXPORT int dlmallopt(int, int); + +/* + malloc_footprint(); + Returns the number of bytes obtained from the system. The total + number of bytes allocated by malloc, realloc etc., is less than this + value. Unlike mallinfo, this function returns only a precomputed + result, so can be called frequently to monitor memory consumption. + Even if locks are otherwise defined, this function does not use them, + so results might not be up to date. +*/ +DLMALLOC_EXPORT size_t dlmalloc_footprint(void); + +/* + malloc_max_footprint(); + Returns the maximum number of bytes obtained from the system. This + value will be greater than current footprint if deallocated space + has been reclaimed by the system. The peak number of bytes allocated + by malloc, realloc etc., is less than this value. Unlike mallinfo, + this function returns only a precomputed result, so can be called + frequently to monitor memory consumption. Even if locks are + otherwise defined, this function does not use them, so results might + not be up to date. +*/ +DLMALLOC_EXPORT size_t dlmalloc_max_footprint(void); + +/* + malloc_footprint_limit(); + Returns the number of bytes that the heap is allowed to obtain from + the system, returning the last value returned by + malloc_set_footprint_limit, or the maximum size_t value if + never set. The returned value reflects a permission. There is no + guarantee that this number of bytes can actually be obtained from + the system. +*/ +DLMALLOC_EXPORT size_t dlmalloc_footprint_limit(); + +/* + malloc_set_footprint_limit(); + Sets the maximum number of bytes to obtain from the system, causing + failure returns from malloc and related functions upon attempts to + exceed this value. The argument value may be subject to page + rounding to an enforceable limit; this actual value is returned. + Using an argument of the maximum possible size_t effectively + disables checks. If the argument is less than or equal to the + current malloc_footprint, then all future allocations that require + additional system memory will fail. However, invocation cannot + retroactively deallocate existing used memory. +*/ +DLMALLOC_EXPORT size_t dlmalloc_set_footprint_limit(size_t bytes); + +#if MALLOC_INSPECT_ALL +/* + malloc_inspect_all(void(*handler)(void *start, + void *end, + size_t used_bytes, + void* callback_arg), + void* arg); + Traverses the heap and calls the given handler for each managed + region, skipping all bytes that are (or may be) used for bookkeeping + purposes. Traversal does not include include chunks that have been + directly memory mapped. Each reported region begins at the start + address, and continues up to but not including the end address. The + first used_bytes of the region contain allocated data. If + used_bytes is zero, the region is unallocated. The handler is + invoked with the given callback argument. If locks are defined, they + are held during the entire traversal. It is a bad idea to invoke + other malloc functions from within the handler. + + For example, to count the number of in-use chunks with size greater + than 1000, you could write: + static int count = 0; + void count_chunks(void* start, void* end, size_t used, void* arg) { + if (used >= 1000) ++count; + } + then: + malloc_inspect_all(count_chunks, NULL); + + malloc_inspect_all is compiled only if MALLOC_INSPECT_ALL is defined. +*/ +DLMALLOC_EXPORT void dlmalloc_inspect_all(void(*handler)(void*, void *, size_t, void*), + void* arg); + +#endif /* MALLOC_INSPECT_ALL */ + +#if !NO_MALLINFO +/* + mallinfo() + Returns (by copy) a struct containing various summary statistics: + + arena: current total non-mmapped bytes allocated from system + ordblks: the number of free chunks + smblks: always zero. + hblks: current number of mmapped regions + hblkhd: total bytes held in mmapped regions + usmblks: the maximum total allocated space. This will be greater + than current total if trimming has occurred. + fsmblks: always zero + uordblks: current total allocated space (normal or mmapped) + fordblks: total free space + keepcost: the maximum number of bytes that could ideally be released + back to system via malloc_trim. ("ideally" means that + it ignores page restrictions etc.) + + Because these fields are ints, but internal bookkeeping may + be kept as longs, the reported values may wrap around zero and + thus be inaccurate. +*/ +DLMALLOC_EXPORT struct mallinfo dlmallinfo(void); +#endif /* NO_MALLINFO */ + +/* + independent_calloc(size_t n_elements, size_t element_size, void* chunks[]); + + independent_calloc is similar to calloc, but instead of returning a + single cleared space, it returns an array of pointers to n_elements + independent elements that can hold contents of size elem_size, each + of which starts out cleared, and can be independently freed, + realloc'ed etc. The elements are guaranteed to be adjacently + allocated (this is not guaranteed to occur with multiple callocs or + mallocs), which may also improve cache locality in some + applications. + + The "chunks" argument is optional (i.e., may be null, which is + probably the most typical usage). If it is null, the returned array + is itself dynamically allocated and should also be freed when it is + no longer needed. Otherwise, the chunks array must be of at least + n_elements in length. It is filled in with the pointers to the + chunks. + + In either case, independent_calloc returns this pointer array, or + null if the allocation failed. If n_elements is zero and "chunks" + is null, it returns a chunk representing an array with zero elements + (which should be freed if not wanted). + + Each element must be freed when it is no longer needed. This can be + done all at once using bulk_free. + + independent_calloc simplifies and speeds up implementations of many + kinds of pools. It may also be useful when constructing large data + structures that initially have a fixed number of fixed-sized nodes, + but the number is not known at compile time, and some of the nodes + may later need to be freed. For example: + + struct Node { int item; struct Node* next; }; + + struct Node* build_list() { + struct Node** pool; + int n = read_number_of_nodes_needed(); + if (n <= 0) return 0; + pool = (struct Node**)(independent_calloc(n, sizeof(struct Node), 0); + if (pool == 0) die(); + // organize into a linked list... + struct Node* first = pool[0]; + for (i = 0; i < n-1; ++i) + pool[i]->next = pool[i+1]; + free(pool); // Can now free the array (or not, if it is needed later) + return first; + } +*/ +DLMALLOC_EXPORT void** dlindependent_calloc(size_t, size_t, void**); + +/* + independent_comalloc(size_t n_elements, size_t sizes[], void* chunks[]); + + independent_comalloc allocates, all at once, a set of n_elements + chunks with sizes indicated in the "sizes" array. It returns + an array of pointers to these elements, each of which can be + independently freed, realloc'ed etc. The elements are guaranteed to + be adjacently allocated (this is not guaranteed to occur with + multiple callocs or mallocs), which may also improve cache locality + in some applications. + + The "chunks" argument is optional (i.e., may be null). If it is null + the returned array is itself dynamically allocated and should also + be freed when it is no longer needed. Otherwise, the chunks array + must be of at least n_elements in length. It is filled in with the + pointers to the chunks. + + In either case, independent_comalloc returns this pointer array, or + null if the allocation failed. If n_elements is zero and chunks is + null, it returns a chunk representing an array with zero elements + (which should be freed if not wanted). + + Each element must be freed when it is no longer needed. This can be + done all at once using bulk_free. + + independent_comallac differs from independent_calloc in that each + element may have a different size, and also that it does not + automatically clear elements. + + independent_comalloc can be used to speed up allocation in cases + where several structs or objects must always be allocated at the + same time. For example: + + struct Head { ... } + struct Foot { ... } + + void send_message(char* msg) { + int msglen = strlen(msg); + size_t sizes[3] = { sizeof(struct Head), msglen, sizeof(struct Foot) }; + void* chunks[3]; + if (independent_comalloc(3, sizes, chunks) == 0) + die(); + struct Head* head = (struct Head*)(chunks[0]); + char* body = (char*)(chunks[1]); + struct Foot* foot = (struct Foot*)(chunks[2]); + // ... + } + + In general though, independent_comalloc is worth using only for + larger values of n_elements. For small values, you probably won't + detect enough difference from series of malloc calls to bother. + + Overuse of independent_comalloc can increase overall memory usage, + since it cannot reuse existing noncontiguous small chunks that + might be available for some of the elements. +*/ +DLMALLOC_EXPORT void** dlindependent_comalloc(size_t, size_t*, void**); + +/* + bulk_free(void* array[], size_t n_elements) + Frees and clears (sets to null) each non-null pointer in the given + array. This is likely to be faster than freeing them one-by-one. + If footers are used, pointers that have been allocated in different + mspaces are not freed or cleared, and the count of all such pointers + is returned. For large arrays of pointers with poor locality, it + may be worthwhile to sort this array before calling bulk_free. +*/ +DLMALLOC_EXPORT size_t dlbulk_free(void**, size_t n_elements); + +/* + pvalloc(size_t n); + Equivalent to valloc(minimum-page-that-holds(n)), that is, + round up n to nearest pagesize. + */ +DLMALLOC_EXPORT void* dlpvalloc(size_t); + +/* + malloc_trim(size_t pad); + + If possible, gives memory back to the system (via negative arguments + to sbrk) if there is unused memory at the `high' end of the malloc + pool or in unused MMAP segments. You can call this after freeing + large blocks of memory to potentially reduce the system-level memory + requirements of a program. However, it cannot guarantee to reduce + memory. Under some allocation patterns, some large free blocks of + memory will be locked between two used chunks, so they cannot be + given back to the system. + + The `pad' argument to malloc_trim represents the amount of free + trailing space to leave untrimmed. If this argument is zero, only + the minimum amount of memory to maintain internal data structures + will be left. Non-zero arguments can be supplied to maintain enough + trailing space to service future expected allocations without having + to re-obtain memory from the system. + + Malloc_trim returns 1 if it actually released any memory, else 0. +*/ +DLMALLOC_EXPORT int dlmalloc_trim(size_t); + +/* + malloc_stats(); + Prints on stderr the amount of space obtained from the system (both + via sbrk and mmap), the maximum amount (which may be more than + current if malloc_trim and/or munmap got called), and the current + number of bytes allocated via malloc (or realloc, etc) but not yet + freed. Note that this is the number of bytes allocated, not the + number requested. It will be larger than the number requested + because of alignment and bookkeeping overhead. Because it includes + alignment wastage as being in use, this figure may be greater than + zero even when no user-level chunks are allocated. + + The reported current and maximum system memory can be inaccurate if + a program makes other calls to system memory allocation functions + (normally sbrk) outside of malloc. + + malloc_stats prints only the most commonly interesting statistics. + More information can be obtained by calling mallinfo. +*/ +DLMALLOC_EXPORT void dlmalloc_stats(void); + +/* + malloc_usable_size(void* p); + + Returns the number of bytes you can actually use in + an allocated chunk, which may be more than you requested (although + often not) due to alignment and minimum size constraints. + You can use this many bytes without worrying about + overwriting other allocated objects. This is not a particularly great + programming practice. malloc_usable_size can be more useful in + debugging and assertions, for example: + + p = malloc(n); + assert(malloc_usable_size(p) >= 256); +*/ +size_t dlmalloc_usable_size(void*); + +#endif /* ONLY_MSPACES */ + +#if MSPACES + +/* + mspace is an opaque type representing an independent + region of space that supports mspace_malloc, etc. +*/ +typedef void* mspace; + +/* + create_mspace creates and returns a new independent space with the + given initial capacity, or, if 0, the default granularity size. It + returns null if there is no system memory available to create the + space. If argument locked is non-zero, the space uses a separate + lock to control access. The capacity of the space will grow + dynamically as needed to service mspace_malloc requests. You can + control the sizes of incremental increases of this space by + compiling with a different DEFAULT_GRANULARITY or dynamically + setting with mallopt(M_GRANULARITY, value). +*/ +DLMALLOC_EXPORT mspace create_mspace(size_t capacity, int locked); + +/* + destroy_mspace destroys the given space, and attempts to return all + of its memory back to the system, returning the total number of + bytes freed. After destruction, the results of access to all memory + used by the space become undefined. +*/ +DLMALLOC_EXPORT size_t destroy_mspace(mspace msp); + +/* + create_mspace_with_base uses the memory supplied as the initial base + of a new mspace. Part (less than 128*sizeof(size_t) bytes) of this + space is used for bookkeeping, so the capacity must be at least this + large. (Otherwise 0 is returned.) When this initial space is + exhausted, additional memory will be obtained from the system. + Destroying this space will deallocate all additionally allocated + space (if possible) but not the initial base. +*/ +DLMALLOC_EXPORT mspace create_mspace_with_base(void* base, size_t capacity, int locked); + +/* + mspace_track_large_chunks controls whether requests for large chunks + are allocated in their own untracked mmapped regions, separate from + others in this mspace. By default large chunks are not tracked, + which reduces fragmentation. However, such chunks are not + necessarily released to the system upon destroy_mspace. Enabling + tracking by setting to true may increase fragmentation, but avoids + leakage when relying on destroy_mspace to release all memory + allocated using this space. The function returns the previous + setting. +*/ +DLMALLOC_EXPORT int mspace_track_large_chunks(mspace msp, int enable); + + +/* + mspace_malloc behaves as malloc, but operates within + the given space. +*/ +DLMALLOC_EXPORT void* mspace_malloc(mspace msp, size_t bytes); + +/* + mspace_free behaves as free, but operates within + the given space. + + If compiled with FOOTERS==1, mspace_free is not actually needed. + free may be called instead of mspace_free because freed chunks from + any space are handled by their originating spaces. +*/ +DLMALLOC_EXPORT void mspace_free(mspace msp, void* mem); + +/* + mspace_realloc behaves as realloc, but operates within + the given space. + + If compiled with FOOTERS==1, mspace_realloc is not actually + needed. realloc may be called instead of mspace_realloc because + realloced chunks from any space are handled by their originating + spaces. +*/ +DLMALLOC_EXPORT void* mspace_realloc(mspace msp, void* mem, size_t newsize); + +/* + mspace_calloc behaves as calloc, but operates within + the given space. +*/ +DLMALLOC_EXPORT void* mspace_calloc(mspace msp, size_t n_elements, size_t elem_size); + +/* + mspace_memalign behaves as memalign, but operates within + the given space. +*/ +DLMALLOC_EXPORT void* mspace_memalign(mspace msp, size_t alignment, size_t bytes); + +/* + mspace_independent_calloc behaves as independent_calloc, but + operates within the given space. +*/ +DLMALLOC_EXPORT void** mspace_independent_calloc(mspace msp, size_t n_elements, + size_t elem_size, void* chunks[]); + +/* + mspace_independent_comalloc behaves as independent_comalloc, but + operates within the given space. +*/ +DLMALLOC_EXPORT void** mspace_independent_comalloc(mspace msp, size_t n_elements, + size_t sizes[], void* chunks[]); + +/* + mspace_footprint() returns the number of bytes obtained from the + system for this space. +*/ +DLMALLOC_EXPORT size_t mspace_footprint(mspace msp); + +/* + mspace_max_footprint() returns the peak number of bytes obtained from the + system for this space. +*/ +DLMALLOC_EXPORT size_t mspace_max_footprint(mspace msp); + + +#if !NO_MALLINFO +/* + mspace_mallinfo behaves as mallinfo, but reports properties of + the given space. +*/ +DLMALLOC_EXPORT struct mallinfo mspace_mallinfo(mspace msp); +#endif /* NO_MALLINFO */ + +/* + malloc_usable_size(void* p) behaves the same as malloc_usable_size; +*/ +DLMALLOC_EXPORT size_t mspace_usable_size(const void* mem); + +/* + mspace_malloc_stats behaves as malloc_stats, but reports + properties of the given space. +*/ +DLMALLOC_EXPORT void mspace_malloc_stats(mspace msp); + +/* + mspace_trim behaves as malloc_trim, but + operates within the given space. +*/ +DLMALLOC_EXPORT int mspace_trim(mspace msp, size_t pad); + +/* + An alias for mallopt. +*/ +DLMALLOC_EXPORT int mspace_mallopt(int, int); + +#endif /* MSPACES */ + +#ifdef __cplusplus +} /* end of extern "C" */ +#endif /* __cplusplus */ + +/* + ======================================================================== + To make a fully customizable malloc.h header file, cut everything + above this line, put into file malloc.h, edit to suit, and #include it + on the next line, as well as in programs that use this malloc. + ======================================================================== +*/ + +/* #include "malloc.h" */ + +/*------------------------------ internal #includes ---------------------- */ + +#ifdef _MSC_VER +#pragma warning( disable : 4146 ) /* no "unsigned" warnings */ +#endif /* _MSC_VER */ +#if !NO_MALLOC_STATS +#include /* for printing in malloc_stats */ +#endif /* NO_MALLOC_STATS */ +#ifndef LACKS_ERRNO_H +#include /* for MALLOC_FAILURE_ACTION */ +#endif /* LACKS_ERRNO_H */ +#ifdef DEBUG +#if ABORT_ON_ASSERT_FAILURE +#undef assert +#define assert(x) if(!(x)) ABORT +#else /* ABORT_ON_ASSERT_FAILURE */ +#include +#endif /* ABORT_ON_ASSERT_FAILURE */ +#else /* DEBUG */ +#ifndef assert +#define assert(x) +#endif +#define DEBUG 0 +#endif /* DEBUG */ +#if !defined(WIN32) && !defined(LACKS_TIME_H) +#include /* for magic initialization */ +#endif /* WIN32 */ +#ifndef LACKS_STDLIB_H +#include /* for abort() */ +#endif /* LACKS_STDLIB_H */ +#ifndef LACKS_STRING_H +#include /* for memset etc */ +#endif /* LACKS_STRING_H */ +#if USE_BUILTIN_FFS +#ifndef LACKS_STRINGS_H +#include /* for ffs */ +#endif /* LACKS_STRINGS_H */ +#endif /* USE_BUILTIN_FFS */ +#if HAVE_MMAP +#ifndef LACKS_SYS_MMAN_H +/* On some versions of linux, mremap decl in mman.h needs __USE_GNU set */ +#if (defined(linux) && !defined(__USE_GNU)) +#define __USE_GNU 1 +#include /* for mmap */ +#undef __USE_GNU +#else +#include /* for mmap */ +#endif /* linux */ +#endif /* LACKS_SYS_MMAN_H */ +#ifndef LACKS_FCNTL_H +#include +#endif /* LACKS_FCNTL_H */ +#endif /* HAVE_MMAP */ +#ifndef LACKS_UNISTD_H +#include /* for sbrk, sysconf */ +#else /* LACKS_UNISTD_H */ +#if !defined(__FreeBSD__) && !defined(__OpenBSD__) && !defined(__NetBSD__) +extern void* sbrk(ptrdiff_t); +#endif /* FreeBSD etc */ +#endif /* LACKS_UNISTD_H */ + +/* Declarations for locking */ +#if USE_LOCKS +#ifndef WIN32 +#if defined (__SVR4) && defined (__sun) /* solaris */ +#include +#elif !defined(LACKS_SCHED_H) +#include +#endif /* solaris or LACKS_SCHED_H */ +#if (defined(USE_RECURSIVE_LOCKS) && USE_RECURSIVE_LOCKS != 0) || !USE_SPIN_LOCKS +#include +#endif /* USE_RECURSIVE_LOCKS ... */ +#elif defined(_MSC_VER) +#ifndef _M_AMD64 +/* These are already defined on AMD64 builds */ +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ +LONG __cdecl _InterlockedCompareExchange(LONG volatile *Dest, LONG Exchange, LONG Comp); +LONG __cdecl _InterlockedExchange(LONG volatile *Target, LONG Value); +#ifdef __cplusplus +} +#endif /* __cplusplus */ +#endif /* _M_AMD64 */ +#pragma intrinsic (_InterlockedCompareExchange) +#pragma intrinsic (_InterlockedExchange) +#define interlockedcompareexchange _InterlockedCompareExchange +#define interlockedexchange _InterlockedExchange +#elif defined(WIN32) && defined(__GNUC__) +#define interlockedcompareexchange(a, b, c) __sync_val_compare_and_swap(a, c, b) +#define interlockedexchange __sync_lock_test_and_set +#endif /* Win32 */ +#else /* USE_LOCKS */ +#endif /* USE_LOCKS */ + +#ifndef LOCK_AT_FORK +#define LOCK_AT_FORK 0 +#endif + +/* Declarations for bit scanning on win32 */ +#if defined(_MSC_VER) && _MSC_VER>=1300 +#ifndef BitScanForward /* Try to avoid pulling in WinNT.h */ +#ifdef __cplusplus +extern "C" { +#endif /* __cplusplus */ +unsigned char _BitScanForward(unsigned long *index, unsigned long mask); +unsigned char _BitScanReverse(unsigned long *index, unsigned long mask); +#ifdef __cplusplus +} +#endif /* __cplusplus */ + +#define BitScanForward _BitScanForward +#define BitScanReverse _BitScanReverse +#pragma intrinsic(_BitScanForward) +#pragma intrinsic(_BitScanReverse) +#endif /* BitScanForward */ +#endif /* defined(_MSC_VER) && _MSC_VER>=1300 */ + +#ifndef WIN32 +#ifndef malloc_getpagesize +# ifdef _SC_PAGESIZE /* some SVR4 systems omit an underscore */ +# ifndef _SC_PAGE_SIZE +# define _SC_PAGE_SIZE _SC_PAGESIZE +# endif +# endif +# ifdef _SC_PAGE_SIZE +# define malloc_getpagesize sysconf(_SC_PAGE_SIZE) +# else +# if defined(BSD) || defined(DGUX) || defined(HAVE_GETPAGESIZE) + extern size_t getpagesize(); +# define malloc_getpagesize getpagesize() +# else +# ifdef WIN32 /* use supplied emulation of getpagesize */ +# define malloc_getpagesize getpagesize() +# else +# ifndef LACKS_SYS_PARAM_H +# include +# endif +# ifdef EXEC_PAGESIZE +# define malloc_getpagesize EXEC_PAGESIZE +# else +# ifdef NBPG +# ifndef CLSIZE +# define malloc_getpagesize NBPG +# else +# define malloc_getpagesize (NBPG * CLSIZE) +# endif +# else +# ifdef NBPC +# define malloc_getpagesize NBPC +# else +# ifdef PAGESIZE +# define malloc_getpagesize PAGESIZE +# else /* just guess */ +# define malloc_getpagesize ((size_t)4096U) +# endif +# endif +# endif +# endif +# endif +# endif +# endif +#endif +#endif + +/* ------------------- size_t and alignment properties -------------------- */ + +/* The byte and bit size of a size_t */ +#define SIZE_T_SIZE (sizeof(size_t)) +#define SIZE_T_BITSIZE (sizeof(size_t) << 3) + +/* Some constants coerced to size_t */ +/* Annoying but necessary to avoid errors on some platforms */ +#define SIZE_T_ZERO ((size_t)0) +#define SIZE_T_ONE ((size_t)1) +#define SIZE_T_TWO ((size_t)2) +#define SIZE_T_FOUR ((size_t)4) +#define TWO_SIZE_T_SIZES (SIZE_T_SIZE<<1) +#define FOUR_SIZE_T_SIZES (SIZE_T_SIZE<<2) +#define SIX_SIZE_T_SIZES (FOUR_SIZE_T_SIZES+TWO_SIZE_T_SIZES) +#define HALF_MAX_SIZE_T (MAX_SIZE_T / 2U) + +/* The bit mask value corresponding to MALLOC_ALIGNMENT */ +#define CHUNK_ALIGN_MASK (MALLOC_ALIGNMENT - SIZE_T_ONE) + +/* True if address a has acceptable alignment */ +#define is_aligned(A) (((size_t)((A)) & (CHUNK_ALIGN_MASK)) == 0) + +/* the number of bytes to offset an address to align it */ +#define align_offset(A)\ + ((((size_t)(A) & CHUNK_ALIGN_MASK) == 0)? 0 :\ + ((MALLOC_ALIGNMENT - ((size_t)(A) & CHUNK_ALIGN_MASK)) & CHUNK_ALIGN_MASK)) + +/* -------------------------- MMAP preliminaries ------------------------- */ + +/* + If HAVE_MORECORE or HAVE_MMAP are false, we just define calls and + checks to fail so compiler optimizer can delete code rather than + using so many "#if"s. +*/ + + +/* MORECORE and MMAP must return MFAIL on failure */ +#define MFAIL ((void*)(MAX_SIZE_T)) +#define CMFAIL ((char*)(MFAIL)) /* defined for convenience */ + +#if HAVE_MMAP + +#ifndef WIN32 +#define MUNMAP_DEFAULT(a, s) munmap((a), (s)) +#define MMAP_PROT (PROT_READ|PROT_WRITE) +#if !defined(MAP_ANONYMOUS) && defined(MAP_ANON) +#define MAP_ANONYMOUS MAP_ANON +#endif /* MAP_ANON */ +#ifdef MAP_ANONYMOUS +#define MMAP_FLAGS (MAP_PRIVATE|MAP_ANONYMOUS) +#define MMAP_DEFAULT(s) mmap(0, (s), MMAP_PROT, MMAP_FLAGS, -1, 0) +#else /* MAP_ANONYMOUS */ +/* + Nearly all versions of mmap support MAP_ANONYMOUS, so the following + is unlikely to be needed, but is supplied just in case. +*/ +#define MMAP_FLAGS (MAP_PRIVATE) +static int dev_zero_fd = -1; /* Cached file descriptor for /dev/zero. */ +#define MMAP_DEFAULT(s) ((dev_zero_fd < 0) ? \ + (dev_zero_fd = open("/dev/zero", O_RDWR), \ + mmap(0, (s), MMAP_PROT, MMAP_FLAGS, dev_zero_fd, 0)) : \ + mmap(0, (s), MMAP_PROT, MMAP_FLAGS, dev_zero_fd, 0)) +#endif /* MAP_ANONYMOUS */ + +#define DIRECT_MMAP_DEFAULT(s) MMAP_DEFAULT(s) + +#else /* WIN32 */ + +/* Win32 MMAP via VirtualAlloc */ +static FORCEINLINE void* win32mmap(size_t size) { + void* ptr = VirtualAlloc(0, size, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE); + return (ptr != 0)? ptr: MFAIL; +} + +/* For direct MMAP, use MEM_TOP_DOWN to minimize interference */ +static FORCEINLINE void* win32direct_mmap(size_t size) { + void* ptr = VirtualAlloc(0, size, MEM_RESERVE|MEM_COMMIT|MEM_TOP_DOWN, + PAGE_READWRITE); + return (ptr != 0)? ptr: MFAIL; +} + +/* This function supports releasing coalesed segments */ +static FORCEINLINE int win32munmap(void* ptr, size_t size) { + MEMORY_BASIC_INFORMATION minfo; + char* cptr = (char*)ptr; + while (size) { + if (VirtualQuery(cptr, &minfo, sizeof(minfo)) == 0) + return -1; + if (minfo.BaseAddress != cptr || minfo.AllocationBase != cptr || + minfo.State != MEM_COMMIT || minfo.RegionSize > size) + return -1; + if (VirtualFree(cptr, 0, MEM_RELEASE) == 0) + return -1; + cptr += minfo.RegionSize; + size -= minfo.RegionSize; + } + return 0; +} + +#define MMAP_DEFAULT(s) win32mmap(s) +#define MUNMAP_DEFAULT(a, s) win32munmap((a), (s)) +#define DIRECT_MMAP_DEFAULT(s) win32direct_mmap(s) +#endif /* WIN32 */ +#endif /* HAVE_MMAP */ + +#if HAVE_MREMAP +#ifndef WIN32 +#define MREMAP_DEFAULT(addr, osz, nsz, mv) mremap((addr), (osz), (nsz), (mv)) +#endif /* WIN32 */ +#endif /* HAVE_MREMAP */ + +/** + * Define CALL_MORECORE + */ +#if HAVE_MORECORE + #ifdef MORECORE + #define CALL_MORECORE(S) MORECORE(S) + #else /* MORECORE */ + #define CALL_MORECORE(S) MORECORE_DEFAULT(S) + #endif /* MORECORE */ +#else /* HAVE_MORECORE */ + #define CALL_MORECORE(S) MFAIL +#endif /* HAVE_MORECORE */ + +/** + * Define CALL_MMAP/CALL_MUNMAP/CALL_DIRECT_MMAP + */ +#if HAVE_MMAP + #define USE_MMAP_BIT (SIZE_T_ONE) + + #ifdef MMAP + #define CALL_MMAP(s) MMAP(s) + #else /* MMAP */ + #define CALL_MMAP(s) MMAP_DEFAULT(s) + #endif /* MMAP */ + #ifdef MUNMAP + #define CALL_MUNMAP(a, s) MUNMAP((a), (s)) + #else /* MUNMAP */ + #define CALL_MUNMAP(a, s) MUNMAP_DEFAULT((a), (s)) + #endif /* MUNMAP */ + #ifdef DIRECT_MMAP + #define CALL_DIRECT_MMAP(s) DIRECT_MMAP(s) + #else /* DIRECT_MMAP */ + #define CALL_DIRECT_MMAP(s) DIRECT_MMAP_DEFAULT(s) + #endif /* DIRECT_MMAP */ +#else /* HAVE_MMAP */ + #define USE_MMAP_BIT (SIZE_T_ZERO) + + #define MMAP(s) MFAIL + #define MUNMAP(a, s) (-1) + #define DIRECT_MMAP(s) MFAIL + #define CALL_DIRECT_MMAP(s) DIRECT_MMAP(s) + #define CALL_MMAP(s) MMAP(s) + #define CALL_MUNMAP(a, s) MUNMAP((a), (s)) +#endif /* HAVE_MMAP */ + +/** + * Define CALL_MREMAP + */ +#if HAVE_MMAP && HAVE_MREMAP + #ifdef MREMAP + #define CALL_MREMAP(addr, osz, nsz, mv) MREMAP((addr), (osz), (nsz), (mv)) + #else /* MREMAP */ + #define CALL_MREMAP(addr, osz, nsz, mv) MREMAP_DEFAULT((addr), (osz), (nsz), (mv)) + #endif /* MREMAP */ +#else /* HAVE_MMAP && HAVE_MREMAP */ + #define CALL_MREMAP(addr, osz, nsz, mv) MFAIL +#endif /* HAVE_MMAP && HAVE_MREMAP */ + +/* mstate bit set if continguous morecore disabled or failed */ +#define USE_NONCONTIGUOUS_BIT (4U) + +/* segment bit set in create_mspace_with_base */ +#define EXTERN_BIT (8U) + + +/* --------------------------- Lock preliminaries ------------------------ */ + +/* + When locks are defined, there is one global lock, plus + one per-mspace lock. + + The global lock_ensures that mparams.magic and other unique + mparams values are initialized only once. It also protects + sequences of calls to MORECORE. In many cases sys_alloc requires + two calls, that should not be interleaved with calls by other + threads. This does not protect against direct calls to MORECORE + by other threads not using this lock, so there is still code to + cope the best we can on interference. + + Per-mspace locks surround calls to malloc, free, etc. + By default, locks are simple non-reentrant mutexes. + + Because lock-protected regions generally have bounded times, it is + OK to use the supplied simple spinlocks. Spinlocks are likely to + improve performance for lightly contended applications, but worsen + performance under heavy contention. + + If USE_LOCKS is > 1, the definitions of lock routines here are + bypassed, in which case you will need to define the type MLOCK_T, + and at least INITIAL_LOCK, DESTROY_LOCK, ACQUIRE_LOCK, RELEASE_LOCK + and TRY_LOCK. You must also declare a + static MLOCK_T malloc_global_mutex = { initialization values };. + +*/ + +#if !USE_LOCKS +#define USE_LOCK_BIT (0U) +#define INITIAL_LOCK(l) (0) +#define DESTROY_LOCK(l) (0) +#define ACQUIRE_MALLOC_GLOBAL_LOCK() +#define RELEASE_MALLOC_GLOBAL_LOCK() + +#else +#if USE_LOCKS > 1 +/* ----------------------- User-defined locks ------------------------ */ +/* Define your own lock implementation here */ +/* #define INITIAL_LOCK(lk) ... */ +/* #define DESTROY_LOCK(lk) ... */ +/* #define ACQUIRE_LOCK(lk) ... */ +/* #define RELEASE_LOCK(lk) ... */ +/* #define TRY_LOCK(lk) ... */ +/* static MLOCK_T malloc_global_mutex = ... */ + +#elif USE_SPIN_LOCKS + +/* First, define CAS_LOCK and CLEAR_LOCK on ints */ +/* Note CAS_LOCK defined to return 0 on success */ + +#if defined(__GNUC__)&& (__GNUC__ > 4 || (__GNUC__ == 4 && __GNUC_MINOR__ >= 1)) +#define CAS_LOCK(sl) __sync_lock_test_and_set(sl, 1) +#define CLEAR_LOCK(sl) __sync_lock_release(sl) + +#elif (defined(__GNUC__) && (defined(__i386__) || defined(__x86_64__))) +/* Custom spin locks for older gcc on x86 */ +static FORCEINLINE int x86_cas_lock(int *sl) { + int ret; + int val = 1; + int cmp = 0; + __asm__ __volatile__ ("lock; cmpxchgl %1, %2" + : "=a" (ret) + : "r" (val), "m" (*(sl)), "0"(cmp) + : "memory", "cc"); + return ret; +} + +static FORCEINLINE void x86_clear_lock(int* sl) { + assert(*sl != 0); + int prev = 0; + int ret; + __asm__ __volatile__ ("lock; xchgl %0, %1" + : "=r" (ret) + : "m" (*(sl)), "0"(prev) + : "memory"); +} + +#define CAS_LOCK(sl) x86_cas_lock(sl) +#define CLEAR_LOCK(sl) x86_clear_lock(sl) + +#else /* Win32 MSC */ +#define CAS_LOCK(sl) interlockedexchange(sl, (LONG)1) +#define CLEAR_LOCK(sl) interlockedexchange (sl, (LONG)0) + +#endif /* ... gcc spins locks ... */ + +/* How to yield for a spin lock */ +#define SPINS_PER_YIELD 63 +#if defined(_MSC_VER) +#define SLEEP_EX_DURATION 50 /* delay for yield/sleep */ +#define SPIN_LOCK_YIELD SleepEx(SLEEP_EX_DURATION, FALSE) +#elif defined (__SVR4) && defined (__sun) /* solaris */ +#define SPIN_LOCK_YIELD thr_yield(); +#elif !defined(LACKS_SCHED_H) +#define SPIN_LOCK_YIELD sched_yield(); +#else +#define SPIN_LOCK_YIELD +#endif /* ... yield ... */ + +#if !defined(USE_RECURSIVE_LOCKS) || USE_RECURSIVE_LOCKS == 0 +/* Plain spin locks use single word (embedded in malloc_states) */ +static int spin_acquire_lock(int *sl) { + int spins = 0; + while (*(volatile int *)sl != 0 || CAS_LOCK(sl)) { + if ((++spins & SPINS_PER_YIELD) == 0) { + SPIN_LOCK_YIELD; + } + } + return 0; +} + +#define MLOCK_T int +#define TRY_LOCK(sl) !CAS_LOCK(sl) +#define RELEASE_LOCK(sl) CLEAR_LOCK(sl) +#define ACQUIRE_LOCK(sl) (CAS_LOCK(sl)? spin_acquire_lock(sl) : 0) +#define INITIAL_LOCK(sl) (*sl = 0) +#define DESTROY_LOCK(sl) (0) +static MLOCK_T malloc_global_mutex = 0; + +#else /* USE_RECURSIVE_LOCKS */ +/* types for lock owners */ +#ifdef WIN32 +#define THREAD_ID_T DWORD +#define CURRENT_THREAD GetCurrentThreadId() +#define EQ_OWNER(X,Y) ((X) == (Y)) +#else +/* + Note: the following assume that pthread_t is a type that can be + initialized to (casted) zero. If this is not the case, you will need to + somehow redefine these or not use spin locks. +*/ +#define THREAD_ID_T pthread_t +#define CURRENT_THREAD pthread_self() +#define EQ_OWNER(X,Y) pthread_equal(X, Y) +#endif + +struct malloc_recursive_lock { + int sl; + unsigned int c; + THREAD_ID_T threadid; +}; + +#define MLOCK_T struct malloc_recursive_lock +static MLOCK_T malloc_global_mutex = { 0, 0, (THREAD_ID_T)0}; + +static FORCEINLINE void recursive_release_lock(MLOCK_T *lk) { + assert(lk->sl != 0); + if (--lk->c == 0) { + CLEAR_LOCK(&lk->sl); + } +} + +static FORCEINLINE int recursive_acquire_lock(MLOCK_T *lk) { + THREAD_ID_T mythreadid = CURRENT_THREAD; + int spins = 0; + for (;;) { + if (*((volatile int *)(&lk->sl)) == 0) { + if (!CAS_LOCK(&lk->sl)) { + lk->threadid = mythreadid; + lk->c = 1; + return 0; + } + } + else if (EQ_OWNER(lk->threadid, mythreadid)) { + ++lk->c; + return 0; + } + if ((++spins & SPINS_PER_YIELD) == 0) { + SPIN_LOCK_YIELD; + } + } +} + +static FORCEINLINE int recursive_try_lock(MLOCK_T *lk) { + THREAD_ID_T mythreadid = CURRENT_THREAD; + if (*((volatile int *)(&lk->sl)) == 0) { + if (!CAS_LOCK(&lk->sl)) { + lk->threadid = mythreadid; + lk->c = 1; + return 1; + } + } + else if (EQ_OWNER(lk->threadid, mythreadid)) { + ++lk->c; + return 1; + } + return 0; +} + +#define RELEASE_LOCK(lk) recursive_release_lock(lk) +#define TRY_LOCK(lk) recursive_try_lock(lk) +#define ACQUIRE_LOCK(lk) recursive_acquire_lock(lk) +#define INITIAL_LOCK(lk) ((lk)->threadid = (THREAD_ID_T)0, (lk)->sl = 0, (lk)->c = 0) +#define DESTROY_LOCK(lk) (0) +#endif /* USE_RECURSIVE_LOCKS */ + +#elif defined(WIN32) /* Win32 critical sections */ +#define MLOCK_T CRITICAL_SECTION +#define ACQUIRE_LOCK(lk) (EnterCriticalSection(lk), 0) +#define RELEASE_LOCK(lk) LeaveCriticalSection(lk) +#define TRY_LOCK(lk) TryEnterCriticalSection(lk) +#define INITIAL_LOCK(lk) (!InitializeCriticalSectionAndSpinCount((lk), 0x80000000|4000)) +#define DESTROY_LOCK(lk) (DeleteCriticalSection(lk), 0) +#define NEED_GLOBAL_LOCK_INIT + +static MLOCK_T malloc_global_mutex; +static volatile LONG malloc_global_mutex_status; + +/* Use spin loop to initialize global lock */ +static void init_malloc_global_mutex() { + for (;;) { + long stat = malloc_global_mutex_status; + if (stat > 0) + return; + /* transition to < 0 while initializing, then to > 0) */ + if (stat == 0 && + interlockedcompareexchange(&malloc_global_mutex_status, (LONG)-1, (LONG)0) == 0) { + InitializeCriticalSection(&malloc_global_mutex); + interlockedexchange(&malloc_global_mutex_status, (LONG)1); + return; + } + SleepEx(0, FALSE); + } +} + +#else /* pthreads-based locks */ +#define MLOCK_T pthread_mutex_t +#define ACQUIRE_LOCK(lk) pthread_mutex_lock(lk) +#define RELEASE_LOCK(lk) pthread_mutex_unlock(lk) +#define TRY_LOCK(lk) (!pthread_mutex_trylock(lk)) +#define INITIAL_LOCK(lk) pthread_init_lock(lk) +#define DESTROY_LOCK(lk) pthread_mutex_destroy(lk) + +#if defined(USE_RECURSIVE_LOCKS) && USE_RECURSIVE_LOCKS != 0 && defined(linux) && !defined(PTHREAD_MUTEX_RECURSIVE) +/* Cope with old-style linux recursive lock initialization by adding */ +/* skipped internal declaration from pthread.h */ +extern int pthread_mutexattr_setkind_np __P ((pthread_mutexattr_t *__attr, + int __kind)); +#define PTHREAD_MUTEX_RECURSIVE PTHREAD_MUTEX_RECURSIVE_NP +#define pthread_mutexattr_settype(x,y) pthread_mutexattr_setkind_np(x,y) +#endif /* USE_RECURSIVE_LOCKS ... */ + +static MLOCK_T malloc_global_mutex = PTHREAD_MUTEX_INITIALIZER; + +static int pthread_init_lock (MLOCK_T *lk) { + pthread_mutexattr_t attr; + if (pthread_mutexattr_init(&attr)) return 1; +#if defined(USE_RECURSIVE_LOCKS) && USE_RECURSIVE_LOCKS != 0 + if (pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE)) return 1; +#endif + if (pthread_mutex_init(lk, &attr)) return 1; + if (pthread_mutexattr_destroy(&attr)) return 1; + return 0; +} + +#endif /* ... lock types ... */ + +/* Common code for all lock types */ +#define USE_LOCK_BIT (2U) + +#ifndef ACQUIRE_MALLOC_GLOBAL_LOCK +#define ACQUIRE_MALLOC_GLOBAL_LOCK() ACQUIRE_LOCK(&malloc_global_mutex); +#endif + +#ifndef RELEASE_MALLOC_GLOBAL_LOCK +#define RELEASE_MALLOC_GLOBAL_LOCK() RELEASE_LOCK(&malloc_global_mutex); +#endif + +#endif /* USE_LOCKS */ + +/* ----------------------- Chunk representations ------------------------ */ + +/* + (The following includes lightly edited explanations by Colin Plumb.) + + The malloc_chunk declaration below is misleading (but accurate and + necessary). It declares a "view" into memory allowing access to + necessary fields at known offsets from a given base. + + Chunks of memory are maintained using a `boundary tag' method as + originally described by Knuth. (See the paper by Paul Wilson + ftp://ftp.cs.utexas.edu/pub/garbage/allocsrv.ps for a survey of such + techniques.) Sizes of free chunks are stored both in the front of + each chunk and at the end. This makes consolidating fragmented + chunks into bigger chunks fast. The head fields also hold bits + representing whether chunks are free or in use. + + Here are some pictures to make it clearer. They are "exploded" to + show that the state of a chunk can be thought of as extending from + the high 31 bits of the head field of its header through the + prev_foot and PINUSE_BIT bit of the following chunk header. + + A chunk that's in use looks like: + + chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Size of previous chunk (if P = 0) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |P| + | Size of this chunk 1| +-+ + mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | | + +- -+ + | | + +- -+ + | : + +- size - sizeof(size_t) available payload bytes -+ + : | + chunk-> +- -+ + | | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |1| + | Size of next chunk (may or may not be in use) | +-+ + mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + And if it's free, it looks like this: + + chunk-> +- -+ + | User payload (must be in use, or we would have merged!) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |P| + | Size of this chunk 0| +-+ + mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Next pointer | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Prev pointer | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | : + +- size - sizeof(struct chunk) unused bytes -+ + : | + chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Size of this chunk | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ |0| + | Size of next chunk (must be in use, or we would have merged)| +-+ + mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | : + +- User payload -+ + : | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |0| + +-+ + Note that since we always merge adjacent free chunks, the chunks + adjacent to a free chunk must be in use. + + Given a pointer to a chunk (which can be derived trivially from the + payload pointer) we can, in O(1) time, find out whether the adjacent + chunks are free, and if so, unlink them from the lists that they + are on and merge them with the current chunk. + + Chunks always begin on even word boundaries, so the mem portion + (which is returned to the user) is also on an even word boundary, and + thus at least double-word aligned. + + The P (PINUSE_BIT) bit, stored in the unused low-order bit of the + chunk size (which is always a multiple of two words), is an in-use + bit for the *previous* chunk. If that bit is *clear*, then the + word before the current chunk size contains the previous chunk + size, and can be used to find the front of the previous chunk. + The very first chunk allocated always has this bit set, preventing + access to non-existent (or non-owned) memory. If pinuse is set for + any given chunk, then you CANNOT determine the size of the + previous chunk, and might even get a memory addressing fault when + trying to do so. + + The C (CINUSE_BIT) bit, stored in the unused second-lowest bit of + the chunk size redundantly records whether the current chunk is + inuse (unless the chunk is mmapped). This redundancy enables usage + checks within free and realloc, and reduces indirection when freeing + and consolidating chunks. + + Each freshly allocated chunk must have both cinuse and pinuse set. + That is, each allocated chunk borders either a previously allocated + and still in-use chunk, or the base of its memory arena. This is + ensured by making all allocations from the `lowest' part of any + found chunk. Further, no free chunk physically borders another one, + so each free chunk is known to be preceded and followed by either + inuse chunks or the ends of memory. + + Note that the `foot' of the current chunk is actually represented + as the prev_foot of the NEXT chunk. This makes it easier to + deal with alignments etc but can be very confusing when trying + to extend or adapt this code. + + The exceptions to all this are + + 1. The special chunk `top' is the top-most available chunk (i.e., + the one bordering the end of available memory). It is treated + specially. Top is never included in any bin, is used only if + no other chunk is available, and is released back to the + system if it is very large (see M_TRIM_THRESHOLD). In effect, + the top chunk is treated as larger (and thus less well + fitting) than any other available chunk. The top chunk + doesn't update its trailing size field since there is no next + contiguous chunk that would have to index off it. However, + space is still allocated for it (TOP_FOOT_SIZE) to enable + separation or merging when space is extended. + + 3. Chunks allocated via mmap, have both cinuse and pinuse bits + cleared in their head fields. Because they are allocated + one-by-one, each must carry its own prev_foot field, which is + also used to hold the offset this chunk has within its mmapped + region, which is needed to preserve alignment. Each mmapped + chunk is trailed by the first two fields of a fake next-chunk + for sake of usage checks. + +*/ + +struct malloc_chunk { + size_t prev_foot; /* Size of previous chunk (if free). */ + size_t head; /* Size and inuse bits. */ + struct malloc_chunk* fd; /* double links -- used only if free. */ + struct malloc_chunk* bk; +}; + +typedef struct malloc_chunk mchunk; +typedef struct malloc_chunk* mchunkptr; +typedef struct malloc_chunk* sbinptr; /* The type of bins of chunks */ +typedef unsigned int bindex_t; /* Described below */ +typedef unsigned int binmap_t; /* Described below */ +typedef unsigned int flag_t; /* The type of various bit flag sets */ + +/* ------------------- Chunks sizes and alignments ----------------------- */ + +#define MCHUNK_SIZE (sizeof(mchunk)) + +#if FOOTERS +#define CHUNK_OVERHEAD (TWO_SIZE_T_SIZES) +#else /* FOOTERS */ +#define CHUNK_OVERHEAD (SIZE_T_SIZE) +#endif /* FOOTERS */ + +/* MMapped chunks need a second word of overhead ... */ +#define MMAP_CHUNK_OVERHEAD (TWO_SIZE_T_SIZES) +/* ... and additional padding for fake next-chunk at foot */ +#define MMAP_FOOT_PAD (FOUR_SIZE_T_SIZES) + +/* The smallest size we can malloc is an aligned minimal chunk */ +#define MIN_CHUNK_SIZE\ + ((MCHUNK_SIZE + CHUNK_ALIGN_MASK) & ~CHUNK_ALIGN_MASK) + +/* conversion from malloc headers to user pointers, and back */ +#define chunk2mem(p) ((void*)((char*)(p) + TWO_SIZE_T_SIZES)) +#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - TWO_SIZE_T_SIZES)) +/* chunk associated with aligned address A */ +#define align_as_chunk(A) (mchunkptr)((A) + align_offset(chunk2mem(A))) + +/* Bounds on request (not chunk) sizes. */ +#define MAX_REQUEST ((-MIN_CHUNK_SIZE) << 2) +#define MIN_REQUEST (MIN_CHUNK_SIZE - CHUNK_OVERHEAD - SIZE_T_ONE) + +/* pad request bytes into a usable size */ +#define pad_request(req) \ + (((req) + CHUNK_OVERHEAD + CHUNK_ALIGN_MASK) & ~CHUNK_ALIGN_MASK) + +/* pad request, checking for minimum (but not maximum) */ +#define request2size(req) \ + (((req) < MIN_REQUEST)? MIN_CHUNK_SIZE : pad_request(req)) + + +/* ------------------ Operations on head and foot fields ----------------- */ + +/* + The head field of a chunk is or'ed with PINUSE_BIT when previous + adjacent chunk in use, and or'ed with CINUSE_BIT if this chunk is in + use, unless mmapped, in which case both bits are cleared. + + FLAG4_BIT is not used by this malloc, but might be useful in extensions. +*/ + +#define PINUSE_BIT (SIZE_T_ONE) +#define CINUSE_BIT (SIZE_T_TWO) +#define FLAG4_BIT (SIZE_T_FOUR) +#define INUSE_BITS (PINUSE_BIT|CINUSE_BIT) +#define FLAG_BITS (PINUSE_BIT|CINUSE_BIT|FLAG4_BIT) + +/* Head value for fenceposts */ +#define FENCEPOST_HEAD (INUSE_BITS|SIZE_T_SIZE) + +/* extraction of fields from head words */ +#define cinuse(p) ((p)->head & CINUSE_BIT) +#define pinuse(p) ((p)->head & PINUSE_BIT) +#define flag4inuse(p) ((p)->head & FLAG4_BIT) +#define is_inuse(p) (((p)->head & INUSE_BITS) != PINUSE_BIT) +#define is_mmapped(p) (((p)->head & INUSE_BITS) == 0) + +#define chunksize(p) ((p)->head & ~(FLAG_BITS)) + +#define clear_pinuse(p) ((p)->head &= ~PINUSE_BIT) +#define set_flag4(p) ((p)->head |= FLAG4_BIT) +#define clear_flag4(p) ((p)->head &= ~FLAG4_BIT) + +/* Treat space at ptr +/- offset as a chunk */ +#define chunk_plus_offset(p, s) ((mchunkptr)(((char*)(p)) + (s))) +#define chunk_minus_offset(p, s) ((mchunkptr)(((char*)(p)) - (s))) + +/* Ptr to next or previous physical malloc_chunk. */ +#define next_chunk(p) ((mchunkptr)( ((char*)(p)) + ((p)->head & ~FLAG_BITS))) +#define prev_chunk(p) ((mchunkptr)( ((char*)(p)) - ((p)->prev_foot) )) + +/* extract next chunk's pinuse bit */ +#define next_pinuse(p) ((next_chunk(p)->head) & PINUSE_BIT) + +/* Get/set size at footer */ +#define get_foot(p, s) (((mchunkptr)((char*)(p) + (s)))->prev_foot) +#define set_foot(p, s) (((mchunkptr)((char*)(p) + (s)))->prev_foot = (s)) + +/* Set size, pinuse bit, and foot */ +#define set_size_and_pinuse_of_free_chunk(p, s)\ + ((p)->head = (s|PINUSE_BIT), set_foot(p, s)) + +/* Set size, pinuse bit, foot, and clear next pinuse */ +#define set_free_with_pinuse(p, s, n)\ + (clear_pinuse(n), set_size_and_pinuse_of_free_chunk(p, s)) + +/* Get the internal overhead associated with chunk p */ +#define overhead_for(p)\ + (is_mmapped(p)? MMAP_CHUNK_OVERHEAD : CHUNK_OVERHEAD) + +/* Return true if malloced space is not necessarily cleared */ +#if MMAP_CLEARS +#define calloc_must_clear(p) (!is_mmapped(p)) +#else /* MMAP_CLEARS */ +#define calloc_must_clear(p) (1) +#endif /* MMAP_CLEARS */ + +/* ---------------------- Overlaid data structures ----------------------- */ + +/* + When chunks are not in use, they are treated as nodes of either + lists or trees. + + "Small" chunks are stored in circular doubly-linked lists, and look + like this: + + chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Size of previous chunk | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + `head:' | Size of chunk, in bytes |P| + mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Forward pointer to next chunk in list | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Back pointer to previous chunk in list | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Unused space (may be 0 bytes long) . + . . + . | +nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + `foot:' | Size of chunk, in bytes | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Larger chunks are kept in a form of bitwise digital trees (aka + tries) keyed on chunksizes. Because malloc_tree_chunks are only for + free chunks greater than 256 bytes, their size doesn't impose any + constraints on user chunk sizes. Each node looks like: + + chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Size of previous chunk | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + `head:' | Size of chunk, in bytes |P| + mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Forward pointer to next chunk of same size | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Back pointer to previous chunk of same size | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Pointer to left child (child[0]) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Pointer to right child (child[1]) | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Pointer to parent | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | bin index of this chunk | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | Unused space . + . | +nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + `foot:' | Size of chunk, in bytes | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + Each tree holding treenodes is a tree of unique chunk sizes. Chunks + of the same size are arranged in a circularly-linked list, with only + the oldest chunk (the next to be used, in our FIFO ordering) + actually in the tree. (Tree members are distinguished by a non-null + parent pointer.) If a chunk with the same size an an existing node + is inserted, it is linked off the existing node using pointers that + work in the same way as fd/bk pointers of small chunks. + + Each tree contains a power of 2 sized range of chunk sizes (the + smallest is 0x100 <= x < 0x180), which is is divided in half at each + tree level, with the chunks in the smaller half of the range (0x100 + <= x < 0x140 for the top nose) in the left subtree and the larger + half (0x140 <= x < 0x180) in the right subtree. This is, of course, + done by inspecting individual bits. + + Using these rules, each node's left subtree contains all smaller + sizes than its right subtree. However, the node at the root of each + subtree has no particular ordering relationship to either. (The + dividing line between the subtree sizes is based on trie relation.) + If we remove the last chunk of a given size from the interior of the + tree, we need to replace it with a leaf node. The tree ordering + rules permit a node to be replaced by any leaf below it. + + The smallest chunk in a tree (a common operation in a best-fit + allocator) can be found by walking a path to the leftmost leaf in + the tree. Unlike a usual binary tree, where we follow left child + pointers until we reach a null, here we follow the right child + pointer any time the left one is null, until we reach a leaf with + both child pointers null. The smallest chunk in the tree will be + somewhere along that path. + + The worst case number of steps to add, find, or remove a node is + bounded by the number of bits differentiating chunks within + bins. Under current bin calculations, this ranges from 6 up to 21 + (for 32 bit sizes) or up to 53 (for 64 bit sizes). The typical case + is of course much better. +*/ + +struct malloc_tree_chunk { + /* The first four fields must be compatible with malloc_chunk */ + size_t prev_foot; + size_t head; + struct malloc_tree_chunk* fd; + struct malloc_tree_chunk* bk; + + struct malloc_tree_chunk* child[2]; + struct malloc_tree_chunk* parent; + bindex_t index; +}; + +typedef struct malloc_tree_chunk tchunk; +typedef struct malloc_tree_chunk* tchunkptr; +typedef struct malloc_tree_chunk* tbinptr; /* The type of bins of trees */ + +/* A little helper macro for trees */ +#define leftmost_child(t) ((t)->child[0] != 0? (t)->child[0] : (t)->child[1]) + +/* ----------------------------- Segments -------------------------------- */ + +/* + Each malloc space may include non-contiguous segments, held in a + list headed by an embedded malloc_segment record representing the + top-most space. Segments also include flags holding properties of + the space. Large chunks that are directly allocated by mmap are not + included in this list. They are instead independently created and + destroyed without otherwise keeping track of them. + + Segment management mainly comes into play for spaces allocated by + MMAP. Any call to MMAP might or might not return memory that is + adjacent to an existing segment. MORECORE normally contiguously + extends the current space, so this space is almost always adjacent, + which is simpler and faster to deal with. (This is why MORECORE is + used preferentially to MMAP when both are available -- see + sys_alloc.) When allocating using MMAP, we don't use any of the + hinting mechanisms (inconsistently) supported in various + implementations of unix mmap, or distinguish reserving from + committing memory. Instead, we just ask for space, and exploit + contiguity when we get it. It is probably possible to do + better than this on some systems, but no general scheme seems + to be significantly better. + + Management entails a simpler variant of the consolidation scheme + used for chunks to reduce fragmentation -- new adjacent memory is + normally prepended or appended to an existing segment. However, + there are limitations compared to chunk consolidation that mostly + reflect the fact that segment processing is relatively infrequent + (occurring only when getting memory from system) and that we + don't expect to have huge numbers of segments: + + * Segments are not indexed, so traversal requires linear scans. (It + would be possible to index these, but is not worth the extra + overhead and complexity for most programs on most platforms.) + * New segments are only appended to old ones when holding top-most + memory; if they cannot be prepended to others, they are held in + different segments. + + Except for the top-most segment of an mstate, each segment record + is kept at the tail of its segment. Segments are added by pushing + segment records onto the list headed by &mstate.seg for the + containing mstate. + + Segment flags control allocation/merge/deallocation policies: + * If EXTERN_BIT set, then we did not allocate this segment, + and so should not try to deallocate or merge with others. + (This currently holds only for the initial segment passed + into create_mspace_with_base.) + * If USE_MMAP_BIT set, the segment may be merged with + other surrounding mmapped segments and trimmed/de-allocated + using munmap. + * If neither bit is set, then the segment was obtained using + MORECORE so can be merged with surrounding MORECORE'd segments + and deallocated/trimmed using MORECORE with negative arguments. +*/ + +struct malloc_segment { + char* base; /* base address */ + size_t size; /* allocated size */ + struct malloc_segment* next; /* ptr to next segment */ + flag_t sflags; /* mmap and extern flag */ +}; + +#define is_mmapped_segment(S) ((S)->sflags & USE_MMAP_BIT) +#define is_extern_segment(S) ((S)->sflags & EXTERN_BIT) + +typedef struct malloc_segment msegment; +typedef struct malloc_segment* msegmentptr; + +/* ---------------------------- malloc_state ----------------------------- */ + +/* + A malloc_state holds all of the bookkeeping for a space. + The main fields are: + + Top + The topmost chunk of the currently active segment. Its size is + cached in topsize. The actual size of topmost space is + topsize+TOP_FOOT_SIZE, which includes space reserved for adding + fenceposts and segment records if necessary when getting more + space from the system. The size at which to autotrim top is + cached from mparams in trim_check, except that it is disabled if + an autotrim fails. + + Designated victim (dv) + This is the preferred chunk for servicing small requests that + don't have exact fits. It is normally the chunk split off most + recently to service another small request. Its size is cached in + dvsize. The link fields of this chunk are not maintained since it + is not kept in a bin. + + SmallBins + An array of bin headers for free chunks. These bins hold chunks + with sizes less than MIN_LARGE_SIZE bytes. Each bin contains + chunks of all the same size, spaced 8 bytes apart. To simplify + use in double-linked lists, each bin header acts as a malloc_chunk + pointing to the real first node, if it exists (else pointing to + itself). This avoids special-casing for headers. But to avoid + waste, we allocate only the fd/bk pointers of bins, and then use + repositioning tricks to treat these as the fields of a chunk. + + TreeBins + Treebins are pointers to the roots of trees holding a range of + sizes. There are 2 equally spaced treebins for each power of two + from TREE_SHIFT to TREE_SHIFT+16. The last bin holds anything + larger. + + Bin maps + There is one bit map for small bins ("smallmap") and one for + treebins ("treemap). Each bin sets its bit when non-empty, and + clears the bit when empty. Bit operations are then used to avoid + bin-by-bin searching -- nearly all "search" is done without ever + looking at bins that won't be selected. The bit maps + conservatively use 32 bits per map word, even if on 64bit system. + For a good description of some of the bit-based techniques used + here, see Henry S. Warren Jr's book "Hacker's Delight" (and + supplement at http://hackersdelight.org/). Many of these are + intended to reduce the branchiness of paths through malloc etc, as + well as to reduce the number of memory locations read or written. + + Segments + A list of segments headed by an embedded malloc_segment record + representing the initial space. + + Address check support + The least_addr field is the least address ever obtained from + MORECORE or MMAP. Attempted frees and reallocs of any address less + than this are trapped (unless INSECURE is defined). + + Magic tag + A cross-check field that should always hold same value as mparams.magic. + + Max allowed footprint + The maximum allowed bytes to allocate from system (zero means no limit) + + Flags + Bits recording whether to use MMAP, locks, or contiguous MORECORE + + Statistics + Each space keeps track of current and maximum system memory + obtained via MORECORE or MMAP. + + Trim support + Fields holding the amount of unused topmost memory that should trigger + trimming, and a counter to force periodic scanning to release unused + non-topmost segments. + + Locking + If USE_LOCKS is defined, the "mutex" lock is acquired and released + around every public call using this mspace. + + Extension support + A void* pointer and a size_t field that can be used to help implement + extensions to this malloc. +*/ + +/* Bin types, widths and sizes */ +#define NSMALLBINS (32U) +#define NTREEBINS (32U) +#define SMALLBIN_SHIFT (3U) +#define SMALLBIN_WIDTH (SIZE_T_ONE << SMALLBIN_SHIFT) +#define TREEBIN_SHIFT (8U) +#define MIN_LARGE_SIZE (SIZE_T_ONE << TREEBIN_SHIFT) +#define MAX_SMALL_SIZE (MIN_LARGE_SIZE - SIZE_T_ONE) +#define MAX_SMALL_REQUEST (MAX_SMALL_SIZE - CHUNK_ALIGN_MASK - CHUNK_OVERHEAD) + +struct malloc_state { + binmap_t smallmap; + binmap_t treemap; + size_t dvsize; + size_t topsize; + char* least_addr; + mchunkptr dv; + mchunkptr top; + size_t trim_check; + size_t release_checks; + size_t magic; + mchunkptr smallbins[(NSMALLBINS+1)*2]; + tbinptr treebins[NTREEBINS]; + size_t footprint; + size_t max_footprint; + size_t footprint_limit; /* zero means no limit */ + flag_t mflags; +#if USE_LOCKS + MLOCK_T mutex; /* locate lock among fields that rarely change */ +#endif /* USE_LOCKS */ + msegment seg; + void* extp; /* Unused but available for extensions */ + size_t exts; +}; + +typedef struct malloc_state* mstate; + +/* ------------- Global malloc_state and malloc_params ------------------- */ + +/* + malloc_params holds global properties, including those that can be + dynamically set using mallopt. There is a single instance, mparams, + initialized in init_mparams. Note that the non-zeroness of "magic" + also serves as an initialization flag. +*/ + +struct malloc_params { + size_t magic; + size_t page_size; + size_t granularity; + size_t mmap_threshold; + size_t trim_threshold; + flag_t default_mflags; +}; + +static struct malloc_params mparams; + +/* Ensure mparams initialized */ +#define ensure_initialization() (void)(mparams.magic != 0 || init_mparams()) + +#if !ONLY_MSPACES + +/* The global malloc_state used for all non-"mspace" calls */ +static struct malloc_state _gm_; +#define gm (&_gm_) +#define is_global(M) ((M) == &_gm_) + +#endif /* !ONLY_MSPACES */ + +#define is_initialized(M) ((M)->top != 0) + +/* -------------------------- system alloc setup ------------------------- */ + +/* Operations on mflags */ + +#define use_lock(M) ((M)->mflags & USE_LOCK_BIT) +#define enable_lock(M) ((M)->mflags |= USE_LOCK_BIT) +#if USE_LOCKS +#define disable_lock(M) ((M)->mflags &= ~USE_LOCK_BIT) +#else +#define disable_lock(M) +#endif + +#define use_mmap(M) ((M)->mflags & USE_MMAP_BIT) +#define enable_mmap(M) ((M)->mflags |= USE_MMAP_BIT) +#if HAVE_MMAP +#define disable_mmap(M) ((M)->mflags &= ~USE_MMAP_BIT) +#else +#define disable_mmap(M) +#endif + +#define use_noncontiguous(M) ((M)->mflags & USE_NONCONTIGUOUS_BIT) +#define disable_contiguous(M) ((M)->mflags |= USE_NONCONTIGUOUS_BIT) + +#define set_lock(M,L)\ + ((M)->mflags = (L)?\ + ((M)->mflags | USE_LOCK_BIT) :\ + ((M)->mflags & ~USE_LOCK_BIT)) + +/* page-align a size */ +#define page_align(S)\ + (((S) + (mparams.page_size - SIZE_T_ONE)) & ~(mparams.page_size - SIZE_T_ONE)) + +/* granularity-align a size */ +#define granularity_align(S)\ + (((S) + (mparams.granularity - SIZE_T_ONE))\ + & ~(mparams.granularity - SIZE_T_ONE)) + + +/* For mmap, use granularity alignment on windows, else page-align */ +#ifdef WIN32 +#define mmap_align(S) granularity_align(S) +#else +#define mmap_align(S) page_align(S) +#endif + +/* For sys_alloc, enough padding to ensure can malloc request on success */ +#define SYS_ALLOC_PADDING (TOP_FOOT_SIZE + MALLOC_ALIGNMENT) + +#define is_page_aligned(S)\ + (((size_t)(S) & (mparams.page_size - SIZE_T_ONE)) == 0) +#define is_granularity_aligned(S)\ + (((size_t)(S) & (mparams.granularity - SIZE_T_ONE)) == 0) + +/* True if segment S holds address A */ +#define segment_holds(S, A)\ + ((char*)(A) >= S->base && (char*)(A) < S->base + S->size) + +/* Return segment holding given address */ +static msegmentptr segment_holding(mstate m, char* addr) { + msegmentptr sp = &m->seg; + for (;;) { + if (addr >= sp->base && addr < sp->base + sp->size) + return sp; + if ((sp = sp->next) == 0) + return 0; + } +} + +/* Return true if segment contains a segment link */ +static int has_segment_link(mstate m, msegmentptr ss) { + msegmentptr sp = &m->seg; + for (;;) { + if ((char*)sp >= ss->base && (char*)sp < ss->base + ss->size) + return 1; + if ((sp = sp->next) == 0) + return 0; + } +} + +#ifndef MORECORE_CANNOT_TRIM +#define should_trim(M,s) ((s) > (M)->trim_check) +#else /* MORECORE_CANNOT_TRIM */ +#define should_trim(M,s) (0) +#endif /* MORECORE_CANNOT_TRIM */ + +/* + TOP_FOOT_SIZE is padding at the end of a segment, including space + that may be needed to place segment records and fenceposts when new + noncontiguous segments are added. +*/ +#define TOP_FOOT_SIZE\ + (align_offset(chunk2mem(0))+pad_request(sizeof(struct malloc_segment))+MIN_CHUNK_SIZE) + + +/* ------------------------------- Hooks -------------------------------- */ + +/* + PREACTION should be defined to return 0 on success, and nonzero on + failure. If you are not using locking, you can redefine these to do + anything you like. +*/ + +#if USE_LOCKS +#define PREACTION(M) ((use_lock(M))? ACQUIRE_LOCK(&(M)->mutex) : 0) +#define POSTACTION(M) { if (use_lock(M)) RELEASE_LOCK(&(M)->mutex); } +#else /* USE_LOCKS */ + +#ifndef PREACTION +#define PREACTION(M) (0) +#endif /* PREACTION */ + +#ifndef POSTACTION +#define POSTACTION(M) +#endif /* POSTACTION */ + +#endif /* USE_LOCKS */ + +/* + CORRUPTION_ERROR_ACTION is triggered upon detected bad addresses. + USAGE_ERROR_ACTION is triggered on detected bad frees and + reallocs. The argument p is an address that might have triggered the + fault. It is ignored by the two predefined actions, but might be + useful in custom actions that try to help diagnose errors. +*/ + +#if PROCEED_ON_ERROR + +/* A count of the number of corruption errors causing resets */ +int malloc_corruption_error_count; + +/* default corruption action */ +static void reset_on_error(mstate m); + +#define CORRUPTION_ERROR_ACTION(m) reset_on_error(m) +#define USAGE_ERROR_ACTION(m, p) + +#else /* PROCEED_ON_ERROR */ + +#ifndef CORRUPTION_ERROR_ACTION +#define CORRUPTION_ERROR_ACTION(m) ABORT +#endif /* CORRUPTION_ERROR_ACTION */ + +#ifndef USAGE_ERROR_ACTION +#define USAGE_ERROR_ACTION(m,p) ABORT +#endif /* USAGE_ERROR_ACTION */ + +#endif /* PROCEED_ON_ERROR */ + + +/* -------------------------- Debugging setup ---------------------------- */ + +#if ! DEBUG + +#define check_free_chunk(M,P) +#define check_inuse_chunk(M,P) +#define check_malloced_chunk(M,P,N) +#define check_mmapped_chunk(M,P) +#define check_malloc_state(M) +#define check_top_chunk(M,P) + +#else /* DEBUG */ +#define check_free_chunk(M,P) do_check_free_chunk(M,P) +#define check_inuse_chunk(M,P) do_check_inuse_chunk(M,P) +#define check_top_chunk(M,P) do_check_top_chunk(M,P) +#define check_malloced_chunk(M,P,N) do_check_malloced_chunk(M,P,N) +#define check_mmapped_chunk(M,P) do_check_mmapped_chunk(M,P) +#define check_malloc_state(M) do_check_malloc_state(M) + +static void do_check_any_chunk(mstate m, mchunkptr p); +static void do_check_top_chunk(mstate m, mchunkptr p); +static void do_check_mmapped_chunk(mstate m, mchunkptr p); +static void do_check_inuse_chunk(mstate m, mchunkptr p); +static void do_check_free_chunk(mstate m, mchunkptr p); +static void do_check_malloced_chunk(mstate m, void* mem, size_t s); +static void do_check_tree(mstate m, tchunkptr t); +static void do_check_treebin(mstate m, bindex_t i); +static void do_check_smallbin(mstate m, bindex_t i); +static void do_check_malloc_state(mstate m); +static int bin_find(mstate m, mchunkptr x); +static size_t traverse_and_check(mstate m); +#endif /* DEBUG */ + +/* ---------------------------- Indexing Bins ---------------------------- */ + +#define is_small(s) (((s) >> SMALLBIN_SHIFT) < NSMALLBINS) +#define small_index(s) (bindex_t)((s) >> SMALLBIN_SHIFT) +#define small_index2size(i) ((i) << SMALLBIN_SHIFT) +#define MIN_SMALL_INDEX (small_index(MIN_CHUNK_SIZE)) + +/* addressing by index. See above about smallbin repositioning */ +#define smallbin_at(M, i) ((sbinptr)((char*)&((M)->smallbins[(i)<<1]))) +#define treebin_at(M,i) (&((M)->treebins[i])) + +/* assign tree index for size S to variable I. Use x86 asm if possible */ +#if defined(__GNUC__) && (defined(__i386__) || defined(__x86_64__)) +#define compute_tree_index(S, I)\ +{\ + unsigned int X = S >> TREEBIN_SHIFT;\ + if (X == 0)\ + I = 0;\ + else if (X > 0xFFFF)\ + I = NTREEBINS-1;\ + else {\ + unsigned int K = (unsigned) sizeof(X)*__CHAR_BIT__ - 1 - (unsigned) __builtin_clz(X); \ + I = (bindex_t)((K << 1) + ((S >> (K + (TREEBIN_SHIFT-1)) & 1)));\ + }\ +} + +#elif defined (__INTEL_COMPILER) +#define compute_tree_index(S, I)\ +{\ + size_t X = S >> TREEBIN_SHIFT;\ + if (X == 0)\ + I = 0;\ + else if (X > 0xFFFF)\ + I = NTREEBINS-1;\ + else {\ + unsigned int K = _bit_scan_reverse (X); \ + I = (bindex_t)((K << 1) + ((S >> (K + (TREEBIN_SHIFT-1)) & 1)));\ + }\ +} + +#elif defined(_MSC_VER) && _MSC_VER>=1300 +#define compute_tree_index(S, I)\ +{\ + size_t X = S >> TREEBIN_SHIFT;\ + if (X == 0)\ + I = 0;\ + else if (X > 0xFFFF)\ + I = NTREEBINS-1;\ + else {\ + unsigned int K;\ + _BitScanReverse((DWORD *) &K, (DWORD) X);\ + I = (bindex_t)((K << 1) + ((S >> (K + (TREEBIN_SHIFT-1)) & 1)));\ + }\ +} + +#else /* GNUC */ +#define compute_tree_index(S, I)\ +{\ + size_t X = S >> TREEBIN_SHIFT;\ + if (X == 0)\ + I = 0;\ + else if (X > 0xFFFF)\ + I = NTREEBINS-1;\ + else {\ + unsigned int Y = (unsigned int)X;\ + unsigned int N = ((Y - 0x100) >> 16) & 8;\ + unsigned int K = (((Y <<= N) - 0x1000) >> 16) & 4;\ + N += K;\ + N += K = (((Y <<= K) - 0x4000) >> 16) & 2;\ + K = 14 - N + ((Y <<= K) >> 15);\ + I = (K << 1) + ((S >> (K + (TREEBIN_SHIFT-1)) & 1));\ + }\ +} +#endif /* GNUC */ + +/* Bit representing maximum resolved size in a treebin at i */ +#define bit_for_tree_index(i) \ + (i == NTREEBINS-1)? (SIZE_T_BITSIZE-1) : (((i) >> 1) + TREEBIN_SHIFT - 2) + +/* Shift placing maximum resolved bit in a treebin at i as sign bit */ +#define leftshift_for_tree_index(i) \ + ((i == NTREEBINS-1)? 0 : \ + ((SIZE_T_BITSIZE-SIZE_T_ONE) - (((i) >> 1) + TREEBIN_SHIFT - 2))) + +/* The size of the smallest chunk held in bin with index i */ +#define minsize_for_tree_index(i) \ + ((SIZE_T_ONE << (((i) >> 1) + TREEBIN_SHIFT)) | \ + (((size_t)((i) & SIZE_T_ONE)) << (((i) >> 1) + TREEBIN_SHIFT - 1))) + + +/* ------------------------ Operations on bin maps ----------------------- */ + +/* bit corresponding to given index */ +#define idx2bit(i) ((binmap_t)(1) << (i)) + +/* Mark/Clear bits with given index */ +#define mark_smallmap(M,i) ((M)->smallmap |= idx2bit(i)) +#define clear_smallmap(M,i) ((M)->smallmap &= ~idx2bit(i)) +#define smallmap_is_marked(M,i) ((M)->smallmap & idx2bit(i)) + +#define mark_treemap(M,i) ((M)->treemap |= idx2bit(i)) +#define clear_treemap(M,i) ((M)->treemap &= ~idx2bit(i)) +#define treemap_is_marked(M,i) ((M)->treemap & idx2bit(i)) + +/* isolate the least set bit of a bitmap */ +#define least_bit(x) ((x) & -(x)) + +/* mask with all bits to left of least bit of x on */ +#define left_bits(x) ((x<<1) | -(x<<1)) + +/* mask with all bits to left of or equal to least bit of x on */ +#define same_or_left_bits(x) ((x) | -(x)) + +/* index corresponding to given bit. Use x86 asm if possible */ + +#if defined(__GNUC__) && (defined(__i386__) || defined(__x86_64__)) +#define compute_bit2idx(X, I)\ +{\ + unsigned int J;\ + J = __builtin_ctz(X); \ + I = (bindex_t)J;\ +} + +#elif defined (__INTEL_COMPILER) +#define compute_bit2idx(X, I)\ +{\ + unsigned int J;\ + J = _bit_scan_forward (X); \ + I = (bindex_t)J;\ +} + +#elif defined(_MSC_VER) && _MSC_VER>=1300 +#define compute_bit2idx(X, I)\ +{\ + unsigned int J;\ + _BitScanForward((DWORD *) &J, X);\ + I = (bindex_t)J;\ +} + +#elif USE_BUILTIN_FFS +#define compute_bit2idx(X, I) I = ffs(X)-1 + +#else +#define compute_bit2idx(X, I)\ +{\ + unsigned int Y = X - 1;\ + unsigned int K = Y >> (16-4) & 16;\ + unsigned int N = K; Y >>= K;\ + N += K = Y >> (8-3) & 8; Y >>= K;\ + N += K = Y >> (4-2) & 4; Y >>= K;\ + N += K = Y >> (2-1) & 2; Y >>= K;\ + N += K = Y >> (1-0) & 1; Y >>= K;\ + I = (bindex_t)(N + Y);\ +} +#endif /* GNUC */ + + +/* ----------------------- Runtime Check Support ------------------------- */ + +/* + For security, the main invariant is that malloc/free/etc never + writes to a static address other than malloc_state, unless static + malloc_state itself has been corrupted, which cannot occur via + malloc (because of these checks). In essence this means that we + believe all pointers, sizes, maps etc held in malloc_state, but + check all of those linked or offsetted from other embedded data + structures. These checks are interspersed with main code in a way + that tends to minimize their run-time cost. + + When FOOTERS is defined, in addition to range checking, we also + verify footer fields of inuse chunks, which can be used guarantee + that the mstate controlling malloc/free is intact. This is a + streamlined version of the approach described by William Robertson + et al in "Run-time Detection of Heap-based Overflows" LISA'03 + http://www.usenix.org/events/lisa03/tech/robertson.html The footer + of an inuse chunk holds the xor of its mstate and a random seed, + that is checked upon calls to free() and realloc(). This is + (probabalistically) unguessable from outside the program, but can be + computed by any code successfully malloc'ing any chunk, so does not + itself provide protection against code that has already broken + security through some other means. Unlike Robertson et al, we + always dynamically check addresses of all offset chunks (previous, + next, etc). This turns out to be cheaper than relying on hashes. +*/ + +#if !INSECURE +/* Check if address a is at least as high as any from MORECORE or MMAP */ +#define ok_address(M, a) ((char*)(a) >= (M)->least_addr) +/* Check if address of next chunk n is higher than base chunk p */ +#define ok_next(p, n) ((char*)(p) < (char*)(n)) +/* Check if p has inuse status */ +#define ok_inuse(p) is_inuse(p) +/* Check if p has its pinuse bit on */ +#define ok_pinuse(p) pinuse(p) + +#else /* !INSECURE */ +#define ok_address(M, a) (1) +#define ok_next(b, n) (1) +#define ok_inuse(p) (1) +#define ok_pinuse(p) (1) +#endif /* !INSECURE */ + +#if (FOOTERS && !INSECURE) +/* Check if (alleged) mstate m has expected magic field */ +#define ok_magic(M) ((M)->magic == mparams.magic) +#else /* (FOOTERS && !INSECURE) */ +#define ok_magic(M) (1) +#endif /* (FOOTERS && !INSECURE) */ + +/* In gcc, use __builtin_expect to minimize impact of checks */ +#if !INSECURE +#if defined(__GNUC__) && __GNUC__ >= 3 +#define RTCHECK(e) __builtin_expect(e, 1) +#else /* GNUC */ +#define RTCHECK(e) (e) +#endif /* GNUC */ +#else /* !INSECURE */ +#define RTCHECK(e) (1) +#endif /* !INSECURE */ + +/* macros to set up inuse chunks with or without footers */ + +#if !FOOTERS + +#define mark_inuse_foot(M,p,s) + +/* Macros for setting head/foot of non-mmapped chunks */ + +/* Set cinuse bit and pinuse bit of next chunk */ +#define set_inuse(M,p,s)\ + ((p)->head = (((p)->head & PINUSE_BIT)|s|CINUSE_BIT),\ + ((mchunkptr)(((char*)(p)) + (s)))->head |= PINUSE_BIT) + +/* Set cinuse and pinuse of this chunk and pinuse of next chunk */ +#define set_inuse_and_pinuse(M,p,s)\ + ((p)->head = (s|PINUSE_BIT|CINUSE_BIT),\ + ((mchunkptr)(((char*)(p)) + (s)))->head |= PINUSE_BIT) + +/* Set size, cinuse and pinuse bit of this chunk */ +#define set_size_and_pinuse_of_inuse_chunk(M, p, s)\ + ((p)->head = (s|PINUSE_BIT|CINUSE_BIT)) + +#else /* FOOTERS */ + +/* Set foot of inuse chunk to be xor of mstate and seed */ +#define mark_inuse_foot(M,p,s)\ + (((mchunkptr)((char*)(p) + (s)))->prev_foot = ((size_t)(M) ^ mparams.magic)) + +#define get_mstate_for(p)\ + ((mstate)(((mchunkptr)((char*)(p) +\ + (chunksize(p))))->prev_foot ^ mparams.magic)) + +#define set_inuse(M,p,s)\ + ((p)->head = (((p)->head & PINUSE_BIT)|s|CINUSE_BIT),\ + (((mchunkptr)(((char*)(p)) + (s)))->head |= PINUSE_BIT), \ + mark_inuse_foot(M,p,s)) + +#define set_inuse_and_pinuse(M,p,s)\ + ((p)->head = (s|PINUSE_BIT|CINUSE_BIT),\ + (((mchunkptr)(((char*)(p)) + (s)))->head |= PINUSE_BIT),\ + mark_inuse_foot(M,p,s)) + +#define set_size_and_pinuse_of_inuse_chunk(M, p, s)\ + ((p)->head = (s|PINUSE_BIT|CINUSE_BIT),\ + mark_inuse_foot(M, p, s)) + +#endif /* !FOOTERS */ + +/* ---------------------------- setting mparams -------------------------- */ + +#if LOCK_AT_FORK +static void pre_fork(void) { ACQUIRE_LOCK(&(gm)->mutex); } +static void post_fork_parent(void) { RELEASE_LOCK(&(gm)->mutex); } +static void post_fork_child(void) { INITIAL_LOCK(&(gm)->mutex); } +#endif /* LOCK_AT_FORK */ + +/* Initialize mparams */ +static int init_mparams(void) { +#ifdef NEED_GLOBAL_LOCK_INIT + if (malloc_global_mutex_status <= 0) + init_malloc_global_mutex(); +#endif + + ACQUIRE_MALLOC_GLOBAL_LOCK(); + if (mparams.magic == 0) { + size_t magic; + size_t psize; + size_t gsize; + +#ifndef WIN32 + psize = malloc_getpagesize; + gsize = ((DEFAULT_GRANULARITY != 0)? DEFAULT_GRANULARITY : psize); +#else /* WIN32 */ + { + SYSTEM_INFO system_info; + GetSystemInfo(&system_info); + psize = system_info.dwPageSize; + gsize = ((DEFAULT_GRANULARITY != 0)? + DEFAULT_GRANULARITY : system_info.dwAllocationGranularity); + } +#endif /* WIN32 */ + + /* Sanity-check configuration: + size_t must be unsigned and as wide as pointer type. + ints must be at least 4 bytes. + alignment must be at least 8. + Alignment, min chunk size, and page size must all be powers of 2. + */ + if ((sizeof(size_t) != sizeof(char*)) || + (MAX_SIZE_T < MIN_CHUNK_SIZE) || + (sizeof(int) < 4) || + (MALLOC_ALIGNMENT < (size_t)8U) || + ((MALLOC_ALIGNMENT & (MALLOC_ALIGNMENT-SIZE_T_ONE)) != 0) || + ((MCHUNK_SIZE & (MCHUNK_SIZE-SIZE_T_ONE)) != 0) || + ((gsize & (gsize-SIZE_T_ONE)) != 0) || + ((psize & (psize-SIZE_T_ONE)) != 0)) + ABORT; + mparams.granularity = gsize; + mparams.page_size = psize; + mparams.mmap_threshold = DEFAULT_MMAP_THRESHOLD; + mparams.trim_threshold = DEFAULT_TRIM_THRESHOLD; +#if MORECORE_CONTIGUOUS + mparams.default_mflags = USE_LOCK_BIT|USE_MMAP_BIT; +#else /* MORECORE_CONTIGUOUS */ + mparams.default_mflags = USE_LOCK_BIT|USE_MMAP_BIT|USE_NONCONTIGUOUS_BIT; +#endif /* MORECORE_CONTIGUOUS */ + +#if !ONLY_MSPACES + /* Set up lock for main malloc area */ + gm->mflags = mparams.default_mflags; + (void)INITIAL_LOCK(&gm->mutex); +#endif +#if LOCK_AT_FORK + pthread_atfork(&pre_fork, &post_fork_parent, &post_fork_child); +#endif + + { +#if USE_DEV_RANDOM + int fd; + unsigned char buf[sizeof(size_t)]; + /* Try to use /dev/urandom, else fall back on using time */ + if ((fd = open("/dev/urandom", O_RDONLY)) >= 0 && + read(fd, buf, sizeof(buf)) == sizeof(buf)) { + magic = *((size_t *) buf); + close(fd); + } + else +#endif /* USE_DEV_RANDOM */ +#ifdef WIN32 + magic = (size_t)(GetTickCount() ^ (size_t)0x55555555U); +#elif defined(LACKS_TIME_H) + magic = (size_t)&magic ^ (size_t)0x55555555U; +#else + magic = (size_t)(time(0) ^ (size_t)0x55555555U); +#endif + magic |= (size_t)8U; /* ensure nonzero */ + magic &= ~(size_t)7U; /* improve chances of fault for bad values */ + /* Until memory modes commonly available, use volatile-write */ + (*(volatile size_t *)(&(mparams.magic))) = magic; + } + } + + RELEASE_MALLOC_GLOBAL_LOCK(); + return 1; +} + +/* support for mallopt */ +static int change_mparam(int param_number, int value) { + size_t val; + ensure_initialization(); + val = (value == -1)? MAX_SIZE_T : (size_t)value; + switch(param_number) { + case M_TRIM_THRESHOLD: + mparams.trim_threshold = val; + return 1; + case M_GRANULARITY: + if (val >= mparams.page_size && ((val & (val-1)) == 0)) { + mparams.granularity = val; + return 1; + } + else + return 0; + case M_MMAP_THRESHOLD: + mparams.mmap_threshold = val; + return 1; + default: + return 0; + } +} + +#if DEBUG +/* ------------------------- Debugging Support --------------------------- */ + +/* Check properties of any chunk, whether free, inuse, mmapped etc */ +static void do_check_any_chunk(mstate m, mchunkptr p) { + assert((is_aligned(chunk2mem(p))) || (p->head == FENCEPOST_HEAD)); + assert(ok_address(m, p)); +} + +/* Check properties of top chunk */ +static void do_check_top_chunk(mstate m, mchunkptr p) { + msegmentptr sp = segment_holding(m, (char*)p); + size_t sz = p->head & ~INUSE_BITS; /* third-lowest bit can be set! */ + assert(sp != 0); + assert((is_aligned(chunk2mem(p))) || (p->head == FENCEPOST_HEAD)); + assert(ok_address(m, p)); + assert(sz == m->topsize); + assert(sz > 0); + assert(sz == ((sp->base + sp->size) - (char*)p) - TOP_FOOT_SIZE); + assert(pinuse(p)); + assert(!pinuse(chunk_plus_offset(p, sz))); +} + +/* Check properties of (inuse) mmapped chunks */ +static void do_check_mmapped_chunk(mstate m, mchunkptr p) { + size_t sz = chunksize(p); + size_t len = (sz + (p->prev_foot) + MMAP_FOOT_PAD); + assert(is_mmapped(p)); + assert(use_mmap(m)); + assert((is_aligned(chunk2mem(p))) || (p->head == FENCEPOST_HEAD)); + assert(ok_address(m, p)); + assert(!is_small(sz)); + assert((len & (mparams.page_size-SIZE_T_ONE)) == 0); + assert(chunk_plus_offset(p, sz)->head == FENCEPOST_HEAD); + assert(chunk_plus_offset(p, sz+SIZE_T_SIZE)->head == 0); +} + +/* Check properties of inuse chunks */ +static void do_check_inuse_chunk(mstate m, mchunkptr p) { + do_check_any_chunk(m, p); + assert(is_inuse(p)); + assert(next_pinuse(p)); + /* If not pinuse and not mmapped, previous chunk has OK offset */ + assert(is_mmapped(p) || pinuse(p) || next_chunk(prev_chunk(p)) == p); + if (is_mmapped(p)) + do_check_mmapped_chunk(m, p); +} + +/* Check properties of free chunks */ +static void do_check_free_chunk(mstate m, mchunkptr p) { + size_t sz = chunksize(p); + mchunkptr next = chunk_plus_offset(p, sz); + do_check_any_chunk(m, p); + assert(!is_inuse(p)); + assert(!next_pinuse(p)); + assert (!is_mmapped(p)); + if (p != m->dv && p != m->top) { + if (sz >= MIN_CHUNK_SIZE) { + assert((sz & CHUNK_ALIGN_MASK) == 0); + assert(is_aligned(chunk2mem(p))); + assert(next->prev_foot == sz); + assert(pinuse(p)); + assert (next == m->top || is_inuse(next)); + assert(p->fd->bk == p); + assert(p->bk->fd == p); + } + else /* markers are always of size SIZE_T_SIZE */ + assert(sz == SIZE_T_SIZE); + } +} + +/* Check properties of malloced chunks at the point they are malloced */ +static void do_check_malloced_chunk(mstate m, void* mem, size_t s) { + if (mem != 0) { + mchunkptr p = mem2chunk(mem); + size_t sz = p->head & ~INUSE_BITS; + do_check_inuse_chunk(m, p); + assert((sz & CHUNK_ALIGN_MASK) == 0); + assert(sz >= MIN_CHUNK_SIZE); + assert(sz >= s); + /* unless mmapped, size is less than MIN_CHUNK_SIZE more than request */ + assert(is_mmapped(p) || sz < (s + MIN_CHUNK_SIZE)); + } +} + +/* Check a tree and its subtrees. */ +static void do_check_tree(mstate m, tchunkptr t) { + tchunkptr head = 0; + tchunkptr u = t; + bindex_t tindex = t->index; + size_t tsize = chunksize(t); + bindex_t idx; + compute_tree_index(tsize, idx); + assert(tindex == idx); + assert(tsize >= MIN_LARGE_SIZE); + assert(tsize >= minsize_for_tree_index(idx)); + assert((idx == NTREEBINS-1) || (tsize < minsize_for_tree_index((idx+1)))); + + do { /* traverse through chain of same-sized nodes */ + do_check_any_chunk(m, ((mchunkptr)u)); + assert(u->index == tindex); + assert(chunksize(u) == tsize); + assert(!is_inuse(u)); + assert(!next_pinuse(u)); + assert(u->fd->bk == u); + assert(u->bk->fd == u); + if (u->parent == 0) { + assert(u->child[0] == 0); + assert(u->child[1] == 0); + } + else { + assert(head == 0); /* only one node on chain has parent */ + head = u; + assert(u->parent != u); + assert (u->parent->child[0] == u || + u->parent->child[1] == u || + *((tbinptr*)(u->parent)) == u); + if (u->child[0] != 0) { + assert(u->child[0]->parent == u); + assert(u->child[0] != u); + do_check_tree(m, u->child[0]); + } + if (u->child[1] != 0) { + assert(u->child[1]->parent == u); + assert(u->child[1] != u); + do_check_tree(m, u->child[1]); + } + if (u->child[0] != 0 && u->child[1] != 0) { + assert(chunksize(u->child[0]) < chunksize(u->child[1])); + } + } + u = u->fd; + } while (u != t); + assert(head != 0); +} + +/* Check all the chunks in a treebin. */ +static void do_check_treebin(mstate m, bindex_t i) { + tbinptr* tb = treebin_at(m, i); + tchunkptr t = *tb; + int empty = (m->treemap & (1U << i)) == 0; + if (t == 0) + assert(empty); + if (!empty) + do_check_tree(m, t); +} + +/* Check all the chunks in a smallbin. */ +static void do_check_smallbin(mstate m, bindex_t i) { + sbinptr b = smallbin_at(m, i); + mchunkptr p = b->bk; + unsigned int empty = (m->smallmap & (1U << i)) == 0; + if (p == b) + assert(empty); + if (!empty) { + for (; p != b; p = p->bk) { + size_t size = chunksize(p); + mchunkptr q; + /* each chunk claims to be free */ + do_check_free_chunk(m, p); + /* chunk belongs in bin */ + assert(small_index(size) == i); + assert(p->bk == b || chunksize(p->bk) == chunksize(p)); + /* chunk is followed by an inuse chunk */ + q = next_chunk(p); + if (q->head != FENCEPOST_HEAD) + do_check_inuse_chunk(m, q); + } + } +} + +/* Find x in a bin. Used in other check functions. */ +static int bin_find(mstate m, mchunkptr x) { + size_t size = chunksize(x); + if (is_small(size)) { + bindex_t sidx = small_index(size); + sbinptr b = smallbin_at(m, sidx); + if (smallmap_is_marked(m, sidx)) { + mchunkptr p = b; + do { + if (p == x) + return 1; + } while ((p = p->fd) != b); + } + } + else { + bindex_t tidx; + compute_tree_index(size, tidx); + if (treemap_is_marked(m, tidx)) { + tchunkptr t = *treebin_at(m, tidx); + size_t sizebits = size << leftshift_for_tree_index(tidx); + while (t != 0 && chunksize(t) != size) { + t = t->child[(sizebits >> (SIZE_T_BITSIZE-SIZE_T_ONE)) & 1]; + sizebits <<= 1; + } + if (t != 0) { + tchunkptr u = t; + do { + if (u == (tchunkptr)x) + return 1; + } while ((u = u->fd) != t); + } + } + } + return 0; +} + +/* Traverse each chunk and check it; return total */ +static size_t traverse_and_check(mstate m) { + size_t sum = 0; + if (is_initialized(m)) { + msegmentptr s = &m->seg; + sum += m->topsize + TOP_FOOT_SIZE; + while (s != 0) { + mchunkptr q = align_as_chunk(s->base); + mchunkptr lastq = 0; + assert(pinuse(q)); + while (segment_holds(s, q) && + q != m->top && q->head != FENCEPOST_HEAD) { + sum += chunksize(q); + if (is_inuse(q)) { + assert(!bin_find(m, q)); + do_check_inuse_chunk(m, q); + } + else { + assert(q == m->dv || bin_find(m, q)); + assert(lastq == 0 || is_inuse(lastq)); /* Not 2 consecutive free */ + do_check_free_chunk(m, q); + } + lastq = q; + q = next_chunk(q); + } + s = s->next; + } + } + return sum; +} + + +/* Check all properties of malloc_state. */ +static void do_check_malloc_state(mstate m) { + bindex_t i; + size_t total; + /* check bins */ + for (i = 0; i < NSMALLBINS; ++i) + do_check_smallbin(m, i); + for (i = 0; i < NTREEBINS; ++i) + do_check_treebin(m, i); + + if (m->dvsize != 0) { /* check dv chunk */ + do_check_any_chunk(m, m->dv); + assert(m->dvsize == chunksize(m->dv)); + assert(m->dvsize >= MIN_CHUNK_SIZE); + assert(bin_find(m, m->dv) == 0); + } + + if (m->top != 0) { /* check top chunk */ + do_check_top_chunk(m, m->top); + /*assert(m->topsize == chunksize(m->top)); redundant */ + assert(m->topsize > 0); + assert(bin_find(m, m->top) == 0); + } + + total = traverse_and_check(m); + assert(total <= m->footprint); + assert(m->footprint <= m->max_footprint); +} +#endif /* DEBUG */ + +/* ----------------------------- statistics ------------------------------ */ + +#if !NO_MALLINFO +static struct mallinfo internal_mallinfo(mstate m) { + struct mallinfo nm = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + ensure_initialization(); + if (!PREACTION(m)) { + check_malloc_state(m); + if (is_initialized(m)) { + size_t nfree = SIZE_T_ONE; /* top always free */ + size_t mfree = m->topsize + TOP_FOOT_SIZE; + size_t sum = mfree; + msegmentptr s = &m->seg; + while (s != 0) { + mchunkptr q = align_as_chunk(s->base); + while (segment_holds(s, q) && + q != m->top && q->head != FENCEPOST_HEAD) { + size_t sz = chunksize(q); + sum += sz; + if (!is_inuse(q)) { + mfree += sz; + ++nfree; + } + q = next_chunk(q); + } + s = s->next; + } + + nm.arena = sum; + nm.ordblks = nfree; + nm.hblkhd = m->footprint - sum; + nm.usmblks = m->max_footprint; + nm.uordblks = m->footprint - mfree; + nm.fordblks = mfree; + nm.keepcost = m->topsize; + } + + POSTACTION(m); + } + return nm; +} +#endif /* !NO_MALLINFO */ + +#if !NO_MALLOC_STATS +static void internal_malloc_stats(mstate m) { + ensure_initialization(); + if (!PREACTION(m)) { + size_t maxfp = 0; + size_t fp = 0; + size_t used = 0; + check_malloc_state(m); + if (is_initialized(m)) { + msegmentptr s = &m->seg; + maxfp = m->max_footprint; + fp = m->footprint; + used = fp - (m->topsize + TOP_FOOT_SIZE); + + while (s != 0) { + mchunkptr q = align_as_chunk(s->base); + while (segment_holds(s, q) && + q != m->top && q->head != FENCEPOST_HEAD) { + if (!is_inuse(q)) + used -= chunksize(q); + q = next_chunk(q); + } + s = s->next; + } + } + POSTACTION(m); /* drop lock */ + fprintf(stderr, "max system bytes = %10lu\n", (unsigned long)(maxfp)); + fprintf(stderr, "system bytes = %10lu\n", (unsigned long)(fp)); + fprintf(stderr, "in use bytes = %10lu\n", (unsigned long)(used)); + } +} +#endif /* NO_MALLOC_STATS */ + +/* ----------------------- Operations on smallbins ----------------------- */ + +/* + Various forms of linking and unlinking are defined as macros. Even + the ones for trees, which are very long but have very short typical + paths. This is ugly but reduces reliance on inlining support of + compilers. +*/ + +/* Link a free chunk into a smallbin */ +#define insert_small_chunk(M, P, S) {\ + bindex_t I = small_index(S);\ + mchunkptr B = smallbin_at(M, I);\ + mchunkptr F = B;\ + assert(S >= MIN_CHUNK_SIZE);\ + if (!smallmap_is_marked(M, I))\ + mark_smallmap(M, I);\ + else if (RTCHECK(ok_address(M, B->fd)))\ + F = B->fd;\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + B->fd = P;\ + F->bk = P;\ + P->fd = F;\ + P->bk = B;\ +} + +/* Unlink a chunk from a smallbin */ +#define unlink_small_chunk(M, P, S) {\ + mchunkptr F = P->fd;\ + mchunkptr B = P->bk;\ + bindex_t I = small_index(S);\ + assert(P != B);\ + assert(P != F);\ + assert(chunksize(P) == small_index2size(I));\ + if (RTCHECK(F == smallbin_at(M,I) || (ok_address(M, F) && F->bk == P))) { \ + if (B == F) {\ + clear_smallmap(M, I);\ + }\ + else if (RTCHECK(B == smallbin_at(M,I) ||\ + (ok_address(M, B) && B->fd == P))) {\ + F->bk = B;\ + B->fd = F;\ + }\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + }\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + }\ +} + +/* Unlink the first chunk from a smallbin */ +#define unlink_first_small_chunk(M, B, P, I) {\ + mchunkptr F = P->fd;\ + assert(P != B);\ + assert(P != F);\ + assert(chunksize(P) == small_index2size(I));\ + if (B == F) {\ + clear_smallmap(M, I);\ + }\ + else if (RTCHECK(ok_address(M, F) && F->bk == P)) {\ + F->bk = B;\ + B->fd = F;\ + }\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + }\ +} + +/* Replace dv node, binning the old one */ +/* Used only when dvsize known to be small */ +#define replace_dv(M, P, S) {\ + size_t DVS = M->dvsize;\ + assert(is_small(DVS));\ + if (DVS != 0) {\ + mchunkptr DV = M->dv;\ + insert_small_chunk(M, DV, DVS);\ + }\ + M->dvsize = S;\ + M->dv = P;\ +} + +/* ------------------------- Operations on trees ------------------------- */ + +/* Insert chunk into tree */ +#define insert_large_chunk(M, X, S) {\ + tbinptr* H;\ + bindex_t I;\ + compute_tree_index(S, I);\ + H = treebin_at(M, I);\ + X->index = I;\ + X->child[0] = X->child[1] = 0;\ + if (!treemap_is_marked(M, I)) {\ + mark_treemap(M, I);\ + *H = X;\ + X->parent = (tchunkptr)H;\ + X->fd = X->bk = X;\ + }\ + else {\ + tchunkptr T = *H;\ + size_t K = S << leftshift_for_tree_index(I);\ + for (;;) {\ + if (chunksize(T) != S) {\ + tchunkptr* C = &(T->child[(K >> (SIZE_T_BITSIZE-SIZE_T_ONE)) & 1]);\ + K <<= 1;\ + if (*C != 0)\ + T = *C;\ + else if (RTCHECK(ok_address(M, C))) {\ + *C = X;\ + X->parent = T;\ + X->fd = X->bk = X;\ + break;\ + }\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + break;\ + }\ + }\ + else {\ + tchunkptr F = T->fd;\ + if (RTCHECK(ok_address(M, T) && ok_address(M, F))) {\ + T->fd = F->bk = X;\ + X->fd = F;\ + X->bk = T;\ + X->parent = 0;\ + break;\ + }\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + break;\ + }\ + }\ + }\ + }\ +} + +/* + Unlink steps: + + 1. If x is a chained node, unlink it from its same-sized fd/bk links + and choose its bk node as its replacement. + 2. If x was the last node of its size, but not a leaf node, it must + be replaced with a leaf node (not merely one with an open left or + right), to make sure that lefts and rights of descendents + correspond properly to bit masks. We use the rightmost descendent + of x. We could use any other leaf, but this is easy to locate and + tends to counteract removal of leftmosts elsewhere, and so keeps + paths shorter than minimally guaranteed. This doesn't loop much + because on average a node in a tree is near the bottom. + 3. If x is the base of a chain (i.e., has parent links) relink + x's parent and children to x's replacement (or null if none). +*/ + +#define unlink_large_chunk(M, X) {\ + tchunkptr XP = X->parent;\ + tchunkptr R;\ + if (X->bk != X) {\ + tchunkptr F = X->fd;\ + R = X->bk;\ + if (RTCHECK(ok_address(M, F) && F->bk == X && R->fd == X)) {\ + F->bk = R;\ + R->fd = F;\ + }\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + }\ + else {\ + tchunkptr* RP;\ + if (((R = *(RP = &(X->child[1]))) != 0) ||\ + ((R = *(RP = &(X->child[0]))) != 0)) {\ + tchunkptr* CP;\ + while ((*(CP = &(R->child[1])) != 0) ||\ + (*(CP = &(R->child[0])) != 0)) {\ + R = *(RP = CP);\ + }\ + if (RTCHECK(ok_address(M, RP)))\ + *RP = 0;\ + else {\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + }\ + }\ + if (XP != 0) {\ + tbinptr* H = treebin_at(M, X->index);\ + if (X == *H) {\ + if ((*H = R) == 0) \ + clear_treemap(M, X->index);\ + }\ + else if (RTCHECK(ok_address(M, XP))) {\ + if (XP->child[0] == X) \ + XP->child[0] = R;\ + else \ + XP->child[1] = R;\ + }\ + else\ + CORRUPTION_ERROR_ACTION(M);\ + if (R != 0) {\ + if (RTCHECK(ok_address(M, R))) {\ + tchunkptr C0, C1;\ + R->parent = XP;\ + if ((C0 = X->child[0]) != 0) {\ + if (RTCHECK(ok_address(M, C0))) {\ + R->child[0] = C0;\ + C0->parent = R;\ + }\ + else\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + if ((C1 = X->child[1]) != 0) {\ + if (RTCHECK(ok_address(M, C1))) {\ + R->child[1] = C1;\ + C1->parent = R;\ + }\ + else\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + }\ + else\ + CORRUPTION_ERROR_ACTION(M);\ + }\ + }\ +} + +/* Relays to large vs small bin operations */ + +#define insert_chunk(M, P, S)\ + if (is_small(S)) insert_small_chunk(M, P, S)\ + else { tchunkptr TP = (tchunkptr)(P); insert_large_chunk(M, TP, S); } + +#define unlink_chunk(M, P, S)\ + if (is_small(S)) unlink_small_chunk(M, P, S)\ + else { tchunkptr TP = (tchunkptr)(P); unlink_large_chunk(M, TP); } + + +/* Relays to internal calls to malloc/free from realloc, memalign etc */ + +#if ONLY_MSPACES +#define internal_malloc(m, b) mspace_malloc(m, b) +#define internal_free(m, mem) mspace_free(m,mem); +#else /* ONLY_MSPACES */ +#if MSPACES +#define internal_malloc(m, b)\ + ((m == gm)? dlmalloc(b) : mspace_malloc(m, b)) +#define internal_free(m, mem)\ + if (m == gm) dlfree(mem); else mspace_free(m,mem); +#else /* MSPACES */ +#define internal_malloc(m, b) dlmalloc(b) +#define internal_free(m, mem) dlfree(mem) +#endif /* MSPACES */ +#endif /* ONLY_MSPACES */ + +/* ----------------------- Direct-mmapping chunks ----------------------- */ + +/* + Directly mmapped chunks are set up with an offset to the start of + the mmapped region stored in the prev_foot field of the chunk. This + allows reconstruction of the required argument to MUNMAP when freed, + and also allows adjustment of the returned chunk to meet alignment + requirements (especially in memalign). +*/ + +/* Malloc using mmap */ +static void* mmap_alloc(mstate m, size_t nb) { + size_t mmsize = mmap_align(nb + SIX_SIZE_T_SIZES + CHUNK_ALIGN_MASK); + if (m->footprint_limit != 0) { + size_t fp = m->footprint + mmsize; + if (fp <= m->footprint || fp > m->footprint_limit) + return 0; + } + if (mmsize > nb) { /* Check for wrap around 0 */ + char* mm = (char*)(CALL_DIRECT_MMAP(mmsize)); + if (mm != CMFAIL) { + size_t offset = align_offset(chunk2mem(mm)); + size_t psize = mmsize - offset - MMAP_FOOT_PAD; + mchunkptr p = (mchunkptr)(mm + offset); + p->prev_foot = offset; + p->head = psize; + mark_inuse_foot(m, p, psize); + chunk_plus_offset(p, psize)->head = FENCEPOST_HEAD; + chunk_plus_offset(p, psize+SIZE_T_SIZE)->head = 0; + + if (m->least_addr == 0 || mm < m->least_addr) + m->least_addr = mm; + if ((m->footprint += mmsize) > m->max_footprint) + m->max_footprint = m->footprint; + assert(is_aligned(chunk2mem(p))); + check_mmapped_chunk(m, p); + return chunk2mem(p); + } + } + return 0; +} + +/* Realloc using mmap */ +static mchunkptr mmap_resize(mstate m, mchunkptr oldp, size_t nb, int flags) { + size_t oldsize = chunksize(oldp); + (void)flags; /* placate people compiling -Wunused */ + if (is_small(nb)) /* Can't shrink mmap regions below small size */ + return 0; + /* Keep old chunk if big enough but not too big */ + if (oldsize >= nb + SIZE_T_SIZE && + (oldsize - nb) <= (mparams.granularity << 1)) + return oldp; + else { + size_t offset = oldp->prev_foot; + size_t oldmmsize = oldsize + offset + MMAP_FOOT_PAD; + size_t newmmsize = mmap_align(nb + SIX_SIZE_T_SIZES + CHUNK_ALIGN_MASK); + char* cp = (char*)CALL_MREMAP((char*)oldp - offset, + oldmmsize, newmmsize, flags); + if (cp != CMFAIL) { + mchunkptr newp = (mchunkptr)(cp + offset); + size_t psize = newmmsize - offset - MMAP_FOOT_PAD; + newp->head = psize; + mark_inuse_foot(m, newp, psize); + chunk_plus_offset(newp, psize)->head = FENCEPOST_HEAD; + chunk_plus_offset(newp, psize+SIZE_T_SIZE)->head = 0; + + if (cp < m->least_addr) + m->least_addr = cp; + if ((m->footprint += newmmsize - oldmmsize) > m->max_footprint) + m->max_footprint = m->footprint; + check_mmapped_chunk(m, newp); + return newp; + } + } + return 0; +} + + +/* -------------------------- mspace management -------------------------- */ + +/* Initialize top chunk and its size */ +static void init_top(mstate m, mchunkptr p, size_t psize) { + /* Ensure alignment */ + size_t offset = align_offset(chunk2mem(p)); + p = (mchunkptr)((char*)p + offset); + psize -= offset; + + m->top = p; + m->topsize = psize; + p->head = psize | PINUSE_BIT; + /* set size of fake trailing chunk holding overhead space only once */ + chunk_plus_offset(p, psize)->head = TOP_FOOT_SIZE; + m->trim_check = mparams.trim_threshold; /* reset on each update */ +} + +/* Initialize bins for a new mstate that is otherwise zeroed out */ +static void init_bins(mstate m) { + /* Establish circular links for smallbins */ + bindex_t i; + for (i = 0; i < NSMALLBINS; ++i) { + sbinptr bin = smallbin_at(m,i); + bin->fd = bin->bk = bin; + } +} + +#if PROCEED_ON_ERROR + +/* default corruption action */ +static void reset_on_error(mstate m) { + int i; + ++malloc_corruption_error_count; + /* Reinitialize fields to forget about all memory */ + m->smallmap = m->treemap = 0; + m->dvsize = m->topsize = 0; + m->seg.base = 0; + m->seg.size = 0; + m->seg.next = 0; + m->top = m->dv = 0; + for (i = 0; i < NTREEBINS; ++i) + *treebin_at(m, i) = 0; + init_bins(m); +} +#endif /* PROCEED_ON_ERROR */ + +/* Allocate chunk and prepend remainder with chunk in successor base. */ +static void* prepend_alloc(mstate m, char* newbase, char* oldbase, + size_t nb) { + mchunkptr p = align_as_chunk(newbase); + mchunkptr oldfirst = align_as_chunk(oldbase); + size_t psize = (char*)oldfirst - (char*)p; + mchunkptr q = chunk_plus_offset(p, nb); + size_t qsize = psize - nb; + set_size_and_pinuse_of_inuse_chunk(m, p, nb); + + assert((char*)oldfirst > (char*)q); + assert(pinuse(oldfirst)); + assert(qsize >= MIN_CHUNK_SIZE); + + /* consolidate remainder with first chunk of old base */ + if (oldfirst == m->top) { + size_t tsize = m->topsize += qsize; + m->top = q; + q->head = tsize | PINUSE_BIT; + check_top_chunk(m, q); + } + else if (oldfirst == m->dv) { + size_t dsize = m->dvsize += qsize; + m->dv = q; + set_size_and_pinuse_of_free_chunk(q, dsize); + } + else { + if (!is_inuse(oldfirst)) { + size_t nsize = chunksize(oldfirst); + unlink_chunk(m, oldfirst, nsize); + oldfirst = chunk_plus_offset(oldfirst, nsize); + qsize += nsize; + } + set_free_with_pinuse(q, qsize, oldfirst); + insert_chunk(m, q, qsize); + check_free_chunk(m, q); + } + + check_malloced_chunk(m, chunk2mem(p), nb); + return chunk2mem(p); +} + +/* Add a segment to hold a new noncontiguous region */ +static void add_segment(mstate m, char* tbase, size_t tsize, flag_t mmapped) { + /* Determine locations and sizes of segment, fenceposts, old top */ + char* old_top = (char*)m->top; + msegmentptr oldsp = segment_holding(m, old_top); + char* old_end = oldsp->base + oldsp->size; + size_t ssize = pad_request(sizeof(struct malloc_segment)); + char* rawsp = old_end - (ssize + FOUR_SIZE_T_SIZES + CHUNK_ALIGN_MASK); + size_t offset = align_offset(chunk2mem(rawsp)); + char* asp = rawsp + offset; + char* csp = (asp < (old_top + MIN_CHUNK_SIZE))? old_top : asp; + mchunkptr sp = (mchunkptr)csp; + msegmentptr ss = (msegmentptr)(chunk2mem(sp)); + mchunkptr tnext = chunk_plus_offset(sp, ssize); + mchunkptr p = tnext; + int nfences = 0; + + /* reset top to new space */ + init_top(m, (mchunkptr)tbase, tsize - TOP_FOOT_SIZE); + + /* Set up segment record */ + assert(is_aligned(ss)); + set_size_and_pinuse_of_inuse_chunk(m, sp, ssize); + *ss = m->seg; /* Push current record */ + m->seg.base = tbase; + m->seg.size = tsize; + m->seg.sflags = mmapped; + m->seg.next = ss; + + /* Insert trailing fenceposts */ + for (;;) { + mchunkptr nextp = chunk_plus_offset(p, SIZE_T_SIZE); + p->head = FENCEPOST_HEAD; + ++nfences; + if ((char*)(&(nextp->head)) < old_end) + p = nextp; + else + break; + } + assert(nfences >= 2); + + /* Insert the rest of old top into a bin as an ordinary free chunk */ + if (csp != old_top) { + mchunkptr q = (mchunkptr)old_top; + size_t psize = csp - old_top; + mchunkptr tn = chunk_plus_offset(q, psize); + set_free_with_pinuse(q, psize, tn); + insert_chunk(m, q, psize); + } + + check_top_chunk(m, m->top); +} + +/* -------------------------- System allocation -------------------------- */ + +/* Get memory from system using MORECORE or MMAP */ +static void* sys_alloc(mstate m, size_t nb) { + char* tbase = CMFAIL; + size_t tsize = 0; + flag_t mmap_flag = 0; + size_t asize; /* allocation size */ + + ensure_initialization(); + + /* Directly map large chunks, but only if already initialized */ + if (use_mmap(m) && nb >= mparams.mmap_threshold && m->topsize != 0) { + void* mem = mmap_alloc(m, nb); + if (mem != 0) + return mem; + } + + asize = granularity_align(nb + SYS_ALLOC_PADDING); + if (asize <= nb) + return 0; /* wraparound */ + if (m->footprint_limit != 0) { + size_t fp = m->footprint + asize; + if (fp <= m->footprint || fp > m->footprint_limit) + return 0; + } + + /* + Try getting memory in any of three ways (in most-preferred to + least-preferred order): + 1. A call to MORECORE that can normally contiguously extend memory. + (disabled if not MORECORE_CONTIGUOUS or not HAVE_MORECORE or + or main space is mmapped or a previous contiguous call failed) + 2. A call to MMAP new space (disabled if not HAVE_MMAP). + Note that under the default settings, if MORECORE is unable to + fulfill a request, and HAVE_MMAP is true, then mmap is + used as a noncontiguous system allocator. This is a useful backup + strategy for systems with holes in address spaces -- in this case + sbrk cannot contiguously expand the heap, but mmap may be able to + find space. + 3. A call to MORECORE that cannot usually contiguously extend memory. + (disabled if not HAVE_MORECORE) + + In all cases, we need to request enough bytes from system to ensure + we can malloc nb bytes upon success, so pad with enough space for + top_foot, plus alignment-pad to make sure we don't lose bytes if + not on boundary, and round this up to a granularity unit. + */ + + if (MORECORE_CONTIGUOUS && !use_noncontiguous(m)) { + char* br = CMFAIL; + size_t ssize = asize; /* sbrk call size */ + msegmentptr ss = (m->top == 0)? 0 : segment_holding(m, (char*)m->top); + ACQUIRE_MALLOC_GLOBAL_LOCK(); + + if (ss == 0) { /* First time through or recovery */ + char* base = (char*)CALL_MORECORE(0); + if (base != CMFAIL) { + size_t fp; + /* Adjust to end on a page boundary */ + if (!is_page_aligned(base)) + ssize += (page_align((size_t)base) - (size_t)base); + fp = m->footprint + ssize; /* recheck limits */ + if (ssize > nb && ssize < HALF_MAX_SIZE_T && + (m->footprint_limit == 0 || + (fp > m->footprint && fp <= m->footprint_limit)) && + (br = (char*)(CALL_MORECORE(ssize))) == base) { + tbase = base; + tsize = ssize; + } + } + } + else { + /* Subtract out existing available top space from MORECORE request. */ + ssize = granularity_align(nb - m->topsize + SYS_ALLOC_PADDING); + /* Use mem here only if it did continuously extend old space */ + if (ssize < HALF_MAX_SIZE_T && + (br = (char*)(CALL_MORECORE(ssize))) == ss->base+ss->size) { + tbase = br; + tsize = ssize; + } + } + + if (tbase == CMFAIL) { /* Cope with partial failure */ + if (br != CMFAIL) { /* Try to use/extend the space we did get */ + if (ssize < HALF_MAX_SIZE_T && + ssize < nb + SYS_ALLOC_PADDING) { + size_t esize = granularity_align(nb + SYS_ALLOC_PADDING - ssize); + if (esize < HALF_MAX_SIZE_T) { + char* end = (char*)CALL_MORECORE(esize); + if (end != CMFAIL) + ssize += esize; + else { /* Can't use; try to release */ + (void) CALL_MORECORE(-ssize); + br = CMFAIL; + } + } + } + } + if (br != CMFAIL) { /* Use the space we did get */ + tbase = br; + tsize = ssize; + } + else + disable_contiguous(m); /* Don't try contiguous path in the future */ + } + + RELEASE_MALLOC_GLOBAL_LOCK(); + } + + if (HAVE_MMAP && tbase == CMFAIL) { /* Try MMAP */ + char* mp = (char*)(CALL_MMAP(asize)); + if (mp != CMFAIL) { + tbase = mp; + tsize = asize; + mmap_flag = USE_MMAP_BIT; + } + } + + if (HAVE_MORECORE && tbase == CMFAIL) { /* Try noncontiguous MORECORE */ + if (asize < HALF_MAX_SIZE_T) { + char* br = CMFAIL; + char* end = CMFAIL; + ACQUIRE_MALLOC_GLOBAL_LOCK(); + br = (char*)(CALL_MORECORE(asize)); + end = (char*)(CALL_MORECORE(0)); + RELEASE_MALLOC_GLOBAL_LOCK(); + if (br != CMFAIL && end != CMFAIL && br < end) { + size_t ssize = end - br; + if (ssize > nb + TOP_FOOT_SIZE) { + tbase = br; + tsize = ssize; + } + } + } + } + + if (tbase != CMFAIL) { + + if ((m->footprint += tsize) > m->max_footprint) + m->max_footprint = m->footprint; + + if (!is_initialized(m)) { /* first-time initialization */ + if (m->least_addr == 0 || tbase < m->least_addr) + m->least_addr = tbase; + m->seg.base = tbase; + m->seg.size = tsize; + m->seg.sflags = mmap_flag; + m->magic = mparams.magic; + m->release_checks = MAX_RELEASE_CHECK_RATE; + init_bins(m); +#if !ONLY_MSPACES + if (is_global(m)) + init_top(m, (mchunkptr)tbase, tsize - TOP_FOOT_SIZE); + else +#endif + { + /* Offset top by embedded malloc_state */ + mchunkptr mn = next_chunk(mem2chunk(m)); + init_top(m, mn, (size_t)((tbase + tsize) - (char*)mn) -TOP_FOOT_SIZE); + } + } + + else { + /* Try to merge with an existing segment */ + msegmentptr sp = &m->seg; + /* Only consider most recent segment if traversal suppressed */ + while (sp != 0 && tbase != sp->base + sp->size) + sp = (NO_SEGMENT_TRAVERSAL) ? 0 : sp->next; + if (sp != 0 && + !is_extern_segment(sp) && + (sp->sflags & USE_MMAP_BIT) == mmap_flag && + segment_holds(sp, m->top)) { /* append */ + sp->size += tsize; + init_top(m, m->top, m->topsize + tsize); + } + else { + if (tbase < m->least_addr) + m->least_addr = tbase; + sp = &m->seg; + while (sp != 0 && sp->base != tbase + tsize) + sp = (NO_SEGMENT_TRAVERSAL) ? 0 : sp->next; + if (sp != 0 && + !is_extern_segment(sp) && + (sp->sflags & USE_MMAP_BIT) == mmap_flag) { + char* oldbase = sp->base; + sp->base = tbase; + sp->size += tsize; + return prepend_alloc(m, tbase, oldbase, nb); + } + else + add_segment(m, tbase, tsize, mmap_flag); + } + } + + if (nb < m->topsize) { /* Allocate from new or extended top space */ + size_t rsize = m->topsize -= nb; + mchunkptr p = m->top; + mchunkptr r = m->top = chunk_plus_offset(p, nb); + r->head = rsize | PINUSE_BIT; + set_size_and_pinuse_of_inuse_chunk(m, p, nb); + check_top_chunk(m, m->top); + check_malloced_chunk(m, chunk2mem(p), nb); + return chunk2mem(p); + } + } + + MALLOC_FAILURE_ACTION; + return 0; +} + +/* ----------------------- system deallocation -------------------------- */ + +/* Unmap and unlink any mmapped segments that don't contain used chunks */ +static size_t release_unused_segments(mstate m) { + size_t released = 0; + int nsegs = 0; + msegmentptr pred = &m->seg; + msegmentptr sp = pred->next; + while (sp != 0) { + char* base = sp->base; + size_t size = sp->size; + msegmentptr next = sp->next; + ++nsegs; + if (is_mmapped_segment(sp) && !is_extern_segment(sp)) { + mchunkptr p = align_as_chunk(base); + size_t psize = chunksize(p); + /* Can unmap if first chunk holds entire segment and not pinned */ + if (!is_inuse(p) && (char*)p + psize >= base + size - TOP_FOOT_SIZE) { + tchunkptr tp = (tchunkptr)p; + assert(segment_holds(sp, (char*)sp)); + if (p == m->dv) { + m->dv = 0; + m->dvsize = 0; + } + else { + unlink_large_chunk(m, tp); + } + if (CALL_MUNMAP(base, size) == 0) { + released += size; + m->footprint -= size; + /* unlink obsoleted record */ + sp = pred; + sp->next = next; + } + else { /* back out if cannot unmap */ + insert_large_chunk(m, tp, psize); + } + } + } + if (NO_SEGMENT_TRAVERSAL) /* scan only first segment */ + break; + pred = sp; + sp = next; + } + /* Reset check counter */ + m->release_checks = (((size_t) nsegs > (size_t) MAX_RELEASE_CHECK_RATE)? + (size_t) nsegs : (size_t) MAX_RELEASE_CHECK_RATE); + return released; +} + +static int sys_trim(mstate m, size_t pad) { + size_t released = 0; + ensure_initialization(); + if (pad < MAX_REQUEST && is_initialized(m)) { + pad += TOP_FOOT_SIZE; /* ensure enough room for segment overhead */ + + if (m->topsize > pad) { + /* Shrink top space in granularity-size units, keeping at least one */ + size_t unit = mparams.granularity; + size_t extra = ((m->topsize - pad + (unit - SIZE_T_ONE)) / unit - + SIZE_T_ONE) * unit; + msegmentptr sp = segment_holding(m, (char*)m->top); + + if (!is_extern_segment(sp)) { + if (is_mmapped_segment(sp)) { + if (HAVE_MMAP && + sp->size >= extra && + !has_segment_link(m, sp)) { /* can't shrink if pinned */ + size_t newsize = sp->size - extra; + (void)newsize; /* placate people compiling -Wunused-variable */ + /* Prefer mremap, fall back to munmap */ + if ((CALL_MREMAP(sp->base, sp->size, newsize, 0) != MFAIL) || + (CALL_MUNMAP(sp->base + newsize, extra) == 0)) { + released = extra; + } + } + } + else if (HAVE_MORECORE) { + if (extra >= HALF_MAX_SIZE_T) /* Avoid wrapping negative */ + extra = (HALF_MAX_SIZE_T) + SIZE_T_ONE - unit; + ACQUIRE_MALLOC_GLOBAL_LOCK(); + { + /* Make sure end of memory is where we last set it. */ + char* old_br = (char*)(CALL_MORECORE(0)); + if (old_br == sp->base + sp->size) { + char* rel_br = (char*)(CALL_MORECORE(-extra)); + char* new_br = (char*)(CALL_MORECORE(0)); + if (rel_br != CMFAIL && new_br < old_br) + released = old_br - new_br; + } + } + RELEASE_MALLOC_GLOBAL_LOCK(); + } + } + + if (released != 0) { + sp->size -= released; + m->footprint -= released; + init_top(m, m->top, m->topsize - released); + check_top_chunk(m, m->top); + } + } + + /* Unmap any unused mmapped segments */ + if (HAVE_MMAP) + released += release_unused_segments(m); + + /* On failure, disable autotrim to avoid repeated failed future calls */ + if (released == 0 && m->topsize > m->trim_check) + m->trim_check = MAX_SIZE_T; + } + + return (released != 0)? 1 : 0; +} + +/* Consolidate and bin a chunk. Differs from exported versions + of free mainly in that the chunk need not be marked as inuse. +*/ +static void dispose_chunk(mstate m, mchunkptr p, size_t psize) { + mchunkptr next = chunk_plus_offset(p, psize); + if (!pinuse(p)) { + mchunkptr prev; + size_t prevsize = p->prev_foot; + if (is_mmapped(p)) { + psize += prevsize + MMAP_FOOT_PAD; + if (CALL_MUNMAP((char*)p - prevsize, psize) == 0) + m->footprint -= psize; + return; + } + prev = chunk_minus_offset(p, prevsize); + psize += prevsize; + p = prev; + if (RTCHECK(ok_address(m, prev))) { /* consolidate backward */ + if (p != m->dv) { + unlink_chunk(m, p, prevsize); + } + else if ((next->head & INUSE_BITS) == INUSE_BITS) { + m->dvsize = psize; + set_free_with_pinuse(p, psize, next); + return; + } + } + else { + CORRUPTION_ERROR_ACTION(m); + return; + } + } + if (RTCHECK(ok_address(m, next))) { + if (!cinuse(next)) { /* consolidate forward */ + if (next == m->top) { + size_t tsize = m->topsize += psize; + m->top = p; + p->head = tsize | PINUSE_BIT; + if (p == m->dv) { + m->dv = 0; + m->dvsize = 0; + } + return; + } + else if (next == m->dv) { + size_t dsize = m->dvsize += psize; + m->dv = p; + set_size_and_pinuse_of_free_chunk(p, dsize); + return; + } + else { + size_t nsize = chunksize(next); + psize += nsize; + unlink_chunk(m, next, nsize); + set_size_and_pinuse_of_free_chunk(p, psize); + if (p == m->dv) { + m->dvsize = psize; + return; + } + } + } + else { + set_free_with_pinuse(p, psize, next); + } + insert_chunk(m, p, psize); + } + else { + CORRUPTION_ERROR_ACTION(m); + } +} + +/* ---------------------------- malloc --------------------------- */ + +/* allocate a large request from the best fitting chunk in a treebin */ +static void* tmalloc_large(mstate m, size_t nb) { + tchunkptr v = 0; + size_t rsize = -nb; /* Unsigned negation */ + tchunkptr t; + bindex_t idx; + compute_tree_index(nb, idx); + if ((t = *treebin_at(m, idx)) != 0) { + /* Traverse tree for this bin looking for node with size == nb */ + size_t sizebits = nb << leftshift_for_tree_index(idx); + tchunkptr rst = 0; /* The deepest untaken right subtree */ + for (;;) { + tchunkptr rt; + size_t trem = chunksize(t) - nb; + if (trem < rsize) { + v = t; + if ((rsize = trem) == 0) + break; + } + rt = t->child[1]; + t = t->child[(sizebits >> (SIZE_T_BITSIZE-SIZE_T_ONE)) & 1]; + if (rt != 0 && rt != t) + rst = rt; + if (t == 0) { + t = rst; /* set t to least subtree holding sizes > nb */ + break; + } + sizebits <<= 1; + } + } + if (t == 0 && v == 0) { /* set t to root of next non-empty treebin */ + binmap_t leftbits = left_bits(idx2bit(idx)) & m->treemap; + if (leftbits != 0) { + bindex_t i; + binmap_t leastbit = least_bit(leftbits); + compute_bit2idx(leastbit, i); + t = *treebin_at(m, i); + } + } + + while (t != 0) { /* find smallest of tree or subtree */ + size_t trem = chunksize(t) - nb; + if (trem < rsize) { + rsize = trem; + v = t; + } + t = leftmost_child(t); + } + + /* If dv is a better fit, return 0 so malloc will use it */ + if (v != 0 && rsize < (size_t)(m->dvsize - nb)) { + if (RTCHECK(ok_address(m, v))) { /* split */ + mchunkptr r = chunk_plus_offset(v, nb); + assert(chunksize(v) == rsize + nb); + if (RTCHECK(ok_next(v, r))) { + unlink_large_chunk(m, v); + if (rsize < MIN_CHUNK_SIZE) + set_inuse_and_pinuse(m, v, (rsize + nb)); + else { + set_size_and_pinuse_of_inuse_chunk(m, v, nb); + set_size_and_pinuse_of_free_chunk(r, rsize); + insert_chunk(m, r, rsize); + } + return chunk2mem(v); + } + } + CORRUPTION_ERROR_ACTION(m); + } + return 0; +} + +/* allocate a small request from the best fitting chunk in a treebin */ +static void* tmalloc_small(mstate m, size_t nb) { + tchunkptr t, v; + size_t rsize; + bindex_t i; + binmap_t leastbit = least_bit(m->treemap); + compute_bit2idx(leastbit, i); + v = t = *treebin_at(m, i); + rsize = chunksize(t) - nb; + + while ((t = leftmost_child(t)) != 0) { + size_t trem = chunksize(t) - nb; + if (trem < rsize) { + rsize = trem; + v = t; + } + } + + if (RTCHECK(ok_address(m, v))) { + mchunkptr r = chunk_plus_offset(v, nb); + assert(chunksize(v) == rsize + nb); + if (RTCHECK(ok_next(v, r))) { + unlink_large_chunk(m, v); + if (rsize < MIN_CHUNK_SIZE) + set_inuse_and_pinuse(m, v, (rsize + nb)); + else { + set_size_and_pinuse_of_inuse_chunk(m, v, nb); + set_size_and_pinuse_of_free_chunk(r, rsize); + replace_dv(m, r, rsize); + } + return chunk2mem(v); + } + } + + CORRUPTION_ERROR_ACTION(m); + return 0; +} + +#if !ONLY_MSPACES + +void* dlmalloc(size_t bytes) { + /* + Basic algorithm: + If a small request (< 256 bytes minus per-chunk overhead): + 1. If one exists, use a remainderless chunk in associated smallbin. + (Remainderless means that there are too few excess bytes to + represent as a chunk.) + 2. If it is big enough, use the dv chunk, which is normally the + chunk adjacent to the one used for the most recent small request. + 3. If one exists, split the smallest available chunk in a bin, + saving remainder in dv. + 4. If it is big enough, use the top chunk. + 5. If available, get memory from system and use it + Otherwise, for a large request: + 1. Find the smallest available binned chunk that fits, and use it + if it is better fitting than dv chunk, splitting if necessary. + 2. If better fitting than any binned chunk, use the dv chunk. + 3. If it is big enough, use the top chunk. + 4. If request size >= mmap threshold, try to directly mmap this chunk. + 5. If available, get memory from system and use it + + The ugly goto's here ensure that postaction occurs along all paths. + */ + +#if USE_LOCKS + ensure_initialization(); /* initialize in sys_alloc if not using locks */ +#endif + + if (!PREACTION(gm)) { + void* mem; + size_t nb; + if (bytes <= MAX_SMALL_REQUEST) { + bindex_t idx; + binmap_t smallbits; + nb = (bytes < MIN_REQUEST)? MIN_CHUNK_SIZE : pad_request(bytes); + idx = small_index(nb); + smallbits = gm->smallmap >> idx; + + if ((smallbits & 0x3U) != 0) { /* Remainderless fit to a smallbin. */ + mchunkptr b, p; + idx += ~smallbits & 1; /* Uses next bin if idx empty */ + b = smallbin_at(gm, idx); + p = b->fd; + assert(chunksize(p) == small_index2size(idx)); + unlink_first_small_chunk(gm, b, p, idx); + set_inuse_and_pinuse(gm, p, small_index2size(idx)); + mem = chunk2mem(p); + check_malloced_chunk(gm, mem, nb); + goto postaction; + } + + else if (nb > gm->dvsize) { + if (smallbits != 0) { /* Use chunk in next nonempty smallbin */ + mchunkptr b, p, r; + size_t rsize; + bindex_t i; + binmap_t leftbits = (smallbits << idx) & left_bits(idx2bit(idx)); + binmap_t leastbit = least_bit(leftbits); + compute_bit2idx(leastbit, i); + b = smallbin_at(gm, i); + p = b->fd; + assert(chunksize(p) == small_index2size(i)); + unlink_first_small_chunk(gm, b, p, i); + rsize = small_index2size(i) - nb; + /* Fit here cannot be remainderless if 4byte sizes */ + if (SIZE_T_SIZE != 4 && rsize < MIN_CHUNK_SIZE) + set_inuse_and_pinuse(gm, p, small_index2size(i)); + else { + set_size_and_pinuse_of_inuse_chunk(gm, p, nb); + r = chunk_plus_offset(p, nb); + set_size_and_pinuse_of_free_chunk(r, rsize); + replace_dv(gm, r, rsize); + } + mem = chunk2mem(p); + check_malloced_chunk(gm, mem, nb); + goto postaction; + } + + else if (gm->treemap != 0 && (mem = tmalloc_small(gm, nb)) != 0) { + check_malloced_chunk(gm, mem, nb); + goto postaction; + } + } + } + else if (bytes >= MAX_REQUEST) + nb = MAX_SIZE_T; /* Too big to allocate. Force failure (in sys alloc) */ + else { + nb = pad_request(bytes); + if (gm->treemap != 0 && (mem = tmalloc_large(gm, nb)) != 0) { + check_malloced_chunk(gm, mem, nb); + goto postaction; + } + } + + if (nb <= gm->dvsize) { + size_t rsize = gm->dvsize - nb; + mchunkptr p = gm->dv; + if (rsize >= MIN_CHUNK_SIZE) { /* split dv */ + mchunkptr r = gm->dv = chunk_plus_offset(p, nb); + gm->dvsize = rsize; + set_size_and_pinuse_of_free_chunk(r, rsize); + set_size_and_pinuse_of_inuse_chunk(gm, p, nb); + } + else { /* exhaust dv */ + size_t dvs = gm->dvsize; + gm->dvsize = 0; + gm->dv = 0; + set_inuse_and_pinuse(gm, p, dvs); + } + mem = chunk2mem(p); + check_malloced_chunk(gm, mem, nb); + goto postaction; + } + + else if (nb < gm->topsize) { /* Split top */ + size_t rsize = gm->topsize -= nb; + mchunkptr p = gm->top; + mchunkptr r = gm->top = chunk_plus_offset(p, nb); + r->head = rsize | PINUSE_BIT; + set_size_and_pinuse_of_inuse_chunk(gm, p, nb); + mem = chunk2mem(p); + check_top_chunk(gm, gm->top); + check_malloced_chunk(gm, mem, nb); + goto postaction; + } + + mem = sys_alloc(gm, nb); + + postaction: + POSTACTION(gm); + return mem; + } + + return 0; +} + +/* ---------------------------- free --------------------------- */ + +void dlfree(void* mem) { + /* + Consolidate freed chunks with preceeding or succeeding bordering + free chunks, if they exist, and then place in a bin. Intermixed + with special cases for top, dv, mmapped chunks, and usage errors. + */ + + if (mem != 0) { + mchunkptr p = mem2chunk(mem); +#if FOOTERS + mstate fm = get_mstate_for(p); + if (!ok_magic(fm)) { + USAGE_ERROR_ACTION(fm, p); + return; + } +#else /* FOOTERS */ +#define fm gm +#endif /* FOOTERS */ + if (!PREACTION(fm)) { + check_inuse_chunk(fm, p); + if (RTCHECK(ok_address(fm, p) && ok_inuse(p))) { + size_t psize = chunksize(p); + mchunkptr next = chunk_plus_offset(p, psize); + if (!pinuse(p)) { + size_t prevsize = p->prev_foot; + if (is_mmapped(p)) { + psize += prevsize + MMAP_FOOT_PAD; + if (CALL_MUNMAP((char*)p - prevsize, psize) == 0) + fm->footprint -= psize; + goto postaction; + } + else { + mchunkptr prev = chunk_minus_offset(p, prevsize); + psize += prevsize; + p = prev; + if (RTCHECK(ok_address(fm, prev))) { /* consolidate backward */ + if (p != fm->dv) { + unlink_chunk(fm, p, prevsize); + } + else if ((next->head & INUSE_BITS) == INUSE_BITS) { + fm->dvsize = psize; + set_free_with_pinuse(p, psize, next); + goto postaction; + } + } + else + goto erroraction; + } + } + + if (RTCHECK(ok_next(p, next) && ok_pinuse(next))) { + if (!cinuse(next)) { /* consolidate forward */ + if (next == fm->top) { + size_t tsize = fm->topsize += psize; + fm->top = p; + p->head = tsize | PINUSE_BIT; + if (p == fm->dv) { + fm->dv = 0; + fm->dvsize = 0; + } + if (should_trim(fm, tsize)) + sys_trim(fm, 0); + goto postaction; + } + else if (next == fm->dv) { + size_t dsize = fm->dvsize += psize; + fm->dv = p; + set_size_and_pinuse_of_free_chunk(p, dsize); + goto postaction; + } + else { + size_t nsize = chunksize(next); + psize += nsize; + unlink_chunk(fm, next, nsize); + set_size_and_pinuse_of_free_chunk(p, psize); + if (p == fm->dv) { + fm->dvsize = psize; + goto postaction; + } + } + } + else + set_free_with_pinuse(p, psize, next); + + if (is_small(psize)) { + insert_small_chunk(fm, p, psize); + check_free_chunk(fm, p); + } + else { + tchunkptr tp = (tchunkptr)p; + insert_large_chunk(fm, tp, psize); + check_free_chunk(fm, p); + if (--fm->release_checks == 0) + release_unused_segments(fm); + } + goto postaction; + } + } + erroraction: + USAGE_ERROR_ACTION(fm, p); + postaction: + POSTACTION(fm); + } + } +#if !FOOTERS +#undef fm +#endif /* FOOTERS */ +} + +void* dlcalloc(size_t n_elements, size_t elem_size) { + void* mem; + size_t req = 0; + if (n_elements != 0) { + req = n_elements * elem_size; + if (((n_elements | elem_size) & ~(size_t)0xffff) && + (req / n_elements != elem_size)) + req = MAX_SIZE_T; /* force downstream failure on overflow */ + } + mem = dlmalloc(req); + if (mem != 0 && calloc_must_clear(mem2chunk(mem))) + memset(mem, 0, req); + return mem; +} + +#endif /* !ONLY_MSPACES */ + +/* ------------ Internal support for realloc, memalign, etc -------------- */ + +/* Try to realloc; only in-place unless can_move true */ +static mchunkptr try_realloc_chunk(mstate m, mchunkptr p, size_t nb, + int can_move) { + mchunkptr newp = 0; + size_t oldsize = chunksize(p); + mchunkptr next = chunk_plus_offset(p, oldsize); + if (RTCHECK(ok_address(m, p) && ok_inuse(p) && + ok_next(p, next) && ok_pinuse(next))) { + if (is_mmapped(p)) { + newp = mmap_resize(m, p, nb, can_move); + } + else if (oldsize >= nb) { /* already big enough */ + size_t rsize = oldsize - nb; + if (rsize >= MIN_CHUNK_SIZE) { /* split off remainder */ + mchunkptr r = chunk_plus_offset(p, nb); + set_inuse(m, p, nb); + set_inuse(m, r, rsize); + dispose_chunk(m, r, rsize); + } + newp = p; + } + else if (next == m->top) { /* extend into top */ + if (oldsize + m->topsize > nb) { + size_t newsize = oldsize + m->topsize; + size_t newtopsize = newsize - nb; + mchunkptr newtop = chunk_plus_offset(p, nb); + set_inuse(m, p, nb); + newtop->head = newtopsize |PINUSE_BIT; + m->top = newtop; + m->topsize = newtopsize; + newp = p; + } + } + else if (next == m->dv) { /* extend into dv */ + size_t dvs = m->dvsize; + if (oldsize + dvs >= nb) { + size_t dsize = oldsize + dvs - nb; + if (dsize >= MIN_CHUNK_SIZE) { + mchunkptr r = chunk_plus_offset(p, nb); + mchunkptr n = chunk_plus_offset(r, dsize); + set_inuse(m, p, nb); + set_size_and_pinuse_of_free_chunk(r, dsize); + clear_pinuse(n); + m->dvsize = dsize; + m->dv = r; + } + else { /* exhaust dv */ + size_t newsize = oldsize + dvs; + set_inuse(m, p, newsize); + m->dvsize = 0; + m->dv = 0; + } + newp = p; + } + } + else if (!cinuse(next)) { /* extend into next free chunk */ + size_t nextsize = chunksize(next); + if (oldsize + nextsize >= nb) { + size_t rsize = oldsize + nextsize - nb; + unlink_chunk(m, next, nextsize); + if (rsize < MIN_CHUNK_SIZE) { + size_t newsize = oldsize + nextsize; + set_inuse(m, p, newsize); + } + else { + mchunkptr r = chunk_plus_offset(p, nb); + set_inuse(m, p, nb); + set_inuse(m, r, rsize); + dispose_chunk(m, r, rsize); + } + newp = p; + } + } + } + else { + USAGE_ERROR_ACTION(m, chunk2mem(p)); + } + return newp; +} + +static void* internal_memalign(mstate m, size_t alignment, size_t bytes) { + void* mem = 0; + if (alignment < MIN_CHUNK_SIZE) /* must be at least a minimum chunk size */ + alignment = MIN_CHUNK_SIZE; + if ((alignment & (alignment-SIZE_T_ONE)) != 0) {/* Ensure a power of 2 */ + size_t a = MALLOC_ALIGNMENT << 1; + while (a < alignment) a <<= 1; + alignment = a; + } + if (bytes >= MAX_REQUEST - alignment) { + if (m != 0) { /* Test isn't needed but avoids compiler warning */ + MALLOC_FAILURE_ACTION; + } + } + else { + size_t nb = request2size(bytes); + size_t req = nb + alignment + MIN_CHUNK_SIZE - CHUNK_OVERHEAD; + mem = internal_malloc(m, req); + if (mem != 0) { + mchunkptr p = mem2chunk(mem); + if (PREACTION(m)) + return 0; + if ((((size_t)(mem)) & (alignment - 1)) != 0) { /* misaligned */ + /* + Find an aligned spot inside chunk. Since we need to give + back leading space in a chunk of at least MIN_CHUNK_SIZE, if + the first calculation places us at a spot with less than + MIN_CHUNK_SIZE leader, we can move to the next aligned spot. + We've allocated enough total room so that this is always + possible. + */ + char* br = (char*)mem2chunk((size_t)(((size_t)((char*)mem + alignment - + SIZE_T_ONE)) & + -alignment)); + char* pos = ((size_t)(br - (char*)(p)) >= MIN_CHUNK_SIZE)? + br : br+alignment; + mchunkptr newp = (mchunkptr)pos; + size_t leadsize = pos - (char*)(p); + size_t newsize = chunksize(p) - leadsize; + + if (is_mmapped(p)) { /* For mmapped chunks, just adjust offset */ + newp->prev_foot = p->prev_foot + leadsize; + newp->head = newsize; + } + else { /* Otherwise, give back leader, use the rest */ + set_inuse(m, newp, newsize); + set_inuse(m, p, leadsize); + dispose_chunk(m, p, leadsize); + } + p = newp; + } + + /* Give back spare room at the end */ + if (!is_mmapped(p)) { + size_t size = chunksize(p); + if (size > nb + MIN_CHUNK_SIZE) { + size_t remainder_size = size - nb; + mchunkptr remainder = chunk_plus_offset(p, nb); + set_inuse(m, p, nb); + set_inuse(m, remainder, remainder_size); + dispose_chunk(m, remainder, remainder_size); + } + } + + mem = chunk2mem(p); + assert (chunksize(p) >= nb); + assert(((size_t)mem & (alignment - 1)) == 0); + check_inuse_chunk(m, p); + POSTACTION(m); + } + } + return mem; +} + +/* + Common support for independent_X routines, handling + all of the combinations that can result. + The opts arg has: + bit 0 set if all elements are same size (using sizes[0]) + bit 1 set if elements should be zeroed +*/ +static void** ialloc(mstate m, + size_t n_elements, + size_t* sizes, + int opts, + void* chunks[]) { + + size_t element_size; /* chunksize of each element, if all same */ + size_t contents_size; /* total size of elements */ + size_t array_size; /* request size of pointer array */ + void* mem; /* malloced aggregate space */ + mchunkptr p; /* corresponding chunk */ + size_t remainder_size; /* remaining bytes while splitting */ + void** marray; /* either "chunks" or malloced ptr array */ + mchunkptr array_chunk; /* chunk for malloced ptr array */ + flag_t was_enabled; /* to disable mmap */ + size_t size; + size_t i; + + ensure_initialization(); + /* compute array length, if needed */ + if (chunks != 0) { + if (n_elements == 0) + return chunks; /* nothing to do */ + marray = chunks; + array_size = 0; + } + else { + /* if empty req, must still return chunk representing empty array */ + if (n_elements == 0) + return (void**)internal_malloc(m, 0); + marray = 0; + array_size = request2size(n_elements * (sizeof(void*))); + } + + /* compute total element size */ + if (opts & 0x1) { /* all-same-size */ + element_size = request2size(*sizes); + contents_size = n_elements * element_size; + } + else { /* add up all the sizes */ + element_size = 0; + contents_size = 0; + for (i = 0; i != n_elements; ++i) + contents_size += request2size(sizes[i]); + } + + size = contents_size + array_size; + + /* + Allocate the aggregate chunk. First disable direct-mmapping so + malloc won't use it, since we would not be able to later + free/realloc space internal to a segregated mmap region. + */ + was_enabled = use_mmap(m); + disable_mmap(m); + mem = internal_malloc(m, size - CHUNK_OVERHEAD); + if (was_enabled) + enable_mmap(m); + if (mem == 0) + return 0; + + if (PREACTION(m)) return 0; + p = mem2chunk(mem); + remainder_size = chunksize(p); + + assert(!is_mmapped(p)); + + if (opts & 0x2) { /* optionally clear the elements */ + memset((size_t*)mem, 0, remainder_size - SIZE_T_SIZE - array_size); + } + + /* If not provided, allocate the pointer array as final part of chunk */ + if (marray == 0) { + size_t array_chunk_size; + array_chunk = chunk_plus_offset(p, contents_size); + array_chunk_size = remainder_size - contents_size; + marray = (void**) (chunk2mem(array_chunk)); + set_size_and_pinuse_of_inuse_chunk(m, array_chunk, array_chunk_size); + remainder_size = contents_size; + } + + /* split out elements */ + for (i = 0; ; ++i) { + marray[i] = chunk2mem(p); + if (i != n_elements-1) { + if (element_size != 0) + size = element_size; + else + size = request2size(sizes[i]); + remainder_size -= size; + set_size_and_pinuse_of_inuse_chunk(m, p, size); + p = chunk_plus_offset(p, size); + } + else { /* the final element absorbs any overallocation slop */ + set_size_and_pinuse_of_inuse_chunk(m, p, remainder_size); + break; + } + } + +#if DEBUG + if (marray != chunks) { + /* final element must have exactly exhausted chunk */ + if (element_size != 0) { + assert(remainder_size == element_size); + } + else { + assert(remainder_size == request2size(sizes[i])); + } + check_inuse_chunk(m, mem2chunk(marray)); + } + for (i = 0; i != n_elements; ++i) + check_inuse_chunk(m, mem2chunk(marray[i])); + +#endif /* DEBUG */ + + POSTACTION(m); + return marray; +} + +/* Try to free all pointers in the given array. + Note: this could be made faster, by delaying consolidation, + at the price of disabling some user integrity checks, We + still optimize some consolidations by combining adjacent + chunks before freeing, which will occur often if allocated + with ialloc or the array is sorted. +*/ +static size_t internal_bulk_free(mstate m, void* array[], size_t nelem) { + size_t unfreed = 0; + if (!PREACTION(m)) { + void** a; + void** fence = &(array[nelem]); + for (a = array; a != fence; ++a) { + void* mem = *a; + if (mem != 0) { + mchunkptr p = mem2chunk(mem); + size_t psize = chunksize(p); +#if FOOTERS + if (get_mstate_for(p) != m) { + ++unfreed; + continue; + } +#endif + check_inuse_chunk(m, p); + *a = 0; + if (RTCHECK(ok_address(m, p) && ok_inuse(p))) { + void ** b = a + 1; /* try to merge with next chunk */ + mchunkptr next = next_chunk(p); + if (b != fence && *b == chunk2mem(next)) { + size_t newsize = chunksize(next) + psize; + set_inuse(m, p, newsize); + *b = chunk2mem(p); + } + else + dispose_chunk(m, p, psize); + } + else { + CORRUPTION_ERROR_ACTION(m); + break; + } + } + } + if (should_trim(m, m->topsize)) + sys_trim(m, 0); + POSTACTION(m); + } + return unfreed; +} + +/* Traversal */ +#if MALLOC_INSPECT_ALL +static void internal_inspect_all(mstate m, + void(*handler)(void *start, + void *end, + size_t used_bytes, + void* callback_arg), + void* arg) { + if (is_initialized(m)) { + mchunkptr top = m->top; + msegmentptr s; + for (s = &m->seg; s != 0; s = s->next) { + mchunkptr q = align_as_chunk(s->base); + while (segment_holds(s, q) && q->head != FENCEPOST_HEAD) { + mchunkptr next = next_chunk(q); + size_t sz = chunksize(q); + size_t used; + void* start; + if (is_inuse(q)) { + used = sz - CHUNK_OVERHEAD; /* must not be mmapped */ + start = chunk2mem(q); + } + else { + used = 0; + if (is_small(sz)) { /* offset by possible bookkeeping */ + start = (void*)((char*)q + sizeof(struct malloc_chunk)); + } + else { + start = (void*)((char*)q + sizeof(struct malloc_tree_chunk)); + } + } + if (start < (void*)next) /* skip if all space is bookkeeping */ + handler(start, next, used, arg); + if (q == top) + break; + q = next; + } + } + } +} +#endif /* MALLOC_INSPECT_ALL */ + +/* ------------------ Exported realloc, memalign, etc -------------------- */ + +#if !ONLY_MSPACES + +void* dlrealloc(void* oldmem, size_t bytes) { + void* mem = 0; + if (oldmem == 0) { + mem = dlmalloc(bytes); + } + else if (bytes >= MAX_REQUEST) { + MALLOC_FAILURE_ACTION; + } +#ifdef REALLOC_ZERO_BYTES_FREES + else if (bytes == 0) { + dlfree(oldmem); + } +#endif /* REALLOC_ZERO_BYTES_FREES */ + else { + size_t nb = request2size(bytes); + mchunkptr oldp = mem2chunk(oldmem); +#if ! FOOTERS + mstate m = gm; +#else /* FOOTERS */ + mstate m = get_mstate_for(oldp); + if (!ok_magic(m)) { + USAGE_ERROR_ACTION(m, oldmem); + return 0; + } +#endif /* FOOTERS */ + if (!PREACTION(m)) { + mchunkptr newp = try_realloc_chunk(m, oldp, nb, 1); + POSTACTION(m); + if (newp != 0) { + check_inuse_chunk(m, newp); + mem = chunk2mem(newp); + } + else { + mem = internal_malloc(m, bytes); + if (mem != 0) { + size_t oc = chunksize(oldp) - overhead_for(oldp); + memcpy(mem, oldmem, (oc < bytes)? oc : bytes); + internal_free(m, oldmem); + } + } + } + } + return mem; +} + +void* dlrealloc_in_place(void* oldmem, size_t bytes) { + void* mem = 0; + if (oldmem != 0) { + if (bytes >= MAX_REQUEST) { + MALLOC_FAILURE_ACTION; + } + else { + size_t nb = request2size(bytes); + mchunkptr oldp = mem2chunk(oldmem); +#if ! FOOTERS + mstate m = gm; +#else /* FOOTERS */ + mstate m = get_mstate_for(oldp); + if (!ok_magic(m)) { + USAGE_ERROR_ACTION(m, oldmem); + return 0; + } +#endif /* FOOTERS */ + if (!PREACTION(m)) { + mchunkptr newp = try_realloc_chunk(m, oldp, nb, 0); + POSTACTION(m); + if (newp == oldp) { + check_inuse_chunk(m, newp); + mem = oldmem; + } + } + } + } + return mem; +} + +void* dlmemalign(size_t alignment, size_t bytes) { + if (alignment <= MALLOC_ALIGNMENT) { + return dlmalloc(bytes); + } + return internal_memalign(gm, alignment, bytes); +} + +int dlposix_memalign(void** pp, size_t alignment, size_t bytes) { + void* mem = 0; + if (alignment == MALLOC_ALIGNMENT) + mem = dlmalloc(bytes); + else { + size_t d = alignment / sizeof(void*); + size_t r = alignment % sizeof(void*); + if (r != 0 || d == 0 || (d & (d-SIZE_T_ONE)) != 0) + return EINVAL; + else if (bytes <= MAX_REQUEST - alignment) { + if (alignment < MIN_CHUNK_SIZE) + alignment = MIN_CHUNK_SIZE; + mem = internal_memalign(gm, alignment, bytes); + } + } + if (mem == 0) + return ENOMEM; + else { + *pp = mem; + return 0; + } +} + +void* dlvalloc(size_t bytes) { + size_t pagesz; + ensure_initialization(); + pagesz = mparams.page_size; + return dlmemalign(pagesz, bytes); +} + +void* dlpvalloc(size_t bytes) { + size_t pagesz; + ensure_initialization(); + pagesz = mparams.page_size; + return dlmemalign(pagesz, (bytes + pagesz - SIZE_T_ONE) & ~(pagesz - SIZE_T_ONE)); +} + +void** dlindependent_calloc(size_t n_elements, size_t elem_size, + void* chunks[]) { + size_t sz = elem_size; /* serves as 1-element array */ + return ialloc(gm, n_elements, &sz, 3, chunks); +} + +void** dlindependent_comalloc(size_t n_elements, size_t sizes[], + void* chunks[]) { + return ialloc(gm, n_elements, sizes, 0, chunks); +} + +size_t dlbulk_free(void* array[], size_t nelem) { + return internal_bulk_free(gm, array, nelem); +} + +#if MALLOC_INSPECT_ALL +void dlmalloc_inspect_all(void(*handler)(void *start, + void *end, + size_t used_bytes, + void* callback_arg), + void* arg) { + ensure_initialization(); + if (!PREACTION(gm)) { + internal_inspect_all(gm, handler, arg); + POSTACTION(gm); + } +} +#endif /* MALLOC_INSPECT_ALL */ + +int dlmalloc_trim(size_t pad) { + int result = 0; + ensure_initialization(); + if (!PREACTION(gm)) { + result = sys_trim(gm, pad); + POSTACTION(gm); + } + return result; +} + +size_t dlmalloc_footprint(void) { + return gm->footprint; +} + +size_t dlmalloc_max_footprint(void) { + return gm->max_footprint; +} + +size_t dlmalloc_footprint_limit(void) { + size_t maf = gm->footprint_limit; + return maf == 0 ? MAX_SIZE_T : maf; +} + +size_t dlmalloc_set_footprint_limit(size_t bytes) { + size_t result; /* invert sense of 0 */ + if (bytes == 0) + result = granularity_align(1); /* Use minimal size */ + if (bytes == MAX_SIZE_T) + result = 0; /* disable */ + else + result = granularity_align(bytes); + return gm->footprint_limit = result; +} + +#if !NO_MALLINFO +struct mallinfo dlmallinfo(void) { + return internal_mallinfo(gm); +} +#endif /* NO_MALLINFO */ + +#if !NO_MALLOC_STATS +void dlmalloc_stats() { + internal_malloc_stats(gm); +} +#endif /* NO_MALLOC_STATS */ + +int dlmallopt(int param_number, int value) { + return change_mparam(param_number, value); +} + +size_t dlmalloc_usable_size(void* mem) { + if (mem != 0) { + mchunkptr p = mem2chunk(mem); + if (is_inuse(p)) + return chunksize(p) - overhead_for(p); + } + return 0; +} + +#endif /* !ONLY_MSPACES */ + +/* ----------------------------- user mspaces ---------------------------- */ + +#if MSPACES + +static mstate init_user_mstate(char* tbase, size_t tsize) { + size_t msize = pad_request(sizeof(struct malloc_state)); + mchunkptr mn; + mchunkptr msp = align_as_chunk(tbase); + mstate m = (mstate)(chunk2mem(msp)); + memset(m, 0, msize); + (void)INITIAL_LOCK(&m->mutex); + msp->head = (msize|INUSE_BITS); + m->seg.base = m->least_addr = tbase; + m->seg.size = m->footprint = m->max_footprint = tsize; + m->magic = mparams.magic; + m->release_checks = MAX_RELEASE_CHECK_RATE; + m->mflags = mparams.default_mflags; + m->extp = 0; + m->exts = 0; + disable_contiguous(m); + init_bins(m); + mn = next_chunk(mem2chunk(m)); + init_top(m, mn, (size_t)((tbase + tsize) - (char*)mn) - TOP_FOOT_SIZE); + check_top_chunk(m, m->top); + return m; +} + +mspace create_mspace(size_t capacity, int locked) { + mstate m = 0; + size_t msize; + ensure_initialization(); + msize = pad_request(sizeof(struct malloc_state)); + if (capacity < (size_t) -(msize + TOP_FOOT_SIZE + mparams.page_size)) { + size_t rs = ((capacity == 0)? mparams.granularity : + (capacity + TOP_FOOT_SIZE + msize)); + size_t tsize = granularity_align(rs); + char* tbase = (char*)(CALL_MMAP(tsize)); + if (tbase != CMFAIL) { + m = init_user_mstate(tbase, tsize); + m->seg.sflags = USE_MMAP_BIT; + set_lock(m, locked); + } + } + return (mspace)m; +} + +mspace create_mspace_with_base(void* base, size_t capacity, int locked) { + mstate m = 0; + size_t msize; + ensure_initialization(); + msize = pad_request(sizeof(struct malloc_state)); + if (capacity > msize + TOP_FOOT_SIZE && + capacity < (size_t) -(msize + TOP_FOOT_SIZE + mparams.page_size)) { + m = init_user_mstate((char*)base, capacity); + m->seg.sflags = EXTERN_BIT; + set_lock(m, locked); + } + return (mspace)m; +} + +int mspace_track_large_chunks(mspace msp, int enable) { + int ret = 0; + mstate ms = (mstate)msp; + if (!PREACTION(ms)) { + if (!use_mmap(ms)) { + ret = 1; + } + if (!enable) { + enable_mmap(ms); + } else { + disable_mmap(ms); + } + POSTACTION(ms); + } + return ret; +} + +size_t destroy_mspace(mspace msp) { + size_t freed = 0; + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + msegmentptr sp = &ms->seg; + (void)DESTROY_LOCK(&ms->mutex); /* destroy before unmapped */ + while (sp != 0) { + char* base = sp->base; + size_t size = sp->size; + flag_t flag = sp->sflags; + (void)base; /* placate people compiling -Wunused-variable */ + sp = sp->next; + if ((flag & USE_MMAP_BIT) && !(flag & EXTERN_BIT) && + CALL_MUNMAP(base, size) == 0) + freed += size; + } + } + else { + USAGE_ERROR_ACTION(ms,ms); + } + return freed; +} + +/* + mspace versions of routines are near-clones of the global + versions. This is not so nice but better than the alternatives. +*/ + +void* mspace_malloc(mspace msp, size_t bytes) { + mstate ms = (mstate)msp; + if (!ok_magic(ms)) { + USAGE_ERROR_ACTION(ms,ms); + return 0; + } + if (!PREACTION(ms)) { + void* mem; + size_t nb; + if (bytes <= MAX_SMALL_REQUEST) { + bindex_t idx; + binmap_t smallbits; + nb = (bytes < MIN_REQUEST)? MIN_CHUNK_SIZE : pad_request(bytes); + idx = small_index(nb); + smallbits = ms->smallmap >> idx; + + if ((smallbits & 0x3U) != 0) { /* Remainderless fit to a smallbin. */ + mchunkptr b, p; + idx += ~smallbits & 1; /* Uses next bin if idx empty */ + b = smallbin_at(ms, idx); + p = b->fd; + assert(chunksize(p) == small_index2size(idx)); + unlink_first_small_chunk(ms, b, p, idx); + set_inuse_and_pinuse(ms, p, small_index2size(idx)); + mem = chunk2mem(p); + check_malloced_chunk(ms, mem, nb); + goto postaction; + } + + else if (nb > ms->dvsize) { + if (smallbits != 0) { /* Use chunk in next nonempty smallbin */ + mchunkptr b, p, r; + size_t rsize; + bindex_t i; + binmap_t leftbits = (smallbits << idx) & left_bits(idx2bit(idx)); + binmap_t leastbit = least_bit(leftbits); + compute_bit2idx(leastbit, i); + b = smallbin_at(ms, i); + p = b->fd; + assert(chunksize(p) == small_index2size(i)); + unlink_first_small_chunk(ms, b, p, i); + rsize = small_index2size(i) - nb; + /* Fit here cannot be remainderless if 4byte sizes */ + if (SIZE_T_SIZE != 4 && rsize < MIN_CHUNK_SIZE) + set_inuse_and_pinuse(ms, p, small_index2size(i)); + else { + set_size_and_pinuse_of_inuse_chunk(ms, p, nb); + r = chunk_plus_offset(p, nb); + set_size_and_pinuse_of_free_chunk(r, rsize); + replace_dv(ms, r, rsize); + } + mem = chunk2mem(p); + check_malloced_chunk(ms, mem, nb); + goto postaction; + } + + else if (ms->treemap != 0 && (mem = tmalloc_small(ms, nb)) != 0) { + check_malloced_chunk(ms, mem, nb); + goto postaction; + } + } + } + else if (bytes >= MAX_REQUEST) + nb = MAX_SIZE_T; /* Too big to allocate. Force failure (in sys alloc) */ + else { + nb = pad_request(bytes); + if (ms->treemap != 0 && (mem = tmalloc_large(ms, nb)) != 0) { + check_malloced_chunk(ms, mem, nb); + goto postaction; + } + } + + if (nb <= ms->dvsize) { + size_t rsize = ms->dvsize - nb; + mchunkptr p = ms->dv; + if (rsize >= MIN_CHUNK_SIZE) { /* split dv */ + mchunkptr r = ms->dv = chunk_plus_offset(p, nb); + ms->dvsize = rsize; + set_size_and_pinuse_of_free_chunk(r, rsize); + set_size_and_pinuse_of_inuse_chunk(ms, p, nb); + } + else { /* exhaust dv */ + size_t dvs = ms->dvsize; + ms->dvsize = 0; + ms->dv = 0; + set_inuse_and_pinuse(ms, p, dvs); + } + mem = chunk2mem(p); + check_malloced_chunk(ms, mem, nb); + goto postaction; + } + + else if (nb < ms->topsize) { /* Split top */ + size_t rsize = ms->topsize -= nb; + mchunkptr p = ms->top; + mchunkptr r = ms->top = chunk_plus_offset(p, nb); + r->head = rsize | PINUSE_BIT; + set_size_and_pinuse_of_inuse_chunk(ms, p, nb); + mem = chunk2mem(p); + check_top_chunk(ms, ms->top); + check_malloced_chunk(ms, mem, nb); + goto postaction; + } + + mem = sys_alloc(ms, nb); + + postaction: + POSTACTION(ms); + return mem; + } + + return 0; +} + +void mspace_free(mspace msp, void* mem) { + if (mem != 0) { + mchunkptr p = mem2chunk(mem); +#if FOOTERS + mstate fm = get_mstate_for(p); + (void)msp; /* placate people compiling -Wunused */ +#else /* FOOTERS */ + mstate fm = (mstate)msp; +#endif /* FOOTERS */ + if (!ok_magic(fm)) { + USAGE_ERROR_ACTION(fm, p); + return; + } + if (!PREACTION(fm)) { + check_inuse_chunk(fm, p); + if (RTCHECK(ok_address(fm, p) && ok_inuse(p))) { + size_t psize = chunksize(p); + mchunkptr next = chunk_plus_offset(p, psize); + if (!pinuse(p)) { + size_t prevsize = p->prev_foot; + if (is_mmapped(p)) { + psize += prevsize + MMAP_FOOT_PAD; + if (CALL_MUNMAP((char*)p - prevsize, psize) == 0) + fm->footprint -= psize; + goto postaction; + } + else { + mchunkptr prev = chunk_minus_offset(p, prevsize); + psize += prevsize; + p = prev; + if (RTCHECK(ok_address(fm, prev))) { /* consolidate backward */ + if (p != fm->dv) { + unlink_chunk(fm, p, prevsize); + } + else if ((next->head & INUSE_BITS) == INUSE_BITS) { + fm->dvsize = psize; + set_free_with_pinuse(p, psize, next); + goto postaction; + } + } + else + goto erroraction; + } + } + + if (RTCHECK(ok_next(p, next) && ok_pinuse(next))) { + if (!cinuse(next)) { /* consolidate forward */ + if (next == fm->top) { + size_t tsize = fm->topsize += psize; + fm->top = p; + p->head = tsize | PINUSE_BIT; + if (p == fm->dv) { + fm->dv = 0; + fm->dvsize = 0; + } + if (should_trim(fm, tsize)) + sys_trim(fm, 0); + goto postaction; + } + else if (next == fm->dv) { + size_t dsize = fm->dvsize += psize; + fm->dv = p; + set_size_and_pinuse_of_free_chunk(p, dsize); + goto postaction; + } + else { + size_t nsize = chunksize(next); + psize += nsize; + unlink_chunk(fm, next, nsize); + set_size_and_pinuse_of_free_chunk(p, psize); + if (p == fm->dv) { + fm->dvsize = psize; + goto postaction; + } + } + } + else + set_free_with_pinuse(p, psize, next); + + if (is_small(psize)) { + insert_small_chunk(fm, p, psize); + check_free_chunk(fm, p); + } + else { + tchunkptr tp = (tchunkptr)p; + insert_large_chunk(fm, tp, psize); + check_free_chunk(fm, p); + if (--fm->release_checks == 0) + release_unused_segments(fm); + } + goto postaction; + } + } + erroraction: + USAGE_ERROR_ACTION(fm, p); + postaction: + POSTACTION(fm); + } + } +} + +void* mspace_calloc(mspace msp, size_t n_elements, size_t elem_size) { + void* mem; + size_t req = 0; + mstate ms = (mstate)msp; + if (!ok_magic(ms)) { + USAGE_ERROR_ACTION(ms,ms); + return 0; + } + if (n_elements != 0) { + req = n_elements * elem_size; + if (((n_elements | elem_size) & ~(size_t)0xffff) && + (req / n_elements != elem_size)) + req = MAX_SIZE_T; /* force downstream failure on overflow */ + } + mem = internal_malloc(ms, req); + if (mem != 0 && calloc_must_clear(mem2chunk(mem))) + memset(mem, 0, req); + return mem; +} + +void* mspace_realloc(mspace msp, void* oldmem, size_t bytes) { + void* mem = 0; + if (oldmem == 0) { + mem = mspace_malloc(msp, bytes); + } + else if (bytes >= MAX_REQUEST) { + MALLOC_FAILURE_ACTION; + } +#ifdef REALLOC_ZERO_BYTES_FREES + else if (bytes == 0) { + mspace_free(msp, oldmem); + } +#endif /* REALLOC_ZERO_BYTES_FREES */ + else { + size_t nb = request2size(bytes); + mchunkptr oldp = mem2chunk(oldmem); +#if ! FOOTERS + mstate m = (mstate)msp; +#else /* FOOTERS */ + mstate m = get_mstate_for(oldp); + if (!ok_magic(m)) { + USAGE_ERROR_ACTION(m, oldmem); + return 0; + } +#endif /* FOOTERS */ + if (!PREACTION(m)) { + mchunkptr newp = try_realloc_chunk(m, oldp, nb, 1); + POSTACTION(m); + if (newp != 0) { + check_inuse_chunk(m, newp); + mem = chunk2mem(newp); + } + else { + mem = mspace_malloc(m, bytes); + if (mem != 0) { + size_t oc = chunksize(oldp) - overhead_for(oldp); + memcpy(mem, oldmem, (oc < bytes)? oc : bytes); + mspace_free(m, oldmem); + } + } + } + } + return mem; +} + +void* mspace_realloc_in_place(mspace msp, void* oldmem, size_t bytes) { + void* mem = 0; + if (oldmem != 0) { + if (bytes >= MAX_REQUEST) { + MALLOC_FAILURE_ACTION; + } + else { + size_t nb = request2size(bytes); + mchunkptr oldp = mem2chunk(oldmem); +#if ! FOOTERS + mstate m = (mstate)msp; +#else /* FOOTERS */ + mstate m = get_mstate_for(oldp); + (void)msp; /* placate people compiling -Wunused */ + if (!ok_magic(m)) { + USAGE_ERROR_ACTION(m, oldmem); + return 0; + } +#endif /* FOOTERS */ + if (!PREACTION(m)) { + mchunkptr newp = try_realloc_chunk(m, oldp, nb, 0); + POSTACTION(m); + if (newp == oldp) { + check_inuse_chunk(m, newp); + mem = oldmem; + } + } + } + } + return mem; +} + +void* mspace_memalign(mspace msp, size_t alignment, size_t bytes) { + mstate ms = (mstate)msp; + if (!ok_magic(ms)) { + USAGE_ERROR_ACTION(ms,ms); + return 0; + } + if (alignment <= MALLOC_ALIGNMENT) + return mspace_malloc(msp, bytes); + return internal_memalign(ms, alignment, bytes); +} + +void** mspace_independent_calloc(mspace msp, size_t n_elements, + size_t elem_size, void* chunks[]) { + size_t sz = elem_size; /* serves as 1-element array */ + mstate ms = (mstate)msp; + if (!ok_magic(ms)) { + USAGE_ERROR_ACTION(ms,ms); + return 0; + } + return ialloc(ms, n_elements, &sz, 3, chunks); +} + +void** mspace_independent_comalloc(mspace msp, size_t n_elements, + size_t sizes[], void* chunks[]) { + mstate ms = (mstate)msp; + if (!ok_magic(ms)) { + USAGE_ERROR_ACTION(ms,ms); + return 0; + } + return ialloc(ms, n_elements, sizes, 0, chunks); +} + +size_t mspace_bulk_free(mspace msp, void* array[], size_t nelem) { + return internal_bulk_free((mstate)msp, array, nelem); +} + +#if MALLOC_INSPECT_ALL +void mspace_inspect_all(mspace msp, + void(*handler)(void *start, + void *end, + size_t used_bytes, + void* callback_arg), + void* arg) { + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + if (!PREACTION(ms)) { + internal_inspect_all(ms, handler, arg); + POSTACTION(ms); + } + } + else { + USAGE_ERROR_ACTION(ms,ms); + } +} +#endif /* MALLOC_INSPECT_ALL */ + +int mspace_trim(mspace msp, size_t pad) { + int result = 0; + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + if (!PREACTION(ms)) { + result = sys_trim(ms, pad); + POSTACTION(ms); + } + } + else { + USAGE_ERROR_ACTION(ms,ms); + } + return result; +} + +#if !NO_MALLOC_STATS +void mspace_malloc_stats(mspace msp) { + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + internal_malloc_stats(ms); + } + else { + USAGE_ERROR_ACTION(ms,ms); + } +} +#endif /* NO_MALLOC_STATS */ + +size_t mspace_footprint(mspace msp) { + size_t result = 0; + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + result = ms->footprint; + } + else { + USAGE_ERROR_ACTION(ms,ms); + } + return result; +} + +size_t mspace_max_footprint(mspace msp) { + size_t result = 0; + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + result = ms->max_footprint; + } + else { + USAGE_ERROR_ACTION(ms,ms); + } + return result; +} + +size_t mspace_footprint_limit(mspace msp) { + size_t result = 0; + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + size_t maf = ms->footprint_limit; + result = (maf == 0) ? MAX_SIZE_T : maf; + } + else { + USAGE_ERROR_ACTION(ms,ms); + } + return result; +} + +size_t mspace_set_footprint_limit(mspace msp, size_t bytes) { + size_t result = 0; + mstate ms = (mstate)msp; + if (ok_magic(ms)) { + if (bytes == 0) + result = granularity_align(1); /* Use minimal size */ + if (bytes == MAX_SIZE_T) + result = 0; /* disable */ + else + result = granularity_align(bytes); + ms->footprint_limit = result; + } + else { + USAGE_ERROR_ACTION(ms,ms); + } + return result; +} + +#if !NO_MALLINFO +struct mallinfo mspace_mallinfo(mspace msp) { + mstate ms = (mstate)msp; + if (!ok_magic(ms)) { + USAGE_ERROR_ACTION(ms,ms); + } + return internal_mallinfo(ms); +} +#endif /* NO_MALLINFO */ + +size_t mspace_usable_size(const void* mem) { + if (mem != 0) { + mchunkptr p = mem2chunk(mem); + if (is_inuse(p)) + return chunksize(p) - overhead_for(p); + } + return 0; +} + +int mspace_mallopt(int param_number, int value) { + return change_mparam(param_number, value); +} + +#endif /* MSPACES */ + + +/* -------------------- Alternative MORECORE functions ------------------- */ + +/* + Guidelines for creating a custom version of MORECORE: + + * For best performance, MORECORE should allocate in multiples of pagesize. + * MORECORE may allocate more memory than requested. (Or even less, + but this will usually result in a malloc failure.) + * MORECORE must not allocate memory when given argument zero, but + instead return one past the end address of memory from previous + nonzero call. + * For best performance, consecutive calls to MORECORE with positive + arguments should return increasing addresses, indicating that + space has been contiguously extended. + * Even though consecutive calls to MORECORE need not return contiguous + addresses, it must be OK for malloc'ed chunks to span multiple + regions in those cases where they do happen to be contiguous. + * MORECORE need not handle negative arguments -- it may instead + just return MFAIL when given negative arguments. + Negative arguments are always multiples of pagesize. MORECORE + must not misinterpret negative args as large positive unsigned + args. You can suppress all such calls from even occurring by defining + MORECORE_CANNOT_TRIM, + + As an example alternative MORECORE, here is a custom allocator + kindly contributed for pre-OSX macOS. It uses virtually but not + necessarily physically contiguous non-paged memory (locked in, + present and won't get swapped out). You can use it by uncommenting + this section, adding some #includes, and setting up the appropriate + defines above: + + #define MORECORE osMoreCore + + There is also a shutdown routine that should somehow be called for + cleanup upon program exit. + + #define MAX_POOL_ENTRIES 100 + #define MINIMUM_MORECORE_SIZE (64 * 1024U) + static int next_os_pool; + void *our_os_pools[MAX_POOL_ENTRIES]; + + void *osMoreCore(int size) + { + void *ptr = 0; + static void *sbrk_top = 0; + + if (size > 0) + { + if (size < MINIMUM_MORECORE_SIZE) + size = MINIMUM_MORECORE_SIZE; + if (CurrentExecutionLevel() == kTaskLevel) + ptr = PoolAllocateResident(size + RM_PAGE_SIZE, 0); + if (ptr == 0) + { + return (void *) MFAIL; + } + // save ptrs so they can be freed during cleanup + our_os_pools[next_os_pool] = ptr; + next_os_pool++; + ptr = (void *) ((((size_t) ptr) + RM_PAGE_MASK) & ~RM_PAGE_MASK); + sbrk_top = (char *) ptr + size; + return ptr; + } + else if (size < 0) + { + // we don't currently support shrink behavior + return (void *) MFAIL; + } + else + { + return sbrk_top; + } + } + + // cleanup any allocated memory pools + // called as last thing before shutting down driver + + void osCleanupMem(void) + { + void **ptr; + + for (ptr = our_os_pools; ptr < &our_os_pools[MAX_POOL_ENTRIES]; ptr++) + if (*ptr) + { + PoolDeallocate(*ptr); + *ptr = 0; + } + } + +*/ + + +/* ----------------------------------------------------------------------- +History: + v2.8.6 Wed Aug 29 06:57:58 2012 Doug Lea + * fix bad comparison in dlposix_memalign + * don't reuse adjusted asize in sys_alloc + * add LOCK_AT_FORK -- thanks to Kirill Artamonov for the suggestion + * reduce compiler warnings -- thanks to all who reported/suggested these + + v2.8.5 Sun May 22 10:26:02 2011 Doug Lea (dl at gee) + * Always perform unlink checks unless INSECURE + * Add posix_memalign. + * Improve realloc to expand in more cases; expose realloc_in_place. + Thanks to Peter Buhr for the suggestion. + * Add footprint_limit, inspect_all, bulk_free. Thanks + to Barry Hayes and others for the suggestions. + * Internal refactorings to avoid calls while holding locks + * Use non-reentrant locks by default. Thanks to Roland McGrath + for the suggestion. + * Small fixes to mspace_destroy, reset_on_error. + * Various configuration extensions/changes. Thanks + to all who contributed these. + + V2.8.4a Thu Apr 28 14:39:43 2011 (dl at gee.cs.oswego.edu) + * Update Creative Commons URL + + V2.8.4 Wed May 27 09:56:23 2009 Doug Lea (dl at gee) + * Use zeros instead of prev foot for is_mmapped + * Add mspace_track_large_chunks; thanks to Jean Brouwers + * Fix set_inuse in internal_realloc; thanks to Jean Brouwers + * Fix insufficient sys_alloc padding when using 16byte alignment + * Fix bad error check in mspace_footprint + * Adaptations for ptmalloc; thanks to Wolfram Gloger. + * Reentrant spin locks; thanks to Earl Chew and others + * Win32 improvements; thanks to Niall Douglas and Earl Chew + * Add NO_SEGMENT_TRAVERSAL and MAX_RELEASE_CHECK_RATE options + * Extension hook in malloc_state + * Various small adjustments to reduce warnings on some compilers + * Various configuration extensions/changes for more platforms. Thanks + to all who contributed these. + + V2.8.3 Thu Sep 22 11:16:32 2005 Doug Lea (dl at gee) + * Add max_footprint functions + * Ensure all appropriate literals are size_t + * Fix conditional compilation problem for some #define settings + * Avoid concatenating segments with the one provided + in create_mspace_with_base + * Rename some variables to avoid compiler shadowing warnings + * Use explicit lock initialization. + * Better handling of sbrk interference. + * Simplify and fix segment insertion, trimming and mspace_destroy + * Reinstate REALLOC_ZERO_BYTES_FREES option from 2.7.x + * Thanks especially to Dennis Flanagan for help on these. + + V2.8.2 Sun Jun 12 16:01:10 2005 Doug Lea (dl at gee) + * Fix memalign brace error. + + V2.8.1 Wed Jun 8 16:11:46 2005 Doug Lea (dl at gee) + * Fix improper #endif nesting in C++ + * Add explicit casts needed for C++ + + V2.8.0 Mon May 30 14:09:02 2005 Doug Lea (dl at gee) + * Use trees for large bins + * Support mspaces + * Use segments to unify sbrk-based and mmap-based system allocation, + removing need for emulation on most platforms without sbrk. + * Default safety checks + * Optional footer checks. Thanks to William Robertson for the idea. + * Internal code refactoring + * Incorporate suggestions and platform-specific changes. + Thanks to Dennis Flanagan, Colin Plumb, Niall Douglas, + Aaron Bachmann, Emery Berger, and others. + * Speed up non-fastbin processing enough to remove fastbins. + * Remove useless cfree() to avoid conflicts with other apps. + * Remove internal memcpy, memset. Compilers handle builtins better. + * Remove some options that no one ever used and rename others. + + V2.7.2 Sat Aug 17 09:07:30 2002 Doug Lea (dl at gee) + * Fix malloc_state bitmap array misdeclaration + + V2.7.1 Thu Jul 25 10:58:03 2002 Doug Lea (dl at gee) + * Allow tuning of FIRST_SORTED_BIN_SIZE + * Use PTR_UINT as type for all ptr->int casts. Thanks to John Belmonte. + * Better detection and support for non-contiguousness of MORECORE. + Thanks to Andreas Mueller, Conal Walsh, and Wolfram Gloger + * Bypass most of malloc if no frees. Thanks To Emery Berger. + * Fix freeing of old top non-contiguous chunk im sysmalloc. + * Raised default trim and map thresholds to 256K. + * Fix mmap-related #defines. Thanks to Lubos Lunak. + * Fix copy macros; added LACKS_FCNTL_H. Thanks to Neal Walfield. + * Branch-free bin calculation + * Default trim and mmap thresholds now 256K. + + V2.7.0 Sun Mar 11 14:14:06 2001 Doug Lea (dl at gee) + * Introduce independent_comalloc and independent_calloc. + Thanks to Michael Pachos for motivation and help. + * Make optional .h file available + * Allow > 2GB requests on 32bit systems. + * new WIN32 sbrk, mmap, munmap, lock code from . + Thanks also to Andreas Mueller , + and Anonymous. + * Allow override of MALLOC_ALIGNMENT (Thanks to Ruud Waij for + helping test this.) + * memalign: check alignment arg + * realloc: don't try to shift chunks backwards, since this + leads to more fragmentation in some programs and doesn't + seem to help in any others. + * Collect all cases in malloc requiring system memory into sysmalloc + * Use mmap as backup to sbrk + * Place all internal state in malloc_state + * Introduce fastbins (although similar to 2.5.1) + * Many minor tunings and cosmetic improvements + * Introduce USE_PUBLIC_MALLOC_WRAPPERS, USE_MALLOC_LOCK + * Introduce MALLOC_FAILURE_ACTION, MORECORE_CONTIGUOUS + Thanks to Tony E. Bennett and others. + * Include errno.h to support default failure action. + + V2.6.6 Sun Dec 5 07:42:19 1999 Doug Lea (dl at gee) + * return null for negative arguments + * Added Several WIN32 cleanups from Martin C. Fong + * Add 'LACKS_SYS_PARAM_H' for those systems without 'sys/param.h' + (e.g. WIN32 platforms) + * Cleanup header file inclusion for WIN32 platforms + * Cleanup code to avoid Microsoft Visual C++ compiler complaints + * Add 'USE_DL_PREFIX' to quickly allow co-existence with existing + memory allocation routines + * Set 'malloc_getpagesize' for WIN32 platforms (needs more work) + * Use 'assert' rather than 'ASSERT' in WIN32 code to conform to + usage of 'assert' in non-WIN32 code + * Improve WIN32 'sbrk()' emulation's 'findRegion()' routine to + avoid infinite loop + * Always call 'fREe()' rather than 'free()' + + V2.6.5 Wed Jun 17 15:57:31 1998 Doug Lea (dl at gee) + * Fixed ordering problem with boundary-stamping + + V2.6.3 Sun May 19 08:17:58 1996 Doug Lea (dl at gee) + * Added pvalloc, as recommended by H.J. Liu + * Added 64bit pointer support mainly from Wolfram Gloger + * Added anonymously donated WIN32 sbrk emulation + * Malloc, calloc, getpagesize: add optimizations from Raymond Nijssen + * malloc_extend_top: fix mask error that caused wastage after + foreign sbrks + * Add linux mremap support code from HJ Liu + + V2.6.2 Tue Dec 5 06:52:55 1995 Doug Lea (dl at gee) + * Integrated most documentation with the code. + * Add support for mmap, with help from + Wolfram Gloger (Gloger@lrz.uni-muenchen.de). + * Use last_remainder in more cases. + * Pack bins using idea from colin@nyx10.cs.du.edu + * Use ordered bins instead of best-fit threshhold + * Eliminate block-local decls to simplify tracing and debugging. + * Support another case of realloc via move into top + * Fix error occuring when initial sbrk_base not word-aligned. + * Rely on page size for units instead of SBRK_UNIT to + avoid surprises about sbrk alignment conventions. + * Add mallinfo, mallopt. Thanks to Raymond Nijssen + (raymond@es.ele.tue.nl) for the suggestion. + * Add `pad' argument to malloc_trim and top_pad mallopt parameter. + * More precautions for cases where other routines call sbrk, + courtesy of Wolfram Gloger (Gloger@lrz.uni-muenchen.de). + * Added macros etc., allowing use in linux libc from + H.J. Lu (hjl@gnu.ai.mit.edu) + * Inverted this history list + + V2.6.1 Sat Dec 2 14:10:57 1995 Doug Lea (dl at gee) + * Re-tuned and fixed to behave more nicely with V2.6.0 changes. + * Removed all preallocation code since under current scheme + the work required to undo bad preallocations exceeds + the work saved in good cases for most test programs. + * No longer use return list or unconsolidated bins since + no scheme using them consistently outperforms those that don't + given above changes. + * Use best fit for very large chunks to prevent some worst-cases. + * Added some support for debugging + + V2.6.0 Sat Nov 4 07:05:23 1995 Doug Lea (dl at gee) + * Removed footers when chunks are in use. Thanks to + Paul Wilson (wilson@cs.texas.edu) for the suggestion. + + V2.5.4 Wed Nov 1 07:54:51 1995 Doug Lea (dl at gee) + * Added malloc_trim, with help from Wolfram Gloger + (wmglo@Dent.MED.Uni-Muenchen.DE). + + V2.5.3 Tue Apr 26 10:16:01 1994 Doug Lea (dl at g) + + V2.5.2 Tue Apr 5 16:20:40 1994 Doug Lea (dl at g) + * realloc: try to expand in both directions + * malloc: swap order of clean-bin strategy; + * realloc: only conditionally expand backwards + * Try not to scavenge used bins + * Use bin counts as a guide to preallocation + * Occasionally bin return list chunks in first scan + * Add a few optimizations from colin@nyx10.cs.du.edu + + V2.5.1 Sat Aug 14 15:40:43 1993 Doug Lea (dl at g) + * faster bin computation & slightly different binning + * merged all consolidations to one part of malloc proper + (eliminating old malloc_find_space & malloc_clean_bin) + * Scan 2 returns chunks (not just 1) + * Propagate failure in realloc if malloc returns 0 + * Add stuff to allow compilation on non-ANSI compilers + from kpv@research.att.com + + V2.5 Sat Aug 7 07:41:59 1993 Doug Lea (dl at g.oswego.edu) + * removed potential for odd address access in prev_chunk + * removed dependency on getpagesize.h + * misc cosmetics and a bit more internal documentation + * anticosmetics: mangled names in macros to evade debugger strangeness + * tested on sparc, hp-700, dec-mips, rs6000 + with gcc & native cc (hp, dec only) allowing + Detlefs & Zorn comparison study (in SIGPLAN Notices.) + + Trial version Fri Aug 28 13:14:29 1992 Doug Lea (dl at g.oswego.edu) + * Based loosely on libg++-1.2X malloc. (It retains some of the overall + structure of old version, but most details differ.) + +*/ diff --git a/libnotcc/wasm-std/src/printf.c b/libnotcc/wasm-std/src/printf.c new file mode 100644 index 00000000..97424165 --- /dev/null +++ b/libnotcc/wasm-std/src/printf.c @@ -0,0 +1,17 @@ +#include +#define NANOPRINTF_IMPLEMENTATION +#define NANOPRINTF_VISIBILITY_STATIC +#define NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS 0 +#define NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS 0 +#define NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS 0 +#define NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS 0 +#define NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS 0 +#define NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS 0 +#include "./printf.h" + +// Copied from EMMALLOC_ALIAS +#define PRINTF_ALIAS(ALIAS, ORIGINAL) \ + extern __typeof(ORIGINAL) ALIAS __attribute__((weak, alias(#ORIGINAL))); + +PRINTF_ALIAS(snprintf, npf_snprintf); +PRINTF_ALIAS(vsnprintf, npf_vsnprintf); diff --git a/libnotcc/wasm-std/src/printf.h b/libnotcc/wasm-std/src/printf.h new file mode 100644 index 00000000..15e35056 --- /dev/null +++ b/libnotcc/wasm-std/src/printf.h @@ -0,0 +1,1143 @@ + +/* nanoprintf v0.5.1: a tiny embeddable printf replacement written in C. + https://github.com/charlesnicholson/nanoprintf + charles.nicholson+nanoprintf@gmail.com + dual-licensed under 0bsd and unlicense, take your pick. see eof for details. */ + +#ifndef NANOPRINTF_H_INCLUDED +#define NANOPRINTF_H_INCLUDED + +#include +#include + +// Define this to fully sandbox nanoprintf inside of a translation unit. +#ifdef NANOPRINTF_VISIBILITY_STATIC + #define NPF_VISIBILITY static +#else + #define NPF_VISIBILITY extern +#endif + +#if defined(__clang__) || defined(__GNUC__) || defined(__GNUG__) + #define NPF_PRINTF_ATTR(FORMAT_INDEX, VARGS_INDEX) \ + __attribute__((format(printf, FORMAT_INDEX, VARGS_INDEX))) +#else + #define NPF_PRINTF_ATTR(FORMAT_INDEX, VARGS_INDEX) +#endif + +// Public API + +#ifdef __cplusplus +extern "C" { +#endif + +// The npf_ functions all return the number of bytes required to express the +// fully-formatted string, not including the null terminator character. +// The npf_ functions do not return negative values, since the lack of 'l' length +// modifier support makes encoding errors impossible. + +NPF_VISIBILITY int npf_snprintf( + char *buffer, size_t bufsz, const char *format, ...) NPF_PRINTF_ATTR(3, 4); + +NPF_VISIBILITY int npf_vsnprintf( + char *buffer, size_t bufsz, char const *format, va_list vlist) NPF_PRINTF_ATTR(3, 0); + +typedef void (*npf_putc)(int c, void *ctx); +NPF_VISIBILITY int npf_pprintf( + npf_putc pc, void *pc_ctx, char const *format, ...) NPF_PRINTF_ATTR(3, 4); + +NPF_VISIBILITY int npf_vpprintf( + npf_putc pc, void *pc_ctx, char const *format, va_list vlist) NPF_PRINTF_ATTR(3, 0); + +#ifdef __cplusplus +} +#endif + +#endif // NANOPRINTF_H_INCLUDED + +/* The implementation of nanoprintf begins here, to be compiled only if + NANOPRINTF_IMPLEMENTATION is defined. In a multi-file library what follows would + be nanoprintf.c. */ + +#ifdef NANOPRINTF_IMPLEMENTATION + +#ifndef NANOPRINTF_IMPLEMENTATION_INCLUDED +#define NANOPRINTF_IMPLEMENTATION_INCLUDED + +#include +#include +#include + +// The conversion buffer must fit at least UINT64_MAX in octal format with the leading '0'. +#ifndef NANOPRINTF_CONVERSION_BUFFER_SIZE + #define NANOPRINTF_CONVERSION_BUFFER_SIZE 23 +#endif +#if NANOPRINTF_CONVERSION_BUFFER_SIZE < 23 + #error The size of the conversion buffer must be at least 23 bytes. +#endif + +// Pick reasonable defaults if nothing's been configured. +#if !defined(NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS) && \ + !defined(NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS) && \ + !defined(NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS) && \ + !defined(NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS) && \ + !defined(NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS) && \ + !defined(NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS) + #define NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS 1 + #define NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS 1 + #define NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS 1 + #define NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS 0 + #define NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS 0 + #define NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS 0 +#endif + +// If anything's been configured, everything must be configured. +#ifndef NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS + #error NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS must be #defined to 0 or 1 +#endif +#ifndef NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS + #error NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS must be #defined to 0 or 1 +#endif +#ifndef NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS + #error NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS must be #defined to 0 or 1 +#endif +#ifndef NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS + #error NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS must be #defined to 0 or 1 +#endif +#ifndef NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS + #error NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS must be #defined to 0 or 1 +#endif +#ifndef NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS + #error NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS must be #defined to 0 or 1 +#endif + +// Ensure flags are compatible. +#if (NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1) && \ + (NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 0) + #error Precision format specifiers must be enabled if float support is enabled. +#endif + +// intmax_t / uintmax_t require stdint from c99 / c++11 +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + #ifndef _MSC_VER + #ifdef __cplusplus + #if __cplusplus < 201103L + #error large format specifier support requires C++11 or later. + #endif + #else + #if __STDC_VERSION__ < 199409L + #error nanoprintf requires C99 or later. + #endif + #endif + #endif +#endif + +// Figure out if we can disable warnings with pragmas. +#ifdef __clang__ + #define NANOPRINTF_CLANG 1 + #define NANOPRINTF_GCC_PAST_4_6 0 +#else + #define NANOPRINTF_CLANG 0 + #if defined(__GNUC__) && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 6))) + #define NANOPRINTF_GCC_PAST_4_6 1 + #else + #define NANOPRINTF_GCC_PAST_4_6 0 + #endif +#endif + +#if NANOPRINTF_CLANG || NANOPRINTF_GCC_PAST_4_6 + #define NANOPRINTF_HAVE_GCC_WARNING_PRAGMAS 1 +#else + #define NANOPRINTF_HAVE_GCC_WARNING_PRAGMAS 0 +#endif + +#if NANOPRINTF_HAVE_GCC_WARNING_PRAGMAS + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wunused-function" + #pragma GCC diagnostic ignored "-Wimplicit-fallthrough" + #ifdef __cplusplus + #pragma GCC diagnostic ignored "-Wold-style-cast" + #endif + #pragma GCC diagnostic ignored "-Wpadded" + #pragma GCC diagnostic ignored "-Wfloat-equal" + #if NANOPRINTF_CLANG + #pragma GCC diagnostic ignored "-Wc++98-compat-pedantic" + #pragma GCC diagnostic ignored "-Wcovered-switch-default" + #pragma GCC diagnostic ignored "-Wdeclaration-after-statement" + #pragma GCC diagnostic ignored "-Wzero-as-null-pointer-constant" + #ifndef __APPLE__ + #pragma GCC diagnostic ignored "-Wunsafe-buffer-usage" + #endif + #elif NANOPRINTF_GCC_PAST_4_6 + #pragma GCC diagnostic ignored "-Wmaybe-uninitialized" + #endif +#endif + +#ifdef _MSC_VER + #pragma warning(push) + #pragma warning(disable:4619) // there is no warning number 'number' + // C4619 has to be disabled first! + #pragma warning(disable:4127) // conditional expression is constant + #pragma warning(disable:4505) // unreferenced local function has been removed + #pragma warning(disable:4514) // unreferenced inline function has been removed + #pragma warning(disable:4701) // potentially uninitialized local variable used + #pragma warning(disable:4706) // assignment within conditional expression + #pragma warning(disable:4710) // function not inlined + #pragma warning(disable:4711) // function selected for inline expansion + #pragma warning(disable:4820) // padding added after struct member + #pragma warning(disable:5039) // potentially throwing function passed to extern C function + #pragma warning(disable:5045) // compiler will insert Spectre mitigation for memory load + #pragma warning(disable:5262) // implicit switch fall-through + #pragma warning(disable:26812) // enum type is unscoped +#endif + +#if defined(__clang__) || defined(__GNUC__) || defined(__GNUG__) + #define NPF_NOINLINE __attribute__((noinline)) +#elif defined(_MSC_VER) + #define NPF_NOINLINE __declspec(noinline) +#else + #define NPF_NOINLINE +#endif + +#if (NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1) || \ + (NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1) +enum { + NPF_FMT_SPEC_OPT_NONE, + NPF_FMT_SPEC_OPT_LITERAL, + NPF_FMT_SPEC_OPT_STAR, +}; +#endif + +enum { + NPF_FMT_SPEC_LEN_MOD_NONE, + NPF_FMT_SPEC_LEN_MOD_SHORT, // 'h' + NPF_FMT_SPEC_LEN_MOD_LONG_DOUBLE, // 'L' + NPF_FMT_SPEC_LEN_MOD_CHAR, // 'hh' + NPF_FMT_SPEC_LEN_MOD_LONG, // 'l' +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + NPF_FMT_SPEC_LEN_MOD_LARGE_LONG_LONG, // 'll' + NPF_FMT_SPEC_LEN_MOD_LARGE_INTMAX, // 'j' + NPF_FMT_SPEC_LEN_MOD_LARGE_SIZET, // 'z' + NPF_FMT_SPEC_LEN_MOD_LARGE_PTRDIFFT, // 't' +#endif +}; + +enum { + NPF_FMT_SPEC_CONV_NONE, + NPF_FMT_SPEC_CONV_PERCENT, // '%' + NPF_FMT_SPEC_CONV_CHAR, // 'c' + NPF_FMT_SPEC_CONV_STRING, // 's' + NPF_FMT_SPEC_CONV_SIGNED_INT, // 'i', 'd' +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 + NPF_FMT_SPEC_CONV_BINARY, // 'b' +#endif + NPF_FMT_SPEC_CONV_OCTAL, // 'o' + NPF_FMT_SPEC_CONV_HEX_INT, // 'x', 'X' + NPF_FMT_SPEC_CONV_UNSIGNED_INT, // 'u' + NPF_FMT_SPEC_CONV_POINTER, // 'p' +#if NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS == 1 + NPF_FMT_SPEC_CONV_WRITEBACK, // 'n' +#endif +#if NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1 + NPF_FMT_SPEC_CONV_FLOAT_DEC, // 'f', 'F' + NPF_FMT_SPEC_CONV_FLOAT_SCI, // 'e', 'E' + NPF_FMT_SPEC_CONV_FLOAT_SHORTEST, // 'g', 'G' + NPF_FMT_SPEC_CONV_FLOAT_HEX, // 'a', 'A' +#endif +}; + +typedef struct npf_format_spec { +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + int field_width; + uint8_t field_width_opt; + char left_justified; // '-' + char leading_zero_pad; // '0' +#endif +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + int prec; + uint8_t prec_opt; +#endif + char prepend; // ' ' or '+' + char alt_form; // '#' + char case_adjust; // 'a' - 'A' + uint8_t length_modifier; + uint8_t conv_spec; +} npf_format_spec_t; + +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 0 + typedef long npf_int_t; + typedef unsigned long npf_uint_t; +#else + typedef intmax_t npf_int_t; + typedef uintmax_t npf_uint_t; +#endif + +typedef struct npf_bufputc_ctx { + char *dst; + size_t len; + size_t cur; +} npf_bufputc_ctx_t; + +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + #ifdef _MSC_VER + #include + typedef SSIZE_T ssize_t; + #else + #include + #endif +#endif + +#ifdef _MSC_VER + #include +#endif + +static int npf_max(int x, int y) { return (x > y) ? x : y; } + +static int npf_parse_format_spec(char const *format, npf_format_spec_t *out_spec) { + char const *cur = format; + +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + out_spec->left_justified = 0; + out_spec->leading_zero_pad = 0; +#endif + out_spec->case_adjust = 'a' - 'A'; // lowercase + out_spec->prepend = 0; + out_spec->alt_form = 0; + + while (*++cur) { // cur points at the leading '%' character + switch (*cur) { // Optional flags +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + case '-': out_spec->left_justified = '-'; out_spec->leading_zero_pad = 0; continue; + case '0': out_spec->leading_zero_pad = !out_spec->left_justified; continue; +#endif + case '+': out_spec->prepend = '+'; continue; + case ' ': if (out_spec->prepend == 0) { out_spec->prepend = ' '; } continue; + case '#': out_spec->alt_form = '#'; continue; + default: break; + } + break; + } + +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + out_spec->field_width_opt = NPF_FMT_SPEC_OPT_NONE; + if (*cur == '*') { + out_spec->field_width_opt = NPF_FMT_SPEC_OPT_STAR; + ++cur; + } else { + out_spec->field_width = 0; + while ((*cur >= '0') && (*cur <= '9')) { + out_spec->field_width_opt = NPF_FMT_SPEC_OPT_LITERAL; + out_spec->field_width = (out_spec->field_width * 10) + (*cur++ - '0'); + } + } +#endif + +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + out_spec->prec = 0; + out_spec->prec_opt = NPF_FMT_SPEC_OPT_NONE; + if (*cur == '.') { + ++cur; + if (*cur == '*') { + out_spec->prec_opt = NPF_FMT_SPEC_OPT_STAR; + ++cur; + } else { + if (*cur == '-') { + ++cur; + } else { + out_spec->prec_opt = NPF_FMT_SPEC_OPT_LITERAL; + } + while ((*cur >= '0') && (*cur <= '9')) { + out_spec->prec = (out_spec->prec * 10) + (*cur++ - '0'); + } + } + } +#endif + + uint_fast8_t tmp_conv = NPF_FMT_SPEC_CONV_NONE; + out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_NONE; + switch (*cur++) { // Length modifier + case 'h': + out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_SHORT; + if (*cur == 'h') { + out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_CHAR; + ++cur; + } + break; + case 'l': + out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_LONG; +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + if (*cur == 'l') { + out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_LARGE_LONG_LONG; + ++cur; + } +#endif + break; +#if NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1 + case 'L': out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_LONG_DOUBLE; break; +#endif +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + case 'j': out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_LARGE_INTMAX; break; + case 'z': out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_LARGE_SIZET; break; + case 't': out_spec->length_modifier = NPF_FMT_SPEC_LEN_MOD_LARGE_PTRDIFFT; break; +#endif + default: --cur; break; + } + + switch (*cur++) { // Conversion specifier + case '%': out_spec->conv_spec = NPF_FMT_SPEC_CONV_PERCENT; +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + out_spec->prec_opt = NPF_FMT_SPEC_OPT_NONE; +#endif + break; + + case 'c': out_spec->conv_spec = NPF_FMT_SPEC_CONV_CHAR; +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + out_spec->prec_opt = NPF_FMT_SPEC_OPT_NONE; +#endif + break; + + case 's': out_spec->conv_spec = NPF_FMT_SPEC_CONV_STRING; +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + out_spec->leading_zero_pad = 0; +#endif + break; + + case 'i': + case 'd': tmp_conv = NPF_FMT_SPEC_CONV_SIGNED_INT; + case 'o': + if (tmp_conv == NPF_FMT_SPEC_CONV_NONE) { tmp_conv = NPF_FMT_SPEC_CONV_OCTAL; } + case 'u': + if (tmp_conv == NPF_FMT_SPEC_CONV_NONE) { tmp_conv = NPF_FMT_SPEC_CONV_UNSIGNED_INT; } + case 'X': + if (tmp_conv == NPF_FMT_SPEC_CONV_NONE) { out_spec->case_adjust = 0; } + case 'x': + if (tmp_conv == NPF_FMT_SPEC_CONV_NONE) { tmp_conv = NPF_FMT_SPEC_CONV_HEX_INT; } + out_spec->conv_spec = (uint8_t)tmp_conv; +#if (NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1) && \ + (NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1) + if (out_spec->prec_opt != NPF_FMT_SPEC_OPT_NONE) { out_spec->leading_zero_pad = 0; } +#endif + break; + +#if NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1 + case 'F': out_spec->case_adjust = 0; + case 'f': + out_spec->conv_spec = NPF_FMT_SPEC_CONV_FLOAT_DEC; + if (out_spec->prec_opt == NPF_FMT_SPEC_OPT_NONE) { out_spec->prec = 6; } + break; + + case 'E': out_spec->case_adjust = 0; + case 'e': + out_spec->conv_spec = NPF_FMT_SPEC_CONV_FLOAT_SCI; + if (out_spec->prec_opt == NPF_FMT_SPEC_OPT_NONE) { out_spec->prec = 6; } + break; + + case 'G': out_spec->case_adjust = 0; + case 'g': + out_spec->conv_spec = NPF_FMT_SPEC_CONV_FLOAT_SHORTEST; + if (out_spec->prec_opt == NPF_FMT_SPEC_OPT_NONE) { out_spec->prec = 6; } + break; + + case 'A': out_spec->case_adjust = 0; + case 'a': + out_spec->conv_spec = NPF_FMT_SPEC_CONV_FLOAT_HEX; + if (out_spec->prec_opt == NPF_FMT_SPEC_OPT_NONE) { out_spec->prec = 6; } + break; +#endif + +#if NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS == 1 + case 'n': + // todo: reject string if flags or width or precision exist + out_spec->conv_spec = NPF_FMT_SPEC_CONV_WRITEBACK; +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + out_spec->prec_opt = NPF_FMT_SPEC_OPT_NONE; +#endif + break; +#endif + + case 'p': + out_spec->conv_spec = NPF_FMT_SPEC_CONV_POINTER; +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + out_spec->prec_opt = NPF_FMT_SPEC_OPT_NONE; +#endif + break; + +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 + case 'B': + out_spec->case_adjust = 0; + case 'b': + out_spec->conv_spec = NPF_FMT_SPEC_CONV_BINARY; + break; +#endif + + default: return 0; + } + + return (int)(cur - format); +} + +static NPF_NOINLINE int npf_utoa_rev( + npf_uint_t val, char *buf, uint_fast8_t base, char case_adj) { + uint_fast8_t n = 0; + do { + int_fast8_t const d = (int_fast8_t)(val % base); + *buf++ = (char)(((d < 10) ? '0' : ('A' - 10 + case_adj)) + d); + ++n; + val /= base; + } while (val); + return (int)n; +} + +#if NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1 + +#include + +#if (DBL_MANT_DIG <= 11) && (DBL_MAX_EXP <= 16) + typedef uint_fast16_t npf_double_bin_t; + typedef int_fast8_t npf_ftoa_exp_t; +#elif (DBL_MANT_DIG <= 24) && (DBL_MAX_EXP <= 128) + typedef uint_fast32_t npf_double_bin_t; + typedef int_fast8_t npf_ftoa_exp_t; +#elif (DBL_MANT_DIG <= 53) && (DBL_MAX_EXP <= 1024) + typedef uint_fast64_t npf_double_bin_t; + typedef int_fast16_t npf_ftoa_exp_t; +#else + #error Unsupported width of the double type. +#endif + +// The floating point conversion code works with an unsigned integer type of any size. +#ifndef NANOPRINTF_CONVERSION_FLOAT_TYPE + #define NANOPRINTF_CONVERSION_FLOAT_TYPE unsigned int +#endif +typedef NANOPRINTF_CONVERSION_FLOAT_TYPE npf_ftoa_man_t; + +#if (NANOPRINTF_CONVERSION_BUFFER_SIZE <= UINT_FAST8_MAX) && (UINT_FAST8_MAX <= INT_MAX) + typedef uint_fast8_t npf_ftoa_dec_t; +#else + typedef int npf_ftoa_dec_t; +#endif + +enum { + NPF_DOUBLE_EXP_MASK = DBL_MAX_EXP * 2 - 1, + NPF_DOUBLE_EXP_BIAS = DBL_MAX_EXP - 1, + NPF_DOUBLE_MAN_BITS = DBL_MANT_DIG - 1, + NPF_DOUBLE_BIN_BITS = sizeof(npf_double_bin_t) * CHAR_BIT, + NPF_FTOA_MAN_BITS = sizeof(npf_ftoa_man_t) * CHAR_BIT, + NPF_FTOA_SHIFT_BITS = + ((NPF_FTOA_MAN_BITS < DBL_MANT_DIG) ? NPF_FTOA_MAN_BITS : DBL_MANT_DIG) - 1 +}; + +/* Generally, floating-point conversion implementations use + grisu2 (https://bit.ly/2JgMggX) and ryu (https://bit.ly/2RLXSg0) algorithms, + which are mathematically exact and fast, but require large lookup tables. + + This implementation was inspired by Wojciech Muła's (zdjęcia@garnek.pl) + algorithm (http://0x80.pl/notesen/2015-12-29-float-to-string.html) and + extended further by adding dynamic scaling and configurable integer width by + Oskars Rubenis (https://github.com/Okarss). */ + +static int npf_ftoa_rev(char *buf, npf_format_spec_t const *spec, double f) { + char const *ret = NULL; + npf_double_bin_t bin; { // Union-cast is UB pre-C11, compiler optimizes byte-copy loop. + char const *src = (char const *)&f; + char *dst = (char *)&bin; + for (uint_fast8_t i = 0; i < sizeof(f); ++i) { dst[i] = src[i]; } + } + + // Unsigned -> signed int casting is IB and can raise a signal but generally doesn't. + npf_ftoa_exp_t exp = + (npf_ftoa_exp_t)((npf_ftoa_exp_t)(bin >> NPF_DOUBLE_MAN_BITS) & NPF_DOUBLE_EXP_MASK); + + bin &= ((npf_double_bin_t)0x1 << NPF_DOUBLE_MAN_BITS) - 1; + if (exp == (npf_ftoa_exp_t)NPF_DOUBLE_EXP_MASK) { // special value + ret = (bin) ? "NAN" : "FNI"; + goto exit; + } + if (spec->prec > (NANOPRINTF_CONVERSION_BUFFER_SIZE - 2)) { goto exit; } + if (exp) { // normal number + bin |= (npf_double_bin_t)0x1 << NPF_DOUBLE_MAN_BITS; + } else { // subnormal number + ++exp; + } + exp = (npf_ftoa_exp_t)(exp - NPF_DOUBLE_EXP_BIAS); + + uint_fast8_t carry; carry = 0; + npf_ftoa_dec_t end, dec; dec = (npf_ftoa_dec_t)spec->prec; + if (dec || spec->alt_form) { + buf[dec++] = '.'; + } + + { // Integer part + npf_ftoa_man_t man_i; + + if (exp >= 0) { + int_fast8_t shift_i = + (int_fast8_t)((exp > NPF_FTOA_SHIFT_BITS) ? (int)NPF_FTOA_SHIFT_BITS : exp); + npf_ftoa_exp_t exp_i = (npf_ftoa_exp_t)(exp - shift_i); + shift_i = (int_fast8_t)(NPF_DOUBLE_MAN_BITS - shift_i); + man_i = (npf_ftoa_man_t)(bin >> shift_i); + + if (exp_i) { + if (shift_i) { + carry = (bin >> (shift_i - 1)) & 0x1; + } + exp = NPF_DOUBLE_MAN_BITS; // invalidate the fraction part + } + + // Scale the exponent from base-2 to base-10. + for (; exp_i; --exp_i) { + if (!(man_i & ((npf_ftoa_man_t)0x1 << (NPF_FTOA_MAN_BITS - 1)))) { + man_i = (npf_ftoa_man_t)(man_i << 1); + man_i = (npf_ftoa_man_t)(man_i | carry); carry = 0; + } else { + if (dec >= NANOPRINTF_CONVERSION_BUFFER_SIZE) { goto exit; } + buf[dec++] = '0'; + carry = (((uint_fast8_t)(man_i % 5) + carry) > 2); + man_i /= 5; + } + } + } else { + man_i = 0; + } + end = dec; + + do { // Print the integer + if (end >= NANOPRINTF_CONVERSION_BUFFER_SIZE) { goto exit; } + buf[end++] = (char)('0' + (char)(man_i % 10)); + man_i /= 10; + } while (man_i); + } + + { // Fraction part + npf_ftoa_man_t man_f; + npf_ftoa_dec_t dec_f = (npf_ftoa_dec_t)spec->prec; + + if (exp < NPF_DOUBLE_MAN_BITS) { + int_fast8_t shift_f = (int_fast8_t)((exp < 0) ? -1 : exp); + npf_ftoa_exp_t exp_f = (npf_ftoa_exp_t)(exp - shift_f); + npf_double_bin_t bin_f = + bin << ((NPF_DOUBLE_BIN_BITS - NPF_DOUBLE_MAN_BITS) + shift_f); + + // This if-else statement can be completely optimized at compile time. + if (NPF_DOUBLE_BIN_BITS > NPF_FTOA_MAN_BITS) { + man_f = (npf_ftoa_man_t)(bin_f >> ((unsigned)(NPF_DOUBLE_BIN_BITS - + NPF_FTOA_MAN_BITS) % + NPF_DOUBLE_BIN_BITS)); + carry = (uint_fast8_t)((bin_f >> ((unsigned)(NPF_DOUBLE_BIN_BITS - + NPF_FTOA_MAN_BITS - 1) % + NPF_DOUBLE_BIN_BITS)) & 0x1); + } else { + man_f = (npf_ftoa_man_t)((npf_ftoa_man_t)bin_f + << ((unsigned)(NPF_FTOA_MAN_BITS - + NPF_DOUBLE_BIN_BITS) % NPF_FTOA_MAN_BITS)); + carry = 0; + } + + // Scale the exponent from base-2 to base-10 and prepare the first digit. + for (uint_fast8_t digit = 0; dec_f && (exp_f < 4); ++exp_f) { + if ((man_f > ((npf_ftoa_man_t)-4 / 5)) || digit) { + carry = (uint_fast8_t)(man_f & 0x1); + man_f = (npf_ftoa_man_t)(man_f >> 1); + } else { + man_f = (npf_ftoa_man_t)(man_f * 5); + if (carry) { man_f = (npf_ftoa_man_t)(man_f + 3); carry = 0; } + if (exp_f < 0) { + buf[--dec_f] = '0'; + } else { + ++digit; + } + } + } + man_f = (npf_ftoa_man_t)(man_f + carry); + carry = (exp_f >= 0); + dec = 0; + } else { + man_f = 0; + } + + if (dec_f) { + // Print the fraction + for (;;) { + buf[--dec_f] = (char)('0' + (char)(man_f >> (NPF_FTOA_MAN_BITS - 4))); + man_f = (npf_ftoa_man_t)(man_f & ~((npf_ftoa_man_t)0xF << (NPF_FTOA_MAN_BITS - 4))); + if (!dec_f) { break; } + man_f = (npf_ftoa_man_t)(man_f * 10); + } + man_f = (npf_ftoa_man_t)(man_f << 4); + } + if (exp < NPF_DOUBLE_MAN_BITS) { + carry &= (uint_fast8_t)(man_f >> (NPF_FTOA_MAN_BITS - 1)); + } + } + + // Round the number + for (; carry; ++dec) { + if (dec >= NANOPRINTF_CONVERSION_BUFFER_SIZE) { goto exit; } + if (dec >= end) { buf[end++] = '0'; } + if (buf[dec] == '.') { continue; } + carry = (buf[dec] == '9'); + buf[dec] = (char)(carry ? '0' : (buf[dec] + 1)); + } + + return (int)end; +exit: + if (!ret) { ret = "RRE"; } + uint_fast8_t i; + for (i = 0; ret[i]; ++i) { buf[i] = (char)(ret[i] + spec->case_adjust); } + return (int)i; +} + +#endif // NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS + +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 +static int npf_bin_len(npf_uint_t u) { + // Return the length of the binary string format of 'u', preferring intrinsics. + if (!u) { return 1; } + +#ifdef _MSC_VER // Win64, use _BSR64 for everything. If x86, use _BSR when non-large. + #ifdef _M_X64 + #define NPF_HAVE_BUILTIN_CLZ + #define NPF_CLZ _BitScanReverse64 + #elif NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 0 + #define NPF_HAVE_BUILTIN_CLZ + #define NPF_CLZ _BitScanReverse + #endif + #ifdef NPF_HAVE_BUILTIN_CLZ + unsigned long idx; + NPF_CLZ(&idx, u); + return (int)(idx + 1); + #endif +#elif defined(NANOPRINTF_CLANG) || defined(NANOPRINTF_GCC_PAST_4_6) + #define NPF_HAVE_BUILTIN_CLZ + #if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + #define NPF_CLZ(X) ((sizeof(long long) * CHAR_BIT) - (size_t)__builtin_clzll(X)) + #else + #define NPF_CLZ(X) ((sizeof(long) * CHAR_BIT) - (size_t)__builtin_clzl(X)) + #endif + return (int)NPF_CLZ(u); +#endif + +#ifndef NPF_HAVE_BUILTIN_CLZ + int n; + for (n = 0; u; ++n, u >>= 1); // slow but small software fallback + return n; +#else + #undef NPF_HAVE_BUILTIN_CLZ + #undef NPF_CLZ +#endif +} +#endif + +static void npf_bufputc(int c, void *ctx) { + npf_bufputc_ctx_t *bpc = (npf_bufputc_ctx_t *)ctx; + if (bpc->cur < bpc->len) { bpc->dst[bpc->cur++] = (char)c; } +} + +static void npf_bufputc_nop(int c, void *ctx) { (void)c; (void)ctx; } + +typedef struct npf_cnt_putc_ctx { + npf_putc pc; + void *ctx; + int n; +} npf_cnt_putc_ctx_t; + +static void npf_putc_cnt(int c, void *ctx) { + npf_cnt_putc_ctx_t *pc_cnt = (npf_cnt_putc_ctx_t *)ctx; + ++pc_cnt->n; + pc_cnt->pc(c, pc_cnt->ctx); // sibling-call optimization +} + +#define NPF_PUTC(VAL) do { npf_putc_cnt((int)(VAL), &pc_cnt); } while (0) + +#define NPF_EXTRACT(MOD, CAST_TO, EXTRACT_AS) \ + case NPF_FMT_SPEC_LEN_MOD_##MOD: val = (CAST_TO)va_arg(args, EXTRACT_AS); break + +#define NPF_WRITEBACK(MOD, TYPE) \ + case NPF_FMT_SPEC_LEN_MOD_##MOD: *(va_arg(args, TYPE *)) = (TYPE)pc_cnt.n; break + +int npf_vpprintf(npf_putc pc, void *pc_ctx, char const *format, va_list args) { + npf_format_spec_t fs; + char const *cur = format; + npf_cnt_putc_ctx_t pc_cnt; + pc_cnt.pc = pc; + pc_cnt.ctx = pc_ctx; + pc_cnt.n = 0; + + while (*cur) { + int const fs_len = (*cur != '%') ? 0 : npf_parse_format_spec(cur, &fs); + if (!fs_len) { NPF_PUTC(*cur++); continue; } + cur += fs_len; + + // Extract star-args immediately +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + if (fs.field_width_opt == NPF_FMT_SPEC_OPT_STAR) { + fs.field_width = va_arg(args, int); + if (fs.field_width < 0) { + fs.field_width = -fs.field_width; + fs.left_justified = 1; + } + } +#endif +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + if (fs.prec_opt == NPF_FMT_SPEC_OPT_STAR) { + fs.prec = va_arg(args, int); + if (fs.prec < 0) { fs.prec_opt = NPF_FMT_SPEC_OPT_NONE; } + } +#endif + + union { char cbuf_mem[NANOPRINTF_CONVERSION_BUFFER_SIZE]; npf_uint_t binval; } u; + char *cbuf = u.cbuf_mem, sign_c = 0; + int cbuf_len = 0, need_0x = 0; +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + int field_pad = 0; + char pad_c = 0; +#endif +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + int prec_pad = 0; +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + int zero = 0; +#endif +#endif + + // Extract and convert the argument to string, point cbuf at the text. + switch (fs.conv_spec) { + case NPF_FMT_SPEC_CONV_PERCENT: + *cbuf = '%'; + cbuf_len = 1; + break; + + case NPF_FMT_SPEC_CONV_CHAR: + *cbuf = (char)va_arg(args, int); + cbuf_len = 1; + break; + + case NPF_FMT_SPEC_CONV_STRING: { + cbuf = va_arg(args, char *); +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + for (char const *s = cbuf; + ((fs.prec_opt == NPF_FMT_SPEC_OPT_NONE) || (cbuf_len < fs.prec)) && *s; + ++s, ++cbuf_len); +#else + for (char const *s = cbuf; *s; ++s, ++cbuf_len); // strlen +#endif + } break; + + case NPF_FMT_SPEC_CONV_SIGNED_INT: { + npf_int_t val = 0; + switch (fs.length_modifier) { + NPF_EXTRACT(NONE, int, int); + NPF_EXTRACT(SHORT, short, int); + NPF_EXTRACT(LONG_DOUBLE, int, int); + NPF_EXTRACT(CHAR, char, int); + NPF_EXTRACT(LONG, long, long); +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + NPF_EXTRACT(LARGE_LONG_LONG, long long, long long); + NPF_EXTRACT(LARGE_INTMAX, intmax_t, intmax_t); + NPF_EXTRACT(LARGE_SIZET, ssize_t, ssize_t); + NPF_EXTRACT(LARGE_PTRDIFFT, ptrdiff_t, ptrdiff_t); +#endif + default: break; + } + + sign_c = (val < 0) ? '-' : fs.prepend; + +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + zero = !val; +#endif + // special case, if prec and value are 0, skip + if (!val && (fs.prec_opt != NPF_FMT_SPEC_OPT_NONE) && !fs.prec) { + cbuf_len = 0; + } else +#endif + { + npf_uint_t uval = (npf_uint_t)val; + if (val < 0) { uval = 0 - uval; } + cbuf_len = npf_utoa_rev(uval, cbuf, 10, fs.case_adjust); + } + } break; + +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 + case NPF_FMT_SPEC_CONV_BINARY: +#endif + case NPF_FMT_SPEC_CONV_OCTAL: + case NPF_FMT_SPEC_CONV_HEX_INT: + case NPF_FMT_SPEC_CONV_UNSIGNED_INT: { + npf_uint_t val = 0; + + switch (fs.length_modifier) { + NPF_EXTRACT(NONE, unsigned, unsigned); + NPF_EXTRACT(SHORT, unsigned short, unsigned); + NPF_EXTRACT(LONG_DOUBLE, unsigned, unsigned); + NPF_EXTRACT(CHAR, unsigned char, unsigned); + NPF_EXTRACT(LONG, unsigned long, unsigned long); +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + NPF_EXTRACT(LARGE_LONG_LONG, unsigned long long, unsigned long long); + NPF_EXTRACT(LARGE_INTMAX, uintmax_t, uintmax_t); + NPF_EXTRACT(LARGE_SIZET, size_t, size_t); + NPF_EXTRACT(LARGE_PTRDIFFT, size_t, size_t); +#endif + default: break; + } + +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + zero = !val; +#endif + if (!val && (fs.prec_opt != NPF_FMT_SPEC_OPT_NONE) && !fs.prec) { + // Zero value and explicitly-requested zero precision means "print nothing". + if ((fs.conv_spec == NPF_FMT_SPEC_CONV_OCTAL) && fs.alt_form) { + fs.prec = 1; // octal special case, print a single '0' + } + } else +#endif +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 + if (fs.conv_spec == NPF_FMT_SPEC_CONV_BINARY) { + cbuf_len = npf_bin_len(val); u.binval = val; + } else +#endif + { + uint_fast8_t const base = (fs.conv_spec == NPF_FMT_SPEC_CONV_OCTAL) ? + 8u : ((fs.conv_spec == NPF_FMT_SPEC_CONV_HEX_INT) ? 16u : 10u); + cbuf_len = npf_utoa_rev(val, cbuf, base, fs.case_adjust); + } + + if (val && fs.alt_form && (fs.conv_spec == NPF_FMT_SPEC_CONV_OCTAL)) { + cbuf[cbuf_len++] = '0'; // OK to add leading octal '0' immediately. + } + + if (val && fs.alt_form) { // 0x or 0b but can't write it yet. + if (fs.conv_spec == NPF_FMT_SPEC_CONV_HEX_INT) { need_0x = 'X'; } +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 + else if (fs.conv_spec == NPF_FMT_SPEC_CONV_BINARY) { need_0x = 'B'; } +#endif + if (need_0x) { need_0x += fs.case_adjust; } + } + } break; + + case NPF_FMT_SPEC_CONV_POINTER: { + cbuf_len = + npf_utoa_rev((npf_uint_t)(uintptr_t)va_arg(args, void *), cbuf, 16, 'a' - 'A'); + need_0x = 'x'; + } break; + +#if NANOPRINTF_USE_WRITEBACK_FORMAT_SPECIFIERS == 1 + case NPF_FMT_SPEC_CONV_WRITEBACK: + switch (fs.length_modifier) { + NPF_WRITEBACK(NONE, int); + NPF_WRITEBACK(SHORT, short); + NPF_WRITEBACK(LONG, long); + NPF_WRITEBACK(LONG_DOUBLE, double); + NPF_WRITEBACK(CHAR, signed char); +#if NANOPRINTF_USE_LARGE_FORMAT_SPECIFIERS == 1 + NPF_WRITEBACK(LARGE_LONG_LONG, long long); + NPF_WRITEBACK(LARGE_INTMAX, intmax_t); + NPF_WRITEBACK(LARGE_SIZET, size_t); + NPF_WRITEBACK(LARGE_PTRDIFFT, ptrdiff_t); +#endif + default: break; + } break; +#endif + +#if NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1 + case NPF_FMT_SPEC_CONV_FLOAT_DEC: + case NPF_FMT_SPEC_CONV_FLOAT_SCI: + case NPF_FMT_SPEC_CONV_FLOAT_SHORTEST: + case NPF_FMT_SPEC_CONV_FLOAT_HEX: { + double val; + if (fs.length_modifier == NPF_FMT_SPEC_LEN_MOD_LONG_DOUBLE) { + val = (double)va_arg(args, long double); + } else { + val = va_arg(args, double); + } + + sign_c = (val < 0.) ? '-' : fs.prepend; +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + zero = (val == 0.); +#endif + cbuf_len = npf_ftoa_rev(cbuf, &fs, val); + } break; +#endif + default: break; + } + +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + // Compute the field width pad character + if (fs.field_width_opt != NPF_FMT_SPEC_OPT_NONE) { + if (fs.leading_zero_pad) { // '0' flag is only legal with numeric types + if ((fs.conv_spec != NPF_FMT_SPEC_CONV_STRING) && + (fs.conv_spec != NPF_FMT_SPEC_CONV_CHAR) && + (fs.conv_spec != NPF_FMT_SPEC_CONV_PERCENT)) { +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + if ((fs.prec_opt != NPF_FMT_SPEC_OPT_NONE) && !fs.prec && zero) { + pad_c = ' '; + } else +#endif + { pad_c = '0'; } + } + } else { pad_c = ' '; } + } +#endif + + // Compute the number of bytes to truncate or '0'-pad. +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + if (fs.conv_spec != NPF_FMT_SPEC_CONV_STRING) { +#if NANOPRINTF_USE_FLOAT_FORMAT_SPECIFIERS == 1 + // float precision is after the decimal point + if (fs.conv_spec != NPF_FMT_SPEC_CONV_FLOAT_DEC) +#endif + { prec_pad = npf_max(0, fs.prec - cbuf_len); } + } +#endif + +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + // Given the full converted length, how many pad bytes? + field_pad = fs.field_width - cbuf_len - !!sign_c; + if (need_0x) { field_pad -= 2; } +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + field_pad -= prec_pad; +#endif + field_pad = npf_max(0, field_pad); + + // Apply right-justified field width if requested + if (!fs.left_justified && pad_c) { // If leading zeros pad, sign goes first. + if (pad_c == '0') { + if (sign_c) { NPF_PUTC(sign_c); sign_c = 0; } + // Pad byte is '0', write '0x' before '0' pad chars. + if (need_0x) { NPF_PUTC('0'); NPF_PUTC(need_0x); } + } + while (field_pad-- > 0) { NPF_PUTC(pad_c); } + // Pad byte is ' ', write '0x' after ' ' pad chars but before number. + if ((pad_c != '0') && need_0x) { NPF_PUTC('0'); NPF_PUTC(need_0x); } + } else +#endif + { if (need_0x) { NPF_PUTC('0'); NPF_PUTC(need_0x); } } // no pad, '0x' requested. + + // Write the converted payload + if (fs.conv_spec == NPF_FMT_SPEC_CONV_STRING) { + for (int i = 0; i < cbuf_len; ++i) { NPF_PUTC(cbuf[i]); } + } else { + if (sign_c) { NPF_PUTC(sign_c); } +#if NANOPRINTF_USE_PRECISION_FORMAT_SPECIFIERS == 1 + while (prec_pad-- > 0) { NPF_PUTC('0'); } // int precision leads. +#endif +#if NANOPRINTF_USE_BINARY_FORMAT_SPECIFIERS == 1 + if (fs.conv_spec == NPF_FMT_SPEC_CONV_BINARY) { + while (cbuf_len) { NPF_PUTC('0' + ((u.binval >> --cbuf_len) & 1)); } + } else +#endif + { while (cbuf_len-- > 0) { NPF_PUTC(cbuf[cbuf_len]); } } // payload is reversed + } + +#if NANOPRINTF_USE_FIELD_WIDTH_FORMAT_SPECIFIERS == 1 + if (fs.left_justified && pad_c) { // Apply left-justified field width + while (field_pad-- > 0) { NPF_PUTC(pad_c); } + } +#endif + } + + return pc_cnt.n; +} + +#undef NPF_PUTC +#undef NPF_EXTRACT +#undef NPF_WRITEBACK + +int npf_pprintf(npf_putc pc, void *pc_ctx, char const *format, ...) { + va_list val; + va_start(val, format); + int const rv = npf_vpprintf(pc, pc_ctx, format, val); + va_end(val); + return rv; +} + +int npf_snprintf(char *buffer, size_t bufsz, const char *format, ...) { + va_list val; + va_start(val, format); + int const rv = npf_vsnprintf(buffer, bufsz, format, val); + va_end(val); + return rv; +} + +int npf_vsnprintf(char *buffer, size_t bufsz, char const *format, va_list vlist) { + npf_bufputc_ctx_t bufputc_ctx; + bufputc_ctx.dst = buffer; + bufputc_ctx.len = bufsz; + bufputc_ctx.cur = 0; + + npf_putc const pc = buffer ? npf_bufputc : npf_bufputc_nop; + int const n = npf_vpprintf(pc, &bufputc_ctx, format, vlist); + pc('\0', &bufputc_ctx); + + if (buffer && bufsz) { +#ifdef NANOPRINTF_SNPRINTF_SAFE_EMPTY_STRING_ON_OVERFLOW + if (n >= (int)bufsz) { buffer[0] = '\0'; } +#else + buffer[bufsz - 1] = '\0'; +#endif + } + + return n; +} + +#if NANOPRINTF_HAVE_GCC_WARNING_PRAGMAS + #pragma GCC diagnostic pop +#endif + +#ifdef _MSC_VER + #pragma warning(pop) +#endif + +#endif // NANOPRINTF_IMPLEMENTATION_INCLUDED +#endif // NANOPRINTF_IMPLEMENTATION + +/* + nanoprintf is dual-licensed under both the "Unlicense" and the + "Zero-Clause BSD" (0BSD) licenses. The intent of this dual-licensing + structure is to make nanoprintf as consumable as possible in as many + environments / countries / companies as possible without any + encumberances. + + The text of the two licenses follows below: + + ============================== UNLICENSE ============================== + + This is free and unencumbered software released into the public domain. + + Anyone is free to copy, modify, publish, use, compile, sell, or + distribute this software, either in source code form or as a compiled + binary, for any purpose, commercial or non-commercial, and by any + means. + + In jurisdictions that recognize copyright laws, the author or authors + of this software dedicate any and all copyright interest in the + software to the public domain. We make this dedication for the benefit + of the public at large and to the detriment of our heirs and + successors. We intend this dedication to be an overt act of + relinquishment in perpetuity of all present and future rights to this + software under copyright law. + + 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 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. + + For more information, please refer to + + ================================ 0BSD ================================= + + Copyright (C) 2019- by Charles Nicholson + + Permission to use, copy, modify, and/or distribute this software for + any purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ diff --git a/libnotcc/wasm-std/src/std.c b/libnotcc/wasm-std/src/std.c new file mode 100644 index 00000000..31a7f8fd --- /dev/null +++ b/libnotcc/wasm-std/src/std.c @@ -0,0 +1,196 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +extern unsigned long __builtin_wasm_memory_grow(int mem_idx, + unsigned long delta); +extern unsigned long __builtin_wasm_memory_size(int mem_idx); +[[noreturn]] extern void __builtin_trap(); + +void _wasmstd_assert(_Bool assertion) { + if (!assertion) + __builtin_trap(); +} +[[noreturn]] void abort() { + __builtin_trap(); +} + +extern size_t __heap_base; + +static uintptr_t sbrk_end = (uintptr_t)&__heap_base; + +#define SBRK_ALIGNMENT (__alignof__(max_align_t)) +typedef long int ssize_t; + +// `resize_heap` and `sbrk` partially adapted from Emscripten +#define WASM_PAGE_SIZE 65536 + +static int resize_heap(size_t size) { + size_t old_size = __builtin_wasm_memory_size(0) * WASM_PAGE_SIZE; + if (size < old_size) + return 1; + ssize_t diff = (size - old_size + WASM_PAGE_SIZE - 1) / WASM_PAGE_SIZE; + size_t result = __builtin_wasm_memory_grow(0, diff); + // Its seems v8 has a bug in memory.grow that causes it to return + // (uint32_t)-1 even with memory64: + // https://bugs.chromium.org/p/v8/issues/detail?id=13948 + if (result != (uint32_t)-1 && result != (size_t)-1) { + return 1; + } + return 0; +} + +void* sbrk(intptr_t increment_) { + uintptr_t increment = (uintptr_t)increment_; + increment = (increment + (SBRK_ALIGNMENT - 1)) & ~(SBRK_ALIGNMENT - 1); + int res = resize_heap(sbrk_end + increment); + if (!res) + return (void*)-1; + void* old_sbrk_end = (void*)sbrk_end; + sbrk_end += increment; + return old_sbrk_end; +} + +void* memset(void* dest, int ch, size_t count) { + for (; count > 0; count -= 1) { + ((unsigned char*)dest)[count - 1] = (unsigned char)ch; + } + return dest; +} +void* memcpy(void* __restrict dest, const void* __restrict src, size_t count) { + for (; count > 0; count -= 1) { + ((unsigned char*)dest)[count - 1] = ((unsigned char*)src)[count - 1]; + } + return dest; +} + +size_t strlen(const char* str) { + size_t len = 0; + while (str[len] != 0) + len += 1; + return len; +} +size_t strnlen(const char* str, size_t max_size) { + size_t len = 0; + while (len < max_size && str[len] != 0) + len += 1; + return len; +} + +char* strdup(const char* str) { + size_t len = strlen(str) + 1; + char* new_str = malloc(len * sizeof(char)); + memcpy(new_str, str, len); + return new_str; +}; + +void* memmove(void* dest, const void* src, size_t count) { + if (src < dest) + return memcpy(dest, src, count); + for (; count > 0; count -= 1) { + *(char*)dest++ = *(char*)src++; + }; + + return dest; +} + +int isspace(int c) { + return c == 0x20 || (c >= 0x09 && c <= 0xd); +} + +long atol(const char* str) { + // 1. Discard whitespace + while (isspace(*str)) + str += 1; + // 2. Consume sign + long sign = +1; + if (*str == '+') { + str += 1; + } else if (*str == '-') { + str += 1; + sign = -1; + } + // 3. Consume numbers + long val = 0; + while (1) { + char ch = *str; + if (!(ch >= '0' && ch <= '9')) + break; + val *= 10; + val += ch - '0'; + str += 1; + } + return val * sign; +} + +int memcmp(const void* rptr1, const void* rptr2, size_t num) { + const uint8_t* ptr1 = rptr1; + const uint8_t* ptr2 = rptr2; + while (num > 0) { + if (*ptr1 > *ptr2) + return 1; + else if (*ptr1 < *ptr2) + return -1; + num -= 1; + ptr1 += 1; + ptr2 += 1; + } + return 0; +} + +float fabsf(float v) { + if (v < 0.) + return -v; + return v; +} + +static inline void qsort_merge_arrs(void* to, + const void* from, + size_t left_idx, + size_t right_idx, + size_t right_end_idx, + size_t size, + int (*comp)(const void*, const void*)) { + const size_t left_end_idx = right_idx; + for (size_t to_idx = left_idx; to_idx < right_end_idx; to_idx += 1) { + const void* left_item = &from[left_idx * size]; + const void* right_item = &from[right_idx * size]; + void* to_item = &to[to_idx * size]; + if (left_idx == left_end_idx || + (right_idx != right_end_idx && comp(left_item, right_item) > 0)) { + memcpy(to_item, right_item, size); + right_idx += 1; + } else { + memcpy(to_item, left_item, size); + left_idx += 1; + } + } +} +// Lol +#define min(a, b) ((a) < (b) ? (a) : (b)) +// Merge sort +void qsort(void* ptr, + size_t count, + size_t size, + int (*comp)(const void*, const void*)) { + char temp_arr[count * size]; + void* from_arr = ptr; + void* to_arr = temp_arr; + for (size_t width = 1; width < count; width *= 2) { + for (size_t pos = 0; pos < count; pos += 2 * width) { + qsort_merge_arrs(to_arr, from_arr, pos, min(pos + width, count), + min(pos + width * 2, count), size, comp); + } + void* tmp_ptr = from_arr; + from_arr = to_arr; + to_arr = tmp_ptr; + } + if (from_arr != ptr) { + memcpy(ptr, from_arr, count * size); + } +} diff --git a/libnotcc/wasm32.cmake b/libnotcc/wasm32.cmake new file mode 100644 index 00000000..f5853994 --- /dev/null +++ b/libnotcc/wasm32.cmake @@ -0,0 +1,18 @@ +# the name of the target operating system +set(CMAKE_SYSTEM_NAME WebAssembly) + +if (CMAKE_TOOLCHAIN_FILE) +endif() + +# which compilers to use for C and C++ +set(CMAKE_C_COMPILER clang -target wasm32 -nostdlib --sysroot=${CMAKE_CURRENT_LIST_DIR}/wasm-std) +add_link_options(-fuse-ld=${CMAKE_CURRENT_LIST_DIR}/wasm-remove-shared-ld.sh) +add_link_options(-Wl,--no-entry,--export-all) + +# adjust the default behavior of the FIND_XXX() commands: +# search programs in the host environment +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NONE) + +# search headers and libraries in the target environment +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) diff --git a/linearizeCheck.mjs b/linearizeCheck.mjs new file mode 100755 index 00000000..3c11de07 --- /dev/null +++ b/linearizeCheck.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env zx + +import "zx/globals" +import { initWasm, makeLinearLevels } from "./libnotcc-bind/dist/index.js" + +const setsDir = argv["sets"] + +if (!setsDir) { + console.error("Must supply --sets!") + process.exit(1) +} + +await initWasm() + +for (const setEnt of await fs.readdir(setsDir)) { + if (setEnt === "." || setEnt === "..") continue + const setDir = path.join(setsDir, setEnt) + if (!(await fs.stat(setDir)).isDirectory()) continue + const allFiles = await fs.readdir(setDir, { recursive: true }) + async function loaderFunction(initPath, binary) { + const truePath = allFiles.find( + pth => pth.toLowerCase() == initPath.toLowerCase() + ) + return await fs.readFile( + path.join(setDir, path.normalize("/" + truePath)), + binary ? null : "utf-8" + ) + } + for (const setFile of await fs.readdir(setDir)) { + if (!setFile.endsWith(".c2g")) continue + const linearLevels = await makeLinearLevels({ + scriptFile: setFile, + loaderFunction, + }) + if (linearLevels === null) { + console.log(`Failed to linearize ${setEnt}/${setFile}`) + } else { + // console.log( + // `Linearized ${setEnt}/${setFile} with ${linearLevels.length} levels` + // ) + } + } +} diff --git a/logic/README.md b/logic/README.md deleted file mode 100644 index 552cb777..00000000 --- a/logic/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# NotCC logic - -This package contains all the game logic for NotCC, a Chip's Challenge 2® emulator. - -## Legal notice - -Chip's Challenge is a registered trademark of Bridgestone Media Group LLC, used here for identification purposes only. Not affiliated with, sponsored, or endorsed by Bridgestone Media Group LLC. - -## Example - -```ts -// Verify a level's built-in solution - -import { parseC2M, createLevelFromData, GameState } from "@notcc/logic" -import { readFileSync } from "fs" - -const levelData = parseC2M(readFileSync("./funLevel.c2m").buffer) - -const level = createLevelFromData(levelData) - -level.playbackSolution(levelData.associatedSolution) - -let bonusTicks = 60 * 60 - -while (level.gameState === GameState.PLAYING && bonusTicks > 0) { - level.tick() - if (level.solutionSubticksLeft === Infinity) { - bonusTicks -= 1 - } -} - -console.log(`Level built-in solution result: ${GameState[level.gameState]}`) -``` diff --git a/logic/src/actor.ts b/logic/src/actor.ts deleted file mode 100644 index e9b2fd93..00000000 --- a/logic/src/actor.ts +++ /dev/null @@ -1,773 +0,0 @@ -import { LevelState } from "./level.js" -import { - Decision, - actorDB, - hasTag, - makeTagFlagField, - hasTagOverlap, -} from "./const.js" -import { Direction, hasOwnProperty } from "./helpers.js" -import { Layer, Tile } from "./tile.js" -import { Item, Key } from "./actors/items.js" -import { CircuitCity, isWired, Wirable, WireOverlapMode } from "./wires.js" -import { iterableIndexOf } from "./iterableHelpers.js" - -/** - * Current state of sliding, playables can escape weak sliding. - */ -export enum SlidingState { - NONE, - // Force floors - WEAK, - // Ice - STRONG, -} - -/** - * Checks if a tag collections matches a tag rule of another tag collection. - * @param actorTags The tag collection the actor has, they are being tested against the rules, cannot have tags which start with "!" - * @param ruleTags The rules which the actor tags are tested for. A rule is considered fulfilled if - * - * a. The rule tag does not start with "!" and is present in the actor tag collection - * - * b. The rule tag starts with "!", and the actor tag collection does not contain the rule tag (with the "!" removed) - * @example ```js - * matchTags(["foo", "bar"], ["bar", "baz"]) // true - * matchTags(["foo", "baz"], ["!foo"]) // false - * matchTags(["foo", "bar"], ["!foo", "bar"]) // true - * ``` - */ -export function matchTags(actorTags: string[], ruleTags: string[]): boolean { - return !!ruleTags.find(val => - val.startsWith("!") - ? !actorTags.includes(val.substr(1)) - : actorTags.includes(val) - ) -} - -export interface Inventory { - keys: Record - items: Item[] - // The max amount of items the actor can carry - itemMax: number -} - -export const tagProperties = [ - "pushTags", - "tags", - "blockTags", - "blockedByTags", - "collisionIgnoreTags", - "ignoreTags", - "immuneTags", -] as const - -export abstract class Actor implements Wirable { - moveDecision = Decision.NONE - currentMoveSpeed: number | null = null - oldTile: Tile | null = null - cooldown = 0 - pendingDecision = Decision.NONE - pendingDecisionLockedIn = false - slidingState = SlidingState.NONE - abstract getLayer(): Layer - layer: Layer - abstract id: string - despawned = false - exists = true - isDeciding = false - isPulled = false - createdN: number - newActor?: Actor - /** - * Tags which the actor can push, provided the pushed actor can be pushed - */ - pushTags = BigInt(0) - /** - * General-use tags to use, for example, for collisions - */ - tags = BigInt(0) - hasTag(tag: string) { - return hasTag(this, tag) - } - /** - * Tags which this actor blocks - */ - blockTags = BigInt(0) - /** - * Tags which this actor is blocked by - */ - blockedByTags = BigInt(0) - - /** - * Tags which this actor refuses to be blocked by - */ - collisionIgnoreTags = BigInt(0) - /** - * Tags which this actor will not conduct any interactions with. - */ - ignoreTags = BigInt(0) - /** - * Tags which this actor should not be destroyed by - */ - immuneTags = BigInt(0) - - calcTag(prop: string, items: boolean) { - let initTags = - (Object.getPrototypeOf(this).constructor[prop] as bigint) ?? BigInt(0) - if (items) { - initTags |= this.calcItemTags(prop) - } - return initTags - } - calcItemTags(prop: string) { - let tags = BigInt(0) - for (const item of this.inventory.items) { - const carrierTags = item.carrierTags?.[prop] - tags |= carrierTags ?? BigInt(0) - } - return tags - } - recomputeTags(items = true) { - for (const prop of tagProperties) { - this[prop] = this.calcTag(prop, items) - } - } - ignores?(other: Actor): boolean - _internalIgnores(other: Actor): boolean { - return ( - (hasTagOverlap(this.tags, other.ignoreTags) || - hasTagOverlap(other.tags, this.ignoreTags) || - this.ignores?.(other) || - other.ignores?.(this)) ?? - false - ) - } - collisionIgnores?(other: Actor, enterDirection: Direction): boolean - /** - * Amount of ticks it takes for the actor to move - */ - moveSpeed = 4 - tile: Tile - inventory: Inventory = { - items: [], - keys: {}, - itemMax: 4, - } - dropItemN(id: number, noSideEffect = false): boolean { - if (this.level.cc1Boots) return false - const itemToDrop = this.inventory.items[id] - if (!itemToDrop) return false - if (this.despawned) { - console.warn("Dropping items while despawned in undefined behaviour.") - } - if (this.tile[itemToDrop.layer]) return false - if (itemToDrop.canBeDropped && !itemToDrop.canBeDropped(this)) return false - this.inventory.items.splice(id, 1) - itemToDrop.oldTile = null - itemToDrop.tile = this.tile - this.level.actors.push(itemToDrop) - itemToDrop._internalUpdateTileStates() - if (!noSideEffect) itemToDrop.onDrop?.(this) - itemToDrop.exists = true - if (itemToDrop.carrierTags) { - for (const prop in itemToDrop.carrierTags) { - this[prop as "tags"] = this.calcTag(prop, true) - } - } - return true - } - dropItem(): boolean { - return this.dropItemN(this.inventory.items.length - 1) - } - constructor( - public level: LevelState, - position: [number, number], - public customData = "", - public direction: Direction = Direction.UP - ) { - this.layer = this.getLayer() - for (const tagProp of tagProperties.concat( - (new.target as any).extraTagProperties ?? [] - )) { - this[tagProp] = (new.target as any)[tagProp] ?? BigInt(0) - } - level.actors.unshift(this) - this.tile = level.field[position[0]][position[1]] - this.tile.addActors(this) - this.isDeciding = !!( - this.layer === Layer.MOVABLE || - this.onEachDecision || - this.decideMovement - ) - if (this.isDeciding) level.decidingActors.unshift(this) - this.createdN = this.level.createdN++ - if (this.level.levelStarted) { - this.onCreation?.() - this.wired = isWired(this) - } - } - /** - * Decides the movements the actor will attempt to do - * Must return an array of absolute directions - */ - decideMovement?(): Direction[] - onEachDecision?(forcedOnly: boolean): void - _internalDecide(forcedOnly = false): void { - if (!this.exists) return - this.bonked = false - this.moveDecision = Decision.NONE - - if (this.cooldown || this.frozen) return // This is where the decision *actually* begins - this.currentMoveSpeed = null - this.isPushing = false - if (this.pendingDecision) { - this.moveDecision = this.pendingDecision - this.pendingDecision = Decision.NONE - this.pendingDecisionLockedIn = true - return - } - // This is where the decision *actually* begins // Since this is a generic actor, we cannot override weak sliding - // TODO Ghost ice shenanigans - if (this.slidingState) { - this.moveDecision = this.direction + 1 - return - } - this.onEachDecision?.(forcedOnly) - if (forcedOnly) return - const directions = this.decideMovement?.() - - if (!directions || directions.length === 0) return - - for (const direction of directions) - if (this.checkCollision(direction)) { - // Yeah, we can go here - this.moveDecision = direction + 1 - return - } - - // Force last decision if all other fail - - this.moveDecision = directions[directions.length - 1] + 1 - } - selfSpeedMod(mult: number): number { - for (const item of this.inventory.items) { - if (item.carrierSpeedMod) mult *= item.carrierSpeedMod(this, mult) - } - return mult - } - _internalStep(direction: Direction): boolean { - if (this.cooldown || !this.moveSpeed) return false - this.direction = direction - const canMove = this.checkCollision(direction) - this.bonked = !canMove - this.direction = this.level.resolvedCollisionCheckDirection - // Welp, something stole our spot, too bad - if (!canMove) return false - if (!this.isDeciding) this.level.decidingActors.push(this) - this.isDeciding = true - const newTile = this.tile.getNeighbor( - this.level.resolvedCollisionCheckDirection, - false - ) - this.pendingDecision = Decision.NONE - this.moveDecision = Decision.NONE - // This is purely a defensive programming thing, shouldn't happen normally (checkCollision should check for going OOB) - if (!newTile) return false - let speedMult = 1 - speedMult = newTile.getSpeedMod(this) - speedMult = this.selfSpeedMod(speedMult) - const moveLength = (this.moveSpeed * 3) / speedMult - this.currentMoveSpeed = this.cooldown = moveLength - this.oldTile = this.tile - this.tile = newTile - // Finally, move ourselves to the new tile - this._internalUpdateTileStates() - return true - } - _internalMove(): void { - if (!this.exists) return - if (this.cooldown > 0) { - this.isPulled = false - this.moveDecision = Decision.NONE - return - } - - if (!this.moveDecision) { - this.isPulled = false - return - } - this.pendingDecision = Decision.NONE - this.pendingDecisionLockedIn = false - const ogDirection = this.moveDecision - 1 - const success = this._internalStep(ogDirection) - - this.isPulled = false - } - /** - * True if the last move failed - */ - bonked = false - blocks?(other: Actor, otherMoveDirection: Direction): boolean - blockedBy?(other: Actor, thisMoveDirection: Direction): boolean - /** - * Called when another actor leaves the current tile - */ - actorLeft?(other: Actor): void - actorCompletelyLeft?(other: Actor): void - /** - * Called when another actor joins the current tile - */ - actorJoined?(other: Actor): void - /** - * Called when another actor stops moving after joining a tile - */ - actorCompletelyJoined?(other: Actor): void - actorCompletelyJoinedIgnored?(other: Actor): void - /** - * Called when this actor steps on a new tile - */ - newTileJoined?(): void - /** - * Called when this actor stops walking to a new tile - */ - newTileCompletelyJoined?(): void - - shouldDie?(killer: Actor): boolean - - _internalShouldDie(killer: Actor): boolean { - return !( - this._internalIgnores(killer) || - hasTagOverlap(killer.tags, this.immuneTags) || - (this.shouldDie && !this.shouldDie(killer)) - ) - } - _internalCollisionIgnores(other: Actor, direction: Direction): boolean { - return !!( - hasTagOverlap(this.tags, other.collisionIgnoreTags) || - other.collisionIgnores?.(this, direction) - ) - } - _internalBlocks(other: Actor, moveDirection: Direction): boolean { - return ( - // A hack for teleports, but it's not that dumb of a limitation, so maybe it's fine? - other !== this && - // FIXME A hack for blocks, really shouldn't be a forced limitation - (!!(this.cooldown && this.moveSpeed) || - (!this._internalCollisionIgnores(other, moveDirection) && - (this.blocks?.(other, moveDirection) || - other.blockedBy?.(this, moveDirection) || - hasTagOverlap(other.tags, this.blockTags) || - hasTagOverlap(this.tags, other.blockedByTags)))) - ) - } - enterTile(noOnTile = false): void { - let thisActor: Actor = this - if (thisActor.oldTile) { - for (const actor of thisActor.oldTile.allActorsReverse) { - if (!thisActor._internalIgnores(actor)) { - actor.actorCompletelyLeft?.(thisActor) - if (thisActor.newActor) thisActor = thisActor.newActor - } - } - } - for (const actor of thisActor.tile.allActorsReverse) { - if (actor === thisActor) continue - const notIgnores = !thisActor._internalIgnores(actor) - if (notIgnores) actor.actorCompletelyJoined?.(thisActor) - else actor.actorCompletelyJoinedIgnored?.(thisActor) - if (thisActor.newActor) thisActor = thisActor.newActor - if (!noOnTile && actor.actorOnTile) { - actor.actorOnTile(thisActor) - } - if (thisActor.newActor) thisActor = thisActor.newActor - } - if (this.exists) { - this.newTileCompletelyJoined?.() - for (const item of this.inventory.items) - item.onCarrierCompleteJoin?.(this) - } - this.cooldown = 0 - } - _internalDoCooldown(): void { - if (!this.exists) return - if (this.cooldown > 0 && this.cooldown <= 1) { - if (this.pendingDecision) this.pendingDecisionLockedIn = true - this.enterTile() - } else if (this.cooldown > 0) this.cooldown-- - else if (this.exists) { - let thisActor: Actor = this - for (const actor of [...this.tile.allActors]) - if (actor !== thisActor && actor.actorOnTile) { - actor.actorOnTile(thisActor) - if (thisActor.newActor) thisActor = thisActor.newActor - } - } - this.bonked = false - } - isPushing = false - /** - * Checks if a specific actor can move in a certain direction - * @param actor The actor to check for - * @param direction The direction the actor wants to move in - * @param pushBlocks If true, it will push blocks - * @returns If the actor *can* move in that direction - */ - checkCollision( - direction: Direction, - redirectOnly = false, - pull = true - ): boolean { - return this.checkCollisionFromTile(this.tile, direction, redirectOnly, pull) - } - /** - * Checks if a specific actor can move in a certain direction to a certain tile - * @param this The actor to check for - * @param direction The direction the actor wants to enter the tile - * @param fromTile The tile the actor is coming from - * @param pushBlocks If true, it will push blocks - * @returns If the actor *can* move in that direction - */ - checkCollisionFromTile( - this: Actor, - fromTile: Tile, - direction: Direction, - redirectOnly = false, - pull = true - ): boolean { - // This is a pass by reference-esque thing, please don't die of cring - this.level.resolvedCollisionCheckDirection = direction - - // Do stuff on the leaving tile - - for (const exitActor of fromTile.allActorsReverse) - if (!redirectOnly && exitActor._internalExitBlocks(this, direction)) { - exitActor.bumped?.(this, direction) - this.bumpedActor?.(exitActor, direction, true) - return false - } else { - if ( - !exitActor.redirectTileMemberDirection || - this._internalIgnores(exitActor) - ) - continue - const redirection = exitActor.redirectTileMemberDirection( - this, - direction - ) - if (redirection === null) return false - this.onRedirect?.((redirection - direction + 4) % 4) - this.level.resolvedCollisionCheckDirection = direction = redirection - } - if (redirectOnly) return true - const newTile = fromTile.getNeighbor(direction, false) - if (newTile === null) { - this.bumpedEdge?.(fromTile, direction) - return false - } - - const toPush: Actor[] = [] - - // Do stuff on the entering tile - loop: for (const layer of [ - Layer.ITEM_SUFFIX, - Layer.SPECIAL, - Layer.STATIONARY, - Layer.MOVABLE, - Layer.ITEM, - ]) { - let blockActor = newTile[layer] - if (!blockActor) continue - for (const item of this.inventory.items) { - item.onCarrierBump?.(this, blockActor, direction) - if (blockActor.newActor) blockActor = blockActor.newActor - } - blockActor.bumped?.(this, direction) - this.bumpedActor?.(blockActor, direction, false) - if (blockActor._internalBlocks(this, direction)) - if (this._internalCanPush(blockActor, direction)) - toPush.push(blockActor) - else { - this.level.resolvedCollisionCheckDirection = direction - return false - } - if (layer === Layer.MOVABLE) - // This is dumb - break loop - } - - for (const pushable of toPush) { - this.level.resolvedCollisionCheckDirection = direction - if (pushable.slidingState) { - // Blocks with no cooldown can't have their pending decision be overriden - if (!pushable.pendingDecisionLockedIn) { - pushable.pendingDecision = pushable.moveDecision = direction + 1 - } - return false - } - if (pushable.cooldown || !pushable.checkCollision(direction)) { - this.level.resolvedCollisionCheckDirection = direction - return false - } - if (pushable._internalStep(direction)) { - pushable.cooldown-- - if (this.hasTag("plays-block-push-sfx")) - this.level.sfxManager?.playOnce("block push") - } - } - this.level.resolvedCollisionCheckDirection = direction - if (toPush.length !== 0) this.isPushing = true - if (pull && this.hasTag("pulling")) { - const backTile = this.tile.getNeighbor((direction + 2) % 4) - if (!backTile) return true - const pulledActor = backTile[Layer.MOVABLE] - if (pulledActor) { - if (pulledActor.cooldown && pulledActor.moveSpeed) return false - - if ( - (pulledActor.pendingDecisionLockedIn && pulledActor.isPulled) || - !pulledActor.hasTag("block") || - (pulledActor.canBePushed && !pulledActor.canBePushed(this, direction)) - ) { - pulledActor.isPulled = true - return true - } - pulledActor.isPulled = true - pulledActor.direction = direction - if (pulledActor.frozen) return true - pulledActor.pendingDecision = pulledActor.moveDecision = direction + 1 - } - } - return true - } - /** - * Updates tile states and calls hooks - */ - _internalUpdateTileStates(noTileRemove?: boolean): void { - this.respawn(false) - if (!noTileRemove) { - this.oldTile?.removeActors(this) - this.slidingState = SlidingState.NONE - // Spread from and to to not have actors which create new actors instantly be interacted with - if (this.oldTile) - for (const actor of [...this.oldTile.allActorsReverse]) - if (!this._internalIgnores(actor)) actor.actorLeft?.(this) - } - - this.tile.addActors(this) - - for (const actor of [...this.tile.allActorsReverse]) - if (actor !== this && !this._internalIgnores(actor)) - actor.actorJoined?.(this) - this.newTileJoined?.() - for (const item of this.inventory.items) item.onCarrierJoin?.(this) - } - respawn(putOnTile = true): void { - if (!this.despawned) return - this.despawned = false - if (this.level.despawnedActors.includes(this)) - this.level.despawnedActors.splice( - this.level.despawnedActors.indexOf(this), - 1 - ) - if (putOnTile) { - this.tile.addActors(this) - } - } - despawn(): void { - if (this.despawned) return - this.level.despawnedActors.push(this) - this.despawned = true - this.tile.removeActors(this) - } - destroy( - killer?: Actor | null, - animType: string | null = "explosion" - ): boolean { - if (killer && !this._internalShouldDie(killer)) return false - if (this.level.actors.includes(this)) - this.level.actors.splice(this.level.actors.indexOf(this), 1) - const decidingPos = this.level.decidingActors.indexOf(this) - if (this.level.decidingActors.includes(this)) - this.level.decidingActors.splice( - this.level.decidingActors.indexOf(this), - 1 - ) - if (this.despawned) { - if (this.level.despawnedActors.includes(this)) - this.level.despawnedActors.splice( - this.level.despawnedActors.indexOf(this), - 1 - ) - } - if (this.level.circuitInputs.includes(this)) { - this.level.circuitInputs.splice(this.level.circuitInputs.indexOf(this), 1) - } - if (this.level.circuitOutputs.includes(this)) { - this.level.circuitOutputs.splice( - this.level.circuitOutputs.indexOf(this), - 1 - ) - } - this.tile.removeActors(this) - this.exists = false - if (animType && actorDB[`${animType}Anim`] && !this.tile[Layer.MOVABLE]) { - const anim = new actorDB[`${animType}Anim`]( - this.level, - this.tile.position - ) - if (decidingPos !== -1) { - this.level.decidingActors.splice( - this.level.decidingActors.indexOf(anim), - 1 - ) - this.level.decidingActors.splice(decidingPos, 0, anim) - } - anim.direction = this.direction - anim.currentMoveSpeed = this.currentMoveSpeed - anim.cooldown = this.cooldown - anim.inventory = this.inventory - this.newActor = anim - } - for (const thing of this.tile.allActors) { - thing.actorDestroyed?.(this) - } - for (const item of this.inventory.items) { - item.onCarrierDestroyed?.(this) - } - return true - } - getVisualPosition(): [number, number] { - if (!this.cooldown || !this.currentMoveSpeed || !this.oldTile) - return this.tile.position - const progress = 1 - this.cooldown / this.currentMoveSpeed - return [ - this.oldTile.x * (1 - progress) + this.tile.x * progress, - this.oldTile.y * (1 - progress) + this.tile.y * progress, - ] - } - actorDestroyed?(actor: Actor): void - /** - * Called when another actor bumps into this actor - * @param other The actor which bumped into this actor - */ - bumped?(other: Actor, bumpDirection: Direction): void - /** - * Called when this actor bumps into another actor - * @param other The actor which this actor bumped into - */ - bumpedActor?(other: Actor, bumpDirection: Direction, onExit: boolean): void - _internalCanPush(other: Actor, direction: Direction): boolean { - //if (other.pendingDecision) return false - if ( - !hasTagOverlap(other.tags, this.pushTags) || - !(other.canBePushed?.(this, direction) ?? true) - ) - return false - if (other.pendingDecisionLockedIn) return false - return other.checkCollision(direction, true) - } - /** - * Called when a new actor enters the tile, must return the number to divide the speed by - */ - speedMod?(other: Actor): number - /** - * Called when another actor tries to exit the tile this actor is on - * @param other The actor which tried to exit - * @param exitDirection The direction the actor is trying to exit in - */ - exitBlocks?(other: Actor, exitDirection: Direction): boolean - _internalExitBlocks(other: Actor, exitDirection: Direction): boolean { - return ( - (!other.collisionIgnores?.(this, exitDirection) && - !hasTagOverlap(this.tags, other.collisionIgnoreTags) && - this.exitBlocks?.(other, exitDirection)) ?? - false - ) - } - /** - * The colors of buttons this actor cares about - */ - caresButtonColors: string[] = [] - /** - * Called when a button is pressed, called only when the button applies to the actor - * @param type The string color name of the button - * @param data Custom data the button sent - */ - buttonPressed?(type: string, data?: string): void - /** - * Called when a button is released, called only when the button applies to the actor - * @param type The string color name of the button - * @param data Custom data the button sent - */ - buttonUnpressed?(type: string, data?: string): void - /** - * Called when the level starts - */ - levelStarted?(): void - /** - * Called each subtick if anything is on this (called at cooldown time (move time)) - */ - actorOnTile?(other: Actor): void - replaceWith(other: (typeof actorDB)[string], customData?: string): Actor { - const decidingPos = this.level.decidingActors.indexOf(this) - this.destroy(null, null) - const newActor = new other( - this.level, - this.tile.position, - customData ?? this.customData - ) - newActor.direction = this.direction - newActor.inventory = this.inventory - if (newActor.isDeciding && decidingPos !== -1) { - this.level.decidingActors.splice( - this.level.decidingActors.indexOf(newActor), - 1 - ) - this.level.decidingActors.splice(decidingPos, 0, newActor) - } - this.newActor = newActor - newActor.recomputeTags() - return newActor - } - /** - * Checks if an actor can push this actor - */ - canBePushed?(other: Actor, direction: Direction): boolean - /** - * When an actor tries to check anything direction related while being on this actor, the direction can be changed with this - * Returning null is the same as exit-blocking on all sides - */ - redirectTileMemberDirection?( - other: Actor, - direction: Direction - ): Direction | null - bumpedEdge?(fromTile: Tile, direction: Direction): void - wires = 0 - /** - * The currently powered wires, either by it's own abilities of via neighbors - */ - poweredWires = 0 - /** - * The wires this actor is powering itself - */ - poweringWires = 0 - wireTunnels = 0 - circuits?: [CircuitCity?, CircuitCity?, CircuitCity?, CircuitCity?] - wireOverlapMode: WireOverlapMode = WireOverlapMode.NONE - /** - * Called at the start of wire phase, usually used to update powered wires. - */ - updateWires?(): void - pulse?(actual: boolean): void - unpulse?(): void - listensWires?: boolean - onCreation?(): void - providesPower?: boolean - wired = false - onRedirect?(delta: number): void - /** - * If true, the actor can't move at all - */ - frozen = false -} diff --git a/logic/src/actors/animation.ts b/logic/src/actors/animation.ts deleted file mode 100644 index 621387d1..00000000 --- a/logic/src/actors/animation.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Actor, SlidingState } from "../actor.js" -import { Layer } from "../tile.js" -import { actorDB, Decision } from "../const.js" -import { LevelState } from "../level.js" -import { Direction } from "../helpers.js" - -export abstract class Animation extends Actor { - animationCooldown = 16 - animationLength = 16 - moveSpeed = 0 - static blockTags = ["real-playable"] - abstract getSfx(): string - getLayer(): Layer { - return Layer.MOVABLE - } - constructor( - level: LevelState, - position: [number, number], - customData = "", - direction?: Direction - ) { - super(level, position, customData, direction) - if (customData === "extended") { - this.animationCooldown += 3 - this.animationLength += 3 - this.animationCooldown-- - } - level.sfxManager?.playOnce(this.getSfx()) - } - _internalDecide(): void { - this.pendingDecision = this.moveDecision = Decision.NONE - this.slidingState = SlidingState.NONE - this.pendingDecisionLockedIn = false - this.animationCooldown-- - if (!this.animationCooldown) this.destroy(null, null) - } - // eslint-disable-next-line @typescript-eslint/no-empty-function - _internalDoCooldown(): void {} - bumped(other: Actor, moveDirection: number): void { - if ( - this._internalBlocks(other, moveDirection) || - other instanceof Animation - ) - return - this.destroy(null, null) - } -} - -export class Explosion extends Animation { - id = "explosionAnim" - getSfx(): string { - return "explosion" - } -} - -actorDB["explosionAnim"] = Explosion - -export class Splash extends Animation { - id = "splashAnim" - getSfx(): string { - return "splash" - } -} - -actorDB["splashAnim"] = Splash diff --git a/logic/src/actors/blocks.ts b/logic/src/actors/blocks.ts deleted file mode 100644 index 49d4eee4..00000000 --- a/logic/src/actors/blocks.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { Actor } from "../actor.js" -import { Layer } from "../tile.js" -import { actorDB } from "../const.js" -import { Water, Dirt, Ice } from "./terrain.js" -import { Direction } from "../helpers.js" - -export class DirtBlock extends Actor { - id = "dirtBlock" - transmogrifierTarget = "iceBlock" - static tags = ["block", "cc1block", "movable", "reverse-on-railroad"] - static ignoreTags = ["fire"] - static immuneTags = ["water"] - getLayer(): Layer { - return Layer.MOVABLE - } - blocks(): boolean { - return true - } - bumpedActor(other: Actor): void { - if ( - other.hasTag("real-playable") && - !this.hasTag("ignore-default-monster-kill") && - !other.hasTag("ignore-default-monster-kill") - ) - other.destroy(this) - } - newTileCompletelyJoined(): void { - const water = this.tile[Layer.STATIONARY] - if (!water?.hasTag("water")) return - - if (!water._internalIgnores(this)) { - this.destroy(this, "splash") - water.destroy(null, null) - new Dirt(this.level, this.tile.position) - } - } -} - -actorDB["dirtBlock"] = DirtBlock - -export class IceBlock extends Actor { - id = "iceBlock" - transmogrifierTarget = "dirtBlock" - static pushTags = ["cc2block"] - static tags = [ - "block", - "cc2block", - "movable", - "can-stand-on-items", - "meltable-block", - "reverse-on-railroad", - ] - static immuneTags = ["water"] - getLayer(): Layer { - return Layer.MOVABLE - } - blocks(): boolean { - return true - } - bumpedActor(other: Actor): void { - if ( - other.hasTag("real-playable") && - !this.hasTag("ignore-default-monster-kill") && - !other.hasTag("ignore-default-monster-kill") - ) - other.destroy(this) - } - newTileCompletelyJoined(): void { - const terrain = this.tile[Layer.STATIONARY] - if (terrain?.hasTag("water") && !terrain._internalIgnores(this)) { - this.destroy(this, "splash") - terrain.destroy(null, null) - new Ice(this.level, this.tile.position) - } - if (terrain?.hasTag("melting") && !terrain._internalIgnores(this)) { - this.destroy(this, "splash") - terrain.destroy(null, null) - new Water(this.level, this.tile.position) - } - } - bumped(other: Actor): void { - if ( - other.hasTag("melting") && - (!this.tile.hasLayer(Layer.STATIONARY) || - this.tile[Layer.STATIONARY]!.id === "water") - ) { - this.destroy(this, "splash") - if (!this.tile.hasLayer(Layer.STATIONARY)) - new Water(this.level, this.tile.position) - } - } - canBePushed(other: Actor): boolean { - // Fun fact: Ice blocks & dir blocks just can't be pushed when they are sliding and a block is pushing them - return !(this.slidingState && other.hasTag("block")) - } -} - -actorDB["iceBlock"] = IceBlock - -export class DirectionalBlock extends Actor { - id = "directionalBlock" - getLayer(): Layer { - return Layer.MOVABLE - } - legalDirections = Array.from(this.customData).map(val => "URDL".indexOf(val)) - blocks(): boolean { - return true - } - static pushTags = ["block"] - static tags = [ - "block", - "cc2block", - "movable", - "can-stand-on-items", - "reverse-on-railroad", - "dies-in-slime", - ] - static immuneTags = ["water"] - bumpedActor(other: Actor): void { - if ( - other.hasTag("real-playable") && - !this.hasTag("ignore-default-monster-kill") && - !other.hasTag("ignore-default-monster-kill") - ) - other.destroy(this) - } - newTileCompletelyJoined(): void { - const water = this.tile[Layer.STATIONARY] - if (water?.hasTag("water") && !water._internalIgnores(this)) { - water.destroy(null, null) - this.destroy(this, "splash") - } - } - canBePushed(other: Actor, direction: Direction): boolean { - if (!this.legalDirections.includes(direction)) return false - // Fun fact: Ice blocks & dir blocks just can't be pushed when they are sliding and a block is pushing them - return !(this.slidingState && other.hasTag("block")) - } - rebuildCustomData(): void { - this.customData = "" - for (const dir of this.legalDirections) { - this.customData += "URDL"[dir] - } - } - onRedirect(delta: number): void { - for (let i = 0; i < this.legalDirections.length; i++) { - this.legalDirections[i] = (this.legalDirections[i] - delta + 4) % 4 - } - this.rebuildCustomData() - } -} - -actorDB["directionalBlock"] = DirectionalBlock diff --git a/logic/src/actors/buttons.ts b/logic/src/actors/buttons.ts deleted file mode 100644 index 64a46cb3..00000000 --- a/logic/src/actors/buttons.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { Actor } from "../actor.js" -import { Layer } from "../tile.js" -import { actorDB, Decision } from "../const.js" -import { Tile } from "../tile.js" -import { getTileWirable, WireOverlapMode } from "../wires.js" -import { onLevelStart } from "../level.js" - -export function globalButtonFactory(color: string) { - return class extends Actor { - id = `button${color[0].toUpperCase()}${color.substr(1).toLowerCase()}` - static tags = ["button"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - - for (const actor of this.level.actors) - if (actor.caresButtonColors.includes(color)) - actor.buttonPressed?.(color) - } - actorLeft(): void { - for (const actor of this.level.actors) - if (actor.caresButtonColors.includes(color)) - actor.buttonUnpressed?.(color) - } - } -} - -export function globalComplexButtonFactory(color: string) { - return class extends Actor { - id = `complexButton${color[0].toUpperCase()}${color - .substr(1) - .toLowerCase()}` - static tags = ["button"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(other: Actor): void { - this.level.sfxManager?.playOnce("button press") - - for (const actor of this.level.actors) - if (actor.caresButtonColors.includes(color)) - actor.buttonPressed?.(color, other.direction.toString()) - } - actorLeft(other: Actor): void { - for (const actor of this.level.actors) - if (actor.caresButtonColors.includes(color)) - actor.buttonUnpressed?.(color, other.direction.toString()) - } - } -} - -class ButtonGreen extends globalButtonFactory("green") { - actorCompletelyJoined(): void { - super.actorCompletelyJoined() - this.level.greenButtonPressed = !this.level.greenButtonPressed - } -} - -actorDB["buttonGreen"] = ButtonGreen - -class ButtonBlue extends globalButtonFactory("blue") { - actorCompletelyJoined(): void { - super.actorCompletelyJoined() - this.level.blueButtonPressed = !this.level.blueButtonPressed - } -} - -actorDB["buttonBlue"] = ButtonBlue - -class ComplexButtonYellow extends globalComplexButtonFactory("yellow") { - actorCompletelyJoined(other: Actor): void { - super.actorCompletelyJoined(other) - this.level.currentYellowButtonPress = other.direction + 1 - } -} - -actorDB["complexButtonYellow"] = ComplexButtonYellow - -export function ROConnectedButtonFactory( - color: string, - shouldActivateOnLevelStart?: boolean -) { - return class extends Actor { - id = `button${color[0].toUpperCase()}${color.substr(1).toLowerCase()}` - static tags = ["button"] - connectedActor: Actor | null = null - explicitlyConnectedTile: Tile | null = null - getLayer(): Layer { - return Layer.STATIONARY - } - levelStarted(): void { - // Search for an explicit connection - for (const connection of this.level.connections) - if ( - connection[0][0] === this.tile.x && - connection[0][1] === this.tile.y - ) - this.explicitlyConnectedTile = - this.level.field[connection[1][0]]?.[connection[1][1]] - const thisIndex = this.level.actors.indexOf(this) - const foundActor = [ - // TODO This relies that actor order is in RRO, maybe this should do it more like teleports? - ...this.level.actors.slice(thisIndex), - ...this.level.actors.slice(0, thisIndex), - ...(this.explicitlyConnectedTile?.allActors ?? []), // Try the explicitly connected one first - ] - .reverse() - .find(actor => actor.caresButtonColors.includes(color)) - if (foundActor) this.connectedActor = foundActor - - if (shouldActivateOnLevelStart && this.tile.hasLayer(Layer.MOVABLE)) - this.connectedActor?.buttonPressed?.(color, "init") - } - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - if (!this.connectedActor?.exists) this.connectedActor = null - this.connectedActor?.buttonPressed?.(color) - } - actorLeft(): void { - if (!this.connectedActor?.exists) this.connectedActor = null - this.connectedActor?.buttonUnpressed?.(color) - } - actorDestroyed(): void { - if (!this.connectedActor?.exists) this.connectedActor = null - this.connectedActor?.buttonUnpressed?.(color) - } - } -} - -actorDB["buttonRed"] = ROConnectedButtonFactory("red") - -actorDB["buttonBrown"] = ROConnectedButtonFactory("brown", true) - -export function diamondConnectedButtonFactory(color: string) { - return class extends Actor { - id = `button${color[0].toUpperCase()}${color.substr(1).toLowerCase()}` - static tags = ["button"] - connectedActor: Actor | null = null - explicitlyConnectedTile: Tile | null = null - getLayer(): Layer { - return Layer.STATIONARY - } - levelStarted(): void { - // Search for an explicit connection - for (const connection of this.level.connections) - if ( - connection[0][0] === this.tile.x && - connection[0][1] === this.tile.y - ) - this.explicitlyConnectedTile = - this.level.field[connection[1][0]]?.[connection[1][1]] - if (this.explicitlyConnectedTile) { - for (const actor of this.explicitlyConnectedTile.allActors) - if (actor.caresButtonColors.includes(color)) - this.connectedActor = actor - } else { - const maxDimension = Math.max(this.level.width, this.level.height) - mainLoop: for ( - let currentLevel = 1; - currentLevel <= maxDimension + 1; - currentLevel += 1 - ) { - for (const tile of this.tile.getDiamondSearch(currentLevel)) { - for (const actor of tile.allActors) { - if (actor.caresButtonColors.includes(color)) { - this.connectedActor = actor - break mainLoop - } - } - } - } - } - } - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - if (!this.connectedActor?.exists) this.connectedActor = null - this.connectedActor?.buttonPressed?.(color) - } - actorLeft(): void { - if (!this.connectedActor?.exists) this.connectedActor = null - this.connectedActor?.buttonUnpressed?.(color) - } - } -} - -actorDB["buttonOrange"] = diamondConnectedButtonFactory("orange") - -export class ButtonPurple extends Actor { - id = "buttonPurple" - static tags = ["button"] - getLayer(): Layer { - return Layer.STATIONARY - } - wireOverlapMode = WireOverlapMode.NONE - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - } - actorOnTile(actor: Actor): void { - if (actor.layer !== Layer.MOVABLE) return - this.poweringWires = 0b1111 - } - processOutput(): void { - this.poweringWires = 0 - } - providesPower = true - requiresFullConnect = true -} - -actorDB["buttonPurple"] = ButtonPurple - -export class ButtonBlack extends Actor { - id = "buttonBlack" - static tags = ["button"] - getLayer(): Layer { - return Layer.STATIONARY - } - wireOverlapMode = WireOverlapMode.ALWAYS_CROSS - poweringWires = 0b1111 - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - } - processOutput() { - const movable = this.tile[Layer.MOVABLE] - if (movable && movable.cooldown <= 0) { - this.poweringWires = 0 - return - } - - this.poweringWires = 0b1111 - } - providesPower = true - requiresFullConnect = true -} - -actorDB["buttonBlack"] = ButtonBlack - -export class ToggleSwitch extends Actor { - id = "toggleSwitch" - static tags = ["button"] - getLayer(): Layer { - return Layer.STATIONARY - } - - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - - this.customData = this.customData === "on" ? "off" : "on" - } - wireOverlapMode = WireOverlapMode.NONE - // This is no error, this is how CC2 does it, too - processOutput() { - this.poweringWires = this.customData === "on" ? 0b1111 : 0 - } - poweringWires = this.customData === "on" ? 0b1111 : 0 - providesPower = true - requiresFullConnect = true -} - -actorDB["toggleSwitch"] = ToggleSwitch - -export class ButtonGray extends Actor { - id = "buttonGray" - static tags = ["button"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("button press") - - for (let y = -2; y <= 2; y++) { - for (let x = -2; x <= 2; x++) { - const tile = this.level.field[this.tile.x + x]?.[this.tile.y + y] - if (!tile) continue - getTileWirable(tile).pulse?.(false) - } - } - } -} - -actorDB["buttonGray"] = ButtonGray diff --git a/logic/src/actors/index.ts b/logic/src/actors/index.ts deleted file mode 100644 index adb93a4b..00000000 --- a/logic/src/actors/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from "./animation.js" -export * from "./blocks.js" -export * from "./buttons.js" -export * from "./itemMods.js" -export * from "./items.js" -export * from "./monsters.js" -export * from "./playables.js" -export * from "./teleport.js" -export * from "./terrain.js" -export * from "./walls.js" -export * from "./weird.js" diff --git a/logic/src/actors/itemMods.ts b/logic/src/actors/itemMods.ts deleted file mode 100644 index cba4bd72..00000000 --- a/logic/src/actors/itemMods.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Actor } from "../actor.js" -import { Layer } from "../tile.js" -import { Direction } from "../helpers.js" -import { Key } from "./items.js" -import { actorDB } from "../const.js" - -export class NoSign extends Actor { - id = "noSign" - static tags = ["ignoreItem", "no-sign"] - getLayer(): Layer { - return Layer.ITEM_SUFFIX - } - blocks(other: Actor): boolean { - const item = this.tile[Layer.ITEM] - if (item instanceof Key) { - if (other.inventory.keys[item.id]?.amount > 0) return true - } else if ( - other.inventory.items.some(otherItem => otherItem.id === item?.id) - ) - return true - - return false - } -} - -actorDB["noSign"] = NoSign diff --git a/logic/src/actors/items.ts b/logic/src/actors/items.ts deleted file mode 100644 index 309c77d6..00000000 --- a/logic/src/actors/items.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { Layer } from "../tile.js" -import { Actor, matchTags, SlidingState } from "../actor.js" -import { actorDB, cc1BootNameList, getTagFlag, keyNameList } from "../const.js" -import { LevelState } from "../level.js" -import { LitTNT, RollingBowlingBall } from "./monsters.js" -import { Explosion } from "./animation.js" -import { Direction } from "../helpers.js" -import { SteelWall } from "./walls.js" - -export const enum ItemDestination { - NONE, - KEY, - ITEM, -} - -export abstract class Item extends Actor { - static tags = ["item"] - constructor( - level: LevelState, - pos: [number, number], - customData?: string, - wires?: number - ) { - super(level, pos, customData, wires) - if ((new.target as any).carrierTags) { - this.carrierTags = (new.target as any).carrierTags - } - } - destination = ItemDestination.ITEM - /** - * Tags to add to the carrier of the item - */ - carrierTags?: Record - carrierSpeedMod?(actor: Actor, mult: number): number - hasItemMod(): boolean { - if (this.tile[Layer.ITEM_SUFFIX]?.hasTag("ignoreItem")) return true - return false - } - blocks?(other: Actor): boolean { - return ( - !this.hasItemMod() && - !other.hasTag("can-pickup-items") && - !other.hasTag("can-stand-on-items") && - !other.hasTag("playable") - ) - } - ignores(_other: Actor): boolean { - return this.hasItemMod() - } - getLayer(): Layer { - return Layer.ITEM - } - pickup(other: Actor): boolean { - if ( - other.hasTag("can-stand-on-items") || - (this.shouldBePickedUp && !this.shouldBePickedUp(other)) || - this.hasItemMod() - ) - return false - this.destroy(other, null) - if (other.hasTag("playable")) { - this.level.sfxManager?.playOnce("item get") - } - switch (this.destination) { - case ItemDestination.KEY: - if (!other.inventory.keys[this.id]) - other.inventory.keys[this.id] = { amount: 0, type: this as Key } - other.inventory.keys[this.id].amount++ - other.inventory.keys[this.id].amount %= 256 - break - case ItemDestination.ITEM: - if (this.level.cc1Boots) { - if ( - !cc1BootNameList.includes(this.id) || - other.inventory.items.some(item => item.id === this.id) - ) - break - } - other.inventory.items.unshift(this) - if (other.inventory.items.length > other.inventory.itemMax) { - if (!other.dropItem()) other.dropItemN(0, true) - } - break - } - this.onPickup?.(other) - if (this.carrierTags) { - for (const prop in this.carrierTags) { - other[prop as "tags"] |= this.carrierTags[prop] - } - } - return true - } - actorCompletelyJoined(other: Actor): void { - this.pickup(other) - } - onPickup?(other: Actor): void - onDrop?(other: Actor): void - shouldBePickedUp?(other: Actor): boolean - onCarrierCompleteJoin?(carrier: Actor): void - onCarrierJoin?(carrier: Actor): void - onCarrierBump?(carrier: Actor, bumpee: Actor, direction: Direction): void - onCarrierDestroyed?(carrier: Actor): void - canBeDropped?(carrier: Actor): boolean -} - -export class EChipPlus extends Item { - id = "echipPlus" - destination = ItemDestination.NONE - hasItemMod(): boolean { - return false - } - constructor( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) { - super(level, position, customData, direction) - level.chipsTotal++ - } - shouldBePickedUp(other: Actor): boolean { - return other.hasTag("real-playable") - } - onPickup(): void { - this.level.chipsLeft = Math.max(0, this.level.chipsLeft - 1) - } -} - -actorDB["echipPlus"] = EChipPlus - -export class EChip extends EChipPlus { - id = "echip" - - constructor( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) { - super(level, position, customData, direction) - level.chipsLeft++ - level.chipsRequired++ - } -} - -actorDB["echip"] = EChip - -export abstract class Key extends Item { - destination = ItemDestination.KEY as const - /** - * Determines if the specific actor can re-use this key - */ - keyUsed?(other: Actor): void -} - -// TODO Turn this into a factory too - -export class KeyRed extends Key { - id = "keyRed" - ignores(other: Actor) { - return !other.hasTag("playable") - } - blocks = undefined - keyUsed(other: Actor): void { - if (other.hasTag("can-reuse-key-red")) - other.inventory.keys[this.id].amount++ - } -} - -actorDB["keyRed"] = KeyRed - -keyNameList.push("keyRed") - -export class KeyBlue extends Key { - id = "keyBlue" - blocks = undefined - keyUsed(other: Actor): void { - if (other.hasTag("can-reuse-key-blue")) - other.inventory.keys[this.id].amount++ - } -} - -actorDB["keyBlue"] = KeyBlue - -keyNameList.push("keyBlue") - -export class KeyYellow extends Key { - id = "keyYellow" - keyUsed(other: Actor): void { - if (other.hasTag("can-reuse-key-yellow")) - other.inventory.keys[this.id].amount++ - } -} - -actorDB["keyYellow"] = KeyYellow - -keyNameList.push("keyYellow") - -export class KeyGreen extends Key { - id = "keyGreen" - keyUsed(other: Actor): void { - if (other.hasTag("can-reuse-key-green")) - other.inventory.keys[this.id].amount++ - } -} - -actorDB["keyGreen"] = KeyGreen - -keyNameList.push("keyGreen") - -export class BootIce extends Item { - id = "bootIce" - static carrierTags: Record = { ignoreTags: ["ice"] } - static extraTagProperties = ["extraTags"] - static extraTags = ["super-weirdly-ignores-ice"] - destination = ItemDestination.ITEM - onPickup(other: Actor): void { - if (other.hasTag("weirdly-ignores-ice")) { - this.carrierTags = { tags: getTagFlag("super-weirdly-ignores-ice") } - } else { - this.carrierTags = { ignoreTags: getTagFlag("ice") } - // Indeed, a hack, but I really don't want to ever calculate the sliding state automatically - if (other.slidingState === SlidingState.STRONG) - other.slidingState = SlidingState.NONE - } - /* if ( - other.getCompleteTags("ignoreTags").includes("ice") && - !other.getCompleteTags("ignoreTags", this).includes("ice") - ) - other.slidingState = SlidingState.NONE */ - } -} - -actorDB["bootIce"] = BootIce -cc1BootNameList.push("bootIce") - -export class BootForceFloor extends Item { - id = "bootForceFloor" - static carrierTags = { ignoreTags: ["force-floor"] } - destination = ItemDestination.ITEM - onPickup(other: Actor): void { - // Indeed, a hack, but I really don't want to ever calculate the sliding state automatically - if (other.slidingState === SlidingState.WEAK) { - other.slidingState = SlidingState.NONE - } - } -} - -actorDB["bootForceFloor"] = BootForceFloor -cc1BootNameList.push("bootForceFloor") - -export class BootFire extends Item { - id = "bootFire" - static carrierTags = { ignoreTags: ["fire"] } - destination = ItemDestination.ITEM - // Double-ignore thing for the ghost - onCarrierCompleteJoin(carrier: Actor): void { - if (!carrier.hasTag("double-item-remove")) return - for (const actor of carrier.tile.allActors) { - if (actor.hasTag("fire") && actor.hasTag("boot-removable")) { - actor.destroy(null, null) - } - } - } -} - -actorDB["bootFire"] = BootFire -cc1BootNameList.push("bootFire") - -export class BootWater extends Item { - id = "bootWater" - static carrierTags = { ignoreTags: ["water"], collisionIgnoreTags: ["water"] } - destination = ItemDestination.ITEM -} - -actorDB["bootWater"] = BootWater -cc1BootNameList.push("bootWater") - -export class BootDirt extends Item { - id = "bootDirt" - static carrierTags = { collisionIgnoreTags: ["filth"] } - destination = ItemDestination.ITEM - onPickup(other: Actor): void { - if (other.hasTag("playable")) { - this.carrierTags = { collisionIgnoreTags: getTagFlag("filth") } - } else { - this.carrierTags = { collisionIgnoreTags: BigInt(0) } - } - } - // Double-ignore thing for the ghost - onCarrierCompleteJoin(carrier: Actor): void { - if (!carrier.hasTag("double-item-remove")) return - for (const actor of carrier.tile.allActors) { - if (actor.hasTag("filth") && actor.hasTag("boot-removable")) { - actor.destroy(null, null) - } - } - } -} - -actorDB["bootDirt"] = BootDirt - -export class GoronBraslet extends Item { - id = "goronBraslet" - static carrierTags = { pushTags: ["wall"] } - destination = ItemDestination.ITEM -} - -actorDB["goronBraslet"] = GoronBraslet - -export class Helmet extends Item { - id = "helmet" - destination = ItemDestination.ITEM - static carrierTags = { tags: ["ignore-default-monster-kill"] } -} - -actorDB["helmet"] = Helmet - -export class BonusFlag extends Item { - hasItemMod(): boolean { - return false - } - id = "bonusFlag" - static tags = ["bonusFlag"] - destination = ItemDestination.NONE - onPickup(carrier: Actor): void { - if (carrier.hasTag("real-playable")) - if (this.customData.startsWith("*")) - this.level.bonusPoints *= parseInt(this.customData.substr(1)) - else this.level.bonusPoints += parseInt(this.customData) - } -} - -actorDB["bonusFlag"] = BonusFlag - -export class TNT extends Item { - id = "tnt" - destination = ItemDestination.ITEM - actorLeft(other: Actor): void { - if (!other.hasTag("real-playable")) return - this.destroy(null, null) - const lit = new LitTNT(this.level, this.tile.position) - lit.inventory.itemMax = other.inventory.itemMax - lit.inventory.items = other.inventory.items.map(item => { - const rootTile = this.level.field[0][0] - const rootActor = rootTile[item.layer] - delete rootTile[item.layer] - const nItem = new actorDB[item.id]( - this.level, - [0, 0], - item.customData - ) as Item - nItem.pickup(lit) - if (rootActor !== undefined) { - rootTile[item.layer] = rootActor - } - return nItem - }) - for (const keyType in other.inventory.keys) - lit.inventory.keys[keyType] = { ...other.inventory.keys[keyType] } - lit.recomputeTags() - } -} - -actorDB["tnt"] = TNT - -export class SecretEye extends Item { - id = "secretEye" - static carrierTags = { tags: ["can-see-secrets"] } - destination = ItemDestination.ITEM -} - -actorDB["secretEye"] = SecretEye - -export class RailroadSign extends Item { - id = "railroadSign" - static carrierTags = { - tags: ["ignores-railroad-redirect"], - collisionIgnoreTags: ["railroad"], - } - destination = ItemDestination.ITEM -} - -actorDB["railroadSign"] = RailroadSign - -export class BowlingBall extends Item { - id = "bowlingBall" - destination = ItemDestination.ITEM - onDrop(dropper: Actor): void { - dropper.tile.removeActors(dropper) - const rollingGuy = this.replaceWith(RollingBowlingBall) - if (rollingGuy._internalStep(dropper.direction)) rollingGuy.cooldown-- - // Hello animation from rolling bowling ball movement failure, please die so my dropper can go back - const movable = dropper.tile[Layer.MOVABLE] - if (movable instanceof Explosion) movable.destroy(null, null) - dropper.tile.addActors(dropper) - } -} - -actorDB["bowlingBall"] = BowlingBall - -export class TimeBonus extends Item { - id = "timeBonus" - hasItemMod(): boolean { - return false - } - destination = ItemDestination.NONE - shouldBePickedUp(other: Actor): boolean { - return other.hasTag("real-playable") - } - onPickup(): void { - this.level.timeLeft += 60 * 10 - } -} - -actorDB["timeBonus"] = TimeBonus - -export class TimePenalty extends Item { - id = "timePenalty" - hasItemMod(): boolean { - return false - } - destination = ItemDestination.NONE - shouldBePickedUp(other: Actor): boolean { - return other.hasTag("real-playable") - } - onPickup(): void { - this.level.timeLeft -= 60 * 10 - if (this.level.timeLeft < 1) this.level.timeLeft = 1 - } -} - -actorDB["timePenalty"] = TimePenalty - -export class TimeToggle extends Item { - id = "timeToggle" - hasItemMod(): boolean { - return false - } - destination = ItemDestination.NONE - shouldBePickedUp(): boolean { - return false - } - actorCompletelyJoined(other: Actor): void { - if (!other.hasTag("playable")) return - this.level.timeFrozen = !this.level.timeFrozen - } -} - -actorDB["timeToggle"] = TimeToggle - -export class SpeedBoots extends Item { - id = "bootSpeed" - carrierSpeedMod(_actor: Actor, mult: number): number { - return mult === 1 ? 2 : 1 - } -} - -actorDB["bootSpeed"] = SpeedBoots - -export class LightningBolt extends Item { - id = "lightningBolt" - onCarrierJoin(carrier: Actor): void { - if (carrier.oldTile && !carrier.oldTile.hasLayer(Layer.STATIONARY)) - carrier.oldTile.poweringWires = 0 - } - onCarrierCompleteJoin(carrier: Actor): void { - if (!carrier.tile.hasLayer(Layer.STATIONARY)) - carrier.tile.poweringWires = carrier.tile.wires - } - onDrop(carrier: Actor): void { - if (!carrier.tile.hasLayer(Layer.STATIONARY)) carrier.tile.poweringWires = 0 - } - onCarrierDestroyed(carrier: Actor): void { - if (!carrier.tile.hasLayer(Layer.STATIONARY)) carrier.tile.poweringWires = 0 - } -} - -actorDB["lightningBolt"] = LightningBolt - -export class Hook extends Item { - id = "hook" - static carrierTags = { tags: ["pulling"] } - destination = ItemDestination.ITEM -} - -actorDB["hook"] = Hook - -export class Foil extends Item { - id = "foil" - onCarrierBump(carrier: Actor, bumpee: Actor): void { - if (!bumpee.hasTag("tinnable")) return - bumpee.replaceWith(SteelWall) - } -} - -actorDB["foil"] = Foil - -export class Bribe extends Item { - id = "bribe" - static tags = ["item", "bribe"] -} - -actorDB["bribe"] = Bribe diff --git a/logic/src/actors/monsters.ts b/logic/src/actors/monsters.ts deleted file mode 100644 index d267d0f2..00000000 --- a/logic/src/actors/monsters.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { Actor, matchTags } from "../actor.js" -import { Layer } from "../tile.js" -import { Direction, hasOwnProperty, relativeToAbsolute } from "../helpers.js" -import { actorDB, Decision, getTagFlag, hasTagOverlap } from "../const.js" -import { Fire } from "./terrain.js" -import { Tile } from "../tile.js" -import { iterableFind, iterableSome } from "../iterableHelpers.js" -import { onLevelAfterTick } from "../level.js" -import { GlitchInfo } from "../parsers/nccs.pb.js" - -export abstract class Monster extends Actor { - blocks(): true { - return true - } - static tags = ["autonomous-monster", "normal-monster", "movable"] - getLayer(): Layer { - return Layer.MOVABLE - } - bumped(other: Actor, _bumpedDirection: Direction): void { - // Monsters kill players which bump into - if ( - other.hasTag("real-playable") && - !this.hasTag("ignore-default-monster-kill") && - !other.hasTag("ignore-default-monster-kill") - ) - other.destroy(this) - } - // Monsters kill players which they bump into - bumpedActor( - other: Actor, - _bumpedDirection: Direction, - _exiting: boolean - ): void { - // Monsters kill players which bump into them if they can move into them - if ( - other.hasTag("real-playable") && - !this.hasTag("ignore-default-monster-kill") && - !other.hasTag("ignore-default-monster-kill") - ) - other.destroy(this) - } -} - -export class Centipede extends Monster { - id = "centipede" - transmogrifierTarget = "fireball" - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.RIGHT, dir.FORWARD, dir.LEFT, dir.BACKWARD] - } -} - -actorDB["centipede"] = Centipede - -export class Ant extends Monster { - id = "ant" - transmogrifierTarget = "glider" - // - static blockedByTags = ["canopy"] - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.LEFT, dir.FORWARD, dir.RIGHT, dir.BACKWARD] - } -} - -actorDB["ant"] = Ant - -export class Glider extends Monster { - id = "glider" - transmogrifierTarget = "centipede" - static ignoreTags = ["water-ish"] - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.FORWARD, dir.LEFT, dir.RIGHT, dir.BACKWARD] - } -} - -actorDB["glider"] = Glider - -export class Fireball extends Monster { - id = "fireball" - transmogrifierTarget = "ant" - static collisionIgnoreTags = ["fire"] - static ignoreTags = ["fire"] - static tags = ["autonomous-monster", "normal-monster", "movable", "melting"] - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.FORWARD, dir.RIGHT, dir.LEFT, dir.BACKWARD] - } -} - -actorDB["fireball"] = Fireball - -export class Ball extends Monster { - id = "ball" - transmogrifierTarget = "walker" - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.FORWARD, dir.BACKWARD] - } -} - -actorDB["ball"] = Ball - -function getPursuitCoords( - actor: Actor, - target: Actor, - reverse = false -): Direction[] { - // This uses the visual position of the target - const targetPos = target.getVisualPosition() - const dx = actor.tile.x - targetPos[0], - dy = actor.tile.y - targetPos[1] - const directions: Direction[] = [] - const addAmount = reverse ? 2 : 0 - if (dx) directions.push((Math.sign(dx) + 2 + addAmount) % 4) - if (dy) directions.push((Math.sign(dy) + 3 - addAmount) % 4) - if (Math.abs(dy) >= Math.abs(dx)) directions.reverse() - return directions -} - -export class TeethRed extends Monster { - id = "teethRed" - transmogrifierTarget = "teethBlue" - decideMovement(): Direction[] { - if (!this.level.selectedPlayable || (this.level.currentTick + 5) % 8 >= 4) - return [] - return getPursuitCoords( - this, - this.level.selectedPlayable, - this.level.selectedPlayable.hasTag("scares-teeth-red") - ) - } -} - -actorDB["teethRed"] = TeethRed - -export class TeethBlue extends Monster { - id = "teethBlue" - transmogrifierTarget = "teethRed" - decideMovement(): Direction[] { - if (!this.level.selectedPlayable || (this.level.currentTick + 5) % 8 >= 4) - return [] - return getPursuitCoords( - this, - this.level.selectedPlayable, - this.level.selectedPlayable.hasTag("scares-teeth-blue") - ) - } -} - -actorDB["teethBlue"] = TeethBlue - -export class FloorMimic extends Monster { - id = "floorMimic" - decideMovement(): Direction[] { - if (!this.level.selectedPlayable || (this.level.currentTick + 5) % 16 >= 4) - return [] - return getPursuitCoords(this, this.level.selectedPlayable) - } -} - -actorDB["floorMimic"] = FloorMimic - -export class TankBlue extends Monster { - id = "tankBlue" - transmogrifierTarget = "tankYellow" - turnPending = this.customData === "rotating" - decideMovement(): Direction[] { - if (this.turnPending) { - this.turnPending = false - this.customData = "" - return [(this.direction + 2) % 4] - } - return [this.direction] - } - rotateTank(): void { - this.turnPending = !this.turnPending - this.customData = this.turnPending ? "rotating" : "" - } -} - -actorDB["tankBlue"] = TankBlue - -onLevelAfterTick.push(level => { - if (level.blueButtonPressed) { - for (const tank of level.decidingActors) { - if ( - hasOwnProperty(tank, "rotateTank") && - typeof tank.rotateTank === "function" - ) - tank.rotateTank() - } - level.blueButtonPressed = false - } -}) - -export class BlobMonster extends Monster { - id = "blob" - static immuneTags = ["slime"] - moveSpeed = 8 - transmogrifierTarget(): string { - return [ - "glider", - "centipede", - "fireball", - "ant", - "walker", - "ball", - "teethRed", - "tankBlue", - "teethBlue", - ][this.level.random() % 9] - } - newTileJoined(): void { - const spreadedSlime = - this.oldTile && - iterableFind(this.oldTile.allActors, (val: Actor) => val.hasTag("slime")) - if (spreadedSlime && !this.tile.hasLayer(spreadedSlime.layer)) - new actorDB[spreadedSlime.id]( - this.level, - this.tile.position, - spreadedSlime.customData - ) - } - decideMovement(): [] { - // Weird quirk: blobs don't check collision at decision time - this.moveDecision = ((this.level.random() + this.level.blobMod()) % 4) + 1 - return [] - } - blockedBy(other: Actor) { - return ( - other.hasTag("canopy") && - !!(this.tile[Layer.SPECIAL] && this.tile[Layer.SPECIAL].hasTag("canopy")) - ) - } -} - -actorDB["blob"] = BlobMonster - -export class Walker extends Monster { - id = "walker" - transmogrifierTarget = "ball" - decideMovement(): [Direction] { - if (!this.checkCollision(this.direction)) - return [(this.level.random() + this.direction) % 4] - return [this.direction] - } -} - -actorDB["walker"] = Walker - -export class LitTNT extends Monster { - lifeLeft = 253 - static tags = ["movable", "cc1block", "tnt"] - explosionStage: 0 | 1 | 2 | 3 = 0 - id = "tntLit" - nukeTile(tile: Tile): void { - let protectedLayer: Layer = Layer.STATIONARY - const tileHadMovable = tile.hasLayer(Layer.MOVABLE) - let movableDied = false - // TODO Canopies - if (tileHadMovable) protectedLayer = Layer.STATIONARY + 1 // Protect stationary only - const protector = iterableFind(tile.allActors, val => - val.hasTag("blocks-tnt") - ) - if (protector && !(tileHadMovable && tile[this.layer]!.id === this.id)) - protectedLayer = protector.layer - - for (const actor of Array.from(tile.allActorsReverse)) - if (actor.layer >= protectedLayer) { - actor.bumped?.( - this, - Math.abs(tile.x - this.tile.x) > Math.abs(tile.y - this.tile.y) - ? 2 + Math.sign(tile.x - this.tile.x) - : 1 + Math.sign(tile.y - this.tile.y) - ) - if (!actor.exists) continue - - let destroyed = actor.destroy( - actor === protector && protectedLayer !== actor.layer ? null : this, - actor.layer === Layer.STATIONARY || actor.layer === Layer.MOVABLE - ? "explosion" - : null - ) - if (destroyed && actor.layer === Layer.MOVABLE) { - movableDied = true - } - } - // Create a memorial fire if a movable got blown up (if we can) - if (tileHadMovable && movableDied && !tile.hasLayer(Layer.STATIONARY)) - new Fire(this.level, tile.position) - } - onEachDecision(): void { - if (this.lifeLeft > 0) this.lifeLeft-- - else this.explosionStage++ - if (!this.explosionStage) return - // For ice blocks - this.recomputeTags(false) - this.tags |= getTagFlag("melting") - this.immuneTags |= getTagFlag("bowling-ball") - for (const tile of this.tile.getDiamondSearch(this.explosionStage)) - if ( - Math.abs(tile.x - this.tile.x) < 3 && - Math.abs(tile.y - this.tile.y) < 3 - ) - this.nukeTile(tile) - if (this.explosionStage === 2 || this.explosionStage === 3) { - // Dynamite sneaking check: If there's a playable in a ring we've nuked last subtick, - // it means that the playable sneaked through the ring we are nuking this subtick - // by moving closer to the dynamite - for (const tile of this.tile.getDiamondSearch(this.explosionStage - 1)) { - const playable = tile.findActor(actor => actor.hasTag("real-playable")) - if (!playable || !playable.oldTile) continue - // Also check if the playable was actually on a nuked tile last - // subtick, "sneaking" onto a 2-tile from outside the explosion range - // is 100% legal - const xOff = Math.abs(playable.oldTile.x - this.tile.x) - const yOff = Math.abs(playable.oldTile.y - this.tile.y) - if (xOff + yOff === this.explosionStage && xOff < 3 && yOff < 3) { - this.level.addGlitch({ - glitchKind: GlitchInfo.KnownGlitches.DYNAMITE_EXPLOSION_SNEAKING, - location: { x: tile.x, y: tile.y }, - }) - } - } - } - if (this.explosionStage >= 3) this.nukeTile(this.tile) - this.recomputeTags(true) - } -} -actorDB["tntLit"] = LitTNT - -export class TankYellow extends Monster { - id = "tankYellow" - static tags = ["normal-monster", "movable"] - static pushTags = ["block"] - transmogrifierTarget = "tankBlue" - movePending: Decision = this.customData === "rotating" ? -1 : Decision.NONE - decideMovement(): [] { - if (this.movePending) { - //@ts-expect-error You literally didn't check if -1 is Decision 3 lines ago, shut up - if (this.movePending === -1) { - this.movePending = this.direction + 1 - } - this.customData = "" - if (this.checkCollision(this.movePending - 1)) - this.moveDecision = this.level.resolvedCollisionCheckDirection + 1 - this.direction = this.level.resolvedCollisionCheckDirection - this.movePending = Decision.NONE - } - return [] - } - rotateYellowTank(data: Decision): void { - this.customData = "rotating" - this.movePending = data - } -} - -actorDB["tankYellow"] = TankYellow - -onLevelAfterTick.push(level => { - if (level.currentYellowButtonPress) { - for (const tank of level.decidingActors) { - if ( - hasOwnProperty(tank, "rotateYellowTank") && - typeof tank.rotateYellowTank === "function" - ) - tank.rotateYellowTank(level.currentYellowButtonPress) - } - level.currentYellowButtonPress = Decision.NONE - } -}) - -export class RollingBowlingBall extends Monster { - id = "bowlingBallRolling" - static tags = [ - "can-pickup-items", - "movable", - "interacts-with-closed-clone-machine", - "bowling-ball", - ] - decideMovement(): [Direction] { - return [this.direction] - } - bumped(other: Actor, direction: Direction): void { - this.bumpedActor(other, direction, false) - } - bumpedActor(other: Actor, direction: Direction, exiting: boolean): void { - if (!this.exists) return - if ( - (!exiting && other._internalBlocks(this, direction)) || - (exiting && other._internalExitBlocks(this, direction)) - ) { - if (other.layer === Layer.MOVABLE) { - other.destroy(this) - this.destroy(this) - } else if (!this.slidingState) this.destroy(this) - } - } - bumpedEdge(): void { - if (!this.slidingState) this.destroy(this) - } -} - -actorDB["bowlingBallRolling"] = RollingBowlingBall - -export const roverMimicOrder: string[] = [ - "teethRed", - "glider", - "ant", - "ball", - "teethBlue", - "fireball", - "centipede", - "walker", -] - -export class Rover extends Monster { - id = "rover" - static tags = ["autonomous-monster", "can-pickup-items", "movable"] - static pushTags = ["block"] - static blockedByTags = ["canopy"] - moveSpeed = 8 - emulatedMonster: (typeof roverMimicOrder)[number] = roverMimicOrder[0] - decisionsUntilNext = 32 - decideMovement(): Direction[] { - this.decisionsUntilNext-- - if (!this.decisionsUntilNext) { - this.emulatedMonster = - roverMimicOrder[ - (roverMimicOrder.indexOf(this.emulatedMonster) + 1) % - roverMimicOrder.length - ] - this.decisionsUntilNext = 32 - } - return ( - actorDB[this.emulatedMonster].prototype.decideMovement?.apply(this) || [] - ) - } -} - -actorDB["rover"] = Rover - -export class MirrorChip extends Monster { - static tags = [ - "can-pickup-items", - "playable", - "chip", - "movable", - "can-reuse-key-green", - "plays-block-push-sfx", - ] - static pushTags = ["block"] - id = "mirrorChip" - fakes = "chip" - transmogrifierTarget = "mirrorMelinda" - decideMovement(): Direction[] { - if ( - !this.level.selectedPlayable || - this.level.selectedPlayable.id !== this.fakes || - this.level.selectedPlayable.lastDecision === Decision.NONE - ) - return [] - this.moveDecision = this.level.selectedPlayable.lastDecision - return [] - } -} - -actorDB["mirrorChip"] = MirrorChip - -export class MirrorMelinda extends Monster { - static tags = [ - "can-pickup-items", - "playable", - "melinda", - "movable", - "can-reuse-key-yellow", - "plays-block-push-sfx", - ] - static pushTags = ["block"] - static ignoreTags = ["ice"] - id = "mirrorMelinda" - fakes = "melinda" - transmogrifierTarget = "mirrorChip" - decideMovement(): Direction[] { - if ( - !this.level.selectedPlayable || - this.level.selectedPlayable.id !== this.fakes || - this.level.selectedPlayable.lastDecision === Decision.NONE - ) - return [] - this.moveDecision = this.level.selectedPlayable.lastDecision - return [] - } -} - -actorDB["mirrorMelinda"] = MirrorMelinda - -export class Ghost extends Monster { - id = "ghost" - static tags = [ - "can-pickup-items", - "movable", - "ghost", - "weirdly-ignores-ice", - "ignores-exit-block", - "double-item-remove", - ] - static extraTagProperties = [ - "ghostBlockedByTags", - "nonIgnoredTags", - "ghostCollisionIgnoreTags", - ] - static ghostBlockedByTags = ["blocks-ghost", "water-ish"] - // @ts-ignore - ghostBlockedByTags: bigint - static nonIgnoredTags = [ - "machinery", - "button", - "door", - "echip-gate", - "jet", - "no-sign", - "ice", - "water-ish", - ] - // @ts-ignore - nonIgnoredTags: bigint - static ignoreTags = ["bonusFlag", "bomb"] - static ghostCollisionIgnoreTags = ["door", "echip-gate", "ice"] - // @ts-ignore - ghostCollisionIgnoreTags: bigint - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.FORWARD, dir.LEFT, dir.RIGHT, dir.BACKWARD] - } - blockedBy(other: Actor): boolean { - if (other.tile.hasLayer(Layer.ITEM_SUFFIX)) return false - return hasTagOverlap(other.tags, this.ghostBlockedByTags) - } - collisionIgnores(other: Actor): boolean { - if (other.tile.hasLayer(Layer.ITEM_SUFFIX)) return false - return ( - hasTagOverlap(other.tags, this.ghostCollisionIgnoreTags) || - (!hasTagOverlap( - other.tags, - this.nonIgnoredTags | this.ghostBlockedByTags - ) && - other.layer !== Layer.MOVABLE) - ) - } - ignores(other: Actor): boolean { - return ( - !hasTagOverlap(other.tags, this.nonIgnoredTags) && - other.layer !== Layer.ITEM && - other.layer !== Layer.MOVABLE - ) - } -} - -actorDB["ghost"] = Ghost diff --git a/logic/src/actors/playables.ts b/logic/src/actors/playables.ts deleted file mode 100644 index 1985c1a1..00000000 --- a/logic/src/actors/playables.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { Actor, SlidingState } from "../actor.js" -import { Layer } from "../tile.js" -import { Direction, relativeToAbsolute } from "../helpers.js" -import { GameState, LevelState } from "../level.js" -import { Decision, actorDB } from "../const.js" -import { Item } from "./items.js" -import { GlitchInfo } from "../parsers/nccs.pb.js" -import { KeyInputs } from "../inputs.js" - -export function getMovementDirections( - input: KeyInputs -): [Direction?, Direction?] { - const directions: [Direction?, Direction?] = [] - for (const directionName of ["up", "right", "down", "left"] as const) { - if (!input[directionName]) continue - const direction = - Direction[directionName.toUpperCase() as "UP" | "RIGHT" | "DOWN" | "LEFT"] - /** - * Type of the direction, 0 is vertical, 1 is horizontal (Not a pseudo-boolean) - */ - const dirType = direction % 2 - if (directions[dirType] === undefined) directions[dirType] = direction - // If we have a counter-direction, reset the direction type - else return [] - } - return directions -} - -export abstract class Playable extends Actor { - static tags = ["playable", "real-playable"] - // Players actually block everything, they just die if non-players bump into them - blocks(): true { - return true - } - static pushTags = ["block"] - hasOverride = false - lastDecision = Decision.NONE - playerBonked = false - constructor( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) { - super(level, position, customData, direction) - level.playables.unshift(this) - if (!this.level.selectedPlayable) this.level.selectedPlayable = this - if ( - !this.level.levelStarted && - this.level.levelData?.playablesRequiredToExit === "all" - ) { - this.level.playablesLeft += 1 - } - } - decideMovement(): Direction[] { - const dir = relativeToAbsolute(this.direction) - return [dir.RIGHT, dir.FORWARD, dir.LEFT, dir.BACKWARD] - } - getLayer(): Layer { - return Layer.MOVABLE - } - getCanMove(): boolean { - return ( - this.level.selectedPlayable === this && - (!this.slidingState || - (this.slidingState === SlidingState.WEAK && this.hasOverride)) - ) - } - canDoAnything(): boolean { - // Can't do anything if you're dead! - if (!this.exists) return false - if (this.level.selectedPlayable !== this) return false - if (this.cooldown > 0) return false - // Normal movement - if (this.getCanMove()) return true - // Player switching - if (this.level.playablesLeft > 1) return true - // Item cycling - if (this.inventory.items.length >= 2) return true - return false - } - shouldDie(other: Actor): boolean { - // Can't be killed by a block we're pulling - return !other.isPulled - } - _internalDecide(forcedOnly: boolean): void { - this.moveDecision = Decision.NONE - if (this.cooldown || this.frozen) return - this.isPushing = false - const wasBonked = this.playerBonked - if (!forcedOnly) this.playerBonked = false - - let characterSwitched = false - - // TODO Split screen - - const canMove = this.getCanMove() && !forcedOnly - - if (this.level.selectedPlayable === this && !forcedOnly) { - if ( - this.level.gameInput.switchPlayable && - this.level.playables.length > 1 && - !this.level.releasedKeys.switchPlayable - ) { - this.level.releasedKeys.switchPlayable = true - characterSwitched = true - this.level.selectedPlayable = - this.level.playables[ - (this.level.playables.indexOf(this.level.selectedPlayable) + 1) % - this.level.playables.length - ] - } - if ( - this.level.gameInput.rotateInv && - !this.level.releasedKeys.rotateInv && - this.inventory.items.length > 0 && - !this.level.cc1Boots - ) { - this.inventory.items.unshift(this.inventory.items.pop() as Item) - this.level.releasedKeys.rotateInv = true - } - if ( - this.level.gameInput.drop && - !this.level.releasedKeys.drop && - canMove - ) { - this.dropItem() - this.level.releasedKeys.drop = true - } - } - let bonked = false - const [vert, horiz] = getMovementDirections(this.level.gameInput) - if ( - this.slidingState && - (!canMove || (vert === undefined && horiz === undefined)) - ) { - // We are forced to move, or we *wanted* to be forced-moved - this.moveDecision = this.direction + 1 - if (this.slidingState === SlidingState.WEAK && !forcedOnly) - this.hasOverride = true - } else if (!canMove || (vert === undefined && horiz === undefined)) { - // We don't wanna move or we can't - } else { - if (characterSwitched) { - this.level.addGlitch({ - glitchKind: GlitchInfo.KnownGlitches.SIMULTANEOUS_CHARACTER_MOVEMENT, - location: { x: this.tile.x, y: this.tile.y }, - }) - } - // We have a direction we certanly wanna move to - if (vert === undefined || horiz === undefined) { - // @ts-expect-error We ruled out the possibility of no directions earlier, so if any of them is undefined, the other one is not - const chosenDirection: Direction = vert ?? horiz - bonked = !this.checkCollision(chosenDirection) - this.moveDecision = chosenDirection + 1 - } else { - // We have two directions - const canHoriz = this.checkCollision(horiz) - //horiz = this.level.resolvedCollisionCheckDirection - const canVert = this.checkCollision(vert) - //vert = this.level.resolvedCollisionCheckDirection - if (canHoriz && !canVert) this.moveDecision = horiz + 1 - else if (canVert && !canHoriz) this.moveDecision = vert + 1 - else { - // We can move in both / none directions, crap - bonked = !canHoriz - // Just discovered: When both dirs are blocked, always choose horiz - if (!canHoriz) this.moveDecision = horiz + 1 - else { - // We first try to be biased towards current direction - if (horiz === this.direction) this.moveDecision = horiz + 1 - else if (vert === this.direction) this.moveDecision = vert + 1 - // As a last resort, we always pick horiz over vert - else this.moveDecision = horiz + 1 - } - } - } - this.hasOverride = bonked && this.slidingState === SlidingState.WEAK - if ( - bonked && - this === this.level.selectedPlayable && - !( - this.tile[Layer.STATIONARY] && - !this._internalIgnores(this.tile[Layer.STATIONARY]) && - this.tile[Layer.STATIONARY].hasTag("force-floor") - ) - ) { - if (!wasBonked) { - this.level.sfxManager?.playOnce("bump") - } - this.playerBonked = true - } - } - this.lastDecision = this.moveDecision - } - deathReason?: string - destroy(other?: Actor | null, anim?: string | null): boolean { - if (!super.destroy(other, anim)) return false - if (this.level.playables.includes(this)) { - this.level.playables.splice(this.level.playables.indexOf(this), 1) - } - this.deathReason = other?.id - this.level.gameState = GameState.DEATH - return true - } - replaceWith(other: (typeof actorDB)[string]): Actor { - const newActor = super.replaceWith(other) as Playable - // `replaceWith` calls `destroy`, which is usually considered death, but - // transformation isn't death, so actually undo all of the death reporting - // the `destroy` did. - delete this.deathReason - if (this.level.selectedPlayable === this) { - this.level.selectedPlayable = newActor - } - this.level.gameState = GameState.PLAYING - return newActor - } -} - -export class Chip extends Playable { - static tags = [ - "playable", - "real-playable", - "chip", - "can-reuse-key-green", - "scares-teeth-blue", - "overpowers-trap-sliding", - "plays-block-push-sfx", - ] - transmogrifierTarget = "melinda" - id = "chip" -} - -actorDB["chip"] = Chip - -export class Melinda extends Playable { - static tags = [ - "playable", - "real-playable", - "melinda", - "can-reuse-key-yellow", - "scares-teeth-red", - "overpowers-trap-sliding", - "plays-block-push-sfx", - ] - transmogrifierTarget = "chip" - id = "melinda" - static ignoreTags = ["ice"] -} - -actorDB["melinda"] = Melinda diff --git a/logic/src/actors/teleport.ts b/logic/src/actors/teleport.ts deleted file mode 100644 index 7f2aaad3..00000000 --- a/logic/src/actors/teleport.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { Actor, SlidingState } from "../actor.js" -import { Layer, Tile } from "../tile.js" -import { actorDB } from "../const.js" -import { Playable } from "./playables.js" -import { Item, ItemDestination } from "./items.js" -import { iterableFind, iterableIncludes } from "../iterableHelpers.js" -import { - CircuitCity, - getTileWirable, - WireOverlapMode, - Wires, -} from "../wires.js" - -function findNextTeleport( - teleport: T, - rro: boolean, - validateDestination?: (newTeleport: T, rolledOver: boolean) => boolean -): T { - const thisConstructor = Object.getPrototypeOf(teleport).constructor - let lastY = teleport.tile.y - let rolledOver = false - for (const tile of teleport.level.tiles(rro, teleport.tile.position)) { - if (!rolledOver && Math.abs(tile.y - lastY) > 1) rolledOver = true - lastY = tile.y - const newTeleport = ( - tile[teleport.layer] instanceof thisConstructor - ? tile[teleport.layer] - : null - ) as T | null - if ( - newTeleport && - (!validateDestination || validateDestination(newTeleport, rolledOver)) - ) - return newTeleport - } - return teleport -} - -function findNextBlueTeleport( - teleport: T, - rro: boolean, - validateDestination: (newTeleport: Tile, rolledOver: boolean) => T | null -): T | null { - let lastY = teleport.tile.y - let rolledOver = false - for (const tile of teleport.level.tiles(rro, teleport.tile.position)) { - if (!rolledOver && Math.abs(tile.y - lastY) > 1) rolledOver = true - lastY = tile.y - const res = validateDestination(tile, rolledOver) - if (res) return res - } - return null -} - -export abstract class Teleport extends Actor { - static tags = ["machinery", "teleport"] - getLayer(): Layer { - return Layer.STATIONARY - } - shouldProcessThing = false - actorCompletelyJoined(): void { - this.shouldProcessThing = true - } - actorOnTile(other: Actor): void { - if (other._internalIgnores(this)) return - if (other.bonked) other.slidingState = SlidingState.NONE - if (!this.shouldProcessThing) return - this.shouldProcessThing = false - this.level.sfxManager?.playOnce("teleport") - this.onTeleport(other) - } - - abstract onTeleport(other: Actor): void - requiresFullConnect = true -} - -export interface BlueTeleportTarget extends Actor { - isBlueTeleportTarget(): boolean - takeTeleport(other: Actor): void - giveUpTeleport(other: Actor): void - isBusy(other: Actor): boolean - getTeleportOutputCircuit(): CircuitCity | undefined - getTeleportInputCircuit(): CircuitCity[] -} - -export function isBlueTeleportTarget(val: Actor): val is BlueTeleportTarget { - return "isBlueTeleportTarget" in val && "takeTeleport" in val -} - -export function doBlueTeleport( - teleport: BlueTeleportTarget, - other: Actor -): void { - const thisNetwork = teleport.getTeleportOutputCircuit() - const seenNetworks = new Set() - const newTeleportish = findNextBlueTeleport( - teleport, - true, - (tile, rolledOver) => { - while (other.newActor) other = other.newActor - const wirable = getTileWirable(tile) - const tpNetworks = - wirable.circuits?.reduce( - (acc, val) => (val ? acc.concat(val) : acc), - [] - ) ?? [] - if (thisNetwork && tpNetworks.length === 0) return null - if (!thisNetwork && tpNetworks.length > 0 && !rolledOver) { - for (const network of tpNetworks) seenNetworks.add(network) - return null - } - if (wirable instanceof Tile || !isBlueTeleportTarget(wirable as Actor)) - return null - const newTeleport = wirable as BlueTeleportTarget - if (!thisNetwork && tpNetworks.some(val => seenNetworks.has(val))) - return null - if ( - tpNetworks.length > 0 && - thisNetwork && - !newTeleport.getTeleportInputCircuit().includes(thisNetwork) - ) - return null - if ( - !thisNetwork && - tpNetworks.length > 0 && - !newTeleport.hasTag("janky-blue-teleport-overflow-target") - ) - return null - if (newTeleport.isBusy(other)) return null - return newTeleport - } - ) - const newTeleport = newTeleportish ?? teleport - teleport.giveUpTeleport(other) - newTeleport.takeTeleport(other) -} - -export class BlueTeleport extends Teleport implements BlueTeleportTarget { - isBlueTeleportTarget(): boolean { - return true - } - takeTeleport(other: Actor): void { - other.oldTile = other.tile - other.tile = this.tile - other._internalUpdateTileStates(other.oldTile[other.layer] !== other) - other.slidingState = SlidingState.STRONG - } - giveUpTeleport(other: Actor): void { - other.tile.removeActors(other) - } - isBusy(other: Actor): boolean { - return ( - this.tile.hasLayer(Layer.MOVABLE) || - !other.checkCollisionFromTile(this.tile, other.direction, false, false) - ) - } - id = "teleportBlue" - static tags = ["machinery", "teleport", "janky-blue-teleport-overflow-target"] - actorJoined(other: Actor): void { - other.slidingState = SlidingState.STRONG - } - onTeleport(other: Actor): void { - doBlueTeleport(this, other) - } - getTeleportOutputCircuit(): CircuitCity | undefined { - return this.circuits?.find(val => val) - } - getTeleportInputCircuit(): CircuitCity[] { - return ( - this.circuits?.reduce( - (acc, val) => (val ? acc.concat(val) : acc), - [] - ) ?? [] - ) - } - - wireOverlapMode = WireOverlapMode.OVERLAP -} - -actorDB["teleportBlue"] = BlueTeleport - -export class RedTeleport extends Teleport { - actorJoined(other: Actor): void { - other.slidingState = SlidingState.WEAK - } - id = "teleportRed" - onTeleport(other: Actor): void { - other.slidingState = SlidingState.WEAK - other.oldTile = other.tile - if (!this.wired || this.poweredWires) - other.tile = findNextTeleport(this, false, (teleport: Actor) => { - while (other.newActor) other = other.newActor - if (teleport.tile.hasLayer(Layer.MOVABLE)) return false - if (teleport.wired && !teleport.poweredWires) return false - for (let offset = 0; offset < 4; offset++) { - if ( - other.checkCollisionFromTile( - teleport.tile, - (other.direction + offset) % 4, - false, - false - ) - ) { - while (other.newActor) other = other.newActor - other.direction += offset - other.direction %= 4 - return true - } - while (other.newActor) other = other.newActor - } - return false - }).tile - while (other.newActor) other = other.newActor - other._internalUpdateTileStates() - other.slidingState = SlidingState.WEAK - if (other instanceof Playable) other.hasOverride = true - } - actorOnTile(other: Actor): void { - if (other._internalIgnores(this)) return - if (other.bonked && other.slidingState) { - if (this.wired && !this.poweredWires) - other.slidingState = SlidingState.WEAK - else other.slidingState = SlidingState.NONE - } - if (!this.shouldProcessThing) return - this.level.sfxManager?.playOnce("teleport") - this.shouldProcessThing = false - - this.onTeleport(other) - } - wireOverlapMode = WireOverlapMode.NONE -} - -actorDB["teleportRed"] = RedTeleport - -export class GreenTeleport extends Teleport { - id = "teleportGreen" - actorJoined(other: Actor): void { - other.slidingState = SlidingState.STRONG - } - onTeleport(other: Actor): void { - other.slidingState = SlidingState.STRONG - // All green TPs - const allTeleports: this[] = [] - // TPs which do not have an actor on them - const validTeleports: this[] = [] - let targetTeleport: this | undefined - for ( - let teleport = findNextTeleport(this, false); - teleport !== this; - teleport = findNextTeleport(teleport, false) - ) { - allTeleports.push(teleport as this) - if (!teleport.tile.hasLayer(Layer.MOVABLE)) - validTeleports.push(teleport as this) - } - allTeleports.push(this) - validTeleports.push(this) - // We have only 1 teleport in level, do not even try anything - if (allTeleports.length === 1) { - targetTeleport = this - } else { - // This is a wack CC2 bug, I guess, (Props to magical and eevee from CCBBCDS for figuring it out) - const targetIndex = - (this.level.random() % (allTeleports.length - 1)) % - validTeleports.length - - const dir = this.level.random() % 4 - - mainLoop: for (let i = 0; i < validTeleports.length; i++) { - const index = i + targetIndex - const teleport = validTeleports[index] - if (teleport === this) continue - if (index >= validTeleports.length) break - - for (let offset = 0; offset < 4; offset++) { - if ( - other.checkCollisionFromTile( - teleport.tile, - (dir + offset) % 4, - false, - false - ) - ) { - while (other.newActor) other = other.newActor - other.direction = (dir + offset) % 4 - targetTeleport = teleport - break mainLoop - } - while (other.newActor) other = other.newActor - } - } - } - while (other.newActor) other = other.newActor - if (!targetTeleport) targetTeleport = this - other.oldTile = other.tile - other.tile = targetTeleport.tile - other._internalUpdateTileStates() - other.slidingState = SlidingState.STRONG - } -} - -actorDB["teleportGreen"] = GreenTeleport - -export class YellowTeleport extends Teleport implements Item { - pickup = Item.prototype.pickup - hasItemMod(): boolean { - return Item.prototype.hasItemMod.apply(this) - } - actorJoined(other: Actor): void { - other.slidingState = SlidingState.WEAK - } - static tags = ["machinery", "teleport"] - id = "teleportYellow" - destination = ItemDestination.ITEM - blocks(): false { - return false - } - ignores = this.blocks - shouldPickup = true - levelStarted(): void { - // If this is the only yellow teleport at yellow start, never pick up - this.shouldPickup = findNextTeleport(this, false) !== this - } - onTeleport(other: Actor): void { - other.slidingState = SlidingState.WEAK - const newTP = findNextTeleport(this, true, teleport => { - while (other.newActor) other = other.newActor - return ( - !teleport.tile.hasLayer(Layer.MOVABLE) && - other.checkCollisionFromTile( - teleport.tile, - other.direction, - false, - false - ) - ) - }) - while (other.newActor) other = other.newActor - let shouldTP = !(this.shouldPickup && newTP === this) - if (!shouldTP) { - other.slidingState = SlidingState.NONE - shouldTP = !this.pickup(other) - } - if (shouldTP) { - if (other.tile !== newTP.tile) { - other.oldTile = other.tile - other.tile = newTP.tile - other._internalUpdateTileStates() - } - other.slidingState = SlidingState.WEAK - if (other instanceof Playable) other.hasOverride = true - } - } -} - -actorDB["teleportYellow"] = YellowTeleport diff --git a/logic/src/actors/terrain.ts b/logic/src/actors/terrain.ts deleted file mode 100644 index fe7e930b..00000000 --- a/logic/src/actors/terrain.ts +++ /dev/null @@ -1,1130 +0,0 @@ -import { Actor, SlidingState, tagProperties } from "../actor.js" -import { Layer } from "../tile.js" -import { actorDB, getTagFlag } from "../const.js" -import { Wall } from "./walls.js" -import { matchTags } from "../actor.js" -import { Playable } from "./playables.js" -import { - GameState, - LevelState, - onLevelDecisionTick, - onLevelWireTick, -} from "../level.js" -import { Direction, Field, hasOwnProperty } from "../helpers.js" -import { - CircuitCity, - dirToWire, - WireOverlapMode, - Wires, - wireToDir, -} from "../wires.js" -import { BlueTeleportTarget, doBlueTeleport } from "./teleport.js" -import { iterableIncludes } from "../iterableHelpers.js" -import { GlitchInfo } from "../parsers/nccs.pb.js" - -export class LetterTile extends Actor { - id = "letterTile" - getLayer(): Layer { - return Layer.STATIONARY - } -} - -actorDB["letterTile"] = LetterTile - -export class CustomFloor extends Actor { - id = "customFloor" - static tags = ["blocks-ghost"] - getLayer(): Layer { - return Layer.STATIONARY - } -} - -actorDB["customFloor"] = CustomFloor - -export class Ice extends Actor { - id = "ice" - static tags = ["ice"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorJoined(other: Actor): void { - other.slidingState = SlidingState.STRONG - } - actorCompletelyJoinedIgnored(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playOnce("slide step") - } - } - actorLeft(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playContinuous("ice slide") - } - } - actorCompletelyLeft(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.stopContinuous("ice slide") - } - } - actorOnTile(other: Actor): void { - if (other._internalIgnores(this)) return - if (!other.bonked) return - other.slidingState = SlidingState.STRONG - if (other.hasTag("super-weirdly-ignores-ice")) return - - // Turn the other way - other.direction += 2 - other.direction %= 4 - if (other._internalStep(other.direction)) other.cooldown-- - } - speedMod(other: Actor): 1 | 2 { - if (other.hasTag("weirdly-ignores-ice")) return 1 - return 2 - } -} -actorDB["ice"] = Ice - -export class IceCorner extends Actor { - id = "iceCorner" - static tags = ["ice"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorJoined(other: Actor): void { - other.slidingState = SlidingState.STRONG - } - actorCompletelyJoinedIgnored(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playOnce("slide step") - } - } - actorLeft(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playContinuous("ice slide") - } - } - actorCompletelyLeft(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.stopContinuous("ice slide") - } - } - actorOnTile(other: Actor): void { - if (other._internalIgnores(this)) return - if (other.hasTag("super-weirdly-ignores-ice")) return - if (other.bonked) other.direction += 2 - if (!other.hasTag("weirdly-ignores-ice")) { - other.direction += (this.direction - other.direction) * 2 - 1 + 8 - } - other.direction %= 4 - if (other.bonked && other._internalStep(other.direction)) other.cooldown-- - } - speedMod(other: Actor): 1 | 2 { - if (other.hasTag("weirdly-ignores-ice")) return 1 - return 2 - } - blocks(_other: Actor, otherMoveDirection: Direction): boolean { - return !( - otherMoveDirection === this.direction || - otherMoveDirection === (this.direction + 1) % 4 - ) - } - exitBlocks(other: Actor, otherMoveDirection: Direction): boolean { - if (other.hasTag("weirdly-ignores-ice")) return false - return ( - otherMoveDirection === this.direction || - otherMoveDirection === (this.direction + 1) % 4 - ) - } -} - -actorDB["iceCorner"] = IceCorner - -export class ForceFloor extends Actor { - id = "forceFloor" - static tags = ["force-floor"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorJoined(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playContinuous("force floor") - } - if (other.hasTag("block")) other.slidingState = SlidingState.WEAK - } - actorCompletelyJoinedIgnored(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playOnce("slide step") - } - } - actorLeft(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.stopContinuous("force floor") - } - } - actorOnTile(other: Actor): void { - if (other.layer !== Layer.MOVABLE) return - if (other.bonked) other.enterTile(true) - if (!other._internalIgnores(this)) { - other.slidingState = SlidingState.WEAK - other.direction = this.direction - if (other.bonked) { - if (other._internalStep(other.direction)) other.cooldown-- - } - } - } - speedMod(): 2 { - return 2 - } - pulse(): void { - this.direction += 2 - this.direction %= 4 - } -} - -actorDB["forceFloor"] = ForceFloor - -export class ForceFloorRandom extends Actor { - id = "forceFloorRandom" - static tags = ["force-floor"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorJoined(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playContinuous("force floor") - } - if (other.hasTag("block")) other.slidingState = SlidingState.WEAK - } - actorCompletelyJoinedIgnored(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.playOnce("slide step") - } - } - actorLeft(other: Actor): void { - if (other.hasTag("real-playable")) { - this.level.sfxManager?.stopContinuous("force floor") - } - } - actorOnTile(other: Actor): void { - if (other.layer !== Layer.MOVABLE) return - if (other.bonked) other.enterTile(true) - if (!other._internalIgnores(this)) { - other.slidingState = SlidingState.WEAK - other.direction = this.level.randomForceFloorDirection++ - this.level.randomForceFloorDirection %= 4 - if (other.bonked) { - if (other._internalStep(other.direction)) other.cooldown-- - } - } - } - speedMod(): 2 { - return 2 - } -} - -actorDB["forceFloorRandom"] = ForceFloorRandom - -// random - -export class RecessedWall extends Actor { - id = "popupWall" - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["cc1block", "normal-monster"] - actorLeft(): void { - if (this.tile.hasLayer(Layer.MOVABLE)) return - this.destroy(this, null) - new Wall(this.level, this.tile.position) - this.level.sfxManager?.playOnce("recessed wall") - } -} - -actorDB["popupWall"] = RecessedWall - -export class Void extends Actor { - id = "void" - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(other: Actor): void { - other.destroy(this, null) - } -} - -actorDB["void"] = Void - -export class Water extends Actor { - id = "water" - static tags = ["water", "water-ish"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoinedIgnored(other: Actor): void { - if (other.hasTag("playable")) { - this.level.sfxManager?.playOnce("water step") - } - } - actorCompletelyJoined(other: Actor): void { - other.destroy(this, "splash") - } -} - -actorDB["water"] = Water - -export class Dirt extends Actor { - id = "dirt" - static tags = ["filth", "boot-removable"] - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["cc1block", "normal-monster", "melinda"] - actorCompletelyJoined(): void { - this.level.sfxManager?.playOnce("dirt clear") - this.destroy(this, null) - } -} - -actorDB["dirt"] = Dirt - -export class Gravel extends Actor { - id = "gravel" - static tags = ["filth"] - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["normal-monster", "melinda"] -} - -actorDB["gravel"] = Gravel - -export class Exit extends Actor { - id = "exit" - static tags = ["exit"] - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["normal-monster", "cc1block"] - actorCompletelyJoined(other: Actor): void { - if (other instanceof Playable) { - const oldGameState = this.level.gameState - if (other === this.level.selectedPlayable) - this.level.selectedPlayable = - this.level.playables[ - (this.level.playables.indexOf(other) + 1) % - this.level.playables.length - ] - other.destroy(this, null) - this.level.gameState = oldGameState - this.level.playablesLeft-- - this.level.sfxManager?.playOnce(`win ${other.id}`) - this.level.releasedKeys = { ...this.level.gameInput } - } - } -} - -actorDB["exit"] = Exit - -export class EChipGate extends Actor { - id = "echipGate" - static tags = ["echip-gate"] - static immuneTags = ["tnt"] - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["normal-monster", "cc1block"] - actorCompletelyJoined(other: Actor): void { - if (this.level.chipsLeft === 0) { - this.destroy(other, null) - this.level.sfxManager?.playOnce("socket unlock") - } - } - blocks(): boolean { - return this.level.chipsLeft !== 0 - } -} - -actorDB["echipGate"] = EChipGate - -export class Hint extends Actor { - id = "hint" - hint?: string - constructor( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) { - super(level, position, customData, direction) - if (this.level.hintsLeft.length > 0) - this.hint = this.level.hintsLeft.shift() - else this.hint = this.level.defaultHint - } - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["normal-monster", "cc1block"] -} - -actorDB["hint"] = Hint - -export class Fire extends Actor { - id = "fire" - static tags = ["fire", "melting", "boot-removable"] - static blockTags = ["autonomous-monster"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoinedIgnored(other: Actor): void { - if (other.hasTag("playable")) { - this.level.sfxManager?.playOnce("fire step") - } - } - actorCompletelyJoined(other: Actor): void { - if (!other.hasTag("meltable-block")) other.destroy(this) - } -} - -actorDB["fire"] = Fire - -export class ThiefTool extends Actor { - id = "thiefTool" - static blockTags = ["normal-monster", "cc1block"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(other: Actor): void { - if (!other.hasTag("real-playable")) return - for (const [key, item] of other.inventory.items.entries()) { - if (item.hasTag("bribe")) { - other.inventory.items.splice(key, 1) - return - } - } - this.level.sfxManager?.playOnce("robbed") - other.inventory.items = [] - other.recomputeTags() - this.level.bonusPoints = Math.floor(this.level.bonusPoints / 2) - } -} - -actorDB["thiefTool"] = ThiefTool - -export class ThiefKey extends Actor { - id = "thiefKey" - static blockTags = ["normal-monster", "cc1block"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(other: Actor): void { - if (!other.hasTag("real-playable")) return - for (const [key, item] of other.inventory.items.entries()) { - if (item.hasTag("bribe")) { - other.inventory.items.splice(key, 1) - return - } - } - this.level.sfxManager?.playOnce("robbed") - other.inventory.keys = {} - this.level.bonusPoints = Math.floor(this.level.bonusPoints / 2) - } -} - -actorDB["thiefKey"] = ThiefKey - -export class Trap extends Actor { - id = "trap" - openRequests = this.customData === "open" ? 1 : 0 - openRequestAt: number | null = null - get isOpen(): boolean { - if (this.openRequestAt === this.level.currentTick * 3 + this.level.subtick) - return true - if (this.wired) return !!this.poweredWires - return this.openRequests > 0 - } - getLayer(): Layer { - return Layer.STATIONARY - } - exitBlocks(): boolean { - return !this.isOpen - } - caresButtonColors = ["brown"] - setFrozen(actor: Actor): void { - const frozen = !this.isOpen - if (!frozen) { - actor.frozen = false - return - } - if (actor.hasTag("overpowers-trap-sliding")) return - - actor.frozen = true - } - setFrozenAll(): void { - if (this.tile[Layer.MOVABLE]) { - this.setFrozen(this.tile[Layer.MOVABLE]) - } - } - buttonPressed(_type: string, data?: string): void { - const wasOpen = this.isOpen - if (this.customData === "open") this.customData = "" - else this.openRequests++ - this.openRequestAt = this.level.currentTick * 3 + this.level.subtick - if (data !== "init" && !wasOpen && this.isOpen) { - const movable = this.tile[Layer.MOVABLE] - if (movable) { - this.setFrozen(movable) - if (movable._internalStep(movable.direction)) movable.cooldown-- - } - } - } - buttonUnpressed(): void { - this.openRequests = Math.max(0, this.openRequests - 1) - this.setFrozenAll() - } - actorJoined(other: Actor): void { - this.setFrozen(other) - } - levelStarted(): void { - this.setFrozenAll() - } - pulse(actual: boolean): void { - if (!actual) return - this.setFrozenAll() - } - unpulse(): void { - this.setFrozenAll() - } - listensWires = true -} - -actorDB["trap"] = Trap - -// onLevelDecisionTick.push(level => { -// for (const trap of level.actors) { -// if (trap.id !== "trap" || !trap.circuits || !(trap instanceof Trap)) -// continue -// trap.isOpen = !!trap.poweredWires -// for (const movable of trap.tile[Layer.MOVABLE]) { -// trap.setFrozen(movable, !trap.isOpen) -// } -// } -// }) - -// TODO CC1 clone machines -export class CloneMachine extends Actor { - id = "cloneMachine" - isCloning = false - static tags = ["machinery"] - cloneArrows = - this.customData === "cc1" - ? [] - : Array.from(this.customData).map(val => "URDL".indexOf(val)) - // Always block boomer actors - static blockTags = ["cc1block", "normal-monster", "real-playable"] - getLayer(): Layer { - return Layer.STATIONARY - } - // Allow actors to exit while cloning, so they can properly move out of the tile - exitBlocks(): boolean { - return !this.isCloning - } - levelStarted(): void { - const movable = this.tile[Layer.MOVABLE] - if (movable) { - movable.frozen = true - } - } - actorJoined(other: Actor): void { - other.frozen = true - } - blocks(other: Actor): boolean { - return ( - !other.hasTag("interacts-with-closed-clone-machine") && - this.tile.hasLayer(Layer.MOVABLE) - ) - } - - caresButtonColors = ["red"] - tryMovingInto(clonee: Actor, direction: Direction): boolean { - return clonee.checkCollision(direction) && clonee._internalStep(direction) - } - // Cloning with rotation happens at the start of the tick (pre-wire tick), so the extra cooldown is not needed - clone(attemptToRotate: boolean): void { - let clonee = this.tile[Layer.MOVABLE] - if (!clonee) return - this.isCloning = true - if (clonee.cooldown) { - this.isCloning = false - return - } - clonee.frozen = false - clonee.slidingState = SlidingState.STRONG - if (this.tryMovingInto(clonee, clonee.direction)) { - clonee.cooldown-- - } else { - if (clonee.newActor) clonee = clonee.newActor - const ogDir = clonee.direction - if (attemptToRotate) { - for (let i = 1; i <= 3; i++) { - if (this.tryMovingInto(clonee, (ogDir + i) % 4)) { - clonee.cooldown-- - - break - } - if (clonee.newActor) clonee = clonee.newActor - } - } - if (clonee.cooldown === 0) { - clonee.direction = ogDir - clonee.frozen = true - clonee.slidingState = SlidingState.NONE - this.isCloning = false - return - } - } - const newClone = new actorDB[clonee.id]( - this.level, - this.tile.position, - clonee.customData - ) - newClone.direction = clonee.direction - newClone.frozen = true - this.isCloning = false - } - buttonPressed(): boolean { - this.clone(false) - return true - } - pulse(actual: boolean): void { - this.clone(actual) - } -} - -actorDB["cloneMachine"] = CloneMachine - -export class Bomb extends Actor { - id = "bomb" - static tags = ["bomb"] - getLayer(): Layer { - return Layer.ITEM // Yes - } - actorOnTile(other: Actor): void { - if (other.layer !== Layer.MOVABLE || other._internalIgnores(this)) return - other.destroy(this) - this.destroy(other, null) - } -} - -actorDB["bomb"] = Bomb - -export class Turtle extends Actor { - id = "turtle" - static tags = ["water-ish"] - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["melting"] - actorLeft(): void { - if (this.tile.hasLayer(Layer.MOVABLE)) return - this.destroy(null, "splash") - new Water(this.level, this.tile.position) - } -} - -actorDB["turtle"] = Turtle - -export class GreenBomb extends Actor { - id = "greenBomb" - static tags = ["bomb"] - constructor( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) { - super(level, position, customData, direction) - level.chipsTotal++ - level.chipsLeft++ - level.chipsRequired++ - if (customData === "echip") { - this.tags |= getTagFlag("item") - } - } - getLayer(): Layer { - return Layer.ITEM // Yes - } - actorCompletelyJoined(other: Actor): void { - if (this.customData === "bomb") { - other.destroy(this, null) - this.destroy(other) - } else if (other.hasTag("real-playable")) { - this.destroy(null, null) - this.level.chipsLeft = Math.max(0, this.level.chipsLeft - 1) - this.level.sfxManager?.playOnce("item get") - } - } - greenToggle(): void { - this.customData = this.customData === "bomb" ? "echip" : "bomb" - if (this.customData === "echip") { - this.tags |= getTagFlag("item") - } else { - this.tags &= ~getTagFlag("item") - } - } - blocks(other: Actor): boolean { - return ( - this.customData === "echip" && - !other.hasTag("can-pickup-items") && - !other.hasTag("can-stand-on-items") && - !other.hasTag("playable") - ) - } -} - -actorDB["greenBomb"] = GreenBomb - -export class Slime extends Actor { - id = "slime" - static tags = ["slime"] - getLayer(): Layer { - return Layer.STATIONARY - } - actorCompletelyJoined(other: Actor): void { - if ( - other.hasTag("dies-in-slime") || - !(other.hasTag("block") || other.hasTag("clears-slime")) - ) - other.destroy(this, "splash") - else this.destroy(null, null) - } -} - -actorDB["slime"] = Slime - -export class FlameJet extends Actor { - id = "flameJet" - static tags = ["jet"] - static immuneTags = ["meltable-block"] - getLayer(): Layer { - return Layer.STATIONARY - } - updateTags(): void { - if (this.customData === "on") { - this.tags |= getTagFlag("fire") - } else { - this.tags &= ~getTagFlag("fire") - } - } - actorOnTile(other: Actor): void { - if (this.customData === "on" && other.layer === Layer.MOVABLE) - other.destroy(this) - } - caresButtonColors = ["orange"] - buttonPressed(): void { - this.customData = this.customData === "on" ? "off" : "on" - this.updateTags() - } - buttonUnpressed = this.buttonPressed - pulse = this.buttonPressed - constructor(level: LevelState, pos: [number, number], customData?: string) { - super(level, pos, customData) - this.updateTags() - } -} - -actorDB["flameJet"] = FlameJet - -export const updateJetlife = (level: LevelState): void => { - if (!level.levelData?.customData?.jetlife) return - if ( - (level.currentTick * 3 + level.subtick) % - parseInt(level.levelData.customData.jetlife) !== - 0 - ) - return - const queuedUpdates: [FlameJet, string][] = [] - for (const actor of level.actors) - if (actor instanceof FlameJet) { - let neighbors = 0 - for (let xOff = -1; xOff <= 1; xOff++) - for (let yOff = -1; yOff <= 1; yOff++) { - const tile = level.field[actor.tile.x + xOff]?.[actor.tile.y + yOff] - if (tile && !(xOff === 0 && yOff === 0)) - for (const actor of tile.allActors) - if (actor.hasTag("fire")) neighbors++ - } - if (neighbors === 3) queuedUpdates.push([actor, "on"]) - else if (neighbors !== 2) queuedUpdates.push([actor, "off"]) - } - for (const update of queuedUpdates) { - update[0].customData = update[1] - update[0].updateTags() - } -} - -onLevelDecisionTick.push(updateJetlife) - -export class Transmogrifier extends Actor { - id = "transmogrifier" - getLayer(): Layer { - return Layer.STATIONARY - } - wired = false - isActive(): boolean { - return !this.wired || !!this.poweredWires - } - actorCompletelyJoined(other: Actor): void { - if (!this.isActive()) return - let transmogValue: string | undefined - if (hasOwnProperty(other, "transmogrifierTarget")) - if (typeof other.transmogrifierTarget === "string") - transmogValue = other.transmogrifierTarget - else if (typeof other.transmogrifierTarget === "function") - transmogValue = other.transmogrifierTarget() - if (transmogValue) { - other.replaceWith(actorDB[transmogValue]) - this.level.sfxManager?.playOnce("teleport") - } - } -} - -actorDB["transmogrifier"] = Transmogrifier - -export const directionStrings = "URDL" - -export class Railroad extends Actor { - id = "railroad" - static tags = ["railroad"] - isSwitch = this.customData.includes("s") - allRRRedirects = ["UR", "DR", "DL", "UL", "LR", "UD"] - activeTrack: string = this.allRRRedirects[parseInt(this.customData[0] || "0")] - lastEnteredDirection: Direction = parseInt(this.customData[1] || "0") - baseRedirects: string[] = Array.from(this.customData.substr(2)) - .filter(val => !isNaN(parseInt(val))) - .map(val => this.allRRRedirects[parseInt(val)]) - get legalRedirects(): string[] { - return this.isSwitch - ? this.baseRedirects.includes(this.activeTrack) - ? [this.activeTrack] - : [] - : this.baseRedirects - } - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(_other: Actor, enterDirection: Direction): boolean { - const directionString = directionStrings[(enterDirection + 2) % 4] - // If there is no legal redirect regarding this direction, this direction cannot be entered - return !this.legalRedirects.find(val => val.includes(directionString)) - } - actorCompletelyJoined(other: Actor): void { - this.lastEnteredDirection = other.direction - } - redirectTileMemberDirection( - other: Actor, - direction: Direction - ): Direction | null { - if (other.hasTag("ignores-railroad-redirect")) return direction - - const directionString = - directionStrings[(this.lastEnteredDirection + 2) % 4] - const legalRedirects = this.legalRedirects - .filter(val => val.includes(directionString)) - .map(val => - directionStrings.indexOf(val[1 - val.indexOf(directionString)]) - ) - if (other.hasTag("reverse-on-railroad")) - legalRedirects.push((this.lastEnteredDirection + 2) % 4) - // Search for a valid (relative) direction in this order: Forward, right, left, backward - for (const offset of [0, 1, -1, 2]) - if (legalRedirects.includes((direction + offset + 4) % 4)) - return (direction + offset + 4) % 4 - // This...shouldn't happen outside of illegal railroads or RR signs, so don't redirect - return null - } - actorLeft(other: Actor): void { - if (!this.isSwitch || this.wired) return - const enterDirection = - directionStrings[(this.lastEnteredDirection + 2) % 4], - // Note that it doesn't have to make a move which makes sense, you just have to enter and exit in directions which are valid in a vacuum - isLegalISH = !!this.legalRedirects.find( - val => - val.includes(enterDirection) && - val.includes(directionStrings[other.direction]) - ) - if (isLegalISH) { - const exActiveTrack = this.allRRRedirects.indexOf(this.activeTrack) - for ( - let redirectID = (exActiveTrack + 1) % 6; - redirectID !== exActiveTrack; - redirectID = (redirectID + 1) % 6 - ) - if (this.baseRedirects.includes(this.allRRRedirects[redirectID])) { - this.activeTrack = this.allRRRedirects[redirectID] - break - } - } - } - pulse(): void { - const exActiveTrack = this.allRRRedirects.indexOf(this.activeTrack) - for ( - let redirectID = (exActiveTrack + 1) % 6; - redirectID !== exActiveTrack; - redirectID = (redirectID + 1) % 6 - ) - if (this.baseRedirects.includes(this.allRRRedirects[redirectID])) { - this.activeTrack = this.allRRRedirects[redirectID] - break - } - } -} - -actorDB["railroad"] = Railroad - -onLevelWireTick.push(level => { - for (const logicGate of level.circuitInputs) { - if (logicGate instanceof LogicGate) { - logicGate.doTeleport() - } - } -}) - -export abstract class LogicGate extends Actor implements BlueTeleportTarget { - static immuneTags = ["tnt"] - getLayer(): Layer { - return Layer.STATIONARY - } - abstract getInputWires(): Wires - abstract getOutputWires(): Wires - constructor( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) { - super(level, position, customData, direction) - - this.wires = this.getInputWires() | this.getOutputWires() - // Shift bits with wrap - this.wires = - ((this.wires << this.direction) & 0b1111) | - (this.wires >> (4 - this.direction)) - } - abstract processWires(wires: Wires): Wires - updateWires(): void { - const poweredWires = - ((this.poweredWires << (4 - this.direction)) & 0b1111) | - (this.poweredWires >> this.direction) - this.poweringWires = this.processWires(poweredWires) - this.poweringWires = - ((this.poweringWires << this.direction) & 0b1111) | - (this.poweringWires >> (4 - this.direction)) - } - providesPower = true - wireOverlapMode = WireOverlapMode.NONE - heldActor: Actor | null = null - isBlueTeleportTarget(): boolean { - return true - } - takeTeleport(other: Actor): void { - other.exists = false - other.oldTile = other.tile - other.tile = this.tile - this.heldActor = other - } - giveUpTeleport(other: Actor): void { - other.exists = true - this.heldActor = null - } - doTeleport(): void { - if (this.heldActor) { - doBlueTeleport(this, this.heldActor) - } - } - isBusy(): boolean { - return ( - !!this.heldActor || - (dirToWire((wireToDir(this.getOutputWires()) + this.direction) % 4) & - (this.poweredWires | this.poweringWires)) === - 0 - ) - } - getTeleportInputCircuit(): CircuitCity[] { - const circuits: CircuitCity[] = [] - for (let dir = Direction.UP; dir <= Direction.LEFT; dir++) { - const wire = 1 << dir - if (this.getInputWires() & wire) { - circuits.push(this.circuits![(dir + this.direction) % 4] as CircuitCity) - } - } - return circuits - } - getTeleportOutputCircuit(): CircuitCity | undefined { - for (let dir = Direction.UP; dir <= Direction.LEFT; dir++) { - const wire = 1 << dir - if (this.getOutputWires() & wire) { - return this.circuits![(dir + this.direction) % 4] - } - } - } -} - -export class NotGate extends LogicGate { - id = "notGate" - getInputWires(): Wires { - return Wires.DOWN - } - getOutputWires(): Wires { - return Wires.UP - } - processWires(wires: Wires): Wires { - if (!(wires & Wires.DOWN)) return Wires.UP - return 0 - } -} - -actorDB["notGate"] = NotGate - -export class AndGate extends LogicGate { - id = "andGate" - getInputWires(): Wires { - return Wires.LEFT | Wires.RIGHT - } - getOutputWires(): Wires { - return Wires.UP - } - processWires(wires: Wires): Wires { - if ((wires & (Wires.RIGHT | Wires.LEFT)) === (Wires.RIGHT | Wires.LEFT)) - return Wires.UP - return 0 - } -} - -actorDB["andGate"] = AndGate - -export class NandGate extends LogicGate { - id = "nandGate" - getInputWires(): Wires { - return Wires.LEFT | Wires.RIGHT - } - getOutputWires(): Wires { - return Wires.UP - } - processWires(wires: Wires): Wires { - if ((wires & (Wires.RIGHT | Wires.LEFT)) !== (Wires.RIGHT | Wires.LEFT)) - return Wires.UP - return 0 - } -} - -actorDB["nandGate"] = NandGate - -export class OrGate extends LogicGate { - id = "orGate" - getInputWires(): Wires { - return Wires.LEFT | Wires.RIGHT - } - getOutputWires(): Wires { - return Wires.UP - } - processWires(wires: Wires): Wires { - if ((wires & (Wires.RIGHT | Wires.LEFT)) !== 0) return Wires.UP - return 0 - } -} - -actorDB["orGate"] = OrGate - -export class XorGate extends LogicGate { - id = "xorGate" - getInputWires(): Wires { - return Wires.LEFT | Wires.RIGHT - } - getOutputWires(): Wires { - return Wires.UP - } - processWires(wires: Wires): Wires { - if (!!(wires & Wires.RIGHT) !== !!(wires & Wires.LEFT)) return Wires.UP - return 0 - } -} - -actorDB["xorGate"] = XorGate - -export class LatchGate extends LogicGate { - id = "latchGate" - getInputWires(): Wires { - return Wires.LEFT | Wires.RIGHT - } - getOutputWires(): Wires { - return Wires.UP - } - memory = false - processWires(wires: Wires): Wires { - if (wires & Wires.RIGHT) this.memory = !!(wires & Wires.LEFT) - return this.memory ? Wires.UP : 0 - } -} - -actorDB["latchGate"] = LatchGate - -export class LatchGateMirror extends LogicGate { - id = "latchGateMirror" - getInputWires(): Wires { - return Wires.LEFT | Wires.RIGHT - } - getOutputWires(): Wires { - return Wires.UP - } - memory = false - processWires(wires: Wires): Wires { - if (wires & Wires.LEFT) this.memory = !!(wires & Wires.RIGHT) - return this.memory ? Wires.UP : 0 - } -} - -actorDB["latchGateMirror"] = LatchGateMirror - -export class CounterGate extends LogicGate { - id = "counterGate" - getInputWires(): Wires { - return Wires.DOWN | Wires.LEFT - } - getOutputWires(): Wires { - return Wires.UP | Wires.RIGHT - } - memory = parseInt(this.customData || "0") - underflowing = false - lastPowered: Wires = 0 - // This is kinda forgettable, but - // Up - Underflow, Right - Increment, Down - Decrement, Left - Overflow - processWires(wires: Wires): Wires { - const nextPowered = wires - wires &= ~this.lastPowered - this.lastPowered = nextPowered - if (wires & (Wires.RIGHT | Wires.DOWN)) { - this.underflowing = false - } - if ((wires & (Wires.RIGHT | Wires.DOWN)) === (Wires.RIGHT | Wires.DOWN)) - return 0 - if (wires & Wires.RIGHT) { - this.memory++ - if (this.memory === 10) { - this.memory = 0 - return Wires.LEFT - } - } - if (wires & Wires.DOWN) { - this.memory-- - if (this.memory === -1) { - this.memory = 9 - this.underflowing = true - } - } - return this.underflowing ? Wires.UP : 0 - } - isBlueTeleportTarget(): boolean { - return false - } -} - -actorDB["counterGate"] = CounterGate diff --git a/logic/src/actors/walls.ts b/logic/src/actors/walls.ts deleted file mode 100644 index acbacff6..00000000 --- a/logic/src/actors/walls.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { Actor, matchTags } from "../actor.js" -import { Layer } from "../tile.js" -import { actorDB, getTagFlag } from "../const.js" -import { Direction, hasOwnProperty } from "../helpers.js" -import { Playable } from "./playables.js" -import { WireOverlapMode } from "../wires.js" -import { LevelState, onLevelAfterTick } from "../level.js" -export class Wall extends Actor { - id = "wall" - static tags = ["wall", "tinnable"] - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(): boolean { - return true - } -} - -actorDB["wall"] = Wall - -export class SteelWall extends Actor { - id = "steelWall" - static tags = ["blocks-ghost"] - static immuneTags = ["tnt"] - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(): boolean { - return true - } - wireOverlapMode = WireOverlapMode.CROSS -} - -actorDB["steelWall"] = SteelWall - -export class CustomWall extends Actor { - id = "customWall" - static tags = ["blocks-ghost"] - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(): boolean { - return true - } -} - -actorDB["customWall"] = CustomWall - -function doorFactory(color: string) { - const sentenceCaseName = - color[0].toUpperCase() + color.substr(1).toLowerCase() - return class extends Actor { - id = `door${sentenceCaseName}` - static tags = ["door"] - static blockTags = ["normal-monster", "cc1block"] - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(other: Actor): boolean { - return !(other.inventory.keys[`key${sentenceCaseName}`]?.amount > 0) - } - actorCompletelyJoined(other: Actor): void { - if (!other.inventory.keys[`key${sentenceCaseName}`]?.amount) return - if (other.hasTag("playable")) { - this.level.sfxManager?.playOnce("door unlock") - } - other.inventory.keys[`key${sentenceCaseName}`].amount-- - other.inventory.keys[`key${sentenceCaseName}`].type.keyUsed?.(other) - this.destroy(null, null) - } - } -} - -actorDB["doorBlue"] = doorFactory("blue") - -actorDB["doorRed"] = doorFactory("red") - -actorDB["doorGreen"] = doorFactory("green") - -actorDB["doorYellow"] = doorFactory("yellow") - -const shortDirNames = "URDL" - -export class ThinWall extends Actor { - id = "thinWall" - static tags = ["thinWall"] - static extraTagProperties = ["extraTags"] - static extraTags = ["blocks-tnt", "canopy"] - allowedDirections = Array.from(this.customData) - .map(val => - shortDirNames.includes(val) ? 2 ** shortDirNames.indexOf(val) : 0 - ) - .reduce((acc, val) => acc + val, 0) - constructor( - level: LevelState, - pos: [number, number], - customData?: string, - wires?: number - ) { - super(level, pos, customData, wires) - if (this.customData.includes("C")) { - this.tags |= getTagFlag("canopy") - this.tags |= getTagFlag("blocks-tnt") - } - } - shouldDie(): boolean { - if (this.hasTag("canopy")) { - // Remove all traces of the canopy - this.tags &= ~(getTagFlag("canopy") | getTagFlag("blocks-tnt")) - this.customData = this.customData.split("C").join("") - return this.customData === "" - } - return true - } - getLayer(): Layer { - return Layer.SPECIAL - } - blocks(_actor: Actor, otherMoveDirection: Direction): boolean { - return !!((2 ** ((otherMoveDirection + 2) % 4)) & this.allowedDirections) - } - exitBlocks(actor: Actor, otherMoveDirection: Direction): boolean { - if (actor.hasTag("ignores-exit-block")) return false - return !!((2 ** otherMoveDirection) & this.allowedDirections) - } -} - -actorDB["thinWall"] = ThinWall - -export class InvisibleWall extends Actor { - id = "invisibleWall" - getLayer(): Layer { - return Layer.STATIONARY - } - animationLeft = 0 - blocks(): true { - return true - } - bumped(other: Actor, direction: Direction): void { - if ( - this._internalCollisionIgnores(other, direction) || - other.hasTag("cc1block") || - other.hasTag("normal-monster") - ) - return - this.animationLeft = 36 - } - onEachDecision(): void { - if (this.animationLeft) this.animationLeft-- - } -} - -actorDB["invisibleWall"] = InvisibleWall - -export class AppearingWall extends Actor { - id = "appearingWall" - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(): true { - return true - } - bumped(other: Actor, direction: Direction): void { - if ( - this._internalCollisionIgnores(other, direction) || - other.hasTag("cc1block") || - other.hasTag("normal-monster") - ) - return - if (other.hasTag("playable")) { - this.level.sfxManager?.playOnce("bump") - } - this.destroy(null, null) - new Wall(this.level, this.tile.position) - } -} - -actorDB["appearingWall"] = AppearingWall - -export class BlueWall extends Actor { - id = "blueWall" - static tags = ["wall"] - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(other: Actor): boolean { - return ( - this.customData === "real" || - other.hasTag("cc1block") || - other.hasTag("normal-monster") - ) - } - bumped(other: Actor, direction: Direction): void { - if ( - this._internalCollisionIgnores(other, direction) || - other.hasTag("cc1block") || - other.hasTag("normal-monster") - ) - return - this.destroy(null, null) - if (other.hasTag("playable")) { - this.level.sfxManager?.playOnce("bump") - } - if (this.customData === "real") { - new Wall(this.level, this.tile.position) - } - } -} - -actorDB["blueWall"] = BlueWall - -export class ToggleWall extends Actor { - id = "toggleWall" - - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(): boolean { - return this.customData === "on" - } - pulse(): void { - this.customData = this.customData === "on" ? "off" : "on" - } - greenToggle(): void { - this.customData = this.customData === "on" ? "off" : "on" - } -} - -onLevelAfterTick.push(level => { - if (level.greenButtonPressed) { - for (const terrain of level.actors) { - if ( - hasOwnProperty(terrain, "greenToggle") && - typeof terrain.greenToggle === "function" - ) - terrain.greenToggle() - } - level.greenButtonPressed = false - } -}) - -actorDB["toggleWall"] = ToggleWall - -export class HoldWall extends Actor { - id = "holdWall" - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(): boolean { - return this.customData === "on" - } - pulse(): void { - this.customData = this.customData === "on" ? "off" : "on" - } - unpulse(): void { - this.customData = this.customData === "on" ? "off" : "on" - } -} - -actorDB["holdWall"] = HoldWall - -/* export class SwivelRotatingPart extends Actor { - id = "swivelRotatingPart" - static immuneTags = ["tnt"] - getLayer(): Layer { - return Layer.SPECIAL - } - blocks(_actor: Actor, otherMoveDirection: Direction): boolean { - return ( - otherMoveDirection === (this.direction + 2) % 4 || - otherMoveDirection === (this.direction + 3) % 4 - ) - } - actorLeft(actor: Actor): void { - if (actor.direction === this.direction) this.direction++ - else if (actor.direction === (this.direction + 1) % 4) this.direction += 3 - this.direction %= 4 - } -} */ - -export class Swivel extends Actor { - id = "swivel" - // rotatingPart?: SwivelRotatingPart - getLayer(): Layer { - return Layer.STATIONARY - } - /* levelStarted(): void { - this.rotatingPart = new SwivelRotatingPart(this.level, this.tile.position) - this.rotatingPart.direction = this.direction - } */ - /* destroy(killer?: Actor | null, animType?: string | null): boolean { - if (super.destroy(killer, animType)) { - this.rotatingPart?.destroy(null, null) - return true - } - return false - }*/ - blocks(_actor: Actor, otherMoveDirection: Direction): boolean { - return ( - otherMoveDirection === (this.direction + 2) % 4 || - otherMoveDirection === (this.direction + 3) % 4 - ) - } - actorLeft(actor: Actor): void { - const oldDir = this.direction - if (actor.direction === this.direction) this.direction++ - else if (actor.direction === (this.direction + 1) % 4) this.direction += 3 - this.direction %= 4 - if (actor.hasTag("playable") && oldDir !== this.direction) { - this.level.sfxManager?.playOnce("door unlock") - } - } - pulse(): void { - this.direction += 1 - this.direction %= 4 - } -} - -actorDB["swivel"] = Swivel - -export class GreenWall extends Actor { - id = "greenWall" - static tags = ["wall"] - getLayer(): Layer { - return Layer.STATIONARY - } - blocks(other: Actor): boolean { - return this.customData === "real" || other.hasTag("block") - } -} - -actorDB["greenWall"] = GreenWall - -export class NoChipSign extends Actor { - id = "noChipSign" - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["chip"] -} - -actorDB["noChipSign"] = NoChipSign - -export class NoMelindaSign extends Actor { - id = "noMelindaSign" - getLayer(): Layer { - return Layer.STATIONARY - } - static blockTags = ["melinda"] -} - -actorDB["noMelindaSign"] = NoMelindaSign diff --git a/logic/src/actors/weird.ts b/logic/src/actors/weird.ts deleted file mode 100644 index ba4d259c..00000000 --- a/logic/src/actors/weird.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Actor } from "../actor.js" -import { Layer } from "../tile.js" -import { actorDB } from "../const.js" - -// Weird "good job CC2" tiles - -export class VoodooTile extends Actor { - id = "voodooTile" - tileOffset: number | null = - this.customData === "" ? null : parseInt(this.customData, 10) - getLayer(): Layer { - return Layer.STATIONARY - } -} - -actorDB["voodooTile"] = VoodooTile diff --git a/logic/src/attemptTracker.ts b/logic/src/attemptTracker.ts deleted file mode 100644 index 307e2852..00000000 --- a/logic/src/attemptTracker.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Direction } from "./helpers.js" -import { KeyInputs, encodeSolutionStep } from "./inputs.js" -import { GameState } from "./level.js" -import { LevelState } from "./level.js" -import { - IAttemptInfo, - ILevelStateInfo, - IScriptState, - google, -} from "./parsers/nccs.pb.js" - -// The two interfaces are structually equivalent, so just output both! -export function msToProtoTime( - ms: number -): google.protobuf.ITimestamp | google.protobuf.IDuration { - const seconds = Math.floor(ms / 1000) - const micros = ms - seconds * 1000 - return { - seconds, - nanos: micros * 1000, - } -} - -export function protoTimeToMs( - protoTime: google.protobuf.ITimestamp | google.protobuf.IDuration -): number { - return (protoTime.seconds ?? 0) * 1000 + (protoTime.nanos ?? 0) / 1000 -} - -export class AttemptTracker { - currentAttempt: IAttemptInfo - attemptStartTime: number = Date.now() - currentStep = -1 - - attemptSteps: Uint8Array = new Uint8Array(100) - constructor( - blobMod: number, - randomForceFloorDirection: Direction, - scriptState?: IScriptState - ) { - this.currentAttempt = { - attemptStart: msToProtoTime(Date.now()), - solution: { - levelState: { - randomForceFloorDirection: randomForceFloorDirection + 1, - cc2Data: { blobModifier: blobMod, scriptState }, - }, - }, - } - } - reallocateStepArray(): void { - if (!this.attemptSteps) - throw new Error( - "Can't reallocate the step array due to no current attempt.." - ) - let newLength = this.attemptSteps.length * 1.5 - // Makes sure we always have space to save the last time amount - if (newLength % 2 !== 0) newLength += 1 - - const newArr = new Uint8Array(newLength) - newArr.set(this.attemptSteps) - this.attemptSteps = newArr - } - recordAttemptStep(keyInput: KeyInputs): void { - if (!this.attemptSteps) - throw new Error("Can't record steps without the steps array set.") - const input = encodeSolutionStep(keyInput) - if (this.currentStep === -1) { - this.attemptSteps[0] = input - this.attemptSteps[1] = 1 - this.currentStep += 1 - } else { - let stepPos = this.currentStep * 2 - const lastStep = this.attemptSteps[stepPos] - if (this.attemptSteps[stepPos + 1] >= 0xfc || input !== lastStep) { - this.currentStep += 1 - stepPos += 2 - if (stepPos >= this.attemptSteps.length) { - this.reallocateStepArray() - } - this.attemptSteps[stepPos] = input - } - - this.attemptSteps[stepPos + 1] += 1 - } - } - endAttempt(level: LevelState): IAttemptInfo { - if ( - !this.currentAttempt || - !this.currentAttempt.solution || - this.attemptStartTime === undefined - ) - throw new Error("The attempt must start before it can end.") - this.currentAttempt.attemptLength = msToProtoTime( - Date.now() - this.attemptStartTime - ) - if (level.gameState !== GameState.WON) { - // If we didn't win, scrap the solution info - delete this.currentAttempt.solution - } - if (level.gameState === GameState.PLAYING) { - // Noop when the attempt is ended prematurely - } else if (level.gameState === GameState.TIMEOUT) { - this.currentAttempt.failReason = "time" - } else if (level.gameState === GameState.DEATH) { - this.currentAttempt.failReason = level.selectedPlayable?.deathReason - } else { - this.currentAttempt.solution!.outcome = { - bonusScore: level.bonusPoints, - timeLeft: msToProtoTime(level.timeLeft * (1000 / 60)), - absoluteTime: msToProtoTime( - (level.timeLeft * 3 + level.subtick) * (1000 / 60) - ), - } - this.currentAttempt.solution!.steps = [ - this.attemptSteps.slice(0, this.currentStep * 2 + 1), - ] - this.currentAttempt.solution!.usedGlitches = level.glitches - } - - return this.currentAttempt - } -} diff --git a/logic/src/const.ts b/logic/src/const.ts deleted file mode 100644 index 7e6f3529..00000000 --- a/logic/src/const.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Actor, tagProperties } from "./actor.js" -import { Direction } from "./helpers.js" -import { LevelState } from "./level.js" - -/** - * An object which matched IDs and and actor classes, is used for loading level actors - */ -export const actorDB: Record< - string, - new ( - level: LevelState, - position: [number, number], - customData?: string, - direction?: Direction - ) => Actor -> = {} - -export const tagFlags: Map = new Map() - -export function makeTagFlagField(tags: string[]): bigint { - if (typeof tags === "bigint") return tags - return tags.reduce((acc, tag) => { - if (tagFlags.has(tag)) return acc | tagFlags.get(tag)! - const flag = BigInt(1) << BigInt(tagFlags.size) - tagFlags.set(tag, flag) - return acc | flag - }, BigInt(0)) -} -export function registerTaggedType(actorType: any): void { - for (const prop of tagProperties.concat(actorType.extraTagProperties ?? [])) { - if (!actorType[prop]) continue - actorType[prop] = makeTagFlagField(actorType[prop]) - } - if (actorType.carrierTags) { - for (const prop in actorType.carrierTags) { - actorType.carrierTags[prop] = makeTagFlagField( - actorType.carrierTags[prop] - ) - } - } -} -export function getTagFlag(tag: string) { - return tagFlags.get(tag) ?? BigInt(0) -} -export function hasTag(actor: Actor, tag: string) { - return !!(actor.tags & getTagFlag(tag)) -} -export function hasTagOverlap(tags1: bigint, tags2: bigint): boolean { - return !!(tags1 & tags2) -} - -/** - * The position of keys to show in the inventory preview - */ -export const keyNameList: string[] = [] -export const cc1BootNameList: string[] = [] - -/** - * A decision an actor can take - */ -export enum Decision { - NONE, - UP, - RIGHT, - DOWN, - LEFT, -} diff --git a/logic/src/helpers.ts b/logic/src/helpers.ts deleted file mode 100644 index 7261dabc..00000000 --- a/logic/src/helpers.ts +++ /dev/null @@ -1,60 +0,0 @@ -export type Field = T[][] -/** - * All the directions, clockwise - */ -export enum Direction { - UP, - RIGHT, - DOWN, - LEFT, -} -export type DirectionString = "UP" | "RIGHT" | "DOWN" | "LEFT" - -const absoluteEnums = [ - { FORWARD: 0, RIGHT: 1, BACKWARD: 2, LEFT: 3 }, - { FORWARD: 1, RIGHT: 2, BACKWARD: 3, LEFT: 0 }, - { FORWARD: 2, RIGHT: 3, BACKWARD: 0, LEFT: 1 }, - { FORWARD: 3, RIGHT: 0, BACKWARD: 1, LEFT: 2 }, -] as const - -/** - * Creates an enum of relative directions from an absolute one - * @param direction The direction to convert - */ -export function relativeToAbsolute( - direction: Direction -): Record<"FORWARD" | "BACKWARD" | "LEFT" | "RIGHT", Direction> { - return absoluteEnums[direction] -} - -/** - * Find neighbors from a center and return them - * @param center The center coordinates of the radius, 0-based - * @param field The field to find neighbors on - * @param radius The radius of range to look for for the neighbors - */ -export function findNeighbors( - center: [number, number], - field: Field, - radius: number -): T[] { - const x = center[0] - const y = center[1] - let neighbors: T[] = [] - if (radius > 0) neighbors = [...findNeighbors(center, field, radius - 1)] - for (let turnAt = radius; turnAt >= -radius; turnAt--) { - neighbors.push(field[y + turnAt][x + Math.abs(turnAt) - radius]) - //To avoid cases when we get duplicate values, when turnAt and radius compromise eachother - if (Math.abs(turnAt) !== radius) - neighbors.push(field[y + turnAt][x - Math.abs(turnAt) + radius]) - } - return neighbors -} - -export function hasOwnProperty( - obj: X, - prop: Y -): obj is X & Record { - // eslint-disable-next-line no-prototype-builtins - return prop in obj -} diff --git a/logic/src/index.ts b/logic/src/index.ts deleted file mode 100644 index 330ee3e3..00000000 --- a/logic/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { actorDB, registerTaggedType } from "./const.js" - -export * from "./level.js" -export * from "./actor.js" -export * from "./tile.js" -export * from "./levelset.js" -export * from "./const.js" -export * from "./helpers.js" -export * from "./parsers/c2m.js" -export * from "./parsers/c2g.js" -export * from "./parsers/nccs.js" -export * from "./actors/index.js" -export * from "./wires.js" -export * from "./attemptTracker.js" -export * from "./inputs.js" - -for (const actor in actorDB) { - registerTaggedType(actorDB[actor]) -} diff --git a/logic/src/inputs.ts b/logic/src/inputs.ts deleted file mode 100644 index d232553d..00000000 --- a/logic/src/inputs.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { Direction } from "./helpers.js" -import { LevelState } from "./level.js" -import { ISolutionInfo } from "./parsers/nccs.pb.js" - -export interface KeyInputs { - up: boolean - down: boolean - left: boolean - right: boolean - drop: boolean - rotateInv: boolean - switchPlayable: boolean -} - -export type InputType = keyof KeyInputs - -export const secondaryActions: InputType[] = [ - "drop", - "rotateInv", - "switchPlayable", -] - -export function decodeSolutionStep(step: number): KeyInputs { - return { - up: (step & 0x1) > 0, - right: (step & 0x2) > 0, - down: (step & 0x4) > 0, - left: (step & 0x8) > 0, - drop: (step & 0x10) > 0, - rotateInv: (step & 0x20) > 0, - switchPlayable: (step & 0x40) > 0, - } -} - -export function encodeSolutionStep(input: KeyInputs): number { - return ( - (input.up ? 0x01 : 0) + - (input.right ? 0x02 : 0) + - (input.down ? 0x04 : 0) + - (input.left ? 0x08 : 0) + - (input.drop ? 0x10 : 0) + - (input.rotateInv ? 0x20 : 0) + - (input.switchPlayable ? 0x40 : 0) - ) -} - -export interface InputProvider { - getInput(level: LevelState): KeyInputs - outOfInput(level: LevelState): boolean - setupLevel(level: LevelState): void -} - -export function makeSimpleInputs(comp: Uint8Array): Uint8Array { - const uncomp: number[] = [] - for (let compIndex = 0; compIndex <= comp.length; compIndex += 2) { - const input = comp[compIndex] - const length = comp[compIndex + 1] - for (let i = 0; i < length; i += 1) { - uncomp.push(input) - } - } - if (comp.length % 2 !== 0) { - uncomp.push(comp[comp.length - 1]) - uncomp.push(comp[comp.length - 1]) - uncomp.push(comp[comp.length - 1]) - } - - return new Uint8Array(uncomp.filter((_, i) => i % 3 === 2)) -} - -export class SolutionInfoInputProvider implements InputProvider { - inputs: Uint8Array - bonusTicks = 3600 - constructor(public solution: ISolutionInfo) { - this.inputs = makeSimpleInputs(solution.steps![0]) - } - getInput(level: LevelState): KeyInputs { - let inputN = - level.currentTick >= this.inputs.length - ? this.inputs.length - 1 - : level.currentTick - return decodeSolutionStep(this.inputs[inputN]) - } - outOfInput(level: LevelState): boolean { - return level.currentTick >= this.inputs.length + this.bonusTicks - } - setupLevel(level: LevelState): void { - const levelState = this.solution.levelState - if (!levelState) return - if (typeof levelState.randomForceFloorDirection === "number") { - level.randomForceFloorDirection = levelState.randomForceFloorDirection - 1 - } - const blobMod = levelState.cc2Data?.blobModifier - if (typeof blobMod === "number") { - level.blobPrngValue = blobMod - } - } -} - -export interface RouteFor { - Set?: string - LevelName?: string - LevelNumber?: number -} - -export interface Route { - Moves: string - Rule: string - Encode?: "UTF-8" - "Initial Slide"?: Direction - /** - * Not the same as "Seed", as Blobmod only affects blobs and nothing else, unlilke the seed in TW, which affects all randomness - */ - Blobmod?: number - // Unused in CC2 - Step?: never - Seed?: never - // NotCC-invented metadata - For?: RouteFor - ExportApp?: string -} - -const keyInputToCharMap: Record = { - up: "u", - right: "r", - down: "d", - left: "l", - switchPlayable: "s", - rotateInv: "c", - drop: "p", -} - -const charToKeyInputMap: Record = { - u: "up", - r: "right", - d: "down", - l: "left", - p: "drop", - c: "rotateInv", - s: "switchPlayable", - "↗": ["up", "right"], - "↘": ["right", "down"], - "↙": ["down", "left"], - "↖": ["left", "up"], -} - -export function areKeyInputsMoving(input: KeyInputs): boolean { - return input.up || input.right || input.down || input.left -} - -export function keyInputToChar( - input: KeyInputs, - uppercase: boolean, - secondaryOnly = false -): string { - let char = "" - for (const keyInput of secondaryActions) { - if (input[keyInput]) { - char += keyInputToCharMap[keyInput] - } - } - if (secondaryOnly) return char - if (input.up && input.right) char += uppercase ? "⇗" : "↗" - else if (input.right && input.down) char += uppercase ? "⇘" : "↘" - else if (input.down && input.left) char += uppercase ? "⇙" : "↙" - else if (input.left && input.up) char += uppercase ? "⇖" : "↖" - else if (input.up) char += uppercase ? "U" : "u" - else if (input.right) char += uppercase ? "R" : "r" - else if (input.down) char += uppercase ? "D" : "d" - else if (input.left) char += uppercase ? "L" : "l" - else char += "-" - return char -} - -export function makeEmptyInputs(): KeyInputs { - return { - up: false, - right: false, - down: false, - left: false, - drop: false, - rotateInv: false, - switchPlayable: false, - } -} - -export function charToKeyInput(char: string): KeyInputs { - const input = makeEmptyInputs() - for (const modChar of char) { - let keyInputs = charToKeyInputMap[modChar] - if (!Array.isArray(keyInputs)) keyInputs = [keyInputs] - for (const keyInput of keyInputs) { - input[keyInput] = true - } - } - return input -} - -export function splitRouteCharString(charString: string): string[] { - return charString.split(/(?= this.moves.length) return makeEmptyInputs() - return charToKeyInput(this.moves[level.currentTick]) - } - outOfInput(level: LevelState): boolean { - return level.currentTick >= this.moves.length - } - setupLevel(level: LevelState): void { - if (!this.route) return - if (this.route["Initial Slide"] !== undefined) { - level.randomForceFloorDirection = this.route["Initial Slide"] - } - if (this.route.Blobmod !== undefined) { - level.blobPrngValue = this.route.Blobmod - } - } -} diff --git a/logic/src/iterableHelpers.ts b/logic/src/iterableHelpers.ts deleted file mode 100644 index a24bd5a3..00000000 --- a/logic/src/iterableHelpers.ts +++ /dev/null @@ -1,73 +0,0 @@ -export function iterableReduce( - iterable: Iterable, - func: (acc: R, val: T, i: number) => R, - defaultValue: R -): R { - let acc = defaultValue, - i = 0 - for (const value of iterable) acc = func(acc, value, i++) - return acc -} - -export function* iterableMap( - iterable: Iterable, - func: (val: S, i: number) => T -): IterableIterator { - let i = 0 - for (const value of iterable) yield func(value, i++) -} - -export function* iterableFilter( - iterable: Iterable, - func: (val: T, i: number) => boolean -): IterableIterator { - let i = 0 - for (const value of iterable) if (func(value, i++)) yield value -} - -export function iterableIncludes( - iterable: Iterable, - searchValue: T -): boolean { - for (const value of iterable) if (value === searchValue) return true - return false -} - -export function iterableFind( - iterable: Iterable, - func: (val: T, i: number) => boolean -): T | null { - let i = 0 - for (const value of iterable) if (func(value, i++)) return value - return null -} - -export function iterableFindIndex( - iterable: Iterable, - func: (val: T, i: number) => boolean -): number { - let i = 0 - for (const value of iterable) if (func(value, i++)) return i - return -1 -} - -export function iterableIndexOf( - iterable: Iterable, - searchValue: T -): number { - let i = 0 - for (const value of iterable) { - if (value === searchValue) return i - i++ - } - return -1 -} - -export function iterableSome( - iterable: Iterable, - func: (val: T, i: number) => boolean -): boolean { - let i = 0 - for (const value of iterable) if (func(value, i++)) return true - return false -} diff --git a/logic/src/level.ts b/logic/src/level.ts deleted file mode 100644 index 80a2810d..00000000 --- a/logic/src/level.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { Actor } from "./actor.js" -import { Field, Direction } from "./helpers.js" -import { Playable } from "./actors/playables.js" -import { Tile } from "./tile.js" -import { Layer } from "./tile.js" -import type { LevelData, CameraType } from "./parsers/c2m.js" -import { actorDB, Decision } from "./const.js" -import { iterableIndexOf, iterableSome } from "./iterableHelpers.js" -import { - buildCircuits, - CircuitCity, - isWired, - Wirable, - wirePretick, - wireTick, -} from "./wires.js" -import { GlitchInfo, IGlitchInfo, ISolutionInfo } from "./parsers/nccs.pb.js" -import { msToProtoTime } from "./attemptTracker.js" -import { InputProvider, KeyInputs } from "./inputs.js" - -export enum GameState { - PLAYING, - DEATH, - TIMEOUT, - WON, -} - -export const onLevelStart: ((level: LevelState) => void)[] = [] -export const onLevelDecisionTick: ((level: LevelState) => void)[] = [] -export const onLevelWireTick: ((level: LevelState) => void)[] = [] -export const onLevelAfterTick: ((level: LevelState) => void)[] = [] - -onLevelDecisionTick.push(level => { - if (!level.selectedPlayable) return - if (level.subtick !== 2) return - if ( - level.selectedPlayable.cooldown > 0 || - level.selectedPlayable.slidingState || - level.selectedPlayable.playerBonked || - level.selectedPlayable.isPushing - ) { - level.selectedPlayable.lastDecision = level.selectedPlayable.direction + 1 - } else { - level.selectedPlayable.lastDecision = Decision.NONE - } -}) - -export interface SfxManager { - playContinuous(sfx: string): void - stopContinuous(sfx: string): void - playOnce(sfx: string): void -} - -/** - * The state of a level, used as a hub of realtime level properties, the most important one being `field` - */ - -export class LevelState { - playables: Playable[] = [] - selectedPlayable?: Playable - gameState = GameState.PLAYING - field: Field = [] - actors: Actor[] = [] - decidingActors: Actor[] = [] - subtick: 0 | 1 | 2 = 0 - currentTick = 0 - tickStage: "decision" | "move" | "wire" | "start" = "start" - cameraType: CameraType = { width: 10, height: 10, screens: 1 } - levelData?: LevelData - hideWires = false - cc1Boots = false - chipsLeft = 0 - chipsTotal = 0 - chipsRequired = 0 - timeLeft = 0 - bonusPoints = 0 - hintsLeft: string[] = [] - defaultHint?: string - hintsLeftInLevel = 0 - playablesLeft = 0 - playablesToSwap = false - levelStarted = false - createdN = 0 - gameInput: KeyInputs = { - up: false, - down: false, - left: false, - right: false, - drop: false, - rotateInv: false, - switchPlayable: false, - } - inputProvider?: InputProvider - releasedKeys: KeyInputs = { - up: false, - right: false, - down: false, - left: false, - drop: false, - rotateInv: false, - switchPlayable: false, - } - /** - * Connections of 2 tiles, used for CC1-style clone machine and trap connections - */ - connections: [[number, number], [number, number]][] = [] - timeFrozen = false - protected decisionTick(forcedOnly = false): void { - onLevelDecisionTick.forEach(val => val(this)) - for (let actor of Array.from(this.decidingActors)) { - while (actor.newActor) actor = actor.newActor - if (!actor.exists) continue - actor._internalDecide(forcedOnly) - } - } - protected moveTick(): void { - for (let actor of Array.from(this.decidingActors)) { - while (actor.newActor) actor = actor.newActor - if (!actor.exists) continue - actor._internalMove() - actor._internalDoCooldown() - } - } - getTime(): string { - return `${this.currentTick}:${this.subtick} (${this.tickStage})` - } - /** - * Ticks the whole level by one subtick - * (Since there are 3 subticks in a tick, and 20 ticks in a second, this should be run 60 times a second) - */ - tick(): void { - if (!this.levelStarted) { - this.initializeLevel() - } else { - if (this.subtick === 2) { - this.currentTick++ - this.subtick = 0 - } else this.subtick++ - } - if (this.timeLeft !== 0 && !this.timeFrozen) { - this.timeLeft-- - if (this.timeLeft <= 1) this.gameState = GameState.TIMEOUT - } - if (this.inputProvider) { - this.gameInput = this.inputProvider.getInput(this) - } - this.releasedKeys = { - up: false, - right: false, - down: false, - left: false, - drop: false, - rotateInv: false, - switchPlayable: false, - } - wirePretick.apply(this) - this.tickStage = "decision" - this.decisionTick(this.subtick !== 2) - this.tickStage = "move" - this.moveTick() - this.tickStage = "wire" - onLevelWireTick.forEach(val => val(this)) - wireTick.apply(this) - // if (this.playables.length === 0) this.lost = true - /*for (const debouncedKey of debouncedInputs) - if (!this.gameInput[debouncedKey]) this.debouncedInputs[debouncedKey] = 0 - else if (this.debouncedInputs[debouncedKey] > 0) { - if (this.debouncedInputs[debouncedKey] === 1) - this.debouncedInputs[debouncedKey]-- - this.debouncedInputs[debouncedKey]-- - } */ - - if (this.gameState === GameState.TIMEOUT) { - if (this.timeLeft > 1) this.gameState = GameState.PLAYING - else this.timeLeft -= 1 - } - if (this.playablesLeft <= 0) { - if (this.gameState === GameState.PLAYING && this.timeLeft > 0) { - this.timeLeft -= 1 - } - this.gameState = GameState.WON - } - onLevelAfterTick.forEach(val => val(this)) - } - /* - debounceInput(inputType: typeof debouncedInputs[number]): void { - if (this.debouncedInputs[inputType] !== -1) - this.debouncedInputs[inputType] = debouncePeriod - } */ - - initializeLevel(): void { - this.inputProvider?.setupLevel(this) - this.levelStarted = true - buildCircuits.apply(this) - for (const actor of Array.from(this.actors)) { - actor.levelStarted?.() - actor.onCreation?.() - } - onLevelStart.forEach(val => val(this)) - } - - constructor( - public width: number, - public height: number - ) { - //Init field - this.field = [] - for (let x = 0; x < width; x++) { - this.field.push([]) - for (let y = 0; y < height; y++) - this.field[x].push(new Tile(this, [x, y], [])) - } - } - - resolvedCollisionCheckDirection: Direction = Direction.UP - prngValue1 = 0 - prngValue2 = 0 - random(): number { - let n = (this.prngValue1 >> 2) - this.prngValue1 - if (!(this.prngValue1 & 0x02)) n-- - this.prngValue1 = ((this.prngValue1 >> 1) | (this.prngValue2 & 0x80)) & 0xff - this.prngValue2 = ((this.prngValue2 << 1) | (n & 0x01)) & 0xff - return this.prngValue1 ^ this.prngValue2 - } - blobPrngValue = 0x55 - blob4PatternsMode = false - blobMod(): number { - let mod = this.blobPrngValue - if (this.blob4PatternsMode) { - mod++ - mod %= 4 - } else { - mod *= 2 - if (mod < 255) mod ^= 0x1d - mod &= 0xff - } - this.blobPrngValue = mod - return mod - } - getHint(): string | null { - if (!this.selectedPlayable) return null - const hintedActor = this.selectedPlayable.tile.findActor( - actor => "hint" in actor && !!actor.hint - ) - if (!hintedActor) return null - return (hintedActor as any).hint - } - forcedPerspective = false - getPerspective(): boolean { - return ( - this.forcedPerspective || - (!!this.selectedPlayable && - this.selectedPlayable.hasTag("can-see-secrets")) - ) - } - *tiles( - rro = true, - relativeTo: [number, number] = [0, 0] - ): Generator { - const stopAt = relativeTo[0] + relativeTo[1] * this.width - for ( - let pos = - (stopAt + (rro ? this.width * this.height - 1 : +1)) % - (this.width * this.height); - pos !== stopAt; - rro - ? (pos = - (pos + this.width * this.height - 1) % (this.width * this.height)) - : (pos = (pos + 1) % (this.width * this.height)) - ) - yield this.field[pos % this.width][Math.floor(pos / this.width)] - yield this.field[relativeTo[0]][relativeTo[1]] - } - circuits: CircuitCity[] = [] - circuitInputs: Actor[] = [] - circuitOutputs: Wirable[] = [] - circuitOutputStates: Map = new Map() - sfxManager: SfxManager | null = null - glitches: IGlitchInfo[] = [] - onGlitch: ((glitch: IGlitchInfo) => void) | null = null - addGlitch(glitch: Omit): void { - const completeGlitch = { - ...glitch, - happensAt: msToProtoTime( - (this.currentTick * 3 + this.subtick) * (1000 / 60) - ), - } - this.glitches.push(completeGlitch) - this.onGlitch?.(completeGlitch) - } - randomForceFloorDirection: Direction = Direction.UP - greenButtonPressed = false - blueButtonPressed = false - currentYellowButtonPress = 0 - despawnedActors: Actor[] = [] -} - -export function createLevelFromData(data: LevelData): LevelState { - const level = new LevelState(data.width, data.height) - level.levelData = data - if (data.hints) level.hintsLeft = [...data.hints] - if (data.defaultHint) level.defaultHint = data.defaultHint - // TODO Misc data setting, like CC1 boots and stuff - if (data.blobMode) { - if (data.blobMode > 1) - level.blobPrngValue = Math.floor(Math.random() * 0x100) - level.blob4PatternsMode = data.blobMode === 4 - } - level.cameraType = data.camera - level.timeLeft = Math.max(0, data.timeLimit * 60) - if (data.playablesRequiredToExit !== "all") - level.playablesLeft = data.playablesRequiredToExit - level.hideWires = !!data.hideWires - level.cc1Boots = !!data.cc1Boots - if (data.extraChipsRequired) level.chipsRequired = data.extraChipsRequired - if (data.connections) level.connections = data.connections - for (let y = 0; y < level.height; y++) - for (let x = 0; x < level.width; x++) - for (const actor of data.field[x][y]) { - if (!actor[0]) { - if (actor[3]) { - const tile = level.field[x][y] - tile.wires = actor[3] & 0x0f - tile.wireTunnels = (actor[3] & 0xf0) >> 4 - } - continue - } - if (!actorDB[actor[0]]) - throw new Error(`Cannot find actor with id "${actor[0]}"!`) - const actorInstance: Actor = new actorDB[actor[0]]( - level, - [x, y], - actor[2], - actor[1] - ) - - if (actor[3]) { - actorInstance.wires = actor[3] & 0x0f - actorInstance.wireTunnels = actor[3] & 0xf0 - } - } - - return level -} diff --git a/logic/src/parsers/autoReader.ts b/logic/src/parsers/autoReader.ts deleted file mode 100644 index fbb2a127..00000000 --- a/logic/src/parsers/autoReader.ts +++ /dev/null @@ -1,82 +0,0 @@ -export default class AutoReadDataView extends DataView { - offset = 0 - smallEndian = true - autoAllocate = true - allocateSize = 1000 - getUint8(): number - getUint8(amount: number): number[] - getUint8(amount: number | null = null): number | number[] { - if (amount === null) return super.getUint8(this.offset++) - const rets: number[] = [] - for (let i = 0; i < amount; i++) { - rets.push(super.getUint8(this.offset)) - this.offset++ - } - return rets - } - getUint8UntilNull(): number[] { - const ret: number[] = [] - while (super.getUint8(this.offset) !== 0 && this.offset < this.byteLength) { - ret.push(this.getUint8()) - } - if (this.offset < this.byteLength) this.offset++ - return ret - } - pushUint8(...values: number[]): void { - for (const i in values) { - super.setUint8(this.offset, values[i] & 0xff) - this.offset++ - } - } - pushUint16(...values: number[]): void { - for (const i in values) { - super.setUint16(this.offset, values[i], this.smallEndian) - this.offset += 2 - } - } - pushInt32(...values: number[]): void { - for (const i in values) { - super.setInt32(this.offset, values[i], this.smallEndian) - this.offset += 4 - } - } - getUint16(): number - getUint16(amount: number): number[] - getUint16(amount: number | null = null): number | number[] { - if (amount === null) { - const retValue = super.getUint16(this.offset, this.smallEndian) - this.offset += 2 - return retValue - } - const rets: number[] = [] - for (let i = 0; i < amount; i++) { - rets.push(super.getUint16(this.offset, this.smallEndian)) - this.offset += 2 - } - return rets - } - getUint32(): number { - const ret = super.getUint32(this.offset, this.smallEndian) - this.offset += 4 - return ret - } - getString(amount: number): string { - let ret = "" - for (let i = 0; i < amount; i++) { - ret += String.fromCharCode(super.getUint8(this.offset)) - this.offset += 1 - } - return ret - } - getStringUntilNull(): string { - let ret = "" - while (super.getUint8(this.offset) !== 0 && this.offset < this.byteLength) { - ret += this.getString(1) - } - if (this.offset < this.byteLength) this.offset++ - return ret - } - skipBytes(amount: number): void { - this.offset += amount - } -} diff --git a/logic/src/parsers/c2m.ts b/logic/src/parsers/c2m.ts deleted file mode 100644 index 3514caba..00000000 --- a/logic/src/parsers/c2m.ts +++ /dev/null @@ -1,602 +0,0 @@ -import AutoReadDataView from "./autoReader.js" -import { Direction, Field } from "../helpers.js" -import data, { cc2Tile } from "./c2mData.js" -import { ISolutionInfo } from "./nccs.pb.js" -import clone from "clone" - -export interface CameraType { - width: number - height: number - screens: number -} - -export interface LevelData { - /** - * The name of the set this belongs to (not present in C2Ms or DATs) - */ - setName?: string - /** - * Name of the level, can be absent - */ - name?: string - /** - * The password used to access the level. Not supported in vanilla CC2 - */ - password?: string - /** - * The field which contains all actors - */ - field: Field<[string | null, Direction?, string?, number?][]> - playablesRequiredToExit: number | "all" - width: number - height: number - /** - * The viewport width/height - */ - camera: CameraType - timeLimit: number - /** - * The blob pattern setting in CC2, 1 by default - */ - blobMode?: 1 | 4 | 256 - hints?: string[] - /** - * If the hint tile didn't get a custom hint, it gets this - */ - defaultHint?: string - note?: string - /** - * The amount of chips to add to the required count beyond the default chip amount. - * Is designed to mostly troll people into thinking there are more chips than there really are - */ - extraChipsRequired?: number - /** - * The clone machine/trap custom connections. Not supported in vanilla CC2 - */ - connections?: [[number, number], [number, number]][] - /** - * The solution for this level - */ - associatedSolution?: ISolutionInfo - // Random misc custom data - customData?: Record - hideWires?: boolean - cc1Boots?: boolean -} - -export type PartialLevelData = Omit< - LevelData, - "field" | "width" | "height" | "playablesRequiredToExit" -> & - Partial - -export function isPartialDataFull( - partial: PartialLevelData -): partial is LevelData { - return ( - !!partial.field && - !!partial.height && - !!partial.width && - !!partial.playablesRequiredToExit - ) -} - -/** - * Gets a bit from a number - * @param number The number to use - * @param bitPosition The position of the bit to use, starts from right - */ -function getBit(number: number, bitPosition: number): boolean { - return (number & (1 << bitPosition)) !== 0 -} - -function createFieldFromArrayBuffer( - fieldData: ArrayBuffer, - size: [number, number] -): LevelData["field"] { - const view = new AutoReadDataView(fieldData) - const field: LevelData["field"] = [] - function parseTile(): cc2Tile[] { - const tileId = view.getUint8() - if (!(tileId in data)) { - throw new Error(`Unknown tile ID ${tileId.toString(16)}`) - } - const tiles = clone(data[tileId]) - - for (let i = 0; i < tiles.length; i++) { - const tile = tiles[i] - if (tile === null) { - tiles.pop() - tiles.push(...parseTile()) - continue - } - if (tile[1] === null) tile[1] = view.getUint8() % 4 - if (tile[2] === null) { - // Handle special cases - switch (tile[0]) { - case "thinWall": { - const options = view.getUint8() - const additions: cc2Tile[] = [] - if (options & 0b11111) - additions.unshift([ - "thinWall", - 0, - "URDLC" - .split("") - .filter((_val, i) => !!((2 ** i) & options)) - .join(""), - ]) - - tiles.splice(tiles.indexOf(tile), 1, ...additions) - break - } - case "directionalBlock": { - const options = view.getUint8() - tile[2] = "" - for (let j = 0; j < 4; j++) - if (getBit(options, j)) tile[2] += "URDL"[j] - break - } - // By default custom tiles are green - case "customFloor": - case "customWall": - tile[2] = "green" - break - // By default letter tiles have a space - case "letterTile": - tile[2] = " " - break - case "modifier8": - case "modifier16": - case "modifier32": { - let options - if (tile[0] === "modifier8") options = view.getUint8() - else if (tile[0] === "modifier16") options = view.getUint16() - else options = view.getUint32() - - const modTiles = parseTile() - tiles.splice(tiles.indexOf(tile), 1) - switch (modTiles[0]?.[0]) { - case undefined: - case "steelWall": - case "toggleSwitch": - case "transmogrifier": - case "teleportRed": - case "teleportBlue": - case "buttonPurple": - case "buttonBlack": - if (!modTiles[0]) modTiles[0] = [null] - modTiles[0][3] = options - tiles.unshift(...modTiles) - break - case "letterTile": - if (options >= 0x1c && options <= 0x1f) - modTiles[0][2] = Direction[options - 0x1c] - else if (options >= 0x20 && options <= 0x5f) - modTiles[0][2] = String.fromCharCode(options) - else throw new Error("Invalid letter tile!") - tiles.unshift(...modTiles) - break - case "cloneMachine": { - modTiles[0][2] = "" - for (let j = 0; j < 4; j++) - if (getBit(options, j)) modTiles[0][2] += "URDL"[j] - tiles.unshift(...modTiles) - break - } - case "customFloor": - case "customWall": - modTiles[0][2] = ["green", "pink", "yellow", "blue"][ - options % 4 - ] - if (modTiles[0][2] === undefined) - throw new Error("Invalid custom wall/floor!") - tiles.unshift(...modTiles) - break - case "notGate": { - // Map of the logic gate space (ranged inclusive): - // 0x00 - 0x17 - Most logic gates - // 0x18 - 0x1D - Voodoo tile - nothing rendered - // 0x1E - 0x27 - Counters - // 0x28 - 0x3F - Glitched counters - // 0x40 - 0x43 - CCW latches - // 0x44 - 0xE7 - Voodoo tiles - corresponding tileset index - // 0xE8 - 0xFFFFFFFF - "Nothing" - if (options <= 0x17) { - modTiles[0][0] = ( - [ - "notGate", - "andGate", - "orGate", - "xorGate", - "latchGate", - "nandGate", - ] as const - )[(options & ~0b11) >> 2] - modTiles[0][1] = options & 0x3 - } else if (options <= 0x1d) { - modTiles[0][0] = "voodooTile" - modTiles[0][2] = "" - } else if (options <= 0x27) { - modTiles[0][0] = "counterGate" - modTiles[0][2] = (options - 0x1e).toString() - } else if (options <= 0x3f) { - throw new Error("Glitched counter gates not supported") - } else if (options <= 0x43) { - modTiles[0][0] = "latchGateMirror" - modTiles[0][1] = options - 0x40 - } else if (options <= 0xe7) { - modTiles[0][0] = "voodooTile" - modTiles[0][2] = (options + 0x118).toString() - } else { - // Let's assume it's rendered as nothing, for now - modTiles[0][0] = "voodooTile" - modTiles[0][2] = "" - } - tiles.push(...modTiles) - break - } - case "railroad": { - modTiles[0][2] = "" - const activeTrack = (options >> 8) - (options >> 12) * 0x10 - if (activeTrack >= 6) - throw new Error( - `Railroad's active track is invalid! Expected value to be less than 6, got ${activeTrack}` - ) - modTiles[0][2] += activeTrack - // Initial direction is mod 4 - modTiles[0][2] += (options >> 12) % 4 - for (let i = 0; i < 6; i++) - if (getBit(options, i)) modTiles[0][2] += i.toString() - if (getBit(options, 6)) modTiles[0][2] += "s" - tiles.push(...modTiles) - break - } - default: - console.warn( - `Found a modifier on an unrelated actor "${tiles[0]}"` - ) - tiles.push(...modTiles) - break - } - break - } - default: - throw new Error( - `(Internal) Bad c2mData.ts provided! (Tile with 2 null without special code: ${JSON.stringify( - tile - )})` - ) - } - } - } - - return tiles - } - for (let x = 0; x < size[0]; x++) { - for (let y = 0; y < size[1]; y++) { - if (x === 0) field.push([]) - field[y][x] = parseTile() - } - } - return field -} - -function createSolutionFromArrayBuffer( - solutionData: ArrayBuffer -): ISolutionInfo { - const solution: ISolutionInfo = { levelState: { cc2Data: {} } } - // Our format is mostly the same as CC2, but without the metadata and a - // different key order. - const view = new AutoReadDataView(solutionData) - - // Use unknown - view.skipBytes(1) - - solution.levelState!.randomForceFloorDirection = (view.getUint8() % 4) + 1 - solution.levelState!.cc2Data!.blobModifier = view.getUint8() - - // Use unknown - view.skipBytes(1) - - // Set the maximum length of the solution; the unneeded zeros are trimmed later - let steps = new Uint8Array(solutionData.byteLength - 4) - let currentStep = 0 - - while (view.offset < view.buffer.byteLength) { - const newInput = view.getUint8() - - const holdTime = - solutionData.byteLength === view.offset ? Infinity : view.getUint8() - if (holdTime === 0xff) break - if (holdTime === 0x00) continue - - const resolvedInput = - (newInput & 0x10) / 0x10 + // Up - (newInput & 0x8) / 0x4 + // Right - (newInput & 0x2) * 0x2 + // Down - (newInput & 0x4) * 0x2 + // Left - (newInput & 0x1) * 0x10 + // Drop item - (newInput & 0x40) / 0x2 + // Cycle items - (newInput & 0x20) * 0x2 // Switch playable - - steps[currentStep * 2] = resolvedInput - // The unset time should indicate to hold this input forever - if (holdTime !== Infinity) { - steps[currentStep * 2 + 1] = holdTime - currentStep += 1 - } else { - // Half step so that the trimming function doesn't lose the infinite key - currentStep += 0.5 - } - } - steps = steps.slice(0, currentStep * 2) - solution.steps = [steps] - return solution -} - -export function unpackagePackedData(buff: ArrayBuffer): ArrayBuffer { - const view = new AutoReadDataView(buff) - const totalLength = view.getUint16() - const newBuff = new ArrayBuffer(totalLength) - const newView = new AutoReadDataView(newBuff) - while (newView.offset < totalLength && view.offset + 1 < buff.byteLength) { - const length = view.getUint8() - if (length < 0x80) { - // Data block - newView.pushUint8(...view.getUint8(length)) - } else { - // Back-reference block - const amount = length - 0x80 - const offset = view.getUint8() - if (offset > newView.offset) throw new Error("Invalid compressed buffer!") - if (offset === 0) { - // Let's just skip these bytes, since it's all 0 anyways - newView.skipBytes(amount) - } else { - for (let copied = 0; copied < amount; ) { - const copyAmount = Math.min(amount - copied, offset) - // Go back ~~in time~~ - newView.skipBytes(-offset - copied) - // Get the bytes - const bytes = newView.getUint8(copyAmount) - // Return - newView.skipBytes(offset - copyAmount + copied) - newView.pushUint8(...bytes) - copied += copyAmount - } - } - } - } - return newBuff -} - -export function packageData(buff: ArrayBuffer): ArrayBuffer | null { - const view = new AutoReadDataView(buff) - // It's easier to navigate with a raw uint8 array in some cases - const arr = new Uint8Array(buff) - const newBuff = new ArrayBuffer(buff.byteLength) - const newView = new AutoReadDataView(newBuff) - newView.pushUint16(buff.byteLength) - - const pendingData: number[] = [] - - while (view.offset < view.byteLength) { - let refOff = -1, - refLength = -1 - for ( - let refCheckOff = Math.max(0, view.offset - 0x80); - refCheckOff !== view.offset; - refCheckOff++ - ) { - let refCheckLength = 0, - targetOffset = view.offset - // Count the amount of bytes we can reference at once - while ( - arr[refCheckOff + refCheckLength] === arr[targetOffset] && - // The limit of references - targetOffset - view.offset < 0x7f - ) { - refCheckLength++ - targetOffset++ - } - // If this is the best reference yet, remember it - if (refCheckLength > refLength) { - refLength = refCheckLength - refOff = refCheckOff - } - } - // If this data does not have a good reference, add it to the unreferenced data pile - if (refLength <= 1) pendingData.push(view.getUint8()) - - // If we are at the non-reference limit or we are about to do a reference, push all of the unreferenced data - // (note that this doesn't influence the reference pointer since pointers reference the source array, while this is pushing to the packed array) - if (pendingData.length >= 0x7f || (refLength > 1 && pendingData.length)) { - newView.pushUint8(pendingData.length, ...pendingData) - pendingData.length = 0 - } - // Do the reference - if (refLength > 1) { - newView.pushUint8(refLength + 0x80, view.offset - refOff) - view.skipBytes(refLength) - } - // If we cannot afford to add the pending data, bail - if (newView.byteLength - newView.offset < pendingData.length + 1) - return null - } - if (pendingData.length) newView.pushUint8(pendingData.length, ...pendingData) - return newBuff.slice(0, newView.offset) -} - -export function parseC2M(buff: ArrayBuffer): LevelData { - const view = new AutoReadDataView(buff) - const data: PartialLevelData = { - camera: { - height: 10, - width: 10, - screens: 1, - }, - timeLimit: 0, - blobMode: 4, - playablesRequiredToExit: "all", - } - - const OPTNFuncs = [ - () => { - data.timeLimit = view.getUint16() - }, - () => { - const camMode = view.getUint8() - switch (camMode) { - case 0: - data.camera = { - height: 10, - width: 10, - screens: 1, - } - break - case 1: - data.camera = { - height: 9, - width: 9, - screens: 1, - } - break - case 2: - data.camera = { width: 10, height: 10, screens: 2 } - break - default: - throw new Error("Invalid camera mode!") - } - }, - () => view.skipBytes(1), // Solution verified? - () => view.skipBytes(1), // Hide map in editor? - () => view.skipBytes(1), // Map is readonly? - () => { - // TODO Actually verify this hash - view.getString(16) - }, - () => { - data.hideWires = view.getUint8() === 1 - }, - () => { - data.cc1Boots = view.getUint8() === 1 - }, - () => { - data.blobMode = ([1, 4, 256] as const)[view.getUint8()] - }, - ] - let preventInvalidity = false - loop: while (view.offset < view.byteLength) { - const sectionName = view.getString(4) - const length = view.getUint32() - const oldOffset = view.offset - switch (sectionName) { - case "CC2M": - if (view.offset === 8) preventInvalidity = true - else throw new Error("The CC2M header must be first!") - if (parseInt(view.getStringUntilNull()) > 7) - throw new Error("Invalid file! (CC2M version is >7)") - break - case "TITL": - data.name = view.getStringUntilNull() - break - case "AUTH": - // Discard (temp) - view.getStringUntilNull() - break - case "CLUE": - data.defaultHint = view.getStringUntilNull() - break - case "NOTE": { - let note = view.getStringUntilNull() - let noteSectionMode: "NOTE" | "CLUE" | "COM" | "JETLIFE" = "NOTE" - while (note.length > 0) { - const nextSection = /\[(JETLIFE|CLUE|COM)\]/.exec(note) - let noteSectionData: string - if (nextSection) noteSectionData = note.substr(0, nextSection.index) - else noteSectionData = note - switch (noteSectionMode) { - case "NOTE": - data.note = noteSectionData - break - case "CLUE": - data.hints ??= [] - data.hints.push(noteSectionData) - break - case "COM": // TODO C2M Inline code - break - //throw new Error("[COM] not supported (yet)!") - case "JETLIFE": - data.customData ??= {} - if (!isNaN(parseInt(noteSectionData, 10))) - data.customData.jetlife = noteSectionData - break - } - note = note.substr( - noteSectionData.length + (nextSection?.[0].length ?? 0) - ) - noteSectionMode = (nextSection?.[1] ?? "NOTE") as - | "NOTE" - | "CLUE" - | "COM" - | "JETLIFE" - } - break - } - case "OPTN": - for (let i = 0; view.offset < oldOffset + length; i++) OPTNFuncs[i]() - break - case "PACK": - case "MAP ": { - let levelData = buff.slice(view.offset, view.offset + length) - if (sectionName === "PACK") levelData = unpackagePackedData(levelData) - view.skipBytes(length) - const [width, height] = new Uint8Array(levelData) - ;[data.width, data.height] = [width, height] - data.field = createFieldFromArrayBuffer(levelData.slice(2), [ - height, - width, - ]) - break - } - case "PRPL": - case "REPL": { - let solutionData = buff.slice(view.offset, view.offset + length) - if (sectionName === "PRPL") - solutionData = unpackagePackedData(solutionData) - // This can just happen sometimes (zero-length solution). I don't know why - if (solutionData.byteLength !== 0) { - data.associatedSolution = createSolutionFromArrayBuffer(solutionData) - } - view.skipBytes(length) - break - } - case "LOCK": - // Discard - view.getStringUntilNull() - break - case "KEY ": - // Discard - view.skipBytes(16) - break - case "END ": - break loop - case "BMP ": - case "CBMP": { - let bmpData = buff.slice(view.offset, view.offset + length) - if (sectionName === "CBMP") bmpData = unpackagePackedData(bmpData) - view.skipBytes(length) - break - } - default: - view.skipBytes(length) - } - if (!preventInvalidity) throw new Error("The CC2M header must be first!") - if (oldOffset + length !== view.offset) - throw new Error("Offsets don't match up!") - } - if (!isPartialDataFull(data)) - throw new Error("This level is missing essential properties!") - return data -} diff --git a/logic/src/parsers/c2mData.ts b/logic/src/parsers/c2mData.ts deleted file mode 100644 index 70a0e3eb..00000000 --- a/logic/src/parsers/c2mData.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Direction } from "../helpers.js" - -const cc2Tiles = [ - [["voodooTile", 0, "0"]], - [], - [["wall"]], - [["ice"]], - [["iceCorner", 2]], - [["iceCorner", 3]], - [["iceCorner"]], - [["iceCorner", 1]], - [["water"]], - [["fire"]], - [["forceFloor"]], - [["forceFloor", 1]], - [["forceFloor", 2]], - [["forceFloor", 3]], - [["toggleWall", 0, "on"]], - [["toggleWall", 0, "off"]], - [["teleportRed"]], - [["teleportBlue"]], - [["teleportYellow"]], - [["teleportGreen"]], - [["exit"]], - [["slime"]], - [["chip", null], null], - [["dirtBlock", null], null], - [["walker", null], null], - [["glider", null], null], - [["iceBlock", null], null], - [["thinWall", 0, "D"], null], - [["thinWall", 0, "R"], null], - [["thinWall", 0, "RD"], null], - [["gravel"]], - [["buttonGreen"]], - [["buttonBlue"]], - [["tankBlue", null], null], - [["doorRed"]], - [["doorBlue"]], - [["doorYellow"]], - [["doorGreen"]], - [["keyRed"], null], - [["keyBlue"], null], - [["keyYellow"], null], - [["keyGreen"], null], - [["echip"], null], - [["echipPlus"], null], - [["echipGate"]], - [["popupWall"]], - [["appearingWall"]], - [["invisibleWall"]], - [["blueWall", 0, "real"]], - [["blueWall", 0, "fake"]], - [["dirt"]], - [["ant", null], null], - [["centipede", null], null], - [["ball", null], null], - [["blob", null], null], - [["teethRed", null], null], - [["fireball", null], null], - [["buttonRed"]], - [["buttonBrown"]], - [["bootIce"], null], - [["bootForceFloor"], null], - [["bootFire"], null], - [["bootWater"], null], - [["thiefTool"]], - [["bomb"], null], - [["trap", 0, "open"]], - [["trap"]], - [["cloneMachine", 0, "cc1"]], - [["cloneMachine"]], - [["hint"]], - [["forceFloorRandom"]], - [["buttonGray"]], - [["swivel", 2]], - [["swivel", 3]], - [["swivel"]], - [["swivel", 1]], - [["timeBonus"], null], - [["timeToggle"], null], - [["transmogrifier"]], - [["railroad"]], // Oh no - [["steelWall"]], - [["tnt"], null], - [["helmet"], null], - [["unknown"]], - [["unknown"]], - [["unknown"]], - [["melinda", null], null], - [["teethBlue", null], null], - [["unknown"]], - [["bootDirt"], null], - [["noMelindaSign"]], - [["noChipSign"]], - [["notGate"]], // Custom stuff - [["unknown"]], - [["buttonPurple"]], - [["flameJet", 0, "off"]], - [["flameJet", 0, "on"]], - [["buttonOrange"]], - [["lightningBolt"], null], - [["tankYellow", null], null], - [["complexButtonYellow"]], - [["mirrorChip", null], null], - [["mirrorMelinda", null], null], - [["unknown"]], - [["bowlingBall"], null], - [["rover", null], null], - [["timePenalty"], null], - [["customFloor", 0, null]], - [["unknown"]], - [["thinWall", 0, null], null], - [["unknown"]], - [["railroadSign"], null], - [["customWall", 0, null]], - [["letterTile", 0, null]], - [["holdWall", 0, "off"]], - [["holdWall", 0, "on"]], - [["unknown"]], - [["unknown"]], - [["modifier8", 0, null]], - [["modifier16", 0, null]], - [["modifier32", 0, null]], - [["unknown"]], - [["bonusFlag", 0, "10"], null], - [["bonusFlag", 0, "100"], null], - [["bonusFlag", 0, "1000"], null], - [["greenWall", 0, "real"]], - [["greenWall", 0, "fake"]], - [["noSign"], null], - [["bonusFlag", 0, "*2"], null], - [["directionalBlock", null, null], null], - [["floorMimic", null], null], - [["greenBomb", 0, "bomb"], null], - [["greenBomb", 0, "echip"], null], - [["unknown"]], - [["unknown"]], - [["buttonBlack"]], - [["toggleSwitch", 0, "off"]], - [["toggleSwitch", 0, "on"]], - [["thiefKey"]], - [["ghost", null], null], - [["foil"], null], - [["turtle"]], - [["secretEye"], null], - [["bribe"], null], - [["bootSpeed"], null], - [["unknown"]], - [["hook"], null], -] as const - -export type cc2TileNames = - | Exclude<(typeof cc2Tiles)[number][0], undefined>[0] - // Indirect tile additions - | "canopy" - | "andGate" - | "orGate" - | "xorGate" - | "latchGate" - | "counterGate" - | "nandGate" - | "latchGateMirror" - | "combinationTile" - | "voodooTile" - // Yep, used for floor wires - | null -export type cc2Tile = [cc2TileNames, Direction?, string?, number?] - -export default cc2Tiles as unknown as cc2Tile[][] diff --git a/logic/src/parsers/nccs.ts b/logic/src/parsers/nccs.ts deleted file mode 100644 index bf86a4ee..00000000 --- a/logic/src/parsers/nccs.ts +++ /dev/null @@ -1,50 +0,0 @@ -import AutoReadDataView from "./autoReader.js" -import { ISetInfo, SetInfo } from "./nccs.pb.js" - -export * as protobuf from "./nccs.pb.js" - -const MAGIC_STRING = "NCCS" -const NCCS_VERSION = "1.0" - -export function writeNCCS(saveData: ISetInfo): Uint8Array { - const wireData = SetInfo.encode(saveData).finish() - - // The magic string + version - const headerData = Uint8Array.from( - `${MAGIC_STRING}todo${NCCS_VERSION}\u{0}`, - char => char.charCodeAt(0) - ) - - headerData.set( - // Get the length of the current version text, and write it as a uint32 spread across bytes - new Uint8Array(new Uint32Array([NCCS_VERSION.length + 1]).buffer), - 4 - ) - - const finalData = new Uint8Array(wireData.length + headerData.length) - finalData.set(headerData) - finalData.set(wireData, headerData.byteLength) - - return finalData -} - -export function parseNCCS(data: ArrayBuffer): ISetInfo { - const view = new AutoReadDataView(data) - const magicText = view.getString(4) - if (magicText !== MAGIC_STRING) throw new Error("Missing magic string") - - const versionLength = view.getUint32() - const versionValue = view.getStringUntilNull() - // +1 is for the null byte - if (versionLength !== versionValue.length + 1) - throw new Error("Wrong header length") - const [major] = versionValue.split(".").map(segment => parseInt(segment, 10)) - if (major < 1) - throw new Error("Pre-release versions of NCCS aren't supported") - if (major > 1) - throw new Error( - `NCCS too new - parser version ${NCCS_VERSION}, file version ${versionValue}` - ) - const setInfo = SetInfo.decode(new Uint8Array(view.buffer.slice(view.offset))) - return setInfo -} diff --git a/logic/src/tile.ts b/logic/src/tile.ts deleted file mode 100644 index 9ac164fd..00000000 --- a/logic/src/tile.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Actor } from "./actor.js" -import { LevelState } from "./level.js" -import { Direction } from "./helpers.js" -import { CircuitCity, Wirable, WireOverlapMode, Wires } from "./wires.js" -import { GlitchInfo } from "./parsers/nccs.pb.js" - -export enum Layer { - STATIONARY, // Terrain, etc. - ITEM, // All item-eque things: Bombs, echips, boots, keys, etc. - ITEM_SUFFIX, // No sign, etc. - MOVABLE, // Blocks, Players, Monsters - SPECIAL, // Thin walls, canopies, etc. -} - -export class Tile implements Wirable { - protected *getAllLayers(): IterableIterator { - if (this[Layer.STATIONARY]) yield this[Layer.STATIONARY] - if (this[Layer.ITEM]) yield this[Layer.ITEM] - if (this[Layer.ITEM_SUFFIX]) yield this[Layer.ITEM_SUFFIX] - if (this[Layer.MOVABLE]) yield this[Layer.MOVABLE] - if (this[Layer.SPECIAL]) yield this[Layer.SPECIAL] - } - protected *getAllLayersReverse(): IterableIterator { - if (this[Layer.SPECIAL]) yield this[Layer.SPECIAL] - if (this[Layer.MOVABLE]) yield this[Layer.MOVABLE] - if (this[Layer.ITEM_SUFFIX]) yield this[Layer.ITEM_SUFFIX] - if (this[Layer.ITEM]) yield this[Layer.ITEM] - if (this[Layer.STATIONARY]) yield this[Layer.STATIONARY] - } - get allActors(): IterableIterator { - return this.getAllLayers() - } - get allActorsReverse(): IterableIterator { - return this.getAllLayersReverse() - } - [Layer.STATIONARY]?: Actor; - [Layer.ITEM]?: Actor; - [Layer.ITEM_SUFFIX]?: Actor; - [Layer.MOVABLE]?: Actor; - [Layer.SPECIAL]?: Actor - x: number - y: number - constructor( - public level: LevelState, - public position: [number, number], - actors: Actor[] - ) { - ;[this.x, this.y] = position - this.addActors(actors) - } - findActor(func: (val: Actor, i: number) => boolean): Actor | null { - let i = 0 - // Handle the per-layer situation first - for (const actor of this.getAllLayers()) if (func(actor, i++)) return actor - return null - } - hasLayer(layer: Layer): boolean { - return layer in this - } - /** - * Adds new actors to the tile, sorting everything automatically - */ - addActors(actors: Actor | Actor[]): void { - actors = actors instanceof Array ? actors : [actors] - for (const actor of actors) { - const layer = actor.layer - if (!this[layer]) this[layer] = actor - else { - this[layer]!.despawned = true - this.level.despawnedActors.push(this[layer]!) - this[layer] = actor - this.level.addGlitch({ - glitchKind: GlitchInfo.KnownGlitches.DESPAWN, - location: { x: this.x, y: this.y }, - specifier: 1, - }) - } - } - } - removeActors(actors: Actor | Actor[]): void { - actors = actors instanceof Array ? actors : [actors] - - for (const actor of actors) { - const theLayer = this[actor.layer] - // Ignore attempts to remove a non-existant actor - if (!theLayer) continue - if (theLayer !== actor) { - this.level.addGlitch({ - glitchKind: GlitchInfo.KnownGlitches.DESPAWN, - location: { x: this.x, y: this.y }, - specifier: 2, - }) - theLayer.despawned = true - this.level.despawnedActors.push(theLayer) - } - delete this[actor.layer] - } - } - getNeighbor(direction: Direction, wrap = true): Tile | null { - switch (direction) { - case Direction.UP: - return ( - this.level.field[this.position[0]]?.[this.position[1] - 1] ?? null - ) - case Direction.LEFT: - if (this.x === 0 && wrap) - return this.level.field[this.level.width - 1]?.[this.y - 1] ?? null - return ( - this.level.field[this.position[0] - 1]?.[this.position[1]] ?? null - ) - case Direction.DOWN: - return ( - this.level.field[this.position[0]]?.[this.position[1] + 1] ?? null - ) - case Direction.RIGHT: - if (this.x === this.level.width - 1 && wrap) - return this.level.field[0]?.[this.y + 1] ?? null - return ( - this.level.field[this.position[0] + 1]?.[this.position[1]] ?? null - ) - } - } - getSpeedMod(other: Actor): number { - let speedMod = 1 - for (const actor of this.getAllLayers()) - if (actor.speedMod && !actor._internalIgnores(other)) - speedMod *= actor.speedMod(other) - return speedMod - } - *getDiamondSearch(level: number): IterableIterator { - const offsets = [ - [-1, -1], - [-1, 1], - [1, 1], - [1, -1], - ] as const - const targets = [ - [0, -level], - [-level, 0], - [0, level], - [level, 0], - ] as const - for ( - let currOffset = [level, 0], currTarget = 0; - true; - currOffset[0] += offsets[currTarget][0], - currOffset[1] += offsets[currTarget][1] - ) { - if ( - currOffset[0] === targets[currTarget][0] && - currOffset[1] === targets[currTarget][1] - ) - currTarget++ - if (currTarget === 4) break - const tile = - this.level.field[this.x + currOffset[0]]?.[this.y + currOffset[1]] - if (tile) yield tile - } - } - wires = 0 - poweredWires = 0 - wireTunnels = 0 - circuits?: [CircuitCity?, CircuitCity?, CircuitCity?, CircuitCity?] - wireOverlapMode: WireOverlapMode = WireOverlapMode.CROSS - poweringWires = 0 -} diff --git a/logic/src/wires.ts b/logic/src/wires.ts deleted file mode 100644 index 254c08a3..00000000 --- a/logic/src/wires.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { Direction } from "./helpers.js" -import { Actor } from "./actor.js" -import { LevelState } from "./level.js" -import { Tile, Layer } from "./tile.js" -import { iterableFindIndex } from "./iterableHelpers.js" - -export interface Wirable { - wires: number - poweredWires: number - wireTunnels: number - circuits?: [CircuitCity?, CircuitCity?, CircuitCity?, CircuitCity?] - wireOverlapMode: WireOverlapMode - poweringWires: number - pulse?(actual: boolean): void - unpulse?(): void - processOutput?(): void - listensWires?: boolean - providesPower?: boolean - requiresFullConnect?: boolean -} - -function getWireableTile(wirable: Wirable) { - if (wirable instanceof Actor) return wirable.tile - else if (wirable instanceof Tile) return wirable - throw new Error("") -} - -const sortRRO = (level: LevelState) => (a: Wirable, b: Wirable) => - -( - getWireableTile(a).x + - getWireableTile(a).y * level.width - - getWireableTile(b).x - - getWireableTile(b).y * level.width - ) - -export enum Wires { - UP = 1, - RIGHT = 2, - DOWN = 4, - LEFT = 8, -} - -export enum WireOverlapMode { - OVERLAP, - CROSS, - ALWAYS_CROSS, - NONE, -} - -export const wireToDir: (wire: Wires) => Direction = Math.log2 -export const dirToWire = (wire: Direction): Wires => 2 ** wire -export function getWireMask(wirable: Wirable, dir: Wires): Wires { - switch (wirable.wireOverlapMode) { - case WireOverlapMode.NONE: - return dir - case WireOverlapMode.CROSS: - case WireOverlapMode.ALWAYS_CROSS: - if ( - wirable.wires === 0b1111 || - wirable.wireOverlapMode === WireOverlapMode.ALWAYS_CROSS - ) - return dir >= 0b0100 ? dir | (dir >> 2) : dir | (dir << 2) - - /* fallthrough */ - case WireOverlapMode.OVERLAP: - return 0b1111 - } -} - -export interface CircuitCity { - /** - * All wirables, including neighbours which don't have wires - */ - population: Map - outputs: Wirable[] -} - -export function getTileWirable(tile: Tile): Wirable { - for (const actor of tile.allActors) - if ( - actor.wires || - (actor.layer === Layer.STATIONARY && - tile.wires === 0 && - tile.wireTunnels === 0) - ) - return actor - return tile -} - -function getMatchingTunnel(tile: Tile, direction: Direction): Wirable | null { - let depth = 0 - const wires = dirToWire(direction) - const oppositeWires = dirToWire((direction + 2) % 4) - for ( - let newTile = tile.getNeighbor(direction, false); - newTile !== null; - newTile = newTile.getNeighbor(direction, false) - ) { - const tileWirable = getTileWirable(newTile) - if (tileWirable.wireTunnels & oppositeWires) { - if (depth === 0) return tileWirable - else depth-- - } - if (tileWirable.wireTunnels & wires) depth++ - } - return null -} - -function traceCircuit(base: Wirable, direction: Direction): CircuitCity { - // TypeScript: Allows 123 as Wires. Also TypeScript: Wires and 123 have no overlap - // Not a map since we need to queue up multiple directions with the same wirable - const TODOstack: [Wirable, Wires][] = [] - const baseWireMask = getWireMask(base, dirToWire(direction)) - for (let i = Wires.UP; i <= Wires.LEFT; i *= 2) - if (baseWireMask & i) TODOstack.push([base, i]) - - const circuit: CircuitCity = { - outputs: [], - population: new Map(), - } - if (base.pulse || base.unpulse || base.listensWires || base.processOutput) - circuit.outputs.push(base) - while (TODOstack.length > 0) { - const [wirable, direction] = TODOstack.shift() as [Wirable, Wires] - const registeredWires = circuit.population.get(wirable) - if (registeredWires && registeredWires & direction) continue - if (registeredWires) - circuit.population.set(wirable, registeredWires | direction) - else circuit.population.set(wirable, direction) - if (!(wirable.wires & direction)) continue - let thisTile: Tile - if (wirable instanceof Actor) thisTile = wirable.tile - else if (wirable instanceof Tile) thisTile = wirable - else throw new Error("Um, this shouldn't happen") - let newWirable: Wirable | null - if (wirable.wireTunnels & direction) - newWirable = getMatchingTunnel(thisTile, wireToDir(direction)) - else { - const neigh = thisTile.getNeighbor(wireToDir(direction)) - if (neigh) newWirable = getTileWirable(neigh) - else continue - const entraceWire = dirToWire((wireToDir(direction) + 2) % 4) - if (newWirable.wireTunnels & entraceWire) continue - } - const entraceWire = dirToWire((wireToDir(direction) + 2) % 4) - if (!newWirable) continue - if ( - newWirable.pulse || - newWirable.unpulse || - newWirable.listensWires || - newWirable.processOutput - ) - circuit.outputs.push(newWirable) - if (!(newWirable.wires & entraceWire)) { - if (newWirable.requiresFullConnect) continue - const newRegisteredWires = circuit.population.get(newWirable) - - // Welp, the journey of this no-wire wirable ends here, - // but we still record it because clone machines - // and stuff use wires as input even when they - // don't have the wires themselves - if (newRegisteredWires) - circuit.population.set(newWirable, newRegisteredWires | entraceWire) - else circuit.population.set(newWirable, entraceWire) - continue - } - const newWireMask = getWireMask(newWirable, entraceWire) - for (let i = Wires.UP; i <= Wires.LEFT; i *= 2) - if (newWirable.wires & i && newWireMask & i) - TODOstack.push([newWirable, i]) - } - return circuit -} - -export function buildCircuits(this: LevelState): void { - for (const tile of this.tiles()) { - const wirable = getTileWirable(tile) - if (!wirable.wires) continue - tileLoop: for (let i = Wires.UP; i <= Wires.LEFT; i *= 2) { - if ( - !(wirable.wires & i) || - this.circuits.some( - val => - iterableFindIndex( - val.population.entries(), - val => - val[0] === wirable && (val[1] & getWireMask(wirable, i)) > 0 - ) >= 0 - ) - ) - continue tileLoop - const circuit: CircuitCity = traceCircuit(wirable, wireToDir(i)) - this.circuits.push(circuit) - for (const wirable of circuit.population) { - if (!wirable[0].circuits) wirable[0].circuits = [] - for (let i = Direction.UP; i <= Wires.LEFT; i++) { - if (dirToWire(i) & wirable[1]) { - wirable[0].circuits[i] = circuit - } - } - } - } - } - this.circuitInputs = this.actors.filter(val => val.providesPower) - this.circuitOutputs = this.circuits - .reduce((acc, val) => acc.concat(val.outputs), []) - .filter((val, i, arr) => arr.indexOf(val) === i) - .sort(sortRRO(this)) - this.circuitOutputStates = new Map() - - for (const actor of this.actors) actor.wired = isWired(actor) -} - -export function wirePretick(this: LevelState): void { - if (!this.circuits.length) return - // Step 3 (of last wire tick). Notify outputs for pulses/unpulses - for (const [output, wasPowered] of this.circuitOutputStates) { - if (wasPowered && !output.poweredWires && output.unpulse) output.unpulse() - else if (!wasPowered && output.poweredWires && output.pulse) - output.pulse(true) - } -} - -// TODO Optimize this -export function wireTick(this: LevelState): void { - if (!this.circuits.length) return - // Step 1. Let all inputs calcuate output - for (const actor of Array.from(this.circuitInputs)) { - if (!actor.exists) { - this.circuitInputs.splice(this.circuitInputs.indexOf(actor), 1) - } - actor.updateWires?.() - } - // Also, save all wire states, for pulse detection - for (const output of this.circuitOutputs) - this.circuitOutputStates.set(output, !!output.poweredWires) - // Step 2. Update circuits to be powered, based on the powering population - for (const circuit of this.circuits) { - let circuitPowered = false - for (const actor of circuit.population) - if (actor[0].poweringWires & actor[1]) { - circuitPowered = true - break - } - - for (const actor of circuit.population) - if (circuitPowered) actor[0].poweredWires |= actor[1] - else actor[0].poweredWires &= ~actor[1] - } - for (const output of this.circuitOutputs) output.processOutput?.() -} - -export function isWired(actor: Actor): boolean { - for (let i = 0; i < 4; i++) { - if (actor.wireTunnels & dirToWire(i)) continue - const neigh = actor.tile.getNeighbor(i) - if (!neigh) continue - const wirable = getTileWirable(neigh) - if ( - !wirable || - // A wirable which doesn't provide power and can't share the power via neighbours is useless for this tile - (wirable.wireOverlapMode === WireOverlapMode.NONE && - !wirable.providesPower) - ) - continue - if ( - wirable.wires & dirToWire((i + 2) % 4) && - !(wirable.wireTunnels & dirToWire((i + 2) % 4)) - ) - return true - } - return false -} diff --git a/package.json b/package.json index 6d300858..1bbbd0fa 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,23 @@ "name": "notcc-global-things", "private": true, "devDependencies": { - "@notcc/cli": "workspace:^", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.10.0", - "typescript": "^5.2.2", + "prettier-plugin-tailwindcss": "^0.5.14", + "typescript": "^5.4.5", "zx": "^7.2.3" }, "scripts": { - "build-web": "pnpm i && pnpm --filter @notcc/logic run build && pnpm --filter @notcc/player build", - "build-desktop": "pnpm i && pnpm --filter @notcc/logic run build && pnpm --filter @notcc/player build-desktop && pnpm --filter @notcc/desktop-player build", - "build-cli": "pnpm i && pnpm --filter @notcc/logic run build && pnpm --filter @notcc/cli build && pnpm i", - "test": "zx ./testing.mjs" + "build:web": "pnpm build:logic && pnpm --filter @notcc/player build", + "build:desktop": "pnpm build:logic && pnpm --filter @notcc/player build:neu-zip", + "build:web-full": "pnpm build:logic && pnpm --filter @notcc/player build:full", + "build:cli": "cd libnotcc;./build-native.sh release", + "build:libnotcc": "cd libnotcc;./build-wasm.sh release", + "build:libnotcc-bind": "pnpm i && pnpm build:libnotcc && pnpm --filter @notcc/logic build", + "build:logic": "pnpm build:libnotcc-bind", + "test": "zx ./testing.mjs", + "cli": "./libnotcc/native-build/notcc-cli" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95631ca4..443e36a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '9.0' settings: autoInstallPeers: true @@ -8,130 +8,123 @@ importers: .: devDependencies: - '@notcc/cli': - specifier: workspace:^ - version: link:cli '@typescript-eslint/eslint-plugin': specifier: ^4.33.0 - version: 4.33.0(@typescript-eslint/parser@4.33.0)(eslint@7.32.0)(typescript@5.2.2) + version: 4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.4.5))(eslint@7.32.0)(typescript@5.4.5) '@typescript-eslint/parser': specifier: ^4.33.0 - version: 4.33.0(eslint@7.32.0)(typescript@5.2.2) + version: 4.33.0(eslint@7.32.0)(typescript@5.4.5) eslint: specifier: ^7.32.0 version: 7.32.0 eslint-config-prettier: specifier: ^8.10.0 version: 8.10.0(eslint@7.32.0) + prettier-plugin-tailwindcss: + specifier: ^0.5.14 + version: 0.5.14(prettier@3.2.5) typescript: - specifier: ^5.2.2 - version: 5.2.2 - zx: - specifier: ^7.2.3 - version: 7.2.3 - - cli: - dependencies: - '@notcc/logic': - specifier: workspace:^ - version: link:../logic - '@types/progress': - specifier: ^2.0.6 - version: 2.0.6 - ini: - specifier: ^4.1.1 - version: 4.1.1 - picocolors: - specifier: ^1.0.0 - version: 1.0.0 - progress: - specifier: ^2.0.3 - version: 2.0.3 - prompts: - specifier: ^2.4.2 - version: 2.4.2 - yargs: - specifier: ^17.7.2 - version: 17.7.2 - devDependencies: - '@types/ini': - specifier: ^1.3.32 - version: 1.3.32 - '@types/node': - specifier: ^15.14.9 - version: 15.14.9 - '@types/prompts': - specifier: ^2.4.7 - version: 2.4.7 - '@types/yargs': - specifier: ^17.0.29 - version: 17.0.29 - typescript: - specifier: ^4.9.5 - version: 4.9.5 - - desktopPlayer: - dependencies: - '@neutralinojs/neu': - specifier: ^9.8.0 - version: 9.8.0 + specifier: ^5.4.5 + version: 5.4.5 zx: specifier: ^7.2.3 version: 7.2.3 gamePlayer: dependencies: - clone: - specifier: ^2.1.2 - version: 2.1.2 + '@dagrejs/dagre': + specifier: ^1.1.5 + version: 1.1.5 + react-error-boundary: + specifier: ^4.1.2 + version: 4.1.2(@preact/compat@18.3.1(preact@10.27.0)) devDependencies: '@neutralinojs/lib': - specifier: ^3.12.0 - version: 3.12.0 + specifier: ^5.6.0 + version: 5.6.0 + '@neutralinojs/neu': + specifier: ^11.5.0 + version: 11.5.0 '@notcc/logic': specifier: workspace:^ - version: link:../logic - '@types/clone': - specifier: ^2.1.3 - version: 2.1.3 + version: link:../libnotcc-bind + '@preact/preset-vite': + specifier: ^2.10.2 + version: 2.10.2(@babel/core@7.28.3)(preact@10.27.0)(vite@4.5.14(@types/node@15.14.9)(terser@5.43.1)) '@types/is-hotkey': - specifier: ^0.1.8 - version: 0.1.8 + specifier: ^0.1.10 + version: 0.1.10 + '@types/node': + specifier: ^15.14.9 + version: 15.14.9 '@types/path-browserify': - specifier: ^1.0.1 - version: 1.0.1 - base64-js: - specifier: ^1.5.1 - version: 1.5.1 - dialog-polyfill: - specifier: ^0.5.6 - version: 0.5.6 + specifier: ^1.0.3 + version: 1.0.3 + autoprefixer: + specifier: ^10.4.21 + version: 10.4.21(postcss@8.5.6) fast-printf: - specifier: ^1.6.9 - version: 1.6.9 + specifier: ^1.6.10 + version: 1.6.10 fflate: specifier: ^0.7.4 version: 0.7.4 + idb-keyval: + specifier: ^6.2.2 + version: 6.2.2 is-hotkey: specifier: ^0.2.0 version: 0.2.0 - less: - specifier: ^4.2.0 - version: 4.2.0 + jotai: + specifier: ^2.13.1 + version: 2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@preact/compat@18.3.1(preact@10.27.0)) + jotai-effect: + specifier: ^1.1.6 + version: 1.1.6(jotai@2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@preact/compat@18.3.1(preact@10.27.0))) lz-string: specifier: ^1.5.0 version: 1.5.0 path-browserify: specifier: ^1.0.1 version: 1.0.1 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + preact: + specifier: ^10.27.0 + version: 10.27.0 + preact-render-to-string: + specifier: ^6.5.13 + version: 6.5.13(preact@10.27.0) + react: + specifier: npm:@preact/compat@^18.3.1 + version: '@preact/compat@18.3.1(preact@10.27.0)' + react-dom: + specifier: npm:@preact/compat@^18.3.1 + version: '@preact/compat@18.3.1(preact@10.27.0)' + react-draggable: + specifier: ^4.5.0 + version: 4.5.0(@preact/compat@18.3.1(preact@10.27.0))(@preact/compat@18.3.1(preact@10.27.0)) + react-responsive: + specifier: ^9.0.2 + version: 9.0.2(@preact/compat@18.3.1(preact@10.27.0)) + suspend-react: + specifier: ^0.1.3 + version: 0.1.3(@preact/compat@18.3.1(preact@10.27.0)) + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 typescript: - specifier: ^4.9.5 - version: 4.9.5 + specifier: ^5.9.2 + version: 5.9.2 vite: - specifier: ^4.5.0 - version: 4.5.0(less@4.2.0) + specifier: ^4.5.14 + version: 4.5.14(@types/node@15.14.9)(terser@5.43.1) - logic: + libnotcc-bind: dependencies: clone: specifier: ^2.1.2 @@ -145,10 +138,10 @@ importers: devDependencies: '@types/clone': specifier: ^2.1.3 - version: 2.1.3 + version: 2.1.4 '@types/node': - specifier: ^15.14.9 - version: 15.14.9 + specifier: ^20.14.2 + version: 20.14.2 protobufjs-cli: specifier: ^1.1.2 version: 1.1.2(protobufjs@7.2.5) @@ -158,476 +151,451 @@ importers: packages: - /@aashutoshrathi/word-wrap@1.2.6: - resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} - engines: {node: '>=0.10.0'} - dev: true + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} - /@babel/code-frame@7.12.11: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.12.11': resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==} - dependencies: - '@babel/highlight': 7.22.20 - dev: true - /@babel/helper-string-parser@7.22.5: + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.0': + resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.3': + resolution: {integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.22.5': resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} engines: {node: '>=6.9.0'} - dev: true - /@babel/helper-validator-identifier@7.22.20: + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.22.20': resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} - dev: true - /@babel/highlight@7.22.20: - resolution: {integrity: sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==} + '@babel/helper-validator-identifier@7.24.5': + resolution: {integrity: sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.22.20 - chalk: 2.4.2 - js-tokens: 4.0.0 - dev: true - /@babel/parser@7.23.0: + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.3': + resolution: {integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==} + engines: {node: '>=6.9.0'} + + '@babel/highlight@7.24.5': + resolution: {integrity: sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.23.0': resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==} engines: {node: '>=6.0.0'} hasBin: true - dependencies: - '@babel/types': 7.23.0 - dev: true - /@babel/types@7.23.0: + '@babel/parser@7.28.3': + resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.27.1': + resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.3': + resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.3': + resolution: {integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.23.0': resolution: {integrity: sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.20 - to-fast-properties: 2.0.0 - dev: true - /@esbuild/android-arm64@0.18.20: + '@babel/types@7.28.2': + resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} + engines: {node: '>=6.9.0'} + + '@dagrejs/dagre@1.1.5': + resolution: {integrity: sha512-Ghgrh08s12DCL5SeiR6AoyE80mQELTWhJBRmXfFoqDiFkR458vPEdgTbbjA0T+9ETNxUblnD0QW55tfdvi5pjQ==} + + '@dagrejs/graphlib@2.2.4': + resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} + engines: {node: '>17.0.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} cpu: [arm64] os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/android-arm@0.18.20: + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} cpu: [arm] os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/android-x64@0.18.20: + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} cpu: [x64] os: [android] - requiresBuild: true - dev: true - optional: true - /@esbuild/darwin-arm64@0.18.20: + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@esbuild/darwin-x64@0.18.20: + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} cpu: [x64] os: [darwin] - requiresBuild: true - dev: true - optional: true - /@esbuild/freebsd-arm64@0.18.20: + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/freebsd-x64@0.18.20: + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-arm64@0.18.20: + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} cpu: [arm64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-arm@0.18.20: + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} cpu: [arm] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-ia32@0.18.20: + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} cpu: [ia32] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-loong64@0.18.20: + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-mips64el@0.18.20: + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-ppc64@0.18.20: + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-riscv64@0.18.20: + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-s390x@0.18.20: + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} cpu: [s390x] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/linux-x64@0.18.20: + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} cpu: [x64] os: [linux] - requiresBuild: true - dev: true - optional: true - /@esbuild/netbsd-x64@0.18.20: + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/openbsd-x64@0.18.20: + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] - requiresBuild: true - dev: true - optional: true - /@esbuild/sunos-x64@0.18.20: + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} cpu: [x64] os: [sunos] - requiresBuild: true - dev: true - optional: true - /@esbuild/win32-arm64@0.18.20: + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} cpu: [arm64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@esbuild/win32-ia32@0.18.20: + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} cpu: [ia32] os: [win32] - requiresBuild: true - dev: true - optional: true - /@esbuild/win32-x64@0.18.20: + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} cpu: [x64] os: [win32] - requiresBuild: true - dev: true - optional: true - /@eslint/eslintrc@0.4.3: + '@eslint/eslintrc@0.4.3': resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - ajv: 6.12.6 - debug: 4.3.4 - espree: 7.3.1 - globals: 13.23.0 - ignore: 4.0.6 - import-fresh: 3.3.0 - js-yaml: 3.14.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - /@humanwhocodes/config-array@0.5.0: + '@humanwhocodes/config-array@0.5.0': resolution: {integrity: sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==} engines: {node: '>=10.10.0'} - dependencies: - '@humanwhocodes/object-schema': 1.2.1 - debug: 4.3.4 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - dev: true + deprecated: Use @eslint/config-array instead - /@humanwhocodes/object-schema@1.2.1: + '@humanwhocodes/object-schema@1.2.1': resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} - dev: true + deprecated: Use @eslint/object-schema instead + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} - /@jsdoc/salty@0.2.5: + '@jsdoc/salty@0.2.5': resolution: {integrity: sha512-TfRP53RqunNe2HBobVBJ0VLhK1HbfvBYeTC1ahnN64PWvyYyGebmMiPkuwvD9fpw2ZbkoPb8Q7mwy0aR8Z9rvw==} engines: {node: '>=v12.0.0'} - dependencies: - lodash: 4.17.21 - dev: true - /@neutralinojs/lib@3.12.0: - resolution: {integrity: sha512-A8+sKEf7sNjM7cE9gdJVC8cEZqt2SNVKTG8vWEUq6yV3sXvdcQmJbbclxR1+7Qxt7f3I3u5ObwIT/I0p5ZwpeA==} - dev: true + '@neutralinojs/lib@5.6.0': + resolution: {integrity: sha512-EyIAlurZKGfPWn9VuGhXwNO9LdwVI7oVRsR18KJkit3E3R80FXUpjfCHdAr9FtNsEqqWxJbeYMtFhp9ByndPgg==} - /@neutralinojs/neu@9.8.0: - resolution: {integrity: sha512-TuHTwGHKvlQtsHT3jugwoFNXZQ+xHnOofO1O3nnBG9uplfDmk3aU0zi95DpuPgQWMOxr+vLKBPL2p9BMrrLDWg==} + '@neutralinojs/neu@11.5.0': + resolution: {integrity: sha512-WnbmB+pBfUdQKE9v5RNQT6zX46j5/7qk9pWXmO3segSEdlvCydN8O0Xso53QHpsHyw72cpgLac4870zKakp2+w==} hasBin: true - dependencies: - archiver: 4.0.2 - asar: 3.2.0 - chalk: 4.1.2 - chokidar: 3.5.3 - commander: 7.2.0 - configstore: 5.0.1 - decompress: 4.2.1 - edit-json-file: 1.7.0 - figlet: 1.6.0 - follow-redirects: 1.15.3 - fs-extra: 9.1.0 - recursive-readdir: 2.2.3 - uuid: 8.3.2 - websocket: 1.0.34 - transitivePeerDependencies: - - debug - - supports-color - dev: false - /@nodelib/fs.scandir@2.1.5: + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - /@nodelib/fs.stat@2.0.5: + '@nodelib/fs.stat@2.0.5': resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - /@nodelib/fs.walk@1.2.8: + '@nodelib/fs.walk@1.2.8': resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.15.0 - /@protobufjs/aspromise@1.1.2: + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@preact/compat@18.3.1': + resolution: {integrity: sha512-Kog4PSRxtT4COtOXjsuQPV1vMXpUzREQfv+6Dmcy9/rMk0HOPK0HTE9fspFjAmY8R80T/T8gtgmZ68u5bOSngw==} + peerDependencies: + preact: '*' + + '@preact/preset-vite@2.10.2': + resolution: {integrity: sha512-K9wHlJOtkE+cGqlyQ5v9kL3Ge0Ql4LlIZjkUTL+1zf3nNdF88F9UZN6VTV8jdzBX9Fl7WSzeNMSDG7qECPmSmg==} + peerDependencies: + '@babel/core': 7.x + vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x + + '@prefresh/babel-plugin@0.5.2': + resolution: {integrity: sha512-AOl4HG6dAxWkJ5ndPHBgBa49oo/9bOiJuRDKHLSTyH+Fd9x00shTXpdiTj1W41l6oQIwUOAgJeHMn4QwIDpHkA==} + + '@prefresh/core@1.5.5': + resolution: {integrity: sha512-H6GTXUl4V4fe3ijz7yhSa/mZ+pGSOh7XaJb6uP/sQsagBx9yl0D1HKDaeoMQA8Ad2Xm27LqvbitMGSdY9UFSKQ==} + peerDependencies: + preact: ^10.0.0 + + '@prefresh/utils@1.2.1': + resolution: {integrity: sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==} + + '@prefresh/vite@2.4.8': + resolution: {integrity: sha512-H7vlo9UbJInuRbZhRQrdgVqLP7qKjDoX7TgYWWwIVhEHeHO0hZ4zyicvwBrV1wX5A3EPOmArgRkUaN7cPI2VXQ==} + peerDependencies: + preact: ^10.4.0 + vite: '>=2.0.0' + + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - /@protobufjs/base64@1.1.2: + '@protobufjs/base64@1.1.2': resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - /@protobufjs/codegen@2.0.4: + '@protobufjs/codegen@2.0.4': resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} - /@protobufjs/eventemitter@1.1.0: + '@protobufjs/eventemitter@1.1.0': resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - /@protobufjs/fetch@1.1.0: + '@protobufjs/fetch@1.1.0': resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 - /@protobufjs/float@1.0.2: + '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - /@protobufjs/inquire@1.1.0: + '@protobufjs/inquire@1.1.0': resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - /@protobufjs/path@1.1.2: + '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - /@protobufjs/pool@1.1.0: + '@protobufjs/pool@1.1.0': resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - /@protobufjs/utf8@1.1.0: + '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - /@types/clone@2.1.3: - resolution: {integrity: sha512-DxFaNYaIUXW1OSRCVCC1UHoLcvk6bVJ0v9VvUaZ6kR5zK8/QazXlOThgdvnK0Xpa4sBq+b/Yoq/mnNn383hVRw==} - dev: true - - /@types/fs-extra@11.0.3: - resolution: {integrity: sha512-sF59BlXtUdzEAL1u0MSvuzWd7PdZvZEtnaVkzX5mjpdWTJ8brG0jUqve3jPCzSzvAKKMHTG8F8o/WMQLtleZdQ==} - dependencies: - '@types/jsonfile': 6.1.3 - '@types/node': 18.18.6 + '@rollup/pluginutils@4.2.1': + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} - /@types/glob@7.2.0: - resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - requiresBuild: true - dependencies: - '@types/minimatch': 5.1.2 - '@types/node': 20.8.7 - dev: false - optional: true + '@types/clone@2.1.4': + resolution: {integrity: sha512-NKRWaEGaVGVLnGLB2GazvDaZnyweW9FJLLFL5LhywGJB3aqGMT9R/EUoJoSRP4nzofYnZysuDmrEJtJdAqUOtQ==} - /@types/ini@1.3.32: - resolution: {integrity: sha512-cCXfrhgTiChyYIBs7ZZdsi8a9JLs4vVdCsUJt8nczxpNcHcUEMO8Tu5vsMwbepDobKC1mOf4w32jgOz1NEDoaA==} - dev: true + '@types/fs-extra@11.0.4': + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} - /@types/is-hotkey@0.1.8: - resolution: {integrity: sha512-4zW6OgrfVWR14IqHt32L5zpsE5IJgAu9uimQmAOFPdKPdv+M5RgXeoB2UCJZSKvVNGzUdLgbKdtCSZ66N2HdTA==} - dev: true + '@types/is-hotkey@0.1.10': + resolution: {integrity: sha512-RvC8KMw5BCac1NvRRyaHgMMEtBaZ6wh0pyPTBu7izn4Sj/AX9Y4aXU5c7rX8PnM/knsuUpC1IeoBkANtxBypsQ==} - /@types/json-schema@7.0.14: - resolution: {integrity: sha512-U3PUjAudAdJBeC2pgN8uTIKgxrb4nlDF3SF0++EldXQvQBGkpFZMSnwQiIoDU77tv45VgNkl/L4ouD+rEomujw==} - dev: true + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - /@types/jsonfile@6.1.3: - resolution: {integrity: sha512-/yqTk2SZ1wIezK0hiRZD7RuSf4B3whFxFamB1kGStv+8zlWScTMcHanzfc0XKWs5vA1TkHeckBlOyM8jxU8nHA==} - dependencies: - '@types/node': 18.18.6 + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} - /@types/linkify-it@3.0.4: + '@types/linkify-it@3.0.4': resolution: {integrity: sha512-hPpIeeHb/2UuCw06kSNAOVWgehBLXEo0/fUs0mw3W2qhqX89PI2yvok83MnuctYGCPrabGIoi0fFso4DQ+sNUQ==} - dev: true - /@types/markdown-it@12.2.3: + '@types/markdown-it@12.2.3': resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} - dependencies: - '@types/linkify-it': 3.0.4 - '@types/mdurl': 1.0.4 - dev: true - /@types/mdurl@1.0.4: + '@types/mdurl@1.0.4': resolution: {integrity: sha512-ARVxjAEX5TARFRzpDRVC6cEk0hUIXCCwaMhz8y7S1/PxU6zZS1UMjyobz7q4w/D/R552r4++EhwmXK1N2rAy0A==} - dev: true - - /@types/minimatch@5.1.2: - resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - requiresBuild: true - dev: false - optional: true - /@types/minimist@1.2.4: - resolution: {integrity: sha512-Kfe/D3hxHTusnPNRbycJE1N77WHDsdS4AjUYIzlDzhDrS47NrwuL3YW4VITxwR7KCVpzwgy4Rbj829KSSQmwXQ==} + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} - /@types/node@15.14.9: + '@types/node@15.14.9': resolution: {integrity: sha512-qjd88DrCxupx/kJD5yQgZdcYKZKSIGBVDIBE1/LTGcNm3d2Np/jxojkdePDdfnBHJc5W7vSMpbJ1aB7p/Py69A==} - /@types/node@18.18.6: - resolution: {integrity: sha512-wf3Vz+jCmOQ2HV1YUJuCWdL64adYxumkrxtc+H1VUQlnQI04+5HtH+qZCOE21lBE7gIrt+CwX2Wv8Acrw5Ak6w==} - - /@types/node@20.8.7: - resolution: {integrity: sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==} - requiresBuild: true - dependencies: - undici-types: 5.25.3 - dev: false - optional: true - - /@types/path-browserify@1.0.1: - resolution: {integrity: sha512-rUSqIy7fAfK6sRasdFCukWO4S77pXcTxViURlLdo1VKuekTDS8ASMdX1LA0TFlbzT3fZgFlgQTCrqmJBuTHpxA==} - dev: true - - /@types/progress@2.0.6: - resolution: {integrity: sha512-VMoR0SmxPQFOZ7Sege+q2gswFNq/bHDSMcWv3sEaPqAkbvYYzCOJJzpmAupgYqXn3AXWWmyAWlw8x5tzRDNR7g==} - dependencies: - '@types/node': 15.14.9 - dev: false - - /@types/prompts@2.4.7: - resolution: {integrity: sha512-5zTamE+QQM4nR6Ab3yHK+ovWuhLJXaa2ZLt3mT1en8U3ubWtjVT1vXDaVFC2+cL89uVn7Y+gIq5B3IcVvBl5xQ==} - dependencies: - '@types/node': 15.14.9 - kleur: 3.0.3 - dev: true + '@types/node@18.19.32': + resolution: {integrity: sha512-2bkg93YBSDKk8DLmmHnmj/Rwr18TLx7/n+I23BigFwgexUJoMHZOd8X1OFxuF/W3NN0S2W2E5sVabI5CPinNvA==} - /@types/ps-tree@1.1.4: - resolution: {integrity: sha512-CJyu2BqU/aZN/s8Ili3jiMctqXfTjCaWXirEcjRD8y1lUQZJ8eNohnal8+LXeWFs1VbdAOrCIdgATFsv+lnQ5Q==} + '@types/node@20.14.2': + resolution: {integrity: sha512-xyu6WAMVwv6AKFLB+e/7ySZVr/0zLCzOa7rSpq6jNwpqOrUbcACDWC+53d4n2QHOnDou0fbIsg8wZu/sxrnI4Q==} - /@types/which@3.0.1: - resolution: {integrity: sha512-OJWjr4k8gS1HXuOnCmQbBrQez+xqt/zqfp5PhgbKtsmEFEuojAg23arr+TiTZZ1TORdUF9RKXb/WKEpT1dwgSg==} + '@types/path-browserify@1.0.3': + resolution: {integrity: sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==} - /@types/yargs-parser@21.0.2: - resolution: {integrity: sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==} - dev: true + '@types/ps-tree@1.1.6': + resolution: {integrity: sha512-PtrlVaOaI44/3pl3cvnlK+GxOM3re2526TJvPvh7W+keHIXdV4TE0ylpPBAcvFQCbGitaTXwL9u+RF7qtVeazQ==} - /@types/yargs@17.0.29: - resolution: {integrity: sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==} - dependencies: - '@types/yargs-parser': 21.0.2 - dev: true + '@types/which@3.0.3': + resolution: {integrity: sha512-2C1+XoY0huExTbs8MQv1DuS5FS86+SEjdM9F/+GS61gg5Hqbtj8ZiDSx8MfWcyei907fIPbfPGCOrNUTnVHY1g==} - /@typescript-eslint/eslint-plugin@4.33.0(@typescript-eslint/parser@4.33.0)(eslint@7.32.0)(typescript@5.2.2): + '@typescript-eslint/eslint-plugin@4.33.0': resolution: {integrity: sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -637,41 +605,14 @@ packages: peerDependenciesMeta: typescript: optional: true - dependencies: - '@typescript-eslint/experimental-utils': 4.33.0(eslint@7.32.0)(typescript@5.2.2) - '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.2.2) - '@typescript-eslint/scope-manager': 4.33.0 - debug: 4.3.4 - eslint: 7.32.0 - functional-red-black-tree: 1.0.1 - ignore: 5.2.4 - regexpp: 3.2.0 - semver: 7.5.4 - tsutils: 3.21.0(typescript@5.2.2) - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - /@typescript-eslint/experimental-utils@4.33.0(eslint@7.32.0)(typescript@5.2.2): + '@typescript-eslint/experimental-utils@4.33.0': resolution: {integrity: sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: eslint: '*' - dependencies: - '@types/json-schema': 7.0.14 - '@typescript-eslint/scope-manager': 4.33.0 - '@typescript-eslint/types': 4.33.0 - '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.2.2) - eslint: 7.32.0 - eslint-scope: 5.1.1 - eslint-utils: 3.0.0(eslint@7.32.0) - transitivePeerDependencies: - - supports-color - - typescript - dev: true - /@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.2.2): + '@typescript-eslint/parser@4.33.0': resolution: {integrity: sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -680,31 +621,16 @@ packages: peerDependenciesMeta: typescript: optional: true - dependencies: - '@typescript-eslint/scope-manager': 4.33.0 - '@typescript-eslint/types': 4.33.0 - '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.2.2) - debug: 4.3.4 - eslint: 7.32.0 - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - /@typescript-eslint/scope-manager@4.33.0: + '@typescript-eslint/scope-manager@4.33.0': resolution: {integrity: sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ==} engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} - dependencies: - '@typescript-eslint/types': 4.33.0 - '@typescript-eslint/visitor-keys': 4.33.0 - dev: true - /@typescript-eslint/types@4.33.0: + '@typescript-eslint/types@4.33.0': resolution: {integrity: sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==} engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} - dev: true - /@typescript-eslint/typescript-estree@4.33.0(typescript@5.2.2): + '@typescript-eslint/typescript-estree@4.33.0': resolution: {integrity: sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA==} engines: {node: ^10.12.0 || >=12.0.0} peerDependencies: @@ -712,298 +638,2321 @@ packages: peerDependenciesMeta: typescript: optional: true - dependencies: - '@typescript-eslint/types': 4.33.0 - '@typescript-eslint/visitor-keys': 4.33.0 - debug: 4.3.4 - globby: 11.1.0 - is-glob: 4.0.3 - semver: 7.5.4 - tsutils: 3.21.0(typescript@5.2.2) - typescript: 5.2.2 - transitivePeerDependencies: - - supports-color - dev: true - /@typescript-eslint/visitor-keys@4.33.0: + '@typescript-eslint/visitor-keys@4.33.0': resolution: {integrity: sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg==} engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} - dependencies: - '@typescript-eslint/types': 4.33.0 - eslint-visitor-keys: 2.1.0 - dev: true - - /acorn-jsx@5.3.2(acorn@7.4.1): - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 7.4.1 - dev: true - /acorn-jsx@5.3.2(acorn@8.10.0): + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - acorn: 8.10.0 - dev: true - /acorn@7.4.1: + acorn@7.4.1: resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} engines: {node: '>=0.4.0'} hasBin: true - dev: true - /acorn@8.10.0: + acorn@8.10.0: resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} engines: {node: '>=0.4.0'} hasBin: true - dev: true - /ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - dependencies: + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.0: + resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + babel-plugin-transform-hook-names@1.0.2: + resolution: {integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==} + peerDependencies: + '@babel/core': ^7.12.10 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.25.2: + resolution: {integrity: sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bufferutil@4.0.9: + resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==} + engines: {node: '>=6.14.2'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001735: + resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==} + + catharsis@0.9.0: + resolution: {integrity: sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==} + engines: {node: '>= 10'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + configstore@5.0.1: + resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==} + engines: {node: '>=8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + + css-mediaquery@0.1.2: + resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} + engines: {node: '>=0.12'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.1: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + edit-json-file@1.8.1: + resolution: {integrity: sha512-x8L381+GwqxQejPipwrUZIyAg5gDQ9tLVwiETOspgXiaQztLsrOm7luBW5+Pe31aNezuzDY79YyzF+7viCRPXA==} + + electron-to-chromium@1.5.203: + resolution: {integrity: sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@2.1.0: + resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} + engines: {node: '>=0.12'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@1.14.3: + resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==} + engines: {node: '>=4.0'} + hasBin: true + + eslint-config-prettier@8.10.0: + resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-utils@2.1.0: + resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} + engines: {node: '>=6'} + + eslint-utils@3.0.0: + resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} + engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} + peerDependencies: + eslint: '>=5' + + eslint-visitor-keys@1.3.0: + resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} + engines: {node: '>=4'} + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@7.32.0: + resolution: {integrity: sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==} + engines: {node: ^10.12.0 || >=12.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} + engines: {node: '>=0.10'} + + espree@7.3.1: + resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==} + engines: {node: ^10.12.0 || >=12.0.0} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + + event-stream@3.3.4: + resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-printf@1.6.10: + resolution: {integrity: sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==} + engines: {node: '>=10.0'} + + fast-printf@1.6.9: + resolution: {integrity: sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg==} + engines: {node: '>=10.0'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fflate@0.7.4: + resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-value@1.0.13: + resolution: {integrity: sha512-epNL4mnl3HUYrwVQtZ8s0nxkE4ogAoSqO1V1fa670Ww1fXp8Yr74zNS9Aib/vLNf0rq0AF/4mboo7ev5XkikXQ==} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + + fs-extra@11.2.0: + resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + engines: {node: '>=14.14'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + fx@34.0.0: + resolution: {integrity: sha512-/fZih3/WLsrtlaj2mahjWxAmyuikmcl3D5kKPqLtFmEilLsy9wp0+/vEmfvYXXhwJc+ajtCFDCf+yttXmPMHSQ==} + hasBin: true + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + + ignore@4.0.6: + resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} + engines: {node: '>= 4'} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-regex@4.3.0: + resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} + engines: {node: '>=8'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-primitive@3.0.1: + resolution: {integrity: sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==} + engines: {node: '>=0.10.0'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + is2@2.0.9: + resolution: {integrity: sha512-rZkHeBn9Zzq52sd9IUIV3a5mfwBY+o2HePMh0wkGBM4z4qjvy2GwVxQ6nNXSfw6MmVP6gf1QIlWjiOavhM3x5g==} + engines: {node: '>=v0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + iterate-object@1.3.5: + resolution: {integrity: sha512-eL23u8oFooYTq6TtJKjp2RYjZnCkUYQvC0T/6fJfWykXJ3quvdDdzKZ3CEjy8b3JGOvLTjDYMEMIp5243R906A==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jotai-effect@1.1.6: + resolution: {integrity: sha512-ZPLNZgRSxuTjyzMqLE9ervx1YjH6FwcaEC0kw77W7sEpZLgqjRm6UZTHjsyAxUWUCSwKQ8A3ai3Vkz0tZxSPgw==} + engines: {node: '>=12.20.0'} + peerDependencies: + jotai: '>=2.5.0' + + jotai@2.13.1: + resolution: {integrity: sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js2xmlparser@4.0.2: + resolution: {integrity: sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==} + + jsdoc@4.0.2: + resolution: {integrity: sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==} + engines: {node: '>=12.0.0'} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + klaw@3.0.0: + resolution: {integrity: sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.3.0: + resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} + engines: {node: '>= 0.8.0'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@3.0.3: + resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + map-stream@0.1.0: + resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + + markdown-it-anchor@8.6.7: + resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==} + peerDependencies: + '@types/markdown-it': '*' + markdown-it: '*' + + markdown-it@12.3.2: + resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} + hasBin: true + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + + matchmediaquery@0.3.1: + resolution: {integrity: sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==} + + mdurl@1.0.1: + resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.1: + resolution: {integrity: sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-html-parser@6.1.13: + resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.8.3: + resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} + engines: {node: '>= 0.8.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + pe-library@1.0.1: + resolution: {integrity: sha512-nh39Mo1eGWmZS7y+mK/dQIqg7S1lp38DpRxkyoHf0ZcUs/HDc+yyTjuOtTvSMZHmfSLuSQaX945u05Y2Q6UWZg==} + engines: {node: '>=14', npm: '>=7'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + png2icons@2.0.1: + resolution: {integrity: sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA==} + hasBin: true + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + preact-render-to-string@6.5.13: + resolution: {integrity: sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==} + peerDependencies: + preact: '>=10' + + preact@10.27.0: + resolution: {integrity: sha512-/DTYoB6mwwgPytiqQTh/7SFRL98ZdiD8Sk8zIUVOxtwq4oWcwrcd1uno9fE/zZmUaUrFNYzbH14CPebOz9tZQw==} + + prelude-ls@1.1.2: + resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} + engines: {node: '>= 0.8.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.5.14: + resolution: {integrity: sha512-Puaz+wPUAhFp8Lo9HuciYKM2Y2XExESjeT+9NQoVFXZsPPnc9VYss2SpxdQ6vbatmt8/4+SN0oe0I1cPDABg9Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig-melody': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig-melody': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + protobufjs-cli@1.1.2: + resolution: {integrity: sha512-8ivXWxT39gZN4mm4ArQyJrRgnIwZqffBWoLDsE21TmMcKI3XwJMV4lEF2WU02C4JAtgYYc2SfJIltelD8to35g==} + engines: {node: '>=12.0.0'} + hasBin: true + peerDependencies: + protobufjs: ^7.0.0 + + protobufjs@7.2.5: + resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==} + engines: {node: '>=12.0.0'} + + ps-tree@1.2.0: + resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} + engines: {node: '>= 0.10'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + r-json@1.3.1: + resolution: {integrity: sha512-5nhRFfjVMQdrwKUfUlRpDUCocdKtjSnYZ1R/86mpZDV3MfsZ3dYYNjSGuMX+mPBvFvQBhdzxSqxkuLPLv4uFGg==} + + react-draggable@4.5.0: + resolution: {integrity: sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + + react-error-boundary@4.1.2: + resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==} + peerDependencies: + react: '>=16.13.1' + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-responsive@9.0.2: + resolution: {integrity: sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==} + engines: {node: '>=0.10'} + peerDependencies: + react: '>=16.8.0' + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recursive-readdir@2.2.3: + resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} + engines: {node: '>=6.0.0'} + + regexpp@3.2.0: + resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} + engines: {node: '>=8'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requizzle@0.2.4: + resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} + + resedit@2.0.3: + resolution: {integrity: sha512-oTeemxwoMuxxTYxXUwjkrOPfngTQehlv0/HoYFNkB4uzsP1Un1A9nI8JQKGOFkxpqkC7qkMs0lUsGrvUlbLNUA==} + engines: {node: '>=14', npm: '>=7'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@3.29.5: + resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + + set-value@4.1.0: + resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==} + engines: {node: '>=11.0'} + + shallow-equal@1.2.1: + resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-code-frame@1.3.0: + resolution: {integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spawn-command@1.0.0: + resolution: {integrity: sha512-zsboEQnjeF6tSJ8SRnojMr22HyFEaRaohgTt0Kgx3BgzkXYiboh09vpmZVIq1HOLzkFZDgFJJfwGUqSbb5fQQQ==} + + split@0.3.3: + resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-trace@1.0.0-pre2: + resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} + engines: {node: '>=16'} + + stream-combiner@0.0.4: + resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + + table@6.8.2: + resolution: {integrity: sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==} + engines: {node: '>=10.0.0'} + + tailwind-merge@2.6.0: + resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + + tailwindcss@3.4.17: + resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} + engines: {node: '>=14.0.0'} + hasBin: true + + tcp-port-used@1.0.2: + resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} + + terser@5.43.1: + resolution: {integrity: sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==} + engines: {node: '>=10'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tmp@0.2.1: + resolution: {integrity: sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==} + engines: {node: '>=8.17.0'} + + to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + type-check@0.3.2: + resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} + engines: {node: '>= 0.8.0'} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type@2.7.3: + resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.2: + resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@1.0.6: + resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} + + uglify-js@3.17.4: + resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} + engines: {node: '>=0.8.0'} + hasBin: true + + underscore@1.13.6: + resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utf-8-validate@5.0.10: + resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} + engines: {node: '>=6.14.2'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + v8-compile-cache@2.4.0: + resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} + + vite-prerender-plugin@0.5.11: + resolution: {integrity: sha512-xWOhb8Ef2zoJIiinYVunIf3omRfUbEXcPEvrkQcrDpJ2yjDokxhvQ26eSJbkthRhymntWx6816jpATrJphh+ug==} + peerDependencies: + vite: 5.x || 6.x || 7.x + + vite@4.5.14: + resolution: {integrity: sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + w-json@1.3.10: + resolution: {integrity: sha512-XadVyw0xE+oZ5FGApXsdswv96rOhStzKqL53uSe5UaTadABGkWIg1+DTx8kiZ/VqTZTBneoL0l65RcPe4W3ecw==} + + w-json@1.3.11: + resolution: {integrity: sha512-Xa8vTinB5XBIYZlcN8YyHpE625pBU6k+lvCetTQM+FKxRtLJxAY9zUVZbRqCqkMeEGbQpKvGUzwh4wZKGem+ag==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webpod@0.0.2: + resolution: {integrity: sha512-cSwwQIeg8v4i3p4ajHhwgR7N6VyxAf+KYSSsY6Pd3aETE+xEU4vbitz7qQkB0I321xnhDdgtxuiSfk5r/FVtjg==} + hasBin: true + + websocket@1.0.35: + resolution: {integrity: sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==} + engines: {node: '>=4.0.0'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@3.0.1: + resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + + xdg-basedir@4.0.0: + resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} + engines: {node: '>=8'} + + xmlcreate@2.0.4: + resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} + + yaeti@0.0.6: + resolution: {integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==} + engines: {node: '>=0.10.32'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.4.2: + resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} + engines: {node: '>= 14'} + hasBin: true + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + yauzl@3.2.0: + resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} + engines: {node: '>=12'} + + yazl@3.3.1: + resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==} + + zip-lib@1.1.2: + resolution: {integrity: sha512-PdPYnjxRcbQ7UAhj6Cu8rggpHXFU/6Slhoy2vuA5E/52+i6aSr+9rWup6ll/Mr0mAxV9/YkRni9xGzVc/qh4rA==} + engines: {node: '>=18'} + + zx@7.2.3: + resolution: {integrity: sha512-QODu38nLlYXg/B/Gw7ZKiZrvPkEsjPN3LQ5JFXM7h0JvwhEdPNNl+4Ao1y4+o3CLNiDUNcwzQYZ4/Ko7kKzCMA==} + engines: {node: '>= 16.0.0'} + hasBin: true + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + + '@babel/code-frame@7.12.11': + dependencies: + '@babel/highlight': 7.24.5 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.0': {} + + '@babel/core@7.28.3': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.3) + '@babel/helpers': 7.28.3 + '@babel/parser': 7.28.3 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.3 + '@babel/types': 7.28.2 + convert-source-map: 2.0.0 + debug: 4.4.1 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.3': + dependencies: + '@babel/parser': 7.28.3 + '@babel/types': 7.28.2 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.2 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.25.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.3 + '@babel/types': 7.28.2 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)': + dependencies: + '@babel/core': 7.28.3 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.3 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.22.5': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.22.20': {} + + '@babel/helper-validator-identifier@7.24.5': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.3': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.2 + + '@babel/highlight@7.24.5': + dependencies: + '@babel/helper-validator-identifier': 7.24.5 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/parser@7.23.0': + dependencies: + '@babel/types': 7.23.0 + + '@babel/parser@7.28.3': + dependencies: + '@babel/types': 7.28.2 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.3)': + dependencies: + '@babel/core': 7.28.3 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.3)': + dependencies: + '@babel/core': 7.28.3 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.3) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.3)': + dependencies: + '@babel/core': 7.28.3 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.3) + '@babel/types': 7.28.2 + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.28.3': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.3 + '@babel/types': 7.28.2 + + '@babel/traverse@7.28.3': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.3 + '@babel/template': 7.27.2 + '@babel/types': 7.28.2 + debug: 4.4.1 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.23.0': + dependencies: + '@babel/helper-string-parser': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + '@babel/types@7.28.2': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@dagrejs/dagre@1.1.5': + dependencies: + '@dagrejs/graphlib': 2.2.4 + + '@dagrejs/graphlib@2.2.4': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.2 + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@eslint/eslintrc@0.4.3': + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 7.3.1 + globals: 13.24.0 + ignore: 4.0.6 + import-fresh: 3.3.0 + js-yaml: 3.14.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/config-array@0.5.0': + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/object-schema@1.2.1': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.30 + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.30': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsdoc/salty@0.2.5': + dependencies: + lodash: 4.17.21 + + '@neutralinojs/lib@5.6.0': {} + + '@neutralinojs/neu@11.5.0': + dependencies: + '@electron/asar': 3.4.1 + chalk: 4.1.2 + chokidar: 3.6.0 + commander: 7.2.0 + configstore: 5.0.1 + edit-json-file: 1.8.1 + follow-redirects: 1.15.11 + fs-extra: 9.1.0 + pe-library: 1.0.1 + png2icons: 2.0.1 + recursive-readdir: 2.2.3 + resedit: 2.0.3 + spawn-command: 1.0.0 + tcp-port-used: 1.0.2 + uuid: 8.3.2 + websocket: 1.0.35 + zip-lib: 1.1.2 + transitivePeerDependencies: + - debug + - supports-color + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@preact/compat@18.3.1(preact@10.27.0)': + dependencies: + preact: 10.27.0 + + '@preact/preset-vite@2.10.2(@babel/core@7.28.3)(preact@10.27.0)(vite@4.5.14(@types/node@15.14.9)(terser@5.43.1))': + dependencies: + '@babel/core': 7.28.3 + '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.3) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.3) + '@prefresh/vite': 2.4.8(preact@10.27.0)(vite@4.5.14(@types/node@15.14.9)(terser@5.43.1)) + '@rollup/pluginutils': 4.2.1 + babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.3) + debug: 4.4.1 + picocolors: 1.1.1 + vite: 4.5.14(@types/node@15.14.9)(terser@5.43.1) + vite-prerender-plugin: 0.5.11(vite@4.5.14(@types/node@15.14.9)(terser@5.43.1)) + transitivePeerDependencies: + - preact + - supports-color + + '@prefresh/babel-plugin@0.5.2': {} + + '@prefresh/core@1.5.5(preact@10.27.0)': + dependencies: + preact: 10.27.0 + + '@prefresh/utils@1.2.1': {} + + '@prefresh/vite@2.4.8(preact@10.27.0)(vite@4.5.14(@types/node@15.14.9)(terser@5.43.1))': + dependencies: + '@babel/core': 7.28.3 + '@prefresh/babel-plugin': 0.5.2 + '@prefresh/core': 1.5.5(preact@10.27.0) + '@prefresh/utils': 1.2.1 + '@rollup/pluginutils': 4.2.1 + preact: 10.27.0 + vite: 4.5.14(@types/node@15.14.9)(terser@5.43.1) + transitivePeerDependencies: + - supports-color + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@rollup/pluginutils@4.2.1': + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + + '@types/clone@2.1.4': {} + + '@types/fs-extra@11.0.4': + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 15.14.9 + + '@types/is-hotkey@0.1.10': {} + + '@types/json-schema@7.0.15': {} + + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 15.14.9 + + '@types/linkify-it@3.0.4': {} + + '@types/markdown-it@12.2.3': + dependencies: + '@types/linkify-it': 3.0.4 + '@types/mdurl': 1.0.4 + + '@types/mdurl@1.0.4': {} + + '@types/minimist@1.2.5': {} + + '@types/node@15.14.9': {} + + '@types/node@18.19.32': + dependencies: + undici-types: 5.26.5 + + '@types/node@20.14.2': + dependencies: + undici-types: 5.26.5 + + '@types/path-browserify@1.0.3': {} + + '@types/ps-tree@1.1.6': {} + + '@types/which@3.0.3': {} + + '@typescript-eslint/eslint-plugin@4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.4.5))(eslint@7.32.0)(typescript@5.4.5)': + dependencies: + '@typescript-eslint/experimental-utils': 4.33.0(eslint@7.32.0)(typescript@5.4.5) + '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.4.5) + '@typescript-eslint/scope-manager': 4.33.0 + debug: 4.3.4 + eslint: 7.32.0 + functional-red-black-tree: 1.0.1 + ignore: 5.3.1 + regexpp: 3.2.0 + semver: 7.6.0 + tsutils: 3.21.0(typescript@5.4.5) + optionalDependencies: + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/experimental-utils@4.33.0(eslint@7.32.0)(typescript@5.4.5)': + dependencies: + '@types/json-schema': 7.0.15 + '@typescript-eslint/scope-manager': 4.33.0 + '@typescript-eslint/types': 4.33.0 + '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.4.5) + eslint: 7.32.0 + eslint-scope: 5.1.1 + eslint-utils: 3.0.0(eslint@7.32.0) + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.4.5)': + dependencies: + '@typescript-eslint/scope-manager': 4.33.0 + '@typescript-eslint/types': 4.33.0 + '@typescript-eslint/typescript-estree': 4.33.0(typescript@5.4.5) + debug: 4.3.4 + eslint: 7.32.0 + optionalDependencies: + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@4.33.0': + dependencies: + '@typescript-eslint/types': 4.33.0 + '@typescript-eslint/visitor-keys': 4.33.0 + + '@typescript-eslint/types@4.33.0': {} + + '@typescript-eslint/typescript-estree@4.33.0(typescript@5.4.5)': + dependencies: + '@typescript-eslint/types': 4.33.0 + '@typescript-eslint/visitor-keys': 4.33.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.6.0 + tsutils: 3.21.0(typescript@5.4.5) + optionalDependencies: + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@4.33.0': + dependencies: + '@typescript-eslint/types': 4.33.0 + eslint-visitor-keys: 2.1.0 + + acorn-jsx@5.3.2(acorn@7.4.1): + dependencies: + acorn: 7.4.1 + + acorn-jsx@5.3.2(acorn@8.10.0): + dependencies: + acorn: 8.10.0 + + acorn@7.4.1: {} + + acorn@8.10.0: {} + + acorn@8.15.0: + optional: true + + ajv@6.12.6: + dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - dev: true - /ajv@8.12.0: - resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ajv@8.13.0: dependencies: fast-deep-equal: 3.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 uri-js: 4.4.1 - dev: true - /ansi-colors@4.1.3: - resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} - engines: {node: '>=6'} - dev: true + ansi-colors@4.1.3: {} - /ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} + ansi-regex@5.0.1: {} - /ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} + ansi-regex@6.2.0: {} + + ansi-styles@3.2.1: dependencies: color-convert: 1.9.3 - dev: true - /ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - /anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - dev: false + ansi-styles@6.2.1: {} - /archiver-utils@2.1.0: - resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} - engines: {node: '>= 6'} + any-promise@1.3.0: {} + + anymatch@3.1.3: dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - lazystream: 1.0.1 - lodash.defaults: 4.2.0 - lodash.difference: 4.5.0 - lodash.flatten: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.union: 4.6.0 normalize-path: 3.0.0 - readable-stream: 2.3.8 - dev: false + picomatch: 2.3.1 - /archiver@4.0.2: - resolution: {integrity: sha512-B9IZjlGwaxF33UN4oPbfBkyA4V1SxNLeIhR1qY8sRXSsbdUkEHrrOvwlYFPx+8uQeCe9M+FG6KgO+imDmQ79CQ==} - engines: {node: '>= 8'} - dependencies: - archiver-utils: 2.1.0 - async: 3.2.4 - buffer-crc32: 0.2.13 - glob: 7.2.3 - readable-stream: 3.6.2 - tar-stream: 2.2.0 - zip-stream: 3.0.1 - dev: false + arg@5.0.2: {} - /argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 - dev: true - - /argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - dev: true - - /array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - dev: true - /asar@3.2.0: - resolution: {integrity: sha512-COdw2ZQvKdFGFxXwX3oYh2/sOsJWJegrdJCGxnN4MZ7IULgRBp9P6665aqj9z1v9VwP4oP1hRBojRDQ//IGgAg==} - engines: {node: '>=10.12.0'} - deprecated: Please use @electron/asar moving forward. There is no API change, just a package name change - hasBin: true - dependencies: - chromium-pickle-js: 0.2.0 - commander: 5.1.0 - glob: 7.2.3 - minimatch: 3.1.2 - optionalDependencies: - '@types/glob': 7.2.0 - dev: false + argparse@2.0.1: {} - /astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - dev: true + array-union@2.1.0: {} - /async@3.2.4: - resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} - dev: false + astral-regex@2.0.0: {} - /at-least-node@1.0.0: - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} - engines: {node: '>= 4.0.0'} - dev: false + at-least-node@1.0.0: {} - /balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + autoprefixer@10.4.21(postcss@8.5.6): + dependencies: + browserslist: 4.25.2 + caniuse-lite: 1.0.30001735 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 - /base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + babel-plugin-transform-hook-names@1.0.2(@babel/core@7.28.3): + dependencies: + '@babel/core': 7.28.3 - /binary-extensions@2.2.0: - resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} - engines: {node: '>=8'} - dev: false + balanced-match@1.0.2: {} - /bl@1.2.3: - resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} - dependencies: - readable-stream: 2.3.8 - safe-buffer: 5.2.1 - dev: false + binary-extensions@2.3.0: {} - /bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - dev: false + bluebird@3.7.2: {} - /bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - dev: true + boolbase@1.0.0: {} - /boolean@3.2.0: - resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + boolean@3.2.0: {} - /brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - /brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.0.1: dependencies: balanced-match: 1.0.2 - dev: true - /braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} + brace-expansion@2.0.2: dependencies: - fill-range: 7.0.1 + balanced-match: 1.0.2 - /buffer-alloc-unsafe@1.1.0: - resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} - dev: false + braces@3.0.3: + dependencies: + fill-range: 7.1.1 - /buffer-alloc@1.2.0: - resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + browserslist@4.25.2: dependencies: - buffer-alloc-unsafe: 1.1.0 - buffer-fill: 1.0.0 - dev: false + caniuse-lite: 1.0.30001735 + electron-to-chromium: 1.5.203 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.25.2) - /buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - dev: false + buffer-crc32@0.2.13: {} - /buffer-fill@1.0.0: - resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} - dev: false + buffer-crc32@1.0.0: {} - /buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - dev: false + buffer-from@1.1.2: + optional: true - /bufferutil@4.0.8: - resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==} - engines: {node: '>=6.14.2'} - requiresBuild: true + bufferutil@4.0.9: dependencies: - node-gyp-build: 4.6.1 - dev: false + node-gyp-build: 4.8.4 - /callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - dev: true + callsites@3.1.0: {} - /catharsis@0.9.0: - resolution: {integrity: sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==} - engines: {node: '>= 10'} + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001735: {} + + catharsis@0.9.0: dependencies: lodash: 4.17.21 - dev: true - /chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 escape-string-regexp: 1.0.5 supports-color: 5.5.0 - dev: true - /chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - /chalk@5.3.0: - resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@5.3.0: {} - /chokidar@3.5.3: - resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} - engines: {node: '>= 8.10.0'} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 @@ -1011,75 +2960,35 @@ packages: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.3 - dev: false - /chromium-pickle-js@0.2.0: - resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} - dev: false + clone@2.1.2: {} - /cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - dev: false + clsx@2.1.1: {} - /clone@2.1.2: - resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} - engines: {node: '>=0.8'} - dev: false - - /color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + color-convert@1.9.3: dependencies: color-name: 1.1.3 - dev: true - /color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} + color-convert@2.0.1: dependencies: color-name: 1.1.4 - /color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - dev: true + color-name@1.1.3: {} - /color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-name@1.1.4: {} - /commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - dev: false + commander@2.20.3: + optional: true - /commander@5.1.0: - resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} - engines: {node: '>= 6'} - dev: false + commander@4.1.1: {} - /commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - dev: false + commander@5.1.0: {} - /compress-commons@3.0.0: - resolution: {integrity: sha512-FyDqr8TKX5/X0qo+aVfaZ+PVmNJHJeckFBlq8jZGSJOgnynhfifoyl24qaqdUdDIBe0EVTHByN6NAkqYvE/2Xg==} - engines: {node: '>= 8'} - dependencies: - buffer-crc32: 0.2.13 - crc32-stream: 3.0.1 - normalize-path: 3.0.0 - readable-stream: 2.3.8 - dev: false + commander@7.2.0: {} - /concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-map@0.0.1: {} - /configstore@5.0.1: - resolution: {integrity: sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==} - engines: {node: '>=8'} + configstore@5.0.1: dependencies: dot-prop: 5.3.0 graceful-fs: 4.2.11 @@ -1087,247 +2996,142 @@ packages: unique-string: 2.0.0 write-file-atomic: 3.0.3 xdg-basedir: 4.0.0 - dev: false - - /copy-anything@2.0.6: - resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} - dependencies: - is-what: 3.14.1 - dev: true - - /core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - dev: false - /crc32-stream@3.0.1: - resolution: {integrity: sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==} - engines: {node: '>= 6.9.0'} - dependencies: - crc: 3.8.0 - readable-stream: 3.6.2 - dev: false + convert-source-map@2.0.0: {} - /crc@3.8.0: - resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + cross-spawn@7.0.3: dependencies: - buffer: 5.7.1 - dev: false + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 - /cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - dev: true - /crypto-random-string@2.0.0: - resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} - engines: {node: '>=8'} - dev: false + crypto-random-string@2.0.0: {} - /d@1.0.1: - resolution: {integrity: sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==} + css-mediaquery@0.1.2: {} + + css-select@5.2.2: dependencies: - es5-ext: 0.10.62 - type: 1.2.0 - dev: false + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 - /data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} + css-what@6.2.2: {} - /debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + cssesc@3.0.0: {} + + d@1.0.2: dependencies: - ms: 2.0.0 - dev: false + es5-ext: 0.10.64 + type: 2.7.3 - /debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - requiresBuild: true - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + data-uri-to-buffer@4.0.1: {} + + debug@2.6.9: dependencies: - ms: 2.1.3 - dev: true - optional: true + ms: 2.0.0 - /debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + debug@4.3.1: dependencies: ms: 2.1.2 - dev: true - /decompress-tar@4.1.1: - resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} - engines: {node: '>=4'} + debug@4.3.4: dependencies: - file-type: 5.2.0 - is-stream: 1.1.0 - tar-stream: 1.6.2 - dev: false + ms: 2.1.2 - /decompress-tarbz2@4.1.1: - resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} - engines: {node: '>=4'} + debug@4.4.1: dependencies: - decompress-tar: 4.1.1 - file-type: 6.2.0 - is-stream: 1.1.0 - seek-bzip: 1.0.6 - unbzip2-stream: 1.4.3 - dev: false + ms: 2.1.3 - /decompress-targz@4.1.1: - resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} - engines: {node: '>=4'} - dependencies: - decompress-tar: 4.1.1 - file-type: 5.2.0 - is-stream: 1.1.0 - dev: false + deep-is@0.1.4: {} - /decompress-unzip@4.0.1: - resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} - engines: {node: '>=4'} + didyoumean@1.2.2: {} + + dir-glob@3.0.1: dependencies: - file-type: 3.9.0 - get-stream: 2.3.1 - pify: 2.3.0 - yauzl: 2.10.0 - dev: false + path-type: 4.0.0 - /decompress@4.2.1: - resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} - engines: {node: '>=4'} + dlv@1.1.3: {} + + doctrine@3.0.0: dependencies: - decompress-tar: 4.1.1 - decompress-tarbz2: 4.1.1 - decompress-targz: 4.1.1 - decompress-unzip: 4.0.1 - graceful-fs: 4.2.11 - make-dir: 1.3.0 - pify: 2.3.0 - strip-dirs: 2.1.0 - dev: false + esutils: 2.0.3 - /deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dev: true + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 - /dialog-polyfill@0.5.6: - resolution: {integrity: sha512-ZbVDJI9uvxPAKze6z146rmfUZjBqNEwcnFTVamQzXH+svluiV7swmVIGr7miwADgfgt1G2JQIytypM9fbyhX4w==} - dev: true + domelementtype@2.3.0: {} - /dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} + domhandler@5.0.3: dependencies: - path-type: 4.0.0 + domelementtype: 2.3.0 - /doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} + domutils@3.2.2: dependencies: - esutils: 2.0.3 - dev: true + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 - /dot-prop@5.3.0: - resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} - engines: {node: '>=8'} + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 - dev: false - /duplexer@0.1.2: - resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} - /edit-json-file@1.7.0: - resolution: {integrity: sha512-eIkLJ9i4ija7b2TbaLHy3scyjWFLzwM2Wa6kHbV4ppVLcCqn7FzqnO1vmCG3dLrkd+teWE3mvACfv166mO0VZg==} + edit-json-file@1.8.1: dependencies: - find-value: 1.0.12 - iterate-object: 1.3.4 - r-json: 1.2.10 + find-value: 1.0.13 + iterate-object: 1.3.5 + r-json: 1.3.1 set-value: 4.1.0 - w-json: 1.3.10 - dev: false + w-json: 1.3.11 - /emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + electron-to-chromium@1.5.203: {} - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: false + emoji-regex@8.0.0: {} - /enquirer@2.4.1: - resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} - engines: {node: '>=8.6'} + emoji-regex@9.2.2: {} + + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 strip-ansi: 6.0.1 - dev: true - /entities@2.1.0: - resolution: {integrity: sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==} - dev: true + entities@2.1.0: {} - /errno@0.1.8: - resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} - hasBin: true - requiresBuild: true - dependencies: - prr: 1.0.1 - dev: true - optional: true + entities@4.5.0: {} - /es5-ext@0.10.62: - resolution: {integrity: sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==} - engines: {node: '>=0.10'} - requiresBuild: true + es5-ext@0.10.64: dependencies: es6-iterator: 2.0.3 - es6-symbol: 3.1.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 next-tick: 1.1.0 - dev: false - /es6-iterator@2.0.3: - resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + es6-iterator@2.0.3: dependencies: - d: 1.0.1 - es5-ext: 0.10.62 - es6-symbol: 3.1.3 - dev: false + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 - /es6-symbol@3.1.3: - resolution: {integrity: sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==} + es6-symbol@3.1.4: dependencies: - d: 1.0.1 + d: 1.0.2 ext: 1.7.0 - dev: false - /esbuild@0.18.20: - resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 '@esbuild/android-arm64': 0.18.20 @@ -1351,32 +3155,16 @@ packages: '@esbuild/win32-arm64': 0.18.20 '@esbuild/win32-ia32': 0.18.20 '@esbuild/win32-x64': 0.18.20 - dev: true - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} - engines: {node: '>=6'} - dev: false + escalade@3.2.0: {} - /escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - dev: true + escape-string-regexp@1.0.5: {} - /escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - dev: true + escape-string-regexp@2.0.0: {} - /escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - dev: true + escape-string-regexp@4.0.0: {} - /escodegen@1.14.3: - resolution: {integrity: sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==} - engines: {node: '>=4.0'} - hasBin: true + escodegen@1.14.3: dependencies: esprima: 4.0.1 estraverse: 4.3.0 @@ -1384,61 +3172,32 @@ packages: optionator: 0.8.3 optionalDependencies: source-map: 0.6.1 - dev: true - /eslint-config-prettier@8.10.0(eslint@7.32.0): - resolution: {integrity: sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' + eslint-config-prettier@8.10.0(eslint@7.32.0): dependencies: eslint: 7.32.0 - dev: true - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 - dev: true - /eslint-utils@2.1.0: - resolution: {integrity: sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==} - engines: {node: '>=6'} + eslint-utils@2.1.0: dependencies: eslint-visitor-keys: 1.3.0 - dev: true - /eslint-utils@3.0.0(eslint@7.32.0): - resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} - engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} - peerDependencies: - eslint: '>=5' + eslint-utils@3.0.0(eslint@7.32.0): dependencies: eslint: 7.32.0 eslint-visitor-keys: 2.1.0 - dev: true - /eslint-visitor-keys@1.3.0: - resolution: {integrity: sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==} - engines: {node: '>=4'} - dev: true + eslint-visitor-keys@1.3.0: {} - /eslint-visitor-keys@2.1.0: - resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} - engines: {node: '>=10'} - dev: true + eslint-visitor-keys@2.1.0: {} - /eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true + eslint-visitor-keys@3.4.3: {} - /eslint@7.32.0: - resolution: {integrity: sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==} - engines: {node: ^10.12.0 || >=12.0.0} - hasBin: true + eslint@7.32.0: dependencies: '@babel/code-frame': 7.12.11 '@eslint/eslintrc': 0.4.3 @@ -1460,7 +3219,7 @@ packages: file-entry-cache: 6.0.1 functional-red-black-tree: 1.0.1 glob-parent: 5.1.2 - globals: 13.23.0 + globals: 13.24.0 ignore: 4.0.6 import-fresh: 3.3.0 imurmurhash: 0.1.4 @@ -1471,74 +3230,61 @@ packages: lodash.merge: 4.6.2 minimatch: 3.1.2 natural-compare: 1.4.0 - optionator: 0.9.3 + optionator: 0.9.4 progress: 2.0.3 regexpp: 3.2.0 - semver: 7.5.4 + semver: 7.6.0 strip-ansi: 6.0.1 strip-json-comments: 3.1.1 - table: 6.8.1 + table: 6.8.2 text-table: 0.2.0 v8-compile-cache: 2.4.0 transitivePeerDependencies: - supports-color - dev: true - /espree@7.3.1: - resolution: {integrity: sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==} - engines: {node: ^10.12.0 || >=12.0.0} + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.3 + + espree@7.3.1: dependencies: acorn: 7.4.1 acorn-jsx: 5.3.2(acorn@7.4.1) eslint-visitor-keys: 1.3.0 - dev: true - /espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + espree@9.6.1: dependencies: acorn: 8.10.0 acorn-jsx: 5.3.2(acorn@8.10.0) eslint-visitor-keys: 3.4.3 - dev: true - /esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - dev: true + esprima@4.0.1: {} - /esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} - engines: {node: '>=0.10'} + esquery@1.5.0: dependencies: estraverse: 5.3.0 - dev: true - /esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 - dev: true - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true + estraverse@4.3.0: {} - /estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - dev: true + estraverse@5.3.0: {} - /esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - dev: true + estree-walker@2.0.2: {} - /event-stream@3.3.4: - resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + esutils@2.0.3: {} + + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + + event-stream@3.3.4: dependencies: duplexer: 0.1.2 from: 0.1.7 @@ -1548,193 +3294,118 @@ packages: stream-combiner: 0.0.4 through: 2.3.8 - /ext@1.7.0: - resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + ext@1.7.0: dependencies: - type: 2.7.2 - dev: false + type: 2.7.3 - /fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true + fast-deep-equal@3.1.3: {} - /fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.5 + micromatch: 4.0.8 - /fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - dev: true + fast-json-stable-stringify@2.1.0: {} - /fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - dev: true + fast-levenshtein@2.0.6: {} - /fast-printf@1.6.9: - resolution: {integrity: sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg==} - engines: {node: '>=10.0'} - dependencies: - boolean: 3.2.0 + fast-printf@1.6.10: {} - /fastq@1.15.0: - resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + fast-printf@1.6.9: dependencies: - reusify: 1.0.4 + boolean: 3.2.0 - /fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fastq@1.19.1: dependencies: - pend: 1.2.0 - dev: false + reusify: 1.1.0 - /fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 - web-streams-polyfill: 3.2.1 - - /fflate@0.7.4: - resolution: {integrity: sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==} - dev: true - - /figlet@1.6.0: - resolution: {integrity: sha512-31EQGhCEITv6+hi2ORRPyn3bulaV9Fl4xOdR169cBzH/n1UqcxsiSB/noo6SJdD7Kfb1Ljit+IgR1USvF/XbdA==} - engines: {node: '>= 0.4.0'} - hasBin: true - dev: false - - /file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - dependencies: - flat-cache: 3.1.1 - dev: true - - /file-type@3.9.0: - resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} - engines: {node: '>=0.10.0'} - dev: false + web-streams-polyfill: 3.3.3 - /file-type@5.2.0: - resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} - engines: {node: '>=4'} - dev: false + fflate@0.7.4: {} - /file-type@6.2.0: - resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} - engines: {node: '>=4'} - dev: false + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} - engines: {node: '>=8'} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - /find-value@1.0.12: - resolution: {integrity: sha512-OCpo8LTk8eZ2sdDCwbU2Lc3ivYsdM6yod6jP2jHcNEFcjPhkgH0+POzTIol7xx1LZgtbI5rkO5jqxsG5MWtPjQ==} - dev: false + find-value@1.0.13: {} - /flat-cache@3.1.1: - resolution: {integrity: sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==} - engines: {node: '>=12.0.0'} + flat-cache@3.2.0: dependencies: - flatted: 3.2.9 + flatted: 3.3.1 keyv: 4.5.4 rimraf: 3.0.2 - dev: true - /flatted@3.2.9: - resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} - dev: true + flatted@3.3.1: {} - /follow-redirects@1.15.3: - resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - dev: false + follow-redirects@1.15.11: {} - /formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 - /from@0.1.7: - resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + fraction.js@4.3.7: {} - /fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - dev: false + from@0.1.7: {} - /fs-extra@11.1.1: - resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} - engines: {node: '>=14.14'} + fs-extra@11.2.0: dependencies: graceful-fs: 4.2.11 jsonfile: 6.1.0 - universalify: 2.0.0 + universalify: 2.0.1 - /fs-extra@9.1.0: - resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} - engines: {node: '>=10'} + fs-extra@9.1.0: dependencies: at-least-node: 1.0.0 graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.0 - dev: false + jsonfile: 6.2.0 + universalify: 2.0.1 - /fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fs.realpath@1.0.0: {} - /fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - requiresBuild: true + fsevents@2.3.3: optional: true - /functional-red-black-tree@1.0.1: - resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} - dev: true + function-bind@1.1.2: {} - /fx@30.2.0: - resolution: {integrity: sha512-rIYQBmx85Jfhd3pkSw06YPgvSvfTi022ZXTeFDkcCZGCs5nt3sjqFBGtcMFe1TR2S00RDz63be0ab5mhCiOLBw==} - hasBin: true + functional-red-black-tree@1.0.1: {} - /get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - dev: false + fx@34.0.0: {} - /get-stream@2.3.1: - resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} - engines: {node: '>=0.10.0'} + gensync@1.0.0-beta.2: {} + + glob-parent@5.1.2: dependencies: - object-assign: 4.1.1 - pinkie-promise: 2.0.1 - dev: false + is-glob: 4.0.3 - /glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 - /glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -1743,216 +3414,145 @@ packages: once: 1.4.0 path-is-absolute: 1.0.1 - /glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} + glob@8.1.0: dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 minimatch: 5.1.6 once: 1.4.0 - dev: true - /globals@13.23.0: - resolution: {integrity: sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==} - engines: {node: '>=8'} + globals@13.24.0: dependencies: type-fest: 0.20.2 - dev: true - /globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} + globby@11.1.0: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.1 - ignore: 5.2.4 + fast-glob: 3.3.3 + ignore: 5.3.1 merge2: 1.4.1 slash: 3.0.0 - dev: true - /globby@13.2.2: - resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + globby@13.2.2: dependencies: dir-glob: 3.0.1 - fast-glob: 3.3.1 - ignore: 5.2.4 + fast-glob: 3.3.3 + ignore: 5.3.1 merge2: 1.4.1 slash: 4.0.0 - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - requiresBuild: true + graceful-fs@4.2.11: {} - /has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - dev: true + has-flag@3.0.0: {} - /has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + has-flag@4.0.0: {} - /iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - requiresBuild: true + hasown@2.0.2: dependencies: - safer-buffer: 2.1.2 - dev: true - optional: true + function-bind: 1.1.2 - /ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: false + he@1.2.0: {} - /ignore@4.0.6: - resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==} - engines: {node: '>= 4'} - dev: true + hyphenate-style-name@1.1.0: {} - /ignore@5.2.4: - resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} - engines: {node: '>= 4'} + idb-keyval@6.2.2: {} - /image-size@0.5.5: - resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} - engines: {node: '>=0.10.0'} - hasBin: true - requiresBuild: true - dev: true - optional: true + ignore@4.0.6: {} - /import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} - engines: {node: '>=6'} + ignore@5.3.1: {} + + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 - dev: true - /imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} + imurmurhash@0.1.4: {} - /inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + inflight@1.0.6: dependencies: once: 1.4.0 wrappy: 1.0.2 - /inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inherits@2.0.4: {} - /ini@4.1.1: - resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - dev: false + ip-regex@4.3.0: {} - /is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} + is-binary-path@2.1.0: dependencies: - binary-extensions: 2.2.0 - dev: false + binary-extensions: 2.3.0 - /is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 - /is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} + is-extglob@2.1.1: {} - /is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - /is-hotkey@0.2.0: - resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} - dev: true - - /is-natural-number@4.0.1: - resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} - dev: false + is-hotkey@0.2.0: {} - /is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + is-number@7.0.0: {} - /is-obj@2.0.0: - resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} - engines: {node: '>=8'} - dev: false + is-obj@2.0.0: {} - /is-plain-object@2.0.4: - resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} - engines: {node: '>=0.10.0'} + is-plain-object@2.0.4: dependencies: isobject: 3.0.1 - dev: false - /is-primitive@3.0.1: - resolution: {integrity: sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w==} - engines: {node: '>=0.10.0'} - dev: false + is-primitive@3.0.1: {} - /is-stream@1.1.0: - resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} - engines: {node: '>=0.10.0'} - dev: false + is-typedarray@1.0.0: {} - /is-typedarray@1.0.0: - resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} - dev: false + is-url@1.2.4: {} - /is-what@3.14.1: - resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} - dev: true + is2@2.0.9: + dependencies: + deep-is: 0.1.4 + ip-regex: 4.3.0 + is-url: 1.2.4 - /isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - dev: false + isexe@2.0.0: {} - /isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isobject@3.0.1: {} - /isobject@3.0.1: - resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} - engines: {node: '>=0.10.0'} - dev: false + iterate-object@1.3.5: {} - /iterate-object@1.3.4: - resolution: {integrity: sha512-4dG1D1x/7g8PwHS9aK6QV5V94+ZvyP4+d19qDv43EzImmrndysIl4prmJ1hWWIGCqrZHyaHBm6BSEWHOLnpoNw==} - dev: false + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 - /js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true + jiti@1.21.7: {} - /js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true + jotai-effect@1.1.6(jotai@2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@preact/compat@18.3.1(preact@10.27.0))): + dependencies: + jotai: 2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@preact/compat@18.3.1(preact@10.27.0)) + + jotai@2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(@preact/compat@18.3.1(preact@10.27.0)): + optionalDependencies: + '@babel/core': 7.28.3 + '@babel/template': 7.27.2 + react: '@preact/compat@18.3.1(preact@10.27.0)' + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - dev: true - /js2xmlparser@4.0.2: - resolution: {integrity: sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==} + js2xmlparser@4.0.2: dependencies: xmlcreate: 2.0.4 - dev: true - /jsdoc@4.0.2: - resolution: {integrity: sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==} - engines: {node: '>=12.0.0'} - hasBin: true + jsdoc@4.0.2: dependencies: '@babel/parser': 7.23.0 '@jsdoc/salty': 0.2.5 @@ -1969,318 +3569,191 @@ packages: requizzle: 0.2.4 strip-json-comments: 3.1.1 underscore: 1.13.6 - dev: true - /json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true + jsesc@3.1.0: {} - /json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - dev: true + json-buffer@3.0.1: {} - /json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - dev: true + json-schema-traverse@0.4.1: {} - /json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - dev: true + json-schema-traverse@1.0.0: {} - /jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.1.0: dependencies: - universalify: 2.0.0 + universalify: 2.0.1 optionalDependencies: graceful-fs: 4.2.11 - /keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - dependencies: - json-buffer: 3.0.1 - dev: true - - /klaw@3.0.0: - resolution: {integrity: sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==} + jsonfile@6.2.0: dependencies: + universalify: 2.0.1 + optionalDependencies: graceful-fs: 4.2.11 - dev: true - /kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - /lazystream@1.0.1: - resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} - engines: {node: '>= 0.6.3'} + keyv@4.5.4: dependencies: - readable-stream: 2.3.8 - dev: false + json-buffer: 3.0.1 - /less@4.2.0: - resolution: {integrity: sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==} - engines: {node: '>=6'} - hasBin: true + klaw@3.0.0: dependencies: - copy-anything: 2.0.6 - parse-node-version: 1.0.1 - tslib: 2.6.2 - optionalDependencies: - errno: 0.1.8 graceful-fs: 4.2.11 - image-size: 0.5.5 - make-dir: 2.1.0 - mime: 1.6.0 - needle: 3.2.0 - source-map: 0.6.1 - transitivePeerDependencies: - - supports-color - dev: true - /levn@0.3.0: - resolution: {integrity: sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==} - engines: {node: '>= 0.8.0'} + kolorist@1.8.0: {} + + levn@0.3.0: dependencies: prelude-ls: 1.1.2 type-check: 0.3.2 - dev: true - /levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - dev: true - /linkify-it@3.0.3: - resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==} - dependencies: - uc.micro: 1.0.6 - dev: true + lilconfig@3.1.3: {} - /lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - dev: false + lines-and-columns@1.2.4: {} - /lodash.difference@4.5.0: - resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} - dev: false + linkify-it@3.0.3: + dependencies: + uc.micro: 1.0.6 - /lodash.flatten@4.4.0: - resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} - dev: false + lodash.merge@4.6.2: {} - /lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: false + lodash.truncate@4.4.2: {} - /lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true + lodash@4.17.21: {} - /lodash.truncate@4.4.2: - resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} - dev: true + long@5.2.3: {} - /lodash.union@4.6.0: - resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} - dev: false + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 - /lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - dev: true + lru-cache@10.4.3: {} - /long@5.2.3: - resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 - /lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} + lru-cache@6.0.0: dependencies: yallist: 4.0.0 - dev: true - /lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - dev: true - - /make-dir@1.3.0: - resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} - engines: {node: '>=4'} - dependencies: - pify: 3.0.0 - dev: false + lz-string@1.5.0: {} - /make-dir@2.1.0: - resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} - engines: {node: '>=6'} - requiresBuild: true + magic-string@0.30.17: dependencies: - pify: 4.0.1 - semver: 5.7.2 - dev: true - optional: true + '@jridgewell/sourcemap-codec': 1.5.5 - /make-dir@3.1.0: - resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} - engines: {node: '>=8'} + make-dir@3.1.0: dependencies: semver: 6.3.1 - dev: false - /map-stream@0.1.0: - resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + map-stream@0.1.0: {} - /markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2): - resolution: {integrity: sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==} - peerDependencies: - '@types/markdown-it': '*' - markdown-it: '*' + markdown-it-anchor@8.6.7(@types/markdown-it@12.2.3)(markdown-it@12.3.2): dependencies: '@types/markdown-it': 12.2.3 markdown-it: 12.3.2 - dev: true - /markdown-it@12.3.2: - resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} - hasBin: true + markdown-it@12.3.2: dependencies: argparse: 2.0.1 entities: 2.1.0 linkify-it: 3.0.3 mdurl: 1.0.1 uc.micro: 1.0.6 - dev: true - /marked@4.3.0: - resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} - engines: {node: '>= 12'} - hasBin: true - dev: true + marked@4.3.0: {} - /mdurl@1.0.1: - resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} - dev: true + matchmediaquery@0.3.1: + dependencies: + css-mediaquery: 0.1.2 - /merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + mdurl@1.0.1: {} - /micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} - engines: {node: '>=8.6'} + merge2@1.4.1: {} + + micromatch@4.0.8: dependencies: - braces: 3.0.2 + braces: 3.0.3 picomatch: 2.3.1 - /mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - requiresBuild: true - dev: true - optional: true - - /minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 - /minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} + minimatch@5.1.6: dependencies: brace-expansion: 2.0.1 - dev: true - /minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 - /mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - dev: true + minimist@1.2.8: {} - /ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - dev: false + minipass@7.1.2: {} - /ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - dev: true + mkdirp@1.0.4: {} - /ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - requiresBuild: true - dev: true - optional: true + ms@2.0.0: {} - /nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - dev: true + ms@2.1.2: {} - /natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - dev: true + ms@2.1.3: {} - /needle@3.2.0: - resolution: {integrity: sha512-oUvzXnyLiVyVGoianLijF9O/RecZUf7TkBfimjGrLM4eQhXyeJwM6GeAWccwfQ9aa4gMCZKqhAOuLaMIcQxajQ==} - engines: {node: '>= 4.4.x'} - hasBin: true - requiresBuild: true + mz@2.7.0: dependencies: - debug: 3.2.7 - iconv-lite: 0.6.3 - sax: 1.3.0 - transitivePeerDependencies: - - supports-color - dev: true - optional: true + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 - /next-tick@1.1.0: - resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - dev: false + nanoid@3.3.11: {} - /node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} + natural-compare@1.4.0: {} - /node-fetch@3.3.1: - resolution: {integrity: sha512-cRVc/kyto/7E5shrWca1Wsea4y6tL9iYJE5FBCius3JQfb/4P4I295PfhgbJQBLTx6lATE4z+wK0rPM4VS2uow==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + next-tick@1.1.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.1: dependencies: data-uri-to-buffer: 4.0.1 fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 - /node-gyp-build@4.6.1: - resolution: {integrity: sha512-24vnklJmyRS8ViBNI8KbtK/r/DmXQMRiOMXTNz2nrTnAYUwjmEEbnnpB/+kt+yWRv73bPsSPRFddrcIbAxSiMQ==} - hasBin: true - dev: false + node-gyp-build@4.8.4: {} - /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - dev: false + node-html-parser@6.1.13: + dependencies: + css-select: 5.2.2 + he: 1.2.0 - /object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - dev: false + node-releases@2.0.19: {} - /once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + once@1.4.0: dependencies: wrappy: 1.0.2 - /optionator@0.8.3: - resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} - engines: {node: '>= 0.8.0'} + optionator@0.8.3: dependencies: deep-is: 0.1.4 fast-levenshtein: 2.0.6 @@ -2288,135 +3761,117 @@ packages: prelude-ls: 1.1.2 type-check: 0.3.2 word-wrap: 1.2.5 - dev: true - /optionator@0.9.3: - resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} - engines: {node: '>= 0.8.0'} + optionator@0.9.4: dependencies: - '@aashutoshrathi/word-wrap': 1.2.6 deep-is: 0.1.4 fast-levenshtein: 2.0.6 levn: 0.4.1 prelude-ls: 1.2.1 type-check: 0.4.0 - dev: true + word-wrap: 1.2.5 - /parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: dependencies: callsites: 3.1.0 - dev: true - /parse-node-version@1.0.1: - resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} - engines: {node: '>= 0.10'} - dev: true + path-browserify@1.0.1: {} - /path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - dev: true + path-is-absolute@1.0.1: {} - /path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} + path-key@3.1.1: {} - /path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - dev: true + path-parse@1.0.7: {} - /path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 - /pause-stream@0.0.11: - resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + path-type@4.0.0: {} + + pause-stream@0.0.11: dependencies: through: 2.3.8 - /pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - dev: false + pe-library@1.0.1: {} - /picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + pend@1.2.0: {} - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + picocolors@1.1.1: {} - /pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} - dev: false + picomatch@2.3.1: {} - /pify@3.0.0: - resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} - engines: {node: '>=4'} - dev: false + pify@2.3.0: {} - /pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - requiresBuild: true - dev: true - optional: true + pirates@4.0.7: {} - /pinkie-promise@2.0.1: - resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} - engines: {node: '>=0.10.0'} + png2icons@2.0.1: {} + + postcss-import@15.1.0(postcss@8.5.6): dependencies: - pinkie: 2.0.4 - dev: false + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.10 - /pinkie@2.0.4: - resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} - engines: {node: '>=0.10.0'} - dev: false + postcss-js@4.0.1(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 - /postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} + postcss-load-config@4.0.2(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.1 + optionalDependencies: + postcss: 8.5.6 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact-render-to-string@6.5.13(preact@10.27.0): dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: true + preact: 10.27.0 + + preact@10.27.0: {} - /prelude-ls@1.1.2: - resolution: {integrity: sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==} - engines: {node: '>= 0.8.0'} - dev: true + prelude-ls@1.1.2: {} - /prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - dev: true + prelude-ls@1.2.1: {} - /process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - dev: false + prettier-plugin-tailwindcss@0.5.14(prettier@3.2.5): + dependencies: + prettier: 3.2.5 - /progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} + prettier@3.2.5: {} - /prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} + progress@2.0.3: {} + + prop-types@15.8.1: dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - dev: false + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 - /protobufjs-cli@1.1.2(protobufjs@7.2.5): - resolution: {integrity: sha512-8ivXWxT39gZN4mm4ArQyJrRgnIwZqffBWoLDsE21TmMcKI3XwJMV4lEF2WU02C4JAtgYYc2SfJIltelD8to35g==} - engines: {node: '>=12.0.0'} - hasBin: true - peerDependencies: - protobufjs: ^7.0.0 + protobufjs-cli@1.1.2(protobufjs@7.2.5): dependencies: chalk: 4.1.2 escodegen: 1.14.3 @@ -2426,15 +3881,11 @@ packages: jsdoc: 4.0.2 minimist: 1.2.8 protobufjs: 7.2.5 - semver: 7.5.4 + semver: 7.6.0 tmp: 0.2.1 uglify-js: 3.17.4 - dev: true - /protobufjs@7.2.5: - resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==} - engines: {node: '>=12.0.0'} - requiresBuild: true + protobufjs@7.2.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -2449,663 +3900,447 @@ packages: '@types/node': 15.14.9 long: 5.2.3 - /prr@1.0.1: - resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} - requiresBuild: true - dev: true - optional: true - - /ps-tree@1.2.0: - resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} - engines: {node: '>= 0.10'} - hasBin: true + ps-tree@1.2.0: dependencies: event-stream: 3.3.4 - /punycode@2.3.0: - resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} - engines: {node: '>=6'} - dev: true + punycode@2.3.1: {} - /queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue-microtask@1.2.3: {} - /r-json@1.2.10: - resolution: {integrity: sha512-hu9vyLjSlHXT62NAS7DjI9WazDlvjN0lgp3n431dCVnirVcLkZIpzSwA3orhZEKzdDD2jqNYI+w0yG0aFf4kpA==} - dev: false + r-json@1.3.1: + dependencies: + w-json: 1.3.10 - /readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + react-draggable@4.5.0(@preact/compat@18.3.1(preact@10.27.0))(@preact/compat@18.3.1(preact@10.27.0)): dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - dev: false + clsx: 2.1.1 + prop-types: 15.8.1 + react: '@preact/compat@18.3.1(preact@10.27.0)' + react-dom: '@preact/compat@18.3.1(preact@10.27.0)' - /readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + react-error-boundary@4.1.2(@preact/compat@18.3.1(preact@10.27.0)): dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - dev: false + '@babel/runtime': 7.28.3 + react: '@preact/compat@18.3.1(preact@10.27.0)' - /readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + react-is@16.13.1: {} + + react-responsive@9.0.2(@preact/compat@18.3.1(preact@10.27.0)): + dependencies: + hyphenate-style-name: 1.1.0 + matchmediaquery: 0.3.1 + prop-types: 15.8.1 + react: '@preact/compat@18.3.1(preact@10.27.0)' + shallow-equal: 1.2.1 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: dependencies: picomatch: 2.3.1 - dev: false - /recursive-readdir@2.2.3: - resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} - engines: {node: '>=6.0.0'} + recursive-readdir@2.2.3: dependencies: minimatch: 3.1.2 - dev: false - - /regexpp@3.2.0: - resolution: {integrity: sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==} - engines: {node: '>=8'} - dev: true - /require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - dev: false + regexpp@3.2.0: {} - /require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - dev: true + require-from-string@2.0.2: {} - /requizzle@0.2.4: - resolution: {integrity: sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==} + requizzle@0.2.4: dependencies: lodash: 4.17.21 - dev: true - /resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - dev: true + resedit@2.0.3: + dependencies: + pe-library: 1.0.1 - /reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + resolve-from@4.0.0: {} - /rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - hasBin: true + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: dependencies: glob: 7.2.3 - dev: true - /rollup@3.29.4: - resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} - engines: {node: '>=14.18.0', npm: '>=8.0.0'} - hasBin: true + rollup@3.29.5: optionalDependencies: fsevents: 2.3.3 - dev: true - /run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - /safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - dev: false - - /safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - dev: false - - /safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - requiresBuild: true - dev: true - optional: true - - /sax@1.3.0: - resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} - requiresBuild: true - dev: true - optional: true - - /seek-bzip@1.0.6: - resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} - hasBin: true - dependencies: - commander: 2.20.3 - dev: false - - /semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - requiresBuild: true - dev: true - optional: true + semver@6.3.1: {} - /semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - dev: false - - /semver@7.5.4: - resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} - engines: {node: '>=10'} - hasBin: true + semver@7.6.0: dependencies: lru-cache: 6.0.0 - dev: true - /set-value@4.1.0: - resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==} - engines: {node: '>=11.0'} + set-value@4.1.0: dependencies: is-plain-object: 2.0.4 is-primitive: 3.0.1 - dev: false - /shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} + shallow-equal@1.2.1: {} + + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 - dev: true - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - dev: true + shebang-regex@3.0.0: {} - /signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: false + signal-exit@3.0.7: {} - /sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - dev: false + signal-exit@4.1.0: {} - /slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - dev: true + simple-code-frame@1.3.0: + dependencies: + kolorist: 1.8.0 - /slash@4.0.0: - resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} - engines: {node: '>=12'} + slash@3.0.0: {} - /slice-ansi@4.0.0: - resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} - engines: {node: '>=10'} + slash@4.0.0: {} + + slice-ansi@4.0.0: dependencies: ansi-styles: 4.3.0 astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 - dev: true - /source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - dev: true + source-map-js@1.2.1: {} - /source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - requiresBuild: true - dev: true + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 optional: true - /split@0.3.3: - resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + source-map@0.6.1: + optional: true + + source-map@0.7.6: {} + + spawn-command@1.0.0: {} + + split@0.3.3: dependencies: through: 2.3.8 - /sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - dev: true + sprintf-js@1.0.3: {} - /stream-combiner@0.0.4: - resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + stack-trace@1.0.0-pre2: {} + + stream-combiner@0.0.4: dependencies: duplexer: 0.1.2 - /string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - /string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string-width@5.1.2: dependencies: - safe-buffer: 5.1.2 - dev: false + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 - /string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - dependencies: - safe-buffer: 5.2.1 - dev: false - - /strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - /strip-dirs@2.1.0: - resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + strip-ansi@7.1.0: dependencies: - is-natural-number: 4.0.1 - dev: false + ansi-regex: 6.2.0 - /strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - dev: true + strip-json-comments@3.1.1: {} - /supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@5.5.0: dependencies: has-flag: 3.0.0 - dev: true - /supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 - /table@6.8.1: - resolution: {integrity: sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==} - engines: {node: '>=10.0.0'} + supports-preserve-symlinks-flag@1.0.0: {} + + suspend-react@0.1.3(@preact/compat@18.3.1(preact@10.27.0)): + dependencies: + react: '@preact/compat@18.3.1(preact@10.27.0)' + + table@6.8.2: dependencies: - ajv: 8.12.0 + ajv: 8.13.0 lodash.truncate: 4.4.2 slice-ansi: 4.0.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: true - /tar-stream@1.6.2: - resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} - engines: {node: '>= 0.8.0'} + tailwind-merge@2.6.0: {} + + tailwindcss@3.4.17: dependencies: - bl: 1.2.3 - buffer-alloc: 1.2.0 - end-of-stream: 1.4.4 - fs-constants: 1.0.0 - readable-stream: 2.3.8 - to-buffer: 1.1.1 - xtend: 4.0.2 - dev: false - - /tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.0.1(postcss@8.5.6) + postcss-load-config: 4.0.2(postcss@8.5.6) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tcp-port-used@1.0.2: dependencies: - bl: 4.1.0 - end-of-stream: 1.4.4 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - dev: false + debug: 4.3.1 + is2: 2.0.9 + transitivePeerDependencies: + - supports-color - /text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - dev: true + terser@5.43.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + optional: true - /through@2.3.8: - resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + text-table@0.2.0: {} - /tmp@0.2.1: - resolution: {integrity: sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==} - engines: {node: '>=8.17.0'} + thenify-all@1.6.0: dependencies: - rimraf: 3.0.2 - dev: true + thenify: 3.3.1 - /to-buffer@1.1.1: - resolution: {integrity: sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==} - dev: false + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 - /to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - dev: true + through@2.3.8: {} - /to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + tmp@0.2.1: + dependencies: + rimraf: 3.0.2 + + to-fast-properties@2.0.0: {} + + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - /tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - dev: true + ts-interface-checker@0.1.13: {} - /tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: true + tslib@1.14.1: {} - /tsutils@3.21.0(typescript@5.2.2): - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} - peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + tsutils@3.21.0(typescript@5.4.5): dependencies: tslib: 1.14.1 - typescript: 5.2.2 - dev: true + typescript: 5.4.5 - /type-check@0.3.2: - resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==} - engines: {node: '>= 0.8.0'} + type-check@0.3.2: dependencies: prelude-ls: 1.1.2 - dev: true - /type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 - dev: true - /type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - dev: true + type-fest@0.20.2: {} - /type@1.2.0: - resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==} - dev: false + type@2.7.3: {} - /type@2.7.2: - resolution: {integrity: sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==} - dev: false - - /typedarray-to-buffer@3.1.5: - resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedarray-to-buffer@3.1.5: dependencies: is-typedarray: 1.0.0 - dev: false - /typescript@4.9.5: - resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} - engines: {node: '>=4.2.0'} - hasBin: true - dev: true + typescript@4.9.5: {} - /typescript@5.2.2: - resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} - engines: {node: '>=14.17'} - hasBin: true - dev: true + typescript@5.4.5: {} - /uc.micro@1.0.6: - resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} - dev: true + typescript@5.9.2: {} - /uglify-js@3.17.4: - resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} - engines: {node: '>=0.8.0'} - hasBin: true - dev: true + uc.micro@1.0.6: {} - /unbzip2-stream@1.4.3: - resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} - dependencies: - buffer: 5.7.1 - through: 2.3.8 - dev: false + uglify-js@3.17.4: {} - /underscore@1.13.6: - resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} - dev: true + underscore@1.13.6: {} - /undici-types@5.25.3: - resolution: {integrity: sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==} - requiresBuild: true - dev: false - optional: true + undici-types@5.26.5: {} - /unique-string@2.0.0: - resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} - engines: {node: '>=8'} + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 - dev: false - /universalify@2.0.0: - resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} - engines: {node: '>= 10.0.0'} + universalify@2.0.1: {} - /uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + update-browserslist-db@1.1.3(browserslist@4.25.2): dependencies: - punycode: 2.3.0 - dev: true + browserslist: 4.25.2 + escalade: 3.2.0 + picocolors: 1.1.1 - /utf-8-validate@5.0.10: - resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==} - engines: {node: '>=6.14.2'} - requiresBuild: true + uri-js@4.4.1: dependencies: - node-gyp-build: 4.6.1 - dev: false + punycode: 2.3.1 - /util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: false + utf-8-validate@5.0.10: + dependencies: + node-gyp-build: 4.8.4 - /uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - dev: false + util-deprecate@1.0.2: {} - /v8-compile-cache@2.4.0: - resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==} - dev: true + uuid@8.3.2: {} - /vite@4.5.0(less@4.2.0): - resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} - engines: {node: ^14.18.0 || >=16.0.0} - hasBin: true - peerDependencies: - '@types/node': '>= 14' - less: '*' - lightningcss: ^1.21.0 - sass: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true + v8-compile-cache@2.4.0: {} + + vite-prerender-plugin@0.5.11(vite@4.5.14(@types/node@15.14.9)(terser@5.43.1)): + dependencies: + kolorist: 1.8.0 + magic-string: 0.30.17 + node-html-parser: 6.1.13 + simple-code-frame: 1.3.0 + source-map: 0.7.6 + stack-trace: 1.0.0-pre2 + vite: 4.5.14(@types/node@15.14.9)(terser@5.43.1) + + vite@4.5.14(@types/node@15.14.9)(terser@5.43.1): dependencies: esbuild: 0.18.20 - less: 4.2.0 - postcss: 8.4.31 - rollup: 3.29.4 + postcss: 8.5.6 + rollup: 3.29.5 optionalDependencies: + '@types/node': 15.14.9 fsevents: 2.3.3 - dev: true + terser: 5.43.1 - /w-json@1.3.10: - resolution: {integrity: sha512-XadVyw0xE+oZ5FGApXsdswv96rOhStzKqL53uSe5UaTadABGkWIg1+DTx8kiZ/VqTZTBneoL0l65RcPe4W3ecw==} - dev: false + w-json@1.3.10: {} - /web-streams-polyfill@3.2.1: - resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} - engines: {node: '>= 8'} + w-json@1.3.11: {} - /webpod@0.0.2: - resolution: {integrity: sha512-cSwwQIeg8v4i3p4ajHhwgR7N6VyxAf+KYSSsY6Pd3aETE+xEU4vbitz7qQkB0I321xnhDdgtxuiSfk5r/FVtjg==} - hasBin: true + web-streams-polyfill@3.3.3: {} - /websocket@1.0.34: - resolution: {integrity: sha512-PRDso2sGwF6kM75QykIesBijKSVceR6jL2G8NGYyq2XrItNC2P5/qL5XeR056GhA+Ly7JMFvJb9I312mJfmqnQ==} - engines: {node: '>=4.0.0'} + webpod@0.0.2: {} + + websocket@1.0.35: dependencies: - bufferutil: 4.0.8 + bufferutil: 4.0.9 debug: 2.6.9 - es5-ext: 0.10.62 + es5-ext: 0.10.64 typedarray-to-buffer: 3.1.5 utf-8-validate: 5.0.10 yaeti: 0.0.6 transitivePeerDependencies: - supports-color - dev: false - /which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true + which@2.0.2: dependencies: isexe: 2.0.0 - dev: true - /which@3.0.1: - resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - hasBin: true + which@3.0.1: dependencies: isexe: 2.0.0 - /word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - dev: true + word-wrap@1.2.5: {} - /wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: false - /wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 - /write-file-atomic@3.0.3: - resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + wrappy@1.0.2: {} + + write-file-atomic@3.0.3: dependencies: imurmurhash: 0.1.4 is-typedarray: 1.0.0 signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - dev: false - - /xdg-basedir@4.0.0: - resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==} - engines: {node: '>=8'} - dev: false - /xmlcreate@2.0.4: - resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} - dev: true + xdg-basedir@4.0.0: {} - /xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - dev: false + xmlcreate@2.0.4: {} - /y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - dev: false + yaeti@0.0.6: {} - /yaeti@0.0.6: - resolution: {integrity: sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==} - engines: {node: '>=0.10.32'} - dev: false + yallist@3.1.1: {} - /yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true + yallist@4.0.0: {} - /yaml@2.3.3: - resolution: {integrity: sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==} - engines: {node: '>= 14'} + yaml@2.4.2: {} - /yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - dev: false + yaml@2.8.1: {} - /yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} + yauzl@3.2.0: dependencies: - cliui: 8.0.1 - escalade: 3.1.1 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - dev: false + buffer-crc32: 0.2.13 + pend: 1.2.0 - /yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yazl@3.3.1: dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - dev: false + buffer-crc32: 1.0.0 - /zip-stream@3.0.1: - resolution: {integrity: sha512-r+JdDipt93ttDjsOVPU5zaq5bAyY+3H19bDrThkvuVxC0xMQzU1PJcS6D+KrP3u96gH9XLomcHPb+2skoDjulQ==} - engines: {node: '>= 8'} + zip-lib@1.1.2: dependencies: - archiver-utils: 2.1.0 - compress-commons: 3.0.0 - readable-stream: 3.6.2 - dev: false + yauzl: 3.2.0 + yazl: 3.3.1 - /zx@7.2.3: - resolution: {integrity: sha512-QODu38nLlYXg/B/Gw7ZKiZrvPkEsjPN3LQ5JFXM7h0JvwhEdPNNl+4Ao1y4+o3CLNiDUNcwzQYZ4/Ko7kKzCMA==} - engines: {node: '>= 16.0.0'} - hasBin: true + zx@7.2.3: dependencies: - '@types/fs-extra': 11.0.3 - '@types/minimist': 1.2.4 - '@types/node': 18.18.6 - '@types/ps-tree': 1.1.4 - '@types/which': 3.0.1 + '@types/fs-extra': 11.0.4 + '@types/minimist': 1.2.5 + '@types/node': 18.19.32 + '@types/ps-tree': 1.1.6 + '@types/which': 3.0.3 chalk: 5.3.0 - fs-extra: 11.1.1 - fx: 30.2.0 + fs-extra: 11.2.0 + fx: 34.0.0 globby: 13.2.2 minimist: 1.2.8 node-fetch: 3.3.1 ps-tree: 1.2.0 webpod: 0.0.2 which: 3.0.1 - yaml: 2.3.3 + yaml: 2.4.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 46c6efb4..d1d134fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,3 @@ packages: - - cli - - logic + - libnotcc-bind - gamePlayer - - desktopPlayer diff --git a/scriptMetadata.md b/scriptMetadata.md new file mode 100644 index 00000000..6724ee32 --- /dev/null +++ b/scriptMetadata.md @@ -0,0 +1,32 @@ +# CC2 Script Metadata + +In Chip's Challenge 2, a set may specfify its name by leaving a string in the first line of its main script. This document describes a method for specifying additional metadata using comments in the C2G file. This syntax is supported by NotCC. + +## Syntax + +Script metadata is specified using specially-formatted comments, called **metadata field**s: + +- A metadata field is of the format `; meta [KEY]: [VALUE]`, with the field's key and value being `[KEY]` and `[VALUE]`, respectively. The key is terminated by a colon, and the value is terminated by a newline or end of file. +- Metadata fields may be anywhere in the script. For readability, fields should be near the top of the script. +- Multiple fields with the same key will be concatenated with a newline. +- Fields with unknown keys should be ignored. + +## Example + +``` +game "Cromulent Mazes" +; meta by: Chip McCallahan +; meta description: A variety of levels inspired by mazes. +; meta description: Enjoy! +; meta difficulty: 3.5 +``` + +## All fields + +| Key | Functionality | Value | +| ------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `by` | Who made this set | A string. Multiple authors may be separated using commas. | +| `description` | A short (one paragraph max) description of what this set is or who it is for | A string | +| `difficulty` | How difficult this set is to solve. The CC2 basegame is 3 stars. | A floating-point number between 0 and 5 (inclusive). | +| `thumbnail` | What this set's thumbnail should be in a set grid | `first level` - this set's thumbnail should be a map of the set's first level. `image` - this set's thumbnail is an image located in the same directory as the script named "preview.png". `none` - this set shouldn' t display a thumbnail. By default, this field is either `image` or `first level`, depending on if there's an preview.png file detected. | +| `listing priority` | Where this set should be put relative to other sets in a set grid | `top` - this set should be put above other levels. Avoid this value for public sets. `bottom` - this set should be put after all other sets. `unlisted` - don't show this set in a set grid. By default, this set is put between `top` and `bottom` sets. | diff --git a/testing.mjs b/testing.mjs index e3218743..76a823d9 100755 --- a/testing.mjs +++ b/testing.mjs @@ -30,8 +30,31 @@ if (!(await fs.exists(defaultSyncPath))) { process.exit(1) } +let setListing = null +let tempDir = null + async function downloadSet(setName) { - await $`wget -r -nH --cut-dirs=3 -P ${setsDirectory} --no-parent --reject="index.html*" "https://bitbusters.club/gliderbot/sets/cc2/${setName}/"` + if (!setListing) { + const res = await fetch("https://api.bitbusters.club/custom-packs/cc2") + if (!res.ok) + throw new Error(`Failed to contact bb.club: ${await res.text()}`) + setListing = await res.json() + tempDir = path.join(os.tmpdir(), await fs.mkdtemp("notcc-test")) + await fs.mkdir(tempDir) + process.on("exit", async () => { + fs.rmdirSync(tempDir, { recursive: true }) + }) + } + const set = setListing.find(set => set.pack_name === setName) + if (!set) throw new Error("Set is not on bb.club") + const res = await fetch(set.download_url) + if (!res.ok) throw new Error(`Failed to contact bb.club: ${await res.text()}`) + const setZipPath = path.join(tempDir, `${setName}.zip`) + const setPath = path.join(setsDirectory, setName) + await fs.promises.writeFile(setZipPath, res.body) + await fs.mkdirp(setPath) + await $`cd ${setPath} && unzip ${setZipPath}` + await fs.rm(setZipPath) } await fs.mkdirp(setsDirectory) @@ -48,7 +71,7 @@ for (const setName of setsToTest) { } const syncPath = path.join(syncDirectory, `${setName}.sync`) const syncExists = await fs.exists(syncPath) - const verifyProcess = $`pnpm notcc verify ${setPath} --ci --sync ${ + const verifyProcess = $`pnpm cli ${setPath} -s ${ syncExists ? syncPath : defaultSyncPath }` await verifyProcess.nothrow()