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

Factory Launch

+

Research. Automate. Launch.

+
+
+
+
+

Launch Site

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

Research Node

+
+ +
+
+

Player Inventory

+
+
+
+

Storage Chests

+
+
+
+

Machines

+
+
+
+
+

Event Log

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