Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f6cf4da
feat: replace eager hand-limit enforcement with discard-to-hand-limit…
itsalaidbacklife May 2, 2026
08ce877
docs(game-state-api.md): init
itsalaidbacklife May 2, 2026
6e0ed94
refactor: switch to disardedCard in api for discarding to hand limit
itsalaidbacklife May 3, 2026
25d6d16
test: fix which player is p0 and handling of empty deck in hand limit…
itsalaidbacklife May 4, 2026
5d2e6fc
fix(translations): match en.json for hand limit
itsalaidbacklife May 5, 2026
e08261a
test(9_nines.spec.js): fix it('Nine returns card to player hand at ha…
itsalaidbacklife May 5, 2026
fbf96e9
fix(translations): add game.snackbar.oneOffs.discardToHandLimit key t…
itsalaidbacklife May 5, 2026
1efd565
refactor(DiscardToHandLimitDialog): switch to script setup
itsalaidbacklife May 5, 2026
1f34c9f
docs(AGENTS.md): describe pinia and script setup
itsalaidbacklife May 5, 2026
1e2a35c
refactor(inGameEvents): move discard to hand limit block into main ha…
itsalaidbacklife May 5, 2026
08c33b7
fix(discard-to-hand-limit/validate): add turn check
itsalaidbacklife May 5, 2026
0c6d5ed
docs(translations/CLAUDE.md): init file to instruct maintaining lang …
itsalaidbacklife May 18, 2026
d2cf03a
docs(translations/CLAUDE.md): revise copy
itsalaidbacklife May 18, 2026
e253033
fix: set phase and increment turn based on hand limit
itsalaidbacklife May 18, 2026
8386e72
chore(.gitignore): allow AI config files
itsalaidbacklife May 18, 2026
b5432db
docs(.clause/rules/translation-parity.md) pull into .claude/rules wit…
itsalaidbacklife May 18, 2026
9acc559
test(9_nines.spec.js): adjusted hand limit cases to avoid dialog on s…
itsalaidbacklife May 19, 2026
35c4d28
refactor(handLimit.spec.js): reorganize by move type
itsalaidbacklife May 19, 2026
6857521
fix(resolve/execute): set discard based on active player not playedBy
itsalaidbacklife May 19, 2026
ff4fd1c
test(handLimit.spec.js): add 'Forces player to discard down to hand l…
itsalaidbacklife May 19, 2026
f506796
test(handLimit.spec.js): implement Skips discarding when opponent nin…
itsalaidbacklife May 19, 2026
4a4370b
chore(handLimit.spec): remove .only()
itsalaidbacklife May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .claude/rules/translation-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
paths:
- "src/translations/*.json"
---

## Translation File Parity

All JSON files in this directory must have identical keys in identical order at all times.

Before editing any translation file, read all other files in this directory to understand the current key set and order.

When adding a key: add it at the same position in every file, with the value translated into each file's language (e.g. English in en.json, French in fr.json).

When deleting a key: delete it from every file.

When changing a value: update the corresponding translation in every other file so the meaning stays in sync across languages.

This applies whether translation changes are the primary task or incidental to a larger change.
5 changes: 2 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,6 @@ storybook-static
#
# Files generated by AI assistants
################################################
.claude*
.cursor*
.gemini*
.gemini/tmp/
.gemini/.env

9 changes: 3 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ In case of conflicting rules, the following hierarchy applies:
- **Discovery**: Glob `src/components` for shared, general-purpose components. Page-specific components are often located in `src/routes/[routeName]/components/`. Page entry points are in `src/routes/[routeName]/[RouteName]View.vue`.
- **UI Library**: Vuetify is used. See existing components for usage patterns.
- **Routing**: `src/router.js` defines routes and navigation guards.
- **State Management**: Pinia is used for state management; stores are located in `src/stores` and use the setup store syntax
- **Syntax** Use `<script setup>` for all new vue components

### Backend (Sails.js)
- **Routes**: `config/routes.js` maps endpoints to controller actions.
Expand Down Expand Up @@ -123,9 +125,4 @@ Before completing any task, verify:
1. **Discovery**: Confirm your implementation matches discovered patterns.
2. **Security**: No secrets hardcoded; policies are correctly applied.
3. **Pattern Consistency**: Changes align with existing file and project structures.
4. **Automated Checks**: Run `npm run lint` and `npm run test:unit`.

---

**Remember**: You are a discovery system first, a code generator second. When in doubt, search, read, and ask questions before writing code.

4. **Automated Checks**: Run `npm run lint` to verify code style
21 changes: 21 additions & 0 deletions api/helpers/game-states/ai/get-move-bodies-for-move-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,27 @@ module.exports = {
}
break;

case MoveType.DISCARD_TO_HAND_LIMIT: {
const overflowCount = playerHand.length - 8;
if (overflowCount <= 0) {break;}
const getCombinations = (arr, k) => {
if (k === 1) {return arr.map((item) => [ item ]);}
const result = [];
for (let i = 0; i <= arr.length - k; i++) {
for (const rest of getCombinations(arr.slice(i + 1), k - 1)) {
result.push([ arr[i], ...rest ]);
}
}
return result;
};
res = getCombinations(playerHand, overflowCount).map((combo) => ({
moveType,
playedBy,
discardedCards: combo.map((card) => card.id),
}));
break;
Comment on lines +144 to +161
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should investigate this from a performance perspective. Recursion smells and I could see this blowing up

}

case MoveType.SEVEN_POINTS:
res = deck.slice(0, 2)
.filter((card) => card.rank <= 10)
Expand Down
3 changes: 3 additions & 0 deletions api/helpers/game-states/get-active-player-p-num.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ module.exports = {
case GamePhase.RESOLVING_FOUR:
return exits.success((currentState.turn + 1) % 2);

case GamePhase.DISCARDING_TO_HAND_LIMIT:
return exits.success(currentState.p0.hand.length > 8 ? 0 : 1);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactor to use turn instead


case GamePhase.CONSIDERING_STALEMATE:
return exits.success((currentState.playedBy + 1) % 2);

Expand Down
5 changes: 5 additions & 0 deletions api/helpers/game-states/get-log.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ module.exports = {
discardedCards.length > 1 ? `and the ${getFullCardName(discardedCards[1])}.` : '.'
}`;

case MoveType.DISCARD_TO_HAND_LIMIT:
return `${player} discarded the ${getFullCardName(discardedCards[0])} ${
discardedCards.length > 1 ? `and the ${getFullCardName(discardedCards[1])} ` : ''
}to the hand limit.`;
Comment on lines +124 to +126
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generalize to n cards


case MoveType.RESOLVE_FIVE:
if (discardedCards.length) {
return `${player} discarded the ${getFullCardName(
Expand Down
65 changes: 65 additions & 0 deletions api/helpers/game-states/moves/discard-to-hand-limit/execute.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const GamePhase = require('../../../../../utils/GamePhase.json');

module.exports = {
friendlyName: 'Discard to hand limit',

description: 'Returns new GameState resulting from a player discarding excess cards to reach the hand limit',

inputs: {
currentState: {
type: 'ref',
description: 'The latest GameState before the requesting player discards excess cards',
required: true,
},
/**
* @param { Object } requestedMove - Object describing the request to discard to hand limit
* @param { string[] } requestedMove.discardedCards - IDs of cards to discard (exactly hand.length - 8)
* @param { MoveType.DISCARD_TO_HAND_LIMIT } requestedMove.moveType
*/
requestedMove: {
type: 'ref',
description: 'The move being requested.',
},
playedBy: {
type: 'number',
description: 'Player number of player requesting move.',
},
priorStates: {
type: 'ref',
description: 'List of packed gameStateRows for this game\'s prior states',
required: true,
}
},
sync: true,
fn: ({ currentState, requestedMove, playedBy }, exits) => {
const { discardedCards: discardIds } = requestedMove;
let result = _.cloneDeep(currentState);

const player = playedBy ? result.p1 : result.p0;
const discardedCards = [];

for (const cardId of discardIds) {
const cardIndex = player.hand.findIndex(({ id }) => id === cardId);
if (cardIndex !== -1) {
discardedCards.push(player.hand[cardIndex]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be this defensive. Given that validate runs first we could take discarded cards as-is. But this further ensures we sanity check which isn't crazy.

Syntactically if we're constructing discarded cards from the ones we find, I'd prefer this in three lines, splice then two pushes

result.scrap.push(...player.hand.splice(cardIndex, 1));
}
}

result.turn++;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
playedBy,
playedCard: null,
targetCard: null,
discardedCards,
resolved: null,
oneOff: null,
};

return exits.success(result);
},
};
72 changes: 72 additions & 0 deletions api/helpers/game-states/moves/discard-to-hand-limit/validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const GamePhase = require('../../../../../utils/GamePhase.json');
const BadRequestError = require('../../../../errors/badRequestError');

module.exports = {
friendlyName: 'Validate request to discard to hand limit',

description: 'Verifies whether a request to discard excess cards is legal, throwing explanatory error if not.',

inputs: {
currentState: {
type: 'ref',
descriptions: 'Object containing the current game state',
required: true,
},
/**
* @param { Object } requestedMove - Object describing the request to discard to hand limit
* @param { string[] } requestedMove.discardedCards - IDs of cards to discard (must equal hand.length - 8)
* @param { MoveType.DISCARD_TO_HAND_LIMIT } requestedMove.moveType
*/
requestedMove: {
type: 'ref',
description: 'Object containing data needed for current move',
required: true,
},
playedBy: {
type: 'number',
description: 'Player number of player requesting move',
required: true,
},
priorStates: {
type: 'ref',
description: 'List of packed gameStateRows for this game\'s prior states',
required: true,
}
},
sync: true,
fn: ({ requestedMove, currentState, playedBy }, exits) => {
try {
const activePlayerPNum = currentState.p0.hand.length > 8 ? 0 : 1;
if (playedBy !== activePlayerPNum) {
throw new BadRequestError('game.snackbar.global.notYourTurn');
}

if (currentState.phase !== GamePhase.DISCARDING_TO_HAND_LIMIT) {
Comment thread
itsalaidbacklife marked this conversation as resolved.
throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.notDiscardingPhase');
}


const player = playedBy ? currentState.p1 : currentState.p0;
const overflowCount = player.hand.length - 8;

if (overflowCount <= 0) {
throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.handNotOverLimit');
}

const { discardedCards } = requestedMove;

if (!Array.isArray(discardedCards) || discardedCards.length !== overflowCount) {
throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.mustSelectCards');
}

const allInHand = discardedCards.every(id => player.hand.some(card => card.id === id));
if (!allInHand) {
throw new BadRequestError('game.snackbar.oneOffs.discardToHandLimit.mustSelectCards');
}

return exits.success();
} catch (err) {
return exits.error(err);
}
},
};
5 changes: 3 additions & 2 deletions api/helpers/game-states/moves/draw/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ module.exports = {
const player = playedBy ? result.p1 : result.p0;

player.hand.push(result.deck.shift());
result.turn++;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard: null,
targetCard: null,
Expand Down
6 changes: 0 additions & 6 deletions api/helpers/game-states/moves/draw/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,6 @@ module.exports = {
throw new BadRequestError('game.snackbar.global.notYourTurn');
}

// Must be under hand limit of 8
const player = playedBy ? currentState.p1 : currentState.p0;
if (player.hand.length >= 8) {
throw new BadRequestError('game.snackbar.draw.handLimit');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure this is removed from translation keys

}

// Deck must have cards
if (!currentState.deck.length) {
throw new BadRequestError('game.snackbar.draw.deckIsEmpty');
Expand Down
5 changes: 3 additions & 2 deletions api/helpers/game-states/moves/face-card/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ module.exports = {

player.faceCards.push(playedCard);

result.turn++;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard,
targetCard: null,
Expand Down
5 changes: 3 additions & 2 deletions api/helpers/game-states/moves/jack/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ module.exports = {
// Add targetCard to player's points
player.points.push(targetCard);

result.turn++;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard,
targetCard,
Expand Down
6 changes: 4 additions & 2 deletions api/helpers/game-states/moves/pass/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ module.exports = {
fn: ({ currentState, requestedMove, playedBy }, exits) => {
let result = _.cloneDeep(currentState);

result.turn++;
const player = playedBy ? result.p1 : result.p0;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard: null,
targetCard: null,
Expand Down
5 changes: 3 additions & 2 deletions api/helpers/game-states/moves/points/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,13 @@ module.exports = {
const cardIndex = player.hand.findIndex(({ id }) => id === cardId);

player.points.push(...player.hand.splice(cardIndex, 1));
result.turn++;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard: player.points.at(-1),
targetCard: null,
Expand Down
10 changes: 4 additions & 6 deletions api/helpers/game-states/moves/resolve-five/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,19 @@ module.exports = {
}

const cardsToDraw = Math.min(3, result.deck.length);
const spaceInHand = 8 - player.hand.length;
const actualCardsToDraw = Math.min(cardsToDraw, spaceInHand);

player.hand.push(...result.deck.splice(0, actualCardsToDraw));
player.hand.push(...result.deck.splice(0, cardsToDraw));
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard: null,
targetCard: null,
resolved: result.oneOff,
oneOff: null,
turn: result.turn + 1,
};

return exits.success(result);
Expand Down
5 changes: 3 additions & 2 deletions api/helpers/game-states/moves/resolve-four/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,13 @@ module.exports = {
result.scrap.push(...player.hand.splice(cardIndex2, 1));
}

result.turn++;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard: null,
targetCard: null,
Expand Down
5 changes: 3 additions & 2 deletions api/helpers/game-states/moves/resolve-three/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ module.exports = {
player.hand.push(targetCard);
result.scrap.push(result.oneOff);

result.turn++;
const playerMustDiscard = player.hand.length > 8;

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: playerMustDiscard ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: playerMustDiscard ? result.turn : result.turn + 1,
playedBy,
playedCard: null,
targetCard,
Expand Down
Loading
Loading