351 lines
18 KiB
Python
351 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)
|
|
# [text](https://...) links (after esc: the URL has no quotes or brackets).
|
|
s = re.sub(r"\[([^\]]+)\]\((https://[^)\s\"<>]+)\)", r'<a href="\2">\1</a>', 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"]:
|
|
depth = f.get("min_depth", 0) or w.get("min_depth", 0)
|
|
rows.append(["<b>%s</b>" % esc(name(f["id"])), esc(water.title()),
|
|
"%g m" % depth,
|
|
"%d%%" % round(100 * f["weight"] / total),
|
|
"%d%%" % round(100 * f.get("dawn_dusk", f["weight"]) / total_dd)])
|
|
waits = ", ".join("%g to %g s in the %s" % (w["bite_wait"][0], w["bite_wait"][1], water)
|
|
for water, w in fish.get("waters", {}).items())
|
|
out.append(("fish", "Fish", "<p>What bites where. Bites come %s after the float settles, sooner at dawn "
|
|
"(05:00-08:30) and dusk (17:00-20:30) and in rain. A fish that needs deeper water only bites "
|
|
"where the float is at least that deep (the share shown is where it can bite).</p>"
|
|
% waits
|
|
+ table(["Fish", "Water", "Deep enough", "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 <setting> "
|
|
"<value></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>Cinderwild guide: everything in the game</title>
|
|
<meta name="description" content="How to play Cinderwild: controls, survival, food, building, weather, wildlife, hunting, fishing, iron, the Old Stag, the journey to the Cove 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">CINDERWILD</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 Cinderwild guide</h1>
|
|
<p class="lead">Everything in Cinderwild {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>Cinderwild {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, "&").replace(/</g, "<").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()
|