feat: nines return two cards to hand, unfrozen - #1378
Open
itsalaidbacklife wants to merge 4 commits into
Open
itsalaidbacklife wants to merge 4 commits into
itsalaidbacklife wants to merge 4 commits into
Conversation
Contributor
Author
|
Migration has been run |
4 tasks
Nines are one of the highest ranks in the deck but have the most niche, least impactful one-off. This buffs the effect: a nine one-off now returns TWO cards from the opponent's board to their controller's hand, and neither returned card is frozen, so both are immediately replayable. Ships as a beta for the Spades season, announced on the home page, with a community poll toward the end of 2026 deciding whether it stays. Rules: - A nine one-off requires exactly two distinct targets, any mix of point cards, royals/glasses, and top jacks. - Both targets resolve SIMULTANEOUSLY: each card goes to the hand of whoever controlled it before the nine resolved. So targeting a jack and the point card it is stealing sends both to the jack holder's hand. - Only the top jack of a stack is targetable. - Any queen now blocks a nine outright: a queen protects everything else, leaving itself the only legal target, so two legal targets are impossible. Enforced in the validator and pre-emptively in the move-choice UI. Twos keep their existing queen rules. Notable details: - isFrozen was never persisted; it was re-derived on unpack from "resolved is a 9 and this hand card equals targetCard". That derivation is removed, so nothing freezes anymore. The freeze plumbing is left dormant rather than deleted so a poll-driven revert stays a clean git revert. - Second-target state is carried in additive nullable columns (oneOffTargetTwo, oneOffTargetTwoType, targetCardTwo) rather than widening the existing singular fields, which twos and the seven flow also read. - Nine validation is extracted to a shared helper called from both the one-off and seven-one-off validators, which previously duplicated it. - The AI move generator now enumerates two-target pairs, and its jack bodies no longer omit targetType. - Fixes a pre-existing leak: the fizzle path never cleared the one-off target fields. It was masked because the next one-off overwrote them. Selected targets get a distinct treatment (pink border/tint plus a check badge) and the green "valid target" overlay is suppressed once a card is chosen, so selection does not read as disabled. Requires a manual migration before deploy, as the repo has no migration tooling and staging/production use migrate: 'safe': ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "oneOffTargetTwo" text; ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "oneOffTargetTwoType" text; ALTER TABLE gamestaterow ADD COLUMN IF NOT EXISTS "targetCardTwo" text; Rules version bumped to 3.0.0, since 2.0.0 is taken by the topdeck-nines rework (#1379) that landed on main first. This change replaces that effect: the topdeck animation and its to-deck transitions are removed, and rank 9 now animates toward the controlling player's hand again. The shared is-top-jack helper introduced by #1379 is kept and reused, so the top-jack rule lives in one place for both twos and nines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
The three nine recording scenarios came from the topdeck rework (#1379) and each played a nine at a single target, which is no longer a legal move. Give each one a second target so they run again, and rewrite the comments and the post-resolve assertions to describe cards returning to hand rather than travelling to the deck. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
itsalaidbacklife
force-pushed
the
feat/nines-discard-two
branch
from
September 19, 2026 14:20
c7b6952 to
b7af675
Compare
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
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
Nines are one of the highest ranks in the deck but have the most niche, least impactful
one-off. This buffs the effect: a nine one-off now returns two cards from the
opponent's board to their controller's hand, and neither returned card is frozen, so
both are immediately replayable.
Ships as a beta for the Spades season, announced on the home page, with a community
poll toward the end of the year deciding whether it stays. Rules version bumped to
4.0.0(
2.0.0is taken by the topdeck-nines rework #1379, which landed on main first, and3.0.0bythe random-fours beta #1384, which goes in ahead of this).
Rebased onto topdeck nines (#1379)
This branch and #1379 were competing alternatives for the same rework, and #1379 merged
first. Rebasing means this PR now replaces the topdeck effect rather than the original
frozen-bounce. What that changed:
processNinesand its three-stage flip-and-travelsequence, the
topdeckedCard/topdeckedCardZonestore state, the fourto-deck-*transitions in
transitions.scssandTransitions.json, and GameView's per-zonetransition selection all went with it. Rank 9 animates toward the controlling player's
hand again, and the
RESOLVEsocket handler goes back toprocessSevens-or-update.is-top-jack.jsis kept and reused. feat: rework nines to topdeck the target card #1379 extracted the top-jack rule into a sharedhelper and — correctly — applied it to twos as well, where targeting a buried jack
silently reassigns control.
validate-nine-targets.jsnow calls that helper and reportsthe shared
game.snackbar.oneOffs.onlyTopJackkey instead of carrying its own copy, sothe rule lives in exactly one place for both ranks.
playerFaceCardsTransition/opponentFaceCardsTransitionnine-arms were gated onlastEventTargetType, which isalways
null(it is never put onlastEvent), so they were dead. They key on theone-off rank alone now, which is sufficient: under this rule a rank-9 resolve always
sends its targets to hand. The always-null
lastEventTargetTyperef is gone rather thanreinstated.
played a nine at a single target, so they were no longer legal moves. Each now picks a
second target, and their assertions describe cards returning to hand.
assertGameState's optionaldeckLengthfrom feat: rework nines to topdeck the target card #1379 is kept — it is opt-in andgenerally useful, even though nothing here topdecks.
The new rule
royals/glasses eights, and top jacks.
controlled it before the nine resolved. So targeting a jack and the point card it is
stealing sends both to the jack holder's hand — the point card does not first revert to
its original owner.
has, leaving itself the only legal target, so two legal targets are impossible. Enforced
in the validator and pre-emptively in the move-choice card. Twos keep their existing
queen rules (blocked only by 2+).
The repo has no migration tooling and staging/production use
migrate: 'safe', so thesenullable columns must be added before this deploys:
All nullable, so legacy rows and in-flight games keep loading. Worth confirming the table
name against the deployed schema first.
Notable implementation details
isFrozenwas never persisted. It was re-derived on unpack from "resolved is a 9 andthis hand card equals
targetCard" (unpack-gamestate.js). Removing the freeze thereforeneeded that derivation deleted too, not just the
isFrozen: truewrites — otherwise the DBround-trip re-froze target #1. Once both are gone, nothing in the game can freeze a card.
The freeze plumbing is left dormant, not deleted. Since a poll may revert this, the
playedCard.isFrozenguards, the snowflake UI, and the related i18n stay in place so therevert is a clean
git revert. If the poll keeps the change, a follow-up should removethem — worth an issue at merge time.
Additive columns rather than widening existing fields.
oneOffTarget/oneOffTargetTypeare read by twos, the seven flow, the log, the socket payload, and thestore; changing their type would be a breaking read for every existing row.
Nine validation is now shared.
one-off/validate.jsandseven-one-off/validate.jspreviously duplicated the whole
case 2: case 9:block. Extracted toapi/helpers/game-states/validate-nine-targets.jsand called from both.AI updated.
get-move-bodies-for-move-type.jsnow enumerates two-target pairs fornines (skipping generation entirely when blocked), so the bot stays a real opponent for the
trial. Also fixes a pre-existing bug where its jack bodies omitted
targetType.Fixes a pre-existing leak. The fizzle path in
resolve/execute.jsnever cleared theone-off target fields. It was masked because the next one-off overwrote them; a new
assertion in
targeting_cleanup.spec.jsexposed it. Now all four target slots clear onfizzle.
Selection UX
Targets are picked on the board and sent with a Confirm button, styled after
BaseDialog's activator fab. Rank 2 still fires on the first click.Selected cards get a pink border/tint plus a check badge, and the green "valid target"
overlay is suppressed once a card is selected — otherwise the overlay renders on top of
the selected styling and the card reads as disabled rather than chosen. Clicking a
chosen target deselects it; a third click is ignored. Three-way read: plain (not
targetable), green (targetable), pink + badge (selected).
Please describe additional details for testing this change
Local results after the rebase:
npm run lintnpm run buildnpm run test:unit:clientgame.dialogs.fourordering drift in es/fr is unchanged by this PRnpm run test:unit(sails)The sails unit tests and the full Cypress suite are covered by CI on this commit rather
than locally. Pre-rebase, the local run was 3 client + 83 sails passing,
one-offs/**118/118, and handLimit/basicMoves/vsAI/reconnecting 64/64.
Worth exercising by hand:
holder's hand.
"You can't play a Nine while your opponent has a Queen".
validation errors in the log.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV