feat: rework fours to discard two cards at random - #1384
Open
itsalaidbacklife wants to merge 3 commits into
Open
itsalaidbacklife wants to merge 3 commits into
itsalaidbacklife wants to merge 3 commits into
Conversation
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
4 tasks done
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
This branch has not been deployed
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.
Issue number
Relevant issue number
Please check the following
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
nothing. A Four is therefore always playable — today's "opponent has no cards in hand"
block is removed.
pause, no second move.
Backward compatibility — the interesting part
Stored games are re-rendered per frame (
unpackGamestate→createSocketEvents), neverre-executed through the move helpers. So the write path is safe to delete; the read path is not.
Retained and commented as legacy-only:
GamePhase.RESOLVING_FOUR: 4validate-gamestate.jshard-throws on unknown phases on every unpack → old frames 500getActivePlayerPNum'sRESOLVING_FOURcasedefault:throwsMoveType/SocketEventRESOLVE_FOUR'resolveFour'get-log.jsRESOLVE_FOURarmgetLogre-renders the whole log from raw rows every requestinGameEvents.jsRESOLVE_FOURcasedefault:— without it, stepping onto the frame calls nothing and the board stays on the previous frameFourDialog+showResolveFour+game.dialogs.four.*get-log.jsalso branches on whether the row carriesdiscardedCards: pre-3.0.0 rows deferredthe discard to a separate
resolveFourrow and carry none, so they keep narrating the oldrule rather than printing
undefined.Notable implementation details
get-legal-moves.jsiteratesObject.values(MoveType)and destructuressails.helpers.gameStates.moves[moveType]. Keepingthe enum key (required above) while deleting the helper directory is a TypeError on every AI
move, so
RESOLVE_FOURis added to itsdisallowedMoveTypes.The validators keep an explicit
case 4: return exits.success(). Deleting the arm dropsfours through to the
default:, which rejects any rank not explicitly listed — it would havemade Fours unplayable. This also retires a latent bug:
one-off/validate.jsthrew a plainErrorhere instead ofBadRequestError, so that path returned 500 rather than 400.Two pre-existing bugs fixed because this change makes them reachable.
processFoursreaddiscardedCards.lengthunguarded while itsprocessFivestwin guards it — a Four against anempty hand (newly legal) sends
null, sincecreate-socket-eventsconverts empty arrays. AndGameDialogs'discardhandler was the only sibling without.catch(this.handleError)._.sampleSizereturns up to n elements, so the short-hand rule needs no branching._is already a Sails global and_.shuffleis the established idiom indeal-cards.js.Deleted:
moves/resolve-four/, thehasValidMoveBodyarm (itsdefault:now answers aclean 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.
discardedCardsalready 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-gamestategains optionalphase/turn/resolved/oneOffoverrides —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_FOURstate andasserts the frame renders. The
get-logRESOLVE_FOURarm and the socket dispatch remainunverified by tests; they're covered by reasoning and unchanged code only.
npm run lintnpm run buildnpm run test:unit4_fours.spec.jsgame.dialogs.fourordering drift in es/fr predates this PR)Worth exercising by hand:
frames and confirm the board advances, the dialog renders, and the log lines read correctly.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV