Repository navigation
Speak BluMint's 2.0.0 partner API: whole-set creates, close, progress, starting wins - #97
Merged
Confetti3 merged 76 commits intoOct 17, 2026
Conversation
The adapter is about to grow a refusal format, a close route and reports after each game, which would push one file past the size CONTRIBUTING asks for. This moves routes/blumint.rs to routes/blumint/mod.rs with no change, so the next commits can add files beside it.
BluMint's partner API 2.0.0 sends each player as a list of identities,
and the Ember ID its lookup answered is one of them, tagged
"source": "partner". The set's length is series.gamesToWin, beside
matchSettings rather than inside it, and it has no default. BluMint reads
a refused creation by a code at the top level of the body only, and
files the bridge's error envelope as an answer it cannot read, which is
final for the match.
The creation now picks the one Ember ID among each player's identities
and reads the length from series. A missing or malformed length is
invalid_request, and one above first to 10 is match_unsupported, so
BluMint hands that match to its host. Anything but two teams of one is
match_unsupported. Every player without a link on the connection is
named in one player_unlinked answer, so BluMint can look them all up
again before it asks again. Refusals answer { "code": ... } through
BluMintRefusal; the bridge's own failures keep the envelope.
The creation moves to routes/blumint/create.rs.
BluMint sends an Idempotency-Key with every match creation and asks again under the same key until it has an answer, and it sends a new key when it wants a new match. The bridge matched a retry by its players and set length instead, because BluMint used to send no key, so it could not tell a lost answer from a new request. A creation now goes through matches::idempotent under the key, scoped to the connection rather than the credential, since each registration issues a new credential. The same key answers the same match, even after the match ends, and team names or BluMint's own settings can change under it. Another set length or other players under the key answer 409 key_reused, and a creation without a key is invalid_request. A refusal is never stored against the key, so the same key works once the player is linked. idempotent takes the route's own error type for that, and the pair lookup is gone.
A creation naming a player who is still in another match was refused with the bridge's 409 lease_conflict, which BluMint reads as final. BluMint holds such a match instead and asks again under the same key while the other match ends, but only when the answer is 423 with the code player_busy and the busy players named. Once both players are linked, the creation now checks each of them for another unfinished match on the connection and names every busy one, in the order BluMint sent them. Nothing is stored against the key, so the same key creates the match once the players are free.
…l abort BluMint's partner API 2.0.0 takes a report with a mode. A finished set is a result whose teams carry wins, not score. A match that ended without a result is an abort with a reason and whether BluMint may ask for it again, and its teams carry the wins so far, so a game the abort overtook is not lost when BluMint asks for the set again. mustRestart is gone. Results now go as mode "result". A cancelled match goes as a retryable abort: disputed when the fighters' reports disagreed or one never came, insufficientPlayers when nobody reported or a link ended, serverFailure for anything else, such as a provider calling it off. An expired match, which nothing could be told before, now goes as an abort BluMint must not retry, insufficientPlayers, so its host takes the match. That ends the silent expiry, so Policy::silent_expiry is gone. The reports move to routes/blumint/report.rs.
The BluMint tests need a set played for real, through claims, the room, permits and reports, to see what the bridge tells BluMint when a set's first game starts. Those helpers were local to tests/play.rs. They move to tests/common/play.rs unchanged, over a small Table trait for the two fighters and their bridge, and tests/play.rs uses them from there.
BluMint shows a set's score while it is played and counts the set as started once it hears so, which closes the window in which its host can still undo the previous match. With only the result, BluMint heard nothing until the set was over. The bridge now queues a status report when a set's first game is permitted, with each player's presence, and a progress report after each game won that does not end the set. A draw, an agreed abort or a voided game changes no score and queues nothing, and the game that ends the set is the result. The reports go out from their own delivery queue (delivery::progress, table blumint_progress in migration 018), on the same schedule and with the same reading of BluMint's answers as results, except that a status BluMint answers 404 to is tried again, as BluMint asks. Each body is read from the ledger when it is sent, and nothing is sent once the match has ended. Policy::reports_progress says which platforms hear this. The BluMint tests now script BluMint's answers and play a set through the shared play helpers, and pin how each answer is read.
BluMint's registration routes take the credential to call a partner back with as authentication.secret, and refuse a body that sends it as apiKey with 400, so blumint-register fails at its first route against BluMint today. Registration now sends secret, and the test reads the credential from there.
BluMint closes a match it no longer needs, because the bracket match was decided, replaced or removed without it or its host took it over, and it waits for the close before it creates the players' next match. Ember had no close, so such a match stayed open and kept its players busy until it expired. BluMint also never calls the status route, which it no longer documents. POST /v1/blumint/matches/close now ends the match it names, or, when BluMint never had the creation's answer, the match made under the creation's Idempotency-Key. A closed match frees its players and is sent nothing more, no result, abort or progress. A closed key answers key_reused to every later creation, including one still on its way, so it can never make a match nobody plays. Every reason closes the match, a second close answers 200 too, and a match the bridge did not make for the connection is 404 unknown_match. The close's own Idempotency-Key is not read, since closing is the same however often it is asked and BluMint's close keys can be longer than 128 characters. Registration now tells BluMint the lookup, create and close URLs. The status route and its match_status are gone; presence moves to the reports, where the first game's status uses it.
BluMint answers 401 to a key it does not know and 403 to a key bound to no game. Both come from a key that was rotated or set up wrong, which its operator fixes, and every result and progress report sent meanwhile was refused for good. BluMint's answers of 401 and 403 are now retried on the usual schedule for a day, like a 429, so the reports go out once the key is right. Every other 4xx is still refused, and a results_url platform's 401 or 403 still refuses its result, as before. This is its own commit so it can be dropped on its own.
When BluMint asks for a set again after its match ended without a result, it sends startingWins on every team, the games each had won, and counts them in every report it takes. The bridge started every set at 0-0, so a set cut short at 1-0 began again from nothing, and a report from it would be refused as lower than the score BluMint already had. The creation now reads startingWins, on every team or on none, each a whole number below the set's length, and makes them part of what one Idempotency-Key names. The match stores them (migration 019) and the ledger counts them into its score, so the set ends when a team reaches its length in all, and every report, the snapshot and the assignments read the same score. match.completed names them when they are not 0-0, and checks one accepted game per win past them. The room binding carries them too, so the room starts from the same score. A binding from 0-0 is the version-1 binding, byte for byte, so a helper from before this keeps playing every such set, a reset set included. One that starts from a score is version 2, which an older helper refuses rather than seat the set at 0-0. The helper passes them to the game, and the SDK's Binding type names them.
A tournament binding can now carry the score its set starts from, when the platform asked for the set again after its match was cut short. The game read no such thing, so the room's table started the set at 0-0 and would play it to its full length while the bridge counted the earlier games. The game now reads starting_wins from the helper's binding, 0-0 when a helper sends none, and refuses a slot at or above the set's length. The room's binding keeps them, in the snapshot only when they are not 0-0, so a set from the start keeps the bytes it always had. The first binding of a room seats the table's score from them. A newer binding for the same set, after a fighter's endpoint changed, keeps the score the table has played to, so a mid-set change does not drop 1-1 back to 1-0.
BLUMINT_QUICKSTART.md described the 1.3.0 contract: inGameId at the top of each player, gamesToWin in matchSettings with a default, a busy 409, the status route, mustRestart and the silent expiry, plus questions for BluMint that BluMint has since answered, and links that now work. It now gives the 2.0.0 creation body with its Idempotency-Key, series and startingWins, the refusals and their codes, the close route, the status, progress, result and abort reports with the abort reasons, how the bridge reads each of BluMint's answers, the data-use terms, and BluMint's answers to the four questions. INTEGRATIONS.md, INTEGRATION_PATHS.md and the server traffic diagram follow.
matches::idempotent now serves BluMint's creation as well as the match, lobby and tournament routes, and its change for that pushed matches.rs past the 1,000 lines CONTRIBUTING asks for. It moves unchanged to routes/idempotency.rs, and matches.rs re-exports it, so its callers are as they were.
tests/blumint.rs had grown to about 1,500 lines with the reports, the close and starting wins. The stand-in BluMint and the helpers move to tests/common/blumint.rs. tests/blumint.rs keeps lookup, creation and its refusals, the close and registration, and tests/blumint_reports.rs takes what the bridge reports: the first game's status, progress, results, aborts, BluMint's answers and starting wins. No test changes.
A creation or close body over the bridge's 64 KiB cap, or cut short, was
refused by the body extractor before the handler ran, so it answered in
the bridge's { "error": ... } envelope. BluMint reads only a top-level
code, so it filed that answer as one it cannot read, which is final, and
closed the match by key. The host's match settings are free-form, so a
creation can outgrow the cap.
Both routes now take the body as a result and refuse a failed one as
400 { "code": "invalid_request" } after authentication, which BluMint
hands to its host without a close.
A player's record decided a set from the games accepted in its match alone, so a set BluMint asked for again at 1-0 and won 2-0 read as neither won nor lost: sets.won missed it, the recent entry said "lost" with 1-0, and the opponent's record never counted the loss. The record now reads the match's starting wins and counts them in the set's result and in the recent entry's score. Games won and lost still count only the games played in the match. Lobby standings are unchanged, since a lobby set always starts at 0-0.
Each progress pass first marks not_required the unsent rows whose match has ended. It found those matches with match_id IN (SELECT id FROM matches WHERE state IN ...), and nothing indexes matches by state, so while any row waited (a status retrying a 404, or a row on a connection with no key) every pass scanned every match inside the write lock: about 80 ms a pass at 200,000 matches, at least once a second. The sweep now checks each unsent row's own match by its primary key, which costs the same whatever the match history.
BluMint frees a player held by a match it could not close only for 24 hours after the match was made: past that, it reads the player as busy for real, holds their next match and in the end disqualifies them. The bridge moved a match's deadline a whole lifetime past each game, so a BluMint set played at its third hour, then left open by a close that failed, kept its players busy until its 27th, three hours after BluMint stopped excusing them. A connection's policy now says whether its matches keep the deadline they were made with, and a BluMint connection's do: a game started or decided no longer moves it, and a set still open 24 hours after its creation ends between games as the final abort an unplayed match gets. Every other connection's sets move their deadline as before.
A seated fighter leaving set the table's score to 0-0, which was harmless while every bound set started there. A set started from 1-0 now dropped to 0-0 when the opponent's connection fell before the first game. After one more win the bridge had ended the set at 2-0 while the room showed 1-0 and waited for another game. A fighter leaving a bound table now resets its score to the binding's starting wins, so a fighter who drops before the first game comes back to them. A newer binding that moves a fighter to another endpoint sends the old one away, and that no longer resets the set: the table keeps the score it had played to, as the binding comment always said.
A BluMint match keeps the deadline it was made with, and that deadline was match_expiry_hours after its creation, a value the bridge's operator may raise to 14 days. BluMint frees a player held by a match it could not close only for 24 hours after the match was made, so on a bridge set to 48 hours a match left open by a failed close kept its players busy for a day after BluMint stopped excusing them, and BluMint could disqualify them for it. A match on a connection whose matches keep their first deadline now lives the lesser of match_expiry_hours and 24 hours. Every other connection still takes match_expiry_hours as it is.
A fighter leaving a bound table set its score back to the binding's starting wins. In a set at 1-1 from 0-0, a fighter whose connection dropped came back to a room showing 0-0. After the next win the bridge ended the set at 2-1 while the room showed 1-0 and waited for a game that could no longer start. A bound table plays one set, so a fighter leaving it now keeps the score it has played to, and only a casual table starts over. The first binding always seeds the score from its starting wins, 0-0 included, so a casual game scored at the table before the binding does not carry into the set. A newer binding that moves a fighter no longer needs to save and restore the score around sending the old endpoint away.
The quickstart said the bridge ends a match nobody plays 24 hours after its creation. A BluMint match lives the lesser of match_expiry_hours and 24 hours, so on a bridge set below a day it ends sooner. The quickstart now gives 24 hours as the latest it ends, as INTEGRATIONS.md does.
The played-score test was added between the casual-room test and its comment, so the comment read as the first line of the new test's.
No workflow runs the bridge workspace, the helper's tournament module or the room tournament suites: the core-tests job stops at SF4E_CORE_TESTS_ONLY. This workflow runs the server workspace's fmt, clippy and tests, the helper's tournament unit tests, and the RoomTournament, TournamentFuzz and TournamentPlay suites built with g++, the same commands the PR's test steps give.
An organizer could void an accepted game of a BluMint set after a progress report counting it had reached BluMint. BluMint keeps the wins a report gave a team and answers any later report below them with invalid_wins, so the set's result or abort was refused for good. And since progress rows are keyed by the games won so far, the next win reused a key already queued or sent, and INSERT OR IGNORE dropped that report. settle now refuses, with lease_conflict, any change that lowers a team's wins on a connection that reports progress once a progress report of the match has been sent at least once, mid-set or in a correction. A row that comes back after a void is queued again, so it is sent with the score as it then stands; its attempt count is kept, so an older lease cannot record on it. The progress checks now live in delivery::progress (game_started, game_settled, keep_reported_wins) instead of at each call site, and expiry::extend takes the match.
…nizer game The status was queued only for a permit with seq 1. An organizer's game recorded before the fighters' first takes seq 1, so their first permit was seq 2 and BluMint never heard the set had begun. After an organizer draw, nothing told BluMint the set was under way until a game raised the score, and its host's window to undo the previous match stayed open that long. Every permit now queues the status, and the status row's key, games played 0, keeps it to one per match.
…tion On a BluMint connection a game no longer moved expires_at, so on a bridge whose match_expiry_hours is under 24 a set still being played expired between two games once its first deadline passed. BluMint was then sent a final insufficientPlayers abort for a set both players were playing. BluMint's window needs only the upper bound: no BluMint match outlives a day after its creation. extend now moves a BluMint match's deadline as on any connection, but never past BLUMINT_MATCH_LIFETIME_SECS after its creation. Policy::fixed_lifetime is Policy::capped_lifetime, and the docs say games move the deadline up to that cap.
The sweep skipped a match with an open game or a live room lease, so a BluMint match with a game permitted shortly before its 24-hour cap, or a claim that kept renewing its lease, outlived the cap. Its players stayed busy after BluMint stopped counting them as held, and BluMint could then disqualify them. A match on a capped-lifetime connection now expires once the cap has passed, whatever is open in it, and its open game is aborted in the same transaction, as a cancellation does. Every other match still waits for its open game and its lease.
The 24-hour cap applied only to matches made after the deploy. A BluMint match still open from before it kept the deadline the old extend gave it, a whole lifetime after its last game, so it could hold its players past the day BluMint counts them held. Migration 019 now also lowers each open BluMint match's deadline to at most a day after its creation, and its expiry_secs to at most a day, as a new match gets. 019 has not shipped, so the clamp is a second paragraph of it rather than a new version.
…when it is read A Ready fighter could read present-not-ready, so the player who stalls and the one waiting on them looked the same to BluMint. - A game's end no longer clears both claims' Ready. The room does not always clear both seats: a fighter taking Ready back calls the game off for both and leaves the other Ready, and a fighter may press Ready for the next game before the bridge decides this one. Each Ember claims a change of its own seat at once, so the bridge needs no clear of its own. - A capabilities read that fails fails a Ready claim, which the game retries in 5 seconds, instead of sending it without ready, which the bridge stored as not ready and the game took as already said. - Only a Ready claim reads the bridge's capabilities; a not-ready claim leaves the field out, which reads the same on every bridge. A bridge that reads ready is remembered for the match until a claim carrying it fails, so a bridge put back to one that refuses the field is asked again rather than refusing every claim for the rest of the match. One that does not read it is asked again on the next Ready claim, so a bridge upgraded while the match is played is heard. - The guide says how a game's end leaves each player's Ready.
The move page says the old Ember ID's live tournament matches end with its links, without the game in progress, and start again from the games already won. The move and connect pages name the Discord account the Ember ID has now, which connecting disconnects. The quickstart says a cancel that never reaches the bridge leaves the page usable until the sign-in lapses, and INTEGRATIONS.md says the answer carries the page's own secret, not the sign-in's state.
Each team's wins in a progress report, a result and an abort count the starting wins and every game so far, not the one game's outcome. The progress example is a first to 3 at 2 to 1.
A pull request's push no longer also runs the workflow as a push, and a newer push cancels the run it supersedes. The cargo cache is keyed on the pinned toolchains as well as the lock files, and CI builds without incremental state or full debug info, which only grow it. The C++ suites run as their own job beside the Rust one, build side by side, and skip the package list refresh when the runner already has nlohmann-json.
…s them The bridge's Discord pages and the connect page speak to the player in a tournament, who uses the site rather than owns it, and name the site by its own name in place of "your tournament site". The bridge's pages name each tenant that finds players by Discord account, from its configured name, and say which of them start an ended match again from the games already won. The connect page shows a name the site adds after the bridge ID (/start#BRIDGE/NAME) as text, and keeps only the bridge ID in its address and in the link it copies, which is what Ember reads.
bridge.embernetplay.link, with its bridge ID, is BluMint's production bridge, and a separate staging bridge serves BluMint staging and its testers. The staging bridge's ID is left for its operator to fill in.
…t hears them The delivery module and the delivery pass say the progress and status queues serve a platform whose policy reports progress, as the policy flag that gates them does, rather than naming BluMint as if it were the mechanism.
A helper that predates starting wins refuses a binding carrying them as an unknown field before it reads the version, so a second version guarded nothing, and a new helper's version check only refused bindings the bridge never signs. The binding names its starting wins under VERSION, as every other signed object pins its one version.
A BluMint match's external_match_id is blumint:<key>, so a close finds the match through the unique index every match already has, and migration 023 fills it from the stored answers rather than adding a second key column and index. The id the route made up, which nothing read, is gone.
A match whose platform caps its lifetime (Policy::max_lifetime_secs, BluMint's 24 hours) stores the end of that cap when it is created, as it stores expiry_secs. extend is one statement on the match ID again, and the sweep reads ends_by, so expiry reads no connection kind and names no platform. Migration 019 sets ends_by on the BluMint matches it caps.
…a down span A bridge that starts again moves each claim still live at its last run to start its lease from now, so claimed_at is when the claim's current lease began, and every read of a claim is its claimed_at plus the lease. The SQL spliced in by a macro, the down_since and resumed_at columns and their parameters are gone, and reading a status no longer reads bridge_liveness.
The room refuses a first binding while a casual game at the match's table is Ready, Playing or Paused, and FollowClaim offered it again every second meanwhile, each offer building and dropping a recovery checkpoint on the game thread. FollowClaim now waits for that game to end, and offers the binding on the first tick after it.
018 describes only the per-game progress rows it holds, 020 no longer deletes status rows no database has, and the three write their foreign keys as REFERENCES matches(id), as every earlier migration does.
- Name the matches' starting score starting_wins in its columns and migration, as everywhere else; correct the progress queue's comment on what indexes matches; list max_lifetime_secs in the documented Policy; map the BluMint tests in STATUS.md; document the new public items; and rewrap the quickstart's ragged paragraphs. - Declare the composite-key tables of 018, 021 and 023 WITHOUT ROWID, and write the sweep's mark only while a match is open, so an idle bridge's maintenance writes nothing; a restart finds the last run from claims and statuses when no sweep has been marked. - Count failed matches as ended in the report-settling test helper, end the room tests' games through one helper, and name two tests by what they show.
Paste link reads past the site's name after the bridge ID (/start#BRIDGE/NAME), as a site sends it in a message or email, so a player who copies that link rather than the page's own can paste it. The integration guide and the page's comments give the exact names the page shows, 1 to 40 letters, digits, spaces and . ' & -, and say the page drops any other.
The Discord move and connect pages said every site's match ends and its game in progress does not count. That holds only for a site that restarts a match (BluMint); a site that reviews disputes may hold the match for its organizer, who may count the game. Each site's sentence now says which.
A creation refused after the first close of a key the bridge held nothing under recorded the key as refused, so a repeated close answered 200 instead of the 404 the first gave. The close now reads its own mark first.
The quickstart presented bridge.embernetplay.link as BluMint's production bridge, though its only BluMint connection is staging's. Both columns are now planned, with Not built yet saying what is missing. Testing on staging trusts the staging bridge under Linked accounts and connects Discord from its connect page, not the Discord row, which uses the production bridge. It also gives the names the connect page shows and that Paste link takes the link with the name, and says a creation refused after a close still answers its 400.
Upstream release ships 018_room_own_delay.sql as version 18, and migrate() keys applied migrations by version alone, so the BluMint migrations, none of them shipped, move to 019 to 025, and 018 is upstream's file as it ships. A bridge that has run upstream's 18 then runs these as new versions, and the fork takes upstream without a clash in its numbers.
The room clears both seats when it ends a decided game, but the bridge kept each claim's Ready until that fighter's next claim, so BluMint read both fighters Ready between games for up to a few seconds. Settling a decided game now clears the claims from no later than its first report. A later claim, a fighter pressing Ready for the next game before this one is decided, stays, and so does a called-off game's.
db.rs then matches upstream release's at 18, its test included, so taking upstream keeps this side of db.rs.
claim_match sends through send_claim, which the test drives with a failing send, so the step that forgets a bridge reads ready once a claim carrying it fails is covered rather than called by hand.
Brings the branch up to release at cd4daa5. Conflicts: - server/ember/bridge/src/db.rs: both sides added migration 018 (room_own_delay) and its test identically; this branch also adds migrations 019 to 025 and two tests after them. Resolved to this branch's side, which keeps every line of release's file and numbers 019 to 025 after release's 018. No renumbering was needed: release has no migration past 018. Auto-merged without conflict: locales/*.po, rust/sf4-net/src/tournament/mod.rs, src/session/RoomModel.cxx, src/session/TournamentAnswers.cxx and src/tests/tournament_fuzz_test.cxx.
release's BridgeSdk ctest (cmake/RunSdkTests.cmake) runs the TypeScript SDK suite against the ember-bridge its tree builds, and build-current.ps1 refuses a run without it. The bridge job now runs the same gate on every push and pull request: build ember-bridge, then npm ci --ignore-scripts, npm run build and npm test with EMBER_BRIDGE_TEST_BINARY pointing at it.
The Binding type's comment named a version 2 that no binding carries: a set from a score adds starting_wins to a version 1 binding.
Confetti3
added a commit
that referenced
this pull request
Oct 17, 2026
Joseph O'Connor's branch (BluMintInc/SF4-Ember-Netplay blumint/contract-pr1-upstream at e36a625) plus seven review fixes: the room model's starting wins read builds as C++14 for MSVC, one GameUnderWay check, the Discord pages in their own file, matches an older bridge left open ended as the bridge starts, a status row that does not parse failing on its own, a 256 KiB cap for BluMint match creation, a second BluMint connection in the cross-connection close test, and docs that match the bridge.
Owner
|
I accept BluMint's published data-use terms for Ember's integration. We'll use and keep the IDs BluMint sends and those returned by its lookup only to seat players in BluMint matches, report those matches, and let listed staff watch. We won't share those IDs with anyone else, and we'll keep them secure. |
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.
Draft for your review, please don't merge yet. Please read it and leave feedback whenever you can. Neither of us can test it end to end until BluMint's side (its partner API change) is live on BluMint's staging site; I'll post here when it is, test against it, and push any small fixes to this branch.
CI: GitHub holds workflow runs from a fork until you approve them, so the checks here need your "Approve and run". This PR also adds .github/workflows/bridge-tests.yml (caching, a concurrency group, and the C++ suites as their own job); the same runs pass on the fork, on a branch with this exact code (the same git tree): https://github.com/BluMintInc/SF4-Ember-Netplay/actions/runs/38996022353
Hi Katie,
This PR moves the bridge's BluMint connection onto version 2.0.0 of BluMint's game partner API. It changes how a match is created, refused, closed and reported, and it adds a way to restart a set from a score. Lookup stays the same. Ember's own routes, the signed
results_urlintegrations and the lobby and tournament paths behave as before, apart from the two small exceptions under "Choices worth a look". The branch is cut fromreleaseat e505111 and mergesreleaseat cd4daa5 (v1.3.0), so it is current with 1.3.0, and it targetsnightlylike the other feature PRs (nightlycarriesrelease). Each commit stands on its own, so you can read it one commit at a time, and the merge commit explains its one conflict: both sides added migration 018 the same way.What it does
1. Match creation reads the 2.0.0 body and refuses in BluMint's format.
POST /v1/blumint/matchesreads each player fromidentities[]: the one entry whoseinGameIdparses as an Ember ID. Team names,matchSettingsandspectatorsare not read. Every refusal on the creation and close routes is now a top-level{ "code": ... }body, plusplayerswhere the code names players, in place of the{ "error": ... }envelope. BluMint reads only that top-levelcode. An answer it cannot read is final for BluMint, and BluMint then closes the match. The refusals:Idempotency-Key400 { "code": "invalid_request" }400 { "code": "match_unsupported" }400 { "code": "invalid_request" }400 { "code": "player_unlinked", "players": [...] }, naming every unlinked id423 { "code": "player_busy", "players": [...] }, naming every busy id409 { "code": "key_reused" }A database failure still answers
503in the bridge's own envelope. BluMint reads that as an outage and retries it. Auth failures keep the envelope too.2.
series.gamesToWin, with no default. The set length comes fromseries.gamesToWinand nothing else. If it is missing,0or not an integer, the answer isinvalid_request. 1 to 10 is accepted. Above 10 ismatch_unsupported, and BluMint's host then runs that match.DEFAULT_GAMES_TO_WINand thematchSettingsread are gone.3.
Idempotency-Key. Every creation runs insideidempotent(). The key's owner is the connection, not the credential, since eachblumint-registerissues a new credential. The digest covers the two players,gamesToWinandstartingWins. Asking again under the same key returns the same match for a day, even after that match has ended, and after that answerskey_reusedfor as long as the bridge keeps the match (item 6). A renamed team or editedmatchSettingsunder the key still replays. A changed roster or set length iskey_reused. Refusals are never stored against the key. The old pair-based dedupe (active()) is gone.4. A busy player is
423 player_busy. Before the insert, both players go throughledger::busy. BluMint holds the match and asks again under the same key every minute until the players are free.insert_match's own busy check is unchanged for every other connection.5. Results, aborts and progress.
submission()sends 2.0.0 bodies:teams[].wins, with noplacementand noscore.reasonandretryable: true.reports_disagree,report_lateandreport_missingmap todisputed.no_reportsandidentity_unlinkedmap toinsufficientPlayers. Anything else isserverFailure.insufficientPlayersandretryable: false, so BluMint's host takes the match.Policy::silent_expiryis gone.A new queue,
delivery::progress(migration019_blumint_progress.sql), sends aprogressreport after each game that raised the score without ending the set. Presence status reports have their own queue (item 13).Each report body is built when it is sent. A report for a set that has ended by then is dropped. Once a progress report has been sent, an organizer can no longer void a game of the set (
409 lease_conflict), since BluMint refuses any later report below the wins an earlier one gave a team, the result and an abort included.6. The close route, and registration.
POST /v1/blumint/matches/closetakes{ idempotencyKey, reason }only: BluMint names the match by theIdempotency-Keyit created it under, and a body with amatchIdand no key is400 invalid_request. The bridge keeps each match's key as the match's ownexternal_match_id,blumint:<key>, for as long as it keeps the match, because the stored answer under the key (idempotency_records) is removed after a day. So a close finds its match through the unique index every match already has, however old the match is, and the key makes no second match. Migration024_blumint_keys.sqlsets that id on each match whose answer is still stored; the random id the route made up before, which nothing read, is gone. A key whose every creation was refused stores no answer, so it is kept for a day inblumint_refused_keys. Any reason from 1 to 64 bytes closes the match, including one the bridge does not know, such ashostOverride. A match that is already over answers200and nothing changes. The close also marks the key closed for a day, so any later creation under that key getskey_reusedand makes no match. This covers a creation that is still on its way. A key the bridge holds nothing under for the connection answers404 { "code": "unknown_match" }, and is closed all the same. A repeat of that close answers404again, so a close whose first answer was lost gets the same answer when BluMint asks again, also when a creation the bridge refused arrived between the two. The close's ownIdempotency-Keyheader is ignored, because closing gives the same result however often it is asked, and BluMint's close keys can be longer than 128 characters. A closed match sends nothing more.blumint-registernow registers lookup, create and close. The status route and its row are deleted, because BluMint never calls a status URL. The credential goes inauthentication.secret, since BluMint's registration routes now refuseapiKey. Once all three calls succeed, the bridge revokes the callback credential any earlier registration handed over, because BluMint keeps one credential per route and has just overwritten it. A registration that fails partway revokes nothing, since BluMint may hold either credential on each route, and running it again finishes the change.7. Answer mapping. The mapping on
releasewas already right for BluMint, with one change. BluMint's401and403(a rotated key, or one bound to no game) are retried for the usual day instead of refused, so reports sent while the key is wrong still go out once it is fixed. This matches how the Twitch notifier treats a401or403. It applies to BluMint only: a signedresults_urlplatform's401or403still refuses its result. The change is the commit "Retry a report BluMint answers 401 or 403" plus the401/403row inBLUMINT_QUICKSTART.md, if you'd rather drop it.8. Lookup. No change.
9. Tests.
tests/blumint.rscovers lookup, creation and its refusals, busy players, the close (including a match that has already ended, a match whose stored answer has been removed, a key whose creations were all refused and another connection's key) and registration (including the credential it supersedes). A newtests/blumint_reports.rscovers what the bridge reports: presence statuses and their revisions, each player's Ready from their claims (an older Ember's included), a player going absent, a build refusal reading as present, progress, results, aborts, BluMint's answers and starting wins. It also shows that a Discord account moved to another Ember ID mid-set ends that set with a retryable abort carrying the games already won, that a BluMint match lives 24 hours on a bridge set to 48, while another connection's match keeps the 48, that a game open at that cap does not hold the players, and that a voided game cannot take back wins BluMint has been sent. A shared stand-in BluMint, with an answer queue, lives intests/common/blumint.rs. The protocol crate has binding tests, andsrc/tests/room_tournament_test.cxxhas a starting-wins case, a case where a fighter drops at 1-1 and rejoins, a binding that waits for a casual game to end, and a fighter rejoining a locked match room.src/tests/tournament_play_test.cxxchecks that each claim carries the fighter's own Ready and that a change is claimed at once, andrust/sf4-netchecks that the helper readsreadyfrom the game and sends it only to a bridge that lists the feature.10. Docs.
BLUMINT_QUICKSTART.mdis rewritten for 2.0.0: the creation body, refusals, close, the reports and how the bridge reads each answer, the data-use terms, and BluMint's answers to your four questions.INTEGRATIONS.md,INTEGRATION_PATHS.mdand the server traffic diagram follow.INTEGRATIONS.mdalso says thatmatch.completed'sstarting_winsis not in EMBER-TB-001's published schema, which stays as published, so a subscriber validating events against it must allow the field.11. Questions. They are at the end of this description.
12. Starting wins. When BluMint asks for a set again after a retryable abort, it sends
startingWinson every team. After a host reset it sends 0 on every team. The bridge counts those wins in at every layer:startingWinsis on both teams or neither, each value a whole number belowgamesToWin.020_match_starting_wins.sqladdsstarting_wins_p1andstarting_wins_p2.ledger::scores()adds the base, so every report, the snapshot, the assignments and each player's record count it.starting_winsis left out at 0-0; a set from a score addsstarting_wins.DecodeBindingcarry it, and the room's first binding seats the table's score from it.13. Presence status reports. BluMint's contract requires every partner to tell it who has shown up, so a later BluMint release can disqualify a player who never joins. The bridge keeps one status per match in its own outbox,
blumint_status(migration021). Whenever a player's presence changes, it sends that status again with a higherrevisionand anobservedAttime:running;routes::blumint::liveness::LEASE_SECS). They then readabsent. The lease is the longest a connected helper takes to commit its next claim when one fails: the answer's own request, the 10-second wait, five requests on the attempt that fails (a session renewal's two, the capabilities read, the challenge and the claim) at the helper's 15-second timeout, the 5-second retry wait and the retry's two requests, plus a second for the claim's whole-second stamp. The existing 10-second maintenance pass checks only the claims that lapsed since the previous pass, reading the open matches through migration023'smatches_openindex (delivery::status::observe_lapsed), and with no match open it writes nothing. Time the bridge was down does not count against a claim that was live when it stopped. On a restart the bridge first runs the pass it missed at the last moment it is known to have run (its last pass, claim or observed status), so a claim that lapsed before the stop stays lapsed, and then starts the lease again from the restart for each claim on an open match still live at that moment. Somatch_claims.claimed_atmeans when a claim's current lease began, which is when the claim was taken except across a restart; nothing else reads it. A status observed far ahead of the clock (a wall clock stepped back) is not followed, so connected players do not read absent until the clock catches up.A player is
absentuntil their Ember claims the match,present-readyonce they press Ready at the match's table or while a game is running or waiting for its reports, andpresent-not-readyotherwise. Each claim now carries the fighter's own Ready (Claim::ready), read from their seat at the bound table in the room snapshot (TournamentPlay::SeatReady), and Ember claims again as soon as it changes, at most every 2 seconds, instead of waiting out the 10-second interval. Taking Ready back or leaving the seat reads not ready (match_claims.ready, migration025_claim_ready.sql). A decided game takes back, as it settles, the Ready of every claim from no later than its first report (ledger::unready_before), since the room clears both seats when it ends a played game, before each Ember's claim says so; a later claim, from a fighter who pressed Ready for the next game before this one was decided, stays. A game called off because one fighter took Ready back leaves the other Ready, and they keep readingpresent-ready. So BluMint can tell the player who stalls from the one waiting on them. A claim without the field, from an older Ember, reads not ready. The bridge listsmatches.claim_readyin/v1/capabilities, and the helper sendsready: trueonly to a bridge that lists it, since an older bridge'sClaimrefuses an unknown field; a not-ready claim leaves the field out. A bridge that lists it is remembered for the match until a claim carrying it fails; one that does not is asked again on the next Ready claim. A capabilities read that fails fails the claim, which the game retries in 5 seconds, rather than sending a Ready fighter's claim that reads not ready. A player refused for a mismatched build is recorded (match_claim_refusals, migration022) and reads as present, not absent, so BluMint never removes the player who tried to join, until a claim of theirs on the opponent's build is taken; from then on they follow the lease like any other player. The refusal message now reads "Your Ember version differs from your opponent's. Update Ember, and ask your opponent to update too." in all 14 catalogs, because the bridge compares the two players' builds and no single build is required. This replaces the earlier one-time first-game status.14. The Discord pages speak to the player. The bridge's Discord connect, move and result pages, and the connect page at
embernetplay.link/start, are written for the player in a tournament and name the site by its own name: the bridge's pages name each tenant that finds players by Discord account, from its configuredname, and the connect page shows a name the site adds after the bridge ID (/start#<bridge id>/BluMint) as text, keeping only the bridge ID in its address and in the link it copies, which is all an older Ember's Paste link reads; Paste link now also reads past the name, so a link the site sent can be pasted as it is. The move page says that moving stops the old Ember ID's live matches and what each site then does: a site that restarts, such as BluMint, ends the match without the game in progress and starts it again, and a site that reviews disputes may hold it for its organizer, who decides that game, and both pages name the Discord account the Ember ID has now, which connecting disconnects.15. What it builds on from 1.3.0.
018_room_own_delay.sql). The BluMint migrations are numbered 019 to 025 after it, and 018 and its test are yours, unchanged.cmake/RunSdkTests.cmake, the BridgeSdk ctest thatbuild-current.ps1requires). The new CI job runs the same steps on Linux against the bridge this branch builds, and the SDK suite passes unchanged. The SDK'sBindingtype insdk/typescript/src/testing.tsgains the optionalstarting_wins(item 12).How to test
From
server/ember:The SDK suite, against the bridge this branch builds, the same steps as the BridgeSdk ctest. Build it with
cargo build --locked -p ember-bridgefromserver/ember, then fromsdk/typescript:From
rust/sf4-net:cargo test --locked --lib tournament. On Linux,tests/tournament_bridge.rsis Windows-only and runs no tests.The three C++ suites build with g++ on Linux. Each also needs
RoomModel.cxx,RoomModelActions.cxx,RoomModelJson.cxx,RoomModelTournament.cxx,RoomModelServerOwned.cxxandRoomDigest.cxxfromsrc/session/:All of the above pass in the fork's CI on this exact code.
Older clients
starting_wins, still version 1. An older helper refuses it as an unknown field (deny_unknown_fieldson the binding) rather than seat it at 0-0, before it reads the version. Its claim then stalls without saying why until the match expires, which BluMint hears as a final abort, and BluMint's host takes the match. I accepted that limitation rather than have new helpers send a capability header so the bridge could refuse an old one's claim withincompatible_build: the bridge cannot tell an older helper from a claim's build id, the case needs a set restarted from a score, and the host takes the match either way.ready. A helper sends it, astrue, only to a bridge whose/v1/capabilitieslistsmatches.claim_ready, since an older bridge refuses a claim with a field it does not know, and a claim without it, from an older helper, reads not ready. The game sends the helperreadywith everymatch_claim; a request without it reads not ready.match.completedgains an optionalstarting_wins. It is omitted at 0-0, so an existing event's bytes are unchanged.018_room_own_delay.sqlexactly asnightlyand upstreamreleaseship it, with its test, so the BluMint migrations follow it and a bridge that already recorded that 18 runs them as new versions;migrate()skips a version already recorded.What I could not build here
I worked on Linux, so the Windows client (CMake/vcpkg, the game DLL) was not compiled, and
rust/sf4-net/tests/tournament_bridge.rsdid not run. The C++ changes are insrc/session/(TournamentAnswers.cxx,TournamentBinding.hxx,RoomModelTournament.cxx,RoomModel.cxx),src/netplay/TournamentPlay.cxx(the claim'sready, and holding the first binding back while a casual game is under way), the one-linereadyinsrc/sf4e/sf4e__NetplayRuntime__Tournament.cxx's claim request,src/common/TournamentLink.hxx(Paste link reading past the site's name, tested insrc/tests/join_link_test.cxx, which the core-tests job runs), and three test files,src/tests/room_tournament_test.cxx,src/tests/tournament_fuzz_test.cxxandsrc/tests/tournament_play_test.cxx. Their room logic is covered by the three suites above, which do build with g++. Please build the client before merging.scripts/build-current.ps1runs RoomTournament, TournamentPlay, TournamentFuzz, BridgeSdk and HelperRust (which runsrust/sf4-net/tests/tournament_bridge.rs) in its ctest pass, so that build covers the Windows-only tests. No CI runs them, since the core-tests job stops atSF4E_CORE_TESTS_ONLY. Once that build is on staging, BluMint's testers play a restarted set at 1-0 on two Windows PCs, so you don't need to find a tester.Choices worth a look
matches::idempotentis generic over the route's error type (E: From<ApiFailure>). This lets a BluMint refusal travel theErrpath and never be stored against the key. Every existing caller infersApiFailureand is unaffected. It also moved, unchanged otherwise, toroutes/idempotency.rs, withmatches.rsre-exporting it, because the change pushedmatches.rspast CONTRIBUTING's 1,000 lines.BluMintRefusalhasUnknownMatch(the close's404) andBridge(ApiFailure).Bridgecarries the bridge's own failures in its own envelope.From<ApiFailure>mapsInvalidRequesttoinvalid_requestandIdempotencyConflicttokey_reused, and keeps everything else asBridge. A refusal commits a write that wrote nothing. ABridgefailure rolls the write back.An oversized or truncated body. The two BluMint routes take the body as
Result<Body<GENERAL_BODY>, ApiFailure>. A body over the cap or cut short is refused asinvalid_requestafter authentication, not by the extractor in the envelope. BluMint passes the host's free-form match settings through, so a creation can outgrow 64 KiB.Ending progress reports. I kept BluMint-specific code out of the shared match-ending flow (
bump()and friends). Two checks do the work instead.delivery::progress::leasefirst marks unsent rows of ended matchesnot_required, looking each row's match up by id, so a pass reads only the matches a report waits on.attempt()then rechecks the match when it sends. The close relies on the same two checks. The visible result is the same: nothing goes out after a match ends. For a moment after a match ends, its rows still readqueued.Progress lives in
delivery::progress.play::issueandledger::settleeach make one call (game_started,game_settled,keep_reported_wins), and each reads the connection's policy itself, so shared match code carries no BluMint conditions.A game BluMint has been told of cannot be voided. BluMint keeps the wins a progress report gave a team and answers any later report below them with
invalid_wins, so a void after one was sent would leave the set unable to report its result or abort.settle()refuses any change that lowers a team's wins once a progress report of the match has been attempted, mid-set or in a correction. A void before anything was sent is still allowed. A progress report is queued only when a game raised the score, keyed by the games won so far; when a void brings a count back, its row is queued again and sent with the score as it then stands.A BluMint match lives at most 24 hours. BluMint stops excusing a player held by a match it could not close 24 hours after that match was made. Without a cap, a set played at its third hour and then left open kept its players busy until hour 27, and BluMint would have disqualified them in that gap. So a connection's policy can cap a match's lifetime (
Policy::max_lifetime_secs, 24 hours for BluMint,BLUMINT_MATCH_LIFETIME_SECS), and a match on it stores the end of that cap asmatches.ends_bywhen it is created, as it storesexpiry_secs. Its lifetime is the lesser ofmatch_expiry_hoursand the cap, and a game started or decided movesexpires_atas on any connection, but never pastends_by; expiry reads no connection kind. At that cap the match expires even with a game open or a room lease live, and its open game is aborted. Every other connection's matches keepmatch_expiry_hoursas it is and still wait for an open game.Player records count the starting score.
GET /v1/players/{id}/recordcountsstartingWinsin a set's won or lost result and in the recent entry's score. Games won and lost still count only games played in the match. Lobby standings are unaffected, since lobby sets start at 0-0.The room's score when a fighter leaves. At a bound table, a fighter leaving keeps the score played, so a fighter who drops at 1-1 rejoins at 1-1. On
releasethe room went back to 0-0, and after the next win the bridge ended the set while the room still waited for a game. A bound room plays one match and its table cannot be re-seated by hand, so nothing there needs the casual reset. The bridge's ledger stays the set's score of record, since spec §16.5 makes the room scoreboard presentation, not the accounting source. A casual table still starts over at 0-0.match.completed's check counts accepted games above the base. Without this, every set with a base would fail the check and answer503.ledger::MatchandNewMatchcarrystarting_wins. The lobby, tournament and generic paths pass[0, 0].Seeding the table's score.
BindTournamentseeds the score fromstartingWinson the room's first binding, 0-0 included, so a casual game scored at the table before the binding does not carry into the set. A first binding that arrives while a casual game at table 0 is Ready, Playing or Paused is refused withWrongPhase, so the casual game's result is never added to the set's score. The state machine (FollowClaim) does not offer the binding while that game is under way, so the room builds no recovery checkpoint for a binding it would refuse, and offers it on the first tick after the game ends. A newer binding for the same set keeps the score played.A locked match room still admits its fighters. The host of a tournament room can lock admission from the room settings. A bound fighter now joins whatever the lock, so one who drops from a locked room can rejoin; a bound room admits nobody else anyway.
The close's answer. It answers
200 { "closed": true }. A key-formidempotencyKeyis checked for length only (1 to 128).No
idempotencyKeymetadata. I left{ idempotencyKey }out of the match metadata, since bridge metadata keys are lowercase snake_case and nothing reads it.Docs in one commit. The docs change is one commit near the end. I know you usually change docs in each code commit. I kept the code commits to code and tests so each is easier to review. The fixes after it update their own docs.
Cleanup commits. Three commits only tidy up, for CONTRIBUTING's size bar:
routes/blumint/.tests/blumint.rswas split intotests/blumint.rs,tests/blumint_reports.rsandtests/common/blumint.rs.tests/common/play.rsbehind a smallTabletrait.Commits before the cleanup still have
matches.rsjust over 1,000 lines.Busy-player cases have their own test. They are in
a_busy_player_is_refused_until_their_match_ends, which needed a third linked player.The expiry test uses a one-hour expiry. It shows that a key still replays its expired match. Idempotency records are pruned after 24 hours, the same as the default expiry. At the default, the key's record is gone by the time the match expires, which is still past the 3 hours BluMint needs.
tests/discord.rs. The second test's refusal names both players, because the second player there was never looked up, so is also unlinked.A CI workflow for the bridge and room tests.
.github/workflows/bridge-tests.ymlruns the "How to test" commands above on ubuntu-latest on every PR and on pushes torelease: the bridge, SDK and helper tests in one job, and the three C++ suites in another. It caches cargo's downloads and build output, and a newer push to the same PR cancels the run still going. No existing workflow runs them:degraded-connection-experiment.ymlruns the core tests and the degraded-connection policy tests only. It is three commits ("Run the bridge and room tests on every push and pull request", "Run the bridge tests once per push, and cache by toolchain" and "Run the SDK tests against the bridge in CI"), so you can keep it or drop it.Questions for you
Two bridges, and registering BluMint on each. Could BluMint's production run on a bridge of its own, separate from staging? Your docs call bridge.embernetplay.link a staging service whose data may be reset and which carries no uptime promise, and prize matches shouldn't run on a service that may be reset. Here is what we'd suggest, in this order:
BLUMINT_QUICKSTART.md's "What you get" table already describes the two bridges and leaves the staging bridge id asbrg_..., so please fill it in there too. The client adds a bridge only when the player approves it in the native UI, so we'd like to know where testers do that, or whether they need a build pointed at it.ember-bridge blumint-registeragainst BluMint staging's connection on the new bridge. BluMint's close route is live on staging now. It has to be this PR's build: BluMint's registration routes refuse theapiKeythatreleasesends and takeauthentication.secret. The registration points BluMint staging's lookup, create and close URLs at the new bridge and registers no status URL. Your current staging registrations keep working until it succeeds.blumint-partner-stagingon bridge.embernetplay.link once step 2 succeeds, so that bridge carries production's connection only.brg_0dbc0598-2312-4ce3-9df8-e160330565e6, with its own BluMint connection and credentials. It is the client's default bridge, so players need no client change and keep the connect link they have today. Nobody but you has linked Discord there, so there are no player links to carry over, and you're free to reset its data before it goes live.ember-bridge blumint-registeragainst the production connection, once BluMint tells you its release has deployed to production. BluMint's close route is not on production until then.Once all three of its calls succeed, each registration revokes the callback credential an earlier one on the same bridge handed over, so only the newest works. If the staging bridge will take a while, tell us, and you can run step 2 against
blumint-partner-stagingon today's bridge in the meantime.Data-use terms. Will you accept BluMint's published terms in writing in this thread? Ember uses and keeps the ids BluMint sends, and the ids its lookup returns, only to seat players in BluMint matches, report those matches and let listed staff watch. It shares them with no one else and keeps them secure. BluMint issues the production key only once you accept them.
Thanks for looking at this.