adventure: stop dropping dispatches Pete has no words for
An event_type with no template was a 400 at ingest. That reads like caution and behaves like deletion: gogobee retries a 400 to its cap and then parks the row forever, so rejecting a type Pete hadn't learned to phrase didn't defer the event, it destroyed it. companion_hire went that way. It has been emitted from `!expedition hire` since the combat-engine work landed and has never once reached the site — the game logged a successful emit every time, and the queue row simply never sent. The mitigation on the books was "always deploy Pete first", which is a thing a person has to remember rather than a property of the system. So invert it. An unknown type now warns, gets counted, and publishes on a neutral fallback. 400 is kept for facts that are actually invalid: no guid, or a name that failed the fact-guard. gogobee can ship a new event type any day of the week now; the worst case is a thin card until Pete learns the words. It is thinner than it sounds in practice. gogobee authors dispatch prose from the fact's fields with no per-type switch, so an unrecognised type still arrives with a real headline and lede and is allowed to use them. The fallback only shows through when the model is off or the prose-guard refused the output. Untemplated types never post live to Matrix, whatever tier they claim. A thin card among cards is cheap and reversible; pinging everyone in the room with a dispatch Pete couldn't phrase is neither. The daily digest still carries it, one line among many, which is the right volume for something we don't understand yet. And give companion_hire its template. Pete is the one being hired, so it is first-person like his duels — third-person Pete filling in as a cleric reads as somebody else reporting on him. The admin status page grows a "dispatches with no template" panel, so the next one of these is a to-do list Pete can see rather than an archaeology dig. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -340,8 +340,10 @@ func TestAdventureDisabled(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestRenderMischief: gogobee's four mischief event types must all render. An
|
||||
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
|
||||
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
|
||||
// untemplated type no longer 400s — it publishes on the neutral fallback (see
|
||||
// TestUnknownEventTypePublishes) — so what is at stake here is voice, not data
|
||||
// loss: these four carry the anonymity mechanic, and the generic fallback would
|
||||
// strip out the part that makes it work.
|
||||
//
|
||||
// It also pins the anonymity contract, which is the feature's whole social
|
||||
// engine: an unsigned contract must not name the buyer, and a survival must.
|
||||
@@ -396,3 +398,142 @@ func TestRenderMischief(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderCompanionHire pins the template whose absence was a live bug.
|
||||
//
|
||||
// gogobee has emitted companion_hire from `!expedition hire` since the combat-
|
||||
// engine work landed (expedition_companion_cmd.go). Pete had no case for it, so
|
||||
// every one of those dispatches 400'd, retried to peteclient's cap, and parked
|
||||
// forever. Nothing surfaced the loss: the game logged a successful emit, the
|
||||
// queue row just never sent.
|
||||
//
|
||||
// The unknown-type inversion (TestUnknownEventTypePublishes) means a repeat of
|
||||
// this costs a thin card rather than a deleted event — but the template is still
|
||||
// the point, and this test is what says so.
|
||||
func TestRenderCompanionHire(t *testing.T) {
|
||||
f := AdvFact{EventType: "companion_hire", Tier: "bulletin",
|
||||
Subject: "Josie", ClassRace: "Cleric", Zone: "holymachina", Level: 14}
|
||||
hl, lede, ok := renderAdventure(f)
|
||||
if !ok {
|
||||
t.Fatal("companion_hire did not render — this is the bug, do not re-break it")
|
||||
}
|
||||
if !strings.Contains(hl, "cleric") {
|
||||
t.Errorf("headline lost the seat Pete is filling: %q", hl)
|
||||
}
|
||||
if !strings.Contains(lede, "Josie") || !strings.Contains(lede, "holymachina") {
|
||||
t.Errorf("lede lost the leader or the zone: %q", lede)
|
||||
}
|
||||
// He is talking about himself here, like his duels. Third-person Pete filling
|
||||
// in as a cleric reads as someone else reporting on him.
|
||||
if !strings.Contains(lede, "I'm") && !strings.Contains(lede, "I ") {
|
||||
t.Errorf("companion_hire should be first-person Pete: %q", lede)
|
||||
}
|
||||
// "needed a cleric", never "needed cleric".
|
||||
if !strings.Contains(lede, "a cleric") {
|
||||
t.Errorf("seat needs its article in the lede: %q", lede)
|
||||
}
|
||||
if lbl, _ := advEventMeta("companion_hire"); lbl == "Dispatch" {
|
||||
t.Error("companion_hire has no permalink label")
|
||||
}
|
||||
|
||||
// A missing class must not produce "needed a ." — the fallback seat carries
|
||||
// its own article.
|
||||
bare := AdvFact{EventType: "companion_hire", Subject: "Josie"}
|
||||
_, bareLede, ok := renderAdventure(bare)
|
||||
if !ok {
|
||||
t.Fatal("companion_hire with no class did not render")
|
||||
}
|
||||
if strings.Contains(bareLede, "a .") || strings.Contains(bareLede, "needed ") {
|
||||
t.Errorf("empty class produced malformed prose: %q", bareLede)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownEventTypePublishes is the regression for the whole class of bug.
|
||||
//
|
||||
// An event type Pete has no template for must PUBLISH, not 400. A 400 is retried
|
||||
// to peteclient's cap and then parked forever, so rejecting an unrecognised type
|
||||
// does not defer the event — it deletes it, permanently, and that is how
|
||||
// companion_hire went missing. The site can carry a thin card; it cannot recover
|
||||
// a dispatch gogobee has given up on.
|
||||
func TestUnknownEventTypePublishes(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, posted := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "brand_new_thing:abc:5000", EventType: "brand_new_thing",
|
||||
Tier: "priority", // claims priority, and still must not interrupt Matrix
|
||||
Subject: "Josie", Actors: []string{"Josie"}, Zone: "holymachina",
|
||||
OccurredAt: 5000,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("unknown event_type: status = %d, want 200 — a 400 parks the dispatch forever", rw.Code)
|
||||
}
|
||||
|
||||
got, err := storage.GetStoryByGUID("brand_new_thing:abc:5000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("unknown event_type was not stored; the event is lost")
|
||||
}
|
||||
if !strings.Contains(got.Headline+got.Lede, "Josie") {
|
||||
t.Errorf("fallback dropped the subject: %q / %q", got.Headline, got.Lede)
|
||||
}
|
||||
|
||||
// Untemplated types never post live, whatever tier they claim: a thin card on
|
||||
// the site is cheap, a thin ping to everyone in the room is not. It still
|
||||
// reaches Matrix via the daily digest.
|
||||
if len(*posted) != 0 {
|
||||
t.Errorf("untemplated priority fact posted live to Matrix: %+v", *posted)
|
||||
}
|
||||
|
||||
// And the operator can see what Pete needs to learn.
|
||||
if AdvUnknownTypeCounts()["brand_new_thing"] == 0 {
|
||||
t.Error("unknown type was not counted for the status page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownEventTypeUsesProse: the inversion is not a downgrade in practice.
|
||||
// gogobee authors LLM prose from the fact's fields with no per-type switch
|
||||
// (authorDispatch), so a type Pete has never heard of still arrives with a real
|
||||
// headline and lede — and must be allowed to use them. The thin fallback is only
|
||||
// for when the model is off or the prose-guard rejected the output.
|
||||
func TestUnknownEventTypeUsesProse(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "another_new_thing:def:6000", EventType: "another_new_thing",
|
||||
Tier: "bulletin", Subject: "Josie", Actors: []string{"Josie"},
|
||||
OccurredAt: 6000,
|
||||
Headline: "Josie has taken up beekeeping.",
|
||||
Lede: "Not the news I expected today, but there she is, out behind the chapel with a smoker and a very calm expression.",
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", rw.Code)
|
||||
}
|
||||
got, err := storage.GetStoryByGUID("another_new_thing:def:6000")
|
||||
if err != nil || got == nil {
|
||||
t.Fatal("story not stored")
|
||||
}
|
||||
if got.Headline != f.Headline {
|
||||
t.Errorf("LLM prose was discarded for an unknown type: got %q", got.Headline)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownEventTypeStillGuarded: publishing an untemplated type must not
|
||||
// weaken the name guards. The fact-guard rejection is still a 400, because a
|
||||
// fact naming someone it did not authorise is genuinely invalid — unlike a type
|
||||
// Pete simply hasn't learned to phrase.
|
||||
func TestUnknownEventTypeStillGuarded(t *testing.T) {
|
||||
const token = "s3cret-token"
|
||||
s, _ := newAdvServer(t, token)
|
||||
|
||||
f := AdvFact{
|
||||
GUID: "unknowable:evil:1", EventType: "unknowable",
|
||||
Subject: "Josie", Actors: []string{"Brannigan"}, OccurredAt: 1,
|
||||
}
|
||||
if rw := postFact(t, s, token, f); rw.Code != 400 {
|
||||
t.Errorf("unguarded subject on an unknown type: status = %d, want 400", rw.Code)
|
||||
}
|
||||
if storage.IsGUIDSeen("unknowable:evil:1") {
|
||||
t.Error("fact-guard rejection was stored anyway")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user