BoxedUp.js

import R from "./ramda.js";
/**
 * BoxedUp.js is a module to model and play "Boxed Up".
 * Boxed Up is a turn-based game for two players (chefs),
 * who take turns packing food-shaped pieces into a shared box.
 * Pieces may be rotated, and may only cover empty cells of the box.
 * Some cells hold garnish from the start; garnish may never be covered.
 * A few cells are special: golden star cells reward whoever covers
 * them, wasabi cells burn points from whoever covers them, and a
 * matcha cell grants the chef who covers it another turn at once.
 * The box is divided into 3×3 compartments; one of them may be the
 * VIP order, which is worth extra when sealed, and the chef who
 * packs the final piece earns a small closing bonus.
 * When a compartment is fully covered it is sealed,
 * and the chef with more food in it earns a bonus.
 * When the chef to move cannot fit any piece anywhere in the box,
 * the box is sealed and the chef with the higher score wins.
 * @namespace BoxedUp
 * @author Leo
 * @version 2025/26
 */
const BoxedUp = Object.create(null);

/**
 * A Board is the rectangular grid of cells that pieces are packed into.
 * It is implemented as an array of rows of cells.
 * @memberof BoxedUp
 * @typedef {BoxedUp.Cell[][]} Board
 */

/**
 * A Player token marks a cell covered by one of that player's pieces.
 * Player 1 packs first unless {@link BoxedUp.new_game} is given a
 * different starting player.
 * @memberof BoxedUp
 * @typedef {(1 | 2)} Player
 */

/**
 * A Cell of the board is either empty (`0`),
 * covered by a player's piece (`1` or `2`),
 * or holds garnish (`3`, see {@link BoxedUp.garnish_token}),
 * which belongs to neither player and may never be covered.
 * @memberof BoxedUp
 * @typedef {(0 | BoxedUp.Player | 3)} Cell
 */

/**
 * A Position picks out a single cell of the board.
 * It is a [row, column] pair of zero-based indices.
 * Row 0 is the top of the board; column 0 is its left edge.
 * @memberof BoxedUp
 * @typedef {number[]} Position
 */

/**
 * A Shape is the footprint of a piece:
 * an array of [row, column] offsets from the piece's anchor cell,
 * which is the position the piece is placed at.
 * The shapes of the standard pieces are listed in
 * {@link BoxedUp.piece_shapes}, and may be rotated with
 * {@link BoxedUp.rotated_shape}.
 * @memberof BoxedUp
 * @typedef {number[][]} Shape
 */

/**
 * A Game records everything needed to continue a game of Boxed Up:
 * the contents of the box, and which player packs next.
 * Game objects are never mutated;
 * {@link BoxedUp.place} returns a new game object.
 * @memberof BoxedUp
 * @typedef {Object} Game
 * @property {BoxedUp.Board} board The current contents of the box.
 * @property {BoxedUp.Player} player The player whose turn it is.
 * @property {BoxedUp.Position[]} [star_cells] Golden cells that award
 *   {@link BoxedUp.star_bonus} to whoever covers them.
 * @property {BoxedUp.Position[]} [wasabi_cells] Fiery cells that cost
 *   {@link BoxedUp.wasabi_penalty} to whoever covers them.
 * @property {BoxedUp.Position[]} [matcha_cells] Energising cells that
 *   grant whoever covers them an immediate extra turn.
 * @property {BoxedUp.Position} [vip_compartment] The origin of the
 *   compartment that is this box's VIP order, worth
 *   {@link BoxedUp.vip_bonus} extra to its owner when sealed.
 * @property {BoxedUp.Player} [last_player] The chef who made the most
 *   recent ply, who earns {@link BoxedUp.closing_bonus} if their piece
 *   finished the box.
 */

/**
 * A Compartment is one 3×3 section of the box,
 * aligned to multiples of three from the top-left corner.
 * Boards whose width or height is not a multiple of three have
 * margin cells that belong to no compartment.
 * @memberof BoxedUp
 * @typedef {Object} Compartment
 * @property {BoxedUp.Position} origin The compartment's top-left cell.
 * @property {BoxedUp.Position[]} cells The nine cells of the compartment.
 */

/**
 * The menu of pieces that either chef may pack on their turn.
 * Each piece has a {@link BoxedUp.Shape} given in its unrotated orientation.
 * @memberof BoxedUp
 * @enum {BoxedUp.Shape}
 * @property {BoxedUp.Shape} tamago A 1×2 slice of rolled egg.
 * @property {BoxedUp.Shape} cucumber A 1×3 stick of cucumber.
 * @property {BoxedUp.Shape} onigiri A triangular rice ball:
 * an upside-down T of four cells.
 * @property {BoxedUp.Shape} rice A 2×2 block of steamed rice.
 * @property {BoxedUp.Shape} salmon A 1×4 fillet of salmon.
 * @property {BoxedUp.Shape} tempura An L-shaped cluster of tempura
 * (three cells).
 * @property {BoxedUp.Shape} umeboshi A single pickled plum (one cell).
 */
BoxedUp.piece_shapes = Object.freeze({
    "cucumber": [[0, 0], [0, 1], [0, 2]],
    "onigiri": [[0, 1], [1, 0], [1, 1], [1, 2]],
    "rice": [[0, 0], [0, 1], [1, 0], [1, 1]],
    "salmon": [[0, 0], [0, 1], [0, 2], [0, 3]],
    "tamago": [[0, 0], [0, 1]],
    "tempura": [[0, 0], [0, 1], [1, 0]],
    "umeboshi": [[0, 0]]
});

/**
 * The cell value marking garnish: decoration already in the box when a
 * game begins. Garnish belongs to neither player, may never be covered,
 * but does count towards sealing a compartment.
 * @memberof BoxedUp
 * @constant {number}
 */
BoxedUp.garnish_token = 3;

/**
 * The points a chef earns for each sealed compartment they own,
 * on top of one point per covered cell.
 * See {@link BoxedUp.sealed_compartments} and {@link BoxedUp.score}.
 * @memberof BoxedUp
 * @constant {number}
 */
BoxedUp.compartment_bonus = 5;

/**
 * The points a chef earns for covering a golden star cell.
 * See {@link BoxedUp.covered_stars} and {@link BoxedUp.score}.
 * @memberof BoxedUp
 * @constant {number}
 */
BoxedUp.star_bonus = 3;

/**
 * The points a chef loses for covering a wasabi cell.
 * Sometimes the burn is worth it to steal a compartment!
 * See {@link BoxedUp.covered_wasabi} and {@link BoxedUp.score}.
 * @memberof BoxedUp
 * @constant {number}
 */
BoxedUp.wasabi_penalty = 3;

/**
 * The extra points (on top of {@link BoxedUp.compartment_bonus}) that
 * the owner of the VIP order compartment earns when it is sealed.
 * @memberof BoxedUp
 * @constant {number}
 */
BoxedUp.vip_bonus = 5;

/**
 * The points the chef who packs the final piece of the box earns
 * for closing the lid.
 * @memberof BoxedUp
 * @constant {number}
 */
BoxedUp.closing_bonus = 2;

/**
 * A set of template token strings for
 * {@link BoxedUp.to_string_with_tokens}.
 * @memberof BoxedUp
 * @enum {string[]}
 * @property {string[]} default ["0", "1", "2", "3"]
 * Displays cells by their value.
 * @property {string[]} food ["⬜", "🍣", "🍙", "🍥"]
 * Displays player pieces and garnish as food in the box.
 */
BoxedUp.token_strings = Object.freeze({
    "default": ["0", "1", "2", "3"],
    "food": ["⬜", "🍣", "🍙", "🍥"]
});

/**
 * Create a new empty board.
 * Optionally with a specified width and height,
 * otherwise returns a standard 9 wide, 9 high board.
 * @memberof BoxedUp
 * @function
 * @param {number} [width = 9] The width of the new board.
 * @param {number} [height = 9] The height of the new board.
 * @returns {BoxedUp.Board} An empty board.
 */
BoxedUp.empty_board = function (width = 9, height = 9) {
    return R.repeat(R.repeat(0, width), height);
};

/**
 * This helper writes a token into one cell of a board,
 * returning a new board.
 * @function
 * @param {BoxedUp.Cell} token The token to write.
 * @returns {function} Reducer from a board and a position to a new board.
 */
const fill_cell = function (token) {
    return function (board, [row, column]) {
        return R.update(
            row,
            R.update(column, token, board[row]),
            board
        );
    };
};

/**
 * Create a new game, ready for the first ply.
 * The box starts empty, except for any garnish, and player 1 packs
 * first. Optionally provide a width and height for the box,
 * otherwise a standard 9×9 box is used.
 * @memberof BoxedUp
 * @function
 * @param {number} [width = 9] The width of the box.
 * @param {number} [height = 9] The height of the box.
 * @param {BoxedUp.Position[]} [garnish_cells = []] Cells that start the
 *   game holding garnish, which may never be covered.
 * @param {BoxedUp.Position[]} [star_cells = []] Empty cells holding a
 *   golden star. Garnish, star, and wasabi cells must not overlap.
 * @param {BoxedUp.Position[]} [wasabi_cells = []] Empty cells holding
 *   a dab of wasabi.
 * @param {BoxedUp.Position[]} [matcha_cells = []] Empty cells holding
 *   a bowl of matcha. Special cells must not overlap.
 * @param {BoxedUp.Position} [vip_compartment] The origin of the
 *   compartment serving as this box's VIP order, if any.
 * @param {BoxedUp.Player} [starting_player = 1] The chef who packs the
 *   first piece of this box — alternate it between games for
 *   fairness, since packing first means first pick of the specials.
 * @returns {BoxedUp.Game} A new game.
 */
BoxedUp.new_game = function (
    width = 9,
    height = 9,
    garnish_cells = [],
    star_cells = [],
    wasabi_cells = [],
    matcha_cells = [],
    vip_compartment = undefined,
    starting_player = 1
) {
    return {
        "board": garnish_cells.reduce(
            fill_cell(BoxedUp.garnish_token),
            BoxedUp.empty_board(width, height)
        ),
        "player": starting_player,
        "star_cells": star_cells,
        "wasabi_cells": wasabi_cells,
        "matcha_cells": matcha_cells,
        "vip_compartment": vip_compartment
    };
};

/**
 * Returns which player is next to pack a piece into the box.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to check.
 * @returns {BoxedUp.Player} The player next to play.
 */
BoxedUp.player_to_ply = function (game) {
    return game.player;
};

/**
 * Returns the size of a board as an array of [width, height].
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Board} board The board to check the size of.
 * @returns {number[]} The width and height of the board, [width, height].
 */
BoxedUp.size = function (board) {
    return [board[0].length, board.length];
};

/**
 * This helper shifts a shape so its offsets are relative to [0, 0],
 * and sorts them, giving each shape one canonical form.
 * @function
 * @param {BoxedUp.Shape} shape The shape to normalise.
 * @returns {BoxedUp.Shape} The normalised shape.
 */
const normalised = function (shape) {
    const min_row = R.reduce(R.min, Infinity, shape.map(R.head));
    const min_column = R.reduce(R.min, Infinity, shape.map(R.last));
    return R.sortWith(
        [R.ascend(R.head), R.ascend(R.last)],
        shape.map(function ([row, column]) {
            return [row - min_row, column - min_column];
        })
    );
};

/**
 * Returns a shape rotated a quarter turn clockwise.
 * The returned shape is normalised,
 * i.e. its offsets are relative to its own top-left corner.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape to rotate.
 * @returns {BoxedUp.Shape} The rotated shape.
 */
BoxedUp.rotated_shape = function (shape) {
    return normalised(shape.map(function ([row, column]) {
        return [column, -row];
    }));
};

/**
 * Returns all distinct orientations of a shape,
 * i.e. its unique quarter-turn rotations.
 * Symmetric shapes have fewer than four distinct orientations.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape to rotate.
 * @returns {BoxedUp.Shape[]} An array of distinct orientations of the shape.
 */
BoxedUp.orientations = function (shape) {
    const quarter_turns = R.range(0, 4).reduce(
        (rotations) => R.append(
            BoxedUp.rotated_shape(R.last(rotations)),
            rotations
        ),
        [normalised(shape)]
    );
    return R.uniq(R.take(4, quarter_turns));
};

/**
 * Returns the coordinates of the cells a shape would cover
 * when placed with its anchor at a given position.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape being placed.
 * @param {BoxedUp.Position} position The position of the shape's anchor.
 * @returns {BoxedUp.Position[]} The cells the placed shape would cover.
 */
BoxedUp.placement_cells = function (shape, position) {
    const [row, column] = position;
    return shape.map(function ([row_offset, column_offset]) {
        return [row + row_offset, column + column_offset];
    });
};

/**
 * Returns whether a shape may be packed at a position on a board.
 * A placement is legal if every cell the shape would cover
 * lies inside the board and is currently empty.
 * Cells holding pieces or garnish may not be covered.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape being placed.
 * @param {BoxedUp.Position} position The position of the shape's anchor.
 * @param {BoxedUp.Board} board The board to place the shape on.
 * @returns {boolean} Whether the placement is legal.
 */
BoxedUp.is_legal_placement = function (shape, position, board) {
    return BoxedUp.placement_cells(shape, position).every(
        function ([row, column]) {
            return (
                board[row] !== undefined &&
                board[row][column] === 0
            );
        }
    );
};

/**
 * This helper returns every position on a board.
 * @function
 * @param {BoxedUp.Board} board The board.
 * @returns {BoxedUp.Position[]} All [row, column] positions on the board.
 */
const all_positions = function (board) {
    const [width, height] = BoxedUp.size(board);
    return R.xprod(R.range(0, height), R.range(0, width));
};

/**
 * Returns all the positions at which a shape may legally be packed.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape being placed.
 * @param {BoxedUp.Board} board The board to place the shape on.
 * @returns {BoxedUp.Position[]} The legal anchor positions for the shape.
 */
BoxedUp.legal_placements = function (shape, board) {
    return all_positions(board).filter(
        (position) => BoxedUp.is_legal_placement(shape, position, board)
    );
};

/**
 * This helper returns whether a shape, in any of its orientations,
 * fits anywhere on a board.
 * @function
 * @param {BoxedUp.Board} board The board to test.
 * @returns {function} A predicate on shapes.
 */
const fits_somewhere_on = function (board) {
    return function (shape) {
        return BoxedUp.orientations(shape).some(
            (orientation) => all_positions(board).some(
                (position) => BoxedUp.is_legal_placement(
                    orientation,
                    position,
                    board
                )
            )
        );
    };
};

/**
 * Returns whether a game has ended.
 * A game ends when the player to move cannot pack any piece from
 * {@link BoxedUp.piece_shapes}, in any orientation, anywhere in the box.
 * Both players choose from the same menu of pieces,
 * so this depends only on the contents of the box.
 * Since the umeboshi covers a single cell, the game ends exactly
 * when every cell of the box is covered.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to test.
 * @returns {boolean} Whether the game has ended.
 */
BoxedUp.is_ended = function (game) {
    return !R.any(
        fits_somewhere_on(game.board),
        R.values(BoxedUp.piece_shapes)
    );
};

/**
 * Returns the names of the menu pieces that can still be packed
 * somewhere in the box, in at least one orientation.
 * This is the menu a chef can actually choose from on their turn:
 * a game has ended (see {@link BoxedUp.is_ended}) exactly when no
 * pieces remain available.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to inspect.
 * @returns {string[]} The names of the pieces that still fit,
 *   drawn from the keys of {@link BoxedUp.piece_shapes}.
 */
BoxedUp.available_pieces = function (game) {
    return R.keys(BoxedUp.piece_shapes).filter(function (name) {
        return BoxedUp.orientations(BoxedUp.piece_shapes[name]).some(
            (orientation) => BoxedUp.legal_placements(
                orientation,
                game.board
            ).length > 0
        );
    });
};

/**
 * Returns how many cells of the box are still empty: cells holding
 * neither a piece nor garnish, and so open to be packed. A measure
 * of how much room is left in the box.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to inspect.
 * @returns {number} The number of empty cells remaining.
 */
BoxedUp.empty_cells = function (game) {
    return R.count(R.equals(0), R.flatten(game.board));
};

/**
 * This helper builds the 3×3 square of offsets used by compartments.
 * @constant {BoxedUp.Shape}
 */
const compartment_square = R.xprod(R.range(0, 3), R.range(0, 3));

/**
 * Returns the compartments of a board:
 * its 3×3 sections, aligned to multiples of three from the top-left.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Board} board The board to divide up.
 * @returns {BoxedUp.Compartment[]} The board's compartments.
 */
BoxedUp.compartments = function (board) {
    const [width, height] = BoxedUp.size(board);
    const starts = (limit) => R.map(
        R.multiply(3),
        R.range(0, Math.floor(limit / 3))
    );
    return R.xprod(starts(height), starts(width)).map(function (origin) {
        return {
            "origin": origin,
            "cells": BoxedUp.placement_cells(compartment_square, origin)
        };
    });
};

/**
 * This helper decides who owns a fully covered compartment:
 * the player with more cells in it, or `0` if they are level.
 * @function
 * @param {BoxedUp.Board} board The board the compartment belongs to.
 * @returns {function} From a compartment to its owner.
 */
const compartment_owner = function (board) {
    return function (compartment) {
        const tokens = compartment.cells.map(function ([row, column]) {
            return board[row][column];
        });
        const count_1 = R.count(R.equals(1), tokens);
        const count_2 = R.count(R.equals(2), tokens);
        if (count_1 === count_2) {
            return 0;
        }
        return (
            count_1 > count_2
            ? 1
            : 2
        );
    };
};

/**
 * Returns the sealed compartments of a game, with their owners.
 * A compartment is sealed when none of its cells remain empty.
 * Its owner is the player whose pieces cover more of its cells
 * (garnish counts to neither player);
 * if they cover it equally, the owner is `0` and
 * nobody earns its bonus.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to check.
 * @returns {Object[]} An array of {origin, owner} records,
 *   one per sealed compartment.
 */
BoxedUp.sealed_compartments = function (game) {
    return BoxedUp.compartments(game.board).filter(
        (compartment) => compartment.cells.every(function ([row, column]) {
            return game.board[row][column] !== 0;
        })
    ).map(function (compartment) {
        return {
            "origin": compartment.origin,
            "owner": compartment_owner(game.board)(compartment)
        };
    });
};

/**
 * Returns the number of cells of the box covered by a player's pieces.
 * Garnish counts to neither player.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Player} player The player whose cells to count.
 * @param {BoxedUp.Game} game The game to count cells in.
 * @returns {number} The number of cells the player has covered.
 */
BoxedUp.cells_covered = function (player, game) {
    return R.count(R.equals(player), R.flatten(game.board));
};

/**
 * This helper counts how many of the given special cells are covered
 * by a player's pieces.
 * @function
 * @param {BoxedUp.Player} player The player whose pieces to look for.
 * @param {BoxedUp.Board} board The board to inspect.
 * @param {BoxedUp.Position[]} positions The special cells to check.
 * @returns {number} How many of those cells the player covers.
 */
const covered_specials = function (player, board, positions) {
    const cells = positions || [];
    return cells.filter(function ([row, column]) {
        return board[row][column] === player;
    }).length;
};

/**
 * Returns how many golden star cells a player has covered.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Player} player The player to count stars for.
 * @param {BoxedUp.Game} game The game to inspect.
 * @returns {number} The number of stars the player covers.
 */
BoxedUp.covered_stars = function (player, game) {
    return covered_specials(player, game.board, game.star_cells);
};

/**
 * Returns how many wasabi cells a player has covered.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Player} player The player to count wasabi for.
 * @param {BoxedUp.Game} game The game to inspect.
 * @returns {number} The number of wasabi cells the player covers.
 */
BoxedUp.covered_wasabi = function (player, game) {
    return covered_specials(player, game.board, game.wasabi_cells);
};

/**
 * Returns a player's score:
 * one point per covered cell,
 * plus {@link BoxedUp.compartment_bonus} per sealed compartment owned
 * (plus {@link BoxedUp.vip_bonus} more if it is the VIP order),
 * plus {@link BoxedUp.star_bonus} per covered golden star,
 * minus {@link BoxedUp.wasabi_penalty} per covered wasabi cell,
 * plus {@link BoxedUp.closing_bonus} if the game has ended and this
 * player packed the final piece.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Player} player The player whose score to compute.
 * @param {BoxedUp.Game} game The game to score.
 * @returns {number} The player's score.
 */
BoxedUp.score = function (player, game) {
    const owned = BoxedUp.sealed_compartments(game).filter(
        (sealed) => sealed.owner === player
    );
    const compartment_points = owned.reduce(function (total, sealed) {
        return total + BoxedUp.compartment_bonus + (
            R.equals(sealed.origin, game.vip_compartment)
            ? BoxedUp.vip_bonus
            : 0
        );
    }, 0);
    const closing = (
        (game.last_player === player && BoxedUp.is_ended(game))
        ? BoxedUp.closing_bonus
        : 0
    );
    return (
        BoxedUp.cells_covered(player, game) +
        compartment_points +
        BoxedUp.star_bonus * BoxedUp.covered_stars(player, game) -
        BoxedUp.wasabi_penalty * BoxedUp.covered_wasabi(player, game) +
        closing
    );
};

/**
 * Returns which player is currently ahead on {@link BoxedUp.score},
 * whether or not the game has ended:
 * the player with the higher score, or `0` if the scores are level.
 * Unlike {@link BoxedUp.winner}, this reports the standing of a game
 * still in progress — useful, for example, to decide the result of a
 * box whose play was cut short.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to check.
 * @returns {(BoxedUp.Player | 0)} The player ahead, or `0` for a tie.
 */
BoxedUp.leader = function (game) {
    const score_1 = BoxedUp.score(1, game);
    const score_2 = BoxedUp.score(2, game);
    if (score_1 === score_2) {
        return 0;
    }
    return (
        score_1 > score_2
        ? 1
        : 2
    );
};

/**
 * Returns the winner of an ended game.
 * The winner is the player with the higher {@link BoxedUp.score}
 * (i.e. the {@link BoxedUp.leader}).
 * If both players have the same score,
 * the game is a draw, signified by `0`.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Game} game The game to check for a winner.
 * @returns {(BoxedUp.Player | 0 | undefined)} The winning player,
 *   `0` for a draw, or `undefined` if the game has not ended.
 */
BoxedUp.winner = function (game) {
    return (
        BoxedUp.is_ended(game)
        ? BoxedUp.leader(game)
        : undefined
    );
};

/**
 * A ply is one turn taken by one of the players:
 * the current player packs one piece, in a chosen orientation,
 * into the box at a chosen position.
 * Returns a new game with the piece packed and the other player to
 * play — unless the piece covered a matcha cell, in which case the
 * energised chef immediately takes another turn.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape of the piece being packed,
 *   one of {@link BoxedUp.piece_shapes} or a rotation of one.
 * @param {BoxedUp.Position} position The position of the shape's anchor.
 * @param {BoxedUp.Game} game The game state that the ply is made on.
 * @returns {(BoxedUp.Game | undefined)} If the ply was legal,
 *   return the new game state, otherwise return `undefined`.
 */
BoxedUp.place = function (shape, position, game) {
    if (BoxedUp.is_ended(game)) {
        return undefined;
    }
    if (!BoxedUp.is_legal_placement(shape, position, game.board)) {
        return undefined;
    }
    const covered = BoxedUp.placement_cells(shape, position);
    const matcha_cells = game.matcha_cells || [];
    const extra_turn = covered.some(
        (cell) => R.includes(cell, matcha_cells)
    );
    return {
        "board": covered.reduce(fill_cell(game.player), game.board),
        "player": (
            extra_turn
            ? game.player
            : 3 - game.player
        ),
        "star_cells": game.star_cells,
        "wasabi_cells": game.wasabi_cells,
        "matcha_cells": game.matcha_cells,
        "vip_compartment": game.vip_compartment,
        "last_player": game.player
    };
};

/**
 * Returns how many points the player to move would gain by packing a
 * shape at a position: the change in their {@link BoxedUp.score} once
 * the ply is made. A ply that is not legal gains nothing, so this
 * returns `0` for it. Lets a chef weigh a move before committing.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Shape} shape The shape being placed.
 * @param {BoxedUp.Position} position The position of the shape's anchor.
 * @param {BoxedUp.Game} game The game the ply would be made on.
 * @returns {number} The points the player to move would gain.
 */
BoxedUp.score_gain = function (shape, position, game) {
    const after = BoxedUp.place(shape, position, game);
    if (after === undefined) {
        return 0;
    }
    return BoxedUp.score(game.player, after) - BoxedUp.score(game.player, game);
};

/**
 * Returns a {@link BoxedUp.to_string} like function,
 * mapping tokens to provided string representations.
 * @memberof BoxedUp
 * @function
 * @param {string[]} token_strings
 * Strings to represent tokens as. Examples are given in
 * {@link BoxedUp.token_strings}.
 * @returns {function} The string representation.
 */
BoxedUp.to_string_with_tokens = (token_strings) => (board) => R.pipe(
    R.map(R.map((token) => token_strings[token] || token)),
    R.map(R.join(" ")), // Add a space between each cell.
    R.join("\n") // Stack rows atop each other.
)(board);

/**
 * Returns a string representation of a board.
 * I.e. for printing to the console rather than serialisation.
 * @memberof BoxedUp
 * @function
 * @param {BoxedUp.Board} board The board to represent.
 * @returns {string} The string representation.
 */
BoxedUp.to_string = BoxedUp.to_string_with_tokens(["0", "1", "2", "3"]);

export default Object.freeze(BoxedUp);