Guildhall alpha: co-op idle game

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 13:48:56 +01:00
commit 9058039eb7
14 changed files with 2815 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
*.sqlite3
result

21
LICENSE Normal file
View file

@ -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.

46
README.md Normal file
View file

@ -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).

411
app.py Normal file
View file

@ -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()

27
flake.lock generated Normal file
View file

@ -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
}

36
flake.nix Normal file
View file

@ -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
'';
});
};
}

921
game.py Normal file
View file

@ -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,
}

97
module.nix Normal file
View file

@ -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;
};
};
})
]);
}

26
package.nix Normal file
View file

@ -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";
};
}

643
static/app.js Normal file
View file

@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
const xp = Object.entries(sum.xp).map(([id, q]) => `<li>${skillOf(id).icon} ${fmt(q)} ${esc(skillOf(id).name)} XP</li>`).join("");
const levels = sum.levels.map(([id, l]) => `<li>🎉 ${esc(skillOf(id).name)} reached level ${l}</li>`).join("");
const spilled = Object.entries(sum.overflow).map(([id, q]) => `<li>${item(id).icon} ${fmt(q)} ${esc(item(id).name)}</li>`).join("");
openModal(`
<h2>While you were away</h2>
<p class="muted">You completed ${fmt(sum.done)} actions${sum.wins || sum.losses ? ` (${sum.wins} fights won, ${sum.losses} lost)` : ""}.</p>
${items ? `<ul>${items}</ul>` : ""}
${sum.gold ? `<p class="gold">+${fmt(sum.gold)} gold</p>` : ""}
${xp ? `<ul>${xp}</ul>` : ""}
${levels ? `<ul>${levels}</ul>` : ""}
${spilled ? `<p class="muted small">Your bank was full, so this went to the city stockpile (you earned favour for it):</p><ul>${spilled}</ul>` : ""}
${sum.stopped ? `<p class="tag bad">Stopped: ${esc(sum.stopped)}</p>` : ""}
<p><button class="btn primary" data-close>Back to work</button></p>`);
}
function openModal(html) {
const modal = $("#modal");
modal.innerHTML = `<div class="modal-box">${html}</div>`;
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() : `<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>${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 `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
}
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 = `
<div class="brand"><img src="/static/icon.svg" alt="">Guildhall <small>alpha</small></div>
<div class="me">
<div class="who">${esc(me.name)}</div>
<div class="cls">${cls.icon} ${cls.name}</div>
<div class="purse"><span class="gold" title="Gold">🪙 ${fmt(me.gold)}</span><span class="favour" title="Favour">✨ ${fmt(me.favour)}</span></div>
</div>
<div class="navhead">City</div>
${nav("town", "🏘️", "Town square", `${S.city.population}`)}
${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "")}
${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`)}
<div class="navhead">You</div>
${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 += `<div class="navhead">${group}</div>`;
}
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
}
return html + (S.dev ? devbar() : "");
}
function devbar() {
return `<div class="devbar"><b>Dev mode</b> · playing as ${esc(S.uid)}
<form data-form="dev-user"><input type="text" data-keep="dev-user" placeholder="switch user"><button class="btn sm">Go</button></form>
<div class="row">
<button class="btn sm" data-dev-advance="1">+1h</button>
<button class="btn sm" data-dev-advance="8">+8h</button>
<button class="btn sm" data-dev-advance="24">+1d</button>
<button class="btn sm" data-dev-event>Event now</button>
</div></div>`;
}
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 += `<button class="chip act" data-nav="skill:${action.skill}">${skillOf(action.skill).icon} <span class="label">${esc(action.name)}</span><span class="bar mini"><span data-progress></span></span></button>`;
} else if (S.me) {
chips += `<span class="chip muted">Idle${S.me.stopped ? ` · ${esc(S.me.stopped)}` : ""}</span>`;
}
if (S.upcoming) chips += `<button class="chip ${S.upcoming.kind === "raid" ? "threat" : ""}" data-nav="council">${eventIcon(S.upcoming.kind)} ${eventWhen(S.upcoming, true)}</button>`;
return `<button class="menu-btn" data-menu aria-label="Menu">☰</button><h1>${esc(title)}</h1>${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 `<span class="item-chip ${short ? "short" : ""}" title="${esc(item(id).name)}">${item(id).icon} ${fmt(qty)}${have !== undefined ? ` <span class="have">/ ${fmt(have)}</span>` : ""}</span>`;
}
// --- 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 = `
<div class="panel"><div class="skill-head">
<div class="big">${skill.icon}</div>
<div class="info">
<h2>${skill.name}</h2>
<div class="lv">Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP</div>
<div class="bar xp thick"><span style="width:${pct}%"></span></div>
</div>
${fast ? `<span class="tag gold">${cls.icon} ${cls.name} bonus</span>` : ""}
</div></div>`;
if (id === "combat") html += combatPanel();
if (id === "reconnaissance") html += `<div class="panel small muted">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.</div>`;
html += `<div class="grid">${C.actions.filter((a) => a.skill === id).map(actionCard).join("")}</div>`;
return html;
}
function combatPanel() {
const me = S.me;
const slot = (name, label) => {
const id = me.equip[name];
return `<div class="item-chip">${id ? `${item(id).icon} ${esc(item(id).name)} (+${item(id).power}) <button class="btn sm" data-unequip="${name}">Unequip</button>` : `<span class="muted">No ${label}</span>`}</div>`;
};
return `<div class="panel"><h2>Equipment</h2>
<div class="btns">${slot("weapon", "weapon")} ${slot("shield", "shield")}</div>
<p class="small muted">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.</p></div>`;
}
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("") + `<span class="arrow">→</span>`;
}
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 = `<div class="small">Win chance <b>${Math.round(chance * 100)}%</b>${a.gold ? ` · ${a.gold} gold` : ""}</div>`;
if (a.drops) io += a.drops.map(([id, q, odds]) => `<span class="item-chip" title="${Math.round(odds * 100)}% chance">${item(id).icon} ${q} <span class="have">${Math.round(odds * 100)}%</span></span>`).join("");
if (a.special === "sabotage") extra += `<div class="small muted">Each win weakens the coming raid by 3%, up to 30%.</div>`;
}
if (a.special === "recon") extra = `<div class="small muted">+${a.recon} recon on the next event</div>`;
return `
<div class="action ${active ? "active" : ""} ${locked ? "locked" : ""}">
<div class="top"><div class="ico">${icon}</div><div>
<div class="name">${esc(a.name)}</div>
<div class="sub">Level ${a.level} · ${dur(time)} · ${a.xp} XP</div>
</div></div>
${io ? `<div class="io">${io}</div>` : ""}
${extra}
${active ? `<div class="bar thick"><span data-progress></span></div>` : ""}
<div class="foot">
<span class="why">${!active && why ? esc(why) : ""}</span>
${active ? `<button class="btn danger" data-stop>Stop</button>` : `<button class="btn primary" data-start="${a.id}" ${why ? "disabled" : ""}>Start</button>`}
</div>
</div>`;
}
// --- 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]) => `
<button class="slot ${selected === id ? "on" : ""}" data-select="${id}" title="${esc(item(id).name)}">
<span class="qty ${q >= me.stack ? "full" : ""}">${fmt(q)}</span>${item(id).icon}<span class="nm">${esc(item(id).name)}</span>
</button>`).join("");
for (let i = entries.length; i < me.slots; i++) slots += `<div class="slot empty"></div>`;
return `
<div class="bank-wrap">
<div>
<div class="panel">
<h2>Bank · ${entries.length} / ${me.slots} slots · up to ${fmt(me.stack)} each</h2>
<div class="slots">${slots}</div>
<p class="small muted">When an item doesn't fit:
<button class="btn sm ${me.overflow === "city" ? "gold" : ""}" data-overflow="city">Send it to the city</button>
<button class="btn sm ${me.overflow === "stop" ? "gold" : ""}" data-overflow="stop">Stop working</button>
<br>Raids never touch your bank. Overflow sent to the city still earns favour.</p>
</div>
${favourPanel()}
</div>
<div>${selected ? itemPanel(selected) : goldPanel()}</div>
</div>`;
}
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) => `<button class="btn sm" ${attr}="${id}" data-qty="${q}">${q}</button>`).join("")
+ `<button class="btn sm" ${attr}="${id}" data-qty="all">All (${fmt(have)})</button>`;
return `<div class="panel item-panel">
<div class="ico">${it.icon}</div>
<h3>${esc(it.name)}</h3>
<div class="muted small">You have ${fmt(have)} · worth ${price % 1 ? price.toFixed(1) : price} gold each${merchant ? " (merchant in town!)" : ""}</div>
${it.slot ? `<div class="row"><button class="btn gold" data-equip="${id}">Equip ${it.slot} (+${it.power} power)</button></div>` : ""}
${it.food ? `<div class="row small muted">Feeds ${it.food} townsfolk for two days in the city granary.</div>` : ""}
<div class="row"><div class="k">Donate to the city · +${it.value} favour each</div><div class="btns">${qtyBtns("data-donate")}</div></div>
${others.length ? `<div class="row"><div class="k">Give to <select data-keep="give-to">${others.map((p) => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join("")}</select></div><div class="btns">${qtyBtns("data-give")}</div></div>` : ""}
<div class="row"><div class="k">Sell</div><div class="btns">${qtyBtns("data-sell")}</div></div>
<div class="row"><button class="btn sm" data-select="">Close</button></div>
</div>${goldPanel()}`;
}
function goldPanel() {
return `<div class="panel"><h2>Gold</h2>
<p class="gold">🪙 ${fmt(S.me.gold)}</p>
<form data-form="gold" class="btns"><input type="number" min="1" data-keep="gold" placeholder="Amount" style="width:120px"><button class="btn gold">Donate to treasury</button></form>
<p class="small muted">The treasury pays the gold part of new buildings. 1 favour per gold.</p></div>`;
}
function favourPanel() {
const me = S.me;
return `<div class="panel"><h2>Favour with the council</h2>
<p class="favour">✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total</p>
<div class="btns"><button class="btn" data-perk="bank_slot" ${me.favour < me.perk_costs.bank_slot ? "disabled" : ""}>+1 bank slot (${fmt(me.perk_costs.bank_slot)} favour)</button></div>
<p class="small muted">More perks to come. The council remembers who helped.</p></div>`;
}
// --- town -----------------------------------------------------------------
function town() {
const city = S.city;
let html = `<div class="stats">
<div class="stat"><div class="k">Day</div><div class="v">${city.day}</div></div>
<div class="stat"><div class="k">Population</div><div class="v">${city.population} <small>/ ${city.housing} homes</small></div></div>
<div class="stat"><div class="k">Treasury</div><div class="v gold">${fmt(city.treasury)}</div></div>
<div class="stat"><div class="k">Defence</div><div class="v">${fmt(city.defence.total)}</div></div>
<div class="stat"><div class="k">Raids repelled</div><div class="v">${city.repelled}</div></div>
</div>`;
if (city.starving) html += `<div class="banner bad">🍞<div class="grow"><b>The granary is empty.</b> People are leaving and the city defends worse. Donate bread, cooked meat or stew.</div></div>`;
if (city.merchant_until > serverNow()) html += `<div class="banner">🐪<div class="grow"><b>A merchant caravan is in town.</b> Everything sells for 50% more for another ${dur(city.merchant_until - serverNow())}.</div></div>`;
const work = city.construction;
if (work) {
const spec = C.buildings[work.building];
html += `<div class="banner">🏗️<div class="grow"><b>${work.repair ? "Repairing" : "Building"} ${esc(spec.name)}</b> · done in <span data-countdown="${work.done_at}">${dur(work.done_at - serverNow())}</span>
<div class="bar gold" style="margin-top:6px"><span data-span="${work.start},${work.done_at}"></span></div></div></div>`;
}
html += `<div class="section-title">Buildings</div><div class="grid wide">`;
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"
? `<span class="item-chip ${city.treasury < q ? "short" : ""}">🪙 ${fmt(q)} <span class="have">/ ${fmt(city.treasury)}</span></span>`
: itemChip(id, q, city.stockpile[id] || 0)).join("") : "";
const label = b.damaged ? "Repair" : `Build level ${b.level + 1}`;
html += `<div class="action building ${b.damaged ? "damaged" : ""}">
<div class="top"><div class="ico">${spec.icon}</div><div>
<div class="name">${esc(spec.name)} <span class="lv muted">${b.level} / ${spec.max}</span> ${b.damaged ? `<span class="tag bad">Damaged</span>` : ""}</div>
<div class="sub">${esc(spec.effect)}</div>
</div></div>
${b.cost ? `<div class="cost">${cost}</div>
<div class="foot"><span class="why">${work ? "Builders busy" : ""}</span>
<button class="btn ${b.damaged ? "danger" : "primary"}" data-build="${key}" ${b.missing.length || work ? "disabled" : ""}>${label}</button></div>`
: `<div class="tag good">Fully built</div>`}
</div>`;
}
html += `</div><div class="section-title">City stockpile</div><div class="panel">`;
const stock = Object.entries(city.stockpile);
html += stock.length ? `<div class="stock">${stock.map(([id, q]) => itemChip(id, q)).join("")}</div>` : `<p class="muted">Empty. Donate from your bank.</p>`;
html += `<p class="small muted">Anyone can start a building once the stockpile and treasury cover it. Until then, the stockpile is what raiders come for.</p></div>`;
return html;
}
// --- council --------------------------------------------------------------
function pips(level) {
return `<span class="pips" title="Intel level ${level} of 3">${[1, 2, 3].map((i) => `<i class="${i <= level ? "on" : ""}"></i>`).join("")}</span>`;
}
function council() {
const ev = S.upcoming, city = S.city, me = S.me;
let html = "";
if (!ev) {
html += `<div class="panel"><h2>All quiet</h2><p class="muted">No news on the roads. The spymasters will report as soon as something stirs.</p></div>`;
} else {
html += `<div class="panel ${ev.kind === "raid" ? "threat-card" : ""}">
<h2>${eventIcon(ev.kind)} ${ev.kind === "raid" ? "Threat" : "News"} · intel ${pips(ev.intel)}</h2>
<div class="headline">${esc(ev.headline)}</div>
<div class="kv">
<span class="k">Expected</span><span>${eventWhen(ev)}</span>
${ev.range ? `<span class="k">Strength</span><span>${ev.strength ?? `${ev.range[0]}–${ev.range[1]}`}</span>` : ""}
${ev.trait ? `<span class="k">Scouts say</span><span>${esc(ev.trait)}</span>` : ""}
${ev.camp ? `<span class="k">Their camp</span><span>Located. Fighters can sabotage it from the combat page${ev.sabotage ? ` (weakened ${ev.sabotage}% so far)` : ""}.</span>` : ""}
${me.cls === "scout" || ev.recon ? `<span class="k">Reconnaissance</span><span>${ev.intel < 3 ? `${ev.recon} points · ${ev.recon_next} more for the next intel level` : `${ev.recon} points · nothing more to learn`}</span>` : ""}
</div>
${ev.intel < 3 ? `<p class="small muted">Better intel comes from the spymaster's guild and scouts' reconnaissance.</p>` : ""}`;
if (ev.kind === "raid") {
const strength = ev.strength ?? (ev.range ? `${ev.range[0]}–${ev.range[1]}` : "?");
html += `<div class="vs"><div class="side"><span class="muted small">Our defence</span><b>${fmt(city.defence.total)}</b></div><span class="muted">vs</span><div class="side"><span class="muted small">Raiders</span><b>${strength}</b></div></div>`;
}
html += `</div>`;
if (ev.kind === "raid") html += raidPrep(ev);
}
html += `<div class="section-title">Past events</div>`;
if (!S.history.length) html += `<p class="muted">Nothing has happened yet.</p>`;
for (const h of S.history) {
if (!h.report) continue;
const win = h.kind !== "raid" || h.report.title.startsWith("Victory");
html += `<div class="report ${h.kind === "raid" ? (win ? "win" : "loss") : ""}">
<b>${eventIcon(h.kind)} ${esc(h.report.title)}</b> <span class="muted small">${ago(h.at)}</span>
${h.kind === "raid" ? `<p class="muted">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(", ")}` : ""}</p>` : ""}
${h.report.lines.map((l) => `<p>${esc(l)}</p>`).join("")}
</div>`;
}
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 `<div class="req"><span>${item(r.item).icon}</span><span>${r.item === "bread" ? "Food (any)" : esc(item(r.item).name)} <span class="muted small">${fmt(r.have)} / ${fmt(r.want)}</span></span>
${mine ? `<button class="btn sm gold" data-donate="${r.item}" data-qty="all">Give ${fmt(mine)}</button>` : "<span></span>"}
<div class="bar"><span style="width:${Math.min(100, r.have / r.want * 100)}%"></span></div></div>`;
}).join("");
return `<div class="cols">
<div class="panel"><h2>Council requests</h2>${reqs}
<p class="small muted">Arrows, swords and shields in the stockpile arm the defenders. Food keeps them fighting.</p></div>
<div class="panel"><h2>Defence</h2>
<table class="parts">${Object.entries(d.parts).map(([k, v]) => `<tr><td>${names[k]}</td><td>${fmt(v)}</td></tr>`).join("")}
${d.starving ? `<tr><td class="bad">Hungry (×0.8)</td><td></td></tr>` : ""}</table>
<h2 style="margin-top:16px">Garrison</h2>
<p class="small">${d.garrison.length ? d.garrison.map((g) => `${esc(g.name)} (${g.power})`).join(", ") : `<span class="muted">Nobody has signed up yet.</span>`}</p>
${me.garrison ? `<button class="btn" data-garrison="0">Leave the garrison</button>`
: `<button class="btn primary" data-garrison="1">Stand guard (+${me.cls === "warrior" ? me.power * 1.5 : me.power})</button>`}
<p class="small muted">You can keep working while you're signed up. Victory earns the garrison favour and combat XP.</p>
</div></div>`;
}
// --- board & roster -------------------------------------------------------
function board() {
const msgs = S.board.map((m) => `<div class="msg"><span class="who">${esc(playerName(m.who))}</span><span class="when">${ago(m.at)}</span><div class="text">${esc(m.text)}</div></div>`).join("");
const ledger = [...S.ledger].reverse().map((l) => `<li>${esc(l.text)}<span class="when">${ago(l.at)}</span></li>`).join("");
return `<div class="cols">
<div class="panel"><h2>Town board</h2>
<div class="msgs">${msgs || `<p class="muted">No messages yet. Say hello, or ask for that iron you need.</p>`}</div>
<form data-form="board"><textarea data-keep="board" maxlength="500" placeholder="Write to the guild…"></textarea>
<p><button class="btn primary">Post</button></p></form></div>
<div class="panel"><h2>Town ledger</h2><ul class="ledger">${ledger || `<li class="muted">Nothing recorded yet.</li>`}</ul></div>
</div>`;
}
function roster() {
return `<div class="grid wide">${S.players.map((p) => {
const cls = C.classes[p.cls];
const levels = C.skills.filter((s) => !s.class || s.class === p.cls).map((s) => `<div>${s.icon} <span>${s.name}</span> ${p.levels[s.id]}</div>`).join("");
return `<div class="panel person"><div class="top"><span class="ico">${cls.icon}</span><div>
<b>${esc(p.name)}</b> <span class="muted small">${cls.name} · total level ${p.total}</span>
<div class="small">${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `<span class="muted">Idle</span>`}${p.garrison ? ` · <span class="tag warn">On guard</span>` : ""}</div>
<div class="small favour">✨ ${fmt(p.donated)} given to the city</div></div></div>
<div class="levels">${levels}</div></div>`;
}).join("")}</div>`;
}
// --- registration ---------------------------------------------------------
function register() {
const cards = Object.entries(C.classes).map(([id, c]) => `
<button class="class-card ${pickedClass === id ? "on" : ""}" data-class="${id}">
<span class="ico">${c.icon}</span><span><b>${c.name}</b><p>${esc(c.bonus)}.</p><p>${esc(c.signature)}.</p></span>
</button>`).join("");
return `<div class="welcome">
<h2>A new city on the frontier</h2>
<p class="muted">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.</p>
<div class="classes">${cards}</div>
<form data-form="register" class="btns">
<input type="text" data-keep="name" maxlength="20" placeholder="Your adventurer's name" style="flex:1;min-width:200px">
<button class="btn primary" ${pickedClass ? "" : "disabled"}>Enter the city</button>
</form>
<p class="small muted">Every class can train every skill. Your class makes some things faster and gives you one thing only you can do.</p>
</div>`;
}
// --- 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 = `<p class="loading">Couldn't reach the city: ${esc(err.message)}</p>`;
return;
}
setInterval(() => { if (!document.hidden) refresh(); }, POLL_MS);
requestAnimationFrame(loop);
})();

1
static/icon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#14161d"/><path d="M14 52V26l6-4v-8h6v5l6-5 6 5v-5h6v8l6 4v26z" fill="#e0b04a"/><path d="M28 52V40a4 4 0 0 1 8 0v12z" fill="#14161d"/></svg>

After

Width:  |  Height:  |  Size: 244 B

24
static/index.html Normal file
View file

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#14161d">
<title>Guildhall</title>
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div id="app">
<aside id="side"></aside>
<div id="scrim" data-menu></div>
<div id="body">
<header id="top"></header>
<main id="main"><p class="loading">Opening the city gates…</p></main>
</div>
</div>
<div id="toasts" aria-live="polite"></div>
<div id="modal" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>

305
static/styles.css Normal file
View file

@ -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; }
}

254
tests/test_game.py Normal file
View file

@ -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()