From 8e39465db02f8f2c506d6148165dac799217b8a0 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Tue, 23 Dec 2025 22:57:16 -0800 Subject: [PATCH] refactor: purify functions, constant-ify, add more testing --- game.js | 169 ++++++++++++++++--------------- game.test.js | 280 ++++++++++++++++++++++++++++++--------------------- index.html | 20 ++-- index.js | 88 +++++++++++++--- style.css | 44 ++++++++ 5 files changed, 381 insertions(+), 220 deletions(-) diff --git a/game.js b/game.js index 62f7967..cf815ae 100644 --- a/game.js +++ b/game.js @@ -11,16 +11,18 @@ export const Weather = Object.freeze({ }); /** - * Enum representing game duration options. - * Provides both numeric values and reverse lookups. - * @readonly - * @enum {number|string} + * Game balance constants. */ -export const Days = Object.freeze({ - 7: 7, - 14: 14, - 30: 30 -}); +const BASE_DEMAND = 30; +const PRICE_SENSITIVITY = 1.5; +const DEMAND_VARIANCE = 0.2; +const TASTE_SCORE_MIN = 0.5; +const TASTE_SCORE_MAX = 1.2; +const TASTE_PENALTY_LEMON = 0.3; +const TASTE_PENALTY_SUGAR = 0.2; +const PERFECT_RECIPE_BONUS = 0.2; +const STARTING_MONEY = 2.00; +const STARTING_PRICE = 1.00; /** * Multiplier applied to base demand based on weather conditions. @@ -74,6 +76,12 @@ const IdealRecipe = { } } +/** + * Valid supply item types. + * @type {string[]} + */ +const SupplyTypes = ['lemons', 'sugar', 'ice', 'cups']; + /** * Tiered pricing structure for supplies. * Price per unit decreases with larger quantities. @@ -108,6 +116,9 @@ const SupplyPricing = { * @returns {number} The total cost */ export function calculate_supply_cost(item, quantity) { + console.assert(item, 'item must be defined'); + console.assert(SupplyTypes.includes(item), 'item must be a valid supply type'); + console.assert(typeof quantity === 'number', 'quantity must be a number'); if (quantity <= 0) return 0; const tiers = SupplyPricing[item]; if (!tiers) return 0; @@ -121,6 +132,8 @@ export function calculate_supply_cost(item, quantity) { * @returns {Array.<{min: number, max: number, price: number}>} The pricing tiers */ export function get_supply_pricing(item) { + console.assert(item, 'item must be defined'); + console.assert(SupplyTypes.includes(item), 'item must be a valid supply type'); return SupplyPricing[item] || []; } @@ -151,10 +164,6 @@ const WeatherChance = [ * @property {number} cups */ -/** - * @typedef {Object.} PriceTable - */ - /** * @typedef {Object} GameState * @property {number} player_money @@ -162,15 +171,10 @@ const WeatherChance = [ * @property {Supplies} supplies * @property {string} weather * @property {number} price_per_cup - * @property {number} days - * @property {{ - * lemons: PriceTable, - * sugar: PriceTable, - * ice: PriceTable, - * cups: PriceTable - * }} supplies_prices * @property {number} cups_sold * @property {number} cost_per_cup + * @property {number} current_day + * @property {number} total_earnings */ /** @@ -181,7 +185,7 @@ const WeatherChance = [ */ export function init_game() { return { - player_money: 2.00, + player_money: STARTING_MONEY, recipe: { lemons: 0, sugar: 0, @@ -194,32 +198,11 @@ export function init_game() { cups: 0 }, weather: Weather.SUNNY, - price_per_cup: 1.00, - days: Days[7], - supplies_prices: { - lemons: { - 12: 4.80, - 24: 7.20, - 48: 9.60 - }, - sugar: { - 12: 4.80, - 20: 7.00, - 50: 15.00 - }, - ice: { - 50: 1.00, - 200: 3.00, - 500: 5.00 - }, - cups: { - 75: 1.00, - 225: 2.35, - 400: 3.75 - } - }, + price_per_cup: STARTING_PRICE, cups_sold: 0, - cost_per_cup: 0 + cost_per_cup: 0, + current_day: 1, + total_earnings: 0 } } @@ -235,9 +218,12 @@ export function init_game() { */ export function set_recipe(game_state, lemons, sugar, ice) { console.assert(game_state, 'game_state must be defined'); - console.assert(typeof lemons == 'number', 'lemons must be a number'); - console.assert(typeof sugar == 'number', 'sugar must be a number'); - console.assert(typeof ice == 'number', 'ice must be a number'); + console.assert(typeof lemons === 'number', 'lemons must be a number'); + console.assert(typeof sugar === 'number', 'sugar must be a number'); + console.assert(typeof ice === 'number', 'ice must be a number'); + console.assert(lemons >= 0, 'lemons must be non-negative'); + console.assert(sugar >= 0, 'sugar must be non-negative'); + console.assert(ice >= 0, 'ice must be non-negative'); return { ...game_state, @@ -255,11 +241,15 @@ export function set_recipe(game_state, lemons, sugar, ice) { * Uses WeatherChance to select a weather type. * * @param {GameState} game_state - The current state of the game. + * @param {number} [random=Math.random()] - Random value between 0-1 for deterministic testing. * @returns {GameState} A new game state with the updated weather. */ -export function set_weather(game_state) { +export function set_weather(game_state, random = Math.random()) { + console.assert(game_state, 'game_state must be defined'); + console.assert(typeof random === 'number', 'random must be a number'); + const totalWeight = WeatherChance.reduce((sum, w) => sum + w.weight, 0); - let roll = Math.random() * totalWeight; + let roll = random * totalWeight; for (const w of WeatherChance) { if (roll < w.weight) { @@ -271,24 +261,11 @@ export function set_weather(game_state) { roll -= w.weight } -} - -/** - * Set the number of days the game will play through. - * Valid options are 7 (week), 14 (two weeks), or 30 (month). - * - * @param {GameState} game_state - The current state of the game. - * @param {number} days - The number of days (7, 14, or 30). - * @returns {GameState} A new game state with the updated number of days. - */ -export function set_days(game_state, days) { - console.assert(game_state, 'game_state must be defined'); - console.assert(typeof days === 'number', 'days must be a number'); - console.assert(Object.values(Days).includes(days), 'invalid days value'); + // Fallback (should never reach here if weights are correct) return { ...game_state, - days: days + weather: Weather.SUNNY } } @@ -301,11 +278,12 @@ export function set_days(game_state, days) { * @returns {GameState} A new game state with the updated cost per cup. */ export function set_price_per_cup(game_state, cost) { - console.assert(typeof cost === 'number', 'cost must be a number'); console.assert(game_state, 'game_state must be defined'); + console.assert(typeof cost === 'number', 'cost must be a number'); + console.assert(cost >= 0, 'cost must be non-negative'); return { ...game_state, - price_per_cup: Math.round(parseFloat(cost) * 100) / 100 + price_per_cup: Math.round(cost * 100) / 100 } } @@ -317,21 +295,26 @@ export function set_price_per_cup(game_state, cost) { * @param {number} cups_in_supplies - The number of cups available to sell. * @param {string} weather - The current weather condition (from Weather enum). * @param {number} [tasteScore=1] - The taste quality score (0.5 to 1.2). + * @param {number} [random=Math.random()] - Random value between 0-1 for deterministic testing. * @returns {number} The number of cups sold (capped by available supplies). */ -export function calculate_cups_sold(price_per_cup, cups_in_supplies, weather, tasteScore = 1) { - const base_demand = 30; +export function calculate_cups_sold(price_per_cup, cups_in_supplies, weather, tasteScore = 1, random = Math.random()) { + console.assert(typeof price_per_cup === 'number', 'price must be a number'); + console.assert(typeof cups_in_supplies === 'number', 'cups in supplies must be a number'); + console.assert(Object.values(Weather).includes(weather), 'invalid weather value'); + console.assert(typeof tasteScore === 'number', 'taste score must be a number'); + console.assert(typeof random === 'number', 'random must be a number'); + const weather_factor = WeatherFactor[weather] || 1.0; const ideal_price = IdealPrice[weather] || 0.35 - const sensitivity = 1.5; - let price_effect = 1 - (price_per_cup - ideal_price) * sensitivity; + let price_effect = 1 - (price_per_cup - ideal_price) * PRICE_SENSITIVITY; if (price_effect < 0) { price_effect = 0; } - let demand = base_demand * weather_factor * price_effect * tasteScore; - demand *= 0.9 + Math.random() * 0.2; + let demand = BASE_DEMAND * weather_factor * price_effect * tasteScore; + demand *= (1 - DEMAND_VARIANCE / 2) + random * DEMAND_VARIANCE; const cupsSold = Math.min(Math.floor(demand), cups_in_supplies); @@ -349,19 +332,24 @@ export function calculate_cups_sold(price_per_cup, cups_in_supplies, weather, ta * @returns {number} A taste score between 0.5 and 1.2. */ export function calculate_taste_score(lemons_per_cup, sugar_per_cup, ideal_lemons = 1, ideal_sugar = 1) { + console.assert(typeof lemons_per_cup === 'number', 'lemons_per_cup must be a number'); + console.assert(typeof sugar_per_cup === 'number', 'sugar_per_cup must be a number'); + console.assert(typeof ideal_lemons === 'number', 'ideal_lemons must be a number'); + console.assert(typeof ideal_sugar === 'number', 'ideal_sugar must be a number'); + const lemon_diff = Math.abs(lemons_per_cup - ideal_lemons); const sugar_diff = Math.abs(sugar_per_cup - ideal_sugar); let score = 1.0; if (lemon_diff === 0 && sugar_diff === 0) { - score += 0.2; // perfect recipe bonus + score += PERFECT_RECIPE_BONUS; } else { - score -= (lemon_diff * 0.3 + sugar_diff * 0.2); + score -= (lemon_diff * TASTE_PENALTY_LEMON + sugar_diff * TASTE_PENALTY_SUGAR); } - if (score < 0.5) score = 0.5; - if (score > 1.2) score = 1.2; + if (score < TASTE_SCORE_MIN) score = TASTE_SCORE_MIN; + if (score > TASTE_SCORE_MAX) score = TASTE_SCORE_MAX; return score; } @@ -389,10 +377,18 @@ export function make_lemonade(game_state) { ideal.sugar ); + // Lemonade requires lemons - can't sell without them in the recipe + if (recipe.lemons === 0) { + return { + ...game_state, + cups_sold: 0 + }; + } + const cups_available = Math.min( - recipe.lemons > 0 ? game_state.supplies.lemons / recipe.lemons : 0, - recipe.sugar > 0 ? game_state.supplies.sugar / recipe.sugar : 0, - recipe.ice > 0 ? game_state.supplies.ice / recipe.ice : 0, + recipe.lemons > 0 ? game_state.supplies.lemons / recipe.lemons : Infinity, + recipe.sugar > 0 ? game_state.supplies.sugar / recipe.sugar : Infinity, + recipe.ice > 0 ? game_state.supplies.ice / recipe.ice : Infinity, game_state.supplies.cups ); @@ -411,7 +407,8 @@ export function make_lemonade(game_state) { ...game_state, player_money: game_state.player_money + profit, supplies: remaining_supplies, - cups_sold + cups_sold, + total_earnings: game_state.total_earnings + profit } } @@ -422,6 +419,12 @@ export function make_lemonade(game_state) { * @returns {number} The cost to make one cup */ export function calculate_cost_per_cup(game_state, recipe) { + console.assert(game_state, 'game_state must be defined'); + console.assert(recipe, 'recipe must be defined'); + console.assert(typeof recipe.lemons === 'number', 'recipe.lemons must be a number'); + console.assert(typeof recipe.sugar === 'number', 'recipe.sugar must be a number'); + console.assert(typeof recipe.ice === 'number', 'recipe.ice must be a number'); + const basePrices = { lemons: SupplyPricing.lemons[0].price, sugar: SupplyPricing.sugar[0].price, @@ -437,6 +440,6 @@ export function calculate_cost_per_cup(game_state, recipe) { return { ...game_state, - cost_per_cup: Math.round(cost * 100) / 100 + cost_per_cup: Math.round(cost * 100) / 100, } } diff --git a/game.test.js b/game.test.js index c846a23..dc20f7b 100644 --- a/game.test.js +++ b/game.test.js @@ -1,23 +1,17 @@ import { Weather, - Days, init_game, set_recipe, set_weather, - set_days, set_price_per_cup, calculate_cups_sold, calculate_taste_score, - make_lemonade + make_lemonade, + calculate_supply_cost, + get_supply_pricing, + calculate_cost_per_cup } from './game.js'; -// Helpers -const withMockedRandom = (value, fn) => { - const orig = Math.random; - Math.random = () => value; - try { fn(); } finally { Math.random = orig; } -}; - describe('enums', () => { test('Weather is frozen and has expected values', () => { expect(Object.isFrozen(Weather)).toBe(true); @@ -29,30 +23,19 @@ describe('enums', () => { }); }); - test('Days is frozen and contains allowed values', () => { - expect(Object.isFrozen(Days)).toBe(true); - expect(Days[7]).toBe(7); - expect(Days[14]).toBe(14); - expect(Days[30]).toBe(30); - expect(Object.values(Days).sort((a, b) => a - b)).toEqual([7, 14, 30]); - }); }); describe('init_game', () => { test('returns correct default state shape and values', () => { const s = init_game(); - expect(s.player_money).toBe(0); + expect(s.player_money).toBe(2.00); expect(s.recipe).toEqual({ lemons: 0, sugar: 0, ice: 0 }); expect(s.supplies).toEqual({ lemons: 0, sugar: 0, ice: 0, cups: 0 }); expect(s.weather).toBe(Weather.SUNNY); - expect(s.days).toBe(7); expect(s.price_per_cup).toBe(1.0); expect(s.cups_sold).toBe(0); - - expect(s.supplies_prices.lemons).toHaveProperty('12', 4.80); - expect(s.supplies_prices.sugar).toHaveProperty('50', 15.00); - expect(s.supplies_prices.ice).toHaveProperty('500', 5.00); - expect(s.supplies_prices.cups).toHaveProperty('400', 3.75); + expect(s.current_day).toBe(1); + expect(s.total_earnings).toBe(0); }); test('does not share nested object references across calls', () => { @@ -61,7 +44,6 @@ describe('init_game', () => { expect(a).not.toBe(b); expect(a.recipe).not.toBe(b.recipe); expect(a.supplies).not.toBe(b.supplies); - expect(a.supplies_prices).not.toBe(b.supplies_prices); }); }); @@ -77,55 +59,34 @@ describe('set_recipe', () => { }); }); -describe('set_days', () => { - test('sets valid days and returns new state', () => { - const s1 = init_game(); - const s2 = set_days(s1, 14); - expect(s2).not.toBe(s1); - expect(s2.days).toBe(14); - expect(s1.days).toBe(7); - }); -}); - describe('set_weather', () => { test('selects CLOUDY for roll < 40%', () => { const s = init_game(); - withMockedRandom(0.00, () => { - expect(set_weather(s).weather).toBe(Weather.CLOUDY); - }); - withMockedRandom(0.39, () => { - expect(set_weather(s).weather).toBe(Weather.CLOUDY); - }); + expect(set_weather(s, 0.00).weather).toBe(Weather.CLOUDY); + expect(set_weather(s, 0.39).weather).toBe(Weather.CLOUDY); }); test('selects SUNNY for 40%-75%', () => { const s = init_game(); - withMockedRandom(0.40, () => { - expect(set_weather(s).weather).toBe(Weather.SUNNY); - }); - withMockedRandom(0.74, () => { - expect(set_weather(s).weather).toBe(Weather.SUNNY); - }); + expect(set_weather(s, 0.40).weather).toBe(Weather.SUNNY); + expect(set_weather(s, 0.74).weather).toBe(Weather.SUNNY); }); test('selects HOT for 75%-90%', () => { const s = init_game(); - withMockedRandom(0.75, () => { - expect(set_weather(s).weather).toBe(Weather.HOT); - }); - withMockedRandom(0.89, () => { - expect(set_weather(s).weather).toBe(Weather.HOT); - }); + expect(set_weather(s, 0.75).weather).toBe(Weather.HOT); + expect(set_weather(s, 0.89).weather).toBe(Weather.HOT); }); test('selects COLD for 90%-100%', () => { const s = init_game(); - withMockedRandom(0.90, () => { - expect(set_weather(s).weather).toBe(Weather.COLD); - }); - withMockedRandom(0.9999, () => { - expect(set_weather(s).weather).toBe(Weather.COLD); - }); + expect(set_weather(s, 0.90).weather).toBe(Weather.COLD); + expect(set_weather(s, 0.9999).weather).toBe(Weather.COLD); + }); + + test('returns fallback weather if loop completes', () => { + const s = init_game(); + expect(set_weather(s, 1.0).weather).toBe(Weather.SUNNY); }); }); @@ -135,9 +96,7 @@ describe('calculate_taste_score', () => { }); test('penalizes lemon and sugar diffs correctly', () => { - // lemons off by 1 => -0.3 expect(calculate_taste_score(2, 1, 1, 1)).toBeCloseTo(0.7); - // sugar off by 2 => -0.4 expect(calculate_taste_score(1, 3, 1, 1)).toBeCloseTo(0.6); }); @@ -149,96 +108,107 @@ describe('calculate_taste_score', () => { describe('calculate_cups_sold', () => { test('caps at available cups and never negative', () => { - withMockedRandom(0, () => { - const sold = calculate_cups_sold(0.35, 5, Weather.SUNNY, 1); - expect(sold).toBeGreaterThanOrEqual(0); - expect(Number.isInteger(sold)).toBe(true); - expect(sold).toBeLessThanOrEqual(5); - }); + const sold = calculate_cups_sold(0.35, 5, Weather.SUNNY, 1, 0); + expect(sold).toBeGreaterThanOrEqual(0); + expect(Number.isInteger(sold)).toBe(true); + expect(sold).toBeLessThanOrEqual(5); }); test('returns 0 when price_effect would go negative', () => { - withMockedRandom(0, () => { - expect(calculate_cups_sold(999, 100, Weather.SUNNY, 1)).toBe(0); - }); + expect(calculate_cups_sold(999, 100, Weather.SUNNY, 1, 0)).toBe(0); }); test('weather factor ordering (HOT >= SUNNY >= CLOUDY >= COLD) with same random roll', () => { - withMockedRandom(0, () => { - const cups = 10_000; - const taste = 1; - const hot = calculate_cups_sold(0.50, cups, Weather.HOT, taste); - const sunny = calculate_cups_sold(0.35, cups, Weather.SUNNY, taste); - const cloudy = calculate_cups_sold(0.30, cups, Weather.CLOUDY, taste); - const cold = calculate_cups_sold(0.20, cups, Weather.COLD, taste); - expect(hot).toBeGreaterThanOrEqual(sunny); - expect(sunny).toBeGreaterThanOrEqual(cloudy); - expect(cloudy).toBeGreaterThanOrEqual(cold); - }); + const cups = 10_000; + const taste = 1; + const random = 0.5; + const hot = calculate_cups_sold(0.50, cups, Weather.HOT, taste, random); + const sunny = calculate_cups_sold(0.35, cups, Weather.SUNNY, taste, random); + const cloudy = calculate_cups_sold(0.30, cups, Weather.CLOUDY, taste, random); + const cold = calculate_cups_sold(0.20, cups, Weather.COLD, taste, random); + expect(hot).toBeGreaterThanOrEqual(sunny); + expect(sunny).toBeGreaterThanOrEqual(cloudy); + expect(cloudy).toBeGreaterThanOrEqual(cold); + }); + + test('same inputs produce same output (pure function)', () => { + const a = calculate_cups_sold(0.35, 100, Weather.SUNNY, 1, 0.5); + const b = calculate_cups_sold(0.35, 100, Weather.SUNNY, 1, 0.5); + expect(a).toBe(b); }); }); describe('make_lemonade', () => { - test('updates money and supplies based on cups_sold (stub randomness via Math.random)', () => { + test('updates money and supplies based on cups_sold', () => { const s = { ...init_game(), weather: Weather.SUNNY, price_per_cup: 1.00, supplies: { lemons: 100, sugar: 100, ice: 300, cups: 100 }, recipe: { lemons: 1, sugar: 1, ice: 3 }, - player_money: 10 + player_money: 10, + total_earnings: 0 }; - withMockedRandom(0, () => { - const next = make_lemonade(s); + const next = make_lemonade(s); - expect(next).not.toBe(s); - expect(next.cups_sold).toBeGreaterThanOrEqual(0); - expect(next.cups_sold).toBeLessThanOrEqual(100); + expect(next).not.toBe(s); + expect(next.cups_sold).toBeGreaterThanOrEqual(0); + expect(next.cups_sold).toBeLessThanOrEqual(100); - // Inventory decreases consistently - expect(next.supplies.cups).toBe(100 - next.cups_sold); - expect(next.supplies.lemons).toBe(100 - 1 * next.cups_sold); - expect(next.supplies.sugar).toBe(100 - 1 * next.cups_sold); - expect(next.supplies.ice).toBe(300 - 3 * next.cups_sold); + expect(next.supplies.cups).toBe(100 - next.cups_sold); + expect(next.supplies.lemons).toBe(100 - 1 * next.cups_sold); + expect(next.supplies.sugar).toBe(100 - 1 * next.cups_sold); + expect(next.supplies.ice).toBe(300 - 3 * next.cups_sold); - // Profit - const expectedMoney = 10 + 1.00 * next.cups_sold; - expect(next.player_money).toBeCloseTo(expectedMoney); - }); + const expectedMoney = 10 + 1.00 * next.cups_sold; + expect(next.player_money).toBeCloseTo(expectedMoney); + + expect(next.total_earnings).toBe(1.00 * next.cups_sold); }); - test('can produce negative profit when price < price_per_cup', () => { - const s = { + test('accumulates total_earnings across multiple days', () => { + let state = { ...init_game(), weather: Weather.SUNNY, - price_per_cup: 0.25, - supplies: { lemons: 100, sugar: 100, ice: 300, cups: 100 }, + price_per_cup: 0.35, + supplies: { lemons: 200, sugar: 200, ice: 600, cups: 200 }, recipe: { lemons: 1, sugar: 1, ice: 3 }, - player_money: 10 + player_money: 0, + total_earnings: 5.00 }; - withMockedRandom(0, () => { - const next = make_lemonade(s); - expect(next.player_money).toBe(19.25); - }); + const next = make_lemonade(state); + const profit = 0.35 * next.cups_sold; + expect(next.total_earnings).toBeCloseTo(5.00 + profit); }); - test('recipe with zero ingredient should not allow sales (expected 0 cups sold)', () => { + test('recipe with zero lemons should not allow sales', () => { const s = { ...init_game(), weather: Weather.SUNNY, - price_per_cup: 1.00, price_per_cup: 0.10, supplies: { lemons: 0, sugar: 100, ice: 300, cups: 100 }, recipe: { lemons: 0, sugar: 1, ice: 3 }, player_money: 0 }; - withMockedRandom(0, () => { - const next = make_lemonade(s); - expect(next.cups_sold).toBe(0); - }); + const next = make_lemonade(s); + expect(next.cups_sold).toBe(0); + }); + + test('limited by available supplies', () => { + const s = { + ...init_game(), + weather: Weather.SUNNY, + price_per_cup: 0.35, + supplies: { lemons: 5, sugar: 100, ice: 300, cups: 100 }, + recipe: { lemons: 1, sugar: 1, ice: 3 }, + player_money: 0 + }; + + const next = make_lemonade(s); + expect(next.cups_sold).toBeLessThanOrEqual(5); }); }); @@ -250,7 +220,7 @@ describe('set_price_per_cup', () => { expect(next).not.toBe(state); expect(next.price_per_cup).toBe(1.25); - expect(state.price_per_cup).toBe(1.00); // original unchanged + expect(state.price_per_cup).toBe(1.00); }); test('rounds to two decimal places', () => { @@ -272,18 +242,96 @@ describe('set_price_per_cup', () => { const next = set_price_per_cup(state, 0.75); - expect(next.days).toBe(state.days); expect(next.weather).toBe(state.weather); - expect(next.supplies).toBe(state.supplies); // same reference, unchanged + expect(next.supplies).toBe(state.supplies); expect(next.recipe).toBe(state.recipe); }); test('rounding edge cases follow JS rounding behavior', () => { const state = init_game(); - // JS floating-point quirk: this rounds DOWN expect(set_price_per_cup(state, 1.005).price_per_cup).toBe(1.00); expect(set_price_per_cup(state, 1.006).price_per_cup).toBe(1.01); }); }); + +describe('calculate_supply_cost', () => { + test('returns 0 for quantity <= 0', () => { + expect(calculate_supply_cost('lemons', 0)).toBe(0); + expect(calculate_supply_cost('lemons', -5)).toBe(0); + }); + + test('uses tier 1 pricing for lemons 1-50', () => { + expect(calculate_supply_cost('lemons', 1)).toBe(0.02); + expect(calculate_supply_cost('lemons', 50)).toBe(1.00); + }); + + test('uses tier 2 pricing for lemons 51-100', () => { + expect(calculate_supply_cost('lemons', 51)).toBe(0.92); + expect(calculate_supply_cost('lemons', 100)).toBe(1.80); + }); + + test('uses tier 3 pricing for lemons 101+', () => { + expect(calculate_supply_cost('lemons', 101)).toBe(1.52); + expect(calculate_supply_cost('lemons', 200)).toBe(3.00); + }); + + test('calculates sugar pricing correctly', () => { + expect(calculate_supply_cost('sugar', 50)).toBe(0.50); + expect(calculate_supply_cost('sugar', 100)).toBe(0.90); + }); + + test('calculates ice pricing correctly', () => { + expect(calculate_supply_cost('ice', 100)).toBe(1.00); + expect(calculate_supply_cost('ice', 300)).toBe(2.70); + }); + + test('calculates cups pricing correctly', () => { + expect(calculate_supply_cost('cups', 100)).toBe(1.00); + expect(calculate_supply_cost('cups', 200)).toBe(1.80); + }); +}); + +describe('get_supply_pricing', () => { + test('returns pricing tiers for lemons', () => { + const tiers = get_supply_pricing('lemons'); + expect(tiers).toHaveLength(3); + expect(tiers[0]).toEqual({ min: 1, max: 50, price: 0.02 }); + }); + + test('returns pricing tiers for cups', () => { + const tiers = get_supply_pricing('cups'); + expect(tiers).toHaveLength(2); + expect(tiers[1].max).toBe(Infinity); + }); + + test('returns empty array for unknown item (triggers assertion warning)', () => { + expect(get_supply_pricing('bananas')).toEqual([]); + }); +}); + +describe('calculate_cost_per_cup', () => { + test('calculates cost from recipe ingredients', () => { + const state = init_game(); + const recipe = { lemons: 1, sugar: 1, ice: 3 }; + const result = calculate_cost_per_cup(state, recipe); + // 1*0.02 + 1*0.01 + 3*0.01 + 0.01 (cup) = 0.07 + expect(result.cost_per_cup).toBe(0.07); + }); + + test('returns 0.01 for empty recipe (just cup cost)', () => { + const state = init_game(); + const recipe = { lemons: 0, sugar: 0, ice: 0 }; + const result = calculate_cost_per_cup(state, recipe); + expect(result.cost_per_cup).toBe(0.01); + }); + + test('does not mutate original state', () => { + const state = init_game(); + const recipe = { lemons: 2, sugar: 2, ice: 4 }; + const result = calculate_cost_per_cup(state, recipe); + expect(result).not.toBe(state); + expect(state.cost_per_cup).toBe(0); + }); +}); diff --git a/index.html b/index.html index a4be8fb..01cc481 100644 --- a/index.html +++ b/index.html @@ -43,13 +43,6 @@ -
-

Total Earnings

- -

$0.00

-
- -

Current Price

@@ -58,11 +51,24 @@
+
+

Total Earnings

+ +

$0.00

+
+

Weather

Weather

sunny

+ +
+

Day 1

+

Cups sold: 0

+

Earnings: $0.00

+ +
diff --git a/index.js b/index.js index 673fbcf..1136ae8 100644 --- a/index.js +++ b/index.js @@ -3,7 +3,7 @@ * Initializes game state, wires up UI events, and coordinates modules. */ -import { init_game, set_price_per_cup, calculate_supply_cost, calculate_cost_per_cup } from './game.js'; +import { init_game, set_price_per_cup, calculate_supply_cost, calculate_cost_per_cup, make_lemonade, set_weather } from './game.js'; import { sprites, cups, render, whenSpritesReady } from './canvasController.js'; import { createReactiveState, updateBindings } from './binding.js'; @@ -21,19 +21,38 @@ function updateWeatherIcon() { } } -// Initial weather icon update updateWeatherIcon(); +let isAnimating = false; + +function animateCupFills(count, onComplete) { + let filled = 0; + sprites.maker.frameIndex = 1; // Maker active + render(); + + const interval = setInterval(() => { + if (filled < count && filled < cups.length) { + cups[filled].fill(); + render(); + filled++; + } else { + clearInterval(interval); + sprites.maker.frameIndex = 0; // Maker idle + render(); + onComplete(); + } + }, 400); +} + +function resetCups() { + cups.forEach(cup => cup.empty()); + render(); +} + // Wait for all sprites to load, then render once whenSpritesReady(() => { render(); - - // Example: fill the first cup after 1 second - setTimeout(() => { - cups[0].fill(); - sprites.maker.frameIndex = 1; - render(); - }, 1000); + resetCups(); // Start with empty cups }); // UI Elements @@ -51,6 +70,8 @@ const priceSaveBtn = document.querySelector('.price_change_save_btn'); const changeRecipeBtn = document.querySelector('.change_recipe_btn'); const recipeModal = document.querySelector('.recipe_modal'); const recipeModalClose = document.querySelector('.recipe_modal_close'); + +const startDayBtn = document.querySelector('.start_day_btn'); const recipeSaveBtn = document.querySelector('.recipe_save_btn'); // Shopping modal - quantity inputs and dynamic pricing @@ -79,12 +100,12 @@ shopBuyBtns.forEach(btn => { const input = document.querySelector(`.shop_qty_input[data-item="${item}"]`); const qty = parseInt(input.value) || 0; const cost = calculate_supply_cost(item, qty); - + if (cost > gameState.player_money) { alert("Not enough money!"); return; } - + setState({ player_money: gameState.player_money - cost, supplies: { @@ -140,13 +161,13 @@ function updateRecipeCost() { const lemons = parseInt(document.querySelector('.recipe_input[data-recipe="lemons"]').value) || 0; const sugar = parseInt(document.querySelector('.recipe_input[data-recipe="sugar"]').value) || 0; const ice = parseInt(document.querySelector('.recipe_input[data-recipe="ice"]').value) || 0; - + // Update breakdown rows document.querySelector('.recipe_cost_item[data-cost="lemons"]').textContent = '$' + (lemons * basePrices.lemons).toFixed(2); document.querySelector('.recipe_cost_item[data-cost="sugar"]').textContent = '$' + (sugar * basePrices.sugar).toFixed(2); document.querySelector('.recipe_cost_item[data-cost="ice"]').textContent = '$' + (ice * basePrices.ice).toFixed(2); document.querySelector('.recipe_cost_item[data-cost="cup"]').textContent = '$' + basePrices.cup.toFixed(2); - + // Update total const result = calculate_cost_per_cup(gameState, { lemons, sugar, ice }); recipeCostValue.textContent = '$' + result.cost_per_cup.toFixed(2); @@ -175,7 +196,7 @@ if (changeRecipeBtn) { const lemons = parseInt(document.querySelector('.recipe_input[data-recipe="lemons"]').value) || 0; const sugar = parseInt(document.querySelector('.recipe_input[data-recipe="sugar"]').value) || 0; const ice = parseInt(document.querySelector('.recipe_input[data-recipe="ice"]').value) || 0; - + const result = calculate_cost_per_cup(gameState, { lemons, sugar, ice }); setState({ recipe: { lemons, sugar, ice }, @@ -185,6 +206,11 @@ if (changeRecipeBtn) { }); } +// Start Day button +if (startDayBtn) { + startDayBtn.addEventListener('click', startDay); +} + // Export for debugging in console window.gameState = gameState; window.sprites = sprites; @@ -196,3 +222,37 @@ function setState(newState) { updateWeatherIcon(); } } + +function startDay() { + if (gameState.supplies.cups <= 0) { + alert('You need cups to sell lemonade!'); + return; + } + + const { recipe } = gameState; + if (recipe.lemons <= 0 && recipe.sugar <= 0 && recipe.ice <= 0) { + alert('Set a recipe with at least one ingredient!'); + return; + } + + if (isAnimating) return; // Prevent double-click + isAnimating = true; + startDayBtn.disabled = true; + + resetCups(); + + const result = make_lemonade(gameState); + const cupsSold = result.cups_sold; + + // Animate cup fills + animateCupFills(cupsSold, () => { + setState(result); + + // Randomize weather for next day + const newWeatherState = set_weather(gameState); + setState({ weather: newWeatherState.weather }); + + isAnimating = false; + startDayBtn.disabled = false; + }); +} diff --git a/style.css b/style.css index 55880e1..e28fffe 100644 --- a/style.css +++ b/style.css @@ -605,6 +605,50 @@ canvas { box-shadow: 0 2px 0 #3f7a33; } +/* Day Section */ +.day_section { + text-align: center; +} + +.day_section .section_title { + font-size: 28px; +} + +.start_day_btn { + font-family: 'Fredoka', sans-serif; + font-size: 18px; + font-weight: 600; + background: linear-gradient(180deg, #8fd16a 0%, #6bb848 100%); + color: #fff; + border: 3px solid #3f7a33; + border-radius: 12px; + padding: 14px 32px; + cursor: pointer; + box-shadow: 0 4px 0 #2d5a24; + transition: all 0.1s ease; + margin: 12px auto 0; + display: block; +} + +.start_day_btn:hover { + background: linear-gradient(180deg, #a5e07a 0%, #8fd16a 100%); + transform: translateY(-2px); + box-shadow: 0 6px 0 #2d5a24; +} + +.start_day_btn:active { + transform: translateY(2px); + box-shadow: 0 2px 0 #2d5a24; +} + +.start_day_btn:disabled { + background: linear-gradient(180deg, #ccc 0%, #aaa 100%); + border-color: #888; + box-shadow: 0 4px 0 #666; + cursor: not-allowed; + opacity: 0.7; +} + /* Recipe Modal */ .recipe_modal { display: none;