├── yahtzbot.nims ├── README.md ├── .gitignore ├── nimzbot.nimble ├── .vscode ├── launch.json └── tasks.json └── yahtzbot.nim /yahtzbot.nims: -------------------------------------------------------------------------------- 1 | --threads:on -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nimzbot 2 | 3 | A Yahtzeebot written in Nim. See http://mode80.github.io/7-langs-in-12-months.html 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/* 2 | !.vscode/tasks.json 3 | !.vscode/launch.json 4 | .idea 5 | .nimcache 6 | .DS_Store 7 | .swp 8 | lldbnim.py 9 | yahtzbot.dSYM 10 | yahtzbot 11 | *.prof* -------------------------------------------------------------------------------- /nimzbot.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.0" 4 | author = "mode80" 5 | description = "A solo yahtzee expected value calculator in Nim" 6 | license = "MIT" 7 | srcDir = "src" 8 | bin = @["nimzbot"] 9 | 10 | 11 | # Dependencies 12 | 13 | requires "nim >= 1.6.1" 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "preLaunchTask": "build debug", 9 | "preRunCommands": [ 10 | "command script import --allow-reload .vscode/lldbnim.py" 11 | // "command script import --allow-reload .vscode/nimgdb.py" 12 | ], 13 | "type": "lldb", 14 | "request": "launch", 15 | "name": "Debug", 16 | "program": "yahtzbot", 17 | "args": [], 18 | "cwd": "${workspaceFolder}", 19 | }, 20 | { 21 | "preLaunchTask": "build release", 22 | "type": "lldb", 23 | "request": "launch", 24 | "name": "Release", 25 | "program": "yahtzbot", 26 | "args": [], 27 | "cwd": "${workspaceFolder}", 28 | "presentation": { 29 | "reveal": "silent", 30 | "revealProblems": "onProblem", 31 | "close": true 32 | } 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "build release", 8 | "type": "shell", 9 | // "command": "nim c -d:release --app:console --opt:speed --hints:off --nimcache:.nimcache yahtzbot.nim", 10 | "command": "nim c -d:danger --threads:on --gc:orc --app:console --opt:speed --panics:on --checks:off -t:-march=native -t:-ffast-math --hints:off --nimcache:.nimcache yahtzbot.nim", 11 | "group": { "kind": "build", "isDefault": true }, 12 | // "presentation": { "reveal": "silent", "revealProblems": "onProblem", "close": true } 13 | }, 14 | { // runs 6x slower than release build 15 | "label": "build debug", 16 | "type": "shell", 17 | "command": "nim c -d:debug --opt:none --threads:on --gc:orc --showAllMismatches:on --debuginfo --app:console --debugger:native --linedir:on --hints:off --nimcache:.nimcache yahtzbot.nim", 18 | // "presentation": { "reveal": "silent", "revealProblems": "onProblem", "close": true } 19 | }, 20 | // generate LTO/PGO profiling for compiler 21 | { 22 | "label": "build LTO/PGO data", 23 | "type": "shell", 24 | "command": "nim c -d:danger --threads:on --gc:orc --app:console --opt:speed --panics:on --checks:off -t:-march=native -t:-ffast-math --hints:off --nimcache:.nimcache --cc:clang --passC:\"-flto -fprofile-instr-generate\" --passL:\"-flto -fprofile-instr-generate\" yahtzbot.nim", 25 | }, // process output with: xcrun llvm-profdata merge default.profraw -output data.profdata 26 | // generate LTO/PGO profiling for compiler 27 | { 28 | "label": "build release with LTO/PGO data", 29 | "type": "shell", 30 | "command": "nim c -d:danger --threads:on --gc:orc --app:console --opt:speed --panics:on --checks:off -t:-march=native -t:-ffast-math --hints:off --nimcache:.nimcache --cc:clang --passC:\"-flto -fprofile-instr-use=data.profdata\" --passL:\"-flto -fprofile-instr-use=data.profdata\" yahtzbot.nim", 31 | }, 32 | { // 16x slower than release build 33 | "label": "build with NIM debug profiling", 34 | "type": "shell", 35 | "command": "nim c -g -d:debug --profiler:on --stacktrace:on --showAllMismatches:on --debuginfo --app:console --debugger:native --linedir:on --hints:off --nimcache:.nimcache yahtzbot.nim", 36 | }, 37 | { 38 | "label": "build for XCode release profiling", 39 | "type": "shell", 40 | // "command": "nim c -d:release --app:console --opt:speed --hints:off --nimcache:.nimcache yahtzbot.nim", 41 | "command": "nim c -d:danger --app:console --opt:speed --hints:off --nimcache:.nimcache yahtzbot.nim", 42 | }, 43 | ] 44 | } -------------------------------------------------------------------------------- /yahtzbot.nim: -------------------------------------------------------------------------------- 1 | import options, tables, sequtils, math, algorithm, strformat#, threadpool 2 | {. hint[XDeclaredButNotUsed]:off .} 3 | # {.experimental: "codeReordering".} 4 | 5 | # ------------------------------------------------------------- 6 | # TYPES 7 | # ------------------------------------------------------------- 8 | type 9 | u8 = uint8 10 | u16 = uint16 11 | u32 = uint32 12 | u64 = uint64 13 | f32 = float32 14 | f64 = float64 # lazy Rust-like abbreviations 15 | 16 | Selection = range[0b00000..0b11111] # types can be constrained to range. cool 17 | Choice = u8 18 | DieVal = u8 19 | 20 | Slot= enum 21 | ACES=1, TWOS, THREES, FOURS, FIVES, SIXES, 22 | THREE_OF_A_KIND, FOUR_OF_A_KIND, FULL_HOUSE, 23 | SM_STRAIGHT, LG_STRAIGHT, YAHTZEE, CHANCE 24 | 25 | # ------------------------------------------------------------- 26 | # UTILS 27 | # ------------------------------------------------------------- 28 | 29 | iterator `|||`[S, T](a: S, b: T): T {.inline, sideEffect.} = 30 | ## a special iterator that encourages the compiler to apply loop vectorization 31 | ## works by abusing the built in || iterator to inject arbitrary loop preface #pragmas 32 | for i in `||`(a,b, "simd \n#pragma GCC ivdepi \n#pragma clang loop vectorize(enable)\n#pragma FP_CONTRACT STDC ON\n#pragma float_control(precise, off)") : 33 | yield i # others? https://www.openmp.org/wp-content/uploads/OpenMP-4.5-1115-CPP-web.pdf 34 | 35 | proc print(it :string) = 36 | ## print to stdout, without a newline 37 | stdout.write(it) 38 | stdout.flushFile() 39 | 40 | iterator items (i :SomeOrdinal) :SomeOrdinal = ## one-liner enables use of "for i in 5:" syntax. pretty cool 41 | for j in 0..= n" syntax 44 | if right!=none(T): left=right.get 45 | 46 | 47 | func as_long_as [T] (left :T, cond:bool) :Option[T] = ## enables "result .set_to tot .as_long_as maxinarow >= n" syntax 48 | if cond: some(left) else: none(T) 49 | 50 | 51 | func unless [T] (left :T, cond:bool) :Option[T] = ## enables "result .set_to tot .unless maxinarow < n" syntax 52 | if not cond: some(left) else: none(T) 53 | 54 | 55 | func n_take_r (n :int, r :int, order_matters :bool = false, with_replacement :bool = false) :int= 56 | ## count of arrangements that can be formed from r selections, chosen from n items, 57 | ## where order DOES or DOESNT matter, and WITH or WITHOUT replacement, as specified. 58 | if (order_matters): # order matters; we're counting "permutations" 59 | if (with_replacement): 60 | return n^r 61 | else: # no replacement 62 | return fac(n) div fac(n-r) # this = factorial(n) when r=n 63 | else : # we're counting "combinations" where order doesn't matter; there are less of these 64 | if (with_replacement) : 65 | return fac(n+r-1) div (fac(r)*fac(n-1)); 66 | else : # no replacement 67 | return fac(n) div (fac(r)*fac(n-r)); 68 | 69 | 70 | proc combos_with_rep [T] (list :seq[T], k: int): seq[seq[T]] = 71 | ## combos with repetition 72 | if k == 0: 73 | @[newSeq[T]()] 74 | elif list.len == 0: 75 | @[] 76 | else: 77 | list.combos_with_rep(k-1).mapIt(list[0] & it) & 78 | list[1..^1].combos_with_rep(k) 79 | 80 | 81 | func distinct_arrangements_for [T] (dieval_seq :seq[T]) :f32 = 82 | 83 | let key_counts = dieval_seq.toCountTable 84 | var divisor = 1 85 | var non_zero_dievals = 0 86 | 87 | for key, count in key_counts: 88 | if key != 0: 89 | divisor *= fac(count) 90 | non_zero_dievals += count 91 | 92 | return fac(non_zero_dievals) / divisor 93 | 94 | 95 | func powerset [T] (list :seq[T]) :seq[seq[T]] = 96 | 97 | let count :int = 2^list.len # set_size of power set of a set with set_size n is (2**n -1) 98 | var i, j :int 99 | 100 | for i in count: # Run from counter 000..0 to 111..1 101 | var innerList = newSeqOfCap[T](count) 102 | # Check each jth bit in the counter is set If set then add jth element from set 103 | for j in list.len: 104 | if (i and (1 shl j)) > 0: innerList .add list[j] 105 | result .add innerList 106 | return result 107 | 108 | func unique_permutations (sorted_list :seq[int]) :seq[seq[int]] = 109 | ## returns a seq of seqs, where each inner seq is a unique permutation of the input seq 110 | var list = sorted_list 111 | while true: 112 | if not result.contains(list): result.add(list) 113 | if list.nextPermutation == false: break # nextPermutation modifies list in-place and returns false if done 114 | 115 | func inverse [T] (s :set[T]) :set[T] = 116 | ## returns the inverse of a set, i.e. the set of all elements not in the input set 117 | for i in T.low..T.high: 118 | if not s.contains(i): result.incl(i) 119 | 120 | 121 | # ------------------------------------------------------------- 122 | # DIEVALS 123 | # ------------------------------------------------------------- 124 | 125 | type DieVals = distinct u16 # 5 dievals, each from 0 to 6, can be encoded in 2 bytes total, each taking 3 bits 126 | 127 | 128 | func init_dievals(d1 :int, d2 :int, d3 :int, d4 :int, d5 :int) :DieVals = # construct a DieVals from 5 int args 129 | result = DieVals(u16(d1) or u16(d2 shl 3) or u16(d3 shl 6) or u16(d4 shl 9) or u16(d5 shl 12)) 130 | 131 | 132 | func toDieVals (args :varargs[int]) :DieVals = # convert a seq of 5 ints to a DieVals 133 | assert args.len == 5 134 | var intout :int = 0 135 | for i in 0..4: 136 | intout = intout or args[i] shl (i * 3) 137 | result = intout.DieVals 138 | 139 | 140 | using self: DieVals # declare self to be of DieVals for below methods 141 | 142 | 143 | func toIntSeq(self) :seq[int] = # convert a DieVals to a seq of 5 ints 144 | var int_self = self.int 145 | for i in 0..4: 146 | let val = (int_self shr (i * 3)) and 7 147 | result.add val.int 148 | 149 | 150 | func toIntArray(self) :array[5,int] = # convert a DieVals to a array of 5 ints 151 | var int_self = self.int 152 | for i in 0..4: 153 | let val = (int_self shr (i * 3)) and 7 154 | result[i] = val.int 155 | 156 | 157 | func `[]`(self; i :int): DieVal = # extract a DieVal from a DieVals 158 | assert i >= 0 and i < 5 159 | var int_self = self.int 160 | result = DieVal (int_self shr (i * 3)) and 0b111 161 | 162 | 163 | iterator items(self) :DieVal = 164 | var int_self = self.int 165 | for i in 0..4: 166 | let val = (int_self shr (i * 3)) and 7 167 | yield val.DieVal 168 | 169 | 170 | iterator pairs(self) :(int,DieVal) = 171 | var int_self = self.int 172 | for i in 0..4: 173 | let val = (int_self shr (i * 3)) and 7 174 | yield (i, val.DieVal) 175 | 176 | 177 | func `$`(self) :string = # convert a DieVals to a string 178 | var int_self = self.int 179 | for i in 0..4: 180 | let val = (int_self shr (i * 3)) and 7 181 | result.add $val 182 | 183 | #------------------------------------------------------------- 184 | # SLOTS 185 | #------------------------------------------------------------- 186 | 187 | # use Nim's built-in bitset type for slots 188 | type Slots = set[Slot] 189 | 190 | using self: Slots 191 | 192 | func toSlots(args: varargs[Slot]): Slots = ## construct a Slots from a varargs 193 | for arg in args: result.incl arg 194 | 195 | func toSlots(args: varargs[int]): Slots = ## construct a Slots from a varargs 196 | for arg in args: 197 | assert arg in 1..13 198 | result.incl arg.Slot 199 | 200 | func toSlotSeq(self) :seq[Slot] = ## convert a Slots to a seq of Slots 201 | for slot in self: result.add slot 202 | 203 | func `$`(self) :string = ## convert a Slots to a string 204 | for slot in self: 205 | result.add $slot.int 206 | result.add '_' 207 | 208 | func removing(self; slot :Slot) :Slots = ## return a new Slots with the given slot removed 209 | result = self 210 | result.excl slot 211 | 212 | func used_upper_slots(unused_slots :Slots) :Slots = 213 | const upper_slots = {ACES, TWOS, THREES, FOURS, FIVES, SIXES} 214 | var used_slots = unused_slots.inverse 215 | result = used_slots * upper_slots # intersection 216 | 217 | 218 | func best_upper_total(slots :Slots) :int= 219 | for slot in slots: 220 | if slot>SIXES: break 221 | if slots.contains(slot): result += slot.int 222 | result *= 5 223 | 224 | 225 | func useful_upper_totals(unused_slots :Slots) :seq[int] = 226 | ## a non-exact but fast estimate of relevant_upper_totals 227 | ## ie the unique and relevant "upper bonus total" that could have occurred from the previously used upper slots 228 | var totals = toSeq(0..63) 229 | var used_uppers = used_upper_slots(unused_slots) 230 | var all_even = true 231 | const BLANK = int.low 232 | 233 | for slot in used_uppers: 234 | if slot.int mod 2 == 1: (all_even = false; break) 235 | 236 | if all_even: # stub out odd totals if the used uppers are all even 237 | for total in totals.mitems: 238 | if total mod 2 == 1: total = BLANK 239 | 240 | # filter out the lowish totals that aren't relevant because they can't reach the goal with the upper slots remaining 241 | # this filters out a lot of dead state space but means the lookup function must later map extraneous deficits to a default 242 | var best_unused_slot_total = best_upper_total(unused_slots) 243 | for total in totals: 244 | if (total!=BLANK and total+best_unused_slot_total>=63) or total==0: 245 | result.add total 246 | 247 | 248 | # ------------------------------------------------------------- 249 | # SCORING FNs 250 | # ------------------------------------------------------------- 251 | using sorted_dievals: DieVals # declare self to be of DieVals for below methods 252 | using slot: Slot 253 | 254 | func score_upperbox (slot, sorted_dievals) :u8 = 255 | for d in sorted_dievals: 256 | if d.u8 == slot.u8: result += slot.u8 257 | 258 | func score_n_of_a_kind(n :int; sorted_dievals) :u8 = 259 | var inarow=1; var maxinarow=1; var lastval=100.u8; var tot=0.u8; 260 | for x in sorted_dievals: 261 | if x==lastval and x!=0.DieVal: inarow+=1 else: inarow=1 262 | maxinarow = max(inarow,maxinarow) 263 | lastval = x 264 | tot+=x 265 | result .set_to tot .as_long_as maxinarow >= n # TODO test performance of this sugar 266 | 267 | 268 | func straight_len(sorted_dievals) :u8 = 269 | var inarow:u8 = 1 270 | var lastval= uint8.high # stub 271 | for x in sorted_dievals: 272 | if x==lastval+1 and x!=0: 273 | inarow+=1 274 | elif x!=lastval: 275 | inarow=1 276 | result = max(inarow, result) 277 | lastval = x 278 | 279 | 280 | func score_aces (sorted_dievals) :u8 = score_upperbox(1.Slot,sorted_dievals) 281 | func score_twos (sorted_dievals) :u8 = score_upperbox(2.Slot,sorted_dievals) 282 | func score_threes(sorted_dievals) :u8 = score_upperbox(3.Slot,sorted_dievals) 283 | func score_fours (sorted_dievals) :u8 = score_upperbox(4.Slot,sorted_dievals) 284 | func score_fives (sorted_dievals) :u8 = score_upperbox(5.Slot,sorted_dievals) 285 | func score_sixes (sorted_dievals) :u8 = score_upperbox(6.Slot,sorted_dievals) 286 | 287 | func score_three_of_a_kind (sorted_dievals) :u8 = score_n_of_a_kind(3,sorted_dievals) 288 | func score_four_of_a_kind (sorted_dievals) :u8 = score_n_of_a_kind(4,sorted_dievals) 289 | func score_sm_str8 (sorted_dievals) :u8 = (if straight_len(sorted_dievals) >= 4: 30 else: 0) 290 | func score_lg_str8 (sorted_dievals) :u8 = (if straight_len(sorted_dievals) == 5: 40 else: 0) 291 | 292 | 293 | func score_fullhouse(sorted_dievals) :u8 = 294 | # The official rule is that a Full House is "three of one number and two of another 295 | var valcounts1, valcounts2, j :int 296 | 297 | for i, val in sorted_dievals: 298 | if val==0: return 0 299 | if j==0 or sorted_dievals[i]!=sorted_dievals[i-1]: inc j 300 | if j==1: valcounts1+=1 301 | if j==2: valcounts2+=1 302 | if j==3: return 0 303 | 304 | if (valcounts1,valcounts2) in [(2,3), (3,2)]: return 25 305 | return 0 306 | 307 | 308 | func score_chance(sorted_dievals) :u8 = 309 | let dv = sorted_dievals 310 | return dv[0]+dv[1]+dv[2]+dv[3]+dv[4] 311 | 312 | 313 | func score_yahtzee(sorted_dievals) :u8 = 314 | let dv = sorted_dievals 315 | if dv[0]==dv[4] and dv[0]!=0: result = 50 316 | 317 | 318 | func score_slot_with_dice(slot, sorted_dievals) :u8 = 319 | # reports the score for a set of dice in a given slot w/o regard for exogenous gamestate (bonuses, yahtzee wildcards etc) 320 | case Slot slot 321 | of ACES: return score_aces sorted_dievals 322 | of TWOS: return score_twos sorted_dievals 323 | of THREES: return score_threes sorted_dievals 324 | of FOURS: return score_fours sorted_dievals 325 | of FIVES: return score_fives sorted_dievals 326 | of SIXES: return score_sixes sorted_dievals 327 | of THREE_OF_A_KIND: return score_three_of_a_kind sorted_dievals 328 | of FOUR_OF_A_KIND: return score_four_of_a_kind sorted_dievals 329 | of SM_STRAIGHT: return score_sm_str8 sorted_dievals 330 | of LG_STRAIGHT: return score_lg_str8 sorted_dievals 331 | of FULL_HOUSE: return score_fullhouse sorted_dievals 332 | of CHANCE: return score_chance sorted_dievals 333 | of YAHTZEE: return score_yahtzee sorted_dievals 334 | 335 | # ------------------------------------------------------------- 336 | # INITIALIZERS etc 337 | # ------------------------------------------------------------- 338 | 339 | const NUM_THREADS=6 340 | 341 | type UI = object 342 | tick_limit: int 343 | tick_interval: int 344 | ticks: int 345 | progress_blocks: int 346 | 347 | 348 | ## These are index spans into the OUTCOME_ arrays below which correspond to each dieval selection. 349 | ## Each of the 32 indecis from 0b00000 to 0b11111 represents the dieval selection as a bitfield 350 | const OUTCOMES_IDX_FOR_SELECTION = [(0..<1), (1..<7), (7..<13), (13..<34), (34..<40), (40..<61), 351 | (61..<82), (82..<138), (138..<144), (144..<165), (165..<186), (186..<242), (242..<263), 352 | (263..<319), (319..<375), (375..<501), (501..<507), (507..<528), (528..<549), (549..<605), 353 | (605..<626), (626..<682), (682..<738), (738..<864), (864..<885), (885..<941), (941..<997), 354 | (997..<1123), (1123..<1179), (1179..<1305), (1305..<1431), (1431..<1683),] 355 | 356 | func make_roll_outcomes(): (array[1683,DieVals], array[1683,DieVals], array[1683,f32]) = 357 | ## preps the caches of roll outcomes data for every possible 5-die selection (where '0' represents an unselected die) 358 | var OUTCOME_DIEVALS {.noinit.} : array[1683,DieVals] 359 | var OUTCOME_MASKS {.noinit.} : array[1683,DieVals] 360 | var OUTCOME_ARRANGEMENTS {.noinit.} : array[1683,f32] 361 | 362 | var i = 0 363 | let idx_powerset: seq[seq[int]] = powerset @[0,1,2,3,4] 364 | assert idx_powerset.len==32 365 | let one_thru_six = @[1,2,3,4,5,6] 366 | 367 | for idx_combo in idx_powerset: 368 | var dievals_arr = [0,0,0,0,0] 369 | let die_count = idx_combo.len 370 | 371 | let dieval_combos = combos_with_rep(one_thru_six, die_count) 372 | 373 | for dieval_combo in dieval_combos: 374 | var mask_arr = [0b111,0b111,0b111,0b111,0b111] 375 | for j, idx in idx_combo: 376 | dievals_arr[idx] = dieval_combo[j] 377 | mask_arr[idx]=0 378 | OUTCOME_DIEVALS[i] = dievals_arr.toDieVals 379 | OUTCOME_MASKS[i] = mask_arr.toDieVals 380 | OUTCOME_ARRANGEMENTS[i] = distinct_arrangements_for(dieval_combo) 381 | inc i 382 | 383 | result = (OUTCOME_DIEVALS, OUTCOME_MASKS, OUTCOME_ARRANGEMENTS) 384 | 385 | 386 | func make_sorted_dievals(): (array[32767, DieVals], array[32767, u8]) = 387 | # for fast access later, this generates an array indexed by every possible DieVals value, 388 | # with each entry being the DieVals in sorted form, along with each's unique "ID" between 0-252, 389 | var SORTED_DIEVALS {.noinit.} : array[32767, DieVals] 390 | var SORTED_DIEVAL_IDS {.noinit.} : array[32767, u8] 391 | SORTED_DIEVALS[0] = 0.DieVals #// first one is for the special wildcard 392 | SORTED_DIEVAL_IDS[0] = 0.u8 #// first one is for the special wildcard 393 | let one_to_six = @[1,2,3,4,5,6] 394 | let combos = combos_with_rep(one_to_six, 5) 395 | for i,sorted_combo in combos: 396 | # var sorted_combo = combo.sorted() # TODO appears this is redundant . is it?? 397 | var dv_sorted:DieVals = sorted_combo.toDieVals 398 | let perms = unique_permutations(sorted_list=sorted_combo) 399 | for perm in perms: 400 | let dv_perm:DieVals = perm.toDieVals 401 | SORTED_DIEVALS[dv_perm.int] = dv_sorted 402 | SORTED_DIEVAL_IDS[dv_perm.int] = i.u8 403 | result = (SORTED_DIEVALS, SORTED_DIEVAL_IDS) 404 | 405 | 406 | const (OUTCOME_DIEVALS, OUTCOME_MASKS, OUTCOME_ARRANGEMENTS) = make_roll_outcomes() 407 | const (SORTED_DIEVALS, SORTED_DIEVAL_IDS) = make_sorted_dievals() 408 | 409 | var EV_CACHE = cast[ptr UncheckedArray[f32]](createSharedU(f32, 1_073_741_824)) 410 | var CHOICE_CACHE = cast[ptr UncheckedArray[Choice]](createSharedU(Choice, 1_073_741_824)) 411 | 412 | 413 | # ------------------------------------------------------------ 414 | # GAMESTATE 415 | # ------------------------------------------------------------ 416 | 417 | #we can store all of below in a sparse array using 2^(8+13+6+2+1) entries = 1_073_741_824 entries = 5.2GB when storing 32bit EVs + 8bit Choice 418 | type GameState = object 419 | id: u32 # the 30-bit encoding which also serves as an index into EV_CACHE and CHOICE_CACHE for this game state 420 | sorted_dievals: DieVals # 15, 3 for each die, OR 8 bits once convereted to an ordinal DieVal_ID (252 possibilities) 421 | yahtzee_bonus_avail: bool # 1bit 422 | open_slots: Slots # 13 bits 423 | upper_total: u8 # 6 bits 424 | rolls_remaining: u8 # 2 bits 425 | 426 | func init(T: type GameState, sorted_dievals: DieVals, open_slots: Slots, upper_total: u8, rolls_remaining: u8, yahtzee_bonus_avail: bool): GameState {.thread.}= 427 | var id:u32 428 | var dievals_id = SORTED_DIEVAL_IDS[sorted_dievals.int] # self.id will use 30 bits total... 429 | id= (dievals_id.u32) or # this is the 8-bit encoding of self.sorted_dievals 430 | (yahtzee_bonus_avail.u32 shl 8.u32) or # this is the 1-bit encoding of self.yahtzee_bonus_avail 431 | (cast[u32](open_slots) shl 9.u32) or # slots uses 13 bits 432 | (upper_total.u32 shl 22.u32) or# upper total uses 6 bits 433 | (rolls_remaining.u32 shl 28.u32) # 0-3 rolls is stored in 2 bits. 8+1+13+6+2 = 30 bits total 434 | result = GameState( 435 | id:id, 436 | sorted_dievals: sorted_dievals, 437 | yahtzee_bonus_avail: yahtzee_bonus_avail, 438 | open_slots: open_slots, 439 | upper_total: upper_total, 440 | rolls_remaining: rolls_remaining 441 | ) 442 | 443 | func counts(self: GameState): int = 444 | ## calculate relevant counts for gamestate: required lookups and saves 445 | var slotsets = powerset(self.open_slots.toSlotSeq) 446 | for slotset in slotsets: 447 | var joker_rules = not slotset.contains YAHTZEE # yahtzees aren't wild whenever yahtzee slot is still available 448 | var totals = useful_upper_totals(slotset.toSlots) 449 | for total in totals: 450 | inc result # this just counts the cost of one pass through the bar.tick call in the dice-choose section of build_cache() loop 451 | 452 | 453 | func score_first_slot_in_context(self: GameState): u8 = 454 | 455 | assert self.open_slots!={} 456 | 457 | # score slot itself w/o regard to game state 458 | var slot = toSeq(self.open_slots)[0] # first slot in open_slots 459 | result = score_slot_with_dice(slot, self.sorted_dievals) 460 | 461 | # add upper bonus when needed total is reached 462 | if slot<=SIXES and self.upper_total<63: 463 | var new_total = min(self.upper_total+result, 63) 464 | if new_total==63 : # we just reach bonus threshold 465 | result += 35 # add the 35 bonus points 466 | 467 | # special handling of "joker rules" 468 | var just_rolled_yahtzee = (score_yahtzee(self.sorted_dievals)==50) 469 | var joker_possible = (slot != YAHTZEE) # joker rules in effect when the yahtzee slot is not open 470 | if (just_rolled_yahtzee and joker_possible): # standard scoring applies against the yahtzee dice except ... 471 | if (slot==FULL_HOUSE) :result=25 472 | if (slot==SM_STRAIGHT):result=30 473 | if (slot==LG_STRAIGHT):result=40 474 | 475 | # # special handling of "extra yahtzee" bonus per rules 476 | if (just_rolled_yahtzee and self.yahtzee_bonus_avail): result+=100 477 | 478 | #------------------------------------------------------------- 479 | # UI 480 | #------------------------------------------------------------- 481 | 482 | proc init_ui(game: GameState) :UI = 483 | result.tick_limit = counts(game) 484 | result.tick_interval = (result.tick_limit) div 100 485 | print "Progress: 0%\r" 486 | 487 | proc tick(ui: var UI) = 488 | ui.ticks.inc 489 | if ui.tick_interval==0 or ui.ticks mod ui.tick_interval == 0: 490 | print &"Progress: {$(ui.ticks * 100 div ui.tick_limit)}%\r" 491 | 492 | proc print_state_choice_header() = 493 | print"rolls_remaining,sorted_dievals,upper_total,yahtzee_bonus_avail,open_slots,choice,ev" 494 | 495 | proc print_state_choice(s: GameState, c: Choice, ev: f32, threadid: int) = 496 | ## sample output: 2,33366,63,Y,1_,00011,9.61 497 | const N_Y = ['N','Y'] 498 | if s.rolls_remaining==0: 499 | echo &"{threadid:2d}|{s.rolls_remaining},{$s.sorted_dievals},{s.upper_total:2d},{N_Y[s.yahtzee_bonus_avail.int]},{s.open_slots},{c:05d},{ev:0.2f}" 500 | else: 501 | echo &"{threadid:2d}|{s.rolls_remaining},{$s.sorted_dievals},{s.upper_total:2d},{N_Y[s.yahtzee_bonus_avail.int]},{s.open_slots},{c:05b},{ev:0.2f}" 502 | 503 | proc output(s: GameState, choice: Choice, ev: f32, threadid: int) = 504 | # Uncomment below for more verbose progress output at the expense of speed 505 | # print_state_choice(s, choice, ev, threadid); 506 | discard 507 | 508 | 509 | #------------------------------------------------------------- 510 | # BUILD CACHE 511 | #------------------------------------------------------------- 512 | 513 | const ALL_DICE = 0b11111.Selection #selections are bitfields where '1' means roll and '0' mean don't 514 | const SELECTION_SET_OF_ALL_DICE_ONLY = @[ALL_DICE] 515 | const SET_OF_ALL_SELECTIONS = toSeq(0b00000.Selection..0b11111.Selection) 516 | 517 | 518 | proc avg_ev(start_dievals: DieVals, selection:Selection, slots: Slots, upper_total: u8, 519 | next_roll: u8, yahtzee_bonus_available: bool, threadid: int): f32 = 520 | ## calculates the average EV for a dice selection from a starting dice combo 521 | ## within the context of the other relevant gamestate variables 522 | 523 | var total_ev_for_selection: f32 = 0.0 524 | var outcomes_arrangements_count: f32 = 0.0 525 | var range = OUTCOMES_IDX_FOR_SELECTION[selection] 526 | var OUTCOME_EVS_BUFFER {.noinit.} : array[1683,f32] 527 | var NEWVALS_BUFFER {.noinit.} : array[1683,DieVals] 528 | 529 | var floor_state = GameState.init( 530 | 0.DieVals, 531 | slots, 532 | upper_total, 533 | next_roll, # we'll average all the 'next roll' possibilities (which we'd calclated on the last pass) to get ev for 'this roll' 534 | yahtzee_bonus_available 535 | ) 536 | var floor_state_idx = floor_state.id.int 537 | # from this floor gamestate we can blend in a dievals_id to quickly calc the index we need to access the ev for the complete state 538 | 539 | # blit all each roll outcome for the given dice selection onto the unrolled start_dievals and stash results in the NEWVALS_BUFFER 540 | for i in range.a ||| range.b : # the custom ||| operator is for a SIMD friendly loop 541 | NEWVALS_BUFFER[i] = (start_dievals.u16 and OUTCOME_MASKS[i].u16).DieVals #make some holes in the dievals for newly rolled die vals 542 | NEWVALS_BUFFER[i] = (NEWVALS_BUFFER[i].u16 or OUTCOME_DIEVALS[i].u16).DieVals # fill in the holes with the newly rolled die vals 543 | 544 | for i in range: # this loop is a bunch of lookups so doesn't benefit from SIMD 545 | #= gather sorted =# 546 | var newvals_idx = NEWVALS_BUFFER[i].int 547 | var sorted_dievals_id = SORTED_DIEVAL_IDS[newvals_idx].int 548 | #= gather ev =# 549 | var state_to_get_id = floor_state_idx or sorted_dievals_id 550 | OUTCOME_EVS_BUFFER[i] = EV_CACHE[state_to_get_id] 551 | 552 | for i in range.a ||| range.b: 553 | # we have EVs for each "combination" but we need the average all "permutations" 554 | # -- so we mutliply by the number of distinct arrangements for each combo 555 | var count = OUTCOME_ARRANGEMENTS[i] 556 | total_ev_for_selection = OUTCOME_EVS_BUFFER[i] * count + total_ev_for_selection 557 | outcomes_arrangements_count += count 558 | 559 | # this final step gives us the average EV for all permutations of rolled dice 560 | return total_ev_for_selection / outcomes_arrangements_count 561 | 562 | # end avg_ev 563 | 564 | 565 | proc process_state(state: GameState, thread_id: int) = #{.thread.}= 566 | ## this does the work of calculating and store the expected value of a single gamestate 567 | 568 | var best_choice: Choice = 0 569 | var best_ev: f32 = 0.0 570 | 571 | if state.rolls_remaining==0 : 572 | 573 | #= HANDLE SLOT SELECTION #= 574 | 575 | for slot in state.open_slots : 576 | 577 | # joker rules say extra yahtzees must be played in their matching upper slot if it's available 578 | var first_dieval = state.sorted_dievals[0] 579 | var joker_rules_matter = state.yahtzee_bonus_avail and # TODO check this departure from C code 580 | score_yahtzee(state.sorted_dievals) > 0 and 581 | first_dieval.Slot in state.open_slots 582 | var head_slot = if joker_rules_matter: first_dieval.Slot else: slot 583 | 584 | var yahtzee_bonus_avail_now = state.yahtzee_bonus_avail 585 | var upper_total_now :u8 = state.upper_total 586 | var dievals_or_placeholder = state.sorted_dievals 587 | var head_plus_tail_ev: f32 = 0.0 588 | var rolls_remaining_now :u8 = 0 589 | var choice: Choice = 0 590 | var ev: f32 = 0.0 591 | 592 | # find the collective ev for the all the slots with this iteration's slot being first 593 | # do this by summing the ev for the first (head) slot with the ev value that we look up for the remaining (tail) slots 594 | var passes = if state.open_slots.len==1: 1 else: 2 # to do this, we need two passes unless there's only 1 slot left 595 | for pass in 1..passes: 596 | 597 | var slots_piece = if pass==1: {head_slot} else: state.open_slots.removing head_slot # work on 1)the head only, or 2) the set without the head 598 | var relevant_upper_total = if (upper_total_now + best_upper_total(slots_piece).u8 >= 63): upper_total_now else: 0 # only relevant totals are cached 599 | var state_to_get = GameState.init( 600 | dievals_or_placeholder, 601 | slots_piece, 602 | relevant_upper_total, 603 | rolls_remaining_now, 604 | yahtzee_bonus_avail_now, 605 | ) 606 | choice = CHOICE_CACHE[state_to_get.id] 607 | ev = EV_CACHE[state_to_get.id] 608 | if pass==1 and passes > 1 : # prep 2nd pass on relevant 1st pass only.. 609 | # going into tail slots next, we may need to adjust the state based on the head choice 610 | if choice.Slot <= SIXES: # adjust upper total for the next pass 611 | var added = ev.u8 mod 100 # removes any yahtzee bonus from ev since that doesnt' count toward upper bonus total 612 | upper_total_now = min(63.u8, upper_total_now + added) 613 | elif choice.Slot==YAHTZEE : # adjust yahtzee related state for the next pass 614 | if ev>0.0: yahtzee_bonus_avail_now=true 615 | 616 | rolls_remaining_now=3 # for upcoming tail lookup, we always want the ev for 3 rolls remaining 617 | dievals_or_placeholder = 0.DieVals # for 3 rolls remaining, use "wildcard" representative dievals since dice don't matter when rolling all of them 618 | # end if pass==1 619 | head_plus_tail_ev += ev; 620 | 621 | # end for pass in 1..passes 622 | 623 | if head_plus_tail_ev >= best_ev : 624 | best_choice = slot.Choice 625 | best_ev = head_plus_tail_ev 626 | 627 | if (joker_rules_matter): break # if joker-rules-matter we were forced to choose one slot, so we can skip trying the rest 628 | 629 | #end for slot in slots 630 | 631 | output(state, best_choice, best_ev, thread_id); 632 | 633 | CHOICE_CACHE[state.id] = best_choice 634 | EV_CACHE[state.id] = best_ev 635 | 636 | 637 | elif state.rolls_remaining > 0: 638 | 639 | #= HANDLE DICE SELECTION =# 640 | 641 | var next_roll = state.rolls_remaining-1 642 | var selections = if state.rolls_remaining==3: SELECTION_SET_OF_ALL_DICE_ONLY else: SET_OF_ALL_SELECTIONS 643 | 644 | # HOT LOOP ! 645 | # for each possible selection of dice from this starting dice combo, 646 | # we calculate the expected value of rolling that selection, then store the best selection along with its EV 647 | for selection in selections: 648 | var avg_ev_for_selection = avg_ev( 649 | state.sorted_dievals, 650 | selection, 651 | state.open_slots, 652 | state.upper_total, 653 | next_roll, 654 | state.yahtzee_bonus_avail, 655 | thread_id 656 | ) 657 | if (avg_ev_for_selection > best_ev): 658 | best_choice = selection.Choice 659 | best_ev = avg_ev_for_selection 660 | 661 | output(state, best_choice, best_ev, thread_id); 662 | CHOICE_CACHE[state.id] = best_choice # we're writing from multiple threads but each thread will be setting a different state_to_set.id 663 | EV_CACHE[state.id] = best_ev # " " " " 664 | 665 | # end if rolls_remaining... 666 | 667 | # end process_state 668 | 669 | type ProcessChunkArgs= tuple[slots: Slots, upper_total :u8, rolls_remaining: u8, joker_possible: bool, chunk_range: HSlice[int,int], thread_id: int] 670 | 671 | # proc process_chunk(slots: Slots, upper_total :u8, rolls_remaining: u8, joker_possible: bool, chunk_range: HSlice[int,int], thread_id: int) = #{.thread.}= 672 | proc process_chunk(args: ProcessChunkArgs) {.thread.}= 673 | 674 | var (slots, upper_total, rolls_remaining, joker_possible, chunk_range, thread_id) = args 675 | 676 | #for each yahtzee bonus possibility 677 | for yahtzee_bonus_avail in false..joker_possible: 678 | 679 | # for each dieval combo in this chunk ... 680 | # for combo in OUTCOME_DIEVALS[1..3]: 681 | for i in chunk_range: 682 | var combo = OUTCOME_DIEVALS[i] 683 | var state = GameState.init(combo, slots , upper_total.u8, rolls_remaining.u8, yahtzee_bonus_avail) 684 | process_state(state, thread_id) 685 | 686 | 687 | proc build_ev_cache(apex_state: GameState) = 688 | ## for a given gamestate, calc and cache all the expected values for dependent states. (this is like.. the main thing) 689 | 690 | var threads: array[NUM_THREADS, Thread[ProcessChunkArgs]] 691 | 692 | var ui = init_ui(apex_state) 693 | 694 | var placeholder_dievals = 0.DieVals 695 | 696 | # first handle special case of the most leafy leaf calcs -- where there's one slot left and no rolls remaining 697 | for single_slot in apex_state.open_slots: 698 | var single_slot_set :Slots = {single_slot} # set of a single slot 699 | var bonus_possibile = (single_slot != YAHTZEE) 700 | # for each yahtzee bonus availability 701 | for yahtzee_bonus_avail in false..bonus_possibile: 702 | # for each upper_total 703 | var upper_totals = useful_upper_totals(single_slot_set) 704 | for upper_total in upper_totals: 705 | # for each outcome_combo 706 | for outcome_combo in OUTCOME_DIEVALS[ OUTCOMES_IDX_FOR_SELECTION[ ALL_DICE ] ]: 707 | var state = GameState.init(outcome_combo, single_slot_set, upper_total.u8, 0.u8, yahtzee_bonus_avail) 708 | var score = score_first_slot_in_context(state) 709 | CHOICE_CACHE[state.id] = single_slot.Choice 710 | EV_CACHE[state.id] = score.f32 711 | output(state, single_slot.Choice, score.f32, 0); 712 | 713 | # for each slotset of each length 714 | for slot_seq in powerset(apex_state.open_slots.toSlotSeq): 715 | 716 | if slot_seq.len==0: continue # skip empty slotsets 717 | var slots = slot_seq.toSlots 718 | var joker_possible = not slots.contains YAHTZEE # joker rules might be in effect whenever the yahtzee slot is already filled 719 | var upper_totals = useful_upper_totals(slots) 720 | 721 | # for each upper total 722 | for upper_total in upper_totals: 723 | 724 | ui.tick() # advance the progress bar 725 | 726 | # for each rolls remaining 727 | for rolls_remaining in 0..3: 728 | 729 | var outcome_range = if rolls_remaining==3: 730 | OUTCOMES_IDX_FOR_SELECTION[0b00000] 731 | else: OUTCOMES_IDX_FOR_SELECTION[0b11111] 732 | 733 | var full_count = outcome_range.len 734 | var chunk_count = full_count .ceilDiv NUM_THREADS 735 | if full_count < NUM_THREADS: chunk_count = full_count 736 | var thread_id=0 737 | 738 | # for each dieval_combo chunk 739 | for chunk_idx in countup(outcome_range.a, outcome_range.b, step=chunk_count): 740 | var chunk_range = chunk_idx..min(chunk_idx+chunk_count-1, outcome_range.b) 741 | var args = (slots, upper_total.u8, rolls_remaining.u8, joker_possible, chunk_range, thread_id) 742 | if chunk_count==1: 743 | process_chunk(args) 744 | else: 745 | createThread threads[thread_id], process_chunk, args 746 | inc thread_id 747 | 748 | joinThreads(threads)# wait for all threads to finish 749 | 750 | 751 | #------------------------------------------------------------- 752 | # MAIN 753 | #------------------------------------------------------------- 754 | 755 | proc main() = 756 | #test stuff 757 | 758 | # var game = GameState.init( [3,4,4,6,6].toDieVals, [1].toSlots, 0, 1, false ) 759 | # var game = GameState.init( [3,4,4,6,6].toDieVals, [4,5,6].toSlots, 0, 2, false ) #38.9117 760 | # var game = GameState.init( [3,4,4,6,6].toDieVals, [1,2,8,9,10,11,12,13].toSlots, 0, 2, false ) #137.3749 761 | var game = GameState.init( [0,0,0,0,0].toDieVals, [1,2,3,4,5,6,7,8,9,10,11,12,13].toSlots, 0, 3, false ) # 254.5896 762 | 763 | build_ev_cache(game) 764 | 765 | echo "\n", CHOICE_CACHE[game.id], " ", EV_CACHE[game.id] 766 | 767 | 768 | when isMainModule: main() --------------------------------------------------------------------------------