Skip to main content

delvewright_dsl/
onkill.rs

1//! **A kill pays** (spec-0074): what happens each time a body of a fight is
2//! killed.
3//!
4//! `on_kill` is an optional property of `waves[]` and `actors[]` — one type on
5//! both. Its `effects` run once per body a player is **credited** with killing
6//! (vanilla's `minecraft:player_killed_entity`), as that player, so a
7//! `player`-scoped datum pays the killer and a `party`-scoped one pays the party
8//! once. It is effect root **R9** ([`crate::EffectRootKind::OnKill`]).
9//!
10//! `fires` is the creator's judgement on whether a body that comes back pays
11//! again. It is required exactly where the fight comes back after the party has
12//! met it (`DW0915`) and refused as inert where it does not (`DW0914`); both
13//! refusals read one compiler-side predicate (`plan::fight_comes_back`), which
14//! needs the campaign's rest points.
15//!
16//! This module holds the types ([`OnKill`], [`KillFires`]) and the two
17//! refusals that need only the documents:
18//!
19//! * `DW0100` — an empty `effects` list (the exported schema's `minItems: 1`,
20//!   which serde does not enforce);
21//! * `DW0913` — a bundle on a body no player can be credited with killing: a
22//!   wave no beat spawns ([`crate::fight::wave_area`] is `None`), or an actor no
23//!   `unleash-actor` names that is not `vulnerable`
24//!   ([`crate::fight::unleashed_actors`]).
25
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28
29use crate::QuestEffect;
30use crate::diagnostic::{Diagnostic, codes};
31use crate::envelope::Campaign;
32use crate::fight::{Fight, fights, unleashed_actors, wave_area};
33
34/// The document-tier `on_kill` refusals (spec-0074 §8.1): `DW0100` for an empty
35/// `effects` list and `DW0913` for a bundle no credited kill can ever reach. A
36/// campaign that declares no bundle gets nothing.
37pub fn on_kill_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
38    let unleashed = unleashed_actors(c);
39    for (path, fight) in fights(c) {
40        let Some(ok) = fight.on_kill() else {
41            continue;
42        };
43        if ok.effects.is_empty() {
44            d.push(Diagnostic::error(
45                codes::SCHEMA,
46                "quests",
47                format!("{path}/on_kill/effects"),
48                format!(
49                    "`on_kill` on {} `{}` has no effects. The schema requires at least one \
50                     (`minItems: 1`): a bundle that does nothing is not a bundle. Add the effect \
51                     each kill should have, or remove `on_kill`.",
52                    fight.word(),
53                    fight.id()
54                ),
55            ));
56        }
57        let unreachable = match fight {
58            Fight::Wave(w) => wave_area(c, w.id.as_str()).is_none().then(|| {
59                format!(
60                    "no beat spawns wave `{}` — no `spawn-wave` names it where the engine can \
61                     seat it, so it has no bodies and no kill can be credited. Spawn it from a \
62                     beat, or remove the bundle.",
63                    w.id
64                )
65            }),
66            Fight::Actor(a) => (!a.vulnerable && !unleashed.contains(a.id.as_str())).then(|| {
67                format!(
68                    "actor `{}` is never unleashed and is not `vulnerable`, so its body is \
69                     `Invulnerable` for the whole delve and no player can be credited with \
70                     killing it. Unleash it with an `unleash-actor` beat, mark it \
71                     `vulnerable: true`, or remove the bundle.",
72                    a.id
73                )
74            }),
75        };
76        if let Some(why) = unreachable {
77            d.push(Diagnostic::error(
78                codes::ON_KILL_UNREACHABLE,
79                "quests",
80                format!("{path}/on_kill"),
81                format!(
82                    "`on_kill` on {} `{}` can never fire: {why}",
83                    fight.word(),
84                    fight.id()
85                ),
86            ));
87        }
88    }
89}
90
91/// What happens each time a body of this fight is killed (spec-0074): the
92/// `on_kill` of a wave or an actor.
93#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
94#[serde(deny_unknown_fields)]
95pub struct OnKill {
96    /// Whether a body that comes back pays again. Required where the fight comes
97    /// back after the party has met it — a bonfire re-seats it, or the beat that
98    /// seats it can fire more than once (`DW0915`); left off where it does not,
99    /// and `every-kill` there is refused as inert (`DW0914`). No default: whether
100    /// an economy can be farmed is the creator's judgement.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub fires: Option<KillFires>,
103    /// The effects, run as the credited player for each credited kill. Every verb
104    /// an `on_objective_complete` bundle accepts, each gated by its own `when`.
105    #[schemars(length(min = 1))]
106    pub effects: Vec<QuestEffect>,
107}
108
109/// Whether a body that comes back after a rest pays again (spec-0074 §4).
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(rename_all = "kebab-case")]
112pub enum KillFires {
113    /// Over the whole delve the fight pays at most once per body it seats — the
114    /// wave's body count, or once for an actor. A re-seat does not renew it.
115    FirstKill,
116    /// Every credited kill pays, however many times the fight is re-seated.
117    EveryKill,
118}
119
120impl KillFires {
121    /// The kebab token, as it appears in the DSL.
122    pub fn token(self) -> &'static str {
123        match self {
124            KillFires::FirstKill => "first-kill",
125            KillFires::EveryKill => "every-kill",
126        }
127    }
128}