diff --git a/backend/src/main/scala/model/entities/customers/Bankroll.scala b/backend/src/main/scala/model/entities/customers/Bankroll.scala index 054ba11..a0ec071 100644 --- a/backend/src/main/scala/model/entities/customers/Bankroll.scala +++ b/backend/src/main/scala/model/entities/customers/Bankroll.scala @@ -1,21 +1,68 @@ package model.entities.customers +/** Defines the contract for an entity that possesses a bankroll (money). + * + * This trait tracks the entity's current financial balance and its initial + * starting balance, providing methods to update the bankroll and calculate its + * ratio relative to the start. + * + * @tparam T + * The concrete type of the entity that extends this trait, enabling + * F-bounded polymorphism for immutable updates. + */ trait Bankroll[T <: Bankroll[T]]: + /** The current amount of money held by the entity. + */ val bankroll: Double + + /** The initial bankroll amount when the entity started. Used for ratio + * calculations. + */ val startingBankroll: Double + + // Precondition to ensure bankroll is non-negative require( bankroll >= 0, s"Bankroll amount must be positive, instead is $bankroll" ) + /** Updates the entity's bankroll by adding a `netValue`. The `netValue` can + * be positive (gain) or negative (loss). A requirement ensures the bankroll + * does not drop below zero. + * + * @param netValue + * The amount to add to the current bankroll. + * @return + * A new instance of the entity with the updated bankroll. + * @throws IllegalArgumentException + * if the new bankroll would be negative. + */ def updateBankroll(netValue: Double): T = val newBankroll = bankroll + netValue require( newBankroll >= 0, s"Bankroll amount must be positive, instead is $newBankroll" ) - withBankroll(newBankroll, true) + withBankroll(newBankroll, true) // Pass true to indicate an update + /** Calculates the ratio of the current bankroll to the starting bankroll. + * This is useful for tracking profit/loss relative to the initial + * investment. + * + * @return + * The bankroll ratio (current bankroll / starting bankroll). + */ def bankrollRatio: Double = bankroll / startingBankroll + /** Returns a new instance of the entity with an updated bankroll value. This + * method must be implemented by concrete classes to ensure immutability. + * + * @param newBankroll + * The new bankroll value. + * @param update + * A boolean flag, often used to differentiate initial setting from an + * update, though its specific use depends on the implementing class. + * @return + * A new instance of the entity with the new bankroll. + */ def withBankroll(newBankroll: Double, update: Boolean = false): T diff --git a/backend/src/main/scala/model/entities/customers/BettingStrategy.scala b/backend/src/main/scala/model/entities/customers/BettingStrategy.scala index fa6bac5..26a4198 100644 --- a/backend/src/main/scala/model/entities/customers/BettingStrategy.scala +++ b/backend/src/main/scala/model/entities/customers/BettingStrategy.scala @@ -4,35 +4,131 @@ import model.entities.customers.CustState.Idle import model.entities.customers.CustState.Playing import model.entities.games._ +/** Defines a contract for entities that possess a betting strategy. + * + * This trait facilitates the integration of dynamic betting behaviors into + * entities that manage their own bankroll and customer state. It ensures that + * such entities can place bets, update their strategy based on outcomes, and + * change strategies. + * + * @tparam T + * The concrete type of the entity that extends this trait. It must also have + * Bankroll, CustomerState, and be able to provide a new instance with a + * changed betting strategy. + */ trait HasBetStrategy[T <: HasBetStrategy[T] & Bankroll[T] & CustomerState[T]]: - this: T => + this: T => // Self-type annotation ensures that 'this' is of type T, allowing 'copy' methods etc. val betStrategy: BettingStrategy[T] + /** Delegates the bet placement to the current betting strategy. The current + * entity instance is passed as context to the strategy. + * @return + * A Bet object representing the placed bet. + */ def placeBet(): Bet = betStrategy.placeBet(this) + /** Updates the entity's betting strategy based on the outcome of a game + * round. The current strategy's `updateAfter` method is called, and the + * entity is returned with the updated strategy (maintaining immutability). + * + * @param result + * The outcome of the game round (e.g., money won/lost, 0 for push). + * @return + * A new instance of the entity with the updated betting strategy. + */ def updateAfter(result: Double): T = withBetStrategy(betStrategy.updateAfter(this, result)) + /** Changes the entity's current betting strategy to a new one. + * @param newStrat + * The new BettingStrategy to adopt. + * @return + * A new instance of the entity with the changed betting strategy. + */ def changeBetStrategy(newStrat: BettingStrategy[T]): T = withBetStrategy(newStrat) + /** Abstract method to be implemented by concrete entities. It provides a way + * to return a new instance of the entity with an updated betting strategy, + * typically implemented using case class's `copy` method. + * + * @param newStrat + * The new BettingStrategy to be set. + * @return + * A new instance of the entity with the new strategy. + */ def withBetStrategy(newStrat: BettingStrategy[T]): T -trait BetStratType +/** Defines the types of betting strategies. + */ +sealed trait BetStratType + +/** Represents a Flat Betting strategy type. */ object FlatBet extends BetStratType + +/** Represents a Martingale strategy type. */ object Martingale extends BetStratType + +/** Represents an Oscar Grind strategy type. */ object OscarGrind extends BetStratType +/** Defines the common interface and properties for all betting strategies. + * + * Betting strategies are responsible for determining the bet amount and + * options, and for updating their internal state based on game outcomes. + * + * @tparam A + * The type of the entity (customer) that uses this strategy. It must have + * Bankroll and CustomerState capabilities. + */ trait BettingStrategy[A <: Bankroll[A] & CustomerState[A]]: - val betAmount: Double - val option: List[Int] + val betAmount: Double // The current amount to bet in the next round + val option: List[ + Int + ] // Game-specific options for the bet (e.g., numbers for Roulette) + require( betAmount >= 0, s"Bet amount must be positive, instead is $betAmount" ) + + /** Returns the type of this betting strategy. + * @return + * The BetStratType for this strategy. + */ def betType: BetStratType + + /** Generates a game-specific Bet object based on the strategy's current state + * and the customer's context. + * + * @param ctx + * The customer entity's context. + * @return + * A Bet object ready to be placed in a game. + */ def placeBet(ctx: A): Bet + + /** Updates the internal state of the betting strategy based on the previous + * game round's result. This method typically returns a new instance of the + * strategy with updated parameters (e.g., for progressive strategies like + * Martingale). + * + * @param ctx + * The customer entity's context (can be used for contextual updates). + * @param result + * The outcome of the game round (positive for win, negative for loss, 0 + * for push). + * @return + * A new instance of the BettingStrategy with its state updated. + */ def updateAfter(ctx: A, result: Double): BettingStrategy[A] + + /** Checks preconditions before placing a bet. Throws a `require` error if the + * bet amount exceeds bankroll or if the customer is not in a Playing state. + * + * @param ctx + * The customer entity's context. + */ protected def checkRequirement(ctx: A): Unit = require( betAmount <= ctx.bankroll, @@ -43,15 +139,38 @@ trait BettingStrategy[A <: Bankroll[A] & CustomerState[A]]: "Bet should be placed only if the customer is playing a game" ) -def defaultRedBet = +/** Default options for a red bet in Roulette. + */ +def defaultRedBet: List[Int] = List(16, 1, 3, 5, 7, 9, 12, 14, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36) +/** Implements a Flat Betting strategy. The bet amount remains constant + * regardless of previous game outcomes. + * + * @param betAmount + * The fixed amount to bet. + * @param option + * Game-specific options for the bet. + * @tparam A + * The entity type using this strategy. + */ case class FlatBetting[A <: Bankroll[A] & CustomerState[A]]( betAmount: Double, option: List[Int] ) extends BettingStrategy[A]: override def betType: BetStratType = FlatBet - def placeBet(ctx: A): Bet = + + /** Generates a game-specific bet for Flat Betting. Checks requirements and + * creates the appropriate Bet type based on the game. + * + * @param ctx + * The customer entity's context. + * @return + * A Bet object. + * @throws MatchError + * if the customer state is not Playing or game type is unknown. + */ + override def placeBet(ctx: A): Bet = checkRequirement(ctx) (ctx.customerState: @unchecked) match case Playing(game) => @@ -59,30 +178,82 @@ case class FlatBetting[A <: Bankroll[A] & CustomerState[A]]( case SlotMachine => SlotBet(betAmount) case Roulette => RouletteBet(betAmount, option) case Blackjack => BlackJackBet(betAmount, option.head) - case _ => ??? - // case Idle => throw new MatchError("Wrong customer state") + case _ => ??? // Placeholder for unhandled game types + // case Idle => throw new MatchError("Wrong customer state") // This case is guarded by checkRequirement - def updateAfter(ctx: A, result: Double): FlatBetting[A] = this + /** For Flat Betting, the strategy's state does not change after a game round. + * Returns the current instance. + * @param ctx + * The customer entity's context (unused in FlatBetting). + * @param result + * The outcome of the game round (unused in FlatBetting). + * @return + * This FlatBetting instance. + */ + override def updateAfter(ctx: A, result: Double): FlatBetting[A] = this +/** Companion object for FlatBetting, providing convenient factory methods. */ object FlatBetting: - + /** Creates a FlatBetting strategy with a single option. + * @param betAmount + * The fixed bet amount. + * @param option + * The single game option. + * @tparam A + * The entity type. + * @return + * A new FlatBetting instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( betAmount: Double, option: Int ): FlatBetting[A] = new FlatBetting[A](betAmount, List(option)) + /** Creates a FlatBetting strategy with a list of options. + * @param betAmount + * The fixed bet amount. + * @param options + * The list of game options. + * @tparam A + * The entity type. + * @return + * A new FlatBetting instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( betAmount: Double, options: List[Int] ): FlatBetting[A] = new FlatBetting[A](betAmount, options) + /** Creates a FlatBetting strategy with no specific options (e.g., for Slot + * Machines). + * @param betAmount + * The fixed bet amount. + * @tparam A + * The entity type. + * @return + * A new FlatBetting instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( betAmount: Double ): FlatBetting[A] = new FlatBetting[A](betAmount, List.empty) +/** Implements the Martingale betting strategy. The bet amount doubles after + * each loss and resets to base after a win. + * + * @param baseBet + * The initial bet amount to which the strategy resets after a win. + * @param betAmount + * The current bet amount for the next round. + * @param lossStreak + * The current number of consecutive losses. + * @param option + * Game-specific options for the bet. + * @tparam A + * The entity type using this strategy. + */ case class MartingaleStrat[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, betAmount: Double, @@ -90,39 +261,89 @@ case class MartingaleStrat[A <: Bankroll[A] & CustomerState[A]]( option: List[Int] ) extends BettingStrategy[A]: override def betType: BetStratType = Martingale - def placeBet(ctx: A): Bet = + + /** Generates a game-specific bet for Martingale strategy. Currently supports + * Roulette and Blackjack. + * + * @param ctx + * The customer entity's context. + * @return + * A Bet object. + * @throws MatchError + * if the customer state is not Playing or game type is unsupported. + */ + override def placeBet(ctx: A): Bet = checkRequirement(ctx) (ctx.customerState: @unchecked) match case Playing(game) => game.gameType match case Roulette => RouletteBet(betAmount, option) case Blackjack => BlackJackBet(betAmount, option.head) - case _ => ??? - // case Idle => throw new MatchError("Wrong customer state") + case _ => ??? // Placeholder for unhandled game types - def updateAfter(ctx: A, result: Double): MartingaleStrat[A] = + /** Updates the Martingale strategy's state based on the game result. If + * result is negative (loss), bet doubles and loss streak increments. If + * result is positive (win), bet resets to base and loss streak resets. If + * result is zero (push), state remains unchanged. + * + * @param ctx + * The customer entity's context. + * @param result + * The outcome of the game round. + * @return + * A new MartingaleStrat instance with updated state. + */ + override def updateAfter(ctx: A, result: Double): MartingaleStrat[A] = if result < 0 then this.copy(betAmount = nextBet(), lossStreak = lossStreak + 1) else if result == 0 then this else copy(betAmount = baseBet, lossStreak = 0) + /** Calculates the next bet amount based on the current loss streak for + * Martingale. + * @return + * The next bet amount. + */ def nextBet(): Double = baseBet * math.pow(2, lossStreak + 1) +/** Companion object for MartingaleStrat, providing convenient factory methods. + */ object MartingaleStrat: - + /** Creates a Martingale strategy with a single option, starting with baseBet. + * @param baseBet + * The initial bet amount. + * @param option + * The single game option. + * @tparam A + * The entity type. + * @return + * A new MartingaleStrat instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, option: Int ): MartingaleStrat[A] = MartingaleStrat(baseBet, baseBet, 0, List(option)) + /** Creates a Martingale strategy with a list of options, starting with + * baseBet. + * @param baseBet + * The initial bet amount. + * @param options + * The list of game options. + * @tparam A + * The entity type. + * @return + * A new MartingaleStrat instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, options: List[Int] ): MartingaleStrat[A] = MartingaleStrat(baseBet, baseBet, 0, options) + // Additional apply methods for more specific initialization (e.g., restoring state) def apply[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, betAmount: Double, @@ -139,6 +360,23 @@ object MartingaleStrat: ): MartingaleStrat[A] = new MartingaleStrat[A](baseBet, betAmount, lossStreak, options) +/** Implements the Oscar's Grind betting strategy. Aims for a single unit profit + * per cycle, adjusting bets based on wins and losses. + * + * @param baseBet + * The base unit bet amount. + * @param betAmount + * The current bet amount for the next round. + * @param startingBankroll + * The bankroll at the start of the current Oscar Grind cycle. + * @param lossStreak + * The current number of consecutive losses (not always used directly in bet + * calculation for Oscar Grind). + * @param option + * Game-specific options for the bet. + * @tparam A + * The entity type using this strategy. + */ case class OscarGrindStrat[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, betAmount: Double, @@ -147,16 +385,44 @@ case class OscarGrindStrat[A <: Bankroll[A] & CustomerState[A]]( option: List[Int] ) extends BettingStrategy[A]: override def betType: BetStratType = OscarGrind - def placeBet(ctx: A): Bet = + + /** Generates a game-specific bet for Oscar's Grind strategy. Currently + * supports Roulette and Blackjack. + * + * @param ctx + * The customer entity's context. + * @return + * A Bet object. + * @throws MatchError + * if the customer state is not Playing or game type is unsupported. + */ + override def placeBet(ctx: A): Bet = checkRequirement(ctx) (ctx.customerState: @unchecked) match case Playing(game) => game.gameType match case Roulette => RouletteBet(betAmount, option) case Blackjack => BlackJackBet(betAmount, option.head) - case _ => ??? + case _ => ??? // Placeholder for unhandled game types - def updateAfter(ctx: A, result: Double): OscarGrindStrat[A] = + /** Updates the Oscar's Grind strategy's state based on the game result. + * Logic: + * - If current bankroll exceeds startingBankroll, cycle profit achieved: + * reset betAmount to baseBet, update startingBankroll. + * - If result is positive (win) and no profit goal reached: increase + * betAmount by baseBet. + * - If result is zero (push): state remains unchanged. + * - If result is negative (loss): increment lossStreak (betAmount remains + * constant). + * + * @param ctx + * The customer entity's context. + * @param result + * The outcome of the game round. + * @return + * A new OscarGrindStrat instance with updated state. + */ + override def updateAfter(ctx: A, result: Double): OscarGrindStrat[A] = if ctx.bankroll > startingBankroll then this.copy(betAmount = baseBet, startingBankroll = ctx.bankroll) else if result > 0 then @@ -164,8 +430,21 @@ case class OscarGrindStrat[A <: Bankroll[A] & CustomerState[A]]( else if result == 0 then this else this.copy(lossStreak = lossStreak + 1) +/** Companion object for OscarGrindStrat, providing convenient factory methods. + */ object OscarGrindStrat: - + /** Creates an OscarGrind strategy, starting a new cycle. + * @param baseBet + * The base unit bet amount. + * @param bankroll + * The current bankroll, which becomes the starting bankroll for the cycle. + * @param option + * The single game option. + * @tparam A + * The entity type. + * @return + * A new OscarGrindStrat instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, bankroll: Double, @@ -173,6 +452,18 @@ object OscarGrindStrat: ): OscarGrindStrat[A] = OscarGrindStrat(baseBet, baseBet, bankroll, 0, List(option)) + /** Creates an OscarGrind strategy, starting a new cycle. + * @param baseBet + * The base unit bet amount. + * @param bankroll + * The current bankroll, which becomes the starting bankroll for the cycle. + * @param options + * The list of game options. + * @tparam A + * The entity type. + * @return + * A new OscarGrindStrat instance. + */ def apply[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, bankroll: Double, @@ -180,6 +471,7 @@ object OscarGrindStrat: ): OscarGrindStrat[A] = OscarGrindStrat(baseBet, baseBet, bankroll, 0, options) + // Additional apply methods for more specific initialization (e.g., restoring state) def apply[A <: Bankroll[A] & CustomerState[A]]( baseBet: Double, bankroll: Double, @@ -195,11 +487,3 @@ object OscarGrindStrat: lossStreak: Int ): OscarGrindStrat[A] = OscarGrindStrat(baseBet, baseBet, bankroll, lossStreak, options) - -//case class ReactiveRandomStrategy(base: Double, min: Double, max: Double) extends BettingStrategy: -// -// def placeBet(ctx: Customer) = BetDecision(base, "random") -// -// def updateAfter(result: GameResult) = -// val newBase = if result.netGain < 0 then (base - 1).max(min) else (base + 1).min(max) -// copy(base = newBase) diff --git a/backend/src/main/scala/model/entities/customers/BoredomFrustration.scala b/backend/src/main/scala/model/entities/customers/BoredomFrustration.scala index ad35cf6..90ca9eb 100644 --- a/backend/src/main/scala/model/entities/customers/BoredomFrustration.scala +++ b/backend/src/main/scala/model/entities/customers/BoredomFrustration.scala @@ -1,8 +1,25 @@ package model.entities.customers +/** Defines the contract for an entity that experiences boredom and frustration. + * + * This trait allows entities to track and update their emotional states, which + * can influence their decisions and behavior in the simulation. Boredom and + * frustration values are typically represented as percentages (0-100). + * + * @tparam T + * The concrete type of the entity that extends this trait, enabling + * F-bounded polymorphism for immutable updates. + */ trait BoredomFrustration[T <: BoredomFrustration[T]]: + /** The current boredom level of the entity (0.0 - 100.0). + */ val boredom: Double + + /** The current frustration level of the entity (0.0 - 100.0). + */ val frustration: Double + + // Preconditions to ensure boredom and frustration are within valid percentage range require( boredom >= 0.0 && boredom <= 100.0, s"Boredom must be a percentile value, instead is $boredom %" @@ -12,14 +29,49 @@ trait BoredomFrustration[T <: BoredomFrustration[T]]: s"Frustration must be a percentile value, instead is $frustration %" ) + /** Updates the entity's boredom level by adding a `boredomGain`. The new + * boredom level is clamped between 0.0 and 100.0. + * + * @param boredomGain + * The amount to add to the current boredom level. Can be positive or + * negative. + * @return + * A new instance of the entity with the updated boredom level. + */ def updateBoredom(boredomGain: Double): T = val newBoredom = boredom + boredomGain withBoredom((newBoredom max 0.0) min 100.0) + /** Updates the entity's frustration level by adding a `frustrationGain`. The + * new frustration level is clamped between 0.0 and 100.0. + * + * @param frustrationGain + * The amount to add to the current frustration level. Can be positive or + * negative. + * @return + * A new instance of the entity with the updated frustration level. + */ def updateFrustration(frustrationGain: Double): T = val newFrustration = frustration + frustrationGain withFrustration((newFrustration max 0.0) min 100.0) + /** Returns a new instance of the entity with an updated boredom level. This + * method must be implemented by concrete classes to ensure immutability. + * + * @param newBoredom + * The new boredom level. + * @return + * A new instance of the entity with the new boredom level. + */ def withBoredom(newBoredom: Double): T + /** Returns a new instance of the entity with an updated frustration level. + * This method must be implemented by concrete classes to ensure + * immutability. + * + * @param newFrustration + * The new frustration level. + * @return + * A new instance of the entity with the new frustration level. + */ def withFrustration(newFrustration: Double): T diff --git a/backend/src/main/scala/model/entities/customers/CustomerState.scala b/backend/src/main/scala/model/entities/customers/CustomerState.scala index 047fb72..4555225 100644 --- a/backend/src/main/scala/model/entities/customers/CustomerState.scala +++ b/backend/src/main/scala/model/entities/customers/CustomerState.scala @@ -3,23 +3,70 @@ package model.entities.customers import model.entities.customers.CustState.Playing import model.entities.games.Game +/** Defines the contract for an entity that possesses a customer state. + * + * This trait tracks whether a customer is `Playing` a game or `Idle`, + * providing methods to change this state and query their current activity. + * + * @tparam T + * The concrete type of the entity that extends this trait, enabling + * F-bounded polymorphism for immutable updates. + */ trait CustomerState[T <: CustomerState[T]]: + /** The current state of the customer (Playing or Idle). + */ val customerState: CustState + /** Changes the customer's state to a new specified state. This is a + * convenience method that delegates to `withCustomerState`. + * + * @param newState + * The new `CustState` to apply. + * @return + * A new instance of the entity with the updated customer state. + */ def changeState(newState: CustState): T = withCustomerState(newState) + /** Returns a new instance of the entity with the updated customer state. This + * method must be implemented by concrete classes to ensure immutability. + * + * @param newState + * The new `CustState` for the customer. + * @return + * A new instance of the entity with the updated customer state. + */ def withCustomerState(newState: CustState): T + /** Returns an `Option` containing the `Game` object if the customer is + * currently `Playing`, otherwise returns `Option.empty`. + * + * @return + * An `Option[Game]` representing the game the customer is playing, if any. + */ def getGameOrElse: Option[Game] = customerState match case Playing(game) => Some(game) case _ => Option.empty + /** Checks if the customer is currently in the `Playing` state. + * + * @return + * `true` if the customer is playing a game, `false` otherwise. + */ def isPlaying: Boolean = customerState match case Playing(_) => true case _ => false +/** Enumeration representing the possible states of a customer. + */ enum CustState: + /** Indicates the customer is currently playing a specific game. + * @param game + * The `Game` instance the customer is playing. + */ case Playing(game: Game) + + /** Indicates the customer is idle and not currently participating in a game. + */ case Idle diff --git a/backend/src/main/scala/model/entities/customers/Movable.scala b/backend/src/main/scala/model/entities/customers/Movable.scala index 0d8c476..32cdf40 100644 --- a/backend/src/main/scala/model/entities/customers/Movable.scala +++ b/backend/src/main/scala/model/entities/customers/Movable.scala @@ -3,12 +3,48 @@ package model.entities.customers import model.entities.Positioned import utils.Vector2D +/** Defines the contract for an entity that can move within the simulation + * environment. + * + * This trait extends `Positioned`, adding the concept of a `direction` vector + * and methods to update both position and direction. + * + * @tparam T + * The concrete type of the entity that extends this trait, enabling + * F-bounded polymorphism for immutable updates. + */ trait Movable[T <: Movable[T]] extends Positioned: + /** The current direction vector of the entity. + */ val direction: Vector2D + /** Returns a new instance of the entity with an updated position. This method + * ensures immutability. + * + * @param newPosition + * The new position for the entity. + * @return + * A new instance of the entity with the updated position. + */ def withPosition(newPosition: Vector2D): T + /** Returns a new instance of the entity with an updated direction. This + * method ensures immutability. + * + * @param newDirection + * The new direction vector for the entity. + * @return + * A new instance of the entity with the updated direction. + */ def withDirection(newDirection: Vector2D): T + /** Returns a new instance of the entity with its direction updated by adding + * a given vector to the current direction. + * + * @param addingDirection + * The vector to add to the current direction. + * @return + * A new instance of the entity with the added direction. + */ def addedDirection(addingDirection: Vector2D): T = withDirection(direction + addingDirection) diff --git a/backend/src/main/scala/model/entities/customers/StatusProfile.scala b/backend/src/main/scala/model/entities/customers/StatusProfile.scala index e7579e4..db43721 100644 --- a/backend/src/main/scala/model/entities/customers/StatusProfile.scala +++ b/backend/src/main/scala/model/entities/customers/StatusProfile.scala @@ -1,7 +1,30 @@ package model.entities.customers +/** Defines the contract for an entity that possesses a risk profile. + * + * This trait is used to categorize customers based on their behavior patterns + * within the casino, influencing decisions made by managers like the + * `DecisionManager`. + */ trait StatusProfile: + /** The risk profile associated with this entity. + */ val riskProfile: RiskProfile +/** Enumeration representing different risk profiles for customers. + * + * These profiles categorize customers' willingness to take risks and influence + * their decision-making, betting strategies, and reactions to game outcomes. + */ enum RiskProfile: - case VIP, Regular, Casual, Impulsive + /** VIP (Very Important Person) customer profile. */ + case VIP + + /** Regular customer profile. */ + case Regular + + /** Casual customer profile. */ + case Casual + + /** Impulsive customer profile. */ + case Impulsive diff --git a/backend/src/main/scala/model/managers/BaseManager.scala b/backend/src/main/scala/model/managers/BaseManager.scala index 7f79fdc..6488f89 100644 --- a/backend/src/main/scala/model/managers/BaseManager.scala +++ b/backend/src/main/scala/model/managers/BaseManager.scala @@ -1,13 +1,55 @@ package model.managers +/** Base trait for all managers in the simulation. + * + * Defines a fundamental contract for components that update a "slice" of the + * simulation state. Managers are designed to be functional, taking an input + * state and returning a new, updated state, promoting immutability. + * + * @tparam A + * The type of the state slice that this manager operates on. + */ trait BaseManager[A]: + /** Updates a given slice of the simulation state. + * + * This method encapsulates the core logic of the manager, transforming an + * input state into an updated state. + * + * @param slice + * The input state slice to be updated. + * @return + * The updated state slice. + */ def update(slice: A): A +/** Provides extension methods for `BaseManager` to enable fluent chaining. This + * allows managers to be composed using the `|` operator, creating processing + * pipelines for state updates. + */ extension [A](first: BaseManager[A]) + /** Chains two BaseManager instances together. The output of the `first` + * manager becomes the input of the `second` manager. + * + * @param second + * The second BaseManager in the chain. + * @return + * A new BaseManager that represents the sequential application of `first` + * then `second`. + */ def |(second: BaseManager[A]): BaseManager[A] = new BaseManager[A]: override def update(slice: A): A = second.update(first.update(slice)) +/** Provides an extension method for a state slice to apply a manager to itself. + * This allows for a fluent syntax like `mySlice | myManager`, applying the + * manager's update logic directly to the slice. + */ extension [A](slice: A) + /** Applies a BaseManager's update logic to the current state slice. + * @param manager + * The BaseManager to apply. + * @return + * The updated state slice after applying the manager. + */ def |(manager: BaseManager[A]): A = manager.update(slice) diff --git a/backend/src/main/scala/model/managers/CustomerBankrollManager.scala b/backend/src/main/scala/model/managers/CustomerBankrollManager.scala index 1c7e301..7f3bae7 100644 --- a/backend/src/main/scala/model/managers/CustomerBankrollManager.scala +++ b/backend/src/main/scala/model/managers/CustomerBankrollManager.scala @@ -1,18 +1,14 @@ package model.managers import model.entities.Entity -import model.entities.Player import model.entities.customers.Bankroll -import model.entities.customers.BoredomFrustration import model.entities.customers.CustState.Idle import model.entities.customers.CustState.Playing import model.entities.customers.CustomerState -import model.entities.customers.HasBetStrategy import model.entities.games.Game case class CustomerBankrollManager[ - A <: BoredomFrustration[A] & CustomerState[A] & Bankroll[A] & - HasBetStrategy[A] & Player[A] & Entity + A <: CustomerState[A] & Bankroll[A] & Entity ](games: List[Game]) extends BaseManager[Seq[A]]: def update(customers: Seq[A]): Seq[A] = diff --git a/backend/src/main/scala/model/managers/DecisionManager.scala b/backend/src/main/scala/model/managers/DecisionManager.scala index 5e7ade0..a1f06a0 100644 --- a/backend/src/main/scala/model/managers/DecisionManager.scala +++ b/backend/src/main/scala/model/managers/DecisionManager.scala @@ -4,11 +4,11 @@ import scala.util.Random import model.entities.ChangingFavouriteGamePlayer import model.entities.Entity -import model.entities.Player import model.entities.customers.Bankroll import model.entities.customers.BetStratType import model.entities.customers.BettingStrategy import model.entities.customers.BoredomFrustration +import model.entities.customers.CustState.Idle import model.entities.customers.CustomerState import model.entities.customers.FlatBet import model.entities.customers.FlatBetting @@ -44,23 +44,73 @@ import utils.TriggerDSL.FrustAbove import utils.TriggerDSL.Losses import utils.TriggerDSL.Trigger +/** Manages the decision-making process for customer entities within the casino + * simulation. + * + * This manager utilizes a configurable Decision Tree to determine customer + * actions based on their current state, risk profile, and game outcomes. It + * aims to provide realistic and complex AI behavior. + * + * @param games + * A list of all available games in the casino, used to access game states. + * @tparam A + * The type of customer entity this manager processes. It must possess + * Bankroll, BoredomFrustration, CustomerState, HasBetStrategy, Entity, and + * StatusProfile capabilities. + */ case class DecisionManager[ A <: Bankroll[A] & BoredomFrustration[A] & CustomerState[A] & - HasBetStrategy[A] & Player[A] & Entity & StatusProfile + HasBetStrategy[A] & Entity & StatusProfile ](games: List[Game]) - extends BaseManager[Seq[A]]: + extends BaseManager[Seq[A]]: // Manages a sequence of customers private val gameList = games.map(_.gameType).distinct - // Configuration + + /** Defines limits for take-profit (tp) and stop-loss (sl) thresholds. + * @param tp + * Take-profit ratio (e.g., 2.0 means 200% of starting bankroll). + * @param sl + * Stop-loss ratio (e.g., 0.3 means 30% of starting bankroll remaining). + */ private case class Limits(tp: Double, sl: Double) + + /** Defines modifiers for various customer profiles, affecting decision + * thresholds. + * @param limits + * Specific take-profit and stop-loss limits for the profile. + * @param bMod + * Boredom modifier (multiplier for boredom threshold). + * @param fMod + * Frustration modifier (multiplier for frustration threshold). + */ private case class Modifiers(limits: Limits, bMod: Double, fMod: Double) + + /** Object containing predefined modifiers for different risk profiles. These + * values can be easily adjusted to tune customer behavior. + */ private object ProfileModifiers: val modifiers: Map[RiskProfile, Modifiers] = Map( RiskProfile.VIP -> Modifiers(Limits(tp = 3.0, sl = 0.3), 1.30, 0.80), RiskProfile.Regular -> Modifiers(Limits(2.5, 0.3), 1.0, 1.0), - RiskProfile.Casual -> Modifiers(Limits(1.5, 0.5), 1.40, 1.30), + RiskProfile.Casual -> Modifiers(Limits(2.0, 0.5), 1.40, 1.30), RiskProfile.Impulsive -> Modifiers(Limits(5.0, 0.1), 0.70, 1.5) ) - // Rule & Future External Config + + /** Defines a single rule for switching betting strategies or influencing + * customer decisions. These rules are evaluated by the Decision Tree. + * + * @param profile + * The RiskProfile to which this rule applies. + * @param game + * The specific GameType to which this rule applies. + * @param strategy + * The current BetStratType of the customer for this rule to be active. + * @param trigger + * A TriggerDSL condition that must be met for the rule to activate. + * @param nextStrategy + * The BetStratType to switch to if the rule activates. + * @param betPercentage + * The percentage of the customer's bankroll to use for the new bet amount. + */ case class SwitchRule( profile: RiskProfile, game: GameType, @@ -69,183 +119,375 @@ case class DecisionManager[ nextStrategy: BetStratType, betPercentage: Double ) -//format: off + + /** Object containing the default predefined set of `SwitchRule`s. This is the + * base configuration for customer decision logic. + */ object DefaultConfig: val switchRules: List[SwitchRule] = List( - // VIP + // VIP Rules SwitchRule(VIP, Blackjack, Martingale, Losses(3), OscarGrind, 0.05), SwitchRule(VIP, Roulette, Martingale, Losses(4), OscarGrind, 0.05), - SwitchRule(VIP, SlotMachine, FlatBet, FrustAbove(50) || BrRatioBelow(0.5), FlatBet, 0.015), - SwitchRule(VIP,SlotMachine,Martingale, Always ,FlatBet,0.03), - SwitchRule(VIP,SlotMachine,OscarGrind, Always ,FlatBet,0.03), - SwitchRule(VIP,Blackjack,FlatBet, Always ,Martingale,0.03), - SwitchRule(VIP,Roulette,FlatBet, Always ,Martingale,0.03), - // Regular - SwitchRule(Regular, Blackjack, OscarGrind, BrRatioAbove(1.3), Martingale, 0.015), + SwitchRule( + VIP, + SlotMachine, + FlatBet, + FrustAbove(50) || BrRatioBelow(0.5), + FlatBet, + 0.015 + ), + SwitchRule(VIP, SlotMachine, Martingale, Always, FlatBet, 0.03), + SwitchRule(VIP, SlotMachine, OscarGrind, Always, FlatBet, 0.03), + SwitchRule(VIP, Blackjack, FlatBet, Always, Martingale, 0.03), + SwitchRule(VIP, Roulette, FlatBet, Always, Martingale, 0.03), + // Regular Rules + SwitchRule( + Regular, + Blackjack, + OscarGrind, + BrRatioAbove(1.3), + Martingale, + 0.015 + ), SwitchRule(Regular, Blackjack, Martingale, Losses(3), OscarGrind, 0.02), - SwitchRule(Regular, Roulette, OscarGrind, BrRatioAbove(1.3), Martingale, 0.015), + SwitchRule( + Regular, + Roulette, + OscarGrind, + BrRatioAbove(1.3), + Martingale, + 0.015 + ), SwitchRule(Regular, Roulette, Martingale, Losses(3), OscarGrind, 0.02), - SwitchRule(Regular, SlotMachine, FlatBet, FrustAbove(60) || BrRatioBelow(0.5), FlatBet, 0.01), - SwitchRule(Regular,SlotMachine,Martingale, Always ,FlatBet,0.03), - SwitchRule(Regular,SlotMachine,OscarGrind, Always ,FlatBet,0.03), - SwitchRule(Regular,Blackjack,FlatBet, Always ,OscarGrind,0.02), - SwitchRule(Regular,Roulette,FlatBet, Always ,OscarGrind,0.02), - // Casual - SwitchRule(Casual, SlotMachine, FlatBet, FrustAbove(50) || BrRatioBelow(0.7), FlatBet, 0.015), - SwitchRule(Casual,SlotMachine,Martingale, Always ,FlatBet,0.03), - SwitchRule(Casual,SlotMachine,OscarGrind, Always ,FlatBet,0.03), - SwitchRule(Casual,Blackjack,FlatBet, Always ,OscarGrind,0.03), - SwitchRule(Casual,Roulette,FlatBet, Always ,FlatBet,0.03), - // Impulsive + SwitchRule( + Regular, + SlotMachine, + FlatBet, + FrustAbove(60) || BrRatioBelow(0.5), + FlatBet, + 0.01 + ), + SwitchRule(Regular, SlotMachine, Martingale, Always, FlatBet, 0.03), + SwitchRule(Regular, SlotMachine, OscarGrind, Always, FlatBet, 0.03), + SwitchRule(Regular, Blackjack, FlatBet, Always, OscarGrind, 0.02), + SwitchRule(Regular, Roulette, FlatBet, Always, OscarGrind, 0.02), + // Casual Rules + SwitchRule( + Casual, + SlotMachine, + FlatBet, + FrustAbove(50) || BrRatioBelow(0.7), + FlatBet, + 0.015 + ), + SwitchRule(Casual, SlotMachine, Martingale, Always, FlatBet, 0.03), + SwitchRule(Casual, SlotMachine, OscarGrind, Always, FlatBet, 0.03), + SwitchRule(Casual, Blackjack, FlatBet, Always, OscarGrind, 0.03), + SwitchRule(Casual, Roulette, FlatBet, Always, FlatBet, 0.03), + // Impulsive Rules SwitchRule(Impulsive, Blackjack, Martingale, Losses(3), OscarGrind, 0.10), SwitchRule(Impulsive, Roulette, Martingale, Losses(3), FlatBet, 0.07), - SwitchRule(Impulsive, Roulette, FlatBet, BrRatioAbove(1), Martingale, 0.03), - SwitchRule(Impulsive, SlotMachine, FlatBet, FrustAbove(50), FlatBet, 0.02), - SwitchRule(Impulsive,SlotMachine,Martingale, Always ,FlatBet,0.03), - SwitchRule(Impulsive,Blackjack,FlatBet, Always ,Martingale,0.02), - SwitchRule(Impulsive,SlotMachine,OscarGrind, Always ,FlatBet,0.03), - SwitchRule(Impulsive,Roulette,FlatBet, Always ,Martingale,0.03), - + SwitchRule( + Impulsive, + Roulette, + FlatBet, + BrRatioAbove(1), + Martingale, + 0.03 + ), + SwitchRule( + Impulsive, + SlotMachine, + FlatBet, + FrustAbove(50), + FlatBet, + 0.02 + ), + SwitchRule(Impulsive, SlotMachine, Martingale, Always, FlatBet, 0.03), + SwitchRule(Impulsive, Blackjack, FlatBet, Always, Martingale, 0.02), + SwitchRule(Impulsive, SlotMachine, OscarGrind, Always, FlatBet, 0.03), + SwitchRule(Impulsive, Roulette, FlatBet, Always, Martingale, 0.03) ) -//format: on + + /** Object responsible for loading the configuration rules. Currently loads + * from DefaultConfig but can be extended to load from external sources. + */ object ConfigLoader: def load(): List[SwitchRule] = DefaultConfig.switchRules + + /** Lazily evaluated map of rules, grouped by RiskProfile for efficient + * lookup. + */ lazy val rulesByProfile: Map[RiskProfile, List[SwitchRule]] = ConfigLoader.load().groupBy(_.profile) + /** Sealed trait defining the possible decisions a customer can make. + */ sealed trait CustomerDecision + + /** Decision: Continue playing the current game. */ case class ContinuePlaying() extends CustomerDecision + + /** Decision: Stop playing the current game (transition to Idle). */ case class StopPlaying() extends CustomerDecision + + /** Decision: Change the current betting strategy to a new one. */ case class ChangeStrategy(newStrategy: BettingStrategy[A]) extends CustomerDecision + + /** Decision: Stay idle in the casino (e.g., wait for a game). */ case class Stay() extends CustomerDecision + + /** Decision: Leave the casino entirely. */ case class LeaveCasino() extends CustomerDecision - case class WaitForGame() extends CustomerDecision - def update(customers: Seq[A]): Seq[A] = - val tree = buildDecisionTree + /** Decision: Wait for a game ( when a game is not yet ready to play). */ + case class WaitForGame() extends CustomerDecision + /** Updates a sequence of customer entities based on their individual + * decisions. For each customer, a decision tree is evaluated, and the + * customer's state is updated accordingly. This method returns a new + * sequence of customers, potentially with some customers filtered out if + * they decide to leave the casino. + * + * @param customers + * The sequence of customer entities to update. + * @return + * A new sequence of updated customer entities. + */ + override def update(customers: Seq[A]): Seq[A] = + val tree = buildDecisionTree // Build the decision tree dynamically customers.flatMap { c => - val mod = ProfileModifiers.modifiers(c.riskProfile) - val decision = tree.eval(c) + val mod = ProfileModifiers.modifiers( + c.riskProfile + ) // Get profile-specific modifiers + val decision = + tree.eval(c) // Evaluate the customer's decision using the tree decision match case ContinuePlaying() => - Some(updateInGameBehaviours(c, mod).updateBoredom(3.0 * mod.bMod)) + Some(updateInGameBehaviours(c, mod).updateBoredom(5.0 * mod.bMod)) case StopPlaying() => - Some(c.stopPlaying.updateFrustration(-15.0 * (2 - mod.fMod))) + Some(c.changeState(Idle).updateFrustration(-20.0 * (2 - mod.fMod))) case ChangeStrategy(s) => - Some(c.changeBetStrategy(s).updateBoredom(-15.0 * (2 - mod.bMod))) + Some(c.changeBetStrategy(s).updateBoredom(-10.0 * (2 - mod.bMod))) case WaitForGame() => Some(getNewGameBet(c)) case Stay() => Some(c) - case LeaveCasino() => None + case LeaveCasino() => + None // Customers deciding to leave are filtered out } + /** Determines an initial or new betting strategy for a customer, typically + * when they are starting a game or need to define a bet where no specific + * rule applies. It prioritizes rules defined in `rulesByProfile`. + * + * @param c + * The customer entity. + * @return + * The customer entity with a potentially changed betting strategy. + */ private def getNewGameBet(c: A): A = val rules = rulesByProfile(c.riskProfile) rules - .collectFirst { + .collectFirst { // Find the first applicable rule case rule if rule.game == c.getGameOrElse.get.gameType && rule.strategy == c.betStrategy.betType && rule.trigger .eval(c) => c.changeBetStrategy(betDefiner(rule, c)) } - .getOrElse( + .getOrElse( // If no rule matches, apply a default FlatBetting strategy c.changeBetStrategy(FlatBetting(c.bankroll * 0.01, defaultRedBet)) ) + /** Updates a customer's in-game behavior attributes (frustration, strategy + * internal state) based on the outcome of the last game round. + * + * @param c + * The customer entity. + * @param mod + * Profile-specific modifiers for calculations. + * @return + * The updated customer entity. + */ private def updateInGameBehaviours(c: A, mod: Modifiers): A = + // Find the game the customer was playing and its last round result val updatedGame = games.find(_.id == c.getGameOrElse.get.id).get val lastRound = updatedGame.getLastRoundResult lastRound.find(_.getCustomerWhichPlayed == c.id) match - case Some(g) => - if g.getMoneyGain > 0 then + case Some(g) => // If the customer played the last round + if g.getMoneyGain > 0 then // Customer won c.updateFrustration( - (5 / c.bankrollRatio.max(0.5).min(2.0)) * mod.fMod - ).updateAfter(-g.getMoneyGain) - else + (5 / c.bankrollRatio + .max(0.7) + .min(2.0)) * mod.fMod // Adjust frustration based on win + ).updateAfter( + -g.getMoneyGain + ) // Update betting strategy with negative gain (loss for strategy context) + else // Customer lost or pushed c.updateFrustration( - (-5 / c.bankrollRatio.max(0.5).min(2.0)) * (2 - mod.fMod) - ).updateAfter(-g.getMoneyGain) - - case _ => c + (-3 / c.bankrollRatio + .max(0.7) + .min(2.0)) * (2 - mod.fMod) // Adjust frustration based on loss + ).updateAfter( + -g.getMoneyGain + ) // Update betting strategy with negative gain + case _ => c // Customer did not play the last round, return as is - // === Tree Builders === + /** Builds the main decision tree for customer actions. The tree's root + * branches based on whether the customer is currently playing. + * + * @return + * The root of the DecisionTree. + */ private def buildDecisionTree: DecisionTree[A, CustomerDecision] = DecisionNode[A, CustomerDecision]( - predicate = _.isPlaying, - trueBranch = gameNode, - falseBranch = leaveStayNode + predicate = _.isPlaying, // Check if the customer's state is Playing + trueBranch = gameNode, // If playing, go to game-specific decisions + falseBranch = leaveStayNode // If idle, decide whether to leave or stay ) + /** Subtree for decisions when a customer is thought to be playing. Checks if + * there's actually a recent game round result for the customer. + * + * @return + * A DecisionTree representing game-related checks. + */ private def gameNode: DecisionTree[A, CustomerDecision] = def checkIfPlaying(c: A): Boolean = + // Check if the customer's game actually has a last round result for them val updatedGame = games.find(_.id == c.getGameOrElse.get.id).get val lastRound = updatedGame.getLastRoundResult lastRound.nonEmpty DecisionNode[A, CustomerDecision]( predicate = c => checkIfPlaying(c), - trueBranch = profileNode, - falseBranch = Leaf[A, CustomerDecision](c => WaitForGame()) + trueBranch = + profileNode, // If actually played, proceed to profile-specific decisions + falseBranch = Leaf[A, CustomerDecision](c => + WaitForGame() + ) // If not played, wait for game ) + /** Subtree that branches decisions based on the customer's RiskProfile. Uses + * a MultiNode to dispatch to different decision paths for each profile. + * + * @return + * A DecisionTree branching by customer risk profile. + */ private def profileNode: DecisionTree[A, CustomerDecision] = MultiNode[A, RiskProfile, CustomerDecision]( - keyOf = _.riskProfile, - branches = RiskProfile.values.map(p => p -> stopContinueNode(p)).toMap, - default = Leaf[A, CustomerDecision](c => StopPlaying()) + keyOf = _.riskProfile, // Key is the customer's risk profile + branches = + RiskProfile.values + .map(p => p -> stopContinueNode(p)) + .toMap, // Map each profile to its decision sub-tree + default = Leaf[A, CustomerDecision](c => + StopPlaying() + ) // Default action if profile somehow not mapped ) + /** Subtree for idle customers, deciding whether they should leave the casino + * or stay. Decision is based on boredom, frustration, and bankroll limits. + * + * @return + * A DecisionTree for idle customer's leave/stay decision. + */ private def leaveStayNode: DecisionTree[A, CustomerDecision] = def leaveRequirements(c: A): Boolean = val mod = ProfileModifiers.modifiers(c.riskProfile) + // Compound trigger checking if boredom/frustration/bankroll limits are met for leaving val trigger: Trigger[A] = BoredomAbove( - (80 * mod.bMod).min(95.0) - ) || FrustAbove((80 * mod.fMod).min(95.0)) + (80 * mod.bMod).min(98.0) + ) || FrustAbove((80 * mod.fMod).min(85.0)) || BrRatioAbove(mod.limits.tp) || BrRatioBelow(mod.limits.sl) trigger.eval(c) DecisionNode[A, CustomerDecision]( predicate = c => leaveRequirements(c), - trueBranch = Leaf[A, CustomerDecision](c => LeaveCasino()), - falseBranch = Leaf[A, CustomerDecision](c => Stay()) + trueBranch = Leaf[A, CustomerDecision](c => + LeaveCasino() + ), // If conditions met, leave + falseBranch = Leaf[A, CustomerDecision](c => Stay()) // Otherwise, stay ) + /** Subtree for in-game customers, deciding whether to stop playing or + * continue. Decision is based on current bet status, boredom, frustration, + * and bankroll limits. + * + * @param profile + * The RiskProfile of the customer (used to retrieve modifiers). + * @param bThreshold + * Base boredom threshold. + * @param fThreshold + * Base frustration threshold. + * @return + * A DecisionTree for in-game customer's stop/continue decision. + */ private def stopContinueNode( profile: RiskProfile, - bThreshold: Double = 70, - fThreshold: Double = 60 + bThreshold: Double = 60, + fThreshold: Double = 50 ): DecisionTree[A, CustomerDecision] = def stopPlayingRequirements(c: A): Boolean = val mod = ProfileModifiers.modifiers(profile) + // Simulate applying game outcome to strategy temporarily to check next bet amount val betAmount = updateInGameBehaviours(c, mod).betStrategy.betAmount + // Compound trigger checking if boredom/frustration/bankroll limits or impossible bet are met for stopping val trigger: Trigger[A] = BoredomAbove(bThreshold * mod.bMod) || FrustAbove(fThreshold * mod.fMod) || BrRatioAbove(mod.limits.tp) || BrRatioBelow(mod.limits.sl) - betAmount > c.bankroll - 1 || trigger.eval(c) + betAmount > c.bankroll - 1 || trigger.eval( + c + ) // Stop if next bet is impossible or trigger activates DecisionNode[A, CustomerDecision]( predicate = c => stopPlayingRequirements(c), - trueBranch = Leaf[A, CustomerDecision](c => StopPlaying()), - falseBranch = strategySwitchNode(profile) + trueBranch = Leaf[A, CustomerDecision](c => + StopPlaying() + ), // If conditions met, stop playing + falseBranch = strategySwitchNode( + profile + ) // Otherwise, proceed to strategy switching logic ) + /** Subtree for determining if a customer should switch betting strategies. + * This is a Leaf node that evaluates defined `SwitchRule`s for the + * customer's profile. + * + * @param profile + * The RiskProfile of the customer. + * @return + * A Leaf DecisionTree that either suggests a strategy change or continues + * playing. + */ private def strategySwitchNode( profile: RiskProfile ): DecisionTree[A, CustomerDecision] = val rules = rulesByProfile.getOrElse(profile, Nil) Leaf[A, CustomerDecision] { c => rules - .collectFirst { + .collectFirst { // Find the first rule that applies case rule if rule.game == c.getGameOrElse.get.gameType && rule.strategy == c.betStrategy.betType && rule.trigger .eval(c) => - ChangeStrategy(betDefiner(rule, c)) + ChangeStrategy( + betDefiner(rule, c) + ) // If rule applies, suggest changing strategy } - .getOrElse(ContinuePlaying()) + .getOrElse(ContinuePlaying()) // If no rule applies, continue playing } - def betDefiner(rule: SwitchRule, c: A): BettingStrategy[A] = + /** Helper function to instantiate a new BettingStrategy based on a + * `SwitchRule`. Calculates the new bet amount as a percentage of the + * customer's bankroll. + * + * @param rule + * The SwitchRule specifying the next strategy type and bet percentage. + * @param c + * The customer entity. + * @return + * A new instance of the appropriate BettingStrategy. + */ + private def betDefiner(rule: SwitchRule, c: A): BettingStrategy[A] = rule.nextStrategy match case FlatBet => FlatBetting(c.bankroll * rule.betPercentage, c.betStrategy.option) @@ -258,56 +500,140 @@ case class DecisionManager[ c.betStrategy.option ) +/** An object responsible for updating the simulation environment after customer + * decisions have been made. + * + * This component handles "side effects" such as updating customer positions, + * changing their favorite games, and unlocking casino games, maintaining a + * functional separation from the core decision-making logic. + */ object PostDecisionUpdater: + /** Updates the physical position and favorite game of customers who have + * stopped playing. Customers who transition from 'Playing' to 'Idle' are + * moved back to their previous position and assigned a new random favorite + * game. + * + * @param before + * The sequence of customer entities before decision processing. + * @param post + * The sequence of customer entities after decision processing. + * @tparam P + * The type of customer entity, which must have MovableWithPrevious, + * CustomerState, ChangingFavouriteGamePlayer, and Entity capabilities. + * @return + * A list of updated customer entities with their new positions and + * favorite games. + */ def updatePosition[ P <: MovableWithPrevious[P] & CustomerState[P] & ChangingFavouriteGamePlayer[P] & Entity - ]( - before: Seq[P], - post: Seq[P] - ): List[P] = + ](before: Seq[P], post: Seq[P]): List[P] = val (hasStopPlaying, unchangedState, remained) = - groupForChangeOfState[P](before, post) + groupForChangeOfState[P]( + before, + post + ) // Identify customers who stopped playing + // For customers who stopped playing, update their position and assign a new favorite game val changePosition = hasStopPlaying.map { case (oldP, newP) => newP - .withPosition(oldP.previousPosition.get) - .withDirection(-newP.direction) + .withPosition( + oldP.previousPosition.get + ) // Move back to previous position + .withDirection(-newP.direction) // Reverse direction .withFavouriteGame( - Random.shuffle(gameTypesPresent.filter(_ != newP.favouriteGame)).head + Random + .shuffle(gameTypesPresent.filter(_ != newP.favouriteGame)) + .head // Assign new random favorite game ) - } - val unchanged = unchangedState.map(_._2) - changePosition ++ unchanged + }.toList // Convert to List after map + val unchanged = + unchangedState + .map(_._2) + .toList // Customers whose playing state didn't change + changePosition ++ unchanged // Combine updated and unchanged customers + + /** Updates the state of casino games, specifically unlocking games that were + * previously occupied by customers who have now stopped playing. + * + * @param before + * The sequence of customer entities before decision processing. + * @param post + * The sequence of customer entities after decision processing. + * @param games + * The list of all available Game entities in the casino. + * @tparam P + * The type of customer entity, which must have CustomerState and Entity + * capabilities. + * @return + * A list of updated Game entities. + */ def updateGames[P <: CustomerState[P] & Entity]( before: Seq[P], post: Seq[P], games: List[Game] ): List[Game] = val (hasStopPlaying, unchangedState, remained) = - groupForChangeOfState(before, post) + groupForChangeOfState( + before, + post + ) // Identify customers who stopped playing a game + val gameCustomerMap = - hasStopPlaying.map((_._1)).map(c => c.getGameOrElse.get.id -> c).toMap - val gameMap = games.map(g => g.id -> g).toMap - val gameLeft = gameMap.keySet.intersect(gameCustomerMap.keySet) + hasStopPlaying + .map((_._1)) + .map(c => c.getGameOrElse.get.id -> c) + .toMap // Map game IDs to customers who left them + val gameMap = + games.map(g => g.id -> g).toMap // Map game IDs to Game objects + + // Partition games into those that need unlocking and those that remain unchanged val (gameToUnlock, gameUnchanged) = gameMap.keySet.toList .map(id => (gameMap(id), gameCustomerMap.get(id))) - .partition { case (game, cust) => cust.isDefined } + .partition { case (game, custOption) => custOption.isDefined } + + // Unlock games and convert to list val updatedGame = - gameToUnlock.map((g, c) => g.unlock(c.get.id)).map(r => r.option().get) - updatedGame ++ gameUnchanged.map((g, _) => g) + gameToUnlock + .map((g, c) => g.unlock(c.get.id)) + .map(r => r.option().get) + .toList + + // Combine updated (unlocked) games with games that were not affected + updatedGame ++ gameUnchanged.map((g, _) => g).toList + /** A private helper method to group customers based on changes in their + * playing state. It compares customer states before and after decision + * processing. + * + * @param before + * The sequence of customer entities before processing. + * @param post + * The sequence of customer entities after processing. + * @tparam P + * The type of customer entity. + * @return + * A tuple containing: + * 1. A list of (old customer state, new customer state) for customers who + * stopped playing. 2. A list of (old customer state, new customer + * state) for customers whose playing state remained unchanged. 3. A set + * of IDs for customers who remained in the simulation. + */ private def groupForChangeOfState[P <: CustomerState[P] & Entity]( before: Seq[P], post: Seq[P] ): (List[(P, P)], List[(P, P)], Set[String]) = val beforeMap = before.map(p => p.id -> p).toMap val postMap = post.map(p => p.id -> p).toMap - val remained = beforeMap.keySet.intersect(postMap.keySet) + val remained = beforeMap.keySet.intersect( + postMap.keySet + ) // IDs of customers still in simulation + + // Partition remaining customers into those who stopped playing and those whose state is unchanged val (hasStopPlaying, unchangedState) = remained.toList .map(id => (beforeMap(id), postMap(id))) .partition { case (oldState, newState) => - oldState.isPlaying != newState.isPlaying + oldState.isPlaying != newState.isPlaying // Check if 'isPlaying' state has changed } (hasStopPlaying, unchangedState, remained) diff --git a/backend/src/main/scala/utils/DecisionTree.scala b/backend/src/main/scala/utils/DecisionTree.scala index 21aa0e0..077eb5b 100644 --- a/backend/src/main/scala/utils/DecisionTree.scala +++ b/backend/src/main/scala/utils/DecisionTree.scala @@ -1,25 +1,120 @@ package utils +/** Represents a generic decision tree structure. + * + * This sealed trait provides the common interface for all nodes within the + * decision tree, ensuring that any concrete node can evaluate a given context + * to produce a result. The use of a sealed trait ensures that all possible + * implementations are known at compile time, enabling exhaustive pattern + * matching and enhancing type safety. + * + * @tparam Ctx + * The type of the context (input) on which the decision tree operates. + * @tparam Res + * The type of the result (output) produced by the decision tree. + */ sealed trait DecisionTree[Ctx, Res]: + /** Evaluates the decision tree (or subtree) with the given context. + * + * This method traverses the tree structure, applying predicates or key + * lookups until a leaf node is reached, which then produces the final + * result. + * + * @param ctx + * The context object used for evaluation. + * @return + * The result produced by the decision tree based on the context. + */ def eval(ctx: Ctx): Res +/** Represents a leaf node in the decision tree. + * + * A Leaf node is a terminal point in the tree, directly providing a result + * based on an associated action function without further branching. + * + * @param action + * A function that takes the context and produces the final result for this + * leaf. + * @tparam Ctx + * The context type. + * @tparam Res + * The result type. + */ case class Leaf[Ctx, Res](action: Ctx => Res) extends DecisionTree[Ctx, Res]: + /** Evaluates the Leaf node by applying its action function to the context. + * @param ctx + * The context object. + * @return + * The result produced by the action. + */ override def eval(ctx: Ctx): Res = action(ctx) +/** Represents a binary decision node in the decision tree. + * + * A DecisionNode evaluates a predicate against the context and branches to + * either a trueBranch or a falseBranch accordingly. + * + * @param predicate + * A function that takes the context and returns a boolean, determining the + * branch to follow. + * @param trueBranch + * The DecisionTree to evaluate if the predicate returns true. + * @param falseBranch + * The DecisionTree to evaluate if the predicate returns false. + * @tparam Ctx + * The context type. + * @tparam Res + * The result type. + */ case class DecisionNode[Ctx, Res]( predicate: Ctx => Boolean, trueBranch: DecisionTree[Ctx, Res], falseBranch: DecisionTree[Ctx, Res] ) extends DecisionTree[Ctx, Res]: + /** Evaluates the DecisionNode by checking its predicate and recursing into + * the appropriate branch. + * @param ctx + * The context object. + * @return + * The result from the chosen branch. + */ override def eval(ctx: Ctx): Res = if predicate(ctx) then trueBranch.eval(ctx) else falseBranch.eval(ctx) +/** Represents a multi-way decision node in the decision tree. + * + * A MultiNode dispatches to one of several branches based on a key extracted + * from the context. It includes a default branch for keys that do not have a + * specific corresponding branch. + * + * @param keyOf + * A function that extracts a key from the context to select a branch. + * @param branches + * A map associating keys with their respective decision tree branches. + * @param default + * The default DecisionTree to evaluate if no matching key is found in + * 'branches'. + * @tparam Ctx + * The context type. + * @tparam Key + * The type of the key extracted from the context. + * @tparam Res + * The result type. + */ case class MultiNode[Ctx, Key, Res]( keyOf: Ctx => Key, branches: Map[Key, DecisionTree[Ctx, Res]], default: DecisionTree[Ctx, Res] ) extends DecisionTree[Ctx, Res]: + /** Evaluates the MultiNode by determining a key from the context and + * selecting the corresponding branch. If no specific branch matches the key, + * the default branch is evaluated. + * @param ctx + * The context object. + * @return + * The result from the selected branch. + */ override def eval(ctx: Ctx): Res = branches .get(keyOf(ctx)) diff --git a/backend/src/main/scala/utils/TriggerDSL.scala b/backend/src/main/scala/utils/TriggerDSL.scala index 8a07dbb..490be40 100644 --- a/backend/src/main/scala/utils/TriggerDSL.scala +++ b/backend/src/main/scala/utils/TriggerDSL.scala @@ -2,45 +2,147 @@ package utils import model.entities.customers._ +/** Object containing the Domain-Specific Language (DSL) for defining dynamic + * triggers. + * + * This DSL allows for the creation of clear, readable, and composable + * conditions used within the simulation's decision-making logic, such as in + * the DecisionManager. + */ object TriggerDSL: + /** Represents a generic trigger condition. + * + * A Trigger takes an entity context and evaluates to a boolean, indicating + * whether the condition is met. + * + * @tparam A + * The type of the entity (context) on which the trigger evaluates. + */ trait Trigger[A]: + /** Evaluates the trigger condition against the given entity context. + * @param c + * The entity context. + * @return + * True if the condition is met, false otherwise. + */ def eval(c: A): Boolean + /** Creates a trigger that evaluates to true if the entity's loss streak is + * greater than or equal to 'n'. + * + * This trigger is specific to entities using Martingale or OscarGrind + * betting strategies. + * + * @param n + * The minimum number of losses in a streak to trigger. + * @tparam A + * The entity type, which must have Bankroll, CustomerState, and + * HasBetStrategy capabilities. + * @return + * A Trigger instance. + */ def Losses[A <: Bankroll[A] & CustomerState[A] & HasBetStrategy[A]]( n: Int ): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = + override def eval(c: A): Boolean = c.betStrategy match case m: MartingaleStrat[A] => m.lossStreak >= n case o: OscarGrindStrat[A] => o.lossStreak >= n - case _ => false + case _ => false // Returns false for strategies without a lossStreak + /** Creates a trigger that evaluates to true if the entity's frustration level + * is greater than or equal to 'p'. + * @param p + * The frustration percentage threshold (0.0 - 100.0). + * @tparam A + * The entity type, which must have BoredomFrustration capabilities. + * @return + * A Trigger instance. + */ def FrustAbove[A <: BoredomFrustration[A]](p: Double): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = c.frustration >= p + override def eval(c: A): Boolean = c.frustration >= p + /** Creates a trigger that evaluates to true if the entity's boredom level is + * greater than or equal to 'p'. + * @param p + * The boredom percentage threshold (0.0 - 100.0). + * @tparam A + * The entity type, which must have BoredomFrustration capabilities. + * @return + * A Trigger instance. + */ def BoredomAbove[A <: BoredomFrustration[A]](p: Double): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = c.boredom >= p + override def eval(c: A): Boolean = c.boredom >= p + /** Creates a trigger that evaluates to true if the entity's bankroll ratio + * (current bankroll / starting bankroll) is greater than or equal to 'r'. + * This is typically used for "take-profit" conditions. + * @param r + * The bankroll ratio threshold. + * @tparam A + * The entity type, which must have Bankroll capabilities. + * @return + * A Trigger instance. + */ def BrRatioAbove[A <: Bankroll[A]](r: Double): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = c.bankrollRatio >= r + override def eval(c: A): Boolean = c.bankrollRatio >= r + /** Creates a trigger that evaluates to true if the entity's bankroll ratio + * (current bankroll / starting bankroll) is less than or equal to 'r'. This + * is typically used for "stop-loss" conditions. + * @param r + * The bankroll ratio threshold. + * @tparam A + * The entity type, which must have Bankroll capabilities. + * @return + * A Trigger instance. + */ def BrRatioBelow[A <: Bankroll[A]](r: Double): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = c.bankrollRatio <= r + override def eval(c: A): Boolean = c.bankrollRatio <= r + /** Creates a trigger that always evaluates to true. Useful for default + * actions or conditions that are always met. + * @tparam A + * The entity type (can be any type as evaluation is constant). + * @return + * A Trigger instance that always returns true. + */ def Always[A]: Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = true + override def eval(c: A): Boolean = true + /** Provides extension methods for composing Trigger instances using logical + * operators. These methods enable a fluent and intuitive syntax for building + * complex trigger conditions. + */ extension [A](a: Trigger[A]) + /** Composes two triggers with a logical AND operation. Both triggers must + * evaluate to true for the combined trigger to be true. + * @param b + * The right-hand side Trigger. + * @return + * A new Trigger representing the logical AND. + */ infix def &&(b: Trigger[A]): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = a.eval(c) && b.eval(c) + override def eval(c: A): Boolean = a.eval(c) && b.eval(c) + /** Composes two triggers with a logical OR operation. At least one trigger + * must evaluate to true for the combined trigger to be true. + * @param b + * The right-hand side Trigger. + * @return + * A new Trigger representing the logical OR. + */ infix def ||(b: Trigger[A]): Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = a.eval(c) || b.eval(c) + override def eval(c: A): Boolean = a.eval(c) || b.eval(c) + /** Negates the result of a trigger (logical NOT operation). + * @return + * A new Trigger representing the logical NOT. + */ def unary_! : Trigger[A] = new Trigger[A]: - def eval(c: A): Boolean = !a.eval(c) + override def eval(c: A): Boolean = !a.eval(c) diff --git a/docs/report.md b/docs/report.md index d99d8df..dae18a5 100644 --- a/docs/report.md +++ b/docs/report.md @@ -42,7 +42,7 @@ This approach allows for timely identification and correction of potential bugs The TDD development process consists of three main steps: - Red Phase (testing): a test is written to describe the expected behavior of a component or feature. Since the implementation is not yet in place, the test initially fails. - Green Phase (implementation): the component or feature is then implemented to ensure that the previously written test passes successfully. - -Refactor Phase: after the test passes, the code is refactored to improve its quality and readability, ensuring that the test continues to pass. +- Refactor Phase: after the test passes, the code is refactored to improve its quality and readability, ensuring that the test continues to pass. ## Requirement Specification ### Business requirements The application is intended to be used by the manager of a [casino](https://en.wikipedia.org/wiki/Casino) who wants to simulate the behaviour of customers inside a given configuration of the casino in order to predict the revenue of the facility. @@ -87,6 +87,7 @@ Wall --|> Obstacle #### User requirements ##### Customers +###### Movements The customers move around the casino according to a [boid](https://en.wikipedia.org/wiki/Boids)-like model. This modeling is taken by the first assigment of PCD course, which is available at [this repo](https://github.com/pcd-2024-2025/assignment-01). Customers are modeled by a `position` and a `velocity` and three rules are applied to them: - **Separation**: Customers try to maintain a minimum distance from each other ``` @@ -174,6 +175,20 @@ Other parameters that influence the boids behavior are: All of these parameters can be configured by the user in order to simulate different scenarios. When a customer is close to a game of its liking, that is the distance between the customer's and game's position is less than `SITTING_RADIUS`, the player sits and plays the game. While a customer is playing it does not move. +###### In-game behaviours +When playing the customers try to play at the best of their capabilities following predetermined strategy that reflect their personality. +The strategies used by the customer are well known bet scheme used in real casino, trying to emulate a real scenario, these strategies include: +- **Flat bet (or Percentage bet)**: An amount equal to a fixed percentage of their current bankroll is used to perform a bet. +- **Martingale (Negative Progression)**: This highly aggressive strategy aims to recover all previous losses and gain a profit equal to the initial stake with a single win. It operates by **doubling your bet after every loss**. Once a win occurs, you **return to your original base bet** and start the progression again. +- **Oscar grind (Positive progression)**: This strategy aims to achieve a profit of a **single bet amount** called **unit**, after which the sequence of bets (the "cycle") is considered complete and a new one begins. You start with a predefined **base betting unit**. After a **loss**, the next bet **remains unchanged**. After a **win**, the bet **increases by one unit**. However, if increasing the bet would cause the total cycle profit to **exceed the one-unit goal**, the bet is reduced to precisely reach that profit with the next win and this **concludes the cycle**. + +A strategies of those is chosen for the customer based on the game is playing and their personality that are grouped in 4 main categories: +- **VIP**: A special customer that's really rich, it doesn't bother to lose their money, but they get bored easily, this type of customer are paired with **entertaining strategy** like Martingale +- **Regular**: An average customer that comes regularly to the casino, it doesn't bring much money and can use various strategies. +- **Casual**: A beginner customer that rarely goes to the casino, it brings only a little amount of money and is not inclined to lose it, prefer basic strategy. +- **Impulsive**: A customer guided by emotion, it brings more money than he can afford and play aggressively to win big prize, he's determined to lose all of his money. + +These personalities, combined with other elements such as **emotional states** and how the current account balance compares to the initial starting balance, also influence the decision to switch between games or exit the casino. ##### Games The Games module manages the placement, configuration, and behavior of the games within the casino. @@ -386,73 +401,148 @@ Each event type triggers specific state transformation logic. The `SimulationState` serves as the context, while different events represent state transition triggers. The `Update` class acts as the state manager, coordinating transitions between different simulation phases. +#### Trait-Based Composition -#### Customer Composition +Trait-based composition is a fundamental design approach of our system, promoting **modularity**, **extensibility**, and robust **type safety**. Instead of relying on rigid inheritance hierarchies, we construct complex entities by combining discrete, reusable units of behavior defined as Scala traits. This strategy significantly enhances code adaptability and simplifies future modifications. -We choose to implement the `Customer` behavior using **F‑bounded polymorphic traits**. This choice brings some great feature enabling a **modular** and **extensible** design. +* **Modular Behavior Definition:** + * Each trait encapsulates a single, well-defined behavior or set of related functionalities (e.g., `Bankroll` for financial operations, `StatusProfile` for status management). + * This promotes a clear separation of concerns, making individual units easier to understand, test, and maintain in isolation. -```scala 3 -case class Customer( - id: String, - bankroll: Double, - customerState: CustState = Idle - ) extends Entity, - Bankroll[Customer], - CustomerState[Customer]: +* **Flexible Entity Construction:** + * Classes are built by "mixing in" multiple traits using Scala's multiple inheritance. + * This allows for dynamic assembly of functionalities, letting different entities selectively acquire the behaviors they need without sharing unnecessary inheritance chains. + * Adding new features to an existing class simply involves defining a new trait and mixing it in, minimizing changes to existing code (Open/Closed Principle). - protected def updatedBankroll(newRoll: Double): Customer = - this.copy(bankroll = newRoll) +* **Enhanced Code Reusability:** + * Traits defining common interfaces can be used across disparate parts of the system, fostering consistency and reducing code duplication. + * Generic interfaces or functions can be defined with **trait bounds** (e.g., `def update[T <: Movable[T] & Entity](customer: T)`), allowing them to operate on any object that implements the required traits, regardless of its specific class hierarchy. This maximizes code reuse by focusing on behavioral contracts. - protected def changedState(newState: CustState): Customer = - this.copy(customerState = newState) -``` -```scala 3 -trait Bankroll[T <: Bankroll[T]]: - val bankroll: Double - require( - bankroll >= 0, - s"Bankroll amount must be positive, instead is $bankroll" - ) +##### Trait-Based Composition Diagram +To visually illustrate our trait-based composition approach, consider the following UML class diagram. This diagram highlights how individual traits define discrete functionalities that are then mixed into concrete classes, demonstrating the flexibility and modularity of the design. - def updateBankroll(netValue: Double): T = - val newBankroll = bankroll + netValue - require( - newBankroll >= 0, - s"Bankroll amount must be positive, instead is $newBankroll" - ) - updatedBankroll(newBankroll) +```mermaid +classDiagram + direction TD - protected def updatedBankroll(newBankroll: Double): T -``` + %% Traits defining core capabilities + class Moveable { + <> + +position: Vector2D + +direction: Vector2D + +move(): void + } -The key strength of this design are: + class BettingStrategy { + <> + +strat: BetStrategy + +placeBet(): Bet + } -- **Strong type safety** - F‑bounded traits restrict generic parameters to subtypes of the trait itself, preventing accidental type error at compile time. + class Entity { + <> + +id: String + } -- **Precise APIs and seamless mvu updates** - By encoding the concrete subtype via `C <: Trait[C]`, trait methods can return `C` directly, enabling `.copy(...)` function in the Customer producing a new instance in a clean and optimize way. This avoids casts or losing type specificity in method returns making updating state easier. + %% Concrete Class implementing multiple traits (Mixin Composition) + class Customer { + } -```scala 3 -val newCustomer = Customer(id = "myCustomer", bankroll = 50.0) -val updatedCustomer = newCustomer.updateBankroll(-20.0) -// ad-hoc method for update that checks that bankroll don't go below zero + Customer ..|> Moveable : implements + Customer ..|> Entity : implements + Customer ..|> BettingStrategy : implements + + + %% Another possible Concrete Class with a different mixin set + class Waiter { + + } + Waiter ..|> Moveable : implements + Waiter ..|> Entity : implements + + + %% Generic Interface / Processor with Trait Bounds + class PositionManager { + <> + +updatePosition(entity: T <: Moveable & Entity): void + } + + PositionManager --o Customer : can process + PositionManager --o Waiter : can process + + ``` -- **Modular and extensible architecture** - Each behavior (e.g., bankroll, boredom, status) is isolated within its own trait. This allows introducing new behaviour without altering existing implementations by just defining the trait and mix it in. -```scala 3 -case class Customer( - id: String, - bankroll: Double, - customerState: CustState = Idle, - boredom: Double - ) extends Entity, - Bankroll[Customer], - CustomerState[Customer], - Boredom[Customer]: // Just adding a new behaviour to the Customer by composition +**Explanation of the Diagram:** + +* **Traits:** `Moveable`, `Entity` and `BettingStrategy` are represented as classes with the `<>` stereotype. These define isolated units of behavior that can be reused across different parts of the system. +* **Mixin Composition:** `Customer` and `Waiter` exemplify how concrete classes are formed by "mixing in" multiple traits (indicated by `..|>` arrows). This allows each class to inherit and combine distinct functionalities without a deep, rigid inheritance chain. +* **Trait-Bounded Generics:** The `PositionManager` interface illustrates how generic types (`T`) are constrained by specific traits (e.g., `T <: Moveable & Entity`). This ensures that `PositionManager` can operate only on objects that provide the required behaviors. The associations (`--o`) indicate potential interactions. + +#### Decision Tree + +For robust and auditable entity decision-making, we employ a **Decision Tree pattern**. This design choice provides a structured, hierarchical, and highly explicit mechanism for evaluating complex conditions and selecting appropriate actions. Unlike imperative `if-else` cascades, our design encapsulates the decision logic within a composable tree structure, promoting clarity and maintainability. + +The core design principle revolves around a `sealed trait DecisionTree[Ctx, Res]`, which serves as the base for all node types. This ensures all possible node variations are known at compile time, enhancing type safety and enabling exhaustive pattern matching when traversing the tree. The tree structure allows for a declarative definition of behaviors, significantly improving the readability and traceability of complex decision flows. + +The key strengths of this design are: + +* **Explicit Decision Flow:** The tree structure inherently visualizes the decision logic, making it exceptionally clear and easy to understand compared to nested conditional statements. Each branch and node represents a specific decision point or outcome. +* **High Modularity and Maintainability:** Each node type (`Leaf`, `DecisionNode`, `MultiNode`) is a self-contained component. This modularity allows for the easy addition, modification, or removal of decision paths by adjusting the tree structure. This significantly boosts maintainability and reduces the risk of introducing side effects. +* **Flexibility in Branching:** The design supports both binary (`DecisionNode`) and multi-way (`MultiNode`) branching, accommodating simple true/false conditions as well as discrete-valued decision points. + +By encapsulating decision logic within this structured tree, our system gains a transparent, adaptable, and robust mechanism for governing entity behaviors. + +#### Decision Tree Diagram + +This diagram illustrates the structure of our generic Decision Tree. + +```mermaid +classDiagram + direction TD + + class DecisionTree~Ctx, Res~ { + <> + +eval(ctx: Ctx): Res + } + + class Leaf~Ctx, Res~ { + +action: Ctx => Res + +eval(ctx: Ctx): Res + } + + class DecisionNode~Ctx, Res~ { + +predicate: Ctx => Boolean + +trueBranch: DecisionTree~Ctx, Res~ + +falseBranch: DecisionTree~Ctx, Res~ + +eval(ctx: Ctx): Res + } + + class MultiNode~Ctx, Key, Res~ { + +keyOf: Ctx => Key + +branches: Map + +default: DecisionTree~Ctx, Res~ + +eval(ctx: Ctx): Res + } + + DecisionTree <|-- Leaf : extends + DecisionTree <|-- DecisionNode : extends + DecisionTree <|-- MultiNode : extends + + DecisionNode --> DecisionTree : trueBranch + DecisionNode --> DecisionTree : falseBranch + MultiNode --> DecisionTree : branches + MultiNode --> DecisionTree : default ``` -By leveraging these traits composition system, our `Customer` model stays **type safe**, **cohesive**, and easy to evolve, supporting future expansion of behaviors and customer types without compromising the maintainability. +**Explanation of the Diagram:** + +* **`DecisionTree`:** This `sealed trait` forms the root of our decision tree hierarchy. It defines the common `eval` method that all decision tree nodes must implement, taking a context `Ctx` and returning a result `Res`. +* **`Leaf`:** Represents the terminal nodes of the tree. It "implements" `DecisionTree` and holds an `action` function that produces the final `Res` from the `Ctx`. +* **`DecisionNode`:** Represents binary branching points. It "implements" `DecisionTree` and contains a `predicate` function and two branches (`trueBranch`, `falseBranch`), both of type `DecisionTree`, illustrating the recursive nature of the tree structure. +* **`MultiNode`:** Represents multi-way branching. It also "implements" `DecisionTree` and uses a `keyOf` function to select a branch from a `Map` of `branches`, with a `default` branch for unmatched keys. This also shows a recursive composition. +* **Relationships:** + * `--|>` (Realization/Implements): Shows that `Leaf`, `DecisionNode`, and `MultiNode` are concrete implementations of the `DecisionTree` trait. + * `-->` (Association): Indicates that `DecisionNode` and `MultiNode` contain references to other `DecisionTree` instances, forming the tree structure. The labels `trueBranch`, `falseBranch`, `branches`, and `default` clarify the role of these associations. #### Customers spawner @@ -909,6 +999,436 @@ problem of UI-state desynchronization that plagues many web applications. ## Implementation ### Student contributions +### Galeri Marco +#### Customer Composition + +I choose to implement the `Customer` behavior using **F‑bounded polymorphic traits** composition. This choice brings some great feature enabling a **modular** and **extensible** design. The various implementation resides within the `model.entities.customers` package. + +```scala 3 +case class Customer( + id: String, + bankroll: Double, + customerState: CustState = Idle + ) extends Entity, + Bankroll[Customer], + CustomerState[Customer]: + + protected def updatedBankroll(newRoll: Double): Customer = + this.copy(bankroll = newRoll) + + protected def changedState(newState: CustState): Customer = + this.copy(customerState = newState) +``` +```scala 3 +trait Bankroll[T <: Bankroll[T]]: + val bankroll: Double + require( + bankroll >= 0, + s"Bankroll amount must be positive, instead is $bankroll" + ) + + def updateBankroll(netValue: Double): T = + val newBankroll = bankroll + netValue + require( + newBankroll >= 0, + s"Bankroll amount must be positive, instead is $newBankroll" + ) + updatedBankroll(newBankroll) + + protected def updatedBankroll(newBankroll: Double): T +``` + +The key strength of this design are: + +- **Strong type safety** + F‑bounded traits restrict generic parameters to subtypes of the trait itself, preventing accidental type error at compile time. + +- **Precise APIs and seamless mvu updates** + By encoding the concrete subtype via `C <: Trait[C]`, trait methods can return `C` directly, enabling `.copy(...)` function in the Customer producing a new instance in a clean and optimize way. This avoids casts or losing type specificity in method returns making updating state easier. + +```scala 3 +val newCustomer = Customer().withId("example-1") +val updatedCustomer = newCustomer.updateBankroll(-20.0) +// ad-hoc method for update that checks that bankroll don't go below zero +``` +- **Modular and extensible architecture** + Each behavior (e.g., bankroll, boredom, status) is isolated within its own trait. This allows introducing new behaviour without altering existing implementations by just defining the trait and mix it in. +```scala 3 +case class Customer( + id: String, + bankroll: Double, + customerState: CustState = Idle, + boredom: Double + ) extends Entity, + Bankroll[Customer], + CustomerState[Customer], + Boredom[Customer]: // Just adding a new behaviour to the Customer by composition + +``` +By leveraging these traits composition system, our `Customer` model stays **type safe**, **cohesive**, and easy to evolve, supporting future expansion of behaviors and customer types without compromising the maintainability. + +#### Betting Strategy + +I implemented this class for managing diverse casino betting strategies, enabling entities to place realistic and logically consistent bets across a variety of games. My primary goal with this implementation was to offer a **simple and convenient API** for generating sensible bets while enforcing **strong and realistic betting logic**. + +I achieved this through the previously discuss trait-based composition that defines the common contract for all betting behaviors, along with concrete implementations for specific strategies. + +##### Core Betting Strategy + +The foundation of my betting system is the `BettingStrategy` trait, which defines the common interface for all concrete betting strategies. + +```scala 3 +trait BettingStrategy[A <: Bankroll[A] & CustomerState[A]]: + val betAmount: Double + val option: List[Int] + // ... requirements and abstract methods + def betType: BetStratType + def placeBet(ctx: A): Bet + def updateAfter(ctx: A, result: Double): BettingStrategy[A] + protected def checkRequirement(ctx: A): Unit = + require( + betAmount <= ctx.bankroll, + s"Bet amount must be equal or less of the total bankroll, instead is $betAmount when the bankroll is ${ctx.bankroll}" + ) + require( + ctx.customerState != Idle, + "Bet should be placed only if the customer is playing a game" + ) +``` + +* **Generics and Type Bounds:** `BettingStrategy[A <: Bankroll[A] & CustomerState[A]]` ensures that any entity `A` using a betting strategy must at least possess a `Bankroll` and a `CustomerState`. +* **`betAmount` and `option`:** These define the current stake and any game-specific options (e.g., a roulette number). +* **`betType`:** An abstract method returning a `BetStratType` (e.g., `FlatBet`, `Martingale`, `OscarGrind`), allowing for runtime identification of the strategy. +* **`placeBet(ctx: A): Bet`:** This abstract method generates a game-specific `Bet` (e.g., `RouletteBet`, `SlotBet`). It takes the entity's current context (`ctx`) to inform the bet. +* **`updateAfter(ctx: A, result: Double): BettingStrategy[A]`:** This abstract method is crucial as allows step strategies (e.g., `Martingale`) to update their internal state based on the outcome of the previous bet (`result`). This is where the core logic of progression resides. +* **`checkRequirement(ctx: A)`:** A protected helper method I included that enforces essential preconditions, such as the bet amount not exceeding the bankroll and the customer being in a `Playing` state. This ensures robust and realistic betting behavior. + +##### `HasBetStrategy` Trait: Integrating Strategies with Entities + +The `HasBetStrategy` trait facilitates seamless integration of betting strategies directly into entities that require decision-making capabilities. + +```scala 3 +trait HasBetStrategy[T <: HasBetStrategy[T] & Bankroll[T] & CustomerState[T]]: + this: T => + val betStrategy: BettingStrategy[T] + + def placeBet(): Bet = betStrategy.placeBet(this) + + def updateAfter(result: Double): T = + withBetStrategy(betStrategy.updateAfter(this, result)) + + def changeBetStrategy(newStrat: BettingStrategy[T]): T = + withBetStrategy(newStrat) + + def withBetStrategy(newStrat: BettingStrategy[T]): T +``` + +* **Self-Type Annotation (`this: T =>`):** I used this to ensure that any class mixing in `HasBetStrategy` *is* itself of type `T`, enabling methods to return `this` (or copies of `this`) with the correct specific type. +* **`placeBet()`:** A convenient method that delegates to the underlying `betStrategy.placeBet()`, automatically passing the current entity instance as the context. +* **`updateAfter()`:** Delegates the result update to the strategy and then applies the updated strategy back to the entity using `withBetStrategy`, maintaining immutability. +* **`changeBetStrategy()`:** Allows for dynamic switching of betting strategies at runtime, promoting adaptability. +* **`withBetStrategy()`:** An abstract method that forces the concrete entity to provide a way to create a new instance with an updated betting strategy (using `copy()` for case classes), reinforcing immutability. + +##### Concrete Betting Strategy Implementations + +I provided several concrete implementations of `BettingStrategy`, each encapsulating a distinct betting logic: + +* **`FlatBetting[A]`:** + + * **Purpose:** Implements a flat betting strategy where the bet amount remains constant regardless of previous outcomes. + * **Implementation:** The `updateAfter` method simply returns `this` as the strategy's internal state doesn't change. The `placeBet` method dynamically creates a `Bet` type based on the `gameType` within the `Playing` state, supporting `SlotMachine`, `Roulette`, and `Blackjack`. + +* **`MartingaleStrat[A]`:** + + * **Purpose:** Implements the classic Martingale strategy, doubling the bet after a loss to recover previous losses. + * **Implementation:** It maintains `baseBet` and `lossStreak`. The `nextBet()` helper calculates the doubled bet. In `updateAfter`, I increment `lossStreak` and update `betAmount` if there's a loss, resetting them to `baseBet` if there's a win. + +* **`OscarGrindStrat[A]`:** + + * **Purpose:** Implements the Oscar's Grind strategy, aiming for a single unit profit per cycle by increasing bets after wins and keeping them constant after losses. + * **Implementation:** It tracks `baseBet`, `betAmount`, `startingBankroll`, and `lossStreak`. The `updateAfter` method contains the specific Oscar's Grind logic: resetting to `baseBet` and a new `startingBankroll` upon reaching a cycle profit, increasing the bet after a win (if not yet at profit goal), and maintaining the bet after a loss. + +Each strategy is implemented as an **immutable `case class`**, ensuring that `updateAfter` methods return new instances of the strategy with updated internal states, adhering to functional programming principles. Companion objects for each strategy provide convenient `apply` methods for easy instantiation with various parameters. + +This comprehensive set of traits and concrete classes provides a highly **modular**, **extensible**, and **type-safe** framework for integrating sophisticated betting behaviors into my simulation entities. + +#### Decision Tree + +The Decision Tree pattern is implemented using Scala's `sealed trait` and `case class` features, providing a type-safe and functional approach to defining decision logic. The core implementation resides within the `utils` package. + +The foundational element is the `sealed trait DecisionTree[Ctx, Res]`, which serves as an abstract base for all node types. This trait defines a single abstract method, `eval(ctx: Ctx): Res`, which is responsible for evaluating the tree (or subtree) from a given context (`Ctx`) to produce a result (`Res`). The `sealed` keyword ensures that all direct implementors of `DecisionTree` are known within the same compilation unit, enabling exhaustive pattern matching on tree structures, which is beneficial for compilers and static analysis. + +Concrete implementations of `DecisionTree` are provided by three `case class` types, each representing a specific kind of node: + +* **`Leaf[Ctx, Res]`**: + + * **Purpose:** Represents an endpoint in the decision path, where a final action is taken without further branching. + * **Implementation:** It holds an `action` of type `Ctx => Res`. Its `eval` method simply applies this `action` function to the provided context. + +* **`DecisionNode[Ctx, Res]`**: + + * **Purpose:** Represents a binary branching point, making a decision based on a boolean condition. + * **Implementation:** It contains a `predicate` of type `Ctx => Boolean`, along with two `DecisionTree[Ctx, Res]` instances: `trueBranch` and `falseBranch`. The `eval` method applies the `predicate` to the context; if `true`, it delegates evaluation to `trueBranch.eval(ctx)`, otherwise to `falseBranch.eval(ctx)`. This recursive structure allows for arbitrary depth in binary decisions. + +* **`MultiNode[Ctx, Key, Res]`**: + + * **Purpose:** Facilitates multi-way branching based on a key extracted from the context, similar to a `switch` or `match` statement. + * **Implementation:** It takes a `keyOf` function (`Ctx => Key`) to determine the branching key. A `branches` `Map[Key, DecisionTree[Ctx, Res]]` holds the various decision paths corresponding to different keys. A `default` `DecisionTree[Ctx, Res]` is provided to handle cases where the `keyOf` result does not have a matching entry in the `branches` map. The `eval` method retrieves the appropriate branch from the map (or uses the `default`) and delegates evaluation to it. This provides a clean way to manage discrete, categorical decisions. + +This functional and immutable implementation leverages Scala's strong type system and pattern matching capabilities to create a robust, readable, and highly maintainable decision-making component. The use of functions (`predicate`, `action`, `keyOf`) directly within the case classes allows for flexible and dynamic decision logic to be injected into the tree structure. + +#### DecisionManager + +My goal with the `DecisionManager` class is to provide entities within the simulation with the most accurate and complex decision-making capabilities possible, leading to a highly **realistic simulation**. Furthermore, I designed the system to be highly configurable, utilizing various tables for multipliers and default rules that can be easily modified and potentially personalized by an end-user in the future. + +The `DecisionManager` is a component that leverages the previously discussed **Decision Tree pattern** to process entity states and determine their next actions. It is instantiated with a list of available `Game`s in the simulation environment. + +```scala 3 +case class DecisionManager[ + A <: Bankroll[A] & BoredomFrustration[A] & CustomerState[A] & + HasBetStrategy[A] & Entity & StatusProfile +](games: List[Game]) + extends BaseManager[Seq[A]]: + private val gameList = games.map(_.gameType).distinct + // ... rest of the code +``` + +##### Configuration and Rule Management + +To achieve the desired flexibility and future customizability, I've incorporated several configuration elements: + +* **`ProfileModifiers`:** This object defines **multipliers and limits** associated with different `RiskProfile`s (e.g., `VIP`, `Regular`, `Casual`, `Impulsive`). These modifiers dynamically adjust thresholds for boredom, frustration, take-profit (TP), and stop-loss (SL) limits. This allows each customer type to react differently to their in-game experience, contributing significantly to simulation realism. + + ```scala 3 + private case class Limits(tp: Double, sl: Double) + private case class Modifiers(limits: Limits, bMod: Double, fMod: Double) + private object ProfileModifiers: + val modifiers: Map[RiskProfile, Modifiers] = Map( + RiskProfile.VIP -> Modifiers(Limits(tp = 3.0, sl = 0.3), 1.30, 0.80), + // ... other profiles + ) + ``` + +* **`SwitchRule`:** This `case class` defines a **single rule for switching betting strategies or games**. Each rule specifies the `RiskProfile`, current `GameType`, current `BetStratType`, a `Trigger` (a dynamic condition), the `nextStrategy`, and a `betPercentage` for the new strategy. These rules form the core logic for how customers adapt their play style. + +* **`DefaultConfig`:** I've centralized all predefined `SwitchRule`s within this object. This provides a clear, single source for the default behaviors. The `ConfigLoader` then loads these rules into `rulesByProfile`, a `Map` that groups rules by `RiskProfile` for efficient lookup during decision evaluation. This structure is designed to be easily externalized in the future, allowing end-users to customize rules without recompiling the core logic. + + ```scala 3 + object DefaultConfig: + val switchRules: List[SwitchRule] = List( + // VIP rules + SwitchRule(VIP, Blackjack, Martingale, Losses(3), OscarGrind, 0.05), + SwitchRule(VIP, SlotMachine, FlatBet, FrustAbove(50) || BrRatioBelow(0.5), FlatBet, 0.015), + // ... extensive list of rules for all profiles + ) + ``` + +#### Decision-Making Flow + +The central operation of the `DecisionManager` is the `update` method, which processes a sequence of customers and applies decision logic to each. + +```scala 3 +def update(customers: Seq[A]): Seq[A] = + val tree = buildDecisionTree // Build the decision tree for this cycle + customers.flatMap { c => + val mod = ProfileModifiers.modifiers(c.riskProfile) + val decision = tree.eval(c) // Evaluate customer's decision + + decision match + case ContinuePlaying() => + Some(updateInGameBehaviours(c, mod).updateBoredom(5.0 * mod.bMod)) + case StopPlaying() => + Some(c.changeState(Idle).updateFrustration(-20.0 * (2 - mod.fMod))) + // ... other decision outcomes + } +``` + +For each customer, the manager evaluates a dynamically constructed **Decision Tree** (`buildDecisionTree`) to determine the customer's next action (`CustomerDecision`). This `CustomerDecision` is then pattern-matched to trigger appropriate updates to the customer's state (e.g., bankroll, boredom, frustration, betting strategy, game played). This use of the Decision Tree pattern ensures that the decision-making process is explicit, traceable, and easily modifiable. + +#### Decision Tree Construction (`buildDecisionTree`) + +The `buildDecisionTree` method dynamically constructs the specific decision tree used by the `DecisionManager`. This tree guides the evaluation flow for each customer. + +```scala 3 +private def buildDecisionTree: DecisionTree[A, CustomerDecision] = + DecisionNode[A, CustomerDecision]( + predicate = _.isPlaying, // Is the customer currently playing a game? + trueBranch = gameNode, // If yes, proceed to game-specific logic + falseBranch = leaveStayNode // If no, decide whether to leave or stay + ) +``` + +This root node immediately branches based on whether a customer is currently playing a game or is idle. Subsequent private methods (`gameNode`, `profileNode`, `leaveStayNode`, `stopContinueNode`, `strategySwitchNode`) recursively define subtrees, progressively narrowing down the decision based on granular conditions. + +* **`gameNode`:** Checks if the customer is currently playing a game (i.e., if there was a recent round result for them). If not, they might `WaitForGame()`, waiting for the next round to come. +* **`profileNode`:** This is a `MultiNode` that branches based on the customer's `riskProfile`, dispatching to profile-specific decision subtrees (e.g., `stopContinueNode(VIP)`). This is where the personalized behavior based on `RiskProfile` comes into play. +* **`leaveStayNode`:** Decides whether an idle customer should `LeaveCasino()` or `Stay()` based on aggregate thresholds of boredom, frustration, and bankroll ratio (TP/SL limits). +* **`stopContinueNode`:** Within game-specific branches, this node decides if a customer should `StopPlaying()` (e.g., due to high boredom/frustration, hitting TP/SL limits, or insufficient funds for the next bet) or `ContinuePlaying()` (potentially with a strategy switch). +* **`strategySwitchNode`:** This `Leaf` node evaluates the `SwitchRule`s for the specific customer's profile, current game, and betting strategy. If a rule's `Trigger` evaluates to true, the customer's `BettingStrategy` is changed using `betDefiner`; otherwise, they `ContinuePlaying()` with their current strategy. This is a critical point for dynamic adaptation. + +##### Supporting Logic (`updateInGameBehaviours`, `getNewGameBet`, `betDefiner`) + +* **`updateInGameBehaviours(c: A, mod: Modifiers): A`:** This method is responsible for updating a customer's frustration and the betting strategy's internal state based on the *last round's outcome* in the game they played. It accesses the `Game` object to get `getLastRoundResult` and adjusts frustration based on money gain/loss and `bankrollRatio`, incorporating `ProfileModifiers` for realism. +* **`getNewGameBet(c: A): A`:** This function primarily handles scenarios where a customer just started a new game or needs an initial bet. It attempts to find a matching `SwitchRule` to determine the initial strategy and bet, falling back to a default `FlatBetting` if no specific rule applies. +* **`betDefiner(rule: SwitchRule, c: A): BettingStrategy[A]`:** This utility function instantiates the correct `BettingStrategy` (`FlatBetting`, `MartingaleStrat`, `OscarGrindStrat`) based on the `rule.nextStrategy` and the customer's current `bankroll` and `betStrategy.option`, ensuring consistent bet amounts as a percentage of bankroll. + +The `DecisionManager` effectively orchestrates complex customer behaviors by combining a flexible rule-based configuration system with a structured, traversable Decision Tree. This design choice provides a highly **accurate** and **realistic** simulation environment for entity decision-making. + +#### PostDecisionUpdater + +To maintain a **functional programming paradigm** and manage potential side effects, I designed the `PostDecisionUpdater` object. This component is responsible for updating the simulation environment—specifically the customers' positions, their favorite games, and the state of the casino games—*after* all customer decisions for a given simulation tick have been processed by the `DecisionManager`. This clear separation of concerns ensures that the decision-making process remains pure and stateless, while any necessary modifications to the environment are handled in a dedicated, controlled phase. + +The `PostDecisionUpdater` operates by comparing the state of customers and games *before* and *after* the `DecisionManager` has processed them, applying the necessary environmental changes based on these state transitions. + +##### Updating Customer Positions and Preferences + +The `updatePosition` method focuses on customers whose state has changed from `Playing` to `Idle` (i.e., those who decided to `StopPlaying`). + +```scala 3 +object PostDecisionUpdater: + def updatePosition[ + P <: MovableWithPrevious[P] & CustomerState[P] & + ChangingFavouriteGamePlayer[P] & Entity + ](before: Seq[P], post: Seq[P]): List[P] = + val (hasStopPlaying, unchangedState, remained) = + groupForChangeOfState[P](before, post) + + val changePosition = hasStopPlaying.map { case (oldP, newP) => + newP + .withPosition(oldP.previousPosition.get) + .withDirection(-newP.direction) + .withFavouriteGame( + Random.shuffle(gameTypesPresent.filter(_ != newP.favouriteGame)).head + ) + } + val unchanged = unchangedState.map(_._2) + changePosition ++ unchanged +``` + +* **Identifying State Changes:** I first use `groupForChangeOfState` to identify customers who have transitioned from `Playing` to `Idle`. +* **Repositioning:** For these customers, I update their position to their `previousPosition` (where they were before starting the game) and reverse their `direction`. This simulates them walking away from a game table. +* **Changing Favorite Game:** To add realism, customers who stop playing also get a **new random favorite game** that is different from their previous one. This encourages exploration within the casino environment. +* **Maintaining Other Customers:** Customers whose state did not change, or who left the casino entirely (handled by `DecisionManager` returning `None`), are filtered and maintained appropriately, ensuring only relevant updates occur. + +##### Updating Game States + +The `updateGames` method is responsible for modifying the state of the `Game` entities within the simulation. Specifically, it focuses on "unlocking" games that were previously occupied by customers who have now stopped playing them. + +```scala 3 + def updateGames[P <: CustomerState[P] & Entity]( + before: Seq[P], + post: Seq[P], + games: List[Game] + ): List[Game] = + val (hasStopPlaying, unchangedState, remained) = + groupForChangeOfState(before, post) + // ... logic to update games + val updatedGame = + gameToUnlock.map((g, c) => g.unlock(c.get.id)).map(r => r.option().get) + updatedGame ++ gameUnchanged.map((g, _) => g) +``` + +* **Identifying Freed Games:** Similar to customer updates, this method identifies which customers have stopped playing a game by comparing their `CustomerState` before and after decision processing. +* **Unlocking Games:** For each game associated with a customer who stopped playing, I invoke the game's `unlock` method, making it available for other customers to join. This ensures that the simulation accurately reflects the availability of casino resources. +* **Maintaining Other Games:** Games that were not affected by state changes (either still being played or already free) are passed through unchanged. + +##### Core Grouping Logic (`groupForChangeOfState`) + +Both `updatePosition` and `updateGames` rely on the private helper method `groupForChangeOfState`. + +```scala 3 + private def groupForChangeOfState[P <: CustomerState[P] & Entity]( + before: Seq[P], + post: Seq[P] + ): (List[(P, P)], List[(P, P)], Set[String]) = + val beforeMap = before.map(p => p.id -> p).toMap + val postMap = post.map(p => p.id -> p).toMap + val remained = beforeMap.keySet.intersect(postMap.keySet) + val (hasStopPlaying, unchangedState) = remained.toList + .map(id => (beforeMap(id), postMap(id))) + .partition { case (oldState, newState) => + oldState.isPlaying != newState.isPlaying + } + (hasStopPlaying, unchangedState, remained) +``` + +This method compares the "before" and "after" states of customers by their IDs. It then partitions the customers who are still present in the simulation into two groups: those whose playing state (`isPlaying`) has changed, and those whose state has remained unchanged. + +By isolating these environmental updates into the `PostDecisionUpdater`, I ensure that the `DecisionManager` remains focused solely on determining optimal actions, while side effects are handled declaratively and immutably at a later stage, enhancing the overall **functional integrity** and **maintainability** of the simulation. + +#### Trigger + +To simplify the already complex configuration of the `DecisionManager` and to enhance the readability of our decision rules, I implemented a small, domain-specific language (DSL) called **`TriggerDSL`**. This DSL provides a concise and expressive way to define dynamic conditions that drive decision-making within the simulation. The primary goal was to make the definition of triggers as intuitive as human-readable statements, directly reflecting the conditions they represent. + +The core of the `TriggerDSL` is the `Trigger[A]` trait: + +```scala 3 +object TriggerDSL: + trait Trigger[A]: + def eval(c: A): Boolean + // ... concrete triggers and combinators +``` + +This trait defines a single abstract method, `eval(c: A): Boolean`, which evaluates the trigger against an entity's context (`A`) and returns a boolean result. The generic type `A` is typically a customer entity, enabling context-specific evaluations. + +##### Concrete Trigger Implementations + +I provided several factory methods within the `TriggerDSL` object to create concrete `Trigger` instances: + +* **`Losses[A](n: Int)`:** + + * **Purpose:** Checks if a customer's current `lossStreak` (from betting strategies like `MartingaleStrat` or `OscarGrindStrat`) has reached or exceeded a specified number `n`. + * **Implementation:** It performs a type-safe pattern match on the `betStrategy` of the customer. If the strategy is a `MartingaleStrat` or `OscarGrindStrat`, it accesses their `lossStreak` property for evaluation. + + + + ```scala 3 + def Losses[A <: Bankroll[A] & CustomerState[A] & HasBetStrategy[A]]( + n: Int + ): Trigger[A] = new Trigger[A]: + def eval(c: A): Boolean = + c.betStrategy match + case m: MartingaleStrat[A] => m.lossStreak >= n + case o: OscarGrindStrat[A] => o.lossStreak >= n + case _ => false + ``` + +* **`FrustAbove[A](p: Double)`:** Checks if a customer's `frustration` level is above a given percentage `p`. + +* **`BoredomAbove[A](p: Double)`:** Checks if a customer's `boredom` level is above a given percentage `p`. + +* **`BrRatioAbove[A](r: Double)`:** Evaluates if a customer's `bankrollRatio` (current bankroll divided by starting bankroll) is above a ratio `r`. This is crucial for "take-profit" conditions. + +* **`BrRatioBelow[A](r: Double)`:** Determines if a customer's `bankrollRatio` is below a ratio `r`. This is used for "stop-loss" conditions. + +* **`Always[A]`:** A simple trigger that always evaluates to `true`. This is useful for default actions or conditions that always apply. + +These factory methods create anonymous class instances of `Trigger`, embedding the specific evaluation logic for each condition. + +#### Trigger Combinators (DSL Operators) + +To enable the construction of complex logical conditions, I extended the `Trigger[A]` trait with custom infix operators, transforming `TriggerDSL` into a powerful and intuitive composition tool: + +```scala 3 + extension [A](a: Trigger[A]) + infix def &&(b: Trigger[A]): Trigger[A] = new Trigger[A]: + def eval(c: A): Boolean = a.eval(c) && b.eval(c) + + infix def ||(b: Trigger[A]): Trigger[A] = new Trigger[A]: + def eval(c: A): Boolean = a.eval(c) || b.eval(c) + + def unary_! : Trigger[A] = new Trigger[A]: + def eval(c: A): Boolean = !a.eval(c) +``` + +* **`&&` (AND operator):** Allows chaining two triggers, where both must evaluate to `true` for the combined trigger to be `true`. +* **`||` (OR operator):** Allows chaining two triggers, where at least one must evaluate to `true` for the combined trigger to be `true`. +* **`unary_!` (NOT operator):** Inverts the result of a single trigger. + +These operators return new `Trigger` instances that encapsulate the combined logic, allowing for a fluent and expressive syntax in defining complex rules within the `DecisionManager`. For example, a rule can be defined as `FrustAbove(50) || BrRatioBelow(0.5)`, directly mimicking natural language. + +By implementing `TriggerDSL`, I significantly simplified the declaration of dynamic conditions within the `DecisionManager`'s configuration (`SwitchRule`s and decision tree nodes). This design choice dramatically improves the **readability**, **maintainability**, and **extensibility** of our simulation's decision logic. + ### Nicolò Ghignatti #### Result Having to deal with data which can have two states (win or loss for a bet, for example) can be quite annoying so, I've @@ -1263,7 +1783,7 @@ Game --> GameType Player --> GameType : favourite game ``` #### Manager composition -All movement managers are implemented independently from one another, to combine them a simple bash-like DSL is created: to combine two managers the `|` operator is used, similarly to what the `andThen` method does in Scala between two functions. This operator is also used to apply a manager to update the current state. In order to weight the contribution of each manager the trait `WeightedManager` is defined which supports the `*` operator. The `*` operator multiplies the contribution of the manager by a given weight. The following example shows how to obtain a manager which combines various components to obtain a boids-like movement: +All movement managers are implemented independently of one another, to combine them a simple bash-like DSL is created: to combine two managers the `|` operator is used, similarly to what the `andThen` method does in Scala between two functions. This operator is also used to apply a manager to update the current state. In order to weight the contribution of each manager the trait `WeightedManager` is defined which supports the `*` operator. The `*` operator multiplies the contribution of the manager by a given weight. The following example shows how to obtain a manager which combines various components to obtain a boids-like movement: ```scala 3 val boidManager : BaseManager[SimulationState] = BoidsAdapter( PerceptionLimiterManager(perceptionRadius) @@ -1305,3 +1825,7 @@ The choice of this testing technology has different pros: ### Development progress, backlog, iterations ### Final comments +#### Galeri Marco +It was a great experience to engaging a challenge of this size after all the knowledge and consciousness acquired on the software development domain. +Even if not very accurate the simulation of a SCRUM methodology was interesting and at first defining and estimating the task correctly was quite tricky. +I also think that the team work we carried out, even though we had different strengths, was excellent and let us deliver the work on time without any additional effort and with a great quality.