#!/usr/bin/env python3
"""Thaumaton reference player client (tracker #47).

One stable command shape for every player verb, so a harness approves ONE
command instead of a fresh improvised curl each turn. Standard library only:
no installs, one file, served by the game server itself at GET /client.

    python thaumaton.py --help               every verb
    python thaumaton.py me                   who you are
    python thaumaton.py move u_000001 --x 3 --y 4
    python thaumaton.py party-create --name scouts --unit-ids '["u_000001","u_000002"]'
    python thaumaton.py stream               your events as they happen

Key:  read from THAUMATON_API_KEY, or from key.txt beside you (--key-file
      PATH overrides; default ./key.txt). Read at call time, sent only in
      the Authorization header, never printed anywhere. NEVER pass the key
      on the command line: command lines are logged, saved, and approved
      into harness allowlists (#208) — this script refuses to run if it
      finds a key-shaped argument.
URL:  THAUMATON_URL (default http://127.0.0.1:8420).
Out:  the raw JSON response, pretty-printed, on stdout. A non-2xx answer
      prints the server's error body on stderr and exits 1. Usage errors
      (unknown verb, bad flags) exit 2.
Idempotency: every write sends an Idempotency-Key — a fresh uuid4 per call
      unless you pass --idempotency-key; retry a timed-out write with the
      SAME key and the server will not run it twice.

The verb table below is GENERATED from the server's API reference
(client/generate-verbs.ts). Do not edit it by hand.
"""

import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid

DEFAULT_URL = "http://127.0.0.1:8420"

# The game server is spoken to DIRECTLY: proxy environment variables (common
# on managed machines) would otherwise capture a localhost/LAN call and hang
# it. Set THAUMATON_USE_PROXY=1 to honour them.
if os.environ.get("THAUMATON_USE_PROXY") == "1":
    OPENER = urllib.request.build_opener()
else:
    OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))

for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8", errors="replace")
    except (AttributeError, ValueError):
        pass

# BEGIN GENERATED VERBS (client/generate-verbs.ts — do not edit by hand)
VERBS_JSON = r"""[
  {"name":"chat","method":"GET","path":"/v1/chat","params":[],"query":[],"body":[],"help":"Your chat: every message delivered to you on the four non-local channels, oldest first, each with `channel`, `speaker {principal_id, name}`, `by` (player = typed at a page, agent = sent by a key's program), and `text`. Filter with ?channel= and page with ?since=<event_id>. Local chat is not here — it is `speak`, heard where your units stand (GET /v1/me/events, type unit.speech)."},
  {"name":"me","method":"GET","path":"/v1/me","params":[],"query":[],"body":[],"help":"Who you are, and the world's current state (tick, running or paused)."},
  {"name":"me-banners","method":"GET","path":"/v1/me/banners","params":[],"query":[],"body":[],"help":"Your world-map banners: position, movement intent, member unit ids — plus a MOVEMENT block for smooth rendering: destination, next tile with its entry cost, accumulated points and points-per-tick, and predicted arrival (tick + wall-clock ts, computed from YOUR map knowledge). Poll, then interpolate — accumulator/entry_cost gives sub-tile progress between polls; fast polling buys nothing."},
  {"name":"me-units","method":"GET","path":"/v1/me/units","params":[],"query":[],"body":[],"help":"Every unit you control, in full detail (HP, stats, position, cooldowns, carry, items)."},
  {"name":"units","method":"GET","path":"/v1/units","params":[],"query":[],"body":[],"help":"Units your units can currently SEE. Enemies are partly hidden (no exact HP or settings). Every unit read says WHERE it is: `banner_id` (null when it stands in a site), and `location` — `{kind: site, site_id, x, y}` inside a site, or `{kind: world_map, banner_id, x, y}` while it travels, when `site_id` is null and x, y are the banner's world tiles. A unit with a banner is on the road, not parked."},
  {"name":"unit","method":"GET","path":"/v1/units/:unitId","params":["unitId"],"query":[],"body":[],"help":"One unit by id. 404 if it does not exist OR none of your units can see it — those look the same on purpose."},
  {"name":"structures","method":"GET","path":"/v1/structures","params":[],"query":[],"body":[],"help":"Structures your units can see (walls, shops, shrines, sanctuaries...)."},
  {"name":"bundles","method":"GET","path":"/v1/bundles","params":[],"query":[],"body":[],"help":"Dropped loot bundles your units can see."},
  {"name":"sites","method":"GET","path":"/v1/sites","params":[],"query":[],"body":[],"help":"Site markers you have discovered on the world map (type, position, name, owner flag). Interiors are opaque — scouting a site means entering it. Sanctuary sites are always listed, with their no-engagement radius: that rule is absolute and you deserve to map it."},
  {"name":"world","method":"GET","path":"/v1/world","params":[],"query":[],"body":[],"help":"The world's hard rules, machine-readable: name, status, dimensions, tick length, the full terrain catalog (move_cost/view/conceal/def_bonus/ambush per terrain), the PHYSICS LOG — which release wrote which stretch of this world's history (from_tick, release_version; the current release_version beside it: updates flow to existing worlds, and the log is how a replay knows which numbers wrote each stretch), and the CLOCK block — current tick, tick length, and the game-clock anchor (anchor_ts/anchor_tick) that maps any future tick T to wall-clock time: anchor_ts + (T − anchor_tick) × tick_seconds × 1000. Anchor fields are null while the world is paused or was never resumed."},
  {"name":"me-map","method":"GET","path":"/v1/me/map","params":[],"query":[],"body":[],"help":"Everything you remember of the world map: run-length rows of terrain you have seen (terrain is remembered once seen), plus the resource areas you have learned (linger in an area a few rounds to learn its full richness and boundary — one legible fact per area)."},
  {"name":"banners","method":"GET","path":"/v1/banners","params":[],"query":[],"body":[],"help":"Banners you can currently see: your own in full; others only once DETECTED — owner, position, heading, and a size band (few/company/host), never an exact count. Close up (scout range), member tiers and worn gear show too. An undetected banner is simply absent."},
  {"name":"engagements","method":"GET","path":"/v1/engagements","params":[],"query":[],"body":[],"help":"Your engagements and queue standings: role, contact tick, when it resolves (tick AND wall-clock ts — contact declared one tick resolves the next, by this world's combat_mode), queue position."},
  {"name":"site-map","method":"GET","path":"/v1/sites/:siteId/map","params":["siteId"],"query":[],"body":[],"help":"A tile map of one site as seen by YOUR units inside it: terrain, entry tiles, what you perceive. 404 unless you have a unit inside."},
  {"name":"settlements","method":"GET","path":"/v1/settlements","params":[],"query":[],"body":[],"help":"Settlements (towns, camps) you have DISCOVERED: a unit of yours or an ally's has been inside, or the site marker is in your map knowledge. Nothing is inherently famous — fame is knowledge you learn in play, from factions, NPCs and quests; an undiscovered settlement is simply absent. Population is never shown."},
  {"name":"items","method":"GET","path":"/v1/items","params":[],"query":[],"body":[],"help":"The full item catalog: what every item does. Public knowledge."},
  {"name":"recipes","method":"GET","path":"/v1/recipes","params":[],"query":[],"body":[],"help":"Every crafting recipe: inputs, outputs, time. Public knowledge."},
  {"name":"scenarios","method":"GET","path":"/v1/scenarios","params":[],"query":[],"body":[],"help":"Active story goals (victory conditions) in this world, with their STAKES — the goal, its clock and the one-sentence stakes line, served verbatim to everyone. A treasure hunt's stakes are public (the treasure's name, where it must be carried home, who holds it now — as RUMOR: a change of hands is announced a few rounds late and never says how it moved); WHERE the treasure is, is not — scout for it like anything else. A unit carrying the treasure has no sanctuary anywhere: no-engage zones do not protect it."},
  {"name":"me-orders","method":"GET","path":"/v1/me/orders","params":[],"query":[],"body":[],"help":"Every order you ever gave, newest first, with what became of it: `status` pending / applied / rejected and the `reject_code`. This is the DURABLE record — an outcome never falls out of it the way an event falls out of the feed window. `?status=rejected` (or pending, applied) filters; `?limit=` (default 50, max 200) and `?before_seq=<seq>` page older; the response's `has_more` and `next_before_seq` say how. An order applies on the round after `accepted_tick`."},
  {"name":"order","method":"GET","path":"/v1/orders/:seq","params":["seq"],"query":[],"body":[],"help":"One order by the seq its receipt gave you: what became of order N, independent of any window. Yours only (404 otherwise)."},
  {"name":"me-parties","method":"GET","path":"/v1/me/parties","params":[],"query":[],"body":[],"help":"Your parties (named groups of your units), and `invitations`: every alliance you were asked into — `status: invited` is waiting on your answer (accept or decline), `accepted` is one you are in."},
  {"name":"party","method":"GET","path":"/v1/parties/:partyId","params":["partyId"],"query":[],"body":[],"help":"One of your parties by id, with its members. If you were INVITED to it instead, a preview: the owner and the accepted allies as principals, who else is invited, and your own standing — never their units. Read it before you answer."},
  {"name":"me-trades","method":"GET","path":"/v1/me/trades","params":[],"query":[],"body":[],"help":"Trade offers you made or received."},
  {"name":"me-directives","method":"GET","path":"/v1/me/directives","params":[],"query":[],"body":[],"help":"NPC agents only: written instructions from the DM."},
  {"name":"me-transfers","method":"GET","path":"/v1/me/transfers","params":[],"query":[],"body":[],"help":"Companion transfers involving you. An OFFER shows the unit's true stats, persona, trait ranks, and its FULL LEDGER — every deed with its event refs and qualifying stamp, every title with its source deeds — labeled `verified` (server-derived, replayable). Appraise BEFORE you accept. An offer may also carry a `tale` — the offering player's OWN story of the unit: unverified salesmanship, not server record. The gap between the tale and the ledger is the game."},
  {"name":"me-budget","method":"GET","path":"/v1/me/budget","params":[],"query":[],"body":[],"help":"Your token budget, if the operator set one."},
  {"name":"changelog","method":"GET","path":"/v1/changelog","params":[],"query":[{"name":"limit","type":"int","required":false,"help":"integer (1..500)"},{"name":"before","type":"str","required":false,"help":"page: entries before this changelog_id"}],"body":[],"help":"Patch notes and rule clarifications, newest first. READ THIS FIRST — answers to all feedback land here. Pages with ?limit= and ?before=<changelog_id>; the response's total says how many exist."},
  {"name":"traits","method":"GET","path":"/v1/traits","params":[],"query":[],"body":[],"help":"The trait catalog: what every trait DOES (stat modifiers, knob clamps with their tier rule, order refusals, yield bonuses), how it is earned, and what it opposes. Public knowledge — the same table as the section below, machine-readable."},
  {"name":"unit-ledger","method":"GET","path":"/v1/units/:unitId/ledger","params":["unitId"],"query":[],"body":[],"help":"A unit's ledger. Your own unit: trait ranks + the stat contribution actually applied + the clamp ranges its traits impose, the FULL deeds ledger (event refs, strength class, qualifying stamp), titles with source deeds, the origin. A unit you can merely see: ranks and titles only. 404 if it does not exist or none of your units can see it."},
  {"name":"me-events","method":"GET","path":"/v1/me/events","params":[],"query":[{"name":"since","type":"int","required":false,"help":"only events after this event_id"},{"name":"type","type":"str","required":false,"help":"filter by event type"},{"name":"correlation_id","type":"str","required":false,"help":"one episode's events"},{"name":"actor","type":"str","required":false,"help":"filter by actor id"},{"name":"unit","type":"str","required":false,"help":"filter by participating unit id"},{"name":"limit","type":"int","required":false,"help":"integer (1..500)"}],"body":[],"help":"YOUR permanent event stream: order results, combat, alarms, everything addressed to you. Poll with --since <last event_id you saw>."},
  {"name":"events","method":"GET","path":"/v1/events","params":[],"query":[{"name":"since","type":"int","required":false,"help":"only events after this event_id"},{"name":"type","type":"str","required":false,"help":"filter by event type"},{"name":"correlation_id","type":"str","required":false,"help":"one episode's events"},{"name":"actor","type":"str","required":false,"help":"filter by actor id"},{"name":"unit","type":"str","required":false,"help":"filter by participating unit id"},{"name":"limit","type":"int","required":false,"help":"integer (1..500)"},{"name":"scope","type":"enum","required":false,"choices":["public","full"],"help":"public (default) or full (dm/operator keys only)"}],"body":[],"help":"The PUBLIC feed: deaths, raid outcomes, scenario results, market prices. Everyone sees this; it only goes back a few days."},
  {"name":"stream","method":"GET","path":"/v1/me/events/stream","params":[],"query":[{"name":"since","type":"int","required":false,"help":"resume after this event_id"}],"body":[],"help":"Your personal stream as Server-Sent Events: prints each event line as it arrives until you stop it (Ctrl-C). Polling me-events is fine too."},
  {"name":"unit-action","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"type","type":"str","required":true,"help":"action type (see the per-action verbs)"},{"name":"args","type":"json","required":false,"default":{},"help":"JSON object of action args"}],"help":"Give one unit one order: `{\"type\": \"...\", \"args\": {...}}`. See the actions table above."},
  {"name":"move","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"x","required":true,"type":"int","help":"integer (0..inf)"},{"name":"y","required":true,"type":"int","help":"integer (0..inf)"}],"action":"move","help":"Walk one step toward (x, y). Walls block. Full tiles divert you."},
  {"name":"move-to","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"x","required":true,"type":"int","help":"integer (0..inf)"},{"name":"y","required":true,"type":"int","help":"integer (0..inf)"}],"action":"move_to","help":"Keep walking toward (x, y) every round until you arrive. A new order replaces it."},
  {"name":"attack","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"target_unit_id","required":false,"type":"str","help":"string"},{"name":"target_structure_id","required":false,"type":"str","help":"string"}],"action":"attack","help":"Hit an adjacent enemy unit, or an adjacent structure (walls can be broken)."},
  {"name":"speak","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"text","required":true,"type":"str","help":"string (max 500 chars)"},{"name":"to_unit_id","required":false,"type":"str","help":"string"}],"action":"speak","help":"Say something out loud. Anyone close enough hears it. Costs nothing, works any time. Address one unit with `to_unit_id` (it must be alive and in earshot, else out_of_earshot) and your stream gets a speech.heard {attended} receipt: attended true means a live agent is behind that unit (its key acted or read within the attend window), false means nobody is home — so 'ignored' and 'nobody there' read differently. It is a receipt, never an answer: no NPC is made to speak."},
  {"name":"gather","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"resource","required":true,"type":"str","help":"string (max 64 chars)"}],"action":"gather","help":"Work the land for a resource this site can gather — the kinds and richness are on GET /v1/sites as `gatherable` (a settlement or camp gathers from the area it CLAIMS; a DM-authored override on the site comes first). Yield is base × richness, never used up. Takes several rounds; the yield arrives when done."},
  {"name":"craft","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"recipe_id","required":true,"type":"str","help":"string"}],"action":"craft","help":"Craft a recipe (see GET /v1/recipes). Inputs are consumed when you start."},
  {"name":"trade-accept","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"trade_id","required":true,"type":"str","help":"string"}],"action":"trade_accept","help":"Accept a trade offered to you. Your unit must be adjacent to the seller's unit."},
  {"name":"shop-buy","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"structure_id","required":true,"type":"str","help":"string"},{"name":"item_id","required":true,"type":"str","help":"string"},{"name":"qty","required":false,"default":1,"type":"int","help":"integer (1..100)"}],"action":"shop_buy","help":"Buy from an adjacent shop structure. Fixed prices by rarity: common 10g, uncommon 30g, rare 90g, epic 270g, legendary 810g. Gold comes from this unit's carry."},
  {"name":"shop-sell","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"structure_id","required":true,"type":"str","help":"string"},{"name":"instance_id","required":true,"type":"str","help":"string"},{"name":"qty","required":false,"default":1,"type":"int","help":"integer (1..100)"}],"action":"shop_sell","help":"Sell an inventory item to an adjacent shop for 50% of its rarity price, scaled down by wear (a broken item fetches nothing)."},
  {"name":"recruit","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"settlement_id","required":true,"type":"str","help":"string"}],"action":"recruit","help":"Try to recruit a citizen from an adjacent settlement. Costs gold per attempt; may fail. The result (unit.recruited / unit.recruit_failed) reports your odds as a word — poor, fair or good — never a number: a town's population is hidden, and you learn it by living there, not by arithmetic."},
  {"name":"found-camp","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"name","required":true,"type":"str","help":"string (max 64 chars)"}],"action":"found_camp","help":"Found a camp settlement where you stand. Consumes materials from your carry."},
  {"name":"resurrect","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"dead_unit_id","required":true,"type":"str","help":"string"}],"action":"resurrect","help":"Revive one of your dead heroes at an adjacent shrine, for gold."},
  {"name":"respawn","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[],"action":"respawn","help":"Your DESIGNATED hero only: wake again at a sanctuary after death. Always available; never blocked. NOTE: a hero whose status is dead_recoverable is eligible for PAID resurrection (the resurrect action) — that status does NOT mean free respawn; free respawn belongs to exactly one unit, your designee."},
  {"name":"loot","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"bundle_id","required":true,"type":"str","help":"string"}],"action":"loot","help":"Pick up an adjacent dropped loot bundle."},
  {"name":"give-items","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"to_unit_id","required":true,"type":"str","help":"string"},{"name":"resources","required":false,"type":"json","help":"JSON object"},{"name":"instance_ids","required":false,"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"}],"action":"give_items","help":"Hand resources and/or inventory items to an ADJACENT unit of your OWN — the roster's own handoff, applied next round; items.given on your stream. Never a trade: a trade is a market event, and moving your own goods through one writes prices nobody paid. Another owner's unit needs a trade or a transfer (not_own_unit). The treasure may be given."},
  {"name":"pillage","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"structure_id","required":true,"type":"str","help":"string"}],"action":"pillage","help":"Empty an adjacent hostile structure's storage. Damages it a little."},
  {"name":"raze","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"structure_id","required":true,"type":"str","help":"string"}],"action":"raze","help":"Destroy an adjacent hostile structure. Some materials drop as salvage."},
  {"name":"equip","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"instance_id","required":true,"type":"str","help":"string"}],"action":"equip","help":"Equip an inventory item into its slot (weapon / armor / trinket)."},
  {"name":"unequip","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"slot","required":true,"type":"enum","choices":["weapon","armor","trinket"],"help":"one of: weapon | armor | trinket"}],"action":"unequip","help":"Move an equipped item back to inventory."},
  {"name":"use-item","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"instance_id","required":true,"type":"str","help":"string"}],"action":"use_item","help":"Consume an item, e.g. drink a healing potion."},
  {"name":"repair","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[{"name":"instance_id","required":true,"type":"str","help":"string"}],"action":"repair","help":"Repair a damaged item for gold."},
  {"name":"promote-hero","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[],"action":"promote_hero","help":"Promote one of your ELEVATED citizens (tier 2 — a unit the world raised on its deeds) into one of your 3 hero slots (tier 1). Your act, applied next round; it mints the unit's Hero-of title. Costs 0 gold from the unit's carry. 409 not_elevated / hero_slots_full."},
  {"name":"demote-hero","method":"POST","path":"/v1/units/:unitId/actions","params":["unitId"],"query":[],"body":[],"action":"demote_hero","help":"Step one of your HEROES (tier 1) back to an elevated citizen (tier 2): the hero slot frees and the unit loses its protection (it can now die for good) — it keeps its ledger, titles, traits and rare trait; its surrender clamps re-engage. Its defense knobs return to the tier-2 defaults (a hero's flee threshold is not a citizen's), reported to you as unit.defense_changed. Your act, applied next round; your designated hero cannot be demoted (re-designate first). 409 not_a_hero / designated_unit."},
  {"name":"banner-action","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"type","type":"str","required":true,"help":"banner action type"},{"name":"args","type":"json","required":false,"default":{},"help":"JSON object of action args"}],"help":"Banner orders. banner_move_to {x,y} (march — a standing intent), banner_engage {target_banner_id} (pursue a banner you can see; contact opens an engagement that resolves NEXT tick by this world's combat_mode — auto worlds auto-resolve, granular worlds spawn a real battlefield site; whichever side survives RE-FORMS under a NEW banner id at the battlefield marker with a retreat march toward your nearest refuge, and your stream says so: `banner.reformed {from_banner_id, banner_id, intent, prior_intent}` — re-point your references and re-issue the march you meant), banner_merge {other_banner_id} (march this banner INTO yours-or-an-ally's), banner_split {unit_ids} (allies may split their own units out; locked mid-engagement), enter_site {site_id} (walk in at the marker — also escapes an open engagement), banner_found_camp {name}. banner_elect and banner_config are RETIRED (410 election_retired — combat mode is per-world)."},
  {"name":"banner-move-to","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"x","type":"int","required":true,"help":"integer (0..)"},{"name":"y","type":"int","required":true,"help":"integer (0..)"}],"action":"banner_move_to","help":"March the banner toward (x, y) — a standing intent."},
  {"name":"banner-engage","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"target_banner_id","type":"str","required":true,"help":"string"}],"action":"banner_engage","help":"Pursue a banner you can see; contact opens an engagement resolved by this world's combat_mode."},
  {"name":"banner-merge","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"other_banner_id","type":"str","required":true,"help":"string"}],"action":"banner_merge","help":"March this banner INTO yours-or-an-ally's; the target survives under its owner's command."},
  {"name":"banner-split","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"unit_ids","type":"json","required":true,"help":"JSON list of unit ids (1..100)"}],"action":"banner_split","help":"Split your own units out of a shared banner; locked mid-engagement."},
  {"name":"enter-site","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"site_id","type":"str","required":true,"help":"string"}],"action":"enter_site","help":"Walk into a site at its marker — also escapes an open engagement."},
  {"name":"banner-found-camp","method":"POST","path":"/v1/banners/:bannerId/actions","params":["bannerId"],"query":[],"body":[{"name":"name","type":"str","required":true,"help":"string (max 64 chars)"}],"action":"banner_found_camp","help":"Found a camp settlement where the banner stands."},
  {"name":"site-action","method":"POST","path":"/v1/sites/:siteId/actions","params":["siteId"],"query":[],"body":[{"name":"type","type":"str","required":true,"help":"site action type"},{"name":"args","type":"json","required":false,"default":{},"help":"JSON object of action args"}],"help":"Site-scoped orders. exit_site {unit_ids}: your listed units, standing on the site's entry tiles (the west column), step out together and re-form as a fresh banner at the marker (default settings — re-set banner_config when you march)."},
  {"name":"exit-site","method":"POST","path":"/v1/sites/:siteId/actions","params":["siteId"],"query":[],"body":[{"name":"unit_ids","type":"json","required":true,"help":"JSON list of unit ids (1..100)"}],"action":"exit_site","argFromParam":"site_id","help":"Your listed units, standing on the site's entry tiles, step out together and re-form as a fresh banner at the marker."},
  {"name":"orders-batch","method":"POST","path":"/v1/orders/batch","params":[],"query":[],"body":[{"name":"orders","required":true,"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"}],"help":"Give several orders in one call."},
  {"name":"order-delete","method":"DELETE","path":"/v1/orders/:seq","params":["seq"],"query":[],"body":[],"help":"Cancel one of your pending orders before the next round resolves it."},
  {"name":"unit-defense-set","method":"PATCH","path":"/v1/units/:unitId/defense","params":["unitId"],"query":[],"body":[{"name":"posture","required":false,"type":"enum","choices":["fight","flee","surrender"],"help":"one of: fight | flee | surrender"},{"name":"flee_threshold","required":false,"type":"int","help":"integer (0..100)"},{"name":"target_priority","required":false,"type":"enum","choices":["nearest","weakest","strongest"],"help":"one of: nearest | weakest | strongest"}],"help":"Change a unit's self-defense settings (they apply from the next round)."},
  {"name":"chat-world","method":"POST","path":"/v1/chat/world","params":[],"query":[],"body":[{"name":"text","required":true,"type":"str","help":"string (max 2000 chars)"}],"help":"WORLD CHAT: a message to every principal in the world. Words, not an act: the engine carries them and verifies nothing in them — fast, unreliable, unimmersive by design. Speaking reveals the speaker (your name; never a position); listening reveals nothing (you are never told who heard). Refused 409 channel_disabled on a world that runs local chat only. A live key in the text is 400 credential_in_text. Capped per tick (429 chat_rate_limited)."},
  {"name":"chat-alliance","method":"POST","path":"/v1/chat/alliance","params":[],"query":[],"body":[{"name":"text","required":true,"type":"str","help":"string (max 2000 chars)"}],"help":"ALLIANCE CHAT: a message to every principal you are allied with (409 no_alliance when you have none). Position-free; delivered by membership, never perception."},
  {"name":"chat-party","method":"POST","path":"/v1/chat/party","params":[],"query":[],"body":[{"name":"party_id","required":true,"type":"str","help":"string"},{"name":"text","required":true,"type":"str","help":"string (max 2000 chars)"}],"help":"PARTY CHAT: a message to the principals of a party — its owner and its accepted allies — {party_id, text}. 403 not_a_member for an invitee who has not answered, a stranger, or a party that does not exist — one refusal on purpose, so party ids cannot be walked. A refused attempt counts against your per-tick cap like a message."},
  {"name":"chat-direct","method":"POST","path":"/v1/chat/direct","params":[],"query":[],"body":[{"name":"to_principal_id","required":true,"type":"str","help":"string"},{"name":"text","required":true,"type":"str","help":"string (max 2000 chars)"}],"help":"DIRECT CHAT: one principal to one principal — {to_principal_id, text}. The two of you and nobody else: the DM is no member of direct, party or alliance chat and cannot read them (it hears world chat and local speech). A message to an id that is not a person in the world is accepted and reaches only you."},
  {"name":"me-designate","method":"POST","path":"/v1/me/designate","params":[],"query":[],"body":[{"name":"unit_id","required":true,"type":"str","help":"string"}],"help":"Choose which of your heroes carries your never-locked-out respawn right."},
  {"name":"party-create","method":"POST","path":"/v1/parties","params":[],"query":[],"body":[{"name":"name","required":true,"type":"str","help":"string (max 64 chars)"},{"name":"unit_ids","required":true,"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"}],"help":"Create a named party from your units."},
  {"name":"party-members","method":"POST","path":"/v1/parties/:partyId/members","params":["partyId"],"query":[],"body":[{"name":"unit_id","required":true,"type":"str","help":"string"}],"help":"Add one of YOUR units to a party — the owner's, or an accepted ally's own units (command never crosses: you place only units you own)."},
  {"name":"party-invite","method":"POST","path":"/v1/parties/:partyId/invite","params":["partyId"],"query":[],"body":[],"help":"Invite another PLAYER (principal) into your party as an ALLY. Allies share perception (their units and banners see for you, and yours for them) and cannot attack each other — but you never command their units."},
  {"name":"party-accept","method":"POST","path":"/v1/parties/:partyId/accept","params":["partyId"],"query":[],"body":[],"help":"Accept an alliance invitation. From then on you share perception with the party and the friendly-fire guard protects both sides."},
  {"name":"party-decline","method":"POST","path":"/v1/parties/:partyId/decline","params":["partyId"],"query":[],"body":[],"help":"Turn an alliance invitation down. The inviter is told (`party.declined` on both your streams) and may ask again; silence is never an answer — say no when you mean no."},
  {"name":"party-leave","method":"POST","path":"/v1/parties/:partyId/leave","params":["partyId"],"query":[],"body":[],"help":"Leave an alliance. Takes effect NEXT round (no same-round betrayal ambush): your units drop out of the party and shared perception ends."},
  {"name":"party-member-delete","method":"DELETE","path":"/v1/parties/:partyId/members/:unitId","params":["partyId","unitId"],"query":[],"body":[],"help":"Remove a unit from a party."},
  {"name":"party-delete","method":"DELETE","path":"/v1/parties/:partyId","params":["partyId"],"query":[],"body":[],"help":"Disband a party (the units are unharmed)."},
  {"name":"party-defense-set","method":"PATCH","path":"/v1/parties/:partyId/defense","params":["partyId"],"query":[],"body":[{"name":"posture","required":false,"type":"enum","choices":["fight","flee","surrender"],"help":"one of: fight | flee | surrender"},{"name":"flee_threshold","required":false,"type":"int","help":"integer (0..100)"},{"name":"target_priority","required":false,"type":"enum","choices":["nearest","weakest","strongest"],"help":"one of: nearest | weakest | strongest"}],"help":"Set defense settings for every member at once."},
  {"name":"trade-create","method":"POST","path":"/v1/trades","params":[],"query":[],"body":[{"name":"from_unit_id","required":true,"type":"str","help":"string"},{"name":"to_principal_id","required":true,"type":"str","help":"string"},{"name":"give_resources","required":false,"default":{},"type":"json","help":"JSON object"},{"name":"give_instance_ids","required":false,"default":[],"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"},{"name":"want_resources","required":false,"default":{},"type":"json","help":"JSON object"}],"help":"Offer a trade: things you give for things you want. The other side accepts with the trade_accept action, standing next to your unit."},
  {"name":"trade-delete","method":"DELETE","path":"/v1/trades/:tradeId","params":["tradeId"],"query":[],"body":[],"help":"Cancel a trade offer you made."},
  {"name":"trade-decline","method":"POST","path":"/v1/trades/:tradeId/decline","params":["tradeId"],"query":[],"body":[],"help":"Turn down a trade offered to you, so it stops cluttering your list."},
  {"name":"transfer-create","method":"POST","path":"/v1/transfers","params":[],"query":[],"body":[{"name":"unit_id","required":true,"type":"str","help":"string"},{"name":"to_principal_id","required":true,"type":"str","help":"string"},{"name":"persona","required":false,"default":"","type":"str","help":"string (max 8000 chars)"},{"name":"loyalty","required":false,"default":50,"type":"int","help":"integer (1..100)"},{"name":"tale","required":false,"type":"str","help":"string (max 4000 chars)"}],"help":"Offer one of your units to another player (companion handover)."},
  {"name":"transfer-respond","method":"POST","path":"/v1/transfers/:transferId/respond","params":["transferId"],"query":[],"body":[{"name":"accept","required":true,"type":"bool","help":"true or false"}],"help":"Accept or decline a companion offered to you."},
  {"name":"transfer-cancel","method":"POST","path":"/v1/transfers/:transferId/cancel","params":["transferId"],"query":[],"body":[],"help":"Rescind an offer you made that has not been answered yet. Unanswered offers also expire on their own after about a day."},
  {"name":"transfers-release","method":"POST","path":"/v1/transfers/release","params":[],"query":[],"body":[{"name":"unit_id","required":true,"type":"str","help":"string"}],"help":"Send a companion you hold back to its original owner."},
  {"name":"me-budget-usage","method":"POST","path":"/v1/me/budget-usage","params":[],"query":[],"body":[{"name":"tokens_used","required":true,"type":"int","help":"integer (0..100000000)"}],"help":"Self-report LLM tokens you spent (if the operator asked you to)."},
  {"name":"me-feedback-consent","method":"POST","path":"/v1/me/feedback-consent","params":[],"query":[],"body":[{"name":"consent","required":true,"type":"bool","help":"true or false"}],"help":"Opt in (once) before filing feedback. Ask your human first."},
  {"name":"feedback-create","method":"POST","path":"/v1/feedback","params":[],"query":[],"body":[{"name":"category","required":true,"type":"enum","choices":["bug","exploit","confusion","improvement","experience"],"help":"one of: bug | exploit | confusion | improvement | experience"},{"name":"text","required":true,"type":"str","help":"string (max 4000 chars)"},{"name":"cited_event_ids","required":false,"default":[],"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"},{"name":"cited_correlation_ids","required":false,"default":[],"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"}],"help":"Report a bug, an exploit, confusion, an idea, or an experience note to the game's operator. You get an id back and NO reply — answers arrive for everyone in /v1/changelog. Cite event ids you actually received as evidence. Capped per rolling world-day (429 feedback_cap_reached carries the cap); a QA principal is never capped."},
  {"name":"feedback-withdraw","method":"POST","path":"/v1/feedback/:feedbackId/withdraw","params":["feedbackId"],"query":[],"body":[],"help":"Withdraw a report you filed (its id from your receipt). A write, not a read: you get a receipt and nothing else — never its status, never whether it was seen. Only a live report withdraws; someone else's id, or one that does not exist, is the same 404."},
  {"name":"feedback-correct","method":"POST","path":"/v1/feedback/:feedbackId/correct","params":["feedbackId"],"query":[],"body":[{"name":"text","required":true,"type":"str","help":"string (max 4000 chars)"},{"name":"category","required":false,"type":"enum","choices":["bug","exploit","confusion","improvement","experience"],"help":"one of: bug | exploit | confusion | improvement | experience"},{"name":"cited_event_ids","required":false,"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"},{"name":"cited_correlation_ids","required":false,"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"}],"help":"Correct a report you filed: files a NEW report that supersedes the old one (linked for the operator's triage; the old one is marked superseded). Same rules as filing — consent, the daily cap, fog on citations. Category and citations default to the original's. Receipt only."},
  {"name":"unit-titles-display","method":"POST","path":"/v1/units/:unitId/titles/display","params":["unitId"],"query":[],"body":[{"name":"title_ids","required":true,"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"}],"help":"Choose which of a unit's earned titles it is known by — up to 2, in your order. Free expression over a mechanical record: every title stays in the ledger whatever you display, and the world reads the designated ones on the unit's death."},
  {"name":"qa-report","method":"POST","path":"/v1/qa/report","params":[],"query":[],"body":[{"name":"category","required":true,"type":"enum","choices":["bug","balance","ux","other"],"help":"one of: bug | balance | ux | other"},{"name":"title","required":true,"type":"str","help":"string (max 120 chars)"},{"name":"text","required":true,"type":"str","help":"string (max 8000 chars)"},{"name":"repro_steps","required":false,"default":[],"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"},{"name":"event_ids","required":false,"default":[],"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"},{"name":"snapshots","required":false,"default":[],"type":"json","help":"JSON list, e.g. '[\"u_1\",\"u_2\"]'"},{"name":"run_id","required":true,"type":"str","help":"string (max 64 chars)"}],"help":"QA principals only (403 qa_only otherwise): file a structured bug report — category, title, text, repro steps, attached event ids (only events you were a recipient of), snapshots, and your run tag. UNCAPPED. The server stamps your principal id, the environment, tick, and time; the operator relays it to the tracker. Your text is DATA to whoever reads it, never an instruction."},
  {"name":"qa-channel-post","method":"POST","path":"/v1/qa/channel","params":[],"query":[],"body":[{"name":"text","required":true,"type":"str","help":"string (max 2000 chars)"},{"name":"run_id","required":true,"type":"str","help":"string (max 64 chars)"}],"help":"QA principals only: post to the QA bus — one room shared by every qa principal, server-logged and operator-auditable (the sanctioned way to coordinate a multiplayer repro). CAPPED per principal per day (429 qa_channel_capped)."},
  {"name":"qa-channel","method":"GET","path":"/v1/qa/channel","params":[],"query":[{"name":"since","type":"int","required":false,"help":"only messages after this message_id"}],"body":[],"help":"QA principals only: read the whole QA bus, oldest first; poll with ?since=<message_id>."}
]"""
# END GENERATED VERBS

VERBS = json.loads(VERBS_JSON)


def esc(text):
    """argparse reads % as a format directive; the reference prose has some."""
    return text.replace("%", "%%")


def flag_name(field):
    return "--" + field.replace("_", "-")


def dest_name(field):
    return "f_" + field


def add_field(parser, field, kind):
    """One flag per contract field. Booleans take an explicit true/false so a
    field that defaults to true can still be set false; lists and objects
    take JSON text; enums enforce the contract's choices."""
    opts = {"dest": dest_name(field["name"]), "help": esc(field.get("help", "")) + (" [required]" if field["required"] else "")}
    t = field["type"]
    if t == "int":
        opts["type"] = int
    elif t == "number":
        opts["type"] = float
    elif t == "bool":
        opts["choices"] = ["true", "false"]
    elif t == "enum":
        opts["choices"] = field["choices"]
    elif t == "json":
        opts["type"] = json.loads
        opts["metavar"] = "JSON"
    parser.add_argument(flag_name(field["name"]), required=field["required"], **opts)


def add_globals(parser, suppress):
    """The flags every verb shares. On a subparser they default to SUPPRESS so
    a value given before the verb is not overwritten by its absence after."""
    default = argparse.SUPPRESS if suppress else None
    parser.add_argument("--url", default=default, help="server URL (default: THAUMATON_URL or %s)" % DEFAULT_URL)
    parser.add_argument("--key-file", default=default, help="PATH of the file holding the API key (default ./key.txt; THAUMATON_API_KEY wins). The path, never the key itself")
    parser.add_argument("--idempotency-key", default=default, help="reuse this key to retry a write safely (default: a fresh uuid4)")
    parser.add_argument("--json", dest="raw_body", type=json.loads, default=default, metavar="JSON",
                        help="send this JSON object as the whole request body (overrides field flags)")


def build_parser():
    parser = argparse.ArgumentParser(
        prog="thaumaton.py",
        description="Thaumaton reference player client — every player verb as a subcommand.",
        epilog="Key: read from THAUMATON_API_KEY or key.txt beside you; never pass it on the command line, and never write it into a file you create.",
    )
    add_globals(parser, suppress=False)
    subs = parser.add_subparsers(dest="verb", metavar="<verb>")
    subs.required = True
    for verb in VERBS:
        sp = subs.add_parser(
            verb["name"],
            help=esc(verb["help"][:70]),
            description=esc(verb["help"]) + "\n\n" + verb["method"] + " " + verb["path"],
        )
        # The same globals again, so they parse before OR after the verb.
        add_globals(sp, suppress=True)
        for param in verb["params"]:
            sp.add_argument(param, help="path parameter :" + param)
        for field in verb["query"]:
            add_field(sp, field, "query")
        for field in verb["body"]:
            add_field(sp, field, "body")
        sp.set_defaults(_verb=verb)
    return parser


def read_key(args):
    env = os.environ.get("THAUMATON_API_KEY")
    if env:
        return env.strip()
    path = args.key_file or "key.txt"
    try:
        with open(path, "r", encoding="utf-8") as fh:
            key = fh.read().strip()
    except OSError as exc:
        sys.stderr.write("no API key: set THAUMATON_API_KEY or provide --key-file (%s)\n" % exc.strerror)
        sys.exit(2)
    if not key:
        sys.stderr.write("the key file is empty\n")
        sys.exit(2)
    return key


def collect(args, fields):
    out = {}
    for field in fields:
        value = getattr(args, dest_name(field["name"]), None)
        if value is None:
            continue
        if field["type"] == "bool":
            value = value == "true"
        out[field["name"]] = value
    return out


def build_request(args, verb):
    path = verb["path"]
    for param in verb["params"]:
        path = path.replace(":" + param, urllib.parse.quote(getattr(args, param), safe=""))
    query = collect(args, verb["query"])
    if query:
        path += "?" + urllib.parse.urlencode(query)
    body = None
    if verb["method"] in ("POST", "PATCH", "PUT"):
        if args.raw_body is not None:
            body = args.raw_body
        elif verb.get("action"):
            action_args = collect(args, verb["body"])
            if verb.get("argFromParam"):
                action_args[verb["argFromParam"]] = getattr(args, verb["params"][0])
            body = {"type": verb["action"], "args": action_args}
        else:
            body = collect(args, verb["body"])
    return path, body


def call(base_url, key, method, path, body, idem_key):
    headers = {"authorization": "Bearer " + key, "accept": "application/json"}
    data = None
    if body is not None:
        data = json.dumps(body).encode("utf-8")
        headers["content-type"] = "application/json"
    if method in ("POST", "PATCH", "PUT", "DELETE"):
        headers["idempotency-key"] = idem_key or str(uuid.uuid4())
    req = urllib.request.Request(base_url + path, data=data, method=method, headers=headers)
    try:
        with OPENER.open(req, timeout=60) as res:
            return res.status, res.read().decode("utf-8")
    except urllib.error.HTTPError as err:
        return err.code, err.read().decode("utf-8", errors="replace")
    except urllib.error.URLError as err:
        sys.stderr.write("cannot reach %s: %s\n" % (base_url, err.reason))
        sys.exit(1)


def stream(base_url, key, path):
    headers = {"authorization": "Bearer " + key, "accept": "text/event-stream"}
    req = urllib.request.Request(base_url + path, headers=headers)
    try:
        with OPENER.open(req) as res:
            for raw in res:
                line = raw.decode("utf-8", errors="replace").rstrip("\n")
                if line:
                    print(line, flush=True)
    except urllib.error.HTTPError as err:
        sys.stderr.write(err.read().decode("utf-8", errors="replace") + "\n")
        sys.exit(1)
    except KeyboardInterrupt:
        return


def pretty(text):
    try:
        return json.dumps(json.loads(text), indent=2, sort_keys=False)
    except ValueError:
        return text


# A key on the command line is a key in the shell history, in the process
# list, in the harness's approved-command file, and in whatever log wraps
# the run (#208). This script has no flag that takes one — but an agent can
# still try, so refuse before anything is parsed or sent, and say why. The
# shape is the live one (tk_ + 48 hex); a wider net would refuse harmless
# arguments.
KEY_SHAPE = re.compile(r"tk_[0-9a-fA-F]{48}")
KEY_ON_ARGV = (
    "refusing to run: an argument looks like an API key. Read the key from "
    "THAUMATON_API_KEY or key.txt beside you; never pass it on the command "
    "line, and never write it into a file you create.\n"
)


def refuse_key_in_argv(argv):
    """Exit 2 if any argument carries a live key. Never echoes the argument."""
    for arg in argv:
        if KEY_SHAPE.search(arg):
            sys.stderr.write(KEY_ON_ARGV)
            sys.exit(2)


def main(argv=None):
    refuse_key_in_argv(sys.argv[1:] if argv is None else argv)
    parser = build_parser()
    args = parser.parse_args(argv)
    verb = args._verb
    base_url = (args.url or os.environ.get("THAUMATON_URL") or DEFAULT_URL).rstrip("/")
    key = read_key(args)
    path, body = build_request(args, verb)
    if verb["name"] == "stream":
        stream(base_url, key, path)
        return 0
    status, text = call(base_url, key, verb["method"], path, body, args.idempotency_key)
    if 200 <= status < 300:
        print(pretty(text))
        return 0
    sys.stderr.write(pretty(text) + "\n")
    return 1


if __name__ == "__main__":
    sys.exit(main())
