9 Commits

9 changed files with 410 additions and 60 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 fixedseq, game
import faststack, fixedseq, game
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

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

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

View File

@ -1,9 +1,10 @@
import cpuinfo, math, options, random, tables
import combinators, game, fixedseq
import combinators, game, faststack, fixedseq
type
ScoreSet* = array[Color, int]
WinPercents* = array[Color, float]
ScoreSpread = object
lo*: array[Color, float]
@ -26,12 +27,18 @@ 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
# ======================
iterator legEndStates(b: Board): Board =
var diceRemaining: ColorStack
var diceRemaining: FixedSeq[5, Color, int8]
diceRemaining.initFixedSeq
for i, c in b.diceRolled:
if not c: diceRemaining.add(i)
@ -52,6 +59,39 @@ proc getLegScores*(b: Board): ScoreSet =
# 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:

View File

@ -1,5 +1,30 @@
import math, random, strformat, times
import fixedseq, game, simulation
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]] =
@ -9,18 +34,24 @@ proc randomDice(r: var Rand): seq[tuple[c: Color, p: int]] =
result.shuffle
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)
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))
let startTime = cpuTime()
let scores = b.randomGames(n)
result = cpuTime() - startTime
scores.display()
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 =
@ -63,17 +94,4 @@ proc testSpread(nTests, nSamples: Natural) =
when isMainModule:
randomize()
# 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)
games(10, 10_000_000).summarize()

59
ui.nim
View File

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