Skip to content

Seat the game master as a member so they can post in Public Press - #1311

Merged
johnpooch merged 1 commit into
mainfrom
claude/game-master-public-press-chat-gbj1il
Sep 6, 2026
Merged

Seat the game master as a member so they can post in Public Press#1311
johnpooch merged 1 commit into
mainfrom
claude/game-master-public-press-chat-gbj1il

Conversation

@johnpooch

Copy link
Copy Markdown
Owner

What this PR does

A non-playing game master could read Public Press but not post in it. Every chat mechanism keys on MemberChannelMessage.sender is a non-null FK to it (service/channel/models.py:127), ChannelMember tracks reads, the unread-count subquery joins through it (service/channel/models.py:39-56), and the channel_message notification audience derives from it — but the game master was only a User FK on Game, with no member row (service/game/serializers.py:578-581 skipped creating one).

This makes Member the participant row rather than strictly a player seat, so the game master gets chat authorship, read tracking, unread counts and notifications with no nullable columns and no second identity shape in the chat layer. Scoped deliberately to Public Press only — private channels, orders, draws and seats stay player-only, leaving the neutrality questions open for a follow-up.

Backend only. No visible change in the web app yet — the serialized member roster is player-scoped, so the frontend still finds no currentMember for a game master and still hides the composer. Rendering a game master's messages (avatar, "Game Master" label, colour) is the follow-up; ChannelMemberSerializer gains isGameMaster so that work has something to key on.

Model

Member gains a kind discriminant (player / game_master) and MemberQuerySet.players(). A game master holds a member row with no nation, no phase state, and no seat against the variant's nation count. Game.seat() takes the kind, so game creation seats the game master into Public Press exactly the way it seats a creator who plays.

Game.game_master stays the source of truth for who the game master is — the member row carries participation. That is a deliberate denormalisation, following the existing Member.sandbox/Game.sandbox precedent; only game creation and the backfill write both. A new unique constraint keeps it to one game master per game, and the existing (game, user) constraint now stops a game master also holding a seat at the database level.

Call-site audit

Everything meaning "player" now goes through players(): game start and the full-seat check (start_if_full would otherwise never fire, since the roster would be one over), IsSpaceAvailable, nation assignment and phase-state creation on both start and phase advance, draw proposals, solo-victory detection, the abandonment check (a game master never enters civil disorder, so a game master game could never be abandoned), admin reassignment, staging reclaim, empty-pending deletion, can_leave, can_remove_member, and the three member prefetches that feed the serialized roster.

Permissions

The member permission classes are renamed to say Player and scoped to seats; two new Participant classes admit any member row. Only ChannelMessageCreateView and ChannelMarkReadView use the participant variants.

Migrations

0013 adds the field and constraint; 0014 backfills a member row plus a Public Press ChannelMember for every existing game with a game master.

Notes

  • Prior art: this was scoped as Let the game master and admin participate in chat #905 (closed as stale by the bot over Joren's objection), which proposed the opposite approach — a nullable sender plus a user FK. That leaves read tracking and unread counts still broken for the game master, and forks the sender shape for every future game master chat feature. Its "Approach" section also cites an IsGameMemberOrGameMaster permission that does not exist.
  • Anonymous games: a game master's identity is already public via GameMasterSerializer, so _is_masked now returns False for them — otherwise the header would show a real name and their messages "Anonymous".
  • Opening this puts the repo at 4 non-dependabot open PRs against the soft limit of 5.

Checklist

  • This PR does one thing — no unrelated fixes, refactors, or drive-by cleanups bundled in
  • For PRs of any significant complexity: I ran /review-pr against this PR in Claude Code and addressed (or responded to) its findings
  • Tests cover the change
  • Screenshots embedded in the PR description for any visual changes (see CLAUDE.md) — n/a, no visual change

Backend suite 2357 passed, frontend 666 passed, npx tsc -b --noEmit clean. Codegen rerun with the Firebase guard satisfied, so the schema diff is the one added field with no /devices/ drift.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C7zRgVoVUWopeN7TBGkQY4


Generated by Claude Code

The game master was a User FK on Game with no Member row, but every
chat mechanism keys on Member: ChannelMessage.sender is a non-null FK
to it, ChannelMember tracks reads, unread counts subquery on it, and
the channel_message notification audience derives from it. A game
master could read Public Press but not post, mark read, or be notified.

Member becomes the participant row and gains a `kind` discriminant.
A game master holds a member row with no nation, no phase state, and
no seat against the variant's nation count; MemberQuerySet.players()
scopes everything that means "player" — seat counting, game start,
nation assignment, phase states, draws, victory, abandonment, staging
reclaim, admin reassignment. Game.seat() takes the kind, so game
creation seats the game master into Public Press the same way it seats
a creator who plays, and a data migration backfills existing games.

Permissions split along the same line: the existing member classes
are renamed to say Player and scoped to seats, and two participant
classes admit any member row. Only message-create and mark-read use
them, so the game master gains public press and nothing else —
private channels, orders, draws and seats stay player-only.

Game.game_master stays the source of truth for who the game master
is; the member row carries participation. A unique constraint keeps
it to one per game, and the existing (game, user) constraint now
stops a game master also holding a seat at the database level.

The serialized member roster is unchanged — the prefetches are
player-scoped — so there is no visible change in the web app yet.
ChannelMemberSerializer gains isGameMaster so a game master's
messages can be attributed when the frontend renders them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7zRgVoVUWopeN7TBGkQY4
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
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.

@johnpooch
johnpooch merged commit 6ccef11 into main Sep 6, 2026
24 checks passed
johnpooch pushed a commit that referenced this pull request Sep 6, 2026
main's #1311 seats the game master as a Member, so the muster queries had
to be narrowed to players or the GM would be treated as an unconfirmed
seat: they would block the early start and be vacated at the deadline.

- Game.unmustered_members() filters members.players(), matching how
  start_if_full and delete_if_empty_pending already count seats.
- IsUnmusteredMember resolves the requester through members.players().
- MemberMusterView permissions follow main's IsGameMember -> IsGamePlayer
  rename.

Adds test_game_master_does_not_block_or_lose_a_seat_in_mustering, which
fails without the players() filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cbt5ynJUeZXJ6MPuAnQ9vR
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants