fix(combat): #279 allegiance comes from the scene, not the participant bucket - #339
Open
MoonlightByte wants to merge 2 commits into
Open
MoonlightByte wants to merge 2 commits into
MoonlightByte wants to merge 2 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the combat-side consequences of #279. The origin is upstream and tracked in #337.
What was wrong
createEncounterbuckets participants asnpcs(carries a character sheet) ormonsters(is a stat block).
combat_buildermaps that bucket totype, andensure_combatant_idsmaps
typetofaction:"npc"becomes"party". Every named villain with a sheettherefore joined the party.
resolver.validate_intentthen made that invented table a hardtargeting gate, so Bandit Captain Gorvek was rejected for "cannot attack ally
cmb-player-eirik-vane-1" three times and handed a
legalTargetswhitelist containing onlyhis own men. He took it, and the narrator truthfully reported him killing them.
The model had already authored the correct answer.
sceneFacts.relationssaid Gorvek washostile 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
factiontable instead.factionis assigned once at creation and never mutated anywhere, so the gate also could notexpress any mid-combat change of side: charm, domination, surrender and betrayal all invert
under it.
Changes
sceneFacts.relationsandobjectivesship in the T096payload for typed encounters, with prompt guidance that
sceneFactsoutrankstype/faction, thattypedescribes storage rather than side, and that a charmed orturncoat combatant keeps whatever faction it was created with.
legalTargetshintsare now truthful (living roster minus self).
apply_scene_declared_sides()promotes a participant tohostile 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_resolvedunsatisfiable and hangthe encounter. The player is never promoted. Dispositions are compared as exact values,
never pattern-matched; an unrecognised disposition is a no-op.
utils/xp.pyguarded ontype == 'enemy'; it nowguards on
is_hostile(). The guard change alone would have madecreature['monsterType']raise
KeyErrorinside 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:1081gates onpipelineMode == "agentic"),pre_typedencounterscarry 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. Thefight 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:
origin/main3-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 relationsand objectives both emptied, the agent still does not fall back to
type/faction.Reviewer notes -- please read before merging
platform of record and NEQ-EVIDENCE-04 acceptance items are not satisfied, so by Design Constitution & Plan Review Protocol — CANONICAL source of truth for all design and planning (Claude + Codex; version in body) #193 this
is not a PASS. The deterministic replay used the real native run artifacts.
verbal go-ahead. Change 2 deletes a validation gate on a play path, which is the kind of
change that protocol exists for.
choosing incoherent targets with no deterministic backstop. The argument for removal: the
gate cites no observed failure (
27a25ce2is a one-line commit message), it cannot covercharm/turncoat/surrender so it is a gate not every path passes, and its worst case -- proven
by TW05-E1: Bandit Captain Gorvek is created as type npc / faction party, so the boss attacks his own warriors #279 -- is worse than what it prevents. Legacy ran for months without it.
Not fixed here
combat_builderruns. That is the origin; this PR repairs the consequences.has no stat block and no
challengeRating, so there is nothing to price the kill with.Awarding nothing and warning is deliberate; deriving a CR from level or HP would be code
inventing meaning from missing data. Fixing Semantic validation reclassifies a named hostile as an encounter NPC, inverting the scene #337 routes him through
monster_builder, whichproduces a CR, and this resolves itself.
all_party_resolved(combat_state.py:314) still counts him as party viatype in ("player","npc"), which faction cannot override. Observed effect was nil andchanging that disjunct has legacy blast radius; left for [EPIC][combat] Recover agentic-first combat while preserving the proven legacy player experience #191.
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