{"openapi":"3.1.0","info":{"title":"manada","description":"Daily Tibia character data, a boss catalog, and hunt coordination for Discord.","license":{"name":"MIT","identifier":"MIT"},"version":"0.1.0"},"paths":{"/health":{"get":{"tags":["system"],"summary":"Liveness *and* readiness.","description":"This actually queries Postgres rather than returning a constant. A health check that cannot fail\ntells a probe nothing: the pod would stay in the load balancer while every real request 500s.","operationId":"health","responses":{"200":{"description":"Service health","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}},"security":[]}},"/openapi.json":{"get":{"tags":["system"],"summary":"The generated OpenAPI document.","description":"Served from a concrete route, never from an SPA-style fallback: a fallback registered too\nbroadly happily returns `index.html` here, and the endpoint looks alive until a client tries to\nparse it (fleet rule 90).","operationId":"openapi","responses":{"200":{"description":"OpenAPI 3.1 contract"}},"security":[]}},"/v1/boosted":{"get":{"tags":["boosted"],"summary":"What Tibia has boosted today.","description":"Reads the cache and **never calls Tibia** — a lookup endpoint must not put an upstream call on\nthe hot path, and Tibia is allowed to be down. Answers `200` with both halves absent when\nnothing has been recorded yet, rather than `404`: \"not fetched yet\" and \"nothing boosted\" are\nthe same thing to a caller deciding whether to post.\n\nDeliberately does *not* apply the server-save guard that `claim_announcements` does. This is a\nread of what we hold, for a human who asked; the guard exists to stop the bot **announcing**\nyesterday's boost unprompted, which is a different question.","operationId":"get_boosted","responses":{"200":{"description":"Today's boosted boss and creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Boosted"}}}}}}},"/v1/boosted/announcements/claim":{"post":{"tags":["boosted"],"summary":"Claim the servers due today's boosted announcement.","description":"**Claims rather than lists**, exactly as `POST /v1/hunts/reminders/claim` does. The selecting\nstatement is also the marking statement, so a server is handed out at most once a day no matter\nhow many pollers ask, how often the bot restarts, or how many replicas run. A list-then-mark\npair has a window between the two, and the visible symptom of losing that race is a channel\nposted to twice.\n\nThe `fetched_at` guard is the one that is easy to leave out: Tibia's boost rolls at server save,\nnot at UTC midnight, so a reading taken at 02:00 UTC carries today's date and *yesterday's*\nboost. See `crate::boosted`.","operationId":"claim_announcements","responses":{"200":{"description":"Servers to announce to, now marked as announced","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/BoostedAnnouncement"}}}}}}}},"/v1/bosses":{"get":{"tags":["bosses"],"summary":"List bosses, newest cursor last. Ordered by slug so the cursor is a single stable key.","operationId":"list","parameters":[{"name":"search","in":"query","description":"Case-insensitive substring match on the boss name.","required":false,"schema":{"type":["string","null"]}},{"name":"boss_type","in":"query","description":"Restrict to one kind of boss (`lever`, `raid`, `quest`, `field`).","required":false,"schema":{"type":["string","null"]}},{"name":"boostable","in":"query","description":"Only bosses that can be the daily boosted boss.","required":false,"schema":{"type":["boolean","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"A page of bosses","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_Boss"}}}}}}},"/v1/bosses/{slug}":{"get":{"tags":["bosses"],"summary":"Fetch one boss by slug.","operationId":"get_boss","parameters":[{"name":"slug","in":"path","description":"Boss slug, e.g. `world-devourer`","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Boss"}}}},"404":{"description":"No such boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/catalog/bestiary-difficulties":{"get":{"tags":["catalog"],"summary":"What each Bestiary difficulty costs and pays.","description":"Served so the curator and the LLM read the mapping rather than hard-coding it — and so the\nnumbers exist in exactly one place, which is the reason they are not columns on `creatures`.","operationId":"bestiary_difficulties","responses":{"200":{"description":"The six difficulties","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DifficultyEntry"}}}}}},"security":[]}},"/v1/catalog/boosted":{"get":{"tags":["catalog"],"summary":"Today's boosted boss and creature.","description":"Never calls Tibia: the refresher writes the day's row and this reads it, so a lookup cannot put\nan upstream call on an anonymous path. Answers `200` with both entries absent when today's row\nhas not been written yet — a legitimate state, not an error.","operationId":"boosted","responses":{"200":{"description":"Today's boosted boss and creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Boosted"}}}}},"security":[]}},"/v1/catalog/bosses":{"get":{"tags":["catalog"],"summary":"Bosses, through the catalog's open door.","description":"# Why bosses have two doors and everything else has one\n\nEvery other catalog resource is written *and* read at `/v1/catalog/…`. Bosses predate the\ncatalog: `/v1/bosses` was the bot's endpoint, behind the service token, before ADR 0015 opened a\npublic namespace for auditing agents. Both paths run the **same handler** — sharing it is what\nkeeps the public answer and the private one identical — but until now only `/v1/bosses` appeared\nin the contract, so a client reading the spec saw a resource it could write and not read.\n\nThese two wrappers exist purely to say so. They add no behaviour; they give the catalog paths a\n`#[utoipa::path]` of their own, because utoipa documents one path per handler.","operationId":"catalog_bosses","parameters":[{"name":"search","in":"query","description":"Case-insensitive substring match on the boss name.","required":false,"schema":{"type":["string","null"]}},{"name":"boss_type","in":"query","description":"Restrict to one kind of boss (`lever`, `raid`, `quest`, `field`).","required":false,"schema":{"type":["string","null"]}},{"name":"boostable","in":"query","description":"Only bosses that can be the daily boosted boss.","required":false,"schema":{"type":["boolean","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Bosses","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_Boss"}}}}},"security":[]}},"/v1/catalog/bosses/{slug}":{"get":{"tags":["catalog"],"summary":"One boss, through the catalog's open door. Same handler as `GET /v1/bosses/{slug}`.","operationId":"catalog_boss","parameters":[{"name":"slug","in":"path","description":"Boss slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Boss"}}}},"404":{"description":"No such boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Write a boss, by slug.","operationId":"put","parameters":[{"name":"slug","in":"path","description":"Boss slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutBoss"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Boss"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Remove a boss.","description":"**Hunts survive it.** `hunts.boss_id` is `ON DELETE SET NULL`, so a scheduled hunt loses its\nlink rather than the hunt — nobody's Saturday night disappears because the curator tidied a\ncatalog. Deliberately checked rather than assumed: the count of affected hunts comes back so a\ncurator can see what it just detached.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"Boss slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Removed"},"404":{"description":"No such boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"patch":{"tags":["catalog"],"summary":"Correct one field of a boss.","description":"The curator owns data quality here, and a wrong cooldown is one number — reading the row,\nmerging it and writing it all back would be three chances to blank something nobody meant to\ntouch, which is the whole reason `PATCH` exists beside `PUT`.","operationId":"patch","parameters":[{"name":"slug","in":"path","description":"Boss slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchBoss"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Boss"}}}},"404":{"description":"No such boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/creatures":{"get":{"tags":["catalog"],"summary":"The creature catalog.","operationId":"list","parameters":[{"name":"search","in":"query","description":"Substring of the name.","required":false,"schema":{"type":["string","null"]}},{"name":"bestiary_class","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"weak_to","in":"query","description":"Only creatures that take **more** than normal damage from this element.","required":false,"schema":{"type":["string","null"]}},{"name":"min_experience","in":"query","description":"Only creatures worth at least this much.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Creatures, slug order","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreaturePage"}}}}},"security":[]}},"/v1/catalog/creatures/{slug}":{"get":{"tags":["catalog"],"summary":"One creature.","operationId":"get_creature","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Creature"}}}},"404":{"description":"No such creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Create or replace a creature.","operationId":"put","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutCreature"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Creature"}}}},"422":{"description":"Unusable field","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Remove a creature, and with it every link that pointed at it.","description":"A boss row referencing it survives with `creature_id` NULL — deleting a bestiary entry must\nnever delete a scheduled hunt's boss.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Gone"},"404":{"description":"No such creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"patch":{"tags":["catalog"],"summary":"Change some fields of a creature.","operationId":"patch","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchCreature"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Creature"}}}},"404":{"description":"No such creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/creatures/{slug}/loot":{"get":{"tags":["catalog"],"summary":"What a creature drops.","operationId":"get_loot","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Its drops","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/LootEntry"}}}}},"404":{"description":"No such creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/creatures/{slug}/loot/{item_slug}":{"put":{"tags":["catalog"],"summary":"Say that a creature drops an item.","description":"Idempotent: re-pushing the same pair updates the rarity rather than failing, because a curator\nre-running its dataset is the normal case.","operationId":"put_loot","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}},{"name":"item_slug","in":"path","description":"Item slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutLoot"}}},"required":true},"responses":{"200":{"description":"Linked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LootEntry"}}}},"422":{"description":"Unknown creature or item, or an unknown rarity","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Unsay it.","operationId":"delete_loot","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}},{"name":"item_slug","in":"path","description":"Item slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Unlinked"},"404":{"description":"That creature does not drop that","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/creatures/{slug}/resistances":{"get":{"tags":["catalog"],"summary":"A creature's elemental resistances.","operationId":"get_resistances","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"One entry per recorded element","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Resistance"}}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Replace a creature's resistances wholesale.","description":"Whole-set rather than per-element: a creature's damage table is read from one place in the game\nand is meaningless in pieces, and replacing it makes a re-push idempotent without the curator\nhaving to work out which elements it previously sent.","operationId":"put_resistances","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutResistances"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Resistance"}}}}},"404":{"description":"No such creature","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Unknown element or percentage","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/creatures/{slug}/respawns":{"get":{"tags":["catalog"],"summary":"Everywhere a creature is found.","operationId":"get_creature_respawns","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Where it lives","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SpawnEntry"}}}}}},"security":[]}},"/v1/catalog/creatures/{slug}/respawns/{respawn_slug}":{"put":{"tags":["catalog"],"summary":"Say that a creature is found somewhere.","operationId":"put_creature_respawn","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}},{"name":"respawn_slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutSpawn"}}},"required":true},"responses":{"204":{"description":"Linked"},"422":{"description":"Unknown creature or respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Unsay it.","operationId":"delete_creature_respawn","parameters":[{"name":"slug","in":"path","description":"Creature slug","required":true,"schema":{"type":"string"}},{"name":"respawn_slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Unlinked"},"404":{"description":"It is not recorded there","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/items":{"get":{"tags":["catalog"],"summary":"Every item, by slug.","operationId":"list","parameters":[{"name":"item_type","in":"query","description":"Only items of this type.","required":false,"schema":{"type":["string","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Items","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_Item"}}}}},"security":[]}},"/v1/catalog/items/{slug}":{"get":{"tags":["catalog"],"summary":"One item.","operationId":"get_item","parameters":[{"name":"slug","in":"path","description":"Item slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The item","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Item"}}}},"404":{"description":"No such item","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Write an item, by slug.","operationId":"put","parameters":[{"name":"slug","in":"path","description":"Item slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutItem"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Item"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Remove an item.","description":"Its loot links go with it (`ON DELETE CASCADE`), because a drop of something that no longer\nexists is not a fact about anything.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"Item slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Removed"},"404":{"description":"No such item","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"patch":{"tags":["catalog"],"summary":"Change some fields of an item.","operationId":"patch","parameters":[{"name":"slug","in":"path","description":"Item slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchItem"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Item"}}}},"404":{"description":"No such item","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/locations":{"get":{"tags":["catalog"],"summary":"Every place, by slug.","operationId":"list","parameters":[{"name":"kind","in":"query","description":"Only places of this kind.","required":false,"schema":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/LocationKind"}]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Places","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_Location"}}}}},"security":[]}},"/v1/catalog/locations/{slug}":{"get":{"tags":["catalog"],"summary":"One place.","operationId":"get_location","parameters":[{"name":"slug","in":"path","description":"Location slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The place","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Location"}}}},"404":{"description":"No such location","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Write a place, by slug.","operationId":"put","parameters":[{"name":"slug","in":"path","description":"Location slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutLocation"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Location"}}}},"422":{"description":"Invalid, or a loop of places","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Remove a place.","description":"Anything inside it is detached rather than deleted (`ON DELETE SET NULL`), and a respawn that\npointed at it keeps existing without one — removing a region must not remove the spawns in it.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"Location slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Removed"},"404":{"description":"No such location","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"patch":{"tags":["catalog"],"summary":"Change some fields of a place.","operationId":"patch","parameters":[{"name":"slug","in":"path","description":"Location slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchLocation"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Location"}}}},"404":{"description":"No such location","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid, or a loop of places","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/respawns":{"get":{"tags":["catalog"],"summary":"Every hunting ground in the catalog.","operationId":"list","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Hunting grounds","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_Respawn"}}}}},"security":[]}},"/v1/catalog/respawns/{slug}":{"get":{"tags":["catalog"],"summary":"One hunting ground.","operationId":"get_respawn","parameters":[{"name":"slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The hunting ground","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"404":{"description":"No such respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Write a hunting ground, by slug.","description":"Upsert rather than insert: a repeated slug is the curator re-pushing its dataset, which is the\nnormal case, not an error. An unhandled unique violation would surface as a `500` — nothing a\ncaller could act on.","operationId":"put","parameters":[{"name":"slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertRespawnRequest"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"409":{"description":"The name is taken by another respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Retire a hunting ground.","description":"Archives rather than deletes, because claims — live and historical — reference it, and \"who was\nhogging Vengoth\" has to survive the curator tidying its dataset. Idempotent.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Retired"},"404":{"description":"No such respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"patch":{"tags":["catalog"],"summary":"Correct one field of a hunting ground.","description":"The whole reason `PATCH` exists beside `PUT`: an agent fixing an experience rate should not have\nto read the row, merge it and write it all back, which is three chances to blank something\nnobody meant to touch.","operationId":"patch","parameters":[{"name":"slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchRespawn"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"404":{"description":"No such respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/respawns/{slug}/creatures":{"get":{"tags":["catalog"],"summary":"Everything found in one place. The same join, read from the other end — which is the reason it\nis a table rather than a list of names on each creature.","operationId":"get_respawn_creatures","parameters":[{"name":"slug","in":"path","description":"Respawn slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"What lives there","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DwellerEntry"}}}}}},"security":[]}},"/v1/catalog/worlds":{"put":{"tags":["catalog"],"summary":"Replace the world list.","description":"**Worlds are a finite set CipSoft publishes, so the curator owns them** like every other game\nfact (ADR 0018). Before this the only writer was the presence sampler, which meant an\nenvironment where sampling never ran had no worlds — and since ADR 0020 made\n`server_settings.world_id` a foreign key, no way to configure a Discord server either.","operationId":"put_worlds","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutWorlds"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorldsWritten"}}}},"422":{"description":"A world with no name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/catalog/worlds/{slug}":{"get":{"tags":["catalog"],"summary":"One world.","description":"Answers for retired worlds too. A server configured to a world Tibia has since merged away must\nstill be able to render its own settings, and an agent auditing the catalog wants the history.","operationId":"get_world","parameters":[{"name":"slug","in":"path","description":"World slug, e.g. `antica`","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The world","content":{"application/json":{"schema":{"$ref":"#/components/schemas/World"}}}},"404":{"description":"No such world","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["catalog"],"summary":"Write one world.","description":"**Unlike the bulk `PUT /v1/catalog/worlds`, this retires nothing.** That endpoint's contract is\n\"this is the whole list\"; this one's is \"this is this world\". Conflating them is how a\nsingle-field correction would take ninety-two worlds offline.\n\nThe name must agree with the slug, because the slug *is* the name. Without that check,\n`PUT /v1/catalog/worlds/antica` with `{\"name\": \"Secura\"}` would cheerfully write a row keyed\n`secura` — leaving the caller believing they had edited Antica.","operationId":"put","parameters":[{"name":"slug","in":"path","description":"World slug, e.g. `antica`","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutWorld"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/World"}}}},"422":{"description":"Invalid, or the name disagrees with the slug","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["catalog"],"summary":"Retire a world. **Does not delete it**, unless you ask for `?purge=true` and nothing objects.","description":"Answers `204` whether or not it was already retired, because `DELETE` is idempotent (api-design\nrule) and \"it is not running\" is the outcome the caller asked for. `404` only when no such world\nhas ever existed, which is a different statement entirely. To bring one back,\n`PATCH {\"active\": true}`.\n\n# Why `purge` needs two locks, not one\n\nThe foreign keys are the second lock: `hunts`, `server_settings` and `tibia_players` all\nreference `tibia_worlds` with `NO ACTION`, so **Postgres refuses the delete itself** when a world\nis in use. There is no hand-written \"is it safe\" check to fall out of date as tables are added —\na new reference makes the purge start failing the moment it exists, which is the correct\ndirection for a check to break in.\n\n**That lock alone is far weaker than it sounds, and this was learned the hard way.** It protects\nworlds that are *already* referenced. On a freshly-seeded database nothing references anything\nyet, so every one of the ninety-three worlds is unreferenced and a single stray `?purge=true`\nerases a live world with a cheerful `204`. That is exactly what happened to `antica`, in\nproduction, during what was meant to be a read-only check.\n\nSo the first lock is a state interlock: **a world must already be retired before it can be\nerased.** Purging an active world is a `409`. Erasing something now takes two deliberate calls\nthat say different things — \"this is not running\" and then \"this should never have existed\" —\nand no single request can take a working world out of the catalog.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"World slug, e.g. `antica`","required":true,"schema":{"type":"string"}},{"name":"purge","in":"query","description":"Erase the row instead of retiring it. **The world must already be retired**, and nothing\nmay reference it.\n\nFor undoing a mistake — a typo'd name, a world invented by a bad payload — not for worlds\nTibia has closed. Those are history and must stay.","required":false,"schema":{"type":"boolean"}}],"responses":{"204":{"description":"Retired — the row remains, with `active: false`. With `?purge=true` on an already-retired world, erased."},"404":{"description":"No such world","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"409":{"description":"Purge refused: the world is still running, or something references it","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"patch":{"tags":["catalog"],"summary":"Change part of one world.","operationId":"patch","parameters":[{"name":"slug","in":"path","description":"World slug, e.g. `antica`","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchWorld"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/World"}}}},"404":{"description":"No such world","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"409":{"description":"Renaming onto a world that already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/characters":{"post":{"tags":["characters"],"summary":"Start tracking a character: fetch it from Tibia now, and refresh it daily from here on.","operationId":"track","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrackCharacterRequest"}}},"required":true},"responses":{"201":{"description":"The character is now tracked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Character"}}}},"404":{"description":"No such character in Tibia","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"502":{"description":"Tibia's API is unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/characters/{name}":{"get":{"tags":["characters"],"summary":"Read a character from our own store.","description":"Does **not** call Tibia: this is the hot path for hunt rosters, and putting an upstream call\nbehind it would make every roster render depend on Tibia being up. Use `/refresh` to force one.","operationId":"get_character","parameters":[{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The character as last synced","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Character"}}}},"404":{"description":"Not tracked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/characters/{name}/deaths":{"get":{"tags":["characters"],"summary":"Deaths, newest first.","operationId":"deaths","parameters":[{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"A page of deaths","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_CharacterDeath"}}}}}}},"/v1/characters/{name}/refresh":{"post":{"tags":["characters"],"summary":"Force an immediate refresh from Tibia.","operationId":"refresh","parameters":[{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Refreshed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Character"}}}},"404":{"description":"No such character in Tibia","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/characters/{name}/snapshots":{"get":{"tags":["characters"],"summary":"Daily snapshots, newest first.","operationId":"snapshots","parameters":[{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"A page of daily snapshots","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_CharacterSnapshot"}}}}}}},"/v1/discord-users/{discord_user_id}/characters":{"get":{"tags":["verification"],"summary":"Every character a Discord user has verified.","operationId":"list_characters","parameters":[{"name":"discord_user_id","in":"path","description":"Discord snowflake","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Verified characters","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/LinkedCharacter"}}}}}}}},"/v1/discord-users/{discord_user_id}/characters/{name}":{"delete":{"tags":["verification"],"summary":"Unlink a character.","operationId":"unlink","parameters":[{"name":"discord_user_id","in":"path","description":"Discord snowflake","required":true,"schema":{"type":"string"}},{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Unlinked"}}}},"/v1/discord-users/{discord_user_id}/characters/{name}/main":{"put":{"tags":["verification"],"summary":"Choose which verified character is the player's main.","operationId":"set_main","parameters":[{"name":"discord_user_id","in":"path","description":"Discord snowflake","required":true,"schema":{"type":"string"}},{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Main character set","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LinkedCharacter"}}}},"404":{"description":"Not linked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/discord-users/{discord_user_id}/hunt-sessions":{"get":{"tags":["hunt-sessions"],"summary":"A player's own hunt sessions.","description":"**Rule 2**: scoped to the submitter, always. The path carries the Discord user id and the query\nis filtered on it — there is no variant of this that returns somebody else's sessions.","operationId":"list","parameters":[{"name":"discord_user_id","in":"path","description":"Whose sessions","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"Their sessions, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HuntSession"}}}}}}}},"/v1/discord-users/{discord_user_id}/party-sessions":{"get":{"tags":["party-sessions"],"summary":"One player's own party sessions, newest first.","operationId":"list","parameters":[{"name":"discord_user_id","in":"path","description":"Whose sessions","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Their sessions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_PartySession"}}}}}}},"/v1/discord-users/{discord_user_id}/preferences":{"get":{"tags":["preferences"],"summary":"What this person prefers.","description":"Answers `200` with everything unset for somebody we have never seen, rather than `404`: \"no row\nyet\" and \"no preferences\" are the same thing to every caller, and one obliged to tell them apart\nwill eventually forget to.","operationId":"get_preferences","parameters":[{"name":"discord_user_id","in":"path","description":"Discord user snowflake","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Their preferences","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPreferences"}}}},"422":{"description":"Not a snowflake","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}},"patch":{"tags":["preferences"],"summary":"Change some of them.","operationId":"update_preferences","parameters":[{"name":"discord_user_id","in":"path","description":"Discord user snowflake","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePreferencesRequest"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPreferences"}}}},"422":{"description":"Not a snowflake, or not a language we speak","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/hunt-sessions":{"post":{"tags":["hunt-sessions"],"summary":"Store a pasted hunt analysis.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHuntSessionRequest"}}},"required":true},"responses":{"201":{"description":"Stored, or already stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HuntSession"}}}},"403":{"description":"That character is not verified as theirs","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Not a readable hunt analysis","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"429":{"description":"Too many pastes this hour","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/hunt-sessions/{id}":{"patch":{"tags":["hunt-sessions"],"summary":"Set or clear a session's note.","description":"**The only editable thing about a stored session.** Everything else came out of the client's own\nwindow; rewriting a figure would be inventing a hunt that never happened, and the raw paste is\nkept precisely so nobody ever has to.\n\n**Ownership is the SQL predicate, not a read-then-write.** The check and the write are one\nstatement, so they cannot be raced and a later edit cannot move the write out from under the\ncheck — the same shape the dashboard's own actions use. A note aimed at somebody else's session\nanswers `404`, because \"that exists, but not yours\" confirms an id the caller should learn\nnothing about.","operationId":"update_note","parameters":[{"name":"id","in":"path","description":"Which session","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSessionNoteRequest"}}},"required":true},"responses":{"200":{"description":"The session, with its note","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HuntSession"}}}},"404":{"description":"No session of yours by that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"The note is too long","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/hunts":{"get":{"tags":["hunts"],"summary":"List hunts, soonest first.","operationId":"list","parameters":[{"name":"discord_server_id","in":"query","description":"Restrict to one Discord server. Almost always set — rosters are server-scoped.","required":false,"schema":{"type":["string","null"]}},{"name":"status","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"upcoming","in":"query","description":"Only hunts that have not started yet.","required":false,"schema":{"type":["boolean","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"A page of hunts","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_HuntSummary"}}}}}},"post":{"tags":["hunts"],"summary":"Schedule a hunt.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateHuntRequest"}}},"required":true},"responses":{"201":{"description":"Hunt scheduled","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hunt"}}}},"404":{"description":"No such boss","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid hunt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/hunts/nudges/claim":{"post":{"tags":["hunts"],"summary":"Claim the hunts that are still short of a party and due another \"confirm or deny\" nudge.","description":"Claims rather than lists, for the same reason as the pre-hunt reminder: the read and the write\nhappen in one statement, so two pollers cannot both nudge the same hunt.\n\nThe bounds are the whole design. A nudge repeats, and anything that repeats into a chat channel\nhas to say when it stops:\n\n* **`min_players`** — small parties organise themselves; chasing them is noise.\n* **`within_minutes`** — nudging only starts once the hunt is near. Hourly nudges from the moment\n  a hunt is created would mean hundreds of messages for one scheduled two weeks out.\n* **`max_nudges`** — a hard ceiling, so a hunt nobody joins goes quiet instead of nagging until\n  it starts.\n* **`reminder_sent_at IS NULL`** — once the \"starting soon\" reminder has gone out, the hunt is\n  imminent and nagging is over.\n* **confirmed < capacity** — the point of the whole thing. A full party is never nudged.","operationId":"claim_nudges","parameters":[{"name":"min_players","in":"query","description":"Only nudge hunts whose party is at least this large. Defaults to 6 — below that a hunt is a\nhandful of friends who do not need chasing.","required":false,"schema":{"type":["integer","null"],"format":"int32"}},{"name":"within_minutes","in":"query","description":"How close a hunt must be before nudging starts. Defaults to 1440 (24 hours).","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"interval_minutes","in":"query","description":"Minimum gap between nudges for one hunt. Defaults to 60.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"max_nudges","in":"query","description":"Give up after this many nudges for one hunt. Defaults to 12.","required":false,"schema":{"type":["integer","null"],"format":"int32"}}],"responses":{"200":{"description":"Hunts newly claimed for a nudge","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Hunt"}}}}}}}},"/v1/hunts/reminders/claim":{"post":{"tags":["hunts"],"summary":"Claim the hunts that are due a reminder, marking them as reminded in the same statement.","description":"**Claim, not list.** The bot polls this on a timer, and a `GET` followed by a separate `PATCH`\nwould let two polls — a retry, an overlapping tick, a second replica during a rolling restart —\nboth see the same hunt as unreminded and both ping the channel. Doing the read and the write in\none `UPDATE … RETURNING` makes a hunt claimable exactly once: the second caller's `WHERE\nreminder_sent_at IS NULL` no longer matches, and it gets an empty list.\n\nThis is why the endpoint is a `POST` on a collection rather than a `GET`: it is a state change\nthat happens to return what it changed.","operationId":"claim_reminders","parameters":[{"name":"within_minutes","in":"query","description":"How far ahead to look. Defaults to 60 minutes.","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"Hunts newly claimed for a reminder","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Hunt"}}}}}}}},"/v1/hunts/{id}":{"get":{"tags":["hunts"],"summary":"One hunt, with its full roster.","operationId":"get_hunt","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"200":{"description":"The hunt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hunt"}}}},"404":{"description":"No such hunt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}},"delete":{"tags":["hunts"],"summary":"Cancel a hunt. Idempotent.","operationId":"cancel","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"204":{"description":"Cancelled"}}},"patch":{"tags":["hunts"],"summary":"Change a hunt. Omitted fields are left alone.","operationId":"update","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateHuntRequest"}}},"required":true},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hunt"}}}},"404":{"description":"No such hunt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/hunts/{id}/audit":{"get":{"tags":["hunts"],"summary":"The hunt's audit trail, newest first.","operationId":"audit_trail","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"200":{"description":"Who did what","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HuntAuditEntry"}}}}}}}},"/v1/hunts/{id}/roster-message":{"put":{"tags":["hunts"],"summary":"Record which Discord message carries this hunt's roster embed.","description":"**Deliberately separate from `PATCH /v1/hunts/{id}`, and deliberately actor-less.** This is the\nbot writing down where it just posted — bookkeeping about Discord, not a change anybody made to\nthe hunt. It used to be a field on the update request, which forced an unhappy choice once\nediting required an actor: either the bot asserts that some *person* edited the hunt (a lie the\naudit trail would repeat), or the edit rule is skipped for requests that \"only\" carry this one\nfield — a check conditional on the payload, which is the shape of bug this change exists to\nremove.\n\nSplitting it lets `PATCH` demand an actor unconditionally. Nothing here is player-visible: the\nworst a wrong message id does is send the bot editing a message that is not there, which it\nalready tolerates. Still server-scoped, so one installation cannot repoint another's roster.","operationId":"put_roster_message","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RosterMessageRequest"}}},"required":true},"responses":{"204":{"description":"Recorded"},"404":{"description":"No such hunt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/hunts/{id}/signups/{discord_user_id}":{"put":{"tags":["hunts"],"summary":"Set a player's answer. Idempotent — the same body twice leaves the same state.","operationId":"put_signup","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_user_id","in":"path","description":"Discord snowflake","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutSignupRequest"}}},"required":true},"responses":{"200":{"description":"The hunt, with the updated roster","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hunt"}}}},"409":{"description":"The hunt is not accepting signups","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Invalid signup","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}},"delete":{"tags":["hunts"],"summary":"Withdraw entirely.","operationId":"delete_signup","parameters":[{"name":"id","in":"path","description":"Hunt id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_user_id","in":"path","description":"Discord snowflake","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The hunt, with the updated roster","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Hunt"}}}}}}},"/v1/knowledge/search":{"get":{"tags":["knowledge"],"summary":"Search the knowledge base.","operationId":"search","parameters":[{"name":"q","in":"query","description":"What the player asked, in their own words.","required":true,"schema":{"type":"string"}},{"name":"entity_type","in":"query","description":"Narrow to one category: `quest`, `creature`, `hunting_area`, `item`, `npc`.","required":false,"schema":{"type":["string","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"Ranked documents, best first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KnowledgeHit"}}}}},"422":{"description":"Empty query or unknown category","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/knowledge/{slug}":{"get":{"tags":["knowledge"],"summary":"One document by slug, for when the agent already knows what it wants.","operationId":"get_document","parameters":[{"name":"slug","in":"path","description":"The document slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The document","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KnowledgeDocument"}}}},"404":{"description":"No such document","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"put":{"tags":["knowledge"],"summary":"Write a document, keeping the version it replaces.","operationId":"upsert","parameters":[{"name":"slug","in":"path","description":"The document slug","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertDocument"}}},"required":true},"responses":{"200":{"description":"Written; the previous version is kept","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KnowledgeDocument"}}}},"422":{"description":"Unknown category, or a field missing or too long","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]},"delete":{"tags":["knowledge"],"summary":"Remove a document and its history.","description":"**Erasure is complete, on purpose.** Keeping orphaned revisions of a deleted document leaves a\nrecord of what something claimed with no way to see its context, which serves nobody: the normal\nway to fix a wrong fact is to write the correct one, which keeps the old version *and* the\ndocument. Deleting is for a document that should never have existed.","operationId":"delete","parameters":[{"name":"slug","in":"path","description":"The document slug","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Gone, with its revisions"},"404":{"description":"No such document","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}},"security":[]}},"/v1/knowledge/{slug}/revisions":{"get":{"tags":["knowledge"],"summary":"What a document used to say.","description":"**The answer to an open write path.** Anyone can change a fact; this is how anyone can see that\nit changed, what it said before, and what claimed to have written it.","operationId":"revisions","parameters":[{"name":"slug","in":"path","description":"The document slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Previous versions, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KnowledgeRevision"}}}}}},"security":[]}},"/v1/maintenance/boosted-refresh":{"post":{"tags":["maintenance"],"summary":"Read today's boosted boss and creature from Tibia and record them.","description":"A failure here is a **skipped refresh**, not an error worth failing a job over: Tibia is allowed\nto be down, and the cost is a late announcement. The handler answers `200` either way and says\nwhat it managed — a CronJob that goes red on somebody else's outage is one people stop reading.","operationId":"boosted_refresh","responses":{"200":{"description":"Refreshed, as far as Tibia allowed"}}}},"/v1/maintenance/character-sync":{"post":{"tags":["maintenance"],"summary":"Refresh every character that is due.","description":"Was a loop inside the API with a 15-second startup stagger so a rolling deploy would not have\nevery replica hit Tibia at once — a workaround for a design where every replica ran it. One\nCronJob does not need staggering.","operationId":"character_sync","responses":{"200":{"description":"What the pass did","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SyncReport"}}}}}}},"/v1/maintenance/presence":{"post":{"tags":["maintenance"],"operationId":"presence","responses":{"200":{"description":"What it rebuilt","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaintenanceReport"}}}}}}},"/v1/maintenance/verification-sweep":{"post":{"tags":["verification"],"summary":"Re-check every pending verification whose token has since appeared.","description":"**Why this exists.** A player edits their character comment and asks us to check it seconds\nlater, and reading Tibia is not instant — even past the cache, they may have pressed confirm\nbefore saving the page. Telling them \"not yet, try again\" puts the burden on them to keep\nretrying a thing that will simply become true on its own. So they are told we will keep looking,\nand this is what does it.\n\nCalled by the `manada-verification-sweep` CronJob, not a timer — nothing in this service runs on\na clock (ADR 0024).\n\nDeliberately reuses [`confirm`] rather than reimplementing the link: the ownership check, the\n\"already claimed by someone else\" conflict, the main-character rule and the sync enrolment all\nlive there, and a second copy of that logic is how the two drift into disagreeing about who owns\na character.","operationId":"sweep","responses":{"200":{"description":"What the pass did","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SweepReport"}}}}}}},"/v1/maintenance/voice":{"post":{"tags":["maintenance"],"summary":"Create next month's transcript partitions, and drop the ones past retention.","description":"**Two jobs in one pass, and the first matters more.** A missing partition does not error — rows\nland in the default partition instead, which can never be dropped cheaply, so a month of silence\nhere turns into a table that retention cannot reach. The same reasoning as the presence mirror's,\nwhich is why this borrows its shape.","operationId":"voice","responses":{"200":{"description":"What the pass did","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoiceMaintenanceReport"}}}}}}},"/v1/manada/search":{"get":{"tags":["manada"],"summary":"Find someone to fill a gap in tonight's party.","description":"**A different question from `/v1/manada/{name}`, deliberately.** That one asks \"who plays when I\ndo\", scored over weeks of observed rhythm — the right answer when you are building a group from\nnothing. This one asks \"who could come *now*\": you have four people, you need a knight who can\nshare with the level you already have, and whether their Tuesdays look like yours is beside the\npoint.\n\nSo the ordering is **recency first**, then how much they play. A player seen yesterday is worth\nmore than one with a beautiful schedule last seen in March.\n\nThe level range is Tibia's own shared-experience rule around `share_with`, and it is the whole\nreason this exists: a candidate you cannot share experience with cannot fill the slot at all.\n`min_level` and `max_level` narrow it further; they can never widen it past what the game allows.","operationId":"search","parameters":[{"name":"world","in":"query","description":"Which world. A player can only hunt with people on their own.","required":true,"schema":{"type":"string"}},{"name":"share_with","in":"query","description":"The level the party already has, that a candidate must be able to share experience with.","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"vocation","in":"query","description":"Restrict to one vocation — the slot you are actually missing.","required":false,"schema":{"type":["string","null"]}},{"name":"min_level","in":"query","description":"Narrow the range further. Never widens it past the game's rule.","required":false,"schema":{"type":["integer","null"],"format":"int32"}},{"name":"max_level","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int32"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int32"}},{"name":"exclude","in":"query","description":"A character to leave out — the one the caller is searching on behalf of.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Candidates, freshest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RosterSearchResult"}}}},"404":{"description":"No such world","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/manada/{name}":{"get":{"tags":["manada"],"summary":"Who should this character hunt with?","operationId":"find","parameters":[{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"vocations","in":"query","description":"Only these vocations, comma-separated (`knight,druid`). Accepts anything\n[`Vocation::parse`] does, including Tibia's promoted names.\n\n**Absent is not the same as empty.** Omitting it keeps the old default — everybody except\nyour own vocation, since a party usually wants what it does not already have. Naming\nvocations replaces that default rather than narrowing it, so `vocations=paladin` really does\nmean paladins even for a paladin.","required":false,"schema":{"type":["string","null"]}},{"name":"offset","in":"query","description":"How many ranked matches to skip. The bot's \"Show more\" button, which is the only reason this\nexists — the scoring is deterministic, so page two is stable as long as the data is.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"actor_discord_id","in":"query","description":"Who is asking, when the caller knows. Rate-limits the searcher and records the lookup.\n\nAttribution, not authentication (ADR 0002) — but this endpoint reveals information about\nplayers who never opted in, so a log of who asked is the first thing anyone will want if it\nis ever abused.","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Matches, or why there are none","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManadaResult"}}}},"422":{"description":"Not a usable name","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/party-sessions":{"post":{"tags":["party-sessions"],"summary":"Store a pasted party analysis and return the split.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePartySessionRequest"}}},"required":true},"responses":{"201":{"description":"Stored, with the split","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartySession"}}}},"422":{"description":"Not a readable party analysis","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"429":{"description":"Too many pastes this hour","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/party-sessions/{id}":{"patch":{"tags":["party-sessions"],"summary":"Set or clear a party session's note. The twin of [`super::hunt_sessions::update_note`], and the\nsame rules: the only editable field, ownership in the predicate, `404` for anybody else's.","operationId":"update_note","parameters":[{"name":"id","in":"path","description":"Which session","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSessionNoteRequest"}}},"required":true},"responses":{"200":{"description":"The session, with its note","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PartySession"}}}},"404":{"description":"No session of yours by that id","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"The note is too long","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/players/{name}":{"get":{"tags":["manada"],"summary":"What we have observed about one player.","operationId":"get_player","parameters":[{"name":"name","in":"path","description":"Character name","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"What we have observed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservedPlayer"}}}},"404":{"description":"Never seen","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/presence/observations":{"post":{"tags":["presence"],"summary":"Record one world's online list.","description":"**Idempotent by construction.** The write is `slots | mask` — commutative and absorbing — so\nsending the same observation twice leaves every row byte-identical. That is what lets the mirror\nretry a failed request without coordination, and what let the old in-process version run without\nleader election.","operationId":"record_observation","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationRequest"}}},"required":true},"responses":{"202":{"description":"Recorded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationAccepted"}}}},"404":{"description":"Unknown world","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Clock drift, or an unusable field","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/presence/plan":{"get":{"tags":["presence"],"summary":"What to sample.","operationId":"plan","responses":{"200":{"description":"The sampling plan","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SamplingPlan"}}}}}}},"/v1/presence/worlds":{"put":{"tags":["presence"],"summary":"Replace the world list.","description":"A world that disappears from Tibia's list is **deactivated, never deleted**: presence rows and\nplayer rows reference it, and a closed world's history is still history. `active = false` is\nwhat stops the sampler visiting it.","operationId":"put_worlds","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorldListRequest"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorldListAccepted"}}}},"422":{"description":"Empty list","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/respawn-claims/sweep":{"post":{"tags":["respawns"],"summary":"Settle expired holds and report what the bot should announce.","description":"**Claims rather than lists**, like the reminder and nudge polls: each event is marked in the\nsame statement that produces it, so a handover is announced exactly once however often the bot\npolls or however many replicas run.\n\nRegistered at `/v1/respawn-claims/sweep` deliberately, **not** `/v1/respawns/claims/sweep` —\nthat has the same segment count as `/v1/respawns/{id}/claims` and would make matchit's\nstatic-over-dynamic preference load-bearing for correctness.","operationId":"sweep","parameters":[{"name":"warn_before_minutes","in":"query","description":"How long before a hold ends to warn its holder. Default 10 minutes.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"frees_soon_minutes","in":"query","description":"How far ahead to tell the next person in the queue that a respawn frees. Defaults to 15 —\ndeliberately longer than the holder's own warning, because the holder is already there and\nthe person waiting has to get to the keyboard.","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}}],"responses":{"200":{"description":"What to announce","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ClaimEvent"}}}}},"422":{"description":"Out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/respawns":{"get":{"tags":["respawns"],"summary":"The board: every respawn a server lists, with who holds it and who is waiting.","description":"One query per collection rather than one per row — the hunt listing's no-N+1 rule. A board of 25\nrespawns is three round trips, not fifty-one.","operationId":"list","parameters":[{"name":"discord_server_id","in":"query","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"The server's respawns","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_Respawn"}}}}}}},"/v1/respawns/{id}":{"get":{"tags":["respawns"],"summary":"One respawn, with its full queue.","operationId":"get_respawn","parameters":[{"name":"id","in":"path","description":"Respawn id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"200":{"description":"The respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"404":{"description":"No such respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/respawns/{id}/claims":{"post":{"tags":["respawns"],"summary":"Claim a respawn, or take a place in its queue.","description":"A respawn that is already held produces a **queue entry, not an error** — the same rule as \"a\nconfirmation that does not fit becomes a waitlist entry\". The player said they want it; the fair\nanswer is a place in line.\n\nIdempotent: a player who already holds it or is already queued gets the same state back, because\nDiscord delivers interactions twice.","operationId":"create_claim","parameters":[{"name":"id","in":"path","description":"Respawn id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateClaimRequest"}}},"required":true},"responses":{"200":{"description":"Already involved; unchanged","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"201":{"description":"Held, or queued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"422":{"description":"Invalid","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"429":{"description":"Cooldown, or too many at once","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/respawns/{id}/claims/{discord_user_id}":{"delete":{"tags":["respawns"],"summary":"Give up a hold, or leave the queue.","description":"Promotes the next in line **in the same transaction**, so the board is never briefly wrong.\nIdempotent: releasing what you do not hold returns the current state rather than an error.","operationId":"delete_claim","parameters":[{"name":"id","in":"path","description":"Respawn id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_user_id","in":"path","description":"Whose claim","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"200":{"description":"Released","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"404":{"description":"No such respawn","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/respawns/{id}/claims/{discord_user_id}/extend":{"post":{"tags":["respawns"],"summary":"Keep a respawn longer, when nobody is waiting.","description":"The honest alternative to cancel-and-rebook, and it matters as much as the cooldown does: it\nremoves the *legitimate* reason a player would give a spawn up and immediately take it again.","operationId":"extend_claim","parameters":[{"name":"id","in":"path","description":"Respawn id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"discord_user_id","in":"path","description":"Whose hold","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtendClaimRequest"}}},"required":true},"responses":{"200":{"description":"Extended","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Respawn"}}}},"409":{"description":"Someone is waiting","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Out of range","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/servers/{discord_server_id}/settings":{"get":{"tags":["settings"],"summary":"A server's settings.","description":"Answers `200` with defaults for a server that has never configured anything, rather than `404`.\n\"No row yet\" and \"configured, and switched off\" mean exactly the same thing to every caller,\nand a caller obliged to tell them apart will eventually forget to.","operationId":"get_settings","parameters":[{"name":"discord_server_id","in":"path","description":"Discord server snowflake","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The server's settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerSettings"}}}},"422":{"description":"Not a snowflake","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}},"patch":{"tags":["settings"],"summary":"Change a server's settings. Omitted fields are left alone.","description":"Upserts, so a server does not need to be created before it can be configured.","operationId":"update_settings","parameters":[{"name":"discord_server_id","in":"path","description":"Discord server snowflake","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateServerSettingsRequest"}}},"required":true},"responses":{"200":{"description":"Updated settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerSettings"}}}},"422":{"description":"Invalid settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/servers/{discord_server_id}/setup":{"put":{"tags":["settings"],"summary":"Set an installation up: its owner, its language, and the Tibia world it plays on.","description":"# This records a decision; it does not make one\n\nThe bot has already checked that whoever asked holds Discord's own `MANAGE_GUILD` — the only\ntrustworthy signal available at the moment an installation is claimed, and one this service\ncannot see, because it authenticates the bot rather than the player (ADR 0002). So\n`owner_discord_user_id` is an attributed claim in the same sense as `actor_discord_id` in\n`crate::audit`: anyone holding the service token can set any server's owner. That is the\npre-existing model, not a hole this endpoint opens, and no check added here could close it.\n\n# Idempotent, because re-running it is how things change\n\nPeople change worlds, hand guilds over, and leave Discord. Re-running `/setup` is how a server\nchanges its language or transfers ownership, so this upserts — and `set_up_at` keeps its first\nvalue, because it means \"configured since\", with `updated_at` carrying \"last changed\".\n\n# Boosted announcements default on, in the channel this was run from (ADR 0046)\n\n`body.announce_channel_id` — the channel the bot saw this command run in — becomes\n`announce_channel_id` and switches `boosted_enabled` on, but **only when the server has neither\nset yet**. A re-run never repoints an already-chosen channel or re-enables a flag a moderator\nexplicitly turned off: both are filled in once, on the way in, never overwritten on the way\nback through.","operationId":"put_setup","parameters":[{"name":"discord_server_id","in":"path","description":"Discord server snowflake","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PutServerSetupRequest"}}},"required":true},"responses":{"200":{"description":"Set up","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServerSettings"}}}},"422":{"description":"Not a snowflake, or not a world we know","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"502":{"description":"The world list has not loaded yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/servers/{discord_server_id}/voice-channel":{"delete":{"tags":["settings"],"summary":"Forget the voice channel: the bot has left and must not rejoin on the next start.","operationId":"clear_voice_channel","parameters":[{"name":"discord_server_id","in":"path","description":"Which server","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"204":{"description":"Forgotten"}}}},"/v1/servers/{discord_server_id}/voice-channel/{discord_channel_id}":{"put":{"tags":["settings"],"summary":"Remember which voice channel the bot is transcribing, so it can rejoin after a redeploy.","description":"A pair of verbs of its own rather than a field on the settings `PATCH`. Clearing a value through\na patch needs `Option<Option<T>>` — \"absent\" and \"explicitly null\" are different — which serde\nexpresses badly and callers get wrong. `PUT` sets, `DELETE` clears, and neither can be\nmisread.","operationId":"set_voice_channel","parameters":[{"name":"discord_server_id","in":"path","description":"Which server","required":true,"schema":{"type":"string"}},{"name":"discord_channel_id","in":"path","description":"The voice channel being transcribed","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"204":{"description":"Remembered"}}}},"/v1/verifications":{"post":{"tags":["verification"],"summary":"Issue (or return) a token proving ownership of a character.","operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateVerificationRequest"}}},"required":true},"responses":{"201":{"description":"Token issued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Verification"}}}},"409":{"description":"Character already linked to another Discord user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/verifications/{id}/confirm":{"post":{"tags":["verification"],"summary":"Check the character's comment for the token and, if it is there, create the link.","operationId":"confirm","parameters":[{"name":"id","in":"path","description":"Verification id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"actor_discord_id","in":"path","description":"Retired in favour of `ServerScope`'s `actor_discord_id`, so every endpoint spells \"who is\nasking\" the same way. Kept only so the type name in the OpenAPI document does not vanish.","required":true,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Verification outcome","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationResult"}}}},"404":{"description":"No such verification","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/voice/active-channels":{"get":{"tags":["voice"],"summary":"Every server the bot should be transcribing right now.","description":"**Read once at startup and never again.** songbird's voice connection lives in process memory,\nso a redeploy drops the bot out of every channel it was in with nothing said — the people in it\nkeep talking to a bot that is no longer listening. This is how it finds its way back.\n\nOnly servers that still have transcription switched on: a channel remembered for a server that\nhas since turned it off must not be rejoined.","operationId":"active_voice_channels","responses":{"200":{"description":"Server and channel pairs","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveVoiceChannel"}}}}}}}},"/v1/voice/optouts":{"get":{"tags":["voice"],"summary":"Who has opted out, so the bot can honour it **before** audio leaves the process.","description":"**This endpoint is what makes the opt-out real rather than declarative.** The module doc above\nsays an opted-out speaker's audio is discarded in the bot; without a way to read the list, the\nbot could not know, and the only enforcement was this service refusing the transcript *after*\nthe words had already been sent to an inference endpoint and turned into text. Refusing at\ningest is a backstop. This is the control.\n\nThe whole list, not per-server: an opt-out is a person saying \"not anywhere\", so scoping it to\nthe server that asked would leak the boundary of the decision and be wrong besides.","operationId":"list_optouts","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Speakers who opted out","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_String"}}}}}}},"/v1/voice/optouts/{discord_user_id}":{"put":{"tags":["voice"],"summary":"Exclude yourself from transcription, everywhere.","operationId":"opt_out","parameters":[{"name":"discord_user_id","in":"path","description":"Who is opting out","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"204":{"description":"Opted out"}}},"delete":{"tags":["voice"],"summary":"Allow transcription again.","operationId":"opt_in","parameters":[{"name":"discord_user_id","in":"path","description":"Who is opting back in","required":true,"schema":{"type":"string"}},{"name":"discord_server_id","in":"query","required":false,"schema":{"type":["string","null"]}},{"name":"actor_discord_id","in":"query","description":"Who is acting.\n\n**This is now authorization, not only attribution.** It used to be a claim the API recorded\nand never acted on; since ADR 0028 the rules in `crate::authz` are decided on it. That is\nsound because each caller *authenticates* the Discord identity before asserting it — the bot\nfrom Discord's own interaction payload, the website from an AuthX session established by\nDiscord login — and the service token is the boundary between them and us.\n\nStill recorded in the audit trail, and still only as trustworthy as the caller.","required":false,"schema":{"type":["string","null"]}},{"name":"actor_moderates","in":"query","description":"What the caller vouches for: Discord's `MANAGE_MESSAGES`, `MANAGE_GUILD` or `ADMINISTRATOR`\nfor this member, in this server.\n\nThe website sends neither this nor `actor_administers`: a session knows *who* somebody is,\nnot what a Discord server lets them do. Absent therefore means \"no elevated rights\", which\nis the safe reading.","required":false,"schema":{"type":["boolean","null"]}},{"name":"actor_administers","in":"query","description":"The stronger claim: `MANAGE_GUILD` or `ADMINISTRATOR`, for installation-wide changes.","required":false,"schema":{"type":["boolean","null"]}}],"responses":{"204":{"description":"Opted back in"}}}},"/v1/voice/profiles/{discord_user_id}":{"get":{"tags":["voice"],"summary":"Read what we have learned about one person.","description":"`404` when there is no profile — which is the normal state for anybody the analyser has not\nreached yet, and the permanent state for anybody who opted out.","operationId":"get_profile","parameters":[{"name":"discord_user_id","in":"path","description":"Whose profile","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Their profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoiceProfile"}}}},"404":{"description":"No profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}},"put":{"tags":["voice"],"summary":"Write or replace one person's profile.","description":"**Replace, not merge.** The analyser reads a window of transcripts and produces a whole view of\nsomebody; merging its output into a previous one would blend two summaries written from\ndifferent evidence into a third that neither model actually believes. Whatever wrote it last is\nwhat it says.\n\nRefused for a speaker who has opted out. That is the same rule the transcript ingest applies, and\nit is stated twice on purpose: an opt-out that only stops *some* of the paths into a person's\nrecord is not an opt-out.","operationId":"put_profile","parameters":[{"name":"discord_user_id","in":"path","description":"Whose profile","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpsertVoiceProfileRequest"}}},"required":true},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoiceProfile"}}}},"403":{"description":"That speaker opted out","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}},"422":{"description":"Not a usable profile","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}},"/v1/voice/transcripts":{"get":{"tags":["voice"],"summary":"Read stored utterances.","description":"# This is the surface ADR 0034 deliberately did not build, opened on purpose\n\nThat ADR left voice transcripts **write-only**: there was no way to read them back, and a\nrelease-gate test pinned the exact set of `/v1/voice/*` paths so that a read endpoint could not\narrive by extension. It has fired every time somebody added one, which is what it is for.\n\nIt is open now because the point of storing speech was always to learn from it (ADR 0037), and\nthe analyser that does the learning runs outside this service. See ADR 0039 for the decision and\nwhat it costs.\n\nWhat has **not** changed:\n\n* an opted-out speaker has no rows here at all — opting out deletes them — so this cannot return\n  anybody who asked to be left alone;\n* it is behind the service token like every other write, and there is no player-facing route to\n  it;\n* it is ordered and paginated by `spoken_at`, so a caller resumes rather than re-reading, and\n  cannot accidentally pull the whole corpus in one request.\n\nWhat **has** changed since 0039: `discord_server_id` is required. It was optional, and absent\nmeant every server — so the pagination above bounded one response, not the corpus, and a caller\ncould walk the lot. One server per request is the scope the analyser actually works in.","operationId":"list_transcripts","parameters":[{"name":"discord_server_id","in":"query","description":"**Which server. Required.**\n\nIt was optional, and a missing value meant *every server* — so one request with no filter\npaginated every conversation in every guild this bot has ever transcribed. That made a\nleaked service token a bulk export of people's speech rather than, as ADR 0039 assumed, a\nway to read the transcripts somebody already had reason to look at.\n\nAn analyser sweeping many servers asks per server, which it must do anyway to write a\nprofile back. Nothing legitimate needed the unscoped form.","required":true,"schema":{"type":"string"}},{"name":"speaker_discord_id","in":"query","description":"Narrow to one speaker — how a profile is rebuilt for one person.","required":false,"schema":{"type":["string","null"]}},{"name":"since","in":"query","description":"Only utterances at or after this instant, RFC 3339. The analyser's high-water mark.","required":false,"schema":{"type":["string","null"]}},{"name":"limit","in":"query","required":false,"schema":{"type":["integer","null"],"format":"int64"}},{"name":"cursor","in":"query","required":false,"schema":{"type":["string","null"]}}],"responses":{"200":{"description":"Stored utterances, oldest first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Page_VoiceTranscript"}}}}}},"post":{"tags":["voice"],"summary":"Store a batch of transcripts.","operationId":"record_transcripts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordTranscriptsRequest"}}},"required":true},"responses":{"202":{"description":"What was stored and what was refused","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TranscriptsAccepted"}}}},"422":{"description":"Malformed batch","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelopeSchema"}}}}}}}},"components":{"schemas":{"ActiveVoiceChannel":{"type":"object","description":"A voice channel the bot should be transcribing, as remembered across restarts.","required":["discord_server_id","discord_channel_id"],"properties":{"discord_channel_id":{"type":"string"},"discord_server_id":{"type":"string"}}},"BestiaryProgress":{"type":"object","description":"What completing a Bestiary entry costs and pays.","required":["kills_first","kills_second","kills_total","charm_points"],"properties":{"charm_points":{"type":"integer","format":"int32"},"kills_first":{"type":"integer","format":"int32"},"kills_second":{"type":"integer","format":"int32"},"kills_total":{"type":"integer","format":"int32"}}},"Boosted":{"type":"object","description":"What Tibia has boosted for a given day.\n\nBoth halves are optional and independently so: they come from two different endpoints, and one\nanswering while the other is down is an ordinary day rather than a failure.","required":["boosted_on"],"properties":{"boosted_on":{"type":"string","format":"date","description":"The UTC day this reading belongs to, as `YYYY-MM-DD`."},"boss":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BoostedEntry"}]},"creature":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BoostedEntry"}]}}},"BoostedAnnouncement":{"type":"object","description":"One server that is due today's boosted announcement, with the payload to post.\n\nReturned by the claim endpoint, which marks each server as announced in the same statement that\nselects it — so the bot receives a server here at most once a day no matter how often it polls.","required":["discord_server_id","discord_channel_id","boosted"],"properties":{"boosted":{"$ref":"#/components/schemas/Boosted"},"discord_channel_id":{"type":"string"},"discord_server_id":{"type":"string"}}},"BoostedEntry":{"type":"object","description":"One thing Tibia has boosted today.","required":["name"],"properties":{"image_url":{"type":["string","null"]},"name":{"type":"string"},"slug":{"type":["string","null"],"description":"The catalog slug, when the name resolves to a boss we hold — so the announcement can point\nat what `/boss` already knows. `null` is normal: Tibia boosts creatures that are not bosses."}}},"Boss":{"type":"object","description":"A boss from the catalog.","required":["id","slug","name","boss_type","boostable","composition","max_players_source","composition_source","updated_at"],"properties":{"boostable":{"type":"boolean","description":"Whether the boss can be the daily boosted boss."},"boss_type":{"$ref":"#/components/schemas/BossType"},"composition":{"type":"array","items":{"$ref":"#/components/schemas/CompositionSlot"},"description":"The party shape this boss wants. Empty when nobody has curated one."},"composition_source":{"type":"string"},"cooldown_hours":{"type":["integer","null"],"format":"int32","description":"Hours before the boss can be fought again by the same participants."},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"id":{"type":"string","format":"uuid"},"image_url":{"type":["string","null"]},"location_details":{"type":["string","null"],"description":"Free prose from TibiaWiki: where inside the place, or how to reach it. **Not a place\nname** — most of these are directions (\"two floors below Zalamon\"), which is why they are\nnot `locations` rows."},"location_name":{"type":["string","null"],"description":"That place's display name, joined so a caller rendering an embed does not have to fetch the\nlocation just to avoid showing a player the string `falcon-bastion`."},"location_slug":{"type":["string","null"],"description":"Which curated place this boss is in. `None` until the curator has linked it. This is the\nkey a writer sends; `location_name` is the one a player reads."},"max_players":{"type":["integer","null"],"format":"int32","description":"How many players the party should have. A hard cap for lever bosses, a recommendation\notherwise — see [`BossType::enforces_player_cap`]."},"max_players_source":{"type":"string","description":"**Where the curated numbers came from**, so a value can be checked rather than argued\nabout. `wiki` when the scraper read it, `override` when a human decided it, `none` when\nnobody has. An agent auditing the catalog needs the origin as much as the value: a field\nwith no stated source is unfalsifiable."},"name":{"type":"string"},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"slug":{"type":"string","description":"URL-safe stable identifier, e.g. `world-devourer`."},"updated_at":{"type":"string","format":"date-time","description":"When this row was last written by the scraper or an override.\n\nRFC 3339, explicitly. `time` serialises an `OffsetDateTime` as an **ordinal array** by\ndefault — `[2026, 228, 2, 48, 39, …]` — which is unreadable to every client and is the trap\nAGENTS.md records. An agent auditing the catalog needs a timestamp it can compare."},"wiki_url":{"type":["string","null"]}}},"BossSummary":{"type":"object","description":"The compact boss reference embedded in a hunt.","required":["id","slug","name","boss_type"],"properties":{"boss_type":{"$ref":"#/components/schemas/BossType"},"id":{"type":"string","format":"uuid"},"image_url":{"type":["string","null"]},"location_details":{"type":["string","null"],"description":"Free prose from TibiaWiki: where inside the place, or how to reach it. **Not a place\nname** — most of these are directions (\"two floors below Zalamon\"), which is why they are\nnot `locations` rows."},"location_name":{"type":["string","null"],"description":"That place's display name, joined so a caller rendering an embed does not have to fetch the\nlocation just to avoid showing a player the string `falcon-bastion`."},"location_slug":{"type":["string","null"],"description":"Which curated place this boss is in. `None` until the curator has linked it. This is the\nkey a writer sends; `location_name` is the one a player reads."},"max_players":{"type":["integer","null"],"format":"int32"},"name":{"type":"string"},"slug":{"type":"string"}}},"BossType":{"type":"string","description":"How a boss is engaged, which is what determines whether a party size is a hard cap or a target.","enum":["lever","raid","quest","field","unknown"]},"Character":{"type":"object","description":"A Tibia character as we last saw it.","required":["name","world","vocation","level","achievement_points","tracked","last_synced_at"],"properties":{"account_status":{"type":["string","null"]},"achievement_points":{"type":"integer","format":"int32"},"guild_name":{"type":["string","null"]},"guild_rank":{"type":["string","null"]},"last_login":{"type":["string","null"],"format":"date-time"},"last_synced_at":{"type":"string","format":"date-time","description":"When the profile was last pulled from Tibia."},"level":{"type":"integer","format":"int32"},"name":{"type":"string"},"residence":{"type":["string","null"]},"sex":{"type":["string","null"]},"title":{"type":["string","null"]},"tracked":{"type":"boolean","description":"Whether the daily snapshot worker refreshes this character."},"vocation":{"$ref":"#/components/schemas/Vocation"},"world":{"type":"string"}}},"CharacterDeath":{"type":"object","description":"A death, as Tibia reports it.","required":["died_at","level","reason","killers"],"properties":{"died_at":{"type":"string","format":"date-time"},"killers":{"type":"array","items":{"type":"string"}},"level":{"type":"integer","format":"int32"},"reason":{"type":"string"}}},"CharacterSnapshot":{"type":"object","description":"One day's reading for a character. The point of the whole ingest pipeline: the deltas between\nconsecutive snapshots are what tell you who is actually playing.","required":["snapshot_date","level","achievement_points","vocation","world","deaths_total"],"properties":{"account_status":{"type":["string","null"]},"achievement_points":{"type":"integer","format":"int32"},"deaths_total":{"type":"integer","format":"int32","description":"Total deaths recorded for the character at snapshot time."},"experience":{"type":["integer","null"],"format":"int64","description":"Exact experience, when the character appeared in its world's experience highscores.\n`null` for everyone outside the top of their world — Tibia does not publish it."},"guild_name":{"type":["string","null"]},"last_login":{"type":["string","null"],"format":"date-time"},"level":{"type":"integer","format":"int32"},"levels_gained":{"type":["integer","null"],"format":"int32","description":"Levels gained since the previous snapshot, or `null` when this is the first one."},"snapshot_date":{"type":"string","format":"date","description":"The UTC day this reading belongs to, as `YYYY-MM-DD`. At most one snapshot per character\nper day."},"vocation":{"$ref":"#/components/schemas/Vocation"},"world":{"type":"string"}}},"ClaimEvent":{"type":"object","description":"Something that happened to a claim, for the bot to announce.\n\nReturned by the sweep, which marks each event in the same statement that selects it — so a\nhandover is announced exactly once however often the bot polls.","required":["kind","discord_server_id","discord_channel_id","respawn_id","respawn_name","queue_depth"],"properties":{"audience_discord_id":{"type":["string","null"],"description":"Who this is *for*, when it is addressed to one person rather than announced to a channel.\n\n`FreesSoon`, `Offered` and `OfferLapsed` all concern somebody in the queue, not the holder,\nand reusing `new_holder_discord_id` for that would be a lie in the one case that matters:\nan offer has no holder at all."},"discord_channel_id":{"type":"string","description":"Where to say it. The server's announce channel when it has one, otherwise the channel the\nclaim was made in — never a DM, which is the failure mode competitors are complained about."},"discord_server_id":{"type":"string"},"ends_at":{"type":["string","null"],"format":"date-time"},"kind":{"$ref":"#/components/schemas/ClaimEventKind"},"new_holder_discord_id":{"type":["string","null"],"description":"Who has it now, on a handover."},"offer_expires_at":{"type":["string","null"],"format":"date-time","description":"When an offer lapses, so the message can say how long they have."},"previous_holder_discord_id":{"type":["string","null"]},"queue_depth":{"type":"integer","format":"int32"},"respawn_id":{"type":"string","format":"uuid"},"respawn_name":{"type":"string"}}},"ClaimEventKind":{"type":"string","description":"What happened on a respawn's sweep.","enum":["handover","expired","ending_soon","frees_soon","offered","offer_lapsed"]},"ClaimStatus":{"type":"string","description":"Where one player stands on one respawn.","enum":["active","queued","released","expired","cancelled"]},"CompositionSlot":{"type":"object","description":"One line of a recommended party: \"4 knights, tanking\".","required":["vocation","count","role"],"properties":{"count":{"type":"integer","format":"int32","description":"How many of this vocation the party wants."},"role":{"$ref":"#/components/schemas/RoleKind"},"vocation":{"$ref":"#/components/schemas/Vocation"}}},"CreateClaimRequest":{"type":"object","required":["discord_user_id","discord_channel_id"],"properties":{"discord_channel_id":{"type":"string","description":"Where this was claimed, so a handover can be announced in the right conversation when the\nserver has nominated no announce channel."},"discord_user_id":{"type":"string"},"discord_username":{"type":["string","null"]},"minutes":{"type":["integer","null"],"format":"int32","description":"How long to hold it. Defaults to two hours; must be 1–240 minutes."},"replace_existing":{"type":"boolean","description":"Give up the respawn this player already holds, and take this one instead.\n\n**Defaults to false, so the swap is always a decision.** Without it the API refuses with\n`already_holding` and names what they hold, and the caller asks. Silently releasing a spawn\nsomebody is mid-hunt on — because they typed the wrong name — is not a thing to do on their\nbehalf. The release and the new claim happen in one transaction: releasing first as a\nseparate call would let somebody else take the new spawn in the gap."}}},"CreateHuntRequest":{"type":"object","description":"Schedule a hunt.","required":["discord_server_id","discord_channel_id","organizer_discord_id","starts_at"],"properties":{"boss_slug":{"type":["string","null"],"description":"Boss slug from the catalog. Omit for a hunt that is not about a specific boss."},"composition":{"type":["array","null"],"items":{"$ref":"#/components/schemas/CompositionSlot"},"description":"Desired party shape. Defaults to the boss's curated composition when omitted."},"description":{"type":["string","null"]},"discord_channel_id":{"type":"string"},"discord_server_id":{"type":"string","description":"Discord server (server) the hunt belongs to. Hunts are scoped to a server so two communities\nsharing the bot never see each other's rosters."},"max_players":{"type":["integer","null"],"format":"int32","description":"Overrides the boss's cap. Required for a hunt with no boss."},"meeting_point":{"type":["string","null"]},"min_level":{"type":["integer","null"],"format":"int32"},"organizer_discord_id":{"type":"string"},"organizer_username":{"type":["string","null"]},"respawn_slug":{"type":["string","null"],"description":"The hunting ground, when this is a session at a respawn rather than a boss fight.\n\n**Mutually exclusive with `boss_slug`** — a hunt has one target or none. Sending both is a\n`422` rather than a silent preference, because whichever the code read first would become\nthe answer and neither choice is defensible."},"starts_at":{"type":"string","format":"date-time","description":"When the hunt starts, as an absolute instant. The bot resolves the player's \"next Tuesday\n3PM PST\" into this before calling, so the server never has to guess a timezone."},"timezone_label":{"type":["string","null"],"description":"IANA-ish label the organizer typed, kept only so the bot can echo it back (\"3:00 PM PST\")."},"title":{"type":["string","null"],"description":"Defaults to the boss name when a boss is given."}}},"CreateHuntSessionRequest":{"type":"object","description":"Paste a hunt analysis.","required":["discord_user_id","raw"],"properties":{"character":{"type":["string","null"],"description":"Which of their verified characters the session belongs to. The paste itself never says.\n\n**Optional since ADR 0033.** Absent — or naming a character they have not verified — stores\nthe session against the Discord user alone. Refusing the paste would turn away somebody at\nthe exact moment they are most willing to try the feature, over a link they can add later."},"discord_user_id":{"type":"string","description":"Who is pasting. Must own `character` through a completed verification."},"raw":{"type":"string","description":"The clipboard text, verbatim."},"source":{"type":["string","null"],"description":"`discord` or `web`."}}},"CreatePartySessionRequest":{"type":"object","description":"Paste a **party** hunt analysis, to be split.\n\nUnlike [`CreateHuntSessionRequest`] this names no character: a party paste already names\neveryone in it, and the person pasting need not be one of them — somebody settling up on behalf\nof a party is an ordinary case and demanding a verified character would refuse it.","required":["discord_user_id","raw"],"properties":{"discord_server_id":{"type":["string","null"],"description":"Which Discord server it was run in, when it was run in one."},"discord_user_id":{"type":"string","description":"Who is pasting. The owner of the stored session and the only one who may read it back."},"hunt_session_id":{"type":["string","null"],"format":"uuid","description":"The pasting player's **own** session for the same hunt, when they have one.\n\nThe two analyser windows answer different questions — the party's split, and your personal\nexperience and damage — and neither contains the other. Naming the solo session here is what\nmakes them one hunt rather than two unrelated rows. Must belong to the same person, or the\nwrite is refused."},"raw":{"type":"string","description":"The clipboard text, verbatim."}}},"CreateVerificationRequest":{"type":"object","description":"Ask for a token proving a Discord user owns a character.","required":["discord_user_id","character_name"],"properties":{"character_name":{"type":"string"},"discord_user_id":{"type":"string","description":"The Discord user's snowflake, as a string — snowflakes exceed `i32` and JSON numbers lose\nprecision past 2^53, so they travel as strings everywhere."},"discord_username":{"type":["string","null"]}}},"Creature":{"type":"object","description":"A creature, with everything derived from what is stored.","required":["slug","name","is_boss","boostable","updated_at"],"properties":{"armor":{"type":["integer","null"],"format":"int32"},"bestiary":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BestiaryProgress","description":"**Derived from the difficulty, never stored.** A creature cannot claim 1,000 kills for 50\ncharm points, because neither number is in its row to be wrong."}]},"bestiary_class":{"type":["string","null"]},"bestiary_difficulty":{"type":["string","null"]},"bestiary_occurrence":{"type":["string","null"]},"boostable":{"type":"boolean"},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"image_url":{"type":["string","null"]},"is_boss":{"type":"boolean"},"name":{"type":"string"},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"slug":{"type":"string"},"speed":{"type":["integer","null"],"format":"int32"},"updated_at":{"type":"string","format":"date-time"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"CreaturePage":{"type":"object","required":["data"],"properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Creature"}},"next_cursor":{"type":["string","null"]}}},"DifficultyEntry":{"type":"object","required":["difficulty","display_name","kills_first","kills_second","kills_total","charm_points"],"properties":{"charm_points":{"type":"integer","format":"int32"},"difficulty":{"type":"string"},"display_name":{"type":"string"},"kills_first":{"type":"integer","format":"int32"},"kills_second":{"type":"integer","format":"int32"},"kills_total":{"type":"integer","format":"int32"}}},"DwellerEntry":{"type":"object","required":["creature_slug","creature_name"],"properties":{"creature_name":{"type":"string"},"creature_slug":{"type":"string"},"experience":{"type":["integer","null"],"format":"int64"},"spawn_count":{"type":["integer","null"],"format":"int32"}}},"ErrorBodySchema":{"type":"object","required":["code","message"],"properties":{"code":{"$ref":"#/components/schemas/ErrorCode"},"details":{"type":"array","items":{"$ref":"#/components/schemas/FieldError"},"description":"Field-level problems, present on a `422`. Omitted when empty, as the real envelope omits it."},"message":{"type":"string","description":"Human-readable and free to change. Never parse it."}}},"ErrorCode":{"type":"string","description":"Stable, machine-readable error codes. Callers branch on these, never on the message.","enum":["validation","unauthorized","forbidden","not_found","conflict","setup_required","already_holding","rate_limited","upstream_unavailable","internal"]},"ErrorEnvelopeSchema":{"type":"object","description":"The envelope as the OpenAPI document describes it.\n\nA separate owned type because the one actually serialised borrows, and a borrowed type cannot\nderive `ToSchema`. `the_documented_envelope_matches_what_is_rendered` keeps the two in step — a\ncontract describing a shape the service does not emit is worse than no contract, because a\nclient generator will believe it.","required":["error"],"properties":{"error":{"$ref":"#/components/schemas/ErrorBodySchema"}}},"ExtendClaimRequest":{"type":"object","required":["minutes"],"properties":{"minutes":{"type":"integer","format":"int32"}}},"FieldError":{"type":"object","description":"A field-level validation problem, carried in `details` on a 422.","required":["field","message"],"properties":{"field":{"type":"string"},"message":{"type":"string"}}},"Health":{"type":"object","required":["status","version","environment","bosses"],"properties":{"bosses":{"type":"integer","format":"int64","description":"Bosses currently in the catalog. Zero means seeding has not run — the service is up but\ncannot schedule a boss hunt, which a bare `\"ok\"` would hide."},"environment":{"type":"string"},"status":{"type":"string","description":"`ok` when the database answered, `degraded` when it did not."},"version":{"type":"string"}}},"Hold":{"type":"object","description":"Who is on a respawn, and until when.","required":["discord_user_id","claimed_at","ends_at"],"properties":{"claimed_at":{"type":"string","format":"date-time"},"discord_user_id":{"type":"string"},"discord_username":{"type":["string","null"]},"ends_at":{"type":"string","format":"date-time"}}},"Hunt":{"type":"object","description":"A hunt with its full roster.","required":["id","discord_server_id","discord_channel_id","title","status","starts_at","world","organizer_discord_id","created_at","signups","roster"],"properties":{"boss":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BossSummary"}]},"created_at":{"type":"string","format":"date-time"},"description":{"type":["string","null"]},"discord_channel_id":{"type":"string"},"discord_message_id":{"type":["string","null"]},"discord_server_id":{"type":"string"},"id":{"type":"string","format":"uuid"},"max_players":{"type":["integer","null"],"format":"int32"},"meeting_point":{"type":["string","null"]},"min_level":{"type":["integer","null"],"format":"int32"},"organizer_discord_id":{"type":"string"},"organizer_username":{"type":["string","null"],"description":"The organizer's Discord display name, when we have seen it."},"respawn":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/HuntRespawn","description":"The hunting ground, when this is a session at a respawn rather than a boss fight.\n\nNever set alongside `boss`: a hunt has one target or none, enforced by a CHECK constraint\nrather than by whichever renderer happens to look first."}]},"roster":{"$ref":"#/components/schemas/Roster"},"signups":{"type":"array","items":{"$ref":"#/components/schemas/Signup"}},"starts_at":{"type":"string","format":"date-time"},"status":{"$ref":"#/components/schemas/HuntStatus"},"timezone_label":{"type":["string","null"]},"title":{"type":"string"},"world":{"type":"string","description":"The Tibia world, inherited from the Discord server that scheduled it (ADR 0020). Always\npresent: a hunt cannot exist in a server that has not been set up."}}},"HuntAuditEntry":{"type":"object","description":"One entry in a hunt's audit trail.\n\n`actor_discord_id` is **attribution, not authentication** — the API authenticates the bot, not\nthe player, so this records who the bot said acted.","required":["action","detail","created_at"],"properties":{"action":{"type":"string"},"actor_discord_id":{"type":["string","null"]},"created_at":{"type":"string","format":"date-time"},"detail":{}}},"HuntRespawn":{"type":"object","description":"The hunting ground a hunt is at, as much of it as a roster needs.\n\n**Not a [`Respawn`]** — that carries claims, a queue and a hold, none of which a roster has any\nbusiness showing. A hunt at a respawn is not a claim on it: two things can be true at once, and\nconflating them would let scheduling a session quietly take the spawn.","required":["id","name"],"properties":{"id":{"type":"string","format":"uuid"},"max_level":{"type":["integer","null"],"format":"int32"},"min_level":{"type":["integer","null"],"format":"int32"},"name":{"type":"string"},"slug":{"type":["string","null"]}}},"HuntSession":{"type":"object","description":"A hunt session a player pasted from their own client.\n\n**Private.** Unlike everything else in this contract, this describes data Tibia does not\npublish — what somebody earned, what they spent, and where they hunt. It is returned only to\nthe Discord user who owns the character, and there is deliberately no endpoint that lists\nsessions across players.","required":["id","started_at","ended_at","duration_minutes","xp_gain","xp_per_hour","loot","supplies","balance","damage","damage_per_hour","healing","healing_per_hour","killed_monsters","looted_items","self_consistent","source","created_at"],"properties":{"balance":{"type":"integer","format":"int64","description":"Signed: a session that spent more than it looted is the one worth advice."},"character_name":{"type":["string","null"],"description":"Absent for a session stored before its owner verified a character (ADR 0033). The session is\nstill theirs — it just does not yet say which character it was."},"created_at":{"type":"string"},"damage":{"type":"integer","format":"int64"},"damage_per_hour":{"type":"integer","format":"int64"},"duration_minutes":{"type":"integer","format":"int32"},"ended_at":{"type":"string"},"healing":{"type":"integer","format":"int64"},"healing_per_hour":{"type":"integer","format":"int64"},"id":{"type":"string"},"killed_monsters":{"type":"object","additionalProperties":{"type":"integer","format":"int64"},"propertyNames":{"type":"string"}},"loot":{"type":"integer","format":"int64"},"looted_items":{"type":"object","additionalProperties":{"type":"integer","format":"int64"},"propertyNames":{"type":"string"}},"note":{"type":["string","null"],"description":"What the person pasting it wanted to remember about this hunt. Never parsed and never\naggregated — the analyser says what happened, and this says why anybody cares."},"raw_xp_gain":{"type":["integer","null"],"format":"int64","description":"Only newer clients report the pre-bonus figures."},"raw_xp_per_hour":{"type":["integer","null"],"format":"int64"},"self_consistent":{"type":"boolean","description":"Whether the paste agreed with itself. A session that does not is kept but never pooled\ninto an aggregate."},"source":{"type":"string"},"started_at":{"type":"string","description":"The client's own wall clock, with **no timezone** — the paste does not say which zone it\nwas, so this is naive by necessity. Compare sessions to each other, never to \"now\"."},"supplies":{"type":"integer","format":"int64"},"xp_gain":{"type":"integer","format":"int64"},"xp_per_hour":{"type":"integer","format":"int64"}}},"HuntStatus":{"type":"string","description":"Statuses a scheduled hunt can be in.","enum":["scheduled","cancelled","completed"]},"HuntSummary":{"type":"object","description":"A hunt in a list.","required":["id","title","status","starts_at","world","organizer_discord_id","confirmed_count","tentative_count","waitlisted_count"],"properties":{"boss":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BossSummary"}]},"confirmed_count":{"type":"integer","format":"int32"},"id":{"type":"string","format":"uuid"},"max_players":{"type":["integer","null"],"format":"int32"},"organizer_discord_id":{"type":"string"},"organizer_username":{"type":["string","null"],"description":"The organizer's Discord display name, when we have seen it. A client should still prefer\nrendering a mention from the id — that stays current when they rename themselves."},"respawn":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/HuntRespawn","description":"The hunting ground, when this is a session at a respawn rather than a boss fight.\n\nNever set alongside `boss`: a hunt has one target or none, enforced by a CHECK constraint\nrather than by whichever renderer happens to look first."}]},"starts_at":{"type":"string","format":"date-time"},"status":{"$ref":"#/components/schemas/HuntStatus"},"tentative_count":{"type":"integer","format":"int32"},"timezone_label":{"type":["string","null"]},"title":{"type":"string"},"waitlisted_count":{"type":"integer","format":"int32"},"world":{"type":"string","description":"The Tibia world, inherited from the Discord server that scheduled it (ADR 0020). Always\npresent: a hunt cannot exist in a server that has not been set up."}}},"Item":{"type":"object","description":"One item, as the catalog holds it.","required":["id","slug","name","stackable","vocations","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"image_url":{"type":["string","null"]},"item_type":{"type":["string","null"],"description":"'weapon', 'armor', 'rune', 'creature-product', … The curator's own vocabulary: this is a\nlabel rather than a closed set, because Tibia adds categories faster than a CHECK does."},"market_value":{"type":["integer","null"],"format":"int64","description":"The observed player-market price, when the curator has one."},"name":{"type":"string"},"notes":{"type":["string","null"]},"npc_buy_price":{"type":["integer","null"],"format":"int64","description":"What an NPC charges you."},"npc_sell_price":{"type":["integer","null"],"format":"int64","description":"What an NPC pays you."},"required_level":{"type":["integer","null"],"format":"int32"},"slug":{"type":"string"},"stackable":{"type":"boolean"},"updated_at":{"type":"string","format":"date-time"},"vocations":{"type":"array","items":{"type":"string"},"description":"Which vocations can use it. Empty means anyone."},"weight_grams":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"],"description":"Which curator version wrote this row."}}},"KnowledgeCategory":{"type":"string","description":"The six kinds of thing this knowledge base holds facts about.","enum":["quest","creature","hunting_area","item","npc","spell"]},"KnowledgeDocument":{"type":"object","required":["slug","entity_type","entity_name","title","summary","body_md","revision","updated_at"],"properties":{"body_md":{"type":"string","description":"The content, as Markdown."},"entity_name":{"type":"string"},"entity_type":{"type":"string"},"revision":{"type":"integer","format":"int32"},"slug":{"type":"string"},"source_url":{"type":["string","null"],"description":"Where the facts came from. A document with none should not be quoted as authority."},"summary":{"type":"string","description":"One or two sentences, so a retrieving agent can judge relevance before spending context on\nthe body."},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"written_by":{"type":["string","null"],"description":"What produced it — a model name, an agent name, a person."}}},"KnowledgeHit":{"allOf":[{"$ref":"#/components/schemas/KnowledgeDocument"},{"type":"object","required":["rank"],"properties":{"rank":{"type":"number","format":"float","description":"Postgres' ranking. Exposed so a retrieving agent can decide \"nothing here is relevant\nenough\" rather than dutifully quoting the least-bad match."}}}],"description":"What a search hit adds to the document: why it matched."},"KnowledgeRevision":{"type":"object","required":["revision","title","summary","body_md","created_at"],"properties":{"body_md":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"revision":{"type":"integer","format":"int32"},"source_url":{"type":["string","null"]},"summary":{"type":"string"},"title":{"type":"string"},"written_by":{"type":["string","null"]}}},"LinkedCharacter":{"type":"object","description":"A character a Discord user has proven they own.","required":["character_name","world","vocation","level","is_main","verified_at"],"properties":{"character_name":{"type":"string"},"is_main":{"type":"boolean","description":"The character the user hunts on by default — the one pre-selected when they sign up."},"level":{"type":"integer","format":"int32"},"verified_at":{"type":"string","format":"date-time"},"vocation":{"$ref":"#/components/schemas/Vocation"},"world":{"type":"string"}}},"Locale":{"type":"string","description":"A language the product speaks.","enum":["en","es","pt"]},"Location":{"type":"object","required":["id","slug","name","kind","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"kind":{"$ref":"#/components/schemas/LocationKind","description":"Which kind of place. Stored as text and validated against the CHECK in migration 0014;\ndeclared here as the enum so a generated client gets the vocabulary."},"name":{"type":"string"},"notes":{"type":["string","null"]},"parent_location_id":{"type":["string","null"],"format":"uuid","description":"The place containing this one, when there is one."},"slug":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"LocationKind":{"type":"string","description":"The same closed set, as a type the contract can publish.\n\n`kind` is stored and validated as text — the CHECK constraint in migration 0014 is the real\nauthority, and `check_kind` gives a `422` naming the alternatives. But a schema of\n`{\"type\": \"string\"}` tells a client generator nothing, so the field declares this as its\n`value_type` and the document carries a real `enum`. A curator reading the spec should not have\nto discover the vocabulary by being rejected.\n\n[`the_published_enum_matches_the_validated_set`] fails if the two ever disagree.","enum":["city","town","dungeon","region","quest-area"]},"LootEntry":{"type":"object","required":["item_slug","item_name"],"properties":{"item_name":{"type":"string"},"item_slug":{"type":"string"},"max_count":{"type":["integer","null"],"format":"int32"},"npc_sell_price":{"type":["integer","null"],"format":"int64","description":"What an NPC pays, so \"is this worth carrying back\" is one request rather than two."},"rarity":{"type":["string","null"]}}},"MaintenanceReport":{"type":"object","description":"Keep the presence tables alive: partitions ahead, retention behind, profiles rolled up.\n\n**These are database operations, and this service is the only thing with a database.** The\nsampler that used to run them left for its own repository and reaches us only over HTTP, so it\ncannot create a partition or rebuild a profile — but it should not have to. This is the endpoint\nits schedule calls instead.","required":["profiles"],"properties":{"profiles":{"type":"integer","format":"int64","description":"Match profiles rebuilt. `/find` and `/manada` answer from these.","minimum":0}}},"ManadaEmptyReason":{"type":"string","description":"Why a search came back empty. A stable enum, because each case needs different words.","enum":["not_observed","not_enough_history","no_candidates_with_history","no_overlap"]},"ManadaMatch":{"type":"object","description":"One suggested hunting partner.","required":["name","vocation","level","reachable","shared_hours_per_week","overlap_score","complement","score"],"properties":{"complement":{"type":"number","format":"float","description":"`0..=1`, how much the two vocations want each other in a party."},"exp_per_day":{"type":["integer","null"],"format":"int64","description":"The least experience a day this player can have been gaining lately, or `null` when we have\nno level span for them. **A floor, from Tibia's own level curve** — the mirror never sees\nanybody's experience, only which level they were on."},"level":{"type":"integer","format":"int32"},"name":{"type":"string"},"overlap_score":{"type":"number","format":"float","description":"`0..=1`, shared hours over the smaller of the two schedules."},"reachable":{"type":"boolean","description":"Whether the searcher can reach them through Manada."},"score":{"type":"number","format":"float","description":"`0..=1`, the weighted total the ranking used."},"shared_hours_per_week":{"type":"number","format":"float","description":"Hours a week both are online. **Inferred**, and the number a human actually wants."},"vocation":{"$ref":"#/components/schemas/Vocation"}}},"ManadaResult":{"type":"object","description":"The answer to \"who should I hunt with?\".","required":["searcher","world_days_observed"],"properties":{"empty_reason":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/ManadaEmptyReason","description":"Present only when `matches` is empty, saying which kind of empty it is."}]},"matches":{"type":"array","items":{"$ref":"#/components/schemas/ManadaMatch"}},"searcher":{"$ref":"#/components/schemas/ObservedPlayer","description":"Who was searched for, as we have observed them."},"world_days_observed":{"type":"integer","format":"int32","description":"How many days of history the mirror has for this world. Lets a caller word \"come back\nsoon\" with a real number instead of a shrug."}}},"ObservationAccepted":{"type":"object","required":["world","slot","players_recorded"],"properties":{"players_recorded":{"type":"integer","minimum":0},"slot":{"type":"integer","format":"int32","description":"Which of the day's 96 slots this landed in.","minimum":0},"world":{"type":"string"}}},"ObservationRequest":{"type":"object","description":"One world's online list, as the mirror saw it.","required":["world","observed_at","players"],"properties":{"observed_at":{"type":"string","format":"date-time","description":"**The start of the pass**, not the moment this world was fetched. RFC 3339."},"players":{"type":"array","items":{"$ref":"#/components/schemas/PresenceObservedPlayer"}},"world":{"type":"string","description":"The world's name as Tibia spells it."}}},"ObservedPlayer":{"type":"object","description":"What we have observed about one player, and how sure we are of it.\n\nEvery field here is something a public online list published, except `hours_per_week`, which is\nan estimate from samples. The distinction is carried into the wire type rather than left to the\nrenderer, because a renderer is what the next person edits.","required":["name","world","vocation","level","days_seen","hours_per_week","reachable","first_seen_on","last_seen_on"],"properties":{"days_seen":{"type":"integer","format":"int32","description":"Distinct days seen in the rollup window. **Observed.**"},"first_seen_on":{"type":"string","format":"date"},"hours_per_week":{"type":"number","format":"float","description":"Roughly how many hours a week they are online. **Inferred** from 15-minute samples."},"last_seen_on":{"type":"string","format":"date"},"level":{"type":"integer","format":"int32"},"name":{"type":"string"},"reachable":{"type":"boolean","description":"Whether they have proven a character on Discord, so they can actually be reached."},"vocation":{"$ref":"#/components/schemas/Vocation"},"world":{"type":"string"}}},"Page_Boss":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"A boss from the catalog.","required":["id","slug","name","boss_type","boostable","composition","max_players_source","composition_source","updated_at"],"properties":{"boostable":{"type":"boolean","description":"Whether the boss can be the daily boosted boss."},"boss_type":{"$ref":"#/components/schemas/BossType"},"composition":{"type":"array","items":{"$ref":"#/components/schemas/CompositionSlot"},"description":"The party shape this boss wants. Empty when nobody has curated one."},"composition_source":{"type":"string"},"cooldown_hours":{"type":["integer","null"],"format":"int32","description":"Hours before the boss can be fought again by the same participants."},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"id":{"type":"string","format":"uuid"},"image_url":{"type":["string","null"]},"location_details":{"type":["string","null"],"description":"Free prose from TibiaWiki: where inside the place, or how to reach it. **Not a place\nname** — most of these are directions (\"two floors below Zalamon\"), which is why they are\nnot `locations` rows."},"location_name":{"type":["string","null"],"description":"That place's display name, joined so a caller rendering an embed does not have to fetch the\nlocation just to avoid showing a player the string `falcon-bastion`."},"location_slug":{"type":["string","null"],"description":"Which curated place this boss is in. `None` until the curator has linked it. This is the\nkey a writer sends; `location_name` is the one a player reads."},"max_players":{"type":["integer","null"],"format":"int32","description":"How many players the party should have. A hard cap for lever bosses, a recommendation\notherwise — see [`BossType::enforces_player_cap`]."},"max_players_source":{"type":"string","description":"**Where the curated numbers came from**, so a value can be checked rather than argued\nabout. `wiki` when the scraper read it, `override` when a human decided it, `none` when\nnobody has. An agent auditing the catalog needs the origin as much as the value: a field\nwith no stated source is unfalsifiable."},"name":{"type":"string"},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"slug":{"type":"string","description":"URL-safe stable identifier, e.g. `world-devourer`."},"updated_at":{"type":"string","format":"date-time","description":"When this row was last written by the scraper or an override.\n\nRFC 3339, explicitly. `time` serialises an `OffsetDateTime` as an **ordinal array** by\ndefault — `[2026, 228, 2, 48, 39, …]` — which is unreadable to every client and is the trap\nAGENTS.md records. An agent auditing the catalog needs a timestamp it can compare."},"wiki_url":{"type":["string","null"]}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_CharacterDeath":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"A death, as Tibia reports it.","required":["died_at","level","reason","killers"],"properties":{"died_at":{"type":"string","format":"date-time"},"killers":{"type":"array","items":{"type":"string"}},"level":{"type":"integer","format":"int32"},"reason":{"type":"string"}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_CharacterSnapshot":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"One day's reading for a character. The point of the whole ingest pipeline: the deltas between\nconsecutive snapshots are what tell you who is actually playing.","required":["snapshot_date","level","achievement_points","vocation","world","deaths_total"],"properties":{"account_status":{"type":["string","null"]},"achievement_points":{"type":"integer","format":"int32"},"deaths_total":{"type":"integer","format":"int32","description":"Total deaths recorded for the character at snapshot time."},"experience":{"type":["integer","null"],"format":"int64","description":"Exact experience, when the character appeared in its world's experience highscores.\n`null` for everyone outside the top of their world — Tibia does not publish it."},"guild_name":{"type":["string","null"]},"last_login":{"type":["string","null"],"format":"date-time"},"level":{"type":"integer","format":"int32"},"levels_gained":{"type":["integer","null"],"format":"int32","description":"Levels gained since the previous snapshot, or `null` when this is the first one."},"snapshot_date":{"type":"string","format":"date","description":"The UTC day this reading belongs to, as `YYYY-MM-DD`. At most one snapshot per character\nper day."},"vocation":{"$ref":"#/components/schemas/Vocation"},"world":{"type":"string"}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_HuntSummary":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"A hunt in a list.","required":["id","title","status","starts_at","world","organizer_discord_id","confirmed_count","tentative_count","waitlisted_count"],"properties":{"boss":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/BossSummary"}]},"confirmed_count":{"type":"integer","format":"int32"},"id":{"type":"string","format":"uuid"},"max_players":{"type":["integer","null"],"format":"int32"},"organizer_discord_id":{"type":"string"},"organizer_username":{"type":["string","null"],"description":"The organizer's Discord display name, when we have seen it. A client should still prefer\nrendering a mention from the id — that stays current when they rename themselves."},"respawn":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/HuntRespawn","description":"The hunting ground, when this is a session at a respawn rather than a boss fight.\n\nNever set alongside `boss`: a hunt has one target or none, enforced by a CHECK constraint\nrather than by whichever renderer happens to look first."}]},"starts_at":{"type":"string","format":"date-time"},"status":{"$ref":"#/components/schemas/HuntStatus"},"tentative_count":{"type":"integer","format":"int32"},"timezone_label":{"type":["string","null"]},"title":{"type":"string"},"waitlisted_count":{"type":"integer","format":"int32"},"world":{"type":"string","description":"The Tibia world, inherited from the Discord server that scheduled it (ADR 0020). Always\npresent: a hunt cannot exist in a server that has not been set up."}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_Item":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"One item, as the catalog holds it.","required":["id","slug","name","stackable","vocations","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"image_url":{"type":["string","null"]},"item_type":{"type":["string","null"],"description":"'weapon', 'armor', 'rune', 'creature-product', … The curator's own vocabulary: this is a\nlabel rather than a closed set, because Tibia adds categories faster than a CHECK does."},"market_value":{"type":["integer","null"],"format":"int64","description":"The observed player-market price, when the curator has one."},"name":{"type":"string"},"notes":{"type":["string","null"]},"npc_buy_price":{"type":["integer","null"],"format":"int64","description":"What an NPC charges you."},"npc_sell_price":{"type":["integer","null"],"format":"int64","description":"What an NPC pays you."},"required_level":{"type":["integer","null"],"format":"int32"},"slug":{"type":"string"},"stackable":{"type":"boolean"},"updated_at":{"type":"string","format":"date-time"},"vocations":{"type":"array","items":{"type":"string"},"description":"Which vocations can use it. Empty means anyone."},"weight_grams":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"],"description":"Which curator version wrote this row."}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_Location":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","slug","name","kind","updated_at"],"properties":{"id":{"type":"string","format":"uuid"},"kind":{"$ref":"#/components/schemas/LocationKind","description":"Which kind of place. Stored as text and validated against the CHECK in migration 0014;\ndeclared here as the enum so a generated client gets the vocabulary."},"name":{"type":"string"},"notes":{"type":["string","null"]},"parent_location_id":{"type":["string","null"],"format":"uuid","description":"The place containing this one, when there is one."},"slug":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_PartySession":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"A stored party session and the split it settled.\n\n**The transfers are stored, not recomputed on read.** A session read back a year later shows the\nanswer it gave on the day; recomputing would silently restate history if the split ever changed.","required":["id","discord_user_id","started_at","ended_at","duration_minutes","loot","supplies","balance","members","transfers","created_at"],"properties":{"balance":{"type":"integer","format":"int64"},"created_at":{"type":"string","format":"date-time","description":"**Our** clock, not the client's, so this one is a real instant."},"discord_server_id":{"type":["string","null"]},"discord_user_id":{"type":"string"},"duration_minutes":{"type":"integer","format":"int32"},"ended_at":{"type":"string"},"hunt_session_id":{"type":["string","null"],"format":"uuid","description":"The owner's own session for this hunt, when one was pasted with it."},"id":{"type":"string","format":"uuid"},"loot":{"type":"integer","format":"int64"},"loot_type":{"type":["string","null"],"description":"`Market` or `Leader` — what the loot column means."},"members":{"type":"array","items":{"$ref":"#/components/schemas/PartySessionMember"}},"note":{"type":["string","null"],"description":"See [`HuntSession::note`]."},"started_at":{"type":"string","description":"The client's own wall clock, with **no timezone** — the paste does not say which zone it\nwas, so this is naive by necessity, exactly as [`HuntSession`] is. Compare sessions to each\nother, never to \"now\". Asserting UTC here would be inventing a fact the paste never carried."},"supplies":{"type":"integer","format":"int64"},"transfers":{"type":"array","items":{"$ref":"#/components/schemas/PartyTransfer"},"description":"Empty when the party was already square."}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_Respawn":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"A hunting respawn a server rotates through, with who holds it and who is waiting.\n\nOne board row. Everything needed to render \"🔴 Vengoth -2 — @someone until 21:00 · 2 waiting\"\ncomes back in one response, so the bot never issues a second request per row.","required":["id","name","queue_depth","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"experience_per_hour":{"type":["integer","null"],"format":"int64"},"hold":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Hold","description":"`None` when the respawn is free. Computed from `ends_at`, so it is correct the instant a\nwindow passes rather than once a sweep has run."}]},"id":{"type":"string","format":"uuid"},"location_slug":{"type":["string","null"],"description":"The city, dungeon or region it sits in. Readable as well as writable: a foreign key you can\nset and not see is one nobody can check."},"max_level":{"type":["integer","null"],"format":"int32"},"min_level":{"type":["integer","null"],"format":"int32","description":"What makes the place worth hunting. From the curator, absent until it has said."},"name":{"type":"string","description":"As players write it: \"Vengoth -2\".\n\n**No `discord_server_id` and no `world`.** A respawn is a place in the game, the same one for\neverybody — the catalog is global and curator-owned (ADR 0018). Which server is looking, and\ntherefore which claims come back, is a property of the *request*; which world they play on is\na property of their server (ADR 0020)."},"notes":{"type":["string","null"]},"profit_per_hour":{"type":["integer","null"],"format":"int64","description":"Signed: plenty of spawns lose money, and that is worth knowing before you go."},"queue":{"type":"array","items":{"$ref":"#/components/schemas/QueueEntry"},"description":"Everyone waiting, in the order they will be promoted."},"queue_depth":{"type":"integer","format":"int32"},"slug":{"type":["string","null"],"description":"The curator's stable key: `vengoth-2`. `None` only for rows predating the catalog."}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_String":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"string"}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"Page_VoiceTranscript":{"type":"object","description":"A page of results. Cursor pagination per the api-design rule: offsets skip or repeat rows when\nthe underlying set changes between pages, which for a character's death history means silently\nlosing deaths.","required":["data"],"properties":{"data":{"type":"array","items":{"type":"object","description":"One utterance, already transcribed. **Text only — there is no audio field, by design.**","required":["discord_channel_id","speaker_discord_id","spoken_at","text"],"properties":{"confidence":{"type":["number","null"],"format":"float","description":"`0..1`, what the transcriber thought of its own answer. Optional because not every engine\nreports one; stored from the first day so a later feature can refuse rows it cannot trust."},"discord_channel_id":{"type":"string"},"speaker_discord_id":{"type":"string","description":"Who spoke. Required: an utterance nobody can be attributed to is not useful for anything this\nfeature exists for, and storing unattributable speech is storing strangers' conversations."},"spoken_at":{"type":"string","format":"date-time"},"text":{"type":"string"}}}},"next_cursor":{"type":["string","null"],"description":"Opaque cursor for the next page, or `null` when this is the last one."}}},"PartySession":{"type":"object","description":"A stored party session and the split it settled.\n\n**The transfers are stored, not recomputed on read.** A session read back a year later shows the\nanswer it gave on the day; recomputing would silently restate history if the split ever changed.","required":["id","discord_user_id","started_at","ended_at","duration_minutes","loot","supplies","balance","members","transfers","created_at"],"properties":{"balance":{"type":"integer","format":"int64"},"created_at":{"type":"string","format":"date-time","description":"**Our** clock, not the client's, so this one is a real instant."},"discord_server_id":{"type":["string","null"]},"discord_user_id":{"type":"string"},"duration_minutes":{"type":"integer","format":"int32"},"ended_at":{"type":"string"},"hunt_session_id":{"type":["string","null"],"format":"uuid","description":"The owner's own session for this hunt, when one was pasted with it."},"id":{"type":"string","format":"uuid"},"loot":{"type":"integer","format":"int64"},"loot_type":{"type":["string","null"],"description":"`Market` or `Leader` — what the loot column means."},"members":{"type":"array","items":{"$ref":"#/components/schemas/PartySessionMember"}},"note":{"type":["string","null"],"description":"See [`HuntSession::note`]."},"started_at":{"type":"string","description":"The client's own wall clock, with **no timezone** — the paste does not say which zone it\nwas, so this is naive by necessity, exactly as [`HuntSession`] is. Compare sessions to each\nother, never to \"now\". Asserting UTC here would be inventing a fact the paste never carried."},"supplies":{"type":"integer","format":"int64"},"transfers":{"type":"array","items":{"$ref":"#/components/schemas/PartyTransfer"},"description":"Empty when the party was already square."}}},"PartySessionMember":{"type":"object","description":"One member of a party session, as the client reported them.","required":["name","leader","loot","supplies","balance","damage","healing","share","delta"],"properties":{"balance":{"type":"integer","format":"int64","description":"Signed: a member who spent more than they looted is ordinary."},"damage":{"type":"integer","format":"int64"},"delta":{"type":"integer","format":"int64","description":"`balance - share`. Positive means they hold more than their due and pay it out."},"healing":{"type":"integer","format":"int64"},"leader":{"type":"boolean"},"loot":{"type":"integer","format":"int64"},"name":{"type":"string"},"share":{"type":"integer","format":"int64","description":"What an even split says this member should end with."},"supplies":{"type":"integer","format":"int64"}}},"PartyTransfer":{"type":"object","description":"One payment that settles part of a party split.","required":["from","to","amount"],"properties":{"amount":{"type":"integer","format":"int64","description":"Always positive. Direction lives in `from`/`to`, never in the sign."},"from":{"type":"string"},"to":{"type":"string"}}},"PatchBoss":{"type":"object","description":"Only the fields mentioned. `null` clears, absent leaves alone — see [`crate::patch::Patch`].","properties":{"boostable":{"type":["boolean","null"]},"boss_type":{"type":["string","null"]},"bosstiary_class":{"type":["string","null"]},"cooldown_hours":{"type":["integer","null"],"format":"int32"},"creature_slug":{"type":["string","null"],"description":"The bestiary row. `null` unlinks without deleting either side."},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"image_url":{"type":["string","null"]},"location_details":{"type":["string","null"],"description":"The prose. `null` clears it."},"location_slug":{"type":["string","null"],"description":"Which curated place. `null` unfiles the boss without touching its directions."},"max_players":{"type":["integer","null"],"format":"int32"},"name":{"type":["string","null"]},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PatchCreature":{"type":"object","description":"Only the fields mentioned. `null` clears, absent leaves alone — see [`Patch`].","properties":{"armor":{"type":["integer","null"],"format":"int32"},"bestiary_class":{"type":["string","null"]},"bestiary_difficulty":{"type":["string","null"]},"bestiary_occurrence":{"type":["string","null"]},"boostable":{"type":["boolean","null"]},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"image_url":{"type":["string","null"]},"is_boss":{"type":["boolean","null"]},"name":{"type":["string","null"]},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"speed":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PatchItem":{"type":"object","description":"Only the fields mentioned. `null` clears, absent leaves alone — see [`Patch`].","properties":{"image_url":{"type":["string","null"]},"item_type":{"type":["string","null"]},"market_value":{"type":["integer","null"],"format":"int64"},"name":{"type":["string","null"]},"notes":{"type":["string","null"]},"npc_buy_price":{"type":["integer","null"],"format":"int64"},"npc_sell_price":{"type":["integer","null"],"format":"int64"},"required_level":{"type":["integer","null"],"format":"int32"},"stackable":{"type":["boolean","null"]},"weight_grams":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PatchLocation":{"type":"object","properties":{"kind":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/LocationKind","description":"Cannot be cleared — a place is always some kind of place."}]},"name":{"type":["string","null"]},"notes":{"type":["string","null"]},"parent_slug":{"type":["string","null"],"description":"`null` detaches the place from its parent."},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PatchRespawn":{"type":"object","description":"Only the fields mentioned. `null` clears, absent leaves alone — see [`Patch`].","properties":{"experience_per_hour":{"type":["integer","null"],"format":"int64"},"location_slug":{"type":["string","null"],"description":"The slug of the place containing it. `null` detaches it."},"max_level":{"type":["integer","null"],"format":"int32"},"min_level":{"type":["integer","null"],"format":"int32"},"name":{"type":["string","null"]},"notes":{"type":["string","null"]},"profit_per_hour":{"type":["integer","null"],"format":"int64"},"written_by":{"type":["string","null"]}}},"PatchWorld":{"type":"object","description":"Part of a world. Absent leaves a field alone; `null` clears it.","properties":{"active":{"type":["boolean","null"],"description":"The supported way to retire or revive one world. Cannot be cleared — a world is running or\nit is not."},"location":{"type":["string","null"]},"name":{"type":["string","null"],"description":"**Renaming moves the resource**, because the slug is derived from the name. The response\ncarries the new slug; the old URL stops resolving. Cannot be cleared."},"pvp_type":{"type":["string","null"]}}},"PresenceObservedPlayer":{"type":"object","description":"**Named for the contract, not for Rust.** `dto::ObservedPlayer` is a different thing entirely —\nthe rolled-up view `/v1/manada` answers with — and utoipa keys schemas by the bare type name, so\ntwo `ObservedPlayer`s silently overwrite each other in the generated document. The one that won\ndecided what every generated client believed the other endpoint returned.","required":["name","level","vocation"],"properties":{"level":{"type":"integer","format":"int32"},"name":{"type":"string"},"vocation":{"type":"string","description":"Tibia's own spelling — \"Royal Paladin\", \"Elder Druid\". Parsed leniently, because a vocation\nwe fail to recognise should cost that player's vocation, not the whole world's presence."}}},"PresenceWorldInput":{"type":"object","description":"Same collision as above, against `routes::catalog::WorldInput`. These two happen to be\nstructurally identical today, which is exactly why it would go unnoticed until one of them\nchanged.","required":["name"],"properties":{"location":{"type":["string","null"]},"name":{"type":"string"},"pvp_type":{"type":["string","null"]}}},"PutBoss":{"type":"object","description":"The whole boss as it should be.","required":["name","boss_type"],"properties":{"boostable":{"type":"boolean"},"boss_type":{"type":"string","description":"lever · raid · quest · field · unknown"},"bosstiary_class":{"type":["string","null"]},"composition":{"description":"Recommended party, as `[{\"vocation\":…,\"count\":…,\"role\":…}]`."},"composition_source":{"type":["string","null"]},"cooldown_hours":{"type":["integer","null"],"format":"int32"},"creature_slug":{"type":["string","null"],"description":"The bestiary row: stats, resistances and loot live there."},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"image_url":{"type":["string","null"]},"location_details":{"type":["string","null"],"description":"Free prose: where inside the place, or how to reach it. Not a place name."},"location_slug":{"type":["string","null"],"description":"Which curated place this boss is in. The location must exist first."},"max_players":{"type":["integer","null"],"format":"int32"},"max_players_source":{"type":["string","null"],"description":"Defaults to `curated` — the curator saying so is itself the provenance."},"name":{"type":"string"},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PutCreature":{"type":"object","description":"The whole creature, as it should be. Anything omitted is genuinely absent.","required":["name"],"properties":{"armor":{"type":["integer","null"],"format":"int32"},"bestiary_class":{"type":["string","null"]},"bestiary_difficulty":{"type":["string","null"],"description":"harmless · trivial · easy · medium · hard · challenging"},"bestiary_occurrence":{"type":["string","null"],"description":"common · uncommon · rare · very-rare"},"boostable":{"type":"boolean"},"experience":{"type":["integer","null"],"format":"int64"},"hitpoints":{"type":["integer","null"],"format":"int64"},"image_url":{"type":["string","null"]},"is_boss":{"type":"boolean"},"name":{"type":"string"},"notes":{"type":["string","null"]},"recommended_level":{"type":["integer","null"],"format":"int32"},"speed":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"],"description":"What produced this — `manada-knowledge-curator/1.2`."}}},"PutItem":{"type":"object","description":"The whole row as it should be. A field left out is genuinely absent — use `PATCH` to change one.","required":["name"],"properties":{"image_url":{"type":["string","null"]},"item_type":{"type":["string","null"]},"market_value":{"type":["integer","null"],"format":"int64"},"name":{"type":"string"},"notes":{"type":["string","null"]},"npc_buy_price":{"type":["integer","null"],"format":"int64"},"npc_sell_price":{"type":["integer","null"],"format":"int64"},"required_level":{"type":["integer","null"],"format":"int32"},"stackable":{"type":"boolean"},"vocations":{"type":"array","items":{"type":"string"}},"weight_grams":{"type":["integer","null"],"format":"int32"},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PutLocation":{"type":"object","required":["name","kind"],"properties":{"kind":{"$ref":"#/components/schemas/LocationKind","description":"One of `city`, `town`, `dungeon`, `region`, `quest-area`. Anything else is a `422` naming\nthe alternatives."},"name":{"type":"string"},"notes":{"type":["string","null"]},"parent_slug":{"type":["string","null"],"description":"The slug of the containing place, not its id — the curator works in slugs."},"wiki_url":{"type":["string","null"]},"written_by":{"type":["string","null"]}}},"PutLoot":{"type":"object","properties":{"max_count":{"type":["integer","null"],"format":"int32","description":"The most that ever drops at once, when the curator knows."},"rarity":{"type":["string","null"],"description":"always · common · uncommon · semi-rare · rare · very-rare"}}},"PutResistances":{"type":"object","required":["resistances"],"properties":{"resistances":{"type":"array","items":{"$ref":"#/components/schemas/ResistanceInput"}}}},"PutServerSetupRequest":{"type":"object","description":"Everything an installation needs before it can be used (ADR 0021).\n\n**`PUT`, not `PATCH`.** Setup is all-or-nothing in the database, so a partial verb would let a\ncaller build exactly the half-configured state the CHECK forbids — and fail confusingly, at the\ndatabase, rather than at the type. Re-running it is how a server changes its world or language\nand how ownership is transferred, so it is idempotent by construction.","required":["owner_discord_user_id","locale","world"],"properties":{"actor_discord_id":{"type":["string","null"],"description":"Who ran this, for the audit trail. **Attribution only** — the authorization actor is the\none in the query string (`ServerScope`), so there is exactly one field a rule reads and no\nsecond one that looks like it should be checked."},"announce_channel_id":{"type":["string","null"],"description":"The channel this command was run in, used as the default announcement channel **only when\nthe server has not already chosen one**. Never overwrites an existing choice — re-running\n`/setup` to change world, language, or ownership must not repoint or re-enable an\nannouncement a moderator explicitly configured or switched off (ADR 0046)."},"locale":{"$ref":"#/components/schemas/Locale"},"owner_discord_user_id":{"type":"string","description":"Who will administer this installation.\n\nThis used to say the bot had already checked `MANAGE_GUILD` and the API merely recorded it.\nSince ADR 0028 the API re-authorises: the caller must vouch `actor_administers`, or be the\nrecorded owner re-running setup."},"world":{"type":"string","description":"The world's **name**, as the catalog publishes it. Resolved to an id here so no client has\nto know one."}}},"PutSignupRequest":{"type":"object","description":"Set (or change) a player's answer. Idempotent: the same body twice leaves the same state.","required":["status"],"properties":{"character_name":{"type":["string","null"],"description":"Which of their verified characters they are bringing. Defaults to their main."},"discord_username":{"type":["string","null"]},"note":{"type":["string","null"]},"role":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/RoleKind"}]},"status":{"$ref":"#/components/schemas/SignupStatus"},"vocation":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Vocation","description":"Overrides the character's vocation — useful before a player has linked a character."}]}}},"PutSpawn":{"type":"object","properties":{"spawn_count":{"type":["integer","null"],"format":"int32","description":"Roughly how many spawn there, when the curator knows. Usually it does not."}}},"PutWorld":{"type":"object","description":"A whole world, replaced.","required":["name"],"properties":{"active":{"type":"boolean","description":"Defaults to `true`: a world you are writing by hand is one you believe in."},"location":{"type":["string","null"]},"name":{"type":"string","description":"As Tibia spells it. Must agree with the slug in the path — see [`put`]."},"pvp_type":{"type":["string","null"]}}},"PutWorlds":{"type":"object","description":"The world list, as the curator publishes it.","required":["worlds"],"properties":{"worlds":{"type":"array","items":{"$ref":"#/components/schemas/WorldInput"}}}},"QueueEntry":{"type":"object","description":"One player waiting for a respawn.","required":["discord_user_id","position","requested_minutes","queued_at"],"properties":{"discord_user_id":{"type":"string"},"discord_username":{"type":["string","null"]},"position":{"type":"integer","format":"int32","description":"1-based. Earned once: the underlying order only ever increases, so leaving the queue and\nrejoining puts you at the back."},"queued_at":{"type":"string","format":"date-time"},"requested_minutes":{"type":"integer","format":"int32"}}},"Reaction":{"type":"string","description":"How a creature reacts to an element, read from its damage percentage.\n\nDerived rather than stored, so it can never disagree with the number it describes.","enum":["healed","immune","resistant","normal","weak"]},"RecordTranscriptsRequest":{"type":"object","description":"A batch of utterances from one Discord server.","required":["discord_server_id","transcripts"],"properties":{"discord_server_id":{"type":"string"},"transcripts":{"type":"array","items":{"$ref":"#/components/schemas/VoiceTranscript"}}}},"Resistance":{"type":"object","description":"One element's damage percentage, with the reaction it implies.","required":["element","damage_percent","reaction"],"properties":{"damage_percent":{"type":"integer","format":"int32","description":"Percentage of normal damage. 100 is normal, 0 immune, 125 takes a quarter more."},"element":{"type":"string"},"reaction":{"$ref":"#/components/schemas/Reaction","description":"Derived from the percentage, so it can never disagree with it."}}},"ResistanceInput":{"type":"object","required":["element","damage_percent"],"properties":{"damage_percent":{"type":"integer","format":"int32"},"element":{"type":"string"}}},"Respawn":{"type":"object","description":"A hunting respawn a server rotates through, with who holds it and who is waiting.\n\nOne board row. Everything needed to render \"🔴 Vengoth -2 — @someone until 21:00 · 2 waiting\"\ncomes back in one response, so the bot never issues a second request per row.","required":["id","name","queue_depth","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"experience_per_hour":{"type":["integer","null"],"format":"int64"},"hold":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Hold","description":"`None` when the respawn is free. Computed from `ends_at`, so it is correct the instant a\nwindow passes rather than once a sweep has run."}]},"id":{"type":"string","format":"uuid"},"location_slug":{"type":["string","null"],"description":"The city, dungeon or region it sits in. Readable as well as writable: a foreign key you can\nset and not see is one nobody can check."},"max_level":{"type":["integer","null"],"format":"int32"},"min_level":{"type":["integer","null"],"format":"int32","description":"What makes the place worth hunting. From the curator, absent until it has said."},"name":{"type":"string","description":"As players write it: \"Vengoth -2\".\n\n**No `discord_server_id` and no `world`.** A respawn is a place in the game, the same one for\neverybody — the catalog is global and curator-owned (ADR 0018). Which server is looking, and\ntherefore which claims come back, is a property of the *request*; which world they play on is\na property of their server (ADR 0020)."},"notes":{"type":["string","null"]},"profit_per_hour":{"type":["integer","null"],"format":"int64","description":"Signed: plenty of spawns lose money, and that is worth knowing before you go."},"queue":{"type":"array","items":{"$ref":"#/components/schemas/QueueEntry"},"description":"Everyone waiting, in the order they will be promoted."},"queue_depth":{"type":"integer","format":"int32"},"slug":{"type":["string","null"],"description":"The curator's stable key: `vengoth-2`. `None` only for rows predating the catalog."}}},"RoleKind":{"type":"string","description":"What a slot in the party is *for*. A hunt asks for four knights, but one of them is blocking and\nthree are damage; saying so up front avoids a party of five tanks and no healer.","enum":["tank","healer","damage","support","any"]},"Roster":{"type":"object","description":"How the party is shaping up against what the hunt asked for.","required":["confirmed","tentative","declined","waitlisted","cap_is_enforced","needs","composition_satisfied"],"properties":{"cap_is_enforced":{"type":"boolean","description":"Whether the game itself enforces the cap (a lever boss) rather than it being a target."},"capacity":{"type":["integer","null"],"format":"int32"},"composition_satisfied":{"type":"boolean","description":"True when every desired slot is filled."},"confirmed":{"type":"integer","format":"int32"},"declined":{"type":"integer","format":"int32"},"needs":{"type":"array","items":{"$ref":"#/components/schemas/RosterNeed"},"description":"Desired vs. confirmed, per vocation."},"slots_remaining":{"type":["integer","null"],"format":"int32","description":"Free slots, or `null` when the hunt has no cap."},"tentative":{"type":"integer","format":"int32"},"waitlisted":{"type":"integer","format":"int32"}}},"RosterCandidate":{"type":"object","description":"One player from a roster-gap search: `/find`.\n\n**Not a [`ManadaMatch`].** That one answers \"who plays when I do\", scored on schedule overlap\nover weeks of observation. This one answers \"who could fill this slot tonight\", so it carries\nrecency instead of a rhythm — the two are different questions and sharing a type would invite\nshowing one number under the other's heading.","required":["name","vocation","level","reachable","days_seen","slots_seen"],"properties":{"days_seen":{"type":"integer","format":"int32","description":"Distinct days observed. A player seen once is a worse bet than one seen daily."},"last_seen_at":{"type":["string","null"],"format":"date-time","description":"The last date this player was seen on an online list."},"level":{"type":"integer","format":"int32"},"name":{"type":"string"},"reachable":{"type":"boolean","description":"Whether the searcher can reach them through Manada."},"slots_seen":{"type":"integer","format":"int32","description":"Half-hour slots observed in total — how *much* they play, not when."},"vocation":{"$ref":"#/components/schemas/Vocation"}}},"RosterMessageRequest":{"type":"object","description":"Where the bot posted this hunt's roster embed.\n\nIts own request type, and its own endpoint, because it is the bot recording what it did rather\nthan a person editing the hunt — see `PUT /v1/hunts/{id}/roster-message`.","required":["discord_message_id"],"properties":{"discord_message_id":{"type":"string"}}},"RosterNeed":{"type":"object","description":"One vocation's line in the roster: how many the hunt wants and how many it has.","required":["vocation","role","wanted","confirmed","missing"],"properties":{"confirmed":{"type":"integer","format":"int32"},"missing":{"type":"integer","format":"int32","description":"`wanted - confirmed`, floored at zero."},"role":{"$ref":"#/components/schemas/RoleKind"},"vocation":{"$ref":"#/components/schemas/Vocation"},"wanted":{"type":"integer","format":"int32"}}},"RosterSearchResult":{"type":"object","description":"What `/find` came back with.","required":["min_level","max_level"],"properties":{"candidates":{"type":"array","items":{"$ref":"#/components/schemas/RosterCandidate"}},"max_level":{"type":"integer","format":"int32"},"min_level":{"type":"integer","format":"int32","description":"The level range searched, so the caller can state it rather than recompute it."}}},"SamplingPlan":{"type":"object","description":"The worlds the mirror should sample, and how often.\n\nServed so the sampling policy lives with the data rather than being configured twice, in two\nrepositories, with nothing to notice when they disagree.","required":["worlds","interval_minutes","per_world_delay_seconds"],"properties":{"interval_minutes":{"type":"integer","format":"int64"},"per_world_delay_seconds":{"type":"integer","format":"int64","description":"Seconds to wait between worlds, so a pass fits inside its slot.","minimum":0},"worlds":{"type":"array","items":{"type":"string"}}}},"ServerSettings":{"type":"object","required":["discord_server_id","boosted_enabled"],"properties":{"announce_channel_id":{"type":["string","null"],"description":"Where announcements go. `null` means the server receives none — **not** \"pick a channel\"."},"boosted_announced_on":{"type":["string","null"],"format":"date","description":"The last UTC day this server was announced to, as `YYYY-MM-DD`."},"boosted_enabled":{"type":"boolean","description":"Whether this server wants the daily boosted boss and creature."},"discord_server_id":{"type":"string"},"respawn_board_channel_id":{"type":["string","null"]},"respawn_board_message_id":{"type":["string","null"]},"setup":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/ServerSetup","description":"`null` until somebody runs `/setup`.\n\n**Server-scoped features do not work while this is null** — the bot refuses the surface and\nthe API refuses to create server-scoped rows, answering `setup_required`. Unlike the\nannouncement fields below, this is a distinction every caller must make: \"not configured\"\nand \"configured and switched off\" are the same thing for announcements and opposite things\nfor setup."}]},"voice_channel_id":{"type":["string","null"],"description":"The voice channel the bot is transcribing, or `null`. **Set by `/voice join` and rejoined at\nstartup**, because songbird's connection lives in process memory and every redeploy would\notherwise drop the bot out of the channel with nothing said."},"voice_speaking_enabled":{"type":"boolean","description":"Whether the bot may **speak** in a voice channel when mentioned. Independent of\ntranscription: listening and talking are separate consents, and a guild that wants a record\nof a hunt does not necessarily want a synthetic voice talking over it.\n\n`#[serde(default)]` because adding a *required* field to a response is a breaking change\nwithin `/v1` — a caller built against yesterday's contract must still parse today's, and one\nbuilt against today's must still parse a payload from a server that has not been upgraded.\nAbsent means off, which is also the safe reading."},"voice_transcription_enabled":{"type":"boolean","description":"Whether an administrator has switched on voice transcription for this server.\n\nOff by default and everywhere it is absent: this decides whether anything a person says out\nloud is written down, so the safe reading of \"we do not know\" is \"no\"."}}},"ServerSetup":{"type":"object","description":"What one Discord server has told the bot it may do.\n\nEvery field is off or absent by default, and that is the contract rather than an accident: a\nserver that has never run `/settings` receives nothing. There is no row for it, `GET` answers\nwith these defaults, and no announcement can be produced for it.\nWhat an installation recorded when somebody ran `/setup` (ADR 0021).\n\nA struct rather than three optional fields on [`ServerSettings`], because the three are only\never meaningful together: a world with no owner is not a partially configured server, it is a\nstate the database refuses to hold. Nesting them makes \"half set up\" unrepresentable here too,\nso no caller can read a world out of a server that has not chosen one.","required":["owner_discord_user_id","locale","world","set_up_at"],"properties":{"locale":{"$ref":"#/components/schemas/Locale","description":"This server's default language. A user's own preference overrides it."},"owner_discord_user_id":{"type":"string","description":"Who administers this installation. A recorded fact, not a permission resolved per channel."},"set_up_at":{"type":"string","format":"date-time"},"world":{"type":"string","description":"The Tibia world this server plays on. The name, because nobody outside the database should\nhave to know the id."}}},"Signup":{"type":"object","description":"One player's answer to a hunt.","required":["discord_user_id","status","role","updated_at"],"properties":{"character_name":{"type":["string","null"],"description":"The verified character they are bringing, when they have linked one."},"discord_user_id":{"type":"string"},"discord_username":{"type":["string","null"]},"level":{"type":["integer","null"],"format":"int32"},"note":{"type":["string","null"]},"role":{"$ref":"#/components/schemas/RoleKind"},"status":{"$ref":"#/components/schemas/SignupStatus"},"updated_at":{"type":"string","format":"date-time"},"vocation":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Vocation"}]}}},"SignupStatus":{"type":"string","description":"How a player answered a hunt invitation.\n\n`Waitlisted` is not something a player picks — the server assigns it when a confirmation would\nexceed the boss's player cap, so an over-subscribed hunt keeps a fair, ordered queue instead of\nsilently rejecting the signup.","enum":["confirmed","tentative","declined","waitlisted"]},"SpawnEntry":{"type":"object","required":["respawn_name"],"properties":{"max_level":{"type":["integer","null"],"format":"int32"},"min_level":{"type":["integer","null"],"format":"int32"},"respawn_name":{"type":"string"},"respawn_slug":{"type":["string","null"]},"spawn_count":{"type":["integer","null"],"format":"int32"}}},"SweepReport":{"type":"object","description":"What one sweep did.","required":["considered","verified"],"properties":{"considered":{"type":"integer","description":"Pending verifications examined this pass.","minimum":0},"verified":{"type":"integer","description":"How many turned out to be provable and were linked.","minimum":0}}},"SyncReport":{"type":"object","description":"What a character sync pass did.","required":["attempted","synced","missing","failed"],"properties":{"attempted":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"missing":{"type":"integer","description":"Characters Tibia no longer has — deleted, renamed, or its API was ambiguous.","minimum":0},"synced":{"type":"integer","minimum":0}}},"TrackCharacterRequest":{"type":"object","description":"Start tracking a character.","required":["name"],"properties":{"name":{"type":"string","description":"Character name, exactly as it appears in game. Case and spacing are normalised for lookup."}}},"TranscriptsAccepted":{"type":"object","description":"What the API did with a batch.\n\n**`stored` and `refused` are reported separately, and that is the useful part.** A caller that\nonly learned \"202\" could not tell a working feature from one silently dropping every row because\nthe server flag is off — and the whole design leans on this endpoint refusing things the bot\nshould already have filtered.","required":["stored","refused"],"properties":{"refused":{"type":"integer","description":"Dropped because the speaker has opted out, or the server has transcription switched off.","minimum":0},"stored":{"type":"integer","minimum":0}}},"UpdateHuntRequest":{"type":"object","description":"Change a hunt. Every field is optional; omitted fields are left alone.","properties":{"composition":{"type":["array","null"],"items":{"$ref":"#/components/schemas/CompositionSlot"}},"description":{"type":["string","null"]},"max_players":{"type":["integer","null"],"format":"int32"},"meeting_point":{"type":["string","null"]},"min_level":{"type":["integer","null"],"format":"int32"},"starts_at":{"type":["string","null"],"format":"date-time"},"status":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/HuntStatus"}]},"timezone_label":{"type":["string","null"]},"title":{"type":["string","null"]}}},"UpdatePreferencesRequest":{"type":"object","description":"A change to one person's preferences. Absent leaves a field alone; `null` clears it.","properties":{"locale":{"type":["string","null"],"description":"`en` · `es` · `pt`, or `null` to fall back to whatever the server speaks."}}},"UpdateServerSettingsRequest":{"type":"object","description":"A partial update to a server's settings. Omitted fields are left alone.\n\n`PATCH` rather than `PUT` deliberately: a full-replace payload would force the bot to\nread-modify-write for a single toggle, and two moderators acting at once would silently lose\none of the two changes.","properties":{"announce_channel_id":{"type":["string","null"]},"boosted_enabled":{"type":["boolean","null"]},"respawn_board_channel_id":{"type":["string","null"]},"respawn_board_message_id":{"type":["string","null"]},"voice_speaking_enabled":{"type":["boolean","null"],"description":"Whether the bot may speak aloud when mentioned in a server it is listening to."},"voice_transcription_enabled":{"type":["boolean","null"],"description":"Turn voice transcription on or off. Administrator only, like every other field here."}}},"UpdateSessionNoteRequest":{"type":"object","description":"Set or clear a session's note. The only thing about a stored session that is editable: every\nother field came from the client's own window, and rewriting one would be inventing a hunt.","properties":{"note":{"type":["string","null"],"description":"The new note. `null` clears it; whitespace-only is treated as `null`, since a note somebody\nblanked is a note they removed."}}},"UpsertDocument":{"type":"object","required":["entity_type","entity_name","title","summary","body_md"],"properties":{"body_md":{"type":"string","description":"The content, in Markdown."},"entity_name":{"type":"string","description":"The name a player would use: \"Soul War\", \"Dragon Lord\"."},"entity_type":{"type":"string","description":"One of `quest`, `creature`, `hunting_area`, `item`, `npc`, `spell`."},"source_url":{"type":["string","null"],"description":"Where these facts came from. Strongly encouraged: a document without it should not be\nquoted as authority."},"summary":{"type":"string","description":"One or two sentences. What a retrieving agent reads first."},"title":{"type":"string"},"written_by":{"type":["string","null"],"description":"What produced this — a model name, an agent name, a person."}}},"UpsertRespawnRequest":{"type":"object","description":"What the **curator** pushes to put a hunting ground in the catalog.\n\nNot what a Discord moderator sends: servers no longer add respawns, they only claim them\n(ADR 0018). The old shape carried `discord_server_id` and `created_by_discord_id`, which is\nexactly the ownership this model removes.","required":["name"],"properties":{"experience_per_hour":{"type":["integer","null"],"format":"int64"},"location_slug":{"type":["string","null"],"description":"The slug of the city, dungeon or region containing it."},"max_level":{"type":["integer","null"],"format":"int32"},"min_level":{"type":["integer","null"],"format":"int32"},"name":{"type":"string"},"notes":{"type":["string","null"]},"profit_per_hour":{"type":["integer","null"],"format":"int64"},"written_by":{"type":["string","null"]}}},"UpsertVoiceProfileRequest":{"type":"object","description":"Write or replace one person's profile.","required":["summary"],"properties":{"covers_from":{"type":["string","null"],"format":"date-time"},"covers_to":{"type":["string","null"],"format":"date-time"},"display_name":{"type":["string","null"]},"facts":{},"summary":{"type":"string"},"transcripts_seen":{"type":"integer","format":"int32"},"written_by":{"type":["string","null"]}}},"UserPreferences":{"type":"object","description":"One person's own preferences, which apply to them in every server.","required":["discord_user_id"],"properties":{"discord_user_id":{"type":"string"},"locale":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/Locale","description":"`null` means \"no preference\", which falls back to the server's default."}]}}},"Verification":{"type":"object","description":"A pending (or settled) proof of character ownership.","required":["id","discord_user_id","character_name","token","status","expires_at","instructions"],"properties":{"character_name":{"type":"string"},"discord_user_id":{"type":"string"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"instructions":{"type":"string","description":"Where the player should paste the token."},"status":{"$ref":"#/components/schemas/VerificationStatus"},"token":{"type":"string","description":"The string the player must publish in their character's comment field. Only ever shown to\nthe requesting user."},"verified_at":{"type":["string","null"],"format":"date-time"}}},"VerificationResult":{"type":"object","description":"The outcome of checking a character's comment for the token.","required":["status"],"properties":{"character":{"oneOf":[{"type":"null"},{"$ref":"#/components/schemas/LinkedCharacter","description":"The linked character, present once verification succeeds."}]},"reason":{"type":["string","null"],"description":"Why the check failed, when it did. `null` on success."},"status":{"$ref":"#/components/schemas/VerificationStatus"}}},"VerificationStatus":{"type":"string","description":"State of a character-ownership verification.","enum":["pending","verified","expired"]},"Vocation":{"type":"string","description":"A base vocation, promotion tiers folded together.","enum":["knight","paladin","druid","sorcerer","monk","none"]},"VoiceMaintenanceReport":{"type":"object","description":"What one voice maintenance pass did.","required":["created","dropped"],"properties":{"created":{"type":"integer","description":"Partitions ensured to exist, this month and the next two.","minimum":0},"dropped":{"type":"integer","description":"Partitions past retention that were dropped.","minimum":0}}},"VoiceProfile":{"type":"object","description":"What we have learned about one person from what they said out loud.\n\nWritten by the analyser that reads transcripts; read by the assistant so it can answer somebody\nas themselves rather than as a stranger. **Deletable by design** — `/voice optout` drops it in\nthe same transaction as the transcripts (ADR 0037).","required":["discord_user_id","summary","transcripts_seen","updated_at"],"properties":{"covers_from":{"type":["string","null"],"format":"date-time"},"covers_to":{"type":["string","null"],"format":"date-time"},"discord_user_id":{"type":"string"},"display_name":{"type":["string","null"],"description":"What they call themselves in voice, which is often not their Discord name."},"facts":{"description":"Extracted facts. The shape is owned by whatever writes it — see the migration for why this\nis deliberately not columns."},"summary":{"type":"string","description":"The rolling prose summary: who this person is to the group."},"transcripts_seen":{"type":"integer","format":"int32","description":"How many utterances went into it, so a thin profile is visible as thin."},"updated_at":{"type":"string","format":"date-time"},"written_by":{"type":["string","null"],"description":"Which model wrote it. When an upgrade changes the character of every profile, this is the\nonly way to tell which came from where."}}},"VoiceTranscript":{"type":"object","description":"One utterance, already transcribed. **Text only — there is no audio field, by design.**","required":["discord_channel_id","speaker_discord_id","spoken_at","text"],"properties":{"confidence":{"type":["number","null"],"format":"float","description":"`0..1`, what the transcriber thought of its own answer. Optional because not every engine\nreports one; stored from the first day so a later feature can refuse rows it cannot trust."},"discord_channel_id":{"type":"string"},"speaker_discord_id":{"type":"string","description":"Who spoke. Required: an utterance nobody can be attributed to is not useful for anything this\nfeature exists for, and storing unattributable speech is storing strangers' conversations."},"spoken_at":{"type":"string","format":"date-time"},"text":{"type":"string"}}},"World":{"type":"object","description":"One world.","required":["slug","name","active","first_seen_at"],"properties":{"active":{"type":"boolean","description":"`false` once Tibia stops running it. Retired worlds stay readable: presence history and any\nDiscord server configured to one still reference it."},"first_seen_at":{"type":"string","format":"date-time"},"last_sampled_at":{"type":["string","null"],"format":"date-time"},"location":{"type":["string","null"]},"name":{"type":"string","description":"As Tibia spells it."},"pvp_type":{"type":["string","null"]},"slug":{"type":"string","description":"How it is addressed here. Derived from the name; see the module docs."}}},"WorldInput":{"type":"object","required":["name"],"properties":{"location":{"type":["string","null"]},"name":{"type":"string"},"pvp_type":{"type":["string","null"]}}},"WorldListAccepted":{"type":"object","required":["worlds_known","deactivated"],"properties":{"deactivated":{"type":"integer","format":"int64","minimum":0},"worlds_known":{"type":"integer","format":"int64"}}},"WorldListRequest":{"type":"object","description":"The world list, as the mirror read it from Tibia.","required":["worlds"],"properties":{"worlds":{"type":"array","items":{"$ref":"#/components/schemas/PresenceWorldInput"}}}},"WorldsWritten":{"type":"object","required":["known","deactivated"],"properties":{"deactivated":{"type":"integer","format":"int64","description":"How many stopped appearing and were retired.","minimum":0},"known":{"type":"integer","format":"int64","description":"How many worlds are active after the write."}}}},"securitySchemes":{"service_token":{"type":"http","scheme":"bearer","description":"Shared service token, sent as `Authorization: Bearer <token>`. Required by every operation that does not declare an empty `security` of its own — those are open on purpose (health, the contract, the game catalog, the knowledge base)."}}},"security":[{"service_token":[]}],"tags":[{"name":"system","description":"Health and contract"},{"name":"characters","description":"Tibia characters and their daily history"},{"name":"verification","description":"Proving a Discord user owns a character"},{"name":"bosses","description":"The boss catalog"},{"name":"hunts","description":"Scheduling hunts and tracking attendance"},{"name":"respawns","description":"Claiming hunting ground"},{"name":"manada","description":"Finding who to hunt with"},{"name":"boosted","description":"Tibia's daily boosted boss and creature"},{"name":"settings","description":"What each Discord server has switched on"},{"name":"presence","description":"Who was seen online, pushed in by manada-mirror"},{"name":"maintenance","description":"The periodic work, driven by Kubernetes CronJobs rather than by timers"}]}