diff --git a/.claude/docs/hanabi-rules.md b/.claude/docs/hanabi-rules.md new file mode 100644 index 0000000..894126c --- /dev/null +++ b/.claude/docs/hanabi-rules.md @@ -0,0 +1,274 @@ +# Hanabi — Complete Rules + +Hanabi is a cooperative card game by Antoine Bauza. Players work together as a team to build five +firework "stacks" (one per color) in ascending order. The defining twist: **you cannot see your own +cards** — you hold them facing outward, so everyone sees your hand except you. Information is shared +only through a limited supply of hints. + +This document describes the standard base game plus the official optional rules. + +--- + +## 1. Components + +### Cards (50 standard + 10 rainbow) + +- **5 colors (suits):** White, Red, Blue, Yellow, Green. +- **Each color has 10 cards** with the values: `1 1 1 2 2 3 3 4 4 5`. + - Three **1s** + - Two each of **2s, 3s, 4s** + - One **5** +- **Rainbow color:** a 6th suit of 10 cards, also distributed `1 1 1 2 2 3 3 4 4 5`. + Used only in advanced/variant play (see §8). + +So the base deck is **50 cards** (5 colors × 10). With the rainbow suit fully added it is 60 cards. + +### Tokens + +- **8 Clue (hint/note) tokens** — typically blue. Spent to give hints, recovered by other actions. +- **3 Fuse (storm/strike) tokens** — typically red/black. Lost when a card is misplayed. Losing all + 3 ends the game immediately in failure. + +These are usually stored on a small lid/board: clue tokens start **all available (8)**, fuse tokens +start **all intact (3)**. + +--- + +## 2. Goal + +All players cooperate to play cards onto color stacks so that each color forms an ascending run +`1 → 2 → 3 → 4 → 5`. A perfect game scores **25 points** (five complete stacks of five colors). + +There are no teams within the group and no individual winner — the whole table wins or loses together. + +--- + +## 3. Setup + +1. Choose colors in play (5 colors for the base game). +2. Shuffle all cards into a single face-down **draw deck**. +3. Place all **8 clue tokens** and all **3 fuse tokens** in their available state. +4. Deal a hand to each player based on player count: + - **2 or 3 players → 5 cards each** + - **4 or 5 players → 4 cards each** +5. **Critical rule:** players hold their hands so the **backs face themselves** and the **faces point + outward**. You never look at the front of your own cards. You can (and should) see every other + player's cards. +6. Pick a starting player (e.g., the most colorful-looking person, per the rulebook's suggestion). + Play proceeds clockwise. + +The remaining undealt cards form the face-down draw deck within reach of all players. + +--- + +## 4. The Firework Stacks + +The play area in the middle holds up to five firework stacks, one per color. A stack is built in +strict ascending order: + +- A color's **1** must be played before its **2**, the **2** before its **3**, and so on up to **5**. +- Only one card of each rank can be on a stack (the duplicates exist as backups against discards). +- A completed stack runs `1-2-3-4-5` and is worth 5 points. + +--- + +## 5. A Turn + +On your turn you **must perform exactly one** of the following three actions. You may **not** pass, +and you may not take more than one action. + +> Important: You may **never** give information about your own hand, and players may not give hints +> outside the formal hint action. + +### Action A — Give a Hint (costs 1 clue token) + +- Only allowed if **at least one clue token is available**. If all 8 have been spent, you cannot + choose this action. +- Spend one clue token (move it to the "used" side). +- Choose **one other player** and tell them about **either one color OR one rank** in their hand. +- You must point to **every** card matching that color or rank, and the hint must be **complete and + truthful**: + - **Color hint:** "These two cards are Red" — indicate all Red cards. + - **Rank hint:** "These three cards are 1s" — indicate all 1s. +- **Negative information is implied:** if you say "these are your 1s," all other cards are known not + to be 1s. +- **Empty (negative) hints are allowed** in this project's ruleset. You may give a hint about a color + or rank that matches **zero** cards in the target's hand ("you have no Red cards", "you have no + 4s"). It still costs a clue token and is a complete, truthful statement about the whole hand. +- You may not give a hint that combines color and rank (e.g., not "your red 1"). One attribute only. + +### Action B — Discard a Card (recovers 1 clue token) + +- Only allowed if **at least one clue token has been used** (i.e., not all 8 are available). You + cannot discard when all clue tokens are already in the pool. +- Choose a card from your hand (without looking at it) and place it **face up** on the discard pile. +- **Recover one clue token** (return it to the available pool). +- Draw a new card from the deck and add it to your hand, keeping it facing outward without looking. + +The discard pile is **public** — anyone may look through it at any time. This matters for tracking +which critical cards remain. + +### Action C — Play a Card (attempt to add to a stack) + +- Choose a card from your hand (without looking) and play it face up to the table. +- **If it legally continues a stack** (it's the next ascending card of its color, or a `1` starting a + new color stack), it is added to that color's firework. Success. + - **Bonus:** completing a stack by playing its **5** recovers **one clue token** (if any have been + used; if the pool is already full, no token is gained — it is not stored). +- **If it cannot be legally played** (wrong rank for that color, duplicate, or no valid position): + - It is a **misplay**. Discard the card to the discard pile and **lose one fuse token**. + - You do **not** recover a clue token for a misplay. +- Either way, draw a new card from the deck to refill your hand (if cards remain). + +--- + +## 6. Drawing & the Deck + +- After discarding or playing, you draw back up to your hand size from the deck. +- If the **deck is empty**, you simply do not draw; you continue playing with a smaller hand. +- You never reveal a drawn card to yourself — slot it into your hand facing outward. + +--- + +## 7. End of the Game + +The game can end in three ways: + +1. **Failure (immediate loss):** the **third fuse token is lost** (three misplays). The fireworks + blow up. The game ends instantly with a score of **0** in the strict official rule. (Many groups + instead score the stacks as-is — see the scoring note below.) +2. **Last round triggered by the deck running out:** when the **last card is drawn** from the deck, + each player — **including the player who drew the last card** — takes **one final turn**, then the + game ends. (Equivalently: the game ends after every player has had one more turn once the deck is + empty.) +3. **Perfect game:** all five stacks reach **5** (score 25). The game ends immediately in a perfect + victory. + +> Official scoring on a blown game: the rulebook states the game is lost and scores 0. A widely used +> house variant scores the completed cards even after three strikes. Pick one and be consistent. + +--- + +## 8. Scoring + +At game end, sum the **top card value of each color stack** (i.e., the highest card successfully +played in each color). Maximum is **25** (5 colors × 5). + +The rulebook provides this evaluation scale (the "fireworks display" rating): + +| Score | Rating | +|------:|--------------------------------------------------| +| 0–5 | Oh dear! The crowd booed. | +| 6–10 | Poor — only just managed to please the crowd. | +| 11–15 | Honourable attempt. | +| 16–20 | Excellent! The crowd is delighted. | +| 21–24 | Amazing! The crowd will remember this for years. | +| 25 | Legendary! A perfect, unforgettable display. | + +--- + +## 9. Official Expansion Modules (DLC) + +These are the three official mini-expansions bundled with **Hanabi Deluxe / Grands Feux**. Each can be +added independently, and they can be **combined** with each other (combine Flamboyant Fireworks with Black +Gunpowder and/or Colour Avalanche; all three at once for the bravest groups). Each adds its component +cards into the **single shuffled draw deck** — they are not separate decks. + +### 9.1 Colour Avalanche (Rainbow suit) + +- **Setup:** add a sixth **rainbow** suit to the deck and build a **sixth firework series** alongside + the others in ascending order `1 → 5`. Maximum score rises to **30**. Rank hints always work + normally; the rainbow suit never substitutes for cards of another series. +- **Scoring scale (max 30):** 25–29 = "Legendary"; 30 = "Sublime! Heaven itself is amazed." + +There are three rainbow modes — one differs in *how rainbow is hinted*, and the others in *suit size*: + +- **Wildcard (official Colour Avalanche):** rainbow is a **wildcard color — it counts as every + color**, so **every** color hint touches a rainbow card (a "Red" hint points at reds *and* + rainbows). You may **not** name "rainbow" as a hint, since it is all colors at once. Full 10-card + suit `1 1 1 2 2 3 3 4 4 5`. +- **Sixth color, long:** rainbow is its **own** sixth color — touched **only** by a "rainbow" hint, + never by another color, and the standard colors never touch it. Full 10-card suit. +- **Sixth color, short:** same own-color hinting, but a **short 5-card suit** `1 2 3 4 5` (one of each, + no duplicates). + +> **Which is harder:** **sixth-color short** is the most demanding — with no duplicate rainbow cards, +> every one is critical, so a single misplay or wasted discard of a rainbow card makes 30 unreachable. +> Among the 10-card modes, **wildcard** is harder to *read* than **sixth-color long**: because a +> wildcard rainbow is touched by every color hint, you can't cleanly single it out, whereas the +> sixth-color mode lets you clue rainbows directly. + +### 9.2 Black Gunpowder (Black Powder) + +- **Setup:** add the **10 black cards** to the deck. Because black is built in reverse, its + distribution is **mirrored**: `5 5 5 4 4 3 3 2 2 1` (three `5`s, two each of `4/3/2`, a single `1`). +- Build a **sixth series** in black, but in **reverse / descending order — `5 → 4 → 3 → 2 → 1`**. The + black **5** is the starter (three copies exist), and the series is completed by playing the lone + **1**, which is the single critical card. +- Completing the black series (with the `1`) recovers a **clue token**, like completing any other `5`. +- **Hints:** black cards are **colorless**. You may **never** give color information about them, and + you may **not** say "you have no black cards." Only **value** hints may reference them. +- **Scoring (max stays 25):** black does **not** add to the score like other colors. Instead, + **subtract 1 point** from your final score for **each card missing** from the black series. You also + earn the **"Black Powder Award"** flavor accolade regardless of completion. + - *Example:* if the black display ends at `5, 4, 3` (the `2` and `1` are missing), subtract 2 points + — final score 16 with the Black Powder Award. + +### 9.3 Flamboyant Fireworks + +- **Setup:** shuffle the **6 BONUS cards** face down beside the deck (no extra suit is added). +- **Trigger:** when you complete a series by successfully playing a **5**, *instead* of recovering a + clue token, flip **one random BONUS card** face up and apply its effect. Then discard that BONUS card + and draw a card to refill your hand. +- The **6 bonus effects** are: + 1. **Gain 1 clue token.** + 2. **Repair an error + gain 1 clue token** — flip one red ERROR/fuse token back to its blue (intact) + side *and* gain 1 clue token. If no error has been made, you simply gain 1 clue token. + 3. **Free color hint** — immediately give a color hint to any player of your choice, **without + spending a clue token** (one piece of information). + 4. **Free value hint** — immediately give a rank/value hint to any player, **without spending a clue + token** (one piece of information). + 5. **Recover a discard into the deck** — take one card from the discard pile and shuffle it back into + the draw deck. *Special case:* if the deck is empty, apply effect #6 instead. + 6. **Recover a discard and play it** — take one card from the discard pile and play it directly onto + a series. It must be a legal play (e.g., a red 3 only after the red 2); if it doesn't fit, the + bonus is lost. *Special case:* if the recovered card is a `5` that completes a series, that + completion **triggers another bonus**. + +### 9.4 Player count + +Hanabi plays 2–5. With 2–3 players the larger 5-card hands give more information per player. + +--- + +## 10. Key Constraints & Etiquette + +- **No looking at your own cards, ever**, until they are played or discarded. +- **No table talk / coded signals.** The only legal channel of information is the formal hint action. + You may not hint with tone of voice, timing, gestures, or by commenting on your own hand. +- You **may** discuss general strategy and the public game state (discard pile, stacks, tokens, whose + turn it is) — but never reveal hidden information about your own hand or use side-channels. +- **Track the discards.** Because each color has a fixed multiset (`1 1 1 2 2 3 3 4 4 5`), discarding + both copies of a needed rank — or the lone `5` — can make a perfect score impossible. +- **Clue economy is central:** hints are scarce (8 max). Discarding and completing stacks are the only + ways to regain clue tokens, so the team must balance giving information vs. preserving it. + +--- + +## 11. Quick Reference + +| Item | Value | +|------------------------|----------------------------------------------------------------------| +| Colors (base) | 5 (White, Red, Blue, Yellow, Green) | +| Cards per color | 10 → `1 1 1 2 2 3 3 4 4 5` | +| Total cards (base) | 50 | +| Hand size | 5 (2–3 players) / 4 (4–5 players) | +| Clue tokens | 8 | +| Fuse tokens | 3 | +| Actions per turn | exactly 1: Hint / Discard / Play | +| Empty (negative) hints | allowed (enabled in this ruleset) | +| Clue token recovered | on discard, and on completing a `5` | +| Fuse lost | on each misplay; 3 lost = game over | +| Max score | 25 (base) / 30 (with Colour Avalanche; Black Gunpowder keeps max 25) | +| Official DLC | Colour Avalanche · Black Gunpowder · Flamboyant Fireworks | +| End triggers | 3 fuses lost · deck empty + 1 final round each · perfect 25 | diff --git a/src/main/java/com/icube/sim/tichu/games/common/domain/GameBuilder.java b/src/main/java/com/icube/sim/tichu/games/common/domain/GameBuilder.java index 2eea189..3ddc66a 100644 --- a/src/main/java/com/icube/sim/tichu/games/common/domain/GameBuilder.java +++ b/src/main/java/com/icube/sim/tichu/games/common/domain/GameBuilder.java @@ -1,5 +1,7 @@ package com.icube.sim.tichu.games.common.domain; +import com.icube.sim.tichu.games.hanabi.Hanabi; +import com.icube.sim.tichu.games.hanabi.HanabiRule; import com.icube.sim.tichu.games.tichu.Tichu; import com.icube.sim.tichu.games.tichu.TichuRule; import com.icube.sim.tichu.games.common.exceptions.InvalidMemberCountException; @@ -15,6 +17,7 @@ public static Game build(GameName gameName, GameRule gameRule, Map return switch (gameName) { case TICHU -> new Tichu((TichuRule) gameRule, members); + case HANABI -> new Hanabi((HanabiRule) gameRule, members); }; } } diff --git a/src/main/java/com/icube/sim/tichu/games/common/domain/GameName.java b/src/main/java/com/icube/sim/tichu/games/common/domain/GameName.java index f3c7ff4..7800e4e 100644 --- a/src/main/java/com/icube/sim/tichu/games/common/domain/GameName.java +++ b/src/main/java/com/icube/sim/tichu/games/common/domain/GameName.java @@ -2,4 +2,5 @@ public enum GameName { TICHU, + HANABI, } diff --git a/src/main/java/com/icube/sim/tichu/games/common/domain/GameRuleWrapper.java b/src/main/java/com/icube/sim/tichu/games/common/domain/GameRuleWrapper.java index 9ab8d35..d660bfe 100644 --- a/src/main/java/com/icube/sim/tichu/games/common/domain/GameRuleWrapper.java +++ b/src/main/java/com/icube/sim/tichu/games/common/domain/GameRuleWrapper.java @@ -2,6 +2,7 @@ import com.icube.sim.tichu.games.common.exceptions.ImmutableGameRuleException; import com.icube.sim.tichu.games.common.exceptions.InvalidGameRuleException; +import com.icube.sim.tichu.games.hanabi.HanabiRule; import com.icube.sim.tichu.games.tichu.TichuRule; import lombok.Getter; import lombok.Setter; @@ -20,6 +21,7 @@ private GameRuleWrapper(GameRule gameRule) { public static GameRuleWrapper of(GameName gameName) { return switch (gameName) { case TICHU -> new GameRuleWrapper(TichuRule.createDefault()); + case HANABI -> new GameRuleWrapper(HanabiRule.createDefault()); }; } diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/BonusEffect.java b/src/main/java/com/icube/sim/tichu/games/hanabi/BonusEffect.java new file mode 100644 index 0000000..1b2cb41 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/BonusEffect.java @@ -0,0 +1,26 @@ +package com.icube.sim.tichu.games.hanabi; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * The six bonus cards of the Flamboyant Fireworks expansion. One of each is shuffled into the bonus deck; + * completing a firework draws one and applies it instead of recovering a clue token. + */ +public enum BonusEffect { + GAIN_CLUE, + REPAIR_AND_CLUE, + FREE_COLOR_HINT, + FREE_VALUE_HINT, + RECOVER_TO_DECK, + RECOVER_TO_PLAY, + ; + + public static List shuffledDeck() { + var deck = new ArrayList<>(Arrays.asList(values())); + Collections.shuffle(deck); + return deck; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/Hanabi.java b/src/main/java/com/icube/sim/tichu/games/hanabi/Hanabi.java new file mode 100644 index 0000000..27a89ba --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/Hanabi.java @@ -0,0 +1,528 @@ +package com.icube.sim.tichu.games.hanabi; + +import com.icube.sim.tichu.games.common.domain.AbstractGame; +import com.icube.sim.tichu.games.hanabi.cards.Clue; +import com.icube.sim.tichu.games.hanabi.cards.ColorClue; +import com.icube.sim.tichu.games.hanabi.cards.ValueClue; +import com.icube.sim.tichu.games.hanabi.cards.Firework; +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import com.icube.sim.tichu.games.hanabi.cards.HanabiDeck; +import com.icube.sim.tichu.games.hanabi.cards.HeldCard; +import com.icube.sim.tichu.games.hanabi.events.*; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidActionException; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidHintException; +import com.icube.sim.tichu.games.hanabi.exceptions.MaxClueTokensException; +import com.icube.sim.tichu.games.hanabi.exceptions.NoClueTokensException; +import com.icube.sim.tichu.games.hanabi.exceptions.NotYourTurnException; +import com.icube.sim.tichu.rooms.Member; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.util.*; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +public class Hanabi extends AbstractGame { + private static final int MAX_CLUE_TOKENS = 8; + private static final int MAX_FUSE_TOKENS = 3; + + private final Lock lock = new ReentrantLock(); + @Getter + private final HanabiRule rule; + private final HanabiPlayer[] players; + private final Map playerIndexById; + private final Deque deck; + private final Map fireworks; + private final List discardPile; + private final Deque bonusDeck; + + @Getter + private int clueTokens = MAX_CLUE_TOKENS; + @Getter + private int fuseTokens = MAX_FUSE_TOKENS; + @Getter + private int currentTurnIndex = 0; + @Getter + @Nullable + private Integer finalTurnsRemaining = null; + @Getter + private boolean ended = false; + @Getter + private boolean exploded = false; + @Getter + @Nullable + private PendingBonus pendingBonus = null; + + public Hanabi(HanabiRule rule, Map members) { + super(new HanabiStartEvent()); + this.rule = rule; + this.players = initPlayers(members); + + var indexById = new HashMap(); + for (var i = 0; i < players.length; i++) { + indexById.put(players[i].getId(), i); + } + this.playerIndexById = Map.copyOf(indexById); + + this.deck = new ArrayDeque<>(HanabiDeck.build(rule)); + this.fireworks = new LinkedHashMap<>(); + for (var color : HanabiDeck.activeColors(rule)) { + fireworks.put(color, new Firework(color)); + } + this.discardPile = new ArrayList<>(); + this.bonusDeck = rule.isBonusEnabled() + ? new ArrayDeque<>(BonusEffect.shuffledDeck()) + : new ArrayDeque<>(); + + deal(); + } + + private static HanabiPlayer[] initPlayers(Map members) { + var shuffledMembers = new ArrayList<>(members.values()); + Collections.shuffle(shuffledMembers); + + return shuffledMembers.stream() + .map(HanabiPlayer::new) + .toArray(HanabiPlayer[]::new); + } + + private void deal() { + var handSize = rule.handSize(players.length); + for (var i = 0; i < handSize; i++) { + for (var player : players) { + player.add(new HeldCard(deck.removeFirst())); + } + } + } + + public void lock() { + lock.lock(); + } + + public void unlock() { + lock.unlock(); + } + + // ----- Actions ----- + + public void giveHint(long fromId, long targetId, Clue clue) { + requireTurn(fromId); + if (clueTokens <= 0) { + throw new NoClueTokensException(); + } + if (!playerIndexById.containsKey(targetId) || targetId == fromId) { + throw new InvalidActionException(); + } + validateClue(clue, rule); + + clueTokens--; + var matchedIndexes = applyClueToHand(targetId, clue); + + addEvent(new HanabiHintEvent(fromId, targetId, clue, matchedIndexes)); + advanceTurn(); + } + + public void discard(long playerId, int index) { + requireTurn(playerId); + if (clueTokens >= MAX_CLUE_TOKENS) { + throw new MaxClueTokensException(); + } + var player = currentPlayer(); + validateIndex(player, index); + + var held = player.removeCardAt(index); + var discarded = held.getCard(); + discardPile.add(discarded); + clueTokens++; + var drawn = drawTo(player); + + addEvent(new HanabiDiscardEvent(playerId, index, discarded, drawn)); + advanceTurn(); + } + + public void play(long playerId, int index) { + requireTurn(playerId); + var player = currentPlayer(); + validateIndex(player, index); + + var held = player.removeCardAt(index); + var card = held.getCard(); + var firework = fireworks.get(card.color()); + + boolean success; + boolean fireworkCompleted; + BonusEffect bonusEffect = null; + HanabiCard drawnCard = null; + if (firework != null && firework.canPlay(card)) { + success = true; + firework.play(card); + fireworkCompleted = firework.isComplete(); + if (fireworkCompleted) { + bonusEffect = onFireworkComplete(playerId); + } + } else { + success = false; + fireworkCompleted = false; + discardPile.add(card); + fuseTokens--; + } + + // Draw only if bonus is not pending. + if (pendingBonus == null) { + drawnCard = drawTo(player); + } + + addEvent(new HanabiPlayEvent(playerId, index, card, success, fireworkCompleted, bonusEffect, drawnCard)); + + if (fuseTokens <= 0) { + explode(); + return; + } + concludeTurn(); + } + + public void resolveBonus( + long playerId, + @Nullable Long targetId, + @Nullable HanabiColor color, + @Nullable Integer value, + @Nullable Integer discardIndex + ) { + if (ended || pendingBonus == null) { + throw new InvalidActionException(); + } + if (pendingBonus.playerId() != playerId) { + throw new NotYourTurnException(); + } + + switch (pendingBonus.effect()) { + case FREE_COLOR_HINT -> { + if (targetId == null || color == null) { + throw new InvalidActionException(); + } + applyFreeHint(playerId, targetId, Clue.color(color)); + } + case FREE_VALUE_HINT -> { + if (targetId == null || value == null) { + throw new InvalidActionException(); + } + applyFreeHint(playerId, targetId, Clue.value(value)); + } + case RECOVER_TO_DECK -> { + if (discardIndex == null) { + throw new InvalidActionException(); + } + if (!deck.isEmpty()) { + recoverToDeck(playerId, discardIndex); + } else { + recoverToPlay(playerId, discardIndex); + } + } + case RECOVER_TO_PLAY -> { + if (discardIndex == null) { + throw new InvalidActionException(); + } + recoverToPlay(playerId, discardIndex); + } + default -> throw new InvalidActionException(); + } + } + + // ----- Bonus handling ----- + + @Nullable + private BonusEffect onFireworkComplete(long completerId) { + if (rule.isBonusEnabled() && !bonusDeck.isEmpty()) { + var effect = bonusDeck.removeFirst(); + applyBonus(effect, completerId); + return effect; + } else { + gainClue(); + return null; + } + } + + private void applyBonus(BonusEffect effect, long completerId) { + switch (effect) { + // For bonuses below, apply a bonus immediately. + case GAIN_CLUE -> gainClue(); + case REPAIR_AND_CLUE -> { + if (fuseTokens < MAX_FUSE_TOKENS) { + fuseTokens++; + } + gainClue(); + } + // For bonuses below, pend a bonus and wait a user to resolve. + case FREE_COLOR_HINT, FREE_VALUE_HINT -> pendingBonus = new PendingBonus(effect, completerId); + case RECOVER_TO_DECK -> { + // Skip the bonus if no discard exists. + if (discardPile.isEmpty()) { + return; + } + // Recover-to-play has to be done if the deck is empty. + // Skip it if no discard is playable. + if (deck.isEmpty() && noDiscardIsPlayable()) { + return; + } + pendingBonus = new PendingBonus(effect, completerId); + } + case RECOVER_TO_PLAY -> { + // Skip the bonus if no discard is playable. + if (discardPile.isEmpty() || noDiscardIsPlayable()) { + return; + } + pendingBonus = new PendingBonus(effect, completerId); + } + } + } + + private void applyFreeHint(long fromId, long targetId, Clue clue) { + assert pendingBonus != null; + assert pendingBonus.effect() == BonusEffect.FREE_COLOR_HINT + || pendingBonus.effect() == BonusEffect.FREE_VALUE_HINT; + + if (!playerIndexById.containsKey(targetId) || targetId == fromId) { + throw new InvalidActionException(); + } + validateClue(clue, rule); + + var matchedIndexes = applyClueToHand(targetId, clue); + pendingBonus = null; + var drawnCard = drawTo(playerById(fromId)); + + addEvent(new HanabiResolveFreeHintEvent(fromId, targetId, clue, matchedIndexes, drawnCard)); + + advanceTurn(); + } + + private void recoverToDeck(long playerId, int index) { + assert pendingBonus != null && pendingBonus.effect() == BonusEffect.RECOVER_TO_DECK; + + if (index < 0 || index >= discardPile.size()) { + throw new InvalidActionException(); + } + + var card = discardPile.remove(index); + var recoveredDeck = new ArrayList<>(deck); + recoveredDeck.add(card); + Collections.shuffle(recoveredDeck); + deck.clear(); + deck.addAll(recoveredDeck); + + pendingBonus = null; + var drawnCard = drawTo(playerById(playerId)); + + addEvent(new HanabiResolveRecoverToDeckEvent(playerId, card, drawnCard)); + + advanceTurn(); + } + + private void recoverToPlay(long playerId, int index) { + assert pendingBonus != null; + assert pendingBonus.effect() == BonusEffect.RECOVER_TO_PLAY + || (pendingBonus.effect() == BonusEffect.RECOVER_TO_DECK && deck.isEmpty()); + + if (index < 0 || index >= discardPile.size()) { + throw new InvalidActionException(); + } + var card = discardPile.get(index); + var firework = fireworks.get(card.color()); + if (firework == null || !firework.canPlay(card)) { + throw new InvalidActionException(); + } + + discardPile.remove(index); + firework.play(card); + pendingBonus = null; + + BonusEffect bonusEffect = null; + HanabiCard drawnCard = null; + boolean fireworkCompleted = firework.isComplete(); + if (fireworkCompleted) { + bonusEffect = onFireworkComplete(playerId); + } + + // Draw only if bonus is not pending. + if (pendingBonus == null) { + drawnCard = drawTo(playerById(playerId)); + } + + addEvent(new HanabiResolveRecoverToPlayEvent(playerId, index, card, fireworkCompleted, bonusEffect, drawnCard)); + + concludeTurn(); + } + + private boolean noDiscardIsPlayable() { + for (var card : discardPile) { + var firework = fireworks.get(card.color()); + if (firework != null && firework.canPlay(card)) { + return false; + } + } + return true; + } + + // ----- Helpers ----- + + private void gainClue() { + if (clueTokens < MAX_CLUE_TOKENS) { + clueTokens++; + } + } + + @Nullable + private HanabiCard drawTo(HanabiPlayer player) { + if (deck.isEmpty()) { + return null; + } + var card = deck.removeFirst(); + player.add(new HeldCard(card)); + return card; + } + + private List applyClueToHand(long targetId, Clue clue) { + var matchedIndexes = new ArrayList(); + + var hand = playerById(targetId).getHand(); + for (var i = 0; i < hand.size(); i++) { + var heldCard = hand.get(i); + var matched = heldCard.applyClue(clue, rule); + if (matched) { + matchedIndexes.add(i); + } + } + + return matchedIndexes; + } + + private static void validateClue(Clue clue, HanabiRule rule) { + switch (clue) { + case ColorClue colorClue -> { + var color = colorClue.color(); + // A color must be in play to be hinted. + if (color == null || !HanabiDeck.activeColors(rule).contains(color)) { + throw new InvalidHintException(); + } + // In wildcard mode rainbow is "all colors", not a nameable color of its own. + if (color == HanabiColor.RAINBOW && rule.rainbowMode() == RainbowMode.WILDCARD) { + throw new InvalidHintException(); + } + // Black is never hintable by color. + if (color == HanabiColor.BLACK) { + throw new InvalidHintException(); + } + } + case ValueClue valueClue -> { + if (valueClue.value() < 1 || valueClue.value() > 5) { + throw new InvalidHintException(); + } + } + } + } + + private void requireTurn(long playerId) { + if (ended || pendingBonus != null) { + throw new InvalidActionException(); + } + if (players[currentTurnIndex].getId() != playerId) { + throw new NotYourTurnException(); + } + } + + private HanabiPlayer currentPlayer() { + return players[currentTurnIndex]; + } + + private HanabiPlayer playerById(long id) { + return players[playerIndexById.get(id)]; + } + + private void validateIndex(HanabiPlayer player, int index) { + if (index < 0 || index >= player.getHandSize()) { + throw new InvalidActionException(); + } + } + + private void advanceTurn() { + if (ended) { + return; + } + if (finalTurnsRemaining != null) { + finalTurnsRemaining--; + if (finalTurnsRemaining <= 0) { + endGame(); + return; + } + } else if (deck.isEmpty()) { + finalTurnsRemaining = players.length; + } + currentTurnIndex = (currentTurnIndex + 1) % players.length; + } + + /** + * Shared end-of-action tail: finish the game if all fireworks are complete, wait if a bonus is + * pending resolution, otherwise pass to the next player. + */ + private void concludeTurn() { + if (allComplete()) { + endGame(); + return; + } + if (pendingBonus != null) { + return; + } + advanceTurn(); + } + + private boolean allComplete() { + return fireworks.values().stream().allMatch(Firework::isComplete); + } + + private void explode() { + exploded = true; + endGame(); + } + + private void endGame() { + if (ended) { + return; + } + ended = true; + addEvent(new HanabiEndEvent()); + } + + // ----- Read-only accessors for mapping ----- + + public HanabiPlayer[] getPlayers() { + return players.clone(); + } + + public int getPlayerIndexById(long id) { + return playerIndexById.get(id); + } + + public long getCurrentPlayerId() { + return players[currentTurnIndex].getId(); + } + + public int getDeckSize() { + return deck.size(); + } + + public List getDiscardPile() { + return List.copyOf(discardPile); + } + + public Map getFireworks() { + return new LinkedHashMap<>(fireworks); + } + + public static int maxClueTokens() { + return MAX_CLUE_TOKENS; + } + + public static int maxFuseTokens() { + return MAX_FUSE_TOKENS; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiEventHandler.java b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiEventHandler.java new file mode 100644 index 0000000..e90bd34 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiEventHandler.java @@ -0,0 +1,117 @@ +package com.icube.sim.tichu.games.hanabi; + +import com.icube.sim.tichu.games.hanabi.dtos.HanabiMessage; +import com.icube.sim.tichu.games.hanabi.events.HanabiDiscardEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiEndEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiHintEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiPlayEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiResolveFreeHintEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiResolveRecoverToDeckEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiResolveRecoverToPlayEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiSetRuleEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiStartEvent; +import com.icube.sim.tichu.games.hanabi.mappers.HanabiMapper; +import com.icube.sim.tichu.rooms.Room; +import com.icube.sim.tichu.rooms.RoomRepository; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import java.util.Set; + +@Component +public class HanabiEventHandler { + private final RoomRepository roomRepository; + private final SimpMessagingTemplate messagingTemplate; + private final HanabiMapper hanabiMapper; + + public HanabiEventHandler(RoomRepository roomRepository, SimpMessagingTemplate messagingTemplate) { + this.roomRepository = roomRepository; + this.messagingTemplate = messagingTemplate; + this.hanabiMapper = new HanabiMapper(); + } + + @EventListener + public void onSetRule(HanabiSetRuleEvent event) { + var message = HanabiMessage.setRule(event.getRule()); + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, message); + } + } + + @EventListener + public void onStart(HanabiStartEvent event) { + var room = getRoom(event.getRoomId()); + var game = (Hanabi) room.getGame(); + for (var userId : room.getMembers().keySet()) { + sendToUser(userId, HanabiMessage.start()); + sendToUser(userId, HanabiMessage.state(hanabiMapper.toDto(game, userId))); + } + } + + @EventListener + public void onHint(HanabiHintEvent event) { + var message = HanabiMessage.hint(hanabiMapper.toHintInfo(event)); + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, message); + } + } + + @EventListener + public void onDiscard(HanabiDiscardEvent event) { + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, HanabiMessage.discard(hanabiMapper.toDiscardInfo(event, userId))); + } + } + + @EventListener + public void onPlay(HanabiPlayEvent event) { + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, HanabiMessage.play(hanabiMapper.toPlayInfo(event, userId))); + } + } + + @EventListener + public void onResolveFreeHint(HanabiResolveFreeHintEvent event) { + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, HanabiMessage.resolveFreeHint(hanabiMapper.toFreeHintInfo(event, userId))); + } + } + + @EventListener + public void onResolveRecoverToDeck(HanabiResolveRecoverToDeckEvent event) { + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, HanabiMessage.resolveRecoverToDeck(hanabiMapper.toRecoverToDeckInfo(event, userId))); + } + } + + @EventListener + public void onResolveRecoverToPlay(HanabiResolveRecoverToPlayEvent event) { + for (var userId : getRoomMemberIds(event.getRoomId())) { + sendToUser(userId, HanabiMessage.resolveRecoverToPlay(hanabiMapper.toRecoverToPlayInfo(event, userId))); + } + } + + @EventListener + public void onEnd(HanabiEndEvent event) { + var room = getRoom(event.getRoomId()); + var game = (Hanabi) room.getGame(); + for (var userId : room.getMembers().keySet()) { + sendToUser(userId, HanabiMessage.end(hanabiMapper.toDto(game, userId))); + } + room.endGame(); + } + + private Room getRoom(String roomId) { + return roomRepository.findById(roomId).orElseThrow(); + } + + private Set getRoomMemberIds(String roomId) { + var room = roomRepository.findById(roomId).orElseThrow(); + return room.getMembers().keySet(); + } + + private void sendToUser(long userId, HanabiMessage message) { + messagingTemplate.convertAndSendToUser(Long.toString(userId), "/queue/game/hanabi", message); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiPlayer.java b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiPlayer.java new file mode 100644 index 0000000..06d55ca --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiPlayer.java @@ -0,0 +1,38 @@ +package com.icube.sim.tichu.games.hanabi; + +import com.icube.sim.tichu.games.hanabi.cards.HeldCard; +import com.icube.sim.tichu.rooms.Member; +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; + +public class HanabiPlayer { + @Getter + private final long id; + @Getter + private final String name; + private final List hand = new ArrayList<>(); + + public HanabiPlayer(Member member) { + this.id = member.getId(); + this.name = member.getName(); + } + + /** Returns the live hand (same {@link HeldCard} references), so clue mutations persist. */ + public List getHand() { + return List.copyOf(hand); + } + + public int getHandSize() { + return hand.size(); + } + + public void add(HeldCard card) { + hand.add(card); + } + + public HeldCard removeCardAt(int index) { + return hand.remove(index); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiRule.java b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiRule.java new file mode 100644 index 0000000..f1f6737 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiRule.java @@ -0,0 +1,40 @@ +package com.icube.sim.tichu.games.hanabi; + +import com.icube.sim.tichu.games.common.domain.GameName; +import com.icube.sim.tichu.games.common.domain.GameRule; + +public record HanabiRule( + boolean isRainbowEnabled, + RainbowMode rainbowMode, + boolean isBlackEnabled, + boolean isBonusEnabled +) implements GameRule { + public HanabiRule { + if (rainbowMode == null) { + rainbowMode = RainbowMode.WILDCARD; + } + } + + public static HanabiRule createDefault() { + return new HanabiRule(false, RainbowMode.WILDCARD, false, false); + } + + @Override + public GameName getGameName() { + return GameName.HANABI; + } + + @Override + public int getMinPlayers() { + return 2; + } + + @Override + public int getMaxPlayers() { + return 5; + } + + public int handSize(int playerCount) { + return playerCount <= 3 ? 5 : 4; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiService.java b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiService.java new file mode 100644 index 0000000..1770d29 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/HanabiService.java @@ -0,0 +1,129 @@ +package com.icube.sim.tichu.games.hanabi; + +import com.icube.sim.tichu.games.common.domain.Game; +import com.icube.sim.tichu.games.common.domain.GameRule; +import com.icube.sim.tichu.games.common.events.GameSetRuleEvent; +import com.icube.sim.tichu.games.common.services.AbstractGameService; +import com.icube.sim.tichu.games.hanabi.cards.Clue; +import com.icube.sim.tichu.games.hanabi.cards.ClueType; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import com.icube.sim.tichu.games.hanabi.dtos.DiscardSend; +import com.icube.sim.tichu.games.hanabi.dtos.HanabiDto; +import com.icube.sim.tichu.games.hanabi.dtos.HintSend; +import com.icube.sim.tichu.games.hanabi.dtos.PlaySend; +import com.icube.sim.tichu.games.hanabi.dtos.ResolveBonusSend; +import com.icube.sim.tichu.games.hanabi.events.HanabiSetRuleEvent; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidActionException; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidHintException; +import com.icube.sim.tichu.games.hanabi.mappers.HanabiMapper; +import com.icube.sim.tichu.rooms.MemberMessagePublisher; +import com.icube.sim.tichu.rooms.Room; +import com.icube.sim.tichu.rooms.RoomRepository; +import org.jspecify.annotations.Nullable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +import java.security.Principal; +import java.util.function.Consumer; +import java.util.function.Function; + +@Service +public class HanabiService extends AbstractGameService { + private final HanabiMapper hanabiMapper; + + public HanabiService(RoomRepository roomRepository, + ApplicationEventPublisher eventPublisher, + MemberMessagePublisher memberMessagePublisher) { + super(roomRepository, eventPublisher, memberMessagePublisher); + this.hanabiMapper = new HanabiMapper(); + } + + @Override + protected void checkRule(GameRule gameRule) { + assert gameRule instanceof HanabiRule; + } + + @Override + protected GameSetRuleEvent createSetRuleEvent(GameRule gameRule) { + return new HanabiSetRuleEvent((HanabiRule) gameRule); + } + + @Override + protected void postStart(Game game, Room room) { + // Hands are dealt in the Hanabi constructor; nothing to do here. + } + + public HanabiDto get(String roomId, Principal principal) { + return withLock(roomId, game -> hanabiMapper.toDto(game, getUserId(principal))); + } + + public void giveHint(String roomId, HintSend hintSend, Principal principal) { + if (hintSend.getTargetId() == null || hintSend.getClueType() == null) { + throw new InvalidHintException(); + } + var clue = toClue(hintSend.getClueType(), hintSend.getColor(), hintSend.getValue()); + withLockAndPublish(roomId, game -> game.giveHint(getUserId(principal), hintSend.getTargetId(), clue)); + } + + public void discard(String roomId, DiscardSend discardSend, Principal principal) { + if (discardSend.getIndex() == null) { + throw new InvalidActionException(); + } + withLockAndPublish(roomId, game -> game.discard(getUserId(principal), discardSend.getIndex())); + } + + public void play(String roomId, PlaySend playSend, Principal principal) { + if (playSend.getIndex() == null) { + throw new InvalidActionException(); + } + withLockAndPublish(roomId, game -> game.play(getUserId(principal), playSend.getIndex())); + } + + public void resolveBonus(String roomId, ResolveBonusSend send, Principal principal) { + withLockAndPublish(roomId, game -> game.resolveBonus( + getUserId(principal), + send.getTargetId(), + send.getColor(), + send.getValue(), + send.getDiscardIndex())); + } + + private Clue toClue(ClueType type, @Nullable HanabiColor color, @Nullable Integer value) { + if (type == ClueType.COLOR) { + if (color == null) { + throw new InvalidHintException(); + } + return Clue.color(color); + } + if (value == null) { + throw new InvalidHintException(); + } + return Clue.value(value); + } + + private T withLock(String roomId, Function action) { + var game = getGame(roomId); + game.lock(); + try { + return action.apply(game); + } finally { + game.unlock(); + } + } + + private void withLockAndPublish(String roomId, Consumer action) { + var game = getGame(roomId); + game.lock(); + try { + action.accept(game); + publishQueuedEvents(game, roomId); + } finally { + game.unlock(); + } + } + + @Override + protected Hanabi getGame(String roomId) { + return (Hanabi) super.getGame(roomId); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/PendingBonus.java b/src/main/java/com/icube/sim/tichu/games/hanabi/PendingBonus.java new file mode 100644 index 0000000..d3f0bc3 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/PendingBonus.java @@ -0,0 +1,8 @@ +package com.icube.sim.tichu.games.hanabi; + +/** + * An interactive Flamboyant Fireworks bonus awaiting a follow-up choice from the player who completed + * the firework (e.g. which player to freely hint, or which discard to recover). + */ +public record PendingBonus(BonusEffect effect, long playerId) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/RainbowMode.java b/src/main/java/com/icube/sim/tichu/games/hanabi/RainbowMode.java new file mode 100644 index 0000000..f646d1b --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/RainbowMode.java @@ -0,0 +1,16 @@ +package com.icube.sim.tichu.games.hanabi; + +public enum RainbowMode { + /** + * Official Colour Avalanche: rainbow is a wildcard — it counts as every color, so any color hint + * touches a rainbow card, and "rainbow" cannot be named as a hint. Full 10-card suit. + */ + WILDCARD, + /** Rainbow is its own sixth color, touched only by a "rainbow" hint. Full 10-card suit. */ + SIXTH_LONG, + /** + * Rainbow is its own sixth color, touched only by a "rainbow" hint. Short 5-card suit + * (1 2 3 4 5, no duplicates), so every rainbow card is critical. + */ + SIXTH_SHORT, +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/Clue.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/Clue.java new file mode 100644 index 0000000..8d10af6 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/Clue.java @@ -0,0 +1,13 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +public sealed interface Clue permits ColorClue, ValueClue { + ClueType type(); + + static Clue color(HanabiColor color) { + return new ColorClue(color); + } + + static Clue value(int value) { + return new ValueClue(value); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ClueType.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ClueType.java new file mode 100644 index 0000000..890800b --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ClueType.java @@ -0,0 +1,6 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +public enum ClueType { + COLOR, + VALUE, +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ColorClue.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ColorClue.java new file mode 100644 index 0000000..43fe968 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ColorClue.java @@ -0,0 +1,10 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import org.jspecify.annotations.Nullable; + +public record ColorClue(@Nullable HanabiColor color) implements Clue { + @Override + public ClueType type() { + return ClueType.COLOR; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/Firework.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/Firework.java new file mode 100644 index 0000000..262b368 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/Firework.java @@ -0,0 +1,56 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import lombok.Getter; + +/** + * A single colored firework stack. Standard colors build ascending (1..5); the black + * (Black Gunpowder) stack builds descending (5..1). + */ +public class Firework { + private static final int COMPLETE_COUNT = 5; + + @Getter + private final HanabiColor color; + // How many cards have been successfully played onto this stack (0..5). + private int playCount = 0; + + public Firework(HanabiColor color) { + this.color = color; + } + + public boolean canPlay(HanabiCard card) { + if (card.color() != color) { + return false; + } + return card.value() == nextValue(); + } + + public void play(HanabiCard card) { + assert canPlay(card); + playCount++; + } + + public int playCount() { + return playCount; + } + + public boolean isComplete() { + return playCount == COMPLETE_COUNT; + } + + /** The value currently on top of the stack, or 0 if empty. */ + public int topValue() { + if (playCount == 0) { + return 0; + } + return color.isDescending() ? COMPLETE_COUNT + 1 - playCount : playCount; + } + + /** The value of the next card must have to be playable, or -1 once complete. */ + private int nextValue() { + if (isComplete()) { + return -1; + } + return color.isDescending() ? COMPLETE_COUNT - playCount : playCount + 1; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiCard.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiCard.java new file mode 100644 index 0000000..41d774e --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiCard.java @@ -0,0 +1,7 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +public record HanabiCard(HanabiColor color, int value) { + public HanabiCard { + assert value >= 1 && value <= 5; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiColor.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiColor.java new file mode 100644 index 0000000..1e8f07f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiColor.java @@ -0,0 +1,20 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +public enum HanabiColor { + RED, + YELLOW, + GREEN, + BLUE, + WHITE, + RAINBOW, + BLACK, + ; + + public boolean isStandard() { + return this != RAINBOW && this != BLACK; + } + + public boolean isDescending() { + return this == BLACK; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiDeck.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiDeck.java new file mode 100644 index 0000000..230f54e --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HanabiDeck.java @@ -0,0 +1,57 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import com.icube.sim.tichu.games.hanabi.RainbowMode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class HanabiDeck { + private static final List STANDARD_COLORS = List.of( + HanabiColor.WHITE, HanabiColor.RED, HanabiColor.BLUE, HanabiColor.YELLOW, HanabiColor.GREEN); + private static final List STANDARD_VALUES = List.of(1, 1, 1, 2, 2, 3, 3, 4, 4, 5); + // Black Gunpowder is played 5 -> 1, so its distribution is mirrored: three 5s, one 1. + private static final List BLACK_VALUES = List.of(5, 5, 5, 4, 4, 3, 3, 2, 2, 1); + + private HanabiDeck() { + } + + public static List build(HanabiRule rule) { + var cards = new ArrayList(); + for (var color : STANDARD_COLORS) { + for (var value : STANDARD_VALUES) { + cards.add(new HanabiCard(color, value)); + } + } + if (rule.isRainbowEnabled()) { + if (rule.rainbowMode() == RainbowMode.SIXTH_SHORT) { + for (var value = 1; value <= 5; value++) { + cards.add(new HanabiCard(HanabiColor.RAINBOW, value)); + } + } else { + for (var value : STANDARD_VALUES) { + cards.add(new HanabiCard(HanabiColor.RAINBOW, value)); + } + } + } + if (rule.isBlackEnabled()) { + for (var value : BLACK_VALUES) { + cards.add(new HanabiCard(HanabiColor.BLACK, value)); + } + } + Collections.shuffle(cards); + return cards; + } + + public static List activeColors(HanabiRule rule) { + var colors = new ArrayList<>(STANDARD_COLORS); + if (rule.isRainbowEnabled()) { + colors.add(HanabiColor.RAINBOW); + } + if (rule.isBlackEnabled()) { + colors.add(HanabiColor.BLACK); + } + return colors; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HeldCard.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HeldCard.java new file mode 100644 index 0000000..ed002f8 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/HeldCard.java @@ -0,0 +1,54 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import com.icube.sim.tichu.games.hanabi.RainbowMode; +import lombok.Getter; + +import java.util.HashSet; +import java.util.Set; + +/** + * A card slot in a player's hand. Holds the real card plus the accumulated clue knowledge + * that has been communicated about it. The owner never sees {@link #card}; everyone else does. + */ +@Getter +public class HeldCard { + private final HanabiCard card; + private final Set positiveColorClues = new HashSet<>(); + private final Set negativeColorClues = new HashSet<>(); + private final Set positiveValueClues = new HashSet<>(); + private final Set negativeValueClues = new HashSet<>(); + + public HeldCard(HanabiCard card) { + this.card = card; + } + + public boolean applyClue(Clue clue, HanabiRule rule) { + var matched = matches(clue, rule); + switch (clue) { + case ColorClue c -> (matched ? positiveColorClues : negativeColorClues).add(c.color()); + case ValueClue v -> (matched ? positiveValueClues : negativeValueClues).add(v.value()); + } + return matched; + } + + /** + * Whether this card is touched by a clue. Black is colorless and is only ever touched by value + * hints. Rainbow depends on the mode: in {@link RainbowMode#WILDCARD} it counts as every color + * (touched by any color hint); otherwise it is its own sixth color (touched only by a rainbow hint). + */ + boolean matches(Clue clue, HanabiRule rule) { + return switch (clue) { + case ColorClue colorClue -> { + if (card.color() == HanabiColor.BLACK) { + yield false; + } else if (card.color() == HanabiColor.RAINBOW) { + yield rule.rainbowMode() == RainbowMode.WILDCARD || colorClue.color() == HanabiColor.RAINBOW; + } else { + yield card.color() == colorClue.color(); + } + } + case ValueClue valueClue -> card.value() == valueClue.value(); + }; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ValueClue.java b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ValueClue.java new file mode 100644 index 0000000..4fed381 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/cards/ValueClue.java @@ -0,0 +1,8 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +public record ValueClue(int value) implements Clue { + @Override + public ClueType type() { + return ClueType.VALUE; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiDiscardController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiDiscardController.java new file mode 100644 index 0000000..c4d22b5 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiDiscardController.java @@ -0,0 +1,26 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiService; +import com.icube.sim.tichu.games.hanabi.dtos.DiscardSend; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiDiscardController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/discard") + public void discard( + @DestinationVariable("roomId") String roomId, + @Payload DiscardSend discardSend, + Principal principal + ) { + hanabiService.discard(roomId, discardSend, principal); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiExceptionHandler.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiExceptionHandler.java new file mode 100644 index 0000000..45d4957 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiExceptionHandler.java @@ -0,0 +1,44 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.common.websocket.ErrorMessage; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidActionException; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidHintException; +import com.icube.sim.tichu.games.hanabi.exceptions.MaxClueTokensException; +import com.icube.sim.tichu.games.hanabi.exceptions.NoClueTokensException; +import com.icube.sim.tichu.games.hanabi.exceptions.NotYourTurnException; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.web.bind.annotation.ControllerAdvice; + +@ControllerAdvice(basePackages = "com.icube.sim.tichu.games.hanabi.controllers") +public class HanabiExceptionHandler { + @MessageExceptionHandler(NotYourTurnException.class) + @SendToUser("/queue/errors") + public ErrorMessage handleNotYourTurn() { + return new ErrorMessage("It is not your turn."); + } + + @MessageExceptionHandler(NoClueTokensException.class) + @SendToUser("/queue/errors") + public ErrorMessage handleNoClueTokens() { + return new ErrorMessage("No clue tokens available."); + } + + @MessageExceptionHandler(MaxClueTokensException.class) + @SendToUser("/queue/errors") + public ErrorMessage handleMaxClueTokens() { + return new ErrorMessage("Clue tokens are already full; cannot discard."); + } + + @MessageExceptionHandler(InvalidHintException.class) + @SendToUser("/queue/errors") + public ErrorMessage handleInvalidHint() { + return new ErrorMessage("Invalid hint."); + } + + @MessageExceptionHandler(InvalidActionException.class) + @SendToUser("/queue/errors") + public ErrorMessage handleInvalidAction() { + return new ErrorMessage("Invalid action."); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiGetController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiGetController.java new file mode 100644 index 0000000..8f61b4f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiGetController.java @@ -0,0 +1,27 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiService; +import com.icube.sim.tichu.games.hanabi.dtos.HanabiMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiGetController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/get") + @SendToUser("/queue/game/hanabi") + public HanabiMessage get( + @DestinationVariable("roomId") String roomId, + Principal principal + ) { + var dto = hanabiService.get(roomId, principal); + return HanabiMessage.state(dto); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiHintController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiHintController.java new file mode 100644 index 0000000..17dd8a3 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiHintController.java @@ -0,0 +1,26 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiService; +import com.icube.sim.tichu.games.hanabi.dtos.HintSend; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiHintController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/hint") + public void hint( + @DestinationVariable("roomId") String roomId, + @Payload HintSend hintSend, + Principal principal + ) { + hanabiService.giveHint(roomId, hintSend, principal); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiPlayController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiPlayController.java new file mode 100644 index 0000000..f638e7c --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiPlayController.java @@ -0,0 +1,26 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiService; +import com.icube.sim.tichu.games.hanabi.dtos.PlaySend; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiPlayController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/play") + public void play( + @DestinationVariable("roomId") String roomId, + @Payload PlaySend playSend, + Principal principal + ) { + hanabiService.play(roomId, playSend, principal); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiResolveBonusController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiResolveBonusController.java new file mode 100644 index 0000000..90e1ccb --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiResolveBonusController.java @@ -0,0 +1,26 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiService; +import com.icube.sim.tichu.games.hanabi.dtos.ResolveBonusSend; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiResolveBonusController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/resolve-bonus") + public void resolveBonus( + @DestinationVariable("roomId") String roomId, + @Payload ResolveBonusSend resolveBonusSend, + Principal principal + ) { + hanabiService.resolveBonus(roomId, resolveBonusSend, principal); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiSetRuleController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiSetRuleController.java new file mode 100644 index 0000000..5c116d7 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiSetRuleController.java @@ -0,0 +1,26 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import com.icube.sim.tichu.games.hanabi.HanabiService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiSetRuleController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/set-rule") + public void setRule( + @DestinationVariable("roomId") String roomId, + @Payload HanabiRule rule, + Principal principal + ) { + hanabiService.setRule(roomId, rule, principal); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiStartController.java b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiStartController.java new file mode 100644 index 0000000..ae1e128 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/controllers/HanabiStartController.java @@ -0,0 +1,20 @@ +package com.icube.sim.tichu.games.hanabi.controllers; + +import com.icube.sim.tichu.games.hanabi.HanabiService; +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@RequiredArgsConstructor +@Controller +public class HanabiStartController { + private final HanabiService hanabiService; + + @MessageMapping("/rooms/{roomId}/game/hanabi/start") + public void start(@DestinationVariable("roomId") String roomId, Principal principal) { + hanabiService.start(roomId, principal); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/CardViewDto.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/CardViewDto.java new file mode 100644 index 0000000..b03fe9e --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/CardViewDto.java @@ -0,0 +1,18 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; + +import java.util.List; + +/** + * A card as seen by one viewer. {@code card} is null when the viewer owns the card (they may not + * see their own cards); the clue lists always reflect the information communicated about it. + */ +public record CardViewDto( + HanabiCardDto card, + List positiveColors, + List negativeColors, + List positiveValues, + List negativeValues +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/DiscardInfo.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/DiscardInfo.java new file mode 100644 index 0000000..7ee265c --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/DiscardInfo.java @@ -0,0 +1,15 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import org.jspecify.annotations.Nullable; + +/** + * A card was discarded and a replacement drawn. {@code drawnCard} is masked (null) for the + * discarder, who does not see their own newly drawn card, and populated for everyone else. + */ +public record DiscardInfo( + long playerId, + int index, + HanabiCardDto discardedCard, + @Nullable HanabiCardDto drawnCard +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/DiscardSend.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/DiscardSend.java new file mode 100644 index 0000000..811f3f0 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/DiscardSend.java @@ -0,0 +1,8 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import lombok.Data; + +@Data +public class DiscardSend { + private Integer index; +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/FireworkDto.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/FireworkDto.java new file mode 100644 index 0000000..4a3938e --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/FireworkDto.java @@ -0,0 +1,12 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; + +public record FireworkDto( + HanabiColor color, + int playCount, + boolean isComplete, + int topValue, + boolean descending +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/FreeHintInfo.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/FreeHintInfo.java new file mode 100644 index 0000000..f7e4445 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/FreeHintInfo.java @@ -0,0 +1,22 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.ClueType; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * A free-hint bonus was resolved: the completer ({@code fromId}) gave a color or value hint to + * another player and then drew a card. {@code drawnCard} is masked (null card) for the completer. + */ +public record FreeHintInfo( + long fromId, + long targetId, + ClueType clueType, + @Nullable HanabiColor color, + @Nullable Integer value, + List matchedIndices, + @Nullable HanabiCardDto drawnCard +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiCardDto.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiCardDto.java new file mode 100644 index 0000000..2934dcd --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiCardDto.java @@ -0,0 +1,7 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import org.jspecify.annotations.Nullable; + +public record HanabiCardDto(@Nullable HanabiColor color, @Nullable Integer value) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiDto.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiDto.java new file mode 100644 index 0000000..83f10cb --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiDto.java @@ -0,0 +1,25 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +public record HanabiDto( + HanabiRule rule, + long myId, + List players, + int deckSize, + List fireworks, + List discardPile, + int clueTokens, + int maxClueTokens, + int fuseTokens, + int maxFuseTokens, + int currentTurnIndex, + @Nullable Integer finalTurnsRemaining, + boolean exploded, + boolean ended, + @Nullable PendingBonusDto pendingBonus +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiMessage.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiMessage.java new file mode 100644 index 0000000..e94c2f3 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiMessage.java @@ -0,0 +1,57 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +@Getter +public class HanabiMessage { + private final HanabiMessageType type; + @Nullable + private final Object data; + + private HanabiMessage(HanabiMessageType type, @Nullable Object data) { + this.type = type; + this.data = data; + } + + public static HanabiMessage setRule(HanabiRule rule) { + return new HanabiMessage(HanabiMessageType.SET_RULE, rule); + } + + public static HanabiMessage start() { + return new HanabiMessage(HanabiMessageType.START, null); + } + + public static HanabiMessage state(HanabiDto dto) { + return new HanabiMessage(HanabiMessageType.STATE, dto); + } + + public static HanabiMessage hint(HintInfo info) { + return new HanabiMessage(HanabiMessageType.HINT, info); + } + + public static HanabiMessage discard(DiscardInfo info) { + return new HanabiMessage(HanabiMessageType.DISCARD, info); + } + + public static HanabiMessage play(PlayInfo info) { + return new HanabiMessage(HanabiMessageType.PLAY, info); + } + + public static HanabiMessage resolveFreeHint(FreeHintInfo info) { + return new HanabiMessage(HanabiMessageType.RESOLVE_FREE_HINT, info); + } + + public static HanabiMessage resolveRecoverToDeck(RecoverToDeckInfo info) { + return new HanabiMessage(HanabiMessageType.RESOLVE_RECOVER_TO_DECK, info); + } + + public static HanabiMessage resolveRecoverToPlay(RecoverToPlayInfo info) { + return new HanabiMessage(HanabiMessageType.RESOLVE_RECOVER_TO_PLAY, info); + } + + public static HanabiMessage end(HanabiDto dto) { + return new HanabiMessage(HanabiMessageType.END, dto); + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiMessageType.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiMessageType.java new file mode 100644 index 0000000..61354ef --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HanabiMessageType.java @@ -0,0 +1,14 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +public enum HanabiMessageType { + SET_RULE, + START, + STATE, + HINT, + DISCARD, + PLAY, + RESOLVE_FREE_HINT, + RESOLVE_RECOVER_TO_DECK, + RESOLVE_RECOVER_TO_PLAY, + END, +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HintInfo.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HintInfo.java new file mode 100644 index 0000000..093a0b4 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HintInfo.java @@ -0,0 +1,20 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.ClueType; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * A hint was given. Fully public information (the touched card positions are shared knowledge). + */ +public record HintInfo( + long fromId, + long targetId, + ClueType clueType, + @Nullable HanabiColor color, + @Nullable Integer value, + List matchedIndices +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HintSend.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HintSend.java new file mode 100644 index 0000000..576dc94 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/HintSend.java @@ -0,0 +1,16 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.ClueType; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import lombok.Data; +import org.jspecify.annotations.Nullable; + +@Data +public class HintSend { + private Long targetId; + private ClueType clueType; + @Nullable + private HanabiColor color; + @Nullable + private Integer value; +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PendingBonusDto.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PendingBonusDto.java new file mode 100644 index 0000000..4712ad4 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PendingBonusDto.java @@ -0,0 +1,6 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.BonusEffect; + +public record PendingBonusDto(BonusEffect effect, long playerId) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlayInfo.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlayInfo.java new file mode 100644 index 0000000..631023f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlayInfo.java @@ -0,0 +1,20 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.BonusEffect; +import org.jspecify.annotations.Nullable; + +/** + * A card was played. On success {@code firework} carries the stack's new top; on a misplay the card + * went to the discard and a fuse was lost. {@code drawnCard} is masked (null) for the actor. + * {@code pendingBonus} is present when completing a firework revealed a bonus tile awaiting resolution. + */ +public record PlayInfo( + long playerId, + int index, + HanabiCardDto playedCard, + boolean success, + boolean fireworkCompleted, + @Nullable BonusEffect bonusEffect, + @Nullable HanabiCardDto drawnCard +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlaySend.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlaySend.java new file mode 100644 index 0000000..c1bf333 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlaySend.java @@ -0,0 +1,8 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import lombok.Data; + +@Data +public class PlaySend { + private Integer index; +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlayerDto.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlayerDto.java new file mode 100644 index 0000000..884e61f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/PlayerDto.java @@ -0,0 +1,6 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import java.util.List; + +public record PlayerDto(long playerId, String name, List cardViews) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/RecoverToDeckInfo.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/RecoverToDeckInfo.java new file mode 100644 index 0000000..925184f --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/RecoverToDeckInfo.java @@ -0,0 +1,15 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import org.jspecify.annotations.Nullable; + +/** + * A recover-to-deck bonus was resolved: the completer ({@code playerId}) picked a discard, which was + * shuffled back into the deck, and then drew a card. {@code drawnCard} is masked (null card) for the + * completer. + */ +public record RecoverToDeckInfo( + long playerId, + HanabiCardDto recoveredCard, + @Nullable HanabiCardDto drawnCard +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/RecoverToPlayInfo.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/RecoverToPlayInfo.java new file mode 100644 index 0000000..4fb5ad9 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/RecoverToPlayInfo.java @@ -0,0 +1,20 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.BonusEffect; +import org.jspecify.annotations.Nullable; + +/** + * A recover-to-play bonus was resolved: the completer ({@code playerId}) picked a discard at + * {@code index} and placed it straight onto its firework, then drew a card. When the placed card + * completed the stack, {@code fireworkCompleted} is true and {@code bonusEffect} carries any newly + * revealed tile. {@code drawnCard} is masked (null card) for the completer. + */ +public record RecoverToPlayInfo( + long playerId, + int index, + HanabiCardDto playedCard, + boolean fireworkCompleted, + @Nullable BonusEffect bonusEffect, + @Nullable HanabiCardDto drawnCard +) { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/ResolveBonusSend.java b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/ResolveBonusSend.java new file mode 100644 index 0000000..1695e75 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/dtos/ResolveBonusSend.java @@ -0,0 +1,19 @@ +package com.icube.sim.tichu.games.hanabi.dtos; + +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jspecify.annotations.Nullable; + +@NoArgsConstructor +@Data +public class ResolveBonusSend { + @Nullable + private Long targetId; + @Nullable + private HanabiColor color; + @Nullable + private Integer value; + @Nullable + private Integer discardIndex; +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiDiscardEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiDiscardEvent.java new file mode 100644 index 0000000..6195f6b --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiDiscardEvent.java @@ -0,0 +1,25 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +/** + * A card was discarded. The drawn replacement is null when the deck was empty; the handler masks it + * from the discarder when building each viewer's message. + */ +@Getter +public class HanabiDiscardEvent extends HanabiEvent { + private final long playerId; + private final int index; + private final HanabiCard discardedCard; + @Nullable + private final HanabiCard drawnCard; + + public HanabiDiscardEvent(long playerId, int index, HanabiCard discardedCard, @Nullable HanabiCard drawnCard) { + this.playerId = playerId; + this.index = index; + this.discardedCard = discardedCard; + this.drawnCard = drawnCard; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiEndEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiEndEvent.java new file mode 100644 index 0000000..9d3da51 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiEndEvent.java @@ -0,0 +1,7 @@ +package com.icube.sim.tichu.games.hanabi.events; + +/** + * Signals that the game has ended. The handler sends a final masked view, then ends the room game. + */ +public class HanabiEndEvent extends HanabiEvent { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiEvent.java new file mode 100644 index 0000000..18ab7a8 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiEvent.java @@ -0,0 +1,13 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.common.events.GameEvent; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.Nullable; + +public abstract class HanabiEvent implements GameEvent { + @Getter + @Setter + @Nullable + private String roomId; +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiHintEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiHintEvent.java new file mode 100644 index 0000000..0fbd1c3 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiHintEvent.java @@ -0,0 +1,25 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.hanabi.cards.Clue; +import lombok.Getter; + +import java.util.List; + +/** + * A hint was given. Carries who hinted whom, the clue, and which of the target's card positions + * it touched. + */ +@Getter +public class HanabiHintEvent extends HanabiEvent { + private final long fromId; + private final long targetId; + private final Clue clue; + private final List matchedIndexes; + + public HanabiHintEvent(long fromId, long targetId, Clue clue, List matchedIndexes) { + this.fromId = fromId; + this.targetId = targetId; + this.clue = clue; + this.matchedIndexes = matchedIndexes; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiPlayEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiPlayEvent.java new file mode 100644 index 0000000..3bd6c43 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiPlayEvent.java @@ -0,0 +1,39 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.hanabi.BonusEffect; +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +/** + * A card was played. {@code success} distinguishes a legal play from a misplay; the drawn + * replacement is null when the deck was empty and is masked from the actor by the handler. + */ +@Getter +public class HanabiPlayEvent extends HanabiEvent { + private final long playerId; + private final int index; + private final HanabiCard playedCard; + private final boolean success; + private final boolean fireworkCompleted; + @Nullable + private final BonusEffect bonusEffect; + @Nullable + private final HanabiCard drawnCard; + + public HanabiPlayEvent(long playerId, + int index, + HanabiCard playedCard, + boolean success, + boolean fireworkCompleted, + @Nullable BonusEffect bonusEffect, + @Nullable HanabiCard drawnCard) { + this.playerId = playerId; + this.index = index; + this.playedCard = playedCard; + this.success = success; + this.fireworkCompleted = fireworkCompleted; + this.bonusEffect = bonusEffect; + this.drawnCard = drawnCard; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveFreeHintEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveFreeHintEvent.java new file mode 100644 index 0000000..12c1939 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveFreeHintEvent.java @@ -0,0 +1,30 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.hanabi.cards.Clue; +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +@Getter +public class HanabiResolveFreeHintEvent extends HanabiEvent { + private final long fromId; + private final long targetId; + private final Clue clue; + private final List matchedIndexes; + @Nullable + private final HanabiCard drawnCard; + + public HanabiResolveFreeHintEvent(long fromId, + long targetId, + Clue clue, + List matchedIndexes, + @Nullable HanabiCard drawnCard) { + this.fromId = fromId; + this.targetId = targetId; + this.clue = clue; + this.matchedIndexes = matchedIndexes; + this.drawnCard = drawnCard; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveRecoverToDeckEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveRecoverToDeckEvent.java new file mode 100644 index 0000000..9ed0996 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveRecoverToDeckEvent.java @@ -0,0 +1,19 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +@Getter +public class HanabiResolveRecoverToDeckEvent extends HanabiEvent { + private final long playerId; + private final HanabiCard card; + @Nullable + private final HanabiCard drawnCard; + + public HanabiResolveRecoverToDeckEvent(long playerId, HanabiCard card, @Nullable HanabiCard drawnCard) { + this.playerId = playerId; + this.card = card; + this.drawnCard = drawnCard; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveRecoverToPlayEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveRecoverToPlayEvent.java new file mode 100644 index 0000000..cb4cb58 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiResolveRecoverToPlayEvent.java @@ -0,0 +1,24 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.hanabi.BonusEffect; +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import lombok.Getter; + +@Getter +public class HanabiResolveRecoverToPlayEvent extends HanabiEvent { + private final long playerId; + private final int index; + private final HanabiCard card; + private final boolean fireworkCompleted; + private final BonusEffect bonusEffect; + private final HanabiCard drawnCard; + + public HanabiResolveRecoverToPlayEvent(long playerId, int index, HanabiCard card, boolean fireworkCompleted, BonusEffect bonusEffect, HanabiCard drawnCard) { + this.playerId = playerId; + this.index = index; + this.card = card; + this.fireworkCompleted = fireworkCompleted; + this.bonusEffect = bonusEffect; + this.drawnCard = drawnCard; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiSetRuleEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiSetRuleEvent.java new file mode 100644 index 0000000..813d36c --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiSetRuleEvent.java @@ -0,0 +1,14 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.common.events.GameSetRuleEvent; +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import lombok.Getter; + +@Getter +public class HanabiSetRuleEvent extends HanabiEvent implements GameSetRuleEvent { + private final HanabiRule rule; + + public HanabiSetRuleEvent(HanabiRule rule) { + this.rule = rule; + } +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiStartEvent.java b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiStartEvent.java new file mode 100644 index 0000000..8806ee3 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/events/HanabiStartEvent.java @@ -0,0 +1,6 @@ +package com.icube.sim.tichu.games.hanabi.events; + +import com.icube.sim.tichu.games.common.events.GameStartEvent; + +public class HanabiStartEvent extends HanabiEvent implements GameStartEvent { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/InvalidActionException.java b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/InvalidActionException.java new file mode 100644 index 0000000..b2b6847 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/InvalidActionException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.games.hanabi.exceptions; + +public class InvalidActionException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/InvalidHintException.java b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/InvalidHintException.java new file mode 100644 index 0000000..3741848 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/InvalidHintException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.games.hanabi.exceptions; + +public class InvalidHintException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/MaxClueTokensException.java b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/MaxClueTokensException.java new file mode 100644 index 0000000..1686dca --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/MaxClueTokensException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.games.hanabi.exceptions; + +public class MaxClueTokensException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/NoClueTokensException.java b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/NoClueTokensException.java new file mode 100644 index 0000000..ca63829 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/NoClueTokensException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.games.hanabi.exceptions; + +public class NoClueTokensException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/NotYourTurnException.java b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/NotYourTurnException.java new file mode 100644 index 0000000..8197141 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/exceptions/NotYourTurnException.java @@ -0,0 +1,4 @@ +package com.icube.sim.tichu.games.hanabi.exceptions; + +public class NotYourTurnException extends RuntimeException { +} diff --git a/src/main/java/com/icube/sim/tichu/games/hanabi/mappers/HanabiMapper.java b/src/main/java/com/icube/sim/tichu/games/hanabi/mappers/HanabiMapper.java new file mode 100644 index 0000000..1b9b549 --- /dev/null +++ b/src/main/java/com/icube/sim/tichu/games/hanabi/mappers/HanabiMapper.java @@ -0,0 +1,203 @@ +package com.icube.sim.tichu.games.hanabi.mappers; + +import com.icube.sim.tichu.games.hanabi.Hanabi; +import com.icube.sim.tichu.games.hanabi.HanabiPlayer; +import com.icube.sim.tichu.games.hanabi.PendingBonus; +import com.icube.sim.tichu.games.hanabi.cards.Clue; +import com.icube.sim.tichu.games.hanabi.cards.ColorClue; +import com.icube.sim.tichu.games.hanabi.cards.Firework; +import com.icube.sim.tichu.games.hanabi.cards.HanabiCard; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import com.icube.sim.tichu.games.hanabi.cards.HeldCard; +import com.icube.sim.tichu.games.hanabi.cards.ValueClue; +import com.icube.sim.tichu.games.hanabi.dtos.CardViewDto; +import com.icube.sim.tichu.games.hanabi.dtos.DiscardInfo; +import com.icube.sim.tichu.games.hanabi.dtos.FireworkDto; +import com.icube.sim.tichu.games.hanabi.dtos.FreeHintInfo; +import com.icube.sim.tichu.games.hanabi.dtos.HanabiCardDto; +import com.icube.sim.tichu.games.hanabi.dtos.HanabiDto; +import com.icube.sim.tichu.games.hanabi.dtos.HintInfo; +import com.icube.sim.tichu.games.hanabi.dtos.PendingBonusDto; +import com.icube.sim.tichu.games.hanabi.dtos.PlayInfo; +import com.icube.sim.tichu.games.hanabi.dtos.PlayerDto; +import com.icube.sim.tichu.games.hanabi.dtos.RecoverToDeckInfo; +import com.icube.sim.tichu.games.hanabi.dtos.RecoverToPlayInfo; +import com.icube.sim.tichu.games.hanabi.events.HanabiDiscardEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiHintEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiPlayEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiResolveFreeHintEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiResolveRecoverToDeckEvent; +import com.icube.sim.tichu.games.hanabi.events.HanabiResolveRecoverToPlayEvent; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +public class HanabiMapper { + /** + * Builds the masked view for {@code viewerId}: every other player's cards are fully visible, + * while the viewer's own cards expose only the clue knowledge gathered about them. + */ + public HanabiDto toDto(Hanabi game, long viewerId) { + var playerDtos = new ArrayList(); + for (var player : game.getPlayers()) { + playerDtos.add(toPlayerDto(player, viewerId)); + } + + return new HanabiDto( + game.getRule(), + viewerId, + playerDtos, + game.getDeckSize(), + game.getFireworks().values().stream().map(this::toFireworkDto).toList(), + game.getDiscardPile().stream().map(this::toCardDto).toList(), + game.getClueTokens(), + Hanabi.maxClueTokens(), + game.getFuseTokens(), + Hanabi.maxFuseTokens(), + game.getCurrentTurnIndex(), + game.getFinalTurnsRemaining(), + game.isExploded(), + game.isEnded(), + toPendingBonusDto(game.getPendingBonus()) + ); + } + + private PlayerDto toPlayerDto(HanabiPlayer player, long viewerId) { + var isViewer = player.getId() == viewerId; + var cardViews = player.getHand().stream() + .map(heldCard -> toCardViewDto(heldCard, isViewer)) + .toList(); + return new PlayerDto(player.getId(), player.getName(), cardViews); + } + + private CardViewDto toCardViewDto(HeldCard heldCard, boolean isOwn) { + var card = isOwn ? emptyCardDto() : toCardDto(heldCard.getCard()); + return new CardViewDto( + card, + List.copyOf(heldCard.getPositiveColorClues()), + List.copyOf(heldCard.getNegativeColorClues()), + List.copyOf(heldCard.getPositiveValueClues()), + List.copyOf(heldCard.getNegativeValueClues()) + ); + } + + private FireworkDto toFireworkDto(Firework firework) { + return new FireworkDto( + firework.getColor(), + firework.playCount(), + firework.isComplete(), + firework.topValue(), + firework.getColor().isDescending() + ); + } + + private @Nullable PendingBonusDto toPendingBonusDto(@Nullable PendingBonus pendingBonus) { + if (pendingBonus == null) { + return null; + } + return new PendingBonusDto(pendingBonus.effect(), pendingBonus.playerId()); + } + + /** + * The per-action delta messages below. They carry only what the action changed; the resulting + * scalar counters (tokens, deck size, turn) are read live from the game, which is already in its + * final post-action state when these run. + */ + + public HintInfo toHintInfo(HanabiHintEvent event) { + return new HintInfo( + event.getFromId(), + event.getTargetId(), + event.getClue().type(), + clueColor(event.getClue()), + clueValue(event.getClue()), + event.getMatchedIndexes() + ); + } + + public DiscardInfo toDiscardInfo(HanabiDiscardEvent event, long viewerId) { + return new DiscardInfo( + event.getPlayerId(), + event.getIndex(), + toCardDto(event.getDiscardedCard()), + maskedDrawn(event.getDrawnCard(), event.getPlayerId(), viewerId) + ); + } + + public PlayInfo toPlayInfo(HanabiPlayEvent event, long viewerId) { + return new PlayInfo( + event.getPlayerId(), + event.getIndex(), + toCardDto(event.getPlayedCard()), + event.isSuccess(), + event.isFireworkCompleted(), + event.getBonusEffect(), + maskedDrawn(event.getDrawnCard(), event.getPlayerId(), viewerId) + ); + } + + /** Free-hint bonus: the completer gave a color or value hint to another player, then drew. */ + public FreeHintInfo toFreeHintInfo(HanabiResolveFreeHintEvent event, long viewerId) { + return new FreeHintInfo( + event.getFromId(), + event.getTargetId(), + event.getClue().type(), + clueColor(event.getClue()), + clueValue(event.getClue()), + event.getMatchedIndexes(), + maskedDrawn(event.getDrawnCard(), event.getFromId(), viewerId) + ); + } + + /** Recover-to-deck bonus: the chosen discard was shuffled back into the deck, then the completer drew. */ + public RecoverToDeckInfo toRecoverToDeckInfo(HanabiResolveRecoverToDeckEvent event, long viewerId) { + return new RecoverToDeckInfo( + event.getPlayerId(), + toCardDto(event.getCard()), + maskedDrawn(event.getDrawnCard(), event.getPlayerId(), viewerId) + ); + } + + /** + * Recover-to-play bonus: the chosen discard was placed straight onto its firework, then the + * completer drew. A recovered '5' that completes the stack may reveal a further bonus tile. + */ + public RecoverToPlayInfo toRecoverToPlayInfo(HanabiResolveRecoverToPlayEvent event, long viewerId) { + return new RecoverToPlayInfo( + event.getPlayerId(), + event.getIndex(), + toCardDto(event.getCard()), + event.isFireworkCompleted(), + event.getBonusEffect(), + maskedDrawn(event.getDrawnCard(), event.getPlayerId(), viewerId) + ); + } + + /** A drawn card is hidden from the drawer (who never sees their own hand) and shown to everyone else. */ + private @Nullable HanabiCardDto maskedDrawn(@Nullable HanabiCard drawn, long drawerId, long viewerId) { + if (drawn == null) { + return null; + } + if (viewerId == drawerId) { + return emptyCardDto(); + } + return toCardDto(drawn); + } + + private @Nullable HanabiColor clueColor(Clue clue) { + return clue instanceof ColorClue(HanabiColor color) ? color : null; + } + + private @Nullable Integer clueValue(Clue clue) { + return clue instanceof ValueClue(int value) ? value : null; + } + + private HanabiCardDto toCardDto(HanabiCard card) { + return new HanabiCardDto(card.color(), card.value()); + } + + private HanabiCardDto emptyCardDto() { + return new HanabiCardDto(null, null); + } +} diff --git a/src/test/java/com/icube/sim/tichu/games/hanabi/HanabiTest.java b/src/test/java/com/icube/sim/tichu/games/hanabi/HanabiTest.java new file mode 100644 index 0000000..a47b19e --- /dev/null +++ b/src/test/java/com/icube/sim/tichu/games/hanabi/HanabiTest.java @@ -0,0 +1,259 @@ +package com.icube.sim.tichu.games.hanabi; + +import com.icube.sim.tichu.games.hanabi.cards.Clue; +import com.icube.sim.tichu.games.hanabi.cards.Firework; +import com.icube.sim.tichu.games.hanabi.cards.HanabiColor; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidActionException; +import com.icube.sim.tichu.games.hanabi.exceptions.InvalidHintException; +import com.icube.sim.tichu.games.hanabi.exceptions.MaxClueTokensException; +import com.icube.sim.tichu.games.hanabi.exceptions.NoClueTokensException; +import com.icube.sim.tichu.games.hanabi.exceptions.NotYourTurnException; +import com.icube.sim.tichu.rooms.Member; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class HanabiTest { + private Map members(int count) { + var members = new LinkedHashMap(); + for (var i = 1; i <= count; i++) { + members.put((long) i, new Member(i, "P" + i)); + } + return members; + } + + private Hanabi newGame(int playerCount) { + return new Hanabi(HanabiRule.createDefault(), members(playerCount)); + } + + private long otherPlayerId(Hanabi game) { + var current = game.getCurrentPlayerId(); + for (var player : game.getPlayers()) { + if (player.getId() != current) { + return player.getId(); + } + } + throw new IllegalStateException(); + } + + @Test + void initialState_twoToThreePlayers_dealsFiveCards() { + var game = newGame(3); + + assertEquals(8, game.getClueTokens()); + assertEquals(3, game.getFuseTokens()); + assertEquals(0, game.getCurrentTurnIndex()); + for (var player : game.getPlayers()) { + assertEquals(5, player.getHandSize()); + } + // 50 - (3 players * 5 cards) = 35 + assertEquals(35, game.getDeckSize()); + } + + @Test + void initialState_fourToFivePlayers_dealsFourCards() { + var game = newGame(4); + + for (var player : game.getPlayers()) { + assertEquals(4, player.getHandSize()); + } + assertEquals(50 - 16, game.getDeckSize()); + } + + @Test + void giveHint_spendsClueAndAdvancesTurn() { + var game = newGame(2); + var from = game.getCurrentPlayerId(); + var target = otherPlayerId(game); + + game.giveHint(from, target, Clue.value(1)); + + assertEquals(7, game.getClueTokens()); + assertEquals(target, game.getCurrentPlayerId()); + } + + @Test + void giveHint_toSelf_throws() { + var game = newGame(2); + var from = game.getCurrentPlayerId(); + + assertThrows(InvalidActionException.class, () -> game.giveHint(from, from, Clue.value(1))); + } + + @Test + void giveHint_whenNotYourTurn_throws() { + var game = newGame(2); + var notCurrent = otherPlayerId(game); + var current = game.getCurrentPlayerId(); + + assertThrows(NotYourTurnException.class, () -> game.giveHint(notCurrent, current, Clue.value(1))); + } + + @Test + void giveHint_invalidValueOrForbiddenColor_throws() { + var game = newGame(2); + var from = game.getCurrentPlayerId(); + var target = otherPlayerId(game); + + assertThrows(InvalidHintException.class, () -> game.giveHint(from, target, Clue.value(6))); + assertThrows(InvalidHintException.class, () -> game.giveHint(from, target, Clue.color(HanabiColor.BLACK))); + assertThrows(InvalidHintException.class, () -> game.giveHint(from, target, Clue.color(HanabiColor.RAINBOW))); + } + + @Test + void giveHint_recordsPositiveAndNegativeKnowledgeCorrectly() { + var game = newGame(2); + var from = game.getCurrentPlayerId(); + var targetId = otherPlayerId(game); + var target = game.getPlayers()[game.getPlayerIndexById(targetId)]; + + game.giveHint(from, targetId, Clue.color(HanabiColor.RED)); + + for (var held : target.getHand()) { + var isRed = held.getCard().color() == HanabiColor.RED; + assertEquals(isRed, held.getPositiveColorClues().contains(HanabiColor.RED)); + assertEquals(!isRed, held.getNegativeColorClues().contains(HanabiColor.RED)); + } + } + + @Test + void giveHint_emptyHintIsAllowed() { + var game = newGame(2); + var from = game.getCurrentPlayerId(); + var targetId = otherPlayerId(game); + var target = game.getPlayers()[game.getPlayerIndexById(targetId)]; + // Pick a value that no card in the target's hand has, if any; otherwise any valid value still works. + var missingValue = 1; + for (var v = 1; v <= 5; v++) { + var value = v; + var present = target.getHand().stream().anyMatch(h -> h.getCard().value() == value); + if (!present) { + missingValue = v; + break; + } + } + + var finalMissing = missingValue; + assertDoesNotThrow(() -> game.giveHint(from, targetId, Clue.value(finalMissing))); + assertEquals(7, game.getClueTokens()); + } + + @Test + void drainAllClueTokens_thenHintThrows() { + var game = newGame(2); + + for (var i = 0; i < 8; i++) { + var from = game.getCurrentPlayerId(); + var target = otherPlayerId(game); + game.giveHint(from, target, Clue.value(1)); + } + assertEquals(0, game.getClueTokens()); + + var from = game.getCurrentPlayerId(); + var target = otherPlayerId(game); + assertThrows(NoClueTokensException.class, () -> game.giveHint(from, target, Clue.value(1))); + } + + @Test + void discard_atFullClueTokens_throws() { + var game = newGame(2); + var current = game.getCurrentPlayerId(); + + assertThrows(MaxClueTokensException.class, () -> game.discard(current, 0)); + } + + @Test + void discard_recoversClueDrawsReplacementAndAdvances() { + var game = newGame(2); + // Spend a clue first, so discarding is legal. + var first = game.getCurrentPlayerId(); + game.giveHint(first, otherPlayerId(game), Clue.value(1)); + assertEquals(7, game.getClueTokens()); + + var discarder = game.getCurrentPlayerId(); + var deckBefore = game.getDeckSize(); + game.discard(discarder, 0); + + assertEquals(8, game.getClueTokens()); + assertEquals(1, game.getDiscardPile().size()); + assertEquals(deckBefore - 1, game.getDeckSize()); + assertEquals(5, game.getPlayers()[game.getPlayerIndexById(discarder)].getHandSize()); + assertNotEquals(discarder, game.getCurrentPlayerId()); + } + + @Test + void play_misplay_losesFuseAndDiscards() { + var game = newGame(2); + var currentId = game.getCurrentPlayerId(); + var current = game.getPlayers()[game.getPlayerIndexById(currentId)]; + + // At the start, only standard 1s are playable. Find a guaranteed misplay (value >= 2, non-black). + var hand = current.getHand(); + int misplayIndex = -1; + for (var i = 0; i < hand.size(); i++) { + if (hand.get(i).getCard().value() >= 2) { + misplayIndex = i; + break; + } + } + assertTrue(misplayIndex >= 0, "expected at least one non-1 card in a 5-card hand"); + + game.play(currentId, misplayIndex); + + assertEquals(2, game.getFuseTokens()); + assertEquals(1, game.getDiscardPile().size()); + } + + @Test + void play_successfulOne_increasesScore() { + var game = newGame(2); + var currentId = game.getCurrentPlayerId(); + var current = game.getPlayers()[game.getPlayerIndexById(currentId)]; + + var hand = current.getHand(); + int playIndex = -1; + for (var i = 0; i < hand.size(); i++) { + var card = hand.get(i).getCard(); + if (card.color().isStandard() && card.value() == 1) { + playIndex = i; + break; + } + } + + if (playIndex < 0) { + return; // No playable 1 in hand this deal; covered deterministically by FireworkTest. + } + + game.play(currentId, playIndex); + + var played = game.getFireworks().values().stream() + .mapToInt(Firework::playCount) + .sum(); + assertEquals(1, played); + assertEquals(3, game.getFuseTokens()); + } + + @Test + void giveHint_rainbowNameable_onlyInSixthColorMode() { + // Sixth-color mode: "rainbow" is a nameable color. + var sixth = new Hanabi(new HanabiRule(true, RainbowMode.SIXTH_LONG, false, false), members(2)); + var f1 = sixth.getCurrentPlayerId(); + var t1 = otherPlayerId(sixth); + assertDoesNotThrow(() -> sixth.giveHint(f1, t1, Clue.color(HanabiColor.RAINBOW))); + + // Wildcard mode: rainbow is "all colors", so it cannot be named directly. + var wild = new Hanabi(new HanabiRule(true, RainbowMode.WILDCARD, false, false), members(2)); + var f2 = wild.getCurrentPlayerId(); + var t2 = otherPlayerId(wild); + assertThrows(InvalidHintException.class, () -> wild.giveHint(f2, t2, Clue.color(HanabiColor.RAINBOW))); + + // Rainbow suit disabled: not a legal color at all. + var none = newGame(2); + var f3 = none.getCurrentPlayerId(); + var t3 = otherPlayerId(none); + assertThrows(InvalidHintException.class, () -> none.giveHint(f3, t3, Clue.color(HanabiColor.RAINBOW))); + } +} diff --git a/src/test/java/com/icube/sim/tichu/games/hanabi/cards/FireworkTest.java b/src/test/java/com/icube/sim/tichu/games/hanabi/cards/FireworkTest.java new file mode 100644 index 0000000..ecba90d --- /dev/null +++ b/src/test/java/com/icube/sim/tichu/games/hanabi/cards/FireworkTest.java @@ -0,0 +1,42 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FireworkTest { + @Test + void ascending_buildsOneToFive() { + var firework = new Firework(HanabiColor.RED); + + assertEquals(0, firework.topValue()); + assertTrue(firework.canPlay(new HanabiCard(HanabiColor.RED, 1))); + assertFalse(firework.canPlay(new HanabiCard(HanabiColor.RED, 2))); + assertFalse(firework.canPlay(new HanabiCard(HanabiColor.BLUE, 1))); + + for (var value = 1; value <= 5; value++) { + assertTrue(firework.canPlay(new HanabiCard(HanabiColor.RED, value))); + firework.play(new HanabiCard(HanabiColor.RED, value)); + assertEquals(value, firework.topValue()); + assertEquals(value, firework.playCount()); + } + assertTrue(firework.isComplete()); + } + + @Test + void descendingBlack_buildsFiveToOne() { + var firework = new Firework(HanabiColor.BLACK); + + assertEquals(0, firework.topValue()); + assertTrue(firework.canPlay(new HanabiCard(HanabiColor.BLACK, 5))); + assertFalse(firework.canPlay(new HanabiCard(HanabiColor.BLACK, 4))); + + for (var value = 5; value >= 1; value--) { + assertTrue(firework.canPlay(new HanabiCard(HanabiColor.BLACK, value))); + firework.play(new HanabiCard(HanabiColor.BLACK, value)); + assertEquals(value, firework.topValue()); + assertEquals(6 - value, firework.playCount()); + } + assertTrue(firework.isComplete()); + } +} diff --git a/src/test/java/com/icube/sim/tichu/games/hanabi/cards/HanabiDeckTest.java b/src/test/java/com/icube/sim/tichu/games/hanabi/cards/HanabiDeckTest.java new file mode 100644 index 0000000..ab30281 --- /dev/null +++ b/src/test/java/com/icube/sim/tichu/games/hanabi/cards/HanabiDeckTest.java @@ -0,0 +1,74 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import com.icube.sim.tichu.games.hanabi.RainbowMode; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class HanabiDeckTest { + private long countColor(List deck, HanabiColor color) { + return deck.stream().filter(c -> c.color() == color).count(); + } + + private long countCard(List deck, HanabiColor color, int value) { + return deck.stream().filter(c -> c.color() == color && c.value() == value).count(); + } + + @Test + void baseDeck_has50StandardCards() { + var deck = HanabiDeck.build(HanabiRule.createDefault()); + + assertEquals(50, deck.size()); + for (var color : List.of(HanabiColor.WHITE, HanabiColor.RED, HanabiColor.BLUE, HanabiColor.YELLOW, HanabiColor.GREEN)) { + assertEquals(10, countColor(deck, color)); + assertEquals(3, countCard(deck, color, 1)); + assertEquals(2, countCard(deck, color, 2)); + assertEquals(2, countCard(deck, color, 3)); + assertEquals(2, countCard(deck, color, 4)); + assertEquals(1, countCard(deck, color, 5)); + } + } + + @Test + void rainbowLong_adds10Cards() { + var rule = new HanabiRule(true, RainbowMode.SIXTH_LONG, false, false); + + var deck = HanabiDeck.build(rule); + + assertEquals(60, deck.size()); + assertEquals(10, countColor(deck, HanabiColor.RAINBOW)); + assertTrue(HanabiDeck.activeColors(rule).contains(HanabiColor.RAINBOW)); + } + + @Test + void rainbowShort_addsOnlyFiveCards() { + var rule = new HanabiRule(true, RainbowMode.SIXTH_SHORT, false, false); + + var deck = HanabiDeck.build(rule); + + assertEquals(55, deck.size()); + assertEquals(5, countColor(deck, HanabiColor.RAINBOW)); + for (var value = 1; value <= 5; value++) { + assertEquals(1, countCard(deck, HanabiColor.RAINBOW, value)); + } + } + + @Test + void blackPowder_addsMirroredDistribution() { + var rule = new HanabiRule(false, RainbowMode.WILDCARD, true, false); + + var deck = HanabiDeck.build(rule); + + assertEquals(60, deck.size()); + assertEquals(10, countColor(deck, HanabiColor.BLACK)); + // Black builds 5 -> 1, so it has three 5s and a single 1. + assertEquals(3, countCard(deck, HanabiColor.BLACK, 5)); + assertEquals(2, countCard(deck, HanabiColor.BLACK, 4)); + assertEquals(2, countCard(deck, HanabiColor.BLACK, 3)); + assertEquals(2, countCard(deck, HanabiColor.BLACK, 2)); + assertEquals(1, countCard(deck, HanabiColor.BLACK, 1)); + } +} diff --git a/src/test/java/com/icube/sim/tichu/games/hanabi/cards/HeldCardTest.java b/src/test/java/com/icube/sim/tichu/games/hanabi/cards/HeldCardTest.java new file mode 100644 index 0000000..6f50a79 --- /dev/null +++ b/src/test/java/com/icube/sim/tichu/games/hanabi/cards/HeldCardTest.java @@ -0,0 +1,43 @@ +package com.icube.sim.tichu.games.hanabi.cards; + +import com.icube.sim.tichu.games.hanabi.HanabiRule; +import com.icube.sim.tichu.games.hanabi.RainbowMode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HeldCardTest { + private HeldCard held(HanabiColor color, int value) { + return new HeldCard(new HanabiCard(color, value)); + } + + private HanabiRule ruleWith(RainbowMode rainbowMode) { + return new HanabiRule(true, rainbowMode, false, false); + } + + @Test + void matches_wildcard_rainbowMatchesEveryColor() { + var rule = ruleWith(RainbowMode.WILDCARD); + + // Rainbow is a wildcard: every color hint touches it. + assertTrue(held(HanabiColor.RAINBOW, 3).matches(Clue.color(HanabiColor.RED), rule)); + assertTrue(held(HanabiColor.RAINBOW, 3).matches(Clue.color(HanabiColor.BLUE), rule)); + // A standard card is touched only by its own color. + assertTrue(held(HanabiColor.RED, 4).matches(Clue.color(HanabiColor.RED), rule)); + assertFalse(held(HanabiColor.RED, 4).matches(Clue.color(HanabiColor.BLUE), rule)); + // Black is never touched by a color hint but is touched by value hints. + assertFalse(held(HanabiColor.BLACK, 3).matches(Clue.color(HanabiColor.RED), rule)); + assertTrue(held(HanabiColor.BLACK, 3).matches(Clue.value(3), rule)); + } + + @Test + void matches_sixthColor_rainbowMatchesOnlyRainbowHint() { + var rule = ruleWith(RainbowMode.SIXTH_LONG); + + assertTrue(held(HanabiColor.RAINBOW, 3).matches(Clue.color(HanabiColor.RAINBOW), rule)); + assertFalse(held(HanabiColor.RAINBOW, 3).matches(Clue.color(HanabiColor.RED), rule)); + assertFalse(held(HanabiColor.RED, 4).matches(Clue.color(HanabiColor.RAINBOW), rule)); + assertTrue(held(HanabiColor.RAINBOW, 3).matches(Clue.value(3), rule)); + } +}