12 Commits

10 changed files with 605 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
```

View File

@ -1,5 +1,5 @@
import algorithm, random, sugar import algorithm, random, sugar
import fixedseq, game import faststack, fixedseq, game
proc nextPermutation(x: var FixedSeq): bool = proc nextPermutation(x: var FixedSeq): bool =

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()

204
faststack.nim Normal file
View File

@ -0,0 +1,204 @@
import bitops, strutils, random
proc show(i: SomeInteger, bitlength = 16) =
echo BiggestInt(i).toBin(bitlength)
type
Color* = enum
cRed, cGreen, cBlue, cYellow, cPurple
ColorStack* = object
data: uint16
len*: uint8
const
masks = [
0'u16, # dummy value just to get the indices right
0b0_000_000_000_000_111,
0b0_000_000_000_111_111,
0b0_000_000_111_111_111,
0b0_000_111_111_111_111,
0b0_111_111_111_111_111,
]
allColors* = ColorStack(len: 5, data: 0b0_000_001_010_011_100)
template offset(s: ColorStack, idx: Natural): uint8 =
# Compute the bit offset for a given index.
# Dependent on the stack's length.
(s.len - 1 - idx.uint8) * 3
# items are stored from left to right but right-aligned, so we
# need to shift right to access anything other than the last
template offset(s: ColorStack, idx: BackwardsIndex): uint8 =
# for backwards index, we are still shifting right but
# the lower the index the less we have to shift
(idx.uint8 - 1) * 3
proc add*(s: var ColorStack, c: Color) =
# e.g. if stack is 0b0000000000000100:
# and color is 0b00000011
# shift: 0b0000000000100000
# bitor: 0b0000000000100000 and 0b00000011
# results in 0b0000000000100011
s.data = (s.data shl 3).bitor(cast[uint8](c))
inc s.len
proc high*(s: ColorStack): uint8 = s.len - 1
proc low*(s: ColorStack): uint8 = 0 # just... always 0, I guess
proc `[]`*(s: ColorStack, i: uint8 | BackwardsIndex): Color =
# shift, then mask everything but the three rightmost bits
result = Color(
(s.data shr s.offset(i)) and masks[1]
)
proc `[]=`*(s: var ColorStack, i: uint8 | BackwardsIndex, c: Color) =
let offset = s.offset(i)
s.data = (s.data and bitnot(masks[1] shl offset)) or (c.uint16 shl offset)
iterator items*(s: ColorStack): Color =
# s.len is unsigned so it will wrap around if we do s.len - 1 in that case
if s.len != 0:
for i in countdown(s.len - 1, 0'u8):
yield Color((s.data shr (i * 3)) and masks[1])
iterator pairs*(s: ColorStack): (uint8, Color) =
var count = 0'u8
for color in s:
yield (count, color)
inc count
proc find*(s: ColorStack, needle: Color): int8 =
for i in 0'u8 .. s.high:
if s[i] == needle:
return i.int8
return -1
proc moveSubstack*(src, dst: var ColorStack, startIdx: uint8) =
if startIdx >= src.len:
raise newException(IndexDefect, "index " & $startIdx & " is out of bounds.")
# Moves a sub-stack from the top of src to the top of dst
# shift the dst stack by the length of the substack to make room
let nToMove = src.len - startIdx
let shift = nToMove * 3
dst.data = dst.data shl shift
# then we mask the source data to present only the items
# being moved, and OR that with the shifted dst data
dst.data = dst.data or (src.data and masks[nToMove])
dst.len += nToMove
# then we shift the source to get rid of the moved items
src.data = src.data shr shift
src.len -= nToMove
proc moveSubstackPre*(src, dst: var ColorStack, startIdx: uint8) =
if startIdx >= src.len:
raise newException(IndexDefect, "index " & $startIdx & " is out of bounds.")
# Moves a sub-stack from the top of src to the bottom of dst
let nToMove = src.len - startIdx
# shift src to position the substack above its destination,
# get rid of everything to the left of the substack,
# and OR that with the existing dst data
let newLen = dst.len + nToMove
dst.data = dst.data or ( (src.data shl (dst.len * 3)) and masks[newLen] )
dst.len = newLen
# get rid of the substack we just moved
src.data = src.data shr (nToMove * 3)
src.len -= nToMove
proc swap*(s: var ColorStack, i1, i2: uint8) =
# Swap the values at two indices in the stack
if i1 == i2: return
# i1 and i2 are unsigned, so we have to watch out for underflows
let diff = if i1 > i2:
(i1 - i2) * 3
else:
(i2 - i1) * 3
# take masks[1] from above (rightmost position) and shift to position of i1.
# then do the same for i2, and OR them together.
let mask = (masks[1] shl s.offset(i1)) or (masks[1] shl s.offset(i2))
# get rid of everything but the two values we're swapping
let masked = s.data and mask
# shift by the distance between values in both directions, combine, then mask
let swapped = ((masked shl diff) or (masked shr diff)) and mask
# finally, AND with the inverse of mask so that only the values being
# swapped are erased, and combine that with the swapped values
s.data = (s.data and mask.bitnot) or swapped
proc shuffle*(r: var Rand, s: var ColorStack) =
# Fisher-Yates shuffle
for i in countdown(s.high, 1'u8):
let j = r.rand(i).uint8
if j != i:
s.swap(i, j)
proc reverse*(s: var ColorStack, first, last: uint8) =
var x = first
var y = last
while x < y:
s.swap(x, y)
inc x
dec y
iterator asInt*(s: ColorStack): int8 =
for i in 0'u8 .. s.high:
yield int8(s[i]) # now we do have to convert
proc `$`*(s: ColorStack): string =
result = "St@["
for c in s:
if result[^1] != '[':
result.add(", ")
result.add($c)
result.add("]")
proc check(s: ColorStack) =
# ensure length is accurate
var d = s.data
for i in 0'u8 .. 4'u8:
if (d and masks[1]) > 4:
raise newException(RangeDefect, "Value out of range.")
if d > 0 and i >= s.len:
raise newException(RangeDefect, "Invalid length.")
else:
d = d shr 3
when isMainModule:
var one: ColorStack
one.add(cRed)
one.add(cGreen)
one.add(cBlue)
one.add(cYellow)
one.add(cPurple)
var two: ColorStack
one.moveSubstack(two, 2)
echo one, " ", one.len
echo two, " ", two.len
echo two.find(cRed)

View File

@ -1,32 +1,39 @@
import hashes, options import hashes, options
import fixedseq import fixedseq, faststack
export faststack.Color, faststack.ColorStack, faststack.allColors
type # type
Color* = enum # Color* = enum
cRed, cGreen, cBlue, cYellow, cPurple # cRed, cGreen, cBlue, cYellow, cPurple
ColorStack* = FixedSeq[5, Color, int8] # ColorStack* = FixedSeq[5, Color, int8]
proc initColorStack*: ColorStack = # proc initColorStack*: ColorStack =
result.initFixedSeq # result.initFixedSeq
proc getAllColors: ColorStack = # proc getAllColors: ColorStack =
var i = 0 # var i = 0'u8
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] = 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,8 +51,8 @@ type
tForward = 1 tForward = 1
Square* = object Square* = object
camels: ColorStack camels*: ColorStack
tile: Option[Tile] tile*: Option[Tile]
Board* = object Board* = object
squares*: array[1..16, Square] squares*: array[1..16, Square]
@ -76,8 +83,8 @@ proc hash*(b: Board): Hash =
proc init*(b: var Board) = proc init*(b: var Board) =
for sq in b.squares.mitems: # for sq in b.squares.mitems:
sq.camels.initFixedSeq # sq.camels.initFixedSeq
b.initialized = true b.initialized = true
@ -86,9 +93,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 ""
@ -106,7 +113,7 @@ proc setState*(b: var Board;
b.leader = some(leadCamel) b.leader = some(leadCamel)
proc diceRemaining*(b: Board): ColorStack = proc diceRemaining*(b: Board): FixedSeq[5, Color, int8] =
result.initFixedSeq result.initFixedSeq
for color, isRolled in b.diceRolled: for color, isRolled in b.diceRolled:
if not isRolled: result.add(color) if not isRolled: result.add(color)
@ -134,11 +141,11 @@ proc advance*(b: var Board, die: Die) =
endPos += int(t) endPos += int(t)
if t == tBackward: prepend = true if t == tBackward: prepend = true
let stackStart = b[startPos].camels.find(color) let stackStart = b[startPos].camels.find(color).uint8
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'u8 ..< 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]] = endPos
else: else:
@ -152,4 +159,4 @@ proc advance*(b: var Board, die: Die) =
if endPos >= b.camels[b.leader.get]: if endPos >= b.camels[b.leader.get]:
b.leader = some(b[endPos].camels[^1]) b.leader = some(b[endPos].camels[^1])
b.diceRolled[color] = true b.diceRolled[color] = true

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

171
simulation.nim Normal file
View File

@ -0,0 +1,171 @@
import cpuinfo, math, options, random, tables
import combinators, game, faststack, 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: FixedSeq[5, Color, int8]
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
# =====================
# get rid of this later
import strutils
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")
# get rid of this later
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

97
test.nim Normal file
View File

@ -0,0 +1,97 @@
import math, random, strformat, times, std/monotimes
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]] =
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 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 =
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:
games(10, 10_000_000).summarize()

59
ui.nim
View File

@ -1,5 +1,5 @@
import os, strutils import os, math, strutils, strformat
import game import faststack, 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")