Sample outdoor weather from the climate preset so people can freeze and the clock can show it.
Protocol v8 adds tenths of a °C and precipitation to the clock frame; warmth drains from insulation versus place temperature. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,7 +46,7 @@ public static class NeedDecay
|
||||
|
||||
foreach (var def in catalog.Needs.Values)
|
||||
{
|
||||
if (def.Abstract || !needs.Values.TryGetValue(def.DefName, out var current))
|
||||
if (def.Abstract || def.Environmental || !needs.Values.TryGetValue(def.DefName, out var current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Street precipitation. Below 0 °C the same roll is snow, above it is rain.</summary>
|
||||
public enum Precipitation : byte
|
||||
{
|
||||
None = 0,
|
||||
Rain = 1,
|
||||
Snow = 2,
|
||||
}
|
||||
|
||||
/// <summary>Cached outdoor state shown on the clock and used to drain warmth.</summary>
|
||||
public readonly record struct OutdoorWeather(float TemperatureC, Precipitation Precipitation)
|
||||
{
|
||||
public static OutdoorWeather None { get; } = new(0f, Precipitation.None);
|
||||
|
||||
public short Tenths => (short)Math.Clamp(
|
||||
Math.Round(TemperatureC * 10d, MidpointRounding.AwayFromZero),
|
||||
short.MinValue,
|
||||
short.MaxValue);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Worn insulation in °C-equivalent points, summed from apparel currently on the body.
|
||||
/// Tests that need a bare frost set <see cref="Naked"/> on the entity explicitly.
|
||||
/// </summary>
|
||||
public readonly record struct PersonInsulation(float Value)
|
||||
{
|
||||
public static PersonInsulation Naked { get; } = new(0f);
|
||||
|
||||
public static PersonInsulation FromWorn(DefCatalog catalog, params string[] defNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
var sum = 0f;
|
||||
foreach (var name in defNames)
|
||||
{
|
||||
if (catalog.Things.TryGetValue(name, out var thing) && thing.Layers.Count > 0)
|
||||
{
|
||||
sum += thing.Insulation;
|
||||
}
|
||||
}
|
||||
|
||||
return new PersonInsulation(sum);
|
||||
}
|
||||
|
||||
public static PersonInsulation FromPerson(Person person, DefCatalog? catalog)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(person);
|
||||
if (catalog is null)
|
||||
{
|
||||
return Naked;
|
||||
}
|
||||
|
||||
var worn = WornDefs(person);
|
||||
return worn.Count == 0 ? Naked : FromWorn(catalog, [.. worn]);
|
||||
}
|
||||
|
||||
private static List<string> WornDefs(Person person)
|
||||
{
|
||||
var worn = new List<string>();
|
||||
if (person.GetType().GetProperty("Items")?.GetValue(person) is not System.Collections.IEnumerable items)
|
||||
{
|
||||
return worn;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var type = item.GetType();
|
||||
var def = type.GetProperty("Def")?.GetValue(item) as string;
|
||||
var location = type.GetProperty("Location")?.GetValue(item) as string
|
||||
?? type.GetProperty("Place")?.GetValue(item) as string;
|
||||
if (def is null || location is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (location.Equals("worn", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
worn.Add(def);
|
||||
}
|
||||
}
|
||||
|
||||
return worn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Street vs indoors. The yard is always outdoor; rooms opt in with <see cref="RoomDef.Outdoor"/>
|
||||
/// so the porch matches the street. Indoor temperature is the street plus the preset's wall offset
|
||||
/// — warmer, not yet comfortable (the technician is phase 33).
|
||||
/// </summary>
|
||||
public static class PlaceClimate
|
||||
{
|
||||
public static bool IsOutdoor(DefCatalog catalog, MapLayout? map, string? nodeId)
|
||||
{
|
||||
if (nodeId is null || map is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (map.Territory is { } territory && territory.Id.Equals(nodeId, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var defName = map.NodeDef(nodeId);
|
||||
if (defName is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (catalog.Territories.ContainsKey(defName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return catalog.Rooms.TryGetValue(defName, out var room) && room.Outdoor;
|
||||
}
|
||||
|
||||
public static float TemperatureC(School school, string? nodeId)
|
||||
{
|
||||
var outdoor = school.Weather.TemperatureC;
|
||||
var catalog = school.Catalog;
|
||||
if (catalog is null || IsOutdoor(catalog, school.Map, nodeId))
|
||||
{
|
||||
return outdoor;
|
||||
}
|
||||
|
||||
var offset = 8f;
|
||||
if (school.ClimatePresetId is { } presetId
|
||||
&& catalog.ClimatePresets.TryGetValue(presetId, out var preset)
|
||||
&& !preset.Abstract)
|
||||
{
|
||||
offset = preset.IndoorOffset;
|
||||
}
|
||||
|
||||
return outdoor + offset;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Arch.Core;
|
||||
using HSchool.People;
|
||||
using HSchool.Ai;
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
@@ -10,7 +11,7 @@ public static class RosterSpawner
|
||||
private static readonly QueryDescription People = new QueryDescription().WithAll<PersonIdentity>();
|
||||
private static readonly QueryDescription Classes = new QueryDescription().WithAll<ClassIdentity>();
|
||||
|
||||
public static void Spawn(World world, Roster roster)
|
||||
public static void Spawn(World world, Roster roster, DefCatalog? catalog = null)
|
||||
{
|
||||
foreach (var schoolClass in roster.Classes)
|
||||
{
|
||||
@@ -30,7 +31,8 @@ public static class RosterSpawner
|
||||
new PersonBody(person.Numbers, person.Choices),
|
||||
new PersonSkills(person.Skills.ToDictionary(pair => pair.Key, pair => (float)pair.Value, StringComparer.Ordinal)),
|
||||
new PersonTraits(person.Traits),
|
||||
new PersonNeeds(new Dictionary<string, float>(person.Needs, StringComparer.Ordinal)),
|
||||
new PersonNeeds(NeedsOf(person, catalog)),
|
||||
PersonInsulation.FromPerson(person, catalog),
|
||||
new PersonRoles(
|
||||
person.IsStudent,
|
||||
person.IsStaff,
|
||||
@@ -45,11 +47,30 @@ public static class RosterSpawner
|
||||
}
|
||||
|
||||
/// <summary>Drops the previous composition and spawns <paramref name="roster"/>. Called on yearly intake.</summary>
|
||||
public static void Replace(World world, Roster roster)
|
||||
public static void Replace(World world, Roster roster, DefCatalog? catalog = null)
|
||||
{
|
||||
DestroyAll(world, People);
|
||||
DestroyAll(world, Classes);
|
||||
Spawn(world, roster);
|
||||
Spawn(world, roster, catalog);
|
||||
}
|
||||
|
||||
private static Dictionary<string, float> NeedsOf(Person person, DefCatalog? catalog)
|
||||
{
|
||||
var values = new Dictionary<string, float>(person.Needs, StringComparer.Ordinal);
|
||||
if (catalog is null)
|
||||
{
|
||||
return values;
|
||||
}
|
||||
|
||||
foreach (var need in catalog.Needs.Values)
|
||||
{
|
||||
if (!need.Abstract && !values.ContainsKey(need.DefName))
|
||||
{
|
||||
values[need.DefName] = need.Initial;
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void DestroyAll(World world, QueryDescription query)
|
||||
|
||||
@@ -78,9 +78,12 @@ public sealed class School : IDisposable
|
||||
/// <summary>Country used to generate this school's people. Needed again on 1 September.</summary>
|
||||
public string? CountryId { get; private set; }
|
||||
|
||||
/// <summary>Climate preset rolled at birth. Phase 32 reads it; it cannot change on a live school.</summary>
|
||||
/// <summary>Climate preset rolled at birth. Weather reads it; it cannot change on a live school.</summary>
|
||||
public string? ClimatePresetId { get; private set; }
|
||||
|
||||
/// <summary>Street temperature and precipitation last committed for the clock and warmth.</summary>
|
||||
public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None;
|
||||
|
||||
/// <summary>Skill everyone generated for this school speaks natively.</summary>
|
||||
public string? NativeLanguage { get; private set; }
|
||||
|
||||
@@ -132,11 +135,12 @@ public sealed class School : IDisposable
|
||||
ClimatePresetId = climatePresetId;
|
||||
NativeLanguage = nativeLanguage;
|
||||
Applicants = applicants;
|
||||
RosterSpawner.Spawn(World, roster);
|
||||
RosterSpawner.Spawn(World, roster, Catalog);
|
||||
PlanDay = null;
|
||||
LastDecisionSlot = null;
|
||||
Plans.Clear();
|
||||
DecisionQueue.Clear();
|
||||
SyncWeather(force: true);
|
||||
}
|
||||
|
||||
public bool TryStartAction(string personId, string actionId)
|
||||
@@ -208,6 +212,7 @@ public sealed class School : IDisposable
|
||||
PlanDay = null;
|
||||
LastDecisionSlot = null;
|
||||
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
|
||||
SyncWeather(force: true);
|
||||
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
|
||||
}
|
||||
|
||||
@@ -224,7 +229,7 @@ public sealed class School : IDisposable
|
||||
Roster = roster;
|
||||
Applicants = applicants;
|
||||
var snapshot = PresenceSystem.Capture(this);
|
||||
RosterSpawner.Replace(World, roster);
|
||||
RosterSpawner.Replace(World, roster, Catalog);
|
||||
PresenceSystem.Restore(this, snapshot);
|
||||
TimetableDirty = true;
|
||||
}
|
||||
@@ -274,16 +279,50 @@ public sealed class School : IDisposable
|
||||
if (Catalog is not null)
|
||||
{
|
||||
var below = PresenceSystem.BelowThreshold(this);
|
||||
SyncWeather(force: false);
|
||||
NeedDecay.Apply(World, Catalog, gameMinutes);
|
||||
WarmthDecay.Apply(this, gameMinutes);
|
||||
PresenceSystem.EnqueueNewlyUrgent(this, below);
|
||||
PresenceSystem.DrainDecisions(this);
|
||||
LessonLearningSystem.Apply(this, gameMinutes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SyncWeather(force: false);
|
||||
}
|
||||
|
||||
return peopleChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes the street from the preset, seed and current time. Commits when the clock
|
||||
/// tenths or precipitation change, so warmth does not jitter every tick. A skip must force
|
||||
/// the morning sample — yesterday's evening must not stick.
|
||||
/// </summary>
|
||||
public void SyncWeather(bool force)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
var next = EvaluateWeather();
|
||||
if (force || next.Tenths != Weather.Tenths || next.Precipitation != Weather.Precipitation)
|
||||
{
|
||||
Weather = next;
|
||||
}
|
||||
}
|
||||
|
||||
private OutdoorWeather EvaluateWeather()
|
||||
{
|
||||
if (Catalog is null
|
||||
|| ClimatePresetId is null
|
||||
|| !Catalog.ClimatePresets.TryGetValue(ClimatePresetId, out var preset)
|
||||
|| preset.Abstract)
|
||||
{
|
||||
return OutdoorWeather.None;
|
||||
}
|
||||
|
||||
return WeatherSampler.Sample(preset, PeopleSeed, Clock.Time);
|
||||
}
|
||||
|
||||
private bool TryYearlyIntake(DateTime before, DateTime after)
|
||||
{
|
||||
if (Roster is null || Catalog is null || CountryId is null)
|
||||
@@ -301,7 +340,7 @@ public sealed class School : IDisposable
|
||||
if (changed)
|
||||
{
|
||||
var snapshot = PresenceSystem.Capture(this);
|
||||
RosterSpawner.Replace(World, Roster);
|
||||
RosterSpawner.Replace(World, Roster, Catalog);
|
||||
PresenceSystem.Restore(this, snapshot);
|
||||
TimetableDirty = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Ai;
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Drains <c>Warmth</c> from the gap between clothing insulation and the temperature of the
|
||||
/// place the person is in. Off campus the generic need restore already snaps it to max.
|
||||
/// </summary>
|
||||
public static class WarmthDecay
|
||||
{
|
||||
private static readonly QueryDescription People =
|
||||
new QueryDescription().WithAll<PersonNeeds, PersonTraits, PersonInsulation, Presence>();
|
||||
|
||||
public static void Apply(School school, double gameMinutes)
|
||||
{
|
||||
if (gameMinutes <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var catalog = school.Catalog;
|
||||
if (catalog is null || !catalog.Needs.TryGetValue("Warmth", out var warmth) || warmth.Abstract)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var preset = Preset(school, catalog);
|
||||
var hours = gameMinutes / 60d;
|
||||
var world = school.World;
|
||||
world.Query(
|
||||
in People,
|
||||
(ref PersonNeeds needs, ref PersonTraits traits, ref PersonInsulation insulation, ref Presence presence) =>
|
||||
{
|
||||
if (!presence.IsOnCampus || !needs.Values.ContainsKey(warmth.DefName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var place = PlaceClimate.TemperatureC(school, presence.NodeId);
|
||||
var felt = place + insulation.Value * (preset?.InsulationPerC ?? 1f);
|
||||
var center = (preset?.ComfortC ?? 21f) + TraitOffset(catalog, traits);
|
||||
var halfWidth = preset?.ComfortHalfWidthC ?? 3f;
|
||||
var mismatch = Math.Abs(felt - center) - halfWidth;
|
||||
if (mismatch <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var next = needs.Values[warmth.DefName] - (float)(mismatch * warmth.DecayPerHour * hours);
|
||||
needs.Values[warmth.DefName] = Math.Clamp(next, warmth.Min, warmth.Max);
|
||||
});
|
||||
}
|
||||
|
||||
private static ClimatePresetDef? Preset(School school, DefCatalog catalog)
|
||||
{
|
||||
if (school.ClimatePresetId is { } id
|
||||
&& catalog.ClimatePresets.TryGetValue(id, out var preset)
|
||||
&& !preset.Abstract)
|
||||
{
|
||||
return preset;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static float TraitOffset(DefCatalog catalog, PersonTraits traits)
|
||||
{
|
||||
var offset = 0f;
|
||||
foreach (var id in traits.Ids)
|
||||
{
|
||||
if (catalog.Traits.TryGetValue(id, out var trait))
|
||||
{
|
||||
offset += trait.ComfortTemperatureOffset;
|
||||
}
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.People;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// Outdoor temperature and precipitation from a climate preset, the school seed, and the
|
||||
/// calendar. Same inputs always produce the same street. Not a tick accumulator — sample it
|
||||
/// when the clock label or warmth would move.
|
||||
/// </summary>
|
||||
public static class WeatherSampler
|
||||
{
|
||||
private const int DaySalt = 0x57EA11;
|
||||
private const int HourSalt = 0x57EA12;
|
||||
|
||||
public static OutdoorWeather Sample(ClimatePresetDef preset, int schoolSeed, DateTime time)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(preset);
|
||||
|
||||
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
|
||||
var month = utc.Month;
|
||||
var norm = MonthNorm(preset, month);
|
||||
var dayNumber = DateOnly.FromDateTime(utc).DayNumber;
|
||||
var dayNoise = SignedUnit(Seed.Mix(schoolSeed, dayNumber, DaySalt)) * preset.DaySpread;
|
||||
var hour = utc.Hour + utc.Minute / 60d + utc.Second / 3600d;
|
||||
var diurnal = -Math.Cos((hour - 3d) / 24d * 2d * Math.PI) * preset.HourSpread;
|
||||
var temperature = (float)(norm + dayNoise + diurnal);
|
||||
|
||||
var wet = Unit(Seed.Mix(schoolSeed, dayNumber * 24 + utc.Hour, HourSalt)) < preset.PrecipitationChance;
|
||||
var precipitation = !wet
|
||||
? Precipitation.None
|
||||
: temperature < 0f ? Precipitation.Snow : Precipitation.Rain;
|
||||
|
||||
return new OutdoorWeather(temperature, precipitation);
|
||||
}
|
||||
|
||||
private static float MonthNorm(ClimatePresetDef preset, int month)
|
||||
{
|
||||
if (preset.MonthlyNorms.Count != 12)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
return preset.MonthlyNorms[month - 1];
|
||||
}
|
||||
|
||||
private static double Unit(int mixed)
|
||||
{
|
||||
return (uint)mixed / (double)uint.MaxValue;
|
||||
}
|
||||
|
||||
private static double SignedUnit(int mixed) => Unit(mixed) * 2d - 1d;
|
||||
}
|
||||
Reference in New Issue
Block a user