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}