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

View File

@ -25,6 +25,16 @@ proc `$`*(s: FixedSeq): string =
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 =
if i > s.last:
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) =
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:
swap(src.data[idx], dst.data[dst.last + 1 + count])
inc count
@ -133,7 +143,7 @@ proc moveSubstack*(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):
swap(dst.data[i], dst.data[i + ssLen])
@ -144,3 +154,5 @@ proc moveSubstackPre*(src, dst: var FixedSeq; start: Natural) =
dst.last += ssLen
src.last -= ssLen
include shiftstack

View File

@ -1,11 +1,9 @@
import hashes, options
import fixedseq
import fixedseq, colors
export colors
type
Color* = enum
cRed, cGreen, cBlue, cYellow, cPurple
ColorStack* = FixedSeq[5, Color, int8]
@ -13,21 +11,7 @@ proc initColorStack*: ColorStack =
result.initFixedSeq
proc getAllColors: ColorStack =
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 =
proc `$`*[T](s: FixedSeq[T, Color, int8]): string =
result.add("St@[")
for i, color in s:
result.add($color)
@ -44,8 +28,8 @@ type
tForward = 1
Square* = object
camels: ColorStack
tile: Option[Tile]
camels*: ShiftStack
tile*: Option[Tile]
Board* = object
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
ScoreSet* = array[Color, int]
WinPercents* = array[Color, float]
ScoreSpread = object
lo*: array[Color, float]
@ -26,6 +27,12 @@ proc display*(scores: ScoreSet) =
# 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
# ======================

View File

@ -1,5 +1,5 @@
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]] =
@ -9,6 +9,15 @@ proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
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)
@ -18,7 +27,7 @@ proc testGames(n: SomeInteger = 100): auto =
b.display(1, 5)
let startTime = cpuTime()
let scores = b.randomGames(n)
let scores = b.randomGames(n, parallel = true)
result = cpuTime() - startTime
scores.display()
@ -64,16 +73,23 @@ proc testSpread(nTests, nSamples: Natural) =
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 ""
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)
# testSpread(100, 1_000_000)

59
ui.nim
View File

@ -1,5 +1,5 @@
import os, strutils
import game
import os, math, strutils, strformat
import fixedseq, game, simulation
const help =
@ -19,6 +19,11 @@ Options:
-h Show this message and exit
"""
# =============================
# User input parsing/validation
# =============================
type
CmdConfig* = object
state*: seq[tuple[c: Color, p: int]]
@ -63,3 +68,53 @@ proc parseArgs*(): CmdConfig =
for c in p:
let color = parseColor(c)
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")