bug: fix panel bugs #507, #521, #567, #583, #613, #617, #618, #619 - #408
Merged
Merged
Conversation
Stage validation applied the elimination halving rule and last-round match count after round robin stages, and required 4 teams per group. Leftover qualifier seeds now go to the best next-placement teams across groups, and a Valve Swiss only sizes its playoff from the teams that reach 3 wins. Fixes 5stackgg/5stack-panel#507 Fixes 5stackgg/5stack-panel#617 Fixes 5stackgg/5stack-panel#618
The 2-2 last round isn't what a Valve Swiss advances, and halving across it resized the playoff when the Swiss was edited.
…ing themselves Hand-granted medals now move the awards leaderboard, and any tournament organizer can grant inside their own tournament, so repeat grants of an allow_multiple award (or a self-grant) could stack golds without limit. A tier now counts once per player per tournament, season, event or league season. Unscoped grants still count every row, since only the grant role can make them. An organizer below the grant floor can no longer grant to themselves or to a team they are rostered on.
… forfeit The organizer route only covered a Finished or Surrendered map. A match whose server died with map 1 Live still forfeited to lineup 1, because both lineups were ready. Any map that left Warmup now counts.
A region-only change released the reservation of the server the match still pointed at, and a hand-picked on-demand row was treated as an assignment that already had a Job, so the old pod kept running on its ports. A region change now leaves a dedicated server, or an on-demand server already in the new region, alone. Otherwise server_id is cleared, and that write's own event stops the Job and reassigns. A row the match did not reserve itself is cleared the same way.
…ueued The create catch block tore down the Job and released the row even after server_id was written, leaving the match on a server with no pod and no reservation. A failure after that write now retries through a reboot.
… left The every-minute sweep listed every Job in the namespace on the one-worker scheduled-matches queue and sent a Redis DEL per healthy match. It now uses the match label selector once no unlabelled m-<uuid> Job remains, and clears the held matches' orphan timers in one DEL.
Guaranteed places assumed every group filled them, so a group short of eligible teams left its seed as a bye while a qualifying wildcard was cut for numbering past the seat count. Qualifiers are now numbered in order.
An odd field's bye is a free win, so a 15-team Valve Swiss sends 8 teams to 3 wins, but the next stage was sized and validated at floor(N/2) = 7 and one qualified team was dropped. Both now use half the field rounded up.
Turning substitutes off once the bracket was seeded left a team at its minimum unable to add a player (the new cap) or drop one (the roster lock). Turning them back on is still allowed at any time.
Every all-digit query value became a number, so a workshop map id came back from /sync as a number, and ids past 2^53 were rounded.
max_players_per_lineup is selected on every match list row, and the bracket, stage and tournament lookup can't change the answer when there are no substitute slots.
The stage form can now point a stage at a LAN region, so the path the stage's match_options take into its scheduled matches is worth pinning: clone_match_options copies the row whole, and the LAN guard on the insert only fires for role 'user', never for the organizer and system sessions that drive bracket scheduling.
This was referenced Sep 18, 2026
This was referenced Sep 18, 2026
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.
Fixes the open
[BUG]issues from 5stack-panel #507 onward that need api changes. The web side is 5stackgg/web#576, and the Swiftly prefix fix (#616) is 5stackgg/game-server#181.Each fix started with a test that reproduced the bug and failed, then went green.
yarn test:unityarn test:sql(fresh container)yarn typecheck:testsis clean.Deploy notes
1886000000619_tournament_substitutes_enabled(#619), thenhasura metadata apply(new column ontournaments).substitutes_enabled.ReconcileOnDemandServerJobs(#567) will delete every orphanedm-*match Job already in the cluster. Expect a burst of "removing orphaned on demand server job" warnings.#507 / #617 / #618 Round robin stage advancement
#507 Round robin as stage 1 rejects valid group counts
Problem
Invalid Groups (4) for stage 1.Root cause
10 % groupswas required to be 0. Halving means nothing after a round robin, because how many advance is set by the next stage's size.Fix
max_teams, and that stage can't shrink below the next stage's size.get_stage_qualifier_seeds:get_team_at_stage_rankhad no other users and is dropped.#617 Only 8 or more teams could advance to the next stage
Problem: a round robin couldn't feed a small playoff, for example two group winners into a single final.
Root cause
validate_tournament_stagerequired the next stage's min to be at least the previous stage's last-round match count, groups × ⌊teams per group / 2⌋. That number means nothing for a round robin.start_league_seasonfor any division of 6 or more teams, in both the round robin and no-elimination Swiss shapes.Fix: after a round robin or no-elimination Swiss, only the next stage's size is validated, from 2 up to the previous stage's max.
#618 Round robin only accepted groups of 4 or more
Problem: 4 groups with 15 teams (groups of 4, 4, 4 and 3) was rejected.
Root cause: the first-stage rule required 4 teams per group for every stage type except Swiss.
Fix
Also fixed in the same code
Swiss 16 → SE 8 advanced 3 teams
get_stage_team_countsonly special-cased round robins. For a Swiss it counted the last round, which is just the 2-2 pool (3 matches).Valve Swiss was validated by the wrong round
generate_swiss_bracket's 3-0, 3-1 and 3-2 pools add up to.Tests
test/tournament-group-stages.spec.ts(new, 11 tests):test/tournament-stages.spec.ts:Known limits
#619 Stand-ins are possible in a Duel tournament
Problem
Root cause
match_options.number_of_substitutes, with no Duel exception.Fix
tournaments.substitutes_enabled,NOT NULL DEFAULT true, so every tournament keeps substitutes unless an organizer turns them off.tournament_max_players_per_lineupandmatch_max_players_per_lineupadd 0 substitutes for Duel tournaments, or when the setting is off.Tests:
test/tournament-substitutes.spec.ts(new, 11 tests). 10 failed before the fix; the guard (a non-tournament Duel match keeps its substitute slots) passed throughout.#613 Manually added awards don't count for the leaderboard
Problem: hand-granted awards never appeared on the Awards leaderboard.
Root cause
_leaderboard_awardscounted medals byaward_recipients.placement, and dropped rows without a placement or without a tournament/season. This was a deliberate "hand-granted awards must not move rankings" rule, andgrantAwardnever sets a placement.get_player_leaderboard_rankranked awards by gold alone, while the board sorts mvp → gold → silver → bronze, so jump-to-my-rank could land on the wrong page.Fix
COALESCE(placement_tier, award tier). Special-tier awards have no column and don't count.Tests:
test/awards.spec.ts› "medal leaderboard":Also added two guard tests for the web-only #609 and #610: a self-grant is allowed, and a team grant without a tournament stores the team row plus one row per roster player.
#567 Match jobs orphaned
Problem
m-<matchId>) could keep running after their match ended, was deleted or moved.Root cause: stopping relied on:
SIGUSR1Nothing ever checked the cluster, and several paths skipped the stop or sent it to the wrong server.
Fix, by orphan path
1. The graceful stop only sent a signal and swallowed failures.
setup.shis PID 1 the signal is accepted and ignored. Kubernetes errors were logged, the row freed and the job resolved, so it never retried.2. Deleting a match never stopped its server.
new = {}, sodata.new.server_idwas undefined.old.3. A cancel during assignment.
server_idwas written stopped nothing, and the late write then tried to signal a pod that was still being created.server_idis only written if the match hasn't ended; otherwise the new Job is removed and the row freed.assignServerstops before the dedicated fallback for an ended match.4. A reboot or reassignment freed the NEW server.
5. End-of-match side-effect errors skipped the stop.
server.is_dedicated, prevented the stop from being queued.is_dedicatedread is null-safe.6. A late or retried stop killed a restarted match.
tv_delayand backoff on a single-worker queue, so an organizer can start the match again before it runs.7. The assignment lock expired mid-assignment.
FailedToCreateOnDemandServer, so the existing retry loop runs.8.
remove=truedeleted pods before the Job.Backstop:
ReconcileOnDemandServerJobs, every minuteapp=game-server, role=match, match-id(labels added to new Jobs), or namedm-<uuid>(existing Jobs).ended_at/cancels_at+tv_delay+ 10 min, orTests: 5 new specs:
matches.controller.match-eventsmatch-assistant.on-demand-teardownmatch-assistant.reconcile-jobsStopOnDemandServerReconcileOnDemandServerJobs36 tests failed on the unfixed code, then 15 more on the first version of the fix after review. All 60 pass.
Open questions
activeDeadlineSecondsfor a match that hasn't ended but whose pod never boots (still kept today).backoffLimit: 10.cache.service.ts.#521 Playcast live stream fails
Problem: in TV mode with
use_playcast, the game streamer never showed the match; the relay answered 404 "broadcast not found".Root cause
GameStreamerService.buildConnectEnvhardcodedPLAYCAST_URL=https://tv.5stack.gg/<matchId>.https://${RELAY_DOMAIN}/<matchId>. So any install other than 5stack.gg (DEAFCS, dev) asked a relay with no broadcast for that match, both at pod start and on stream switching.signup_fragmentwas always 0./syncreturnedtick/tpsetc. as strings.Fix
PLAYCAST_URLis built fromappConfig.relayDomain.Tests
game-streamer.service.spec.ts(relay domain). Before the fix it gothttps://tv.5stack.gg/m-1.match-relay.service.spec.ts:/sync#583 Playcast matches do not get marked as finished
Evidence (match
f14e4aca, from the attached logs):cancels_at = NOW() + live_match_timeoutarmedWaitingForTVpublished with the winner; "deferring HandleEndOfMap by 115s"Received signal to stop the match(SIGUSR1). HandleEndOfMap, UploadingDemo and Finished never happenRoot cause
stopOnDemandServer, which fires with no delay when a match is Canceled. The timing fits a 30-minutelive_match_timeoutand the 21:29 run ofCancelExpiredMatches.cancels_atonce the map reachedWaitingForTV.isAwaitingWarmupcounted only Knife, Live, Overtime and Paused as started. So the played match:abandoned_matchesrows for everyone who left after the final roundRemoveCancelledMatchesa day laterMatchManagerstops atWaitingForTV.Fix
WaitingForTVorUploadingDemopushescancels_attoNOW() + tv_delay + live_match_timeout(Live matches only).auto_cancel_duration).update_match_statesettles the match. The server is asked for the next map only if the match is still Live.is_readyis check-in state, and a Canceled tournament match never advances the bracket.MatchMapStatusEventignores a lateWaitingForTV/UploadingDemo/Finishedaimed at a map that isn't in play, so it can't overwrite the next, unplayed map.Tests
CancelExpiredMatches.spec.ts:MatchMapStatusEvent.spec.tstest/match-scoring.spec.ts› "the end-of-map window": bothcancels_atwindows, plus a guard that finishing a WaitingForTV map finishes the match.Follow-ups, not in this PR
/syncand retry beforeplaycast.HandleEndOfMapafter a plugin reload.mp_match_restart_delayrace on game nodes.cancels_aton each round.