Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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);
},
};
66 changes: 66 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,66 @@
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 {
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);
}
},
};
8 changes: 6 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,16 @@ module.exports = {
const player = playedBy ? result.p1 : result.p0;

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

if (!handExceedsLimit) {
result.turn++;
}

result = {
...result,
...requestedMove,
phase: GamePhase.MAIN,
phase: handExceedsLimit ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
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
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 handExceedsLimit = player.hand.length > 8;

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

return exits.success(result);
Expand Down
8 changes: 6 additions & 2 deletions api/helpers/game-states/moves/resolve/execute.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,19 @@ module.exports = {
default:
return exits.error(new Error(`${oneOff.rank} is not a valid one-off rank`));
}


const nineTargetedPlayer = playedBy ? result.p1 : result.p0;
const nineOverflow = oneOff.rank === 9 && nineTargetedPlayer.hand.length > 8;
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.

Rename to handExceedsLimit


result.scrap.push(result.oneOff);
result = {
...result,
oneOff: null,
targetCard: result.oneOffTarget,
oneOffTarget: null,
oneOffTargetType: null,
turn: result.turn + 1,
phase: nineOverflow ? GamePhase.DISCARDING_TO_HAND_LIMIT : GamePhase.MAIN,
turn: nineOverflow ? result.turn : result.turn + 1,
};

return exits.success(result);
Expand Down
16 changes: 15 additions & 1 deletion api/policies/hasValidMoveBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ module.exports = function (req, res, next) {
}
return next();

// Requires `card1` and optionally accepts `card2`
// Requires `cardId1` and optionally accepts `cardId2`
case MoveType.RESOLVE_FOUR:
{
if (!cardId1) {
Expand All @@ -60,6 +60,20 @@ module.exports = function (req, res, next) {
}
return next();

// Requires a non-empty `discardedCards` array of valid card IDs
case MoveType.DISCARD_TO_HAND_LIMIT:
{
const { discardedCards } = req.body;
if (!Array.isArray(discardedCards) || discardedCards.length === 0) {
return res.badRequest({ message: 'Must specify cards to discard' });
}
const invalidId = discardedCards.find((id) => !DeckIds.includes(id));
if (invalidId) {
return res.badRequest({ message: `${invalidId} is not a valid cardId` });
}
}
return next();

// These require `cardId` and `targetId`
case MoveType.JACK:
case MoveType.SCUTTLE:
Expand Down
Loading
Loading