diff --git a/README.md b/README.md index 19db01e..625b6c5 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,14 @@ Le `reach_ft` d'une attaque naturelle (morsure, sabots) n'est **pas** une arme à allonge : il encode l'allonge naturelle de la créature, qui reste un disque. `spell_resistance` (entier), `resistances` (dict type → montant) et `immunities` (liste de types) sont optionnels. -Deux monstres d'exemple sont fournis : gobelin et orc. +Le tableau `carried_items` (optionnel, défaut vide) énumère les **objets +transportés non maniés et non portés serré** — potions, scrolls, outils, +bourses, clés, vivres… — seuls objets volables par la manœuvre « Steal » de +l'APG (voir « Manœuvres de combat »). Les armes maniées vivent dans +`attacks` (elles se prennent par disarm, pas par steal) ; l'équipement porté +serré (armure, sac à dos, vêtements, anneaux) ne peut pas être volé et n'a +pas à être encodé — la CA des JSON reste une valeur finale. Deux monstres +d'exemple sont fournis : gobelin et orc. ### Fiches de personnages (PJ) @@ -318,7 +325,17 @@ uv run pf1e-sim --map data/maps/sample_arena.yaml \ Les armes placeholder des fiches sans dégâts encodés sont omises (notées dans `notes` le cas échéant) ; le champ `source` de chaque fichier pointe -vers la fiche Foundry d'origine. +vers la fiche Foundry d'origine. Les `carried_items` (objets volables par +« Steal ») sont transcrits des inventaires `inventory.items` des fiches +Foundry (esha 13, jeanne 3, misty 9, nairda 7, oni 6, tammara 20), filtrés +conformément à l'APG : armes maniées (déjà dans `attacks`), équipement porté +serré (armures, anneaux, amulettes, capes, ceintures, wayfinders, vêtements, +sacs à dos) et contenants portés exclus. Trois fiches n'ont **aucun** objet +volable : `harvie.json` (fiche sans inventaire), `ierlieth.json` (seul un +« Backpack of Naidra » — sac à dos = porté serré, non volable) et +`esha_mount.json` (monture sans inventaire propre) — listes laissées vides. +Les monstres (`data/monsters/*.json`, issu du bestiaire, sans fiche Foundry +ni inventaire encodé) portent `carried_items` vide. > **`data/pcs/*.json` est la référence canonique des PJ.** Le chargeur > `loaders/foundry.py` est un outil de migration **one-shot et incomplet** : @@ -1504,7 +1521,16 @@ Règles modélisées : trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » : « your target drops one item it is carrying » — `weapon_dropped`, exclue de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou - plus fait tomber **l'arme de l'attaquant**) ; bull_rush → pousse la + plus fait tomber **l'arme de l'attaquant**) ; conformance (SRD « Disarm ») + : « If you successfully disarm your opponent without using a weapon, you + may automatically pick up the item dropped » — un désarmement **réussi** + par un attaquant **sans aucune arme de mêlée utilisable** (tentative à + mains nues) saisit aussitôt l'arme tombée : elle quitte le sol et entre + dans les **objets transportés** du désarmeur (`carried_items`, journalisé + `unarmed disarm pickup`) — simplification documentée : l'arme saisie + n'étant pas une `AttackSpec` du désarmeur, elle est **tenue, pas maniée** + (pas utilisable pour attaquer) ; l'arme reste exclue des attaques de la + victime jusqu'à son propre `retrieve_weapon` ; bull_rush → pousse la cible (direction sélectionnable via `Action.direction`) ; conformance L6 (SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which your attack exceeds your opponent's CMD you can push the target back an @@ -1558,12 +1584,23 @@ Règles modélisées : « Dirty Trick » ; `blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles (liste exacte de l'APG), `nauseated` exclu car hors de - la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le modèle ne porte - aucun objet transporté non tenu : en application de l'APG « Steal » (« Items - held in the hands (such as wielded weapons or wands) also cannot be taken - with the steal maneuver—you must use the disarm combat maneuver instead »), - une réussite ne vole rien (journalisé `nothing stealable — wielded weapons - require disarm`) ; reposition → déplacement de la cible de 5 ft plus + la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le + champ `carried_items` du JSON de combattant porte les **objets transportés + non maniés et non portés serré** (potions, scrolls, outils, bourses, + vivres…) — seuls objets volables selon l'APG « Steal » (« any item that is + neither held nor hidden in a bag or pack [...] Items that are closely worn + (such as armor, backpacks, boots, clothing, or rings) cannot be taken [...] + Items held in the hands (such as wielded weapons or wands) also cannot be + taken with the steal maneuver—you must use the disarm combat maneuver + instead ») : une réussite **transfère un objet** de la liste de la cible + vers celle du voleur (journalisé `took `) ; simplifications + documentées : le moteur prend le **premier** objet de la liste (le choix + RAW de l'objet n'a pas de slot d'action), la main libre obligatoire et le + +5 CMD des objets attachés (capes, armes fourrées, bourses) ne sont pas + modélisés ; si la cible n'a **aucun objet transporté**, une réussite ne + vole rien (journalisé `nothing stealable — wielded weapons + require disarm`) ; les armes maniées (`attacks`) restent insaisissables + par steal — il faut désarmer ; reposition → déplacement de la cible de 5 ft plus « For every 5 by which your attack exceeds your opponent's CMD, you can move the target an additional 5 feet » (APG « Reposition ») — `1 + marge//5` cases le long de la direction choisie (premier voisin libre par @@ -1702,7 +1739,9 @@ Règles modélisées : state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) : - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), - weapon_broken/weapon_destroyed/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack, + weapon_broken/weapon_destroyed/weapon_stolen/weapon_dropped, carried_items + (tuple dans l'ordre de la liste — les objets transportés volables, APG + « Steal »), grappled_with, moved_this_turn, power_attack, name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, diff --git a/data/pcs/esha.json b/data/pcs/esha.json index 1abe6b3..8af5488 100644 --- a/data/pcs/esha.json +++ b/data/pcs/esha.json @@ -104,5 +104,21 @@ ], "spells": [ "Unseen Servant" + ], + "carried_items": [ + + "Arrow", + "Blunt Arrow", + "Dye Arrow", + "Smoke Arrow", + "Tangleshot Arrow", + "Whistling Arrow", + "Thieves' Tools, common", + "Clefs de la loge", + "Badge d'éclaireur", + "Rope", + "Pomme", + "La pomme de Notch", + "Pierre de communication" ] } diff --git a/data/pcs/esha_mount.json b/data/pcs/esha_mount.json index d3f8786..63ee56f 100644 --- a/data/pcs/esha_mount.json +++ b/data/pcs/esha_mount.json @@ -61,5 +61,6 @@ "armor_spell_failure": 0, "resources": {}, "metamagic_feats": [], - "skills": {} + "skills": {}, + "carried_items": [] } diff --git a/data/pcs/harvie.json b/data/pcs/harvie.json index 0158a44..295f56a 100644 --- a/data/pcs/harvie.json +++ b/data/pcs/harvie.json @@ -83,5 +83,6 @@ "spell_slots": {}, "resources": {}, "metamagic_feats": [], - "skills": {} + "skills": {}, + "carried_items": [] } diff --git a/data/pcs/ierlieth.json b/data/pcs/ierlieth.json index e3cd40f..7de7598 100644 --- a/data/pcs/ierlieth.json +++ b/data/pcs/ierlieth.json @@ -82,5 +82,6 @@ "spell_slots": {}, "resources": {}, "metamagic_feats": [], - "skills": {} + "skills": {}, + "carried_items": [] } diff --git a/data/pcs/jeanne.json b/data/pcs/jeanne.json index 7e9be21..61df63b 100644 --- a/data/pcs/jeanne.json +++ b/data/pcs/jeanne.json @@ -80,5 +80,6 @@ "spell_slots": {}, "resources": {}, "metamagic_feats": [], - "skills": {} + "skills": {}, + "carried_items": ["Saddle (riding)", "Feed", "Trail Rations"] } diff --git a/data/pcs/misty.json b/data/pcs/misty.json index 983f93d..3e1b04f 100644 --- a/data/pcs/misty.json +++ b/data/pcs/misty.json @@ -172,5 +172,17 @@ ], "resistances": { "electricity": 5 - } + }, + "carried_items": [ + + "Pouch, Spell Component", + "Tools, Thieves'", + "Thieves' Tools, masterwork", + "Clef de la Loge de Sauerton", + "+1 Mithral Arrow", + "Bouteille de ??? à moitié vide", + "Scroll of Detect Secret Doors", + "Scroll of Cure Light Wounds", + "Wand of Infernal Healing" + ] } diff --git a/data/pcs/nairda.json b/data/pcs/nairda.json index baa23b9..7469943 100644 --- a/data/pcs/nairda.json +++ b/data/pcs/nairda.json @@ -117,5 +117,15 @@ "Ice Slick", "Darkness", "Flurry of Snowballs" + ], + "carried_items": [ + + "Spell Component Pouch", + "Holy symbol (Wood)", + "Arrow", + "Lettres dans une chambre de bandits", + "Clefs de la loge", + "Badge des éclaireurs", + "Potion de soin de Nairda" ] } diff --git a/data/pcs/oni.json b/data/pcs/oni.json index 6f4b1b0..ac3b514 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -126,5 +126,14 @@ "Delay Poison", "Ironskin", "Restoration, Lesser" - ] + ], + "carried_items": [ + + "Arrow", + "Holy symbol (Wood)", + "Clefs de la loge", + "Eh je suis Éclaireuse maintenant", + "Kit, Fishing", + "Alchemist's Fire" + ] } diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index 7f274b8..1c585c5 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -206,5 +206,28 @@ "Delay Poison", "Aid", "Drunkard's Breath" + ], + "carried_items": [ + + "Mess Kit", + "Gunsmith's Kit", + "Holy symbol (Wood)", + "Spell component pouch", + "Powder horn", + "Firearm Bullet", + "Alchemical Cartridge (Paper, Bullet Or Pellet)", + "Black Powder (Dose)", + "Matériaux pour munitions", + "Rope", + "Bouteille d'alcool (poste des bandits)", + "Clefs de la loge (4)", + "Black Powder (Dose) en stock", + "Firearm Bullet en stock", + "Alchemical Cartridge (Paper, Bullet Or Pellet) en stock", + "Alcool de poison d'araignée ogre", + "Badge des éclaireurs", + "Bouteille d'alcool fort (genre plus que du désinfectant)", + "Une médaille de Caiden Cailean", + "Potion of Cure Light Wounds" ] } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 3390f85..e1d8b40 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -854,6 +854,11 @@ class CombatantState: # Runtime weapon theft (not in the frozen data model): weapon ids taken by # steal maneuvers. weapon_stolen: set[str] = field(default_factory=set) + # Runtime carried items (APG "Steal"): mutable copy of + # ``combatant.carried_items``; a successful steal transfers one item from + # the victim's list to the thief's, and an unarmed successful disarm's + # automatic pickup appends the dropped weapon there. + carried_items: list[str] = field(default_factory=list) # Runtime disarmed weapons (not in the frozen data model): weapon ids the # combatant DROPPED on a failed disarm maneuver (SRD Disarm: "your # opponent drops the weapon"). A retrieve_weapon move action picks the @@ -965,6 +970,8 @@ class CombatantState: def __post_init__(self) -> None: if not self.spell_slots_left: self.spell_slots_left = dict(self.combatant.spell_slots) + if not self.carried_items: + self.carried_items = list(self.combatant.carried_items) def spend_resource(self, name: str, n: int = 1) -> bool: """Spend n points of a resource. Returns True if successful, False if insufficient.""" @@ -1655,6 +1662,7 @@ class CombatEngine: "weapon_destroyed": tuple(sorted(state.weapon_destroyed)), "weapon_stolen": tuple(sorted(state.weapon_stolen)), "weapon_dropped": tuple(sorted(state.weapon_dropped)), + "carried_items": tuple(state.carried_items), "grappled_with": state.grappled_with, "moved_this_turn": state.moved_this_turn, "power_attack": state.power_attack, @@ -5639,6 +5647,23 @@ class CombatEngine: f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"CMD {m.target_cmd} -> SUCCESS ({weapon.name} dropped)" ) + # PF1e (SRD Disarm): "If you successfully disarm your opponent + # without using a weapon, you may automatically pick up the item + # dropped." The engine does not track WHICH weapon a maneuver used, + # so "without using a weapon" = the attacker has no usable melee + # weapon at all (a true unarmed attempt). The picked-up item is not + # one of the attacker's AttackSpecs, so it lands in the attacker's + # carried_items (held, not wielded — the attacker cannot swing it; + # documented simplification). + attacker_armed = any( + w.kind == "melee" for w in state.usable_attacks() + ) + if not attacker_armed: + state.carried_items.append(weapon.name) + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"unarmed disarm pickup ({weapon.name} taken)" + ) def _maneuver_bull_rush( self, @@ -5936,15 +5961,31 @@ class CombatEngine: if not m.success: self._maneuver_fail(state, target, "steal", m) return - # PF1e (APG Steal): "Items held in the hands (such as wielded weapons - # or wands) also cannot be taken with the steal maneuver—you must use - # the disarm combat maneuver instead." The model carries no non-held - # items, so every stealable target is held: a success takes nothing. + # PF1e (APG Steal): "This maneuver can be used in melee to take any + # item that is neither held nor hidden in a bag or pack [...] Items + # held in the hands (such as wielded weapons or wands) also cannot be + # taken with the steal maneuver—you must use the disarm combat + # maneuver instead." Only ``carried_items`` (non-wielded, non-closely- + # worn gear) are stealable; weapons live in ``attacks`` and stay + # immune here. The RAW lets the thief choose the item ("You must + # select the item to be taken before the check is made") — the action + # carries no item slot, so the engine takes the FIRST carried item + # (deterministic list order). The RAW "at least one hand free" gate is + # not modeled (the engine has no hand bookkeeping). + if not target.carried_items: + self._log( + f"round {self._current_round} {state.combatant.id}: steal vs " + f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " + f"CMD {m.target_cmd} -> SUCCESS (nothing stealable — wielded " + f"weapons require disarm)" + ) + return + item = target.carried_items.pop(0) + state.carried_items.append(item) self._log( f"round {self._current_round} {state.combatant.id}: steal vs " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " - f"CMD {m.target_cmd} -> SUCCESS (nothing stealable — wielded " - f"weapons require disarm)" + f"CMD {m.target_cmd} -> SUCCESS (took {item})" ) def _maneuver_reposition( diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 4d85a8f..dec2f60 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -242,6 +242,9 @@ def load_combatant(path: Path) -> Combatant: The ``armor_spell_failure`` percent and the ``arcane_caster`` flag (SRD "Arcane Spells and Armor") are plain fields of the schema (defaults ``0`` and ``false``), so their JSON keys load directly as well. + The ``carried_items`` list (APG "Steal" — non-wielded, stealable gear) is + a plain ``tuple[str, ...]`` field of the schema (default empty), so a JSON + ``carried_items`` key loads directly too. """ try: raw = path.read_text(encoding="utf-8") diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 0a44667..cd6b825 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -382,6 +382,18 @@ class Combatant(_FrozenModel): # PF1e two-axis alignment (SRD "Alignment"): law-good axis codes, one of # LG/NG/CG/LN/N/CN/LE/NE/CE. Default "N" (true neutral). alignment: str = "N" + # PF1e (APG "Steal"): carried items that are NEITHER wielded in a hand + # NOR closely worn - potions, scrolls, tools, pouches, keys, rations... + # "This maneuver can be used in melee to take any item that is neither + # held nor hidden in a bag or pack [...] Items that are closely worn + # (such as armor, backpacks, boots, clothing, or rings) cannot be taken + # with this maneuver. Items held in the hands (such as wielded weapons + # or wands) also cannot be taken with the steal maneuver - you must use + # the disarm combat maneuver instead." Wielded weapons live in + # ``attacks``; closely worn gear is not modeled (the AC in the JSON is + # a final value), so this list holds only stealable carried items. + # rules/advanced-players-guide/steal.md + carried_items: tuple[str, ...] = () @field_validator("alignment") @classmethod diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index be3f6a3..6e91aac 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -617,6 +617,75 @@ def test_steal_nothing_to_steal() -> None: assert "nothing stealable" in engine._transcript[-1] +def test_steal_takes_one_carried_item() -> None: + # APG Steal: "If your attack is successful, you may take one item from + # your opponent" — one non-wielded, non-closely-worn carried item moves + # from the victim's list to the thief's (the engine takes the FIRST + # carried item; the RAW item choice has no action slot). The wielded + # sword stays with the victim. + a_spec = make_combatant("a", attack_bonus=2) + b_spec = make_combatant("b").model_copy( + update={"carried_items": ("potion of cure light wounds", "thieves' tools")} + ) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([1, 12], [a, b]) + engine._execute_maneuver(a, b, "steal") + assert b.carried_items == ["thieves' tools"] + assert a.carried_items == ["potion of cure light wounds"] + assert "took potion of cure light wounds" in engine._transcript[-1] + assert engine.weapon_for(b, a) is not None # the sword is untouched + # The observation snapshot exposes the transfer. + assert engine._combatant_snapshot(a)["carried_items"] == ( + "potion of cure light wounds", + ) + assert engine._combatant_snapshot(b)["carried_items"] == ("thieves' tools",) + + +def test_disarmed_actor_recovers_weapon_with_a_move_action() -> None: + # SRD Disarm + "Picking up an item": the dropped sword lies in the + # victim's own square; retrieve_weapon (a move action) restores it and + # the weapon is usable again for a real attack. + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + engine = make_engine([1, 12], [a, b]) + engine._execute_maneuver(a, b, "disarm") + assert b.weapon_dropped == {"b-w"} + assert engine.weapon_for(b, a) is None + engine._retrieve_weapon(b, "b-w") + assert not b.weapon_dropped + assert engine.weapon_for(b, a) is not None + assert "retrieve b-w (restored)" in engine._transcript[-1] + + +def test_unarmed_disarm_success_auto_picks_up_the_weapon() -> None: + # SRD Disarm: "If you successfully disarm your opponent without using a + # weapon, you may automatically pick up the item dropped." a is unarmed + # (no melee weapon at all): the dropped sword lands in a's carried_items + # (held, not wielded) with no retrieve move action spent. + a_spec = make_combatant("a", attack_bonus=2).model_copy(update={"attacks": []}) + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([1, 12], [a, b]) + engine._execute_maneuver(a, b, "disarm") + assert b.weapon_dropped == {"b-w"} + assert a.carried_items == ["sword"] + assert "unarmed disarm pickup (sword taken)" in engine._transcript[-1] + + +def test_armed_disarm_success_does_not_pick_up() -> None: + # The automatic pickup is reserved to unarmed disarms: an armed success + # leaves the weapon on the ground (victim's weapon_dropped only). + a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1)) + b = make_state(make_combatant("b"), "monsters", (1, 2)) + engine = make_engine([1, 12], [a, b]) + engine._execute_maneuver(a, b, "disarm") + assert b.weapon_dropped == {"b-w"} + assert a.carried_items == [] + assert engine._transcript[-1].endswith("SUCCESS (sword dropped)") + + def test_reposition_distance_scales_with_margin() -> None: # APG Reposition: "For every 5 by which your attack exceeds your # opponent's CMD, you can move the target an additional 5 feet."