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:
Leonid Pershin
2026-08-20 03:49:40 +03:00
co-authored by Cursor
parent d8f4958167
commit 8e7ab46e79
40 changed files with 1008 additions and 39 deletions
@@ -0,0 +1,116 @@
using Arch.Core;
using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation.Tests;
public class WarmthTests
{
private static readonly DateTime JanuaryMorning = new(2012, 1, 15, 6, 0, 0, DateTimeKind.Utc);
[Fact]
public void NakedOnFrost_LosesWarmth_AndHomeRestoresItOvernight()
{
using var school = Open(JanuaryMorning);
PutFirstPerson(school, "yard", PersonInsulation.Naked, warmth: 1f);
school.SyncWeather(force: true);
Assert.True(school.Weather.TemperatureC < 0f);
WarmthDecay.Apply(school, gameMinutes: 60);
var afterHour = WarmthOf(school);
Assert.True(afterHour < 1f, $"warmth stayed {afterHour} on the frost");
PutFirstPerson(school, nodeId: null, PersonInsulation.Naked, afterHour);
NeedDecay.Apply(school.World, school.Catalog!, gameMinutes: 60);
Assert.Equal(school.Catalog!.Needs["Warmth"].Max, WarmthOf(school));
}
[Fact]
public void JacketOnFrost_LosesLessWarmthThanNaked()
{
using var school = Open(JanuaryMorning);
var catalog = school.Catalog!;
school.SyncWeather(force: true);
PutFirstPerson(school, "yard", PersonInsulation.Naked, warmth: 1f);
WarmthDecay.Apply(school, gameMinutes: 60);
var naked = WarmthOf(school);
PutFirstPerson(school, "yard", PersonInsulation.FromWorn(catalog, "Jacket"), warmth: 1f);
WarmthDecay.Apply(school, gameMinutes: 60);
var coated = WarmthOf(school);
Assert.True(naked < 1f);
Assert.True(coated > naked, $"jacket {coated} should beat naked {naked}");
}
private static void PutFirstPerson(School school, string? nodeId, PersonInsulation insulation, float warmth)
{
var query = new QueryDescription().WithAll<PersonNeeds, PersonInsulation, Presence>();
var first = true;
school.World.Query(
in query,
(ref PersonNeeds needs, ref PersonInsulation worn, ref Presence presence) =>
{
if (!first)
{
return;
}
first = false;
worn = insulation;
needs.Values["Warmth"] = warmth;
presence = nodeId is null
? Presence.OffCampus
: new Presence(nodeId, 0f, nodeId, false, []);
});
}
private static float WarmthOf(School school)
{
var value = float.NaN;
var query = new QueryDescription().WithAll<PersonNeeds>();
school.World.Query(in query, (ref PersonNeeds needs) =>
{
if (float.IsNaN(value) && needs.Values.TryGetValue("Warmth", out var warmth))
{
value = warmth;
}
});
return value;
}
private static School Open(DateTime start)
{
var (catalog, map) = Vanilla();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
var school = School.Create(1, "Тепло", start, catalog, map);
school.InstallPeople(roster, seed: 1, "Russia", pool, climatePresetId: "TemperateContinental");
school.ConfigurePresence();
return school;
}
private static (DefCatalog Catalog, MapLayout Map) Vanilla()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
return (catalog, map);
}
}
@@ -0,0 +1,134 @@
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation.Tests;
public class WeatherTests
{
private static readonly DateTime JanuaryMorning = new(2012, 1, 15, 6, 0, 0, DateTimeKind.Utc);
private static readonly DateTime JulyAfternoon = new(2012, 7, 15, 15, 0, 0, DateTimeKind.Utc);
[Fact]
public void SamePresetSeedAndTimestamp_YieldTheSameStreet()
{
var preset = Vanilla().ClimatePresets["TemperateContinental"];
var first = WeatherSampler.Sample(preset, schoolSeed: 7, JanuaryMorning);
var second = WeatherSampler.Sample(preset, schoolSeed: 7, JanuaryMorning);
Assert.Equal(first, second);
}
[Fact]
public void January_IsColderThanJuly_OnTheVanillaPreset()
{
var preset = Vanilla().ClimatePresets["TemperateContinental"];
var january = WeatherSampler.Sample(preset, schoolSeed: 3, JanuaryMorning);
var july = WeatherSampler.Sample(preset, schoolSeed: 3, JulyAfternoon);
Assert.True(january.TemperatureC < july.TemperatureC, $"{january.TemperatureC} vs {july.TemperatureC}");
}
[Fact]
public void PrecipitationBelowZero_IsSnowNotRain()
{
var wet = new ClimatePresetDef
{
DefName = "AlwaysWet",
MonthlyNorms = [-12, -10, -4, 5, 12, 17, 20, 18, 12, 5, -1, -8],
DaySpread = 0,
HourSpread = 0,
PrecipitationChance = 1f,
};
var january = WeatherSampler.Sample(wet, schoolSeed: 1, JanuaryMorning);
var july = WeatherSampler.Sample(wet, schoolSeed: 1, JulyAfternoon);
Assert.True(january.TemperatureC < 0);
Assert.Equal(Precipitation.Snow, january.Precipitation);
Assert.True(july.TemperatureC > 0);
Assert.Equal(Precipitation.Rain, july.Precipitation);
}
[Fact]
public void VanillaJanuary_SnowIsNeverRain()
{
var preset = Vanilla().ClimatePresets["TemperateContinental"];
for (var seed = 1; seed <= 40; seed++)
{
var weather = WeatherSampler.Sample(preset, seed, JanuaryMorning);
if (weather.Precipitation == Precipitation.None)
{
continue;
}
Assert.True(weather.TemperatureC < 0);
Assert.Equal(Precipitation.Snow, weather.Precipitation);
}
}
[Fact]
public void SkipEmpty_SetsMondayMorningWeather_NotSaturdays()
{
var saturday = new DateTime(2012, 1, 14, 22, 0, 0, DateTimeKind.Utc);
var monday = new DateTime(2012, 1, 16, 6, 0, 0, DateTimeKind.Utc);
using var school = Open(saturday);
var evening = school.Weather;
Assert.True(school.TrySkipEmpty().Succeeded);
Assert.Equal(monday, school.Clock.Time);
var expected = WeatherSampler.Sample(
school.Catalog!.ClimatePresets["TemperateContinental"],
school.PeopleSeed,
monday);
Assert.Equal(expected, school.Weather);
Assert.NotEqual(evening, school.Weather);
}
[Fact]
public void Indoor_IsWarmerThanTheYard_ButNotComfortableInJanuary()
{
using var school = Open(JanuaryMorning);
var outdoor = PlaceClimate.TemperatureC(school, "yard");
var porch = PlaceClimate.TemperatureC(school, "porch");
var room = PlaceClimate.TemperatureC(school, "classroom-101");
Assert.Equal(outdoor, porch);
Assert.True(room > outdoor);
Assert.True(room < 18f, $"walls only, got {room}");
}
private static School Open(DateTime start)
{
var (catalog, map) = VanillaMap();
var roster = RosterGenerator.Generate(catalog, map, schoolSeed: 1, "Russia", start);
var pool = ApplicantPool.Create(catalog, roster, schoolSeed: 1, "Russia", start);
var school = School.Create(1, "Погода", start, catalog, map);
school.InstallPeople(roster, seed: 1, "Russia", pool, climatePresetId: "TemperateContinental");
school.ConfigurePresence();
return school;
}
private static DefCatalog Vanilla()
{
var (catalog, _) = VanillaMap();
return catalog;
}
private static (DefCatalog Catalog, MapLayout Map) VanillaMap()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(root, path).Replace('\\', '/');
documents.Add(new ContentDocument(CatalogLoader.CorePackId, relative, File.ReadAllText(path)));
}
var catalog = new CatalogLoader().Load([CatalogLoader.CorePackId], documents);
var map = CatalogLoader.LastDefaultMap([CatalogLoader.CorePackId], documents);
Assert.NotNull(map);
return (catalog, map);
}
}