Skip to content

Commit

Permalink
feat(core): handle probabilities
Browse files Browse the repository at this point in the history
Handle probabilities by calculating the score three times: once as if the player has no luck at all, once as if the player has average luck, and once as if the player has all the luck.
  • Loading branch information
kleinfreund committed Mar 24, 2024
1 parent afa69f9 commit a5b7189
Show file tree
Hide file tree
Showing 34 changed files with 283 additions and 105 deletions.
16 changes: 12 additions & 4 deletions src/lib/balatro.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import case023 from './test-files/023.js'
import case024 from './test-files/024.js'
import case025 from './test-files/025.js'
import case026 from './test-files/026.js'

type Expected = Omit<ReturnType<typeof calculateScore>, 'scoringCards'> & { scoringCards: Partial<Card>[] }
import case027 from './test-files/027.js'
import case028 from './test-files/028.js'

export type TestCase = {
message: string
initialState: InitialState
expected: Expected
expected: Omit<ReturnType<typeof calculateScore>, 'scoringCards'> & {
scoringCards: Partial<Card>[]
}
}

describe('calculateScore', () => {
Expand Down Expand Up @@ -66,7 +68,13 @@ describe('calculateScore', () => {
case024('Pair, inactive Verdant Leaf'),
case025('Pair, active Verdant Leaf'),
case026('Pair, The Pillar'),
case027('Lucky Pair'),
case028('Lucky Flush, Bloodstone'),
])('$message', ({ initialState, expected }) => {
expect(calculateScore(getState(initialState))).toMatchObject(expected)
const score = calculateScore(getState(initialState))

expect(score.hand).toEqual(expected.hand)
expect(score.scoringCards).toMatchObject(expected.scoringCards)
expect(score.scores).toMatchObject(expected.scores)
})
})
76 changes: 45 additions & 31 deletions src/lib/balatro.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RANK_TO_CHIP_MAP, PLAYED_CARD_RETRIGGER_JOKER_NAMES, HELD_CARD_RETRIGGER_JOKER_NAMES } from '#lib/data.js'
import type { Card, Joker, JokerCardEffect, JokerEffect, Result, Score, State } from '#lib/types.js'
import { RANK_TO_CHIP_MAP, PLAYED_CARD_RETRIGGER_JOKER_NAMES, HELD_CARD_RETRIGGER_JOKER_NAMES, LUCKS } from '#lib/data.js'
import type { Card, Joker, JokerCardEffect, JokerEffect, Luck, Result, ResultScore, Score, State } from '#lib/types.js'
import { formatScore } from '#utilities/formatScore.js'
import { log, logGroup, logGroupEnd } from '#utilities/log.js'
import { isFaceCard } from '#utilities/isFaceCard.js'
Expand All @@ -8,41 +8,49 @@ import { notNullish } from '#utilities/notNullish.js'
import { resolveJoker } from '#utilities/resolveJokers.js'

export function calculateScore (state: State): Result {
const { chips, multiplier } = getScore(state)
const scores: ResultScore[] = []

log('\nReceived:', { chips, multiplier })
log('Expected:', { chips: 340, multiplier: 1685e13 })
for (const luck of LUCKS) {
const { chips, multiplier } = getScore(state, luck)

let actualScore
if (state.deck === 'Plasma Deck') {
log('Balanced:', { chips: 2.470e16, multiplier: 2.470e16 })
actualScore = Math.pow((chips + multiplier) / 2, 2)
} else {
actualScore = chips * multiplier
}
log('\nReceived:', { chips, multiplier })
log('Expected:', { chips: 340, multiplier: 1685e13 })

let actualScore
if (state.deck === 'Plasma Deck') {
log('Balanced:', { chips: 2.470e16, multiplier: 2.470e16 })
actualScore = Math.pow((chips + multiplier) / 2, 2)
} else {
actualScore = chips * multiplier
}

// Balatro seems to round values starting at a certain threshold and it seems to round down. 🤔
const score = actualScore > 10000 ? Math.floor(actualScore) : actualScore
// Balatro seems to round values starting at a certain threshold and it seems to round down. 🤔
const score = actualScore > 10000 ? Math.floor(actualScore) : actualScore
scores.push({
score,
formattedScore: formatScore(score),
luck,
})
}

return {
hand: state.playedHand,
scoringCards: state.scoringCards,
score,
formattedScore: formatScore(score),
scores,
}
}

function getScore (state: State): Score {
const { chips: baseChips, multiplier: baseMultiplier } = state.handBaseScores[state.playedHand]
function getScore (state: State, luck: Luck): Score {
const baseScore = state.handBaseScores[state.playedHand]

// Step 0. Determine base chips and multiplier.
// Determine base chips and multiplier.
log('\n0. Determining base score …')
// The Flint halves the base chips and multiplier.
// The base score seems to be rounded here.
const baseFactor = (state.blind.name === 'The Flint' && state.blind.isActive ? 0.5 : 1)
// The base score seems to be rounded here.
const score: Score = {
chips: Math.round(baseChips * baseFactor),
multiplier: Math.round(baseMultiplier * baseFactor),
chips: Math.round(baseScore.chips * baseFactor),
multiplier: Math.round(baseScore.multiplier * baseFactor),
}
log('\n0. BASE SCORE =>', score)

Expand Down Expand Up @@ -83,6 +91,12 @@ function getScore (state: State): Score {
log(score, '(+Mult from mult enhancement)')
break
}
case 'lucky': {
const hasOops = state.jokerSet.has('Oops! All 6s')
score.multiplier += luck === 'all' ? 20 : luck === 'none' ? 0 : (hasOops ? 8 : 4)
log(score, '(+Mult from lucky enhancement)')
break
}
case 'glass': {
score.multiplier *= 2
log(score, '(xMult from glass enhancement)')
Expand Down Expand Up @@ -111,7 +125,7 @@ function getScore (state: State): Score {

// 4. Joker effects for played cards
for (const joker of state.jokers) {
scoreJokerCardEffect(joker.playedCardEffect, { state, score, joker, card })
scoreJokerCardEffect(joker.playedCardEffect, { state, score, joker, card, luck })
}
}

Expand Down Expand Up @@ -144,7 +158,7 @@ function getScore (state: State): Score {

// 2. Joker effects for held cards
for (const joker of state.jokers) {
scoreJokerCardEffect(joker.heldCardEffect, { state, score, joker, card })
scoreJokerCardEffect(joker.heldCardEffect, { state, score, joker, card, luck })
log(score, `(${joker})`)
}
}
Expand Down Expand Up @@ -177,7 +191,7 @@ function getScore (state: State): Score {
}

// 2. JOKER EFFECTS
scoreJokerEffect(joker.effect, { state, score, joker })
scoreJokerEffect(joker.effect, { state, score, joker, luck })

logGroupEnd(`← ${joker}`, score)
}
Expand Down Expand Up @@ -258,7 +272,7 @@ function getHeldCardTriggers ({ state, card }: { state: State, card: Card }): st
return triggers
}

function scoreJokerEffect (effect: JokerEffect | undefined, { state, score, joker }: { state: State, score: Score, joker: Joker }) {
function scoreJokerEffect (effect: JokerEffect | undefined, { state, score, joker, luck }: { state: State, score: Score, joker: Joker, luck: Luck }) {
const triggers = ['Regular']

// Increase triggers from Blueprint/Brainstorm
Expand All @@ -276,27 +290,27 @@ function scoreJokerEffect (effect: JokerEffect | undefined, { state, score, joke
for (const [index, trigger] of triggers.entries()) {
log(`Trigger: ${index + 1} (${trigger})`)
if (effect) {
effect.call(joker, { state, score })
effect.call(joker, { state, score, luck })
log(score, `(${joker.name})`)
}

for (const target of targets) {
if (target.indirectEffect) {
target.indirectEffect({ state, score, joker })
target.indirectEffect({ state, score, joker, luck })
log(score, trigger)
}
}

for (const jokersWithIndirectEffect of jokersWithIndirectEffects) {
if (jokersWithIndirectEffect.indirectEffect) {
jokersWithIndirectEffect.indirectEffect({ state, score, joker })
jokersWithIndirectEffect.indirectEffect({ state, score, joker, luck })
log(score, `(${jokersWithIndirectEffect.name})`)
}
}
}
}

function scoreJokerCardEffect (effect: JokerCardEffect | undefined, { state, score, joker, card }: { state: State, score: Score, joker: Joker, card: Card }) {
function scoreJokerCardEffect (effect: JokerCardEffect | undefined, { state, score, joker, card, luck }: { state: State, score: Score, joker: Joker, card: Card, luck: Luck }) {
if (!effect) {
return
}
Expand Down Expand Up @@ -326,7 +340,7 @@ function scoreJokerCardEffect (effect: JokerCardEffect | undefined, { state, sco

for (let trigger = 0; trigger < triggers.length; trigger++) {
log(`Trigger: ${trigger + 1} (${triggers[trigger]})`)
effect.call(joker, { state, score, joker, card })
effect.call(joker, { state, score, joker, card, luck })
log(score, `(${joker.name})`)
}
logGroupEnd('←', score)
Expand Down
19 changes: 10 additions & 9 deletions src/lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { flush, nOfAKind, straight } from '#lib/getHand.js'
import { isFaceCard } from '#utilities/isFaceCard.js'
import { isRank } from '#utilities/isRank.js'
import { isSuit } from '#utilities/isSuit.js'
import type { BlindName, DeckName, Edition, Enhancement, HandName, JokerDefinition, JokerEdition, JokerName, Rank, Score, Seal, Suit } from '#lib/types.js'
import type { BlindName, DeckName, Edition, Enhancement, HandName, JokerDefinition, JokerEdition, JokerName, Luck, Rank, Score, Seal, Suit } from '#lib/types.js'

export const BLINDS: BlindName[] = ['Small Blind', 'Big Blind', 'The Hook', 'The Ox', 'The House', 'The Wall', 'The Wheel', 'The Arm', 'The Club', 'The Fish', 'The Psychic', 'The Goad', 'The Water', 'The Window', 'The Manacle', 'The Eye', 'The Mouth', 'The Plant', 'The Serpent', 'The Pillar', 'The Needle', 'The Head', 'The Tooth', 'The Flint', 'The Mark', 'Amber Acorn', 'Verdant Leaf', 'Violet Vessel', 'Crimson Heart', 'Cerulean Bell']

Expand All @@ -18,6 +18,8 @@ export const JOKER_EDITIONS: JokerEdition[] = ['base', 'foil', 'holographic', 'p
export const RANKS: Rank[] = ['Ace', 'King', 'Queen', 'Jack', '10', '9', '8', '7', '6', '5', '4', '3', '2']
export const SUITS: Suit[] = ['Clubs', 'Spades', 'Hearts', 'Diamonds']

export const LUCKS: Luck[] = ['none', 'average', 'all']

export const PLANET_SCORE_SETS: Record<HandName, Score> = {
'Flush Five': { chips: 40, multiplier: 3 },
'Flush House': { chips: 40, multiplier: 3 },
Expand Down Expand Up @@ -258,8 +260,8 @@ export const JOKER_DEFINITIONS: Record<JokerName, JokerDefinition> = {
},
'Misprint': {
rarity: 'common',
effect ({ score }) {
score.multiplier += 0
effect ({ score, luck }) {
score.multiplier += luck === 'all' ? 23 : luck === 'none' ? 0 : 11.5
},
},
'Dusk': {
Expand Down Expand Up @@ -711,12 +713,11 @@ export const JOKER_DEFINITIONS: Record<JokerName, JokerDefinition> = {
},
'Bloodstone': {
rarity: 'uncommon',
probability: {
numerator: 1,
denominator: 3,
},
playedCardEffect ({ score, card }) {
score.multiplier *= (isSuit(card, 'Hearts') ? 1 : 1)
playedCardEffect ({ score, card, state, luck }) {
if (isSuit(card, 'Hearts')) {
const hasOops = state.jokerSet.has('Oops! All 6s')
score.multiplier *= luck === 'all' ? 2 : luck === 'none' ? 1 : 1 + (hasOops ? 2 : 1)/3
}
},
},
'Arrowhead': {
Expand Down
7 changes: 5 additions & 2 deletions src/lib/test-files/001.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ export default (message: string): TestCase => {
{ rank: '7', suit: 'Clubs' },
{ rank: '7', suit: 'Diamonds' },
],
score: 108,
formattedScore: '108',
scores: [
{ score: 108, formattedScore: '108', luck: 'none' },
{ score: 108, formattedScore: '108', luck: 'average' },
{ score: 108, formattedScore: '108', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/002.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ export default (message: string): TestCase => {
{ rank: '4', suit: 'Spades' },
{ rank: '4', suit: 'Hearts', edition: 'holographic' },
],
score: 600,
formattedScore: '600',
scores: [
{ score: 600, formattedScore: '600', luck: 'none' },
{ score: 600, formattedScore: '600', luck: 'average' },
{ score: 600, formattedScore: '600', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/003.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ export default (message: string): TestCase => {
scoringCards: [
{ rank: '7', suit: 'Hearts', enhancement: 'bonus' },
],
score: 147,
formattedScore: '147',
scores: [
{ score: 147, formattedScore: '147', luck: 'none' },
{ score: 147, formattedScore: '147', luck: 'average' },
{ score: 147, formattedScore: '147', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/004.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ export default (message: string): TestCase => {
{ rank: '7', suit: 'Diamonds' },
{ rank: '6', suit: 'Diamonds' },
],
score: 539,
formattedScore: '539',
scores: [
{ score: 539, formattedScore: '539', luck: 'none' },
{ score: 539, formattedScore: '539', luck: 'average' },
{ score: 539, formattedScore: '539', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/005.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ export default (message: string): TestCase => {
scoringCards: [
{ rank: 'Queen', suit: 'Diamonds', isDebuffed: true },
],
score: 16.25,
formattedScore: '16',
scores: [
{ score: 16.25, formattedScore: '16', luck: 'none' },
{ score: 16.25, formattedScore: '16', luck: 'average' },
{ score: 16.25, formattedScore: '16', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/006.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ export default (message: string): TestCase => {
{ rank: '7', suit: 'Spades' },
{ rank: '7', suit: 'Hearts', isDebuffed: true },
],
score: 134,
formattedScore: '134',
scores: [
{ score: 134, formattedScore: '134', luck: 'none' },
{ score: 134, formattedScore: '134', luck: 'average' },
{ score: 134, formattedScore: '134', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/007.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ export default (message: string): TestCase => {
{ rank: '4', suit: 'Hearts' },
{ rank: '4', suit: 'Clubs' },
],
score: 3192,
formattedScore: '3,192',
scores: [
{ score: 3192, formattedScore: '3,192', luck: 'none' },
{ score: 3192, formattedScore: '3,192', luck: 'average' },
{ score: 3192, formattedScore: '3,192', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/008.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ export default (message: string): TestCase => {
{ rank: '9', suit: 'Spades' },
{ rank: '8', suit: 'Spades' },
],
score: 2772,
formattedScore: '2,772',
scores: [
{ score: 2772, formattedScore: '2,772', luck: 'none' },
{ score: 2772, formattedScore: '2,772', luck: 'average' },
{ score: 2772, formattedScore: '2,772', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/009.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ export default (message: string): TestCase => {
{ rank: '4', suit: 'Hearts' },
{ rank: '2', suit: 'Hearts' },
],
score: 3082,
formattedScore: '3,082',
scores: [
{ score: 3082, formattedScore: '3,082', luck: 'none' },
{ score: 3082, formattedScore: '3,082', luck: 'average' },
{ score: 3082, formattedScore: '3,082', luck: 'all' },
],
},
}
}
7 changes: 5 additions & 2 deletions src/lib/test-files/010.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ export default (message: string): TestCase => {
{ rank: '6', suit: 'Spades' },
{ rank: '6', suit: 'Spades' },
],
score: 2856,
formattedScore: '2,856',
scores: [
{ score: 2856, formattedScore: '2,856', luck: 'none' },
{ score: 2856, formattedScore: '2,856', luck: 'average' },
{ score: 2856, formattedScore: '2,856', luck: 'all' },
],
},
}
}
Loading

0 comments on commit a5b7189

Please sign in to comment.