# @ayshrj/ludo.js

[![npm](https://img.shields.io/npm/v/@ayshrj/ludo.js?color=blue)](https://www.npmjs.com/package/@ayshrj/ludo.js)
[![npm](https://img.shields.io/npm/dm/@ayshrj/ludo.js)](https://www.npmjs.com/package/@ayshrj/ludo.js)

**@ayshrj/ludo.js** is a TypeScript library for simulating the classic Ludo board
game. It handles:

- **Board Setup** (2, 3, or 4 players)
- **Dice Rolling** (automatic skip after three consecutive sixes)
- **Token Movement** (including leaving home on a 6)
- **Capturing Opponents** on non-safe squares
- **Safe Zones & Final Squares**
- **Ranking** (tracks order in which players finish)
- **Lightweight AI** with `bestMove()`
- **EventEmitter-based Updates** (automatically emit `"stateChange"` with the latest state)
- **Comprehensive State Tracking** for easy integration into any UI

---

## Table of Contents

- [@ayshrj/ludo.js](#ayshrjludojs)
  - [Table of Contents](#table-of-contents)
  - [Installation](#installation)
  - [Importing](#importing)
    - [Import (as ESM)](#import-as-esm)
    - [Import (as CommonJS)](#import-as-commonjs)
  - [Quick Start Example](#quick-start-example)
  - [Features](#features)
  - [API](#api)
    - [Constructor: `new Ludo(numberOfPlayers)`](#constructor-new-ludonumberofplayers)
    - [`.rollDiceForCurrentPiece()`](#rolldiceforcurrentpiece)
    - [`.selectToken(tokenIndex)`](#selecttokentokenindex)
    - [`.bestMove()`](#bestmove)
    - [`.reset()`](#reset)
    - [`.getCurrentState()`](#getcurrentstate)
    - [`.tokenPositions`](#tokenpositions)
    - [`.validTokenIndices`](#validtokenindices)
    - [`.ranking`](#ranking)
    - [`.gameState`](#gamestate)
    - [`.currentPiece`](#currentpiece)
    - [Event Emitter: `"stateChange"`](#event-emitter-statechange)
    - [`.players`](#players)
    - [`initializeTokenPosition()`](#initializetokenposition)
  - [Example: React Integration](#example-react-integration)
  - [Advanced Usage Notes](#advanced-usage-notes)
  - [License](#license)

---

## Installation

```bash
npm install @ayshrj/ludo.js
```
or
```bash
yarn add @ayshrj/ludo.js
```

---

## Importing

### Import (as ESM)

```js
import { Ludo } from '@ayshrj/ludo.js'
```

### Import (as CommonJS)

```js
const { Ludo } = require('@ayshrj/ludo.js')
```

---

## Quick Start Example

```ts
import { Ludo } from '@ayshrj/ludo.js'

// Create a Ludo game with 4 players
const game = new Ludo(4)

// Subscribe to state changes (optional)
game.on("stateChange", (state) => {
  console.log("STATE CHANGE:", state)
})

// Roll the dice
const diceValue = game.rollDiceForCurrentPiece()
console.log(`Dice: ${diceValue}`)

// If there's a valid token, select it
if (game.validTokenIndices.length > 0) {
  game.selectToken(game.validTokenIndices[0])
}

// Or use the basic AI
const best = game.bestMove()
if (best >= 0) game.selectToken(best)
```

---

## Features

1. **2, 3, or 4 Players**  
   Colors are chosen from `["blue","red","green","yellow"]` based on the
   requested number of players.

2. **Dice Rolling**  
   Rolls `1..6`. Three consecutive sixes skip your turn.

3. **Movement & Safe Zones**  
   Leave home only on a 6. Safe squares cannot be captured.

4. **Capturing**  
   If you land on an opponent in a non-safe zone, that opponent’s token goes
   home (`-1`).

5. **Final & Ranking**  
   Index `56` is the final square; finishing all 4 tokens places you in `ranking`.

6. **Simple AI**  
   `bestMove()` returns a recommended token index.

7. **Event Emitter**  
   The `Ludo` class extends Node.js’s `EventEmitter`. Each time the internal
   state changes (after rolls, moves, etc.), it emits `"stateChange"` with the
   updated `LudoGameState`.

---

## API

### Constructor: `new Ludo(numberOfPlayers)`

```ts
// 2 players => uses ["blue","green"]
// 3 players => uses ["blue","red","green"]
// 4 players => uses ["blue","red","green","yellow"]
const game = new Ludo(4)
```

---

### `.rollDiceForCurrentPiece()`
Rolls a 6-sided die and updates `currentDiceRoll`, `lastDiceRoll`, and
`validTokenIndices`. Automatically skips turn on three consecutive sixes or if
there are no valid moves.

---

### `.selectToken(tokenIndex)`
Moves the chosen token for the current color using the last dice roll. Handles
captures, final squares, turn passing, etc.

---

### `.bestMove()`
A basic heuristic that returns the “best” token index or -1 if none.

---

### `.reset()`
Completely reinitializes the board and picks a new starting player randomly.

---

### `.getCurrentState()`
Returns a snapshot of the entire state:

```ts
interface LudoGameState {
  turn: Color;
  tokenPositions: TokenPositions;
  ranking: Color[];
  boardStatus: string;
  diceRoll: number | null;
  lastDiceRoll: number | null;
  gameState: GameState;
  players: Color[];
}
```

---

### `.tokenPositions`
A record of each color’s four token positions (`-1` for home, `0..56` on track).

---

### `.validTokenIndices`
Which tokens (0..3) the current color can move on their turn.  
Updated when `.rollDiceForCurrentPiece()` is called.

---

### `.ranking`
Colors finish in order. If `ranking.length` equals number of players, the game is
complete.

---

### `.gameState`
One of:
- `"playerHasToRollADice"`
- `"playerHasToSelectAPosition"`
- `"gameFinished"`

---

### `.currentPiece`
Which color’s turn it is right now.

---

### Event Emitter: `"stateChange"`
Every time the game state changes (e.g., after a roll or move), the library
calls: 
```ts
this.emit("stateChange", this.getCurrentState())
```
So you can do:
```ts
game.on("stateChange", (state) => {
  // React or update UI automatically with the new state
})
```

---

### `.players`
The array of active colors, in turn order. For example, with 3 players it might
be `["blue","red","green"]`.

---

### `initializeTokenPosition()`
A utility function that initializes the token positions for all colors. Each token starts at `-1` (home).

```ts
/**
 * Initializes the token positions for all colors.
 * @returns {TokenPositions} An object with token positions for each color.
 * @example
 * const tokenPositions = initializeTokenPosition();
 * // Returns:
 * // {
 * //   red: [-1, -1, -1, -1],
 * //   green: [-1, -1, -1, -1],
 * //   yellow: [-1, -1, -1, -1],
 * //   blue: [-1, -1, -1, -1]
 * // }
 */
function initializeTokenPosition(): TokenPositions;
```

---

## Example: React Integration

```tsx
import React, { useState, useEffect, useCallback, useRef } from "react";
import {
  Ludo,
  Color,
  LudoGameState,
  initializeTokenPosition,
} from "@ayshrj/ludo.js";

// Available color sets for 2/3/4 players:
const COLOR_SETS: Record<2 | 3 | 4, Color[]> = {
  2: ["blue", "green"],
  3: ["blue", "red", "green"],
  4: ["blue", "red", "green", "yellow"],
};

const LudoPage: React.FC = () => {
  const [playerCount, setPlayerCount] = useState<2 | 3 | 4>(4);
  const [playerTypes, setPlayerTypes] = useState<
    Record<Color, "human" | "bot">
  >({
    blue: "human",
    red: "human",
    green: "human",
    yellow: "human",
  });
  const [ludo, setLudo] = useState<Ludo | null>(null);
  const [gameState, setGameState] = useState<LudoGameState>({
    turn: "blue",
    ranking: [],
    tokenPositions: initializeTokenPosition(),
    boardStatus: "",
    diceRoll: null,
    lastDiceRoll: null,
    gameState: "playerHasToRollADice",
    players: [],
  });
  const [showSetupModal, setShowSetupModal] = useState<boolean>(true);
  const [showResetModal, setShowResetModal] = useState<boolean>(false);
  const [botThinking, setBotThinking] = useState<boolean>(false);
  const botTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    if (!ludo) return;
    const handleStateChange = (newState: LudoGameState) => {
      setGameState(newState);
    };
    ludo.on("stateChange", handleStateChange);
    return () => {
      ludo.off("stateChange", handleStateChange);
    };
  }, [ludo]);

  const s = gameState;
  const isGameFinished =
    s.ranking.length >= s.players.length && s.players.length > 0;

  const startNewGame = useCallback(() => {
    const chosenColors = COLOR_SETS[playerCount];
    const newLudo = new Ludo(playerCount);
    newLudo.players = chosenColors;
    newLudo.reset();
    setGameState(newLudo.getCurrentState());
    setLudo(newLudo);
    setShowSetupModal(false);
    setShowResetModal(false);
  }, [playerCount]);

  useEffect(() => {
    if (!ludo || isGameFinished) return;
    if (s.gameState !== "playerHasToRollADice") return;

    const botTurn = playerTypes[s.turn] === "bot";
    if (!botTurn || botThinking) return;

    setBotThinking(true);
    botTimeoutRef.current = setTimeout(() => {
      ludo.rollDiceForCurrentPiece();
      setBotThinking(false);
    }, 1200);
  }, [
    ludo,
    s.gameState,
    s.turn,
    s.players,
    botThinking,
    isGameFinished,
    playerTypes,
  ]);

  useEffect(() => {
    if (!ludo || isGameFinished) return;
    if (s.gameState !== "playerHasToSelectAPosition" || s.diceRoll === null)
      return;

    const botTurn = playerTypes[s.turn] === "bot";
    if (!botTurn || botThinking) return;

    setBotThinking(true);
    botTimeoutRef.current = setTimeout(() => {
      const best = ludo.bestMove();
      if (best >= 0) {
        ludo.selectToken(best);
      }
      setBotThinking(false);
    }, 800);
  }, [
    ludo,
    s.gameState,
    s.turn,
    s.players,
    s.diceRoll,
    botThinking,
    isGameFinished,
    playerTypes,
  ]);

  const handleRollDice = (): void => {
    if (!ludo || isGameFinished) return;
    const { turn } = ludo.getCurrentState();
    if (playerTypes[turn] === "bot") return;
    setTimeout(() => {
      ludo.rollDiceForCurrentPiece();
    }, 1000);
  };

  const handleTokenClick = (color: Color, tokenIndex: number): void => {
    if (!ludo || isGameFinished) return;
    const st = ludo.getCurrentState();
    if (color !== st.turn) return;
    if (playerTypes[color] === "bot") return;
    if (!ludo.validTokenIndices.includes(tokenIndex)) return;
    ludo.selectToken(tokenIndex);
  };

  const colorMap: Record<Color, string> = {
    red: "#FF0000",
    green: "#00FF00",
    yellow: "#FFFF00",
    blue: "#0000FF",
  };

  function getColorOffset(index: number, totalColors: number) {
    const offset = 10;
    if (totalColors === 1) return { x: 0, y: 0 };
    if (totalColors === 2)
      return index === 0
        ? { x: -offset, y: -offset }
        : { x: offset, y: offset };
    if (totalColors === 3) {
      if (index === 0) return { x: -offset, y: -offset };
      if (index === 1) return { x: offset, y: -offset };
      if (index === 2) return { x: offset, y: offset };
    }
    if (totalColors === 4) {
      if (index === 0) return { x: -offset, y: -offset };
      if (index === 1) return { x: offset, y: -offset };
      if (index === 2) return { x: -offset, y: offset };
      if (index === 3) return { x: offset, y: offset };
    }
    return { x: 0, y: 0 };
  }

  const startingPositions: Record<Color, [number, number][]> = {
    red: [
      [1, 1],
      [1, 4],
      [4, 1],
      [4, 4],
    ],
    green: [
      [1, 10],
      [1, 13],
      [4, 10],
      [4, 13],
    ],
    blue: [
      [10, 1],
      [10, 4],
      [13, 1],
      [13, 4],
    ],
    yellow: [
      [10, 10],
      [10, 13],
      [13, 10],
      [13, 13],
    ],
  };

  const getTokensAtSquare = (
    rowIndex: number,
    colIndex: number
  ): { token: Color; index: number }[] => {
    if (!ludo) return [];
    const tokens: { token: Color; index: number }[] = [];
    s.players.forEach((color) => {
      s.tokenPositions[color].forEach((pos, i) => {
        if (pos !== -1) {
          const [r2, c2] = ludo.colorPaths[color][pos];
          if (r2 === rowIndex && c2 === colIndex) {
            tokens.push({ token: color, index: i });
          }
        } else {
          const [hr, hc] = startingPositions[color][i];
          if (hr === rowIndex && hc === colIndex) {
            tokens.push({ token: color, index: i });
          }
        }
      });
    });
    return tokens;
  };

  const groupTokensByColor = (
    tokens: { token: Color; index: number }[]
  ): { token: Color; total: number }[] => {
    const counts: Record<Color, number> = {
      red: 0,
      green: 0,
      blue: 0,
      yellow: 0,
    };
    tokens.forEach(({ token }) => {
      counts[token]++;
    });
    return Object.entries(counts)
      .filter(([, cnt]) => cnt > 0)
      .map(([color, total]) => ({ token: color as Color, total }));
  };

  let statusMessage = "No game in progress.";
  if (ludo) {
    if (isGameFinished) {
      statusMessage = `Game finished! Final ranking: ${s.ranking.join(" -> ")}`;
    } else {
      statusMessage = `${s.turn.toUpperCase()}'s turn.`;
      if (s.lastDiceRoll !== null) {
        statusMessage += ` (Last roll: ${s.lastDiceRoll})`;
      }
      if (s.boardStatus) {
        statusMessage += ` — ${s.boardStatus}`;
      }
    }
  }

  const isDiceDisabled =
    !ludo ||
    isGameFinished ||
    s.gameState !== "playerHasToRollADice" ||
    playerTypes[s.turn] === "bot" ||
    botThinking;

  const modalStyle: React.CSSProperties = {
    position: "fixed",
    top: "50%",
    left: "50%",
    transform: "translate(-50%, -50%)",
    backgroundColor: "white",
    padding: "20px",
    borderRadius: "8px",
    boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
    zIndex: 1000,
  };

  const buttonStyle: React.CSSProperties = {
    padding: "10px 20px",
    borderRadius: "4px",
    border: "none",
    cursor: "pointer",
    fontSize: "16px",
  };

  const selectStyle: React.CSSProperties = {
    padding: "5px",
    borderRadius: "4px",
    border: "1px solid #ccc",
    fontSize: "16px",
  };

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        padding: "16px",
        height: "100vh",
      }}
    >
      {/* Game Status */}
      <div
        style={{
          width: "100%",
          textAlign: "center",
          fontSize: "18px",
          fontWeight: "500",
        }}
      >
        {statusMessage}
      </div>

      {/* Board */}
      {ludo ? (
        <div
          style={{
            width: "100%",
            overflow: "hidden",
            aspectRatio: "1/1",
            backgroundColor: "#f0f0f0",
            borderRadius: "8px",
          }}
        >
          <div
            style={{
              display: "grid",
              width: "100%",
              height: "100%",
              gridTemplateColumns: "repeat(15, 1fr)",
              gridTemplateRows: "repeat(15, 1fr)",
            }}
          >
            {ludo.board.map((row, rowIndex) =>
              row.map((col, colIndex) => {
                const rawTokens = getTokensAtSquare(rowIndex, colIndex);
                const groupedTokens = groupTokensByColor(rawTokens);
                return (
                  <div
                    key={`${rowIndex}-${colIndex}`}
                    style={{
                      position: "relative",
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "center",
                      borderBottom: "1px solid #ccc",
                      borderRight: "1px solid #ccc",
                      backgroundColor: col === null ? "#f8f8f8" : "",
                      ...(col?.isOnPathToFinalPosition
                        ? {
                            backgroundColor:
                              colorMap[col.isOnPathToFinalPosition],
                          }
                        : {}),
                      ...(col?.isStartingPosition
                        ? { backgroundColor: colorMap[col.isStartingPosition] }
                        : {}),
                      ...(col?.isFinalPosition
                        ? { backgroundColor: colorMap[col.isFinalPosition] }
                        : {}),
                    }}
                  >
                    {groupedTokens.map(({ token, total }, i) => {
                      const isCurrentPlayerStack =
                        token === s.turn &&
                        rawTokens.some(
                          (t) =>
                            t.token === token &&
                            ludo.validTokenIndices.includes(t.index)
                        );
                      const onCircleClick = () => {
                        const validToken = rawTokens.find(
                          (t) =>
                            t.token === token &&
                            ludo.validTokenIndices.includes(t.index)
                        );
                        if (validToken) {
                          handleTokenClick(validToken.token, validToken.index);
                        }
                      };
                      const offset = getColorOffset(i, groupedTokens.length);
                      return (
                        <div
                          key={`${token}-${i}`}
                          onClick={onCircleClick}
                          style={{
                            position: "absolute",
                            transform: `translate(${offset.x}px, ${offset.y}px)`,
                            width: `${40 + total * 10}%`,
                            height: `${40 + total * 10}%`,
                            backgroundColor: colorMap[token],
                            borderRadius: "50%",
                            display: "flex",
                            alignItems: "center",
                            justifyContent: "center",
                            color: "white",
                            fontSize: "12px",
                            fontWeight: "bold",
                            cursor: "pointer",
                            border:
                              isCurrentPlayerStack && !isGameFinished
                                ? "2px solid white"
                                : "none",
                          }}
                        >
                          {total > 1 ? total : ""}
                        </div>
                      );
                    })}
                    {col?.isSafeZone &&
                      !col?.isStartingPosition &&
                      !col?.isFinalPosition && (
                        <div
                          style={{
                            position: "absolute",
                            left: "50%",
                            top: "50%",
                            transform: "translate(-50%, -50%)",
                            zIndex: 1,
                            display: "flex",
                            alignItems: "center",
                            justifyContent: "center",
                            height: "100%",
                            width: "100%",
                          }}
                        >
                          <div
                            style={{
                              width: "50%",
                              height: "50%",
                              border: "1px dashed #800080",
                              borderRadius: "50%",
                              backgroundColor: "#e0e0ff",
                            }}
                          />
                        </div>
                      )}
                  </div>
                );
              })
            )}
          </div>
        </div>
      ) : (
        <div
          style={{
            width: "100%",
            aspectRatio: "1/1",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            backgroundColor: "#f0f0f0",
            borderRadius: "8px",
          }}
        >
          No Game Yet
        </div>
      )}

      {/* Ranking/Turn Info & Controls */}
      <div
        style={{
          width: "100%",
          display: "flex",
          flexDirection: "column",
          gap: "8px",
        }}
      >
        {s.ranking.length > 0 && !isGameFinished && (
          <div style={{ textAlign: "center" }}>
            Current Ranking: {s.ranking.join(" -> ")}
          </div>
        )}
        {isGameFinished && (
          <div style={{ textAlign: "center" }}>
            Final Ranking: {s.ranking.join(" -> ")}
          </div>
        )}
        {!isGameFinished && ludo && (
          <button
            style={{
              ...buttonStyle,
              backgroundColor: "#0000FF",
              color: "white",
              width: "100%",
            }}
            onClick={handleRollDice}
            disabled={isDiceDisabled}
          >
            Roll Dice
          </button>
        )}
        <button
          style={{
            ...buttonStyle,
            backgroundColor: "#00FF00",
            color: "white",
            width: "100%",
          }}
          onClick={() => setShowResetModal(true)}
        >
          Reset
        </button>
      </div>

      {/* Setup Modal */}
      {showSetupModal && !ludo && (
        <div style={modalStyle}>
          <h2 style={{ marginBottom: "16px" }}>Ludo Setup</h2>
          <div
            style={{ display: "flex", flexDirection: "column", gap: "16px" }}
          >
            <div
              style={{
                display: "grid",
                gridTemplateColumns: "repeat(5, 1fr)",
                alignItems: "center",
                gap: "8px",
              }}
            >
              <label style={{ gridColumn: "span 3" }} htmlFor="playerCount">
                Number of Players:
              </label>
              <select
                id="playerCount"
                value={playerCount}
                onChange={(e) => setPlayerCount(+e.target.value as 2 | 3 | 4)}
                style={selectStyle}
              >
                <option value={2}>2</option>
                <option value={3}>3</option>
                <option value={4}>4</option>
              </select>
            </div>
            {COLOR_SETS[playerCount].map((color) => (
              <div
                style={{
                  display: "grid",
                  gridTemplateColumns: "repeat(5, 1fr)",
                  alignItems: "center",
                  gap: "8px",
                }}
                key={color}
              >
                <label
                  style={{ gridColumn: "span 3" }}
                  htmlFor={`sel-${color}`}
                >
                  {color}:
                </label>
                <select
                  id={`sel-${color}`}
                  value={playerTypes[color]}
                  onChange={(e) =>
                    setPlayerTypes((prev) => ({
                      ...prev,
                      [color]: e.target.value as "human" | "bot",
                    }))
                  }
                  style={selectStyle}
                >
                  <option value="human">Human</option>
                  <option value="bot">Bot</option>
                </select>
              </div>
            ))}
            <button
              style={{
                ...buttonStyle,
                backgroundColor: "#00FF00",
                color: "white",
                width: "100%",
              }}
              onClick={startNewGame}
            >
              Start
            </button>
          </div>
        </div>
      )}

      {/* Reset Confirmation Modal */}
      {showResetModal && (
        <div style={modalStyle}>
          <h2 style={{ marginBottom: "16px" }}>Reset Game</h2>
          <div
            style={{ display: "flex", flexDirection: "column", gap: "16px" }}
          >
            <p>Are you sure you want to start a new Ludo game?</p>
            <div style={{ display: "flex", gap: "8px" }}>
              <button
                style={{
                  ...buttonStyle,
                  backgroundColor: "#ccc",
                  color: "black",
                  width: "50%",
                }}
                onClick={() => setShowResetModal(false)}
              >
                Cancel
              </button>
              <button
                style={{
                  ...buttonStyle,
                  backgroundColor: "#00FF00",
                  color: "white",
                  width: "50%",
                }}
                onClick={() => {
                  setShowResetModal(false);
                  setLudo(null);
                  setShowSetupModal(true);
                }}
              >
                Start
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
};

export default LudoPage;
```

---

## Advanced Usage Notes

1. **Skipping / Passing**  
   If `.rollDiceForCurrentPiece()` yields no valid moves, it auto-passes to the
   next player.
2. **Consecutive Sixes**  
   Rolling three in a row ends your turn immediately.
3. **Event-Driven Updates**  
   Subscribing to `"stateChange"` means you can react to changes
   without manually querying the state after every call.

---

## License

MIT License. Feel free to modify or contribute!

---