Skip to content

feat: rework nines to topdeck the target card - #1379

Merged
itsalaidbacklife merged 14 commits into
mainfrom
feat/topdeck-nines
Sep 19, 2026
Merged

itsalaidbacklife merged 14 commits into
mainfrom
feat/topdeck-nines

Conversation

@itsalaidbacklife

@itsalaidbacklife itsalaidbacklife commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Issue number

Relevant issue number

  • Resolves #

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 main so the two can be compared and one chosen.

The new rule

  • A Nine one-off puts one target card on top of the deck.
  • Legal targets: point cards, Royals/glasses, and the top Jack of a stack. Buried jacks are not targetable.
  • A targeted jack's host point card still reverts to its owner.
  • Non-targeted attachments on a targeted point card are still scrapped.
  • Queen rules are unchanged (1 Queen → only the Queen is targetable; 2+ → no Nine one-off).
  • Nothing is frozen any more.
  • Playing a 9 for points or as a scuttle is unchanged.

No schema change, so no migration is required. The deck stays face down — create-socket-events.js already hides it — and the game log names the topdecked card, so both players have the information.

Notable findings along the way

isFrozen was never persisted. It was re-derived on unpack from "resolved is a 9 AND this card equals targetCard". That derivation runs over every converted array including deck, so it had to be deleted — otherwise it would have stamped isFrozen on the card the nine had just topdecked. Since resolve/nine.js was its only producer, the freeze mechanic is now dormant. The rest of the plumbing is deliberately left in place so a revert stays a clean git revert; a follow-up issue should remove it if the poll keeps the change.

Pre-existing bug fixed: findTargetCard matches any jack in any stack, while the resolver blindly pop()ed the top one — so a raw socket request naming a buried jack scrapped the wrong card. New is-top-jack.js helper, called from both one-off/validate.js and its seven-one-off twin.

Pre-existing AI bug fixed: jack move bodies omitted targetType, so findTargetCard returned null and 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.js never cleared oneOffTarget / oneOffTargetType on 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 always null — resolve/execute.js nulls oneOffTargetType before the socket event is built, and create-socket-events.js never puts it on lastEvent. 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.js tests 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

  • 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?

Please describe additional details for testing this change

Local results

Check Result
npm run lint clean
npm run test:unit 86 / 86
Full npx cypress run (34 specs) 372 passing, 1 failing, 5 pending

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.js computes the Current Season startTime (line 30) and the Week-1 game updatedAt (line 571) as two separate dayjs.utc().subtract(2, 'week') calls in the same module, while StatsController filters with a strict updatedAt > 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:

Condition Deltas observed Result
Idle CPU 1 0 0 1 0 1 0 0 fails ~half the time
CPU under load 1 1 1 1 1 1 1 1 always passes

So 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.js relative dates since before #1097, Oct 2024; StatsController filter 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:

  • the empty-deck endgame: a Nine played at deck-out puts a card back, which re-enables drawing and removes the pass affordance (pass/validate.js throws on a non-empty deck, and it breaks the consecutive-pass run countPasses uses for the 3-pass stalemate). This is allowed deliberately — it's a real endgame tactic.
  • top-jack-only targeting, asserted through the UI on a triple-jack stack
  • the hand-limit non-interaction from both perspectives

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 the resolveNine unit 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

  • If the poll keeps the change, open an issue to delete the dormant freeze plumbing (convert-str-to-card's isFrozen input, the playedCard.isFrozen validator guards, GameCard.vue's snowflake/.frozen styles, MoveChoiceOverlay handling, the two i18n keys ×5, SnackBarError.FROZEN_CARD).
  • If the poll reverts it, git revert needs zero data migration, but bump CURRENT_RULES_VERSION forward to 3.0.0 rather than reusing 1.0.2 — games are stamped with it.
  • The rules-page art is now stale and can't be fixed here: /img/rulesView/oneoffs_nine.png and the animated GIF in the external cuttle-cards/cuttle-assets repo.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JnmfJeHNxebpfcDzSN8PqV

itsalaidbacklife and others added 2 commits September 17, 2026 07:36
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
@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 17, 2026
Comment thread api/helpers/game-states/ai/get-move-bodies-for-move-type.js
Comment thread api/helpers/game-states/is-top-jack.js Outdated
Comment thread api/helpers/game-states/moves/one-off/validate.js Outdated
Comment thread api/helpers/game-states/moves/resolve/execute.js
Comment thread api/helpers/game-states/moves/resolve/nine.js Outdated
Comment thread src/routes/game/GameView.vue
Comment thread src/routes/game/GameView.vue
Comment thread src/routes/home/components/announcementDialog/data/announcementData.js Outdated
Comment thread src/stores/game.js
Comment thread tests/e2e/specs/in-game/one-offs/9_nines.spec.js Outdated
- 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
Comment thread api/helpers/game-states/moves/seven-one-off/validate.js Outdated
itsalaidbacklife and others added 7 commits September 18, 2026 07:08
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
Comment thread src/plugins/sockets/inGameEvents.js Outdated
Comment thread src/routes/game/GameView.vue
Comment thread src/sass/transitions.scss Outdated
Comment thread src/stores/game.js Outdated
itsalaidbacklife and others added 2 commits September 18, 2026 18:35
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
Comment thread src/routes/game/GameView.vue Outdated
Comment thread src/stores/game.js Outdated
Comment thread tests/e2e/specs/in-game/one-offs/9_nines.spec.js Outdated
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
itsalaidbacklife merged commit 6fbea6d into main Sep 19, 2026
9 checks passed
@itsalaidbacklife
itsalaidbacklife deleted the feat/topdeck-nines branch September 19, 2026 12:49
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
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