diff --git a/README.md b/README.md index ca75d4f..3718afa 100644 --- a/README.md +++ b/README.md @@ -79,3 +79,9 @@ snapshot and exits; the NixOS module runs it nightly when - `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). + +## Credits + +Icons are from [game-icons.net](https://game-icons.net) by Lorc, Delapouite +and contributors, used under [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). +`tools/build_icons.py` fetches the glyphs the game uses into `static/icons.svg`. diff --git a/game.py b/game.py index b797899..5ce99f8 100644 --- a/game.py +++ b/game.py @@ -2363,6 +2363,130 @@ def buy_perk(player: dict, perk: str) -> str | None: return None +# --- icons ------------------------------------------------------------------------ +# Every item, skill, class, building, prayer and output-less action gets a +# game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are +# told apart by colour: a rune sword is the copper sword in cyan. The sprite +# with just these glyphs is built by tools/build_icons.py into static/icons.svg. + +METAL_TINTS = {"copper": "#d07a3c", "iron": "#9aa1ab", "steel": "#d4dae2", + "mithril": "#6f8fec", "adamant": "#4cb878", "rune": "#3fd0e0"} +WOOD_TINTS = ["#b9844e", "#8f5e30", "#a3b86c", "#d0663a", "#6f9a4a", "#a77be8"] +LEATHER_TINTS = ["#b07a4a", "#8a5a32", "#6b4a33", "#5fae5a", "#4f86d6", "#d0503c"] +HERB_TINTS = {"guam_leaf": "#7fbf5a", "marrentill": "#e7c84a", "tarromin": "#d05a8a", + "harralander": "#f0b030", "ranarr": "#8a6fd0", "snapdragon": "#e86a9a"} +PIECE_GLYPHS = {"sword": "pointy-sword", "shield": "round-shield", "helmet": "visored-helm", + "legs": "leg-armor", "body": "breastplate"} +TOOL_GLYPHS = {"pickaxe": "war-pick", "axe": "wood-axe", "hoe": "sickle", "fishing rod": "fishing-pole", + "hunting knife": "bowie-knife", "hammer": "claw-hammer"} +LEATHER_GLYPHS = {"cap": "hood", "legs": "trousers", "body": "leather-vest"} +GLYPHS = { + "stone": ("stone-block", "#a8a29e"), "wheat": ("wheat", "#e3c060"), "vegetables": ("carrot", "#f08a3c"), + "feathers": ("feather", "#e8e8ee"), "raw_meat": ("meat", "#e06a6a"), "arrows": ("arrow-cluster", "#c9b28a"), + "bricks": ("brick-pile", "#c66a45"), "cut_stone": ("stone-stack", "#b8b2aa"), "beam": ("wood-beam", "#9a6a3a"), + "bread": ("bread", "#deb064"), "cooked_meat": ("ham-shank", "#cf8f50"), "stew": ("hot-meal", "#c9884a"), + "bones": ("broken-bone", "#e9dfc6"), "big_bones": ("crossed-bones", "#e9dfc6"), + "dragon_bones": ("dinosaur-bones", "#a6d98f"), "studded_body": ("leather-armor", "#a3825a"), + "coal": ("ore", "#5d626b"), "water_barrel": ("barrel", "#5aa0d8"), + "raw_shrimp": ("shrimp", "#f39a82"), "raw_trout": ("flatfish", "#9fb5c8"), "raw_salmon": ("salmon", "#f08a6a"), + "raw_eel": ("eel", "#7f9a6a"), "raw_lobster": ("crab", "#e05a3a"), "raw_swordfish": ("angler-fish", "#7fa3c8"), + "cooked_shrimp": ("shrimp", "#f0b07a"), "cooked_trout": ("fish-cooked", "#d9a060"), + "cooked_salmon": ("fish-cooked", "#f09a6a"), "smoked_eel": ("eel", "#b07a4a"), + "cooked_lobster": ("crab", "#f07a4a"), "cooked_swordfish": ("fish-cooked", "#c8a070"), +} +SKILL_GLYPHS = { + "mining": ("mining", "#b8b2aa"), "woodcutting": ("wood-axe", "#c98a4a"), "farming": ("wheat", "#e3c060"), + "hunting": ("deer-head", "#c49a6c"), "fishing": ("fishing-pole", "#5aa0d8"), "smithing": ("anvil", "#d07a3c"), + "masonry": ("brick-wall", "#c66a45"), "construction": ("hammer-nails", "#d1a46a"), + "cooking": ("cooking-pot", "#e0703a"), "leatherworking": ("sewing-needle", "#d6a26e"), + "herblore": ("cauldron", "#7fbf5a"), "combat": ("crossed-swords", "#e05a5a"), "prayer": ("prayer", "#e9dfc6"), + "reconnaissance": ("spyglass", "#6f8fec"), +} +CLASS_GLYPHS = {"warrior": ("black-knight-helm", "#c0c6d0"), "scout": ("bow-arrow", "#8fbf6a"), + "artisan": ("anvil-impact", "#e0a050"), "pioneer": ("compass", "#4fb3d9")} +BUILDING_GLYPHS = { + "houses": ("house", "#d0a060"), "walls": ("stone-wall", "#a8a29e"), "barracks": ("barracks-tent", "#c06a4a"), + "smithy": ("anvil", "#d07a3c"), "temple": ("temple-gate", "#e9dfc6"), "granary": ("granary", "#e3c060"), + "lumber_shed": ("wood-pile", "#b9844e"), "well": ("well", "#5aa0d8"), "spymaster": ("hooded-figure", "#8a7fd0"), + "storehouse": ("wooden-crate", "#b9844e"), +} +ACTION_GLYPHS = { + "fight_wolves": ("wolf-head", "#9aa1ab"), "fight_scouts": ("hooded-assassin", "#a08060"), + "fight_ruins": ("castle-ruins", "#a8a29e"), "fight_stronghold": ("castle", "#8a8f98"), + "fight_green_dragon": ("dragon-head", "#5fae5a"), "fight_blue_dragon": ("dragon-head", "#4f86d6"), + "fight_red_dragon": ("dragon-head", "#d0503c"), "fight_black_dragon": ("dragon-head", "#8a8f98"), + "sabotage": ("fire-bomb", "#e0703a"), "recon_roads": ("spyglass", "#6f8fec"), "recon_camps": ("spyglass", "#8a7fd0"), + "labour": ("hammer-nails", "#d1a46a"), "clear_wolves": ("wolf-head", "#9aa1ab"), + "catch_goblins": ("goblin-head", "#8fbf6a"), "drive_bear": ("bear-head", "#8a5a32"), "cull_boars": ("boar", "#a06a4a"), + "catch_rats": ("rat", "#9aa1ab"), "clear_rockslide": ("falling-rocks", "#a8a29e"), +} +PRAYER_GLYPHS = {"thick_skin": ("shield", "#a8a29e"), "burst_of_strength": ("biceps", "#e06a6a"), + "rapid_heal": ("heart-plus", "#e06a8a"), "steel_skin": ("armor-upgrade", "#d4dae2"), + "superhuman_strength": ("biceps", "#ff8a3a"), "second_wind": ("wind-hole", "#8fd0e0"), + "protection": ("aura", "#f0d060"), "piety": ("angel-wings", "#f0e6c0")} +UI_GLYPHS = ["two-coins", "sparkles", "hearts", "prayer", "checked-shield"] + + +def _assign_glyphs() -> None: + for item, (glyph, tint) in GLYPHS.items(): + ITEMS[item].update(glyph=glyph, tint=tint) + for key, _name, ore, *_rest in METALS: + tint = METAL_TINTS[key] + if ore: + ITEMS[ore[0]].update(glyph="ore", tint=tint) + ITEMS[f"{key}_bar"].update(glyph="metal-bar", tint=tint) + for piece, glyph in PIECE_GLYPHS.items(): + ITEMS[f"{key}_{piece}"].update(glyph=glyph, tint=tint) + for tool, glyph in TOOL_GLYPHS.items(): + ITEMS[f"{key}_{tool.replace(' ', '_')}"].update(glyph=glyph, tint=tint) + for (log, *_tree), (plank, _name), tint in zip(TREES, PLANKS, WOOD_TINTS): + ITEMS[log].update(glyph="log", tint=tint) + ITEMS[plank].update(glyph="planks", tint=tint) + for (raw, leather, prefix, *_rest), tint in zip(LEATHERS, LEATHER_TINTS): + ITEMS[raw[0]].update(glyph="animal-hide", tint=tint) + ITEMS[leather[0]].update(glyph="rolled-cloth", tint=tint) + for suffix, glyph in LEATHER_GLYPHS.items(): + ITEMS[f"{prefix}_{suffix}"].update(glyph=glyph, tint=tint) + for herb, tint in HERB_TINTS.items(): + ITEMS[herb].update(glyph="herbs-bundle", tint=tint) + for potion, *_rest, skill, _effect in POTIONS: + if skill == "combat": + ITEMS[potion].update(glyph="potion-ball", tint="#e05a5a") + else: + ITEMS[potion].update(glyph="round-bottom-flask", tint=SKILL_GLYPHS[skill][1]) + for skill in SKILLS: + skill["glyph"], skill["tint"] = SKILL_GLYPHS[skill["id"]] + for key, spec in CLASSES.items(): + spec["glyph"], spec["tint"] = CLASS_GLYPHS[key] + for key, spec in BUILDINGS.items(): + spec["glyph"], spec["tint"] = BUILDING_GLYPHS[key] + for prayer in PRAYERS: + prayer["glyph"], prayer["tint"] = PRAYER_GLYPHS[prayer["id"]] + for action in ACTIONS: + if action["id"] in ACTION_GLYPHS: + action["glyph"], action["tint"] = ACTION_GLYPHS[action["id"]] + elif action["outputs"]: + first = ITEMS[next(iter(action["outputs"]))] + action["glyph"], action["tint"] = first["glyph"], first["tint"] + elif action.get("special") in ("bury", "offer"): + bone = ITEMS[next(iter(action["inputs"]))] + action["glyph"] = "tombstone" if action["special"] == "bury" else "candle-holder" + action["tint"] = bone["tint"] if action["special"] == "bury" else "#f0c060" + else: + action["glyph"], action["tint"] = SKILL_GLYPHS[action["skill"]] + + +def all_glyphs() -> set[str]: + """Every glyph the game draws, for building the sprite.""" + names = set(UI_GLYPHS) + for collection in (ITEMS.values(), SKILLS, CLASSES.values(), BUILDINGS.values(), PRAYERS, ACTIONS): + names.update(entry["glyph"] for entry in collection) + return names + + +_assign_glyphs() + + def content() -> dict: """The static tables the client renders from.""" buildings = { diff --git a/static/app.js b/static/app.js index 2183bda..19c832a 100644 --- a/static/app.js +++ b/static/app.js @@ -19,6 +19,17 @@ let version = null; // server build this page was loaded from let failures = 0; // polls in a row that didn't reach the server const $ = (sel, root = document) => root.querySelector(sel); + +// Icons are game-icons.net glyphs (CC BY 3.0) from one sprite, tinted per tier or material. +const glyph = (name, tint) => ``; +const COIN = glyph("two-coins", "#e0b04a"), FAVOUR = glyph("sparkles", "#b98ce8"), HEART = glyph("hearts", "#e2645c"); + +function iconise(content) { + const entries = [...Object.values(content.items), ...content.skills, ...Object.values(content.classes), + ...Object.values(content.buildings), ...content.prayers, ...content.actions]; + for (const entry of entries) if (entry.glyph) entry.icon = glyph(entry.glyph, entry.tint); + return content; +} const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); const fmt = (n) => Math.floor(n).toLocaleString("en-GB"); const serverNow = () => Date.now() / 1000 + skew; @@ -125,7 +136,7 @@ function toast(text, kind = "") { 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}`); + const got = Object.entries(sum.items).map(([id, q]) => `+${fmt(q)} ${item(id).name}`); if (sum.gold) got.push(`+${fmt(sum.gold)} gold`); if (got.length) toast(got.join(" · "), "good"); const eaten = Object.entries(sum.eaten || {}); @@ -218,8 +229,8 @@ function sidebar() {
Guildhall alpha
${esc(me.name)}
-
${cls.icon} ${cls.name} · ❤️ ${fmt(me.hp)}/${me.max_hp}
-
🪙 ${fmt(me.gold)}✨ ${fmt(me.favour)}
+
${cls.icon} ${cls.name} · ${HEART} ${fmt(me.hp)}/${me.max_hp}
+
${COIN} ${fmt(me.gold)}${FAVOUR} ${fmt(me.favour)}
${nav("town", "🏘️", "Town square", `${S.city.population}`)} @@ -227,7 +238,7 @@ function sidebar() { ${nav("board", "💬", "Town board")} ${nav("roster", "👥", "Guild roster", `${S.players.length}`)} - ${nav("character", cls.icon, "Character", `⚔️ ${me.levels.combat}`)} + ${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`)} ${nav("bank", "🏦", "Bank", `${Object.keys(me.bank).length}/${me.slots}`)}`; let group = null; for (const skill of C.skills) { @@ -239,7 +250,8 @@ function sidebar() { const busy = doing?.skill === skill.id ? `` : ""; html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy); } - return html + (S.dev ? devbar() : ""); + return html + (S.dev ? devbar() : "") + + `

Icons by Lorc, Delapouite and others at game-icons.net, CC BY 3.0

`; } function devbar() { @@ -391,7 +403,7 @@ function prayerPanel() { const temple = city.buildings.temple.level; return `

Prayers · ${me.prayers.length} / ${C.max_prayers} active

-
🙏 ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}
+
${skillOf("prayer").icon} ${fmt(me.pp)} prayer points${cost ? ` · ${cost} per fight, enough for about ${fmt(me.pp / cost)} fights` : ""}
${cards}

Active prayers work in every fight while you have the points to pay for them, and each point spent trains Prayer. Bury bones for more points.

@@ -423,11 +435,11 @@ function combatPanel() { const hpPct = me.hp / me.max_hp * 100; return `

Health

-
❤️ ${fmt(me.hp)} / ${me.max_hp}recovers ${Math.round(C.regen_per_hour * 100)}% an hour
+
${HEART} ${fmt(me.hp)} / ${me.max_hp}recovers ${Math.round(C.regen_per_hour * 100)}% an hour
-

🙏 ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`} +

${skillOf("prayer").icon} ${me.prayers.length ? `${me.prayers.map((id) => esc(C.prayers.find((p) => p.id === id).name)).join(" + ")} · ${fmt(me.pp)} points${me.blessing.cost ? "" : ` (not enough points: prayers are off)`}` : `No prayers active.`}

Below half health you eat from your bank automatically. ${me.edible.length ? "" : `You have no food in your bank.`} @@ -459,7 +471,7 @@ function actionCard(a) { const why = me.blocked[a.id]; const locked = isLocked(a); const out = Object.keys(a.outputs)[0]; - const icon = out ? item(out).icon : skillOf(a.skill).icon; + const icon = a.icon; const time = me.times[a.id]; let io = ""; if (Object.keys(a.inputs).length) { @@ -477,7 +489,7 @@ function actionCard(a) { if (a.special === "recon") extra = `

+${a.recon} recon on the next event
`; const temple = S.city.buildings.temple?.level || 0; if (a.special === "labour") extra = `
🏗️ +${me.labour_per_action} labour per action
`; - if (a.special === "bury") extra = `
🙏 +${a.pp + temple} prayer points
`; + if (a.special === "bury") extra = `
${skillOf("prayer").icon} +${a.pp + temple} prayer points
`; if (a.special === "offer") extra = `
⛪ +${+(a.devotion * (1 + 0.25 * temple)).toFixed(1)} devotion for the city's ward
`; const made = Object.keys(a.outputs).map(itemStats).filter(Boolean); if (made.length) extra += `
${made.join(" · ")}
`; @@ -576,7 +588,7 @@ function equipLabel(it) { function goldPanel() { return `

Gold

-

🪙 ${fmt(S.me.gold)}

+

${COIN} ${fmt(S.me.gold)}

The treasury pays the gold part of new buildings. 1 favour per gold.

`; } @@ -584,7 +596,7 @@ function goldPanel() { function favourPanel() { const me = S.me; return `

Favour with the council

-

✨ ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total

+

${FAVOUR} ${fmt(me.favour)} favour · ${fmt(me.donated)} given in total

Favour can also hire builders, pay informants and rally the militia: see .

`; } @@ -633,7 +645,7 @@ function siteBanner() { ${fmt(work.labour)} / ${fmt(work.needed)} labour ${working ? `You're working on it` : ``} - ${hire.why ? "" : ``} + ${hire.why ? "" : ``}
${builders.length ? `On site: ${builders.join(", ")}. ` : ""}The townsfolk add about ${fmt(city.townsfolk_labour)} labour an hour.${queued ? ` Next: ${queued}.` : ""}
@@ -710,7 +722,7 @@ function buildingCard(key, b) { const gold = id === "gold"; const mine = Math.min(missing, gold ? me.gold : me.bank[id] || 0); const stores = Math.min(missing, gold ? city.treasury : city.stockpile[id] || 0); - const chip = costChip(gold ? "🪙" : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing); + const chip = costChip(gold ? COIN : item(id).icon, gold ? "Gold" : item(id).name, need, need - missing); return `
${chip} ${mine ? `` : ""} ${stores ? `` : ""} @@ -782,10 +794,10 @@ function favourSpends() { ${icon} ${title}${text} + title="${esc(o.why || (short ? "Not enough favour" : ""))}">${FAVOUR} ${fmt(o.cost)}
`; }; - return `

Spend favour on the city · you have ✨ ${fmt(me.favour)}

+ return `

Spend favour on the city · you have ${FAVOUR} ${fmt(me.favour)}

${option("builders", "🏗️", "Hire extra builders", f.builders.why ? esc(f.builders.why) + "." : `Adds ${fmt(f.builders.labour)} labour to the current building, half of what's left.`)} ${option("informant", "🕵️", "Pay an informant", f.informant.why ? esc(f.informant.why) + "." : "One more level of intel on what's coming.")} ${option("militia", "📯", "Rally the militia", f.militia.why ? esc(f.militia.why) + "." : `+10 defence for the coming raid${f.militia.rallied ? ` (+${f.militia.rallied} so far)` : ""}. Costs more each time.`)} @@ -838,7 +850,7 @@ function roster() { return `
${cls.icon}
${esc(p.name)} ${cls.name} · total level ${p.total}
${p.doing ? `${skillOf(p.skill).icon} ${esc(p.doing)}` : `Idle`}${p.garrison ? ` · On guard` : ""}
-
✨ ${fmt(p.donated)} given to the city
+
${FAVOUR} ${fmt(p.donated)} given to the city
${levels}

`; }).join("")}
`; @@ -1127,7 +1139,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden) refr (async function boot() { try { - C = await api("/api/content"); + C = iconise(await api("/api/content")); await refresh(); } catch (err) { $("#main").innerHTML = `

Couldn't reach the city: ${esc(err.message)}

`; diff --git a/static/icons.svg b/static/icons.svg new file mode 100644 index 0000000..5e70e6a --- /dev/null +++ b/static/icons.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/styles.css b/static/styles.css index 83e0111..6e3c750 100644 --- a/static/styles.css +++ b/static/styles.css @@ -397,3 +397,10 @@ button.sheet-skill:hover { background: var(--panel-2); } .skill-happenings { flex-direction: column; align-items: flex-start; gap: 4px; } .msg-delete { background: none; border: 0; color: var(--muted); padding: 0 4px; margin-left: 4px; font-size: 12px; } .msg-delete:hover { color: var(--bad); } +.gi { width: 1em; height: 1em; display: inline-block; vertical-align: -0.15em; flex: none; } +.item-chip .gi { width: 1.25em; height: 1.25em; vertical-align: -0.3em; } +.slot .gi { width: 34px; height: 34px; } +.action .ico .gi, .skill-head .big .gi, .sheet-head .big .gi { width: 70%; height: 70%; } +.nav .ic .gi { width: 20px; height: 20px; vertical-align: middle; } +.credit { margin: 14px 18px 0; font-size: 11.5px; color: var(--muted); } +.credit a { color: var(--muted); } diff --git a/tests/test_game.py b/tests/test_game.py index 1fdcd23..ef31d47 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -995,5 +995,15 @@ class HappeningsTest(unittest.TestCase): self.assertIsNotNone(world["next_fortune_at"]) + +class IconTest(unittest.TestCase): + def test_everything_has_a_glyph_in_the_sprite(self): + sprite = (Path(__file__).resolve().parent.parent / "static" / "icons.svg").read_text() + missing = [name for name in game.all_glyphs() if f'id="{name}"' not in sprite] + self.assertEqual(missing, [], "run tools/build_icons.py") + for item, spec in game.ITEMS.items(): + self.assertIn("glyph", spec, item) + + if __name__ == "__main__": unittest.main() diff --git a/tools/build_icons.py b/tools/build_icons.py new file mode 100644 index 0000000..1d8090b --- /dev/null +++ b/tools/build_icons.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Build static/icons.svg: one SVG sprite with every glyph the game uses. + +The glyphs are from game-icons.net (CC BY 3.0, see README), fetched through +the Iconify API. Run this after adding a glyph name in game.py; the sprite is +committed, so the game itself never fetches anything at runtime. + + python3 tools/build_icons.py +""" + +import json +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +import game # noqa: E402 + +names = sorted(game.all_glyphs()) +url = "https://api.iconify.design/game-icons.json?icons=" + ",".join(names) +request = urllib.request.Request(url, headers={"User-Agent": "guildhall-icon-builder"}) +with urllib.request.urlopen(request, timeout=30) as response: + data = json.load(response) +if data.get("not_found"): + sys.exit(f"Not on game-icons.net: {', '.join(data['not_found'])}") + +size = data.get("width", 512) +symbols = [] +for name in names: + icon = data["icons"][name] + width, height = icon.get("width", size), icon.get("height", data.get("height", size)) + symbols.append(f'{icon["body"]}') +sprite = ('\n' + '\n' + + "\n".join(symbols) + "\n\n") +(ROOT / "static" / "icons.svg").write_text(sprite) +print(f"Wrote {len(names)} glyphs to static/icons.svg ({len(sprite) // 1024} KiB)")