Skip to content

Validate that placed tiles come from the player's own hand - #54

Open
paskal wants to merge 1 commit into
masterfrom
fix/turn-letter-ownership
Open

Validate that placed tiles come from the player's own hand#54
paskal wants to merge 1 commit into
masterfrom
fix/turn-letter-ownership

Conversation

@paskal

@paskal paskal commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Problem

In makeTurn the check that a turn only used tiles from the player's own hand was commented out, with a TODO saying it didn't work because of board wildcards. So a modified client could place tiles it didn't hold — letters not in hand, or more copies of a letter than it held — and the downstream subtract(userLetters, putLetters) would silently keep the unmatched tiles, letting a player score with tiles they never had and keep their real hand.

Fix

Reinstated the ownership check in services/turn.ts, running after the existing wildcard-on-board swap loop (which has already adjusted userLetters, splicing out a real letter and inserting a * for each legitimate swap):

  • build a multiset of the player's current hand
  • for every placed cell, determine the tile it consumes: a wildcard cell ("*x", 2 chars) consumes a *; a real-letter cell (1 char) consumes that letter; anything else (malformed) is rejected
  • decrement the multiset per placed tile; if a required tile isn't available, mark the turn invalid

Duplicates are counted, so over-spending a letter is caught. This is consistent with the downstream subtract, which removes the same placed tiles from the hand. Legitimate turns, including legitimate wildcard swaps, are unaffected. Also removed the dead commented-out block and a stray closing-brace comment.

@paskal
paskal requested a review from Ksinia as a code owner July 3, 2026 23:46
@paskal
paskal requested a review from Copilot July 3, 2026 23:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Reintroduces server-side validation in makeTurn to ensure a player can only place tiles they actually possess in their hand (including correct handling of wildcard tiles), closing a cheating vector where a modified client could place arbitrary letters.

Changes:

  • Adds a multiset-based “available letters” check to validate every placed tile is spendable from the player’s current hand.
  • Removes the old commented-out (non-working) ownership-check block and related dead comment.
  • Runs the new check after the wildcard-on-board swap reconciliation so legitimate swaps remain valid.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/turn.ts
Comment on lines +112 to +129
row.forEach((cell) => {
if (!cell) {
return;
}
let neededLetter = null;
if (typeof cell === "string") {
if (cell[0] === "*" && cell.length === 2) {
neededLetter = "*";
} else if (cell[0] !== "*" && cell.length === 1) {
neededLetter = cell;
}
}
if (neededLetter !== null && availableLetters[neededLetter]) {
availableLetters[neededLetter] -= 1;
} else {
ok = false;
}
});

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Both good points, thanks.

  1. Falsy-cell bypass: fixed. The empty test now uses cell === null (and the top-level pass check uses letter !== null), so a non-null falsy value (0, false, "") is no longer skipped/treated as a pass; it falls through to the type/length checks and is rejected. This matches how newBoard treats userBoard[y][x] !== null as a placement, so nothing non-null can slip onto the board unvalidated.

  2. Wildcard represented-letter ("*$"): left as-is on purpose. That check is about the represented letter's legality (does it belong to the alphabet), not about tile ownership, which is this PR's scope: the player does own a *. Words in this game aren't dictionary-validated server-side anyway (they're approved by the other players), so a wildcard representing a non-letter has no scoring advantage. Worth a separate follow-up for input hygiene, but out of scope here to keep this PR atomic.

@paskal
paskal force-pushed the fix/turn-letter-ownership branch 2 times, most recently from a972fbd to 97c86d1 Compare July 4, 2026 00:02
@paskal
paskal requested a review from Copilot July 4, 2026 00:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated 3 comments.

Comment thread services/turn.ts
Comment on lines +105 to 108
// check that every placed letter comes from the user's hand,
// counting duplicates: a wildcard cell ("*x") consumes a "*"
// from the hand, any other cell consumes the letter itself
if (ok) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. The swap loop no longer mutates on an invalid swap: it computes isValidSwap = index !== -1 && typeof boardCell === 'string' && boardCell[0] === '*' && boardCell[1] === letter, and on !isValidSwap it sets ok = false and returns before any splice/board write. That removes both the splice(-1, 1, '*') hand corruption and the currentGameBoard[y][x][1] dereference on a non-wildcard/empty target cell. Legitimate swaps are unchanged.

Comment thread services/turn.ts
Comment on lines +24 to +26
// a placement is any non-null cell (matching newBoard/ownership below),
// so a board of only non-null junk is not mistaken for a pass
if (!userBoard.some((row) => row.some((letter) => letter !== null))) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. validateRequestBody now enforces the exact grid: userBoard must be 15 rows, each an array of 15 cells, each cell null or a string, else 400. That closes the ragged/short-board path where newBoard read missing cells as undefined (or threw on a missing row). Confirmed against the client, which sends a 15x15 grid with empty cells converted from '' to null before submit, so legit turns and all-null passes still validate.

Comment thread services/turn.ts Outdated
@paskal
paskal force-pushed the fix/turn-letter-ownership branch from 97c86d1 to ac69fbf Compare July 4, 2026 01:05
@paskal
paskal requested a review from Copilot July 4, 2026 09:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

services/turn.ts:156

  • If userBoard is treated as containing null cells (and is validated that way), this explicit callback parameter type should also allow null; otherwise it can become a TypeScript type error / hide null-handling in this check.
          !userBoard.some((row, y) =>
            row.some(
              (letter: string, x) =>
                letter !== null && game.board[y][x] !== null

Comment thread routers/game.ts Outdated
Comment on lines +27 to +31
@@ -28,6 +28,8 @@ interface RequestBody {
wildCardOnBoard: { [x: string]: { [x: string]: string } };
}

const BOARD_SIZE = 15;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. RequestBody.userBoard is now typed (string | null)[][], matching that empty cells are null after validation.

Comment thread services/turn.ts
Comment on lines +24 to +26
// a placement is any non-null cell (matching newBoard/ownership below),
// so a board of only non-null junk is not mistaken for a pass
if (!userBoard.some((row) => row.some((letter) => letter !== null))) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. makeTurn's userBoard parameter (and the empty-cell callback annotation) are now (string | null)[][], so the type matches the runtime contract the validator enforces. Type-only change, no behaviour impact.

@paskal
paskal force-pushed the fix/turn-letter-ownership branch from ac69fbf to 736b56e Compare July 4, 2026 09:16
@paskal
paskal requested a review from Copilot July 4, 2026 09:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment thread routers/game.ts
res: Response,
next: NextFunction
) {
const body = req.body as RequestBody;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. validateRequestBody now rejects a non-object body up front: if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) return res.status(400)..., so a JSON null (or array/primitive) body gives a controlled 400 instead of throwing on the property access.

Comment thread routers/game.ts
}
}

if ("wildCardOnBoard" in body) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. When wildCardOnBoard is absent it now defaults to {} in the validator (and RequestBody.wildCardOnBoard is typed optional to match), so makeTurn's Object.keys(wildCardOnBoard) never receives undefined. A null value still returns a controlled 400.

@paskal
paskal force-pushed the fix/turn-letter-ownership branch from 736b56e to 9c45cd7 Compare July 4, 2026 09:47
@paskal
paskal requested a review from Copilot July 4, 2026 09:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread routers/game.ts
}
}

if ("wildCardOnBoard" in body) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed. validateRequestBody now rejects arrays explicitly at both levels (Array.isArray(body.wildCardOnBoard) and Array.isArray(innerObj)), returning 400, consistent with the non-object body rejection. So an array can't reach makeTurn's Object.keys/index logic. (The swap guard already rejected such input via isValidSwap, but rejecting at the boundary is cleaner.)

@paskal
paskal force-pushed the fix/turn-letter-ownership branch from 9c45cd7 to 049ce70 Compare July 4, 2026 10:02
Previously, the check that a turn only used letters from the player's
hand was commented out with a TODO, so a modified client could place
any tiles (letters not held, or more copies than held) and keep its
real hand; the downstream subtract silently retained the unmatched
letters.

After this change, makeTurn counts the player's post-swap hand and
requires every placed cell to be backed by a held tile: a wildcard
cell ("*x") consumes a "*", any other cell consumes that letter, and
duplicates are counted, so over-spending or placing unheld tiles is
rejected.
@paskal
paskal force-pushed the fix/turn-letter-ownership branch from 049ce70 to bf256bc Compare July 4, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants