├── project ├── build.properties └── assembly.sbt ├── .gitignore ├── src └── main │ └── scala │ ├── Player.scala │ ├── CommonTypes.scala │ ├── Sandbox.scala │ ├── CardPropertyMap.scala │ ├── Color.scala │ ├── Card.scala │ ├── RichTypes.scala │ ├── RandomPlayer.scala │ ├── Actions.scala │ ├── Hand.scala │ ├── Rules.scala │ ├── Sim.scala │ ├── Interactive.scala │ ├── SeenMap.scala │ ├── Rand.scala │ ├── Tests.scala │ ├── Game.scala │ └── HeuristicPlayer.scala └── README.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.9 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .settings/ 4 | .cache-main 5 | project/target/ 6 | project/project/ 7 | target/ 8 | bin/ 9 | .ensime_cache 10 | src/main/scala/*.class 11 | -------------------------------------------------------------------------------- /project/assembly.sbt: -------------------------------------------------------------------------------- 1 | //See https://github.com/sbt/sbt-assembly 2 | //Adds the 'assembly' command within sbt to package the scala libraries and stuff 3 | //into and executable jar so that it's actually standalone runnable unlike 'package' 4 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.1") 5 | -------------------------------------------------------------------------------- /src/main/scala/Player.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Player.scala 3 | * Hanabi player implementations must satisfy this interface. 4 | */ 5 | 6 | package fireflower 7 | 8 | abstract class Player { 9 | //Called once at the start of the game after cards are drawn 10 | def handleGameStart(game: Game): Unit 11 | //Called after each action. 12 | def handleSeenAction(sa: SeenAction, preGame: Game, postGame: Game): Unit 13 | 14 | //Called when the Player should make its move. 15 | def getAction(game: Game): GiveAction 16 | } 17 | 18 | //A PlayerGen is an object that specifies the players for a given game, with a seed 19 | //to randomize the players in case they are players that could behave randomly. 20 | trait PlayerGen { 21 | def genPlayers(rules: Rules, seed: Long): Array[Player] 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/CommonTypes.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * CommonTypes.scala 3 | * Some basic type aliases for convenience so that method and class signatures and are more self-documenting. 4 | */ 5 | 6 | package fireflower 7 | 8 | object `package` { 9 | type ColorId = Int //Integer index for different colors, ranges from 0 to (number of colors supported in this code - 1) 10 | type CardId = Int //Integer id for cards in a particular game, ranges from 0 to (deck size - 1) 11 | type PlayerId = Int //Integer id for players in a particular game, ranges from 0 to (num players - 1) 12 | type HandId = Int //Integer id for hand positions in a player's hand, ranges from 0 to (player's hand size) 13 | type Number = Int //Possible numbers for a card, ranges from 0 to 4 under normal rules. 14 | } 15 | 16 | object CardId { 17 | //CardIds sometimes need a null value, such as for filling empty spots in a hand. 18 | //We could use an option, but this is a bit cheaper. 19 | val NULL = -1 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/Sandbox.scala: -------------------------------------------------------------------------------- 1 | package fireflower 2 | 3 | object Sandbox { 4 | 5 | //Usage: () () 6 | def main(args: Array[String]): Unit = { 7 | //println("Hello world!") 8 | //RandTest.test() 9 | 10 | val debugTurnAndPath = { 11 | if(args.length >= 2) { 12 | val turn = args(1).toInt 13 | val pathstr = if(args.length >= 3) args(2) else "" 14 | val path = pathstr.split(",").filter(_.nonEmpty).map { s => GiveAction.ofString(s) }.toList 15 | Some((turn,path)) 16 | } 17 | else 18 | None 19 | } 20 | 21 | val _game = Sim.runSingle( 22 | rules = Rules.Standard(numPlayers=2,stopEarlyLoss=true), 23 | // rules = Rules.Standard(numPlayers=3), 24 | gameSeed = args(0).toLong, 25 | playerSeed = 0L, 26 | playerGen = HeuristicPlayer, 27 | doPrint = true, 28 | useAnsiColors = true, 29 | debugTurnAndPath = debugTurnAndPath 30 | ) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/main/scala/CardPropertyMap.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * CardPropertyMap.scala 3 | * A basic mutable structure for storing sets of properties known about CardIds. 4 | */ 5 | 6 | package fireflower 7 | import scala.reflect.ClassTag 8 | 9 | object CardPropertyMap { 10 | def apply[T](rules: Rules): CardPropertyMap[T] = { 11 | new CardPropertyMap[T](Array.fill(rules.deckSize)(List[T]())) 12 | } 13 | def apply[T](that: CardPropertyMap[T]) = { 14 | new CardPropertyMap[T](that.arr.clone()) 15 | } 16 | } 17 | 18 | class CardPropertyMap[T] private ( 19 | val arr: Array[List[T]] 20 | ) { 21 | 22 | def copyTo(other: CardPropertyMap[T]): Unit = { 23 | Array.copy(arr, 0, other.arr, 0, arr.size) 24 | } 25 | 26 | //The most recently added values are at the front of the list 27 | def apply(cid: CardId): List[T] = { 28 | arr(cid) 29 | } 30 | 31 | def add(cid: CardId, value: T): Unit = { 32 | arr(cid) = value :: arr(cid) 33 | } 34 | 35 | def filterOut(cid: CardId)(f: T => Boolean): Unit = { 36 | arr(cid) = arr(cid).filterNot(f) 37 | } 38 | 39 | def pop(cid: CardId): Unit = { 40 | if(arr(cid).nonEmpty) { 41 | arr(cid) = arr(cid).tail 42 | } 43 | } 44 | 45 | def remove(cid: CardId, value: T): Unit = { 46 | filterOut(cid) { x => x == value } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/scala/Color.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Color.scala 3 | * Colors of Hanabi cards. 4 | */ 5 | 6 | package fireflower 7 | import scala.reflect.ClassTag 8 | 9 | object Color { 10 | val ansiResetColor = "\u001B[0m" 11 | 12 | //Global cap on how big color ids are allowed to be - all ids are LESS than this. 13 | //Used for array size bounds and such. 14 | val LIMIT: Int = 6 15 | } 16 | 17 | sealed trait Color extends Ordered[Color] { 18 | val id: ColorId 19 | def compare(that: Color): Int = id.compare(that.id) 20 | 21 | override def toString(): String = { 22 | this match { 23 | case Red => "R" 24 | case Yellow => "Y" 25 | case Green => "G" 26 | case Blue => "B" 27 | case White => "W" 28 | case MultiColor => "M" 29 | case NullColor => "?" 30 | } 31 | } 32 | 33 | def toString(useAnsiColors: Boolean): String = { 34 | toAnsiColorCode() + toString() + Color.ansiResetColor 35 | } 36 | 37 | def toAnsiColorCode(): String = { 38 | this match { 39 | case Red => "\u001B[31m" 40 | case Green => "\u001B[32m" 41 | case Blue => "\u001B[34m" 42 | case Yellow => "\u001B[33m" 43 | case White => "" 44 | case MultiColor => "\u001B[35m" 45 | case NullColor => "\u001B[37m" 46 | } 47 | } 48 | } 49 | 50 | case object Red extends Color { val id = 0 } 51 | case object Yellow extends Color { val id = 1 } 52 | case object Green extends Color { val id = 2 } 53 | case object Blue extends Color { val id = 3 } 54 | case object White extends Color { val id = 4 } 55 | case object MultiColor extends Color { val id = 5 } 56 | case object NullColor extends Color { val id = -1 } 57 | -------------------------------------------------------------------------------- /src/main/scala/Card.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Card.scala 3 | * The basic type of a Hanabi card. 4 | * By convention, numbers of hanabi cards start at ZERO. They are incremented upon display. 5 | */ 6 | 7 | package fireflower 8 | import scala.reflect.ClassTag 9 | 10 | object Card { 11 | val NULL: Card = Card(NullColor,-1) 12 | 13 | //Global cap on how big numbers on cards are allowed to be - all numbers are LESS than this. 14 | //Used for array size bounds and such. 15 | val NUMBER_LIMIT: Int = 5 16 | 17 | //Maximum possible card array index 18 | val maxArrayIdx: Int = Card.NUMBER_LIMIT * Color.LIMIT 19 | 20 | //Map this card to an array index based on its properties, for array-based card lookups. 21 | def arrayIdx(color: Color, number: Int): Int = { 22 | number + Card.NUMBER_LIMIT * color.id 23 | } 24 | } 25 | 26 | case class Card( 27 | color: Color, 28 | number: Number 29 | ) extends Ordered[Card] { 30 | 31 | //Map this card to an array index based on its properties, for array-based card lookups. 32 | def arrayIdx: Int = { 33 | number + Card.NUMBER_LIMIT * color.id 34 | } 35 | 36 | def compare(that: Card): Int = { 37 | import scala.math.Ordered.orderingToOrdered 38 | (color,number).compare((that.color,that.number)) 39 | } 40 | 41 | override def toString(): String = { 42 | toString(useAnsiColors = true) 43 | } 44 | 45 | def toString(useAnsiColors: Boolean): String = { 46 | if(this == Card.NULL) 47 | { 48 | if(useAnsiColors) "?" 49 | else "??" 50 | } 51 | else { 52 | if(useAnsiColors) color.toAnsiColorCode() + (number+1).toString() + Color.ansiResetColor 53 | else color.toString() + (number+1).toString() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/RichTypes.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * RichTypes.scala 3 | * Implements some convenient functions not present in the Scala standard library, with implicit conversions 4 | * so that it's as if they were implemented for the standard library classes. 5 | */ 6 | 7 | package fireflower 8 | 9 | import scala.language.implicitConversions 10 | import scala.reflect.ClassTag 11 | 12 | class RicherSeq[T](val seq: Seq[T]) { 13 | def findMap[U](f: T => Option[U]): Option[U] = seq.iterator.map(f).find(_.nonEmpty).flatten 14 | def filterNotFirst(f: T => Boolean): Seq[T] = { 15 | var filtering = true 16 | seq.filterNot { x => if(filtering && f(x)) { filtering = false; true } else false } 17 | } 18 | def partitionMap[U,V](f: T => Either[U,V]): (List[U],List[V]) = { 19 | var us: List[U] = List() 20 | var vs: List[V] = List() 21 | seq.foreach { x => f(x) match { case Left(u) => us = u :: us case Right(v) => vs = v :: vs } } 22 | (us.reverse, vs.reverse) 23 | } 24 | } 25 | class RicherList[T](val list: List[T]) { 26 | def findMap[U](f: T => Option[U]): Option[U] = list.iterator.map(f).find(_.nonEmpty).flatten 27 | def filterNotFirst(f: T => Boolean): List[T] = { 28 | var filtering = true 29 | list.filterNot { x => if(filtering && f(x)) { filtering = false; true } else false } 30 | } 31 | def partitionMap[U,V](f: T => Either[U,V]): (List[U],List[V]) = { 32 | var us: List[U] = List() 33 | var vs: List[V] = List() 34 | list.foreach { x => f(x) match { case Left(u) => us = u :: us case Right(v) => vs = v :: vs } } 35 | (us.reverse, vs.reverse) 36 | } 37 | } 38 | class RicherArray[T](val arr: Array[T]) { 39 | def findMap[U](f: T => Option[U]): Option[U] = arr.iterator.map(f).find(_.nonEmpty).flatten 40 | def filterNotFirst(f: T => Boolean): Array[T] = { 41 | var filtering = true 42 | arr.filterNot { x => if(filtering && f(x)) { filtering = false; true } else false } 43 | } 44 | def partitionMap[U:ClassTag,V:ClassTag](f: T => Either[U,V]): (Array[U],Array[V]) = { 45 | var us: List[U] = List() 46 | var vs: List[V] = List() 47 | arr.foreach { x => f(x) match { case Left(u) => us = u :: us case Right(v) => vs = v :: vs } } 48 | (us.reverse.toArray, vs.reverse.toArray) 49 | } 50 | } 51 | 52 | class RicherMap[T,U](val map: Map[T,U]) { 53 | def update(key:T)(f: Option[U] => U): Map[T,U] = map + (key -> (f(map.get(key)))) 54 | def change(key:T)(f: Option[U] => Option[U]): Map[T,U] = { 55 | f(map.get(key)) match { 56 | case None => map - key 57 | case Some(v) => map + (key -> v) 58 | } 59 | } 60 | } 61 | 62 | object RichImplicits { 63 | implicit def richifyList[T](list: List[T]) = new RicherList(list) 64 | implicit def richifyArray[T](arr: Array[T]) = new RicherArray(arr) 65 | implicit def richifySeq[T](seq: Seq[T]) = new RicherSeq(seq) 66 | implicit def richifyMap[T,U](map: Map[T,U]) = new RicherMap(map) 67 | 68 | //Also just a useful function 69 | def assertUnreachable(): Nothing = { 70 | throw new Exception("Bug - reached code that should be unreachable") 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/scala/RandomPlayer.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * RandomPlayer.scala 3 | * A player that plays randomly. Not very good, just intended as a really really low baseline. 4 | */ 5 | 6 | 7 | package fireflower 8 | 9 | //RunSeed 0L 10 | // [info] Score 0 Games: 5356 Percent: 53.6% 11 | // [info] Score 1 Games: 2970 Percent: 29.7% 12 | // [info] Score 2 Games: 1176 Percent: 11.8% 13 | // [info] Score 3 Games: 369 Percent: 3.7% 14 | // [info] Score 4 Games: 91 Percent: 0.9% 15 | // [info] Score 5 Games: 28 Percent: 0.3% 16 | // [info] Score 6 Games: 7 Percent: 0.1% 17 | // [info] Score 7 Games: 3 Percent: 0.0% 18 | // [info] Score 8 Games: 0 Percent: 0.0% 19 | // [info] Score 9 Games: 0 Percent: 0.0% 20 | // [info] Score 10 Games: 0 Percent: 0.0% 21 | // [info] Score 11 Games: 0 Percent: 0.0% 22 | // [info] Score 12 Games: 0 Percent: 0.0% 23 | // [info] Score 13 Games: 0 Percent: 0.0% 24 | // [info] Score 14 Games: 0 Percent: 0.0% 25 | // [info] Score 15 Games: 0 Percent: 0.0% 26 | // [info] Score 16 Games: 0 Percent: 0.0% 27 | // [info] Score 17 Games: 0 Percent: 0.0% 28 | // [info] Score 18 Games: 0 Percent: 0.0% 29 | // [info] Score 19 Games: 0 Percent: 0.0% 30 | // [info] Score 20 Games: 0 Percent: 0.0% 31 | // [info] Score 21 Games: 0 Percent: 0.0% 32 | // [info] Score 22 Games: 0 Percent: 0.0% 33 | // [info] Score 23 Games: 0 Percent: 0.0% 34 | // [info] Score 24 Games: 0 Percent: 0.0% 35 | // [info] Score 25 Games: 0 Percent: 0.0% 36 | // [info] Average Utility: 1.3992 37 | 38 | object RandomPlayer extends PlayerGen { 39 | def apply(seed: Long, myPid: Int, rules: Rules): RandomPlayer = { 40 | new RandomPlayer(seed,myPid,rules) 41 | } 42 | 43 | def genPlayers(rules: Rules, seed: Long): Array[Player] = { 44 | (0 to (rules.numPlayers-1)).map { myPid => 45 | this(seed,myPid,rules) 46 | }.toArray 47 | } 48 | 49 | } 50 | 51 | class RandomPlayer(val seed: Long, val myPid: Int, val rules: Rules) extends Player { 52 | val rand = Rand(Array(seed,myPid.toLong)) 53 | val possibleHintTypes: Array[GiveHintType] = rules.possibleHintTypes() 54 | 55 | override def handleGameStart(game: Game): Unit = {} 56 | override def handleSeenAction(sa: SeenAction, preGame: Game, postGame: Game): Unit = {} 57 | 58 | override def getAction(game: Game): GiveAction = { 59 | rand.nextInt(5) match { 60 | case 0 => 61 | GivePlay(rand.nextInt(game.hands(myPid).numCards)) 62 | case 1 | 2 => 63 | if(game.numHints < rules.maxHints) 64 | GiveDiscard(rand.nextInt(game.hands(myPid).numCards)) 65 | else 66 | getAction(game) 67 | case 3 | 4 => 68 | if(game.numHints <= 0) 69 | getAction(game) 70 | else { 71 | var hintPid = rand.nextInt(rules.numPlayers-1) 72 | if(hintPid >= myPid) 73 | hintPid += 1 74 | var hintType = possibleHintTypes(rand.nextInt(possibleHintTypes.length)) 75 | while(!game.hands(hintPid).exists { cid => rules.hintApplies(hintType, game.seenMap(cid)) }) 76 | hintType = possibleHintTypes(rand.nextInt(possibleHintTypes.length)) 77 | GiveHint(hintPid, hintType) 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/main/scala/Actions.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Actions.scala 3 | * Basic types for hints and possible actions 4 | */ 5 | 6 | package fireflower 7 | 8 | //What kinds of hints may be given 9 | sealed trait GiveHintType 10 | //What kinds of hints a player may recieve / observe / learn 11 | sealed trait SeenHintType 12 | 13 | case class HintColor(color: Color) extends SeenHintType with GiveHintType 14 | case class HintNumber(number: Int) extends SeenHintType with GiveHintType 15 | //For some hanabi variants 16 | case object HintSameColor extends SeenHintType 17 | case object HintSameNumber extends SeenHintType 18 | case object HintSame extends SeenHintType 19 | 20 | //Not actually a legal hint, but used in when we need a hint type but don't exactly know what it could be. Applies to no cards. 21 | case object UnknownHint extends SeenHintType with GiveHintType 22 | 23 | 24 | //What kinds of actions a player may take 25 | sealed trait GiveAction 26 | case class GiveDiscard(hid: HandId) extends GiveAction 27 | case class GivePlay(hid: HandId) extends GiveAction 28 | case class GiveHint(pid: PlayerId, hint: GiveHintType) extends GiveAction 29 | 30 | //What kinds of actions a player may observe 31 | sealed trait SeenAction 32 | case class SeenDiscard(hid: HandId, cid: CardId) extends SeenAction 33 | case class SeenPlay(hid: HandId, cid: CardId) extends SeenAction 34 | case class SeenBomb(hid: HandId, cid: CardId) extends SeenAction 35 | case class SeenHint(pid: PlayerId, hint: SeenHintType, appliedTo: Array[Boolean]) extends SeenAction 36 | 37 | object GiveAction { 38 | 39 | //Parses inputs like "hint 2 R" or "play 5" into actions. 40 | def ofString(s:String) = { 41 | def fail() = throw new Exception() 42 | try { 43 | val pieces = s.split("\\s+").filter(_.nonEmpty).toArray 44 | 45 | if(pieces.length < 1) fail() 46 | pieces(0).toLowerCase match { 47 | case ("discard" | "givediscard") => 48 | if(pieces.length < 2) fail() 49 | val hid = pieces(1).toInt-1 50 | if(hid < 0) fail() 51 | GiveDiscard(hid) 52 | case ("play" | "giveplay" | "bomb") => 53 | if(pieces.length < 2) fail() 54 | val hid = pieces(1).toInt-1 55 | if(hid < 0) fail() 56 | GivePlay(hid) 57 | case ("hint" | "givehint" | "clue") => 58 | if(pieces.length < 3) fail() 59 | val pid = pieces(1).toInt 60 | if(pid < 0) fail() 61 | val hint = pieces(2).toLowerCase match { 62 | case ("r" | "red") => HintColor(Red) 63 | case ("g" | "green") => HintColor(Green) 64 | case ("y" | "yellow") => HintColor(Yellow) 65 | case ("b" | "blue") => HintColor(Blue) 66 | case ("w" | "white") => HintColor(White) 67 | case ("m" | "multi" | "multicolor" | "rainbow") => HintColor(MultiColor) 68 | case ("nullcolor") => HintColor(NullColor) 69 | case ("1") => HintNumber(0) 70 | case ("2") => HintNumber(1) 71 | case ("3") => HintNumber(2) 72 | case ("4") => HintNumber(3) 73 | case ("5") => HintNumber(4) 74 | case ("u" | "?" | "unknown" | "unknownhint") => UnknownHint 75 | } 76 | GiveHint(pid,hint) 77 | } 78 | } 79 | catch { 80 | case e:Exception => throw new Exception("Could not parse GiveAction: " + s + "\n", e) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/scala/Hand.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Hand.scala 3 | * A basic mutable structure for representing a person's hand in Hanabi. 4 | * Index 0 is the newest card drawn. 5 | */ 6 | 7 | package fireflower 8 | import scala.reflect.ClassTag 9 | 10 | object Hand { 11 | def apply(handSize: Int): Hand = new Hand(cards = Array.fill(handSize)(CardId.NULL), numCards = 0) 12 | def apply(that: Hand): Hand = new Hand(cards = that.cards.clone(), numCards = that.numCards) 13 | } 14 | 15 | class Hand private ( 16 | private val cards: Array[CardId], 17 | var numCards: Int 18 | ) { 19 | 20 | def length: Int = numCards 21 | 22 | def apply(hid: HandId): CardId = { 23 | cards(hid) 24 | } 25 | 26 | def add(cid: CardId): Unit = { 27 | var i = numCards 28 | while(i > 0) { 29 | cards(i) = cards(i-1) 30 | i -= 1 31 | } 32 | cards(0) = cid 33 | numCards += 1 34 | } 35 | 36 | def remove(hid: HandId): CardId = { 37 | var i = hid 38 | val removed = cards(i) 39 | while(i < numCards-1) { 40 | cards(i) = cards(i+1) 41 | i += 1 42 | } 43 | numCards -= 1 44 | cards(numCards) = CardId.NULL 45 | removed 46 | } 47 | 48 | def exists(f: CardId => Boolean): Boolean = { 49 | var i = 0 50 | var found = false 51 | while(i < numCards && !found) { 52 | found = f(cards(i)) 53 | i += 1 54 | } 55 | found 56 | } 57 | 58 | def forall(f: CardId => Boolean): Boolean = { 59 | var i = 0 60 | var allgood = true 61 | while(i < numCards && allgood) { 62 | allgood = f(cards(i)) 63 | i += 1 64 | } 65 | allgood 66 | } 67 | 68 | def foreach(f: CardId => Unit): Unit = { 69 | var i = 0 70 | while(i < numCards) { 71 | f(cards(i)) 72 | i += 1 73 | } 74 | } 75 | 76 | def foldLeft[U](init: U)(f: (U,CardId) => U): U = { 77 | var i = 0 78 | var acc = init 79 | while(i < numCards) { 80 | acc = f(acc,cards(i)) 81 | i += 1 82 | } 83 | acc 84 | } 85 | 86 | def count(f: CardId => Boolean): Int = { 87 | var i = 0 88 | var acc = 0 89 | while(i < numCards) { 90 | if(f(cards(i))) 91 | acc += 1 92 | i += 1 93 | } 94 | acc 95 | } 96 | 97 | def find(f: CardId => Boolean): Option[CardId] = { 98 | var i = 0 99 | var cid = 0 100 | var found = false 101 | while(i < numCards && !found) { 102 | if(f(cards(i))) { 103 | cid = cards(i) 104 | found = true 105 | } 106 | i += 1 107 | } 108 | if(found) Some(cid) 109 | else None 110 | } 111 | 112 | 113 | def findIdx(f: CardId => Boolean): Option[HandId] = { 114 | var i = 0 115 | var hid = 0 116 | var found = false 117 | while(i < numCards && !found) { 118 | if(f(cards(i))) { 119 | hid = i 120 | found = true 121 | } 122 | i += 1 123 | } 124 | if(found) Some(hid) 125 | else None 126 | } 127 | 128 | def contains(cid: CardId): Boolean = { 129 | cards.contains(cid) 130 | } 131 | 132 | //Makes a copy 133 | def cardArray(): Array[CardId] = { 134 | Array.tabulate(numCards) { i => cards(i) } 135 | } 136 | 137 | def mapCards[T:ClassTag](f: CardId => T): Array[T] = { 138 | Array.tabulate[T](numCards) { i => f(cards(i)) } 139 | } 140 | 141 | override def toString(): String = { 142 | cardArray().mkString("") 143 | } 144 | 145 | def toString(seenMap: SeenMap, useAnsiColors: Boolean): String = { 146 | mapCards { cid => seenMap(cid).toString(useAnsiColors) }.mkString("") 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fireflower 2 | 3 | ## Overview 4 | Fireflower is a bot-in-progress that plays (some variations of) the game Hanabi. Right now, in early stages and focusing mainly on two-player, and mostly focusing on playing to win rather than maximizing score. 5 | 6 | Currently (+/- 2 stdevs), assuming that players keep their points upon bombing out rather than being scored as 0: 7 | * Wins 2-player 52.6% +/- 0.6% of the time (based on 25000 games), with an average score of 23.37 +/- 0.04 8 | * Wins 3-player 40.2% +/- 3.1% of the time (based on 1000 games), with an average score of 22.95 +/- 0.21 9 | * Wins 4-player 26.5% +/- 2.8% of the time (based on 1000 games), with an average score of 22.83 +/- 0.17 10 | 11 | And assuming that bombing out is counted as 0 points on those same games: 12 | * 2-player average score was 22.56 +/- 0.07 (1226 games bombed) 13 | * 3-player average score was 21.05 +/- 0.49 (118 games bombed) 14 | * 4-player average score was 21.78 +/- 0.38 (67 games bombed) 15 | 16 | ## Getting started 17 | 18 | ### Setup 19 | 1. Install [sbt](http://www.scala-sbt.org/download.html) 20 | 2. Clone this github repo to any desired directory and navigate to that directory. 21 | 3. Run `sbt`. 22 | 4. Within sbt, run `compile` to build the Scala code 23 | 5. Within sbt, run `run` and select one of the options to run the Scala code. 24 | 25 | ### SBT memory issues 26 | 27 | Note that to avoid memory leaks via Java's permanent generation in a long-running sbt process, 28 | you may need to edit your sbt configuration (i.e. the sbt script installed at ~/bin/sbt) if 29 | you have Java 1.7 or earlier. If you do encounter out-of-memory issues in sbt, try editing the script 30 | to call Java with the following flags: 31 | 32 | -XX:+CMSClassUnloadingEnabled 33 | -XX:+UseConcMarkSweepGC 34 | -XX:MaxPermSize=1G 35 | 36 | ### Scala SBT file name length errors 37 | 38 | If during a compile in SBT you encounter the error `filename too long` or similar, it may be due to sbt trying to generate a file whose name exceeds the max allowed filename length on your system. See if you can specify an override for your sbt install to cap the filename length it uses: 39 | 40 | http://stackoverflow.com/questions/28565837/filename-too-long-sbt 41 | 42 | ## Code Overview 43 | 44 | A quick overview of what files in src/main/scala/ contain what, in approximately dependency order: 45 | * Infrastructure 46 | * RichTypes.scala - convenience functions 47 | * Rand.scala - basic system-independent random number generator 48 | * Game Implementation 49 | * CommonTypes.scala - some simple types and type aliases 50 | * Color.scala - defines Color type for colors of Hanabi cards 51 | * Card.scala - defines Card type for Hanabi cards 52 | * Actions.scala - defines types for hints and actions 53 | * Rules.scala - defines the possible Hanabi rule sets and game parameters 54 | * Hand.scala - a simple mutable container for storing a player's hand. 55 | * SeenMap.scala - a mapping that stores the mapping of cardIds to cards based on what cards have been seen or not 56 | * Game.scala - the main game implementation and rules 57 | * Player.scala - interface that players of the game need to satisfy 58 | * Sim.scala - functions to actually run the game with a group of players 59 | * Player Implementation 60 | * CardPropertyMap.scala - data structure to remember properties of cards, used in implementation of AI players 61 | * RandomPlayer.scala - a player that plays randomly 62 | * HeuristicPlayer.scala - a player that plays via hardcoded conventions and heuristics, with a bit of search 63 | * Top-level 64 | * Sandbox.scala - driver for random experimentation and debugging 65 | * Test.scala - a standard runnable set of tests to evaluate player quality 66 | 67 | ## Contributors 68 | 69 | * David Wu 70 | -------------------------------------------------------------------------------- /src/main/scala/Rules.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Rules.scala 3 | * Defines various rule sets for Hanabi. 4 | * Capable of expressing a variety of sets of game parameters and also 5 | * variations on how hints work, including rainbow cards and such. 6 | */ 7 | 8 | package fireflower 9 | 10 | abstract class Rules { 11 | val numPlayers: Int 12 | val deckSize: Int 13 | val handSize: Int 14 | val initialHints: Int 15 | val maxHints: Int 16 | val maxBombs: Int 17 | val maxDiscards: Int 18 | val maxScore: Int 19 | val maxNumber: Int 20 | 21 | //Do you get extra hints from playing 5s, or whatever the max number is? 22 | val extraHintFromPlayingMax: Boolean 23 | 24 | //Stop the game immediately if a maxScore is not reachable any more? 25 | val stopEarlyLoss: Boolean 26 | 27 | def cards(): Array[Card] 28 | def colors(): Array[Color] 29 | def possibleHintTypes(): Array[GiveHintType] 30 | 31 | def seenHint(hint: GiveHintType): SeenHintType 32 | def hintApplies(hint: GiveHintType, card: Card): Boolean 33 | 34 | //Is it possible for a card to be [card] when in a hand where this hint was seen 35 | //and the hint applied (or not) to this card? 36 | def isConsistent(hint: SeenHintType, applied: Boolean, card: Card): Boolean 37 | } 38 | 39 | object Rules { 40 | case class Standard(val numPlayers: Int, val stopEarlyLoss: Boolean = false) extends Rules { 41 | if(numPlayers < 2 || numPlayers > 5) 42 | throw new Exception("Standard rules do not support numPlayers < 2 or > 5") 43 | 44 | val deckSize = 50 45 | val handSize = numPlayers match { 46 | case 2 | 3 => 5 47 | case 4 | 5 => 4 48 | } 49 | 50 | val initialHints = 8 51 | val maxHints = 8 52 | val maxBombs = 2 53 | 54 | val maxDiscards = numPlayers match { 55 | case 2 => 17 56 | case 3 | 4 => 13 57 | case 5 => 10 58 | } 59 | 60 | val maxScore = 25 61 | val maxNumber = 4 62 | val extraHintFromPlayingMax = true 63 | 64 | val colorList: List[Color] = List(Red,Yellow,Green,Blue,White) 65 | val maxColorId = colorList.map(color => color.id).reduceLeft(math.max) 66 | 67 | def colors(): Array[Color] = { 68 | colorList.toArray 69 | } 70 | 71 | def cards(): Array[Card] = { 72 | (0 to maxNumber).flatMap { number => 73 | colorList.flatMap { color => 74 | if(number == 0) 75 | List(Card(color,number),Card(color,number),Card(color,number)) 76 | else if(number < maxNumber) 77 | List(Card(color,number),Card(color,number)) 78 | else 79 | List(Card(color,number)) 80 | } 81 | }.toArray 82 | } 83 | 84 | def possibleHintTypes(): Array[GiveHintType] = { 85 | colorList.map(color => HintColor(color)).toArray[GiveHintType] ++ 86 | (0 to maxNumber).map(number => HintNumber(number)).toArray[GiveHintType] 87 | } 88 | 89 | def seenHint(hint: GiveHintType): SeenHintType = { 90 | hint.asInstanceOf[SeenHintType] 91 | } 92 | 93 | def hintApplies(hint: GiveHintType, card: Card): Boolean = { 94 | hint match { 95 | case HintColor(color) => 96 | if(card == Card.NULL) 97 | throw new Exception("Null card used in Game.hintApplies") 98 | card.color == color 99 | case HintNumber(number) => 100 | if(card == Card.NULL) 101 | throw new Exception("Null card used in Game.hintApplies") 102 | card.number == number 103 | case UnknownHint => 104 | false 105 | } 106 | } 107 | 108 | def isConsistent(hint: SeenHintType, applied: Boolean, card: Card): Boolean = { 109 | hint match { 110 | case HintColor(color) => 111 | (card.color == color) == applied 112 | case HintNumber(number) => 113 | (card.number == number) == applied 114 | case HintSameColor | HintSameNumber | HintSame => true 115 | case UnknownHint => true 116 | } 117 | } 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/main/scala/Sim.scala: -------------------------------------------------------------------------------- 1 | package fireflower 2 | 3 | object Sim { 4 | def runSingle( 5 | rules: Rules, 6 | gameSeed: Long, 7 | playerSeed: Long, 8 | playerGen: PlayerGen, 9 | doPrint: Boolean, 10 | useAnsiColors: Boolean, 11 | debugTurnAndPath: Option[(Int,List[GiveAction])] 12 | ): Game = { 13 | val players = playerGen.genPlayers(rules,playerSeed) 14 | if(players.length != rules.numPlayers) 15 | throw new Exception("players.length (%d) != rules.numPlayers (%d)".format(players.length,rules.numPlayers)) 16 | 17 | if(doPrint) 18 | println("GameSeed: " + gameSeed + " PlayerSeed: " + playerSeed) 19 | 20 | val game = Game(rules,gameSeed) 21 | game.drawInitialCards() 22 | 23 | for(pid <- 0 to (players.length - 1)) { 24 | players(pid).handleGameStart(game.hiddenFor(pid)) 25 | } 26 | 27 | while(!game.isDone()) { 28 | debugTurnAndPath.foreach { case (turn,path) => 29 | if(game.turnNumber == turn) 30 | game.debugPath = Some(path) 31 | else 32 | game.debugPath = None 33 | } 34 | 35 | val player = players(game.curPlayer) 36 | val ga = player.getAction(game.hiddenFor(game.curPlayer)) 37 | if(!game.isLegal(ga)) { 38 | throw new Exception("Illegal action: " + game.giveActionToString(ga)) 39 | } 40 | else { 41 | val preGame = Game(game) 42 | val sa = game.seenAction(ga) 43 | if(doPrint) 44 | println(game.toString(useAnsiColors) + " " + game.seenActionToString(sa,useAnsiColors)) 45 | game.doAction(ga) 46 | for(pid <- 0 to (players.length - 1)) { 47 | players(pid).handleSeenAction(sa, preGame.hiddenFor(pid), game.hiddenFor(pid)) 48 | } 49 | } 50 | } 51 | 52 | if(doPrint) { 53 | println(game.toString(useAnsiColors)) 54 | } 55 | game 56 | } 57 | 58 | def runSingle( 59 | rules: Rules, 60 | playerGen: PlayerGen, 61 | doPrint: Boolean, 62 | useAnsiColors: Boolean 63 | ): Game = { 64 | val rand = Rand() 65 | val gameSeed = rand.nextLong() 66 | val playerSeed = rand.nextLong() 67 | runSingle( 68 | rules = rules, 69 | gameSeed = gameSeed, 70 | playerSeed = playerSeed, 71 | playerGen = playerGen, 72 | doPrint = doPrint, 73 | useAnsiColors = useAnsiColors, 74 | debugTurnAndPath = None 75 | ) 76 | } 77 | 78 | def runMulti( 79 | name: String, 80 | rules: Rules, 81 | numGames: Int, 82 | runSeed: Long, 83 | playerGen: PlayerGen, 84 | doPrint: Boolean, 85 | doPrintDetails: Boolean, 86 | useAnsiColors: Boolean 87 | ): List[Game] = { 88 | if(doPrint) 89 | println(name + " starting " + numGames + " games, runSeed: " + runSeed) 90 | 91 | val rand = Rand(runSeed) 92 | val games = 93 | (0 to (numGames-1)).map { i => 94 | val gameSeed = rand.nextLong() 95 | val playerSeed = rand.nextLong() 96 | val game = runSingle( 97 | rules = rules, 98 | gameSeed = gameSeed, 99 | playerSeed = playerSeed, 100 | playerGen = playerGen, 101 | doPrint = doPrintDetails, 102 | useAnsiColors = useAnsiColors, 103 | debugTurnAndPath = None 104 | ) 105 | if(doPrint) 106 | println(name + " Game " + i + " Score: " + game.numPlayed + " GameSeed: " + gameSeed) 107 | game 108 | }.toList 109 | games 110 | } 111 | 112 | def runMulti( 113 | name: String, 114 | rules: Rules, 115 | numGames: Int, 116 | playerGen: PlayerGen, 117 | doPrint: Boolean, 118 | doPrintDetails: Boolean, 119 | useAnsiColors: Boolean 120 | ): List[Game] = { 121 | val rand = Rand() 122 | runMulti( 123 | name = name, 124 | rules = rules, 125 | numGames = numGames, 126 | runSeed = rand.nextLong(), 127 | playerGen = playerGen, 128 | doPrint = doPrint, 129 | doPrintDetails = doPrintDetails, 130 | useAnsiColors = useAnsiColors 131 | ) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/main/scala/Interactive.scala: -------------------------------------------------------------------------------- 1 | package fireflower 2 | 3 | import RichImplicits._ 4 | 5 | object Interactive { 6 | def inputCard(prompt:String): Card = { 7 | print(prompt + ": ") 8 | //TODO fix this 9 | assertUnreachable() 10 | } 11 | 12 | def inputAction(prompt:String, game: Game): SeenAction = { 13 | print(prompt + ": ") 14 | //TODO fix this 15 | assertUnreachable() 16 | } 17 | 18 | def playInRealWorld(numPlayers: Int, myPid: PlayerId): Unit = { 19 | val rules = Rules.Standard(numPlayers) 20 | val game = Game(rules,seed=0L) 21 | game.drawInitialCards() 22 | 23 | val player = HeuristicPlayer(rules,myPid) 24 | for(pid <- 0 to (rules.numPlayers-1)) { 25 | game.hideFor(pid) 26 | } 27 | 28 | { 29 | println("Enter cards players drew, starting with the person to your left, from newest to oldest.") 30 | var pid = (myPid+1) % rules.numPlayers 31 | while(pid != myPid) { 32 | for(hid <- 0 to (game.hands(pid).length-1)) { 33 | game.seenMap(game.hands(pid)(hid)) = inputCard("Player " + pid + "HandPos " + (hid+1)) 34 | } 35 | pid = (pid+1) % rules.numPlayers 36 | } 37 | } 38 | player.handleGameStart(Game(game)) 39 | 40 | var pid = 0 41 | while(!game.isDone()) { 42 | val preGame = Game(game) 43 | if(pid == myPid) { 44 | val ga = player.getAction(Game(game)) 45 | ga match { 46 | case GiveDiscard(hid) => 47 | println("Discard #" + (hid+1)) 48 | val card = inputCard("Discard was a") 49 | game.seenMap(game.hands(pid)(hid)) = card 50 | val sa = game.seenAction(ga) 51 | game.doAction(ga) 52 | player.handleSeenAction(sa, preGame, Game(game)) 53 | case GivePlay(hid) => 54 | println("Play #" + (hid+1)) 55 | val card = inputCard("Play was a") 56 | game.seenMap(game.hands(pid)(hid)) = card 57 | val sa = game.seenAction(ga) 58 | game.doAction(ga) 59 | player.handleSeenAction(sa, preGame, Game(game)) 60 | case GiveHint(pid,hint) => 61 | val sa = game.seenAction(ga) match { 62 | case (_: SeenDiscard) => assertUnreachable() 63 | case (_: SeenPlay) => assertUnreachable() 64 | case (_: SeenBomb) => assertUnreachable() 65 | case (x: SeenHint) => x 66 | } 67 | game.doAction(ga) 68 | player.handleSeenAction(sa, preGame, Game(game)) 69 | 70 | val appliedTo = sa.appliedTo.zipWithIndex.flatMap { case (b,hid) => 71 | if(b) Some(hid.toString) else None 72 | }.mkString("") 73 | val hintString = sa.hint match { 74 | case HintColor(color) => 75 | color.toAnsiColorCode() + color.toString() + Color.ansiResetColor 76 | case HintNumber(number) => 77 | (number+1).toString() 78 | case HintSameColor => 79 | "a color" 80 | case HintSameNumber => 81 | "a number" 82 | case HintSame => 83 | "something" 84 | case UnknownHint => 85 | "unknown" 86 | } 87 | println("Hint player " + pid + " cards #" + appliedTo + " are " + hintString) 88 | } 89 | } 90 | else { 91 | val sa = inputAction("Player " + pid + " action", game) 92 | //Hacky - convert sa to a ga, and the precise hint doesn't matter because the 93 | //game's state update doesn't depend on it! 94 | var shouldDraw = game.finalTurnsLeft < 0 95 | val ga: GiveAction = sa match { 96 | case SeenDiscard(hid,_) => GiveDiscard(hid) 97 | case SeenPlay(hid,_) => GivePlay(hid) 98 | case SeenBomb(hid,_) => GivePlay(hid) 99 | case SeenHint(pid,_,_) => shouldDraw=false; GiveHint(pid,HintNumber(0)) 100 | } 101 | game.doAction(ga) 102 | if(shouldDraw) { 103 | val card = inputCard("Drawn card was") 104 | game.seenMap(game.hands(pid)(0)) = card 105 | } 106 | player.handleSeenAction(sa, preGame, Game(game)) 107 | } 108 | pid = (pid+1) % rules.numPlayers 109 | } 110 | } 111 | 112 | def main(args: Array[String]): Unit = { 113 | playInRealWorld(numPlayers=2, myPid=0) 114 | 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/scala/SeenMap.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * SeenMap.scala 3 | * A basic mutable structure that maps CardId => Card, or to Card.NULL, representing which cards are visible 4 | * and which cards are not visible. Also keeps counts of the number of unseen cards of each type. 5 | */ 6 | 7 | package fireflower 8 | 9 | object SeenMap { 10 | def apply(rules: Rules, rand: Rand): SeenMap = { 11 | val cards = rules.cards() 12 | rand.shuffle(cards) 13 | new SeenMap( 14 | cards = cards, 15 | numUnseenByCard = Array.fill(Card.maxArrayIdx)(0), 16 | numUnseen = 0, 17 | distinctCards = cards.distinct 18 | ) 19 | } 20 | def apply(that: SeenMap): SeenMap = { 21 | new SeenMap( 22 | cards = that.cards.clone(), 23 | numUnseenByCard = that.numUnseenByCard.clone(), 24 | numUnseen = that.numUnseen, 25 | distinctCards = that.distinctCards.clone() 26 | ) 27 | } 28 | def empty(rules: Rules): SeenMap = { 29 | val cards = rules.cards() 30 | val numUnseenByCard = Array.fill(Card.maxArrayIdx)(0) 31 | cards.foreach { card => numUnseenByCard(card.arrayIdx) += 1 } 32 | new SeenMap( 33 | cards = Array.fill(rules.deckSize)(Card.NULL), 34 | numUnseenByCard = numUnseenByCard, 35 | numUnseen = rules.deckSize, 36 | distinctCards = cards.distinct 37 | ) 38 | } 39 | } 40 | 41 | class SeenMap private ( 42 | //Maps CardId -> Card, or Card.NULL if not known 43 | val cards: Array[Card], 44 | //Count of number of unseen cards, indexed by card.arrayIdx 45 | val numUnseenByCard: Array[Int], 46 | //Total number of unseen cards 47 | var numUnseen: Int, 48 | //Unique cards in the deck 49 | val distinctCards: Array[Card] 50 | ) { 51 | 52 | def copyTo(other: SeenMap): Unit = { 53 | Array.copy(cards, 0, other.cards, 0, cards.size) 54 | Array.copy(numUnseenByCard, 0, other.numUnseenByCard, 0, numUnseenByCard.size) 55 | other.numUnseen = numUnseen 56 | Array.copy(distinctCards, 0, other.distinctCards, 0, distinctCards.size) 57 | } 58 | 59 | def apply(cid: CardId): Card = { 60 | cards(cid) 61 | } 62 | 63 | //Swap the cards mapped to two card ids 64 | def swap(c0: CardId, c1: CardId): Unit = { 65 | val tmp = cards(c0) 66 | cards(c0) = cards(c1) 67 | cards(c1) = tmp 68 | } 69 | 70 | //Set the mapping of card for a card id, does not check validity 71 | def update(cid: CardId, card: Card): Unit = { 72 | val oldCard = cards(cid) 73 | if(oldCard != Card.NULL) { 74 | numUnseenByCard(oldCard.arrayIdx) += 1 75 | } 76 | cards(cid) = card 77 | if(card != Card.NULL) { 78 | numUnseenByCard(card.arrayIdx) -= 1 79 | } 80 | } 81 | 82 | //Get a list of the distinct cards that have at least one unseen 83 | def distinctUnseen(): List[Card] = { 84 | var list: List[Card] = Nil 85 | var i: Int = distinctCards.length-1 86 | while(i >= 0) { 87 | if(numUnseenByCard(distinctCards(i).arrayIdx) > 0) 88 | list = list :+ distinctCards(i) 89 | i -= 1 90 | } 91 | list 92 | } 93 | 94 | //Get a list of the distinct cards that have at least one unseen, filtering it in the process 95 | def filterDistinctUnseen(f: Card => Boolean): List[Card] = { 96 | var list: List[Card] = Nil 97 | var i: Int = distinctCards.length-1 98 | while(i >= 0) { 99 | if(numUnseenByCard(distinctCards(i).arrayIdx) > 0 && f(distinctCards(i))) 100 | list = list :+ distinctCards(i) 101 | i -= 1 102 | } 103 | list 104 | } 105 | 106 | //If there is a unique distinct unseen card for which f is true, return it, else return Card.NULL 107 | def filterUniqueDistinctUnseen(f: Card => Boolean): Card = { 108 | def loop(i:Int, matched:Card): Card = { 109 | if(i == distinctCards.length) 110 | matched 111 | else { 112 | val curIsMatch = numUnseenByCard(distinctCards(i).arrayIdx) > 0 && f(distinctCards(i)) 113 | if(curIsMatch && matched == Card.NULL) 114 | loop(i+1, distinctCards(i)) 115 | else if(curIsMatch) 116 | Card.NULL 117 | else 118 | loop(i+1,matched) 119 | } 120 | } 121 | loop(0,Card.NULL) 122 | } 123 | 124 | //Find if there is any unseen card for which f is true, else return Card.NULL 125 | def existsUnseen(f: Card => Boolean): Boolean = { 126 | distinctCards.exists { card => 127 | numUnseenByCard(card.arrayIdx) > 0 && f(card) 128 | } 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /src/main/scala/Rand.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Rand.scala 3 | * A simple class for random number generation, independent of the scala or java built-in random. High-period and high-quality. 4 | */ 5 | 6 | package fireflower 7 | 8 | object Rand { 9 | def apply(): Rand = new Rand(RandUtils.makeSeedsFromTime("Rand",2)) 10 | def apply(seed: Long) = new Rand(Array(seed)) 11 | def apply(seeds: Array[Long]) = new Rand(seeds) 12 | } 13 | 14 | class Rand private (seeds: Array[Long]) { 15 | val seed = seeds.map(_.toString).mkString("|") 16 | 17 | val xoro = new Xoroshiro128plus(seed) 18 | val xors = new XorShift1024Mult(seed) 19 | val pcg = new PCG32(seed) 20 | 21 | def nextLong(): Long = (xoro.next() ^ xors.next()) + pcg.next() 22 | def nextInt(): Int = nextLong.toInt 23 | def nextInt(n: Int): Int = { 24 | if(n <= 0) throw new Exception("Rand.nextInt(n): non-positive n: " + n) 25 | var x = 0 26 | var y = 0 27 | do { 28 | x = nextInt() & 0x7FFFFFFF 29 | y = x % n 30 | } while (x - y + (n - 1) < 0); 31 | y 32 | } 33 | 34 | def nextDouble(): Double = (nextLong() & 0x1FFFFFFFFFFFFFL) / (1L << 53).toDouble 35 | 36 | def shuffle[T](arr: Array[T]): Unit = { 37 | for(i <- 1 to (arr.length-1)) { 38 | val j = nextInt(i+1) 39 | val tmp = arr(i) 40 | arr(i) = arr(j) 41 | arr(j)= tmp 42 | } 43 | } 44 | } 45 | 46 | 47 | //-------------------------------------------------------------------------------------- 48 | //Implementation of underlying random number generators 49 | //-------------------------------------------------------------------------------------- 50 | 51 | trait LongGen { 52 | def next(): Long 53 | } 54 | 55 | //xoroshiro128plus from http://xoroshiro.di.unimi.it/ 56 | //Initial values must be not both zero 57 | //Period = 2^128 - 1 58 | class Xoroshiro128plus(initX0: Long, initX1: Long) extends LongGen { 59 | 60 | private def this(longs: Array[Long]) = this(longs(0),longs(1)) 61 | def this(seed: String) = this(RandUtils.makeNonzeroSeedsFromSeed("Xoroshiro128plus",seed,2)) 62 | 63 | private var x0: Long = initX0 64 | private var x1: Long = initX1 65 | 66 | def rotateLeft(x: Long, i: Int): Long = { 67 | (x << i) | (x >>> (64 - i)); 68 | } 69 | 70 | def next(): Long = { 71 | var s0 = x0 72 | var s1 = x1 73 | 74 | s1 ^= s0 75 | x0 = rotateLeft(s0,55) ^ s1 ^ (s1 << 14) 76 | x1 = rotateLeft(s1,36) 77 | 78 | x0 + x1 79 | } 80 | } 81 | 82 | //xorshift1024* from http://xoroshiro.di.unimi.it/ 83 | //Not all values should be zero 84 | //Period = 2^1024 - 1 85 | class XorShift1024Mult(initS: Array[Long]) extends LongGen { 86 | val len: Int = 16 87 | if(initS.length != len) 88 | throw new Exception("XorShift1024Mult initialized with array initS of length != 16") 89 | 90 | def this(seed: String) = this(RandUtils.makeNonzeroSeedsFromSeed("XorShift1024Mult",seed,16)) 91 | 92 | private var s: Array[Long] = initS.clone 93 | private var idx: Int = 0 94 | 95 | def next(): Long = { 96 | val s0 = s(idx) 97 | idx = (idx + 1) % len 98 | var s1 = s(idx) 99 | s1 ^= s1 << 31 100 | s(idx) = s1 ^ s0 ^ (s1 >>> 11) ^ (s0 >>> 30) 101 | 102 | s(idx) * 1181783497276652981L 103 | } 104 | } 105 | 106 | //PCG Generator from http://www.pcg-random.org/ 107 | //Period = 2^64 108 | class PCG32(initS: Long) extends LongGen { 109 | def this(seed: String) = this(RandUtils.makeNonzeroSeedsFromSeed("PCG32",seed,1)(0)) 110 | 111 | private var s: Long = initS 112 | 113 | def nextInt(): Int = { 114 | s = s * 6364136223846793005L + 1442695040888963407L 115 | val x: Int = (((s >>> 18) ^ s) >>> 27).toInt 116 | val rot: Int = (s >>> 59).toInt 117 | (x >>> rot) | (x << (32-rot)) 118 | } 119 | 120 | def next(): Long = { 121 | val low = nextInt() 122 | val high = nextInt() 123 | (low.toLong & 0xFFFFFFFFL) ^ (high.toLong << 32) 124 | } 125 | } 126 | 127 | object RandUtils { 128 | def sha256Bytes(s: String): Array[Byte] = { 129 | java.security.MessageDigest.getInstance("SHA-256").digest(s.getBytes("UTF-8")) 130 | } 131 | def sha256(s: String): String = { 132 | javax.xml.bind.DatatypeConverter.printHexBinary(sha256Bytes(s)) 133 | } 134 | 135 | def sha256Long(s: String): Long = { 136 | bytesToLongs(sha256Bytes("sha256Long" + sha256(s)))(0) 137 | } 138 | 139 | def bytesToLongs(bytes: Array[Byte]): Array[Long] = { 140 | val longs = (0 to (bytes.length / 8 - 1)).map { i => 141 | ((bytes(i*8+0).toLong & 0xFFL) << 0) | 142 | ((bytes(i*8+1).toLong & 0xFFL) << 8) | 143 | ((bytes(i*8+2).toLong & 0xFFL) << 16) | 144 | ((bytes(i*8+3).toLong & 0xFFL) << 24) | 145 | ((bytes(i*8+4).toLong & 0xFFL) << 32) | 146 | ((bytes(i*8+5).toLong & 0xFFL) << 40) | 147 | ((bytes(i*8+6).toLong & 0xFFL) << 48) | 148 | ((bytes(i*8+7).toLong & 0xFFL) << 56) 149 | } 150 | longs.toArray 151 | } 152 | 153 | private val counter: java.util.concurrent.atomic.AtomicLong = new java.util.concurrent.atomic.AtomicLong() 154 | def makeSeedsFromTime(salt: String, num: Int): Array[Long] = { 155 | val len = (num + 3) / 4 //divide rounded up 156 | val hashsalt = sha256(salt) 157 | val bytes: Seq[Byte] = (0 to (len-1)).flatMap { i => 158 | val hashStr = "fromtime:" + i + ":" + counter.incrementAndGet() + ":" + System.nanoTime() + ":" + hashsalt 159 | sha256Bytes(hashStr) 160 | } 161 | bytesToLongs(bytes.toArray).slice(0,num) 162 | } 163 | 164 | def makeNonzeroSeedsFromSeed(salt: String, seed: String, num: Int): Array[Long] = { 165 | val len = (num + 3) / 4 //divide rounded up 166 | val hashsalt = sha256(salt) 167 | 168 | def loop(tries: Int): Array[Long] = { 169 | val bytes: Seq[Byte] = (0 to (len-1)).flatMap { i => 170 | val hashStr = "fromseed:" + tries + ":" + i + ":" + hashsalt + ":" + seed 171 | sha256Bytes(hashStr) 172 | } 173 | val result = bytesToLongs(bytes.toArray).slice(0,num) 174 | if(result.exists { x => x != 0 }) 175 | result 176 | else 177 | loop(tries+1) 178 | } 179 | loop(0) 180 | } 181 | } 182 | 183 | 184 | object RandTest { 185 | def test(): Unit = { 186 | val xoro = new Xoroshiro128plus(12345,67890) 187 | 188 | val xorm = new XorShift1024Mult(Array( 189 | -3298461724703502529L, 190 | 3601266951833665894L, 191 | -1517299006908105192L, 192 | -4970805572606481462L, 193 | -2733606064565797204L, 194 | 4148159782736716337L, 195 | -2411149239708519475L, 196 | 5555591070439871209L, 197 | 4101130512537511022L, 198 | -5625196436916664707L, 199 | 9050874162294428797L, 200 | 6187760405891629771L, 201 | -8393097797189788308L, 202 | 2219782655280501359L, 203 | 3719698449347562208L, 204 | 5421263376768154227L 205 | )) 206 | 207 | val pcg = new PCG32(123) 208 | 209 | //First value should be 1c9390b04e43f913 210 | //Last value should be a50a8874ba8ba1d2 211 | for(i <- 1 to 150) println("Xoroshiro128plus: " + i + " " + xoro.next().toHexString) 212 | 213 | //First value should be 749746d1c27d2463 214 | //Last value should be f9add2e499dabbfc 215 | for(i <- 1 to 150) println("Xorshift1024mult: " + i + " " + xorm.next().toHexString) 216 | 217 | //First value should be 65fdd305b3766cbd 218 | //Last value should be e4aef6a6d6858d66 219 | for(i <- 1 to 150) println("PCG32: " + i + " " + pcg.next().toHexString) 220 | 221 | //Should be: EF537F25C895BFA782526529A9B63D97AA631564D5D789C2B765448C8635FB6C 222 | println("SHA256: " + RandUtils.sha256("The quick brown fox jumps over the lazy dog.")) 223 | //Should be: a7bf95c8257f53ef 224 | println("SHA256Longs(0): " + RandUtils.bytesToLongs(RandUtils.sha256Bytes("The quick brown fox jumps over the lazy dog."))(0).toHexString) 225 | 226 | println("Some random numbers:") 227 | val rand: Rand = Rand() 228 | for(i <- 1 to 10) 229 | println(rand.nextInt() + " " + rand.nextLong() + " " + rand.nextInt(10) + " " + rand.nextDouble()) 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/main/scala/Tests.scala: -------------------------------------------------------------------------------- 1 | package fireflower 2 | 3 | object PlayerTests { 4 | 5 | def main(args: Array[String]): Unit = { 6 | val numGames = { 7 | if(args.length >= 1) 8 | args(0).toInt 9 | else 10 | 1000 11 | } 12 | val numPlayers = { 13 | if(args.length >= 2) 14 | Some(args(1).toInt) 15 | else 16 | None 17 | } 18 | println("NumGames=" + numGames) 19 | 20 | runTests(prefix="",salt="g",numGames=numGames, numPlayers=numPlayers) 21 | } 22 | 23 | def runTests(prefix: String, salt: String, numGames: Int, numPlayers: Option[Int]): Unit = { 24 | val start = System.nanoTime() 25 | val rules2p = Rules.Standard(numPlayers=2,stopEarlyLoss=false) 26 | val rules3p = Rules.Standard(numPlayers=3,stopEarlyLoss=false) 27 | val rules4p = Rules.Standard(numPlayers=4,stopEarlyLoss=false) 28 | 29 | def makeRunSeed(name:String): Long = { 30 | RandUtils.sha256Long(RandUtils.sha256Long(name) + salt) 31 | } 32 | 33 | val name2p = prefix + "HeuristicStandard2P" 34 | val games2p = { 35 | Sim.runMulti( 36 | name = name2p, 37 | rules = rules2p, 38 | numGames, 39 | runSeed = makeRunSeed(name2p), 40 | playerGen = HeuristicPlayer, 41 | doPrint = true, 42 | doPrintDetails = false, 43 | useAnsiColors = true 44 | ) 45 | } 46 | println(name2p + ":") 47 | printScoreSummary(rules2p,games2p) 48 | 49 | val name3p = prefix + "HeuristicStandard3P" 50 | val games3p = { 51 | Sim.runMulti( 52 | name = name3p, 53 | rules = rules3p, 54 | numGames, 55 | runSeed = makeRunSeed(name3p), 56 | playerGen = HeuristicPlayer, 57 | doPrint = true, 58 | doPrintDetails = false, 59 | useAnsiColors = true 60 | ) 61 | } 62 | println(name3p + ":") 63 | printScoreSummary(rules3p,games3p) 64 | 65 | val name4p = prefix + "HeuristicStandard4P" 66 | val games4p = { 67 | Sim.runMulti( 68 | name = name4p, 69 | rules = rules4p, 70 | numGames, 71 | runSeed = makeRunSeed(name4p), 72 | playerGen = HeuristicPlayer, 73 | doPrint = true, 74 | doPrintDetails = false, 75 | useAnsiColors = true 76 | ) 77 | } 78 | println(name4p + ":") 79 | printScoreSummary(rules4p,games4p) 80 | 81 | val end = System.nanoTime() 82 | 83 | println("Done!") 84 | println("") 85 | println(name2p + ":") 86 | printScoreSummary(rules2p,games2p) 87 | printScoreSummaryBombZero(rules2p,games2p) 88 | println("") 89 | println(name3p + ":") 90 | printScoreSummary(rules3p,games3p) 91 | printScoreSummaryBombZero(rules3p,games3p) 92 | println("") 93 | println(name4p + ":") 94 | printScoreSummary(rules4p,games4p) 95 | printScoreSummaryBombZero(rules4p,games4p) 96 | println("") 97 | println("Time: " + (end-start).toDouble / 1.0e9) 98 | } 99 | 100 | def printScoreSummary(rules: Rules, games: List[Game]) = { 101 | val scoreTable = (0 to rules.maxScore).map { score => 102 | (score,games.count(game => game.numPlayed == score)) 103 | } 104 | val numGames = games.length 105 | var cumulativeCount = 0 106 | scoreTable.foreach { case (score,count) => 107 | println("Score %2d Games: %2d Percent: %4.1f%% Cum: %4.1f%%".format( 108 | score, count, count.toDouble * 100.0 / numGames, (numGames - cumulativeCount.toDouble) * 100.0 / numGames 109 | )) 110 | cumulativeCount += count 111 | } 112 | val avgScore = scoreTable.foldLeft(0) { case (acc,(score,count)) => 113 | acc + count * score 114 | }.toDouble / numGames 115 | val avgUtility = scoreTable.foldLeft(0) { case (acc,(score,count)) => 116 | acc + count * (if(score == rules.maxScore) score * 4 else score * 2) 117 | }.toDouble / numGames 118 | println("Average Score: " + avgScore) 119 | println("Average Utility: " + avgUtility) 120 | } 121 | 122 | def printScoreSummaryBombZero(rules: Rules, games: List[Game]) = { 123 | val scoreTable = (0 to rules.maxScore).map { score => 124 | (score,games.count(game => (if (game.numBombs > rules.maxBombs) 0 else game.numPlayed) == score)) 125 | } 126 | val numGames = games.length 127 | var cumulativeCount = 0 128 | scoreTable.foreach { case (score,count) => 129 | println("Score %2d Games: %2d Percent: %4.1f%% Cum: %4.1f%%".format( 130 | score, count, count.toDouble * 100.0 / numGames, (numGames - cumulativeCount.toDouble) * 100.0 / numGames 131 | )) 132 | cumulativeCount += count 133 | } 134 | val avgScore = scoreTable.foldLeft(0) { case (acc,(score,count)) => 135 | acc + count * score 136 | }.toDouble / numGames 137 | val avgUtility = scoreTable.foldLeft(0) { case (acc,(score,count)) => 138 | acc + count * (if(score == rules.maxScore) score * 4 else score * 2) 139 | }.toDouble / numGames 140 | println("Average Score: " + avgScore) 141 | println("Average Utility: " + avgUtility) 142 | } 143 | 144 | /* 145 | Results: 146 | 147 | [info] HeuristicStandard2P: 148 | [info] Score 0 Games: 0 Percent: 0.0% Cum: 100.0% 149 | [info] Score 1 Games: 0 Percent: 0.0% Cum: 100.0% 150 | [info] Score 2 Games: 1 Percent: 0.1% Cum: 100.0% 151 | [info] Score 3 Games: 0 Percent: 0.0% Cum: 99.9% 152 | [info] Score 4 Games: 0 Percent: 0.0% Cum: 99.9% 153 | [info] Score 5 Games: 0 Percent: 0.0% Cum: 99.9% 154 | [info] Score 6 Games: 1 Percent: 0.1% Cum: 99.9% 155 | [info] Score 7 Games: 0 Percent: 0.0% Cum: 99.8% 156 | [info] Score 8 Games: 1 Percent: 0.1% Cum: 99.8% 157 | [info] Score 9 Games: 2 Percent: 0.2% Cum: 99.7% 158 | [info] Score 10 Games: 2 Percent: 0.2% Cum: 99.5% 159 | [info] Score 11 Games: 3 Percent: 0.3% Cum: 99.3% 160 | [info] Score 12 Games: 1 Percent: 0.1% Cum: 99.0% 161 | [info] Score 13 Games: 1 Percent: 0.1% Cum: 98.9% 162 | [info] Score 14 Games: 4 Percent: 0.4% Cum: 98.8% 163 | [info] Score 15 Games: 3 Percent: 0.3% Cum: 98.4% 164 | [info] Score 16 Games: 7 Percent: 0.7% Cum: 98.1% 165 | [info] Score 17 Games: 9 Percent: 0.9% Cum: 97.4% 166 | [info] Score 18 Games: 15 Percent: 1.5% Cum: 96.5% 167 | [info] Score 19 Games: 28 Percent: 2.8% Cum: 95.0% 168 | [info] Score 20 Games: 23 Percent: 2.3% Cum: 92.2% 169 | [info] Score 21 Games: 38 Percent: 3.8% Cum: 89.9% 170 | [info] Score 22 Games: 58 Percent: 5.8% Cum: 86.1% 171 | [info] Score 23 Games: 114 Percent: 11.4% Cum: 80.3% 172 | [info] Score 24 Games: 124 Percent: 12.4% Cum: 68.9% 173 | [info] Score 25 Games: 565 Percent: 56.5% Cum: 56.5% 174 | [info] Average Score: 23.537 175 | [info] Average Utility: 75.324 176 | 177 | [info] HeuristicStandard3P: 178 | [info] Score 0 Games: 0 Percent: 0.0% Cum: 100.0% 179 | [info] Score 1 Games: 0 Percent: 0.0% Cum: 100.0% 180 | [info] Score 2 Games: 0 Percent: 0.0% Cum: 100.0% 181 | [info] Score 3 Games: 0 Percent: 0.0% Cum: 100.0% 182 | [info] Score 4 Games: 3 Percent: 0.3% Cum: 100.0% 183 | [info] Score 5 Games: 3 Percent: 0.3% Cum: 99.7% 184 | [info] Score 6 Games: 4 Percent: 0.4% Cum: 99.4% 185 | [info] Score 7 Games: 1 Percent: 0.1% Cum: 99.0% 186 | [info] Score 8 Games: 2 Percent: 0.2% Cum: 98.9% 187 | [info] Score 9 Games: 1 Percent: 0.1% Cum: 98.7% 188 | [info] Score 10 Games: 4 Percent: 0.4% Cum: 98.6% 189 | [info] Score 11 Games: 8 Percent: 0.8% Cum: 98.2% 190 | [info] Score 12 Games: 3 Percent: 0.3% Cum: 97.4% 191 | [info] Score 13 Games: 3 Percent: 0.3% Cum: 97.1% 192 | [info] Score 14 Games: 6 Percent: 0.6% Cum: 96.8% 193 | [info] Score 15 Games: 9 Percent: 0.9% Cum: 96.2% 194 | [info] Score 16 Games: 7 Percent: 0.7% Cum: 95.3% 195 | [info] Score 17 Games: 9 Percent: 0.9% Cum: 94.6% 196 | [info] Score 18 Games: 12 Percent: 1.2% Cum: 93.7% 197 | [info] Score 19 Games: 24 Percent: 2.4% Cum: 92.5% 198 | [info] Score 20 Games: 25 Percent: 2.5% Cum: 90.1% 199 | [info] Score 21 Games: 42 Percent: 4.2% Cum: 87.6% 200 | [info] Score 22 Games: 77 Percent: 7.7% Cum: 83.4% 201 | [info] Score 23 Games: 135 Percent: 13.5% Cum: 75.7% 202 | [info] Score 24 Games: 220 Percent: 22.0% Cum: 62.2% 203 | [info] Score 25 Games: 402 Percent: 40.2% Cum: 40.2% 204 | [info] Average Score: 22.953 205 | [info] Average Utility: 66.006 206 | 207 | [info] HeuristicStandard4P: 208 | [info] Score 0 Games: 0 Percent: 0.0% Cum: 100.0% 209 | [info] Score 1 Games: 0 Percent: 0.0% Cum: 100.0% 210 | [info] Score 2 Games: 0 Percent: 0.0% Cum: 100.0% 211 | [info] Score 3 Games: 0 Percent: 0.0% Cum: 100.0% 212 | [info] Score 4 Games: 0 Percent: 0.0% Cum: 100.0% 213 | [info] Score 5 Games: 1 Percent: 0.1% Cum: 100.0% 214 | [info] Score 6 Games: 1 Percent: 0.1% Cum: 99.9% 215 | [info] Score 7 Games: 0 Percent: 0.0% Cum: 99.8% 216 | [info] Score 8 Games: 3 Percent: 0.3% Cum: 99.8% 217 | [info] Score 9 Games: 1 Percent: 0.1% Cum: 99.5% 218 | [info] Score 10 Games: 3 Percent: 0.3% Cum: 99.4% 219 | [info] Score 11 Games: 2 Percent: 0.2% Cum: 99.1% 220 | [info] Score 12 Games: 2 Percent: 0.2% Cum: 98.9% 221 | [info] Score 13 Games: 6 Percent: 0.6% Cum: 98.7% 222 | [info] Score 14 Games: 2 Percent: 0.2% Cum: 98.1% 223 | [info] Score 15 Games: 9 Percent: 0.9% Cum: 97.9% 224 | [info] Score 16 Games: 8 Percent: 0.8% Cum: 97.0% 225 | [info] Score 17 Games: 9 Percent: 0.9% Cum: 96.2% 226 | [info] Score 18 Games: 8 Percent: 0.8% Cum: 95.3% 227 | [info] Score 19 Games: 25 Percent: 2.5% Cum: 94.5% 228 | [info] Score 20 Games: 35 Percent: 3.5% Cum: 92.0% 229 | [info] Score 21 Games: 66 Percent: 6.6% Cum: 88.5% 230 | [info] Score 22 Games: 124 Percent: 12.4% Cum: 81.9% 231 | [info] Score 23 Games: 188 Percent: 18.8% Cum: 69.5% 232 | [info] Score 24 Games: 242 Percent: 24.2% Cum: 50.7% 233 | [info] Score 25 Games: 265 Percent: 26.5% Cum: 26.5% 234 | [info] Average Score: 22.832 235 | [info] Average Utility: 58.914 236 | 237 | Much longer run: 238 | 239 | [info] HeuristicStandard2P: 240 | [info] Score 0 Games: 0 Percent: 0.0% Cum: 100.0% 241 | [info] Score 1 Games: 0 Percent: 0.0% Cum: 100.0% 242 | [info] Score 2 Games: 3 Percent: 0.0% Cum: 100.0% 243 | [info] Score 3 Games: 8 Percent: 0.0% Cum: 100.0% 244 | [info] Score 4 Games: 11 Percent: 0.0% Cum: 100.0% 245 | [info] Score 5 Games: 17 Percent: 0.1% Cum: 99.9% 246 | [info] Score 6 Games: 18 Percent: 0.1% Cum: 99.8% 247 | [info] Score 7 Games: 29 Percent: 0.1% Cum: 99.8% 248 | [info] Score 8 Games: 26 Percent: 0.1% Cum: 99.7% 249 | [info] Score 9 Games: 33 Percent: 0.1% Cum: 99.6% 250 | [info] Score 10 Games: 37 Percent: 0.1% Cum: 99.4% 251 | [info] Score 11 Games: 40 Percent: 0.2% Cum: 99.3% 252 | [info] Score 12 Games: 46 Percent: 0.2% Cum: 99.1% 253 | [info] Score 13 Games: 75 Percent: 0.3% Cum: 98.9% 254 | [info] Score 14 Games: 79 Percent: 0.3% Cum: 98.6% 255 | [info] Score 15 Games: 110 Percent: 0.4% Cum: 98.3% 256 | [info] Score 16 Games: 165 Percent: 0.7% Cum: 97.9% 257 | [info] Score 17 Games: 279 Percent: 1.1% Cum: 97.2% 258 | [info] Score 18 Games: 378 Percent: 1.5% Cum: 96.1% 259 | [info] Score 19 Games: 506 Percent: 2.0% Cum: 94.6% 260 | [info] Score 20 Games: 825 Percent: 3.3% Cum: 92.6% 261 | [info] Score 21 Games: 1377 Percent: 5.5% Cum: 89.3% 262 | [info] Score 22 Games: 2091 Percent: 8.4% Cum: 83.8% 263 | [info] Score 23 Games: 2514 Percent: 10.1% Cum: 75.4% 264 | [info] Score 24 Games: 3181 Percent: 12.7% Cum: 65.3% 265 | [info] Score 25 Games: 13152 Percent: 52.6% Cum: 52.6% 266 | [info] Average Score: 23.37016 267 | [info] Average Utility: 73.04432 268 | [info] Score 0 Games: 1226 Percent: 4.9% Cum: 100.0% 269 | [info] Score 1 Games: 0 Percent: 0.0% Cum: 95.1% 270 | [info] Score 2 Games: 0 Percent: 0.0% Cum: 95.1% 271 | [info] Score 3 Games: 0 Percent: 0.0% Cum: 95.1% 272 | [info] Score 4 Games: 0 Percent: 0.0% Cum: 95.1% 273 | [info] Score 5 Games: 0 Percent: 0.0% Cum: 95.1% 274 | [info] Score 6 Games: 0 Percent: 0.0% Cum: 95.1% 275 | [info] Score 7 Games: 1 Percent: 0.0% Cum: 95.1% 276 | [info] Score 8 Games: 1 Percent: 0.0% Cum: 95.1% 277 | [info] Score 9 Games: 1 Percent: 0.0% Cum: 95.1% 278 | [info] Score 10 Games: 4 Percent: 0.0% Cum: 95.1% 279 | [info] Score 11 Games: 5 Percent: 0.0% Cum: 95.1% 280 | [info] Score 12 Games: 6 Percent: 0.0% Cum: 95.0% 281 | [info] Score 13 Games: 22 Percent: 0.1% Cum: 95.0% 282 | [info] Score 14 Games: 27 Percent: 0.1% Cum: 94.9% 283 | [info] Score 15 Games: 47 Percent: 0.2% Cum: 94.8% 284 | [info] Score 16 Games: 89 Percent: 0.4% Cum: 94.6% 285 | [info] Score 17 Games: 186 Percent: 0.7% Cum: 94.3% 286 | [info] Score 18 Games: 270 Percent: 1.1% Cum: 93.5% 287 | [info] Score 19 Games: 392 Percent: 1.6% Cum: 92.5% 288 | [info] Score 20 Games: 722 Percent: 2.9% Cum: 90.9% 289 | [info] Score 21 Games: 1242 Percent: 5.0% Cum: 88.0% 290 | [info] Score 22 Games: 1978 Percent: 7.9% Cum: 83.0% 291 | [info] Score 23 Games: 2474 Percent: 9.9% Cum: 75.1% 292 | [info] Score 24 Games: 3155 Percent: 12.6% Cum: 65.2% 293 | [info] Score 25 Games: 13152 Percent: 52.6% Cum: 52.6% 294 | [info] Average Score: 22.55656 295 | [info] Average Utility: 71.41712 296 | 297 | */ 298 | 299 | } 300 | -------------------------------------------------------------------------------- /src/main/scala/Game.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * Game.scala 3 | * Implements the main hanabi game rules and mechanics! 4 | * Fields in this class are visible externally, for use in the Hanabi AI players, like HeuristicPlayer, 5 | * but generally they should NOT be modified, only read, so as to avoid accidentally ending up in an 6 | * inconstent game state. 7 | * 8 | * By default, when a game is constructed, all cards, even the ones in the deck, are "seen" and visible. 9 | * Actual running games will call hideFor(pid) prior to passing it off to each player to hide the cards 10 | * that the given player shouldn't be able to see. 11 | */ 12 | 13 | package fireflower 14 | 15 | import RichImplicits._ 16 | 17 | object Game { 18 | 19 | //Construct a Game from the given rules and a seed for shuffling the deck. 20 | def apply(rules: Rules, seed: Long): Game = { 21 | assert(rules.numPlayers >= 2) 22 | assert(rules.handSize > 0) 23 | assert(rules.deckSize >= rules.numPlayers * rules.handSize) 24 | assert(rules.initialHints >= 0) 25 | assert(rules.maxHints >= rules.initialHints) 26 | assert(rules.maxBombs >= 0) 27 | assert(rules.maxDiscards >= 0) 28 | assert(rules.maxNumber >= 0) 29 | assert(rules.maxNumber < Card.NUMBER_LIMIT) 30 | assert(rules.maxScore == (rules.maxNumber+1) * rules.colors().length) 31 | assert(rules.colors().length == rules.colors().distinct.length) 32 | assert(rules.possibleHintTypes().length == rules.possibleHintTypes().distinct.length) 33 | 34 | val seenMap = SeenMap(rules,Rand(seed)) 35 | val numCardRemaining = Array.fill(Card.maxArrayIdx)(0) 36 | val nextPlayable = Array.fill(Color.LIMIT)(-1) 37 | seenMap.cards.foreach { card => 38 | assert(card.number >= 0 && card.number <= rules.maxNumber) 39 | numCardRemaining(card.arrayIdx) += 1 40 | nextPlayable(card.color.id) = 0 41 | } 42 | 43 | new Game( 44 | rules = rules, 45 | turnNumber = 0, 46 | numHints = rules.initialHints, 47 | numBombs = 0, 48 | numPlayed = 0, 49 | numDiscarded = 0, 50 | numUnknownHintsGiven = 0, 51 | seenMap = seenMap, 52 | played = List(), 53 | discarded = List(), 54 | deck = (0 to (rules.deckSize-1)).toList, 55 | curPlayer = 0, 56 | finalTurnsLeft = -1, 57 | hands = Array.fill(rules.numPlayers)(Hand(rules.handSize)), 58 | nextPlayable = nextPlayable, 59 | numCardRemaining = numCardRemaining, 60 | revHistory = List(), 61 | debugPath = None 62 | ) 63 | } 64 | 65 | //Constuct a copy of this game that can be modified separately from the original. 66 | def apply(that: Game): Game = { 67 | new Game( 68 | rules = that.rules, 69 | turnNumber = that.turnNumber, 70 | numHints = that.numHints, 71 | numBombs = that.numBombs, 72 | numPlayed = that.numPlayed, 73 | numDiscarded = that.numDiscarded, 74 | numUnknownHintsGiven = that.numUnknownHintsGiven, 75 | seenMap = SeenMap(that.seenMap), 76 | played = that.played, 77 | discarded = that.discarded, 78 | deck = that.deck, 79 | curPlayer = that.curPlayer, 80 | finalTurnsLeft = that.finalTurnsLeft, 81 | hands = that.hands.map { hand => Hand(hand) }, 82 | nextPlayable = that.nextPlayable.clone(), 83 | numCardRemaining = that.numCardRemaining.clone(), 84 | revHistory = that.revHistory, 85 | debugPath = that.debugPath 86 | ) 87 | } 88 | 89 | } 90 | 91 | class Game private ( 92 | var rules: Rules, 93 | var turnNumber: Int, 94 | var numHints: Int, 95 | var numBombs: Int, 96 | var numPlayed: Int, 97 | var numDiscarded: Int, 98 | 99 | //Used by the HeuristicPlayer to track how many hints it has given where it hasn't simulated the particular 100 | //hint, so that it can account for the value that on average they are presumably worth later. 101 | var numUnknownHintsGiven: Int, 102 | 103 | var seenMap: SeenMap, 104 | var played: List[CardId], 105 | var discarded: List[CardId], 106 | var deck: List[CardId], 107 | var curPlayer: PlayerId, 108 | //This is -1, except once the deck runs out, where it is the number of turns left in the game, with the 109 | //game ending when this hits 0. 110 | var finalTurnsLeft: Int, 111 | 112 | val hands: Array[Hand], 113 | //Indexed by ColorId, the next Number that is playable. 114 | val nextPlayable: Array[Number], 115 | //Number of this card remaining in deck or hand, indexed by card.arrayIdx 116 | val numCardRemaining: Array[Int], 117 | val revHistory: List[SeenAction], 118 | 119 | //Used by the HeuristicPlayer, to only print debug information tracing down a particular line of the search. 120 | //The mechanism is that while this is Some, every GiveAction done pops the head of the list if it exactly 121 | //matches the head of the list, else it sets this to None if it doesn't. When this is Some, debugging is on. 122 | var debugPath: Option[List[GiveAction]] 123 | ) { 124 | 125 | val possibleHintTypes = rules.possibleHintTypes() 126 | 127 | def isLegal(ga: GiveAction): Boolean = { 128 | ga match { 129 | case GiveDiscard(hid) => 130 | numHints < rules.maxHints && hid >= 0 && hid < hands(curPlayer).numCards 131 | case GivePlay(hid) => 132 | hid >= 0 && hid < hands(curPlayer).numCards 133 | case GiveHint(pid,hint) => 134 | numHints > 0 && 135 | pid != curPlayer && 136 | hint != UnknownHint && 137 | possibleHintTypes.exists { ht => hint == ht } && 138 | hands(pid).exists { cid => rules.hintApplies(hint,seenMap(cid)) } 139 | } 140 | } 141 | 142 | def seenAction(ga: GiveAction): SeenAction = { 143 | ga match { 144 | case GiveDiscard(hid) => SeenDiscard(hid,hands(curPlayer)(hid)) 145 | case GivePlay(hid) => 146 | if(isPlayable(seenMap(hands(curPlayer)(hid)))) 147 | SeenPlay(hid,hands(curPlayer)(hid)) 148 | else 149 | SeenBomb(hid,hands(curPlayer)(hid)) 150 | case GiveHint(pid,hint) => 151 | val appliedTo = hands(pid).mapCards { cid => rules.hintApplies(hint,seenMap(cid)) } 152 | SeenHint(pid,rules.seenHint(hint),appliedTo) 153 | } 154 | } 155 | 156 | def isKilledFromBelow(card: Card): Boolean = { 157 | !rules.stopEarlyLoss && (nextPlayable(card.color.id) to (card.number-1)).exists { number => 158 | numCardRemaining(Card.arrayIdx(card.color,number)) <= 0 159 | } 160 | } 161 | 162 | def isPlayable(card: Card): Boolean = { 163 | nextPlayable(card.color.id) == card.number 164 | } 165 | def isOneFromPlayable(card: Card): Boolean = { 166 | nextPlayable(card.color.id) == card.number-1 167 | } 168 | def isUseful(card: Card): Boolean = { 169 | nextPlayable(card.color.id) <= card.number && 170 | !isKilledFromBelow(card) 171 | } 172 | def isDangerous(card: Card): Boolean = { 173 | nextPlayable(card.color.id) <= card.number && 174 | numCardRemaining(card.arrayIdx) <= 1 && 175 | !isKilledFromBelow(card) 176 | } 177 | def isJunk(card: Card): Boolean = { 178 | nextPlayable(card.color.id) > card.number || isKilledFromBelow(card) 179 | } 180 | 181 | def doAction(ga: GiveAction): Unit = { 182 | var shouldDraw = false 183 | ga match { 184 | case GiveDiscard(hid) => 185 | val cid = hands(curPlayer).remove(hid) 186 | shouldDraw = true 187 | numHints += 1 188 | numDiscarded += 1 189 | discarded = cid :: discarded 190 | val card = seenMap(cid) 191 | val cardArrayIdx = card.arrayIdx 192 | if(numCardRemaining(cardArrayIdx) > 0) 193 | numCardRemaining(cardArrayIdx) -= 1 194 | case GivePlay(hid) => 195 | val cid = hands(curPlayer).remove(hid) 196 | shouldDraw = true 197 | 198 | val card = seenMap(cid) 199 | if(isPlayable(card)) { 200 | numPlayed += 1 201 | nextPlayable(card.color.id) += 1 202 | played = cid :: played 203 | if(rules.extraHintFromPlayingMax && card.number == rules.maxNumber) 204 | numHints = Math.min(numHints+1,rules.maxHints) 205 | 206 | val cardArrayIdx = card.arrayIdx 207 | numCardRemaining(cardArrayIdx) = -1 208 | } 209 | else { 210 | numDiscarded += 1 211 | numBombs += 1 212 | discarded = cid :: discarded 213 | val cardArrayIdx = card.arrayIdx 214 | if(numCardRemaining(cardArrayIdx) > 0) 215 | numCardRemaining(cardArrayIdx) -= 1 216 | } 217 | case GiveHint(_,hint) => 218 | numHints -= 1 219 | hint match { 220 | case UnknownHint => numUnknownHintsGiven += 1 221 | case _ => () 222 | } 223 | } 224 | 225 | if(shouldDraw) { 226 | deck match { 227 | case Nil => () 228 | case cid :: rest => 229 | deck = rest 230 | hands(curPlayer).add(cid) 231 | if(rest.isEmpty && finalTurnsLeft < 0) 232 | finalTurnsLeft = rules.numPlayers + 1 //+1 because will get 1 subtracted from it below 233 | } 234 | } 235 | 236 | curPlayer = (curPlayer + 1) % rules.numPlayers 237 | 238 | if(finalTurnsLeft > 0) 239 | finalTurnsLeft -= 1 240 | turnNumber += 1 241 | 242 | debugPath = debugPath match { 243 | case None => None 244 | case Some(Nil) => None 245 | case Some(action::rest) => 246 | if(action != ga) None 247 | else Some(rest) 248 | } 249 | } 250 | 251 | def replaceSeenMap(newSeenMap: SeenMap): Unit = { 252 | seenMap = newSeenMap 253 | } 254 | 255 | def hideDeck(): Unit = { 256 | deck.foreach { cid => seenMap(cid) = Card.NULL } 257 | } 258 | 259 | def hideFor(pid: PlayerId): Unit = { 260 | deck.foreach { cid => seenMap(cid) = Card.NULL } 261 | hands(pid).foreach { cid => seenMap(cid) = Card.NULL } 262 | } 263 | 264 | def hiddenFor(pid: PlayerId): Game = { 265 | val copy = Game(this) 266 | copy.hideFor(pid) 267 | copy 268 | } 269 | 270 | def drawInitialCards(): Unit = { 271 | for(pid <- 0 to (rules.numPlayers - 1)) { 272 | for(i <- 0 to (rules.handSize - 1)) { 273 | deck match { 274 | case Nil => throw new Exception("Not enough cards in deck to draw initial cards") 275 | case cid :: rest => 276 | deck = rest 277 | hands(pid).add(cid) 278 | } 279 | } 280 | } 281 | if(deck.isEmpty && finalTurnsLeft < 0) 282 | finalTurnsLeft = rules.numPlayers 283 | } 284 | 285 | def isDone(): Boolean = { 286 | numBombs > rules.maxBombs || 287 | finalTurnsLeft == 0 || 288 | numPlayed >= rules.maxScore || 289 | (rules.stopEarlyLoss && ( 290 | numDiscarded > rules.maxDiscards || 291 | discarded.exists { cid => 292 | val card = seenMap(cid) 293 | card.number >= nextPlayable(card.color.id) && numCardRemaining(card.arrayIdx) == 0 294 | } 295 | )) 296 | } 297 | 298 | def isWon(): Boolean = { 299 | numPlayed == rules.maxScore 300 | } 301 | 302 | override def toString(): String = { 303 | toString(useAnsiColors = false) 304 | } 305 | 306 | def toString(useAnsiColors: Boolean): String = { 307 | val handsString = (0 to (rules.numPlayers-1)).map { pid => 308 | val toPlayString = if(pid == curPlayer) "*" else " " 309 | toPlayString + "P" + pid + ": " + hands(pid).toString(seenMap,useAnsiColors) 310 | }.mkString("|") 311 | 312 | val playedString = rules.colors().flatMap { color => 313 | val next = nextPlayable(color.id) 314 | if(next <= 0) 315 | None 316 | else 317 | Some(Card(color,next-1).toString(useAnsiColors)) 318 | }.mkString("") 319 | 320 | val dangerString = discarded.map { cid => seenMap(cid) }.sorted.flatMap { card => 321 | if(card.number >= nextPlayable(card.color.id)) 322 | Some(card.toString(useAnsiColors)) 323 | else 324 | None 325 | }.mkString("") 326 | 327 | val endRoundString = { 328 | if(finalTurnsLeft >= 0) 329 | "final" + finalTurnsLeft 330 | else 331 | "" 332 | } 333 | 334 | "T%3d HL %d NB %d ND %2d Played %s %s danger %s %s".format( 335 | turnNumber, 336 | numHints, 337 | numBombs, 338 | numDiscarded, 339 | playedString, 340 | handsString, 341 | dangerString, 342 | endRoundString 343 | ) 344 | } 345 | 346 | def seenActionToString(sa: SeenAction, useAnsiColors: Boolean): String = { 347 | sa match { 348 | case SeenDiscard(hid,cid) => 349 | "Discard #%d %s".format(hid+1,seenMap(cid).toString(useAnsiColors)) 350 | case SeenPlay(hid,cid) => 351 | "Play #%d %s".format(hid+1,seenMap(cid).toString(useAnsiColors)) 352 | case SeenBomb(hid,cid) => 353 | "Bomb #%d %s".format(hid+1,seenMap(cid).toString(useAnsiColors)) 354 | case SeenHint(pid,hint,appliedTo) => 355 | val hintString = hint match { 356 | case HintColor(color) => 357 | if(useAnsiColors) 358 | color.toAnsiColorCode() + color.toString() + Color.ansiResetColor 359 | else 360 | color.toString() 361 | case HintNumber(number) => 362 | (number+1).toString() 363 | case HintSameColor => 364 | "color" 365 | case HintSameNumber => 366 | "number" 367 | case HintSame => 368 | "" 369 | case UnknownHint => 370 | "UnknownHint" 371 | } 372 | 373 | val appliedString = appliedTo.zipWithIndex.flatMap { case (b,hid) => 374 | if(b) Some(seenMap(hands(pid)(hid)).toString(useAnsiColors)) 375 | else None 376 | }.mkString("") 377 | 378 | "Hint P%d %s %s".format(pid,hintString,appliedString) 379 | } 380 | } 381 | 382 | //For debug purposes 383 | def giveActionToString(ga: GiveAction): String = { 384 | ga match { 385 | case GiveDiscard(hid) => "Discard #" + (hid+1) 386 | case GivePlay(hid) => "Play #" + (hid+1) 387 | case GiveHint(pid,hint) => 388 | val hintString = hint match { 389 | case HintColor(color) => color.toString() 390 | case HintNumber(number) => (number+1).toString() 391 | case UnknownHint => "Unknown" 392 | } 393 | "Hint " + pid + " " + hintString 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/main/scala/HeuristicPlayer.scala: -------------------------------------------------------------------------------- 1 | /** 2 | * HeuristicPlayer.scala 3 | * Plays according to bunch of simple hardcoded conventions that dictate how cards are signaled 4 | * as playable or discardable or protectworthy by hints or other actions. 5 | * 6 | * However, conventions mostly do not dictate the actions taken. Instead, those are computed by 7 | * looping over all actions and running a pseudo-depth-2 search by predicting the opponent's likely 8 | * action or actions and then running an evaluation function, and choosing the action that leads 9 | * to the highest expected evaluation. 10 | */ 11 | 12 | package fireflower 13 | 14 | import RichImplicits._ 15 | 16 | //Below are a bunch of types for different kinds of beliefs or knowledge. Each comes in a pair, 17 | //with an "*Info" that is shared between all cards involved in that belief or piece of knowledge, 18 | //and a second type that contains the former as a field that is tracked per-card. 19 | 20 | //All the arrays in these information-related are read-only and should NOT be modified. 21 | 22 | //The shared information between all cards connected in a belief 23 | sealed trait BeliefInfo 24 | //The value we track per-card that stays attached to card as it moves in a hand. 25 | sealed trait Belief { 26 | override def toString(): String = { 27 | this match { 28 | case PlaySequence(seqIdx,finesseCard,info) => 29 | "PlaySequence(seqIdx=" + seqIdx + ",finesse=" + finesseCard + ",cids=(" + info.cids.mkString(",") + "))" 30 | case ProtectedSet(seqIdx,info) => 31 | "ProtectedSet(seqIdx=" + seqIdx + ",cids=(" + info.cids.mkString(",") + "))" 32 | case JunkSet(seqIdx,info) => 33 | "JunkSet(seqIdx=" + seqIdx + ",cids=(" + info.cids.mkString(",") + "))" 34 | } 35 | } 36 | } 37 | 38 | //A hint was received - this info is meant to track the purely logical info learned. 39 | case class HintedInfo(sh: SeenHint, hand: Array[CardId]) 40 | case class Hinted(hid: HandId, applied: Boolean, info: HintedInfo) 41 | 42 | //We think the following cards are playable 43 | //and should be played in this order. Possibly includes cards in other player's hands. 44 | case class PlaySequenceInfo(cids: Array[CardId]) extends BeliefInfo 45 | case class PlaySequence(seqIdx: Int, finesseCard: Option[Card], info: PlaySequenceInfo) extends Belief 46 | 47 | //We think that these cards are protected and should be held onto and not discarded 48 | case class ProtectedSetInfo(cids: Array[CardId]) extends BeliefInfo 49 | case class ProtectedSet(seqIdx: Int, info: ProtectedSetInfo) extends Belief 50 | 51 | //We think these cards can be thrown away 52 | case class JunkSetInfo(cids: Array[CardId]) extends BeliefInfo 53 | case class JunkSet(seqIdx: Int, info: JunkSetInfo) extends Belief 54 | 55 | //One is created every time someone plays or discards, recording the state of the game when they did so. 56 | case class DPSnapshot( 57 | pid: PlayerId, 58 | turnNumber: Int, 59 | postHands: Array[Hand], 60 | postHints: Int, 61 | nextPlayable: Array[Number], 62 | preDangers: Array[Card], 63 | nextPidExpectedPlaysNow: List[HandId], 64 | nextPidMostLikelyDiscard: HandId, 65 | isFromPlay: Boolean 66 | ) 67 | 68 | //Basic constructors and other static functions for the player 69 | object HeuristicPlayer extends PlayerGen { 70 | 71 | val ENABLE_FINESSE = true 72 | 73 | //Construct a HeuristicPlayer for the given rule set 74 | def apply(rules: Rules, myPid: Int): HeuristicPlayer = { 75 | val numCardsInitial = Array.fill(Card.maxArrayIdx)(0) 76 | rules.cards().foreach { card => 77 | numCardsInitial(card.arrayIdx) += 1 78 | } 79 | new HeuristicPlayer( 80 | myPid = myPid, 81 | rules = rules, 82 | possibleHintTypes = rules.possibleHintTypes(), 83 | maxHints = rules.maxHints, 84 | distinctCards = rules.cards().distinct.toList, 85 | numCardsInitial = numCardsInitial, 86 | colors = rules.colors(), 87 | seenMap = SeenMap.empty(rules), 88 | seenMapCK = SeenMap.empty(rules), 89 | hintedMap = CardPropertyMap(rules), 90 | beliefMap = CardPropertyMap(rules), 91 | dpSnapshots = List() 92 | ) 93 | } 94 | 95 | //PlayerGen interface - Generate a set of players for a game. 96 | def genPlayers(rules: Rules, seed: Long): Array[Player] = { 97 | (0 until rules.numPlayers).map { myPid => 98 | this(rules,myPid) 99 | }.toArray 100 | } 101 | } 102 | 103 | case class SavedState( 104 | val seenMap: SeenMap, 105 | val seenMapCK: SeenMap, 106 | val hintedMap: CardPropertyMap[Hinted], 107 | val beliefMap: CardPropertyMap[Belief], 108 | val dpSnapshots: List[DPSnapshot] 109 | ) 110 | 111 | class HeuristicPlayer private ( 112 | //IMMUTABLE------------------------------------------- 113 | val myPid: Int, 114 | var rules: Rules, 115 | 116 | //Various utility values we compute once and cache 117 | val maxHints: Int, 118 | val possibleHintTypes: Array[GiveHintType], 119 | val distinctCards: List[Card], //contains each distinct card type once 120 | val numCardsInitial: Array[Int], //indexed by card.arrayIdx, counts the quantity of that card in the whole deck 121 | val colors: Array[Color], //an array of the colors in this game 122 | 123 | //STATE----------------------------------------------- 124 | 125 | //Tracks what cards are visible by us 126 | val seenMap: SeenMap, 127 | //Tracks what cards are visible as common knowledge 128 | val seenMapCK: SeenMap, 129 | 130 | //Logical information we've received via hints, tracked by card 131 | val hintedMap: CardPropertyMap[Hinted], 132 | //Beliefs we have about cards based on conventions. 133 | //In general, it's important that this map doesn't contain things that can be inferred only based on 134 | //private information because it's used to predict other players' actions. 135 | val beliefMap: CardPropertyMap[Belief], 136 | //Snapshots of past states of the game when people discarded. 137 | var dpSnapshots: List[DPSnapshot] 138 | ) extends Player { 139 | 140 | def saveState(): SavedState = { 141 | SavedState( 142 | seenMap = SeenMap(seenMap), 143 | seenMapCK = SeenMap(seenMapCK), 144 | hintedMap = CardPropertyMap(hintedMap), 145 | beliefMap = CardPropertyMap(beliefMap), 146 | dpSnapshots = dpSnapshots 147 | ) 148 | } 149 | def restoreState(saved: SavedState): Unit = { 150 | saved.seenMap.copyTo(seenMap) 151 | saved.seenMapCK.copyTo(seenMapCK) 152 | saved.hintedMap.copyTo(hintedMap) 153 | saved.beliefMap.copyTo(beliefMap) 154 | dpSnapshots = saved.dpSnapshots 155 | } 156 | 157 | //Checks whether the current game state is one where we should be printing debug messages. 158 | def debugging(game: Game): Boolean = { 159 | game.debugPath match { 160 | case None => false 161 | case Some(_) => true 162 | } 163 | } 164 | 165 | //Update the seen maps based on a new incoming game state 166 | def updateSeenMap(game: Game): Unit = { 167 | game.seenMap.copyTo(seenMap) 168 | game.seenMap.copyTo(seenMapCK) 169 | (0 until rules.numPlayers).foreach { pid => 170 | game.hands(pid).foreach { cid => seenMapCK(cid) = Card.NULL } 171 | } 172 | 173 | //Apply one pass where we treat provable cards as themselves seen so that 174 | //we can deduce further cards. 175 | (0 until rules.numPlayers).foreach { pid => 176 | game.hands(pid).foreach { cid => 177 | val ckCard = uniquePossible(cid,ck=true) 178 | if(ckCard != Card.NULL) seenMapCK(cid) = ckCard 179 | val card = uniquePossible(cid,ck=false) 180 | if(card != Card.NULL) seenMap(cid) = card 181 | } 182 | } 183 | } 184 | 185 | //Add a belief via its shared info, computing the per-card values to store 186 | def addBelief(info: BeliefInfo): Unit = { 187 | info match { 188 | case (info:PlaySequenceInfo) => 189 | for(i <- 0 until info.cids.length) { 190 | //Preserve finesse cards 191 | val cid = info.cids(i) 192 | val finesseCard = getFinesseCard(cid) 193 | beliefMap.add(cid,PlaySequence(seqIdx=i,finesseCard=finesseCard,info=info)) 194 | } 195 | case (info:ProtectedSetInfo) => 196 | for(i <- 0 until info.cids.length) 197 | beliefMap.add(info.cids(i),ProtectedSet(seqIdx=i,info=info)) 198 | case (info:JunkSetInfo) => 199 | for(i <- 0 until info.cids.length) 200 | beliefMap.add(info.cids(i),JunkSet(seqIdx=i,info=info)) 201 | } 202 | } 203 | 204 | //Add a finesse belief for a given card 205 | def addFinesse(targetCid: CardId, baseCid: CardId, finesseCard: Card): Unit = { 206 | primeBelief(baseCid) match { 207 | case Some(b: PlaySequence) => 208 | val seqIdx = b.seqIdx 209 | val cids: Array[CardId] = b.info.cids.take(seqIdx) ++ Array(targetCid) ++ b.info.cids.drop(seqIdx) 210 | val info = PlaySequenceInfo(cids) 211 | addBelief(info) 212 | //Replace the top info of the target itself to have a finesse card 213 | beliefMap.pop(targetCid) 214 | beliefMap.add(targetCid,PlaySequence(seqIdx=seqIdx,finesseCard=Some(finesseCard),info=info)) 215 | case Some(_) | None => 216 | val cids = Array(targetCid) 217 | val info = PlaySequenceInfo(cids) 218 | beliefMap.add(targetCid,PlaySequence(seqIdx=0,finesseCard=Some(finesseCard),info=info)) 219 | } 220 | } 221 | 222 | //Remove all beliefs that have this card as a finesse playable now, exposing any underneath 223 | def removePlayableFinesseBeliefs(game: Game, cid: CardId): Unit = { 224 | primeBelief(cid) match { 225 | case Some(b: PlaySequence) => 226 | b.finesseCard match { 227 | case None => () 228 | case Some(card) => 229 | if(game.isPlayable(card)) { 230 | //Remove belief from this card 231 | beliefMap.pop(cid) 232 | //Also adjust from the sequence for other cards 233 | addBelief(PlaySequenceInfo(cids = b.info.cids.filter { c => c != cid })) 234 | //And repeat until there are no more 235 | removePlayableFinesseBeliefs(game,cid) 236 | } 237 | } 238 | case Some(_) | None => () 239 | } 240 | } 241 | 242 | //Is every hint we've received consistent with cid being card? 243 | def allHintsConsistent(cid: CardId, card: Card): Boolean = { 244 | hintedMap(cid).forall { hinted => rules.isConsistent(hinted.info.sh.hint, hinted.applied, card) } 245 | } 246 | 247 | //TODO this function is called frequently! 248 | //Maybe we can memoize it - might be a decent speedup. 249 | 250 | //What cards could [cid] be as a strictly logical possiblity? 251 | //If ck is false, uses all information known. 252 | //If ck is true, uses only common knowledge information. 253 | def possibleCards(cid: CardId, ck: Boolean): List[Card] = { 254 | var sm = seenMap 255 | if(ck) sm = seenMapCK 256 | 257 | val seenCard = sm(cid) 258 | if(seenCard != Card.NULL) List(seenCard) 259 | else sm.filterDistinctUnseen { card => allHintsConsistent(cid,card) } 260 | } 261 | 262 | //If there is a unique possible value for this card, return it, else Card.NULL 263 | def uniquePossible(cid: CardId, ck: Boolean): Card = { 264 | var sm = seenMap 265 | if(ck) sm = seenMapCK 266 | 267 | val seenCard = sm(cid) 268 | if(seenCard != Card.NULL) seenCard 269 | else sm.filterUniqueDistinctUnseen { card => allHintsConsistent(cid,card) } 270 | } 271 | 272 | //If there is a unique possible useful value for this card, return it, else Card.NULL 273 | def uniquePossibleUseful(cid: CardId, game: Game, ck: Boolean): Card = { 274 | var sm = seenMap 275 | if(ck) sm = seenMapCK 276 | 277 | val seenCard = sm(cid) 278 | if(seenCard != Card.NULL) { if(game.isUseful(seenCard)) seenCard else Card.NULL } 279 | else sm.filterUniqueDistinctUnseen { card => allHintsConsistent(cid,card) && game.isUseful(card)} 280 | } 281 | 282 | //Check if there is any possible value for this card. ALSO verifies consistency of cards we've seen. 283 | def hasPossible(cid: CardId): Boolean = { 284 | val seenCard = seenMap(cid) 285 | if(seenCard != Card.NULL) allHintsConsistent(cid,seenCard) 286 | else seenMap.existsUnseen { card => allHintsConsistent(cid,card) } 287 | } 288 | 289 | //Check if there is a unique possible color for this card conditioned on it being useful. If not, returns NullColor 290 | def uniquePossibleUsefulColor(cid: CardId, game: Game, ck: Boolean): Color = { 291 | var sm = seenMap 292 | if(ck) sm = seenMapCK 293 | 294 | val seenCard = sm(cid) 295 | if(seenCard != Card.NULL) { 296 | if(game.isUseful(seenCard)) seenCard.color 297 | else NullColor 298 | } 299 | else { 300 | val possibles = sm.filterDistinctUnseen { card => allHintsConsistent(cid,card) && game.isUseful(card) } 301 | possibles match { 302 | case Nil => NullColor 303 | case head :: tail => 304 | if(tail.forall { card => card.color == head.color }) 305 | head.color 306 | else 307 | NullColor 308 | } 309 | } 310 | } 311 | 312 | def provablyPlayable(possibles: List[Card], game: Game): Boolean = { 313 | possibles.forall { card => game.isPlayable(card) } 314 | } 315 | def provablyPlayableIfUseful(possibles: List[Card], game: Game): Boolean = { 316 | possibles.forall { card => game.isPlayable(card) || game.isJunk(card) } 317 | } 318 | def provablyNotPlayable(possibles: List[Card], game: Game): Boolean = { 319 | possibles.forall { card => !game.isPlayable(card) } 320 | } 321 | def provablyUseful(possibles: List[Card], game: Game): Boolean = { 322 | possibles.forall { card => game.isUseful(card) } 323 | } 324 | def provablyNotDangerous(possibles: List[Card], game: Game): Boolean = { 325 | possibles.forall { card => !game.isDangerous(card) } 326 | } 327 | def provablyDangerous(possibles: List[Card], game: Game): Boolean = { 328 | possibles.forall { card => game.isDangerous(card) } 329 | } 330 | def provablyJunk(possibles: List[Card], game: Game): Boolean = { 331 | possibles.forall { card => game.isJunk(card) } 332 | } 333 | 334 | //The most recent belief formed about this card, if any. 335 | def primeBelief(cid: CardId): Option[Belief] = { 336 | beliefMap(cid) match { 337 | case Nil => None 338 | case belief :: _ => Some(belief) 339 | } 340 | } 341 | 342 | def isBelievedProtected(cid: CardId): Boolean = { 343 | primeBelief(cid) match { 344 | case None => false 345 | case Some(_: ProtectedSet) => true 346 | case Some(_: PlaySequence) => false 347 | case Some(_: JunkSet) => false 348 | } 349 | } 350 | def isBelievedPlayable(cid: CardId, now: Boolean): Boolean = { 351 | primeBelief(cid) match { 352 | case None => false 353 | case Some(_: ProtectedSet) => false 354 | case Some(b: PlaySequence) => if (now) b.seqIdx == 0 else true 355 | case Some(_: JunkSet) => false 356 | } 357 | } 358 | def isBelievedUseful(cid: CardId): Boolean = { 359 | primeBelief(cid) match { 360 | case None => false 361 | case Some(_: ProtectedSet) => true 362 | case Some(_: PlaySequence) => true 363 | case Some(_: JunkSet) => false 364 | } 365 | } 366 | def isBelievedJunk(cid: CardId): Boolean = { 367 | primeBelief(cid) match { 368 | case None => false 369 | case Some(_: ProtectedSet) => false 370 | case Some(_: PlaySequence) => false 371 | case Some(_: JunkSet) => true 372 | } 373 | } 374 | 375 | def getFinesseCard(cid: CardId): Option[Card] = { 376 | primeBelief(cid) match { 377 | case Some(b:PlaySequence) => b.finesseCard 378 | case Some(_:ProtectedSet) => None 379 | case Some(_: JunkSet) => None 380 | case None => None 381 | } 382 | } 383 | 384 | //TODO can we use this? It didn't seem to help when using it in probablyCorrectlyBelievedPlayableSoon 385 | //If knowledge proves or if beliefs and conventions strongly suggest that this card should be a specific card, return 386 | //that card, otherwise return Card.NULL. 387 | def believedCard(cid: CardId, game: Game, ck: Boolean): Card = { 388 | var sm = seenMap 389 | if(ck) sm = seenMapCK 390 | val known = uniquePossible(cid, ck) 391 | if(known != Card.NULL) known 392 | else { 393 | primeBelief(cid) match { 394 | case None => Card.NULL 395 | case Some(_: ProtectedSet) => Card.NULL 396 | case Some(_: JunkSet) => Card.NULL 397 | case Some(b: PlaySequence) => 398 | //Believed playable now 399 | if(b.seqIdx <= 0) 400 | sm.filterUniqueDistinctUnseen { card => allHintsConsistent(cid,card) && game.isPlayable(card) } 401 | //Believed playable later 402 | else { 403 | //TODO why is this worse for 3p and 4p? 404 | if(rules.numPlayers > 2) 405 | Card.NULL 406 | else { 407 | val possibles = sm.filterDistinctUnseen { card => allHintsConsistent(cid,card) && game.isUseful(card) } 408 | possibles match { 409 | case Nil => Card.NULL 410 | case head :: tail => 411 | //If this card must be a certain color... 412 | if(tail.forall { card => card.color == head.color }) { 413 | val color = head.color 414 | //Check all earlier cards to count and see which this could be 415 | var simulatedNextPlayable = game.nextPlayable(color.id) 416 | def loop(seqIdx:Int): Card = { 417 | if(seqIdx >= b.seqIdx) 418 | possibles.find { card => card.number == simulatedNextPlayable }.getOrElse(Card.NULL) 419 | else { 420 | val card = sm.filterUniqueDistinctUnseen { card => allHintsConsistent(cid,card) && card.color == color && card.number == simulatedNextPlayable } 421 | if(card == Card.NULL) 422 | loop(seqIdx + 1) 423 | else { 424 | simulatedNextPlayable += 1 425 | loop(seqIdx + 1) 426 | } 427 | } 428 | } 429 | loop(0) 430 | } 431 | //If it doesn't have to be a certain color, we have no idea 432 | else Card.NULL 433 | } 434 | } 435 | } 436 | } 437 | } 438 | } 439 | 440 | //Given a list of turns where each turn has a list of cards that could be played, approximate 441 | //the longest sequence of cards that can be played with one pass. 442 | def numPlayableInOrder(cardsByTurn: List[List[Card]], game: Game): Int = { 443 | //Loop and see if the card becomes playable as we play in sequence 444 | val simulatedNextPlayable = game.nextPlayable.clone() 445 | def loop(cardsByTurn: List[List[Card]], acc: Int): Int = { 446 | cardsByTurn match { 447 | case Nil => acc 448 | case Nil :: cardsByTurn => 449 | loop(cardsByTurn,acc) 450 | case cards :: cardsByTurn => 451 | val playables = cards.filter { card => simulatedNextPlayable(card.color.id) == card.number } 452 | val count = if(playables.length >= 1) 1 else 0 453 | //Pretend we could play all of them, for the purpose of determining if future cards are playable. 454 | playables.foreach { card => simulatedNextPlayable(card.color.id) = card.number + 1 } 455 | loop(cardsByTurn,acc+count) 456 | } 457 | } 458 | loop(cardsByTurn,0) 459 | } 460 | 461 | //Check if to the best of our knowledge, based on what's actually visible and what we suspect, a given card will 462 | //be playable once it gets reached in play sequence. NOT COMMON KNOWLEDGE! 463 | //Also, skips over bad cards in the sequence, assuming we can hint to fix them. 464 | def probablyCorrectlyBelievedPlayableSoon(cid: CardId, game: Game): Boolean = { 465 | primeBelief(cid) match { 466 | case None => false 467 | case Some(_: ProtectedSet) => false 468 | case Some(_: JunkSet) => false 469 | case Some(b: PlaySequence) => 470 | if(b.seqIdx <= 0) { 471 | val card = believedCard(cid, game, ck=false) 472 | if(card == Card.NULL) { 473 | //TODO for some reason this helps on 2p and 3p but hurts on 4p. Why? 474 | if(rules.numPlayers <= 3) provablyPlayable(possibleCards(cid,ck=false),game) 475 | else false 476 | } 477 | else 478 | game.isPlayable(card) 479 | } 480 | else { 481 | //Loop and see if the card becomes playable as we play in sequence 482 | val simulatedNextPlayable = game.nextPlayable.clone() 483 | def loopOk(seqIdx:Int, okIfStopHere:Boolean): Boolean = { 484 | if(seqIdx > b.seqIdx) 485 | okIfStopHere 486 | else { 487 | val cid = b.info.cids(seqIdx) 488 | val card = believedCard(cid, game, ck=false) 489 | 490 | //Don't have a guess as to what the card is - can't say that it's playable soon 491 | if(card == Card.NULL) 492 | false 493 | //It's playable next in sequence! 494 | else if(simulatedNextPlayable(card.color.id) == card.number) { 495 | simulatedNextPlayable(card.color.id) += 1 496 | loopOk(seqIdx+1,true) //Loop again and if we stop here, it was playable, so good. 497 | } 498 | //It's provably junk if the earlier cards play as expected 499 | else if(simulatedNextPlayable(card.color.id) > card.number) { 500 | loopOk(seqIdx+1,false) //Loop again and if we stop here, it wasn't playable, so not good. 501 | } 502 | //TODO for some reason this helps on 2p and 3p but hurts on 4p. Why? 503 | //For <= 3 players, skipping bad cards is okay - count the later ones even if needing fixing 504 | else { 505 | if(rules.numPlayers <= 3) loopOk(seqIdx+1,false) 506 | else false 507 | } 508 | } 509 | } 510 | loopOk(0,false) 511 | } 512 | } 513 | } 514 | 515 | //Check if there was no moment where this card was in a hand when someone discarded resulting 516 | //in at least minPostHints hints left. 517 | //Pid should be the player who holds the card. 518 | def cardIsNew(pid: PlayerId, cid: CardId, minPostHints: Int) = { 519 | //Find the most recent instance where someone other than the player who holds the card 520 | //discarded with many hints left 521 | val ds = dpSnapshots.find { ds => ds.postHints >= 3 && ds.pid != pid && !ds.isFromPlay} 522 | ds.forall { ds => !ds.postHands(pid).contains(cid) } 523 | } 524 | 525 | type DiscardGoodness = Int 526 | val DISCARD_PROVABLE_JUNK: DiscardGoodness = 6 527 | val DISCARD_JUNK: DiscardGoodness = 5 528 | val DISCARD_REGULAR: DiscardGoodness = 4 529 | val DISCARD_USEFUL: DiscardGoodness = 3 530 | val DISCARD_PLAYABLE: DiscardGoodness = 2 531 | val DISCARD_MAYBE_GAMEOVER: DiscardGoodness = 1 532 | val DISCARD_GAMEOVER: DiscardGoodness = 0 533 | 534 | //Goodness and discard are by common knowledge if and only if ck is true 535 | def mostLikelyDiscard(pid: PlayerId, game: Game, ck: Boolean): (HandId,DiscardGoodness) = { 536 | val revHand: Array[CardId] = game.hands(pid).cardArray().reverse 537 | val numCards = revHand.length 538 | val possibles: Array[List[Card]] = revHand.map { cid => possibleCards(cid,ck) } 539 | 540 | val (pos,dg): (Int,DiscardGoodness) = { 541 | val provableJunkDiscard = (0 until numCards).find { pos => provablyJunk(possibles(pos),game) } 542 | provableJunkDiscard match { 543 | case Some(pos) => (pos,DISCARD_PROVABLE_JUNK) 544 | case None => 545 | val junkDiscard = (0 until numCards).find { pos => isBelievedJunk(revHand(pos)) && !provablyUseful(possibles(pos),game) } 546 | junkDiscard match { 547 | case Some(pos) => (pos,DISCARD_JUNK) 548 | case None => 549 | val regularDiscard = (0 until numCards).find { pos => !isBelievedUseful(revHand(pos)) } 550 | regularDiscard match { 551 | case Some(pos) => (pos,DISCARD_REGULAR) 552 | case None => 553 | val usefulDiscard = (0 until numCards).find { pos => 554 | !isBelievedPlayable(revHand(pos),now=false) && 555 | !isBelievedProtected(revHand(pos)) && 556 | !provablyDangerous(possibles(pos),game) && 557 | !provablyPlayable(possibles(pos),game) 558 | } 559 | usefulDiscard match { 560 | case Some(pos) => (pos,DISCARD_USEFUL) 561 | case None => 562 | val maybeGameOverDiscard = (0 until numCards).find { pos => 563 | !provablyDangerous(possibles(pos),game) 564 | } 565 | maybeGameOverDiscard match { 566 | case Some(pos) => (pos,DISCARD_MAYBE_GAMEOVER) 567 | case None => (0,DISCARD_GAMEOVER) 568 | } 569 | } 570 | } 571 | } 572 | } 573 | } 574 | (numCards-1-pos,dg) 575 | } 576 | 577 | //Find all the hand positions we think the given player can play, by convention and belief and knowledge. 578 | //Now determines if the cards must be playable now rather than eventually. 579 | //ck determines if we only check using common knowledge or all observed info 580 | def expectedPlays(pid: PlayerId, game: Game, now: Boolean, ck: Boolean): List[HandId] = { 581 | val hand = game.hands(pid) 582 | (0 until hand.numCards).filter { hid => 583 | val cid = hand(hid) 584 | val possibles = possibleCards(cid,ck) 585 | if(provablyNotPlayable(possibles,game)) 586 | false 587 | else { 588 | //Provably playable 589 | provablyPlayable(possibles,game) || 590 | //Or believed... 591 | { 592 | primeBelief(cid) match { 593 | case None => false 594 | case Some(_: JunkSet) => false 595 | //Protected and playable conditional on being useful 596 | case Some(_: ProtectedSet) => 597 | provablyPlayableIfUseful(possibles,game) 598 | //Playable and right now 599 | case Some(b: PlaySequence) => 600 | if(now) b.seqIdx == 0 else true 601 | } 602 | } 603 | 604 | } 605 | }.toList 606 | } 607 | 608 | //All cards believed playable or known exactly and playable. 609 | def allBelievedAndUniqueProvablePlays(game: Game): List[CardId] = { 610 | game.hands.flatMap { hand => 611 | (0 until hand.numCards).flatMap { hid => 612 | val cid = hand(hid) 613 | val card = uniquePossible(cid,ck=true) 614 | if(card != Card.NULL && game.isPlayable(card)) 615 | Some(cid) 616 | else { 617 | primeBelief(cid) match { 618 | case None => None 619 | case Some(_: ProtectedSet) => None 620 | case Some(_: JunkSet) => None 621 | case Some(_: PlaySequence) => Some(cid) 622 | } 623 | } 624 | } 625 | }.toList 626 | } 627 | 628 | def firstPossiblyPlayableHid(game: Game, pid: PlayerId, ck: Boolean): Option[HandId] = { 629 | game.hands(pid).findIdx { cid => !provablyNotPlayable(possibleCards(cid,ck),game) } 630 | } 631 | 632 | //When a hint affects multiple cards to imply a finesse, which of the hinted cards should the finesse 633 | //be based off of? 634 | def finesseBase(game: Game, hintCids: Array[CardId]): Option[CardId] = { 635 | //If there is an ck-exactly-known card that is 1 step from playable, then that one. 636 | hintCids.find { cid => uniquePossible(cid,ck=true) != Card.NULL && game.isOneFromPlayable(seenMap(cid)) } match { 637 | case Some(cid) => Some(cid) 638 | case None => 639 | //Otherwise the first card that could be one step from playable, if it actually is. 640 | hintCids.find { cid => possibleCards(cid,ck=true).exists { card => game.isOneFromPlayable(card) } } match { 641 | case Some(cid) => if(game.isOneFromPlayable(seenMap(cid))) Some(cid) else None 642 | case None => None 643 | } 644 | } 645 | } 646 | 647 | def isGoodForFinesse(game: Game, card: Card, baseCard: Card): Boolean = { 648 | card.color == baseCard.color && card.number == baseCard.number - 1 && game.isUseful(card) 649 | } 650 | 651 | //Given that the given Card was hinted from someone else's hand, assuming it's a finesse targeting pid, 652 | //what's the card their hand they should play according to common knowledge? 653 | def finesseTarget(game: Game, pid: PlayerId, baseCard: Card): Option[CardId] = { 654 | 655 | //For simplicity, there are no finesse targets if the player has a card that they think could be it but 656 | //is already part of a play sequence. 657 | val matchesPlay = { 658 | game.hands(pid).exists { cid => primeBelief(cid) match { 659 | case Some(b:PlaySequence) => possibleCards(cid,ck=true).exists { card => (b.seqIdx > 0 || game.isPlayable(card)) && isGoodForFinesse(game,card,baseCard) } 660 | case Some(_:ProtectedSet) | Some(_:JunkSet) | None => false 661 | }} 662 | } 663 | if(matchesPlay) 664 | None 665 | else { 666 | //Otherwise, first prefer all cards that have been protected. Then any non-junk cards. 667 | val protectedMatch = { 668 | game.hands(pid).find { cid => primeBelief(cid) match { 669 | case Some(_:ProtectedSet) => possibleCards(cid,ck=true).exists { card => isGoodForFinesse(game,card,baseCard) } 670 | case Some(_:PlaySequence) | Some(_:JunkSet) | None => false 671 | }} 672 | } 673 | protectedMatch match { 674 | case Some(cid) => Some(cid) 675 | case None => 676 | game.hands(pid).find { cid => primeBelief(cid) match { 677 | case None => possibleCards(cid,ck=true).exists { card => isGoodForFinesse(game,card,baseCard) } 678 | case Some(_) => false 679 | }} 680 | } 681 | } 682 | } 683 | 684 | //Handle a discard that we've seen, updating info and beliefmaps. 685 | //Assumes seenMap is already updated, but nothing else. 686 | def handleSeenDiscard(sd: SeenDiscard, preGame: Game, postGame: Game): Unit = { 687 | val cid = sd.cid 688 | val discardPid = preGame.curPlayer 689 | val preExpectedPlaysNow: List[HandId] = expectedPlays(discardPid,preGame,now=true,ck=true) 690 | val prePossibles = possibleCards(cid,ck=true) 691 | val preDangers = seenMapCK.filterDistinctUnseen { card => preGame.isDangerous(card) }.toArray 692 | val preMLD = mostLikelyDiscard(preGame.curPlayer,preGame,ck=true)._1 693 | 694 | updateSeenMap(postGame) 695 | 696 | val card = seenMap(cid) 697 | 698 | //TODO if there are sufficiently many hints left and a player discards, they must not believe they have playable cards, 699 | //so update those beliefs. 700 | 701 | //If a card discarded is part of a play sequence and had it been played it would have proven subsequent 702 | //cards in the sequence to be unplayable, go ahead and mark them as protected or junk as appropriate. 703 | //This is possible in the rare cases where a play gets discarded. In the case where 704 | //it actually does get played, we don't need a special case because simplifyBeliefs will do this updating 705 | //of the remaining cards in sequence. 706 | primeBelief(cid) match { 707 | case None => () 708 | case Some(_: ProtectedSet) => () 709 | case Some(_: JunkSet) => () 710 | case Some(b: PlaySequence) => 711 | val remainingCids = b.info.cids.zipWithIndex.flatMap { case (laterCid,i) => 712 | if(i <= b.seqIdx) Some(laterCid) 713 | else { 714 | val possiblesWithoutThis = possibleCards(laterCid,ck=true).filter { laterCard => laterCard != card } 715 | if(provablyJunk(possiblesWithoutThis,postGame)) { 716 | addBelief(JunkSetInfo(cids = Array(laterCid))) 717 | None 718 | } 719 | else if(provablyNotPlayable(possiblesWithoutThis,postGame)) { 720 | addBelief(ProtectedSetInfo(cids = Array(laterCid))) 721 | None 722 | } 723 | else 724 | Some(laterCid) 725 | } 726 | } 727 | if(remainingCids.length != b.info.cids.length) { 728 | addBelief(PlaySequenceInfo(cids = remainingCids)) 729 | } 730 | } 731 | 732 | //Discard convention based on if the discard if the discard was of an expected play 733 | if(card != Card.NULL && preExpectedPlaysNow.contains(sd.hid)) { 734 | //Disallow discard convention from applying to throwing away finessed cards 735 | val wasFromFinesse = getFinesseCard(cid).nonEmpty 736 | 737 | //TODO maybe also check that it was NOT the most likely discard? 738 | //TODO this massively hurts playing strength on 4 player. Why? 739 | if(rules.numPlayers <= 3 && !wasFromFinesse) { 740 | //It's a hint about a playable duplicate of what that player believed that card could be. 741 | val prePossiblesPlayable = prePossibles.filter { card => postGame.isPlayable(card) } 742 | if(prePossiblesPlayable.nonEmpty) { 743 | 744 | //Find all players that have a copy of that card other than the discarder 745 | val hasCardAndNotDiscarder = (0 until rules.numPlayers).map { pid => 746 | if(pid == discardPid) false 747 | else postGame.hands(pid).exists { cid => prePossiblesPlayable.contains(seenMap(cid)) } 748 | }.toArray 749 | 750 | val hasCardCount = hasCardAndNotDiscarder.count(x => x == true) 751 | 752 | //Which players does the hint signal 753 | val targetedPids: List[PlayerId] = { 754 | //If nobody has that card and it wasn't us giving the hint, it's us. 755 | if(hasCardCount == 0 && myPid != discardPid) List(myPid) 756 | //If nobody has that card and it was us giving that hint, it signals everyone else. 757 | else if(hasCardCount == 0 && myPid == discardPid) { 758 | (0 until rules.numPlayers).filter { pid => pid != myPid }.toList 759 | } 760 | //If exactly one other player than the discarder has the card, it signals them. 761 | else if(hasCardCount == 1) (0 until rules.numPlayers).filter { pid => hasCardAndNotDiscarder(pid) }.toList 762 | //Else signals nobody 763 | else List() 764 | } 765 | 766 | def markPlayable(targetCid: CardId): Unit = { 767 | //If the targeted card is already playable now, then do nothing 768 | val alreadyBelievedPlayableNow = { 769 | primeBelief(targetCid) match { 770 | case Some(PlaySequence(0,_,_)) => true 771 | case _ => false 772 | } 773 | } 774 | if(!alreadyBelievedPlayableNow) { 775 | primeBelief(cid) match { 776 | //If old card was part of a sequence, then the new card is part of that sequence. 777 | case Some(PlaySequence(seqIdx,_finesseCard,info)) => 778 | val cids = info.cids.clone 779 | cids(seqIdx) = targetCid 780 | addBelief(PlaySequenceInfo(cids)) 781 | //Otherwise the new card is simply thought playable 782 | case _ => 783 | addBelief(PlaySequenceInfo(cids = Array(cid))) 784 | } 785 | } 786 | } 787 | 788 | targetedPids.foreach { pid => 789 | //If there is a positively hinted card that by CK could be the card, mark the first such card as playable if it 790 | //is not already marked. Else mark the first card that could by CK be it if not already marked. 791 | val hand = postGame.hands(pid) 792 | 793 | hand.find { cid => 794 | hintedMap(cid).exists { hinted => hinted.applied } && 795 | possibleCards(cid,ck=true).exists { c => prePossiblesPlayable.contains(c) } 796 | } match { 797 | case Some(cid) => markPlayable(cid) 798 | case None => 799 | hand.find { cid => 800 | possibleCards(cid,ck=true).exists { c => prePossiblesPlayable.contains(c) } 801 | } match { 802 | case Some(cid) => markPlayable(cid) 803 | case None => () 804 | } 805 | } 806 | } 807 | } 808 | } 809 | } 810 | //Changing mind abound junk sets 811 | else if(card != Card.NULL && !postGame.isJunk(card)) { 812 | primeBelief(cid) match { 813 | case None => () 814 | case Some(_: ProtectedSet) | Some(_: PlaySequence) => () 815 | case Some(b: JunkSet) => 816 | //If the card was not actually junk, then immediately change everything in the believed junk set 817 | //to protected so we don't keep discarding them. 818 | addBelief(ProtectedSetInfo(cids = b.info.cids)) 819 | } 820 | } 821 | 822 | val nextPidMostLikelyDiscard = mostLikelyDiscard(postGame.curPlayer,postGame,ck=true)._1 823 | 824 | //If discarding MLD when there was an expected play and there are no hints, 825 | //then protect the next player's MLD 826 | if(postGame.numHints <= 1 && preExpectedPlaysNow.nonEmpty && preMLD == sd.hid) { 827 | val nextMLDcid = postGame.hands(postGame.curPlayer)(nextPidMostLikelyDiscard) 828 | if(!isBelievedProtected(nextMLDcid)) 829 | addBelief(ProtectedSetInfo(cids = Array(nextMLDcid))) 830 | } 831 | 832 | //Make a new snapshot 833 | val newDPSnapshot = { 834 | DPSnapshot( 835 | pid = discardPid, 836 | turnNumber = preGame.turnNumber, 837 | postHands = postGame.hands.map { hand => Hand(hand) }, 838 | postHints = postGame.numHints, 839 | nextPlayable = postGame.nextPlayable.clone(), 840 | preDangers = preDangers, 841 | //TODO these make things quite a bit slower, any way to speed up? 842 | nextPidExpectedPlaysNow = expectedPlays(postGame.curPlayer,postGame,now=true,ck=true), 843 | nextPidMostLikelyDiscard, 844 | isFromPlay = false 845 | ) 846 | } 847 | dpSnapshots = newDPSnapshot :: dpSnapshots 848 | } 849 | 850 | //Handle a play that we've seen, updating info and beliefmaps. 851 | //Assumes seenMap is already updated, but nothing else. 852 | def handleSeenPlay(sp: SeenPlay, preGame: Game, postGame: Game): Unit = { 853 | val playPid = preGame.curPlayer 854 | val preDangers = seenMapCK.filterDistinctUnseen { card => preGame.isDangerous(card) }.toArray 855 | 856 | val cid = sp.cid 857 | updateSeenMap(postGame) 858 | 859 | //Successful play 860 | primeBelief(cid) match { 861 | //No prior belief 862 | case None => () 863 | //Card was protected - presumably the player somehow inferred it as playable 864 | case Some(_: ProtectedSet) => () 865 | //Card was believed junk - presumably the player somehow inferred it as playable 866 | case Some(_: JunkSet) => () 867 | //Card was a believed play. Remove the card from the play sequence it was a part of 868 | case Some(b: PlaySequence) => 869 | addBelief(PlaySequenceInfo(cids = b.info.cids.filter { c => c != cid })) 870 | } 871 | 872 | //Make a new snapshot 873 | val nextPidMostLikelyDiscard = mostLikelyDiscard(postGame.curPlayer,postGame,ck=true)._1 874 | val newDPSnapshot = { 875 | DPSnapshot( 876 | pid = playPid, 877 | turnNumber = preGame.turnNumber, 878 | postHands = postGame.hands.map { hand => Hand(hand) }, 879 | postHints = postGame.numHints, 880 | nextPlayable = postGame.nextPlayable.clone(), 881 | preDangers = preDangers, 882 | //TODO these make things quite a bit slower, any way to speed up? 883 | nextPidExpectedPlaysNow = expectedPlays(postGame.curPlayer,postGame,now=true,ck=true), 884 | nextPidMostLikelyDiscard, 885 | isFromPlay = true 886 | ) 887 | } 888 | dpSnapshots = newDPSnapshot :: dpSnapshots 889 | 890 | } 891 | 892 | def handleSeenBomb(sb: SeenBomb, preGame: Game, postGame: Game): Unit = { 893 | val cid = sb.cid 894 | val bombPid = preGame.curPlayer 895 | updateSeenMap(postGame) 896 | 897 | primeBelief(cid) match { 898 | //No prior belief 899 | case None => () 900 | //Card was protected - presumably the player somehow inferred it as playable but bombed?? 901 | case Some(_: ProtectedSet) => () 902 | //Card was believed junk - presumably the player somehow inferred it as playable or chose to play it anyways?? 903 | case Some(_: JunkSet) => () 904 | //Card was a believed play, but turned out to bomb. 905 | case Some(b: PlaySequence) => 906 | //Immediately change everything in the sequence to protected if the card was useful 907 | //If it was junk, assume it was just an unfortunate collision 908 | val card = seenMap(cid) 909 | if(card != Card.NULL && postGame.isUseful(card)) { 910 | addBelief(ProtectedSetInfo(cids = b.info.cids)) 911 | } 912 | //If the card was only ever hinted playable as the first in its sequence, 913 | //protect everything older than the oldest in the most recent play sequence. 914 | val beliefs = beliefMap(cid) 915 | if(beliefs.forall { belief => 916 | belief match { 917 | case (b2: PlaySequence) => b2.seqIdx == 0 918 | case (_: ProtectedSet) => true 919 | case (_: JunkSet) => true 920 | } 921 | }) { 922 | val preHand = preGame.hands(bombPid) 923 | val lastHid = b.info.cids.foldLeft(0) { case (acc,cid) => 924 | preHand.findIdx { c => c == cid } match { 925 | //A different player had the card in sequence! Ignore it. 926 | case None => acc 927 | case Some(hid) => Math.max(acc,hid) 928 | } 929 | } 930 | val protectedCids = ((lastHid+1) until preHand.length).map { hid => preHand(hid) }.toArray 931 | addBelief(ProtectedSetInfo(cids = protectedCids)) 932 | } 933 | } 934 | } 935 | 936 | 937 | //Finesses! 938 | def applyFinesses(pid: PlayerId, postGame: Game, hintCids: Array[CardId]): Unit = { 939 | if(HeuristicPlayer.ENABLE_FINESSE && pid != postGame.curPlayer && pid != myPid) { 940 | finesseBase(postGame,hintCids) match { 941 | case None => () 942 | case Some(baseCid) => 943 | //Finesses can only apply if the baseCid is also first in its play sequence. 944 | val firstInSequence = { 945 | primeBelief(baseCid) match { 946 | case None => true 947 | case Some(_: JunkSet) => false 948 | case Some(b: ProtectedSet) => true 949 | case Some(b: PlaySequence) => b.seqIdx == 0 950 | } 951 | } 952 | 953 | if(firstInSequence) { 954 | val baseCard = seenMap(baseCid) 955 | assert(baseCard != Card.NULL && baseCard.number > 0) 956 | val finesseCard = Card(color=baseCard.color,number=baseCard.number-1) 957 | 958 | //Walk through all the players strictly in between the hinting player and the hinted player 959 | //and get all the cards that might be targeted 960 | val targetsInBetween = { 961 | var cids: List[CardId] = List() 962 | var pidInBetween = postGame.curPlayer 963 | while(pidInBetween != pid) { 964 | finesseTarget(postGame,pidInBetween,baseCard) match { 965 | case None => () 966 | case Some(cid) => cids = cids :+ cid 967 | } 968 | pidInBetween = (pidInBetween + 1) % rules.numPlayers 969 | } 970 | cids 971 | } 972 | 973 | //Must have at most 1 target be good - finesse does nothing if multiple good targets. 974 | val numGood = targetsInBetween.count { cid => seenMap(cid) != Card.NULL && isGoodForFinesse(postGame,seenMap(cid),baseCard) } 975 | if(numGood <= 1) { 976 | val goodTarget = targetsInBetween.find { cid => seenMap(cid) != Card.NULL && isGoodForFinesse(postGame,seenMap(cid),baseCard) } 977 | goodTarget match { 978 | case Some(targetCid) => 979 | addFinesse(targetCid,baseCid,finesseCard) 980 | case None => 981 | //If there's a card in there we can't see (i.e. it targets us) then do that one. 982 | val unknownTarget = targetsInBetween.find { cid => seenMap(cid) == Card.NULL } 983 | unknownTarget match { 984 | case Some(targetCid) => 985 | addFinesse(targetCid,baseCid,finesseCard) 986 | case None => 987 | //No real targets at all - then everyone will assume it hits everyone, so reflect that in beliefs. 988 | targetsInBetween.foreach { targetCid => addFinesse(targetCid,baseCid,finesseCard) } 989 | } 990 | } 991 | } 992 | } 993 | } 994 | } 995 | } 996 | 997 | //Handle a hint that we've seen, updating info and beliefmaps. 998 | //Assumes seenMap is already updated, but nothing else. 999 | def handleSeenHint(sh: SeenHint, postGame: Game): Unit = { 1000 | updateSeenMap(postGame) 1001 | 1002 | val pid = sh.pid //Player who was hinted 1003 | val hand = postGame.hands(pid) //Hand of player who was hinted 1004 | val hintCids = (0 until hand.numCards).flatMap { hid => 1005 | if(sh.appliedTo(hid)) Some(hand(hid)) 1006 | else None 1007 | }.toArray 1008 | 1009 | //Prior to updating the hintedMap of what we and everyone knows about cards, figure out common 1010 | //knowledge about what cards could have been what prior to this action. 1011 | //handPrePossiblesCKByCid: For all cids in a hand, the possibles for those cids 1012 | val handPrePossiblesCKByCid: Array[List[Card]] = Array.fill(rules.deckSize)(List()) 1013 | val prePossiblesCKByHand: Array[Array[List[Card]]] = postGame.hands.map { hand => 1014 | hand.cardArray().map { cid => 1015 | val possibles = possibleCards(cid,ck=true) 1016 | handPrePossiblesCKByCid(cid) = possibles 1017 | possibles 1018 | } 1019 | } 1020 | 1021 | //See what cards would have been be possible for the player to play by common knowledge 1022 | val preExpectedPlaysNow: List[HandId] = expectedPlays(pid,postGame,now=true,ck=true) 1023 | val preAllBelievedAndUniqueProvablePlays: List[CardId] = allBelievedAndUniqueProvablePlays(postGame) 1024 | 1025 | //Now update hintedMap with the logical information of the hint 1026 | val hintedInfo = HintedInfo(sh, hand.cardArray()) 1027 | for (hid <- 0 until hand.numCards) { 1028 | val hinted = Hinted(hid,sh.appliedTo(hid),hintedInfo) 1029 | hintedMap.add(hand(hid),hinted) 1030 | } 1031 | 1032 | //Scan through all cids provided. 1033 | //If some cids are provably of the same colors as other cards believed playable already, then 1034 | //chain those cids on to the appropriate play sequence for those other cards, and filter them out of the array. 1035 | def chainAndFilterFuturePlays(cids: Array[CardId]): Array[CardId] = { 1036 | cids.filter { cid => 1037 | val color = uniquePossibleUsefulColor(cid, postGame, ck=true) 1038 | var keep = true 1039 | if(color != NullColor) { 1040 | val earlierPlayCid = preAllBelievedAndUniqueProvablePlays.find { 1041 | playCid => playCid != cid && color == uniquePossibleUsefulColor(playCid, postGame, ck=true) 1042 | } 1043 | earlierPlayCid match { 1044 | case None => () 1045 | case Some(earlierPlayCid) => 1046 | primeBelief(earlierPlayCid) match { 1047 | case Some(b: PlaySequence) => 1048 | val info = b.info 1049 | val cidsOfSameColor = info.cids.filter { c => c == earlierPlayCid || color == uniquePossibleUsefulColor(c, postGame, ck=true) } 1050 | addBelief(PlaySequenceInfo(cids = cidsOfSameColor :+ cid)) 1051 | keep = false 1052 | case _ => 1053 | addBelief(PlaySequenceInfo(cids = Array(earlierPlayCid,cid))) 1054 | keep = false 1055 | } 1056 | } 1057 | } 1058 | keep 1059 | } 1060 | } 1061 | 1062 | //See what card that player would have been likely to discard 1063 | val (preMLD,preMLDGoodness): (HandId, DiscardGoodness) = mostLikelyDiscard(pid,postGame,ck=true) 1064 | 1065 | //Affects the most likely discard, and the most likely discard could possibly be dangerous 1066 | val suggestsMLDPossibleDanger = { 1067 | sh.appliedTo(preMLD) && 1068 | !provablyNotDangerous(possibleCards(hand(preMLD),ck=true),postGame) 1069 | } 1070 | //Proves that an expected/believed play is actually dangerous 1071 | val provesPlayNowAsDanger = { 1072 | preExpectedPlaysNow.exists { hid => 1073 | sh.appliedTo(hid) 1074 | provablyDangerous(possibleCards(hand(hid),ck=true),postGame) 1075 | } 1076 | } 1077 | 1078 | //MLD was hinted and it had no prior belief, but an earlier discard or play snapshot indicates it's safe 1079 | val mldSuggestedButSafeDueToDiscardOrPlay = { 1080 | suggestsMLDPossibleDanger && 1081 | primeBelief(hand(preMLD)).isEmpty && { 1082 | //Player immediately before the hinted player failed to take the relevant action 1083 | val beforePid = (pid + rules.numPlayers - 1) % rules.numPlayers 1084 | def dpSnapshotOkay(ds: DPSnapshot): Boolean = { 1085 | //There was no card we were expected to play, and the same card was our MLD at the time as well. 1086 | ds.nextPidExpectedPlaysNow.isEmpty && 1087 | ds.postHands(pid)(ds.nextPidMostLikelyDiscard) == hand(preMLD) && 1088 | { 1089 | //There is no possible card for mld that became dangerous in the meantime 1090 | val dangerNow = seenMapCK.filterDistinctUnseen { card => postGame.isDangerous(card) }.toArray 1091 | !(possibleCards(hand(preMLD),ck=true).exists { card => 1092 | dangerNow.contains(card) && !ds.preDangers.contains(card) 1093 | }) 1094 | } 1095 | } 1096 | 1097 | val ds = dpSnapshots.find { ds => ds.postHints >= 3 && ds.postHints < rules.maxHints && ds.pid == beforePid } 1098 | ds match { 1099 | case None => false 1100 | case Some(ds) => dpSnapshotOkay(ds) 1101 | } 1102 | } 1103 | } 1104 | 1105 | //Check if it's a hint where the manner of the hint strongly indicates that it's a play hint 1106 | //even if it would otherwise touch the most likely discard or a believed play that was actually danger 1107 | val isPlayEvenIfAffectingMldOrDangerPlay: Boolean = { 1108 | { 1109 | //Hint affects at least one card that was not a play before and that could be playable now. 1110 | hintCids.exists { cid => 1111 | val possibles = possibleCards(cid,ck=true) 1112 | !provablyNotPlayable(possibles,postGame) //could be playable now 1113 | !preExpectedPlaysNow.exists { hid => cid == hand(hid) } //not possible play before 1114 | } 1115 | } && { 1116 | //Suggests the MLD but a discard snapshot makes it safe, and there's no other reason we should 1117 | //interpret this hint as protection 1118 | (mldSuggestedButSafeDueToDiscardOrPlay && !provesPlayNowAsDanger) || { 1119 | //Number-and-color-specific rules 1120 | sh.hint match { 1121 | case HintNumber(num) => 1122 | //All cards in hint are either provably junk, possibly playable, or completely known 1123 | hintCids.forall { cid => 1124 | val possibles = possibleCards(cid,ck=true) 1125 | !provablyNotPlayable(possibles,postGame) || 1126 | provablyJunk(possibles,postGame) || 1127 | possibles.length == 1 1128 | } && { 1129 | //TODO tweak these conditions 1130 | //The number of cards possibly playable is > the number of cards of this number that are useful. 1131 | //OR all color piles are >= that number 1132 | //OR the first card is new and the number of cards possibly playable is >= the number useful and not playable 1133 | val numPossiblyPlayable = hintCids.count { cid => 1134 | val possibles = possibleCards(cid,ck=true) 1135 | !provablyNotPlayable(possibles,postGame) 1136 | } 1137 | numPossiblyPlayable > colors.count { color => postGame.nextPlayable(color.id) <= num } || 1138 | colors.forall { color => postGame.nextPlayable(color.id) >= num } || 1139 | (cardIsNew(pid,hintCids.head,minPostHints=3) && 1140 | numPossiblyPlayable >= colors.count { color => postGame.nextPlayable(color.id) < num }) 1141 | } 1142 | case HintColor(color) => 1143 | //Affects the first card and cards other than the first are already protected. 1144 | //OR newest card is new and there are no dangers yet in that color 1145 | sh.appliedTo(0) && (1 until hand.length).forall { hid => isBelievedProtected(hand(hid)) } || 1146 | { 1147 | val firstHid = sh.appliedTo.indexWhere(b => b) 1148 | val firstCid = hand(firstHid) 1149 | //Test if "new" - find the first snapshot with at least 3 hints after, and if it fails to contain the card, 1150 | //then the card "never clearly had a chance to be hinted yet". 1151 | cardIsNew(pid,firstCid,minPostHints=3) && 1152 | //No dangers yet - all possiblities for all cards in hint either have numCardsInitial = 1 or are not dangerous 1153 | //or are completely known or are playable. 1154 | hintCids.forall { cid => 1155 | val possibles = possibleCards(cid,ck=true) 1156 | possibles.length == 1 || 1157 | possibles.forall { card => numCardsInitial(card.arrayIdx) <= 1 || !postGame.isDangerous(card) || postGame.isPlayable(card) } 1158 | } 1159 | } 1160 | case _ => false 1161 | } 1162 | } 1163 | } 1164 | } 1165 | 1166 | //If this hint is an unknown hint, it does nothing 1167 | if(sh.hint == UnknownHint) 1168 | {} 1169 | //If (the hint possibly protects the most likely discard OR proves a believed play now is danger) 1170 | //AND (there are no cards that the player would have played OR the hint touches a card we would have played) 1171 | //AND (we don't trigger one of the exceptions for isPlayEvenIfAffectingMldOrDangerPlay) 1172 | //then it's a protection hint. 1173 | else if( 1174 | (suggestsMLDPossibleDanger || provesPlayNowAsDanger) && 1175 | (preExpectedPlaysNow.isEmpty || preExpectedPlaysNow.exists { hid => sh.appliedTo(hid) }) && 1176 | !isPlayEvenIfAffectingMldOrDangerPlay 1177 | ) { 1178 | addBelief(ProtectedSetInfo(cids = hintCids)) 1179 | } 1180 | //Otherwise if at least one card hinted could be playable after the hint, then it's a play hint 1181 | else if(hintCids.exists { cid => !provablyNotPlayable(possibleCards(cid,ck=true),postGame) }) { 1182 | //Cards that are provably playable come first in the ordering 1183 | val (hintCidsProvable, hintCidsNotProvable): (Array[CardId],Array[CardId]) = 1184 | hintCids.partition { cid => provablyPlayable(possibleCards(cid,ck=true),postGame) } 1185 | 1186 | //Split out any cards that should belong to other play sequences 1187 | val hintCidsNotProvable2 = chainAndFilterFuturePlays(hintCidsNotProvable) 1188 | addBelief(PlaySequenceInfo(cids = hintCidsProvable ++ hintCidsNotProvable2)) 1189 | //Finesses! 1190 | applyFinesses(pid,postGame,hintCids) 1191 | } 1192 | //Otherwise if all cards in the hint are provably unplayable and not provably junk 1193 | else if(hintCids.forall { cid => 1194 | val possibles = possibleCards(cid,ck=true) 1195 | provablyNotPlayable(possibles,postGame) && !provablyJunk(possibles,postGame) 1196 | }) { 1197 | //Finesses! Apply them first so that other things can get chained on to them. 1198 | applyFinesses(pid,postGame,hintCids) 1199 | //Split out any cards that should belong to other play sequences 1200 | val leftoverCids = chainAndFilterFuturePlays(hintCids) 1201 | //Anything remaining treat as protected 1202 | addBelief(ProtectedSetInfo(cids = leftoverCids)) 1203 | } 1204 | //Otherwise if all cards in the hint are provably junk, then it's a protection hint 1205 | //to all older cards that are not provably junk older than the oldest in the hint 1206 | else if(hintCids.forall { cid => provablyJunk(possibleCards(cid,ck=true),postGame) }) { 1207 | var oldestHintHid = 0 1208 | for(hid <- 0 until sh.appliedTo.length) { 1209 | if(sh.appliedTo(hid)) 1210 | oldestHintHid = hid 1211 | } 1212 | val protectedCids = ((oldestHintHid+1) until sh.appliedTo.length).map { hid => postGame.hands(pid)(hid) } 1213 | addBelief(ProtectedSetInfo(cids = protectedCids.toArray)) 1214 | } 1215 | 1216 | } 1217 | 1218 | //Simplify and prune beliefs based on actual common-knowledge observations that may contradict them. 1219 | def simplifyBeliefs(preGame: Game, postGame: Game) = { 1220 | //Array to avoid visiting each cid more than once 1221 | val visited = Array.fill(rules.deckSize)(false) 1222 | postGame.hands.foreach { hand => 1223 | hand.foreach { cid => 1224 | if(!visited(cid)) { 1225 | primeBelief(cid) match { 1226 | case None => () 1227 | //Filter protected sets down to only cards that could be dangerous 1228 | case Some(b: ProtectedSet) => 1229 | b.info.cids.foreach { cid => visited(cid) = true } 1230 | val (remainingCids,filteredCids) = b.info.cids.partition { cid => !provablyJunk(possibleCards(cid,ck=true),postGame) } 1231 | val (newCids,playCids) = remainingCids.partition { cid => !provablyPlayableIfUseful(possibleCards(cid,ck=true),postGame) } 1232 | if(filteredCids.length > 0) addBelief(JunkSetInfo(cids = filteredCids)) 1233 | if(playCids.length > 0) addBelief(PlaySequenceInfo(cids = playCids)) 1234 | if(newCids.length < b.info.cids.length) addBelief(ProtectedSetInfo(cids = newCids)) 1235 | 1236 | //Filter junk sets down to only cards that could be safe 1237 | case Some(b: JunkSet) => 1238 | b.info.cids.foreach { cid => visited(cid) = true } 1239 | val (newCids,filteredCids) = b.info.cids.partition { cid => !provablyDangerous(possibleCards(cid,ck=true),postGame) } 1240 | if(filteredCids.length > 0) { 1241 | addBelief(JunkSetInfo(cids = newCids)) 1242 | addBelief(ProtectedSetInfo(cids = filteredCids)) 1243 | } 1244 | 1245 | //Filter play sequences down to only card ids that could be playable in that sequence given the cards before 1246 | //Also remove cards from the play sequence that were superseeded by another belief 1247 | case Some(b: PlaySequence) => 1248 | b.info.cids.foreach { cid => visited(cid) = true } 1249 | var count = 0 1250 | var expectedPlaysUpToNow: List[Card] = List() 1251 | def possiblyPlayable(card: Card): Boolean = { 1252 | postGame.isPlayable(card) || 1253 | expectedPlaysUpToNow.exists { c => c.color == card.color && c.number == card.number-1 } 1254 | } 1255 | def partOfThisSequence(cid: CardId): Boolean = { 1256 | primeBelief(cid) match { 1257 | case Some(other: PlaySequence) => other.info eq b.info 1258 | case _ => false 1259 | } 1260 | } 1261 | val remainingCids = b.info.cids.filter { cid => partOfThisSequence(cid) } 1262 | val (newCids,filteredCids) = remainingCids.partition { cid => 1263 | count += 1 1264 | if(count == b.info.cids.length) 1265 | possibleCards(cid,ck=true).exists { card => possiblyPlayable(card) } 1266 | else { 1267 | val possiblePlays = possibleCards(cid,ck=true).filter { card => possiblyPlayable(card) } 1268 | if(possiblePlays.isEmpty) 1269 | false 1270 | else { 1271 | expectedPlaysUpToNow = possiblePlays ++ expectedPlaysUpToNow 1272 | true 1273 | } 1274 | } 1275 | } 1276 | //TODO this is weird. We update the PlaySequence belief and filter out cids that are no longer part of the sequence 1277 | //only at the times where we have protected or junk card to separate out. 1278 | //It makes things worse on all of 2p,3p,4p to: 1279 | // * Always filter out cids that are no longer part of the sequence 1280 | // * Never filter out cids that are no longer part of the sequence. 1281 | //Why?? 1282 | if(filteredCids.length > 0) { 1283 | val (protectCids,junkCids) = filteredCids.partition { cid => !provablyJunk(possibleCards(cid,ck=true),postGame) } 1284 | addBelief(PlaySequenceInfo(cids = newCids)) 1285 | addBelief(ProtectedSetInfo(cids = protectCids)) 1286 | addBelief(JunkSetInfo(cids = junkCids)) 1287 | } 1288 | } 1289 | } 1290 | } 1291 | } 1292 | 1293 | //Also remove any finesses by the player who just moved if their finesse-implied card was playable now. 1294 | preGame.hands(preGame.curPlayer).foreach { cid => 1295 | removePlayableFinesseBeliefs(preGame,cid) 1296 | } 1297 | } 1298 | 1299 | //Check if the current state appears to be consistent. 1300 | //Not exhaustive, but should catch most inconsistencies that might occur in practice. 1301 | //(i.e. discarding things assuming them to be X and then finding out later that X must 1302 | //still be in your hand due to more complex inferences that you didn't work out then) 1303 | def checkIsConsistent(postGame: Game): Boolean = { 1304 | postGame.hands.forall { hand => 1305 | hand.forall { cid => hasPossible(cid) } 1306 | } 1307 | } 1308 | 1309 | def softPlus(x: Double, width: Double) = { 1310 | if(x/width >= 40.0) //To avoid floating point overflow 1311 | 40.0 1312 | else 1313 | Math.log(1.0 + Math.exp(x/width)) * width 1314 | } 1315 | 1316 | //Maps from expected score space ("raw eval") -> goodness space ("eval") 1317 | //This is a bit of a hack, because otherwise the horizon effect makes the bot highly reluctant to discard 1318 | //due to fears of discarding the exact same card as partner is about to discard. By exping the values, we make 1319 | //the averaging of that scenario have less effect. 1320 | def transformEval(rawEval: Double) = { 1321 | Math.exp(rawEval / 2.5) 1322 | } 1323 | def untransformEval(eval: Double) = { 1324 | Math.log(eval) * 2.5 1325 | } 1326 | def evalToString(eval: Double) = { 1327 | "%.1f (%.3f)".format(eval,untransformEval(eval)) 1328 | } 1329 | 1330 | //If we're not stopping on early losses, drop the raw eval by this many points for each point of score 1331 | //we provably will miss a win by. 1332 | val scoreDropPerLostPoint = 3.0 1333 | 1334 | def staticEvalGame(game: Game): Double = { 1335 | if(game.isDone()) { 1336 | transformEval(game.numPlayed.toDouble - scoreDropPerLostPoint * (rules.maxScore - game.numPlayed)) 1337 | } 1338 | else { 1339 | //PRELIMARIES----------------------------------------------------------------------------------------- 1340 | //Compute some basic bounds and values used in the eval 1341 | 1342 | //Number of cards successfully played already 1343 | val numPlayed = game.numPlayed.toDouble 1344 | 1345 | //Maximum number of turns that there could potentially be this game that play a card. 1346 | val turnsWithPossiblePlayLeft = { 1347 | //On the last round 1348 | if(game.finalTurnsLeft >= 0) { 1349 | //Count remaining players who have a turn 1350 | (0 until game.finalTurnsLeft).count { pidOffset => 1351 | val pid = (game.curPlayer + pidOffset) % rules.numPlayers 1352 | //Whose hand has at least one possibly useful card. 1353 | game.hands(pid).exists { cid => !provablyJunk(possibleCards(cid,ck=false),game) } 1354 | } 1355 | } 1356 | else { 1357 | //Simply the number of possible cards left in the deck, plus 1 for every player with 1358 | //any useful card 1359 | game.deck.length + game.hands.count { hand => 1360 | hand.exists { cid => !provablyJunk(possibleCards(cid,ck=false),game) } 1361 | } 1362 | } 1363 | } 1364 | 1365 | //TODO this logic doesn't make a lot of sense (why not also use turnsWithPossiblePlayLeft in the 1366 | //case where we stop early loss)? 1367 | //But attempts to change it make the bot worse. 1368 | //Also, the bot still misplays in the endgame by not understanding that everyone must have a playable 1369 | //if you get to max discards, but adding that understanding also makes things worse...!? 1370 | 1371 | //Maximum number of possible plays left to make in the game, taking into account turnsWithPossiblePlayLeft 1372 | val maxPlaysLeft = { 1373 | //Count up cards that are still useful taking into account dead piles. 1374 | var usefulCardCount = 0 1375 | colors.foreach { color => 1376 | var number = game.nextPlayable(color.id) 1377 | while(game.numCardRemaining(Card.arrayIdx(color,number)) > 0 && number <= rules.maxNumber) { 1378 | usefulCardCount += 1 1379 | number += 1 1380 | } 1381 | } 1382 | Math.min(usefulCardCount,turnsWithPossiblePlayLeft) 1383 | }.toDouble 1384 | 1385 | //The amount by which we will provably miss the max score by. 1386 | val provableLossBy = rules.maxScore - numPlayed - maxPlaysLeft 1387 | 1388 | //NET HINTS----------------------------------------------------------------------------------------- 1389 | //The most important term in the eval function - having more hints left in the game 1390 | //(including in the future) is better. 1391 | 1392 | val numHints = game.numHints 1393 | val numDiscardsLeft = rules.maxDiscards - game.numDiscarded 1394 | val numUnknownHintsGiven = game.numUnknownHintsGiven 1395 | val numPotentialHints = { 1396 | numDiscardsLeft + 1397 | numHints + 1398 | //Assume that unknown hints gain some value, even if we don't know what would be hinted 1399 | numUnknownHintsGiven * { 1400 | //They're more valuable if we have a new card that we just drew. 1401 | //TODO this isn't exactly right for 3 and 4 player 1402 | if(cardIsNew(myPid,game.hands(myPid)(1),minPostHints=3)) 1403 | 0.45 1404 | else if(cardIsNew(myPid,game.hands(myPid)(0),minPostHints=3)) 1405 | 0.30 1406 | else 1407 | 0.10 1408 | } + 1409 | //Also count future hints from playing 5s. But the last one isn't useful, so subtract 1. 1410 | { 1411 | if(rules.extraHintFromPlayingMax) 1412 | Math.max(0, -1 + colors.count { color => game.nextPlayable(color.id) <= rules.maxNumber }) 1413 | else 1414 | 0 1415 | } 1416 | } 1417 | 1418 | //TODO fix up all of the coefficients below and tune them 1419 | //Adjustment - penalize for "bad" beliefs that need more hints to fix 1420 | val fixupHintsRequired = 1421 | game.hands.foldLeft(0.0) { case (acc,hand) => 1422 | hand.foldLeft(acc) { case (acc,cid) => 1423 | val card = game.seenMap(cid) 1424 | val value = { 1425 | if(card == Card.NULL) 1426 | 0.0 1427 | else { 1428 | val possibles = possibleCards(cid,ck=true) 1429 | if(!provablyNotPlayable(possibles,game) && 1430 | isBelievedPlayable(cid,now=true) && 1431 | !game.isPlayable(card) && 1432 | !game.isDangerous(card) //This because danger we often have to hint anyways, so no cost to have to fixup 1433 | ) 1434 | 0.30 / 0.85 1435 | else 1436 | 0.0 1437 | } 1438 | } 1439 | acc + value 1440 | } 1441 | } 1442 | 1443 | //Collects what cards are known and could be played soon, player by player over the next round 1444 | //in the order that they come up. 1445 | var distinctKnownPlayCardsByTurn: List[List[Card]] = List() 1446 | //Adjustment - bonus for "good" knowledge we already know that saves hints 1447 | val (knownPlays,goodKnowledge) = { 1448 | var kp = 0.0 1449 | var gk = 0.0 1450 | val nextRoundLen = { 1451 | if(game.finalTurnsLeft >= 0) game.finalTurnsLeft 1452 | else rules.numPlayers 1453 | } 1454 | (0 until nextRoundLen).foreach { pidOffset => 1455 | val pid = (game.curPlayer+pidOffset) % rules.numPlayers 1456 | var distinctKnownPlayCardsThisTurn: List[Card] = List() 1457 | val handLen = game.hands(pid).length 1458 | (0 until handLen).foreach { hid => 1459 | val cid = game.hands(pid)(hid) 1460 | val card = game.seenMap(cid) 1461 | val possibles = possibleCards(cid,ck=true) 1462 | if(probablyCorrectlyBelievedPlayableSoon(cid,game)) { 1463 | val inferredCard = uniquePossibleUseful(cid,game,ck=false) 1464 | //If we can't exactly determine a card, then count it as a good play, but otherwise 1465 | //filter out cases where multiple players each think they have the playable of a card 1466 | //to avoid over-counting them. 1467 | if(inferredCard == Card.NULL || { 1468 | !distinctKnownPlayCardsByTurn.exists(_.contains(inferredCard)) && 1469 | !distinctKnownPlayCardsThisTurn.contains(inferredCard) 1470 | }) { 1471 | if(inferredCard != Card.NULL) 1472 | distinctKnownPlayCardsThisTurn = inferredCard :: distinctKnownPlayCardsThisTurn 1473 | gk += 0.45 / 0.85 1474 | kp += 1.00 1475 | } 1476 | } 1477 | else if(isBelievedProtected(cid) && (card != Card.NULL && game.isDangerous(card))) { 1478 | //Protection at end of hand more efficient than other protection which could be deferred 1479 | //TODO is this really bad for 3 players or is it just overfitting to the test games? 1480 | if(hid == handLen-1 && rules.numPlayers != 3) gk += 0.35 / 0.85 1481 | else gk += 0.20 / 0.85 1482 | } 1483 | else if(isBelievedProtected(cid) && (card != Card.NULL && game.isPlayable(card))) 1484 | gk += 0.10 / 0.85 1485 | 1486 | //Add a bonus for knowing the card exactly 1487 | val numPossibles = possibles.length 1488 | if(numPossibles <= 1) 1489 | gk += 0.02 / 0.85 1490 | else 1491 | gk += 0.01 / numPossibles 1492 | } 1493 | distinctKnownPlayCardsByTurn = distinctKnownPlayCardsThisTurn.reverse :: distinctKnownPlayCardsByTurn 1494 | } 1495 | distinctKnownPlayCardsByTurn = distinctKnownPlayCardsByTurn.reverse 1496 | (kp,gk) 1497 | } 1498 | 1499 | //TODO not sure why we can't count a known play more. 1500 | //Increasing this makes things worse! 1501 | val knownPlayValue = if(rules.numPlayers <= 2) 0.20 else 0.1625 1502 | val numHintedOrPlayed = numPlayed + knownPlays * knownPlayValue 1503 | val numRemainingToHint = maxPlaysLeft - knownPlays * knownPlayValue 1504 | val netFreeHints = (numPotentialHints - fixupHintsRequired + goodKnowledge) * 0.85 - numRemainingToHint - 3.0 1505 | 1506 | //How many plays we have or expect to be able to hint in the future. 1507 | val expectedNumPlaysDueToHints = { 1508 | //Dummy value if there's nothing left to hint 1509 | if(numRemainingToHint <= 0) 1510 | numHintedOrPlayed 1511 | else { 1512 | val gapDueToLowHints = softPlus(-netFreeHints,2.5) 1513 | //Add 3 in numerator and denominator to scale to be closer to 1 1514 | var hintScoreFactorRaw = (maxPlaysLeft - gapDueToLowHints + 3.0) / (maxPlaysLeft + 3.0) 1515 | //Avoid it going negative, smoothly 1516 | hintScoreFactorRaw = softPlus(hintScoreFactorRaw,0.1) 1517 | //Avoid it going above 1 1518 | hintScoreFactorRaw = Math.min(hintScoreFactorRaw,1.0) 1519 | //Apply factor for final result 1520 | numHintedOrPlayed + numRemainingToHint * hintScoreFactorRaw 1521 | } 1522 | } 1523 | 1524 | //Hack - for adjusting the evaluation in the last round given the cards that are probably correctly believed playable. 1525 | //Ideally we would also count known plays as worth more (see above TODO), but that seems to make things worse! 1526 | val singleRoundExpectedNumPlays = { 1527 | numPlayed + numPlayableInOrder(distinctKnownPlayCardsByTurn,game) 1528 | } 1529 | 1530 | val finalExpectedNumPlaysDueToHints = { 1531 | if(expectedNumPlaysDueToHints < singleRoundExpectedNumPlays) 1532 | expectedNumPlaysDueToHints + 0.65 * (singleRoundExpectedNumPlays - expectedNumPlaysDueToHints) 1533 | else 1534 | expectedNumPlaysDueToHints 1535 | } 1536 | 1537 | //Re-adjust to be a factor in terms of numPlayed and maxPlaysLeft. 1538 | val hintScoreFactor = { 1539 | if(maxPlaysLeft == 0) 1.0 1540 | else (Math.min(finalExpectedNumPlaysDueToHints, numPlayed + maxPlaysLeft) - numPlayed) / maxPlaysLeft 1541 | } 1542 | 1543 | //LIMITED TIME/TURNS ----------------------------------------------------------------------------------------- 1544 | //Compute eval factors relating to having a limited amount of time or discards in the game. 1545 | 1546 | //How much of the remaining score are we not getting due to lack of turns 1547 | val turnsLeftFactor = { 1548 | if(maxPlaysLeft == 0) 1.0 1549 | else Math.min( 1550 | maxPlaysLeft, 1551 | //0.8 * turnsWithPossiblePlayLeft because we want a slight excess in amount 1552 | //of turns left to feel comfortable 1553 | 0.8 * turnsWithPossiblePlayLeft 1554 | ) / maxPlaysLeft 1555 | } 1556 | 1557 | //DANGER AND CLOGGING ----------------------------------------------------------------------------------------- 1558 | //Compute eval factors relating to having clogged hands or having discarded useful cards 1559 | 1560 | //TODO consider making this more principled - score based on the distribution of the remaining deck 1561 | //and not merely dangerousness? 1562 | 1563 | //How much of the remaining score are we not getting due to danger stuff 1564 | val dangerCount = distinctCards.foldLeft(0.0) { case (acc,card) => 1565 | val gap: Double = (rules.maxNumber - card.number).toDouble 1566 | if(card.number >= game.nextPlayable(card.color.id) && 1567 | game.isDangerous(card) && 1568 | seenMap.numUnseenByCard(card.arrayIdx) == 1) 1569 | acc + (gap + 0.1 * gap * gap) 1570 | else if(card.number >= game.nextPlayable(card.color.id) && 1571 | numCardsInitial(card.arrayIdx) > 2 && 1572 | game.numCardRemaining(card.arrayIdx) == 2 && 1573 | seenMap.numUnseenByCard(card.arrayIdx) == 2) 1574 | acc + (gap + 0.1 * gap * gap) * 0.6 1575 | else 1576 | acc 1577 | } 1578 | val dangerFactor = Math.max(0.0, 1.0 - (dangerCount / 200.0)) 1579 | 1580 | //For decreasing the clog value a little for things near playable or if nothing in front is dangerous. 1581 | //Equals distance from playable + number of dangers in front 1582 | def distanceFromPlayable(card: Card): Int = { 1583 | var distance = card.number - game.nextPlayable(card.color.id) 1584 | for(num <- game.nextPlayable(card.color.id) until card.number) { 1585 | if(game.isDangerous(Card(card.color,num))) 1586 | distance += 1 1587 | } 1588 | distance 1589 | } 1590 | def clogFactorOfDistance(distance: Int): Double = { 1591 | if(distance <= 1) 0.80 1592 | else if(distance <= 2) 0.88 1593 | else if(distance <= 3) 0.94 1594 | else if(distance <= 4) 0.97 1595 | else if(distance <= 5) 0.99 1596 | else 1.00 1597 | } 1598 | 1599 | //TODO clogginess depend on distance from playable in more cases, such as for danger cards 1600 | val handClogFactor = game.hands.foldLeft(1.0) { case (acc,hand) => 1601 | val numClogs = hand.foldLeft(0.0) { case (acc,cid) => 1602 | val card = seenMap(cid) 1603 | //We can't see the card - either in our hand or we're a simulation for that player 1604 | //This means it's safe to use ck=false, since we know no more than that player does, so whatever we 1605 | //prove can be proven by them too. 1606 | val clogAmount = { 1607 | if(card == Card.NULL) { 1608 | val possibles = possibleCards(cid,ck=false) 1609 | val base = { 1610 | if(provablyPlayable(possibles,game)) 1611 | 0.0 1612 | else if(isBelievedPlayable(cid,now=false)) 1613 | 0.0 1614 | else if(provablyJunk(possibles,game)) 1615 | 0.0 1616 | else if(provablyDangerous(possibles,game)) 1617 | 1.0 1618 | else if(isBelievedUseful(cid) && !isBelievedPlayable(cid,now=false)) 1619 | 1.0 1620 | else 1621 | 0.0 1622 | } 1623 | if(base <= 0.0) base 1624 | else { 1625 | val distance = possibles.foldLeft(0) { case (acc,card) => math.max(acc,distanceFromPlayable(card)) } 1626 | base * clogFactorOfDistance(distance) 1627 | } 1628 | } 1629 | //We can actually see the card 1630 | else { 1631 | val base = { 1632 | //TODO playable cards can sometimes clog a hand if they're hard to hint out and/or the 1633 | //player's current belief about them is wrong. Maybe experiment with this. 1634 | if(game.isPlayable(card)) 1635 | 0.0 1636 | else if(probablyCorrectlyBelievedPlayableSoon(cid,game)) 1637 | 0.0 1638 | else if(game.isDangerous(card)) 1639 | 1.0 1640 | //TODO should we count believed-playable junk cards as clogging? 1641 | else if(isBelievedUseful(cid)) 1642 | 1.0 1643 | else 1644 | 0.0 1645 | } 1646 | if(base <= 0.0) base 1647 | else { 1648 | val distance = distanceFromPlayable(card) 1649 | base * clogFactorOfDistance(distance) 1650 | } 1651 | } 1652 | } 1653 | acc + clogAmount 1654 | } 1655 | val freeSpace = rules.handSize.toDouble - numClogs 1656 | val knots = Array(0.66, 0.88, 0.96, 0.99, 1.00) 1657 | val value = { 1658 | val idx = math.floor(freeSpace).toInt 1659 | if(idx >= knots.length-1) knots(knots.length-1) 1660 | else knots(idx) + (knots(idx+1)-knots(idx)) * (freeSpace - idx.toDouble) 1661 | } 1662 | acc * value 1663 | } 1664 | 1665 | //BOMBS ----------------------------------------------------------------------------------------- 1666 | 1667 | val bombsLeft = rules.maxBombs - game.numBombs + 1 1668 | val bombsFactor = { 1669 | if(bombsLeft >= 3) 1.0 1670 | else if(bombsLeft == 2) 0.98 1671 | else if(bombsLeft == 1) 0.93 1672 | else 0.0 1673 | } 1674 | 1675 | //NEXT TURN ------------------------------------------------------------------------------------- 1676 | //Penalize if the player next to move is possibly about to just lose the game. 1677 | val nextTurnLossFactor = { 1678 | val pid = game.curPlayer 1679 | //TODO restricting all these to < 1 hint right now because if we do more, then the bot wrongly evals many actions 1680 | //because likelyActionsSimple isn't great and often allows us to end up in this kind of situation when in reality 1681 | //that next player would prevent this. Restricting to low hints limits to cases where this is less likely to miseval. 1682 | val dueToDiscardFactor = { 1683 | if(pid == myPid || game.numHints >= rules.maxHints || game.numDiscarded >= rules.maxDiscards || game.numHints > 1) 1684 | 1.0 1685 | else { 1686 | val (hid,dg) = mostLikelyDiscard(pid, game, ck=true) 1687 | val cid = game.hands(pid)(hid) 1688 | val aboutToLose = { 1689 | !isBelievedProtected(cid) && 1690 | provablyDangerous(possibleCards(cid,ck=false),game) && 1691 | dg >= DISCARD_REGULAR && 1692 | //TODO try ck=false here 1693 | expectedPlays(pid, game, now=true, ck=false).isEmpty 1694 | } 1695 | if(!aboutToLose) 1.00 1696 | else { 1697 | if(game.numHints <= 0) 0.80 1698 | else 0.90 1699 | } 1700 | } 1701 | } 1702 | val dueToBombFactor = { 1703 | if(pid == myPid || game.numHints > 1) 1704 | 1.0 1705 | else { 1706 | val plays = expectedPlays(pid, game, now=true, ck=true) 1707 | val aboutToLose = 1708 | plays.exists { hid => 1709 | val cid = game.hands(pid)(hid) 1710 | val possibles = possibleCards(cid,ck=false) 1711 | provablyNotPlayable(possibles,game) && (bombsLeft <= 1 || provablyDangerous(possibles,game)) 1712 | } 1713 | if(!aboutToLose) 1.0 1714 | else { 1715 | val factorLoss = if(game.numHints <= 0) 0.20 else 0.10 1716 | 1.0 - factorLoss / plays.length 1717 | } 1718 | } 1719 | } 1720 | 1721 | dueToDiscardFactor * dueToBombFactor 1722 | } 1723 | 1724 | val fewHintsFactor = { 1725 | val nextPid = (game.curPlayer + 1) % rules.numPlayers 1726 | //Penalize if the current player has 0 hints and the next player's MLD is scary and not known to be scary. 1727 | val cantProtectDangerFactor = { 1728 | val cantProtectDanger = { 1729 | (game.numHints == 0 && game.numDiscarded < rules.maxDiscards && nextPid != myPid) && { 1730 | val (hid,_) = mostLikelyDiscard(nextPid, game, ck=true) 1731 | val cid = game.hands(nextPid)(hid) 1732 | !isBelievedProtected(cid) && 1733 | provablyDangerous(possibleCards(cid,ck=false),game) && 1734 | //Ideally should be now=true, but the problem is that we want to not penalize the case 1735 | //where the next player has no playables but the current player has a playable that makes 1736 | //the next player have a playable! 1737 | expectedPlays(nextPid, game, now=false, ck=true).isEmpty 1738 | } 1739 | } 1740 | if(cantProtectDanger) 0.85 1741 | else 1.0 1742 | } 1743 | //Penalize if the current player has 0 hints and the next player is about to bomb and lose the game 1744 | val cantAvoidBombLossFactor = { 1745 | if(game.numHints > 0 || nextPid == myPid) 1.0 1746 | else { 1747 | val plays = expectedPlays(nextPid, game, now=true, ck=true) 1748 | val cantAvoidBombLoss = { 1749 | plays.exists { hid => 1750 | val cid = game.hands(nextPid)(hid) 1751 | val possibles = possibleCards(cid,ck=false) 1752 | provablyNotPlayable(possibles,game) && (bombsLeft <= 1 || provablyDangerous(possibles,game)) 1753 | } 1754 | } 1755 | if(cantAvoidBombLoss) 1.0 - (0.15 / plays.length) 1756 | else 1.0 1757 | } 1758 | } 1759 | 1760 | // if(cantProtectDangerFactor < 1.0) cantProtectDangerFactor 1761 | if(cantProtectDangerFactor < 1.0 || cantAvoidBombLossFactor < 1.0) cantProtectDangerFactor * cantAvoidBombLossFactor 1762 | //TODO why does this hurt 3 player? 1763 | else if(game.numHints == 0 && rules.numPlayers != 3) 0.98 1764 | else 1.00 1765 | } 1766 | 1767 | //PUT IT ALL TOGETHER ----------------------------------------------------------------------------------------- 1768 | 1769 | val totalFactor = { 1770 | dangerFactor * 1771 | turnsLeftFactor * 1772 | hintScoreFactor * 1773 | bombsFactor * 1774 | handClogFactor * 1775 | nextTurnLossFactor * 1776 | fewHintsFactor 1777 | } 1778 | val raw = { 1779 | numPlayed + maxPlaysLeft * totalFactor - scoreDropPerLostPoint * provableLossBy 1780 | } 1781 | 1782 | val eval = transformEval(raw) 1783 | 1784 | if(debugging(game)) { 1785 | println("EVAL----------------") 1786 | maybePrintAllBeliefs(game) 1787 | println("NumPlayed: %.0f, MaxPlaysLeft: %.0f, ProvableLossBy %.0f".format( 1788 | numPlayed, maxPlaysLeft, provableLossBy)) 1789 | println("Hints: %.2f, Knol: %.2f, Fixup: %.2f, NetHnt: %.2f".format( 1790 | numPotentialHints,goodKnowledge,fixupHintsRequired,netFreeHints)) 1791 | println("HintedOrPlayed: %.2f, Remaining: %.2f, SingleRoundPlays: %.2f, Expected: %.2f, HSF: %.3f".format( 1792 | numHintedOrPlayed,numRemainingToHint,singleRoundExpectedNumPlays,finalExpectedNumPlaysDueToHints,hintScoreFactor)) 1793 | println("TurnsWPossPlayLeft: %d, TWPPLF: %.3f".format( 1794 | turnsWithPossiblePlayLeft, turnsLeftFactor)) 1795 | println("DangerCount: %.3f, DF: %.3f".format( 1796 | dangerCount, dangerFactor)) 1797 | println("BombsLeft: %d, BF: %.3f".format( 1798 | bombsLeft, bombsFactor)) 1799 | println("HandClogF: %.3f".format( 1800 | handClogFactor)) 1801 | println("NextTurnLossF: %.3f".format( 1802 | nextTurnLossFactor)) 1803 | println("FewHintsF: %.3f".format( 1804 | fewHintsFactor)) 1805 | println("TotalFactor: %.3f".format( 1806 | totalFactor)) 1807 | println("Eval: %s".format( 1808 | evalToString(eval))) 1809 | } 1810 | 1811 | eval 1812 | } 1813 | } 1814 | 1815 | //Called at the start of the game once 1816 | def doHandleGameStart(game: Game): Unit = { 1817 | updateSeenMap(game) 1818 | } 1819 | 1820 | 1821 | //Update player for a given action. Return true if game still appears consistent, false otherwise. 1822 | def doHandleSeenAction(sa: SeenAction, preGame: Game, postGame: Game): Boolean = { 1823 | sa match { 1824 | case (sd: SeenDiscard) => 1825 | handleSeenDiscard(sd,preGame,postGame) 1826 | case (sp: SeenPlay) => 1827 | handleSeenPlay(sp,preGame,postGame) 1828 | case (sb: SeenBomb) => 1829 | handleSeenBomb(sb,preGame,postGame) 1830 | case (sh: SeenHint) => 1831 | handleSeenHint(sh,postGame) 1832 | } 1833 | 1834 | val consistent = checkIsConsistent(postGame) 1835 | if(consistent) 1836 | simplifyBeliefs(preGame,postGame) 1837 | consistent 1838 | } 1839 | 1840 | //Perform the given action assuming the given CardIds are the given Cards, and recursively search and evaluate the result. 1841 | //Assumes other players act "simply", according to evalLikelyActionSimple 1842 | //At the end, restore to the saved state. 1843 | def tryAction(game: Game, ga: GiveAction, assumingCards: List[(CardId,Card)], weight: Double, cDepth: Int, rDepth: Int, saved: SavedState): Double = { 1844 | val gameCopy = Game(game) 1845 | assumingCards.foreach { case (cid,card) => gameCopy.seenMap(cid) = card } 1846 | 1847 | val sa = gameCopy.seenAction(ga) 1848 | gameCopy.doAction(ga) 1849 | 1850 | //We need to check consistency in case the act of doing the action makes a higher-order deduction clear that we hadn't deduced 1851 | //before that the position is actually impossible, since our logical inferencing isn't 100% complete. 1852 | //doHandleSeenAction returns whether it finds things to be consistent or not. 1853 | val consistent = doHandleSeenAction(sa, game, gameCopy) 1854 | 1855 | if(!consistent) { 1856 | restoreState(saved) 1857 | Double.NaN 1858 | } 1859 | else { 1860 | val newCDepth = cDepth+1 1861 | val newRDepth = rDepth-1 1862 | val eval = { 1863 | if(newRDepth <= 0) 1864 | staticEvalGame(gameCopy) 1865 | else { 1866 | if(gameCopy.curPlayer == myPid) { 1867 | val (_,eval) = doGetAction(gameCopy,newCDepth,newRDepth) 1868 | eval 1869 | } 1870 | else 1871 | evalLikelyActionSimple(gameCopy,newCDepth,newRDepth) 1872 | } 1873 | } 1874 | restoreState(saved) 1875 | if(debugging(gameCopy)) { 1876 | println("Tried %-10s Assuming %s Weight %.2f Eval: %s".format( 1877 | game.giveActionToString(ga), 1878 | assumingCards.map({case (_,card) => card.toString(useAnsiColors=true)}).mkString(""), 1879 | weight, 1880 | evalToString(eval) 1881 | )) 1882 | } 1883 | eval 1884 | } 1885 | } 1886 | 1887 | def average[T](list: List[T])(f: (T,Double) => Double): Double = { 1888 | var sum = 0.0 1889 | var weightSum = 0.0 1890 | 1891 | list.foreach { elt => 1892 | val weight = 1.0 1893 | val eval = f(elt,weight) 1894 | if(!eval.isNaN()) { 1895 | sum += eval * weight 1896 | weightSum += weight 1897 | } 1898 | } 1899 | sum / weightSum 1900 | } 1901 | 1902 | def weightedAverage[T](list: List[(T,Double)])(f: (T,Double) => Double): Double = { 1903 | var sum = 0.0 1904 | var weightSum = 0.0 1905 | 1906 | list.foreach { case (elt,weight) => 1907 | val eval = f(elt,weight) 1908 | if(!eval.isNaN()) { 1909 | sum += eval * weight 1910 | weightSum += weight 1911 | } 1912 | } 1913 | sum / weightSum 1914 | } 1915 | 1916 | //Recursively evaluate averaging over a prediction of what the next player might do. 1917 | def evalLikelyActionSimple(game: Game, cDepth: Int, rDepth: Int): Double = { 1918 | val saved = saveState() 1919 | val nextActions = likelyActionsSimple(game.curPlayer,game,saved) 1920 | weightedAverage(nextActions) { (nextAction,prob) => 1921 | val eval = tryAction(game,nextAction,List(),prob,cDepth,rDepth,saved) 1922 | if(debugging(game)) { 1923 | maybePrintAllBeliefs(game) 1924 | println("Likely next: %-12s Weight: %.2f Eval: %s".format( 1925 | game.giveActionToString(nextAction), 1926 | prob, 1927 | evalToString(eval) 1928 | )) 1929 | } 1930 | eval 1931 | } 1932 | } 1933 | 1934 | //TODO make this better 1935 | 1936 | //Returns a probability distribution on possible actions the next player might do. Modifies the state to 1937 | //reflect what that player sees, restoring to the given state afterwards. 1938 | def likelyActionsSimple(pid: PlayerId, game: Game, saved: SavedState): List[(GiveAction,Double)] = { 1939 | updateSeenMap(game.hiddenFor(pid)) 1940 | val actions = { 1941 | val playsNow: List[HandId] = expectedPlays(pid, game, now=true, ck=false) 1942 | //Play if possible, randomly among all of them 1943 | //TODO the next player will prefer actually to play what makes future cards playable. 1944 | if(playsNow.nonEmpty) { 1945 | if(game.numDiscarded == rules.maxDiscards && game.numHints > 0 && playsNow.length <= 1 && 1946 | game.deck.length > 0 && 1947 | game.deck.forall { cid => provablyJunk(possibleCards(cid,ck=true),game) }) 1948 | List((GiveHint((pid+1) % game.rules.numPlayers, UnknownHint),1.0)) 1949 | else 1950 | playsNow.map { hid => (GivePlay(hid),1.0) } 1951 | } 1952 | //Give a hint if at max hints 1953 | else if(game.numHints >= rules.maxHints) 1954 | List((GiveHint((pid+1) % game.rules.numPlayers, UnknownHint),1.0)) 1955 | //No hints, must discard 1956 | else if(game.numHints <= 0) { 1957 | val (mld,dg) = mostLikelyDiscard(pid,game,ck=false) 1958 | //But a discard kills us - so play the first possibly playable card 1959 | if(game.numDiscarded >= rules.maxDiscards || dg <= DISCARD_USEFUL) { 1960 | val hid = firstPossiblyPlayableHid(game,pid,ck=true).getOrElse(0) 1961 | List((GivePlay(hid),1.0)) 1962 | } 1963 | //Discard doesn't kill us 1964 | else { 1965 | List((GiveDiscard(mld),1.0)) 1966 | } 1967 | } 1968 | //Neither max nor no hints 1969 | else { 1970 | //Discard kills us - then give a hint //TODO improve this for the last round 1971 | val (mld,dg) = mostLikelyDiscard(pid,game,ck=false) 1972 | if(game.numDiscarded >= rules.maxDiscards || dg <= DISCARD_USEFUL) { 1973 | List((GiveHint((pid+1) % game.rules.numPlayers, UnknownHint),1.0)) 1974 | } 1975 | else { 1976 | //TODO pretty inaccurate, make this smarter. Note though that the evaluation 1977 | //underestimates how good UnknownHint is because it doesn't do anything! 1978 | //TODO why is this only possible at such a low value? 1979 | //Assign a 20% probability to giving a hint 1980 | //TODO assign more probability in 3 and 4 player if you can see the next person needs a hint. 1981 | //TODO make this understand last-round mechanics and when delay hints are needed? 1982 | List( 1983 | (GiveDiscard(mld),0.80), 1984 | (GiveHint((pid+1) % game.rules.numPlayers, UnknownHint),0.20) 1985 | ) 1986 | } 1987 | } 1988 | } 1989 | restoreState(saved) 1990 | actions 1991 | } 1992 | 1993 | //Get an action for this player in the current game state via a short search. 1994 | //rDepth is the remaining number of turns until we evaluate. 1995 | //Returns the action and its evaluation. 1996 | def doGetAction(game: Game, cDepth: Int, rDepth: Int): (GiveAction,Double) = { 1997 | assert(myPid == game.curPlayer) 1998 | val nextPid = (myPid+1) % rules.numPlayers 1999 | 2000 | //We loop over all "reasonable" actions, computing their values, using these variables 2001 | //to store the best one, which we return at the end. 2002 | var bestAction: GiveAction = GivePlay(0) //always legal 2003 | var bestActionValue: Double = -10000.0 2004 | val saved = saveState() 2005 | 2006 | def recordAction(ga: GiveAction, value: Double) = { 2007 | if(!value.isNaN() && value > bestActionValue) { 2008 | bestActionValue = value 2009 | bestAction = ga 2010 | } 2011 | if(debugging(game)) { 2012 | println("Action %-10s Eval: %s".format( 2013 | game.giveActionToString(ga), 2014 | evalToString(value) 2015 | )) 2016 | } 2017 | } 2018 | 2019 | //Try all play actions 2020 | val ckPlaysNow: List[HandId] = expectedPlays(myPid, game, now=true, ck=true) 2021 | val playsNow: List[HandId] = expectedPlays(myPid, game, now=true, ck=false) 2022 | playsNow.foreach { hid => 2023 | //Right now, we only play cards we think are probably playable, so get all the possibilities 2024 | //and filter down conditioning on the card being playable, and average over the results 2025 | val cid = game.hands(myPid)(hid) 2026 | //If the card was from a finesse, weight that possibility very highly 2027 | val finesseCard = getFinesseCard(cid) 2028 | 2029 | val possiblesAndWeights = possibleCards(cid,ck=false).flatMap { card => 2030 | if(!game.isPlayable(card)) None 2031 | else if(finesseCard == Some(card)) Some((card,100.0)) 2032 | else Some((card,1.0)) 2033 | } 2034 | val ga = GivePlay(hid) 2035 | 2036 | val value = weightedAverage(possiblesAndWeights) { (card,weight) => 2037 | tryAction(game, ga, assumingCards=List((cid,card)), weight, cDepth, rDepth, saved) 2038 | } 2039 | recordAction(ga,value) 2040 | } 2041 | 2042 | //Try playing our first possibly-playable-card 2043 | if(playsNow.isEmpty) { 2044 | firstPossiblyPlayableHid(game,myPid,ck=false) match { 2045 | case None => () 2046 | case Some(hid) => 2047 | val cid = game.hands(myPid)(hid) 2048 | val isProtected = isBelievedProtected(cid) 2049 | val possibles = possibleCards(cid,ck=false).map { card => 2050 | //Weight nonplayable cards ultra-heavily, so that we'll only do this as a last resort. 2051 | //TODO can we decrease the weight? 2052 | if(isProtected && !game.isPlayable(card) && game.isDangerous(card)) (card,200.0) 2053 | else if(!game.isPlayable(card) && game.isDangerous(card)) (card,150.0) 2054 | else if(!game.isPlayable(card)) (card,100.0) 2055 | else (card,1.0) 2056 | } 2057 | val ga = GivePlay(hid) 2058 | val value = weightedAverage(possibles) { (card,weight) => 2059 | tryAction(game, ga, assumingCards=List((cid,card)), weight, cDepth, rDepth, saved) 2060 | } 2061 | recordAction(ga,value) 2062 | } 2063 | } 2064 | 2065 | //Try our most likely discard action 2066 | if(game.numHints < rules.maxHints) { 2067 | val (mld,dg) = mostLikelyDiscard(myPid,game,ck=false) 2068 | val cid = game.hands(myPid)(mld) 2069 | 2070 | //TODO technically this and many other places that use probability distributions or weighted 2071 | //card distributions don't handle multiplicity of cards properly in the prior - weights are not 2072 | //affected by whether there are 1, 2, or 3 of a card left 2073 | 2074 | //Based on what kind of discard it is, reweight the various cards that it could logically be 2075 | //to reflect that for better discards, it's rather unlikely for us to throw away something bad. 2076 | val possiblesAndWeights = dg match { 2077 | case (DISCARD_PROVABLE_JUNK | DISCARD_JUNK) => 2078 | possibleCards(cid,ck=false).map { card => 2079 | if(game.isJunk(card)) (card,1.0) 2080 | else if(!game.isDangerous(card)) (card,0.1) 2081 | else (card,0.01) 2082 | } 2083 | case (DISCARD_REGULAR) => 2084 | possibleCards(cid,ck=false).map { card => 2085 | if(game.isJunk(card)) (card,1.0) 2086 | else if(!game.isDangerous(card)) (card,0.7) 2087 | //Opponent is not expecting us to discard if we have a play available 2088 | else if(ckPlaysNow.nonEmpty) (card,0.5) 2089 | else (card,0.02) //TODO this should depend on hand position 2090 | } 2091 | case (DISCARD_USEFUL | DISCARD_PLAYABLE) => 2092 | possibleCards(cid,ck=false).map { card => (card,1.0) } 2093 | 2094 | case (DISCARD_MAYBE_GAMEOVER | DISCARD_GAMEOVER) => 2095 | possibleCards(cid,ck=false).map { card => 2096 | if(!game.isDangerous(card)) (card,1.0) 2097 | else (card,2.0) 2098 | } 2099 | } 2100 | 2101 | //Compute the average eval weighted by the weight of each card it could be. 2102 | val ga = GiveDiscard(mld) 2103 | val value = weightedAverage(possiblesAndWeights) { (card,weight) => 2104 | tryAction(game, ga, assumingCards=List((cid,card)), weight, cDepth, rDepth, saved) 2105 | } 2106 | recordAction(ga,value) 2107 | 2108 | //Try discarding each of our playables 2109 | playsNow.foreach { hid => 2110 | if(hid != mld) { 2111 | val cid = game.hands(myPid)(hid) 2112 | val possiblesAndWeights = possibleCards(cid,ck=false).flatMap { card => if(game.isPlayable(card)) Some((card,1.0)) else None } 2113 | val ga = GiveDiscard(hid) 2114 | val value = weightedAverage(possiblesAndWeights) { (card,weight) => 2115 | tryAction(game, ga, assumingCards=List((cid,card)), weight, cDepth, rDepth, saved) 2116 | } 2117 | recordAction(ga,value) 2118 | } 2119 | } 2120 | } 2121 | 2122 | //Try all hint actions 2123 | if(game.numHints > 0) { 2124 | (0 until rules.numPlayers-1).foreach { pidOffset => 2125 | possibleHintTypes.foreach { hint => 2126 | val ga = GiveHint((nextPid+pidOffset) % rules.numPlayers,hint) 2127 | if(game.isLegal(ga)) { 2128 | val value = tryAction(game, ga, assumingCards=List(), weight=1.0, cDepth, rDepth, saved) 2129 | recordAction(ga,value) 2130 | } 2131 | } 2132 | } 2133 | } 2134 | 2135 | //And return the best action 2136 | (bestAction,bestActionValue) 2137 | } 2138 | 2139 | def maybePrintAllBeliefs(game: Game): Unit = { 2140 | if(debugging(game)) { 2141 | (0 until rules.numPlayers).foreach { pid => 2142 | game.hands(pid).foreach { cid => 2143 | val card = seenMap(cid) 2144 | println("P%d Card %s (#%d) Belief %s".format( 2145 | pid, 2146 | card.toString(useAnsiColors=true), 2147 | cid, 2148 | primeBelief(cid).toString() 2149 | )) 2150 | } 2151 | } 2152 | } 2153 | } 2154 | 2155 | //INTERFACE -------------------------------------------------------------------- 2156 | 2157 | override def handleGameStart(game: Game): Unit = { 2158 | doHandleGameStart(game) 2159 | } 2160 | 2161 | override def handleSeenAction(sa: SeenAction, preGame: Game, postGame: Game): Unit = { 2162 | val consistent = doHandleSeenAction(sa,preGame,postGame) 2163 | assert(consistent) 2164 | } 2165 | 2166 | override def getAction(game: Game): GiveAction = { 2167 | //Hack 2168 | //If the game would not be done under rules that stop early on provable loss, then play as if that were the case. 2169 | var maybeModifiedGame = game 2170 | val origRules = rules 2171 | if(!maybeModifiedGame.rules.stopEarlyLoss) { 2172 | maybeModifiedGame.rules match { 2173 | case (gameRules: Rules.Standard) => 2174 | maybeModifiedGame = Game(game) 2175 | maybeModifiedGame.rules = gameRules.copy(stopEarlyLoss = true) 2176 | rules = maybeModifiedGame.rules 2177 | if(maybeModifiedGame.isDone()) { 2178 | maybeModifiedGame = game 2179 | rules = origRules 2180 | } 2181 | } 2182 | } 2183 | 2184 | maybePrintAllBeliefs(game) 2185 | val (action,_eval) = doGetAction(maybeModifiedGame,cDepth=0,rDepth=2) 2186 | rules = origRules 2187 | action 2188 | } 2189 | 2190 | } 2191 | --------------------------------------------------------------------------------