Skip to content

fix(combat): #279 allegiance comes from the scene, not the participant bucket - #339

Open
MoonlightByte wants to merge 2 commits into
mainfrom
fix/279-encounter-faction
Open

MoonlightByte wants to merge 2 commits into
mainfrom
fix/279-encounter-faction

Conversation

@MoonlightByte

Copy link
Copy Markdown
Owner

Fixes the combat-side consequences of #279. The origin is upstream and tracked in #337.

What was wrong

createEncounter buckets participants as npcs (carries a character sheet) or monsters
(is a stat block). combat_builder maps that bucket to type, and ensure_combatant_ids
maps type to faction: "npc" becomes "party". Every named villain with a sheet
therefore joined the party. resolver.validate_intent then made that invented table a hard
targeting gate, so Bandit Captain Gorvek was rejected for "cannot attack ally
cmb-player-eirik-vane-1" three times and handed a legalTargets whitelist containing only
his own men. He took it, and the narrator truthfully reported him killing them.

The model had already authored the correct answer. sceneFacts.relations said Gorvek was
hostile to the player, reconciled to exact combatant IDs and persisted by combat_builder.
Nothing in the repository read it, and the T096 payload never showed it back to the actor
agent -- which was shown the invented faction table instead.

faction is assigned once at creation and never mutated anywhere, so the gate also could not
express any mid-combat change of side: charm, domination, surrender and betrayal all invert
under it.

Changes

  1. Show the agent its own facts. sceneFacts.relations and objectives ship in the T096
    payload for typed encounters, with prompt guidance that sceneFacts outranks
    type/faction, that type describes storage rather than side, and that a charmed or
    turncoat combatant keeps whatever faction it was created with.
  2. Delete the same-faction "cannot attack ally" rejection. Remaining legalTargets hints
    are now truthful (living roster minus self).
  3. Correct the side at creation. apply_scene_declared_sides() promotes a participant to
    hostile only when the scene explicitly declares it hostile toward the party side.
    One-directional by design: it can promote, never demote. Demoting an adversary would end a
    fight early; promoting an ally would make all_hostiles_resolved unsatisfiable and hang
    the encounter. The player is never promoted. Dispositions are compared as exact values,
    never pattern-matched; an unrecognised disposition is a no-op.
  4. XP counts hostility, not buckets. utils/xp.py guarded on type == 'enemy'; it now
    guards on is_hostile(). The guard change alone would have made creature['monsterType']
    raise KeyError inside the completion path, so that access is now safe.

The eight factual gates are untouched: unknown actor, actor cannot act, stale stateVersion,
outside the turn window, unknown target, target already down, ability not on sheet, no
ammunition. Code keeps identity, canonical state, ordering and arithmetic; who counts as a
friend goes back to the agent.

Absence-safe throughout. Legacy encounters never reach this resolver
(combat_orchestrator.py:1081 gates on pipelineMode == "agentic"), pre_typed encounters
carry no sceneFacts, and encounters already on disk are never migrated.

Evidence

Deterministic replay of the real captured encounter through the production functions,
61/61 across three harnesses (24 targeting, 28 sides, 9 XP): all five observed
ally-rejections now pass, all eight factual gates still fire, one-directional safety holds,
and legacy / pre_typed / unknown-contract / malformed-relation inputs are all no-ops. The
fight no longer auto-completes with the boss standing, and does complete once he is down.

Live OpenAI T096 A/B, same encounter, same turn, same model:

sceneFacts sent boss friendly-fire
unmodified origin/main no 3 / 4 samples
this branch yes (5 relations, 2 objectives) 0 / 8 samples

3-of-4 on baseline matches the 3-of-6 originally observed in #279. Adversarial live probes: a
charmed bandit (faction still hostile) attacks his own captain, and a turncoat companion
(faction still party) attacks the PC -- both impossible under the old gate. With relations
and objectives both emptied, the agent still does not fall back to type/faction.

Reviewer notes -- please read before merging

Not fixed here

Refs #279, #337. Doctrine: #193 NEQ-COMBAT-02, AP-6, AP-7, NEQ-CORE-01, NEQ-CORE-06,
leanness tests 1-3.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CKDbsAQBJqRUp8QjeqtfvM

MoonlightByte and others added 2 commits September 9, 2026 12:14
…t bucket

Bandit Captain Gorvek fought for the party in his own set-piece. Root cause is
not a mis-typed creature: it is an authority boundary in the wrong place.

createEncounter buckets participants as `npcs` (has a character sheet) or
`monsters` (is a stat block). combat_builder maps that bucket to `type`, and
ensure_combatant_ids maps `type` to `faction` ("npc" -> "party"). Every named
villain with a sheet therefore became a party member. resolver.validate_intent
then made that invented table a hard targeting gate, so the boss was rejected
for "cannot attack ally cmb-player-eirik-vane-1" three times and handed a
legalTargets whitelist containing only his own men. He took it, and the
narrator truthfully reported him killing them.

Meanwhile the model had already authored the correct answer. sceneFacts.relations
said Gorvek was hostile to the player, reconciled to exact combatant IDs and
persisted by combat_builder. Nothing in the repository read it, and the T096
payload never showed it back to the actor agent -- which was shown the invented
faction table instead. The agent was punished for not knowing a fact that was
deliberately withheld from it.

`faction` is assigned once at creation and never mutated anywhere, so the gate
also could not express any mid-combat change of side: charm, domination,
surrender or betrayal all invert under it.

This change:
- sends sceneFacts.relations and objectives in the T096 payload for typed
  encounters, and tells the agent that sceneFacts wins over type/faction;
- deletes the same-faction "cannot attack ally" rejection;
- makes the remaining legalTargets hints truthful (living roster minus self).

The eight factual gates are untouched: unknown actor, actor cannot act, stale
stateVersion, outside the turn window, unknown target, target already down,
ability not on sheet, no ammunition. Code keeps identity, canonical state,
ordering and arithmetic; who counts as a friend goes back to the agent.

Absence-safe. Legacy encounters never reach this resolver (combat_orchestrator
gates on pipelineMode == "agentic"), and pre_typed encounters carry no
sceneFacts, so the payload is unchanged for them.

Evidence, real captured encounter TW05-E1 plus live OpenAI T096 calls:
- deterministic replay of the incident, 24/24 checks, including all five
  observed ally-rejections now allowed and all eight factual gates still firing;
- live A/B on the same encounter and turn: baseline 3/4 samples had the boss
  attacking his own men (matching the observed 3/6); with this change 0/4;
- charm and turncoat scenarios, impossible under the old gate, both resolve
  correctly; with relations and objectives both emptied the agent still does not
  fall back to type/faction.

Known and NOT fixed here: is_hostile() still derives from type/faction, so the
boss is excluded from all_hostiles_resolved and a set-piece can complete with
him alive. Pre-existing, unchanged by this commit, reported on the issue.

Refs #279. Doctrine: #193 NEQ-COMBAT-02, AP-6, NEQ-CORE-06, leanness tests 1-3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKDbsAQBJqRUp8QjeqtfvM
…lity

Follow-on to 8bb9dc7b, which fixed targeting but left three downstream readers
of the same bucket-derived guess.

combat_builder seeds `faction` via ensure_combatant_ids BEFORE the authored
scene is reconciled, then stores the scene and never applies it. The boss was
therefore still seeded "party" even though sceneFacts said he was hostile to the
player, so is_hostile() excluded him from all_hostiles_resolved and a set-piece
could auto-complete at combat_manager.py:4869 with the villain untouched at
full HP; voice_context omitted him from the threat list shown to companions;
and utils/xp.py, keyed on `type == 'enemy'`, awarded nothing for killing him.

apply_scene_declared_sides() promotes a participant to "hostile" only when the
scene explicitly declares it hostile toward somebody on the party side. It is
deliberately one-directional: it can promote, never demote. Demoting an
adversary would end a fight early; promoting an ally would make
all_hostiles_resolved unsatisfiable and hang the encounter, so the only move
available is the one the model stated outright. The player is never promoted.
Dispositions are compared as EXACT values against a small vocabulary, never
scored or pattern-matched, and an unrecognised disposition is a no-op.

utils/xp.py now counts defeated hostiles rather than defeated buckets. A hostile
carrying a character sheet has no stat block, so its CR cannot be resolved; that
awards nothing and warns, rather than inventing a CR from level or HP. The
guard change alone would have made creature['monsterType'] raise KeyError inside
the completion path, so that access is now safe.

Absence-safe throughout: no relations, an unrecognised disposition, a legacy or
pre_typed encounter, or malformed relations all leave the roster untouched, and
encounters already on disk are never migrated.

Evidence, real captured encounter TW05-E1:
- 61/61 deterministic checks across three harnesses (24 targeting, 28 sides,
  9 XP), including one-directional safety, idempotence, dangling-ID and garbage
  relation handling, and legacy/pre_typed/unknown-contract no-ops;
- the fight no longer auto-completes with the boss standing, and does complete
  once he is down;
- mook XP identical before and after (150, unchanged breakdown); the boss is now
  counted as a defeated hostile; a hostile with a resolvable CR awards 200;
- live OpenAI T096, 4 further samples with both fixes: 0/4 friendly fire
  (running total 0/8 with fixes, against 3/4 on unmodified origin/main);
- charm and turncoat scenarios still resolve correctly, and with relations and
  objectives both emptied the agent still does not fall back to type/faction.

KNOWN DATA GAP, not fixed here: The_Thornwood_Watch authored Bandit Captain
Gorvek with no challengeRating (level 1, 12 HP), so defeating him still awards
0 XP. That is a module-generation gap, not a combat gap -- char_schema.json
already allows challengeRating. Reported on the issue for separate triage.

Also unchanged: all_party_resolved at combat_state.py:314 still counts the boss
as party via `type in ("player","npc")`, which faction cannot override. Its
observed effect in this incident was nil and changing that disjunct has legacy
blast radius; left for the #191 epic.

Refs #279. Doctrine: #193 NEQ-COMBAT-02, AP-6, AP-7 (values not prose),
NEQ-CORE-01 (no hang), leanness tests 1-3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CKDbsAQBJqRUp8QjeqtfvM
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.

1 participant