8 Commits

11 changed files with 563 additions and 137 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
```

17
colors.nim Normal file
View File

@ -0,0 +1,17 @@
type
Color* = enum
cRed, cGreen, cBlue, cYellow, cPurple
const
colorNames: array[Color, string] =
["Red", "Green", "Blue", "Yellow", "Purple"]
colorAbbrevs: array[Color, char] = ['R', 'G', 'B', 'Y', 'P']
proc `$`*(c: Color): string =
result = colorNames[c]
proc abbrev*(c: Color): char =
result = colorAbbrevs[c]

5
config.nims Normal file
View File

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

17
cup.nim Normal file
View File

@ -0,0 +1,17 @@
import game, simulation, ui
when isMainModule:
let config = parseArgs()
var b: Board
b.init
b.setState(config.state, [])
b.diceRolled = config.diceRolled
echo b.showSpaces(1, 16)
let legScores = b.getLegScores
echo "\nCurrent leg probabilities:"
echo legScores.showPercents()
let gameScores = b.randomGames(1_000_000)
echo "\nFull game probabilities (1M simulations):"
echo gameScores.showPercents()

View File

@ -25,6 +25,16 @@ proc `$`*(s: FixedSeq): string =
result.add("]") result.add("]")
proc `==`*[T1: FixedSeq, T2: FixedSeq](a: T1, b: T2): bool =
# generics are so that we can compare ShiftStack vs regular FixedSeq
if a.len != b.len:
return false
for i in 0 ..< a.len:
if a.data[i] != b.data[i]:
return false
return true
proc `[]`*(s: FixedSeq, i: Natural): FixedSeq.Contents = proc `[]`*(s: FixedSeq, i: Natural): FixedSeq.Contents =
if i > s.last: if i > s.last:
raise newException(IndexDefect, "index " & $i & " is out of bounds.") raise newException(IndexDefect, "index " & $i & " is out of bounds.")
@ -124,7 +134,7 @@ proc shuffle*(s: var FixedSeq, r: var Rand) =
proc moveSubstack*(src, dst: var FixedSeq; start: Natural) = proc moveSubstack*(src, dst: var FixedSeq; start: Natural) =
var count: typeof(src.last) = 0 # have to track this separately apparently var count: FixedSeq.Pointer = 0 # have to track this separately apparently
for idx in start .. src.last: for idx in start .. src.last:
swap(src.data[idx], dst.data[dst.last + 1 + count]) swap(src.data[idx], dst.data[dst.last + 1 + count])
inc count inc count
@ -133,7 +143,7 @@ proc moveSubstack*(src, dst: var FixedSeq; start: Natural) =
proc moveSubstackPre*(src, dst: var FixedSeq; start: Natural) = proc moveSubstackPre*(src, dst: var FixedSeq; start: Natural) =
let ssLen = typeof(src.last)(src.last - start + 1) # length of substack let ssLen = FixedSeq.Pointer(src.last - start + 1) # length of substack
for i in countdown(dst.last, 0): for i in countdown(dst.last, 0):
swap(dst.data[i], dst.data[i + ssLen]) swap(dst.data[i], dst.data[i + ssLen])
@ -144,3 +154,5 @@ proc moveSubstackPre*(src, dst: var FixedSeq; start: Natural) =
dst.last += ssLen dst.last += ssLen
src.last -= ssLen src.last -= ssLen
include shiftstack

View File

@ -1,11 +1,9 @@
import hashes, options import hashes, options
import fixedseq import fixedseq, colors
export colors
type type
Color* = enum
cRed, cGreen, cBlue, cYellow, cPurple
ColorStack* = FixedSeq[5, Color, int8] ColorStack* = FixedSeq[5, Color, int8]
@ -13,21 +11,7 @@ proc initColorStack*: ColorStack =
result.initFixedSeq result.initFixedSeq
proc getAllColors: ColorStack = proc `$`*[T](s: FixedSeq[T, Color, int8]): string =
var i = 0
for c in Color.low .. Color.high:
result[i] = c
const allColors* = getAllColors()
const colorNames: array[Color, string] =
["Red", "Green", "Blue", "Yellow", "Purple"]
proc `$`*(c: Color): string =
result = colorNames[c]
proc `$`*(s: ColorStack): string =
result.add("St@[") result.add("St@[")
for i, color in s: for i, color in s:
result.add($color) result.add($color)
@ -44,8 +28,8 @@ type
tForward = 1 tForward = 1
Square* = object Square* = object
camels: ColorStack camels*: ShiftStack
tile: Option[Tile] tile*: Option[Tile]
Board* = object Board* = object
squares*: array[1..16, Square] squares*: array[1..16, Square]
@ -86,9 +70,9 @@ proc display*(b: Board, start, stop: int) =
let sq = b.squares[i] let sq = b.squares[i]
let lead = $i & ": " let lead = $i & ": "
if sq.tile.isSome: if sq.tile.isSome:
echo lead, sq.tile.get stdout.writeLine($lead & $sq.tile.get)
else: else:
echo lead, sq.camels stdout.writeLine($lead & $sq.camels)
echo "" echo ""

110
main.nim
View File

@ -1,110 +0,0 @@
import math, options, sequtils, random, sets
import combinators, game, fixedseq, ui
type
ScoreSet* = array[Color, int]
ScoreSpread = object
lo: array[Color, float]
hi: array[Color, float]
LegResults* = tuple[scores: ScoreSet, endStates: HashSet[Board]]
proc update*(scores: var ScoreSet, toAdd: ScoreSet) =
for i, s in toAdd:
scores[i] += s
proc display*(scores: ScoreSet) =
let total = scores.sum
for color, score in scores:
echo color, ": ", round(100 * scores[color] / total, 2), '%'
proc projectLeg*(b: Board): LegResults =
var scores: ScoreSet
var endStates: HashSet[Board]
var diceRemaining: ColorStack
diceRemaining.initFixedSeq
for i, c in b.diceRolled:
if not c: diceRemaining.add(i)
for future in possibleFutures(diceRemaining):
var prediction = b # make a copy
for dieRoll in future:
prediction.advance(dieRoll)
inc scores[prediction.leader.get]
# deduplicate results
endStates.incl(prediction)
result = (scores, endStates)
proc projectOutcomes(b: Board, maxDepth = 1): ScoreSet =
var outcomeStack = @[ [b].toHashSet ]
for depth in 1..maxDepth:
echo "simulating ", outcomeStack[^1].len, " possible legs."
var endStates: HashSet[Board]
for o in outcomeStack[^1]:
var o = o # make it mutable
if outcomeStack.len > 1:
o.resetDice # o was describina an end-of-leg state, so dice were exhausted
let projection = o.projectLeg
result.update(projection[0])
endStates.incl(projection[1])
stdout.write("simulated: " & $result.sum & "\r")
outcomeStack.add(endStates)
echo "\nDistinct end states: ", outcomeStack.mapIt(it.len).sum
proc randomGame(b: Board, r: var Rand): Color =
var projection = b
while true:
for roll in randomFuture(projection.diceRemaining, r):
projection.advance(roll)
if projection.gameOver:
return projection.leader.get
projection.resetDice
proc randomGames(b: Board, count: SomeInteger): ScoreSet =
randomize()
var r = initRand(rand(int64))
for i in 1 .. count:
let winner = b.randomGame(r)
inc result[winner]
# if i mod 100_000 == 0 or i == count - 1:
# stdout.write("simulating " & count & "random games: " & $i & "\r")
# echo ""
proc randomSpread(b: Board, nTests: SomeInteger, nSamples: SomeInteger): ScoreSpread =
for s in result.lo.mitems:
s = 1
for i in 0 ..< nTests:
let scores = b.randomGames(nSamples)
let total = scores.sum
for color, score in scores:
let pct = score / total
if pct < result.lo[color]:
result.lo[color] = pct
if pct > result.hi[color]:
result.hi[color] = pct
when isMainModule:
let config = parseArgs()
var b: Board
b.init
b.setState(config.state, [])
b.diceRolled = config.diceRolled
b.display(1, 5)
let scores = b.projectLeg()[0]
scores.display

191
shiftstack.nim Normal file
View File

@ -0,0 +1,191 @@
# optimized bit-shifting versions of the FixedSequence substack operations
import bitops, macros
import colors
macro show(expr: untyped) =
let node = expr.toStrLit
quote do:
echo `node`, " => ", `expr`
proc getMasks(): (array[9, uint64], array[9, uint64]) =
# on little-endian architectures, casting an array[8, Color] to uint64 effectively
# reverses it. So we switch these masks so that we can refer to them consistently.
let
left = [
0'u64,
0xff_00_00_00_00_00_00_00'u64,
0xff_ff_00_00_00_00_00_00'u64,
0xff_ff_ff_00_00_00_00_00'u64,
0xff_ff_ff_ff_00_00_00_00'u64,
0xff_ff_ff_ff_ff_00_00_00'u64,
0xff_ff_ff_ff_ff_ff_00_00'u64,
0xff_ff_ff_ff_ff_ff_ff_00'u64,
0xff_ff_ff_ff_ff_ff_ff_ff'u64,
]
right = [
0'u64,
0x00_00_00_00_00_00_00_ff'u64,
0x00_00_00_00_00_00_ff_ff'u64,
0x00_00_00_00_00_ff_ff_ff'u64,
0x00_00_00_00_ff_ff_ff_ff'u64,
0x00_00_00_ff_ff_ff_ff_ff'u64,
0x00_00_ff_ff_ff_ff_ff_ff'u64,
0x00_ff_ff_ff_ff_ff_ff_ff'u64,
0xff_ff_ff_ff_ff_ff_ff_ff'u64,
]
when cpuEndian == bigEndian:
result = (left, right)
when cpuEndian == littleEndian:
result = (right, left)
type ShiftStack* = FixedSeq[8, Color, int8]
const (masksLeft, masksRight) = getMasks()
template `shl`(a: array[8, Color], offset: Natural): array[8, Color] =
when cpuEndian == bigEndian:
cast[array[8, Color]](cast[uint64](a) shl (offset * 8))
when cpuEndian == littleEndian: # direction is reversed
cast[array[8, Color]](cast[uint64](a) shr (offset * 8))
template `shr`(a: array[8, Color], offset: Natural): array[8, Color] =
when cpuEndian == bigEndian:
cast[array[8, Color]](cast[uint64](a) shr (offset * 8))
when cpuEndian == littleEndian:
cast[array[8, Color]](cast[uint64](a) shl (offset * 8))
template `and`(a: array[8, Color], mask: uint64): array[8, Color] =
cast[array[8, Color]](cast[uint64](a) and mask)
template `or`(a: array[8, Color], mask: uint64): array[8, Color] =
cast[array[8, Color]](cast[uint64](a) or mask)
template `or`(a: array[8, Color], mask: array[8, Color]): array[8, Color] =
cast[array[8, Color]](cast[uint64](a) or cast[uint64](mask))
import strutils # remove later
proc moveSubstack*(src, dst: var ShiftStack; start: Natural) =
# shift the source stack to position the substack above its final resting place
# offset is the length of the destination stack, minus the number of items NOT being moved
# number of items not being moved is the same as the start index
var substack: array[8, Color]
if dst.len == start: # no shift necessary in this case
substack = src.data
elif dst.len > start:
substack = src.data shr (dst.len - start)
elif dst.len < start:
substack = src.data shl (start - dst.len)
# next, mask the source data to present only the items being moved
# dst.len of 0 corresponds to last mask in masksRight, aka masksRight[^1]
substack = substack and masksRight[^(dst.len + 1)]
# then combine
dst.data = dst.data or substack
# then git rid of the moved items from the source stack
src.data = src.data and masksLeft[start]
# a little bookkeeping
let ssLen = int8(src.len - start)
src.last -= ssLen
dst.last += ssLen
proc moveSubstackPre*(src, dst: var ShiftStack; start: Natural) =
let ssLen = int8(src.len - start)
# shift the destination stack to make room for the new items
dst.data = dst.data shr ssLen
# shift source stack to line up the substack with its final resting place
let substack = src.data shl start
# combine
dst.data = dst.data or substack
# get rid of the moved items
src.data = src.data and masksLeft[start]
# more bookkeeping
src.last -= ssLen
dst.last += ssLen
proc testMove[T1, T2: FixedSeq](a1, a2: var T1; b1, b2: var T2; i: Natural): bool =
let (orig_a1, orig_a2) = (a1, a2)
let (orig_b1, orig_b2) = (b1, b2)
a1.moveSubstack(a2, i)
b1.moveSubstack(b2, i)
if a1 != b1 or a2 != b2:
echo "Failed!"
show orig_b1
show orig_b2
echo "<<move ", i, ">>"
show b1
show b2
return false
return true
when isMainModule:
var c1 = initFixedSeq(5, Color, int8)
var c2 = initFixedSeq(5, Color, int8)
var s1: ShiftStack
s1.initFixedSeq
var s2: ShiftStack
s2.initFixedSeq
c1.add(cPurple)
c1.add(cRed)
c1.add(cYellow)
c1.add(cBlue)
c1.add(cGreen)
s1.add(cPurple)
s1.add(cRed)
s1.add(cYellow)
s1.add(cBlue)
s1.add(cGreen)
# show s1
# show s2
# echo "<<move 2>>"
# s1.moveSubstack(s2, 2)
# show s1
# show s2
import random
randomize()
var r = initRand(rand(int64))
var success = true
for n in 1 .. 1_000_000:
var ranFirst, ranSecond: bool
if c1.len > 0:
let i = r.rand(c1.high)
ranFirst = true
if not testMove(c1, c2, s1, s2, i):
success = false
echo "Failed after ", n, " iterations."
break
else:
ranFirst = false
if c2.len > 0:
let j = r.rand(c2.high)
ranSecond = true
if not testMove(c2, c1, s2, s1, j):
success = false
echo "Failed after ", n, " iterations."
break
else:
ranSecond = false
if (not ranFirst) and (not ranSecond):
echo "Ran neither first nor second move."
break
if success:
echo "Success."

138
simulation.nim Normal file
View File

@ -0,0 +1,138 @@
import cpuinfo, math, options, random, tables
import combinators, game, fixedseq
type
ScoreSet* = array[Color, int]
WinPercents* = array[Color, float]
ScoreSpread = object
lo*: array[Color, float]
hi*: array[Color, float]
LegResults* = tuple[scores: ScoreSet, endStates: CountTable[Board]]
proc update*(scores: var ScoreSet, toAdd: ScoreSet) =
for i, s in toAdd:
scores[i] += s
proc display*(scores: ScoreSet) =
let total = scores.sum
for color, score in scores:
let line = $color & ": " & $round(100 * scores[color] / total, 2) & '%'
stdout.writeLine(line)
stdout.flushFile()
# 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
# ======================
iterator legEndStates(b: Board): Board =
var diceRemaining: ColorStack
diceRemaining.initFixedSeq
for i, c in b.diceRolled:
if not c: diceRemaining.add(i)
for future in possibleFutures(diceRemaining):
var prediction = b # make a copy so we can mutate
for dieRoll in future:
prediction.advance(dieRoll)
yield prediction
proc getLegScores*(b: Board): ScoreSet =
for prediction in b.legEndStates:
inc result[prediction.leader.get]
# =====================
# Full-game simulations
# =====================
proc randomGame*(b: Board, r: var Rand): Color =
var projection = b
while true:
for roll in randomFuture(projection.diceRemaining, r):
projection.advance(roll)
if projection.gameOver:
return projection.leader.get
projection.resetDice()
proc randomGamesWorker(b: Board, count: Natural, r: var Rand): ScoreSet =
for i in 1 .. count:
let winner = b.randomGame(r)
inc result[winner]
# =======================
# Multithreading nonsense
# =======================
type WorkerArgs = object
board: Board
count: Natural
seed: int64
# have to do this at the module level so it can be shared
var gamesChannel: Channel[ScoreSet]
gamesChannel.open()
proc randomGamesThread(args: WorkerArgs) =
var r = initRand(args.seed)
let scores = randomGamesWorker(args.board, args.count, r)
gamesChannel.send(scores)
proc randomGames*(b: Board, count: Natural, parallel = true, numThreads = 0): ScoreSet =
randomize()
if not parallel:
var r = initRand(rand(int64))
return randomGamesWorker(b, count, r)
let numThreads =
if numThreads == 0:
countProcessors()
else:
numThreads
var workers = newSeq[Thread[WorkerArgs]](numThreads)
for i, w in workers.mpairs:
var numGames = int(floor(count / numThreads))
if i < (count mod numThreads):
numGames += 1
let args = WorkerArgs(board: b, count: numGames, seed: rand(int64))
createThread(w, randomGamesThread, args)
for i in 1 .. numThreads:
let scores = gamesChannel.recv()
result.update(scores)
proc randomSpread*(b: Board; nTests, nSamples: Natural): ScoreSpread =
for s in result.lo.mitems:
s = 1
for i in 0 ..< nTests:
let scores = b.randomGames(nSamples)
let total = scores.sum
for color, score in scores:
let pct = score / total
if pct < result.lo[color]:
result.lo[color] = pct
if pct > result.hi[color]:
result.hi[color] = pct

95
test.nim Normal file
View File

@ -0,0 +1,95 @@
import math, random, strformat, times
import fixedseq, game, simulation, ui
proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
for c in Color:
let v = r.rand(1..3)
result.add((c, v))
result.shuffle
proc newRandomGame(r: var Rand): Board =
var dice: array[5, tuple[c: Color, p: int]]
for i in 0 .. 4:
dice[i] = (Color(i), r.rand(1..3))
result.init
result.setState(dice, [])
proc testGames(n: SomeInteger = 100): auto =
var r = initRand(rand(int64))
let dice = randomDice(r)
var b: Board
b.init
b.setState(dice, [])
b.display(1, 5)
let startTime = cpuTime()
let scores = b.randomGames(n, parallel = true)
result = cpuTime() - startTime
scores.display()
proc testLegs(n: Natural = 100): auto =
var boards: seq[Board]
var r = initRand(rand(int64))
for i in 1 .. n:
var b: Board
b.init
let dice = randomDice(r)
b.setState(dice, [])
boards.add(b)
stdout.write("Constructed: " & $i & "\r")
echo ""
echo "Running..."
let start = cpuTime()
for b in boards:
discard b.getLegScores
result = cpuTime() - start
proc testSpread(nTests, nSamples: Natural) =
var b: Board
b.init
var r = initRand(rand(int64))
let dice = randomDice(r)
b.setState(dice, [])
b.display(1, 5)
let spread = randomSpread(b, nTests, nSamples)
stdout.writeLine("Variance:")
for c in Color:
let variance = 100 * (spread.hi[c] - spread.lo[c])
stdout.writeLine(fmt"{c}: {round(variance, 2):.2f}%")
let diff = 100 * (max(spread.hi) - min(spread.lo))
stdout.writeLine(fmt"Win percentage differential: {round(diff, 2):.2f}%")
stdout.flushFile()
when isMainModule:
randomize()
# var r = initRand(rand(int64))
# let b = newRandomGame(r)
# b.display(1, 5)
# echo b.showSpaces(1, 16)
# let scores = b.getLegScores
# echo scores.showPercents
# 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")