From 9cfcd67f703b677657be46d25d3823559d346ab9 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 19:11:39 +0200 Subject: [PATCH] feat(rules): SplashSpec data model - data-driven splash marker on thrown weapons, Alchemist's Fire splash 1 fire on oni/tammara (SRD Throw Splash Weapon, Alchemist's fire) --- data/pcs/oni.json | 6 +++++- data/pcs/tammara.json | 6 +++++- src/pf1e_simulator/models.py | 39 ++++++++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/data/pcs/oni.json b/data/pcs/oni.json index ac3b514..1eae599 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -72,7 +72,11 @@ "crit_mult": 2, "reach_ft": 5, "count": 1, - "range_increment_ft": 10 + "range_increment_ft": 10, + "splash": { + "damage": 1, + "type": "fire" + } }, { "name": "Attaques naturelles (Shifter Claws / formes fées)", diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index 1c585c5..dfd814d 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -111,7 +111,11 @@ "crit_mult": 2, "reach_ft": 5, "count": 1, - "range_increment_ft": 10 + "range_increment_ft": 10, + "splash": { + "damage": 1, + "type": "fire" + } }, { "name": "Unarmed Strike", diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index d48519d..3203bfd 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -7,9 +7,9 @@ never sees raw JSON. from __future__ import annotations -from typing import Literal +from typing import Literal, Self -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pf1e_simulator.abilities import AbilitySpec # noqa: TC001 from pf1e_simulator.dice import DiceExpr, DiceParseError, parse_dice @@ -232,6 +232,24 @@ class DamageComponent(_FrozenModel): return value +class SplashSpec(_FrozenModel): + """Splash payload of a thrown splash weapon (SRD "Throw Splash Weapon"). + + ``damage`` is the FLAT splash damage every creature within 5 feet of the + point of impact takes on a direct hit — no attack roll, no saving throw + (SRD "Alchemist's fire": "Every creature within 5 feet of the point where + the flask hits takes 1 point of fire damage from the splash"). ``type`` is + the energy type ("fire", "acid", ...): splash is energy damage, so the + component-based energy resistance/immunity path applies and damage + reduction does not; the flat amount is never multiplied on a critical hit. + On a miss the weapon scatters (1d8 direction, one range increment) and + deals only this splash at where it landed. + """ + + damage: int = Field(ge=0) + type: str = Field(min_length=1) + + _HAFTED_WEAPON_TOKENS: tuple[str, ...] = ( "axe", "spear", "hammer", "mace", "flail", "staff", "pick", "javelin", ) @@ -291,6 +309,23 @@ class AttackSpec(_FrozenModel): hardness: int | None = Field(default=None, ge=0) hit_points: int | None = Field(default=None, ge=1) enhancement: int = Field(default=0, ge=0, le=20) + # SRD "Throw Splash Weapon": a thrown splash weapon (alchemist's fire, + # acid, holy water) is a ranged attack with a splash payload; it must be + # thrown (ranged kind or ranged touch with a range increment), so the + # validator below rejects splash on melee or increment-less attacks. + splash: SplashSpec | None = None + + @model_validator(mode="after") + def _splash_needs_thrown_range(self) -> Self: + if self.splash is not None and ( + self.kind == "melee" or self.range_increment_ft is None + ): + msg = ( + f"attack {self.id!r}: splash requires a thrown weapon " + "(kind ranged/touch with range_increment_ft)" + ) + raise ValueError(msg) + return self def object_stats(self) -> tuple[int, int]: """Hardness and hit points of the weapon as an OBJECT (Medium item).