diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 2d2456b..0297f90 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index ad12092..f655772 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/assets/images/swdbg-cards-art.png b/assets/images/swdbg-cards-art.png index 96ea45b..01a9ff3 100644 Binary files a/assets/images/swdbg-cards-art.png and b/assets/images/swdbg-cards-art.png differ diff --git a/assets/images/swdbg-cards-art.psd b/assets/images/swdbg-cards-art.psd index bdabbe3..d32a38c 100644 Binary files a/assets/images/swdbg-cards-art.psd and b/assets/images/swdbg-cards-art.psd differ diff --git a/data/balatro-artwork.json b/data/balatro-artwork.json new file mode 100644 index 0000000..6940eed --- /dev/null +++ b/data/balatro-artwork.json @@ -0,0 +1,292 @@ +{ + "_readme": [ + "Drop-in art for Balatro. Set a sheet path (e.g. assets/images/balatro-jokers.png) to load it;", + "until then everything renders procedurally. Frames are 0-based, row-major, in the exact id order below.", + "See src/games/balatro/sprites.md for grid layouts and sizes." + ], + "jokerSheet": { + "key": "balatro-jokers", + "path": null, + "frameWidth": 150, + "frameHeight": 200 + }, + "tarotSheet": { + "key": "balatro-tarots", + "path": null, + "frameWidth": 150, + "frameHeight": 200 + }, + "planetSheet": { + "key": "balatro-planets", + "path": null, + "frameWidth": 150, + "frameHeight": 200 + }, + "spectralSheet": { + "key": "balatro-spectrals", + "path": null, + "frameWidth": 150, + "frameHeight": 200 + }, + "voucherSheet": { + "key": "balatro-vouchers", + "path": null, + "frameWidth": 190, + "frameHeight": 250 + }, + "packSheet": { + "key": "balatro-packs", + "path": null, + "frameWidth": 180, + "frameHeight": 240 + }, + "deckBackSheet": { + "key": "balatro-deckbacks", + "path": null, + "frameWidth": 120, + "frameHeight": 164 + }, + "bossIconSheet": { + "key": "balatro-bossicons", + "path": null, + "frameWidth": 64, + "frameHeight": 64 + }, + "jokers": { + "joker": 0, + "greedy": 1, + "lusty": 2, + "wrathful": 3, + "gluttonous": 4, + "jolly": 5, + "zany": 6, + "mad": 7, + "crazy": 8, + "droll": 9, + "halfjoker": 10, + "misprint": 11, + "sly": 12, + "wily": 13, + "clever": 14, + "devious": 15, + "crafty": 16, + "banner": 17, + "stuntman": 18, + "fibonacci": 19, + "scaryface": 20, + "evensteven": 21, + "oddtodd": 22, + "scholar": 23, + "smileyface": 24, + "walkietalkie": 25, + "roughgem": 26, + "bloodstone": 27, + "arrowhead": 28, + "onyxagate": 29, + "blackboard": 30, + "baron": 31, + "photograph": 32, + "ancient": 33, + "cavendish": 34, + "cardsharp": 35, + "acrobat": 36, + "duo": 37, + "trio": 38, + "family": 39, + "order": 40, + "tribe": 41, + "flowerpot": 42, + "runner": 43, + "icecream": 44, + "bluejoker": 45, + "greenjoker": 46, + "ridethebus": 47, + "squarejoker": 48, + "supernova": 49, + "obelisk": 50, + "constellation": 51, + "hologram": 52, + "vampire": 53, + "madness": 54, + "castle": 55, + "weejoker": 56, + "erosion": 57, + "hiker": 58, + "ramen": 59, + "popcorn": 60, + "throwback": 61, + "goldenjoker": 62, + "delayedgrat": 63, + "businesscard": 64, + "faceless": 65, + "cloud9": 66, + "rocket": 67, + "egg": 68, + "tothemoon": 69, + "satellite": 70, + "bull": 71, + "bootstraps": 72, + "goldenticket": 73, + "redcard": 74, + "swashbuckler": 75, + "fortuneteller": 76, + "tradingcard": 77, + "hack": 78, + "dusk": 79, + "sockandbuskin": 80, + "seltzer": 81, + "hangingchad": 82, + "mime": 83, + "juggler": 84, + "drunkard": 85, + "troubadour": 86, + "merryandy": 87, + "turtlebean": 88, + "burglar": 89, + "creditcard": 90, + "chaostheclown": 91, + "oops": 92, + "pareidolia": 93, + "splash": 94, + "smeared": 95, + "shortcut": 96, + "fourfingers": 97, + "stencil": 98, + "eightball": 99, + "superposition": 100, + "vagabond": 101, + "cartomancer": 102, + "mysticsummit": 103, + "loyaltycard": 104, + "raisedfist": 105, + "shootthemoon": 106, + "abstract": 107, + "grosmichel": 108, + "stonejoker": 109, + "baseball": 110, + "dna": 111, + "blueprint": 112, + "brainstorm": 113 + }, + "tarots": { + "fool": 0, + "magician": 1, + "highpriestess": 2, + "empress": 3, + "emperor": 4, + "hierophant": 5, + "lovers": 6, + "chariot": 7, + "justice": 8, + "hermit": 9, + "wheeloffortune": 10, + "strength": 11, + "hangedman": 12, + "death": 13, + "temperance": 14, + "devil": 15, + "tower": 16, + "star": 17, + "moon": 18, + "sun": 19, + "judgement": 20, + "world": 21 + }, + "planets": { + "pluto": 0, + "mercury": 1, + "uranus": 2, + "venus": 3, + "saturn": 4, + "jupiter": 5, + "earth": 6, + "mars": 7, + "neptune": 8, + "planetx": 9, + "ceres": 10, + "eris": 11 + }, + "spectrals": { + "familiar": 0, + "grim": 1, + "incantation": 2, + "talisman": 3, + "dejavu": 4, + "trance": 5, + "medium": 6, + "aura": 7, + "wraith": 8, + "sigil": 9, + "ouija": 10, + "ectoplasm": 11, + "immolate": 12, + "blackhole": 13, + "hex": 14 + }, + "vouchers": { + "overstock": 0, + "overstockplus": 1, + "clearance": 2, + "liquidation": 3, + "hone": 4, + "glowup": 5, + "rerollsurplus": 6, + "rerollglut": 7, + "grabber": 8, + "nachotong": 9, + "wasteful": 10, + "recyclomancy": 11, + "seedmoney": 12, + "moneytree": 13, + "crystalball": 14, + "telescope": 15 + }, + "packs": { + "standard_normal": 0, + "standard_jumbo": 1, + "arcana_normal": 2, + "arcana_jumbo": 3, + "celestial_normal": 4, + "celestial_jumbo": 5, + "buffoon_normal": 6, + "buffoon_jumbo": 7, + "spectral_normal": 8, + "spectral_jumbo": 9 + }, + "deckBacks": { + "red": 0, + "blue": 1, + "yellow": 2, + "green": 3, + "black": 4, + "magic": 5, + "nebula": 6, + "ghost": 7 + }, + "bosses": { + "the_hook": 0, + "the_ox": 1, + "the_house": 2, + "the_wall": 3, + "the_wheel": 4, + "the_arm": 5, + "the_club": 6, + "the_goad": 7, + "the_window": 8, + "the_head": 9, + "the_psychic": 10, + "the_fish": 11, + "the_water": 12, + "the_manacle": 13, + "the_eye": 14, + "the_mouth": 15, + "the_plant": 16, + "the_needle": 17, + "the_flint": 18, + "the_mark": 19, + "the_pillar": 20, + "violet_vessel": 21, + "crimson_heart": 22, + "cerulean_bell": 23 + } +} diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 6100d82..281fc8a 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -106,3 +106,4 @@ registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, registerGame({ slug: 'jumble', name: 'Jumble', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 76 }); registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 77, defaultPlayfield: 'fantasy' }); registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' }); +registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 }); diff --git a/src/games/balatro/BalatroData.js b/src/games/balatro/BalatroData.js new file mode 100644 index 0000000..57a90e7 --- /dev/null +++ b/src/games/balatro/BalatroData.js @@ -0,0 +1,299 @@ +// BalatroData.js +// Static content for Balatro: poker hand table + level curves, ante/blind chip +// requirements (incl. endless), decks, enhancements/editions/seals, tarot / +// planet / spectral consumables, vouchers, booster packs, boss blinds, prices +// and rarity weights. Joker definitions live in BalatroJokers.js; the engine +// is BalatroLogic.js + BalatroScoring.js. + +// ── Cards ──────────────────────────────────────────────────────────────────── +export const SUITS = ['S', 'H', 'D', 'C']; +export const SUIT_NAMES = { S: 'Spades', H: 'Hearts', D: 'Diamonds', C: 'Clubs' }; +export const RANK_NAMES = { 2: '2', 3: '3', 4: '4', 5: '5', 6: '6', 7: '7', 8: '8', 9: '9', 10: '10', 11: 'J', 12: 'Q', 13: 'K', 14: 'A' }; + +export function rankChips(rank) { + if (rank === 14) return 11; + if (rank >= 11) return 10; + return rank; +} +export function isFaceRank(rank) { return rank === 11 || rank === 12 || rank === 13; } + +// ── Poker hands (id order = detection priority, strongest first) ───────────── +// base chips × mult at level 1; each level adds levelChips / levelMult. +export const HAND_TYPES = [ + { id: 'flushfive', name: 'Flush Five', chips: 160, mult: 16, levelChips: 50, levelMult: 3, secret: true }, + { id: 'flushhouse', name: 'Flush House', chips: 140, mult: 14, levelChips: 40, levelMult: 4, secret: true }, + { id: 'fiveofakind', name: 'Five of a Kind', chips: 120, mult: 12, levelChips: 35, levelMult: 3, secret: true }, + { id: 'straightflush', name: 'Straight Flush', chips: 100, mult: 8, levelChips: 40, levelMult: 4 }, + { id: 'fourofakind', name: 'Four of a Kind', chips: 60, mult: 7, levelChips: 30, levelMult: 3 }, + { id: 'fullhouse', name: 'Full House', chips: 40, mult: 4, levelChips: 25, levelMult: 2 }, + { id: 'flush', name: 'Flush', chips: 35, mult: 4, levelChips: 15, levelMult: 2 }, + { id: 'straight', name: 'Straight', chips: 30, mult: 4, levelChips: 30, levelMult: 3 }, + { id: 'threeofakind', name: 'Three of a Kind', chips: 30, mult: 3, levelChips: 20, levelMult: 2 }, + { id: 'twopair', name: 'Two Pair', chips: 20, mult: 2, levelChips: 20, levelMult: 1 }, + { id: 'pair', name: 'Pair', chips: 10, mult: 2, levelChips: 15, levelMult: 1 }, + { id: 'highcard', name: 'High Card', chips: 5, mult: 1, levelChips: 10, levelMult: 1 }, +]; +export const HAND_BY_ID = Object.fromEntries(HAND_TYPES.map((h) => [h.id, h])); + +export function handBase(handId, level) { + const h = HAND_BY_ID[handId]; + const lv = Math.max(1, level | 0); + return { chips: h.chips + h.levelChips * (lv - 1), mult: h.mult + h.levelMult * (lv - 1) }; +} + +// ── Antes & blinds ─────────────────────────────────────────────────────────── +const ANTE_BASE = { 1: 300, 2: 800, 3: 2000, 4: 5000, 5: 11000, 6: 20000, 7: 35000, 8: 50000 }; +const CHIP_CAP = Number.MAX_VALUE / 1e6; // requirements clamp here; a hand at Infinity ends an endless run + +export function anteBase(ante) { + if (ante <= 8) return ANTE_BASE[Math.max(1, ante)]; + const n = ante - 8; + const v = Math.floor(50000 * Math.pow(2.1 + 0.12 * n, n)); + return Number.isFinite(v) ? Math.min(v, CHIP_CAP) : CHIP_CAP; +} + +export const BLINDS = { + small: { name: 'Small Blind', mult: 1, reward: 3, skippable: true }, + big: { name: 'Big Blind', mult: 1.5, reward: 4, skippable: true }, + boss: { name: 'Boss Blind', mult: 2, reward: 5, skippable: false }, +}; + +export function fmtChips(n) { + if (!Number.isFinite(n)) return '∞'; + if (n >= 1e11) return n.toExponential(2).replace('e+', 'e'); + return Math.floor(n).toLocaleString('en-US'); +} + +// ── Run defaults ───────────────────────────────────────────────────────────── +export const DEFAULTS = { + handSize: 8, hands: 4, discards: 3, jokerSlots: 5, consumableSlots: 2, + startGold: 4, interestPer: 5, interestCap: 5, rerollBase: 5, +}; + +// ── Enhancements / editions / seals ────────────────────────────────────────── +export const ENHANCEMENTS = { + bonus: { name: 'Bonus', desc: '+30 Chips when scored' }, + mult: { name: 'Mult', desc: '+4 Mult when scored' }, + wild: { name: 'Wild', desc: 'Counts as every suit' }, + glass: { name: 'Glass', desc: 'x2 Mult when scored; 1 in 4 chance to shatter' }, + steel: { name: 'Steel', desc: 'x1.5 Mult while held in hand' }, + stone: { name: 'Stone', desc: '+50 Chips; no rank or suit; always scores' }, + gold: { name: 'Gold', desc: '$3 if held in hand at end of round' }, + lucky: { name: 'Lucky', desc: '1 in 5 chance for +20 Mult; 1 in 15 chance for $20' }, +}; +export const EDITIONS = { + foil: { name: 'Foil', desc: '+50 Chips', cost: 2 }, + holo: { name: 'Holographic', desc: '+10 Mult', cost: 3 }, + poly: { name: 'Polychrome', desc: 'x1.5 Mult', cost: 5 }, + negative: { name: 'Negative', desc: '+1 Joker slot', cost: 5, jokerOnly: true }, +}; +export const SEALS = { + red: { name: 'Red Seal', desc: 'Retrigger this card once' }, + blue: { name: 'Blue Seal', desc: 'Creates the Planet of the last played hand if held at end of round' }, + gold: { name: 'Gold Seal', desc: '$3 when this card is played and scores' }, + purple: { name: 'Purple Seal', desc: 'Creates a Tarot when discarded' }, +}; + +// ── Decks ──────────────────────────────────────────────────────────────────── +export const DECKS = [ + { id: 'red', name: 'Red Deck', color: 0xd83a3a, desc: '+1 discard every round', + mods: { discards: 1 } }, + { id: 'blue', name: 'Blue Deck', color: 0x3a6ad8, desc: '+1 hand every round', + mods: { hands: 1 } }, + { id: 'yellow', name: 'Yellow Deck', color: 0xd8b23a, desc: 'Start with an extra $10', + mods: { gold: 10 } }, + { id: 'green', name: 'Green Deck', color: 0x3ad86a, desc: 'No interest; $2 per unused hand, $1 per unused discard at end of round', + mods: { noInterest: true, unusedHand: 2, unusedDiscard: 1 } }, + { id: 'black', name: 'Black Deck', color: 0x333344, desc: '+1 Joker slot, -1 hand every round', + mods: { jokerSlots: 1, hands: -1 } }, + { id: 'magic', name: 'Magic Deck', color: 0x8a3ad8, desc: 'Start with the Crystal Ball voucher and 2 copies of The Fool', + mods: { startVouchers: ['crystalball'], startConsumables: [{ kind: 'tarot', id: 'fool' }, { kind: 'tarot', id: 'fool' }] } }, + { id: 'nebula', name: 'Nebula Deck', color: 0x3ac8d8, desc: 'Start with the Telescope voucher, -1 consumable slot', + mods: { startVouchers: ['telescope'], consumableSlots: -1 } }, + { id: 'ghost', name: 'Ghost Deck', color: 0xbfc8e8, desc: 'Spectral cards may appear in the shop; start with a Hex', + mods: { shopSpectral: true, startConsumables: [{ kind: 'spectral', id: 'hex' }] } }, +]; +export const DECK_BY_ID = Object.fromEntries(DECKS.map((d) => [d.id, d])); + +// ── Consumables ────────────────────────────────────────────────────────────── +// Declarative `effect` specs interpreted by BalatroLogic.useConsumable. +// `targets: n` = the player must select up to n cards in hand first. +export const TAROTS = [ + { id: 'fool', name: 'The Fool', effect: { kind: 'fool' }, desc: 'Creates a copy of the last Tarot or Planet used this run (The Fool excluded)' }, + { id: 'magician', name: 'The Magician', targets: 2, effect: { kind: 'enhance', enh: 'lucky' }, desc: 'Enhances up to 2 selected cards to Lucky Cards' }, + { id: 'highpriestess', name: 'The High Priestess', effect: { kind: 'create', what: 'planet', count: 2 }, desc: 'Creates up to 2 random Planet cards' }, + { id: 'empress', name: 'The Empress', targets: 2, effect: { kind: 'enhance', enh: 'mult' }, desc: 'Enhances up to 2 selected cards to Mult Cards' }, + { id: 'emperor', name: 'The Emperor', effect: { kind: 'create', what: 'tarot', count: 2 }, desc: 'Creates up to 2 random Tarot cards' }, + { id: 'hierophant', name: 'The Hierophant', targets: 2, effect: { kind: 'enhance', enh: 'bonus' }, desc: 'Enhances up to 2 selected cards to Bonus Cards' }, + { id: 'lovers', name: 'The Lovers', targets: 1, effect: { kind: 'enhance', enh: 'wild' }, desc: 'Enhances 1 selected card into a Wild Card' }, + { id: 'chariot', name: 'The Chariot', targets: 1, effect: { kind: 'enhance', enh: 'steel' }, desc: 'Enhances 1 selected card into a Steel Card' }, + { id: 'justice', name: 'Justice', targets: 1, effect: { kind: 'enhance', enh: 'glass' }, desc: 'Enhances 1 selected card into a Glass Card' }, + { id: 'hermit', name: 'The Hermit', effect: { kind: 'doubleMoney', max: 20 }, desc: 'Doubles money (max +$20)' }, + { id: 'wheeloffortune', name: 'Wheel of Fortune', effect: { kind: 'wheel' }, desc: '1 in 4 chance to add Foil, Holographic, or Polychrome to a random Joker' }, + { id: 'strength', name: 'Strength', targets: 2, effect: { kind: 'rankUp' }, desc: 'Raises the rank of up to 2 selected cards by 1' }, + { id: 'hangedman', name: 'The Hanged Man', targets: 2, effect: { kind: 'destroy' }, desc: 'Destroys up to 2 selected cards' }, + { id: 'death', name: 'Death', targets: 2, effect: { kind: 'death' }, desc: 'Select 2 cards: the left card becomes a copy of the right card' }, + { id: 'temperance', name: 'Temperance', effect: { kind: 'temperance', max: 50 }, desc: 'Gives the total sell value of all current Jokers (max $50)' }, + { id: 'devil', name: 'The Devil', targets: 1, effect: { kind: 'enhance', enh: 'gold' }, desc: 'Enhances 1 selected card into a Gold Card' }, + { id: 'tower', name: 'The Tower', targets: 1, effect: { kind: 'enhance', enh: 'stone' }, desc: 'Enhances 1 selected card into a Stone Card' }, + { id: 'star', name: 'The Star', targets: 3, effect: { kind: 'suit', suit: 'D' }, desc: 'Converts up to 3 selected cards to Diamonds' }, + { id: 'moon', name: 'The Moon', targets: 3, effect: { kind: 'suit', suit: 'C' }, desc: 'Converts up to 3 selected cards to Clubs' }, + { id: 'sun', name: 'The Sun', targets: 3, effect: { kind: 'suit', suit: 'H' }, desc: 'Converts up to 3 selected cards to Hearts' }, + { id: 'judgement', name: 'Judgement', effect: { kind: 'create', what: 'joker', count: 1 }, desc: 'Creates a random Joker (must have room)' }, + { id: 'world', name: 'The World', targets: 3, effect: { kind: 'suit', suit: 'S' }, desc: 'Converts up to 3 selected cards to Spades' }, +]; + +export const PLANETS = [ + { id: 'pluto', name: 'Pluto', hand: 'highcard' }, + { id: 'mercury', name: 'Mercury', hand: 'pair' }, + { id: 'uranus', name: 'Uranus', hand: 'twopair' }, + { id: 'venus', name: 'Venus', hand: 'threeofakind' }, + { id: 'saturn', name: 'Saturn', hand: 'straight' }, + { id: 'jupiter', name: 'Jupiter', hand: 'flush' }, + { id: 'earth', name: 'Earth', hand: 'fullhouse' }, + { id: 'mars', name: 'Mars', hand: 'fourofakind' }, + { id: 'neptune', name: 'Neptune', hand: 'straightflush' }, + { id: 'planetx', name: 'Planet X', hand: 'fiveofakind', secret: true }, + { id: 'ceres', name: 'Ceres', hand: 'flushhouse', secret: true }, + { id: 'eris', name: 'Eris', hand: 'flushfive', secret: true }, +]; +export const PLANET_BY_HAND = Object.fromEntries(PLANETS.map((p) => [p.hand, p])); + +export const SPECTRALS = [ + { id: 'familiar', name: 'Familiar', effect: { kind: 'spectralAdd', destroy: 1, add: 3, addKind: 'face' }, desc: 'Destroy 1 random held card; add 3 random enhanced face cards to your hand' }, + { id: 'grim', name: 'Grim', effect: { kind: 'spectralAdd', destroy: 1, add: 2, addKind: 'ace' }, desc: 'Destroy 1 random held card; add 2 random enhanced Aces to your hand' }, + { id: 'incantation', name: 'Incantation', effect: { kind: 'spectralAdd', destroy: 1, add: 4, addKind: 'number' }, desc: 'Destroy 1 random held card; add 4 random enhanced numbered cards to your hand' }, + { id: 'talisman', name: 'Talisman', targets: 1, effect: { kind: 'seal', seal: 'gold' }, desc: 'Adds a Gold Seal to 1 selected card' }, + { id: 'dejavu', name: 'Deja Vu', targets: 1, effect: { kind: 'seal', seal: 'red' }, desc: 'Adds a Red Seal to 1 selected card' }, + { id: 'trance', name: 'Trance', targets: 1, effect: { kind: 'seal', seal: 'blue' }, desc: 'Adds a Blue Seal to 1 selected card' }, + { id: 'medium', name: 'Medium', targets: 1, effect: { kind: 'seal', seal: 'purple' }, desc: 'Adds a Purple Seal to 1 selected card' }, + { id: 'aura', name: 'Aura', targets: 1, effect: { kind: 'aura' }, desc: 'Adds Foil, Holographic, or Polychrome to 1 selected card' }, + { id: 'wraith', name: 'Wraith', effect: { kind: 'wraith' }, desc: 'Creates a random Rare Joker; sets money to $0' }, + { id: 'sigil', name: 'Sigil', effect: { kind: 'sigil' }, desc: 'Converts all cards in hand to a single random suit' }, + { id: 'ouija', name: 'Ouija', effect: { kind: 'ouija' }, desc: 'Converts all cards in hand to a single random rank; -1 hand size' }, + { id: 'ectoplasm', name: 'Ectoplasm', effect: { kind: 'ectoplasm' }, desc: 'Adds Negative to a random Joker; -1 hand size' }, + { id: 'immolate', name: 'Immolate', effect: { kind: 'immolate' }, desc: 'Destroys 5 random cards in hand; gain $20' }, + { id: 'blackhole', name: 'Black Hole', effect: { kind: 'blackhole' }, desc: 'Upgrades every poker hand by 1 level' }, + { id: 'hex', name: 'Hex', effect: { kind: 'hex' }, desc: 'Adds Polychrome to a random Joker; destroys all other Jokers' }, +]; + +export const TAROT_BY_ID = Object.fromEntries(TAROTS.map((t) => [t.id, t])); +export const PLANET_BY_ID = Object.fromEntries(PLANETS.map((p) => [p.id, p])); +export const SPECTRAL_BY_ID = Object.fromEntries(SPECTRALS.map((s) => [s.id, s])); + +// ── Vouchers (8 two-tier chains; tier 2 offered only once tier 1 is owned) ─── +export const VOUCHERS = [ + { id: 'overstock', name: 'Overstock', tier: 1, desc: '+1 card slot in the shop', mods: { shopCards: 1 } }, + { id: 'overstockplus', name: 'Overstock Plus', tier: 2, requires: 'overstock', desc: '+1 more card slot in the shop', mods: { shopCards: 1 } }, + { id: 'clearance', name: 'Clearance Sale', tier: 1, desc: 'All shop items 25% off', mods: { discount: 0.25 } }, + { id: 'liquidation', name: 'Liquidation', tier: 2, requires: 'clearance', desc: 'All shop items 50% off', mods: { discount: 0.5 } }, + { id: 'hone', name: 'Hone', tier: 1, desc: 'Foil, Holographic and Polychrome cards appear 2x more often', mods: { editionOdds: 2 } }, + { id: 'glowup', name: 'Glow Up', tier: 2, requires: 'hone', desc: 'Foil, Holographic and Polychrome cards appear 4x more often', mods: { editionOdds: 2 } }, + { id: 'rerollsurplus', name: 'Reroll Surplus', tier: 1, desc: 'Rerolls cost $2 less', mods: { rerollOff: 2 } }, + { id: 'rerollglut', name: 'Reroll Glut', tier: 2, requires: 'rerollsurplus', desc: 'Rerolls cost $2 less again', mods: { rerollOff: 2 } }, + { id: 'grabber', name: 'Grabber', tier: 1, desc: '+1 hand every round', mods: { hands: 1 } }, + { id: 'nachotong', name: 'Nacho Tong', tier: 2, requires: 'grabber', desc: '+1 more hand every round', mods: { hands: 1 } }, + { id: 'wasteful', name: 'Wasteful', tier: 1, desc: '+1 discard every round', mods: { discards: 1 } }, + { id: 'recyclomancy', name: 'Recyclomancy', tier: 2, requires: 'wasteful', desc: '+1 more discard every round', mods: { discards: 1 } }, + { id: 'seedmoney', name: 'Seed Money', tier: 1, desc: 'Raises the interest cap to $10', mods: { interestCap: 10 } }, + { id: 'moneytree', name: 'Money Tree', tier: 2, requires: 'seedmoney', desc: 'Raises the interest cap to $20', mods: { interestCap: 20 } }, + { id: 'crystalball', name: 'Crystal Ball', tier: 1, desc: '+1 consumable slot', mods: { consumableSlots: 1 } }, + { id: 'telescope', name: 'Telescope', tier: 2, requires: 'crystalball', desc: 'Celestial packs always contain the Planet of your most played hand', mods: { telescope: true } }, +]; +export const VOUCHER_BY_ID = Object.fromEntries(VOUCHERS.map((v) => [v.id, v])); +export const VOUCHER_COST = 10; + +// ── Booster packs ──────────────────────────────────────────────────────────── +export const BOOSTERS = [ + { id: 'standard_normal', name: 'Standard Pack', what: 'playing', options: 3, picks: 1, cost: 4 }, + { id: 'standard_jumbo', name: 'Jumbo Standard Pack', what: 'playing', options: 5, picks: 1, cost: 6 }, + { id: 'arcana_normal', name: 'Arcana Pack', what: 'tarot', options: 3, picks: 1, cost: 4 }, + { id: 'arcana_jumbo', name: 'Jumbo Arcana Pack', what: 'tarot', options: 5, picks: 1, cost: 6 }, + { id: 'celestial_normal', name: 'Celestial Pack', what: 'planet', options: 3, picks: 1, cost: 4 }, + { id: 'celestial_jumbo', name: 'Jumbo Celestial Pack', what: 'planet', options: 5, picks: 1, cost: 6 }, + { id: 'buffoon_normal', name: 'Buffoon Pack', what: 'joker', options: 2, picks: 1, cost: 4 }, + { id: 'buffoon_jumbo', name: 'Jumbo Buffoon Pack', what: 'joker', options: 3, picks: 1, cost: 6 }, + { id: 'spectral_normal', name: 'Spectral Pack', what: 'spectral', options: 2, picks: 1, cost: 4 }, + { id: 'spectral_jumbo', name: 'Jumbo Spectral Pack', what: 'spectral', options: 3, picks: 1, cost: 6 }, +]; +export const BOOSTER_BY_ID = Object.fromEntries(BOOSTERS.map((b) => [b.id, b])); +// Weighted roll for the shop's two pack slots. +export const BOOSTER_WEIGHTS = [ + ['standard_normal', 20], ['standard_jumbo', 8], + ['arcana_normal', 20], ['arcana_jumbo', 8], + ['celestial_normal', 20], ['celestial_jumbo', 8], + ['buffoon_normal', 10], ['buffoon_jumbo', 4], + ['spectral_normal', 3], ['spectral_jumbo', 1], +]; + +// ── Shop odds & prices ─────────────────────────────────────────────────────── +export const SHOP_CARD_WEIGHTS = { joker: 68, tarot: 16, planet: 12, playing: 4 }; +export const SHOP_SPECTRAL_WEIGHT = 8; // added for Ghost deck +export const RARITY_WEIGHTS = { common: 62, uncommon: 30, rare: 8 }; +export const CONSUMABLE_PRICES = { tarot: 3, planet: 3, spectral: 4 }; +// Base odds that a shop joker rolls an edition (multiplied by Hone/Glow Up). +export const SHOP_EDITION_ODDS = { foil: 0.02, holo: 0.014, poly: 0.006, negative: 0.003 }; + +// ── Boss blinds ────────────────────────────────────────────────────────────── +// Hooks are pure helpers over run/bossState; BalatroLogic invokes them at the +// matching points and provides `api` = { rng, discardRandom, log }. +// cardDebuffed is evaluated lazily (every render/score) so late Wild cards etc. +// stay correct. Transient card flags (faceDown/debuffed) reset at blind end. +function suitDebuff(suit) { + return (run, card, effSuits) => !card.debuffed && card.enhancement !== 'stone' && effSuits(card).includes(suit); +} + +export const BOSSES = [ + { id: 'the_hook', name: 'The Hook', minAnte: 1, desc: 'Discards 2 random cards per hand played', + afterHandPlayed: (run, api) => api.discardRandom(run, 2) }, + { id: 'the_ox', name: 'The Ox', minAnte: 6, desc: 'Playing your most played hand sets money to $0', + afterHandPlayed: (run, api, info) => { if (info.handType === api.mostPlayedHand(run)) run.gold = 0; } }, + { id: 'the_house', name: 'The House', minAnte: 2, desc: 'First hand is drawn face down', + drawTransform: (run, card, api) => { if (api.initial) card.faceDown = true; } }, + { id: 'the_wall', name: 'The Wall', minAnte: 2, reqMult: 4, desc: 'Extra large blind' }, + { id: 'the_wheel', name: 'The Wheel', minAnte: 2, desc: '1 in 7 cards get drawn face down', + drawTransform: (run, card, api) => { if (api.rng.int(7) === 0) card.faceDown = true; } }, + { id: 'the_arm', name: 'The Arm', minAnte: 2, desc: 'Decreases level of played poker hand', + preScore: true }, // handled in scoring: level-1 (min 1) before base lookup + { id: 'the_club', name: 'The Club', minAnte: 1, desc: 'All Club cards are debuffed', cardDebuffed: suitDebuff('C') }, + { id: 'the_goad', name: 'The Goad', minAnte: 1, desc: 'All Spade cards are debuffed', cardDebuffed: suitDebuff('S') }, + { id: 'the_window', name: 'The Window', minAnte: 1, desc: 'All Diamond cards are debuffed', cardDebuffed: suitDebuff('D') }, + { id: 'the_head', name: 'The Head', minAnte: 1, desc: 'All Heart cards are debuffed', cardDebuffed: suitDebuff('H') }, + { id: 'the_psychic', name: 'The Psychic', minAnte: 1, desc: 'Must play 5 cards', + validPlay: (run, uids) => (uids.length === 5 ? null : 'Must play exactly 5 cards') }, + { id: 'the_fish', name: 'The Fish', minAnte: 2, desc: 'Cards drawn face down after each hand played', + drawTransform: (run, card) => { if (run.bossState.handsPlayed > 0) card.faceDown = true; } }, + { id: 'the_water', name: 'The Water', minAnte: 2, desc: 'Start with 0 discards', + onBlindStart: (run) => { run.discardsLeft = 0; } }, + { id: 'the_manacle', name: 'The Manacle', minAnte: 1, desc: '-1 hand size', + handSizeDelta: -1 }, + { id: 'the_eye', name: 'The Eye', minAnte: 3, desc: 'No repeat hand types this round', + validPlay: (run, uids, handType) => (run.handTypesPlayedThisRound.includes(handType) ? 'No repeat hand types this round' : null) }, + { id: 'the_mouth', name: 'The Mouth', minAnte: 2, desc: 'Only one hand type can be played this round', + validPlay: (run, uids, handType) => { + const played = run.handTypesPlayedThisRound; + return (played.length && handType !== played[0]) ? `Only ${played[0]} can be played this round` : null; + } }, + { id: 'the_plant', name: 'The Plant', minAnte: 4, desc: 'All face cards are debuffed', + cardDebuffed: (run, card, effSuits, isFace) => isFace(card) }, + { id: 'the_needle', name: 'The Needle', minAnte: 2, reqMult: 1, desc: 'Play only 1 hand', + onBlindStart: (run) => { run.handsLeft = 1; } }, + { id: 'the_flint', name: 'The Flint', minAnte: 2, desc: 'Base Chips and Mult are halved', + preScore: true }, // handled in scoring: halve base chips/mult (round up) + { id: 'the_mark', name: 'The Mark', minAnte: 2, desc: 'All face cards are drawn face down', + drawTransform: (run, card, api) => { if (api.isFace(card)) card.faceDown = true; } }, + { id: 'the_pillar', name: 'The Pillar', minAnte: 1, desc: 'Cards played previously this ante are debuffed', + cardDebuffed: (run, card) => run.playedUidsThisAnte.includes(card.uid) }, +]; + +export const FINISHERS = [ + { id: 'violet_vessel', name: 'Violet Vessel', reqMult: 6, desc: 'Very large blind' }, + { id: 'crimson_heart', name: 'Crimson Heart', reqMult: 2, desc: 'One random Joker disabled every hand', + onBlindStart: (run, api) => api.disableRandomJoker(run), + afterHandPlayed: (run, api) => api.disableRandomJoker(run) }, + { id: 'cerulean_bell', name: 'Cerulean Bell', reqMult: 2, desc: 'Forces 1 card to always be selected', + onBlindStart: (run, api) => api.forceRandomCard(run), + afterHandPlayed: (run, api) => api.forceRandomCard(run) }, +]; + +export const BOSS_BY_ID = Object.fromEntries([...BOSSES, ...FINISHERS].map((b) => [b.id, b])); diff --git a/src/games/balatro/BalatroGame.js b/src/games/balatro/BalatroGame.js new file mode 100644 index 0000000..81b7788 --- /dev/null +++ b/src/games/balatro/BalatroGame.js @@ -0,0 +1,930 @@ +// BalatroGame.js +// Phaser scene for Balatro: view dispatch, the play table (hand, jokers, +// consumables, score panel), and the trace-replay scoring animation. All +// rules live in BalatroLogic/BalatroScoring — this file only renders state, +// forwards input, and replays the event traces the engine returns. +// Non-play views (title, deck select, blind select, shop, packs, game over) +// live in BalatroViews.js. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { api } from '../../services/api.js'; +import * as store from '../../services/localStore.js'; +import { + SUIT_NAMES, RANK_NAMES, HAND_BY_ID, handBase, fmtChips, BLINDS, DECK_BY_ID, + ENHANCEMENTS, EDITIONS, SEALS, TAROT_BY_ID, PLANET_BY_ID, SPECTRAL_BY_ID, + VOUCHER_BY_ID, BOOSTER_BY_ID, BOSS_BY_ID, +} from './BalatroData.js'; +import { JOKER_BY_ID } from './BalatroJokers.js'; +import { evaluate } from './BalatroScoring.js'; +import { + newRun, serializeRun, deserializeRun, passives, cardsOf, handCards, sortHand, + selectBlind, skipBlind, blindChipsFor, bossFor, playHand, discard, anyLegalPlay, + forfeitRound, collectCashOut, continueEndless, leaveShop, rerollShop, + buyShopCard, buyVoucher, sellJoker, sellConsumable, buyPack, pickFromPack, + skipPack, useConsumable, consumableDef, isDebuffed, +} from './BalatroLogic.js'; +import { makeSwirlBackground } from './BalatroSwirlPipeline.js'; +import { + renderTitle, renderDeckSelect, renderBlindSelect, renderShop, renderPackOpen, + renderGameOver, renderCashout, +} from './BalatroViews.js'; + +const SAVE_KEY = 'balatro:run'; +const STATS_KEY = 'balatro:stats'; + +// palette +export const C = { + ink: '#f4efe6', muted: '#b9b2c8', dark: '#141019', + panel: 0x1b1524, panelEdge: 0x3d3355, + chips: 0x2f7fd4, chipsHex: '#5aa9f4', mult: 0xd4372f, multHex: '#ff6b5e', + gold: 0xe7c14b, goldHex: '#e7c14b', money: '#f0d060', + cardFace: 0xf6f1e6, red: '#c8342c', black: '#2b2733', + rarity: { common: 0x4f9ad4, uncommon: 0x4fae6a, rare: 0xd44f4f }, + tarot: 0x8a5fd0, planet: 0x3f9ad0, spectral: 0x4fc8c0, voucher: 0x3fae6a, +}; +export const SUIT_GLYPH = { S: '♠', H: '♥', D: '♦', C: '♣' }; +const SUIT_COLOR = { S: '#2b2733', C: '#2b3a63', H: '#c8342c', D: '#d07a2c' }; + +export default class BalatroGame extends Phaser.Scene { + constructor() { super('BalatroGame'); } + + init(data) { + this.gameDef = data?.game ?? { slug: 'balatro', name: 'Balatro' }; + this.run = null; + this.view = 'title'; + this.selected = []; + this.animating = false; + this.pendingConsumable = null; + this.pendingJoker = null; + this._handSprites = {}; + this._playedSprites = {}; + this._jokerSprites = {}; + this._toastText = null; + this._recorded = false; + } + + create() { + try { const m = this.cache.json.get('music'); if (m?.tracks) new MusicPlayer(this, m.tracks); } catch (_) {} + this.art = this.cache.json.get('balatro-artwork') || {}; + + if (!this.textures.exists('balatro-spark')) { + const g = this.add.graphics(); + g.fillStyle(0xffffff, 1); g.fillCircle(4, 4, 4); + g.generateTexture('balatro-spark', 8, 8); + g.destroy(); + } + + this.bgLayer = this.add.container(0, 0); + this.viewLayer = this.add.container(0, 0).setDepth(10); + this.handLayer = this.add.container(0, 0).setDepth(40); + this.fxLayer = this.add.container(0, 0).setDepth(80); + this.swirl = makeSwirlBackground(this, this.bgLayer); + + // Fast-forward: tapping during a scoring animation snaps to the end. + this.input.on('pointerdown', () => { if (this.animating) this._skipAnim = true; }); + + const saved = store.read(SAVE_KEY); + if (saved) { + const run = deserializeRun(saved); + if (run && run.phase !== 'over') this._savedRun = run; + else store.write(SAVE_KEY, null); + } + this.renderView(); + } + + // ── persistence / results ───────────────────────────────────────────────── + save() { + if (!this.run) return; + if (this.run.phase === 'over') { this.onRunOver(); return; } + store.write(SAVE_KEY, serializeRun(this.run)); + } + + onRunOver() { + store.write(SAVE_KEY, null); + if (this._recorded || !this.run) return; + this._recorded = true; + const run = this.run; + const stats = store.read(STATS_KEY) || { wins: 0, losses: 0, runs: 0, bestAnte: 0, bestHandScore: 0, bestRoundScore: 0, winsByDeck: {} }; + stats.runs += 1; + if (run.won) { stats.wins += 1; stats.winsByDeck[run.deckId] = (stats.winsByDeck[run.deckId] || 0) + 1; } + else stats.losses += 1; + stats.bestAnte = Math.max(stats.bestAnte, run.stats.bestAnte); + stats.bestHandScore = Math.max(stats.bestHandScore, run.stats.bestHandScore); + stats.bestRoundScore = Math.max(stats.bestRoundScore, run.stats.bestRoundScore); + store.write(STATS_KEY, stats); + const antesCleared = run.won ? Math.max(8, run.ante - 1) : run.ante - 1; + api.post('/history/single-player', { + slug: 'balatro', + score: Math.min(100000, Math.max(0, antesCleared * 1000 + run.round)), + opponentScores: [], + result: run.won ? 'win' : 'loss', + }).catch(() => {}); + } + + startRun(deckId) { + this.run = newRun(deckId); + this._recorded = false; + this._savedRun = null; + this.save(); + this.setView('blindselect'); + } + + continueRun() { + if (!this._savedRun) return; + this.run = this._savedRun; + this._savedRun = null; + this._recorded = false; + const phase = this.run.phase; + this.setView(phase === 'playing' ? 'play' : phase === 'cashout' ? 'cashout' : phase === 'shop' ? (this.run.pack ? 'packopen' : 'shop') : 'blindselect'); + } + + // ── view plumbing ───────────────────────────────────────────────────────── + clearView() { + this.viewLayer.removeAll(true); + this.handLayer.removeAll(true); + this.fxLayer.removeAll(true); + this._handSprites = {}; + this._playedSprites = {}; + this._jokerSprites = {}; + this._toastText = null; + } + + setView(v) { + this.view = v; + this.selected = []; + this.pendingConsumable = null; + this.pendingJoker = null; + this.renderView(); + } + + paletteFor() { + if (!this.run) return this.view === 'deckselect' ? 'title' : 'title'; + if (this.view === 'gameover') return this.run.won ? 'win' : 'lose'; + if (this.view === 'shop') return 'shop'; + if (this.view === 'packopen') return 'pack'; + if (this.view === 'play' || this.view === 'cashout' || this.view === 'blindselect') { + return this.run.blind === 'boss' ? 'boss' : this.run.blind; + } + return 'title'; + } + + renderView() { + this.clearView(); + if (this.swirl) this.swirl.setPalette(this.paletteFor()); + switch (this.view) { + case 'title': return renderTitle(this); + case 'deckselect': return renderDeckSelect(this); + case 'blindselect': return renderBlindSelect(this); + case 'play': return this.renderPlay(); + case 'cashout': { this.renderPlay(true); return renderCashout(this); } + case 'shop': return renderShop(this); + case 'packopen': return renderPackOpen(this); + case 'gameover': return renderGameOver(this); + default: return renderTitle(this); + } + } + + // ── tiny UI helpers ─────────────────────────────────────────────────────── + add2(obj, layer = this.viewLayer) { layer.add(obj); return obj; } + + text(x, y, str, size, color = C.ink, opts = {}) { + const t = this.add.text(x, y, str, { + fontFamily: opts.font || '"Julius Sans One"', fontSize: `${size}px`, color, + align: opts.align || 'left', wordWrap: opts.wrap ? { width: opts.wrap } : undefined, + fontStyle: opts.bold ? 'bold' : 'normal', + }).setOrigin(opts.ox ?? 0, opts.oy ?? 0); + return this.add2(t, opts.layer); + } + + panel(x, y, w, h, opts = {}) { + const g = this.add.graphics(); + g.fillStyle(opts.fill ?? C.panel, opts.alpha ?? 0.92); + g.fillRoundedRect(x, y, w, h, opts.radius ?? 14); + if (opts.stroke !== false) { + g.lineStyle(opts.strokeW ?? 2, opts.strokeColor ?? C.panelEdge, 1); + g.strokeRoundedRect(x, y, w, h, opts.radius ?? 14); + } + return this.add2(g, opts.layer); + } + + button(x, y, label, onClick, opts = {}) { + const b = new Button(this, x, y, label, () => { if (!this.animating) onClick(); }, opts); + this.add.existing(b); + return this.add2(b, opts.layer); + } + + toast(msg) { + if (this._toastText) this._toastText.destroy(); + const t = this.add.text(GAME_WIDTH / 2, 640, msg, { + fontFamily: '"Julius Sans One"', fontSize: '30px', color: '#ffd2c8', + backgroundColor: '#301418', padding: { x: 18, y: 10 }, + }).setOrigin(0.5).setDepth(90); + this.fxLayer.add(t); + this._toastText = t; + this.tweens.add({ targets: t, alpha: 0, delay: 1600, duration: 400, onComplete: () => { if (t.active) t.destroy(); } }); + } + + artFrame(sheetKey, mapKey, id) { + const sheet = this.art[sheetKey]; + const map = this.art[mapKey]; + if (!sheet || !map || map[id] === undefined) return null; + if (!this.textures.exists(sheet.key)) return null; + return { key: sheet.key, frame: map[id] }; + } + + // ── card rendering ──────────────────────────────────────────────────────── + // Playing card: procedural Balatro-style face + enhancement / edition / seal + // dressing. Returns a container sized w×h centered on (x, y). + drawPlayingCard(x, y, card, opts = {}) { + const w = opts.w || 150, h = opts.h || 204; + const cont = this.add.container(x, y); + const g = this.add.graphics(); + const debuffed = opts.debuffed; + + if (card.faceDown && !opts.reveal) { + if (this.textures.exists('cardbacks')) { + const img = this.add.image(0, 0, 'cardbacks', 0).setDisplaySize(w, h); + cont.add(img); + } else { + g.fillStyle(0x28204a, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); + g.lineStyle(3, 0x4a3f7a, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + cont.add(g); + } + cont.setSize(w, h); + return cont; + } + + // Base face by enhancement + const enh = card.enhancement; + let face = C.cardFace; + if (enh === 'stone') face = 0x7d7a74; + if (enh === 'gold') face = 0xd8b74a; + if (enh === 'steel') face = 0xb9c2cc; + if (enh === 'glass') face = 0xdfeef4; + g.fillStyle(face, enh === 'glass' ? 0.72 : 1); + g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); + if (enh === 'bonus') { g.fillStyle(0x3f7fd0, 0.25); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); } + if (enh === 'mult') { g.fillStyle(0xd4372f, 0.22); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); } + if (enh === 'lucky') { g.fillStyle(0x4fae6a, 0.18); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); } + if (enh === 'wild') { g.fillGradientStyle(0xd07a2c, 0x8a5fd0, 0x3f9ad0, 0x4fae6a, 0.35); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); } + // Edition border + const edgeColor = card.edition === 'poly' ? 0xd05fd0 : card.edition === 'holo' ? 0x5fd0d0 : card.edition === 'foil' ? 0x5f8ad0 : 0x9a917d; + g.lineStyle(card.edition ? 4 : 2, edgeColor, 1); + g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + cont.add(g); + + if (enh !== 'stone') { + const color = SUIT_COLOR[card.suit]; + const rank = this.add.text(-w / 2 + 10, -h / 2 + 6, RANK_NAMES[card.rank], { + fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.21)}px`, color, fontStyle: 'bold', + }); + const suitSm = this.add.text(-w / 2 + 12, -h / 2 + 8 + h * 0.21, SUIT_GLYPH[card.suit], { + fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.16)}px`, color, + }); + const suitBig = this.add.text(w * 0.16, h * 0.16, SUIT_GLYPH[card.suit], { + fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.42)}px`, color, + }).setOrigin(0.5).setAlpha(0.9); + cont.add([rank, suitSm, suitBig]); + if (enh === 'wild') { + const wl = this.add.text(0, h * 0.38, 'WILD', { fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.1)}px`, color: '#5a3a10', fontStyle: 'bold' }).setOrigin(0.5); + cont.add(wl); + } + } else { + const st = this.add.text(0, 0, '◼', { fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.32)}px`, color: '#4c4a45' }).setOrigin(0.5); + const lbl = this.add.text(0, h * 0.3, 'STONE', { fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.1)}px`, color: '#3d3b37' }).setOrigin(0.5); + cont.add([st, lbl]); + } + if (card.seal) { + const sealColor = { red: 0xd4372f, blue: 0x3f7fd0, gold: 0xd8b23a, purple: 0x8a5fd0 }[card.seal]; + const dot = this.add.circle(w / 2 - 18, -h / 2 + 18, 11, sealColor, 1).setStrokeStyle(2, 0xffffff, 0.7); + cont.add(dot); + } + if (card.edition) { + const tag = this.add.text(0, -h / 2 + 4, EDITIONS[card.edition].name.toUpperCase(), { + fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.07)}px`, color: '#ffffff', + }).setOrigin(0.5, 0).setAlpha(0.85); + cont.add(tag); + } + if (card.permaChips) { + const pc = this.add.text(-w / 2 + 8, h / 2 - 26, `+${card.permaChips}`, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.round(h * 0.09)}px`, color: C.chipsHex, + }); + cont.add(pc); + } + if (debuffed) { + const shade = this.add.rectangle(0, 0, w, h, 0x1a1420, 0.62); + const xg = this.add.text(0, 0, '✕', { fontFamily: 'Georgia, serif', fontSize: `${Math.round(h * 0.3)}px`, color: '#8a4550' }).setOrigin(0.5); + cont.add([shade, xg]); + } + cont.setSize(w, h); + return cont; + } + + // Joker card: art frame when supplied, else a procedural card with rarity + // band + ability text. `inst` optional (owned joker), else def-only (shop). + drawJokerCard(x, y, id, opts = {}) { + const def = JOKER_BY_ID[id]; + const w = opts.w || 150, h = opts.h || 200; + const cont = this.add.container(x, y); + const af = this.artFrame('jokerSheet', 'jokers', id); + if (af) { + const img = this.add.image(0, 0, af.key, af.frame).setDisplaySize(w, h); + cont.add(img); + } else { + const g = this.add.graphics(); + g.fillStyle(0x241d33, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); + g.fillStyle(C.rarity[def.rarity], 0.9); g.fillRoundedRect(-w / 2, -h / 2, w, 30, { tl: 10, tr: 10, bl: 0, br: 0 }); + g.lineStyle(2, C.rarity[def.rarity], 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + cont.add(g); + const name = this.add.text(0, -h / 2 + 15, def.name, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.min(17, Math.round(280 / def.name.length))}px`, color: '#141019', fontStyle: 'bold', + }).setOrigin(0.5); + const desc = typeof def.desc === 'function' ? def.desc(opts.inst || { state: def.initState ? def.initState() : {} }) : def.desc; + const body = this.add.text(0, 8, desc, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: C.ink, align: 'center', + wordWrap: { width: w - 16 }, + }).setOrigin(0.5); + cont.add([name, body]); + } + const edition = opts.edition ?? (opts.inst && opts.inst.edition); + if (edition) { + const edColor = { foil: 0x5f8ad0, holo: 0x5fd0d0, poly: 0xd05fd0, negative: 0x282038 }[edition]; + const eg = this.add.graphics(); + eg.lineStyle(4, edColor, 0.95); eg.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + cont.add(eg); + const tag = this.add.text(0, h / 2 - 16, EDITIONS[edition].name.toUpperCase(), { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: '#ffffff', + }).setOrigin(0.5).setAlpha(0.9); + cont.add(tag); + if (edition === 'negative') cont.setAlpha(0.8); + } + if (opts.inst && opts.inst.debuffedByBoss) { + cont.add(this.add.rectangle(0, 0, w, h, 0x1a1420, 0.6)); + cont.add(this.add.text(0, 0, 'DISABLED', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#c86a6a' }).setOrigin(0.5)); + } + cont.setSize(w, h); + return cont; + } + + // Tarot / planet / spectral card (also used for shop + packs). + drawConsumableCard(x, y, kind, id, opts = {}) { + const w = opts.w || 150, h = opts.h || 200; + const def = kind === 'tarot' ? TAROT_BY_ID[id] : kind === 'planet' ? PLANET_BY_ID[id] : SPECTRAL_BY_ID[id]; + const cont = this.add.container(x, y); + const sheet = { tarot: ['tarotSheet', 'tarots'], planet: ['planetSheet', 'planets'], spectral: ['spectralSheet', 'spectrals'] }[kind]; + const af = this.artFrame(sheet[0], sheet[1], id); + if (af) { + cont.add(this.add.image(0, 0, af.key, af.frame).setDisplaySize(w, h)); + } else { + const tint = { tarot: C.tarot, planet: C.planet, spectral: C.spectral }[kind]; + const g = this.add.graphics(); + g.fillStyle(0x18131f, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 10); + g.lineStyle(3, tint, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + g.lineStyle(1, tint, 0.6); g.strokeRoundedRect(-w / 2 + 6, -h / 2 + 6, w - 12, h - 12, 8); + cont.add(g); + const name = this.add.text(0, -h / 2 + 20, def.name, { + fontFamily: '"Julius Sans One"', fontSize: `${Math.min(16, Math.round(300 / def.name.length))}px`, + color: `#${tint.toString(16).padStart(6, '0')}`, fontStyle: 'bold', align: 'center', wordWrap: { width: w - 14 }, + }).setOrigin(0.5, 0); + const descText = kind === 'planet' ? `+1 level: ${HAND_BY_ID[def.hand].name}` : def.desc; + const body = this.add.text(0, 16, descText, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: C.ink, align: 'center', wordWrap: { width: w - 18 }, + }).setOrigin(0.5); + cont.add([name, body]); + const glyph = { tarot: '☽', planet: '✶', spectral: '✧' }[kind]; + cont.add(this.add.text(0, h / 2 - 22, glyph, { fontFamily: 'Georgia, serif', fontSize: '20px', color: `#${tint.toString(16).padStart(6, '0')}` }).setOrigin(0.5)); + } + cont.setSize(w, h); + return cont; + } + + // Hover tilt for any card container. + addHoverTilt(cont, base = {}) { + const bs = base.scale ?? 1; + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerover', () => { + if (this.animating) return; + this.tweens.add({ targets: cont, scale: bs * 1.07, angle: (base.angle ?? 0) + Phaser.Math.Between(-3, 3), duration: 120 }); + }); + cont.on('pointerout', () => { + this.tweens.add({ targets: cont, scale: bs, angle: base.angle ?? 0, duration: 140 }); + }); + return cont; + } + + // ── PLAY VIEW ───────────────────────────────────────────────────────────── + renderPlay(frozen = false) { + const run = this.run; + const p = passives(run); + this.renderLeftPanel(); + this.renderJokerRow(frozen); + this.renderConsumableRow(frozen); + this.renderDeckThumb(); + this.renderHand(frozen); + if (!frozen) this.renderPlayButtons(); + } + + renderLeftPanel() { + const run = this.run; + const X = 24, W = 380; + this.panel(X, 24, W, 1032, { alpha: 0.85 }); + + // Blind badge + const blind = BLINDS[run.blind]; + const boss = run.blind === 'boss' ? bossFor(run) : null; + const badgeColor = run.blind === 'boss' ? 0xc22f3a : run.blind === 'big' ? 0x8556d6 : 0x2f6fce; + this.panel(X + 16, 40, W - 32, boss ? 168 : 128, { fill: badgeColor, alpha: 0.35, strokeColor: badgeColor }); + this.text(X + W / 2, 58, boss ? boss.name : blind.name, 30, C.ink, { ox: 0.5, bold: true }); + this.text(X + W / 2, 100, `Score at least`, 17, C.muted, { ox: 0.5 }); + this.text(X + W / 2, 124, fmtChips(run.blindChips), 32, '#ff8a6a', { ox: 0.5, bold: true }); + if (boss) this.text(X + W / 2, 166, boss.desc, 16, '#ffb9a8', { ox: 0.5, align: 'center', wrap: W - 60 }); + + // Round score + const sy = boss ? 226 : 190; + this.text(X + 24, sy, 'Round score', 18, C.muted); + this._roundScoreText = this.text(X + W - 24, sy - 6, fmtChips(run.roundScore), 34, C.ink, { ox: 1, bold: true }); + + // chips × mult live boxes + const cy = sy + 66; + const half = (W - 64) / 2; + this.panel(X + 24, cy, half, 64, { fill: C.chips, alpha: 0.85, stroke: false, radius: 10 }); + this.panel(X + 40 + half, cy, half, 64, { fill: C.mult, alpha: 0.85, stroke: false, radius: 10 }); + this._chipsText = this.text(X + 24 + half / 2, cy + 32, '0', 30, C.ink, { ox: 0.5, oy: 0.5, bold: true }); + this._multText = this.text(X + 40 + half * 1.5, cy + 32, '0', 30, C.ink, { ox: 0.5, oy: 0.5, bold: true }); + this.text(X + 16 + W / 2 - 8, cy + 32, '×', 30, C.muted, { ox: 0.5, oy: 0.5 }); + this._handNameText = this.text(X + W / 2, cy - 26, '', 20, C.gold ? '#e7c14b' : C.ink, { ox: 0.5 }); + + // hands / discards / gold / ante + const iy = cy + 96; + const stat = (label, value, x, color = C.ink) => { + this.text(x, iy, label, 16, C.muted, { ox: 0.5 }); + return this.text(x, iy + 24, value, 30, color, { ox: 0.5, bold: true }); + }; + this._handsText = stat('Hands', String(run.handsLeft), X + 70, C.chipsHex); + this._discardsText = stat('Discards', String(run.discardsLeft), X + 180, C.multHex); + this._goldText = stat('Money', `$${run.gold}`, X + 300, C.money); + const ay = iy + 78; + this.text(X + 70, ay, 'Ante', 16, C.muted, { ox: 0.5 }); + this.text(X + 70, ay + 24, run.endless ? String(run.ante) : `${run.ante}/8`, 28, C.ink, { ox: 0.5, bold: true }); + this.text(X + 180, ay, 'Round', 16, C.muted, { ox: 0.5 }); + this.text(X + 180, ay + 24, String(run.round + 1), 28, C.ink, { ox: 0.5, bold: true }); + this.text(X + 300, ay, 'Deck', 16, C.muted, { ox: 0.5 }); + this.text(X + 300, ay + 24, DECK_BY_ID[run.deckId].name.replace(' Deck', ''), 20, C.ink, { ox: 0.5 }); + + // Hand-levels quick reference (compact) + const hy = ay + 72; + this.text(X + 24, hy, 'Hand levels', 16, C.muted); + let row = 0; + for (const [hid, lv] of Object.entries(run.handLevels)) { + if (lv <= 1) continue; + const hd = HAND_BY_ID[hid]; + this.text(X + 24, hy + 24 + row * 22, `${hd.name} lvl.${lv}`, 15, '#a8d8ff'); + row++; + if (row > 7) break; + } + + // Options + this.button(X + W / 2, 1006, 'Options', () => this.showOptions(), { width: 180, height: 44, fontSize: 20, variant: 'ghost' }); + } + + showOptions() { + const ov = this.add.container(0, 0).setDepth(95); + this.fxLayer.add(ov); + const shade = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive(); + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(GAME_WIDTH / 2 - 240, 360, 480, 360, 16); + g.lineStyle(2, C.panelEdge, 1); g.strokeRoundedRect(GAME_WIDTH / 2 - 240, 360, 480, 360, 16); + ov.add([shade, g]); + const t = this.add.text(GAME_WIDTH / 2, 400, 'Options', { fontFamily: '"Julius Sans One"', fontSize: '32px', color: C.ink }).setOrigin(0.5); + ov.add(t); + const mk = (y, label, cb) => { + const b = new Button(this, GAME_WIDTH / 2, y, label, cb, { width: 320, height: 56, fontSize: 22 }); + this.add.existing(b); ov.add(b); + }; + mk(480, 'Resume', () => ov.destroy()); + mk(560, 'Abandon Run', () => { + ov.destroy(); + if (this.run && this.run.phase !== 'over') { + this.run.phase = 'over'; + this.run.lostAt = { ante: this.run.ante, blind: this.run.blind }; + this.onRunOver(); + } + this.setView('gameover'); + }); + mk(640, 'Main Menu', () => { this.scene.start('GameMenu'); }); + shade.on('pointerdown', () => ov.destroy()); + } + + renderJokerRow(frozen) { + const run = this.run; + const p = passives(run); + const w = 150, h = 200, y = 140; + const x0 = 470; + const maxSpan = 900; + const n = Math.max(run.jokers.length, 1); + const step = Math.min(w + 12, maxSpan / n); + this.text(x0 - 10, 22, `Jokers ${run.jokers.length}/${p.jokerSlots}`, 18, C.muted); + run.jokers.forEach((inst, i) => { + const cx = x0 + w / 2 + i * step; + const cont = this.drawJokerCard(cx, y, inst.id, { inst, w, h }); + this.add2(cont); + this._jokerSprites[inst.uid] = cont; + if (!frozen) { + this.addHoverTilt(cont); + cont.on('pointerdown', () => this.showJokerPanel(inst)); + this.input.setDraggable(cont); + cont.on('drag', (ptr, dx) => { cont.x = dx; }); + cont.on('dragend', () => { + const idx = Phaser.Math.Clamp(Math.round((cont.x - x0 - w / 2) / step), 0, run.jokers.length - 1); + const cur = run.jokers.indexOf(inst); + if (idx !== cur) { + run.jokers.splice(cur, 1); + run.jokers.splice(idx, 0, inst); + this.save(); + } + this.renderView(); + }); + } + }); + for (let i = run.jokers.length; i < p.jokerSlots; i++) { + const cx = x0 + w / 2 + i * step; + const g = this.add.graphics(); + g.lineStyle(2, 0xffffff, 0.12); g.strokeRoundedRect(cx - w / 2, y - h / 2, w, h, 10); + this.add2(g); + } + } + + showJokerPanel(inst) { + const def = JOKER_BY_ID[inst.id]; + this.dismissPanel(); + const cont = this.add.container(0, 0).setDepth(92); + this.fxLayer.add(cont); + const px = 700, py = 280, pw = 460, ph = 190; + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(px, py, pw, ph, 12); + g.lineStyle(2, C.rarity[def.rarity], 1); g.strokeRoundedRect(px, py, pw, ph, 12); + cont.add(g); + const desc = typeof def.desc === 'function' ? def.desc(inst) : def.desc; + cont.add(this.add.text(px + 20, py + 14, `${def.name}${inst.edition ? ` (${EDITIONS[inst.edition].name})` : ''}`, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.ink })); + cont.add(this.add.text(px + 20, py + 52, desc, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, wordWrap: { width: pw - 40 } })); + const sell = new Button(this, px + pw - 110, py + ph - 40, `Sell $${inst.sellValue}`, () => { + const r = sellJoker(this.run, inst.uid); + if (r.ok) { playSound(this, SFX.COINS); this.save(); this.renderView(); } + }, { width: 170, height: 48, fontSize: 20 }); + this.add.existing(sell); cont.add(sell); + const close = new Button(this, px + 90, py + ph - 40, 'Close', () => this.dismissPanel(), { width: 130, height: 48, fontSize: 20, variant: 'ghost' }); + this.add.existing(close); cont.add(close); + this._panel = cont; + } + + dismissPanel() { if (this._panel) { this._panel.destroy(); this._panel = null; } } + + renderConsumableRow(frozen) { + const run = this.run; + const p = passives(run); + const w = 130, h = 176, y = 140; + const x0 = 1470; + this.text(x0 - 4, 22, `Consumables ${run.consumables.length}/${p.consumableSlots}`, 18, C.muted); + run.consumables.forEach((inst, i) => { + const cx = x0 + w / 2 + i * (w + 10); + const cont = this.drawConsumableCard(cx, y, inst.kind, inst.id, { w, h }); + this.add2(cont); + if (!frozen) { + this.addHoverTilt(cont); + cont.on('pointerdown', () => this.showConsumablePanel(inst)); + } + }); + for (let i = run.consumables.length; i < p.consumableSlots; i++) { + const cx = x0 + w / 2 + i * (w + 10); + const g = this.add.graphics(); + g.lineStyle(2, 0xffffff, 0.12); g.strokeRoundedRect(cx - w / 2, y - h / 2, w, h, 10); + this.add2(g); + } + } + + showConsumablePanel(inst) { + const def = consumableDef(inst); + this.dismissPanel(); + const cont = this.add.container(0, 0).setDepth(92); + this.fxLayer.add(cont); + const px = 1230, py = 260, pw = 470, ph = 210; + const g = this.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(px, py, pw, ph, 12); + const tint = { tarot: C.tarot, planet: C.planet, spectral: C.spectral }[inst.kind]; + g.lineStyle(2, tint, 1); g.strokeRoundedRect(px, py, pw, ph, 12); + cont.add(g); + cont.add(this.add.text(px + 20, py + 14, def.name, { fontFamily: '"Julius Sans One"', fontSize: '24px', color: C.ink })); + const descText = inst.kind === 'planet' ? `+1 level: ${HAND_BY_ID[def.hand].name}` : def.desc; + cont.add(this.add.text(px + 20, py + 52, descText, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, wordWrap: { width: pw - 40 } })); + const needs = def.targets || 0; + if (needs) cont.add(this.add.text(px + 20, py + ph - 96, `Select up to ${needs} card${needs > 1 ? 's' : ''} in hand first`, { fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#a8d8ff' })); + const use = new Button(this, px + pw - 110, py + ph - 40, 'Use', () => { + const r = useConsumable(this.run, inst.uid, this.selected.slice(0, Math.max(needs, 2))); + if (!r.ok) { this.toast(r.error); return; } + playSound(this, SFX.CARD_SHOW); + if (r.note) this.toast(r.note); + this.dismissPanel(); + this.selected = []; + this.save(); + this.renderView(); + }, { width: 140, height: 48, fontSize: 20 }); + this.add.existing(use); cont.add(use); + const sellV = Math.max(1, Math.floor(({ tarot: 3, planet: 3, spectral: 4 })[inst.kind] / 2)); + const sell = new Button(this, px + pw - 270, py + ph - 40, `Sell $${sellV}`, () => { + const r = sellConsumable(this.run, inst.uid); + if (r.ok) { playSound(this, SFX.COINS); this.dismissPanel(); this.save(); this.renderView(); } + }, { width: 140, height: 48, fontSize: 20, variant: 'ghost' }); + this.add.existing(sell); cont.add(sell); + const close = new Button(this, px + 85, py + ph - 40, 'Close', () => this.dismissPanel(), { width: 120, height: 48, fontSize: 20, variant: 'ghost' }); + this.add.existing(close); cont.add(close); + this._panel = cont; + } + + renderDeckThumb() { + const run = this.run; + const x = 1840, y = 150, w = 110, h = 148; + const deck = DECK_BY_ID[run.deckId]; + const g = this.add.graphics(); + g.fillStyle(deck.color, 0.9); g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8); + g.lineStyle(2, 0xffffff, 0.4); g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8); + this.add2(g); + this.text(x, y + h / 2 + 14, `${run.drawPile.length}/${run.deck.length}`, 18, C.ink, { ox: 0.5 }); + } + + renderHand(frozen) { + const run = this.run; + const hand = handCards(run); + const w = 150, h = 204; + const centerX = 1160, y = 880; + const maxSpan = 1300; + const step = hand.length > 1 ? Math.min(w * 0.72, (maxSpan - w) / (hand.length - 1)) : 0; + const x0 = centerX - ((hand.length - 1) * step) / 2; + hand.forEach((card, i) => { + const selected = this.selected.includes(card.uid); + const cx = x0 + i * step; + const cy = y - (selected ? 44 : 0); + const cont = this.drawPlayingCard(cx, cy, card, { w, h, debuffed: isDebuffed(run, card) }); + const tilt = (i - (hand.length - 1) / 2) * 1.6; + cont.setAngle(tilt); + this.add2(cont, this.handLayer); + this._handSprites[card.uid] = cont; + if (selected) { + const glow = this.add.graphics(); + glow.lineStyle(4, 0xe7c14b, 0.95); glow.strokeRoundedRect(-w / 2, -h / 2, w, h, 10); + cont.add(glow); + } + if (run.bossState && run.bossState.forcedUid === card.uid) { + const lock = this.add.text(0, -h / 2 - 20, '⛓ forced', { fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#ff8a6a' }).setOrigin(0.5); + cont.add(lock); + } + if (!frozen) { + cont.setInteractive({ useHandCursor: true }); + cont.on('pointerover', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy - 16, duration: 100 }); }); + cont.on('pointerout', () => { if (!this.animating && !selected) this.tweens.add({ targets: cont, y: cy, duration: 120 }); }); + cont.on('pointerdown', () => this.toggleSelect(card.uid)); + } + }); + this.updateHandPreview(); + } + + toggleSelect(uid) { + if (this.animating) return; + const run = this.run; + if (run.bossState && run.bossState.forcedUid === uid && this.selected.includes(uid)) { + this.toast('That card is forced by the Boss Blind'); + return; + } + const i = this.selected.indexOf(uid); + if (i >= 0) this.selected.splice(i, 1); + else { + if (this.selected.length >= 5) { this.toast('Up to 5 cards'); return; } + this.selected.push(uid); + } + playSound(this, SFX.PIECE_CLICK); + // Re-render just the hand + preview (cheap enough to redo the view). + this.handLayer.removeAll(true); + this._handSprites = {}; + this.renderHand(false); + } + + updateHandPreview() { + if (!this._handNameText || !this._handNameText.active) return; + const run = this.run; + if (!this.selected.length) { + this._handNameText.setText(''); + if (this._chipsText.active) this._chipsText.setText('0'); + if (this._multText.active) this._multText.setText('0'); + return; + } + const p = passives(run); + const { handType } = evaluate(cardsOf(run, this.selected), p); + const lv = run.handLevels[handType] || 1; + const base = handBase(handType, lv); + this._handNameText.setText(`${HAND_BY_ID[handType].name} lvl.${lv}`); + this._chipsText.setText(fmtChips(base.chips)); + this._multText.setText(String(base.mult)); + } + + renderPlayButtons() { + const run = this.run; + const y = 1030; + this.button(980, y, 'Play Hand', () => this.doPlay(), { width: 240, height: 58, fontSize: 24, bg: 0x2f6fce }); + this.button(1250, y, `Discard (${run.discardsLeft})`, () => this.doDiscard(), { width: 240, height: 58, fontSize: 24, bg: 0x8c2f2f }); + this.button(1540, y, 'Rank', () => { sortHand(run, 'rank'); this.save(); this.renderView(); }, { width: 110, height: 46, fontSize: 18, variant: 'ghost' }); + this.button(1665, y, 'Suit', () => { sortHand(run, 'suit'); this.save(); this.renderView(); }, { width: 110, height: 46, fontSize: 18, variant: 'ghost' }); + if (!anyLegalPlay(run) && run.discardsLeft === 0) { + this.button(1830, y, 'Forfeit', () => { + forfeitRound(run); + this.onRunOver(); + this.setView('gameover'); + }, { width: 140, height: 46, fontSize: 18, bg: 0x5a2f3a }); + } + } + + doDiscard() { + if (!this.selected.length) { this.toast('Select cards to discard'); return; } + const r = discard(this.run, this.selected.slice()); + if (!r.ok) { this.toast(r.error); return; } + playSound(this, SFX.CARD_DEAL); + this.selected = []; + this.save(); + this.renderView(); + } + + doPlay() { + if (!this.selected.length) { this.toast('Select cards to play'); return; } + const run = this.run; + const uids = run.hand.filter((u) => this.selected.includes(u)); + const playedCards = cardsOf(run, uids).map((c) => ({ ...c })); // snapshot for display + const res = playHand(run, uids); + if (!res.ok) { this.toast(res.error); return; } + this.selected = []; + this.save(); + playSound(this, SFX.CARD_PLACE); + this.animateScoring(playedCards, res); + } + + // ── scoring animation: pure playback of the trace ───────────────────────── + animateScoring(playedCards, res) { + this.animating = true; + this._skipAnim = false; + this.dismissPanel(); + + // Freeze the table (re-render without input), lift played cards to center. + this.clearView(); + this.renderLeftPanel(); + this.renderJokerRow(true); + this.renderConsumableRow(true); + this.renderDeckThumb(); + + // Hand without the played cards + this.renderHand(true); + + // Played row + const w = 150, h = 204; + const centerX = 1160, py = 480; + const step = Math.min(w + 22, 900 / Math.max(playedCards.length, 1)); + const x0 = centerX - ((playedCards.length - 1) * step) / 2; + this._playedSprites = {}; + const scoringSet = new Set(res.trace.scoringUids); + playedCards.forEach((card, i) => { + const cont = this.drawPlayingCard(x0 + i * step, py, card, { w, h, reveal: true }); + if (!scoringSet.has(card.uid)) cont.setAlpha(0.45); + this.add2(cont); + this._playedSprites[card.uid] = cont; + }); + + const events = res.trace.events; + const stepMs = Phaser.Math.Clamp(Math.round(2600 / Math.max(events.length, 1)), 70, 240); + let idx = 0; + + const findSprite = (source) => { + if (!source) return null; + if (source.kind === 'card' || source.kind === 'seal') return this._playedSprites[source.uid] || this._handSprites[source.uid] || null; + if (source.kind === 'joker') { + if (source.jokerUid && this._jokerSprites[source.jokerUid]) return this._jokerSprites[source.jokerUid]; + if (source.cardUid) return this._playedSprites[source.cardUid] || null; + } + return null; + }; + + const floater = (sprite, textStr, color) => { + const x = sprite ? this.fxX(sprite) : 980; + const y = sprite ? this.fxY(sprite) - 80 : 400; + const t = this.add.text(x, y, textStr, { + fontFamily: '"Julius Sans One"', fontSize: '34px', color, fontStyle: 'bold', + stroke: '#141019', strokeThickness: 5, + }).setOrigin(0.5).setDepth(85); + this.fxLayer.add(t); + this.tweens.add({ targets: t, y: y - 56, alpha: 0, duration: 700, ease: 'Cubic.easeOut', onComplete: () => t.destroy() }); + }; + + const pop = (sprite, big = false) => { + if (!sprite || !sprite.active) return; + this.tweens.add({ targets: sprite, scale: big ? 1.2 : 1.1, duration: 90, yoyo: true }); + }; + + const applyEvent = (ev, silent = false) => { + if (this._chipsText && this._chipsText.active) this._chipsText.setText(fmtChips(ev.chipsAfter)); + if (this._multText && this._multText.active) this._multText.setText(String(Math.round(ev.multAfter * 100) / 100)); + if (silent) return; + const sprite = findSprite(ev.source); + switch (ev.t) { + case 'base': + if (this._handNameText && this._handNameText.active) this._handNameText.setText(`${HAND_BY_ID[ev.handType].name} lvl.${ev.level}`); + playSound(this, SFX.CARD_SHOW); + break; + case 'chips': floater(sprite, `+${fmtChips(ev.v)}`, C.chipsHex); pop(sprite); playSound(this, SFX.PIECE_CLICK); break; + case 'mult': floater(sprite, `+${ev.v} Mult`, C.multHex); pop(sprite); playSound(this, SFX.PIECE_CLICK); break; + case 'xmult': + floater(sprite, `×${ev.v}`, '#ffa24f'); pop(sprite, true); + playSound(this, SFX.SCIFI_PLINK); + if (ev.v >= 2) this.cameras.main.shake(120, 0.004); + break; + case 'money': floater(sprite, `+$${ev.v}`, C.money); playSound(this, SFX.COINS); if (this._goldText && this._goldText.active) this._goldText.setText(`$${this.run.gold}`); break; + case 'retrigger': if (sprite) this.ringPulse(sprite); break; + case 'destroy': if (sprite) this.shatter(sprite); break; + case 'jokerState': if (sprite) { pop(sprite); if (ev.text) floater(sprite, ev.text, '#d0c8ff'); } break; + case 'info': if (ev.text) floater(sprite, ev.text, '#d0c8ff'); break; + case 'total': break; + default: break; + } + }; + + const finish = () => { + const totalEv = events[events.length - 1]; + applyEvent(totalEv, true); + const score = res.trace.score; + // Slam: big score text at the played row. + const slam = this.add.text(centerX, py - 170, fmtChips(score), { + fontFamily: '"Julius Sans One"', fontSize: '72px', color: '#ffffff', fontStyle: 'bold', + stroke: '#c8342c', strokeThickness: 8, + }).setOrigin(0.5).setScale(1.6).setAlpha(0).setDepth(86); + this.fxLayer.add(slam); + this.tweens.add({ targets: slam, alpha: 1, scale: 1, duration: 180, ease: 'Back.easeOut' }); + this.cameras.main.shake(160, score >= this.run.blindChips ? 0.008 : 0.004); + playSound(this, score >= 10000 ? SFX.SCIFI_EXPLODE : SFX.CASINO_BLACKJACK); + if (this._roundScoreText && this._roundScoreText.active) this._roundScoreText.setText(fmtChips(this.run.roundScore)); + + this.time.delayedCall(this._skipAnim ? 250 : 900, () => { + this.animating = false; + if (res.destroyedJokers && res.destroyedJokers.length) { + this.toast(`${res.destroyedJokers.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); + } + if (res.outcome === 'blindWon') { + playSound(this, SFX.CASINO_WIN); + this.setView('cashout'); + } else if (res.outcome === 'runLost') { + playSound(this, SFX.CASINO_LOSE); + this.onRunOver(); + this.setView('gameover'); + } else { + this.setView('play'); + } + }); + }; + + const tick = () => { + if (this._skipAnim) { + // Snap: apply remaining counter states instantly. + finish(); + return; + } + if (idx >= events.length) { finish(); return; } + const ev = events[idx++]; + applyEvent(ev); + this.time.delayedCall(ev.t === 'base' ? 420 : stepMs, tick); + }; + tick(); + } + + fxX(sprite) { let x = sprite.x, p = sprite.parentContainer; while (p) { x += p.x; p = p.parentContainer; } return x; } + fxY(sprite) { let y = sprite.y, p = sprite.parentContainer; while (p) { y += p.y; p = p.parentContainer; } return y; } + + ringPulse(sprite) { + const x = this.fxX(sprite), y = this.fxY(sprite); + const ring = this.add.circle(x, y, 30, 0xffffff, 0).setStrokeStyle(4, 0xffffff, 0.9).setDepth(84); + this.fxLayer.add(ring); + this.tweens.add({ targets: ring, radius: 110, alpha: 0, duration: 380, onComplete: () => ring.destroy() }); + } + + shatter(sprite) { + const x = this.fxX(sprite), y = this.fxY(sprite); + if (sprite.active) sprite.setAlpha(0.25); + for (let i = 0; i < 7; i++) { + const shard = this.add.rectangle(x, y, 14, 20, 0xdfeef4, 0.9).setDepth(84).setAngle(Phaser.Math.Between(0, 360)); + this.fxLayer.add(shard); + this.tweens.add({ + targets: shard, + x: x + Phaser.Math.Between(-140, 140), y: y + Phaser.Math.Between(-60, 180), + angle: shard.angle + Phaser.Math.Between(-240, 240), alpha: 0, + duration: 520, ease: 'Cubic.easeOut', onComplete: () => shard.destroy(), + }); + } + playSound(this, SFX.SCIFI_PLONK); + } +} diff --git a/src/games/balatro/BalatroJokers.js b/src/games/balatro/BalatroJokers.js new file mode 100644 index 0000000..b466c8b --- /dev/null +++ b/src/games/balatro/BalatroJokers.js @@ -0,0 +1,410 @@ +// BalatroJokers.js +// Joker definitions: data rows + effect hooks, consumed by BalatroScoring +// (scoring-time hooks) and BalatroLogic (economy/round hooks). +// +// Hook taxonomy — all optional per joker: +// onScored(ctx, j, card, index) per scored card, per trigger +// onHeld(ctx, j, card) per held card, per trigger +// independent(ctx, j) joker-loop step (chips/mult/xmult) +// cardRetriggers(ctx, j, card, i)→n extra triggers for a scored card +// heldRetriggers(ctx, j, card)→n extra triggers for a held card +// onHandPlayed(ctx, j) state update BEFORE this joker's independent +// afterHand(ctx, j) state update after the whole hand (decay) +// onDiscard(run, j, cards, info) info = { isFirst, api } +// onBlindStart(run, j, api) after boss setup, before first draw +// onBlindSelected(run, j, blind, api) small/big blind chosen (Madness) +// onCashOut(run, j)→$ extra money line at round end +// onRoundEnd(run, j, api)→{destroyed?} decay / self-destruct rolls +// onPackSkipped / onBossDefeated / onCardAdded / onConsumableUsed(run, j, ...) +// passive(p, j, run) folded into BalatroLogic.passives() +// isDead(j)→bool removed by Logic when true +// copyable:false Blueprint/Brainstorm resolve to no-op +// +// Copies (Blueprint/Brainstorm) read the TARGET's state and run only +// scoring-time hooks + onCashOut; state-updating hooks fire once, on the +// original, so counters never double-tick. + +import { rankChips, isFaceRank } from './BalatroData.js'; + +const C = 'common', U = 'uncommon', R = 'rare'; + +// ── Roster ─────────────────────────────────────────────────────────────────── +export const JOKERS = [ + // Flat +Mult + { id: 'joker', name: 'Joker', rarity: C, cost: 2, desc: '+4 Mult', + independent: (ctx) => ctx.mult(4, sj('joker')) }, + ...[['greedy', 'Greedy Joker', 'D', 'Diamond'], ['lusty', 'Lusty Joker', 'H', 'Heart'], + ['wrathful', 'Wrathful Joker', 'S', 'Spade'], ['gluttonous', 'Gluttonous Joker', 'C', 'Club']] + .map(([id, name, suit, sn]) => ({ + id, name, rarity: C, cost: 5, desc: `Played cards with ${sn} suit give +3 Mult when scored`, + onScored: (ctx, j, card) => { if (ctx.hasSuit(card, suit)) ctx.mult(3, sj(id, card)); } })), + ...[['jolly', 'Jolly Joker', 'pair', 8, 3], ['zany', 'Zany Joker', 'threeofakind', 12, 4], + ['mad', 'Mad Joker', 'twopair', 10, 4], ['crazy', 'Crazy Joker', 'straight', 12, 4], + ['droll', 'Droll Joker', 'flush', 10, 4]] + .map(([id, name, want, amt, cost]) => ({ + id, name, rarity: C, cost, desc: `+${amt} Mult if played hand contains a ${want === 'threeofakind' ? 'Three of a Kind' : want === 'twopair' ? 'Two Pair' : want[0].toUpperCase() + want.slice(1)}`, + independent: (ctx) => { if (ctx.contains(want)) ctx.mult(amt, sj(id)); } })), + { id: 'halfjoker', name: 'Half Joker', rarity: C, cost: 5, desc: '+20 Mult if played hand contains 3 or fewer cards', + independent: (ctx) => { if (ctx.playedCards.length <= 3) ctx.mult(20, sj('halfjoker')); } }, + { id: 'misprint', name: 'Misprint', rarity: C, cost: 4, desc: '+0 to +23 Mult', + independent: (ctx) => ctx.mult(ctx.rng.int(24), sj('misprint')) }, + + // Flat +Chips + ...[['sly', 'Sly Joker', 'pair', 50, 3], ['wily', 'Wily Joker', 'threeofakind', 100, 4], + ['clever', 'Clever Joker', 'twopair', 80, 4], ['devious', 'Devious Joker', 'straight', 100, 4], + ['crafty', 'Crafty Joker', 'flush', 80, 4]] + .map(([id, name, want, amt, cost]) => ({ + id, name, rarity: C, cost, desc: `+${amt} Chips if played hand contains a ${want === 'threeofakind' ? 'Three of a Kind' : want === 'twopair' ? 'Two Pair' : want[0].toUpperCase() + want.slice(1)}`, + independent: (ctx) => { if (ctx.contains(want)) ctx.chips(amt, sj(id)); } })), + { id: 'banner', name: 'Banner', rarity: C, cost: 5, desc: '+30 Chips per remaining discard', + independent: (ctx) => ctx.chips(30 * ctx.run.discardsLeft, sj('banner')) }, + { id: 'stuntman', name: 'Stuntman', rarity: R, cost: 7, desc: '+250 Chips, -2 hand size', + independent: (ctx) => ctx.chips(250, sj('stuntman')), + passive: (p) => { p.handSize -= 2; } }, + + // Per-card onScored + { id: 'fibonacci', name: 'Fibonacci', rarity: U, cost: 8, desc: 'Each played Ace, 2, 3, 5 or 8 gives +8 Mult when scored', + onScored: (ctx, j, card) => { if ([14, 2, 3, 5, 8].includes(card.rank) && card.enhancement !== 'stone') ctx.mult(8, sj('fibonacci', card)); } }, + { id: 'scaryface', name: 'Scary Face', rarity: C, cost: 4, desc: 'Played face cards give +30 Chips when scored', + onScored: (ctx, j, card) => { if (ctx.isFace(card)) ctx.chips(30, sj('scaryface', card)); } }, + { id: 'evensteven', name: 'Even Steven', rarity: C, cost: 4, desc: 'Played cards with even rank give +4 Mult when scored (10, 8, 6, 4, 2)', + onScored: (ctx, j, card) => { if (card.rank <= 10 && card.rank % 2 === 0 && card.enhancement !== 'stone') ctx.mult(4, sj('evensteven', card)); } }, + { id: 'oddtodd', name: 'Odd Todd', rarity: C, cost: 4, desc: 'Played cards with odd rank give +31 Chips when scored (A, 9, 7, 5, 3)', + onScored: (ctx, j, card) => { if ((card.rank === 14 || (card.rank <= 9 && card.rank % 2 === 1)) && card.enhancement !== 'stone') ctx.chips(31, sj('oddtodd', card)); } }, + { id: 'scholar', name: 'Scholar', rarity: C, cost: 4, desc: 'Played Aces give +20 Chips and +4 Mult when scored', + onScored: (ctx, j, card) => { if (card.rank === 14 && card.enhancement !== 'stone') { ctx.chips(20, sj('scholar', card)); ctx.mult(4, sj('scholar', card)); } } }, + { id: 'smileyface', name: 'Smiley Face', rarity: C, cost: 4, desc: 'Played face cards give +5 Mult when scored', + onScored: (ctx, j, card) => { if (ctx.isFace(card)) ctx.mult(5, sj('smileyface', card)); } }, + { id: 'walkietalkie', name: 'Walkie Talkie', rarity: C, cost: 4, desc: 'Each played 10 or 4 gives +10 Chips and +4 Mult when scored', + onScored: (ctx, j, card) => { if (card.rank === 10 || card.rank === 4) { ctx.chips(10, sj('walkietalkie', card)); ctx.mult(4, sj('walkietalkie', card)); } } }, + { id: 'roughgem', name: 'Rough Gem', rarity: U, cost: 7, desc: 'Played Diamonds earn $1 when scored', + onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'D')) ctx.money(1, sj('roughgem', card)); } }, + { id: 'bloodstone', name: 'Bloodstone', rarity: U, cost: 7, desc: '1 in 2 chance for played Hearts to give x1.5 Mult when scored', + onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'H') && ctx.prob(2)) ctx.xmult(1.5, sj('bloodstone', card)); } }, + { id: 'arrowhead', name: 'Arrowhead', rarity: U, cost: 7, desc: 'Played Spades give +50 Chips when scored', + onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'S')) ctx.chips(50, sj('arrowhead', card)); } }, + { id: 'onyxagate', name: 'Onyx Agate', rarity: U, cost: 7, desc: 'Played Clubs give +7 Mult when scored', + onScored: (ctx, j, card) => { if (ctx.hasSuit(card, 'C')) ctx.mult(7, sj('onyxagate', card)); } }, + + // xMult + { id: 'blackboard', name: 'Blackboard', rarity: U, cost: 6, desc: 'x3 Mult if all cards held in hand are Spades or Clubs', + independent: (ctx) => { + const ok = ctx.heldCards.every((c) => ctx.hasSuit(c, 'S') || ctx.hasSuit(c, 'C')); + if (ok) ctx.xmult(3, sj('blackboard')); + } }, + { id: 'baron', name: 'Baron', rarity: R, cost: 8, desc: 'Each King held in hand gives x1.5 Mult', + onHeld: (ctx, j, card) => { if (card.rank === 13 && card.enhancement !== 'stone') ctx.xmult(1.5, sj('baron', card)); } }, + { id: 'photograph', name: 'Photograph', rarity: C, cost: 5, desc: 'First played face card gives x2 Mult when scored', + onScored: (ctx, j, card) => { if (ctx.isFace(card) && !ctx.flags.photograph) { ctx.flags.photograph = true; ctx.xmult(2, sj('photograph', card)); } } }, + { id: 'ancient', name: 'Ancient Joker', rarity: R, cost: 8, initState: (rng) => ({ suit: 'SHDC'[rng ? rng.int(4) : 0] }), + desc: (j) => `Each played card with ${suitName(j.state.suit)} suit gives x1.5 Mult when scored (suit changes every round)`, + onScored: (ctx, j, card) => { if (ctx.hasSuit(card, j.state.suit)) ctx.xmult(1.5, sj('ancient', card)); }, + onRoundEnd: (run, j, api) => { j.state.suit = 'SHDC'[api.rng.int(4)]; } }, + { id: 'cavendish', name: 'Cavendish', rarity: C, cost: 4, desc: 'x3 Mult; 1 in 1000 chance it is destroyed at end of round', + independent: (ctx) => ctx.xmult(3, sj('cavendish')), + onRoundEnd: (run, j, api) => (api.prob(1000) ? { destroyed: true } : null) }, + { id: 'cardsharp', name: 'Card Sharp', rarity: U, cost: 6, desc: 'x3 Mult if played hand type was already played this round', + independent: (ctx) => { if (ctx.run.handTypesPlayedThisRound.includes(ctx.handType)) ctx.xmult(3, sj('cardsharp')); } }, + { id: 'acrobat', name: 'Acrobat', rarity: U, cost: 6, desc: 'x3 Mult on the final hand of the round', + independent: (ctx) => { if (ctx.run.handsLeft === 1) ctx.xmult(3, sj('acrobat')); } }, + ...[['duo', 'The Duo', 'pair', 2], ['trio', 'The Trio', 'threeofakind', 3], + ['family', 'The Family', 'fourofakind', 4], ['order', 'The Order', 'straight', 3], + ['tribe', 'The Tribe', 'flush', 2]] + .map(([id, name, want, x]) => ({ + id, name, rarity: R, cost: 8, desc: `x${x} Mult if played hand contains a ${want === 'threeofakind' ? 'Three of a Kind' : want === 'fourofakind' ? 'Four of a Kind' : want[0].toUpperCase() + want.slice(1)}`, + independent: (ctx) => { if (ctx.contains(want)) ctx.xmult(x, sj(id)); } })), + { id: 'flowerpot', name: 'Flower Pot', rarity: U, cost: 6, desc: 'x3 Mult if the scoring hand contains a Diamond, Club, Heart and Spade', + independent: (ctx) => { + const need = ['D', 'C', 'H', 'S']; + const cards = ctx.scoredCards.filter((c) => c.enhancement !== 'stone'); + const wilds = cards.filter((c) => c.enhancement === 'wild').length; + const suits = new Set(cards.filter((c) => c.enhancement !== 'wild').map((c) => c.suit)); + if (suits.size + Math.min(wilds, need.length - suits.size) >= 4) ctx.xmult(3, sj('flowerpot')); + } }, + + // Scaling + { id: 'runner', name: 'Runner', rarity: C, cost: 5, initState: () => ({ chips: 0 }), + desc: (j) => `Gains +15 Chips if played hand contains a Straight (now +${j.state.chips} Chips)`, + onHandPlayed: (ctx, j) => { if (ctx.contains('straight')) { j.state.chips += 15; ctx.emit({ t: 'jokerState', source: sj('runner'), text: '+15' }); } }, + independent: (ctx, j) => ctx.chips(j.state.chips, sj('runner')) }, + { id: 'icecream', name: 'Ice Cream', rarity: C, cost: 5, initState: () => ({ chips: 100 }), + desc: (j) => `+${j.state.chips} Chips; -5 Chips per hand played`, + independent: (ctx, j) => ctx.chips(j.state.chips, sj('icecream')), + afterHand: (ctx, j) => { j.state.chips -= 5; ctx.emit({ t: 'jokerState', source: sj('icecream'), text: '-5' }); }, + isDead: (j) => j.state.chips <= 0 }, + { id: 'bluejoker', name: 'Blue Joker', rarity: C, cost: 5, desc: '+2 Chips per remaining card in the draw pile', + independent: (ctx) => ctx.chips(2 * ctx.run.drawPile.length, sj('bluejoker')) }, + { id: 'greenjoker', name: 'Green Joker', rarity: C, cost: 4, initState: () => ({ n: 0 }), + desc: (j) => `+1 Mult per hand played, -1 Mult per discard (now +${j.state.n})`, + onHandPlayed: (ctx, j) => { j.state.n += 1; ctx.emit({ t: 'jokerState', source: sj('greenjoker'), text: '+1' }); }, + independent: (ctx, j) => ctx.mult(j.state.n, sj('greenjoker')), + onDiscard: (run, j) => { j.state.n = Math.max(0, j.state.n - 1); } }, + { id: 'ridethebus', name: 'Ride the Bus', rarity: C, cost: 6, initState: () => ({ n: 0 }), + desc: (j) => `Gains +1 Mult per consecutive hand played without a scoring face card (now +${j.state.n})`, + onHandPlayed: (ctx, j) => { + const face = ctx.scoredCards.some((c) => ctx.isFace(c)); + j.state.n = face ? 0 : j.state.n + 1; + ctx.emit({ t: 'jokerState', source: sj('ridethebus'), text: face ? 'Reset' : '+1' }); + }, + independent: (ctx, j) => ctx.mult(j.state.n, sj('ridethebus')) }, + { id: 'squarejoker', name: 'Square Joker', rarity: C, cost: 4, initState: () => ({ chips: 0 }), + desc: (j) => `Gains +4 Chips if played hand has exactly 4 cards (now +${j.state.chips})`, + onHandPlayed: (ctx, j) => { if (ctx.playedCards.length === 4) { j.state.chips += 4; ctx.emit({ t: 'jokerState', source: sj('squarejoker'), text: '+4' }); } }, + independent: (ctx, j) => ctx.chips(j.state.chips, sj('squarejoker')) }, + { id: 'supernova', name: 'Supernova', rarity: C, cost: 5, desc: 'Adds the number of times the played hand type has been played this run to Mult', + independent: (ctx) => ctx.mult((ctx.run.handPlayCounts[ctx.handType] || 0) + 1, sj('supernova')) }, + { id: 'obelisk', name: 'Obelisk', rarity: R, cost: 8, initState: () => ({ x: 1 }), + desc: (j) => `x${j.state.x.toFixed(1)} Mult per consecutive hand played that is not your most played hand`, + onHandPlayed: (ctx, j) => { + const counts = ctx.run.handPlayCounts; + const max = Math.max(0, ...Object.values(counts)); + const isMost = (counts[ctx.handType] || 0) >= max && max > 0; + j.state.x = isMost ? 1 : Math.round((j.state.x + 0.2) * 10) / 10; + ctx.emit({ t: 'jokerState', source: sj('obelisk'), text: isMost ? 'Reset' : '+0.2' }); + }, + independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('obelisk')); } }, + { id: 'constellation', name: 'Constellation', rarity: U, cost: 6, initState: () => ({ x: 1 }), + desc: (j) => `Gains x0.1 Mult per Planet card used (now x${j.state.x.toFixed(1)})`, + onConsumableUsed: (run, j, kind) => { if (kind === 'planet') j.state.x = Math.round((j.state.x + 0.1) * 10) / 10; }, + independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('constellation')); } }, + { id: 'hologram', name: 'Hologram', rarity: U, cost: 7, initState: () => ({ x: 1 }), + desc: (j) => `Gains x0.25 Mult per playing card added to your deck (now x${j.state.x.toFixed(2)})`, + onCardAdded: (run, j) => { j.state.x = Math.round((j.state.x + 0.25) * 100) / 100; }, + independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('hologram')); } }, + { id: 'vampire', name: 'Vampire', rarity: U, cost: 7, initState: () => ({ x: 1 }), + desc: (j) => `Gains x0.1 Mult per scoring enhanced card; removes the enhancement (now x${j.state.x.toFixed(1)})`, + onScored: (ctx, j, card) => { + if (card.enhancement && card.enhancement !== 'stone') { + card.enhancement = null; + j.state.x = Math.round((j.state.x + 0.1) * 10) / 10; + ctx.emit({ t: 'jokerState', source: sj('vampire', card), text: '+0.1' }); + } + }, + independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('vampire')); } }, + { id: 'madness', name: 'Madness', rarity: U, cost: 7, initState: () => ({ x: 1 }), + desc: (j) => `When a Small or Big Blind is selected, gains x0.5 Mult and destroys a random other Joker (now x${j.state.x.toFixed(1)})`, + onBlindSelected: (run, j, blind, api) => { + if (blind === 'boss') return; + j.state.x = Math.round((j.state.x + 0.5) * 10) / 10; + api.destroyRandomOtherJoker(run, j); + }, + independent: (ctx, j) => { if (j.state.x > 1) ctx.xmult(j.state.x, sj('madness')); } }, + { id: 'castle', name: 'Castle', rarity: U, cost: 6, initState: (rng) => ({ suit: 'SHDC'[rng ? rng.int(4) : 0], chips: 0 }), + desc: (j) => `Gains +3 Chips per discarded ${suitName(j.state.suit)} card (now +${j.state.chips}; suit changes every round)`, + onDiscard: (run, j, cards, info) => { j.state.chips += 3 * cards.filter((c) => info.api.hasSuit(c, j.state.suit)).length; }, + independent: (ctx, j) => ctx.chips(j.state.chips, sj('castle')), + onRoundEnd: (run, j, api) => { j.state.suit = 'SHDC'[api.rng.int(4)]; } }, + { id: 'weejoker', name: 'Wee Joker', rarity: R, cost: 8, initState: () => ({ chips: 0 }), + desc: (j) => `Gains +8 Chips when each played 2 is scored (now +${j.state.chips})`, + onScored: (ctx, j, card) => { if (card.rank === 2 && card.enhancement !== 'stone') { j.state.chips += 8; ctx.emit({ t: 'jokerState', source: sj('weejoker', card), text: '+8' }); } }, + independent: (ctx, j) => ctx.chips(j.state.chips, sj('weejoker')) }, + { id: 'erosion', name: 'Erosion', rarity: U, cost: 6, desc: '+4 Mult for each card below your starting deck size', + independent: (ctx) => ctx.mult(4 * Math.max(0, ctx.run.startDeckSize - ctx.run.deck.length), sj('erosion')) }, + { id: 'hiker', name: 'Hiker', rarity: U, cost: 5, desc: 'Every played card permanently gains +5 Chips when scored', + onScored: (ctx, j, card) => { card.permaChips = (card.permaChips || 0) + 5; } }, + { id: 'ramen', name: 'Ramen', rarity: U, cost: 6, initState: () => ({ x: 2 }), + desc: (j) => `x${j.state.x.toFixed(2)} Mult; loses x0.01 per card discarded`, + independent: (ctx, j) => ctx.xmult(j.state.x, sj('ramen')), + onDiscard: (run, j, cards) => { j.state.x = Math.round((j.state.x - 0.01 * cards.length) * 100) / 100; }, + isDead: (j) => j.state.x <= 1 }, + { id: 'popcorn', name: 'Popcorn', rarity: C, cost: 5, initState: () => ({ mult: 20 }), + desc: (j) => `+${j.state.mult} Mult; -4 Mult per round played`, + independent: (ctx, j) => ctx.mult(j.state.mult, sj('popcorn')), + onRoundEnd: (run, j) => { j.state.mult -= 4; }, + isDead: (j) => j.state.mult <= 0 }, + { id: 'throwback', name: 'Throwback', rarity: U, cost: 6, desc: 'x0.25 Mult for each blind skipped this run', + independent: (ctx) => { const n = ctx.run.stats.blindsSkipped; if (n > 0) ctx.xmult(1 + 0.25 * n, sj('throwback')); } }, + + // Economy + { id: 'goldenjoker', name: 'Golden Joker', rarity: C, cost: 6, desc: 'Earn $4 at end of round', + onCashOut: () => 4 }, + { id: 'delayedgrat', name: 'Delayed Gratification', rarity: C, cost: 4, desc: 'Earn $2 per discard if no discards are used by end of round', + onCashOut: (run) => (run.discardsUsedThisRound === 0 ? 2 * run.discardsLeft : 0) }, + { id: 'businesscard', name: 'Business Card', rarity: C, cost: 4, desc: 'Played face cards have a 1 in 2 chance to give $2 when scored', + onScored: (ctx, j, card) => { if (ctx.isFace(card) && ctx.prob(2)) ctx.money(2, sj('businesscard', card)); } }, + { id: 'faceless', name: 'Faceless Joker', rarity: C, cost: 4, desc: 'Earn $5 if 3 or more face cards are discarded at the same time', + onDiscard: (run, j, cards, info) => { if (cards.filter((c) => info.api.isFace(c)).length >= 3) run.gold += 5; } }, + { id: 'cloud9', name: 'Cloud 9', rarity: U, cost: 7, desc: 'Earn $1 for each 9 in your full deck at end of round', + onCashOut: (run) => run.deck.filter((c) => c.rank === 9 && c.enhancement !== 'stone').length }, + { id: 'rocket', name: 'Rocket', rarity: U, cost: 6, initState: () => ({ payout: 1 }), + desc: (j) => `Earn $${j.state.payout} at end of round; payout increases by $2 when a Boss Blind is defeated`, + onCashOut: (run, j) => j.state.payout, + onBossDefeated: (run, j) => { j.state.payout += 2; } }, + { id: 'egg', name: 'Egg', rarity: C, cost: 4, copyable: false, desc: 'Gains $3 of sell value at end of round', + onRoundEnd: (run, j) => { j.sellValue += 3; } }, + { id: 'tothemoon', name: 'To the Moon', rarity: U, cost: 5, desc: 'Earn an extra $1 of interest for every $5 you have at end of round', + passive: (p) => { p.interestPerFive += 1; } }, + { id: 'satellite', name: 'Satellite', rarity: U, cost: 6, desc: 'Earn $1 at end of round per unique Planet card used this run', + onCashOut: (run) => run.stats.planetsUsedSet.length }, + { id: 'bull', name: 'Bull', rarity: U, cost: 6, desc: '+2 Chips for each $1 you have', + independent: (ctx) => ctx.chips(2 * Math.max(0, ctx.run.gold), sj('bull')) }, + { id: 'bootstraps', name: 'Bootstraps', rarity: U, cost: 7, desc: '+2 Mult for every $5 you have', + independent: (ctx) => ctx.mult(2 * Math.floor(Math.max(0, ctx.run.gold) / 5), sj('bootstraps')) }, + { id: 'goldenticket', name: 'Golden Ticket', rarity: C, cost: 5, desc: 'Played Gold cards earn $4 when scored', + onScored: (ctx, j, card) => { if (card.enhancement === 'gold') ctx.money(4, sj('goldenticket', card)); } }, + { id: 'redcard', name: 'Red Card', rarity: C, cost: 5, desc: '+3 Mult for each Booster Pack skipped this run', + independent: (ctx) => ctx.mult(3 * ctx.run.stats.packsSkipped, sj('redcard')) }, + { id: 'swashbuckler', name: 'Swashbuckler', rarity: C, cost: 4, desc: 'Adds the sell value of all other owned Jokers to Mult', + independent: (ctx, j) => ctx.mult(ctx.run.jokers.filter((o) => o.uid !== j.uid).reduce((s, o) => s + o.sellValue, 0), sj('swashbuckler')) }, + { id: 'fortuneteller', name: 'Fortune Teller', rarity: C, cost: 6, desc: '+1 Mult per Tarot card used this run', + independent: (ctx) => ctx.mult(ctx.run.stats.tarotsUsed, sj('fortuneteller')) }, + { id: 'tradingcard', name: 'Trading Card', rarity: U, cost: 6, desc: 'If the first discard of a round has only 1 card, destroy it and earn $3', + onDiscard: (run, j, cards, info) => { + if (info.isFirst && cards.length === 1) { info.api.destroyCards(run, [cards[0].uid]); run.gold += 3; } + } }, + + // Retriggers + { id: 'hack', name: 'Hack', rarity: U, cost: 6, desc: 'Retrigger each played 2, 3, 4 or 5', + cardRetriggers: (ctx, j, card) => ([2, 3, 4, 5].includes(card.rank) && card.enhancement !== 'stone' ? 1 : 0) }, + { id: 'dusk', name: 'Dusk', rarity: U, cost: 5, desc: 'Retrigger all played cards on the final hand of the round', + cardRetriggers: (ctx) => (ctx.run.handsLeft === 1 ? 1 : 0) }, + { id: 'sockandbuskin', name: 'Sock and Buskin', rarity: U, cost: 6, desc: 'Retrigger all played face cards', + cardRetriggers: (ctx, j, card) => (ctx.isFace(card) ? 1 : 0) }, + { id: 'seltzer', name: 'Seltzer', rarity: U, cost: 6, initState: () => ({ n: 10 }), + desc: (j) => `Retrigger all played cards for the next ${j.state.n} hands`, + cardRetriggers: () => 1, + afterHand: (ctx, j) => { j.state.n -= 1; }, + isDead: (j) => j.state.n <= 0 }, + { id: 'hangingchad', name: 'Hanging Chad', rarity: C, cost: 4, desc: 'Retrigger the first played card 2 additional times', + cardRetriggers: (ctx, j, card, i) => (i === 0 ? 2 : 0) }, + { id: 'mime', name: 'Mime', rarity: U, cost: 5, desc: 'Retrigger all card abilities held in hand', + heldRetriggers: () => 1 }, + + // Utility / negative-space + { id: 'juggler', name: 'Juggler', rarity: C, cost: 4, desc: '+1 hand size', + passive: (p) => { p.handSize += 1; } }, + { id: 'drunkard', name: 'Drunkard', rarity: C, cost: 4, desc: '+1 discard every round', + passive: (p) => { p.discards += 1; } }, + { id: 'troubadour', name: 'Troubadour', rarity: U, cost: 6, desc: '+2 hand size, -1 hand every round', + passive: (p) => { p.handSize += 2; p.hands -= 1; } }, + { id: 'merryandy', name: 'Merry Andy', rarity: U, cost: 7, desc: '+3 discards every round, -1 hand size', + passive: (p) => { p.discards += 3; p.handSize -= 1; } }, + { id: 'turtlebean', name: 'Turtle Bean', rarity: U, cost: 6, initState: () => ({ n: 5 }), + desc: (j) => `+${j.state.n} hand size; reduces by 1 every round`, + passive: (p, j) => { p.handSize += j.state.n; }, + onRoundEnd: (run, j) => { j.state.n -= 1; }, + isDead: (j) => j.state.n <= 0 }, + { id: 'burglar', name: 'Burglar', rarity: U, cost: 6, desc: 'When a Blind starts, gain 3 extra hands but lose all discards', + onBlindStart: (run) => { run.handsLeft += 3; run.discardsLeft = 0; } }, + { id: 'creditcard', name: 'Credit Card', rarity: C, cost: 1, desc: 'Go up to -$20 in debt', + passive: (p) => { p.creditFloor = -20; } }, + { id: 'chaostheclown', name: 'Chaos the Clown', rarity: C, cost: 4, desc: '1 free reroll per shop', + passive: (p) => { p.freeRerolls += 1; } }, + { id: 'oops', name: 'Oops! All 6s', rarity: U, cost: 4, desc: 'Doubles all listed probabilities (e.g. 1 in 4 becomes 2 in 4)', + passive: (p) => { p.oops = true; } }, + { id: 'pareidolia', name: 'Pareidolia', rarity: U, cost: 5, desc: 'All cards are considered face cards', + passive: (p) => { p.pareidolia = true; } }, + { id: 'splash', name: 'Splash', rarity: C, cost: 3, desc: 'Every played card counts in scoring', + passive: (p) => { p.splash = true; } }, + { id: 'smeared', name: 'Smeared Joker', rarity: U, cost: 7, desc: 'Hearts and Diamonds count as the same suit; Spades and Clubs count as the same suit', + passive: (p) => { p.smeared = true; } }, + { id: 'shortcut', name: 'Shortcut', rarity: U, cost: 7, desc: 'Straights can be made with gaps of 1 rank', + passive: (p) => { p.shortcut = true; } }, + { id: 'fourfingers', name: 'Four Fingers', rarity: U, cost: 7, desc: 'All Flushes and Straights can be made with 4 cards', + passive: (p) => { p.fourFingers = true; } }, + { id: 'stencil', name: 'Joker Stencil', rarity: U, cost: 8, desc: 'x1 Mult for each empty Joker slot (Joker Stencil included)', + independent: (ctx) => { + const empty = Math.max(0, ctx.passives.jokerSlots - ctx.run.jokers.length) + 1; + if (empty > 1) ctx.xmult(empty, sj('stencil')); + } }, + { id: 'eightball', name: '8 Ball', rarity: C, cost: 5, desc: 'Each played 8 has a 1 in 4 chance to create a Tarot card when scored', + onScored: (ctx, j, card) => { if (card.rank === 8 && card.enhancement !== 'stone' && ctx.prob(4)) ctx.createConsumable('tarot', sj('eightball', card)); } }, + { id: 'superposition', name: 'Superposition', rarity: C, cost: 4, desc: 'Create a Tarot card if played hand contains an Ace and a Straight', + afterHand: (ctx) => { + const straight = ctx.handType === 'straight' || ctx.handType === 'straightflush'; + if (straight && ctx.playedCards.some((c) => c.rank === 14 && c.enhancement !== 'stone')) ctx.createConsumable('tarot', sj('superposition')); + } }, + { id: 'vagabond', name: 'Vagabond', rarity: R, cost: 8, desc: 'Create a Tarot card if the hand is played with $4 or less', + afterHand: (ctx) => { if (ctx.run.gold <= 4) ctx.createConsumable('tarot', sj('vagabond')); } }, + { id: 'cartomancer', name: 'Cartomancer', rarity: U, cost: 8, desc: 'Create a Tarot card when a Blind is selected', + onBlindStart: (run, j, api) => api.createConsumable(run, 'tarot') }, + { id: 'mysticsummit', name: 'Mystic Summit', rarity: C, cost: 5, desc: '+15 Mult when 0 discards remain', + independent: (ctx) => { if (ctx.run.discardsLeft === 0) ctx.mult(15, sj('mysticsummit')); } }, + { id: 'loyaltycard', name: 'Loyalty Card', rarity: U, cost: 5, initState: () => ({ n: 5 }), + desc: (j) => `x4 Mult every 6 hands played (${j.state.n} remaining)`, + independent: (ctx, j) => { if (j.state.n === 0) ctx.xmult(4, sj('loyaltycard')); }, + afterHand: (ctx, j) => { j.state.n = j.state.n === 0 ? 5 : j.state.n - 1; } }, + { id: 'raisedfist', name: 'Raised Fist', rarity: C, cost: 5, desc: 'Adds double the rank value of the lowest ranked card held in hand to Mult', + independent: (ctx) => { + const held = ctx.heldCards.filter((c) => c.enhancement !== 'stone'); + if (!held.length) return; + const low = held.reduce((m, c) => (c.rank < m.rank ? c : m)); + ctx.mult(2 * rankChips(low.rank), sj('raisedfist', low)); + } }, + { id: 'shootthemoon', name: 'Shoot the Moon', rarity: C, cost: 5, desc: 'Each Queen held in hand gives +13 Mult', + onHeld: (ctx, j, card) => { if (card.rank === 12 && card.enhancement !== 'stone') ctx.mult(13, sj('shootthemoon', card)); } }, + { id: 'abstract', name: 'Abstract Joker', rarity: C, cost: 4, desc: '+3 Mult for each Joker you own', + independent: (ctx) => ctx.mult(3 * ctx.run.jokers.length, sj('abstract')) }, + { id: 'grosmichel', name: 'Gros Michel', rarity: C, cost: 5, desc: '+15 Mult; 1 in 6 chance it is destroyed at end of round', + independent: (ctx) => ctx.mult(15, sj('grosmichel')), + onRoundEnd: (run, j, api) => (api.prob(6) ? { destroyed: true } : null) }, + { id: 'stonejoker', name: 'Stone Joker', rarity: U, cost: 6, desc: '+25 Chips for each Stone Card in your full deck', + independent: (ctx) => ctx.chips(25 * ctx.run.deck.filter((c) => c.enhancement === 'stone').length, sj('stonejoker')) }, + { id: 'baseball', name: 'Baseball Card', rarity: R, cost: 8, desc: 'Each Uncommon Joker you own gives x1.5 Mult', + independent: (ctx, j) => { + for (const o of ctx.run.jokers) { + if (o.uid === j.uid) continue; + const d = JOKER_BY_ID[o.id]; + if (d && d.rarity === U) ctx.xmult(1.5, { kind: 'joker', id: 'baseball', jokerUid: j.uid, via: o.id }); + } + } }, + + // Copiers + { id: 'dna', name: 'DNA', rarity: R, cost: 8, desc: 'If the first hand of the round has only 1 card, add a permanent copy of it to your deck and draw it to hand', + afterHand: (ctx) => { + if (ctx.run.handsPlayedThisRound === 0 && ctx.playedCards.length === 1) { + const src = ctx.playedCards[0]; + const copy = { ...src, uid: ctx.run.uidCounter++, faceDown: false, debuffed: false }; + ctx.run.deck.push(copy); + ctx.run.hand.push(copy.uid); + ctx.run.stats.cardsAddedToDeck += 1; + ctx.emit({ t: 'info', source: sj('dna'), text: 'Copied card' }); + } + } }, + { id: 'blueprint', name: 'Blueprint', rarity: R, cost: 10, copyable: false, desc: 'Copies the ability of the Joker to the right' }, + { id: 'brainstorm', name: 'Brainstorm', rarity: R, cost: 10, copyable: false, desc: 'Copies the ability of the leftmost Joker' }, +]; + +function sj(id, card) { + const s = { kind: 'joker', id }; + if (card) s.cardUid = card.uid; + return s; +} +function suitName(s) { return { S: 'Spade', H: 'Heart', D: 'Diamond', C: 'Club' }[s]; } + +export const JOKER_BY_ID = Object.fromEntries(JOKERS.map((j) => [j.id, j])); + +export function jokerPool(rarity) { return JOKERS.filter((j) => j.rarity === rarity); } + +export function makeJokerInstance(id, uid, rng, edition = null) { + const def = JOKER_BY_ID[id]; + return { + id, uid, edition, + sellValue: Math.max(1, Math.floor((def.cost + (edition ? ({ foil: 2, holo: 3, poly: 5, negative: 5 })[edition] : 0)) / 2)), + state: def.initState ? def.initState(rng) : {}, + debuffedByBoss: false, + }; +} + +// Blueprint/Brainstorm resolution: each owned joker maps to the definition it +// acts as. Copies read the target's state (hookInst) but keep their own +// edition/debuff (inst). Cycle-guarded; non-copyable targets resolve to no-op. +export function resolveEffectiveJokers(run) { + return run.jokers.map((inst, idx) => { + let def = JOKER_BY_ID[inst.id]; + let hookInst = inst; + let i = idx; + const visited = new Set([idx]); + while (def && (def.id === 'blueprint' || def.id === 'brainstorm')) { + const ni = def.id === 'blueprint' ? i + 1 : 0; + if (ni >= run.jokers.length || visited.has(ni)) { def = null; break; } + visited.add(ni); + i = ni; + hookInst = run.jokers[ni]; + def = JOKER_BY_ID[hookInst.id]; + } + const isCopy = hookInst !== inst; + if (isCopy && def && def.copyable === false) def = null; + return { inst, hookInst, def: def || {}, isCopy }; + }); +} diff --git a/src/games/balatro/BalatroLogic.js b/src/games/balatro/BalatroLogic.js new file mode 100644 index 0000000..155f22d --- /dev/null +++ b/src/games/balatro/BalatroLogic.js @@ -0,0 +1,968 @@ +// BalatroLogic.js +// Pure (no Phaser) run engine for Balatro: seeded serializable RNG, the ante / +// blind state machine, hand play + discard, cash out, shop (cards, vouchers, +// booster packs, rerolls), consumable resolution, boss-blind hooks, endless +// mode and save/load. BalatroGame renders state and forwards input here; +// tools/verifyBalatro.js drives it headless. +// +// Every public mutator restores the RNG from run.rngState on entry and writes +// it back on exit, so a serialized run resumes deterministically. + +import { + SUITS, DEFAULTS, HAND_TYPES, DECK_BY_ID, DECKS, anteBase, BLINDS, isFaceRank, + TAROTS, PLANETS, SPECTRALS, TAROT_BY_ID, PLANET_BY_ID, SPECTRAL_BY_ID, PLANET_BY_HAND, + VOUCHERS, VOUCHER_BY_ID, VOUCHER_COST, BOOSTERS, BOOSTER_BY_ID, BOOSTER_WEIGHTS, + SHOP_CARD_WEIGHTS, SHOP_SPECTRAL_WEIGHT, RARITY_WEIGHTS, CONSUMABLE_PRICES, + SHOP_EDITION_ODDS, BOSSES, FINISHERS, BOSS_BY_ID, EDITIONS, +} from './BalatroData.js'; +import { JOKERS, JOKER_BY_ID, jokerPool, makeJokerInstance, resolveEffectiveJokers } from './BalatroJokers.js'; +import { scorePlayedHand, evaluate, cardHasSuit } from './BalatroScoring.js'; + +export const SAVE_VERSION = 1; + +// ── RNG (mulberry32, seedable & serializable) ──────────────────────────────── +export function makeRng(seed) { + let s = (seed >>> 0) || 1; + const fn = () => { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + fn.int = (n) => Math.floor(fn() * n); + fn.pick = (arr) => arr[fn.int(arr.length)]; + fn.shuffle = (arr) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = fn.int(i + 1); [a[i], a[j]] = [a[j], a[i]]; } return a; }; + fn.getState = () => s >>> 0; + fn.setState = (v) => { s = (v >>> 0) || 1; }; + return fn; +} + +function rngOf(run) { const r = makeRng(1); r.setState(run.rngState); return r; } +function saveRng(run, rng) { run.rngState = rng.getState(); } +function weighted(rng, pairs) { + const total = pairs.reduce((s, [, w]) => s + w, 0); + let roll = rng() * total; + for (const [v, w] of pairs) { roll -= w; if (roll < 0) return v; } + return pairs[pairs.length - 1][0]; +} + +// ── Run construction ───────────────────────────────────────────────────────── +export function newRun(deckId, seed = (Math.random() * 1e9) | 0) { + const deckDef = DECK_BY_ID[deckId] || DECK_BY_ID.red; + const mods = deckDef.mods || {}; + const run = { + v: SAVE_VERSION, + seed, rngState: 0, + deckId: deckDef.id, + uidCounter: 1, + ante: 1, blind: 'small', phase: 'blindselect', round: 0, + gold: DEFAULTS.startGold + (mods.gold || 0), + won: false, endless: false, pendingWin: false, lostAt: null, + deck: [], drawPile: [], hand: [], + handsLeft: 0, discardsLeft: 0, handSizePenalty: 0, + discardsUsedThisRound: 0, firstDiscardDone: false, + handsPlayedThisRound: 0, handTypesPlayedThisRound: [], + handLevels: Object.fromEntries(HAND_TYPES.map((h) => [h.id, 1])), + handPlayCounts: {}, + jokers: [], consumables: [], vouchers: [], + bossId: null, lastBossId: null, bossState: {}, anteVoucherId: null, + playedUidsThisAnte: [], + shop: null, pack: null, + lastConsumable: null, + sortMode: 'rank', + roundScore: 0, blindChips: 0, cashout: null, + startDeckSize: 52, + stats: { + bestHandScore: 0, bestRoundScore: 0, bestAnte: 1, + tarotsUsed: 0, planetsUsed: 0, planetsUsedSet: [], spectralsUsed: 0, + blindsSkipped: 0, packsSkipped: 0, cardsAddedToDeck: 0, handsPlayed: 0, + }, + }; + const rng = makeRng(seed); + for (const suit of SUITS) { + for (let rank = 2; rank <= 14; rank++) { + run.deck.push({ uid: run.uidCounter++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false }); + } + } + for (const v of mods.startVouchers || []) run.vouchers.push(v); + for (const c of mods.startConsumables || []) run.consumables.push({ kind: c.kind, id: c.id, uid: run.uidCounter++ }); + saveRng(run, rng); + beginAnte(run); + return run; +} + +export function serializeRun(run) { return JSON.stringify(run); } +export function deserializeRun(json) { + try { + const run = JSON.parse(json); + if (!run || run.v !== SAVE_VERSION || !Array.isArray(run.deck)) return null; + return run; + } catch (e) { return null; } +} + +// ── Derived passives ───────────────────────────────────────────────────────── +// Always recomputed, never stored: buying/selling jokers or vouchers is +// trivially consistent. +export function passives(run) { + const mods = (DECK_BY_ID[run.deckId] || {}).mods || {}; + const p = { + handSize: DEFAULTS.handSize - run.handSizePenalty, + hands: DEFAULTS.hands + (mods.hands || 0), + discards: DEFAULTS.discards + (mods.discards || 0), + jokerSlots: DEFAULTS.jokerSlots + (mods.jokerSlots || 0), + consumableSlots: DEFAULTS.consumableSlots + (mods.consumableSlots || 0), + freeRerolls: 0, creditFloor: 0, + oops: false, pareidolia: false, splash: false, smeared: false, shortcut: false, fourFingers: false, + interestPerFive: 1, interestCap: DEFAULTS.interestCap, + rerollOff: 0, discount: 0, editionOdds: 1, shopCards: 2, telescope: false, + noInterest: !!mods.noInterest, unusedHand: mods.unusedHand || 0, unusedDiscard: mods.unusedDiscard || 0, + shopSpectral: !!mods.shopSpectral, + }; + for (const vid of run.vouchers) { + const v = VOUCHER_BY_ID[vid]; + if (!v) continue; + const m = v.mods; + if (m.shopCards) p.shopCards += m.shopCards; + if (m.discount) p.discount = Math.max(p.discount, m.discount); + if (m.editionOdds) p.editionOdds *= m.editionOdds; + if (m.rerollOff) p.rerollOff += m.rerollOff; + if (m.hands) p.hands += m.hands; + if (m.discards) p.discards += m.discards; + if (m.interestCap) p.interestCap = Math.max(p.interestCap, m.interestCap); + if (m.consumableSlots) p.consumableSlots += m.consumableSlots; + if (m.telescope) p.telescope = true; + } + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.passive) def.passive(p, inst, run); + if (inst.edition === 'negative') p.jokerSlots += 1; + } + if (run.phase === 'playing' && run.blind === 'boss') { + const boss = BOSS_BY_ID[run.bossId]; + if (boss && boss.handSizeDelta) p.handSize += boss.handSizeDelta; + } + p.handSize = Math.max(1, p.handSize); + p.hands = Math.max(1, p.hands); + p.discards = Math.max(0, p.discards); + return p; +} + +// ── Small shared helpers ───────────────────────────────────────────────────── +export function cardsOf(run, uids) { + const byUid = new Map(run.deck.map((c) => [c.uid, c])); + return uids.map((u) => byUid.get(u)).filter(Boolean); +} +export function handCards(run) { return cardsOf(run, run.hand); } + +function effSuitsOf(run, p) { + return (card) => { + if (card.enhancement === 'stone') return []; + if (card.enhancement === 'wild') return ['S', 'H', 'D', 'C']; + if (p.smeared) return (card.suit === 'H' || card.suit === 'D') ? ['H', 'D'] : ['S', 'C']; + return [card.suit]; + }; +} + +export function isDebuffed(run, card) { + if (run.phase !== 'playing' || run.blind !== 'boss') return false; + const boss = BOSS_BY_ID[run.bossId]; + if (!boss || !boss.cardDebuffed) return false; + const p = passives(run); + return !!boss.cardDebuffed(run, card, effSuitsOf(run, p), (c) => c.enhancement !== 'stone' && (p.pareidolia || isFaceRank(c.rank))); +} + +function makeApi(run, rng, p) { + const api = { + rng, + initial: false, + prob: (n) => rng() < Math.min(1, (p.oops ? 2 : 1) / n), + isFace: (c) => c.enhancement !== 'stone' && (p.pareidolia || isFaceRank(c.rank)), + hasSuit: (c, suit) => cardHasSuit(c, suit, p.smeared), + mostPlayedHand: (r) => { + let best = null, bestN = -1; + for (const [h, n] of Object.entries(r.handPlayCounts)) if (n > bestN) { best = h; bestN = n; } + return best; + }, + discardRandom: (r, n) => { + for (let i = 0; i < n && r.hand.length; i++) r.hand.splice(rng.int(r.hand.length), 1); + }, + destroyCards: (r, uids) => { + r.deck = r.deck.filter((c) => !uids.includes(c.uid)); + r.hand = r.hand.filter((u) => !uids.includes(u)); + r.drawPile = r.drawPile.filter((u) => !uids.includes(u)); + }, + disableRandomJoker: (r) => { + for (const j of r.jokers) j.debuffedByBoss = false; + if (r.jokers.length) rng.pick(r.jokers).debuffedByBoss = true; + }, + forceRandomCard: (r) => { r.bossState.forcedUid = r.hand.length ? rng.pick(r.hand) : null; }, + destroyRandomOtherJoker: (r, j) => { + const others = r.jokers.filter((o) => o.uid !== j.uid); + if (others.length) { const victim = rng.pick(others); r.jokers = r.jokers.filter((o) => o.uid !== victim.uid); } + }, + createConsumable: (r, kind, id = null) => { + const pp = passives(r); + if (r.consumables.length >= pp.consumableSlots) return false; + const pool = kind === 'tarot' ? TAROTS : kind === 'planet' ? PLANETS : SPECTRALS; + const pick = id ? pool.find((x) => x.id === id) : pool[rng.int(pool.length)]; + if (!pick) return false; + r.consumables.push({ kind, id: pick.id, uid: r.uidCounter++ }); + return true; + }, + }; + return api; +} + +function removeDeadJokers(run) { + const dead = []; + run.jokers = run.jokers.filter((inst) => { + const def = JOKER_BY_ID[inst.id]; + if (def && def.isDead && def.isDead(inst)) { dead.push(inst.id); return false; } + return true; + }); + return dead; +} + +// ── Ante / blind flow ──────────────────────────────────────────────────────── +export function bossFor(run) { return BOSS_BY_ID[run.bossId] || null; } + +export function blindChipsFor(run, blind) { + const base = anteBase(run.ante); + if (blind !== 'boss') return Math.floor(base * BLINDS[blind].mult); + const boss = bossFor(run); + return Math.floor(base * ((boss && boss.reqMult) || 2)); +} + +export function beginAnte(run) { + const rng = rngOf(run); + run.blind = 'small'; + run.phase = 'blindselect'; + run.playedUidsThisAnte = []; + run.stats.bestAnte = Math.max(run.stats.bestAnte, run.ante); + const finisher = run.ante % 8 === 0; + const pool = finisher ? FINISHERS : BOSSES.filter((b) => (b.minAnte || 1) <= run.ante && b.id !== run.lastBossId); + const boss = rng.pick(pool.length ? pool : (finisher ? FINISHERS : BOSSES)); + run.bossId = boss.id; + run.lastBossId = boss.id; + // One voucher offer per ante; persists in the shop until bought. + const owned = run.vouchers; + const offerable = VOUCHERS.filter((v) => !owned.includes(v.id) && (!v.requires || owned.includes(v.requires))); + run.anteVoucherId = offerable.length ? rng.pick(offerable).id : null; + saveRng(run, rng); +} + +export function skipBlind(run) { + if (run.phase !== 'blindselect') return { ok: false, error: 'Not choosing a blind' }; + if (!BLINDS[run.blind].skippable) return { ok: false, error: 'The Boss Blind cannot be skipped' }; + run.stats.blindsSkipped += 1; + run.blind = run.blind === 'small' ? 'big' : 'boss'; + return { ok: true }; +} + +export function selectBlind(run) { + if (run.phase !== 'blindselect') return { ok: false, error: 'Not choosing a blind' }; + const rng = rngOf(run); + const p = passives(run); + const api = makeApi(run, rng, p); + if (run.blind !== 'boss') { + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onBlindSelected) def.onBlindSelected(run, inst, run.blind, api); + } + } + startBlind(run, rng); + saveRng(run, rng); + return { ok: true }; +} + +function startBlind(run, rng) { + const p = passives(run); + run.phase = 'playing'; + run.roundScore = 0; + run.blindChips = blindChipsFor(run, run.blind); + run.handsLeft = p.hands; + run.discardsLeft = p.discards; + run.discardsUsedThisRound = 0; + run.firstDiscardDone = false; + run.handsPlayedThisRound = 0; + run.handTypesPlayedThisRound = []; + run.bossState = { handsPlayed: 0, forcedUid: null }; + for (const c of run.deck) { c.faceDown = false; c.debuffed = false; } + for (const j of run.jokers) j.debuffedByBoss = false; + run.drawPile = rng.shuffle(run.deck.map((c) => c.uid)); + run.hand = []; + + const api = makeApi(run, rng, p); + const boss = run.blind === 'boss' ? bossFor(run) : null; + if (boss && boss.onBlindStart) boss.onBlindStart(run, api); + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onBlindStart) def.onBlindStart(run, inst, api); + } + api.initial = true; + drawToHandSize(run, rng, api); + api.initial = false; + // Cerulean Bell's onBlindStart fires before cards exist; force after the deal. + if (boss && boss.id === 'cerulean_bell') api.forceRandomCard(run); +} + +function drawToHandSize(run, rng, api) { + const p = passives(run); + const drawn = []; + const boss = run.blind === 'boss' ? bossFor(run) : null; + const byUid = new Map(run.deck.map((c) => [c.uid, c])); + while (run.hand.length < p.handSize && run.drawPile.length) { + const uid = run.drawPile.pop(); + const card = byUid.get(uid); + if (boss && boss.drawTransform) boss.drawTransform(run, card, api); + run.hand.push(uid); + drawn.push(uid); + } + sortHand(run); + return drawn; +} + +export function sortHand(run, mode = null) { + if (mode) run.sortMode = mode; + const byUid = new Map(run.deck.map((c) => [c.uid, c])); + const suitOrder = { S: 0, H: 1, D: 2, C: 3 }; + run.hand.sort((a, b) => { + const ca = byUid.get(a), cb = byUid.get(b); + if (run.sortMode === 'suit') { + if (suitOrder[ca.suit] !== suitOrder[cb.suit]) return suitOrder[ca.suit] - suitOrder[cb.suit]; + return cb.rank - ca.rank; + } + if (cb.rank !== ca.rank) return cb.rank - ca.rank; + return suitOrder[ca.suit] - suitOrder[cb.suit]; + }); +} + +// ── Playing ────────────────────────────────────────────────────────────────── +export function validatePlay(run, uids) { + if (run.phase !== 'playing') return 'Not in a round'; + if (run.handsLeft <= 0) return 'No hands left'; + if (!uids.length || uids.length > 5) return 'Select 1 to 5 cards'; + if (!uids.every((u) => run.hand.includes(u))) return 'Card not in hand'; + if (run.bossState.forcedUid && !uids.includes(run.bossState.forcedUid)) return 'A card is forced by the Boss Blind'; + const boss = run.blind === 'boss' ? bossFor(run) : null; + if (boss && boss.validPlay) { + const p = passives(run); + const { handType } = evaluate(cardsOf(run, uids), p); + const err = boss.validPlay(run, uids, handType); + if (err) return err; + } + return null; +} + +// Boss constraints (The Psychic / Eye / Mouth, Cerulean Bell) can leave no +// legal selection. The scene checks this to offer a forfeit instead of a +// soft-lock; discards may still open up a play. +export function anyLegalPlay(run) { + if (run.phase !== 'playing' || run.handsLeft <= 0) return false; + const hand = run.hand; + const found = { hit: false }; + const rec = (start, cur) => { + if (found.hit) return; + if (cur.length >= 1 && cur.length <= 5 && !validatePlay(run, cur)) { found.hit = true; return; } + if (cur.length >= 5) return; + for (let i = start; i < hand.length && !found.hit; i++) { cur.push(hand[i]); rec(i + 1, cur); cur.pop(); } + }; + rec(0, []); + return found.hit; +} + +export function forfeitRound(run) { + if (run.phase !== 'playing') return { ok: false, error: 'Not in a round' }; + run.phase = 'over'; + run.lostAt = { ante: run.ante, blind: run.blind }; + return { ok: true }; +} + +export function playHand(run, uids) { + const err = validatePlay(run, uids); + if (err) return { ok: false, error: err }; + const rng = rngOf(run); + const p = passives(run); + const api = makeApi(run, rng, p); + + // Score in hand (left→right) order regardless of click order. + const ordered = run.hand.filter((u) => uids.includes(u)); + const played = cardsOf(run, ordered); + for (const c of played) c.faceDown = false; + const held = cardsOf(run, run.hand.filter((u) => !uids.includes(u))); + + const effJokers = resolveEffectiveJokers(run); + const trace = scorePlayedHand(run, played, held, { + effJokers, passives: p, rng, + isDebuffed: (c) => isDebuffed(run, c), + boss: run.blind === 'boss' ? bossFor(run) : null, + }); + + run.roundScore += trace.score; + run.handsPlayedThisRound += 1; + run.stats.handsPlayed += 1; + run.handTypesPlayedThisRound.push(trace.handType); + run.handPlayCounts[trace.handType] = (run.handPlayCounts[trace.handType] || 0) + 1; + run.playedUidsThisAnte.push(...ordered); + run.stats.bestHandScore = Math.max(run.stats.bestHandScore, trace.score); + run.hand = run.hand.filter((u) => !uids.includes(u)); + if (trace.destroyedUids.length) api.destroyCards(run, trace.destroyedUids); + const destroyedJokers = removeDeadJokers(run); + + const boss = run.blind === 'boss' ? bossFor(run) : null; + if (boss && boss.afterHandPlayed) boss.afterHandPlayed(run, api, { handType: trace.handType }); + run.bossState.handsPlayed += 1; + run.handsLeft -= 1; + + let outcome = 'continue'; + let drawn = []; + if (run.roundScore >= run.blindChips || !Number.isFinite(run.roundScore)) { + outcome = 'blindWon'; + winBlind(run, rng, api, trace.handType); + } else if (run.handsLeft <= 0) { + outcome = 'runLost'; + run.phase = 'over'; + run.lostAt = { ante: run.ante, blind: run.blind }; + } else { + drawn = drawToHandSize(run, rng, api); + if (run.bossState.forcedUid !== null && !run.hand.includes(run.bossState.forcedUid)) api.forceRandomCard(run); + } + saveRng(run, rng); + return { ok: true, trace, outcome, drawn, destroyedJokers }; +} + +function winBlind(run, rng, api, lastHandType) { + run.round += 1; + run.stats.bestRoundScore = Math.max(run.stats.bestRoundScore, Number.isFinite(run.roundScore) ? run.roundScore : run.stats.bestRoundScore); + // Blue seals held at end of round create the Planet of the last played hand. + for (const card of handCards(run)) { + if (card.seal === 'blue') { + const planet = PLANET_BY_HAND[lastHandType]; + if (planet) api.createConsumable(run, 'planet', planet.id); + } + card.faceDown = false; + } + if (run.blind === 'boss') { + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onBossDefeated) def.onBossDefeated(run, inst); + } + } + run.cashout = computeCashOut(run); + run.phase = 'cashout'; + if (run.blind === 'boss' && run.ante === 8 && !run.endless) run.pendingWin = true; + if (!Number.isFinite(run.roundScore)) { run.pendingWin = true; } +} + +export function computeCashOut(run) { + const p = passives(run); + const lines = [{ label: BLINDS[run.blind].name, amount: BLINDS[run.blind].reward }]; + if (run.handsLeft > 0) { + lines.push({ label: `${run.handsLeft} remaining hand${run.handsLeft > 1 ? 's' : ''}`, amount: run.handsLeft * (p.unusedHand || 1) }); + } + if (p.unusedDiscard && run.discardsLeft > 0) { + lines.push({ label: `${run.discardsLeft} remaining discard${run.discardsLeft > 1 ? 's' : ''}`, amount: run.discardsLeft * p.unusedDiscard }); + } + if (!p.noInterest && run.gold > 0) { + const interest = Math.min(Math.floor(run.gold / DEFAULTS.interestPer), p.interestCap) * p.interestPerFive; + if (interest > 0) lines.push({ label: 'Interest', amount: interest }); + } + const goldCards = handCards(run).filter((c) => c.enhancement === 'gold' && !c.debuffed).length; + if (goldCards > 0) lines.push({ label: `${goldCards} Gold card${goldCards > 1 ? 's' : ''} held`, amount: goldCards * 3 }); + for (const e of resolveEffectiveJokers(run)) { + if (!e.def.onCashOut || e.inst.debuffedByBoss) continue; + const amount = e.def.onCashOut(run, e.hookInst) || 0; + if (amount > 0) lines.push({ label: e.def.name || e.inst.id, amount }); + } + return { lines, total: lines.reduce((s, l) => s + l.amount, 0) }; +} + +// Collect the cash-out and enter the shop (or the win screen). +export function collectCashOut(run) { + if (run.phase !== 'cashout') return { ok: false, error: 'Nothing to collect' }; + const rng = rngOf(run); + run.gold += run.cashout ? run.cashout.total : 0; + run.cashout = null; + // Round-end joker upkeep: decay, self-destruct rolls, suit rerolls, Egg. + const p = passives(run); + const api = makeApi(run, rng, p); + const destroyed = []; + run.jokers = run.jokers.filter((inst) => { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onRoundEnd) { + const res = def.onRoundEnd(run, inst, api); + if (res && res.destroyed) { destroyed.push(inst.id); return false; } + } + return true; + }); + destroyed.push(...removeDeadJokers(run)); + if (run.pendingWin) { + run.phase = 'over'; + run.won = true; + saveRng(run, rng); + return { ok: true, destroyed, won: true }; + } + enterShop(run, rng); + saveRng(run, rng); + return { ok: true, destroyed }; +} + +// Endless: from the win screen, keep the run going. +export function continueEndless(run) { + if (!run.won || run.endless) return { ok: false, error: 'No win to continue' }; + const rng = rngOf(run); + run.endless = true; + run.pendingWin = false; + run.phase = 'shop'; + enterShop(run, rng); + saveRng(run, rng); + return { ok: true }; +} + +// Leave the shop → next blind (or next ante after a boss). +export function leaveShop(run) { + if (run.phase !== 'shop') return { ok: false, error: 'Not in the shop' }; + if (run.pack) return { ok: false, error: 'Finish the open pack first' }; + run.shop = null; + if (run.blind === 'small') { run.blind = 'big'; run.phase = 'blindselect'; } + else if (run.blind === 'big') { run.blind = 'boss'; run.phase = 'blindselect'; } + else { run.ante += 1; beginAnte(run); } + return { ok: true }; +} + +// ── Discards ───────────────────────────────────────────────────────────────── +export function discard(run, uids) { + if (run.phase !== 'playing') return { ok: false, error: 'Not in a round' }; + if (run.discardsLeft <= 0) return { ok: false, error: 'No discards left' }; + if (!uids.length || uids.length > 5) return { ok: false, error: 'Select 1 to 5 cards' }; + if (!uids.every((u) => run.hand.includes(u))) return { ok: false, error: 'Card not in hand' }; + const rng = rngOf(run); + const p = passives(run); + const api = makeApi(run, rng, p); + const cards = cardsOf(run, uids); + for (const c of cards) c.faceDown = false; + + // Purple seals create a Tarot per discarded card (slots permitting). + for (const c of cards) { if (c.seal === 'purple') api.createConsumable(run, 'tarot'); } + const info = { isFirst: !run.firstDiscardDone, api }; + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onDiscard) def.onDiscard(run, inst, cards, info); + } + run.hand = run.hand.filter((u) => !uids.includes(u)); + run.discardsLeft -= 1; + run.discardsUsedThisRound += 1; + run.firstDiscardDone = true; + const destroyedJokers = removeDeadJokers(run); + const drawn = drawToHandSize(run, rng, api); + if (run.bossState.forcedUid !== null && !run.hand.includes(run.bossState.forcedUid)) api.forceRandomCard(run); + saveRng(run, rng); + return { ok: true, drawn, destroyedJokers }; +} + +// ── Shop ───────────────────────────────────────────────────────────────────── +function discounted(run, p, cost) { return Math.max(1, Math.round(cost * (1 - p.discount))); } + +function rollEdition(rng, p, jokerOk) { + const odds = SHOP_EDITION_ODDS; + const roll = rng(); + let acc = 0; + for (const ed of ['poly', 'holo', 'foil', 'negative']) { + if (ed === 'negative' && !jokerOk) continue; + acc += odds[ed] * p.editionOdds; + if (roll < acc) return ed; + } + return null; +} + +function rollShopCard(run, rng, p, excludeJokers) { + const weights = Object.entries(SHOP_CARD_WEIGHTS).map(([k, w]) => [k, w]); + if (p.shopSpectral) weights.push(['spectral', SHOP_SPECTRAL_WEIGHT]); + const kind = weighted(rng, weights); + if (kind === 'joker') { + const rarity = weighted(rng, Object.entries(RARITY_WEIGHTS)); + const owned = new Set([...run.jokers.map((j) => j.id), ...excludeJokers]); + let pool = jokerPool(rarity).filter((j) => !owned.has(j.id)); + if (!pool.length) pool = JOKERS.filter((j) => !owned.has(j.id)); + if (!pool.length) pool = [JOKER_BY_ID.joker]; + const def = rng.pick(pool); + const edition = rollEdition(rng, p, true); + const cost = discounted(run, p, def.cost + (edition ? EDITIONS[edition].cost : 0)); + return { slot: 'joker', id: def.id, edition, cost }; + } + if (kind === 'tarot' || kind === 'planet' || kind === 'spectral') { + const pool = kind === 'tarot' ? TAROTS : kind === 'planet' ? PLANETS : SPECTRALS; + const pick = rng.pick(pool); + return { slot: kind, id: pick.id, cost: discounted(run, p, CONSUMABLE_PRICES[kind]) }; + } + // Playing card: random rank/suit, small chance of enhancement / edition / seal. + const card = { rank: 2 + rng.int(13), suit: rng.pick(SUITS), enhancement: null, edition: null, seal: null }; + let cost = 1; + if (rng() < 0.4) { card.enhancement = rng.pick(['bonus', 'mult', 'wild', 'glass', 'steel', 'stone', 'gold', 'lucky']); cost += 1; } + const ed = rollEdition(rng, p, false); + if (ed) { card.edition = ed; cost += EDITIONS[ed].cost; } + if (rng() < 0.05) card.seal = rng.pick(['red', 'blue', 'gold', 'purple']); + return { slot: 'playing', card, cost: discounted(run, p, cost) }; +} + +function rollShop(run, rng) { + const p = passives(run); + const cards = []; + for (let i = 0; i < p.shopCards; i++) { + cards.push(rollShopCard(run, rng, p, cards.filter((c) => c.slot === 'joker').map((c) => c.id))); + } + const packs = []; + for (let i = 0; i < 2; i++) { + const id = weighted(rng, BOOSTER_WEIGHTS); + packs.push({ id, cost: discounted(run, p, BOOSTER_BY_ID[id].cost) }); + } + return { + cards, packs, + voucher: run.anteVoucherId ? { id: run.anteVoucherId, cost: discounted(run, p, VOUCHER_COST), sold: false } : null, + rerollCost: Math.max(0, DEFAULTS.rerollBase - p.rerollOff), + rerollsUsed: 0, + freeRerolls: p.freeRerolls, + }; +} + +function enterShop(run, rng) { + run.phase = 'shop'; + run.shop = rollShop(run, rng); +} + +export function rerollShop(run) { + if (run.phase !== 'shop' || !run.shop) return { ok: false, error: 'Not in the shop' }; + const rng = rngOf(run); + const p = passives(run); + const shop = run.shop; + let cost = 0; + if (shop.freeRerolls > 0) shop.freeRerolls -= 1; + else { + cost = shop.rerollCost; + if (run.gold - cost < p.creditFloor) return { ok: false, error: 'Not enough money' }; + run.gold -= cost; + shop.rerollCost += 1; + } + shop.rerollsUsed += 1; + shop.cards = []; + for (let i = 0; i < p.shopCards; i++) { + shop.cards.push(rollShopCard(run, rng, p, shop.cards.filter((c) => c.slot === 'joker').map((c) => c.id))); + } + saveRng(run, rng); + return { ok: true, cost }; +} + +export function buyShopCard(run, idx) { + if (run.phase !== 'shop' || !run.shop) return { ok: false, error: 'Not in the shop' }; + const item = run.shop.cards[idx]; + if (!item) return { ok: false, error: 'Nothing there' }; + const p = passives(run); + if (run.gold - item.cost < p.creditFloor) return { ok: false, error: 'Not enough money' }; + const rng = rngOf(run); + if (item.slot === 'joker') { + const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length; + if (item.edition !== 'negative' && slotsUsed >= p.jokerSlots) return { ok: false, error: 'No Joker slots left' }; + run.jokers.push(makeJokerInstance(item.id, run.uidCounter++, rng, item.edition)); + } else if (item.slot === 'playing') { + addCardToDeck(run, item.card); + } else { + if (run.consumables.length >= p.consumableSlots) return { ok: false, error: 'No consumable slots left' }; + run.consumables.push({ kind: item.slot, id: item.id, uid: run.uidCounter++ }); + } + run.gold -= item.cost; + run.shop.cards.splice(idx, 1); + saveRng(run, rng); + return { ok: true, item }; +} + +function addCardToDeck(run, proto) { + const card = { uid: run.uidCounter++, faceDown: false, debuffed: false, ...proto }; + run.deck.push(card); + run.stats.cardsAddedToDeck += 1; + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onCardAdded) def.onCardAdded(run, inst); + } + return card; +} + +export function buyVoucher(run) { + if (run.phase !== 'shop' || !run.shop || !run.shop.voucher || run.shop.voucher.sold) return { ok: false, error: 'No voucher available' }; + const p = passives(run); + const v = run.shop.voucher; + if (run.gold - v.cost < p.creditFloor) return { ok: false, error: 'Not enough money' }; + run.gold -= v.cost; + run.vouchers.push(v.id); + run.shop.voucher.sold = true; + run.anteVoucherId = null; + return { ok: true, id: v.id }; +} + +export function sellJoker(run, uid) { + const inst = run.jokers.find((j) => j.uid === uid); + if (!inst) return { ok: false, error: 'No such Joker' }; + run.jokers = run.jokers.filter((j) => j.uid !== uid); + run.gold += inst.sellValue; + return { ok: true, amount: inst.sellValue }; +} + +export function sellConsumable(run, uid) { + const inst = run.consumables.find((c) => c.uid === uid); + if (!inst) return { ok: false, error: 'No such card' }; + run.consumables = run.consumables.filter((c) => c.uid !== uid); + const amount = Math.max(1, Math.floor((CONSUMABLE_PRICES[inst.kind] || 3) / 2)); + run.gold += amount; + return { ok: true, amount }; +} + +// ── Booster packs ──────────────────────────────────────────────────────────── +export function buyPack(run, idx) { + if (run.phase !== 'shop' || !run.shop) return { ok: false, error: 'Not in the shop' }; + if (run.pack) return { ok: false, error: 'A pack is already open' }; + const slot = run.shop.packs[idx]; + if (!slot) return { ok: false, error: 'Nothing there' }; + const p = passives(run); + if (run.gold - slot.cost < p.creditFloor) return { ok: false, error: 'Not enough money' }; + const rng = rngOf(run); + const def = BOOSTER_BY_ID[slot.id]; + run.gold -= slot.cost; + run.shop.packs.splice(idx, 1); + const options = []; + const seen = new Set(); + for (let i = 0; i < def.options; i++) { + if (def.what === 'playing') { + const card = { rank: 2 + rng.int(13), suit: rng.pick(SUITS), enhancement: null, edition: null, seal: null }; + if (rng() < 0.35) card.enhancement = rng.pick(['bonus', 'mult', 'wild', 'glass', 'steel', 'gold', 'lucky']); + const ed = rollEdition(rng, p, false); + if (ed) card.edition = ed; + if (rng() < 0.08) card.seal = rng.pick(['red', 'blue', 'gold', 'purple']); + options.push({ what: 'playing', card }); + } else if (def.what === 'joker') { + const rarity = weighted(rng, Object.entries(RARITY_WEIGHTS)); + const owned = new Set([...run.jokers.map((j) => j.id), ...seen]); + let pool = jokerPool(rarity).filter((j) => !owned.has(j.id)); + if (!pool.length) pool = JOKERS.filter((j) => !owned.has(j.id)); + const jk = pool.length ? rng.pick(pool) : JOKER_BY_ID.joker; + seen.add(jk.id); + options.push({ what: 'joker', id: jk.id, edition: rollEdition(rng, p, true) }); + } else { + const pool = def.what === 'tarot' ? TAROTS : def.what === 'planet' ? PLANETS : SPECTRALS; + let pick; + if (def.what === 'planet' && i === 0 && p.telescope) { + const most = Object.entries(run.handPlayCounts).sort((a, b) => b[1] - a[1])[0]; + pick = most ? PLANET_BY_HAND[most[0]] : rng.pick(pool); + } else { + pick = rng.pick(pool.filter((x) => !seen.has(x.id)).length ? pool.filter((x) => !seen.has(x.id)) : pool); + } + seen.add(pick.id); + options.push({ what: def.what, id: pick.id }); + } + } + run.pack = { boosterId: def.id, options, picksLeft: def.picks }; + saveRng(run, rng); + return { ok: true }; +} + +export function pickFromPack(run, idx) { + if (!run.pack) return { ok: false, error: 'No open pack' }; + const opt = run.pack.options[idx]; + if (!opt) return { ok: false, error: 'Nothing there' }; + const p = passives(run); + const rng = rngOf(run); + if (opt.what === 'playing') { + addCardToDeck(run, opt.card); + } else if (opt.what === 'joker') { + const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length; + if (opt.edition !== 'negative' && slotsUsed >= p.jokerSlots) return { ok: false, error: 'No Joker slots left' }; + run.jokers.push(makeJokerInstance(opt.id, run.uidCounter++, rng, opt.edition || null)); + } else if (opt.what === 'planet') { + applyPlanet(run, opt.id); // Celestial picks are used immediately + } else { + if (run.consumables.length >= p.consumableSlots) return { ok: false, error: 'No consumable slots left' }; + run.consumables.push({ kind: opt.what, id: opt.id, uid: run.uidCounter++ }); + } + run.pack.options.splice(idx, 1); + run.pack.picksLeft -= 1; + if (run.pack.picksLeft <= 0 || !run.pack.options.length) run.pack = null; + saveRng(run, rng); + return { ok: true, picked: opt }; +} + +export function skipPack(run) { + if (!run.pack) return { ok: false, error: 'No open pack' }; + run.pack = null; + run.stats.packsSkipped += 1; + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onPackSkipped) def.onPackSkipped(run, inst); + } + return { ok: true }; +} + +// ── Consumables ────────────────────────────────────────────────────────────── +function applyPlanet(run, planetId) { + const planet = PLANET_BY_ID[planetId]; + run.handLevels[planet.hand] = (run.handLevels[planet.hand] || 1) + 1; + run.stats.planetsUsed += 1; + if (!run.stats.planetsUsedSet.includes(planetId)) run.stats.planetsUsedSet.push(planetId); + run.lastConsumable = { kind: 'planet', id: planetId }; + for (const inst of run.jokers) { + const def = JOKER_BY_ID[inst.id]; + if (def && def.onConsumableUsed) def.onConsumableUsed(run, inst, 'planet'); + } + return { note: `${planet.name}: ${planet.hand} is now level ${run.handLevels[planet.hand]}` }; +} + +export function consumableDef(inst) { + return inst.kind === 'tarot' ? TAROT_BY_ID[inst.id] : inst.kind === 'planet' ? PLANET_BY_ID[inst.id] : SPECTRAL_BY_ID[inst.id]; +} + +export function useConsumable(run, uid, targetUids = []) { + const inst = run.consumables.find((c) => c.uid === uid); + if (!inst) return { ok: false, error: 'No such card' }; + if (run.phase === 'over') return { ok: false, error: 'The run is over' }; + const def = consumableDef(inst); + const spec = def.effect || {}; + const needsTargets = def.targets || 0; + if (needsTargets > 0) { + if (run.phase !== 'playing') return { ok: false, error: 'Can only be used during a round' }; + if (!targetUids.length || targetUids.length > needsTargets) return { ok: false, error: `Select up to ${needsTargets} card${needsTargets > 1 ? 's' : ''}` }; + if (spec.kind === 'death' && targetUids.length !== 2) return { ok: false, error: 'Select exactly 2 cards' }; + if (!targetUids.every((u) => run.hand.includes(u))) return { ok: false, error: 'Card not in hand' }; + } + const rng = rngOf(run); + const p = passives(run); + const api = makeApi(run, rng, p); + const targets = cardsOf(run, run.hand.filter((u) => targetUids.includes(u))); + let note = def.name; + + // Remove before resolving so created consumables have room where possible. + run.consumables = run.consumables.filter((c) => c.uid !== uid); + + if (inst.kind === 'planet') { + note = applyPlanet(run, inst.id).note; + saveRng(run, rng); + return { ok: true, note }; + } + + switch (spec.kind) { + case 'fool': { + if (!run.lastConsumable) { run.consumables.push(inst); return { ok: false, error: 'No card to copy yet' }; } + if (!api.createConsumable(run, run.lastConsumable.kind, run.lastConsumable.id)) { + run.consumables.push(inst); + return { ok: false, error: 'No consumable slots left' }; + } + note = 'Copied the last card used'; + break; + } + case 'enhance': for (const c of targets) c.enhancement = spec.enh; break; + case 'create': { + if (spec.what === 'joker') { + const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length; + if (slotsUsed >= p.jokerSlots) { run.consumables.push(inst); return { ok: false, error: 'No Joker slots left' }; } + const owned = new Set(run.jokers.map((j) => j.id)); + const pool = JOKERS.filter((j) => !owned.has(j.id) && j.id !== 'blueprint' && j.id !== 'brainstorm'); + run.jokers.push(makeJokerInstance(rng.pick(pool.length ? pool : JOKERS).id, run.uidCounter++, rng)); + note = 'Created a Joker'; + } else { + let made = 0; + for (let i = 0; i < (spec.count || 1); i++) { if (api.createConsumable(run, spec.what)) made++; } + if (!made) { run.consumables.push(inst); return { ok: false, error: 'No consumable slots left' }; } + note = `Created ${made} ${spec.what}${made > 1 ? 's' : ''}`; + } + break; + } + case 'doubleMoney': { const gain = Math.max(0, Math.min(spec.max, run.gold)); run.gold += gain; note = `+$${gain}`; break; } + case 'wheel': { + const cands = run.jokers.filter((j) => !j.edition); + if (cands.length && api.prob(4)) { + const j = rng.pick(cands); + j.edition = weighted(rng, [['foil', 50], ['holo', 35], ['poly', 15]]); + note = `${JOKER_BY_ID[j.id].name} became ${EDITIONS[j.edition].name}!`; + } else note = 'Nope!'; + break; + } + case 'rankUp': for (const c of targets) { if (c.enhancement !== 'stone') c.rank = c.rank === 14 ? 2 : c.rank + 1; } break; + case 'destroy': api.destroyCards(run, targets.map((c) => c.uid)); note = `Destroyed ${targets.length}`; break; + case 'death': { + const [left, right] = targets; + left.rank = right.rank; left.suit = right.suit; + left.enhancement = right.enhancement; left.edition = right.edition; left.seal = right.seal; + note = 'Copied the card'; + break; + } + case 'temperance': { const gain = Math.min(spec.max, run.jokers.reduce((s, j) => s + j.sellValue, 0)); run.gold += gain; note = `+$${gain}`; break; } + case 'suit': for (const c of targets) { if (c.enhancement !== 'stone') c.suit = spec.suit; } break; + case 'seal': for (const c of targets) c.seal = spec.seal; break; + case 'aura': { if (targets[0]) { targets[0].edition = weighted(rng, [['foil', 50], ['holo', 35], ['poly', 15]]); } break; } + case 'wraith': { + const slotsUsed = run.jokers.filter((j) => j.edition !== 'negative').length; + if (slotsUsed >= p.jokerSlots) { run.consumables.push(inst); return { ok: false, error: 'No Joker slots left' }; } + const owned = new Set(run.jokers.map((j) => j.id)); + const pool = jokerPool('rare').filter((j) => !owned.has(j.id)); + run.jokers.push(makeJokerInstance(rng.pick(pool.length ? pool : jokerPool('rare')).id, run.uidCounter++, rng)); + run.gold = 0; + note = 'A Rare Joker appears... your money is gone'; + break; + } + case 'sigil': { const suit = rng.pick(SUITS); for (const c of handCards(run)) { if (c.enhancement !== 'stone') c.suit = suit; } note = 'All one suit'; break; } + case 'ouija': { const rank = 2 + rng.int(13); for (const c of handCards(run)) { if (c.enhancement !== 'stone') c.rank = rank; } run.handSizePenalty += 1; note = 'All one rank; -1 hand size'; break; } + case 'ectoplasm': { + if (!run.jokers.length) { run.consumables.push(inst); return { ok: false, error: 'No Jokers to enhance' }; } + rng.pick(run.jokers).edition = 'negative'; + run.handSizePenalty += 1; + note = 'Negative Joker; -1 hand size'; + break; + } + case 'immolate': { + const pool = run.hand.slice(); + const burn = []; + for (let i = 0; i < 5 && pool.length; i++) burn.push(pool.splice(rng.int(pool.length), 1)[0]); + api.destroyCards(run, burn); + run.gold += 20; + note = `Destroyed ${burn.length}; +$20`; + break; + } + case 'blackhole': { for (const h of HAND_TYPES) run.handLevels[h.id] += 1; note = 'Every hand leveled up'; break; } + case 'hex': { + if (!run.jokers.length) { run.consumables.push(inst); return { ok: false, error: 'No Jokers to enhance' }; } + const keep = rng.pick(run.jokers); + keep.edition = 'poly'; + run.jokers = [keep]; + note = `${JOKER_BY_ID[keep.id].name} is Polychrome; the rest are gone`; + break; + } + default: break; + } + + if (inst.kind === 'tarot') { + run.stats.tarotsUsed += 1; + if (spec.kind !== 'fool') run.lastConsumable = { kind: 'tarot', id: inst.id }; + } else if (inst.kind === 'spectral') { + run.stats.spectralsUsed += 1; + } + if (spec.kind === 'spectralAdd') { + // Handled here so the created cards land in hand + deck. + if (run.hand.length) { + const victim = run.hand[rng.int(run.hand.length)]; + api.destroyCards(run, [victim]); + } + for (let i = 0; i < spec.add; i++) { + const rank = spec.addKind === 'ace' ? 14 : spec.addKind === 'face' ? 11 + rng.int(3) : 2 + rng.int(9); + const card = addCardToDeck(run, { + rank, suit: rng.pick(SUITS), + enhancement: rng.pick(['bonus', 'mult', 'wild', 'glass', 'steel', 'gold', 'lucky']), + edition: null, seal: null, + }); + run.hand.push(card.uid); + } + sortHand(run); + note = `${def.name}: new cards in hand`; + } + for (const jinst of run.jokers) { + const jdef = JOKER_BY_ID[jinst.id]; + if (jdef && jdef.onConsumableUsed && inst.kind !== 'planet') jdef.onConsumableUsed(run, jinst, inst.kind); + } + saveRng(run, rng); + return { ok: true, note }; +} diff --git a/src/games/balatro/BalatroScoring.js b/src/games/balatro/BalatroScoring.js new file mode 100644 index 0000000..98c552f --- /dev/null +++ b/src/games/balatro/BalatroScoring.js @@ -0,0 +1,267 @@ +// BalatroScoring.js +// Pure poker-hand evaluator + the chips×mult scoring pipeline. The pipeline +// both mutates run state (gold, joker counters, hand levels under The Arm, +// Vampire strips, DNA copies) and emits an ordered trace of events; +// BalatroGame replays the trace as animation and never recomputes scoring. +// Every event carries chipsAfter/multAfter so counters are pure playback. + +import { handBase, rankChips, isFaceRank, TAROTS, PLANETS, SPECTRALS } from './BalatroData.js'; + +// ── Suit helpers ───────────────────────────────────────────────────────────── +function suitGroup(suit, smeared) { + if (!smeared) return suit; + return (suit === 'H' || suit === 'D') ? 'R' : 'B'; +} + +// Does this card count as `suit`? Stone: never. Wild: always. Smeared merges +// the red pair and the black pair. +export function cardHasSuit(card, suit, smeared) { + if (card.enhancement === 'stone') return false; + if (card.enhancement === 'wild') return true; + return suitGroup(card.suit, smeared) === suitGroup(suit, smeared); +} + +// ── Evaluator ──────────────────────────────────────────────────────────────── +// evaluate(cards, {fourFingers, shortcut, smeared}) → { handType, scoringUids } +// Stone cards have no rank/suit (excluded from detection, always score). +// Wild cards count as every suit. Debuffed cards still count for detection. + +// Cards forming a flush of >= n, or null. Wilds join any group. +function findFlush(active, n, smeared) { + const wilds = active.filter((c) => c.enhancement === 'wild'); + const rest = active.filter((c) => c.enhancement !== 'wild'); + const groups = {}; + for (const c of rest) { + const g = suitGroup(c.suit, smeared); + (groups[g] = groups[g] || []).push(c); + } + let best = null; + for (const g of Object.keys(groups)) { + if (groups[g].length + wilds.length >= n && (!best || groups[g].length > best.length)) best = groups[g]; + } + if (!best && wilds.length >= n) best = []; + return best ? best.concat(wilds) : null; +} + +// Cards forming a straight of n distinct ranks (Ace high or low; Shortcut +// allows a single-rank gap between consecutive ranks), or null. +function findStraight(active, n, shortcut) { + const byRank = new Map(); + for (const c of active) { + const ranks = c.rank === 14 ? [14, 1] : [c.rank]; + for (const r of ranks) { + if (!byRank.has(r)) byRank.set(r, []); + byRank.get(r).push(c); + } + } + const ranks = [...byRank.keys()].sort((a, b) => a - b); + const maxGap = shortcut ? 2 : 1; + for (let i = 0; i <= ranks.length - n; i++) { + const chain = [ranks[i]]; + for (let j = i + 1; j < ranks.length && chain.length < n; j++) { + const gap = ranks[j] - chain[chain.length - 1]; + if (gap === 0) continue; + if (gap > maxGap) break; + chain.push(ranks[j]); + } + if (chain.length >= n) { + const used = new Set(); + const cards = []; + for (const r of chain) { + const card = byRank.get(r).find((c) => !used.has(c.uid)); + if (card) { used.add(card.uid); cards.push(card); } + } + if (cards.length >= n) return cards; + } + } + return null; +} + +export function evaluate(cards, opts = {}) { + const { fourFingers = false, shortcut = false, smeared = false } = opts; + const n = fourFingers ? 4 : 5; + const stones = cards.filter((c) => c.enhancement === 'stone'); + const active = cards.filter((c) => c.enhancement !== 'stone'); + const withStones = (arr) => [...new Set([...arr.map((c) => c.uid), ...stones.map((c) => c.uid)])]; + + const byRank = {}; + for (const c of active) (byRank[c.rank] = byRank[c.rank] || []).push(c); + const rankGroups = Object.values(byRank).sort((a, b) => b.length - a.length); + const flush = active.length >= n ? findFlush(active, n, smeared) : null; + const straight = active.length >= n ? findStraight(active, n, shortcut) : null; + const straightFlush = flush && straight ? findStraight(flush, n, shortcut) : null; + + if (rankGroups[0] && rankGroups[0].length >= 5) { + if (flush && flush.length >= 5) return { handType: 'flushfive', scoringUids: withStones(active) }; + return { handType: 'fiveofakind', scoringUids: withStones(rankGroups[0].slice(0, 5)) }; + } + const fullHouse = rankGroups[0] && rankGroups[1] && rankGroups[0].length >= 3 && rankGroups[1].length >= 2; + if (fullHouse && flush && flush.length >= 5) return { handType: 'flushhouse', scoringUids: withStones(active) }; + if (straightFlush) return { handType: 'straightflush', scoringUids: withStones([...new Set([...straightFlush, ...flush])]) }; + if (rankGroups[0] && rankGroups[0].length >= 4) return { handType: 'fourofakind', scoringUids: withStones(rankGroups[0].slice(0, 4)) }; + if (fullHouse) return { handType: 'fullhouse', scoringUids: withStones(active) }; + if (flush) return { handType: 'flush', scoringUids: withStones(flush) }; + if (straight) return { handType: 'straight', scoringUids: withStones(straight) }; + if (rankGroups[0] && rankGroups[0].length >= 3) return { handType: 'threeofakind', scoringUids: withStones(rankGroups[0].slice(0, 3)) }; + if (rankGroups[0] && rankGroups[1] && rankGroups[0].length >= 2 && rankGroups[1].length >= 2) { + return { handType: 'twopair', scoringUids: withStones([...rankGroups[0].slice(0, 2), ...rankGroups[1].slice(0, 2)]) }; + } + if (rankGroups[0] && rankGroups[0].length >= 2) return { handType: 'pair', scoringUids: withStones(rankGroups[0].slice(0, 2)) }; + const high = active.slice().sort((a, b) => b.rank - a.rank)[0]; + return { handType: 'highcard', scoringUids: withStones(high ? [high] : []) }; +} + +// "Does the played hand contain a X" — used by Jolly/Sly/Duo joker families. +export function handContains(cards, want, opts = {}) { + const active = cards.filter((c) => c.enhancement !== 'stone'); + const n = opts.fourFingers ? 4 : 5; + if (want === 'straight') return !!findStraight(active, n, opts.shortcut); + if (want === 'flush') return !!findFlush(active, n, opts.smeared); + const byRank = {}; + for (const c of active) byRank[c.rank] = (byRank[c.rank] || 0) + 1; + const counts = Object.values(byRank).sort((a, b) => b - a); + if (want === 'pair') return (counts[0] || 0) >= 2; + if (want === 'threeofakind') return (counts[0] || 0) >= 3; + if (want === 'fourofakind') return (counts[0] || 0) >= 4; + if (want === 'twopair') return (counts[0] || 0) >= 2 && (counts[1] || 0) >= 2; + return false; +} + +// ── Scoring pipeline ───────────────────────────────────────────────────────── +// scorePlayedHand(run, played, held, opts) → trace +// played/held: arrays of card objects (played left→right in table order) +// opts: { effJokers: resolveEffectiveJokers(run), passives, rng, +// isDebuffed(card), boss } +// Copies (isCopy) run only scoring-time hooks; onHandPlayed/afterHand state +// updates fire once, on originals, so counters never double-tick. + +export function scorePlayedHand(run, played, held, opts) { + const { effJokers, passives, rng, isDebuffed, boss } = opts; + const events = []; + let chips = 0; + let mult = 0; + + const { handType, scoringUids } = evaluate(played, passives); + const scoringSet = passives.splash ? played.slice() : played.filter((c) => scoringUids.includes(c.uid)); + const isFace = (c) => c.enhancement !== 'stone' && (passives.pareidolia || isFaceRank(c.rank)); + const dead = (c) => c.debuffed || isDebuffed(c); + const destroyedUids = []; + + // The Arm permanently levels the played hand down before base lookup. + if (boss && boss.id === 'the_arm' && (run.handLevels[handType] || 1) > 1) { + run.handLevels[handType] -= 1; + events.push({ t: 'info', text: 'The Arm: hand level down', chipsAfter: 0, multAfter: 0 }); + } + const level = run.handLevels[handType] || 1; + let base = handBase(handType, level); + if (boss && boss.id === 'the_flint') { + base = { chips: Math.ceil(base.chips / 2), mult: Math.ceil(base.mult / 2) }; + } + chips = base.chips; + mult = base.mult; + + const ctx = { + run, rng, handType, level, events, passives, + playedCards: played, scoredCards: scoringSet, heldCards: held, + flags: {}, isFace, + hasSuit: (card, suit) => cardHasSuit(card, suit, passives.smeared), + contains: (want) => handContains(played, want, passives), + prob(nOdds) { const odds = passives.oops ? 2 / nOdds : 1 / nOdds; return rng() < Math.min(1, odds); }, + emit(ev) { events.push({ chipsAfter: chips, multAfter: mult, ...ev }); }, + chips(v, source) { if (!v) return; chips += v; events.push({ t: 'chips', v, source, chipsAfter: chips, multAfter: mult }); }, + mult(v, source) { if (!v) return; mult += v; events.push({ t: 'mult', v, source, chipsAfter: chips, multAfter: mult }); }, + xmult(v, source) { if (!v || v === 1) return; mult = Math.round(mult * v * 100) / 100; events.push({ t: 'xmult', v, source, chipsAfter: chips, multAfter: mult }); }, + money(v, source) { if (!v) return; run.gold += v; events.push({ t: 'money', v, source, chipsAfter: chips, multAfter: mult }); }, + createConsumable(kind, source) { + if (run.consumables.length >= passives.consumableSlots) return; + const pool = kind === 'tarot' ? TAROTS : kind === 'planet' ? PLANETS : SPECTRALS; + const pick = pool[rng.int(pool.length)]; + run.consumables.push({ kind, id: pick.id, uid: run.uidCounter++ }); + events.push({ t: 'info', source, text: `Created ${pick.name}`, chipsAfter: chips, multAfter: mult }); + }, + }; + + events.push({ t: 'base', chips, mult, handType, level, chipsAfter: chips, multAfter: mult }); + + // 1. Scored cards, left to right. Debuffed cards count toward the hand type + // above but contribute nothing and trigger nothing. + for (let i = 0; i < scoringSet.length; i++) { + const card = scoringSet[i]; + if (dead(card)) { ctx.emit({ t: 'info', source: { kind: 'card', uid: card.uid, index: i }, text: 'Debuffed' }); continue; } + let triggers = 1 + (card.seal === 'red' ? 1 : 0); + for (const e of effJokers) { + if (e.inst.debuffedByBoss || !e.def.cardRetriggers) continue; + triggers += e.def.cardRetriggers(ctx, e.hookInst, card, i) || 0; + } + for (let t = 0; t < triggers; t++) { + if (t > 0) ctx.emit({ t: 'retrigger', source: { kind: card.seal === 'red' && t === 1 ? 'seal' : 'joker', uid: card.uid } }); + const src = { kind: 'card', uid: card.uid, index: i }; + const baseChips = (card.enhancement === 'stone' ? 50 : rankChips(card.rank)) + (card.permaChips || 0); + ctx.chips(baseChips, src); + switch (card.enhancement) { + case 'bonus': ctx.chips(30, { ...src, enh: 'bonus' }); break; + case 'mult': ctx.mult(4, { ...src, enh: 'mult' }); break; + case 'glass': ctx.xmult(2, { ...src, enh: 'glass' }); break; + case 'lucky': + if (ctx.prob(5)) ctx.mult(20, { ...src, enh: 'lucky' }); + if (ctx.prob(15)) ctx.money(20, { ...src, enh: 'lucky' }); + break; + default: break; + } + if (card.edition === 'foil') ctx.chips(50, { ...src, edition: 'foil' }); + if (card.edition === 'holo') ctx.mult(10, { ...src, edition: 'holo' }); + if (card.edition === 'poly') ctx.xmult(1.5, { ...src, edition: 'poly' }); + if (card.seal === 'gold') ctx.money(3, { kind: 'seal', seal: 'gold', uid: card.uid }); + for (const e of effJokers) { + if (e.inst.debuffedByBoss || !e.def.onScored) continue; + e.def.onScored(ctx, e.hookInst, card, i); + } + } + // Glass shatter: one roll per scored glass card, after its triggers. + if (card.enhancement === 'glass' && ctx.prob(4)) { + destroyedUids.push(card.uid); + ctx.emit({ t: 'destroy', source: { kind: 'card', uid: card.uid, reason: 'glass' } }); + } + } + + // 2. Held-in-hand cards, left to right. + for (const card of held) { + if (dead(card)) continue; + let triggers = 1 + (card.seal === 'red' ? 1 : 0); + for (const e of effJokers) { + if (e.inst.debuffedByBoss || !e.def.heldRetriggers) continue; + triggers += e.def.heldRetriggers(ctx, e.hookInst, card) || 0; + } + for (let t = 0; t < triggers; t++) { + if (card.enhancement === 'steel') ctx.xmult(1.5, { kind: 'card', uid: card.uid, enh: 'steel', held: true }); + for (const e of effJokers) { + if (e.inst.debuffedByBoss || !e.def.onHeld) continue; + e.def.onHeld(ctx, e.hookInst, card); + } + } + } + + // 3. Jokers left to right: Foil/Holo before the effect, Polychrome after. + // State updates (onHandPlayed) land just before the joker's own + // contribution so scalers apply to the hand that grew them. + for (const e of effJokers) { + if (e.inst.debuffedByBoss) continue; + const src = { kind: 'joker', id: e.def.id || e.inst.id, jokerUid: e.inst.uid }; + if (e.inst.edition === 'foil') ctx.chips(50, { ...src, edition: 'foil' }); + if (e.inst.edition === 'holo') ctx.mult(10, { ...src, edition: 'holo' }); + if (e.def.onHandPlayed && !e.isCopy) e.def.onHandPlayed(ctx, e.hookInst); + if (e.def.independent) e.def.independent(ctx, e.hookInst); + if (e.inst.edition === 'poly') ctx.xmult(1.5, { ...src, edition: 'poly' }); + } + + // 4. Post-hand decays / triggers (Ice Cream, Seltzer, Loyalty, DNA...). + for (const e of effJokers) { + if (e.inst.debuffedByBoss || e.isCopy || !e.def.afterHand) continue; + e.def.afterHand(ctx, e.hookInst); + } + + const score = Math.floor(chips) * mult; + events.push({ t: 'total', chips: Math.floor(chips), mult, score, chipsAfter: Math.floor(chips), multAfter: mult }); + + return { handType, level, scoringUids: scoringSet.map((c) => c.uid), events, score, destroyedUids }; +} diff --git a/src/games/balatro/BalatroSwirlPipeline.js b/src/games/balatro/BalatroSwirlPipeline.js new file mode 100644 index 0000000..ba3770e --- /dev/null +++ b/src/games/balatro/BalatroSwirlPipeline.js @@ -0,0 +1,155 @@ +// BalatroSwirlPipeline.js +// The signature melty psychedelic background: a fullscreen GLSL quad running a +// domain-warped polar swirl (Balatro-style paint bands), with a two-color +// palette the scene retunes per context (blinds, boss, shop, packs...). +// Canvas renderer (no WebGL) falls back to a gradient with slow drifting +// blobs so the game stays fully playable. + +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; + +const FRAG = ` +precision mediump float; + +uniform float time; +uniform vec2 resolution; +uniform vec3 uColorA; +uniform vec3 uColorB; +uniform float uSpin; +uniform float uContrast; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + for (int i = 0; i < 3; i++) { + v += a * vnoise(p); + p = p * 2.03 + vec2(17.3, 9.1); + a *= 0.5; + } + return v; +} + +void main(void) { + vec2 uv = (gl_FragCoord.xy - 0.5 * resolution) / min(resolution.x, resolution.y); + float r = length(uv); + float ang = atan(uv.y, uv.x); + + // Spiral twist grows toward the center and slowly drifts. + float twist = uSpin * (1.35 - r) * 2.6 + time * 0.09; + ang += twist; + vec2 p = vec2(cos(ang), sin(ang)) * (r * 2.2 + 0.15); + + // Domain-warped fbm drives the paint bands. + vec2 warp = vec2(fbm(p * 1.7 + vec2(time * 0.045, -time * 0.03)), + fbm(p * 1.7 - vec2(time * 0.035, time * 0.05))); + float n = fbm(p * 2.1 + warp * 1.9); + float bands = sin(6.28318 * (n * 0.9 + r * 1.4 - time * 0.035)); + float m = smoothstep(-0.55, 0.75, bands); + + // Posterize toward flat paint blobs, keep a little smoothness. + float post = floor(m * 4.0) / 4.0; + m = mix(m, post, 0.55) * uContrast; + + vec3 col = mix(uColorA, uColorB, clamp(m, 0.0, 1.0)); + + // Soft center glow + corner vignette. + col += 0.05 * (1.0 - smoothstep(0.0, 0.7, r)); + col *= 1.0 - 0.38 * smoothstep(0.55, 1.15, r); + + gl_FragColor = vec4(col, 1.0); +} +`; + +function hexToVec(hex) { + return [((hex >> 16) & 0xff) / 255, ((hex >> 8) & 0xff) / 255, (hex & 0xff) / 255]; +} + +// Named palettes the scene switches between per view / blind. +export const SWIRL_PALETTES = { + title: [0x2a1a4a, 0xb03060], + small: [0x122a52, 0x2f6fce], + big: [0x2a2456, 0x8556d6], + boss: [0x38080e, 0xc22f3a], + shop: [0x0e2e22, 0x2e9e6a], + pack: [0x241040, 0x7a3fd0], + win: [0x2e2408, 0xd8b23a], + lose: [0x1a1418, 0x5a2f3a], +}; + +export function makeSwirlBackground(scene, layer) { + const usingWebGL = scene.renderer && scene.renderer.type === Phaser.WEBGL; + + if (usingWebGL) { + const base = new Phaser.Display.BaseShader('balatro-swirl', FRAG, undefined, { + uColorA: { type: '3f', value: { x: 0.16, y: 0.10, z: 0.29 } }, + uColorB: { type: '3f', value: { x: 0.69, y: 0.19, z: 0.38 } }, + uSpin: { type: '1f', value: 1.0 }, + uContrast: { type: '1f', value: 1.0 }, + }); + const go = scene.add.shader(base, GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT); + layer.add(go); + const state = { a: hexToVec(SWIRL_PALETTES.title[0]), b: hexToVec(SWIRL_PALETTES.title[1]) }; + const apply = () => { + go.setUniform('uColorA.value.x', state.a[0]); + go.setUniform('uColorA.value.y', state.a[1]); + go.setUniform('uColorA.value.z', state.a[2]); + go.setUniform('uColorB.value.x', state.b[0]); + go.setUniform('uColorB.value.y', state.b[1]); + go.setUniform('uColorB.value.z', state.b[2]); + }; + apply(); + let tween = null; + return { + setPalette(name, ms = 900) { + const [ha, hb] = SWIRL_PALETTES[name] || SWIRL_PALETTES.title; + const ta = hexToVec(ha), tb = hexToVec(hb); + if (tween) tween.stop(); + const from = { a: state.a.slice(), b: state.b.slice() }; + const obj = { t: 0 }; + tween = scene.tweens.add({ + targets: obj, t: 1, duration: ms, ease: 'Sine.easeInOut', + onUpdate: () => { + for (let i = 0; i < 3; i++) { + state.a[i] = from.a[i] + (ta[i] - from.a[i]) * obj.t; + state.b[i] = from.b[i] + (tb[i] - from.b[i]) * obj.t; + } + apply(); + }, + }); + }, + setSpin(v) { go.setUniform('uSpin.value', v); }, + destroy() { if (tween) tween.stop(); go.destroy(); }, + }; + } + + // Canvas fallback: static two-tone gradient + two huge slow-drifting blobs. + const g = scene.add.graphics(); + layer.add(g); + const blobs = [ + scene.add.circle(GAME_WIDTH * 0.3, GAME_HEIGHT * 0.4, 420, 0xffffff, 0.05), + scene.add.circle(GAME_WIDTH * 0.7, GAME_HEIGHT * 0.6, 520, 0xffffff, 0.04), + ]; + for (const b of blobs) layer.add(b); + scene.tweens.add({ targets: blobs[0], x: GAME_WIDTH * 0.7, y: GAME_HEIGHT * 0.55, duration: 16000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + scene.tweens.add({ targets: blobs[1], x: GAME_WIDTH * 0.35, y: GAME_HEIGHT * 0.45, duration: 21000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); + const paint = (name) => { + const [a, b] = SWIRL_PALETTES[name] || SWIRL_PALETTES.title; + g.clear(); + g.fillGradientStyle(a, a, b, b, 1); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + }; + paint('title'); + return { setPalette(name) { paint(name); }, setSpin() {}, destroy() { g.destroy(); blobs.forEach((b) => b.destroy()); } }; +} diff --git a/src/games/balatro/BalatroViews.js b/src/games/balatro/BalatroViews.js new file mode 100644 index 0000000..a5b3ded --- /dev/null +++ b/src/games/balatro/BalatroViews.js @@ -0,0 +1,375 @@ +// BalatroViews.js +// Non-play view renderers for BalatroGame (title, deck select, blind select, +// shop, pack opening, cash-out overlay, game over). Each receives the scene +// and uses its helpers (text/panel/button/draw*Card) so all styling stays in +// one place. Pure rendering + Logic calls; no game rules here. + +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { + DECKS, DECK_BY_ID, BLINDS, fmtChips, anteBase, VOUCHER_BY_ID, BOOSTER_BY_ID, + HAND_BY_ID, EDITIONS, +} from './BalatroData.js'; +import { JOKER_BY_ID } from './BalatroJokers.js'; +import { + passives, selectBlind, skipBlind, blindChipsFor, bossFor, collectCashOut, + continueEndless, leaveShop, rerollShop, buyShopCard, buyVoucher, buyPack, + pickFromPack, skipPack, +} from './BalatroLogic.js'; +import * as store from '../../services/localStore.js'; +import { C } from './BalatroGame.js'; + +const CX = GAME_WIDTH / 2; + +// ── Title ──────────────────────────────────────────────────────────────────── +export function renderTitle(scene) { + const letters = 'BALATRO'; + const colors = ['#ff5e5e', '#5aa9f4', '#ffd25e', '#7ce07c', '#c88aff', '#ff9a5e', '#7ce0e0']; + const size = 128; + const total = letters.length * (size * 0.72); + letters.split('').forEach((ch, i) => { + const t = scene.text(CX - total / 2 + i * size * 0.72 + size * 0.36, 260, ch, size, colors[i % colors.length], { ox: 0.5, oy: 0.5, bold: true }); + t.setStroke('#141019', 12); + t.setAngle(Math.sin(i * 1.7) * 6); + scene.tweens.add({ + targets: t, y: 260 + Math.sin(i) * 6, angle: t.angle + 4, + duration: 1600 + i * 130, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', + }); + }); + scene.text(CX, 356, 'a poker roguelike', 26, C.muted, { ox: 0.5 }); + + let y = 500; + if (scene._savedRun) { + const r = scene._savedRun; + scene.button(CX, y, `Continue (Ante ${r.ante} · ${DECK_BY_ID[r.deckId].name})`, () => scene.continueRun(), { width: 520, height: 66, fontSize: 24 }); + y += 90; + scene.button(CX, y, 'New Run', () => confirmNewRun(scene), { width: 320, height: 58, fontSize: 22, variant: 'ghost' }); + } else { + scene.button(CX, y, 'New Run', () => scene.setView('deckselect'), { width: 360, height: 66, fontSize: 26 }); + } + y += 90; + scene.button(CX, y, 'Main Menu', () => scene.scene.start('GameMenu'), { width: 280, height: 52, fontSize: 20, variant: 'ghost' }); + + const stats = store.read('balatro:stats'); + if (stats && stats.runs) { + scene.text(CX, y + 90, `Runs ${stats.runs} · Wins ${stats.wins} · Best Ante ${stats.bestAnte} · Best Hand ${fmtChips(stats.bestHandScore)}`, 20, C.muted, { ox: 0.5 }); + } +} + +function confirmNewRun(scene) { + const ov = scene.add.container(0, 0).setDepth(95); + scene.fxLayer.add(ov); + const shade = scene.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setInteractive(); + const g = scene.add.graphics(); + g.fillStyle(C.panel, 0.97); g.fillRoundedRect(CX - 280, 420, 560, 240, 16); + g.lineStyle(2, C.panelEdge, 1); g.strokeRoundedRect(CX - 280, 420, 560, 240, 16); + ov.add([shade, g]); + ov.add(scene.add.text(CX, 470, 'Abandon the saved run?', { fontFamily: '"Julius Sans One"', fontSize: '28px', color: C.ink }).setOrigin(0.5)); + const yes = scene.button(CX - 110, 590, 'Yes', () => { scene._savedRun = null; store.write('balatro:run', null); ov.destroy(); scene.setView('deckselect'); }, { width: 160, height: 54, fontSize: 22 }); + const no = scene.button(CX + 110, 590, 'No', () => ov.destroy(), { width: 160, height: 54, fontSize: 22, variant: 'ghost' }); + ov.add([yes, no]); + shade.on('pointerdown', () => ov.destroy()); +} + +// ── Deck select ────────────────────────────────────────────────────────────── +export function renderDeckSelect(scene) { + scene.text(CX, 90, 'Choose a Deck', 52, C.ink, { ox: 0.5, bold: true }); + const w = 300, h = 330; + DECKS.forEach((deck, i) => { + const col = i % 4, row = Math.floor(i / 4); + const x = CX - 1.5 * (w + 60) + col * (w + 60); + const y = 330 + row * (h + 80); + const cont = scene.add.container(x, y); + const g = scene.add.graphics(); + g.fillStyle(C.panel, 0.92); g.fillRoundedRect(-w / 2, -h / 2, w, h, 14); + g.lineStyle(2, deck.color, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 14); + cont.add(g); + // Deck back: art frame if present, else colored card back. + const af = scene.artFrame('deckBackSheet', 'deckBacks', deck.id); + if (af) cont.add(scene.add.image(0, -60, af.key, af.frame).setDisplaySize(120, 164)); + else { + const bg = scene.add.graphics(); + bg.fillStyle(deck.color, 0.95); bg.fillRoundedRect(-60, -142, 120, 164, 8); + bg.lineStyle(2, 0xffffff, 0.5); bg.strokeRoundedRect(-60, -142, 120, 164, 8); + bg.lineStyle(1, 0xffffff, 0.25); bg.strokeRoundedRect(-48, -130, 96, 140, 6); + cont.add(bg); + } + cont.add(scene.add.text(0, 52, deck.name, { fontFamily: '"Julius Sans One"', fontSize: '26px', color: C.ink, fontStyle: 'bold' }).setOrigin(0.5)); + cont.add(scene.add.text(0, 108, deck.desc, { fontFamily: '"Julius Sans One"', fontSize: '17px', color: C.muted, align: 'center', wordWrap: { width: w - 30 } }).setOrigin(0.5)); + cont.setSize(w, h); + scene.add2(cont); + scene.addHoverTilt(cont); + cont.on('pointerdown', () => { playSound(scene, SFX.CARD_SHUFFLE); scene.startRun(deck.id); }); + }); + scene.button(CX, GAME_HEIGHT - 50, 'Back', () => scene.setView('title'), { width: 220, height: 50, fontSize: 20, variant: 'ghost' }); +} + +// ── Blind select ───────────────────────────────────────────────────────────── +export function renderBlindSelect(scene) { + const run = scene.run; + scene.text(CX, 70, `Ante ${run.endless ? run.ante : `${run.ante} of 8`}`, 46, C.ink, { ox: 0.5, bold: true }); + scene.text(CX, 124, `$${run.gold}`, 30, C.money, { ox: 0.5 }); + + const stages = ['small', 'big', 'boss']; + const curIdx = stages.indexOf(run.blind); + const w = 400, h = 560; + stages.forEach((stage, i) => { + const x = CX - (w + 60) + i * (w + 60); + const y = 480; + const isCur = i === curIdx; + const done = i < curIdx; + const boss = stage === 'boss' ? bossFor(run) : null; + const color = stage === 'boss' ? 0xc22f3a : stage === 'big' ? 0x8556d6 : 0x2f6fce; + scene.panel(x - w / 2, y - h / 2, w, h, { fill: isCur ? color : C.panel, alpha: isCur ? 0.4 : 0.75, strokeColor: color }); + scene.text(x, y - h / 2 + 46, boss ? boss.name : BLINDS[stage].name, 32, C.ink, { ox: 0.5, bold: true }); + // Requirement uses the boss multiplier for the boss column. + const req = stage === 'boss' + ? Math.floor(anteBase(run.ante) * ((boss && boss.reqMult) || 2)) + : Math.floor(anteBase(run.ante) * BLINDS[stage].mult); + scene.text(x, y - h / 2 + 110, 'Score at least', 18, C.muted, { ox: 0.5 }); + scene.text(x, y - h / 2 + 142, fmtChips(req), 36, '#ff8a6a', { ox: 0.5, bold: true }); + scene.text(x, y - h / 2 + 200, `Reward: $${BLINDS[stage].reward}`, 22, C.money, { ox: 0.5 }); + if (boss) scene.text(x, y - h / 2 + 260, boss.desc, 20, '#ffb9a8', { ox: 0.5, align: 'center', wrap: w - 60 }); + + if (done) { + scene.text(x, y + h / 2 - 120, '✔ cleared', 26, '#7ce07c', { ox: 0.5 }); + } else if (isCur) { + scene.button(x, y + h / 2 - 120, 'Select', () => { + const r = selectBlind(run); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.CARD_SHUFFLE); + scene.save(); + scene.setView('play'); + }, { width: 240, height: 64, fontSize: 26, bg: color }); + if (BLINDS[stage].skippable) { + scene.button(x, y + h / 2 - 48, 'Skip Blind', () => { + const r = skipBlind(run); + if (!r.ok) { scene.toast(r.error); return; } + scene.save(); + scene.renderView(); + }, { width: 200, height: 46, fontSize: 18, variant: 'ghost' }); + } + } + }); +} + +// ── Cash out overlay (rendered over the frozen play view) ──────────────────── +export function renderCashout(scene) { + const run = scene.run; + const cash = run.cashout || { lines: [], total: 0 }; + const w = 620, h = 200 + cash.lines.length * 44; + const x = CX - w / 2, y = 300; + scene.panel(x, y, w, h, { alpha: 0.97, layer: scene.fxLayer }); + scene.text(CX, y + 30, 'Round Won!', 40, '#7ce07c', { ox: 0.5, bold: true, layer: scene.fxLayer }); + cash.lines.forEach((line, i) => { + scene.text(x + 50, y + 100 + i * 44, line.label, 24, C.ink, { layer: scene.fxLayer }); + scene.text(x + w - 50, y + 100 + i * 44, `$${line.amount}`, 24, C.money, { ox: 1, layer: scene.fxLayer }); + }); + scene.button(CX, y + h - 46, `Cash Out $${cash.total}`, () => { + const r = collectCashOut(run); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.COINS); + if (r.destroyed && r.destroyed.length) scene.toast(`${r.destroyed.map((id) => JOKER_BY_ID[id].name).join(', ')} destroyed`); + scene.save(); + if (run.phase === 'over') { scene.onRunOver(); scene.setView('gameover'); } + else scene.setView('shop'); + }, { width: 340, height: 64, fontSize: 26, bg: 0x2e9e6a, layer: scene.fxLayer }); +} + +// ── Shop ───────────────────────────────────────────────────────────────────── +export function renderShop(scene) { + const run = scene.run; + const shop = run.shop; + if (!shop) { scene.setView('blindselect'); return; } + const p = passives(run); + + // Owned jokers/consumables stay on screen (sellable while shopping). + scene.renderJokerRow(false); + scene.renderConsumableRow(false); + scene.renderDeckThumb(); + + scene.text(120, 40, 'SHOP', 54, '#7ce0c0', { bold: true }); + scene._goldText = scene.text(120, 110, `$${run.gold}`, 40, C.money, { bold: true }); + const nextBlind = run.blind === 'small' ? 'Big Blind' : run.blind === 'big' ? (bossFor(run) ? bossFor(run).name : 'Boss Blind') : `Ante ${run.ante + 1}`; + scene.text(120, 168, `Next: ${nextBlind}`, 20, C.muted); + + const refresh = () => { scene.save(); scene.renderView(); }; + + // Card slots + const cy = 430; + scene.text(470, cy - 150, 'For sale', 20, C.muted); + shop.cards.forEach((item, i) => { + const x = 560 + i * 190; + let cont; + if (item.slot === 'joker') cont = scene.drawJokerCard(x, cy, item.id, { edition: item.edition, w: 160, h: 214 }); + else if (item.slot === 'playing') cont = scene.drawPlayingCard(x, cy, { ...item.card, uid: -1, faceDown: false, debuffed: false }, { w: 160, h: 214 }); + else cont = scene.drawConsumableCard(x, cy, item.slot, item.id, { w: 160, h: 214 }); + scene.add2(cont); + scene.addHoverTilt(cont); + scene.text(x, cy + 132, `$${item.cost}`, 26, C.money, { ox: 0.5, bold: true }); + cont.on('pointerdown', () => { + const r = buyShopCard(run, i); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.PURCHASE); + refresh(); + }); + }); + + // Reroll + scene.button(560 + p.shopCards * 190 + 40, cy, shop.freeRerolls > 0 ? 'Reroll (free)' : `Reroll $${shop.rerollCost}`, () => { + const r = rerollShop(run); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.CARD_SHUFFLE); + refresh(); + }, { width: 190, height: 60, fontSize: 20 }); + + // Packs + const py2 = 760; + scene.text(470, py2 - 140, 'Booster packs', 20, C.muted); + shop.packs.forEach((slot, i) => { + const x = 580 + i * 240; + const def = BOOSTER_BY_ID[slot.id]; + const cont = scene.add.container(x, py2); + const af = scene.artFrame('packSheet', 'packs', slot.id); + if (af) cont.add(scene.add.image(0, 0, af.key, af.frame).setDisplaySize(180, 240)); + else { + const tint = { playing: 0x4f9ad4, tarot: C.tarot, planet: C.planet, joker: 0xd4884f, spectral: C.spectral }[def.what]; + const g = scene.add.graphics(); + g.fillStyle(tint, 0.85); g.fillRoundedRect(-90, -120, 180, 240, 12); + g.lineStyle(3, 0xffffff, 0.5); g.strokeRoundedRect(-90, -120, 180, 240, 12); + g.fillStyle(0x141019, 0.35); g.fillRoundedRect(-90, -30, 180, 60, 0); + cont.add(g); + cont.add(scene.add.text(0, 0, def.name.replace(' Pack', '\nPack'), { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#ffffff', align: 'center', fontStyle: 'bold' }).setOrigin(0.5)); + } + cont.setSize(180, 240); + scene.add2(cont); + scene.addHoverTilt(cont); + scene.text(x, py2 + 148, `$${slot.cost}`, 26, C.money, { ox: 0.5, bold: true }); + cont.on('pointerdown', () => { + const r = buyPack(run, i); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.PURCHASE); + scene.save(); + scene.setView('packopen'); + }); + }); + + // Voucher + const vx = 1560, vy = 560; + if (shop.voucher && !shop.voucher.sold) { + const v = VOUCHER_BY_ID[shop.voucher.id]; + scene.text(vx, vy - 180, 'Voucher', 20, C.muted, { ox: 0.5 }); + const cont = scene.add.container(vx, vy); + const af = scene.artFrame('voucherSheet', 'vouchers', v.id); + if (af) cont.add(scene.add.image(0, 0, af.key, af.frame).setDisplaySize(190, 250)); + else { + const g = scene.add.graphics(); + g.fillStyle(0x143a2a, 0.95); g.fillRoundedRect(-95, -125, 190, 250, 10); + g.lineStyle(3, C.voucher, 1); g.strokeRoundedRect(-95, -125, 190, 250, 10); + g.lineStyle(1, C.voucher, 0.5); g.strokeRoundedRect(-85, -115, 170, 230, 8); + cont.add(g); + cont.add(scene.add.text(0, -80, v.name, { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#7ce0c0', align: 'center', fontStyle: 'bold', wordWrap: { width: 170 } }).setOrigin(0.5)); + cont.add(scene.add.text(0, 20, v.desc, { fontFamily: '"Julius Sans One"', fontSize: '16px', color: C.ink, align: 'center', wordWrap: { width: 165 } }).setOrigin(0.5)); + } + cont.setSize(190, 250); + scene.add2(cont); + scene.addHoverTilt(cont); + scene.text(vx, vy + 152, `$${shop.voucher.cost}`, 26, C.money, { ox: 0.5, bold: true }); + cont.on('pointerdown', () => { + const r = buyVoucher(run); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.PURCHASE); + refresh(); + }); + } + + scene.button(GAME_WIDTH - 200, GAME_HEIGHT - 60, 'Next Round →', () => { + const r = leaveShop(run); + if (!r.ok) { scene.toast(r.error); return; } + scene.save(); + scene.setView('blindselect'); + }, { width: 300, height: 64, fontSize: 24, bg: 0xc26a2f }); +} + +// ── Pack opening ───────────────────────────────────────────────────────────── +export function renderPackOpen(scene) { + const run = scene.run; + const pack = run.pack; + if (!pack) { scene.setView('shop'); return; } + const def = BOOSTER_BY_ID[pack.boosterId]; + scene.text(CX, 110, def.name, 46, C.ink, { ox: 0.5, bold: true }); + scene.text(CX, 170, `Choose ${pack.picksLeft}`, 24, C.muted, { ox: 0.5 }); + + const n = pack.options.length; + const w = 190, h = 254; + const step = Math.min(w + 46, 1500 / Math.max(n, 1)); + const x0 = CX - ((n - 1) * step) / 2; + pack.options.forEach((opt, i) => { + const x = x0 + i * step, y = 480; + let cont; + if (opt.what === 'playing') cont = scene.drawPlayingCard(x, y, { ...opt.card, uid: -1, faceDown: false, debuffed: false }, { w, h }); + else if (opt.what === 'joker') cont = scene.drawJokerCard(x, y, opt.id, { edition: opt.edition, w, h }); + else cont = scene.drawConsumableCard(x, y, opt.what, opt.id, { w, h }); + scene.add2(cont); + scene.addHoverTilt(cont); + cont.on('pointerdown', () => { + const r = pickFromPack(run, i); + if (!r.ok) { scene.toast(r.error); return; } + playSound(scene, SFX.CARD_SHOW); + scene.save(); + scene.setView(run.pack ? 'packopen' : 'shop'); + }); + }); + if (def.what === 'planet') scene.text(CX, 680, 'Planets from packs are used immediately', 18, C.muted, { ox: 0.5 }); + + scene.button(CX, 860, 'Skip Pack', () => { + skipPack(run); + scene.save(); + scene.setView('shop'); + }, { width: 240, height: 56, fontSize: 22, variant: 'ghost' }); +} + +// ── Game over ──────────────────────────────────────────────────────────────── +export function renderGameOver(scene) { + const run = scene.run; + if (!run) { scene.setView('title'); return; } + const won = run.won; + scene.text(CX, 200, won ? 'YOU WIN!' : 'GAME OVER', 96, won ? '#ffd25e' : '#ff6b5e', { ox: 0.5, bold: true }).setStroke('#141019', 10); + if (!won && run.lostAt) { + scene.text(CX, 300, `Defeated at Ante ${run.lostAt.ante} — ${run.lostAt.blind === 'boss' ? (bossFor(run) ? bossFor(run).name : 'Boss Blind') : BLINDS[run.lostAt.blind].name}`, 26, C.muted, { ox: 0.5 }); + } + if (won && !Number.isFinite(run.roundScore)) { + scene.text(CX, 300, 'Your score exceeded the bounds of reality.', 26, C.muted, { ox: 0.5 }); + } + + const w2 = 720, y2 = 380, h2 = 300; + scene.panel(CX - w2 / 2, y2, w2, h2, {}); + const lines = [ + ['Ante reached', run.endless ? String(run.ante) : `${Math.min(run.ante, 8)}/8`], + ['Best hand', fmtChips(run.stats.bestHandScore)], + ['Hands played', String(run.stats.handsPlayed)], + ['Cards added to deck', String(run.stats.cardsAddedToDeck)], + ['Tarots / Planets used', `${run.stats.tarotsUsed} / ${run.stats.planetsUsed}`], + ['Deck', DECK_BY_ID[run.deckId].name], + ]; + lines.forEach(([label, val], i) => { + const col = i % 2, row = Math.floor(i / 2); + const x = CX - w2 / 2 + 60 + col * (w2 / 2); + scene.text(x, y2 + 44 + row * 84, label, 18, C.muted); + scene.text(x, y2 + 70 + row * 84, val, 28, C.ink, { bold: true }); + }); + + let by = 780; + if (won && !run.endless) { + scene.button(CX, by, 'Continue in Endless Mode', () => { + const r = continueEndless(run); + if (!r.ok) { scene.toast(r.error); return; } + scene._recorded = true; // already recorded the win + scene.save(); + scene.setView('shop'); + }, { width: 440, height: 64, fontSize: 24, bg: 0x8556d6 }); + by += 90; + } + scene.button(CX - 170, by, 'New Run', () => { scene.run = null; scene.setView('deckselect'); }, { width: 280, height: 60, fontSize: 24 }); + scene.button(CX + 170, by, 'Main Menu', () => scene.scene.start('GameMenu'), { width: 280, height: 60, fontSize: 24, variant: 'ghost' }); +} diff --git a/src/games/balatro/sprites.md b/src/games/balatro/sprites.md new file mode 100644 index 0000000..24d0efb --- /dev/null +++ b/src/games/balatro/sprites.md @@ -0,0 +1,305 @@ +# Balatro — sprite sheets + +All sheets are **optional drop-ins**: the game renders everything procedurally until a sheet exists. +To activate one, save the PNG into `assets/images/` and set its `path` in `data/balatro-artwork.json` +(e.g. `"path": "assets/images/balatro-jokers.png"`). Frames are **0-based, row-major** and must follow +the exact order below (it matches the id maps in balatro-artwork.json). + +Also needed: **game-icons.png frame 79** (44x44) — the Balatro icon in the game menu. + +## balatro-jokers.png + +- Frame size: **150x200** px +- Frames: **114** — suggested grid **12 x 10** (image 1800x2000) +- One frame per Joker. Rarity in-game is shown by UI, so paint the character/art only. + +| # | id | name | +|---|----|------| +| 0 | `joker` | Joker | +| 1 | `greedy` | Greedy Joker | +| 2 | `lusty` | Lusty Joker | +| 3 | `wrathful` | Wrathful Joker | +| 4 | `gluttonous` | Gluttonous Joker | +| 5 | `jolly` | Jolly Joker | +| 6 | `zany` | Zany Joker | +| 7 | `mad` | Mad Joker | +| 8 | `crazy` | Crazy Joker | +| 9 | `droll` | Droll Joker | +| 10 | `halfjoker` | Half Joker | +| 11 | `misprint` | Misprint | +| 12 | `sly` | Sly Joker | +| 13 | `wily` | Wily Joker | +| 14 | `clever` | Clever Joker | +| 15 | `devious` | Devious Joker | +| 16 | `crafty` | Crafty Joker | +| 17 | `banner` | Banner | +| 18 | `stuntman` | Stuntman | +| 19 | `fibonacci` | Fibonacci | +| 20 | `scaryface` | Scary Face | +| 21 | `evensteven` | Even Steven | +| 22 | `oddtodd` | Odd Todd | +| 23 | `scholar` | Scholar | +| 24 | `smileyface` | Smiley Face | +| 25 | `walkietalkie` | Walkie Talkie | +| 26 | `roughgem` | Rough Gem | +| 27 | `bloodstone` | Bloodstone | +| 28 | `arrowhead` | Arrowhead | +| 29 | `onyxagate` | Onyx Agate | +| 30 | `blackboard` | Blackboard | +| 31 | `baron` | Baron | +| 32 | `photograph` | Photograph | +| 33 | `ancient` | Ancient Joker | +| 34 | `cavendish` | Cavendish | +| 35 | `cardsharp` | Card Sharp | +| 36 | `acrobat` | Acrobat | +| 37 | `duo` | The Duo | +| 38 | `trio` | The Trio | +| 39 | `family` | The Family | +| 40 | `order` | The Order | +| 41 | `tribe` | The Tribe | +| 42 | `flowerpot` | Flower Pot | +| 43 | `runner` | Runner | +| 44 | `icecream` | Ice Cream | +| 45 | `bluejoker` | Blue Joker | +| 46 | `greenjoker` | Green Joker | +| 47 | `ridethebus` | Ride the Bus | +| 48 | `squarejoker` | Square Joker | +| 49 | `supernova` | Supernova | +| 50 | `obelisk` | Obelisk | +| 51 | `constellation` | Constellation | +| 52 | `hologram` | Hologram | +| 53 | `vampire` | Vampire | +| 54 | `madness` | Madness | +| 55 | `castle` | Castle | +| 56 | `weejoker` | Wee Joker | +| 57 | `erosion` | Erosion | +| 58 | `hiker` | Hiker | +| 59 | `ramen` | Ramen | +| 60 | `popcorn` | Popcorn | +| 61 | `throwback` | Throwback | +| 62 | `goldenjoker` | Golden Joker | +| 63 | `delayedgrat` | Delayed Gratification | +| 64 | `businesscard` | Business Card | +| 65 | `faceless` | Faceless Joker | +| 66 | `cloud9` | Cloud 9 | +| 67 | `rocket` | Rocket | +| 68 | `egg` | Egg | +| 69 | `tothemoon` | To the Moon | +| 70 | `satellite` | Satellite | +| 71 | `bull` | Bull | +| 72 | `bootstraps` | Bootstraps | +| 73 | `goldenticket` | Golden Ticket | +| 74 | `redcard` | Red Card | +| 75 | `swashbuckler` | Swashbuckler | +| 76 | `fortuneteller` | Fortune Teller | +| 77 | `tradingcard` | Trading Card | +| 78 | `hack` | Hack | +| 79 | `dusk` | Dusk | +| 80 | `sockandbuskin` | Sock and Buskin | +| 81 | `seltzer` | Seltzer | +| 82 | `hangingchad` | Hanging Chad | +| 83 | `mime` | Mime | +| 84 | `juggler` | Juggler | +| 85 | `drunkard` | Drunkard | +| 86 | `troubadour` | Troubadour | +| 87 | `merryandy` | Merry Andy | +| 88 | `turtlebean` | Turtle Bean | +| 89 | `burglar` | Burglar | +| 90 | `creditcard` | Credit Card | +| 91 | `chaostheclown` | Chaos the Clown | +| 92 | `oops` | Oops! All 6s | +| 93 | `pareidolia` | Pareidolia | +| 94 | `splash` | Splash | +| 95 | `smeared` | Smeared Joker | +| 96 | `shortcut` | Shortcut | +| 97 | `fourfingers` | Four Fingers | +| 98 | `stencil` | Joker Stencil | +| 99 | `eightball` | 8 Ball | +| 100 | `superposition` | Superposition | +| 101 | `vagabond` | Vagabond | +| 102 | `cartomancer` | Cartomancer | +| 103 | `mysticsummit` | Mystic Summit | +| 104 | `loyaltycard` | Loyalty Card | +| 105 | `raisedfist` | Raised Fist | +| 106 | `shootthemoon` | Shoot the Moon | +| 107 | `abstract` | Abstract Joker | +| 108 | `grosmichel` | Gros Michel | +| 109 | `stonejoker` | Stone Joker | +| 110 | `baseball` | Baseball Card | +| 111 | `dna` | DNA | +| 112 | `blueprint` | Blueprint | +| 113 | `brainstorm` | Brainstorm | + +## balatro-tarots.png + +- Frame size: **150x200** px +- Frames: **22** — suggested grid **11 x 2** (image 1650x400) +- Classic tarot-card look encouraged. + +| # | id | name | +|---|----|------| +| 0 | `fool` | The Fool | +| 1 | `magician` | The Magician | +| 2 | `highpriestess` | The High Priestess | +| 3 | `empress` | The Empress | +| 4 | `emperor` | The Emperor | +| 5 | `hierophant` | The Hierophant | +| 6 | `lovers` | The Lovers | +| 7 | `chariot` | The Chariot | +| 8 | `justice` | Justice | +| 9 | `hermit` | The Hermit | +| 10 | `wheeloffortune` | Wheel of Fortune | +| 11 | `strength` | Strength | +| 12 | `hangedman` | The Hanged Man | +| 13 | `death` | Death | +| 14 | `temperance` | Temperance | +| 15 | `devil` | The Devil | +| 16 | `tower` | The Tower | +| 17 | `star` | The Star | +| 18 | `moon` | The Moon | +| 19 | `sun` | The Sun | +| 20 | `judgement` | Judgement | +| 21 | `world` | The World | + +## balatro-planets.png + +- Frame size: **150x200** px +- Frames: **12** — suggested grid **6 x 2** (image 900x400) + +| # | id | name | +|---|----|------| +| 0 | `pluto` | Pluto | +| 1 | `mercury` | Mercury | +| 2 | `uranus` | Uranus | +| 3 | `venus` | Venus | +| 4 | `saturn` | Saturn | +| 5 | `jupiter` | Jupiter | +| 6 | `earth` | Earth | +| 7 | `mars` | Mars | +| 8 | `neptune` | Neptune | +| 9 | `planetx` | Planet X | +| 10 | `ceres` | Ceres | +| 11 | `eris` | Eris | + +## balatro-spectrals.png + +- Frame size: **150x200** px +- Frames: **15** — suggested grid **5 x 3** (image 750x600) + +| # | id | name | +|---|----|------| +| 0 | `familiar` | Familiar | +| 1 | `grim` | Grim | +| 2 | `incantation` | Incantation | +| 3 | `talisman` | Talisman | +| 4 | `dejavu` | Deja Vu | +| 5 | `trance` | Trance | +| 6 | `medium` | Medium | +| 7 | `aura` | Aura | +| 8 | `wraith` | Wraith | +| 9 | `sigil` | Sigil | +| 10 | `ouija` | Ouija | +| 11 | `ectoplasm` | Ectoplasm | +| 12 | `immolate` | Immolate | +| 13 | `blackhole` | Black Hole | +| 14 | `hex` | Hex | + +## balatro-vouchers.png + +- Frame size: **190x250** px +- Frames: **16** — suggested grid **8 x 2** (image 1520x500) +- Poster / coupon style. + +| # | id | name | +|---|----|------| +| 0 | `overstock` | Overstock | +| 1 | `overstockplus` | Overstock Plus | +| 2 | `clearance` | Clearance Sale | +| 3 | `liquidation` | Liquidation | +| 4 | `hone` | Hone | +| 5 | `glowup` | Glow Up | +| 6 | `rerollsurplus` | Reroll Surplus | +| 7 | `rerollglut` | Reroll Glut | +| 8 | `grabber` | Grabber | +| 9 | `nachotong` | Nacho Tong | +| 10 | `wasteful` | Wasteful | +| 11 | `recyclomancy` | Recyclomancy | +| 12 | `seedmoney` | Seed Money | +| 13 | `moneytree` | Money Tree | +| 14 | `crystalball` | Crystal Ball | +| 15 | `telescope` | Telescope | + +## balatro-packs.png + +- Frame size: **180x240** px +- Frames: **10** — suggested grid **5 x 2** (image 900x480) +- Booster wrapper art; normal + jumbo variants are separate frames. + +| # | id | name | +|---|----|------| +| 0 | `standard_normal` | Standard Pack | +| 1 | `standard_jumbo` | Jumbo Standard Pack | +| 2 | `arcana_normal` | Arcana Pack | +| 3 | `arcana_jumbo` | Jumbo Arcana Pack | +| 4 | `celestial_normal` | Celestial Pack | +| 5 | `celestial_jumbo` | Jumbo Celestial Pack | +| 6 | `buffoon_normal` | Buffoon Pack | +| 7 | `buffoon_jumbo` | Jumbo Buffoon Pack | +| 8 | `spectral_normal` | Spectral Pack | +| 9 | `spectral_jumbo` | Jumbo Spectral Pack | + +## balatro-deckbacks.png + +- Frame size: **120x164** px +- Frames: **8** — suggested grid **4 x 2** (image 480x328) +- Card-back design per deck. + +| # | id | name | +|---|----|------| +| 0 | `red` | Red Deck | +| 1 | `blue` | Blue Deck | +| 2 | `yellow` | Yellow Deck | +| 3 | `green` | Green Deck | +| 4 | `black` | Black Deck | +| 5 | `magic` | Magic Deck | +| 6 | `nebula` | Nebula Deck | +| 7 | `ghost` | Ghost Deck | + +## balatro-bossicons.png + +- Frame size: **64x64** px +- Frames: **24** — suggested grid **8 x 3** (image 512x192) +- Small emblem per boss blind (shown on blind badges). + +| # | id | name | +|---|----|------| +| 0 | `the_hook` | The Hook | +| 1 | `the_ox` | The Ox | +| 2 | `the_house` | The House | +| 3 | `the_wall` | The Wall | +| 4 | `the_wheel` | The Wheel | +| 5 | `the_arm` | The Arm | +| 6 | `the_club` | The Club | +| 7 | `the_goad` | The Goad | +| 8 | `the_window` | The Window | +| 9 | `the_head` | The Head | +| 10 | `the_psychic` | The Psychic | +| 11 | `the_fish` | The Fish | +| 12 | `the_water` | The Water | +| 13 | `the_manacle` | The Manacle | +| 14 | `the_eye` | The Eye | +| 15 | `the_mouth` | The Mouth | +| 16 | `the_plant` | The Plant | +| 17 | `the_needle` | The Needle | +| 18 | `the_flint` | The Flint | +| 19 | `the_mark` | The Mark | +| 20 | `the_pillar` | The Pillar | +| 21 | `violet_vessel` | Violet Vessel | +| 22 | `crimson_heart` | Crimson Heart | +| 23 | `cerulean_bell` | Cerulean Bell | + +## Notes + +- **Editions** (Foil/Holo/Polychrome/Negative) and **seals** are rendered as runtime overlays — do not paint them. +- **Enhanced playing cards** (Bonus/Mult/Wild/Glass/Steel/Stone/Gold/Lucky) are procedural tints/glyphs on the standard face; no sheet needed. +- Card backs for face-down cards reuse the shared `cardbacks` sheet. diff --git a/src/games/balatro/tutorial.md b/src/games/balatro/tutorial.md new file mode 100644 index 0000000..c5d558e --- /dev/null +++ b/src/games/balatro/tutorial.md @@ -0,0 +1,106 @@ +# Balatro + +> *The lights dim. A grinning paper jester unfolds itself from the top of the +> deck and takes a theatrical bow.* +> +> Welcome, welcome, WELCOME to the greatest little card room outside of +> reality! I'm the Joker — well, *a* Joker; you'll be collecting plenty more of +> me — and this is **Balatro**, the game where poker hands are ammunition and +> the house is an actual monster. Pull up a chair. The felt is melting, but +> it's supposed to do that. + +## The Goal (or: Feeding the Blinds) + +A run is **8 Antes**. Each Ante serves you three **Blinds** in order: the +**Small Blind**, the **Big Blind**, and a **Boss Blind**. Every Blind demands a +chip score — *"Score at least 300"* and so on. Beat the score before you run +out of hands and the Blind pays out; run dry and the run is over. Beat the Ante +8 Boss and you win. (Then, if you're greedy — and I like greedy — **Endless +Mode** keeps the Antes coming until the numbers stop fitting in the machine.) + +- **Small and Big Blinds can be skipped.** You get nothing for it, but a few + Jokers get *very* excited about skipping. +- **Boss Blinds cannot be skipped**, and each one cheats in its own special + way — debuffing suits, dealing your cards face down, taxing your favorite + hand. Read the red text. Respect the red text. + +## Playing Hands + +Each round you get a number of **Hands** (default 4) and **Discards** +(default 3): + +1. Click up to **5 cards** in your hand to select them. +2. **Play Hand** scores them as a poker hand — Pair, Flush, Full House, all + the classics up through Straight Flush (and a few *illegal* ones you'll + discover later). +3. **Discard** throws up to 5 selected cards away and draws replacements. + Discards are precious. Spend them like they're rent money. + +## Chips × Mult (the entire game in one line) + +Every hand scores **Chips × Mult**: + +- The **hand type** sets the base — a level 1 Flush starts at 35 Chips × 4 Mult. +- Each **scoring card** adds its rank in Chips (face cards 10, Aces 11). +- Then your **Jokers** pile on: +Chips, +Mult, and the beautiful **×Mult**. +- **Planet cards** level up hand types permanently. Play Flushes? Feed Jupiter. + +The order matters: cards score left to right, then Jokers fire **left to +right**. Drag Jokers to reorder them — put your ×Mult Jokers *after* your ++Mult Jokers, or I will personally come to your house and sigh at you. + +## Jokers + +You can hold **5 Jokers** (more with the right tricks). They are the entire +engine of this game: some are dumb and honest (+4 Mult, always), some scale +forever (Ride the Bus), some retrigger cards, some print money, and some copy +other Jokers like an unlicensed photocopier. Click a Joker to inspect or +**sell** it. Buy them in the shop. Love them. Sacrifice them. + +## The Shop + +After every Blind you cash out — Blind reward, $1 per unused hand, plus +**interest** ($1 for every $5 you hold, up to $5). Then the shop opens: + +- **Cards for sale** — Jokers, Tarot / Planet cards, sometimes playing cards. +- **Booster Packs** — pick 1 from a spread: Standard (playing cards), Arcana + (Tarots), Celestial (Planets — used immediately), Buffoon (Jokers), + Spectral (dangerous magic). +- **Vouchers** — one per Ante, permanent run upgrades (more shop slots, + cheaper rerolls, bigger interest cap...). +- **Reroll** to refresh the card slots. The price climbs. The temptation too. + +## Card Modifiers + +Playing cards can be upgraded like little racehorses: + +- **Enhancements** — Bonus (+30 Chips), Mult (+4), Wild (any suit), Glass + (×2 Mult but may shatter), Steel (×1.5 while held), Stone (+50, no suit or + rank), Gold ($3 if held at round end), Lucky (maybe +20 Mult, maybe $20). +- **Editions** — Foil (+50 Chips), Holographic (+10 Mult), Polychrome + (×1.5 Mult). Jokers can be **Negative**: they take up no slot at all. +- **Seals** — Red retriggers the card, Gold pays $3 when scored, Blue makes a + Planet if held at round end, Purple makes a Tarot when discarded. + +## Tarot, Planet & Spectral Cards + +Consumables live in the two slots at the top right (click to use or sell): + +- **Tarots** transform your deck — enhance cards, change suits, raise ranks, + destroy the chaff, double your money. +- **Planets** level up a poker hand, permanently, for the rest of the run. +- **Spectrals** are the back-room stuff: seals, editions, whole-hand + transmutations. Powerful. Occasionally horrifying. Read twice, click once. +- Targeted cards (like *The Lovers*) need you to **select cards in hand + first**, so they can only be used mid-round. + +## Decks + +Eight decks, eight opening gambits — extra discards (Red), extra hands +(Blue), head-start money (Yellow), a sixth Joker slot (Black), spooky shop +Spectrals (Ghost), and more. Your run is saved automatically, so you can +close the table and come back — the cards will wait. They have nowhere else +to be. + +> Now then. The Small Blind is sitting right there, fat and skippable. +> Deal yourself in. *The jester folds back into the deck, still grinning.* diff --git a/src/main.js b/src/main.js index 989ea05..125e7bc 100644 --- a/src/main.js +++ b/src/main.js @@ -86,6 +86,7 @@ import AzulGame from './games/azul/AzulGame.js'; import JumbleGame from './games/jumble/JumbleGame.js'; import DungeonBossGame from './games/dungeonboss/DungeonBossGame.js'; import SWDBGGame from './games/swdbg/SWDBGGame.js'; +import BalatroGame from './games/balatro/BalatroGame.js'; const config = { type: Phaser.AUTO, @@ -185,6 +186,7 @@ const config = { JumbleGame, DungeonBossGame, SWDBGGame, + BalatroGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index a809393..df8c79b 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index d5cd232..4d10ddb 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -77,6 +77,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('swdbg-cards', 'data/swdbg-cards.json'); this.load.json('swdbg-artwork', 'data/swdbg-artwork.json'); this.load.json('jumble', 'data/jumble.json'); + this.load.json('balatro-artwork', 'data/balatro-artwork.json'); this.load.image('spireclimb-act1', 'assets/images/spireclimb-act1.png'); this.load.image('spireclimb-act2', 'assets/images/spireclimb-act2.png'); this.load.image('spireclimb-act3', 'assets/images/spireclimb-act3.png'); @@ -244,9 +245,15 @@ export default class PreloadScene extends Phaser.Scene { const swSheets = [swArt?.cardSheet, swArt?.baseSheet] .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); - if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0) { + // Balatro drop-in spritesheets, same contract. + const balArt = this.cache.json.get('balatro-artwork'); + const balSheets = [balArt?.jokerSheet, balArt?.tarotSheet, balArt?.planetSheet, balArt?.spectralSheet, + balArt?.voucherSheet, balArt?.packSheet, balArt?.deckBackSheet, balArt?.bossIconSheet] + .filter((s) => s && s.path && s.key && !this.textures.exists(s.key)); + + if (toLoad.length > 0 || scSheets.length > 0 || dbSheets.length > 0 || swSheets.length > 0 || balSheets.length > 0) { for (const asset of toLoad) this.load.image(asset.key, asset.path); - for (const s of [...scSheets, ...dbSheets, ...swSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); + for (const s of [...scSheets, ...dbSheets, ...swSheets, ...balSheets]) this.load.spritesheet(s.key, s.path, { frameWidth: s.frameWidth, frameHeight: s.frameHeight }); await new Promise((resolve) => { this.load.once('complete', resolve); this.load.start(); diff --git a/tools/verifyBalatro.js b/tools/verifyBalatro.js new file mode 100644 index 0000000..93cab80 --- /dev/null +++ b/tools/verifyBalatro.js @@ -0,0 +1,530 @@ +// verifyBalatro.js — headless engine verification for Balatro. +// Content sanity, evaluator table tests, scoring goldens, greedy-bot full +// runs across many seeds with state invariants, determinism (incl. mid-run +// serialize round trip) and an endless smoke test. Pure-logic (no Phaser). +// +// node verifyBalatro.js (from the tools directory) + +import { + HAND_TYPES, HAND_BY_ID, handBase, anteBase, DECKS, DECK_BY_ID, TAROTS, PLANETS, + SPECTRALS, VOUCHERS, VOUCHER_BY_ID, BOOSTERS, BOOSTER_WEIGHTS, BOOSTER_BY_ID, + BOSSES, FINISHERS, BOSS_BY_ID, PLANET_BY_HAND, fmtChips, BLINDS, +} from '../src/games/balatro/BalatroData.js'; +import { JOKERS, JOKER_BY_ID, makeJokerInstance, resolveEffectiveJokers, jokerPool } from '../src/games/balatro/BalatroJokers.js'; +import { evaluate, handContains, scorePlayedHand, cardHasSuit } from '../src/games/balatro/BalatroScoring.js'; +import { + makeRng, newRun, serializeRun, deserializeRun, passives, cardsOf, handCards, + beginAnte, selectBlind, skipBlind, validatePlay, playHand, discard, + computeCashOut, collectCashOut, continueEndless, leaveShop, rerollShop, forfeitRound, anyLegalPlay, + buyShopCard, buyVoucher, sellJoker, sellConsumable, buyPack, pickFromPack, + skipPack, useConsumable, sortHand, blindChipsFor, isDebuffed, consumableDef, +} from '../src/games/balatro/BalatroLogic.js'; + +let pass = 0, fail = 0; +const fails = []; +function ok(cond, msg) { if (cond) pass++; else { fail++; fails.push(msg); if (fails.length < 40) console.error('FAIL:', msg); } } + +// ── 1. Content sanity ──────────────────────────────────────────────────────── +{ + const JOKER_KEYS = new Set(['id', 'name', 'rarity', 'cost', 'desc', 'copyable', 'initState', + 'onScored', 'onHeld', 'independent', 'cardRetriggers', 'heldRetriggers', 'onHandPlayed', + 'afterHand', 'onDiscard', 'onBlindStart', 'onBlindSelected', 'onCashOut', 'onRoundEnd', + 'onPackSkipped', 'onBossDefeated', 'onCardAdded', 'onConsumableUsed', 'passive', 'isDead']); + const ids = new Set(); + for (const j of JOKERS) { + ok(!ids.has(j.id), `joker ${j.id} unique`); + ids.add(j.id); + ok(['common', 'uncommon', 'rare'].includes(j.rarity), `joker ${j.id} rarity`); + ok(typeof j.cost === 'number' && j.cost >= 1, `joker ${j.id} cost`); + ok(typeof j.desc === 'string' || typeof j.desc === 'function', `joker ${j.id} desc`); + for (const k of Object.keys(j)) ok(JOKER_KEYS.has(k), `joker ${j.id} unknown key '${k}'`); + if (typeof j.desc === 'function') { + const inst = makeJokerInstance(j.id, 1, makeRng(1)); + ok(typeof j.desc(inst) === 'string', `joker ${j.id} desc(inst) returns string`); + } + } + console.log(`Jokers: ${JOKERS.length} (${jokerPool('common').length}C/${jokerPool('uncommon').length}U/${jokerPool('rare').length}R)`); + ok(JOKERS.length >= 100, 'roster size >= 100'); + + const BOSS_KEYS = new Set(['id', 'name', 'desc', 'minAnte', 'reqMult', 'onBlindStart', + 'validPlay', 'cardDebuffed', 'drawTransform', 'afterHandPlayed', 'preScore', 'handSizeDelta']); + for (const b of [...BOSSES, ...FINISHERS]) { + for (const k of Object.keys(b)) ok(BOSS_KEYS.has(k), `boss ${b.id} unknown key '${k}'`); + ok(typeof b.desc === 'string', `boss ${b.id} desc`); + } + ok(BOSSES.length >= 18 && FINISHERS.length === 3, 'boss pool sizes'); + + for (const h of HAND_TYPES) ok(!!PLANET_BY_HAND[h.id], `hand ${h.id} has a planet`); + ok(PLANETS.length === HAND_TYPES.length, 'planet/hand bijection'); + + for (const v of VOUCHERS) { if (v.requires) ok(!!VOUCHER_BY_ID[v.requires], `voucher ${v.id} requires exists`); } + for (const [bid] of BOOSTER_WEIGHTS) ok(!!BOOSTER_BY_ID[bid], `booster weight ${bid} exists`); + for (const d of DECKS) { + for (const vid of (d.mods.startVouchers || [])) ok(!!VOUCHER_BY_ID[vid], `deck ${d.id} voucher ${vid}`); + for (const c of (d.mods.startConsumables || [])) { + const pool = c.kind === 'tarot' ? TAROTS : c.kind === 'planet' ? PLANETS : SPECTRALS; + ok(pool.some((x) => x.id === c.id), `deck ${d.id} consumable ${c.id}`); + } + } + const cids = new Set(); + for (const c of [...TAROTS, ...PLANETS, ...SPECTRALS]) { ok(!cids.has(c.id), `consumable ${c.id} unique`); cids.add(c.id); } +} + +// ── 2. Evaluator table ─────────────────────────────────────────────────────── +{ + let uid = 1; + const K = (rank, suit, extra = {}) => ({ uid: uid++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false, ...extra }); + const t = (cards, want, opts = {}, label = '') => { + const r = evaluate(cards, opts); + ok(r.handType === want, `evaluate ${label || want}: got ${r.handType}`); + return r; + }; + t([K(14, 'S'), K(13, 'S'), K(12, 'S'), K(11, 'S'), K(10, 'S')], 'straightflush', {}, 'royal flush'); + t([K(14, 'S'), K(2, 'H'), K(3, 'D'), K(4, 'C'), K(5, 'S')], 'straight', {}, 'ace-low straight'); + t([K(9, 'S'), K(10, 'H'), K(11, 'D'), K(12, 'C'), K(13, 'S')], 'straight', {}, 'K-high straight'); + t([K(2, 'S'), K(4, 'H'), K(6, 'D'), K(8, 'C'), K(10, 'S')], 'highcard', {}, 'gaps no shortcut'); + t([K(2, 'S'), K(4, 'H'), K(6, 'D'), K(8, 'C'), K(10, 'S')], 'straight', { shortcut: true }, 'shortcut straight'); + t([K(2, 'S'), K(3, 'H'), K(4, 'D'), K(5, 'C')], 'straight', { fourFingers: true }, '4-card straight'); + t([K(2, 'S'), K(3, 'H'), K(4, 'D'), K(5, 'C')], 'highcard', {}, '4 cards no straight'); + t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H'), K(13, 'H')], 'flush'); + t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H')], 'flush', { fourFingers: true }, '4-card flush'); + t([K(2, 'H'), K(7, 'D'), K(9, 'H'), K(11, 'D'), K(13, 'H')], 'flush', { smeared: true }, 'smeared flush'); + t([K(2, 'H'), K(7, 'D'), K(9, 'H'), K(11, 'D'), K(13, 'H')], 'highcard', {}, 'mixed no smeared'); + t([K(2, 'H'), K(7, 'H'), K(9, 'H'), K(11, 'H'), K(13, 'S', { enhancement: 'wild' })], 'flush', {}, 'wild completes flush'); + t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(5, 'C'), K(2, 'S')], 'fourofakind'); + t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'fullhouse'); + t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C')], 'threeofakind'); + t([K(5, 'S'), K(5, 'H'), K(2, 'D'), K(2, 'C'), K(9, 'S')], 'twopair'); + t([K(5, 'S'), K(5, 'H'), K(2, 'D')], 'pair'); + t([K(5, 'S'), K(9, 'H'), K(2, 'D')], 'highcard'); + t([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(5, 'C'), K(5, 'S')], 'fiveofakind', {}, '5oak off-suit'); + t([K(5, 'H'), K(5, 'H'), K(5, 'H'), K(5, 'H'), K(5, 'H')], 'flushfive'); + t([K(5, 'H'), K(5, 'H'), K(5, 'H'), K(2, 'H'), K(2, 'H')], 'flushhouse'); + // Stones: excluded from detection, always score. + { + const stone = K(9, 'S', { enhancement: 'stone' }); + const r = t([K(5, 'S'), K(5, 'H'), stone], 'pair', {}, 'pair + stone'); + ok(r.scoringUids.includes(stone.uid), 'stone always scores'); + const r2 = t([stone], 'highcard', {}, 'stone-only hand'); + ok(r2.scoringUids.includes(stone.uid), 'lone stone scores'); + } + // Scoring set shapes. + { + const cards = [K(5, 'S'), K(5, 'H'), K(9, 'D'), K(11, 'C'), K(13, 'S')]; + const r = evaluate(cards, {}); + ok(r.handType === 'pair' && r.scoringUids.length === 2, 'pair scores 2 cards'); + const hi = evaluate([K(5, 'S'), K(9, 'H'), K(13, 'D')], {}); + ok(hi.scoringUids.length === 1, 'high card scores 1'); + } + // handContains + ok(handContains([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'pair'), 'full house contains pair'); + ok(handContains([K(5, 'S'), K(5, 'H'), K(5, 'D'), K(2, 'C'), K(2, 'S')], 'threeofakind'), 'full house contains trips'); + ok(!handContains([K(5, 'S'), K(5, 'H'), K(9, 'D')], 'threeofakind'), 'pair lacks trips'); + ok(cardHasSuit(K(5, 'H'), 'D', true) && !cardHasSuit(K(5, 'H'), 'D', false), 'smeared suit merge'); + ok(cardHasSuit(K(5, 'S', { enhancement: 'wild' }), 'D', false), 'wild any suit'); + ok(!cardHasSuit(K(5, 'D', { enhancement: 'stone' }), 'D', false), 'stone no suit'); +} + +// ── 3. Scoring goldens ─────────────────────────────────────────────────────── +{ + let uid = 100; + const K = (rank, suit, extra = {}) => ({ uid: uid++, rank, suit, enhancement: null, edition: null, seal: null, faceDown: false, debuffed: false, ...extra }); + function fixture(jokerIds = [], deckId = 'red') { + const run = newRun(deckId, 42); + run.jokers = jokerIds.map((id, i) => makeJokerInstance(id, 9000 + i, makeRng(7))); + return run; + } + function score(run, played, held = [], boss = null, seed = 1) { + const p = passives(run); + return scorePlayedHand(run, played, held, { + effJokers: resolveEffectiveJokers(run), passives: p, rng: makeRng(seed), + isDebuffed: () => false, boss, + }); + } + + // Pair of Kings + Joker = (10+10+10) × (2+4) = 180 + { + const run = fixture(['joker']); + const r = score(run, [K(13, 'S'), K(13, 'H')]); + ok(r.score === 180, `pair of kings + Joker = 180 (got ${r.score})`); + ok(r.events[0].t === 'base' && r.events[r.events.length - 1].t === 'total', 'trace bookends'); + ok(r.events.every((e) => typeof e.chipsAfter === 'number' && typeof e.multAfter === 'number'), 'events carry counters'); + } + // Red seal + Hack on a played 4 → 3 triggers of its 4 chips: highcard 5 + 4*3 = 17 + { + const run = fixture(['hack']); + const r = score(run, [K(4, 'D', { seal: 'red' })]); + ok(r.score === 17 * 1, `red seal + Hack triggers x3 (got ${r.score})`); + ok(r.events.filter((e) => e.t === 'retrigger').length === 2, 'two retrigger markers'); + } + // Blueprint copies The Duo: pair of 2s → mult 2 × 2 × 2 = 8, chips 10+2+2=14 → 112 + { + const run = fixture(['blueprint', 'duo']); + const r = score(run, [K(2, 'S'), K(2, 'H')]); + ok(r.score === 112, `Blueprint copies Duo = 112 (got ${r.score})`); + } + // Brainstorm copies leftmost: [duo, brainstorm] → same 112 + { + const run = fixture(['duo', 'brainstorm']); + const r = score(run, [K(2, 'S'), K(2, 'H')]); + ok(r.score === 112, `Brainstorm copies leftmost Duo = 112 (got ${r.score})`); + } + // Blueprint pointing at Blueprint→Duo chain resolves through + { + const run = fixture(['blueprint', 'blueprint', 'duo']); + const r = score(run, [K(2, 'S'), K(2, 'H')]); + ok(r.score === 14 * 16, `Blueprint chain = ${14 * 16} (got ${r.score})`); + } + // The Flint halves base: pair of kings → ceil(10/2)=5 +10+10 chips, mult ceil(2/2)=1 → 25 + { + const run = fixture([]); + const r = score(run, [K(13, 'S'), K(13, 'H')], [], BOSS_BY_ID.the_flint); + ok(r.score === 25, `The Flint halving = 25 (got ${r.score})`); + } + // Debuffed card counts for hand type but contributes 0 + { + const run = fixture([]); + const cards = [K(13, 'S'), K(13, 'H', { debuffed: true })]; + const r = score(run, cards); + ok(r.handType === 'pair' && r.score === (10 + 10) * 2, `debuffed contributes 0 (got ${r.score})`); + } + // Steel held ×1.5: high card 5+11=16 chips... A♠ played (11), steel held: mult 1×1.5=1.5 → floor(16)*1.5=24 + { + const run = fixture([]); + const r = score(run, [K(14, 'S')], [K(9, 'H', { enhancement: 'steel' })]); + ok(r.score === Math.floor(16) * 1.5, `steel held x1.5 (got ${r.score})`); + } + // Glass ×2 then Polychrome ×1.5 on the same card: high card A: chips 5+11+? poly? glass: + { + const run = fixture([]); + const r = score(run, [K(14, 'S', { enhancement: 'glass', edition: 'poly' })], [], null, 3); + ok(r.score === 16 * 3, `glass x2 then poly x1.5 = mult 3 (got ${r.score})`); + } + // Foil +50 chips on scored card + { + const run = fixture([]); + const r = score(run, [K(14, 'S', { edition: 'foil' })]); + ok(r.score === (5 + 11 + 50) * 1, `foil +50 (got ${r.score})`); + } + // The Arm: hand level drops before scoring (level 2 pair → level 1) + { + const run = fixture([]); + run.handLevels.pair = 2; + const r = score(run, [K(13, 'S'), K(13, 'H')], [], BOSS_BY_ID.the_arm); + ok(run.handLevels.pair === 1, 'The Arm decrements level'); + ok(r.score === 180 / 6 * 2, `The Arm scores at reduced level (got ${r.score})`); + } + // Lucky determinism: same seed → same score twice + { + const mk = () => { + const run = fixture([]); + return score(run, [K(7, 'S', { enhancement: 'lucky' }), K(7, 'H', { enhancement: 'lucky' })], [], null, 12345); + }; + ok(mk().score === mk().score, 'lucky cards deterministic under seed'); + } + // Gold seal pays $3 per trigger + { + const run = fixture([]); + const before = run.gold; + score(run, [K(9, 'S', { seal: 'gold' })]); + ok(run.gold === before + 3, 'gold seal +$3'); + } + // Ride the Bus grows before contributing; resets on face + { + const run = fixture(['ridethebus']); + const r1 = score(run, [K(9, 'S'), K(9, 'H')]); + ok(run.jokers[0].state.n === 1 && r1.score === (10 + 9 + 9) * 3, `RTB first hand +1 (got ${r1.score})`); + score(run, [K(13, 'S')]); + ok(run.jokers[0].state.n === 0, 'RTB resets on face card'); + } + // Ice Cream decays after the hand + { + const run = fixture(['icecream']); + const r = score(run, [K(9, 'S')]); + ok(r.score === (5 + 9 + 100) * 1, `Ice Cream +100 first hand (got ${r.score})`); + ok(run.jokers[0].state.n === undefined && run.jokers[0].state.chips === 95, 'Ice Cream decayed to 95'); + } + // Splash: every played card scores + { + const run = fixture(['splash']); + const r = score(run, [K(13, 'S'), K(13, 'H'), K(2, 'D'), K(3, 'C'), K(4, 'S')]); + ok(r.score === (10 + 10 + 10 + 2 + 3 + 4) * 2, `Splash scores all (got ${r.score})`); + } + // Stencil: empty slots + itself + { + const run = fixture(['stencil']); // 5 slots, 1 used → 4 empty + itself = x5 + const r = score(run, [K(9, 'S')]); + ok(r.score === 14 * 5, `Stencil x5 (got ${r.score})`); + } + // Baseball Card: uncommon jokers give x1.5 + { + const run = fixture(['baseball', 'hack', 'mime']); + const r = score(run, [K(9, 'S')]); + ok(r.score === 14 * 1 * 1.5 * 1.5, `Baseball x1.5 per uncommon (got ${r.score})`); + } + // Hiker: permanent +5 chips accumulates + { + const run = fixture(['hiker']); + const card = K(9, 'S'); + score(run, [card]); + ok(card.permaChips === 5, 'Hiker adds permaChips'); + const r2 = score(run, [card]); + ok(r2.score === (5 + 9 + 5) * 1, `Hiker perma chips apply next play (got ${r2.score})`); + } +} + +// ── 4. Greedy-bot full runs ────────────────────────────────────────────────── +function combos(arr, k) { + const out = []; + const rec = (start, cur) => { + if (cur.length === k) { out.push(cur.slice()); return; } + for (let i = start; i < arr.length; i++) { cur.push(arr[i]); rec(i + 1, cur); cur.pop(); } + }; + rec(0, []); + return out; +} + +function bestPlay(run) { + const hand = run.hand.slice(); + const p = passives(run); + let best = null; + for (const k of [5, 4, 3, 2, 1]) { + if (hand.length < k) continue; + for (const uids of combos(hand, Math.min(k, hand.length))) { + if (validatePlay(run, uids)) continue; + const { handType } = evaluate(cardsOf(run, uids), p); + const base = handBase(handType, run.handLevels[handType] || 1); + const est = base.chips * base.mult; + if (!best || est > best.est) best = { uids, est, handType }; + } + if (best && k <= 3) break; + } + return best; +} + +function invariants(run, tag) { + const uids = run.deck.map((c) => c.uid); + ok(new Set(uids).size === uids.length, `${tag}: deck uids unique`); + const inDeck = new Set(uids); + ok(run.hand.every((u) => inDeck.has(u)), `${tag}: hand ⊆ deck`); + ok(run.drawPile.every((u) => inDeck.has(u)), `${tag}: drawPile ⊆ deck`); + ok(run.hand.every((u) => !run.drawPile.includes(u)), `${tag}: hand ∩ drawPile empty`); + const p = passives(run); + ok(run.gold >= p.creditFloor - 0.001, `${tag}: gold ${run.gold} >= floor ${p.creditFloor}`); + ok(run.handsLeft >= 0 && run.discardsLeft >= 0, `${tag}: counters >= 0`); + ok(run.consumables.length <= p.consumableSlots, `${tag}: consumables within slots`); + // Slots can shrink after acquisition (a Negative joker destroyed by Madness / + // Hex, or sold) — over-cap is legal then; only gross blowout is a bug. + ok(run.jokers.filter((j) => j.edition !== 'negative').length <= p.jokerSlots + 2, `${tag}: jokers within slots (+slack)`); +} + +function botRun(seed, deckId) { + const rng = makeRng(seed ^ 0x9e3779b9); + const run = newRun(deckId, seed); + let steps = 0; + const MAX = 4000; + while (run.phase !== 'over' && steps++ < MAX) { + if (steps % 25 === 0) invariants(run, `seed ${seed} step ${steps}`); + if (run.phase === 'blindselect') { + if (BLINDS[run.blind].skippable && rng() < 0.08) { skipBlind(run); continue; } + const r = selectBlind(run); + ok(r.ok, `seed ${seed}: selectBlind ok`); + } else if (run.phase === 'playing') { + // Occasionally use a consumable to exercise paths. + if (run.consumables.length && rng() < 0.35) { + const inst = rng.pick(run.consumables); + const def = consumableDef(inst); + const n = def.targets || 0; + const targets = n ? run.hand.slice(0, def.effect && def.effect.kind === 'death' ? 2 : n) : []; + useConsumable(run, inst.uid, targets); + } + const p = passives(run); + const best = bestPlay(run); + if (!best) { + // No legal play (boss constraints); discard toward one, else forfeit. + if (run.discardsLeft > 0 && run.hand.length) { + const r = discard(run, run.hand.slice(-Math.min(3, run.hand.length))); + ok(r.ok, `seed ${seed}: fallback discard ok`); + continue; + } + ok(!anyLegalPlay(run), `seed ${seed}: forfeit only when truly stuck`); + const r = forfeitRound(run); + ok(r.ok && run.phase === 'over', `seed ${seed}: forfeit ends run`); + continue; + } + if (best.est < 40 && run.discardsLeft > 0 && rng() < 0.6 && run.hand.length > 3) { + const worst = run.hand.slice().sort((a, b) => { + const ca = cardsOf(run, [a])[0], cb = cardsOf(run, [b])[0]; + return ca.rank - cb.rank; + }).slice(0, 3); + const r = discard(run, worst); + ok(r.ok, `seed ${seed}: discard ok`); + continue; + } + const r = playHand(run, best.uids); + ok(r.ok, `seed ${seed}: playHand ok (${r.error || ''})`); + if (r.ok) { + ok(r.trace.events.length >= 2, `seed ${seed}: trace has events`); + ok(Number.isFinite(r.trace.score) || run.endless, `seed ${seed}: finite score`); + } + } else if (run.phase === 'cashout') { + const r = collectCashOut(run); + ok(r.ok, `seed ${seed}: collectCashOut ok`); + } else if (run.phase === 'shop') { + if (run.pack) { + const r = pickFromPack(run, 0); + if (!r.ok) skipPack(run); + continue; + } + const p = passives(run); + let acted = false; + // Buy first affordable shop card. + for (let i = 0; i < run.shop.cards.length; i++) { + const item = run.shop.cards[i]; + if (run.gold - item.cost >= 0) { + const r = buyShopCard(run, i); + if (r.ok) { acted = true; break; } + } + } + if (!acted && run.shop.voucher && !run.shop.voucher.sold && run.gold >= run.shop.voucher.cost + 4) { + if (buyVoucher(run).ok) acted = true; + } + if (!acted && run.shop.packs.length && run.gold >= run.shop.packs[0].cost + 2 && rng() < 0.7) { + if (buyPack(run, 0).ok) continue; + } + if (!acted && rng() < 0.2 && run.gold > (run.shop.rerollCost + 6)) { + rerollShop(run); + continue; + } + if (!acted) { + // Sometimes sell a joker to exercise selling. + if (run.jokers.length > 4 && rng() < 0.1) sellJoker(run, rng.pick(run.jokers).uid); + const r = leaveShop(run); + ok(r.ok, `seed ${seed}: leaveShop ok`); + } + } else { + ok(false, `seed ${seed}: unknown phase ${run.phase}`); + break; + } + } + ok(steps < MAX, `seed ${seed}: run terminated (${steps} steps)`); + invariants(run, `seed ${seed} final`); + return run; +} + +{ + const SEEDS = 200; + const anteReached = []; + let wins = 0; + for (let s = 1; s <= SEEDS; s++) { + const deckId = DECKS[s % DECKS.length].id; + let run; + try { + run = botRun(s * 7919, deckId); + } catch (e) { + ok(false, `seed ${s * 7919} (${deckId}) threw: ${e.stack.split('\n').slice(0, 3).join(' | ')}`); + continue; + } + anteReached.push(run.stats.bestAnte); + if (run.won) wins++; + } + const dist = {}; + for (const a of anteReached) dist[a] = (dist[a] || 0) + 1; + console.log('Bot ante distribution:', JSON.stringify(dist), `wins: ${wins}/${SEEDS}`); + const reach4 = anteReached.filter((a) => a >= 4).length; + ok(reach4 >= SEEDS * 0.05, `>=5% of seeds reach ante 4 (got ${reach4}/${SEEDS})`); + ok(anteReached.length && Math.max(...anteReached) >= 5, 'some seed reaches ante 5+'); +} + +// ── 5. Determinism ─────────────────────────────────────────────────────────── +{ + const play = (seed) => { + const run = newRun('blue', seed); + const traces = []; + let guard = 0; + while (run.phase !== 'over' && guard++ < 400) { + if (run.phase === 'blindselect') selectBlind(run); + else if (run.phase === 'playing') { + const best = bestPlay(run) || { uids: run.hand.slice(0, Math.min(5, run.hand.length)) }; + const r = playHand(run, best.uids); + if (r.ok) traces.push(r.trace.score); + else break; + } else if (run.phase === 'cashout') collectCashOut(run); + else if (run.phase === 'shop') { + if (run.pack) { if (!pickFromPack(run, 0).ok) skipPack(run); continue; } + if (run.shop.cards.length && run.gold >= run.shop.cards[0].cost) buyShopCard(run, 0); + leaveShop(run); + } + } + return { snap: serializeRun(run), traces: traces.join(',') }; + }; + const a = play(777), b = play(777); + ok(a.snap === b.snap, 'same seed → identical final run'); + ok(a.traces === b.traces, 'same seed → identical score traces'); + + // Mid-run serialize round trip continues identically. + const run1 = newRun('red', 4242); + selectBlind(run1); + const best1 = bestPlay(run1); + playHand(run1, best1.uids); + const run2 = deserializeRun(serializeRun(run1)); + ok(run2 !== null, 'deserialize succeeds'); + const cont = (run) => { + let guard = 0; + const log = []; + while (run.phase !== 'over' && guard++ < 200) { + if (run.phase === 'blindselect') selectBlind(run); + else if (run.phase === 'playing') { + const b = bestPlay(run) || { uids: run.hand.slice(0, 1) }; + const r = playHand(run, b.uids); + if (!r.ok) break; + log.push(r.trace.score); + } else if (run.phase === 'cashout') collectCashOut(run); + else if (run.phase === 'shop') { if (run.pack) { if (!pickFromPack(run, 0).ok) skipPack(run); continue; } leaveShop(run); } + } + return log.join(','); + }; + ok(cont(run1) === cont(run2), 'serialize round trip continues identically'); +} + +// ── 6. Endless smoke ───────────────────────────────────────────────────────── +{ + const run = newRun('red', 31337); + let lastReq = 0; + let guard = 0; + let overflow = false; + while (run.phase !== 'over' && run.ante <= 20 && guard++ < 3000) { + if (run.phase === 'blindselect') { + selectBlind(run); + ok(run.blindChips > 0 && Number.isFinite(run.blindChips), `ante ${run.ante} requirement finite`); + if (run.blind === 'small') { ok(run.blindChips >= lastReq, `ante ${run.ante} small >= previous small (${run.blindChips} vs ${lastReq})`); lastReq = run.blindChips; } + ok(typeof fmtChips(run.blindChips) === 'string' && !fmtChips(run.blindChips).includes('NaN'), 'fmtChips clean'); + } else if (run.phase === 'playing') { + run.roundScore = run.blindChips - 1; // god hand: any valid play wins + // Respect boss constraints: include the forced card, satisfy The Psychic. + let sel = run.hand.slice(0, Math.min(5, run.hand.length)); + const forced = run.bossState.forcedUid; + if (forced && !sel.includes(forced)) sel = [forced, ...sel.slice(0, 4)]; + let r = playHand(run, sel); + if (!r.ok) r = playHand(run, forced ? [forced] : [run.hand[0]]); + if (!r.ok) { + if (run.discardsLeft > 0) { discard(run, [run.hand[run.hand.length - 1]]); continue; } + forfeitRound(run); + } + } else if (run.phase === 'cashout') { + collectCashOut(run); + if (run.phase === 'over' && run.won && !run.endless) { continueEndless(run); } + } else if (run.phase === 'shop') { + if (run.pack) { skipPack(run); continue; } + leaveShop(run); + } + } + ok(run.ante >= 20 || run.phase === 'over', `endless reached ante ${run.ante}`); + ok(run.endless && run.won, 'endless flag + win recorded'); + ok(anteBase(30) > anteBase(20) && anteBase(20) > anteBase(9), 'endless scaling increases'); + ok(Number.isFinite(anteBase(60)), 'anteBase clamps before Infinity'); + ok(fmtChips(anteBase(25)).length > 0 && fmtChips(1e15).includes('e'), 'e-notation past 1e11'); +} + +console.log(`\nverifyBalatro: ${pass} passed, ${fail} failed`); +if (fail) { console.error(fails.slice(0, 60).join('\n')); process.exit(1); }