Merge branch 'phase/78-diseases'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 13:39:49 +03:00
co-authored by Cursor
28 changed files with 1189 additions and 39 deletions
+8 -1
View File
@@ -319,6 +319,7 @@ public sealed class CatalogLoader
var orientations = new Dictionary<string, OrientationDef>(StringComparer.Ordinal);
var affinity = new Dictionary<string, AffinityRulesDef>(StringComparer.Ordinal);
var events = new Dictionary<string, EventDef>(StringComparer.Ordinal);
var diseases = new Dictionary<string, DiseaseDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -396,6 +397,9 @@ public sealed class CatalogLoader
case DefKind.Event:
events[key.Name] = Jsonc.Deserialize<EventDef>(json);
break;
case DefKind.Disease:
diseases[key.Name] = Jsonc.Deserialize<DiseaseDef>(json);
break;
}
}
@@ -425,6 +429,7 @@ public sealed class CatalogLoader
orientations,
affinity,
events,
diseases,
ru,
en);
}
@@ -547,6 +552,7 @@ public sealed class CatalogLoader
PeopleDefValidator.Validate(catalog, log);
EventDefValidator.Validate(catalog);
DiseaseDefValidator.Validate(catalog);
}
private static void WarnMissingLabels(DefCatalog catalog, IContentLog log)
@@ -585,7 +591,8 @@ public sealed class CatalogLoader
.Concat(Enumerate(catalog.Topics.Values))
.Concat(Enumerate(catalog.Orientations.Values))
.Concat(Enumerate(catalog.Affinity.Values))
.Concat(Enumerate(catalog.Events.Values));
.Concat(Enumerate(catalog.Events.Values))
.Concat(Enumerate(catalog.Diseases.Values));
static IEnumerable<Def> Enumerate(IEnumerable<Def> defs) => defs.Where(def => !def.Abstract);
}
+6
View File
@@ -32,6 +32,7 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, OrientationDef> orientations,
IReadOnlyDictionary<string, AffinityRulesDef> affinity,
IReadOnlyDictionary<string, EventDef> events,
IReadOnlyDictionary<string, DiseaseDef> diseases,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -60,6 +61,7 @@ public sealed class DefCatalog
Orientations = orientations;
Affinity = affinity;
Events = events;
Diseases = diseases;
_ru = ru;
_en = en;
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
@@ -125,6 +127,8 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, EventDef> Events { get; }
public IReadOnlyDictionary<string, DiseaseDef> Diseases { get; }
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
@@ -168,6 +172,7 @@ public sealed class DefCatalog
DefKind.Orientation => Orientations.GetValueOrDefault(defName),
DefKind.AffinityRules => Affinity.GetValueOrDefault(defName),
DefKind.Event => Events.GetValueOrDefault(defName),
DefKind.Disease => Diseases.GetValueOrDefault(defName),
_ => null,
};
@@ -252,6 +257,7 @@ public sealed class DefCatalog
OrientationDef => DefKind.Orientation,
AffinityRulesDef => DefKind.AffinityRules,
EventDef => DefKind.Event,
DiseaseDef => DefKind.Disease,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
+1
View File
@@ -26,6 +26,7 @@ public enum DefKind
Orientation,
AffinityRules,
Event,
Disease,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
@@ -0,0 +1,91 @@
namespace HSchool.Content;
internal static class DiseaseDefValidator
{
private static readonly HashSet<string> Families = new(StringComparer.Ordinal)
{
DiseaseFamilies.Respiratory,
DiseaseFamilies.Gastrointestinal,
DiseaseFamilies.Ent,
};
public static void Validate(DefCatalog catalog)
{
foreach (var def in catalog.Diseases.Values)
{
if (def.Abstract)
{
continue;
}
if (string.IsNullOrWhiteSpace(def.Family))
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' family is required.");
}
if (!Families.Contains(def.Family))
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' has unknown family '{def.Family}'.");
}
if (def.Stages.Count == 0)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' stages are required.");
}
if (def.IncubationDays < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' incubationDays cannot be negative.");
}
if (def.ImmunityDays < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' immunityDays cannot be negative.");
}
if (def.BaseChancePerDay < 0f
|| def.ColdChancePerDay < 0f
|| def.RainChancePerDay < 0f
|| def.SnowChancePerDay < 0f)
{
throw new ContentLoadException($"DiseaseDef '{def.DefName}' chance fields cannot be negative.");
}
DiseaseStage? previous = null;
foreach (var stage in def.Stages)
{
if (stage.MinSeverity < 0f || stage.MinSeverity > 1f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' stage minSeverity must be 01.");
}
if (previous is not null && stage.MinSeverity < previous.MinSeverity)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' stages must be ascending by minSeverity.");
}
if (stage.LessonLearningFactor < 0f || stage.LessonLearningFactor > 1f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' lessonLearningFactor must be 01.");
}
if (stage.StayHomeChance < 0f || stage.StayHomeChance > 1f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' stayHomeChance must be 01.");
}
if (stage.WarmthDecayFactor < 0f)
{
throw new ContentLoadException(
$"DiseaseDef '{def.DefName}' warmthDecayFactor cannot be negative.");
}
previous = stage;
}
}
}
}
+61
View File
@@ -0,0 +1,61 @@
namespace HSchool.Content;
/// <summary>Stable family ids for immunity grouping and realism labels.</summary>
public static class DiseaseFamilies
{
public const string Respiratory = "respiratory";
public const string Gastrointestinal = "gastrointestinal";
public const string Ent = "ent";
}
/// <summary>One severity band on a <see cref="DiseaseDef"/> curve.</summary>
public sealed class DiseaseStage
{
/// <summary>Inclusive lower bound of severity for this stage.</summary>
public float MinSeverity { get; init; }
public float SeverityPerDay { get; init; }
public float ProgressPerDay { get; init; }
/// <summary>Multiplies lesson skill gain while this stage is active (after incubation).</summary>
public float LessonLearningFactor { get; init; } = 1f;
/// <summary>Per-day chance the person stays off campus while in this stage (after incubation).</summary>
public float StayHomeChance { get; init; }
/// <summary>Multiplies Warmth decay. 1 = unchanged.</summary>
public float WarmthDecayFactor { get; init; } = 1f;
}
/// <summary>
/// A named disease: stages, weather vectors, lesson/attendance effects, temporary immunity.
/// Contagion between people is phase 79 — these defs may omit it.
/// </summary>
public sealed class DiseaseDef : Def
{
/// <summary><see cref="DiseaseFamilies"/> id. Required on concrete defs.</summary>
public string Family { get; init; } = "";
/// <summary>Game days after onset before stage effects (lesson / stay-home) apply.</summary>
public float IncubationDays { get; init; }
public IReadOnlyList<DiseaseStage> Stages { get; init; } = [];
/// <summary>Base daily onset chance before weather vectors and <see cref="BehaviorDef.DiseaseVectorScale"/>.</summary>
public float BaseChancePerDay { get; init; }
/// <summary>Street °C at or below this adds <see cref="ColdChancePerDay"/>. Null — no cold vector.</summary>
public float? ColdBelowC { get; init; }
public float ColdChancePerDay { get; init; }
public float RainChancePerDay { get; init; }
public float SnowChancePerDay { get; init; }
/// <summary>Days of immunity to this def after recovery.</summary>
public float ImmunityDays { get; init; }
}
+3
View File
@@ -141,6 +141,9 @@ internal static class PackPaths
case "events":
kind = DefKind.Event;
return true;
case "diseases":
kind = DefKind.Disease;
return true;
default:
kind = default;
return false;
@@ -751,6 +751,12 @@ internal static class PeopleDefValidator
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' lessonLateMinutes cannot be negative.");
}
if (behavior.DiseaseVectorScale < 0f)
{
throw new ContentLoadException(
$"BehaviorDef '{behavior.DefName}' diseaseVectorScale cannot be negative.");
}
}
private static void ValidateTopic(TopicDef topic, DefCatalog catalog)
+5
View File
@@ -655,6 +655,11 @@ public sealed class BehaviorDef : Def
/// </summary>
public bool LessonMarkWhenLate { get; init; } = true;
/// <summary>
/// Scales DiseaseDef weather/base onset chances. 0 disables natural onset; missing keeps 1.
/// </summary>
public float DiseaseVectorScale { get; init; } = 1f;
public static IReadOnlyList<float> DefaultLessonMarkThresholds { get; } =
[
0.85f,