Pin skills to the top of the sidebar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-20 20:06:16 +01:00
parent fb237cb686
commit be478f3b2e
5 changed files with 55 additions and 4 deletions

2
app.py
View file

@ -409,6 +409,8 @@ class Handler(BaseHTTPRequestHandler):
return game.plant_second_plot(world, player, body.get("id") or None) return game.plant_second_plot(world, player, body.get("id") or None)
if path == "/api/perk": if path == "/api/perk":
return game.buy_perk(player, str(body.get("perk"))) return game.buy_perk(player, str(body.get("perk")))
if path == "/api/pin":
return game.toggle_pin(player, str(body.get("skill")))
if path == "/api/settings": if path == "/api/settings":
if body.get("overflow") not in ("city", "stop"): if body.get("overflow") not in ("city", "stop"):
return "Bad setting" return "Bad setting"

18
game.py
View file

@ -1033,7 +1033,7 @@ RAID_TRAITS = ["lightly armoured, heavy on archers", "mounted and fast, few bows
# --- world --------------------------------------------------------------- # --- world ---------------------------------------------------------------
WORLD_VERSION = 18 WORLD_VERSION = 19
def new_world(now: float, speed: float = 1.0) -> dict: def new_world(now: float, speed: float = 1.0) -> dict:
@ -1183,6 +1183,10 @@ def migrate(world: dict, now: float | None = None) -> dict:
for slot in ("ring", "amulet", "trinket"): for slot in ("ring", "amulet", "trinket"):
player["equip"].setdefault(slot, None) player["equip"].setdefault(slot, None)
world["version"] = 18 world["version"] = 18
if world["version"] < 19:
for player in world["players"].values():
player.setdefault("pinned", [])
world["version"] = 19
return world return world
@ -1425,6 +1429,7 @@ def new_player(uid: str, name: str, cls: str, now: float) -> dict:
"perks": {"bank_slot": 0}, "perks": {"bank_slot": 0},
"garrison": False, "garrison": False,
"donated": 0, "donated": 0,
"pinned": [], # skills pinned to the top of the sidebar
} }
@ -3841,6 +3846,17 @@ def buy_perk(player: dict, perk: str) -> str | None:
return None return None
def toggle_pin(player: dict, skill: str) -> str | None:
if skill not in SKILL_IDS:
return "No such skill"
pinned = player["pinned"]
if skill in pinned:
pinned.remove(skill)
else:
pinned.append(skill)
return None
# --- icons ------------------------------------------------------------------------ # --- icons ------------------------------------------------------------------------
# Every item, skill, class, building, prayer and output-less action gets a # 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 # game-icons.net glyph (CC BY 3.0) and a tint. Families share a shape and are

View file

@ -308,6 +308,16 @@ function nav(id, icon, label, meta = "", extra = "", tip = "") {
return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"${tip ? ` title="${esc(tip)}"` : ""}><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`; return `<button class="nav ${page === id ? "on" : ""}" data-nav="${id}"${tip ? ` title="${esc(tip)}"` : ""}><span class="ic">${icon}</span><span class="label">${label}</span>${extra}<span class="meta">${meta}</span></button>`;
} }
function skillNav(skill, me, doing) {
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : "";
return nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
}
function pinnedNav(me, doing) {
const pinned = C.skills.filter((s) => me.pinned.includes(s.id) && (!s.class || s.class === me.cls));
return pinned.length ? `<div class="navhead">📌 Pinned</div>${pinned.map((s) => skillNav(s, me, doing)).join("")}` : "";
}
function sidebar() { function sidebar() {
const me = S.me, cls = C.classes[me.cls]; const me = S.me, cls = C.classes[me.cls];
const doing = me.action && C.actions.find((a) => a.id === me.action.id); const doing = me.action && C.actions.find((a) => a.id === me.action.id);
@ -320,6 +330,7 @@ function sidebar() {
<div class="cls">${cls.icon} ${cls.name} · ${HEART} <span data-live-hp>${fmt(me.hp)}</span>/${me.max_hp}</div> <div class="cls">${cls.icon} ${cls.name} · ${HEART} <span data-live-hp>${fmt(me.hp)}</span>/${me.max_hp}</div>
<div class="purse"><span class="gold" title="Gold">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by donating">${FAVOUR} ${fmt(me.favour)}</span></div> <div class="purse"><span class="gold" title="Gold">${COIN} ${fmt(me.gold)}</span><span class="favour" title="Favour: earned by donating">${FAVOUR} ${fmt(me.favour)}</span></div>
</div> </div>
${pinnedNav(me, doing)}
<div class="navhead">City</div> <div class="navhead">City</div>
${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)} ${nav("town", "🏘️", "Town square", `${S.city.population}`, "", `Population ${S.city.population}`)}
${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "", raid ? "A threat is coming" : "")} ${nav("council", "📜", "Council & threats", "", raid ? `<span class="alert"></span>` : "", raid ? "A threat is coming" : "")}
@ -336,8 +347,7 @@ function sidebar() {
group = skill.group; group = skill.group;
html += `<div class="navhead">${group}</div>`; html += `<div class="navhead">${group}</div>`;
} }
const busy = doing?.skill === skill.id ? `<span class="dot"></span>` : ""; html += skillNav(skill, me, doing);
html += nav("skill:" + skill.id, skill.icon, skill.name, `${me.levels[skill.id]}`, busy);
} }
return html + (S.dev ? devbar() : "") return html + (S.dev ? devbar() : "")
+ `<p class="credit">Icons by Lorc, Delapouite and others at <a href="https://game-icons.net" target="_blank" rel="noopener">game-icons.net</a>, CC BY 3.0</p>`; + `<p class="credit">Icons by Lorc, Delapouite and others at <a href="https://game-icons.net" target="_blank" rel="noopener">game-icons.net</a>, CC BY 3.0</p>`;
@ -423,7 +433,7 @@ function skillPage(id) {
<div class="panel"><div class="skill-head"> <div class="panel"><div class="skill-head">
<div class="big">${skill.icon}</div> <div class="big">${skill.icon}</div>
<div class="info"> <div class="info">
<h2>${skill.name}</h2> <h2>${skill.name} <button class="pin ${me.pinned.includes(id) ? "on" : ""}" data-pin="${id}" title="${me.pinned.includes(id) ? "Unpin" : "Pin"}">📌</button></h2>
<div class="lv">Level ${level} / 99 · ${fmt(xp)}${ceil ? ` / ${fmt(ceil)}` : ""} XP</div> <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 class="bar xp thick"><span style="width:${pct}%"></span></div>
</div> </div>
@ -1677,6 +1687,7 @@ document.addEventListener("click", (e) => {
if ("plot" in d) return act("/api/plot", { id: d.plot || null }); if ("plot" in d) return act("/api/plot", { id: d.plot || null });
if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" }); if (d.garrison) return act("/api/garrison", { join: d.garrison === "1" });
if (d.perk) return act("/api/perk", { perk: d.perk }); if (d.perk) return act("/api/perk", { perk: d.perk });
if (d.pin) return act("/api/pin", { skill: d.pin });
if (d.favour) return act("/api/favour", { kind: d.favour }); if (d.favour) return act("/api/favour", { kind: d.favour });
if (d.food) return act("/api/food", { item: d.food === "cheapest" ? null : d.food }); if (d.food) return act("/api/food", { item: d.food === "cheapest" ? null : d.food });
if (d.potion) return act("/api/potion", { item: d.potion }); if (d.potion) return act("/api/potion", { item: d.potion });

View file

@ -183,6 +183,10 @@ textarea { width: 100%; resize: vertical; min-height: 60px; }
.skill-head .info { flex: 1; min-width: 0; } .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 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; } .skill-head .lv { font-variant-numeric: tabular-nums; color: var(--muted); margin: 2px 0 8px; }
.skill-head .pin { background: none; border: 0; padding: 0 4px; font-size: 16px; cursor: pointer; vertical-align: middle;
filter: grayscale(1); opacity: .35; }
.skill-head .pin:hover { opacity: .7; }
.skill-head .pin.on { filter: none; opacity: 1; }
.action { .action {
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);

View file

@ -1496,6 +1496,24 @@ class FittingTest(unittest.TestCase):
self.assertEqual(world["city"]["buildings"]["houses"]["fittings"], []) self.assertEqual(world["city"]["buildings"]["houses"]["fittings"], [])
class PinTest(unittest.TestCase):
def test_pin_toggles(self):
world = world_with(("fred", "commoner"))
p = world["players"]["fred"]
self.assertIsNone(game.toggle_pin(p, "mining"))
self.assertEqual(p["pinned"], ["mining"])
self.assertIsNone(game.toggle_pin(p, "mining"))
self.assertEqual(p["pinned"], [])
self.assertIsNotNone(game.toggle_pin(p, "juggling"))
def test_old_players_get_pins(self):
world = world_with(("fred", "commoner"))
del world["players"]["fred"]["pinned"]
world["version"] = 18
game.migrate(world, T0)
self.assertEqual(world["players"]["fred"]["pinned"], [])
class IconTest(unittest.TestCase): class IconTest(unittest.TestCase):
def test_everything_has_a_glyph_in_the_sprite(self): def test_everything_has_a_glyph_in_the_sprite(self):
sprite = (Path(__file__).resolve().parent.parent / "static" / "icons.svg").read_text() sprite = (Path(__file__).resolve().parent.parent / "static" / "icons.svg").read_text()