import express from 'express';
import crypto from 'crypto';
import { layout, staticData } from '../util/layout';
import basePath from '../basepath';
import { debug, all, info, INCOMING_GET_BATCH_MS } from './games/constants';

import { getValidRoads, getValidCorners, isRuleEnabled } from '../util/validLocations';
import {
  Player,
  Game,
  Session,
  CornerPlacement,
  RoadPlacement,
  Offer,
  Turn,
  Tile,
  PlayerColor,
  PLAYER_COLORS,
  RESOURCE_TYPES,
  ResourceType,
} from './games/types';
import {
  audio,
  join as webrtcJoin,
  part as webrtcPart,
  handleRelayICECandidate,
  handleRelaySessionDescription,
  broadcastPeerStateUpdate,
} from './webrtc-signaling';

const router = express.Router();

import {
  addActivity,
  addChatMessage,
  getNextPlayerSession,
  clearPlayer,
  canGiveBuilding,
  setForRoadPlacement,
  setForCityPlacement,
  setForSettlementPlacement,
  adjustResources,
  sendUpdateToPlayers,
  getFilteredPlayers,
  sendUpdateToPlayer,
  startTurnTimer,
  getName,
  queueSend,
  shuffle,
  resetTurnTimer,
} from './games/helpers';
import { gameDB, games } from './games/store';
import { transientState } from './games/sessionState';
import { createGame, resetGame, setBeginnerGame } from './games/gameFactory';
import { getVictoryPointRule, setRules, supportedRules } from './games/rules';
import { pickRobber } from './games/robber';
import { IncomingMessage } from './games/types';
import { newTurn } from './games/turnFactory';

const processTies = (players: Player[]): boolean => {
  /* Sort the players into buckets based on their
   * order, and their current roll. If a resulting
   * roll array has more than one element, then there
   * is a tie that must be resolved. To ensure that
   * earlier rolls remain more significant across
   * subsequent tie-break rounds, we pad singleton
   * players' numeric `order` when ties are detected so
   * their earlier digits don't get outranked by extra
   * digits appended to tied players. */
  let slots: Player[][] = [];
  players.forEach((player: Player) => {
    if (!slots[player.order]) {
      slots[player.order] = [];
    }
    slots[player.order]!.push(player);
  });

  let ties = false,
    position = 1;

  const irstify = (position: number): string => {
    switch (position) {
      case 1:
        return `1st`;
      case 2:
        return `2nd`;
      case 3:
        return `3rd`;
      case 4:
        return `4th`;
      default:
        return position.toString();
    }
  };

  // First detect whether any ties exist at all. If so, we need to pad
  // singleton players' `order` values (multiply by 6) so that their
  // existing rolls remain more significant than any additional digits
  // that will be appended by tied players when they re-roll.
  for (const slot of slots) {
    if (slot && slot.length > 1) {
      ties = true;
      break;
    }
  }

  if (ties) {
    // Pad singleton players by shifting their order a digit left in base-6
    players.forEach((player: Player) => {
      const slot = slots[player.order];
      if (slot && slot.length === 1) {
        player.order = (player.order || 0) * 6;
      }
    });

    // Rebuild slots based on the newly padded orders so we can assign
    // positions correctly below.
    slots = [];
    players.forEach((player: Player) => {
      if (!slots[player.order]) {
        slots[player.order] = [];
      }
      slots[player.order]!.push(player);
    });
  }

  /* Reverse from high to low */
  const rev = slots.slice().reverse();
  for (const slot of rev) {
    const s = slot || [];
    // Only consider actual populated slots. Empty slots (holes) should be skipped.
    if (s.length > 1) {
      // real tie among multiple players in this slot
      ties = true;
      s.forEach((player: Player) => {
        player.orderRoll = 0; /* Ties have to be re-rolled */
        player.position = irstify(position);
        player.orderStatus = `Tied for ${irstify(position)}`;
        player.tied = true;
      });
      position += s.length;
    } else if (s.length === 1 && s[0]) {
      // single player in this slot - clear tie and record position
      s[0].tied = false;
      s[0].position = irstify(position);
      s[0].orderStatus = `Placed in ${irstify(position)}.`;
      position += 1;
    } else {
      // empty slot - skip
      continue;
    }
  }

  return ties;
};

const processGameOrder = (game: Game, player: Player, dice: number): string | undefined => {
  if (player.orderRoll) {
    return `You have already rolled for game order and are not in a tie.`;
  }

  player.orderRoll = dice;
  player.order = (player.order || 0) * 6 + dice;

  const players: Player[] = [];

  let doneRolling = true;
  for (const key in game.players) {
    const p = game.players[key];
    if (!p) {
      doneRolling = false;
      continue;
    }
    if (!p.orderRoll) {
      doneRolling = false;
    }
    players.push(p);
  }

  /* If 'doneRolling' is FALSE then there are still players to roll */
  if (!doneRolling) {
    sendUpdateToPlayers(game, {
      players: getFilteredPlayers(game),
      chat: game.chat,
    });
    return undefined;
  }

  /* sort updated player.order into the array  */
  players.sort((A, B) => {
    return B.order - A.order;
  });

  console.log(
    `${info}: Pre process ties: `,
    players.reduce((acc, p) => ({ ...acc, [p.color as string]: p.orderRoll }), {})
  );

  if (processTies(players)) {
    console.log(
      `${info}: There are ties in player rolls:`,
      players.reduce((acc, p) => ({ ...acc, [p.color as string]: p.orderRoll }), {})
    );
    sendUpdateToPlayers(game, {
      players: getFilteredPlayers(game),
      chat: game.chat,
    });
    return undefined;
  }

  addChatMessage(
    game,
    null,
    `Player order set to ` +
      players.map(player => `${player.position}: ${player.name}`).join(', ') +
      `.`
  );

  game.playerOrder = players.map(player => player.color);
  game.state = 'initial-placement';
  game.direction = 'forward';
  const first = players[0]!;
  game.turn = newTurn(first);
  setForSettlementPlacement(game, getValidCorners(game));
  addActivity(
    game,
    null,
    `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`
  );
  addChatMessage(game, null, `Initial settlement placement has started!`);
  addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);

  sendUpdateToPlayers(game, {
    players: getFilteredPlayers(game),
    state: game.state,
    direction: game.direction,
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
  });

  return undefined;
};

const processVolcano = (game: Game, session: Session, dice: number[]) => {
  const name = session.name ? session.name : 'Unnamed';
  void session.player;

  const volcano = layout.tiles.findIndex((_tile, index) => {
    const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined;
    return (
      typeof tileIndex === 'number' &&
      !!staticData.tiles &&
      staticData.tiles[tileIndex]?.type === 'desert'
    );
  });

  /* Find the volcano tile */
  console.log(`${info}: Processing volcano roll!`, { dice });
  addChatMessage(game, session, `${name} rolled ${dice[0]} for the Volcano!`);

  game.dice = dice;
  game.state = 'normal';

  if (volcano !== -1 && layout.tiles?.[volcano] && dice && dice[0] !== undefined) {
    const corners = layout.tiles[volcano].corners;
    if (corners && corners[dice[0] % 6] !== undefined) {
      game.turn.volcano = corners[dice[0] % 6];
    }
  }
  const volcanoIdx = typeof game.turn.volcano === 'number' ? game.turn.volcano : undefined;
  const corner = volcanoIdx !== undefined ? game.placements.corners[volcanoIdx] : undefined;
  if (corner && corner.color && corner.color !== 'unassigned') {
    const player = game.players[corner.color];
    if (player) {
      if (corner.type === 'city') {
        if (player.settlements && player.settlements > 0) {
          addChatMessage(game, null, `${player.name}'s city was wiped back to just a settlement!`);
          player.cities = (player.cities || 0) + 1;
          player.settlements = (player.settlements || 0) - 1;
          corner.type = 'settlement';
        } else {
          addChatMessage(
            game,
            null,
            `${player.name}'s city was wiped out, and they have no settlements to replace it!`
          );
          corner.type = 'none';
          corner.color = 'unassigned';
          player.cities = (player.cities || 0) + 1;
        }
      } else {
        addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
        corner.type = 'none';
        corner.color = 'unassigned';
        player.settlements = (player.settlements || 0) + 1;
      }
    }
  }

  sendUpdateToPlayers(game, {
    turn: game.turn,
    state: game.state,
    chat: game.chat,
    dice: game.dice,
    placements: game.placements,
    players: getFilteredPlayers(game),
    longestRoad: game.longestRoad,
    longestRoadLength: game.longestRoadLength,
  });
};

const roll = (game: Game, session: Session, dice?: number[] | undefined): string | undefined => {
  const player = session.player as Player,
    name = session.name ? session.name : 'Unnamed';

  if (!dice) {
    dice = [Math.ceil(Math.random() * 6), Math.ceil(Math.random() * 6)];
  }
  switch (game.state) {
    case 'lobby':
      /* currently not available as roll is only after color is
       * set for players */ addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
      sendUpdateToPlayers(game, { chat: game.chat });
      return undefined;

    case 'game-order':
      game.startTime = Date.now();
      addChatMessage(game, session, `${name} rolled ${dice[0]}.`);
      if (typeof dice[0] !== 'number') {
        return `Invalid roll value.`;
      }
      return processGameOrder(game, player, dice[0]);

    case 'normal':
      if (game.turn.color !== session.color) {
        return `It is not your turn.`;
      }
      if (game.turn.roll) {
        return `You already rolled this turn.`;
      }
      processRoll(game, session, dice);
      return;

    case 'volcano':
      if (game.turn.color !== session.color) {
        return `It is not your turn.`;
      }
      if (game.turn.select) {
        return `You can not roll for the Volcano until all players have mined their resources.`;
      }
      /* Only use the first die for the Volcano roll */
      if (typeof dice[0] !== 'number') {
        return `Invalid roll value.`;
      }
      processVolcano(game, session, [dice[0]]);
      return;

    default:
      return `Invalid game state (${game.state}) in roll.`;
  }
};

const sessionFromColor = (game: Game, color: string): Session | undefined => {
  for (const key in game.sessions) {
    const s = game.sessions[key];
    if (s && s.color === color) {
      return s;
    }
  }
  return undefined;
};

interface ResourceCount {
  wood: number;
  brick: number;
  sheep: number;
  wheat: number;
  stone: number;
  desert: number;
  bank: number;
}

type Received = Record<PlayerColor | 'robber', ResourceCount>;

const distributeResources = (game: Game, roll: number): void => {
  console.log(`Roll: ${roll}`);
  /* Find which tiles have this roll */
  const matchedTiles: Tile[] = [];
  const pipOrder = game.pipOrder || [];
  pipOrder.forEach((pipIndex: number, pos: number) => {
    if (staticData.pips?.[pipIndex] && staticData.pips[pipIndex].roll === roll) {
      /* TODO: Fix so it isn't hard coded to "wheat" and instead is the correct resource given
       * the resource distribution in shuffeled */
      matchedTiles.push({
        type: 'wheat',
        robber: game.robber === pos,
        index: pos,
        corners: [],
        pip: 0,
        roads: [],
        asset: 0,
      });
    }
  });

  const receives: Received = {} as Received;
  /* Initialize all fields of 'receives' to zero so we can safely increment any
   * matched tile */
  PLAYER_COLORS.forEach(color => {
    receives[color] = {} as ResourceCount;
    RESOURCE_TYPES.forEach(type => {
      receives[color]![type] = 0;
    });
  });
  /* Ensure robber entry exists */
  if (!receives.robber) {
    receives.robber = {} as ResourceCount;
    RESOURCE_TYPES.forEach(type => {
      receives.robber[type] = 0;
    });
  }

  /* Find which corners are on each tile */
  matchedTiles.forEach((tile: Tile) => {
    const tileOrder = game.tileOrder;
    const gameTiles = staticData.tiles;
    if (tile.index >= tileOrder.length) {
      return;
    }
    const shuffle = tileOrder[tile.index]!;
    const resource = gameTiles[shuffle] ? gameTiles[shuffle] : null;
    if (!resource) {
      return;
    }
    const tileLayout = layout.tiles[tile.index];
    if (!tileLayout) {
      return;
    }
    tileLayout.corners.forEach((cornerIndex: number) => {
      // corners may refer to undefined slots in placements; guard against that
      const active = game.placements.corners[cornerIndex];
      if (!active || !active.color) {
        return;
      }

      const count = active.type === 'settlement' ? 1 : 2;
      if (!tile.robber) {
        if (resource && resource.type) {
          // ensure receives entry for this color exists
          if (!receives[active.color]) {
            receives[active.color] = {} as ResourceCount;
            RESOURCE_TYPES.forEach(t => (receives[active.color]![t] = 0));
          }
          receives[active.color]![resource.type] =
            (receives[active.color]![resource.type] || 0) + count;
        }
      } else {
        const victim = game.players[active.color];
        if (isRuleEnabled(game, `robin-hood-robber`) && victim && (victim.points || 0) <= 2) {
          addChatMessage(
            game,
            null,
            `Robber does not steal ${count} ${resource.type} from ${victim?.name} due to Robin Hood Robber house rule.`
          );
          if (resource && resource.type) {
            if (!receives[active.color]) {
              receives[active.color] = {} as ResourceCount;
              RESOURCE_TYPES.forEach(t => (receives[active.color]![t] = 0));
            }
            receives[active.color]![resource.type] =
              (receives[active.color]![resource.type] || 0) + count;
          }
        } else {
          // If resource.type is falsy, skip.
          if (resource && resource.type) {
            trackTheft(game, active.color, 'robber', resource.type, count);
            receives.robber[resource.type] = (receives.robber[resource.type] || 0) + count;
          }
        }
      }
    });
  });

  const robberList: string[] = [];
  PLAYER_COLORS.forEach(color => {
    const entry = receives[color];
    if (!(entry.wood || entry.brick || entry.sheep || entry.wheat || entry.stone)) {
      return;
    }
    const messageParts: string[] = [];
    let s: Session | undefined;
    RESOURCE_TYPES.forEach(type => {
      if (entry[type] === 0) {
        return;
      }
      if (color !== 'robber') {
        s = sessionFromColor(game, color);
        if (s && s.player) {
          switch (type) {
            case 'wood':
            case 'brick':
            case 'sheep':
            case 'wheat':
            case 'stone':
              s.player[type] += entry[type];
              s.player.resources += entry[type];
              break;
            default:
              console.error('Invalid resource type to distribute:', type);
              return;
          }
          messageParts.push(`${entry[type]} ${type}`);
        }
      } else {
        robberList.push(`${entry[type]} ${type}`);
      }
    });

    if (s) {
      addChatMessage(game, s, `${s.name} receives ${messageParts.join(', ')} for pip ${roll}.`);
    }
  });

  if (robberList.length) {
    addChatMessage(
      game,
      null,
      `That pesky ${game.robberName} Robber Roberson stole ${robberList.join(', ')}!`
    );
  }
};

const processRoll = (game: Game, session: Session, dice: number[]): any => {
  if (!dice[1]) {
    console.error(`Invalid roll sequence!`);
    return;
  }

  addChatMessage(game, session, `${session.name} rolled ` + `${dice[0]}, ${dice[1]}.`);

  const sum = dice && dice[0] !== undefined && dice[1] !== undefined ? dice[0] + dice[1] : 0;

  game.dice = dice;
  game.turn.roll = sum;

  if (game.turn.roll !== 7) {
    let synonym = isRuleEnabled(game, 'twelve-and-two-are-synonyms') && (sum === 2 || sum === 12);

    distributeResources(game, game.turn.roll);

    if (isRuleEnabled(game, 'twelve-and-two-are-synonyms')) {
      if (sum === 12) {
        addChatMessage(
          game,
          session,
          `House rule 'Twelve and Two are 
        Synonyms' activated. Twelve was rolled, so two is triggered too!`
        );
        distributeResources(game, 2);
      }
      if (sum === 2) {
        addChatMessage(
          game,
          session,
          `House rule 'Twelve and Two are 
        Synonyms' activated. Two was rolled, so twelve is triggered too!`
        );
        distributeResources(game, 12);
      }
    }

    if (isRuleEnabled(game, 'roll-double-roll-again')) {
      if (dice[0] === dice[1]) {
        addChatMessage(
          game,
          session,
          `House rule 'Roll Double, Roll 
        Again' activated.`
        );
        game.turn.roll = 0;
      }
    }

    if (isRuleEnabled(game, 'volcano')) {
      if (
        sum === parseInt(game.rules['volcano'].number) ||
        (synonym && (game.rules['volcano'].number === 2 || game.rules['volcano'].number === 12))
      ) {
        addChatMessage(
          game,
          session,
          `House rule 'Volcano' activated. The
        Volcano is erupting!`
        );

        game.state = 'volcano';

        let count = 0;

        if (game.rules['volcano'].gold) {
          game.turn.select = {};
          const volcanoIdx = layout.tiles.findIndex((_tile, index) => {
            const tileIndex = game.tileOrder ? game.tileOrder[index] : undefined;
            return (
              typeof tileIndex === 'number' &&
              !!staticData.tiles &&
              staticData.tiles[tileIndex]?.type === 'desert'
            );
          });
          if (volcanoIdx !== -1 && layout.tiles[volcanoIdx]) {
            const vCorners = layout.tiles[volcanoIdx].corners || [];
            vCorners.forEach((index: number) => {
              const corner = game.placements.corners[index];
              if (corner && corner.color && corner.color !== 'unassigned') {
                if (!game.turn.select) {
                  game.turn.select = {} as Record<string, number>;
                }
                if (!game.turn.select) {
                  game.turn.select = {} as Record<string, number>;
                }
                if (!(corner.color in game.turn.select)) {
                  game.turn.select[corner.color] = 0;
                }
                game.turn.select[corner.color] =
                  (game.turn.select[corner.color] || 0) + (corner.type === 'settlement' ? 1 : 2);
                count += corner.type === 'settlement' ? 1 : 2;
              }
            });
          }
          console.log(`Volcano! - `, {
            mode: 'gold',
            selected: game.turn.select,
          });
          if (count) {
            /* To gain volcano resources, you need at least 3 settlements,
             * so Robin Hood Robber does not apply */
            if (volcanoIdx === game.robber) {
              addChatMessage(
                game,
                null,
                `That pesky ${game.robberName} Robber Roberson blocked ${count} volcanic mineral resources!`
              );
              addChatMessage(
                game,
                null,
                `${game.turn.name} must roll the die to determine which direction the lava will flow!`
              );
              if (game.turn.select) delete game.turn.select;
            } else {
              addChatMessage(
                game,
                null,
                `House rule 'Volcanoes have minerals' activated. Players must select which resources to receive from the Volcano!`
              );
              game.turn.actions = ['select-resources'];
              game.turn.active = 'volcano';
            }
          } else {
            addChatMessage(
              game,
              null,
              `${game.turn.name} must roll the die to determine which direction the lava will flow!`
            );
            delete game.turn.select;
          }
        }
      }
    }

    for (let id in game.sessions) {
      const _sess = game.sessions[id];
      if (_sess && _sess.player) {
        sendUpdateToPlayer(game, _sess, {
          private: _sess.player,
        });
      }
    }

    sendUpdateToPlayers(game, {
      turn: game.turn,
      players: getFilteredPlayers(game),
      chat: game.chat,
      dice: game.dice,
      state: game.state,
    });
    return;
  }

  /* ROBBER Robber Robinson! */

  game.turn.robberInAction = true;
  game.turn.placedRobber = false;
  const mustDiscard = [];

  for (let id in game.sessions) {
    const player = game.sessions[id]?.player;
    if (player) {
      let discard =
        (player.stone || 0) +
        (player.wheat || 0) +
        (player.brick || 0) +
        (player.wood || 0) +
        (player.sheep || 0);
      if (discard > 7) {
        discard = Math.floor(discard / 2);
        player.mustDiscard = discard;
        mustDiscard.push(player);
      } else {
        delete player.mustDiscard;
      }
    }
  }

  if (mustDiscard.length === 0) {
    addChatMessage(
      game,
      null,
      `ROBBER! ${game.robberName} Robber Roberson has fled, and no one had to discard!`
    );
    addChatMessage(game, null, `A new robber has arrived and must be placed by ${game.turn.name}.`);
    game.turn.actions = ['place-robber'];
    game.turn.limits = { pips: [] };
    for (let i = 0; i < staticData.tiles.length; i++) {
      if (i === game.robber) {
        continue;
      }
      game.turn.limits.pips.push(i);
    }
  } else {
    mustDiscard.forEach(player => {
      addChatMessage(
        game,
        null,
        `The robber was rolled and ${player.name} must discard ${player.mustDiscard} resource cards!`
      );
      for (let key in game.sessions) {
        const _sess = game.sessions[key];
        if (_sess && _sess.player === player) {
          sendUpdateToPlayer(game, _sess, {
            private: player,
          });
          break;
        }
      }
    });
  }

  sendUpdateToPlayers(game, {
    turn: game.turn,
    players: getFilteredPlayers(game),
    chat: game.chat,
    dice: game.dice,
  });
};

// newPlayer is provided by ./games/playerFactory

const getSession = (game: Game, id: string): Session => {
  if (!game.sessions) {
    game.sessions = {};
  }

  /* If this session is not yet in the game, add it and set the player's name */
  if (!(id in game.sessions)) {
    game.sessions[id] = {
      id: id,
      short: `[${id.substring(0, 8)}]`,
      name: '',
      color: 'unassigned',
      lastActive: Date.now(),
      live: true,
    };
  }

  const session = game.sessions[id]!;
  session.lastActive = Date.now();
  session.live = true;
  if (session.player) {
    session.player.live = true;
    session.player.lastActive = session.lastActive;
  }

  /* Expire old unused sessions */
  for (let _id in game.sessions) {
    const _session = game.sessions[_id];
    if (!_session) {
      continue;
    }
    // Treat the explicit "unassigned" sentinel as not set for expiring sessions
    if ((_session.color && _session.color !== 'unassigned') || _session.name || _session.player) {
      continue;
    }
    if (_id === id) {
      continue;
    }
    /* 60 minutes */
    const age = Date.now() - (_session.lastActive || 0);
    if (age > 60 * 60 * 1000) {
      console.log(`${session.short}: Expiring old session ${_id}: ${age / (60 * 1000)} minutes`);
      delete game.sessions[_id];
      transientState.clearSession(game.id, _id);
    }
  }

  transientState.preserveSession(game.id, session.id, session);
  return game.sessions[id]!;
};

const loadGame = async (id: string): Promise<Game> => {
  if (/^\.|\//.exec(id)) {
    throw Error('Invalid game ID');
  }

  if (id in games) {
    const cached = games[id]!;
    return cached;
  }

  let game: Game | null = null;
  try {
    game = await gameDB.getGameById(id);
  } catch (e) {
    console.error(`${info}: gameDB.getGameById error`, e);
    game = null;
  }

  if (game) {
    // After loading, restore transient state
    transientState.restoreGame(id, game);
    for (let sid in game.sessions) {
      if (game.sessions[sid]) {
        transientState.restoreSession(id, sid, game.sessions[sid]);
      }
    }
  }

  if (!game) {
    // If an id was requested, create the new game using that id
    // so clients that connect to /ws/:id or POST /:id will get a game
    // with the expected slug instead of a random one.
    game = await createGame(id || null);
    // Persist the newly-created game immediately
    try {
      await gameDB.saveGame(game);
    } catch (e) {
      console.error(`${info}: Failed to persist newly created game ${game.id}`, e);
    }
  }

  /* Clear out cached names from player colors and rebuild them
   * from the information in the saved game sessions */
  for (let color in game.players) {
    game.players[color]!.name = '';
    game.players[color]!.status = 'Not active';
  }

  /* Reconnect session player colors to the player objects */
  game.unselected = [];
  for (let id in game.sessions) {
    const session = game.sessions[id]!;
    if (
      session.name &&
      session.color &&
      session.color !== 'unassigned' &&
      session.color in game.players
    ) {
      session.player = game.players[session.color]!;
      session.player.name = session.name;
      session.player.status = 'Active';
      session.player.live = false;
    } else {
      session.color = 'unassigned';
      delete session.player;
    }

    session.live = false;

    /* Populate the 'unselected' list from the session table */
    if ((!session.color || session.color === 'unassigned') && session.name) {
      game.unselected.push(session);
    }
  }

  /* Reconstruct turn.limits if in initial-placement state and limits are missing */
  if (
    game.state === 'initial-placement' &&
    game.turn &&
    (!game.turn.limits || Object.keys(game.turn.limits).length === 0)
  ) {
    console.log(`${info}: Reconstructing turn.limits for initial-placement state after reload`);
    const currentColor = game.turn.color;

    // Check if we need to place a settlement (no action or place-settlement action)
    if (
      !game.turn.actions ||
      game.turn.actions.length === 0 ||
      game.turn.actions.indexOf('place-settlement') !== -1
    ) {
      // During initial placement, settlements may be placed on any valid corner
      // (they are not constrained by existing roads), so do not filter by color.
      setForSettlementPlacement(game, getValidCorners(game));
      console.log(
        `${info}: Set turn limits for settlement placement (${
          game.turn.limits?.corners?.length || 0
        } valid corners)`
      );
    }
    // Check if we need to place a road
    else if (game.turn.actions.indexOf('place-road') !== -1) {
      // Find the most recently placed settlement by the current player
      let mostRecentSettlementIndex = -1;
      if (game.placements && game.placements.corners) {
        // Look for settlements of the current player's color
        for (let i = game.placements.corners.length - 1; i >= 0; i--) {
          const corner = game.placements.corners[i];
          if (corner && corner.color === currentColor && corner.type === 'settlement') {
            mostRecentSettlementIndex = i;
            break;
          }
        }
      }

      if (mostRecentSettlementIndex >= 0 && layout.corners?.[mostRecentSettlementIndex]?.roads) {
        const roads = layout.corners?.[mostRecentSettlementIndex]?.roads || [];
        setForRoadPlacement(game, roads);
        console.log(
          `${info}: Set turn limits for road placement (${roads.length} valid roads from settlement ${mostRecentSettlementIndex})`
        );
      } else {
        // Fallback: Allow all valid roads for this player
        const roads = getValidRoads(game, currentColor);
        setForRoadPlacement(game, roads);
        console.log(
          `${info}: Set turn limits for road placement (fallback: ${roads.length} valid roads)`
        );
      }
    }
  }

  games[id] = game;
  return game;
};

const adminCommands = (game: Game, action: string, value: string, query: any): any => {
  let color: string | undefined,
    parts: RegExpMatchArray | null,
    session: Session | any,
    corners: any,
    corner: any,
    error: any;
  void color;

  switch (action) {
    case 'rules':
      const rule = value.replace(/=.*$/, '');
      if (rule === 'list') {
        const rules: any = {};
        for (let key in supportedRules) {
          if (game.rules[key]) {
            rules[key] = game.rules[key];
          } else {
            rules[key] = { enabled: false };
          }
        }
        return JSON.stringify(rules, null, 2);
      }
      let values = value.replace(/^.*=/, '').split(',');
      const rulesObj: Record<string, any> = {};
      rulesObj[rule] = {};
      values.forEach(keypair => {
        let [key, val] = keypair.split(':');
        let parsed: any = val;
        if (val === 'true') {
          parsed = true;
        } else if (val === 'false') {
          parsed = false;
        } else if (typeof val === 'string' && !isNaN(parseInt(val))) {
          parsed = parseInt(val);
        }
        if (rule && key) rulesObj[rule][key] = parsed;
      });
      console.log(`admin - setRules -`, rulesObj);
      setRules(game, undefined, rulesObj);
      break;

    case 'debug':
      if (parseInt(value) === 0 || value === 'false') {
        delete game.debug;
      } else {
        game.debug = true;
      }
      break;

    case 'give':
      parts = value.match(/^([^-]+)(-(.*))?$/);
      if (!parts) {
        return `Unable to parse give request.`;
      }
      const type = parts[1],
        card = parts[3] || 1;

      if (game.sessions) {
        for (let id in game.sessions) {
          if (game.sessions[id] && game.sessions[id].name === game.turn.name) {
            session = game.sessions[id];
          }
        }
      }

      if (!session) {
        return `Unable to determine current player turn to give resources.`;
      }

      let done = true;
      switch (type) {
        case 'road':
          error = canGiveBuilding(game);
          if (error) {
            return error;
          }

          if (session.player.roads === 0) {
            return `Player ${game.turn.name} does not have any more roads to give.`;
          }
          let roads = getValidRoads(game, session.color === 'unassigned' ? '' : session.color);
          if (roads.length === 0) {
            return `There are no valid locations for ${game.turn.name} to place a road.`;
          }
          game.turn.free = true;
          setForRoadPlacement(game, roads);
          addChatMessage(
            game,
            null,
            `Admin gave a road to ${game.turn.name}.` + `They must now place the road.`
          );
          break;
        case 'city':
          error = canGiveBuilding(game);
          if (error) {
            return error;
          }

          if (session.player.cities === 0) {
            return `Player ${game.turn.name} does not have any more cities to give.`;
          }
          corners = getValidCorners(
            game,
            session.color === 'unassigned' ? '' : session.color,
            'settlement'
          );
          if (corners.length === 0) {
            return `There are no valid locations for ${game.turn.name} to place a settlement.`;
          }
          game.turn.free = true;
          setForCityPlacement(game, corners);
          addChatMessage(
            game,
            null,
            `Admin gave a city to ${game.turn.name}. ` + `They must now place the city.`
          );
          break;
        case 'settlement':
          error = canGiveBuilding(game);
          if (error) {
            return error;
          }

          if (session.player.settlements === 0) {
            return `Player ${game.turn.name} does not have any more settlements to give.`;
          }
          corners = getValidCorners(game, session.color === 'unassigned' ? '' : session.color);
          if (corners.length === 0) {
            return `There are no valid locations for ${game.turn.name} to place a settlement.`;
          }
          game.turn.free = true;
          setForSettlementPlacement(game, corners);
          addChatMessage(
            game,
            null,
            `Admin gave a settlment to ${game.turn.name}. ` + `They must now place the settlement.`
          );
          break;
        case 'wheat':
        case 'sheep':
        case 'wood':
        case 'stone':
        case 'brick':
          const count = parseInt(String(card));
          session.player[type] += count;
          session.resources += count;
          addChatMessage(game, null, `Admin gave ${count} ${type} to ${game.turn.name}.`);
          break;
        default:
          done = false;
          break;
      }
      if (done) {
        break;
      }

      const index = game.developmentCards.findIndex(
        (item: any) => item.card.toString() === card && item.type === type
      );

      if (index === -1) {
        console.log({ card, type }, game.developmentCards);
        return `Unable to find ${type}-${card} in the current deck of development cards.`;
      }

      const tmp = game.developmentCards.splice(index, 1)[0]!;
      tmp.turn = game.turns ? game.turns - 1 : 0;
      session.player.development.push(tmp);
      addChatMessage(game, null, `Admin gave a ${card}-${type} to ${game.turn.name}.`);
      break;

    case 'cards':
      let results = game.developmentCards
        .map((card: any) => `${card.type}-${card.card}`)
        .join(', ');
      return results;

    case 'roll':
      let diceRaw = (query.dice || Math.ceil(Math.random() * 6)).toString();
      let dice = diceRaw.split(',').map((die: string) => parseInt(die));

      console.log({ dice });
      if (!value) {
        return `Unable to parse roll request.`;
      }

      switch (value) {
        case 'orange':
          color = 'O';
          break;
        case 'red':
          color = 'R';
          break;
        case 'blue':
          color = 'B';
          break;
        case 'white':
          color = 'W';
          break;
      }
      if (corner && corner.color && corner.color !== 'unassigned') {
        const player = game.players ? game.players[corner.color] : undefined;
        if (player) {
          if (corner.type === 'city') {
            if (player.settlements) {
              addChatMessage(
                game,
                null,
                `${player.name}'s city was wiped back to just a settlement!`
              );
              player.cities = (player.cities || 0) + 1;
              player.settlements = (player.settlements || 1) - 1;
              corner.type = 'settlement';
            } else {
              addChatMessage(
                game,
                null,
                `${player.name}'s city was wiped out, and they have no settlements to replace it!`
              );
              delete corner.type;
              delete corner.color;
              player.cities = (player.cities || 0) + 1;
            }
          } else {
            addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
            delete corner.type;
            delete corner.color;
            player.settlements = (player.settlements || 0) + 1;
          }
        }
      }
      if (!session) {
        return `Unable to determine current player turn for admin roll.`;
      }
      let warning = roll(game, session, dice);
      if (warning) {
        sendWarning(session, warning);
      }
      break;

    case 'pass':
      let name = game.turn.name;
      const next = getNextPlayerSession(game, name || '');
      if (!next) {
        addChatMessage(game, null, `Admin attempted to skip turn but no next player was found.`);
        break;
      }
      game.turn = {
        name: next.name,
        color: next.color,
      } as unknown as Turn;
      game.turns = (game.turns || 0) + 1;
      startTurnTimer(game, next);
      addChatMessage(game, null, `The admin skipped ${name}'s turn.`);
      addChatMessage(game, null, `It is ${next.name}'s turn.`);
      break;

    case 'kick':
      switch (value) {
        case 'orange':
          color = 'O';
          break;
        case 'red':
          color = 'R';
          break;
        case 'blue':
          color = 'B';
          break;
        case 'white':
          color = 'W';
          break;
      }
      if (corner && corner.color && corner.color !== 'unassigned') {
        const player = game.players[corner.color];
        if (player) {
          if (corner.type === 'city') {
            if (player.settlements && player.settlements > 0) {
              addChatMessage(
                game,
                null,
                `${player.name}'s city was wiped back to just a settlement!`
              );
              player.cities = (player.cities || 0) + 1;
              player.settlements = (player.settlements || 0) - 1;
              corner.type = 'settlement';
            } else {
              addChatMessage(
                game,
                null,
                `${player.name}'s city was wiped out, and they have no settlements to replace it!`
              );
              delete corner.type;
              delete corner.color;
              player.cities = (player.cities || 0) + 1;
            }
          } else {
            addChatMessage(game, null, `${player.name}'s settlement was wiped out!`);
            delete corner.type;
            delete corner.color;
            player.settlements = (player.settlements || 0) + 1;
          }
        }
      }
      break;

    case 'state':
      if (game.state !== 'lobby') {
        return `Game already started.`;
      }
      if (!game.active || game.active < 2) {
        return `Not enough players in game to start.`;
      }
      game.state = 'game-order';
      /* Delete any non-played colors from the player map; reduces all
       * code that would otherwise have to filter out players by checking
       * the 'Not active' state of player.status */
      for (let key in game.players) {
        const p = game.players[key];
        if (!p || p.status !== 'Active') {
          delete game.players[key];
        }
      }
      addChatMessage(game, null, `Admin requested to start the game.`);
      break;

    default:
      return `Invalid admin action ${action}.`;
  }
};

const setPlayerName = (game: Game, session: Session, name: string): string | undefined => {
  if (session.name === name) {
    return; /* no-op */
  }
  if (session.color !== 'unassigned') {
    return `You cannot change your name while you have a color selected.`;
  }

  if (!name) {
    return `You can not set your name to nothing!`;
  }

  if (name.toLowerCase() === 'the bank') {
    return `You cannot play as the bank!`;
  }

  /* Check to ensure name is not already in use */
  let rejoin = false;
  for (let id in game.sessions) {
    const tmp = game.sessions[id];
    if (!tmp || tmp === session || !tmp.name) {
      continue;
    }
    if (tmp.name.toLowerCase() === name.toLowerCase()) {
      if (!tmp.player || Date.now() - (tmp.player.lastActive || 0) > 60000) {
        rejoin = true;
        /* Update the session object from tmp, but retain websocket
         * from active session */
        Object.assign(session, tmp, { ws: session.ws, id: session.id });
        console.log(`${info}: ${name} has been reallocated to a new session.`);
        delete game.sessions[id];
        transientState.clearSession(game.id, id);
      } else {
        return `${name} is already taken and has been active in the last minute.`;
      }
    }
  }

  let message;

  if (!session.name) {
    message = `A new player has entered the lobby as ${name}.`;
  } else {
    if (rejoin) {
      if (session.color !== 'unassigned') {
        message = `${name} has reconnected to the game.`;
      } else {
        message = `${name} has rejoined the lobby.`;
      }
      session.name = name;
      if (session.ws && game.id in audio && session.id in audio[game.id]!) {
        webrtcPart(audio[game.id]!, session);
      }
    } else {
      message = `${session.name} hs changed their name to ${name}.`;
      if (session.ws && game.id in audio) {
        webrtcPart(audio[game.id]!, session);
      }
    }
  }

  session.name = name;
  session.live = true;
  if (session.player) {
    session.color = session.player.color || '';
    session.player.name = session.name;
    session.player.status = `Active`;
    session.player.lastActive = Date.now();
    session.player.name = name;
    session.player.live = true;
  }

  if (session.ws && session.hasAudio) {
    webrtcJoin(audio[game.id]!, session);
  }
  console.log(`${info}: ${message}`);
  addChatMessage(game, null, message);

  /* Rebuild the unselected list */
  if (session.color === 'unassigned') {
    console.log(`${info}: Adding ${session.name} to the unselected`);
  }
  game.unselected = [];
  for (let id in game.sessions) {
    const s = game.sessions[id];
    if (!s) continue;
    if (!s.color && s.name) {
      game.unselected.push(s);
    }
  }

  sendUpdateToPlayer(game, session, {
    name: session.name,
    color: session.color,
    live: session.live,
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    players: getFilteredPlayers(game),
    participants: getParticipants(game),
    unselected: getFilteredUnselected(game),
    chat: game.chat,
  });
  /* Now that a name is set, send the full game to the player */
  sendGameToPlayer(game, session);
  return undefined;
};

const colorToWord = (color: string): string => {
  switch (color) {
    case 'O':
      return 'orange';
    case 'W':
      return 'white';
    case 'B':
      return 'blue';
    case 'R':
      return 'red';
    default:
      return '';
  }
};

const getActiveCount = (game: Game): number => {
  let active = 0;
  for (let color in game.players) {
    const p = game.players[color];
    if (!p || !p.name) {
      continue;
    }
    active++;
  }
  return active;
};

const setPlayerColor = (game: Game, session: Session, color: PlayerColor): string | undefined => {
  /* Selecting the same color is a NO-OP */
  if (session.color === color) {
    return;
  }

  /* Verify the player has a name set */
  if (!session.name) {
    return `You may only select a player when you have set your name.`;
  }

  if (game.state !== 'lobby') {
    return `You may only select a player when the game is in the lobby.`;
  }

  /* Verify selection is valid */
  if (color && !(color in game.players)) {
    return `An invalid player selection was attempted.`;
  }

  /* Verify selection is not already taken */
  if (color) {
    const candidate = game.players[color];
    if (!candidate) {
      return `An invalid player selection was attempted.`;
    }
    if (candidate.status !== 'Not active') {
      return `${candidate.name} already has ${colorToWord(color)}`;
    }
  }

  let active = getActiveCount(game);

  if (session.player) {
    /* Deselect currently active player for this session */
    clearPlayer(session.player);
    // remove the player association
    delete session.player;
    const old_color = session.color;
    session.color = 'unassigned';
    active--;

    /* If the player is not selecting a color, then return */
    if (!color) {
      const msg =
        String(session.name || '') + ' is no longer ' + String(colorToWord(String(old_color)));
      addChatMessage(game, null, msg);
      if (!game.unselected) game.unselected = [];
      game.unselected.push(session);
      game.active = active;
      if (active === 1) {
        addChatMessage(game, null, `There are no longer enough players to start a game.`);
      }
      sendUpdateToPlayer(game, session, {
        name: session.name,
        color: '',
        live: session.live,
        private: session.player,
      });
      sendUpdateToPlayers(game, {
        active: game.active,
        unselected: getFilteredUnselected(game),
        players: getFilteredPlayers(game),
        chat: game.chat,
      });
      return;
    }
  }

  /* All good -- set this player to requested selection */
  active++;
  session.color = color;
  session.live = true;
  const picked = game.players[color];
  if (picked) {
    session.player = picked;
    picked.name = session.name;
    picked.status = `Active`;
    picked.lastActive = Date.now();
    picked.live = true;
  }
  addChatMessage(game, session, `${session.name} has chosen to play as ${colorToWord(color)}.`);

  const update: any = {
    players: getFilteredPlayers(game),
    participants: getParticipants(game),
    chat: game.chat,
  };

  /* Rebuild the unselected list */
  const unselected = [];
  for (let id in game.sessions) {
    const s = game.sessions[id];
    if (!s) continue;
    if (!s.color && s.name) {
      unselected.push(s);
    }
  }
  if (!game.unselected) game.unselected = [];
  if (unselected.length !== game.unselected.length) {
    game.unselected = unselected;
    update.unselected = getFilteredUnselected(game);
  }

  if (!game.active) game.active = 0;
  if (game.active !== active) {
    if (game.active < 2 && active >= 2) {
      addChatMessage(game, null, `There are now enough players to start the game.`);
    }
    game.active = active;
    update.active = game.active;
  }

  sendUpdateToPlayer(game, session, {
    name: session.name,
    color: session.color,
    live: session.live,
    private: session.player,
  });
  sendUpdateToPlayers(game, update);
  return undefined;
};

const processCorner = (
  game: Game,
  color: string,
  cornerIndex: number,
  placedCorner: CornerPlacement
): number => {
  /* If this corner is allocated and isn't assigned to the walking color, skip it */
  if (placedCorner.color && placedCorner.color !== 'unassigned' && placedCorner.color !== color) {
    return 0;
  }
  /* If this corner is already being walked, skip it */
  if (placedCorner.walking) {
    return 0;
  }

  placedCorner.walking = true;
  /* Calculate the longest road branching from both corners */
  let longest = 0;
  layout.corners?.[cornerIndex]?.roads.forEach((roadIndex: number) => {
    const placedRoad = game.placements.roads[roadIndex];
    if (!placedRoad) return;
    if (placedRoad.walking) {
      return;
    }
    const tmp = processRoad(game, color, roadIndex, placedRoad);
    longest = Math.max(tmp, longest);
    /*if (tmp > longest) {
      longest = tmp;
      placedCorner.longestRoad = roadIndex;
      placedCorner.longest
    }
    longest = Math.max(
      */
  });

  return longest;
};

const buildCornerGraph = (
  game: Game,
  color: string,
  cornerIndex: number,
  placedCorner: CornerPlacement,
  set: any
): void => {
  /* If this corner is allocated and isn't assigned to the walking color, skip it */
  if (placedCorner.color && placedCorner.color !== 'unassigned' && placedCorner.color !== color) {
    return;
  }
  /* If this corner is already being walked, skip it */
  if (placedCorner.walking) {
    return;
  }

  placedCorner.walking = true;
  /* Calculate the longest road branching from both corners */
  layout.corners?.[cornerIndex]?.roads.forEach((roadIndex: number) => {
    const placedRoad = game.placements.roads[roadIndex];
    if (!placedRoad) return;
    buildRoadGraph(game, color, roadIndex, placedRoad, set);
  });
};

const processRoad = (
  game: Game,
  color: string,
  roadIndex: number,
  placedRoad: RoadPlacement
): number => {
  /* If this road isn't assigned to the walking color, skip it */
  if (placedRoad.color !== color) {
    return 0;
  }

  /* If this road is already being walked, skip it */
  if (placedRoad.walking) {
    return 0;
  }

  placedRoad.walking = true;
  /* Calculate the longest road branching from both corners */
  let roadLength = 1;
  layout.roads?.[roadIndex]?.corners.forEach((cornerIndex: number) => {
    const placedCorner = game.placements.corners?.[cornerIndex];
    if (!placedCorner) return;
    if (placedCorner.walking) {
      return;
    }
    roadLength += processCorner(game, color, cornerIndex, placedCorner);
  });

  return roadLength;
};

const buildRoadGraph = (
  game: Game,
  color: string,
  roadIndex: number,
  placedRoad: RoadPlacement,
  set: number[]
) => {
  /* If this road isn't assigned to the walking color, skip it */
  if (placedRoad.color !== color) {
    return;
  }
  /* If this road is already being walked, skip it */
  if (placedRoad.walking) {
    return;
  }

  placedRoad.walking = true;
  set.push(roadIndex);
  /* Calculate the longest road branching from both corners */
  layout.roads?.[roadIndex]?.corners.forEach((cornerIndex: number) => {
    const placedCorner = game.placements?.corners?.[cornerIndex];
    if (!placedCorner) return;
    buildCornerGraph(game, color, cornerIndex, placedCorner, set);
  });
};

const clearRoadWalking = (game: Game): void => {
  /* Clear out walk markers on roads */
  layout.roads.forEach((_item, itemIndex) => {
    if (game.placements?.roads?.[itemIndex]) {
      delete game.placements.roads[itemIndex].walking;
    }
  });

  /* Clear out walk markers on corners */
  layout.corners.forEach((_item, itemIndex) => {
    if (game.placements?.corners?.[itemIndex]) {
      delete game.placements.corners[itemIndex].walking;
    }
  });
};

const calculateRoadLengths = (game: Game, session: Session): void => {
  clearRoadWalking(game);

  let currentLongest = game.longestRoad,
    currentLength =
      currentLongest && typeof currentLongest === 'string' && game.players[currentLongest]
        ? game.players[currentLongest].longestRoad || -1
        : -1;

  /* Clear out player longest road counts */
  for (let key in game.players) {
    if (game.players[key]) {
      game.players[key].longestRoad = 0;
    }
  }

  /* Build a set of connected road graphs. Once all graphs are
   * constructed, walk through each graph, starting from each
   * location in the graph. If the length ever equals the
   * number of items in the graph, short circuit--longest path.
   * Otherwise, check all paths from each segment. This is
   * needed to catch loops where starting from an outside end
   * point may result in not counting the length of the loop
   */
  let graphs: {
    color: string;
    set: number[];
    longestRoad?: number;
    longestStartSegment?: number;
  }[] = [];
  layout.roads.forEach((_: any, roadIndex: number) => {
    const placedRoad = game.placements?.roads?.[roadIndex];
    if (
      placedRoad &&
      placedRoad.color &&
      placedRoad.color !== 'unassigned' &&
      typeof placedRoad.color === 'string'
    ) {
      let set: number[] = [];
      buildRoadGraph(game, placedRoad.color, roadIndex, placedRoad, set);
      if (set.length) {
        graphs.push({ color: placedRoad.color, set });
      }
    }
  });

  if (debug.road) console.log('Graphs A:', graphs);

  clearRoadWalking(game);
  graphs.forEach((graph: any) => {
    graph.longestRoad = 0;
    graph.set.forEach((roadIndex: number) => {
      const placedRoad = game.placements.roads[roadIndex];
      if (!placedRoad) return;
      clearRoadWalking(game);
      const length = processRoad(game, placedRoad.color as string, roadIndex, placedRoad);
      if (length >= graph.longestRoad) {
        graph.longestStartSegment = roadIndex;
        graph.longestRoad = length;
      }
    });
  });

  if (debug.road) console.log('Graphs B:', graphs);

  if (debug.road)
    console.log(
      'Pre update:',
      game.placements.roads.filter(road => road && road.color)
    );

  for (let color in game.players) {
    if (game.players[color]?.status === 'Not active') {
      continue;
    }
    if (game.players[color]) {
      game.players[color].longestRoad = 0;
    }
  }

  graphs.forEach((graph: any) => {
    graph.set.forEach((roadIndex: number) => {
      const placedRoad = game.placements.roads[roadIndex];
      if (!placedRoad) return;
      clearRoadWalking(game);
      const longestRoad = processRoad(game, placedRoad.color as string, roadIndex, placedRoad);
      placedRoad['longestRoad'] = longestRoad;
      if (
        placedRoad.color &&
        placedRoad.color !== 'unassigned' &&
        typeof placedRoad.color === 'string'
      ) {
        const player = game.players[placedRoad.color];
        if (player) {
          const prevVal = player['longestRoad'] || 0;
          player['longestRoad'] = Math.max(prevVal, longestRoad);
        }
      }
    });
  });

  game.placements.roads.forEach((road: any) => delete road.walking);

  if (debug.road)
    console.log(
      'Post update:',
      game.placements.roads.filter((road: any) => road && road.color)
    );

  let checkForTies = false;

  if (debug.road) console.log(currentLongest, currentLength);

  if (currentLongest && typeof currentLongest === 'string' && game.players[currentLongest]) {
    const playerLongest = game.players[currentLongest]['longestRoad'] || 0;
    if (playerLongest < currentLength) {
      const prevSession = sessionFromColor(game, currentLongest as string);
      if (prevSession) {
        addChatMessage(game, prevSession, `${prevSession.name} had their longest road split!`);
      }
      checkForTies = true;
    }
  }

  let longestRoad = 4;
  let longestPlayers: Player[] = [];
  for (let key in game.players) {
    const player = game.players[key];
    if (!player || player.status === 'Not active') {
      continue;
    }
    const pLen = player.longestRoad || 0;
    if (pLen > longestRoad) {
      longestPlayers = [player];
      longestRoad = pLen;
    } else if (pLen === longestRoad) {
      if (longestRoad >= 5) {
        longestPlayers.push(player);
      }
    }
  }

  console.log({ longestPlayers });

  if (longestPlayers.length > 0) {
    if (longestPlayers.length === 1) {
      game.longestRoadLength = longestRoad;
      if (longestPlayers[0] && longestPlayers[0].color) {
        if (game.longestRoad !== longestPlayers[0].color) {
          game.longestRoad = longestPlayers[0].color;
          addChatMessage(
            game,
            session,
            `${longestPlayers[0].name} now has the longest ` + `road (${longestRoad})!`
          );
        }
      }
    } else {
      if (checkForTies) {
        game.longestRoadLength = longestRoad;
        const names = longestPlayers.map(player => player.name);
        addChatMessage(
          game,
          session,
          `${names.join(', ')} are tied for longest ` + `road (${longestRoad})!`
        );
      }
      /* Do not reset the longest road! Current Longest is still longest! */
    }
  } else {
    game.longestRoad = false;
    game.longestRoadLength = 0;
  }
};

const isCompatibleOffer = (player: Player, offer: Offer): boolean => {
  const isBank = offer.name === 'The bank';

  const playerGetsLen = player.gets.length;
  const playerGivesLen = player.gives.length;
  const offerGetsLen = offer.gets.length;
  const offerGivesLen = offer.gives.length;

  let valid = playerGetsLen === offerGivesLen && playerGivesLen === offerGetsLen;

  if (!valid) {
    console.log(`Gives and gets lengths do not match!`);
    return false;
  }

  console.log(
    {
      player: 'Submitting player',
      gets: player.gets,
      gives: player.gives,
    },
    {
      name: offer.name,
      gets: offer.gets,
      gives: offer.gives,
    }
  );

  for (const get of player.gets) {
    if (!offer.gives.some(item => (item.type === get.type || isBank) && item.count === get.count)) {
      valid = false;
      break;
    }
  }

  if (valid) {
    for (const give of player.gives) {
      if (
        !offer.gets.some(item => (item.type === give.type || isBank) && item.count === give.count)
      ) {
        valid = false;
        break;
      }
    }
  }
  return valid;
};

const isSameOffer = (player: Player, offer: Offer): boolean => {
  const isBank = offer.name === 'The bank';
  if (isBank) {
    return false;
  }

  if (player.gets.length !== offer.gets.length || player.gives.length !== offer.gives.length) {
    return false;
  }

  for (const get of player.gets) {
    if (!offer.gets.find(item => item.type === get.type && item.count === get.count)) {
      return false;
    }
  }

  for (const give of player.gives) {
    if (!offer.gives.find(item => item.type === give.type && item.count === give.count)) {
      return false;
    }
  }

  return true;
};

/* Verifies player can meet the offer */
const checkPlayerOffer = (_game: Game, player: Player, offer: Offer): string | undefined => {
  let error: string | undefined = undefined;
  const name = player.name || 'Unknown';

  console.log({
    checkPlayerOffer: {
      name,
      player,
      gets: offer.gets,
      gives: offer.gives,
      sheep: player.sheep,
      wheat: player.wheat,
      brick: player.brick,
      stone: player.stone,
      wood: player.wood,
      description: offerToString(offer),
    },
  });

  for (const give of offer.gives) {
    if (error) break;
    if (!RESOURCE_TYPES.includes(give.type)) {
      error = `${give.type} is not a valid resource!`;
      break;
    }

    if (give.count <= 0) {
      error = `${give.count} must be more than 0!`;
      break;
    }

    if (player[give.type] < give.count) {
      error = `${name} does do not have ${give.count} ${give.type}!`;
      break;
    }

    if (offer.gets.find(get => give.type === get.type)) {
      error = `${name} can not give and get the same resource type!`;
      break;
    }
  }

  if (!error) {
    for (const get of offer.gets) {
      if (error) break;
      if (get.count <= 0) {
        error = `${get.count} must be more than 0!`;
        break;
      }
      if (offer.gives.find(give => get.type === give.type)) {
        error = `${name} can not give and get the same resource type!`;
        break;
      }
    }
  }

  return error;
};

const canMeetOffer = (player: Player, offer: Offer): boolean => {
  for (const get of offer.gets) {
    if (get.type === 'bank') {
      if (!player.gives[0]) {
        return false;
      }
      const giveType = player.gives[0].type;
      if (player[giveType] < get.count || get.count <= 0) {
        return false;
      }
    } else if (player[get.type] < get.count || get.count <= 0) {
      return false;
    }
  }
  return true;
};

const setGameFromSignature = (game: Game, border: string, pip: string, tile: string): boolean => {
  const salt = 251;
  const borders = [],
    pips = [],
    tiles = [];
  for (let i = 0; i < 6; i++) {
    const parsed = parseInt(border.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(parsed)) return false;
    borders[i] = parsed ^ salt;
    if (borders[i]! > 6) {
      return false;
    }
  }
  for (let i = 0; i < staticData.tiles.length; i++) {
    const parsed = parseInt(pip.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(parsed)) return false;
    // Ensure we decode to a single byte explicitly. Parenthesize to avoid
    // surprises from operator precedence (% vs ^) and mask to 0..255.
    pips[i] = (parsed ^ salt ^ (salt * i)) & 0xff;
    if (pips[i]! > staticData.tiles.length - 1) {
      return false;
    }
  }
  for (let i = 0; i < staticData.tiles.length; i++) {
    const parsed = parseInt(tile.slice(i * 2, i * 2 + 2), 16);
    if (Number.isNaN(parsed)) return false;
    // As above, decode unambiguously into a single byte value.
    tiles[i] = (parsed ^ salt ^ (salt * i)) & 0xff;
    if (tiles[i]! > staticData.tiles.length - 1) {
      return false;
    }
  }
  game.borderOrder = borders;
  game.pipOrder = pips;
  game.tileOrder = tiles;
  return true;
};

const offerToString = (offer: Offer): string => {
  return (
    (offer.gives || []).map(item => `${item.count} ${item.type}`).join(', ') +
    ' in exchange for ' +
    (offer.gets || []).map(item => `${item.count} ${item.type}`).join(', ')
  );
};

router.put('/:id/:action/:value?', async (req, res) => {
  const { action, id } = req.params,
    value = req.params.value ? req.params.value : '';
  console.log(`PUT games/${id}/${action}/${value}`);

  const game = await loadGame(id);
  if (!game) {
    const error = `Game not found and cannot be created: ${id}`;
    return res.status(404).send(error);
  }

  let error = 'Invalid request';

  if ('private-token' in req.headers) {
    if (req.headers['private-token'] !== req.app.get('admin')) {
      error = `Invalid admin credentials.`;
    } else {
      error = adminCommands(game, action, value, req.query);
    }
    if (!error) {
      sendGameToPlayers(game);
    } else {
      console.log(`admin-action error: ${error}`);
    }
  }

  return res.status(400).send(error);
});

const startTrade = (game: Game, session: Session): string | undefined => {
  /* Only the active player can begin trading */
  if (game.turn.name !== session.name) {
    return `You cannot start trading negotiations when it is not your turn.`;
  }
  /* Clear any free gives if the player begins trading */
  game.turn.free = false;
  game.turn.actions = ['trade'];
  game.turn.limits = {};
  for (let key in game.players) {
    const p = game.players[key];
    if (!p) continue;
    p.gives = [];
    p.gets = [];
    PLAYER_COLORS.forEach(color => {
      p.offerRejected[color] = false;
    });
  }
  addActivity(game, session, `${session.name} requested to begin trading negotiations.`);
  return undefined;
};

const cancelTrade = (game: Game, session: Session): string | undefined => {
  /* TODO: Perhaps 'cancel' is how a player can remove an offer... */
  if (game.turn.name !== session.name) {
    return `Only the active player can cancel trading negotiations.`;
  }
  game.turn.actions = [];
  game.turn.limits = {};
  addActivity(game, session, `${session.name} has cancelled trading negotiations.`);
  return undefined;
};

const processOffer = (game: Game, session: Session, offer: Offer): string | undefined => {
  const player = session.player as Player;
  let warning = checkPlayerOffer(game, player, offer);
  if (warning) {
    return warning;
  }
  if (isSameOffer(player, offer)) {
    console.log(player);
    return `You already have a pending offer submitted for ${offerToString(offer)}.`;
  }

  player.gives = offer.gives;
  player.gets = offer.gets;

  if (game.turn.color === session.color) {
    game.turn.offer = offer;
  }

  /* If this offer matches what another player wants, clear rejection on that other player's offer */
  for (const color in game.players) {
    if (color === session.color) continue;
    const other = game.players[color]!;
    if (other.status !== 'Active') continue;
    /* Comparison reverses give/get order */
    if (isSameOffer(other, { name: other.name, color: other.color, gives: offer.gets, gets: offer.gives })) {
      other.offerRejected[session.color] = false;
    }
  }

  addActivity(game, session, `${session.name} submitted an offer to give ${offerToString(offer)}.`);
  return undefined;
};

const rejectOffer = (game: Game, session: Session, offer: Offer): void => {
  /* If the active player rejected an offer, they rejected another player */
  const other = game.players[offer.color]!;

  other.offerRejected[session.color] = true;
  session.player!.offerRejected[offer.color] = true;
  addActivity(game, session, `${session.name} rejected ${other.name}'s offer.`);
};

const acceptOffer = (game: Game, session: Session, offer: Offer): string | undefined => {
  const name = session.name,
    player = session.player as Player;

  if (game.turn.name !== name) {
    return `Only the active player can accept an offer.`;
  }

  let target: Player | undefined = undefined;

  console.log({ description: offerToString(offer) });

  let warning = checkPlayerOffer(game, player, offer);
  if (warning) {
    return warning;
  }

  if (
    !isCompatibleOffer(player, {
      name: offer.name,
      color: offer.color,
      gives: offer.gets,
      gets: offer.gives,
    })
  ) {
    return `Unfortunately, trades were re-negotiated in transit and the deal is invalid!`;
  }

  /* Verify that the offer sent by the active player matches what
   * the latest offer was that was received by the requesting player */
  if (offer.name !== 'The bank') {
    target = game.players[offer.color];
    if (!target) return `Invalid trade target.`;
    if (
      target.offerRejected[offer.color]
    ) {
      return `${target.name} rejected this offer.`;
    }
    if (!isCompatibleOffer(target, offer)) {
      return `Unfortunately, trades were re-negotiated in transit and ` + `the deal is invalid!`;
    }

    warning = checkPlayerOffer(
      game,
      target,
      {
        name: target.name,
        color: target.color,
        gives: offer.gets,
        gets: offer.gives,
      }
    );
    if (warning) {
      return warning;
    }

    if (
      !isSameOffer(
        target,
        { name: target.name, color: target.color, gives: offer.gets, gets: offer.gives }
      )
    ) {
      console.log({ target, offer });
      return `These terms were not agreed to by ${target.name}!`;
    }

    if (!canMeetOffer(target, player)) {
      return `${target.name} cannot meet the terms.`;
    }
  }

  debugChat(game, 'Before trade');

  /* Transfer goods */
  for (const item of offer.gets) {
    if (target) {
      target[item.type] -= item.count;
      target.resources -= item.count;
    }
    player[item.type] += item.count;
    player.resources += item.count;
  }
  for (const item of offer.gives) {
    if (target) {
      target[item.type] += item.count;
      target.resources += item.count;
    }
    player[item.type] -= item.count;
    player.resources -= item.count;
  }

  const from = offer.name === 'The bank' ? 'the bank' : offer.name;
  addChatMessage(
    game,
    session,
    `${session.name} traded ` + ` ${offerToString(offer)} ` + `from ${from}.`
  );
  addActivity(game, session, `${session.name} accepted a trade from ${from}.`);
  if (target) {
    target.gives = [];
    target.gets = [];
  }
  if (session.player) {
    session.player.gives = [];
    session.player.gets = [];
  }
  game.turn.offer = null;

  debugChat(game, 'After trade');

  game.turn.actions = [];
  return undefined;
};

const trade = (game: Game, session: Session, action: string, offer?: Offer): string | undefined => {
  if (game.state !== 'normal') {
    return `Game not in correct state to begin trading.`;
  }

  if (!session.player) {
    return `You must select a color before trading.`;
  }

  if (!game.turn.actions || game.turn.actions.indexOf('trade') === -1) {
    return startTrade(game, session);
  }

  /* Only the active player can cancel trading */
  if (action === 'cancel') {
    return cancelTrade(game, session);
  }

  /* Any player can make an offer */
  if (action === 'offer') {
    if (!offer) {
      return `No offer was specified.`;
    }
    return processOffer(game, session, offer);
  }

  /* Any player can reject an offer */
  if (action === 'reject') {
    if (!offer) {
      return `No offer was specified.`;
    }
    rejectOffer(game, session, offer);
    return undefined;
  }

  /* Only the active player can accept an offer */
  if (action === 'accept') {
    if (!offer) {
      return `No offer was specified.`;
    }

    if (offer.name === 'The bank') {
      session.player.gets = offer.gets;
      session.player.gives = offer.gives;
    }
    return acceptOffer(game, session, offer);
  }
  return undefined;
};

const clearTimeNotice = (game: Game, session: Session): string | undefined => {
  if (!session.player || !session.player.turnNotice) {
    /* benign state; don't alert the user */
    //return `You have not been idle.`;
  }
  if (session.player) session.player.turnNotice = '';
  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  return undefined;
};

const pass = (game: Game, session: Session): string | undefined => {
  const name = session.name;
  if (game.turn.name !== name) {
    return `You cannot pass when it isn't your turn.`;
  }

  if (!session.player) {
    return `You must select a color before passing.`;
  }

  /* If the current turn is a robber placement, and everyone has
   * discarded, set the limits for where the robber can be placed */
  if (game.turn && game.turn.robberInAction) {
    return `Robber is in action. Turn can not stop until all Robber tasks are resolved.`;
  }

  if (game.state === 'volcano') {
    return `You cannot not stop turn until you have finished the Volcano tasks.`;
  }

  const next = getNextPlayerSession(game, session.name);
  if (!next || !next.player) {
    return `Unable to find the next player to pass to.`;
  }
  // Only accumulate totalTime if turnStart is a valid timestamp
  if (session.player.turnStart && typeof session.player.turnStart === 'number' && session.player.turnStart > 0) {
    const delta = Date.now() - session.player.turnStart;
    if (!isNaN(delta) && delta > 0) {
      session.player.totalTime += delta;
    }
  } else {
    console.warn(`${session.short}: pass() called but player.turnStart is not set; skipping time accumulation.`);
  }
  session.player.turnNotice = '';
  game.turn = newTurn(next.player);
  next.player.turnStart = Date.now();
  startTurnTimer(game, next);
  game.turns++;
  addActivity(game, session, `${name} passed their turn.`);
  addChatMessage(game, null, `It is ${next.name}'s turn.`);
  sendUpdateToPlayer(game, next, {
    private: next.player,
  });
  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  delete game.dice;

  sendUpdateToPlayers(game, {
    turns: game.turns,
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
    dice: game.dice,
  });
  return undefined;
};

const placeRobber = (game: Game, session: Session, robber: number | string): string | undefined => {
  const name = session.name;
  let robberIdx = typeof robber === 'string' ? parseInt(robber) : robber;

  if (game.state !== 'normal' && game.turn.roll !== 7) {
    return `You cannot place robber unless 7 was rolled!`;
  }
  if (game.turn.name !== name) {
    return `You cannot place the robber when it isn't your turn.`;
  }

  for (const color in game.players) {
    const p = game.players[color];
    if (!p) continue;
    if (p.status === 'Not active') continue;
    if ((p.mustDiscard || 0) > 0) {
      return `You cannot place the robber until everyone has discarded!`;
    }
  }

  if (game.robber === robberIdx) {
    return `You must move the robber to a new location!`;
  }
  game.robber = robberIdx as number;
  game.turn.placedRobber = true;

  pickRobber(game);
  addActivity(
    game,
    null,
    `${game.robberName} Robber Robinson entered the scene as the nefarious robber!`
  );

  const targets: Array<{ color: string; name: string }> = [];
  layout.tiles?.[robberIdx as number]?.corners?.forEach((cornerIndex: number) => {
    const active = game.placements?.corners?.[cornerIndex];
    if (
      active &&
      active.color &&
      active.color !== game.turn.color &&
      targets.findIndex(item => item.color === active.color) === -1
    ) {
      targets.push({
        color: active.color,
        name: game.players?.[active.color]?.name || '',
      });
    }
  });

  if (targets.length) {
    game.turn.actions = ['steal-resource'];
    game.turn.limits = { players: targets } as any;
  } else {
    game.turn.actions = [];
    game.turn.robberInAction = false;
    delete game.turn.limits;
    addChatMessage(
      game,
      null,
      `The dread robber ${game.robberName} was placed on a terrain ` +
        `with no other players, ` +
        `so ${game.turn.name} does not steal resources from anyone.`
    );
  }

  sendUpdateToPlayers(game, {
    placements: game.placements,
    turn: game.turn,
    chat: game.chat,
    robber: game.robber,
    robberName: game.robberName,
    activities: game.activities,
    longestRoad: game.longestRoad,
    longestRoadLength: game.longestRoadLength,
  });
  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  return undefined;
};

const stealResource = (game: Game, session: Session, color: string): string | undefined => {
  if (!game.turn.actions || game.turn.actions.indexOf('steal-resource') === -1) {
    return `You can only steal a resource when it is valid to do so!`;
  }
  const playersLimit = (game.turn.limits as any)?.players || [];
  if (playersLimit.findIndex((item: any) => item.color === color) === -1) {
    return `You can only steal a resource from a player on this terrain!`;
  }

  const victimSession = sessionFromColor(game, color);
  if (!victimSession || !victimSession.player) {
    return `You sent a weird color for the target to steal from.`;
  }
  const victimPlayer = victimSession.player as Player;
  const sessionPlayer = session.player as Player;
  const cards: string[] = [];
  ['wheat', 'brick', 'sheep', 'stone', 'wood'].forEach((field: string) => {
    for (let i = 0; i < ((victimPlayer as any)[field] || 0); i++) {
      cards.push(field);
    }
  });

  debugChat(game, 'Before steal');

  if (cards.length === 0) {
    addChatMessage(
      game,
      session,
      `${victimSession.name} did not have any cards for ${session.name} to steal.`
    );
    game.turn.actions = [];
    game.turn.limits = {} as any;
  } else {
    const idx = Math.floor(Math.random() * cards.length);
    const type = cards[idx];
    if (!type) {
      game.turn.actions = [];
      game.turn.limits = {} as any;
      return undefined;
    }
    const t = String(type);
    // adjust typed resource counts via helper
    adjustResources(victimPlayer as Player, { [t]: -1 });
    adjustResources(sessionPlayer as Player, { [t]: 1 });
    game.turn.actions = [];
    game.turn.limits = {} as any;
    trackTheft(game, (victimSession as any).color || '', session.color, type, 1);

    addChatMessage(
      game,
      session,
      `${session.name} randomly stole 1 ${type} from ${victimSession.name}.`
    );
    sendUpdateToPlayer(game, victimSession, {
      private: victimSession.player,
    });
  }
  debugChat(game, 'After steal');

  game.turn.robberInAction = false;

  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
    players: getFilteredPlayers(game),
  });
  return undefined;
};

const buyDevelopment = (game: Game, session: Session): string | undefined => {
  const player = session.player as Player;

  if (game.state !== 'normal') {
    return `You cannot purchase a development card unless the game is active (${game.state}).`;
  }

  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }

  if (!game.turn.roll) {
    return `You cannot build until you have rolled.`;
  }

  if (game.turn && game.turn.robberInAction) {
    return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
  }

  if (!game.developmentCards || game.developmentCards.length < 1) {
    return `There are no more development cards!`;
  }

  if ((player.stone || 0) < 1 || (player.wheat || 0) < 1 || (player.sheep || 0) < 1) {
    return `You have insufficient resources to purchase a development card.`;
  }

  if (game.turn.developmentPurchased) {
    return `You have already purchased a development card this turn.`;
  }

  debugChat(game, 'Before development purchase');
  addActivity(game, session, `${session.name} purchased a development card.`);
  addChatMessage(
    game,
    session,
    `${session.name} spent 1 stone, 1 wheat, 1 sheep to purchase a development card.`
  );
  player.stone = (player.stone || 0) - 1;
  player.wheat = (player.wheat || 0) - 1;
  player.sheep = (player.sheep || 0) - 1;
  player.resources = 0;
  player.developmentCards = (player.developmentCards || 0) + 1;
  ['wheat', 'brick', 'sheep', 'stone', 'wood'].forEach(resource => {
    player.resources = (player.resources || 0) + ((player as any)[resource] || 0);
  });
  debugChat(game, 'After development purchase');
  const card = (game.developmentCards || []).pop();
  if (card) {
    (card as any).turn = game.turns ? game.turns - 1 : 0;
    if (!player.development) player.development = [] as any;
    (player.development as any).push(card as any);
  }

  if (isRuleEnabled(game, 'most-developed')) {
    if (
      (player.development?.length || 0) >= 5 &&
      (!(game as any)['mostDeveloped'] ||
        (player.developmentCards || 0) >
          (game.players[(game as any)['mostDeveloped'] as string]?.developmentCards || 0))
    ) {
      if ((game as any)['mostDeveloped'] !== session.color) {
        (game as any)['mostDeveloped'] = session.color;
        (game as any)['mostPortCount'] = player.developmentCards;
        addChatMessage(
          game,
          session,
          `${session.name} now has the most development cards (${player.developmentCards})!`
        );
      }
    }
  }

  sendUpdateToPlayer(game, session, {
    private: session.player,
  });

  sendUpdateToPlayers(game, {
    chat: game.chat,
    activities: game.activities,
    mostDeveloped: (game as any)['mostDeveloped'],
    players: getFilteredPlayers(game),
  });
  return undefined;
};

const playCard = (game: Game, session: Session, card: any): string | undefined => {
  const name = session.name;
  const player = session.player as Player;

  if (game.state !== 'normal') {
    return `You cannot purchase a development card unless the game is active (${game.state}).`;
  }
  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }
  if (!game.turn.roll) {
    return `You cannot play a card until you have rolled.`;
  }

  if (game.turn && game.turn.robberInAction) {
    return `Robber is in action. You can not play a card until all Robber tasks are resolved.`;
  }

  card = (player.development || []).find(
    (item: any) => item.type == card.type && item.card == card.card && !(item.card as any).played
  );
  if (!card) {
    return `The card you want to play was not found in your hand!`;
  }

  if ((player as any)['playedCard'] === game.turns && card.type !== 'vp') {
    return `You can only play one development card per turn!`;
  }

  /* Check if this is a victory point */
  if (card.type === 'vp') {
    let points = player.points || 0;
    (player.development || []).forEach((item: any) => {
      if (item.type === 'vp') {
        points++;
      }
    });
    if (points < getVictoryPointRule(game)) {
      return `You can not play victory point cards until you can reach ${getVictoryPointRule(
        game
      )}!`;
    }
    addChatMessage(game, session, `${name} played a Victory Point card.`);
  }

  if (card.type === 'progress') {
    switch (card.card) {
      case 'road-1':
      case 'road-2': {
        const allowed = Math.min(player.roads || 0, 2);
        if (!allowed) {
          addChatMessage(
            game,
            session,
            `${session.name} played a Road Building card, but has no roads to build.`
          );
          break;
        }
        const roads = getValidRoads(game, session.color as string);
        if (roads.length === 0) {
          addChatMessage(
            game,
            session,
            `${session.name} played a Road Building card, but they do not have any valid locations to place them.`
          );
          break;
        }
        game.turn.active = 'road-building' as any;
        game.turn.free = true;
        game.turn.freeRoads = allowed;
        addChatMessage(
          game,
          session,
          `${session.name} played a Road Building card. They now place ${allowed} roads for free.`
        );
        setForRoadPlacement(game, roads);
        break;
      }
      case 'monopoly':
        game.turn.actions = ['select-resources'];
        game.turn.active = 'monopoly' as any;
        addActivity(
          game,
          session,
          `${session.name} played the Monopoly card, and is selecting their resource type to claim.`
        );
        break;
      case 'year-of-plenty':
        game.turn.actions = ['select-resources'];
        game.turn.active = 'year-of-plenty' as any;
        addActivity(game, session, `${session.name} played the Year of Plenty card.`);
        break;
      default:
        addActivity(game, session, `Oh no! ${card.card} isn't impmented yet!`);
        break;
    }
  }
  (card as any).played = true;
  (player as any)['playedCard'] = game.turns;

  if (card.type === 'army') {
    (player as any)['army'] = ((player as any)['army'] || 0) + 1;
    addChatMessage(game, session, `${session.name} played a Knight and must move the robber!`);

    if (
      (player as any)['army'] > 2 &&
      (!(game as any)['largestArmy'] ||
        ((game.players as any)[(game as any)['largestArmy']]?.army || 0) < (player as any)['army'])
    ) {
      if ((game as any)['largestArmy'] !== session.color) {
        (game as any)['largestArmy'] = session.color;
        (game as any)['largestArmySize'] = (player as any)['army'];
        addChatMessage(
          game,
          session,
          `${session.name} now has the largest army (${(player as any)['army']})!`
        );
      }
    }

    game.turn.robberInAction = true;
    game.turn.placedRobber = false;
    addChatMessage(
      game,
      null,
      `The robber ${game.robberName} has fled before the power of the Knight, ` +
        `but a new robber has returned and ${session.name} must now place them.`
    );
    game.turn.actions = ['place-robber', 'playing-knight'];
    game.turn.limits = { pips: [] } as any;
    for (let i = 0; i < staticData.tiles.length; i++) {
      if (i === game.robber) continue;
      (game.turn.limits as any).pips.push(i);
    }
  }

  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    chat: game.chat,
    activities: game.activities,
    largestArmy: (game as any)['largestArmy'],
    largestArmySize: (game as any)['largestArmySize'],
    turn: game.turn,
    players: getFilteredPlayers(game),
  });
  return undefined;
};

const placeSettlement = (
  game: Game,
  session: Session,
  index: number | string
): string | undefined => {
  if (!session.player) return `You are not playing a player.`;
  const player: Player = session.player;
  if (typeof index === 'string') index = parseInt(index);

  if (game.state !== 'initial-placement' && game.state !== 'normal') {
    return `You cannot place a settlement unless the game is active (${game.state}).`;
  }

  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }

  /* index out of range... */
  if (
    !game.placements ||
    game.placements.corners === undefined ||
    game.placements.corners[index] === undefined
  ) {
    return `You have requested to place a settlement illegally!`;
  }

  /* If this is not a valid road in the turn limits, discard it */
  if (
    !game.turn ||
    !game.turn.limits ||
    !game.turn.limits.corners ||
    game.turn.limits.corners.indexOf(index) === -1
  ) {
    return `You tried to cheat! You should not try to break the rules.`;
  }
  const corner = game.placements.corners[index]!;
  if (corner.color && corner.color !== 'unassigned') {
    const owner = game.players && game.players[corner.color];
    const ownerName = owner ? owner.name : 'unknown';
    return `This location already has a settlement belonging to ${ownerName}!`;
  }

  if (!player.banks) {
    player.banks = [];
  }

  if (game.state === 'normal') {
    if (!game.turn.free) {
      if (
        (player.brick || 0) < 1 ||
        (player.wood || 0) < 1 ||
        (player.wheat || 0) < 1 ||
        (player.sheep || 0) < 1
      ) {
        return `You have insufficient resources to build a settlement.`;
      }
    }

    if ((player.settlements || 0) < 1) {
      return `You have already built all of your settlements.`;
    }

    player.settlements = (player.settlements || 0) - 1;

    if (!game.turn.free) {
      addChatMessage(
        game,
        session,
        `${session.name} spent 1 brick, 1 wood, 1 sheep, 1 wheat to purchase a settlement.`
      );
      player.brick = (player.brick || 0) - 1;
      player.wood = (player.wood || 0) - 1;
      player.wheat = (player.wheat || 0) - 1;
      player.sheep = (player.sheep || 0) - 1;
      player.resources -= 4;
    }
    game.turn.free = false;

    corner.color = session.color;
    corner.type = 'settlement';
    let bankType = undefined;

    const banks = layout.corners?.[index]?.banks;
    if (banks && banks.length) {
      banks.forEach((bank: any) => {
        const border = game.borderOrder[Math.floor(bank / 3)]!,
          type: ResourceType = staticData.borders?.[border]?.[bank % 3]!;
        console.log(`${session.short}: Bank ${bank} = ${type}`);
        if (!type) {
          console.log(`${session.short}: Bank ${bank}`);
          return;
        }
        bankType = type === 'bank' ? '3 of anything for 1 resource' : `2 ${type} for 1 resource`;
        if (player.banks.indexOf(type) === -1) {
          player.banks.push(type);
        }

        player.ports++;

        if (isRuleEnabled(game, 'port-of-call')) {
          console.log(`Checking port-of-call`, player.ports, game.mostPorts);
          if (player.ports >= 3 && (!game.mostPorts || player.ports > game.mostPortCount)) {
            if (game.mostPorts !== session.color) {
              game.mostPorts = session.color;
              game.mostPortCount = player.ports;
              addChatMessage(
                game,
                session,
                `${session.name} now has the most ports (${player.ports})!`
              );
            }
          }
        }
      });
    }
    game.turn.actions = [];
    game.turn.limits = {};
    if (bankType) {
      addActivity(
        game,
        session,
        `${session.name} placed a settlement by a maritime bank that trades ${bankType}.`
      );
    } else {
      addActivity(game, session, `${session.name} placed a settlement.`);
    }
    calculateRoadLengths(game, session);
  } else if (game.state === 'initial-placement') {
    if (game.direction && game.direction === 'backward') {
      (session as any).initialSettlement = index;
    }
    corner.color = session.color || '';
    corner.type = 'settlement';
    let bankType = undefined;
    const banks2 = layout.corners?.[index]?.banks;
    if (banks2 && banks2.length) {
      banks2.forEach((bank: any) => {
        const border = game.borderOrder[Math.floor(bank / 3)]!,
          type = staticData.borders?.[border]?.[bank % 3];
        console.log(`${session.short}: Bank ${bank} = ${type}`);
        if (!type) {
          return;
        }
        bankType = type === 'bank' ? '3 of anything for 1 resource' : `2 ${type} for 1 resource`;
        if (player.banks.indexOf(type) === -1) {
          player.banks.push(type);
        }
        player.ports++;
      });
    }
    player.settlements = (player.settlements || 0) - 1;
    if (bankType) {
      addActivity(
        game,
        session,
        `${session.name} placed a settlement by a maritime bank that trades ${bankType}. ` +
          `Next, they need to place a road.`
      );
    } else {
      addActivity(
        game,
        session,
        `${session.name} placed a settlement. ` + `Next, they need to place a road.`
      );
    }
    setForRoadPlacement(game, layout.corners?.[index]?.roads || []);
  }

  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    placements: game.placements,
    turn: game.turn,
    chat: game.chat,
    state: game.state,
    longestRoad: game.longestRoad,
    longestRoadLength: game.longestRoadLength,
    players: getFilteredPlayers(game),
  });
  return undefined;
};

const placeRoad = (game: Game, session: Session, index: number): string | undefined => {
  if (!session.player) {
    return `You are not playing a player.`;
  }

  const player: Player = session.player;

  if (!game || !game.turn) {
    return `Invalid game state.`;
  }

  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }

  if (game.placements.roads[index] === undefined) {
    return `You have requested to place a road illegally!`;
  }

  if (
    !game.turn.limits ||
    !game.turn.limits.roads ||
    game.turn.limits.roads.indexOf(index) === -1
  ) {
    return `You tried to cheat! You should not try to break the rules.`;
  }

  const road = game.placements.roads[index];
  if (road.color) {
    return `This location already has a road belonging to ${game.players[road.color]?.name}!`;
  }

  if (game.state === 'normal') {
    if (!game.turn.free) {
      if (player.brick < 1 || player.wood < 1) {
        return `You have insufficient resources to build a road.`;
      }
    }

    if (player.roads < 1) {
      return `You have already built all of your roads.`;
    }

    if (!game.turn.free) {
      addChatMessage(game, session, `${session.name} spent 1 brick and 1 wood to build a road.`);
      player.brick--;
      player.wood--;
      player.resources -= 2;
    }
    game.turn.free = false;
  }

  road.color = session.color;
  road.type = 'road';
  player.roads--;
  /* Handle normal play road placement including Road Building free roads.
   * If the turn is a road-building action, decrement freeRoads and
   * only clear actions when no free roads remain. Otherwise, during
   * initial placement we advance the initial-placement sequence. */
  if (game.state === 'normal') {
    addActivity(game, session, `${session.name} placed a road.`);
    calculateRoadLengths(game, session);

    let resetLimits = true;
    if (game.turn && game.turn.active === 'road-building') {
      if (game.turn.freeRoads !== undefined) {
        game.turn.freeRoads -= 1;
      }
      if (game.turn.freeRoads === 0) {
        game.turn.free = false;
        game.turn.active = null;
        game.turn.freeRoads = 0;
      }

      const roads = getValidRoads(game, session.color as string);
      if (!roads || roads.length === 0) {
        game.turn.active = null;
        game.turn.freeRoads = 0;
        addActivity(
          game,
          session,
          `${session.name} has another road to play, but there are no more valid locations.`
        );
      } else if (game.turn.freeRoads > 0) {
        game.turn.free = true;
        setForRoadPlacement(game, roads);
        resetLimits = false;
      }
    }

    if (resetLimits) {
      game.turn.free = false;
      game.turn.actions = [];
      game.turn.limits = {};
    }
  } else if (game.state === 'initial-placement') {
    const order: PlayerColor[] = game.playerOrder;
    const idx = order.indexOf(session.color);
    // defensive: if player not found, just clear actions and continue
    if (idx === -1 || order.length === 0) {
      game.turn.actions = [];
      game.turn.limits = {};
    } else {
      const direction = game.direction || 'forward';
      if (direction === 'forward') {
        if (idx === order.length - 1) {
          // Last player in forward pass: switch to backward and allow that
          // same last player to place a settlement to begin reverse pass.
          game.direction = 'backward';
          const nextColor = order[order.length - 1];
          if (nextColor && game.players && game.players[nextColor]) {
            const limits = getValidCorners(game);
            console.log(
              `${info}: initial-placement - ${
                session.name
              } placed road; direction=forward; next=${nextColor}; nextName=${
                game.players[nextColor].name
              }; corners=${limits ? limits.length : 0}`
            );
            game.turn = {
              name: game.players[nextColor].name,
              color: nextColor,
            } as unknown as Turn;
            // During initial placement, settlements may be placed on any valid corner
            setForSettlementPlacement(game, limits);
          }
          addChatMessage(
            game,
            null,
            `Initial placement now proceeds in reverse order. It is ${game.turn.name}'s turn to place a settlement.`
          );
        } else {
          const nextColor = order[idx + 1];
          if (nextColor && game.players && game.players[nextColor]) {
            game.turn = {
              name: game.players[nextColor].name,
              color: nextColor,
            } as unknown as Turn;
            // During initial placement forward pass, allow any valid corner
            // (not restricted by the player's existing roads).
            setForSettlementPlacement(game, getValidCorners(game));
            addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
          }
        }
      } else {
        // backward
        if (idx === 0) {
          // Finished reverse initial placement; move to normal play and first player's turn
          game.state = 'normal';
          const firstColor = order[0];
          if (firstColor && game.players && game.players[firstColor]) {
            game.turn = {
              name: game.players[firstColor].name,
              color: firstColor,
            } as unknown as Turn;
          }

          addChatMessage(game, null, `Everyone has placed their two settlements!`);
          // Ensure the first player's turn timer and turnStart are initialized
          if (game.turn && game.turn.color) {
            const firstSession = sessionFromColor(game, game.turn.color);
            if (firstSession && firstSession.player) {
              // initialize turnStart if not already set
              if (!firstSession.player.turnStart || firstSession.player.turnStart === 0) {
                firstSession.player.turnStart = Date.now();
              }
              startTurnTimer(game, firstSession);
            }
          }
          /* Figure out which players received which resources for their
           * initial (second) settlement placement. This mirrors the original
           * behaviour where the player receives resources adjacent to their
           * final initial settlement. */
          for (const sid in game.sessions) {
            const s = game.sessions[sid];
            const p = s && s.player;
            const receives: Record<string, number> = {} as Record<string, number>;
            if (!p) continue;
            if (
              (s as any).initialSettlement !== undefined &&
              (s as any).initialSettlement !== null
            ) {
              layout.tiles.forEach((tile, tindex) => {
                if (tile.corners.indexOf((s as any).initialSettlement) !== -1) {
                  const tileIdx = game.tileOrder ? game.tileOrder[tindex] : undefined;
                  if (tileIdx === undefined || !staticData.tiles) return;
                  const tileDef = staticData.tiles[tileIdx];
                  if (!tileDef || !tileDef.type) return;
                  const resource = tileDef.type as ResourceType | undefined;
                  if (!resource) return;
                  // only count known resource types
                  if (RESOURCE_TYPES.indexOf(resource as ResourceType) === -1) return;
                  if (!(resource in receives)) receives[resource] = 0;
                  receives[resource] = (receives[resource] || 0) + 1;
                }
              });
              const messageParts: string[] = [];
              for (const type in receives) {
                const cnt = receives[type] || 0;
                // update player resources
                // guard against unknown resource keys
                switch (type) {
                  case 'wood':
                  case 'brick':
                  case 'sheep':
                  case 'wheat':
                  case 'stone':
                    (p as any)[type] = ((p as any)[type] || 0) + cnt;
                    p.resources = (p.resources || 0) + cnt;
                    break;
                  default:
                    // ignore unknown resource types
                    continue;
                }
                sendUpdateToPlayer(game, s, {
                  private: p,
                });
                messageParts.push(`${cnt} ${type}`);
              }
              if (messageParts.length) {
                addChatMessage(
                  game,
                  s,
                  `${s.name} receives ${messageParts.join(', ')} for initial settlement placement.`
                );
              }
            }
          }

          addChatMessage(game, null, `It is ${game.turn.name}'s turn.`);
        } else {
          const nextColor = order[idx - 1];
          if (nextColor && game.players && game.players[nextColor]) {
            const limits = getValidCorners(game);
            console.log(
              `${info}: initial-placement - ${
                session.name
              } placed road; direction=backward; next=${nextColor}; nextName=${
                game.players[nextColor].name
              }; corners=${limits ? limits.length : 0}`
            );
            game.turn = {
              name: game.players[nextColor].name,
              color: nextColor,
            } as unknown as Turn;
            // During initial placement, settlements may be placed on any valid corner
            setForSettlementPlacement(game, limits);
            addChatMessage(game, null, `It is ${game.turn.name}'s turn to place a settlement.`);
          }
        }
      }
    }
  } else {
    game.turn.actions = [];
    game.turn.limits = {};
  }

  calculateRoadLengths(game, session);

  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    placements: game.placements,
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
    players: getFilteredPlayers(game),
    state: game.state,
    direction: (game as any)['direction'],
    longestRoad: game.longestRoad,
    longestRoadLength: game.longestRoadLength,
  });
  return undefined;
};
const discard = (game: any, session: any, discards: Record<string, any>): string | undefined => {
  const player = session.player;

  if (game.turn.roll !== 7) {
    return `You can only discard due to the Robber!`;
  }
  let sum = 0;
  for (let type in discards) {
    const val = discards[type];
    const parsed = typeof val === 'string' ? parseInt(val) : Number(val);
    if (player[type] < parsed) {
      return `You have requested to discard more ${type} than you have.`;
    }
    sum += parsed;
  }
  if (sum > player.mustDiscard) {
    return `You can not discard that many cards! You can only discard ${player.mustDiscard}.`;
  }

  if (sum === 0) {
    return `You must discard at least one card.`;
  }

  for (let type in discards) {
    const count = parseInt(discards[type]);
    player[type] -= count;
    player.mustDiscard -= count;
    player.resources -= count;
  }
  addChatMessage(game, null, `${session.name} discarded ${sum} resource cards.`);
  if (player.mustDiscard > 0) {
    addChatMessage(
      game,
      null,
      `${session.name} did not discard enough and must discard ${player.mustDiscard} more cards.`
    );
  }

  let move = true;
  for (let color in game.players) {
    const discard = game.players[color].mustDiscard > 0;
    if (discard) {
      move = false;
    }
  }

  if (move) {
    addChatMessage(
      game,
      null,
      `Drat! A new robber has arrived and must be placed by ${game.turn.name}!`
    );
    game.turn.actions = ['place-robber'];
    game.turn.limits = { pips: [] };
    for (let i = 0; i < staticData.tiles.length; i++) {
      if (i === game.robber) {
        continue;
      }
      game.turn.limits.pips.push(i);
    }
  }
  sendUpdateToPlayer(game, session, {
    private: player,
  });
  sendUpdateToPlayers(game, {
    players: getFilteredPlayers(game),
    chat: game.chat,
    turn: game.turn,
  });
  return undefined;
};

const buyRoad = (game: any, session: any): string | undefined => {
  const player = session.player;

  if (game.state !== 'normal') {
    return `You cannot purchase a development card unless the game is active (${game.state}).`;
  }
  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }
  if (!game.turn.roll) {
    return `You cannot build until you have rolled.`;
  }

  if (game.turn && game.turn.robberInAction) {
    return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
  }

  if (player.brick < 1 || player.wood < 1) {
    return `You have insufficient resources to build a road.`;
  }
  if (player.roads < 1) {
    return `You have already built all of your roads.`;
  }
  const roads = getValidRoads(game, session.color);
  if (roads.length === 0) {
    return `There are no valid locations for you to place a road.`;
  }
  setForRoadPlacement(game, roads);
  addActivity(game, session, `${game.turn.name} is considering building a road.`);
  sendUpdateToPlayers(game, {
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
  });
  return undefined;
};

const selectResources = (game: any, session: any, cards: string[]): string | undefined => {
  const player = session.player;
  void player;
  if (
    !game ||
    !game.turn ||
    !game.turn.actions ||
    game.turn.actions.indexOf('select-resources') === -1
  ) {
    return `Please, let's not cheat. Ok?`;
  }

  if (
    session.color !== game.turn.color &&
    (!game.turn.select || !(session.color in game.turn.select))
  ) {
    console.log(session.color, game.turn.color, game.turn.select);
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }

  let count = 2;
  if (game.turn && game.turn.active === 'monopoly') {
    count = 1;
  }
  if (game.state === 'volcano') {
    console.log({ cards, turn: game.turn });
    if (!game.turn.select) {
      count = 0;
    } else if (session.color in game.turn.select) {
      count = game.turn.select[session.color];
      delete game.turn.select[session.color];
      if (Object.getOwnPropertyNames(game.turn.select).length === 0) {
        addChatMessage(
          game,
          null,
          `${game.turn.name} must roll the die to determine which direction the lava will flow!`
        );
        delete game.turn.select;
      }
    } else {
      count = 0;
    }
  }

  if (!cards || cards.length > count || cards.length === 0) {
    return `You have chosen the wrong number of cards!`;
  }

  const isValidCard = (type: string): boolean => {
    switch (type.trim()) {
      case 'wheat':
      case 'brick':
      case 'sheep':
      case 'stone':
      case 'wood':
        return true;
      default:
        return false;
    }
  };

  const selected: Record<string, number> = {};
  for (const card of cards) {
    if (!isValidCard(card)) {
      return `Invalid resource type!`;
    }
    selected[card] = (selected[card] || 0) + 1;
  }
  const display: string[] = [];
  for (let card in selected) {
    display.push(`${selected[card]} ${card}`);
  }

  switch (game.turn.active) {
    case 'monopoly':
      const gave: string[] = [],
        type = String(cards[0]);
      let total = 0;
      for (let color in game.players) {
        const player = game.players[color];
        if (player.status === 'Not active') {
          continue;
        }
        if (color === session.color) {
          continue;
        }
        if ((player as any)[type]) {
          gave.push(`${player.name} gave ${(player as any)[type]} ${type}`);
          (session.player as any)[type] += (player as any)[type];
          session.resources += (player as any)[type];
          total += (player as any)[type];
          (player as any)[type] = 0;
          for (let key in game.sessions) {
            if (game.sessions[key].player === player) {
              sendUpdateToPlayer(game, game.sessions[key], {
                private: game.sessions[key].player,
              });
              break;
            }
          }
        }
      }

      if (gave.length) {
        addChatMessage(
          game,
          session,
          `${session.name} played Monopoly and selected ${display.join(', ')}. ` +
            `Players ${gave.join(', ')}. In total, they received ${total} ${type}.`
        );
      } else {
        addActivity(
          game,
          session,
          `${session.name} has chosen ${display.join(
            ', '
          )}! Unfortunately, no players had that resource. Wa-waaaa.`
        );
      }
      delete game.turn.active;
      game.turn.actions = [];
      break;

    case 'year-of-plenty':
      cards.forEach(type => {
        session.player[type]++;
        session.player.resources++;
      });
      addChatMessage(
        game,
        session,
        `${session.name} player Year of Plenty.` +
          `They chose to receive ${display.join(', ')} from the bank.`
      );
      delete game.turn.active;
      game.turn.actions = [];
      break;
    case 'volcano':
      cards.forEach(type => {
        session.player[type]++;
        session.player.resources++;
      });
      addChatMessage(
        game,
        session,
        `${session.name} player mined ${display.join(', ')} from the Volcano!`
      );
      if (!game.turn.select) {
        delete game.turn.active;
        game.turn.actions = [];
      }
      break;
  }

  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
    players: getFilteredPlayers(game),
  });
  return undefined;
};

const buySettlement = (game: any, session: any): string | undefined => {
  const player = session.player;
  if (game.state !== 'normal') {
    return `You cannot purchase a development card unless the game is active (${game.state}).`;
  }
  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }
  if (!game.turn.roll) {
    return `You cannot build until you have rolled.`;
  }

  if (game.turn && game.turn.robberInAction) {
    return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
  }

  if (player.brick < 1 || player.wood < 1 || player.wheat < 1 || player.sheep < 1) {
    return `You have insufficient resources to build a settlement.`;
  }
  if (player.settlements < 1) {
    return `You have already built all of your settlements.`;
  }
  const corners = getValidCorners(game, session.color);
  if (corners.length === 0) {
    return `There are no valid locations for you to place a settlement.`;
  }
  setForSettlementPlacement(game, corners);
  addActivity(game, session, `${game.turn.name} is considering placing a settlement.`);
  sendUpdateToPlayers(game, {
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
  });
  return undefined;
};

const buyCity = (game: any, session: any): string | undefined => {
  const player = session.player;
  if (game.state !== 'normal') {
    return `You cannot purchase a development card unless the game is active (${game.state}).`;
  }
  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }
  if (!game.turn.roll) {
    return `You cannot build until you have rolled.`;
  }
  if (player.wheat < 2 || player.stone < 3) {
    return `You have insufficient resources to build a city.`;
  }

  if (game.turn && game.turn.robberInAction) {
    return `Robber is in action. You can not purchase until all Robber tasks are resolved.`;
  }

  if (player.city < 1) {
    return `You have already built all of your cities.`;
  }
  const corners = getValidCorners(game, session.color, 'settlement');
  if (corners.length === 0) {
    return `There are no valid locations for you to place a city.`;
  }
  setForCityPlacement(game, corners);
  addActivity(game, session, `${game.turn.name} is considering upgrading a settlement to a city.`);
  sendUpdateToPlayers(game, {
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
  });
  return undefined;
};

const placeCity = (game: any, session: any, index: any): string | undefined => {
  const player = session.player;
  if (typeof index === 'string') index = parseInt(index);
  if (game.state !== 'normal') {
    return `You cannot purchase a development card unless the game is active (${game.state}).`;
  }
  if (session.color !== game.turn.color) {
    return `It is not your turn! It is ${game.turn.name}'s turn.`;
  }
  /* Valid index check */
  if (game.placements.corners[index] === undefined) {
    return `You have requested to place a city illegally!`;
  }
  /* If this is not a placement the turn limits, discard it */
  if (
    !game.turn ||
    !game.turn.limits ||
    !game.turn.limits.corners ||
    game.turn.limits.corners.indexOf(index) === -1
  ) {
    return `You tried to cheat! You should not try to break the rules.`;
  }
  const corner = game.placements.corners[index];
  if (corner.color !== 'unassigned' && corner.color !== session.color) {
    return `This location already has a settlement belonging to ${
      game.players[corner.color].name
    }!`;
  }
  if (corner.type !== 'settlement') {
    return `This location already has a city!`;
  }
  if (game.turn.free) {
    delete game.turn.free;
  }

  debugChat(game, 'Before city placement');

  corner.color = session.color;
  corner.type = 'city';
  player.cities--;
  player.settlements++;
  if (!game.turn.free) {
    addChatMessage(game, session, `${session.name} spent 2 wheat, 3 stone to upgrade to a city.`);
    player.wheat -= 2;
    player.stone -= 3;
    player.resources = 0;
    ['wheat', 'brick', 'sheep', 'stone', 'wood'].forEach(resource => {
      player.resources += player[resource];
    });
  }
  delete game.turn.free;

  debugChat(game, 'After city placement');
  game.turn.actions = [];
  game.turn.limits = {};
  addActivity(game, session, `${session.name} upgraded a settlement to a city!`);
  sendUpdateToPlayer(game, session, {
    private: session.player,
  });
  sendUpdateToPlayers(game, {
    placements: game.placements,
    turn: game.turn,
    chat: game.chat,
    activities: game.activities,
    players: getFilteredPlayers(game),
    longestRoad: game.longestRoad,
    longestRoadLength: game.longestRoadLength,
  });
  return undefined;
};

const ping = (session: Session) => {
  if (!session.ws) {
    console.log(`[no socket] Not sending ping to ${session.name} -- connection does not exist.`);
    return;
  }

  session.ping = Date.now();
  // console.log(`${session.short}: Sending ping to ${session.name}`);

  try {
    session.ws.send(JSON.stringify({ type: 'ping', ping: session.ping }));
  } catch (e) {
    console.error(`${session.id}: Failed to send ping:`, e);
    // If send fails, the socket is likely dead - clean up
    if (session.keepAlive) {
      clearTimeout(session.keepAlive);
      session.keepAlive = undefined;
    }
    return;
  }

  // Clear any existing timeout
  if (session.keepAlive) {
    clearTimeout(session.keepAlive);
  }

  // Set timeout to disconnect if no pong received within 20 seconds
  session.keepAlive = setTimeout(() => {
    console.warn(
      `${session.id}: No pong received from ${session.name} within 20s, closing connection`
    );
    if (session.ws) {
      try {
        session.ws.close();
      } catch (e) {
        console.error(`${session.id}: Error closing socket:`, e);
      }
    }
    session.ws = undefined;
    session.keepAlive = undefined;
  }, 20000);
};

// Add new function to schedule recurring pings
const schedulePing = (session: Session) => {
  // Diagnostic logging to help detect multiple intervals being created
  try {
    console.log(
      `${session.short}: schedulePing called for ${getName(
        session
      )} - existing pingInterval? ${!!session.pingInterval}`
    );
  } catch (e) {
    /* ignore logging errors */
  }

  if (session.pingInterval) {
    // Clear any previous interval before creating a new one
    try {
      clearInterval(session.pingInterval);
    } catch (e) {
      console.warn(`${session.short}: Failed to clear previous pingInterval:`, e);
    }
  }

  // Send ping every 10 seconds
  session.pingInterval = setInterval(() => {
    ping(session);
  }, 10000);
  try {
    console.log(`${session.short}: pingInterval started for ${getName(session)}`);
  } catch (e) {
    /* ignore logging errors */
  }
};

// wsInactive not present in this refactor; no-op placeholder removed

const setGame = (game: any, session: any, state: any): string | undefined => {
  if (!state) {
    return `Invalid state.`;
  }

  if (session.color === 'unassigned') {
    return `You must have an active player to start the game.`;
  }

  if (state === game.state) {
    return;
  }

  switch (state) {
    case 'game-order':
      if (game.state !== 'lobby') {
        return `You can only start the game from the lobby.`;
      }
      const active = getActiveCount(game);
      if (active < 2) {
        return `You need at least two players to start the game.`;
      }
      /* Delete any non-played colors from the player map; reduces all
       * code that would otherwise have to filter out players by checking
       * the 'Not active' state of player.status */
      for (let key in game.players) {
        if (game.players[key].status !== 'Active') {
          delete game.players[key];
        }
      }
      addChatMessage(game, null, `${session.name} requested to start the game.`);
      game.state = state;

      sendUpdateToPlayers(game, {
        state: game.state,
        chat: game.chat,
      });
      break;
  }
  return undefined;
};

const resetDisconnectCheck = (_game: any, req: any): void => {
  void _game;
  if (req.disconnectCheck) {
    clearTimeout(req.disconnectCheck);
  }
  //req.disconnectCheck = setTimeout(() => { wsInactive(game, req) }, 20000);
};

// WebRTC join/part handling moved to server/routes/webrtc-signaling.ts
// use webrtcJoin(audio[gameId], session, config) and webrtcPart(audio[gameId], session)

const departLobby = (game: any, session: any, _color?: string): void => {
  const update: any = {};
  update.unselected = getFilteredUnselected(game);

  if (session.player) {
    session.player.live = false;
    update.players = game.players;
  }

  if (session.name) {
    if (session.color !== 'unassigned') {
      addChatMessage(game, null, `${session.name} has disconnected ` + `from the  game.`);
    } else {
      addChatMessage(game, null, `${session.name} has left the lobby.`);
    }
    update.chat = game.chat;
  } else {
    console.log(
      `${session.short}: departLobby - ${getName(session)} is ` +
        `being removed from ${game.id}'s sessions.`
    );
    for (let id in game.sessions) {
      if (game.sessions[id] === session) {
        delete game.sessions[id];
        transientState.clearSession(game.id, id);
        break;
      }
    }
  }

  sendUpdateToPlayers(game, update);
};

const sendGameToPlayer = (game: any, session: any): void => {
  console.log(`${session.short}: -> sendGamePlayer:${getName(session)} - full game`);
  if (!session.ws) {
    console.log(`${session.short}: -> sendGamePlayer:: Currently no connection`);
    return;
  }

  let update: any;

  /* Only send empty name data to unnamed players */
  if (!session.name) {
    console.log(
      `${session.short}: -> sendGamePlayer:${getName(session)} - only sending empty name`
    );
    update = { name: '' };
  } else {
    update = getFilteredGameForPlayer(game, session);
  }

  const message = JSON.stringify({
    type: 'game-update',
    update: update,
  });
  queueSend(session, message);
};

const sendGameToPlayers = (game: any): void => {
  console.log(`${all}: -> sendGamePlayers - full game`);

  for (let key in game.sessions) {
    sendGameToPlayer(game, game.sessions[key]);
  }
};

const getFilteredUnselected = (game: any): string[] => {
  if (!game.unselected) {
    return [];
  }
  return game.unselected.filter((session: any) => session.live).map((session: any) => session.name);
};

const parseChatCommands = (game: any, message: string): void => {
  /* Chat messages can set game flags and fields */
  const partsRaw = message.match(/^set +([^ ]*) +(.*)$/i) as RegExpMatchArray | null;
  if (!partsRaw || partsRaw.length !== 3) {
    return;
  }
  const parts = partsRaw as RegExpMatchArray;
  const key = parts[1] || '';
  switch (key.toLowerCase()) {
    case 'game':
      if (parts[2] && parts[2].trim().match(/^beginner('?s)?( +layout)?/i)) {
        setBeginnerGame(game);
        addChatMessage(game, null, `Game board set to the Beginner's Layout.`);
        break;
      }
      const signature = parts[2]
        ? parts[2].match(/^([0-9a-f]{12})-([0-9a-f]{38})-([0-9a-f]{38})/i)
        : null;
      if (signature) {
        if (
          setGameFromSignature(game, signature[1] || '', signature[2] || '', signature[3] || '')
        ) {
          game.signature = parts[2];
          addChatMessage(game, null, `Game board set to ${parts[2]}.`);
        } else {
          addChatMessage(game, null, `Requested an invalid game board.`);
        }
      }
      break;
  }
};

const sendError = (session: Session, error: string): void => {
  console.error(`${session.short}: Error: ${error}`);
  try {
    session.ws.send(JSON.stringify({ type: 'error', data: error }));
  } catch (e) {
    /* ignore */
  }
};

const sendWarning = (session: Session, warning: string): void => {
  console.warn(`${session.short}: Warning: ${warning}`);
  try {
    session?.ws?.send(JSON.stringify({ type: 'warning', warning }));
  } catch (e) {
    /* ignore */
  }
};

/**
 * Get participants list for the game room
 * Uses the reusable room helper and adds game-specific data (color)
 *
 * This demonstrates how to extend the base participant list with app-specific data
 */
const getParticipants = (game: any): any[] => {
  // Use the reusable room helper for base participant data
  // If you were using the new architecture, this would be:
  // import { getParticipants as getBaseParticipants } from './room/helpers';
  // const baseParticipants = getBaseParticipants(game.sessions);

  const participants: any[] = [];
  for (let id in game.sessions) {
    const session = game.sessions[id];
    if (!session) continue;

    // Base participant data (reusable across any application)
    const baseParticipant = {
      name: session.name || null,
      session_id: session.id,
      live: session.live || false,
      protected: session.protected || false,
      has_media: session.has_media !== false,
      bot_run_id: session.bot_run_id || null,
      bot_provider_id: session.bot_provider_id || null,
      bot_instance_id: session.bot_instance_id || null,
      muted: session.muted || false,
      video_on: session.video_on !== false,
    };

    // Game-specific data (in metadata layer)
    // This is the ONLY game-specific code in this function
    const gameSpecific = {
      color: session.color || null, // Game-specific: player color
      // In the new architecture, this would be: session.metadata?.color
    };

    participants.push({ ...baseParticipant, ...gameSpecific });
  }
  return participants;
};

const clearGame = (game: any, _session: any): string | undefined => {
  void _session;
  resetGame(game);
  addChatMessage(
    game,
    null,
    `The game has been reset. You can play again with this board, or ` +
      `click 'New Table' to mix things up a bit.`
  );
  sendGameToPlayers(game);
  return undefined;
};

const gotoLobby = (game: any, session: any): string | undefined => {
  if (!game.waiting) {
    game.waiting = [];
  }
  const already = game.waiting.indexOf(session.name) !== -1;

  const waitingFor = [];
  for (let key in game.sessions) {
    if (game.sessions[key] === session) {
      continue;
    }

    if (game.sessions[key].player && game.waiting.indexOf(game.sessions[key].name) == -1) {
      waitingFor.push(game.sessions[key].name);
    }
  }

  if (!already) {
    game.waiting.push(session.name);
    addChatMessage(game, null, `${session.name} has gone to the lobby.`);
  } else if (waitingFor.length !== 0) {
    return (
      `You are already waiting in the lobby. ` +
      `${waitingFor.join(',')} still needs to go to the lobby.`
    );
  }

  if (waitingFor.length === 0) {
    resetGame(game);
    addChatMessage(game, null, `All players are back to the lobby.`);
    addChatMessage(
      game,
      null,
      `The game has been reset. You can play again with this board, or ` +
        `click 'New Table' to mix things up a bit.`
    );
    sendGameToPlayers(game);
    return;
  }

  addChatMessage(game, null, `Waiting for ${waitingFor.join(',')} to go to lobby.`);
  sendUpdateToPlayers(game, {
    chat: game.chat,
  });
  return undefined;
};

const normalizeIncoming = (msg: unknown): IncomingMessage | null => {
  let parsed: IncomingMessage | null = null;
  try {
    if (typeof msg === 'string') {
      parsed = JSON.parse(msg);
    } else {
      parsed = msg as IncomingMessage;
    }
  } catch (e) {
    return null;
  }
  if (!parsed) return null;
  const type = parsed.type || (parsed as any).action || null;
  const data = parsed.data || (Object.keys(parsed).length ? Object.assign({}, parsed) : null);
  return { type, data };
};

router.ws('/ws/:id', async (ws, req) => {
  console.log('New WebSocket connection');
  if (!req.cookies || !(req.cookies as any)['player']) {
    // If the client hasn't established a session cookie, they cannot
    // participate in a websocket-backed game session. Log the request
    // headers to aid debugging (e.g. missing Cookie header due to
    // cross-site requests or proxy configuration) and close the socket
    // with a sensible code so the client sees a deterministic close.
    try {
      const remote =
        req.ip ||
        (req.headers &&
          (req.headers['x-forwarded-for'] || (req.connection && req.connection.remoteAddress))) ||
        'unknown';
      console.warn(
        `[ws] Rejecting connection from ${remote} - missing session cookie. headers=${JSON.stringify(
          req.headers || {}
        )}`
      );
    } catch (e) {
      console.warn(
        '[ws] Rejecting connection - missing session cookie (unable to serialize headers)'
      );
    }
    try {
      // Inform the client why we are closing, then close the socket.
      ws.send(JSON.stringify({ type: 'error', error: `Unable to find session cookie` }));
    } catch (e) {
      /* ignore send errors */
    }
    try {
      // 1008 = Policy Violation - appropriate for missing auth cookie
      ws.close && ws.close(1008, 'Missing session cookie');
    } catch (e) {
      /* ignore close errors */
    }
    return;
  }

  const { id } = req.params;
  const gameId = id;

  if (!gameId) {
    console.log('Missing game id');
    try {
      ws.send(JSON.stringify({ type: 'error', error: 'Missing game id' }));
    } catch (e) {}
    try {
      console.log('Missing game id');
      ws.close && ws.close(1008, 'Missing game id');
    } catch (e) {}
    return;
  }

  const playerCookie =
    req.cookies && (req.cookies as any)['player'] ? String((req.cookies as any)['player']) : '';
  const short = playerCookie ? `[${playerCookie.substring(0, 8)}]` : '[unknown]';
  (ws as any).id = short;

  console.log(`${short}: Game ${gameId} - New connection from client.`);
  try {
    console.log(
      `${short}: WS handshake headers: origin=${req.headers.origin} cookie=${req.headers.cookie}`
    );
  } catch (e) {
    /* ignore logging errors */
  }
  if (!(gameId in audio)) {
    audio[gameId] = {}; /* List of peer sockets using session.id as index. */
    console.log(`${short}: Game ${gameId} - New Game Audio`);
  } else {
    console.log(`${short}: Game ${gameId} - Already has Audio`);
  }

  /* Setup WebSocket event handlers prior to performing any async calls or
   * we may miss the first messages from clients */
  ws.on('error', async event => {
    console.error(`WebSocket error: `, event && event.message ? event.message : event);
    const game = await loadGame(gameId);
    if (!game) {
      return;
    }
    const _session = getSession(
      game,
      req.cookies && (req.cookies as any)['player'] ? String((req.cookies as any)['player']) : ''
    );
    if (!_session) return;
    const session = _session;
    session.live = false;
    try {
      console.log(`${short}: ws.on('error') - session.ws === ws? ${session.ws === ws}`);
      console.log(`${short}: ws.on('error') - session.id=${session && session.id}`);
      console.log(`${short}: ws.on('error') - stack:`, new Error().stack);
      // Only close the session.ws if it is the same socket that errored.
      if (session.ws && session.ws === ws) {
        // Clear ping interval
        if (session.pingInterval) {
          clearInterval(session.pingInterval);
          session.pingInterval = undefined;
        }

        // Clear keepAlive timeout
        if (session.keepAlive) {
          clearTimeout(session.keepAlive);
          session.keepAlive = undefined;
        }

        try {
          session.ws.close();
        } catch (e) {
          console.warn(`${short}: error while closing session.ws:`, e);
        }
        session.ws = undefined;
      }
    } catch (e) {
      console.warn(`${short}: exception in ws.on('error') handler:`, e);
    }

    departLobby(game, session);
  });

  ws.on('close', async event => {
    console.log(
      `${short} - closed connection (event: ${
        event && typeof event === 'object' ? JSON.stringify(event) : event
      })`
    );

    const game = await loadGame(gameId);
    if (!game) {
      return;
    }
    const _session = getSession(
      game,
      req.cookies && (req.cookies as any)['player'] ? String((req.cookies as any)['player']) : ''
    );
    if (!_session) return;
    const session = _session;
    if (session.player) {
      session.player.live = false;
    }
    session.live = false;
    session.initialSnapshotSent = false;

    // Only cleanup the session.ws if it references the same socket object
    try {
      console.log(`${short}: ws.on('close') - session.ws === ws? ${session.ws === ws}`);
      console.log(
        `${short}: ws.on('close') - session.id=${session && session.id}, lastActive=${
          session && session.lastActive
        }`
      );
      if (session.ws && session.ws === ws) {
        // Clear ping interval
        if (session.pingInterval) {
          clearInterval(session.pingInterval);
          session.pingInterval = undefined;
        }

        // Clear keepAlive timeout
        if (session.keepAlive) {
          clearTimeout(session.keepAlive);
          session.keepAlive = undefined;
        }

        /* Cleanup any voice channels */
        if (gameId in audio) {
          try {
            webrtcPart(audio[gameId]!, session);
          } catch (e) {
            console.warn(`${short}: Error during part():`, e);
          }
        }
        try {
          session.ws.close();
        } catch (e) {
          console.warn(`${short}: error while closing session.ws in on('close'):`, e);
        }
        session.ws = undefined;
        console.log(`${short}:WebSocket closed for ${getName(session)}`);
      }
    } catch (e) {
      console.warn(`${short}: exception in ws.on('close') handler:`, e);
    }

    departLobby(game, session);

    /* Check for a game in the Winner state with no more connections
     * and remove it */
    if (game.state === 'winner') {
      let dead = true;
      for (let id in game.sessions) {
        if (game.sessions[id]!.live && game.sessions[id]!.name) {
          dead = false;
        }
      }
      if (dead) {
        console.log(`${session.short}: No more players in ${game.id}. ` + `Removing.`);
        addChatMessage(
          game,
          null,
          `No more active players in game. ` + `It is being removed from the server.`
        );
        sendUpdateToPlayers(game, {
          chat: game.chat,
        });
        for (let id in game.sessions) {
          if (game.sessions[id]!.ws) {
            try {
              console.log(
                `${short}: Removing game - closing session ${id} socket (game removal cleanup)`
              );
              console.log(`${short}: Closing socket stack:`, new Error().stack);
              game.sessions[id]!.ws.close();
            } catch (e) {
              console.warn(`${short}: error closing session socket during game removal:`, e);
            }
            delete game.sessions[id];
            transientState.clearSession(game.id, id);
          }
        }
        delete audio[gameId];
        delete games[gameId];
        transientState.clearGame(gameId);
        try {
          if (!gameDB || !gameDB.deleteGame) {
            console.error(`${session.id}: gameDB.deleteGame is not available; cannot remove ${id}`);
          } else {
            await gameDB.deleteGame(gameId);
          }
        } catch (error) {
          console.error(`${session.id}: Unable to remove game ${id} via gameDB.deleteGame`, error);
        }
      }
    }
  });

  ws.on('message', async message => {
    // Normalize the incoming message to { type, data } so handlers can
    // reliably access the payload without repeated defensive checks.
    const incoming = normalizeIncoming(message);
    if (!incoming || !incoming.type) {
      // If we couldn't parse or determine the type, log and ignore the
      // message to preserve previous behavior.
      try {
        console.error(`${all}: parse/normalize error`, message);
      } catch (e) {
        console.error('parse/normalize error');
      }
      return;
    }
    const data = incoming.data;
    const game = await loadGame(gameId);
    const _session = getSession(
      game,
      req.cookies && (req.cookies as any)['player'] ? String((req.cookies as any)['player']) : ''
    );
    if (!_session) return;
    const session = _session;
    // Keep track of any previously attached websocket so we can detect
    // first-time attaches and websocket replacements (reconnects).
    const previousWs = session.ws;
    // If there was a previous websocket and it's a different object, try to
    // close it to avoid stale sockets lingering in memory.
    if (previousWs && previousWs !== ws) {
      // Clear any existing ping/keepAlive timers associated with the old socket
      try {
        if (session.pingInterval) {
          clearInterval(session.pingInterval);
          session.pingInterval = undefined;
          console.log(
            `${short}: Cleared old pingInterval during reconnection for ${getName(session)}`
          );
        }
        if (session.keepAlive) {
          clearTimeout(session.keepAlive);
          session.keepAlive = undefined;
          console.log(
            `${short}: Cleared old keepAlive during reconnection for ${getName(session)}`
          );
        }
      } catch (e) {
        console.warn(`${short}: Error clearing old timers during reconnection:`, e);
      }

      // Clean up peer from audio registry before replacing WebSocket
      if (gameId in audio) {
        try {
          webrtcPart(audio[gameId]!, session);
          console.log(
            `${short}: Cleaned up peer ${session.name} from audio registry during reconnection`
          );
        } catch (e) {
          console.warn(`${short}: Error cleaning up peer during reconnection:`, e);
        }
      }

      try {
        previousWs.close();
      } catch (e) {
        /* ignore close errors */
      }
    }
    // Attach the current websocket for this session.
    session.ws = ws;
    if (session.player) {
      session.player.live = true;
    }
    session.live = true;
    session.lastActive = Date.now();
    session.short = short;

    let error: string | undefined;
    let warning: string | void | undefined;
    let processed = true;

    // The initial-game snapshot is sent from the connection attach path to
    // ensure it is only sent once per websocket lifecycle. Avoid sending it
    // here from the message handler to prevent duplicate snapshots when a
    // client sends messages during the attach/reconnect sequence.

    switch (incoming.type) {
      case 'join':
        // Accept either legacy `config`, newer `data`, or flat payloads where
        // the client sent fields at the top level (normalizeIncoming will
        // populate `data` with the parsed object in that case).
        webrtcJoin(audio[gameId]!, session);
        break;

      case 'part':
        webrtcPart(audio[gameId]!, session);
        break;

      case 'relayICECandidate':
        {
          // Delegate to the webrtc signaling helper (it performs its own checks)
          // Accept either config/data or a flat payload (data).
          const cfg = data.config || data.data || data || {};
          handleRelayICECandidate(gameId, cfg, session, debug);
        }
        break;

      case 'relaySessionDescription':
        {
          // Accept either config/data or a flat payload (data).
          const cfg = data.config || data.data || data || {};
          handleRelaySessionDescription(gameId, cfg, session, debug);
        }
        break;

      case 'pong':
        // Clear the keepAlive timeout since we got a response
        if (session.keepAlive) {
          clearTimeout(session.keepAlive);
          session.keepAlive = undefined;
        }

        // Calculate latency if ping timestamp was sent
        // if (session.ping) {
        //   session.lastPong = Date.now();
        //   const latency = session.lastPong - session.ping;
        //   console.log(`${short}: Received pong from ${getName(session)}. Latency: ${latency}ms`);
        // } else {
        //   console.log(`${short}: Received pong from ${getName(session)}.`);
        // }

        // No need to resetDisconnectCheck since it's non-functional
        break;

      case 'game-update':
        console.log(`${short}: <- game-update ${getName(session)} - full game update.`);
        sendGameToPlayer(game, session);
        break;

      case 'peer_state_update':
        {
          // Accept either config/data or a flat payload (data).
          const cfg = data.config || data.data || data || {};
          broadcastPeerStateUpdate(gameId, cfg, session);
        }
        break;

      case 'player-name':
        // Support both legacy { type: 'player-name', name: 'Foo' }
        // and normalized { type: 'player-name', data: { name: 'Foo' } }
        const _pname = (data && data.name) || (data && data.data && data.data.name);
        console.log(`${short}: <- player-name:${getName(session)} - setPlayerName - ${_pname}`);
        error = setPlayerName(game, session, _pname);
        if (error) {
          sendError(session, error);
        } else {
          gameDB.saveGame(game);
        }
        break;

      case 'set':
        console.log(`${short}: <- set:${getName(session)} ${data.field} = ${data.value}`);
        switch (data.field) {
          case 'state':
            warning = setGame(game, session, data.value);
            if (warning) {
              sendWarning(session, warning);
            } else {
              gameDB.saveGame(game);
            }
            break;

          case 'color':
            warning = setPlayerColor(game, session, data.value);
            if (warning) {
              sendWarning(session, warning);
            } else {
              gameDB.saveGame(game);
            }
            break;
          default:
            console.warn(`WARNING: Requested SET unsupported field: ${data.field}`);
            break;
        }
        break;

      case 'get':
        // Batch 'get' requests per-session for a short window so multiple
        // near-simultaneous requests are merged into one response. This
        // reduces CPU and network churn during client startup.
        const requestedFields: string[] = Array.isArray(data.fields)
          ? (data.fields as string[])
          : data.data && Array.isArray(data.data.fields)
          ? (data.data.fields as string[])
          : [];

        console.log(
          `${short}: <- get:${getName(session)} ${
            requestedFields.length ? requestedFields.join(',') : '<none>'
          }`
        );

        // Ensure a batch structure exists on the session
        if (!session._getBatch) {
          session._getBatch = { fields: new Set<string>(), timer: undefined };
        }
        // Merge requested fields into the batch set
        requestedFields.forEach(
          (f: string) => session._getBatch && session._getBatch.fields.add(f)
        );

        // If a timer is already scheduled, we will respond when it fires.
        if (session._getBatch.timer) {
          break;
        }

        // Schedule a single reply after the batching window
        session._getBatch.timer = setTimeout(() => {
          try {
            if (!session._getBatch) return;
            const fieldsArray: string[] = Array.from(session._getBatch.fields) as string[];
            const batchedUpdate: any = {};
            fieldsArray.forEach((field: string) => {
              switch (field) {
                case 'player':
                  sendWarning(session, `'player' is not a valid item. use 'private' instead`);
                  batchedUpdate.player = undefined;
                  break;
                case 'id':
                case 'chat':
                case 'startTime':
                case 'state':
                case 'turn':
                case 'turns':
                case 'winner':
                case 'placements':
                case 'longestRoadLength':
                case 'robber':
                case 'robberName':
                case 'tileOrder':
                case 'active':
                case 'largestArmy':
                case 'mostDeveloped':
                case 'mostPorts':
                case 'longestRoad':
                case 'pipOrder':
                case 'signature':
                case 'borderOrder':
                case 'dice':
                case 'activities':
                  batchedUpdate[field] = game[field];
                  break;
                case 'tiles':
                  batchedUpdate.tiles = staticData.tiles;
                  break;
                case 'pips':
                  // pips are static data (number/roll mapping). Return from staticData
                  batchedUpdate.pips = staticData.pips;
                  break;
                case 'borders':
                  // borders are static data describing ports/banks
                  batchedUpdate.borders = staticData.borders;
                  break;
                case 'rules':
                  batchedUpdate[field] = game.rules ? game.rules : {};
                  break;
                case 'name':
                  batchedUpdate.name = session.name;
                  break;
                case 'unselected':
                  batchedUpdate.unselected = getFilteredUnselected(game);
                  break;
                case 'private':
                  batchedUpdate.private = session.player;
                  break;
                case 'players':
                  batchedUpdate.players = getFilteredPlayers(game);
                  break;
                case 'participants':
                  batchedUpdate.participants = getParticipants(game);
                  break;
                case 'color':
                  console.log(
                    `${_session.short}: -> Returning color as ${session.color} for ${getName(
                      session
                    )}`
                  );
                  batchedUpdate.color = session.color;
                  break;
                case 'timestamp':
                  batchedUpdate.timestamp = Date.now();
                  break;
                default:
                  function hasKey<T>(obj: T, key: PropertyKey): key is keyof T {
                    return key in (obj as unknown as Record<string, unknown>);
                  }
                  if (hasKey(game, field)) {
                    console.warn(
                      `${short}: WARNING: Requested GET not-privatized/sanitized field: ${field}`
                    );
                    batchedUpdate[field] = game[field];
                  } else if (hasKey(session, field)) {
                    console.warn(
                      `${short}: WARNING: Requested GET not-sanitized session field: ${field}`
                    );
                    batchedUpdate[field as keyof typeof game] = session[field];
                  } else {
                    console.warn(`${short}: WARNING: Requested GET unsupported field: ${field}`);
                  }
                  break;
              }
            });
            sendUpdateToPlayer(game, session, batchedUpdate);
          } catch (e) {
            console.warn(`${session.id}: get batch handler failed:`, e);
          }
          // clear batch
          if (session._getBatch) {
            session._getBatch.fields.clear();
            clearTimeout(session._getBatch.timer as any);
            session._getBatch.timer = undefined;
          }
        }, INCOMING_GET_BATCH_MS);
        break;

      case 'chat':
        /* If the chat message is empty, do not add it to the chat */
        if (data.message.trim() == '') {
          break;
        }
        console.log(`${short}:${id} - ${data.type} - "${data.message}"`);
        addChatMessage(game, session, `${session.name}: ${data.message}`, true);
        parseChatCommands(game, data.message);
        sendUpdateToPlayers(game, { chat: game.chat });
        gameDB.saveGame(game);
        break;

      case 'media-status':
        console.log(`${short}: <- media-status - `, data.audio, data.video);
        session['video'] = data.video;
        session['audio'] = data.audio;
        break;

      default:
        processed = false;
        break;
    }

    if (processed) {
      /* saveGame(game); -- do not save here; only save on changes */
      return;
    }

    /* The rest of the actions and commands require an active game
     * participant */

    if (session.player?.color === 'unassigned') {
      error = `Player must have an active color.`;
      sendError(session, error);
      return;
    }

    processed = true;

    switch (incoming.type) {
      case 'roll':
        console.log(`${short}: <- roll:${getName(session)}`);
        warning = roll(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'shuffle':
        console.log(`${short}: <- shuffle:${getName(session)}`);
        warning = shuffle(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'place-settlement':
        console.log(`${short}: <- place-settlement:${getName(session)} ${data.index}`);
        warning = placeSettlement(game, session, data.index);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'place-city':
        console.log(`${short}: <- place-city:${getName(session)} ${data.index}`);
        warning = placeCity(game, session, data.index);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'place-road':
        console.log(`${short}: <- place-road:${getName(session)} ${data.index}`);
        warning = placeRoad(game, session, parseInt(data.index));
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'place-robber':
        console.log(`${short}: <- place-robber:${getName(session)} ${data.index}`);
        warning = placeRobber(game, session, data.index);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'steal-resource':
        console.log(`${short}: <- steal-resource:${getName(session)} ${data.color}`);
        warning = stealResource(game, session, data.color);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'discard':
        console.log(`${short}: <- discard:${getName(session)}`);
        warning = discard(game, session, data.discards);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'pass':
        console.log(`${short}: <- pass:${getName(session)}`);
        warning = pass(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'select-resources':
        console.log(`${short}: <- select-resources:${getName(session)} - `, data.cards);
        warning = selectResources(game, session, data.cards);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'buy-city':
        console.log(`${short}: <- buy-city:${getName(session)}`);
        warning = buyCity(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'buy-road':
        console.log(`${short}: <- buy-road:${getName(session)}`);
        warning = buyRoad(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'buy-settlement':
        console.log(`${short}: <- buy-settlement:${getName(session)}`);
        warning = buySettlement(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'buy-development':
        console.log(`${short}: <- buy-development:${getName(session)}`);
        warning = buyDevelopment(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'play-card':
        console.log(`${short}: <- play-card:${getName(session)}`);
        warning = playCard(game, session, data.card);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'trade':
        console.log(
          `${short}: <- trade:${getName(session)} - ` +
            (data.action ? data.action : 'start') +
            ` -`,
          data.offer ? data.offer : 'no trade yet'
        );
        warning = trade(game, session, data.action, data.offer);
        if (warning) {
          sendWarning(session, warning);
        } else {
          for (let key in game.sessions) {
            const tmp = game.sessions[key];
            if (tmp!.player) {
              sendUpdateToPlayer(game, tmp, {
                private: tmp!.player,
              });
            }
          }
          sendUpdateToPlayers(game, {
            turn: game.turn,
            activities: game.activities,
            chat: game.chat,
            players: getFilteredPlayers(game),
          });
        }
        break;
      case 'turn-notice':
        console.log(`${short}: <- turn-notice:${getName(session)}`);
        warning = clearTimeNotice(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'clear-game':
        console.log(`${short}: <- clear-game:${getName(session)}`);
        warning = clearGame(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'goto-lobby':
        console.log(`${short}: <- goto-lobby:${getName(session)}`);
        warning = gotoLobby(game, session);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      case 'rules':
        console.log(`${short} - <- rules:${getName(session)} - `, data.rules);
        warning = setRules(game, session, data.rules);
        if (warning) {
          sendWarning(session, warning);
        }
        break;
      default:
        console.warn(`Unsupported request: ${data.type}`);
        processed = false;
        break;
    }

    /* If action was taken, persist the game */
    if (processed) {
      gameDB.saveGame(game);
    }

    /* If the current player took an action, reset the session timer */
    if (processed && session.color === game.turn.color && game.state !== 'winner') {
      resetTurnTimer(game, session);
    }
  });

  /* This will result in the node tick moving forward; if we haven't already
   * setup the event handlers, a 'message' could come through prior to this
   * completing */
  const game = await loadGame(gameId);
  if (!game) {
    console.error(`Unable to load/create new game for WS request.`);
    return;
  }

  const _session2 = getSession(
    game,
    req.cookies && (req.cookies as any)['player'] ? String((req.cookies as any)['player']) : ''
  );
  if (!_session2) return;
  const session = _session2;
  session.ws = ws;
  if (session.player) {
    session.player.live = true;
  }
  session.live = true;
  session.lastActive = Date.now();
  session.short = short;
  // Ensure we only attempt to send the consolidated initial snapshot once
  // per session lifecycle. Tests and clients expect a single 'initial-game'
  // message when a socket first attaches.
  sendInitialGameSnapshot(game, session);
  if (session.name) {
    sendUpdateToPlayers(game, {
      players: getFilteredPlayers(game),
      participants: getParticipants(game),
      unselected: getFilteredUnselected(game),
    });
  }

  /* If the current turn player just rejoined, set their turn timer */
  if (game.turn && game.turn.color === session.color && game.state !== 'winner') {
    resetTurnTimer(game, session);
  }

  if (session.name) {
    if (session.color !== 'unassigned') {
      addChatMessage(game, null, `${session.name} has reconnected to the game.`);
    } else {
      addChatMessage(game, null, `${session.name} has rejoined the lobby.`);
    }
    sendUpdateToPlayers(game, { chat: game.chat });
  }

  resetDisconnectCheck(game, req);
  console.log(`${short}: Game ${id} - WebSocket connect from ${getName(session)}`);

  /* Start recurring ping mechanism */
  console.log(`${short}: Starting ping interval for ${getName(session)}`);
  schedulePing(session);
});

const debugChat = (game: any, preamble: any) => {
  preamble = `Degug ${preamble.trim()}`;

  let playerInventory = preamble;

  for (let key in game.players) {
    const player = game.players[key];
    if (player.status === 'Not active') {
      continue;
    }
    if (playerInventory !== '') {
      playerInventory += ' player';
    } else {
      playerInventory += ' Player';
    }
    playerInventory += ` ${player.name} has `;
    const has = ['wheat', 'brick', 'sheep', 'stone', 'wood']
      .map(resource => {
        const count = player[resource] ? player[resource] : 0;
        return `${count} ${resource}`;
      })
      .filter(item => item !== '')
      .join(', ');
    if (has) {
      playerInventory += `${has}, `;
    } else {
      playerInventory += `nothing, `;
    }
  }
  if (game.debug) {
    addChatMessage(game, null, playerInventory.replace(/, $/, '').trim());
  } else {
    console.log(playerInventory.replace(/, $/, '').trim());
  }
};

const getFilteredGameForPlayer = (game: any, session: any) => {
  /* Shallow copy game, filling its sessions with a shallow copy of
   * sessions so we can then delete the player field from them */
  const reducedGame = Object.assign({}, game, { sessions: {} }),
    reducedSessions = [];

  for (let id in game.sessions) {
    // Make a shallow copy and then scrub any fields that are private,
    // non-serializable (timers, sockets), or internal (prefixed with '_').
    const original = game.sessions[id];
    const reduced = Object.assign({}, original);

    // Remove obvious non-serializable fields
    if ('player' in reduced) delete reduced.player;
    if ('ws' in reduced) delete reduced.ws;
    if ('keepAlive' in reduced) delete reduced.keepAlive;

    // Remove internal helper fields (e.g. _pendingTimeout) and functions
    Object.keys(reduced).forEach(k => {
      try {
        if (k.startsWith('_')) {
          delete reduced[k];
        } else if (typeof reduced[k] === 'function') {
          delete reduced[k];
        } else {
          // Remove values that are likely to be non-serializable objects
          // such as Timers that may appear on some runtime fields.
          const v = reduced[k];
          if (typeof v === 'object' && v !== null) {
            // A quick heuristic: if the object has constructor name 'Timeout' or
            // properties typical of timer internals, drop it to avoid circular refs.
            const ctor = v.constructor && v.constructor.name ? v.constructor.name : '';
            if (ctor === 'Timeout' || ctor === 'TimersList') {
              delete reduced[k];
            }
          }
        }
      } catch (e) {
        // Defensive: if introspection fails, delete the key to be safe
        try {
          delete reduced[k];
        } catch (err) {
          /* ignore */
        }
      }
    });

    reducedGame.sessions[id] = reduced;
    /* Do not send session-id as those are secrets */
    reducedSessions.push(reduced);
  }

  const player = session.player ? session.player : undefined;

  /* Strip out data that should not be shared with players */
  delete reducedGame.developmentCards;

  /* Delete the game timer */
  delete reducedGame.turnTimer;

  reducedGame.unselected = getFilteredUnselected(game);

  return Object.assign(reducedGame, {
    live: true,
    status: session.error ? session.error : 'success',
    name: session.name,
    color: session.color,
    order: session.color in game.players ? game.players[session.color].order : 0,
    private: player,
    sessions: reducedSessions,
    participants: getParticipants(game),
    layout: layout,
    players: getFilteredPlayers(game),
    // Include static asset metadata so clients can render the board immediately
    tiles: staticData.tiles,
    pips: staticData.pips,
    borders: staticData.borders,
    // Include board order/state so clients can render without waiting for extra GETs
    pipOrder: game.pipOrder,
    tileOrder: game.tileOrder,
    borderOrder: game.borderOrder,
    signature: game.signature,
    animationSeeds: game.animationSeeds,
    robber: game.robber,
    robberName: game.robberName,
  });
};

/**
 * Send a consolidated initial snapshot to a single session.
 * This is used to allow clients (and tests) to render the full
 * game state deterministically on first attach instead of having
 * to wait for a flurry of incremental game-update events.
 */
const sendInitialGameSnapshot = (game: Game, session: Session) => {
  if (session.initialSnapshotSent) {
    return;
  }

  try {
    const snapshot = getFilteredGameForPlayer(game, session);
    const message = JSON.stringify({ type: 'initial-game', snapshot });
    // Small debug log to help test harnesses detect that the server sent
    // the consolidated snapshot. Keep output small to avoid noisy logs.
    try {
      const topKeys = Object.keys(snapshot || {})
        .slice(0, 10)
        .join(',');
      console.log(`${session.short}: sending initial-game snapshot keys: ${topKeys}`);
    } catch (e) {
      /* ignore logging errors */
    }
    if (session && session.ws && session.ws.send) {
      session.ws.send(message);
      session.initialSnapshotSent = true;
    } else {
      console.warn(`${session.id}: Unable to send initial snapshot - no websocket available`);
    }
  } catch (err) {
    console.error(`${session.id}: error in sendInitialGameSnapshot`, err);
  }
};

/* Example:
"stolen": {
  "robber": {
    "stole": {
      "total": 5,
      "wheat": 2,
      "wood": 1,
      "sheep": 2
    }
  },
  "O": {
    "stolen": {
      "total": 2,
      "wheat": 2
    },
    "stole": {
      "total": 2,
      "brick": 2
    }
  },
  "W": {
    "stolen": {
      "total": 4,
      "brick": 2,
      "wood": 1,
      "sheep": 2
    },
    "stole": {
      "total": 3,
      "brick": 2,
      "wheat": 1
    }
  }
}
*/
const trackTheft = (game: any, from: any, to: any, type: any, count: any) => {
  const stats = game.stolen;

  /* Initialize the stole / stolen structures */
  [to, from].forEach(player => {
    if (!(player in stats)) {
      stats[player] = {
        stole: {
          /* the resources this player stole */ total: 0,
        },
        stolen: {
          /* the resources stolen from this player */ total: 0,
          player: 0 /* by players */,
          robber: 0 /* by robber */,
        },
      };
    }
  });

  /* Initialize 'type' field in structures */
  if (!(type in stats[from].stolen)) {
    stats[from].stolen[type] = 0;
  }
  if (!(type in stats[to].stole)) {
    stats[to].stole[type] = 0;
  }

  /* Update counts */
  stats[from].stolen.total += count;
  if (to === 'robber') {
    stats[from].stolen.robber += count;
  } else {
    stats[from].stolen.player += count;
  }
  stats[from].stolen[type] += count;
  stats[to].stole.total += count;
  stats[to].stole[type] += count;
};

/* Simple NO-OP to set session cookie so player-id can use it as the
 * index */
router.get('/', (req, res /*, next*/) => {
  let playerId;
  if (!req.cookies.player) {
    playerId = crypto.randomBytes(16).toString('hex');
    // Determine whether this request is secure so we can set cookie flags
    // appropriately. In production behind TLS we want SameSite=None and
    // Secure so the cookie is sent on cross-site websocket connects.
    const secure =
      req.secure ||
      (req.headers && req.headers['x-forwarded-proto'] === 'https') ||
      process.env['NODE_ENV'] === 'production';
    const cookieOpts: any = {
      httpOnly: false,
      sameSite: secure ? 'none' : 'lax',
      secure: !!secure,
    };
    // Ensure cookie is scoped to the application basePath so it will be
    // included on requests under the same prefix (and on the websocket
    // handshake which uses the same path prefix).
    cookieOpts.path = basePath || '/';
    res.cookie('player', playerId, cookieOpts as any);
    console.log(
      `[${playerId.substring(0, 8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`
    );
  } else {
    playerId = req.cookies.player;
  }

  console.log(`[${playerId.substring(0, 8)}]: Browser hand-shake achieved.`);

  // Mark this response as coming from the backend API to aid debugging
  res.setHeader('X-Backend', 'games');
  return res.status(200).send({
    id: playerId,
    player: playerId,
    name: null,
    lobbies: [],
    has_media: true, // Default to true for regular users
  });
});

router.post('/:id?', async (req, res /*, next*/) => {
  const { id } = req.params;

  let playerId;
  if (!req.cookies.player) {
    playerId = crypto.randomBytes(16).toString('hex');
    const secure =
      req.secure ||
      (req.headers && req.headers['x-forwarded-proto'] === 'https') ||
      process.env['NODE_ENV'] === 'production';
    const cookieOpts: any = {
      httpOnly: false,
      sameSite: secure ? 'none' : 'lax',
      secure: !!secure,
    };
    cookieOpts.path = basePath || '/';
    res.cookie('player', playerId, cookieOpts as any);
    console.log(
      `[${playerId.substring(0, 8)}]: Set player cookie (opts=${JSON.stringify(cookieOpts)})`
    );
  } else {
    playerId = req.cookies.player;
  }

  if (id) {
    console.log(`[${playerId.substring(0, 8)}]: Attempting load of ${id}`);
  } else {
    console.log(`[${playerId.substring(0, 8)}]: Creating new game.`);
  }
  const game = await loadGame(id || ''); /* will create game if it doesn't exist */
  console.log(`[${playerId.substring(0, 8)}]: ${game.id} loaded.`);

  return res.status(200).send({ id: game.id });
});

export default router;

// Export helpers for unit testing
export { processTies, processGameOrder };
