Skip to content

feat: nines return two cards to hand, unfrozen - #1378

Open
itsalaidbacklife wants to merge 4 commits into
mainfrom
feat/nines-discard-two
Open

itsalaidbacklife wants to merge 4 commits into
mainfrom
feat/nines-discard-two

Conversation

@itsalaidbacklife

@itsalaidbacklife itsalaidbacklife commented Sep 9, 2026

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

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.0 is taken by the topdeck-nines rework #1379, which landed on main first, and 3.0.0 by
the 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:

  • The topdeck animation is removed. processNines and its three-stage flip-and-travel
    sequence, the topdeckedCard / topdeckedCardZone store state, the four to-deck-*
    transitions in transitions.scss and Transitions.json, and GameView's per-zone
    transition selection all went with it. Rank 9 animates toward the controlling player's
    hand again, and the RESOLVE socket handler goes back to processSevens-or-update.
  • is-top-jack.js is kept and reused. feat: rework nines to topdeck the target card #1379 extracted the top-jack rule into a shared
    helper and — correctly — applied it to twos as well, where targeting a buried jack
    silently reassigns control. validate-nine-targets.js now calls that helper and reports
    the shared game.snackbar.oneOffs.onlyTopJack key instead of carrying its own copy, so
    the rule lives in exactly one place for both ranks.
  • Face-card transitions actually fire now. The old playerFaceCardsTransition /
    opponentFaceCardsTransition nine-arms were gated on lastEventTargetType, which is
    always null (it is never put on lastEvent), so they were dead. They key on the
    one-off rank alone now, which is sufficient: under this rule a rank-9 resolve always
    sends its targets to hand. The always-null lastEventTargetType ref is gone rather than
    reinstated.
  • The three nine playground recording scenarios were written for topdeck and each
    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 optional deckLength from feat: rework nines to topdeck the target card #1379 is kept — it is opt-in and
    generally useful, even though nothing here topdecks.

The new rule

  • A nine one-off requires exactly two distinct targets, any mix of point cards,
    royals/glasses eights, 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 — the point card does not first revert to
    its original owner.
  • Only the top jack of a stack is targetable.
  • Any queen blocks a nine outright. A queen protects everything else its controller
    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+).
  • Playing a 9 for points or as a scuttle is unchanged.

⚠️ Requires a manual migration before deploy

The repo has no migration tooling and staging/production use migrate: 'safe', so these
nullable columns must be added before this deploys:

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;

All nullable, so legacy rows and in-flight games keep loading. Worth confirming the table
name against the deployed schema first.

Notable implementation details

isFrozen was never persisted. It was re-derived on unpack from "resolved is a 9 and
this hand card equals targetCard"
(unpack-gamestate.js). Removing the freeze therefore
needed that derivation deleted too, not just the isFrozen: true writes — otherwise the DB
round-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.isFrozen guards, the snowflake UI, and the related i18n stay in place so the
revert is a clean git revert. If the poll keeps the change, a follow-up should remove
them — worth an issue at merge time.

Additive columns rather than widening existing fields. oneOffTarget /
oneOffTargetType are read by twos, the seven flow, the log, the socket payload, and the
store; changing their type would be a breaking read for every existing row.

Nine validation is now shared. one-off/validate.js and seven-one-off/validate.js
previously duplicated the whole case 2: case 9: block. Extracted to
api/helpers/game-states/validate-nine-targets.js and called from both.

AI updated. get-move-bodies-for-move-type.js now enumerates two-target pairs for
nines (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.js never cleared the
one-off target fields. It was masked because the next one-off overwrote them; a new
assertion in targeting_cleanup.spec.js exposed it. Now all four target slots clear on
fizzle.

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:

Check Result
npm run lint clean
npm run build clean
npm run test:unit:client 3/3
Translation key parity en/de/es/fr/ukr identical keys; the pre-existing game.dialogs.four ordering drift in es/fr is unchanged by this PR
npm run test:unit (sails) not run — port 1337 was occupied locally
Cypress not run — same

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:

  • Two-target selection: select, deselect by re-clicking, cancel mid-selection, confirm.
  • Target a jack and the point card it is stealing — both should land in the jack
    holder's hand.
  • Target a jack without its host — the host should revert to its owner's points, as before.
  • With one opponent queen, the One-Off choice should be disabled with
    "You can't play a Nine while your opponent has a Queen".
  • With fewer than two legal targets, it should read "A Nine needs two cards to target".
  • Play a nine off the top of the deck via a seven.
  • Play a game vs the AI and confirm it plays two-target nines without server-side
    validation errors in the log.
  • Check the home-page announcement in a non-English locale.

🤖 Generated with Claude Code

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 backend Requires changes to the (node) backend webserver frontend Requires changes to the frontend (vue) client labels Sep 9, 2026
@itsalaidbacklife

Copy link
Copy Markdown
Contributor Author

Migration has been run

itsalaidbacklife and others added 3 commits September 19, 2026 10:14
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Requires changes to the (node) backend webserver frontend Requires changes to the frontend (vue) client 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