use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::{AnchorId, FlagId, QuestEffect, StateCompare, TrapId};
#[cfg(doc)]
use crate::EnvTrigger;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Trap {
pub id: TrapId,
pub at: AnchorId,
pub trigger: TrapTrigger,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<TrapEffect>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub payload: Vec<QuestEffect>,
#[serde(default)]
pub lethality: Lethality,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disarm: Option<TrapDisarm>,
#[serde(default)]
pub reset: TrapReset,
#[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>,
}
impl Trap {
pub fn dispense(&self) -> Option<(&str, u32)> {
match &self.effect {
Some(TrapEffect::Dispense { item, count }) => Some((item.as_str(), *count)),
None => None,
}
}
pub fn is_lethal(&self) -> bool {
matches!(self.lethality, Lethality::Lethal)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TrapTrigger {
PressurePlate,
Tripwire,
TrappedChest,
}
impl TrapTrigger {
pub fn kind(&self) -> &'static str {
match self {
TrapTrigger::PressurePlate => "pressure-plate",
TrapTrigger::Tripwire => "tripwire",
TrapTrigger::TrappedChest => "trapped-chest",
}
}
pub fn is_trigger_block(&self, block: &str) -> bool {
let id = block.split('[').next().unwrap_or(block);
match self {
TrapTrigger::PressurePlate => id.ends_with("_pressure_plate"),
TrapTrigger::Tripwire => id == "minecraft:tripwire",
TrapTrigger::TrappedChest => id == "minecraft:trapped_chest",
}
}
pub fn trigger_block_name(&self) -> &'static str {
match self {
TrapTrigger::PressurePlate => "a pressure plate (`minecraft:*_pressure_plate`)",
TrapTrigger::Tripwire => "a tripwire string (`minecraft:tripwire`)",
TrapTrigger::TrappedChest => "a trapped chest (`minecraft:trapped_chest`)",
}
}
}
impl TrapTrigger {
pub const STEPPED: [TrapTrigger; 2] = [TrapTrigger::PressurePlate, TrapTrigger::Tripwire];
}
pub fn stepped_blocks() -> Vec<&'static str> {
crate::blocks::BlockRegistry::v1_21_11()
.ids()
.filter(|id| TrapTrigger::STEPPED.iter().any(|k| k.is_trigger_block(id)))
.collect()
}
pub fn fires_on_step(block: &str) -> bool {
let id = block.split('[').next().unwrap_or(block);
let id = if id.contains(':') {
id.to_string()
} else {
format!("minecraft:{id}")
};
stepped_blocks().contains(&id.as_str())
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub enum TrapEffect {
Dispense {
item: String,
count: u32,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Lethality {
Lethal,
#[default]
Harmful,
Nonlethal,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TrapReset {
Once,
#[default]
Rearm,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TrapDisarm {
pub via: AnchorId,
pub sets_flag: FlagId,
}
use std::collections::BTreeSet;
use crate::Verb;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::loot::check_stack_count;
use crate::registry::{
AnchorRegistry, BlockRegistry, EntityRegistry, ItemBackedBlockRegistry, ItemRegistry,
};
use crate::validate::{
AnchorProviders, collect_declared_flags, for_each_trap_payload_deep, station_kind_diag,
};
crate::dw_code! {
pub const TRAP_INVALID: DwCode = DwCode::new("DW0340", ExitTier::Build);
}
crate::dw_code! {
pub const TRAP_PAYLOAD_UNKNOWN: DwCode = DwCode::new("DW0341", ExitTier::Build);
}
crate::dw_code! {
pub const TRAP_NO_CONSEQUENCE: DwCode = DwCode::new("DW0440", ExitTier::Build);
}
crate::dw_code! {
pub const TRAP_VERB_ID_UNKNOWN: DwCode = DwCode::new("DW0441", ExitTier::Build);
}
crate::dw_code! {
pub const VOLLEY_CADENCE: DwCode = DwCode::new("DW0443", ExitTier::Build);
}
pub(crate) fn trap_checks(
c: &Campaign,
items: &dyn ItemRegistry,
entities: &dyn EntityRegistry,
anchors: &dyn AnchorRegistry,
d: &mut Vec<Diagnostic>,
) {
let quests = &c.quests.content;
if quests.traps.is_empty() {
return;
}
let providers = AnchorProviders::build(c, anchors);
let flags = collect_declared_flags(c);
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (i, t) in quests.traps.iter().enumerate() {
if !t.id.is_valid_syntax() {
d.push(Diagnostic::error(
TRAP_INVALID,
"quests",
format!("/content/traps/{i}/id"),
format!(
"malformed trap id `{}` — trap ids must be lowercase kebab-case with the \
`trap/` prefix (e.g. `trap/dart-hall`)",
t.id
),
));
}
if !seen.insert(t.id.as_str()) {
d.push(Diagnostic::error(
TRAP_INVALID,
"quests",
format!("/content/traps/{i}/id"),
format!(
"duplicate trap id `{}` — rename one so every trap id is unique",
t.id
),
));
}
if let Some(f) = station_kind_diag(
&providers,
t.at.as_str(),
crate::layout::StationKind::Point,
"a trap's `at`",
"quests",
format!("/content/traps/{i}/at"),
) {
d.push(f);
}
if !providers.resolvable(t.at.as_str()) {
d.push(Diagnostic::error(
TRAP_INVALID,
"quests",
format!("/content/traps/{i}/at"),
format!(
"trap `at` anchor `{}` is not provided by any area's prefab — {}",
t.at,
providers.anchor_remedy(
"bind the trap to a point anchor some area's prefab exposes, whatever \
that anchor is called (names come from prefab metadata; do NOT invent \
one). A `payload` trap needs nothing of the piece but that one cell and \
the trigger block standing in it (`DW0917`) — the compiler emits the \
detection; only a legacy `dispense` effect \
needs the anchor's `dispenser` socket, and only a flag-gated trap \
needs its `trigger_block`"
),
),
));
}
if let Some(dis) = &t.disarm {
if let Some(f) = station_kind_diag(
&providers,
dis.via.as_str(),
crate::layout::StationKind::Point,
"a trap's disarm affordance",
"quests",
format!("/content/traps/{i}/disarm/via"),
) {
d.push(f);
}
if !providers.resolvable(dis.via.as_str()) {
d.push(Diagnostic::error(
TRAP_INVALID,
"quests",
format!("/content/traps/{i}/disarm/via"),
format!(
"trap `disarm.via` anchor `{}` is not provided by any area's prefab — \
{}",
dis.via,
providers.anchor_remedy(
"use an anchor some area's prefab exposes for the disarm affordance"
),
),
));
}
if dis.via == t.at {
d.push(Diagnostic::error(
TRAP_INVALID,
"quests",
format!("/content/traps/{i}/disarm/via"),
format!(
"trap `disarm.via` anchor `{}` is the trap's own trigger anchor — the \
disarm must be a distinct, separately-reachable affordance, not the trap \
cell itself",
dis.via
),
));
}
}
if t.effect.is_none() && t.payload.is_empty() {
d.push(Diagnostic::error(
TRAP_NO_CONSEQUENCE,
"quests",
format!("/content/traps/{i}"),
format!(
"trap `{}` declares no consequence — give it a `payload` (an ordered \
effect list: `volley`, `collapse`, `damage-players`, `play-sound`, \
`narrate`, `set-flag`, `spawn-wave`, …). A trigger with nothing \
downstream of it is scenery, not a trap",
t.id
),
));
}
for_each_trap_payload_deep(t, |path, eff| {
let base = format!("/content/traps/{i}/{path}");
match &eff.verb {
Verb::Volley {
projectile,
salvos,
interval,
..
} => {
let proj = projectile
.as_deref()
.unwrap_or(crate::DEFAULT_VOLLEY_PROJECTILE);
if !entities.contains(proj) {
d.push(Diagnostic::error(
TRAP_VERB_ID_UNKNOWN,
"quests",
format!("{base}/projectile"),
format!(
"volley `projectile` `{proj}` is not in the pinned 1.21.11 \
entity registry — use a projectile entity id (e.g. \
`minecraft:arrow`, `minecraft:spectral_arrow`)"
),
));
}
let n = salvos.unwrap_or(crate::DEFAULT_VOLLEY_SALVOS);
if n == 0 || n > crate::MAX_VOLLEY_SALVOS {
d.push(Diagnostic::error(
VOLLEY_CADENCE,
"quests",
format!("{base}/salvos"),
format!(
"volley `salvos` is {n} — must be 1..={}. A volley fires \
its whole kill zone every salvo, so the entity count is \
`salvos x standable cells`; beyond the cap that is a \
server hazard, not a trap",
crate::MAX_VOLLEY_SALVOS
),
));
}
let iv = interval.unwrap_or(crate::DEFAULT_VOLLEY_INTERVAL);
if iv == 0 || iv > crate::MAX_VOLLEY_INTERVAL {
d.push(Diagnostic::error(
VOLLEY_CADENCE,
"quests",
format!("{base}/interval"),
format!(
"volley `interval` is {iv} ticks — must be 1..={}. Salvos \
spaced wider than that stop reading as one trap event",
crate::MAX_VOLLEY_INTERVAL
),
));
}
}
Verb::Collapse {
falling_block,
then_floor,
..
} => {
let blocks = ItemBackedBlockRegistry::new(items);
let fb = falling_block
.as_deref()
.unwrap_or(crate::DEFAULT_COLLAPSE_FALLING_BLOCK);
for (field, id) in [
("falling_block", Some(fb)),
("then_floor", then_floor.as_deref()),
] {
let Some(id) = id else { continue };
if !blocks.contains(id) {
d.push(Diagnostic::error(
TRAP_VERB_ID_UNKNOWN,
"quests",
format!("{base}/{field}"),
format!(
"collapse `{field}` `{id}` is not in the pinned 1.21.11 \
block registry — use a placeable block id (e.g. \
`minecraft:gravel`, `minecraft:sand`)"
),
));
}
}
}
_ => {}
}
for (kind, list) in [
("requires_flags", eff.requires_flags()),
("forbids_flags", eff.forbids_flags()),
] {
for (n, f) in list.iter().enumerate() {
if !flags.contains(f.as_str()) {
d.push(Diagnostic::error(
codes::FLAG_UNKNOWN,
"quests",
format!("{base}/{kind}/{n}"),
format!(
"trap payload effect `{kind}` references flag `{f}`, which no \
`set-flag` effect ever produces — add the producing \
`set-flag {{ flag: \"{f}\" }}`, or correct the flag name"
),
));
}
}
}
if let Some(w) = eff.spawn_wave()
&& !c.quests.content.waves.iter().any(|x| x.id == *w)
{
d.push(Diagnostic::error(
codes::WAVE_UNKNOWN,
"quests",
format!("{base}/wave"),
format!("trap payload `spawn-wave` references unknown wave `{w}`"),
));
}
if let Some(item) = eff.give_item()
&& !items.contains(item)
{
d.push(Diagnostic::error(
codes::ITEM_UNKNOWN,
"quests",
format!("{base}/item"),
format!(
"trap payload `give-item` item `{item}` is not in the pinned \
1.21.11 item registry"
),
));
}
});
if let Some((item, count)) = t.dispense() {
if !items.contains(item) {
d.push(Diagnostic::error(
TRAP_PAYLOAD_UNKNOWN,
"quests",
format!("/content/traps/{i}/effect/dispense/item"),
format!(
"trap dispense payload item `{item}` is not in the pinned 1.21.11 item \
registry — use a valid namespaced item id (e.g. `minecraft:arrow`)"
),
));
}
check_stack_count(
item,
count,
&format!("trap `{}` dispense payload", t.id),
format!("/content/traps/{i}/effect/dispense/count"),
items,
d,
);
}
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/traps/{i}/requires_flags/{m}"),
format!(
"trap `requires_flags` references flag `{f}`, which no `set-flag` effect or \
trap disarm ever produces — add a producer 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/traps/{i}/forbids_flags/{m}"),
format!(
"trap `forbids_flags` references flag `{f}`, which no `set-flag` effect or \
trap disarm ever produces — the gate can never suppress anything; add a \
producer or correct the flag name"
),
));
}
}
}
}