commit 514505903f9e4445c9daf1c5a79182f4b7ea148b Author: John Coffey Date: Tue Sep 22 23:48:58 2026 -0700 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. 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 0000000..117ef1b Binary files /dev/null and b/src/audio/shuffle.mp3 differ 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 ( +
+

Dominoes

+

Somebody knocked the table over. The game hit an error and can’t carry on.

+

{this.state.error.message}

+ +
+ ) + } +} 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.

+
+ +
+

+ {/* + Absolute rather than "/", because this game is served from a + subdirectory in production and from the root in development. + */} + The rest of the games + {' · '} + Source +

+

+ Dominoes is free software under the{' '} + GNU Affero General Public License, version 3 or later. + It comes with no warranty whatsoever. You may use, study, change and share it; if you run a changed version + where other people can reach it, they are entitled to its source. +

+

+ The game is centuries old and belongs to everybody. Every tile on the screen and every note of the music is + generated in code in this repository. The shuffle is a real one: “Shuffling Dominos” by RobertWulfman + on Freesound, in the public + domain. +

+
+
+ ) +} 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"; + } +}