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 + + + +
+

Factory Launch

+

Research. Automate. Launch.

+
+
+
+

Research Node

+
+ +
+
+

Player Inventory

+
+
+

Gather Resources

+
+ + + + +
+
+
+
+

Storage Chests

+
+
+
+

Machines

+
+
+
+
+

Event Log

+ +
+
+ + + + 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 = `

Coal Drill

@@ -139,6 +150,16 @@ export class UIController { } renderSmelters(onRecipeChange, onFuel) { + const active = document.activeElement; + if ( + active && + active.tagName === "SELECT" && + this.smelterContainer.contains(active) + ) { + this.refreshSmelterStatuses(); + return; + } + this.smelterContainer.innerHTML = ""; if (!this.gameState.smelters.length) { const hint = document.createElement("p"); @@ -151,6 +172,7 @@ export class UIController { for (const smelter of this.gameState.smelters) { const card = document.createElement("div"); card.className = "machine-card"; + card.dataset.id = smelter.id; card.innerHTML = `

Stone Smelter

@@ -181,6 +203,32 @@ export class UIController { }); } + refreshDrillStatuses() { + this.drillContainer.querySelectorAll(".machine-card").forEach((card) => { + const id = card.dataset.id; + const drill = this.gameState.drills.find((d) => d.id === id); + if (!drill) return; + const status = card.querySelector(".status span"); + if (status) { + status.className = drill.active ? "active" : "inactive"; + status.textContent = `${drill.active ? "Active" : "Idle"} — Fuel ${drill.fuel.toFixed(1)}`; + } + }); + } + + refreshSmelterStatuses() { + this.smelterContainer.querySelectorAll(".machine-card").forEach((card) => { + const id = card.dataset.id; + const smelter = this.gameState.smelters.find((s) => s.id === id); + if (!smelter) return; + const status = card.querySelector(".status span"); + if (status) { + status.className = smelter.active ? "active" : "inactive"; + status.textContent = `${smelter.active ? "Active" : "Idle"} — Fuel ${smelter.fuel.toFixed(1)}`; + } + }); + } + renderLog(entries) { this.logContainer.innerHTML = ""; entries.slice(0, 20).forEach((entry) => { diff --git a/tests/test_game_state.py b/tests/test_game_state.py index b8e84bf..11d4ea1 100644 --- a/tests/test_game_state.py +++ b/tests/test_game_state.py @@ -13,10 +13,7 @@ def test_initial_state_has_resources_and_machines(): 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 + assert chest.items == {} def test_drill_output_scales_with_cluster(): From ccbe6ba33196365dcb1df8a9ae7f65d354159291 Mon Sep 17 00:00:00 2001 From: highvoltaage <138823657+highvoltaage@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:27:58 -0400 Subject: [PATCH 3/5] Ensure empty start state and enforce storage limits --- js/gameState.js | 41 ++++++++++++++++++++++++++++++++++------ js/storage.js | 13 +++++++++---- tests/test_game_state.py | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 10 deletions(-) diff --git a/js/gameState.js b/js/gameState.js index 8184f71..6ca12e3 100644 --- a/js/gameState.js +++ b/js/gameState.js @@ -40,8 +40,25 @@ export class GameState { } depositToPlayer(resource, amount) { - this.resources[resource] += amount; - this.storage.playerInventory.add(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) { @@ -62,12 +79,24 @@ export class GameState { } gather(resource, amount = 1) { - const leftover = this.storage.playerInventory.add(resource, amount); - const gained = amount - leftover; - this.resources[resource] += gained; + let remaining = this.storage.playerInventory.add(resource, amount); + const gained = amount - remaining; + const label = RESOURCE_LABELS[resource] ?? resource; if (gained > 0) { - const label = RESOURCE_LABELS[resource] ?? resource; + 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}.`); + } } } diff --git a/js/storage.js b/js/storage.js index fa3bee3..2138f21 100644 --- a/js/storage.js +++ b/js/storage.js @@ -15,9 +15,15 @@ class Inventory { } add(resource, amount) { + if (amount <= 0) return 0; const current = this.get(resource); - this.items.set(resource, current + amount); - return 0; + 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) { @@ -62,8 +68,7 @@ export class StorageManager { let remaining = amount; for (const chest of this.chests) { if (remaining <= 0) break; - chest.add(resource, remaining); - remaining = 0; + remaining = chest.add(resource, remaining); } return remaining; } diff --git a/tests/test_game_state.py b/tests/test_game_state.py index 11d4ea1..9603248 100644 --- a/tests/test_game_state.py +++ b/tests/test_game_state.py @@ -1,4 +1,7 @@ +import json import math +import subprocess +from pathlib import Path from factory_launch import GameState from factory_launch.constants import ( @@ -66,3 +69,38 @@ def test_inventory_stack_limit(): 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 From 0d6844e447e8d49edaa67147ec65bf7a1d774bb1 Mon Sep 17 00:00:00 2001 From: highvoltaage <138823657+highvoltaage@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:24:54 -0400 Subject: [PATCH 4/5] Transform UI into grid-based builder --- README.md | 8 +- css/styles.css | 284 +++++++++++++++++++++++++++------------ img/coal.svg | 5 + img/copper.svg | 5 + img/drill.svg | 9 ++ img/iron.svg | 5 + img/smelter.svg | 9 ++ img/stone.svg | 5 + index.html | 34 +++-- js/constants.js | 16 +++ js/gameState.js | 75 +++++++++++ js/machines.js | 10 +- js/main.js | 129 ++++++++++++------ js/ui.js | 347 ++++++++++++++++++++++++++++++++---------------- js/world.js | 118 ++++++++++++++++ 15 files changed, 799 insertions(+), 260 deletions(-) create mode 100644 img/coal.svg create mode 100644 img/copper.svg create mode 100644 img/drill.svg create mode 100644 img/iron.svg create mode 100644 img/smelter.svg create mode 100644 img/stone.svg create mode 100644 js/world.js diff --git a/README.md b/README.md index 43a06bd..b3e4fd4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Factory Launch is a research-driven resource management and automation prototype ## 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. +- **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. @@ -21,10 +21,10 @@ python -m http.server 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. +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** – 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. +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 diff --git a/css/styles.css b/css/styles.css index eea033d..4af1f1c 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,6 +1,6 @@ :root { --bg: #0e1016; - --bg-panel: #171b26; + --bg-panel: rgba(23, 27, 38, 0.95); --accent: #ffae42; --accent-dark: #d98b1a; --text: #f4f5f7; @@ -24,18 +24,30 @@ body { 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.8); - backdrop-filter: blur(6px); + 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 { @@ -44,37 +56,65 @@ body { gap: 1.25rem; padding: 1.5rem; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + align-content: start; } .panel { - background: rgba(23, 27, 38, 0.95); - border-radius: 12px; - padding: 1rem 1.25rem 1.5rem; + 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.4rem; + 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: 8px; - padding: 0.5rem 0.75rem; + 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 { +button:hover:not(:disabled) { transform: translateY(-1px); - background: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.14); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; } button.primary { @@ -84,58 +124,147 @@ button.primary { font-weight: 600; } -button.primary:disabled { - background: rgba(255, 255, 255, 0.2); +.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 { + --world-width: 8; + --world-height: 8; + display: grid; + grid-template-columns: repeat(var(--world-width), 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; + overflow: hidden; + padding: 0.35rem; + transition: border 0.2s ease, transform 0.15s ease; + color: var(--text); + font: inherit; +} + +.world-tile img { + width: 72%; + pointer-events: none; +} + +.world-tile:hover:not(:disabled) { border-color: rgba(255, 255, 255, 0.2); - color: rgba(255, 255, 255, 0.6); - cursor: not-allowed; + 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-fill, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.75rem; } .inventory-item, .storage-item { - background: rgba(255, 255, 255, 0.04); - border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; padding: 0.75rem; - display: flex; - flex-direction: column; - gap: 0.35rem; border: 1px solid rgba(255, 255, 255, 0.06); + display: grid; + gap: 0.45rem; } -.inventory-item h3, -.storage-item h3 { - margin: 0; - font-size: 1rem; - text-transform: capitalize; +.inventory-title { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; } -.inventory-item .meta, -.storage-item .meta { - font-size: 0.8rem; - color: var(--muted); +.inventory-title img { + width: 28px; + height: 28px; } -.gather-buttons { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; +.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: 10px; - padding: 0.75rem; - margin-bottom: 0.75rem; + border-radius: 12px; + padding: 0.9rem; display: grid; - gap: 0.5rem; + gap: 0.65rem; } .machine-card header { @@ -144,79 +273,64 @@ button.primary:disabled { align-items: baseline; } -.machine-card h3 { - margin: 0; -} - -.machine-card select, -.machine-card input { +.machine-card select { 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); + 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.85rem; + font-size: 0.9rem; color: var(--muted); + line-height: 1.4; } -.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); +.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.5rem; + gap: 0.6rem; font-size: 0.95rem; - background: rgba(255, 255, 255, 0.04); - padding: 0.75rem; - border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + padding: 0.9rem; + border-radius: 12px; } .progress { height: 8px; - border-radius: 99px; + border-radius: 999px; overflow: hidden; - background: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.1); } .progress-bar { height: 100%; - width: 0%; + 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); +.event-log { + display: grid; + gap: 0.4rem; + max-height: 16rem; + overflow-y: auto; } -@media (max-width: 768px) { - .layout { - grid-template-columns: 1fr; - } +.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/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 index 60d0a70..462afb1 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,27 @@

Factory Launch

Research. Automate. Launch.

+
+
+

Launch Site

+
Select a machine to place.
+
+ +
+
Select a tile to see details.
+

Research Node

@@ -20,19 +41,10 @@

Research Node

Player Inventory

-
-

Gather Resources

-
- - - - -
-

Storage Chests

-
+

Machines

@@ -41,7 +53,7 @@

Machines

Event Log

-
    +