diff --git a/README.md b/README.md index ec569a3..befea7a 100644 --- a/README.md +++ b/README.md @@ -133,5 +133,4 @@ A wave-based runner where the player leads a flock of soldiers through a series ## 10. Analytics & Telemetry -* **Abstract `Telemetry` interface** with methods like `trackEvent(name, payload)`. -* Default implementation logs to console; ready for remote analytics plug-in in future. \ No newline at end of file +* **Abstract `Telemetry` interface** with methods like `trackEvent(name, payload)`.* Default implementation logs to console; ready for remote analytics plug-in in future.\n---\n## Running the Game\nOpen index.html in a modern browser supporting ES modules.\n\n## Running Tests\nRun `npm install` and then `npm test`.\n diff --git a/index.html b/index.html index 30404ce..b5514b9 100644 --- a/index.html +++ b/index.html @@ -1 +1,29 @@ -TODO \ No newline at end of file + + + + + Math Marauders + + + +

Math Marauders

+
+ Soldiers: 0 | Enemy: 0 +
+
+ + +
+ + + + + diff --git a/src/game/BattleSystem.js b/src/game/BattleSystem.js new file mode 100644 index 0000000..a342d29 --- /dev/null +++ b/src/game/BattleSystem.js @@ -0,0 +1,18 @@ +import WaveGenerator from './WaveGenerator.js'; + +/** + * Resolves skirmishes and the final showdown. + */ +export default class BattleSystem { + /** + * Resolve a skirmish after a gate. + * @param {number} playerSize - current player army size + * @param {number} optimalSize - optimal army size at this point + * @returns {{enemy:number, result:number}} + */ + resolveSkirmish(playerSize, optimalSize) { + const enemy = Math.floor(optimalSize * WaveGenerator.SKIRMISH_FACTOR); + const result = Math.max(0, playerSize - enemy); + return { enemy, result }; + } +} diff --git a/src/game/FlockSystem.js b/src/game/FlockSystem.js new file mode 100644 index 0000000..3998e3f --- /dev/null +++ b/src/game/FlockSystem.js @@ -0,0 +1,20 @@ +/** + * Manages the player's army size. + */ +export default class FlockSystem { + constructor(initialSize = 10) { + this.size = initialSize; + } + + /** + * Increase or decrease army size. + * @param {number} delta + */ + add(delta) { + this.size = Math.max(0, this.size + delta); + } + + set(value) { + this.size = Math.max(0, value); + } +} diff --git a/src/game/GameController.js b/src/game/GameController.js new file mode 100644 index 0000000..77fda11 --- /dev/null +++ b/src/game/GameController.js @@ -0,0 +1,94 @@ +import WaveGenerator from './WaveGenerator.js'; +import GateSystem from './GateSystem.js'; +import FlockSystem from './FlockSystem.js'; +import BattleSystem from './BattleSystem.js'; + +/** + * Main game orchestrator. + */ +export default class GameController { + constructor(ui, persistence, telemetry) { + this.ui = ui; + this.persistence = persistence; + this.telemetry = telemetry; + this.waveGen = new WaveGenerator(); + this.battle = new BattleSystem(); + this.currentWave = 1; + this.ui.onStart(() => this.startWave()); + this.ui.onLeft(() => this.handleChoice(0)); + this.ui.onRight(() => this.handleChoice(1)); + this.ui.onNext(() => this.nextWave()); + this.ui.onRetry(() => this.retryWave()); + } + + startWave() { + this.ui.hidePopup(); + this.player = new FlockSystem(10); + const data = this.waveGen.generate(this.currentWave); + this.gateSystem = new GateSystem(data.gates); + this.optimalSize = this.player.size; + this.calculateOptimalPath(); + this.ui.updateCounts(this.player.size); + this.showCurrentGate(); + this.telemetry.trackEvent('wave_start', { wave: this.currentWave }); + } + + calculateOptimalPath() { + let size = this.player.size; + for (const gate of this.gateSystem.gates) { + const resA = gate[0].fn(size); + const resB = gate[1].fn(size); + size = Math.max(resA, resB); + size = Math.max(0, Math.floor(size)); + const enemy = Math.floor(size * WaveGenerator.SKIRMISH_FACTOR); + size = Math.max(0, size - enemy); + } + this.optimalSize = size; + } + + showCurrentGate() { + if (!this.gateSystem.hasMore()) { + return this.endWave(); + } + const gate = this.gateSystem.currentGate(); + this.ui.updateGateLabels(gate[0].label, gate[1].label); + } + + handleChoice(index) { + if (!this.gateSystem.hasMore()) return; + const optimal = this.gateSystem.optimalOutcome(this.player.size); + const newSize = this.gateSystem.applyChoice(index, this.player.size); + this.player.set(newSize); + const { enemy, result } = this.battle.resolveSkirmish(this.player.size, optimal); + this.player.set(result); + this.ui.updateCounts(this.player.size, enemy); + this.showCurrentGate(); + } + + endWave() { + const stars = this.computeStars(); + if (stars > this.persistence.getStarRating(this.currentWave)) { + this.persistence.setStarRating(this.currentWave, stars); + } + this.ui.showPopup(`Wave ${this.currentWave} Complete`, stars); + this.telemetry.trackEvent('wave_complete', { wave: this.currentWave, stars }); + } + + computeStars() { + const ratio = this.optimalSize === 0 ? 0 : this.player.size / this.optimalSize; + if (ratio <= 0.4) return 1; + if (ratio <= 0.6) return 2; + if (ratio <= 0.75) return 3; + if (ratio <= 0.9) return 4; + return 5; + } + + nextWave() { + this.currentWave += 1; + this.startWave(); + } + + retryWave() { + this.startWave(); + } +} diff --git a/src/game/GateSystem.js b/src/game/GateSystem.js new file mode 100644 index 0000000..903ac6f --- /dev/null +++ b/src/game/GateSystem.js @@ -0,0 +1,42 @@ +/** + * Handles math gates and player choices. + */ +export default class GateSystem { + constructor(gates = []) { + this.gates = gates; + this.index = 0; + } + + currentGate() { + return this.gates[this.index]; + } + + /** + * Apply player's choice to the army size. + * @param {number} choiceIndex + * @param {number} armySize + * @returns {number} new army size + */ + applyChoice(choiceIndex, armySize) { + const gate = this.currentGate(); + const op = gate[choiceIndex]; + this.index++; + return op.fn(armySize); + } + + /** + * Compute optimal army size after this gate. + * @param {number} armySize + * @returns {number} + */ + optimalOutcome(armySize) { + const gate = this.currentGate(); + const resA = gate[0].fn(armySize); + const resB = gate[1].fn(armySize); + return Math.max(resA, resB); + } + + hasMore() { + return this.index < this.gates.length; + } +} diff --git a/src/game/PersistenceManager.js b/src/game/PersistenceManager.js new file mode 100644 index 0000000..187dba2 --- /dev/null +++ b/src/game/PersistenceManager.js @@ -0,0 +1,30 @@ +/** + * Handles saving and loading star ratings using localStorage. + */ +export default class PersistenceManager { + constructor(storage = (typeof window !== 'undefined' ? window.localStorage : null)) { + this.storage = storage; + this.prefix = 'mathMarauders_wave_'; + } + + /** + * Get stored star rating for a wave. + * @param {number} wave + * @returns {number} + */ + getStarRating(wave) { + if (!this.storage) return 0; + const value = this.storage.getItem(this.prefix + wave); + return value ? parseInt(value, 10) : 0; + } + + /** + * Save star rating for a wave. + * @param {number} wave + * @param {number} stars + */ + setStarRating(wave, stars) { + if (!this.storage) return; + this.storage.setItem(this.prefix + wave, String(stars)); + } +} diff --git a/src/game/Telemetry.js b/src/game/Telemetry.js new file mode 100644 index 0000000..17b8fac --- /dev/null +++ b/src/game/Telemetry.js @@ -0,0 +1,14 @@ +/** + * Default Telemetry implementation. + * Logs events to the console. Future versions may send data to a server. + */ +export default class Telemetry { + /** + * Track a telemetry event. + * @param {string} name - Event name. + * @param {object} payload - Additional data. + */ + trackEvent(name, payload = {}) { + console.log(`[Telemetry] ${name}`, payload); + } +} diff --git a/src/game/UIManager.js b/src/game/UIManager.js new file mode 100644 index 0000000..034e3b1 --- /dev/null +++ b/src/game/UIManager.js @@ -0,0 +1,52 @@ +/** + * Handles DOM updates and user interactions. + */ +export default class UIManager { + constructor() { + this.root = document.getElementById('game'); + this.startBtn = document.getElementById('startBtn'); + this.leftBtn = document.getElementById('leftChoice'); + this.rightBtn = document.getElementById('rightChoice'); + this.playerCountEl = document.getElementById('playerCount'); + this.enemyCountEl = document.getElementById('enemyCount'); + this.popup = document.getElementById('popup'); + this.popupText = document.getElementById('popupText'); + this.nextBtn = document.getElementById('nextBtn'); + this.retryBtn = document.getElementById('retryBtn'); + } + + onStart(cb) { + this.startBtn.addEventListener('click', cb); + } + onLeft(cb) { + this.leftBtn.addEventListener('click', cb); + } + onRight(cb) { + this.rightBtn.addEventListener('click', cb); + } + onNext(cb) { + this.nextBtn.addEventListener('click', cb); + } + onRetry(cb) { + this.retryBtn.addEventListener('click', cb); + } + + updateGateLabels(a, b) { + this.leftBtn.textContent = a; + this.rightBtn.textContent = b; + } + + updateCounts(player, enemy = 0) { + this.playerCountEl.textContent = player; + this.enemyCountEl.textContent = enemy; + } + + showPopup(text, stars) { + this.popupText.textContent = `${text}\n${'★'.repeat(stars)}${'☆'.repeat(5 - stars)}`; + this.popup.style.display = 'block'; + } + + hidePopup() { + this.popup.style.display = 'none'; + } +} diff --git a/src/game/WaveGenerator.js b/src/game/WaveGenerator.js new file mode 100644 index 0000000..1d7b84d --- /dev/null +++ b/src/game/WaveGenerator.js @@ -0,0 +1,73 @@ +/** + * Procedurally generates wave configurations including gates and enemy sizes. + */ +export default class WaveGenerator { + static SKIRMISH_FACTOR = 0.8; + + /** + * Calculate number of gates for a wave. + * Wave 1 = 5 gates and each subsequent wave adds 1. + * @param {number} wave + * @returns {number} + */ + static gateCount(wave) { + return 4 + wave; + } + + /** + * Generate a wave configuration. + * @param {number} wave + * @returns {{gates:Array, retreatGateCount:number}} + */ + generate(wave) { + const count = WaveGenerator.gateCount(wave); + const gates = []; + for (let i = 0; i < count; i++) { + gates.push(this._generateGateOps(wave)); + } + return { gates, retreatGateCount: count }; + } + + _rand(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + _opLabelAndFn(op, value) { + switch (op) { + case '+': + return { label: `+${value}`, fn: x => x + value }; + case '-': + return { label: `-${value}`, fn: x => x - value }; + case '*': + return { label: `x${value}`, fn: x => x * value }; + case '/': + return { label: `/${value}`, fn: x => Math.floor(x / value) }; + default: + return { label: '', fn: x => x }; + } + } + + _generateOneStep() { + const ops = ['+', '-', '*', '/']; + const op = ops[this._rand(0, ops.length - 1)]; + const val = this._rand(2, 5); + return this._opLabelAndFn(op, val); + } + + _generateTwoStep() { + const first = this._generateOneStep(); + const second = this._generateOneStep(); + return { + label: `${first.label}${second.label}`, + fn: x => second.fn(first.fn(x)) + }; + } + + _generateGateOps(wave) { + const tierTwo = wave >= 6 && wave <= 10; + const tierThree = wave >= 11; + const opA = tierTwo || tierThree ? this._generateTwoStep() : this._generateOneStep(); + const opB = tierThree ? this._generateTwoStep() : this._generateOneStep(); + return [opA, opB]; + } +} diff --git a/src/index.js b/src/index.js index ac0ebea..639ad86 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,11 @@ -console.log("Hello from index.js!"); +import GameController from './game/GameController.js'; +import UIManager from './game/UIManager.js'; +import PersistenceManager from './game/PersistenceManager.js'; +import Telemetry from './game/Telemetry.js'; + +document.addEventListener('DOMContentLoaded', () => { + const ui = new UIManager(); + const persistence = new PersistenceManager(); + const telemetry = new Telemetry(); + new GameController(ui, persistence, telemetry); +}); diff --git a/src/index.test.js b/src/index.test.js index e27abde..63c43c5 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -1,12 +1,59 @@ -// This is a placeholder test file for index.js. -// Since index.js currently only has a console.log, -// a meaningful test isn't really possible without -// more complex setup (e.g., mocking console.log or -// integrating with an HTML environment if it manipulates the DOM). - -describe('Index', () => { - test('placeholder test', () => { - // Replace with actual tests once index.js has testable logic - expect(true).toBe(true); +import GateSystem from './game/GateSystem.js'; +import WaveGenerator from './game/WaveGenerator.js'; +import PersistenceManager from './game/PersistenceManager.js'; +import Telemetry from './game/Telemetry.js'; + +describe('GateSystem optimal path', () => { + test('chooses max outcome', () => { + const gate = [[{label:'+2', fn:x=>x+2},{label:'-1', fn:x=>x-1}]]; + const gs = new GateSystem(gate); + expect(gs.optimalOutcome(5)).toBe(7); + }); +}); + +describe('WaveGenerator', () => { + test('gate count increases with wave', () => { + const gen = new WaveGenerator(); + expect(WaveGenerator.gateCount(1)).toBe(5); + expect(WaveGenerator.gateCount(2)).toBe(6); + }); +}); + +describe('Star rating thresholds', () => { + function computeStars(ratio) { + if (ratio <= 0.4) return 1; + if (ratio <= 0.6) return 2; + if (ratio <= 0.75) return 3; + if (ratio <= 0.9) return 4; + return 5; + } + test('rating boundaries', () => { + expect(computeStars(0.3)).toBe(1); + expect(computeStars(0.5)).toBe(2); + expect(computeStars(0.7)).toBe(3); + expect(computeStars(0.85)).toBe(4); + expect(computeStars(0.95)).toBe(5); + }); +}); + +describe('PersistenceManager', () => { + test('saves and loads stars', () => { + const storage = new Map(); + const fakeStorage = { + getItem: key => storage.get(key), + setItem: (key, val) => storage.set(key, val) + }; + const p = new PersistenceManager(fakeStorage); + p.setStarRating(1, 3); + expect(p.getStarRating(1)).toBe(3); + }); +}); + +describe('Telemetry', () => { + test('logs events', () => { + const t = new Telemetry(); + console.log = jest.fn(); + t.trackEvent('test', {a:1}); + expect(console.log).toHaveBeenCalled(); }); });