Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- import {
- BackgammonMessage,
- MessageType,
- Move,
- Place,
- } from "./backgammonMessage"
- import { ExpectedError, isExpectedError } from "./utils"
- const numericPlace = (p: Place) => {
- if (p === "jail") return 25
- if (p === "free") return 0
- return p
- }
- export type MatchState = {
- going: undefined | 0 | 1
- score: [number, number]
- until: number
- boards: [PlayerBoard, PlayerBoard]
- rolls: number[]
- jacoby: boolean
- crawford: "notYetActive" | "active" | "disabled"
- doubling: "disabled" | [number, number]
- requestingDouble: boolean
- winner: undefined | string
- }
- export type BoardInit = [Place, number][]
- export const defaultBoard: BoardInit = [
- [6, 5],
- [8, 3],
- [13, 5],
- [24, 2],
- ]
- export class PlayerBoard {
- private points: Map<number, number>
- private jail = 0
- private free = 0
- constructor(init: BoardInit, private boardLength = 24) {
- this.points = new Map()
- for (const [i, v] of init) {
- if (i === "jail") {
- this.jail = v
- } else if (i === "free") {
- this.free = v
- } else {
- this.points.set(i, v)
- }
- }
- }
- copy() {
- return new PlayerBoard(this.serialize(), this.boardLength)
- }
- serialize(): [Place, number][] {
- const points = [...this.points.entries()]
- points.sort(([a], [b]) => a - b)
- return [...points, ["jail", this.jail], ["free", this.free]]
- }
- getOur(i: Place) {
- if (i === "free") return this.free
- if (i === "jail") return this.jail
- return this.points.get(i) ?? 0
- }
- setOur(i: Place, v: number) {
- this.validatePlace(i)
- if (i === "free") {
- this.free = v
- } else if (i === "jail") {
- this.jail = v
- } else {
- if (v) this.points.set(i, v)
- else this.points.delete(i)
- }
- }
- getOther(i: Place) {
- return this.getOur(this.toOtherPlace(i))
- }
- setOther(i: Place, v: number) {
- this.setOur(this.toOtherPlace(i), v)
- }
- getFurthest(): Place {
- if (this.jail) return "jail"
- let furthest: "free" | number = "free"
- for (const occupiedIndex of this.points.keys()) {
- if (furthest === "free" || furthest < occupiedIndex) {
- furthest = occupiedIndex
- }
- }
- return furthest
- }
- // get list of all potentially possible ways to play the roll, regarding only starting positions
- getOurPotentialMoves(roll: number): Move[] {
- const potential: Move[] = []
- if (this.jail) {
- potential.push(new Move("jail", this.boardLength + 1 - roll))
- } else {
- for (const occupiedIndex of this.points.keys()) {
- const endIndex = occupiedIndex - roll
- if (endIndex >= 1) {
- potential.push(new Move(occupiedIndex, endIndex))
- } else {
- potential.push(new Move(occupiedIndex, "free"))
- }
- }
- }
- return potential
- }
- private toOtherPlace = (i: Place) => {
- if (i === "free" || i === "jail") {
- return i
- }
- return this.boardLength - i + 1
- }
- private validatePlace(i: Place) {
- if (i === "free" || i === "jail") {
- return
- }
- if (i < 1 || i > this.boardLength) throw Error("bad index: " + i)
- }
- }
- export class BackgammonEngine {
- private match: MatchState
- constructor(
- config: Partial<MatchState>,
- private players: [string, string],
- private publish: (msg: string) => void,
- initialState: [BoardInit, BoardInit] = [defaultBoard, defaultBoard],
- private resetState: [BoardInit, BoardInit] = [defaultBoard, defaultBoard]
- ) {
- this.publish(
- JSON.stringify({ message: `${this.players[0]} vs ${this.players[1]}` })
- )
- const rolls = config.rolls || this.doRoll(false)
- this.match = {
- going: rolls[0] > rolls[1] ? 0 : 1,
- score: [0, 0],
- boards: [
- new PlayerBoard(initialState[0]),
- new PlayerBoard(initialState[1]),
- ],
- until: 5,
- rolls,
- crawford: "notYetActive",
- doubling: [0, 0],
- jacoby: true,
- requestingDouble: false,
- winner: undefined,
- ...config,
- }
- this.publish(this.getBoardStateMessage())
- }
- getBoardStateMessage() {
- const toPlay = this.getOursOther(this.players)[0]
- return JSON.stringify({
- match: {
- ...this.match,
- boards: this.match.boards.map((b) => b.serialize()),
- },
- players: this.players,
- toPlay,
- })
- }
- private doRoll(allowDoubles = true) {
- const doRoll = () => Math.floor(Math.random() * 6) + 1
- let rolls = []
- do {
- rolls = [doRoll(), doRoll()]
- } while (!allowDoubles && rolls[0] === rolls[1])
- if (rolls[0] === rolls[1]) {
- rolls.push(rolls[0], rolls[0])
- }
- this.publish(JSON.stringify({ message: "Rolled " + rolls.join(", ") }))
- return rolls
- }
- private getOursOther<T>([a, b]: [T, T]): [T, T] {
- if (this.match.going === 0) {
- return [a, b]
- }
- if (this.match.going === 1) {
- return [b, a]
- }
- throw Error("bad player state")
- }
- private advancePlayer() {
- if (this.match.going === undefined) {
- throw Error("no player to advance")
- }
- this.match.going = ((this.match.going + 1) % 2) as 0 | 1
- }
- private getCubeVal() {
- return this.match.doubling === "disabled"
- ? 1
- : 2 ** Math.max(...this.match.doubling)
- }
- private applyRoundWin() {
- if (this.match.going === undefined) {
- throw Error("no player to mark winner")
- }
- const [ours, other] = this.getOursOther(this.match.boards)
- let scale = 1
- const cubeVal = this.getCubeVal()
- if (ours.getFurthest() === "free") {
- const opponentFurthest = other.getFurthest()
- if (opponentFurthest === "free") {
- throw Error("bad state, both win?")
- }
- if (opponentFurthest === "jail" || opponentFurthest > 12) {
- scale = 3
- } else if (!other.getOther("free")) {
- scale = 2
- }
- }
- let gameVal = cubeVal
- const winner = this.players[this.match.going]
- if (this.match.jacoby && scale > 1 && cubeVal === 1) {
- this.publish(
- JSON.stringify({
- message: `Winner: ${winner}!. 1 point. Mars/Backgammon multiplier disabled due to Jacoby rule.`,
- })
- )
- } else {
- gameVal *= scale
- this.publish(
- JSON.stringify({
- message:
- gameVal === 1
- ? `Winner: ${winner}! 1 point.`
- : `Winner: ${winner}! ${gameVal} points.`,
- })
- )
- }
- let [ourScore, otherScore] = this.getOursOther(this.match.score)
- ourScore += gameVal
- this.match.score = this.getOursOther([ourScore, otherScore])
- if (ourScore >= this.match.until) {
- this.match.winner = this.players[this.match.going]
- }
- this.match.boards = [
- new PlayerBoard(this.resetState[0]),
- new PlayerBoard(this.resetState[1]),
- ]
- this.match.rolls = this.doRoll(false)
- this.match.going = this.match.rolls[0] > this.match.rolls[1] ? 0 : 1
- if (this.match.doubling !== "disabled") {
- this.match.doubling = [0, 0]
- }
- if (
- this.match.crawford === "notYetActive" &&
- ourScore === this.match.until - 1
- ) {
- this.publish(JSON.stringify({ message: "Crawford rule activated!" }))
- this.match.crawford = "active"
- } else if (this.match.crawford === "active") {
- this.publish(
- JSON.stringify({ message: "Crawford rule no longer active!" })
- )
- this.match.crawford = "disabled"
- }
- }
- acceptMessage(message: BackgammonMessage): ExpectedError | undefined {
- if (this.match.winner) {
- return ExpectedError("Match over.")
- }
- const toPlay = this.getOursOther(this.players)[0]
- if (toPlay !== message.speaker) {
- return ExpectedError("Not your turn")
- }
- const isDoubleResponse =
- message.type === MessageType.Accept || message.type === MessageType.Reject
- if (this.match.requestingDouble && !isDoubleResponse) {
- return ExpectedError("Must accept or reject double offer.")
- }
- if (!this.match.requestingDouble && isDoubleResponse) {
- return ExpectedError("No doubling was requested.")
- }
- switch (message.type) {
- case MessageType.Roll: {
- if (this.match.rolls.length) {
- return ExpectedError("Already rolled.")
- }
- this.match.rolls = this.doRoll()
- break
- }
- case MessageType.Move: {
- if (!message.data?.moves) {
- return { error: "No move list supplied" }
- }
- const result = this.tryApplyMove(message.data.moves)
- if ("error" in result) return result
- this.match.boards = result.boards
- this.match.rolls = []
- const ourBoard = this.getOursOther(result.boards)[0]
- if (ourBoard.getFurthest() === "free") {
- this.applyRoundWin()
- } else {
- this.advancePlayer()
- }
- break
- }
- case MessageType.Double: {
- if (this.match.rolls.length) {
- return ExpectedError("Already rolled. Ask for double at turn start.")
- }
- if (this.match.doubling === "disabled") {
- return ExpectedError("Doubling disabled: match settings.")
- }
- if (this.match.crawford === "active") {
- return ExpectedError("Doubling disabled: Crawford rule.")
- }
- let ourCube = this.getOursOther(this.match.doubling)[0]
- if (ourCube !== 0) {
- return ExpectedError("Doubling disabled: opponent controls cube.")
- }
- this.match.requestingDouble = true
- this.advancePlayer()
- break
- }
- case MessageType.Accept: {
- this.advancePlayer()
- this.match.requestingDouble = false
- if (this.match.doubling === "disabled") {
- throw Error("invalid state, doubling disabled")
- }
- let [ourCube, otherCube] = this.getOursOther(this.match.doubling)
- ourCube = otherCube + 1
- otherCube = 0
- this.match.doubling = this.getOursOther([ourCube, otherCube])
- break
- }
- case MessageType.Reject: {
- this.match.requestingDouble = false
- this.match.doubling = [0, 0]
- this.advancePlayer()
- this.applyRoundWin()
- break
- }
- default: {
- return ExpectedError("Unknown message type: " + message.type)
- }
- }
- this.publish(JSON.stringify({ moveApplied: message }))
- this.publish(this.getBoardStateMessage())
- return undefined
- }
- tryApplyMove(
- moves: Move[],
- boards: [PlayerBoard, PlayerBoard] = this.match.boards,
- rolls: number[] = this.match.rolls,
- ensureMaximal = true
- ): ExpectedError | { boards: [PlayerBoard, PlayerBoard]; consumed: number } {
- const [_ourBoard, _otherBoard] = this.getOursOther(boards)
- const ourBoard = _ourBoard.copy()
- const otherBoard = _otherBoard.copy()
- const rollsToUse = [...rolls]
- let consumed = 0
- for (const { from, to } of moves) {
- const ourFromCheckers = ourBoard.getOur(from)
- if (ourFromCheckers === 0) {
- return ExpectedError(
- `Invalid move ${from}/${to}: no checkers on ${from}`
- )
- }
- if (from === "jail") {
- } else {
- if (ourBoard.getOur("jail")) {
- return ExpectedError(
- `Invalid move ${from}/${to}: must remove pieces from jail`
- )
- }
- const otherFromCheckers = otherBoard.getOther(from)
- if (otherFromCheckers > 0) {
- return ExpectedError(
- `Invalid state ${from}/${to}: opponent has checkers on ${from}`
- )
- }
- }
- const delta = numericPlace(from) - numericPlace(to)
- consumed += delta
- let dieIndex = rollsToUse.findIndex((x) => x === delta)
- if (to === "free") {
- if (dieIndex === -1) {
- if (ourBoard.getFurthest() !== from) {
- return ExpectedError(
- `Invalid move ${from}/${to}: overshooting only allowed when moving from furthest point (${ourBoard.getFurthest()})`
- )
- }
- dieIndex = rollsToUse.findIndex((x) => x >= delta)
- if (dieIndex === -1) {
- return ExpectedError(
- `Invalid move ${from}/${to}: no die available for overshooting (available: ${rollsToUse})`
- )
- }
- }
- const furthest = ourBoard.getFurthest()
- if (furthest === "free" || furthest === "jail" || furthest > 6) {
- return ExpectedError(
- `Invalid move ${from}/${to}: cannot free checkers until all pieces are in home quadrant`
- )
- }
- } else {
- if (dieIndex === -1) {
- return ExpectedError(
- `Invalid move ${from}/${to}: die ${delta} not available (available: ${rollsToUse})`
- )
- }
- const otherToCheckers = otherBoard.getOther(to)
- if (otherToCheckers > 1) {
- return ExpectedError(
- `Invalid move ${from}/${to}: opponent has more than one checker on ${to}`
- )
- }
- if (otherToCheckers === 1) {
- otherBoard.setOther(to, 0)
- otherBoard.setOther("jail", otherBoard.getOther("jail") + 1)
- }
- }
- rollsToUse.splice(dieIndex, 1)
- const ourToCheckers = ourBoard.getOur(to)
- ourBoard.setOur(from, ourFromCheckers - 1)
- ourBoard.setOur(to, ourToCheckers + 1)
- }
- const maxConsumption = rolls.reduce((a, b) => a + b, 0)
- if (ensureMaximal && consumed < maxConsumption) {
- // first pass
- for (const roll of new Set(rollsToUse)) {
- for (const nextMove of ourBoard.getOurPotentialMoves(roll)) {
- const testMove = [...moves, nextMove]
- if (!("error" in this.tryApplyMove(testMove, boards, rolls, false))) {
- const moveStr = testMove.map((m) => m.toString()).join(" ")
- return ExpectedError(
- `Invalid move: does not maximize travel (see: ${moveStr})`
- )
- }
- }
- }
- // backtrack
- const uniqueRolls = new Set(rolls)
- // if all rolls are the same (doubles), first pass is guaranteed to find better path
- if (uniqueRolls.size !== 1) {
- // if not, backtracking is required (grr.)
- const dieOrderings = [
- [rolls[0], rolls[1]],
- [rolls[1], rolls[0]],
- ]
- for (const [d1, d2] of dieOrderings) {
- const initialOurBoard = _ourBoard.copy()
- for (const firstMove of initialOurBoard.getOurPotentialMoves(d1)) {
- const updated = this.tryApplyMove([firstMove], boards, [d1], false)
- if (isExpectedError(updated)) continue
- const { boards: nextBoards, consumed: firstConsume } = updated
- if (firstConsume > consumed) {
- const moveStr = firstMove.toString()
- return ExpectedError(
- `Invalid move: does not maximize travel (see: ${moveStr})`
- )
- }
- const [nextOurBoard] = this.getOursOther(nextBoards)
- for (const secondMove of nextOurBoard.getOurPotentialMoves(d2)) {
- const updated = this.tryApplyMove(
- [secondMove],
- nextBoards,
- [d2],
- false
- )
- if (isExpectedError(updated)) continue
- const { consumed: secondConsume } = updated
- if (firstConsume + secondConsume > consumed) {
- const moveStr =
- firstMove.toString() + " " + secondMove.toString()
- return ExpectedError(
- `Invalid move: does not maximize travel (see: ${moveStr}, ${consumed} < ${
- firstConsume + secondConsume
- })`
- )
- }
- }
- }
- }
- }
- }
- return {
- boards: this.getOursOther([ourBoard, otherBoard]),
- consumed,
- }
- }
- }
Advertisement
Add Comment
Please, Sign In to add comment