Keep NMR'd players removable across phases they could not order in - #1312
Closed
johnpooch wants to merge 2 commits into
Closed
Keep NMR'd players removable across phases they could not order in#1312johnpooch wants to merge 2 commits into
johnpooch wants to merge 2 commits into
Conversation
can_remove_member consulted nmrd_member_ids, which only looked at the single most recent completed phase. A player who missed orders in a movement phase stopped being removable as soon as any later phase resolved in which they had nothing to order — a retreat phase with no dislodged units, or an adjustment phase with no builds or disbands. Those phase states carry has_possible_orders=False and are left with a null orders_outcome by _set_orders_outcome, so the NMR simply dropped out of view and the game master lost the remove control with no explanation. Resolve each member's outcome from the most recent completed phase in which they actually had orders to give, walking back past phases they could not order in. A member who submitted orders after missing them is still not removable. The list and retrieve prefetches narrowed phase_states to the current and latest completed phase, so the walk would have found nothing beyond those; both now also load each member's latest completed orderable phase state via a set-based subquery, keeping the endpoints at a constant query count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YD3HRmfKzkav1N9x4LS5jV
Contributor
|
Warning WIP limit exceeded. There are now 8 open pull requests — the project target is 5 or fewer. Please close or merge an existing PR before continuing with this one. |
Three fixes from /review-pr on the previous commit. The backwards walk called phase.phase_states.all() per completed phase, which is free when phases are prefetched but issues one query each when they are not. can_remove_member on a bare Game went from 4 to 24 queries against a 20-phase history, and MemberSerializer.get_removable reaches the same path via obj.game. Resolve the outcomes through a single DISTINCT ON query when the prefetch cache is absent, mirroring how current_phase already chooses between prefetched and live data. Query count is now flat at 3 regardless of history depth. The walk also never expired, so an eliminated player who missed orders before elimination stayed removable for the rest of the game: their later phase states are all non-orderable and skipped, leaving the old NMR permanently in view. Guard on eliminated, matching Member.replaceable which already excludes eliminated members. The new tests placed completed phases at ordinal 1, colliding with the ordinal the active game factory assigns its live phase. Since current_phase is the highest ordinal broken by id, the completed phase shadowed the live one, so _lock_active_phase returned None and the removal never exercised _vacate_phase. Lay the history out below the live phase and move the live phase above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YD3HRmfKzkav1N9x4LS5jV
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.
What this PR does
Fixes a Discord bug report: a GM could not remove a player who had NMR'd, while removing two other players from the same game worked fine.
Game.can_remove_membergates onnmrd_member_ids(service/game/models.py), which only ever looked at the single most recent completed phase. A player who missed orders in a movement phase stopped being removable as soon as any later phase resolved in which they had nothing to order — a retreat phase with no dislodged units, or an adjustment phase with no builds or disbands.Those phase states carry
has_possible_orders=False, and_set_orders_outcome(service/phase/models.py:298) only writes an outcome for states withhas_possible_orders=True, so theirorders_outcomestaysNULL. The NMR simply dropped out of view. The frontend hides the control entirely whenremovableis false (packages/web/src/components/PlayerInfoContent.tsx:103), so the GM got no error — the button was just gone.This also explains why the other two removals succeeded: they were players whose miss landed in the latest completed phase, or who were already in civil disorder.
The change
nmrd_member_idsnow resolves each member's outcome from the most recent completed phase in which they actually had orders to give, walking back past phases they could not order in. A member who submitted orders after missing them is still not removable.It resolves those outcomes two ways, mirroring how
current_phasealready chooses between prefetched and live data:with_list_dataandwith_retrieve_datanarrowedphase_statesto the current and latest completed phase, so the walk would have found nothing beyond those; both now also load each member's latest completed orderable phase state via a set-based subquery, matching the existingdistinct(...)idiom beside it. Endpoints stay at a constant query count.IsRemovableMemberviaresolve_game,MemberSerializer.get_removableviaobj.game): a singleDISTINCT ONquery. Flat at 3 queries regardless of history depth.Also guards removal on
member.eliminated, matchingMember.replaceablewhich already excludes eliminated members. Without it, the new backwards walk never expires, so an eliminated player who missed orders before elimination would stay removable for the rest of the game.No migration, no serializer or schema change, so no codegen. The fix is computed at read time: affected games become removable as soon as this deploys.
Checklist
/review-pragainst this PR in Claude Code and addressed (or responded to) its findingsReview findings
/review-prraised four findings on the first commit. Three are fixed in99d1ac3:phase_states.all()per phase. Measured 24 queries on a 20-phase history; now 3, flat. Fixed by the dual path above, with a test that pins the count.eliminatedguard, with a test.current_phaseis the highest ordinal broken by id, the completed phase shadowed the live one and the removal never exercised_vacate_phase. The history now sits below the live phase.One finding I did not fix, flagging for your call:
latest_orderable_phase_state_idsis not scoped to the page's games, so Postgres computes theDISTINCT ONover the wholePhaseState ⋈ Phaseset. The query count stays at 8, so no test catches it. I left it because the two subqueries directly above it (current_phase_ids,latest_completed_phase_ids) are already global in exactly the same way, and diverging from them unilaterally felt like the wrong call — butPhaseStateis a much bigger table thanPhase, so the argument is weaker here.The clean fix is to stop deriving this from phase history at all and denormalise onto
Member(amissed_ordersboolean maintained at resolution). That makes it O(1) everywhere, removes both prefetch changes, and letsnmrd_member_idsgo away entirely. It needs a migration with a backfill, which is an architectural decision I did not want to make silently. Happy to do it in a follow-up if you want it.On tests
Six new tests in
service/member/tests.pyand one inservice/game/tests.py. I verified each fails without the specific change it covers:origin/main, the three "stays removable" tests and the deep-history query-count test fail.removable is False, because the walk silently sees no orderable states.Full backend suite: 2365 passed, 8 skipped.
On screenshots
No frontend code changed. The visible effect is that the pre-existing "Remove" control appears for a player it was wrongly hidden for; there is no new or altered component, layout, copy, or state to show. Flagging this rather than silently skipping the checklist item.
Note on scope
I could not confirm the diagnosis against the reporter's actual game — production queries were blocked by the permission classifier in this session. The diagnosis is instead confirmed by local reproduction: the failing scenario reproduces exactly as described (403 on kick) against a game with an NMR followed by a non-orderable phase.
🤖 Generated with Claude Code
https://claude.ai/code/session_01YD3HRmfKzkav1N9x4LS5jV