Skip to content

feat: rework fours to reveal three cards, caster chooses two to discard - #1443

Open
itsalaidbacklife wants to merge 3 commits into
mainfrom
feat/reveal-three-fours
Open

itsalaidbacklife wants to merge 3 commits into
mainfrom
feat/reveal-three-fours

Conversation

@itsalaidbacklife

@itsalaidbacklife itsalaidbacklife commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Issue number

Relevant issue number

Please check the following

  • Do the tests still pass? (see Run the Tests)
  • Is the code formatted properly? (see Linting (Formatting))
  • For New Features:
    • Have tests been added to cover any new features or fixes?
    • Has the documentation been updated accordingly?

What this does

Replaces the 3.0.0 random-discard Four with Reveal-three: the opponent reveals three cards of their choice from hand, and the Four's player chooses two of those for them to discard. Rules version bumped to 4.0.0, and the Spades-season beta announcement now describes this variation.

Victim's hand What happens
4+ cards Victim reveals 3 (RESOLVING_FOUR_REVEAL), then the caster picks 2 of them (RESOLVING_FOUR_DISCARD)
Exactly 3 Reveal skipped; the whole hand is revealed and the caster picks 2
2 or fewer Discarded outright as the Four resolves, with no input steps

A Four stays playable against any hand, including an empty one. Playing a 4 for points or as a scuttle is unchanged.

⚠️ Deploy step

Production and staging use migrate: 'safe', so the new column must be added by hand before deploying:

ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "revealedCards" jsonb;

Implementation

Two new phases and moves, following the resolveFive pattern for a player-input step:

  • GamePhase.RESOLVING_FOUR_REVEAL: 8. The victim is active, (turn + 1) % 2. The only legal move is revealFour { revealedCards: [3 ids] }.
  • GamePhase.RESOLVING_FOUR_DISCARD: 9. The caster is active, turn % 2. The only legal move is discardFour { discardedCards: [2 ids] }. This completes the Four: it scraps the one-off, runs the hand-limit check and advances the turn.
  • resolve/four.js picks the branch by hand size. resolve/execute.js returns early when the Four is now waiting on either new phase, the same way it already does for 3/5/7.
  • Both moves are wired through hasValidMoveBody, getActivePlayerPNum, AI move-body generation, get-log, MoveType and SocketEvent. getCombinations in the AI generator is hoisted to module scope so both moves can use it.

Storage: the revealed cards stay in the victim's hand and are recorded in a new revealedCards column (nullable jsonb, default []). It's threaded through the model, pack/unpack, the validate-gamestate whitelist, deal-cards and load-fixture-gamestate, which gains a revealedCards override for staging phase 9. The socket game payload sends it to both players, outside players[].hand, since both players know those cards. It is deliberately left out of validate-all-cards, because the cards are still in the hand and would trip the duplicate check.

Backward compatibility: everything #1384 retained for pre-3.0.0 replays (RESOLVING_FOUR, resolveFour, FourDialog) is untouched. get-log keeps the "at random" wording only for rules 3.x games, which it checks with game.rulesVersion. 4.0.0 games that discard a short hand on resolution are narrated without it. Legacy resolve rows are identified by their RESOLVING_FOUR phase (every pre-3.0.0 four resolve entered it), so a Four against an empty hand is no longer mislogged as "must discard two cards"; it now reads "…has no cards to discard."

Fixes #1440: create-socket-events sends the one-off's player as playedBy on a resolve, but the RESOLVE case 4 socket handler (from #1384) treated it as the resolver. So when a hand was discarded on resolution, the victim saw their own discards flash in the caster's hand, and the caster never saw them. The check is inverted and the comment corrected. 4_fours.spec.js now asserts the caster sees the reveal and spies on processFours to assert the victim never runs it; both assertions fail with the old check.

Frontend:

  • New <script setup> dialogs FourRevealDialog and FourDiscardDialog, modelled on DiscardToHandLimitDialog.
  • New waiting overlays. The victim's overlay shows their three revealed cards while the caster chooses.
  • New store getters and actions.
  • New inGameEvents cases; discardFour animates the discards out of the caster's view of the hidden hand.

Copy: new dialog, overlay and snackbar keys, plus updated move-effect text, rules page text and announcement, in all five locales with identical key sets. The announcement id changed so the popup shows again.

Please describe additional details for testing this change

Check Result
npm run lint clean
npm run build clean
npm run test:unit 122 sails + 6 client passing
4_fours.spec.js 11/11
player_sevens.spec.js 24/24
reconnecting.spec.js 35/35
handLimit.spec.js 6/6
layout.spec.js 6 passing (4 pending: the pre-existing skipped aesthetic tests)
winConditions.spec.js 20/20
  • New unit coverage:
    • four.spec.js covers the resolve branches by hand size, the reveal and discard validators, the active player and the log text.
    • New AI fixtures cover both phases.
  • E2E updates:
    • 4_fours.spec.js is rewritten. It covers each hand size from both perspectives, glasses, a staged phase-9 state, and the legacy phase-4 replay.
    • The reconnect spec reloads mid-reveal and mid-choice.
    • The concede-mid-four test now concedes during the reveal.
    • A "Four dialogs layout" test is restored.
  • Flake: in the first e2e run, the first 4s test passed only on retry. No error was captured, and it was the first test against a freshly booted Vite server. A second full run of the spec passed 11/11 with no retries.

Worth exercising by hand:

  • Play a Four against 5, 3, 2 and 0 cards.
  • Play a Four off a seven.
  • Reload during each new phase, from both seats.
  • Play vs the AI and watch the server log for errors in move generation.
  • Replay a pre-3.0.0 game and a 3.x game that contain a Four, and check the frames and log text.
  • Review the de/es/fr/ukr strings. They were written for this PR and need a native-speaker pass.

Coordination: the unmerged feat/nines-discard-two branch also claims rules 4.0.0, so whichever merges second renumbers. If nines lands first and this becomes 5.0.0, games played under 4.x would still have random Fours. The get-log check (parseInt(game.rulesVersion, 10) === 3) would then need to cover majors 3 and 4.

🤖 Generated with Claude Code

The second of the two Four variations trialled this Spades season. A four
one-off now makes the opponent reveal three cards of their choice from hand,
and the four's player chooses two of those for them to discard. Replaces the
3.0.0 random discard. Rules version bumped to 4.0.0.

Rules:
- 4+ cards in hand: the victim reveals three (RESOLVING_FOUR_REVEAL), then the
  caster chooses two of them (RESOLVING_FOUR_DISCARD).
- Exactly 3 cards: the reveal is skipped; the whole hand is revealed.
- 2 or fewer: discarded outright as the four resolves. A four stays playable
  against any hand, including an empty one.

Implementation:
- New GamePhases RESOLVING_FOUR_REVEAL (8) and RESOLVING_FOUR_DISCARD (9), and
  MoveTypes revealFour / discardFour with validate/execute helpers, policy
  arms, AI move-body generation, active-player cases, and log lines.
- Revealed cards persist between the two moves in a new nullable jsonb
  `revealedCards` column on GameStateRow, threaded through pack/unpack,
  validate-gamestate, deal-cards, load-fixture-gamestate, and the socket game
  payload (sent to both players, outside opponent-hand hiding).
- get-log keeps the "at random" wording for rules 3.x games only.
- Legacy RESOLVING_FOUR / resolveFour replay paths are retained untouched.
- New reveal and choose-discard dialogs, waiting overlays (the victim sees
  their revealed cards), store getters/actions, and socket handling.
- Announcement copy, rules text, and move-effect text updated in all locales,
  under a new announcement id so it shows again.

Deploy: production and staging use migrate: 'safe', so before deploying run
  ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "revealedCards" jsonb;

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@itsalaidbacklife itsalaidbacklife added version-major A large update that warrants changing the MAJOR version of the app e.g. (4.0.0 => 5.0.0) rules change Adjustment to the rules of the game announcement labels Oct 16, 2026
itsalaidbacklife and others added 2 commits October 17, 2026 17:58
A four resolved against an empty hand carries no discardedCards, so it was
mistaken for a legacy (rules < 3.0.0) row and logged "must discard two cards".
Identify legacy rows by their RESOLVING_FOUR phase instead, which every pre-3.0.0
four resolve entered and no later one does, and give the empty hand its own line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create-socket-events flips playedBy on a resolve to the player who played the
one-off, but the RESOLVE case 4 handler assumed it was the resolver. So the
victim saw their own discards flash in the caster's hand, and the caster never
saw the reveal. Invert the check, correct the comment, and cover both sides in
4_fours.spec.js.

Fixes #1440

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

announcement rules change Adjustment to the rules of the game version-major A large update that warrants changing the MAJOR version of the app e.g. (4.0.0 => 5.0.0)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Four one-off: discarded cards flash in the caster's hand on the victim's screen

1 participant