mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Polytone: read targets and biome/dimension attribute modifiers
A new crate, horizon-polytone, reads the Polytone resource pack format clean-room from the public wiki: modifier targets (implicit path id, ids, tags, regex, required) and the attributes_modifiers layers of biome and dimension modifiers (groups, REMOVE, values, modifier objects, colors, colormap references, expressions kept as source). Nothing uses it yet. Part of #11486 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
13f70ad482
commit
c80eac9506
8 files changed
+617
No files matched your search
@@ -65,6 +65,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
|
||||
Generated
+8
@@ -1703,6 +1703,14 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-port-pack"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -37,6 +37,7 @@ members = [
|
||||
"crates/horizon-port-pack",
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Polytone-format resource pack files: targets, and the biome and dimension modifiers' environment attribute layers"
|
||||
|
||||
[dependencies]
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] }
|
||||
serde_json = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Environment attribute layers (wiki "Environment Attributes"): a map from attribute id to a
|
||||
//! value, which sets the attribute, a `{argument, modifier}` object, which modifies the value the
|
||||
//! earlier layers gave, or `REMOVE`, which drops the game's own layer for it.
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The vanilla `modifier` operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Modifier {
|
||||
Override,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Minimum,
|
||||
Maximum,
|
||||
AlphaBlend,
|
||||
BlendToGray,
|
||||
And,
|
||||
Nand,
|
||||
Or,
|
||||
Nor,
|
||||
Xor,
|
||||
Xnor,
|
||||
}
|
||||
|
||||
impl Modifier {
|
||||
/// The `modifier` field, in any case (the wiki writes both `add` and `ADD`).
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"override" => Self::Override,
|
||||
"add" => Self::Add,
|
||||
"subtract" => Self::Subtract,
|
||||
"multiply" => Self::Multiply,
|
||||
"minimum" => Self::Minimum,
|
||||
"maximum" => Self::Maximum,
|
||||
"alpha_blend" => Self::AlphaBlend,
|
||||
"blend_to_gray" => Self::BlendToGray,
|
||||
"and" => Self::And,
|
||||
"nand" => Self::Nand,
|
||||
"or" => Self::Or,
|
||||
"nor" => Self::Nor,
|
||||
"xor" => Self::Xor,
|
||||
"xnor" => Self::Xnor,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer's value or `argument`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Argument {
|
||||
/// A number: a float, an angle, or a packed color, as the attribute's type reads it.
|
||||
Number(f64),
|
||||
Bool(bool),
|
||||
/// A `#rrggbb` / `#aarrggbb` string (the `#` optional), packed ARGB; opaque without alpha.
|
||||
Color(u32),
|
||||
/// A `namespace:path` string: a colormap sampled for the value.
|
||||
Colormap(String),
|
||||
/// Any other string: a scripting expression, kept as source.
|
||||
Expression(String),
|
||||
/// Vanilla's structured arguments, such as `alpha_blend`'s `{value, alpha}`.
|
||||
Object(Map<String, Value>),
|
||||
}
|
||||
|
||||
impl Argument {
|
||||
fn parse(v: &Value) -> Result<Self, String> {
|
||||
Ok(match v {
|
||||
Value::Number(n) => Self::Number(n.as_f64().unwrap_or_default()),
|
||||
Value::Bool(b) => Self::Bool(*b),
|
||||
Value::Object(o) => Self::Object(o.clone()),
|
||||
Value::String(s) => {
|
||||
let s = s.trim();
|
||||
let hex = s.strip_prefix('#').unwrap_or(s);
|
||||
let color =
|
||||
matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit());
|
||||
if let (true, Ok(packed)) = (color, u32::from_str_radix(hex, 16)) {
|
||||
Self::Color(if hex.len() == 6 {
|
||||
0xff00_0000 | packed
|
||||
} else {
|
||||
packed
|
||||
})
|
||||
} else if s.contains(':') && crate::target::parse_id(s).is_some() {
|
||||
Self::Colormap(s.to_owned())
|
||||
} else {
|
||||
Self::Expression(s.to_owned())
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("{v} is no attribute value")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What one modifier file does to one attribute.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Layer {
|
||||
/// `REMOVE`: the game's own layer for the attribute in this group is dropped.
|
||||
Remove,
|
||||
/// A value (`Override`) or a `{argument, modifier}` object. `biome_blend` and `time_blend`
|
||||
/// (default `true`) say whether a computed value is mixed across biome borders and faded
|
||||
/// over time when it jumps.
|
||||
Apply {
|
||||
modifier: Modifier,
|
||||
argument: Argument,
|
||||
biome_blend: bool,
|
||||
time_blend: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
fn parse(v: &Value) -> Result<Self, String> {
|
||||
if v.as_str().is_some_and(|s| s.trim() == "REMOVE") {
|
||||
return Ok(Self::Remove);
|
||||
}
|
||||
let object = v.as_object().filter(|o| o.contains_key("modifier"));
|
||||
let Some(o) = object else {
|
||||
return Ok(Self::Apply {
|
||||
modifier: Modifier::Override,
|
||||
argument: Argument::parse(v)?,
|
||||
biome_blend: true,
|
||||
time_blend: true,
|
||||
});
|
||||
};
|
||||
let name = o["modifier"].as_str().ok_or("modifier is not a string")?;
|
||||
let flag = |key: &str| o.get(key).and_then(Value::as_bool).unwrap_or(true);
|
||||
Ok(Self::Apply {
|
||||
modifier: Modifier::parse(name).ok_or_else(|| format!("unknown modifier {name:?}"))?,
|
||||
argument: Argument::parse(o.get("argument").ok_or("modifier without an argument")?)?,
|
||||
biome_blend: flag("biome_blend"),
|
||||
time_blend: flag("time_blend"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `visual/fog_color` and `minecraft:visual/fog_color` are the same attribute.
|
||||
pub fn normalize_attribute(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// One group's layers, by attribute id (with its namespace).
|
||||
pub type Attributes = BTreeMap<String, Layer>;
|
||||
|
||||
pub(crate) fn parse_attributes(v: &Value) -> Result<Attributes, String> {
|
||||
let map = v.as_object().ok_or("attributes are not an object")?;
|
||||
map.iter()
|
||||
.map(|(id, layer)| {
|
||||
let layer = Layer::parse(layer).map_err(|e| format!("{id}: {e}"))?;
|
||||
Ok((normalize_attribute(id), layer))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn layer(v: Value) -> Layer {
|
||||
Layer::parse(&v).unwrap()
|
||||
}
|
||||
fn set(argument: Argument) -> Layer {
|
||||
Layer::Apply {
|
||||
modifier: Modifier::Override,
|
||||
argument,
|
||||
biome_blend: true,
|
||||
time_blend: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_values_set_the_attribute() {
|
||||
assert_eq!(layer(json!(192.5)), set(Argument::Number(192.5)));
|
||||
assert_eq!(layer(json!(false)), set(Argument::Bool(false)));
|
||||
assert_eq!(layer(json!("REMOVE")), Layer::Remove);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_strings_pack_to_argb_with_or_without_the_hash() {
|
||||
assert_eq!(layer(json!("4cb1a8")), set(Argument::Color(0xff4c_b1a8)));
|
||||
assert_eq!(layer(json!("#332211")), set(Argument::Color(0xff33_2211)));
|
||||
assert_eq!(layer(json!("#867a7aff")), set(Argument::Color(0x867a_7aff)));
|
||||
assert_eq!(layer(json!("#00000000")), set(Argument::Color(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_name_colormaps_and_other_strings_are_expressions() {
|
||||
let colormap = Argument::Colormap("my_pack:swamp_sky_color".into());
|
||||
assert_eq!(layer(json!("my_pack:swamp_sky_color")), set(colormap));
|
||||
let expression = Argument::Expression("r.rand() + 0.1".into());
|
||||
assert_eq!(layer(json!("r.rand() + 0.1")), set(expression));
|
||||
// Not hex, so no color, though six letters long.
|
||||
let word = Argument::Expression("g.rain".into());
|
||||
assert_eq!(layer(json!("g.rain")), set(word));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_objects_read_any_case_and_the_blend_flags() {
|
||||
let l = layer(json!({"argument": "0.2 * g.rain", "modifier": "ADD", "biome_blend": false}));
|
||||
assert_eq!(
|
||||
l,
|
||||
Layer::Apply {
|
||||
modifier: Modifier::Add,
|
||||
argument: Argument::Expression("0.2 * g.rain".into()),
|
||||
biome_blend: false,
|
||||
time_blend: true,
|
||||
}
|
||||
);
|
||||
let blend =
|
||||
layer(json!({"argument": {"value": 4.0, "alpha": 0.5}, "modifier": "alpha_blend"}));
|
||||
assert!(matches!(
|
||||
blend,
|
||||
Layer::Apply {
|
||||
modifier: Modifier::AlphaBlend,
|
||||
argument: Argument::Object(_),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_modifiers_and_missing_arguments_are_errors() {
|
||||
assert!(Layer::parse(&json!({"argument": 1, "modifier": "divide"})).is_err());
|
||||
assert!(Layer::parse(&json!({"modifier": "add"})).is_err());
|
||||
assert!(Layer::parse(&json!(null)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_ids_get_the_minecraft_namespace() {
|
||||
let a =
|
||||
parse_attributes(&json!({"visual/fog_end_distance": 64, "mod:visual/x": 1})).unwrap();
|
||||
let ids: Vec<&str> = a.keys().map(String::as_str).collect();
|
||||
assert_eq!(ids, ["minecraft:visual/fog_end_distance", "mod:visual/x"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Polytone-format resource pack files (`assets/<namespace>/polytone/...`), the format of the
|
||||
//! Polytone mod's packs (#11486).
|
||||
//!
|
||||
//! Clean room: written from the public wiki (<https://github.com/MehVahdJukaar/polytone/wiki>) and
|
||||
//! from observing packs, never from the mod's GPL source. This crate only reads the files: no GPU,
|
||||
//! no gameplay. The game applies what it reads; nothing changes without a Polytone pack.
|
||||
//!
|
||||
//! Read so far: [`Targets`] (what a modifier aims at), and the [`BiomeModifier`] and
|
||||
//! [`DimensionModifier`] environment attribute layers. Expressions stay source text
|
||||
//! ([`Argument::Expression`]) until the expression language lands.
|
||||
mod attributes;
|
||||
mod modifiers;
|
||||
mod target;
|
||||
|
||||
pub use attributes::{Argument, Attributes, Layer, Modifier, normalize_attribute};
|
||||
pub use modifiers::{BiomeModifier, DimensionModifier};
|
||||
pub use target::{Target, Targets, implicit_id};
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Biome modifiers (`polytone/biome_modifiers/`) and dimension modifiers
|
||||
//! (`polytone/dimension_modifiers/`): their targets, priority and `attributes_modifiers` groups.
|
||||
use crate::attributes::{Attributes, parse_attributes};
|
||||
use crate::target::Targets;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The fields every modifier shares. `None` when the file is switched off (`polytone_ignore`).
|
||||
fn common(json: &Value, implicit: Option<&str>) -> Result<Option<(Targets, i32)>, String> {
|
||||
if json.get("polytone_ignore").and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(None);
|
||||
}
|
||||
let targets = Targets::parse(json.get("targets"), implicit)?;
|
||||
let priority = json.get("priority").and_then(Value::as_i64).unwrap_or(0);
|
||||
let priority = i32::try_from(priority).map_err(|_| "priority out of range")?;
|
||||
Ok(Some((targets, priority)))
|
||||
}
|
||||
|
||||
/// `attributes_modifiers` split into its groups. Without any group key, the whole object is the
|
||||
/// `base` group.
|
||||
fn groups<const N: usize>(json: &Value, names: [&str; N]) -> Result<[Attributes; N], String> {
|
||||
let mut out = std::array::from_fn(|_| Attributes::new());
|
||||
let Some(field) = json.get("attributes_modifiers") else {
|
||||
return Ok(out);
|
||||
};
|
||||
let object = field
|
||||
.as_object()
|
||||
.ok_or("attributes_modifiers is not an object")?;
|
||||
if !object.keys().any(|k| names.contains(&k.as_str())) {
|
||||
out[0] = parse_attributes(field)?;
|
||||
return Ok(out);
|
||||
}
|
||||
for (key, value) in object {
|
||||
let i = names
|
||||
.iter()
|
||||
.position(|n| n == key)
|
||||
.ok_or_else(|| format!("{key:?} is no attribute group (expected one of {names:?})"))?;
|
||||
out[i] = parse_attributes(value).map_err(|e| format!("{key}: {e}"))?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A biome modifier's attribute layers: `base` applies in the biome group, before the dimension
|
||||
/// timeline and the weather; `post_process` after them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BiomeModifier {
|
||||
pub targets: Targets,
|
||||
/// Higher wins where several modifiers apply.
|
||||
pub priority: i32,
|
||||
pub base: Attributes,
|
||||
pub post_process: Attributes,
|
||||
}
|
||||
|
||||
impl BiomeModifier {
|
||||
/// `implicit` is the id the file's path names ([`crate::implicit_id`]).
|
||||
pub fn parse(json: &Value, implicit: Option<&str>) -> Result<Option<Self>, String> {
|
||||
let Some((targets, priority)) = common(json, implicit)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let [base, post_process] = groups(json, ["base", "post_process"])?;
|
||||
Ok(Some(Self {
|
||||
targets,
|
||||
priority,
|
||||
base,
|
||||
post_process,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension modifier's attribute layers: `base` in the dimension group (first of all), `rain`
|
||||
/// and `thunder` in the weather groups, `post_process` last.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DimensionModifier {
|
||||
pub targets: Targets,
|
||||
pub priority: i32,
|
||||
pub base: Attributes,
|
||||
pub rain: Attributes,
|
||||
pub thunder: Attributes,
|
||||
pub post_process: Attributes,
|
||||
}
|
||||
|
||||
impl DimensionModifier {
|
||||
pub fn parse(json: &Value, implicit: Option<&str>) -> Result<Option<Self>, String> {
|
||||
let Some((targets, priority)) = common(json, implicit)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let [base, rain, thunder, post_process] =
|
||||
groups(json, ["base", "rain", "thunder", "post_process"])?;
|
||||
Ok(Some(Self {
|
||||
targets,
|
||||
priority,
|
||||
base,
|
||||
rain,
|
||||
thunder,
|
||||
post_process,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::attributes::{Argument, Layer, Modifier};
|
||||
use serde_json::json;
|
||||
|
||||
const FOG: &str = "minecraft:visual/fog_color";
|
||||
|
||||
#[test]
|
||||
fn a_flat_biome_modifier_fills_the_base_group() {
|
||||
let json = json!({"attributes_modifiers": {"visual/fog_color": "#102030"}});
|
||||
let m = BiomeModifier::parse(&json, Some("minecraft:forest"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(m.targets.matches("minecraft:forest", |_| false));
|
||||
assert_eq!(m.priority, 0);
|
||||
assert!(m.post_process.is_empty());
|
||||
assert!(matches!(
|
||||
m.base[FOG],
|
||||
Layer::Apply {
|
||||
modifier: Modifier::Override,
|
||||
argument: Argument::Color(0xff10_2030),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_biome_modifier_keeps_its_groups_apart() {
|
||||
let json = json!({
|
||||
"targets": ["minecraft:swamp", "minecraft:mangrove_swamp"],
|
||||
"priority": -2,
|
||||
"attributes_modifiers": {
|
||||
"base": {"minecraft:visual/sky_color": "my_pack:sky"},
|
||||
"post_process": {"visual/sunrise_sunset_color": "REMOVE"}
|
||||
}
|
||||
});
|
||||
let m = BiomeModifier::parse(&json, None).unwrap().unwrap();
|
||||
assert_eq!(m.priority, -2);
|
||||
assert!(m.targets.matches("minecraft:mangrove_swamp", |_| false));
|
||||
assert_eq!(m.base.len(), 1);
|
||||
assert_eq!(
|
||||
m.post_process["minecraft:visual/sunrise_sunset_color"],
|
||||
Layer::Remove
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_modifiers_read_the_weather_groups() {
|
||||
let json = json!({
|
||||
"targets": "minecraft:overworld",
|
||||
"attributes_modifiers": {
|
||||
"base": {"visual/fog_start_distance": "REMOVE"},
|
||||
"rain": {"visual/cloud_color": "REMOVE", "visual/fog_color": "#3f3f4c"},
|
||||
"thunder": {"visual/sky_color": {"argument": 0.5, "modifier": "multiply"}}
|
||||
}
|
||||
});
|
||||
let m = DimensionModifier::parse(&json, None).unwrap().unwrap();
|
||||
assert_eq!(m.base.len(), 1);
|
||||
assert_eq!(m.rain.len(), 2);
|
||||
assert!(matches!(
|
||||
m.thunder["minecraft:visual/sky_color"],
|
||||
Layer::Apply {
|
||||
modifier: Modifier::Multiply,
|
||||
argument: Argument::Number(0.5),
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(m.post_process.is_empty());
|
||||
// Without groups, a dimension modifier's attributes are its base group too.
|
||||
let flat = json!({"targets": "minecraft:overworld", "attributes_modifiers": {"visual/sky_color": -1}});
|
||||
let flat = DimensionModifier::parse(&flat, None).unwrap().unwrap();
|
||||
assert_eq!(flat.base.len(), 1);
|
||||
assert!(flat.rain.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_files_load_nothing_and_broken_ones_fail() {
|
||||
let ignored = json!({"polytone_ignore": true, "attributes_modifiers": {"x": "REMOVE"}});
|
||||
assert!(
|
||||
BiomeModifier::parse(&ignored, Some("minecraft:forest"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// `rain` is a dimension group, not a biome one.
|
||||
let wrong_group = json!({"attributes_modifiers": {"base": {}, "rain": {}}});
|
||||
assert!(BiomeModifier::parse(&wrong_group, Some("minecraft:forest")).is_err());
|
||||
let bad_layer = json!({"attributes_modifiers": {"visual/fog_color": null}});
|
||||
assert!(DimensionModifier::parse(&bad_layer, Some("minecraft:overworld")).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Targeting (wiki "Shared Concepts"): a modifier aims at the id its file path names, or at the
|
||||
//! ids, `#tags` and regular expressions of its `targets` field.
|
||||
use regex::Regex;
|
||||
use serde_json::Value;
|
||||
|
||||
/// One entry of `targets`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Target {
|
||||
/// A literal id, `minecraft` when the namespace is left out.
|
||||
Id(String),
|
||||
/// `#namespace:path`, stored without the `#`.
|
||||
Tag(String),
|
||||
/// Anything else: a regular expression the whole id must match.
|
||||
Pattern(Regex),
|
||||
}
|
||||
|
||||
/// What a modifier applies to. Each entry carries `required` (default `true`): whether a missing
|
||||
/// target is an error.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Targets(pub Vec<(Target, bool)>);
|
||||
|
||||
/// `minecraft:` for an id without a namespace; `None` when it holds characters ids can't
|
||||
/// (`a-z 0-9 . _ -`, and `/` in the path).
|
||||
pub(crate) fn parse_id(s: &str) -> Option<String> {
|
||||
let (namespace, path) = s.split_once(':').unwrap_or(("minecraft", s));
|
||||
let valid = |part: &str, slash: bool| {
|
||||
!part.is_empty()
|
||||
&& part.chars().all(|c| {
|
||||
matches!(c, 'a'..='z' | '0'..='9' | '.' | '_' | '-') || (slash && c == '/')
|
||||
})
|
||||
};
|
||||
(valid(namespace, false) && valid(path, true)).then(|| format!("{namespace}:{path}"))
|
||||
}
|
||||
|
||||
/// The id a file's path targets when it has no `targets` field:
|
||||
/// `assets/byg/polytone/biome_modifiers/redwood_forest.json` is `byg:redwood_forest` for the kind
|
||||
/// `biome_modifiers`.
|
||||
pub fn implicit_id(path: &str, kind: &str) -> Option<String> {
|
||||
let path = path.replace('\\', "/");
|
||||
let after = &path[path.find("assets/")? + "assets/".len()..];
|
||||
let (namespace, rest) = after.split_once('/')?;
|
||||
let name = rest.strip_prefix("polytone/")?.strip_prefix(kind)?;
|
||||
let name = name.strip_prefix('/')?.strip_suffix(".json")?;
|
||||
parse_id(&format!("{namespace}:{name}"))
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// A string is an id first, then a `#tag`, then a regular expression.
|
||||
fn parse(s: &str) -> Result<Self, String> {
|
||||
if let Some(id) = parse_id(s) {
|
||||
return Ok(Self::Id(id));
|
||||
}
|
||||
if let Some(tag) = s.strip_prefix('#').and_then(parse_id) {
|
||||
return Ok(Self::Tag(tag));
|
||||
}
|
||||
Regex::new(&format!("^(?:{s})$"))
|
||||
.map(Self::Pattern)
|
||||
.map_err(|e| format!("target {s:?} is no id, tag or pattern: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Targets {
|
||||
/// The `targets` field (a string, an object or a list of them), else the file's implicit id.
|
||||
pub fn parse(field: Option<&Value>, implicit: Option<&str>) -> Result<Self, String> {
|
||||
let entry = |v: &Value| match v {
|
||||
Value::String(s) => Ok((Target::parse(s)?, true)),
|
||||
Value::Object(o) => {
|
||||
let id = o
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("target without an id")?;
|
||||
let required = o.get("required").and_then(Value::as_bool).unwrap_or(true);
|
||||
Ok((Target::parse(id)?, required))
|
||||
}
|
||||
_ => Err(format!("target {v} is neither a string nor an object")),
|
||||
};
|
||||
match field {
|
||||
Some(Value::Array(list)) => list.iter().map(entry).collect::<Result<_, _>>().map(Self),
|
||||
Some(v) => Ok(Self(vec![entry(v)?])),
|
||||
None => {
|
||||
let id = implicit.ok_or("no targets field and no implicit target")?;
|
||||
Ok(Self(vec![(Target::parse(id)?, true)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` (`namespace:path`) is a target; `in_tag` answers tag membership.
|
||||
pub fn matches(&self, id: &str, in_tag: impl Fn(&str) -> bool) -> bool {
|
||||
self.0.iter().any(|(target, _)| match target {
|
||||
Target::Id(t) => t == id,
|
||||
Target::Tag(tag) => in_tag(tag),
|
||||
Target::Pattern(re) => re.is_match(id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn targets(v: Value) -> Targets {
|
||||
Targets::parse(Some(&v), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_path_names_the_target_without_a_targets_field() {
|
||||
let path = "pack/assets/byg/polytone/biome_modifiers/redwood_forest.json";
|
||||
assert_eq!(
|
||||
implicit_id(path, "biome_modifiers").as_deref(),
|
||||
Some("byg:redwood_forest")
|
||||
);
|
||||
assert_eq!(implicit_id(path, "dimension_modifiers"), None);
|
||||
let t = Targets::parse(None, Some("byg:redwood_forest")).unwrap();
|
||||
assert!(t.matches("byg:redwood_forest", |_| false));
|
||||
assert!(!t.matches("minecraft:forest", |_| false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_default_to_minecraft_and_match_literally() {
|
||||
let t = targets(json!(["stone", "create:brass_block"]));
|
||||
assert!(t.matches("minecraft:stone", |_| false));
|
||||
assert!(t.matches("create:brass_block", |_| false));
|
||||
// `.` is an id character: no pattern, so it doesn't match other ids.
|
||||
assert!(!targets(json!("minecraft:a.b")).matches("minecraft:axb", |_| false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_that_are_no_id_are_tags_then_patterns() {
|
||||
let t = targets(json!(["#minecraft:is_overworld", "create:.*"]));
|
||||
assert!(t.matches("create:anything", |_| false));
|
||||
assert!(!t.matches("minecraft:plains", |_| false));
|
||||
assert!(t.matches("minecraft:plains", |tag| tag == "minecraft:is_overworld"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_long_form_carries_required() {
|
||||
let t =
|
||||
targets(json!([{"id": "minecraft:pale_oak_leaves", "required": false}, "oak_leaves"]));
|
||||
let required: Vec<bool> = t.0.iter().map(|e| e.1).collect();
|
||||
assert_eq!(required, [false, true]);
|
||||
assert!(t.matches("minecraft:pale_oak_leaves", |_| false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_pattern_is_an_error() {
|
||||
assert!(Targets::parse(Some(&json!("broken(")), None).is_err());
|
||||
assert!(Targets::parse(None, None).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user