Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/src/api/patchnotes_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
72 changes: 57 additions & 15 deletions backend/src/patchnotes/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
49 changes: 40 additions & 9 deletions backend/src/patchnotes/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/updates/UpdatesPageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function UpdatesPageView({
return (
<main className="mx-auto min-h-[calc(100dvh-var(--tfmc-header-h))] max-w-3xl px-6 py-16">
<h1 className="font-[family-name:var(--font-fraunces)] text-4xl text-[var(--tfmc-cream)]">Updates</h1>
<p className="mt-2 text-[var(--tfmc-mist)]">What changed on TFMC. Technical notes stay folded.</p>
<p className="mt-2 text-[var(--tfmc-mist)]">A short summary, then the changes. Technical notes stay folded.</p>
<StaffTestPreview />

{unavailable ? (
Expand Down
13 changes: 7 additions & 6 deletions frontend/app/components/updates/WeekArchiveList.tsx
Original file line number Diff line number Diff line change
@@ -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)]";
Expand Down Expand Up @@ -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) => (
<VisibleSection key={section} title={SECTION_LABELS[section]} bullets={groups[section]} />
<VisibleSection title="Highlights" bullets={note.highlights} />
{note.topics.map((topic) => (
<VisibleSection key={topic.id} title={topic.label} bullets={topic.bullets} />
))}
<TechnicalSection bullets={groups.technical} />
<VisibleSection title="Bug fixes" bullets={note.fixes} />
<TechnicalSection bullets={note.technical} />
</>
);
}
Expand Down
10 changes: 9 additions & 1 deletion frontend/app/updates/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("<details open");
expect(html.indexOf("Added a station")).toBeLessThan(html.indexOf("<details"));
expect(html.indexOf("Highlights")).toBeLessThan(html.indexOf("Crafting"));
expect(html.indexOf("Crafting")).toBeLessThan(html.indexOf("Bug fixes"));
expect(html.indexOf("Bug fixes")).toBeLessThan(html.indexOf("<details"));
expect(html.indexOf("<details")).toBeLessThan(html.indexOf("Rebuilt a plugin"));
});

Expand Down
8 changes: 7 additions & 1 deletion frontend/lib/patchnotes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ function readBullet(value: unknown): PublicBullet | null {
// A leaked review row must not be rendered, even if this route is public.
if ("status" in row && row.status !== "approved") return null;
if (typeof row.deny_reason === "string" && row.deny_reason.trim() !== "") return null;
return { id: row.id, section: row.section, body: row.body };
return {
id: row.id,
section: row.section,
body: row.body,
...(typeof row.topic === "string" && row.topic.trim() !== "" ? { topic: row.topic } : {}),
...(row.highlight === true ? { highlight: true } : {}),
};
}

function readWeek(value: unknown): WeekNotes | null {
Expand Down
35 changes: 34 additions & 1 deletion frontend/lib/patchnotes/notes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { groupBullets, weekLabel } from "./notes";
import { arrangeNote, groupBullets, weekLabel } from "./notes";

describe("weekLabel", () => {
it("names the Monday of the ISO week", () => {
Expand All @@ -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"]);
});
});
Loading