diff --git a/backend/src/api/patchnotes_routes.py b/backend/src/api/patchnotes_routes.py index d3c8569e..2a76e4fb 100644 --- a/backend/src/api/patchnotes_routes.py +++ b/backend/src/api/patchnotes_routes.py @@ -121,7 +121,11 @@ def _serialize(row: dict[str, Any], *, public: bool) -> dict[str, Any]: "section": row["section"], "body": row["body"], "created_at": _iso(row.get("created_at")), + "highlight": bool(row.get("highlight")), } + topic = str(row.get("topic") or "").strip() + if topic: + payload["topic"] = topic if public: return payload payload["status"] = row["status"] diff --git a/backend/src/patchnotes/db.py b/backend/src/patchnotes/db.py index e2f6a69f..343956a5 100644 --- a/backend/src/patchnotes/db.py +++ b/backend/src/patchnotes/db.py @@ -30,7 +30,19 @@ _WEEK_RE = re.compile(r"^(\d{4})-W(\d{2})$") _BULLET_COLUMNS = ( - "id, week, section, body, status, deny_reason, created_at, reviewed_at, revision_note" + "id, week, section, body, status, deny_reason, created_at, reviewed_at, revision_note, topic, highlight" +) +TOPICS = ( + "classes", + "combat", + "magic", + "crafting", + "professions", + "animals", + "world", + "town", + "dungeons", + "chat", ) _NOT_POSTPONED = """ AND NOT EXISTS ( @@ -204,6 +216,13 @@ def _clean_body(body: str) -> str: return text +def _topic_or_none(value: Any) -> str | None: + text = str(value or "").strip().lower() + if text in TOPICS: + return text + return None + + def _clean_reason(reason: str) -> str: text = reason.strip() if not text: @@ -309,7 +328,9 @@ def migrate() -> None: ADD COLUMN IF NOT EXISTS supersedes UUID, ADD COLUMN IF NOT EXISTS revision_note TEXT, ADD COLUMN IF NOT EXISTS carried_from TEXT, - ADD COLUMN IF NOT EXISTS carried_status TEXT + ADD COLUMN IF NOT EXISTS carried_status TEXT, + ADD COLUMN IF NOT EXISTS topic TEXT, + ADD COLUMN IF NOT EXISTS highlight BOOLEAN NOT NULL DEFAULT FALSE """ ) cur.execute( @@ -522,18 +543,22 @@ def replace_preview(*, week: str, bullets: list[dict[str, Any]]) -> dict[str, An The row expires one hour after it is written. Public readers never see it. """ week_key = parse_week(week) - stored: list[dict[str, str]] = [] + stored: list[dict[str, Any]] = [] for bullet in bullets: section = str(bullet.get("section") or "") if section not in SECTIONS: raise ValueError("section must be new, fixed, adjusted, or technical") - stored.append( - { - "id": str(bullet.get("id") or ""), - "section": section, - "body": _clean_body(str(bullet.get("body") or "")), - } - ) + item: dict[str, Any] = { + "id": str(bullet.get("id") or ""), + "section": section, + "body": _clean_body(str(bullet.get("body") or "")), + } + topic = str(bullet.get("topic") or "").strip().lower() + if topic in TOPICS: + item["topic"] = topic + if bullet.get("highlight") is True: + item["highlight"] = True + stored.append(item) conn = _connect() try: with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: @@ -882,10 +907,17 @@ def apply_feedback(week: str, feedback: str, edits: list[dict[str, Any]]) -> dic cur.execute( f""" INSERT INTO patchnote_bullets - (week, section, body, status, revision_note) - VALUES (%s, %s, %s, 'pending', %s) + (week, section, body, status, revision_note, topic, highlight) + VALUES (%s, %s, %s, 'pending', %s, %s, %s) """, - (week_key, edit["section"], edit["body"], note), + ( + week_key, + edit["section"], + edit["body"], + note, + _topic_or_none(edit.get("topic")), + bool(edit.get("highlight")), + ), ) changed += 1 continue @@ -896,10 +928,20 @@ def apply_feedback(week: str, feedback: str, edits: list[dict[str, Any]]) -> dic UPDATE patchnote_bullets SET section = %s, body = %s, - revision_note = %s + revision_note = %s, + topic = %s, + highlight = %s WHERE id = %s AND week = %s AND status IN ('pending', 'approved') """, - (edit["section"], edit["body"], note, bullet_id, week_key), + ( + edit["section"], + edit["body"], + note, + _topic_or_none(edit.get("topic")), + bool(edit.get("highlight")), + bullet_id, + week_key, + ), ) changed += int(cur.rowcount) continue diff --git a/backend/src/patchnotes/feedback.py b/backend/src/patchnotes/feedback.py index 3d1f8007..149f93dd 100644 --- a/backend/src/patchnotes/feedback.py +++ b/backend/src/patchnotes/feedback.py @@ -20,6 +20,20 @@ _MODEL = "claude-sonnet-5" _SECTIONS = frozenset({"new", "fixed", "adjusted", "technical"}) +_TOPICS = frozenset( + { + "classes", + "combat", + "magic", + "crafting", + "professions", + "animals", + "world", + "town", + "dungeons", + "chat", + } +) _MAX_ADDS = 3 _SYSTEM = """You rewrite a Minecraft server's weekly patch notes after staff feedback. @@ -34,6 +48,9 @@ - drop a line when staff do not want it posted. - add a line only when staff asked for something that is not already there. At most 3. - Each body is one short player-facing sentence. +- topic is one of classes, combat, magic, crafting, professions, animals, world, town, dungeons, chat. +- highlight is true only for the few lines that belong in the short summary. At most 6. +- A bug fix uses section fixed. A plugin or internal change uses section technical. - Do not copy the feedback into a body. - Do not include stat numbers, coordinates, file paths, commands, permissions, secrets, dungeon names, or lore-item names. - Never use an em dash. @@ -101,26 +118,40 @@ def edits_from_response( body = _safe_body(str(item.get("body") or ""), feedback) if body is None: continue - edits.append({"action": "add", "section": section, "body": body}) + edits.append(_with_placement({"action": "add", "section": section, "body": body}, item)) added += 1 return edits -def _rewrite(original: dict[str, Any], item: dict[str, Any], feedback: str) -> dict[str, str] | None: +def _rewrite(original: dict[str, Any], item: dict[str, Any], feedback: str) -> dict[str, Any] | None: section = str(item.get("section") or original.get("section") or "") if section not in _SECTIONS: return None body = _safe_body(str(item.get("body") or ""), feedback) if body is None: return None - if body == str(original.get("body") or "").strip() and section == original.get("section"): + edit = _with_placement( + { + "id": str(original.get("id") or ""), + "action": "rewrite", + "section": section, + "body": body, + }, + item, + ) + same_line = body == str(original.get("body") or "").strip() and section == original.get("section") + if same_line and not edit.get("topic") and not edit.get("highlight"): return None - return { - "id": str(original.get("id") or ""), - "action": "rewrite", - "section": section, - "body": body, - } + return edit + + +def _with_placement(edit: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]: + topic = str(item.get("topic") or "").strip().lower() + if topic in _TOPICS: + edit["topic"] = topic + if item.get("highlight") is True: + edit["highlight"] = True + return edit def _safe_body(text: str, feedback: str) -> str | None: diff --git a/frontend/app/components/updates/UpdatesPageView.tsx b/frontend/app/components/updates/UpdatesPageView.tsx index 6c6da1b1..e42c5451 100644 --- a/frontend/app/components/updates/UpdatesPageView.tsx +++ b/frontend/app/components/updates/UpdatesPageView.tsx @@ -17,7 +17,7 @@ export default function UpdatesPageView({ return (

Updates

-

What changed on TFMC. Technical notes stay folded.

+

A short summary, then the changes. Technical notes stay folded.

{unavailable ? ( diff --git a/frontend/app/components/updates/WeekArchiveList.tsx b/frontend/app/components/updates/WeekArchiveList.tsx index b37946c4..1a2be8da 100644 --- a/frontend/app/components/updates/WeekArchiveList.tsx +++ b/frontend/app/components/updates/WeekArchiveList.tsx @@ -1,4 +1,4 @@ -import { groupBullets, SECTION_LABELS, type PublicBullet, type SectionName, type WeekNotes } from "@/lib/patchnotes/notes"; +import { arrangeNote, SECTION_LABELS, type PublicBullet, type WeekNotes } from "@/lib/patchnotes/notes"; const detailsClass = "rounded-md border border-[color-mix(in_srgb,var(--tfmc-cream)_14%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest-deep)_35%,transparent)]"; @@ -38,14 +38,15 @@ export function TechnicalSection({ bullets }: { bullets: readonly PublicBullet[] } export function WeekSections({ bullets }: { bullets: readonly PublicBullet[] }) { - const groups = groupBullets(bullets); - const visible: SectionName[] = ["new", "fixed", "adjusted"]; + const note = arrangeNote(bullets); return ( <> - {visible.map((section) => ( - + + {note.topics.map((topic) => ( + ))} - + + ); } diff --git a/frontend/app/updates/page.test.tsx b/frontend/app/updates/page.test.tsx index dbb459a5..d4f97c50 100644 --- a/frontend/app/updates/page.test.tsx +++ b/frontend/app/updates/page.test.tsx @@ -32,13 +32,21 @@ describe("Updates page", () => { it("shows the latest week open and folds technical notes", () => { const html = markup([current]); expect(html).toContain("Week of 21 September 2026"); + expect(html).toContain("Highlights"); + expect(html).toContain("Crafting"); expect(html).toContain("Added a station"); + expect(html).toContain("Bug fixes"); expect(html).toContain("Fixed a door"); + expect(html).toContain("Other"); expect(html).toContain("Lowered a price"); expect(html).toContain("Technical (1)"); expect(html).toContain("Rebuilt a plugin"); + expect(html).not.toContain(">New<"); + expect(html).not.toContain(">Adjusted<"); expect(html).not.toContain("
{ it("names the Monday of the ISO week", () => { @@ -27,3 +27,36 @@ describe("groupBullets", () => { expect(groups.technical.map((bullet) => bullet.id)).toEqual(["4"]); }); }); + +describe("arrangeNote", () => { + it("puts a summary first, groups the changes, and leaves fixes and technical apart", () => { + const note = arrangeNote([ + { id: "mage", section: "adjusted", body: "Mage gear now scales by ingot tier." }, + { id: "station", section: "new", body: "Added a weapon station." }, + { id: "door", section: "fixed", body: "Fixed a door" }, + { id: "plugin", section: "technical", body: "Rebuilt a plugin" }, + { id: "price", section: "adjusted", body: "Lowered a price" }, + ]); + expect(note.highlights.map((bullet) => bullet.id)).toEqual(["mage", "station", "price"]); + expect(note.topics.map((topic) => topic.label)).toEqual(["Classes", "Crafting", "Other"]); + expect(note.topics[0].bullets.map((bullet) => bullet.id)).toEqual(["mage"]); + expect(note.fixes.map((bullet) => bullet.id)).toEqual(["door"]); + expect(note.technical.map((bullet) => bullet.id)).toEqual(["plugin"]); + }); + + it("uses a marked highlight instead of the first changes", () => { + const note = arrangeNote([ + { id: "mage", section: "adjusted", body: "Mage gear now scales by ingot tier.", highlight: true }, + { id: "food", section: "adjusted", body: "Food recipes changed." }, + ]); + expect(note.highlights.map((bullet) => bullet.id)).toEqual(["mage"]); + expect(note.topics.map((topic) => topic.id)).toEqual(["classes", "crafting"]); + }); + + it("keeps an explicit topic ahead of the guess", () => { + const note = arrangeNote([ + { id: "knife", section: "new", body: "Added a hunting knife.", topic: "classes" }, + ]); + expect(note.topics.map((topic) => topic.id)).toEqual(["classes"]); + }); +}); diff --git a/frontend/lib/patchnotes/notes.ts b/frontend/lib/patchnotes/notes.ts index 10007871..127751e9 100644 --- a/frontend/lib/patchnotes/notes.ts +++ b/frontend/lib/patchnotes/notes.ts @@ -4,6 +4,8 @@ export type PublicBullet = { id: string; section: string; body: string; + topic?: string; + highlight?: boolean; }; export type WeekNotes = { @@ -71,3 +73,102 @@ export function groupBullets(bullets: readonly PublicBullet[]): Record = { + classes: "Classes", + combat: "Combat and gear", + magic: "Magic", + crafting: "Crafting", + professions: "Professions", + animals: "Animals", + world: "World and nodes", + town: "Town", + dungeons: "Dungeons", + chat: "Chat and rules", +}; + +export const HIGHLIGHT_LIMIT = 6; + +const TOPIC_PATTERNS: readonly { id: TopicName; pattern: RegExp }[] = [ + { id: "dungeons", pattern: /\bdungeons?\b/i }, + { id: "chat", pattern: /\b(chat|yell|rules?)\b/i }, + { id: "town", pattern: /\b(donations?|denars?|insurance|warehouse|granary|stable)\b/i }, + { id: "animals", pattern: /\b(breeding|animals?|genetic|genetics|friendship)\b/i }, + { id: "professions", pattern: /\bprofessions?\b/i }, + { id: "magic", pattern: /\b(runes?|magic|spells?|rituals?)\b/i }, + { id: "classes", pattern: /\b(classes?|mage|archer|musketeer|wildlands)\b/i }, + { id: "crafting", pattern: /\b(craft(?:ing|able)?|recipes?|stations?|grindstone|ingredients?|repair kits?|food)\b/i }, + { id: "combat", pattern: /\b(weapons?|armou?r|gear|damage|knives|knife)\b/i }, + { id: "world", pattern: /\b(nodes?|crops?|biomes?|mining|drops?)\b/i }, +]; + +export function isTopicName(value: string): value is TopicName { + return (TOPIC_ORDER as readonly string[]).includes(value); +} + +export function topicFor(bullet: PublicBullet): TopicName | "other" { + if (bullet.topic && isTopicName(bullet.topic)) return bullet.topic; + for (const topic of TOPIC_PATTERNS) { + if (topic.pattern.test(bullet.body)) return topic.id; + } + return "other"; +} + +export type NoteTopic = { + id: string; + label: string; + bullets: PublicBullet[]; +}; + +export type ArrangedNote = { + highlights: PublicBullet[]; + topics: NoteTopic[]; + fixes: PublicBullet[]; + technical: PublicBullet[]; +}; + +function summaryLines(bullets: readonly PublicBullet[]): PublicBullet[] { + const visible = bullets.filter((bullet) => bullet.section !== "technical"); + const flagged = visible.filter((bullet) => bullet.highlight === true); + if (flagged.length > 0) return flagged.slice(0, HIGHLIGHT_LIMIT); + const changes = visible.filter((bullet) => bullet.section === "new" || bullet.section === "adjusted"); + if (changes.length > 0) return changes.slice(0, HIGHLIGHT_LIMIT); + return visible.filter((bullet) => bullet.section === "fixed").slice(0, HIGHLIGHT_LIMIT); +} + +/** Highlights, then one group per topic, then bug fixes. Technical stays separate. */ +export function arrangeNote(bullets: readonly PublicBullet[]): ArrangedNote { + const fixes = bullets.filter((bullet) => bullet.section === "fixed"); + const technical = bullets.filter((bullet) => bullet.section === "technical"); + const changes = bullets.filter((bullet) => bullet.section === "new" || bullet.section === "adjusted"); + const buckets = new Map(); + for (const bullet of changes) { + const topic = topicFor(bullet); + const list = buckets.get(topic) ?? []; + list.push(bullet); + buckets.set(topic, list); + } + const topics: NoteTopic[] = []; + for (const id of TOPIC_ORDER) { + const items = buckets.get(id); + if (items && items.length > 0) topics.push({ id, label: TOPIC_LABELS[id], bullets: items }); + } + const other = buckets.get("other"); + if (other && other.length > 0) topics.push({ id: "other", label: "Other", bullets: other }); + return { highlights: summaryLines(bullets), topics, fixes, technical }; +} diff --git a/frontend/lib/patchnotes/preview.ts b/frontend/lib/patchnotes/preview.ts index 0368ee61..bd29e897 100644 --- a/frontend/lib/patchnotes/preview.ts +++ b/frontend/lib/patchnotes/preview.ts @@ -17,7 +17,13 @@ export function readStaffPreview(value: unknown): StaffPreview | null { continue; } if (!isSectionName(bullet.section) || bullet.body.trim() === "") continue; - bullets.push({ id: bullet.id, section: bullet.section, body: bullet.body }); + bullets.push({ + id: bullet.id, + section: bullet.section, + body: bullet.body, + ...(typeof bullet.topic === "string" && bullet.topic.trim() !== "" ? { topic: bullet.topic } : {}), + ...(bullet.highlight === true ? { highlight: true } : {}), + }); } if (bullets.length === 0) return null; return { week: row.week, label: weekLabel(row.week), bullets, expiresAt: row.expires_at };