From 514505903f9e4445c9daf1c5a79182f4b7ea148b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 23:48:58 -0700 Subject: [PATCH] Dominoes: All Fives, one against three, first to 250 Four at the table with a double-six set: five tiles each and a boneyard, the first double a spinner that opens four ways, and every multiple of five on the ends scored as it falls. Three computer players who count what the table can see and never a hand, and talk the whole time. Served at games.jcoffey.dev/dominoes/, built from this repository. --- .gitignore | 24 + LICENSE | 661 ++++++++ NOTICE.md | 53 + README.md | 84 ++ index.html | 131 ++ package-lock.json | 2928 ++++++++++++++++++++++++++++++++++++ package.json | 38 + public/favicon.svg | 12 + src/App.css | 527 +++++++ src/App.tsx | 543 +++++++ src/audio/play.ts | 33 + src/audio/score.ts | 80 + src/audio/shuffle.mp3 | Bin 0 -> 58557 bytes src/audio/synth.ts | 682 +++++++++ src/components/Crash.tsx | 32 + src/components/Landing.tsx | 117 ++ src/components/Seats.tsx | 69 + src/components/Shuffle.tsx | 103 ++ src/components/Side.tsx | 74 + src/components/Table.tsx | 142 ++ src/components/Tally.tsx | 29 + src/components/Tile.tsx | 70 + src/game/bot.ts | 135 ++ src/game/describe.test.ts | 18 + src/game/describe.ts | 76 + src/game/game.test.ts | 191 +++ src/game/game.ts | 427 ++++++ src/game/layout.test.ts | 56 + src/game/layout.ts | 198 +++ src/game/names.ts | 41 + src/game/play.test.ts | 110 ++ src/game/rng.ts | 26 + src/game/talk.ts | 273 ++++ src/game/tiles.ts | 46 + src/game/trash.test.ts | 15 + src/game/trash.ts | 72 + src/index.css | 15 + src/main.tsx | 13 + tsconfig.app.json | 28 + tsconfig.json | 7 + tsconfig.node.json | 23 + vite.config.ts | 15 + web/Dockerfile | 23 + web/nginx.conf | 22 + 44 files changed, 8262 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/audio/play.ts create mode 100644 src/audio/score.ts create mode 100644 src/audio/shuffle.mp3 create mode 100644 src/audio/synth.ts create mode 100644 src/components/Crash.tsx create mode 100644 src/components/Landing.tsx create mode 100644 src/components/Seats.tsx create mode 100644 src/components/Shuffle.tsx create mode 100644 src/components/Side.tsx create mode 100644 src/components/Table.tsx create mode 100644 src/components/Tally.tsx create mode 100644 src/components/Tile.tsx create mode 100644 src/game/bot.ts create mode 100644 src/game/describe.test.ts create mode 100644 src/game/describe.ts create mode 100644 src/game/game.test.ts create mode 100644 src/game/game.ts create mode 100644 src/game/layout.test.ts create mode 100644 src/game/layout.ts create mode 100644 src/game/names.ts create mode 100644 src/game/play.test.ts create mode 100644 src/game/rng.ts create mode 100644 src/game/talk.ts create mode 100644 src/game/tiles.ts create mode 100644 src/game/trash.test.ts create mode 100644 src/game/trash.ts create mode 100644 src/index.css create mode 100644 src/main.tsx create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts create mode 100644 web/Dockerfile create mode 100644 web/nginx.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..f920e09 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,53 @@ +# Notice, credits and provenance + +## What this is + +An independent implementation of All Fives, the domino game, written from +scratch in TypeScript in 2026: one human against three computer players. + +## The game + +Dominoes is centuries old and belongs to everybody; so do All Fives and its +spinner. The rules here follow the published descriptions at +[pagat.com](https://www.pagat.com/domino/cross/all_fives.html), with the house +rules stated on the landing page. Rules are ideas, not expression, and nothing +of anybody's text is reproduced. + +## What is ours + +Everything else: the rules engine, the layout, the bots and their reasoning, +the table talk, the interface, the music and every other sound effect, and +every word on screen. Every tile is drawn in code, and every sound but one is +synthesized in code; there are no image files. + +## The shuffle + +The one recorded sound is the shuffle, in `src/audio/shuffle.mp3`: the first +3.6 seconds of **"Shuffling Dominos" by RobertWulfman** on Freesound, + +- , CC0 1.0 + (public domain dedication) + +trimmed, leveled and faded at both ends for this game. CC0 asks for nothing, +not even credit. It is credited anyway, because it was the only shuffle that +sounded like a shuffle. If the file cannot be loaded, the game falls back to a +synthesized one. + +## License + +Copyright (C) 2026 John Coffey. + +This program is free software: you can redistribute it and/or modify it under +the terms of the **GNU Affero General Public License** as published by the +Free Software Foundation, either version 3 of the License, or (at your option) +any later version. See [LICENSE](LICENSE). + +AGPL rather than plain GPL because this is served over a network. Section 13 +means anyone who runs a modified copy of this for other people over a network +has to offer them its source. + +This program is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + +*None of the above is legal advice.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..d879e21 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Dominoes + +All Fives with a double-six set, in the browser: four at the table, five +tiles each and a boneyard, first to 250. One human against three computer players, old men +and young ones, every one of whom has something to say about your play. + +## The table + +**All Fives, the way it is played on a card table.** The rules are spelled +out on the landing page before anyone plays, and they follow +[pagat's All Fives](https://www.pagat.com/domino/cross/all_fives.html) with +one table's worth of house rules: + +- five tiles each and eight in the boneyard; a player who cannot match + draws, one tile at a time, and knocks only when the boneyard is empty; +- the highest double dealt opens the first hand; after that the winner of + the last hand leads anything; +- the first double down is the spinner, marked with a star, and once both + its sides are covered its top and bottom open, so the line runs four ways; +- after every tile the open ends are added up -- a double on an end counts + both halves, and the spinner counts both halves until both its sides are + covered -- and a multiple of five scores on the spot; +- going out, or the lowest count in a blocked hand, scores the pips left in + the other hands to the nearest five; +- first to 250 wins, the moment they reach it, even in the middle of a hand. + +The score sheet is kept the way it is at the table: a stroke for every five, +the fifth a slash through the four, so a box is twenty-five. + +**No hints.** Nothing lights up to say which tiles fit or what a play would +score. You drag a tile to an end, or tap it and tap the end, and if it does +not match it comes back. + +## The layout + +`src/game/layout.ts`. The line is drawn from a hub -- the spinner once there +is one, otherwise the first tile -- so the spinner always sits in the middle +of the felt. Each arm turns its corners the same way round, like a pinwheel, +and each leg may run further than the last, so four arms wind outward without +meeting. `layout.test.ts` lays the table after every tile of hundreds of +hands at four widths and holds it to no tile on another and no tile floating +free. + +## The bots + +`src/game/bot.ts`. A bot knows what a careful player knows: its own tiles, +the table, how many each player holds, and which numbers each has knocked on. +It never looks at another hand. It prices each legal move -- points on the +board now first, then shedding weight, getting doubles down, keeping a hand it +can play, and leaving the next man stuck -- and plays the dearest. It blocks a +hand only when it is certain to hold the lowest count. + +In tests a bot beats three players who always lay their heaviest tile, and +four bots win evenly from every seat. + +## The talk + +`src/game/talk.ts`. Every play gets a line from somebody, in a speech bubble +off the side of his seat: the man who played it, or whoever has an opinion. +It reads only what the table can see, so it never gives away a hand. + +## The sound + +The synth is the one the site's other games share. What is written for this +one is the table: a tile slammed onto an old folding card table (the crack, +the thin top booming, the loose legs rattling), knuckles for a knock, a pencil +stroke for every five on the sheet. The shuffle is a real recording, public +domain (CC0) from Freesound and credited in [NOTICE.md](NOTICE.md), with a +synthesized stand-in if it cannot be loaded. The music is a loop in the son idiom: clave, tumbao, piano on +the off-beats. + +## Where it lives + +, built straight from this repository's +`main`. This is AGPL, and section 13 asks that players be offered the source +of the version they are running. Building from the published ref is what +makes that offer true. + +```bash +npm install +npm run dev # http://localhost:5173 +npm test +npm run build +``` diff --git a/index.html b/index.html new file mode 100644 index 0000000..fae5873 --- /dev/null +++ b/index.html @@ -0,0 +1,131 @@ + + + + + + Dominoes — All Fives, in your browser + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fc53061 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2928 @@ +{ + "name": "dominoes", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dominoes", + "version": "0.1.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^3.2.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz", + "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz", + "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz", + "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz", + "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz", + "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz", + "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz", + "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz", + "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz", + "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz", + "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz", + "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz", + "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz", + "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz", + "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz", + "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz", + "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz", + "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz", + "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz", + "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.85.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz", + "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.85.0", + "@oxlint/binding-android-arm64": "1.85.0", + "@oxlint/binding-darwin-arm64": "1.85.0", + "@oxlint/binding-darwin-x64": "1.85.0", + "@oxlint/binding-freebsd-x64": "1.85.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.85.0", + "@oxlint/binding-linux-arm-musleabihf": "1.85.0", + "@oxlint/binding-linux-arm64-gnu": "1.85.0", + "@oxlint/binding-linux-arm64-musl": "1.85.0", + "@oxlint/binding-linux-ppc64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-gnu": "1.85.0", + "@oxlint/binding-linux-riscv64-musl": "1.85.0", + "@oxlint/binding-linux-s390x-gnu": "1.85.0", + "@oxlint/binding-linux-x64-gnu": "1.85.0", + "@oxlint/binding-linux-x64-musl": "1.85.0", + "@oxlint/binding-openharmony-arm64": "1.85.0", + "@oxlint/binding-win32-arm64-msvc": "1.85.0", + "@oxlint/binding-win32-ia32-msvc": "1.85.0", + "@oxlint/binding-win32-x64-msvc": "1.85.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ccdbaf --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "dominoes", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "test": "vitest run", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "oxlint": "^1.79.0", + "typescript": "~6.0.2", + "vite": "^8.2.2", + "vitest": "^3.2.7" + }, + "description": "Classic double-six dominoes in the browser: the block game, one human against three computer players.", + "license": "AGPL-3.0-or-later", + "author": "John Coffey", + "repository": { + "type": "git", + "url": "git+https://git.coffeylabs.org/jcoffey-dev/dominoes.git" + }, + "homepage": "https://games.jcoffey.dev/dominoes/", + "bugs": { + "url": "https://git.coffeylabs.org/jcoffey-dev/dominoes/issues" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..041c625 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..f93e1b8 --- /dev/null +++ b/src/App.css @@ -0,0 +1,527 @@ +/* ===================================================================== + Dominoes. + + The site's cel look: flat fills, one heavy outline on everything, no + gradients anywhere near the table. The felt is the one big color on the + page, and the tiles are ivory on it, because that is what they are. + ===================================================================== */ + +.app { + min-height: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) 290px; + grid-template-rows: auto minmax(0, 1fr); + grid-template-areas: 'head head' 'room side'; + gap: 10px 18px; + padding: clamp(10px, 2vw, 22px); +} + +.app > header { + grid-area: head; + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 6px 12px; +} + +header .ways { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: baseline; + gap: 6px 10px; + font-size: 0.85rem; + font-weight: 700; +} + +h1 { + margin: 0; + font-size: clamp(1.4rem, 3.2vw, 2.2rem); + font-weight: 900; + letter-spacing: -0.02em; + color: #ffd479; + -webkit-text-stroke: 3px #241a10; + paint-order: stroke fill; +} + +button { + font: inherit; + font-weight: 700; + font-size: 0.85rem; + color: #f2e9d8; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 7px; + padding: 4px 11px; + cursor: pointer; +} + +button:disabled { opacity: 0.4; cursor: default; } +button:focus-visible, .pad:focus-visible { outline: 3px solid #ffd479; outline-offset: 1px; } + +button.go { + color: #241a10; + background: #ffd479; + font-weight: 800; +} + +.sound[aria-pressed='false'] { color: #8c98ab; background: #1b2437; } + +/* Red, because this one cannot be taken back. */ +.leave { color: #ffb3a7; background: #2c3b57; } + +header select { + font: inherit; + font-weight: 700; + color: #f2e9d8; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 1px 6px; +} + +.dim { color: #a3b3c9; } +.small { font-size: 0.78rem; font-weight: 600; } + +.row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + +/* ------------------------------------------------------------ the tiles */ + +.tile rect { fill: #fffaf0; stroke: #241a10; stroke-width: 0.07; } +.tile line { stroke: #241a10; stroke-width: 0.05; stroke-linecap: round; } +.tile .pip { fill: #241a10; } +.tile.last rect { fill: #fff1c7; stroke: #b3770f; stroke-width: 0.1; } +.tile.spinner rect { stroke: #c8452f; stroke-width: 0.12; } +.spin-mark circle { fill: #c8452f; stroke: #241a10; stroke-width: 0.07; } +.spin-mark path { fill: #ffd479; } + +/* Where a held tile can be put down. The same at both ends, fit or not. */ +.pad { cursor: pointer; } +.pad .aim { fill: transparent; } +.pad .spot { fill: rgba(255, 250, 240, 0.12); stroke: #cfe8d9; stroke-width: 0.07; stroke-dasharray: 0.2 0.12; } +.pad:hover .spot, .pad.hot .spot { fill: rgba(255, 212, 121, 0.35); stroke: #ffd479; stroke-width: 0.1; } + +/* ------------------------------------------------------------ the room */ + +.room { + grid-area: room; + min-width: 0; + display: grid; + grid-template-columns: 150px minmax(0, 1fr) 150px; + grid-template-areas: + '. north .' + 'west felt east' + 'mine mine mine'; + gap: 10px; + align-items: center; + align-content: start; +} + +.felt { + grid-area: felt; + position: relative; + min-height: 260px; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + background: #2f6b4f; + border: 3px solid #241a10; + border-radius: 18px; + box-shadow: inset 0 0 0 6px #285c44; +} + +.line { width: 100%; height: auto; display: block; } + +.waiting { + position: absolute; + margin: 0; + color: #cfe8d9; + font-weight: 700; + font-size: 0.95rem; +} + +.seat { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 8px 6px; + border: 2px solid #2b2318; + border-radius: 10px; + background: #1b2437; + min-width: 0; +} +.seat.north { grid-area: north; justify-self: center; min-width: 180px; } +.seat.west { grid-area: west; } +.seat.east { grid-area: east; } +.seat.turn, .mine.turn { box-shadow: 0 0 0 3px #ffd479; } + +.seat header, .mine header { display: flex; align-items: baseline; gap: 8px; } +.pts { + font-weight: 900; + color: #241a10; + background: #ffd479; + border: 2px solid #241a10; + border-radius: 999px; + padding: 0 7px; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; +} + +.backs { display: flex; flex-wrap: wrap; justify-content: center; gap: 3px; min-height: 26px; } +.seat.west .backs, .seat.east .backs { flex-direction: column; flex-wrap: nowrap; } +.back { + width: 13px; + height: 24px; + border: 2px solid #241a10; + border-radius: 3px; + background: #3a5a88; + box-shadow: inset 0 0 0 2px #4e72a6; +} +.seat.west .back, .seat.east .back { width: 24px; height: 13px; } + +.compass { margin-top: -4px; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #8c98ab; } +.seat header { max-width: 100%; flex-wrap: wrap; justify-content: center; text-align: center; } + +/* The talk comes out of the man saying it: a comic bubble off the side of + his seat that faces the table, with its tail pointing back at him. It + floats over the felt rather than pushing anything about. */ +.seat { position: relative; } +.speech { + position: absolute; + z-index: 5; + width: max-content; + max-width: 220px; + margin: 0; + padding: 8px 12px; + font-size: 0.86rem; + font-weight: 800; + line-height: 1.3; + text-align: center; + color: #241a10; + background: #fffaf0; + border: 3px solid #241a10; + border-radius: 14px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); + pointer-events: none; + animation: pop 0.2s cubic-bezier(0.3, 1.6, 0.6, 1); +} +.speech::before, +.speech::after { + content: ''; + position: absolute; + border: solid transparent; +} + +.seat.north .speech { top: calc(100% + 14px); left: 50%; translate: -50% 0; transform-origin: top center; } +.seat.north .speech::before { bottom: 100%; left: calc(50% - 12px); border-width: 0 12px 14px; border-bottom-color: #241a10; } +.seat.north .speech::after { bottom: calc(100% - 4px); left: calc(50% - 8px); border-width: 0 8px 10px; border-bottom-color: #fffaf0; } + +.seat.west .speech { left: calc(100% + 16px); top: 18px; transform-origin: left center; } +.seat.west .speech::before { right: 100%; top: 10px; border-width: 10px 14px 10px 0; border-right-color: #241a10; } +.seat.west .speech::after { right: calc(100% - 4px); top: 14px; border-width: 6px 10px 6px 0; border-right-color: #fffaf0; } + +.seat.east .speech { right: calc(100% + 16px); top: 18px; transform-origin: right center; } +.seat.east .speech::before { left: 100%; top: 10px; border-width: 10px 0 10px 14px; border-left-color: #241a10; } +.seat.east .speech::after { left: calc(100% - 4px); top: 14px; border-width: 6px 0 6px 10px; border-left-color: #fffaf0; } + +/* Your own bubble comes up out of your seat, over the table. */ +.mine { position: relative; } +.mine > .speech { bottom: calc(100% + 14px); left: 50%; translate: -50% 0; transform-origin: bottom center; max-width: min(360px, 90%); } +.mine > .speech::before { top: 100%; left: calc(50% - 12px); border-width: 14px 12px 0; border-top-color: #241a10; } +.mine > .speech::after { top: calc(100% - 4px); left: calc(50% - 8px); border-width: 10px 8px 0; border-top-color: #fffaf0; } + +.mine header .trash { margin-left: 6px; padding: 2px 9px; font-size: 0.75rem; color: #ffb3a7; } +.trash-menu { + display: flex; + flex-direction: column; + gap: 5px; + width: min(460px, 100%); + padding: 8px; + background: #22304a; + border: 2px solid #2b2318; + border-radius: 10px; +} +.trash-menu button { text-align: left; font-weight: 700; line-height: 1.3; background: #1b2437; } +.trash-menu button:hover { background: #3c527a; } +.trash-menu .more { align-self: flex-end; text-align: center; color: #ffd479; background: none; } + +@keyframes pop { from { transform: scale(0.4); opacity: 0; } to { transform: none; opacity: 1; } } + +.bubble { margin: 0; font-size: 0.75rem; font-weight: 800; color: #ffd479; min-height: 1.2em; } + +.mine { + grid-area: mine; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 10px; + border: 2px solid #2b2318; + border-radius: 12px; + background: #1b2437; +} + +.hand { list-style: none; display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin: 0; padding: 0; } + +.held { + padding: 0; + border: none; + background: none; + border-radius: 8px; + transition: transform 0.12s; +} +.held .face { display: block; width: 46px; height: 92px; } +.held:disabled { opacity: 1; cursor: default; } +/* Every tile the same: which of them fit is for the player to see. */ +.mine.turn .held { cursor: grab; touch-action: none; } +.mine.turn .held:hover { transform: translateY(-4px); } +.held.lifted { transform: translateY(-12px); } +.held.lifted .tile rect { stroke: #ffd479; stroke-width: 0.1; } +.held.dragging { opacity: 0.25; } + +.floating { + position: fixed; + z-index: 10; + pointer-events: none; + transform: translate(-50%, -50%) rotate(-6deg) scale(1.08); + filter: drop-shadow(0 10px 12px rgba(0, 0, 0, 0.5)); +} +.floating .face { display: block; width: 46px; height: 92px; } + +.pick { font-size: 0.85rem; font-weight: 700; min-height: 30px; justify-content: center; } +.knock { color: #ffd479; } +.refusal { color: #ffb3a7; } + +/* ---------------------------------------------------------- the count */ + +/* Points scored, called out on the felt: the number, big, rising and gone. */ +.callout { + position: absolute; + inset: 0; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + animation: called 1.8s ease-out forwards; +} +.callout span { + font-size: clamp(3rem, 9vw, 5.5rem); + font-weight: 900; + line-height: 1; + color: #ffd479; + -webkit-text-stroke: 4px #241a10; + paint-order: stroke fill; +} +.callout small { font-size: 0.95rem; font-weight: 900; color: #fffaf0; text-shadow: 0 2px 0 #241a10; text-transform: uppercase; letter-spacing: 0.08em; } +@keyframes called { + 0% { opacity: 0; transform: scale(0.5); } + 12% { opacity: 1; transform: scale(1.1); } + 22% { transform: scale(1); } + 75% { opacity: 1; transform: translateY(-8px); } + 100% { opacity: 0; transform: translateY(-24px); } +} + +.tally { display: inline-flex; flex-direction: column; gap: 1px; vertical-align: middle; } +.tally.empty { color: #5d6b80; } +.tally-line { display: flex; gap: 3px; } +.house { width: 26px; height: 19px; } +.house line { stroke: #f2e9d8; stroke-width: 2.4; stroke-linecap: round; } +.house .slash { stroke: #ffd479; } +.table td.marks { text-align: left; padding-left: 6px; } + +/* ---------------------------------------------------------- the shuffle */ + +.shuffle { position: absolute; inset: 0; overflow: hidden; border-radius: 14px; pointer-events: none; } +.tile-back { + position: absolute; + left: 50%; + top: 50%; + width: 22px; + height: 44px; + border: 2px solid #241a10; + border-radius: 4px; + background: #3a5a88; + box-shadow: inset 0 0 0 3px #4e72a6; + transition-property: transform, opacity; + transition-timing-function: cubic-bezier(0.45, 0, 0.3, 1); +} + +/* The boneyard, in the felt's corner, where the shuffle leaves it. */ +.boneyard { + position: absolute; + left: 10px; + top: 10px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 6px; + background: rgba(0, 0, 0, 0.12); + border: 2px dashed rgba(207, 232, 217, 0.35); + border-radius: 10px; +} +.boneyard:hover { background: rgba(0, 0, 0, 0.22); } +.boneyard .pile { display: grid; grid-template-columns: repeat(4, 13px); gap: 2px; min-height: 22px; } +.boneyard .label { font-size: 0.7rem; font-weight: 800; color: #cfe8d9; text-transform: uppercase; letter-spacing: 0.06em; } +.tile-back.small { position: static; display: block; width: 13px; height: 24px; border-width: 2px; box-shadow: inset 0 0 0 2px #4e72a6; transition: none; } + +/* ------------------------------------------------------------ the end */ + +.ending { + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + width: min(460px, 94%); + padding: 16px 18px; + border: 2px solid #2b2318; + border-radius: 10px; + background: #1b2437; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); +} + +.ending h2 { margin: 0 0 6px; color: #ffd479; font-size: 1.25rem; } +.ending p { margin: 0 0 10px; color: #cbd6e6; line-height: 1.45; } +.ending .final { font-weight: 800; color: #f2e9d8; } + +.reveal { width: 100%; border-collapse: collapse; margin-bottom: 10px; } +.reveal th { text-align: left; padding: 3px 8px 3px 0; white-space: nowrap; } +.reveal td { padding: 3px 0; } +.reveal .num { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; } +.reveal tr.won th, .reveal tr.won .num { color: #ffd479; } +.shown { display: flex; flex-wrap: wrap; gap: 3px; } +.shown .face { width: 15px; height: 30px; } + +/* ------------------------------------------------------------ the side */ + +.side { + grid-area: side; + min-height: 0; + display: flex; + flex-direction: column; + gap: 14px; + font-size: 0.86rem; +} + +.side section { display: flex; flex-direction: column; gap: 6px; } +.side p { margin: 0; line-height: 1.45; } +.side h2 { margin: 0; font-size: 0.95rem; color: #ffd479; } + +.now { + padding: 10px; + border: 2px solid #2b2318; + border-radius: 8px; + background: #22304a; + font-weight: 700; +} + +.table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } +.table th, .table td { padding: 2px 4px; text-align: right; } +.table thead th { font-size: 0.72rem; color: #a3b3c9; font-weight: 700; } +.table tbody th { text-align: left; white-space: nowrap; } +.table tr.now th { color: #ffd479; } + +.log ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; } +.log li { font-size: 0.78rem; line-height: 1.35; color: #cbd6e6; } +.log li.heading { margin-top: 5px; color: #ffd479; font-weight: 700; } + +@media (max-width: 900px) { + .app { + grid-template-columns: 1fr; + grid-template-areas: 'head' 'room' 'side'; + } +} + +/* On a phone the three opponents sit in a row above the felt. The table's + geometry is lost, but the felt gets the whole width, which it needs more. */ +@media (max-width: 640px) { + .room { + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-areas: + 'west north east' + 'felt felt felt' + 'mine mine mine'; + gap: 8px; + } + .seat.north { min-width: 0; justify-self: stretch; } + .seat.west .backs, .seat.east .backs { flex-direction: row; flex-wrap: wrap; } + .seat.west .back, .seat.east .back { width: 10px; height: 18px; } + .back { width: 10px; height: 18px; } + .felt { min-height: 200px; padding: 6px; border-radius: 12px; } + + /* The seats are a row above the felt here, so every bubble drops down + out of its seat, held to its own side of the screen. */ + .seat.west .speech, .seat.north .speech, .seat.east .speech { + top: calc(100% + 14px); + right: auto; + max-width: 62vw; + transform-origin: top center; + } + .seat.west .speech { left: 0; translate: none; } + .seat.north .speech { left: 50%; translate: -50% 0; } + .seat.east .speech { left: auto; right: 0; translate: none; } + .seat.west .speech::before, .seat.east .speech::before { top: auto; bottom: 100%; left: 18px; right: auto; border-width: 0 12px 14px; border-color: transparent transparent #241a10; } + .seat.west .speech::after, .seat.east .speech::after { top: auto; bottom: calc(100% - 4px); left: 22px; right: auto; border-width: 0 8px 10px; border-color: transparent transparent #fffaf0; } + .seat.east .speech::before { left: auto; right: 18px; } + .seat.east .speech::after { left: auto; right: 22px; } + .hand { gap: 5px; } + .held .face { width: 38px; height: 76px; } +} + +/* ---------------------------------------------------------- the landing */ + +.landing { + max-width: 1100px; + margin: 0 auto; + padding: clamp(16px, 4vw, 48px); + line-height: 1.55; +} + +.landing header { display: block; margin-bottom: 22px; } +.landing h1 { font-size: clamp(2rem, 6vw, 3.4rem); } +.landing .tagline { font-size: 1.1rem; color: #a3b3c9; margin: 6px 0 0; } + +.hero { display: flex; gap: 10px; margin-top: 18px; } +.hero .face { width: 40px; height: 80px; } +.hero .face:nth-child(2) { transform: rotate(-8deg); } +.hero .face:nth-child(3) { transform: rotate(5deg) translateY(4px); } +.hero .face:nth-child(4) { transform: rotate(-3deg); } + +.landing .cols { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 4px 36px; +} + +.landing h2 { font-size: 1.05rem; color: #ffd479; margin: 22px 0 6px; letter-spacing: 0.02em; } +.landing p { margin: 0 0 10px; } +.landing ol { margin: 0 0 10px; padding-left: 1.3em; } +.landing ol li { margin-bottom: 6px; } +.landing strong { color: #f2e9d8; } +.landing a { color: #7fc4e8; } + +.landing .choose { margin-top: 26px; } +.landing .choose p { margin: 10px 0 0; } + +.landing .choose .start { + font-size: 1.05rem; + font-weight: 800; + color: #241a10; + background: #ffd479; + padding: 10px 26px; +} + +.landing footer { + margin-top: 34px; + padding-top: 16px; + border-top: 1px solid #2f3b52; + font-size: 0.82rem; + color: #8c98ab; +} +.landing footer p { margin: 0 0 8px; } +.landing footer .ways { font-size: 0.95rem; color: #cbd6e6; margin-bottom: 12px; } diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..8be7a82 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,543 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import './App.css' +import { playFor } from './audio/play' +import { PORCH } from './audio/score' +import { synth } from './audio/synth' +import { Landing } from './components/Landing' +import { lastActs, Seat, Shown } from './components/Seats' +import { Shuffle } from './components/Shuffle' +import { Side } from './components/Side' +import { Table } from './components/Table' +import { TileFace } from './components/Tile' +import { leanings, step, type Leaning } from './game/bot' +import { banter, comeback } from './game/talk' +import { fiveLines } from './game/trash' +import { Game, SEATS, type Side as End } from './game/game' +import { count, TILES, tileName, type Tile } from './game/tiles' + +export type Speed = 'steady' | 'quick' | 'instant' + +const HUMAN = 0 +const TARGET = 250 + +/** + * How long a man takes over his turn, in milliseconds, at steady. + * + * Like a real table: nobody plays the instant it comes round. A man with + * one tile that fits still looks at it a second; a man with choices turns + * his tiles over in his hand; a man with nothing takes a moment to be sure + * before he knocks. The old men take their time, and nobody hurries them. + */ +function thinking(game: Game, speed: Speed): number { + const moves = game.legal().length + // A man fishing in the boneyard reaches for each tile quicker than he thinks over a play. + const base = game.mustDraw ? 700 : moves === 0 ? 1600 : moves === 1 ? 1800 : 2400 + Math.min(moves, 5) * 300 + const old = game.people[game.current].age === 'old' ? 1.3 : 1 + const t = (base + Math.random() * 1500) * old + return speed === 'steady' ? t : t * 0.4 +} + +/** How long a line of talk stays up. */ +const TALK_MS = 3600 + +interface Held { + tile: Tile + /** Where the pointer is, while dragging; null when the tile was tapped and lifted. */ + at: { x: number; y: number } | null +} + +export default function App() { + const [screen, setScreen] = useState<'landing' | 'game'>('landing') + const [version, setVersion] = useState(0) + const bump = useCallback(() => setVersion((v) => v + 1), []) + const gameRef = useRef(null) + const leanRef = useRef([]) + const seen = useRef(0) + + const [speed, setSpeed] = useState('steady') + const [music, setMusic] = useState(true) + const [sfx, setSfx] = useState(true) + const [held, setHeld] = useState(null) + const [hot, setHot] = useState(null) + const [leaving, setLeaving] = useState(false) + /** The tiles are being washed and drawn. Nobody plays until they are. */ + const [shuffling, setShuffling] = useState(false) + const shufflingRef = useRef(false) + shufflingRef.current = shuffling + /** Takes down the listeners of the drag in progress, if there is one. */ + const dropDrag = useRef<(() => void) | null>(null) + /** What each man last said, and when it came down. */ + const [talk, setTalk] = useState>({}) + const [refusal, setRefusal] = useState(null) + /** The player's five lines on offer, when the trash-talk menu is open. */ + const [lines, setLines] = useState(null) + /** Points just scored, called out big on the felt for a moment. */ + const [callout, setCallout] = useState<{ seat: number; points: number; id: number } | null>(null) + + const g = gameRef.current + + const say = useCallback((seat: number, text: string) => { + const id = Date.now() + Math.random() + setTalk((t) => ({ ...t, [seat]: { text, id } })) + window.setTimeout(() => setTalk((t) => (t[seat]?.id === id ? Object.fromEntries(Object.entries(t).filter(([k]) => Number(k) !== seat)) : t)), TALK_MS) + }, []) + + /** Everything that happens goes through here: the sound, the talk, and a redraw. */ + const settle = useCallback(() => { + const game = gameRef.current + if (!game) return + const fresh = game.events.slice(seen.current) + seen.current = game.events.length + playFor(fresh, HUMAN) + const scored = fresh.findLast((e) => e.kind === 'score') + if (scored?.kind === 'score') { + const id = Date.now() + setCallout({ seat: scored.seat, points: scored.points, id }) + window.setTimeout(() => setCallout((c) => (c?.id === id ? null : c)), 1800) + } + // One line per batch, about the last thing in it that anybody would talk about. + for (let i = fresh.length - 1; i >= 0; i--) { + const chat = banter(fresh[i], game, game.people, HUMAN, Math.random) + if (chat) { + say(chat.seat, chat.text) + break + } + } + if (game.phase === 'over') synth.stopTune() + dropDrag.current?.() + setHeld(null) + setHot(null) + setRefusal(null) + bump() + }, [bump, say]) + + // ------------------------------------------------------ the other three + + // Keyed on the version, not on every render: a line of talk going up or + // coming down must not restart a man's think. + useEffect(() => { + const game = gameRef.current + if (!game || screen !== 'game' || game.phase !== 'play' || shuffling) return + if (game.current === HUMAN) return + + if (speed === 'instant') { + // Straight round to the next thing that needs a person. A hand is at + // most a few dozen moves; the ceiling is for a bug, not a game. + const id = window.setTimeout(() => { + for (let i = 0; i < 200 && game.phase === 'play' && game.current !== HUMAN; i++) step(game, leanRef.current[game.current]) + settle() + }, 250) + return () => window.clearTimeout(id) + } + + const id = window.setTimeout(() => { + step(game, leanRef.current[game.current]) + settle() + }, thinking(game, speed)) + return () => window.clearTimeout(id) + }, [version, screen, shuffling, speed, settle]) + + // ------------------------------------------------------------- sound + + useEffect(() => { + synth.setMusic(music) + }, [music]) + useEffect(() => { + synth.setSfx(sfx) + }, [sfx]) + + // ---------------------------------------------------------- starting + + const start = () => { + synth.ensure() + const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0 + gameRef.current = new Game(seed, TARGET) + leanRef.current = leanings(seed, SEATS) + seen.current = 0 + setHeld(null) + setTalk({}) + setLeaving(false) + setShuffling(true) + setScreen('game') + if (music) synth.playTune(PORCH) + settle() + } + + /** The player says something. Most of the time, somebody answers. */ + const talkTrash = (text: string) => { + say(HUMAN, text) + setLines(null) + if (Math.random() < 0.7) { + const back = comeback(gameRef.current!.people, HUMAN, Math.random) + window.setTimeout(() => say(back.seat, back.text), 1300 + Math.random() * 900) + } + } + + const leave = () => { + synth.stopTune() + gameRef.current = null + setScreen('landing') + } + + const nextHand = () => { + gameRef.current?.nextHand() + setShuffling(true) + settle() + } + + // ------------------------------------------------ the player's moves + + const mine = g !== null && g.phase === 'play' && g.current === HUMAN && !shuffling + + /** + * Whether the player may put a tile down right now, asked of the game + * itself rather than of whatever this render remembered: a drag outlives + * the render it started in, and it must not land on somebody else's turn. + */ + const myTurn = () => { + const game = gameRef.current + return game !== null && game.phase === 'play' && game.current === HUMAN && !shufflingRef.current + } + + /** + * Put the held tile down at an end. If it does not match there, it goes + * back in the hand with a noise, and that is all the table says about it. + */ + const put = (tile: Tile, side: End) => { + const g = gameRef.current + if (!g || !myTurn() || !g.hands[HUMAN].includes(tile)) return + if (!g.fits(tile, side)) { + synth.reject() + setHeld(null) + setHot(null) + setRefusal(g.center ? 'That one doesn’t go there.' : 'The double six opens the first hand.') + return + } + g.play(tile, side) + settle() + } + + const knock = () => { + const g = gameRef.current + if (!g || !myTurn()) return + if (g.legal().length) { + synth.reject() + setRefusal('You can’t knock with a play in your hand.') + return + } + if (g.boneyard.length) { + synth.reject() + setRefusal('Not while there’s tiles in the boneyard. Draw.') + return + } + g.pass() + settle() + } + + /** Take a tile from the boneyard. Refused, with no more said, if anything in hand would go. */ + const draw = () => { + const g = gameRef.current + if (!g || !myTurn()) return + if (!g.center) return + if (!g.boneyard.length) { + synth.reject() + setRefusal('The boneyard’s empty.') + return + } + if (g.legal().length) { + synth.reject() + setRefusal('You can’t draw with a play in your hand.') + return + } + g.draw() + settle() + } + + /** Which end, if any, a point on the screen is over: the one under it, or the nearest within reach. */ + const endAt = (x: number, y: number): End | null => { + const under = document.elementFromPoint(x, y)?.closest('[data-side]') + if (under) return under.getAttribute('data-side') as End + let best: End | null = null + let dist = 70 + for (const el of document.querySelectorAll('.pad')) { + const r = el.getBoundingClientRect() + const d = Math.hypot(x - (r.left + r.width / 2), y - (r.top + r.height / 2)) + if (d < dist) { + dist = d + best = el.getAttribute('data-side') as End + } + } + return best + } + + /** Pick a tile up. Drag it to an end and let go, or tap it to lift it and then tap an end. */ + const grab = (tile: Tile, e: React.PointerEvent) => { + if (!myTurn() || e.button !== 0) return + e.preventDefault() + // Only ever one drag. A pointer-up the browser never delivered must not + // leave an old one listening, ready to drop a tile on the next click. + dropDrag.current?.() + const sx = e.clientX + const sy = e.clientY + let dragging = false + const move = (ev: PointerEvent) => { + if (!dragging && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 6) return + if (!dragging) synth.tap() + dragging = true + setHeld({ tile, at: { x: ev.clientX, y: ev.clientY } }) + setHot(endAt(ev.clientX, ev.clientY)) + } + const stop = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + window.removeEventListener('pointercancel', up) + dropDrag.current = null + } + const up = (ev: PointerEvent) => { + stop() + if (!dragging) { + // A tap: lift it, or put it back if it was already lifted. + setHeld((h) => (h?.tile === tile ? null : { tile, at: null })) + setRefusal(null) + synth.tap() + return + } + const side = ev.type === 'pointerup' ? endAt(ev.clientX, ev.clientY) : null + setHot(null) + if (side) put(tile, side) + else setHeld(null) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + window.addEventListener('pointercancel', up) + dropDrag.current = stop + } + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setHeld(null) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + if (screen === 'landing' || !g) { + return + } + + const acts = lastActs(g) + const lastPlay = g.events.findLast((e) => e.kind === 'play' || e.kind === 'deal') + const last = lastPlay?.kind === 'play' ? lastPlay.tile : null + const ended = g.phase === 'over' + const lifted = held?.tile ?? null + + return ( +
+
+

Dominoes

+ +
+ +
+ {([[2, 'north'], [1, 'west'], [3, 'east']] as const).map(([seat, where]) => ( +
+ + + + {held?.at && ( + + )} +
+ ) +} + +/** The end of a hand: everybody's tiles turned over, and the count. */ +function Result({ game, onNext, onLeave }: { game: Game; onNext: () => void; onLeave: () => void }) { + const r = game.result! + const over = game.phase === 'over' + const you = r.winner === HUMAN + const headline = over + ? r.how === 'reached' + ? game.winner === HUMAN + ? `You hit ${game.target}. The game is yours.` + : `${game.names[game.winner!]} hits ${game.target} and takes the game.` + : game.winner === HUMAN + ? 'You win the game.' + : `${game.names[game.winner!]} wins the game.` + : r.how === 'domino' + ? you + ? 'Domino! You went out.' + : `${game.names[r.winner!]} went out.` + : r.winner === null + ? 'Blocked, and tied.' + : `Blocked. ${you ? 'Your' : `${game.names[r.winner]}’s`} count is lowest.` + const sub = + r.how === 'reached' + ? 'It ended on that tile, in the middle of the hand. Here is what everybody was holding.' + : r.winner === null + ? 'Two hands tie for the lowest count, so nobody scores. The lead passes to the left.' + : `${r.pips} pips left in the other hands. ${you ? 'You score' : `${game.names[r.winner]} scores`} ${r.points}.` + const final = over ? [...game.scores].map((s, i) => `${game.names[i]} ${s}`).join(' · ') : null + + return ( +
+

{headline}

+

{sub}

+ + + {r.hands.map((h, s) => ( + + + + + + ))} + +
{game.names[s]} + + {count(h)}
+ {final &&

{final}

} +
+ {over ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/audio/play.ts b/src/audio/play.ts new file mode 100644 index 0000000..5092358 --- /dev/null +++ b/src/audio/play.ts @@ -0,0 +1,33 @@ +import type { GameEvent } from '../game/game' +import { isDouble, pips } from '../game/tiles' +import { synth } from './synth' + +/** + * What a batch of events sounds like. + * + * At a steady pace a batch is one move and gets its sound. At speed it can + * be a whole round of the table, and four clacks at once are one clatter -- + * so a batch makes at most one noise, the most important thing in it, in this + * order: the game, the hand, a tile (and its points), a draw, a knock. A fresh deal makes no sound + * here: the shuffle on screen makes its own, in time with the tiles. + */ +export function playFor(events: readonly GameEvent[], human: number) { + if (!synth.sfxOn || events.length === 0) return + const find = (kind: K) => + events.findLast((e): e is Extract => e.kind === kind) + + const win = find('win') + if (win) return win.seat === human ? synth.victory() : synth.defeat() + const out = find('domino') + if (out) return synth.domino(out.seat === human) + if (find('block')) return synth.blocked() + const play = find('play') + // Harder for a double and for weight, the way people slam them -- and a + // play that scores comes down hardest of all, then goes on the sheet. + const score = find('score') + if (play) synth.smack(score ? 1 : isDouble(play.tile) ? 0.95 : 0.45 + pips(play.tile) / 24) + if (score) synth.tally(score.points / 5) + if (play) return + if (find('draw')) return synth.slide() + if (find('pass')) return synth.knock() +} diff --git a/src/audio/score.ts b/src/audio/score.ts new file mode 100644 index 0000000..aadee42 --- /dev/null +++ b/src/audio/score.ts @@ -0,0 +1,80 @@ +import type { Tune } from './synth' + +/** + * The score: one loop for the table, and what it is. + * + * Not a quotation of anything. Dominoes is the game on the porch and in the + * park across the Caribbean, so the idiom is the one that goes with it -- + * a son clave, a bass that lands ahead of the beat, piano stabs on the + * off-beats -- and none of that belongs to anybody. The tune over the top is + * this game's own. + * + * F major, four bars: I, IV, V7, I, and round again. The bass is the + * tumbao: it plays the fifth on the "and" of two and jumps to the next + * chord's root on four, so every bar arrives before it starts. The clave is + * 3-2, on a pitched tick rather than a drum, because a clave is a pair of + * sticks and a drum kit does not have one. + */ + +const bar = (...steps: string[]) => steps + +export const PORCH: Tune = { + bpm: 100, + stepsPerBeat: 4, + tracks: [ + { + // The tune. + wave: 'triangle', + gain: 0.2, + gate: 0.85, + notes: [ + ...bar('A4', '=', '=', 'C5', '=', '=', 'F5', '=', '=', '=', 'E5', '=', 'D5', '=', 'C5', '='), + ...bar('D5', '=', '=', '=', '.', '.', 'D5', '=', 'F5', '=', '=', '=', 'D5', '=', 'A#4', '='), + ...bar('C5', '=', '=', 'E5', '=', '=', 'G5', '=', '=', '=', 'F5', '=', 'E5', '=', 'D5', '='), + ...bar('C5', '=', '=', '=', '=', '=', 'A4', '=', '=', '=', '.', '.', '.', '.', '.', '.'), + ], + }, + { + // Piano, on the off-beats. + wave: 'pulse25', + gain: 0.035, + gate: 0.45, + notes: [ + ...bar('.', '.', 'F4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.'), + ...bar('.', '.', 'F4/A#4/D5', '.', '.', '.', 'F4/A#4/D5', '.', '.', '.', 'F4/A#4/D5', '.', '.', '.', 'F4/A#4/D5', '.'), + ...bar('.', '.', 'E4/G4/A#4', '.', '.', '.', 'E4/G4/A#4', '.', '.', '.', 'E4/G4/C5', '.', '.', '.', 'E4/G4/A#4', '.'), + ...bar('.', '.', 'F4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.', '.', '.', 'F4/A4/C5', '.'), + ], + }, + { + // The tumbao. + wave: 'triangle', + gain: 0.26, + gate: 0.9, + notes: [ + ...bar('=', '=', '=', '=', '=', '=', 'C3', '=', '=', '=', '=', '=', 'A#1', '=', '=', '='), + ...bar('=', '=', '=', '=', '=', '=', 'F2', '=', '=', '=', '=', '=', 'C2', '=', '=', '='), + ...bar('=', '=', '=', '=', '=', '=', 'G2', '=', '=', '=', '=', '=', 'F2', '=', '=', '='), + ...bar('=', '=', '=', '=', '=', '=', 'C3', '=', '=', '=', '=', '=', 'F2', '=', '=', '='), + ], + }, + { + // The clave, 3-2, twice. + wave: 'triangle', + gain: 0.07, + gate: 0.12, + notes: [ + ...bar('E6', '.', '.', '.', '.', '.', 'E6', '.', '.', '.', '.', '.', 'E6', '.', '.', '.'), + ...bar('.', '.', '.', '.', 'E6', '.', '.', '.', 'E6', '.', '.', '.', '.', '.', '.', '.'), + ...bar('E6', '.', '.', '.', '.', '.', 'E6', '.', '.', '.', '.', '.', 'E6', '.', '.', '.'), + ...bar('.', '.', '.', '.', 'E6', '.', '.', '.', 'E6', '.', '.', '.', '.', '.', '.', '.'), + ], + }, + { + // A shaker on the eighths, and a soft low drum on one. + wave: 'noise', + gain: 0.05, + notes: Array.from({ length: 64 }, (_, i) => (i % 16 === 0 ? 'K' : i % 2 === 0 ? 'H' : '.')), + }, + ], +} diff --git a/src/audio/shuffle.mp3 b/src/audio/shuffle.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..117ef1bd54d0e3666c792d4e86c7058494eafd42 GIT binary patch literal 58557 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

#k0vCe-Fr(gka4Z^<=Xx9Llp=h8t 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 literal 0 HcmV?d00001 diff --git a/src/audio/synth.ts b/src/audio/synth.ts new file mode 100644 index 0000000..34b6378 --- /dev/null +++ b/src/audio/synth.ts @@ -0,0 +1,682 @@ +/** + * The synth, shared with its siblings. + * + * The engine below -- the scheduler, the six voices and the small kit -- is + * the one written for the lemonade stand and carried through the cave game, + * the starship, the seven powers and the world war. Same author, same + * license, and deliberately not forked: a look-ahead scheduler is not the + * part of a game worth writing six times. + * + * What is written *for this game* is everything under "the table", and the + * score in `score.ts`. This is a game of tiles hitting wood, so that sound + * gets the most care. The shuffle is the one sound that is recorded rather + * than made, because nothing made sounded like twenty-eight real tiles. + */ + +import shuffleUrl from './shuffle.mp3' + +/** + * The one recorded sound: real dominoes shuffled on a table, the first few + * seconds of RobertWulfman's "Shuffling Dominos" on Freesound, which he + * released into the public domain (CC0). Trimmed, leveled and faded here; + * see NOTICE.md. Fetched as soon as the page loads, so it is in hand by the + * time anybody presses Shuffle up. + */ +const shuffleBytes: Promise = + typeof window === 'undefined' + ? Promise.resolve(null) + : fetch(shuffleUrl) + .then((r) => (r.ok ? r.arrayBuffer() : null)) + .catch(() => null) + +const NOTE_INDEX: Record = { + C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, + 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11, +} + +/** "C#4" -> Hz. A4 = 440. */ +export function noteToFreq(note: string): number { + const m = /^([A-G]#?)(-?\d)$/.exec(note) + if (!m) return 0 + const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12 + return 440 * Math.pow(2, (semis - 69) / 12) +} + +export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise' + +export interface Track { + wave: Wave + gain: number + /** + * One entry per step. '.' rest, '=' sustain previous, otherwise a note. + * Slashes stack notes into a chord: "F4/A4/C5". On a noise track the + * letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat. + */ + notes: string[] + /** Sweeping lowpass, the whole point of a funk bass. */ + filter?: { from: number; to: number; q?: number } + /** Fraction of the note's length actually sounded; low values are stabs. */ + gate?: number + /** Cents, for a fatter unison. */ + detune?: number +} + +export interface Tune { + bpm: number + stepsPerBeat: number + tracks: Track[] + /** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */ + swing?: number +} + +/** Fourier series for a pulse wave of the given duty cycle. */ +function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave { + const real = new Float32Array(harmonics + 1) + const imag = new Float32Array(harmonics + 1) + for (let n = 1; n <= harmonics; n++) { + imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty) + } + return ctx.createPeriodicWave(real, imag, { disableNormalization: false }) +} + +export class Synth { + private ctx: AudioContext | null = null + private master!: GainNode + private musicBus!: GainNode + private sfxBus!: GainNode + private waves: Partial> = {} + private noiseBuffer!: AudioBuffer + private shuffleBuffer: Promise | null = null + + private tune: Tune | null = null + private step = 0 + private nextStepTime = 0 + private timer: number | null = null + + musicOn = true + sfxOn = true + + /** Must be called from a user gesture the first time. */ + ensure(): AudioContext { + if (this.ctx) { + if (this.ctx.state === 'suspended') void this.ctx.resume() + return this.ctx + } + const ctx = new AudioContext() + this.ctx = ctx + this.master = ctx.createGain() + this.master.gain.value = 0.5 + this.master.connect(ctx.destination) + + /* + * The buses open at whatever the toggles already say, not at full. + * A one-shot calls `ensure` itself, so a game started with the sound + * turned off would otherwise make exactly one noise -- the first one -- + * before anything got round to muting it. + */ + this.musicBus = ctx.createGain() + this.musicBus.gain.value = this.musicOn ? 0.55 : 0 + this.musicBus.connect(this.master) + + this.sfxBus = ctx.createGain() + this.sfxBus.gain.value = this.sfxOn ? 0.9 : 0 + this.sfxBus.connect(this.master) + + this.waves.pulse12 = pulseWave(ctx, 0.125) + this.waves.pulse25 = pulseWave(ctx, 0.25) + this.waves.pulse50 = pulseWave(ctx, 0.5) + + const len = Math.floor(ctx.sampleRate * 1.5) + const buf = ctx.createBuffer(1, len, ctx.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1 + this.noiseBuffer = buf + + return ctx + } + + setMusic(on: boolean) { + this.musicOn = on + if (!this.ctx) return + this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05) + } + + setSfx(on: boolean) { + this.sfxOn = on + if (!this.ctx) return + this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02) + } + + // ---------------------------------------------------------------- voices + + private voice( + dest: AudioNode, + wave: Wave, + freq: number, + at: number, + dur: number, + gain: number, + opts: { filter?: Track['filter']; detune?: number } = {}, + ) { + const ctx = this.ensure() + const osc = ctx.createOscillator() + if (wave === 'triangle') osc.type = 'triangle' + else if (wave === 'saw') osc.type = 'sawtooth' + else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!) + osc.frequency.setValueAtTime(freq, at) + if (opts.detune) osc.detune.setValueAtTime(opts.detune, at) + + const env = ctx.createGain() + const peak = Math.max(0.0001, gain) + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(peak, at + 0.008) + env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6)) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + + let node: AudioNode = osc + if (opts.filter) { + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.Q.value = opts.filter.q ?? 6 + lp.frequency.setValueAtTime(opts.filter.from, at) + lp.frequency.exponentialRampToValueAtTime( + Math.max(60, opts.filter.to), + at + Math.max(0.05, dur), + ) + osc.connect(lp) + node = lp + } + + node.connect(env).connect(dest) + osc.start(at) + osc.stop(at + dur + 0.02) + } + + /** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */ + private drum(dest: AudioNode, kind: string, at: number, gain: number) { + const ctx = this.ensure() + + if (kind === 'K') { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(130, at) + osc.frequency.exponentialRampToValueAtTime(42, at + 0.11) + const env = ctx.createGain() + env.gain.setValueAtTime(gain * 1.5, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24) + osc.connect(env).connect(dest) + osc.start(at) + osc.stop(at + 0.26) + return + } + + const noise = ctx.createBufferSource() + noise.buffer = this.noiseBuffer + const filter = ctx.createBiquadFilter() + const env = ctx.createGain() + let dur = 0.05 + + if (kind === 'S' || kind === 'C') { + filter.type = 'bandpass' + filter.frequency.value = kind === 'S' ? 1900 : 1300 + filter.Q.value = kind === 'S' ? 0.9 : 2.4 + dur = kind === 'S' ? 0.16 : 0.1 + if (kind === 'S') { + // A little body under the crack. + const tone = ctx.createOscillator() + tone.type = 'triangle' + tone.frequency.setValueAtTime(210, at) + tone.frequency.exponentialRampToValueAtTime(150, at + 0.09) + const tenv = ctx.createGain() + tenv.gain.setValueAtTime(gain * 0.6, at) + tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1) + tone.connect(tenv).connect(dest) + tone.start(at) + tone.stop(at + 0.12) + } + } else { + filter.type = 'highpass' + filter.frequency.value = 7200 + dur = kind === 'O' ? 0.22 : 0.035 + } + + env.gain.setValueAtTime(gain, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + noise.connect(filter).connect(env).connect(dest) + noise.start(at) + noise.stop(at + dur + 0.02) + } + + // ------------------------------------------------------------- sequencer + + playTune(tune: Tune, restart = true) { + this.ensure() + if (this.tune === tune && this.timer !== null && !restart) return + this.stopTune() + this.tune = tune + this.step = 0 + this.nextStepTime = this.ctx!.currentTime + 0.08 + this.timer = window.setInterval(() => this.schedule(), 25) + } + + stopTune() { + if (this.timer !== null) window.clearInterval(this.timer) + this.timer = null + this.tune = null + } + + get playing() { + return this.timer !== null + } + + private schedule() { + const ctx = this.ctx + const tune = this.tune + if (!ctx || !tune) return + const stepDur = 60 / tune.bpm / tune.stepsPerBeat + const length = Math.max(...tune.tracks.map((t) => t.notes.length)) + const swing = tune.swing ?? 0 + + while (this.nextStepTime < ctx.currentTime + 0.2) { + // A shuffle pushes every other step late without moving the downbeats. + const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0) + + for (const track of tune.tracks) { + const note = track.notes[this.step % track.notes.length] + if (!note || note === '.' || note === '=') continue + + // A note runs until the next step that is not a sustain marker. + let held = 1 + for (let i = 1; i < length; i++) { + if (track.notes[(this.step + i) % track.notes.length] === '=') held++ + else break + } + const dur = held * stepDur * (track.gate ?? 0.95) + + if (track.wave === 'noise') { + this.drum(this.musicBus, note, at, track.gain) + continue + } + + for (const part of note.split('/')) { + const f = noteToFreq(part) + if (!f) continue + this.voice(this.musicBus, track.wave, f, at, dur, track.gain, { + filter: track.filter, + detune: track.detune, + }) + } + } + this.nextStepTime += stepDur + this.step = (this.step + 1) % length + } + } + + // ------------------------------------------------------------------ sfx + + private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) { + const ctx = this.ensure() + let t = ctx.currentTime + 0.01 + for (const [note, dur] of notes) { + if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain) + t += dur + } + } + + /** A click on the map. Small, dry, and not a musical note. */ + tap() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = 2600 + bp.Q.value = 1.4 + const env = ctx.createGain() + env.gain.setValueAtTime(0.1, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.035) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 0.05) + } + + /** A choice made: a territory picked, a number settled. */ + written() { + this.seq([['B4', 0.045], ['E5', 0.08]], 'triangle', 0.14) + } + + /** Something the rules will not take. */ + reject() { + this.seq([['A3', 0.09], ['D#3', 0.18]], 'saw', 0.16) + } + + // ------------------------------------------------------------ the table + + /** + * One short woody knock: a burst of bandpassed noise with a touch of pitch + * under it. Everything a tile does on a table is made of these. + */ + private knockAt(at: number, freq: number, gain: number, dur = 0.05) { + const ctx = this.ensure() + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = freq + bp.Q.value = 4 + const env = ctx.createGain() + env.gain.setValueAtTime(gain, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + dur + 0.02) + + const body = ctx.createOscillator() + body.type = 'triangle' + body.frequency.setValueAtTime(freq / 4, at) + body.frequency.exponentialRampToValueAtTime(freq / 7, at + dur) + const benv = ctx.createGain() + benv.gain.setValueAtTime(gain * 0.7, at) + benv.gain.exponentialRampToValueAtTime(0.0001, at + dur * 1.4) + body.connect(benv).connect(this.sfxBus) + body.start(at) + body.stop(at + dur * 1.4 + 0.02) + } + + /** + * One hard little ping: a few milliseconds of noise through a narrow + * resonant filter, which is what a hard tile striking another hard tile + * sounds like -- a click with a short bright ring on it. + */ + private ping(at: number, freq: number, q: number, gain: number, decay: number) { + const ctx = this.ensure() + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + src.playbackRate.value = 1 + Math.random() + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = freq + bp.Q.value = q + const env = ctx.createGain() + env.gain.setValueAtTime(gain, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + decay) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + decay + 0.02) + } + + /** + * A tile slammed down on an old folding card table. + * + * Four things at once. The crack of the tile's edge hitting the top; the + * thin top itself, which booms low and dies fast because it is a sheet of + * board on a frame; the panel ringing a little higher; and, just behind + * it, the loose metal legs rattling. `power` is how hard: a double, a + * heavy tile and going out all come down harder, the way people play. + */ + smack(power = 0.7) { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const p = Math.max(0.2, Math.min(1, power)) + + // The crack. + const crack = ctx.createBufferSource() + crack.buffer = this.noiseBuffer + const hp = ctx.createBiquadFilter() + hp.type = 'highpass' + hp.frequency.value = 1800 + const cenv = ctx.createGain() + cenv.gain.setValueAtTime(0.55 * p, at) + cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.012) + crack.connect(hp).connect(cenv).connect(this.sfxBus) + crack.start(at) + crack.stop(at + 0.03) + this.ping(at, 3400 + Math.random() * 900, 14, 0.12 * p, 0.05) + + // The top, booming. + const boom = ctx.createOscillator() + boom.type = 'sine' + boom.frequency.setValueAtTime(128, at) + boom.frequency.exponentialRampToValueAtTime(78, at + 0.16) + const benv = ctx.createGain() + benv.gain.setValueAtTime(0.0001, at) + benv.gain.exponentialRampToValueAtTime(0.7 * p, at + 0.004) + benv.gain.exponentialRampToValueAtTime(0.0001, at + 0.2) + boom.connect(benv).connect(this.sfxBus) + boom.start(at) + boom.stop(at + 0.22) + + // The panel, a little higher and hollow. + const panel = ctx.createBufferSource() + panel.buffer = this.noiseBuffer + panel.playbackRate.value = 0.7 + const pb = ctx.createBiquadFilter() + pb.type = 'bandpass' + pb.frequency.value = 240 + pb.Q.value = 2.5 + const penv = ctx.createGain() + penv.gain.setValueAtTime(1.3 * p, at) + penv.gain.exponentialRampToValueAtTime(0.0001, at + 0.13) + panel.connect(pb).connect(penv).connect(this.sfxBus) + panel.start(at) + panel.stop(at + 0.15) + + // The legs. + for (let i = 0; i < 4; i++) { + this.ping(at + 0.018 + i * (0.016 + Math.random() * 0.01), 1500 + Math.random() * 700, 9, 0.05 * p * (1 - i / 5), 0.03) + } + } + + /** Knuckles on the same table: two hollow raps. */ + knock() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [t, g] of [[0, 0.5], [0.17, 0.42]] as const) { + this.knockAt(at + t, 420, g, 0.08) + const thump = ctx.createOscillator() + thump.type = 'sine' + thump.frequency.setValueAtTime(150, at + t) + thump.frequency.exponentialRampToValueAtTime(95, at + t + 0.1) + const env = ctx.createGain() + env.gain.setValueAtTime(0.35, at + t) + env.gain.exponentialRampToValueAtTime(0.0001, at + t + 0.12) + thump.connect(env).connect(this.sfxBus) + thump.start(at + t) + thump.stop(at + t + 0.14) + } + } + + /** + * The shuffle, for real: the recording, cut to however long the tiles are + * moving on screen and faded out there. If the recording could not be had, + * the synthesized one below stands in, so there is always a shuffle. + */ + shuffle(seconds = 2.6) { + const ctx = this.ensure() + this.shuffleBuffer ??= shuffleBytes.then((b) => (b ? ctx.decodeAudioData(b) : null)).catch(() => null) + const asked = ctx.currentTime + void this.shuffleBuffer.then((buf) => { + if (!buf) return this.synthShuffle(seconds) + // However long the decode took comes off the front, so it still ends with the tiles. + const late = Math.max(0, ctx.currentTime - asked) + const length = Math.min(buf.duration, seconds + 0.6) - late + if (length <= 0.1) return + const at = ctx.currentTime + 0.01 + const src = ctx.createBufferSource() + src.buffer = buf + const env = ctx.createGain() + env.gain.setValueAtTime(1, at) + env.gain.setValueAtTime(1, at + Math.max(0, length - 0.35)) + env.gain.linearRampToValueAtTime(0.0001, at + length) + src.connect(env).connect(this.sfxBus) + src.start(at, late, length) + }) + } + + /** + * The synthesized shuffle, for when the recording is not there: twenty- + * eight tiles washed round face down under two flat hands. + * + * Listen to a real one and it is not a handful of clicks. It is a + * continuous, dense clatter -- a hundred-odd collisions a second, each a + * hard click with a short bright ring -- over the dry scrape of tiles + * sliding on the tabletop, and the whole thing surges with every push of + * the hands, twice or so a second. Now and then a tile knocks the table + * itself, lower. That is what is built here. + */ + private synthShuffle(seconds: number) { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const surges = 2.2 + // How hard the hands are pushing at time t: never quite still. + const push = (t: number) => 0.25 + 0.75 * Math.sin(Math.PI * surges * t) ** 2 + const fade = (t: number) => Math.min(1, t / 0.15, (seconds - t) / 0.3) + + // The scrape, following the hands. + const scrape = ctx.createBufferSource() + scrape.buffer = this.noiseBuffer + scrape.loop = true + const shp = ctx.createBiquadFilter() + shp.type = 'highpass' + shp.frequency.value = 1400 + const slp = ctx.createBiquadFilter() + slp.type = 'lowpass' + slp.frequency.value = 7000 + const senv = ctx.createGain() + senv.gain.setValueAtTime(0.0001, at) + for (let t = 0; t <= seconds; t += 0.05) senv.gain.linearRampToValueAtTime(0.045 * push(t) * fade(t) + 0.0001, at + t) + scrape.connect(shp).connect(slp).connect(senv).connect(this.sfxBus) + scrape.start(at) + scrape.stop(at + seconds + 0.05) + + // The clatter. + let t = 0 + while (t < seconds) { + const level = push(t) * fade(t) + this.ping(at + t, 2200 + Math.random() * 4300, 10 + Math.random() * 10, (0.025 + Math.random() * 0.075) * level, 0.015 + Math.random() * 0.035) + if (Math.random() < 0.05) this.knockAt(at + t, 700 + Math.random() * 400, 0.08 * level, 0.04) + // Busier while the hands are pushing. + t += (0.004 + Math.random() * 0.014) / (0.4 + level) + } + } + + /** One tile pulled out of the boneyard: slid across the felt, then a click as it joins the hand. */ + slide() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.Q.value = 1.2 + bp.frequency.setValueAtTime(1800, at) + bp.frequency.linearRampToValueAtTime(2600, at + 0.22) + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.linearRampToValueAtTime(0.07, at + 0.05) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 0.26) + this.ping(at + 0.25, 3000 + Math.random() * 800, 12, 0.1, 0.035) + this.ping(at + 0.27, 2400 + Math.random() * 600, 10, 0.06, 0.03) + } + + /** Tiles drawn off the pile to the four seats: slid out in fives, a click as each one stops. */ + draw(count = 28, seconds = 0.65) { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (let i = 0; i < count; i++) { + const t = at + (i / count) * seconds + Math.random() * 0.01 + this.ping(t, 2800 + Math.random() * 2000, 12, 0.05 + Math.random() * 0.04, 0.03) + } + } + + /** + * Out. The last tile slapped down, then a short run up to a held note -- + * brighter when it is yours. + */ + domino(mine: boolean) { + this.smack(1) + const run: [string, number][] = mine + ? [['.', 0.12], ['C5', 0.08], ['E5', 0.08], ['G5', 0.08], ['C6', 0.4]] + : [['.', 0.12], ['G4', 0.1], ['E4', 0.1], ['C4', 0.35]] + this.seq(run, 'triangle', 0.16) + } + + /** + * Points going on the score sheet: a pencil stroke for every five. A + * stroke is a short scratch of bright noise, swept a little as the lead + * drags, and the slash that closes a box of twenty-five is longer. + */ + tally(marks: number, closes = false) { + const ctx = this.ensure() + const at = ctx.currentTime + 0.28 + const n = Math.min(marks, 8) + for (let i = 0; i < n; i++) { + const t = at + i * 0.13 + const long = closes && i === n - 1 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.Q.value = 1.8 + bp.frequency.setValueAtTime(3600, t) + bp.frequency.linearRampToValueAtTime(long ? 5200 : 4400, t + (long ? 0.16 : 0.07)) + const env = ctx.createGain() + const len = long ? 0.17 : 0.075 + env.gain.setValueAtTime(0.0001, t) + env.gain.linearRampToValueAtTime(0.11, t + 0.01) + env.gain.setValueAtTime(0.11, t + len * 0.7) + env.gain.exponentialRampToValueAtTime(0.0001, t + len) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(t) + src.stop(t + len + 0.02) + } + } + + /** Nobody can go. Two falling notes, and the table goes quiet. */ + blocked() { + this.seq([['E4', 0.14], ['C4', 0.14], ['G3', 0.45]], 'triangle', 0.15) + } + + // ---------------------------------------------------------- the endings + + private bell(at: number, note: string, partials: readonly (readonly [number, number, number])[]) { + const ctx = this.ensure() + const f = noteToFreq(note) + for (const [mult, gain, dur] of partials) { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = f * mult + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(gain, at + 0.01) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + osc.connect(env).connect(this.sfxBus) + osc.start(at) + osc.stop(at + dur + 0.05) + } + } + + /** The game is yours. Bells, going up. */ + victory() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [i, note] of ['F4', 'A4', 'C5', 'F5', 'C5', 'F5'].entries()) { + this.bell(at + i * 0.32, note, [[1, 0.13, 2.4], [2.76, 0.05, 1], [5.4, 0.025, 0.5]]) + } + } + + /** Somebody else's game: the same bells, fewer of them, coming down. */ + defeat() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [i, note] of ['C5', 'A4', 'F4', 'C4'].entries()) { + this.bell(at + i * 0.45, note, [[1, 0.12, 2.8], [2.76, 0.04, 1.1]]) + } + } +} + +export const synth = new Synth() diff --git a/src/components/Crash.tsx b/src/components/Crash.tsx new file mode 100644 index 0000000..ef7a45e --- /dev/null +++ b/src/components/Crash.tsx @@ -0,0 +1,32 @@ +import { Component, type ReactNode } from 'react' + +/** + * If anything in the game throws while drawing, say so and offer a way on, + * instead of leaving the player looking at an empty page. The error goes to + * the console for whoever wants to report it. + */ +export class Crash extends Component<{ children: ReactNode }, { error: Error | null }> { + state = { error: null as Error | null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + componentDidCatch(error: Error) { + console.error('Dominoes crashed:', error) + } + + render() { + if (!this.state.error) return this.props.children + return ( +

+ ) + } +} diff --git a/src/components/Landing.tsx b/src/components/Landing.tsx new file mode 100644 index 0000000..6fcb53f --- /dev/null +++ b/src/components/Landing.tsx @@ -0,0 +1,117 @@ +import { TileFace } from './Tile' + +/** + * The page before the table. + * + * Everybody has played dominoes, and almost everybody learned it from a + * table with its own house rules. So the rules are stated in full -- they + * are short -- along with the places where tables differ, and which way this + * one goes. + */ +export function Landing({ target, onStart }: { target: number; onStart: () => void }) { + return ( +
+
+

Dominoes

+

+ All Fives with a double-six set, four at the table, first to 250. You against three old hands and young bucks, each for himself, and every one of them has something to say. +

+ +
+ +
+
+

The whole game

+

+ All Fives. Five tiles each, and the other eight face down in the boneyard. +

+
    +
  1. + The first hand is opened by whoever was dealt the highest double, with that + double. After that, whoever won the last hand leads, with any tile they like. +
  2. +
  3. + Play goes to the left. Match a number on any open end. Doubles lie crosswise. + Nothing that matches? Draw from the boneyard, one at a time, until something does. Only when the + boneyard is empty do you knock and let the turn pass. +
  4. +
  5. + The spinner is the first double down, marked with a star. Once both its sides are + covered, its top and bottom open too, and the line runs four ways. +
  6. +
  7. + Score as you play. After every tile, add up the open ends. A multiple of five -- + 5, 10, 15, 20 and on -- goes straight on your sheet. +
  8. +
  9. + The hand ends when somebody plays their last tile, or when nobody can match any end + and it is blocked. +
  10. +
+

+ Whoever goes out scores the pips left in the other three hands, to the nearest five. In a blocked + hand, the lowest count scores them instead. First to {target} wins, the moment they get there. +

+
+ +
+

Counting the ends

+

+ A double on the end of the line counts both halves: the 5–5 out on an end is ten. The spinner counts + both halves too, until both its sides are covered; after that only the ends count, and its top and + bottom count once something is on them. The first tile alone counts both ends, so leading the 5–0, + 6–4 or 5–5 scores straight away. +

+

The sheet

+

+ Scores go down the way they do at the table: a stroke for every five, and the fifth stroke a slash + across the four, so every box is twenty-five. Ten boxes is the game. +

+

At this table

+

+ Each man plays for himself. Drag a tile to an open end, or tap it and tap the end; hit Draw, or the + boneyard, to fish. Nobody tells you what fits, or what it scores -- count it yourself. A draw or a + knock tells everybody which numbers you are out of, and these three are listening. They know what a careful player knows and nothing else: they never + see your hand. +

+
+
+ +
+ +

First to {target} wins.

+
+ +
+
+ ) +} diff --git a/src/components/Seats.tsx b/src/components/Seats.tsx new file mode 100644 index 0000000..5cee98f --- /dev/null +++ b/src/components/Seats.tsx @@ -0,0 +1,69 @@ +import type { Game } from '../game/game' +import { COMPASS } from '../game/names' +import { TILES, type Tile } from '../game/tiles' +import { TileFace } from './Tile' + +/** What each seat last did this hand, for the little speech bubble. */ +export function lastActs(g: Game): ('knock' | 'play' | null)[] { + const acts: ('knock' | 'play' | null)[] = [null, null, null, null] + for (let i = g.events.length - 1; i >= 0; i--) { + const e = g.events[i] + if (e.kind === 'deal') break + if ((e.kind === 'play' || e.kind === 'pass') && acts[e.seat] === null) acts[e.seat] = e.kind === 'play' ? 'play' : 'knock' + } + return acts +} + +/** An opponent: name, score, and the backs of the tiles they hold. */ +export function Seat({ + game, + seat, + where, + act, + hidden, + says, +}: { + game: Game + seat: number + where: 'west' | 'north' | 'east' + act: 'knock' | 'play' | null + /** Still shuffling: nobody holds anything yet. */ + hidden: boolean + /** What he is saying, if anything. */ + says: string | null +}) { + const turn = game.phase === 'play' && game.current === seat && !hidden + const held = hidden ? 0 : game.hands[seat].length + return ( +
+
+ {game.names[seat]} + {game.scores[seat]} +
+ {COMPASS[seat].toLowerCase()} + +

{turn ? 'thinking…' : act === 'knock' ? 'knocked' : ' '}

+ {says && ( +

+ {says} +

+ )} +
+ ) +} + +/** Tiles face up, small, for the end of a hand. */ +export function Shown({ tiles }: { tiles: readonly Tile[] }) { + return ( + + {tiles.map((t) => ( + + ))} + {tiles.length === 0 && out} + + ) +} diff --git a/src/components/Shuffle.tsx b/src/components/Shuffle.tsx new file mode 100644 index 0000000..5edd798 --- /dev/null +++ b/src/components/Shuffle.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react' +import { synth } from '../audio/synth' + +/** + * The shuffle, before every hand. + * + * All twenty-eight tiles face down on the felt, pushed round in a few big + * swirls the way hands wash them on a real table, and then drawn off to the + * four seats, five each, with the rest pushed into the boneyard. The sound + * is timed to it: a wash of clacks while the tiles are moving, and a run of + * small clicks as they are drawn. + * + * Every position is worked out here and moved by a CSS transition, so the + * tiles glide between spots instead of jumping, and the whole thing is + * skipped down to a short pause for anyone who has asked for reduced motion. + */ + +const TILES = 28 +const SWIRLS = 6 +const SWIRL_MS = 440 +const DEAL_MS = 650 + +interface Pose { + x: number + y: number + r: number + o: number +} + +export function Shuffle({ onDone }: { onDone: () => void }) { + const box = useRef(null) + const [poses, setPoses] = useState(() => Array.from({ length: TILES }, () => ({ x: 0, y: 0, r: 0, o: 0 }))) + const [ms, setMs] = useState(SWIRL_MS) + const done = useRef(onDone) + done.current = onDone + + useEffect(() => { + const el = box.current! + const { width, height } = el.getBoundingClientRect() + const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const timers: number[] = [] + const at = (t: number, f: () => void) => timers.push(window.setTimeout(f, t)) + + // Anywhere on the felt, clear of the edge. + const scatter = (spread: number): Pose[] => + Array.from({ length: TILES }, () => ({ + x: (Math.random() - 0.5) * width * spread, + y: (Math.random() - 0.5) * height * spread, + r: Math.random() * 360, + o: 1, + })) + + // Off to the seats, five each -- you at the bottom, then West, North, + // East -- and the last eight pushed together into the boneyard in the + // corner, where they stay. + const dealt = (): Pose[] => + Array.from({ length: TILES }, (_, i) => { + if (i >= 20) { + const k = i - 20 + return { x: -width / 2 + 34 + (k % 4) * 15, y: -height / 2 + 34 + Math.floor(k / 4) * 26, r: 0, o: 1 } + } + const seat = i % 4 + const [dx, dy] = [[0, 0.75], [-0.75, 0], [0, -0.75], [0.75, 0]][seat] + return { x: dx * width, y: dy * height, r: seat % 2 ? 90 : 0, o: 0 } + }) + + if (still) { + synth.shuffle(0.6) + at(700, () => done.current()) + return () => timers.forEach(clearTimeout) + } + + synth.shuffle((SWIRLS * SWIRL_MS) / 1000) + // A frame at the center first, so the first swirl has somewhere to come from. + at(30, () => setPoses(scatter(0.5))) + for (let k = 1; k < SWIRLS; k++) at(30 + k * SWIRL_MS, () => setPoses(scatter(0.55 + 0.1 * (k % 2)))) + at(30 + SWIRLS * SWIRL_MS, () => { + setMs(DEAL_MS) + setPoses(dealt()) + synth.draw(20, DEAL_MS / 1000) + }) + at(30 + SWIRLS * SWIRL_MS + DEAL_MS + 80, () => done.current()) + return () => timers.forEach(clearTimeout) + }, []) + + return ( +
+ {poses.map((p, i) => ( + + ))} +
+ ) +} diff --git a/src/components/Side.tsx b/src/components/Side.tsx new file mode 100644 index 0000000..5b18490 --- /dev/null +++ b/src/components/Side.tsx @@ -0,0 +1,74 @@ +import { describe } from '../game/describe' +import { Tally } from './Tally' +import { SEATS, type Game } from '../game/game' + +/** + * The column beside the table: what is happening now, the score, and the + * hand so far in words. The log is only this hand -- the previous hands are + * in the score, and nobody scrolls back through them -- which is short + * enough to read top to bottom, in the order it happened. + */ +export function Side({ game, human, shuffling }: { game: Game; human: number; shuffling: boolean }) { + const start = game.events.findLastIndex((e) => e.kind === 'deal') + // Nothing about the new hand until the tiles are in people's hands. + const lines = shuffling ? [] : describe(game.events.slice(start), human, game.names) + + return ( + + ) +} + +/** + * One line on what is happening. On the player's turn it says only that it + * is their turn: which tiles fit is for them to see, as it is at any table. + */ +function now(g: Game, human: number): string { + if (g.phase === 'over') return g.winner === human ? 'The game is yours.' : `${g.names[g.winner!]} has the game.` + if (g.phase === 'scored') return 'The hand is over.' + if (g.current !== human) return `${g.names[g.current]} to play.` + if (!g.center) return g.mustOpen !== null ? 'You have the high double. Put it down to start.' : 'Your lead.' + return 'Your turn.' +} diff --git a/src/components/Table.tsx b/src/components/Table.tsx new file mode 100644 index 0000000..b7ff8ed --- /dev/null +++ b/src/components/Table.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from 'react' +import type { Game, Link, Side } from '../game/game' +import { bounds, layout } from '../game/layout' +import { has, other, TILES, type Tile } from '../game/tiles' +import { TileG } from './Tile' + +/** + * The felt, and the line of play on it. + * + * How long a row runs before it turns depends on how wide the table is, so a + * phone gets short rows of big-enough tiles instead of one long row of tiny + * ones. While the player holds a tile, both open ends are marked as places + * to put it down -- whether it fits there or not is for the player to see. + */ +export function Table({ + game, + shuffling, + held, + hot, + last, + onEnd, + onDraw, + children, +}: { + game: Game + shuffling: boolean + /** A tile in the player's hand, lifted or being dragged. Both ends open up for it, fit or not. */ + held: Tile | null + /** The end the dragged tile is over. */ + hot: Side | null + last: Tile | null + onEnd: (side: Side) => void + /** Draw from the boneyard, if the player may. */ + onDraw: () => void + children?: React.ReactNode +}) { + const wrap = useRef(null) + const [px, setPx] = useState(640) + + useEffect(() => { + const el = wrap.current + if (!el) return + const ro = new ResizeObserver(([e]) => setPx(e.contentRect.width)) + ro.observe(el) + return () => ro.disconnect() + }, []) + + // About 28 pixels a half-tile, in whole tiles, never so few that a + // crosswise double and a corner cannot both fit. + const width = Math.max(10, Math.min(26, 2 * Math.floor(px / 56))) + + // Where the held tile would go at each open end. Only the place is drawn, + // never the tile in it: the table does not tell anybody what fits where. + const pads: Partial> = {} + if (held !== null) { + for (const e of game.ends) pads[e.side] = { tile: held, inner: e.value, outer: has(held, e.value) ? other(held, e.value) : e.value } + } + const lead = held !== null && !game.center + const spots = lead + ? layout({ ...game, center: { tile: held, left: TILES[held][1], right: TILES[held][0] } }, width).map((s) => ({ ...s, ghost: true, arm: 'right' as const })) + : layout(game, width, pads) + + const b = bounds(spots) + const y0 = Math.min(b.y0, -3) - 0.6 + const y1 = Math.max(b.y1, 3) + 0.6 + const x0 = -width / 2 - 0.6 + const w = width + 1.2 + + return ( +
+ + {spots.map((s) => + s.ghost ? ( + onEnd(s.arm as Side)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onEnd(s.arm as Side) + } + }} + > + {/* A generous target: the outline, and a margin round it to aim at. */} + + + + ) : ( + + + {s.spinner && ( + // The spinner's mark: a small star at its corner, so the table can see which double opens four ways. + + The spinner + + + + )} + + ), + )} + + {!game.center && game.phase === 'play' && !shuffling && !lead && ( +

{game.mustOpen !== null ? 'Waiting for the high double.' : 'Waiting for the lead.'}

+ )} + {!shuffling && game.phase === 'play' && ( + // The boneyard: what is left face down, in the corner where the shuffle pushed it. + + )} + {children} +
+ ) +} + +/** A five-pointed star centered on the origin. */ +function star(outer: number, inner: number): string { + const pts = Array.from({ length: 10 }, (_, i) => { + const r = i % 2 ? inner : outer + const a = -Math.PI / 2 + (i * Math.PI) / 5 + return `${(r * Math.cos(a)).toFixed(3)},${(r * Math.sin(a)).toFixed(3)}` + }) + return `M${pts.join('L')}Z` +} + +function tableLabel(g: Game): string { + if (!g.center) return 'The table is empty.' + const n = g.played.length + const ends = g.ends.map((e) => `${e.value} ${e.side === 'up' ? 'at the top' : e.side === 'down' ? 'at the bottom' : `on the ${e.side}`}`) + return `${n} tile${n === 1 ? '' : 's'} down. The ends show ${ends.join(', ')}.` +} diff --git a/src/components/Tally.tsx b/src/components/Tally.tsx new file mode 100644 index 0000000..9d1fd39 --- /dev/null +++ b/src/components/Tally.tsx @@ -0,0 +1,29 @@ +/** + * A score the way it goes down on the back of an envelope at the table: + * one stroke for every five points, and the fifth stroke a slash through + * the four before it, so each box is twenty-five. Two boxes to a line, and + * a new line every fifty, so a game to 250 is five full lines. + */ +export function Tally({ points }: { points: number }) { + const marks = Math.floor(points / 5) + const houses = Array.from({ length: Math.ceil(marks / 5) }, (_, i) => Math.min(5, marks - i * 5)) + const lines: number[][] = [] + for (let i = 0; i < houses.length; i += 2) lines.push(houses.slice(i, i + 2)) + if (marks === 0) return — + return ( + + {lines.map((line, i) => ( + + {line.map((n, j) => ( + + ))} + + ))} + + ) +} diff --git a/src/components/Tile.tsx b/src/components/Tile.tsx new file mode 100644 index 0000000..e278add --- /dev/null +++ b/src/components/Tile.tsx @@ -0,0 +1,70 @@ +/** + * A tile, drawn in half-tile units: 2 by 1 lying down, 1 by 2 on end. + * + * Pips sit where they sit on a real set. A six on a tile standing up is two + * columns of three; lying down it turns with the tile and becomes two rows, + * which is what everybody's eye already expects. + */ + +const P = 0.27 +const Q = 1 - P +const PIPS: Record = { + 0: [], + 1: [[0.5, 0.5]], + 2: [[P, P], [Q, Q]], + 3: [[P, P], [0.5, 0.5], [Q, Q]], + 4: [[P, P], [Q, P], [P, Q], [Q, Q]], + 5: [[P, P], [Q, P], [0.5, 0.5], [P, Q], [Q, Q]], + 6: [[P, 0.24], [P, 0.5], [P, 0.76], [Q, 0.24], [Q, 0.5], [Q, 0.76]], +} + +function Half({ n, x, y, lying }: { n: number; x: number; y: number; lying: boolean }) { + return ( + <> + {PIPS[n].map(([px, py], i) => ( + + ))} + + ) +} + +export function TileG({ + x, + y, + w, + h, + first, + second, + className = '', +}: { + x: number + y: number + w: number + h: number + first: number + second: number + className?: string +}) { + const lying = w > h + return ( + + + {lying ? ( + + ) : ( + + )} + + + + ) +} + +/** A tile on its own, standing up, for a hand. */ +export function TileFace({ first, second }: { first: number; second: number }) { + return ( + + ) +} diff --git a/src/game/bot.ts b/src/game/bot.ts new file mode 100644 index 0000000..2408639 --- /dev/null +++ b/src/game/bot.ts @@ -0,0 +1,135 @@ +import { SEATS, type Game, type Move } from './game' +import { makeRng } from './rng' +import { TILE_COUNT, TILES, count, has, isDouble, pips, type Tile } from './tiles' + +/** + * How a computer player chooses a tile. + * + * It knows what anybody at a real table knows: its own tiles, every tile on + * the table, how many each player holds and how many are in the boneyard, + * and which numbers each of them has knocked or drawn on. It never looks at another hand. Everything below is worked out + * from those, the way a careful player counts. + * + * Each legal move is priced, and the dearest is played. What makes a move + * good, roughly in order of how much it usually matters: + * + * - it goes out, which ends the argument; + * - it scores: the ends add up to a multiple of five, and the points are + * on the board now instead of maybe later; + * - it blocks the hand when this player is sure to have the lowest count, + * and never when it is not; + * - it leaves ends the next player has already knocked on, or numbers + * nobody else can hold -- especially when somebody is down to a tile or two; + * - it sheds weight, because every pip still held at the end is a point for + * somebody else; + * - it gets a double down, since a double only ever fits one number; + * - it leaves this player something to play next time round. + */ + +export interface Leaning { + /** Appetite for dumping heavy tiles early. */ + shed: number + /** Hurry to get doubles off the hand. */ + doubles: number + /** Care for keeping a playable hand. */ + hold: number + /** Relish for leaving a neighbor stuck. */ + spite: number +} + +/** + * Each bot leans a little one way or another, so three of them are not the + * same player three times. The lean comes from the seed, so a seed still + * replays a whole game exactly. + */ +export function leanings(seed: number, n: number): Leaning[] { + const rng = makeRng(seed ^ 0x5eed) + const around = () => 0.75 + rng() * 0.5 + return Array.from({ length: n }, () => ({ shed: around(), doubles: around(), hold: around(), spite: around() })) +} + +export const EVEN: Leaning = { shed: 1, doubles: 1, hold: 1, spite: 1 } + +/** The move this seat would make, or null to knock. */ +export function decide(g: Game, lean: Leaning = EVEN): Move | null { + const moves = g.legal() + if (moves.length === 0) return null + if (moves.length === 1) return moves[0] + let best = moves[0] + let bestScore = -Infinity + for (const m of moves) { + const s = price(g, g.current, m, lean) + if (s > bestScore) { + best = m + bestScore = s + } + } + return best +} + +/** + * One action: play what `decide` picks, or draw one tile if nothing fits and + * there is something to draw, or knock. A draw leaves the turn with the same + * player, so the loop in the app paces every tile drawn, the way a man at a + * table draws them one at a time. The tests go through here too. + */ +export function step(g: Game, lean: Leaning = EVEN): Move | 'draw' | null { + const m = decide(g, lean) + if (m) g.play(m.tile, m.side) + else if (g.mustDraw) { + g.draw() + return 'draw' + } else g.pass() + return m +} + +export function price(g: Game, seat: number, m: Move, lean: Leaning): number { + const hand = g.hands[seat] + const after = hand.filter((t) => t !== m.tile) + if (after.length === 0) return 1e9 + + const [a, b] = TILES[m.tile] + const showing = g.endsAfter(m.tile, m.side) + const matches = (t: Tile) => showing.some((v) => has(t, v)) + + // Tiles this seat cannot see: in somebody else's hand. + const seen = new Set([...g.played, ...hand]) + const unseen: Tile[] = [] + for (let t = 0; t < TILE_COUNT; t++) if (!seen.has(t)) unseen.push(t) + const outThere = unseen.some(matches) + + // The hand locks after this move if nobody can possibly follow it -- + // which, with tiles still in the boneyard, is not yet: they get drawn first. + if (g.boneyard.length === 0 && !outThere && !after.some(matches)) { + // Certain, not probable: each opponent is given the lightest tiles they + // could possibly be holding, and this seat still has to come in under + // every one of them. "Probably lowest" is a coin that costs the whole + // hand when it lands wrong. + const mine = count(after) + const light = [...unseen].sort((x, y) => pips(x) - pips(y)) + const floor = (k: number) => count(light.slice(0, g.hands[(seat + k) % SEATS].length)) + const sure = unseen.length > 0 && [1, 2, 3].every((k) => mine < floor(k)) + return sure ? 5e8 - mine : -5e8 + } + + // Points on the board now are worth more than anything that might happen later. + let s = 2.2 * g.scoreAfter(m.tile, m.side) + lean.shed * (a + b) + if (isDouble(m.tile)) s += lean.doubles * 5 + + const follow = after.filter(matches).length + s += lean.hold * 2.5 * Math.min(follow, 3) + s += lean.hold * new Set(after.flatMap((t) => TILES[t])).size + + for (let k = 1; k < SEATS; k++) { + const o = (seat + k) % SEATS + const weight = k === 1 ? 1 : 0.45 + const close = g.hands[o].length <= 2 ? 2.5 : 1 + const stuck = !outThere || showing.every((v) => g.voids[o][v]) + if (stuck) s += lean.spite * 6 * weight * close + else if (close > 1) s -= lean.spite * 4 * weight + } + + // A tie in everything else goes to the heavier tile, not to whichever + // happened to be dealt first. + return s + pips(m.tile) * 0.01 +} diff --git a/src/game/describe.test.ts b/src/game/describe.test.ts new file mode 100644 index 0000000..43571df --- /dev/null +++ b/src/game/describe.test.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest' +import { describe as tell } from './describe' +import { Game } from './game' +import { DOUBLE_SIX, tileOf as t } from './tiles' + +it('tells the hand in plain words, with "you" for the player', () => { + const g = new Game(1) + g.events = [] + g.setUp([[t(6, 6), t(0, 1)], [t(2, 3)], [t(3, 4)], [t(1, 6)]], 0, DOUBLE_SIX) + g.play(t(6, 6), 'right') + g.pass() + expect(tell(g.events, 0, ['You', 'West', 'North', 'East']).map((l) => l.text)).toEqual([ + 'Hand 2', + 'You have the high double, the double six.', + 'You set the double six. The spinner.', + 'West knocks: no six.', + ]) +}) diff --git a/src/game/describe.ts b/src/game/describe.ts new file mode 100644 index 0000000..05eaa99 --- /dev/null +++ b/src/game/describe.ts @@ -0,0 +1,76 @@ +import type { GameEvent } from './game' +import { isDouble, NUMBERS, tileName } from './tiles' + +/** + * The log, in words, with "you" for the player and everybody else by name. + */ + +const WHERE = { + left: 'on the left', + right: 'on the right', + up: 'on top of the spinner', + down: 'under the spinner', +} as const + +export interface Line { + seat: number | null + text: string + heading?: boolean +} + +const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}` + +export function describe(events: readonly GameEvent[], human: number, names: readonly string[]): Line[] { + const who = (s: number) => (s === human ? 'You' : names[s]) + const verb = (s: number, you: string, they: string) => (s === human ? you : they) + const lines: Line[] = [] + for (const e of events) { + switch (e.kind) { + case 'deal': + lines.push({ seat: null, heading: true, text: `Hand ${e.hand}` }) + // A later hand's lead says itself when the tile goes down. + if (e.mustOpen !== null) { + lines.push({ seat: e.leader, text: `${who(e.leader)} ${verb(e.leader, 'have', 'has')} the high double, the ${tileName(e.mustOpen)}.` }) + } + break + case 'play': + lines.push({ + seat: e.seat, + text: e.side + ? `${who(e.seat)} ${verb(e.seat, 'play', 'plays')} the ${tileName(e.tile)} ${WHERE[e.side]}${e.spinner ? '. The spinner' : ''}.` + : isDouble(e.tile) + ? `${who(e.seat)} ${verb(e.seat, 'set', 'sets')} the ${tileName(e.tile)}${e.spinner ? '. The spinner' : ''}.` + : `${who(e.seat)} ${verb(e.seat, 'lead', 'leads')} the ${tileName(e.tile)}.`, + }) + break + case 'pass': + lines.push({ + seat: e.seat, + text: `${who(e.seat)} ${verb(e.seat, 'knock', 'knocks')}: ${e.ends.map((n) => `no ${NUMBERS[n]}`).join(', ')}.`, + }) + break + case 'draw': + lines.push({ seat: e.seat, text: `${who(e.seat)} ${verb(e.seat, 'draw', 'draws')}.` }) + break + case 'score': + lines.push({ seat: e.seat, text: `${who(e.seat)} ${verb(e.seat, 'score', 'scores')} ${e.points}.` }) + break + case 'domino': + lines.push({ seat: e.seat, text: `${who(e.seat)} ${verb(e.seat, 'go', 'goes')} out, for ${plural(e.points, 'point')}.` }) + break + case 'block': + lines.push({ + seat: e.seat, + text: + e.seat === null + ? 'Blocked, and the lowest count is tied. Nobody scores.' + : `Blocked. ${who(e.seat)} ${verb(e.seat, 'have', 'has')} the lowest count, for ${plural(e.points, 'point')}.`, + }) + break + case 'win': + lines.push({ seat: e.seat, heading: true, text: e.seat === human ? 'You win the game.' : `${who(e.seat)} wins the game.` }) + break + } + } + return lines +} diff --git a/src/game/game.test.ts b/src/game/game.test.ts new file mode 100644 index 0000000..a89d1cd --- /dev/null +++ b/src/game/game.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { Game } from './game' +import { DOUBLE_SIX, TILES, TILE_COUNT, count, tileOf } from './tiles' + +const t = tileOf + +describe('the set', () => { + it('is twenty-eight different tiles with 168 pips between them', () => { + expect(TILE_COUNT).toBe(28) + expect(new Set(TILES.map(([a, b]) => `${a}${b}`)).size).toBe(28) + expect(count(TILES.map((_, i) => i))).toBe(168) + }) +}) + +describe('the deal', () => { + it('gives five to each of four and leaves eight in the boneyard', () => { + for (let seed = 1; seed <= 50; seed++) { + const g = new Game(seed) + expect(g.hands.map((h) => h.length)).toEqual([5, 5, 5, 5]) + expect(g.boneyard).toHaveLength(8) + expect(new Set([...g.hands.flat(), ...g.boneyard]).size).toBe(28) + } + }) + + it('makes the holder of the highest double dealt open the first hand, with it and nothing else', () => { + for (let seed = 1; seed <= 50; seed++) { + const g = new Game(seed) + const high = Math.max(...g.hands.flat().filter((x) => TILES[x][0] === TILES[x][1])) + expect(g.mustOpen).toBe(high) + expect(g.hands[g.current]).toContain(high) + expect(g.legal()).toEqual([{ tile: high, side: 'right' }]) + } + }) +}) + +describe('play', () => { + it('matches ends and moves them', () => { + const g = new Game(1) + g.setUp( + [ + [t(6, 6), t(0, 0), t(0, 1), t(0, 2), t(0, 3), t(0, 4), t(0, 5)], + [t(6, 4), t(1, 1), t(1, 2), t(1, 3), t(1, 4), t(1, 5), t(0, 6)], + [t(6, 5), t(2, 2), t(2, 3), t(2, 4), t(2, 5), t(1, 6), t(2, 6)], + [t(3, 3), t(3, 4), t(3, 5), t(3, 6), t(4, 4), t(4, 5), t(4, 6)], + ], + 0, + DOUBLE_SIX, + ) + g.play(t(6, 6), 'right') + expect(g.endValues).toEqual([6]) + expect(g.current).toBe(1) + g.play(t(6, 4), 'right') + expect(g.ends.map((e) => e.value)).toEqual([6, 4]) + expect(() => g.play(t(2, 2), 'left')).toThrow() + g.play(t(6, 5), 'left') + // Both sides of the spinner are covered, so its top and bottom open. + expect(g.ends.map((e) => [e.side, e.value])).toEqual([['left', 5], ['right', 4], ['up', 6], ['down', 6]]) + // A tile that fits two ends is two moves. + expect(g.legal().filter((m) => m.tile === t(4, 5)).map((m) => m.side)).toEqual(['left', 'right']) + }) + + it('makes a player with nothing that fits draw, one tile at a time, before he may knock', () => { + const g = new Game(1) + g.setUp( + [[t(6, 6), t(0, 0)], [t(1, 1), t(1, 2)], [t(3, 3)], [t(4, 4)]], + 0, + DOUBLE_SIX, + [t(2, 3), t(6, 1), t(0, 5)], + ) + g.play(t(6, 6), 'right') + expect(() => g.pass()).toThrow() + expect(g.mustDraw).toBe(true) + expect(g.draw()).toBe(t(0, 5)) + expect(g.voids[1][6]).toBe(true) + expect(g.mustDraw).toBe(true) + expect(g.draw()).toBe(t(6, 1)) + // Now he has a six: no more drawing, and no knocking either. + expect(g.mustDraw).toBe(false) + expect(() => g.draw()).toThrow() + expect(g.current).toBe(1) + g.play(t(6, 1), 'right') + }) + + it('refuses a knock from a player who can play, and remembers the numbers a knock gives away', () => { + const g = new Game(1) + g.setUp( + [ + [t(6, 6), t(0, 0), t(0, 1), t(0, 2), t(0, 3), t(0, 4), t(0, 5)], + [t(1, 1), t(1, 2), t(1, 3), t(1, 4), t(1, 5), t(2, 2), t(2, 3)], + [t(6, 5), t(0, 6), t(2, 4), t(2, 5), t(1, 6), t(2, 6), t(3, 3)], + [t(3, 4), t(3, 5), t(3, 6), t(4, 4), t(4, 5), t(4, 6), t(5, 5)], + ], + 0, + DOUBLE_SIX, + ) + g.play(t(6, 6), 'right') + expect(g.legal()).toEqual([]) + g.pass() + expect(g.voids[1][6]).toBe(true) + expect(() => g.pass()).toThrow() + }) +}) + +describe('the count', () => { + const at = (hands: number[][]) => { + const g = new Game(1) + g.setUp(hands, 0, null) + return g + } + + it('adds the open ends, with a double on an end counting both halves', () => { + const g = at([[t(5, 5), t(0, 0)], [t(5, 0), t(1, 1)], [t(0, 3), t(2, 2)], [t(6, 6), t(4, 4)]]) + g.play(t(5, 5), 'right') + expect(g.count).toBe(10) // the spinner alone: both halves + expect(g.scores[0]).toBe(10) + g.play(t(5, 0), 'right') + expect(g.count).toBe(10) // spinner still counts, one side open: 10 + 0 + expect(g.scores[1]).toBe(10) + g.play(t(0, 3), 'right') + expect(g.count).toBe(13) + expect(g.scores[2]).toBe(0) + }) + + it('stops counting the spinner once both its sides are covered', () => { + const g = at([[t(5, 5), t(1, 1)], [t(5, 0), t(2, 2)], [t(5, 3), t(4, 4)], [t(6, 6), t(3, 3)]]) + g.play(t(5, 5), 'right') + g.play(t(5, 0), 'right') + g.play(t(5, 3), 'left') + expect(g.count).toBe(3) // 3 + 0: the spinner has dropped out + expect(g.ends.map((e) => e.side)).toEqual(['left', 'right', 'up', 'down']) + }) + + it('scores the first tile if it is a multiple of five', () => { + const g = at([[t(6, 4), t(1, 1)], [t(2, 2)], [t(3, 3)], [t(0, 0)]]) + g.play(t(6, 4), 'right') + expect(g.scores[0]).toBe(10) + }) + + it('ends the game the moment somebody reaches the target', () => { + const g = new Game(1, 10) + g.setUp([[t(5, 5), t(1, 1)], [t(2, 2)], [t(3, 3)], [t(0, 0)]], 0, null) + g.play(t(5, 5), 'right') + expect(g.phase).toBe('over') + expect(g.winner).toBe(0) + expect(g.result?.how).toBe('reached') + }) +}) + +describe('scoring', () => { + it('keeps the board in fives', async () => { + const { toFive } = await import('./game') + expect([0, 2, 3, 12, 13, 22, 23, 29, 31].map(toFive)).toEqual([0, 0, 5, 10, 15, 20, 25, 30, 30]) + }) + + + it('gives the player who goes out every pip left in the other hands, to the nearest five', () => { + const g = new Game(1) + g.setUp([[t(6, 6)], [t(1, 2)], [t(3, 4)], [t(0, 6)]], 0, null) + g.play(t(6, 6), 'right') + expect(g.result).toMatchObject({ how: 'domino', winner: 0, pips: 3 + 7 + 6, points: 15 }) + expect(g.scores).toEqual([15, 0, 0, 0]) + expect(g.phase).toBe('scored') + }) + + it('gives a blocked hand to the lowest count', () => { + // Every six but the last is already somewhere nobody can use it: once + // the 6-6 goes down against a six, there is no six left in any hand. + const g = new Game(1) + g.setUp([[t(6, 6), t(0, 1)], [t(4, 5)], [t(2, 2)], [t(3, 3)]], 0, null) + g.play(t(6, 6), 'right') + expect(g.result).toMatchObject({ how: 'block', winner: 0, pips: 9 + 4 + 6, points: 20 }) + }) + + it('scores nobody when the lowest count is tied, and moves the lead left', () => { + const g = new Game(1) + g.setUp([[t(6, 6), t(0, 2)], [t(1, 1)], [t(4, 5)], [t(3, 5)]], 0, null) + g.play(t(6, 6), 'right') + expect(g.result).toMatchObject({ how: 'block', winner: null, points: 0 }) + g.nextHand() + expect(g.current).toBe(1) + expect(g.mustOpen).toBeNull() + }) + + it('ends the game at the target', () => { + const g = new Game(1, 10) + g.setUp([[t(6, 6)], [t(1, 2)], [t(3, 4)], [t(0, 5)]], 0, null) + g.play(t(6, 6), 'right') + expect(g.phase).toBe('over') + expect(g.winner).toBe(0) + }) +}) diff --git a/src/game/game.ts b/src/game/game.ts new file mode 100644 index 0000000..fca96e4 --- /dev/null +++ b/src/game/game.ts @@ -0,0 +1,427 @@ +import { seatPeople, type Person } from './names' +import { makeRng, shuffle, type Rng } from './rng' +import { TILE_COUNT, count, has, isDouble, other, sortHand, tileOf, TILES, type Tile } from './tiles' + +/** + * All Fives, four players, each for themselves. + * + * Five tiles each and the other eight face down in the boneyard. A player + * who cannot match an open end draws from it, one at a time, until he can; + * only when the boneyard is empty does he knock and let the turn go by. + * Play passes to the left, which from the seat at the bottom of the screen is + * West, then North, then East. + * + * The first hand is opened by whoever was dealt the highest double, with + * that double. Every later hand is led by the last hand's winner, with + * anything. + * + * The points come as you play. After every tile the open ends are added up + * -- a double on an end counts both halves -- and if the total is a + * multiple of five, whoever laid it scores that many on the spot. + * + * A hand ends when somebody lays their last tile, or when nobody at the table + * holds a tile that matches any open end. Whoever went out, or holds the lowest + * count in a blocked hand, scores every pip left in the other three hands, + * to the nearest five. The first to the target wins the game, the moment + * they reach it, in the middle of a hand if that is where it happens. + * + * The first double laid is the spinner. The line runs through it like any + * double, and once both of those sides are covered, its top and bottom open + * too, and the line can grow four ways instead of two. Until then the + * spinner counts both its halves in the total; after, only the ends count. + */ + +export const SEATS = 4 +export const HAND_SIZE = 5 + +export type Side = 'left' | 'right' | 'up' | 'down' +export const SIDES: readonly Side[] = ['left', 'right', 'up', 'down'] + +/** Where the spinner is: the first tile, or a double somewhere out along one arm of the line. */ +export interface Spinner { + tile: Tile + arm: 'center' | 'left' | 'right' + index: number +} + +export interface End { + side: Side + value: number +} + +export interface Move { + tile: Tile + side: Side +} + +/** A tile on the table, away from the first one: the end it was laid against, and the end it leaves. */ +export interface Link { + tile: Tile + inner: number + outer: number +} + +export interface Center { + tile: Tile + left: number + right: number +} + +export interface HandResult { + /** Out, blocked, or somebody reached the target in the middle of the hand. */ + how: 'domino' | 'block' | 'reached' + /** Null when a blocked hand ties for the lowest count, and nobody scores. */ + winner: number | null + /** What went on the board: the pips, to the nearest five. */ + points: number + /** The pips themselves, before rounding. */ + pips: number + counts: number[] + hands: Tile[][] +} + +export type GameEvent = + | { kind: 'deal'; hand: number; leader: number; mustOpen: Tile | null } + | { kind: 'play'; seat: number; tile: Tile; side: Side | null; spinner: boolean } + | { kind: 'pass'; seat: number; ends: number[] } + | { kind: 'draw'; seat: number; tile: Tile } + | { kind: 'score'; seat: number; points: number } + | { kind: 'domino'; seat: number; points: number } + | { kind: 'block'; seat: number | null; points: number; counts: number[] } + | { kind: 'win'; seat: number } + +export type Phase = 'play' | 'scored' | 'over' + +/** To the nearest five: 22 is 20, 23 is 25. */ +export const toFive = (n: number) => Math.round(n / 5) * 5 + +export class Game { + readonly rng: Rng + readonly target: number + /** Who sits where: 'You', then the three at the table. */ + readonly people: Person[] + readonly names: string[] + + hands: Tile[][] = [] + /** Face down in the middle, to be drawn by whoever cannot go. */ + boneyard: Tile[] = [] + center: Center | null = null + left: Link[] = [] + right: Link[] = [] + up: Link[] = [] + down: Link[] = [] + spinner: Spinner | null = null + + phase: Phase = 'play' + current = 0 + leader = 0 + handNo = 0 + /** The highest double dealt, which has to open the first hand. Null once it is down, and in every later hand. */ + mustOpen: Tile | null = null + scores = [0, 0, 0, 0] + /** + * What everybody at the table knows about who is out of which number: a + * player who knocks on a 3 and a 5 holds neither. Reset every hand. The bots + * read this and the hand sizes, and never anybody's tiles. + */ + voids: boolean[][] = [] + played: Tile[] = [] + result: HandResult | null = null + winner: number | null = null + events: GameEvent[] = [] + + constructor(seed: number, target = 250) { + this.rng = makeRng(seed) + this.target = target + this.people = seatPeople(seed) + this.names = this.people.map((p) => p.name) + this.deal(null) + } + + // ------------------------------------------------------------ dealing + + private deal(leader: number | null) { + for (;;) { + const deck = shuffle( + this.rng, + Array.from({ length: TILE_COUNT }, (_, t) => t), + ) + const hands = Array.from({ length: SEATS }, (_, s) => deck.slice(s * HAND_SIZE, (s + 1) * HAND_SIZE)) + const boneyard = deck.slice(SEATS * HAND_SIZE) + if (leader !== null) return this.setUp(hands, leader, null, boneyard) + // The highest double dealt opens. Nobody dealt a double at all is a + // misdeal, and the tiles go back in and round again. + for (let n = 6; n >= 0; n--) { + const d = tileOf(n, n) + const holder = hands.findIndex((h) => h.includes(d)) + if (holder >= 0) return this.setUp(hands, holder, d, boneyard) + } + } + } + + /** A hand from given tiles. The deal uses it, and so do the tests. */ + setUp(hands: Tile[][], leader: number, mustOpen: Tile | null, boneyard: Tile[] = []) { + this.hands = hands.map(sortHand) + this.boneyard = [...boneyard] + this.center = null + this.left = [] + this.right = [] + this.up = [] + this.down = [] + this.spinner = null + this.played = [] + this.voids = Array.from({ length: SEATS }, () => Array(7).fill(false)) + this.result = null + this.phase = 'play' + this.handNo++ + this.leader = leader + this.current = leader + this.mustOpen = mustOpen + this.events.push({ kind: 'deal', hand: this.handNo, leader, mustOpen }) + } + + nextHand() { + if (this.phase !== 'scored' || !this.result) throw new Error('the hand is not over') + // A tied block scores nothing, and the lead moves on to the left. + this.deal(this.result.winner ?? (this.leader + 1) % SEATS) + } + + // -------------------------------------------------------------- state + + arm(side: Side): Link[] { + return side === 'left' ? this.left : side === 'right' ? this.right : side === 'up' ? this.up : this.down + } + + /** + * Whether the spinner's top and bottom are open: both sides of it along the + * line are covered. On the first tile that is one tile each way; out on an + * arm, its inner side is the line itself, so one tile beyond it will do. + */ + get spinnerOpen(): boolean { + const sp = this.spinner + if (!sp) return false + if (sp.arm === 'center') return this.left.length > 0 && this.right.length > 0 + return this.arm(sp.arm).length > sp.index + 1 + } + + /** Every open end and the number showing on it. Empty before the first tile. */ + get ends(): End[] { + const c = this.center + if (!c) return [] + const tip = (side: Side, start: number): End => { + const a = this.arm(side) + return { side, value: a.length ? a[a.length - 1].outer : start } + } + const out = [tip('left', c.left), tip('right', c.right)] + if (this.spinner && this.spinnerOpen) { + const n = TILES[this.spinner.tile][0] + out.push(tip('up', n), tip('down', n)) + } + return out + } + + /** The numbers showing, each once. What a knock gives away. */ + get endValues(): number[] { + return [...new Set(this.ends.map((e) => e.value))] + } + + /** + * The count: every open end added up, the way All Fives adds them. A + * double on the end of an arm counts both halves. The first tile, alone, + * counts both its ends; a double there counts both halves once. The + * spinner's top and bottom count only once something is on them. + */ + get count(): number { + const c = this.center + if (!c) return 0 + if (!this.left.length && !this.right.length) return c.left + c.right + const tip = (arm: Link[]) => { + const l = arm[arm.length - 1] + return isDouble(l.tile) ? 2 * l.inner : l.outer + } + const d = isDouble(c.tile) ? 2 : 1 + let n = (this.left.length ? tip(this.left) : d * c.left) + (this.right.length ? tip(this.right) : d * c.right) + if (this.up.length) n += tip(this.up) + if (this.down.length) n += tip(this.down) + return n + } + + /** What the count would score: itself if it is a multiple of five, otherwise nothing. */ + get scoring(): number { + const n = this.count + return n > 0 && n % 5 === 0 ? n : 0 + } + + fits(t: Tile, side: Side): boolean { + if (!this.center) return this.mustOpen === null || t === this.mustOpen + const end = this.ends.find((e) => e.side === side) + return end !== undefined && has(t, end.value) + } + + /** Every move open to a seat: each tile, on each end it matches. */ + legal(seat = this.current): Move[] { + if (this.phase !== 'play' || seat !== this.current) return [] + const hand = this.hands[seat] + if (!this.center) { + return hand.filter((t) => this.mustOpen === null || t === this.mustOpen).map((tile) => ({ tile, side: 'right' as const })) + } + const ends = this.ends + const moves: Move[] = [] + for (const tile of hand) for (const e of ends) if (has(tile, e.value)) moves.push({ tile, side: e.side }) + return moves + } + + // -------------------------------------------------------------- moves + + play(tile: Tile, side: Side) { + if (this.phase !== 'play') throw new Error('the hand is over') + const seat = this.current + const hand = this.hands[seat] + const at = hand.indexOf(tile) + if (at < 0) throw new Error('not in hand') + if (!this.fits(tile, side)) throw new Error('does not fit') + + const first = !this.center + let spun = false + if (first) { + const [a, b] = TILES[tile] + this.center = { tile, left: a, right: b } + this.mustOpen = null + if (isDouble(tile)) { + this.spinner = { tile, arm: 'center', index: 0 } + spun = true + } + } else { + const end = this.ends.find((e) => e.side === side)!.value + const arm = this.arm(side) + arm.push({ tile, inner: end, outer: other(tile, end) }) + if (!this.spinner && isDouble(tile) && (side === 'left' || side === 'right')) { + this.spinner = { tile, arm: side, index: arm.length - 1 } + spun = true + } + } + hand.splice(at, 1) + this.played.push(tile) + this.events.push({ kind: 'play', seat, tile, side: first ? null : side, spinner: spun }) + + const points = this.scoring + if (points) { + this.scores[seat] += points + this.events.push({ kind: 'score', seat, points }) + if (this.scores[seat] >= this.target) return this.finish('reached', seat) + } + + if (hand.length === 0) return this.finish('domino', seat) + if (this.blocked()) return this.finish('block', null) + this.current = (seat + 1) % SEATS + } + + /** Whether the player to move must draw: nothing fits, and there is something to draw. */ + get mustDraw(): boolean { + return this.phase === 'play' && !!this.center && this.legal().length === 0 && this.boneyard.length > 0 + } + + /** + * Take one tile from the boneyard. Only for a player with nothing that + * fits; the turn stays with him, to play what he drew or draw again. + */ + draw(): Tile { + if (!this.mustDraw) throw new Error(this.boneyard.length ? 'a player who can play must' : 'the boneyard is empty') + const seat = this.current + // Drawing says as much as knocking does: he holds none of these numbers. + for (const v of this.endValues) this.voids[seat][v] = true + const tile = this.boneyard.pop()! + this.hands[seat] = sortHand([...this.hands[seat], tile]) + this.events.push({ kind: 'draw', seat, tile }) + // Everybody else is still to go before anyone could be stuck, so a draw + // never blocks the hand: the next pass or play will see to that. + return tile + } + + pass() { + if (this.phase !== 'play') throw new Error('the hand is over') + if (this.legal().length) throw new Error('a player who can play must') + if (this.boneyard.length) throw new Error('draw first') + const seat = this.current + const values = this.endValues + for (const v of values) this.voids[seat][v] = true + this.events.push({ kind: 'pass', seat, ends: values }) + if (this.blocked()) return this.finish('block', null) + this.current = (seat + 1) % SEATS + } + + /** + * The boneyard is empty and nobody holds a tile that matches any open end. + * (A spinner that is not open yet is itself the end on its unfinished + * side, so its number is counted.) While there are tiles to draw the hand + * is never blocked: somebody has to draw them first. + */ + private blocked(): boolean { + if (this.boneyard.length) return false + const values = this.endValues + return this.hands.every((h) => h.every((t) => !values.some((v) => has(t, v)))) + } + + /** + * The numbers that would be showing after a move, without making it. The + * tile goes down, the ends are read, and it comes straight back up. + */ + endsAfter(tile: Tile, side: Side): number[] { + if (!this.center) { + const [a, b] = TILES[tile] + return [...new Set([a, b])] + } + const spinner = this.spinner + const end = this.ends.find((e) => e.side === side)!.value + const arm = this.arm(side) + arm.push({ tile, inner: end, outer: other(tile, end) }) + if (!spinner && isDouble(tile) && (side === 'left' || side === 'right')) this.spinner = { tile, arm: side, index: arm.length - 1 } + const values = this.endValues + arm.pop() + this.spinner = spinner + return values + } + + /** What a move would score as it goes down, without making it. */ + scoreAfter(tile: Tile, side: Side): number { + if (!this.center) { + const [a, b] = TILES[tile] + return (a + b) % 5 === 0 && a + b > 0 ? a + b : 0 + } + const end = this.ends.find((e) => e.side === side)!.value + const arm = this.arm(side) + arm.push({ tile, inner: end, outer: other(tile, end) }) + const points = this.scoring + arm.pop() + return points + } + + private finish(how: HandResult['how'], seat: number | null) { + const counts = this.hands.map(count) + let winner = seat + if (how === 'reached') { + this.result = { how, winner, points: 0, pips: 0, counts, hands: this.hands.map((h) => [...h]) } + this.phase = 'over' + this.winner = seat + this.events.push({ kind: 'win', seat: seat! }) + return + } + if (how === 'block') { + const low = Math.min(...counts) + const lowest = counts.flatMap((c, s) => (c === low ? [s] : [])) + winner = lowest.length === 1 ? lowest[0] : null + } + const pips = winner === null ? 0 : counts.reduce((sum, c, s) => (s === winner ? sum : sum + c), 0) + const points = toFive(pips) + if (winner !== null) this.scores[winner] += points + + this.result = { how, winner, points, pips, counts, hands: this.hands.map((h) => [...h]) } + this.events.push(how === 'domino' ? { kind: 'domino', seat: seat!, points } : { kind: 'block', seat: winner, points, counts }) + + if (winner !== null && this.scores[winner] >= this.target) { + this.phase = 'over' + this.winner = winner + this.events.push({ kind: 'win', seat: winner }) + } else this.phase = 'scored' + } +} diff --git a/src/game/layout.test.ts b/src/game/layout.test.ts new file mode 100644 index 0000000..c2ec7e1 --- /dev/null +++ b/src/game/layout.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { step } from './bot' +import { Game } from './game' +import { layout, type Spot } from './layout' + +const overlap = (a: Spot, b: Spot) => + a.x < b.x + b.w - 1e-9 && b.x < a.x + a.w - 1e-9 && a.y < b.y + b.h - 1e-9 && b.y < a.y + a.h - 1e-9 + +/** Two tiles share a stretch of edge. */ +const touch = (a: Spot, b: Spot) => { + const sideBySide = (Math.abs(a.x + a.w - b.x) < 1e-9 || Math.abs(b.x + b.w - a.x) < 1e-9) && a.y < b.y + b.h && b.y < a.y + a.h + const stacked = (Math.abs(a.y + a.h - b.y) < 1e-9 || Math.abs(b.y + b.h - a.y) < 1e-9) && a.x < b.x + b.w && b.x < a.x + a.w + return sideBySide || stacked +} + +/** Plays hands out, and lays the table after every tile. */ +function* tables(seeds: number) { + for (let seed = 1; seed <= seeds; seed++) { + const g = new Game(seed) + while (g.phase === 'play') { + step(g) + yield g + } + } +} + +describe('the table', () => { + for (const width of [10, 14, 20, 26]) { + it(`never lays one tile over another, ${width} half-tiles wide`, () => { + // One expect at the end: tens of thousands of them are what is slow. + const bad: string[] = [] + for (const g of tables(200)) { + const spots = layout(g, width) + if (spots.length !== g.played.length) bad.push('count') + for (let i = 0; i < spots.length; i++) { + for (let j = i + 1; j < spots.length; j++) if (overlap(spots[i], spots[j])) bad.push(`overlap ${i} ${j}`) + if (i > 0 && !spots.some((o, j) => j !== i && touch(spots[i], o))) bad.push(`floating ${i}`) + } + } + expect(bad.slice(0, 5)).toEqual([]) + }) + } + + it('grows four ways once the spinner opens', () => { + let four = 0 + for (const g of tables(200)) if (g.up.length && g.down.length) four++ + expect(four).toBeGreaterThan(0) + }) + + it('marks the spinner, and only the spinner', () => { + for (const g of tables(60)) { + const marked = layout(g, 20).filter((s) => s.spinner) + expect(marked.map((s) => s.tile)).toEqual(g.spinner ? [g.spinner.tile] : []) + } + }) +}) diff --git a/src/game/layout.ts b/src/game/layout.ts new file mode 100644 index 0000000..ddff775 --- /dev/null +++ b/src/game/layout.ts @@ -0,0 +1,198 @@ +import type { Center, Link, Side, Spinner } from './game' +import { isDouble, type Tile } from './tiles' + +/** + * Where each tile on the table goes. + * + * The line is laid out from a hub: the spinner if there is one, otherwise + * the first tile. From the hub it runs east and west, and once the spinner + * opens, north and south too. When the spinner is not the first tile, the + * part of the line between them is simply one of the spinner's arms, laid + * back the other way, so the spinner always sits in the middle of the + * table where everybody can see it. + * + * An arm that reaches the edge of its room turns a corner, always the same + * way round -- east turns north, north turns west, west turns south, south + * turns east -- so four arms wind outward like a pinwheel instead of into + * each other. Each leg can run further than the last, which keeps the + * windings apart. A tile that would still land on another is turned early. + * + * Units are half a tile: a tile is 2 long and 1 wide. (0, 0) is the middle + * of the hub. + */ + +export interface Spot { + tile: Tile + x: number + y: number + w: number + h: number + /** The number drawn at the left end, or the top end of a tile on end. */ + first: number + second: number + arm: 'center' | Side + ghost?: boolean + spinner?: boolean +} + +export interface Line { + center: Center | null + left: readonly Link[] + right: readonly Link[] + up: readonly Link[] + down: readonly Link[] + spinner: Spinner | null +} + +type Vec = [number, number] +interface Item { + link: Link + side: Side + ghost?: boolean +} + +const flip = (l: Link): Link => ({ tile: l.tile, inner: l.outer, outer: l.inner }) +/** A quarter turn, anticlockwise on the screen: east to north, north to west. */ +const turn = ([dx, dy]: Vec): Vec => [dy, -dx] + +/** + * `width` is how many half-tiles the table has across, which sets how far + * each leg runs before it turns. Pads are places for a held tile at the + * open ends, laid out like tiles but drawn as outlines. + */ +export function layout(line: Line, width: number, pads: Partial> = {}): Spot[] { + const { center, left, right, up, down, spinner } = line + if (!center) return [] + + // The hub, and what runs off it in each direction, outward. + let hub: { tile: Tile; first: number; second: number } + let east: Item[] + let west: Item[] + const tag = (side: Side) => (l: Link): Item => ({ link: l, side }) + if (!spinner || spinner.arm === 'center') { + hub = { tile: center.tile, first: center.left, second: center.right } + east = right.map(tag('right')) + west = left.map(tag('left')) + } else if (spinner.arm === 'right') { + const k = spinner.index + hub = { tile: right[k].tile, first: right[k].inner, second: right[k].inner } + east = right.slice(k + 1).map(tag('right')) + west = [ + ...right.slice(0, k).reverse().map(flip).map(tag('right')), + { link: { tile: center.tile, inner: center.right, outer: center.left }, side: 'left' as const }, + ...left.map(tag('left')), + ] + } else { + const k = spinner.index + hub = { tile: left[k].tile, first: left[k].inner, second: left[k].inner } + west = left.slice(k + 1).map(tag('left')) + east = [ + ...left.slice(0, k).reverse().map(flip).map(tag('left')), + { link: { tile: center.tile, inner: center.left, outer: center.right }, side: 'right' as const }, + ...right.map(tag('right')), + ] + } + const north = up.map(tag('up')) + const south = down.map(tag('down')) + if (pads.right) east.push({ link: pads.right, side: 'right', ghost: true }) + if (pads.left) west.push({ link: pads.left, side: 'left', ghost: true }) + if (pads.up) north.push({ link: pads.up, side: 'up', ghost: true }) + if (pads.down) south.push({ link: pads.down, side: 'down', ghost: true }) + + const out: Spot[] = [] + const hubDouble = isDouble(hub.tile) + out.push({ + tile: hub.tile, + ...(hubDouble ? { x: -0.5, y: -1, w: 1, h: 2 } : { x: -1, y: -0.5, w: 2, h: 1 }), + first: hub.first, + second: hub.second, + arm: 'center', + spinner: spinner !== null, + }) + + // How far a leg may run: each further out than the one before. + const reachX = Math.max(4, Math.floor(width / 4)) + const reachY = 3 + + const clear = (s: { x: number; y: number; w: number; h: number }) => + out.every((o) => !(s.x < o.x + o.w - 1e-9 && o.x < s.x + s.w - 1e-9 && s.y < o.y + o.h - 1e-9 && o.y < s.y + s.h - 1e-9)) + + const lay = (items: Item[], start: Vec, dir: Vec, halfAcross: number) => { + let p = start + let d = dir + let across = halfAcross + let leg = 0 + let run = 0 + + /** The tile's rectangle, laid from open point `p` in direction `d`. */ + const place = (tile: Tile, p: Vec, d: Vec) => { + const along = isDouble(tile) ? 1 : 2 + const wide = isDouble(tile) ? 2 : 1 + const cx = p[0] + (d[0] * along) / 2 + const cy = p[1] + (d[1] * along) / 2 + const w = d[0] !== 0 ? along : wide + const h = d[0] !== 0 ? wide : along + return { x: cx - w / 2, y: cy - h / 2, w, h, along, wide } + } + + /** Round the corner: the next tile starts off the side of the last one's end. */ + const corner = (p: Vec, d: Vec, across: number): [Vec, Vec] => { + const c: Vec = [p[0] - d[0] * 0.5, p[1] - d[1] * 0.5] + const d2 = turn(d) + return [[c[0] + d2[0] * across, c[1] + d2[1] * across], d2] + } + + for (const item of items) { + const { link } = item + const limit = (d[0] !== 0 ? reachX : reachY) * (leg + 1) + const along = isDouble(link.tile) ? 1 : 2 + let r = place(link.tile, p, d) + if (run + along > limit || !clear(r)) { + ;[p, d] = corner(p, d, across) + leg++ + run = 0 + r = place(link.tile, p, d) + // Still in the way: one more quarter turn is always room somewhere. + if (!clear(r)) { + ;[p, d] = corner(p, d, 0.5) + leg++ + r = place(link.tile, p, d) + } + } + // Which number is drawn first (left, or top) depends on which way the arm is running. + const backwards = d[0] < 0 || d[1] < 0 + out.push({ + tile: link.tile, + x: r.x, + y: r.y, + w: r.w, + h: r.h, + first: backwards ? link.outer : link.inner, + second: backwards ? link.inner : link.outer, + arm: item.side, + ghost: item.ghost, + }) + p = [p[0] + d[0] * r.along, p[1] + d[1] * r.along] + run += r.along + across = r.wide / 2 + } + } + + const halfW = hubDouble ? 0.5 : 1 + const halfH = hubDouble ? 1 : 0.5 + lay(east, [halfW, 0], [1, 0], halfH) + lay(west, [-halfW, 0], [-1, 0], halfH) + lay(north, [0, -halfH], [0, -1], halfW) + lay(south, [0, halfH], [0, 1], halfW) + return out +} + +export function bounds(spots: readonly Spot[]) { + if (spots.length === 0) return { x0: -1, y0: -1, x1: 1, y1: 1 } + return { + x0: Math.min(...spots.map((s) => s.x)), + y0: Math.min(...spots.map((s) => s.y)), + x1: Math.max(...spots.map((s) => s.x + s.w)), + y1: Math.max(...spots.map((s) => s.y + s.h)), + } +} diff --git a/src/game/names.ts b/src/game/names.ts new file mode 100644 index 0000000..0e1574d --- /dev/null +++ b/src/game/names.ts @@ -0,0 +1,41 @@ +import { makeRng, shuffle } from './rng' + +/** + * Who is sitting at the table. + * + * Three men for each game, young and old mixed -- at least one of each, + * because the table is better for the argument between them. They come from + * their own stream of the seed rather than the deal's, so naming the players + * can never change which tiles anybody gets. + * + * The compass points stay as the seats: play goes to the left, which from + * the bottom of the screen is West, then North, then East. + */ + +export const COMPASS = ['South', 'West', 'North', 'East'] as const + +export type Age = 'old' | 'young' + +export interface Person { + name: string + age: Age +} + +const OLD = [ + 'Pops', 'Old Man Ray', 'Uncle Winston', 'Big Earl', 'Don Ramón', 'Mr. Delroy', 'Grandpa Joe', 'Clarence', + 'Papi Tomás', 'Otis', 'Mr. Lucius', 'Tío Hector', +] +const YOUNG = [ + 'Junior', 'Tito', 'DeShawn', 'Marcus', 'Kenny', 'Javi', 'Lil Mike', 'Andre', 'Darnell', 'Rico', 'Jaylen', 'Manny', +] + +/** Everybody at the table, the player first. */ +export function seatPeople(seed: number): Person[] { + const rng = makeRng(seed ^ 0x4e414d45) + const olds = shuffle(rng, OLD).map((name) => ({ name, age: 'old' as const })) + const youngs = shuffle(rng, YOUNG).map((name) => ({ name, age: 'young' as const })) + const third = rng() < 0.5 ? olds[1] : youngs[1] + return [{ name: 'You', age: 'young' }, ...shuffle(rng, [olds[0], youngs[0], third])] +} + +export const seatNames = (seed: number) => seatPeople(seed).map((p) => p.name) diff --git a/src/game/play.test.ts b/src/game/play.test.ts new file mode 100644 index 0000000..51c17c2 --- /dev/null +++ b/src/game/play.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { EVEN, leanings, step } from './bot' +import { Game, SEATS } from './game' +import { makeRng } from './rng' +import { count, pips } from './tiles' + +/** + * Plays a whole game. A seat without a bot plays at random, or, if `greedy`, + * always its heaviest tile -- the rule of thumb most people start with. + */ +function playOut(seed: number, bots: boolean[], target = 250, greedy = false) { + const g = new Game(seed, target) + const lean = leanings(seed, SEATS) + const rng = makeRng(seed * 7 + 1) + let hands = 0 + while (g.phase !== 'over') { + if (g.phase === 'scored') { + g.nextHand() + continue + } + const seat = g.current + if (bots[seat]) step(g, lean[seat]) + else { + const moves = g.legal() + if (moves.length === 0) { + if (g.mustDraw) g.draw() + else g.pass() + } + else { + const m = greedy + ? moves.reduce((x, y) => (pips(y.tile) > pips(x.tile) ? y : x)) + : moves[Math.floor(rng() * moves.length)] + g.play(m.tile, m.side) + } + } + // Nothing is ever lost or doubled. Thrown rather than expected: an + // expect on every move of thousands of games is most of the run time. + if (g.phase === 'play' && g.played.length + g.boneyard.length + g.hands.reduce((n, h) => n + h.length, 0) !== 28) { + throw new Error(`seed ${seed}: tiles went missing`) + } + if (g.phase !== 'play') hands++ + } + return { g, hands } +} + +describe('whole games', () => { + it('always finish, with the scores adding up', () => { + const rounds: number[] = [] + for (let seed = 1; seed <= 200; seed++) { + const { g, hands } = playOut(seed, [true, true, true, true]) + expect(g.winner).not.toBeNull() + expect(g.scores[g.winner!]).toBeGreaterThanOrEqual(250) + for (const s of g.scores) expect(s % 5).toBe(0) + rounds.push(hands) + } + const avg = rounds.reduce((a, b) => a + b, 0) / rounds.length + // First to 250, scoring fives as they fall: measured at about six hands. + expect(avg).toBeGreaterThan(3) + expect(avg).toBeLessThan(12) + }) + + it('leave a knock only to a player who has nothing that fits', () => { + const g = new Game(3) + for (let i = 0; i < 400 && g.phase !== 'over'; i++) { + if (g.phase === 'scored') { + g.nextHand() + continue + } + const before = g.legal().length + const bones = g.boneyard.length + const m = step(g, EVEN) + // Nothing fits: draw while there is something to draw, then knock. + if (before === 0) expect(m).toBe(bones ? 'draw' : null) + else expect(m).not.toBeNull() + } + }) + + it('a bot beats three players who play at random, clearly', () => { + let wins = 0 + const N = 400 + for (let seed = 1; seed <= N; seed++) { + const { g } = playOut(seed, [true, false, false, false]) + if (g.winner === 0) wins++ + } + // One seat in four would win by chance. + expect(wins / N).toBeGreaterThan(0.4) + }) + + it('a bot beats three players who always lay their heaviest tile', () => { + // Measured at 54% over two thousand games; one in four is chance. + let wins = 0 + const N = 400 + for (let seed = 1; seed <= N; seed++) if (playOut(seed, [true, false, false, false], 250, true).g.winner === 0) wins++ + expect(wins / N).toBeGreaterThan(0.4) + }) + + it('no seat is favored when four bots play', () => { + const seats = [0, 0, 0, 0] + for (let seed = 1; seed <= 400; seed++) seats[playOut(seed, [true, true, true, true]).g.winner!]++ + for (const s of seats) expect(s).toBeGreaterThan(70) + }) + + it('a blocked hand is scored from what is actually left', () => { + for (let seed = 1; seed <= 300; seed++) { + const { g } = playOut(seed, [true, true, true, true], 1) + const r = g.result! + expect(r.counts).toEqual(r.hands.map(count)) + } + }) +}) diff --git a/src/game/rng.ts b/src/game/rng.ts new file mode 100644 index 0000000..b1d42d2 --- /dev/null +++ b/src/game/rng.ts @@ -0,0 +1,26 @@ +/** + * mulberry32 — small, fast, seedable PRNG. + * The dice and the deal come from it, and so do the bots' leanings, so a seed + * replays a whole game exactly and a test can throw the same dice twice. + */ +export function makeRng(seed: number) { + let a = seed >>> 0 + return function rng(): number { + a = (a + 0x6d2b79f5) >>> 0 + let t = a + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export type Rng = ReturnType + +export function shuffle(rng: Rng, items: readonly T[]): T[] { + const out = [...items] + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)) + ;[out[i], out[j]] = [out[j], out[i]] + } + return out +} diff --git a/src/game/talk.ts b/src/game/talk.ts new file mode 100644 index 0000000..10e0db4 --- /dev/null +++ b/src/game/talk.ts @@ -0,0 +1,273 @@ +import type { Game, GameEvent } from './game' +import type { Age, Person } from './names' +import { isDouble, pips } from './tiles' + +/** + * The table talk. + * + * Nobody plays dominoes in silence. Every tile gets a word from somebody: + * the man who laid it, or whoever has an opinion about it, which at this + * table is everyone. The old men talk like they have been winning since + * before the young ones were born; the young ones talk like it is about + * to stop. + * + * It is flavor and nothing else. It reads the same things the bots read -- + * what just happened, the score, how many tiles each man holds -- and it + * says nothing about anybody's hand that the table does not already know. + */ + +type Say = (who: string) => string +type Lines = Record + +/** Laying a tile, nothing special about it. */ +const PLAY: Lines = { + old: [ + 'Learn something, young man.', + 'Watch and learn.', + 'I was doing this before your daddy was born.', + 'Mm-hm. That’s how it’s done.', + 'Don’t rush me. I got all night.', + 'Pay attention, now.', + 'Ahh, look at that.', + 'Somebody count for me, my glasses are in the car.', + 'That’s forty years of practice right there.', + 'Too easy.', + ], + young: [ + 'Yeah, that’s me. That’s all me.', + 'Keep up, old man.', + 'Easy work.', + 'Y’all ain’t ready.', + 'Put that on my tab.', + 'Say less.', + 'Oh, I’m cooking tonight.', + 'That’s a problem for somebody.', + 'I’m just getting warm.', + 'Too smooth.', + ], +} + +/** Slamming a big tile down. */ +const HEAVY: Lines = { + old: ['FEEL that one!', 'Heavy! Pick THAT up!', 'That’s grown-man weight.', 'Hear that table? That’s respect.'], + young: ['BOOM!', 'Get that outta here!', 'Heavy hands tonight!', 'Shook the whole table!'], +} + +/** Laying a double. */ +const DOUBLE: Lines = { + old: ['Double. Like my blood pressure.', 'Two for the price of one.', 'Doubles, baby.'], + young: ['Double trouble!', 'Doubled up. Deal with it.', 'Two of ’em. Crossways. Beautiful.'], +} + +/** The first double: the spinner. */ +const SPINNER: Lines = { + old: ['There’s your spinner, boys. Four ways now.', 'Spinner’s down. Now it gets interesting.'], + young: ['SPINNER! Open it up!', 'Spinner on the table. Four ways, let’s go.'], +} + +/** Setting the high double to open the game. */ +const SET: Lines = { + old: ['High double. Let’s go to work.', 'Sit down, boys. School is in session.'], + young: ['Big double on the table. Y’all better pray.', 'Opening heavy. Let’s eat.'], +} + +/** Going to the boneyard. */ +const DRAW_SELF: Lines = { + old: ['Going fishing. Don’t look at me.', 'Boneyard. I’m shopping, that’s all.', 'Hmph. Let me see what’s in the yard.'], + young: ['Man, boneyard again?', 'Fishing. Don’t say nothing.', 'Hold on, hold on, I’m shopping.'], +} + +/** Somebody else is drawing. */ +const DRAW_OTHER: Lines = { + old: [(w) => `${w} going to the boneyard again. Buy something nice.`, (w) => `Keep digging, ${w}.`], + young: [(w) => `${w} out here fishing! HA!`, (w) => `Look at ${w} shopping in the yard.`], +} + +/** The player is drawing. */ +const DRAW_YOU: Lines = { + old: ['Go on, draw. We’ll wait.', 'Boneyard’s that way, son.'], + young: ['Fishing already?! Ha!', 'Draw, draw, draw. Take your time, rookie.'], +} + +/** Having to knock. */ +const KNOCK_SELF: Lines = { + old: ['Pass. Don’t start.', 'Knock knock. Not a word from you.', 'I’m letting you have one. Out of pity.', 'Hmph. Pass.'], + young: ['Man, pass. This deal is rigged.', 'Knock. Whoever shuffled, I’m looking at you.', 'Pass. I’m chilling.', 'Nah. Pass. Relax.'], +} + +/** Somebody else just knocked, and this man has a word about it. */ +const KNOCK_OTHER: Lines = { + old: [ + (w) => `${w}, you knocking already? Go home.`, + (w) => `Look at ${w} knocking. Just like his daddy.`, + (w) => `${w} got nothing. Nothing!`, + (w) => `Hear that, ${w}? That’s the sound of losing.`, + ], + young: [ + (w) => `${w} knocking?! Ha! Somebody take his chair.`, + (w) => `Ohhh, ${w} is STUCK.`, + (w) => `${w} ain’t got it, y’all!`, + (w) => `Knock knock, ${w}. Who’s there? Not you.`, + ], +} + +/** The player knocked. */ +const KNOCK_YOU: Lines = { + old: ['You knocking, son? Sit up straight.', 'Pass? Already? Lord.', 'That’s okay. Everybody starts somewhere.'], + young: ['You knocking?! Somebody get the camera!', 'Bro, you got NOTHING.', 'Ain’t nothing in that hand but air.'], +} + +/** The player just laid a tile. */ +const AFTER_YOU: Lines = { + old: ['That’s what you went with? Hm.', 'Not bad for a rookie.', 'I seen worse. Not much worse.', 'Take your time, take your time.'], + young: ['Oh, that’s cute.', 'You sure about that?', 'Okay, okay. I see you.', 'That’s all you got?'], +} + +/** Scoring: the count is a multiple of five, and he calls it. */ +const SCORE: Lines = { + old: [ + (n) => `${n}! Mark me down, and use a sharp pencil.`, + (n) => `That’s ${n}. Write it big.`, + (n) => `${n}. Been counting since before you could.`, + (n) => `Gimme my ${n}.`, + (n) => `${n}! Put another stick on my house.`, + ], + young: [ + (n) => `${n}! Mark it! MARK IT!`, + (n) => `That’s ${n}, baby! Write it down!`, + (n) => `${n} on the board. Y’all watching?`, + (n) => `Count it: ${n}. Easy money.`, + (n) => `${n}! Somebody get the pencil!`, + ], +} + +/** The player scored. */ +const YOU_SCORED: Lines = { + old: [ + (n) => `Mm. Give the boy his ${n}.`, + (n) => `${n}? Even a broken clock.`, + (n) => `Fine. ${n}. Don’t let it go to your head.`, + ], + young: [ + (n) => `${n}?! Who taught you that?`, + (n) => `Aight, aight, mark him ${n}. Lucky.`, + (n) => `${n}. Enjoy it, that’s your last.`, + ], +} + +/** Down to the last tile. */ +const LAST_ONE: Lines = { + old: ['One left. Get your money ready.', 'Last one, boys. Say your prayers.'], + young: ['One tile, y’all! ONE!', 'Last one. Somebody better stop me.'], +} + +/** Going out. */ +const DOMINO: Lines = { + old: ['DOMINO! Count ’em up and weep.', 'DOMINÓ! Pay the old man!', 'Domino. Like taking candy from babies.'], + young: ['DOMINOOO! Count it up!', 'DOMINO! Somebody write that DOWN!', 'Out! Pay me, pay me, pay me!'], +} + +/** Somebody else went out. */ +const LOST_HAND: Lines = { + old: ['Lucky. Pure luck.', 'Hmph. Even a blind hog finds an acorn.', 'Enjoy it. It won’t last.'], + young: ['Man, what?! Reshuffle!', 'That’s crazy. Deal again.', 'Nah, that was luck, and everybody saw it.'], +} + +/** A blocked hand. */ +const BLOCKED: Lines = { + old: ['Locked up tight. Count your bones.', 'She’s closed! Everybody show your hand.'], + young: ['Blocked?! Count it up, count it up!', 'Nobody can go! Flip ’em!'], +} + +/** Winning the game. */ +const WIN: Lines = { + old: ['That’s game. Same time next week, boys.', 'Game! Tell your mama I said hello.', 'Game. Somebody fetch me a cold one.'], + young: ['GAME! I told y’all! I TOLD Y’ALL!', 'That’s game! Run it back if you want another one!', 'Game over. Y’all can stay mad.'], +} + +/** The player won the game. */ +const YOU_WON: Lines = { + old: ['Well, damn. Beginner’s luck.', 'Hmph. I let you have that one.', 'Good game, young man. Don’t get cocky.'], + young: ['Run it back. Right now.', 'Nah, that was a fluke. Again!', 'Okay, okay. Respect. Now deal.'], +} + +/** The player talked trash, and somebody is not having it. */ +const COMEBACK: Lines = { + old: [ + 'Boy, hush and play.', + 'Talk is cheap. Tiles ain’t.', + 'I been insulted by better men than you.', + 'Cute. Write that one down for your memoirs.', + 'Keep talking. I’ll keep counting.', + 'Your mouth is writing checks your hand can’t cash.', + ], + young: [ + 'That’s the best you got?', + 'Bro wrote that on a napkin.', + 'Words don’t score, rookie.', + 'Okay, comedian. Scoreboard.', + 'Who laughed? Nobody laughed.', + 'Say that again when you got points.', + ], +} + +export interface Chat { + seat: number + text: string +} + +const pick = (rng: () => number, lines: readonly (string | Say)[], who: string) => { + const l = lines[Math.floor(rng() * lines.length)] + return typeof l === 'string' ? l : l(who) +} + +/** + * Who says what after an event, or nothing. `human` never talks: the words + * are the bots'. `rng` is not the game's, so talking changes no tile. + */ +export function banter(e: GameEvent, g: Game, people: readonly Person[], human: number, rng: () => number): Chat | null { + const bots = people.map((_, s) => s).filter((s) => s !== human) + const other = (not: number) => { + const pool = bots.filter((s) => s !== not) + return pool[Math.floor(rng() * pool.length)] + } + const say = (seat: number, lines: Lines, who = '') => ({ seat, text: pick(rng, lines[people[seat].age], who) }) + + switch (e.kind) { + case 'play': { + if (e.seat === human) return say(other(human), AFTER_YOU) + if (g.phase === 'play' && g.hands[e.seat].length === 1) return say(e.seat, LAST_ONE) + if (e.side === null && isDouble(e.tile) && g.handNo === 1) return say(e.seat, SET) + if (e.spinner) return say(e.seat, SPINNER) + if (pips(e.tile) >= 10) return say(e.seat, HEAVY) + if (isDouble(e.tile) && rng() < 0.7) return say(e.seat, DOUBLE) + return say(e.seat, PLAY) + } + case 'pass': + if (e.seat === human) return say(other(human), KNOCK_YOU) + // Half the time he makes an excuse; the other half somebody else gets there first. + return rng() < 0.5 ? say(e.seat, KNOCK_SELF) : say(other(e.seat), KNOCK_OTHER, people[e.seat].name) + case 'draw': + // Not every tile drawn: a man drawing four gets one remark, not four. + if (rng() < 0.55) return null + if (e.seat === human) return say(other(human), DRAW_YOU) + return rng() < 0.5 ? say(e.seat, DRAW_SELF) : say(other(e.seat), DRAW_OTHER, people[e.seat].name) + case 'score': + return e.seat === human ? say(other(human), YOU_SCORED, String(e.points)) : say(e.seat, SCORE, String(e.points)) + case 'domino': + return e.seat === human ? say(other(human), LOST_HAND) : say(e.seat, DOMINO) + case 'block': + return say(e.seat !== null && e.seat !== human ? e.seat : other(human), BLOCKED) + case 'win': + return e.seat === human ? say(other(human), YOU_WON) : say(e.seat, WIN) + default: + return null + } +} + +/** A bot answering the player's trash talk: one of the three, at random. */ +export function comeback(people: readonly Person[], human: number, rng: () => number): Chat { + const bots = people.map((_, s) => s).filter((s) => s !== human) + const seat = bots[Math.floor(rng() * bots.length)] + return { seat, text: pick(rng, COMEBACK[people[seat].age], '') } +} diff --git a/src/game/tiles.ts b/src/game/tiles.ts new file mode 100644 index 0000000..8753b2e --- /dev/null +++ b/src/game/tiles.ts @@ -0,0 +1,46 @@ +/** + * The double-six set: every pair of numbers from blank to six, once each, + * which is twenty-eight tiles. A tile is its index into TILES, and each pair + * is stored low end first. + */ + +export type Tile = number + +export const TILES: readonly (readonly [number, number])[] = (() => { + const out: [number, number][] = [] + for (let a = 0; a <= 6; a++) for (let b = a; b <= 6; b++) out.push([a, b]) + return out +})() + +export const TILE_COUNT = TILES.length + +export const tileOf = (a: number, b: number): Tile => + TILES.findIndex(([x, y]) => x === Math.min(a, b) && y === Math.max(a, b)) + +export const DOUBLE_SIX = tileOf(6, 6) + +export const pips = (t: Tile) => TILES[t][0] + TILES[t][1] +export const isDouble = (t: Tile) => TILES[t][0] === TILES[t][1] +export const has = (t: Tile, n: number) => TILES[t][0] === n || TILES[t][1] === n + +/** The end left showing when this tile is laid against `n`. */ +export const other = (t: Tile, n: number) => (TILES[t][0] === n ? TILES[t][1] : TILES[t][0]) + +export const count = (hand: readonly Tile[]) => hand.reduce((sum, t) => sum + pips(t), 0) + +export const NUMBERS = ['blank', 'one', 'two', 'three', 'four', 'five', 'six'] as const + +/** "6–4", or "double six". Written high end first, the way people say them. */ +export function tileName(t: Tile): string { + const [a, b] = TILES[t] + return a === b ? `double ${NUMBERS[a]}` : `${b}–${a}` +} + +/** Highest first: doubles by their number, then by the high end, then the low. */ +export function sortHand(hand: readonly Tile[]): Tile[] { + return [...hand].sort((x, y) => { + const [xa, xb] = TILES[x] + const [ya, yb] = TILES[y] + return yb - xb || ya - xa + }) +} diff --git a/src/game/trash.test.ts b/src/game/trash.test.ts new file mode 100644 index 0000000..02cd1b3 --- /dev/null +++ b/src/game/trash.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from 'vitest' +import { fiveLines, POOL_SIZE, trashLine } from './trash' + +it('holds exactly a million lines, every one different', () => { + expect(POOL_SIZE).toBe(1_000_000) + // Neighbors differ in their last pick, and the far ends differ in every pick. + const sample = [0, 1, 49, 50, 1999, 2000, 49_999, 50_000, 999_998, 999_999] + expect(new Set(sample.map(trashLine)).size).toBe(sample.length) + expect(trashLine(0)).toBe('Yo, you play like a raccoon with oven mitts on.') + expect(trashLine(999_999)).toBe('Straight up, you been playing like a weather man at halftime.') +}) + +it('offers five different lines at a time', () => { + for (let i = 0; i < 50; i++) expect(new Set(fiveLines()).size).toBe(5) +}) diff --git a/src/game/trash.ts b/src/game/trash.ts new file mode 100644 index 0000000..ae43fd1 --- /dev/null +++ b/src/game/trash.ts @@ -0,0 +1,72 @@ +/** + * The player's own trash talk: a million canned lines, five at a time. + * + * Nobody writes a million lines. Four short lists do it instead -- how the + * line opens, what it goes after, what it is compared to, and how the + * comparison ends -- and every way of picking one from each is a line: + * 20 × 25 × 40 × 50 is exactly 1,000,000. A line's number is its four picks + * read as one mixed-radix number, so every number from 0 to 999,999 is a + * different line and none is ever stored. + * + * They are meant to be dumb. That is the point of them. + */ + +const OPENERS = [ + 'Yo,', 'Man,', 'Listen,', 'Real talk,', 'No offense,', 'Bless your heart,', 'Look here,', 'Ay,', + 'Not gonna lie,', 'With all due respect,', 'Hate to say it,', 'Honestly,', 'Ha,', 'Lord have mercy,', + 'Somebody tell him,', 'I’m just saying,', 'Hold up,', 'Say what you want,', 'Between you and me,', 'Straight up,', +] as const + +const JABS = [ + 'you play like', 'you shuffle like', 'you count like', 'you slam tiles like', 'you think like', + 'that play looked like', 'your hand looks like', 'your strategy is', 'watching you is like watching', 'you knock like', + 'you fish the boneyard like', 'you hold your tiles like', 'you talk trash like', 'you sit at this table like', + 'you celebrate like', 'your spinner game is', 'your whole family plays like', 'you take your time like', + 'you read the board like', 'you defend like', 'you score like', 'that last move was', 'you lose like', + 'you smile like', 'you been playing like', +] as const + +const LIKE = [ + 'a raccoon', 'a Roomba', 'my grandma', 'a toddler', 'a sleepy mall cop', 'a golden retriever', + 'a substitute teacher', 'a lost tourist', 'a pigeon', 'a vending machine', 'a used car salesman', 'a mime', + 'a lawn chair', 'a wet sock', 'a potato', 'a GPS', 'a flip phone', 'a DMV clerk', 'a sleepy cat', 'a traffic cone', + 'a folding chair', 'a rookie', 'a garden gnome', 'a bowl of soup', 'a confused goat', 'a karaoke drunk', + 'a parking meter', 'a broken ice machine', 'a telemarketer', 'a scarecrow', 'a turtle', 'a dial-up modem', + 'a birthday clown', 'a screen door', 'a lawn flamingo', 'a busted piñata', 'a Sunday school teacher', 'a squirrel', + 'a hot dog vendor', 'a weather man', +] as const + +const TAILS = [ + 'with oven mitts on.', 'trying to parallel park.', 'at a funeral.', 'on a Tuesday.', 'who lost his glasses.', + 'in the rain.', 'after Thanksgiving dinner.', 'stuck in traffic.', 'with the hiccups.', 'on its first day.', + 'in flip-flops.', 'at 3 a.m.', 'doing its taxes.', 'in a wind tunnel.', 'that just woke up.', 'with no batteries.', + 'at a job interview.', 'reading the manual upside down.', 'during a blackout.', 'on roller skates.', 'at the DMV.', + 'on a sugar crash.', 'in a hurricane.', 'that lost the remote.', 'with mittens on.', 'in the wrong church.', + 'at a spelling bee.', 'on the last day of school.', 'after two naps.', 'on dial-up.', 'with a sprained ankle.', + 'at a surprise party.', 'in slow motion.', 'in a three-piece suit.', 'with its eyes closed.', 'that forgot the rules.', + 'in a mall parking lot.', 'on the first day of spring.', 'after one beer.', 'with stage fright.', 'in a hot tub.', + 'at a magic show.', 'on a treadmill.', 'that owes me money.', 'at a bingo hall.', 'that got stood up.', 'on a boat.', + 'with a cold.', 'in a museum.', 'at halftime.', +] as const + +export const POOL_SIZE = OPENERS.length * JABS.length * LIKE.length * TAILS.length + +/** Line number `n`, 0 to POOL_SIZE - 1. */ +export function trashLine(n: number): string { + let k = n + const tail = TAILS[k % TAILS.length] + k = Math.floor(k / TAILS.length) + const like = LIKE[k % LIKE.length] + k = Math.floor(k / LIKE.length) + const jab = JABS[k % JABS.length] + k = Math.floor(k / JABS.length) + const opener = OPENERS[k % OPENERS.length] + return `${opener} ${jab} ${like} ${tail}` +} + +/** Five different lines, drawn at random from the whole pool. */ +export function fiveLines(rng: () => number = Math.random): string[] { + const picked = new Set() + while (picked.size < 5) picked.add(Math.floor(rng() * POOL_SIZE)) + return [...picked].map(trashLine) +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..bf6321c --- /dev/null +++ b/src/index.css @@ -0,0 +1,15 @@ +:root { + font-synthesis: none; + -webkit-font-smoothing: antialiased; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body, #root { height: 100%; margin: 0; } + +body { + background: radial-gradient(120% 90% at 50% 0%, #23324a 0%, #16202f 55%, #0d141d 100%); + color: #f2e9d8; + font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..1e43054 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { Crash } from './components/Crash.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..18d7d9c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "resolveJsonModule": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..056061a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// Vitest is left on its defaults on purpose: it already picks up *.test.ts and +// nothing else, and importing `vitest/config` here drags in a second copy of +// vite whose plugin types disagree with this one's. +// +// There is no dev proxy here, unlike the arcade games on the site. They post +// scores to a shared service; this one has nothing to post. +export default defineConfig({ + // Asset URLs are baked in at build time, so this has to match the path the + // game is served from. Local development stays at the root. + base: process.env.BASE_PATH ?? '/', + plugins: [react()], +}) diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..074548d --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,23 @@ +# The game is a static bundle. It is built here and served by nginx, in its own +# container - it shares nothing with the rest of the site but the domain. +# +# It talks to no service at all. There is no score to post: a game against +# three bots is won or lost, and the points in it are only the way there. +FROM node:26-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig*.json vite.config.ts index.html ./ +COPY public ./public +COPY src ./src +# Must match where the host proxies it, or the asset URLs will be wrong. +ARG BASE_PATH=/dominoes/ +ENV BASE_PATH=${BASE_PATH} +RUN npm run build + +FROM nginx:alpine +COPY web/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1 diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..e368b82 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + + # Hashed filenames, so they can be cached hard. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } + + location = /healthz { + access_log off; + return 200 "ok\n"; + } +}