commit 9058039eb7d881d591cd6675199e798549b6f3b5 Author: rope Date: Sun Oct 18 13:48:56 2026 +0100 Guildhall alpha: co-op idle game Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc7d676 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.sqlite3 +result diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4c083a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fredrik Gregersen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..79db06d --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Guildhall + +A co-op idle game for a handful of friends. Everyone is an adventurer in the +same young frontier city: train skills Melvor-style (your character keeps +working for up to a day while you're away), supply the city council, and get +the city ready for bandit raids that arrive at times the game picks. + +Alpha. One season, bandit raids plus two friendly events, six buildings. + +## What's in it + +- **Skills:** mining, woodcutting, farming, hunting, smithing, masonry, + cooking, combat, plus the scout's reconnaissance. +- **Classes:** warrior, scout, artisan, pioneer. Every class can train every + skill; a class makes some skills 20% faster and has one signature ability. +- **Bank:** 12 slots of up to 1,000 each to start. When something doesn't + fit it goes to the city stockpile (or the action stops, if you prefer). + Raids never touch your bank. Players can give items to each other. +- **City:** donations fill the stockpile and treasury; anyone can start a + building once they cover its cost. Population eats food and pays taxes. +- **Events:** raids are announced a day or two ahead. Intel (spymaster's + guild + scout reconnaissance) narrows down when, how strong, and finally + where their camp is, so fighters can sabotage it. Defeat kills or drives + off people, loots the stockpile and treasury, and may damage a building. + Damaged buildings stop working until repaired but keep their level. + +## Running + +```sh +nix run . -- --dev --speed 60 # http://127.0.0.1:8098, time runs 60x +python3 -m unittest discover -s tests +``` + +Without `--dev` the server trusts the `Remote-User` header from the +authenticating proxy (Authelia). With `--dev` it reads a `gh_user` cookie +instead, so the dev bar can switch between players and fast-forward time. + +The world is one JSON document in SQLite. `--backup-to DIR` writes a dated +snapshot and exits; the NixOS module runs it nightly when +`services.guildhall.backup.directory` is set. + +## Layout + +- `game.py`: content tables and the rules. Pure functions of `(world, now)`. +- `app.py`: stdlib HTTP server, persistence and the background ticker. +- `static/`: the client (vanilla JS, no build step). diff --git a/app.py b/app.py new file mode 100644 index 0000000..5a3ce6a --- /dev/null +++ b/app.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +"""Small, dependency-free server for Guildhall, a co-op idle game.""" + +from __future__ import annotations + +import argparse +import json +import logging +import mimetypes +import random +import re +import sqlite3 +import threading +import time +from contextlib import closing +from datetime import date +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import unquote, urlparse + +import game + +APP_DIR = Path(__file__).resolve().parent +STATIC_DIR = APP_DIR / "static" +MAX_BODY = 16 * 1024 +NAME = re.compile(r"^[\w .'-]{2,20}$") +TICK_SECONDS = 15 + +mimetypes.add_type("application/manifest+json", ".webmanifest") + + +class Store: + """The world lives in memory and is written whole to SQLite after each change.""" + + def __init__(self, path: Path, speed: float): + self.path = path + self.lock = threading.Lock() + self.rng = random.Random() + path.parent.mkdir(parents=True, exist_ok=True) + with closing(sqlite3.connect(path)) as db, db: + db.execute("CREATE TABLE IF NOT EXISTS world (id INTEGER PRIMARY KEY CHECK (id = 1), data TEXT NOT NULL)") + row = db.execute("SELECT data FROM world WHERE id = 1").fetchone() + if row: + self.world = json.loads(row[0]) + self.world["config"]["speed"] = speed + else: + self.world = game.new_world(time.time(), speed) + self.save() + + def now(self) -> float: + return time.time() + self.world["config"]["clock_offset"] + + def save(self) -> None: + data = json.dumps(self.world, separators=(",", ":")) + with closing(sqlite3.connect(self.path)) as db, db: + db.execute("INSERT INTO world (id, data) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET data = excluded.data", + (data,)) + + def backup(self, destination: Path, keep: int) -> Path: + """Write today's consistent snapshot and prune all but the newest `keep`.""" + destination.mkdir(parents=True, exist_ok=True) + target = destination / f"guildhall-{date.today().isoformat()}.sqlite3" + partial = target.with_suffix(".partial") + with closing(sqlite3.connect(self.path)) as source, closing(sqlite3.connect(partial)) as copy: + source.backup(copy) + partial.replace(target) + snapshots = sorted(destination.glob("guildhall-*.sqlite3")) + for old in snapshots[:-keep] if keep > 0 else []: + old.unlink() + return target + + def tick(self) -> None: + with self.lock: + if game.tick(self.world, self.now(), self.rng): + self.save() + + +def player_view(world: dict, player: dict, now: float) -> dict: + view = {key: value for key, value in player.items() if key != "last_active"} + view["levels"] = {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS} + view["slots"] = game.bank_slots(world, player) + view["stack"] = game.stack_size(world) + view["power"] = game.combat_power(player) + view["perk_costs"] = {perk: game.perk_cost(player, perk) for perk in player["perks"]} + if player["action"]: + action = game.ACTION_BY_ID[player["action"]["id"]] + view["action"] = {**player["action"], "duration": game.action_time(world, player, action)} + view["times"] = {action["id"]: game.action_time(world, player, action) for action in game.ACTIONS} + view["blocked"] = {action["id"]: game.can_start(world, player, action) for action in game.ACTIONS} + return view + + +def city_view(world: dict, now: float) -> dict: + city = world["city"] + buildings = {} + for key, building in city["buildings"].items(): + spec = game.BUILDINGS[key] + if building["damaged"]: + cost = game.repair_cost(key, building["level"]) + elif building["level"] < spec["max"]: + cost = game.build_cost(key, building["level"] + 1) + else: + cost = None + buildings[key] = {**building, "cost": cost, "missing": game.missing_for(city, cost) if cost else []} + return { + "population": int(city["population"]), + "housing": game.housing(world), + "treasury": int(city["treasury"]), + "stockpile": city["stockpile"], + "buildings": buildings, + "construction": city["construction"], + "starving": city["starving"], + "merchant_until": city.get("merchant_until", 0), + "repelled": city["repelled"], + "defence": game.defence(world), + "day": int((now - world["created"]) * game.speed(world) // game.DAY) + 1, + } + + +def roster(world: dict) -> list[dict]: + people = [] + for player in world["players"].values(): + action = game.ACTION_BY_ID[player["action"]["id"]] if player["action"] else None + people.append({ + "id": player["id"], "name": player["name"], "cls": player["cls"], + "total": sum(game.skill_level(player, skill) for skill in game.SKILL_IDS), + "levels": {skill: game.skill_level(player, skill) for skill in game.SKILL_IDS}, + "doing": action["name"] if action else None, + "skill": action["skill"] if action else None, + "garrison": player["garrison"], "donated": player["donated"], + }) + return sorted(people, key=lambda p: -p["donated"]) + + +class Handler(BaseHTTPRequestHandler): + server_version = "Guildhall" + store: Store + dev = False + user_header = "Remote-User" + + def log_message(self, fmt: str, *args) -> None: + logging.debug("%s - %s", self.address_string(), fmt % args) + + # --- plumbing -------------------------------------------------------- + + def identity(self) -> str | None: + if self.dev: + cookie = self.headers.get("Cookie", "") + match = re.search(r"(?:^|;\s*)gh_user=([^;]+)", cookie) + return unquote(match.group(1)) if match else "fred" + user = self.headers.get(self.user_header, "").strip() + return user or None + + def send_json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None: + body = json.dumps(payload, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def fail(self, message: str, status: HTTPStatus = HTTPStatus.BAD_REQUEST) -> None: + self.send_json({"error": message}, status) + + def read_body(self) -> dict | None: + length = int(self.headers.get("Content-Length") or 0) + if length > MAX_BODY: + return None + try: + data = json.loads(self.rfile.read(length) or b"{}") + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + def send_static(self, path: str) -> None: + name = "index.html" if path in ("", "/") else path.lstrip("/").removeprefix("static/") + target = (STATIC_DIR / name).resolve() + if STATIC_DIR.resolve() not in target.parents or not target.is_file(): + target = STATIC_DIR / "index.html" + body = target.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", mimetypes.guess_type(target.name)[0] or "application/octet-stream") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + + # --- routes ---------------------------------------------------------- + + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/api/content": + self.send_json(game.content()) + elif path == "/api/state": + uid = self.identity() + if not uid: + return self.fail("Not signed in", HTTPStatus.UNAUTHORIZED) + with self.store.lock: + world = self.store.world + now = self.store.now() + game.tick(world, now, self.store.rng) + player = world["players"].get(uid) + summary = None + if player: + summary = game.process_player(world, player, now, self.store.rng) + player["last_active"] = now + self.store.save() + self.send_json(self.state(uid, summary)) + elif path.startswith("/api/"): + self.fail("Not found", HTTPStatus.NOT_FOUND) + else: + self.send_static(path) + + def do_POST(self) -> None: + path = urlparse(self.path).path + uid = self.identity() + if not uid: + return self.fail("Not signed in", HTTPStatus.UNAUTHORIZED) + body = self.read_body() + if body is None: + return self.fail("Bad request body") + with self.store.lock: + world = self.store.world + now = self.store.now() + game.tick(world, now, self.store.rng) + player = world["players"].get(uid) + if path == "/api/register": + error = self.register(uid, body, now) + elif path.startswith("/api/dev/") and self.dev: + error = self.dev_route(path, body) + elif not player: + return self.fail("Create your adventurer first", HTTPStatus.CONFLICT) + else: + summary = game.process_player(world, player, now, self.store.rng) + player["last_active"] = now + error = self.player_route(path, player, body, now) + if error is None: + game.tick(world, now, self.store.rng) + self.store.save() + if error: + return self.fail(error) + return self.send_json(self.state(uid, summary)) + self.store.save() + if error: + return self.fail(error) + self.send_json(self.state(uid)) + + def player_route(self, path: str, player: dict, body: dict, now: float) -> str | None: + world = self.store.world + item = body.get("item") + qty = body.get("qty") + if path in ("/api/donate", "/api/sell", "/api/give"): + if item != "gold" and item not in game.ITEMS: + return "No such item" + if qty == "all": + qty = player["gold"] if item == "gold" else player["bank"].get(item, 0) + if not isinstance(qty, int): + return "Bad quantity" + if path == "/api/action": + return game.start_action(world, player, str(body.get("id")), now) + if path == "/api/stop": + game.stop(player) + return None + if path == "/api/donate": + return game.donate(world, player, item, qty, now) + if path == "/api/give": + receiver = world["players"].get(str(body.get("to"))) + if not receiver: + return "No such adventurer" + if item == "gold": + return "Gold goes to the treasury, not between players" + return game.give(world, player, receiver, item, qty, now) + if path == "/api/sell": + return "Gold can't be sold" if item == "gold" else game.sell(world, player, item, qty, now) + if path == "/api/equip": + return self.equip(player, item, body.get("slot")) + if path == "/api/build": + return game.commission(world, player, str(body.get("building")), now) + if path == "/api/garrison": + raid = game.upcoming_raid(world) + if body.get("join") and not raid: + return "There's no raid to stand guard for" + player["garrison"] = bool(body.get("join")) + return None + if path == "/api/perk": + return game.buy_perk(player, str(body.get("perk"))) + if path == "/api/settings": + if body.get("overflow") not in ("city", "stop"): + return "Bad setting" + player["overflow"] = body["overflow"] + return None + if path == "/api/board": + text = str(body.get("text", "")).strip()[:500] + if not text: + return "Say something" + world["board"].append({"at": now, "who": player["id"], "text": text}) + del world["board"][:-game.MAX_LOG] + return None + return "Not found" + + def equip(self, player: dict, item: str | None, slot: str | None) -> str | None: + if item is None: + if slot not in player["equip"]: + return "No such slot" + current = player["equip"][slot] + if current: + if game.bank_room(self.store.world, player, current) < 1: + return "No room in the bank" + player["bank"][current] = player["bank"].get(current, 0) + 1 + player["equip"][slot] = None + return None + spec = game.ITEMS.get(item) + if not spec or "slot" not in spec: + return "That can't be equipped" + if player["bank"].get(item, 0) < 1: + return "You don't have one" + game.bank_take(player, item, 1) + current = player["equip"][spec["slot"]] + if current: + player["bank"][current] = player["bank"].get(current, 0) + 1 + player["equip"][spec["slot"]] = item + return None + + def register(self, uid: str, body: dict, now: float) -> str | None: + world = self.store.world + if uid in world["players"]: + return "You already have an adventurer" + name = str(body.get("name", "")).strip() + cls = body.get("cls") + if not NAME.match(name): + return "Pick a name of 2 to 20 letters" + if cls not in game.CLASSES: + return "Pick a class" + world["players"][uid] = game.new_player(uid, name, cls, now) + game.log(world, now, "join", f"{name} the {game.CLASSES[cls]['name'].lower()} arrived in the city", uid) + return None + + def dev_route(self, path: str, body: dict) -> str | None: + world = self.store.world + if path == "/api/dev/advance": + hours = float(body.get("hours", 1)) + world["config"]["clock_offset"] += hours * game.HOUR / game.speed(world) + game.tick(world, self.store.now(), self.store.rng) + return None + if path == "/api/dev/event": + world["next_event_at"] = self.store.now() + game.tick(world, self.store.now(), self.store.rng) + return None + return "Not found" + + def state(self, uid: str, summary: dict | None = None) -> dict: + world = self.store.world + now = self.store.now() + player = world["players"].get(uid) + upcoming = game.upcoming_event(world) + history = [ + {"id": e["id"], "kind": e["kind"], "at": e["resolved"], "report": e.get("report")} + for e in reversed(world["events"]) if e["status"] == "done" + ][:10] + return { + "now": now, "dev": self.dev, "uid": uid, + "me": player_view(world, player, now) if player else None, + "summary": summary, + "city": city_view(world, now), + "upcoming": game.event_view(world, upcoming, now) if upcoming else None, + "history": history, + "players": roster(world), + "ledger": world["ledger"][-80:], + "board": world["board"][-80:], + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8098) + parser.add_argument("--db", type=Path, default=Path("guildhall.sqlite3")) + parser.add_argument("--speed", type=float, default=1.0, help="game time multiplier (dev worlds)") + parser.add_argument("--dev", action="store_true", help="trust a gh_user cookie and enable time travel") + parser.add_argument("--user-header", default="Remote-User") + parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit") + parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + + store = Store(args.db, args.speed) + if args.backup_to: + logging.info("Wrote %s", store.backup(args.backup_to, args.keep)) + return + Handler.store = store + Handler.dev = args.dev + Handler.user_header = args.user_header + + def ticker() -> None: + while True: + time.sleep(TICK_SECONDS) + try: + store.tick() + except Exception: + logging.exception("tick failed") + + threading.Thread(target=ticker, daemon=True).start() + server = ThreadingHTTPServer((args.host, args.port), Handler) + logging.info("Guildhall listening on http://%s:%d%s", args.host, args.port, " (dev)" if args.dev else "") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..84c7095 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1792030654, + "narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..bb7d2de --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "Guildhall: a co-op idle game for a small group of friends"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + + outputs = { self, nixpkgs }: + let + forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ]; + in + { + packages = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in { + default = pkgs.callPackage ./package.nix { }; + guildhall = self.packages.${system}.default; + }); + + nixosModules.default = import ./module.nix self; + nixosModules.guildhall = self.nixosModules.default; + + checks = forAllSystems (system: + let pkgs = import nixpkgs { inherit system; }; + in { + inherit (self.packages.${system}) default; + tests = pkgs.runCommand "guildhall-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } '' + cp -r ${self} source + chmod -R u+w source + cd source + python3 -m unittest discover -s tests -v + python3 -m py_compile app.py game.py + node --check static/app.js + touch $out + ''; + }); + }; +} diff --git a/game.py b/game.py new file mode 100644 index 0000000..3cace1f --- /dev/null +++ b/game.py @@ -0,0 +1,921 @@ +"""Guildhall rules: content tables and pure state transitions. + +The whole world is one JSON-able dict. Every function takes `now` (unix +seconds) so the server, the tests and the dev clock drive it the same way. +Durations below are in game seconds; `speed` in the world config divides +them, so a dev world can run a week in an afternoon. +""" + +from __future__ import annotations + +import math +import random + +HOUR = 3600 +DAY = 24 * HOUR +OFFLINE_CAP = 24 * HOUR +BASE_SLOTS = 12 +BASE_STACK = 1000 +CLASS_SPEED = 0.8 +LUCKY_CHANCE = 0.1 +RECON_PER_INTEL = 8 +MAX_LOG = 200 + + +def _xp_table(max_level: int = 99) -> list[int]: + # RuneScape's curve, as Melvor uses: index = level, value = xp needed. + table = [0, 0] + points = 0 + for level in range(1, max_level): + points += math.floor(level + 300 * 2 ** (level / 7)) + table.append(points // 4) + return table + + +XP_TABLE = _xp_table() +MAX_LEVEL = len(XP_TABLE) - 1 + + +def level_for(xp: float) -> int: + level = 1 + while level < MAX_LEVEL and XP_TABLE[level + 1] <= xp: + level += 1 + return level + + +ITEMS = { + "stone": {"name": "Stone", "icon": "πŸͺ¨", "value": 1}, + "copper_ore": {"name": "Copper ore", "icon": "🟀", "value": 2}, + "iron_ore": {"name": "Iron ore", "icon": "⚫", "value": 4}, + "logs": {"name": "Logs", "icon": "πŸͺ΅", "value": 1}, + "oak_logs": {"name": "Oak logs", "icon": "🌳", "value": 4}, + "wheat": {"name": "Wheat", "icon": "🌾", "value": 1}, + "vegetables": {"name": "Vegetables", "icon": "πŸ₯•", "value": 2}, + "feathers": {"name": "Feathers", "icon": "πŸͺΆ", "value": 1}, + "raw_meat": {"name": "Raw meat", "icon": "πŸ₯©", "value": 2}, + "hide": {"name": "Hide", "icon": "🟫", "value": 3}, + "copper_bar": {"name": "Copper bar", "icon": "🟧", "value": 5}, + "iron_bar": {"name": "Iron bar", "icon": "⬛", "value": 10}, + "arrows": {"name": "Arrows", "icon": "🏹", "value": 1}, + "copper_sword": {"name": "Copper sword", "plural": "Copper swords", "icon": "πŸ—‘οΈ", "value": 14, "slot": "weapon", "power": 4}, + "iron_sword": {"name": "Iron sword", "plural": "Iron swords", "icon": "βš”οΈ", "value": 30, "slot": "weapon", "power": 9}, + "iron_shield": {"name": "Iron shield", "plural": "Iron shields", "icon": "πŸ›‘οΈ", "value": 40, "slot": "shield", "power": 6}, + "bricks": {"name": "Bricks", "icon": "🧱", "value": 3}, + "cut_stone": {"name": "Cut stone", "icon": "πŸ”·", "value": 5}, + "beam": {"name": "Reinforced beam", "plural": "Reinforced beams", "icon": "πŸͺœ", "value": 25}, + "bread": {"name": "Bread", "icon": "🍞", "value": 4, "food": 1}, + "cooked_meat": {"name": "Cooked meat", "icon": "πŸ–", "value": 5, "food": 1}, + "stew": {"name": "Stew", "icon": "🍲", "value": 10, "food": 2}, +} +FOOD_ORDER = ("bread", "cooked_meat", "stew") + +SKILLS = [ + {"id": "mining", "name": "Mining", "icon": "⛏️", "group": "Gathering"}, + {"id": "woodcutting", "name": "Woodcutting", "icon": "πŸͺ“", "group": "Gathering"}, + {"id": "farming", "name": "Farming", "icon": "🌱", "group": "Gathering"}, + {"id": "hunting", "name": "Hunting", "icon": "🦌", "group": "Gathering"}, + {"id": "smithing", "name": "Smithing", "icon": "πŸ”¨", "group": "Crafting"}, + {"id": "masonry", "name": "Masonry", "icon": "🧱", "group": "Crafting"}, + {"id": "cooking", "name": "Cooking", "icon": "🍳", "group": "Crafting"}, + {"id": "combat", "name": "Combat", "icon": "βš”οΈ", "group": "Combat"}, + {"id": "reconnaissance", "name": "Reconnaissance", "icon": "πŸ”­", "group": "Signature", "class": "scout"}, +] +SKILL_IDS = [skill["id"] for skill in SKILLS] +GATHERING = {"mining", "woodcutting", "farming", "hunting"} +CRAFTING = {"smithing", "masonry", "cooking"} + +CLASSES = { + "warrior": { + "name": "Warrior", "icon": "πŸ›‘οΈ", "fast": ["combat"], + "bonus": "Combat actions 20% faster", + "signature": "Command: counts for half again as much in the garrison", + }, + "scout": { + "name": "Scout", "icon": "🏹", "fast": ["hunting", "reconnaissance"], + "bonus": "Hunting 20% faster", + "signature": "Reconnaissance: scout the roads to sharpen intel on what's coming", + }, + "artisan": { + "name": "Artisan", "icon": "βš’οΈ", "fast": ["smithing", "masonry", "cooking"], + "bonus": "Smithing, masonry and cooking 20% faster", + "signature": "Masterwork: 1 in 10 crafts makes double", + }, + "pioneer": { + "name": "Pioneer", "icon": "🧭", "fast": ["mining", "woodcutting", "farming"], + "bonus": "Mining, woodcutting and farming 20% faster", + "signature": "Surveying: 1 in 10 gathers yields double", + }, +} + + +def _action(id, skill, name, level, time, xp, inputs=None, outputs=None, **extra): + return {"id": id, "skill": skill, "name": name, "level": level, "time": time, "xp": xp, + "inputs": inputs or {}, "outputs": outputs or {}, **extra} + + +ACTIONS = [ + _action("mine_stone", "mining", "Quarry stone", 1, 20, 5, outputs={"stone": 1}), + _action("mine_copper", "mining", "Mine copper", 1, 25, 7, outputs={"copper_ore": 1}), + _action("mine_iron", "mining", "Mine iron", 15, 35, 15, outputs={"iron_ore": 1}), + _action("cut_logs", "woodcutting", "Cut trees", 1, 20, 5, outputs={"logs": 1}), + _action("cut_oak", "woodcutting", "Cut oaks", 15, 35, 15, outputs={"oak_logs": 1}), + _action("farm_wheat", "farming", "Grow wheat", 1, 25, 6, outputs={"wheat": 2}), + _action("farm_veg", "farming", "Grow vegetables", 12, 35, 13, outputs={"vegetables": 2}), + _action("hunt_birds", "hunting", "Snare birds", 1, 25, 6, outputs={"feathers": 3}), + _action("hunt_deer", "hunting", "Hunt deer", 8, 40, 13, outputs={"raw_meat": 2, "hide": 1}), + _action("smith_copper_bar", "smithing", "Copper bar", 1, 20, 6, + {"copper_ore": 1, "logs": 1}, {"copper_bar": 1}), + _action("smith_arrows", "smithing", "Arrows", 5, 30, 10, + {"copper_bar": 1, "logs": 1, "feathers": 2}, {"arrows": 15}), + _action("smith_copper_sword", "smithing", "Copper sword", 8, 40, 18, + {"copper_bar": 3}, {"copper_sword": 1}), + _action("smith_iron_bar", "smithing", "Iron bar", 15, 30, 14, + {"iron_ore": 1, "logs": 1}, {"iron_bar": 1}), + _action("smith_iron_sword", "smithing", "Iron sword", 20, 45, 32, + {"iron_bar": 2}, {"iron_sword": 1}, city={"smithy": 1}), + _action("smith_iron_shield", "smithing", "Iron shield", 25, 50, 40, + {"iron_bar": 3}, {"iron_shield": 1}, city={"smithy": 2}), + _action("mason_bricks", "masonry", "Bricks", 1, 25, 6, {"stone": 2, "logs": 1}, {"bricks": 2}), + _action("mason_cut_stone", "masonry", "Cut stone", 10, 30, 12, {"stone": 3}, {"cut_stone": 1}), + _action("mason_beam", "masonry", "Reinforced beam", 20, 45, 30, + {"oak_logs": 2, "iron_bar": 1}, {"beam": 1}), + _action("cook_bread", "cooking", "Bread", 1, 20, 5, {"wheat": 2, "logs": 1}, {"bread": 1}), + _action("cook_meat", "cooking", "Cooked meat", 5, 20, 7, {"raw_meat": 1, "logs": 1}, {"cooked_meat": 1}), + _action("cook_stew", "cooking", "Stew", 15, 35, 16, + {"raw_meat": 1, "vegetables": 2, "logs": 1}, {"stew": 2}), + _action("fight_wolves", "combat", "Wolves in the woods", 1, 60, 14, difficulty=2, gold=2, + drops=[["raw_meat", 1, 0.6], ["hide", 1, 0.5]]), + _action("fight_scouts", "combat", "Bandit scouts", 10, 75, 30, difficulty=14, gold=10, + drops=[["copper_bar", 1, 0.3], ["arrows", 5, 0.4]]), + _action("fight_ruins", "combat", "Watchtower ruins", 25, 90, 60, difficulty=30, gold=25, + drops=[["iron_bar", 1, 0.4], ["iron_sword", 1, 0.03]]), + _action("fight_stronghold", "combat", "Bandit stronghold", 40, 105, 95, difficulty=50, gold=60, + drops=[["iron_bar", 2, 0.5], ["iron_shield", 1, 0.04]]), + _action("sabotage", "combat", "Sabotage the bandit camp", 10, 120, 35, difficulty=12, + special="sabotage"), + _action("recon_roads", "reconnaissance", "Scout the roads", 1, 60, 12, special="recon", recon=1), + _action("recon_camps", "reconnaissance", "Shadow the war bands", 15, 90, 28, special="recon", recon=2), +] +ACTION_BY_ID = {action["id"]: action for action in ACTIONS} + +BUILDINGS = { + "houses": { + "name": "Houses", "icon": "🏠", "max": 5, "gold": 200, + "base": {"logs": 600, "bricks": 300}, "advanced": {"oak_logs": 200}, + "effect": "Room for 15 more people per level", + }, + "walls": { + "name": "Walls", "icon": "🏰", "max": 5, "gold": 300, + "base": {"stone": 1200, "bricks": 500}, "advanced": {"cut_stone": 300, "beam": 40}, + "effect": "+20 defence per level, and raiders carry off less", + }, + "barracks": { + "name": "Barracks", "icon": "β›Ί", "max": 5, "gold": 300, + "base": {"logs": 600, "bricks": 400, "copper_sword": 20}, "advanced": {"iron_sword": 15}, + "effect": "Trains townsfolk as militia: more defence per citizen", + }, + "smithy": { + "name": "Smithy", "icon": "βš’οΈ", "max": 3, "gold": 250, + "base": {"stone": 500, "logs": 600, "copper_bar": 150}, "advanced": {"iron_bar": 150}, + "effect": "Smithing 8% faster per level; unlocks iron swords (1) and shields (2)", + }, + "spymaster": { + "name": "Spymaster's guild", "icon": "πŸ•΅οΈ", "max": 3, "gold": 400, + "base": {"logs": 500, "bricks": 300, "hide": 100, "feathers": 300}, "advanced": {"cut_stone": 150}, + "effect": "One more level of intel on every event per level", + }, + "storehouse": { + "name": "Storehouse", "icon": "πŸ“¦", "max": 5, "gold": 200, + "base": {"logs": 800, "stone": 800}, "advanced": {"oak_logs": 300, "cut_stone": 200}, + "effect": "+3 bank slots and +250 per stack for everyone, per level", + }, +} +BUILD_HOURS = 3 +REPAIR_SHARE = 0.3 + +RAID_GROUPS = ["the Red Hand", "the Hollow Crows", "the Blackwater gang", "the Ashen Wolves", + "the Gallows Men", "the Thornback raiders"] +RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows", + "well armed and disciplined", "a ragged mob, many but poorly armed", + "led by a deserter knight in plate"] + + +# --- world --------------------------------------------------------------- + +def new_world(now: float, speed: float = 1.0) -> dict: + return { + "version": 1, + "created": now, + "config": {"speed": speed, "clock_offset": 0}, + "players": {}, + "city": { + "population": 20.0, + "treasury": 0.0, + "stockpile": {"bread": 40}, + "buildings": {key: {"level": 0, "damaged": False} for key in BUILDINGS}, + "construction": None, + "last_tick": now, + "food_due": 0.0, + "starving": False, + "repelled": 0, + }, + "events": [], + "next_event_at": now + 12 * HOUR / speed, + "next_event_id": 1, + "ledger": [], + "board": [], + } + + +def speed(world: dict) -> float: + return world["config"]["speed"] + + +def amount(item: str, qty: int) -> str: + """'179 copper swords', '1 copper sword', '40 stone'.""" + spec = ITEMS[item] + name = spec.get("plural", spec["name"]) if qty != 1 else spec["name"] + return f"{qty:,} {name.lower()}" + + +def upper_first(text: str) -> str: + return text[:1].upper() + text[1:] + + +def log(world: dict, now: float, kind: str, text: str, who: str | None = None) -> None: + world["ledger"].append({"at": now, "kind": kind, "text": text, "who": who}) + del world["ledger"][:-MAX_LOG] + + +def building_level(world: dict, key: str) -> int: + """Effective level: a damaged building does nothing until repaired.""" + building = world["city"]["buildings"][key] + return 0 if building["damaged"] else building["level"] + + +def build_cost(key: str, level: int) -> dict: + spec = BUILDINGS[key] + cost = {item: qty * level for item, qty in spec["base"].items()} + for item, qty in spec["advanced"].items(): + if level > 1: + cost[item] = cost.get(item, 0) + qty * (level - 1) + cost["gold"] = spec["gold"] * level + return cost + + +def repair_cost(key: str, level: int) -> dict: + return {item: max(1, math.ceil(qty * REPAIR_SHARE)) for item, qty in build_cost(key, level).items()} + + +def housing(world: dict) -> int: + return 25 + 15 * building_level(world, "houses") + + +# --- players ------------------------------------------------------------- + +def new_player(uid: str, name: str, cls: str, now: float) -> dict: + return { + "id": uid, "name": name, "cls": cls, "joined": now, + "xp": {skill: 0 for skill in SKILL_IDS}, + "bank": {}, "gold": 0, "favour": 0, + "equip": {"weapon": None, "shield": None}, + "action": None, "stopped": None, + "last_active": now, "overflow": "city", + "perks": {"bank_slot": 0}, + "garrison": False, + "donated": 0, + } + + +def skill_level(player: dict, skill: str) -> int: + return level_for(player["xp"].get(skill, 0)) + + +def bank_slots(world: dict, player: dict) -> int: + return BASE_SLOTS + 3 * building_level(world, "storehouse") + player["perks"]["bank_slot"] + + +def stack_size(world: dict) -> int: + return BASE_STACK + 250 * building_level(world, "storehouse") + + +def bank_room(world: dict, player: dict, item: str) -> int: + """How many more of `item` fit in the bank.""" + have = player["bank"].get(item, 0) + if have == 0 and len(player["bank"]) >= bank_slots(world, player): + return 0 + return max(0, stack_size(world) - have) + + +def bank_add(world: dict, player: dict, item: str, qty: int, now: float, overflow: dict) -> int: + """Store what fits; the rest goes to the city (or is lost). Returns qty kept.""" + keep = min(qty, bank_room(world, player, item)) + if keep: + player["bank"][item] = player["bank"].get(item, 0) + keep + spill = qty - keep + if spill and player["overflow"] == "city": + stockpile = world["city"]["stockpile"] + stockpile[item] = stockpile.get(item, 0) + spill + gain = spill * ITEMS[item]["value"] + player["favour"] += gain + player["donated"] += gain + overflow[item] = overflow.get(item, 0) + spill + return keep + + +def bank_take(player: dict, item: str, qty: int) -> None: + left = player["bank"][item] - qty + if left > 0: + player["bank"][item] = left + else: + del player["bank"][item] + + +def combat_power(player: dict) -> int: + power = skill_level(player, "combat") + for slot in ("weapon", "shield"): + item = player["equip"][slot] + if item: + power += ITEMS[item]["power"] + return power + + +def action_time(world: dict, player: dict, action: dict) -> float: + seconds = action["time"] + if action["skill"] in CLASSES[player["cls"]]["fast"]: + seconds *= CLASS_SPEED + if action["skill"] == "smithing": + seconds *= 1 - 0.08 * building_level(world, "smithy") + return seconds / speed(world) + + +def upcoming_raid(world: dict) -> dict | None: + for event in world["events"]: + if event["status"] == "upcoming" and event["kind"] == "raid": + return event + return None + + +def upcoming_event(world: dict) -> dict | None: + for event in world["events"]: + if event["status"] == "upcoming": + return event + return None + + +def can_start(world: dict, player: dict, action: dict) -> str | None: + """Why the action can't run right now, or None.""" + if action["skill"] == "reconnaissance" and player["cls"] != "scout": + return "Only scouts can do that" + if skill_level(player, action["skill"]) < action["level"]: + return f"Needs level {action['level']}" + for key, level in action.get("city", {}).items(): + if building_level(world, key) < level: + return f"Needs {BUILDINGS[key]['name']} level {level}" + for item, qty in action["inputs"].items(): + if player["bank"].get(item, 0) < qty: + return f"Out of {ITEMS[item]['name'].lower()}" + if action.get("special") == "sabotage": + raid = upcoming_raid(world) + if not raid or intel_level(world, raid) < 3: + return "No bandit camp has been located" + if raid["sabotage"] >= 0.3: + return "The camp is already in ruins" + return None + + +def _complete(world: dict, player: dict, action: dict, now: float, rng: random.Random, summary: dict) -> None: + for item, qty in action["inputs"].items(): + bank_take(player, item, qty) + xp = action["xp"] + lucky = ( + (player["cls"] == "artisan" and action["skill"] in CRAFTING) + or (player["cls"] == "pioneer" and action["skill"] in GATHERING - {"hunting"}) + ) and rng.random() < LUCKY_CHANCE + outputs = {item: qty * (2 if lucky else 1) for item, qty in action["outputs"].items()} + if lucky: + summary["lucky"] += 1 + + if action["skill"] == "combat": + chance = 0.5 + (combat_power(player) - action["difficulty"]) * 0.04 + if rng.random() < min(0.97, max(0.15, chance)): + summary["wins"] += 1 + player["gold"] += action.get("gold", 0) + summary["gold"] += action.get("gold", 0) + for item, qty, odds in action.get("drops", []): + if rng.random() < odds: + outputs[item] = outputs.get(item, 0) + qty + if action.get("special") == "sabotage": + raid = upcoming_raid(world) + raid["sabotage"] = min(0.3, raid["sabotage"] + 0.03) + else: + summary["losses"] += 1 + xp = xp // 3 + if action.get("special") == "recon": + event = upcoming_event(world) + if event: + event["recon"] += action["recon"] + + for item, qty in outputs.items(): + kept = bank_add(world, player, item, qty, now, summary["overflow"]) + if kept: + summary["items"][item] = summary["items"].get(item, 0) + kept + gain_xp(world, player, action["skill"], xp, now, summary) + + +def gain_xp(world: dict, player: dict, skill: str, xp: float, now: float, summary: dict | None = None) -> None: + before = skill_level(player, skill) + player["xp"][skill] += xp + after = skill_level(player, skill) + if summary is not None: + summary["xp"][skill] = summary["xp"].get(skill, 0) + xp + if after > before: + if summary is not None: + summary["levels"] = [entry for entry in summary["levels"] if entry[0] != skill] + [[skill, after]] + if after % 10 == 0 or after == MAX_LEVEL: + name = next(s["name"] for s in SKILLS if s["id"] == skill) + log(world, now, "level", f"{player['name']} reached {name} level {after}", player["id"]) + + +def empty_summary() -> dict: + return {"done": 0, "items": {}, "overflow": {}, "xp": {}, "levels": [], + "gold": 0, "wins": 0, "losses": 0, "lucky": 0, "stopped": None} + + +def process_player(world: dict, player: dict, now: float, rng: random.Random | None = None) -> dict: + """Run the player's action forward to `now` (capped at 24h past last_active).""" + rng = rng or random.Random() + summary = empty_summary() + current = player["action"] + if not current: + return summary + action = ACTION_BY_ID[current["id"]] + limit = min(now, player["last_active"] + OFFLINE_CAP / speed(world)) + while player["action"]: + duration = action_time(world, player, action) + if current["start"] + duration > limit: + break + reason = can_start(world, player, action) + if reason: + stop(player, reason) + summary["stopped"] = reason + break + if player["overflow"] == "stop" and any(bank_room(world, player, item) < qty + for item, qty in action["outputs"].items()): + stop(player, "Bank full") + summary["stopped"] = "Bank full" + break + _complete(world, player, action, current["start"] + duration, rng, summary) + summary["done"] += 1 + current["start"] += duration + if player["action"] and limit < now and current["start"] < now: + current["start"] = now + return summary + + +def stop(player: dict, reason: str | None = None) -> None: + player["action"] = None + player["stopped"] = reason + + +def start_action(world: dict, player: dict, action_id: str, now: float) -> str | None: + action = ACTION_BY_ID.get(action_id) + if not action: + return "No such action" + reason = can_start(world, player, action) + if reason: + return reason + player["action"] = {"id": action_id, "start": now} + player["stopped"] = None + return None + + +# --- city ---------------------------------------------------------------- + +def stockpile_value(city: dict) -> float: + return sum(ITEMS[item]["value"] * qty for item, qty in city["stockpile"].items()) + + +def donate(world: dict, player: dict, item: str, qty: int, now: float) -> str | None: + city = world["city"] + if qty <= 0: + return "Nothing to donate" + if item == "gold": + if player["gold"] < qty: + return "Not enough gold" + player["gold"] -= qty + city["treasury"] += qty + gain = qty + label = f"{qty:,} gold" + else: + if player["bank"].get(item, 0) < qty: + return "You don't have that many" + bank_take(player, item, qty) + city["stockpile"][item] = city["stockpile"].get(item, 0) + qty + gain = qty * ITEMS[item]["value"] + label = amount(item, qty) + player["favour"] += gain + player["donated"] += gain + log(world, now, "donation", f"{player['name']} donated {label}", player["id"]) + return None + + +def give(world: dict, giver: dict, receiver: dict, item: str, qty: int, now: float) -> str | None: + if receiver is giver: + return "You can't give to yourself" + if qty <= 0 or giver["bank"].get(item, 0) < qty: + return "You don't have that many" + room = bank_room(world, receiver, item) + if room < qty: + return f"{receiver['name']} only has room for {room:,}" + bank_take(giver, item, qty) + receiver["bank"][item] = receiver["bank"].get(item, 0) + qty + log(world, now, "gift", f"{giver['name']} gave {amount(item, qty)} to {receiver['name']}", giver["id"]) + return None + + +def sell(world: dict, player: dict, item: str, qty: int, now: float) -> str | None: + if qty <= 0 or player["bank"].get(item, 0) < qty: + return "You don't have that many" + bank_take(player, item, qty) + player["gold"] += int(qty * sell_price(world, item, now)) + return None + + +def sell_price(world: dict, item: str, now: float) -> float: + price = ITEMS[item]["value"] + if world["city"].get("merchant_until", 0) > now: + price *= 1.5 + return price + + +def commission(world: dict, player: dict, key: str, now: float) -> str | None: + city = world["city"] + if key not in BUILDINGS: + return "No such building" + if city["construction"]: + return "The builders are busy" + building = city["buildings"][key] + repair = building["damaged"] + if not repair and building["level"] >= BUILDINGS[key]["max"]: + return "Already at its highest level" + cost = repair_cost(key, building["level"]) if repair else build_cost(key, building["level"] + 1) + missing = missing_for(city, cost) + if missing: + return "The stockpile is short: " + ", ".join(missing) + for item, qty in cost.items(): + if item == "gold": + city["treasury"] -= qty + else: + left = city["stockpile"][item] - qty + if left: + city["stockpile"][item] = left + else: + del city["stockpile"][item] + hours = BUILD_HOURS * (building["level"] + 1) * (0.25 if repair else 1) + city["construction"] = {"building": key, "repair": repair, "start": now, + "done_at": now + hours * HOUR / speed(world)} + verb = "repairs to" if repair else "work on" + log(world, now, "build", f"{player['name']} started {verb} the {BUILDINGS[key]['name'].lower()}", player["id"]) + return None + + +def missing_for(city: dict, cost: dict) -> list[str]: + missing = [] + for item, qty in cost.items(): + have = city["treasury"] if item == "gold" else city["stockpile"].get(item, 0) + if have < qty: + name = "gold" if item == "gold" else ITEMS[item]["name"].lower() + missing.append(f"{qty - int(have):,} {name}") + return missing + + +def defence(world: dict) -> dict: + """The city's defence right now, part by part.""" + city = world["city"] + pop = city["population"] + stock = city["stockpile"] + armed = int(pop // 2) + swords = min(armed, stock.get("copper_sword", 0) + stock.get("iron_sword", 0)) + iron = min(swords, stock.get("iron_sword", 0)) + parts = { + "townsfolk": 10, + "walls": 20 * building_level(world, "walls"), + "militia": round(0.3 * building_level(world, "barracks") * pop, 1), + "weapons": round(3 * iron + 1.5 * (swords - iron), 1), + "shields": 2 * min(armed, stock.get("iron_shield", 0)), + "arrows": min(100, stock.get("arrows", 0) // 10), + "garrison": 0, + } + garrison = [] + for player in world["players"].values(): + if player["garrison"]: + power = combat_power(player) * (1.5 if player["cls"] == "warrior" else 1) + garrison.append({"id": player["id"], "name": player["name"], "power": round(power, 1)}) + parts["garrison"] += power + total = sum(parts.values()) + if city["starving"]: + total *= 0.8 + return {"parts": parts, "garrison": garrison, "total": round(total, 1), "starving": city["starving"]} + + +def tick(world: dict, now: float, rng: random.Random | None = None) -> bool: + """Advance the city and events to `now`. Returns True if anything changed.""" + rng = rng or random.Random() + city = world["city"] + changed = False + step = HOUR / speed(world) + # Never replay more than two weeks if the server was off. + city["last_tick"] = max(city["last_tick"], now - 14 * DAY / speed(world)) + while city["last_tick"] + step <= now: + city["last_tick"] += step + _city_hour(world, city["last_tick"]) + changed = True + construction = city["construction"] + if construction and construction["done_at"] <= now: + _finish_construction(world, construction["done_at"]) + changed = True + if world["next_event_at"] and world["next_event_at"] <= now and not upcoming_event(world): + _announce(world, now, rng) + changed = True + for event in world["events"]: + if event["status"] == "upcoming" and event["fire_at"] <= now: + for player in world["players"].values(): + process_player(world, player, event["fire_at"], rng) + _fire(world, event, now, rng) + world["next_event_at"] = now + rng.uniform(8, 24) * HOUR / speed(world) + changed = True + return changed + + +def _city_hour(world: dict, now: float) -> None: + city = world["city"] + pop = city["population"] + city["food_due"] += pop * 0.5 / 24 + stock = city["stockpile"] + for item in FOOD_ORDER: + while city["food_due"] >= ITEMS[item]["food"] and stock.get(item, 0) > 0: + stock[item] -= 1 + if not stock[item]: + del stock[item] + city["food_due"] -= ITEMS[item]["food"] + was_starving = city["starving"] + city["starving"] = city["food_due"] >= 1 + if city["starving"]: + city["food_due"] = min(city["food_due"], pop) + city["population"] = max(5.0, pop * (1 - 0.04 / 24)) + if not was_starving: + log(world, now, "city", "The granary is empty. People are going hungry and some are leaving.") + else: + room = housing(world) + if pop < room: + city["population"] = min(room, pop + max(0.25, pop * 0.06) / 24) + elif pop > room: + city["population"] = max(room, pop - pop * 0.05 / 24) + city["treasury"] += pop * 0.5 / 24 + + +def _finish_construction(world: dict, now: float) -> None: + city = world["city"] + work = city["construction"] + building = city["buildings"][work["building"]] + name = BUILDINGS[work["building"]]["name"] + if work["repair"]: + building["damaged"] = False + log(world, now, "build", f"The {name.lower()} have been repaired" if name.endswith("s") + else f"The {name.lower()} has been repaired") + else: + building["level"] += 1 + log(world, now, "build", f"{name} reached level {building['level']}") + city["construction"] = None + + +# --- events -------------------------------------------------------------- + +def _announce(world: dict, now: float, rng: random.Random) -> None: + city = world["city"] + hours = HOUR / speed(world) + first = not world["events"] + roll = rng.random() + kind = "raid" if first or roll < 0.7 else ("merchant" if roll < 0.85 else "refugees") + event = { + "id": world["next_event_id"], "kind": kind, "status": "upcoming", + "announced": now, "recon": 0, "sabotage": 0.0, "jitter": rng.uniform(-1, 1), + } + world["next_event_id"] += 1 + if kind == "raid": + # Bigger, richer and older cities draw bigger bands; every win draws a bolder one. + wealth = city["treasury"] + stockpile_value(city) + day = (now - world["created"]) * speed(world) / DAY + strength = (15 + 4 * day + 0.8 * city["population"] + 1.2 * math.sqrt(wealth)) * (1 + 0.08 * city["repelled"]) + event.update({ + "fire_at": now + rng.uniform(30, 54) * hours, + "strength": round(strength * rng.uniform(0.85, 1.2), 1), + "group": rng.choice(RAID_GROUPS), + "trait": rng.choice(RAID_TRAITS), + }) + log(world, now, "event", "Rumours of bandits on the roads reach the council") + elif kind == "merchant": + event["fire_at"] = now + rng.uniform(12, 30) * hours + log(world, now, "event", "Word arrives that a merchant caravan is headed our way") + else: + event["fire_at"] = now + rng.uniform(12, 36) * hours + event["people"] = rng.randint(6, 12) + log(world, now, "event", "Refugees from the borderlands are walking towards the city") + world["events"].append(event) + del world["events"][:-30] + + +def intel_level(world: dict, event: dict) -> int: + return min(3, building_level(world, "spymaster") + event["recon"] // RECON_PER_INTEL) + + +SIZE_LABELS = [(30, "small"), (60, "medium"), (100, "large"), (math.inf, "huge")] +WINDOW_HOURS = [24, 8, 2, 0] + + +def event_view(world: dict, event: dict, now: float) -> dict: + """What the city knows about an upcoming event, filtered by intel.""" + level = intel_level(world, event) + half = WINDOW_HOURS[level] * HOUR / speed(world) + centre = event["fire_at"] + event["jitter"] * half * 0.6 + view = { + "id": event["id"], "kind": event["kind"], "intel": level, + "earliest": max(now, centre - half), "latest": max(now, centre + half), + "exact": event["fire_at"] if level >= 3 else None, + "recon": event["recon"], "recon_next": RECON_PER_INTEL - event["recon"] % RECON_PER_INTEL, + } + if event["kind"] == "raid": + strength = event["strength"] * (1 - event["sabotage"]) + view["headline"] = "Travellers speak of trouble on the roads." + if level >= 1: + size = next(label for limit, label in SIZE_LABELS if strength < limit) + view["headline"] = f"A {size} bandit band is gathering in the hills." + view["size"] = size + if level >= 2: + spread = 0.25 * strength + low = strength - spread * (1 + event["jitter"]) / 2 + view["range"] = [round(low), round(low + spread)] + view["group"] = event["group"] + view["headline"] = f"{upper_first(event["group"])} are gathering in the hills." + if level >= 3: + view["strength"] = round(strength) + view["trait"] = event["trait"] + view["camp"] = True + view["sabotage"] = round(event["sabotage"] * 100) + view["requests"] = council_requests(world, event) + elif event["kind"] == "merchant": + view["headline"] = "A merchant caravan is on its way. Everything sells for half again as much while it's here." + else: + view["headline"] = f"About {event['people']} refugees are walking towards the city, hoping for a roof." + return view + + +def council_requests(world: dict, event: dict) -> list[dict]: + city = world["city"] + stock = city["stockpile"] + strength = event["strength"] + armed = int(city["population"] // 2) + wants = [ + ("arrows", min(1000, 50 * math.ceil(strength * 4 / 50))), + ("copper_sword", min(armed, max(5, math.ceil(strength / 6)))), + ("bread", math.ceil(city["population"] * 1.5)), + ] + if building_level(world, "smithy") >= 2: + wants.append(("iron_shield", min(armed, math.ceil(strength / 10)))) + requests = [] + for item, qty in wants: + have = stock.get(item, 0) + if item == "copper_sword": + have += stock.get("iron_sword", 0) + if item == "bread": + have = sum(stock.get(food, 0) * ITEMS[food]["food"] for food in FOOD_ORDER) + requests.append({"item": item, "want": qty, "have": have}) + return requests + + +def _fire(world: dict, event: dict, now: float, rng: random.Random) -> None: + event["status"] = "done" + event["resolved"] = now + if event["kind"] == "raid": + _resolve_raid(world, event, now, rng) + elif event["kind"] == "merchant": + world["city"]["merchant_until"] = now + 12 * HOUR / speed(world) + event["report"] = {"title": "A merchant caravan has arrived", + "lines": ["For the next 12 hours, everything sells for 50% more."]} + log(world, now, "event", "The merchant caravan arrived. Prices are up for 12 hours") + else: + city = world["city"] + room = max(0, housing(world) - int(city["population"])) + taken = min(room, event["people"]) + city["population"] += taken + lines = [f"{taken} of {event['people']} refugees found a home."] + if taken < event["people"]: + lines.append("The rest moved on. More houses would have kept them.") + event["report"] = {"title": "Refugees arrived", "lines": lines} + log(world, now, "event", f"{taken} refugees settled in the city") + + +def _resolve_raid(world: dict, event: dict, now: float, rng: random.Random) -> None: + city = world["city"] + stock = city["stockpile"] + attack = event["strength"] * (1 - event["sabotage"]) + guard = defence(world) + ratio = guard["total"] / attack if attack else 9 + garrison = guard["garrison"] + lines = [] + if event["sabotage"]: + lines.append(f"Sabotage weakened the raiders by {round(event['sabotage'] * 100)}%.") + report = { + "group": event["group"], "attack": round(attack), "defence": guard["total"], + "parts": guard["parts"], "garrison": garrison, + } + if ratio >= 1: + loot = round(attack * 2) + city["treasury"] += loot + city["repelled"] += 1 + _use_up(stock, "arrows", 0.3) + report["title"] = f"Victory! {upper_first(event["group"])} were driven off" + lines.append(f"The raiders fled and left {loot} gold worth of plunder behind.") + for entry in garrison: + player = world["players"][entry["id"]] + player["favour"] += 25 + gain_xp(world, player, "combat", round(attack * 5), now) + if garrison: + lines.append("Everyone in the garrison earned 25 favour and combat experience.") + log(world, now, "raid", f"{upper_first(event["group"])} were driven off") + else: + severity = min(1.0, 1 - ratio) + shelter = 1 - 0.08 * building_level(world, "walls") + lost_people = int(city["population"] * (0.1 + 0.3 * severity)) + city["population"] = max(5.0, city["population"] - lost_people) + share = min(0.9, (0.2 + 0.5 * severity) * shelter) + taken = {} + for item in list(stock): + gone = int(stock[item] * share) + if gone: + taken[item] = gone + stock[item] -= gone + if not stock[item]: + del stock[item] + gold = int(city["treasury"] * share) + city["treasury"] -= gold + _use_up(stock, "arrows", 0.6) + report["title"] = f"Defeat. {upper_first(event["group"])} broke into the city" + lines.append(f"{lost_people} townsfolk were killed or fled.") + if gold or taken: + stolen = ", ".join(amount(item, qty) for item, qty in taken.items()) + lines.append(f"The raiders carried off {gold:,} gold" + (f" and {stolen}." if stolen else ".")) + built = [key for key, b in city["buildings"].items() if b["level"] and not b["damaged"]] + if severity > 0.35 and built: + key = rng.choice(built) + city["buildings"][key]["damaged"] = True + lines.append(f"The {BUILDINGS[key]['name'].lower()} were damaged and won't work until repaired." + if BUILDINGS[key]["name"].endswith("s") else + f"The {BUILDINGS[key]['name'].lower()} was damaged and won't work until repaired.") + lines.append("A smaller, poorer city will draw less attention for a while.") + for entry in garrison: + gain_xp(world, world["players"][entry["id"]], "combat", round(attack * 2), now) + log(world, now, "raid", f"{upper_first(event["group"])} broke into the city") + report["lines"] = lines + event["report"] = report + for player in world["players"].values(): + player["garrison"] = False + + +def _use_up(stock: dict, item: str, share: float) -> None: + if stock.get(item): + stock[item] -= int(stock[item] * share) + if not stock[item]: + del stock[item] + + +# --- favour -------------------------------------------------------------- + +def perk_cost(player: dict, perk: str) -> int: + if perk == "bank_slot": + return 500 * 2 ** player["perks"]["bank_slot"] + raise KeyError(perk) + + +def buy_perk(player: dict, perk: str) -> str | None: + if perk not in player["perks"]: + return "No such perk" + cost = perk_cost(player, perk) + if player["favour"] < cost: + return f"Needs {cost:,} favour" + player["favour"] -= cost + player["perks"][perk] += 1 + return None + + +def content() -> dict: + """The static tables the client renders from.""" + buildings = { + key: {**spec, "costs": [build_cost(key, level) for level in range(1, spec["max"] + 1)]} + for key, spec in BUILDINGS.items() + } + return { + "items": ITEMS, "skills": SKILLS, "classes": CLASSES, "actions": ACTIONS, + "buildings": buildings, "xp_table": XP_TABLE, "offline_cap": OFFLINE_CAP, + } diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..ba8390a --- /dev/null +++ b/module.nix @@ -0,0 +1,97 @@ +self: { config, lib, pkgs, ... }: +let + cfg = config.services.guildhall; + database = "/var/lib/guildhall/guildhall.sqlite3"; + hardening = { + User = "guildhall"; + Group = "guildhall"; + StateDirectory = "guildhall"; + StateDirectoryMode = "0700"; + UMask = "0077"; + NoNewPrivileges = true; + PrivateTmp = true; + PrivateDevices = true; + ProtectSystem = "strict"; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + }; +in +{ + options.services.guildhall = { + enable = lib.mkEnableOption "the Guildhall co-op idle game"; + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.stdenv.hostPlatform.system}.default; + defaultText = lib.literalExpression "guildhall.packages.${pkgs.stdenv.hostPlatform.system}.default"; + description = "The guildhall package to run."; + }; + port = lib.mkOption { + type = lib.types.port; + default = 8098; + description = "Loopback TCP port used by the game."; + }; + userHeader = lib.mkOption { + type = lib.types.str; + default = "Remote-User"; + description = "Request header carrying the signed-in user, set by the authenticating proxy."; + }; + backup = { + directory = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Directory for nightly world snapshots; null disables backups."; + }; + keep = lib.mkOption { + type = lib.types.ints.positive; + default = 30; + description = "Number of nightly snapshots to keep."; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + users.users.guildhall = { + isSystemUser = true; + group = "guildhall"; + description = "Guildhall service user"; + }; + users.groups.guildhall = { }; + + systemd.services.guildhall = { + description = "Guildhall co-op idle game"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + serviceConfig = hardening // { + ExecStart = "${cfg.package}/bin/guildhall --host 127.0.0.1 --port ${toString cfg.port} --db ${database} --user-header ${lib.escapeShellArg cfg.userHeader}"; + Restart = "on-failure"; + RestartSec = 3; + }; + }; + } + (lib.mkIf (cfg.backup.directory != null) { + systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 guildhall guildhall -" ]; + + systemd.services.guildhall-backup = { + description = "Snapshot the Guildhall world"; + unitConfig.RequiresMountsFor = [ cfg.backup.directory ]; + serviceConfig = hardening // { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/guildhall --db ${database} --backup-to ${cfg.backup.directory} --keep ${toString cfg.backup.keep}"; + ReadWritePaths = [ cfg.backup.directory ]; + }; + }; + + systemd.timers.guildhall-backup = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*-*-* 03:20:00"; + Persistent = true; + }; + }; + }) + ]); +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..fd59f04 --- /dev/null +++ b/package.nix @@ -0,0 +1,26 @@ +{ lib, stdenvNoCC, makeWrapper, python3 }: + +stdenvNoCC.mkDerivation { + pname = "guildhall"; + version = "0.1.0"; + src = ./.; + + nativeBuildInputs = [ makeWrapper ]; + + installPhase = '' + runHook preInstall + mkdir -p $out/share/guildhall $out/bin + cp app.py game.py $out/share/guildhall/ + cp -r static $out/share/guildhall/static + makeWrapper ${python3}/bin/python3 $out/bin/guildhall \ + --add-flags "$out/share/guildhall/app.py" + runHook postInstall + ''; + + meta = { + description = "Co-op idle game for a small group of friends"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + mainProgram = "guildhall"; + }; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..fc8848c --- /dev/null +++ b/static/app.js @@ -0,0 +1,643 @@ +"use strict"; + +// Guildhall client: renders the server's state and animates progress bars +// between polls. The server is the source of truth for everything. + +const POLL_MS = 10000; +const SUMMARY_MODAL_AT = 25; + +let C = null; // static content tables +let S = null; // latest state +let skew = 0; // server clock minus client clock, seconds +let page = decodeURIComponent(location.hash.slice(1)) || remember("page") || "town"; +let selected = null; // bank item +let pickedClass = null; // registration +let refreshing = false; +let refreshedFor = null; // action start a completion refresh was sent for + +const $ = (sel, root = document) => root.querySelector(sel); +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); +const fmt = (n) => Math.floor(n).toLocaleString("en-GB"); +const serverNow = () => Date.now() / 1000 + skew; +const item = (id) => C.items[id] || { name: id, icon: "❔", value: 0 }; +const skillOf = (id) => C.skills.find((s) => s.id === id); +const playerName = (uid) => S.players.find((p) => p.id === uid)?.name || uid; + +function remember(key, value) { + try { + if (value === undefined) return localStorage.getItem("gh." + key); + localStorage.setItem("gh." + key, value); + } catch { /* private mode: fine */ } + return null; +} + +// --- server --------------------------------------------------------------- + +async function api(path, body) { + const options = body === undefined ? {} : { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + }; + const res = await fetch(path, options); + const data = await res.json().catch(() => ({ error: "The server didn't answer properly" })); + if (!res.ok) throw new Error(data.error || res.statusText); + return data; +} + +async function act(path, body) { + try { + accept(await api(path, body)); + } catch (err) { + toast(err.message, "bad"); + } +} + +async function refresh() { + if (refreshing) return; + refreshing = true; + try { + accept(await api("/api/state")); + } catch (err) { + toast(err.message, "bad"); + } finally { + refreshing = false; + } +} + +function accept(state) { + S = state; + skew = state.now - Date.now() / 1000; + if (state.summary) showSummary(state.summary); + render(); +} + +// --- time ----------------------------------------------------------------- + +function dur(seconds) { + if (seconds > 0 && seconds < 10) return `${+seconds.toFixed(1)}s`; + seconds = Math.max(0, Math.round(seconds)); + const d = Math.floor(seconds / 86400), h = Math.floor(seconds % 86400 / 3600); + const m = Math.floor(seconds % 3600 / 60), s = seconds % 60; + if (d) return `${d}d ${h}h`; + if (h) return `${h}h ${m}m`; + if (m) return `${m}m ${s}s`; + return `${s}s`; +} + +function clock(t) { + return new Date((t - skew) * 1000).toLocaleString("en-GB", { weekday: "short", hour: "2-digit", minute: "2-digit" }); +} + +function ago(t) { + const s = serverNow() - t; + if (s < 60) return "just now"; + return dur(s) + " ago"; +} + +// --- toasts & summaries --------------------------------------------------- + +function toast(text, kind = "") { + const el = document.createElement("div"); + el.className = "toast " + kind; + el.textContent = text; + $("#toasts").append(el); + setTimeout(() => el.remove(), kind === "level" ? 6000 : 3500); + while ($("#toasts").children.length > 5) $("#toasts").firstChild.remove(); +} + +function showSummary(sum) { + if (!sum.done && !sum.stopped) return; + if (sum.done >= SUMMARY_MODAL_AT) return awayModal(sum); + const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).icon} ${item(id).name}`); + if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); + if (got.length) toast(got.join(" Β· "), "good"); + if (sum.lucky) toast(sum.lucky === 1 ? "✨ Double yield!" : `✨ ${sum.lucky} double yields!`, "level"); + const spilled = Object.entries(sum.overflow); + if (spilled.length) toast(`πŸ“¦ Bank full: ${spilled.map(([id, q]) => `${fmt(q)} ${item(id).name.toLowerCase()}`).join(", ")} went to the city`); + for (const [skill, level] of sum.levels) toast(`πŸŽ‰ ${skillOf(skill).name} level ${level}!`, "level"); + if (sum.stopped) toast(`Stopped: ${sum.stopped}`, "bad"); +} + +function awayModal(sum) { + const items = Object.entries(sum.items).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); + const xp = Object.entries(sum.xp).map(([id, q]) => `
  • ${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP
  • `).join(""); + const levels = sum.levels.map(([id, l]) => `
  • πŸŽ‰ ${esc(skillOf(id).name)} reached level ${l}
  • `).join(""); + const spilled = Object.entries(sum.overflow).map(([id, q]) => `
  • ${item(id).icon} ${fmt(q)} ${esc(item(id).name)}
  • `).join(""); + openModal(` +

    While you were away

    +

    You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.

    + ${items ? `` : ""} + ${sum.gold ? `

    +${fmt(sum.gold)} gold

    ` : ""} + ${xp ? `` : ""} + ${levels ? `` : ""} + ${spilled ? `

    Your bank was full, so this went to the city stockpile (you earned favour for it):

    ` : ""} + ${sum.stopped ? `

    Stopped: ${esc(sum.stopped)}

    ` : ""} +

    `); +} + +function openModal(html) { + const modal = $("#modal"); + modal.innerHTML = ``; + modal.hidden = false; +} + +// --- rendering ------------------------------------------------------------ + +function render() { + const kept = {}; + for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value; + const focus = document.activeElement?.dataset?.keep; + + $("#side").innerHTML = S.me ? sidebar() : `
    Guildhall alpha
    ${S.dev ? devbar() : ""}`; + $("#top").innerHTML = topbar(); + $("#main").innerHTML = S.me ? pageHtml() : register(); + + for (const [key, value] of Object.entries(kept)) { + const el = document.querySelector(`[data-keep="${key}"]`); + if (el) el.value = value; + } + if (focus) document.querySelector(`[data-keep="${focus}"]`)?.focus(); + const msgs = $(".msgs"); + if (msgs) msgs.scrollTop = msgs.scrollHeight; + animate(); +} + +function nav(id, icon, label, meta = "", extra = "") { + return ``; +} + +function sidebar() { + const me = S.me, cls = C.classes[me.cls]; + const doing = me.action && C.actions.find((a) => a.id === me.action.id); + const raid = S.upcoming?.kind === "raid"; + let html = ` +
    Guildhall alpha
    +
    +
    ${esc(me.name)}
    +
    ${cls.icon} ${cls.name}
    +
    πŸͺ™ ${fmt(me.gold)}✨ ${fmt(me.favour)}
    +
    + + ${nav("town", "🏘️", "Town square", `${S.city.population}`)} + ${nav("council", "πŸ“œ", "Council & threats", "", raid ? `` : "")} + ${nav("board", "πŸ’¬", "Town board")} + ${nav("roster", "πŸ‘₯", "Guild roster", `${S.players.length}`)} + + ${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`; + let group = null; + for (const skill of C.skills) { + if (skill.class && skill.class !== me.cls) continue; + if (skill.group !== group) { + group = skill.group; + html += ``; + } + const busy = doing?.skill === skill.id ? `` : ""; + html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy); + } + return html + (S.dev ? devbar() : ""); +} + +function devbar() { + return `
    Dev mode Β· playing as ${esc(S.uid)} +
    +
    + + + + +
    `; +} + +const TITLES = { town: "Town square", council: "Council & threats", board: "Town board", roster: "Guild roster", bank: "Bank" }; + +function topbar() { + let title = TITLES[page] || "Guildhall"; + if (page.startsWith("skill:")) title = skillOf(page.slice(6))?.name || title; + if (!S.me) title = "Welcome to Guildhall"; + let chips = ""; + if (S.me?.action) { + const action = C.actions.find((a) => a.id === S.me.action.id); + chips += ``; + } else if (S.me) { + chips += `Idle${S.me.stopped ? ` Β· ${esc(S.me.stopped)}` : ""}`; + } + if (S.upcoming) chips += ``; + return `

    ${esc(title)}

    ${chips}`; +} + +function eventIcon(kind) { + return { raid: "βš”οΈ", merchant: "πŸͺ", refugees: "🧳" }[kind] || "❗"; +} + +function eventWhen(ev, short = false) { + const now = serverNow(); + if (ev.exact) return short ? `in ${dur(ev.exact - now)}` : `${clock(ev.exact)} (in ${dur(ev.exact - now)})`; + if (short) return `in ${dur(ev.earliest - now)}–${dur(ev.latest - now)}`; + return `between ${clock(ev.earliest)} and ${clock(ev.latest)}`; +} + +function pageHtml() { + if (page.startsWith("skill:")) { + const id = page.slice(6); + const skill = skillOf(id); + if (skill && (!skill.class || skill.class === S.me.cls)) return skillPage(id); + page = "town"; + } + return ({ town, council, board, roster, bank }[page] || town)(); +} + +function itemChip(id, qty, have) { + const short = have !== undefined && have < qty; + return `${item(id).icon} ${fmt(qty)}${have !== undefined ? ` / ${fmt(have)}` : ""}`; +} + +// --- skill pages ---------------------------------------------------------- + +function skillPage(id) { + const me = S.me, skill = skillOf(id), level = me.levels[id], xp = me.xp[id]; + const floor = C.xp_table[level], ceil = C.xp_table[level + 1]; + const pct = ceil ? (xp - floor) / (ceil - floor) * 100 : 100; + const cls = C.classes[me.cls]; + const fast = cls.fast.includes(id); + let html = ` +
    +
    ${skill.icon}
    +
    +

    ${skill.name}

    +
    Level ${level} / 99 Β· ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP
    +
    +
    + ${fast ? `${cls.icon} ${cls.name} bonus` : ""} +
    `; + if (id === "combat") html += combatPanel(); + if (id === "reconnaissance") html += `
    Every ${8} reconnaissance points on an upcoming event raises its intel by one level, on top of what the spymaster's guild provides. The council & threats page shows the progress.
    `; + html += `
    ${C.actions.filter((a) => a.skill === id).map(actionCard).join("")}
    `; + return html; +} + +function combatPanel() { + const me = S.me; + const slot = (name, label) => { + const id = me.equip[name]; + return `
    ${id ? `${item(id).icon} ${esc(item(id).name)} (+${item(id).power}) ` : `No ${label}`}
    `; + }; + return `

    Equipment

    +
    ${slot("weapon", "weapon")} ${slot("shield", "shield")}
    +

    Combat power ${me.power} = combat level + gear${me.cls === "warrior" ? ". As a warrior you count for Γ—1.5 in the garrison" : ""}. Equip swords and shields from the bank.

    `; +} + +function actionCard(a) { + const me = S.me; + const active = me.action?.id === a.id; + const why = me.blocked[a.id]; + const locked = why && (why.startsWith("Needs") || why.startsWith("Only")); + const out = Object.keys(a.outputs)[0]; + const icon = out ? item(out).icon : skillOf(a.skill).icon; + const time = me.times[a.id]; + let io = ""; + if (Object.keys(a.inputs).length) { + io += Object.entries(a.inputs).map(([id, q]) => itemChip(id, q, me.bank[id] || 0)).join("") + `β†’`; + } + io += Object.entries(a.outputs).map(([id, q]) => itemChip(id, q)).join(""); + let extra = ""; + if (a.skill === "combat") { + const chance = Math.min(0.97, Math.max(0.15, 0.5 + (me.power - a.difficulty) * 0.04)); + extra = `
    Win chance ${Math.round(chance * 100)}%${a.gold ? ` Β· ${a.gold} gold` : ""}
    `; + if (a.drops) io += a.drops.map(([id, q, odds]) => `${item(id).icon} ${q} ${Math.round(odds * 100)}%`).join(""); + if (a.special === "sabotage") extra += `
    Each win weakens the coming raid by 3%, up to 30%.
    `; + } + if (a.special === "recon") extra = `
    +${a.recon} recon on the next event
    `; + return ` +
    +
    ${icon}
    +
    ${esc(a.name)}
    +
    Level ${a.level} Β· ${dur(time)} Β· ${a.xp} XP
    +
    + ${io ? `
    ${io}
    ` : ""} + ${extra} + ${active ? `
    ` : ""} +
    + ${!active && why ? esc(why) : ""} + ${active ? `` : ``} +
    +
    `; +} + +// --- bank ----------------------------------------------------------------- + +function bank() { + const me = S.me; + const entries = Object.entries(me.bank).sort((x, y) => Object.keys(C.items).indexOf(x[0]) - Object.keys(C.items).indexOf(y[0])); + if (selected && !me.bank[selected]) selected = null; + let slots = entries.map(([id, q]) => ` + `).join(""); + for (let i = entries.length; i < me.slots; i++) slots += `
    `; + return ` +
    +
    +
    +

    Bank Β· ${entries.length} / ${me.slots} slots Β· up to ${fmt(me.stack)} each

    +
    ${slots}
    +

    When an item doesn't fit: + + +
    Raids never touch your bank. Overflow sent to the city still earns favour.

    +
    + ${favourPanel()} +
    +
    ${selected ? itemPanel(selected) : goldPanel()}
    +
    `; +} + +function itemPanel(id) { + const me = S.me, it = item(id), have = me.bank[id]; + const merchant = S.city.merchant_until > serverNow(); + const price = it.value * (merchant ? 1.5 : 1); + const others = S.players.filter((p) => p.id !== S.uid); + const qtyBtns = (attr) => [1, 10, 100].filter((q) => q <= have).map((q) => ``).join("") + + ``; + return `
    +
    ${it.icon}
    +

    ${esc(it.name)}

    +
    You have ${fmt(have)} Β· worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}
    + ${it.slot ? `
    ` : ""} + ${it.food ? `
    Feeds ${it.food} townsfolk for two days in the city granary.
    ` : ""} +
    Donate to the city Β· +${it.value} favour each
    ${qtyBtns("data-donate")}
    + ${others.length ? `
    Give to
    ${qtyBtns("data-give")}
    ` : ""} +
    Sell
    ${qtyBtns("data-sell")}
    +
    +
    ${goldPanel()}`; +} + +function goldPanel() { + return `

    Gold

    +

    πŸͺ™ ${fmt(S.me.gold)}

    +
    +

    The treasury pays the gold part of new buildings. 1 favour per gold.

    `; +} + +function favourPanel() { + const me = S.me; + return `

    Favour with the council

    +

    ✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total

    +
    +

    More perks to come. The council remembers who helped.

    `; +} + +// --- town ----------------------------------------------------------------- + +function town() { + const city = S.city; + let html = `
    +
    Day
    ${city.day}
    +
    Population
    ${city.population} / ${city.housing} homes
    +
    Treasury
    ${fmt(city.treasury)}
    +
    Defence
    ${fmt(city.defence.total)}
    +
    Raids repelled
    ${city.repelled}
    +
    `; + if (city.starving) html += ``; + if (city.merchant_until > serverNow()) html += ``; + const work = city.construction; + if (work) { + const spec = C.buildings[work.building]; + html += ``; + } + html += `
    Buildings
    `; + for (const [key, b] of Object.entries(city.buildings)) { + const spec = C.buildings[key]; + const cost = b.cost ? Object.entries(b.cost).map(([id, q]) => id === "gold" + ? `πŸͺ™ ${fmt(q)} / ${fmt(city.treasury)}` + : itemChip(id, q, city.stockpile[id] || 0)).join("") : ""; + const label = b.damaged ? "Repair" : `Build level ${b.level + 1}`; + html += `
    +
    ${spec.icon}
    +
    ${esc(spec.name)} ${b.level} / ${spec.max} ${b.damaged ? `Damaged` : ""}
    +
    ${esc(spec.effect)}
    +
    + ${b.cost ? `
    ${cost}
    +
    ${work ? "Builders busy" : ""} +
    ` + : `
    Fully built
    `} +
    `; + } + html += `
    City stockpile
    `; + const stock = Object.entries(city.stockpile); + html += stock.length ? `
    ${stock.map(([id, q]) => itemChip(id, q)).join("")}
    ` : `

    Empty. Donate from your bank.

    `; + html += `

    Anyone can start a building once the stockpile and treasury cover it. Until then, the stockpile is what raiders come for.

    `; + return html; +} + +// --- council -------------------------------------------------------------- + +function pips(level) { + return `${[1, 2, 3].map((i) => ``).join("")}`; +} + +function council() { + const ev = S.upcoming, city = S.city, me = S.me; + let html = ""; + if (!ev) { + html += `

    All quiet

    No news on the roads. The spymasters will report as soon as something stirs.

    `; + } else { + html += `
    +

    ${eventIcon(ev.kind)} ${ev.kind === "raid" ? "Threat" : "News"} Β· intel ${pips(ev.intel)}

    +
    ${esc(ev.headline)}
    +
    + Expected${eventWhen(ev)} + ${ev.range ? `Strength${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}` : ""} + ${ev.trait ? `Scouts say${esc(ev.trait)}` : ""} + ${ev.camp ? `Their campLocated. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.` : ""} + ${me.cls === "scout" || ev.recon ? `Reconnaissance${ev.intel < 3 ? `${ev.recon} points Β· ${ev.recon_next} more for the next intel level` : `${ev.recon} points Β· nothing more to learn`}` : ""} +
    + ${ev.intel < 3 ? `

    Better intel comes from the spymaster's guild and scouts' reconnaissance.

    ` : ""}`; + if (ev.kind === "raid") { + const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?"); + html += `
    Our defence${fmt(city.defence.total)}
    vs
    Raiders${strength}
    `; + } + html += `
    `; + if (ev.kind === "raid") html += raidPrep(ev); + } + html += `
    Past events
    `; + if (!S.history.length) html += `

    Nothing has happened yet.

    `; + for (const h of S.history) { + if (!h.report) continue; + const win = h.kind !== "raid" || h.report.title.startsWith("Victory"); + html += `
    + ${eventIcon(h.kind)} ${esc(h.report.title)} ${ago(h.at)} + ${h.kind === "raid" ? `

    Defence ${fmt(h.report.defence)} vs raiders ${fmt(h.report.attack)}${h.report.garrison.length ? ` Β· garrison: ${h.report.garrison.map((g) => esc(g.name)).join(", ")}` : ""}

    ` : ""} + ${h.report.lines.map((l) => `

    ${esc(l)}

    `).join("")} +
    `; + } + return html; +} + +function raidPrep(ev) { + const city = S.city, me = S.me, d = city.defence; + const names = { townsfolk: "Townsfolk with pitchforks", walls: "Walls", militia: "Militia (barracks)", weapons: "Swords in the armoury", shields: "Shields in the armoury", arrows: "Arrows", garrison: "Garrison" }; + const reqs = (ev.requests || []).map((r) => { + const mine = me.bank[r.item] || 0; + return `
    ${item(r.item).icon}${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} ${fmt(r.have)} / ${fmt(r.want)} + ${mine ? `` : ""} +
    `; + }).join(""); + return `
    +

    Council requests

    ${reqs} +

    Arrows, swords and shields in the stockpile arm the defenders. Food keeps them fighting.

    +

    Defence

    + ${Object.entries(d.parts).map(([k, v]) => ``).join("")} + ${d.starving ? `` : ""}
    ${names[k]}${fmt(v)}
    Hungry (Γ—0.8)
    +

    Garrison

    +

    ${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `Nobody has signed up yet.`}

    + ${me.garrison ? `` + : ``} +

    You can keep working while you're signed up. Victory earns the garrison favour and combat XP.

    +
    `; +} + +// --- board & roster ------------------------------------------------------- + +function board() { + const msgs = S.board.map((m) => `
    ${esc(playerName(m.who))}${ago(m.at)}
    ${esc(m.text)}
    `).join(""); + const ledger = [...S.ledger].reverse().map((l) => `
  • ${esc(l.text)}${ago(l.at)}
  • `).join(""); + return `
    +

    Town board

    +
    ${msgs || `

    No messages yet. Say hello, or ask for that iron you need.

    `}
    +
    +

    +

    Town ledger

      ${ledger || `
    • Nothing recorded yet.
    • `}
    +
    `; +} + +function roster() { + return `
    ${S.players.map((p) => { + const cls = C.classes[p.cls]; + const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `
    ${s.icon} ${s.name} ${p.levels[s.id]}
    `).join(""); + return `
    ${cls.icon}
    + ${esc(p.name)} ${cls.name} Β· total level ${p.total} +
    ${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` Β· On guard` : ""}
    +
    ✨ ${fmt(p.donated)} given to the city
    +
    ${levels}
    `; + }).join("")}
    `; +} + +// --- registration --------------------------------------------------------- + +function register() { + const cards = Object.entries(C.classes).map(([id, c]) => ` + `).join(""); + return `
    +

    A new city on the frontier

    +

    You're one of a handful of adventurers settling a young city at the edge of the kingdom. + Train your skills, supply the council, and keep the place standing when the bandits come. + Your adventurer keeps working while you're away, for up to a day.

    +
    ${cards}
    +
    + + +
    +

    Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.

    +
    `; +} + +// --- animation ------------------------------------------------------------ + +function animate() { + const now = serverNow(); + const a = S?.me?.action; + if (a) { + const pct = Math.min(1, (now - a.start) / a.duration); + for (const el of document.querySelectorAll("[data-progress]")) el.style.width = (pct * 100) + "%"; + if (pct >= 1 && refreshedFor !== a.start) { + refreshedFor = a.start; + setTimeout(refresh, 150); + } + } + for (const el of document.querySelectorAll("[data-span]")) { + const [start, end] = el.dataset.span.split(",").map(Number); + el.style.width = Math.min(100, (now - start) / (end - start) * 100) + "%"; + } + for (const el of document.querySelectorAll("[data-countdown]")) el.textContent = dur(Number(el.dataset.countdown) - now); +} + +function loop() { + if (S) animate(); + requestAnimationFrame(loop); +} + +// --- events --------------------------------------------------------------- + +document.addEventListener("click", (e) => { + const t = e.target.closest("button, [data-menu], [data-close]"); + if (!t) return; + const d = t.dataset; + if (t.matches("[data-close]") || e.target === $("#modal")) { $("#modal").hidden = true; return; } + if ("menu" in d) { $("#app").classList.toggle("open"); return; } + if (d.nav) { + page = d.nav; remember("page", page); selected = null; + history.replaceState(null, "", "#" + page); + $("#app").classList.remove("open"); + render(); window.scrollTo(0, 0); return; + } + if (d.start) return act("/api/action", { id: d.start }); + if ("stop" in d) return act("/api/stop", {}); + if ("select" in d) { selected = d.select || null; render(); return; } + const qty = d.qty === "all" ? "all" : Number(d.qty); + if (d.donate) return act("/api/donate", { item: d.donate, qty }); + if (d.sell) return act("/api/sell", { item: d.sell, qty }); + if (d.give) return act("/api/give", { item: d.give, qty, to: $("[data-keep=give-to]").value }); + if (d.equip) return act("/api/equip", { item: d.equip }); + if (d.unequip) return act("/api/equip", { slot: d.unequip }); + if (d.build) return act("/api/build", { building: d.build }); + if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); + if (d.perk) return act("/api/perk", { perk: d.perk }); + if (d.overflow) return act("/api/settings", { overflow: d.overflow }); + if (d.class) { pickedClass = d.class; render(); return; } + if (d.devAdvance) return act("/api/dev/advance", { hours: Number(d.devAdvance) }); + if ("devEvent" in d) return act("/api/dev/event", {}); +}); + +$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") e.target.hidden = true; }); + +document.addEventListener("submit", async (e) => { + const form = e.target.closest("[data-form]"); + if (!form) return; + e.preventDefault(); + const field = form.querySelector("[data-keep]"); + const value = field.value.trim(); + if (form.dataset.form === "register") { + if (!pickedClass) return toast("Pick a class first", "bad"); + return act("/api/register", { name: value, cls: pickedClass }); + } + if (form.dataset.form === "board") { + if (!value) return; + field.value = ""; + return act("/api/board", { text: value }); + } + if (form.dataset.form === "gold") { + field.value = ""; + return act("/api/donate", { item: "gold", qty: Number(value) }); + } + if (form.dataset.form === "dev-user" && value) { + document.cookie = `gh_user=${encodeURIComponent(value)}; path=/; SameSite=Lax`; + field.value = ""; + return refresh(); + } +}); + +document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); + +(async function boot() { + try { + C = await api("/api/content"); + await refresh(); + } catch (err) { + $("#main").innerHTML = `

    Couldn't reach the city: ${esc(err.message)}

    `; + return; + } + setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS); + requestAnimationFrame(loop); +})(); diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..cebd14f --- /dev/null +++ b/static/icon.svg @@ -0,0 +1 @@ + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f509112 --- /dev/null +++ b/static/index.html @@ -0,0 +1,24 @@ + + + + + + + Guildhall + + + + +
    + +
    +
    +
    +

    Opening the city gates…

    +
    +
    +
    + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..86b8303 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,305 @@ +:root { + --bg: #14161d; + --side: #1a1d26; + --panel: #212532; + --panel-2: #292e3d; + --line: #353b4d; + --text: #e7e9f0; + --muted: #98a1b4; + --gold: #e0b04a; + --xp: #4fa3e0; + --good: #5cbf6a; + --bad: #e2645c; + --warn: #e3a043; + --favour: #b98ce8; + --radius: 10px; + color-scheme: dark; +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); + color: var(--text); + font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +button, input, textarea, select { font: inherit; color: inherit; } +button { cursor: pointer; } +a { color: var(--xp); } + +#app { display: flex; min-height: 100vh; } + +/* --- sidebar ---------------------------------------------------------- */ + +#side { + width: 260px; + flex: none; + background: var(--side); + border-right: 1px solid var(--line); + height: 100vh; + position: sticky; + top: 0; + overflow-y: auto; + padding-bottom: 24px; +} +.brand { + display: flex; align-items: center; gap: 10px; + padding: 16px 18px 12px; + font-weight: 700; font-size: 19px; letter-spacing: .02em; +} +.brand img { width: 28px; height: 28px; } +.brand small { + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; + color: var(--bg); background: var(--gold); border-radius: 4px; padding: 1px 6px; +} +.me { + margin: 0 12px 8px; padding: 10px 12px; + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); +} +.me .who { font-weight: 600; } +.me .cls { color: var(--muted); font-size: 13px; } +.purse { display: flex; gap: 14px; margin-top: 6px; font-size: 14px; } +.gold { color: var(--gold); } +.favour { color: var(--favour); } + +.navhead { + padding: 14px 18px 4px; + font-size: 11.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; + color: var(--muted); +} +.nav { + display: flex; align-items: center; gap: 10px; width: 100%; + padding: 7px 18px; border: 0; background: none; text-align: left; + border-left: 3px solid transparent; +} +.nav:hover { background: var(--panel); } +.nav.on { background: var(--panel-2); border-left-color: var(--gold); } +.nav .ic { width: 22px; text-align: center; font-size: 17px; } +.nav .label { flex: 1; } +.nav .meta { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; } +.nav .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--good); box-shadow: 0 0 6px var(--good); } +.nav .alert { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); } + +.devbar { + margin: 18px 12px 0; padding: 10px 12px; border: 1px dashed var(--warn); border-radius: var(--radius); + font-size: 13px; color: var(--muted); +} +.devbar b { color: var(--warn); } +.devbar form { display: flex; gap: 6px; margin: 6px 0; } +.devbar input { width: 100%; } +.devbar .row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } + +/* --- top bar ---------------------------------------------------------- */ + +#body { flex: 1; min-width: 0; display: flex; flex-direction: column; } +#top { + position: sticky; top: 0; z-index: 5; + display: flex; align-items: center; gap: 12px; + padding: 10px 24px; min-height: 56px; + background: rgba(20, 22, 29, .92); backdrop-filter: blur(6px); + border-bottom: 1px solid var(--line); +} +#top h1 { font-size: 18px; margin: 0; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.menu-btn { display: none; background: none; border: 0; font-size: 22px; padding: 0 4px; } +.chip { + display: inline-flex; align-items: center; gap: 8px; + padding: 5px 12px; border-radius: 999px; border: 1px solid var(--line); + background: var(--panel); font-size: 13.5px; white-space: nowrap; +} +.chip.threat { border-color: rgba(226, 100, 92, .5); } +.chip .mini { width: 70px; } + +main { padding: 24px; max-width: 1200px; width: 100%; } +.loading { color: var(--muted); } + +/* --- panels ----------------------------------------------------------- */ + +.panel { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: 16px 18px; margin-bottom: 18px; +} +.panel > h2, .section-title { + font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; + color: var(--muted); margin: 0 0 12px; +} +.section-title { margin: 26px 0 12px; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +.grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } +.grid.wide { grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); } +.cols { display: grid; gap: 18px; grid-template-columns: 1fr 1fr; } +.stats { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-bottom: 18px; } +.stat { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; } +.stat .k { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } +.stat .v { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; } +.stat .v small { font-size: 13px; color: var(--muted); font-weight: 400; } + +.bar { height: 8px; background: var(--bg); border-radius: 99px; overflow: hidden; } +.bar > span { display: block; height: 100%; background: var(--good); border-radius: 99px; width: 0; } +.bar.xp > span { background: var(--xp); } +.bar.gold > span { background: var(--gold); } +.bar.thick { height: 12px; } + +.btn { + border: 1px solid var(--line); background: var(--panel-2); border-radius: 8px; + padding: 7px 14px; font-weight: 600; font-size: 14px; +} +.btn:hover:not(:disabled) { border-color: var(--muted); } +.btn:disabled { opacity: .45; cursor: not-allowed; } +.btn.primary { background: #2f6b3a; border-color: #3f8a4c; } +.btn.danger { background: #6b2f2f; border-color: #8a3f3f; } +.btn.gold { background: #6b5320; border-color: #8f6f2a; } +.btn.sm { padding: 3px 9px; font-size: 13px; } +.btns { display: flex; flex-wrap: wrap; gap: 6px; } + +input[type=text], input[type=number], textarea { + background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; +} +textarea { width: 100%; resize: vertical; min-height: 60px; } + +.tag { + display: inline-block; font-size: 11.5px; font-weight: 600; padding: 1px 7px; border-radius: 99px; + background: var(--panel-2); color: var(--muted); border: 1px solid var(--line); +} +.tag.good { color: var(--good); border-color: rgba(92, 191, 106, .4); } +.tag.bad { color: var(--bad); border-color: rgba(226, 100, 92, .4); } +.tag.warn { color: var(--warn); border-color: rgba(227, 160, 67, .4); } +.tag.gold { color: var(--gold); border-color: rgba(224, 176, 74, .4); } + +/* --- skill page ------------------------------------------------------- */ + +.skill-head { display: flex; gap: 16px; align-items: center; } +.skill-head .big { font-size: 42px; width: 64px; height: 64px; display: grid; place-items: center; + background: var(--panel-2); border-radius: 14px; flex: none; } +.skill-head .info { flex: 1; min-width: 0; } +.skill-head h2 { margin: 0; font-size: 22px; text-transform: none; letter-spacing: 0; color: var(--text); } +.skill-head .lv { font-variant-numeric: tabular-nums; color: var(--muted); margin: 2px 0 8px; } + +.action { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: 14px; display: flex; flex-direction: column; gap: 10px; +} +.action.active { border-color: var(--good); box-shadow: 0 0 0 1px var(--good) inset; } +.action.locked { opacity: .55; } +.action .top { display: flex; gap: 12px; align-items: center; } +.action .ico { font-size: 30px; width: 46px; height: 46px; display: grid; place-items: center; + background: var(--panel-2); border-radius: 10px; flex: none; } +.action .name { font-weight: 650; } +.action .sub { font-size: 13px; color: var(--muted); } +.io { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13.5px; } +.io .arrow { color: var(--muted); } +.item-chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 6px; background: var(--panel-2); } +.item-chip.short { color: var(--bad); } +.action .why { font-size: 13px; color: var(--warn); } +.action .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: auto; } + +/* --- bank ------------------------------------------------------------- */ + +.bank-wrap { display: grid; grid-template-columns: 1fr 300px; gap: 18px; align-items: start; } +.slots { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); } +.slot { + aspect-ratio: 1; border-radius: 10px; border: 1px solid var(--line); background: var(--panel-2); + display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; + font-size: 30px; padding: 0; +} +.slot.empty { background: transparent; border-style: dashed; cursor: default; } +.slot.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) inset; } +.slot .qty { position: absolute; left: 6px; top: 4px; font-size: 12px; font-weight: 700; } +.slot .qty.full { color: var(--warn); } +.slot .nm { font-size: 10.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 4px; } +.item-panel h3 { margin: 0; font-size: 18px; } +.item-panel .ico { font-size: 40px; } +.item-panel .row { margin-top: 12px; } +.item-panel .row .k { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 4px; } + +/* --- city ------------------------------------------------------------- */ + +.building .lv { font-variant-numeric: tabular-nums; } +.building.damaged { border-color: rgba(226, 100, 92, .6); } +.cost { display: flex; flex-wrap: wrap; gap: 6px; } +.cost .item-chip .have { color: var(--muted); } +.banner { + display: flex; gap: 14px; align-items: center; padding: 12px 16px; margin-bottom: 18px; + border-radius: var(--radius); border: 1px solid var(--line); background: var(--panel-2); +} +.banner.bad { border-color: rgba(226, 100, 92, .6); background: rgba(226, 100, 92, .08); } +.banner .grow { flex: 1; } +.stock { display: flex; flex-wrap: wrap; gap: 6px; } + +.threat-card { border-color: rgba(226, 100, 92, .5); } +.threat-card .headline { font-size: 18px; font-weight: 650; margin: 2px 0 6px; } +.pips { display: inline-flex; gap: 4px; vertical-align: middle; } +.pips i { width: 10px; height: 10px; border-radius: 2px; background: var(--line); } +.pips i.on { background: var(--xp); } +.kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; font-size: 14px; } +.kv .k { color: var(--muted); } +.vs { display: flex; gap: 12px; align-items: center; margin: 12px 0 4px; } +.vs .side { flex: 1; text-align: center; padding: 10px; border-radius: 8px; background: var(--panel-2); } +.vs .side b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; } +.req { display: grid; grid-template-columns: 26px 1fr auto; gap: 4px 10px; align-items: center; margin-bottom: 10px; } +.req .bar { grid-column: 2 / 4; } +table.parts { width: 100%; border-collapse: collapse; font-size: 14px; } +table.parts td { padding: 4px 0; border-bottom: 1px solid var(--line); } +table.parts td:last-child { text-align: right; font-variant-numeric: tabular-nums; } +.report { border-left: 3px solid var(--line); padding: 4px 0 4px 12px; margin-bottom: 14px; } +.report.win { border-left-color: var(--good); } +.report.loss { border-left-color: var(--bad); } +.report p { margin: 2px 0; font-size: 14px; } + +/* --- board, ledger, roster ------------------------------------------- */ + +.msgs { display: flex; flex-direction: column; gap: 10px; max-height: 60vh; overflow-y: auto; margin-bottom: 12px; } +.msg .who { font-weight: 600; } +.msg .when { color: var(--muted); font-size: 12px; margin-left: 6px; } +.msg .text { white-space: pre-wrap; overflow-wrap: anywhere; } +.ledger { list-style: none; margin: 0; padding: 0; max-height: 70vh; overflow-y: auto; } +.ledger li { padding: 6px 0; border-bottom: 1px solid var(--line); font-size: 14px; } +.ledger .when { color: var(--muted); font-size: 12px; display: block; } +.person .top { display: flex; gap: 12px; align-items: center; } +.person .ico { font-size: 30px; } +.levels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px 10px; font-size: 13px; margin-top: 10px; } +.levels span { color: var(--muted); } + +/* --- register --------------------------------------------------------- */ + +.welcome { max-width: 760px; } +.welcome h2 { font-size: 26px; margin: 0 0 6px; } +.classes { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); margin: 16px 0; } +.class-card { + text-align: left; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: 14px; display: flex; gap: 12px; +} +.class-card.on { border-color: var(--gold); box-shadow: 0 0 0 1px var(--gold) inset; } +.class-card .ico { font-size: 32px; } +.class-card p { margin: 4px 0 0; font-size: 13.5px; color: var(--muted); } + +/* --- toasts and modal ------------------------------------------------- */ + +#toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 30; } +.toast { + background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 8px 14px; + font-size: 14px; box-shadow: 0 6px 18px rgba(0, 0, 0, .35); animation: rise .2s ease-out; +} +.toast.bad { border-color: var(--bad); } +.toast.good { border-color: var(--good); } +.toast.level { border-color: var(--gold); } +@keyframes rise { from { transform: translateY(8px); opacity: 0; } } +#modal { position: fixed; inset: 0; z-index: 40; background: rgba(0, 0, 0, .6); display: grid; place-items: center; padding: 16px; } +#modal[hidden] { display: none; } +.modal-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 20px 22px; max-width: 460px; width: 100%; max-height: 85vh; overflow-y: auto; } +.modal-box h2 { margin: 0 0 10px; } +.modal-box ul { padding-left: 18px; } + +/* --- small screens ---------------------------------------------------- */ + +#scrim { display: none; } +@media (max-width: 900px) { + #side { position: fixed; left: 0; top: 0; z-index: 20; transform: translateX(-100%); transition: transform .2s; } + #app.open #side { transform: none; } + #app.open #scrim { display: block; position: fixed; inset: 0; z-index: 15; background: rgba(0, 0, 0, .5); } + .menu-btn { display: block; } + #top { padding: 10px 16px; flex-wrap: wrap; } + main { padding: 16px; } + .cols, .bank-wrap { grid-template-columns: 1fr; } + .chip.act .label { max-width: 110px; overflow: hidden; text-overflow: ellipsis; } +} diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100644 index 0000000..d5a95e5 --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,254 @@ +import random +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import game # noqa: E402 + +T0 = 1_800_000_000.0 + + +def world_with(*players): + world = game.new_world(T0) + for uid, cls in players: + world["players"][uid] = game.new_player(uid, uid.title(), cls, T0) + return world + + +class XpTest(unittest.TestCase): + def test_curve_matches_runescape(self): + self.assertEqual(game.XP_TABLE[2], 83) + self.assertEqual(game.XP_TABLE[99], 13034431) + self.assertEqual(game.level_for(0), 1) + self.assertEqual(game.level_for(83), 2) + self.assertEqual(game.level_for(10**9), 99) + + +class ActionTest(unittest.TestCase): + def test_gathering_runs_while_away(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + self.assertIsNone(game.start_action(world, p, "mine_stone", T0)) + summary = game.process_player(world, p, T0 + 200, random.Random(1)) + self.assertEqual(summary["done"], 10) + self.assertEqual(p["bank"]["stone"], 10) + self.assertEqual(p["xp"]["mining"], 50) + self.assertEqual(p["action"]["start"], T0 + 200) + + def test_class_speed_and_lucky_yield(self): + world = world_with(("pia", "pioneer")) + p = world["players"]["pia"] + game.start_action(world, p, "mine_stone", T0) + summary = game.process_player(world, p, T0 + 1600, random.Random(3)) + self.assertEqual(summary["done"], 100) # 16s instead of 20s + self.assertGreater(p["bank"]["stone"], 100) + self.assertEqual(p["bank"]["stone"], 100 + summary["lucky"]) + + def test_offline_cap(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + game.start_action(world, p, "cut_logs", T0) + summary = game.process_player(world, p, T0 + 3 * game.DAY, random.Random(1)) + self.assertEqual(summary["done"], game.OFFLINE_CAP // 20) + self.assertEqual(p["action"]["start"], T0 + 3 * game.DAY) + + def test_crafting_stops_when_inputs_run_out(self): + world = world_with(("ann", "warrior")) + p = world["players"]["ann"] + p["bank"] = {"copper_ore": 3, "logs": 10} + game.start_action(world, p, "smith_copper_bar", T0) + summary = game.process_player(world, p, T0 + 1000, random.Random(1)) + self.assertEqual(p["bank"]["copper_bar"], 3) + self.assertNotIn("copper_ore", p["bank"]) + self.assertIsNone(p["action"]) + self.assertEqual(summary["stopped"], "Out of copper ore") + + def test_level_and_city_requirements(self): + world = world_with(("ann", "warrior")) + p = world["players"]["ann"] + self.assertEqual(game.start_action(world, p, "mine_iron", T0), "Needs level 15") + p["xp"]["smithing"] = game.XP_TABLE[30] + p["bank"] = {"iron_bar": 10} + self.assertEqual(game.start_action(world, p, "smith_iron_sword", T0), "Needs Smithy level 1") + self.assertEqual(game.start_action(world, p, "recon_roads", T0), "Only scouts can do that") + + def test_full_bank_overflows_to_city(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + p["bank"] = {"stone": game.BASE_STACK - 2} + game.start_action(world, p, "mine_stone", T0) + summary = game.process_player(world, p, T0 + 100, random.Random(1)) + self.assertEqual(p["bank"]["stone"], game.BASE_STACK) + self.assertEqual(summary["overflow"], {"stone": 3}) + self.assertEqual(world["city"]["stockpile"]["stone"], 3) + self.assertEqual(p["favour"], 3) + + def test_full_bank_can_stop_instead(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + p["overflow"] = "stop" + p["bank"] = {f"x{i}": 1 for i in range(game.BASE_SLOTS)} + game.start_action(world, p, "mine_stone", T0) + summary = game.process_player(world, p, T0 + 100, random.Random(1)) + self.assertEqual(summary["stopped"], "Bank full") + self.assertNotIn("stone", p["bank"]) + + def test_combat_pays_and_loses(self): + world = world_with(("wil", "warrior")) + p = world["players"]["wil"] + game.start_action(world, p, "fight_wolves", T0) + summary = game.process_player(world, p, T0 + 48 * 100, random.Random(5)) + self.assertEqual(summary["done"], 100) + self.assertEqual(summary["wins"] + summary["losses"], 100) + self.assertEqual(p["gold"], summary["wins"] * 2) + + +class GiftTest(unittest.TestCase): + def test_give_moves_items_within_bank_room(self): + world = world_with(("fred", "pioneer"), ("kay", "artisan")) + fred, kay = world["players"]["fred"], world["players"]["kay"] + fred["bank"] = {"copper_ore": 300} + self.assertIsNone(game.give(world, fred, kay, "copper_ore", 200, T0)) + self.assertEqual(fred["bank"], {"copper_ore": 100}) + self.assertEqual(kay["bank"], {"copper_ore": 200}) + self.assertEqual(world["ledger"][-1]["text"], "Fred gave 200 copper ore to Kay") + kay["bank"] = {f"x{i}": 1 for i in range(game.BASE_SLOTS)} + self.assertEqual(game.give(world, fred, kay, "copper_ore", 1, T0), "Kay only has room for 0") + self.assertEqual(game.give(world, fred, fred, "copper_ore", 1, T0), "You can't give to yourself") + + def test_amount_pluralises(self): + self.assertEqual(game.amount("copper_sword", 179), "179 copper swords") + self.assertEqual(game.amount("copper_sword", 1), "1 copper sword") + self.assertEqual(game.amount("stone", 1200), "1,200 stone") + + +class CityTest(unittest.TestCase): + def test_build_needs_stockpile_then_takes_time(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + self.assertIn("short", game.commission(world, p, "walls", T0)) + world["city"]["stockpile"].update({"stone": 1200, "bricks": 500}) + world["city"]["treasury"] = 300 + self.assertIsNone(game.commission(world, p, "walls", T0)) + self.assertEqual(world["city"]["treasury"], 0) + self.assertNotIn("stone", world["city"]["stockpile"]) + game.tick(world, T0 + 3 * game.HOUR, random.Random(1)) + self.assertEqual(world["city"]["buildings"]["walls"]["level"], 1) + self.assertEqual(game.defence(world)["parts"]["walls"], 20) + + def test_people_eat_and_go_hungry(self): + world = world_with() + city = world["city"] + city["stockpile"] = {"bread": 5} + game.tick(world, T0 + 2 * game.DAY, random.Random(1)) + self.assertTrue(city["starving"]) + self.assertNotIn("bread", city["stockpile"]) + self.assertLess(city["population"], 20) + + def test_fed_city_grows_to_housing_and_pays_tax(self): + world = world_with() + city = world["city"] + city["stockpile"] = {"bread": 1000} + world["next_event_at"] = None + game.tick(world, T0 + 10 * game.DAY, random.Random(1)) + self.assertFalse(city["starving"]) + self.assertEqual(city["population"], 25) + self.assertGreater(city["treasury"], 100) + + def test_speed_scales_time(self): + world = game.new_world(T0, speed=60) + world["players"]["fred"] = game.new_player("fred", "Fred", "warrior", T0) + p = world["players"]["fred"] + game.start_action(world, p, "mine_stone", T0) + game.process_player(world, p, T0 + 10, random.Random(1)) + self.assertEqual(p["bank"]["stone"], 30) + + +class EventTest(unittest.TestCase): + def announce(self, world): + world["next_event_at"] = T0 + game.tick(world, T0, random.Random(2)) + return game.upcoming_raid(world) + + def test_first_event_is_a_raid_with_vague_intel(self): + world = world_with() + raid = self.announce(world) + self.assertIsNotNone(raid) + view = game.event_view(world, raid, T0) + self.assertEqual(view["intel"], 0) + self.assertNotIn("strength", view) + self.assertNotIn("group", view) + self.assertLessEqual(view["earliest"], raid["fire_at"]) + self.assertGreaterEqual(view["latest"], raid["fire_at"]) + + def test_spymaster_and_recon_sharpen_intel(self): + world = world_with(("sam", "scout")) + raid = self.announce(world) + world["city"]["buildings"]["spymaster"]["level"] = 1 + self.assertIn("size", game.event_view(world, raid, T0)) + p = world["players"]["sam"] + p["xp"]["reconnaissance"] = game.XP_TABLE[20] + game.start_action(world, p, "recon_camps", T0) + game.process_player(world, p, T0 + 8 * 72, random.Random(1)) + view = game.event_view(world, raid, T0) + self.assertEqual(view["intel"], 3) + self.assertEqual(view["exact"], raid["fire_at"]) + self.assertIn("strength", view) + self.assertTrue(view["camp"]) + + def test_victory_rewards_garrison(self): + world = world_with(("wil", "warrior")) + raid = self.announce(world) + p = world["players"]["wil"] + p["xp"]["combat"] = game.XP_TABLE[60] + p["garrison"] = True + game.tick(world, raid["fire_at"], random.Random(1)) + self.assertEqual(raid["status"], "done") + self.assertTrue(raid["report"]["title"].startswith("Victory")) + self.assertEqual(world["city"]["repelled"], 1) + self.assertEqual(p["favour"], 25) + self.assertFalse(p["garrison"]) + + def test_defeat_culls_and_loots_but_spares_banks(self): + world = world_with(("fred", "warrior")) + p = world["players"]["fred"] + p["bank"] = {"stone": 500} + city = world["city"] + city["stockpile"] = {"bread": 200, "stone": 1000} + city["treasury"] = 500 + city["buildings"]["walls"]["level"] = 1 + raid = self.announce(world) + raid["strength"] = 500 + game.tick(world, raid["fire_at"], random.Random(1)) + self.assertTrue(raid["report"]["title"].startswith("Defeat")) + self.assertLess(city["population"], 20) + self.assertLess(city["stockpile"]["stone"], 1000) + self.assertLess(city["treasury"], 500) + self.assertTrue(city["buildings"]["walls"]["damaged"]) + self.assertEqual(city["buildings"]["walls"]["level"], 1) + self.assertEqual(p["bank"], {"stone": 500}) + self.assertEqual(game.defence(world)["parts"]["walls"], 0) + + def test_repair_is_cheaper_than_building(self): + cost = game.repair_cost("walls", 2) + full = game.build_cost("walls", 2) + for item, qty in cost.items(): + self.assertLess(qty, full[item]) + + def test_sabotage_needs_a_located_camp(self): + world = world_with(("wil", "warrior")) + p = world["players"]["wil"] + p["xp"]["combat"] = game.XP_TABLE[40] + raid = self.announce(world) + self.assertEqual(game.start_action(world, p, "sabotage", T0), "No bandit camp has been located") + raid["recon"] = 3 * game.RECON_PER_INTEL + self.assertIsNone(game.start_action(world, p, "sabotage", T0)) + game.process_player(world, p, T0 + 96 * 20, random.Random(1)) + self.assertGreater(raid["sabotage"], 0) + self.assertLessEqual(raid["sabotage"], 0.3) + + +if __name__ == "__main__": + unittest.main()