World Conquest: the classic game of world domination, one against five

Forty-two territories grouped from Natural Earth's public-domain outlines,
the classic eighty-three links, exact dice odds, escalating card sets, and
five bots that play every seeded game through to a winner.
This commit is contained in:
2026-09-17 23:13:56 -07:00
commit 49feff0e58
39 changed files with 8141 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# 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?
# Natural Earth downloads and the Python tools' environment, for tools/map.py.
natural-earth
.venv
+661
View File
@@ -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/>.
+70
View File
@@ -0,0 +1,70 @@
# Notice, credits and provenance
## What this is
An independent reimplementation of the classic game of world domination,
written from scratch in TypeScript in 2026: one human against five computer
players, in one sitting.
## The name
It isn't called what you'd expect, and that's deliberate. The rules of a game
are ideas rather than expression, and nobody gets to fence them off, so they're
rebuilt here freely. The **name** is a live trademark belonging to a company
that enforces it, and this ships on a public site with somebody's name in the
footer. So the mechanics are exact and the vocabulary is ours. *World
Conquest* is what the game was called when it was first published, in French,
as *La Conquête du Monde*.
None of that is a copyright question. Game mechanics are ideas. Names are a
trademark question, which is about whether a reader would think this came from
the trademark holder. Calling it *World Conquest* answers that, and costs the
game nothing.
The same goes for the cards. The published game draws a soldier, a horseman and
a cannon. The rules only need three kinds, fourteen of each, so here they're
named for what they are and drawn as plain shapes.
## The map
**The outlines are from Natural Earth**, 1:50m cultural vectors, admin-0
countries and admin-1 states and provinces:
- <https://www.naturalearthdata.com/>, public domain
Being public domain, they carry no license terms of their own. They're credited
here anyway, because somebody drew the world properly and that deserves saying.
`tools/map.py` reads them and writes `src/game/board.json`.
**The grouping is ours**: which countries and provinces make up each of the
forty-two territories, where the board's line down the Urals falls, and which
pieces are left off the map. So are the projection, the simplification and
every color drawn on top.
The **topology**, which territory borders which, is the classic board's. It's
a fact about a published game, typed out by hand in `src/game/map.ts` and
checked against itself.
## What is ours
Everything else: the rules engine, the odds, the bots and their reasoning, the
interface, the sound effects and the music, and every word on screen.
## 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.*
+124
View File
@@ -0,0 +1,124 @@
# World Conquest
A browser rebuild of the classic game of world domination: forty-two
territories, six continents and the dice, with one human against five
computer players.
It isn't called what you'd expect, and that's deliberate. The rules are free to
rebuild, but the trademark and the published artwork are not. *World Conquest*
is the name the game had when it first came out, in French, in 1957. See
[NOTICE.md](NOTICE.md).
## The table
**You against five, at the classic six-player table.** You get seven
territories and twenty armies, the same as everybody else. The world is dealt
at random, the armies go down a turn at a time, and play continues until one
player holds all forty-two territories.
**The rules are the classic ones.** Choices that vary from table to table are
settled like this, and the landing page says so before anyone plays:
- sets escalate for the whole table (4, 6, 8, 10, 12, 15, then five more each
time), not at a fixed value per set;
- a card showing a territory you hold adds two armies there when traded,
on one territory per set;
- five cards means you must trade, and taking a player's cards up to six or
more means you trade immediately, in the middle of your attack;
- fortifying moves once, along any chain of your own territories;
- the defender always rolls every die it can.
## The map
**Real geography, grouped into the classic forty-two.** The outlines come from
[Natural Earth](https://www.naturalearthdata.com/), which is public domain.
`tools/map.py` groups countries, and for Russia, Canada, the United States,
Australia, China and Indonesia their provinces, into the board's territories,
then writes `src/game/board.json`. The grouping is this project's own. Deciding
which country goes in which territory is the only part of the map anyone had to
decide.
**The links are the classic board's, link for link.** They're typed out in
`src/game/map.ts`, and `npm test` holds them to eighty-three, symmetric and
connected, with each continent's entry points where every player expects them:
one into Australia, two into South America, and five into Asia.
Where real geography and the classic links disagree, the rules win and the map
says so:
- **A lane across the water** for every link the shapes don't show:
Brazil to North Africa, Greenland to Iceland, and so on. Alaska and Kamchatka
meet off the two edges of the board, across the date line.
- **A closed border** where the real map touches but the classic board has no
link. There's exactly one. The Altai Republic gives Siberia a short border with
Kazakhstan (Afghanistan, on this board), so it's drawn as a fence. Every other
accidental border was grouped away: North Korea goes with Manchuria rather
than China, and the steppe south of Siberia goes with the Ural.
Owners are shown by the fill. Continents are shown by the coastline, with each
continent's shore drawn in its own color underneath the land. That way the six
read at a glance without taking the fill away from who holds what.
To rebuild the map:
```bash
python3 -m venv .venv && .venv/bin/pip install shapely pyshp
.venv/bin/python tools/map.py path/to/natural-earth
```
The directory needs the 1:50m admin-0 countries and admin-1 states and
provinces from naciscdn.org, unzipped.
## The dice
`src/game/dice.ts` computes the odds from the 36, 216, 1,296 and 7,776 equally
likely throws, rather than copying a table, and the tests hold it to the
published figures. A 20,000-battle run of the actual dice has to agree with it
too. The same numbers drive two things: the "Pressed to the end, 64% to take
it" line shown when you aim an attack, and every decision a bot makes.
## The bots
`src/game/bot.ts`. A bot keeps no plan between moves. Each time it's asked, it
looks at the board and answers with the one thing it would do next. A plan the
dice have already ruined can never linger, and a game can be paused anywhere.
What a bot wants comes down to three things, in this order: a continent it can
hold, a card for the turn, and nobody else holding a continent in peace. It
prices each attack as what winning is worth, multiplied by the exact chance of
winning. It fights when the odds clear a line set by its nerve (roughly 55% to
75%), and goes a little lower for the first territory of a turn, because a
card is on the line. Reinforcements pile up at one staging point rather than
spreading thin. Idle stacks walk to the front.
Each bot leans a little: braver or more careful, and slightly fonder of some
continents than others. The lean comes from the seed, so a seed still replays a
game exactly.
**They finish.** `play.test.ts` plays thirty seeded six-bot games to the end,
and every one has to end with somebody holding the world. They take 20 rounds
on average, 9 at the fewest and 40 at the most, and more than three different
seats win. A game that can't be won isn't a hard game, it's a broken one, and
the sibling game on this site learned that the hard way.
## The sound
The synth is the one the site's other games share. What's written for this
game is the dice, which clatter as many dice as were actually thrown; a
conquest, which is three guns and a bugle going up; the card-trade fanfare,
which starts higher the more the set is worth; and one march in C minor. When
the bots are playing fast, a batch of moves makes at most one sound, the most
important thing in it, so a whole round doesn't arrive as one crash.
## Where it lives
<https://games.jcoffey.dev/conquest/>, built straight from this repository's
`main`. This is AGPL, and section 13 asks that players be offered the source
of the version they're running. Building from the published ref is what makes
that offer true.
## Still to build
- On a phone the whole world is 390 pixels wide. It fits and plays, but the
small territories are small targets, and there's no zoom of its own beyond the
browser's pinch.
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>World Conquest — the classic game of world domination, in your browser</title>
<meta
name="description"
content="Forty-two territories, six continents and the dice. You against five computer players, reinforcing, attacking and trading cards until one of you holds the whole world. 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 /conquest/ rather than returning a 404. That is right
for the app and wrong for a crawler, which would otherwise be free to
index /conquest/anything-at-all as a separate page with identical content.
-->
<link rel="canonical" href="https://games.jcoffey.dev/conquest/" />
<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="World Conquest — the classic game of world domination, in your browser" />
<meta property="og:url" content="https://games.jcoffey.dev/conquest/" />
<meta
property="og:description"
content="Forty-two territories, six armies and the dice. You against five, until one of you holds the world."
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="World Conquest — the classic game of world domination, in your browser" />
<meta
name="twitter:description"
content="Forty-two territories, six armies and the dice. You against five, until one of you holds the world."
/>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoGame",
"name": "World Conquest",
"url": "https://games.jcoffey.dev/conquest/",
"description": "A browser rebuild of the classic game of world domination. Forty-two territories in six continents, dice for every battle, and cards that trade in for ever larger armies. One human against five computer players that reinforce, attack and fortify by the exact odds.",
"genre": ["Strategy", "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": "World Conquest",
"item": "https://games.jcoffey.dev/conquest/"
}
]
}
</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>World Conquest</h1>
<p>
Forty-two territories in six continents, dealt out at random. Reinforce,
attack with the dice, trade in cards for armies, and hold whole
continents for more. You play one army against five computer players,
until one of you holds the world. It needs JavaScript to play.
</p>
<p><a href="https://games.jcoffey.dev/">The rest of the games</a></p>
<p><a href="https://github.com/jcoffey-dev/world-conquest">Source, AGPL-3.0-or-later</a></p>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2928
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "world-conquest",
"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": "A browser rebuild of the classic world-conquest board game, one human against five computer players.",
"license": "AGPL-3.0-or-later",
"author": "John Coffey",
"repository": {
"type": "git",
"url": "git+https://github.com/jcoffey-dev/world-conquest.git"
},
"homepage": "https://games.jcoffey.dev/conquest/",
"bugs": {
"url": "https://github.com/jcoffey-dev/world-conquest/issues"
}
}
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="World Conquest">
<!-- A six on a die, one pip in each of the six armies' colors: the whole
game is those six and the dice. Drawn by hand, like every other mark in
this repository. -->
<rect x="4" y="4" width="56" height="56" rx="12" fill="#fffaf0" stroke="#2b2318" stroke-width="3.5"/>
<g stroke="#2b2318" stroke-width="2.5">
<circle cx="20" cy="18" r="6" fill="#d8543f"/>
<circle cx="44" cy="18" r="6" fill="#3f7fd8"/>
<circle cx="20" cy="32" r="6" fill="#e8b53a"/>
<circle cx="44" cy="32" r="6" fill="#3fae7a"/>
<circle cx="20" cy="46" r="6" fill="#9a62c9"/>
<circle cx="44" cy="46" r="6" fill="#7c8a99"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 749 B

+337
View File
@@ -0,0 +1,337 @@
/* =====================================================================
World Conquest.
The site's cel look: flat fills, one heavy outline on everything, no
gradients anywhere near the board. And, like the seven-power game, it
takes the whole window -- forty-two territories do not fit a cabinet.
===================================================================== */
.app {
height: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: 'head head' 'map side';
gap: 10px 18px;
padding: clamp(10px, 2vw, 22px);
}
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 { outline: 3px solid #ffd479; outline-offset: 1px; }
button.go {
color: #241a10;
background: #ffd479;
font-weight: 800;
}
button.quiet { background: none; color: #a3b3c9; border-color: #2f3b52; }
.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; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
/* ------------------------------------------------------------ the map */
.map-wrap {
grid-area: map;
position: relative;
min-width: 0;
min-height: 0;
display: flex;
align-items: flex-start;
justify-content: center;
}
.board {
/* Sized by its width and the map's own proportions, never letterboxed:
empty ocean above and below the world is not a place anybody can go. */
width: 100%;
height: auto;
max-height: 100%;
border: 2px solid #2b2318;
border-radius: 8px;
background: #cfe3ef;
user-select: none;
}
.ocean { fill: #cfe3ef; }
/* Each continent's shore in its own color, drawn under the land so only the
coast shows. */
.shores path {
fill: none;
stroke-width: 7;
stroke-linejoin: round;
stroke-opacity: 0.85;
}
.lanes line {
stroke: #41596b;
stroke-width: 2;
stroke-dasharray: 6 5;
stroke-linecap: round;
}
.land {
stroke: #2b2318;
stroke-width: 0.9;
stroke-linejoin: round;
cursor: pointer;
transition: filter 0.2s;
}
.land:hover { filter: brightness(1.12); }
.land.picked { stroke: #fffaf0; stroke-width: 3.5; filter: brightness(1.18); }
.land.open { stroke: #241a10; stroke-width: 2.6; stroke-dasharray: 5 4; }
.land.aimed { stroke: #fffaf0; stroke-width: 3.5; stroke-dasharray: none; }
@keyframes taken {
from { filter: brightness(1.8) saturate(0.4); }
to { filter: none; }
}
.land.flash { animation: taken 0.9s ease-out; }
.borders path { fill: none; pointer-events: none; stroke-linejoin: round; stroke-linecap: round; }
.borders .inner { stroke: none; }
.borders .continental { stroke: #241a10; stroke-width: 2.4; }
/* A fence: the real map touches here and the rules do not cross. */
.borders .closed { stroke: #241a10; stroke-width: 4.5; stroke-dasharray: 1 5; }
.badges g { cursor: pointer; }
.badges circle { stroke: #fffaf0; stroke-width: 1.6; }
.badges text {
fill: #fffaf0;
font-size: 11px;
font-weight: 900;
text-anchor: middle;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
/* ------------------------------------------------------------ the end */
.ending {
position: absolute;
inset: auto 50% 50% auto;
transform: translate(50%, 50%);
max-width: min(440px, 90%);
padding: 18px 20px;
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.3rem; }
.ending p { margin: 0 0 12px; color: #cbd6e6; line-height: 1.45; }
/* ------------------------------------------------------------ the side */
.side {
grid-area: side;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
padding-right: 4px;
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; }
.side strong { color: #fffaf0; }
.side section > button { align-self: flex-start; }
.now {
padding: 10px;
border: 2px solid #2b2318;
border-radius: 8px;
background: #22304a;
}
.turn { font-weight: 800; font-size: 0.95rem; display: flex; align-items: center; }
.toggle[aria-pressed='true'] { background: #3c527a; box-shadow: inset 0 0 0 2px #ffd479; }
.odds { color: #ffd479; font-weight: 700; }
input[type='range'] { width: 100%; accent-color: #ffd479; }
.chip {
display: inline-block;
width: 11px;
height: 11px;
border: 2px solid #241a10;
border-radius: 3px;
margin-right: 6px;
vertical-align: -1px;
flex: none;
}
.throw .dice { display: flex; align-items: center; gap: 10px; }
.throw .dice div { display: flex; gap: 4px; }
.throw .vs { color: #a3b3c9; font-size: 0.75rem; }
.die { width: 26px; height: 26px; }
.die rect { stroke: #241a10; stroke-width: 0.25; }
.die.attack rect { fill: #c8452f; }
.die.attack circle { fill: #fffaf0; }
.die.defend rect { fill: #fffaf0; }
.die.defend circle { fill: #241a10; }
.cards { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 5px; }
.card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
min-width: 80px;
padding: 5px 8px;
font-size: 0.76rem;
text-align: left;
background: #2a3a58;
}
.card:disabled { opacity: 1; cursor: default; }
.card[aria-pressed='true'] { box-shadow: inset 0 0 0 2px #ffd479; background: #3c527a; }
.card .glyph { font-size: 0.95rem; color: #ffd479; }
.card.wild .glyph { color: #ff9a8b; }
.card .own { font-size: 0.66rem; color: #9fe0b9; }
.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; }
.table tr.gone { opacity: 0.4; text-decoration: line-through; }
.continents { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
.continents li { display: flex; align-items: center; gap: 6px; }
.continents .shore { width: 16px; height: 0; border-top: 5px solid; border-radius: 3px; }
.continents .held { margin-left: auto; display: flex; align-items: center; font-weight: 700; }
.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: 860px) {
.app {
height: auto;
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
grid-template-areas: 'head' 'map' 'side';
}
.side { overflow: visible; }
}
/* ---------------------------------------------------------- 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; }
.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 ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; padding: 0; }
.landing .choose li button { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 0.95rem; }
.landing .choose button.on { background: #3c527a; box-shadow: inset 0 0 0 2px #ffd479; }
.swatch { width: 15px; height: 15px; border: 2px solid #241a10; border-radius: 4px; }
.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; }
+402
View File
@@ -0,0 +1,402 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import './App.css'
import { playFor } from './audio/play'
import { MARCH } from './audio/score'
import { synth } from './audio/synth'
import { Board } from './components/Board'
import { Landing } from './components/Landing'
import { Side } from './components/Side'
import { apply, decide, leanings, type Action, type Leaning } from './game/bot'
import { setsIn } from './game/cards'
import { PLAYER_NAMES } from './game/describe'
import { Game, PLAYER_COUNT } from './game/game'
import type { Territory } from './game/map'
export type Speed = 'steady' | 'quick' | 'instant'
/**
* How long a bot takes over each kind of move, in milliseconds, at steady.
*
* Slow enough to follow, which is the whole reason to pace it at all: a bot
* that finishes its turn before you have looked up has told you nothing
* about what it did. The dice get the least, because there are the most of
* them, and a trade the most, because it is the one move everybody at a
* table stops to watch.
*/
const PACE: Record<Action['type'], number> = {
place: 320,
trade: 900,
beginAttack: 250,
attack: 380,
occupy: 320,
endAttack: 200,
fortify: 600,
endTurn: 350,
}
const pace = (a: Action, speed: Speed, setup: boolean) =>
setup ? (speed === 'steady' ? 70 : 25) : PACE[a.type] * (speed === 'steady' ? 1 : 0.35)
export default function App() {
const [screen, setScreen] = useState<'landing' | 'game'>('landing')
const [color, setColor] = useState(0)
const [, 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 [sel, setSel] = useState<Territory | null>(null)
const [target, setTarget] = useState<Territory | null>(null)
const [flash, setFlash] = useState<ReadonlySet<Territory>>(new Set())
const [autoSetup, setAutoSetup] = useState(false)
const [watching, setWatching] = useState(false)
const [leaving, setLeaving] = useState(false)
/** How many armies a click puts down while reinforcing. */
const [step, setStep] = useState<1 | 5 | 'all'>(1)
const g = gameRef.current
/** Everything that happens goes through here: sound, the flash, 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
const taken = new Set<Territory>()
for (const e of fresh) if (e.kind === 'conquer') taken.add(e.to)
if (taken.size) setFlash(taken)
playFor(fresh, color)
if (game.phase === 'over') synth.stopTune()
bump()
}, [bump, color])
// ---------------------------------------------------------- the bots
const humanOut = g ? !g.players[color].alive : false
useEffect(() => {
const game = gameRef.current
if (!game || screen !== 'game' || game.phase === 'over') return
const human = game.players[game.current].human
const autopilot = human && game.phase === 'setup' && autoSetup
if (human && !autopilot) return
if (humanOut && !watching) return
const act = () => {
const a = decide(game, leanRef.current[game.current])
apply(game, a)
return a
}
if (speed === 'instant' && !autopilot) {
// Straight through to the next thing that needs a person, with the
// screen drawn once at the end. There is a ceiling in case that never
// comes -- a whole game of bots is a few thousand moves.
const id = window.setTimeout(() => {
for (let i = 0; i < 20000; i++) {
if (game.phase === 'over' || game.players[game.current].human) break
act()
}
settle()
}, 60)
return () => window.clearTimeout(id)
}
const next = decide(game, leanRef.current[game.current])
const id = window.setTimeout(() => {
apply(game, next)
settle()
}, pace(next, speed, game.phase === 'setup'))
return () => window.clearTimeout(id)
})
// The flash fades on its own.
useEffect(() => {
if (flash.size === 0) return
const id = window.setTimeout(() => setFlash(new Set()), 900)
return () => window.clearTimeout(id)
}, [flash])
// ------------------------------------------------------------- music
useEffect(() => {
synth.setMusic(music)
}, [music])
useEffect(() => {
synth.setSfx(sfx)
}, [sfx])
// --------------------------------------------------------- starting
const start = () => {
synth.ensure()
const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0
const game = new Game(seed, color)
gameRef.current = game
leanRef.current = leanings(seed, PLAYER_COUNT)
seen.current = 0
setSel(null)
setTarget(null)
setAutoSetup(false)
setWatching(false)
setLeaving(false)
setScreen('game')
synth.dice(4)
if (music) synth.playTune(MARCH)
settle()
}
const leave = () => {
synth.stopTune()
gameRef.current = null
setScreen('landing')
}
// ------------------------------------------------ the player's moves
const mine = g ? g.current === color && g.phase !== 'over' : false
/** The one move a click on the map can mean, given the phase. */
const pick = (t: Territory) => {
if (!g || !mine) return
const own = g.owner[t] === color
switch (g.phase) {
case 'setup':
if (!own) return synth.reject()
g.place(t)
synth.tap()
return settle()
case 'reinforce':
if (!own || g.mustTrade || g.reinforcements === 0) return synth.reject()
return act.place(t, step === 'all' ? g.reinforcements : Math.min(step, g.reinforcements))
case 'attack':
if (own) {
if (t === sel) {
setSel(null)
setTarget(null)
} else if (g.canAttackFrom(t)) {
setSel(t)
setTarget(null)
synth.tap()
} else synth.reject()
return
}
if (sel && g.attackTargets(sel).includes(t)) {
setTarget(t)
synth.written()
} else synth.reject()
return
case 'fortify':
if (!own) return synth.reject()
if (sel && t !== sel && g.fortifyTargets(sel).includes(t)) {
setTarget(t)
synth.written()
} else if (t === sel) {
setSel(null)
setTarget(null)
} else if (g.fortifyTargets(t).length) {
setSel(t)
setTarget(null)
synth.tap()
} else synth.reject()
return
}
}
const act = {
place: (t: Territory, n: number) => {
if (!g) return
g.place(t, n)
synth.muster()
// Nothing left to place and no set worth trading: straight on.
if (g.reinforcements === 0 && setsIn(g.hand).length === 0) g.beginAttack()
settle()
},
trade: (cards: [number, number, number]) => {
g?.trade(cards)
settle()
},
beginAttack: () => {
g?.beginAttack()
settle()
},
roll: (dice: number) => {
if (!g || !sel || !target) return
g.attack(sel, target, dice)
afterRoll()
},
blitz: () => {
if (!g || !sel || !target) return
while (g.phase === 'attack' && g.armies[sel] > 1) g.attack(sel, target)
afterRoll()
},
occupy: (n: number) => {
if (!g || !g.occupation) return
const to = g.occupation.to
g.occupy(n)
// Stay on the new ground, ready to push on from it.
setSel(g.canAttackFrom(to) ? to : null)
setTarget(null)
settle()
},
endAttack: () => {
g?.endAttack()
setSel(null)
setTarget(null)
settle()
},
fortify: (n: number) => {
if (!g || !sel || !target) return
g.fortify(sel, target, n)
setSel(null)
setTarget(null)
settle()
},
endTurn: () => {
g?.endTurn()
setSel(null)
setTarget(null)
settle()
},
autoSetup: () => {
setAutoSetup(true)
},
}
const afterRoll = () => {
if (!g || !sel) return
if (g.phase === 'attack' && !g.canAttackFrom(sel)) {
setSel(null)
setTarget(null)
}
settle()
}
// Escape lets go of whatever is picked.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSel(null)
setTarget(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const targets: Territory[] =
!g || !sel || !mine
? []
: g.phase === 'attack'
? g.attackTargets(sel)
: g.phase === 'fortify'
? g.fortifyTargets(sel)
: []
if (screen === 'landing' || !g) {
return <Landing color={color} onPick={setColor} onStart={start} />
}
const ended = g.phase === 'over'
const won = ended && g.winner === color
return (
<div className="app">
<header>
<h1>World Conquest</h1>
<nav className="ways">
<label>
Bots{' '}
<select value={speed} onChange={(e) => setSpeed(e.target.value as Speed)}>
<option value="steady">steady</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(MARCH, 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, abandon it
</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="map-wrap">
<Board game={g} selected={sel} targets={targets} target={target} flash={flash} onPick={pick} />
{(ended || (humanOut && !watching)) && (
<div className="ending" role="dialog" aria-live="polite">
{ended ? (
<>
<h2>{won ? 'The world is yours.' : `${PLAYER_NAMES[g.winner!]} holds the world.`}</h2>
<p>
{won
? `Round ${g.round}. Forty-two territories, and nobody left to take them back.`
: humanOut
? 'You were out of it before the end, but the end came all the same.'
: `Round ${g.round}.`}
</p>
</>
) : (
<>
<h2>You are out.</h2>
<p>Your last territory has gone. The war goes on without you.</p>
</>
)}
<div className="row">
{!ended && (
<button type="button" onClick={() => setWatching(true)}>
Watch it finish
</button>
)}
<button type="button" className="go" onClick={leave}>
Another war
</button>
</div>
</div>
)}
</main>
<Side
game={g}
human={color}
sel={sel}
target={target}
autoSetup={autoSetup}
step={step}
onStep={setStep}
act={act}
/>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { GameEvent } from '../game/game'
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, and forty dice and six conquests at once are one
* indistinct crash -- so a batch makes at most one noise, the most important
* thing in it, in this order: the end, somebody going out, a trade, a
* territory taken, a throw of the dice.
*/
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.player === human ? synth.victory() : synth.defeat()
const out = find('eliminate')
if (out) return out.loser === human ? synth.eliminated() : synth.fallen()
const trade = find('trade')
if (trade) return synth.fanfare(trade.armies)
if (find('conquer')) return synth.conquest()
const battle = find('battle')
if (battle) return synth.dice(battle.roll.attack.length + battle.roll.defend.length)
}
+98
View File
@@ -0,0 +1,98 @@
import type { Tune } from './synth'
/**
* The score: one march, and what it is.
*
* Not a quotation of anything. The idiom belongs to nobody -- a minor key, a
* dotted figure, brass moving over a band that holds its chords a bar at a
* time, and a side drum that never lets up -- and it is the same idiom the
* seven-power game uses, so the two sound like the same site without either
* borrowing a bar from the other.
*
* C minor, where that game is in D, and a little quicker: this is a game of
* dice and it moves faster than a game of letters. It loops for as long as
* the war does, and stops when somebody has the world.
*
* Eight bars. The first four climb the dotted figure twice and fall back;
* the second four answer them from a fourth higher and come home on the
* dominant borrowed from the harmonic minor -- the B natural in bar seven is
* the only note that is not in the key, and it is the one that makes the
* loop want to go round again.
*/
const bar = (...steps: string[]) => steps
const held = (chord: string) => [chord, '=', '=', '=', '=', '=', '=', '=']
export const MARCH: Tune = {
bpm: 108,
stepsPerBeat: 2,
tracks: [
{
wave: 'saw',
gain: 0.11,
gate: 0.92,
detune: 6,
filter: { from: 1700, to: 900, q: 1.3 },
notes: [
...bar('C4', '=', '=', 'D4', 'D#4', '=', '=', '='),
...bar('D4', '=', '=', 'D#4', 'F4', '=', '=', '='),
...bar('G4', '=', '=', '=', 'F4', '=', 'D#4', '='),
...bar('D4', '=', '=', '=', '=', '=', '.', '.'),
...bar('F4', '=', '=', 'G4', 'G#4', '=', '=', '='),
...bar('G4', '=', '=', 'F4', 'D#4', '=', '=', '='),
...bar('D4', '=', '=', '=', 'B3', '=', 'D4', '='),
...bar('C4', '=', '=', '=', '=', '=', '=', '.'),
],
},
{
wave: 'pulse50',
gain: 0.04,
gate: 1,
notes: [
...held('C3/D#3/G3'),
...held('G#2/C3/D#3'),
...held('C3/D#3/G3'),
...held('G2/B2/D3'),
...held('F2/G#2/C3'),
...held('C3/D#3/G3'),
...held('G2/B2/D3'),
...held('C3/D#3/G3'),
],
},
{
wave: 'triangle',
gain: 0.22,
gate: 1,
notes: [
...held('C2'),
...held('G#1'),
...held('C2'),
...held('G1'),
...held('F1'),
...held('C2'),
...held('G1'),
...held('C2'),
],
},
{
// The side drum, marking the end of each half rather than vamping.
wave: 'noise',
gain: 0.14,
notes: [
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', 'S', 'S', 'S', 'S', '.', 'S', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'),
...bar('S', '.', 'S', 'S', '.', 'S', '.', 'S'),
...bar('S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'),
],
},
{
wave: 'noise',
gain: 0.28,
notes: Array.from({ length: 64 }, (_, i) => (i % 4 === 0 ? 'K' : '.')),
},
],
}
+528
View File
@@ -0,0 +1,528 @@
/**
* 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 and the seven powers. Same author, same license, and
* deliberately not forked: a look-ahead scheduler is not the part of a game
* worth writing five times.
*
* What is written *for this game* is everything under "the war", and the
* score in `score.ts`. This is a game of dice, so the dice get the most care:
* a throw is heard as the number of dice actually thrown.
*/
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 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 war
/**
* Dice on a table.
*
* A die is a handful of short bright knocks at uneven spacing, slowing as
* it loses energy, and then it stops -- so the gaps grow and the knocks
* quieten, which between them is the whole illusion. As many as were
* thrown, up to five, each a hair behind the last so they do not merge.
*/
dice(count = 2) {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (let die = 0; die < Math.min(5, count); die++) {
let t = at + die * 0.035
let gap = 0.035
for (let i = 0; i < 8; i++) {
const knock = ctx.createBufferSource()
knock.buffer = this.noiseBuffer
knock.playbackRate.value = 1.3 + die * 0.15
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 1500 + ((i * 7 + die * 11) % 5) * 260
bp.Q.value = 3
const env = ctx.createGain()
const gain = 0.1 * (1 - i / 9)
env.gain.setValueAtTime(gain, t)
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.03)
knock.connect(bp).connect(env).connect(this.sfxBus)
knock.start(t)
knock.stop(t + 0.04)
t += gap
gap *= 1.2
}
}
}
/**
* A field gun, and the shell landing.
*
* A crack with almost no body, then a lowpassed thump a beat later with
* plenty. Artillery at a distance is mostly the second one, so the first is
* kept quiet and quick -- turn it up and the whole thing becomes a drum.
*/
private gun(at: number, gain: number, pitch: number) {
const ctx = this.ensure()
const crack = ctx.createBufferSource()
crack.buffer = this.noiseBuffer
crack.playbackRate.value = 1.2
const hp = ctx.createBiquadFilter()
hp.type = 'highpass'
hp.frequency.value = 1100
const cenv = ctx.createGain()
cenv.gain.setValueAtTime(gain * 0.5, at)
cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.09)
crack.connect(hp).connect(cenv).connect(this.sfxBus)
crack.start(at)
crack.stop(at + 0.11)
const body = ctx.createBufferSource()
body.buffer = this.noiseBuffer
body.playbackRate.value = 0.45
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.Q.value = 1.2
lp.frequency.setValueAtTime(900, at + 0.02)
lp.frequency.exponentialRampToValueAtTime(110, at + 0.5)
const benv = ctx.createGain()
benv.gain.setValueAtTime(0.0001, at + 0.02)
benv.gain.exponentialRampToValueAtTime(gain, at + 0.05)
benv.gain.exponentialRampToValueAtTime(0.0001, at + 0.55)
body.connect(lp).connect(benv).connect(this.sfxBus)
body.start(at + 0.02)
body.stop(at + 0.6)
const boom = ctx.createOscillator()
boom.type = 'sine'
boom.frequency.setValueAtTime(pitch, at + 0.03)
boom.frequency.exponentialRampToValueAtTime(pitch * 0.35, at + 0.4)
const oenv = ctx.createGain()
oenv.gain.setValueAtTime(gain * 0.8, at + 0.03)
oenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.45)
boom.connect(oenv).connect(this.sfxBus)
boom.start(at + 0.03)
boom.stop(at + 0.5)
}
/**
* A territory changing hands.
*
* Three guns and a bugle going up. The dice are the fight; this is the
* moment it is over, so it is shorter than a barrage and ends on the
* rising interval that has meant "forward" since there were bugles.
*/
conquest() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, offset] of [0, 0.14, 0.31].entries()) this.gun(at + offset, 0.2 - i * 0.03, 88 - i * 8)
const t = 0.42
for (const [i, note] of ['C4', 'G4'].entries()) {
this.voice(this.sfxBus, 'saw', noteToFreq(note), at + t + i * 0.13, i ? 0.32 : 0.12, 0.1, {
filter: { from: 2400, to: 1200, q: 1 },
})
}
}
/** Armies raised: a drum and a short rising figure, quieter the more often it plays. */
muster() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
this.drum(this.sfxBus, 'K', at, 0.22)
this.seq([['G3', 0.06], ['C4', 0.1]], 'triangle', 0.1)
}
/**
* Cards traded in.
*
* A fanfare, because it is the one moment in the game when armies appear
* out of nowhere and everybody at the table notices. Three notes up and a
* held fourth; the later the set, the more it is worth, and the higher it
* starts.
*/
fanfare(worth: number) {
const lift = Math.min(7, Math.floor(worth / 5))
const root = ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5'][lift]
const f = noteToFreq(root)
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, [ratio, dur]] of [[1, 0.12], [1.25, 0.12], [1.5, 0.12], [2, 0.5]].entries()) {
this.voice(this.sfxBus, 'saw', f * ratio, at + i * 0.14, dur, 0.09, {
filter: { from: 2600, to: 1400, q: 1 },
detune: 5,
})
}
}
/** A player gone from the board, and not you: the drum stopping. */
fallen() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
this.drum(this.sfxBus, 'K', at, 0.3)
this.drum(this.sfxBus, 'K', at + 0.3, 0.2)
this.seq([['.', 0.3], ['E4', 0.18], ['D#4', 0.6]], 'triangle', 0.12)
}
// ---------------------------------------------------------- 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 world. Bells, and guns firing at nothing. */
victory() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, note] of ['C5', 'G4', 'E4', 'C4', 'G4', 'C5'].entries()) {
this.bell(at + i * 0.42, note, [[1, 0.13, 2.6], [2.76, 0.05, 1.1], [5.4, 0.025, 0.5]])
}
for (const offset of [0.6, 1.4, 2.1]) this.gun(at + offset, 0.12, 80)
}
/** Somebody else's world: the same bells, in a minor key, for a flag that is not yours. */
defeat() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, note] of ['C4', 'D#4', 'G3', 'C3'].entries()) {
this.bell(at + i * 0.5, note, [[1, 0.13, 3], [2.76, 0.04, 1.2]])
}
}
/** Your last territory. A single low bell and nothing after it. */
eliminated() {
const ctx = this.ensure()
this.bell(ctx.currentTime + 0.01, 'C2', [[1, 0.16, 4], [2.76, 0.05, 1.6]])
}
}
export const synth = new Synth()
+156
View File
@@ -0,0 +1,156 @@
import { memo } from 'react'
import board from '../game/board.json'
import type { Game } from '../game/game'
import { LINK_LIST, TERRITORIES, TERRITORY_IDS, type Continent, type Territory } from '../game/map'
import { PLAYER_NAMES } from '../game/describe'
/**
* The map.
*
* The shapes are real geography, grouped into the forty-two territories by
* `tools/map.py`; the links are the classic board's, from `map.ts`. Where
* the two disagree the rules win and the drawing says so: a dashed lane
* across water that the rules cross, and a closed border -- drawn as a
* fence -- where two territories happen to touch and the rules never let
* anybody across.
*
* Owners are the fill. Continents are the coastline: each one's shore is
* drawn in its own color, under everything, so the six read at a glance
* without taking the fill away from the question that matters, which is
* whose it is.
*/
export const COLORS = ['#d8543f', '#3f7fd8', '#e8b53a', '#3fae7a', '#9a62c9', '#7c8a99'] as const
/** A darker tone of each, for the army badges, so a number is legible on its own color. */
export const DARK = ['#8e2a1b', '#1d4a8e', '#94690f', '#1d6b48', '#5c3182', '#434e5a'] as const
export const SHORE: Record<Continent, string> = {
'north-america': '#e3a33b',
'south-america': '#d4543b',
europe: '#4a79c9',
africa: '#8a5a33',
asia: '#3e9b5a',
australia: '#a052b8',
}
interface Drawn {
d: string
label: [number, number]
}
const DRAWN = board.territories as unknown as Record<Territory, Drawn>
const NEAR = board.near as unknown as Record<string, [number, number, number, number]>
const BORDERS = board.borders as Record<string, string>
const TOUCHING = new Set(board.touching.map(([a, b]) => `${a}|${b}`))
const LINKED = new Set(LINK_LIST.map(([a, b]) => [a, b].sort().join('|')))
const WRAP = board.wrap as unknown as { alaska: [number, number]; kamchatka: [number, number] }
/** Links drawn as lanes: every one the shapes do not already show. */
const LANES = LINK_LIST.map(([a, b]) => [a, b].sort().join('|'))
.filter((k) => !TOUCHING.has(k) && k !== 'alaska|kamchatka')
.map((k) => ({ key: k, xy: NEAR[k] }))
.filter((l) => l.xy)
const edgeKind = (key: string) => {
const [a, b] = key.split('|') as [Territory, Territory]
if (!LINKED.has(key)) return 'closed'
return TERRITORIES[a].continent === TERRITORIES[b].continent ? 'inner' : 'continental'
}
/** Drawn once: nothing about the land or the sea changes during a game. */
const Ground = memo(function Ground() {
return (
<>
<rect className="ocean" x={-40} y={-40} width={board.width + 80} height={board.height + 80} />
<g className="shores">
{TERRITORY_IDS.map((t) => (
<path key={t} d={DRAWN[t].d} stroke={SHORE[TERRITORIES[t].continent]} />
))}
</g>
<g className="lanes">
{LANES.map((l) => (
<line key={l.key} x1={l.xy[0]} y1={l.xy[1]} x2={l.xy[2]} y2={l.xy[3]} />
))}
{/* Across the date line, which is the edge of the board. */}
<line x1={WRAP.alaska[0]} y1={WRAP.alaska[1]} x2={-30} y2={WRAP.alaska[1]} />
<line x1={WRAP.kamchatka[0]} y1={WRAP.kamchatka[1]} x2={board.width + 30} y2={WRAP.kamchatka[1]} />
</g>
</>
)
})
export function Board({
game,
selected,
targets,
target,
flash,
onPick,
}: {
game: Game
selected: Territory | null
targets: readonly Territory[]
target: Territory | null
/** Territories that just changed hands, briefly marked. */
flash: ReadonlySet<Territory>
onPick: (t: Territory) => void
}) {
return (
<svg
className="board"
viewBox={`-30 -10 ${board.width + 60} ${board.height + 20}`}
role="group"
aria-label="The map"
>
<Ground />
<g>
{TERRITORY_IDS.map((t) => {
const owner = game.owner[t]
const cls = [
'land',
t === selected && 'picked',
t === target && 'aimed',
targets.includes(t) && 'open',
flash.has(t) && 'flash',
]
.filter(Boolean)
.join(' ')
return (
<path
key={t}
d={DRAWN[t].d}
className={cls}
fill={COLORS[owner]}
onClick={() => onPick(t)}
>
<title>
{TERRITORIES[t].name}: {PLAYER_NAMES[owner]}, {game.armies[t]}{' '}
{game.armies[t] === 1 ? 'army' : 'armies'}
</title>
</path>
)
})}
</g>
<g className="borders">
{Object.entries(BORDERS).map(([key, d]) => (
<path key={key} d={d} className={edgeKind(key)}>
{edgeKind(key) === 'closed' && <title>A closed border: no link on the classic board</title>}
</path>
))}
</g>
<g className="badges" aria-hidden="true">
{TERRITORY_IDS.map((t) => {
const [x, y] = DRAWN[t].label
const n = game.armies[t]
const r = n >= 100 ? 13 : n >= 10 ? 11 : 9.5
return (
<g key={t} transform={`translate(${x} ${y})`} onClick={() => onPick(t)}>
<circle r={r} fill={DARK[game.owner[t]]} />
<text dy="0.36em">{n}</text>
</g>
)
})}
</g>
</svg>
)
}
+143
View File
@@ -0,0 +1,143 @@
import { PLAYER_NAMES } from '../game/describe'
import { PLAYER_COUNT, STARTING_ARMIES } from '../game/game'
import { CONTINENTS, CONTINENT_IDS } from '../game/map'
import { COLORS, SHORE } from './Board'
/**
* The page before the map.
*
* Everybody thinks they know the rules to this one, and most people learned
* them from somebody who had changed three. So they are stated here in full
* -- they are short -- along with the two or three places where tables
* differ, and which way this one goes.
*
* It is also the honest place for the license and for where the map came
* from, and for saying plainly whose game this is not.
*/
export function Landing({
color,
onPick,
onStart,
}: {
color: number
onPick: (c: number) => void
onStart: () => void
}) {
return (
<div className="landing">
<header>
<h1>World Conquest</h1>
<p className="tagline">
Forty-two territories, six armies, and the dice. You against five, until one of you holds
the world.
</p>
</header>
<div className="cols">
<section>
<h2>The whole game</h2>
<p>
The world is dealt out at random, seven territories each, and everybody puts down{' '}
{STARTING_ARMIES} armies on their own ground, a turn at a time. Then each turn goes the
same way:
</p>
<ol>
<li>
<strong>Reinforce.</strong> A third of your territories, never fewer than three,
plus a bonus for every whole continent you hold.
</li>
<li>
<strong>Attack</strong> as often as you like, from any territory with an army to
spare into any enemy territory next to it. Up to three dice attack and up to two
defend; highest against highest, and a tie goes to the defender. Empty a territory
and you march in.
</li>
<li>
<strong>Fortify</strong> once: move armies between two of your territories joined
by your own ground. Then the turn passes.
</li>
</ol>
<p>
Take at least one territory in a turn and you draw a card. Three alike, or one of
each, trade in for armies: 4, then 6, 8, 10, 12, 15, and five more every time after.
The count is shared by the whole table, so every set traded makes the next one
dearer for everybody. Hold five cards and you must trade. Finish a player off and
their cards are yours.
</p>
</section>
<section>
<h2>The continents</h2>
<ul className="continents">
{CONTINENT_IDS.map((c) => (
<li key={c}>
<span className="shore" style={{ borderColor: SHORE[c] }} />
{CONTINENTS[c].name} <span className="dim">+{CONTINENTS[c].bonus} a turn</span>
</li>
))}
</ul>
<p>
The shapes on the map are the real ones; the links are the classic board's. Dashed
lines cross the water where the rules do, and Alaska meets Kamchatka off the edge of
the world. The one border drawn as a fence, between Siberia and the steppe, is a place
the real map touches and the rules never let anybody across.
</p>
<h2>Where tables differ</h2>
<p>
Fortifying goes anywhere along a chain of your own territories, not only next door.
The defender always rolls every die they can. Sets escalate for the whole table rather
than staying fixed. A card showing a territory you hold puts two more armies there
when you trade it.
</p>
</section>
</div>
<div className="choose">
<h2>Take a color</h2>
<ul>
{Array.from({ length: PLAYER_COUNT }, (_, c) => (
<li key={c}>
<button type="button" className={c === color ? 'on' : ''} aria-pressed={c === color} onClick={() => onPick(c)}>
<span className="swatch" style={{ background: COLORS[c] }} />
{PLAYER_NAMES[c]}
</button>
</li>
))}
</ul>
<button type="button" className="start" onClick={onStart}>
Deal
</button>
</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://github.com/jcoffey-dev/world-conquest">Source</a>
</p>
<p>
World Conquest 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 coastlines and borders are from <a href="https://www.naturalearthdata.com/">Natural Earth</a>,
which is in the public domain. Which country belongs to which territory is this
project's own choice.
</p>
<p>
This is not the board game it is modeled on, and is not connected to the people who
publish it. No artwork, text or trademark of theirs is used here: every mark on the screen
and every note of the music is generated in code in this repository.
</p>
</footer>
</div>
)
}
+473
View File
@@ -0,0 +1,473 @@
import { useState } from 'react'
import { isSet, setsIn, type Card } from '../game/cards'
import { winChance } from '../game/dice'
import { PLAYER_NAMES, describe } from '../game/describe'
import type { Game } from '../game/game'
import { CONTINENTS, CONTINENT_IDS, TERRITORIES, inContinent, type Territory } from '../game/map'
import { COLORS, SHORE } from './Board'
/**
* Everything beside the map: whose turn it is and what they can do, the
* dice, your cards, the table and the log.
*
* The map is where you point; this is where you are told what pointing
* will do. Every control here is only ever offered when the rules would
* take it, which is why so much of it is conditional.
*/
interface Acts {
place: (t: Territory, n: number) => void
trade: (cards: [number, number, number]) => void
beginAttack: () => void
roll: (dice: number) => void
blitz: () => void
occupy: (n: number) => void
endAttack: () => void
fortify: (n: number) => void
endTurn: () => void
autoSetup: () => void
}
const Chip = ({ p }: { p: number }) => <span className="chip" style={{ background: COLORS[p] }} />
const name = (t: Territory) => TERRITORIES[t].name
/** A chance as a player would say it: never a flat 0% or 100% while the dice can still disagree. */
function percent(p: number) {
if (p >= 0.995) return 'better than 99%'
if (p > 0 && p < 0.005) return 'less than 1%'
return `${Math.round(p * 100)}%`
}
export function Side({
game: g,
human,
sel,
target,
autoSetup,
step,
onStep,
act,
}: {
game: Game
human: number
sel: Territory | null
target: Territory | null
autoSetup: boolean
step: 1 | 5 | 'all'
onStep: (s: 1 | 5 | 'all') => void
act: Acts
}) {
const mine = g.current === human && g.phase !== 'over'
const who = (p: number) => (p === human ? 'You' : PLAYER_NAMES[p])
return (
<aside className="side">
<section className="now">
<p className="turn">
<Chip p={g.current} />
{g.phase === 'over'
? `${who(g.winner!)} ${g.winner === human ? 'hold' : 'holds'} the world`
: g.phase === 'setup'
? 'Placing armies'
: `Round ${g.round} · ${mine ? 'your turn' : `${PLAYER_NAMES[g.current]}'s turn`}`}
</p>
{mine ? (
<Controls g={g} sel={sel} target={target} autoSetup={autoSetup} step={step} onStep={onStep} act={act} />
) : g.phase !== 'over' ? (
<p className="dim">{g.phase === 'setup' ? 'Everybody puts down their armies in turn.' : 'Watching.'}</p>
) : null}
</section>
<LastThrow g={g} human={human} />
<Hand g={g} human={human} act={act} />
<section>
<h2>The table</h2>
<table className="table">
<thead>
<tr>
<th />
<th>Lands</th>
<th>Armies</th>
<th>Cards</th>
<th>Pays</th>
</tr>
</thead>
<tbody>
{g.players.map((p) => (
<tr key={p.id} className={[p.alive ? '' : 'gone', p.id === g.current ? 'now' : ''].join(' ')}>
<th>
<Chip p={p.id} />
{p.id === human ? 'You' : PLAYER_NAMES[p.id]}
</th>
<td>{g.territoriesOf(p.id).length}</td>
<td>{g.armiesOf(p.id)}</td>
<td>{p.cards.length}</td>
<td>{p.alive ? g.income(p.id) : '-'}</td>
</tr>
))}
</tbody>
</table>
</section>
<section>
<h2>Continents</h2>
<ul className="continents">
{CONTINENT_IDS.map((c) => {
const land = inContinent(c)
const holder = g.owner[land[0]]
const whole = land.every((t) => g.owner[t] === holder)
return (
<li key={c}>
<span className="shore" style={{ borderColor: SHORE[c] }} />
{CONTINENTS[c].name} <span className="dim">+{CONTINENTS[c].bonus}</span>
{whole && (
<span className="held">
<Chip p={holder} />
{who(holder)}
</span>
)}
</li>
)
})}
</ul>
</section>
<Log g={g} human={human} />
</aside>
)
}
// ------------------------------------------------------------ controls
function Controls({
g,
sel,
target,
autoSetup,
step,
onStep,
act,
}: {
g: Game
sel: Territory | null
target: Territory | null
autoSetup: boolean
step: 1 | 5 | 'all'
onStep: (s: 1 | 5 | 'all') => void
act: Acts
}) {
switch (g.phase) {
case 'setup':
return (
<>
<p>
<strong>{g.toPlace[g.current]}</strong> armies to put down, one at a time, taking turns.
Click one of your territories.
</p>
{!autoSetup && (
<button type="button" onClick={act.autoSetup}>
Place the rest for me
</button>
)}
</>
)
case 'reinforce':
if (g.mustTrade)
return (
<p>
<strong>{g.hand.length} cards</strong> in hand: trade a set before anything else. Your cards
are below.
</p>
)
if (g.reinforcements > 0)
return (
<>
<p>
<strong>{g.reinforcements}</strong> to place. Click your territories.
{g.resume && ' Then back to the fighting.'}
</p>
<div className="row" role="group" aria-label="Armies per click">
{([1, 5, 'all'] as const).map((s) => (
<button key={s} type="button" aria-pressed={step === s} className="toggle" onClick={() => onStep(s)}>
{s === 'all' ? 'All' : `+${s}`}
</button>
))}
</div>
</>
)
return (
<>
<p>All placed. Trade another set now, or go to war.</p>
<button type="button" className="go" onClick={act.beginAttack}>
Attack
</button>
</>
)
case 'attack':
return <Attack g={g} sel={sel} target={target} act={act} />
case 'occupy':
return <Occupy key={g.events.length} g={g} act={act} />
case 'fortify':
return <Fortify key={`${sel}-${target}`} g={g} sel={sel} target={target} act={act} />
default:
return null
}
}
function Attack({ g, sel, target, act }: { g: Game; sel: Territory | null; target: Territory | null; act: Acts }) {
const most = sel ? Math.min(3, g.armies[sel] - 1) : 0
return (
<>
{!sel && <p>Pick one of your territories with an army to spare and an enemy next door.</p>}
{sel && !target && (
<p>
From <strong>{name(sel)}</strong> ({g.armies[sel]}). Now pick who to attack.
</p>
)}
{sel && target && (
<>
<p>
<strong>{name(sel)}</strong> ({g.armies[sel]}) against <strong>{name(target)}</strong> (
{g.armies[target]}, {PLAYER_NAMES[g.owner[target]]})
</p>
<p className="odds">Pressed to the end, {percent(winChance(g.armies[sel] - 1, g.armies[target]))} to take it.</p>
<div className="row">
{[3, 2, 1]
.filter((d) => d <= most)
.map((d) => (
<button key={d} type="button" onClick={() => act.roll(d)}>
Roll {d}
</button>
))}
<button type="button" className="go" onClick={act.blitz}>
Blitz
</button>
</div>
</>
)}
<button type="button" className="quiet" onClick={act.endAttack}>
Stop attacking
</button>
</>
)
}
function Occupy({ g, act }: { g: Game; act: Acts }) {
const o = g.occupation!
const [n, setN] = useState(o.max)
return (
<>
<p>
<strong>{name(o.to)}</strong> is yours. How many march in from {name(o.from)}?
</p>
{o.max > o.min && (
<input
type="range"
min={o.min}
max={o.max}
value={n}
onChange={(e) => setN(Number(e.target.value))}
aria-label="Armies to move in"
/>
)}
<div className="row">
<button type="button" className="go" onClick={() => act.occupy(n)}>
Move {n} in
</button>
{o.max > o.min && (
<span className="dim">
leaving {g.armies[o.from] - n} behind
</span>
)}
</div>
</>
)
}
function Fortify({ g, sel, target, act }: { g: Game; sel: Territory | null; target: Territory | null; act: Acts }) {
const max = sel ? g.armies[sel] - 1 : 0
const [n, setN] = useState(max)
return (
<>
<p>
{!sel
? 'One move to finish: pick a territory to move armies from, or end the turn.'
: !target
? `From ${name(sel)}. Pick where to, anywhere joined to it by your own territories.`
: `${name(sel)} to ${name(target)}.`}
</p>
{sel && target && (
<>
{max > 1 && (
<input
type="range"
min={1}
max={max}
value={n}
onChange={(e) => setN(Number(e.target.value))}
aria-label="Armies to move"
/>
)}
<button type="button" className="go" onClick={() => act.fortify(n)}>
Move {n} and end turn
</button>
</>
)}
<button type="button" className="quiet" onClick={act.endTurn}>
End turn
</button>
</>
)
}
// ------------------------------------------------------------ the dice
function Die({ face, side }: { face: number; side: 'attack' | 'defend' }) {
const pips: Record<number, [number, number][]> = {
1: [[2, 2]],
2: [[1, 1], [3, 3]],
3: [[1, 1], [2, 2], [3, 3]],
4: [[1, 1], [3, 1], [1, 3], [3, 3]],
5: [[1, 1], [3, 1], [2, 2], [1, 3], [3, 3]],
6: [[1, 1], [3, 1], [1, 2], [3, 2], [1, 3], [3, 3]],
}
return (
<svg className={`die ${side}`} viewBox="0 0 4 4" role="img" aria-label={`${face}`}>
<rect x={0.2} y={0.2} width={3.6} height={3.6} rx={0.7} />
{pips[face].map(([x, y], i) => (
<circle key={i} cx={x} cy={y} r={0.36} />
))}
</svg>
)
}
function LastThrow({ g, human }: { g: Game; human: number }) {
const last = g.events.findLast((e) => e.kind === 'battle')
if (!last || last.kind !== 'battle') return null
const { roll } = last
const who = (p: number) => (p === human ? 'You' : PLAYER_NAMES[p])
return (
<section className="throw" aria-live="polite">
<p className="dim">
{who(last.player)}, {name(last.from)} {name(last.to)}
</p>
<div className="dice">
<div>
{roll.attack.map((f, i) => (
<Die key={i} face={f} side="attack" />
))}
</div>
<span className="vs">vs</span>
<div>
{roll.defend.map((f, i) => (
<Die key={i} face={f} side="defend" />
))}
</div>
</div>
<p className="dim">
Attacker lost {roll.attackerLoss}, defender lost {roll.defenderLoss}.
</p>
</section>
)
}
// ------------------------------------------------------------ the cards
const MARK_GLYPH: Record<Card['mark'], string> = {
infantry: '▲',
cavalry: '●',
artillery: '■',
wild: '★',
}
function Hand({ g, human, act }: { g: Game; human: number; act: Acts }) {
const hand = g.players[human].cards
const [chosen, setChosen] = useState<number[]>([])
const canTrade = g.current === human && g.phase === 'reinforce'
const picked = chosen.filter((i) => i < hand.length)
const cards = picked.map((i) => hand[i])
const ready = picked.length === 3 && isSet(cards)
const sets = setsIn(hand)
const toggle = (i: number) =>
setChosen((c) => (c.includes(i) ? c.filter((x) => x !== i) : c.length < 3 ? [...c, i] : c))
return (
<section>
<h2>
Your cards <span className="dim">· next set pays {g.nextSetValue}</span>
</h2>
{hand.length === 0 ? (
<p className="dim">None yet. Take a territory in a turn and you draw one at the end of it.</p>
) : (
<ul className="cards">
{hand.map((c, i) => (
<li key={i}>
<button
type="button"
className={`card ${c.mark}`}
aria-pressed={picked.includes(i)}
disabled={!canTrade}
onClick={() => toggle(i)}
>
<span className="glyph">{MARK_GLYPH[c.mark]}</span>
<span>{c.territory ? name(c.territory) : 'Wild'}</span>
{c.territory && g.owner[c.territory] === human && <span className="own">yours</span>}
</button>
</li>
))}
</ul>
)}
{canTrade && sets.length > 0 && (
<div className="row">
<button
type="button"
className={ready ? 'go' : ''}
disabled={!ready}
onClick={() => {
act.trade(picked as [number, number, number])
setChosen([])
}}
>
Trade for {g.nextSetValue}
</button>
{!ready && (
<button type="button" onClick={() => setChosen(sets[0])}>
Pick a set
</button>
)}
</div>
)}
{hand.length > 0 && (
<p className="dim small">Three alike, one of each, or any two with a wild.</p>
)}
</section>
)
}
// -------------------------------------------------------------- the log
function Log({ g, human }: { g: Game; human: number }) {
const lines = describe(g.events, human).slice(-60).reverse()
return (
<section className="log">
<h2>What happened</h2>
<ol>
{lines.map((l, i) => (
<li key={lines.length - i} className={l.heading ? 'heading' : ''}>
<span className="chip" style={{ background: COLORS[l.player] }} />
{l.text}
</li>
))}
</ol>
</section>
)
}
File diff suppressed because one or more lines are too long
+268
View File
@@ -0,0 +1,268 @@
import { setsIn, type Card } from './cards'
import { winChance } from './dice'
import type { Game } from './game'
import {
ADJACENT,
BORDERS,
CONTINENTS,
CONTINENT_IDS,
TERRITORIES,
inContinent,
type Continent,
type Territory,
} from './map'
import { makeRng } from './rng'
/**
* The five opponents.
*
* A bot keeps no plan between moves. Every time it is asked, it looks at the
* board and answers with the one thing it would do next -- trade, place,
* throw the dice, march in, move up, or stop -- so it can never be caught
* holding a plan the dice have already made nonsense of, and a game can be
* stopped and resumed anywhere without anybody's intentions going stale.
*
* What it wants comes down to three things, in this order: a continent it
* can hold, a card for the turn, and nobody else holding a continent in
* peace. Every attack is priced as what winning is worth times the exact
* chance of winning it, from `dice.ts` -- not a rule of thumb about armies.
* A player watching one of these turn on them is owed a reason, and "it had
* a 71% chance at Brazil and Brazil finished South America" is one.
*/
export type Action =
| { type: 'place'; territory: Territory; count: number }
| { type: 'trade'; cards: [number, number, number] }
| { type: 'beginAttack' }
| { type: 'attack'; from: Territory; to: Territory }
| { type: 'occupy'; count: number }
| { type: 'endAttack' }
| { type: 'fortify'; from: Territory; to: Territory; count: number }
| { type: 'endTurn' }
export interface Leaning {
/** 0 is careful, 1 is reckless. Moves the odds a bot will fight at. */
nerve: number
/** A small private taste for some continents over others. */
taste: Record<Continent, number>
}
/**
* Six minds reasoning identically play the same game every time. Each one
* leans a little -- braver or more careful, fonder of one continent -- and
* the lean comes from the seed, so a seed still replays a game exactly.
*/
export function leanings(seed: number, players: number): Leaning[] {
return Array.from({ length: players }, (_, id) => {
const rng = makeRng(seed * 31 + id * 7919 + 1)
return {
nerve: rng(),
taste: Object.fromEntries(CONTINENT_IDS.map((c) => [c, 0.85 + rng() * 0.3])) as Record<
Continent,
number
>,
}
})
}
// ------------------------------------------------------------ appraisal
/** How hard each continent would be to take from here, and what it pays. */
export function goal(g: Game, p: number, lean: Leaning): Continent | null {
let best: Continent | null = null
let bestScore = -Infinity
for (const c of CONTINENT_IDS) {
const land = inContinent(c)
const mine = land.filter((t) => g.owner[t] === p)
if (mine.length === land.length) continue
if (mine.length === 0 && !land.some((t) => ADJACENT[t].some((n) => g.owner[n] === p))) continue
const theirs = land.filter((t) => g.owner[t] !== p).reduce((n, t) => n + g.armies[t], 0)
const ours = mine.reduce((n, t) => n + g.armies[t], 0)
const held = mine.length / land.length
// What it pays, made cheaper by what is already there and dearer by the
// opposition and by every door that will need watching afterwards.
const score =
(CONTINENTS[c].bonus * (1 + held) ** 2 * lean.taste[c] * (1 + ours / 10)) /
((theirs + 1) * Math.sqrt(BORDERS[c].length))
if (score > bestScore) {
bestScore = score
best = c
}
}
return best
}
const enemies = (g: Game, t: Territory) => ADJACENT[t].filter((n) => g.owner[n] !== g.owner[t])
const threat = (g: Game, t: Territory) => enemies(g, t).reduce((n, e) => n + g.armies[e], 0)
/** What taking `t` would be worth to `p`, before the odds. */
export function worth(g: Game, p: number, t: Territory, target: Continent | null): number {
const c = TERRITORIES[t].continent
const holder = g.owner[t]
const land = inContinent(c)
let v = 1
if (c === target) v += 3
// The last one in a continent pays out every turn.
if (land.every((x) => x === t || g.owner[x] === p)) v += CONTINENTS[c].bonus * 2
// Somebody else's continent stops paying the moment one territory goes.
if (land.every((x) => g.owner[x] === holder)) v += CONTINENTS[c].bonus * 1.5
// Finishing a player off hands over their cards.
const left = g.territoriesOf(holder).length
if (left <= 2) v += 2 + g.players[holder].cards.length * 2
// A territory taken this turn is a card at the end of it; the first one
// is worth more than any after.
if (!g.conquered) v += 2
return v
}
interface Plan {
from: Territory
to: Territory
odds: number
value: number
}
function bestAttack(g: Game, p: number, lean: Leaning): Plan | null {
const target = goal(g, p, lean)
// The odds a bot will fight at: between about 55% and 75%, by nerve. For
// the first territory of the turn it will go lower, because the card is
// on the line and a card is armies later.
const floor = 0.75 - lean.nerve * 0.2
let best: Plan | null = null
for (const from of g.territoriesOf(p)) {
if (g.armies[from] < 2) continue
for (const to of enemies(g, from)) {
const odds = winChance(g.armies[from] - 1, g.armies[to])
const needed = g.conquered ? floor : floor - 0.1
if (odds < needed) continue
const value = worth(g, p, to, target) * odds
if (!best || value > best.value) best = { from, to, odds, value }
}
}
return best
}
/** Where the next army does most good: somewhere to attack from, or a door to hold. */
function stagingPoint(g: Game, p: number, lean: Leaning): Territory {
const target = goal(g, p, lean)
const held = g.continentsOf(p)
let best: Territory | null = null
let bestScore = -Infinity
for (const t of g.territoriesOf(p)) {
const foes = enemies(g, t)
if (foes.length === 0) continue
let score = 0
// Attack: the best thing next door, cheapest to take.
for (const n of foes) {
const s = worth(g, p, n, target) / (1 + g.armies[n])
if (s > score) score = s
}
// Defence: a door of a continent we hold, outnumbered.
const door = held.some((c) => BORDERS[c].includes(t))
if (door) score += Math.max(0, threat(g, t) - g.armies[t]) * 0.4 + 1
// Pile up rather than spread thin: a stack attacks, a scatter does not.
score += Math.min(g.armies[t], 12) * 0.05
if (score > bestScore) {
bestScore = score
best = t
}
}
return best ?? g.territoriesOf(p)[0]
}
/** The set that pays most: one showing a territory we hold, and wild cards kept if possible. */
function bestSet(g: Game, p: number, hand: readonly Card[]): [number, number, number] | null {
const sets = setsIn(hand)
if (sets.length === 0) return null
const score = (s: [number, number, number]) =>
s.reduce((n, i) => {
const c = hand[i]
if (c.mark === 'wild') return n - 1
return n + (c.territory && g.owner[c.territory] === p ? 2 : 0)
}, 0)
return sets.reduce((a, b) => (score(b) > score(a) ? b : a))
}
// ------------------------------------------------------------- deciding
export function decide(g: Game, lean: Leaning): Action {
const p = g.current
switch (g.phase) {
case 'setup':
return { type: 'place', territory: stagingPoint(g, p, lean), count: 1 }
case 'reinforce': {
// Trading now rather than saving up: the value rises for everybody
// whether or not we wait, and armies now are armies on the board.
const set = bestSet(g, p, g.hand)
if (set && (g.mustTrade || g.reinforcements > 0 || g.resume)) return { type: 'trade', cards: set }
if (g.reinforcements > 0) {
// Most on the best point, and the rest reconsidered, so a big
// income can cover a threatened door as well as a push.
const count = g.reinforcements <= 3 ? g.reinforcements : Math.ceil(g.reinforcements * 0.7)
return { type: 'place', territory: stagingPoint(g, p, lean), count }
}
return { type: 'beginAttack' }
}
case 'attack': {
const plan = bestAttack(g, p, lean)
return plan ? { type: 'attack', from: plan.from, to: plan.to } : { type: 'endAttack' }
}
case 'occupy': {
const o = g.occupation!
const behind = threat(g, o.from)
const ahead = threat(g, o.to)
let count: number
if (behind === 0) count = o.max
else if (ahead === 0) count = o.min
else count = Math.round(o.max * (ahead / (ahead + behind * 0.6)))
return { type: 'occupy', count: Math.max(o.min, Math.min(o.max, count)) }
}
case 'fortify': {
// The biggest idle stack -- one with no enemy beside it -- walks to the
// front that needs it most.
const idle = g
.territoriesOf(p)
.filter((t) => g.armies[t] > 1 && enemies(g, t).length === 0)
.sort((a, b) => g.armies[b] - g.armies[a])
for (const from of idle) {
const fronts = g.connected(from).filter((t) => enemies(g, t).length > 0)
if (fronts.length === 0) continue
const to = fronts.reduce((a, b) =>
threat(g, b) - g.armies[b] > threat(g, a) - g.armies[a] ? b : a,
)
return { type: 'fortify', from, to, count: g.armies[from] - 1 }
}
return { type: 'endTurn' }
}
case 'over':
throw new Error('the game is over')
}
}
export function apply(g: Game, a: Action) {
switch (a.type) {
case 'place':
return g.place(a.territory, a.count)
case 'trade':
return g.trade(a.cards)
case 'beginAttack':
return g.beginAttack()
case 'attack':
return g.attack(a.from, a.to)
case 'occupy':
return g.occupy(a.count)
case 'endAttack':
return g.endAttack()
case 'fortify':
return g.fortify(a.from, a.to, a.count)
case 'endTurn':
return g.endTurn()
}
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { fullDeck, isSet, setValue, setsIn, type Card } from './cards'
const c = (mark: Card['mark']): Card => ({ territory: null, mark })
describe('the deck', () => {
it('is forty-two territories, fourteen of each mark, and two wild', () => {
const deck = fullDeck()
expect(deck).toHaveLength(44)
for (const m of ['infantry', 'cavalry', 'artillery'] as const)
expect(deck.filter((x) => x.mark === m)).toHaveLength(14)
expect(deck.filter((x) => x.mark === 'wild')).toHaveLength(2)
})
})
describe('sets', () => {
it('are three alike, one of each, or anything with a wild', () => {
expect(isSet([c('infantry'), c('infantry'), c('infantry')])).toBe(true)
expect(isSet([c('infantry'), c('cavalry'), c('artillery')])).toBe(true)
expect(isSet([c('infantry'), c('infantry'), c('wild')])).toBe(true)
expect(isSet([c('infantry'), c('infantry'), c('cavalry')])).toBe(false)
})
it('five cards always hold one', () => {
const marks = ['infantry', 'cavalry', 'artillery'] as const
for (let a = 0; a < 3; a++)
for (let b = 0; b < 3; b++)
for (let d = 0; d < 3; d++)
for (let e = 0; e < 3; e++)
for (let f = 0; f < 3; f++)
expect(setsIn([a, b, d, e, f].map((i) => c(marks[i]))).length).toBeGreaterThan(0)
})
it('are worth more each time anybody trades one', () => {
expect([0, 1, 2, 3, 4, 5, 6, 7, 8].map(setValue)).toEqual([4, 6, 8, 10, 12, 15, 20, 25, 30])
})
})
+56
View File
@@ -0,0 +1,56 @@
import { TERRITORY_IDS, type Territory } from './map'
/**
* The deck: one card per territory, each carrying one of three marks, and two
* wild cards that stand for any of them.
*
* The marks are ours. The published game draws a soldier, a horseman and a
* cannon; what matters to the rules is only that there are three kinds,
* fourteen of each, so they are named for what they are and drawn as plain
* shapes.
*/
export type Mark = 'infantry' | 'cavalry' | 'artillery'
export interface Card {
/** Null for a wild card. */
territory: Territory | null
mark: Mark | 'wild'
}
const MARKS: Mark[] = ['infantry', 'cavalry', 'artillery']
export function fullDeck(): Card[] {
const cards: Card[] = TERRITORY_IDS.map((territory, i) => ({ territory, mark: MARKS[i % 3] }))
cards.push({ territory: null, mark: 'wild' }, { territory: null, mark: 'wild' })
return cards
}
/** Three of a kind, one of each, or anything with a wild card in it. */
export function isSet(cards: readonly Card[]): boolean {
if (cards.length !== 3) return false
if (cards.some((c) => c.mark === 'wild')) return true
const marks = new Set(cards.map((c) => c.mark))
return marks.size === 1 || marks.size === 3
}
/**
* What the nth set traded in by anybody is worth: 4, 6, 8, 10, 12, 15, and
* five more each time after that. The count is shared by the whole table,
* which is why the value of holding cards rises while nobody else trades.
*/
export function setValue(tradesSoFar: number): number {
const early = [4, 6, 8, 10, 12, 15]
if (tradesSoFar < early.length) return early[tradesSoFar]
return 15 + 5 * (tradesSoFar - early.length + 1)
}
/** Every set that can be made from a hand, as index triples. */
export function setsIn(hand: readonly Card[]): [number, number, number][] {
const out: [number, number, number][] = []
for (let i = 0; i < hand.length; i++)
for (let j = i + 1; j < hand.length; j++)
for (let k = j + 1; k < hand.length; k++)
if (isSet([hand[i], hand[j], hand[k]])) out.push([i, j, k])
return out
}
+32
View File
@@ -0,0 +1,32 @@
import { describe as suite, expect, it } from 'vitest'
import { describe } from './describe'
import type { GameEvent } from './game'
const throwOf = (attackerLoss: number, defenderLoss: number): GameEvent => ({
kind: 'battle',
player: 1,
from: 'peru',
to: 'brazil',
defender: 0,
roll: { attack: [], defend: [], attackerLoss, defenderLoss },
})
suite('the log', () => {
it('folds a run of throws into one line', () => {
const lines = describe([throwOf(1, 1), throwOf(2, 0), { kind: 'card', player: 1 }], 0)
expect(lines.map((l) => l.text)).toEqual(['Cobalt attacked Brazil from Peru: lost 3, killed 1.'])
})
it('lets a conquest speak for the fight that won it', () => {
const lines = describe(
[throwOf(0, 2), throwOf(1, 1), { kind: 'conquer', player: 1, from: 'peru', to: 'brazil', loser: 0 }],
0,
)
expect(lines.map((l) => l.text)).toEqual(['Cobalt took Brazil from you (lost 1, killed 3).'])
})
it('speaks to the player in the second person', () => {
const lines = describe([{ kind: 'eliminate', player: 3, loser: 0, cards: 2 }], 0)
expect(lines[0].text).toBe('You are out, finished by Jade, who takes 2 cards.')
})
})
+106
View File
@@ -0,0 +1,106 @@
import type { GameEvent } from './game'
import { TERRITORIES, type Territory } from './map'
/**
* The log, in words.
*
* A game of this throws the dice thousands of times and nobody wants to read
* a line for each throw. So consecutive throws between the same two
* territories fold into one line saying how the fight went, and the log
* reads like an account of the war rather than a transcript of the dice.
*/
export const PLAYER_NAMES = ['Crimson', 'Cobalt', 'Saffron', 'Jade', 'Violet', 'Ash'] as const
export interface Line {
/** Whose line it is, for the color chip. */
player: number
text: string
/** A new turn starts a new paragraph. */
heading?: boolean
}
const name = (t: Territory) => TERRITORIES[t].name
export function describe(events: readonly GameEvent[], human: number | null): Line[] {
const who = (p: number) => (p === human ? 'You' : PLAYER_NAMES[p])
const whom = (p: number) => (p === human ? 'you' : PLAYER_NAMES[p])
const lines: Line[] = []
let fight: { player: number; from: Territory; to: Territory; lost: number; killed: number; defender: number } | null =
null
const closeFight = (taken: boolean) => {
if (!fight) return
const f = fight
fight = null
if (taken) return // the conquest line says it better
lines.push({
player: f.player,
text: `${who(f.player)} attacked ${name(f.to)} from ${name(f.from)}: lost ${f.lost}, killed ${f.killed}.`,
})
}
for (const e of events) {
if (e.kind !== 'battle' && e.kind !== 'conquer') closeFight(false)
switch (e.kind) {
case 'turn':
lines.push({
player: e.player,
heading: true,
text: `Round ${e.round}: ${who(e.player)} ${e.player === human ? 'have' : 'has'} ${
e.reinforcements
} to place.`,
})
break
case 'battle':
if (fight && (fight.from !== e.from || fight.to !== e.to)) closeFight(false)
fight ??= { player: e.player, from: e.from, to: e.to, lost: 0, killed: 0, defender: e.defender }
fight.lost += e.roll.attackerLoss
fight.killed += e.roll.defenderLoss
break
case 'conquer': {
const f = fight
closeFight(true)
const cost = f ? ` (lost ${f.lost}, killed ${f.killed})` : ''
lines.push({
player: e.player,
text: `${who(e.player)} took ${name(e.to)} from ${whom(e.loser)}${cost}.`,
})
break
}
case 'trade':
lines.push({
player: e.player,
text: `${who(e.player)} traded a set for ${e.armies}${
e.bonus.length ? `, and 2 more on ${name(e.bonus[0])}` : ''
}.`,
})
break
case 'eliminate':
lines.push({
player: e.player,
text: `${who(e.loser)} ${e.loser === human ? 'are' : 'is'} out, finished by ${whom(e.player)}${
e.cards ? `, who takes ${e.cards} card${e.cards === 1 ? '' : 's'}` : ''
}.`,
})
break
case 'fortify':
lines.push({
player: e.player,
text: `${who(e.player)} moved ${e.count} from ${name(e.from)} to ${name(e.to)}.`,
})
break
case 'win':
lines.push({
player: e.player,
heading: true,
text: `${who(e.player)} ${e.player === human ? 'hold' : 'holds'} the world.`,
})
break
default:
break
}
}
closeFight(false)
return lines
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { OUTCOMES, compare, roll, winChance } from './dice'
import { makeRng } from './rng'
describe('one throw', () => {
it('matches highest against highest, and a tie goes to the defender', () => {
expect(compare([6, 1, 3], [6, 2])).toMatchObject({ attackerLoss: 1, defenderLoss: 1 })
expect(compare([5, 5, 5], [4, 4])).toMatchObject({ attackerLoss: 0, defenderLoss: 2 })
expect(compare([2], [2, 1])).toMatchObject({ attackerLoss: 1, defenderLoss: 0 })
})
it('rolls the dice it was asked for', () => {
const r = roll(makeRng(1), 3, 2)
expect(r.attack).toHaveLength(3)
expect(r.defend).toHaveLength(2)
expect(r.attackerLoss + r.defenderLoss).toBe(2)
})
})
describe('the odds', () => {
// The figures everybody quotes, to four places.
it('agrees with the published exchange tables', () => {
const three = OUTCOMES['3v2']
expect(three.get(0)).toBeCloseTo(2890 / 7776, 10)
expect(three.get(1)).toBeCloseTo(2611 / 7776, 10)
expect(three.get(2)).toBeCloseTo(2275 / 7776, 10)
expect(OUTCOMES['1v1'].get(0)).toBeCloseTo(15 / 36, 10)
expect(OUTCOMES['3v1'].get(0)).toBeCloseTo(855 / 1296, 10)
expect(OUTCOMES['2v2'].get(0)).toBeCloseTo(295 / 1296, 10)
})
it('knows what a whole battle is worth', () => {
expect(winChance(1, 1)).toBeCloseTo(0.4167, 3)
expect(winChance(3, 2)).toBeCloseTo(0.6560, 3)
// Two against one: win the first exchange, or lose it and win one-on-one.
expect(winChance(2, 1)).toBeCloseTo(125 / 216 + (91 / 216) * (15 / 36), 12)
expect(winChance(10, 10)).toBeGreaterThan(0.5)
expect(winChance(5, 0)).toBe(1)
expect(winChance(0, 3)).toBe(0)
})
it('agrees with the dice when they are actually thrown', () => {
const rng = makeRng(99)
let wins = 0
const games = 20000
for (let i = 0; i < games; i++) {
let a = 5
let d = 4
while (a > 0 && d > 0) {
const r = roll(rng, Math.min(3, a), Math.min(2, d))
a -= r.attackerLoss
d -= r.defenderLoss
}
if (d === 0) wins++
}
expect(wins / games).toBeCloseTo(winChance(5, 4), 1)
})
})
+99
View File
@@ -0,0 +1,99 @@
import type { Rng } from './rng'
/**
* The dice, and the arithmetic of them.
*
* An attacker rolls up to three, never more than the armies it has beyond
* the one that must stay home. A defender rolls up to two, never more than
* it has. Highest against highest, second against second; a tie goes to the
* defender, and each comparison costs the loser one army.
*
* The odds are worked out here rather than looked up. A table copied from
* somewhere is a table nobody can check; one computed from thirty-six and
* two hundred and sixteen and seven thousand seven hundred and seventy-six
* equally likely throws is its own proof, and the tests hold it to the
* figures every Risk player's cousin has quoted at them.
*/
export interface Roll {
attack: number[]
defend: number[]
attackerLoss: number
defenderLoss: number
}
const die = (rng: Rng) => 1 + Math.floor(rng() * 6)
export function compare(attack: number[], defend: number[]) {
const a = [...attack].sort((x, y) => y - x)
const d = [...defend].sort((x, y) => y - x)
let attackerLoss = 0
let defenderLoss = 0
for (let i = 0; i < Math.min(a.length, d.length); i++) {
if (a[i] > d[i]) defenderLoss++
else attackerLoss++
}
return { attack: a, defend: d, attackerLoss, defenderLoss }
}
export function roll(rng: Rng, attackDice: number, defendDice: number): Roll {
const attack = Array.from({ length: attackDice }, () => die(rng))
const defend = Array.from({ length: defendDice }, () => die(rng))
return compare(attack, defend)
}
/** Every throw of `n` dice, each equally likely. */
function throws(n: number): number[][] {
if (n === 0) return [[]]
const out: number[][] = []
for (const rest of throws(n - 1)) for (let f = 1; f <= 6; f++) out.push([f, ...rest])
return out
}
/**
* For one exchange of `a` attacking and `d` defending dice: the chance of
* each outcome, keyed by how many armies the attacker loses.
*/
export const OUTCOMES: Record<string, Map<number, number>> = (() => {
const table: Record<string, Map<number, number>> = {}
for (let a = 1; a <= 3; a++) {
for (let d = 1; d <= 2; d++) {
const counts = new Map<number, number>()
let total = 0
for (const at of throws(a)) {
for (const de of throws(d)) {
const { attackerLoss } = compare(at, de)
counts.set(attackerLoss, (counts.get(attackerLoss) ?? 0) + 1)
total++
}
}
for (const [k, v] of counts) counts.set(k, v / total)
table[`${a}v${d}`] = counts
}
}
return table
})()
const memo = new Map<string, number>()
/**
* The chance that `attackers` armies -- the ones free to attack, not counting
* the one left behind -- take a territory held by `defenders`, if the attack
* is pressed with every die available until one side is gone.
*/
export function winChance(attackers: number, defenders: number): number {
if (defenders <= 0) return 1
if (attackers <= 0) return 0
const key = `${attackers},${defenders}`
const hit = memo.get(key)
if (hit !== undefined) return hit
const a = Math.min(3, attackers)
const d = Math.min(2, defenders)
const fights = Math.min(a, d)
let p = 0
for (const [attackerLoss, chance] of OUTCOMES[`${a}v${d}`]) {
p += chance * winChance(attackers - attackerLoss, defenders - (fights - attackerLoss))
}
memo.set(key, p)
return p
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest'
import { Game, STARTING_ARMIES } from './game'
import { TERRITORY_IDS, type Territory } from './map'
/** Place every setup army wherever it is legal, so a test can start at turn one. */
function pastSetup(g: Game) {
while (g.phase === 'setup') g.place(g.territoriesOf(g.current)[0])
}
describe('the deal', () => {
it('shares the world out seven each, with twenty armies apiece', () => {
const g = new Game(1)
for (const p of g.players) expect(g.territoriesOf(p.id)).toHaveLength(7)
pastSetup(g)
for (const p of g.players) expect(g.armiesOf(p.id)).toBe(STARTING_ARMIES)
expect(g.phase).toBe('reinforce')
expect(g.current).toBe(g.first)
expect(g.round).toBe(1)
})
it('is the same deal for the same seed', () => {
const a = new Game(42)
const b = new Game(42)
expect(TERRITORY_IDS.map((t) => a.owner[t])).toEqual(TERRITORY_IDS.map((t) => b.owner[t]))
})
})
describe('reinforcements', () => {
it('are a third of your territories, at least three, plus whole continents', () => {
const g = new Game(1)
const p = 0
for (const t of TERRITORY_IDS) g.owner[t] = 1
expect(g.income(p)).toBe(3)
for (const t of ['indonesia', 'new-guinea', 'western-australia', 'eastern-australia'] as Territory[])
g.owner[t] = p
expect(g.income(p)).toBe(3 + 2)
for (const t of TERRITORY_IDS.slice(0, 15)) g.owner[t] = p
// 19 territories, North and South America and Australia.
expect(g.territoriesOf(p)).toHaveLength(19)
expect(g.income(p)).toBe(6 + 5 + 2 + 2)
})
})
describe('a battle', () => {
function staged(attackers: number, defenders: number) {
const g = new Game(3)
pastSetup(g)
for (const t of TERRITORY_IDS) {
g.owner[t] = 1
g.armies[t] = 1
}
g.current = 0
g.owner.brazil = 0
g.armies.brazil = attackers
g.armies['north-africa'] = defenders
g.reinforcements = 0
g.beginAttack()
return g
}
it('refuses what the rules refuse', () => {
const g = staged(5, 2)
expect(() => g.attack('brazil', 'china')).toThrow(/border/)
expect(() => g.attack('peru', 'brazil')).toThrow(/not yours/)
g.armies.brazil = 1
expect(() => g.attack('brazil', 'north-africa')).toThrow(/spare/)
})
it('takes a territory, marches in, and earns a card', () => {
const g = staged(40, 1)
while (g.phase === 'attack') g.attack('brazil', 'north-africa')
expect(g.phase).toBe('occupy')
expect(g.owner['north-africa']).toBe(0)
const o = g.occupation!
expect(o.min).toBe(3)
expect(() => g.occupy(o.min - 1)).toThrow()
g.occupy(o.max)
expect(g.armies.brazil).toBe(1)
g.endAttack()
g.endTurn()
expect(g.players[0].cards).toHaveLength(1)
})
it('hands over the loser\'s cards and forces a trade at six', () => {
const g = staged(40, 1)
for (const t of TERRITORY_IDS) if (t !== 'north-africa' && t !== 'brazil') g.owner[t] = 2
g.players[0].cards = [
{ territory: 'china', mark: 'infantry' },
{ territory: 'peru', mark: 'cavalry' },
{ territory: 'egypt', mark: 'infantry' },
]
g.players[1].cards = [
{ territory: 'japan', mark: 'cavalry' },
{ territory: 'siam', mark: 'cavalry' },
{ territory: 'ural', mark: 'artillery' },
]
while (g.phase === 'attack') g.attack('brazil', 'north-africa')
expect(g.players[1].alive).toBe(false)
g.occupy(3)
expect(g.phase).toBe('reinforce')
expect(g.mustTrade).toBe(true)
expect(() => g.beginAttack()).toThrow(/trade/)
g.trade([0, 1, 5])
expect(g.hand).toHaveLength(3)
g.place('brazil', g.reinforcements)
g.beginAttack()
expect(g.phase).toBe('attack')
})
})
describe('fortifying', () => {
it('moves once, along a chain of your own, and ends the turn', () => {
const g = new Game(5)
pastSetup(g)
for (const t of TERRITORY_IDS) g.owner[t] = 1
const me = g.current
for (const t of ['alaska', 'alberta', 'western-us'] as Territory[]) g.owner[t] = me
g.armies.alaska = 6
g.place('alaska', g.reinforcements)
g.beginAttack()
g.endAttack()
expect(g.fortifyTargets('alaska').sort()).toEqual(['alberta', 'western-us'])
expect(() => g.fortify('alaska', 'ontario', 1)).toThrow()
const before = g.current
g.fortify('alaska', 'western-us', 3)
expect(g.current).not.toBe(before)
})
})
+370
View File
@@ -0,0 +1,370 @@
import { fullDeck, isSet, setValue, type Card } from './cards'
import { roll, type Roll } from './dice'
import {
ADJACENT,
CONTINENTS,
CONTINENT_IDS,
TERRITORY_IDS,
adjacent,
inContinent,
type Continent,
type Territory,
} from './map'
import { makeRng, shuffle, type Rng } from './rng'
/**
* The game, with no interface attached.
*
* Every rule lives here and nowhere else, and every move is checked here
* whether a person or a bot is making it -- so the board can only ever offer
* what this will accept, and a whole game can be played out in a test from
* the deal to the last territory.
*
* It is a plain mutable object rather than a reducer. A game of this runs to
* thousands of dice rolls, and copying forty-two territories on every one of
* them buys nothing the event log does not already give: everything that has
* ever happened is in `events`, in order, and the interface reads that.
*/
export const PLAYER_COUNT = 6
/** The classic allowance for a six-player table. */
export const STARTING_ARMIES = 20
export type Phase = 'setup' | 'reinforce' | 'attack' | 'occupy' | 'fortify' | 'over'
export interface Player {
id: number
human: boolean
alive: boolean
cards: Card[]
}
export type GameEvent =
| { kind: 'deal'; first: number }
| { kind: 'turn'; player: number; round: number; reinforcements: number }
| { kind: 'place'; player: number; territory: Territory; count: number }
| { kind: 'trade'; player: number; cards: Card[]; armies: number; bonus: Territory[] }
| { kind: 'battle'; player: number; from: Territory; to: Territory; defender: number; roll: Roll }
| { kind: 'conquer'; player: number; from: Territory; to: Territory; loser: number }
| { kind: 'occupy'; player: number; from: Territory; to: Territory; count: number }
| { kind: 'eliminate'; player: number; loser: number; cards: number }
| { kind: 'fortify'; player: number; from: Territory; to: Territory; count: number }
| { kind: 'card'; player: number }
| { kind: 'win'; player: number }
export interface Occupation {
from: Territory
to: Territory
min: number
max: number
}
export class Game {
readonly seed: number
readonly players: Player[]
readonly owner = {} as Record<Territory, number>
readonly armies = {} as Record<Territory, number>
readonly events: GameEvent[] = []
phase: Phase = 'setup'
current: number
first: number
round = 0
/** Armies waiting to be placed, in setup or at the start of a turn. */
reinforcements = 0
/** In setup, what each player has still to place. */
readonly toPlace: number[]
/** Sets traded by anybody, which is what prices the next one. */
trades = 0
conquered = false
occupation: Occupation | null = null
winner: number | null = null
/**
* Set when an elimination handed over enough cards to force a trade in the
* middle of an attack: the trade, and placing what it pays, then back to
* attacking.
*/
resume: 'attack' | null = null
private deck: Card[]
private discard: Card[] = []
private readonly rng: Rng
constructor(seed: number, human: number | null = 0) {
this.seed = seed
this.rng = makeRng(seed)
this.players = Array.from({ length: PLAYER_COUNT }, (_, id) => ({
id,
human: id === human,
alive: true,
cards: [],
}))
// Deal the territories round the table, one army on each.
this.first = Math.floor(this.rng() * PLAYER_COUNT)
const dealt = shuffle(this.rng, TERRITORY_IDS)
dealt.forEach((t, i) => {
this.owner[t] = (this.first + i) % PLAYER_COUNT
this.armies[t] = 1
})
this.toPlace = this.players.map((p) => STARTING_ARMIES - this.territoriesOf(p.id).length)
this.current = this.first
this.deck = shuffle(this.rng, fullDeck())
this.events.push({ kind: 'deal', first: this.first })
}
// ------------------------------------------------------------- queries
territoriesOf(player: number): Territory[] {
return TERRITORY_IDS.filter((t) => this.owner[t] === player)
}
armiesOf(player: number): number {
return this.territoriesOf(player).reduce((n, t) => n + this.armies[t], 0)
}
continentsOf(player: number): Continent[] {
return CONTINENT_IDS.filter((c) => inContinent(c).every((t) => this.owner[t] === player))
}
/** A third of your territories, never less than three, and your continents. */
income(player: number): number {
const base = Math.max(3, Math.floor(this.territoriesOf(player).length / 3))
return base + this.continentsOf(player).reduce((n, c) => n + CONTINENTS[c].bonus, 0)
}
get alive(): Player[] {
return this.players.filter((p) => p.alive)
}
get hand(): Card[] {
return this.players[this.current].cards
}
/** Five cards in hand at the start of a turn, or after taking a loser's, and you must trade. */
get mustTrade(): boolean {
return this.phase === 'reinforce' && this.hand.length >= 5
}
get nextSetValue(): number {
return setValue(this.trades)
}
canAttackFrom(t: Territory): boolean {
return (
this.phase === 'attack' &&
this.owner[t] === this.current &&
this.armies[t] >= 2 &&
ADJACENT[t].some((n) => this.owner[n] !== this.current)
)
}
attackTargets(t: Territory): Territory[] {
if (!this.canAttackFrom(t)) return []
return ADJACENT[t].filter((n) => this.owner[n] !== this.current)
}
/** Everywhere reachable from `t` through territories of the same owner. */
connected(t: Territory): Territory[] {
const who = this.owner[t]
const seen = new Set<Territory>([t])
const queue = [t]
while (queue.length) {
const at = queue.shift()!
for (const n of ADJACENT[at]) {
if (this.owner[n] === who && !seen.has(n)) {
seen.add(n)
queue.push(n)
}
}
}
seen.delete(t)
return [...seen]
}
fortifyTargets(t: Territory): Territory[] {
if (this.phase !== 'fortify' || this.owner[t] !== this.current || this.armies[t] < 2) return []
return this.connected(t)
}
// ------------------------------------------------------------ actions
place(t: Territory, count = 1) {
if (this.owner[t] !== this.current) throw new Error(`${t} is not yours to reinforce`)
if (count < 1 || !Number.isInteger(count)) throw new Error(`cannot place ${count}`)
if (this.phase === 'setup') {
if (count !== 1) throw new Error('setup places one army at a time')
this.armies[t] += 1
this.toPlace[this.current]--
this.events.push({ kind: 'place', player: this.current, territory: t, count: 1 })
this.advanceSetup()
return
}
if (this.phase !== 'reinforce') throw new Error(`cannot place during ${this.phase}`)
if (this.mustTrade) throw new Error('trade cards first')
if (count > this.reinforcements) throw new Error(`only ${this.reinforcements} to place`)
this.armies[t] += count
this.reinforcements -= count
this.events.push({ kind: 'place', player: this.current, territory: t, count })
}
/** Hand in three cards, by their positions in the hand. */
trade(indices: readonly number[]) {
if (this.phase !== 'reinforce') throw new Error('cards are traded while reinforcing')
const hand = this.hand
const unique = [...new Set(indices)]
if (unique.length !== 3 || unique.some((i) => i < 0 || i >= hand.length))
throw new Error('a trade is three cards from the hand')
const cards = unique.map((i) => hand[i])
if (!isSet(cards)) throw new Error('those three are not a set')
const armies = setValue(this.trades)
this.trades++
this.reinforcements += armies
// Two extra on one territory you hold that is pictured on the set.
// Only one of them, however many match.
const bonus: Territory[] = []
const pictured = cards.find((c) => c.territory && this.owner[c.territory] === this.current)
if (pictured?.territory) {
this.armies[pictured.territory] += 2
bonus.push(pictured.territory)
}
this.players[this.current].cards = hand.filter((_, i) => !unique.includes(i))
this.discard.push(...cards)
this.events.push({ kind: 'trade', player: this.current, cards, armies, bonus })
}
/** Done placing; on to the fighting (or back to it). */
beginAttack() {
if (this.phase !== 'reinforce') throw new Error(`not reinforcing`)
if (this.mustTrade) throw new Error('trade cards first')
if (this.reinforcements > 0) throw new Error(`${this.reinforcements} armies still to place`)
this.phase = 'attack'
this.resume = null
}
/** One throw of the dice. Returns it, and the territory may change hands. */
attack(from: Territory, to: Territory, dice = 3): Roll {
if (this.phase !== 'attack') throw new Error(`cannot attack during ${this.phase}`)
if (this.owner[from] !== this.current) throw new Error(`${from} is not yours`)
if (this.owner[to] === this.current) throw new Error(`${to} is already yours`)
if (!adjacent(from, to)) throw new Error(`${from} does not border ${to}`)
if (this.armies[from] < 2) throw new Error(`${from} has nobody to spare`)
const attackDice = Math.max(1, Math.min(3, dice, this.armies[from] - 1))
const defendDice = Math.min(2, this.armies[to])
const defender = this.owner[to]
const r = roll(this.rng, attackDice, defendDice)
this.armies[from] -= r.attackerLoss
this.armies[to] -= r.defenderLoss
this.events.push({ kind: 'battle', player: this.current, from, to, defender, roll: r })
if (this.armies[to] === 0) {
this.owner[to] = this.current
this.conquered = true
this.events.push({ kind: 'conquer', player: this.current, from, to, loser: defender })
this.occupation = { from, to, min: attackDice, max: this.armies[from] - 1 }
this.phase = 'occupy'
if (this.territoriesOf(defender).length === 0) {
const loser = this.players[defender]
loser.alive = false
const taken = loser.cards.length
this.players[this.current].cards.push(...loser.cards)
loser.cards = []
this.events.push({ kind: 'eliminate', player: this.current, loser: defender, cards: taken })
}
}
return r
}
/** March into the territory just taken: at least as many as rolled, at most all but one. */
occupy(count: number) {
const o = this.occupation
if (this.phase !== 'occupy' || !o) throw new Error('nothing to occupy')
if (count < o.min || count > o.max) throw new Error(`move between ${o.min} and ${o.max}`)
this.armies[o.from] -= count
this.armies[o.to] = count
this.events.push({ kind: 'occupy', player: this.current, from: o.from, to: o.to, count })
this.occupation = null
this.phase = 'attack'
if (this.territoriesOf(this.current).length === TERRITORY_IDS.length) {
this.winner = this.current
this.phase = 'over'
this.events.push({ kind: 'win', player: this.current })
return
}
if (this.hand.length >= 6) {
this.phase = 'reinforce'
this.resume = 'attack'
this.reinforcements = 0
}
}
endAttack() {
if (this.phase !== 'attack') throw new Error(`not attacking`)
this.phase = 'fortify'
}
/** One move between two of your territories joined by your own, and the turn ends. */
fortify(from: Territory, to: Territory, count: number) {
if (this.phase !== 'fortify') throw new Error(`not fortifying`)
if (!this.fortifyTargets(from).includes(to)) throw new Error(`${from} cannot reach ${to}`)
if (count < 1 || count > this.armies[from] - 1) throw new Error(`cannot move ${count}`)
this.armies[from] -= count
this.armies[to] += count
this.events.push({ kind: 'fortify', player: this.current, from, to, count })
this.endTurn()
}
endTurn() {
if (this.phase !== 'attack' && this.phase !== 'fortify') throw new Error(`cannot end turn during ${this.phase}`)
if (this.conquered) {
if (this.deck.length === 0) {
this.deck = shuffle(this.rng, this.discard)
this.discard = []
}
const card = this.deck.pop()
if (card) {
this.players[this.current].cards.push(card)
this.events.push({ kind: 'card', player: this.current })
}
}
// A round is counted each time play comes back past the seat that went
// first, whether or not that player is still at the table.
let next = this.current
do {
next = (next + 1) % PLAYER_COUNT
if (next === this.first) this.round++
} while (!this.players[next].alive)
this.startTurn(next)
}
// ------------------------------------------------------------ internals
private advanceSetup() {
if (this.toPlace.every((n) => n === 0)) {
this.round = 1
this.startTurn(this.first)
return
}
let next = this.current
do next = (next + 1) % PLAYER_COUNT
while (this.toPlace[next] === 0)
this.current = next
}
private startTurn(player: number) {
this.current = player
this.phase = 'reinforce'
this.resume = null
this.conquered = false
this.reinforcements = this.income(player)
this.events.push({ kind: 'turn', player, round: this.round, reinforcements: this.reinforcements })
}
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import board from './board.json'
import { ADJACENT, BORDERS, CONTINENTS, CONTINENT_IDS, LINK_LIST, TERRITORY_IDS, inContinent } from './map'
describe('the board', () => {
it('has the classic forty-two territories in six continents', () => {
expect(TERRITORY_IDS).toHaveLength(42)
expect(CONTINENT_IDS.map((c) => inContinent(c).length)).toEqual([9, 4, 7, 6, 12, 4])
expect(CONTINENT_IDS.map((c) => CONTINENTS[c].bonus)).toEqual([5, 2, 5, 3, 7, 2])
})
it('has the classic eighty-three links, none twice', () => {
expect(LINK_LIST).toHaveLength(83)
const keys = LINK_LIST.map(([a, b]) => [a, b].sort().join('|'))
expect(new Set(keys).size).toBe(83)
})
it('is symmetric, and nothing borders itself', () => {
for (const t of TERRITORY_IDS) {
expect(ADJACENT[t]).not.toContain(t)
for (const n of ADJACENT[t]) expect(ADJACENT[n]).toContain(t)
}
})
it('is one connected world', () => {
const seen = new Set([TERRITORY_IDS[0]])
const queue = [TERRITORY_IDS[0]]
while (queue.length) {
for (const n of ADJACENT[queue.shift()!]) {
if (seen.has(n)) continue
seen.add(n)
queue.push(n)
}
}
expect(seen.size).toBe(42)
})
// The doors are the strategy. If one of these moves, the game has changed.
it('keeps the doors where every player expects them', () => {
expect([...BORDERS['australia']]).toEqual(['indonesia'])
expect([...BORDERS['south-america']].sort()).toEqual(['brazil', 'venezuela'])
expect([...BORDERS['north-america']].sort()).toEqual(['alaska', 'central-america', 'greenland'])
expect([...BORDERS['africa']].sort()).toEqual(['east-africa', 'egypt', 'north-africa'])
expect([...BORDERS['europe']].sort()).toEqual(['iceland', 'southern-europe', 'ukraine', 'western-europe'])
expect([...BORDERS['asia']].sort()).toEqual(['afghanistan', 'kamchatka', 'middle-east', 'siam', 'ural'])
})
})
describe('the drawing', () => {
const drawn = board.territories as Record<string, { d: string; label: number[] }>
it('draws every territory and nothing else', () => {
expect(Object.keys(drawn).sort()).toEqual([...TERRITORY_IDS].sort())
})
it('can draw a lane for every link that is not a shared border', () => {
const touching = new Set(board.touching.map((p) => [...p].sort().join('|')))
const near = board.near as Record<string, number[]>
for (const [a, b] of LINK_LIST) {
const key = [a, b].sort().join('|')
if (touching.has(key) || key === 'alaska|kamchatka') continue
expect(near[key], key).toBeDefined()
}
})
})
+201
View File
@@ -0,0 +1,201 @@
/**
* The board as the rules see it: forty-two territories in six continents, and
* which of them border which.
*
* The topology is the classic one, link for link. It is a fact about a
* published game and free to use, and it is also the part players know by
* heart: that Australia has one door, that South America has two, that Asia
* has nowhere to hide. The shapes the board draws come from real geography in
* `board.json` and only approximate this -- where they disagree, the rules
* here are the ones that count, and the board says so by drawing a lane across
* the water or closing a border that happens to touch.
*/
export const CONTINENTS = {
'north-america': { name: 'North America', bonus: 5 },
'south-america': { name: 'South America', bonus: 2 },
europe: { name: 'Europe', bonus: 5 },
africa: { name: 'Africa', bonus: 3 },
asia: { name: 'Asia', bonus: 7 },
australia: { name: 'Australia', bonus: 2 },
} as const
export type Continent = keyof typeof CONTINENTS
export const TERRITORIES = {
alaska: { name: 'Alaska', continent: 'north-america' },
'northwest-territory': { name: 'Northwest Territory', continent: 'north-america' },
greenland: { name: 'Greenland', continent: 'north-america' },
alberta: { name: 'Alberta', continent: 'north-america' },
ontario: { name: 'Ontario', continent: 'north-america' },
quebec: { name: 'Quebec', continent: 'north-america' },
'western-us': { name: 'Western United States', continent: 'north-america' },
'eastern-us': { name: 'Eastern United States', continent: 'north-america' },
'central-america': { name: 'Central America', continent: 'north-america' },
venezuela: { name: 'Venezuela', continent: 'south-america' },
peru: { name: 'Peru', continent: 'south-america' },
brazil: { name: 'Brazil', continent: 'south-america' },
argentina: { name: 'Argentina', continent: 'south-america' },
iceland: { name: 'Iceland', continent: 'europe' },
'great-britain': { name: 'Great Britain', continent: 'europe' },
scandinavia: { name: 'Scandinavia', continent: 'europe' },
'northern-europe': { name: 'Northern Europe', continent: 'europe' },
'western-europe': { name: 'Western Europe', continent: 'europe' },
'southern-europe': { name: 'Southern Europe', continent: 'europe' },
ukraine: { name: 'Ukraine', continent: 'europe' },
'north-africa': { name: 'North Africa', continent: 'africa' },
egypt: { name: 'Egypt', continent: 'africa' },
'east-africa': { name: 'East Africa', continent: 'africa' },
congo: { name: 'Congo', continent: 'africa' },
'south-africa': { name: 'South Africa', continent: 'africa' },
madagascar: { name: 'Madagascar', continent: 'africa' },
ural: { name: 'Ural', continent: 'asia' },
siberia: { name: 'Siberia', continent: 'asia' },
yakutsk: { name: 'Yakutsk', continent: 'asia' },
kamchatka: { name: 'Kamchatka', continent: 'asia' },
irkutsk: { name: 'Irkutsk', continent: 'asia' },
mongolia: { name: 'Mongolia', continent: 'asia' },
japan: { name: 'Japan', continent: 'asia' },
afghanistan: { name: 'Afghanistan', continent: 'asia' },
china: { name: 'China', continent: 'asia' },
'middle-east': { name: 'Middle East', continent: 'asia' },
india: { name: 'India', continent: 'asia' },
siam: { name: 'Siam', continent: 'asia' },
indonesia: { name: 'Indonesia', continent: 'australia' },
'new-guinea': { name: 'New Guinea', continent: 'australia' },
'western-australia': { name: 'Western Australia', continent: 'australia' },
'eastern-australia': { name: 'Eastern Australia', continent: 'australia' },
} as const satisfies Record<string, { name: string; continent: Continent }>
export type Territory = keyof typeof TERRITORIES
export const TERRITORY_IDS = Object.keys(TERRITORIES) as Territory[]
/** Each link once, in the order a player would read the board. */
const LINKS: readonly (readonly [Territory, Territory])[] = [
['alaska', 'northwest-territory'],
['alaska', 'alberta'],
['alaska', 'kamchatka'],
['northwest-territory', 'alberta'],
['northwest-territory', 'ontario'],
['northwest-territory', 'greenland'],
['greenland', 'ontario'],
['greenland', 'quebec'],
['greenland', 'iceland'],
['alberta', 'ontario'],
['alberta', 'western-us'],
['ontario', 'western-us'],
['ontario', 'eastern-us'],
['ontario', 'quebec'],
['quebec', 'eastern-us'],
['western-us', 'eastern-us'],
['western-us', 'central-america'],
['eastern-us', 'central-america'],
['central-america', 'venezuela'],
['venezuela', 'peru'],
['venezuela', 'brazil'],
['peru', 'brazil'],
['peru', 'argentina'],
['brazil', 'argentina'],
['brazil', 'north-africa'],
['iceland', 'great-britain'],
['iceland', 'scandinavia'],
['great-britain', 'scandinavia'],
['great-britain', 'northern-europe'],
['great-britain', 'western-europe'],
['scandinavia', 'northern-europe'],
['scandinavia', 'ukraine'],
['northern-europe', 'ukraine'],
['northern-europe', 'southern-europe'],
['northern-europe', 'western-europe'],
['western-europe', 'southern-europe'],
['western-europe', 'north-africa'],
['southern-europe', 'ukraine'],
['southern-europe', 'middle-east'],
['southern-europe', 'egypt'],
['southern-europe', 'north-africa'],
['ukraine', 'middle-east'],
['ukraine', 'afghanistan'],
['ukraine', 'ural'],
['north-africa', 'egypt'],
['north-africa', 'east-africa'],
['north-africa', 'congo'],
['egypt', 'middle-east'],
['egypt', 'east-africa'],
['east-africa', 'congo'],
['east-africa', 'south-africa'],
['east-africa', 'madagascar'],
['east-africa', 'middle-east'],
['congo', 'south-africa'],
['south-africa', 'madagascar'],
['middle-east', 'afghanistan'],
['middle-east', 'india'],
['afghanistan', 'ural'],
['afghanistan', 'china'],
['afghanistan', 'india'],
['ural', 'siberia'],
['ural', 'china'],
['siberia', 'yakutsk'],
['siberia', 'irkutsk'],
['siberia', 'mongolia'],
['siberia', 'china'],
['yakutsk', 'kamchatka'],
['yakutsk', 'irkutsk'],
['kamchatka', 'irkutsk'],
['kamchatka', 'mongolia'],
['kamchatka', 'japan'],
['irkutsk', 'mongolia'],
['mongolia', 'japan'],
['mongolia', 'china'],
['china', 'siam'],
['china', 'india'],
['india', 'siam'],
['siam', 'indonesia'],
['indonesia', 'new-guinea'],
['indonesia', 'western-australia'],
['new-guinea', 'western-australia'],
['new-guinea', 'eastern-australia'],
['western-australia', 'eastern-australia'],
]
export const LINK_LIST = LINKS
export const ADJACENT: Record<Territory, readonly Territory[]> = (() => {
const adj = Object.fromEntries(TERRITORY_IDS.map((t) => [t, [] as Territory[]])) as Record<
Territory,
Territory[]
>
for (const [a, b] of LINKS) {
adj[a].push(b)
adj[b].push(a)
}
return adj
})()
export const adjacent = (a: Territory, b: Territory) => ADJACENT[a].includes(b)
export const inContinent = (c: Continent) =>
TERRITORY_IDS.filter((t) => TERRITORIES[t].continent === c)
export const CONTINENT_IDS = Object.keys(CONTINENTS) as Continent[]
/**
* The territories through which a continent can be entered: those with a
* neighbor outside it. Holding a continent means holding these.
*/
export const BORDERS: Record<Continent, readonly Territory[]> = Object.fromEntries(
CONTINENT_IDS.map((c) => [
c,
inContinent(c).filter((t) => ADJACENT[t].some((n) => TERRITORIES[n].continent !== c)),
]),
) as Record<Continent, Territory[]>
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { apply, decide, leanings } from './bot'
import { Game, PLAYER_COUNT } from './game'
/**
* Six bots left alone, to the end.
*
* The question that matters about the bots is not whether each move looks
* sensible but whether a table of them ever finishes. A game that cannot be
* won is not a hard game, it is a broken one -- so these play out in full,
* on thirty seeds, and every one of them has to end in a winner.
*/
export function playOut(seed: number, limit = 20000) {
const g = new Game(seed, null)
const lean = leanings(seed, PLAYER_COUNT)
let actions = 0
while (g.phase !== 'over' && actions < limit) {
apply(g, decide(g, lean[g.current]))
actions++
}
return { g, actions }
}
describe('a table of bots', () => {
it('plays every seed through to a winner', () => {
const rounds: number[] = []
for (let seed = 1; seed <= 30; seed++) {
const { g } = playOut(seed)
expect(g.phase, `seed ${seed}`).toBe('over')
expect(g.winner).not.toBeNull()
expect(g.territoriesOf(g.winner!)).toHaveLength(42)
rounds.push(g.round)
}
const mean = rounds.reduce((a, b) => a + b, 0) / rounds.length
console.log(`rounds: mean ${mean.toFixed(1)}, min ${Math.min(...rounds)}, max ${Math.max(...rounds)}`)
expect(Math.max(...rounds)).toBeLessThan(80)
})
it('does not always hand it to the same seat', () => {
const winners = new Set<number>()
for (let seed = 1; seed <= 30; seed++) winners.add(playOut(seed).g.winner!)
expect(winners.size).toBeGreaterThan(3)
})
it('replays exactly from the same seed', () => {
const a = playOut(7).g
const b = playOut(7).g
expect(a.events.length).toBe(b.events.length)
expect(a.winner).toBe(b.winner)
})
})
+26
View File
@@ -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
}
+15
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
Build src/game/board.json from Natural Earth.
The forty-two territories are groups of real countries and, for the five
countries that the board cuts through (Russia, Canada, the United States,
Australia, China) and the one it cuts across (Indonesia, whose half of New
Guinea belongs with the other half), their first-level provinces.
Natural Earth is public domain, so the outlines carry no license of their own.
The grouping below is ours: which country goes in which territory is the one
part of this map anybody had to decide.
python3 -m venv .venv && .venv/bin/pip install shapely pyshp
.venv/bin/python tools/map.py path/to/natural-earth
The directory needs the 1:50m admin-0 countries and admin-1 states and
provinces, unzipped where naciscdn.org puts them:
ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp
ne_50m_admin_1_states_provinces/ne_50m_admin_1_states_provinces.shp
"""
import json
import math
import sys
from pathlib import Path
import shapefile
from shapely.geometry import MultiPolygon, Polygon, shape
from shapely.ops import polylabel, unary_union, nearest_points
from shapely import affinity
# ---------------------------------------------------------------- grouping
COUNTRIES = {
# North America. Alaska, Canada and the United States come from admin-1.
'greenland': ['GRL'],
'central-america': [
'MEX', 'GTM', 'BLZ', 'SLV', 'HND', 'NIC', 'CRI', 'PAN',
'CUB', 'JAM', 'HTI', 'DOM', 'BHS', 'PRI', 'TCA', 'CYM',
],
# South America.
'venezuela': ['VEN', 'COL', 'GUY', 'SUR'], # plus French Guiana, below
'peru': ['PER', 'ECU', 'BOL'],
'brazil': ['BRA'],
'argentina': ['ARG', 'CHL', 'URY', 'PRY', 'FLK'],
# Europe.
'iceland': ['ISL', 'FRO'],
'great-britain': ['GBR', 'IRL', 'IMN', 'GGY', 'JEY'],
'scandinavia': ['NOR', 'SWE', 'FIN', 'ALD'],
'northern-europe': ['DEU', 'POL', 'DNK', 'NLD', 'BEL', 'LUX', 'CZE', 'SVK'],
'western-europe': ['FRA', 'ESP', 'PRT', 'AND', 'MCO'], # metropolitan France only
'southern-europe': [
'ITA', 'CHE', 'LIE', 'AUT', 'SVN', 'HRV', 'BIH', 'SRB', 'MNE', 'KOS',
'ALB', 'MKD', 'GRC', 'BGR', 'ROU', 'HUN', 'MLT', 'SMR', 'VAT',
],
'ukraine': ['UKR', 'BLR', 'MDA', 'EST', 'LVA', 'LTU'], # plus European Russia
# Africa.
'north-africa': [
'MAR', 'SAH', 'DZA', 'TUN', 'MRT', 'MLI', 'NER', 'TCD', 'SEN', 'GMB',
'GNB', 'GIN', 'SLE', 'LBR', 'CIV', 'BFA', 'GHA', 'TGO', 'BEN', 'NGA',
],
'egypt': ['EGY', 'LBY'],
'east-africa': [
'SDN', 'SDS', 'ETH', 'ERI', 'DJI', 'SOM', 'SOL', 'KEN', 'UGA', 'TZA',
'RWA', 'BDI',
],
'congo': ['COD', 'COG', 'CAF', 'CMR', 'GAB', 'GNQ', 'AGO'],
'south-africa': ['ZAF', 'NAM', 'BWA', 'ZWE', 'ZMB', 'MOZ', 'MWI', 'LSO', 'SWZ'],
'madagascar': ['MDG', 'COM'],
# Asia. Russia and China come from admin-1.
'middle-east': [
'TUR', 'SYR', 'LBN', 'ISR', 'PSX', 'JOR', 'IRQ', 'SAU', 'KWT', 'BHR',
'QAT', 'ARE', 'OMN', 'YEM', 'IRN', 'CYP', 'CYN', 'GEO', 'ARM', 'AZE',
],
'afghanistan': ['AFG', 'KAZ', 'UZB', 'TKM', 'TJK', 'KGZ'],
'india': ['IND', 'PAK', 'NPL', 'BTN', 'BGD', 'LKA', 'KAS'],
'siam': ['THA', 'MMR', 'LAO', 'KHM', 'VNM', 'MYS', 'SGP'],
'china': ['TWN', 'HKG', 'MAC'],
# Korea goes with Manchuria rather than China: on the board China does not
# reach the Pacific coast of Russia, and North Korea would take it there.
'mongolia': ['MNG', 'PRK', 'KOR'],
'japan': ['JPN'],
# Australia. Indonesia and Australia come from admin-1.
'indonesia': ['PHL', 'BRN', 'TLS'],
'new-guinea': ['PNG', 'SLB'],
'eastern-australia': ['NZL'],
}
# Countries taken apart by province instead. Each maps a province to its
# territory; anything not named goes to the default.
PROVINCES = {
'USA': ('eastern-us', {
'Alaska': 'alaska',
**{s: 'western-us' for s in [
'Washington', 'Oregon', 'California', 'Nevada', 'Idaho', 'Montana',
'Wyoming', 'Utah', 'Colorado', 'Arizona', 'New Mexico',
'North Dakota', 'South Dakota', 'Nebraska', 'Kansas',
]},
'Hawaii': None,
}),
'CAN': ('quebec', {
'Yukon': 'northwest-territory',
'Northwest Territories': 'northwest-territory',
'Nunavut': 'northwest-territory',
'British Columbia': 'alberta',
'Alberta': 'alberta',
'Saskatchewan': 'alberta',
'Manitoba': 'ontario',
'Ontario': 'ontario',
}),
'AUS': ('eastern-australia', {
'Western Australia': 'western-australia',
'Northern Territory': 'western-australia',
'South Australia': 'western-australia',
}),
'CHN': ('china', {
'Inner Mongol': 'mongolia',
'Heilongjiang': 'mongolia',
'Jilin': 'mongolia',
'Liaoning': 'mongolia',
}),
'IDN': ('indonesia', {
'Papua': 'new-guinea',
'Papua Barat': 'new-guinea',
}),
}
# Russia is grouped by federal district first, then by name, because the
# board's line down the Urals is not quite the administrative one.
RUSSIA_BY_NAME = {
'Komi': 'ural', 'Nenets': 'ural', "Perm'": 'ural', 'Bashkortostan': 'ural',
'Orenburg': 'ural', 'Udmurt': 'ural',
# The steppe south of the Siberian plain, so Siberia meets Kazakhstan only
# where the Altai Republic does, and the board's Siberia never borders it.
'Omsk': 'ural', 'Novosibirsk': 'ural', 'Altay': 'ural',
'Irkutsk': 'irkutsk', 'Buryat': 'irkutsk', 'Chita': 'irkutsk',
'Sakha (Yakutia)': 'yakutsk',
'Kaliningrad': 'northern-europe',
}
RUSSIA_BY_REGION = {
'Urals': 'ural',
'Siberian': 'siberia',
'Far Eastern': 'kamchatka',
}
def russia(rec):
return RUSSIA_BY_NAME.get(rec['name']) or RUSSIA_BY_REGION.get(rec['region']) or 'ukraine'
# ---------------------------------------------------------------- geometry
LON0 = -170.0 # the board's left edge; Alaska's Aleutians wrap to it
LON1 = 192.0 # and the right edge, past the date line for Chukotka
LAT_MIN = -56.0
LAT_MAX = 84.0
SCALE = 4.0 # board units per degree of longitude
SIMPLIFY = 0.12 # degrees
MIN_ISLAND = 0.35 # square degrees; smaller islands are dropped
def miller(lat):
lat = max(LAT_MIN, min(LAT_MAX, lat))
return math.degrees(1.25 * math.log(math.tan(math.pi / 4 + 0.4 * math.radians(lat))))
Y_TOP = miller(LAT_MAX)
def project(geom):
def xy(x, y, z=None):
return ((x - LON0) * SCALE, (Y_TOP - miller(y)) * SCALE)
from shapely.ops import transform
return transform(lambda xs, ys, zs=None: tuple(zip(*[xy(x, y) for x, y in zip(xs, ys)])), geom)
def polygons(geom):
if isinstance(geom, Polygon):
return [geom]
if isinstance(geom, MultiPolygon):
return list(geom.geoms)
return [g for g in getattr(geom, 'geoms', []) if isinstance(g, Polygon)]
def wrap(tid, poly):
"""Move a polygon across the date line to the side of the board it lives on."""
c = poly.centroid.x
if tid == 'alaska' and c > 0:
return affinity.translate(poly, -360)
if c < LON0:
return affinity.translate(poly, 360)
return poly
def keep(tid, code, poly):
"""Drop the overseas pieces a country's outline carries with it."""
x, y = poly.centroid.x, poly.centroid.y
if code == 'FRA':
if -55 < x < -50 and 2 < y < 6:
return 'venezuela' # French Guiana
return tid if (-6 < x < 10 and 41 < y < 52) else None
if code == 'NOR' and y > 74:
return None # Svalbard
if code == 'ESP' and (x < -12 or y < 36):
return None # the Canaries, Ceuta and Melilla
if code == 'PRT' and x < -12:
return None # the Azores and Madeira
if code == 'CHL' and x < -100:
return None # Easter Island
if code == 'ECU' and x < -85:
return None # the Galápagos
if code == 'GBR' and not (-9 < x < 3 and 49 < y < 61):
return None
if code == 'NLD' and y < 40:
return None # the Caribbean Netherlands
if code == 'DNK' and x < 0:
return None
if y < LAT_MIN:
return None
return tid
def main(src):
src = Path(src)
parts = {}
def add(tid, geom, code):
for p in polygons(geom):
if not p.is_valid:
p = p.buffer(0)
t = keep(tid, code, p)
if t:
parts.setdefault(t, []).append(wrap(t, p))
lookup = {code: tid for tid, codes in COUNTRIES.items() for code in codes}
unused = []
r0 = shapefile.Reader(str(src / 'ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp'))
for sr in r0.iterShapeRecords():
code = sr.record['ADM0_A3']
if code in PROVINCES or code == 'RUS':
continue
tid = lookup.get(code)
if tid is None:
unused.append(code)
continue
add(tid, shape(sr.shape.__geo_interface__), code)
r1 = shapefile.Reader(str(src / 'ne_50m_admin_1_states_provinces/ne_50m_admin_1_states_provinces.shp'))
for sr in r1.iterShapeRecords():
rec = sr.record
code = rec['adm0_a3']
if code == 'RUS':
tid = russia(rec)
elif code in PROVINCES:
default, named = PROVINCES[code]
tid = named.get(rec['name'], default)
else:
continue
if tid:
add(tid, shape(sr.shape.__geo_interface__), code)
out = {}
shapes = {}
for tid, ps in sorted(parts.items()):
merged = unary_union([p.buffer(0.02) for p in ps]).buffer(-0.02)
pieces = sorted(polygons(merged), key=lambda p: -p.area)
big = [p for p in pieces if p.area >= MIN_ISLAND] or pieces[:1]
geom = MultiPolygon(big).simplify(SIMPLIFY, preserve_topology=True)
shapes[tid] = geom
proj = project(geom)
main_piece = max(polygons(proj), key=lambda p: p.area)
label = polylabel(main_piece, tolerance=0.5)
out[tid] = {
'd': ' '.join(path(p) for p in polygons(proj)),
'label': [round(label.x, 1), round(label.y, 1)],
}
touching = []
ids = sorted(shapes)
grown = {t: shapes[t].buffer(0.25) for t in ids}
for i, a in enumerate(ids):
for b in ids[i + 1:]:
if grown[a].intersects(shapes[b]):
touching.append([a, b])
# Nearest points for every pair not touching but close enough that the
# rules might join them across water. The board draws a lane between
# these; which pairs are joined is the rules' business, not this file's.
near = {}
pshapes = {t: project(shapes[t]) for t in ids}
for i, a in enumerate(ids):
for b in ids[i + 1:]:
if [a, b] in touching:
continue
pa, pb = nearest_points(pshapes[a], pshapes[b])
if pa.distance(pb) < 45 * SCALE:
near[f'{a}|{b}'] = [round(pa.x, 1), round(pa.y, 1), round(pb.x, 1), round(pb.y, 1)]
# The line two touching territories share, so the board can say something
# about it: heavier where it is also a continent's edge, and closed where
# the classic board has no link across it.
borders = {}
for a, b in touching:
line = pshapes[a].boundary.intersection(pshapes[b].buffer(0.25 * SCALE))
parts = [g for g in getattr(line, 'geoms', [line]) if g.geom_type == 'LineString' and g.length > 2]
if parts:
borders[f'{a}|{b}'] = ''.join(
'M' + 'L'.join(f'{x:.1f},{y:.1f}' for x, y in g.coords) for g in parts
)
# Alaska and Kamchatka meet across the date line, which is the edge of
# the board: a lane off each side, at the height of each coast.
alaska = max(polygons(pshapes['alaska']), key=lambda p: p.area)
kamchatka = max(polygons(pshapes['kamchatka']), key=lambda p: p.area)
west = min(alaska.exterior.coords, key=lambda c: c[0])
east = max(kamchatka.exterior.coords, key=lambda c: c[0])
width = round((LON1 - LON0) * SCALE)
height = round((Y_TOP - miller(LAT_MIN)) * SCALE)
board = {
'source': 'Natural Earth 1:50m, public domain; grouping by tools/map.py',
'width': width,
'height': height,
'territories': out,
'touching': touching,
'near': near,
'borders': borders,
'wrap': {
'alaska': [round(west[0], 1), round(west[1], 1)],
'kamchatka': [round(east[0], 1), round(east[1], 1)],
},
}
dest = Path(__file__).resolve().parent.parent / 'src/game/board.json'
dest.write_text(json.dumps(board, separators=(',', ':')) + '\n')
print(f'{len(out)} territories, {len(touching)} land borders, {len(near)} crossings -> {dest}')
print(f'{dest.stat().st_size // 1024} KiB')
print('left out:', ' '.join(sorted(unused)))
def path(poly):
def ring(coords):
pts = [f'{x:.1f},{y:.1f}' for x, y in coords[:-1]]
return 'M' + 'L'.join(pts) + 'Z'
return ring(list(poly.exterior.coords)) + ''.join(ring(list(i.coords)) for i in poly.interiors)
if __name__ == '__main__':
main(sys.argv[1] if len(sys.argv) > 1 else 'natural-earth')
+28
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+15
View File
@@ -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()],
})
+24
View File
@@ -0,0 +1,24 @@
# 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
# five bots is won or lost, and a leaderboard of how many rounds it took would
# mostly measure the dice.
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=/conquest/
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
+22
View File
@@ -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";
}
}