use std::collections::BTreeMap;
use serde::Deserialize;
use crate::Off;
use crate::content::icons::Tint;
use crate::content::items::StatFormat;
use crate::content::vanilla::Vanilla;
use crate::content::workshops::{Station, WorkshopId, stations};
use crate::content::{Guid, Id};
use crate::i18n::Texts;
use crate::loot::{Drop, Kinds, Source};
use super::entries::{FormatEntry, FromEntry, ItemsFile, LootFile, RecipesFile, WorkshopsFile};
use super::files::{PackError, PackFiles, ParsePackError};
use super::{ITEMS_FILE, LOOT_FILE, RECIPES_FILE, TEXTS_FILE, WORKSHOPS_FILE};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Ref {
Own,
Elsewhere,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct PackItem {
pub(super) id: String,
pub(super) like: Guid,
pub(super) name: Option<String>,
pub(super) description: Option<String>,
pub(super) debug_name: String,
pub(super) meter: Option<(u16, u16)>,
pub(super) stats: Vec<(String, i32, StatFormat)>,
pub(super) icon: Option<PackIcon>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum PackIcon {
Tint { like: Guid, tint: Tint },
Png(Vec<u8>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct PackRecipe {
pub(super) id: String,
pub(super) like: Guid,
pub(super) output: Guid,
pub(super) count: u32,
pub(super) cost: u32,
pub(super) workshop: Option<Guid>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct PackWorkshop {
pub(super) id: String,
pub(super) like: Station,
pub(super) name: Option<String>,
pub(super) description: Option<String>,
pub(super) cost: Option<u32>,
pub(super) debug_name: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Pack {
pub(super) namespace: String,
pub(super) items: Vec<PackItem>,
pub(super) recipes: Vec<PackRecipe>,
pub(super) workshops: Vec<PackWorkshop>,
pub(super) drops: Vec<Drop>,
pub(super) texts: String,
pub(super) hash: u32,
}
pub(super) struct Reader<'a> {
pub(super) namespace: &'a str,
pub(super) files: &'a PackFiles,
pub(super) errors: Vec<PackError>,
pub(super) defined: BTreeMap<String, &'static str>,
pub(super) vanilla: Option<&'a Vanilla>,
}
impl Reader<'_> {
fn file<T: for<'de> Deserialize<'de> + Default>(&mut self, name: &str) -> (T, String) {
let text = match self.files.text(name) {
Ok(Some(text)) => text.to_owned(),
Ok(None) => return (T::default(), String::new()),
Err(error) => {
self.errors.push(error);
return (T::default(), String::new());
}
};
match toml::from_str(&text) {
Ok(parsed) => (parsed, text),
Err(error) => {
self.errors.push(PackError::of(name, &text, &error));
(T::default(), text)
}
}
}
fn error(&mut self, file: &str, text: &str, at: usize, message: impl AsRef<str>) {
self.errors
.push(PackError::at(file, text, at, message.as_ref()));
}
fn own(&self, name: &str) -> String {
format!("{}:{name}", self.namespace)
}
fn define(&mut self, file: &str, text: &str, at: usize, name: &str, what: &'static str) {
let full = self.own(name);
if name.contains(':') || Guid::try_named(&full).is_none() {
self.error(
file,
text,
at,
format!("{what} id {name:?} is no name: 1 to 128 of [a-z0-9_./-]"),
);
return;
}
if let Some(earlier) = self.defined.insert(full, what) {
self.error(
file,
text,
at,
format!("{what} id {name:?} is taken by a {earlier} already"),
);
}
}
fn reference(
&mut self,
file: &str,
text: &str,
at: usize,
reference: &str,
what: &'static str,
suffix: &str,
) -> Option<(Guid, Ref)> {
if let Some(guid) = Guid::try_parse(reference) {
return Some((guid, Ref::Elsewhere));
}
if reference.contains(':') {
let guid = Guid::try_named(reference);
if guid.is_none() {
self.error(
file,
text,
at,
format!("{reference:?} is no content id: <mod>:<name>"),
);
}
return guid.map(|g| (g, Ref::Elsewhere));
}
let full = self.own(reference);
if self.defined.get(&full) == Some(&what) {
return Guid::try_named(&format!("{full}{suffix}")).map(|g| (g, Ref::Own));
}
let found = match self.vanilla {
None => Err(format!(
"no {what} {reference:?} in this pack; name another mod's as <mod>:<name>, or a vanilla one by its GUID or, where the game's data is at hand, its debug name"
)),
Some(vanilla) => vanilla
.item(reference)
.and_then(|item| match what {
"workshop" => vanilla.workshop(item).map(WorkshopId::guid),
_ => Ok(item),
})
.map_err(|why| format!("no {what} {reference:?} in this pack, and {why}")),
};
match found {
Ok(guid) => Some((guid, Ref::Elsewhere)),
Err(why) => {
self.error(file, text, at, why);
None
}
}
}
fn station(
&mut self,
file: &str,
text: &str,
at: usize,
id: &str,
like: &str,
) -> Option<Station> {
if like == "workbench" {
return Some(stations::WORKBENCH);
}
let found = match (self.vanilla, Guid::try_parse(like)) {
(_, Some(item)) if item == stations::WORKBENCH.item => Ok(stations::WORKBENCH),
(None, _) => Err(format!(
"like {like:?}: a station other than the workbench needs the game's data at hand"
)),
(Some(vanilla), Some(item)) => vanilla.station(item).map_err(|why| why.to_string()),
(Some(vanilla), None) => vanilla
.item(like)
.and_then(|item| vanilla.station(item))
.map_err(|why| why.to_string()),
};
found
.map_err(|why| self.error(file, text, at, format!("workshop {id}: {why}")))
.ok()
}
fn text_key(
&mut self,
file: &str,
text: &str,
at: usize,
key: &str,
texts: Option<&Texts>,
) -> Option<String> {
let tag = self.own(&format!("text/{key}"));
if Guid::try_named(&tag).is_none() {
self.error(
file,
text,
at,
format!("text key {key:?} is no name: 1 to 123 of [a-z0-9_./-]"),
);
return None;
}
if texts.is_some_and(|t| t.translations(key).next().is_none()) {
self.error(
file,
text,
at,
format!("text key {key:?} has no text in {TEXTS_FILE}"),
);
}
Some(key.to_owned())
}
}
pub(super) fn source_of(from: &FromEntry) -> Result<Source, String> {
let kind = |name: &str| match name {
"corpses" => Some(Kinds::CORPSES),
"chests" => Some(Kinds::CHESTS),
"containers" => Some(Kinds::CONTAINERS),
"destroyed" => Some(Kinds::DESTROYED),
_ => None,
};
let unknown = |name: &str| {
format!("from {name:?}: bosses, locked-chests, corpses, chests, containers or destroyed")
};
match from {
FromEntry::One(name) if name == "bosses" => Ok(Source::bosses()),
FromEntry::One(name) if name == "locked-chests" => Ok(Source::locked_chests()),
FromEntry::One(name) => kind(name).map(Source::kinds).ok_or_else(|| unknown(name)),
FromEntry::Many(names) if names.is_empty() => Err("from names no kind".to_owned()),
FromEntry::Many(names) => names
.iter()
.try_fold(Source::kinds(Kinds::default()), |s, n| {
kind(n)
.map(|k| Source::kinds(s.kinds.with(k)))
.ok_or_else(|| unknown(n))
}),
}
}
impl Pack {
pub fn parse(namespace: &str, files: &PackFiles) -> Result<Self, ParsePackError> {
Self::parse_with(namespace, files, Vanilla::game())
}
pub fn parse_with(
namespace: &str,
files: &PackFiles,
vanilla: Option<&Vanilla>,
) -> Result<Self, ParsePackError> {
let mut reader = Reader {
namespace,
files,
errors: Vec::new(),
defined: BTreeMap::new(),
vanilla,
};
let texts_text = match files.text(TEXTS_FILE) {
Ok(text) => text.unwrap_or("").to_owned(),
Err(error) => {
reader.errors.push(error);
String::new()
}
};
let texts = match toml::from_str::<BTreeMap<String, BTreeMap<String, String>>>(&texts_text)
{
Err(error) => {
reader
.errors
.push(PackError::of(TEXTS_FILE, &texts_text, &error));
None
}
Ok(_) => match Texts::parse(&texts_text) {
Ok(texts) => Some(texts),
Err(off) => {
reader.error(TEXTS_FILE, &texts_text, 0, off.to_string());
None
}
},
};
let (items_file, items_text): (ItemsFile, String) = reader.file(ITEMS_FILE);
let (workshops_file, workshops_text): (WorkshopsFile, String) = reader.file(WORKSHOPS_FILE);
let (recipes_file, recipes_text): (RecipesFile, String) = reader.file(RECIPES_FILE);
let (loot_file, loot_text): (LootFile, String) = reader.file(LOOT_FILE);
for entry in &items_file.item {
let at = entry.span().start;
reader.define(ITEMS_FILE, &items_text, at, &entry.get_ref().id, "item");
}
for entry in &workshops_file.workshop {
let at = entry.span().start;
reader.define(
WORKSHOPS_FILE,
&workshops_text,
at,
&entry.get_ref().id,
"workshop",
);
}
for entry in &recipes_file.recipe {
let at = entry.span().start;
reader.define(
RECIPES_FILE,
&recipes_text,
at,
&entry.get_ref().id,
"recipe",
);
}
let mut pack = Self {
namespace: namespace.to_owned(),
items: Vec::new(),
recipes: Vec::new(),
workshops: Vec::new(),
drops: Vec::new(),
texts: texts_text,
hash: files.hash(),
};
pack.items = items(&mut reader, &items_file, &items_text, texts.as_ref());
pack.workshops = workshops(
&mut reader,
&workshops_file,
&workshops_text,
texts.as_ref(),
);
pack.recipes = recipes(&mut reader, &recipes_file, &recipes_text);
pack.drops = drops(&mut reader, &loot_file, &loot_text, &pack.items);
if reader.errors.is_empty() {
Ok(pack)
} else {
Err(ParsePackError(reader.errors))
}
}
#[must_use]
pub fn ids(&self) -> Vec<&str> {
self.workshops
.iter()
.map(|w| w.id.as_str())
.chain(self.items.iter().map(|i| i.id.as_str()))
.chain(self.recipes.iter().map(|r| r.id.as_str()))
.collect()
}
#[must_use]
pub fn drops(&self) -> &[Drop] {
&self.drops
}
#[must_use]
pub fn same_content(&self, other: &Self) -> bool {
self.namespace == other.namespace
&& self.items == other.items
&& self.recipes == other.recipes
&& self.workshops == other.workshops
&& self.texts == other.texts
}
#[must_use]
pub fn hash(&self) -> u32 {
self.hash
}
}
pub(super) fn items(
reader: &mut Reader<'_>,
items_file: &ItemsFile,
items_text: &str,
texts: Option<&Texts>,
) -> Vec<PackItem> {
let files = reader.files;
let mut out = Vec::new();
let file = ITEMS_FILE;
let text = items_text;
for entry in &items_file.item {
let at = entry.span().start;
let e = entry.get_ref();
let like = reader.reference(file, text, at, &e.like, "item", "");
let name = e
.name
.as_deref()
.and_then(|k| reader.text_key(file, text, at, k, texts));
let description = e
.description
.as_deref()
.and_then(|k| reader.text_key(file, text, at, k, texts));
let meter = e.meter.map(|m| (m.max, m.start.unwrap_or(m.max)));
if let Some((max, start)) = meter
&& (max == 0 || start > max)
{
reader.error(
file,
text,
at,
format!(
"item {}: a meter holds 1 or more and starts at most full",
e.id
),
);
}
let mut stats = Vec::new();
for stat in &e.stats {
if let Some(label) = reader.text_key(file, text, at, &stat.label, texts) {
let format = match stat.format {
FormatEntry::Number => StatFormat::Number,
FormatEntry::Signed => StatFormat::Signed,
FormatEntry::Percent => StatFormat::Percent,
};
stats.push((label, stat.value, format));
}
}
let icon = e
.icon
.as_ref()
.and_then(|icon| match (&icon.tint, &icon.png) {
(Some(colour), None) => {
let like = match &icon.like {
Some(r) => reader.reference(file, text, at, r, "item", "")?.0,
None => like?.0,
};
let amount = icon.amount.unwrap_or(50).min(100);
Some(PackIcon::Tint {
like,
tint: Tint {
colour: *colour,
amount,
},
})
}
(None, Some(png)) => match files.get(png) {
Some(bytes) if png.starts_with("icons/") => Some(PackIcon::Png(bytes.to_vec())),
_ => {
reader.error(
file,
text,
at,
format!("item {}: no icon {png:?} under icons/", e.id),
);
None
}
},
_ => {
reader.error(
file,
text,
at,
format!("item {}: an icon has either tint or png", e.id),
);
None
}
});
if let Some((like, _)) = like {
out.push(PackItem {
id: reader.own(&e.id),
like,
name,
description,
debug_name: e.debug_name.clone().unwrap_or_else(|| reader.own(&e.id)),
meter,
stats,
icon,
});
}
}
out
}
pub(super) fn workshops(
reader: &mut Reader<'_>,
workshops_file: &WorkshopsFile,
workshops_text: &str,
texts: Option<&Texts>,
) -> Vec<PackWorkshop> {
let mut out = Vec::new();
let file = WORKSHOPS_FILE;
let text = workshops_text;
for entry in &workshops_file.workshop {
let at = entry.span().start;
let e = entry.get_ref();
let like = reader.station(file, text, at, &e.id, &e.like);
let name = e
.name
.as_deref()
.and_then(|k| reader.text_key(file, text, at, k, texts));
let description = e
.description
.as_deref()
.and_then(|k| reader.text_key(file, text, at, k, texts));
if let Some(like) = like {
out.push(PackWorkshop {
id: reader.own(&e.id),
like,
name,
description,
cost: e.recipe.map(|r| r.cost),
debug_name: e.debug_name.clone().unwrap_or_else(|| reader.own(&e.id)),
});
}
}
out
}
pub(super) fn recipes(
reader: &mut Reader<'_>,
recipes_file: &RecipesFile,
recipes_text: &str,
) -> Vec<PackRecipe> {
let mut out = Vec::new();
let file = RECIPES_FILE;
let text = recipes_text;
for entry in &recipes_file.recipe {
let at = entry.span().start;
let e = entry.get_ref();
let like = reader.reference(file, text, at, &e.like, "item", "");
let output = reader.reference(file, text, at, &e.output, "item", "");
let workshop = e
.workshop
.as_deref()
.map(|w| reader.reference(file, text, at, w, "workshop", ".workshop"));
if e.count == 0 || e.cost == 0 {
reader.error(
file,
text,
at,
format!("recipe {}: count and cost are 1 or more", e.id),
);
}
let workshop = match workshop {
None => Some(None),
Some(Some((guid, _))) => Some(Some(guid)),
Some(None) => None,
};
if let (Some((like, _)), Some((output, _)), Some(workshop)) = (like, output, workshop) {
out.push(PackRecipe {
id: reader.own(&e.id),
like,
output,
count: e.count,
cost: e.cost,
workshop,
});
}
}
out
}
pub(super) fn drops(
reader: &mut Reader<'_>,
loot: &LootFile,
text: &str,
items: &[PackItem],
) -> Vec<Drop> {
let file = LOOT_FILE;
let mut drops = Vec::new();
for entry in &loot.drop {
let at = entry.span().start;
let e = entry.get_ref();
let mut ids = Vec::new();
let mut meter_of = Vec::new();
for item in &e.items {
if let Some((guid, kind)) = reader.reference(file, text, at, item, "item", "") {
ids.push(guid.id());
let own = (kind == Ref::Own)
.then(|| items.iter().find(|i| Guid::try_named(&i.id) == Some(guid)))
.flatten();
meter_of.push(own.and_then(|i| i.meter).map(|(max, _)| max));
}
}
if e.items.is_empty() {
reader.error(file, text, at, "a drop names no item");
}
if !(0.0..=1.0).contains(&e.chance) {
reader.error(
file,
text,
at,
format!("chance {} is not between 0 and 1", e.chance),
);
}
let from = match source_of(&e.from) {
Ok(from) => from,
Err(why) => {
reader.error(file, text, at, why);
continue;
}
};
let [min, max] = e.count.unwrap_or([1, 1]);
if min == 0 || min > max {
reader.error(file, text, at, format!("count {min}..{max} is empty"));
}
let mut drop = Drop::one_of(&ids, e.chance, from).count(min, max);
if let Some([low, high]) = e.meter {
match meter_of.first().copied().flatten() {
Some(of) if meter_of.iter().all(|m| *m == Some(of)) && low <= high && high <= of => {
drop = drop.meter(low, high, of);
}
_ => reader.error(
file,
text,
at,
format!("meter {low}..{high}: every item must be one of the pack's with a meter of the same maximum, at least {high}"),
),
}
}
if ids.len() == e.items.len() {
drops.push(drop);
}
}
drops
}
pub(super) fn id(full: &str) -> Result<Id, Off> {
let name: &'static str = Box::leak(full.to_owned().into_boxed_str());
Id::try_new(name).ok_or_else(|| Off::new(format!("content pack: {full:?} is no content id")))
}
#[must_use]
pub(crate) fn group(namespace: &str) -> u64 {
namespace
.bytes()
.fold(0xcbf2_9ce4_8422_2325_u64, |hash, b| {
(hash ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3)
})
| 1
}
#[doc(hidden)]
pub fn reload_loot(namespace: &str, drops: Vec<Drop>) -> Result<(), String> {
crate::loot::replace_group(group(namespace), drops)
}