Browse Source

refactor: purify functions, constant-ify, add more testing

master
Stephanie Gredell 3 weeks ago
parent
commit
8e39465db0
  1. 169
      game.js
  2. 280
      game.test.js
  3. 20
      index.html
  4. 78
      index.js
  5. 44
      style.css

169
game.js

@ -11,16 +11,18 @@ export const Weather = Object.freeze({ @@ -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 = { @@ -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 = { @@ -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) { @@ -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 = [ @@ -151,10 +164,6 @@ const WeatherChance = [
* @property {number} cups
*/
/**
* @typedef {Object.<number, number>} PriceTable
*/
/**
* @typedef {Object} GameState
* @property {number} player_money
@ -162,15 +171,10 @@ const WeatherChance = [ @@ -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 = [ @@ -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() { @@ -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() { @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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 @@ -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) { @@ -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) { @@ -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) { @@ -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) { @@ -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,
}
}

280
game.test.js

@ -1,23 +1,17 @@ @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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);
});
});

20
index.html

@ -43,13 +43,6 @@ @@ -43,13 +43,6 @@
<button class="change_recipe_btn">Change Recipe</button>
</section>
<section class="game_section">
<h2 class="section_title">Total Earnings</h2>
<p>$0.00</p>
</section>
<section class="game_section">
<h2 class="section_title">Current Price</h2>
@ -58,11 +51,24 @@ @@ -58,11 +51,24 @@
<button class="change_price_button">Change Price</button>
</section>
<section class="game_section">
<h2 class="section_title">Total Earnings</h2>
<p data-bind="total_earnings" data-format="currency">$0.00</p>
</section>
<section class="game_section weather_section">
<h2 class="section_title">Weather</h2>
<img class="weather_icon" src="sunny.png" alt="Weather">
<p class="weather_label" data-bind="weather">sunny</p>
</section>
<section class="game_section day_section">
<h2 class="section_title">Day <span data-bind="current_day">1</span></h2>
<p class="section_hint">Cups sold: <span data-bind="cups_sold">0</span></p>
<p class="section_hint">Earnings: <span data-bind="total_earnings" data-format="currency">$0.00</span></p>
<button class="start_day_btn">Start Day</button>
</section>
</div>
<div class="shopping_modal">

78
index.js

@ -3,7 +3,7 @@ @@ -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() { @@ -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'); @@ -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
@ -185,6 +206,11 @@ if (changeRecipeBtn) { @@ -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) { @@ -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;
});
}

44
style.css

@ -605,6 +605,50 @@ canvas { @@ -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;

Loading…
Cancel
Save