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();
});
});