From 9058039eb7d881d591cd6675199e798549b6f3b5 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 13:48:56 +0100 Subject: [PATCH] Guildhall alpha: co-op idle game Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + LICENSE | 21 ++ README.md | 46 +++ app.py | 411 ++++++++++++++++++++ flake.lock | 27 ++ flake.nix | 36 ++ game.py | 921 +++++++++++++++++++++++++++++++++++++++++++++ module.nix | 97 +++++ package.nix | 26 ++ static/app.js | 643 +++++++++++++++++++++++++++++++ static/icon.svg | 1 + static/index.html | 24 ++ static/styles.css | 305 +++++++++++++++ tests/test_game.py | 254 +++++++++++++ 14 files changed, 2815 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 game.py create mode 100644 module.nix create mode 100644 package.nix create mode 100644 static/app.js create mode 100644 static/icon.svg create mode 100644 static/index.html create mode 100644 static/styles.css create mode 100644 tests/test_game.py 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()