From 91d7ac3c883ca23cba81e6ede19cf0e1e11afc9b Mon Sep 17 00:00:00 2001
From: highvoltaage <138823657+highvoltaage@users.noreply.github.com>
Date: Mon, 27 Oct 2025 13:07:15 -0400
Subject: [PATCH 1/5] Add browser-based prototype for Factory Launch
---
README.md | 40 ++++++-
css/styles.css | 222 +++++++++++++++++++++++++++++++++++
factory_launch/__init__.py | 16 +++
factory_launch/constants.py | 25 ++++
factory_launch/game_state.py | 145 +++++++++++++++++++++++
factory_launch/machines.py | 91 ++++++++++++++
factory_launch/research.py | 45 +++++++
factory_launch/storage.py | 51 ++++++++
index.html | 55 +++++++++
js/constants.js | 40 +++++++
js/gameState.js | 133 +++++++++++++++++++++
js/machines.js | 103 ++++++++++++++++
js/main.js | 89 ++++++++++++++
js/storage.js | 88 ++++++++++++++
js/ui.js | 193 ++++++++++++++++++++++++++++++
requirements-dev.txt | 1 +
requirements.txt | 1 +
tests/__init__.py | 6 +
tests/test_game_state.py | 71 +++++++++++
19 files changed, 1414 insertions(+), 1 deletion(-)
create mode 100644 css/styles.css
create mode 100644 factory_launch/__init__.py
create mode 100644 factory_launch/constants.py
create mode 100644 factory_launch/game_state.py
create mode 100644 factory_launch/machines.py
create mode 100644 factory_launch/research.py
create mode 100644 factory_launch/storage.py
create mode 100644 index.html
create mode 100644 js/constants.js
create mode 100644 js/gameState.js
create mode 100644 js/machines.js
create mode 100644 js/main.js
create mode 100644 js/storage.js
create mode 100644 js/ui.js
create mode 100644 requirements-dev.txt
create mode 100644 requirements.txt
create mode 100644 tests/__init__.py
create mode 100644 tests/test_game_state.py
diff --git a/README.md b/README.md
index 4f693b3..43a06bd 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,39 @@
-# Factory_Launch
\ No newline at end of file
+# Factory Launch
+
+Factory Launch is a research-driven resource management and automation prototype inspired by **Factorio** and **Immortality Factory**. The project now includes a browser-playable build that can be hosted on GitHub Pages for quick playtesting alongside the original Python simulation code.
+
+## What's Included
+
+- **Static Web Client** – `index.html`, `css/`, and `js/` provide a standalone build that runs entirely in the browser. The interface lets you gather resources, contribute to research, fuel coal drills, manage clustering bonuses, and operate the starter stone smelter.
+- **Simulation Logic in JavaScript** – Modular ES6 scripts (`js/gameState.js`, `js/machines.js`, `js/storage.js`, and `js/constants.js`) mirror the original game systems and drive the UI. Everything runs locally with no server dependencies, making it ideal for GitHub Pages hosting.
+- **Python Core Simulation (Legacy)** – The earlier Python game-state package and tests remain in place for reference and future backend tooling.
+
+## Getting Started
+
+Open `index.html` directly in a browser or serve the repository with any static web host. For GitHub Pages, place the repository on a branch configured for Pages (for example `main` or `gh-pages`) and enable Pages in the repository settings—the client works without any build step.
+
+Local development using a lightweight HTTP server:
+
+```bash
+python -m http.server 8000
+# then visit http://localhost:8000
+```
+
+## Gameplay Overview
+
+1. **Manual Gathering** – Use the gather buttons to collect the starting stone, coal, iron, and copper required for the first research node.
+2. **Research Progression** – Contribute resources to unlock coal-powered drills and the stone smelter. Progress bars reflect how close you are to completing the initial tech.
+3. **Coal-Powered Drills** – Configure each drill to mine a specific resource, adjust the cluster size (1x1, 2x2, 3x3) to earn production bonuses, and refuel them with coal.
+4. **Stone Smelter** – Switch between iron and copper plate recipes and keep the smelter powered with coal to transform raw ore into processed materials.
+5. **Storage & Inventory** – The UI surfaces player inventory and the starting storage chests, honoring stack limits of 50 (player) and 999 (chests).
+
+## Automated Tests
+
+The Python simulation is still covered by a pytest suite:
+
+```bash
+pip install -r requirements-dev.txt
+pytest
+```
+
+The tests validate drill clustering, smelting throughput, research costs, and inventory limits in the Python reference implementation.
diff --git a/css/styles.css b/css/styles.css
new file mode 100644
index 0000000..eea033d
--- /dev/null
+++ b/css/styles.css
@@ -0,0 +1,222 @@
+:root {
+ --bg: #0e1016;
+ --bg-panel: #171b26;
+ --accent: #ffae42;
+ --accent-dark: #d98b1a;
+ --text: #f4f5f7;
+ --muted: #9aa0b5;
+ --success: #4caf50;
+ --danger: #ff6b6b;
+ font-size: 16px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
+ background: radial-gradient(circle at top, #1f2a44, #0e1016 60%);
+ color: var(--text);
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-header,
+.app-footer {
+ text-align: center;
+ padding: 1.5rem 1rem;
+ background: rgba(15, 19, 29, 0.8);
+ backdrop-filter: blur(6px);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.app-footer {
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ border-bottom: none;
+}
+
+.layout {
+ flex: 1;
+ display: grid;
+ gap: 1.25rem;
+ padding: 1.5rem;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.panel {
+ background: rgba(23, 27, 38, 0.95);
+ border-radius: 12px;
+ padding: 1rem 1.25rem 1.5rem;
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.panel h2 {
+ margin: 0;
+ font-size: 1.4rem;
+ letter-spacing: 0.02em;
+}
+
+button {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ border-radius: 8px;
+ padding: 0.5rem 0.75rem;
+ cursor: pointer;
+ transition: transform 0.15s ease, background 0.2s ease, border 0.2s ease;
+}
+
+button:hover {
+ transform: translateY(-1px);
+ background: rgba(255, 255, 255, 0.12);
+}
+
+button.primary {
+ background: var(--accent);
+ border-color: var(--accent-dark);
+ color: #1b1e27;
+ font-weight: 600;
+}
+
+button.primary:disabled {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: rgba(255, 255, 255, 0.2);
+ color: rgba(255, 255, 255, 0.6);
+ cursor: not-allowed;
+}
+
+.inventory-grid,
+.storage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 0.75rem;
+}
+
+.inventory-item,
+.storage-item {
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 10px;
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.inventory-item h3,
+.storage-item h3 {
+ margin: 0;
+ font-size: 1rem;
+ text-transform: capitalize;
+}
+
+.inventory-item .meta,
+.storage-item .meta {
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.gather-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.machine-card {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ padding: 0.75rem;
+ margin-bottom: 0.75rem;
+ display: grid;
+ gap: 0.5rem;
+}
+
+.machine-card header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.machine-card h3 {
+ margin: 0;
+}
+
+.machine-card select,
+.machine-card input {
+ width: 100%;
+ padding: 0.35rem 0.5rem;
+ border-radius: 6px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(0, 0, 0, 0.2);
+ color: var(--text);
+}
+
+.machine-card .status {
+ font-size: 0.85rem;
+ color: var(--muted);
+}
+
+.machine-card .status span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.machine-card .status .active {
+ color: var(--success);
+}
+
+.machine-card .status .inactive {
+ color: var(--danger);
+}
+
+.event-log-item {
+ font-size: 0.85rem;
+ padding: 0.35rem 0;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+ color: var(--muted);
+}
+
+.research-status {
+ display: grid;
+ gap: 0.5rem;
+ font-size: 0.95rem;
+ background: rgba(255, 255, 255, 0.04);
+ padding: 0.75rem;
+ border-radius: 10px;
+}
+
+.progress {
+ height: 8px;
+ border-radius: 99px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.progress-bar {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(135deg, var(--accent), var(--accent-dark));
+ transition: width 0.3s ease;
+}
+
+.badge {
+ padding: 0.15rem 0.4rem;
+ border-radius: 999px;
+ font-size: 0.7rem;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+@media (max-width: 768px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/factory_launch/__init__.py b/factory_launch/__init__.py
new file mode 100644
index 0000000..8842569
--- /dev/null
+++ b/factory_launch/__init__.py
@@ -0,0 +1,16 @@
+"""Factory Launch core game logic package."""
+
+from .game_state import GameState
+from .research import ResearchNode, TechTree
+from .machines import CoalDrill, StoneSmelter
+from .storage import Inventory, StorageChest
+
+__all__ = [
+ "GameState",
+ "ResearchNode",
+ "TechTree",
+ "CoalDrill",
+ "StoneSmelter",
+ "Inventory",
+ "StorageChest",
+]
diff --git a/factory_launch/constants.py b/factory_launch/constants.py
new file mode 100644
index 0000000..93db862
--- /dev/null
+++ b/factory_launch/constants.py
@@ -0,0 +1,25 @@
+"""Game balance constants."""
+
+COAL_DRILL_COAL_PER_SECOND = 0.1 # 1 coal per 10 seconds
+COAL_DRILL_OUTPUT_PER_SECOND = 1.0
+
+SMELTER_COAL_PER_SECOND = 0.2 # 1 coal per 5 seconds
+SMELTER_OUTPUT_PER_SECOND = 1.0
+
+PLAYER_STACK_LIMIT = 50
+CHEST_STACK_LIMIT = 999
+
+RESOURCE_TYPES = (
+ "stone",
+ "coal",
+ "iron",
+ "copper",
+ "iron_plates",
+ "copper_plates",
+)
+
+SMELTER_RECIPES = {
+ "stone": {"iron_plates": 1},
+ "iron": {"iron_plates": 1},
+ "copper": {"copper_plates": 1},
+}
diff --git a/factory_launch/game_state.py b/factory_launch/game_state.py
new file mode 100644
index 0000000..a71c63b
--- /dev/null
+++ b/factory_launch/game_state.py
@@ -0,0 +1,145 @@
+"""Centralised game state for Factory Launch."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional
+
+from .constants import RESOURCE_TYPES
+from .machines import CoalDrill, StoneSmelter
+from .research import ResearchNode, TechTree
+from .storage import Inventory, StorageChest
+
+
+@dataclass
+class GameState:
+ """Represents the complete game state."""
+
+ resources: Dict[str, float] = field(default_factory=lambda: {resource: 0.0 for resource in RESOURCE_TYPES})
+ player_inventory: Inventory = field(default_factory=Inventory)
+ storage_chests: List[StorageChest] = field(default_factory=list)
+ drills: List[CoalDrill] = field(default_factory=list)
+ smelters: List[StoneSmelter] = field(default_factory=list)
+ tech_tree: Optional[TechTree] = None
+
+ def __post_init__(self) -> None:
+ self._init_default_storage()
+ self._init_starting_machines()
+ if self.tech_tree is None:
+ self.tech_tree = self._create_default_tech_tree()
+
+ def _init_default_storage(self) -> None:
+ starting_chest = StorageChest()
+ for resource in ("stone", "coal", "iron", "copper"):
+ starting_chest.items[resource] = 100
+ for product in ("iron_plates", "copper_plates"):
+ starting_chest.items[product] = 50
+ self.storage_chests.append(starting_chest)
+
+ def _init_starting_machines(self) -> None:
+ for _ in range(2):
+ drill = CoalDrill()
+ drill.feed_coal(10)
+ self.drills.append(drill)
+ smelter = StoneSmelter()
+ smelter.feed_coal(10)
+ self.smelters.append(smelter)
+
+ def _create_default_tech_tree(self) -> TechTree:
+ starter_research = ResearchNode(
+ key="starter",
+ name="Coal Drills & Stone Smelter",
+ requirements={"stone": 10, "coal": 10, "iron": 5},
+ children=["automation"],
+ )
+ return TechTree([starter_research])
+
+ def add_drill(self, drill: CoalDrill) -> None:
+ self.drills.append(drill)
+
+ def add_smelter(self, smelter: StoneSmelter) -> None:
+ self.smelters.append(smelter)
+
+ def total_inventory(self) -> Dict[str, float]:
+ totals = defaultdict(float, self.resources)
+ for container in [self.player_inventory, *self.storage_chests]:
+ for resource, amount in container.items.items():
+ totals[resource] += amount
+ return dict(totals)
+
+ def feed_coal_to_machines(self, coal_amount: float) -> None:
+ per_machine = coal_amount / (len(self.drills) + len(self.smelters) or 1)
+ for machine in [*self.drills, *self.smelters]:
+ machine.feed_coal(per_machine)
+
+ def _cluster_sizes(self) -> Dict[Optional[int], int]:
+ sizes: Dict[Optional[int], int] = defaultdict(int)
+ for drill in self.drills:
+ if drill.cluster_id is None:
+ continue
+ sizes[drill.cluster_id] += 1
+ return sizes
+
+ def tick(self, seconds: float) -> None:
+ cluster_sizes = self._cluster_sizes()
+ for drill in self.drills:
+ output = drill.tick(seconds, cluster_sizes)
+ if output:
+ self.resources[drill.resource] += output
+ for smelter in self.smelters:
+ available_inputs = {resource: self.resources.get(resource, 0.0) for resource in RESOURCE_TYPES}
+ outputs, consumed = smelter.tick(seconds, available_inputs)
+ if outputs:
+ for resource, amount in outputs.items():
+ self.resources[resource] = self.resources.get(resource, 0.0) + amount
+ input_resource = smelter.input_resource
+ if input_resource:
+ self.resources[input_resource] = max(
+ 0.0, self.resources.get(input_resource, 0.0) - consumed
+ )
+
+ def start_research(self, key: str) -> bool:
+ if not self.tech_tree:
+ return False
+ node = self.tech_tree.get(key)
+ if not node:
+ return False
+ inventory = self.total_inventory()
+ requirements = dict(node.requirements)
+ if node.complete(inventory):
+ remaining = dict(requirements)
+ for container in [self.player_inventory, *self.storage_chests]:
+ for resource, needed in list(remaining.items()):
+ if needed <= 0:
+ continue
+ removed = container.remove(resource, needed)
+ remaining[resource] -= removed
+ for resource, needed in remaining.items():
+ if needed > 0:
+ available = self.resources.get(resource, 0.0)
+ consumed = min(available, needed)
+ self.resources[resource] = available - consumed
+ return True
+ return False
+
+ def describe(self) -> str:
+ parts = ["Resources:"]
+ for resource, amount in sorted(self.resources.items()):
+ parts.append(f" {resource}: {amount:.1f}")
+ parts.append("\nDrills:")
+ for idx, drill in enumerate(self.drills, start=1):
+ parts.append(
+ f" Drill {idx}: resource={drill.resource} coal={drill.coal_buffer:.1f} cluster={drill.cluster_id}"
+ )
+ parts.append("\nSmelters:")
+ for idx, smelter in enumerate(self.smelters, start=1):
+ parts.append(
+ f" Smelter {idx}: recipe={smelter.input_resource} coal={smelter.coal_buffer:.1f}"
+ )
+ parts.append("\nResearch:")
+ if self.tech_tree:
+ for node in self.tech_tree.nodes.values():
+ state = "Unlocked" if node.unlocked else "Locked"
+ parts.append(f" {node.name}: {state}")
+ return "\n".join(parts)
diff --git a/factory_launch/machines.py b/factory_launch/machines.py
new file mode 100644
index 0000000..2abcd7e
--- /dev/null
+++ b/factory_launch/machines.py
@@ -0,0 +1,91 @@
+"""Machine implementations for Factory Launch."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from math import sqrt
+from typing import Dict, Optional, Tuple
+
+from .constants import (
+ COAL_DRILL_COAL_PER_SECOND,
+ COAL_DRILL_OUTPUT_PER_SECOND,
+ SMELTER_COAL_PER_SECOND,
+ SMELTER_OUTPUT_PER_SECOND,
+ SMELTER_RECIPES,
+)
+
+
+@dataclass
+class Machine:
+ """Base machine with coal consumption logic."""
+
+ coal_buffer: float = 0.0
+
+ def feed_coal(self, amount: float) -> None:
+ if amount < 0:
+ raise ValueError("Cannot feed negative coal")
+ self.coal_buffer += amount
+
+ def consume_coal(self, amount: float) -> bool:
+ if self.coal_buffer >= amount:
+ self.coal_buffer -= amount
+ return True
+ return False
+
+
+@dataclass
+class CoalDrill(Machine):
+ """A coal-powered drill that mines a single resource."""
+
+ resource: str = "stone"
+ cluster_id: Optional[int] = None
+ base_output_per_second: float = COAL_DRILL_OUTPUT_PER_SECOND
+ coal_per_second: float = COAL_DRILL_COAL_PER_SECOND
+
+ def effective_output(self, cluster_sizes: Dict[Optional[int], int]) -> float:
+ size = cluster_sizes.get(self.cluster_id, 1)
+ multiplier = sqrt(size)
+ return self.base_output_per_second * multiplier
+
+ def tick(self, seconds: float, cluster_sizes: Dict[Optional[int], int]) -> float:
+ required_coal = self.coal_per_second * seconds
+ if not self.consume_coal(required_coal):
+ return 0.0
+ return self.effective_output(cluster_sizes) * seconds
+
+
+@dataclass
+class StoneSmelter(Machine):
+ """Processes raw materials into plates using coal."""
+
+ input_resource: Optional[str] = None
+ output_buffer: Dict[str, float] = field(default_factory=dict)
+ coal_per_second: float = SMELTER_COAL_PER_SECOND
+ output_per_second: float = SMELTER_OUTPUT_PER_SECOND
+
+ def set_recipe(self, resource: Optional[str]) -> None:
+ if resource is not None and resource not in SMELTER_RECIPES:
+ raise ValueError(f"Unsupported resource: {resource}")
+ self.input_resource = resource
+
+ def tick(
+ self, seconds: float, available_inputs: Dict[str, float]
+ ) -> Tuple[Dict[str, float], float]:
+ if self.input_resource is None:
+ return {}, 0.0
+ required_coal = self.coal_per_second * seconds
+ if not self.consume_coal(required_coal):
+ return {}, 0.0
+ recipe = SMELTER_RECIPES.get(self.input_resource)
+ if not recipe:
+ return {}, 0.0
+ total_input_needed = self.output_per_second * seconds
+ available_amount = available_inputs.get(self.input_resource, 0.0)
+ processed = min(total_input_needed, available_amount)
+ if processed <= 0:
+ return {}, 0.0
+ ratio = processed / total_input_needed if total_input_needed else 0
+ outputs: Dict[str, float] = {}
+ for product, quantity in recipe.items():
+ outputs[product] = quantity * ratio * self.output_per_second * seconds
+ return outputs, processed
diff --git a/factory_launch/research.py b/factory_launch/research.py
new file mode 100644
index 0000000..ea7b902
--- /dev/null
+++ b/factory_launch/research.py
@@ -0,0 +1,45 @@
+"""Research system for unlocking technologies."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Dict, Iterable, List, Optional
+
+
+@dataclass
+class ResearchNode:
+ """Represents a research node with resource requirements."""
+
+ key: str
+ name: str
+ requirements: Dict[str, int]
+ unlocked: bool = False
+ children: List[str] = field(default_factory=list)
+
+ def can_start(self, inventory: Dict[str, int]) -> bool:
+ return all(inventory.get(resource, 0) >= amount for resource, amount in self.requirements.items())
+
+ def complete(self, inventory: Dict[str, int]) -> bool:
+ if not self.can_start(inventory):
+ return False
+ for resource, amount in self.requirements.items():
+ inventory[resource] = inventory.get(resource, 0) - amount
+ self.unlocked = True
+ return True
+
+
+class TechTree:
+ """A simple tech tree managing research nodes."""
+
+ def __init__(self, nodes: Iterable[ResearchNode]):
+ self.nodes = {node.key: node for node in nodes}
+
+ def unlocked(self, key: str) -> bool:
+ node = self.nodes.get(key)
+ return bool(node and node.unlocked)
+
+ def get(self, key: str) -> Optional[ResearchNode]:
+ return self.nodes.get(key)
+
+ def available_nodes(self) -> List[ResearchNode]:
+ return [node for node in self.nodes.values() if not node.unlocked]
diff --git a/factory_launch/storage.py b/factory_launch/storage.py
new file mode 100644
index 0000000..24447cf
--- /dev/null
+++ b/factory_launch/storage.py
@@ -0,0 +1,51 @@
+"""Inventory and storage implementations."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Dict
+
+from .constants import CHEST_STACK_LIMIT, PLAYER_STACK_LIMIT
+
+
+@dataclass
+class Inventory:
+ """Represents the player's inventory with stack limits."""
+
+ stack_limit: int = PLAYER_STACK_LIMIT
+ items: Dict[str, int] = field(default_factory=dict)
+
+ def add(self, resource: str, amount: int) -> int:
+ """Add a resource to the inventory.
+
+ Returns the amount that could not be stored due to stack limits.
+ """
+
+ if amount < 0:
+ raise ValueError("Amount to add must be non-negative")
+ current = self.items.get(resource, 0)
+ capacity = self.stack_limit
+ new_total = min(capacity, current + amount)
+ self.items[resource] = new_total
+ return amount - (new_total - current)
+
+ def remove(self, resource: str, amount: int) -> int:
+ """Remove a resource and return the amount actually removed."""
+
+ if amount < 0:
+ raise ValueError("Amount to remove must be non-negative")
+ current = self.items.get(resource, 0)
+ removed = min(current, amount)
+ if removed:
+ self.items[resource] = current - removed
+ return removed
+
+ def has(self, resource: str, amount: int) -> bool:
+ return self.items.get(resource, 0) >= amount
+
+
+@dataclass
+class StorageChest(Inventory):
+ """A storage chest with a larger stack limit."""
+
+ stack_limit: int = CHEST_STACK_LIMIT
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..60d0a70
--- /dev/null
+++ b/index.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Factory Launch Prototype
+
+
+
+
+
+
+ Research Node
+
+
+
+
+ Player Inventory
+
+
+
Gather Resources
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/constants.js b/js/constants.js
new file mode 100644
index 0000000..479de4a
--- /dev/null
+++ b/js/constants.js
@@ -0,0 +1,40 @@
+export const RESOURCE_LIST = [
+ "stone",
+ "coal",
+ "iron",
+ "copper",
+ "ironPlates",
+ "copperPlates"
+];
+
+export const RESOURCE_LABELS = {
+ stone: "Stone",
+ coal: "Coal",
+ iron: "Iron Ore",
+ copper: "Copper Ore",
+ ironPlates: "Iron Plates",
+ copperPlates: "Copper Plates"
+};
+
+export const PLAYER_STACK_LIMIT = 50;
+export const CHEST_STACK_LIMIT = 999;
+
+export const DRILL_BASE_OUTPUT_PER_SECOND = 0.6;
+export const DRILL_COAL_PER_SECOND = 0.1;
+
+export const SMELT_SECONDS_PER_PLATE = 4;
+export const SMELTER_COAL_PER_SECOND = 0.2;
+
+export const RESEARCH_REQUIREMENTS = {
+ stone: 30,
+ coal: 20,
+ iron: 20,
+ copper: 10
+};
+
+export const RESEARCH_REWARD = Object.freeze({
+ drillsUnlocked: true,
+ smelterUnlocked: true,
+ rewardDrills: 2,
+ rewardSmelters: 1
+});
diff --git a/js/gameState.js b/js/gameState.js
new file mode 100644
index 0000000..8184f71
--- /dev/null
+++ b/js/gameState.js
@@ -0,0 +1,133 @@
+import {
+ RESOURCE_LIST,
+ RESOURCE_LABELS,
+ RESEARCH_REQUIREMENTS,
+ RESEARCH_REWARD
+} from "./constants.js";
+import { CoalDrill, StoneSmelter } from "./machines.js";
+import { StorageManager } from "./storage.js";
+
+export class GameState {
+ constructor({ onLog } = {}) {
+ this.time = 0;
+ this.resources = RESOURCE_LIST.reduce((acc, resource) => {
+ acc[resource] = 0;
+ return acc;
+ }, {});
+ this.storage = new StorageManager();
+ this.drills = [];
+ this.smelters = [];
+ this.research = {
+ completed: false,
+ progress: RESOURCE_LIST.reduce((acc, resource) => {
+ if (RESEARCH_REQUIREMENTS[resource] !== undefined) {
+ acc[resource] = 0;
+ }
+ return acc;
+ }, {})
+ };
+ this.log = [];
+ this.onLog = onLog;
+ }
+
+ addLog(message) {
+ const entry = { time: this.time, message };
+ this.log.unshift(entry);
+ if (typeof this.onLog === "function") {
+ this.onLog(entry);
+ }
+ this.log = this.log.slice(0, 40);
+ }
+
+ depositToPlayer(resource, amount) {
+ this.resources[resource] += amount;
+ this.storage.playerInventory.add(resource, amount);
+ }
+
+ takeFromPlayer(resource, amount) {
+ if (this.resources[resource] < amount) {
+ const needed = amount - this.resources[resource];
+ const pulled = this.storage.takeFromChests(resource, needed);
+ if (pulled > 0) {
+ this.storage.playerInventory.add(resource, pulled);
+ this.resources[resource] += pulled;
+ }
+ }
+
+ const available = this.resources[resource];
+ const taken = Math.min(available, amount);
+ this.resources[resource] -= taken;
+ this.storage.playerInventory.remove(resource, taken);
+ return taken;
+ }
+
+ gather(resource, amount = 1) {
+ const leftover = this.storage.playerInventory.add(resource, amount);
+ const gained = amount - leftover;
+ this.resources[resource] += gained;
+ if (gained > 0) {
+ const label = RESOURCE_LABELS[resource] ?? resource;
+ this.addLog(`Gathered ${gained} ${label}.`);
+ }
+ }
+
+ contributeToResearch(costs) {
+ if (this.research.completed) {
+ return false;
+ }
+ let contributedAny = false;
+ for (const [resource, required] of Object.entries(RESEARCH_REQUIREMENTS)) {
+ const remaining = required - this.research.progress[resource];
+ if (remaining <= 0) continue;
+ const available =
+ costs?.[resource] ?? this.resources[resource] + this.storage.totalInChests(resource);
+ const toSpend = Math.min(remaining, available);
+ if (toSpend > 0) {
+ this.takeFromPlayer(resource, toSpend);
+ this.research.progress[resource] += toSpend;
+ contributedAny = true;
+ }
+ }
+ if (contributedAny) {
+ this.addLog("Contributed resources to research.");
+ }
+ const completed = Object.entries(RESEARCH_REQUIREMENTS).every(
+ ([resource, required]) => this.research.progress[resource] >= required
+ );
+ if (completed) {
+ this.research.completed = true;
+ this.addLog("Research completed! Coal drills and smelters unlocked.");
+ this.unlockStartingMachines();
+ }
+ return contributedAny;
+ }
+
+ unlockStartingMachines() {
+ for (let i = 0; i < RESEARCH_REWARD.rewardDrills; i += 1) {
+ this.drills.push(new CoalDrill());
+ }
+ for (let i = 0; i < RESEARCH_REWARD.rewardSmelters; i += 1) {
+ this.smelters.push(new StoneSmelter());
+ }
+ }
+
+ addFuelToMachine(machine, amount) {
+ if (this.takeFromPlayer("coal", amount) < amount) {
+ this.addLog("Not enough coal to fuel the machine.");
+ return false;
+ }
+ machine.addFuel(amount);
+ this.addLog(`Added ${amount} coal to ${machine.id}.`);
+ return true;
+ }
+
+ tick(deltaSeconds) {
+ this.time += deltaSeconds;
+ for (const drill of this.drills) {
+ drill.tick(deltaSeconds, this);
+ }
+ for (const smelter of this.smelters) {
+ smelter.tick(deltaSeconds, this);
+ }
+ }
+}
diff --git a/js/machines.js b/js/machines.js
new file mode 100644
index 0000000..dbda8ff
--- /dev/null
+++ b/js/machines.js
@@ -0,0 +1,103 @@
+import {
+ DRILL_BASE_OUTPUT_PER_SECOND,
+ DRILL_COAL_PER_SECOND,
+ SMELT_SECONDS_PER_PLATE,
+ SMELTER_COAL_PER_SECOND
+} from "./constants.js";
+
+let nextMachineId = 1;
+
+export function clusterMultiplier(clusterSize) {
+ const root = Math.round(Math.sqrt(clusterSize));
+ if (root * root !== clusterSize) {
+ return 1;
+ }
+ return root;
+}
+
+export class CoalDrill {
+ constructor({ resource = "stone", clusterSize = 1 } = {}) {
+ this.id = `drill-${nextMachineId++}`;
+ this.resource = resource;
+ this.clusterSize = clusterSize;
+ this.fuel = 0;
+ this.outputBuffer = 0;
+ }
+
+ addFuel(amount) {
+ this.fuel += amount;
+ }
+
+ setResource(resource) {
+ this.resource = resource;
+ }
+
+ setClusterSize(size) {
+ this.clusterSize = size;
+ }
+
+ get active() {
+ return this.fuel > 0;
+ }
+
+ tick(deltaSeconds, gameState) {
+ if (!this.active) {
+ return;
+ }
+
+ const fuelConsumed = DRILL_COAL_PER_SECOND * deltaSeconds;
+ this.fuel = Math.max(0, this.fuel - fuelConsumed);
+
+ const outputRate = DRILL_BASE_OUTPUT_PER_SECOND * clusterMultiplier(this.clusterSize);
+ this.outputBuffer += outputRate * deltaSeconds;
+
+ if (this.outputBuffer >= 1) {
+ const produced = Math.floor(this.outputBuffer);
+ this.outputBuffer -= produced;
+ gameState.depositToPlayer(this.resource, produced);
+ }
+ }
+}
+
+export class StoneSmelter {
+ constructor({ recipe = "iron" } = {}) {
+ this.id = `smelter-${nextMachineId++}`;
+ this.recipe = recipe; // "iron" | "copper"
+ this.fuel = 0;
+ this.progress = 0;
+ }
+
+ addFuel(amount) {
+ this.fuel += amount;
+ }
+
+ setRecipe(recipe) {
+ this.recipe = recipe;
+ }
+
+ get active() {
+ return this.fuel > 0;
+ }
+
+ tick(deltaSeconds, gameState) {
+ if (!this.active) {
+ return;
+ }
+
+ const oreResource = this.recipe;
+ const plateResource = this.recipe === "iron" ? "ironPlates" : "copperPlates";
+
+ const consumedFuel = SMELTER_COAL_PER_SECOND * deltaSeconds;
+ this.fuel = Math.max(0, this.fuel - consumedFuel);
+
+ this.progress += deltaSeconds;
+ while (this.progress >= SMELT_SECONDS_PER_PLATE) {
+ if (gameState.takeFromPlayer(oreResource, 1) < 1) {
+ this.progress = SMELT_SECONDS_PER_PLATE;
+ return;
+ }
+ this.progress -= SMELT_SECONDS_PER_PLATE;
+ gameState.depositToPlayer(plateResource, 1);
+ }
+ }
+}
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..503bfa7
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,89 @@
+import { RESOURCE_LABELS } from "./constants.js";
+import { GameState } from "./gameState.js";
+import { UIController } from "./ui.js";
+
+const gameState = new GameState();
+const ui = new UIController(gameState);
+
+gameState.onLog = () => ui.renderLog(gameState.log);
+
+ui.bindGatherButtons((resource) => {
+ gameState.gather(resource);
+ ui.renderInventory();
+ ui.renderResearch();
+ ui.renderStorage();
+ ui.renderLog(gameState.log);
+});
+
+const contributeButton = document.getElementById("contribute-research");
+contributeButton.addEventListener("click", () => {
+ gameState.contributeToResearch();
+ ui.renderInventory();
+ ui.renderResearch();
+ ui.renderDrills(handleDrillResourceChange, handleDrillFuel, handleDrillClusterChange);
+ ui.renderSmelters(handleSmelterRecipeChange, handleSmelterFuel);
+ ui.renderLog(gameState.log);
+});
+
+function handleDrillResourceChange(id, resource) {
+ const drill = gameState.drills.find((d) => d.id === id);
+ if (!drill) return;
+ drill.setResource(resource);
+ gameState.addLog(`Configured ${id} to mine ${RESOURCE_LABELS[resource] ?? resource}.`);
+ ui.renderLog(gameState.log);
+}
+
+function handleDrillClusterChange(id, clusterSize) {
+ const drill = gameState.drills.find((d) => d.id === id);
+ if (!drill) return;
+ drill.setClusterSize(clusterSize);
+ const size = Math.sqrt(clusterSize);
+ gameState.addLog(`Updated ${id} cluster to ${size}x${size}.`);
+ ui.renderLog(gameState.log);
+}
+
+function handleDrillFuel(id) {
+ const drill = gameState.drills.find((d) => d.id === id);
+ if (!drill) return;
+ if (gameState.addFuelToMachine(drill, 10)) {
+ ui.renderDrills(handleDrillResourceChange, handleDrillFuel, handleDrillClusterChange);
+ ui.renderInventory();
+ ui.renderLog(gameState.log);
+ }
+}
+
+function handleSmelterRecipeChange(id, recipe) {
+ const smelter = gameState.smelters.find((s) => s.id === id);
+ if (!smelter) return;
+ smelter.setRecipe(recipe);
+ gameState.addLog(`Configured ${id} to craft ${recipe === "iron" ? "Iron" : "Copper"} Plates.`);
+ ui.renderLog(gameState.log);
+}
+
+function handleSmelterFuel(id) {
+ const smelter = gameState.smelters.find((s) => s.id === id);
+ if (!smelter) return;
+ if (gameState.addFuelToMachine(smelter, 10)) {
+ ui.renderSmelters(handleSmelterRecipeChange, handleSmelterFuel);
+ ui.renderInventory();
+ ui.renderLog(gameState.log);
+ }
+}
+
+function tick() {
+ gameState.tick(1);
+ ui.renderInventory();
+ ui.renderStorage();
+ ui.renderDrills(handleDrillResourceChange, handleDrillFuel, handleDrillClusterChange);
+ ui.renderSmelters(handleSmelterRecipeChange, handleSmelterFuel);
+ setTimeout(tick, 1000);
+}
+
+ui.renderInventory();
+ui.renderStorage();
+ui.renderResearch();
+ui.renderDrills(handleDrillResourceChange, handleDrillFuel, handleDrillClusterChange);
+ui.renderSmelters(handleSmelterRecipeChange, handleSmelterFuel);
+ui.renderLog(gameState.log);
+
+setTimeout(tick, 1000);
diff --git a/js/storage.js b/js/storage.js
new file mode 100644
index 0000000..b849081
--- /dev/null
+++ b/js/storage.js
@@ -0,0 +1,88 @@
+import { RESOURCE_LIST, PLAYER_STACK_LIMIT, CHEST_STACK_LIMIT } from "./constants.js";
+
+class Inventory {
+ constructor(stackLimit) {
+ this.stackLimit = stackLimit;
+ this.items = new Map();
+ }
+
+ get(resource) {
+ return this.items.get(resource) ?? 0;
+ }
+
+ set(resource, value) {
+ this.items.set(resource, Math.max(0, value));
+ }
+
+ add(resource, amount) {
+ const current = this.get(resource);
+ this.items.set(resource, current + amount);
+ return 0;
+ }
+
+ remove(resource, amount) {
+ const current = this.get(resource);
+ const next = Math.max(0, current - amount);
+ this.items.set(resource, next);
+ return current - next;
+ }
+
+ toJSON() {
+ const result = {};
+ for (const res of RESOURCE_LIST) {
+ result[res] = this.get(res);
+ }
+ return result;
+ }
+
+ stackCount(resource) {
+ const qty = this.get(resource);
+ if (qty === 0) return 0;
+ return Math.ceil(qty / this.stackLimit);
+ }
+}
+
+export class StorageManager {
+ constructor() {
+ this.playerInventory = new Inventory(PLAYER_STACK_LIMIT);
+ this.chests = [new Inventory(CHEST_STACK_LIMIT), new Inventory(CHEST_STACK_LIMIT)];
+
+ for (const resource of ["stone", "coal", "iron", "copper"]) {
+ this.chests[0].add(resource, CHEST_STACK_LIMIT * 2);
+ }
+ for (const item of ["ironPlates", "copperPlates"]) {
+ this.chests[1].add(item, CHEST_STACK_LIMIT);
+ }
+ }
+
+ takeFromChests(resource, amount) {
+ let remaining = amount;
+ for (const chest of this.chests) {
+ if (remaining <= 0) break;
+ const removed = chest.remove(resource, remaining);
+ remaining -= removed;
+ }
+ return amount - remaining;
+ }
+
+ depositToChests(resource, amount) {
+ let remaining = amount;
+ for (const chest of this.chests) {
+ if (remaining <= 0) break;
+ chest.add(resource, remaining);
+ remaining = 0;
+ }
+ return remaining;
+ }
+
+ totalInChests(resource) {
+ return this.chests.reduce((sum, chest) => sum + chest.get(resource), 0);
+ }
+
+ toJSON() {
+ return {
+ player: this.playerInventory.toJSON(),
+ chests: this.chests.map((chest) => chest.toJSON())
+ };
+ }
+}
diff --git a/js/ui.js b/js/ui.js
new file mode 100644
index 0000000..14947ed
--- /dev/null
+++ b/js/ui.js
@@ -0,0 +1,193 @@
+import { RESOURCE_LIST, RESOURCE_LABELS, RESEARCH_REQUIREMENTS } from "./constants.js";
+
+export class UIController {
+ constructor(gameState) {
+ this.gameState = gameState;
+ this.inventoryGrid = document.getElementById("inventory-grid");
+ this.storageContainer = document.getElementById("storage-chests");
+ this.drillContainer = document.getElementById("drills");
+ this.smelterContainer = document.getElementById("smelters");
+ this.researchStatus = document.getElementById("research-status");
+ this.logContainer = document.getElementById("event-log");
+ }
+
+ bindGatherButtons(onGather) {
+ document.querySelectorAll(".gather-buttons button").forEach((button) => {
+ button.addEventListener("click", () => onGather(button.dataset.resource));
+ });
+ }
+
+ renderInventory() {
+ this.inventoryGrid.innerHTML = "";
+ for (const resource of RESOURCE_LIST) {
+ const amount = this.gameState.storage.playerInventory.get(resource);
+ if (amount === 0) continue;
+ const item = document.createElement("div");
+ item.className = "inventory-item";
+ item.innerHTML = `
+ ${RESOURCE_LABELS[resource] ?? resource}
+ ${amount} items
+ `;
+ this.inventoryGrid.appendChild(item);
+ }
+ if (!this.inventoryGrid.children.length) {
+ const empty = document.createElement("p");
+ empty.textContent = "Inventory is empty. Gather resources to begin.";
+ empty.className = "meta";
+ this.inventoryGrid.appendChild(empty);
+ }
+ }
+
+ renderStorage() {
+ this.storageContainer.innerHTML = "";
+ this.gameState.storage.chests.forEach((chest, index) => {
+ const card = document.createElement("div");
+ card.className = "storage-item";
+ const contents = [];
+ for (const resource of RESOURCE_LIST) {
+ const amount = chest.get(resource);
+ if (amount > 0) {
+ contents.push(`${RESOURCE_LABELS[resource] ?? resource}: ${amount}`);
+ }
+ }
+ card.innerHTML = `
+ Chest ${index + 1}
+ ${contents.join(" ") || "Empty"}
+ `;
+ this.storageContainer.appendChild(card);
+ });
+ }
+
+ renderResearch() {
+ const status = this.gameState.research;
+ const lines = Object.entries(RESEARCH_REQUIREMENTS).map(([resource, required]) => {
+ const progress = status.progress[resource] ?? 0;
+ const percent = Math.min(100, Math.round((progress / required) * 100));
+ return `
+
+
+ ${RESOURCE_LABELS[resource]}
+ ${progress}/${required}
+
+
+
+ `;
+ });
+ const completeLine = status.completed
+ ? 'Research complete!'
+ : 'Awaiting resources';
+ this.researchStatus.innerHTML = `${lines.join("")}${completeLine}`;
+ document.getElementById("contribute-research").disabled = status.completed;
+ }
+
+ renderDrills(onResourceChange, onFuel, onClusterChange) {
+ this.drillContainer.innerHTML = "";
+ if (!this.gameState.drills.length) {
+ const hint = document.createElement("p");
+ hint.textContent = "Complete the first research to unlock coal-powered drills.";
+ hint.className = "meta";
+ this.drillContainer.appendChild(hint);
+ return;
+ }
+
+ for (const drill of this.gameState.drills) {
+ const card = document.createElement("div");
+ card.className = "machine-card";
+ card.innerHTML = `
+
+ Coal Drill
+ ${drill.id}
+
+
+
+
+
+ ${drill.active ? "Active" : "Idle"} — Fuel ${drill.fuel.toFixed(1)}
+
+
+
+ `;
+ this.drillContainer.appendChild(card);
+ }
+
+ this.drillContainer.querySelectorAll("select[data-role='resource']").forEach((el) => {
+ el.addEventListener("change", (event) => onResourceChange(event.target.dataset.id, event.target.value));
+ });
+ this.drillContainer.querySelectorAll("select[data-role='cluster']").forEach((el) => {
+ el.addEventListener("change", (event) => onClusterChange(event.target.dataset.id, Number(event.target.value)));
+ });
+ this.drillContainer.querySelectorAll("button[data-role='fuel']").forEach((button) => {
+ button.addEventListener("click", (event) => onFuel(event.target.dataset.id));
+ });
+ }
+
+ renderSmelters(onRecipeChange, onFuel) {
+ this.smelterContainer.innerHTML = "";
+ if (!this.gameState.smelters.length) {
+ const hint = document.createElement("p");
+ hint.textContent = "Complete the first research to unlock the stone smelter.";
+ hint.className = "meta";
+ this.smelterContainer.appendChild(hint);
+ return;
+ }
+
+ for (const smelter of this.gameState.smelters) {
+ const card = document.createElement("div");
+ card.className = "machine-card";
+ card.innerHTML = `
+
+ Stone Smelter
+ ${smelter.id}
+
+
+
+
+ ${smelter.active ? "Active" : "Idle"} — Fuel ${smelter.fuel.toFixed(1)}
+
+
+
+ `;
+ this.smelterContainer.appendChild(card);
+ }
+
+ this.smelterContainer.querySelectorAll("select[data-role='recipe']").forEach((el) => {
+ el.addEventListener("change", (event) => onRecipeChange(event.target.dataset.id, event.target.value));
+ });
+ this.smelterContainer.querySelectorAll("button[data-role='fuel']").forEach((button) => {
+ button.addEventListener("click", (event) => onFuel(event.target.dataset.id));
+ });
+ }
+
+ renderLog(entries) {
+ this.logContainer.innerHTML = "";
+ entries.slice(0, 20).forEach((entry) => {
+ const li = document.createElement("li");
+ li.className = "event-log-item";
+ li.textContent = `t=${entry.time.toFixed(1)}s • ${entry.message}`;
+ this.logContainer.appendChild(li);
+ });
+ }
+}
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..b197d32
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1 @@
+pytest>=7.0
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..b197d32
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+pytest>=7.0
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..2a855d9
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,6 @@
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
diff --git a/tests/test_game_state.py b/tests/test_game_state.py
new file mode 100644
index 0000000..b8e84bf
--- /dev/null
+++ b/tests/test_game_state.py
@@ -0,0 +1,71 @@
+import math
+
+from factory_launch import GameState
+from factory_launch.constants import (
+ COAL_DRILL_OUTPUT_PER_SECOND,
+ PLAYER_STACK_LIMIT,
+)
+from factory_launch.machines import CoalDrill, StoneSmelter
+
+
+def test_initial_state_has_resources_and_machines():
+ state = GameState()
+ assert len(state.drills) == 2
+ assert len(state.smelters) == 1
+ chest = state.storage_chests[0]
+ for resource in ("stone", "coal", "iron", "copper"):
+ assert chest.items[resource] == 100
+ for product in ("iron_plates", "copper_plates"):
+ assert chest.items[product] == 50
+
+
+def test_drill_output_scales_with_cluster():
+ state = GameState()
+ drill = CoalDrill(resource="iron", cluster_id=1)
+ drill.feed_coal(10)
+ state.drills = [drill]
+ state.tick(1.0)
+ assert state.resources["iron"] == COAL_DRILL_OUTPUT_PER_SECOND
+
+ # Add 3 more drills to form a 2x2 cluster (size 4) doubling output
+ for _ in range(3):
+ extra = CoalDrill(resource="iron", cluster_id=1)
+ extra.feed_coal(10)
+ state.add_drill(extra)
+ state.resources["iron"] = 0
+ state.tick(1.0)
+ expected_total = COAL_DRILL_OUTPUT_PER_SECOND * math.sqrt(4) * 4
+ assert math.isclose(state.resources["iron"], expected_total)
+
+
+def test_smelter_consumes_inputs_and_produces_outputs():
+ state = GameState()
+ smelter = StoneSmelter()
+ smelter.set_recipe("stone")
+ smelter.feed_coal(10)
+ state.smelters = [smelter]
+ state.drills = []
+ state.resources["stone"] = 10
+ state.tick(5.0)
+ assert state.resources["stone"] < 10
+ assert state.resources["iron_plates"] > 0
+
+
+def test_research_consumes_required_resources():
+ state = GameState()
+ # move required resources into inventory to simplify accounting
+ chest = state.storage_chests[0]
+ for resource, amount in {"stone": 10, "coal": 10, "iron": 5}.items():
+ chest.remove(resource, amount)
+ state.player_inventory.add(resource, amount)
+ assert state.start_research("starter")
+ assert state.tech_tree.unlocked("starter")
+ for resource in ("stone", "coal", "iron"):
+ assert not state.player_inventory.has(resource, 1)
+
+
+def test_inventory_stack_limit():
+ state = GameState()
+ overflow = state.player_inventory.add("stone", PLAYER_STACK_LIMIT + 25)
+ assert state.player_inventory.items["stone"] == PLAYER_STACK_LIMIT
+ assert overflow == 25
From d959497196c0d0e5f6cb178553d0c1638018f9f1 Mon Sep 17 00:00:00 2001
From: highvoltaage <138823657+highvoltaage@users.noreply.github.com>
Date: Mon, 27 Oct 2025 13:16:35 -0400
Subject: [PATCH 2/5] Keep starting chests empty and stabilize machine
selectors
---
factory_launch/game_state.py | 9 +++----
js/storage.js | 7 ------
js/ui.js | 48 ++++++++++++++++++++++++++++++++++++
tests/test_game_state.py | 5 +---
4 files changed, 52 insertions(+), 17 deletions(-)
diff --git a/factory_launch/game_state.py b/factory_launch/game_state.py
index a71c63b..6ed193d 100644
--- a/factory_launch/game_state.py
+++ b/factory_launch/game_state.py
@@ -30,12 +30,9 @@ def __post_init__(self) -> None:
self.tech_tree = self._create_default_tech_tree()
def _init_default_storage(self) -> None:
- starting_chest = StorageChest()
- for resource in ("stone", "coal", "iron", "copper"):
- starting_chest.items[resource] = 100
- for product in ("iron_plates", "copper_plates"):
- starting_chest.items[product] = 50
- self.storage_chests.append(starting_chest)
+ """Create empty starting storage chests."""
+
+ self.storage_chests.append(StorageChest())
def _init_starting_machines(self) -> None:
for _ in range(2):
diff --git a/js/storage.js b/js/storage.js
index b849081..fa3bee3 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -46,13 +46,6 @@ export class StorageManager {
constructor() {
this.playerInventory = new Inventory(PLAYER_STACK_LIMIT);
this.chests = [new Inventory(CHEST_STACK_LIMIT), new Inventory(CHEST_STACK_LIMIT)];
-
- for (const resource of ["stone", "coal", "iron", "copper"]) {
- this.chests[0].add(resource, CHEST_STACK_LIMIT * 2);
- }
- for (const item of ["ironPlates", "copperPlates"]) {
- this.chests[1].add(item, CHEST_STACK_LIMIT);
- }
}
takeFromChests(resource, amount) {
diff --git a/js/ui.js b/js/ui.js
index 14947ed..14ba211 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -83,6 +83,16 @@ export class UIController {
}
renderDrills(onResourceChange, onFuel, onClusterChange) {
+ const active = document.activeElement;
+ if (
+ active &&
+ active.tagName === "SELECT" &&
+ this.drillContainer.contains(active)
+ ) {
+ this.refreshDrillStatuses();
+ return;
+ }
+
this.drillContainer.innerHTML = "";
if (!this.gameState.drills.length) {
const hint = document.createElement("p");
@@ -95,6 +105,7 @@ export class UIController {
for (const drill of this.gameState.drills) {
const card = document.createElement("div");
card.className = "machine-card";
+ card.dataset.id = drill.id;
card.innerHTML = `