Hunt the Wumpus, in two skins
A browser recreation of Gregory Yob's 1973 game. The rules are his and are free to use; the sentences he wrote to explain them are not, and none of them are here. Every line this game prints was written for it. NOTICE.md sets out what is ours, what is not, and why -- the same argument the lemonade stand makes about its own text, made before shipping rather than after. Two skins over one game, and the period one is a Teletype rather than a television. Wumpus is six years older than the Atari the lemonade stand runs on. It lived on a timesharing service you telephoned and it printed on paper, which is why the game asks you to remember things: there is no screen to look back at. So 1973 is fanfold and black ink and a print head hammering out one character at a time, and the remaster is the same cave with the lamp on. The rules never differ, and neither does a single word. The cave is proved rather than trusted. The adjacency table is typed out by hand, because a generated dodecahedron comes out with an arbitrary numbering and this is the numbering every listing has used since 1973 -- so a transcript from a magazine still makes sense against it. Typed out means it can be typed wrong, and a wrong table would not crash: it would quietly stop being a dodecahedron while the game still played. cave.test.ts checks degree three, symmetry, connectivity, a diameter of exactly five, and three pentagons through every room. The map is not allowed to know more than the paper. CaveMap draws only from the rooms the hunter has stood in and the warnings those rooms gave, and it marks suspects rather than answers. That guarantee is asserted in the engine rather than in the component, because it is a property of the engine and would still have to hold if the map were thrown away. Drawing a solid flat took two attempts. Rooms 6 to 15 are not two pentagons at different depths, they are one ten-room ring alternating inward and outward wiring, and getting that wrong still draws and still looks cave-shaped. It is much harder to read. The test checks the property that actually matters -- that no tunnel crosses another -- and was confirmed to fail against the bad layout before it was believed. The dial-in is a real Bell 103 call rather than a noise: dial tone at 350 and 440 Hz, Touch-Tone dialling, ringback at 440 and 480, the far modem's 2225 Hz mark tone, then both ends keying at once through a telephone band. The screech everybody remembers is a V.34 handshake from the 1990s and could not have existed here. 38 tests. Typecheck clean.
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# 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?
|
||||||
|
|
||||||
|
# The scores service writes its database here in development.
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Notice, credits and provenance
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
An independent reimplementation of *Hunt the Wumpus*, the cave-hunting game
|
||||||
|
that went round timesharing systems and then every home computer of the
|
||||||
|
following decade, written from scratch in TypeScript in 2026.
|
||||||
|
|
||||||
|
## Credit where it is due
|
||||||
|
|
||||||
|
- **Gregory Yob**, 1973 — wrote the original, reportedly out of irritation at
|
||||||
|
how many computer games of the day were played on a grid. Hanging the rooms
|
||||||
|
on a dodecahedron instead is the whole idea, and it is his.
|
||||||
|
- **People's Computer Company** and **Creative Computing**, who carried the
|
||||||
|
listing and the article that explained it.
|
||||||
|
- Every type-in book and school terminal that passed it along, which is how
|
||||||
|
most people met it.
|
||||||
|
|
||||||
|
This project is not affiliated with or endorsed by anyone connected to the
|
||||||
|
original. No original source code was used, consulted or copied.
|
||||||
|
|
||||||
|
## How this code came to exist
|
||||||
|
|
||||||
|
Clean-room, and worth being specific about because it is what makes the licence
|
||||||
|
below meaningful:
|
||||||
|
|
||||||
|
- **The rules are not ours to own, and we use them freely.** Twenty rooms,
|
||||||
|
three tunnels each, two pits, two bats, one wumpus, five crooked arrows that
|
||||||
|
fly through five rooms, a wumpus that wakes three times in four. Game
|
||||||
|
mechanics are ideas rather than expression; nobody gets to fence them off,
|
||||||
|
and so may you use them.
|
||||||
|
- **The cave is the cave.** `src/game/cave.ts` uses the room numbering every
|
||||||
|
listing of this game has used since 1973 — room 1 leads to 2, 5 and 8 — so
|
||||||
|
that a transcript from a magazine still makes sense against it. A numbering
|
||||||
|
is a fact about a graph, not a piece of writing. It is written out by hand
|
||||||
|
and then *proved* to be a dodecahedron in `cave.test.ts` rather than
|
||||||
|
trusted.
|
||||||
|
- **The artwork and the music are ours.** Every mark on the map is SVG
|
||||||
|
generated from the graph in code, and every note is generated by the synth in
|
||||||
|
`src/audio/`. There are no imported assets of any kind.
|
||||||
|
- **The synth is shared with its sibling.** `src/audio/synth.ts` is the same
|
||||||
|
engine as the one in [lemonade](https://github.com/Coffey-Labs/lemonade) —
|
||||||
|
same author, same licence. What is written for it here is new.
|
||||||
|
|
||||||
|
## The words on screen are ours, deliberately
|
||||||
|
|
||||||
|
This is the part worth reading, because the obvious thing to do was the wrong
|
||||||
|
thing.
|
||||||
|
|
||||||
|
The 1973 game has famous lines in it. Anyone who played it can still quote the
|
||||||
|
one about the bats. Those sentences are Gregory Yob's writing, not a rule and
|
||||||
|
not a fact — and a project released under a copyleft licence has no business
|
||||||
|
licensing words it did not write. The sibling project went back and took the
|
||||||
|
original's wording out of itself for exactly this reason; this one never
|
||||||
|
shipped with it.
|
||||||
|
|
||||||
|
So every line the machine prints here was written for this project:
|
||||||
|
|
||||||
|
| What the game has to say | Where the words came from |
|
||||||
|
| --- | --- |
|
||||||
|
| the three warnings | ours (`SENSE_TEXT` in `src/game/constants.ts`) |
|
||||||
|
| the four deaths, and the kill | ours (`DEATH_TEXT`, `WIN_TEXT`) |
|
||||||
|
| being carried off by bats | ours (`BATS_TEXT`) |
|
||||||
|
| the refusal, the prompts | ours |
|
||||||
|
| the instructions screen | ours — the rules are Yob's, the sentences are not |
|
||||||
|
|
||||||
|
Two things are still not original, and neither is a copyright question:
|
||||||
|
|
||||||
|
- **The name.** It is still *Hunt the Wumpus*, and the animal is still a
|
||||||
|
wumpus. Names and short titles are not subject to copyright, and it is kept
|
||||||
|
deliberately — calling it something else would be pretending this is a
|
||||||
|
different game, which would be less honest rather than more.
|
||||||
|
- **`YOU ARE IN ROOM 12` and `TUNNELS LEAD TO 3 11 13`.** These are the only
|
||||||
|
sensible words for what they report, and the order follows from the fact
|
||||||
|
that you need to know where you are before the exits mean anything. There is
|
||||||
|
no creative expression in them to own.
|
||||||
|
|
||||||
|
## One rule that is ours
|
||||||
|
|
||||||
|
Yob's game is a single cave: you kill the wumpus or it kills you, and either
|
||||||
|
way that is the end. Here a kill is a door rather than an ending — you go
|
||||||
|
deeper, into a fresh cave with the same twenty rooms and whatever arrows you
|
||||||
|
did not spend, until something gets you. It is a frame around the 1973 game
|
||||||
|
rather than a change to it: every hunt inside it is the original, unaltered,
|
||||||
|
and the reasoning is in `src/game/constants.ts`.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
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 the leaderboard is a network service: §13
|
||||||
|
means anyone who runs a modified copy of this for other people over a network
|
||||||
|
has to offer them its source. Running the game without the scores service is
|
||||||
|
entirely supported and imposes nothing extra.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||||
|
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
*None of the above is legal advice.*
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Hunt the Wumpus
|
||||||
|
|
||||||
|
A browser recreation of the 1973 cave game — the one you played on a Teletype,
|
||||||
|
by smell, with five arrows you could not aim straight.
|
||||||
|
|
||||||
|
It ships in two skins over one game. **1973** is the machine as it was: a
|
||||||
|
printing terminal, black ink on fanfold paper, the print head hammering out one
|
||||||
|
character at a time and nothing on screen you have not read. **Remaster** is
|
||||||
|
the same cave with the lamp on — a cel-shaded map of the dodecahedron, warnings
|
||||||
|
drawn as marks over the rooms they came from. The rules never differ between
|
||||||
|
them, and neither does a single word the machine prints. Switch any time with
|
||||||
|
the button under the frame.
|
||||||
|
|
||||||
|
React + TypeScript + Vite, and not one binary asset: every mark on the map is
|
||||||
|
SVG generated from the graph in code, and every note is generated by a small
|
||||||
|
synth on the Web Audio API.
|
||||||
|
|
||||||
|
## Playing
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The leaderboard is a separate service; run
|
||||||
|
[games-scores](https://github.com/Coffey-Labs/games-scores) alongside this if
|
||||||
|
you want one, or do not, and the board will say so rather than break.
|
||||||
|
|
||||||
|
You dial in rather than switch on, because in 1973 the game was not on your
|
||||||
|
machine — it was on somebody else's, down a telephone line. Pressing DIAL is
|
||||||
|
also what unlocks the audio: browsers want a gesture before they will make a
|
||||||
|
sound, and that happens to be exactly the gesture the game already wanted.
|
||||||
|
|
||||||
|
One to four hunters share the keyboard, hot-seat style. A turn is one whole
|
||||||
|
cave rather than one move — you walk yours until it kills you or you kill it,
|
||||||
|
and only then does the next hunter sit down. Taking turns move by move would
|
||||||
|
mean three people watching a fourth think.
|
||||||
|
|
||||||
|
- The cave is always a **dodecahedron**: twenty rooms, three tunnels from each.
|
||||||
|
That never changes. What changes is where things are.
|
||||||
|
- **Two bottomless pits.** Walk into one and you keep going.
|
||||||
|
- **Two roosts of super bats.** They will not hurt you. They will pick you up
|
||||||
|
and put you down somewhere at random, and they do not care what is already
|
||||||
|
there.
|
||||||
|
- **One wumpus**, asleep. Nothing down there troubles it — it is too heavy to
|
||||||
|
fall down a pit and too heavy for the bats to lift.
|
||||||
|
- Standing **one tunnel** from any of them, you are told something is close.
|
||||||
|
You are never told which room. Three tunnels and one warning is the whole
|
||||||
|
puzzle.
|
||||||
|
- **Five crooked arrows.** Each can be aimed through as many as five rooms in a
|
||||||
|
row. Name a room the arrow cannot reach from where it is and it does not stop
|
||||||
|
and does not fail — it takes a tunnel of its own choosing, and one of those
|
||||||
|
leads back to you.
|
||||||
|
- A shot that misses **usually wakes the wumpus**, which then moves one room or
|
||||||
|
stays where it is. If it arrives where you are, that is the end of it.
|
||||||
|
|
||||||
|
Kill it and you go deeper: a fresh cave, the same twenty rooms, and whatever
|
||||||
|
arrows you did not spend. Die and you are finished. **CLIMB OUT** closes the
|
||||||
|
books whenever you like and shows the standings.
|
||||||
|
|
||||||
|
## High scores
|
||||||
|
|
||||||
|
One board, shared by everybody. Ending an expedition posts every hunter in the
|
||||||
|
party, and the top 50 comes back ranked best first: wumpodes bagged, then
|
||||||
|
arrows still in the quiver, then fewest rooms walked. Runs from the expedition
|
||||||
|
you just finished are picked out.
|
||||||
|
|
||||||
|
There is no way to clear it. A hunter leaves the list only by being pushed off
|
||||||
|
the bottom by a better one.
|
||||||
|
|
||||||
|
The board is not in this repository and is not ours alone: every game on the
|
||||||
|
site shares one service, [games-scores](https://github.com/Coffey-Labs/games-scores),
|
||||||
|
which is one container and one volume however many games there are. All this
|
||||||
|
repository holds is the client in `src/game/highscores.ts`, which names the
|
||||||
|
game on every call and is otherwise exactly what it would be if the board were
|
||||||
|
ours — rows come back in this game's own field names.
|
||||||
|
|
||||||
|
It is the one part of the game that needs a line out. With the service
|
||||||
|
unreachable the game plays exactly as normal and the board says so plainly
|
||||||
|
rather than breaking; posting happens behind the closing standings, so a slow
|
||||||
|
network never holds up the end of a run. That repository's README has the API,
|
||||||
|
and what a board with no accounts can and cannot promise.
|
||||||
|
|
||||||
|
## How it is put together
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
game/ pure simulation - no React, no DOM
|
||||||
|
cave.ts the dodecahedron: adjacency, and where to draw it
|
||||||
|
constants.ts hazard counts, arrows, probabilities, every line of text
|
||||||
|
engine.ts one turn: move, shoot, hazards, the wumpus waking
|
||||||
|
reducer.ts the turn/phase machine
|
||||||
|
rng.ts seeded mulberry32, so an expedition can be replayed
|
||||||
|
audio/
|
||||||
|
synth.ts pulse voices, a drum kit, filters, look-ahead sequencer
|
||||||
|
tunes.ts the chiptune
|
||||||
|
dread.ts the remaster's band
|
||||||
|
skin.ts which of the two skins is on, and where it is kept
|
||||||
|
components/
|
||||||
|
Teletype the transcript, printed a character at a time
|
||||||
|
CaveMap the remaster's map, generated from the graph
|
||||||
|
HuntScreen picks between them, so no screen needs to care
|
||||||
|
```
|
||||||
|
|
||||||
|
`engine.ts` takes a cave and a command and hands back the cave that follows,
|
||||||
|
plus the lines the machine would have printed. Both skins render those lines;
|
||||||
|
neither one decides anything. That is what makes it possible to run five
|
||||||
|
hundred hunts in a test and check that a hunter walking blind dies almost every
|
||||||
|
time.
|
||||||
|
|
||||||
|
### The cave, and proving it is one
|
||||||
|
|
||||||
|
The adjacency table is written out by hand rather than generated, because a
|
||||||
|
generated dodecahedron comes out with an arbitrary numbering and this one is
|
||||||
|
the numbering every listing has used since 1973. Written out means it can be
|
||||||
|
typed wrong, and a wrong table would not crash — it would quietly stop being a
|
||||||
|
dodecahedron while the game still played. So `cave.test.ts` proves it: twenty
|
||||||
|
rooms, three tunnels each, symmetric, connected, diameter exactly five, and
|
||||||
|
every room on three pentagons.
|
||||||
|
|
||||||
|
### The map, and what it is not allowed to know
|
||||||
|
|
||||||
|
The remaster must not be an easier game than the transcript. `CaveMap` may only
|
||||||
|
draw what has already been printed: it reads the rooms the hunter has stood in
|
||||||
|
and the warnings those rooms gave, and it marks the *suspects* — never the
|
||||||
|
answer. The reveal on the report screen is the one exception, and the hunt is
|
||||||
|
over by then.
|
||||||
|
|
||||||
|
That guarantee is asserted in `engine.test.ts` rather than in the component,
|
||||||
|
because it is a property of the engine and would still have to hold if the map
|
||||||
|
were thrown away tomorrow.
|
||||||
|
|
||||||
|
### Drawing a solid on paper
|
||||||
|
|
||||||
|
A dodecahedron cannot be drawn flat without lying about something, so the map
|
||||||
|
is its Schlegel diagram: one face pushed out to become the rim, the other
|
||||||
|
eleven nested inside. Rings of 5, 10 and 5 — and the middle one is the part
|
||||||
|
that is easy to get wrong, because rooms 6 to 15 are not two pentagons at
|
||||||
|
different depths but a single ten-room ring alternating between the rooms wired
|
||||||
|
inward and the rooms wired outward. Getting that wrong still draws, still
|
||||||
|
scales, still looks broadly cave-shaped, and is much harder to read. So the
|
||||||
|
test checks the property that actually matters: no tunnel crosses another.
|
||||||
|
|
||||||
|
## Sound
|
||||||
|
|
||||||
|
Audio starts on the first gesture, as browsers require.
|
||||||
|
|
||||||
|
The 1973 skin has one instrument that is not a lie: the print head. An ASR-33
|
||||||
|
printed ten characters a second and you could hear every one of them, so the
|
||||||
|
transcript is typed rather than shown, and every character is a solenoid thump
|
||||||
|
with a little bandpassed clatter on it.
|
||||||
|
|
||||||
|
The dial-in is a real Bell 103 call, in order and at the real frequencies:
|
||||||
|
dial tone at 350 + 440 Hz, Touch-Tone dialling off the DTMF grid, ringback at
|
||||||
|
440 + 480, the far modem putting up its 2225 Hz mark tone, and then both ends
|
||||||
|
talking at once — the originating coupler keying 1070 and 1270 Hz while the
|
||||||
|
answering machine keys 2025 and 2225, which is full duplex and is where most of
|
||||||
|
the texture comes from. All of it through a 300–3400 Hz telephone band with a
|
||||||
|
resonance around 900 Hz for the two rubber cups.
|
||||||
|
|
||||||
|
Worth being clear that this is *not* the sound most people mean by "modem".
|
||||||
|
That one is a V.32 or V.34 handshake from the 1990s — the ascending probe tones
|
||||||
|
and the long grinding echo-cancel training — and none of it existed in 1973 or
|
||||||
|
could have. A Model 33 at 110 baud through an acoustic coupler is Bell 103, and
|
||||||
|
Bell 103 is a much quieter animal. The boot prints each stage as it happens,
|
||||||
|
off the same schedule the audio is built from, and can be skipped.
|
||||||
|
|
||||||
|
The music is the one thing the period skin claims that the period did not have.
|
||||||
|
The real game was silent. What plays instead is the music it would have got
|
||||||
|
when it followed everybody home: two pulse voices, a triangle bass, filtered
|
||||||
|
noise for a kit, and a minor key, which is roughly what a 1981 home-computer
|
||||||
|
port of a cave game sounded like.
|
||||||
|
|
||||||
|
The remaster gets a different band, and deliberately not the lemonade stand's
|
||||||
|
funk. Same engine, other end of the scale: D phrygian, no shuffle, a sub bass
|
||||||
|
that sits on the root and refuses to move, a resonant filter sweep on a
|
||||||
|
detuned saw, and toms instead of a backbeat — a snare on two and four would
|
||||||
|
make it music to walk to, and nothing down there walks in time.
|
||||||
|
|
||||||
|
Both run off one scheduler that queues notes 200 ms ahead, so the groove does
|
||||||
|
not stutter when React re-renders. `MUSIC` and `SOUND` toggle independently.
|
||||||
|
|
||||||
|
## Provenance
|
||||||
|
|
||||||
|
The rules are Yob's and are free to use. The sentences he wrote to explain them
|
||||||
|
are not, and none of them are here — every line this game prints was written
|
||||||
|
for it. [NOTICE.md](NOTICE.md) sets out what is ours, what is not, and why.
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#2a2135" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Hunt the Wumpus - a browser recreation of Gregory Yob's 1973 original, on a Teletype or in a cel-shaded cave."
|
||||||
|
/>
|
||||||
|
<title>Hunt the Wumpus</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2928
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "hunt-the-wumpus",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.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 recreation of Gregory Yob's 1973 Hunt the Wumpus, with a cel-shaded remaster.",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"author": "John Coffey"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- The wumpus, at favicon size: all it can afford is a silhouette, two
|
||||||
|
eyes and the teeth. Drawn in code like everything else here. -->
|
||||||
|
<rect width="64" height="64" rx="12" fill="#2a2135"/>
|
||||||
|
<path d="M14 48 Q9 20 32 15 Q55 20 50 48 Z" fill="#9be36f" stroke="#16111f" stroke-width="3"/>
|
||||||
|
<circle cx="24" cy="30" r="4" fill="#16111f"/>
|
||||||
|
<circle cx="40" cy="30" r="4" fill="#16111f"/>
|
||||||
|
<path d="M20 40 L25 47 L30 40 L35 47 L40 40 L45 47" fill="none" stroke="#16111f" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 592 B |
+681
@@ -0,0 +1,681 @@
|
|||||||
|
/* =====================================================================
|
||||||
|
Two skins, one game. Layout is shared; every colour and every piece of
|
||||||
|
chrome comes from a token that the skin swaps.
|
||||||
|
|
||||||
|
1973 is a Teletype Model 33: black ink on fanfold paper, one weight, one
|
||||||
|
family, no colour at all. Anything that wants to be emphatic has to do it
|
||||||
|
with capitals or spacing, which is exactly the constraint the original was
|
||||||
|
written under.
|
||||||
|
|
||||||
|
The remaster is underground: warm lamplight in a cold cave, flat cel fills
|
||||||
|
and one heavy outline on everything.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* --- 1973: paper ---------------------------------------------------- */
|
||||||
|
--ink: #241f1a;
|
||||||
|
--ink-dim: #6b6157;
|
||||||
|
--ink-bright: #0d0b09;
|
||||||
|
--accent: #241f1a;
|
||||||
|
--warn: #241f1a;
|
||||||
|
--outline: #b6a992;
|
||||||
|
--screen-bg: #e8e0cc;
|
||||||
|
--screen-radius: 2px;
|
||||||
|
--paper-edge: #d6cbb2;
|
||||||
|
--glow: none;
|
||||||
|
--btn-radius: 0;
|
||||||
|
--btn-shadow: none;
|
||||||
|
--btn-bg: #f3ecd9;
|
||||||
|
--btn-fg: #241f1a;
|
||||||
|
--field-bg: #f7f1e0;
|
||||||
|
--field-fg: #241f1a;
|
||||||
|
--map-radius: 0;
|
||||||
|
--body-font: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
|
||||||
|
--line-height: 1.42;
|
||||||
|
--letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
/* Cave map tokens, unused by the teletype but defined so nothing is
|
||||||
|
ever undefined when the skin flips mid-render. */
|
||||||
|
--rock: #2a2135;
|
||||||
|
--room-fill: #3d3350;
|
||||||
|
--room-line: #16111f;
|
||||||
|
--room-here: #ffd93d;
|
||||||
|
--room-safe: #7be495;
|
||||||
|
--room-suspect: #ff9f4a;
|
||||||
|
--tunnel: #4a3f5e;
|
||||||
|
--tunnel-lit: #ffd08a;
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] {
|
||||||
|
/* --- now: the cave -------------------------------------------------- */
|
||||||
|
--ink: #f4ece0;
|
||||||
|
--ink-dim: #a596b8;
|
||||||
|
--ink-bright: #ffffff;
|
||||||
|
--accent: #ffd93d;
|
||||||
|
--warn: #ff7a6b;
|
||||||
|
--outline: #16111f;
|
||||||
|
--screen-bg: linear-gradient(175deg, #3a2f4c 0%, #2a2135 50%, #1b1524 100%);
|
||||||
|
--screen-radius: 26px;
|
||||||
|
--paper-edge: transparent;
|
||||||
|
--glow: 0 0 18px rgba(255, 208, 138, 0.25);
|
||||||
|
--btn-radius: 999px;
|
||||||
|
--btn-shadow: 4px 4px 0 var(--outline);
|
||||||
|
--btn-bg: #f4ece0;
|
||||||
|
--btn-fg: #16111f;
|
||||||
|
--field-bg: #f4ece0;
|
||||||
|
--field-fg: #16111f;
|
||||||
|
--map-radius: 18px;
|
||||||
|
--body-font: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;
|
||||||
|
--line-height: 1.5;
|
||||||
|
--letter-spacing: 0;
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Layout
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinet {
|
||||||
|
width: min(980px, 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bezel {
|
||||||
|
position: relative;
|
||||||
|
padding: clamp(10px, 2vw, 20px);
|
||||||
|
border-radius: calc(var(--screen-radius) + 10px);
|
||||||
|
background: #cfc4ab;
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .bezel {
|
||||||
|
background: linear-gradient(160deg, #241c30 0%, #150f1e 100%);
|
||||||
|
border: 4px solid var(--outline);
|
||||||
|
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The platen: the roller the paper comes over, with the feed knob at the end. */
|
||||||
|
.platen {
|
||||||
|
height: 16px;
|
||||||
|
margin: -4px 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, #4a4038 0%, #2b241e 60%, #1a1512 100%);
|
||||||
|
box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platen::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -10px;
|
||||||
|
top: -5px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 30%, #6b5c4d, #241f1a 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--screen-radius);
|
||||||
|
background: var(--screen-bg);
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* A phone is taller than it is wide; a fixed 4:3 wastes most of it. */
|
||||||
|
.screen {
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: clamp(12px, 2.6vw, 26px);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--body-font);
|
||||||
|
font-size: clamp(11px, 1.55vw, 15px);
|
||||||
|
line-height: var(--line-height);
|
||||||
|
letter-spacing: var(--letter-spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fit shrinks rather than clipping; see Frame.tsx. */
|
||||||
|
.screen-body > * {
|
||||||
|
transform: scale(var(--fit, 1));
|
||||||
|
transform-origin: top center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 72ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .stack {
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dim {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On paper there is no colour, so emphasis is weight and spacing only. */
|
||||||
|
:root:not([data-skin='modern']) .accent,
|
||||||
|
:root:not([data-skin='modern']) .warn {
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- paper chrome ---------------------------------------------------- */
|
||||||
|
|
||||||
|
.sprockets {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 22px;
|
||||||
|
background-image: radial-gradient(circle at 50% 10px, #b6a992 3.5px, transparent 4px);
|
||||||
|
background-size: 22px 26px;
|
||||||
|
background-color: var(--paper-edge);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprockets-left {
|
||||||
|
left: 0;
|
||||||
|
border-right: 1px dashed #c3b79e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sprockets-right {
|
||||||
|
right: 0;
|
||||||
|
border-left: 1px dashed #c3b79e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper is not flat and not one colour; a faint fibre keeps it from reading
|
||||||
|
as a beige rectangle. */
|
||||||
|
.paper-grain {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.35;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025) 0 1px, transparent 1px 3px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.02) 0 1px, transparent 1px 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .screen-inner {
|
||||||
|
padding-left: 34px;
|
||||||
|
padding-right: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- cave chrome ------------------------------------------------------ */
|
||||||
|
|
||||||
|
.lamp {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(60% 50% at 50% 42%, rgba(255, 208, 138, 0.16), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vignette {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: inset 0 0 120px 40px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
The transcript
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.printed {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .paper {
|
||||||
|
max-height: 9em;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.92em;
|
||||||
|
border-top: 2px solid rgba(244, 236, 224, 0.12);
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The print head: a block where the next character is about to land. */
|
||||||
|
.head {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.62em;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 1px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
The map
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
.map-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cave-map {
|
||||||
|
width: min(100%, 46vh);
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--map-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel {
|
||||||
|
stroke: var(--tunnel);
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-walked {
|
||||||
|
stroke: #6a5c82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tunnel-lit {
|
||||||
|
stroke: var(--tunnel-lit);
|
||||||
|
stroke-width: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aim {
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 3.5;
|
||||||
|
stroke-dasharray: 7 6;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-disc {
|
||||||
|
fill: var(--room-fill);
|
||||||
|
stroke: var(--room-line);
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-label {
|
||||||
|
fill: var(--ink-dim);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-anchor: middle;
|
||||||
|
font-family: var(--body-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-visited .room-disc {
|
||||||
|
fill: #4b5f4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-open .room-disc {
|
||||||
|
fill: #5b4b72;
|
||||||
|
stroke: var(--tunnel-lit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-suspect .room-disc {
|
||||||
|
fill: #6b4630;
|
||||||
|
stroke: var(--room-suspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-here .room-disc {
|
||||||
|
fill: var(--room-here);
|
||||||
|
stroke: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-here .room-label {
|
||||||
|
fill: var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-revealed .room-disc {
|
||||||
|
fill: #6b3040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-visited .room-label,
|
||||||
|
.room-open .room-label {
|
||||||
|
fill: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark path {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-wumpus path {
|
||||||
|
fill: var(--warn);
|
||||||
|
stroke: var(--outline);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-pit path {
|
||||||
|
stroke: #8fd0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-bats path {
|
||||||
|
stroke: #d7a6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth path,
|
||||||
|
.truth ellipse {
|
||||||
|
stroke: var(--outline);
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-wumpus path {
|
||||||
|
fill: #9be36f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-wumpus .eye {
|
||||||
|
fill: var(--outline);
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-wumpus .teeth {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--outline);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-pit ellipse {
|
||||||
|
fill: #120d1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-pit .deeper {
|
||||||
|
fill: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-bats path {
|
||||||
|
fill: #c58cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-hat {
|
||||||
|
fill: #ff7a6b;
|
||||||
|
stroke: var(--outline);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-crown {
|
||||||
|
fill: #ffd93d;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The map is the remaster's, and the paper skin never draws one. The reveal
|
||||||
|
on the report screen is the exception, and it is drawn in ink. */
|
||||||
|
:root:not([data-skin='modern']) .map-reveal .cave-map {
|
||||||
|
filter: grayscale(1) contrast(1.35) brightness(1.15);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .map-reveal .room-disc {
|
||||||
|
fill: #e8e0cc;
|
||||||
|
stroke: #241f1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .map-reveal .tunnel {
|
||||||
|
stroke: #8c8272;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
The prompt
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-q {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-sep {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-note {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.85em;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 18px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--outline);
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar .senses {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--warn);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--body-font);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: clamp(1.4rem, 5vw, 2.6rem);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--ink-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .banner {
|
||||||
|
color: var(--accent);
|
||||||
|
-webkit-text-stroke: 3px var(--outline);
|
||||||
|
paint-order: stroke fill;
|
||||||
|
text-shadow: 5px 5px 0 var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
min-width: 10ch;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 22ch;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 2px solid var(--outline);
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
background: var(--field-bg);
|
||||||
|
color: var(--field-fg);
|
||||||
|
font: inherit;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Buttons
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 2px solid var(--outline);
|
||||||
|
border-radius: var(--btn-radius);
|
||||||
|
background: var(--btn-bg);
|
||||||
|
color: var(--btn-fg);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--btn-shadow);
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition:
|
||||||
|
transform 110ms ease,
|
||||||
|
box-shadow 110ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .btn:hover:not(:disabled) {
|
||||||
|
transform: translate(-2px, -2px);
|
||||||
|
box-shadow: 6px 6px 0 var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] .btn:active:not(:disabled) {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0 var(--outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .btn-primary {
|
||||||
|
background: #241f1a;
|
||||||
|
color: #f3ecd9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 8px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-family: var(--body-font);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seed {
|
||||||
|
margin-left: auto;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The stack is a column, so a button in it stretches to the full width unless
|
||||||
|
it is told not to. A button that wide reads as a banner rather than a
|
||||||
|
control -- and on the board screen it was wider than the board. */
|
||||||
|
.stack > .btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hunter is two rings around the room, so the number stays legible. */
|
||||||
|
.hunter-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--room-here);
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hunter-ring-outer {
|
||||||
|
stroke-width: 1.5;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
The footer
|
||||||
|
|
||||||
|
It sits outside the frame, on the page rather than on the paper -- so it
|
||||||
|
cannot use the ink tokens, which are chosen to be dark on a light sheet.
|
||||||
|
On the teletype skin that made the hovered control the same colour as the
|
||||||
|
room it is standing in, and the skin toggle disappeared entirely.
|
||||||
|
===================================================================== */
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .controls {
|
||||||
|
color: #9a9186;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .controls .btn-ghost {
|
||||||
|
color: #9a9186;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root:not([data-skin='modern']) .controls .btn-ghost:hover:not(:disabled) {
|
||||||
|
color: #f0e8d4;
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, useReducer } from 'react'
|
||||||
|
import { DREAD_END, DREAD_HUNT, DREAD_TITLE } from './audio/dread'
|
||||||
|
import { END_TUNE, HUNT_TUNE, TITLE_TUNE } from './audio/tunes'
|
||||||
|
import { synth } from './audio/synth'
|
||||||
|
import { NO_TUNNEL_TEXT } from './game/constants'
|
||||||
|
import { describe, layCave, randomSeed, step } from './game/engine'
|
||||||
|
import { makeRng, type Rng } from './game/rng'
|
||||||
|
import { fetchScores, submitScores, type Score } from './game/highscores'
|
||||||
|
import {
|
||||||
|
currentHunter,
|
||||||
|
depthFor,
|
||||||
|
initialState,
|
||||||
|
livingHunters,
|
||||||
|
reducer,
|
||||||
|
} from './game/reducer'
|
||||||
|
import type { Command, Line } from './game/types'
|
||||||
|
import { useSkin } from './skin'
|
||||||
|
import { Btn, Fit, Frame } from './components/Frame'
|
||||||
|
import { HuntScreen } from './components/HuntScreen'
|
||||||
|
import {
|
||||||
|
BootScreen,
|
||||||
|
GameOverScreen,
|
||||||
|
HighScoreScreen,
|
||||||
|
InstructionsScreen,
|
||||||
|
ReportScreen,
|
||||||
|
SetupScreen,
|
||||||
|
TitleScreen,
|
||||||
|
type BoardState,
|
||||||
|
} from './components/screens'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AGPL section 13: anyone playing this over a network is entitled to the
|
||||||
|
* source of the version they are playing, so the offer sits on every screen.
|
||||||
|
*/
|
||||||
|
const SOURCE_URL = 'https://github.com/Coffey-Labs/wumpus'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [seed] = useState(randomSeed)
|
||||||
|
const [state, dispatch] = useReducer(reducer, seed, initialState)
|
||||||
|
const rng = useRef<Rng>(makeRng(seed))
|
||||||
|
const [music, setMusic] = useState(true)
|
||||||
|
const [sfx, setSfx] = useState(true)
|
||||||
|
const started = useRef(false)
|
||||||
|
const { skin, toggle: toggleSkin } = useSkin()
|
||||||
|
const [scores, setScores] = useState<Score[]>([])
|
||||||
|
const [boardState, setBoardState] = useState<BoardState>('loading')
|
||||||
|
const [freshScores, setFreshScores] = useState<string[]>([])
|
||||||
|
/** True while the teletype is mid-line, so no prompt appears over it. */
|
||||||
|
const [printing, setPrinting] = useState(false)
|
||||||
|
|
||||||
|
const who = currentHunter(state)
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ audio glue
|
||||||
|
|
||||||
|
const wake = useCallback(() => {
|
||||||
|
if (started.current) return
|
||||||
|
started.current = true
|
||||||
|
synth.ensure()
|
||||||
|
synth.setMusic(music)
|
||||||
|
synth.setSfx(sfx)
|
||||||
|
}, [music, sfx])
|
||||||
|
|
||||||
|
// 1973 gets the chiptune it never had; the remaster gets the other band.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!started.current) return
|
||||||
|
const front =
|
||||||
|
state.phase === 'title' || state.phase === 'instructions' || state.phase === 'setup'
|
||||||
|
const set =
|
||||||
|
skin === 'modern'
|
||||||
|
? { title: DREAD_TITLE, hunt: DREAD_HUNT, end: DREAD_END }
|
||||||
|
: { title: TITLE_TUNE, hunt: HUNT_TUNE, end: END_TUNE }
|
||||||
|
const tune = state.phase === 'gameover' ? set.end : front ? set.title : set.hunt
|
||||||
|
synth.playTune(tune, false)
|
||||||
|
}, [state.phase, skin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
synth.setMusic(music)
|
||||||
|
}, [music])
|
||||||
|
useEffect(() => {
|
||||||
|
synth.setSfx(sfx)
|
||||||
|
}, [sfx])
|
||||||
|
|
||||||
|
const loadBoard = useCallback(async () => {
|
||||||
|
setBoardState('loading')
|
||||||
|
try {
|
||||||
|
setScores(await fetchScores())
|
||||||
|
setBoardState('ready')
|
||||||
|
} catch {
|
||||||
|
setBoardState('error')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const blip = useCallback(() => synth.blip(), [])
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- turns
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lay a cave for whoever is up and print the first room.
|
||||||
|
*
|
||||||
|
* A cave is dealt off the same seeded stream as everything else, which is
|
||||||
|
* what makes a whole expedition -- four hunters, a dozen caves -- replayable
|
||||||
|
* from one number in the footer.
|
||||||
|
*/
|
||||||
|
const enterCave = useCallback((depth: number, arrows: number) => {
|
||||||
|
const cave = layCave(depth, arrows, rng.current)
|
||||||
|
const lines: Line[] = [
|
||||||
|
{ kind: 'system', text: `HUNT THE WUMPUS -- CAVE ${depth}` },
|
||||||
|
...describe(cave),
|
||||||
|
]
|
||||||
|
dispatch({ type: 'ENTER_CAVE', cave, lines })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Whenever it is somebody's turn and there is no cave, deal them one.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase !== 'hunting' || state.cave || !who) return
|
||||||
|
enterCave(depthFor(who), who.arrows)
|
||||||
|
}, [state.phase, state.cave, who, enterCave])
|
||||||
|
|
||||||
|
const command = (c: Command) => {
|
||||||
|
if (!state.cave) return
|
||||||
|
const { cave, lines } = step(state.cave, c, rng.current)
|
||||||
|
|
||||||
|
// The sound belongs to what happened, so it is chosen from the lines the
|
||||||
|
// engine produced rather than guessed at from the command.
|
||||||
|
if (c.type === 'shoot') synth.arrow()
|
||||||
|
else if (lines.some((l) => l.kind === 'system' && l.text === NO_TUNNEL_TEXT)) synth.reject()
|
||||||
|
else synth.walk()
|
||||||
|
|
||||||
|
if (lines.some((l) => l.kind === 'bats')) synth.bats()
|
||||||
|
if (cave.outcome === 'fell-in-a-pit') synth.pit()
|
||||||
|
if (cave.outcome === 'eaten') synth.wumpus()
|
||||||
|
if (cave.outcome === 'shot-the-wumpus') synth.fanfare()
|
||||||
|
if (cave.outcome === 'shot-yourself' || cave.outcome === 'out-of-arrows') synth.sad()
|
||||||
|
|
||||||
|
// A room that refused the move prints its refusal and nothing else.
|
||||||
|
const next = cave.outcome
|
||||||
|
? { cave, lines }
|
||||||
|
: { cave, lines: c.type === 'move' && cave.you !== state.cave.you ? [...lines, ...describe(cave)] : lines }
|
||||||
|
|
||||||
|
dispatch({ type: 'ADVANCE', cave: next.cave, lines: next.lines })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The report waits for the teletype to finish saying how it ended.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase !== 'resolve' || printing) return
|
||||||
|
const t = window.setTimeout(() => dispatch({ type: 'SHOW_REPORT' }), 500)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [state.phase, printing])
|
||||||
|
|
||||||
|
const start = (names: string[]) => {
|
||||||
|
wake()
|
||||||
|
synth.select()
|
||||||
|
dispatch({ type: 'START', names })
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTurn = () => {
|
||||||
|
synth.select()
|
||||||
|
dispatch({ type: 'NEXT_TURN' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the books. The standings show straight away; posting to the board
|
||||||
|
* happens behind them, so a slow or missing line out never holds up the end
|
||||||
|
* of an expedition.
|
||||||
|
*/
|
||||||
|
const post = useCallback(
|
||||||
|
(hunters: typeof state.hunters) => {
|
||||||
|
setBoardState('loading')
|
||||||
|
submitScores(
|
||||||
|
hunters.map((h) => ({
|
||||||
|
name: h.name,
|
||||||
|
bagged: h.bagged,
|
||||||
|
arrows: h.arrows,
|
||||||
|
roomsWalked: h.roomsWalked,
|
||||||
|
died: h.died,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.then(({ ids, scores: table }) => {
|
||||||
|
setScores(table)
|
||||||
|
setFreshScores(ids)
|
||||||
|
setBoardState('ready')
|
||||||
|
})
|
||||||
|
.catch(() => setBoardState('error'))
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const climbOut = () => {
|
||||||
|
synth.fanfare()
|
||||||
|
dispatch({ type: 'CLIMB_OUT' })
|
||||||
|
post(state.hunters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An expedition that ends because everybody died posts itself.
|
||||||
|
const posted = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.phase !== 'gameover' || posted.current) return
|
||||||
|
posted.current = true
|
||||||
|
if (!state.climbedOut) post(state.hunters)
|
||||||
|
}, [state.phase, state.climbedOut, state.hunters, post])
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
const s = randomSeed()
|
||||||
|
rng.current = makeRng(s)
|
||||||
|
posted.current = false
|
||||||
|
setFreshScores([])
|
||||||
|
dispatch({ type: 'RESTART', seed: s })
|
||||||
|
}
|
||||||
|
|
||||||
|
const showScores = () => {
|
||||||
|
wake()
|
||||||
|
synth.select()
|
||||||
|
dispatch({ type: 'SHOW_SCORES' })
|
||||||
|
if (boardState !== 'ready') void loadBoard()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- render
|
||||||
|
|
||||||
|
const moreToCome = livingHunters(state).some((h) => h.id !== who?.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app" onPointerDown={wake} onKeyDown={wake}>
|
||||||
|
<Frame
|
||||||
|
footer={
|
||||||
|
<div className="controls">
|
||||||
|
<Btn
|
||||||
|
kind="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
wake()
|
||||||
|
setMusic((m) => !m)
|
||||||
|
}}
|
||||||
|
title="Background music"
|
||||||
|
>
|
||||||
|
MUSIC {music ? 'ON' : 'OFF'}
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
kind="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
wake()
|
||||||
|
setSfx((s) => !s)
|
||||||
|
}}
|
||||||
|
title="Sound effects"
|
||||||
|
>
|
||||||
|
SOUND {sfx ? 'ON' : 'OFF'}
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
kind="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
wake()
|
||||||
|
synth.select()
|
||||||
|
toggleSkin()
|
||||||
|
}}
|
||||||
|
title={skin === 'teletype' ? 'Switch to the remaster' : 'Switch to the 1973 Teletype'}
|
||||||
|
>
|
||||||
|
{skin === 'teletype' ? 'REMASTER' : '1973'}
|
||||||
|
</Btn>
|
||||||
|
<Btn kind="ghost" onClick={restart} title="Abandon this expedition">
|
||||||
|
NEW HUNT
|
||||||
|
</Btn>
|
||||||
|
<a
|
||||||
|
className="btn btn-ghost"
|
||||||
|
href={SOURCE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
title="Free software, AGPL-3.0-or-later"
|
||||||
|
>
|
||||||
|
SOURCE
|
||||||
|
</a>
|
||||||
|
<span className="seed">SEED {state.seed}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{state.phase === 'boot' && <BootScreen onLoaded={() => dispatch({ type: 'BOOTED' })} />}
|
||||||
|
|
||||||
|
<Fit>
|
||||||
|
{state.phase === 'title' && (
|
||||||
|
<TitleGate
|
||||||
|
onStart={() => {
|
||||||
|
wake()
|
||||||
|
synth.select()
|
||||||
|
dispatch({ type: 'SHOW_SETUP' })
|
||||||
|
}}
|
||||||
|
onInstructions={() => {
|
||||||
|
wake()
|
||||||
|
synth.select()
|
||||||
|
dispatch({ type: 'SHOW_INSTRUCTIONS' })
|
||||||
|
}}
|
||||||
|
onScores={showScores}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'instructions' && (
|
||||||
|
<InstructionsScreen onDone={() => dispatch({ type: 'SHOW_SETUP' })} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'setup' && <SetupScreen onStart={start} onBlip={blip} />}
|
||||||
|
|
||||||
|
{(state.phase === 'hunting' || state.phase === 'resolve') && state.cave && who && (
|
||||||
|
<HuntScreen
|
||||||
|
key={`${who.id}-${state.cave.depth}`}
|
||||||
|
cave={state.cave}
|
||||||
|
transcript={state.transcript}
|
||||||
|
hunterName={who.name}
|
||||||
|
busy={printing}
|
||||||
|
onCommand={command}
|
||||||
|
onBlip={blip}
|
||||||
|
onReject={() => synth.reject()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'report' && state.cave && who && (
|
||||||
|
<ReportScreen
|
||||||
|
cave={state.cave}
|
||||||
|
hunter={who}
|
||||||
|
moreToCome={moreToCome}
|
||||||
|
onNext={nextTurn}
|
||||||
|
onClimbOut={climbOut}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'gameover' && (
|
||||||
|
<GameOverScreen
|
||||||
|
hunters={state.hunters}
|
||||||
|
climbedOut={state.climbedOut}
|
||||||
|
onRestart={restart}
|
||||||
|
onScores={showScores}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.phase === 'scores' && (
|
||||||
|
<HighScoreScreen
|
||||||
|
scores={scores}
|
||||||
|
state={boardState}
|
||||||
|
highlight={freshScores}
|
||||||
|
onBack={() => {
|
||||||
|
synth.select()
|
||||||
|
dispatch({ type: 'CLOSE_SCORES' })
|
||||||
|
}}
|
||||||
|
onRetry={() => {
|
||||||
|
synth.select()
|
||||||
|
void loadBoard()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fit>
|
||||||
|
</Frame>
|
||||||
|
{/* Kept out of the tree above so a re-render of the game cannot reset it. */}
|
||||||
|
<PrintingWatcher lines={state.transcript} onChange={setPrinting} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the machine is still typing.
|
||||||
|
*
|
||||||
|
* The prompt must not appear while a line is half printed -- the whole point
|
||||||
|
* of the teletype is that you read the room before you answer it. This
|
||||||
|
* watches the transcript rather than the printer so both skins agree, and so
|
||||||
|
* the remaster's map does not become a way to skip the reading.
|
||||||
|
*
|
||||||
|
* It times the characters that have just *arrived*, not the whole transcript.
|
||||||
|
* Timing the whole thing is the obvious way to write this and it is wrong in
|
||||||
|
* a way that takes a few turns to notice: `Teletype` only prints lines it has
|
||||||
|
* not printed yet, so the wait kept growing while the actual printing stayed
|
||||||
|
* the same length, and by the fourth room the prompt was gone for five
|
||||||
|
* seconds after a print that took one.
|
||||||
|
*/
|
||||||
|
function PrintingWatcher({
|
||||||
|
lines,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
lines: Line[]
|
||||||
|
onChange: (busy: boolean) => void
|
||||||
|
}) {
|
||||||
|
const chars = lines.reduce((n, l) => n + l.text.length, 0)
|
||||||
|
const printed = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// A new cave throws the transcript away; nothing is owed from the old one.
|
||||||
|
if (chars < printed.current) printed.current = 0
|
||||||
|
|
||||||
|
const fresh = chars - printed.current
|
||||||
|
printed.current = chars
|
||||||
|
if (fresh <= 0) {
|
||||||
|
onChange(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(true)
|
||||||
|
// 42 characters a second, plus a beat to read the last line.
|
||||||
|
const t = window.setTimeout(() => onChange(false), (fresh / 42) * 1000 + 120)
|
||||||
|
return () => window.clearTimeout(t)
|
||||||
|
}, [chars, onChange])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enter or Space starts the hunt, the way a terminal would take it. */
|
||||||
|
function TitleGate(props: {
|
||||||
|
onStart: () => void
|
||||||
|
onInstructions: () => void
|
||||||
|
onScores: () => void
|
||||||
|
}) {
|
||||||
|
const { onStart } = props
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') onStart()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onStart])
|
||||||
|
return <TitleScreen {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { SKIN_KEY, SkinContext, readSkin, writeSkin, type Skin, type SkinApi } from './skin'
|
||||||
|
|
||||||
|
export function SkinProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [skin, setSkinState] = useState<Skin>(readSkin)
|
||||||
|
|
||||||
|
const setSkin = useCallback((s: Skin) => {
|
||||||
|
setSkinState(s)
|
||||||
|
writeSkin(s)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// The attribute lives on <html> so the page background can follow the skin.
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.skin = skin
|
||||||
|
}, [skin])
|
||||||
|
|
||||||
|
// Keep sibling tabs in the same browser in step.
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === SKIN_KEY || e.key === null) setSkinState(readSkin())
|
||||||
|
}
|
||||||
|
window.addEventListener('storage', onStorage)
|
||||||
|
return () => window.removeEventListener('storage', onStorage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo<SkinApi>(
|
||||||
|
() => ({ skin, setSkin, toggle: () => setSkin(skin === 'teletype' ? 'modern' : 'teletype') }),
|
||||||
|
[skin, setSkin],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <SkinContext value={value}>{children}</SkinContext>
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import type { Track, Tune } from './synth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remaster's band, and it is deliberately not the lemonade stand's.
|
||||||
|
*
|
||||||
|
* That one is a funk band on a hot street in daylight. This is underground:
|
||||||
|
* D phrygian, no shuffle at all, everything on the grid and slightly too
|
||||||
|
* slow. The engine is identical -- same voices, same kit, same scheduler --
|
||||||
|
* and every difference is in what is written for it.
|
||||||
|
*
|
||||||
|
* Three ideas, and nothing else, because the player is listening for a draft:
|
||||||
|
*
|
||||||
|
* - a sub bass that sits on the root and refuses to move, so the harmony
|
||||||
|
* never resolves anywhere;
|
||||||
|
* - a resonant filter sweep on a detuned saw, which is the sound of a
|
||||||
|
* space being bigger than you thought;
|
||||||
|
* - toms instead of a backbeat. A snare on two and four would make this
|
||||||
|
* music to walk to, and nothing down here walks in time.
|
||||||
|
*
|
||||||
|
* Sixteenths, sixteen to a bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bar = (...steps: string[]) => steps
|
||||||
|
|
||||||
|
const kick = (notes: string[]): Track => ({ wave: 'noise', gain: 0.46, notes })
|
||||||
|
const hats = (notes: string[]): Track => ({ wave: 'noise', gain: 0.09, notes })
|
||||||
|
|
||||||
|
/** Hunting. Two bars, almost empty, meant to be forgotten while you think. */
|
||||||
|
export const DREAD_HUNT: Tune = {
|
||||||
|
bpm: 76,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
tracks: [
|
||||||
|
// The drone. One note, held, and it is the room rather than the tune.
|
||||||
|
{
|
||||||
|
wave: 'saw',
|
||||||
|
gain: 0.13,
|
||||||
|
gate: 1,
|
||||||
|
detune: 7,
|
||||||
|
filter: { from: 260, to: 150, q: 3 },
|
||||||
|
notes: [
|
||||||
|
...bar('D1', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
...bar('D1', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
...bar('D1', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
...bar('A#0', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The sweep. Phrygian second, which is the interval that sounds wrong.
|
||||||
|
{
|
||||||
|
wave: 'saw',
|
||||||
|
gain: 0.07,
|
||||||
|
gate: 0.9,
|
||||||
|
filter: { from: 3400, to: 320, q: 11 },
|
||||||
|
notes: [
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'D3/A3', '=', '=', '=', '=', '=', '.', '.'),
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', '.', '.', 'D#3/A3', '=', '=', '=', '=', '=', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', 'D3/G3', '=', '=', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// A thin bell, six steps apart, so it never lines up with the bar.
|
||||||
|
{
|
||||||
|
wave: 'pulse12',
|
||||||
|
gain: 0.05,
|
||||||
|
gate: 0.5,
|
||||||
|
notes: [
|
||||||
|
...bar('D5', '.', '.', '.', '.', '.', 'A4', '.', '.', '.', '.', '.', 'F4', '.', '.', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kick([
|
||||||
|
...bar('K', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', 'K', '.', '.', '.'),
|
||||||
|
...bar('K', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
]),
|
||||||
|
hats([
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'O', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', 'H', '.'),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Title. The same cave with something moving in it. */
|
||||||
|
export const DREAD_TITLE: Tune = {
|
||||||
|
bpm: 84,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
wave: 'saw',
|
||||||
|
gain: 0.2,
|
||||||
|
gate: 0.62,
|
||||||
|
detune: 6,
|
||||||
|
filter: { from: 1500, to: 190, q: 8 },
|
||||||
|
notes: [
|
||||||
|
...bar('D1', '.', '.', 'D1', '.', '.', 'D2', '.', '.', '.', 'A#1', '.', '.', 'D1', '.', '.'),
|
||||||
|
...bar('D1', '.', '.', 'D1', '.', '.', 'D2', '.', 'F1', '.', '.', 'G1', '.', '.', '.', '.'),
|
||||||
|
...bar('C1', '.', '.', 'C1', '.', '.', 'C2', '.', '.', '.', 'G1', '.', '.', 'C1', '.', '.'),
|
||||||
|
...bar('A#0', '.', '.', 'A#0', '.', '.', 'A#1', '.', 'C1', '.', '.', 'D1', '.', '.', 'D1', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// Stabs in the gaps. Minor seconds, held just long enough to be unpleasant.
|
||||||
|
{
|
||||||
|
wave: 'pulse25',
|
||||||
|
gain: 0.075,
|
||||||
|
gate: 0.34,
|
||||||
|
filter: { from: 2800, to: 700, q: 10 },
|
||||||
|
notes: [
|
||||||
|
...bar('.', '.', 'D4/F4/A4', '.', '.', 'D4/F4/A4', '.', '.', '.', '.', '.', '.', 'D#4/F4/A4', '.', '.', '.'),
|
||||||
|
...bar('.', '.', 'D4/F4/A4', '.', '.', '.', '.', 'D4/G4/A#4', '.', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', 'C4/E4/G4', '.', '.', 'C4/E4/G4', '.', '.', '.', '.', '.', '.', 'C4/F4/G4', '.', '.', '.'),
|
||||||
|
...bar('.', '.', 'A#3/D4/F4', '.', '.', '.', 'A#3/D4/F4', '.', '.', 'C4/D4/F4', '.', '.', '.', '.', '.', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The lead only ever plays four notes, and always the same four.
|
||||||
|
{
|
||||||
|
wave: 'pulse12',
|
||||||
|
gain: 0.08,
|
||||||
|
gate: 0.8,
|
||||||
|
notes: [
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'A4', '.', 'A#4', '.', 'A4', '=', '.', '.'),
|
||||||
|
...bar('.', '.', '.', '.', 'F4', '.', 'D4', '=', '=', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'G4', '.', 'A4', '.', 'G4', '=', '.', '.'),
|
||||||
|
...bar('F4', '.', 'D#4', '.', 'D4', '=', '=', '=', '.', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kick([
|
||||||
|
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', '.', '.', 'K', '.', '.', '.'),
|
||||||
|
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
|
||||||
|
]),
|
||||||
|
// Toms, not a backbeat. Bandpassed noise reads as a floor tom well enough.
|
||||||
|
{ wave: 'noise', gain: 0.2, notes: [
|
||||||
|
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', 'S', '.'),
|
||||||
|
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
|
||||||
|
] },
|
||||||
|
hats([
|
||||||
|
...bar('.', '.', 'H', '.', '.', '.', 'H', '.', '.', '.', 'H', '.', '.', '.', 'O', '.'),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The standings. It stops stalking and just tolls. */
|
||||||
|
export const DREAD_END: Tune = {
|
||||||
|
bpm: 70,
|
||||||
|
stepsPerBeat: 4,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
wave: 'saw',
|
||||||
|
gain: 0.17,
|
||||||
|
gate: 1,
|
||||||
|
detune: 5,
|
||||||
|
filter: { from: 900, to: 180, q: 4 },
|
||||||
|
notes: [
|
||||||
|
...bar('D1', '=', '=', '=', '=', '=', '=', '=', 'A#0', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
...bar('C1', '=', '=', '=', '=', '=', '=', '=', 'D1', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'pulse25',
|
||||||
|
gain: 0.1,
|
||||||
|
gate: 0.85,
|
||||||
|
notes: [
|
||||||
|
...bar('D4', '=', '=', '=', '=', '=', '.', '.', 'F4', '=', '=', '=', '=', '=', '.', '.'),
|
||||||
|
...bar('E4', '=', '=', '=', '=', '=', '.', '.', 'D4', '=', '=', '=', '=', '=', '=', '='),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kick([...bar('K', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.')]),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
/**
|
||||||
|
* A very small chiptune engine: pulse waves for voices, filtered white noise
|
||||||
|
* for percussion, and a look-ahead scheduler so patterns stay in time even
|
||||||
|
* when React is busy re-rendering.
|
||||||
|
*
|
||||||
|
* Shared with the lemonade stand, note for note -- same author, same licence,
|
||||||
|
* and a synth is not the part of a game worth writing twice. What is not
|
||||||
|
* shared is everything under "sfx" below: a cave needs wings, a fall and a
|
||||||
|
* growl, and it needs a Teletype.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dial-in, as a schedule rather than a number buried in the audio.
|
||||||
|
*
|
||||||
|
* The boot screen prints what is happening while it happens, so both have to
|
||||||
|
* agree about when each stage starts -- and the only way to keep two clocks in
|
||||||
|
* step is to have one clock. All values are seconds from the moment of DIAL.
|
||||||
|
*/
|
||||||
|
export const DIAL_UP = {
|
||||||
|
/** 5551973. The last four are the year, which is the only joke in here. */
|
||||||
|
number: [5, 5, 5, 1, 9, 7, 3],
|
||||||
|
dialTone: 0.62,
|
||||||
|
ringAt: 2.0,
|
||||||
|
ring: 1.0,
|
||||||
|
answerAt: 3.35,
|
||||||
|
answer: 0.95,
|
||||||
|
total: 5.5,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
this.musicBus = ctx.createGain()
|
||||||
|
this.musicBus.gain.value = 0.55
|
||||||
|
this.musicBus.connect(this.master)
|
||||||
|
|
||||||
|
this.sfxBus = ctx.createGain()
|
||||||
|
this.sfxBus.gain.value = 0.9
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blip() {
|
||||||
|
this.seq([['E5', 0.05]], 'pulse12', 0.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
select() {
|
||||||
|
this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room that will not take you: the listing just said NOT POSSIBLE. */
|
||||||
|
reject() {
|
||||||
|
this.seq([['A3', 0.09], ['E3', 0.16]], 'saw', 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Footsteps down a tunnel. Two dull knocks, the second lower. */
|
||||||
|
walk() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.01
|
||||||
|
for (const [i, f] of [190, 150].entries()) {
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'sine'
|
||||||
|
const t = at + i * 0.13
|
||||||
|
osc.frequency.setValueAtTime(f, t)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(f * 0.55, t + 0.1)
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.16, t)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.13)
|
||||||
|
osc.connect(env).connect(this.sfxBus)
|
||||||
|
osc.start(t)
|
||||||
|
osc.stop(t + 0.15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The arrow. A short noise burst swept downward through a bandpass, which
|
||||||
|
* is close enough to something crooked going past your ear in the dark.
|
||||||
|
*/
|
||||||
|
arrow() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.01
|
||||||
|
const src = ctx.createBufferSource()
|
||||||
|
src.buffer = this.noiseBuffer
|
||||||
|
const band = ctx.createBiquadFilter()
|
||||||
|
band.type = 'bandpass'
|
||||||
|
band.Q.value = 4
|
||||||
|
band.frequency.setValueAtTime(2600, at)
|
||||||
|
band.frequency.exponentialRampToValueAtTime(420, at + 0.42)
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.0001, at)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.3, at + 0.04)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.45)
|
||||||
|
src.connect(band).connect(env).connect(this.sfxBus)
|
||||||
|
src.start(at)
|
||||||
|
src.stop(at + 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wings. Filtered noise pulsed at wingbeat rate, rising as they carry you. */
|
||||||
|
bats() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.01
|
||||||
|
const src = ctx.createBufferSource()
|
||||||
|
src.buffer = this.noiseBuffer
|
||||||
|
src.loop = true
|
||||||
|
const band = ctx.createBiquadFilter()
|
||||||
|
band.type = 'bandpass'
|
||||||
|
band.Q.value = 1.6
|
||||||
|
band.frequency.setValueAtTime(900, at)
|
||||||
|
band.frequency.exponentialRampToValueAtTime(2600, at + 0.9)
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.0001, at)
|
||||||
|
// Twelve wingbeats a second, getting louder as they lift you.
|
||||||
|
for (let i = 0; i < 11; i++) {
|
||||||
|
const t = at + i * 0.082
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.05 + i * 0.018, t + 0.03)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.012, t + 0.075)
|
||||||
|
}
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.0)
|
||||||
|
src.connect(band).connect(env).connect(this.sfxBus)
|
||||||
|
src.start(at)
|
||||||
|
src.stop(at + 1.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pit. One long fall, no bottom, and then nothing. */
|
||||||
|
pit() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.01
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'triangle'
|
||||||
|
osc.frequency.setValueAtTime(660, at)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(34, at + 1.5)
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.0001, at)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.24, at + 0.06)
|
||||||
|
env.gain.setValueAtTime(0.24, at + 1.0)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.6)
|
||||||
|
osc.connect(env).connect(this.sfxBus)
|
||||||
|
osc.start(at)
|
||||||
|
osc.stop(at + 1.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wumpus, up close. A growl is a sawtooth an octave too low, wobbling. */
|
||||||
|
wumpus() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.01
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'sawtooth'
|
||||||
|
osc.frequency.setValueAtTime(78, at)
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(46, at + 1.1)
|
||||||
|
|
||||||
|
// The wobble is what makes it an animal rather than a chord.
|
||||||
|
const lfo = ctx.createOscillator()
|
||||||
|
lfo.type = 'sine'
|
||||||
|
lfo.frequency.value = 17
|
||||||
|
const depth = ctx.createGain()
|
||||||
|
depth.gain.value = 11
|
||||||
|
lfo.connect(depth).connect(osc.frequency)
|
||||||
|
|
||||||
|
const lp = ctx.createBiquadFilter()
|
||||||
|
lp.type = 'lowpass'
|
||||||
|
lp.Q.value = 5
|
||||||
|
lp.frequency.setValueAtTime(1500, at)
|
||||||
|
lp.frequency.exponentialRampToValueAtTime(280, at + 1.1)
|
||||||
|
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.0001, at)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.3, at + 0.09)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.2)
|
||||||
|
|
||||||
|
osc.connect(lp).connect(env).connect(this.sfxBus)
|
||||||
|
osc.start(at)
|
||||||
|
lfo.start(at)
|
||||||
|
osc.stop(at + 1.3)
|
||||||
|
lfo.stop(at + 1.3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kill. */
|
||||||
|
fanfare() {
|
||||||
|
this.seq(
|
||||||
|
[['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]],
|
||||||
|
'pulse25',
|
||||||
|
0.2,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sad() {
|
||||||
|
this.seq([['G4', 0.12], ['F#4', 0.12], ['F4', 0.12], ['E4', 0.4]], 'triangle', 0.22)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Teletype's key-click. */
|
||||||
|
beep() {
|
||||||
|
this.seq([['A5', 0.06]], 'pulse50', 0.14)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The print head.
|
||||||
|
*
|
||||||
|
* An ASR-33 printed ten characters a second and you could hear every one of
|
||||||
|
* them: a solenoid thump with a little bandpassed clatter on top. Called
|
||||||
|
* once per character while a line is being typed out, so the transcript
|
||||||
|
* sounds like it is being printed rather than appearing.
|
||||||
|
*/
|
||||||
|
print() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.001
|
||||||
|
|
||||||
|
const thump = ctx.createOscillator()
|
||||||
|
thump.type = 'square'
|
||||||
|
thump.frequency.setValueAtTime(320 + Math.random() * 90, at)
|
||||||
|
const tenv = ctx.createGain()
|
||||||
|
tenv.gain.setValueAtTime(0.05, at)
|
||||||
|
tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.02)
|
||||||
|
thump.connect(tenv).connect(this.sfxBus)
|
||||||
|
thump.start(at)
|
||||||
|
thump.stop(at + 0.03)
|
||||||
|
|
||||||
|
const clack = ctx.createBufferSource()
|
||||||
|
clack.buffer = this.noiseBuffer
|
||||||
|
clack.playbackRate.value = 0.8 + Math.random() * 0.5
|
||||||
|
const band = ctx.createBiquadFilter()
|
||||||
|
band.type = 'bandpass'
|
||||||
|
band.frequency.value = 2600
|
||||||
|
band.Q.value = 1.2
|
||||||
|
const cenv = ctx.createGain()
|
||||||
|
cenv.gain.setValueAtTime(0.06, at)
|
||||||
|
cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.018)
|
||||||
|
clack.connect(band).connect(cenv).connect(this.sfxBus)
|
||||||
|
clack.start(at)
|
||||||
|
clack.stop(at + 0.03)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carriage return and line feed: the head slams back, the platen turns. */
|
||||||
|
carriage() {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const at = ctx.currentTime + 0.005
|
||||||
|
const src = ctx.createBufferSource()
|
||||||
|
src.buffer = this.noiseBuffer
|
||||||
|
src.playbackRate.value = 0.55
|
||||||
|
const band = ctx.createBiquadFilter()
|
||||||
|
band.type = 'bandpass'
|
||||||
|
band.frequency.setValueAtTime(1500, at)
|
||||||
|
band.frequency.exponentialRampToValueAtTime(600, at + 0.13)
|
||||||
|
band.Q.value = 0.8
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.11, at)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.15)
|
||||||
|
src.connect(band).connect(env).connect(this.sfxBus)
|
||||||
|
src.start(at)
|
||||||
|
src.stop(at + 0.17)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialling in, properly.
|
||||||
|
*
|
||||||
|
* The sound everybody means by "modem" is a V.32/V.34 handshake from the
|
||||||
|
* 1990s -- the ascending probe tones and the long grinding echo-cancel
|
||||||
|
* training. None of that existed in 1973 and none of it could have: this
|
||||||
|
* game was played on a Model 33 at 110 baud through an acoustic coupler,
|
||||||
|
* two rubber cups you pushed a telephone handset into. What that sounded
|
||||||
|
* like is Bell 103, and Bell 103 is a much quieter animal.
|
||||||
|
*
|
||||||
|
* So this is the real sequence, in order, at the real frequencies:
|
||||||
|
*
|
||||||
|
* 1. Dial tone. North American precise tone plan: 350 Hz + 440 Hz.
|
||||||
|
* 2. Dialling. Touch-Tone, which existed from 1963 -- each digit is one
|
||||||
|
* row tone and one column tone from the DTMF grid, together.
|
||||||
|
* 3. Ringback. 440 Hz + 480 Hz, the same pair the far end hears as ring.
|
||||||
|
* 4. The answer tone. The far modem puts up its mark frequency, 2225 Hz,
|
||||||
|
* and holds it while the near end decides it has found a friend.
|
||||||
|
* 5. Data, both ways at once, which is the part people forget. Bell 103
|
||||||
|
* is full duplex on two separate channels: the originating end keys
|
||||||
|
* 1070 Hz for a space and 1270 Hz for a mark, the answering end 2025
|
||||||
|
* and 2225. Both are chattering simultaneously and the beating
|
||||||
|
* between them is most of the texture.
|
||||||
|
*
|
||||||
|
* Everything runs through a 300-3400 Hz telephone band, because that is all
|
||||||
|
* a phone line passes, with a peak around 900 Hz and a good deal of hiss for
|
||||||
|
* the two rubber cups and the room they are sitting in.
|
||||||
|
*/
|
||||||
|
dialUp(): () => void {
|
||||||
|
const ctx = this.ensure()
|
||||||
|
const t0 = ctx.currentTime + 0.03
|
||||||
|
const stop: { stop: (when?: number) => void }[] = []
|
||||||
|
|
||||||
|
// --- the telephone line itself ------------------------------------
|
||||||
|
const line = ctx.createGain()
|
||||||
|
line.gain.value = 1
|
||||||
|
const hp = ctx.createBiquadFilter()
|
||||||
|
hp.type = 'highpass'
|
||||||
|
hp.frequency.value = 300
|
||||||
|
const lp = ctx.createBiquadFilter()
|
||||||
|
lp.type = 'lowpass'
|
||||||
|
lp.frequency.value = 3400
|
||||||
|
// The handset in the cups: a resonance, not a clean channel.
|
||||||
|
const cup = ctx.createBiquadFilter()
|
||||||
|
cup.type = 'peaking'
|
||||||
|
cup.frequency.value = 900
|
||||||
|
cup.Q.value = 1.1
|
||||||
|
cup.gain.value = 5
|
||||||
|
line.connect(hp).connect(lp).connect(cup).connect(this.sfxBus)
|
||||||
|
|
||||||
|
/** A pure tone on the line for a fixed window. */
|
||||||
|
const tone = (freq: number, from: number, to: number, gain: number) => {
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.value = freq
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.0001, from)
|
||||||
|
env.gain.exponentialRampToValueAtTime(gain, from + 0.012)
|
||||||
|
env.gain.setValueAtTime(gain, to - 0.012)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, to)
|
||||||
|
osc.connect(env).connect(line)
|
||||||
|
osc.start(from)
|
||||||
|
osc.stop(to + 0.02)
|
||||||
|
stop.push(osc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 1. dial tone --------------------------------------------------
|
||||||
|
const DIAL_END = t0 + DIAL_UP.dialTone
|
||||||
|
tone(350, t0, DIAL_END, 0.075)
|
||||||
|
tone(440, t0, DIAL_END, 0.075)
|
||||||
|
|
||||||
|
// --- 2. Touch-Tone dialling ----------------------------------------
|
||||||
|
const ROW = [941, 697, 697, 697, 770, 770, 770, 852, 852, 852]
|
||||||
|
const COL = [1336, 1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336, 1477]
|
||||||
|
let at = DIAL_END + 0.12
|
||||||
|
for (const digit of DIAL_UP.number) {
|
||||||
|
const end = at + 0.09
|
||||||
|
tone(ROW[digit]!, at, end, 0.085)
|
||||||
|
tone(COL[digit]!, at, end, 0.085)
|
||||||
|
at = end + 0.055
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. ringback ---------------------------------------------------
|
||||||
|
const RING_START = t0 + DIAL_UP.ringAt
|
||||||
|
const RING_END = RING_START + DIAL_UP.ring
|
||||||
|
tone(440, RING_START, RING_END, 0.07)
|
||||||
|
tone(480, RING_START, RING_END, 0.07)
|
||||||
|
|
||||||
|
// --- 4. the far end answers ----------------------------------------
|
||||||
|
const ANSWER_START = t0 + DIAL_UP.answerAt
|
||||||
|
const ANSWER_END = ANSWER_START + DIAL_UP.answer
|
||||||
|
tone(2225, ANSWER_START, ANSWER_END, 0.055)
|
||||||
|
|
||||||
|
// --- 5. both ends talking at once ----------------------------------
|
||||||
|
/*
|
||||||
|
* One oscillator per direction, keyed bit by bit. 110 baud is 9.09 ms a
|
||||||
|
* bit, and the bits are random because what is actually going down the
|
||||||
|
* line at this point is a login banner nobody has typed yet -- scrambled
|
||||||
|
* to the ear either way.
|
||||||
|
*/
|
||||||
|
const DATA_START = ANSWER_END - 0.1
|
||||||
|
const DATA_END = t0 + DIAL_UP.total
|
||||||
|
const BIT = 1 / 110
|
||||||
|
|
||||||
|
const channel = (mark: number, space: number, gain: number) => {
|
||||||
|
const osc = ctx.createOscillator()
|
||||||
|
osc.type = 'sine'
|
||||||
|
osc.frequency.setValueAtTime(mark, DATA_START)
|
||||||
|
for (let t = DATA_START; t < DATA_END; t += BIT) {
|
||||||
|
osc.frequency.setValueAtTime(Math.random() < 0.5 ? mark : space, t)
|
||||||
|
}
|
||||||
|
const env = ctx.createGain()
|
||||||
|
env.gain.setValueAtTime(0.0001, DATA_START)
|
||||||
|
env.gain.exponentialRampToValueAtTime(gain, DATA_START + 0.05)
|
||||||
|
env.gain.setValueAtTime(gain, DATA_END - 0.08)
|
||||||
|
env.gain.exponentialRampToValueAtTime(0.0001, DATA_END)
|
||||||
|
osc.connect(env).connect(line)
|
||||||
|
osc.start(DATA_START)
|
||||||
|
osc.stop(DATA_END + 0.02)
|
||||||
|
stop.push(osc)
|
||||||
|
}
|
||||||
|
|
||||||
|
channel(1270, 1070, 0.05) // originating end: the coupler on this desk
|
||||||
|
channel(2225, 2025, 0.042) // answering end: the machine at the other
|
||||||
|
|
||||||
|
// --- line noise, under all of it -----------------------------------
|
||||||
|
const hiss = ctx.createBufferSource()
|
||||||
|
hiss.buffer = this.noiseBuffer
|
||||||
|
hiss.loop = true
|
||||||
|
const hissGain = ctx.createGain()
|
||||||
|
hissGain.gain.setValueAtTime(0.012, t0)
|
||||||
|
// The line gets noisier once it is carrying something.
|
||||||
|
hissGain.gain.setValueAtTime(0.012, DATA_START)
|
||||||
|
hissGain.gain.linearRampToValueAtTime(0.03, DATA_START + 0.2)
|
||||||
|
hissGain.gain.linearRampToValueAtTime(0.0001, DATA_END)
|
||||||
|
hiss.connect(hissGain).connect(line)
|
||||||
|
hiss.start(t0)
|
||||||
|
hiss.stop(DATA_END + 0.05)
|
||||||
|
stop.push(hiss)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const node of stop) {
|
||||||
|
try {
|
||||||
|
node.stop()
|
||||||
|
} catch {
|
||||||
|
// Already stopped; nothing to do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const synth = new Synth()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type { Tune } from './synth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The old-school music, and a word about what it is honestly claiming.
|
||||||
|
*
|
||||||
|
* Hunt the Wumpus was silent. It ran on a timesharing system through a
|
||||||
|
* Teletype, and a Teletype's only instrument is its own print head -- which
|
||||||
|
* this game does play, in `synth.print()`. So a chiptune here is not a
|
||||||
|
* restoration of anything.
|
||||||
|
*
|
||||||
|
* What it is instead: the music the game would have got when it followed
|
||||||
|
* everybody home. The listing spread through People's Computer Company and
|
||||||
|
* Creative Computing and was typed into every machine of the next decade, and
|
||||||
|
* a 1981 home-computer port of a cave game sounded roughly like this -- two
|
||||||
|
* pulse voices, a triangle bass, filtered noise for a kit, and a minor key
|
||||||
|
* because the room next door has something in it.
|
||||||
|
*
|
||||||
|
* Written in eighths, eight to a bar, in D natural minor.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bar = (...notes: string[]) => notes
|
||||||
|
|
||||||
|
/** Title. A stalking figure that keeps arriving back where it started. */
|
||||||
|
export const TITLE_TUNE: Tune = {
|
||||||
|
bpm: 116,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
wave: 'pulse25',
|
||||||
|
gain: 0.15,
|
||||||
|
notes: [
|
||||||
|
...bar('D4', '.', 'F4', '.', 'A4', '.', 'F4', '.'),
|
||||||
|
...bar('E4', '.', 'G4', '.', 'A#4', '=', '=', '.'),
|
||||||
|
...bar('C4', '.', 'E4', '.', 'G4', '.', 'E4', '.'),
|
||||||
|
...bar('D4', '=', '=', '.', 'A3', '.', 'D4', '.'),
|
||||||
|
...bar('F4', '.', 'A4', '.', 'D5', '.', 'A4', '.'),
|
||||||
|
...bar('G4', '.', 'A#4', '.', 'E5', '=', '=', '.'),
|
||||||
|
...bar('F5', '.', 'E5', '.', 'D5', '.', 'C5', '.'),
|
||||||
|
...bar('D5', '=', '=', '=', '=', '.', '.', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'pulse12',
|
||||||
|
gain: 0.06,
|
||||||
|
notes: [
|
||||||
|
...bar('.', '.', 'A5', '.', '.', '.', 'D5', '.'),
|
||||||
|
...bar('.', '.', 'A#5', '.', '.', '.', 'G5', '.'),
|
||||||
|
...bar('.', '.', 'G5', '.', '.', '.', 'C5', '.'),
|
||||||
|
...bar('.', '.', 'A5', '.', '.', '.', 'F5', '.'),
|
||||||
|
...bar('.', '.', 'D6', '.', '.', '.', 'A5', '.'),
|
||||||
|
...bar('.', '.', 'E6', '.', '.', '.', 'A#5', '.'),
|
||||||
|
...bar('A5', '.', 'G5', '.', 'F5', '.', 'E5', '.'),
|
||||||
|
...bar('D5', '=', '=', '.', '.', '.', '.', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
gain: 0.3,
|
||||||
|
notes: [
|
||||||
|
...bar('D2', '.', 'D3', '.', 'A2', '.', 'D3', '.'),
|
||||||
|
...bar('E2', '.', 'E3', '.', 'B2', '.', 'E3', '.'),
|
||||||
|
...bar('C2', '.', 'C3', '.', 'G2', '.', 'C3', '.'),
|
||||||
|
...bar('D2', '.', 'D3', '.', 'A2', '.', 'A2', '.'),
|
||||||
|
...bar('D2', '.', 'D3', '.', 'A2', '.', 'D3', '.'),
|
||||||
|
...bar('E2', '.', 'E3', '.', 'B2', '.', 'E3', '.'),
|
||||||
|
...bar('F2', '.', 'F3', '.', 'C3', '.', 'F3', '.'),
|
||||||
|
...bar('D2', '=', '=', '=', 'A2', '.', 'D2', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'noise',
|
||||||
|
gain: 0.14,
|
||||||
|
notes: [
|
||||||
|
...bar('K', '.', 'H', '.', 'S', '.', 'H', '.'),
|
||||||
|
...bar('K', '.', 'H', '.', 'S', '.', 'K', 'H'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walking music. Almost nothing: a bass on the beat and a hat every other
|
||||||
|
* bar, so the room has a pulse without arguing with the print head. The
|
||||||
|
* hunting screen is where somebody is actually thinking.
|
||||||
|
*/
|
||||||
|
export const HUNT_TUNE: Tune = {
|
||||||
|
bpm: 92,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
gain: 0.26,
|
||||||
|
notes: [
|
||||||
|
...bar('D2', '.', '.', '.', 'A2', '.', '.', '.'),
|
||||||
|
...bar('D2', '.', '.', '.', 'F2', '.', '.', '.'),
|
||||||
|
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
|
||||||
|
...bar('D2', '.', '.', '.', 'A2', '.', 'D2', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'pulse12',
|
||||||
|
gain: 0.05,
|
||||||
|
notes: [
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', 'A4', '.', '.', '.', 'F4', '.'),
|
||||||
|
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
|
||||||
|
...bar('.', '.', 'E4', '.', '.', '.', 'D4', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'noise',
|
||||||
|
gain: 0.07,
|
||||||
|
notes: [
|
||||||
|
...bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
|
||||||
|
...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays once behind the standings, whoever is left to read them. */
|
||||||
|
export const END_TUNE: Tune = {
|
||||||
|
bpm: 84,
|
||||||
|
stepsPerBeat: 2,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
wave: 'pulse25',
|
||||||
|
gain: 0.15,
|
||||||
|
notes: [
|
||||||
|
...bar('A4', '.', 'D5', '=', 'F5', '.', 'A5', '='),
|
||||||
|
...bar('G5', '.', 'F5', '=', 'D5', '=', '=', '.'),
|
||||||
|
...bar('C5', '.', 'E5', '=', 'G5', '.', 'C6', '='),
|
||||||
|
...bar('A5', '.', 'F5', '=', 'D5', '=', '=', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: 'triangle',
|
||||||
|
gain: 0.28,
|
||||||
|
notes: [
|
||||||
|
...bar('D2', '.', '.', '.', 'D3', '.', '.', '.'),
|
||||||
|
...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'),
|
||||||
|
...bar('C2', '.', '.', '.', 'C3', '.', '.', '.'),
|
||||||
|
...bar('D2', '.', '.', '.', 'A2', '.', 'D2', '.'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { EDGES, LAYOUT, ROOM_COUNT, tunnelsFrom } from '../game/cave'
|
||||||
|
import type { Cave, Hazard } from '../game/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remaster: the cave, drawn.
|
||||||
|
*
|
||||||
|
* Every mark here is generated from `LAYOUT` and `EDGES` -- there is no
|
||||||
|
* artwork file in this repository and there is not going to be one. Cel
|
||||||
|
* shading in practice means flat fills, one heavy outline, and a highlight
|
||||||
|
* that is a second flat fill rather than a gradient, which is a style that
|
||||||
|
* happens to suit a thing made entirely of `<circle>` and `<path>`.
|
||||||
|
*
|
||||||
|
* The hard rule is that this component may not show the player anything the
|
||||||
|
* teletype has not already printed. It takes `reveal` for the two screens
|
||||||
|
* that are allowed to lift that -- the death and the standings -- and
|
||||||
|
* everywhere else the wumpus, the pits and the bats are simply not in the
|
||||||
|
* output. A map that quietly knows more than the transcript would make the
|
||||||
|
* remaster an easier game than the original, and they are meant to be the
|
||||||
|
* same game.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const R = 210 // Unit radius; the viewBox is a little larger to hold labels.
|
||||||
|
const ROOM_R = 15
|
||||||
|
|
||||||
|
type Known = { hazards: Hazard[]; visited: boolean; adjacent: boolean }
|
||||||
|
|
||||||
|
export function CaveMap({
|
||||||
|
cave,
|
||||||
|
reveal = false,
|
||||||
|
aim = [],
|
||||||
|
}: {
|
||||||
|
cave: Cave
|
||||||
|
/** Show where everything actually was. Only the end of a hunt earns this. */
|
||||||
|
reveal?: boolean
|
||||||
|
/** Rooms in the arrow path being typed, so a shot can be seen before it flies. */
|
||||||
|
aim?: readonly number[]
|
||||||
|
}) {
|
||||||
|
const at = (room: number): [number, number] => {
|
||||||
|
const [x, y] = LAYOUT[room] ?? [0, 0]
|
||||||
|
return [x * R, y * R]
|
||||||
|
}
|
||||||
|
|
||||||
|
const near = tunnelsFrom(cave.you)
|
||||||
|
const known = new Map<number, Known>()
|
||||||
|
for (let room = 1; room <= ROOM_COUNT; room++) {
|
||||||
|
known.set(room, {
|
||||||
|
hazards: cave.sensed[room] ? [...cave.sensed[room]] : [],
|
||||||
|
visited: cave.visited.includes(room),
|
||||||
|
adjacent: near.includes(room),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a room is worth worrying about, from what has been printed only.
|
||||||
|
*
|
||||||
|
* A room the hunter has stood in is safe and is drawn so. A room next to a
|
||||||
|
* warning is suspect, and the map says which warning -- never which room
|
||||||
|
* the hazard is actually in, because nothing has told the hunter that.
|
||||||
|
*/
|
||||||
|
const suspicion = (room: number): Hazard[] => {
|
||||||
|
if (known.get(room)!.visited) return []
|
||||||
|
const out = new Set<Hazard>()
|
||||||
|
for (const from of cave.visited) {
|
||||||
|
if (!tunnelsFrom(from).includes(room)) continue
|
||||||
|
for (const h of cave.sensed[from] ?? []) out.add(h)
|
||||||
|
}
|
||||||
|
return [...out]
|
||||||
|
}
|
||||||
|
|
||||||
|
const aimPath = aim.length ? [cave.you, ...aim] : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="cave-map"
|
||||||
|
viewBox={`${-R - 34} ${-R - 34} ${(R + 34) * 2} ${(R + 34) * 2}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Cave map. You are in room ${cave.you}.`}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* The lamp. A radial fill is the one gradient the style allows,
|
||||||
|
because it is light rather than shading. */}
|
||||||
|
<radialGradient id="lamp" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#ffd97a" stopOpacity="0.30" />
|
||||||
|
<stop offset="60%" stopColor="#ffb347" stopOpacity="0.08" />
|
||||||
|
<stop offset="100%" stopColor="#ffb347" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Tunnels first, so the rooms sit on top of them. */}
|
||||||
|
<g className="tunnels">
|
||||||
|
{EDGES.map(([a, b]) => {
|
||||||
|
const [ax, ay] = at(a)
|
||||||
|
const [bx, by] = at(b)
|
||||||
|
const walked = cave.visited.includes(a) && cave.visited.includes(b)
|
||||||
|
const lit = a === cave.you || b === cave.you
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`${a}-${b}`}
|
||||||
|
x1={ax}
|
||||||
|
y1={ay}
|
||||||
|
x2={bx}
|
||||||
|
y2={by}
|
||||||
|
className={`tunnel${lit ? ' tunnel-lit' : walked ? ' tunnel-walked' : ''}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* The arrow being aimed, drawn over the tunnels it is going down. */}
|
||||||
|
{aimPath.length > 1 && (
|
||||||
|
<polyline
|
||||||
|
className="aim"
|
||||||
|
points={aimPath.map((r) => at(r).join(',')).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<circle cx={at(cave.you)[0]} cy={at(cave.you)[1]} r={96} fill="url(#lamp)" />
|
||||||
|
|
||||||
|
<g className="rooms">
|
||||||
|
{Array.from({ length: ROOM_COUNT }, (_, i) => i + 1).map((room) => {
|
||||||
|
const k = known.get(room)!
|
||||||
|
const worry = suspicion(room)
|
||||||
|
const here = room === cave.you
|
||||||
|
const shown = reveal
|
||||||
|
? [
|
||||||
|
...(cave.wumpus === room ? (['wumpus'] as Hazard[]) : []),
|
||||||
|
...(cave.pits.includes(room) ? (['pit'] as Hazard[]) : []),
|
||||||
|
...(cave.bats.includes(room) ? (['bats'] as Hazard[]) : []),
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
const [x, y] = at(room)
|
||||||
|
|
||||||
|
const cls = [
|
||||||
|
'room',
|
||||||
|
here && 'room-here',
|
||||||
|
k.visited && !here && 'room-visited',
|
||||||
|
k.adjacent && !here && 'room-open',
|
||||||
|
!reveal && worry.length > 0 && 'room-suspect',
|
||||||
|
reveal && shown.length > 0 && 'room-revealed',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={room} className={cls} transform={`translate(${x} ${y})`}>
|
||||||
|
<circle className="room-disc" r={ROOM_R} />
|
||||||
|
<text className="room-label" y={4}>
|
||||||
|
{room}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Warnings sit above the room as marks, never as the answer. */}
|
||||||
|
{!reveal && worry.length > 0 && (
|
||||||
|
<g className="marks" transform={`translate(0 ${-ROOM_R - 9})`}>
|
||||||
|
{worry.map((h, i) => (
|
||||||
|
<Mark key={h} hazard={h} x={(i - (worry.length - 1) / 2) * 15} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reveal &&
|
||||||
|
shown.map((h, i) => (
|
||||||
|
<g key={h} transform={`translate(${(i - (shown.length - 1) / 2) * 18} ${-ROOM_R - 12})`}>
|
||||||
|
<Truth hazard={h} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{here && <Hunter />}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A suspicion: the shape of a warning, not the thing itself. */
|
||||||
|
function Mark({ hazard, x }: { hazard: Hazard; x: number }) {
|
||||||
|
if (hazard === 'wumpus') {
|
||||||
|
// A nose. It is the smell that has been reported, so it is a nostril.
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} 0)`} className="mark mark-wumpus">
|
||||||
|
<path d="M-5 3 Q0 -6 5 3 Q0 6 -5 3 Z" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hazard === 'pit') {
|
||||||
|
// A draft. Three lines leaning the way the air is going.
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} 0)`} className="mark mark-pit">
|
||||||
|
<path d="M-6 -3 H4 M-6 0 H6 M-6 3 H3" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x} 0)`} className="mark mark-bats">
|
||||||
|
<path d="M-6 2 Q-3 -4 0 1 Q3 -4 6 2" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What was actually in the room, once the hunt is over and it can be told. */
|
||||||
|
function Truth({ hazard }: { hazard: Hazard }) {
|
||||||
|
if (hazard === 'wumpus') {
|
||||||
|
return (
|
||||||
|
<g className="truth truth-wumpus">
|
||||||
|
<path d="M-9 6 Q-11 -6 0 -8 Q11 -6 9 6 Z" />
|
||||||
|
<circle cx={-3.5} cy={-2} r={1.7} className="eye" />
|
||||||
|
<circle cx={3.5} cy={-2} r={1.7} className="eye" />
|
||||||
|
<path d="M-4 3 L-2 6 L0 3 L2 6 L4 3" className="teeth" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (hazard === 'pit') {
|
||||||
|
return (
|
||||||
|
<g className="truth truth-pit">
|
||||||
|
<ellipse rx={9} ry={5} />
|
||||||
|
<ellipse rx={5} ry={2.6} className="deeper" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<g className="truth truth-bats">
|
||||||
|
<path d="M-10 3 Q-6 -5 -2 1 Q0 -3 2 1 Q6 -5 10 3 Q5 0 0 4 Q-5 0 -10 3 Z" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hunter.
|
||||||
|
*
|
||||||
|
* Drawn as a ring around the room rather than a figure standing on it. A
|
||||||
|
* figure covered the room number, and the number is what the whole game is
|
||||||
|
* played in -- "TUNNELS LEAD TO 13 16 19" is useless if you cannot see which
|
||||||
|
* one you are standing in.
|
||||||
|
*/
|
||||||
|
function Hunter() {
|
||||||
|
return (
|
||||||
|
<g className="hunter">
|
||||||
|
<circle r={ROOM_R + 7} className="hunter-ring" />
|
||||||
|
<circle r={ROOM_R + 11} className="hunter-ring hunter-ring-outer" />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { useSkin } from '../skin'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thing the game is happening inside.
|
||||||
|
*
|
||||||
|
* Both skins get a frame, and the frames are not the same object: 1973 is a
|
||||||
|
* Teletype, so it is a sheet of fanfold paper coming out of a machine, with
|
||||||
|
* the sprocket holes down both edges and a platen at the top. The remaster is
|
||||||
|
* a cave, so it is a lit chamber with rock around it. Everything inside is
|
||||||
|
* laid out identically -- only the box changes.
|
||||||
|
*/
|
||||||
|
export function Frame({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||||
|
const { skin } = useSkin()
|
||||||
|
return (
|
||||||
|
<div className="cabinet">
|
||||||
|
<div className="bezel">
|
||||||
|
{skin === 'teletype' && <div className="platen" aria-hidden />}
|
||||||
|
<div className="screen">
|
||||||
|
<div className="screen-inner">{children}</div>
|
||||||
|
{skin === 'teletype' ? (
|
||||||
|
<>
|
||||||
|
<div className="sprockets sprockets-left" aria-hidden />
|
||||||
|
<div className="sprockets sprockets-right" aria-hidden />
|
||||||
|
<div className="paper-grain" aria-hidden />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="lamp" aria-hidden />
|
||||||
|
<div className="vignette" aria-hidden />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sheet of paper does not scroll and a cave mouth does not either. If the
|
||||||
|
* page comes out taller than the frame, shrink it rather than clipping it.
|
||||||
|
* Lifted from the lemonade stand, where the argument was about televisions.
|
||||||
|
*/
|
||||||
|
export function Fit({ children }: { children: ReactNode }) {
|
||||||
|
const box = useRef<HTMLDivElement>(null)
|
||||||
|
const [scale, setScale] = useState(1)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const measure = () => {
|
||||||
|
const el = box.current
|
||||||
|
const page = el?.firstElementChild as HTMLElement | null
|
||||||
|
if (!el || !page) return
|
||||||
|
// offsetHeight is the laid-out size and ignores the transform, so
|
||||||
|
// measuring here cannot feed back into itself.
|
||||||
|
const h = page.offsetHeight
|
||||||
|
const avail = el.clientHeight
|
||||||
|
setScale(h > 0 && avail > 0 ? Math.min(1, avail / h) : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
measure()
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
if (box.current) ro.observe(box.current)
|
||||||
|
if (box.current?.firstElementChild) ro.observe(box.current.firstElementChild)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen-body" ref={box} style={{ '--fit': scale } as React.CSSProperties}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Line({ children, className = '' }: { children?: ReactNode; className?: string }) {
|
||||||
|
return <div className={`line ${className}`}>{children ?? ' '}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Btn({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
kind = 'normal',
|
||||||
|
disabled,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
kind?: 'normal' | 'primary' | 'ghost'
|
||||||
|
disabled?: boolean
|
||||||
|
title?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button className={`btn btn-${kind}`} onClick={onClick} disabled={disabled} title={title}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { MAX_ARROW_PATH } from '../game/constants'
|
||||||
|
import { areConnected, tunnelsFrom } from '../game/cave'
|
||||||
|
import type { Cave, Command, Line } from '../game/types'
|
||||||
|
import { useSkin } from '../skin'
|
||||||
|
import { CaveMap } from './CaveMap'
|
||||||
|
import { Btn } from './Frame'
|
||||||
|
import { Teletype } from './Teletype'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen where the game is played, and the only one either skin shows
|
||||||
|
* differently in any way that matters.
|
||||||
|
*
|
||||||
|
* 1973 gets the transcript and a prompt. The remaster gets the map, the same
|
||||||
|
* transcript underneath it, and buttons for the rooms you can reach -- which
|
||||||
|
* is a convenience over typing a number, not information. The three tunnels
|
||||||
|
* out of a room are printed in both skins, because the listing printed them.
|
||||||
|
*/
|
||||||
|
export function HuntScreen({
|
||||||
|
cave,
|
||||||
|
transcript,
|
||||||
|
hunterName,
|
||||||
|
busy,
|
||||||
|
onCommand,
|
||||||
|
onBlip,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
cave: Cave
|
||||||
|
transcript: Line[]
|
||||||
|
hunterName: string
|
||||||
|
/** True while the teletype is still printing; a prompt mid-line is a lie. */
|
||||||
|
busy: boolean
|
||||||
|
onCommand: (c: Command) => void
|
||||||
|
onBlip: () => void
|
||||||
|
onReject: () => void
|
||||||
|
}) {
|
||||||
|
const { skin } = useSkin()
|
||||||
|
const [mode, setMode] = useState<'move' | 'shoot'>('move')
|
||||||
|
const [path, setPath] = useState<number[]>([])
|
||||||
|
const exits = tunnelsFrom(cave.you)
|
||||||
|
|
||||||
|
// A new room is a fresh decision; never carry a half-typed shot into it.
|
||||||
|
useEffect(() => {
|
||||||
|
setMode('move')
|
||||||
|
setPath([])
|
||||||
|
}, [cave.you, cave.depth])
|
||||||
|
|
||||||
|
/** Where the arrow is now, so the next room can be offered from there. */
|
||||||
|
const arrowAt = path.length ? path[path.length - 1]! : cave.you
|
||||||
|
const arrowExits = tunnelsFrom(arrowAt)
|
||||||
|
|
||||||
|
const canFire = path.length > 0
|
||||||
|
const canExtend = path.length < MAX_ARROW_PATH
|
||||||
|
|
||||||
|
const shoot = () => {
|
||||||
|
if (!canFire) return onReject()
|
||||||
|
onCommand({ type: 'shoot', path })
|
||||||
|
setPath([])
|
||||||
|
setMode('move')
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (to: number) => {
|
||||||
|
if (!areConnected(cave.you, to)) return onReject()
|
||||||
|
onCommand({ type: 'move', to })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing a room number works in both skins; it is how the game was played.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (busy) return
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setPath([])
|
||||||
|
setMode('move')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && mode === 'shoot') {
|
||||||
|
shoot()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const n = Number(e.key)
|
||||||
|
if (!Number.isInteger(n)) return
|
||||||
|
// Room numbers run to 20, so a digit alone is ambiguous; the buttons
|
||||||
|
// and the number field are the honest ways in. This is a shortcut for
|
||||||
|
// the common case only: a single-digit room you can actually reach.
|
||||||
|
const target = mode === 'move' ? exits : arrowExits
|
||||||
|
const hit = target.find((r) => r === n)
|
||||||
|
if (hit === undefined) return
|
||||||
|
if (mode === 'move') move(hit)
|
||||||
|
else setPath((p) => (p.length < MAX_ARROW_PATH ? [...p, hit] : p))
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
const senses = useMemo(
|
||||||
|
() => transcript.filter((l) => l.kind === 'sense').slice(-3),
|
||||||
|
[transcript],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack hunt">
|
||||||
|
{skin === 'modern' && (
|
||||||
|
<div className="map-wrap">
|
||||||
|
<CaveMap cave={cave} aim={path} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Teletype lines={transcript} />
|
||||||
|
|
||||||
|
{!busy && !cave.outcome && (
|
||||||
|
<div className="prompt">
|
||||||
|
{mode === 'move' ? (
|
||||||
|
<>
|
||||||
|
<div className="prompt-q">WALK, OR SHOOT?</div>
|
||||||
|
<div className="prompt-row">
|
||||||
|
<Btn
|
||||||
|
kind="primary"
|
||||||
|
onClick={() => {
|
||||||
|
onBlip()
|
||||||
|
setMode('shoot')
|
||||||
|
}}
|
||||||
|
title="Fire a crooked arrow"
|
||||||
|
>
|
||||||
|
SHOOT
|
||||||
|
</Btn>
|
||||||
|
<span className="prompt-sep">WHICH TUNNEL?</span>
|
||||||
|
{exits.map((r) => (
|
||||||
|
<Btn key={r} onClick={() => move(r)} title={`Walk into room ${r}`}>
|
||||||
|
{r}
|
||||||
|
</Btn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="prompt-q">
|
||||||
|
AIM IT THROUGH HOW MANY ROOMS, UP TO {MAX_ARROW_PATH}?{' '}
|
||||||
|
{path.length ? path.join(' - ') : '--'}
|
||||||
|
</div>
|
||||||
|
<div className="prompt-row">
|
||||||
|
{canExtend ? (
|
||||||
|
arrowExits.map((r) => (
|
||||||
|
<Btn
|
||||||
|
key={r}
|
||||||
|
onClick={() => {
|
||||||
|
onBlip()
|
||||||
|
setPath((p) => [...p, r])
|
||||||
|
}}
|
||||||
|
title={`Send the arrow on to room ${r}`}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</Btn>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="prompt-sep">THE ARROW WILL GO NO FURTHER</span>
|
||||||
|
)}
|
||||||
|
<Btn kind="primary" onClick={shoot} disabled={!canFire} title="Loose the arrow">
|
||||||
|
FIRE
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
kind="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onBlip()
|
||||||
|
setPath([])
|
||||||
|
setMode('move')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BACK
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
{/* The rule that makes long shots frightening, said out loud. */}
|
||||||
|
<div className="prompt-note">
|
||||||
|
NAME A ROOM THE ARROW CANNOT REACH AND IT PICKS ITS OWN TUNNEL.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="statusbar">
|
||||||
|
<span>{hunterName}</span>
|
||||||
|
<span>CAVE {cave.depth}</span>
|
||||||
|
<span>ARROWS {cave.arrows}</span>
|
||||||
|
<span className="senses">{senses.map((s) => s.text).join(' ') || ' '}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { synth } from '../audio/synth'
|
||||||
|
import type { Line } from '../game/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 1973 skin: everything the machine has said, printed.
|
||||||
|
*
|
||||||
|
* A Teletype prints at ten characters a second and cannot take any of it
|
||||||
|
* back, so this types rather than appears, and nothing already printed ever
|
||||||
|
* changes. That is not decoration -- it is the reason the game feels the way
|
||||||
|
* it does. Reading "I SMELL A WUMPUS" arrive one letter at a time is a
|
||||||
|
* different experience from finding it already on screen, and the whole game
|
||||||
|
* is three sentences arriving in the dark.
|
||||||
|
*
|
||||||
|
* The print head is played per character. At ten a second that is a lot of
|
||||||
|
* one-shot oscillators, which is why `synth.print()` is as small as it is.
|
||||||
|
*/
|
||||||
|
const CPS = 42 // Faster than a real ASR-33. A real one is 10, and it is a lot.
|
||||||
|
|
||||||
|
export function Teletype({ lines, onIdle }: { lines: Line[]; onIdle?: () => void }) {
|
||||||
|
const [printed, setPrinted] = useState<string[]>([])
|
||||||
|
const [partial, setPartial] = useState('')
|
||||||
|
const done = useRef(0)
|
||||||
|
const paper = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// A new expedition throws the old transcript away rather than scrolling it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (lines.length < done.current) {
|
||||||
|
done.current = 0
|
||||||
|
setPrinted([])
|
||||||
|
setPartial('')
|
||||||
|
}
|
||||||
|
}, [lines.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (done.current >= lines.length) {
|
||||||
|
onIdle?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = lines[done.current]!.text
|
||||||
|
let at = 0
|
||||||
|
setPartial('')
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
at += 1
|
||||||
|
setPartial(text.slice(0, at))
|
||||||
|
if (synth.sfxOn) synth.print()
|
||||||
|
if (at >= text.length) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
synth.carriage()
|
||||||
|
done.current += 1
|
||||||
|
setPartial('')
|
||||||
|
setPrinted((p) => [...p, text])
|
||||||
|
}
|
||||||
|
}, 1000 / CPS)
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
// `printed` is the trigger: finishing one line starts the next.
|
||||||
|
}, [lines, printed.length, onIdle])
|
||||||
|
|
||||||
|
// The paper feeds up; you always read at the bottom.
|
||||||
|
useEffect(() => {
|
||||||
|
paper.current?.scrollTo({ top: paper.current.scrollHeight })
|
||||||
|
}, [printed.length, partial])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="paper" ref={paper}>
|
||||||
|
{printed.map((text, i) => (
|
||||||
|
<div className="printed" key={i}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{partial && (
|
||||||
|
<div className="printed">
|
||||||
|
{partial}
|
||||||
|
<span className="head" aria-hidden />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { DIAL_UP, synth } from '../audio/synth'
|
||||||
|
import { MAX_HUNTERS, MAX_NAME, STARTING_ARROWS } from '../game/constants'
|
||||||
|
import { ROOM_COUNT } from '../game/cave'
|
||||||
|
import { compareHunts, outcomeText } from '../game/engine'
|
||||||
|
import type { Score } from '../game/highscores'
|
||||||
|
import type { Cave, Hunter } from '../game/types'
|
||||||
|
import { isWin } from '../game/types'
|
||||||
|
import { CaveMap } from './CaveMap'
|
||||||
|
import { Btn, Line } from './Frame'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that is not the hunt itself. These are small enough that keeping
|
||||||
|
* them in one file makes the shape of the game easier to see than eight files
|
||||||
|
* would, and none of them holds a rule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- boot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialling in.
|
||||||
|
*
|
||||||
|
* The lemonade stand loads from cassette because that is how an Atari got its
|
||||||
|
* programs. This game is six years older than that machine and did not live on
|
||||||
|
* a machine you owned at all -- it lived on a timesharing service you
|
||||||
|
* telephoned. So the boot is a real Bell 103 call, and pushing the handset
|
||||||
|
* into the coupler is the gesture that unlocks the audio.
|
||||||
|
*
|
||||||
|
* The stages are printed as they happen, from the same schedule the audio is
|
||||||
|
* built on, so what is on the paper is what is on the line. See DIAL_UP.
|
||||||
|
*/
|
||||||
|
const STAGES: [number, string][] = [
|
||||||
|
[0, 'DIAL TONE'],
|
||||||
|
[DIAL_UP.dialTone, 'DIALLING 555-1973'],
|
||||||
|
[DIAL_UP.ringAt, 'RINGING'],
|
||||||
|
[DIAL_UP.answerAt, 'ANSWER TONE 2225 HZ'],
|
||||||
|
[DIAL_UP.answerAt + DIAL_UP.answer, 'CARRIER -- 110 BAUD, FULL DUPLEX'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function BootScreen({ onLoaded }: { onLoaded: () => void }) {
|
||||||
|
const [dialling, setDialling] = useState(false)
|
||||||
|
const [stage, setStage] = useState(-1)
|
||||||
|
const stop = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dialling) return
|
||||||
|
stop.current = synth.dialUp()
|
||||||
|
|
||||||
|
const timers = STAGES.map(([at], i) =>
|
||||||
|
window.setTimeout(() => setStage(i), at * 1000),
|
||||||
|
)
|
||||||
|
const done = window.setTimeout(() => {
|
||||||
|
stop.current?.()
|
||||||
|
onLoaded()
|
||||||
|
}, DIAL_UP.total * 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const t of timers) window.clearTimeout(t)
|
||||||
|
window.clearTimeout(done)
|
||||||
|
stop.current?.()
|
||||||
|
}
|
||||||
|
}, [dialling, onLoaded])
|
||||||
|
|
||||||
|
/** Nobody should have to sit through a handshake twice. */
|
||||||
|
const skip = () => {
|
||||||
|
stop.current?.()
|
||||||
|
onLoaded()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack boot">
|
||||||
|
<Line className="accent">PEOPLE'S COMPUTER COMPANY</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
{dialling ? (
|
||||||
|
<>
|
||||||
|
{STAGES.slice(0, stage + 1).map(([, label]) => (
|
||||||
|
<Line key={label}>{label}</Line>
|
||||||
|
))}
|
||||||
|
<Line> </Line>
|
||||||
|
<Btn kind="ghost" onClick={skip} title="Skip the handshake">
|
||||||
|
SKIP
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Line>TELEPHONE THE MACHINE TO BEGIN.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Btn
|
||||||
|
kind="primary"
|
||||||
|
onClick={() => setDialling(true)}
|
||||||
|
title="Dial in. This is also what lets the browser make a sound."
|
||||||
|
>
|
||||||
|
DIAL
|
||||||
|
</Btn>
|
||||||
|
<Line> </Line>
|
||||||
|
<Line className="dim">SOUND STARTS HERE, AS BROWSERS INSIST.</Line>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ title
|
||||||
|
|
||||||
|
export function TitleScreen({
|
||||||
|
onStart,
|
||||||
|
onInstructions,
|
||||||
|
onScores,
|
||||||
|
}: {
|
||||||
|
onStart: () => void
|
||||||
|
onInstructions: () => void
|
||||||
|
onScores: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stack title">
|
||||||
|
<pre className="banner" aria-label="Hunt the Wumpus">{`
|
||||||
|
HUNT THE WUMPUS
|
||||||
|
`}</pre>
|
||||||
|
<Line className="dim">TWENTY ROOMS. THREE TUNNELS EACH. ONE OF IT.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<div className="prompt-row">
|
||||||
|
<Btn kind="primary" onClick={onStart}>
|
||||||
|
HUNT
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={onInstructions}>INSTRUCTIONS</Btn>
|
||||||
|
<Btn onClick={onScores}>BOARD</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- instructions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules, on request, the way the machine offered them in 1973 -- but in
|
||||||
|
* our words rather than Yob's. See NOTICE.md: the rules themselves are ideas
|
||||||
|
* and free to use, the sentences he wrote to explain them are not.
|
||||||
|
*/
|
||||||
|
export function InstructionsScreen({ onDone }: { onDone: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="stack rules">
|
||||||
|
<Line className="accent">THE CAVE</Line>
|
||||||
|
<Line>
|
||||||
|
{ROOM_COUNT} ROOMS, EACH WITH EXACTLY THREE TUNNELS OUT OF IT. THE SHAPE NEVER CHANGES.
|
||||||
|
WHAT IS IN IT DOES.
|
||||||
|
</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Line className="accent">WHAT IS DOWN THERE WITH YOU</Line>
|
||||||
|
<Line>ONE WUMPUS. IT SLEEPS. IT IS TOO HEAVY TO FALL DOWN A PIT AND TOO HEAVY FOR THE
|
||||||
|
BATS TO LIFT, SO NOTHING DOWN HERE TROUBLES IT BUT YOU.</Line>
|
||||||
|
<Line>TWO PITS. WALK INTO ONE AND YOU KEEP GOING.</Line>
|
||||||
|
<Line>TWO ROOSTS OF BATS. THEY WILL NOT HURT YOU. THEY WILL PICK YOU UP AND PUT YOU DOWN
|
||||||
|
SOMEWHERE ELSE, AND THEY DO NOT CARE WHAT IS ALREADY THERE.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Line className="accent">WHAT THE CAVE TELLS YOU</Line>
|
||||||
|
<Line>STANDING ONE TUNNEL AWAY FROM ANY OF THEM, YOU WILL KNOW SOMETHING IS CLOSE. YOU
|
||||||
|
WILL NEVER BE TOLD WHICH ROOM. THREE TUNNELS AND ONE WARNING IS THE WHOLE PUZZLE.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Line className="accent">YOUR TURN</Line>
|
||||||
|
<Line>WALK, OR SHOOT. YOU CARRY {STARTING_ARROWS} ARROWS AND THEY ARE CROOKED: EACH ONE
|
||||||
|
CAN BE AIMED THROUGH AS MANY AS FIVE ROOMS IN A ROW.</Line>
|
||||||
|
<Line>NAME A ROOM THE ARROW CANNOT REACH FROM WHERE IT IS AND IT WILL NOT STOP. IT WILL
|
||||||
|
TAKE A TUNNEL OF ITS OWN CHOOSING, AND ONE OF THOSE LEADS BACK TO YOU.</Line>
|
||||||
|
<Line>A SHOT THAT MISSES USUALLY WAKES THE WUMPUS. A WOKEN WUMPUS MOVES ONE ROOM, OR
|
||||||
|
STAYS WHERE IT IS. IF IT ARRIVES WHERE YOU ARE, THAT IS THE END OF IT.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Line className="accent">AFTERWARDS</Line>
|
||||||
|
<Line>KILL IT AND YOU GO DEEPER, KEEPING WHAT IS LEFT IN THE QUIVER. DIE AND YOU ARE
|
||||||
|
FINISHED. STOP WHENEVER YOU LIKE -- THAT IS WHAT CLIMB OUT IS FOR.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Btn kind="primary" onClick={onDone}>
|
||||||
|
GOT IT
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ setup
|
||||||
|
|
||||||
|
export function SetupScreen({
|
||||||
|
onStart,
|
||||||
|
onBlip,
|
||||||
|
}: {
|
||||||
|
onStart: (names: string[]) => void
|
||||||
|
onBlip: () => void
|
||||||
|
}) {
|
||||||
|
const [names, setNames] = useState<string[]>([''])
|
||||||
|
|
||||||
|
const set = (i: number, v: string) =>
|
||||||
|
setNames((n) => n.map((old, j) => (j === i ? v.slice(0, MAX_NAME) : old)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack setup">
|
||||||
|
<Line className="accent">WHO IS GOING IN?</Line>
|
||||||
|
<Line className="dim">EACH HUNTER GETS THEIR OWN CAVE. YOU TAKE IT IN TURNS.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
{names.map((name, i) => (
|
||||||
|
<div className="field" key={i}>
|
||||||
|
<label htmlFor={`hunter-${i}`}>HUNTER {i + 1}</label>
|
||||||
|
<input
|
||||||
|
id={`hunter-${i}`}
|
||||||
|
className="field-input"
|
||||||
|
value={name}
|
||||||
|
maxLength={MAX_NAME}
|
||||||
|
placeholder={`HUNTER ${i + 1}`}
|
||||||
|
onChange={(e) => set(i, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Line> </Line>
|
||||||
|
<div className="prompt-row">
|
||||||
|
{names.length < MAX_HUNTERS && (
|
||||||
|
<Btn
|
||||||
|
onClick={() => {
|
||||||
|
onBlip()
|
||||||
|
setNames((n) => [...n, ''])
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ONE MORE
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
<Btn kind="primary" onClick={() => onStart(names)}>
|
||||||
|
GO IN
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- report
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The end of one hunt. This is the only screen other than the standings
|
||||||
|
* allowed to show where things actually were -- the hunt is over, so knowing
|
||||||
|
* costs nothing, and not showing it would waste the one moment the map is
|
||||||
|
* genuinely interesting.
|
||||||
|
*/
|
||||||
|
export function ReportScreen({
|
||||||
|
cave,
|
||||||
|
hunter,
|
||||||
|
moreToCome,
|
||||||
|
onNext,
|
||||||
|
onClimbOut,
|
||||||
|
}: {
|
||||||
|
cave: Cave
|
||||||
|
hunter: Hunter
|
||||||
|
/** True when somebody is still alive to take another turn. */
|
||||||
|
moreToCome: boolean
|
||||||
|
onNext: () => void
|
||||||
|
onClimbOut: () => void
|
||||||
|
}) {
|
||||||
|
const won = isWin(cave.outcome)
|
||||||
|
return (
|
||||||
|
<div className="stack report">
|
||||||
|
<Line className={won ? 'accent' : 'warn'}>{cave.outcome ? outcomeText(cave.outcome) : ''}</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<div className="map-wrap map-reveal">
|
||||||
|
<CaveMap cave={cave} reveal />
|
||||||
|
</div>
|
||||||
|
<Line> </Line>
|
||||||
|
<Line>
|
||||||
|
{hunter.name} — {hunter.bagged} BAGGED, {hunter.arrows} ARROW
|
||||||
|
{hunter.arrows === 1 ? '' : 'S'} LEFT, {hunter.roomsWalked} ROOMS WALKED
|
||||||
|
</Line>
|
||||||
|
<Line className="dim">
|
||||||
|
THE WUMPUS WAS IN {cave.wumpus}. PITS: {cave.pits.join(' AND ')}. BATS:{' '}
|
||||||
|
{cave.bats.join(' AND ')}.
|
||||||
|
</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<div className="prompt-row">
|
||||||
|
<Btn kind="primary" onClick={onNext}>
|
||||||
|
{moreToCome ? 'NEXT HUNTER' : won ? 'GO DEEPER' : 'THAT IS THAT'}
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={onClimbOut} title="End the expedition and post the board">
|
||||||
|
CLIMB OUT
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- standings
|
||||||
|
|
||||||
|
export function GameOverScreen({
|
||||||
|
hunters,
|
||||||
|
climbedOut,
|
||||||
|
onRestart,
|
||||||
|
onScores,
|
||||||
|
}: {
|
||||||
|
hunters: Hunter[]
|
||||||
|
climbedOut: boolean
|
||||||
|
onRestart: () => void
|
||||||
|
onScores: () => void
|
||||||
|
}) {
|
||||||
|
const ranked = [...hunters].sort(compareHunts)
|
||||||
|
return (
|
||||||
|
<div className="stack over">
|
||||||
|
<Line className="accent">{climbedOut ? 'YOU CLIMBED OUT.' : 'THE CAVE KEPT EVERYBODY.'}</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
{ranked.map((h, i) => (
|
||||||
|
<Line key={h.id}>
|
||||||
|
{String(i + 1).padStart(2)}. {h.name.padEnd(MAX_NAME)} {String(h.bagged).padStart(2)}{' '}
|
||||||
|
BAGGED {String(h.arrows).padStart(2)} ARROWS {String(h.roomsWalked).padStart(3)} ROOMS
|
||||||
|
{h.died ? ` ${outcomeText(h.died)}` : ' WALKED OUT'}
|
||||||
|
</Line>
|
||||||
|
))}
|
||||||
|
<Line> </Line>
|
||||||
|
<div className="prompt-row">
|
||||||
|
<Btn kind="primary" onClick={onRestart}>
|
||||||
|
AGAIN
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={onScores}>BOARD</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- board
|
||||||
|
|
||||||
|
export type BoardState = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
export function HighScoreScreen({
|
||||||
|
scores,
|
||||||
|
state,
|
||||||
|
highlight,
|
||||||
|
onBack,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
scores: Score[]
|
||||||
|
state: BoardState
|
||||||
|
highlight: string[]
|
||||||
|
onBack: () => void
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stack board">
|
||||||
|
<Line className="accent">THE BOARD</Line>
|
||||||
|
<Line className="dim">RANKED ON WUMPODES, THEN ARROWS LEFT, THEN ROOMS WALKED.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
{state === 'loading' && <Line>ASKING THE MACHINE...</Line>}
|
||||||
|
{state === 'error' && (
|
||||||
|
<>
|
||||||
|
{/* The game is entirely playable without the board, and says so
|
||||||
|
rather than pretending the run did not happen. */}
|
||||||
|
<Line className="warn">THE BOARD IS NOT ANSWERING. YOUR HUNT STILL COUNTED.</Line>
|
||||||
|
<Line> </Line>
|
||||||
|
<Btn onClick={onRetry}>TRY AGAIN</Btn>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{state === 'ready' && scores.length === 0 && <Line>NOBODY HAS COME BACK OUT YET.</Line>}
|
||||||
|
{state === 'ready' &&
|
||||||
|
scores.map((s, i) => (
|
||||||
|
<Line key={s.id} className={highlight.includes(s.id) ? 'accent' : ''}>
|
||||||
|
{String(i + 1).padStart(2)}. {s.name.padEnd(MAX_NAME)} {String(s.bagged).padStart(2)}{' '}
|
||||||
|
{String(s.arrows).padStart(2)} {String(s.roomsWalked).padStart(3)}
|
||||||
|
{s.died ? '' : ' *'}
|
||||||
|
</Line>
|
||||||
|
))}
|
||||||
|
<Line> </Line>
|
||||||
|
<Btn kind="primary" onClick={onBack}>
|
||||||
|
BACK
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { EDGES, LAYOUT, ROOM_COUNT, TUNNELS, areConnected, tunnelsFrom } from './cave'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adjacency table is typed out by hand, which means it can be typed out
|
||||||
|
* wrong. A wrong table would not crash and would not look wrong on screen --
|
||||||
|
* it would just quietly stop being a dodecahedron, and the game would still
|
||||||
|
* play. So it gets proved rather than trusted.
|
||||||
|
*/
|
||||||
|
describe('the cave is a dodecahedron', () => {
|
||||||
|
it('has twenty rooms', () => {
|
||||||
|
expect(TUNNELS).toHaveLength(ROOM_COUNT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives every room exactly three tunnels', () => {
|
||||||
|
for (let room = 1; room <= ROOM_COUNT; room++) {
|
||||||
|
expect(tunnelsFrom(room)).toHaveLength(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never leads a room to itself or twice to the same place', () => {
|
||||||
|
for (let room = 1; room <= ROOM_COUNT; room++) {
|
||||||
|
const to = tunnelsFrom(room)
|
||||||
|
expect(to).not.toContain(room)
|
||||||
|
expect(new Set(to).size).toBe(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A tunnel you can walk down but not back up is not a tunnel. */
|
||||||
|
it('is symmetric', () => {
|
||||||
|
for (let a = 1; a <= ROOM_COUNT; a++) {
|
||||||
|
for (const b of tunnelsFrom(a)) {
|
||||||
|
expect(areConnected(b, a)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has thirty tunnels, counted once each', () => {
|
||||||
|
expect(EDGES).toHaveLength(30)
|
||||||
|
const seen = new Set(EDGES.map(([a, b]) => `${a}-${b}`))
|
||||||
|
expect(seen.size).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connected, and no shortcuts. A dodecahedron has a diameter of five: from
|
||||||
|
* any room, the furthest room is exactly five tunnels away. This catches
|
||||||
|
* the kind of typo that leaves the graph 3-regular and symmetric but wired
|
||||||
|
* to the wrong vertex -- which the tests above would all pass.
|
||||||
|
*/
|
||||||
|
it('is connected with a diameter of five', () => {
|
||||||
|
const distances = (from: number) => {
|
||||||
|
const dist = new Map<number, number>([[from, 0]])
|
||||||
|
const queue = [from]
|
||||||
|
while (queue.length) {
|
||||||
|
const at = queue.shift()!
|
||||||
|
for (const next of tunnelsFrom(at)) {
|
||||||
|
if (dist.has(next)) continue
|
||||||
|
dist.set(next, dist.get(at)! + 1)
|
||||||
|
queue.push(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dist
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let room = 1; room <= ROOM_COUNT; room++) {
|
||||||
|
const dist = distances(room)
|
||||||
|
expect(dist.size).toBe(ROOM_COUNT)
|
||||||
|
expect(Math.max(...dist.values())).toBe(5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every vertex of a dodecahedron lies on three pentagonal faces, so every
|
||||||
|
* room sits on three five-room loops. Nothing in the game reads this; it is
|
||||||
|
* here because it is the property that makes the shape a dodecahedron
|
||||||
|
* rather than merely a 3-regular graph of diameter five.
|
||||||
|
*/
|
||||||
|
it('puts every room on a five-room loop', () => {
|
||||||
|
const fives = (start: number) => {
|
||||||
|
let found = 0
|
||||||
|
const walk = (at: number, path: number[]) => {
|
||||||
|
if (path.length === 5) {
|
||||||
|
if (areConnected(at, start)) found++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const next of tunnelsFrom(at)) {
|
||||||
|
if (path.includes(next)) continue
|
||||||
|
walk(next, [...path, next])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(start, [start])
|
||||||
|
// Each pentagon is walked twice, once in each direction.
|
||||||
|
return found / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let room = 1; room <= ROOM_COUNT; room++) {
|
||||||
|
expect(fives(room)).toBe(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the map it is drawn on', () => {
|
||||||
|
it('places every room', () => {
|
||||||
|
for (let room = 1; room <= ROOM_COUNT; room++) {
|
||||||
|
expect(LAYOUT[room]).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never puts two rooms in the same place', () => {
|
||||||
|
const seen = new Set(Object.values(LAYOUT).map(([x, y]) => `${x.toFixed(3)},${y.toFixed(3)}`))
|
||||||
|
expect(seen.size).toBe(ROOM_COUNT)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole point of a Schlegel diagram: it is planar, so no two tunnels
|
||||||
|
* cross. A layout that crosses is still a layout -- it draws, it scales, it
|
||||||
|
* looks broadly cave-shaped -- and it is much harder to read than it looks
|
||||||
|
* in a screenshot, so the property is asserted rather than eyeballed.
|
||||||
|
*
|
||||||
|
* Tunnels that share a room meet at it and are skipped; anything else that
|
||||||
|
* touches is a crossing.
|
||||||
|
*/
|
||||||
|
it('draws no tunnel across another', () => {
|
||||||
|
const point = (room: number) => LAYOUT[room]!
|
||||||
|
|
||||||
|
const orientation = (
|
||||||
|
[ax, ay]: readonly [number, number],
|
||||||
|
[bx, by]: readonly [number, number],
|
||||||
|
[cx, cy]: readonly [number, number],
|
||||||
|
) => {
|
||||||
|
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
|
||||||
|
return Math.abs(v) < 1e-9 ? 0 : Math.sign(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [a, b] of EDGES) {
|
||||||
|
for (const [c, d] of EDGES) {
|
||||||
|
if (a === c && b === d) continue
|
||||||
|
// Sharing an endpoint is a junction, not a crossing.
|
||||||
|
if (a === c || a === d || b === c || b === d) continue
|
||||||
|
|
||||||
|
const [p1, p2, p3, p4] = [point(a), point(b), point(c), point(d)]
|
||||||
|
const crosses =
|
||||||
|
orientation(p1, p2, p3) !== orientation(p1, p2, p4) &&
|
||||||
|
orientation(p3, p4, p1) !== orientation(p3, p4, p2)
|
||||||
|
expect(crosses, `tunnel ${a}-${b} crosses ${c}-${d}`).toBe(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* The cave is a dodecahedron and always has been.
|
||||||
|
*
|
||||||
|
* Yob's note on the game says it plainly: he was tired of grid games, so he
|
||||||
|
* hung the rooms on the vertices of a dodecahedron. Twenty rooms, three
|
||||||
|
* tunnels from each, thirty tunnels in all. That is not a parameter and it is
|
||||||
|
* not generated -- it is the shape of the game, and it is the same shape in
|
||||||
|
* every cave you will ever walk into here.
|
||||||
|
*
|
||||||
|
* What changes between caves is only where the hazards sit. That is worth
|
||||||
|
* being clear about, because it is the whole reason the game is playable at
|
||||||
|
* all: you are not learning a maze, you are learning to read three sentences
|
||||||
|
* about a room you cannot see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rooms are numbered 1..20, the way they are printed. */
|
||||||
|
export const ROOM_COUNT = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The adjacency Yob used, in the order the rooms print.
|
||||||
|
*
|
||||||
|
* Written out rather than derived. A dodecahedron can be generated, but the
|
||||||
|
* generated one comes out with an arbitrary numbering, and this numbering is
|
||||||
|
* the one every listing of this game has used since 1973 -- so room 1 has
|
||||||
|
* always led to 2, 5 and 8, and a transcript from a magazine still makes
|
||||||
|
* sense against it. `cave.test.ts` proves it is a real dodecahedron rather
|
||||||
|
* than trusting that it was typed correctly.
|
||||||
|
*/
|
||||||
|
export const TUNNELS: readonly (readonly number[])[] = Object.freeze([
|
||||||
|
Object.freeze([2, 5, 8]), // 1
|
||||||
|
Object.freeze([1, 3, 10]), // 2
|
||||||
|
Object.freeze([2, 4, 12]), // 3
|
||||||
|
Object.freeze([3, 5, 14]), // 4
|
||||||
|
Object.freeze([1, 4, 6]), // 5
|
||||||
|
Object.freeze([5, 7, 15]), // 6
|
||||||
|
Object.freeze([6, 8, 17]), // 7
|
||||||
|
Object.freeze([1, 7, 9]), // 8
|
||||||
|
Object.freeze([8, 10, 18]), // 9
|
||||||
|
Object.freeze([2, 9, 11]), // 10
|
||||||
|
Object.freeze([10, 12, 19]), //11
|
||||||
|
Object.freeze([3, 11, 13]), // 12
|
||||||
|
Object.freeze([12, 14, 20]), //13
|
||||||
|
Object.freeze([4, 13, 15]), // 14
|
||||||
|
Object.freeze([6, 14, 16]), // 15
|
||||||
|
Object.freeze([15, 17, 20]), //16
|
||||||
|
Object.freeze([7, 16, 18]), // 17
|
||||||
|
Object.freeze([9, 17, 19]), // 18
|
||||||
|
Object.freeze([11, 18, 20]), //19
|
||||||
|
Object.freeze([13, 16, 19]), //20
|
||||||
|
])
|
||||||
|
|
||||||
|
/** The three tunnels out of a room, in printing order. */
|
||||||
|
export function tunnelsFrom(room: number): readonly number[] {
|
||||||
|
return TUNNELS[room - 1] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areConnected(a: number, b: number): boolean {
|
||||||
|
return tunnelsFrom(a).includes(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRoom = (n: unknown): n is number =>
|
||||||
|
typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= ROOM_COUNT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each room sits when the cave is drawn flat.
|
||||||
|
*
|
||||||
|
* A dodecahedron cannot be drawn on paper without lying about something, so
|
||||||
|
* this is the Schlegel diagram: one face pushed out to become the rim and the
|
||||||
|
* other eleven nested inside it. Done right, every tunnel is a straight line
|
||||||
|
* and no two of them cross -- which is the property `cave.test.ts` checks,
|
||||||
|
* because it is the one that makes the map readable and the one that is easy
|
||||||
|
* to break by moving a room.
|
||||||
|
*
|
||||||
|
* The rings are 5 / 10 / 5, and the middle one is the part that is easy to
|
||||||
|
* get wrong. Rooms 6 to 15 are not two pentagons at different depths; they
|
||||||
|
* are a single ten-room ring that alternates between the rooms wired inward
|
||||||
|
* to the middle pentagon (8, 10, 12, 14, 6) and the rooms wired outward to
|
||||||
|
* the rim (9, 11, 13, 15, 7). Drawing them as two rings is what puts crossed
|
||||||
|
* tunnels on the map.
|
||||||
|
*
|
||||||
|
* Unit coordinates about an origin at (0, 0); the map scales them.
|
||||||
|
*/
|
||||||
|
export const LAYOUT: Readonly<Record<number, readonly [number, number]>> = Object.freeze(
|
||||||
|
buildLayout(),
|
||||||
|
)
|
||||||
|
|
||||||
|
function buildLayout(): Record<number, [number, number]> {
|
||||||
|
/**
|
||||||
|
* Each ring in the order its rooms are wired together, so that the nth
|
||||||
|
* entry of one ring lines up radially with the room it is joined to. Room 1
|
||||||
|
* leads out to 8, and 8 leads out to 9 and back to 7, so 1 / 8 / 18 all sit
|
||||||
|
* on rays a fixed angle apart.
|
||||||
|
*/
|
||||||
|
const rings: [number[], number][] = [
|
||||||
|
[[1, 2, 3, 4, 5], 0.3],
|
||||||
|
[[8, 9, 10, 11, 12, 13, 14, 15, 6, 7], 0.66],
|
||||||
|
[[18, 19, 20, 16, 17], 1.0],
|
||||||
|
]
|
||||||
|
|
||||||
|
const out: Record<number, [number, number]> = {}
|
||||||
|
for (const [rooms, radius] of rings) {
|
||||||
|
const step = 360 / rooms.length
|
||||||
|
rooms.forEach((room, i) => {
|
||||||
|
// -90 puts the first room of each ring at the top rather than the right.
|
||||||
|
// The rim starts one half-step round, because 18 hangs off 9 rather
|
||||||
|
// than off 8.
|
||||||
|
const offset = rooms.length === 5 && radius === 1 ? 36 : 0
|
||||||
|
const rad = ((-90 + offset + step * i) * Math.PI) / 180
|
||||||
|
out[room] = [Math.cos(rad) * radius, Math.sin(rad) * radius]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tunnel once, as a pair, so the map does not draw each of them twice. */
|
||||||
|
export const EDGES: readonly (readonly [number, number])[] = Object.freeze(
|
||||||
|
TUNNELS.flatMap((to, i) =>
|
||||||
|
to.filter((b) => i + 1 < b).map((b) => Object.freeze([i + 1, b]) as readonly [number, number]),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* The numbers, all of them, in one place.
|
||||||
|
*
|
||||||
|
* Unlike the lemonade stand's demand curve there is almost nothing to
|
||||||
|
* reconstruct here: Yob's rules are arithmetic-free. Two pits, two bats, one
|
||||||
|
* wumpus, five arrows, an arrow that flies through five rooms, and a wumpus
|
||||||
|
* that wakes three times in four. The only judgement calls are marked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PIT_COUNT = 2
|
||||||
|
export const BAT_COUNT = 2
|
||||||
|
export const STARTING_ARROWS = 5
|
||||||
|
|
||||||
|
/** An arrow is crooked: it can be aimed through up to five rooms. */
|
||||||
|
export const MAX_ARROW_PATH = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shot wakes the wumpus three times in four, and a woken wumpus moves one
|
||||||
|
* room -- or stays where it is, which is the fourth of its four choices.
|
||||||
|
*/
|
||||||
|
export const WUMPUS_WAKES = 0.75
|
||||||
|
|
||||||
|
export const MAX_HUNTERS = 4
|
||||||
|
export const MAX_NAME = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one addition to the 1973 rules, and it is a frame rather than a rule.
|
||||||
|
*
|
||||||
|
* Yob's game is a single cave: you kill the wumpus or it kills you, and
|
||||||
|
* either way that is the end. That makes a fine two minutes and a poor
|
||||||
|
* leaderboard -- there are only two scores, and half the players get the
|
||||||
|
* lower one through no fault of their own.
|
||||||
|
*
|
||||||
|
* So a win is not the end here, it is a door. Bag the wumpus and you go
|
||||||
|
* deeper: a fresh cave, the same twenty rooms, hazards laid out again, and
|
||||||
|
* whatever arrows you did not spend. Death ends the expedition. The score is
|
||||||
|
* how many you bagged before one of them got you, which is a number a board
|
||||||
|
* can rank, and every hunt inside it is the 1973 game unaltered.
|
||||||
|
*
|
||||||
|
* Going deeper does not make the cave harder. It was already a coin toss
|
||||||
|
* dressed as a deduction; stacking hazards on top would only make a long run
|
||||||
|
* impossible rather than unlikely.
|
||||||
|
*/
|
||||||
|
export const ARROWS_PER_CAVE = 1
|
||||||
|
|
||||||
|
/** Never more than you started with, however many caves you clear. */
|
||||||
|
export const MAX_CARRIED_ARROWS = STARTING_ARROWS
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Everything the machine says, and none of it is Yob's.
|
||||||
|
*
|
||||||
|
* The 1973 listing has famous lines in it and they are his writing, not ours.
|
||||||
|
* The sibling project went back and took the original's wording out of itself
|
||||||
|
* for exactly this reason -- see NOTICE.md, and the same paragraph in the
|
||||||
|
* lemonade stand's. A game released under a copyleft licence cannot go around
|
||||||
|
* licensing sentences somebody else wrote.
|
||||||
|
*
|
||||||
|
* So these are written for this project. They carry the same information in
|
||||||
|
* the same order, in the same shouted uppercase a printing terminal gave you,
|
||||||
|
* because that much is the medium rather than the author.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SENSE_TEXT: Record<'wumpus' | 'pit' | 'bats', string> = {
|
||||||
|
wumpus: 'SOMETHING IN HERE SMELLS ALIVE',
|
||||||
|
pit: 'THE AIR MOVES. SOMETHING NEARBY IS OPEN',
|
||||||
|
bats: 'WINGS, SOMEWHERE CLOSE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEATH_TEXT: Record<string, string> = {
|
||||||
|
eaten: 'IT FINDS YOU IN THE DARK. IT IS NOT SLOW.',
|
||||||
|
'fell-in-a-pit': 'THE FLOOR IS NOT THERE. NEITHER IS ANYTHING ELSE.',
|
||||||
|
'shot-yourself': 'YOUR OWN ARROW COMES BACK OUT OF THE DARK.',
|
||||||
|
'out-of-arrows': 'THE QUIVER IS EMPTY. THE CAVE HAS ALL THE TIME IT NEEDS.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WIN_TEXT = 'THE ARROW FINDS IT. THE CAVE GOES QUIET.'
|
||||||
|
|
||||||
|
export const BATS_TEXT = 'WINGS CLOSE ON YOU AND PUT YOU DOWN SOMEWHERE ELSE.'
|
||||||
|
|
||||||
|
/** A room with no tunnel to it. Refused, at no cost. */
|
||||||
|
export const NO_TUNNEL_TEXT = 'NO TUNNEL GOES THAT WAY'
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ROOM_COUNT, tunnelsFrom } from './cave'
|
||||||
|
import { BAT_COUNT, NO_TUNNEL_TEXT, PIT_COUNT, SENSE_TEXT, STARTING_ARROWS } from './constants'
|
||||||
|
import { arrowsAfterWin, compareHunts, describe as describeRoom, layCave, sense, step } from './engine'
|
||||||
|
import { makeRng } from './rng'
|
||||||
|
import type { Cave } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine is the game, so this is where the rules are held to account.
|
||||||
|
*
|
||||||
|
* Most of these build a cave by hand rather than dealing one, because a rule
|
||||||
|
* about what happens when you walk into a pit is not a rule about where pits
|
||||||
|
* end up. The dealt caves are tested separately, once, for the things dealing
|
||||||
|
* has to guarantee.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const caveWith = (over: Partial<Cave> = {}): Cave => ({
|
||||||
|
depth: 1,
|
||||||
|
wumpus: 20,
|
||||||
|
pits: [18, 19],
|
||||||
|
bats: [16, 17],
|
||||||
|
you: 1,
|
||||||
|
start: 1,
|
||||||
|
arrows: STARTING_ARROWS,
|
||||||
|
visited: [1],
|
||||||
|
sensed: {},
|
||||||
|
outcome: null,
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** A stream that hands back exactly the numbers a test wants, then zeros. */
|
||||||
|
const scripted = (...values: number[]) => {
|
||||||
|
let i = 0
|
||||||
|
return () => values[i++] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dealing a cave', () => {
|
||||||
|
it('gives everything its own room, including the hunter', () => {
|
||||||
|
for (let seed = 1; seed <= 400; seed++) {
|
||||||
|
const cave = layCave(1, STARTING_ARROWS, makeRng(seed))
|
||||||
|
const occupied = [cave.wumpus, ...cave.pits, ...cave.bats, cave.you]
|
||||||
|
expect(new Set(occupied).size).toBe(occupied.length)
|
||||||
|
expect(cave.pits).toHaveLength(PIT_COUNT)
|
||||||
|
expect(cave.bats).toHaveLength(BAT_COUNT)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lays the same cave twice from the same seed', () => {
|
||||||
|
const a = layCave(3, 4, makeRng(12345))
|
||||||
|
const b = layCave(3, 4, makeRng(12345))
|
||||||
|
expect(b).toEqual(a)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first room is the one it is easy to forget, because `describe`
|
||||||
|
* recomputes the senses rather than reading them back -- so the transcript
|
||||||
|
* is right whether or not they were ever stored, and only the map notices.
|
||||||
|
*/
|
||||||
|
it('records what the first room senses, not just what it prints', () => {
|
||||||
|
let found = 0
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const cave = layCave(1, STARTING_ARROWS, makeRng(seed))
|
||||||
|
expect(cave.sensed[cave.you]).toEqual(sense(cave))
|
||||||
|
if ((cave.sensed[cave.you] ?? []).length > 0) found++
|
||||||
|
}
|
||||||
|
// A start next to a hazard is common enough that the case is real.
|
||||||
|
expect(found).toBeGreaterThan(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts the hunter somewhere they can be told about', () => {
|
||||||
|
const cave = layCave(1, STARTING_ARROWS, makeRng(7))
|
||||||
|
const lines = describeRoom(cave)
|
||||||
|
expect(lines.some((l) => l.text === `YOU ARE IN ROOM ${cave.you}`)).toBe(true)
|
||||||
|
expect(lines.some((l) => l.text.startsWith('TUNNELS LEAD TO'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('what a room tells you', () => {
|
||||||
|
it('reports a hazard one tunnel away and never says which room', () => {
|
||||||
|
// Room 1 leads to 2, 5 and 8. Put the wumpus in 5.
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 5, pits: [18, 19], bats: [16, 17] })
|
||||||
|
expect(sense(cave)).toEqual(['wumpus'])
|
||||||
|
const printed = describeRoom(cave).map((l) => l.text)
|
||||||
|
expect(printed).toContain(SENSE_TEXT.wumpus)
|
||||||
|
expect(printed.join(' ')).not.toContain('ROOM 5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing about a hazard two tunnels away', () => {
|
||||||
|
// 12 is two from 1 (1-2-12), so it must be silent from room 1.
|
||||||
|
expect(tunnelsFrom(1)).not.toContain(12)
|
||||||
|
expect(sense(caveWith({ you: 1, wumpus: 12 }))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports all three at once, in the order the listing printed them', () => {
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 2, pits: [5, 19], bats: [8, 17] })
|
||||||
|
expect(sense(cave)).toEqual(['wumpus', 'pit', 'bats'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('moving', () => {
|
||||||
|
it('refuses a room with no tunnel to it, and does not cost a turn', () => {
|
||||||
|
const cave = caveWith({ you: 1 })
|
||||||
|
const { cave: after, lines } = step(cave, { type: 'move', to: 12 }, makeRng(1))
|
||||||
|
expect(after).toBe(cave)
|
||||||
|
expect(lines.map((l) => l.text)).toEqual([NO_TUNNEL_TEXT])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('eats you when you walk in on the wumpus', () => {
|
||||||
|
const { cave } = step(caveWith({ you: 1, wumpus: 2 }), { type: 'move', to: 2 }, makeRng(1))
|
||||||
|
expect(cave.outcome).toBe('eaten')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops you down a pit', () => {
|
||||||
|
const { cave } = step(caveWith({ you: 1, pits: [2, 19] }), { type: 'move', to: 2 }, makeRng(1))
|
||||||
|
expect(cave.outcome).toBe('fell-in-a-pit')
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bat rule is the one worth testing hardest: it is the only thing in
|
||||||
|
* the game that moves you somewhere you did not choose, and the room it
|
||||||
|
* picks can hold anything.
|
||||||
|
*/
|
||||||
|
it('lets a bat carry you somewhere else entirely', () => {
|
||||||
|
const cave = caveWith({ you: 1, bats: [2, 17], wumpus: 20, pits: [18, 19] })
|
||||||
|
const { cave: after, lines } = step(cave, { type: 'move', to: 2 }, makeRng(99))
|
||||||
|
expect(lines.some((l) => l.kind === 'bats')).toBe(true)
|
||||||
|
expect(after.you).not.toBe(2)
|
||||||
|
expect(after.outcome).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets a bat drop you on something that kills you', () => {
|
||||||
|
// The first roll picks the destination out of the nineteen other rooms;
|
||||||
|
// 0 lands on room 1, where the pit is.
|
||||||
|
const cave = caveWith({ you: 5, bats: [6, 17], pits: [1, 19], wumpus: 20 })
|
||||||
|
const { cave: after } = step(cave, { type: 'move', to: 6 }, scripted(0))
|
||||||
|
expect(after.outcome).toBe('fell-in-a-pit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never drops you back in the room the bat took you from', () => {
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const cave = caveWith({ you: 1, bats: [2, 17], wumpus: 20, pits: [18, 19] })
|
||||||
|
const { cave: after } = step(cave, { type: 'move', to: 2 }, makeRng(seed))
|
||||||
|
if (after.outcome) continue
|
||||||
|
expect(after.you).not.toBe(2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remembers every room it has stood in', () => {
|
||||||
|
let cave = caveWith({ you: 1, wumpus: 20, pits: [18, 19], bats: [16, 17] })
|
||||||
|
cave = step(cave, { type: 'move', to: 2 }, makeRng(1)).cave
|
||||||
|
cave = step(cave, { type: 'move', to: 3 }, makeRng(1)).cave
|
||||||
|
expect(cave.visited).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shooting', () => {
|
||||||
|
it('kills the wumpus when the arrow reaches it', () => {
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 2 })
|
||||||
|
const { cave: after, lines } = step(cave, { type: 'shoot', path: [2] }, makeRng(1))
|
||||||
|
expect(after.outcome).toBe('shot-the-wumpus')
|
||||||
|
expect(lines.some((l) => l.kind === 'win')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('spends an arrow whether it hits or misses', () => {
|
||||||
|
const hit = step(caveWith({ you: 1, wumpus: 2 }), { type: 'shoot', path: [2] }, makeRng(1))
|
||||||
|
const miss = step(caveWith({ you: 1, wumpus: 20 }), { type: 'shoot', path: [2] }, makeRng(1))
|
||||||
|
expect(hit.cave.arrows).toBe(STARTING_ARROWS - 1)
|
||||||
|
expect(miss.cave.arrows).toBe(STARTING_ARROWS - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule the whole game turns on. Room 1 leads to 2, 5 and 8; room 2
|
||||||
|
* leads to 1, 3 and 10. Naming 2 and then 8 asks the arrow to make a jump
|
||||||
|
* it cannot -- so it takes a tunnel of its own, and the scripted stream
|
||||||
|
* makes that tunnel the one back to room 1.
|
||||||
|
*/
|
||||||
|
it('sends a crooked arrow down a random tunnel when it cannot reach the room named', () => {
|
||||||
|
expect(tunnelsFrom(2)).not.toContain(8)
|
||||||
|
expect(tunnelsFrom(2)[0]).toBe(1)
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 20 })
|
||||||
|
// First roll picks tunnel 0 out of room 2, which is room 1: the hunter.
|
||||||
|
const { cave: after } = step(cave, { type: 'shoot', path: [2, 8] }, scripted(0))
|
||||||
|
expect(after.outcome).toBe('shot-yourself')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('will not fly further than five rooms', () => {
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 20 })
|
||||||
|
const { lines } = step(
|
||||||
|
cave,
|
||||||
|
{ type: 'shoot', path: [2, 3, 4, 5, 6, 7, 8] },
|
||||||
|
// Every roll picks the first tunnel, so nothing is left to chance.
|
||||||
|
scripted(0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
const flight = lines.find((l) => l.kind === 'shot')!.text
|
||||||
|
expect(flight.split(' - ')).toHaveLength(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('wakes the wumpus onto you when the roll says so', () => {
|
||||||
|
// Wumpus in 2, hunter in 1. The shot at 5 is a legal tunnel, so it costs
|
||||||
|
// no roll: the first roll is the wake (0.1 is under the 0.75 threshold)
|
||||||
|
// and the second picks from [1, 3, 10, 2] -- index 0 being room 1, which
|
||||||
|
// is where the hunter is standing.
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 2 })
|
||||||
|
const { cave: after } = step(cave, { type: 'shoot', path: [5] }, scripted(0.1, 0))
|
||||||
|
expect(after.outcome).toBe('eaten')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the wumpus asleep when the roll says so', () => {
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 2 })
|
||||||
|
// A miss (path goes to 5), then 0.99 -- above the 0.75 wake threshold.
|
||||||
|
const { cave: after } = step(cave, { type: 'shoot', path: [5] }, scripted(0.9, 0.99))
|
||||||
|
expect(after.wumpus).toBe(2)
|
||||||
|
expect(after.outcome).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ends the hunt when the last arrow misses', () => {
|
||||||
|
const cave = caveWith({ you: 1, wumpus: 20, arrows: 1 })
|
||||||
|
const { cave: after } = step(cave, { type: 'shoot', path: [5] }, scripted(0.9, 0.99))
|
||||||
|
expect(after.outcome).toBe('out-of-arrows')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a shot with nowhere to go, and does not cost an arrow', () => {
|
||||||
|
const cave = caveWith({ you: 1 })
|
||||||
|
const { cave: after } = step(cave, { type: 'shoot', path: [] }, makeRng(1))
|
||||||
|
expect(after.arrows).toBe(STARTING_ARROWS)
|
||||||
|
expect(after).toBe(cave)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a hunt that has already ended', () => {
|
||||||
|
it('ignores everything after it', () => {
|
||||||
|
const dead = caveWith({ outcome: 'eaten' })
|
||||||
|
expect(step(dead, { type: 'move', to: 2 }, makeRng(1)).cave).toBe(dead)
|
||||||
|
expect(step(dead, { type: 'shoot', path: [2] }, makeRng(1)).cave).toBe(dead)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the expedition around it', () => {
|
||||||
|
it('gives back one arrow for a kill, and never more than you started with', () => {
|
||||||
|
expect(arrowsAfterWin(2)).toBe(3)
|
||||||
|
expect(arrowsAfterWin(STARTING_ARROWS)).toBe(STARTING_ARROWS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ranks on wumpodes first, then arrows left, then rooms walked', () => {
|
||||||
|
const a = { bagged: 3, arrows: 1, roomsWalked: 40 }
|
||||||
|
const b = { bagged: 2, arrows: 5, roomsWalked: 4 }
|
||||||
|
const c = { bagged: 3, arrows: 4, roomsWalked: 90 }
|
||||||
|
const d = { bagged: 3, arrows: 4, roomsWalked: 12 }
|
||||||
|
expect([a, b, c, d].sort(compareHunts)).toEqual([d, c, a, b])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remaster must not be an easier game than the transcript.
|
||||||
|
*
|
||||||
|
* `CaveMap` decides what to draw from `cave.sensed` and `cave.visited`, so
|
||||||
|
* the guarantee it needs is that neither of those ever names a room the
|
||||||
|
* hunter has not been told about. That is asserted here rather than in the
|
||||||
|
* component, because it is a property of the engine and it would still have
|
||||||
|
* to hold if the map were thrown away tomorrow.
|
||||||
|
*/
|
||||||
|
describe('what the map is allowed to know', () => {
|
||||||
|
it('only ever records senses for rooms the hunter has stood in', () => {
|
||||||
|
for (let seed = 1; seed <= 120; seed++) {
|
||||||
|
const rng = makeRng(seed)
|
||||||
|
let cave = layCave(1, STARTING_ARROWS, rng)
|
||||||
|
for (let turn = 0; turn < 12 && !cave.outcome; turn++) {
|
||||||
|
const exits = tunnelsFrom(cave.you)
|
||||||
|
cave = step(cave, { type: 'move', to: exits[turn % 3]! }, rng).cave
|
||||||
|
}
|
||||||
|
for (const room of Object.keys(cave.sensed).map(Number)) {
|
||||||
|
expect(cave.visited).toContain(room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never records a sense that the room would not have printed', () => {
|
||||||
|
const rng = makeRng(4242)
|
||||||
|
let cave = layCave(1, STARTING_ARROWS, rng)
|
||||||
|
for (let turn = 0; turn < 10 && !cave.outcome; turn++) {
|
||||||
|
const exits = tunnelsFrom(cave.you)
|
||||||
|
cave = step(cave, { type: 'move', to: exits[turn % 3]! }, rng).cave
|
||||||
|
}
|
||||||
|
for (const [room, hazards] of Object.entries(cave.sensed)) {
|
||||||
|
// Recompute from scratch: what the room says now is what was stored.
|
||||||
|
expect(sense(cave, Number(room))).toEqual(hazards)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the whole thing, run a few hundred times', () => {
|
||||||
|
/**
|
||||||
|
* A hunter who walks at random and never shoots should die most of the
|
||||||
|
* time and should never hang, never leave the cave, and never end up in a
|
||||||
|
* room that does not exist. This is the cheap version of the lemonade
|
||||||
|
* stand's "run a few hundred seasons" check: it is not tuning anything,
|
||||||
|
* it is looking for a state the rules cannot produce.
|
||||||
|
*/
|
||||||
|
it('never leaves the cave or hangs', () => {
|
||||||
|
let deaths = 0
|
||||||
|
for (let seed = 1; seed <= 500; seed++) {
|
||||||
|
const rng = makeRng(seed)
|
||||||
|
let cave = layCave(1, STARTING_ARROWS, rng)
|
||||||
|
let turns = 0
|
||||||
|
while (!cave.outcome && turns < 200) {
|
||||||
|
const exits = tunnelsFrom(cave.you)
|
||||||
|
cave = step(cave, { type: 'move', to: exits[Math.floor(rng() * 3)]! }, rng).cave
|
||||||
|
expect(cave.you).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(cave.you).toBeLessThanOrEqual(ROOM_COUNT)
|
||||||
|
turns++
|
||||||
|
}
|
||||||
|
if (cave.outcome) deaths++
|
||||||
|
}
|
||||||
|
// Walking blind into a cave with four lethal rooms out of twenty is not
|
||||||
|
// survivable for long; if this ever drops, something has stopped biting.
|
||||||
|
expect(deaths).toBeGreaterThan(480)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { ROOM_COUNT, areConnected, isRoom, tunnelsFrom } from './cave'
|
||||||
|
import {
|
||||||
|
ARROWS_PER_CAVE,
|
||||||
|
BATS_TEXT,
|
||||||
|
BAT_COUNT,
|
||||||
|
DEATH_TEXT,
|
||||||
|
NO_TUNNEL_TEXT,
|
||||||
|
MAX_ARROW_PATH,
|
||||||
|
MAX_CARRIED_ARROWS,
|
||||||
|
PIT_COUNT,
|
||||||
|
SENSE_TEXT,
|
||||||
|
STARTING_ARROWS,
|
||||||
|
WIN_TEXT,
|
||||||
|
WUMPUS_WAKES,
|
||||||
|
} from './constants'
|
||||||
|
import { chance, type Rng } from './rng'
|
||||||
|
import type { Cave, Command, Hazard, Line, Outcome } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole game lives in this file and none of it knows what a screen is.
|
||||||
|
* `step` takes a cave and a command and hands back the cave that follows,
|
||||||
|
* plus the lines the machine would have printed. Both skins render those
|
||||||
|
* lines; neither one decides anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- laying out
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deal the hazards.
|
||||||
|
*
|
||||||
|
* Yob's listing allowed the hunter to start in the same room as a bat or a
|
||||||
|
* pit, and several later ports quietly stopped doing that because starting a
|
||||||
|
* game by falling down a hole is not a game. It is not a rule so much as an
|
||||||
|
* oversight, so it is fixed here: every hazard and the hunter get their own
|
||||||
|
* room. The wumpus may still be one tunnel away on the first turn -- that
|
||||||
|
* part is the game.
|
||||||
|
*/
|
||||||
|
export function layCave(depth: number, arrows: number, rng: Rng): Cave {
|
||||||
|
const rooms = shuffle(
|
||||||
|
Array.from({ length: ROOM_COUNT }, (_, i) => i + 1),
|
||||||
|
rng,
|
||||||
|
)
|
||||||
|
let n = 0
|
||||||
|
const wumpus = rooms[n++]!
|
||||||
|
const pits = rooms.slice(n, (n += PIT_COUNT))
|
||||||
|
const bats = rooms.slice(n, (n += BAT_COUNT))
|
||||||
|
const you = rooms[n]!
|
||||||
|
|
||||||
|
const cave: Cave = {
|
||||||
|
depth,
|
||||||
|
wumpus,
|
||||||
|
pits,
|
||||||
|
bats,
|
||||||
|
you,
|
||||||
|
start: you,
|
||||||
|
arrows,
|
||||||
|
visited: [you],
|
||||||
|
sensed: {},
|
||||||
|
outcome: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Record what the first room says before handing it over.
|
||||||
|
*
|
||||||
|
* Every other room gets this from `enter`, and leaving the first one out is
|
||||||
|
* an easy thing not to notice: the transcript is right either way, because
|
||||||
|
* `describe` recomputes the senses rather than reading them back. What goes
|
||||||
|
* wrong is the map, which reads `sensed` -- so a hunter who starts next to a
|
||||||
|
* pit would be told about it in words and see nothing marked, which makes
|
||||||
|
* the remaster a harder game than the original rather than the same one.
|
||||||
|
*/
|
||||||
|
return { ...cave, sensed: { [you]: sense(cave) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fisher-Yates, off the seeded stream so a cave replays exactly. */
|
||||||
|
function shuffle<T>(items: T[], rng: Rng): T[] {
|
||||||
|
const out = items.slice()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = <T,>(items: readonly T[], rng: Rng): T => items[Math.floor(rng() * items.length)]!
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ senses
|
||||||
|
|
||||||
|
/** What the hunter can smell, feel and hear from where they are standing. */
|
||||||
|
export function sense(cave: Cave, room = cave.you): Hazard[] {
|
||||||
|
const near = tunnelsFrom(room)
|
||||||
|
const out: Hazard[] = []
|
||||||
|
// Printed in this order every time, so the transcript reads the same way.
|
||||||
|
if (near.includes(cave.wumpus)) out.push('wumpus')
|
||||||
|
if (near.some((r) => cave.pits.includes(r))) out.push('pit')
|
||||||
|
if (near.some((r) => cave.bats.includes(r))) out.push('bats')
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lines a room prints on arrival: what it senses, then where it leads. */
|
||||||
|
export function describe(cave: Cave): Line[] {
|
||||||
|
const lines: Line[] = sense(cave).map((h) => ({ kind: 'sense', text: SENSE_TEXT[h] }))
|
||||||
|
lines.push({ kind: 'system', text: `YOU ARE IN ROOM ${cave.you}` })
|
||||||
|
lines.push({ kind: 'system', text: `TUNNELS LEAD TO ${tunnelsFrom(cave.you).join(' ')}` })
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record what this room told us, so the map can show it later. */
|
||||||
|
function remember(cave: Cave): Cave {
|
||||||
|
const here = sense(cave)
|
||||||
|
return { ...cave, sensed: { ...cave.sensed, [cave.you]: here } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ moving
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk into a room and find out what is in it.
|
||||||
|
*
|
||||||
|
* Bats are the awkward part and the reason this recurses: a bat drops you in
|
||||||
|
* a room chosen at random, and that room can hold the wumpus, a pit, or
|
||||||
|
* another bat. Yob's listing loops; so does this, with a stop after a handful
|
||||||
|
* of throws. Two bats in twenty rooms cannot actually bounce you forever, but
|
||||||
|
* an unbounded loop in a game nobody can inspect is worth not writing.
|
||||||
|
*/
|
||||||
|
function enter(cave: Cave, room: number, lines: Line[], rng: Rng, throws = 0): Cave {
|
||||||
|
const moved: Cave = {
|
||||||
|
...cave,
|
||||||
|
you: room,
|
||||||
|
visited: cave.visited.includes(room) ? cave.visited : [...cave.visited, room],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room === moved.wumpus) {
|
||||||
|
// Walking in on a sleeping wumpus wakes it, and it is faster than you.
|
||||||
|
lines.push({ kind: 'death', text: DEATH_TEXT.eaten! })
|
||||||
|
return { ...moved, outcome: 'eaten' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved.pits.includes(room)) {
|
||||||
|
lines.push({ kind: 'death', text: DEATH_TEXT['fell-in-a-pit']! })
|
||||||
|
return { ...moved, outcome: 'fell-in-a-pit' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moved.bats.includes(room) && throws < 5) {
|
||||||
|
lines.push({ kind: 'bats', text: BATS_TEXT })
|
||||||
|
// Anywhere but where you already are -- being dropped back is not a snatch.
|
||||||
|
const elsewhere = Array.from({ length: ROOM_COUNT }, (_, i) => i + 1).filter((r) => r !== room)
|
||||||
|
return enter(moved, pick(elsewhere, rng), lines, rng, throws + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return remember(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- shooting
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fly a crooked arrow.
|
||||||
|
*
|
||||||
|
* The hunter names up to five rooms. If the next named room is not reachable
|
||||||
|
* from the one the arrow is in, the arrow does not stop and it does not fail
|
||||||
|
* -- it blunders on into a random tunnel, which is how a crooked arrow ends
|
||||||
|
* up in the room behind you. That rule is the reason this game is frightening
|
||||||
|
* to play well: a long shot is a good way to shoot yourself.
|
||||||
|
*/
|
||||||
|
function flyArrow(cave: Cave, path: readonly number[], lines: Line[], rng: Rng): Cave {
|
||||||
|
let at = cave.you
|
||||||
|
const flown: number[] = []
|
||||||
|
|
||||||
|
for (const named of path.slice(0, MAX_ARROW_PATH)) {
|
||||||
|
const next = areConnected(at, named) ? named : pick(tunnelsFrom(at), rng)
|
||||||
|
flown.push(next)
|
||||||
|
at = next
|
||||||
|
|
||||||
|
if (at === cave.wumpus) {
|
||||||
|
lines.push({ kind: 'win', text: WIN_TEXT })
|
||||||
|
lines.push({ kind: 'system', text: 'THERE IS ANOTHER ONE FURTHER IN. THERE ALWAYS IS.' })
|
||||||
|
return { ...cave, arrows: cave.arrows - 1, outcome: 'shot-the-wumpus' }
|
||||||
|
}
|
||||||
|
if (at === cave.you) {
|
||||||
|
lines.push({ kind: 'death', text: DEATH_TEXT['shot-yourself']! })
|
||||||
|
return { ...cave, arrows: cave.arrows - 1, outcome: 'shot-yourself' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push({ kind: 'shot', text: `MISSED. THE ARROW WENT ${flown.join(' - ')}` })
|
||||||
|
return { ...cave, arrows: cave.arrows - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shot that misses wakes the wumpus, and a woken wumpus takes one of four
|
||||||
|
* doors: the three tunnels out, or the floor it is already lying on. If it
|
||||||
|
* picks the room the hunter is standing in, the hunt is over.
|
||||||
|
*/
|
||||||
|
function stirWumpus(cave: Cave, lines: Line[], rng: Rng): Cave {
|
||||||
|
if (!chance(rng, WUMPUS_WAKES)) return cave
|
||||||
|
|
||||||
|
const options = [...tunnelsFrom(cave.wumpus), cave.wumpus]
|
||||||
|
const to = pick(options, rng)
|
||||||
|
const moved = { ...cave, wumpus: to }
|
||||||
|
|
||||||
|
if (to === cave.you) {
|
||||||
|
lines.push({ kind: 'death', text: DEATH_TEXT.eaten! })
|
||||||
|
return { ...moved, outcome: 'eaten' }
|
||||||
|
}
|
||||||
|
// It moved, and the only way to know is that the room smells different now.
|
||||||
|
return remember(moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- step
|
||||||
|
|
||||||
|
export interface StepResult {
|
||||||
|
cave: Cave
|
||||||
|
lines: Line[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One turn. Everything above, in the order the 1973 game does it.
|
||||||
|
*
|
||||||
|
* A command that does not make sense does not cost a turn and does not end
|
||||||
|
* the game -- it is refused with the same words the listing used, because a
|
||||||
|
* player who mistypes a room number has not made a decision worth punishing.
|
||||||
|
*/
|
||||||
|
export function step(cave: Cave, command: Command, rng: Rng): StepResult {
|
||||||
|
const lines: Line[] = []
|
||||||
|
if (cave.outcome) return { cave, lines }
|
||||||
|
|
||||||
|
if (command.type === 'move') {
|
||||||
|
if (!isRoom(command.to) || !areConnected(cave.you, command.to)) {
|
||||||
|
lines.push({ kind: 'system', text: NO_TUNNEL_TEXT })
|
||||||
|
return { cave, lines }
|
||||||
|
}
|
||||||
|
return { cave: enter(cave, command.to, lines, rng), lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = command.path.filter(isRoom).slice(0, MAX_ARROW_PATH)
|
||||||
|
if (path.length === 0) {
|
||||||
|
lines.push({ kind: 'system', text: NO_TUNNEL_TEXT })
|
||||||
|
return { cave, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = flyArrow(cave, path, lines, rng)
|
||||||
|
if (next.outcome) return { cave: next, lines }
|
||||||
|
|
||||||
|
next = stirWumpus(next, lines, rng)
|
||||||
|
if (next.outcome) return { cave: next, lines }
|
||||||
|
|
||||||
|
if (next.arrows <= 0) {
|
||||||
|
lines.push({ kind: 'death', text: DEATH_TEXT['out-of-arrows']! })
|
||||||
|
return { cave: { ...next, outcome: 'out-of-arrows' }, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cave: next, lines }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- expeditions
|
||||||
|
|
||||||
|
/** What a hunter carries into the next cave after bagging one. */
|
||||||
|
export const arrowsAfterWin = (left: number): number =>
|
||||||
|
Math.min(MAX_CARRIED_ARROWS, left + ARROWS_PER_CAVE)
|
||||||
|
|
||||||
|
export const startingArrows = () => STARTING_ARROWS
|
||||||
|
|
||||||
|
export const outcomeText = (o: Outcome): string =>
|
||||||
|
o === 'shot-the-wumpus' ? WIN_TEXT : (DEATH_TEXT[o] ?? '')
|
||||||
|
|
||||||
|
export const randomSeed = () => Math.floor(Math.random() * 1_000_000) + 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hunt is worth ranking on what it caught first, then on the two things
|
||||||
|
* that separate a careful hunter from a lucky one: arrows they did not need
|
||||||
|
* to fire, and rooms they did not need to walk.
|
||||||
|
*/
|
||||||
|
export function compareHunts(
|
||||||
|
a: { bagged: number; arrows: number; roomsWalked: number },
|
||||||
|
b: { bagged: number; arrows: number; roomsWalked: number },
|
||||||
|
): number {
|
||||||
|
if (a.bagged !== b.bagged) return b.bagged - a.bagged
|
||||||
|
if (a.arrows !== b.arrows) return b.arrows - a.arrows
|
||||||
|
return a.roomsWalked - b.roomsWalked
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* One board, shared by everybody, held by the scores service rather than the
|
||||||
|
* browser. Nothing about it is trusted on the way in: the server decides what
|
||||||
|
* is plausible, and the client re-checks the shape of whatever comes back
|
||||||
|
* before putting it on screen.
|
||||||
|
*
|
||||||
|
* The service is shared with the other games on the site rather than being one
|
||||||
|
* of ours -- see https://github.com/Coffey-Labs/games-scores. That is why
|
||||||
|
* every call names the game. Nothing else about it leaks in here: the rows
|
||||||
|
* come back in this game's own field names, so this file would be identical if
|
||||||
|
* the board were ours alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Which board on the shared service is ours. */
|
||||||
|
export const GAME = 'wumpus'
|
||||||
|
|
||||||
|
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
|
||||||
|
const TIMEOUT_MS = 8000
|
||||||
|
|
||||||
|
export const MAX_SCORES = 50
|
||||||
|
|
||||||
|
export interface Score {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
/** Wumpodes bagged. The rank, before any tie-break. */
|
||||||
|
bagged: number
|
||||||
|
/** Arrows still in the quiver when the expedition ended. */
|
||||||
|
arrows: number
|
||||||
|
roomsWalked: number
|
||||||
|
/** How it ended, or null for a hunter who climbed out on their own legs. */
|
||||||
|
died: string | null
|
||||||
|
at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewScore = Omit<Score, 'id' | 'at'>
|
||||||
|
|
||||||
|
function isScore(v: unknown): v is Score {
|
||||||
|
if (typeof v !== 'object' || v === null) return false
|
||||||
|
const s = v as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
typeof s.id === 'string' &&
|
||||||
|
typeof s.name === 'string' &&
|
||||||
|
Number.isFinite(s.bagged) &&
|
||||||
|
Number.isFinite(s.arrows) &&
|
||||||
|
Number.isFinite(s.roomsWalked) &&
|
||||||
|
(s.died === null || typeof s.died === 'string') &&
|
||||||
|
Number.isFinite(s.at)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseBoard = (body: unknown): Score[] => {
|
||||||
|
const rows = (body as { scores?: unknown })?.scores
|
||||||
|
if (!Array.isArray(rows)) return []
|
||||||
|
return rows
|
||||||
|
.filter(isScore)
|
||||||
|
.map((s) => ({ ...s, name: s.name.slice(0, 12) }))
|
||||||
|
.slice(0, MAX_SCORES)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(path: string, init?: RequestInit): Promise<unknown> {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
|
||||||
|
})
|
||||||
|
const body: unknown = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) {
|
||||||
|
const why = (body as { error?: string })?.error
|
||||||
|
throw new Error(why ?? `scores service returned ${res.status}`)
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchScores(): Promise<Score[]> {
|
||||||
|
return parseBoard(await call(`/scores?game=${GAME}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts every hunter in the party at once and returns the new board. */
|
||||||
|
export async function submitScores(
|
||||||
|
entries: NewScore[],
|
||||||
|
): Promise<{ ids: string[]; scores: Score[] }> {
|
||||||
|
const body = await call('/scores', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ game: GAME, entries }),
|
||||||
|
})
|
||||||
|
const ids = (body as { ids?: unknown })?.ids
|
||||||
|
return {
|
||||||
|
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
|
||||||
|
scores: parseBoard(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { STARTING_ARROWS } from './constants'
|
||||||
|
import { arrowsAfterWin } from './engine'
|
||||||
|
import type { Cave, Hunter, Line, Phase } from './types'
|
||||||
|
import { isWin } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phase machine, and the only place a turn changes hands.
|
||||||
|
*
|
||||||
|
* A turn is one cave, not one move. A hunter walks their cave until it kills
|
||||||
|
* them or they kill it, and only then does the teletype go quiet and the next
|
||||||
|
* hunter sit down. Splitting turns move-by-move would be fairer in some
|
||||||
|
* abstract sense and unplayable in practice: this is a game about what you
|
||||||
|
* have worked out, and taking turns to walk into the same darkness would mean
|
||||||
|
* four people watching one person think.
|
||||||
|
*/
|
||||||
|
export interface GameState {
|
||||||
|
phase: Phase
|
||||||
|
seed: number
|
||||||
|
hunters: Hunter[]
|
||||||
|
/** Index into `hunters`; the dead are skipped, never removed. */
|
||||||
|
turn: number
|
||||||
|
cave: Cave | null
|
||||||
|
/** Everything the machine has printed for the cave now being walked. */
|
||||||
|
transcript: Line[]
|
||||||
|
/** Set once somebody calls the expedition off, so the standings say so. */
|
||||||
|
climbedOut: boolean
|
||||||
|
/** Where the board was opened from, so BACK has somewhere to go. */
|
||||||
|
scoresReturn: Phase
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialState = (seed: number, phase: Phase = 'boot'): GameState => ({
|
||||||
|
phase,
|
||||||
|
seed,
|
||||||
|
hunters: [],
|
||||||
|
turn: 0,
|
||||||
|
cave: null,
|
||||||
|
transcript: [],
|
||||||
|
climbedOut: false,
|
||||||
|
scoresReturn: 'title',
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Action =
|
||||||
|
| { type: 'BOOTED' }
|
||||||
|
| { type: 'SHOW_INSTRUCTIONS' }
|
||||||
|
| { type: 'SHOW_SETUP' }
|
||||||
|
| { type: 'START'; names: string[] }
|
||||||
|
| { type: 'ENTER_CAVE'; cave: Cave; lines: Line[] }
|
||||||
|
| { type: 'PRINT'; lines: Line[] }
|
||||||
|
| { type: 'ADVANCE'; cave: Cave; lines: Line[] }
|
||||||
|
| { type: 'SHOW_REPORT' }
|
||||||
|
| { type: 'NEXT_TURN' }
|
||||||
|
| { type: 'CLIMB_OUT' }
|
||||||
|
| { type: 'SHOW_SCORES' }
|
||||||
|
| { type: 'CLOSE_SCORES' }
|
||||||
|
| { type: 'RESTART'; seed: number }
|
||||||
|
|
||||||
|
export const livingHunters = (s: GameState) => s.hunters.filter((h) => !h.dead)
|
||||||
|
|
||||||
|
/** Whose turn it is, or undefined once everybody is out of the cave. */
|
||||||
|
export const currentHunter = (s: GameState): Hunter | undefined => s.hunters[s.turn]
|
||||||
|
|
||||||
|
/** How deep the hunter about to play is going: one more than they have bagged. */
|
||||||
|
export const depthFor = (h: Hunter): number => h.bagged + 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next living hunter after `from`, wrapping once. Returns -1 when there
|
||||||
|
* is nobody left, which is what ends the expedition.
|
||||||
|
*/
|
||||||
|
export function nextLiving(hunters: Hunter[], from: number): number {
|
||||||
|
for (let i = 1; i <= hunters.length; i++) {
|
||||||
|
const at = (from + i) % hunters.length
|
||||||
|
if (!hunters[at]!.dead) return at
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reducer(state: GameState, action: Action): GameState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'BOOTED':
|
||||||
|
return { ...state, phase: 'title' }
|
||||||
|
|
||||||
|
case 'SHOW_INSTRUCTIONS':
|
||||||
|
return { ...state, phase: 'instructions' }
|
||||||
|
|
||||||
|
case 'SHOW_SETUP':
|
||||||
|
return { ...state, phase: 'setup' }
|
||||||
|
|
||||||
|
case 'START':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
phase: 'hunting',
|
||||||
|
hunters: action.names.map((name, i) => ({
|
||||||
|
id: i,
|
||||||
|
name: name.trim().toUpperCase() || `HUNTER ${i + 1}`,
|
||||||
|
bagged: 0,
|
||||||
|
arrows: STARTING_ARROWS,
|
||||||
|
roomsWalked: 0,
|
||||||
|
dead: false,
|
||||||
|
died: null,
|
||||||
|
diedAtDepth: null,
|
||||||
|
})),
|
||||||
|
turn: 0,
|
||||||
|
cave: null,
|
||||||
|
transcript: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ENTER_CAVE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
phase: 'hunting',
|
||||||
|
cave: action.cave,
|
||||||
|
transcript: action.lines,
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PRINT':
|
||||||
|
return { ...state, transcript: [...state.transcript, ...action.lines] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One turn's worth of consequences. The hunter's running totals are kept
|
||||||
|
* on the hunter rather than recomputed from the cave, because a cave is
|
||||||
|
* thrown away at the end of a hunt and the expedition is not.
|
||||||
|
*/
|
||||||
|
case 'ADVANCE': {
|
||||||
|
const before = state.cave
|
||||||
|
const cave = action.cave
|
||||||
|
const walked = before && cave.you !== before.you ? 1 : 0
|
||||||
|
|
||||||
|
const hunters = state.hunters.map((h) =>
|
||||||
|
h.id !== currentHunter(state)?.id
|
||||||
|
? h
|
||||||
|
: {
|
||||||
|
...h,
|
||||||
|
roomsWalked: h.roomsWalked + walked,
|
||||||
|
arrows: cave.arrows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
cave,
|
||||||
|
hunters,
|
||||||
|
transcript: [...state.transcript, ...action.lines],
|
||||||
|
phase: cave.outcome ? 'resolve' : 'hunting',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The hunt has ended; settle it against the hunter and show the report. */
|
||||||
|
case 'SHOW_REPORT': {
|
||||||
|
const cave = state.cave
|
||||||
|
const who = currentHunter(state)
|
||||||
|
if (!cave?.outcome || !who) return { ...state, phase: 'report' }
|
||||||
|
|
||||||
|
const won = isWin(cave.outcome)
|
||||||
|
const hunters = state.hunters.map((h) =>
|
||||||
|
h.id !== who.id
|
||||||
|
? h
|
||||||
|
: {
|
||||||
|
...h,
|
||||||
|
bagged: won ? h.bagged + 1 : h.bagged,
|
||||||
|
arrows: won ? arrowsAfterWin(cave.arrows) : cave.arrows,
|
||||||
|
dead: !won,
|
||||||
|
died: won ? null : cave.outcome,
|
||||||
|
diedAtDepth: won ? null : cave.depth,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return { ...state, phase: 'report', hunters }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'NEXT_TURN': {
|
||||||
|
const at = nextLiving(state.hunters, state.turn)
|
||||||
|
if (at === -1) return { ...state, phase: 'gameover', cave: null }
|
||||||
|
return { ...state, phase: 'hunting', turn: at, cave: null, transcript: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIMB_OUT':
|
||||||
|
return { ...state, phase: 'gameover', climbedOut: true, cave: null }
|
||||||
|
|
||||||
|
case 'SHOW_SCORES':
|
||||||
|
return state.phase === 'scores'
|
||||||
|
? state
|
||||||
|
: { ...state, phase: 'scores', scoresReturn: state.phase }
|
||||||
|
|
||||||
|
case 'CLOSE_SCORES':
|
||||||
|
return { ...state, phase: state.scoresReturn }
|
||||||
|
|
||||||
|
case 'RESTART':
|
||||||
|
// The tape only loads once a session; a new expedition starts at the title.
|
||||||
|
return initialState(action.seed, 'title')
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* mulberry32 — small, fast, seedable PRNG.
|
||||||
|
* The 1973 listing ran on RND(1); we keep a seed so a cave can be replayed or
|
||||||
|
* shared, and so a test can lay out the same hazards 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 const chance = (rng: Rng, p: number) => rng() < p
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
export type Hazard = 'wumpus' | 'pit' | 'bats'
|
||||||
|
|
||||||
|
/** What is where. Never handed to a screen that has not earned it. */
|
||||||
|
export interface Cave {
|
||||||
|
/** Which cave of the expedition this is, counting from 1. */
|
||||||
|
depth: number
|
||||||
|
wumpus: number
|
||||||
|
pits: readonly number[]
|
||||||
|
bats: readonly number[]
|
||||||
|
/** Where the hunter is standing. */
|
||||||
|
you: number
|
||||||
|
/** Where the hunter started, so a transcript can say so. */
|
||||||
|
start: number
|
||||||
|
arrows: number
|
||||||
|
/** Rooms the hunter has stood in, in order, including the one they are in. */
|
||||||
|
visited: readonly number[]
|
||||||
|
/** Rooms the hunter has been warned about, whether or not they went. */
|
||||||
|
sensed: Readonly<Record<number, readonly Hazard[]>>
|
||||||
|
outcome: Outcome | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Outcome =
|
||||||
|
| 'shot-the-wumpus'
|
||||||
|
| 'eaten'
|
||||||
|
| 'fell-in-a-pit'
|
||||||
|
| 'shot-yourself'
|
||||||
|
| 'out-of-arrows'
|
||||||
|
|
||||||
|
/** True when the hunt ended in a way the hunter walks away from. */
|
||||||
|
export const isWin = (o: Outcome | null): boolean => o === 'shot-the-wumpus'
|
||||||
|
|
||||||
|
export interface Hunter {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
/** Wumpodes bagged. The score, before any tie-break. */
|
||||||
|
bagged: number
|
||||||
|
/** Arrows carried into the next cave; a win keeps what is left of them. */
|
||||||
|
arrows: number
|
||||||
|
/** Rooms entered across the whole expedition, for the tie-break. */
|
||||||
|
roomsWalked: number
|
||||||
|
dead: boolean
|
||||||
|
/** How the expedition ended, for the standings. */
|
||||||
|
died: Outcome | null
|
||||||
|
diedAtDepth: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line of the transcript. The teletype prints these and the map reads
|
||||||
|
* them for its log, so both skins say exactly the same things.
|
||||||
|
*/
|
||||||
|
export interface Line {
|
||||||
|
kind: 'system' | 'sense' | 'move' | 'shot' | 'death' | 'win' | 'prompt' | 'bats'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Phase =
|
||||||
|
| 'boot'
|
||||||
|
| 'title'
|
||||||
|
| 'instructions'
|
||||||
|
| 'setup'
|
||||||
|
| 'hunting'
|
||||||
|
| 'resolve'
|
||||||
|
| 'report'
|
||||||
|
| 'gameover'
|
||||||
|
| 'scores'
|
||||||
|
|
||||||
|
/** A move or a shot, before the cave has had its say. */
|
||||||
|
export type Command =
|
||||||
|
| { type: 'move'; to: number }
|
||||||
|
| { type: 'shoot'; path: readonly number[] }
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
:root {
|
||||||
|
font-synthesis: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The root is a flex item of body; without this the cabinet shrinks to fit. */
|
||||||
|
#root {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The room the machine is standing in. 1973 is a fluorescent-lit terminal
|
||||||
|
room; the remaster is somewhere underground with a lamp. */
|
||||||
|
body {
|
||||||
|
background: radial-gradient(120% 80% at 50% 0%, #5a5a5a 0%, #3a3a3a 55%, #232323 100%);
|
||||||
|
color: #241f1a;
|
||||||
|
font-family: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(8px, 2vw, 28px);
|
||||||
|
transition: background 400ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-skin='modern'] body {
|
||||||
|
background:
|
||||||
|
radial-gradient(80% 55% at 20% 0%, rgba(255, 208, 138, 0.16) 0%, transparent 60%),
|
||||||
|
radial-gradient(70% 55% at 85% 20%, rgba(140, 90, 220, 0.22) 0%, transparent 55%),
|
||||||
|
radial-gradient(130% 95% at 50% 100%, #2a2135 0%, #191322 55%, #0c0812 100%);
|
||||||
|
color: #f4ece0;
|
||||||
|
font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import { SkinProvider } from './SkinProvider.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<SkinProvider>
|
||||||
|
<App />
|
||||||
|
</SkinProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two skins over one game. "teletype" is 1973 as it actually was -- a
|
||||||
|
* printing terminal, no screen, no colour and no sound but the print head.
|
||||||
|
* "modern" is the same cave with the lamp on. The rules never differ between
|
||||||
|
* them, and neither does a single word the machine prints.
|
||||||
|
*
|
||||||
|
* Worth saying why this is a Teletype rather than a CRT, since the lemonade
|
||||||
|
* stand's period skin is an Atari television. Wumpus is six years older than
|
||||||
|
* that machine. It was written for a timesharing system and played on paper,
|
||||||
|
* which is why the game asks you to remember things: there is no screen to
|
||||||
|
* look back at, only what you have already torn off.
|
||||||
|
*/
|
||||||
|
export type Skin = 'teletype' | 'modern'
|
||||||
|
|
||||||
|
export const SKIN_KEY = 'wumpus.skin.v1'
|
||||||
|
|
||||||
|
export function readSkin(): Skin {
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(SKIN_KEY) === 'teletype' ? 'teletype' : 'modern'
|
||||||
|
} catch {
|
||||||
|
return 'modern'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeSkin(s: Skin): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(SKIN_KEY, s)
|
||||||
|
} catch {
|
||||||
|
// A skin that does not stick is a small loss; carry on.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinApi {
|
||||||
|
skin: Skin
|
||||||
|
setSkin: (s: Skin) => void
|
||||||
|
toggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkinContext = createContext<SkinApi>({
|
||||||
|
skin: 'modern',
|
||||||
|
setSkin: () => {},
|
||||||
|
toggle: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useSkin = () => useContext(SkinContext)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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.
|
||||||
|
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()],
|
||||||
|
server: {
|
||||||
|
// The game talks to /api in every environment; in production that is
|
||||||
|
// nginx in front of the shared scores container. In development, run
|
||||||
|
// https://github.com/Coffey-Labs/games-scores alongside this -- or do not,
|
||||||
|
// and the board will say so rather than breaking.
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.SCORES_TARGET ?? 'http://localhost:5184',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# The game is a static bundle. It is built here and served by nginx, in its own
|
||||||
|
# container - it shares nothing with the scores service but the domain.
|
||||||
|
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=/wumpus/
|
||||||
|
ENV BASE_PATH=${BASE_PATH}
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
# Hashed filenames, so they can be cached hard.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user