Compare commits

..

6 Commits

16 changed files with 1025 additions and 796 deletions

4
.gitignore vendored
View File

@ -1,2 +1,2 @@
/target
*.etl*
*.exe
profile_results.txt

90
Cargo.lock generated
View File

@ -1,90 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cup"
version = "0.1.0"
dependencies = [
"enum-map",
"fastrand",
]
[[package]]
name = "enum-map"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50c25992259941eb7e57b936157961b217a4fc8597829ddef0596d6c3cd86e1a"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4da76b3b6116d758c7ba93f7ec6a35d2e2cf24feda76c6e38a375f4d5c59f2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "fastrand"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499"
dependencies = [
"instant",
]
[[package]]
name = "instant"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
dependencies = [
"cfg-if",
]
[[package]]
name = "proc-macro2"
version = "1.0.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b"
dependencies = [
"proc-macro2",
]
[[package]]
name = "syn"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc"

View File

@ -1,17 +0,0 @@
[package]
name = "cup"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
enum-map = "2.4.2"
fastrand = "1.8.0"
# [profile.release]
# lto = true
[profile.perf]
inherits = "release"
debug = true

View File

@ -1,12 +1,10 @@
# `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.)
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:

96
combinators.nim Normal file
View File

@ -0,0 +1,96 @@
import algorithm, random, sugar
import faststack, fixedseq, game
proc nextPermutation(x: var FixedSeq): bool =
# copied shamelessly from std/algorithm.nim
if x.len < 2:
return false
var i = x.high
while i > 0 and x[i - 1] >= x[i]:
dec i
if i == 0:
return false
var j = x.high
while j >= i and x[j] <= x[i - 1]:
dec j
swap x[j], x[i - 1]
x.reverse(i, x.high)
result = true
proc prevPermutation(x: var FixedSeq): bool =
# copied shamelessly from std/algorithm.nim
if x.len < 2:
return false
var i = x.high
while i > 0 and x[i - 1] <= x[i]:
dec i
if i == 0:
return false
x.reverse(i, x.high)
var j = x.high
while j >= i and x[j - 1] < x[i - 1]:
dec j
swap x[i - 1], x[j]
result = true
iterator allPermutations*(x: FixedSeq): FixedSeq =
# returns all permutations of a given seq. Order is wonky but we don't care.
var workingCopy = x
yield workingCopy
while workingCopy.nextPermutation: # this mutates workingCopy
yield workingCopy
workingCopy = x
while workingCopy.prevPermutation:
yield workingCopy
iterator allDigits*(lo, hi, size: Natural): auto =
if size > 0: # otherwise we get an infinite loop
var digits: FixedSeq[5, int, int8]
digits.initFixedSeq
for i in 0 ..< size:
digits.add(lo)
var complete = false
while not complete:
yield digits
for i in countdown(digits.high, 0):
if digits[i] < hi:
inc digits[i]
break
elif i == 0: # since this is the last digit to be incremented, we must be done
complete = true
else:
digits[i] = lo
iterator possibleFutures*(dice: FixedSeq): auto =
# iterate over all possible sequences of die rolls. Each outcome
# is returned as a 5-sequence of (color, number) tuples.
for perm in dice.allPermutations:
for digits in allDigits(1, 3, dice.len):
var f = initFixedSeq(5, Die, int8)
for i in 0 .. dice.high:
f.add((perm[i], digits[i]))
yield f
proc randomFuture*(dice: FixedSeq, r: var Rand): FixedSeq[5, Die, int8] =
result.initFixedSeq
let order = dice.dup(shuffle(r))
for i, color in order:
result.add((color, r.rand(1..3)))

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)

146
fixedseq.nim Normal file
View File

@ -0,0 +1,146 @@
import random
type
FixedSeq*[Idx: static int; Contents; Pointer: SomeSignedInt] = object
data: array[Idx, Contents]
last: Pointer
proc initFixedSeq*(size: static Natural; cType: typedesc; pType: typedesc[SomeSignedInt]): auto =
var s: FixedSeq[size, cType, pType]
s.last = -1
result = s
proc initFixedSeq*(s: var FixedSeq) =
s.last = -1
proc `$`*(s: FixedSeq): string =
result.add("FixedSeq[")
for i, item in s:
if i != 0:
result.add(", ")
result.add($item)
result.add("]")
proc `[]`*(s: FixedSeq, i: Natural): FixedSeq.Contents =
if i > s.last:
raise newException(IndexDefect, "index " & $i & " is out of bounds.")
s.data[i]
proc `[]`*(s: var FixedSeq, i: Natural): var FixedSeq.Contents =
if i > s.last:
raise newException(IndexDefect, "index " & $i & " is out of bounds.")
s.data[i]
proc `[]`*(s: FixedSeq, i: BackwardsIndex): auto =
if s.last == -1:
raise newException(IndexDefect, "index out of bounds, the container is empty.") # matching stdlib again
s.data[s.last - typeof(s.last)(i) + 1]
proc `[]=`*(s: var FixedSeq, i: Natural, v: FixedSeq.Contents) =
if i > s.last:
raise newException(IndexDefect, "index " & $i & " is out of bounds.")
s.data[i] = v
proc high*(s: FixedSeq): auto =
result = s.last
proc low*(s: FixedSeq): auto =
result = case s.last
of -1: 0 # a bit weird but it's how the stdlib seq works
else: s.last
proc len*(s: FixedSeq): auto =
result = s.last + 1
iterator items*(s: FixedSeq): auto =
for i in 0 .. s.last:
yield s.data[i]
iterator asInt*(s: FixedSeq): int8 =
for i in 0 .. s.last:
yield int8(s.data[i]) # now we do have to convert
iterator pairs*(s: FixedSeq): auto =
var count = 0
for c in s:
yield (count, c)
inc count
proc add*(s: var FixedSeq, v: FixedSeq.Contents) =
let i = s.last + 1
s.data[i] = v # will raise exception if out of bounds
s.last = i
proc insert*(s: var FixedSeq, v: FixedSeq.Contents, idx: Natural = 0) =
for i in countdown(s.last, typeof(s.last)(idx)):
swap(s.data[i], s.data[i + 1]) # will also raise exception if out of bounds
s.data[idx] = v
inc s.last
proc delete*(s: var FixedSeq, idx: Natural) =
if idx > s.last:
raise newException(IndexDefect, "index " & $idx & " is out of bounds.")
s.data[idx] = -1
dec s.last
for i in typeof(s.last)(idx) .. s.last:
swap(s.data[i], s.data[i + 1])
proc find*(s: FixedSeq, needle: FixedSeq.Contents): FixedSeq.Pointer =
for i, v in s.data:
if v == needle:
return i
return -1
proc reverse*(s: var FixedSeq; first, last: Natural) =
# copied shamelessly from std/algorithm.nim
var x = first
var y = last
while x < y:
swap(s[x], s[y])
inc x
dec y
proc shuffle*(s: var FixedSeq, r: var Rand) =
r.shuffle(s.data)
proc moveSubstack*(src, dst: var FixedSeq; start: Natural) =
var count: typeof(src.last) = 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
dst.last += count
src.last -= count
proc moveSubstackPre*(src, dst: var FixedSeq; start: Natural) =
let ssLen = typeof(src.last)(src.last - start + 1) # length of substack
for i in countdown(dst.last, 0):
swap(dst.data[i], dst.data[i + ssLen])
var count = 0
for i in start .. src.last:
swap(src.data[i], dst.data[count])
inc count
dst.last += ssLen
src.last -= ssLen

162
game.nim Normal file
View File

@ -0,0 +1,162 @@
import hashes, options
import fixedseq, faststack
export faststack.Color, faststack.ColorStack, faststack.allColors
# type
# Color* = enum
# cRed, cGreen, cBlue, cYellow, cPurple
# ColorStack* = FixedSeq[5, Color, int8]
# proc initColorStack*: ColorStack =
# result.initFixedSeq
# proc getAllColors: ColorStack =
# var i = 0'u8
# for c in Color.low .. Color.high:
# result[i] = c
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:
result.add($color)
if i < s.high:
result.add(", ")
result.add("]")
type
Die* = tuple[color: Color, value: int]
Tile* = enum
tBackward = -1,
tForward = 1
Square* = object
camels*: ColorStack
tile*: Option[Tile]
Board* = object
squares*: array[1..16, Square]
camels*: array[Color, range[1..16]]
diceRolled*: array[Color, bool]
leader*: Option[Color]
gameOver*: bool
initialized: bool
# use a template here for better inlining
template `[]`*[T](b: var Board, idx: T): var Square =
b.squares[idx]
proc hash*(b: Board): Hash =
var h: Hash = 0
# there could be a tile anywhere so we have to check all squares
for i, sq in b.squares:
if sq.camels.len > 0 or sq.tile.isSome:
h = h !& i
if sq.tile.isSome:
h = h !& int(sq.tile.get) * 10 # so it isn't confused with a camel
else:
for c in sq.camels.asInt:
h = h !& c
result = !$h
proc init*(b: var Board) =
# for sq in b.squares.mitems:
# sq.camels.initFixedSeq
b.initialized = true
proc display*(b: Board, start, stop: int) =
for i in start..stop:
let sq = b.squares[i]
let lead = $i & ": "
if sq.tile.isSome:
stdout.writeLine($lead & $sq.tile.get)
else:
stdout.writeLine($lead & $sq.camels)
echo ""
proc setState*(b: var Board;
camels: openArray[tuple[c: Color, p: int]];
tiles: openArray[tuple[t: Tile, p: int]]) =
for (color, dest) in camels: # note that `camels` is ordered, as this determines stacking
b[dest].camels.add(color)
b.camels[color] = dest
for (tile, dest) in tiles:
b[dest].tile = some(tile)
let leadCamel = b[max(b.camels)].camels[^1] # top camel in the last currently-occupied space
b.leader = some(leadCamel)
proc diceRemaining*(b: Board): FixedSeq[5, Color, int8] =
result.initFixedSeq
for color, isRolled in b.diceRolled:
if not isRolled: result.add(color)
proc resetDice*(b: var Board) =
for c, rolled in b.diceRolled:
b.diceRolled[c] = false
proc advance*(b: var Board, die: Die) =
let
(color, roll) = die
startPos = b.camels[color]
var endPos = startPos + roll
if endPos > 16: # camel has passed the finish line
b.leader = some(b[startPos].camels[^1])
b.gameOver = true
return
var prepend = false
if b[endPos].tile.isSome: # adjust position (and possibly stacking) to account for tile
let t = b[endPos].tile.get
endPos += int(t)
if t == tBackward: prepend = true
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'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:
let dstPrevHigh = b[endPos].camels.high
b[startPos].camels.moveSubstack(b[endPos].camels, stackStart)
# the camels that have moved start immediately after the previous high camel
for i in (dstPrevHigh + 1) .. b[endPos].camels.high:
b.camels[b[endPos].camels[i]] = endPos
# if we are stacking on or moving past the previous leader
if endPos >= b.camels[b.leader.get]:
b.leader = some(b[endPos].camels[^1])
b.diceRolled[color] = true

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

View File

@ -1,375 +0,0 @@
use enum_map::{Enum, EnumMap};
use fastrand::Rng;
use crate::stack::Stack;
#[derive(Debug, Copy, Clone, Eq, PartialEq, Default, Enum)]
pub enum Color {
#[default] Red, Green, Blue, Yellow, Purple,
}
// const COLORS: Stack<Color, 5> = Stack::from_array([
// Color::Red,
// Color::Green,
// Color::Blue,
// Color::Yellow,
// Color::Purple,
// ]);
const COLORS: [Color; 5] = [
Color::Red,
Color::Green,
Color::Blue,
Color::Yellow,
Color::Purple,
];
type ColorStack = Stack<Color, 5>;
#[derive(Debug, Copy, Clone)]
pub enum Tile {
Forward,
Backward,
}
#[derive(Debug, Copy, Clone)]
pub enum Square {
Camels(ColorStack),
Tile(Tile),
}
impl Square {
fn assume_stack(&self) -> &ColorStack {
match self {
Square::Camels(stack) => stack,
_ => panic!("Attempted to use the stack from a non-stack square"),
}
}
fn assume_stack_mut(&mut self) -> &mut ColorStack {
match self {
Square::Camels(stack) => stack,
_ => panic!("Attempted to use the stack from a non-stack square"),
}
}
}
impl Default for Square {
fn default() -> Self {
Square::Camels(ColorStack::new())
}
}
#[derive(Debug, Default, Copy, Clone)]
pub struct Game {
squares: [Square; 16],
dice: EnumMap<Color, bool>,
camels: EnumMap<Color, usize>,
}
impl Game {
pub fn new() -> Self {
Self::default()
}
// new game with random starting positions
pub fn new_random() -> Self {
let mut game = Self::default();
let rng = Rng::new();
let mut dice = *&COLORS;
rng.shuffle(&mut dice);
for color in dice {
let roll = rng.usize(1..=3);
game.squares[roll - 1].assume_stack_mut().push(color);
game.camels[color] = roll - 1;
}
game
}
pub fn set_state(&mut self, camels: &[(Color, usize); 5], dice: &EnumMap<Color, bool>) {
for i in 0..16 {
self.squares[i] = match self.squares[i] {
Square::Camels(mut stack) => {
stack.clear();
Square::Camels(stack)
},
_ => Square::Camels(Stack::new())
};
}
for square in self.squares {
assert_eq!(square.assume_stack().len(), 0)
}
self.dice = *dice;
for &(color, sq) in camels {
self.squares[sq].assume_stack_mut().push(color);
self.camels[color] = sq;
}
}
pub fn get_state(&self) -> ([(Color, usize); 5], EnumMap<Color, bool>) {
let mut state = [(Color::Red, 0); 5];
let mut j = 0;
for (sq_idx, square) in self.squares.iter().enumerate() {
if let Square::Camels(stack) = square {
for camel in stack.iter() {
state[j] = (*camel, sq_idx);
j += 1;
}
}
}
(state, self.dice)
}
// returns winner if there is one
pub fn advance(&mut self, die: Color, roll: usize) -> Option<Color> {
let src_sq = self.camels[die];
let dst_sq = src_sq + roll;
if dst_sq >= 16 {
self.dice[die] = true;
return self.squares[src_sq].assume_stack().last().copied();
}
// special case when the destination square is the same as the source square
if let Square::Tile(Tile::Backward) = self.squares[dst_sq] {
if roll == 1 {
let src_stack = self.squares[src_sq].assume_stack_mut();
let slice_start = src_stack.iter().position(|&c| c == die).unwrap();
src_stack.shift_slice_under(slice_start);
}
}
else {
// we have to split self.squares into two slices using split_at_mut, otherwise
// rustc complains that we're trying to use two mutable references to the same value
let (left, right) = self.squares.split_at_mut(src_sq + 1);
let src_stack = left[src_sq].assume_stack_mut();
let slice_start = src_stack.iter().position(|&c| c == die).unwrap();
// since `right` starts immediately after the source square, the index of the
// destination square will be roll - 1 (e.g. if roll is 1, dst will be right[0])
let (dst_rel_idx, prepend) = match right[roll - 1] {
Square::Tile(Tile::Forward) => (roll, false), // roll - 1 + 1
Square::Tile(Tile::Backward) => (roll - 2, true), // roll is guaranteed to be >= 2 since we already handled roll == 1
_ => (roll - 1, false),
};
let dst_stack = right[dst_rel_idx].assume_stack_mut();
let dst_true_idx = src_sq + 1 + dst_rel_idx; // src_sq + 1 was the original split boundary, so add the relative index to that to get the true index
if prepend {
let slice_len = src_stack.len() - slice_start;
src_stack.move_slice_under(dst_stack, slice_start);
for i in 0..slice_len {
self.camels[dst_stack[i]] = dst_true_idx;
}
}
else {
let dst_prev_len = dst_stack.len();
src_stack.move_slice(dst_stack, slice_start);
for i in dst_prev_len..dst_stack.len() {
self.camels[dst_stack[i]] = dst_true_idx;
}
}
}
self.dice[die] = true;
None
}
fn finish_leg_random(&mut self, rng: &Rng) -> Option<Color> {
let mut leg_dice: Stack<Color, 5> = Stack::new();
for (color, rolled) in self.dice {
if !rolled {
leg_dice.push(color);
}
}
rng.shuffle(&mut leg_dice[..]);
for color in leg_dice.iter() {
let roll = rng.usize(1..=3);
if let Some(winner) = self.advance(*color, roll) {
return Some(winner);
}
}
None
}
fn finish_game_random(&mut self, rng: &Rng) -> Color {
if let Some(winner) = self.finish_leg_random(rng) {
return winner;
}
let mut dice = COLORS; // makes a copy of the constant
// we are now guaranteed to be at the start of a new leg,
// so we don't need to check the dice state
loop {
// easiest if we shuffle at the start of the leg
rng.shuffle(&mut dice);
for i in 0..5 {
let roll = rng.usize(1..=3);
if let Some(winner) = self.advance(dice[i], roll) {
return winner;
}
}
}
}
pub fn project_outcomes(&self, count: usize) -> EnumMap<Color, usize> {
let (orig_camels, orig_dice) = self.get_state();
let mut projection = *self;
let mut scores: EnumMap<Color, usize> = EnumMap::default();
let rng = Rng::new();
for i in 0..count {
let winner = projection.finish_game_random(&rng);
scores[winner] += 1;
projection.set_state(&orig_camels, &orig_dice);
}
scores
}
}
#[cfg(test)]
mod test {
use super::*;
use Color::*;
#[test]
fn test_advance() {
let mut game = Game::new();
// all dice are false (not rolled) to start with
assert_eq!(game.dice.values().any(|&v| v), false);
let camel_state = [
(Blue, 0),
(Yellow, 0),
(Red, 1),
(Green, 2),
(Purple, 2),
];
game.set_state(&camel_state, &Default::default());
assert_eq!(game.squares[0].assume_stack(), &Stack::from([Blue, Yellow]));
assert_eq!(game.camels[Blue], 0);
assert_eq!(game.camels[Yellow], 0);
assert_eq!(game.squares[1].assume_stack(), &Stack::from([Red]));
assert_eq!(game.camels[Red], 1);
assert_eq!(game.squares[2].assume_stack(), &Stack::from([Green, Purple]));
assert_eq!(game.camels[Green], 2);
assert_eq!(game.camels[Purple], 2);
// BY, R, GP
game.advance(Yellow, 2);
assert_eq!(game.dice[Yellow], true);
assert_eq!(game.camels[Yellow], 2);
assert_eq!(game.squares[2].assume_stack(), &Stack::from([Green, Purple, Yellow]));
// B, R, GPY
game.advance(Red, 2);
assert_eq!(game.dice[Red], true);
assert_eq!(game.camels[Red], 3);
// B, _, GPY, R
game.advance(Purple, 1);
assert_eq!(game.dice[Purple], true);
assert_eq!(game.squares[3].assume_stack(), &Stack::from([Red, Purple, Yellow]));
// B, _, G, RPY
}
#[test]
fn test_new_random() {
for _ in 0..100 {
let game = Game::new_random();
for (camel, i) in game.camels {
assert!(i < 3); // since we've only rolled the die once for each camel
let stack = game.squares[i].assume_stack();
assert!(stack[..].contains(&camel));
}
}
}
#[test]
fn test_finish_leg() {
let mut game = Game::new();
let camel_state = [
(Purple, 0),
(Blue, 0),
(Green, 1),
(Red, 1),
(Yellow, 2),
];
game.set_state(&camel_state, &Default::default());
// PB, G, RY
game.advance(Green, 2);
// PB, _, RY, G
game.advance(Purple, 1);
// _, PB, RY, G
// since this is randomized, we should do it a bunch of times to make sure
for _ in 0..100 {
let mut projection = game; // copy?
assert_eq!(projection.squares[1].assume_stack(), &Stack::from([Purple, Blue]));
let rng = Rng::new();
projection.finish_leg_random(&rng);
// since we already rolled Green, it can't have moved
assert_eq!(projection.camels[Green], 3);
// likewise purple
assert_eq!(projection.camels[Purple], 1);
// blue, red,and yellow, on the other hand, *must* have moved
assert_ne!(projection.camels[Blue], 1);
assert_ne!(projection.camels[Red], 2);
assert_ne!(projection.camels[Yellow], 2);
}
}
#[test]
fn test_finish_leg_winner() {
let mut game = Game::new();
let camel_state = [
(Green, 13),
(Red, 14),
(Purple, 14),
(Blue, 15),
(Yellow, 15),
];
game.set_state(&camel_state, &Default::default());
// since there are no tiles involved, and multiple camels are on 15, there must be a winner
let rng = Rng::new();
assert!(matches!(game.finish_leg_random(&rng), Some(_)));
}
#[test]
fn test_project_outcomes() {
let mut game = Game::new();
let camel_state = [
(Blue, 1),
(Green, 2),
(Yellow, 2),
(Purple, 4),
(Red, 10),
];
game.set_state(&camel_state, &Default::default());
// _, B, GY, _, P, _, _, _, _, _, R
let scores = game.project_outcomes(10_000);
let mut max = 0;
let mut winner = Blue; // just "anything that's not red"
for (color, score) in scores {
if score > max {
max = score;
winner = color;
}
}
assert_eq!(winner, Red);
}
}

View File

@ -1,40 +0,0 @@
use std::time::Instant;
mod stack;
mod game;
use game::{Game, Color::*};
fn main() {
let n_games = 200_000;
let game = Game::new_random();
// let mut game = Game::new();
// let camel_state = [
// (Blue, 5),
// (Purple, 5),
// (Red, 7),
// (Yellow, 8),
// (Green, 10),
// ];
// game.set_state(&camel_state, &Default::default());
let start = Instant::now();
let scores = game.project_outcomes(n_games);
let end = Instant::now();
let elapsed = end.duration_since(start);
let secs = (elapsed.as_secs_f64() * 100f64).round() / 100f64;
let rate = (n_games as f64) / elapsed.as_secs_f64();
println!("Test completed:");
println!("{n_games} in {secs} seconds", );
println!("Games per second: {rate}\n");
let total = scores.values().sum::<usize>() as f64;
for (color, score) in scores {
let fract = (score as f64) / total;
let pct = (fract * 10_000f64).round() / 100f64;
println!("{color:?}: {pct}%");
}
}

View File

@ -1,265 +0,0 @@
use std::ops::{Index, IndexMut, RangeFull};
use std::iter::IntoIterator;
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct Stack<T, const S: usize> {
data: [T; S],
len: usize // we can experiment with using u8 some other time
}
impl<T, const S: usize> Stack<T, S> {
pub fn push(&mut self, v: T) {
self.data[self.len] = v;
self.len += 1;
}
pub fn len(&self) -> usize {
self.len
}
pub fn clear(&mut self) {
self.len = 0;
}
pub fn last(&self) -> Option<&T> {
if self.len == 0 {
None
}
else {
Some(&self.data[self.len - 1])
}
}
pub fn iter(&self) -> impl Iterator<Item = &T> {
self.data.iter().take(self.len)
}
pub const fn from_array(array: [T; S]) -> Self {
Stack {
data: array,
len: S,
}
}
pub fn into_inner(self) -> [T; S] {
self.data
}
}
impl<T, const S: usize> Stack<T, S>
where T: Copy + Default
{
pub fn new() -> Self {
Stack {
data: [Default::default(); S],
len: 0,
}
}
pub fn move_slice(&mut self, dst: &mut Self, start: usize) {
let slice_len = self.len - start;
let src_slice = &mut self.data[start..self.len];
let dst_slice = &mut dst.data[dst.len..(dst.len + slice_len)];
dst_slice.copy_from_slice(src_slice);
self.len -= slice_len;
dst.len += slice_len;
}
pub fn move_slice_under(&mut self, dst: &mut Self, start: usize) {
let slice_len = self.len - start;
let src_slice = &mut self.data[start..self.len];
dst.data.rotate_right(slice_len);
let dst_slice = &mut dst.data[0..slice_len];
dst_slice.copy_from_slice(src_slice);
self.len -= slice_len;
dst.len += slice_len;
}
// like above, except source and destination are the same, i.e. reordering the stack
pub fn shift_slice_under(&mut self, start: usize) {
for mut i in start..self.len {
while i > 0 {
self.data.swap(i, i -1);
i -= 1;
}
}
}
}
impl<T, const S: usize> Default for Stack<T, S>
where T: Copy + Default
{
fn default() -> Self {
Self::new()
}
}
impl<T, const S: usize> Index<usize> for Stack<T, S> {
type Output = T;
fn index(&self, index: usize) -> &T {
&self.data[index]
}
}
impl<T, const S: usize> Index<RangeFull> for Stack<T, S> {
type Output = [T];
fn index(&self, _index: RangeFull) -> &[T] {
&self.data[..self.len]
}
}
impl<T, const S: usize> IndexMut<RangeFull> for Stack<T, S> {
fn index_mut(&mut self, _index: RangeFull) -> &mut [T] {
&mut self.data[..self.len]
}
}
impl<I, T, const S: usize> From<I> for Stack<T, S>
where
T: Copy + Default,
I: IntoIterator<Item = T>
{
fn from(src: I) -> Self {
let mut res = Self::new();
for (i, item) in src.into_iter().enumerate() {
if i >= S {
break;
}
res.push(item);
}
res
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_basic() {
let mut stack: Stack<usize, 5> = Stack::new();
stack.push(1);
stack.push(2);
stack.push(3);
assert_eq!(stack.len(), 3);
assert_eq!(stack[0], 1);
assert_eq!(stack[1], 2);
assert_eq!(stack[2], 3);
assert_eq!(stack.last(), Some(&3));
stack.clear();
assert_eq!(stack.len(), 0);
}
#[test]
fn test_move_slice() {
let mut a: Stack<usize, 5> = Stack::new();
let mut b: Stack<usize, 5> = Stack::new();
a.push(1);
a.push(2);
a.push(3);
b.push(9);
b.push(8);
a.move_slice(&mut b, 1);
assert_eq!(b[2], 2);
assert_eq!(b[3], 3);
b.move_slice(&mut a, 1);
assert_eq!(a[1], 8);
assert_eq!(a[2], 2);
assert_eq!(a[3], 3);
a.move_slice(&mut b, 0);
assert_eq!(a.len(), 0);
assert_eq!(b[0], 9);
assert_eq!(b.last(), Some(&3));
}
#[test]
fn test_move_slice_under() {
let mut a: Stack<usize, 5> = Stack::new();
let mut b: Stack<usize, 5> = Stack::new();
a.push(1);
a.push(2);
a.push(3);
b.push(9);
b.push(8);
a.move_slice_under(&mut b, 1);
assert_eq!(a.len(), 1);
assert_eq!(a[0], 1);
assert_eq!(b.len(), 4);
assert_eq!(b[0], 2);
assert_eq!(b[3], 8);
b.move_slice_under(&mut a, 0);
assert_eq!(b.len(), 0);
assert_eq!(a[0], 2);
assert_eq!(a[4], 1);
}
fn test_shift_slice_under() {
let mut a: Stack<usize, 5> = Stack::from([1, 2, 3, 4, 5]);
a.shift_slice_under(3);
assert_eq!(a[0], 4);
assert_eq!(a[1], 5);
assert_eq!(a[2], 1);
assert_eq!(a[3], 2);
assert_eq!(a[4], 3);
}
#[test]
fn test_from_iter() {
let s = Stack::<_, 5>::from([1, 2, 3]);
assert_eq!(s[0], 1);
assert_eq!(s[2], 3);
let s = Stack::<_, 2>::from([1, 2, 3]);
assert_eq!(s.len(), 2);
assert_eq!(s[0], 1);
assert_eq!(s[1], 2);
}
#[test]
fn test_iter() {
let s = Stack::<_, 5>::from([1, 2, 3]);
let mut it = s.iter();
assert_eq!(it.next(), Some(&1));
assert_eq!(it.next(), Some(&2));
assert_eq!(it.next(), Some(&3));
assert_eq!(it.next(), None);
}
#[test]
fn test_from_array() {
let s = Stack::from_array([1, 2, 3]);
assert_eq!(s[0], 1);
assert_eq!(s[1], 2);
assert_eq!(s[2], 3);
assert_eq!(s.len(), 3);
}
#[test]
fn test_slice_index() {
let mut s = Stack::<_, 5>::from([3, 4, 5]);
assert_eq!(s[..], [3, 4, 5]);
assert_eq!(&mut s[..], &mut [3, 4, 5]);
}
}

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

120
ui.nim Normal file
View File

@ -0,0 +1,120 @@
import os, math, strutils, strformat
import faststack, fixedseq, game, simulation
const help =
"""cup - Probability calculator for the board game CamelUp
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
"""
# =============================
# User input parsing/validation
# =============================
type
CmdConfig* = object
state*: seq[tuple[c: Color, p: int]]
interactive*: bool
diceRolled*: array[Color, bool]
proc parseColor(c: char): Color =
case c:
of 'R', 'r':
return cRed
of 'G', 'g':
return cGreen
of 'B', 'b':
return cBlue
of 'Y', 'y':
return cYellow
of 'P', 'p':
return cPurple
else:
raise newException(ValueError, "Invalid camel color specified.")
proc parseArgs*(): CmdConfig =
for p in os.commandLineParams():
if p == "-h":
echo help
quit 0
elif p == "-i":
result.interactive = true
elif result.state.len < 5:
let splat = p.split(':')
let sq = splat[0]
let square = sq.parseInt
let colors = splat[1]
for c in colors:
let color = parseColor(c)
result.state.add((color, square))
else:
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")