Guildhall alpha: co-op idle game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
9058039eb7
14 changed files with 2815 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
__pycache__/
|
||||||
|
*.sqlite3
|
||||||
|
result
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
46
README.md
Normal 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
411
app.py
Normal 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
27
flake.lock
generated
Normal 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
36
flake.nix
Normal 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
921
game.py
Normal 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
97
module.nix
Normal 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
26
package.nix
Normal 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
643
static/app.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
1
static/icon.svg
Normal 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
24
static/index.html
Normal 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
305
static/styles.css
Normal 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
254
tests/test_game.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue