use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_fields::default_true;
use crate::{AnchorId, AssemblyId, FlagId, NpcId, QuestEffect, StateCompare, TriggerId};
#[cfg(doc)]
use crate::stepped_blocks;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EnvTrigger {
pub id: TriggerId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<AnchorId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prop: Option<Prop>,
pub on: TriggerOn,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires_flags: Vec<FlagId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forbids_flags: Vec<FlagId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires_state: Vec<StateCompare>,
#[serde(default = "default_true")]
pub once: bool,
#[serde(default, skip_serializing_if = "TriggerAudience::is_party")]
pub audience: TriggerAudience,
pub effects: Vec<QuestEffect>,
}
impl EnvTrigger {
pub fn at_anchor(&self) -> Option<&str> {
self.at.as_ref().map(|a| a.as_str())
}
pub fn addresses_presser(&self) -> bool {
self.audience == TriggerAudience::Presser
}
pub fn attributes_its_actor(&self) -> bool {
matches!(self.on, TriggerOn::Use | TriggerOn::Step)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TriggerAudience {
#[default]
Party,
Presser,
}
impl TriggerAudience {
fn is_party(&self) -> bool {
*self == TriggerAudience::Party
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "on", rename_all = "kebab-case", deny_unknown_fields)]
pub enum TriggerOn {
Strike,
Use,
Approach {
range: u32,
},
Step,
StrikeNpc {
npc: NpcId,
},
StrikeAssembly {
assembly: AssemblyId,
},
}
impl TriggerOn {
pub fn kind(&self) -> &'static str {
match self {
TriggerOn::Strike => "strike",
TriggerOn::Use => "use",
TriggerOn::Approach { .. } => "approach",
TriggerOn::Step => "step",
TriggerOn::StrikeNpc { .. } => "strike-npc",
TriggerOn::StrikeAssembly { .. } => "strike-assembly",
}
}
pub fn is_click(&self) -> bool {
matches!(
self,
TriggerOn::Strike
| TriggerOn::Use
| TriggerOn::StrikeNpc { .. }
| TriggerOn::StrikeAssembly { .. }
)
}
pub fn needs_anchor(&self) -> bool {
!matches!(
self,
TriggerOn::StrikeNpc { .. } | TriggerOn::StrikeAssembly { .. }
)
}
pub fn assembly_target(&self) -> Option<&AssemblyId> {
match self {
TriggerOn::StrikeAssembly { assembly } => Some(assembly),
_ => None,
}
}
pub fn npc_target(&self) -> Option<&NpcId> {
match self {
TriggerOn::StrikeNpc { npc } => Some(npc),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Prop {
pub block: String,
}
impl Prop {
pub fn is_hand_pressed(&self) -> bool {
crate::blockshape::is_hand_pressed(&self.block)
}
}
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::quest::check::check_effect_v04;
use crate::registry::{AnchorRegistry, BlockRegistry};
use crate::validate::{
AnchorProviders, check_block_field, for_each_trigger_effect_deep, station_kind_diag,
};
use std::collections::{BTreeMap, BTreeSet};
crate::dw_code! {
pub const TRIGGER_INVALID: DwCode = DwCode::new("DW0194", ExitTier::Build);
}
crate::dw_code! {
pub const USE_TRIGGER_ON_NPC: DwCode = DwCode::new("DW0350", ExitTier::Build);
}
crate::dw_code! {
pub const TRIGGER_AUDIENCE_UNATTRIBUTABLE: DwCode = DwCode::new("DW0427", ExitTier::Build);
}
crate::dw_code! {
pub const TRIGGER_ID_RESERVED: DwCode = DwCode::new("DW0428", ExitTier::Build);
}
crate::dw_code! {
pub const SEALED_BODY_UNANSWERED: DwCode = DwCode::new("DW0429", ExitTier::Build);
}
pub(crate) fn press_answer_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, t) in c.quests.content.triggers.iter().enumerate() {
if t.addresses_presser() && !t.attributes_its_actor() {
d.push(Diagnostic::error(
TRIGGER_AUDIENCE_UNATTRIBUTABLE,
"quests",
format!("/content/triggers/{i}/audience"),
format!(
"trigger `{}` watches a `{}` and asks for `audience: presser`, but vanilla \
names the player only for a RIGHT-click and a step. \
`minecraft:player_interacted_with_entity` is the one criterion that runs a \
function as the clicker, and a step is a player standing in the cell; a \
left-click is recorded in the interaction entity's `attack` NBT, which names \
a UUID no command can become, and an `approach` is not attributed. Guessing \
— polling the record and hoping the nearest player is the striker — is the \
kind of downstream folklore this engine refuses (CLAUDE.md: a capability \
with no vanilla primitive under it is excluded, not faked). Prescription: \
make it an `on: use` or `on: step` trigger, or drop `audience` and let the \
beat address the party",
t.id,
t.on.kind()
),
));
}
let local = crate::l10n::local_id(t.id.as_str());
if local.starts_with(RESERVED_TRIGGER_PREFIX) {
d.push(Diagnostic::error(
TRIGGER_ID_RESERVED,
"quests",
format!("/content/triggers/{i}/id"),
format!(
"trigger id `{}` opens with `{RESERVED_TRIGGER_PREFIX}`, which the compiler \
reserves for the triggers it synthesizes itself — today the press answer \
every sealed gate and shortcut door gives (`trigger/dw-press-…`). Two \
triggers with one id would share one `dw_trig_…` tag and one emitted \
function, so one of them would silently vanish. Prescription: rename it; any \
kebab id not starting with `{RESERVED_TRIGGER_PREFIX}` is yours",
t.id
),
));
}
}
}
pub(crate) fn press_obligation_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
let mut sealed: BTreeMap<&str, (bool, String)> = BTreeMap::new();
crate::for_each_campaign_effect(c, &mut |path, _site, eff| {
let Some(anchor) = eff.close_gate_anchor() else {
return;
};
let entry = sealed
.entry(anchor.as_str())
.or_insert_with(|| (false, path.to_string()));
entry.0 |= eff.close_gate_sealed_hint().is_some();
});
for (anchor, (authored, path)) in sealed {
if authored || quests.answers_press_at(anchor) {
continue;
}
d.push(Diagnostic::error(
SEALED_BODY_UNANSWERED,
"quests",
path,
format!(
"this `close-gate` seals `{anchor}`, and nothing says what the wall answers when \
the party presses it. A seal is a thing the party walks back to and pushes on, \
so the press has to say something — and the compiler will not word it for you: a \
baked default decides this wall's tone on your behalf and never tells you it \
did. Two ways to say it, and either is enough: add `\"sealed_hint\": \"<what the \
wall says>\"` to this effect, or anchor a trigger on the gate — \
`{{\"id\": \"trigger/<name>\", \"at\": \"{anchor}\", \"on\": {{\"on\": \"use\"}}, \
\"once\": false, \"audience\": \"presser\", \"effects\": [{{\"type\": \"narrate\", \
\"style\": \"actionbar\", \"text\": \"<what the wall says>\"}}]}}`. The trigger form \
is the general one and can carry a sound, a flag gate or any other effect"
),
));
}
for (i, sc) in quests.shortcuts.iter().enumerate() {
let gate = sc.gate.as_str();
if quests.answers_press_at(gate) {
continue;
}
d.push(Diagnostic::error(
SEALED_BODY_UNANSWERED,
"quests",
format!("/content/shortcuts/{i}"),
format!(
"shortcut `{}` bars the gate `{gate}` from world-load, and nothing in the \
campaign answers a right-click on it — so a player who walks the long way \
round, arrives at the wrong side of the door and pushes on it is told nothing. \
That is the press a shortcut loop most invites. The compiler will not word it \
for you: a baked default would be the engine deciding this door's tone and \
never saying that it had. A `shortcut` carries no wording field, deliberately — \
the line is a trigger. Prescription: add a trigger anchored on the gate — \
`{{\"id\": \"trigger/<name>\", \"at\": \"{gate}\", \"on\": {{\"on\": \"use\"}}, \
\"once\": false, \"audience\": \"presser\", \"effects\": [{{\"type\": \"narrate\", \
\"style\": \"actionbar\", \"text\": \"<what the door says>\"}}]}}` — which rides \
the door's own hitboxes, fires only from the sealed side, and retires when the \
door opens. Any `use` trigger on `{gate}` discharges this, whatever it does",
sc.id
),
));
}
}
const RESERVED_TRIGGER_PREFIX: &str = "dw-";
pub(crate) fn trigger_anchor_checks(
c: &Campaign,
providers: &AnchorProviders,
d: &mut Vec<Diagnostic>,
) {
if providers.all_areas_known() {
for (ti, t) in c.quests.content.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
for (suffix, anchor, demands) in eff.anchor_refs() {
if let Some(f) = station_kind_diag(
providers,
anchor.as_str(),
demands,
&format!("`{}`", eff.verb.tag()),
"quests",
format!("/content/triggers/{ti}/{path}/{suffix}"),
) {
d.push(f);
continue;
}
if providers.union().contains(anchor.as_str()) {
continue;
}
d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/triggers/{ti}/{path}/{suffix}"),
format!(
"`{verb}` anchor `{anchor}` in an environment trigger is not \
provided by any area's prefab — {}",
providers.anchor_remedy(
"use an anchor a prefab exposes (anchor names come from prefab \
metadata; do NOT invent one)"
),
verb = eff.verb.tag(),
),
));
}
});
}
}
}
pub(crate) fn trigger_effect_flag_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let quests = &c.quests.content;
let declared_flags: BTreeSet<String> = crate::validate::produced_flags(c);
for (i, t) in quests.triggers.iter().enumerate() {
for_each_trigger_effect_deep(t, |path, eff| {
for (n, f) in eff.requires_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/{path}/when/requires_flags/{n}"),
format!(
"effect `requires_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
earlier, or correct the flag name"
),
));
}
}
for (n, f) in eff.forbids_flags().iter().enumerate() {
if !declared_flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/{path}/when/forbids_flags/{n}"),
format!(
"effect `forbids_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — the gate can never suppress anything; add the \
producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag \
name"
),
));
}
}
});
}
}
pub(crate) fn trigger_prop_checks(
c: &Campaign,
blocks: &dyn BlockRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
for (i, t) in quests.triggers.iter().enumerate() {
if let Some(prop) = &t.prop {
check_block_field(
blocks,
&prop.block,
format!("/content/triggers/{i}/prop/block"),
"triggers[].prop",
"minecraft:lever[face=floor,facing=north]",
d,
);
}
}
}
pub(crate) fn trigger_decl_checks(
c: &Campaign,
anchors: &dyn AnchorRegistry,
blocks: &dyn BlockRegistry,
flags: &BTreeSet<&str>,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
let npc_ids: BTreeSet<&str> = c.npcs.content.npcs.iter().map(|n| n.id.as_str()).collect();
let declared_waves: BTreeSet<&str> = quests.waves.iter().map(|w| w.id.as_str()).collect();
let providers = AnchorProviders::build(c, anchors);
let mut seen_triggers: BTreeSet<&str> = BTreeSet::new();
for (i, t) in quests.triggers.iter().enumerate() {
if !t.id.is_valid_syntax() {
d.push(Diagnostic::error(
TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/id"),
format!(
"malformed trigger id `{}` — trigger ids must be lowercase kebab-case with \
the `trigger/` prefix (e.g. `trigger/pressure-plate`)",
t.id
),
));
}
if !seen_triggers.insert(t.id.as_str()) {
d.push(Diagnostic::error(
TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/id"),
format!(
"duplicate trigger id `{}` — rename one so every trigger id is unique",
t.id
),
));
}
match (t.on.needs_anchor(), t.at_anchor()) {
(true, None) => d.push(Diagnostic::error(
TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"trigger `{}` fires on `{}`, which watches a place, but declares no `at` \
anchor — add one ({}), or switch to `strike-npc` if the target is an NPC's \
body",
t.id,
t.on.kind(),
providers
.anchor_remedy("anchor names come from prefab metadata; do NOT invent one"),
),
)),
(false, Some(at)) => d.push(Diagnostic::error(
TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"trigger `{}` fires on `{}`, whose target is {} — it watches no cell, so \
the `at` anchor `{at}` names nothing and would be silently ignored. \
Remove `at`.",
t.id,
t.on.kind(),
match (t.on.npc_target(), t.on.assembly_target()) {
(Some(n), _) => format!("NPC `{n}`'s body"),
(_, Some(m)) => format!("assembly `{m}`'s hitbox"),
_ => "an object".to_string(),
}
),
)),
(true, Some(at)) if !providers.resolvable(at) => d.push(Diagnostic::error(
codes::ANCHOR_UNRESOLVED,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"trigger `at` anchor `{at}` is not provided by any area's prefab — {}",
providers.anchor_remedy(
"set `at` to an anchor some area's prefab exposes (anchor names come from \
prefab metadata; do NOT invent one)"
),
),
)),
_ => {}
}
if let Some(npc) = t.on.npc_target()
&& !c.npcs.content.npcs.iter().any(|n| n.id == *npc)
{
d.push(Diagnostic::error(
codes::DANGLING_REF,
"quests",
format!("/content/triggers/{i}/on/npc"),
format!(
"`strike-npc` trigger `{}` targets NPC `{npc}`, which stage 2 does not \
declare — use a declared npc id",
t.id
),
));
}
if let TriggerOn::Approach { range } = &t.on
&& *range == 0
{
d.push(Diagnostic::error(
TRIGGER_INVALID,
"quests",
format!("/content/triggers/{i}/on/range"),
"`approach` trigger `range` must be > 0 — set a positive block radius (e.g. 3)"
.to_string(),
));
}
if matches!(t.on, TriggerOn::Use)
&& let Some(at) = t.at_anchor()
&& let Some(npc) = c
.npcs
.content
.npcs
.iter()
.find(|n| n.anchor.as_str() == at && n.offset == [0, 0, 0])
{
d.push(Diagnostic::error(
USE_TRIGGER_ON_NPC,
"quests",
format!("/content/triggers/{i}/at"),
format!(
"`use` trigger `{}` is anchored at `{}`, where NPC `{}` stands — a \
right-click there already belongs to the NPC's dialogue, and two \
interaction hitboxes in one cell race for the same click (the loser is \
silently dead, which can soft-lock the delve). Move the trigger to its \
own anchor, or express the interaction as a dialogue option on the NPC. \
(To make an NPC's body itself the target, use `strike-npc`.)",
t.id, at, npc.id
),
));
}
for (m, f) in t.requires_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/requires_flags/{m}"),
format!(
"trigger `requires_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — add a `set-flag {{ flag: \"{f}\" }}` effect \
somewhere, or correct the flag name"
),
));
}
}
for (m, f) in t.forbids_flags.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("/content/triggers/{i}/forbids_flags/{m}"),
format!(
"trigger `forbids_flags` references flag `{f}`, which no `set-flag` \
effect ever produces — the gate can never suppress anything; add the \
producing `set-flag {{ flag: \"{f}\" }}` effect, or correct the flag name"
),
));
}
}
for_each_trigger_effect_deep(t, |path, eff| {
check_effect_v04(
eff,
blocks,
&declared_waves,
&format!("/content/triggers/{i}/{path}"),
&npc_ids,
d,
);
});
}
}