diff --git a/README.md b/README.md
index 4f693b3..b3e4fd4 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 now renders a grid-based launch site where you click deposits to gather materials, place machines, contribute to research, and refuel your production line.
+- **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** – Click resource deposits on the grid 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** – Select the drill tool from the build toolbar and place drills directly on deposits to automate mining, then keep them fueled with coal.
+4. **Stone Smelter** – Place the smelter on open ground, switch between iron and copper plate recipes, and keep it 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..926f499
--- /dev/null
+++ b/css/styles.css
@@ -0,0 +1,345 @@
+:root {
+ --bg: #0e1016;
+ --bg-panel: rgba(23, 27, 38, 0.95);
+ --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;
+}
+
+img {
+ max-width: 100%;
+ display: block;
+}
+
+.app-header,
+.app-footer {
+ text-align: center;
+ padding: 1.5rem 1rem;
+ background: rgba(15, 19, 29, 0.82);
+ backdrop-filter: blur(8px);
+ 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;
+ margin-top: auto;
+}
+
+.tagline {
+ margin: 0.35rem 0 0;
+ color: var(--muted);
+ font-size: 1rem;
+}
+
+.layout {
+ flex: 1;
+ display: grid;
+ gap: 1.25rem;
+ padding: 1.5rem;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ align-content: start;
+}
+
+.panel {
+ background: var(--bg-panel);
+ border-radius: 14px;
+ padding: 1.2rem 1.35rem 1.6rem;
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ min-height: 0;
+}
+
+.panel h2 {
+ margin: 0;
+ font-size: 1.35rem;
+ letter-spacing: 0.02em;
+}
+
+.panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+}
+
+.world-panel {
+ grid-column: span 2;
+}
+
+@media (max-width: 960px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
+ .world-panel {
+ grid-column: span 1;
+ }
+}
+
+button {
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: var(--text);
+ border-radius: 10px;
+ padding: 0.55rem 0.85rem;
+ cursor: pointer;
+ transition: transform 0.15s ease, background 0.2s ease, border 0.2s ease;
+ font: inherit;
+}
+
+button:hover:not(:disabled) {
+ transform: translateY(-1px);
+ background: rgba(255, 255, 255, 0.14);
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+button.primary {
+ background: var(--accent);
+ border-color: var(--accent-dark);
+ color: #1b1e27;
+ font-weight: 600;
+}
+
+.build-button {
+ display: grid;
+ gap: 0.25rem;
+ align-items: center;
+ text-align: left;
+ min-width: 9.5rem;
+}
+
+.build-button.active {
+ border-color: var(--accent);
+ background: rgba(255, 174, 66, 0.2);
+}
+
+.build-button--cancel {
+ justify-self: flex-end;
+}
+
+.build-label {
+ font-weight: 600;
+}
+
+.build-count {
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.world-toolbar {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 0.75rem;
+ align-items: stretch;
+}
+
+.world-grid {
+ display: grid;
+ grid-template-columns: repeat(12, minmax(52px, 1fr));
+ grid-auto-rows: minmax(52px, 1fr);
+ gap: 0.65rem;
+ background: rgba(12, 15, 23, 0.7);
+ padding: 0.85rem;
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+}
+
+.world-tile {
+ position: relative;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(255, 255, 255, 0.04);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 1 / 1;
+ min-height: 52px;
+ overflow: hidden;
+ padding: 0.35rem;
+ transition: border 0.2s ease, transform 0.15s ease;
+ color: var(--text);
+ font: inherit;
+}
+
+.world-tile::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
+ pointer-events: none;
+}
+
+.world-tile img {
+ width: 72%;
+ pointer-events: none;
+}
+
+.world-tile:hover:not(:disabled) {
+ border-color: rgba(255, 255, 255, 0.2);
+ transform: translateY(-1px);
+}
+
+.world-tile .tile-label {
+ position: absolute;
+ bottom: 0.35rem;
+ left: 0.4rem;
+ right: 0.4rem;
+ padding: 0.15rem 0.35rem;
+ border-radius: 6px;
+ background: rgba(10, 13, 19, 0.8);
+ font-size: 0.65rem;
+ text-align: center;
+}
+
+.world-tile--eligible {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(255, 174, 66, 0.3);
+}
+
+.world-tile:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.tile-info {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 10px;
+ padding: 0.75rem;
+ min-height: 3rem;
+}
+
+.inventory-grid,
+.storage-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
+ gap: 0.75rem;
+}
+
+.inventory-item,
+.storage-item {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 12px;
+ padding: 0.75rem;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ display: grid;
+ gap: 0.45rem;
+}
+
+.inventory-title {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-weight: 600;
+}
+
+.inventory-title img {
+ width: 28px;
+ height: 28px;
+}
+
+.meta {
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.machine-card {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ padding: 0.9rem;
+ display: grid;
+ gap: 0.65rem;
+}
+
+.machine-card header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.machine-card select {
+ width: 100%;
+ padding: 0.4rem 0.5rem;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ background: rgba(0, 0, 0, 0.25);
+ color: var(--text);
+}
+
+.machine-card .status {
+ font-size: 0.9rem;
+ color: var(--muted);
+ line-height: 1.4;
+}
+
+.badge {
+ padding: 0.2rem 0.45rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+.research-status {
+ display: grid;
+ gap: 0.6rem;
+ font-size: 0.95rem;
+ background: rgba(255, 255, 255, 0.05);
+ padding: 0.9rem;
+ border-radius: 12px;
+}
+
+.progress {
+ height: 8px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.progress-bar {
+ height: 100%;
+ width: 0;
+ background: linear-gradient(135deg, var(--accent), var(--accent-dark));
+ transition: width 0.3s ease;
+}
+
+.event-log {
+ display: grid;
+ gap: 0.4rem;
+ max-height: 16rem;
+ overflow-y: auto;
+}
+
+.event-log-item {
+ font-size: 0.85rem;
+ padding: 0.5rem 0.6rem;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ color: var(--muted);
+}
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..6ed193d
--- /dev/null
+++ b/factory_launch/game_state.py
@@ -0,0 +1,142 @@
+"""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:
+ """Create empty starting storage chests."""
+
+ self.storage_chests.append(StorageChest())
+
+ 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/img/coal.svg b/img/coal.svg
new file mode 100644
index 0000000..a9154d1
--- /dev/null
+++ b/img/coal.svg
@@ -0,0 +1,5 @@
+
diff --git a/img/copper.svg b/img/copper.svg
new file mode 100644
index 0000000..be7dbd0
--- /dev/null
+++ b/img/copper.svg
@@ -0,0 +1,5 @@
+
diff --git a/img/drill.svg b/img/drill.svg
new file mode 100644
index 0000000..b613b0e
--- /dev/null
+++ b/img/drill.svg
@@ -0,0 +1,9 @@
+
diff --git a/img/iron.svg b/img/iron.svg
new file mode 100644
index 0000000..f58c654
--- /dev/null
+++ b/img/iron.svg
@@ -0,0 +1,5 @@
+
diff --git a/img/smelter.svg b/img/smelter.svg
new file mode 100644
index 0000000..d179907
--- /dev/null
+++ b/img/smelter.svg
@@ -0,0 +1,9 @@
+
diff --git a/img/stone.svg b/img/stone.svg
new file mode 100644
index 0000000..5e7ff72
--- /dev/null
+++ b/img/stone.svg
@@ -0,0 +1,5 @@
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..462afb1
--- /dev/null
+++ b/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ Factory Launch Prototype
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select a tile to see details.
+
+
+ Research Node
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/constants.js b/js/constants.js
new file mode 100644
index 0000000..377cb29
--- /dev/null
+++ b/js/constants.js
@@ -0,0 +1,56 @@
+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 RESOURCE_IMAGES = Object.freeze({
+ stone: "img/stone.svg",
+ coal: "img/coal.svg",
+ iron: "img/iron.svg",
+ copper: "img/copper.svg",
+ ironPlates: "img/iron.svg",
+ copperPlates: "img/copper.svg"
+});
+
+export const MACHINE_IMAGES = Object.freeze({
+ drill: "img/drill.svg",
+ smelter: "img/smelter.svg"
+});
+
+export const WORLD_DIMENSIONS = Object.freeze({ width: 12, height: 8 });
+
+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..3330cd0
--- /dev/null
+++ b/js/gameState.js
@@ -0,0 +1,237 @@
+import {
+ RESOURCE_LIST,
+ RESOURCE_LABELS,
+ RESEARCH_REQUIREMENTS,
+ RESEARCH_REWARD
+} from "./constants.js";
+import { CoalDrill, StoneSmelter } from "./machines.js";
+import { StorageManager } from "./storage.js";
+import { World, TILE_KIND } from "./world.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.world = new World();
+ 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) {
+ if (amount <= 0) {
+ return;
+ }
+ let remaining = this.storage.playerInventory.add(resource, amount);
+ const storedInInventory = amount - remaining;
+ if (storedInInventory > 0) {
+ this.resources[resource] += storedInInventory;
+ }
+ if (remaining > 0) {
+ const leftover = this.storage.depositToChests(resource, remaining);
+ const storedInChests = remaining - leftover;
+ const label = RESOURCE_LABELS[resource] ?? resource;
+ if (storedInChests > 0) {
+ this.addLog(`Inventory full. Routed ${storedInChests} ${label} to storage.`);
+ }
+ if (leftover > 0) {
+ this.addLog(`No space for ${leftover} ${label}.`);
+ }
+ }
+ }
+
+ 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) {
+ let remaining = this.storage.playerInventory.add(resource, amount);
+ const gained = amount - remaining;
+ const label = RESOURCE_LABELS[resource] ?? resource;
+ if (gained > 0) {
+ this.resources[resource] += gained;
+ this.addLog(`Gathered ${gained} ${label}.`);
+ } else {
+ this.addLog(`Inventory full. Unable to carry ${label}.`);
+ }
+ if (remaining > 0) {
+ const leftover = this.storage.depositToChests(resource, remaining);
+ const storedInChests = remaining - leftover;
+ if (storedInChests > 0) {
+ this.addLog(`Routed ${storedInChests} ${label} to storage.`);
+ }
+ if (leftover > 0) {
+ this.addLog(`Storage full. Lost ${leftover} ${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());
+ }
+ this.addLog(
+ `Received ${RESEARCH_REWARD.rewardDrills} coal drills and ${RESEARCH_REWARD.rewardSmelters} smelter${
+ RESEARCH_REWARD.rewardSmelters === 1 ? "" : "s"
+ }.`
+ );
+ }
+
+ 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);
+ }
+ }
+
+ unplacedDrills() {
+ return this.drills.filter((drill) => !drill.position);
+ }
+
+ unplacedSmelters() {
+ return this.smelters.filter((smelter) => !smelter.position);
+ }
+
+ getMachineAt(x, y) {
+ const tile = this.world.getTile(x, y);
+ if (!tile || tile.kind !== TILE_KIND.MACHINE) return null;
+ return tile.machine;
+ }
+
+ gatherFromTile(x, y) {
+ const resource = this.world.gatherResource(x, y);
+ if (!resource) {
+ return false;
+ }
+ this.gather(resource, 1);
+ return true;
+ }
+
+ placeDrillAt(x, y) {
+ if (!this.research.completed) {
+ this.addLog("Research the starter tech to place drills.");
+ return false;
+ }
+ const available = this.drills.find((drill) => !drill.position);
+ if (!available) {
+ this.addLog("No available drills to place.");
+ return false;
+ }
+ if (!this.world.canPlaceDrill(x, y)) {
+ this.addLog("Drills must be placed on a resource deposit.");
+ return false;
+ }
+ const tile = this.world.getTile(x, y);
+ available.setResource(tile.resource);
+ available.setClusterSize(1);
+ if (this.world.placeMachine(available, x, y)) {
+ this.addLog(`Placed a coal drill on ${RESOURCE_LABELS[tile.resource]}.`);
+ return true;
+ }
+ return false;
+ }
+
+ placeSmelterAt(x, y) {
+ if (!this.research.completed) {
+ this.addLog("Research the starter tech to place smelters.");
+ return false;
+ }
+ const available = this.smelters.find((smelter) => !smelter.position);
+ if (!available) {
+ this.addLog("No available smelters to place.");
+ return false;
+ }
+ if (!this.world.canPlaceSmelter(x, y)) {
+ this.addLog("That tile is already occupied.");
+ return false;
+ }
+ if (this.world.placeMachine(available, x, y)) {
+ this.addLog("Placed a stone smelter.");
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/js/machines.js b/js/machines.js
new file mode 100644
index 0000000..6770af4
--- /dev/null
+++ b/js/machines.js
@@ -0,0 +1,105 @@
+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, position = null } = {}) {
+ this.id = `drill-${nextMachineId++}`;
+ this.resource = resource;
+ this.clusterSize = clusterSize;
+ this.fuel = 0;
+ this.outputBuffer = 0;
+ this.position = position;
+ }
+
+ addFuel(amount) {
+ this.fuel += amount;
+ }
+
+ setResource(resource) {
+ this.resource = resource;
+ }
+
+ setClusterSize(size) {
+ this.clusterSize = size;
+ }
+
+ get active() {
+ return this.fuel > 0 && Boolean(this.position);
+ }
+
+ 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", position = null } = {}) {
+ this.id = `smelter-${nextMachineId++}`;
+ this.recipe = recipe; // "iron" | "copper"
+ this.fuel = 0;
+ this.progress = 0;
+ this.position = position;
+ }
+
+ addFuel(amount) {
+ this.fuel += amount;
+ }
+
+ setRecipe(recipe) {
+ this.recipe = recipe;
+ }
+
+ get active() {
+ return this.fuel > 0 && Boolean(this.position);
+ }
+
+ 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..be0ec7c
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,130 @@
+import { GameState } from "./gameState.js";
+import { UIController } from "./ui.js";
+
+const gameState = new GameState();
+const ui = new UIController(gameState);
+let placementMode = null;
+
+function getBuildCounts() {
+ return {
+ drills: gameState.unplacedDrills().length,
+ smelters: gameState.unplacedSmelters().length
+ };
+}
+
+function updatePlacementMode(mode) {
+ placementMode = mode;
+ ui.updatePlacementButtons(mode);
+ const counts = getBuildCounts();
+ ui.renderPlacementIndicator(mode, counts);
+ ui.renderBuildCounts(counts);
+ ui.renderWorld({ placementMode: mode });
+}
+
+function refreshInterface() {
+ const counts = getBuildCounts();
+ ui.renderWorld({ placementMode });
+ ui.renderInventory();
+ ui.renderStorage();
+ ui.renderResearch();
+ ui.renderDrills(handleDrillFuel);
+ ui.renderSmelters(handleSmelterRecipeChange, handleSmelterFuel);
+ ui.renderBuildCounts(counts);
+ ui.renderPlacementIndicator(placementMode, counts);
+ ui.updatePlacementButtons(placementMode);
+ ui.renderLog(gameState.log);
+}
+
+function handleTileClick(x, y) {
+ let acted = false;
+ if (placementMode === "drill") {
+ acted = gameState.placeDrillAt(x, y);
+ if (acted && gameState.unplacedDrills().length === 0) {
+ updatePlacementMode(null);
+ }
+ } else if (placementMode === "smelter") {
+ acted = gameState.placeSmelterAt(x, y);
+ if (acted && gameState.unplacedSmelters().length === 0) {
+ updatePlacementMode(null);
+ }
+ } else {
+ acted = gameState.gatherFromTile(x, y);
+ }
+
+ const tile = gameState.world.getTile(x, y);
+ ui.showTileInfo(tile);
+
+ if (acted) {
+ refreshInterface();
+ } else {
+ const counts = getBuildCounts();
+ ui.renderPlacementIndicator(placementMode, counts);
+ ui.renderBuildCounts(counts);
+ ui.renderWorld({ placementMode });
+ }
+}
+
+function handleSelectBuild(mode) {
+ if (placementMode === mode) {
+ updatePlacementMode(null);
+ return;
+ }
+ if (mode === "drill" && gameState.unplacedDrills().length === 0) {
+ return;
+ }
+ if (mode === "smelter" && gameState.unplacedSmelters().length === 0) {
+ return;
+ }
+ updatePlacementMode(mode);
+}
+
+function handleCancelPlacement() {
+ updatePlacementMode(null);
+}
+
+function handleDrillFuel(id) {
+ const drill = gameState.drills.find((d) => d.id === id);
+ if (!drill) return;
+ if (gameState.addFuelToMachine(drill, 10)) {
+ refreshInterface();
+ }
+}
+
+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)) {
+ refreshInterface();
+ }
+}
+
+ui.bindWorldInteractions({
+ onTileClick: handleTileClick,
+ onSelectBuild: handleSelectBuild,
+ onCancel: handleCancelPlacement
+});
+
+document.getElementById("contribute-research").addEventListener("click", () => {
+ if (gameState.contributeToResearch()) {
+ refreshInterface();
+ } else {
+ ui.renderLog(gameState.log);
+ }
+});
+
+function tick() {
+ gameState.tick(1);
+ refreshInterface();
+ setTimeout(tick, 1000);
+}
+
+refreshInterface();
+setTimeout(tick, 1000);
diff --git a/js/storage.js b/js/storage.js
new file mode 100644
index 0000000..2138f21
--- /dev/null
+++ b/js/storage.js
@@ -0,0 +1,86 @@
+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) {
+ if (amount <= 0) return 0;
+ const current = this.get(resource);
+ const capacity = this.stackLimit;
+ const space = Math.max(0, capacity - current);
+ const toStore = Math.min(space, amount);
+ if (toStore > 0) {
+ this.items.set(resource, current + toStore);
+ }
+ return amount - toStore;
+ }
+
+ 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)];
+ }
+
+ 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;
+ remaining = chest.add(resource, remaining);
+ }
+ 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..bb758b8
--- /dev/null
+++ b/js/ui.js
@@ -0,0 +1,364 @@
+import {
+ RESOURCE_LIST,
+ RESOURCE_LABELS,
+ RESEARCH_REQUIREMENTS,
+ RESOURCE_IMAGES,
+ MACHINE_IMAGES
+} from "./constants.js";
+import { TILE_KIND } from "./world.js";
+
+function createImageElement(src, alt) {
+ const img = document.createElement("img");
+ img.src = src;
+ img.alt = alt;
+ img.draggable = false;
+ return img;
+}
+
+export class UIController {
+ constructor(gameState) {
+ this.gameState = gameState;
+ this.worldGrid = document.getElementById("world-grid");
+ 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");
+ this.placementIndicator = document.getElementById("placement-indicator");
+ this.tileInfo = document.getElementById("tile-info");
+ this.buildButtons = Array.from(document.querySelectorAll("[data-build]"));
+ this.cancelBuildButton = document.getElementById("cancel-placement");
+ }
+
+ bindWorldInteractions({ onTileClick, onSelectBuild, onCancel }) {
+ if (this.worldGrid) {
+ this.worldGrid.addEventListener("click", (event) => {
+ const target = event.target.closest("[data-x]");
+ if (!target) return;
+ const x = Number(target.dataset.x);
+ const y = Number(target.dataset.y);
+ onTileClick(x, y);
+ });
+ }
+ this.buildButtons.forEach((button) => {
+ button.addEventListener("click", () => {
+ onSelectBuild(button.dataset.build);
+ });
+ });
+ if (this.cancelBuildButton) {
+ this.cancelBuildButton.addEventListener("click", () => onCancel());
+ }
+ }
+
+ updatePlacementButtons(mode) {
+ this.buildButtons.forEach((button) => {
+ button.classList.toggle("active", button.dataset.build === mode);
+ });
+ if (this.cancelBuildButton) {
+ this.cancelBuildButton.disabled = !mode;
+ }
+ }
+
+ renderWorld({ placementMode }) {
+ if (!this.worldGrid) return;
+ const { width, height } = this.gameState.world;
+ this.worldGrid.style.gridTemplateColumns = `repeat(${width}, minmax(52px, 1fr))`;
+ this.worldGrid.style.gridTemplateRows = `repeat(${height}, minmax(52px, 1fr))`;
+ this.worldGrid.innerHTML = "";
+
+ for (const tile of this.gameState.world.tiles()) {
+ const cell = document.createElement("button");
+ cell.type = "button";
+ cell.className = `world-tile world-tile--${tile.kind}`;
+ cell.dataset.x = tile.x;
+ cell.dataset.y = tile.y;
+ cell.setAttribute("aria-label", this.describeTile(tile));
+ cell.setAttribute("role", "gridcell");
+
+ if (tile.kind === TILE_KIND.DEPOSIT) {
+ const img = createImageElement(
+ RESOURCE_IMAGES[tile.resource],
+ RESOURCE_LABELS[tile.resource]
+ );
+ cell.appendChild(img);
+ const label = document.createElement("span");
+ label.className = "tile-label";
+ label.textContent = RESOURCE_LABELS[tile.resource];
+ cell.appendChild(label);
+ if (placementMode === "drill") {
+ cell.classList.add("world-tile--eligible");
+ }
+ } else if (tile.kind === TILE_KIND.MACHINE) {
+ const machine = tile.machine;
+ const type = machine.id.startsWith("drill") ? "drill" : "smelter";
+ const imageSrc = type === "drill" ? MACHINE_IMAGES.drill : MACHINE_IMAGES.smelter;
+ const img = createImageElement(imageSrc, type === "drill" ? "Coal Drill" : "Stone Smelter");
+ cell.appendChild(img);
+ const label = document.createElement("span");
+ label.className = "tile-label";
+ label.textContent = type === "drill" ? "Drill" : "Smelter";
+ cell.appendChild(label);
+ } else if (placementMode === "smelter") {
+ cell.classList.add("world-tile--eligible");
+ }
+
+ this.worldGrid.appendChild(cell);
+ }
+ }
+
+ describeTile(tile) {
+ if (!tile) return "Unknown";
+ if (tile.kind === TILE_KIND.DEPOSIT) {
+ return `${RESOURCE_LABELS[tile.resource]} deposit at ${tile.x}, ${tile.y}`;
+ }
+ if (tile.kind === TILE_KIND.MACHINE) {
+ const machine = tile.machine;
+ return `${machine.id} at ${tile.x}, ${tile.y}`;
+ }
+ return `Empty tile at ${tile.x}, ${tile.y}`;
+ }
+
+ renderPlacementIndicator(mode, { drills, smelters }) {
+ if (!this.placementIndicator) return;
+ if (!mode) {
+ this.placementIndicator.textContent = "Select a machine to place.";
+ return;
+ }
+ const label = mode === "drill" ? "drill" : "smelter";
+ const remaining = mode === "drill" ? drills : smelters;
+ this.placementIndicator.textContent = `Placing ${label}s — ${remaining} available`;
+ }
+
+ renderBuildCounts({ drills, smelters }) {
+ this.buildButtons.forEach((button) => {
+ const target = button.querySelector("[data-count]");
+ if (!target) return;
+ if (button.dataset.build === "drill") {
+ target.textContent = drills;
+ button.disabled = drills === 0;
+ } else if (button.dataset.build === "smelter") {
+ target.textContent = smelters;
+ button.disabled = smelters === 0;
+ }
+ });
+ }
+
+ showTileInfo(tile) {
+ if (!this.tileInfo) return;
+ this.tileInfo.innerHTML = "";
+ if (!tile) {
+ this.tileInfo.textContent = "Select a tile to see details.";
+ return;
+ }
+ if (tile.kind === TILE_KIND.DEPOSIT) {
+ this.tileInfo.innerHTML = `
+ ${RESOURCE_LABELS[tile.resource]} Deposit
+ Click to gather resources manually or place a drill for automation.
+ `;
+ return;
+ }
+ if (tile.kind === TILE_KIND.MACHINE) {
+ const machine = tile.machine;
+ const type = machine.id.startsWith("drill") ? "drill" : "smelter";
+ const container = document.createElement("div");
+ const header = document.createElement("h3");
+ header.textContent = machine.id;
+ const coords = document.createElement("p");
+ coords.className = "meta";
+ coords.textContent = `Location: (${tile.x}, ${tile.y})`;
+ const status = document.createElement("p");
+ status.className = "meta";
+ status.textContent = `Fuel: ${machine.fuel.toFixed(1)}`;
+ container.append(header, coords, status);
+ if (type === "drill" && tile.resource) {
+ const mining = document.createElement("p");
+ mining.className = "meta";
+ mining.textContent = `Mining ${RESOURCE_LABELS[tile.resource] ?? tile.resource}`;
+ container.append(mining);
+ }
+ this.tileInfo.append(container);
+ return;
+ }
+ this.tileInfo.textContent = "Empty ground. Place a smelter here after research.";
+ }
+
+ 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";
+ const title = document.createElement("div");
+ title.className = "inventory-title";
+ const img = createImageElement(RESOURCE_IMAGES[resource], RESOURCE_LABELS[resource] ?? resource);
+ title.appendChild(img);
+ const text = document.createElement("span");
+ text.textContent = RESOURCE_LABELS[resource] ?? resource;
+ title.appendChild(text);
+ const meta = document.createElement("div");
+ meta.className = "meta";
+ meta.textContent = `${amount} items`;
+ item.append(title, meta);
+ 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 header = document.createElement("h3");
+ header.textContent = `Chest ${index + 1}`;
+ const contents = document.createElement("div");
+ contents.className = "meta";
+ const parts = [];
+ for (const resource of RESOURCE_LIST) {
+ const amount = chest.get(resource);
+ if (amount > 0) {
+ parts.push(`${RESOURCE_LABELS[resource] ?? resource}: ${amount}`);
+ }
+ }
+ contents.textContent = parts.join(" · ") || "Empty";
+ card.append(header, contents);
+ 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}`;
+ const contributeButton = document.getElementById("contribute-research");
+ if (contributeButton) {
+ contributeButton.disabled = status.completed;
+ }
+ }
+
+ renderDrills(onFuel) {
+ this.drillContainer.innerHTML = "";
+ if (!this.gameState.drills.length) {
+ const hint = document.createElement("p");
+ hint.className = "meta";
+ hint.textContent = "Complete research to receive drills.";
+ this.drillContainer.appendChild(hint);
+ return;
+ }
+
+ const unplaced = this.gameState.unplacedDrills();
+ if (unplaced.length) {
+ const notice = document.createElement("p");
+ notice.className = "meta";
+ notice.textContent = `${unplaced.length} drill${unplaced.length > 1 ? "s" : ""} awaiting placement.`;
+ this.drillContainer.appendChild(notice);
+ }
+
+ const placed = this.gameState.drills.filter((drill) => drill.position);
+ placed.forEach((drill) => {
+ const card = document.createElement("div");
+ card.className = "machine-card";
+ card.innerHTML = `
+
+ ${drill.id}
+ ${drill.position.x},${drill.position.y}
+
+
+ Mining ${RESOURCE_LABELS[drill.resource] ?? drill.resource}
+ Fuel: ${drill.fuel.toFixed(1)}
+
+
+ `;
+ this.drillContainer.appendChild(card);
+ });
+
+ this.drillContainer.querySelectorAll("button[data-role='fuel']").forEach((button) => {
+ button.addEventListener("click", (event) => onFuel(event.currentTarget.dataset.id));
+ });
+ }
+
+ renderSmelters(onRecipeChange, onFuel) {
+ this.smelterContainer.innerHTML = "";
+ if (!this.gameState.smelters.length) {
+ const hint = document.createElement("p");
+ hint.className = "meta";
+ hint.textContent = "Complete research to receive a smelter.";
+ this.smelterContainer.appendChild(hint);
+ return;
+ }
+
+ const unplaced = this.gameState.unplacedSmelters();
+ if (unplaced.length) {
+ const notice = document.createElement("p");
+ notice.className = "meta";
+ notice.textContent = `${unplaced.length} smelter${unplaced.length > 1 ? "s" : ""} awaiting placement.`;
+ this.smelterContainer.appendChild(notice);
+ }
+
+ const placed = this.gameState.smelters.filter((smelter) => smelter.position);
+ placed.forEach((smelter) => {
+ const card = document.createElement("div");
+ card.className = "machine-card";
+ card.dataset.id = smelter.id;
+ card.innerHTML = `
+
+ ${smelter.id}
+ ${smelter.position.x},${smelter.position.y}
+
+
+
+ Fuel: ${smelter.fuel.toFixed(1)}
+
+
+ `;
+ this.smelterContainer.appendChild(card);
+ });
+
+ this.smelterContainer.querySelectorAll("select[data-role='recipe']").forEach((select) => {
+ select.addEventListener("change", (event) => onRecipeChange(event.currentTarget.dataset.id, event.currentTarget.value));
+ });
+ this.smelterContainer.querySelectorAll("button[data-role='fuel']").forEach((button) => {
+ button.addEventListener("click", (event) => onFuel(event.currentTarget.dataset.id));
+ });
+ }
+
+ renderLog(log) {
+ this.logContainer.innerHTML = "";
+ log.slice(0, 8).forEach((entry) => {
+ const item = document.createElement("div");
+ item.className = "event-log-item";
+ item.textContent = `t+${entry.time.toFixed(0)}s — ${entry.message}`;
+ this.logContainer.appendChild(item);
+ });
+ }
+}
diff --git a/js/world.js b/js/world.js
new file mode 100644
index 0000000..6ae8ab2
--- /dev/null
+++ b/js/world.js
@@ -0,0 +1,118 @@
+import { RESOURCE_LIST, WORLD_DIMENSIONS } from "./constants.js";
+
+export const TILE_KIND = Object.freeze({
+ EMPTY: "empty",
+ DEPOSIT: "deposit",
+ MACHINE: "machine"
+});
+
+const RESOURCE_PRESETS = [
+ { resource: "stone", positions: [
+ [1, 1], [2, 1], [1, 2], [2, 2],
+ [1, 4], [2, 4]
+ ] },
+ { resource: "coal", positions: [
+ [5, 1], [6, 1], [5, 2], [6, 2],
+ [5, 4], [6, 4]
+ ] },
+ { resource: "iron", positions: [
+ [9, 2], [10, 2], [9, 3], [10, 3]
+ ] },
+ { resource: "copper", positions: [
+ [8, 5], [9, 5], [8, 6], [9, 6]
+ ] }
+];
+
+function createEmptyTile() {
+ return { kind: TILE_KIND.EMPTY };
+}
+
+function createDepositTile(resource) {
+ return { kind: TILE_KIND.DEPOSIT, resource };
+}
+
+function createMachineTile(machine, resource) {
+ return {
+ kind: TILE_KIND.MACHINE,
+ machine,
+ resource: resource ?? null
+ };
+}
+
+export class World {
+ constructor(width = WORLD_DIMENSIONS.width, height = WORLD_DIMENSIONS.height) {
+ this.width = width;
+ this.height = height;
+ this.grid = Array.from({ length: height }, () =>
+ Array.from({ length: width }, () => createEmptyTile())
+ );
+
+ for (const preset of RESOURCE_PRESETS) {
+ for (const [x, y] of preset.positions) {
+ if (x < this.width && y < this.height) {
+ this.grid[y][x] = createDepositTile(preset.resource);
+ }
+ }
+ }
+ }
+
+ inBounds(x, y) {
+ return x >= 0 && x < this.width && y >= 0 && y < this.height;
+ }
+
+ getTile(x, y) {
+ if (!this.inBounds(x, y)) return null;
+ return this.grid[y][x];
+ }
+
+ gatherResource(x, y) {
+ const tile = this.getTile(x, y);
+ if (!tile || tile.kind !== TILE_KIND.DEPOSIT) {
+ return null;
+ }
+ return tile.resource;
+ }
+
+ canPlaceDrill(x, y) {
+ const tile = this.getTile(x, y);
+ return Boolean(tile && tile.kind === TILE_KIND.DEPOSIT);
+ }
+
+ canPlaceSmelter(x, y) {
+ const tile = this.getTile(x, y);
+ return Boolean(tile && tile.kind !== TILE_KIND.MACHINE);
+ }
+
+ placeMachine(machine, x, y) {
+ const tile = this.getTile(x, y);
+ if (!tile) return false;
+ if (tile.kind === TILE_KIND.MACHINE) return false;
+
+ machine.position = { x, y };
+ const resource = tile.kind === TILE_KIND.DEPOSIT ? tile.resource : null;
+ this.grid[y][x] = createMachineTile(machine, resource);
+ return true;
+ }
+
+ removeMachine(x, y) {
+ const tile = this.getTile(x, y);
+ if (!tile || tile.kind !== TILE_KIND.MACHINE) return null;
+ const machine = tile.machine;
+ tile.machine.position = null;
+ const fallback = tile.resource && RESOURCE_LIST.includes(tile.resource)
+ ? createDepositTile(tile.resource)
+ : createEmptyTile();
+ this.grid[y][x] = fallback;
+ return machine;
+ }
+
+ tiles() {
+ const result = [];
+ for (let y = 0; y < this.height; y += 1) {
+ for (let x = 0; x < this.width; x += 1) {
+ result.push({ x, y, ...this.grid[y][x] });
+ }
+ }
+ return result;
+ }
+}
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..9603248
--- /dev/null
+++ b/tests/test_game_state.py
@@ -0,0 +1,106 @@
+import json
+import math
+import subprocess
+from pathlib import Path
+
+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]
+ assert chest.items == {}
+
+
+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
+
+
+def test_research_requires_gathering_before_starting():
+ state = GameState()
+ assert not state.start_research("starter")
+
+
+def test_browser_state_starts_empty_and_locked():
+ repo_root = Path(__file__).resolve().parents[1]
+ script = """
+import { GameState } from './js/gameState.js';
+const state = new GameState();
+const summary = {
+ resources: state.resources,
+ storage: state.storage.toJSON(),
+ research: state.research,
+ contributed: state.contributeToResearch()
+};
+console.log(JSON.stringify(summary));
+"""
+ result = subprocess.run(
+ ["node", "--input-type=module", "-e", script],
+ cwd=repo_root,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ data = json.loads(result.stdout.strip())
+
+ assert data["contributed"] is False
+ assert all(value == 0 for value in data["resources"].values())
+ for chest in data["storage"]["chests"]:
+ assert all(value == 0 for value in chest.values())
+ assert all(value == 0 for value in data["research"]["progress"].values())
+ assert data["research"]["completed"] is False