diff --git a/CLAUDE.md b/CLAUDE.md
index b7dedf7..33dd38b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,3 +6,12 @@
needed and none is held) — recipe inputs, building costs, order slots, gold,
tooltips, server messages. Never `need / have`.
- In-game text is terse: icons and numbers over prose, details in tooltips.
+
+## Shipping
+
+- Confirm with Fred before shipping anything discussed (patch notes, commit,
+ push, flake bump). Show the patch note lines with the summary of the change.
+- Every shipped change adds a line to `PATCH_NOTES` in `game.py` (`new` /
+ `balance` / `fix`), in the same commit. Players read these: say what changed
+ for them, with numbers, not how the code changed. Same-day changes go under
+ the top entry; a new day or release starts a new entry.
diff --git a/game.py b/game.py
index b1cedfa..61b47d3 100644
--- a/game.py
+++ b/game.py
@@ -4457,6 +4457,23 @@ _assign_glyphs()
ORDER_RECIPES, ORDER_RAW = _order_sources()
+# What changed, shown on the Patch notes page. Newest first; every shipped change adds a
+# line here (new / balance / fix), and a new release a new entry on top.
+PATCH_NOTES = [
+ {"date": "2026-10-21", "title": "Beta", "notes": [
+ ("new", "Beta: completely meaningless milestone reached; every class now makes a decent amount of valuable contributions to the guild."),
+ ("new", "Patch notes menu."),
+ ("new", "Firekeeping: tend the fire for your guild members' crafting stations. Builders get a bonus. Use tiers of wood to light "
+ "and maintain a fire, which saves resources on guild members' crafts of the same tier. For every craft completed while "
+ "the fire is lit, the Firekeeper gains 10% of the XP the craft gave the crafter, shared between all players keeping a "
+ "fire of that tier. Each player keeps their own \"pile\", which contributes an equal amount to the fire."),
+ ("new", "Work orders: anyone can provide reagents towards an incomplete work order and gets paid based on the value of the items provided."),
+ ("balance", "Gear: combat requirement adjusted to match the tier of crafting required to make it."),
+ ("balance", "Combat XP reduced to keep it in line with professions. Eating now delays your next swing; eat higher tier food to reduce the impact."),
+ ("balance", "Construction workbench now also affects labour on building sites."),
+ ]},
+]
+
def content() -> dict:
"""The static tables the client renders from."""
buildings = {
@@ -4475,4 +4492,5 @@ def content() -> dict:
"resistant": RESISTANT, "jewel_stats": JEWEL_STATS, "stations": list(STATIONS), "class_style": CLASS_STYLE, "block_chance": BLOCK_CHANCE,
"regen_per_hour": REGEN_PER_HOUR,
"fires": FIRES, "flames": FLAMES, "firewood": FIREWOOD, "tend_xp": TEND_XP, "fire_low": FIRE_LOW,
+ "patch_notes": PATCH_NOTES,
}
diff --git a/static/app.js b/static/app.js
index 7496c03..96f5e3c 100644
--- a/static/app.js
+++ b/static/app.js
@@ -276,6 +276,7 @@ function render() {
for (const el of document.querySelectorAll("[data-keep]")) kept[el.dataset.keep] = el.value;
const focus = document.activeElement?.dataset?.keep;
+ if (page === "notes") remember("notes", notesKey());
setHtml($("#side"), S.me ? sidebar() : `

Guildhall
beta ${S.dev ? devbar() : ""}`);
setHtml($("#top"), topbar());
// The fires keep burning through a re-render: put the old canvases back in place of the new ones.
@@ -349,6 +350,7 @@ function sidebar() {
${nav("orders", ORDER_ICON(), "Work orders", "", takeable ? `${takeable}` : "", takeable ? `${takeable} for you` : "")}
${nav("board", "💬", "Town board")}
${nav("roster", "👥", "Guild roster", `${S.players.length}`, "", `${S.players.length} ${S.players.length === 1 ? "adventurer" : "adventurers"}`)}
+ ${nav("notes", "📰", "Patch notes", "", notesUnread() ? `new` : "", notesUnread() ? `What's new: ${C.patch_notes[0].title}` : "")}
You
${nav("character", cls.icon, "Character", `${skillOf("combat").icon} ${me.levels.combat}`, "", `Combat level ${me.levels.combat}`)}
${nav("bank", "🏦", "Bank", `${me.slots_used}/${me.slots}`, "", `${me.slots_used} of ${me.slots} bank slots used`)}`;
@@ -379,7 +381,7 @@ function devbar() {
`;
}
-const TITLES = { town: "Town square", council: "Council & threats", orders: "Work orders", board: "Town board", roster: "Guild roster", bank: "Bank", character: "Character" };
+const TITLES = { town: "Town square", council: "Council & threats", orders: "Work orders", board: "Town board", roster: "Guild roster", notes: "Patch notes", bank: "Bank", character: "Character" };
function topbar() {
let title = TITLES[page] || "Guildhall";
@@ -439,7 +441,7 @@ function pageHtml() {
}
if (page.startsWith("character:")) return characterSheet(page.slice(10));
if (page === "character") return characterSheet(S.uid);
- return ({ town, council, orders, board, roster, bank }[page] || town)();
+ return ({ town, council, orders, board, roster, notes, bank }[page] || town)();
}
// Recipe input as "have / need"; without `have`, just the quantity.
@@ -1663,6 +1665,19 @@ function roster() {
}).join("")}`;
}
+// Patch notes, newest first. The badge stays until this browser has opened the page since the last release.
+const NOTE_KINDS = { new: ["✨", "good", "New"], balance: ["⚖️", "gold", "Balance"], fix: ["🔧", "warn", "Fixed"] };
+const notesKey = () => C.patch_notes[0] ? `${C.patch_notes[0].date} ${C.patch_notes[0].title}` : "";
+const notesUnread = () => !!C.patch_notes[0] && remember("notes") !== notesKey();
+
+function notes() {
+ return C.patch_notes.map((release) => `${esc(release.title)} ${esc(release.date)}
+
${release.notes.map(([kind, text]) => {
+ const [icon, tone, label] = NOTE_KINDS[kind];
+ return `- ${icon} ${esc(text)}
`;
+ }).join("")}
`).join("");
+}
+
// --- character sheet -----------------------------------------------------
const SLOT_NAMES = { weapon: "Weapon", shield: "Shield", helmet: "Helmet", body: "Body", legs: "Legs" };
diff --git a/tests/test_game.py b/tests/test_game.py
index 4df453a..427b589 100644
--- a/tests/test_game.py
+++ b/tests/test_game.py
@@ -1571,10 +1571,6 @@ class IconTest(unittest.TestCase):
self.assertIn("glyph", spec, item)
-if __name__ == "__main__":
- unittest.main()
-
-
class CombatReworkMigrationTest(unittest.TestCase):
def test_old_monsters_trophies_and_dragonhide_carry_over(self):
world = world_with(("kay", "ranger"))
@@ -2041,3 +2037,18 @@ class FirekeepingTest(unittest.TestCase):
self.assertEqual(set(self.world["city"]["fires"]), set(game.FIRES))
self.assertEqual(self.kai["xp"]["firekeeping"], 0)
self.assertIn("tool_firekeeping", self.kai["equip"])
+
+
+class PatchNotesTest(unittest.TestCase):
+ def test_newest_first_and_known_kinds(self):
+ dates = [release["date"] for release in game.PATCH_NOTES]
+ self.assertEqual(dates, sorted(dates, reverse=True))
+ for release in game.PATCH_NOTES:
+ self.assertTrue(release["notes"])
+ for kind, text in release["notes"]:
+ self.assertIn(kind, ("new", "balance", "fix"))
+ self.assertTrue(text)
+
+
+if __name__ == "__main__":
+ unittest.main()