diff --git a/CHANGELOG.md b/CHANGELOG.md index 51b1cfa..44b1fa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - [`GrantItem`][osrlib.crawl.commands.GrantItem] can grant a magic item by its catalog id, so a game can sell potions or hand out a magic reward (#173). Each copy arrives unidentified, with a separate instance id in the [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]. The session rolls whatever the item's entry in the OSE SRD leaves to chance, like a wand's charges, on the treasure stream, as it does for a magic item found in a cache. A mundane grant is unchanged. - An adventure document can use `RemoveItem`, `RemoveCoins`, and a `GrantItem` of a magic item as trigger consequences and quest rewards (#173). A `RemoveItem` can name its member with [`CARRIER_SELECTOR`][osrlib.crawl.triggers.CARRIER_SELECTOR] (`"@carrier"`), which the interpreter replaces with the first living member in marching order who carries the item. When nobody does, the interpreter drops the command with a note. A quest reward written this way uses up the quest item when the quest completes. [`validate_adventure`][osrlib.crawl.adventure.validate_adventure] checks an authored `GrantItem`'s or `RemoveItem`'s item id against the equipment and magic item catalogs, and accepts `"@carrier"` only on a `RemoveItem`. - [`Rest`][osrlib.crawl.commands.Rest] has a new field `hidden`, and [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] takes a matching `hidden` parameter, so a game can let the party hide while it rests. The OSE SRD lowers the chance of wandering monsters when the party rests quietly in an out-of-the-way place, and a game sets `hidden` to say the party does. Each wandering-monster check during a hidden rest takes 1 off the chance in 6, after noise and bright light add theirs. On a level with the usual 1 in 6 chance, the session rolls no check during a hidden rest. The field defaults to `False`, so a saved command without it still parses as a plain rest. In town no checks run, so `hidden` changes nothing there. +- Each attack routine has a new field, [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use], that gives when the monster uses the routine. Its value is a [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine is `PRIMARY`. A later routine is `ALTERNATIVE` when the OSE SRD lets the monster use it in place of the first in any round, like a thoul's weapon. It's `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model, like an elephant's trample or a giant's thrown boulder. It's `SPECIAL` when it's a breath, a gaze, or a spell. Every routine in the built-in monster and spell catalogs has its label. A routine with no label is `PRIMARY`, so in a monster template of your own, label each later routine, or the default policy picks it as often as the first. +- A policy of your own can choose which attack routine a monster uses, in the new field [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. The field is an index into the template's `attacks`, and `None` uses the first routine. So your policy can have an elephant trample when your game decides it charges. Battle rolls the routine's attacks as melee attacks, and rolls nothing for a `SPECIAL` routine. An index the template doesn't have makes the action impossible, so the session skips it. ### Changed @@ -27,7 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - A melee attack on a petrified creature hits automatically, as on a paralysed or sleeping one (#166). [`attack_roll`][osrlib.core.combat.attack_roll] reports `combat.attack.auto_hit` and draws no d20, so only the damage is rolled. A missile attack on a petrified creature is still rolled. - [`UseItem`][osrlib.crawl.commands.UseItem] with a potion and a `target_id` that names someone other than the member using it is now refused with `items.use.target_not_allowed`. Before, the session accepted the command and the member in `character_id` drank the potion, so a front end that expected the target to drink got no warning. A `target_id` of `None`, or the id of the member using the potion, drinks the potion as before, and a healing device still takes another member (#174). - A plain [`Rest`][osrlib.crawl.commands.Rest] in a dungeon now checks for wandering monsters at the level's full chance. Before, every rest took 1 off the chance, so a level with the usual 1 in 6 chance never rolled during a rest and resting was always safe. The session still adds 1 for noise and 1 for bright light, and a rest of a night or a day still checks once each turn and stops when monsters come. To rest quietly and take the 1 off, set the new `hidden` field. The `resting` keyword of [`wandering_check`][osrlib.crawl.exploration.wandering_check] still takes 1 off the chance. [`GameSession.advance_turns`][osrlib.crawl.session.GameSession.advance_turns] now sets it from `hidden`, so `advance_turns(resting=True)` alone no longer lowers the chance. It still counts the turns as rest. -- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, or a melee attack on a petrified creature. Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. +- Some command logs recorded under 2.0.1 don't replay under this release. A log that contains a command this release refuses makes [`replay_game`][osrlib.persistence.replay_game] raise [`ContentValidationError`][osrlib.errors.ContentValidationError]. Two commands that 2.0.1 accepted are refused now, so a log that contains one of them raises: a potion `UseItem` aimed at another member, and a temple service that would do nothing. Other logs replay without an error but play out differently. That happens with *sleep* cast at a drained monster, a lair the party left and came back to, a rest in a dungeon, a rest that a wandering monster interrupted, a trap save with a `modifier`, or a chute trap where every member passed the save. In battle, it also happens with a monster that regenerates (a troll, a thoul, or a vampire), a monster whose attack has an effect (like a ghoul's paralysing claw or a wight's level drain), a basilisk's or a medusa's gaze, a melee attack on a petrified creature, or a monster that can choose between attack routines (a spitting cobra, a thoul, a mule, a minotaur, a wererat, or a monster of your own with more than one unlabelled routine). Each of these changes has an entry in this section or under Fixed. Loading a 2.0.1 save with [`load_game`][osrlib.persistence.load_game] isn't affected, because a load restores the saved state and runs no commands. The loaded game then plays on under this release's rules. ### Fixed @@ -37,6 +39,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Trolls, thouls, and vampires regenerate in play (#167). Until this release a spawned one never healed, though its template had the ability. A monster whose template has the `regeneration` ability now starts to regenerate when its encounter opens. That's true whether the monster came from a [`SpawnMonsters`][osrlib.crawl.commands.SpawnMonsters] command, a keyed area, or a wandering-monster check. The command that opened the encounter reports an [`EffectAttachedEvent`][osrlib.core.events.EffectAttachedEvent] for each such monster. A hurt troll gets back 3 hit points a round, starting 3 rounds after it last took damage. It never gets back damage from fire or acid. A troll slain while its encounter is still running gets back up 2d6 rounds later and fights again. It stays dead if fire and acid alone did as much damage as its maximum hit points. The session rolls the revival delay on the effects stream. A thoul or a vampire heals every round while it lives. The session releases the effect when the encounter ends, so a troll that is still dead then stays dead. A game recorded under 2.0.1 that met one of these monsters replays differently under this release. No golden file changed. - Undead that a cleric turns now flee and attack no one, even when the cleric turns only some of a group (#168). Before, the turned members of a partly turned group kept closing and attacking, the cleric who turned them included. Now they break away as a new group, which runs from the battle the way a wholly turned group does. The session reports the split with a [`MonsterFledEvent`][osrlib.crawl.events.MonsterFledEvent] whose `group_id` is the new group's id. The members who weren't turned stay in the original group and fight on, and a policy you registered for that group still chooses their actions. The group's shared treasure stays with the members who fight on, and each turned member takes only the treasure it carries. A turned monster that can't move, such as one caught in a *web*, stays where it is and does nothing. The session skips any action a policy returns for a turned monster. A wholly turned group behaves as before, and no golden file changed. - When a monster's attack hits in battle, the session applies the effects the attack lists (#167). A ghoul's or a thoul's claw paralyses for 2d4 turns unless the target saves versus paralysis. An elf isn't affected. A carcass crawler's tentacle and a gelatinous cube's touch paralyse the same way, and they affect elves too. A poisonous bite or sting kills unless the target saves versus death. The target dies at once, or when the poison's onset runs out if the monster's poison has one. The session records the death as a poison death, so *neutralize poison* can revive the character. A mummy's touch gives mummy rot, with no save. A wight's, a wraith's, a spectre's, or a vampire's touch drains levels, also with no save. A basilisk's bite and a cockatrice's beak turn the target to stone unless it saves versus petrification. The basilisk's and the medusa's petrifying gaze reaches each character in the party's front rank once a round while the gazer is in melee. The gaze takes no attack roll, and the session no longer rolls the basilisk's gaze as an attack. A character with the `averted_eyes` condition doesn't meet the gaze, but no command lets a member avert their eyes yet. So in play, every member in the front rank meets the gaze. The adaptations register lists the attack effects that the session leaves to the referee. In a battle against one of these monsters, the session now makes more draws from the combat, effects, and advancement streams. So the same seed and commands give a different battle than they gave under 2.0.1. No golden file changed. +- Battle uses every attack routine a monster has. Before, it used only the first routine in the monster's stat block, so a thoul never used a weapon, a mule never bit, and an elephant never trampled, and an effect in a later routine never applied. Now the default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picks a routine for each melee attack, with equal chance among the first routine and the alternatives the OSE SRD offers. The spitting cobra spits or bites, and its bite poisons. The thoul claws or swings a weapon, the mule kicks or bites, the minotaur gores and bites or swings a weapon, and the wererat bites or uses a weapon. The policy never picks a situational routine, like an elephant's trample. A policy of your own can name one, and battle then rolls it as a melee attack. Battle never rolls a breath, a gaze, or a spell as an attack, even when a policy names one. The policy draws the pick from the monster-action stream, and only for a monster that has a choice. Every other monster draws as before. - A trap's saving throw now adds the `modifier` of the trap's [`SaveSpec`][osrlib.core.spells.SaveSpec], as a spell's save does. Before, the session left the modifier out, so a trap you wrote with a bonus or a penalty on its save played as if it had none. The modifier changes whether the save passes, and the dice the session rolls are the same as before (#167). - Lighting the last oil flask in a pile on the floor with [`LightSource`][osrlib.crawl.commands.LightSource] now leaves the pile when gems, jewellery, or magic items are still in it. Before, the session removed the pile once it had no ordinary items and no coins, which deleted any gems, jewellery, or magic items still in it (#167). - When a breath weapon or *disintegrate* kills a party member, the magic items that make their saving throw now go to a drop pile on the party's cell in the same command. The party picks them up with [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] and the feature id `"pile"`. Before, the items stayed in the dead member's inventory, and the commands that hand over or drop an item refuse a dead member, so the party couldn't recover them. [`EquipmentDestroyedEvent`][osrlib.core.events.EquipmentDestroyedEvent]`.saved_items` still lists them, and the draws are unchanged (#167). diff --git a/docs/adaptations.md b/docs/adaptations.md index db2ff5f..148f506 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -164,6 +164,8 @@ Spawned HP is the sum of the HD count in d8s (d4 for ½ HD) plus the signed modi `No hit roll required` AC means attacks auto-hit, and `Varies` morale compiles to none. Packed-variant pages expand to concrete entries under enumerated ids, because a frozen template must be spawnable. `undead` auto-tags from the standard verbatim bullet with the shadow excluded ("incorporeal (but not undead)"), `person` comes from the `General.md` §Persons list (the SRD's default for a rule otherwise left to the referee's judgment), and `enchanted` is hand-curated via overrides with a reason per entry. Embedded XP values are authoritative, compound alignments compile to an options model, and leader or chieftain XP compiles to structured notes with stats left as prose. Locked by `test_monster_data.py`. +Every attack routine carries a `use` label. The first is `primary`. A later routine whose attacks are a breath, a gaze, or a spell is `special` by name, and every other later routine is labelled by hand from the monster's text: `alternative` where the SRD's "or" is a free choice each round, and `situational` where the text ties the routine to a range, a chance, water, a ship, a form, or a loadout. The compiler labels the routines after the overrides apply, so the bone golem's override-split routines are labelled too, and it fails the build on a later routine with no label, naming the monster. The stat blocks printed on spell pages go through the same labelling. Locked by `test_attack_routines.py::TestTheCatalog` and `TestTheCompilerRefusesAnUnlabelledRoutine`. + ### The event discriminator is `event_type`, with outcome-bearing codes Every kernel event class declares a single-valued `event_type` Literal (snake_case, schema-stable, additive-only) that the `KernelEvent` union discriminates on. The message `code` stays free to be outcome-bearing from a per-class closed set (`combat.attack.hit` and `combat.attack.missed`), so formatters select on codes while consumers discriminate on `event_type` and skip unknown values mechanically. The default English formatter is total: unknown codes format to the code string itself. Locked by `test_events_kernel.py`. @@ -384,7 +386,7 @@ An HD-budget spell needs no footprint to aim at a group. A `cast` in an `hd_budg ### The default action policy and morale -The policy draws only from the monster-action stream. Monsters with a scripted pattern in their data follow it: an `uses_per_day` breath weapon opens with breath, then breath or melee with equal chance while daily uses remain (the dragons, RAW), and the engine rolls a `per_round_chance_in_six` gate each round (the hellhound). Otherwise a group beyond 5' closes and at 5' melees. Monster missile routines lack structured range data and stay close-then-melee (pinned), and groups never cast (casting tags are `manual` data). Morale is auto-invoked at the start of each monster block through a per-battle tracker. The triggers are first death and half incapacitated. A monster's alternate morale score applies when the round meets its condition (fear of fire when the round's damage included fire), and the spell morale modifier applies per the cumulative-effects rule. ML 2 groups rout when battle starts, or as they join a battle already running (none exist in the compiled data, so both the battle-start branch and the join branch are test-forced). A failed check means retreat exposure that round, then flight at full speed, leaving the battle past 120' (the pursuit sight figure, symmetric) as routed. A fled group stays in the player view's `groups`, in order, with `fled` set. The session refuses every declaration aimed at it with `battle.declaration.unknown_group`: an attack, a `close` move, a thrown item, a device, or a spell. The party can still declare against a group that is running but still inside 120', because it can chase the group or shoot at it. A cast aimed at a group that flees earlier in the same round fizzles at the magic phase's re-check (see [The battle round](#the-battle-round)). Chasing routed monsters is left to the game (an evasion procedure with roles swapped, deferred). A group whose living members are all turned or afraid retreats as a whole, and individually turned members of a mixed group don't act (pinned). Locked by `test_battle.py::TestDefaultPolicy` and `TestMoraleAndEnds`, for a group that joins by `test_spawn_into_encounter.py::TestAGroupThatWillNotFight`, and for a fled group by `test_battle_player_view.py::TestAFledGroup`. +The policy draws only from the monster-action stream. Monsters with a scripted pattern in their data follow it: an `uses_per_day` breath weapon opens with breath, then breath or melee with equal chance while daily uses remain (the dragons, RAW), and the engine rolls a `per_round_chance_in_six` gate each round (the hellhound). Otherwise a group beyond 5' closes and at 5' melees. A monster that melees picks its target, then one of its attack routines, with equal chance among the routines whose `use` is `primary` or `alternative`: the thoul claws or swings a weapon, the mule kicks or bites, and the spitting cobra spits or bites. A monster with one such routine draws nothing for the pick. The policy never picks a `situational` routine, which the SRD ties to a situation osrlib doesn't model: a range, a charge or a trample chance, water, a ship, a form, or a loadout. A game's own policy names a situational routine in `MonsterAction.routine` when the game decides the situation applies, and battle rolls it as melee. So the default policy never rolls the 3-in-4 trample chance of the elephant and its kin or the cave locust's 50% jump (pinned). A `special` routine, which is a breath, a gaze, or a spell, rolls nothing even when a policy names it, and battle never rolls an entry named `gaze` or `breath` as an attack. A confused monster that attacks uses its first routine (pinned). Monster missile attacks lack structured range data and stay close-then-melee, so the spitting cobra spits from 5' (pinned). A thrown missile in a later routine, like a giant's boulder, is a situational routine. Groups never cast (casting tags are `manual` data). Morale is auto-invoked at the start of each monster block through a per-battle tracker. The triggers are first death and half incapacitated. A monster's alternate morale score applies when the round meets its condition (fear of fire when the round's damage included fire), and the spell morale modifier applies per the cumulative-effects rule. ML 2 groups rout when battle starts, or as they join a battle already running (none exist in the compiled data, so both the battle-start branch and the join branch are test-forced). A failed check means retreat exposure that round, then flight at full speed, leaving the battle past 120' (the pursuit sight figure, symmetric) as routed. A fled group stays in the player view's `groups`, in order, with `fled` set. The session refuses every declaration aimed at it with `battle.declaration.unknown_group`: an attack, a `close` move, a thrown item, a device, or a spell. The party can still declare against a group that is running but still inside 120', because it can chase the group or shoot at it. A cast aimed at a group that flees earlier in the same round fizzles at the magic phase's re-check (see [The battle round](#the-battle-round)). Chasing routed monsters is left to the game (an evasion procedure with roles swapped, deferred). A group whose living members are all turned or afraid retreats as a whole, and individually turned members of a mixed group don't act (pinned). Locked by `test_battle.py::TestDefaultPolicy` and `TestMoraleAndEnds`, for the routine pick by `test_attack_routines.py`, for a group that joins by `test_spawn_into_encounter.py::TestAGroupThatWillNotFight`, and for a fled group by `test_battle_player_view.py::TestAFledGroup`. ### Battle applies five kinds of monster attack effect and the petrifying gaze, and leaves the rest to the referee @@ -392,7 +394,7 @@ When a monster's attack hits in battle, the session applies each effect the atta The session runs the basilisk's and the medusa's petrifying gaze through `resolve_gaze` once a round while the gazer's group is at 5', before the group acts. The gaze reaches the party's front rank and takes no attack roll. The session never rolls the basilisk's `gaze` attack entry as an attack. `resolve_gaze` spares a creature with the `averted_eyes` condition, but no command gives a member that condition, so in play every member in the front rank meets the gaze. The SRD's penalties for fighting with averted eyes (−4 to the member's attacks and +2 to the basilisk's) aren't applied. A gazer that surprises the party from beyond 5' doesn't gaze in its free round, though the SRD says characters a basilisk surprises meet its gaze. Mirrors and the medusa's disguise are left to the referee. -Battle uses only a monster's first attack routine, so an effect in a later routine never applies. The spitting cobra's poison bite and the vampire's gaze are two such effects. +An effect in a later attack routine applies when battle resolves that routine. The spitting cobra's poison bite is an alternative routine, so under the default policy the cobra bites in about half the rounds it fights. The vampire's charming gaze is a special routine, which battle never rolls, so the referee applies it. The session doesn't apply these attack effects and gazes, so the referee applies them from the monster's description: diff --git a/docs/reference/rng-streams.md b/docs/reference/rng-streams.md index 701b21d..d95a6d0 100644 --- a/docs/reference/rng-streams.md +++ b/docs/reference/rng-streams.md @@ -21,7 +21,7 @@ The table below lists every key, the constant for it, and what it governs. | `"wandering"` | [`WANDERING_STREAM`][osrlib.crawl.session.WANDERING_STREAM] | Wandering monsters: the wandering check, group counts, variant picks. | | `"encounter"` | [`ENCOUNTER_STREAM`][osrlib.crawl.session.ENCOUNTER_STREAM] | Encounter setup: surprise, distance, reaction rolls, distraction, and group counts for monsters or NPC parties spawned by command. | | `"exploration"` | [`EXPLORATION_STREAM`][osrlib.crawl.session.EXPLORATION_STREAM] | Exploration: forcing doors, listening, searching, traps, tinder, and thief skill checks. | -| `"monster_action"` | [`MONSTER_ACTION_STREAM`][osrlib.crawl.session.MONSTER_ACTION_STREAM] | Monster action policy during battle: which action a monster group takes each round and which target it picks. | +| `"monster_action"` | [`MONSTER_ACTION_STREAM`][osrlib.crawl.session.MONSTER_ACTION_STREAM] | Monster action policy during battle: which action a monster group takes each round, which target it picks, and which attack routine a monster uses when it has a free choice. | | `"adjudication"` | [`ADJUDICATION_STREAM`][osrlib.crawl.session.ADJUDICATION_STREAM] | The referee's ad-hoc adjudication rolls: freeform dice commanded through the seeded session. | ## Character streams @@ -62,7 +62,7 @@ The [`ENCOUNTER_STREAM`][osrlib.crawl.session.ENCOUNTER_STREAM] (key `"encounter The [`EXPLORATION_STREAM`][osrlib.crawl.session.EXPLORATION_STREAM] (key `"exploration"`) covers the rest of dungeon exploration: forcing doors, listening at doors, searching for secret doors or traps, disarming or triggering traps, lighting tinder, and thief skill checks like picking locks. -The [`MONSTER_ACTION_STREAM`][osrlib.crawl.session.MONSTER_ACTION_STREAM] (key `"monster_action"`) covers the action policy that drives monster groups during battle: which action a group takes on its turn and which target it selects. Because the policy has a separate stream, swapping in a different monster action policy never shifts the combat stream's attack and damage rolls. +The [`MONSTER_ACTION_STREAM`][osrlib.crawl.session.MONSTER_ACTION_STREAM] (key `"monster_action"`) covers the action policy that drives monster groups during battle: which action a group takes on its turn, which target it selects, and which attack routine a monster uses when its stat block offers a free choice. A monster with only one routine to choose from makes no draw for it. Because the policy has a separate stream, swapping in a different monster action policy never shifts the combat stream's attack and damage rolls. The [`ADJUDICATION_STREAM`][osrlib.crawl.session.ADJUDICATION_STREAM] (key `"adjudication"`) covers the referee's ad-hoc rolls for freeform adjudication: dice commanded through the seeded session to resolve a chance outcome the content model can't express, like whether a frayed rope holds. Because these rolls have a separate stream, an ad-hoc referee roll never changes the draws of any other stream. The outcomes of encounters, combat, and exploration stay the same under replay, however many adjudication rolls the referee makes in between them. diff --git a/src/osrlib/core/monsters.py b/src/osrlib/core/monsters.py index 921ab65..77e453b 100644 --- a/src/osrlib/core/monsters.py +++ b/src/osrlib/core/monsters.py @@ -20,7 +20,8 @@ points, and returns a [`MonsterInstance`][osrlib.core.monsters.MonsterInstance]. - The template's fields contain the rest of the stat block as models you read: [`MonsterHitDice`][osrlib.core.monsters.MonsterHitDice], - [`AttackRoutine`][osrlib.core.monsters.AttackRoutine] and + [`AttackRoutine`][osrlib.core.monsters.AttackRoutine] with + [`RoutineUse`][osrlib.core.monsters.RoutineUse], and [`MonsterAttack`][osrlib.core.monsters.MonsterAttack], [`MovementMode`][osrlib.core.monsters.MovementMode], [`MonsterSaves`][osrlib.core.monsters.MonsterSaves], @@ -118,6 +119,7 @@ "MovementMode", "NumberAppearing", "NumberAppearingValue", + "RoutineUse", "TreasureRef", "XpNote", "spawn_monster", @@ -392,12 +394,52 @@ def _damage_must_parse(cls, value: str | None) -> str | None: return value +class RoutineUse(StrEnum): + """When a monster uses one of its attack routines, as the OSE SRD's stat block and description put it. + + Read it as [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use]. The first routine of + every template is `PRIMARY`. Each later routine has one of the other three values, which tell a + policy whether it may pick the routine in a round of melee. The default policy, + [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picks with equal chance among a + monster's `PRIMARY` and `ALTERNATIVE` routines. Your own policy can name any routine by its + index in [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. + """ + + PRIMARY = "primary" + """The first routine the stat block prints, which battle resolves when a policy names no routine.""" + + ALTERNATIVE = "alternative" + """A routine the monster may use in place of the first in any round, as the SRD's "or" offers. + + A thoul claws or uses a weapon, and a mule kicks or bites. The default policy picks it as often + as the first routine. + """ + + SITUATIONAL = "situational" + """A routine the SRD ties to a situation osrlib doesn't model, so the default policy never picks it. + + The situation is a range, a charge, a chance to trample, water, a ship, a form, or a loadout: + an elephant's trample, a giant's thrown boulder, a sperm whale's ram against a ship. A policy of + your own can name the routine when your game decides the situation applies, and battle then + rolls its attacks as melee attacks. + """ + + SPECIAL = "special" + """A routine battle never rolls as an attack: a breath, a gaze, or a spell. + + A dragon breathes through a `"breath"` action, a petrifying gaze runs once a round before its + group acts, and the rest are left to the referee. Naming this routine in a `"melee"` action + makes the monster roll nothing that round. + """ + + class AttackRoutine(BaseModel): """One set of attacks a monster can make in a round, chosen as a whole. Read them from [`MonsterTemplate.attacks`][osrlib.core.monsters.MonsterTemplate.attacks]. Most creatures have one. A creature with more than one uses one of them in a round, not all of them: - a dragon either claws and bites or breathes. You or the referee decide which. Frozen. + a dragon either claws and bites or breathes. The routine's `use` says when the SRD lets the + monster use it, and an action policy picks one each round. Frozen. """ model_config = ConfigDict(frozen=True) @@ -405,6 +447,15 @@ class AttackRoutine(BaseModel): attacks: tuple[MonsterAttack, ...] = Field(min_length=1) """The attacks the routine makes, at least one. See [`MonsterAttack`][osrlib.core.monsters.MonsterAttack].""" + use: RoutineUse = RoutineUse.PRIMARY + """When the monster uses this routine. See [`RoutineUse`][osrlib.core.monsters.RoutineUse]. + + The first routine of a template is `PRIMARY`. Every later routine in the built-in catalogs + carries one of the other labels, taken from the OSE SRD's text for that monster. In a template + of your own, label each later routine: one you leave unlabelled is `PRIMARY` too, so the default + policy picks it as often as the first. + """ + class MovementMode(BaseModel): """One way a monster moves, and how fast. diff --git a/src/osrlib/crawl/battle.py b/src/osrlib/crawl/battle.py index d368a6c..7508120 100644 --- a/src/osrlib/crawl/battle.py +++ b/src/osrlib/crawl/battle.py @@ -231,7 +231,7 @@ WeaponQuality, magic_item_template, ) -from osrlib.core.monsters import MonsterAbility, MonsterAttack, MonsterInstance +from osrlib.core.monsters import MonsterAbility, MonsterAttack, MonsterInstance, RoutineUse from osrlib.core.spells import ( MAGIC_STREAM, cast_spell, @@ -391,6 +391,15 @@ class MonsterAction(BaseModel): """Which mode of that spell to use, for `"npc_cast"`.""" item_id: str | None = None """The instance id of the potion to drink, for `"npc_drink"`.""" + routine: int | None = None + """Which of the monster's attack routines a `"melee"` action uses, as an index into its template's + [`attacks`][osrlib.core.monsters.MonsterTemplate.attacks]. None uses the first routine. + + Any routine can be named, whatever its + [`use`][osrlib.core.monsters.AttackRoutine.use]. Battle rolls the attacks of a `PRIMARY`, + `ALTERNATIVE`, or `SITUATIONAL` routine, and rolls nothing for a `SPECIAL` one. An index the + template doesn't have, a negative one included, makes the action impossible, so the session + skips it. An NPC adventurer fights with its weapon and ignores this field.""" class ActionPolicy(Protocol): @@ -526,6 +535,13 @@ class ScriptedPolicy: picks a target at random from the party's front rank, leaving out invisible members and members whose *protection from evil* bars that monster. When no target is left, the monster holds. + The monster then picks which of its attack routines to use, with equal chance among the routines + whose [`use`][osrlib.core.monsters.AttackRoutine.use] is `PRIMARY` or `ALTERNATIVE`, and names + it in [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine]. A thoul claws or + swings a weapon, and a mule kicks or bites. The policy never picks a `SITUATIONAL` or `SPECIAL` + routine, such as an elephant's trample or a vampire's gaze. A monster with only one routine to + pick from draws nothing for it. + The monster data has no ranges for monsters' missile attacks, so osrlib treats them like melee: the monster closes first, then attacks. This is an adaptation, a place where osrlib commits to one reading of a rule the SRD leaves open. The @@ -534,8 +550,9 @@ class ScriptedPolicy: spellcasting, which is the referee's call. Write a policy of your own when you want a group to hold a chokepoint, focus its attacks, back - off, or cast. To keep these tactics and change one thing, wrap this policy and change the - actions its `choose` returns, as the example does. + off, cast, or use a situational routine when your game decides its situation applies. To keep + these tactics and change one thing, wrap this policy and change the actions its `choose` + returns, as the example does. [`NpcPartyPolicy`][osrlib.crawl.battle.NpcPartyPolicy] is the built-in policy for NPC adventurers, which also heals, drinks potions, casts, and shoots. @@ -653,7 +670,8 @@ def choose(self, session, group, stream) -> list[MonsterAction]: actions.append(MonsterAction(monster_id=monster.id, kind="hold")) continue target = targets[stream.randbelow(len(targets))] - actions.append(MonsterAction(monster_id=monster.id, kind="melee", target_id=target.id)) + routine = _pick_routine(monster, stream) + actions.append(MonsterAction(monster_id=monster.id, kind="melee", target_id=target.id, routine=routine)) return actions @@ -871,6 +889,24 @@ def _npc_wielded(npc, *, missile: bool, distance_feet: int = MELEE_RANGE_FEET): return None +_PICKED_ROUTINE_USES = frozenset({RoutineUse.PRIMARY, RoutineUse.ALTERNATIVE}) +"""The routines `ScriptedPolicy` picks among. A situational or special routine is left to a game's own policy.""" + + +def _pick_routine(monster: MonsterInstance, stream) -> int | None: + """Pick the routine a melee action uses, with equal chance among the monster's primary and alternative routines. + + A monster with one such routine draws nothing and gets None, which resolves its first routine, so + the stream advances only for a monster that has a choice to make. + """ + candidates = [ + index for index, routine in enumerate(monster.template.attacks) if routine.use in _PICKED_ROUTINE_USES + ] + if len(candidates) < 2: + return None + return candidates[stream.randbelow(len(candidates))] + + def _breath_usable(monster: MonsterInstance, distance_feet: int) -> bool: if validate_breath(monster): return False @@ -2660,7 +2696,11 @@ def _monster_block( _resolve_npc_attack(session, monster, target, group, missile=False, retreating=party_retreating) ) else: - events.extend(_resolve_monster_melee(session, monster, target, party_retreating=party_retreating)) + events.extend( + _resolve_monster_melee( + session, monster, target, party_retreating=party_retreating, routine=action.routine + ) + ) _watch_disruption(events, pending_casters, disrupted, acted) elif action.kind == "npc_shoot": target = session.registry().get(action.target_id) @@ -2989,18 +3029,31 @@ def _behaviour_outcome(params, total: int) -> str: return "no_action" -def _resolve_monster_melee(session, monster, target, *, party_retreating: bool) -> list[Event]: - """Resolve a monster's attack routine against one target, with what each hit does beyond damage. +_UNROLLED_ATTACK_NAMES = frozenset({"gaze", "breath"}) +"""Attack entries battle never rolls as attacks, whichever routine holds them. + +A petrifying gaze runs through `_resolve_gazes` once a round, a breath weapon through a `"breath"` +action, and the rest are left to the referee. +""" + + +def _resolve_monster_melee( + session, monster, target, *, party_retreating: bool, routine: int | None = None +) -> list[Event]: + """Resolve one of a monster's attack routines against one target, with what each hit does beyond damage. - An attack named `gaze` takes no attack roll, so this function skips it. `_resolve_gazes` runs a - petrifying gaze once a round instead. + `routine` is an index into the template's `attacks`, and None resolves the first routine, as a + confused monster's attack does. An index the template doesn't have, a negative one included, + resolves nothing, so the session skips the action. A `SPECIAL` routine resolves nothing either, + and an attack named `gaze` or `breath` takes no attack roll in any routine. """ events: list[Event] = [] - routine = monster.template.attacks[0] if monster.template.attacks else None - if routine is None: + routines = monster.template.attacks + index = 0 if routine is None else routine + if not 0 <= index < len(routines) or routines[index].use is RoutineUse.SPECIAL: return [] - for attack in routine.attacks: - if attack.name == "gaze": + for attack in routines[index].attacks: + if attack.name in _UNROLLED_ATTACK_NAMES: continue for _ in range(attack.count): if has_condition(target, Condition.DEAD): diff --git a/src/osrlib/data/monsters.json b/src/osrlib/data/monsters.json index 8d0cd3e..9ca8dcf 100644 --- a/src/osrlib/data/monsters.json +++ b/src/osrlib/data/monsters.json @@ -182,7 +182,8 @@ "fixed_damage_options": [], "name": "mace" } - ] + ], + "use": "primary" } ], "categories": [ @@ -307,7 +308,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -434,7 +436,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -561,7 +564,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -712,7 +716,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [ @@ -827,7 +832,8 @@ "fixed_damage_options": [], "name": "claw" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -841,7 +847,8 @@ "fixed_damage_options": [], "name": "thrown rock" } - ] + ], + "use": "situational" } ], "categories": [], @@ -956,7 +963,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -1108,7 +1116,8 @@ "fixed_damage_options": [], "name": "gaze" } - ] + ], + "use": "primary" } ], "categories": [], @@ -1217,7 +1226,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -1347,7 +1357,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -1532,7 +1543,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -1546,7 +1558,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -1686,7 +1699,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [], @@ -1801,7 +1815,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -1922,7 +1937,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -2107,7 +2123,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -2121,7 +2138,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -2225,7 +2243,8 @@ "fixed_damage_options": [], "name": "tusk" } - ] + ], + "use": "primary" } ], "categories": [], @@ -2367,7 +2386,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -2381,7 +2401,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "situational" } ], "categories": [ @@ -2548,7 +2569,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -2689,7 +2711,8 @@ "fixed_damage_options": [], "name": "fist" } - ] + ], + "use": "primary" } ], "categories": [ @@ -2849,7 +2872,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -2953,7 +2977,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -3071,7 +3096,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -3087,7 +3113,8 @@ "fixed_damage_options": [], "name": "ram" } - ] + ], + "use": "situational" } ], "categories": [], @@ -3187,7 +3214,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -3327,7 +3355,8 @@ "fixed_damage_options": [], "name": "hoof" } - ] + ], + "use": "primary" } ], "categories": [], @@ -3439,7 +3468,8 @@ "fixed_damage_options": [], "name": "tentacle" } - ] + ], + "use": "primary" } ], "categories": [], @@ -3558,7 +3588,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -3688,7 +3719,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -3702,7 +3734,8 @@ "fixed_damage_options": [], "name": "jump" } - ] + ], + "use": "situational" }, { "attacks": [ @@ -3718,7 +3751,8 @@ "fixed_damage_options": [], "name": "spit" } - ] + ], + "use": "situational" } ], "categories": [], @@ -3849,7 +3883,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [], @@ -3953,7 +3988,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -4107,7 +4143,8 @@ "fixed_damage_options": [], "name": "dragon: bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -4121,7 +4158,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -4256,7 +4294,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -4270,7 +4309,8 @@ "fixed_damage_options": [], "name": "boulder" } - ] + ], + "use": "situational" } ], "categories": [], @@ -4376,7 +4416,8 @@ "fixed_damage_options": [], "name": "beak" } - ] + ], + "use": "primary" } ], "categories": [], @@ -4483,7 +4524,8 @@ "fixed_damage_options": [], "name": "pincer" } - ] + ], + "use": "primary" } ], "categories": [], @@ -4611,7 +4653,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -4727,7 +4770,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -4854,7 +4898,8 @@ "fixed_damage_options": [], "name": "club" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -4868,7 +4913,8 @@ "fixed_damage_options": [], "name": "rock" } - ] + ], + "use": "situational" } ], "categories": [], @@ -5011,7 +5057,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -5205,7 +5252,8 @@ "fixed_damage_options": [], "name": "gore" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -5219,7 +5267,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "situational" }, { "attacks": [ @@ -5235,7 +5284,8 @@ "fixed_damage_options": [], "name": "magic" } - ] + ], + "use": "special" } ], "categories": [], @@ -5352,7 +5402,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -5483,7 +5534,8 @@ "fixed_damage_options": [], "name": "magic" } - ] + ], + "use": "primary" } ], "categories": [ @@ -5615,7 +5667,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -5712,7 +5765,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -5959,7 +6013,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -5973,7 +6028,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -6094,7 +6150,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -6231,7 +6288,8 @@ "fixed_damage_options": [], "name": "magic" } - ] + ], + "use": "primary" } ], "categories": [ @@ -6343,7 +6401,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -6473,7 +6532,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -6600,7 +6660,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -6727,7 +6788,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -6878,7 +6940,8 @@ "fixed_damage_options": [], "name": "magic" } - ] + ], + "use": "primary" } ], "categories": [ @@ -7003,7 +7066,8 @@ "fixed_damage_options": [], "name": "tusk" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -7017,7 +7081,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -7126,7 +7191,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -7235,7 +7301,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -7335,7 +7402,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -7463,7 +7531,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -7597,7 +7666,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -7731,7 +7801,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -7865,7 +7936,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -7879,7 +7951,8 @@ "fixed_damage_options": [], "name": "boulder" } - ] + ], + "use": "situational" } ], "categories": [], @@ -8034,7 +8107,8 @@ "fixed_damage_options": [], "name": "heat aura" } - ] + ], + "use": "primary" } ], "categories": [], @@ -8164,7 +8238,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -8180,7 +8255,8 @@ "fixed_damage_options": [], "name": "spray" } - ] + ], + "use": "situational" } ], "categories": [], @@ -8301,7 +8377,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -8315,7 +8392,8 @@ "fixed_damage_options": [], "name": "boulder" } - ] + ], + "use": "situational" } ], "categories": [], @@ -8463,7 +8541,8 @@ "fixed_damage_options": [], "name": "cold aura" } - ] + ], + "use": "primary" } ], "categories": [], @@ -8613,7 +8692,8 @@ "fixed_damage_options": [], "name": "horn" } - ] + ], + "use": "primary" } ], "categories": [ @@ -8729,7 +8809,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -8857,7 +8938,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [], @@ -8996,7 +9078,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [ @@ -9096,7 +9179,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9196,7 +9280,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9303,7 +9388,8 @@ "fixed_damage_options": [], "name": "feeler" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9403,7 +9489,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9522,7 +9609,8 @@ "fixed_damage_options": [], "name": "talons or beak" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9629,7 +9717,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9745,7 +9834,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -9872,7 +9962,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -10005,7 +10096,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -10135,7 +10227,8 @@ "fixed_damage_options": [], "name": "spine" } - ] + ], + "use": "primary" } ], "categories": [], @@ -10235,7 +10328,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -10351,7 +10445,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -10456,7 +10551,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -10601,7 +10697,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -10758,7 +10855,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -10977,7 +11075,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -10991,7 +11090,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -11116,7 +11216,8 @@ "fixed_damage_options": [], "name": "gore" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -11132,7 +11233,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -11241,7 +11343,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -11425,7 +11528,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -11439,7 +11543,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -11581,7 +11686,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [], @@ -11698,7 +11804,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [], @@ -11831,7 +11938,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -11948,7 +12056,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -12057,7 +12166,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -12197,7 +12307,8 @@ "fixed_damage_options": [], "name": "song" } - ] + ], + "use": "primary" } ], "categories": [], @@ -12339,7 +12450,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -12353,7 +12465,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -12495,7 +12608,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -12509,7 +12623,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -12651,7 +12766,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -12665,7 +12781,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -12807,7 +12924,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -12821,7 +12939,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -12963,7 +13082,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -12977,7 +13097,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -13084,7 +13205,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [], @@ -13210,7 +13332,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -13315,7 +13438,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -13469,7 +13593,8 @@ "fixed_damage_options": [], "name": "tail" } - ] + ], + "use": "primary" } ], "categories": [], @@ -13585,7 +13710,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -13701,7 +13827,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -13817,7 +13944,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -13933,7 +14061,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14049,7 +14178,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14165,7 +14295,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14281,7 +14412,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14397,7 +14529,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14551,7 +14684,8 @@ ], "name": "swarm" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14711,7 +14845,8 @@ ], "name": "swarm" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14871,7 +15006,8 @@ ], "name": "swarm" } - ] + ], + "use": "primary" } ], "categories": [], @@ -14993,7 +15129,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -15118,7 +15255,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -15271,7 +15409,8 @@ "fixed_damage_options": [], "name": "sting" } - ] + ], + "use": "primary" } ], "categories": [], @@ -15382,7 +15521,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -15512,7 +15652,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -15632,7 +15773,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -15751,7 +15893,8 @@ "fixed_damage_options": [], "name": "butt" } - ] + ], + "use": "primary" } ], "categories": [], @@ -15882,7 +16025,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -16010,7 +16154,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -16134,7 +16279,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -16243,7 +16389,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -16366,7 +16513,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -16483,7 +16631,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -16497,7 +16646,8 @@ "fixed_damage_options": [], "name": "tail spike" } - ] + ], + "use": "situational" } ], "categories": [], @@ -16618,7 +16768,8 @@ "fixed_damage_options": [], "name": "tusk" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -16632,7 +16783,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -16743,7 +16895,8 @@ "fixed_damage_options": [], "name": "dagger" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -16757,7 +16910,8 @@ "fixed_damage_options": [], "name": "spell" } - ] + ], + "use": "special" } ], "categories": [ @@ -16875,7 +17029,8 @@ "fixed_damage_options": [], "name": "butt" } - ] + ], + "use": "primary" } ], "categories": [], @@ -17018,7 +17173,8 @@ "fixed_damage_options": [], "name": "snakebites" } - ] + ], + "use": "primary" } ], "categories": [], @@ -17169,7 +17325,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -17294,7 +17451,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -17424,7 +17582,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -17438,7 +17597,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "alternative" } ], "categories": [], @@ -17566,7 +17726,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -17682,7 +17843,8 @@ "fixed_damage_options": [], "name": "kick" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -17696,7 +17858,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "alternative" } ], "categories": [], @@ -17821,7 +17984,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [ @@ -17947,7 +18111,8 @@ "fixed_damage_options": [], "name": "horn" } - ] + ], + "use": "primary" } ], "categories": [], @@ -18070,7 +18235,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -18202,7 +18368,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -18218,7 +18385,8 @@ "fixed_damage_options": [], "name": "group spell" } - ] + ], + "use": "special" } ], "categories": [ @@ -18331,7 +18499,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -18492,7 +18661,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -18619,7 +18789,8 @@ "fixed_damage_options": [], "name": "swarm" } - ] + ], + "use": "primary" } ], "categories": [], @@ -18724,7 +18895,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -18836,7 +19008,8 @@ "fixed_damage_options": [], "name": "talons or beak" } - ] + ], + "use": "primary" } ], "categories": [], @@ -18938,7 +19111,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -19072,7 +19246,8 @@ "fixed_damage_options": [], "name": "bite per pack" } - ] + ], + "use": "primary" } ], "categories": [], @@ -19186,7 +19361,8 @@ "fixed_damage_options": [], "name": "horn" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -19200,7 +19376,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -19307,7 +19484,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -19433,7 +19611,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [], @@ -19562,7 +19741,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -19669,7 +19849,8 @@ "fixed_damage_options": [], "name": "club" } - ] + ], + "use": "primary" } ], "categories": [ @@ -19785,7 +19966,8 @@ "fixed_damage_options": [], "name": "oil spray" } - ] + ], + "use": "primary" } ], "categories": [], @@ -19934,7 +20116,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -20062,7 +20245,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -20188,7 +20372,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -20304,7 +20489,8 @@ "fixed_damage_options": [], "name": "hoof" } - ] + ], + "use": "primary" } ], "categories": [], @@ -20464,7 +20650,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -20586,7 +20773,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -20700,7 +20888,8 @@ "fixed_damage_options": [], "name": "dagger" } - ] + ], + "use": "primary" } ], "categories": [ @@ -20834,7 +21023,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -20928,7 +21118,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21022,7 +21213,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21150,7 +21342,8 @@ "fixed_damage_options": [], "name": "sting" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21343,7 +21536,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -21357,7 +21551,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -21495,7 +21690,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21604,7 +21800,8 @@ "fixed_damage_options": [], "name": "hoof" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21718,7 +21915,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21835,7 +22033,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -21944,7 +22143,8 @@ "fixed_damage_options": [], "name": "magma jet" } - ] + ], + "use": "primary" } ], "categories": [ @@ -22052,7 +22252,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -22163,7 +22364,8 @@ "fixed_damage_options": [], "name": "feeler" } - ] + ], + "use": "primary" } ], "categories": [], @@ -22287,7 +22489,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -22410,7 +22613,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -22426,7 +22630,8 @@ "fixed_damage_options": [], "name": "spray" } - ] + ], + "use": "situational" } ], "categories": [], @@ -22554,7 +22759,8 @@ "fixed_damage_options": [], "name": "sting" } - ] + ], + "use": "primary" } ], "categories": [], @@ -22736,7 +22942,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -22750,7 +22957,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -22869,7 +23077,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -22885,7 +23094,8 @@ "fixed_damage_options": [], "name": "squeeze" } - ] + ], + "use": "situational" } ], "categories": [], @@ -23003,7 +23213,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -23126,7 +23337,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [], @@ -23261,7 +23473,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -23446,7 +23659,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -23566,7 +23780,8 @@ "fixed_damage_options": [], "name": "butt" } - ] + ], + "use": "primary" } ], "categories": [], @@ -23680,7 +23895,8 @@ "fixed_damage_options": [], "name": "butt" } - ] + ], + "use": "primary" } ], "categories": [], @@ -23811,7 +24027,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -23942,7 +24159,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [ @@ -24064,7 +24282,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -24080,7 +24299,8 @@ "fixed_damage_options": [], "name": "ram" } - ] + ], + "use": "situational" } ], "categories": [], @@ -24195,7 +24415,8 @@ "fixed_damage_options": [], "name": "spit" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -24211,7 +24432,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "alternative" } ], "categories": [], @@ -24320,7 +24542,8 @@ "fixed_damage_options": [], "name": "spell" } - ] + ], + "use": "primary" } ], "categories": [ @@ -24491,7 +24714,8 @@ "fixed_damage_options": [], "name": "beak" } - ] + ], + "use": "primary" } ], "categories": [], @@ -24593,7 +24817,8 @@ "fixed_damage_options": [], "name": "tail" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -24607,7 +24832,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -24723,7 +24949,8 @@ "fixed_damage_options": [], "name": "beak" } - ] + ], + "use": "primary" } ], "categories": [], @@ -24844,7 +25071,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -24858,7 +25086,8 @@ "fixed_damage_options": [], "name": "boulder" } - ] + ], + "use": "situational" } ], "categories": [], @@ -24998,7 +25227,8 @@ "fixed_damage_options": [], "name": "lightning bolt" } - ] + ], + "use": "primary" } ], "categories": [], @@ -25126,7 +25356,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -25142,7 +25373,8 @@ "fixed_damage_options": [], "name": "spray" } - ] + ], + "use": "situational" } ], "categories": [], @@ -25258,7 +25490,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -25380,7 +25613,8 @@ "fixed_damage_options": [], "name": "claw" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -25394,7 +25628,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "alternative" } ], "categories": [], @@ -25527,7 +25762,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -25621,7 +25857,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -25730,7 +25967,8 @@ "fixed_damage_options": [], "name": "butt" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -25744,7 +25982,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -25858,7 +26097,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -25967,7 +26207,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -26086,7 +26327,8 @@ "fixed_damage_options": [], "name": "fist" } - ] + ], + "use": "primary" } ], "categories": [], @@ -26202,7 +26444,8 @@ "fixed_damage_options": [], "name": "gore" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -26216,7 +26459,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -26340,7 +26584,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [ @@ -26483,7 +26728,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -26600,7 +26846,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -26702,7 +26949,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -26821,7 +27069,8 @@ "fixed_damage_options": [], "name": "horn" } - ] + ], + "use": "primary" } ], "categories": [], @@ -27000,7 +27249,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -27016,7 +27266,8 @@ "fixed_damage_options": [], "name": "gaze" } - ] + ], + "use": "special" } ], "categories": [ @@ -27205,7 +27456,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -27221,7 +27473,8 @@ "fixed_damage_options": [], "name": "gaze" } - ] + ], + "use": "special" } ], "categories": [ @@ -27410,7 +27663,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -27426,7 +27680,8 @@ "fixed_damage_options": [], "name": "gaze" } - ] + ], + "use": "special" } ], "categories": [ @@ -27538,7 +27793,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -27644,7 +27900,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -27750,7 +28007,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ @@ -27875,7 +28133,8 @@ "fixed_damage_options": [], "name": "hoof" } - ] + ], + "use": "primary" } ], "categories": [], @@ -27989,7 +28248,8 @@ "fixed_damage_options": [], "name": "tentacle" } - ] + ], + "use": "primary" } ], "categories": [], @@ -28112,7 +28372,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -28244,7 +28505,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -28376,7 +28638,8 @@ "fixed_damage_options": [], "name": "blow" } - ] + ], + "use": "primary" } ], "categories": [ @@ -28517,7 +28780,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -28705,7 +28969,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -28879,7 +29144,8 @@ "fixed_damage_options": [], "name": "tusk/bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -29060,7 +29326,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -29074,7 +29341,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "alternative" } ], "categories": [], @@ -29258,7 +29526,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -29432,7 +29701,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], @@ -29626,7 +29896,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -29640,7 +29911,8 @@ "fixed_damage_options": [], "name": "breath" } - ] + ], + "use": "special" } ], "categories": [], @@ -29771,7 +30043,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [ @@ -29889,7 +30162,8 @@ "fixed_damage_options": [], "name": "hoof" } - ] + ], + "use": "primary" } ], "categories": [], @@ -30024,7 +30298,8 @@ "fixed_damage_options": [], "name": "fist" } - ] + ], + "use": "primary" } ], "categories": [ @@ -30146,7 +30421,8 @@ "fixed_damage_options": [], "name": "horn" } - ] + ], + "use": "primary" }, { "attacks": [ @@ -30160,7 +30436,8 @@ "fixed_damage_options": [], "name": "trample" } - ] + ], + "use": "situational" } ], "categories": [], @@ -30286,7 +30563,8 @@ "fixed_damage_options": [], "name": "touch" } - ] + ], + "use": "primary" } ], "categories": [ @@ -30423,7 +30701,8 @@ "fixed_damage_options": [], "name": "sting" } - ] + ], + "use": "primary" } ], "categories": [], @@ -30560,7 +30839,8 @@ "fixed_damage_options": [], "name": "spores" } - ] + ], + "use": "primary" } ], "categories": [], @@ -30676,7 +30956,8 @@ "fixed_damage_options": [], "name": "weapon" } - ] + ], + "use": "primary" } ], "categories": [ diff --git a/src/osrlib/data/spells.json b/src/osrlib/data/spells.json index 5a32421..7cab1b3 100644 --- a/src/osrlib/data/spells.json +++ b/src/osrlib/data/spells.json @@ -5791,7 +5791,8 @@ "fixed_damage_options": [], "name": "bite" } - ] + ], + "use": "primary" } ], "categories": [], diff --git a/tests/test_attack_routines.py b/tests/test_attack_routines.py new file mode 100644 index 0000000..534c9fd --- /dev/null +++ b/tests/test_attack_routines.py @@ -0,0 +1,897 @@ +"""Acceptance tests for monsters with more than one attack routine. + +A monster template lists one or more attack routines, and a monster uses one of them in a round. +The OSE SRD joins them with "or", but its text says when each is used: a thoul claws or uses a +weapon as it likes, an elephant tramples on a 3-in-4 chance, and a dragon's breath is no attack +at all. These tests pin how the catalog labels each routine and how battle uses the labels: + +- [`AttackRoutine.use`][osrlib.core.monsters.AttackRoutine.use] holds a + [`RoutineUse`][osrlib.core.monsters.RoutineUse]. The first routine of every template is + `PRIMARY`. A later routine is `ALTERNATIVE` when the SRD offers it as a free choice each round, + `SITUATIONAL` when the SRD ties it to a situation osrlib doesn't model (range, a charge or a + trample chance, water, a ship, a form, or a loadout), and `SPECIAL` when it is a breath, a gaze, + or a spell that battle never rolls. The SRD compiler labels every routine and refuses to compile + a monster whose later routine it can't label. +- [`MonsterAction.routine`][osrlib.crawl.battle.MonsterAction.routine] names a routine by its index + in `template.attacks`. `None` resolves the first routine, as battle does today. An index the + template doesn't have makes the action impossible, and the session skips it. +- The default policy, [`ScriptedPolicy`][osrlib.crawl.battle.ScriptedPolicy], picks a melee + routine on the monster-action stream, with equal chance, among the `PRIMARY` and `ALTERNATIVE` + routines. It never picks a `SITUATIONAL` or `SPECIAL` one. With one candidate it draws nothing, + so a monster without an alternative draws exactly as it does today and no golden moves. +- A game's own policy may name any routine. Battle rolls a `SITUATIONAL` routine as melee and + rolls nothing for a `SPECIAL` one. It never rolls an entry named `gaze` or `breath` as an attack. + +Saving throws are forced where a test needs a particular outcome, +with a `save_bonus` of +20 or -20, so a test never depends on which draw a save comes from. Each +test asserts its own preconditions, so a test that stops exercising its case fails loudly. +""" + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from osrlib.core.abilities import AbilityScore +from osrlib.core.character import Character +from osrlib.core.clock import ROUNDS_PER_TURN +from osrlib.core.combat import incapacitated +from osrlib.core.effects import Condition, EffectDefinition, ModifierSpec, has_condition +from osrlib.core.events import AttackRolledEvent, DamageDealtEvent, LevelDrainedEvent, SavingThrowRolledEvent +from osrlib.core.monsters import AttackRoutine, MonsterAttack, RoutineUse +from osrlib.core.rng import RngStream +from osrlib.crawl.adventure import Adventure, TownSpec +from osrlib.crawl.battle import MonsterAction, ScriptedPolicy +from osrlib.crawl.commands import ( + BattleDeclaration, + EngageBattle, + EnterDungeon, + ResolveBattleRound, + SessionMode, + SpawnMonsters, +) +from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec +from osrlib.crawl.party import Party +from osrlib.crawl.session import MONSTER_ACTION_STREAM, GameSession +from osrlib.data import load_classes, load_monsters, load_spells + +REPO = Path(__file__).resolve().parents[1] + +HIT_CODES = frozenset({"combat.attack.hit", "combat.attack.auto_hit"}) + +ALWAYS_PASS = 20 +"""A save bonus no d20 can fail with.""" + +ALWAYS_FAIL = -20 +"""A save penalty no d20 can pass with.""" + +P = RoutineUse.PRIMARY +A = RoutineUse.ALTERNATIVE +S = RoutineUse.SITUATIONAL +X = RoutineUse.SPECIAL + +EXPECTED_USES: dict[str, tuple[RoutineUse, ...]] = { + # A free choice each round: the SRD prints "or" and names no situation. + "minotaur": (P, A), + "mule": (P, A), + "spitting_cobra": (P, A), + "thoul": (P, A), + "wererat": (P, A), # "Weapons: May also use weapons in animal form." + # A breath that resolves through `kind="breath"`. + "black_dragon": (P, X), + "blue_dragon": (P, X), + "chimera": (P, X), + "dragon_turtle": (P, X), + "gold_dragon": (P, X), + "green_dragon": (P, X), + "hellhound_3": (P, X), + "hellhound_4": (P, X), + "hellhound_5": (P, X), + "hellhound_6": (P, X), + "hellhound_7": (P, X), + "red_dragon": (P, X), + "sea_dragon": (P, X), + "white_dragon": (P, X), + # A breath or gaze with no attack roll, left to the referee. + "gorgon": (P, X), + "vampire_7": (P, X), + "vampire_8": (P, X), + "vampire_9": (P, X), + # A spell. + "medium": (P, X), # "Each individual has one 1st level arcane spell memorized." + "nixie": (P, X), # "10 nixies together can cast a charm" + # Gore in swine form, a weapon in human form, and "Charm person: 3 times a day." + "devil_swine": (P, S, X), + # Range: a thrown or spat missile. + "ape_white": (P, S), + "cloud_giant": (P, S), + "cyclops": (P, S), + "fire_giant": (P, S), + "frost_giant": (P, S), + "manticore": (P, S), + "stone_giant": (P, S), + # "Jump: ... 50% chance of jumping at a random opponent" and "Spit: Used defensively. 10' range." + "cave_locust": (P, S, S), + # "Trample: 3-in-4 chance of trampling each round." + "elephant": (P, S), + "mastodon": (P, S), + "normal_rhinoceros": (P, S), + "stegosaurus": (P, S), + "titanothere": (P, S), + "triceratops": (P, S), + "woolly_rhinoceros": (P, S), + # Water and ships. + "bull_shark": (P, S), # "Attack by ramming, then biting the stunned prey." + "freshwater_termite": (P, S), # "Irritant spray: Above water; maximum once a turn." + "saltwater_termite": (P, S), + "swamp_termite": (P, S), + "sea_serpent_lesser": (P, S), # "Squeeze: Coil around and crush a vessel" + "sperm_whale": (P, S), # "Ram ships: 10% likely to attack vessels" + # A loadout: "Weapons: 4×1-handed or 2×2-handed." + "bone_golem": (P, S), +} +"""The label of every routine of every template with more than one, as the OSE SRD's text gives it.""" + +FREE_CHOICE = tuple(sorted(template_id for template_id, uses in EXPECTED_USES.items() if A in uses)) +"""Templates with an alternative routine, which the default policy picks as often as the first.""" + +ONE_CANDIDATE = tuple(sorted(template_id for template_id, uses in EXPECTED_USES.items() if A not in uses)) +"""Templates with more than one routine, of which the default policy may pick only the first.""" + +ROUNDS = 200 +"""Policy calls in a run, enough that an equal-chance routine can't go unpicked or wildly over-picked.""" + + +# ---------------------------------------------------------------------- setup + + +def _character(name: str = "Hild", *, level: int = 3, hp: int = 400) -> Character: + """A fighter with average scores, no armour, and enough hit points to outlast a long fight.""" + definition = load_classes().get("fighter") + return Character( + name=name, + class_id="fighter", + race=definition.race, + level=level, + xp=definition.row(level).xp, + scores={ability: 10 for ability in AbilityScore}, + alignment="lawful", + max_hp=hp, + current_hp=hp, + ) + + +def _game(seed: int, members: list[Character]) -> GameSession: + """A session standing at the entrance of a one-cell-wide, two-cell corridor.""" + level = LevelSpec(number=1, width=2, height=1, entrance=(0, 0), edges={"1,0:west": Edge(kind=EdgeKind.OPEN)}) + crypt = DungeonSpec(id="crypt", name="Crypt", levels=(level,)) + adventure = Adventure(name="A", town=TownSpec(name="Threshold", travel_turns={"crypt": 1}), dungeons=(crypt,)) + game = GameSession.new(Party(members=members), adventure, seed=seed) + assert game.execute(EnterDungeon(dungeon_id="crypt")).accepted + return game + + +def _battle(template_id: str, *, seed: int = 4, members: list[Character] | None = None, distance_feet: int = 5): + """Start a battle against one monster and return the session and the monster. + + Tries seeds from `seed` upward until neither side is surprised, so every attack happens in a + resolved round and the test doesn't depend on one seed's surprise roll. + """ + for attempt in range(seed, seed + 50): + game = _game(attempt, members or [_character()]) + result = game.execute(SpawnMonsters(template_id=template_id, count_fixed=1, distance_feet=distance_feet)) + assert result.accepted + surprise = [event for event in result.events if event.code == "encounter.surprise.rolled"] + if not surprise or any(event.roll is not None and event.roll <= event.threshold for event in surprise): + continue + assert game.execute(EngageBattle()).accepted + assert game.mode is SessionMode.BATTLE + monster = game.monsters[game.encounter.groups[0].monster_ids[0]] + assert monster.template.id == template_id + assert game.encounter.groups[0].distance_feet == distance_feet + return game, monster + pytest.fail(f"precondition: no seed from {seed} starts an unsurprised battle with {template_id}") + + +def _spend_breath(monster) -> None: + """Use up a daily breath weapon, so the default policy melees instead of opening with breath.""" + breath = monster.template.ability("breath_weapon") + if breath is not None and breath.params.get("uses_per_day") is not None: + monster.breath_uses_today = int(breath.params["uses_per_day"]) + + +def _force_saves(game: GameSession, character: Character, bonus: int) -> None: + """Make every save the character rolls pass (+20) or fail (-20).""" + effect, _ = game.ledger.attach( + EffectDefinition(kind="test_save_ward", modifiers=(ModifierSpec(kind="save_bonus", value=bonus),)), + character.id, + clock=game.clock, + allocator=game.allocator, + registry=game.registry(), + ) + assert effect is not None + + +def _round(game: GameSession) -> list: + """Resolve one battle round in which every able member holds, and return its events.""" + assert game.mode is SessionMode.BATTLE, "precondition: the battle is still running" + able = [member for member in game.party.living_members() if not incapacitated(member)] + declarations = tuple(BattleDeclaration(character_id=member.id, action="hold") for member in able) + result = game.execute(ResolveBattleRound(declarations=declarations)) + assert result.accepted + return list(result.events) + + +def _rolls(events, attacker_id: str) -> list[AttackRolledEvent]: + return [event for event in events if isinstance(event, AttackRolledEvent) and event.attacker_id == attacker_id] + + +def _hits(events, attacker_id: str, name: str) -> list[AttackRolledEvent]: + return [event for event in _rolls(events, attacker_id) if event.code in HIT_CODES and event.attack_name == name] + + +def _saves(events, target_id: str) -> list[SavingThrowRolledEvent]: + return [event for event in events if isinstance(event, SavingThrowRolledEvent) and event.target_id == target_id] + + +def _routine_names(template) -> list[set[str]]: + """The attack names in each of the template's routines, in routine order.""" + return [{attack.name for attack in routine.attacks} for routine in template.attacks] + + +def _expanded(routine) -> list[str]: + """The attack names a routine rolls in a round, each repeated by its count.""" + return [attack.name for attack in routine.attacks for _ in range(attack.count)] + + +def _candidates(template_id: str) -> set[int]: + """The indexes of the routines the default policy may pick, by the expected labels.""" + uses = EXPECTED_USES.get(template_id, (P,)) + return {index for index, use in enumerate(uses) if use in (P, A)} + + +def _choose(game: GameSession) -> list[MonsterAction]: + """Call the default policy for the battle's only group, as the session does.""" + return ScriptedPolicy().choose(game, game.encounter.groups[0], game.streams.get(MONSTER_ACTION_STREAM)) + + +def _melee_routines(game: GameSession, calls: int = ROUNDS) -> list[int]: + """The routine of every melee action the default policy chooses over `calls` calls. + + `None` counts as the first routine. A chance-in-six breath, like the hellhound's, is left out. + """ + chosen = [] + for _ in range(calls): + actions = _choose(game) + assert len(actions) == 1, "precondition: one monster, one action" + if actions[0].kind == "breath": + continue + assert actions[0].kind == "melee", "precondition: the monster melees" + chosen.append(0 if actions[0].routine is None else actions[0].routine) + assert len(chosen) >= calls // 2, "precondition: the monster meleed in most calls" + return chosen + + +class RoutinePolicy: + """A game's own policy: every living monster melees the first party member with one routine.""" + + def __init__(self, routine: int | None) -> None: + self.routine = routine + + def choose(self, session, group, stream): + target = session.party.living_members()[0] + return [ + MonsterAction(monster_id=monster_id, kind="melee", target_id=target.id, routine=self.routine) + for monster_id in group.monster_ids + if not has_condition(session.combatant(monster_id), Condition.DEAD) + ] + + +def _with_policy(game: GameSession, routine: int | None) -> None: + game.action_policies[game.encounter.groups[0].id] = RoutinePolicy(routine) + + +# ---------------------------------------------------------------------- the contract + + +class TestTheContract: + """The label and the routine index are fields with defaults that keep today's behavior.""" + + def test_routine_use_has_four_values(self): + assert [(use.name, use.value) for use in RoutineUse] == [ + ("PRIMARY", "primary"), + ("ALTERNATIVE", "alternative"), + ("SITUATIONAL", "situational"), + ("SPECIAL", "special"), + ] + + def test_a_routine_is_primary_unless_labelled(self): + routine = AttackRoutine(attacks=(MonsterAttack(count=1, name="bite", damage="1d4"),)) + assert routine.use is RoutineUse.PRIMARY + labelled = AttackRoutine.model_validate( + {"attacks": [{"count": 1, "name": "bite", "damage": "1d4"}], "use": "situational"} + ) + assert labelled.use is RoutineUse.SITUATIONAL + + def test_the_routine_field_defaults_to_none(self): + action = MonsterAction(monster_id="monster-0001", kind="melee", target_id="character-0001") + assert action.routine is None + named = MonsterAction(monster_id="monster-0001", kind="melee", target_id="character-0001", routine=1) + assert named.routine == 1 + + +# ---------------------------------------------------------------------- the catalog + + +class TestTheCatalog: + """The compiled data labels every routine as the OSE SRD's text gives it.""" + + def test_the_label_table_covers_every_template_with_more_than_one_routine(self): + catalog = load_monsters() + multi = {template.id for template in catalog.monsters if len(template.attacks) > 1} + assert multi == set(EXPECTED_USES) + for template_id, uses in EXPECTED_USES.items(): + assert len(catalog.get(template_id).attacks) == len(uses), template_id + + def test_the_routines_these_tests_rely_on_are_in_the_data(self): + catalog = load_monsters() + cobra = catalog.get("spitting_cobra") + assert _routine_names(cobra) == [{"spit"}, {"bite"}] + bite = cobra.attacks[1].attacks[0] + assert bite.damage == "1d3" + assert bite.effects == ("poison",) + poison = cobra.ability("poison") + assert poison is not None and not poison.manual + assert poison.params["onset_dice"] == "1d10" and poison.params["onset_unit"] == "turn" + assert _routine_names(catalog.get("vampire_7")) == [{"touch"}, {"gaze"}] + assert _routine_names(catalog.get("gorgon")) == [{"gore"}, {"breath"}] + assert _routine_names(catalog.get("red_dragon")) == [{"claw", "bite"}, {"breath"}] + assert _routine_names(catalog.get("hellhound_3")) == [{"bite"}, {"breath"}] + assert catalog.get("hellhound_3").attacks[1].attacks[0].damage == "3d6", ( + "the hellhound's breath entry has damage dice, so rolling it as melee would deal them" + ) + assert _routine_names(catalog.get("medium")) == [{"dagger"}, {"spell"}] + assert _routine_names(catalog.get("nixie")) == [{"weapon"}, {"group spell"}] + assert _routine_names(catalog.get("devil_swine")) == [{"gore"}, {"weapon"}, {"magic"}] + assert _routine_names(catalog.get("cave_locust")) == [{"bite"}, {"jump"}, {"spit"}] + assert _routine_names(catalog.get("elephant")) == [{"tusk"}, {"trample"}] + assert _routine_names(catalog.get("basilisk")) == [{"bite", "gaze"}] + + @pytest.mark.parametrize("template_id", sorted(EXPECTED_USES)) + def test_each_routine_carries_the_label_the_srd_gives(self, template_id): + template = load_monsters().get(template_id) + assert tuple(routine.use for routine in template.attacks) == EXPECTED_USES[template_id] + + def test_every_first_routine_is_primary(self): + armed = [template for template in load_monsters().monsters if template.attacks] + assert len(armed) > len(EXPECTED_USES), "precondition: most templates have attacks" + for template in armed: + assert template.attacks[0].use is RoutineUse.PRIMARY, template.id + + def test_summoned_creatures_carry_labels_too(self): + """The monster stat blocks printed on spell pages compile through the same routine parser.""" + conjured = [monster for spell in load_spells().spells for monster in spell.conjured_monsters] + assert conjured, "precondition: a spell page prints a monster stat block" + for template in conjured: + assert template.attacks[0].use is RoutineUse.PRIMARY, template.id + assert all(routine.use is not RoutineUse.PRIMARY for routine in template.attacks[1:]), template.id + + @pytest.mark.parametrize("filename", ["monsters.json", "spells.json"]) + def test_the_generated_data_writes_every_label(self, filename): + """Every routine in the generated data names its label, so a reader of the JSON sees it.""" + document = json.loads((REPO / "src" / "osrlib" / "data" / filename).read_text(encoding="utf-8")) + routines = [] + + def walk(node): + if isinstance(node, dict): + if isinstance(node.get("attacks"), list): + routines.extend(item for item in node["attacks"] if isinstance(item, dict) and "attacks" in item) + for value in node.values(): + walk(value) + elif isinstance(node, list): + for value in node: + walk(value) + + walk(document) + assert routines, f"precondition: {filename} has attack routines" + assert all(routine.get("use") in {use.value for use in RoutineUse} for routine in routines) + + +# ---------------------------------------------------------------------- the compiler + + +def _compile_edited_srd(tmp_path: Path, filename: str, old: str, new: str) -> subprocess.CompletedProcess: + """Compile a copy of the SRD with one page edited, and return the finished process.""" + srd = tmp_path / "srd" + shutil.copytree(REPO / "srd", srd) + page = srd / filename + text = page.read_text(encoding="utf-8") + assert text.count(old) == 1, f"precondition: {filename} has {old!r} once" + page.write_text(text.replace(old, new), encoding="utf-8") + out = tmp_path / "out" + out.mkdir() + return subprocess.run( + [sys.executable, "-m", "tools.srd_compile", "--srd-dir", str(srd), "--out-dir", str(out)], + cwd=REPO, + capture_output=True, + text=True, + check=False, + ) + + +class TestTheCompilerRefusesAnUnlabelledRoutine: + """A new SRD monster with a later routine the compiler can't label stops the build. + + The edit gives a page a second routine with an attack name no monster has, so no label table or + breath, gaze, or spell rule can cover it. + """ + + def test_a_monster_page(self, tmp_path): + process = _compile_edited_srd( + tmp_path, + "Goblin.md", + "| Attacks | 1 × weapon (1d6 or by weapon) |", + "| Attacks | 1 × weapon (1d6 or by weapon) or 1 × tentacle lash (1d4) |", + ) + assert process.returncode != 0, "the compiler wrote data with an unlabelled routine" + assert "goblin" in process.stderr.lower(), "the failure names the monster" + + def test_a_stat_block_on_a_spell_page(self, tmp_path): + process = _compile_edited_srd( + tmp_path, + "Sticks_to_Snakes.md", + "**Att** 1 × bite (1d4),", + "**Att** 1 × bite (1d4) or 1 × tentacle lash (1d2),", + ) + assert process.returncode != 0, "the compiler wrote data with an unlabelled routine" + assert "snake" in process.stderr.lower(), "the failure names the monster" + + +# ---------------------------------------------------------------------- the default policy + + +class TestTheDefaultPolicyPicksARoutine: + """Under `ScriptedPolicy`, a monster picks among its primary and alternative routines.""" + + @pytest.mark.parametrize("template_id", FREE_CHOICE) + def test_a_free_choice_is_an_equal_chance(self, template_id): + game, _ = _battle(template_id) + + chosen = _melee_routines(game) + + assert set(chosen) == {0, 1} + assert 0.3 * len(chosen) < chosen.count(1) < 0.7 * len(chosen), "each routine has an equal chance" + + @pytest.mark.parametrize("template_id", ONE_CANDIDATE) + def test_a_situational_or_special_routine_is_never_picked(self, template_id): + game, monster = _battle(template_id) + _spend_breath(monster) + assert _candidates(template_id) == {0} + + chosen = _melee_routines(game, calls=60) + + assert set(chosen) == {0} + + def test_the_choice_draws_on_the_monster_action_stream_every_round(self): + game, _ = _battle("spitting_cobra") + stream = game.streams.get(MONSTER_ACTION_STREAM) + others = {name: state for name, state in game.streams.export_states().items() if name != MONSTER_ACTION_STREAM} + + for _ in range(20): + replica = RngStream.restore(stream.export_state()) + replica.randbelow(1) # the target pick in a one-member party + actions = _choose(game) + assert [action.kind for action in actions] == ["melee"] + assert actions[0].routine in {None, 0, 1} + assert stream.export_state() != replica.export_state(), "the policy drew nothing for the routine" + + after = {name: state for name, state in game.streams.export_states().items() if name != MONSTER_ACTION_STREAM} + assert after == others + + def test_a_group_that_closes_draws_nothing_for_a_routine(self): + game, _ = _battle("spitting_cobra", distance_feet=40) + stream = game.streams.get(MONSTER_ACTION_STREAM) + before = stream.export_state() + + actions = _choose(game) + + assert [action.kind for action in actions] == ["close"] + assert stream.export_state() == before + + +class TestOneCandidateDrawsAsToday: + """A monster with no alternative routine draws only its target, exactly as before the change. + + A one-member party gives each melee monster one target, so the policy's only draw for it is + `randbelow(1)`. The test replays that draw on a copy of the stream and compares positions. + These pass today and must still pass after the change: they are why the goldens don't move. + """ + + @pytest.mark.parametrize("template_id", ["goblin", "troll", "basilisk", *ONE_CANDIDATE]) + def test_the_target_pick_is_the_only_draw(self, template_id): + game, monster = _battle(template_id) + _spend_breath(monster) + breath = monster.template.ability("breath_weapon") + stream = game.streams.get(MONSTER_ACTION_STREAM) + + for _ in range(20): + replica = RngStream.restore(stream.export_state()) + actions = _choose(game) + assert len(actions) == 1 + if breath is not None and breath.params.get("per_round_chance_in_six") is not None: + gate = replica.randbelow(6) + 1 + if gate <= int(breath.params["per_round_chance_in_six"]): + assert actions[0].kind == "breath" + assert stream.export_state() == replica.export_state() + continue + assert actions[0].kind == "melee" + assert actions[0].target_id == game.party.members[0].id + assert actions[0].routine in {None, 0} + replica.randbelow(1) + assert stream.export_state() == replica.export_state(), "the policy drew more than the target pick" + + @pytest.mark.parametrize( + ("template_id", "names"), + [ + ("vampire_7", {"touch"}), + ("gorgon", {"gore"}), + ("red_dragon", {"claw", "bite"}), + ("hellhound_3", {"bite"}), + ("medium", {"dagger"}), + ("devil_swine", {"gore"}), + ("cave_locust", {"bite"}), + ("elephant", {"tusk"}), + ("stone_giant", {"weapon"}), + ], + ) + def test_battle_rolls_only_the_first_routine(self, template_id, names): + game, monster = _battle(template_id, members=[_character(level=12)]) + _spend_breath(monster) + + rolled = set() + rounds = 0 + while rounds < 10 and game.mode is SessionMode.BATTLE: + rolled |= {event.attack_name for event in _rolls(_round(game), monster.id)} + rounds += 1 + + assert rounds >= 3, "precondition: the battle ran long enough to see the routine" + assert rolled == names + + +# ---------------------------------------------------------------------- the later routine's effects + + +class TestTheLaterRoutineApplies: + """The effects of a later routine apply when battle resolves it, with the save the SRD gives.""" + + def test_the_spitting_cobra_spits_and_bites_under_the_default_policy(self): + game, cobra = _battle("spitting_cobra") + fighter = game.party.members[0] + _force_saves(game, fighter, ALWAYS_PASS) + + rolled = [] + for _ in range(30): + rolled += [event.attack_name for event in _rolls(_round(game), cobra.id)] + + assert set(rolled) == {"spit", "bite"} + assert len(rolled) == 30, "one attack a round: the routines are alternatives, not a sequence" + + def test_a_failed_save_against_the_cobras_bite_kills_in_1d10_turns(self): + game, cobra = _battle("spitting_cobra") + fighter = game.party.members[0] + _force_saves(game, fighter, ALWAYS_FAIL) + + for _ in range(40): + events = _round(game) + if _hits(events, cobra.id, "bite"): + break + else: + pytest.fail("precondition: the cobra's bite hit within 40 rounds") + + saves = _saves(events, fighter.id) + assert [save.category for save in saves] == ["death"], "save versus poison is the death category" + assert saves[0].code == "combat.save.failed" + assert has_condition(fighter, Condition.POISONED) + assert not has_condition(fighter, Condition.DEAD), "the cobra's poison kills in 1d10 turns, not at once" + poison = next( + effect for effect in game.ledger.active_on(fighter.id) if effect.definition.condition is Condition.POISONED + ) + onset = poison.expires_round - poison.attached_round + assert ROUNDS_PER_TURN <= onset <= 10 * ROUNDS_PER_TURN + assert onset % ROUNDS_PER_TURN == 0 + assert poison.definition.expiry == "death" + + def test_a_passed_save_leaves_the_bite_damage(self): + game, cobra = _battle("spitting_cobra") + fighter = game.party.members[0] + _force_saves(game, fighter, ALWAYS_PASS) + + for _ in range(40): + events = _round(game) + if _hits(events, cobra.id, "bite"): + break + else: + pytest.fail("precondition: the cobra's bite hit within 40 rounds") + + saves = _saves(events, fighter.id) + assert [(save.category, save.code) for save in saves] == [("death", "combat.save.passed")] + damage = [event for event in events if isinstance(event, DamageDealtEvent) and event.target_id == fighter.id] + assert damage and all(event.amount >= 1 for event in damage) + assert not has_condition(fighter, Condition.POISONED) + + def test_the_cobras_spit_applies_no_save_in_battle(self): + """Blindness stays with the referee, as the register says, whichever routine policy is in force.""" + game, cobra = _battle("spitting_cobra") + fighter = game.party.members[0] + _with_policy(game, None) + + hit = False + for _ in range(20): + events = _round(game) + rolled = _rolls(events, cobra.id) + assert rolled and {event.attack_name for event in rolled} == {"spit"} + hit = hit or bool(_hits(events, cobra.id, "spit")) + assert not _saves(events, fighter.id) + assert hit, "precondition: a spit hit" + assert not has_condition(fighter, Condition.BLIND) + + +# ---------------------------------------------------------------------- a game's own policy + + +class TestAGamesPolicyChoosesTheRoutine: + """A policy registered in `session.action_policies` names the routine, and battle resolves that routine.""" + + def test_no_routine_means_the_first(self): + game, cobra = _battle("spitting_cobra") + _with_policy(game, None) + + rolled = set() + for _ in range(10): + rolled |= {event.attack_name for event in _rolls(_round(game), cobra.id)} + + assert rolled == {"spit"} + + def test_the_named_alternative_is_the_one_rolled(self): + game, cobra = _battle("spitting_cobra") + fighter = game.party.members[0] + _force_saves(game, fighter, ALWAYS_PASS) + _with_policy(game, 1) + + rolled = [] + for _ in range(10): + rolled += [event.attack_name for event in _rolls(_round(game), cobra.id)] + + assert rolled == ["bite"] * 10 + + @pytest.mark.parametrize( + ("template_id", "index"), + [ + ("elephant", 1), + ("stone_giant", 1), + ("cave_locust", 1), + ("cave_locust", 2), + ("devil_swine", 1), + ("bull_shark", 1), + ("bone_golem", 1), + ], + ) + def test_a_named_situational_routine_is_rolled_as_melee(self, template_id, index): + game, monster = _battle(template_id, members=[_character(level=12)]) + routine = monster.template.attacks[index] + _force_saves(game, game.party.members[0], ALWAYS_PASS) + _with_policy(game, index) + + for _ in range(6): + rolled = [event.attack_name for event in _rolls(_round(game), monster.id)] + assert rolled == _expanded(routine), "each round rolls the named routine's attacks, and only those" + assert routine.use is RoutineUse.SITUATIONAL, "the routine rolled is one the default policy never picks" + + def test_the_thouls_weapon_routine_doesnt_paralyse(self): + game, thoul = _battle("thoul") + fighter = game.party.members[0] + _force_saves(game, fighter, ALWAYS_FAIL) + _with_policy(game, 1) + + hit = False + for _ in range(10): + events = _round(game) + rolled = _rolls(events, thoul.id) + assert [event.attack_name for event in rolled] == ["weapon"] + hit = hit or bool(_hits(events, thoul.id, "weapon")) + assert not _saves(events, fighter.id) + assert hit, "precondition: a weapon hit" + assert not has_condition(fighter, Condition.PARALYSED) + + def test_a_wrapped_default_policy_can_switch_to_a_situational_routine(self): + class TrampleAlways: + def choose(self, session, group, stream): + actions = ScriptedPolicy().choose(session, group, stream) + return [ + action.model_copy(update={"routine": 1}) if action.kind == "melee" else action for action in actions + ] + + game, elephant = _battle("elephant", members=[_character(level=12)]) + game.action_policies[game.encounter.groups[0].id] = TrampleAlways() + + rolled = set() + for _ in range(6): + rolled |= {event.attack_name for event in _rolls(_round(game), elephant.id)} + + assert rolled == {"trample"} + + @pytest.mark.parametrize(("template_id", "index"), [("spitting_cobra", 2), ("spitting_cobra", -1), ("goblin", 1)]) + def test_an_index_the_template_lacks_is_skipped(self, template_id, index): + """A negative index is not one the template has, so it isn't read from the end of the list.""" + game, monster = _battle(template_id) + fighter = game.party.members[0] + _with_policy(game, index) + + for _ in range(3): + events = _round(game) + assert not _rolls(events, monster.id) + assert fighter.current_hp == fighter.max_hp + + def test_naming_the_first_routine_matches_naming_none(self): + """For a monster with one routine, `routine=0` and `routine=None` play the same fight.""" + + def play(routine): + game, _ = _battle("goblin", seed=11) + _with_policy(game, routine) + events = [] + for _ in range(5): + events += _round(game) + return [event.model_dump(mode="json") for event in events] + + with_zero = play(0) + assert any(event["event_type"] == "attack_rolled" for event in with_zero), "precondition: the goblin attacked" + assert with_zero == play(None) + + +# ---------------------------------------------------------------------- special routines + + +class TestSpecialRoutinesAreNeverRolled: + """A special routine, and any entry named `gaze` or `breath`, is never rolled, whoever picked it.""" + + def test_the_basilisks_gaze_entry_stays_skipped(self): + game, basilisk = _battle("basilisk") + _force_saves(game, game.party.members[0], ALWAYS_PASS) + + rolled = set() + for _ in range(5): + rolled |= {event.attack_name for event in _rolls(_round(game), basilisk.id)} + + assert rolled == {"bite"} + + @pytest.mark.parametrize( + ("template_id", "index"), + [ + ("vampire_7", 1), + ("gorgon", 1), + ("hellhound_3", 1), + ("red_dragon", 1), + ("chimera", 1), + ("medium", 1), + ("nixie", 1), + ("devil_swine", 2), + ], + ) + def test_a_named_special_routine_rolls_nothing(self, template_id, index): + """A breath, a gaze, or a spell named in a melee action does nothing that round. + + The hellhound's breath entry has damage dice, so rolling it as melee would deal them. The + gorgon's petrifying breath and the vampire's charming gaze stay with the referee. + """ + game, monster = _battle(template_id) + fighter = game.party.members[0] + level = fighter.level + _force_saves(game, fighter, ALWAYS_FAIL) + _with_policy(game, index) + + for _ in range(5): + events = _round(game) + assert not _rolls(events, monster.id) + assert not [event for event in events if isinstance(event, LevelDrainedEvent)] + assert not _saves(events, fighter.id) + assert fighter.level == level + assert fighter.current_hp == fighter.max_hp + assert not has_condition(fighter, Condition.PETRIFIED) + assert not has_condition(fighter, Condition.CHARMED) + assert monster.template.attacks[index].use is RoutineUse.SPECIAL + + def test_the_vampires_touch_routine_still_drains(self): + game, vampire = _battle("vampire_7", members=[_character(level=6)]) + fighter = game.party.members[0] + _with_policy(game, 0) + + for _ in range(20): + events = _round(game) + if _hits(events, vampire.id, "touch"): + break + else: + pytest.fail("precondition: the vampire's touch hit within 20 rounds") + assert [event for event in events if isinstance(event, LevelDrainedEvent)] + assert fighter.level < 6 + + +# ---------------------------------------------------------------------- determinism + + +class TestDeterminism: + """The same seed and the same commands give the same events, whichever routines are picked.""" + + @staticmethod + def _play(template_id: str): + game, monster = _battle(template_id, seed=8, members=[_character("Hild"), _character("Brakk")]) + events = [] + for _ in range(20): + if game.mode is not SessionMode.BATTLE: + break + events += _round(game) + return game, monster, [event.model_dump(mode="json") for event in events] + + @pytest.mark.parametrize("template_id", ["spitting_cobra", "thoul", "minotaur"]) + def test_replaying_a_battle_gives_the_same_events(self, template_id): + first_game, monster, first = self._play(template_id) + second_game, _, second = self._play(template_id) + + names = { + event["attack_name"] + for event in first + if event["event_type"] == "attack_rolled" and event["attacker_id"] == monster.id + } + expected = set().union(*(_routine_names(monster.template)[index] for index in _candidates(template_id))) + assert names == expected, "precondition: the battle used every routine the policy may pick" + assert first == second + assert first_game.ledger.model_dump(mode="json") == second_game.ledger.model_dump(mode="json") + assert first_game.streams.export_states() == second_game.streams.export_states() + + +# ---------------------------------------------------------------------- the register + + +def _register() -> str: + return (REPO / "docs" / "adaptations.md").read_text(encoding="utf-8") + + +def _sentences(text: str) -> list[str]: + return re.split(r"(?<=[.!?])\s+", text) + + +def _section(text: str, heading: str) -> str: + match = re.search(rf"(?m)^### {re.escape(heading)}\n(.*?)(?=^#{{1,6}} |\Z)", text, re.S) + assert match, f"precondition: the register has the section {heading!r}" + return match.group(1) + + +class TestTheRegister: + """The adaptations register describes the routine choice and no longer says battle uses only the first.""" + + def test_the_register_no_longer_says_battle_uses_only_the_first_routine(self): + stale = [ + sentence + for sentence in _sentences(_register()) + if re.search(r"(?i)\bonly\b", sentence) and re.search(r"(?i)\bfirst\b[^.]*\broutine", sentence) + ] + assert not stale, f"the register still says battle uses only the first routine: {stale}" + + def test_the_default_policy_section_names_the_routine_choice(self): + section = _section(_register(), "The default action policy and morale") + assert re.search(r"(?i)\battack routines?\b", section), ( + "the default policy's section says how it picks a routine" + ) + + def test_the_register_leaves_situational_routines_to_a_games_policy(self): + records = [ + sentence + for sentence in _sentences(_register()) + if re.search(r"(?i)\bsituational\b", sentence) + and re.search(r"(?i)\broutines?\b", sentence) + and re.search(r"(?i)\bpolic(y|ies)\b", sentence) + ] + assert records, "the register says situational routines are left to a game's own policy" diff --git a/tools/srd_compile/__main__.py b/tools/srd_compile/__main__.py index fb17113..8851f02 100644 --- a/tools/srd_compile/__main__.py +++ b/tools/srd_compile/__main__.py @@ -99,11 +99,13 @@ def main() -> None: monsters_data = monsters.compile_monsters(srd_dir) apply_overrides(monsters_data["monsters"], load_overrides("monsters.json")) + monsters.label_routines(monsters_data["monsters"]) monsters.validate_xp(monsters_data["monsters"], combat_tables_data["xp_awards"]) _write(out_dir, "monsters.json", MonsterCatalog.model_validate(monsters_data), monsters.source_pages(srd_dir)) spells_data = spells.compile_spells(srd_dir, monsters_data["monsters"]) apply_overrides(spells_data["spells"], load_overrides("spells.json")) + monsters.label_routines([monster for spell in spells_data["spells"] for monster in spell["conjured_monsters"]]) _write(out_dir, "spells.json", SpellCatalog.model_validate(spells_data), spells.source_pages(srd_dir)) # Encounter-table overrides apply inside the compiler (they normalize the printed diff --git a/tools/srd_compile/monsters.py b/tools/srd_compile/monsters.py index a5c6614..f7db6d9 100644 --- a/tools/srd_compile/monsters.py +++ b/tools/srd_compile/monsters.py @@ -25,6 +25,10 @@ parsers; semantic corrections (the bone golem's `2 or 4 × weapon` routines, the minotaur's missing bite damage) live in `overrides/monsters.json` with reasons. +Every attack routine after the first is labelled with when the monster uses it, after the +overrides apply: breaths, gazes, and spells by name, and the rest from the `_ROUTINE_USES` +table. A later routine with no label fails the build, naming the monster. + Printed XP is authoritative and ships as data; the compiler cross-validates every entry against the XP-awards table (base by HD row plus asterisks × bonus, both inflated by 250 per HD above 21) and fails the build on mismatch unless the entry's @@ -235,6 +239,56 @@ "Giant.md": ("Fire Giant",), } +# Every attack routine carries a label saying when the monster uses it, one of the +# `RoutineUse` values in osrlib.core.monsters. The first routine is always `primary`. A +# later routine whose attacks are all a breath, a gaze, or a spell is `special` by its +# name, so a new dragon or hellhound variant needs no entry. Every other later routine is +# labelled in `_ROUTINE_USES`, by template id and the routine's attack name, from the +# monster's text in the OSE SRD. `label_routines` fails the build on a later routine that +# neither covers, so a new SRD monster with a second routine can't ship unlabelled. +_SPECIAL_ROUTINE_NAMES = frozenset({"breath", "gaze", "spell", "group spell", "magic"}) + +_ROUTINE_USES: dict[str, dict[str, str]] = { + # `alternative`: a free choice each round. The SRD prints "or" and names no situation. + "minotaur": {"weapon": "alternative"}, + "mule": {"bite": "alternative"}, + "spitting_cobra": {"bite": "alternative"}, + "thoul": {"weapon": "alternative"}, + "wererat": {"weapon": "alternative"}, # "Weapons: May also use weapons in animal form." + # `situational`: the SRD ties the routine to a situation osrlib doesn't model, so only a + # game's own action policy picks it. A form: the devil swine gores as a swine and wields + # a weapon in human form. + "devil_swine": {"weapon": "situational"}, + # Range: a thrown or spat missile ("Boulder throwing: Up to 200'."). + "ape_white": {"thrown rock": "situational"}, + "cloud_giant": {"boulder": "situational"}, + "cyclops": {"rock": "situational"}, # "Rock throwing: Up to 200'." + "fire_giant": {"boulder": "situational"}, + "frost_giant": {"boulder": "situational"}, + "manticore": {"tail spike": "situational"}, # "Tail spikes: 180' range." + "stone_giant": {"boulder": "situational"}, + # "Jump: ... 50% chance of jumping at a random opponent" and "Spit: Used defensively. 10' range." + "cave_locust": {"jump": "situational", "spit": "situational"}, + # A chance: "Trample: 3-in-4 chance of trampling each round." + "elephant": {"trample": "situational"}, + "mastodon": {"trample": "situational"}, + "normal_rhinoceros": {"trample": "situational"}, + "stegosaurus": {"trample": "situational"}, + "titanothere": {"trample": "situational"}, + "triceratops": {"trample": "situational"}, + "woolly_rhinoceros": {"trample": "situational"}, + # Water and ships. + "bull_shark": {"ram": "situational"}, # "Attack by ramming, then biting the stunned prey." + "freshwater_termite": {"spray": "situational"}, # "Irritant spray: Above water; maximum once a turn." + "saltwater_termite": {"spray": "situational"}, + "swamp_termite": {"spray": "situational"}, + "sea_serpent_lesser": {"squeeze": "situational"}, # "Squeeze: Coil around and crush a vessel" + "sperm_whale": {"ram": "situational"}, # "Ram ships: 10% likely to attack vessels" + # A loadout: "Weapons: 4×1-handed or 2×2-handed." The override in overrides/monsters.json + # splits the printed "2 or 4 × weapon" into the two routines. + "bone_golem": {"weapon": "situational"}, +} + def manifest(srd_dir: Path) -> list[tuple[str, str]]: """Return the monster page inventory: `(display name, filename)` pairs. @@ -514,6 +568,48 @@ def _parse_attacks(cell: str) -> list[dict[str, object]]: return routines +def label_routines(entries: list[dict[str, object]]) -> None: + """Label every attack routine of every entry with when the monster uses it. + + The first routine is `primary`. A later routine whose attacks are all named in + `_SPECIAL_ROUTINE_NAMES` is `special`, and every other later routine takes its label + from `_ROUTINE_USES`. Run it after overrides, because an override can replace a + template's routines. The monsters printed on spell pages go through it too. + + Args: + entries: Compiled monster-template dicts, labelled in place. + + Raises: + ValueError: When a later routine has no label, naming each such monster, or when + `_ROUTINE_USES` labels a routine the monster doesn't have. + """ + problems = [] + for entry in entries: + table = _ROUTINE_USES.get(str(entry["id"]), {}) + labelled = set() + routines = entry["attacks"] + assert isinstance(routines, list) + for index, routine in enumerate(routines): + names = [str(attack["name"]) for attack in routine["attacks"]] + key = ", ".join(names) + if index == 0: + routine["use"] = "primary" + elif all(name in _SPECIAL_ROUTINE_NAMES for name in names): + routine["use"] = "special" + elif key in table: + routine["use"] = table[key] + labelled.add(key) + else: + problems.append(f"{entry['id']} ({entry['page']}): routine {index + 1} ({key}) has no label") + for key in sorted(set(table) - labelled): + problems.append(f"{entry['id']} ({entry['page']}): no later routine is {key!r}, which _ROUTINE_USES labels") + if problems: + raise ValueError( + "attack routine labels (label each later routine in _ROUTINE_USES in tools/srd_compile/monsters.py):\n" + + "\n".join(problems) + ) + + def _parse_movement(cell: str) -> list[dict[str, object]]: modes = [] for part in _normalize(cell).split(" / "):