Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions src/Extensions/ElementTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ public List<string> Validate()
if (elements.Count == 0)
errors.Add("The table defines no elements.");

foreach (var dup in Duplicates(elements.Select(e => e.Key), KeyComparer))
foreach (var dup in elements.Select(e => e.Key).Duplicates(KeyComparer))
errors.Add($"Element key {dup} is not unique.");
foreach (var dup in Duplicates(elements.Select(e => e.Id), EqualityComparer<byte>.Default))
foreach (var dup in elements.Select(e => e.Id).Duplicates())
errors.Add($"Element id {dup} is not unique.");

foreach (var element in elements)
Expand All @@ -196,7 +196,7 @@ public List<string> Validate()
if (defaults != 1)
errors.Add($"Exactly one element must have Default=\"true\". This table has {defaults}.");

foreach (var dup in Duplicates(groups.Select(g => g.Key), KeyComparer))
foreach (var dup in groups.Select(g => g.Key).Duplicates(KeyComparer))
errors.Add($"Group key {dup} is not unique.");
foreach (var group in groups)
{
Expand All @@ -213,14 +213,14 @@ public List<string> Validate()

if (rows.Count == 0)
errors.Add("The table has no Source rows.");
foreach (var dup in Duplicates(rows.Select(s => s.Element), KeyComparer))
foreach (var dup in rows.Select(s => s.Element).Duplicates(KeyComparer))
errors.Add($"Source element {dup} has more than one row.");
foreach (var row in rows)
{
var targets = row.Target ?? [];
if (!TryGetElement(row.Element, out _))
errors.Add($"Source row {row.Element} names an element that the table does not define.");
foreach (var dup in Duplicates(targets.Select(t => t.Element), KeyComparer))
foreach (var dup in targets.Select(t => t.Element).Duplicates(KeyComparer))
errors.Add($"Source row {row.Element} has more than one Target for {dup}.");
foreach (var target in targets.Where(t => !TryGetElement(t.Element, out _)))
errors.Add($"Source row {row.Element} has a Target for {target.Element}, which the table does not define.");
Expand All @@ -245,10 +245,4 @@ private bool TryGetGroup(string key, out ElementTableGroup group)
return group != null;
}

// Each value that occurs more than once. An empty key is reported elsewhere, so it is not a duplicate here.
private static IEnumerable<T> Duplicates<T>(IEnumerable<T> values, IEqualityComparer<T> comparer) =>
values.Where(v => v is not (null or ""))
.GroupBy(v => v, comparer)
.Where(g => g.Count() > 1)
.Select(g => g.Key);
}
10 changes: 10 additions & 0 deletions src/Extensions/Enumerable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,14 @@ public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> source)
{
return source.OrderBy(keySelector: x => Guid.NewGuid());
}

/// <summary>
/// Each value that occurs more than once. A null or empty value is not a duplicate here: the caller reports
/// a missing value on its own.
/// </summary>
internal static IEnumerable<T> Duplicates<T>(this IEnumerable<T> values, IEqualityComparer<T> comparer = null) =>
values.Where(v => v is not (null or ""))
.GroupBy(v => v, comparer)
.Where(g => g.Count() > 1)
.Select(g => g.Key);
}
206 changes: 206 additions & 0 deletions src/Extensions/Species.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// This file is part of Project Hybrasyl.
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the Affero General Public License as published by
// the Free Software Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but
// without ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the Affero General Public License
// for more details.
//
// You should have received a copy of the Affero General Public License along
// with this program. If not, see <http://www.gnu.org/licenses/>.
//
// (C) 2020-2026 ERISCO, LLC
//
// For contributors and individual authors please refer to CONTRIBUTORS.MD.

using System;
using System.Collections.Generic;
using System.Linq;
using Hybrasyl.Xml.Enums;
using Hybrasyl.Xml.Interfaces;
using Hybrasyl.Xml.Manager;

namespace Hybrasyl.Xml.Objects;

/// <summary>
/// A species in the world data. Saves store <see cref="Key" /> and the key of a body, so neither changes. Clients
/// receive <see cref="Id" /> and the body <see cref="SpeciesBody.Id" />.
/// </summary>
public partial class Species : ILoadOnStart<Species>, IAdditionalValidation<Species>
{
/// <summary>Compares species keys and body keys. Case does not matter.</summary>
public static readonly StringComparer KeyComparer = StringComparer.OrdinalIgnoreCase;

public override string PrimaryKey => Key;

public override List<string> SecondaryKeys => Key == null ? [] : [Key.ToLowerInvariant()];

public bool IsBranch => !string.IsNullOrEmpty(BranchOf);

/// <summary>The body with <c>Default="true"</c>, or null for a species with no bodies.</summary>
public SpeciesBody DefaultBody => Bodies?.FirstOrDefault(b => b.Default);

public new static void LoadAll(IWorldDataManager manager, string path) =>
HybrasylEntity<Species>.LoadAll(manager, path);

public bool TryGetBody(string key, out SpeciesBody body)
{
body = null;
if (string.IsNullOrEmpty(key) || Bodies == null) return false;
foreach (var candidate in Bodies)
{
if (!KeyComparer.Equals(candidate.Key, key)) continue;
body = candidate;
return true;
}

return false;
}

/// <summary>
/// Find a loaded species by key. Case does not matter, as for body keys. Use this, not the store key, because
/// the store compares primary keys with case.
/// </summary>
public static bool TryGet(IWorldDataManager manager, string key, out Species species)
{
species = null;
return !string.IsNullOrEmpty(key) && manager.TryGetValueByIndex(key.ToLowerInvariant(), out species);
}

/// <summary>
/// The parent of a branch, or null when the species is not a branch or the parent is not loaded. A branch takes
/// only <see cref="Affinity" /> and <see cref="Resistance" /> from its parent.
/// </summary>
public Species GetParent(IWorldDataManager manager) => IsBranch && TryGet(manager, BranchOf, out var parent) ? parent : null;

/// <summary>The affinity element key. A branch without its own affinity takes its parent's.</summary>
public string GetAffinity(IWorldDataManager manager) =>
string.IsNullOrEmpty(Affinity) ? GetParent(manager)?.Affinity : Affinity;

/// <summary>The resistance element key. A branch without its own resistance takes its parent's.</summary>
public string GetResistance(IWorldDataManager manager) =>
string.IsNullOrEmpty(Resistance) ? GetParent(manager)?.Resistance : Resistance;

/// <summary>
/// Run <see cref="Validate(IWorldDataManager)" /> on each species, and check the rules across species: keys that
/// differ only in case, unique species ids, body ids that are unique across all species, and a branch for each
/// ByBranch species. The manager runs it at load.
/// </summary>
public static void ValidateAll(IWorldDataManager manager)
{
var result = new XmlValidationResult();
var all = manager.Values<Species>().ToList();
var errors = all.ToDictionary(s => s.Guid, s => s.Validate(manager));

// Add a message to each species that shares a value with another species.
void Shared<T>(Func<Species, IEnumerable<T>> values, IEqualityComparer<T> comparer, Func<T, string> message)
{
var pairs = all.SelectMany(s => values(s).Where(v => v is not (null or "")).Distinct(comparer)
.Select(v => (Species: s, Value: v)));
foreach (var group in pairs.GroupBy(p => p.Value, comparer).Where(g => g.Count() > 1))
foreach (var (species, _) in group)
errors[species.Guid].Add(message(group.Key));
}

// Two keys that differ only in case have one lower-case index key, so a lookup finds either species. This is a
// stopgap: when the store reports a case-only collision for every type (HXML-34), remove it.
Shared<string>(s => [s.Key], KeyComparer, key => $"Species key {key} is not unique. Keys compare without case.");
Shared<byte>(s => [s.Id], null, id => $"Species id {id} is not unique.");
Shared<ushort>(s => (s.Bodies ?? []).Select(b => b.Id), null, id => $"Body id {id} is used by more than one species.");

// Nobody can pick a ByBranch species without branches. The usual cause is a typo in a branch's BranchOf, which
// gives the branch an error, so the intended parent gets one too.
var parents = all.Where(s => s.IsBranch).Select(s => s.BranchOf).ToHashSet(KeyComparer);
foreach (var species in all.Where(s => s.PlayerOption == SpeciesPlayerOption.ByBranch && !s.IsBranch))
if (!parents.Contains(species.Key))
errors[species.Guid].Add("The species is ByBranch, but no species is a branch of it.");

foreach (var species in all)
{
result.TotalProcessed++;
if (errors[species.Guid].Count > 0)
result.Errors.Add(species.Guid, $"Species {species.Key}: {string.Join(" ", errors[species.Guid])}");
}

manager.UpdateResult<Species>(result);
}

/// <summary>
/// Check the rules for one species that the schema cannot check, or that the loader does not check because it
/// does not validate files against the schema (HXML-35).
/// </summary>
/// <returns>One message for each problem. An empty list means the species is correct.</returns>
public List<string> Validate(IWorldDataManager manager)
{
var errors = new List<string>();
var bodies = Bodies ?? [];

if (IsBadValue(Key))
errors.Add("Key is empty, or it has a space at the start or the end.");
if (Names == null)
errors.Add("The species has no Names.");
else
foreach (var (form, name) in new[] { ("Singular", Names.Singular), ("Plural", Names.Plural), ("Adjective", Names.Adjective) })
if (IsBadValue(name))
errors.Add($"The {form} name is empty, or it has a space at the start or the end.");
if (bodies.Count == 0 && PlayerOption is SpeciesPlayerOption.Starter or SpeciesPlayerOption.Unlockable)
errors.Add($"A {PlayerOption} species has no bodies.");
var defaults = bodies.Count(b => b.Default);
if (bodies.Count > 0 && defaults != 1)
errors.Add($"Exactly one body must have Default=\"true\". This species has {defaults}.");
foreach (var body in bodies.Where(b => IsBadValue(b.Key)))
errors.Add($"Body id {body.Id} has no key, or its key has a space at the start or the end.");
foreach (var key in bodies.Select(b => b.Key).Duplicates(KeyComparer))
errors.Add($"Body key {key} is not unique.");
foreach (var id in bodies.Select(b => b.Id).Duplicates())
errors.Add($"Body id {id} is not unique.");
foreach (var body in bodies.Where(b => b.RetailSkinColor > 9))
errors.Add($"Body {body.Key} has RetailSkinColor {body.RetailSkinColor}. The maximum is 9.");

if (IsBranch)
{
if (PlayerOption == SpeciesPlayerOption.ByBranch)
errors.Add("A branch cannot be ByBranch, because a branch cannot have branches.");
var parent = GetParent(manager);
if (parent == null)
errors.Add($"BranchOf names {BranchOf}, which is not a loaded species.");
else if (parent.IsBranch)
errors.Add($"BranchOf names {BranchOf}, which is a branch itself.");
else if (parent.PlayerOption != SpeciesPlayerOption.ByBranch)
errors.Add($"BranchOf names {BranchOf}, which is not ByBranch.");
}
else
{
if (string.IsNullOrEmpty(Affinity))
errors.Add("The species has no Affinity, and it is not a branch.");
if (string.IsNullOrEmpty(Resistance))
errors.Add("The species has no Resistance, and it is not a branch.");
}

return errors;
}

// Keys are saved, and the loader does not trim them, so " Dwarf " is a different key from "Dwarf". Validate
// rejects such a key instead of trimming it, because a change to a saved key must be explicit. The name forms go to
// the client, so the same check applies to them.
private static bool IsBadValue(string value) => string.IsNullOrWhiteSpace(value) || value.Trim() != value;

/// <summary>
/// Check that <see cref="Affinity" /> and <see cref="Resistance" /> name elements of <paramref name="table" />.
/// The package cannot know the active element table, so the server calls this with it.
/// </summary>
/// <returns>One message for each problem. An empty list means the species is correct.</returns>
public List<string> Validate(ElementTable table)
{
ArgumentNullException.ThrowIfNull(table);
var errors = new List<string>();
if (!string.IsNullOrEmpty(Affinity) && !table.TryGetElement(Affinity, out _))
errors.Add($"Affinity {Affinity} is not an element of table {table.Name}.");
if (!string.IsNullOrEmpty(Resistance) && !table.TryGetElement(Resistance, out _))
errors.Add($"Resistance {Resistance} is not an element of table {table.Name}.");
return errors;
}
}
6 changes: 5 additions & 1 deletion src/Manager/WorldDataDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ public static string NameFor(string typeName)

var lower = typeName.ToLowerInvariant();

// A word whose plural is the same word.
if (lower == "species")
return lower;

// Sibilants take -es; a consonant before a final -y turns it into -ies. Every other
// entity name in this model is regular.
// entity name in this model, apart from the invariant plural above, is regular.
if (lower.EndsWith("s") || lower.EndsWith("x") || lower.EndsWith("z")
|| lower.EndsWith("ch") || lower.EndsWith("sh"))
return lower + "es";
Expand Down
2 changes: 1 addition & 1 deletion src/Objects/Access.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//------------------------------------------------------------------------------

// This code was generated by XmlSchemaClassGenerator version 3.0.1356.0 using the following command:
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Species.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
using System.Linq;

namespace Hybrasyl.Xml.Objects
Expand Down
2 changes: 1 addition & 1 deletion src/Objects/AddStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//------------------------------------------------------------------------------

// This code was generated by XmlSchemaClassGenerator version 3.0.1356.0 using the following command:
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Species.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
namespace Hybrasyl.Xml.Objects
{

Expand Down
2 changes: 1 addition & 1 deletion src/Objects/Animation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//------------------------------------------------------------------------------

// This code was generated by XmlSchemaClassGenerator version 3.0.1356.0 using the following command:
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Species.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
namespace Hybrasyl.Xml.Objects
{

Expand Down
2 changes: 1 addition & 1 deletion src/Objects/AnimationGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
//------------------------------------------------------------------------------

// This code was generated by XmlSchemaClassGenerator version 3.0.1356.0 using the following command:
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
// xscgen src/XSD/Castable.xsd src/XSD/Common.xsd src/XSD/Creature.xsd src/XSD/ElementTable.xsd src/XSD/Item.xsd src/XSD/Localization.xsd src/XSD/Loot.xsd src/XSD/Map.xsd src/XSD/Nation.xsd src/XSD/Recipe.xsd src/XSD/ServerConfig.xsd src/XSD/Spawns.xsd src/XSD/Species.xsd src/XSD/Status.xsd src/XSD/Trait.xsd -o src/Objects -n http://www.hybrasyl.com/XML/Hybrasyl/2020-02=Hybrasyl.Xml.Objects --separateFiles --collectionType=System.Collections.Generic.List`1 --collectionSettersMode=Public --enumCollection
namespace Hybrasyl.Xml.Objects
{

Expand Down
Loading
Loading