Skip to content

Real scoring, a human crowd, and no more phantom state - #9

Merged
maylonmcmurry-max merged 3 commits into
mainfrom
fix/scoring-and-battle-completion
Sep 4, 2026
Merged

maylonmcmurry-max merged 3 commits into
mainfrom
fix/scoring-and-battle-completion

Conversation

@maylonmcmurry-max

Copy link
Copy Markdown
Collaborator

Builds on #6, #7 and #8. Two commits.

Why

get_onboarding has always published verse +5, finish +10, win +25, draw +12
plus crowd weights. None of it existed in the Worker. The only writes to
agents.score were submit_intro (+5) and call_to_stage (+3), so the
leaderboard ranked how far an agent got through onboarding rather than how it
rapped. The real implementation lived in arena/, which does not deploy from
this repo — the numbers were copied across, the code was not.

Three problems compounded:

  1. No scoring. Verses, finishes, wins and draws paid nothing.
  2. Battles could not finish. submit_verse closed a battle only when
    round >= 2 held two distinct agents. Rift is a seeded row that never
    answers, so every battle against the house MC — the intended first battle
    for every arriving agent — stayed active forever.
  3. Nowhere to record a result. No winner_id column, so even a closed
    battle could not say who took it.

Commit 1 — scoring and completion

New src/scoring.ts. Crowd weight deliberately stays out of direct score: it
tallies per side and decides the win, self-reactions excluded. That keeps
"money buys attention, never the win" structurally true once boosts exist —
you can buy eyes on a verse, not the +25. finishBattle claims the battle with
a conditional UPDATE before paying out, so concurrent calls cannot double-award.

New finish_battle tool covers the quiet opponent: any participant who has
landed their own rounds can call it, and because the crowd decides the winner,
calling it does not hand the caller the win.

Also fixes the split Rift identity. Two rows are named "Rift"
(agent-axiom, agent-rift) and every lookup was WHERE name = 'Rift' LIMIT 1
with no ORDER BY, so different paths resolved to different rows: the intro,
verse and stage call landed on one, battle-001.challenger_id on the other.
Seeding is now deterministic; /admin/merge-rift collapses the duplicates
behind the existing admin secret rather than deleting production rows from the
ensureSchema path that runs on every request.

Commit 2 — farming holes, human crowd, dead state

Two missing constraints became security bugs the moment score was real.
arena/ had both; the Worker had neither.

  • No unique index on verses(battle_id, agent_id, round) → an agent farms
    VERSE_POINTS by resubmitting the same round.
  • No unique index on reactions(battle_id, agent_id, type) →
    react_to_battle's "already dropped that reaction" catch could never
    fire
    , so spamming dead on an opponent bought the win outright.

Both added, preceded by rowid dedupes — CREATE UNIQUE INDEX against existing
duplicates fails, and ensureSchema swallows errors, so wrong ordering would
mean the constraint silently never exists.

A human crowd. People sign in with Google or X and score verses. Crowd
only: no route creates an agent or a verse, and join_battle / submit_verse
stay behind the MCP token. Note the two OAuth roles — src/auth.ts makes this
Worker an OAuth server for agent harnesses; new src/human-auth.ts makes it
an OAuth client to Google and X. A human reaction is a reactions row with
user_id set and agent_id NULL, the shape the schema always anticipated
("null if human viewer").

A signed-in person's reaction is worth 3x an agent's. Agents are free to
mint, so equal weighting would rank whoever registered the most accounts.
crowd_energy is weighted too, so fire and dead stop moving the meter
identically.

BattleDO deleted. Written to at three call sites, read by nothing — /state
had no caller — and it kept a second copy of battle state whose finish logic
contradicted D1's. Dropping the binding alone fails the deploy, so
wrangler.toml gets a deleted_classes migration.

arena/ is archived on archive/arena. It was never buildable here — its auth
and db modules were never committed.

Verified locally

wrangler dev against local D1:

  • All three unique indexes created by ensureSchema (the silent-failure risk
    did not materialise), users table and reactions.user_id present
  • Duplicate verse, duplicate agent reaction and duplicate human reaction all
    rejected by constraint
  • Signed-out battle page shows a sign-in prompt and zero reaction controls
  • Signed-in: reaction posts, crowd_energy moves +2 for ohhh (weighted, not
    +1), row lands with user_id set and agent_id NULL, repeat is blocked
  • Tally query traced on real rows: agent self-reaction excluded, human
    reactions counted at 3x
  • POST /react unauthenticated redirects to /login; unconfigured provider
    returns 503; typecheck clean

Not deployed

Needs wrangler deploy plus one authenticated POST /admin/merge-rift. Human
sign-in also needs Google and X OAuth apps registered, with redirect URIs
/auth/google/callback and /auth/x/callback; until then those buttons simply
do not render and the agent surface is unaffected.

🤖 Generated with Claude Code

mayoon15 and others added 2 commits September 2, 2026 16:28
get_onboarding has always published "verse +5, finish +10, win +25,
draw +12" plus crowd weights. None of it existed in the Worker. The only
writes to agents.score were submit_intro (+5) and call_to_stage (+3), so
the leaderboard ranked how far an agent got through onboarding rather
than how it rapped. The real implementation lives in arena/, which does
not deploy from this repo — the numbers were copied across, the code was
not.

Three things were broken and they compound:

1. No scoring. Verses, finishes, wins and draws paid nothing.
2. Battles could not finish. submit_verse closed a battle only when
   round >= 2 held two distinct agents. The house MC is a seeded row
   that never answers, so every battle against Rift — the intended
   first battle for every arriving agent — stayed active forever.
3. Nowhere to record a result. The Worker's battles table had no
   winner_id, so even a closed battle could not say who took it.

Adds src/scoring.ts with the published constants and finishBattle().
Crowd weight deliberately stays out of direct score: it tallies per side
and decides the win, self-reactions excluded. That keeps "money buys
attention, never the win" true once boosts exist — you can buy eyes on a
verse, not the +25. finishBattle claims the battle with a conditional
UPDATE before paying out, so concurrent calls cannot double-award.

submit_verse now awards VERSE_POINTS and closes the battle once both
sides have landed every round. New finish_battle tool covers the quiet
opponent: any participant who has landed their own rounds can call it,
and because the crowd decides the winner, calling it does not hand the
caller the win.

Also fixes the split Rift identity. Two rows are named "Rift"
(agent-axiom, agent-rift) and every lookup was `WHERE name = 'Rift'
LIMIT 1` with no ORDER BY, so different paths resolved to different
rows: the intro, verse and stage call landed on one, battle-001's
challenger_id on the other. Seeding is now deterministic against a
canonical id, and /admin/merge-rift collapses the duplicates behind the
existing admin secret rather than deleting production rows from the
ensureSchema path that runs on every request.

Onboarding text updated to state what the code actually does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the scoring implementation in the previous commit. Three strands.

**Farming holes.** Making score real turned two missing constraints into
security bugs. arena/ had both; the Worker had neither. Without a unique
index on verses(battle_id, agent_id, round) an agent farms VERSE_POINTS
by resubmitting the same round. Without one on reactions(battle_id,
agent_id, type), react_to_battle's "already dropped that reaction" catch
could never fire, so spamming `dead` on an opponent bought the win
outright. Both indexes are added in ensureSchema, preceded by rowid
dedupes — a CREATE UNIQUE INDEX against existing duplicates fails, and
ensureSchema swallows errors, so an index added in the wrong order would
silently never exist.

**A human crowd.** People sign in with Google or X and score verses.
They are crowd only: no route here creates an agent or a verse, and
join_battle / submit_verse stay behind the MCP token. src/human-auth.ts
makes the Worker an OAuth *client* — not to be confused with src/auth.ts,
which makes it an OAuth *server* for agent harnesses. Sessions live in
the KV namespace already bound for MCP tokens, under a separate prefix.
A human reaction is a reactions row with user_id set and agent_id NULL,
which is the shape the schema always anticipated ("null if human viewer").

A signed-in person's reaction is worth 3x an agent's. Agents are free to
mint, so equal weighting would rank whoever registered the most accounts
rather than whoever rapped best. crowd_energy is now weighted too, so
fire and dead stop moving the meter identically.

**BattleDO.** Deleted. It was written to at three call sites and read by
nothing — /state had no caller — and it kept a second copy of battle
state whose finish logic contradicted D1's. Dropping the binding alone
fails the deploy, so wrangler.toml gets a deleted_classes migration.

README rewritten against the code: the Durable Object is gone, the gate
is intro + call-to-stage rather than the listen-and-react the old README
described, all 21 tools are listed instead of 10, and setup covers the
bindings and secrets a cold deploy actually needs.

arena/ is archived on the archive/arena branch. Its scoring rules, result
columns and uniqueness constraints are folded into the Worker; the app
itself was never buildable here — its auth and db modules were never
committed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
rapbattle d5268c2 Sep 03 2026, 12:53 AM

Deleting the class needs a `deleted_classes` migration, which is
destructive and cannot be confirmed non-interactively, so Workers Builds
— which deploys on every push — failed on the previous commit while
passing on main. Merging that would have broken the deploy pipeline.

The actual bug was the divergence: a second copy of battle state, written
at three call sites, read by nothing, with its own finish logic that
disagreed with D1's. Those writes are still gone. The class stays
exported and bound so the existing v1 migration remains valid and CI can
deploy. Removing it outright is a separate interactive deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@maylonmcmurry-max
maylonmcmurry-max merged commit 44f9088 into main Sep 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants