use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_fields::default_true;
use crate::{AreaId, AtmosphereId, CelestialTime, Clock, MoonPhase, PoolId, PrefabId};
#[cfg(doc)]
use crate::Verb;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorldContent {
pub title: String,
pub theme: String,
pub premise: String,
pub seed: u64,
pub target_minutes: u32,
pub areas: Vec<Area>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub languages: Vec<String>,
pub time: WorldTime,
pub weather: WorldWeather,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub difficulty: Option<WorldDifficulty>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub horizon: Option<Horizon>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_distance: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub atmospheres: Vec<Atmosphere>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub boundary: Option<Boundary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outro: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_players: Option<u8>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub textures: Vec<TextureOverride>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub require_resource_pack: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub respawn_wait: Option<RespawnWait>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TextureOverride {
pub id: String,
pub replaces: String,
pub license: crate::license::LicenseEvidence,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Atmosphere {
pub id: AtmosphereId,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub attributes: BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tint: Option<AtmosphereTint>,
pub precipitation: Precipitation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub climate: Option<Climate>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AtmosphereTint {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grass: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub foliage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_foliage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub water: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Precipitation {
None,
Rain,
Snow,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Climate {
pub temperature: f64,
pub downfall: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RespawnWait {
pub seconds: u16,
#[serde(default)]
pub alone: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum WorldTime {
Day,
Noon,
Dusk,
Night,
Midnight,
Dawn,
Celestial(CelestialTime),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum TimeKeyword {
Day,
Noon,
Dusk,
Night,
Midnight,
#[serde(alias = "sunrise")]
Dawn,
}
impl From<TimeKeyword> for WorldTime {
fn from(k: TimeKeyword) -> WorldTime {
match k {
TimeKeyword::Day => WorldTime::Day,
TimeKeyword::Noon => WorldTime::Noon,
TimeKeyword::Dusk => WorldTime::Dusk,
TimeKeyword::Night => WorldTime::Night,
TimeKeyword::Midnight => WorldTime::Midnight,
TimeKeyword::Dawn => WorldTime::Dawn,
}
}
}
impl Serialize for WorldTime {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
WorldTime::Celestial(c) => c.serialize(s),
kw => s.serialize_str(kw.keyword_str().expect("a keyword")),
}
}
}
impl<'de> Deserialize<'de> for WorldTime {
fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<WorldTime, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = WorldTime;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(
"a time: a keyword (day, noon, dusk, night, midnight, dawn) or an object \
naming one body, where it stands and its phase \
({\"moon\": \"just-risen\", \"phase\": \"new-moon\"})",
)
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<WorldTime, E> {
use serde::de::IntoDeserializer;
TimeKeyword::deserialize(v.into_deserializer()).map(WorldTime::from)
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> Result<WorldTime, A::Error> {
CelestialTime::deserialize(serde::de::value::MapAccessDeserializer::new(map))
.map(WorldTime::Celestial)
}
}
de.deserialize_any(V)
}
}
impl JsonSchema for WorldTime {
fn schema_name() -> std::borrow::Cow<'static, str> {
"WorldTime".into()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"description": "A time: a keyword, vanilla's hour on day 0 (a keyword night is a full \
moon), or a celestial statement naming one body, where it stands and, \
where the moon shows, its phase (spec-0081).",
"anyOf": [g.subschema_for::<TimeKeyword>(), g.subschema_for::<CelestialTime>()],
})
}
}
impl WorldTime {
const fn spec(self) -> Option<(&'static str, i64)> {
match self {
WorldTime::Day => Some(("day", 1000)),
WorldTime::Noon => Some(("noon", 6000)),
WorldTime::Dusk => Some(("12000", 12000)),
WorldTime::Night => Some(("night", 13000)),
WorldTime::Midnight => Some(("midnight", 18000)),
WorldTime::Dawn => Some(("23000", 23000)),
WorldTime::Celestial(_) => None,
}
}
pub fn is_keyword(self) -> bool {
!matches!(self, WorldTime::Celestial(_))
}
pub fn celestial(self) -> Option<CelestialTime> {
match self {
WorldTime::Celestial(c) => Some(c),
_ => None,
}
}
pub fn stated_phase(self) -> Option<MoonPhase> {
self.celestial().and_then(|c| c.phase)
}
pub fn daytime_ticks(self) -> i64 {
match self.spec() {
Some((_, t)) => t,
None => self.celestial().expect("celestial").daytime(),
}
}
pub fn world_day(self) -> i64 {
self.stated_phase().map(MoonPhase::index).unwrap_or(0)
}
pub fn clock(self, site: TimeSite, world: WorldTime) -> Clock {
let daytime = self.daytime_ticks();
let day = match (self, site) {
(WorldTime::Celestial(c), _) => c
.phase
.map(MoonPhase::index)
.unwrap_or_else(|| world.world_day()),
(_, TimeSite::Cut) => world.world_day(),
(_, TimeSite::Sky) => 0,
};
Clock { day, daytime }
}
pub fn world_clock(self) -> Clock {
self.clock(TimeSite::Sky, self)
}
pub fn token(self, clock: Clock) -> String {
match self.spec() {
Some((tok, t)) if clock.day == 0 && clock.daytime == t => tok.to_string(),
_ => clock.absolute().to_string(),
}
}
fn keyword_str(self) -> Option<&'static str> {
match self {
WorldTime::Day => Some("day"),
WorldTime::Noon => Some("noon"),
WorldTime::Dusk => Some("dusk"),
WorldTime::Night => Some("night"),
WorldTime::Midnight => Some("midnight"),
WorldTime::Dawn => Some("dawn"),
WorldTime::Celestial(_) => None,
}
}
pub fn keyword(self) -> String {
match self {
WorldTime::Celestial(c) => c.spelling(),
kw => kw.keyword_str().expect("a keyword").to_string(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TimeSite {
Sky,
Cut,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum WorldWeather {
Clear,
Rain,
Thunder,
}
impl WorldWeather {
pub fn keyword(self) -> &'static str {
self.token()
}
pub fn token(self) -> &'static str {
match self {
WorldWeather::Clear => "clear",
WorldWeather::Rain => "rain",
WorldWeather::Thunder => "thunder",
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum WorldDifficulty {
#[default]
Easy,
Normal,
Hard,
Peaceful,
}
impl WorldDifficulty {
pub fn token(self) -> &'static str {
match self {
WorldDifficulty::Peaceful => "peaceful",
WorldDifficulty::Easy => "easy",
WorldDifficulty::Normal => "normal",
WorldDifficulty::Hard => "hard",
}
}
pub fn id(self) -> i32 {
match self {
WorldDifficulty::Peaceful => 0,
WorldDifficulty::Easy => 1,
WorldDifficulty::Normal => 2,
WorldDifficulty::Hard => 3,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum Horizon {
Name(HorizonBase),
Spec(HorizonSpec),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum HorizonBase {
#[default]
Void,
Ocean,
Valley,
}
impl HorizonBase {
pub fn token(self) -> &'static str {
match self {
HorizonBase::Void => "void",
HorizonBase::Ocean => "ocean",
HorizonBase::Valley => "valley",
}
}
pub fn has_surround(self) -> bool {
matches!(self, HorizonBase::Valley)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HorizonSpec {
pub base: HorizonBase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratio: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rim_height: Option<i32>,
}
pub mod horizon_defaults {
pub const RATIO: f64 = 2.5;
pub const RATIO_MIN: f64 = 2.0;
pub const RATIO_MAX: f64 = 3.0;
pub const RIM_HEIGHT: i32 = 48;
pub const RIM_HEIGHT_MIN: i32 = 16;
pub const RIM_HEIGHT_MAX: i32 = 128;
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedHorizon {
pub base: HorizonBase,
pub ratio: f64,
pub rim_height: i32,
}
impl Default for ResolvedHorizon {
fn default() -> Self {
ResolvedHorizon {
base: HorizonBase::Void,
ratio: horizon_defaults::RATIO,
rim_height: horizon_defaults::RIM_HEIGHT,
}
}
}
impl ResolvedHorizon {
pub fn of_base(base: HorizonBase) -> Self {
ResolvedHorizon {
base,
..Default::default()
}
}
}
impl Horizon {
pub fn resolved(&self) -> ResolvedHorizon {
match self {
Horizon::Name(base) => ResolvedHorizon::of_base(*base),
Horizon::Spec(s) => ResolvedHorizon {
base: s.base,
ratio: s.ratio.unwrap_or(horizon_defaults::RATIO),
rim_height: s.rim_height.unwrap_or(horizon_defaults::RIM_HEIGHT),
},
}
}
pub fn base(&self) -> HorizonBase {
self.resolved().base
}
pub fn needs_horizon_library(&self) -> bool {
match self {
Horizon::Spec(_) => true,
Horizon::Name(base) => match base {
HorizonBase::Void | HorizonBase::Ocean => false,
HorizonBase::Valley => true,
},
}
}
}
pub fn horizon_base(horizon: &Option<Horizon>) -> HorizonBase {
horizon.as_ref().map(|h| h.base()).unwrap_or_default()
}
pub fn resolved_horizon(horizon: &Option<Horizon>) -> ResolvedHorizon {
horizon.as_ref().map(|h| h.resolved()).unwrap_or_default()
}
fn default_margin() -> u16 {
16
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Boundary {
#[serde(default = "default_margin")]
pub margin: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub returns: bool,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_true(b: &bool) -> bool {
*b
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Fixture {
Torch,
Lantern,
Campfire,
Shroomlight,
}
impl Fixture {
pub fn token(self) -> &'static str {
match self {
Fixture::Torch => "torch",
Fixture::Lantern => "lantern",
Fixture::Campfire => "campfire",
Fixture::Shroomlight => "shroomlight",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AreaLighting {
pub fixture: Fixture,
#[serde(default = "default_min_light")]
pub min_light: u8,
}
fn default_min_light() -> u8 {
7
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum AreaMitigation {
NightVision,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Area {
pub id: AreaId,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefab: Option<PrefabId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefab_pool: Option<PoolId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pieces: Option<Pieces>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lighting: Option<AreaLighting>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mitigation: Option<AreaMitigation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub atmosphere: Option<AtmosphereId>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Pieces {
pub min: u32,
pub max: u32,
}
use std::collections::BTreeSet;
use crate::diagnostic::{Diagnostic, DwCode, ExitTier, codes};
use crate::envelope::Campaign;
use crate::ids::is_kebab;
use crate::registry::AnchorRegistry;
crate::dw_code! {
pub const PREFAB_BINDING: DwCode = DwCode::new("DW0160", ExitTier::Build);
}
crate::dw_code! {
pub const POOL_UNKNOWN: DwCode = DwCode::new("DW0161", ExitTier::Build);
}
crate::dw_code! {
pub const PREFAB_UNKNOWN: DwCode = DwCode::new("DW0856", ExitTier::Build);
}
crate::dw_code! {
pub const OCEAN_NO_BOUNDARY: DwCode = DwCode::new("DW0320", ExitTier::Build);
}
crate::dw_code! {
pub const BOUNDARY_MARGIN: DwCode = DwCode::new("DW0321", ExitTier::Build);
}
crate::dw_code! {
pub const HORIZON_PARAM: DwCode = DwCode::new("DW0853", ExitTier::Build);
}
crate::dw_code! {
pub const SURROUND_NO_REGION: DwCode = DwCode::new("DW0855", ExitTier::Build);
}
crate::dw_code! {
pub const PARTY_SIZE: DwCode = DwCode::new("DW0356", ExitTier::Build);
}
crate::dw_code! {
pub const RESPAWN_WAIT_INVALID: DwCode = DwCode::new("DW0925", ExitTier::Build);
}
crate::dw_code! {
pub const DIFFICULTY_INVALID: DwCode = DwCode::new("DW0468", ExitTier::Build);
}
crate::dw_code! {
pub const TEXTURE_PATH: DwCode = DwCode::new("DW0939", ExitTier::Build);
}
fn one_piece_gap(c: &Campaign) -> String {
let areas = &c.world.content.areas;
match areas.len() {
0 => ", and no area is declared at all".to_string(),
1 => format!(
", and its one area `{id}` draws from a pool rather than binding a single `prefab`",
id = areas[0].id.as_str(),
),
n => format!(", which is {n} areas rather than one"),
}
}
pub(crate) fn horizon_param_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::{HorizonBase, horizon_defaults};
let Some(h) = c.world.content.horizon.as_ref() else {
return;
};
let r = h.resolved();
if let crate::Horizon::Spec(spec) = h {
let mut foreign: Vec<&str> = Vec::new();
if !matches!(r.base, HorizonBase::Valley) {
if spec.ratio.is_some() {
foreign.push("ratio");
}
if spec.rim_height.is_some() {
foreign.push("rim_height");
}
}
for name in foreign {
d.push(Diagnostic::error(
HORIZON_PARAM,
"world",
format!("/content/horizon/{name}"),
format!(
"`{name}` is a `valley` param and this horizon declares base `{base}`, which \
reads nothing from it. Remove it, or declare `base: \"valley\"` — a param \
nothing reads is a statement the author believes is taking effect.",
base = r.base.token()
),
));
}
}
if r.base.has_surround() && !crate::placement::Extent::of(c).is_stated() {
d.push(Diagnostic::error(
SURROUND_NO_REGION,
"world",
"/content/horizon/base",
format!(
"`horizon` base `{base}` builds terrain around the map, and this campaign never \
says how big the map is. A surround rings a DECLARED extent, and this campaign \
declares none: it places {n} area(s) with `areas[]`{how}. The union of whatever \
those place is not a substitute — areas sit on the compiler's fixed stride with \
void between them, and a pool's footprint is whatever the solver drew — so that \
union is mostly nothing and the horizon would be a mountain range built around \
empty space. There are three moves and all three are reachable from here: make \
the map ONE PIECE — a single area bound to a single `prefab`, whose own declared \
region is then the map's extent, which is how a site (a building with its \
island, its moat and its banks in one box) is placed; or give the campaign a \
site plan and declare `areas` empty, which is the same choice `DW0839` asks for; \
or set `horizon` to `void` or `ocean`, which need no map to be a horizon of.",
base = r.base.token(),
n = c.world.content.areas.len(),
how = one_piece_gap(c),
),
));
}
if r.base.has_surround() {
if !(horizon_defaults::RATIO_MIN..=horizon_defaults::RATIO_MAX).contains(&r.ratio)
|| !r.ratio.is_finite()
{
d.push(Diagnostic::error(
HORIZON_PARAM,
"world",
"/content/horizon/ratio",
format!(
"`ratio` = {} is out of range — set it within {}..={} ({} is the default). \
It is the surround's total footprint as a multiple of the \
map's: under {} there is no room for a gap floor and a slope run \
both, and over {} the surround is mostly terrain no body reaches, at a cost \
that is all shipped bytes.",
r.ratio,
horizon_defaults::RATIO_MIN,
horizon_defaults::RATIO_MAX,
horizon_defaults::RATIO,
horizon_defaults::RATIO_MIN,
horizon_defaults::RATIO_MAX,
),
));
}
if !(horizon_defaults::RIM_HEIGHT_MIN..=horizon_defaults::RIM_HEIGHT_MAX)
.contains(&r.rim_height)
{
d.push(Diagnostic::error(
HORIZON_PARAM,
"world",
"/content/horizon/rim_height",
format!(
"`rim_height` = {} is out of range — set it within {}..={} ({} is the \
default). It is the crest's height over the gap floor: under {} \
the rim does not close the horizon from a body standing on that floor, and \
over {} the surround stops fitting under whatever the map puts above it.",
r.rim_height,
horizon_defaults::RIM_HEIGHT_MIN,
horizon_defaults::RIM_HEIGHT_MAX,
horizon_defaults::RIM_HEIGHT,
horizon_defaults::RIM_HEIGHT_MIN,
horizon_defaults::RIM_HEIGHT_MAX,
),
));
}
}
}
fn texture_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let mut ids: BTreeSet<&str> = BTreeSet::new();
let mut replaced: BTreeMap<&str, usize> = BTreeMap::new();
for (i, t) in c.world.content.textures.iter().enumerate() {
if !is_kebab(&t.id) {
d.push(Diagnostic::error(
codes::SKIN_INVALID,
"world",
format!("/content/textures/{i}/id"),
format!(
"texture `id` `{}` is malformed — it must be a bare kebab token (e.g. \
`red-moon`), matching the `textures/<id>.png` filename",
t.id
),
));
} else if !ids.insert(t.id.as_str()) {
d.push(Diagnostic::error(
codes::SKIN_INVALID,
"world",
format!("/content/textures/{i}/id"),
format!(
"duplicate texture `id` `{}` — each row names its own image; rename one \
(and its `textures/<id>.png`)",
t.id
),
));
}
if let Some(first) = replaced.insert(t.replaces.as_str(), i) {
d.push(Diagnostic::error(
TEXTURE_PATH,
"world",
format!("/content/textures/{i}/replaces"),
format!(
"texture `{}` replaces `{}`, which `world.textures[{first}]` already \
replaces — a texture is drawn one way, so remove one of the two rows",
t.id, t.replaces
),
));
}
for reason in crate::license::image_license_refusals(&t.license) {
d.push(Diagnostic::error(
codes::LICENSE_REFUSED,
"world",
format!("/content/textures/{i}/license"),
format!("texture `{}` (replaces `{}`): {reason}", t.id, t.replaces),
));
}
}
}
pub(crate) fn world_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
texture_checks(c, d);
crate::viewdistance::checks(c, d);
if let Some(n) = c.world.content.min_players
&& !(1..=4).contains(&n)
{
d.push(Diagnostic::error(
PARTY_SIZE,
"world",
"/content/min_players".to_string(),
format!(
"`min_players` = {n} is out of range — a delve is played by one party of 1–4, \
so set it to a value in 1..=4 (absent = 1, a party of one)"
),
));
}
if let Some(w) = c.world.content.respawn_wait {
if !(1..=120).contains(&w.seconds) {
d.push(Diagnostic::error(
RESPAWN_WAIT_INVALID,
"world",
"/content/respawn_wait/seconds".to_string(),
format!(
"`respawn_wait.seconds` = {} is out of range — a fallen player waits 1 to 120 \
seconds, so set it to a value in 1..=120, or drop `respawn_wait` for no wait",
w.seconds
),
));
}
if !crate::declares_checkpoint(c) {
d.push(Diagnostic::error(
RESPAWN_WAIT_INVALID,
"world",
"/content/respawn_wait".to_string(),
"`respawn_wait` is declared but this campaign declares no `set-checkpoint` or \
`bonfire` — the wait begins on the checkpoint respawn edge, so with nothing to \
come back to it never runs. Add the checkpoint or bonfire a fallen player \
returns to, or drop `respawn_wait`."
.to_string(),
));
}
}
if matches!(
c.world.content.difficulty,
Some(crate::WorldDifficulty::Peaceful)
) {
d.push(Diagnostic::error(
DIFFICULTY_INVALID,
"world",
"/content/difficulty".to_string(),
"`difficulty: \"peaceful\"` is refused: on peaceful the server discards every \
hostile-category mob as it ticks it — being `/summon`ed, `NoAI` or \
`PersistenceRequired` does not save one — so every wave, hostile actor and \
ambush in this campaign would silently cease to exist. Declare `easy`, `normal` \
or `hard`; for a delve that is genuinely combat-free, simply omit `difficulty` \
(a campaign with no waves already ships peaceful by derivation)"
.to_string(),
));
}
let entered_base = match crate::horizon_base(&c.world.content.horizon) {
crate::HorizonBase::Void => None,
crate::HorizonBase::Ocean => Some((
"ocean",
"an infinite swimmable sea with no return rule lets players wander off the map",
)),
crate::HorizonBase::Valley => Some((
"valley",
"the gap floor between the map and the rim is walkable ground, and with no \
return rule a player who steps off the map is simply outside it",
)),
};
if let Some((base, why)) = entered_base
&& c.world.content.boundary.is_none()
{
d.push(Diagnostic::error(
OCEAN_NO_BOUNDARY,
"world",
"/content/horizon".to_string(),
format!(
"`horizon` base `{base}` needs a `boundary` — {why}. Add a `boundary` (a \
bare `{{}}` uses the default margin), or set `horizon` to `void`"
),
));
}
if let Some(b) = &c.world.content.boundary
&& !(0..=64).contains(&b.margin)
{
d.push(Diagnostic::error(
BOUNDARY_MARGIN,
"world",
"/content/boundary/margin".to_string(),
format!(
"`boundary.margin` = {} is out of range — set it to a value in 0..=64 (16 is \
the default)",
b.margin
),
));
}
}
pub(crate) fn lighting_range_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, area) in c.world.content.areas.iter().enumerate() {
if let Some(lighting) = &area.lighting
&& !(1..=14).contains(&lighting.min_light)
{
d.push(Diagnostic::error(
codes::LIGHTING_RANGE,
"world",
format!("/content/areas/{i}/lighting/min_light"),
format!(
"area `{}` `lighting.min_light` = {} is out of range — set it to a value \
in 1..=14 (7 is the default)",
area.id, lighting.min_light
),
));
}
}
}
pub(crate) fn prefab_binding(c: &Campaign, anchors: &dyn AnchorRegistry, d: &mut Vec<Diagnostic>) {
for (i, a) in c.world.content.areas.iter().enumerate() {
match (&a.prefab, &a.prefab_pool) {
(Some(_), Some(_)) => d.push(Diagnostic::error(
PREFAB_BINDING,
"world",
format!("/content/areas/{i}"),
format!(
"area `{}` binds both `prefab` and `prefab_pool`; bind exactly one",
a.id
),
)),
(None, None) => d.push(Diagnostic::error(
PREFAB_BINDING,
"world",
format!("/content/areas/{i}"),
format!(
"area `{}` binds neither `prefab` nor `prefab_pool`; bind exactly one",
a.id
),
)),
_ => {}
}
if let Some(prefab) = &a.prefab
&& prefab.is_valid_syntax()
&& anchors.has_prefab(prefab) == Some(false)
{
d.push(Diagnostic::error(
PREFAB_UNKNOWN,
"world",
format!("/content/areas/{i}/prefab"),
format!(
"area `{}` binds `prefab` `{prefab}`, which is not declared in the prefab \
metadata — bind a piece that exists in the prefabs dir, or add `{prefab}` \
to the prefab library. This is a prefab-library/naming issue, not a \
quest-logic one. It is refused rather than deferred because an area whose \
piece is absent declares NO anchors, so every anchor a quest in this area \
names would be accepted without being examined — a misspelling here \
switches the anchor proof (`DW0142`) off for the whole area instead of \
failing it",
a.id
),
));
}
if let Some(pool) = &a.prefab_pool
&& pool.is_valid_syntax()
&& !anchors.has_pool(pool)
{
d.push(Diagnostic::error(
POOL_UNKNOWN,
"world",
format!("/content/areas/{i}/prefab_pool"),
format!(
"area `prefab_pool` `{pool}` is not declared in the prefab metadata — bind a \
pool that exists in the prefabs dir, or add `{pool}` to the prefab library. \
This is a prefab-library/naming issue, not a quest-logic one"
),
));
}
}
}
pub(crate) fn declared_area_ids(c: &Campaign) -> BTreeSet<&str> {
let mut area_ids: BTreeSet<&str> = c
.world
.content
.areas
.iter()
.map(|a| a.id.as_str())
.collect();
if c.site_plan.is_some() {
area_ids.insert(crate::siteplan::SITE_AREA);
}
area_ids
}
pub(crate) fn world_id_syntax(c: &Campaign, d: &mut Vec<Diagnostic>) {
for (i, a) in c.world.content.areas.iter().enumerate() {
crate::ids::id_syntax!(d, a.id, "world", format!("/content/areas/{i}/id"));
if let Some(prefab) = &a.prefab {
crate::ids::id_syntax!(d, prefab, "world", format!("/content/areas/{i}/prefab"));
}
if let Some(pool) = &a.prefab_pool {
crate::ids::id_syntax!(d, pool, "world", format!("/content/areas/{i}/prefab_pool"));
}
}
for (i, a) in c.world.content.atmospheres.iter().enumerate() {
crate::ids::id_syntax!(d, a.id, "world", format!("/content/atmospheres/{i}/id"));
}
}
pub(crate) fn world_id_uniqueness(c: &Campaign, d: &mut Vec<Diagnostic>) {
crate::ids::dup_check(
c.world
.content
.areas
.iter()
.enumerate()
.map(|(i, a)| (a.id.as_str(), format!("/content/areas/{i}/id"))),
"world",
"area",
d,
);
}
pub(crate) fn atmosphere_dangling_refs(c: &Campaign, d: &mut Vec<Diagnostic>) {
use crate::ids::dangling;
let atmosphere_ids: BTreeSet<&str> = c
.world
.content
.atmospheres
.iter()
.map(|a| a.id.as_str())
.collect();
let atmosphere_remedy = |id: &str| {
format!(
"unknown atmosphere `{id}` — declare it in `world.atmospheres[]` or correct the \
reference"
)
};
for (i, a) in c.world.content.areas.iter().enumerate() {
if let Some(id) = &a.atmosphere {
dangling(
d,
atmosphere_ids.contains(id.as_str()),
"world",
format!("/content/areas/{i}/atmosphere"),
atmosphere_remedy(id.as_str()),
);
}
}
let mut place_ids: BTreeSet<&str> = c
.world
.content
.areas
.iter()
.map(|a| a.id.as_str())
.collect();
if let Some(sp) = &c.site_plan {
for (i, b) in sp.content.boxes.iter().enumerate() {
place_ids.insert(b.node.as_str());
if let Some(id) = &b.atmosphere {
dangling(
d,
atmosphere_ids.contains(id.as_str()),
"site-plan",
format!("/content/boxes/{i}/atmosphere"),
atmosphere_remedy(id.as_str()),
);
}
}
}
crate::for_each_campaign_effect(c, &mut |path, site, e| {
let crate::Verb::SetAtmosphere {
atmosphere, place, ..
} = &e.verb
else {
return;
};
let stage = match site {
crate::EffectSite::DialogueRespawn { .. } => "dialogue",
_ => "quests",
};
if let Some(id) = atmosphere {
dangling(
d,
atmosphere_ids.contains(id.as_str()),
stage,
format!("{path}/atmosphere"),
atmosphere_remedy(id.as_str()),
);
}
if let Some(place) = place {
dangling(
d,
place_ids.contains(place.as_str()),
stage,
format!("{path}/place"),
format!(
"`set-atmosphere` repaints unknown place `{place}` — name an `area/…` from \
`world.areas[]` or a site-plan box's `node/…`"
),
);
}
});
}