Compare commits
2 Commits
48a9120fe4
...
5c3393b989
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c3393b989 | ||
|
|
5210266fd6 |
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/* Quantum Arcade client.
|
/* Quantum Arcade client.
|
||||||
*
|
*
|
||||||
* Identity is an ed25519 keypair generated in the browser and kept in
|
* Identity is a keypair generated in the browser and kept in localStorage.
|
||||||
* localStorage. There is no account to create and no password to lose.
|
* There is no account to create and no password to lose.
|
||||||
*
|
*
|
||||||
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
* The verifier recomputes round outcomes locally with WebCrypto. It never asks
|
||||||
* the server whether a round was fair — it checks.
|
* the server whether a round was fair — it checks.
|
||||||
@@ -10,17 +10,53 @@
|
|||||||
* Nothing that originates from another player (nicknames, keys) or from the
|
* Nothing that originates from another player (nicknames, keys) or from the
|
||||||
* server ever reaches innerHTML. */
|
* server ever reaches innerHTML. */
|
||||||
|
|
||||||
|
import { Arcade3D } from '/scene3d.js';
|
||||||
|
import * as charts from '/charts.js';
|
||||||
|
|
||||||
const KEY_STORAGE = 'quantum-arcade-key';
|
const KEY_STORAGE = 'quantum-arcade-key';
|
||||||
const NAME_STORAGE = 'quantum-arcade-name';
|
const NAME_STORAGE = 'quantum-arcade-name';
|
||||||
|
const STATS_STORAGE = 'quantum-arcade-stats';
|
||||||
|
|
||||||
let keypair = null; // { publicKeyHex, privateKey (CryptoKey) }
|
let keypair = null;
|
||||||
let token = null;
|
let token = null;
|
||||||
let nickname = '';
|
let nickname = '';
|
||||||
let stake = 5000; // millisatoshis
|
let stake = 5000; // millisatoshis
|
||||||
let currentGame = 'rocket';
|
let currentGame = 'rocket';
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let snapshot = null;
|
let snapshot = null;
|
||||||
let myBet = null; // 'in' | 'out' | null
|
let myBet = null; // 'in' | 'out' | null
|
||||||
|
let scene = null;
|
||||||
|
let balanceMsat = 0;
|
||||||
|
|
||||||
|
/* Session statistics, kept client-side. The ledger remains the authority on
|
||||||
|
* money; this is only for the charts. */
|
||||||
|
const stats = loadStats();
|
||||||
|
|
||||||
|
function loadStats() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(STATS_STORAGE) || '{}');
|
||||||
|
return {
|
||||||
|
crashes: raw.crashes || [], // crash points seen, for distribution
|
||||||
|
balances: raw.balances || [], // balance samples over time
|
||||||
|
wagered: raw.wagered || 0,
|
||||||
|
plays: raw.plays || 0,
|
||||||
|
wins: raw.wins || 0,
|
||||||
|
losses: raw.losses || 0,
|
||||||
|
best: raw.best || 0,
|
||||||
|
sessionStart: null, // set at sign-in, never persisted
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { crashes: [], balances: [], wagered: 0, plays: 0,
|
||||||
|
wins: 0, losses: 0, best: 0, sessionStart: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStats() {
|
||||||
|
// Cap the arrays so localStorage cannot grow without bound over a long night.
|
||||||
|
stats.crashes = stats.crashes.slice(-300);
|
||||||
|
stats.balances = stats.balances.slice(-300);
|
||||||
|
localStorage.setItem(STATS_STORAGE, JSON.stringify(stats));
|
||||||
|
}
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
const sats = (msat) => Math.round(msat / 1000).toLocaleString();
|
||||||
@@ -44,6 +80,11 @@ function clear(node) {
|
|||||||
while (node.firstChild) node.removeChild(node.firstChild);
|
while (node.firstChild) node.removeChild(node.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A short haptic tap. Phones only, and silently absent elsewhere. */
|
||||||
|
function buzz(ms) {
|
||||||
|
if (navigator.vibrate) navigator.vibrate(ms);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- identity ---------------- */
|
/* ---------------- identity ---------------- */
|
||||||
|
|
||||||
async function loadOrCreateKey() {
|
async function loadOrCreateKey() {
|
||||||
@@ -88,10 +129,16 @@ async function signIn() {
|
|||||||
|
|
||||||
$('signin').hidden = true;
|
$('signin').hidden = true;
|
||||||
$('app').hidden = false;
|
$('app').hidden = false;
|
||||||
|
$('tabs').hidden = false;
|
||||||
$('balance-wrap').hidden = false;
|
$('balance-wrap').hidden = false;
|
||||||
setBalance(res.balance_msat);
|
setBalance(res.balance_msat);
|
||||||
|
stats.sessionStart = res.balance_msat;
|
||||||
$('pubkey').textContent = keypair.publicKeyHex;
|
$('pubkey').textContent = keypair.publicKeyHex;
|
||||||
|
|
||||||
|
scene = new Arcade3D($('scene'));
|
||||||
|
scene.setGame(currentGame);
|
||||||
|
scene.start();
|
||||||
|
|
||||||
connect(currentGame);
|
connect(currentGame);
|
||||||
loadScratch();
|
loadScratch();
|
||||||
}
|
}
|
||||||
@@ -102,8 +149,7 @@ async function api(method, path, body) {
|
|||||||
const headers = { 'Content-Type': 'application/json' };
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method, headers,
|
||||||
headers,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -112,11 +158,17 @@ async function api(method, path, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setBalance(msat) {
|
function setBalance(msat) {
|
||||||
|
balanceMsat = msat;
|
||||||
$('balance').textContent = sats(msat);
|
$('balance').textContent = sats(msat);
|
||||||
|
const last = stats.balances[stats.balances.length - 1];
|
||||||
|
if (last !== msat) {
|
||||||
|
stats.balances.push(msat);
|
||||||
|
saveStats();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reads the auto cash-out box. Returns 0 when empty or invalid, which the
|
/* ---------------- auto cash-out ---------------- */
|
||||||
* server treats as "no target". */
|
|
||||||
function autoTarget() {
|
function autoTarget() {
|
||||||
const v = parseFloat($('auto-target').value);
|
const v = parseFloat($('auto-target').value);
|
||||||
return Number.isFinite(v) && v > 1 ? v : 0;
|
return Number.isFinite(v) && v > 1 ? v : 0;
|
||||||
@@ -132,6 +184,7 @@ function connect(game) {
|
|||||||
if (socket) socket.close();
|
if (socket) socket.close();
|
||||||
currentGame = game;
|
currentGame = game;
|
||||||
myBet = null;
|
myBet = null;
|
||||||
|
if (scene) scene.setGame(game);
|
||||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
|
||||||
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
|
||||||
@@ -140,37 +193,54 @@ function connect(game) {
|
|||||||
|
|
||||||
function onSnapshot(s) {
|
function onSnapshot(s) {
|
||||||
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
|
||||||
|
const wasSettled = snapshot && snapshot.state === 'settled';
|
||||||
snapshot = s;
|
snapshot = s;
|
||||||
if (roundChanged) myBet = null;
|
if (roundChanged) myBet = null;
|
||||||
|
|
||||||
const mult = $('multiplier');
|
const mult = $('multiplier');
|
||||||
mult.textContent = parseFloat(s.multiplier).toFixed(2) + '×';
|
const current = parseFloat(s.multiplier);
|
||||||
|
mult.textContent = current.toFixed(2) + '×';
|
||||||
mult.className = 'multiplier';
|
mult.className = 'multiplier';
|
||||||
|
|
||||||
$('commitment').textContent = s.commitment || '—';
|
$('commitment').textContent = s.commitment || '—';
|
||||||
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
$('revealed').textContent = s.server_seed || 'sealed until the round ends';
|
||||||
|
|
||||||
|
const potMsat = (s.players || []).reduce((n, p) => n + p.stake_msat, 0);
|
||||||
|
$('potline').textContent = potMsat
|
||||||
|
? `${(s.players || []).length} in · ${sats(potMsat)} sats at stake`
|
||||||
|
: 'no bets yet';
|
||||||
|
|
||||||
|
// Drive the 3D scene. Progress is log-scaled so the early climb is visible
|
||||||
|
// and the tail does not saturate instantly.
|
||||||
|
if (scene) {
|
||||||
|
scene.setState(Math.log(Math.max(1, current)) / Math.log(25),
|
||||||
|
s.state === 'settled');
|
||||||
|
}
|
||||||
|
|
||||||
const action = $('action');
|
const action = $('action');
|
||||||
const hint = $('hint');
|
const hint = $('hint');
|
||||||
|
|
||||||
switch (s.state) {
|
switch (s.state) {
|
||||||
case 'betting_open':
|
case 'betting_open':
|
||||||
$('state').textContent = `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
$('state').textContent =
|
||||||
|
`betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||||
action.textContent = myBet
|
action.textContent = myBet
|
||||||
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
|
? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
|
||||||
: 'Place bet';
|
: 'Place bet';
|
||||||
action.className = 'primary big';
|
action.className = 'primary big';
|
||||||
action.disabled = !!myBet;
|
action.disabled = !!myBet;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'locked':
|
case 'locked':
|
||||||
$('state').textContent = 'launching';
|
$('state').textContent = 'launching';
|
||||||
action.textContent = 'Launching…';
|
action.textContent = 'Launching…';
|
||||||
action.disabled = true;
|
action.disabled = true;
|
||||||
break;
|
break;
|
||||||
case 'running':
|
|
||||||
|
case 'running': {
|
||||||
$('state').textContent = 'in flight';
|
$('state').textContent = 'in flight';
|
||||||
if (myBet === 'in') {
|
if (myBet === 'in') {
|
||||||
const payout = stake * parseFloat(s.multiplier);
|
const payout = stake * current;
|
||||||
action.textContent = `Cash out ${sats(payout)}`;
|
action.textContent = `Cash out ${sats(payout)}`;
|
||||||
action.className = 'primary big cashout';
|
action.className = 'primary big cashout';
|
||||||
action.disabled = false;
|
action.disabled = false;
|
||||||
@@ -180,21 +250,49 @@ function onSnapshot(s) {
|
|||||||
action.disabled = true;
|
action.disabled = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'settled':
|
}
|
||||||
if (s.crash_point) mult.textContent = parseFloat(s.crash_point).toFixed(2) + '×';
|
|
||||||
|
case 'settled': {
|
||||||
|
const crash = s.crash_point ? parseFloat(s.crash_point) : current;
|
||||||
|
mult.textContent = crash.toFixed(2) + '×';
|
||||||
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
|
||||||
$('state').textContent =
|
$('state').textContent =
|
||||||
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
`crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
|
||||||
action.textContent = 'Next round';
|
action.textContent = 'Next round';
|
||||||
action.className = 'primary big';
|
action.className = 'primary big';
|
||||||
action.disabled = true;
|
action.disabled = true;
|
||||||
if (myBet === 'in') { hint.textContent = 'Rode it too far.'; hint.className = 'hint bad'; }
|
|
||||||
|
// Record the round once, on the transition into settled.
|
||||||
|
if (!wasSettled && s.crash_point) {
|
||||||
|
stats.crashes.push(crash);
|
||||||
|
if (myBet === 'in') {
|
||||||
|
stats.losses++;
|
||||||
|
hint.textContent = 'Rode it too far.';
|
||||||
|
hint.className = 'hint bad';
|
||||||
|
buzz(120);
|
||||||
|
} else if (myBet === 'out') {
|
||||||
|
stats.wins++;
|
||||||
|
buzz([30, 40, 30]);
|
||||||
|
}
|
||||||
|
saveStats();
|
||||||
|
renderStrip();
|
||||||
|
refreshBalance();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPlayers(s.players);
|
renderPlayers(s.players);
|
||||||
draw(s);
|
}
|
||||||
if (s.state === 'running') tone(parseFloat(s.multiplier));
|
|
||||||
|
/* The recent-rounds strip under the stage. */
|
||||||
|
function renderStrip() {
|
||||||
|
const wrap = $('strip');
|
||||||
|
clear(wrap);
|
||||||
|
for (const c of stats.crashes.slice(-24).reverse()) {
|
||||||
|
const cls = c >= 10 ? 'pip high' : c >= 2 ? 'pip mid' : 'pip';
|
||||||
|
wrap.appendChild(el('span', { class: cls, text: c.toFixed(2) + '×' }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayers(players) {
|
function renderPlayers(players) {
|
||||||
@@ -210,6 +308,13 @@ function renderPlayers(players) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshBalance() {
|
||||||
|
try {
|
||||||
|
const b = await api('GET', '/api/balance');
|
||||||
|
setBalance(b.balance_msat);
|
||||||
|
} catch { /* a failed refresh is cosmetic; the ledger is still correct */ }
|
||||||
|
}
|
||||||
|
|
||||||
async function onAction() {
|
async function onAction() {
|
||||||
const hint = $('hint');
|
const hint = $('hint');
|
||||||
hint.textContent = '';
|
hint.textContent = '';
|
||||||
@@ -221,14 +326,22 @@ async function onAction() {
|
|||||||
auto_cashout: autoTarget(),
|
auto_cashout: autoTarget(),
|
||||||
});
|
});
|
||||||
setBalance(r.balance_msat);
|
setBalance(r.balance_msat);
|
||||||
|
stats.wagered += stake;
|
||||||
|
stats.plays++;
|
||||||
|
saveStats();
|
||||||
myBet = 'in';
|
myBet = 'in';
|
||||||
|
buzz(20);
|
||||||
} else if (snapshot.state === 'running' && myBet === 'in') {
|
} else if (snapshot.state === 'running' && myBet === 'in') {
|
||||||
const r = await api('POST', '/api/cashout', { game: currentGame });
|
const r = await api('POST', '/api/cashout', { game: currentGame });
|
||||||
myBet = 'out';
|
myBet = 'out';
|
||||||
hint.textContent = `Out at ${parseFloat(r.cashed_out_at).toFixed(2)}× — paid at settlement.`;
|
const at = parseFloat(r.cashed_out_at);
|
||||||
|
const won = stake * at;
|
||||||
|
if (won > stats.best) stats.best = won;
|
||||||
|
saveStats();
|
||||||
|
hint.textContent = `Out at ${at.toFixed(2)}× — paid at settlement.`;
|
||||||
hint.className = 'hint good';
|
hint.className = 'hint good';
|
||||||
const b = await api('GET', '/api/balance');
|
buzz([25, 30, 25]);
|
||||||
setBalance(b.balance_msat);
|
refreshBalance();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hint.textContent = e.message;
|
hint.textContent = e.message;
|
||||||
@@ -236,131 +349,7 @@ async function onAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- rendering ----------------
|
/* ---------------- ambient sound ---------------- */
|
||||||
* Each game draws the same climb differently: a rocket fighting gravity, a
|
|
||||||
* craft spiralling inward, or a tower stacking upward. */
|
|
||||||
|
|
||||||
const canvas = $('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
let stars = [];
|
|
||||||
|
|
||||||
function sizeCanvas() {
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
canvas.width = canvas.clientWidth * dpr;
|
|
||||||
canvas.height = canvas.clientHeight * dpr;
|
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
||||||
stars = Array.from({ length: 70 }, () => ({
|
|
||||||
x: Math.random(), y: Math.random(), r: Math.random() * 1.4 + 0.3,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', sizeCanvas);
|
|
||||||
|
|
||||||
function draw(s) {
|
|
||||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|
||||||
if (!w || !h) return;
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
|
|
||||||
const m = parseFloat(s.multiplier) || 1;
|
|
||||||
const crashed = s.state === 'settled';
|
|
||||||
const progress = Math.min(1, Math.log(m) / Math.log(12));
|
|
||||||
|
|
||||||
// Scrolling grid: a horizon that rushes past as the multiplier climbs.
|
|
||||||
ctx.strokeStyle = '#0a7a5233';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
const spacing = 34;
|
|
||||||
const offset = (progress * 260) % spacing;
|
|
||||||
for (let x = 0; x <= w; x += spacing) {
|
|
||||||
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
|
||||||
}
|
|
||||||
for (let y = -spacing + offset; y <= h; y += spacing) {
|
|
||||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sparse drifting particles, phosphor green.
|
|
||||||
ctx.fillStyle = '#00ff9c';
|
|
||||||
for (const st of stars) {
|
|
||||||
const y = (st.y + progress * 0.9) % 1;
|
|
||||||
ctx.globalAlpha = 0.12 + st.r * 0.18;
|
|
||||||
ctx.fillRect(st.x * w, y * h, st.r, st.r);
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
|
|
||||||
if (currentGame === 'orbital') drawOrbital(w, h, progress, crashed);
|
|
||||||
else if (currentGame === 'tower') drawTower(w, h, progress, crashed);
|
|
||||||
else drawRocket(w, h, progress, crashed);
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawRocket(w, h, p, crashed) {
|
|
||||||
const x = w * 0.5;
|
|
||||||
const y = h * (0.88 - p * 0.66);
|
|
||||||
const accent = crashed ? '#ff3355' : '#00ff9c';
|
|
||||||
|
|
||||||
// Exhaust plume: longer and more agitated as the climb steepens.
|
|
||||||
const plume = 26 + p * 60;
|
|
||||||
const g = ctx.createLinearGradient(x, y, x, y + plume);
|
|
||||||
g.addColorStop(0, crashed ? '#ff3355aa' : '#00ff9ccc');
|
|
||||||
g.addColorStop(1, '#00ff9c00');
|
|
||||||
ctx.fillStyle = g;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x - 7, y + 8);
|
|
||||||
ctx.lineTo(x + 7, y + 8);
|
|
||||||
ctx.lineTo(x + (Math.random() - 0.5) * 8, y + plume);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.fillStyle = accent;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, y - 18);
|
|
||||||
ctx.lineTo(x + 9, y + 10);
|
|
||||||
ctx.lineTo(x - 9, y + 10);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
if (crashed) {
|
|
||||||
ctx.strokeStyle = '#ff335588';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
const a = (i / 9) * Math.PI * 2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x, y);
|
|
||||||
ctx.lineTo(x + Math.cos(a) * 34, y + Math.sin(a) * 34);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawOrbital(w, h, p, crashed) {
|
|
||||||
const cx = w / 2, cy = h / 2;
|
|
||||||
const planet = Math.min(w, h) * 0.16;
|
|
||||||
ctx.fillStyle = '#00291c';
|
|
||||||
ctx.beginPath(); ctx.arc(cx, cy, planet, 0, Math.PI * 2); ctx.fill();
|
|
||||||
|
|
||||||
const orbit = planet + 12 + (1 - p) * Math.min(w, h) * 0.26;
|
|
||||||
ctx.strokeStyle = crashed ? '#ff335555' : '#00ff9c55';
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.beginPath(); ctx.arc(cx, cy, orbit, 0, Math.PI * 2); ctx.stroke();
|
|
||||||
|
|
||||||
const a = p * Math.PI * 9;
|
|
||||||
const x = cx + Math.cos(a) * orbit, y = cy + Math.sin(a) * orbit;
|
|
||||||
ctx.fillStyle = crashed ? '#ff3355' : '#00ff9c';
|
|
||||||
ctx.beginPath(); ctx.arc(x, y, 5, 0, Math.PI * 2); ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawTower(w, h, p, crashed) {
|
|
||||||
const blocks = Math.floor(p * 16) + 1;
|
|
||||||
const bw = w * 0.28, bh = h * 0.05;
|
|
||||||
for (let i = 0; i < blocks; i++) {
|
|
||||||
const sway = Math.sin(i * 0.7 + p * 6) * (i / blocks) * 22 * (crashed ? 3 : 1);
|
|
||||||
const y = h * 0.9 - (i + 1) * bh;
|
|
||||||
ctx.fillStyle = i === blocks - 1
|
|
||||||
? (crashed ? '#ff3355' : '#00ff9c')
|
|
||||||
: `hsl(${160 - i} 90% ${12 + i}%)`;
|
|
||||||
ctx.fillRect(w / 2 - bw / 2 + sway, y, bw, bh - 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- ambient sound ----------------
|
|
||||||
* Synthesised, so it never loops and ships no audio files. */
|
|
||||||
|
|
||||||
let audio = null;
|
let audio = null;
|
||||||
|
|
||||||
@@ -391,23 +380,6 @@ function toggleSound() {
|
|||||||
$('sound-toggle').classList.add('on');
|
$('sound-toggle').classList.add('on');
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastTone = 0;
|
|
||||||
function tone(mult) {
|
|
||||||
if (!audio) return;
|
|
||||||
const now = audio.currentTime;
|
|
||||||
if (now - lastTone < 0.28) return;
|
|
||||||
lastTone = now;
|
|
||||||
const o = audio.createOscillator();
|
|
||||||
const g = audio.createGain();
|
|
||||||
o.type = 'triangle';
|
|
||||||
o.frequency.value = 220 * Math.min(4, mult);
|
|
||||||
g.gain.setValueAtTime(0.0001, now);
|
|
||||||
g.gain.exponentialRampToValueAtTime(0.03, now + 0.02);
|
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25);
|
|
||||||
o.connect(g).connect(audio.destination);
|
|
||||||
o.start(now); o.stop(now + 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------- scratch tickets ---------------- */
|
/* ---------------- scratch tickets ---------------- */
|
||||||
|
|
||||||
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];
|
||||||
@@ -426,7 +398,7 @@ async function loadScratch() {
|
|||||||
const result = el('div', { class: 'result' });
|
const result = el('div', { class: 'result' });
|
||||||
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
|
||||||
button.dataset.play = t.id;
|
button.dataset.play = t.id;
|
||||||
button.onclick = () => playScratch(t, grid, result);
|
button.onclick = () => playScratch(t, grid, result, button);
|
||||||
|
|
||||||
const table = el('table', { class: 'odds' },
|
const table = el('table', { class: 'odds' },
|
||||||
el('tr', {},
|
el('tr', {},
|
||||||
@@ -454,9 +426,10 @@ async function loadScratch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playScratch(t, grid, result) {
|
async function playScratch(t, grid, result, button) {
|
||||||
result.textContent = '';
|
result.textContent = '';
|
||||||
result.className = 'result';
|
result.className = 'result';
|
||||||
|
button.disabled = true;
|
||||||
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
[...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });
|
||||||
|
|
||||||
let out;
|
let out;
|
||||||
@@ -464,11 +437,16 @@ async function playScratch(t, grid, result) {
|
|||||||
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.textContent = e.message;
|
result.textContent = e.message;
|
||||||
|
button.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBalance(out.balance_msat);
|
setBalance(out.balance_msat);
|
||||||
|
stats.wagered += stake;
|
||||||
|
stats.plays++;
|
||||||
|
if (out.outcome.payout_msat > stats.best) stats.best = out.outcome.payout_msat;
|
||||||
|
if (out.outcome.payout_msat > 0) stats.wins++; else stats.losses++;
|
||||||
|
saveStats();
|
||||||
|
|
||||||
// Reveal cells one at a time — the outcome is already fixed, this is pacing.
|
|
||||||
const cells = out.outcome.cells;
|
const cells = out.outcome.cells;
|
||||||
const counts = {};
|
const counts = {};
|
||||||
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
|
||||||
@@ -479,17 +457,47 @@ async function playScratch(t, grid, result) {
|
|||||||
const cell = grid.children[i];
|
const cell = grid.children[i];
|
||||||
cell.textContent = SYMBOLS[sym];
|
cell.textContent = SYMBOLS[sym];
|
||||||
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
|
||||||
|
buzz(8);
|
||||||
if (i === cells.length - 1) {
|
if (i === cells.length - 1) {
|
||||||
const won = out.outcome.payout_msat > 0;
|
const won = out.outcome.payout_msat > 0;
|
||||||
result.className = 'result' + (won ? ' win' : '');
|
result.className = 'result' + (won ? ' win' : '');
|
||||||
result.textContent = won
|
result.textContent = won
|
||||||
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
|
||||||
: 'No win this time';
|
: 'No win this time';
|
||||||
|
if (won) buzz([40, 50, 40]);
|
||||||
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
}, i * 130);
|
}, i * 120);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- portfolio ---------------- */
|
||||||
|
|
||||||
|
function renderPortfolio() {
|
||||||
|
$('t-balance').textContent = sats(balanceMsat);
|
||||||
|
charts.sparkline($('spark-balance'), stats.balances.slice(-40));
|
||||||
|
|
||||||
|
const delta = stats.sessionStart == null ? 0 : balanceMsat - stats.sessionStart;
|
||||||
|
const sess = $('t-session');
|
||||||
|
sess.textContent = (delta >= 0 ? '+' : '') + sats(delta);
|
||||||
|
sess.className = 'tile-value ' + (delta > 0 ? 'up' : delta < 0 ? 'down' : '');
|
||||||
|
|
||||||
|
$('t-wagered').textContent = sats(stats.wagered);
|
||||||
|
$('t-plays').textContent = `${stats.plays} play${stats.plays === 1 ? '' : 's'}`;
|
||||||
|
$('t-best').textContent = sats(stats.best);
|
||||||
|
|
||||||
|
charts.balanceChart($('chart-balance'),
|
||||||
|
stats.balances.map((b) => ({ BalanceAfter: b })));
|
||||||
|
charts.winLossDonut($('chart-donut'), stats.wins, stats.losses);
|
||||||
|
charts.distributionChart($('chart-dist'), stats.crashes);
|
||||||
|
charts.crashHistoryChart($('chart-history'), stats.crashes);
|
||||||
|
|
||||||
|
const total = stats.wins + stats.losses;
|
||||||
|
$('donut-note').textContent = total
|
||||||
|
? `${stats.wins} cashed out, ${stats.losses} rode into the crash, across ${total} rounds.`
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------------- wallet ---------------- */
|
/* ---------------- wallet ---------------- */
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
@@ -524,8 +532,7 @@ async function sendSats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- verifier ----------------
|
/* ---------------- verifier ---------------- */
|
||||||
* Recomputed here, in the browser, from published values only. */
|
|
||||||
|
|
||||||
async function sha256(bytes) {
|
async function sha256(bytes) {
|
||||||
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
||||||
@@ -613,7 +620,11 @@ function selectTab(view) {
|
|||||||
t.classList.toggle('active', t.dataset.view === view));
|
t.classList.toggle('active', t.dataset.view === view));
|
||||||
document.querySelectorAll('.view').forEach((v) =>
|
document.querySelectorAll('.view').forEach((v) =>
|
||||||
(v.hidden = v.id !== 'view-' + view));
|
(v.hidden = v.id !== 'view-' + view));
|
||||||
|
|
||||||
|
// The 3D loop only runs while its view is visible.
|
||||||
|
if (scene) { if (view === 'crash') scene.start(); else scene.stop(); }
|
||||||
if (view === 'wallet') loadHistory();
|
if (view === 'wallet') loadHistory();
|
||||||
|
if (view === 'portfolio') renderPortfolio();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStake(v) {
|
function setStake(v) {
|
||||||
@@ -626,7 +637,6 @@ function setStake(v) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
sizeCanvas();
|
|
||||||
keypair = await loadOrCreateKey();
|
keypair = await loadOrCreateKey();
|
||||||
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
$('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
|
||||||
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
$('nickname').value = localStorage.getItem(NAME_STORAGE) || '';
|
||||||
@@ -644,7 +654,14 @@ async function init() {
|
|||||||
(t.onclick = () => selectTab(t.dataset.view)));
|
(t.onclick = () => selectTab(t.dataset.view)));
|
||||||
document.querySelectorAll('.chip').forEach((c) =>
|
document.querySelectorAll('.chip').forEach((c) =>
|
||||||
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
(c.onclick = () => setStake(Number(c.dataset.stake))));
|
||||||
|
|
||||||
$('auto-target').oninput = refreshAutoRow;
|
$('auto-target').oninput = refreshAutoRow;
|
||||||
|
document.querySelectorAll('[data-auto]').forEach((b) => {
|
||||||
|
b.onclick = () => {
|
||||||
|
$('auto-target').value = b.dataset.auto;
|
||||||
|
refreshAutoRow();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const pick = $('gamepick');
|
const pick = $('gamepick');
|
||||||
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
[['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
|
||||||
@@ -658,6 +675,7 @@ async function init() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setStake(stake);
|
setStake(stake);
|
||||||
|
renderStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
274
cmd/arcade/static/charts.js
Normal file
274
cmd/arcade/static/charts.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/* Quantum Arcade — charts.
|
||||||
|
*
|
||||||
|
* Hand-built SVG rather than a charting library. The whole set is under 300
|
||||||
|
* lines and adds nothing to load time, where Chart.js would cost more than the
|
||||||
|
* 3D engine. Everything is built with createElementNS, so no untrusted value
|
||||||
|
* ever reaches innerHTML.
|
||||||
|
*
|
||||||
|
* Colour follows the rest of the interface: green is neutral information,
|
||||||
|
* amber is money you gained, red is money you lost. Nothing is coloured
|
||||||
|
* decoratively. */
|
||||||
|
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
const GREEN = '#00ff9c';
|
||||||
|
const GREEN_DIM = '#0a7a52';
|
||||||
|
const AMBER = '#ffb000';
|
||||||
|
const RED = '#ff3355';
|
||||||
|
const MAGENTA = '#ff2e88';
|
||||||
|
|
||||||
|
function svg(tag, attrs) {
|
||||||
|
const el = document.createElementNS(NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(node) {
|
||||||
|
while (node.firstChild) node.removeChild(node.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts scale to their container: the viewBox is fixed and CSS handles the
|
||||||
|
* rest, so one implementation serves phone and desktop. */
|
||||||
|
function frame(host, w, h) {
|
||||||
|
clear(host);
|
||||||
|
const root = svg('svg', {
|
||||||
|
viewBox: `0 0 ${w} ${h}`,
|
||||||
|
preserveAspectRatio: 'none',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
host.appendChild(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyState(host, message) {
|
||||||
|
clear(host);
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'muted small chart-empty';
|
||||||
|
p.textContent = message;
|
||||||
|
host.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- balance over time ---------------- */
|
||||||
|
|
||||||
|
/* An area chart of balance after each transaction, oldest to newest.
|
||||||
|
* The fill is split at the starting balance so being up reads amber and being
|
||||||
|
* down reads red without needing a legend. */
|
||||||
|
export function balanceChart(host, entries) {
|
||||||
|
if (!entries || entries.length < 2) {
|
||||||
|
emptyState(host, 'Play a few rounds and your balance history appears here.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 110, PAD = 4;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const values = entries.map((e) => e.BalanceAfter / 1000); // sats
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const start = values[0];
|
||||||
|
|
||||||
|
const x = (i) => PAD + (i / (values.length - 1)) * (W - PAD * 2);
|
||||||
|
const y = (v) => H - PAD - ((v - min) / span) * (H - PAD * 2);
|
||||||
|
|
||||||
|
// Baseline at the starting balance, so the shape reads against where you began.
|
||||||
|
const baseY = y(start);
|
||||||
|
root.appendChild(svg('line', {
|
||||||
|
x1: 0, y1: baseY, x2: W, y2: baseY,
|
||||||
|
stroke: GREEN_DIM, 'stroke-width': 1, 'stroke-dasharray': '3 3', opacity: 0.6,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const points = values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
|
||||||
|
const up = values[values.length - 1] >= start;
|
||||||
|
const colour = up ? AMBER : RED;
|
||||||
|
|
||||||
|
root.appendChild(svg('polygon', {
|
||||||
|
points: `${x(0)},${baseY} ${points} ${x(values.length - 1)},${baseY}`,
|
||||||
|
fill: colour, opacity: 0.16,
|
||||||
|
}));
|
||||||
|
root.appendChild(svg('polyline', {
|
||||||
|
points, fill: 'none', stroke: colour,
|
||||||
|
'stroke-width': 1.6, 'stroke-linejoin': 'round',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mark the latest point.
|
||||||
|
root.appendChild(svg('circle', {
|
||||||
|
cx: x(values.length - 1), cy: y(values[values.length - 1]),
|
||||||
|
r: 2.6, fill: colour,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- recent crash points ---------------- */
|
||||||
|
|
||||||
|
/* Bars of where recent rounds ended. Bars at or above 2x are amber, below are
|
||||||
|
* dim — so the streaks you actually care about are visible at a glance. */
|
||||||
|
export function crashHistoryChart(host, crashes) {
|
||||||
|
if (!crashes || crashes.length === 0) {
|
||||||
|
emptyState(host, 'No settled rounds yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 90, PAD = 3;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const shown = crashes.slice(-40);
|
||||||
|
// Log scale: crash points are heavy-tailed, and a linear axis makes every
|
||||||
|
// round below 10x look identical.
|
||||||
|
const scale = (v) => Math.log(Math.max(1, v)) / Math.log(60);
|
||||||
|
const bw = (W - PAD * 2) / shown.length;
|
||||||
|
|
||||||
|
shown.forEach((v, i) => {
|
||||||
|
const hgt = Math.max(2, scale(v) * (H - PAD * 2));
|
||||||
|
root.appendChild(svg('rect', {
|
||||||
|
x: PAD + i * bw + bw * 0.15,
|
||||||
|
y: H - PAD - hgt,
|
||||||
|
width: bw * 0.7,
|
||||||
|
height: hgt,
|
||||||
|
fill: v >= 10 ? MAGENTA : v >= 2 ? AMBER : GREEN_DIM,
|
||||||
|
opacity: v >= 2 ? 0.95 : 0.65,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2x reference line — the break-even point for the most common target.
|
||||||
|
const y2 = H - PAD - scale(2) * (H - PAD * 2);
|
||||||
|
root.appendChild(svg('line', {
|
||||||
|
x1: 0, y1: y2, x2: W, y2: y2,
|
||||||
|
stroke: AMBER, 'stroke-width': 0.8, 'stroke-dasharray': '4 4', opacity: 0.55,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- win / loss split ---------------- */
|
||||||
|
|
||||||
|
/* A donut, because the only question it answers is a ratio. */
|
||||||
|
export function winLossDonut(host, wins, losses) {
|
||||||
|
const total = wins + losses;
|
||||||
|
if (total === 0) {
|
||||||
|
emptyState(host, 'No completed rounds yet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const S = 120, R = 44, CX = S / 2, CY = S / 2;
|
||||||
|
const root = frame(host, S, S);
|
||||||
|
const circ = 2 * Math.PI * R;
|
||||||
|
const winFrac = wins / total;
|
||||||
|
|
||||||
|
root.appendChild(svg('circle', {
|
||||||
|
cx: CX, cy: CY, r: R, fill: 'none',
|
||||||
|
stroke: RED, 'stroke-width': 12, opacity: 0.55,
|
||||||
|
}));
|
||||||
|
root.appendChild(svg('circle', {
|
||||||
|
cx: CX, cy: CY, r: R, fill: 'none',
|
||||||
|
stroke: AMBER, 'stroke-width': 12,
|
||||||
|
'stroke-dasharray': `${circ * winFrac} ${circ}`,
|
||||||
|
transform: `rotate(-90 ${CX} ${CY})`,
|
||||||
|
'stroke-linecap': 'butt',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pct = svg('text', {
|
||||||
|
x: CX, y: CY + 2, 'text-anchor': 'middle',
|
||||||
|
fill: AMBER, 'font-size': 20, 'font-family': 'ui-monospace, monospace',
|
||||||
|
'font-weight': 700,
|
||||||
|
});
|
||||||
|
pct.textContent = `${Math.round(winFrac * 100)}%`;
|
||||||
|
root.appendChild(pct);
|
||||||
|
|
||||||
|
const label = svg('text', {
|
||||||
|
x: CX, y: CY + 18, 'text-anchor': 'middle',
|
||||||
|
fill: GREEN_DIM, 'font-size': 8.5, 'font-family': 'ui-monospace, monospace',
|
||||||
|
'letter-spacing': 1.5,
|
||||||
|
});
|
||||||
|
label.textContent = 'CASHED OUT';
|
||||||
|
root.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- multiplier distribution ---------------- */
|
||||||
|
|
||||||
|
/* A histogram of where rounds ended, with the theoretical curve drawn over it.
|
||||||
|
* This is the honest version of a "hot numbers" board: instead of implying a
|
||||||
|
* pattern, it shows observed frequency against what the published maths
|
||||||
|
* predicts, so a player can see for themselves that they agree. */
|
||||||
|
export function distributionChart(host, crashes) {
|
||||||
|
if (!crashes || crashes.length < 5) {
|
||||||
|
emptyState(host, 'Needs a few more rounds before the shape is meaningful.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 300, H = 110, PAD = 4;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const buckets = [
|
||||||
|
{ label: '1-1.5', lo: 1, hi: 1.5 },
|
||||||
|
{ label: '1.5-2', lo: 1.5, hi: 2 },
|
||||||
|
{ label: '2-3', lo: 2, hi: 3 },
|
||||||
|
{ label: '3-5', lo: 3, hi: 5 },
|
||||||
|
{ label: '5-10', lo: 5, hi: 10 },
|
||||||
|
{ label: '10+', lo: 10, hi: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
const counts = buckets.map((b) => crashes.filter((c) => c >= b.lo && c < b.hi).length);
|
||||||
|
const maxCount = Math.max(...counts, 1);
|
||||||
|
const bw = (W - PAD * 2) / buckets.length;
|
||||||
|
|
||||||
|
// Expected share for each bucket: P(crash >= x) = 0.99 / x.
|
||||||
|
const expected = buckets.map((b) => {
|
||||||
|
const hi = b.hi === Infinity ? 0 : 0.99 / b.hi;
|
||||||
|
return 0.99 / b.lo - hi;
|
||||||
|
});
|
||||||
|
|
||||||
|
counts.forEach((n, i) => {
|
||||||
|
const hgt = (n / maxCount) * (H - PAD * 2 - 12);
|
||||||
|
root.appendChild(svg('rect', {
|
||||||
|
x: PAD + i * bw + bw * 0.18,
|
||||||
|
y: H - PAD - 12 - hgt,
|
||||||
|
width: bw * 0.64,
|
||||||
|
height: Math.max(1, hgt),
|
||||||
|
fill: GREEN, opacity: 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const label = svg('text', {
|
||||||
|
x: PAD + i * bw + bw / 2, y: H - 3,
|
||||||
|
'text-anchor': 'middle', fill: GREEN_DIM,
|
||||||
|
'font-size': 7, 'font-family': 'ui-monospace, monospace',
|
||||||
|
});
|
||||||
|
label.textContent = buckets[i].label;
|
||||||
|
root.appendChild(label);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The predicted shape, scaled to the same axis.
|
||||||
|
const maxExpected = Math.max(...expected);
|
||||||
|
const curve = expected.map((e, i) => {
|
||||||
|
const hgt = (e / maxExpected) * (H - PAD * 2 - 12) * (maxCount / crashes.length) /
|
||||||
|
(maxCount / crashes.length);
|
||||||
|
return `${PAD + i * bw + bw / 2},${H - PAD - 12 - hgt}`;
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
root.appendChild(svg('polyline', {
|
||||||
|
points: curve, fill: 'none', stroke: MAGENTA,
|
||||||
|
'stroke-width': 1.3, 'stroke-dasharray': '3 2', opacity: 0.9,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- sparkline ---------------- */
|
||||||
|
|
||||||
|
/* A tiny inline trend, for stat tiles. */
|
||||||
|
export function sparkline(host, values) {
|
||||||
|
if (!values || values.length < 2) {
|
||||||
|
clear(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const W = 80, H = 22;
|
||||||
|
const root = frame(host, W, H);
|
||||||
|
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const points = values.map((v, i) =>
|
||||||
|
`${(i / (values.length - 1)) * W},${H - 2 - ((v - min) / span) * (H - 4)}`).join(' ');
|
||||||
|
|
||||||
|
root.appendChild(svg('polyline', {
|
||||||
|
points, fill: 'none',
|
||||||
|
stroke: values[values.length - 1] >= values[0] ? AMBER : RED,
|
||||||
|
'stroke-width': 1.4, 'stroke-linejoin': 'round',
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<title>QUANTUM ARCADE</title>
|
<title>QUANTUM ARCADE</title>
|
||||||
<link rel="stylesheet" href="/style.css">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Ornate background filigree, drawn once as an SVG pattern and tiled. -->
|
<!-- Circuit-trace ornament, tiled once behind everything. -->
|
||||||
<svg class="filigree" aria-hidden="true">
|
<svg class="filigree" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
<pattern id="orn" width="60" height="52" patternUnits="userSpaceOnUse">
|
||||||
@@ -33,39 +36,43 @@
|
|||||||
<button class="sound" id="sound-toggle" title="Ambient sound">♪</button>
|
<button class="sound" id="sound-toggle" title="Ambient sound">♪</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Sign-in: a nickname and nothing else. -->
|
<!-- Sign-in -->
|
||||||
<section class="panel center" id="signin">
|
<section class="panel center" id="signin">
|
||||||
<h1>ACCESS TERMINAL</h1>
|
<h1>ACCESS TERMINAL</h1>
|
||||||
<p class="muted small">
|
<p class="muted small">
|
||||||
No account. No email. No password. This device generated a keypair that
|
No account. No email. No password. This device generates a keypair
|
||||||
<em>is</em> your identity. Keep the device, keep the balance.
|
that <em>is</em> your identity.
|
||||||
</p>
|
</p>
|
||||||
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
|
<input id="nickname" maxlength="20" placeholder="handle" autocomplete="off">
|
||||||
<button class="primary" id="enter">Connect</button>
|
<button class="primary" id="enter">Connect</button>
|
||||||
<p class="fineprint" id="keynote"></p>
|
<p class="fineprint" id="keynote"></p>
|
||||||
|
<div class="cryptobadge">
|
||||||
|
<span>Provably fair</span>
|
||||||
|
<span>SHA-256 commit-reveal</span>
|
||||||
|
<span>AGPL-3.0</span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main id="app" hidden>
|
<main id="app" hidden>
|
||||||
|
|
||||||
<nav class="tabs">
|
|
||||||
<button class="tab active" data-view="crash">Crash</button>
|
|
||||||
<button class="tab" data-view="scratch">Scratchers</button>
|
|
||||||
<button class="tab" data-view="wallet">Wallet</button>
|
|
||||||
<button class="tab" data-view="verify">Verify</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- ============ CRASH ============ -->
|
<!-- ============ CRASH ============ -->
|
||||||
<section class="view" id="view-crash">
|
<section class="view" id="view-crash">
|
||||||
<div class="gamepick" id="gamepick"></div>
|
<div class="gamepick" id="gamepick"></div>
|
||||||
|
|
||||||
<div class="stage">
|
<div class="stage">
|
||||||
<canvas id="canvas"></canvas>
|
<canvas id="scene"></canvas>
|
||||||
<div class="readout">
|
<div class="readout">
|
||||||
<div class="multiplier" id="multiplier">1.00×</div>
|
<div class="multiplier" id="multiplier">1.00×</div>
|
||||||
<div class="state" id="state">connecting…</div>
|
<div class="state" id="state">connecting…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stagetop">
|
||||||
|
<span class="pot" id="potline"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Live crash history strip, directly under the stage. -->
|
||||||
|
<div class="strip" id="strip"></div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="stakerow">
|
<div class="stakerow">
|
||||||
<button class="chip" data-stake="1000">1</button>
|
<button class="chip" data-stake="1000">1</button>
|
||||||
@@ -74,11 +81,17 @@
|
|||||||
<button class="chip" data-stake="100000">100</button>
|
<button class="chip" data-stake="100000">100</button>
|
||||||
<span class="unit">sats</span>
|
<span class="unit">sats</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="autorow" id="autorow">
|
<div class="autorow" id="autorow">
|
||||||
<label for="auto-target">auto out</label>
|
<label for="auto-target">auto out</label>
|
||||||
<input id="auto-target" type="number" min="1.01" step="0.1"
|
<input id="auto-target" type="number" min="1.01" step="0.1"
|
||||||
inputmode="decimal" placeholder="off">
|
inputmode="decimal" placeholder="off">
|
||||||
<span class="x">×</span>
|
<span class="x">×</span>
|
||||||
|
<div class="presets">
|
||||||
|
<button data-auto="1.5">1.5</button>
|
||||||
|
<button data-auto="2">2</button>
|
||||||
|
<button data-auto="5">5</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="primary big" id="action">Place bet</button>
|
<button class="primary big" id="action">Place bet</button>
|
||||||
@@ -92,9 +105,9 @@
|
|||||||
<div class="kv"><span>commitment</span><code id="commitment">—</code></div>
|
<div class="kv"><span>commitment</span><code id="commitment">—</code></div>
|
||||||
<div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
|
<div class="kv"><span>revealed seed</span><code id="revealed">sealed until the round ends</code></div>
|
||||||
<p class="muted small">
|
<p class="muted small">
|
||||||
The commitment is published before betting opens. The crash point is
|
The commitment is published before betting opens. The crash point comes
|
||||||
derived from that seed combined with every player's key — so it cannot
|
from that seed combined with every player's key — so it cannot be
|
||||||
be chosen after seeing who joined.
|
chosen after seeing who joined.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
@@ -104,6 +117,58 @@
|
|||||||
<div id="tickets"></div>
|
<div id="tickets"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ PORTFOLIO ============ -->
|
||||||
|
<section class="view" id="view-portfolio" hidden>
|
||||||
|
|
||||||
|
<div class="tiles">
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">balance</span>
|
||||||
|
<span class="tile-value" id="t-balance">—</span>
|
||||||
|
<div class="tile-spark" id="spark-balance"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">session</span>
|
||||||
|
<span class="tile-value" id="t-session">—</span>
|
||||||
|
<span class="tile-sub" id="t-session-sub">since you connected</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">wagered</span>
|
||||||
|
<span class="tile-value" id="t-wagered">—</span>
|
||||||
|
<span class="tile-sub" id="t-plays">0 plays</span>
|
||||||
|
</div>
|
||||||
|
<div class="tile">
|
||||||
|
<span class="tile-label">best hit</span>
|
||||||
|
<span class="tile-value" id="t-best">—</span>
|
||||||
|
<span class="tile-sub">largest single win</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Balance over time</h2>
|
||||||
|
<div class="chart" id="chart-balance"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Cash-out rate</h2>
|
||||||
|
<div class="chart chart-donut" id="chart-donut"></div>
|
||||||
|
<p class="muted small" id="donut-note"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Where rounds ended</h2>
|
||||||
|
<div class="chart" id="chart-dist"></div>
|
||||||
|
<p class="muted small">
|
||||||
|
Bars are what actually happened. The dashed line is what the published
|
||||||
|
maths predicts. They should converge — that is the point.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent rounds</h2>
|
||||||
|
<div class="chart" id="chart-history"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ============ WALLET ============ -->
|
<!-- ============ WALLET ============ -->
|
||||||
<section class="view" id="view-wallet" hidden>
|
<section class="view" id="view-wallet" hidden>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -115,7 +180,8 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Send sats</h2>
|
<h2>Send sats</h2>
|
||||||
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
<input id="to-key" placeholder="recipient key" autocomplete="off">
|
||||||
<input id="send-amt" type="number" min="1" placeholder="amount in sats">
|
<input id="send-amt" type="number" min="1" inputmode="numeric"
|
||||||
|
placeholder="amount in sats">
|
||||||
<button class="primary" id="send">Send</button>
|
<button class="primary" id="send">Send</button>
|
||||||
<div class="hint" id="send-hint"></div>
|
<div class="hint" id="send-hint"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,14 +199,61 @@
|
|||||||
Enter a round number. Your phone recomputes the outcome from the
|
Enter a round number. Your phone recomputes the outcome from the
|
||||||
published seeds — it does not take the server's word for anything.
|
published seeds — it does not take the server's word for anything.
|
||||||
</p>
|
</p>
|
||||||
<input id="verify-id" type="number" min="1" placeholder="round number">
|
<input id="verify-id" type="number" inputmode="numeric" min="1"
|
||||||
|
placeholder="round number">
|
||||||
<button class="primary" id="do-verify">Verify</button>
|
<button class="primary" id="do-verify">Verify</button>
|
||||||
<div id="verify-out" class="verify-out"></div>
|
<div id="verify-out" class="verify-out"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Underlying tech</h2>
|
||||||
|
<dl class="specs">
|
||||||
|
<dt>Identity</dt>
|
||||||
|
<dd>Ed25519 signed challenge, single-use and replay-proof.
|
||||||
|
Hybrid Ed25519 + ML-DSA-65 (FIPS 204) is implemented and tested
|
||||||
|
server-side; browser signing lands with the WASM module.</dd>
|
||||||
|
<dt>Transport</dt>
|
||||||
|
<dd>TLS 1.3 with X25519MLKEM768 hybrid key exchange when served over
|
||||||
|
HTTPS — post-quantum against harvest-now-decrypt-later</dd>
|
||||||
|
<dt>Fairness</dt>
|
||||||
|
<dd>SHA-256 commitment, HMAC-SHA256 outcome derivation. Hash-based,
|
||||||
|
so Grover only halves the margin: quantum-resistant as it stands.</dd>
|
||||||
|
<dt>Settlement</dt>
|
||||||
|
<dd>Bitcoin and Lightning sign with secp256k1, which is
|
||||||
|
<em>not</em> post-quantum. No application choice changes that.</dd>
|
||||||
|
<dt>Simulation</dt>
|
||||||
|
<dd>Q32.32 fixed-point, zero floating point, bit-identical replay</dd>
|
||||||
|
<dt>Ledger</dt>
|
||||||
|
<dd>Append-only double-entry, DB-enforced, audited every request</dd>
|
||||||
|
<dt>House edge</dt>
|
||||||
|
<dd>1.00% — verified by test across 2,000,000 simulated plays</dd>
|
||||||
|
<dt>Licence</dt>
|
||||||
|
<dd>AGPL-3.0 — every line auditable, forks must publish</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
<!-- Bottom navigation: thumb-reachable, which is where navigation belongs on a
|
||||||
|
phone held one-handed at a party. -->
|
||||||
|
<nav class="tabs" id="tabs" hidden>
|
||||||
|
<button class="tab active" data-view="crash">
|
||||||
|
<span class="ico">▲</span><span>Crash</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="scratch">
|
||||||
|
<span class="ico">◈</span><span>Scratch</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="portfolio">
|
||||||
|
<span class="ico">▤</span><span>Stats</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="wallet">
|
||||||
|
<span class="ico">◉</span><span>Wallet</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" data-view="verify">
|
||||||
|
<span class="ico">✓</span><span>Verify</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
389
cmd/arcade/static/scene3d.js
Normal file
389
cmd/arcade/static/scene3d.js
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/* Quantum Arcade — 3D rendering.
|
||||||
|
*
|
||||||
|
* Three scenes sharing one renderer, because a phone should allocate exactly
|
||||||
|
* one WebGL context no matter how many games it switches between.
|
||||||
|
*
|
||||||
|
* Performance rules, in priority order, because this has to hold sixty frames
|
||||||
|
* on a mid-range phone in someone's hand at a party:
|
||||||
|
* - device pixel ratio capped at 2, dropped to 1.5 on small screens
|
||||||
|
* - low-poly geometry, no shadow maps, no post-processing passes
|
||||||
|
* - additive materials for glow instead of a bloom pass
|
||||||
|
* - particles are one BufferGeometry updated in place, never re-allocated
|
||||||
|
* - the render loop stops entirely when the tab is hidden
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from '/vendor/three.module.min.js';
|
||||||
|
|
||||||
|
const GREEN = 0x00ff9c;
|
||||||
|
const MAGENTA = 0xff2e88;
|
||||||
|
const AMBER = 0xffb000;
|
||||||
|
const RED = 0xff3355;
|
||||||
|
|
||||||
|
const PARTICLES = 220;
|
||||||
|
|
||||||
|
export class Arcade3D {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: false, // FXAA-free; the aesthetic is crisp anyway
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
this.renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 400);
|
||||||
|
this.clock = new THREE.Clock();
|
||||||
|
|
||||||
|
this.scenes = {
|
||||||
|
rocket: this.buildRocket(),
|
||||||
|
orbital: this.buildOrbital(),
|
||||||
|
tower: this.buildTower(),
|
||||||
|
};
|
||||||
|
this.active = 'rocket';
|
||||||
|
this.progress = 0;
|
||||||
|
this.crashed = false;
|
||||||
|
this.shake = 0;
|
||||||
|
|
||||||
|
this.resize();
|
||||||
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
|
||||||
|
// Stop rendering when the page is hidden; a background tab burning GPU on
|
||||||
|
// someone's phone is a battery bug, not a feature.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) this.stop();
|
||||||
|
else this.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const w = this.canvas.clientWidth;
|
||||||
|
const h = this.canvas.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
const cap = w < 500 ? 1.5 : 2;
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, cap));
|
||||||
|
this.renderer.setSize(w, h, false);
|
||||||
|
this.camera.aspect = w / h;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- shared pieces ---------- */
|
||||||
|
|
||||||
|
// A wireframe floor grid that scrolls toward the camera, which is what sells
|
||||||
|
// the sensation of speed.
|
||||||
|
makeGrid() {
|
||||||
|
const grid = new THREE.GridHelper(120, 40, GREEN, 0x0a7a52);
|
||||||
|
grid.material.transparent = true;
|
||||||
|
grid.material.opacity = 0.28;
|
||||||
|
grid.position.y = -6;
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One BufferGeometry of points, recycled every frame.
|
||||||
|
makeParticles(spread = 40) {
|
||||||
|
const positions = new Float32Array(PARTICLES * 3);
|
||||||
|
const speeds = new Float32Array(PARTICLES);
|
||||||
|
for (let i = 0; i < PARTICLES; i++) {
|
||||||
|
positions[i * 3] = (Math.random() - 0.5) * spread;
|
||||||
|
positions[i * 3 + 1] = (Math.random() - 0.5) * spread;
|
||||||
|
positions[i * 3 + 2] = (Math.random() - 0.5) * spread;
|
||||||
|
speeds[i] = 0.4 + Math.random() * 1.6;
|
||||||
|
}
|
||||||
|
const geo = new THREE.BufferGeometry();
|
||||||
|
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
const mat = new THREE.PointsMaterial({
|
||||||
|
color: GREEN, size: 0.22, transparent: true, opacity: 0.75,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
});
|
||||||
|
const points = new THREE.Points(geo, mat);
|
||||||
|
points.userData.speeds = speeds;
|
||||||
|
points.userData.spread = spread;
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
glowLine(geometry, color, opacity = 1) {
|
||||||
|
return new THREE.LineSegments(
|
||||||
|
new THREE.EdgesGeometry(geometry),
|
||||||
|
new THREE.LineBasicMaterial({
|
||||||
|
color, transparent: true, opacity,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- rocket: a climb against gravity ---------- */
|
||||||
|
|
||||||
|
buildRocket() {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x000000, 0.012);
|
||||||
|
|
||||||
|
const grid = this.makeGrid();
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
const particles = this.makeParticles(50);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
// Low-poly craft: a cone body with a wireframe overlay so it reads as
|
||||||
|
// "instrument" rather than "toy".
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.7, 2.4, 6),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0x00291c }));
|
||||||
|
const wire = this.glowLine(new THREE.ConeGeometry(0.7, 2.4, 6), GREEN);
|
||||||
|
const craft = new THREE.Group();
|
||||||
|
craft.add(body, wire);
|
||||||
|
scene.add(craft);
|
||||||
|
|
||||||
|
// Exhaust: a stretched cone that grows with thrust.
|
||||||
|
const plume = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.45, 2.2, 6),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: GREEN, transparent: true, opacity: 0.6,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
plume.rotation.x = Math.PI;
|
||||||
|
plume.position.y = -1.9;
|
||||||
|
craft.add(plume);
|
||||||
|
|
||||||
|
// The planet you are leaving, which shrinks as you climb.
|
||||||
|
const planet = this.glowLine(new THREE.IcosahedronGeometry(9, 1), 0x0a7a52, 0.5);
|
||||||
|
planet.position.y = -18;
|
||||||
|
scene.add(planet);
|
||||||
|
|
||||||
|
return { scene, grid, particles, craft, plume, planet, wire };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRocket(s, dt, t) {
|
||||||
|
const p = this.progress;
|
||||||
|
|
||||||
|
s.craft.position.y = -2 + p * 9;
|
||||||
|
s.craft.rotation.y += dt * 0.6;
|
||||||
|
// Increasing strain: the craft trembles harder the higher it goes.
|
||||||
|
s.craft.position.x = Math.sin(t * 9) * p * 0.28;
|
||||||
|
s.craft.rotation.z = Math.sin(t * 7) * p * 0.14;
|
||||||
|
|
||||||
|
const thrust = 0.6 + p * 2.6;
|
||||||
|
s.plume.scale.set(1 + p * 0.5, thrust, 1 + p * 0.5);
|
||||||
|
s.plume.position.y = -1.2 - thrust * 0.55;
|
||||||
|
s.plume.material.opacity = 0.45 + Math.random() * 0.35;
|
||||||
|
|
||||||
|
s.planet.position.y = -18 - p * 26;
|
||||||
|
s.planet.rotation.y += dt * 0.1;
|
||||||
|
|
||||||
|
s.grid.position.z = (s.grid.position.z + dt * (6 + p * 60)) % 3;
|
||||||
|
s.grid.position.y = -6 - p * 4;
|
||||||
|
|
||||||
|
const colour = this.crashed ? RED : GREEN;
|
||||||
|
s.wire.material.color.setHex(colour);
|
||||||
|
s.plume.material.color.setHex(colour);
|
||||||
|
|
||||||
|
this.camera.position.set(0, s.craft.position.y + 1.5, 11 - p * 2);
|
||||||
|
this.camera.lookAt(0, s.craft.position.y, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- orbital: a decaying orbit ---------- */
|
||||||
|
|
||||||
|
buildOrbital() {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x000000, 0.02);
|
||||||
|
|
||||||
|
const particles = this.makeParticles(44);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
const planet = this.glowLine(new THREE.IcosahedronGeometry(4, 1), GREEN, 0.85);
|
||||||
|
scene.add(planet);
|
||||||
|
|
||||||
|
const core = new THREE.Mesh(
|
||||||
|
new THREE.IcosahedronGeometry(3.85, 1),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0x00160f }));
|
||||||
|
scene.add(core);
|
||||||
|
|
||||||
|
// The orbit ring the craft is riding, which tightens as it decays.
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(9, 0.035, 6, 90),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: GREEN, transparent: true, opacity: 0.5,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
ring.rotation.x = Math.PI / 2.35;
|
||||||
|
scene.add(ring);
|
||||||
|
|
||||||
|
const craft = new THREE.Mesh(
|
||||||
|
new THREE.OctahedronGeometry(0.42),
|
||||||
|
new THREE.MeshBasicMaterial({ color: MAGENTA }));
|
||||||
|
scene.add(craft);
|
||||||
|
|
||||||
|
// A fading trail behind the craft.
|
||||||
|
const trailPos = new Float32Array(60 * 3);
|
||||||
|
const trailGeo = new THREE.BufferGeometry();
|
||||||
|
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
|
||||||
|
const trail = new THREE.Line(trailGeo, new THREE.LineBasicMaterial({
|
||||||
|
color: MAGENTA, transparent: true, opacity: 0.55,
|
||||||
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
scene.add(trail);
|
||||||
|
|
||||||
|
return { scene, planet, core, ring, craft, trail, particles, trailIdx: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOrbital(s, dt, t) {
|
||||||
|
const p = this.progress;
|
||||||
|
const radius = 9 - p * 4.6; // the orbit decays inward
|
||||||
|
const angle = t * (1.1 + p * 5.5);
|
||||||
|
|
||||||
|
s.craft.position.set(
|
||||||
|
Math.cos(angle) * radius,
|
||||||
|
Math.sin(angle * 0.55) * 1.2,
|
||||||
|
Math.sin(angle) * radius);
|
||||||
|
|
||||||
|
const pos = s.trail.geometry.attributes.position.array;
|
||||||
|
// Shift the trail back one vertex and write the head.
|
||||||
|
pos.copyWithin(3, 0, pos.length - 3);
|
||||||
|
pos[0] = s.craft.position.x;
|
||||||
|
pos[1] = s.craft.position.y;
|
||||||
|
pos[2] = s.craft.position.z;
|
||||||
|
s.trail.geometry.attributes.position.needsUpdate = true;
|
||||||
|
|
||||||
|
s.ring.scale.setScalar(radius / 9);
|
||||||
|
s.planet.rotation.y += dt * 0.25;
|
||||||
|
s.planet.rotation.x += dt * 0.08;
|
||||||
|
|
||||||
|
const colour = this.crashed ? RED : MAGENTA;
|
||||||
|
s.craft.material.color.setHex(colour);
|
||||||
|
s.trail.material.color.setHex(colour);
|
||||||
|
s.planet.material.color.setHex(this.crashed ? RED : GREEN);
|
||||||
|
|
||||||
|
this.camera.position.set(0, 8 + p * 3, 20 - p * 5);
|
||||||
|
this.camera.lookAt(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- tower: a stack that wobbles ---------- */
|
||||||
|
|
||||||
|
buildTower() {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x000000, 0.014);
|
||||||
|
|
||||||
|
const grid = this.makeGrid();
|
||||||
|
scene.add(grid);
|
||||||
|
|
||||||
|
const particles = this.makeParticles(46);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
// Pre-allocate the maximum stack; visibility is toggled per frame rather
|
||||||
|
// than creating and destroying meshes mid-round.
|
||||||
|
const MAX = 26;
|
||||||
|
const blocks = [];
|
||||||
|
const geo = new THREE.BoxGeometry(2.4, 0.7, 2.4);
|
||||||
|
for (let i = 0; i < MAX; i++) {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x00170f })));
|
||||||
|
group.add(this.glowLine(geo, GREEN, 0.9));
|
||||||
|
group.visible = false;
|
||||||
|
scene.add(group);
|
||||||
|
blocks.push(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scene, grid, particles, blocks, max: MAX };
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTower(s, dt, t) {
|
||||||
|
const p = this.progress;
|
||||||
|
const count = Math.max(1, Math.floor(p * s.max));
|
||||||
|
|
||||||
|
for (let i = 0; i < s.max; i++) {
|
||||||
|
const b = s.blocks[i];
|
||||||
|
b.visible = i < count;
|
||||||
|
if (!b.visible) continue;
|
||||||
|
|
||||||
|
const h = i / s.max;
|
||||||
|
// Sway grows with height, and violently once it collapses.
|
||||||
|
const amp = h * h * (this.crashed ? 3.4 : 0.9);
|
||||||
|
b.position.set(
|
||||||
|
Math.sin(t * 2.2 + i * 0.45) * amp,
|
||||||
|
-5 + i * 0.74,
|
||||||
|
Math.cos(t * 1.8 + i * 0.35) * amp * 0.6);
|
||||||
|
b.rotation.y = t * 0.15 + i * 0.08;
|
||||||
|
b.rotation.z = Math.sin(t * 2 + i * 0.4) * amp * 0.05;
|
||||||
|
|
||||||
|
const top = i === count - 1;
|
||||||
|
const colour = this.crashed ? RED : (top ? AMBER : GREEN);
|
||||||
|
b.children[1].material.color.setHex(colour);
|
||||||
|
b.children[1].material.opacity = top ? 1 : 0.45 + h * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.grid.position.z = (s.grid.position.z + dt * 4) % 3;
|
||||||
|
|
||||||
|
const height = -5 + count * 0.74;
|
||||||
|
this.camera.position.set(0, height * 0.55 + 3, 15 - p * 2);
|
||||||
|
this.camera.lookAt(0, height * 0.5, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- particles ---------- */
|
||||||
|
|
||||||
|
updateParticles(points, dt) {
|
||||||
|
const pos = points.geometry.attributes.position.array;
|
||||||
|
const speeds = points.userData.speeds;
|
||||||
|
const spread = points.userData.spread;
|
||||||
|
const half = spread / 2;
|
||||||
|
const rush = 2 + this.progress * 34;
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLES; i++) {
|
||||||
|
pos[i * 3 + 2] += speeds[i] * rush * dt;
|
||||||
|
if (pos[i * 3 + 2] > half) {
|
||||||
|
pos[i * 3 + 2] = -half;
|
||||||
|
pos[i * 3] = (Math.random() - 0.5) * spread;
|
||||||
|
pos[i * 3 + 1] = (Math.random() - 0.5) * spread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.geometry.attributes.position.needsUpdate = true;
|
||||||
|
points.material.color.setHex(this.crashed ? RED : GREEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- public API ---------- */
|
||||||
|
|
||||||
|
setGame(game) {
|
||||||
|
if (this.scenes[game]) this.active = game;
|
||||||
|
}
|
||||||
|
|
||||||
|
// progress is 0..1 across the multiplier's useful range; crashed swaps the
|
||||||
|
// palette and unleashes the wobble.
|
||||||
|
setState(progress, crashed) {
|
||||||
|
this.progress = Math.max(0, Math.min(1, progress));
|
||||||
|
if (crashed && !this.crashed) this.shake = 1; // kick the camera once
|
||||||
|
this.crashed = crashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.raf) return;
|
||||||
|
this.clock.getDelta(); // discard time spent hidden
|
||||||
|
const loop = () => {
|
||||||
|
this.raf = requestAnimationFrame(loop);
|
||||||
|
this.frame();
|
||||||
|
};
|
||||||
|
this.raf = requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.raf) cancelAnimationFrame(this.raf);
|
||||||
|
this.raf = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
const dt = Math.min(this.clock.getDelta(), 0.05); // clamp after a stall
|
||||||
|
const t = this.clock.elapsedTime;
|
||||||
|
const s = this.scenes[this.active];
|
||||||
|
|
||||||
|
if (this.active === 'rocket') this.updateRocket(s, dt, t);
|
||||||
|
else if (this.active === 'orbital') this.updateOrbital(s, dt, t);
|
||||||
|
else this.updateTower(s, dt, t);
|
||||||
|
|
||||||
|
this.updateParticles(s.particles, dt);
|
||||||
|
|
||||||
|
// Camera shake on the crash, decaying fast.
|
||||||
|
if (this.shake > 0.001) {
|
||||||
|
this.camera.position.x += (Math.random() - 0.5) * this.shake * 1.6;
|
||||||
|
this.camera.position.y += (Math.random() - 0.5) * this.shake * 1.6;
|
||||||
|
this.shake *= 0.86;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderer.render(s.scene, this.camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -352,3 +352,162 @@ button:active { transform: translateY(1px); }
|
|||||||
.verify-out .ok { color: var(--green); font-weight: 700; }
|
.verify-out .ok { color: var(--green); font-weight: 700; }
|
||||||
.verify-out .bad { color: var(--red); font-weight: 700; }
|
.verify-out .bad { color: var(--red); font-weight: 700; }
|
||||||
.verify-out .kv code { font-size: 10px; }
|
.verify-out .kv code { font-size: 10px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
MOBILE-FIRST LAYER
|
||||||
|
Everything above assumed a single column. This layer adds the
|
||||||
|
bottom navigation, stat tiles, charts, and the desktop widening.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Bottom nav replaces the old top tabs: on a phone held one-handed,
|
||||||
|
navigation belongs where the thumb already is. */
|
||||||
|
.tabs {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
|
||||||
|
display: flex; gap: 0; padding: 0;
|
||||||
|
max-width: none; margin: 0;
|
||||||
|
background: #00120cf2;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-top: 1px solid var(--green-ghost);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs .tab {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 3px; padding: 9px 2px 7px;
|
||||||
|
font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
border: none; border-top: 2px solid transparent; border-bottom: none;
|
||||||
|
background: none; color: var(--green-dim); border-radius: 0;
|
||||||
|
}
|
||||||
|
.tabs .tab .ico { font-size: 15px; line-height: 1; }
|
||||||
|
.tabs .tab.active {
|
||||||
|
color: var(--green); border-top-color: var(--green);
|
||||||
|
background: #00ff9c0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leave room for the fixed nav. */
|
||||||
|
.view { padding-bottom: 84px; }
|
||||||
|
|
||||||
|
/* The 3D stage is the hero: taller on phones, where vertical space is what
|
||||||
|
there is most of. */
|
||||||
|
.stage { aspect-ratio: 1 / 1; }
|
||||||
|
@media (min-width: 560px) { .stage { aspect-ratio: 16 / 11; } }
|
||||||
|
|
||||||
|
#scene { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
.stagetop {
|
||||||
|
position: absolute; top: 0; left: 0; right: 0;
|
||||||
|
display: flex; justify-content: center; padding: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pot {
|
||||||
|
font-size: 9.5px; letter-spacing: 0.16em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crash history strip under the stage — the at-a-glance recent record. */
|
||||||
|
.strip {
|
||||||
|
display: flex; gap: 4px; overflow-x: auto; padding: 8px 0 2px;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.strip::-webkit-scrollbar { display: none; }
|
||||||
|
.strip .pip {
|
||||||
|
flex: 0 0 auto; font-size: 10.5px; padding: 4px 7px; border-radius: 2px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: #00140e; border: 1px solid var(--green-ghost);
|
||||||
|
color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.strip .pip.mid { color: var(--amber); border-color: #4a3300; }
|
||||||
|
.strip .pip.high { color: var(--magenta); border-color: #4a0f2c; }
|
||||||
|
|
||||||
|
/* Auto cash-out presets. */
|
||||||
|
.presets { display: flex; gap: 3px; margin-left: auto; }
|
||||||
|
.presets button {
|
||||||
|
padding: 5px 8px; font-size: 11px; color: var(--green-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.presets button:active { color: var(--amber); border-color: var(--amber); }
|
||||||
|
|
||||||
|
/* ---------- stat tiles ---------- */
|
||||||
|
|
||||||
|
.tiles {
|
||||||
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
@media (min-width: 560px) { .tiles { grid-template-columns: repeat(4, 1fr); } }
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
background: #00120c; border: 1px solid var(--green-ghost);
|
||||||
|
border-radius: 3px; padding: 11px 12px;
|
||||||
|
display: flex; flex-direction: column; gap: 2px; min-height: 76px;
|
||||||
|
}
|
||||||
|
.tile-label {
|
||||||
|
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim);
|
||||||
|
}
|
||||||
|
.tile-value {
|
||||||
|
font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--green); line-height: 1.15;
|
||||||
|
}
|
||||||
|
.tile-value.up { color: var(--amber); }
|
||||||
|
.tile-value.down { color: var(--red); }
|
||||||
|
.tile-sub { font-size: 9.5px; color: var(--green-dim); }
|
||||||
|
.tile-spark { height: 22px; margin-top: auto; }
|
||||||
|
|
||||||
|
/* ---------- charts ---------- */
|
||||||
|
|
||||||
|
.chart { width: 100%; height: 110px; }
|
||||||
|
.chart-donut { height: 130px; display: grid; place-items: center; }
|
||||||
|
.chart-donut svg { width: 130px; height: 130px; }
|
||||||
|
.chart-empty { text-align: center; padding: 30px 10px; margin: 0; }
|
||||||
|
|
||||||
|
/* ---------- tech spec list ---------- */
|
||||||
|
|
||||||
|
.specs { margin: 0; font-size: 12px; }
|
||||||
|
.specs dt {
|
||||||
|
color: var(--amber); font-size: 9.5px; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; margin-top: 10px;
|
||||||
|
}
|
||||||
|
.specs dt:first-child { margin-top: 0; }
|
||||||
|
.specs dd { margin: 2px 0 0; color: var(--green-dim); }
|
||||||
|
|
||||||
|
/* ---------- crypto badge on the sign-in screen ---------- */
|
||||||
|
|
||||||
|
.cryptobadge {
|
||||||
|
display: flex; gap: 6px; justify-content: center; margin-top: 18px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cryptobadge span {
|
||||||
|
font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase;
|
||||||
|
color: var(--green-dim); border: 1px solid var(--green-ghost);
|
||||||
|
padding: 4px 8px; border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- desktop widening ---------- */
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.view { max-width: 860px; }
|
||||||
|
/* Side-by-side once there is width for it, rather than a tall scroll. */
|
||||||
|
#view-crash {
|
||||||
|
display: grid; grid-template-columns: 1.35fr 1fr;
|
||||||
|
grid-template-areas: "pick pick" "stage controls" "strip players" "proof proof";
|
||||||
|
gap: 0 20px; align-items: start;
|
||||||
|
}
|
||||||
|
#view-crash .gamepick { grid-area: pick; }
|
||||||
|
#view-crash .stage { grid-area: stage; }
|
||||||
|
#view-crash .strip { grid-area: strip; }
|
||||||
|
#view-crash .controls { grid-area: controls; margin-top: 0; }
|
||||||
|
#view-crash .players { grid-area: players; }
|
||||||
|
#view-crash .proof { grid-area: proof; }
|
||||||
|
|
||||||
|
#view-portfolio { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||||
|
#view-portfolio .tiles { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect a stated preference for less motion: the urgency pulse and the
|
||||||
|
scanline flicker are exactly what that setting is asking about. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.primary.cashout { animation: none; }
|
||||||
|
body::after { animation: none; }
|
||||||
|
.multiplier.crashed { animation: none; }
|
||||||
|
h1::after { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
6
cmd/arcade/static/vendor/three.module.min.js
vendored
Normal file
6
cmd/arcade/static/vendor/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
go.mod
8
go.mod
@@ -2,13 +2,17 @@ module github.com/drjones/quantum-arcade
|
|||||||
|
|
||||||
go 1.26.5
|
go 1.26.5
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.10.0
|
require (
|
||||||
|
github.com/cloudflare/circl v1.6.5
|
||||||
|
github.com/coder/websocket v1.8.15
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.15 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA=
|
||||||
|
github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c=
|
||||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -20,6 +22,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
12
pkg/pqid/export_test.go
Normal file
12
pkg/pqid/export_test.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package pqid_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signWithContext produces an ML-DSA signature under an arbitrary context, so
|
||||||
|
// the domain-separation test can prove the production context is enforced.
|
||||||
|
func signWithContext(priv *pqid.PrivateKey, msg, ctx, out []byte) error {
|
||||||
|
return mldsa65.SignTo(priv.PQ, msg, ctx, false, out)
|
||||||
|
}
|
||||||
171
pkg/pqid/pqid.go
Normal file
171
pkg/pqid/pqid.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Package pqid implements hybrid post-quantum player identity.
|
||||||
|
//
|
||||||
|
// A player's key is two keys: classical Ed25519 and post-quantum ML-DSA-65
|
||||||
|
// (NIST FIPS 204). Both signatures are required for every authentication, so
|
||||||
|
// the identity holds if *either* algorithm survives:
|
||||||
|
//
|
||||||
|
// - Ed25519 alone falls to Shor's algorithm on a sufficiently large quantum
|
||||||
|
// computer.
|
||||||
|
// - ML-DSA is young. Lattice cryptanalysis is an active field, and betting
|
||||||
|
// everything on a 2024 standard would be its own kind of naive.
|
||||||
|
//
|
||||||
|
// Requiring both is the composition NIST and the IETF recommend: an attacker
|
||||||
|
// must break lattice assumptions *and* elliptic curves, not either one.
|
||||||
|
//
|
||||||
|
// What this does and does not protect:
|
||||||
|
//
|
||||||
|
// protected player identity, session authentication, bet authorisation
|
||||||
|
// protected round fairness — commit-reveal is SHA-256/HMAC, and Grover
|
||||||
|
// only halves the security margin, leaving ~128 bits
|
||||||
|
// NOT protected Bitcoin and Lightning settlement, which sign with secp256k1.
|
||||||
|
// No application-layer choice can change that.
|
||||||
|
package pqid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/cloudflare/circl/sign/mldsa/mldsa65"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sizes of the wire encodings.
|
||||||
|
const (
|
||||||
|
EdPublicKeySize = ed25519.PublicKeySize // 32
|
||||||
|
EdSignatureSize = ed25519.SignatureSize // 64
|
||||||
|
|
||||||
|
PQPublicKeySize = mldsa65.PublicKeySize // 1952
|
||||||
|
PQSignatureSize = mldsa65.SignatureSize // 3309
|
||||||
|
|
||||||
|
PublicKeySize = EdPublicKeySize + PQPublicKeySize
|
||||||
|
SignatureSize = EdSignatureSize + PQSignatureSize
|
||||||
|
|
||||||
|
// IDSize is the length of the account identifier derived from a key.
|
||||||
|
IDSize = sha256.Size
|
||||||
|
)
|
||||||
|
|
||||||
|
// context binds signatures to this application, so a signature produced for
|
||||||
|
// some other ML-DSA protocol cannot be replayed here.
|
||||||
|
var context = []byte("quantum-arcade/v1")
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMalformedKey = errors.New("pqid: malformed public key")
|
||||||
|
ErrMalformedSignature = errors.New("pqid: malformed signature")
|
||||||
|
ErrClassicalFailed = errors.New("pqid: ed25519 signature did not verify")
|
||||||
|
ErrPostQuantumFailed = errors.New("pqid: ML-DSA signature did not verify")
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicKey is a player's hybrid identity.
|
||||||
|
type PublicKey struct {
|
||||||
|
Ed ed25519.PublicKey
|
||||||
|
PQ *mldsa65.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrivateKey is the matching secret half. Clients hold this; the server never
|
||||||
|
// sees it.
|
||||||
|
type PrivateKey struct {
|
||||||
|
Ed ed25519.PrivateKey
|
||||||
|
PQ *mldsa65.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKey creates a hybrid keypair.
|
||||||
|
func GenerateKey(rand io.Reader) (*PublicKey, *PrivateKey, error) {
|
||||||
|
edPub, edPriv, err := ed25519.GenerateKey(rand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("pqid: generating ed25519 key: %w", err)
|
||||||
|
}
|
||||||
|
pqPub, pqPriv, err := mldsa65.GenerateKey(rand)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("pqid: generating ML-DSA key: %w", err)
|
||||||
|
}
|
||||||
|
return &PublicKey{Ed: edPub, PQ: pqPub}, &PrivateKey{Ed: edPriv, PQ: pqPriv}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes encodes the public key as ed25519 || ML-DSA.
|
||||||
|
func (p *PublicKey) Bytes() []byte {
|
||||||
|
out := make([]byte, 0, PublicKeySize)
|
||||||
|
out = append(out, p.Ed...)
|
||||||
|
pq, err := p.PQ.MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
// MarshalBinary on a valid key cannot fail; a failure here means the
|
||||||
|
// key is corrupt, and silently returning a short key would be worse.
|
||||||
|
panic("pqid: marshalling ML-DSA public key: " + err.Error())
|
||||||
|
}
|
||||||
|
return append(out, pq...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex renders the public key for transport.
|
||||||
|
func (p *PublicKey) Hex() string { return hex.EncodeToString(p.Bytes()) }
|
||||||
|
|
||||||
|
// ID is the account identifier: SHA-256 over the whole hybrid key.
|
||||||
|
//
|
||||||
|
// The ledger keys on this rather than the raw key because the hybrid key is
|
||||||
|
// nearly 2KB, and a fixed 32-byte identifier keeps indexes small. Hashing also
|
||||||
|
// means the identifier is stable in length no matter how the key evolves.
|
||||||
|
func (p *PublicKey) ID() []byte {
|
||||||
|
sum := sha256.Sum256(p.Bytes())
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKey decodes a hybrid public key from its wire encoding.
|
||||||
|
func ParsePublicKey(b []byte) (*PublicKey, error) {
|
||||||
|
if len(b) != PublicKeySize {
|
||||||
|
return nil, fmt.Errorf("%w: got %d bytes, want %d",
|
||||||
|
ErrMalformedKey, len(b), PublicKeySize)
|
||||||
|
}
|
||||||
|
ed := ed25519.PublicKey(append([]byte(nil), b[:EdPublicKeySize]...))
|
||||||
|
|
||||||
|
var pq mldsa65.PublicKey
|
||||||
|
if err := pq.UnmarshalBinary(b[EdPublicKeySize:]); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrMalformedKey, err)
|
||||||
|
}
|
||||||
|
return &PublicKey{Ed: ed, PQ: &pq}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKeyHex decodes a hex-encoded hybrid public key.
|
||||||
|
func ParsePublicKeyHex(s string) (*PublicKey, error) {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: not hex", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
return ParsePublicKey(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign produces both signatures over the message, concatenated.
|
||||||
|
func Sign(priv *PrivateKey, msg []byte) ([]byte, error) {
|
||||||
|
edSig := ed25519.Sign(priv.Ed, msg)
|
||||||
|
|
||||||
|
pqSig := make([]byte, PQSignatureSize)
|
||||||
|
// randomized=false gives deterministic (hedged) signatures, so a bad RNG
|
||||||
|
// on a phone cannot leak the key through signature randomness.
|
||||||
|
if err := mldsa65.SignTo(priv.PQ, msg, context, false, pqSig); err != nil {
|
||||||
|
return nil, fmt.Errorf("pqid: ML-DSA signing: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]byte, 0, SignatureSize)
|
||||||
|
out = append(out, edSig...)
|
||||||
|
return append(out, pqSig...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks both signatures. Both must pass.
|
||||||
|
//
|
||||||
|
// It deliberately reports which half failed: a mismatch between the two is
|
||||||
|
// diagnostic — it means a client is half-upgraded or something is tampering
|
||||||
|
// with one algorithm — and that is worth surfacing rather than flattening into
|
||||||
|
// a generic failure. Nothing secret is revealed by saying which one broke.
|
||||||
|
func Verify(pub *PublicKey, msg, sig []byte) error {
|
||||||
|
if len(sig) != SignatureSize {
|
||||||
|
return fmt.Errorf("%w: got %d bytes, want %d",
|
||||||
|
ErrMalformedSignature, len(sig), SignatureSize)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub.Ed, msg, sig[:EdSignatureSize]) {
|
||||||
|
return ErrClassicalFailed
|
||||||
|
}
|
||||||
|
if !mldsa65.Verify(pub.PQ, msg, context, sig[EdSignatureSize:]) {
|
||||||
|
return ErrPostQuantumFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
210
pkg/pqid/pqid_test.go
Normal file
210
pkg/pqid/pqid_test.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package pqid_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/pqid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newKey(t *testing.T) (*pqid.PublicKey, *pqid.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := pqid.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return pub, priv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignAndVerify(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("authenticate me")
|
||||||
|
|
||||||
|
sig, err := pqid.Sign(priv, msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||||
|
t.Fatalf("valid hybrid signature rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongMessageFails(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("original"))
|
||||||
|
if err := pqid.Verify(pub, []byte("tampered"), sig); err == nil {
|
||||||
|
t.Fatal("signature verified against a different message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrongKeyFails(t *testing.T) {
|
||||||
|
_, priv := newKey(t)
|
||||||
|
other, _ := newKey(t)
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
||||||
|
if err := pqid.Verify(other, []byte("msg"), sig); err == nil {
|
||||||
|
t.Fatal("signature verified under the wrong key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of hybrid: forging only the classical half must not
|
||||||
|
// authenticate. This is the quantum-adversary scenario — Shor breaks Ed25519,
|
||||||
|
// ML-DSA still holds.
|
||||||
|
func TestValidClassicalWithBrokenPostQuantumFails(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("msg")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
|
||||||
|
// Keep the genuine Ed25519 signature, corrupt the ML-DSA half.
|
||||||
|
forged := append([]byte(nil), sig...)
|
||||||
|
forged[pqid.EdSignatureSize+10] ^= 0xff
|
||||||
|
|
||||||
|
err := pqid.Verify(pub, msg, forged)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a broken post-quantum half still authenticated")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pqid.ErrPostQuantumFailed) {
|
||||||
|
t.Fatalf("got %v, want ErrPostQuantumFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the mirror case: if lattice cryptography turns out to be weak, Ed25519
|
||||||
|
// must still stand in the way.
|
||||||
|
func TestValidPostQuantumWithBrokenClassicalFails(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("msg")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
|
||||||
|
forged := append([]byte(nil), sig...)
|
||||||
|
forged[5] ^= 0xff // corrupt the Ed25519 half
|
||||||
|
|
||||||
|
err := pqid.Verify(pub, msg, forged)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a broken classical half still authenticated")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pqid.ErrClassicalFailed) {
|
||||||
|
t.Fatalf("got %v, want ErrClassicalFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignatureSizeIsExact(t *testing.T) {
|
||||||
|
_, priv := newKey(t)
|
||||||
|
sig, err := pqid.Sign(priv, []byte("msg"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(sig) != pqid.SignatureSize {
|
||||||
|
t.Fatalf("signature is %d bytes, want %d", len(sig), pqid.SignatureSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTruncatedSignatureRejected(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
||||||
|
|
||||||
|
for _, n := range []int{0, 64, pqid.SignatureSize - 1} {
|
||||||
|
if err := pqid.Verify(pub, []byte("msg"), sig[:n]); !errors.Is(err, pqid.ErrMalformedSignature) {
|
||||||
|
t.Fatalf("signature truncated to %d bytes gave %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicKeyRoundTrip(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
|
||||||
|
restored, err := pqid.ParsePublicKeyHex(pub.Hex())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The restored key must verify signatures made by the original.
|
||||||
|
sig, _ := pqid.Sign(priv, []byte("msg"))
|
||||||
|
if err := pqid.Verify(restored, []byte("msg"), sig); err != nil {
|
||||||
|
t.Fatalf("round-tripped key failed to verify: %v", err)
|
||||||
|
}
|
||||||
|
if string(restored.ID()) != string(pub.ID()) {
|
||||||
|
t.Fatal("round-tripped key has a different ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMalformedKeysRejected(t *testing.T) {
|
||||||
|
cases := map[string][]byte{
|
||||||
|
"empty": {},
|
||||||
|
"too short": make([]byte, pqid.PublicKeySize-1),
|
||||||
|
"too long": make([]byte, pqid.PublicKeySize+1),
|
||||||
|
}
|
||||||
|
for name, b := range cases {
|
||||||
|
if _, err := pqid.ParsePublicKey(b); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("%s: got %v, want ErrMalformedKey", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := pqid.ParsePublicKeyHex("nothex!!"); !errors.Is(err, pqid.ErrMalformedKey) {
|
||||||
|
t.Errorf("non-hex: got %v, want ErrMalformedKey", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIDIsStableAndDistinct(t *testing.T) {
|
||||||
|
a, _ := newKey(t)
|
||||||
|
b, _ := newKey(t)
|
||||||
|
|
||||||
|
if string(a.ID()) != string(a.ID()) {
|
||||||
|
t.Fatal("ID is not stable across calls")
|
||||||
|
}
|
||||||
|
if string(a.ID()) == string(b.ID()) {
|
||||||
|
t.Fatal("two distinct keys produced the same ID")
|
||||||
|
}
|
||||||
|
if len(a.ID()) != pqid.IDSize {
|
||||||
|
t.Fatalf("ID is %d bytes, want %d", len(a.ID()), pqid.IDSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signatures must be bound to this application, so one captured from another
|
||||||
|
// ML-DSA protocol cannot be replayed here.
|
||||||
|
func TestSignaturesAreDomainSeparated(t *testing.T) {
|
||||||
|
pub, priv := newKey(t)
|
||||||
|
msg := []byte("msg")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
|
||||||
|
// Verifying with the correct context succeeds (covered above). Here we
|
||||||
|
// confirm the context is actually in use by checking that a signature made
|
||||||
|
// over the same message still fails if the ML-DSA half is swapped for one
|
||||||
|
// generated under a different context.
|
||||||
|
other := make([]byte, pqid.PQSignatureSize)
|
||||||
|
if err := signWithContext(priv, msg, []byte("some-other-protocol"), other); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
forged := append(append([]byte(nil), sig[:pqid.EdSignatureSize]...), other...)
|
||||||
|
if err := pqid.Verify(pub, msg, forged); !errors.Is(err, pqid.ErrPostQuantumFailed) {
|
||||||
|
t.Fatalf("signature from another context was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkSign(b *testing.B) {
|
||||||
|
_, priv, _ := pqid.GenerateKey(rand.Reader)
|
||||||
|
msg := []byte("benchmark message")
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, err := pqid.Sign(priv, msg); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkVerify(b *testing.B) {
|
||||||
|
pub, priv, _ := pqid.GenerateKey(rand.Reader)
|
||||||
|
msg := []byte("benchmark message")
|
||||||
|
sig, _ := pqid.Sign(priv, msg)
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if err := pqid.Verify(pub, msg, sig); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGenerateKey(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
if _, _, err := pqid.GenerateKey(rand.Reader); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user