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.
This commit is contained in:
+24
@@ -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?
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
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.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
- <https://freesound.org/people/RobertWulfman/sounds/220839/>, 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.*
|
||||||
@@ -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
|
||||||
|
|
||||||
|
<https://games.jcoffey.dev/dominoes/>, 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
|
||||||
|
```
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Dominoes — All Fives, in your browser</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="All Fives with a double-six set: four at the table, five tiles each and a boneyard, a spinner that opens four ways, and every multiple of five on the ends scores. You against three trash-talking computer players, first to 250. Free, no account, nothing to install."
|
||||||
|
/>
|
||||||
|
<meta name="author" content="John Coffey" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Canonical, because the container serving this falls back to index.html
|
||||||
|
for any path under /dominoes/ rather than returning a 404. That is right
|
||||||
|
for the app and wrong for a crawler, which would otherwise be free to
|
||||||
|
index /dominoes/anything-at-all as a separate page with identical content.
|
||||||
|
-->
|
||||||
|
<link rel="canonical" href="https://games.jcoffey.dev/dominoes/" />
|
||||||
|
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="theme-color" content="#1b2437" />
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Games · jcoffey.dev" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Dominoes — All Fives, in your browser" />
|
||||||
|
<meta property="og:url" content="https://games.jcoffey.dev/dominoes/" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="All Fives, four at the table, first to 250. You against three, and they talk."
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
The preview image is served by the site rather than carried here, the
|
||||||
|
same as the other games: a social card is not part of the game.
|
||||||
|
-->
|
||||||
|
<meta property="og:image" content="https://games.jcoffey.dev/img/dominoes-social.png?v=2026-09-22a" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="Dominoes: a line of ivory tiles snaking across green felt" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Dominoes — All Fives, in your browser" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="All Fives, four at the table, first to 250. You against three, and they talk."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="https://games.jcoffey.dev/img/dominoes-social.png?v=2026-09-22a" />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "VideoGame",
|
||||||
|
"name": "Dominoes",
|
||||||
|
"url": "https://games.jcoffey.dev/dominoes/",
|
||||||
|
"description": "All Fives with a double-six set. Four players, five tiles each and eight in the boneyard, and the first double is a spinner that opens four ways. Every play that makes the open ends a multiple of five scores it; whoever goes out scores the pips left in the other hands. First to 250. One human against three computer players that count tiles and remember every knock.",
|
||||||
|
"genre": ["Tile game", "Board game", "Turn-based"],
|
||||||
|
"gamePlatform": "Web browser",
|
||||||
|
"applicationCategory": "GameApplication",
|
||||||
|
"operatingSystem": "Any modern web browser",
|
||||||
|
"playMode": "SinglePlayer",
|
||||||
|
"numberOfPlayers": {
|
||||||
|
"@type": "QuantitativeValue",
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "John Coffey",
|
||||||
|
"url": "https://jcoffey.dev/"
|
||||||
|
},
|
||||||
|
"license": "https://www.gnu.org/licenses/agpl-3.0.html",
|
||||||
|
"isAccessibleForFree": true,
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "USD",
|
||||||
|
"availability": "https://schema.org/InStock"
|
||||||
|
},
|
||||||
|
"inLanguage": "en"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": 1,
|
||||||
|
"name": "John Coffey",
|
||||||
|
"item": "https://jcoffey.dev/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": 2,
|
||||||
|
"name": "Games",
|
||||||
|
"item": "https://games.jcoffey.dev/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": 3,
|
||||||
|
"name": "Dominoes",
|
||||||
|
"item": "https://games.jcoffey.dev/dominoes/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
What the page says before React has said anything. A crawler that does
|
||||||
|
not run scripts, and a reader who has them turned off, both get the game
|
||||||
|
described and a way onwards rather than an empty div.
|
||||||
|
-->
|
||||||
|
<noscript>
|
||||||
|
<h1>Dominoes</h1>
|
||||||
|
<p>
|
||||||
|
All Fives with a double-six set. Four at the table, five tiles each:
|
||||||
|
match a number on any open end, or draw from the boneyard. Make the ends add up to a
|
||||||
|
multiple of five and you score it. You play against three computer
|
||||||
|
players, first to 250. It needs JavaScript to play.
|
||||||
|
</p>
|
||||||
|
<p><a href="https://games.jcoffey.dev/">The rest of the games</a></p>
|
||||||
|
<p><a href="https://git.coffeylabs.org/jcoffey-dev/dominoes">Source, AGPL-3.0-or-later</a></p>
|
||||||
|
</noscript>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2928
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Dominoes">
|
||||||
|
<!-- The double six, standing on end. Drawn by hand, like every other mark in
|
||||||
|
this repository. -->
|
||||||
|
<rect x="15" y="3" width="34" height="58" rx="7" fill="#fffaf0" stroke="#241a10" stroke-width="3.5"/>
|
||||||
|
<line x1="20" y1="32" x2="44" y2="32" stroke="#241a10" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<g fill="#241a10">
|
||||||
|
<circle cx="24.5" cy="11" r="3.3"/><circle cx="24.5" cy="18" r="3.3"/><circle cx="24.5" cy="25" r="3.3"/>
|
||||||
|
<circle cx="39.5" cy="11" r="3.3"/><circle cx="39.5" cy="18" r="3.3"/><circle cx="39.5" cy="25" r="3.3"/>
|
||||||
|
<circle cx="24.5" cy="39" r="3.3"/><circle cx="24.5" cy="46" r="3.3"/><circle cx="24.5" cy="53" r="3.3"/>
|
||||||
|
<circle cx="39.5" cy="39" r="3.3"/><circle cx="39.5" cy="46" r="3.3"/><circle cx="39.5" cy="53" r="3.3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 880 B |
+527
@@ -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; }
|
||||||
+543
@@ -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<Game | null>(null)
|
||||||
|
const leanRef = useRef<Leaning[]>([])
|
||||||
|
const seen = useRef(0)
|
||||||
|
|
||||||
|
const [speed, setSpeed] = useState<Speed>('steady')
|
||||||
|
const [music, setMusic] = useState(true)
|
||||||
|
const [sfx, setSfx] = useState(true)
|
||||||
|
const [held, setHeld] = useState<Held | null>(null)
|
||||||
|
const [hot, setHot] = useState<End | null>(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<Record<number, { text: string; id: number }>>({})
|
||||||
|
const [refusal, setRefusal] = useState<string | null>(null)
|
||||||
|
/** The player's five lines on offer, when the trash-talk menu is open. */
|
||||||
|
const [lines, setLines] = useState<string[] | null>(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 <Landing target={TARGET} onStart={start} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="app">
|
||||||
|
<header>
|
||||||
|
<h1>Dominoes</h1>
|
||||||
|
<nav className="ways">
|
||||||
|
<label>
|
||||||
|
Pace{' '}
|
||||||
|
<select value={speed} onChange={(e) => setSpeed(e.target.value as Speed)}>
|
||||||
|
<option value="steady">real</option>
|
||||||
|
<option value="quick">quick</option>
|
||||||
|
<option value="instant">instant</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sound"
|
||||||
|
aria-pressed={music}
|
||||||
|
onClick={() => {
|
||||||
|
const on = !music
|
||||||
|
setMusic(on)
|
||||||
|
if (on && !ended) synth.playTune(PORCH, false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Music
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sound" aria-pressed={sfx} onClick={() => setSfx(!sfx)}>
|
||||||
|
Sound
|
||||||
|
</button>
|
||||||
|
{leaving ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="leave" onClick={leave}>
|
||||||
|
Yes, leave the table
|
||||||
|
</button>
|
||||||
|
<button type="button" className="sound" onClick={() => setLeaving(false)}>
|
||||||
|
Keep playing
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="leave" onClick={() => (ended ? leave() : setLeaving(true))}>
|
||||||
|
New game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="room">
|
||||||
|
{([[2, 'north'], [1, 'west'], [3, 'east']] as const).map(([seat, where]) => (
|
||||||
|
<Seat key={seat} game={g} seat={seat} where={where} act={acts[seat]} hidden={shuffling} says={talk[seat]?.text ?? null} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Table game={g} shuffling={shuffling} held={mine ? lifted : null} hot={hot} last={last} onEnd={(side) => lifted !== null && put(lifted, side)} onDraw={draw}>
|
||||||
|
{shuffling && <Shuffle onDone={() => setShuffling(false)} />}
|
||||||
|
{callout && (
|
||||||
|
<p key={callout.id} className={`callout seat-${callout.seat}`} aria-live="polite">
|
||||||
|
<span>{callout.points}</span>
|
||||||
|
<small>{callout.seat === HUMAN ? 'you score' : g.names[callout.seat]}</small>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{g.phase !== 'play' && g.result && <Result game={g} onNext={nextHand} onLeave={leave} />}
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<section className={`mine${mine ? ' turn' : ''}`} aria-label="Your tiles">
|
||||||
|
<header>
|
||||||
|
<strong>You</strong>
|
||||||
|
<span className="pts">{g.scores[HUMAN]}</span>
|
||||||
|
<button type="button" className="trash" aria-expanded={lines !== null} onClick={() => setLines(lines ? null : fiveLines())}>
|
||||||
|
Talk trash
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
{talk[HUMAN] && (
|
||||||
|
<p key={talk[HUMAN].text} className="speech" role="status">
|
||||||
|
{talk[HUMAN].text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lines && (
|
||||||
|
<div className="trash-menu" role="menu" aria-label="Say something">
|
||||||
|
{lines.map((l) => (
|
||||||
|
<button key={l} type="button" role="menuitem" onClick={() => talkTrash(l)}>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" className="more" onClick={() => setLines(fiveLines())}>
|
||||||
|
Five more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="hand">
|
||||||
|
{(shuffling ? [] : g.hands[HUMAN]).map((t) => (
|
||||||
|
<li key={t}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`held${lifted === t ? (held?.at ? ' dragging' : ' lifted') : ''}`}
|
||||||
|
disabled={!mine}
|
||||||
|
aria-pressed={lifted === t}
|
||||||
|
aria-label={`The ${tileName(t)}`}
|
||||||
|
onPointerDown={(e) => grab(t, e)}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Keyboard only: a pointer has already been handled on the way down.
|
||||||
|
if (e.detail === 0) setHeld((h) => (h?.tile === t ? null : { tile: t, at: null }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TileFace first={TILES[t][1]} second={TILES[t][0]} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{mine && (
|
||||||
|
<p className="row pick">
|
||||||
|
{lifted !== null && g.center && (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => put(lifted, 'left')}>
|
||||||
|
Left end
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => put(lifted, 'right')}>
|
||||||
|
Right end
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{lifted !== null && !g.center && (
|
||||||
|
<button type="button" onClick={() => put(lifted, 'right')}>
|
||||||
|
Lead it
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{g.center && (
|
||||||
|
<>
|
||||||
|
<button type="button" className="knock" onClick={draw}>
|
||||||
|
Draw
|
||||||
|
</button>
|
||||||
|
<button type="button" className="knock" onClick={knock}>
|
||||||
|
Knock
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{refusal && <span className="refusal">{refusal}</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Side game={g} human={HUMAN} shuffling={shuffling} />
|
||||||
|
|
||||||
|
{held?.at && (
|
||||||
|
<div className="floating" style={{ left: held.at.x, top: held.at.y }} aria-hidden="true">
|
||||||
|
<TileFace first={TILES[held.tile][1]} second={TILES[held.tile][0]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="ending" role="dialog" aria-live="polite" aria-label={headline}>
|
||||||
|
<h2>{headline}</h2>
|
||||||
|
<p>{sub}</p>
|
||||||
|
<table className="reveal">
|
||||||
|
<tbody>
|
||||||
|
{r.hands.map((h, s) => (
|
||||||
|
<tr key={s} className={s === r.winner ? 'won' : ''}>
|
||||||
|
<th>{game.names[s]}</th>
|
||||||
|
<td>
|
||||||
|
<Shown tiles={h} />
|
||||||
|
</td>
|
||||||
|
<td className="num">{count(h)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{final && <p className="final">{final}</p>}
|
||||||
|
<div className="row">
|
||||||
|
{over ? (
|
||||||
|
<button type="button" className="go" onClick={onLeave}>
|
||||||
|
Another game
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="go" onClick={onNext} autoFocus>
|
||||||
|
Next hand
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 = <K extends GameEvent['kind']>(kind: K) =>
|
||||||
|
events.findLast((e): e is Extract<GameEvent, { kind: K }> => 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()
|
||||||
|
}
|
||||||
@@ -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' : '.')),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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<ArrayBuffer | null> =
|
||||||
|
typeof window === 'undefined'
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: fetch(shuffleUrl)
|
||||||
|
.then((r) => (r.ok ? r.arrayBuffer() : null))
|
||||||
|
.catch(() => null)
|
||||||
|
|
||||||
|
const NOTE_INDEX: Record<string, number> = {
|
||||||
|
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<Record<Wave, PeriodicWave>> = {}
|
||||||
|
private noiseBuffer!: AudioBuffer
|
||||||
|
private shuffleBuffer: Promise<AudioBuffer | null> | 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()
|
||||||
@@ -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 (
|
||||||
|
<div className="landing crashed" role="alert">
|
||||||
|
<h1>Dominoes</h1>
|
||||||
|
<p>Somebody knocked the table over. The game hit an error and can’t carry on.</p>
|
||||||
|
<p className="dim small">{this.state.error.message}</p>
|
||||||
|
<button type="button" className="go" onClick={() => window.location.reload()}>
|
||||||
|
Start over
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="landing">
|
||||||
|
<header>
|
||||||
|
<h1>Dominoes</h1>
|
||||||
|
<p className="tagline">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="hero" aria-hidden="true">
|
||||||
|
<TileFace first={6} second={6} />
|
||||||
|
<TileFace first={6} second={4} />
|
||||||
|
<TileFace first={4} second={1} />
|
||||||
|
<TileFace first={1} second={0} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="cols">
|
||||||
|
<section>
|
||||||
|
<h2>The whole game</h2>
|
||||||
|
<p>
|
||||||
|
All Fives. Five tiles each, and the other eight face down in the boneyard.
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<strong>The first hand</strong> 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Play goes to the left.</strong> 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The spinner</strong> 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.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Score as you play.</strong> After every tile, add up the open ends. A multiple of five --
|
||||||
|
5, 10, 15, 20 and on -- goes straight on your sheet.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>The hand ends</strong> when somebody plays their last tile, or when nobody can match any end
|
||||||
|
and it is blocked.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Counting the ends</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<h2>The sheet</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<h2>At this table</h2>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="choose">
|
||||||
|
<button type="button" className="start" onClick={onStart}>
|
||||||
|
Shuffle up
|
||||||
|
</button>
|
||||||
|
<p className="dim">First to {target} wins.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p className="ways">
|
||||||
|
{/*
|
||||||
|
Absolute rather than "/", because this game is served from a
|
||||||
|
subdirectory in production and from the root in development.
|
||||||
|
*/}
|
||||||
|
<a href="https://games.jcoffey.dev/">The rest of the games</a>
|
||||||
|
{' · '}
|
||||||
|
<a href="https://git.coffeylabs.org/jcoffey-dev/dominoes">Source</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Dominoes is free software under the{' '}
|
||||||
|
<a href="https://www.gnu.org/licenses/agpl-3.0.html">GNU Affero General Public License, version 3 or later</a>.
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
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 <a href="https://freesound.org/people/RobertWulfman/sounds/220839/">Freesound</a>, in the public
|
||||||
|
domain.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<section className={`seat ${where}${turn ? ' turn' : ''}`} aria-label={`${game.names[seat]}, ${COMPASS[seat]}, ${held} tiles, ${game.scores[seat]} points`}>
|
||||||
|
<header>
|
||||||
|
<strong>{game.names[seat]}</strong>
|
||||||
|
<span className="pts">{game.scores[seat]}</span>
|
||||||
|
</header>
|
||||||
|
<span className="compass">{COMPASS[seat].toLowerCase()}</span>
|
||||||
|
<div className="backs" aria-hidden="true">
|
||||||
|
{Array.from({ length: held }, (_, i) => (
|
||||||
|
<span key={i} className="back" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="bubble">{turn ? 'thinking…' : act === 'knock' ? 'knocked' : ' '}</p>
|
||||||
|
{says && (
|
||||||
|
<p key={says} className="speech" role="status">
|
||||||
|
{says}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiles face up, small, for the end of a hand. */
|
||||||
|
export function Shown({ tiles }: { tiles: readonly Tile[] }) {
|
||||||
|
return (
|
||||||
|
<span className="shown">
|
||||||
|
{tiles.map((t) => (
|
||||||
|
<TileFace key={t} first={TILES[t][1]} second={TILES[t][0]} />
|
||||||
|
))}
|
||||||
|
{tiles.length === 0 && <span className="dim">out</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(null)
|
||||||
|
const [poses, setPoses] = useState<Pose[]>(() => 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 (
|
||||||
|
<div className="shuffle" ref={box} aria-label="Shuffling" role="status">
|
||||||
|
{poses.map((p, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="tile-back"
|
||||||
|
style={{
|
||||||
|
transform: `translate(-50%, -50%) translate(${p.x}px, ${p.y}px) rotate(${p.r}deg)`,
|
||||||
|
opacity: p.o,
|
||||||
|
transitionDuration: `${ms}ms, ${ms}ms`,
|
||||||
|
// Not all at once: hands on a table never move every tile together.
|
||||||
|
transitionDelay: `${(i % 7) * 12}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside className="side">
|
||||||
|
<section className="now" aria-live="polite">
|
||||||
|
<p>{shuffling ? 'Shuffling…' : now(game, human)}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
Score <span className="dim small">first to {game.target}</span>
|
||||||
|
</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th />
|
||||||
|
<th>points</th>
|
||||||
|
<th>tiles</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: SEATS }, (_, s) => (
|
||||||
|
<tr key={s} className={!shuffling && game.phase === 'play' && game.current === s ? 'now' : ''}>
|
||||||
|
<th>{game.names[s]}</th>
|
||||||
|
<td className="marks">
|
||||||
|
<Tally points={game.scores[s]} />
|
||||||
|
</td>
|
||||||
|
<td>{game.scores[s]}</td>
|
||||||
|
<td>{shuffling ? '–' : game.hands[s].length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="log">
|
||||||
|
<h2>This hand</h2>
|
||||||
|
<ol>
|
||||||
|
{lines.map((l, i) => (
|
||||||
|
<li key={i} className={l.heading ? 'heading' : ''}>
|
||||||
|
{l.text}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.'
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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<Record<Side, Link>> = {}
|
||||||
|
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 (
|
||||||
|
<div className="felt" ref={wrap}>
|
||||||
|
<svg className="line" viewBox={`${x0} ${y0} ${w} ${y1 - y0}`} role="img" aria-label={tableLabel(game)}>
|
||||||
|
{spots.map((s) =>
|
||||||
|
s.ghost ? (
|
||||||
|
<g
|
||||||
|
key={`pad-${s.arm}`}
|
||||||
|
className={`pad${hot === s.arm ? ' hot' : ''}`}
|
||||||
|
data-side={s.arm}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={game.center ? (s.arm === 'up' ? 'The top of the spinner' : s.arm === 'down' ? 'The bottom of the spinner' : `The ${s.arm} end`) : 'The middle of the table'}
|
||||||
|
onClick={() => 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. */}
|
||||||
|
<rect className="aim" x={s.x - 0.6} y={s.y - 0.6} width={s.w + 1.2} height={s.h + 1.2} />
|
||||||
|
<rect className="spot" x={s.x + 0.05} y={s.y + 0.05} width={s.w - 0.1} height={s.h - 0.1} rx={0.13} />
|
||||||
|
</g>
|
||||||
|
) : (
|
||||||
|
<g key={s.tile}>
|
||||||
|
<TileG {...s} className={`${s.tile === last ? 'last' : ''}${s.spinner ? ' spinner' : ''}`} />
|
||||||
|
{s.spinner && (
|
||||||
|
// The spinner's mark: a small star at its corner, so the table can see which double opens four ways.
|
||||||
|
<g className="spin-mark" transform={`translate(${s.x + s.w}, ${s.y})`}>
|
||||||
|
<title>The spinner</title>
|
||||||
|
<circle r={0.42} />
|
||||||
|
<path d={star(0.3, 0.13)} />
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{!game.center && game.phase === 'play' && !shuffling && !lead && (
|
||||||
|
<p className="waiting">{game.mustOpen !== null ? 'Waiting for the high double.' : 'Waiting for the lead.'}</p>
|
||||||
|
)}
|
||||||
|
{!shuffling && game.phase === 'play' && (
|
||||||
|
// The boneyard: what is left face down, in the corner where the shuffle pushed it.
|
||||||
|
<button type="button" className="boneyard" onClick={onDraw} aria-label={`The boneyard, ${game.boneyard.length} tiles. Draw one.`}>
|
||||||
|
<span className="pile" aria-hidden="true">
|
||||||
|
{game.boneyard.map((t) => (
|
||||||
|
<span key={t} className="tile-back small" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="label">{game.boneyard.length ? `boneyard ${game.boneyard.length}` : 'boneyard empty'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(', ')}.`
|
||||||
|
}
|
||||||
@@ -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 <span className="tally empty">—</span>
|
||||||
|
return (
|
||||||
|
<span className="tally" role="img" aria-label={`${points} points`}>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<span key={i} className="tally-line">
|
||||||
|
{line.map((n, j) => (
|
||||||
|
<svg key={j} className="house" viewBox="0 0 30 22" aria-hidden="true">
|
||||||
|
{Array.from({ length: Math.min(n, 4) }, (_, k) => (
|
||||||
|
<line key={k} x1={5 + k * 6} y1={3} x2={5 + k * 6} y2={19} />
|
||||||
|
))}
|
||||||
|
{n === 5 && <line className="slash" x1={1} y1={18} x2={27} y2={4} />}
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<number, [number, number][]> = {
|
||||||
|
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) => (
|
||||||
|
<circle key={i} className="pip" cx={x + (lying ? py : px)} cy={y + (lying ? px : py)} r={0.088} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<g className={`tile ${className}`}>
|
||||||
|
<rect x={x + 0.03} y={y + 0.03} width={w - 0.06} height={h - 0.06} rx={0.13} />
|
||||||
|
{lying ? (
|
||||||
|
<line x1={x + 1} y1={y + 0.16} x2={x + 1} y2={y + h - 0.16} />
|
||||||
|
) : (
|
||||||
|
<line x1={x + 0.16} y1={y + 1} x2={x + w - 0.16} y2={y + 1} />
|
||||||
|
)}
|
||||||
|
<Half n={first} x={x} y={y} lying={lying} />
|
||||||
|
<Half n={second} x={lying ? x + 1 : x} y={lying ? y : y + 1} lying={lying} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tile on its own, standing up, for a hand. */
|
||||||
|
export function TileFace({ first, second }: { first: number; second: number }) {
|
||||||
|
return (
|
||||||
|
<svg className="face" viewBox="0 0 1 2" aria-hidden="true">
|
||||||
|
<TileG x={0} y={0} w={1} h={2} first={first} second={second} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
+135
@@ -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<Tile>([...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
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
])
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] : [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Record<Side, Link>> = {}): 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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof makeRng>
|
||||||
|
|
||||||
|
export function shuffle<T>(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
|
||||||
|
}
|
||||||
@@ -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<Age, readonly (string | Say)[]>
|
||||||
|
|
||||||
|
/** 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], '') }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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<number>()
|
||||||
|
while (picked.size < 5) picked.add(Math.floor(rng() * POOL_SIZE))
|
||||||
|
return [...picked].map(trashLine)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<Crash>
|
||||||
|
<App />
|
||||||
|
</Crash>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./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"]
|
||||||
|
}
|
||||||
@@ -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()],
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user