openvibers/openvibe.casino Commits

OpenVibe.Casino v1

OP OpenVibers committed 13 days ago · d38c152020 · 52 files, +6872 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..2c2216c
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,45 @@
++# OpenVibe.Casino — environment (production: /etc/openvibe/casino.env). Names only; never commit values.
++
++NODE_ENV=production
++PORT=5210
++HOST=127.0.0.1
++BASE_URL=https://openvibe.casino
++TRUST_PROXY=2
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,
++# the owner on a direct connection (migrations), and Valkey (shared limit counters).
++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=ov:casino:
++# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).
++#CASINO_LIMITS_MINUTE=120
++#CASINO_LIMITS_HOUR=3000
++#CASINO_API_RATE_LIMIT_PER_MIN=240
++#CASINO_RATE_LIMIT_PER_MIN=300
++
++# OpenVibe.Network: SSO (OAuth client `casino`, PKCE S256) and its JWKS; app tokens for audience openvibe.casino.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# OV_NETWORK_ISSUER=https://openvibe.network
++OV_OAUTH_CLIENT_ID=casino
++OV_OAUTH_CLIENT_SECRET=
++# OV_OAUTH_REDIRECT_URI=https://openvibe.casino/auth/callback
++# CASINO_AUDIENCE=openvibe.casino
++# OV_SESSION_AUDIENCE=openvibe.network
++# CASINO_PGLITE_DIR=./data/pglite
++
++# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
++# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
++CASINO_EVENTS_SECRET=
++CASINO_EVENTS_URL=
++#CASINO_EVENTS_ENDPOINT=http://127.0.0.1:5210/internal/events
++#CASINO_EVENTS_INTERVAL_MS=5000
++
++# Simulations run on the server (POST /api/v1/simulate): the most rounds one may ask for, and how many an address may
++# run a minute. The simulator page runs in the visitor's browser and has neither limit.
++#CASINO_SIM_MAX_ROUNDS=200000
++#CASINO_SIM_PER_MINUTE=6
++
++# Drill mode (a restore drill): no subscriptions, no outbound calls at boot.
++#CASINO_DRILL=1
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..e3cdaae
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,17 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ # install, node --check and npm test: the shared test job (Track Q).
++ test:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
++ with:
++ syntax-dirs: server test scripts
++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..d2a8a4e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++.npm-cache/
++*.log
++*.pem
++*.key
++!server/data/
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..7f00465
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,16 @@
++# Changelog
++
++## 0.1.0 — 2026-10-31
++
++The first version: learn it, play it, never pay it.
++
++- **Engines** for blackjack, roulette, baccarat, craps and slots, deterministic from a seed and the same files on the
++ server, at the tables and in the simulator. Every house edge is computed (exactly, or by simulation for blackjack
++ strategies) and checked against the published figure.
++- **Tables** for every game, with a fresh 1,000 play chips a day kept in the browser, worth nothing; an 18+ gate; a
++ reminder every 30 minutes.
++- **Learn**: the rules and each bet's cost, with the engine's own basic-strategy charts, the baccarat tableau, the dice
++ odds and the slot machine's strips.
++- **Simulate**: up to a million rounds in a Web Worker, charted against the exact edge, and shareable.
++- **API**: `/api/v1/games`, `/api/v1/simulate`, `/api/v1/sims/:id`, OpenAPI.
++- **Play safe** everywhere: one calm line on every page, and a page with free, confidential help.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,661 @@
++ GNU AFFERO GENERAL PUBLIC LICENSE
++ Version 3, 19 November 2007
++
++ Copyright (C) 2007 Free Software Foundation, Inc. <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/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..df888c5
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,67 @@
++# OpenVibe.Casino
++
++> Learn it, play it, never pay it.
++
++**Domain:** `openvibe.casino` · **Port:** 5210 · **Service id:** `casino` · **Env prefix:** `CASINO`
++
++Every casino game with its insides showing: the rules, the exact odds and what each bet costs, tables you can play with
++chips worth nothing, and a simulator that plays a million rounds in your browser. Plan: the new-domains plan, §5.14.
++
++**No real money, ever.** Play chips are free, a fresh 1,000 arrive each day, they live in the player's browser, and they
++cannot be bought, sold, won as anything or cashed out. No operator listings and no affiliate links: those wait for the
++owner's legal review. The tables sit behind a self-declared 18+ gate, remind a player every 30 minutes, and every page
++carries one calm line pointing at free help (1-800-GAMBLER, BeGambleAware, Gamblers Anonymous).
++
++## The engines (`engine/`)
++
++Pure JavaScript, deterministic from a seed (`rng.js`, mulberry32), UMD so the same file runs on the server, at the table
++and in the simulator's Web Worker.
++
++| Engine | What it plays | How the edge is known |
++|---|---|---|
++| `blackjack.js` | 6 decks, S17, DAS, split to 4, split aces one card, peek, insurance, 3:2; basic strategy, "like the dealer", "never bust" | simulation; basic strategy checked against the published 0.40% |
++| `roulette.js` | European and American, every layout bet | exact: 2.70%, 5.26%, top line 7.89% |
++| `baccarat.js` | punto banco, 8 decks, the third-card tableau, 5% commission | exact, every six-card path weighted: 1.06 / 1.24 / 14.36% |
++| `craps.js` | pass, don't pass, odds (3-4-5x), place, field | exact from the 36 dice outcomes |
++| `slots.js` | three 24-stop reels, a paytable | exact over all 13,824 stops: RTP 93.63% |
++
++`engine/games.js` is the catalogue every page and the API read; no edge in it is typed in.
++`test/engines.test.js` checks every figure against Wizard of Odds' published one.
++
++## Pages
++
++| Path | What |
++|---|---|
++| `/` | every bet's cost on one ladder, a real bankroll chart, what you can do, questions |
++| `/play`, `/play/:game` | the tables (`public/js/table.js`), the 18+ gate, the chips (`public/js/casino.js`) |
++| `/learn`, `/learn/:game` | the rules and each bet's edge, with the engine's own tables (basic strategy, the tableau, the strips) |
++| `/simulate` | the simulator (`public/js/simulate.js`, `public/js/sim-worker.js`) |
++| `/s/:id` | a shared run (noindex) |
++| `/safe`, `/developers` | play safe; the API |
++
++## API (`/api/v1`, open, no key)
++
++- `GET /games`, `GET /games/:id`: rules, each bet's edge, how it is known and the published source.
++- `POST /simulate` `{ game, choice, rounds, seed?, share? }`: a bounded run on the server (`CASINO_SIM_MAX_ROUNDS`,
++ default 200,000; `CASINO_SIM_PER_MINUTE` per address, default 6). With `share`, the run is kept and its link answered.
++- `GET /sims/:id`: a shared run. `GET /openapi.json`: OpenAPI 3.1.
++
++## Data
++
++One table, `casino_sims` (a shared run: game, params with the seed, result). No column names a person. Casino keeps
++nothing about anyone, so an account export says so and a deletion has nothing to erase (`server/identity/account-data.js`).
++
++## The service
++
++Express, server-rendered through `openvibe-shared/shell` and the room kit (`rooms.js` id `casino`). Sign-in through
++OpenVibe.Network (OAuth client `casino`, PKCE). PostgreSQL through `openvibe-sdk/db` (`migrations/`), PGlite in
++development and tests. Deploy files: `deploy/systemd/openvibe-casino.service`, `deploy/nginx/openvibe.casino.conf`
++(Meme's, renamed). Env: `.env.example`.
++
++```bash
++npm install
++npm run dev # http://localhost:5210
++npm test # node test/run.js on PGlite
++```
++
++AGPL-3.0-only.
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..390d7a2
---- /dev/null
++++ b/SECURITY.md
@@@ -0,0 +1,38 @@
++# Security policy
++
++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
++
++## Reporting a vulnerability
++
++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
++a vulnerability, and do not test against other people's accounts, data or streams.
++
++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
++
++## Scope
++
++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.casino, openvibe.today, openvibe.tools,
++openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under
++github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.
++
++## What OpenVibe.Casino holds
++
++- Public entries: the title, kind, AI-written summary, status, times and revision. An entry is the network's record,
++ not a person's row, so it is not exported or erased with an account.
++- Public citations: a verbatim quote from public speech, its sha256, the canonical public URL and the source ids
++ (chat message id, VOD/clip id, Rundown segment id). No private VOD bytes, thumbnails or metadata are kept.
++- A person's weekly votes, saved caption lines and reports. These are exported and erased with an account.
++- Network session tokens only in httpOnly cookies (`casino_at`, `casino_rt`); no token is stored in the database,
++ rendered into a page or logged.
++
++VOD 5709 and clip 483 are a fixed deny list: no title, thumbnail or quote from either is ever stored, and a
++citation naming them is dropped before it is written. A citation URL is re-read daily; a 404 marks it gone.
++
++A caption is plain text lines on a template; v1 never accepts, renders or stores an image uploaded by a person.
++The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for
++`/internal` and `/metrics`.
++
++## Supported versions
++
++Only the current `main` branch, which is what runs in production, receives fixes.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..b28ae5c
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,53 @@
++{
++ "repository": "OpenVibers/OpenVibe.Casino",
++ "version": "0.1.0",
++ "stage": "v1",
++ "code": true,
++ "wave": "W21",
++ "plan": "OpenVibe.Casino: learn it, play it, never pay it. Casino games with their odds showing, tables with play chips worth nothing, and simulations; plan 5.14.",
++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
++ "port": "5210",
++ "domain": "openvibe.casino",
++ "contracts": "openvibe-contracts v0.234.0",
++ "sdk": "openvibe-sdk v0.74.0",
++ "shared": "openvibe-shared v4.67.0",
++ "packages": [
++ "openvibe-contracts v0.234.0",
++ "openvibe-sdk v0.74.0",
++ "openvibe-shared v4.67.0"
++ ],
++ "summary": "Five open engines (blackjack, roulette, baccarat, craps, slots) with exact or checked house edges, playable tables, Learn explainers built on the engines, a Web Worker simulator with shareable runs, and an open API. No real money; an 18+ gate and play-safe lines throughout.",
++ "works": [
++ "Engines: blackjack (S17, DAS, basic strategy and two naive strategies, simulated), roulette (European, American, every bet, exact), baccarat (8 decks, exact by path enumeration), craps (pass, don't pass, odds, place, field, exact), slots (exact RTP over every stop)",
++ "Every edge checked against Wizard of Odds' published figure (test/engines.test.js)",
++ "Tables for every game with daily play chips in the browser, an 18+ gate and a 30-minute reminder",
++ "Learn pages with the engine's own basic-strategy charts, the baccarat tableau, the dice odds and the slot strips",
++ "A Web Worker simulator charting the house's share toward the exact edge; shareable runs (casino_sims)",
++ "API: /games, /simulate (bounded), /sims/:id, OpenAPI"
++ ],
++ "notYet": [
++ "Daily chip highs for signed-in people (play chips only)",
++ "3D tables on the Games engine",
++ "Operator and affiliate content only after the owner's legal review"
++ ],
++ "capabilitiesRegistered": false,
++ "serviceManifestRegistered": false,
++ "deployed": false,
++ "placeholderInSites": false,
++ "features": [
++ "blackjack",
++ "roulette",
++ "baccarat",
++ "craps",
++ "slots",
++ "house edge ladder",
++ "basic strategy hints",
++ "simulator",
++ "shareable runs",
++ "play chips",
++ "18+ gate",
++ "play safe"
++ ],
++ "deployment": "openvibe-casino.service on 127.0.0.1:5210 behind deploy/nginx/openvibe.casino.conf",
++ "updated": "2026-10-31"
++}
ddiff --git a/deploy/nginx/openvibe.casino.conf b/deploy/nginx/openvibe.casino.conf
nnew file mode 100644
iindex 0000000..fa940cd
---- /dev/null
++++ b/deploy/nginx/openvibe.casino.conf
@@@ -0,0 +1,116 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.casino — Nginx configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.casino.conf (ov-vhost-install) — keep them in sync and reload
++## nginx after changing it. The Node app (port 5210, systemd unit openvibe-casino) renders every page and serves
++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses
++## text.
++##
++## No proxy_cache: the app decides cacheability per response (public for the public pages and entries; private,
++## no-store for anything drawn for a signed-in person). Never log request bodies here.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=casino_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=casino_api:10m rate=5r/s;
++limit_req_zone $binary_remote_addr zone=casino_forms:10m rate=1r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.casino www.openvibe.casino;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.casino$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.casino;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.casino/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.casino/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.casino$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.casino;
++ include snippets/security-txt.conf;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.casino/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.casino/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.casino.access.log;
++ error_log /var/log/nginx/openvibe.casino.error.log;
++
++ # The API's JSON body cap is 64 KB; leave room for the headers.
++ client_max_body_size 128k;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ # ── Network SSO session endpoints ───────────────────────
++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
++ location = /auth/me {
++ limit_req zone=casino_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5210;
++ }
++
++ location /auth/ {
++ limit_req zone=casino_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5210;
++ }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=casino_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5210;
++ }
++
++ # ── Forms and other writes: the vote, the caption generator, the report ──
++ location ~ ^/(week/vote|make|m/[a-z0-9-]+/report)$ {
++ limit_req zone=casino_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5210;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5210/metrics) ──
++ location ~* ^/metrics(/|$) { return 404; }
++
++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:5210/internal/events directly. Never proxied from outside (ADR-033).
++ location ~* ^/internal(/|$) { return 404; }
++
++ # ── Pages, docs, robots.txt, sitemap, static assets ─────
++ location / {
++ proxy_pass http://127.0.0.1:5210;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-casino.service b/deploy/systemd/openvibe-casino.service
nnew file mode 100644
iindex 0000000..1c0ca20
---- /dev/null
++++ b/deploy/systemd/openvibe-casino.service
@@@ -0,0 +1,31 @@
++[Unit]
++Description=OpenVibe.Casino — the open meme database, rebuilt with AI.
++# Network (sign-in, JWKS, account events) should be up first, but Meme degrades honestly without it: the public
++# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).
++After=network.target postgresql.service pgbouncer.service valkey-server.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.casino
++EnvironmentFile=/etc/openvibe/meme.env
++Environment=NODE_ENV=production
++Environment=PORT=5210
++Environment=HOST=127.0.0.1
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.casino/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++# In-flight requests get the 10 s drain.
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# OpenVibe.Casino's own database is PostgreSQL on the host's data role; the code tree stays read-only.
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/engine/baccarat.js b/engine/baccarat.js
nnew file mode 100644
iindex 0000000..e76ebce
---- /dev/null
++++ b/engine/baccarat.js
@@@ -0,0 +1,127 @@
++'use strict';
++
++/**
++ * Baccarat (punto banco) from an eight-deck shoe: the player and the banker get two cards, a natural 8 or 9 stands, the
++ * player draws on 0 to 5, and the banker's third card follows the tableau below. Bets: player (1:1), banker (1:1 less a
++ * 5% commission) and tie (8:1; player and banker bets push on a tie).
++ *
++ * exactEdges() works the edges out exactly by walking every way the six cards can fall from the shoe (card values only,
++ * so 10^6 paths), weighting each by its probability without replacement: banker 1.06%, player 1.24%, tie 14.36%.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeBaccarat = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const node = typeof module === 'object' && module.exports;
++ const rngLib = node ? require('./rng') : root.OpenVibeRng;
++ const cardsLib = node ? require('./cards') : root.OpenVibeCards;
++
++ const PAYS = Object.freeze({ player: 1, banker: 0.95, tie: 8 });
++ const value = (card) => (card.rank >= 10 ? 0 : card.rank);
++ const total = (vals) => vals.reduce((a, v) => a + v, 0) % 10;
++
++ /** Does the banker draw, given its total and the player's third card value (null when the player stood)? */
++ function bankerDraws(bankerTotal, playerThird) {
++ if (playerThird == null) return bankerTotal <= 5;
++ if (bankerTotal <= 2) return true;
++ if (bankerTotal === 3) return playerThird !== 8;
++ if (bankerTotal === 4) return playerThird >= 2 && playerThird <= 7;
++ if (bankerTotal === 5) return playerThird >= 4 && playerThird <= 7;
++ if (bankerTotal === 6) return playerThird === 6 || playerThird === 7;
++ return false;
++ }
++
++ /** Play a coup from six card values in deal order (p1, b1, p2, b2, then the thirds as needed). */
++ function coup(next) {
++ const p = [next(), 0]; const b = [next(), 0];
++ p[1] = next(); b[1] = next();
++ let pt = total(p); let bt = total(b);
++ let playerThird = null;
++ if (pt < 8 && bt < 8) {
++ if (pt <= 5) { playerThird = next(); p.push(playerThird); pt = total(p); }
++ if (bankerDraws(bt, playerThird)) { b.push(next()); bt = total(b); }
++ }
++ return { player: p, banker: b, playerTotal: pt, bankerTotal: bt, winner: pt > bt ? 'player' : bt > pt ? 'banker' : 'tie' };
++ }
++
++ function net(bet, winner, amount = 1) {
++ if (bet === 'tie') return winner === 'tie' ? amount * PAYS.tie : -amount;
++ if (winner === 'tie') return 0;
++ return winner === bet ? amount * PAYS[bet] : -amount;
++ }
++
++ /** The exact edges for a fresh shoe of `decks` decks (Wizard of Odds lists 1.06 / 1.24 / 14.36 for eight). */
++ function exactEdges(decks = 8) {
++ const counts = [16 * decks, 4 * decks, 4 * decks, 4 * decks, 4 * decks, 4 * decks, 4 * decks, 4 * decks, 4 * decks, 4 * decks];
++ let left = 52 * decks;
++ const odds = { player: 0, banker: 0, tie: 0 };
++ // Depth-first over card values; a coup never needs more than six cards.
++ function walk(drawn, prob) {
++ let i = 0;
++ const seq = drawn;
++ const next = () => { const v = seq[i]; i += 1; return v; };
++ const need = needed(seq);
++ if (need === 0) { const c = coup(next); odds[c.winner] += prob; return; }
++ for (let v = 0; v < 10; v += 1) {
++ if (!counts[v]) continue;
++ const p = prob * counts[v] / left;
++ counts[v] -= 1; left -= 1;
++ walk(seq.concat(v), p);
++ counts[v] += 1; left += 1;
++ }
++ }
++ // How many more cards the coup needs given the values drawn so far (0 when it is complete).
++ function needed(seq) {
++ if (seq.length < 4) return 4 - seq.length;
++ const pt = (seq[0] + seq[2]) % 10; const bt = (seq[1] + seq[3]) % 10;
++ if (pt >= 8 || bt >= 8) return 0;
++ if (pt <= 5) {
++ if (seq.length < 5) return 1;
++ return bankerDraws(bt, seq[4]) && seq.length < 6 ? 1 : 0;
++ }
++ return bankerDraws(bt, null) && seq.length < 5 ? 1 : 0;
++ }
++ walk([], 1);
++ const edge = (bet) => -(['player', 'banker', 'tie'].reduce((a, w) => a + odds[w] * net(bet, w), 0));
++ return { odds, edges: { player: edge('player'), banker: edge('banker'), tie: edge('tie') } };
++ }
++
++ function createGame({ decks = 8, seed = 1, rng = null } = {}) {
++ const g = rng || rngLib.createRng(seed);
++ let shoe = [];
++ const draw = () => { if (shoe.length < 14) shoe = g.shuffle(cardsLib.makeShoe(decks)); return shoe.pop(); };
++ return {
++ /** Deal one coup and settle the bets ({ bet: player|banker|tie, amount }). */
++ deal(bets = []) {
++ const cards = [];
++ const c = coup(() => { const card = draw(); cards.push(card); return value(card); });
++ // Put the real cards back on their hands in deal order: p1 b1 p2 b2, then the player's third, the banker's.
++ const p = [cards[0], cards[2]]; const b = [cards[1], cards[3]];
++ let k = 4;
++ if (c.player.length === 3) { p.push(cards[k]); k += 1; }
++ if (c.banker.length === 3) b.push(cards[k]);
++ const results = bets.map((x) => ({ ...x, net: net(x.bet, c.winner, x.amount) }));
++ return { ...c, playerCards: p, bankerCards: b, results, net: results.reduce((a, x) => a + x.net, 0) };
++ },
++ };
++ }
++
++ function houseEdge({ bet = 'banker', coups = 200000, seed = 20261031, onProgress = null } = {}) {
++ const every = Math.max(100, Math.floor(coups / 200)); // about 200 progress points a run
++ const game = createGame({ seed });
++ let sum = 0; let squares = 0;
++ for (let i = 0; i < coups; i += 1) {
++ const n = game.deal([{ bet, amount: 1 }]).net;
++ sum += n; squares += n * n;
++ if (onProgress && (i + 1) % every === 0) onProgress(i + 1, -sum / (i + 1));
++ }
++ const mean = sum / coups;
++ const sd = Math.sqrt(Math.max(0, squares / coups - mean * mean));
++ return { game: 'baccarat', bet, coups, edge: -mean, percent: -mean * 100, stderr: (sd / Math.sqrt(coups)) * 100 };
++ }
++
++ return { PAYS, bankerDraws, coup, net, exactEdges, createGame, houseEdge, value };
++});
ddiff --git a/engine/blackjack.js b/engine/blackjack.js
nnew file mode 100644
iindex 0000000..fdd4e90
---- /dev/null
++++ b/engine/blackjack.js
@@@ -0,0 +1,242 @@
++'use strict';
++
++/**
++ * Blackjack: a six-deck shoe, the dealer stands on soft 17 (a table rule; hitting soft 17 is the other), double on any
++ * two cards and after a split, split up to four hands, split aces take one card each, the dealer peeks under an ace or
++ * a ten, insurance pays 2:1 and a blackjack 3:2. Pure: it takes a seeded generator and returns state, so the same seed
++ * deals the same cards on the server, in the browser and in a Web Worker.
++ *
++ * strategies: basic (the multi-deck chart, S17, double after split), dealer (hit below 17, as the dealer does) and
++ * never-bust (stand on any hard 12 or more). houseEdge() plays one of them for many hands and measures what the house
++ * keeps; test/engines.test.js checks basic strategy against the published figure for these rules.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeBlackjack = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const node = typeof module === 'object' && module.exports;
++ const cardsLib = node ? require('./cards') : root.OpenVibeCards;
++ const rngLib = node ? require('./rng') : root.OpenVibeRng;
++ const { handValue, isBlackjack } = cardsLib;
++
++ const DEFAULT_RULES = Object.freeze({ decks: 6, dealerHitsSoft17: false, doubleAfterSplit: true, maxHands: 4, blackjackPays: 1.5, penetration: 0.75 });
++
++ // The published figure for these rules: 6 decks, S17, DAS, no surrender, 3:2, peek.
++ const PUBLISHED = Object.freeze({ percent: 0.40, source: 'Wizard of Odds, blackjack house edge calculator', url: 'https://wizardofodds.com/games/blackjack/calculator/' });
++
++ // Basic strategy for 4 to 8 decks, dealer stands on soft 17, double after split (the standard chart).
++ // Dealer up card as 2..11 (11 is the ace). H hit, S stand, D double else hit, Ds double else stand, P split.
++ function chart(cards, dealerUp, canSplit) {
++ const v = handValue(cards);
++ const d = dealerUp;
++ const in_ = (lo, hi) => d >= lo && d <= hi;
++ if (canSplit && cards.length === 2 && rankValue(cards[0]) === rankValue(cards[1])) {
++ const p = rankValue(cards[0]);
++ if (p === 11 || p === 8) return 'P';
++ if ((p === 2 || p === 3 || p === 7) && in_(2, 7)) return 'P';
++ if (p === 4 && in_(5, 6)) return 'P';
++ if (p === 6 && in_(2, 6)) return 'P';
++ if (p === 9 && (in_(2, 6) || in_(8, 9))) return 'P';
++ }
++ if (v.soft && cards.length >= 2) {
++ const t = v.total;
++ if (t >= 19) return 'S';
++ if (t === 18) return in_(3, 6) ? 'Ds' : in_(2, 8) ? 'S' : 'H';
++ if (t === 17) return in_(3, 6) ? 'D' : 'H';
++ if (t === 15 || t === 16) return in_(4, 6) ? 'D' : 'H';
++ if (t === 13 || t === 14) return in_(5, 6) ? 'D' : 'H';
++ return 'H';
++ }
++ const t = v.total;
++ if (t >= 17) return 'S';
++ if (t >= 13) return in_(2, 6) ? 'S' : 'H';
++ if (t === 12) return in_(4, 6) ? 'S' : 'H';
++ if (t === 11) return d === 11 ? 'H' : 'D';
++ if (t === 10) return in_(2, 9) ? 'D' : 'H';
++ if (t === 9) return in_(3, 6) ? 'D' : 'H';
++ return 'H';
++ }
++ const rankValue = (c) => (c.rank === 1 ? 11 : Math.min(10, c.rank));
++
++ /** The chart's move as one of the actions the table offers now. */
++ function basicMove(cards, dealerUp, options) {
++ const code = chart(cards, dealerUp, options.includes('split'));
++ if (code === 'P') return 'split';
++ if (code === 'D') return options.includes('double') ? 'double' : 'hit';
++ if (code === 'Ds') return options.includes('double') ? 'double' : 'stand';
++ return code === 'S' ? 'stand' : 'hit';
++ }
++
++ const STRATEGIES = {
++ basic: { name: 'Basic strategy', move: basicMove },
++ dealer: { name: 'Play like the dealer', move: (cards) => (handValue(cards).total < 17 ? 'hit' : 'stand') },
++ 'never-bust': { name: 'Never bust', move: (cards) => { const v = handValue(cards); return v.total <= 11 || (v.soft && v.total < 18) ? 'hit' : 'stand'; } },
++ };
++
++ function createGame({ rules = DEFAULT_RULES, seed = 1, rng = null } = {}) {
++ const r = { ...DEFAULT_RULES, ...(rules || {}) };
++ const g = rng || rngLib.createRng(seed);
++ const shoeSize = r.decks * 52;
++ let shoe = [];
++ let hands = [];
++ let dealer = [];
++ let current = 0;
++ let phase = 'idle'; // idle | insurance | player | done
++ let insurance = 0; // the insurance stake (half the bet) when taken
++ let outcome = null;
++
++ const draw = () => {
++ if (shoe.length < shoeSize * (1 - r.penetration)) shoe = g.shuffle(cardsLib.makeShoe(r.decks));
++ return shoe.pop();
++ };
++ const upCard = () => dealer[0];
++ const dealerUpValue = () => rankValue(upCard());
++
++ function start(bet = 1) {
++ if (phase === 'player' || phase === 'insurance') throw new Error('a hand is in play');
++ const stake = Math.max(1, Math.floor(Number(bet) || 1));
++ hands = [{ cards: [draw()], bet: stake, done: false, split: false, splitAces: false }];
++ dealer = [draw()];
++ hands[0].cards.push(draw());
++ dealer.push(draw());
++ current = 0;
++ insurance = 0;
++ outcome = null;
++ if (upCard().rank === 1) { phase = 'insurance'; return state(); }
++ return afterPeek();
++ }
++
++ function afterPeek() {
++ const up10 = upCard().rank === 1 || upCard().rank >= 10;
++ if ((up10 && isBlackjack(dealer)) || isBlackjack(hands[0].cards)) return finish();
++ phase = 'player';
++ return state();
++ }
++
++ function available() {
++ if (phase === 'insurance') return ['insure', 'no-insurance'];
++ if (phase !== 'player') return [];
++ const h = hands[current];
++ const out = ['hit', 'stand'];
++ const two = h.cards.length === 2;
++ if (two && (!h.split || r.doubleAfterSplit)) out.push('double');
++ if (two && rankValue(h.cards[0]) === rankValue(h.cards[1]) && hands.length < r.maxHands && !h.splitAces) out.push('split');
++ return out;
++ }
++
++ function act(action) {
++ const options = available();
++ if (!options.includes(action)) return { ok: false, error: `not now: ${action}`, state: state() };
++ if (phase === 'insurance') {
++ if (action === 'insure') insurance = hands[0].bet / 2;
++ return { ok: true, state: afterPeek() };
++ }
++ const h = hands[current];
++ if (action === 'hit') {
++ h.cards.push(draw());
++ if (handValue(h.cards).total >= 21) h.done = true;
++ } else if (action === 'stand') {
++ h.done = true;
++ } else if (action === 'double') {
++ h.bet *= 2;
++ h.cards.push(draw());
++ h.done = true;
++ } else if (action === 'split') {
++ const aces = h.cards[0].rank === 1;
++ const second = { cards: [h.cards.pop(), draw()], bet: h.bet, done: false, split: true, splitAces: aces };
++ h.cards.push(draw());
++ h.split = true;
++ h.splitAces = aces;
++ hands.splice(current + 1, 0, second);
++ if (aces) { h.done = true; second.done = true; }
++ else if (handValue(h.cards).total === 21) h.done = true;
++ }
++ while (current < hands.length && hands[current].done) current += 1;
++ if (current < hands.length && handValue(hands[current].cards).total === 21) { hands[current].done = true; current += 1; }
++ if (current >= hands.length) finish();
++ return { ok: true, state: state() };
++ }
++
++ function dealerPlays() {
++ if (hands.every((h) => handValue(h.cards).bust)) return;
++ for (;;) {
++ const v = handValue(dealer);
++ if (v.total < 17 || (v.total === 17 && v.soft && r.dealerHitsSoft17)) dealer.push(draw());
++ else break;
++ }
++ }
++
++ function finish() {
++ const natural = hands.length === 1 && isBlackjack(hands[0].cards) && !hands[0].split;
++ const dealerNatural = isBlackjack(dealer);
++ if (!natural && !dealerNatural) dealerPlays();
++ const dv = handValue(dealer);
++ const results = hands.map((h) => {
++ const v = handValue(h.cards);
++ let result; let net;
++ if (natural && !dealerNatural) { result = 'blackjack'; net = h.bet * r.blackjackPays; }
++ else if (natural && dealerNatural) { result = 'push'; net = 0; }
++ else if (dealerNatural) { result = 'lose'; net = -h.bet; }
++ else if (v.bust) { result = 'bust'; net = -h.bet; }
++ else if (dv.bust || v.total > dv.total) { result = 'win'; net = h.bet; }
++ else if (v.total < dv.total) { result = 'lose'; net = -h.bet; }
++ else { result = 'push'; net = 0; }
++ return { result, net, bet: h.bet, total: v.total };
++ });
++ const insuranceNet = insurance ? (dealerNatural ? insurance * 2 : -insurance) : 0;
++ outcome = { results, insurance: insuranceNet, net: results.reduce((a, x) => a + x.net, 0) + insuranceNet, dealer: dv.total, dealerBust: dv.bust, dealerBlackjack: dealerNatural };
++ phase = 'done';
++ return state();
++ }
++
++ function hint() {
++ const options = available();
++ if (phase === 'insurance') return 'no-insurance';
++ if (phase !== 'player') return null;
++ return basicMove(hands[current].cards, dealerUpValue(), options);
++ }
++
++ function state() {
++ return {
++ phase,
++ current,
++ hands: hands.map((h) => ({ cards: h.cards.slice(), bet: h.bet, total: handValue(h.cards).total, soft: handValue(h.cards).soft, done: h.done })),
++ dealer: phase === 'done' ? dealer.slice() : [dealer[0]],
++ dealerTotal: phase === 'done' ? handValue(dealer).total : null,
++ insurance,
++ outcome,
++ };
++ }
++
++ return { start, act, available, hint, state, get phase() { return phase; }, get outcome() { return outcome; }, rules: r };
++ }
++
++ /** Play one strategy for many hands; the house edge is what the player loses per unit first bet. */
++ function houseEdge({ rules = DEFAULT_RULES, hands = 200000, seed = 20261031, strategy = 'basic', onProgress = null } = {}) {
++ const every = Math.max(100, Math.floor(hands / 200)); // about 200 progress points a run
++ const game = createGame({ rules, seed });
++ const plan = STRATEGIES[strategy] || STRATEGIES.basic;
++ let sum = 0; let squares = 0;
++ for (let i = 0; i < hands; i += 1) {
++ game.start(1);
++ if (game.phase === 'insurance') game.act('no-insurance');
++ while (game.phase === 'player') {
++ const s = game.state();
++ const options = game.available();
++ const move = plan.move(s.hands[s.current].cards, rankValue(s.dealer[0]), options);
++ game.act(options.includes(move) ? move : 'stand');
++ }
++ const net = game.outcome.net;
++ sum += net; squares += net * net;
++ if (onProgress && (i + 1) % every === 0) onProgress(i + 1, -sum / (i + 1));
++ }
++ const mean = sum / hands;
++ const sd = Math.sqrt(Math.max(0, squares / hands - mean * mean));
++ return { game: 'blackjack', strategy, hands, edge: -mean, percent: -mean * 100, stderr: (sd / Math.sqrt(hands)) * 100 };
++ }
++
++ return { DEFAULT_RULES, PUBLISHED, STRATEGIES, createGame, houseEdge, chart, basicMove };
++});
ddiff --git a/engine/cards.js b/engine/cards.js
nnew file mode 100644
iindex 0000000..3ea5b96
---- /dev/null
++++ b/engine/cards.js
@@@ -0,0 +1,63 @@
++'use strict';
++
++/**
++ * Cards, for the games that use them. A card is a plain object — { rank: 1..13, suit: 0..3 } — so it survives
++ * JSON, a Web Worker's message and a database column without a parser. Rank 1 is the ace, 11 the jack, 12 the
++ * queen, 13 the king.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeCards = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ const SUITS = ['♠', '♥', '♦', '♣'];
++ const RANKS = [null, 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
++
++ /** A fresh shoe of `decks` shuffled decks, in order (the caller shuffles). */
++ function makeShoe(decks = 6) {
++ const out = [];
++ for (let d = 0; d < decks; d += 1) {
++ for (let s = 0; s < 4; s += 1) {
++ for (let r = 1; r <= 13; r += 1) out.push({ rank: r, suit: s });
++ }
++ }
++ return out;
++ }
++
++ const label = (card) => (card && card.rank ? RANKS[card.rank] : '');
++ const suit = (card) => (card ? SUITS[card.suit] : '');
++ const isRed = (card) => Boolean(card) && (card.suit === 1 || card.suit === 2);
++ const text = (card) => `${label(card)}${suit(card)}`;
++ const isTen = (card) => Boolean(card) && card.rank >= 10;
++
++ /**
++ * The best total a hand can make: an ace is 11 unless that busts, then 1; jack, queen and king are 10.
++ * Returns { total, soft, bust }. Two aces are a soft 12, three aces a soft 13.
++ */
++ function handValue(cards) {
++ const list = Array.isArray(cards) ? cards : [];
++ let aces = 0;
++ let total = 0;
++ for (const c of list) {
++ if (!c) continue;
++ if (c.rank === 1) aces += 1;
++ else total += c.rank > 10 ? 10 : c.rank;
++ }
++ let soft = false;
++ if (aces > 0) {
++ total += 10 + aces;
++ soft = true;
++ if (total > 21) { total -= 10; soft = false; }
++ }
++ return { total, soft, bust: total > 21 };
++ }
++
++ /** A natural: an ace and a ten-value card, two cards only. */
++ function isBlackjack(cards) {
++ return Array.isArray(cards) && cards.length === 2 && handValue(cards).total === 21;
++ }
++
++ return { SUITS, RANKS, makeShoe, label, suit, isRed, text, handValue, isBlackjack, isTen };
++});
ddiff --git a/engine/craps.js b/engine/craps.js
nnew file mode 100644
iindex 0000000..68e9cc3
---- /dev/null
++++ b/engine/craps.js
@@@ -0,0 +1,121 @@
++'use strict';
++
++/**
++ * Craps with the bets people learn first: pass and don't pass (bar 12), come and don't come, free odds behind a pass or
++ * come bet (3-4-5x), place bets on 4, 5, 6, 8, 9 and 10 (9:5, 7:5, 7:6), and the one-roll field (2 and 12 pay double).
++ * exactEdges() works every edge out from the 36 ways two dice fall: pass 1.41%, don't pass 1.36%, odds 0%, place 6 and 8
++ * 1.52%, place 5 and 9 4.00%, place 4 and 10 6.67%, field 5.56%.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeCraps = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const rngLib = (typeof module === 'object' && module.exports) ? require('./rng') : root.OpenVibeRng;
++ const WAYS = [0, 0, 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1]; // ways to roll each total out of 36
++ const POINTS = [4, 5, 6, 8, 9, 10];
++ const PLACE_PAYS = { 4: 9 / 5, 10: 9 / 5, 5: 7 / 5, 9: 7 / 5, 6: 7 / 6, 8: 7 / 6 };
++ const ODDS_PAYS = { 4: 2, 10: 2, 5: 3 / 2, 9: 3 / 2, 6: 6 / 5, 8: 6 / 5 };
++ const ODDS_MAX = { 4: 3, 10: 3, 5: 4, 9: 4, 6: 5, 8: 5 }; // 3-4-5x odds
++ const p = (t) => WAYS[t] / 36;
++ // The chance of making a point before a seven.
++ const make = (pt) => WAYS[pt] / (WAYS[pt] + 6);
++
++ function exactEdges() {
++ const pass = p(7) + p(11) + POINTS.reduce((a, pt) => a + p(pt) * make(pt), 0);
++ const passEdge = 1 - 2 * pass;
++ // Don't pass: wins on 2 or 3, pushes on 12, loses on 7 or 11, then wins when the seven comes first.
++ const dpWin = p(2) + p(3) + POINTS.reduce((a, pt) => a + p(pt) * (1 - make(pt)), 0);
++ const dpLose = p(7) + p(11) + POINTS.reduce((a, pt) => a + p(pt) * make(pt), 0);
++ const dontPassEdge = dpLose - dpWin;
++ const place = {};
++ for (const pt of POINTS) place[pt] = 1 - make(pt) * (PLACE_PAYS[pt] + 1);
++ const fieldWin = [2, 3, 4, 9, 10, 11, 12].reduce((a, t) => a + p(t) * (t === 2 || t === 12 ? 2 : 1), 0);
++ const fieldLose = [5, 6, 7, 8].reduce((a, t) => a + p(t), 0);
++ const odds = {};
++ for (const pt of POINTS) odds[pt] = 1 - make(pt) * (ODDS_PAYS[pt] + 1);
++ return { pass: passEdge, dontPass: dontPassEdge, come: passEdge, dontCome: dontPassEdge, place, field: fieldLose - fieldWin, odds };
++ }
++
++ function roll(rng) { const a = 1 + rng.int(6); const b = 1 + rng.int(6); return { dice: [a, b], total: a + b }; }
++
++ /**
++ * A table: bets live between rolls. bet(type, amount, point?) places one; roll() throws the dice and settles what the
++ * roll decides. Types: pass, dontPass, odds (behind the pass line, once there is a point), place (point), field.
++ */
++ function createGame({ seed = 1, rng = null } = {}) {
++ const g = rng || rngLib.createRng(seed);
++ let point = null;
++ let bets = [];
++ function bet(type, amount, at = null) {
++ const a = Math.max(1, Math.floor(Number(amount) || 0));
++ if (type === 'pass' || type === 'dontPass') { if (point) return { ok: false, error: 'a line bet is made before the point' }; }
++ else if (type === 'odds') {
++ const line = bets.find((b) => b.type === 'pass');
++ if (!point || !line) return { ok: false, error: 'odds go behind a pass bet once there is a point' };
++ const already = bets.filter((b) => b.type === 'odds').reduce((s, b) => s + b.amount, 0);
++ if (already + a > line.amount * ODDS_MAX[point]) return { ok: false, error: `at most ${ODDS_MAX[point]}x odds on ${point}` };
++ } else if (type === 'place') { if (!POINTS.includes(Number(at))) return { ok: false, error: 'place bets go on 4, 5, 6, 8, 9 or 10' }; }
++ else if (type !== 'field') return { ok: false, error: 'not a bet here' };
++ bets.push({ type, amount: a, at: type === 'place' ? Number(at) : type === 'odds' ? point : null });
++ return { ok: true };
++ }
++ function throwDice() {
++ const r = roll(g);
++ const t = r.total;
++ const settled = []; const keep = [];
++ for (const b of bets) {
++ let net = null;
++ if (b.type === 'field') net = [2, 3, 4, 9, 10, 11, 12].includes(t) ? b.amount * (t === 2 || t === 12 ? 2 : 1) : -b.amount;
++ else if (b.type === 'place') net = t === b.at ? b.amount * PLACE_PAYS[b.at] : t === 7 ? -b.amount : null;
++ else if (b.type === 'odds') net = t === b.at ? b.amount * ODDS_PAYS[b.at] : t === 7 ? -b.amount : null;
++ else if (b.type === 'pass') {
++ if (!point) net = t === 7 || t === 11 ? b.amount : [2, 3, 12].includes(t) ? -b.amount : null;
++ else net = t === point ? b.amount : t === 7 ? -b.amount : null;
++ } else if (b.type === 'dontPass') {
++ if (!point) net = t === 2 || t === 3 ? b.amount : t === 12 ? 0 : t === 7 || t === 11 ? -b.amount : null;
++ else net = t === 7 ? b.amount : t === point ? -b.amount : null;
++ }
++ if (net == null) keep.push(b); else settled.push({ ...b, net });
++ }
++ bets = keep;
++ const before = point;
++ if (!point) { if (POINTS.includes(t)) point = t; } else if (t === point || t === 7) point = null;
++ return { ...r, pointBefore: before, point, settled, net: settled.reduce((a, b) => a + b.net, 0), open: bets.slice() };
++ }
++ return { bet, roll: throwDice, get point() { return point; }, get bets() { return bets.slice(); } };
++ }
++
++ /** Play pass line (optionally with full odds) for many decisions and measure the edge per unit of the line bet. */
++ function houseEdge({ bet = 'pass', withOdds = false, decisions = 200000, seed = 20261031, onProgress = null } = {}) {
++ const every = Math.max(100, Math.floor(decisions / 200)); // about 200 progress points a run
++ const game = createGame({ seed });
++ let sum = 0; let squares = 0; let done = 0;
++ while (done < decisions) {
++ if (bet === 'place') {
++ game.bet('place', 6, 6);
++ let net = 0;
++ for (;;) { const r = game.roll(); const s = r.settled.find((x) => x.type === 'place'); if (s) { net = s.net / 6; break; } }
++ sum += net; squares += net * net; done += 1;
++ } else {
++ game.bet(bet, 1);
++ let net = 0;
++ for (;;) {
++ const r = game.roll();
++ if (withOdds && bet === 'pass' && !r.pointBefore && r.point) game.bet('odds', ODDS_MAX[r.point], null);
++ net += r.net;
++ if (!game.bets.some((b) => b.type === bet)) break;
++ }
++ sum += net; squares += net * net; done += 1;
++ }
++ if (onProgress && done % every === 0) onProgress(done, -sum / done);
++ }
++ const mean = sum / decisions;
++ const sd = Math.sqrt(Math.max(0, squares / decisions - mean * mean));
++ return { game: 'craps', bet, withOdds, decisions, edge: -mean, percent: -mean * 100, stderr: (sd / Math.sqrt(decisions)) * 100 };
++ }
++
++ return { WAYS, POINTS, PLACE_PAYS, ODDS_PAYS, ODDS_MAX, exactEdges, roll, createGame, houseEdge };
++});
ddiff --git a/engine/games.js b/engine/games.js
nnew file mode 100644
iindex 0000000..9b2b658
---- /dev/null
++++ b/engine/games.js
@@@ -0,0 +1,86 @@
++'use strict';
++
++/**
++ * The games and what each bet costs. Every edge here is computed by the engines (exactly where the maths allows, by the
++ * blackjack simulation otherwise), never typed in, and each sits next to the published figure it is checked against
++ * (test/engines.test.js). The API (GET /api/v1/games) and every page read this one list.
++ */
++const blackjack = require('./blackjack');
++const roulette = require('./roulette');
++const baccarat = require('./baccarat');
++const craps = require('./craps');
++const slots = require('./slots');
++
++const WIZARD = 'https://wizardofodds.com/games/';
++let cached = null;
++// The blackjack strategies with no exact figure are measured once, on a fixed seed, so every page shows the same number.
++const SIM_HANDS = 400000;
++const measured = (strategy) => Math.round(blackjack.houseEdge({ strategy, hands: SIM_HANDS, seed: 20261031 }).percent * 100) / 100;
++
++function catalogue() {
++ if (cached) return cached;
++ const pct = (x) => Math.round(x * 100000) / 1000; // a fraction → percent, three decimals
++ const bac = baccarat.exactEdges(8).edges;
++ const cr = craps.exactEdges();
++ const sl = slots.exact();
++ const games = [
++ {
++ id: 'blackjack', name: 'Blackjack', line: 'Beat the dealer\'s hand without going over 21.',
++ rules: 'Six decks, the dealer stands on soft 17, double on any two cards and after a split, blackjack pays 3:2.',
++ bets: [
++ { id: 'basic', name: 'Basic strategy', edge: blackjack.PUBLISHED.percent, how: 'published, and our simulation agrees' },
++ { id: 'dealer', name: 'Playing like the dealer', edge: measured('dealer'), how: `simulation, ${SIM_HANDS.toLocaleString('en-US')} hands` },
++ { id: 'never-bust', name: 'Never busting', edge: measured('never-bust'), how: `simulation, ${SIM_HANDS.toLocaleString('en-US')} hands` },
++ ],
++ best: 'basic', source: { name: 'Wizard of Odds, blackjack', url: `${WIZARD}blackjack/` },
++ },
++ {
++ id: 'roulette', name: 'Roulette', line: 'One ball, 37 or 38 pockets, every bet the same price.',
++ rules: 'European wheel: one zero. American wheel: 0 and 00. A bet on n numbers pays 36/n − 1 to one.',
++ bets: [
++ { id: 'european', name: 'Any bet, European wheel', edge: pct(roulette.edge({ type: 'red' }, 'european')), how: 'exact' },
++ { id: 'american', name: 'Any bet, American wheel', edge: pct(roulette.edge({ type: 'red' }, 'american')), how: 'exact' },
++ { id: 'american-top', name: 'The top line (0, 00, 1, 2, 3), American', edge: pct(roulette.edge({ type: 'top' }, 'american')), how: 'exact' },
++ ],
++ best: 'european', source: { name: 'Wizard of Odds, roulette', url: `${WIZARD}roulette/` },
++ },
++ {
++ id: 'baccarat', name: 'Baccarat', line: 'Bet on the player, the banker or a tie; the cards decide everything.',
++ rules: 'Eight decks. Naturals stand, the player draws on 0 to 5, the banker follows the tableau. Banker wins pay 0.95.',
++ bets: [
++ { id: 'banker', name: 'Banker', edge: pct(bac.banker), how: 'exact' },
++ { id: 'player', name: 'Player', edge: pct(bac.player), how: 'exact' },
++ { id: 'tie', name: 'Tie (8 to 1)', edge: pct(bac.tie), how: 'exact' },
++ ],
++ best: 'banker', source: { name: 'Wizard of Odds, baccarat', url: `${WIZARD}baccarat/` },
++ },
++ {
++ id: 'craps', name: 'Craps', line: 'Two dice, one point, and the best-priced bet in the house.',
++ rules: 'Pass wins on 7 or 11 and loses on 2, 3 or 12; any other total is the point, which must come before a 7. Odds behind the line pay true odds.',
++ bets: [
++ { id: 'odds', name: 'Free odds', edge: pct(cr.odds[6]), how: 'exact' },
++ { id: 'dontPass', name: 'Don\'t pass', edge: pct(cr.dontPass), how: 'exact' },
++ { id: 'pass', name: 'Pass line', edge: pct(cr.pass), how: 'exact' },
++ { id: 'place6', name: 'Place 6 or 8', edge: pct(cr.place[6]), how: 'exact' },
++ { id: 'place5', name: 'Place 5 or 9', edge: pct(cr.place[5]), how: 'exact' },
++ { id: 'field', name: 'Field', edge: pct(cr.field), how: 'exact' },
++ { id: 'place4', name: 'Place 4 or 10', edge: pct(cr.place[4]), how: 'exact' },
++ ],
++ best: 'pass', source: { name: 'Wizard of Odds, craps', url: `${WIZARD}craps/` },
++ },
++ {
++ id: 'slots', name: 'Slots', line: 'A machine with its insides printed on the front.',
++ rules: `Three reels of 24 stops, a paytable, and every stop equally likely. Return to player ${pct(sl.rtp)}%, a win on ${Math.round(sl.hitRate * 100)}% of spins.`,
++ bets: [{ id: 'spin', name: 'One spin', edge: pct(sl.edge), how: 'exact' }],
++ best: 'spin', rtp: pct(sl.rtp), hitRate: sl.hitRate, volatility: sl.volatility,
++ source: { name: 'How slot machines work (the maths here is this machine\'s own)', url: `${WIZARD}slots/` },
++ },
++ ];
++ cached = games;
++ return games;
++}
++
++const get = (id) => catalogue().find((g) => g.id === id) || null;
++const lowest = (g) => g.bets.reduce((a, b) => (b.edge < a.edge ? b : a), g.bets[0]);
++
++module.exports = { catalogue, get, lowest, engines: { blackjack, roulette, baccarat, craps, slots } };
ddiff --git a/engine/rng.js b/engine/rng.js
nnew file mode 100644
iindex 0000000..350d2c5
---- /dev/null
++++ b/engine/rng.js
@@@ -0,0 +1,61 @@
++'use strict';
++
++/**
++ * The seeded random source every game engine uses. One generator, one seed, the same cards in the same order on
++ * the server, in the browser and in a Web Worker: mulberry32 (a 32-bit state, no platform maths, identical in
++ * Node and in a browser).
++ *
++ * A seed is a 32-bit unsigned integer. The same seed always gives the same stream, so a hand can be replayed,
++ * a bug can be found again, and a test can assert on the exact cards.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeRng = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
++ 'use strict';
++
++ /** A seed from a string, so "blackjack-42" is a stable seed too. */
++ function seedFrom(text) {
++ const s = String(text == null ? '' : text);
++ let h = 2166136261 >>> 0;
++ for (let i = 0; i < s.length; i += 1) {
++ h ^= s.charCodeAt(i);
++ h = Math.imul(h, 16777619) >>> 0;
++ }
++ return h >>> 0;
++ }
++
++ /** mulberry32: 32-bit state, uniform in [0, 1). */
++ function createRng(seed) {
++ let a = (Number(seed) >>> 0) || 0x9e3779b9;
++ const next = () => {
++ a = (a + 0x6D2B79F5) | 0;
++ let t = Math.imul(a ^ (a >>> 15), 1 | a);
++ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
++ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
++ };
++ return {
++ /** The next float in [0, 1). */
++ next,
++ /** The next integer in [0, n). */
++ int(n) { const k = Math.floor(n); return k > 0 ? Math.floor(next() * k) : 0; },
++ /** The next float in [lo, hi). */
++ range(lo, hi) { return lo + next() * (hi - lo); },
++ /** A copy of the array, shuffled (Fisher-Yates, from the top down). */
++ shuffle(list) {
++ const out = Array.isArray(list) ? list.slice() : Array.from(list);
++ for (let i = out.length - 1; i > 0; i -= 1) {
++ const j = Math.floor(next() * (i + 1));
++ const t = out[i]; out[i] = out[j]; out[j] = t;
++ }
++ return out;
++ },
++ /** The state, so a run can be continued from where it stopped. */
++ get state() { return a >>> 0; },
++ set state(v) { a = (Number(v) >>> 0) || 0x9e3779b9; },
++ };
++ }
++
++ return { createRng, seedFrom };
++});
ddiff --git a/engine/roulette.js b/engine/roulette.js
nnew file mode 100644
iindex 0000000..c8e57b8
---- /dev/null
++++ b/engine/roulette.js
@@@ -0,0 +1,94 @@
++'use strict';
++
++/**
++ * Roulette, European (one zero, 37 pockets) and American (0 and 00, 38 pockets), with every bet on the layout: straight,
++ * split, street, corner, six line, the first four or the top line, column, dozen, red, black, odd, even, low and high.
++ * A bet covering n numbers pays 36/n − 1 to one, so the house edge is exactly the share of the zeros: 1/37 = 2.70% and
++ * 2/38 = 5.26%. The American top line (0, 00, 1, 2, 3) pays 6 to 1 for five numbers and costs 7.89%.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeRoulette = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const rngLib = (typeof module === 'object' && module.exports) ? require('./rng') : root.OpenVibeRng;
++ const RED = new Set([1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36]);
++ const WHEELS = {
++ european: { name: 'European', pockets: ['0', ...range(1, 36)], order: [0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26].map(String) },
++ american: { name: 'American', pockets: ['0', '00', ...range(1, 36)], order: [0, 28, 9, 26, 30, 11, 7, 20, 32, 17, 5, 22, 34, 15, 3, 24, 36, 13, 1, '00', 27, 10, 25, 29, 12, 8, 19, 31, 18, 6, 21, 33, 16, 4, 23, 35, 14, 2].map(String) },
++ };
++ function range(a, b) { const out = []; for (let i = a; i <= b; i += 1) out.push(String(i)); return out; }
++ const colour = (p) => (p === '0' || p === '00' ? 'green' : RED.has(Number(p)) ? 'red' : 'black');
++
++ /** The numbers a bet covers, or null when the bet is not one on this wheel's layout. */
++ function covers(bet, wheel = 'european') {
++ const t = bet.type; const n = (bet.numbers || []).map(String);
++ const nums = (list) => list.map(String);
++ const valid = (p) => WHEELS[wheel].pockets.includes(p);
++ if (t === 'red' || t === 'black') return range(1, 36).filter((p) => colour(p) === t);
++ if (t === 'odd') return range(1, 36).filter((p) => Number(p) % 2 === 1);
++ if (t === 'even') return range(1, 36).filter((p) => Number(p) % 2 === 0);
++ if (t === 'low') return range(1, 18);
++ if (t === 'high') return range(19, 36);
++ if (t === 'dozen') { const d = Number(bet.which); return d >= 1 && d <= 3 ? range(d * 12 - 11, d * 12) : null; }
++ if (t === 'column') { const c = Number(bet.which); return c >= 1 && c <= 3 ? range(1, 36).filter((p) => (Number(p) - c) % 3 === 0) : null; }
++ if (t === 'top') return wheel === 'american' ? nums(['0', '00', 1, 2, 3]) : nums(['0', 1, 2, 3]);
++ const sizes = { straight: 1, split: 2, street: 3, corner: 4, line: 6 };
++ if (!sizes[t] || n.length !== sizes[t] || !n.every(valid) || new Set(n).size !== n.length) return null;
++ return n;
++ }
++
++ /** What a bet pays to one when it wins. */
++ function payout(bet, wheel = 'european') {
++ const c = covers(bet, wheel);
++ if (!c) return null;
++ if (bet.type === 'top' && wheel === 'american') return 6;
++ return 36 / c.length - 1;
++ }
++
++ /** The exact house edge of a bet, as a fraction of the stake. */
++ function edge(bet, wheel = 'european') {
++ const c = covers(bet, wheel);
++ if (!c) return null;
++ const p = c.length / WHEELS[wheel].pockets.length;
++ return 1 - p * (payout(bet, wheel) + 1);
++ }
++
++ function spin(rng, wheel = 'european') { const order = WHEELS[wheel].order; return order[rng.int(order.length)]; }
++
++ /** Settle a list of bets ({ type, numbers?, which?, amount }) on a pocket: each bet's net and the total. */
++ function settle(bets, pocket, wheel = 'european') {
++ const results = bets.map((b) => {
++ const c = covers(b, wheel);
++ if (!c) return { ...b, net: 0, error: 'not a bet on this layout' };
++ const win = c.includes(String(pocket));
++ return { ...b, win, net: win ? b.amount * payout(b, wheel) : -b.amount };
++ });
++ return { pocket: String(pocket), colour: colour(String(pocket)), results, net: results.reduce((a, r) => a + r.net, 0) };
++ }
++
++ function createGame({ wheel = 'european', seed = 1, rng = null } = {}) {
++ const g = rng || rngLib.createRng(seed);
++ return { wheel, spin: (bets) => settle(bets, spin(g, wheel), wheel) };
++ }
++
++ /** Spin the same bet many times: what the house kept, measured, next to the exact edge. */
++ function houseEdge({ wheel = 'european', bet = { type: 'red' }, spins = 200000, seed = 20261031, onProgress = null } = {}) {
++ const every = Math.max(100, Math.floor(spins / 200)); // about 200 progress points a run
++ const g = rngLib.createRng(seed);
++ const stake = { ...bet, amount: 1 };
++ let sum = 0; let squares = 0;
++ for (let i = 0; i < spins; i += 1) {
++ const net = settle([stake], spin(g, wheel), wheel).net;
++ sum += net; squares += net * net;
++ if (onProgress && (i + 1) % every === 0) onProgress(i + 1, -sum / (i + 1));
++ }
++ const mean = sum / spins;
++ const sd = Math.sqrt(Math.max(0, squares / spins - mean * mean));
++ return { game: 'roulette', wheel, bet: bet.type, spins, edge: -mean, percent: -mean * 100, stderr: (sd / Math.sqrt(spins)) * 100, exact: edge(stake, wheel) * 100 };
++ }
++
++ return { WHEELS, RED, colour, covers, payout, edge, spin, settle, createGame, houseEdge };
++});
ddiff --git a/engine/slots.js b/engine/slots.js
nnew file mode 100644
iindex 0000000..595c4b1
---- /dev/null
++++ b/engine/slots.js
@@@ -0,0 +1,92 @@
++'use strict';
++
++/**
++ * A slot machine you can see inside: three reels with their strips printed, a paytable, and the maths a real machine
++ * hides. Every reel stop is equally likely, so the return to player (RTP), the hit rate and the volatility are worked
++ * out exactly by walking all stops (24 × 24 × 24 = 13,824 spins), and spinning many times only shows the same numbers
++ * arriving slowly. The house edge is 1 − RTP.
++ */
++(function (root, factory) {
++ const api = factory(root);
++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
++ root.OpenVibeSlots = api;
++})(typeof globalThis !== 'undefined' ? globalThis : this, function (root) {
++ 'use strict';
++
++ const rngLib = (typeof module === 'object' && module.exports) ? require('./rng') : root.OpenVibeRng;
++
++ // Symbols, rarest first. Each reel strip has 24 stops.
++ const SYMBOLS = ['seven', 'bell', 'star', 'plum', 'lemon', 'cherry', 'blank'];
++ const COUNTS = [
++ { seven: 1, bell: 2, star: 3, plum: 4, lemon: 4, cherry: 2, blank: 8 },
++ { seven: 1, bell: 2, star: 3, plum: 4, lemon: 4, cherry: 2, blank: 8 },
++ { seven: 1, bell: 2, star: 3, plum: 4, lemon: 4, cherry: 2, blank: 8 },
++ ];
++ // Three of a kind pays the first column; one or two cherries anywhere pay the cherry rows.
++ const PAYTABLE = { seven: 800, bell: 120, star: 45, plum: 18, lemon: 18, cherryThree: 34, cherryTwo: 6, cherryOne: 2 };
++
++ /** The strips: each reel's symbols spread out so the same symbol rarely sits next to itself. */
++ function strips() {
++ return COUNTS.map((c, reel) => {
++ const out = [];
++ const left = { ...c };
++ const order = SYMBOLS.slice();
++ while (out.length < 24) {
++ for (const s of order) if (left[s] > 0 && out.length < 24) { out.push(s); left[s] -= 1; }
++ order.push(order.shift());
++ }
++ for (let i = 0; i < reel; i += 1) out.push(out.shift()); // each reel starts at a different place
++ return out;
++ });
++ }
++ const STRIPS = strips();
++
++ function pays(line) {
++ const [a, b, c] = line;
++ if (a === b && b === c) return a === 'cherry' ? PAYTABLE.cherryThree : a === 'blank' ? 0 : PAYTABLE[a];
++ const cherries = line.filter((s) => s === 'cherry').length;
++ return cherries === 2 ? PAYTABLE.cherryTwo : cherries === 1 ? PAYTABLE.cherryOne : 0;
++ }
++
++ /** The exact maths: RTP, hit rate, the share of what comes back from each win, and the volatility (one spin's SD). */
++ function exact() {
++ const n = STRIPS[0].length * STRIPS[1].length * STRIPS[2].length;
++ let sum = 0; let squares = 0; let hits = 0;
++ const byWin = {};
++ for (const a of STRIPS[0]) for (const b of STRIPS[1]) for (const c of STRIPS[2]) {
++ const w = pays([a, b, c]);
++ sum += w; squares += w * w;
++ if (w > 0) { hits += 1; byWin[w] = (byWin[w] || 0) + 1; }
++ }
++ const rtp = sum / n;
++ return { spins: n, rtp, edge: 1 - rtp, hitRate: hits / n, volatility: Math.sqrt(squares / n - rtp * rtp), wins: Object.entries(byWin).map(([pay, count]) => ({ pay: Number(pay), chance: count / n })).sort((x, y) => y.pay - x.pay) };
++ }
++
++ function createGame({ seed = 1, rng = null } = {}) {
++ const g = rng || rngLib.createRng(seed);
++ return {
++ spin(bet = 1) {
++ const stops = STRIPS.map((s) => g.int(s.length));
++ const line = stops.map((i, reel) => STRIPS[reel][i]);
++ const win = pays(line) * bet;
++ return { stops, line, win, net: win - bet };
++ },
++ };
++ }
++
++ function houseEdge({ spins = 200000, seed = 20261031, onProgress = null } = {}) {
++ const every = Math.max(100, Math.floor(spins / 200)); // about 200 progress points a run
++ const game = createGame({ seed });
++ let sum = 0; let squares = 0;
++ for (let i = 0; i < spins; i += 1) {
++ const n = game.spin(1).net;
++ sum += n; squares += n * n;
++ if (onProgress && (i + 1) % every === 0) onProgress(i + 1, -sum / (i + 1));
++ }
++ const mean = sum / spins;
++ const sd = Math.sqrt(Math.max(0, squares / spins - mean * mean));
++ return { game: 'slots', spins, edge: -mean, percent: -mean * 100, stderr: (sd / Math.sqrt(spins)) * 100 };
++ }
++
++ return { SYMBOLS, COUNTS, PAYTABLE, STRIPS, pays, exact, createGame, houseEdge };
++});
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..0461f74
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,56 @@
++-- phase: expand
++-- OpenVibe.Casino: learn it, play it, never pay it. Nothing here is money or a person: play chips live in the player's
++-- browser, and a shared simulation is a seeded run of a game's engine with its result, with no name on it.
++
++-- A simulation someone shared (POST /api/v1/simulate with share): the game, the strategy and the seed are enough to
++-- run it again; the result is kept so the link opens at once. sim_ + 20 random base32 characters.
++CREATE TABLE casino_sims (
++ id text COLLATE "C" PRIMARY KEY,
++ game text COLLATE "C" NOT NULL,
++ params jsonb NOT NULL,
++ result jsonb NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT casino_sims_id CHECK (id ~ '^sim_[a-z2-7]{20}$'),
++ CONSTRAINT casino_sims_game CHECK (game IN ('blackjack', 'roulette', 'baccarat', 'craps', 'slots'))
++);
++CREATE INDEX casino_sims_created ON casino_sims (created_at);
++
++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes
++-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
++CREATE TABLE account_data_events (
++ id text PRIMARY KEY,
++ kind text NOT NULL,
++ subject text NOT NULL,
++ outcome jsonb,
++ applied_at timestamptz NOT NULL DEFAULT now(),
++ sent_at timestamptz
++);
++
++-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.
++CREATE TABLE telemetry_samples (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ at timestamptz NOT NULL,
++ service text NOT NULL,
++ project text,
++ subject text,
++ resource text,
++ provider text,
++ node text,
++ cell text,
++ region text,
++ operation text NOT NULL,
++ latency_ms double precision,
++ queue_delay_ms double precision,
++ ttfb_ms double precision,
++ throughput_per_second double precision,
++ bytes bigint,
++ status text,
++ cache_status text,
++ cost_estimate double precision,
++ route_epoch bigint,
++ trace_id text,
++ extra jsonb NOT NULL DEFAULT '{}'::jsonb
++);
++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
++
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..7f3e63a
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1693 @@
++{
++ "name": "openvibe-casino",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-casino",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.264.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@acemir/cssom": {
++ "version": "0.9.32",
++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
++ "license": "MIT"
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.14",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.14.tgz",
++ "integrity": "sha512-aAQe7iArcFB2w1xtx91l3l9trxhMzD6aZf+WnosSHNhwgp70EJz80Igw/bHnW14fUEFYWPBoQ1sGom8Xc0Jttg==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.9",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.9",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/linkedom": {
++ "version": "0.18.14",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
++ "license": "ISC",
++ "dependencies": {
++ "@acemir/cssom": "^0.9.32",
++ "css-select": "^7.0.0",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.2",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.264.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.264.0",
++ "integrity": "sha512-40u95anBLMZBSIklHmfINb5yIRaXuHLxlEa4P6mQ+X1S5fdZ01SQIB/QkE2RSkUF1GZgbJtNp/pO3BFfZARImg==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.74.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
++ "license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.67.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
++ "integrity": "sha512-Aiyso8FDWXcTBEUrybhCwwKt6AXmNuB03ZsGjGBtrD0Lt8RtX9c+qz1StWkOHo5nulHg/TovVwU9Bhi+/qFecA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
++ },
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/pg": {
++ "version": "8.23.1",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.1",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.1",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.1"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.1",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..d52b259
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,36 @@
++{
++ "name": "openvibe-casino",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Casino \u2014 learn it, play it, never pay it: casino games with their odds showing, play chips worth nothing.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "test:pg": "CASINO_TEST_STORE=pg node test/run.js"
++ },
++ "keywords": [
++ "openvibe"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.264.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.67.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/public/css/casino.css b/public/css/casino.css
nnew file mode 100644
iindex 0000000..a27e3a2
---- /dev/null
++++ b/public/css/casino.css
@@@ -0,0 +1,246 @@
++/* OpenVibe.Casino — the room's look. Colours come from the theme (the kit's --ovk-* tokens) and the room's accent
++ * (rooms.js 'casino', a calm green that passes AA in both modes); the felt is felt in every theme. Motion uses the
++ * network's tokens, so Calm stills it and reduced motion shows the final state. */
++
++.cs { font-family: var(--ovk-font); color: var(--ovk-text); }
++.skip { position: absolute; left: -9999px; top: 8px; z-index: 100; padding: 8px 12px; border-radius: 8px; background: var(--ovk-panel); color: var(--ovk-text); }
++.skip:focus { left: 8px; }
++.cs-wrap { width: min(1180px, 100% - 32px); margin-inline: auto; }
++.cs-page { padding-block: 28px 56px; }
++.cs-narrow { width: min(820px, 100% - 32px); }
++.cs-page-h { display: grid; gap: 8px; margin-bottom: 24px; }
++.cs-page-h h1 { margin: 0; font-size: clamp(1.8rem, 4.5vw, 2.6rem); line-height: 1.1; text-wrap: balance; }
++.cs-page-h p { margin: 0; color: var(--ovk-muted); max-width: 70ch; line-height: 1.55; }
++.cs-kicker { margin: 0; font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--room-strong, var(--ovk-accent)) !important; }
++.cs-acts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
++.cs-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 44px; padding: 0 18px; border: 0; border-radius: 999px;
++ background: var(--room-fill, var(--ovk-fill)); color: var(--room-on, var(--ovk-on-fill)); font: 700 15px/1 var(--ovk-font); text-decoration: none; cursor: pointer;
++ transition: transform var(--ov-dur-1, .12s) var(--ov-ease-out, ease-out), filter var(--ov-dur-1, .12s) var(--ov-ease-out, ease-out); }
++.cs-btn:hover { filter: brightness(1.08); }
++.cs-btn:active { transform: scale(.97); }
++.cs-btn:focus-visible, .cs-cell:focus-visible, .cs-spot:focus-visible, .cs-chip:focus-visible { outline: 3px solid color-mix(in srgb, var(--room-accent) 60%, transparent); outline-offset: 2px; }
++.cs-btn.is-quiet { background: transparent; color: var(--ovk-text); box-shadow: inset 0 0 0 1.5px var(--ovk-line); }
++.cs-btn.is-small { min-height: 36px; min-width: 36px; padding: 0 12px; }
++.cs-btn.is-hint { box-shadow: 0 0 0 3px #ffd27a; }
++.cs-btn:disabled { opacity: .55; cursor: default; }
++.cs-muted { color: var(--ovk-muted); margin: 0; }
++.cs-fine { color: var(--ovk-muted); font-size: 13px; line-height: 1.5; }
++.cs-fine a, .cs-prose a, .cs-safe-line a, .cs-edge-now a { color: var(--room-strong, var(--ovk-accent)); }
++.cs-noscript { padding: 16px; border-radius: 14px; background: rgba(0, 0, 0, .25); color: #f1f5f2; }
++
++/* ── The motif: a fan of cards, chips, and the curve drawing beneath ── */
++.cs-motif { position: relative; width: min(100%, 380px); aspect-ratio: 5 / 4; margin: 0 auto; color: var(--room-accent); }
++.cs-fan { position: absolute; left: 50%; top: 6%; width: 0; height: 60%; }
++.cs-mc { position: absolute; left: -46px; top: 0; width: 92px; height: 128px; border-radius: 12px; background: #fdfdf8; color: #121212; box-shadow: 0 14px 30px -14px rgba(0, 0, 0, .6);
++ display: grid; place-items: start; padding: 10px 12px; transform-origin: 50% 160%; animation: csFan var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both; }
++.cs-mc b { font: 800 22px/1 Georgia, serif; }
++.cs-mc i { font-style: normal; font-size: 30px; line-height: 1; margin-top: 4px; }
++.cs-mc.is-red { color: #c0262d; }
++.cs-mc-1 { --r: -21deg; } .cs-mc-2 { --r: -7deg; animation-delay: var(--ov-dur-1, .12s); } .cs-mc-3 { --r: 7deg; animation-delay: calc(var(--ov-dur-1, .12s) * 2); } .cs-mc-4 { --r: 21deg; animation-delay: calc(var(--ov-dur-1, .12s) * 3); }
++.cs-mc { transform: rotate(var(--r)); }
++@keyframes csFan { from { transform: rotate(0deg); opacity: 0; } }
++.cs-chips { position: absolute; right: 8%; bottom: 26%; display: grid; }
++.cs-chips span { width: 64px; height: 14px; margin-top: -4px; border-radius: 50%; background: radial-gradient(closest-side, transparent 58%, rgba(255, 255, 255, .85) 60% 66%, transparent 68%), var(--room-accent); box-shadow: 0 3px 0 color-mix(in srgb, var(--room-accent) 55%, #000); }
++.cs-chips span:nth-child(2) { background-color: #c2410c; box-shadow: 0 3px 0 #7c2d12; }
++.cs-chips span:nth-child(3) { background-color: #1f2937; box-shadow: 0 3px 0 #000; }
++.cs-curve { position: absolute; left: 4%; right: 4%; bottom: 0; width: 92%; height: 22%; }
++.cs-curve path { stroke-dasharray: 100; animation: csDraw calc(var(--ov-dur-4, .52s) * 3) var(--ov-ease-in-out, ease-in-out) both; animation-delay: var(--ov-dur-4, .52s); }
++@keyframes csDraw { from { stroke-dashoffset: 100; } to { stroke-dashoffset: 0; } }
++
++/* ── What each bet costs: the ladder ── */
++.cs-section { margin-block: 32px; }
++.cs-head { display: grid; gap: 6px; margin-bottom: 16px; }
++.cs-head h2 { margin: 0; font-size: clamp(1.4rem, 3vw, 1.8rem); }
++.cs-head p { margin: 0; color: var(--ovk-muted); max-width: 70ch; }
++.cs-ladder { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
++.cs-ladder a { display: grid; grid-template-columns: 96px minmax(0, 220px) minmax(0, 1fr) 72px; align-items: center; gap: 12px; padding: 7px 10px; border-radius: 10px; color: var(--ovk-text); text-decoration: none; }
++.cs-ladder a:hover { background: var(--ovk-panel); }
++.cs-l-game { font-size: 13px; font-weight: 700; color: var(--tone); }
++.cs-l-bet { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.cs-l-bar { height: 10px; border-radius: 5px; background: var(--ovk-panel-2); overflow: hidden; }
++.cs-l-bar i { display: block; height: 100%; width: var(--w); border-radius: inherit; background: var(--tone); transform-origin: 0 50%; animation: csGrow var(--ov-dur-4, .52s) var(--ov-ease-out, ease-out) both; }
++.cs-l-edge { text-align: right; font-variant-numeric: tabular-nums; }
++.cs-l-g2 { display: none; }
++@keyframes csGrow { from { transform: scaleX(0); } }
++@media (max-width: 640px) {
++ .cs-ladder a { grid-template-columns: minmax(0, 1fr) 64px; grid-template-areas: "bet edge" "bar bar"; gap: 4px 10px; }
++ .cs-l-game { display: none; }
++ .cs-l-bet { grid-area: bet; white-space: normal; }
++ .cs-l-g2 { display: inline; color: var(--tone); font-weight: 700; }
++ .cs-l-bar { grid-area: bar; height: 8px; }
++ .cs-l-edge { grid-area: edge; }
++}
++
++/* ── The scenes' pictures ── */
++.cs-stack { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 14px 0; }
++.cs-stack span { display: grid; place-items: center; width: 76px; height: 76px; margin-left: -14px; border-radius: 50%; font: 800 16px/1 var(--ovk-font); color: #fff;
++ background: radial-gradient(closest-side, var(--c) 62%, #fff 63% 70%, var(--c) 71%); box-shadow: 0 6px 14px -6px rgba(0, 0, 0, .6);
++ transform: rotate(var(--t)); animation: csDeal var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; }
++.cs-stack span.c1 { --c: #b91c1c; --t: -10deg; margin-left: 0; } .cs-stack span.c2 { --c: #15803d; --t: -3deg; animation-delay: var(--ov-dur-1, .12s); }
++.cs-stack span.c3 { --c: #1f2937; --t: 4deg; animation-delay: calc(var(--ov-dur-1, .12s) * 2); } .cs-stack span.c4 { --c: #6d28d9; --t: 10deg; animation-delay: calc(var(--ov-dur-1, .12s) * 3); }
++.cs-stack b { flex-basis: 100%; text-align: center; font-size: 14px; color: var(--ovk-muted); }
++.cs-bank { margin: 0; display: grid; gap: 8px; width: min(420px, 100%); justify-self: center; }
++.cs-bank svg { width: 100%; height: auto; aspect-ratio: 32 / 15; border-radius: 12px; background: var(--ovk-panel-2); }
++.cs-bank-start { stroke: var(--ovk-muted); stroke-dasharray: 4 4; stroke-width: 1; }
++.cs-bank-p { fill: none; stroke-width: 1.8; stroke-dasharray: 100; animation: csDraw calc(var(--ov-dur-4, .52s) * 3) var(--ov-ease-out, ease-out) both; }
++.cs-bank-p0 { stroke: var(--room-accent); } .cs-bank-p1 { stroke: #f59e0b; animation-delay: var(--ov-dur-1, .12s); } .cs-bank-p2 { stroke: #60a5fa; animation-delay: calc(var(--ov-dur-1, .12s) * 2); } .cs-bank-p3 { stroke: #f472b6; animation-delay: calc(var(--ov-dur-1, .12s) * 3); } .cs-bank-p4 { stroke: #a78bfa; animation-delay: calc(var(--ov-dur-1, .12s) * 4); }
++.cs-bank figcaption { font-size: 13px; color: var(--ovk-muted); }
++.cs-chart-wrap { overflow-x: auto; max-width: 100%; }
++.cs-chart { border-collapse: separate; border-spacing: 2px; font: 700 13px/1 var(--ovk-font); font-variant-numeric: tabular-nums; }
++.cs-chart caption { text-align: left; font-weight: 700; padding-bottom: 8px; color: var(--ovk-text); }
++.cs-chart th { padding: 6px 7px; color: var(--ovk-muted); font-weight: 700; }
++.cs-chart td { min-width: 30px; height: 28px; padding: 0 4px; text-align: center; border-radius: 6px; background: var(--ovk-panel-2); color: var(--ovk-text); }
++.cs-chart td.is-H { background: color-mix(in srgb, #ef4444 26%, var(--ovk-panel)); }
++.cs-chart td.is-S { background: color-mix(in srgb, #22c55e 26%, var(--ovk-panel)); }
++.cs-chart td.is-D { background: color-mix(in srgb, #f59e0b 34%, var(--ovk-panel)); }
++.cs-chart td.is-P { background: color-mix(in srgb, #6366f1 34%, var(--ovk-panel)); }
++
++/* ── Game cards (Play, Learn) ── */
++.cs-games { list-style: none; margin: 0 0 24px; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: 12px; }
++.cs-game a { display: grid; gap: 6px; height: 100%; padding: 18px; border-radius: 18px; border: 1px solid var(--ovk-line); text-decoration: none; color: var(--ovk-text);
++ background: linear-gradient(160deg, color-mix(in srgb, var(--tone) 22%, var(--ovk-panel)), var(--ovk-panel) 62%);
++ transition: transform var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out), border-color var(--ov-dur-2, .2s) var(--ov-ease-out, ease-out); }
++.cs-game a:hover { transform: translateY(-2px); border-color: var(--tone); }
++.cs-game-name { font-size: 1.2rem; font-weight: 800; }
++.cs-game-line { color: var(--ovk-muted); font-size: 14px; line-height: 1.45; }
++.cs-game-edge { margin-top: auto; font-size: 13px; color: var(--ovk-muted); }
++.cs-game-edge b { color: var(--ovk-text); }
++
++/* ── The tables ── */
++.cs-table-h { grid-template-columns: minmax(0, 1fr) auto; align-items: end; }
++.cs-wallet { display: grid; justify-items: end; padding: 10px 16px; border-radius: 16px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
++.cs-wallet span, .cs-wallet small { color: var(--ovk-muted); font-size: 12px; }
++.cs-wallet b { font-size: 1.6rem; font-variant-numeric: tabular-nums; }
++.cs-felt { position: relative; min-height: 380px; padding: 22px; border-radius: 28px; color: #eef6f0;
++ background: radial-gradient(120% 90% at 50% 0%, #2c7a4b, #14462b 70%, #0d321f); box-shadow: inset 0 0 0 10px #3b2416, inset 0 0 0 12px #6b4423, 0 30px 60px -36px rgba(0, 0, 0, .8); }
++.cs-felt .cs-muted, .cs-felt .cs-label { color: #b9d8c4; }
++.cs-felt .cs-btn.is-quiet { color: #eef6f0; box-shadow: inset 0 0 0 1.5px rgba(238, 246, 240, .35); }
++.cs-label { margin: 0 0 6px; font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
++.cs-say { min-height: 1.6em; margin: 14px 0; font-size: 1.05rem; font-weight: 600; }
++.cs-say.is-good { color: #b6f5c8; } .cs-say.is-bad { color: #ffd0c8; }
++.cs-controls, .cs-betline, .cs-denoms, .cs-seg { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
++.cs-betline { margin-top: 12px; }
++.cs-chip { width: 44px; height: 44px; border: 0; border-radius: 50%; color: #fff; font: 800 13px/1 var(--ovk-font); cursor: pointer;
++ background: radial-gradient(closest-side, var(--c) 60%, #fff 61% 68%, var(--c) 69%); box-shadow: 0 4px 10px -4px rgba(0, 0, 0, .6); opacity: .75; }
++.cs-chip.c5 { --c: #b91c1c; } .cs-chip.c25 { --c: #15803d; } .cs-chip.c100 { --c: #1f2937; }
++.cs-chip[aria-checked="true"] { opacity: 1; transform: translateY(-3px); box-shadow: 0 0 0 3px #ffd27a, 0 6px 12px -4px rgba(0, 0, 0, .6); }
++.cs-seg button { min-height: 36px; padding: 0 12px; border-radius: 999px; border: 1.5px solid rgba(238, 246, 240, .35); background: transparent; color: inherit; font: 600 14px/1 var(--ovk-font); cursor: pointer; }
++.cs-seg button[aria-checked="true"] { background: #eef6f0; color: #0d321f; border-color: #eef6f0; }
++.cs-hint-toggle { display: inline-flex; align-items: center; gap: 8px; margin-top: 12px; font-size: 14px; color: #b9d8c4; }
++
++/* Cards */
++.cs-hand, .cs-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-height: 96px; }
++.cs-hands { display: flex; flex-wrap: wrap; gap: 18px; }
++.cs-hands .cs-hand { padding: 6px; border-radius: 14px; }
++.cs-hand.is-now { box-shadow: 0 0 0 2px #ffd27a; }
++.cs-hand.is-win { box-shadow: 0 0 0 2px #b6f5c8; border-radius: 14px; padding: 6px; }
++.cs-card { position: relative; display: grid; align-content: start; width: 62px; height: 88px; padding: 6px 8px; border-radius: 9px; background: #fdfdf8; color: #121212;
++ box-shadow: 0 8px 16px -8px rgba(0, 0, 0, .7); animation: csDeal var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; }
++.cs-card b { font: 800 18px/1 Georgia, serif; }
++.cs-card i { font-style: normal; font-size: 22px; line-height: 1.1; }
++.cs-card.is-red { color: #c0262d; }
++.cs-card.is-back { background: repeating-linear-gradient(45deg, #7f1d1d 0 6px, #991b1b 6px 12px); box-shadow: inset 0 0 0 4px #fdfdf8, 0 8px 16px -8px rgba(0, 0, 0, .7); }
++@keyframes csDeal { from { opacity: 0; transform: translateY(-16px) rotate(-4deg); } }
++.cs-total { margin-left: 6px; font-weight: 700; color: #eef6f0; }
++
++/* Roulette */
++.cs-board { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "zero grid cols" ". outs ."; gap: 4px; margin-top: 14px; overflow-x: auto; }
++.cs-zeros { grid-area: zero; display: grid; gap: 4px; }
++.cs-grid { grid-area: grid; display: grid; grid-template-columns: repeat(12, minmax(28px, 1fr)); gap: 4px; }
++.cs-cols { grid-area: cols; display: grid; gap: 4px; }
++.cs-outs { grid-area: outs; display: grid; grid-template-columns: repeat(auto-fit, minmax(76px, 1fr)); gap: 4px; }
++.cs-cell { position: relative; min-height: 40px; padding: 0 6px; border: 1px solid rgba(238, 246, 240, .3); border-radius: 6px; background: transparent; color: #eef6f0; font: 700 14px/1 var(--ovk-font); cursor: pointer; }
++.cs-cell.is-red { background: #b91c1c; } .cs-cell.is-black { background: #111827; } .cs-cell.is-green { background: #15803d; min-height: 64px; }
++.cs-cell.has-bet::after { content: attr(data-chips); position: absolute; right: -4px; top: -6px; min-width: 22px; padding: 3px 5px; border-radius: 999px; background: #ffd27a; color: #3b2416; font-size: 11px; }
++.cs-strip { min-height: 64px; display: flex; justify-content: center; }
++.cs-pocket { display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; font: 800 24px/1 var(--ovk-font); color: #fff; box-shadow: 0 0 0 4px #eef6f0;
++ animation: csLand var(--ov-dur-4, .52s) var(--ov-ease-spring, cubic-bezier(.34, 1.56, .64, 1)) both; }
++.cs-pocket.is-red { background: #b91c1c; } .cs-pocket.is-black { background: #111827; } .cs-pocket.is-green { background: #15803d; }
++@keyframes csLand { from { transform: scale(.4); opacity: 0; } }
++@media (max-width: 640px) { .cs-board { grid-template-columns: 1fr; grid-template-areas: "zero" "grid" "cols" "outs"; } .cs-zeros, .cs-cols { grid-auto-flow: column; } .cs-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } .cs-cell.is-green { min-height: 40px; } }
++
++/* Baccarat and craps spots */
++.cs-bac-hands { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; min-height: 120px; }
++.cs-spots { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 8px; margin-top: 12px; }
++.cs-spot { display: grid; gap: 3px; justify-items: start; min-height: 76px; padding: 12px 14px; border: 1.5px dashed rgba(238, 246, 240, .4); border-radius: 14px; background: rgba(0, 0, 0, .12); color: #eef6f0; text-align: left; cursor: pointer; }
++.cs-spot b { font-size: 1.05rem; }
++.cs-spot span, .cs-spot small { color: #b9d8c4; font-size: 13px; }
++.cs-spot em { font-style: normal; font-weight: 800; color: #ffd27a; }
++.cs-spot.has-bet { border-style: solid; border-color: #ffd27a; }
++.cs-cr-top { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; min-height: 76px; }
++.cs-dice { display: flex; gap: 10px; }
++.cs-die { display: grid; grid-template-columns: repeat(3, 10px); grid-template-rows: repeat(3, 10px); gap: 4px; padding: 9px; border-radius: 12px; background: #fdfdf8; box-shadow: 0 8px 16px -8px rgba(0, 0, 0, .7);
++ animation: csDeal var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; }
++.cs-die i { border-radius: 50%; }
++.cs-die i.on { background: #121212; }
++.cs-puck { margin: 0; padding: 8px 14px; border-radius: 999px; background: #111827; color: #eef6f0; font-weight: 700; }
++.cs-puck.is-on { background: #fdfdf8; color: #111827; }
++
++/* Slots */
++.cs-reels-live { display: grid; grid-template-columns: repeat(3, minmax(0, 120px)); justify-content: center; gap: 10px; padding: 14px; border-radius: 18px; background: #0b2416; box-shadow: inset 0 0 0 3px #3b2416; }
++.cs-reel-win { display: grid; place-items: center; height: 120px; border-radius: 12px; background: linear-gradient(#e7e5dc, #fdfdf8 35%, #fdfdf8 65%, #e7e5dc); }
++.cs-reels-live.is-new .cs-reel-win { animation: csDeal var(--ov-dur-3, .32s) var(--ov-ease-out, ease-out) both; }
++.cs-reels-live.is-new .cs-reel-win:nth-child(2) { animation-delay: var(--ov-dur-1, .12s); }
++.cs-reels-live.is-new .cs-reel-win:nth-child(3) { animation-delay: calc(var(--ov-dur-1, .12s) * 2); }
++.cs-sym { font: 900 18px/1 var(--ovk-font); letter-spacing: .02em; color: #3b2416; }
++.cs-sym.sym-seven { font: 900 64px/1 Georgia, serif; color: #b91c1c; }
++.cs-sym.sym-star { font-size: 54px; color: #d97706; }
++.cs-sym.sym-cherry { color: #b91c1c; } .cs-sym.sym-lemon { color: #a16207; } .cs-sym.sym-plum { color: #6d28d9; } .cs-sym.sym-bell { color: #b45309; }
++.cs-reels { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 18px; }
++.cs-reel ol { list-style: none; margin: 6px 0 0; padding: 0; display: grid; gap: 2px; font-size: 12px; }
++.cs-reel li { padding: 3px 8px; border-radius: 6px; background: var(--ovk-panel-2); }
++.cs-reel li.sym-seven { background: color-mix(in srgb, #b91c1c 30%, var(--ovk-panel)); font-weight: 800; }
++
++/* Beside the table */
++.cs-table-side { display: grid; gap: 8px; margin-top: 16px; }
++.cs-edge-now { margin: 0; color: var(--ovk-muted); }
++.cs-session { margin: 0; padding: 10px 14px; border-radius: 12px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
++.cs-gate { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 16px; background: rgba(5, 12, 8, .72); }
++.cs-gate[hidden] { display: none; }
++.cs-gate-box { width: min(460px, 100%); display: grid; gap: 12px; padding: 24px; border-radius: 22px; background: var(--ovk-panel); color: var(--ovk-text); border: 1px solid var(--ovk-line); }
++.cs-gate-box h2 { margin: 0; }
++.cs-gate-box p { margin: 0; color: var(--ovk-muted); line-height: 1.55; }
++.cs-gate-go { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 6px; }
++
++/* Learn, Simulate, Play safe */
++.cs-prose { display: grid; gap: 12px; max-width: 860px; line-height: 1.6; }
++.cs-prose h2 { margin: 18px 0 0; }
++.cs-prose p { margin: 0; }
++.cs-bets { border-collapse: collapse; min-width: 100%; font-variant-numeric: tabular-nums; }
++.cs-bets caption { text-align: left; font-weight: 700; padding-bottom: 8px; }
++.cs-bets th, .cs-bets td { padding: 9px 10px; border-bottom: 1px solid var(--ovk-line); text-align: left; }
++.cs-bets thead th { color: var(--ovk-muted); font-size: 13px; }
++.cs-dice-odds { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); align-items: end; gap: 6px; height: 180px; }
++.cs-dice-odds span { display: grid; grid-template-rows: 1fr auto auto; height: 100%; text-align: center; font-variant-numeric: tabular-nums; }
++.cs-dice-odds i { align-self: end; height: calc(var(--h) / 6 * 100%); border-radius: 6px 6px 0 0; background: var(--tone, var(--room-accent)); }
++.cs-dice-odds b { font-size: 14px; }
++.cs-dice-odds small { font-size: 11px; color: var(--ovk-muted); }
++.cs-sim-form { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 16px; border-radius: 18px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
++.cs-sim-form label { display: grid; gap: 4px; font-size: 13px; font-weight: 700; color: var(--ovk-muted); }
++.cs-sim-form select, .cs-sim-form input { min-height: 40px; min-width: 0; padding: 0 10px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: 500 15px/1 var(--ovk-font); }
++.cs-sim-form input { width: 130px; }
++.cs-sim-out { display: grid; gap: 12px; margin-top: 18px; }
++.cs-progress { height: 6px; border-radius: 3px; background: var(--ovk-panel-2); overflow: hidden; }
++.cs-progress i { display: block; height: 100%; width: 0; background: var(--room-accent); }
++.cs-sim-chart { width: 100%; height: auto; aspect-ratio: 720 / 260; border-radius: 16px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
++.cs-sim-result { margin: 0; font-size: 1.1rem; font-weight: 600; line-height: 1.5; }
++.cs-sim-result a { color: var(--room-strong); word-break: break-all; }
++.cs-help { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.cs-help li { display: grid; gap: 2px; padding: 12px 14px; border-radius: 14px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
++.cs-help a { font-weight: 800; color: var(--room-strong); }
++.cs-help span { color: var(--ovk-muted); font-size: 14px; }
++.cs-safe-line { display: flex; align-items: center; gap: 10px; margin: 28px 0 0; padding: 12px 16px; border-radius: 14px; background: var(--ovk-panel); color: var(--ovk-muted); font-size: 14px; line-height: 1.45; }
++.cs-safe-line .ovk-i { flex: none; width: 18px; height: 18px; color: var(--room-accent); }
++.cs-home .cs-safe-line { margin-bottom: 28px; }
++
++@media (max-width: 640px) {
++ .cs-table-h { grid-template-columns: 1fr; }
++ .cs-wallet { justify-items: start; }
++ .cs-felt { padding: 16px; border-radius: 20px; }
++ .cs-card { width: 50px; height: 72px; }
++ .cs-dice-odds { height: 140px; gap: 3px; }
++}
++@media (prefers-reduced-motion: reduce) {
++ .cs-mc, .cs-stack span, .cs-curve path, .cs-l-bar i, .cs-bank-p, .cs-card, .cs-pocket, .cs-die, .cs-reels-live.is-new .cs-reel-win { animation: none; }
++ .cs-btn, .cs-game a { transition: none; }
++}
ddiff --git a/public/js/casino.js b/public/js/casino.js
nnew file mode 100644
iindex 0000000..4677f20
---- /dev/null
++++ b/public/js/casino.js
@@@ -0,0 +1,75 @@
++/* OpenVibe.Casino, on every page: the play chips (a fresh 1,000 each day, kept in this browser, worth nothing), the
++ * 18+ gate in front of the tables, and the reminder of how long a session has run. Everything here is local: no chip
++ * count, age answer or session time leaves the browser. */
++(function () {
++ 'use strict';
++ var DAILY = 1000;
++ var KEY = 'cs_chips';
++ var store = {
++ get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
++ set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* private mode: this page only */ } },
++ };
++ var today = function () { var d = new Date(); return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate(); };
++ var memory = null;
++
++ function read() {
++ var v = null;
++ try { v = JSON.parse(store.get(KEY) || 'null'); } catch (e) { v = null; }
++ if (!v && memory) v = memory;
++ if (!v || v.day !== today() || typeof v.chips !== 'number' || !isFinite(v.chips)) v = { day: today(), chips: DAILY, best: DAILY };
++ return v;
++ }
++ function write(v) { memory = v; store.set(KEY, JSON.stringify(v)); paint(); }
++ function fmt(n) { return Math.round(n).toLocaleString('en-US'); }
++ function paint() {
++ var v = read();
++ document.querySelectorAll('[data-cs-chips]').forEach(function (el) { el.textContent = fmt(v.chips); });
++ document.querySelectorAll('[data-cs-reset]').forEach(function (el) { el.textContent = v.chips < 1 ? 'Out for today: a fresh 1,000 at midnight' : 'A fresh 1,000 each day'; });
++ }
++
++ /** The chips: get(), change(net) after a settled bet, and can(n) before one. */
++ window.CasinoChips = {
++ get: function () { return read().chips; },
++ can: function (n) { return read().chips >= n; },
++ change: function (net) {
++ var v = read();
++ v.chips = Math.max(0, Math.round((v.chips + net) * 100) / 100);
++ v.best = Math.max(v.best || DAILY, v.chips);
++ write(v);
++ return v.chips;
++ },
++ paint: paint,
++ };
++
++ function gate() {
++ var g = document.querySelector('[data-cs-gate]');
++ if (!g) return;
++ if (store.get('cs_adult') === 'yes' || (memory && memory.adult)) return;
++ g.hidden = false;
++ var yes = g.querySelector('[data-cs-adult]');
++ if (yes) {
++ yes.focus();
++ yes.addEventListener('click', function () { store.set('cs_adult', 'yes'); memory = Object.assign(read(), { adult: true }); g.hidden = true; document.dispatchEvent(new CustomEvent('cs-gate-open')); });
++ }
++ }
++
++ // The session reminder: every 30 minutes at a table, one calm line.
++ function session() {
++ var el = document.querySelector('[data-cs-session]');
++ if (!el) return;
++ var started = Number(sessionStorage.getItem('cs_started')) || Date.now();
++ try { sessionStorage.setItem('cs_started', String(started)); } catch (e) { /* */ }
++ function check() {
++ var mins = Math.floor((Date.now() - started) / 60000);
++ if (mins >= 30) {
++ el.hidden = false;
++ el.textContent = 'You have been at the tables for ' + mins + ' minutes. A good moment for a break; the chips will be here.';
++ }
++ }
++ check();
++ setInterval(check, 60000);
++ }
++
++ function init() { paint(); gate(); session(); }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++})();
ddiff --git a/public/js/sim-worker.js b/public/js/sim-worker.js
nnew file mode 100644
iindex 0000000..798bf20
---- /dev/null
++++ b/public/js/sim-worker.js
@@@ -0,0 +1,26 @@
++/* The simulator's worker: loads the engines (the same files the server and the tables run) and plays one game for the
++ * rounds asked, posting the house's share so far about 200 times a run, then the result. Nothing leaves the browser. */
++/* global importScripts, OpenVibeBlackjack, OpenVibeRoulette, OpenVibeBaccarat, OpenVibeCraps, OpenVibeSlots */
++'use strict';
++importScripts('/engine/rng.js', '/engine/cards.js', '/engine/blackjack.js', '/engine/roulette.js', '/engine/baccarat.js', '/engine/craps.js', '/engine/slots.js');
++
++// The same game and choice names as the server's POST /api/v1/simulate (server/sims.js), so a run can be shared.
++var RUN = {
++ blackjack: function (c, n, seed, p) { return OpenVibeBlackjack.houseEdge({ strategy: c, hands: n, seed: seed, onProgress: p }); },
++ roulette: function (c, n, seed, p) {
++ var parts = c.split('-');
++ var bet = parts[1] === 'straight' ? { type: 'straight', numbers: ['17'] } : { type: parts[1] };
++ return OpenVibeRoulette.houseEdge({ wheel: parts[0], bet: bet, spins: n, seed: seed, onProgress: p });
++ },
++ baccarat: function (c, n, seed, p) { return OpenVibeBaccarat.houseEdge({ bet: c, coups: n, seed: seed, onProgress: p }); },
++ craps: function (c, n, seed, p) { return OpenVibeCraps.houseEdge({ bet: c === 'pass-odds' ? 'pass' : c, withOdds: c === 'pass-odds', decisions: n, seed: seed, onProgress: p }); },
++ slots: function (c, n, seed, p) { return OpenVibeSlots.houseEdge({ spins: n, seed: seed, onProgress: p }); },
++};
++
++self.onmessage = function (e) {
++ var m = e.data || {};
++ if (!RUN[m.game]) { self.postMessage({ type: 'error', message: 'no such game' }); return; }
++ var t = Date.now();
++ var r = RUN[m.game](m.choice, m.rounds, m.seed >>> 0, function (done, edge) { self.postMessage({ type: 'progress', done: done, edge: edge }); });
++ self.postMessage({ type: 'done', result: { rounds: m.rounds, seed: m.seed >>> 0, edge_percent: Math.round(r.percent * 1000) / 1000, stderr_percent: Math.round(r.stderr * 1000) / 1000, ms: Date.now() - t } });
++};
ddiff --git a/public/js/simulate.js b/public/js/simulate.js
nnew file mode 100644
iindex 0000000..65d9768
---- /dev/null
++++ b/public/js/simulate.js
@@@ -0,0 +1,95 @@
++/* The simulator page: the form picks a game, a bet or strategy, the rounds and a seed; a Web Worker (sim-worker.js) plays
++ * them with the engines and this file draws the house's share as it settles toward the exact figure, then says the result
++ * in one plain sentence. A run of up to the server's limit can be shared: the server plays the same seed and keeps it. */
++(function () {
++ 'use strict';
++ var root = document.getElementById('cs-sim');
++ if (!root) return;
++ var form = root.querySelector('[data-cs-sim]');
++ var gameSel = root.querySelector('[data-cs-sim-game]');
++ var choiceSel = root.querySelector('[data-cs-sim-choice]');
++ var canvas = root.querySelector('[data-cs-chart]');
++ var result = root.querySelector('[data-cs-result]');
++ var progress = root.querySelector('[data-cs-progress]');
++ var shareBtn = root.querySelector('[data-cs-share]');
++ var plans = JSON.parse(root.getAttribute('data-plans') || '{}');
++ var refs = JSON.parse(root.getAttribute('data-refs') || '{}');
++ var labels = JSON.parse(root.getAttribute('data-labels') || '{}');
++ var maxShare = Number(root.getAttribute('data-max-share')) || 200000;
++ var worker = null; var points = []; var last = null;
++
++ function fillChoices(initial) {
++ var list = plans[gameSel.value] || [];
++ choiceSel.replaceChildren();
++ list.forEach(function (c) { var o = document.createElement('option'); o.value = c; o.textContent = labels[gameSel.value + ':' + c] || c; if (c === initial) o.selected = true; choiceSel.appendChild(o); });
++ }
++ gameSel.addEventListener('change', function () { fillChoices(''); });
++ fillChoices(choiceSel.getAttribute('data-initial'));
++
++ function css(name, fallback) { var v = getComputedStyle(root).getPropertyValue(name).trim(); return v || fallback; }
++ function draw(ref) {
++ var ctx = canvas.getContext('2d');
++ var w = canvas.width; var h = canvas.height;
++ ctx.clearRect(0, 0, w, h);
++ if (!points.length) return;
++ var vals = points.map(function (p) { return p.edge * 100; }).concat([ref]);
++ var lo = Math.min.apply(null, vals); var hi = Math.max.apply(null, vals);
++ var pad = Math.max(0.5, (hi - lo) * 0.15); lo -= pad; hi += pad;
++ var x = function (i) { return 40 + (i / Math.max(1, points.length - 1)) * (w - 56); };
++ var y = function (v) { return 12 + (1 - (v - lo) / (hi - lo)) * (h - 36); };
++ ctx.font = '12px system-ui, sans-serif';
++ ctx.fillStyle = css('--ovk-muted', '#93a0b4');
++ ctx.fillText(hi.toFixed(1) + '%', 2, 16); ctx.fillText(lo.toFixed(1) + '%', 2, h - 24);
++ ctx.strokeStyle = css('--ovk-muted', '#93a0b4'); ctx.setLineDash([5, 5]); ctx.lineWidth = 1.5;
++ ctx.beginPath(); ctx.moveTo(40, y(ref)); ctx.lineTo(w - 16, y(ref)); ctx.stroke(); ctx.setLineDash([]);
++ ctx.fillText('the exact edge, ' + ref.toFixed(2) + '%', w - 170, y(ref) - 6);
++ ctx.strokeStyle = css('--room-accent', '#51c672'); ctx.lineWidth = 2.5;
++ ctx.beginPath();
++ points.forEach(function (p, i) { var px = x(i); var py = y(p.edge * 100); if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); });
++ ctx.stroke();
++ ctx.fillStyle = css('--ovk-muted', '#93a0b4');
++ ctx.fillText('rounds played →', w - 120, h - 6);
++ }
++
++ form.addEventListener('submit', function (e) {
++ e.preventDefault();
++ if (worker) worker.terminate();
++ var game = gameSel.value; var choice = choiceSel.value;
++ var rounds = Number(form.elements.rounds.value);
++ var seed = form.elements.seed.value ? Number(form.elements.seed.value) >>> 0 : (function () { try { var a = new Uint32Array(1); crypto.getRandomValues(a); return a[0]; } catch (x) { return Date.now() >>> 0; } })();
++ form.elements.seed.value = String(seed);
++ var ref = refs[game + ':' + choice] == null ? 0 : refs[game + ':' + choice];
++ points = []; last = null; shareBtn.hidden = true;
++ progress.hidden = false; progress.firstElementChild.style.width = '0%';
++ result.textContent = 'Playing ' + rounds.toLocaleString('en-US') + ' rounds…';
++ worker = new Worker('/js/sim-worker.js');
++ worker.onmessage = function (m) {
++ var d = m.data;
++ if (d.type === 'progress') { points.push(d); progress.firstElementChild.style.width = (100 * d.done / rounds).toFixed(1) + '%'; draw(ref); return; }
++ if (d.type === 'done') {
++ progress.hidden = true;
++ last = { game: game, choice: choice, rounds: rounds, seed: seed, result: d.result };
++ var kept = d.result.edge_percent;
++ result.textContent = 'Over ' + rounds.toLocaleString('en-US') + ' rounds the house kept ' + kept.toFixed(2) + '% of what was bet' + (kept < 0 ? ' (the player came out ahead this time; run longer and it turns)' : '') + '. The exact edge is ' + ref.toFixed(2) + '%; this run\'s margin of error is about ±' + (2 * d.result.stderr_percent).toFixed(2) + '%.';
++ shareBtn.hidden = rounds > maxShare;
++ worker.terminate(); worker = null;
++ }
++ };
++ worker.postMessage({ game: game, choice: choice, rounds: rounds, seed: seed });
++ });
++
++ shareBtn.addEventListener('click', function () {
++ if (!last) return;
++ shareBtn.disabled = true;
++ fetch('/api/v1/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game: last.game, choice: last.choice, rounds: last.rounds, seed: last.seed, share: true }) })
++ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
++ .then(function (x) {
++ if (!x.ok) { result.textContent += ' Sharing did not work: ' + (x.j.detail || 'try again in a minute') + '.'; return; }
++ var a = document.createElement('a'); a.href = x.j.url; a.textContent = x.j.url;
++ result.appendChild(document.createTextNode(' Shared: ')); result.appendChild(a);
++ try { navigator.clipboard.writeText(x.j.url); } catch (e) { /* the link is on the page */ }
++ })
++ .catch(function () { result.textContent += ' Sharing did not work; try again.'; })
++ .then(function () { shareBtn.disabled = false; });
++ });
++})();
ddiff --git a/public/js/table.js b/public/js/table.js
nnew file mode 100644
iindex 0000000..c321afb
---- /dev/null
++++ b/public/js/table.js
@@@ -0,0 +1,273 @@
++/* OpenVibe.Casino's tables. The engines (/engine/*.js, the same files the server and the simulator run) deal every card,
++ * spin and roll from a seed made here; this file only draws the table and passes your choices to them. Chips come from
++ * window.CasinoChips (public/js/casino.js) and are worth nothing. Every control is a button, every result is read out
++ * (aria-live), and motion follows the network's tokens, so Calm and reduced motion keep it still. */
++(function () {
++ 'use strict';
++ var felt = document.querySelector('[data-cs-felt]');
++ if (!felt) return;
++ var game = document.getElementById('cs-table').getAttribute('data-game');
++ var chips = window.CasinoChips;
++ var seed = (function () { try { var a = new Uint32Array(1); crypto.getRandomValues(a); return a[0]; } catch (e) { return Date.now() >>> 0; } })();
++
++ // ── Small helpers: DOM nodes only, never markup from strings ──
++ function el(tag, attrs) {
++ var n = document.createElement(tag);
++ if (attrs) Object.keys(attrs).forEach(function (k) {
++ if (k === 'text') n.textContent = attrs[k];
++ else if (k === 'on') Object.keys(attrs.on).forEach(function (ev) { n.addEventListener(ev, attrs.on[ev]); });
++ else if (k === 'class') n.className = attrs[k];
++ else n.setAttribute(k, attrs[k]);
++ });
++ var add = function (c) { if (c == null) return; if (Array.isArray(c)) { c.forEach(add); return; } n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); };
++ for (var i = 2; i < arguments.length; i++) add(arguments[i]);
++ return n;
++ }
++ function fmt(n) { return (Math.round(n * 100) / 100).toLocaleString('en-US'); }
++ var say = el('p', { class: 'cs-say', 'aria-live': 'polite', text: '' });
++ function tell(text, kind) { say.textContent = text; say.className = 'cs-say' + (kind ? ' is-' + kind : ''); }
++ var denom = 25;
++ function denomPicker() {
++ var row = el('div', { class: 'cs-denoms', role: 'radiogroup', 'aria-label': 'Chip size' });
++ [5, 25, 100].forEach(function (d) {
++ var b = el('button', { type: 'button', class: 'cs-chip c' + d, role: 'radio', 'aria-checked': String(d === denom), text: String(d), on: { click: function () { denom = d; row.querySelectorAll('button').forEach(function (x) { x.setAttribute('aria-checked', String(x === b)); }); } } });
++ row.appendChild(b);
++ });
++ return row;
++ }
++ function card(c, hidden) {
++ if (hidden) return el('span', { class: 'cs-card is-back', 'aria-label': 'face-down card' });
++ var red = c.suit === 1 || c.suit === 2;
++ var name = window.OpenVibeCards.label(c) + window.OpenVibeCards.suit(c);
++ return el('span', { class: 'cs-card' + (red ? ' is-red' : ''), 'aria-label': name }, el('b', { text: window.OpenVibeCards.label(c) }), el('i', { text: window.OpenVibeCards.suit(c) }));
++ }
++ function btn(label, fn, cls) { return el('button', { type: 'button', class: 'cs-btn' + (cls ? ' ' + cls : ''), text: label, on: { click: fn } }); }
++
++ var TABLES = {};
++
++ // ── Blackjack ────────────────────────────────────────────
++ TABLES.blackjack = function () {
++ var B = window.OpenVibeBlackjack;
++ var g = B.createGame({ seed: seed });
++ var bet = 25; var showHint = false;
++ var dealerRow = el('div', { class: 'cs-hand cs-dealer' });
++ var handsRow = el('div', { class: 'cs-hands' });
++ var controls = el('div', { class: 'cs-controls' });
++ var betLine = el('div', { class: 'cs-betline' });
++ var hintBox = el('label', { class: 'cs-hint-toggle' }, el('input', { type: 'checkbox', on: { change: function (e) { showHint = e.target.checked; draw(); } } }), ' Show the basic-strategy move');
++ felt.appendChild(el('div', { class: 'cs-bj' }, el('p', { class: 'cs-label', text: 'Dealer' }), dealerRow, el('p', { class: 'cs-label', text: 'You' }), handsRow, say, controls, betLine, hintBox));
++
++ function total(s) { return s.dealerTotal != null ? ' · ' + s.dealerTotal : ''; }
++ function draw() {
++ var s = g.state();
++ dealerRow.replaceChildren();
++ if (s.phase !== 'idle') {
++ s.dealer.forEach(function (c) { dealerRow.appendChild(card(c)); });
++ if (s.phase !== 'done') dealerRow.appendChild(card(null, true));
++ dealerRow.appendChild(el('span', { class: 'cs-total', text: s.phase === 'done' ? 'Dealer' + total(s) : '' }));
++ }
++ handsRow.replaceChildren();
++ s.hands.forEach(function (h, i) {
++ var box = el('div', { class: 'cs-hand' + (s.phase === 'player' && i === s.current ? ' is-now' : '') });
++ h.cards.forEach(function (c) { box.appendChild(card(c)); });
++ box.appendChild(el('span', { class: 'cs-total', text: (h.soft && h.total < 21 ? 'soft ' : '') + h.total + ' · bet ' + h.bet }));
++ handsRow.appendChild(box);
++ });
++ controls.replaceChildren();
++ var options = g.available();
++ var hint = showHint ? g.hint() : null;
++ var labels = { hit: 'Hit', stand: 'Stand', double: 'Double', split: 'Split', insure: 'Take insurance', 'no-insurance': 'No insurance' };
++ options.forEach(function (o) {
++ var extra = (o === 'double' || o === 'split') ? s.hands[s.current].bet : o === 'insure' ? s.hands[0].bet / 2 : 0;
++ if (extra && !chips.can(committed() + extra)) return;
++ var b = btn(labels[o], function () { g.act(o); after(); }, hint === o ? 'is-hint' : '');
++ controls.appendChild(b);
++ });
++ betLine.replaceChildren();
++ if (s.phase === 'idle' || s.phase === 'done') {
++ betLine.appendChild(denomPicker());
++ betLine.appendChild(btn('Deal ' + bet, deal));
++ betLine.appendChild(el('span', { class: 'cs-muted', text: 'Bet' }));
++ [-1, 1].forEach(function (d) { betLine.appendChild(btn(d < 0 ? '−' : '+', function () { bet = Math.max(5, bet + d * denom); draw(); }, 'is-quiet is-small')); });
++ }
++ }
++ function committed() { return g.state().hands.reduce(function (a, h) { return a + h.bet; }, 0) + (g.state().insurance || 0); }
++ function deal() {
++ if (!chips.can(bet)) { tell('Not enough chips for that bet. A fresh 1,000 arrive at midnight.', 'bad'); return; }
++ g.start(bet);
++ tell(g.phase === 'insurance' ? 'The dealer shows an ace. Insurance? (Basic strategy says no.)' : 'Your move.');
++ after();
++ }
++ function after() {
++ var s = g.state();
++ if (s.phase === 'done') {
++ var net = s.outcome.net;
++ chips.change(net);
++ var words = s.outcome.results.map(function (r) { return { blackjack: 'Blackjack!', win: 'You win', lose: 'Dealer wins', push: 'Push', bust: 'Bust' }[r.result]; }).join(', ');
++ tell(words + (net ? (net > 0 ? ': +' : ': ') + fmt(net) + ' chips' : ''), net > 0 ? 'good' : net < 0 ? 'bad' : '');
++ } else if (s.phase === 'player') tell(showHint ? 'Basic strategy: ' + g.hint() + '.' : 'Your move.');
++ draw();
++ }
++ draw();
++ tell('Pick a bet and deal. Six decks, dealer stands on soft 17, blackjack pays 3 to 2.');
++ };
++
++ // ── Roulette ─────────────────────────────────────────────
++ TABLES.roulette = function () {
++ var R = window.OpenVibeRoulette;
++ var wheel = 'european';
++ var g = R.createGame({ wheel: wheel, seed: seed });
++ var bets = {}; // key → { bet, amount }
++ var board = el('div', { class: 'cs-board' });
++ var strip = el('div', { class: 'cs-strip', 'aria-hidden': 'true' });
++ var placed = el('p', { class: 'cs-muted' });
++ var wheelPick = el('div', { class: 'cs-seg', role: 'radiogroup', 'aria-label': 'Wheel' });
++ ['european', 'american'].forEach(function (w) {
++ wheelPick.appendChild(el('button', { type: 'button', role: 'radio', 'aria-checked': String(w === wheel), text: w === 'european' ? 'European (one zero)' : 'American (0 and 00)', on: { click: function () { wheel = w; g = R.createGame({ wheel: wheel, seed: seed + 1 }); bets = {}; build(); wheelPick.querySelectorAll('button').forEach(function (x) { x.setAttribute('aria-checked', String(x.textContent.indexOf(w === 'european' ? 'European' : 'American') === 0)); }); } } }));
++ });
++ felt.appendChild(el('div', { class: 'cs-rl' }, wheelPick, strip, board, say, placed, el('div', { class: 'cs-betline' }, denomPicker(), btn('Spin', spin), btn('Clear bets', function () { bets = {}; paintBets(); }, 'is-quiet'))));
++
++ function sum() { return Object.keys(bets).reduce(function (a, k) { return a + bets[k].amount; }, 0); }
++ function place(key, bet) {
++ if (!chips.can(sum() + denom)) { tell('Not enough chips for another ' + denom + '.', 'bad'); return; }
++ bets[key] = bets[key] || { bet: bet, amount: 0 };
++ bets[key].amount += denom;
++ paintBets();
++ }
++ function paintBets() {
++ board.querySelectorAll('[data-k]').forEach(function (c) { var b = bets[c.getAttribute('data-k')]; c.setAttribute('data-chips', b ? String(b.amount) : ''); c.classList.toggle('has-bet', !!b); });
++ placed.textContent = sum() ? 'On the table: ' + fmt(sum()) + ' chips. House edge on every bet: ' + (R.edge({ type: 'red' }, wheel) * 100).toFixed(2) + '%' + (wheel === 'american' && bets.top ? ' (the top line: 7.89%)' : '') + '.' : 'Pick a chip size, then click the numbers or the outside bets.';
++ }
++ function cell(label, key, bet, cls) { return el('button', { type: 'button', class: 'cs-cell ' + (cls || ''), 'data-k': key, text: label, 'aria-label': label + (bet.type === 'straight' ? '' : ' bet'), on: { click: function () { place(key, bet); } } }); }
++ function build() {
++ board.replaceChildren();
++ var zeros = el('div', { class: 'cs-zeros' });
++ (wheel === 'american' ? ['0', '00'] : ['0']).forEach(function (z) { zeros.appendChild(cell(z, 'n' + z, { type: 'straight', numbers: [z] }, 'is-green')); });
++ var grid = el('div', { class: 'cs-grid' });
++ for (var row = 3; row >= 1; row--) for (var col = 0; col < 12; col++) {
++ var n = col * 3 + row;
++ grid.appendChild(cell(String(n), 'n' + n, { type: 'straight', numbers: [String(n)] }, R.colour(String(n)) === 'red' ? 'is-red' : 'is-black'));
++ }
++ var cols = el('div', { class: 'cs-cols' });
++ [3, 2, 1].forEach(function (c) { cols.appendChild(cell('2 to 1', 'c' + c, { type: 'column', which: c })); });
++ var outs = el('div', { class: 'cs-outs' });
++ [['1st 12', 'd1', { type: 'dozen', which: 1 }], ['2nd 12', 'd2', { type: 'dozen', which: 2 }], ['3rd 12', 'd3', { type: 'dozen', which: 3 }], ['1 to 18', 'low', { type: 'low' }], ['Even', 'even', { type: 'even' }], ['Red', 'red', { type: 'red' }, 'is-red'], ['Black', 'black', { type: 'black' }, 'is-black'], ['Odd', 'odd', { type: 'odd' }], ['19 to 36', 'high', { type: 'high' }]].concat(wheel === 'american' ? [['0, 00, 1, 2, 3', 'top', { type: 'top' }]] : [])
++ .forEach(function (o) { outs.appendChild(cell(o[0], o[1], o[2], o[3])); });
++ board.append(zeros, grid, cols, outs);
++ strip.replaceChildren();
++ paintBets();
++ }
++ function spin() {
++ var list = Object.keys(bets).map(function (k) { var b = bets[k]; return Object.assign({}, b.bet, { amount: b.amount }); });
++ if (!list.length) { tell('Place a bet first.'); return; }
++ var r = g.spin(list);
++ chips.change(r.net);
++ strip.replaceChildren(el('span', { class: 'cs-pocket is-' + r.colour, text: r.pocket }));
++ tell(r.pocket + ' ' + r.colour + '. ' + (r.net > 0 ? 'You win ' + fmt(r.net) + ' chips.' : r.net < 0 ? 'The house takes ' + fmt(-r.net) + '.' : 'Even.'), r.net > 0 ? 'good' : r.net < 0 ? 'bad' : '');
++ }
++ build();
++ };
++
++ // ── Baccarat ─────────────────────────────────────────────
++ TABLES.baccarat = function () {
++ var BC = window.OpenVibeBaccarat;
++ var g = BC.createGame({ seed: seed });
++ var stakes = { player: 0, banker: 0, tie: 0 };
++ var hands = el('div', { class: 'cs-bac-hands' });
++ var spots = el('div', { class: 'cs-spots' });
++ var edges = { player: '1.24%', banker: '1.06%', tie: '14.36%' };
++ ['player', 'banker', 'tie'].forEach(function (k) {
++ spots.appendChild(el('button', { type: 'button', class: 'cs-spot', 'data-k': k, on: { click: function () {
++ var total = stakes.player + stakes.banker + stakes.tie;
++ if (!chips.can(total + denom)) { tell('Not enough chips for another ' + denom + '.', 'bad'); return; }
++ stakes[k] += denom; paint();
++ } } }, el('b', { text: k === 'tie' ? 'Tie' : k[0].toUpperCase() + k.slice(1) }), el('span', { text: k === 'banker' ? 'pays 0.95 to 1' : k === 'tie' ? 'pays 8 to 1' : 'pays 1 to 1' }), el('small', { text: 'house edge ' + edges[k] }), el('em', { 'data-amount': '' })));
++ });
++ felt.appendChild(el('div', { class: 'cs-bac' }, hands, spots, say, el('div', { class: 'cs-betline' }, denomPicker(), btn('Deal', deal), btn('Clear bets', function () { stakes = { player: 0, banker: 0, tie: 0 }; paint(); }, 'is-quiet'))));
++ function paint() { spots.querySelectorAll('[data-k]').forEach(function (s) { var a = stakes[s.getAttribute('data-k')]; s.querySelector('em').textContent = a ? a + ' chips' : ''; s.classList.toggle('has-bet', !!a); }); }
++ function deal() {
++ var list = Object.keys(stakes).filter(function (k) { return stakes[k]; }).map(function (k) { return { bet: k, amount: stakes[k] }; });
++ if (!list.length) { tell('Put chips on the player, the banker or a tie.'); return; }
++ var r = g.deal(list);
++ chips.change(r.net);
++ hands.replaceChildren(
++ el('div', { class: 'cs-hand' + (r.winner === 'player' ? ' is-win' : '') }, el('p', { class: 'cs-label', text: 'Player · ' + r.playerTotal }), el('div', { class: 'cs-row' }, r.playerCards.map(function (c) { return card(c); }))),
++ el('div', { class: 'cs-hand' + (r.winner === 'banker' ? ' is-win' : '') }, el('p', { class: 'cs-label', text: 'Banker · ' + r.bankerTotal }), el('div', { class: 'cs-row' }, r.bankerCards.map(function (c) { return card(c); }))));
++ tell((r.winner === 'tie' ? 'A tie' : (r.winner === 'player' ? 'The player' : 'The banker') + ' wins') + ', ' + r.playerTotal + ' to ' + r.bankerTotal + '. ' + (r.net > 0 ? '+' + fmt(r.net) + ' chips.' : r.net < 0 ? fmt(r.net) + ' chips.' : 'Your bet pushes.'), r.net > 0 ? 'good' : r.net < 0 ? 'bad' : '');
++ }
++ paint();
++ tell('Put chips on a spot and deal. The cards play themselves; the banker bet costs least.');
++ };
++
++ // ── Craps ────────────────────────────────────────────────
++ TABLES.craps = function () {
++ var C = window.OpenVibeCraps;
++ var g = C.createGame({ seed: seed });
++ var dice = el('div', { class: 'cs-dice', 'aria-hidden': 'true' });
++ var puck = el('p', { class: 'cs-puck' });
++ var spots = el('div', { class: 'cs-spots' });
++ var open = el('p', { class: 'cs-muted' });
++ var kinds = [['pass', 'Pass line', 'even money · 1.41%'], ['dontPass', 'Don\'t pass', 'even money · 1.36%'], ['odds', 'Odds behind the line', 'true odds · 0%'], ['place6', 'Place 6', '7 to 6 · 1.52%'], ['place8', 'Place 8', '7 to 6 · 1.52%'], ['field', 'Field', 'one roll · 5.56%']];
++ kinds.forEach(function (k) {
++ spots.appendChild(el('button', { type: 'button', class: 'cs-spot', 'data-k': k[0], on: { click: function () {
++ var stake = g.bets.reduce(function (a, b) { return a + b.amount; }, 0);
++ if (!chips.can(stake + denom)) { tell('Not enough chips for another ' + denom + '.', 'bad'); return; }
++ var type = k[0].indexOf('place') === 0 ? 'place' : k[0];
++ var out = g.bet(type, denom, type === 'place' ? Number(k[0].slice(5)) : null);
++ tell(out.ok ? k[1] + ': ' + denom + ' chips down.' : out.error[0].toUpperCase() + out.error.slice(1) + '.', out.ok ? '' : 'bad');
++ paint();
++ } } }, el('b', { text: k[1] }), el('small', { text: k[2] })));
++ });
++ felt.appendChild(el('div', { class: 'cs-cr' }, el('div', { class: 'cs-cr-top' }, dice, puck), spots, say, open, el('div', { class: 'cs-betline' }, denomPicker(), btn('Roll', roll))));
++ var PIPS = { 1: [4], 2: [0, 8], 3: [0, 4, 8], 4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8] };
++ function die(n) { var d = el('span', { class: 'cs-die' }); for (var i = 0; i < 9; i++) d.appendChild(el('i', { class: PIPS[n].indexOf(i) >= 0 ? 'on' : '' })); return d; }
++ function paint() {
++ puck.textContent = g.point ? 'The point is ' + g.point : 'Come-out roll: no point yet';
++ puck.classList.toggle('is-on', !!g.point);
++ var list = g.bets;
++ open.textContent = list.length ? 'On the table: ' + list.map(function (b) { return (b.type === 'place' ? 'place ' + b.at : b.type === 'dontPass' ? 'don\'t pass' : b.type) + ' ' + b.amount; }).join(', ') : 'No bets down.';
++ }
++ function roll() {
++ if (!g.bets.length) { tell('Put a bet down first; the pass line is the classic.'); return; }
++ var r = g.roll();
++ chips.change(r.net);
++ dice.replaceChildren(die(r.dice[0]), die(r.dice[1]));
++ var line = r.total + '. ' + (r.settled.length ? r.settled.map(function (b) { return (b.type === 'place' ? 'place ' + b.at : b.type === 'dontPass' ? 'don\'t pass' : b.type) + (b.net > 0 ? ' wins ' + fmt(b.net) : b.net < 0 ? ' loses' : ' pushes'); }).join(', ') + '.' : 'Nothing settles.');
++ if (!r.pointBefore && r.point) line += ' The point is ' + r.point + '.';
++ if (r.pointBefore && !r.point) line += ' The point is over.';
++ tell(line, r.net > 0 ? 'good' : r.net < 0 ? 'bad' : '');
++ paint();
++ }
++ paint();
++ tell('Bet the pass line and roll. Once there is a point, odds behind it pay true odds, the only free bet in the house.');
++ };
++
++ // ── Slots ────────────────────────────────────────────────
++ TABLES.slots = function () {
++ var S = window.OpenVibeSlots;
++ var g = S.createGame({ seed: seed });
++ var bet = 5;
++ var reels = el('div', { class: 'cs-reels-live', 'aria-hidden': 'true' });
++ var x = S.exact();
++ felt.appendChild(el('div', { class: 'cs-sl' }, reels, say,
++ el('p', { class: 'cs-muted', text: 'This machine pays back ' + (x.rtp * 100).toFixed(2) + '% over time and wins something on ' + Math.round(x.hitRate * 100) + '% of spins. The house keeps ' + (x.edge * 100).toFixed(2) + '%.' }),
++ el('div', { class: 'cs-betline' }, el('span', { class: 'cs-muted', text: 'Bet' }), [1, 5, 25].reduce(function (seg, b) { seg.appendChild(el('button', { type: 'button', role: 'radio', 'aria-checked': String(b === bet), text: String(b), on: { click: function (e) { bet = b; seg.querySelectorAll('button').forEach(function (y) { y.setAttribute('aria-checked', String(y === e.currentTarget)); }); } } })); return seg; }, el('div', { class: 'cs-seg', role: 'radiogroup', 'aria-label': 'Bet' })), btn('Spin', spin))));
++ function face(sym) { return el('span', { class: 'cs-sym sym-' + sym, text: { seven: '7', bell: 'BELL', star: '★', plum: 'PLUM', lemon: 'LEMON', cherry: 'CHERRY', blank: '' }[sym] }); }
++ function paint(line) { reels.replaceChildren(); line.forEach(function (s) { reels.appendChild(el('span', { class: 'cs-reel-win' }, face(s))); }); }
++ function spin() {
++ if (!chips.can(bet)) { tell('Not enough chips for that bet.', 'bad'); return; }
++ var r = g.spin(bet);
++ chips.change(r.net);
++ paint(r.line);
++ reels.classList.remove('is-new'); void reels.offsetWidth; reels.classList.add('is-new');
++ tell(r.win ? 'Pays ' + fmt(r.win) + (r.win < bet ? ': less than the bet, so still a loss of ' + fmt(bet - r.win) : r.win === bet ? ': your bet back' : ': +' + fmt(r.net)) + '.' : 'No win. −' + bet + '.', r.net > 0 ? 'good' : r.net < 0 ? 'bad' : '');
++ }
++ paint(['seven', 'bell', 'star']);
++ tell('Pick a bet and spin. Every stop on every reel is equally likely; Learn shows the strips.');
++ };
++
++ function start() { if (TABLES[game]) TABLES[game](); }
++ var gate = document.querySelector('[data-cs-gate]');
++ if (gate && !gate.hidden) document.addEventListener('cs-gate-open', start, { once: true });
++ else start();
++})();
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..f5ca79a
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,171 @@
++'use strict';
++
++/**
++ * OpenVibe.Casino — Express app factory; server/index.js listens, tests build their own instance with a temp
++ * database and a mock Network. Learn it, play it, never pay it: the games' engines (engine/) run here and in the
++ * browser, play chips live in the player's browser, and nothing is ever bought, sold or cashed out.
++ *
++ * /, /play, /play/:game, /learn, /learn/:game, /simulate, /s/:id, /safe, /developers the pages (http/pages.js)
++ * /api/v1/* the API (http/api.js)
++ * /auth/* Network SSO with PKCE (auth/sso.js)
++ * /api/health, /api/ready, /release.json, /metrics (loopback only)
++ * /internal/events account export/deletion (loopback only)
++ */
++const path = require('path');
++const express = require('express');
++const helmet = require('helmet');
++const cookieParser = require('cookie-parser');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++const cache = require('openvibe-shared/cache-policy');
++
++const configLib = require('./config');
++const { openStore } = require('./db');
++const { createKeyStore } = require('./auth/keys');
++const { createSso } = require('./auth/sso');
++const { createPrincipal } = require('./http/principal');
++const { createApi } = require('./http/api');
++const { createPageRoutes } = require('./http/pages');
++const { createServiceReadiness } = require('./observability');
++const { assetVersion, send } = require('./render/layout');
++const { raw } = require('./render/html');
++
++const accountDataLib = require('./identity/account-data');
++const { createSims } = require('./sims');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++const { createHttpTelemetry } = require('openvibe-sdk/service');
++const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
++
++/**
++ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend (a
++ * stand-in for Network's internal routes; tests only), telemetrySink.
++ */
++async function createApp(opts = {}) {
++ const config = opts.config || configLib.load();
++ const log = opts.log || console;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const s = opts.store || await openStore(config, { now: opts.now, log });
++
++ const keys = createKeyStore({ config, fetchImpl, log });
++ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
++ const principal = createPrincipal({ config, keys });
++
++ const sims = createSims({ config, s });
++
++ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
++ // client-credentials token; a test injects a stand-in through opts.accountSend.
++ const accountData = accountDataLib.create({ db: s.db, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
++
++ const ctx = {
++ config, s, keys, sso, principal, sims, accountData, accountSend, log,
++ };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'casino', root: path.join(__dirname, '..') });
++ require('./render/layout').setRelease(release.release);
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'casino', release: release.release });
++ app.locals.metrics = metrics.registry;
++ ctx.telemetry = createHttpTelemetry({ service: 'casino', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
++ app.use(ctx.telemetry.middleware());
++ app.locals.ctx = ctx;
++
++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
++ ctx.valkey = valkey;
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'none'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ frameguard: { action: 'deny' },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Machine endpoints ───────────────────────────────────
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-casino', name: 'OpenVibe.Casino', version: VERSION }));
++ release.mount(app, { registry: metrics.registry });
++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey, sso});
++ app.get('/api/ready', readiness.handler);
++
++ // ── Who is asking (verified offline; refreshed when expired) ──
++ app.use(sso.middleware());
++
++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'casino', service: 'casino', host: 'openvibe.casino', name: 'OpenVibe.Casino', profile: 'games' })); }
++
++ // ── OpenVibe.Events → this service (loopback only) ──────
++ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.
++ // nginx answers 404 for /internal/.
++ app.use('/internal/events', accountData.consumer({
++ secrets: config.events.secrets,
++ send: accountSend,
++ now: s.now,
++ onEvent: async (ev) => `ignored:${ev.event_type}`,
++ }));
++
++ // ── Static assets (content-hashed ?v= → immutable) ──────
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ // The games' engines, the same files the server runs, for the tables and the simulation worker.
++ app.use('/engine', express.static(path.join(__dirname, '..', 'engine'), { index: false, redirect: false, setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=300'); } }));
++ app.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
++ },
++ }));
++
++ // ── API ─────────────────────────────────────────────────
++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.CASINO_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ app.use('/api/v1', createApi(ctx));
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ // ── Pages ───────────────────────────────────────────────
++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CASINO_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ app.use(createPageRoutes(ctx));
++ app.use((req, res) => send(res, 404, {
++ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
++ body: raw(require('./views').notFound()),
++ }));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ log.error('[OpenVibe.Casino]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ });
++
++ return { app, ctx };
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..b6ac115
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,84 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
++ * (sign-in) and a person's Bearer token (the API).
++ *
++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
++ * this module: token.no_key answers 'signing key not loaded yet'.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++function publicReason(err) {
++ const code = err && err.code;
++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
++}
++
++function jwksUrl(config) {
++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
++}
++
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null;
++
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) { log.warn(`[OpenVibe.Casino] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
++ }
++ async function ensure() { await refreshMirror(); }
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
++
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
++
++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Casino] user token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++
++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
++ try {
++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
++ await refreshMirror();
++ return { ok: true, claims };
++ } catch (err) {
++ await refreshMirror();
++ const code = err && err.code;
++ log.warn(`[OpenVibe.Casino] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code };
++ }
++ }
++
++ async function pemForToken(token) {
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch { return null; }
++ }
++
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
++}
++
++module.exports = { createKeyStore, jwksUrl };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..23974cd
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,230 @@
++'use strict';
++
++/**
++ * Sign-in with OpenVibe.Network: openvibe-sdk/sso (0.73.0) is the one module every site uses. This file is this
++ * site's thin wrapper around it — the routes, the middleware and the shapes this site's pages still read.
++ *
++ * GET /auth/login → Network /oauth/authorize with state and PKCE (S256); ?next, ?silent, ?prompt, ?login_hint
++ * GET /auth/callback → the state is compared in constant time, the code is exchanged server-side (client secret)
++ * GET /auth/logout → the cookies are cleared, the refresh token revoked (best effort)
++ * GET /auth/me → { user } for the shared navbar
++ * GET /auth/health → { ok, client }: does the Network accept this site's OAuth client?
++ * POST /auth/fedcm → the shared navbar's FedCM assertion, swapped for tokens
++ * POST /auth/refresh → the tokens are rotated with the refresh token
++ *
++ * The SDK recovers by itself once (a lost or expired flow, a code the Network no longer accepts) and shows a page
++ * with "Try again" when it cannot: no more a bare "The Network refused the sign-in code." A client the Network
++ * refuses is logged as a configuration error, and GET /auth/health answers 503 for it.
++ *
++ * The person's old cookie (casino_at) is read as the session until the next sign-in or refresh writes ov_token, so
++ * nobody is signed out by the move. The access token stays httpOnly: no script can read it (the navbar asks
++ * /auth/me). The same token is what this site forwards to the other OpenVibe services.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const { createSsoClient, COOKIES, parseCookies, sanitizeNext } = require('openvibe-sdk/sso');
++
++const ACCESS_COOKIE = 'casino_at';
++const REFRESH_COOKIE = 'casino_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++function ownOrigins(config) {
++ const out = new Set();
++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
++ }
++ return out;
++}
++
++/** The authorize URL for a sign-in (pure; the SDK builds its own, this one stays for the site's tests). */
++function authorizeUrl(config, { state, challenge, silent = false }) {
++ const q = new URLSearchParams({
++ response_type: 'code',
++ client_id: config.oauth.clientId,
++ redirect_uri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ state,
++ code_challenge: challenge,
++ code_challenge_method: 'S256',
++ });
++ if (silent) q.set('prompt', 'none');
++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
++}
++
++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ // The one module. The site's own values, its own old cookies, and the audience it always verified with.
++ const sso = createSsoClient({
++ site: 'casino',
++ siteName: 'OpenVibe.Casino',
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ networkUrl: config.networkUrl,
++ networkInternalUrl: config.networkInternalUrl || config.networkUrl,
++ issuer: config.networkIssuer || config.networkUrl,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ audience: config.oauth.sessionAudience,
++ secureCookies: config.cookies.secure !== false,
++ httpOnlyAccess: true,
++ legacyCookies: { access: [ACCESS_COOKIE], refresh: [REFRESH_COOKIE] },
++ fetch: fetchImpl,
++ log,
++ });
++
++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: true, path: '/' });
++ const nextOf = (value) => sanitizeNext(value, { baseUrl: config.baseUrl, networkUrl: config.networkUrl });
++
++ /** The person, in the shape this site's pages read (req.viewer). */
++ function viewerFrom(claims, token) {
++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
++ const role = typeof claims.role === 'string' ? claims.role : 'user';
++ const username = typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
++ return {
++ kind: 'user',
++ subject,
++ username,
++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || 'you'),
++ role,
++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
++ token,
++ };
++ }
++
++ /** The public profile the navbar and the API read. */
++ const meOf = (claims) => ({
++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
++ display_name: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : null,
++ subject_id: typeof claims.subject_id === 'string' ? claims.subject_id : null,
++ role: typeof claims.role === 'string' ? claims.role : 'user',
++ avatar_url: typeof claims.avatar_url === 'string' ? claims.avatar_url.slice(0, 500) : null,
++ profile_color: typeof claims.profile_color === 'string' ? claims.profile_color.slice(0, 16) : null,
++ });
++
++ /**
++ * The access token expired (or is gone) but the refresh token is still there: rotate it and write the SDK's own
++ * cookies, the same names and options the SDK uses. The old cookies are cleared.
++ */
++ async function refreshSession(req, res) {
++ const c = parseCookies(req);
++ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];
++ if (!rt) return null;
++ let data = null;
++ try { data = await sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: rt }); } catch { return null; }
++ if (!data || !data.access_token) return null;
++ const claims = await sso.verify(data.access_token);
++ if (!claims) return null;
++ res.cookie(COOKIES.access, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
++ if (data.refresh_token) res.cookie(COOKIES.refresh, data.refresh_token, { ...cookieBase(), path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
++ res.clearCookie(ACCESS_COOKIE, { path: '/' });
++ res.clearCookie(REFRESH_COOKIE, { path: '/' });
++ return { claims, token: data.access_token };
++ }
++
++ /** req.user and req.token for a valid session; req.viewer keeps this site's pages working as they are. */
++ function middleware() {
++ const optional = sso.optionalAuth();
++ return (req, res, next) => {
++ req.viewer = { kind: 'anonymous' };
++ optional(req, res, async () => {
++ try {
++ if (req.user && req.token) { req.viewer = viewerFrom(req.user, req.token); return next(); }
++ const fresh = await refreshSession(req, res);
++ if (fresh) {
++ req.user = fresh.claims;
++ req.token = fresh.token;
++ req.viewer = viewerFrom(fresh.claims, fresh.token);
++ }
++ } catch (err) {
++ log.warn('[OpenVibe.Casino] session refresh failed:', (err && err.message) || err);
++ }
++ return next();
++ });
++ };
++ }
++
++ /** A call with the person's own token; a 401 refreshes the session once and tries again. */
++ async function asViewer(req, res, fn) {
++ const token = req.token || (req.viewer && req.viewer.token) || null;
++ if (!token) throw Object.assign(new Error('Not signed in'), { status: 401 });
++ try {
++ return await fn(token);
++ } catch (err) {
++ if (!err || err.status !== 401) throw err;
++ const fresh = await refreshSession(req, res);
++ if (!fresh) throw err;
++ req.user = fresh.claims;
++ req.token = fresh.token;
++ req.viewer = viewerFrom(fresh.claims, fresh.token);
++ return fn(fresh.token);
++ }
++ }
++
++ function routes() {
++ const r = express.Router();
++
++ // GET /auth/me: the shared navbar's answer. A guest is { user: null } (200); a session that does not verify
++ // is 401, as it always was here.
++ r.get('/me', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const c = parseCookies(req);
++ const presented = Boolean(c[COOKIES.access] || c[ACCESS_COOKIE] || c[COOKIES.refresh] || c[REFRESH_COOKIE] || req.get('authorization'));
++ if (!presented) return res.json({ user: null });
++ const claims = await sso.verify(sso.extractToken(req));
++ if (!claims) return res.status(401).json({ error: 'Not signed in' });
++ return res.json({ user: meOf(claims) });
++ });
++
++ // Sign-out stays a same-site action: another site cannot sign a person out.
++ const sameOriginRequest = (req) => {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
++ const origin = req.get('origin');
++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
++ return req.method === 'GET';
++ };
++ r.get('/logout', (req, res, next) => {
++ if (sameOriginRequest(req)) return next();
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const q = req.query.next ? `?next=${encodeURIComponent(nextOf(req.query.next))}` : '';
++ res.type('html').send('<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">'
++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
++ + '<p>Sign out of OpenVibe.Casino?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>');
++ });
++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
++ r.all('/logout', async (req, res) => {
++ const c = parseCookies(req);
++ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];
++ if (rt) {
++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ res.clearCookie(COOKIES.access, { path: '/' });
++ res.clearCookie(COOKIES.refresh, { path: '/auth' });
++ res.clearCookie(ACCESS_COOKIE, { path: '/' });
++ res.clearCookie(REFRESH_COOKIE, { path: '/' });
++ res.set('Clear-Site-Data', '"cache"');
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ return res.redirect(303, nextOf(req.query.next));
++ });
++
++ // The SDK's own routes: /login, /callback, /fedcm, /refresh, /health.
++ r.use(sso.router(express));
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..7aa875f
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++/**
++ * OpenVibe.Casino configuration. Every value comes from the environment (production: /etc/openvibe/casino.env,
++ * see .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ */
++require('dotenv').config();
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 5210);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.casino' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++
++ return {
++ service: 'casino',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ drill: env.CASINO_DRILL === '1' || env.CASINO_DRILL === 'true',
++ limits: {
++ minute: Math.max(1, int(env.CASINO_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.CASINO_LIMITS_HOUR, 3000)),
++ },
++
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (CASINO_PGLITE_DIR
++ // overrides the directory).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CASINO_PGLITE_DIR || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:casino:' },
++
++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ audience: env.CASINO_AUDIENCE || 'openvibe.casino',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'casino',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: 'profile',
++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
++ },
++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++
++ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery
++ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).
++ events: {
++ secrets: String(env.CASINO_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
++ url: trim(env.CASINO_EVENTS_URL || env.EVENTS_URL || ''),
++ endpoint: env.CASINO_EVENTS_ENDPOINT || '',
++ intervalMs: Math.max(500, int(env.CASINO_EVENTS_INTERVAL_MS, 5000)),
++ scope: env.CASINO_EVENTS_SCOPE || 'events.event.publish',
++ },
++
++ // Simulations run on the server (POST /api/v1/simulate): at most this many rounds each, and shared links kept.
++ simulate: {
++ maxRounds: Math.min(2_000_000, Math.max(1000, int(env.CASINO_SIM_MAX_ROUNDS, 200_000))),
++ perMinute: Math.max(1, int(env.CASINO_SIM_PER_MINUTE, 6)),
++ },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..b15469e
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,48 @@
++'use strict';
++
++/**
++ * OpenVibe.Casino's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
++ * Nothing written here is ever a secret. A citation stores a public URL and a sha256 of the verbatim quote;
++ * the quote itself is public speech already, and a person's vote, caption or report is theirs to export/erase.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++async function openDb(config, { log = console, registry } = {}) {
++ if (!config.db.url) {
++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Casino] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'casino', registry, log });
++ await db.migrate({ dir: MIGRATIONS, log });
++ return db;
++ }
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'casino-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'casino', registry, log });
++}
++
++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ tx: async (fn) => await db.tx((t) => fn(t)),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..4065312
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,92 @@
++'use strict';
++
++/**
++ * /api/v1 — OpenVibe.Casino's JSON API. Open to anyone: there is nothing personal here and no money.
++ *
++ * GET /ping liveness for the API
++ * GET /games every game, its rules, each bet's house edge and where the figure comes from
++ * GET /games/:id one game
++ * POST /simulate { game, choice, rounds, seed?, share? } → the measured edge (and a share link)
++ * GET /sims/:id a shared simulation
++ * GET /openapi.json this API as OpenAPI 3.1
++ *
++ * Simulations are bounded (CASINO_SIM_MAX_ROUNDS rounds, CASINO_SIM_PER_MINUTE a minute per address) because they run in
++ * this process. Errors are application/problem+json with a `code`.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const games = require('../../engine/games');
++
++function createApi(ctx) {
++ const { config, sims } = ctx;
++ const r = asyncRouter();
++ r.use(express.json({ limit: '4kb' }));
++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail });
++
++ // The per-address budget for simulations: they spend CPU in this process.
++ const recent = new Map();
++ const allowed = (key) => {
++ const now = Date.now();
++ const list = (recent.get(key) || []).filter((t) => now - t < 60_000);
++ if (list.length >= config.simulate.perMinute) { recent.set(key, list); return false; }
++ list.push(now);
++ recent.set(key, list);
++ if (recent.size > 10_000) recent.clear();
++ return true;
++ };
++
++ r.get('/ping', (_req, res) => res.json({ ok: true, service: 'casino' }));
++ r.get('/games', (_req, res) => { res.set('Cache-Control', 'public, max-age=3600'); res.json({ games: games.catalogue() }); });
++ r.get('/games/:id', (req, res) => {
++ const g = games.get(req.params.id);
++ if (!g) return problem(res, 404, 'casino.not_found', 'no such game');
++ res.set('Cache-Control', 'public, max-age=3600');
++ return res.json({ game: g });
++ });
++
++ r.post('/simulate', async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ if (!allowed(req.ip || 'unknown')) return problem(res, 429, 'casino.rate_limited', `at most ${config.simulate.perMinute} server simulations a minute; the simulator page runs them in your browser without a limit`);
++ let p;
++ try { p = sims.parse(req.body || {}); } catch (err) { return problem(res, err.status || 400, err.code || 'casino.invalid', err.message); }
++ const result = sims.run(p);
++ if (req.body && req.body.share === true) {
++ const id = await sims.share(p, result);
++ return res.status(201).json({ result, id, url: `${config.baseUrl}/s/${id}` });
++ }
++ return res.json({ result });
++ });
++
++ r.get('/sims/:id', async (req, res) => {
++ const sim = await sims.get(req.params.id);
++ if (!sim) return problem(res, 404, 'casino.not_found', 'no such simulation');
++ res.set('Cache-Control', 'public, max-age=3600');
++ return res.json({ sim });
++ });
++
++ r.get('/openapi.json', (_req, res) => res.json(openapi(config)));
++ return r;
++}
++
++function openapi(config) {
++ const json = (schema) => ({ content: { 'application/json': { schema } } });
++ const problemRef = { description: 'Problem', ...json({ type: 'object', properties: { code: { type: 'string' }, detail: { type: 'string' } } }) };
++ return {
++ openapi: '3.1.0',
++ info: { title: 'OpenVibe.Casino API', version: require('../../package.json').version, description: 'Casino games\' rules and house edges, worked out by open engines, and simulations of them. Play chips only: nothing here is money.' },
++ servers: [{ url: `${config.baseUrl}/api/v1` }],
++ paths: {
++ '/games': { get: { summary: 'Every game, its rules and each bet\'s house edge', responses: { 200: { description: 'The games', ...json({ type: 'object', properties: { games: { type: 'array', items: { type: 'object' } } } }) } } } },
++ '/games/{id}': { get: { summary: 'One game', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', enum: games.catalogue().map((g) => g.id) } }], responses: { 200: { description: 'The game' }, 404: problemRef } } },
++ '/simulate': { post: {
++ summary: 'Run a bounded simulation on the server',
++ requestBody: { required: true, ...json({ type: 'object', required: ['game'], properties: { game: { type: 'string' }, choice: { type: 'string' }, rounds: { type: 'integer', minimum: 1000, maximum: config.simulate.maxRounds }, seed: { type: 'integer', minimum: 0, maximum: 4294967295 }, share: { type: 'boolean' } } }) },
++ responses: { 200: { description: 'The measured edge' }, 201: { description: 'The measured edge and its share link' }, 400: problemRef, 429: problemRef },
++ } },
++ '/sims/{id}': { get: { summary: 'A shared simulation', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', pattern: '^sim_[a-z2-7]{20}$' } }], responses: { 200: { description: 'The simulation' }, 404: problemRef } } },
++ },
++ };
++}
++
++module.exports = { createApi, openapi };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..6bfee61
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,96 @@
++'use strict';
++
++/**
++ * Crawl artifacts for openvibe.casino: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Every game's table and
++ * explainer is for search engines and AI crawlers; sign-in, the API and the internal route are not.
++ */
++const fs = require('fs');
++const path = require('path');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++const { asyncRouter } = require('./router');
++const games = require('../../engine/games');
++
++const SITE_NAME = 'OpenVibe.Casino';
++const DESCRIPTION = 'OpenVibe.Casino: learn casino games, play them with chips worth nothing, and see exactly what each bet costs. Open engines, exact house edges, simulations in your browser. Never real money.';
++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/s/'];
++
++function dayOf(ts) {
++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
++ return m ? m[1] : null;
++}
++function siteUpdated() {
++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
++}
++
++function homeJsonLd(config) {
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ return [
++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'GameApplication', keywords: 'casino odds, house edge, blackjack basic strategy, roulette odds, baccarat, craps, slot machine RTP, gambling simulator, free casino games' }),
++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
++ ];
++}
++
++function publicPages() {
++ const list = games.catalogue();
++ return [
++ { path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Casino', text: 'Learn it, play it, never pay it: every game and what each bet costs.' },
++ { path: '/play', changefreq: 'monthly', priority: 0.8, title: 'Play', text: 'Every table, with play chips worth nothing.' },
++ ...list.map((g) => ({ path: `/play/${g.id}`, changefreq: 'monthly', priority: 0.8, title: `Play ${g.name}`, text: g.line })),
++ { path: '/learn', changefreq: 'monthly', priority: 0.8, title: 'Learn', text: 'The rules, the odds and the house edge of every game.' },
++ ...list.map((g) => ({ path: `/learn/${g.id}`, changefreq: 'monthly', priority: 0.8, title: `${g.name}: rules, odds and the house edge`, text: g.rules })),
++ { path: '/simulate', changefreq: 'monthly', priority: 0.7, title: 'Simulate', text: 'A million hands in your browser: what the house keeps, measured.' },
++ { path: '/safe', changefreq: 'monthly', priority: 0.6, title: 'Play safe', text: 'Why this is for learning, and where to get help.' },
++ { path: '/developers', changefreq: 'monthly', priority: 0.5, title: 'The OpenVibe.Casino API', text: 'Games, house edges and simulations as JSON.' },
++ ];
++}
++
++function createDiscoveryRoutes(ctx) {
++ const { config } = ctx;
++ const r = asyncRouter();
++ const site = String(config.baseUrl).replace(/\/+$/, '');
++ const abs = (p) => `${site}${p}`;
++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
++ const pages = publicPages();
++
++ r.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', TEXT).send(
++ '# openvibe.casino: every table and explainer is for search and AI crawlers; sign-in, the API and shared runs are not.\n'
++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
++ });
++
++ r.get('/llms.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
++ name: SITE_NAME,
++ summary: DESCRIPTION,
++ details: 'GET /api/v1/games lists every game, its rules and each bet\'s house edge (exact where the maths allows, simulated for blackjack strategies) with the published source. POST /api/v1/simulate runs a bounded simulation. Play chips are free, reset daily and are worth nothing; nothing is bought or cashed out.',
++ sections: [
++ { title: 'Start here', links: pages.map((p) => ({ title: p.title, url: abs(p.path), note: p.text })) },
++ { title: 'Machine-readable', links: [
++ { title: 'Games and house edges (JSON)', url: abs('/api/v1/games') },
++ { title: 'OpenAPI', url: abs('/api/v1/openapi.json') },
++ { title: 'Sitemap', url: abs('/sitemap.xml') },
++ { title: 'Full text for language models', url: abs('/llms-full.txt') },
++ ] },
++ ],
++ }));
++ });
++
++ r.get('/llms-full.txt', (_req, res) => {
++ const list = games.catalogue().map((g) => ({ url: `/learn/${g.id}`, title: g.name, text: `${g.rules} House edge by bet: ${g.bets.map((b) => `${b.name} ${b.edge}%`).join('; ')}. Source: ${g.source.name}.` }));
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
++ site: SITE_NAME, summary: DESCRIPTION, base: site, maxBytes: 256 * 1024,
++ sections: [{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) }, { title: 'Games', pages: list }],
++ }));
++ });
++
++ r.get('/sitemap.xml', (_req, res) => {
++ const lastmod = siteUpdated();
++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }))));
++ });
++
++ return r;
++}
++
++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..5c7c679
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,115 @@
++'use strict';
++
++/**
++ * Casino's server-rendered pages. Public pages are served `public, max-age=300` with Vary: Cookie; a page drawn for a
++ * signed-in person is `private, no-store` (layout.send).
++ *
++ * / the floor: every bet's cost, a real bankroll chart, what you can do
++ * /play, /play/:game the tables (public/js/casino.js deals)
++ * /learn, /learn/:game the rules, the odds and the edges
++ * /simulate the simulator (a Web Worker runs the engines)
++ * /s/:id a shared simulation (noindex)
++ * /safe, /developers play safe; the API
++ */
++const seo = require('openvibe-shared/seo');
++const { asyncRouter } = require('./router');
++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
++const { raw } = require('../render/html');
++const { send } = require('../render/layout');
++const games = require('../../engine/games');
++const views = require('../views');
++
++const PUBLIC_CACHE = 'public, max-age=300';
++// What every table loads before its own engine: the seeded generator and the cards.
++const ENGINE = ['/engine/rng.js', '/engine/cards.js'];
++
++function createPageRoutes(ctx) {
++ const { config, sims } = ctx;
++ const r = asyncRouter();
++ const base = () => String(config.baseUrl).replace(/\/+$/, '');
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const webPage = (name, path, description) => seo.jsonLd.webPage({ name, url: `${base()}${path}`, description, siteUrl: base() });
++ const ids = games.catalogue().map((g) => g.id);
++ const plans = Object.fromEntries(Object.entries(sims.PLANS).map(([k, v]) => [k, v.choices]));
++
++ r.get('/', async (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ description: 'Learn casino games, play them with chips worth nothing, and see exactly what each bet costs: exact house edges from open engines, and a million hands simulated in your browser.',
++ jsonLd: homeJsonLd(config),
++ body: raw(views.home({ sims: await sims.count().catch(() => 0) })),
++ }));
++
++ r.get('/play', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Play: every table',
++ description: 'Blackjack, roulette, baccarat, craps and slots with free play chips worth nothing.',
++ jsonLd: [webPage('Play', '/play', 'Every table, with play chips worth nothing.')],
++ body: raw(views.playIndex()),
++ }));
++
++ r.get('/play/:game', (req, res, next) => {
++ if (!ids.includes(req.params.game)) return next();
++ const g = games.get(req.params.game);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: `Play ${g.name}`,
++ description: `${g.name} with free play chips: ${g.rules}`,
++ jsonLd: [webPage(`Play ${g.name}`, `/play/${g.id}`, g.line)],
++ scripts: [...ENGINE, `/engine/${g.id}.js`, 'js/table.js'],
++ body: raw(views.table({ game: g.id })),
++ });
++ });
++
++ r.get('/learn', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Learn: the rules, the odds, the cost',
++ description: 'The rules, the odds and the house edge of blackjack, roulette, baccarat, craps and slots, worked out by open engines.',
++ jsonLd: [webPage('Learn', '/learn', 'The rules, the odds and the cost of every game.')],
++ body: raw(views.learnIndex()),
++ }));
++
++ r.get('/learn/:game', (req, res, next) => {
++ if (!ids.includes(req.params.game)) return next();
++ const g = games.get(req.params.game);
++ const best = games.lowest(g);
++ const description = `${g.name} explained: ${g.rules} The cheapest bet, ${best.name}, has a ${best.edge}% house edge.`;
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: `${g.name}: rules, odds and the house edge`, description,
++ jsonLd: [seo.jsonLd.article ? seo.jsonLd.article({ headline: `${g.name}: rules, odds and the house edge`, url: `${base()}/learn/${g.id}`, description }) : webPage(g.name, `/learn/${g.id}`, description)],
++ body: raw(views.learn({ game: g.id })),
++ });
++ });
++
++ r.get('/simulate', (req, res) => {
++ const game = ids.includes(String(req.query.game || '')) ? String(req.query.game) : 'blackjack';
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Simulate: watch the house edge arrive',
++ description: 'Run up to a million rounds of any game in your browser and see what the house keeps, next to the exact figure.',
++ jsonLd: [webPage('Simulate', '/simulate', 'A million rounds in your browser.')],
++ scripts: ['js/simulate.js'],
++ body: raw(views.simulate({ game, choice: String(req.query.choice || '').slice(0, 40), maxShare: config.simulate.maxRounds, plans, meta: sims.meta() })),
++ });
++ });
++
++ r.get('/s/:id', async (req, res, next) => {
++ const sim = await sims.get(req.params.id);
++ if (!sim) return next();
++ return page(req, res, { cache: PUBLIC_CACHE, title: 'A shared run', body: raw(views.shared({ sim })) });
++ });
++
++ r.get('/safe', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Play safe',
++ description: 'OpenVibe.Casino is for learning: no real money. What the house edge means for real money, the signs to stop, and free help.',
++ jsonLd: [webPage('Play safe', '/safe', 'What this is, and where to get help.')],
++ body: raw(views.safe()),
++ }));
++
++ r.get('/developers', (req, res) => page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Casino API',
++ description: 'Games, house edges and simulations as JSON, and the engines as files.',
++ jsonLd: [webPage('The OpenVibe.Casino API', '/developers', 'Games, house edges and simulations as JSON.')],
++ body: raw(views.developers()),
++ }));
++
++ r.use(createDiscoveryRoutes(ctx));
++ return r;
++}
++
++module.exports = { createPageRoutes };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..420d1b8
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * Who is calling /api/v1 — req.principal:
++ *
++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
++ * a person: their Network user token as a Bearer, or this site's session cookie (casino_at).
++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
++ * a Network app, agent or service token for audience openvibe.casino. No Casino route needs one; the shape is
++ * kept so a future capability route can be added in one place.
++ * { kind: 'anonymous' }
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
++ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site
++ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor vote, caption or report.
++ */
++const { serviceAuth, http, ids } = require('openvibe-contracts');
++
++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++// Casino's routes are a person acting for themself, a public read, and staff acting on an entry. No capability.
++const CAPABILITIES = [];
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++function roleClaim(claims) {
++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
++}
++function usernameClaim(claims) {
++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
++}
++
++function requesterOfSub(sub) {
++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
++ return sub;
++}
++
++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
++function sameOrigin(req, baseUrl) {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin';
++ const origin = req.get('origin');
++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
++ return false;
++}
++
++function createPrincipal({ config, keys }) {
++ async function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ const publicKey = await keys.pemForToken(token);
++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
++ }
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
++ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };
++ }
++ const viewer = req.viewer;
++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
++ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
++ }
++ return { principal: { kind: 'anonymous' } };
++ }
++
++ async function middleware(req, res, next) {
++ const r = await resolve(req);
++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
++ req.principal = r.principal;
++ return next();
++ }
++
++ function require_(cap) {
++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Casino's capabilities`);
++ return function guard(req, res, next) {
++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.casino, or send a person\'s Network token as a Bearer.', ctx: req.ov });
++ if (req.principal.kind === 'user') {
++ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'casino.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.casino itself.', ctx: req.ov });
++ return next();
++ }
++ return http.sendProblem(res, 403, 'casino.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
++ };
++ }
++
++ return { middleware, resolve, requireCapability: require_ };
++}
++
++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..e6e845d
---- /dev/null
++++ b/server/http/router.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
++ */
++const express = require('express');
++
++function asyncRouter() {
++ const r = express.Router();
++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
++ const orig = r[method].bind(r);
++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
++ try {
++ const out = h(req, res, next);
++ if (out && typeof out.catch === 'function') out.catch(next);
++ } catch (err) { next(err); }
++ })));
++ }
++ return r;
++}
++
++module.exports = { asyncRouter };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..f2e2002
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -0,0 +1,22 @@
++'use strict';
++
++/**
++ * Account export and deletion → Casino (ADR-033; openvibe-sdk/account-data). Casino keeps nothing about a person: play
++ * chips live in the player's browser and a shared simulation has no name on it, so an export says so and a deletion has
++ * nothing to erase. The consumer still answers both topics, so Network's ledger shows Casino as done.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [];
++
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db,
++ service: 'casino',
++ tables: TABLES,
++ note: 'Casino keeps nothing about you: play chips live in your browser, and a shared simulation has no name on it.',
++ log,
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..4c6686c
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,68 @@
++'use strict';
++
++/**
++ * OpenVibe.Casino — process entry. `node server/index.js`
++ * Listens on PORT (5210) behind nginx (deploy/).
++ */
++const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
++
++/**
++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the
++ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.
++ */
++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
++ return gracefulStop({
++ name: 'OpenVibe.Casino', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
++ close: [
++ () => { for (const t of timers) clearInterval(t); },
++ () => ctx.keys.client.stop(),
++ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
++ () => ctx.s.close(),
++ ...extra,
++ ],
++ });
++}
++
++async function start() {
++ const { app, ctx } = await createApp();
++ const { config } = ctx;
++
++ // Socket activation (2026-10-31): systemd's .socket unit holds the listener across restarts, so a deploy
++ // queues requests in the kernel instead of answering 502. Without the socket unit, the usual listen.
++ const socketActivated = process.env.LISTEN_FDS === '1' && Number(process.env.LISTEN_PID) === process.pid;
++ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, config.host]), () => {
++ console.log(`[OpenVibe.Casino] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
++ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded
++ // only because the first JWKS fetch had not happened.
++ ctx.keys.ensure().catch(() => {});
++ });
++ server.keepAliveTimeout = 65_000;
++ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
++ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
++ ctx.telemetryTick.unref();
++ ctx.keys.client.start();
++
++ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
++ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.
++ const subs = config.drill ? null : startSubscriptions({
++ eventsUrl: config.events.url,
++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
++ secret: config.events.secrets[0] || '',
++ networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ log: ctx.log || console,
++ });
++ const extra = [() => { if (subs) subs.stop(); }];
++ createLifecycle({ server, ctx, extra });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => { console.error('[OpenVibe.Casino] failed to start:', err); process.exit(1); });
++}
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..5055972
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
++ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has
++ * signed in to yet is not degraded only because the first fetch had not happened.
++ */
++const { jwksStatus } = require('openvibe-sdk/auth');
++const { createReadiness } = require('openvibe-shared/ready');
++
++function createServiceReadiness({ s, config, valkey = null, release = null , sso = null}) {
++ return createReadiness({
++ service: 'casino',
++ release,
++ checks: [
++ {
++ // Does the Network accept this site's OAuth client? 'invalid' means the id or the secret is wrong:
++ // the service must not stay ready on a sign-in that cannot work. 'unknown' (the Network did not
++ // answer) is left alone: that is the Network's outage, not this site's configuration.
++ name: 'sso_client', required: true,
++ check: async () => {
++ if (!sso || typeof sso.checkClient !== 'function') return { skipped: 'no SSO client' };
++ const client = await sso.checkClient();
++ if (client === 'invalid') return "the Network refuses this site's OAuth client: check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET";
++ return true;
++ },
++ },
++ {
++ name: 'db', required: true,
++ check: async () => {
++ const r = await s.db.ready();
++ return r.ok ? { ok: true, detail: r.detail } : r.error;
++ },
++ },
++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
++ {
++ name: 'network_jwks', required: false,
++ check: () => {
++ const statuses = jwksStatus();
++ if (!statuses.length) return 'no JWKS clients (misconfigured)';
++ const first = statuses[0];
++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
++ },
++ },
++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
++ ],
++ });
++}
++
++module.exports = { createServiceReadiness };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..2769b87
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
++const raw = (s) => new Raw(s);
++function html(strings, ...values) {
++ let out = strings[0];
++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
++ return raw(out);
++}
++function render(v) {
++ if (v == null || v === false) return '';
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ return esc(v);
++}
++
++const code = (s) => html`<code>${s}</code>`;
++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
++
++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
++ if (!rows.length) return html`<p class="muted">${empty}</p>`;
++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
++}
++
++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
++
++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..a0bc3ab
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,120 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
++ * This is Today's layout with Casino's name, navigation, room accent and assets (copied from OpenVibe.Meme).
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const shell = require('openvibe-shared/shell');
++const rooms = require('openvibe-shared/rooms');
++const ovServe = require('openvibe-shared/serve');
++const cache = require('openvibe-shared/cache-policy');
++const { render, esc } = require('./html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Casino';
++const TAGLINE = 'Learn it, play it, never pay it.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Home', href: '/' },
++ { label: 'Play', href: '/play' },
++ { label: 'Learn', href: '/learn' },
++ { label: 'Simulate', href: '/simulate' },
++ { label: 'Play safe', href: '/safe' },
++];
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
++/**
++ * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles
++ */
++function renderPage(o) {
++ const viewer = o.viewer || { kind: 'anonymous' };
++ const signedIn = viewer.kind === 'user';
++ const pathNow = o.path || '/';
++ const loginNext = encodeURIComponent(pathNow);
++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
++ const nav = {
++ service: 'casino',
++ apiBase: NETWORK_URL,
++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
++ history: { type: 'page', title: o.title || SITE_NAME },
++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
++ sessionUrl: '/auth/me',
++ loginUrl: '/auth/login?next={path}',
++ logoutUrl: '/auth/logout?next={path}',
++ notificationsRealtime: true,
++ };
++ const footer = { service: 'casino', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ const account = signedIn
++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
++ const crumbs = (o.crumbs || []).length
++ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`
++ : '';
++ // A page's own scripts, in order: the engines (/engine/*.js, served as they are) before the page code that uses them.
++ const scripts = (o.scripts || []).map((rel) => `<script src="${rel.startsWith('/') ? esc(rel) : asset(rel)}" defer></script>`).join('\n');
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
++ siteName: SITE_NAME,
++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
++ canonical,
++ robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ jsonLd: (o.jsonLd || []).filter(Boolean),
++ home: '/',
++ navLinks: NAV,
++ navbar: nav,
++ footer,
++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
++${appIcon.headTags({ site: 'casino' })}
++<link rel="stylesheet" href="${ovServe.url('kit.css')}">
++<link rel="stylesheet" href="${ovServe.url('room.css')}">
++<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
++<link rel="stylesheet" href="${asset('css/casino.css')}">
++<script src="${ovServe.url('motion.js')}" defer></script>
++<script src="${asset('js/casino.js')}" defer></script>
++${scripts}
++<meta name="ov-boost" content="casino@${require('./html').esc(RELEASE)}">
++<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
++ body: `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
++<main id="main" class="cs ov-room" style="${rooms.style('casino')}">
++${crumbs}
++${render(o.body)}
++</main>
++<script>
++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
++document.addEventListener('DOMContentLoaded', function () {
++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
++});
++</script>`,
++ });
++}
++
++/** Send a page; a page rendered for a signed-in person is private and never cached. */
++function send(res, status, o) {
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
++ res.set('Vary', 'Cookie');
++ res.status(status).type('html').send(renderPage(o));
++}
++
++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
ddiff --git a/server/sims.js b/server/sims.js
nnew file mode 100644
iindex 0000000..47db020
---- /dev/null
++++ b/server/sims.js
@@@ -0,0 +1,89 @@
++'use strict';
++
++/**
++ * Simulations on the server (POST /api/v1/simulate): a game, a bet or strategy, a seed and a number of rounds, run by the
++ * same engine the browser uses, bounded so one request cannot hold the process. A shared one is kept (casino_sims) so
++ * its link (/s/<id>) opens at once and anyone can run it again from the same seed.
++ */
++const crypto = require('crypto');
++const games = require('../engine/games');
++
++const ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
++const newId = () => `sim_${Array.from(crypto.randomBytes(20), (b) => ALPHABET[b & 31]).join('')}`;
++
++// What each game can simulate, and the engine call that does it.
++const PLANS = {
++ blackjack: { choices: ['basic', 'dealer', 'never-bust'], run: (e, c, n, seed) => e.blackjack.houseEdge({ strategy: c, hands: n, seed }) },
++ roulette: { choices: ['european-red', 'american-red', 'european-straight', 'american-top'], run: (e, c, n, seed) => {
++ const [wheel, bet] = c.split('-');
++ const b = bet === 'straight' ? { type: 'straight', numbers: ['17'] } : { type: bet };
++ return e.roulette.houseEdge({ wheel, bet: b, spins: n, seed });
++ } },
++ baccarat: { choices: ['banker', 'player', 'tie'], run: (e, c, n, seed) => e.baccarat.houseEdge({ bet: c, coups: n, seed }) },
++ craps: { choices: ['pass', 'dontPass', 'pass-odds', 'place'], run: (e, c, n, seed) => e.craps.houseEdge({ bet: c === 'pass-odds' ? 'pass' : c, withOdds: c === 'pass-odds', decisions: n, seed }) },
++ slots: { choices: ['spin'], run: (e, c, n, seed) => e.slots.houseEdge({ spins: n, seed }) },
++};
++
++// Each choice's name and the catalogue bet whose exact edge it should settle on (the simulator's reference line).
++const CHOICE_META = {
++ 'blackjack:basic': ['Basic strategy', 'basic'], 'blackjack:dealer': ['Play like the dealer', 'dealer'], 'blackjack:never-bust': ['Never bust', 'never-bust'],
++ 'roulette:european-red': ['Red, European wheel', 'european'], 'roulette:american-red': ['Red, American wheel', 'american'],
++ 'roulette:european-straight': ['One number, European wheel', 'european'], 'roulette:american-top': ['The top line, American wheel', 'american-top'],
++ 'baccarat:banker': ['Banker', 'banker'], 'baccarat:player': ['Player', 'player'], 'baccarat:tie': ['Tie', 'tie'],
++ 'craps:pass': ['Pass line', 'pass'], 'craps:dontPass': ['Don\'t pass', 'dontPass'], 'craps:pass-odds': ['Pass line with full odds (per line bet)', 'pass'], 'craps:place': ['Place 6', 'place6'],
++ 'slots:spin': ['One spin', 'spin'],
++};
++/** { labels, refs } keyed "game:choice" for the simulator page. */
++function simMeta() {
++ const labels = {}; const refs = {};
++ for (const [key, [label, betId]] of Object.entries(CHOICE_META)) {
++ labels[key] = label;
++ const g = games.get(key.split(':')[0]);
++ const b = g && g.bets.find((x) => x.id === betId);
++ if (b) refs[key] = b.edge;
++ }
++ return { labels, refs };
++}
++
++function createSims({ config, s }) {
++ const max = config.simulate.maxRounds;
++
++ /** Validate a request; returns { game, choice, rounds, seed } or throws { status, code, message }. */
++ function parse(body = {}) {
++ const fail = (message) => Object.assign(new Error(message), { status: 400, code: 'casino.invalid' });
++ const game = String(body.game || '');
++ const plan = PLANS[game];
++ if (!plan) throw fail(`game is one of ${Object.keys(PLANS).join(', ')}`);
++ const choice = String(body.choice || plan.choices[0]);
++ if (!plan.choices.includes(choice)) throw fail(`choice for ${game} is one of ${plan.choices.join(', ')}`);
++ const rounds = Math.floor(Number(body.rounds || 100000));
++ if (!Number.isFinite(rounds) || rounds < 1000 || rounds > max) throw fail(`rounds is between 1,000 and ${max.toLocaleString('en-US')}`);
++ const seed = body.seed == null ? crypto.randomInt(1, 2 ** 31) : Math.floor(Number(body.seed));
++ if (!Number.isSafeInteger(seed) || seed < 0 || seed >= 2 ** 32) throw fail('seed is a whole number from 0 to 4294967295');
++ return { game, choice, rounds, seed };
++ }
++
++ function run(p) {
++ const r = PLANS[p.game].run(games.engines, p.choice, p.rounds, p.seed);
++ const round = (x) => Math.round(x * 1000) / 1000;
++ return { game: p.game, choice: p.choice, rounds: p.rounds, seed: p.seed, edge_percent: round(r.percent), stderr_percent: round(r.stderr) };
++ }
++
++ async function share(params, result) {
++ const id = newId();
++ await s.db.exec('INSERT INTO casino_sims (id, game, params, result) VALUES ($1, $2, $3::jsonb, $4::jsonb)', [id, params.game, JSON.stringify(params), JSON.stringify(result)]);
++ return id;
++ }
++
++ async function get(id) {
++ if (!/^sim_[a-z2-7]{20}$/.test(String(id || ''))) return null;
++ const row = await s.db.maybe('SELECT id, game, params, result, created_at FROM casino_sims WHERE id = $1', [id]);
++ return row ? { id: row.id, game: row.game, params: typeof row.params === 'string' ? JSON.parse(row.params) : row.params, result: typeof row.result === 'string' ? JSON.parse(row.result) : row.result, created_at: row.created_at } : null;
++ }
++
++ async function count() { return Number((await s.db.maybe('SELECT count(*)::int AS n FROM casino_sims')).n) || 0; }
++
++ return { PLANS, parse, run, share, get, count, meta: simMeta };
++}
++
++module.exports = { createSims, PLANS, CHOICE_META, simMeta };
ddiff --git a/server/views/index.js b/server/views/index.js
nnew file mode 100644
iindex 0000000..004bbc0
---- /dev/null
++++ b/server/views/index.js
@@@ -0,0 +1,271 @@
++'use strict';
++
++/**
++ * Casino's pages (plan 2026-10-28 §5.14: learn it, play it, never pay it). Every number on them comes from the engines
++ * (engine/games.js), and every page reads whole without JavaScript; the tables and the simulator are the parts that
++ * need it, and say so.
++ *
++ * home() the motif, every bet's house edge on one ladder, a real bankroll chart, what you can do, questions
++ * playIndex() every table
++ * table({ game }) one table's shell (public/js/casino.js deals the cards)
++ * learnIndex(), learn({ game }) the rules, the odds and each bet's edge, with the engine's own tables
++ * simulate({ game, choice }) the simulator (a Web Worker runs the engines)
++ * shared({ sim }) a shared simulation
++ * safe() play safe: what this is, and where to get help
++ * developers(), notFound()
++ */
++const sc = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
++const { esc } = require('../render/html');
++const games = require('../../engine/games');
++
++const HELP = [
++ { name: '1-800-GAMBLER', where: 'United States, free and confidential, any hour', href: 'https://www.1800gambler.net/' },
++ { name: 'BeGambleAware', where: 'United Kingdom: support, tools and the National Gambling Helpline', href: 'https://www.begambleaware.org/' },
++ { name: 'Gamblers Anonymous', where: 'meetings worldwide', href: 'https://www.gamblersanonymous.org/' },
++];
++const GAME_TONE = { blackjack: '#2f9e5b', roulette: '#c2410c', baccarat: '#7c3aed', craps: '#0e7490', slots: '#b45309' };
++const pct = (x, d = 2) => `${Number(x).toFixed(d)}%`;
++
++/** A calm line under every table and on the home: what this is, and where help is. */
++function safeLine() {
++ return `<p class="cs-safe-line">${kit.icon('heart')}<span>Play chips are free and worth nothing. Real gambling costs real money; if it stops being fun, <a href="/safe">help is one call away</a>.</span></p>`;
++}
++
++/** The room's motif: a fan of cards, a short stack of chips, and the probability curve drawing beneath them. */
++function motif() {
++ const card = (rank, suit, red, n) => `<span class="cs-mc cs-mc-${n}${red ? ' is-red' : ''}"><b>${rank}</b><i>${suit}</i></span>`;
++ return `<div class="cs-motif" aria-hidden="true">
++<div class="cs-fan">${card('A', '♠', false, 1)}${card('K', '♥', true, 2)}${card('Q', '♦', true, 3)}${card('J', '♣', false, 4)}</div>
++<div class="cs-chips"><span></span><span></span><span></span><span></span></div>
++<svg class="cs-curve" viewBox="0 0 300 80" preserveAspectRatio="none"><path d="M0 76 C 60 74, 90 70, 120 50 S 160 6, 180 6 S 220 40, 250 66 S 290 76, 300 76" fill="none" stroke="currentColor" stroke-width="2.5" pathLength="100"/></svg>
++</div>`;
++}
++
++/** Every bet of every game on one ladder, cheapest first: the page's real content, straight from the engines. */
++function edgeLadder() {
++ const rows = [];
++ for (const g of games.catalogue()) for (const b of g.bets) rows.push({ game: g, bet: b });
++ rows.sort((a, b) => a.bet.edge - b.bet.edge);
++ const max = Math.max(...rows.map((r) => r.bet.edge));
++ return `<section class="cs-section" id="cs-ladder" aria-labelledby="cs-ladder-h">
++<div class="cs-head"><h2 id="cs-ladder-h">What each bet costs</h2><p>What the house keeps from every 100 chips bet, over the long run. Worked out by the engines this site plays with; the published figures agree.</p></div>
++<ol class="cs-ladder">${rows.map(({ game, bet }) => `<li style="--tone:${GAME_TONE[game.id]};--w:${Math.max(0.6, (bet.edge / max) * 100).toFixed(1)}%"><a href="/learn/${game.id}"><span class="cs-l-game">${esc(game.name)}</span><span class="cs-l-bet"><span class="cs-l-g2">${esc(game.name)} · </span>${esc(bet.name)}</span><span class="cs-l-bar"><i></i></span><b class="cs-l-edge">${pct(bet.edge)}</b></a></li>`).join('')}</ol>
++<p class="cs-fine">Blackjack's figure assumes perfect basic strategy; every other figure is exact. ${games.catalogue().map((g) => `<a href="${esc(g.source.url)}" rel="noopener">${esc(g.source.name)}</a>`).join(' · ')}.</p>
++</section>`;
++}
++
++/** Five real bankroll paths: basic-strategy blackjack, 1,000 chips, 400 hands of 10 each, one seed per path. */
++function bankrollChart() {
++ const { blackjack } = games.engines;
++ const W = 320; const H = 150; const HANDS = 400; const START = 1000; const BET = 10;
++ const paths = [];
++ let lo = START; let hi = START;
++ for (let p = 0; p < 5; p += 1) {
++ const game = blackjack.createGame({ seed: 1031 + p * 7919 });
++ let bank = START; const pts = [bank];
++ for (let i = 0; i < HANDS; i += 1) {
++ game.start(BET);
++ if (game.phase === 'insurance') game.act('no-insurance');
++ while (game.phase === 'player') game.act(game.hint());
++ bank += game.outcome.net; pts.push(bank);
++ }
++ lo = Math.min(lo, ...pts); hi = Math.max(hi, ...pts);
++ paths.push(pts);
++ }
++ const y = (v) => (H - 8 - ((v - lo) / Math.max(1, hi - lo)) * (H - 16)).toFixed(1);
++ const line = (pts) => pts.map((v, i) => `${i ? 'L' : 'M'}${((i / HANDS) * W).toFixed(1)} ${y(v)}`).join(' ');
++ const ends = paths.map((p) => p[p.length - 1]);
++ return `<figure class="cs-bank" role="img" aria-label="Five players' chips over 400 hands of blackjack with basic strategy: they end at ${ends.join(', ')} of 1,000">
++<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"><line x1="0" x2="${W}" y1="${y(START)}" y2="${y(START)}" class="cs-bank-start"/>${paths.map((p, i) => `<path d="${line(p)}" class="cs-bank-p cs-bank-p${i}" pathLength="100"/>`).join('')}</svg>
++<figcaption>Five players, 400 hands of basic strategy, 1,000 chips each. They end at ${ends.map((v) => v.toLocaleString('en-US')).join(', ')}.</figcaption>
++</figure>`;
++}
++
++function chipStack() {
++ return `<div class="cs-stack" aria-hidden="true"><span class="c1">5</span><span class="c2">25</span><span class="c3">100</span><span class="c4">500</span><b>1,000 chips a day · worth nothing</b></div>`;
++}
++
++/** The engine's own basic-strategy chart for hard totals: the cell the player should learn first. */
++function hardChart({ compact = false } = {}) {
++ const { blackjack } = games.engines;
++ const ups = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
++ const card = (r) => ({ rank: r, suit: 0 });
++ const totals = compact ? [9, 10, 11, 12, 13, 16] : [8, 9, 10, 11, 12, 13, 14, 15, 16, 17];
++ const handFor = (t) => (t <= 10 ? [card(2), card(t - 2)] : t <= 19 ? [card(10), card(t - 10)] : [card(10), card(10)]);
++ const label = { H: 'H', S: 'S', D: 'D', Ds: 'D', P: 'P' };
++ return `<div class="cs-chart-wrap"><table class="cs-chart"><caption>Hard totals: what basic strategy says against the dealer's card</caption><thead><tr><th scope="col">You</th>${ups.map((u) => `<th scope="col">${u === 11 ? 'A' : u}</th>`).join('')}</tr></thead><tbody>${totals.map((t) => `<tr><th scope="row">${t}</th>${ups.map((u) => { const c = blackjack.chart(handFor(t), u, false); return `<td class="is-${label[c]}">${label[c]}</td>`; }).join('')}</tr>`).join('')}</tbody></table><p class="cs-fine">H hit · S stand · D double (hit when you cannot)</p></div>`;
++}
++
++function home({ sims = 0 } = {}) {
++ const bj = games.get('blackjack'); const bac = games.get('baccarat'); const rou = games.get('roulette');
++ const room = sc.room({
++ room: 'casino',
++ hero: {
++ title: 'Learn it, play it, never pay it',
++ lede: 'Every casino game with its insides showing: the rules, the exact odds, and what each bet really costs. Play with chips worth nothing, and watch a million hands play out in seconds.',
++ start: { href: '/play', label: 'Pick a table' },
++ chips: [{ label: 'Learn the odds', href: '/learn' }, { label: 'Simulate', href: '/simulate' }, { label: 'Play safe', href: '/safe' }],
++ motif: motif(),
++ },
++ // Real numbers only (the room kit leaves a zero out): what the cheapest bets cost, from the engines.
++ proof: [
++ { value: pct(bj.bets[0].edge), label: 'blackjack, basic strategy' },
++ { value: pct(bac.bets[0].edge), label: 'baccarat, banker' },
++ { value: pct(rou.bets[0].edge), label: 'European roulette' },
++ ...(sims ? [{ value: sims, label: sims === 1 ? 'shared run' : 'shared runs' }] : []),
++ ],
++ scenes: [
++ { title: 'Play with nothing at stake', text: 'A thousand play chips every day, free, gone at midnight. They cannot be bought, won as anything, or cashed out, so the only thing on the table is learning how the game works.', html: chipStack(), action: { href: '/play', label: 'Pick a table' } },
++ { title: 'Watch the house edge happen', text: 'Run a million hands in your browser and see what the house keeps, measured, next to the exact figure. Short runs swing wildly; long ones never miss.', html: bankrollChart(), action: { href: '/simulate', label: 'Run a simulation' } },
++ { title: 'Learn the move, not a superstition', text: 'Basic strategy is a table worked out from the cards, not a feeling. The table plays it as a hint when you want one, and Learn shows why each cell is what it is.', html: hardChart({ compact: true }), action: { href: '/learn/blackjack', label: 'Learn blackjack' } },
++ ],
++ faq: { items: [
++ ['Is this gambling?', 'No. Play chips cannot be bought, sold, won as anything or cashed out, and a fresh stack arrives each day. This is a place to learn how the games work and what they cost.'],
++ ['Where do the numbers come from?', 'From the same engines the tables play with. Roulette, baccarat, craps and the slot machine are worked out exactly; blackjack strategy is simulated. Each figure sits next to the published one it is checked against.'],
++ ['Is there a way to win in the long run?', 'Not with these bets: every one of them has a house edge, and the simulator shows it arriving. The free odds bet in craps is the only one at 0%, and it is only allowed next to a bet that is not.'],
++ ['What if gambling is a problem for me?', 'Call 1-800-GAMBLER in the United States, or see BeGambleAware in the United Kingdom. Both are free and confidential. The Play safe page has more.'],
++ ] },
++ });
++ const at = room.indexOf('<section class="sc-room-scenes"');
++ const page = at < 0 ? edgeLadder() + room : room.slice(0, at) + edgeLadder() + room.slice(at);
++ return `<div class="cs-wrap cs-home" id="cs-home">${page}${safeLine()}</div>`;
++}
++
++function gameCard(g, href) {
++ const best = games.lowest(g);
++ return `<li class="cs-game" style="--tone:${GAME_TONE[g.id]}"><a href="${href}"><span class="cs-game-name">${esc(g.name)}</span><span class="cs-game-line">${esc(g.line)}</span><span class="cs-game-edge">From <b>${pct(best.edge)}</b> house edge</span></a></li>`;
++}
++
++function playIndex() {
++ return `<div class="cs-wrap cs-page" id="cs-play">
++<header class="cs-page-h"><p class="cs-kicker">Play</p><h1>Pick a table</h1><p>Every table deals from the same engine Learn and the simulator use. A thousand play chips a day, worth nothing.</p></header>
++<ul class="cs-games">${games.catalogue().map((g) => gameCard(g, `/play/${g.id}`)).join('')}</ul>
++${safeLine()}</div>`;
++}
++
++function table({ game }) {
++ const g = games.get(game);
++ const best = games.lowest(g);
++ return `<div class="cs-wrap cs-page cs-table-page" id="cs-table" data-game="${esc(g.id)}">
++<header class="cs-page-h cs-table-h"><div><p class="cs-kicker">Play · ${esc(g.name)}</p><h1>${esc(g.name)}</h1><p>${esc(g.rules)}</p></div>
++<div class="cs-wallet" data-cs-wallet><span>Play chips</span><b data-cs-chips>1,000</b><small data-cs-reset>A fresh 1,000 each day</small></div></header>
++<div class="cs-felt" data-cs-felt style="--tone:${GAME_TONE[g.id]}">
++<noscript><p class="cs-noscript">The table deals with JavaScript. Everything about ${esc(g.name)}, its odds and each bet's cost is on <a href="/learn/${esc(g.id)}">Learn</a>, without it.</p></noscript>
++</div>
++<aside class="cs-table-side">
++<p class="cs-edge-now">Cheapest bet here: <b>${esc(best.name)}</b>, ${pct(best.edge)} house edge. <a href="/learn/${esc(g.id)}">Why</a> · <a href="/simulate?game=${esc(g.id)}">Simulate it</a></p>
++<p class="cs-session" data-cs-session hidden></p>
++</aside>
++${safeLine()}
++<div class="cs-gate" data-cs-gate hidden role="dialog" aria-modal="true" aria-labelledby="cs-gate-h"><div class="cs-gate-box">
++<h2 id="cs-gate-h">For adults, and for learning</h2>
++<p>The tables are for people 18 or older. The chips are free and worth nothing; this is a place to learn how the games work and what they cost.</p>
++<div class="cs-gate-go"><button type="button" class="cs-btn" data-cs-adult>I am 18 or older</button><a class="cs-btn is-quiet" href="/learn/${esc(g.id)}">Learn instead</a></div>
++</div></div>
++</div>`;
++}
++
++function edgeTable(g) {
++ return `<div class="cs-chart-wrap"><table class="cs-bets"><caption>What each bet costs</caption><thead><tr><th scope="col">Bet</th><th scope="col">House edge</th><th scope="col">How we know</th></tr></thead><tbody>${g.bets.slice().sort((a, b) => a.edge - b.edge).map((b) => `<tr><th scope="row">${esc(b.name)}</th><td>${pct(b.edge, 3)}</td><td>${esc(b.how)}</td></tr>`).join('')}</tbody></table></div>`;
++}
++
++const LEARN = {
++ blackjack: () => {
++ const { blackjack } = games.engines;
++ const soft = [13, 14, 15, 16, 17, 18, 19];
++ const ups = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
++ const lab = { H: 'H', S: 'S', D: 'D', Ds: 'Ds', P: 'P' };
++ const softTable = `<div class="cs-chart-wrap"><table class="cs-chart"><caption>Soft totals (an ace counted as 11)</caption><thead><tr><th scope="col">You</th>${ups.map((u) => `<th scope="col">${u === 11 ? 'A' : u}</th>`).join('')}</tr></thead><tbody>${soft.map((t) => `<tr><th scope="row">A,${t - 11}</th>${ups.map((u) => { const c = blackjack.chart([{ rank: 1, suit: 0 }, { rank: t - 11, suit: 0 }], u, false); return `<td class="is-${c === 'Ds' ? 'D' : c}">${lab[c]}</td>`; }).join('')}</tr>`).join('')}</tbody></table><p class="cs-fine">Ds double, or stand when you cannot</p></div>`;
++ const pairs = [11, 10, 9, 8, 7, 6, 5, 4, 3, 2];
++ const pairTable = `<div class="cs-chart-wrap"><table class="cs-chart"><caption>Pairs: split or not</caption><thead><tr><th scope="col">Pair</th>${ups.map((u) => `<th scope="col">${u === 11 ? 'A' : u}</th>`).join('')}</tr></thead><tbody>${pairs.map((p) => { const r = p === 11 ? 1 : p; return `<tr><th scope="row">${p === 11 ? 'A,A' : `${p},${p}`}</th>${ups.map((u) => { const c = blackjack.chart([{ rank: r, suit: 0 }, { rank: r, suit: 1 }], u, true); return `<td class="is-${c === 'P' ? 'P' : '-'}">${c === 'P' ? 'P' : ''}</td>`; }).join('')}</tr>`; }).join('')}</tbody></table><p class="cs-fine">P split; an empty cell plays the hand as a total</p></div>`;
++ return `<h2>The rules here</h2><p>You and the dealer each get two cards; one of the dealer's is face down. Closest to 21 without going over wins. Face cards count 10, an ace 1 or 11. The dealer must draw to 16 and stand on 17. A blackjack, an ace and a ten, pays 3 to 2.</p>
++<h2>Basic strategy</h2><p>For every hand you can hold against every card the dealer can show, one move loses least over time. These tables come from the engine the tables play with; the hint at the table shows the same move.</p>${hardChart()}${softTable}${pairTable}`;
++ },
++ roulette: () => `<h2>One price for everything</h2><p>A bet on n numbers pays 36 ÷ n − 1 to one, as if there were only 36 pockets. The zeros are what the house keeps: 1 pocket in 37 on a European wheel (2.70%), 2 in 38 on an American one (5.26%). The American top line, 0, 00, 1, 2 and 3, pays 6 to 1 for five numbers and costs 7.89%.</p>
++<h2>What a bet covers and pays</h2><div class="cs-chart-wrap"><table class="cs-bets"><thead><tr><th scope="col">Bet</th><th scope="col">Numbers</th><th scope="col">Pays</th><th scope="col">Wins on a European wheel</th></tr></thead><tbody>${[['Straight', 1], ['Split', 2], ['Street', 3], ['Corner', 4], ['Six line', 6], ['Dozen or column', 12], ['Red, black, odd, even, low, high', 18]].map(([n, k]) => `<tr><th scope="row">${n}</th><td>${k}</td><td>${36 / k - 1} to 1</td><td>${(k / 37 * 100).toFixed(2)}%</td></tr>`).join('')}</tbody></table></div>`,
++ baccarat: () => `<h2>Nothing to decide but the bet</h2><p>Two hands, the player and the banker, get two cards each; only the last digit of the total counts. An 8 or 9 is a natural and stands. Otherwise the player draws on 0 to 5, and the banker draws by the tableau below. The banker bet wins a little more often, so it pays 0.95 to 1.</p>
++<div class="cs-chart-wrap"><table class="cs-chart"><caption>When the banker draws, by the player's third card</caption><thead><tr><th scope="col">Banker</th>${['none', 0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((v) => `<th scope="col">${v}</th>`).join('')}</tr></thead><tbody>${[0, 1, 2, 3, 4, 5, 6, 7].map((b) => `<tr><th scope="row">${b}</th>${[null, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((p) => { const d = games.engines.baccarat.bankerDraws(b, p); return `<td class="is-${d ? 'H' : 'S'}">${d ? 'Draw' : ''}</td>`; }).join('')}</tr>`).join('')}</tbody></table></div>`,
++ craps: () => { const { WAYS } = games.engines.craps; return `<h2>Two dice, 36 ways</h2><p>Seven is the most likely total, six ways in 36. The pass line wins at once on 7 or 11 and loses on 2, 3 or 12; any other total becomes the point, and the point must come again before a 7. Free odds behind the line pay exactly the true odds, so they cost nothing.</p>
++<div class="cs-dice-odds">${[2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((t) => `<span style="--h:${WAYS[t]}"><i></i><b>${t}</b><small>${WAYS[t]}/36</small></span>`).join('')}</div>`; },
++ slots: () => { const sl = games.engines.slots; const x = sl.exact(); return `<h2>The machine, opened</h2><p>Three reels of 24 stops, every stop equally likely, so every one of the 13,824 spins can be counted. This one pays back ${(x.rtp * 100).toFixed(2)}% of what goes in over time and wins something on ${(x.hitRate * 100).toFixed(1)}% of spins; most of those wins are smaller than the bet.</p>
++<div class="cs-chart-wrap"><table class="cs-bets"><caption>The paytable and how often each win comes</caption><thead><tr><th scope="col">Pays</th><th scope="col">Chance on a spin</th><th scope="col">About once every</th></tr></thead><tbody>${x.wins.map((w) => `<tr><th scope="row">${w.pay} to 1</th><td>${(w.chance * 100).toFixed(3)}%</td><td>${Math.round(1 / w.chance).toLocaleString('en-US')} spins</td></tr>`).join('')}</tbody></table></div>
++<div class="cs-reels">${sl.STRIPS.map((s, i) => `<div class="cs-reel"><b>Reel ${i + 1}</b><ol>${s.map((sym) => `<li class="sym-${sym}">${sym}</li>`).join('')}</ol></div>`).join('')}</div>`; },
++};
++
++function learnIndex() {
++ return `<div class="cs-wrap cs-page" id="cs-learn">
++<header class="cs-page-h"><p class="cs-kicker">Learn</p><h1>The rules, the odds, the cost</h1><p>Each game explained with its engine's own numbers: what every bet pays, how often it wins, and what the house keeps.</p></header>
++<ul class="cs-games">${games.catalogue().map((g) => gameCard(g, `/learn/${g.id}`)).join('')}</ul>
++${edgeLadder()}${safeLine()}</div>`;
++}
++
++function learn({ game }) {
++ const g = games.get(game);
++ return `<div class="cs-wrap cs-page cs-learn-page" id="cs-learn-game" data-game="${esc(g.id)}" style="--tone:${GAME_TONE[g.id]}">
++<header class="cs-page-h"><p class="cs-kicker">Learn · ${esc(g.name)}</p><h1>${esc(g.name)}: the rules, the odds and the house edge</h1><p>${esc(g.line)}</p>
++<div class="cs-acts"><a class="cs-btn" href="/play/${esc(g.id)}">Play ${esc(g.name)}</a><a class="cs-btn is-quiet" href="/simulate?game=${esc(g.id)}">Simulate it</a></div></header>
++<div class="cs-prose">${edgeTable(g)}${LEARN[g.id]()}<p class="cs-fine">Checked against: <a href="${esc(g.source.url)}" rel="noopener">${esc(g.source.name)}</a>.</p></div>
++${safeLine()}</div>`;
++}
++
++function simulate({ game = 'blackjack', choice = '', maxShare = 200000, plans = {}, meta = { labels: {}, refs: {} } } = {}) {
++ const options = games.catalogue().map((g) => `<option value="${esc(g.id)}"${g.id === game ? ' selected' : ''}>${esc(g.name)}</option>`).join('');
++ return `<div class="cs-wrap cs-page" id="cs-sim" data-max-share="${Number(maxShare)}" data-plans="${esc(JSON.stringify(plans))}" data-refs="${esc(JSON.stringify(meta.refs))}" data-labels="${esc(JSON.stringify(meta.labels))}">
++<header class="cs-page-h"><p class="cs-kicker">Simulate</p><h1>Watch the house edge arrive</h1><p>Your browser plays the game with the same engine as the tables, as many rounds as you ask for, and shows what the house kept next to the exact figure.</p></header>
++<form class="cs-sim-form" data-cs-sim action="/api/v1/simulate" method="post">
++<label>Game<select name="game" data-cs-sim-game>${options}</select></label>
++<label>Bet or strategy<select name="choice" data-cs-sim-choice data-initial="${esc(choice)}"></select></label>
++<label>Rounds<select name="rounds"><option value="10000">10,000</option><option value="100000" selected>100,000</option><option value="1000000">1,000,000</option></select></label>
++<label>Seed<input name="seed" inputmode="numeric" pattern="[0-9]*" placeholder="any number"></label>
++<button class="cs-btn" type="submit">Run it</button>
++</form>
++<div class="cs-sim-out" data-cs-sim-out>
++<div class="cs-progress" data-cs-progress hidden><i></i></div>
++<canvas class="cs-sim-chart" data-cs-chart width="720" height="260" aria-label="The house's share of the money bet, as the rounds go by"></canvas>
++<p class="cs-sim-result" data-cs-result aria-live="polite">Pick a game and run it. A short run swings; a long one settles on the edge.</p>
++<button class="cs-btn is-quiet" type="button" data-cs-share hidden>Share this run</button>
++</div>
++<noscript><p class="cs-noscript">The simulator runs in your browser with JavaScript. Without it, the API runs a shorter one: POST /api/v1/simulate.</p></noscript>
++${safeLine()}</div>`;
++}
++
++function shared({ sim }) {
++ const g = games.get(sim.game);
++ const r = sim.result;
++ return `<div class="cs-wrap cs-page" id="cs-shared">
++<header class="cs-page-h"><p class="cs-kicker">A shared run · ${esc(g.name)}</p><h1>Over ${Number(r.rounds).toLocaleString('en-US')} rounds the house kept ${pct(r.edge_percent)}</h1>
++<p>${esc(g.name)}, ${esc(r.choice)}, seed ${esc(r.seed)}. The margin of error is about ±${pct(r.stderr_percent * 2)}. Run it again from the same seed and you get the same rounds.</p>
++<div class="cs-acts"><a class="cs-btn" href="/simulate?game=${esc(g.id)}&amp;choice=${encodeURIComponent(r.choice)}">Run your own</a><a class="cs-btn is-quiet" href="/learn/${esc(g.id)}">Why it costs that</a></div></header>
++${safeLine()}</div>`;
++}
++
++function safe() {
++ return `<div class="cs-wrap cs-page cs-narrow" id="cs-safe">
++<header class="cs-page-h"><p class="cs-kicker">Play safe</p><h1>This is for learning</h1><p>OpenVibe.Casino has no real money in it, and never will without saying so plainly first. Play chips are free, arrive fresh each day, and are worth nothing anywhere.</p></header>
++<div class="cs-prose">
++<h2>What the numbers mean for real money</h2>
++<p>Every bet in a casino has a house edge, and over enough play it is what you lose. A 2.7% edge on 100 bets of $10 is about $27 gone on average, and some nights much more. Nothing here teaches a way around that, because there is none.</p>
++<h2>Signs it may be time to stop</h2>
++<ul><li>Playing to win back what you lost</li><li>Spending more time or money than you meant to</li><li>Hiding it, or borrowing for it</li><li>Feeling restless or low when you are not playing</li></ul>
++<h2>Free, confidential help</h2>
++<ul class="cs-help">${HELP.map((h) => `<li><a href="${esc(h.href)}" rel="noopener">${esc(h.name)}</a><span>${esc(h.where)}</span></li>`).join('')}</ul>
++<p>The tables remind you how long you have been playing every 30 minutes.</p>
++</div></div>`;
++}
++
++function developers() {
++ return `<div class="cs-wrap cs-page cs-narrow" id="cs-dev">
++<header class="cs-page-h"><p class="cs-kicker">Developers</p><h1>The OpenVibe.Casino API</h1><p>Open, no key: every game, its rules and each bet's house edge, and simulations from the same engines. <a href="/api/v1/openapi.json">OpenAPI</a>.</p></header>
++<div class="cs-prose">
++<h2>GET /api/v1/games</h2><p>Every game with its rules, each bet's edge in percent, how the figure was found (exact or simulated) and the published source it is checked against.</p>
++<h2>POST /api/v1/simulate</h2><p><code>{ "game": "craps", "choice": "pass", "rounds": 100000, "seed": 42, "share": true }</code>: runs on the server, up to the configured number of rounds, a few a minute per address. With <code>share</code> it keeps the run and answers its link.</p>
++<h2>The engines</h2><p>The same files the server, the tables and the simulator run: <code>/engine/rng.js</code>, <code>cards.js</code>, <code>blackjack.js</code>, <code>roulette.js</code>, <code>baccarat.js</code>, <code>craps.js</code>, <code>slots.js</code>. Each is deterministic from a seed. The source is AGPL-3.0 on GitHub.</p>
++</div></div>`;
++}
++
++function notFound() {
++ return `<div class="cs-wrap cs-page cs-narrow" id="cs-not-found"><header class="cs-page-h"><p class="cs-kicker">404</p><h1>No table here</h1><p>That address is not a page on OpenVibe.Casino.</p><div class="cs-acts"><a class="cs-btn" href="/">Back to the floor</a></div></header></div>`;
++}
++
++module.exports = { home, playIndex, table, learnIndex, learn, simulate, shared, safe, developers, notFound, edgeLadder, GAME_TONE, HELP };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..a9d02cf
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,91 @@
++'use strict';
++/**
++ * ADR-033: Casino's part of an account export and of a deletion, applied through /internal/events with a stand-in
++ * Network. Casino keeps nothing about a person, so the export says so and a deletion is confirmed with nothing to
++ * erase. A redelivery changes nothing; a bad signature and a proxied request are refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const { createNetworkSender, uncovered } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { TABLES } = require('../server/identity/account-data');
++const { testDb } = require('./helpers/db');
++
++const A = ids.newId('user');
++const EXP = `exp_${ids.ulid()}`;
++const DEL = `del_${ids.ulid()}`;
++const SECRET = `fixture_${'account'.repeat(6)}`;
++const WRONG = `fixture_${'mismatch'.repeat(6)}`;
++
++const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
++const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_casino', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++(async () => {
++ await check('every table that names a person is covered or exempted with a reason', async () => {
++ const d = await testDb();
++ try {
++ const missing = await uncovered(d.db, {
++ tables: TABLES,
++ exempt: {account_data_events: 'the ledger of requests itself: which export and deletion casino applied'},
++ });
++ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
++ } finally { await d.close(); }
++ });
++
++ const stub = await startNetworkStub();
++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'casino', clientSecret: 'casino-secret' });
++ const t = await boot({ env: { CASINO_EVENTS_SECRET: SECRET }, accountSend: sender });
++ try {
++ await check('an export says Casino keeps nothing about the person', async () => {
++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
++ assert.strictEqual(res.status, 200, res.text);
++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
++ assert.ok(part, 'the part was pushed to Network');
++ assert.strictEqual(part.auth, 'Bearer tok_casino');
++ assert.strictEqual(part.body.subject, A);
++ assert.match(JSON.stringify(part.body), /keeps nothing about you/);
++ });
++
++ await check('a deletion is confirmed with nothing to erase, and a redelivery changes nothing', async () => {
++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
++ assert.strictEqual(res.status, 200, res.text);
++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
++ assert.ok(confirmation, 'the confirmation was sent');
++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
++ const again = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json().outcome, 'unchanged');
++ });
++
++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
++ assert.strictEqual(bad.status, 401);
++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
++ assert.strictEqual(forwarded.status, 403);
++ });
++ } finally {
++ await t.close();
++ await stub.close();
++ }
++ done();
++})();
ddiff --git a/test/engines.test.js b/test/engines.test.js
nnew file mode 100644
iindex 0000000..e01dcda
---- /dev/null
++++ b/test/engines.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++/**
++ * The engines against the published figures (Wizard of Odds, each game's page): every exact edge to the digit the
++ * figure is published to, the blackjack basic-strategy simulation within its margin of error, and the rules that make
++ * those numbers true (the third-card tableau, the craps line, the roulette layout, the slot strips). A seed always
++ * deals the same cards.
++ */
++const assert = require('assert');
++const { check, done } = require('./helpers/boot');
++const blackjack = require('../engine/blackjack');
++const roulette = require('../engine/roulette');
++const baccarat = require('../engine/baccarat');
++const craps = require('../engine/craps');
++const slots = require('../engine/slots');
++const games = require('../engine/games');
++
++const near = (actual, expected, tol, what) => assert.ok(Math.abs(actual - expected) <= tol, `${what}: ${actual} is not within ${tol} of ${expected}`);
++
++(async () => {
++ await check('roulette: every bet costs the zeros, exactly; the American top line costs 7.89%', () => {
++ near(roulette.edge({ type: 'red' }, 'european') * 100, 2.70, 0.005, 'European');
++ near(roulette.edge({ type: 'straight', numbers: ['17'] }, 'american') * 100, 5.26, 0.005, 'American');
++ near(roulette.edge({ type: 'top' }, 'american') * 100, 7.89, 0.005, 'top line');
++ assert.strictEqual(roulette.covers({ type: 'red' }).length, 18);
++ assert.strictEqual(roulette.covers({ type: 'column', which: 1 }).join(','), '1,4,7,10,13,16,19,22,25,28,31,34');
++ assert.strictEqual(roulette.covers({ type: 'straight', numbers: ['00'] }, 'european'), null, 'no 00 on a European wheel');
++ assert.strictEqual(roulette.payout({ type: 'corner', numbers: ['1', '2', '4', '5'] }), 8);
++ const r = roulette.settle([{ type: 'red', amount: 10 }, { type: 'straight', numbers: ['3'], amount: 1 }], '3');
++ assert.deepStrictEqual([r.colour, r.net], ['red', 10 + 35]);
++ });
++
++ await check('baccarat: the exact edges for eight decks are the published 1.06 / 1.24 / 14.36', () => {
++ const e = baccarat.exactEdges(8).edges;
++ near(e.banker * 100, 1.06, 0.005, 'banker');
++ near(e.player * 100, 1.24, 0.005, 'player');
++ near(e.tie * 100, 14.36, 0.005, 'tie');
++ assert.strictEqual(baccarat.bankerDraws(3, 8), false, 'banker 3 stands on a player 8');
++ assert.strictEqual(baccarat.bankerDraws(6, 7), true, 'banker 6 draws on a player 6 or 7');
++ assert.strictEqual(baccarat.bankerDraws(5, null), true, 'with no player third card, the banker draws on 0-5');
++ assert.strictEqual(baccarat.net('banker', 'banker', 100), 95);
++ assert.strictEqual(baccarat.net('player', 'tie', 100), 0, 'a tie pushes the player and banker bets');
++ });
++
++ await check('craps: pass 1.41%, don\'t pass 1.36%, place 6 1.52%, odds 0%, field 5.56%', () => {
++ const e = craps.exactEdges();
++ near(e.pass * 100, 1.41, 0.005, 'pass');
++ near(e.dontPass * 100, 1.36, 0.005, 'don\'t pass');
++ near(e.place[6] * 100, 1.52, 0.005, 'place 6');
++ near(e.place[5] * 100, 4.00, 0.005, 'place 5');
++ near(e.place[4] * 100, 6.67, 0.005, 'place 4');
++ near(e.field * 100, 5.56, 0.005, 'field');
++ for (const pt of craps.POINTS) near(e.odds[pt], 0, 1e-12, `odds on ${pt}`);
++ const t = craps.createGame({ seed: 3 });
++ assert.strictEqual(t.bet('odds', 5).ok, false, 'no odds before a point');
++ assert.strictEqual(t.bet('place', 5, 7).ok, false, 'no place bet on 7');
++ });
++
++ await check('slots: the RTP is counted over every stop, and spinning agrees', () => {
++ const x = slots.exact();
++ assert.strictEqual(x.spins, 24 ** 3);
++ assert.ok(x.rtp > 0.85 && x.rtp < 0.99, `a believable machine returns 85-99%: ${x.rtp}`);
++ near(x.edge, 1 - x.rtp, 1e-12, 'edge');
++ const sim = slots.houseEdge({ spins: 300000, seed: 4 });
++ near(sim.percent, x.edge * 100, 4 * sim.stderr, 'simulated edge');
++ });
++
++ await check('blackjack: basic strategy lands on the published 0.40% within its margin; naive play costs far more', () => {
++ const r = blackjack.houseEdge({ hands: 600000, seed: 99 });
++ near(r.percent, blackjack.PUBLISHED.percent, Math.max(0.25, 3 * r.stderr), 'basic strategy');
++ const dealer = blackjack.houseEdge({ strategy: 'dealer', hands: 200000, seed: 5 });
++ assert.ok(dealer.percent > 4 && dealer.percent < 7.5, `playing like the dealer costs about 5.5%: ${dealer.percent}`);
++ });
++
++ await check('blackjack: the chart says what every chart says, and a seed deals the same hand', () => {
++ const c = (r) => ({ rank: r, suit: 0 });
++ assert.strictEqual(blackjack.chart([c(10), c(6)], 10, false), 'H', '16 hits a ten');
++ assert.strictEqual(blackjack.chart([c(10), c(2)], 4, false), 'S', '12 stands on a four');
++ assert.strictEqual(blackjack.chart([c(6), c(5)], 10, false), 'D', '11 doubles on a ten');
++ assert.strictEqual(blackjack.chart([c(8), c(8)], 10, true), 'P', 'always split eights');
++ assert.strictEqual(blackjack.chart([c(1), c(7)], 9, false), 'H', 'soft 18 hits a nine');
++ const a = blackjack.createGame({ seed: 42 }); const b = blackjack.createGame({ seed: 42 });
++ assert.deepStrictEqual(a.start(10), b.start(10));
++ const g = blackjack.createGame({ seed: 7 });
++ g.start(10);
++ if (g.phase === 'insurance') g.act('no-insurance');
++ assert.strictEqual(g.act('nonsense').ok, false, 'an action the table does not offer is refused');
++ });
++
++ await check('the catalogue reads every edge from the engines, cheapest first per game', () => {
++ for (const g of games.catalogue()) {
++ assert.ok(g.bets.length && g.source && /^https:\/\//.test(g.source.url), g.id);
++ for (const b of g.bets) assert.ok(Number.isFinite(b.edge) && b.edge >= 0 && b.edge < 20, `${g.id}/${b.id}`);
++ }
++ assert.strictEqual(games.lowest(games.get('craps')).id, 'odds');
++ });
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..f0a8daa
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,95 @@
++'use strict';
++// Many requests per test file: the per-address limits would answer 429 part-way.
++process.env.CASINO_RATE_LIMIT_PER_MIN = process.env.CASINO_RATE_LIMIT_PER_MIN || '100000';
++process.env.CASINO_API_RATE_LIMIT_PER_MIN = process.env.CASINO_API_RATE_LIMIT_PER_MIN || '100000';
++process.env.CASINO_LIMITS_MINUTE = process.env.CASINO_LIMITS_MINUTE || '100000';
++process.env.CASINO_LIMITS_HOUR = process.env.CASINO_LIMITS_HOUR || '100000';
++/**
++ * Boots OpenVibe.Casino on a temp database with a mock Network (test/helpers/mocks.js), captures log lines, and returns
++ * a small HTTP client. Every test file gets its own.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })
++ * t.signIn(user) → cookie header value (casino_at = a Network user token, as after /auth/callback)
++ * t.logs(), t.dbDump()
++ */
++const http = require('http');
++const { startNetwork } = require('./mocks');
++
++const captured = [];
++for (const m of ['log', 'info', 'warn', 'error']) {
++ const orig = console[m].bind(console);
++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
++}
++
++async function boot(opts = {}) {
++ const network = await startNetwork(opts.network || {});
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.casino', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'casino', OV_OAUTH_CLIENT_SECRET: 'casino-secret', COOKIE_SECURE: 'false',
++ CASINO_DRILL: '1',
++ CASINO_EVENTS_URL: network.url,
++
++ ...(opts.env || {}),
++ };
++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
++ const configLib = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
++
++ const config = configLib.load(env);
++ const { createStore } = require('../../server/db');
++ const testdb = await require('./db').testDb();
++ const store = createStore(testdb.db, { now: opts.now });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
++ await built.ctx.keys.ensure();
++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const signIn = (user) => `casino_at=${network.userToken(user)}`;
++
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
++ if (o.cookie) headers.cookie = o.cookie;
++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ const method = o.method || (body ? 'POST' : 'GET');
++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
++ if (o.origin) headers.origin = o.origin;
++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
++ const buf = Buffer.from(await res.arrayBuffer());
++ const text = buf.toString('utf8');
++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
++ }
++
++ async function dbDump() {
++ const db = built.ctx.s.db;
++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
++ }
++
++ const t = {
++ base, network, config, ctx: built.ctx, get, signIn, dbDump,
++ logs: () => captured.join('\n'),
++ async close() {
++ await new Promise((r) => server.close(r));
++ built.ctx.keys.client.stop();
++ await built.ctx.telemetry.stop().catch(() => {});
++ await testdb.close();
++ await network.close();
++ },
++ };
++ return t;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..25da11f
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * CASINO_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
++ */
++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
++const { MIGRATIONS } = require('../../server/db');
++
++const testDb = ({ store = process.env.CASINO_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'casino', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..2d5aba7
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,137 @@
++'use strict';
++/**
++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
++ *
++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `casino`, refresh_token,
++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Casino, kept for parity);
++ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.
++ *
++ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({
++ server, url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
++ }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
++ let issuer = null;
++ let nextUserId = 1;
++
++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
++
++ function addUser(username, { role = 'user' } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
++ st.users.set(u.id, u); st.byUsername.set(username, u);
++ return u;
++ }
++ function userToken(u, { ttl = 3600, iat = null } = {}) {
++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
++ }
++ function userFrom(req) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const parts = h.slice(7).split('.');
++ if (parts.length !== 3) return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (c.exp * 1000 < Date.now()) return 'expired';
++ return st.users.get(c.sub) || null;
++ }
++
++ const srv = await listen(async (req, raw, json) => {
++ const url = new URL(req.url, 'http://x');
++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });
++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
++
++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
++ if (url.pathname === '/oauth/revoke') return json(200, {});
++ if (url.pathname === '/oauth/token' && req.method === 'POST') {
++ const ct = String(req.headers['content-type'] || '');
++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
++ if (body.client_id !== 'casino' || body.client_secret !== 'casino-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (body.grant_type === 'authorization_code') {
++ const c = st.codes.get(body.code);
++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
++ st.codes.delete(body.code);
++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, c.user);
++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'refresh_token') {
++ const user = st.refresh.get(body.refresh_token);
++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
++ st.refresh.delete(body.refresh_token);
++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
++ st.refresh.set(rt, user);
++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
++ }
++ if (body.grant_type === 'client_credentials') {
++ const now = Math.floor(Date.now() / 1000);
++ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);
++ return json(200, {
++ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:casino', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),
++ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),
++ });
++ }
++ return json(400, { error: 'unsupported_grant_type' });
++ }
++
++ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ return json(200, { items: [], next_cursor: null });
++ }
++
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
++
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++
++ return {
++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
++ setDown(v) { st.down = v; },
++ close: srv.close,
++ };
++}
++
++module.exports = { startNetwork, signJwt };
ddiff --git a/test/migration.test.js b/test/migration.test.js
nnew file mode 100644
iindex 0000000..5d895c7
---- /dev/null
++++ b/test/migration.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++/**
++ * The expand migration applies in one pass and creates exactly Casino's table (shared simulations) plus the SDK's account
++ * receipts and telemetry; its constraints refuse an id or a game it does not know.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('0001_initial.sql applies and creates the Casino tables', async () => {
++ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
++ for (const table of ['casino_sims', 'account_data_events', 'telemetry_samples']) assert.ok(names.includes(table), `missing ${table}`);
++ const cols = (await t.ctx.s.db.many("SELECT column_name AS c FROM information_schema.columns WHERE table_name = 'casino_sims'")).map((r) => r.c);
++ assert.ok(!cols.some((c) => /subject|user|person|ip/.test(c)), 'a shared simulation has no name on it');
++ });
++
++ await check('a simulation row refuses an unknown game and a malformed id', async () => {
++ const db = t.ctx.s.db;
++ await assert.rejects(db.exec("INSERT INTO casino_sims (id, game, params, result) VALUES ('sim_aaaaaaaaaaaaaaaaaaaa', 'poker', '{}', '{}')"), /casino_sims_game/);
++ await assert.rejects(db.exec("INSERT INTO casino_sims (id, game, params, result) VALUES ('nope', 'craps', '{}', '{}')"), /casino_sims_id/);
++ await db.exec("INSERT INTO casino_sims (id, game, params, result) VALUES ('sim_aaaaaaaaaaaaaaaaaaaa', 'craps', '{}', '{}')");
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..56fcc91
---- /dev/null
++++ b/test/pages.test.js
@@@ -0,0 +1,86 @@
++'use strict';
++/**
++ * The pages and the API: the floor leads with every bet's cost from the engines (never an empty section), each table
++ * loads its engines before its own code and puts the 18+ gate in front, Learn shows the engine's own tables, the
++ * simulator knows every choice's exact edge, a run can be shared and opened, and the API is bounded and open.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const games = require('../engine/games');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the floor: the edge ladder from the engines, a real bankroll chart, the safe line, public cache', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
++ const rungs = (r.text.match(/<li style="--tone:[^"]+"><a href="\/learn\//g) || []).length;
++ assert.strictEqual(rungs, games.catalogue().reduce((a, g) => a + g.bets.length, 0), 'every bet on the ladder');
++ assert.ok(r.text.indexOf('id="cs-ladder"') < r.text.indexOf('class="sc-room-scenes"'), 'the real data leads');
++ assert.match(r.text, /<figure class="cs-bank" role="img"/);
++ assert.match(r.text, /1-800-GAMBLER|help is one call away/);
++ assert.ok(!/Nothing (is |has )?[^.<]{0,40}yet/.test(r.text), 'no empty section');
++ });
++
++ await check('a table: engines before its code, the gate, the cheapest bet named, a page for every game', async () => {
++ for (const g of games.catalogue()) {
++ const r = await t.get(`/play/${g.id}`);
++ assert.strictEqual(r.status, 200, g.id);
++ const at = (s) => r.text.indexOf(s);
++ assert.ok(at('/engine/rng.js') > 0 && at('/engine/rng.js') < at(`/engine/${g.id}.js`) && at(`/engine/${g.id}.js`) < at('/js/table.js'), `${g.id}: engines load first`);
++ assert.match(r.text, /data-cs-gate hidden role="dialog"/);
++ assert.match(r.text, /<noscript><p class="cs-noscript">/);
++ assert.match(r.text, new RegExp(`Cheapest bet here: <b>${games.lowest(g).name.replace(/[()]/g, '\\$&')}</b>`));
++ }
++ assert.strictEqual((await t.get('/play/poker')).status, 404);
++ });
++
++ await check('Learn: the engine\'s basic-strategy chart and the baccarat tableau', async () => {
++ const bj = await t.get('/learn/blackjack');
++ assert.match(bj.text, /<table class="cs-chart"><caption>Hard totals/);
++ assert.match(bj.text, /Soft totals/);
++ const bc = await t.get('/learn/baccarat');
++ assert.match(bc.text, /When the banker draws/);
++ assert.match(bc.text, /1\.058%/);
++ });
++
++ await check('the simulator knows every choice and its exact edge', async () => {
++ const r = await t.get('/simulate?game=craps');
++ const refs = JSON.parse(r.text.match(/data-refs="([^"]+)"/)[1].replace(/&quot;/g, '"'));
++ assert.strictEqual(refs['craps:pass'], 1.414);
++ assert.strictEqual(Object.keys(refs).length, 15);
++ assert.match(r.text, /<option value="craps" selected>/);
++ });
++
++ await check('the API: games, a bounded simulation, a shared run that opens, problem+json errors', async () => {
++ const list = (await t.get('/api/v1/games')).json();
++ assert.strictEqual(list.games.length, 5);
++ const sim = await t.get('/api/v1/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game: 'roulette', choice: 'european-red', rounds: 50000, seed: 7, share: true }) });
++ assert.strictEqual(sim.status, 201, sim.text);
++ const body = sim.json();
++ assert.match(body.id, /^sim_[a-z2-7]{20}$/);
++ assert.ok(Math.abs(body.result.edge_percent - 2.703) < 1.5, 'a 50,000-spin run lands near 2.70%');
++ const again = (await t.get('/api/v1/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game: 'roulette', choice: 'european-red', rounds: 50000, seed: 7 }) })).json();
++ assert.strictEqual(again.result.edge_percent, body.result.edge_percent, 'the same seed plays the same spins');
++ const page = await t.get(`/s/${body.id}`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /the house kept/);
++ const tooMany = await t.get('/api/v1/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game: 'craps', rounds: 99999999 }) });
++ assert.strictEqual(tooMany.status, 400);
++ assert.match(tooMany.headers.get('content-type'), /problem\+json/);
++ assert.strictEqual((await t.get('/api/v1/sims/sim_nope')).status, 404);
++ assert.strictEqual((await t.get('/api/v1/openapi.json')).json().openapi, '3.1.0');
++ });
++
++ await check('the engines are served as they are, and discovery lists every page', async () => {
++ const e = await t.get('/engine/craps.js');
++ assert.strictEqual(e.status, 200);
++ assert.match(e.text, /OpenVibeCraps/);
++ const map = await t.get('/sitemap.xml');
++ for (const g of games.catalogue()) assert.ok(map.text.includes(`/learn/${g.id}</loc>`) && map.text.includes(`/play/${g.id}</loc>`), g.id);
++ assert.match((await t.get('/robots.txt')).text, /Disallow: \/s\//);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..27af56b
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,20 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
++ * in-process mock of OpenVibe.Network, and in-process Chat/Live/Video fixtures; none needs the network.
++ *
++ * npm test # everything
++ * npm test -- importer # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ */
++'use strict';
++const { run } = require('openvibe-shared/test-runner');
++
++// VERBOSE=1 also prints every file's output, then the summary again.
++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
++ if (process.env.VERBOSE) {
++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
++ console.log(`\n${r.summary}`);
++ }
++ process.exit(r.exitCode);
++}, (err) => { console.error(err); process.exit(1); });
ddiff --git a/test/sso.test.js b/test/sso.test.js
nnew file mode 100644
iindex 0000000..3be8b9a
---- /dev/null
++++ b/test/sso.test.js
@@@ -0,0 +1,180 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256 through openvibe-sdk/sso (0.73.0): the routes, the SDK's own recovery (a lost flow or a
++ * code the Network no longer accepts starts the sign-in once more by itself, and only then shows a page with
++ * "Try again"), the old cookies read as the session, the session-token rules (a FedCM assertion or an app token is
++ * not a session), the open-redirect guard, truthful readiness and cross-site sign-out.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/boot');
++const { sanitizeNext } = require('../server/auth/sso');
++
++const cookieJar = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++
++ await check('login redirects to Network with state and an S256 code challenge, client id casino', async () => {
++ const r = await t.get('/auth/login?next=/week');
++ assert.strictEqual(r.status, 302);
++ const loc = new URL(r.headers.get('location'));
++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
++ assert.strictEqual(loc.searchParams.get('client_id'), 'casino');
++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
++ assert.match(loc.searchParams.get('state'), /^[A-Za-z0-9_-]{32}$/);
++ assert.match(r.headers.get('set-cookie'), /ov_oauth_state=.*Path=\/auth.*HttpOnly/i);
++ });
++
++ await check('login passes ?silent, ?prompt and ?login_hint to the Network', async () => {
++ const silent = new URL((await t.get('/auth/login?silent=1')).headers.get('location'));
++ assert.strictEqual(silent.searchParams.get('prompt'), 'none');
++ const chooser = new URL((await t.get('/auth/login?prompt=select_account')).headers.get('location'));
++ assert.strictEqual(chooser.searchParams.get('prompt'), 'select_account');
++ const hint = new URL((await t.get('/auth/login?login_hint=kim')).headers.get('location'));
++ assert.strictEqual(hint.searchParams.get('login_hint'), 'kim');
++ });
++
++ await check('callback exchanges the code with the matching verifier and sets the SDK\'s own httpOnly cookies', async () => {
++ const login = await t.get('/auth/login?next=/week');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = cookieJar(login);
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(r.status, 302, r.text);
++ assert.strictEqual(r.headers.get('location'), '/week');
++ const set = r.headers.get('set-cookie');
++ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);
++ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);
++ assert.match(set, /casino_at=;/);
++ const at = set.match(/ov_token=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `ov_token=${at}` });
++ assert.strictEqual(me.json().user.username, 'kim');
++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
++ });
++
++ await check('the old cookie is still the session, so nobody is signed out by the move', async () => {
++ const at = t.network.userToken(user);
++ const me = await t.get('/auth/me', { cookie: `casino_at=${at}` });
++ assert.strictEqual(me.status, 200);
++ assert.strictEqual(me.json().user.username, 'kim');
++ assert.strictEqual(me.json().user.subject_id, user.subject);
++ });
++
++ await check('a wrong state, a verifier that does not match, or no flow cookie starts the sign-in once more', async () => {
++ const login = await t.get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = cookieJar(login);
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const wrong = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
++ assert.strictEqual(wrong.status, 302, 'the SDK recovers by itself once');
++ assert.match(wrong.headers.get('location'), /^\/auth\/login/);
++ const retry = wrong.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
++ assert.match(retry, /ov_oauth_retry=1/);
++ const again = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: `${flowCookie}; ${retry}` });
++ assert.strictEqual(again.status, 400);
++ assert.match(again.text, /Try again/);
++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
++ assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 302);
++ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 302);
++ });
++
++ await check('next= only accepts same-site paths', async () => {
++ const opts = { baseUrl: 'https://openvibe.casino', networkUrl: 'https://openvibe.network' };
++ assert.strictEqual(sanitizeNext('/week?x=1', opts), '/week?x=1');
++ assert.strictEqual(sanitizeNext('https://openvibe.casino/w/casino_x?y=1', opts), 'https://openvibe.casino/w/casino_x?y=1');
++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.casino.evil.com/x']) {
++ assert.strictEqual(sanitizeNext(v, opts), '/', v);
++ }
++ });
++
++ await check('/auth/health says whether the Network accepts this site\'s client', async () => {
++ const r = await t.get('/auth/health');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.json(), { ok: true, client: 'ok' });
++ });
++
++ await check('readiness is truthful: db required; JWKS, the OAuth client, identity resolve and Valkey optional', async () => {
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(b.checks.db.status, 'ok');
++ assert.strictEqual(b.checks.db.required, true);
++ assert.strictEqual(b.checks.network_jwks.status, 'ok');
++ assert.strictEqual(b.checks.network_jwks.required, false);
++ assert.strictEqual(b.checks.oauth_client.status, 'ok');
++
++ assert.strictEqual(b.checks.sso_client.status, 'ok');
++ assert.strictEqual(b.checks.sso_client.required, true);
++ assert.ok('valkey' in b.checks);
++ });
++
++ await check('/release.json and /metrics (loopback only)', async () => {
++ const rel = await t.get('/release.json');
++ assert.strictEqual(rel.status, 200);
++ assert.strictEqual(rel.json().service, 'casino');
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
++ const m = await t.get('/metrics');
++ assert.strictEqual(m.status, 200);
++ assert.match(m.text, /http_requests_total|release_info/);
++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
++ });
++
++ await check('public pages are complete without JavaScript', async () => {
++ for (const p of ['/', '/play', '/play/blackjack', '/learn', '/learn/craps', '/simulate', '/safe', '/developers']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ assert.match(r.text, /<noscript><nav/);
++ assert.match(r.text, /<footer id="ov-footer"/);
++ assert.match(r.text, /<main id="main"/);
++ }
++ });
++
++ await check('the API answers /ping and unknown routes are problem+json 404', async () => {
++ const ping = await t.get('/api/v1/ping');
++ assert.strictEqual(ping.status, 200);
++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'casino' });
++ const gone = await t.get('/api/v1/nope');
++ assert.strictEqual(gone.status, 404);
++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
++ assert.strictEqual(gone.json().code, 'route.not_found');
++ });
++
++ await check('a FedCM assertion and an app token are not sessions', async () => {
++ const now = Math.floor(Date.now() / 1000);
++ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
++ assert.strictEqual((await t.get('/auth/me', { cookie: `casino_at=${fedcm}` })).status, 401);
++ const page = await t.get('/', { cookie: `casino_at=${fedcm}` });
++ assert.ok(!page.text.includes('Signed in as <strong>kim'));
++ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });
++ assert.strictEqual((await t.get('/auth/me', { cookie: `casino_at=${app}` })).status, 401);
++ });
++
++ await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {
++ const guest = await t.get('/auth/me');
++ assert.strictEqual(guest.status, 200);
++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await t.get('/auth/me', { cookie: 'casino_at=garbage' })).status, 401);
++ assert.strictEqual((await t.get('/auth/me', { cookie: 'casino_rt=stale' })).status, 401);
++ assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);
++ });
++
++ await check('another site cannot sign a person out; this site can', async () => {
++ const cross = await t.get('/auth/logout', { as: user, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 200);
++ assert.ok(!/casino_at=;|casino_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));
++ assert.match(cross.text, /method="post"/);
++ assert.strictEqual((await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
++ const same = await t.get('/auth/logout?next=/week', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(same.status, 303);
++ assert.strictEqual(same.headers.get('location'), '/week');
++ assert.match(String(same.headers.get('set-cookie') || ''), /casino_at=;/);
++ });
++
++ await t.close();
++ done();
++})();