Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
llms.txt server.json tool.json \
charactercheck/source.py charactercheck/registry.py \
charactercheck/ddb_registry.py charactercheck/ddb-config-registry.json \
ECOSYSTEM_CONFORMANCE.md \
ECOSYSTEM_CONFORMANCE.md docs/human-differential-testing.md \
tests/test_input_contract.py tests/test_snapshot.py tests/fixtures-manifest.json
do
grep -Eq "/${required}$" /tmp/charactercheck-sdist-files.txt || {
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## Unreleased

- Convert a second round of reversible live D&D Beyond observations into
fail-closed handlers for base darkvision and walking speed, magical-sleep
immunity, Charmed-save advantage, spell-group healing bonuses, structural
subclass markers, and selected-option ability-based skill bonuses.
- Preserve only the mechanical join for explicitly selected option definitions;
inactive builder catalogs and presentation metadata remain trust-neutral,
while novel selected-option mechanics retain scoped or global uncertainty.
- Prevent individual weapon records from repeating a printed Sap/Push/etc.
property as though it were a learned character mastery.
- Add a privacy-gated A → B → A human differential protocol and a proposed
cross-character corpus for gathering reproducible schema evidence without
treating one character or one UI observation as a universal rule.

## 0.7.0 - 2026-08-02

- Add a deterministic, value-free `table.evaluation/1.0` projection for
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ include llms.txt
include server.json
include tool.json
recursive-include charactercheck *.json *.py
recursive-include docs *.md
recursive-include examples *.json
recursive-include tests *.json *.py
global-exclude __pycache__ *.py[cod]
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ the relationship as `indeterminate`; an exact identical snapshot can still be
persona/non-mechanical delta are `mechanically_unchanged`, not `unchanged`. A
changed mechanical revision that reaches no classifier also falls back to `$`.
Diff reports candidates or uncertainty only and never applies a change.
For controlled D&D Beyond UI research, use the privacy and reversal gates in
[the A → B → A human differential protocol](docs/human-differential-testing.md).

## Trust and canonical field semantics

Expand Down Expand Up @@ -270,6 +272,10 @@ than treating an aggregate state as proof of completeness. See
recovers selected skills, languages, and standard tools even when D&D Beyond
leaves the modifier's `isGranted` flag false. Builder labels and unselected
option catalogs are not retained.
- Reviewed direct facts now include species walking speed and darkvision,
magical-sleep immunity, the closed Charmed-save condition, and numeric
spell-group healing bonuses. They retain handler/source provenance and stay
non-trusted whenever separate omitted source mechanics reach their family.
- Armor and shield AC requires `equipped: true`. `combat.weapons` remains an
inventory view; `combat.active_attacks` is the action-facing view and
includes the 2024 Unarmed Strike. A weapon mastery property is reported
Expand Down
79 changes: 69 additions & 10 deletions charactercheck/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,7 @@
"proficiency:calligraphers-supplies": (["skills"], "tool proficiency"),
"set-base:darkvision": (["senses"], "darkvision distance"),
"set:innate-speed-walking": (["speeds"], "walking speed"),
"set:subclass": ([
"abilities", "ac", "initiative", "hp", "saves", "skills",
"attacks", "weapons", "speeds", "senses", "defenses",
"languages", "proficiency_bonus", "spellcasting", "spell_save_dc",
"spell_attack_bonus", "spell_output", "spell_slots",
"prepared_spells", "resources",
], "subclass selection can affect the static class build"),
"set:subclass": ([], "structural subclass-selection marker"),
}
MAXIMAL = ["unknown — treat all derived values as unverified"]

Expand Down Expand Up @@ -432,7 +426,9 @@ def carried(it):
for m in mods:
st = (m.get("subType") or "").lower()
if m.get("type") == "bonus" and st in SKILLS:
skill_bonus[st] = skill_bonus.get(st, 0) + (m.get("value") or 0)
value = m.get("value")
bonus = value if type(value) is int else am[ABIL[m["statId"]]]
skill_bonus[st] = skill_bonus.get(st, 0) + bonus
cv_skill_bon, cv_skill_prof, cv_skill_abil = {}, {}, {}
for c in cv:
try:
Expand Down Expand Up @@ -462,6 +458,47 @@ def skill(n):

W.update(profs=profs, expertise=expertise, halfprof=halfprof, skill=skill)

# ---- directly encoded movement/defense/conditional modifier facts.
# These are source observations, not encounter adjudication. Closed codes
# keep upstream prose outside agent/snapshot outputs.
normal_speeds = copy.deepcopy(
(((d.get("race") or {}).get("weightSpeeds") or {}).get("normal") or {}))
W["speeds"] = {
key: value for key, value in normal_speeds.items()
if key in ("walk", "fly", "swim", "climb", "burrow")
and type(value) is int and value >= 0
}
W["save_advantages"] = []
W["defenses"] = {"immunities": []}
W["spell_output_modifiers"] = []
for modifier in mods:
handler_id = modifier.get("_handler_id")
provenance = {
"handler_id": handler_id,
"source_bucket": modifier.get("_source_bucket"),
"component_id": modifier.get("componentId"),
}
if handler_id == "speed.walking.base":
W["speeds"]["walk"] = modifier["value"]
elif handler_id == "save.advantage.charmed":
W["save_advantages"].append({
"condition": "charmed",
"timing": "avoid_or_end",
"scope": "all_saving_throws",
"provenance": provenance,
})
elif handler_id == "defense.magical-sleep":
W["defenses"]["immunities"].append({
"effect": "magical_sleep",
"provenance": provenance,
})
elif handler_id == "spell.healing.bonus":
W["spell_output_modifiers"].append({
"spell_group": "healing",
"bonus": modifier["value"],
"provenance": provenance,
})

# ---- initiative
init_bonus = sum(m.get("value") or 0 for m in mods
if m.get("type") == "bonus" and m.get("subType") == "initiative")
Expand Down Expand Up @@ -607,7 +644,10 @@ def acof(it):
"attack_provenance": f"{why} {use:+d} + PB {pb}",
"damage": f"{dice}{use:+d}",
"damage_type": de.get("damageType"),
"properties": sorted(props), "mastery": sorted(ms),
# A mastery property printed on a weapon is not evidence that this
# character learned that mastery. Keep the observed property in
# ``properties``/``mastery_properties_on_weapons`` only.
"properties": sorted(props), "mastery": [],
"offhand_label": bool(re.search(r"off.?hand", cname.get(str(it["id"]), ""), re.I)),
"two_handed": "Two-Handed" in props, "light": "Light" in props,
"loading": "Loading" in props})
Expand Down Expand Up @@ -1065,11 +1105,18 @@ def add(field_id, value, family=None, **kwargs):
add(f"saves.{ability}.bonus", values.get("bonus"), "saves")
add(f"saves.{ability}.proficient", values.get("proficient"), "saves",
authority="source")
add("saves.conditional_advantages",
((report.get("save_modifiers") or {}).get("conditional_advantages")),
"saves", authority="source")
for skill, values in (report.get("skills") or {}).items():
add(f"skills.{skill}.bonus", values.get("bonus"), "skills")
add(f"skills.{skill}.proficiency", values.get("proficiency"), "skills")
add("senses.vision", ((report.get("senses") or {}).get("vision")),
"senses", authority="source")
add("movement.speeds", report.get("speeds"), "speeds",
authority="source")
add("defenses", report.get("defenses"), "defenses",
authority="source")

combat = report.get("combat") or {}
ac = combat.get("ac") or {}
Expand Down Expand Up @@ -1098,6 +1145,7 @@ def add(field_id, value, family=None, **kwargs):
"spellcasting.ability": "spellcasting",
"spellcasting.save_dc": "spell_save_dc",
"spellcasting.attack_bonus": "spell_attack_bonus",
"spellcasting.output_modifiers": "spell_output",
"spellcasting.prepared": "prepared_spells",
"spellcasting.slots.maximum": "spell_slots",
"spellcasting.slots.current": "spell_slots",
Expand All @@ -1115,6 +1163,8 @@ def add(field_id, value, family=None, **kwargs):
add("spellcasting.attack_bonus", spell.get("attack_bonus"),
"spell_attack_bonus", formula=spell.get("provenance"))
add("spellcasting.prepared", spell.get("prepared"), "prepared_spells")
add("spellcasting.output_modifiers", spell.get("output_modifiers"),
"spell_output", authority="source")
add("spellcasting.slots.maximum", spell.get("slots_max"), "spell_slots")
slot_state, slot_findings = _family_assessment(trust, "spell_slots")
if slot_state == "trusted":
Expand Down Expand Up @@ -1242,7 +1292,10 @@ def public_unhandled(record):
("wis", "wisdom"), ("cha", "charisma")]},
"skills": {n: {"bonus": W["skill"](n)[0], "proficiency": W["skill"](n)[1]}
for n in sorted(SKILLS)},
"save_modifiers": {"conditional_advantages": W["save_advantages"]},
"speeds": W["speeds"],
"senses": {"vision": vision(d)},
"defenses": W["defenses"],
"combat": {
"ac": {"value": W["ac"], "provenance": W["ac_prov"]},
"initiative": {"bonus": W["init"], "provenance": W["init_prov"]},
Expand All @@ -1259,6 +1312,7 @@ def public_unhandled(record):
"provenance": W["spell"]["provenance"],
"cantrips": W["cantrips"], "prepared": W["prepared"],
"spell_profiles": W["spell_profiles"],
"output_modifiers": W["spell_output_modifiers"],
"slots_max": W["slots"], "slots_current": W["slots_cur"]}
if W["spell"] else None),
"resources": W["resources"],
Expand Down Expand Up @@ -1951,7 +2005,8 @@ def vision(d):
# never an independent feature-recognition path.
# as arithmetic. Raw modifiers are never a second recognition path.
for modifier in registry.classify_modifiers(d)["applied"]:
if modifier.get("_handler_id") == "sense.darkvision":
if modifier.get("_handler_id") in (
"sense.darkvision", "sense.darkvision.base"):
out.append({"feature": "Darkvision",
"range_ft": modifier.get("value"),
"provenance": f"modifier ({modifier.get('_source_bucket')})"})
Expand All @@ -1978,6 +2033,10 @@ def seatpack_data(d, r, *, for_dm=False, include_persona=False):
"saves": r.get("saves"),
"skills": sk,
"passives": passives,
"save_modifiers": r.get("save_modifiers"),
"speeds": r.get("speeds"),
"senses": r.get("senses"),
"defenses": r.get("defenses"),
"combat": r.get("combat"),
"spellcasting": r.get("spellcasting"),
"resources": r.get("resources"),
Expand Down
Loading