5 Commits

7 changed files with 169 additions and 49 deletions

22
README.md Normal file
View File

@ -0,0 +1,22 @@
# `cup` - CamelUp probability calculator
This tool calculates probable outcomes for the board game CamelUp.
It can calculate all possible outcomes for a single game leg in about 5ms, so effectively instantaneously.
Full-game calculations take a little bit longer and are not exact (since it isn't practical to simulate all possible full game states.)
However it can easily simulate a million random games in about 80ms in the worst case, which should provide estimates accurate to within about 0.2%.
(Numbers from running on a Ryzen 3700X.)
```
Usage:
cup [-i] SPACE:STACK [...SPACE:STACK] [DICE]
SPACE refers to a numbered board space (1-16).
STACK refers to a stack of camel colors from bottom to top, e.g.
YBR (Yellow, Blue, Red, with Red on top).
DICE refers to the set of dice that have already been rolled,
e.g. GPR (Green, Purple, Red)
Options:
-i Interactive mode (currently unimplemented)
-h Show this message and exit
```

5
config.nims Normal file
View File

@ -0,0 +1,5 @@
--threads: on
--d: release
--opt: speed
--passC: -flto
--passL: -flto

View File

@ -1,5 +1,4 @@
import math, options, sequtils, random, sets import game, simulation, ui
import combinators, game, fixedseq, simulation, ui
when isMainModule: when isMainModule:
@ -8,11 +7,11 @@ when isMainModule:
b.init b.init
b.setState(config.state, []) b.setState(config.state, [])
b.diceRolled = config.diceRolled b.diceRolled = config.diceRolled
b.display(1, 5) echo b.showSpaces(1, 16)
let legScores = b.getLegScores let legScores = b.getLegScores
echo "Current leg probabilities:" echo "\nCurrent leg probabilities:"
legScores.display echo legScores.showPercents()
let gameScores = b.randomGames(1_000_000) let gameScores = b.randomGames(1_000_000)
echo "\nFull game probabilities (1M simulations):" echo "\nFull game probabilities (1M simulations):"
gameScores.display echo gameScores.showPercents()

View File

@ -18,15 +18,21 @@ proc getAllColors: ColorStack =
for c in Color.low .. Color.high: for c in Color.low .. Color.high:
result[i] = c result[i] = c
const allColors* = getAllColors() const
const colorNames: array[Color, string] = allColors* = getAllColors()
colorNames: array[Color, string] =
["Red", "Green", "Blue", "Yellow", "Purple"] ["Red", "Green", "Blue", "Yellow", "Purple"]
colorAbbrevs: array[Color, char] = ['R', 'G', 'B', 'Y', 'P']
proc `$`*(c: Color): string = proc `$`*(c: Color): string =
result = colorNames[c] result = colorNames[c]
proc abbrev*(c: Color): char =
result = colorAbbrevs[c]
proc `$`*(s: ColorStack): string = proc `$`*(s: ColorStack): string =
result.add("St@[") result.add("St@[")
for i, color in s: for i, color in s:
@ -44,12 +50,16 @@ type
tForward = 1 tForward = 1
Square* = object Square* = object
camels: ColorStack camels*: ColorStack
tile: Option[Tile] tile*: Option[Tile]
CamelPos* = object
square*: range[1..16]
stackIdx*: int8
Board* = object Board* = object
squares*: array[1..16, Square] squares*: array[1..16, Square]
camels*: array[Color, range[1..16]] camels*: array[Color, CamelPos]
diceRolled*: array[Color, bool] diceRolled*: array[Color, bool]
leader*: Option[Color] leader*: Option[Color]
gameOver*: bool gameOver*: bool
@ -97,13 +107,17 @@ proc setState*(b: var Board;
tiles: openArray[tuple[t: Tile, p: int]]) = tiles: openArray[tuple[t: Tile, p: int]]) =
for (color, dest) in camels: # note that `camels` is ordered, as this determines stacking for (color, dest) in camels: # note that `camels` is ordered, as this determines stacking
b[dest].camels.add(color) b[dest].camels.add(color)
b.camels[color] = dest let height = b[dest].camels.high
b.camels[color] = CamelPos(square: dest, stackIdx: height)
for (tile, dest) in tiles: for (tile, dest) in tiles:
b[dest].tile = some(tile) b[dest].tile = some(tile)
let leadCamel = b[max(b.camels)].camels[^1] # top camel in the last currently-occupied space for sq in b.squares:
b.leader = some(leadCamel) if sq.camels.len > 0:
let squareLeader = sq.camels[^1]
if b.leader.isNone or b.leader.get != squareLeader:
b.leader = some(squareLeader)
proc diceRemaining*(b: Board): ColorStack = proc diceRemaining*(b: Board): ColorStack =
@ -120,7 +134,7 @@ proc resetDice*(b: var Board) =
proc advance*(b: var Board, die: Die) = proc advance*(b: var Board, die: Die) =
let let
(color, roll) = die (color, roll) = die
startPos = b.camels[color] startPos = b.camels[color].square
var endPos = startPos + roll var endPos = startPos + roll
if endPos > 16: # camel has passed the finish line if endPos > 16: # camel has passed the finish line
@ -138,18 +152,18 @@ proc advance*(b: var Board, die: Die) =
if prepend: if prepend:
b[startPos].camels.moveSubstackPre(b[endPos].camels, stackStart) b[startPos].camels.moveSubstackPre(b[endPos].camels, stackStart)
let stackLen = b[startPos].camels.len - stackStart let stackLen = b[startPos].camels.len - stackStart
for i in 0 ..< stackLen: for i in 0'i8 ..< stackLen:
# we know how many camels we added to the bottom, so set the position for each of those # we know how many camels we added to the bottom, so set the position for each of those
b.camels[b[endPos].camels[i]] = endPos b.camels[b[endPos].camels[i]] = CamelPos(square: endPos, stackIdx: i) # replace with cast later?
else: else:
let dstPrevHigh = b[endPos].camels.high let dstPrevHigh = b[endPos].camels.high
b[startPos].camels.moveSubstack(b[endPos].camels, stackStart) b[startPos].camels.moveSubstack(b[endPos].camels, stackStart)
# the camels that have moved start immediately after the previous high camel # the camels that have moved start immediately after the previous high camel
for i in (dstPrevHigh + 1) .. b[endPos].camels.high: for i in (dstPrevHigh + 1) .. b[endPos].camels.high:
b.camels[b[endPos].camels[i]] = endPos b.camels[b[endPos].camels[i]] = CamelPos(square: endPos, stackIdx: i)
# if we are stacking on or moving past the previous leader # if we are stacking on or moving past the previous leader
if endPos >= b.camels[b.leader.get]: if endPos >= b.camels[b.leader.get].square:
b.leader = some(b[endPos].camels[^1]) b.leader = some(b[endPos].camels[^1])
b.diceRolled[color] = true b.diceRolled[color] = true

View File

@ -4,6 +4,7 @@ import combinators, game, fixedseq
type type
ScoreSet* = array[Color, int] ScoreSet* = array[Color, int]
WinPercents* = array[Color, float]
ScoreSpread = object ScoreSpread = object
lo*: array[Color, float] lo*: array[Color, float]
@ -26,6 +27,12 @@ proc display*(scores: ScoreSet) =
# echo color, ": ", round(100 * scores[color] / total, 2), '%' # echo color, ": ", round(100 * scores[color] / total, 2), '%'
proc percents*(scores: ScoreSet): WinPercents =
let total = scores.sum
for c, score in scores:
result[c] = score / total
# ====================== # ======================
# Single-leg simulations # Single-leg simulations
# ====================== # ======================

View File

@ -1,5 +1,30 @@
import math, random, strformat, times import math, random, strformat, times, std/monotimes
import fixedseq, game, simulation import fixedseq, game, simulation, ui
type
TestResults = object
ops: int
time: Duration
proc summarize(tr: TestResults) =
let secs = tr.time.inMilliseconds.float / 1000
stdout.write("Test completed:\n")
stdout.write(" " & $tr.ops, " operations in " & $round(secs, 2) & " seconds\n")
stdout.write(" " & $round(tr.ops.float / secs, 2) & " operations per second")
stdout.flushFile()
template executionTime(body: untyped): Duration =
let start = getMonoTime()
body
getMonoTime() - start
proc getRand(): Rand =
randomize()
result = initRand(rand(int64))
proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] = proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
@ -9,18 +34,24 @@ proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
result.shuffle result.shuffle
proc testGames(n: SomeInteger = 100): auto = proc newRandomGame(r: var Rand): Board =
var r = initRand(rand(int64)) var dice: array[5, tuple[c: Color, p: int]]
let dice = randomDice(r) for i in 0 .. 4:
var b: Board dice[i] = (Color(i), r.rand(1..3))
b.init
b.setState(dice, [])
b.display(1, 5)
let startTime = cpuTime() result.init
let scores = b.randomGames(n) result.setState(dice, [])
result = cpuTime() - startTime
scores.display()
proc games(nTests, nSamples: SomeInteger, parallel = true): TestResults =
var r = getRand()
var scores: ScoreSet
for i in 1 .. nTests:
let b = newRandomGame(r)
let dur = executionTime:
let s = b.randomGames(nSamples, parallel = parallel)
result.ops += s.sum()
result.time += dur
proc testLegs(n: Natural = 100): auto = proc testLegs(n: Natural = 100): auto =
@ -63,17 +94,4 @@ proc testSpread(nTests, nSamples: Natural) =
when isMainModule: when isMainModule:
randomize() games(10, 10_000_000).summarize()
# let start_states = 2_000
# let executionTime = testLegs(start_states)
# echo "Execution time: ", executionTime
# echo "Leg simulations per second: ", float(start_states * 29_160) / executionTime
# for i in 1 .. 1:
# let num_games = 100_000_005
# let executionTime = testGames(num_games)
# echo "Execution time: ", executionTime
# echo "Full-game simulations per second: ", float(num_games) / executionTime
# echo ""
testSpread(100, 1_000_000)

59
ui.nim
View File

@ -1,5 +1,5 @@
import os, strutils import os, math, strutils, strformat
import game import fixedseq, game, simulation
const help = const help =
@ -19,6 +19,11 @@ Options:
-h Show this message and exit -h Show this message and exit
""" """
# =============================
# User input parsing/validation
# =============================
type type
CmdConfig* = object CmdConfig* = object
state*: seq[tuple[c: Color, p: int]] state*: seq[tuple[c: Color, p: int]]
@ -63,3 +68,53 @@ proc parseArgs*(): CmdConfig =
for c in p: for c in p:
let color = parseColor(c) let color = parseColor(c)
result.diceRolled[color] = true result.diceRolled[color] = true
# ==========================
# Game state representations
# ==========================
proc showSpaces*(b: Board; start, stop: Natural): string =
let numSpaces = stop - start + 1
let width = 4 * numSpaces - 1
var lines: array[7, string]
# start by building up an empty board
for i in 0 .. 6: # gotta initialize the strings
lines[i] = newString(width)
for c in lines[i].mitems:
c = ' '
# fill in the dividers
lines[5] = repeat("=== ", numSpaces - 1)
lines[5].add("===")
# now populate the board
for sp in 0 ..< numSpaces:
# fill in the square numbers
let squareNum = sp + start
let cellMid = 4 * sp + 1
for i, chr in $squareNum:
lines[6][cellMid + i] = chr
# fill in the camel stacks
for i, color in b.squares[squareNum].camels:
let lineNum = 4 - i # lines go to 6, but bottom 2 are reserved
let repr = '|' & color.abbrev & '|'
for j, chr in repr:
lines[lineNum][cellMid - 1 + j] = chr
result = lines.join("\n")
proc showPercents*(scores: ScoreSet): string =
var lines: array[5, string]
for color, pct in scores.percents:
let label = align($color, 7) # e.g. " Green"
var bar = repeat(" ", 20)
let percentage = round(pct * 100, 2)
# populate the progress bar
let barFill = int(round(pct * 100 / 20))
for i in 0 ..< barFill:
bar[i] = '='
lines[int(color)] = fmt"{label}: [{bar}] {percentage}%"
result = lines.join("\n")