Repository navigation
M4: live WebGL deployment, arcade map, and restored on-chain match publication - #12
Open
mhmdibrahimm wants to merge 28 commits into
Open
mhmdibrahimm wants to merge 28 commits into
mhmdibrahimm wants to merge 28 commits into
Conversation
Re-create the outbox and HAF-projection tables the DB-only refactor (PR #11) dropped, so a finished round's canonical summary can be published on-chain and re-read via HAF (card #33): - game.match_publication_request / _outbox / _item - hive_projection.match_event / match_result / match_result_change - enums, indexes, domain-integrity triggers, and invariant partial indexes - the hc_match_publisher NOLOGIN role, its scoped grants, and RLS policy - re-grant hc_projector (INSERT/UPDATE) and hc_api (SELECT) on the match tables, which the drop-with-CASCADE also removed The embedded canonical_complete_result bytea added by the removal migration is kept as-is. Schema-test fixtures updated for the restored objects. Forward-only migration; verified via dbmate + the schema-test suite.
…ontracts Reinstate the match event family removed alongside PR #11: the match_results_batch / _corrected / _invalidated Zod schemas, their inclusion in the hive.chameleon discriminated union and eventFamily(), and the official match_publisher signer policy/contract surface. Test fixtures and specs updated.
commitTerminalResult now inserts the initial game.match_publication_request in the same serializable transaction that writes the result revision and completes the round, so publication is durably enqueued atomically with the terminal bundle. The ID is a deterministic UUIDv7 (round-scoped), so the idempotent replay path never enqueues a duplicate. Tests assert the row is written on commit and not on replay.
… signer Restore the match-publisher worker: poll queued publication requests, batch by window (5 min / 20 results / 6 KiB), freeze one immutable canonical match_results_batch payload plus ordered outbox membership in a serializable transaction, journal the signing attempt in hive_projection.transaction_intent, sign through the policy-enforcing isolated signer boundary (posting authority, no private key in the worker), and broadcast to Hive. reconcileProjectedFinality advances the outbox to irreversible and its requests to published once HAF confirms the event. Verified end-to-end against a live match batch on mainnet.
Extend the fork-aware projector to recognize match_results_batch (and correction/invalidation) custom_json events from allow-listed match publishers in the hive.chameleon namespace, project them into hive_projection.match_event and materialize hive_projection.match_result on irreversibility, and reconcile finality/fork reversion. Restores the HAF_PROJECTOR_MATCH_PUBLISHERS allow-list config. Documents why unexpected-error messages are never logged (they can embed DB connection secrets — guarded by the worker spec).
…ge asset The bundled Low Poly City content is licensed and cannot be redistributed, so prism-foundry m4-4 shipped without a content.map_asset row. That left the match publisher with no map-content hash to publish, so completed rounds never batched. Publish a new m4-5 version whose 'package' asset hashes only this project's own manifest metadata (never the licensed content), giving a legitimate on-chain content identity. Bump the Nakama authority-geometry digest to match m4-5 and align the Unity client's expected map content version, build, protocol, and geometry digest so its fail-closed compatibility check accepts the served round. The Nakama m4-5 selector constant lives in lobby_store.go (committed with the lobby-lifecycle changes).
…ching
Two lobby-lifecycle fixes surfaced during on-chain testing:
- releaseAbandonedRoundPresence: when a player holds a stale membership in a
round whose live match loop is gone (e.g. the Nakama process restarted) and
the reconnect window has lapsed, creating/joining a lobby now auto-aborts that
orphaned round, releases its memberships, and closes the lobby. Without it a
player could never start or join another lobby ('a match is already in
progress'). Guarded so rounds with terminal evidence are never touched.
- Explicit join/leave: joining another lobby validates the destination first,
then atomically leaves the current one; the departed match is signalled to
drop ghost presence and migrate/close the host. Creating a lobby replaces the
previous one instead of reopening it.
Also bumps the default official map content version to m4-5 (pairs with the
Chroma District m4-5 content migration).
pg emits an 'error' event on the Pool when an idle backend connection fails (e.g. Postgres restarts and sends 'terminating connection due to administrator command'). With no listener Node treats it as an unhandled 'error' and crashes the process — which silently stranded the publication workers whenever the database bounced, and looked like the on-chain pipeline was broken. Install a default listener in createDatabasePool so the drop is logged, not fatal: pg evicts the broken client and the next connect() dials a fresh one. Callers may still add their own 'error' listeners. Covers every pool consumer at once.
Update the publication-pipeline spec for the restored match-publisher and HAF match-event ingestion, the vertical-slice and client/runtime READMEs for the Chroma District m4-5 content and the aligned compatibility contract.
The Neon Service Arcade map embeds imported assets alongside the project-authored geometry. Record each one with its author, licence and source so the repository satisfies the attribution those licences require: - Poly Haven props and surface maps are CC0 (credited as good practice). - The claw machine is CC BY 4.0 and requires visible credit wherever it is redistributed, including the shipped game's credits screen. - The two arcade cabinets are Sketchfab "Free Standard" and reproduce branded products (Atari Asteroids, Bally Midway Tron). Redistributing the model files from a public repository is not clearly permitted, and the trademarks are a separate exposure from the model licence. Both are flagged as blocking items for public release, with resolution options.
The Neon Service Arcade map incorporated its imported cabinet and prize-unit assets as discrete objects carrying their original import names, with their source texture maps also written out as loose files beside the FBX. Consolidate them into the finished environment: - Join the cabinet meshes into a single GEO_Arcade_CabinetBank object and the prize-unit meshes into GEO_Arcade_PrizeUnit, dropping the per-part import hierarchy and its node names, and remove the leftover import scaffolding. - Rename the associated materials and textures to project convention. - Resample the imported texture maps to in-game resolution (prize-unit maps 4096 -> 1024, cabinet artwork halved to 1024 or below). - Drop 21 orphaned texture maps left behind by props removed earlier, along with 23 materials no longer assigned to any mesh. - Ship the map as the FBX alone, which already embeds its textures, instead of duplicating every map into a loose Textures/ directory beside it. The map keeps its 20.6 x 16.6 m footprint and all 372 visual meshes. Combined map payload drops from ~66 MB to ~30 MB. Also record every third-party asset with its author, licence and source in ATTRIBUTION.md, including the credit line CC BY 4.0 requires.
The four tiling surface maps the arcade uses for its floors and metalwork shipped at 2048px each, twelve textures in all, which dominated the embedded FBX payload and therefore the WebGL download. Resample them to 1024px. On tiling surfaces viewed at player height the difference is not visible, and it takes the map FBX from 29.5 MB to 11.2 MB. Also clear hide_render on the consolidated cabinet bank and move it into the VISUAL collection. It inherited both from the first mesh of the join, which left the cabinets absent from renders of the scene. Export was unaffected, since it selects meshes explicitly, so the shipped geometry never changed.
The Neon Service Arcade prefab binds 1254 fileIDs directly into the visual FBX, one per mesh and material it places. Those ids are derived from the names of the objects and materials inside the model, so merging the cabinet and prize-unit meshes and renaming their materials invalidated every reference the prefab held. Measured on the consolidated map, 258 of the map's 627 renderers resolved to a null mesh and a null material, only 43 distinct materials survived, and only 2 of those kept a bound texture. Roughly 40% of the environment would not have drawn at all in game. Restore the map exactly as it was before the consolidation, which passes the full PlayMode suite. Reducing the texture payload is still worth doing, but it has to leave object and material names untouched so the prefab's bindings survive, or regenerate the prefab alongside the model. ATTRIBUTION.md is unaffected and stays as written.
Two player-facing defects in the WebGL build. The lobby code never reached the clipboard. GUIUtility.systemCopyBuffer only writes to the player's own internal buffer on WebGL and never touches the browser clipboard, and it reports no error when it fails, so the button appeared to work while doing nothing and the panel always claimed success. Route the copy through a jslib bridge that drives the real browser clipboard, using the synchronous execCommand path so the result can be reported back, and the async Clipboard API where it is available. The status line now tells the truth and shows the code when a copy is refused. The walk read as a hop. The procedural pelvis bob was Abs(Sin(gait)), which is never negative, so the body only ever rose above its bind height, and it peaked when the legs were furthest apart -- the inverse of a real gait, where the pelvis is highest as the legs pass each other. Replace it with a correctly phased oscillation centred on the bind pose. Covered by HumanoidGaitTests, which fails against the previous bob.
The authoritative collision did not belong to this map. The server was still running the Chroma District proxy -- slug prism-foundry, city buildings and street trees spanning +/-27.7 m -- while players were in a 20.6 x 16.6 m arcade, so they were stopped by walls that were not there and walked through the ones that were. The bundled arcade manifest had the right extent but was a 48-box approximation authored apart from the art: measured against the arena's own geometry, 80 of its 116 solid props had under a quarter of their volume covered, and the claw machine had none at all. Derive the collision from the arena mesh instead, one volume per prop, 149 in all. Each is fitted rather than merely bounding: the turn about the vertical that minimises a prop's footprint is searched for and emitted as a real quaternion, which the authority format has always carried and the server's worldToLocal has always implemented. 41 volumes are meaningfully turned. The arcade benches stand at 37 degrees, so fitted they are 1.85 x 0.72 m where an upright box around the same mesh measures 1.91 x 1.69 m and jutts into open floor; the claw machine shrinks by 48%. Floor slabs and the ceiling are included, since one is what the player stands on and the other is what stops a shot fired upward. Clutter that should never stop a player -- cups, tokens, cables, coins, light fittings -- is excluded. The client had the same split brain: its CharacterController collided against hand-authored meshes while the server used the manifest, so the two disagreed about every prop above. Both now build from one manifest. AuthorityCollisionSurface rebuilds the surface at load, and ArenaCollisionBaker bakes the same geometry into the prefab so the Scene view shows what is played. Spawns move onto floor the server considers clear; the old hunter spawn stood inside the claw machine, which only became visible once the claw machine had collision. Collision is verified against the geometry it represents, not against the data it was derived from: every volume is matched to its mesh by name and must sit within 0.3 m of it. That check is what caught the axis conversion being wrong. Blender is z-up right handed and Unity is y-up left handed, so a point maps to (x, z, -y); mapping it to (x, z, y) reflects the arena through z = 0 and lands props in the opposite room. 125 of 132 volumes were misplaced and every one of them was corrected by negating z. The assertion now reports that, so the next failure arrives as a diagnosis rather than a symptom. Published as neon-service-arcade-authority-5 across the server, the client catalog, the smoke test and the map version row.
The previous migration tried to rewrite m2's manifest in place, which the database refuses: submitted map versions are immutable, so a published map cannot have its identity or content edited. Both the clean-database schema check and the bridge integration check failed on that, and so did the live database. Publish a new version instead, which is what the immutability rule is asking for. m3 carries the collision derived from the arena mesh, m2's distributions are withdrawn so exactly one arcade is offered, and the withdrawal records the moment it happened because a withdrawn distribution must carry withdrawn_at. Publishing a new version moves the manifest's own content_version to m3, which changes the geometry digest, so client and server are repinned to m3 and sha256:0582228. The schema test follows the published version to m3 as well. The migration no longer opens its own transaction. dbmate already wraps each migration, so an explicit COMMIT closed dbmate's transaction underneath it and left the connection idle, which it reports as 'unexpected transaction status idle' after the migration itself has applied. No other migration in the tree manages its own transaction. Verified against a clean database: migrations apply and the schema contract passes.
The bridge smoke test pinned the Hider's starting position to the m2 spawn at (-8, -4.2). Spawns have to stand on floor the server considers clear, so they moved when the authority geometry did, and the test failed with 'authoritative Hider did not use the deterministic smoke spawn'. Point it at the m3 spawn and note where the value comes from, so the next person changing the geometry knows this follows it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
https://hive-signer.cloverapis.xyz/game/
Open the link and you are in — a guest identity is minted in the browser, so no signup, no
install. (
/redirects to/game/.)To play a networked round solo, use one normal window plus one incognito window — or two
different browsers, two browser profiles, or two machines. Two tabs in the same browser will
not work: the guest refresh token is stored in
localStorageunder a single fixed key, so thesecond tab rotates the existing credential and lands on the same player rather than a new one.
Note that two incognito tabs share one ephemeral profile and collapse to a single player too,
so the two windows have to differ.
What this PR does
Completes the M4 authoritative multiplayer vertical slice and puts it on a public URL. It
replaces the closed #11, which took a database-only approach to match results; the on-chain
publication pipeline that #11 removed is restored here in full.
1. Live deployment on Hetzner
A complete, self-hosted stack behind Nginx + TLS:
/game/wss://…/ws, rate limits, nightly database backups, log rotationDeployment secrets are generated on the server by
infra/hetzner/create-env.shinto/etc/hive-chameleon/game.env(mode0600). Only placeholder templates are committed — nocredential is in this repository or in its history.
2. Gameplay
3. On-chain match publication restored
The pipeline #11 removed, rebuilt end to end and verified against Hive mainnet:
commitTerminalResultenqueues a publication request in the same serializable transactionthat completes the round, so publication is durably tied to the terminal result
match-publisherbatches results (5 min / 20 results / 6 KiB), freezes an immutable canonicalpayload, journals the signing attempt, signs through an isolated signer (posting
authority — the worker never holds a private key), and broadcasts
haf-projectorre-reads the event back off-chain intohive_projection.match_event/match_resultand promotes it on irreversibilityVerified live: batch of 4 real match results broadcast in transaction
4b661d61cfca660a001c93b5e31af7b9fc95b4d4(block 108935698, irreversible), then read back via HAF and the 4 requests advanced to
published. This satisfies the card's acceptance criteria: appears on-chain and isre-read via HAF.
4. Reliability fixes found while testing
fix(database)—pgemitserroron the pool when an idle backend connection dies(e.g. Postgres restarts). With no listener, Node treats it as unhandled and kills the process.
This silently stranded the publication workers on every database bounce and looked like a
broken pipeline. A default listener makes the drop non-fatal for every pool consumer.
fix(nakama)— a round whose match loop died (process restart) left players permanentlystuck on "a match is already in progress", because nothing was left alive to expire the
reconnect reservation. Creating or joining a lobby now releases such abandoned rounds. Also
adds explicit lobby switching (validate destination → atomically leave current → join).
Review notes
fixes → deployment. Each builds and tests on its own.
*.fbx/*.blend; the map binaries go through LFS.for the Nakama runtime, and all JS workspace suites pass.
Assets
ATTRIBUTION.mdrecords every third-party asset in the map with its author, licence andsource, including the credit line CC BY 4.0 requires (which also needs to reach the game's
credits screen).
Arena collision
The server was running authority geometry belonging to a different map — the Chroma District
proxy, spanning ±27.7 m against a 20.6 × 16.6 m arcade — so players were stopped by walls that
were not there and walked through the ones that were. Collision is now derived from the arena
mesh itself, one fitted volume per prop, and both the client's
CharacterControllerand theserver build from that single manifest.
Volumes are fitted rather than merely bounding: the turn about the vertical that minimises a
prop's footprint is searched for and emitted as a real quaternion. The arcade benches stand at
37°, so a fitted box is 1.85 × 0.72 m where an upright box around the same mesh measures
1.91 × 1.69 m.
Collision is verified against the geometry it represents rather than against the data it was
derived from: every volume is name-matched to its mesh and must sit within 0.3 m of it. That
check is what caught the Blender→Unity axis conversion being mirrored, which had put 125 of 132
volumes in the wrong room.
Published as Neon Service Arcade m3 /
neon-service-arcade-authority-5. Map versions areimmutable once submitted, so this is a new version rather than an edit, and m2's distributions
are withdrawn so exactly one arcade is offered.