Files
Cinderwild/tools/site/make_guide.py

345 lines
18 KiB
Python

#!/usr/bin/env python3
"""Builds the help pages, build/site/guide.html: the written guide
(tools/site/guide_sections.md) plus reference tables made from the game's own
data, so they can't drift from the game: every item (data/items.json), every
recipe (data/recipes.json), the fish (data/fish.json), and the creatures and
server settings (dumped from the scripts by tools/site/dump_data.gd).
python3 tools/site/make_guide.py <site_data.json> <out dir>
Called by tools/site/make_site.sh.
"""
import html, json, os, re, sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
esc = lambda s: html.escape(str(s), quote=True)
# --- the written guide: a small, strict markdown subset -----------------------------
def inline(s):
s = esc(s)
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", s)
return s
def written_sections():
"""[(id, title, html)] from guide_sections.md."""
out, cur, buf, in_list = [], None, [], False
def close_list():
nonlocal in_list
if in_list:
buf.append("</ul>")
in_list = False
for raw in open(os.path.join(ROOT, "tools", "site", "guide_sections.md"), encoding="utf-8"):
line = raw.rstrip("\n")
m = re.match(r"^## (.+?) \{#([a-z0-9-]+)\}\s*$", line)
if m:
close_list()
if cur:
out.append((cur[1], cur[0], "\n".join(buf)))
cur, buf = (m.group(1), m.group(2)), []
continue
if cur is None:
continue
if line.startswith("### "):
close_list()
buf.append("<h3>%s</h3>" % inline(line[4:]))
elif line.startswith("- "):
if not in_list:
buf.append("<ul>")
in_list = True
buf.append("<li>%s</li>" % inline(line[2:]))
elif line.strip() == "":
close_list()
else:
close_list()
buf.append("<p>%s</p>" % inline(line))
close_list()
if cur:
out.append((cur[1], cur[0], "\n".join(buf)))
return out
# --- reference tables from the game's data ---------------------------------------------
def table(headers, rows, cls=""):
h = "".join("<th scope=\"col\">%s</th>" % esc(x) for x in headers)
body = "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % c for c in r) for r in rows)
return "<div class=\"tbl\"><table class=\"%s\"><thead><tr>%s</tr></thead><tbody>%s</tbody></table></div>" % (cls, h, body)
def ref_sections(data):
items = json.load(open(os.path.join(ROOT, "data", "items.json")))
items = items["items"] if isinstance(items, dict) else items
recipes = json.load(open(os.path.join(ROOT, "data", "recipes.json")))
recipes = recipes["recipes"] if isinstance(recipes, dict) else recipes
fish = json.load(open(os.path.join(ROOT, "data", "fish.json")))
by_id = {i["id"]: i for i in items}
name = lambda i: by_id[i]["name"] if i in by_id else i.replace("_", " ").title()
stations = {"": "By hand", "workbench": "Workbench", "campfire": "Campfire", "forge": "Forge",
"stonecutter": "Stonecutter"}
out = []
# Items, by category.
cats = [("resource", "Materials"), ("tool", "Tools and clothing"), ("weapon", "Weapons"), ("food", "Food"),
("building", "Building pieces"), ("placeable", "Things to place")]
parts = []
for cat, title in cats:
rows = []
for i in items:
if i.get("category") != cat or i.get("hidden"):
continue
facts = []
f = i.get("food")
if f:
facts.append("food %+d" % f.get("hunger", 0))
if f.get("health"):
facts.append("health %+d" % f["health"])
for g, v in f.get("diet", {}).items():
facts.append("%s %+d" % ({"plants": "greens"}.get(g, g), v))
if f.get("risk"):
facts.append("%d%% chance of food poisoning" % round(f["risk"] * 100))
if "keeps" in i:
facts.append("keeps %s days" % i["keeps"])
t = i.get("tool")
if t:
facts.append("%s, tier %s" % (t.get("kind", ""), t.get("tier", 1)))
w = i.get("weapon")
if w and "damage" in w:
facts.append("damage %s" % w["damage"])
if w and "throw" in w:
facts.append("thrown: damage %s" % w["throw"].get("damage"))
b = i.get("bow")
if b:
facts.append("damage %s to %s, by draw" % tuple(b["damage"]))
if "wear" in i:
facts.append("+%s warmth" % i["wear"].get("warmth", 0))
if "durability" in i:
facts.append("%s uses" % i["durability"])
if "fuel" in i:
facts.append("burns %s s" % i["fuel"])
rows.append(["<b>%s</b>" % esc(i["name"]), esc(i.get("description", "")), esc(", ".join(facts))])
if rows:
parts.append("<h3 id=\"items-%s\">%s</h3>" % (cat, esc(title)) + table(["Item", "What it is", "Numbers"], rows))
out.append(("items", "All items", "<p>Every item in the game, from its own data. Food values are how much a "
"bite fills each bar; diet groups are meat, fish and greens.</p>" + "".join(parts)))
# Recipes, by where you make them.
parts = []
for st in ["", "workbench", "campfire", "stonecutter", "forge"] + sorted(
{r["station"] for r in recipes} - set(stations)):
rows = []
for r in recipes:
if r["station"] != st:
continue
ins = ", ".join("%d %s" % (n, esc(name(k))) for k, n in r["inputs"].items())
makes = "<b>%s</b>%s" % (esc(name(r["output"])), " x%d" % r["count"] if r["count"] > 1 else "")
rows.append([makes, ins])
if rows:
parts.append("<h3 id=\"recipes-%s\">%s</h3>" % (st or "hand", esc(stations.get(st, st.title())))
+ table(["Makes", "From"], rows))
out.append(("recipes", "All recipes", "<p>What you can make and where. <b>By hand</b> needs nothing nearby; the "
"others need that station within about 6 m (a campfire must be lit).</p>" + "".join(parts)))
# Creatures.
order = ["rabbit", "deer", "boar", "crow", "fox", "wolf", "alpha", "bear", "guard", "spirit_wolf", "old_stag"]
cr = data["creatures"]
rows = []
for k in order + sorted(set(cr) - set(order)):
if k not in cr:
continue
c = cr[k]
drops = ", ".join("%s %s" % (("%d" % a[0]) if a[0] == a[1] else "%d-%d" % (a[0], a[1]), esc(name(i)))
for i, a in c.get("drops", {}).items()) or "nothing"
dmg = "%g every %g s" % (c["damage"], c.get("attack_cd", 1)) if c.get("damage") else "harmless"
rows.append(["<b>%s</b>" % esc(c["name"]), "%g" % c["hp"], dmg, "%g m/s" % c.get("run", 0), drops])
out.append(("creatures", "All creatures", "<p>Health, how hard each hits (on normal difficulty), its top speed "
"and what it leaves when butchered. Your walk is 5 m/s and your sprint 8 m/s.</p>"
+ table(["Creature", "Health", "Damage", "Runs", "Drops"], rows)))
# Fish.
rows = []
for water, w in fish.get("waters", {}).items():
total = sum(f["weight"] for f in w["fish"])
total_dd = sum(f.get("dawn_dusk", f["weight"]) for f in w["fish"])
for f in w["fish"]:
rows.append(["<b>%s</b>" % esc(name(f["id"])), esc(water.title()),
"%d%%" % round(100 * f["weight"] / total),
"%d%%" % round(100 * f.get("dawn_dusk", f["weight"]) / total_dd)])
m = fish.get("modifiers", {})
out.append(("fish", "Fish", "<p>What bites where. Bites come %g to %g s after the float settles, sooner at dawn "
"(05:00-08:30) and dusk (17:00-20:30) and in rain.</p>"
% tuple(fish["waters"]["lake"]["bite_wait"])
+ table(["Fish", "Water", "Usually", "At dawn and dusk"], rows)))
# Server settings.
desc = {
"server_name": "Its name in the player list", "motd": "The welcome message",
"password": "Players need it to join (blank: anyone)", "port": "The UDP port players connect to",
"max_players": "How many can play at once", "world": "The save folder for the island",
"worldlet": "The island type", "seed": "The island's seed, for a new world",
"pvp": "Players can hurt each other", "raiding": "Players can take down others' buildings",
"locks": "Who can open your chests and doors and use your bed",
"death_penalty": "What happens to your things when you die",
"sleep": "How many must be in bed to sleep through the night",
"pause_when_empty": "The island waits while nobody is on",
"day_length_minutes": "Real minutes in a whole day", "wolf_grace_days": "Days before the wolves move in",
"difficulty": "How hard creatures hit and how fast you get hungry", "rot": "How fast food spoils",
"autosave_minutes": "How often it saves", "backups_kept": "How many backups it keeps",
"backup_hours": "How often it backs up while running", "check_updates": "Says in the log when a newer version is out",
"admins": "Player ids of admins", "allowlist": "If not empty, only these player ids may join",
"banned": "Player ids that may not join",
}
settable = data.get("server_settable", {})
rows = []
for k, v in data["server_defaults"].items():
if k == "worldlet":
continue
dv = ", ".join(v) if isinstance(v, list) else ("(none)" if v in ("", []) else str(v).lower())
opts = settable.get(k)
live = ("yes: " + " / ".join(opts)) if isinstance(opts, list) else (("yes: " + opts) if opts else "no")
rows.append(["<code>%s</code>" % esc(k), esc(desc.get(k, "")), esc(dv), esc(live)])
out.append(("server-settings", "Server settings", "<p>Everything in a server's <code>server.json</code>, with its "
"default. Admins can change those marked live while it runs with <code>/set &lt;setting&gt; "
"&lt;value&gt;</code>; the rest take effect when the server restarts.</p>"
+ table(["Setting", "What it does", "Default", "Change live"], rows)))
return out
# --- the page ----------------------------------------------------------------------------
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Emberwild guide: everything in the game</title>
<meta name="description" content="How to play Emberwild: controls, survival, food, building, weather, wildlife, hunting, fishing, iron, the Old Stag and multiplayer, with every item, recipe and creature.">
<style>
:root { --bg: #15110d; --panel: #221b14; --line: #3a2e22; --text: #f1e8da; --dim: #b8a892;
--ember: #e8833a; --ember-hi: #f6a35c; --mark: #5a3a14; }
* { box-sizing: border-box; }
html { scroll-padding-top: 16px; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 17px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
a { color: var(--ember-hi); }
a:focus-visible, input:focus-visible { outline: 3px solid var(--ember-hi); outline-offset: 2px; }
.skip { position: absolute; left: -999px; } .skip:focus { left: 12px; top: 12px; background: var(--panel); padding: 8px; z-index: 9; }
.layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 36px; max-width: 1180px; margin: 0 auto; padding: 0 20px; }
nav.toc { position: sticky; top: 0; align-self: start; max-height: 100vh; overflow: auto; padding: 24px 0; }
nav.toc .home { display: block; font-weight: 700; letter-spacing: .08em; font-size: 22px; color: var(--text); text-decoration: none; margin-bottom: 14px; }
nav.toc input { width: 100%; padding: 9px 11px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--text); font-size: 15px; }
nav.toc ol { list-style: none; padding: 0; margin: 14px 0 0; }
nav.toc li a { display: block; padding: 4px 8px; border-radius: 6px; color: var(--dim); text-decoration: none; font-size: 15px; }
nav.toc li a:hover { background: var(--panel); color: var(--text); }
nav.toc .group { color: var(--dim); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin: 14px 8px 4px; }
main { padding: 28px 0 60px; min-width: 0; }
h1 { font-size: 38px; margin: 0 0 6px; } .lead { color: var(--dim); margin: 0 0 28px; }
section { border-top: 1px solid var(--line); padding: 22px 0 6px; }
h2 { font-size: 27px; margin: 0 0 10px; } h3 { font-size: 19px; margin: 22px 0 6px; color: var(--ember-hi); }
p, ul { margin: 0 0 12px; } li { margin-bottom: 4px; }
code { background: var(--panel); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; font-size: .9em; color: var(--text); }
.tbl { overflow-x: auto; margin: 6px 0 16px; border: 1px solid var(--line); border-radius: 8px; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); }
th { background: var(--panel); color: var(--dim); font-weight: 600; position: sticky; top: 0; }
tbody tr:last-child td { border-bottom: 0; }
mark { background: var(--mark); color: var(--text); border-radius: 3px; }
.hidden { display: none; } .none { color: var(--dim); }
footer { color: var(--dim); font-size: 14px; border-top: 1px solid var(--line); padding: 20px 0 40px; }
@media (max-width: 820px) { .layout { grid-template-columns: 1fr; } nav.toc { position: static; max-height: none; padding-bottom: 0; } }
</style>
</head>
<body>
<a class="skip" href="#content">Skip to the guide</a>
<div class="layout">
<nav class="toc" aria-label="Guide contents">
<a class="home" href="index.html">EMBERWILD</a>
<label for="q" class="group" style="display:block;margin:0 0 6px">Search the guide</label>
<input id="q" type="search" placeholder="wolves, bed, iron..." autocomplete="off">
<ol>
<li class="group">How to play</li>
{TOC_GUIDE}
<li class="group">Reference</li>
{TOC_REF}
</ol>
</nav>
<main id="content">
<h1>The Emberwild guide</h1>
<p class="lead">Everything in Emberwild {VERSION}: how to play, what can happen, and every item, recipe and
creature. <a href="index.html">Download the game</a>.</p>
<p id="none" class="none hidden" role="status">Nothing matches that.</p>
{SECTIONS}
<footer>Emberwild {VERSION}. The reference tables are made from the game's own data each time this page is built.</footer>
</main>
</div>
<script>
// Search: hide sections (and table rows, list items, paragraphs) without the words; highlight them.
(function () {
var q = document.getElementById("q"), none = document.getElementById("none");
var secs = Array.prototype.slice.call(document.querySelectorAll("main section"));
var bits = secs.map(function (s) { return Array.prototype.slice.call(s.querySelectorAll("p, li, tbody tr, h3")); });
function clear(el) { el.querySelectorAll("mark").forEach(function (m) { m.replaceWith(m.textContent); }); el.normalize(); }
function mark(el, words) {
var walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), nodes = [], n;
while ((n = walk.nextNode())) nodes.push(n);
var re = new RegExp("(" + words.map(function (w) { return w.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&"); }).join("|") + ")", "gi");
nodes.forEach(function (t) {
if (!re.test(t.nodeValue)) return; re.lastIndex = 0;
var span = document.createElement("span");
span.innerHTML = t.nodeValue.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(re, "<mark>$1</mark>");
t.replaceWith(span);
});
}
var timer;
q.addEventListener("input", function () { clearTimeout(timer); timer = setTimeout(run, 120); });
function run() {
var words = q.value.toLowerCase().split(/\\s+/).filter(function (w) { return w.length > 1; });
var shown = 0;
secs.forEach(function (s, i) {
clear(s);
if (!words.length) { s.classList.remove("hidden"); bits[i].forEach(function (b) { b.classList.remove("hidden"); }); return; }
var any = false;
bits[i].forEach(function (b) {
var t = b.textContent.toLowerCase(), hit = words.every(function (w) { return t.indexOf(w) >= 0; });
b.classList.toggle("hidden", !hit && b.tagName !== "H3"); any = any || hit;
});
var title = s.querySelector("h2").textContent.toLowerCase();
if (words.every(function (w) { return title.indexOf(w) >= 0; })) { any = true; bits[i].forEach(function (b) { b.classList.remove("hidden"); }); }
s.classList.toggle("hidden", !any);
if (any) { shown++; mark(s, words); }
});
none.classList.toggle("hidden", shown > 0 || !words.length);
}
})();
</script>
</body>
</html>
"""
def main():
data = json.load(open(sys.argv[1]))
out_dir = sys.argv[2]
guide = written_sections()
ref = ref_sections(data)
ids = [i for i, _, _ in guide + ref]
assert len(ids) == len(set(ids)), "two sections share an id"
toc = lambda secs: "\n".join(" <li><a href=\"#%s\">%s</a></li>" % (i, esc(t)) for i, t, _ in secs)
sections = "\n".join("<section id=\"%s\" aria-labelledby=\"h-%s\"><h2 id=\"h-%s\">%s</h2>\n%s\n</section>"
% (i, i, i, esc(t), body) for i, t, body in guide + ref)
page = PAGE.replace("{TOC_GUIDE}", toc(guide)).replace("{TOC_REF}", toc(ref)) \
.replace("{SECTIONS}", sections).replace("{VERSION}", esc(data.get("version", "")))
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "guide.html"), "w", encoding="utf-8") as f:
f.write(page)
words = len(re.sub(r"<[^>]+>", " ", sections).split())
print("built %s: %d written sections, %d reference tables, ~%d words" %
(os.path.relpath(os.path.join(out_dir, "guide.html"), ROOT), len(guide), len(ref), words))
if __name__ == "__main__":
main()