5 Commits

10 changed files with 348 additions and 40 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

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

@ -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]

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."

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,5 @@
import math, random, strformat, times import math, random, strformat, times
import fixedseq, game, simulation import fixedseq, game, simulation, ui
proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] = proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
@ -9,6 +9,15 @@ proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
result.shuffle 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 = proc testGames(n: SomeInteger = 100): auto =
var r = initRand(rand(int64)) var r = initRand(rand(int64))
let dice = randomDice(r) let dice = randomDice(r)
@ -18,7 +27,7 @@ proc testGames(n: SomeInteger = 100): auto =
b.display(1, 5) b.display(1, 5)
let startTime = cpuTime() let startTime = cpuTime()
let scores = b.randomGames(n) let scores = b.randomGames(n, parallel = true)
result = cpuTime() - startTime result = cpuTime() - startTime
scores.display() scores.display()
@ -64,16 +73,23 @@ proc testSpread(nTests, nSamples: Natural) =
when isMainModule: when isMainModule:
randomize() 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 start_states = 2_000
# let executionTime = testLegs(start_states) # let executionTime = testLegs(start_states)
# echo "Execution time: ", executionTime # echo "Execution time: ", executionTime
# echo "Leg simulations per second: ", float(start_states * 29_160) / executionTime # echo "Leg simulations per second: ", float(start_states * 29_160) / executionTime
# for i in 1 .. 1: for i in 1 .. 1:
# let num_games = 100_000_005 let num_games = 100_000_005
# let executionTime = testGames(num_games) let executionTime = testGames(num_games)
# echo "Execution time: ", executionTime echo "Execution time: ", executionTime
# echo "Full-game simulations per second: ", float(num_games) / executionTime echo "Full-game simulations per second: ", float(num_games) / executionTime
# echo "" echo ""
testSpread(100, 1_000_000) # 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")