Skip to content

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

Open
itsalaidbacklife wants to merge 1 commit into
mainfrom
feat/nines-discard-two
Open

itsalaidbacklife wants to merge 1 commit into
mainfrom
feat/nines-discard-two

Conversation

@itsalaidbacklife

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 2.0.0.

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:

Check Result
npm run lint clean
npm run test:unit 3 client + 83 sails passing
one-offs/** (12 specs) 118/118
handLimit, basicMoves, vsAI, reconnecting 64/64
Full cypress run was still completing at PR time; 0 failures through 11/34 specs, including home.spec.js with the new announcement live

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

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 2.0.0.

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

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