Skip to content

feat: rework fours to discard two cards at random - #1384

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

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

Conversation

@itsalaidbacklife

Copy link
Copy Markdown
Contributor

Issue number

Relevant issue number

  • Resolves #

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

The Four is the weakest card in the game — a weak effect on a low rank with poor points and
scuttling value. And because the victim chose which two cards to discard, the effect always
stripped their two worst cards.

This buffs it: a Four one-off now discards two cards chosen at random from the opponent's
hand. Worst case it matches the old effect; usually it takes something that actually hurts.

Ships as a beta for the Spades season, announced on the home page, with a community poll
toward the end of the year. It's one of the four trials covered by the Patreon post the
announcement links to. Rules version bumped to 3.0.0.

This is the first of the two Four variations; Reveal-three (opponent reveals three, you pick
two to discard) is a separate future branch.

The new rule

  • A Four one-off discards two random cards from the opponent's hand.
  • Fewer than two cards in hand → they discard whatever they have; an empty hand discards
    nothing. A Four is therefore always playable — today's "opponent has no cards in hand"
    block is removed.
  • No player input step. The Four resolves immediately, like an Ace or a Six. No dialog, no
    pause, no second move.
  • The discarded cards go to the scrap and are named in the log.
  • Playing a 4 for points or as a scuttle is unchanged.

Backward compatibility — the interesting part

Stored games are re-rendered per frame (unpackGamestate → createSocketEvents), never
re-executed through the move helpers. So the write path is safe to delete; the read path is not.
Retained and commented as legacy-only:

Kept Why removing it breaks old games
GamePhase.RESOLVING_FOUR: 4 validate-gamestate.js hard-throws on unknown phases on every unpack → old frames 500
getActivePlayerPNum's RESOLVING_FOUR case runs per frame; its default: throws
MoveType / SocketEvent RESOLVE_FOUR old rows store the literal string 'resolveFour'
get-log.js RESOLVE_FOUR arm getLog re-renders the whole log from raw rows every request
inGameEvents.js RESOLVE_FOUR case that switch has no default: — without it, stepping onto the frame calls nothing and the board stays on the previous frame
FourDialog + showResolveFour + game.dialogs.four.* replay renders dialogs from the historical phase

get-log.js also branches on whether the row carries discardedCards: pre-3.0.0 rows deferred
the discard to a separate resolveFour row and carry none, so they keep narrating the old
rule
rather than printing undefined.

Notable implementation details

⚠️ The one genuinely dangerous interaction. get-legal-moves.js iterates
Object.values(MoveType) and destructures sails.helpers.gameStates.moves[moveType]. Keeping
the enum key (required above) while deleting the helper directory is a TypeError on every AI
move
, so RESOLVE_FOUR is added to its disallowedMoveTypes.

The validators keep an explicit case 4: return exits.success(). Deleting the arm drops
fours through to the default:, which rejects any rank not explicitly listed — it would have
made Fours unplayable. This also retires a latent bug: one-off/validate.js threw a plain
Error here instead of BadRequestError, so that path returned 500 rather than 400.

Two pre-existing bugs fixed because this change makes them reachable. processFours read
discardedCards.length unguarded while its processFives twin guards it — a Four against an
empty hand (newly legal) sends null, since create-socket-events converts empty arrays. And
GameDialogs' discard handler was the only sibling without .catch(this.handleError).

_.sampleSize returns up to n elements, so the short-hand rule needs no branching.
_ is already a Sails global and _.shuffle is the established idiom in deal-cards.js.

Deleted: moves/resolve-four/, the hasValidMoveBody arm (its default: now answers a
clean 400 for stale in-flight clients, and policies run before the controller), the AI
generator arm and its three fixtures, and cy.discardOpponent.

No schema change and no migration. discardedCards already existed.

Testing

Randomness makes exact-hand assertions impossible for large hands, so the specs are structured
around that rather than adding a test-only RNG hook: most cases give the victim exactly two
cards (deterministic), and one case with a four-card hand asserts only counts and membership.

load-fixture-gamestate gains optional phase / turn / resolved / oneOff overrides —
without them no test could stage a phase-4 state once the write path is gone, and the
retained legacy shim would ship untested. One new spec loads a stored RESOLVING_FOUR state and
asserts the frame renders. The get-log RESOLVE_FOUR arm and the socket dispatch remain
unverified by tests; they're covered by reasoning and unchanged code only.

Check Result
npm run lint clean
npm run build clean
npm run test:unit 77/77
4_fours.spec.js 8/8
Translation key parity en/de/es/fr/ukr identical keys (the game.dialogs.four ordering drift in es/fr predates this PR)

Worth exercising by hand:

  • Play a Four against a large hand several times and confirm the discard varies.
  • Confirm the log names both cards, and one card when they hold only one.
  • Play a Four against an empty hand — legal now, discards nothing.
  • Play a Four off a seven.
  • Play a game vs the AI and watch the server log for TypeErrors on move generation.
  • Replay a game created before this branch that contains a Four: step onto the discard
    frames and confirm the board advances, the dialog renders, and the log lines read correctly.
  • Check the announcement and its Patreon link in a non-English locale.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV

itsalaidbacklife and others added 2 commits September 19, 2026 12:33
Fours are the weakest card in Cuttle: a weak effect on a low rank with poor
points and scuttling value. Because the victim chose which two cards to
discard, the effect always stripped their two *worst* cards, which is what
keeps the 4 well below every other card in the game.

This buffs it: a four one-off now discards two cards chosen AT RANDOM from
the opponent's hand. Worst case it matches the old effect; usually it takes
something that actually hurts.

Ships as a beta for the Spades season, announced on the home page, with a
community poll toward the end of 2026. It is one of the four trials covered
by the Patreon post the announcement links to.

Rules:
- Two cards are discarded at random; fewer than two in hand discards whatever
  is there, so a four is now always playable (the "opponent has no cards in
  hand" block is gone).
- There is no player input step anymore. A four resolves immediately, like an
  ace or a six -- no dialog, no pause, no second move.

Notable details:
- resolve/execute.js moves `case 4` out of the deferred 3/4/5/7 group, which
  set `phase: oneOff.rank` and returned early, into the immediate group. The
  new resolve/four.js uses _.sampleSize, which returns *up to* n elements and
  so expresses the short-hand rule without branching.
- The one-off validators keep an explicit `case 4: return exits.success()`.
  Deleting the arm would drop fours through to the default, which rejects any
  rank not listed. This also retires a latent bug: one-off/validate.js threw a
  plain Error here rather than BadRequestError, so the path returned 500.
- get-log.js branches on whether the row carries discardedCards. Pre-3.0.0
  rows deferred the discard to a separate resolveFour row and carry none, so
  they keep narrating the old rule instead of printing undefined.
- processFours lacked the null guard its processFives twin has. A four against
  an empty hand sends discardedCards as null (create-socket-events converts
  empty arrays), which would have crashed the reveal animation.

Backward compatibility -- stored games are re-rendered per frame through
unpackGamestate and createSocketEvents, never re-executed, so the write path
is safe to delete but the read path is not. Retained and commented as legacy:
GamePhase.RESOLVING_FOUR (validate-gamestate hard-throws on unknown phases),
the getActivePlayerPNum case (its default throws), MoveType/SocketEvent
RESOLVE_FOUR, the get-log RESOLVE_FOUR arm, the inGameEvents RESOLVE_FOUR case
(that switch has no default, so a missing case leaves the board on the prior
frame), and FourDialog with its store computeds and i18n.

Deleted: moves/resolve-four/, the hasValidMoveBody arm (its default now
answers a clean 400 for stale clients), the AI generator arm and its three
fixtures, and cy.discardOpponent. RESOLVE_FOUR is added to get-legal-moves'
disallowedMoveTypes -- it indexes helpers by enum value, so keeping the key
while removing the directory would TypeError on every AI move.

load-fixture-gamestate gains optional phase/turn/resolved/oneOff overrides so
the retained legacy path stays testable; without them no test could stage a
phase-4 state once the write path is gone.

Rules version bumped to 3.0.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
itsalaidbacklife added a commit that referenced this pull request Sep 19, 2026
The random-fours beta (#1384) goes in first and takes 3.0.0, so this rework
moves to 4.0.0. Adds the fours history line and updates the one doc reference
that named the old number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
The converted empty-hand test waited on #cannot-counter-dialog, which belongs
to the resolving player. This spec plays as the caster, so it has to wait on
the counter scrim and let the opponent resolve, matching the other seven
one-off tests in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
@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 labels Sep 19, 2026

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

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.

1 participant