feat: rework nines to topdeck the target card - #1379
Merged
Merged
Conversation
A Nine one-off now puts its target on top of the deck instead of returning it to its controller's hand. The opponent's next draw is their own card coming back, so the Nine costs them a whole turn. This ships as a beta for the Spades season, announced on the home page, with a community poll toward the end of the year. Point cards, Royals/glasses, and the top Jack of a stack are legal targets. A targeted jack's host still reverts to its owner, and non-targeted attachments on a targeted point card are still scrapped. Queen rules are unchanged. Notes: - isFrozen was never persisted; it was re-derived on unpack. That derivation runs over every converted array including deck, so it had to go, or it would have stamped isFrozen on the card the nine had just topdecked. resolve/nine.js was its only producer, so the freeze mechanic is now dormant. The rest of the plumbing is left in place so a revert stays a clean git revert. - findTargetCard matches any jack in any stack while the resolver popped the top one, so a raw request naming a buried jack scrapped the wrong card. New is-top-jack helper, called from both one-off and seven-one-off validators. - AI jack move bodies omitted targetType, so the validator rejected every one the bot tried. Fixed, and nines now enumerate point-card targets. - resolve/execute never cleared the target slots on the fizzle path. - The four nine transition computeds branched on lastEventTargetType, which is always null because resolve/execute nulls oneOffTargetType before the socket event is built. Replaced with one direction per row and dropped the dead store field. - No effect can hand a card to the inactive player any more, so you can never start a turn over the hand limit. Two handLimit tests covering that are removed (their paths are covered by the Drawing and Fives blocks); the pass-triggers-discard test now seeds the over-limit hand. No schema change, so no migration is required. 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
- is-top-jack: collapse find-then-compare into a single `some` with optional chaining, per review suggestion. - Restrict jack targeting to the top of the stack for twos as well as nines, and rename the message key to game.snackbar.oneOffs.onlyTopJack since it is no longer nine-specific. Twos already removed the exact targeted jack, but each jack's owner is derived from its position in the stack (convert-card-to-str.js infers it from attachment parity), so destroying a buried jack silently reassigns every jack above it and can flip control of the point card. The UI already offered only the top jack for both ranks; this closes the socket-level hole. - resolve/nine.js: lean on that validator guarantee and pop the top attachment instead of re-finding it by id. - Announcement: frame the change as the first of four rules variations trialling over the Spades season (two Nine buffs, two Four buffs) and link the Patreon post explaining all four. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Adds processNines, a three-stage store action in the shape of
processThrees:
1. the target flips face down where it sits (1000ms, matching
CARD_FLIP's --duration-slow)
2. it leaves the field toward the deck, and the deck count grows
3. updateGame applies the server state
It has to be staged rather than done in one render. A single rank-9
resolve can both remove a topdecked point card from the opponent's
points (which should travel toward the deck) and hand a jacked point
card back to its owner (which should travel toward that owner). A
TransitionGroup has one name per render, so those two leave directions
cannot coexist. Splitting them across renders gives each the right one,
and avoids having to persist the target's type through resolve just to
tell them apart.
The flip needs no GameCard change: withholding suit and rank satisfies
its existing isBack check, and the CARD_FLIP transition already wrapping
those images does the rotation. Data attributes keep using the real card
so test selectors stay stable.
Direction depends on where the deck sits relative to the card, via
toDeckTransition(side, area):
xs points -> down, faceCards -> down and left
above xs player -> up and left, opponent -> straight left
Jacks follow their own point row, which meant wrapping .jacks-container
in a TransitionGroup -- it was a plain div, so jacks never animated.
Also adds an optional deckLength to assertGameState, asserted against
both the store and the deck badge, and threads it through the nines
fixtures. Deck contents stay unasserted on purpose: hideDeck() replaces
every deck card with { isHidden: true } outside a seven, so a player
cannot see them. The specs prove top-of-deck placement by drawing the
card back instead, since a draw is deck.shift().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
processNines deferred updateGame to stage three, so the client kept the pre-resolve phase for the whole two second animation. GamePhase.COUNTERING drives waitingForOpponentToCounter, which meant the "waiting for opponent to counter" scrim stayed up and covered the flip and the card's trip to the deck -- it only cleared once the animation had already finished. processThrees, processFours and processFives all set phase to MAIN before animating for this reason; processNines now does the same. Caught while recording the playground clip: the animation was running correctly underneath the scrim the entire time. Also updates the existing 'Playing Nines' playground test to open on the settled board via markClipStart, pace the setup moves, and hold on the result so the flip and the move to the deck are both watchable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Companion to the existing 'Playing Nines' clip. The opponent plays a point card taking them to 20 of the 21 they need -- any point card wins for them next turn -- and the player answers with a nine, putting that card on top of the deck and dropping them back to 10. Shows the topdeck against a point card rather than a jack, and gives the effect a stake the board can display: the goal readout goes 20/21 before the nine and 10/21 after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Mirrors the nine-on-jack clip with a two, so the two effects can be compared side by side: the player puts out a point card, the opponent steals it with a jack, and the player answers by scrapping that jack. Recorded to check how the jack exits. It does not animate at all -- see the note on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Set up from the seat that owns the glasses, so the effect is visible on the recorded screen: the opponent's hand starts face up, the opponent topdecks the glasses with a nine, and the hand goes back to card backs. Also covers the branch where a glasses eight is the topdeck target. GameView suppresses is-glasses for the card being topdecked so it falls through to GameCard's isBack check and flips to a card back rather than holding the goggles art. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Review was right that the third stage was unnecessary, and that the
conflict I cited to justify it was self-inflicted.
The claim was that opponent points needs two leave directions at once: a
topdecked point card toward the deck, and a topdecked jack's point card
back to its owner. But those two cards leave through different
TransitionGroups -- the jack through .jacks-container, the point card
through .field-points. I had bound the jacks container to the point row's
transition name, which is what made them share a direction and appear to
conflict.
Binding the jacks container to its own transition removes the conflict,
so the exit and the knock-on move now share a render:
1. the target flips face down where it sits
2. the server state lands; the target leaves for the deck while a
topdecked jack's point card heads back to its owner
Each row now resolves its own direction from the zone the target sat in
(playerPoints, opponentJacks, and so on), captured before the state lands.
That is more precise than the previous blanket "is a topdeck running"
check, which pointed every row at the deck and left the point card
entering the player's row with no enter animation to play.
Also drops removeCardFromField: the local board mutation only existed to
give stage two something to animate, and the deck count now updates with
the rest of the state instead of being nudged early.
Other review fixes:
- inGameEvents: switch on the one-off rank instead of chained if/else
- GameView: inline the single-use topdeckedCardId
- transitions.scss: generate the four to-deck directions from one @each
rather than repeating six blocks. -leave-active and -move stay separate
because only the former may be positioned absolutely.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Restores the three-stage timing: the card gets its own beat to reach the deck before a topdecked jack's point card comes home. Two stages worked and was less code, but three reads better, which is the point of the animation. Reverts only the timing. Kept from e52a68b: - per-row direction from the zone the target sat in, rather than pointing every row at the deck whenever a topdeck is running - the jacks lists on their own transitions - switch on the one-off rank in inGameEvents - topdeckedCardId inlined into its only caller - the to-deck directions generated from one @each removeCardFromField comes back, since stage two needs something to animate before the server state lands. The comment above processNines no longer claims the staging is forced. It is a deliberate choice about pacing: the two leave directions could share a render, because a jack and its point card leave through separate TransitionGroups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV
Review fixes: - toDeckTransition was a computed returning an arrow function, so callers read the computed and then invoked its result. That works, but only as a workaround for having put an argument-taking helper in the computed block. It is a plain method now, taking side and area directly. The transition computeds still track $vuetify.display.xs, since the method reads it while they evaluate. - Move removeCardFromField above processNines, next to findFieldZone, so both helpers precede their caller. - Reword a nines spec comment to state what happened rather than what did not. 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
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
itsalaidbacklife
added a commit
that referenced
this pull request
Sep 19, 2026
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
4 tasks
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
What this does
A Nine one-off now puts its target on top of the deck instead of returning it to its controller's hand. The opponent's next draw is their own card coming back, so the Nine costs them a whole turn rather than one card-play.
Nines are one of the highest ranks in the deck, which makes them excellent scuttle fuel — so the one-off was almost never the right line. This is a deliberate buff, shipped as a beta for the Spades season with a home-page announcement and a community poll toward the end of the year.
This is an alternative to the double-bounce rework in #1378, branched fresh from
mainso the two can be compared and one chosen.The new rule
No schema change, so no migration is required. The deck stays face down —
create-socket-events.jsalready hides it — and the game log names the topdecked card, so both players have the information.Notable findings along the way
isFrozenwas never persisted. It was re-derived on unpack from "resolved is a 9 AND this card equalstargetCard". That derivation runs over every converted array includingdeck, so it had to be deleted — otherwise it would have stampedisFrozenon the card the nine had just topdecked. Sinceresolve/nine.jswas its only producer, the freeze mechanic is now dormant. The rest of the plumbing is deliberately left in place so a revert stays a cleangit revert; a follow-up issue should remove it if the poll keeps the change.Pre-existing bug fixed:
findTargetCardmatches any jack in any stack, while the resolver blindlypop()ed the top one — so a raw socket request naming a buried jack scrapped the wrong card. Newis-top-jack.jshelper, called from bothone-off/validate.jsand itsseven-one-offtwin.Pre-existing AI bug fixed: jack move bodies omitted
targetType, sofindTargetCardreturnednulland the validator rejected every one the bot attempted. Nines now also enumerate point-card targets, which topdecking makes worth playing.Pre-existing leak fixed:
resolve/execute.jsnever clearedoneOffTarget/oneOffTargetTypeon the fizzle path (masked because the next one-off overwrote them).Dead animation code removed: the four nine transition computeds branched on
lastEventTargetType, which is alwaysnull—resolve/execute.jsnullsoneOffTargetTypebefore the socket event is built, andcreate-socket-events.jsnever puts it onlastEvent. Reviving it would need a new column, so instead each row gets one direction that suits both things a Nine can do to it.Hand limit interaction is gone entirely. The nine was the only effect that handed a card to the inactive player, so after this change you can never start a turn over the hand limit. Two
handLimit.spec.jstests covering that state are removed — their draw→discard and discard-2 paths are already covered by the Drawing and Fives blocks — and the pass-triggers-discard test (the only coverage of that path) now seeds the over-limit hand directly, with a comment explaining why.Please check the following
Please describe additional details for testing this change
Local results
npm run lintnpm run test:unitnpx cypress run(34 specs)The single failure is
out-of-game/stats.spec.js, which is unrelated and pre-existing. CI is green on this branch (all 5 cypress containers) — the failure only reproduces locally, and only on a fast, idle machine.Root cause, worth its own issue:
statsFixtures.jscomputes the Current SeasonstartTime(line 30) and the Week-1 gameupdatedAt(line 571) as two separatedayjs.utc().subtract(2, 'week')calls in the same module, whileStatsControllerfilters with a strictupdatedAt > startTime. If both land in the same millisecond the game is excluded and week 1 counts 0.The counter-intuitive part is that a faster machine makes it more likely to fail. Measuring the real fixture module, the delta between the two timestamps is 0 or 1 ms:
1 0 0 1 0 1 0 01 1 1 1 1 1 1 1So GitHub's shared ubuntu-22.04 runners are slow enough that the fixture always straddles a millisecond tick and the test passes, which is why this has never been seen in CI. Both the fixture and the controller logic predate this PR by over a year (
statsFixtures.jsrelative dates since before #1097, Oct 2024;StatsControllerfilter since #796, Nov 2023).The fix would be a one-liner — capture the timestamp once and derive both from it, or make the filter
>=.Test coverage
9_nines.spec.js(16 tests) is rewritten around draw-back proofs — after the nine resolves, the next draw returns exactly the topdecked card, which demonstrates both top-of-deck placement and the absence of freezing in one assertion. New coverage for:pass/validate.jsthrows on a non-empty deck, and it breaks the consecutive-pass runcountPassesuses for the 3-pass stalemate). This is allowed deliberately — it's a real endgame tactic.Also updated:
targeting_cleanup.spec.js,opponent_sevens.spec.js(extended with the seven → nine → draw-back path for both faceCard and jack targets),player_sevens.spec.js,handLimit.spec.js, and theresolveNineunit fixture.Manual pass
Home-page announcement verified rendering (title, both 9s as display cards, both sections, no unresolved i18n keys). All five locales carry the new keys at full parity with real translations.
Follow-ups
convert-str-to-card'sisFrozeninput, theplayedCard.isFrozenvalidator guards,GameCard.vue's snowflake/.frozenstyles,MoveChoiceOverlayhandling, the two i18n keys ×5,SnackBarError.FROZEN_CARD).git revertneeds zero data migration, but bumpCURRENT_RULES_VERSIONforward to3.0.0rather than reusing1.0.2— games are stamped with it./img/rulesView/oneoffs_nine.pngand the animated GIF in the externalcuttle-cards/cuttle-assetsrepo.🤖 Generated with Claude Code
https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV