From 8e7ab46e7921cc7e849541972ca1f993cadb4629 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 20 Aug 2026 03:49:40 +0300 Subject: [PATCH] Sample outdoor weather from the climate preset so people can freeze and the clock can show it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Protocol v8 adds tenths of a °C and precipitation to the clock frame; warmth drains from insulation versus place temperature. Co-authored-by: Cursor --- docs/protocol.md | 10 +- src/HSchool.Client/src/format/weather.test.ts | 26 ++++ src/HSchool.Client/src/format/weather.ts | 23 +++ src/HSchool.Client/src/i18n/strings.test.ts | 1 + src/HSchool.Client/src/i18n/strings.ts | 8 ++ src/HSchool.Client/src/net/protocol.test.ts | 6 +- src/HSchool.Client/src/net/protocol.ts | 16 ++- src/HSchool.Client/src/style.css | 6 + src/HSchool.Client/src/ui/gameScreen.ts | 36 ++++- src/HSchool.Content/Defs.cs | 6 + src/HSchool.Content/PeopleDefValidator.cs | 39 +++++ src/HSchool.Content/PeopleDefs.cs | 45 +++++- src/HSchool.Protocol/Messages.cs | 14 +- src/HSchool.Protocol/PacketReader.cs | 8 ++ src/HSchool.Protocol/PacketWriter.cs | 7 + src/HSchool.Protocol/ProtocolCodec.cs | 16 ++- src/HSchool.Protocol/ProtocolConstants.cs | 2 +- src/HSchool.Server/Game/SchoolWorker.cs | 4 +- .../core/defs/climates/continental-cold.jsonc | 8 ++ .../defs/climates/temperate-continental.jsonc | 9 ++ .../mods/core/defs/needs/needs.jsonc | 9 ++ .../mods/core/defs/rooms/circulation.jsonc | 2 +- .../mods/core/defs/traits/traits.jsonc | 12 ++ .../mods/core/localizations/en.jsonc | 3 + .../mods/core/localizations/ru.jsonc | 3 + src/HSchool.Simulation/NeedDecay.cs | 2 +- src/HSchool.Simulation/OutdoorWeather.cs | 20 +++ src/HSchool.Simulation/PersonInsulation.cs | 73 ++++++++++ src/HSchool.Simulation/PlaceClimate.cs | 57 ++++++++ src/HSchool.Simulation/RosterSpawner.cs | 31 +++- src/HSchool.Simulation/School.cs | 47 +++++- src/HSchool.Simulation/WarmthDecay.cs | 81 +++++++++++ src/HSchool.Simulation/WeatherSampler.cs | 53 +++++++ .../HSchool.AppHost.Tests/GameSocketTests.cs | 16 +++ .../ClimatePresetTests.cs | 72 ++++++++++ .../HSchool.Content.Tests/CountryDefTests.cs | 4 +- tests/HSchool.Content.Tests/PeopleDefTests.cs | 8 +- .../ProtocolCodecTests.cs | 14 +- tests/HSchool.Simulation.Tests/WarmthTests.cs | 116 +++++++++++++++ .../HSchool.Simulation.Tests/WeatherTests.cs | 134 ++++++++++++++++++ 40 files changed, 1008 insertions(+), 39 deletions(-) create mode 100644 src/HSchool.Client/src/format/weather.test.ts create mode 100644 src/HSchool.Client/src/format/weather.ts create mode 100644 src/HSchool.Simulation/OutdoorWeather.cs create mode 100644 src/HSchool.Simulation/PersonInsulation.cs create mode 100644 src/HSchool.Simulation/PlaceClimate.cs create mode 100644 src/HSchool.Simulation/WarmthDecay.cs create mode 100644 src/HSchool.Simulation/WeatherSampler.cs create mode 100644 tests/HSchool.Content.Tests/ClimatePresetTests.cs create mode 100644 tests/HSchool.Simulation.Tests/WarmthTests.cs create mode 100644 tests/HSchool.Simulation.Tests/WeatherTests.cs diff --git a/docs/protocol.md b/docs/protocol.md index 578d960..990f0b6 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,4 +1,4 @@ -# Wire protocol v7 +# Wire protocol v8 The client talks to the server two ways: @@ -624,11 +624,13 @@ The first frame the client receives. | 1 | `i64` | client clock, echoed unchanged | | 9 | `u32` | server tick when the ping was handled | -### `0x83` Clock — 24 bytes +### `0x83` Clock — 27 bytes Sent every tick to every connection that has a school open, and only to those. `skipAllowed` is the server's verdict; the client must not recompute it. `skipTargetUnixMs` is 0 when skip is refused. +Temperature is outdoor tenths of a °C (`i16`, so −50 is −5.0 °C). Precipitation is `0` none, +`1` rain, `2` snow. The client must not derive weather from the month. | Offset | Type | Field | | --- | --- | --- | @@ -639,6 +641,8 @@ Sent every tick to every connection that has a school open, and only to those. | 14 | `u8` | speed index | | 15 | `u8` | `1` skip allowed, `0` refused | | 16 | `i64` | skip target, milliseconds since the Unix epoch, UTC; `0` if refused | +| 24 | `i16` | outdoor temperature, tenths of a °C | +| 26 | `u8` | precipitation: `0` none, `1` rain, `2` snow | ### `0x84` SchoolGone — 5 bytes @@ -724,7 +728,7 @@ Each person: the oldest, because a stale clock is worthless once a newer one exists. - The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out. -## Not in v7 yet +## Not in v8 yet Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server — the tree and the location panel are filtered on the client from the snapshot plus presence. diff --git a/src/HSchool.Client/src/format/weather.test.ts b/src/HSchool.Client/src/format/weather.test.ts new file mode 100644 index 0000000..9dcf3fe --- /dev/null +++ b/src/HSchool.Client/src/format/weather.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { Precipitation } from '../net/protocol.ts'; +import { getLocale, setLocale } from '../i18n/locale.ts'; +import { formatTemperatureC, formatWeather } from './weather.ts'; + +const initial = getLocale(); + +afterEach(() => setLocale(initial)); + +describe('formatWeather', () => { + it('draws tenths and snow from the frame, not from the month', () => { + setLocale('ru'); + expect(formatTemperatureC(-50)).toBe('\u22125'); + expect(formatWeather(-50, Precipitation.Snow)).toBe('\u22125 °C, снег'); + }); + + it('omits precipitation when the street is dry', () => { + setLocale('ru'); + expect(formatWeather(82, Precipitation.None)).toBe('8.2 °C'); + }); + + it('uses English rain labels', () => { + setLocale('en'); + expect(formatWeather(40, Precipitation.Rain)).toBe('4 °C, rain'); + }); +}); diff --git a/src/HSchool.Client/src/format/weather.ts b/src/HSchool.Client/src/format/weather.ts new file mode 100644 index 0000000..39884cd --- /dev/null +++ b/src/HSchool.Client/src/format/weather.ts @@ -0,0 +1,23 @@ +import { Precipitation } from '../net/protocol.ts'; +import { t } from '../i18n/strings.ts'; + +/** Formats tenths of a °C as the clock shows them. Does not invent weather from the date. */ +export function formatTemperatureC(tenths: number): string { + const value = tenths / 10; + const abs = Math.abs(value); + const body = Number.isInteger(value) ? String(abs) : abs.toFixed(1); + return `${value < 0 ? '\u2212' : ''}${body}`; +} + +export function formatWeather(tenths: number, precipitation: number): string { + const temp = formatTemperatureC(tenths); + if (precipitation === Precipitation.Snow) { + return t('weatherPrecip', { temp, precip: t('precipSnow') }); + } + + if (precipitation === Precipitation.Rain) { + return t('weatherPrecip', { temp, precip: t('precipRain') }); + } + + return t('weatherClear', { temp }); +} diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index ef7c00f..3b6a7e8 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -28,6 +28,7 @@ describe('t', () => { .toBe('Начальные классы (1–4) — 3 short'); expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' })) .toBe('Кабинет 204 (Математика · 5Б)'); + expect(t('weatherPrecip', { temp: '\u22125', precip: t('precipSnow') })).toBe('\u22125 °C, snow'); expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)'); expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' })) .toBe('Класс 101 (18 · Математика · 5А)'); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 86af8c6..999c478 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -191,6 +191,10 @@ const ru = { mapHeadcount: '{name} ({count})', mapHeadcountActivity: '{name} ({count} · {activity})', skipTo: 'Пропустить до {date}', + weatherClear: '{temp} °C', + weatherPrecip: '{temp} °C, {precip}', + precipRain: 'дождь', + precipSnow: 'снег', presenceAt: '{name}', presenceWalking: 'в пути ({name})', presenceAway: 'вне школы', @@ -408,6 +412,10 @@ const en: Messages = { mapHeadcount: '{name} ({count})', mapHeadcountActivity: '{name} ({count} · {activity})', skipTo: 'Skip to {date}', + weatherClear: '{temp} °C', + weatherPrecip: '{temp} °C, {precip}', + precipRain: 'rain', + precipSnow: 'snow', presenceAt: '{name}', presenceWalking: 'walking ({name})', presenceAway: 'off campus', diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index a5971c7..1ecdd56 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -98,7 +98,7 @@ describe('decodeServerMessage', () => { // 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0); const skipTargetMs = Date.UTC(2012, 3, 4, 6, 0, 0); - const buffer = new ArrayBuffer(24); + const buffer = new ArrayBuffer(27); const view = new DataView(buffer); view.setUint8(0, MessageType.ServerClock); view.setInt32(1, 7, true); @@ -107,6 +107,8 @@ describe('decodeServerMessage', () => { view.setUint8(14, 2); view.setUint8(15, 1); view.setBigInt64(16, BigInt(skipTargetMs), true); + view.setInt16(24, -50, true); + view.setUint8(26, 2); expect(decodeServerMessage(buffer)).toEqual({ type: 'clock', @@ -116,6 +118,8 @@ describe('decodeServerMessage', () => { speedIndex: 2, skipAllowed: true, skipTarget: new Date(skipTargetMs), + temperatureTenths: -50, + precipitation: 2, }); }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index 607ec6c..8dfbc35 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -5,7 +5,7 @@ * changed together and documented in `docs/protocol.md`. All numbers are little-endian. */ -export const PROTOCOL_VERSION = 7; +export const PROTOCOL_VERSION = 8; export const MessageType = { ClientHello: 0x01, @@ -50,6 +50,12 @@ export interface PongMessage { readonly serverTick: number; } +export const Precipitation = { + None: 0, + Rain: 1, + Snow: 2, +} as const; + export interface ClockMessage { readonly type: 'clock'; readonly schoolId: number; @@ -61,6 +67,10 @@ export interface ClockMessage { readonly skipAllowed: boolean; /** UTC instant the skip would land on; null when skip is refused. */ readonly skipTarget: Date | null; + /** Outdoor temperature in tenths of a °C. */ + readonly temperatureTenths: number; + /** `Precipitation.None` / `Rain` / `Snow`. */ + readonly precipitation: number; } export interface SchoolGoneMessage { @@ -248,7 +258,7 @@ function decodePong(view: DataView): PongMessage { } function decodeClock(view: DataView): ClockMessage { - ensure(view, 24); + ensure(view, 27); const skipTargetMs = Number(view.getBigInt64(16, true)); return { @@ -259,6 +269,8 @@ function decodeClock(view: DataView): ClockMessage { speedIndex: view.getUint8(14), skipAllowed: view.getUint8(15) !== 0, skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs), + temperatureTenths: view.getInt16(24, true), + precipitation: view.getUint8(26), }; } diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 828ac2a..17326e9 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -266,6 +266,12 @@ body { text-transform: capitalize; } +.clock__weather { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + .clock__controls { display: flex; align-items: center; diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 7f726ec..0e8c6a0 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -8,6 +8,7 @@ import { type PresenceNode, } from '../net/protocol.ts'; import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; +import { formatWeather } from '../format/weather.ts'; import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { fetchDirectory, type School } from '../net/api.ts'; @@ -38,6 +39,7 @@ export class GameScreen { private readonly time = el('p', { class: 'clock__time', text: '--:--' }); private readonly date = el('p', { class: 'clock__date' }); private readonly weekday = el('p', { class: 'clock__weekday' }); + private readonly weather = el('p', { class: 'clock__weather' }); private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' }); private readonly skipButton = el('button', { class: 'button button--small', type: 'button' }); private readonly speedButtons: HTMLButtonElement[]; @@ -86,6 +88,8 @@ export class GameScreen { private lastSpeedIndex = 0; private skipAllowed = false; private skipTarget: Date | null = null; + private lastTemperatureTenths: number | null = null; + private lastPrecipitation: number | null = null; private inspected: 'location' | 'person' = 'location'; constructor(options: GameScreenOptions) { @@ -112,7 +116,7 @@ export class GameScreen { 'div', { class: 'clockbar__now' }, this.time, - el('div', { class: 'clockbar__labels' }, this.date, this.weekday), + el('div', { class: 'clockbar__labels' }, this.date, this.weekday, this.weather), ), el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons, this.skipButton), ), @@ -184,7 +188,15 @@ export class GameScreen { this.paintSelection(); if (this.lastGameTime !== null) { - this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex, this.skipAllowed, this.skipTarget); + this.applyClock( + this.lastGameTime, + this.running, + this.lastSpeedIndex, + this.skipAllowed, + this.skipTarget, + this.lastTemperatureTenths, + this.lastPrecipitation, + ); } else { this.playPauseButton.title = t('resume'); } @@ -208,7 +220,7 @@ export class GameScreen { this.skipAllowed = false; this.skipTarget = null; this.rebuildTree(); - this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null); + this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null); this.people.show(school.id); this.people.setLocate((id) => this.placeOf(id)); this.management.setLocate((id) => this.placeOf(id)); @@ -251,7 +263,15 @@ export class GameScreen { } update(clock: ClockMessage): void { - this.applyClock(clock.gameTime, clock.running, clock.speedIndex, clock.skipAllowed, clock.skipTarget); + this.applyClock( + clock.gameTime, + clock.running, + clock.speedIndex, + clock.skipAllowed, + clock.skipTarget, + clock.temperatureTenths, + clock.precipitation, + ); } private rebuildTree(): void { @@ -358,16 +378,24 @@ export class GameScreen { speedIndex: number, skipAllowed: boolean, skipTarget: Date | null, + temperatureTenths: number | null, + precipitation: number | null, ): void { this.running = running; this.lastGameTime = gameTime; this.lastSpeedIndex = speedIndex; this.skipAllowed = skipAllowed; this.skipTarget = skipTarget; + this.lastTemperatureTenths = temperatureTenths; + this.lastPrecipitation = precipitation; this.time.textContent = formatGameTimeOfDay(gameTime); this.date.textContent = formatGameDate(gameTime); this.weekday.textContent = formatGameWeekday(gameTime); + this.weather.textContent = + temperatureTenths === null || precipitation === null + ? '' + : formatWeather(temperatureTenths, precipitation); this.playPauseButton.textContent = running ? '⏸' : '▶'; this.playPauseButton.title = running ? t('pause') : t('resume'); diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 9af2bb3..65e6db5 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -142,6 +142,12 @@ public sealed class RoomDef : Def /// Game minutes spent occupying this room when walking through it. public float TravelMinutes { get; init; } + + /// + /// Outdoor like the yard: porch, crossing between buildings. Indoor rooms stay warmer than + /// the street by the climate preset's wall offset. + /// + public bool Outdoor { get; init; } } public sealed class BuildingDef : Def; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 25c5d45..d1e20bc 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -30,6 +30,11 @@ internal static class PeopleDefValidator ValidateCountry(country, catalog); } + foreach (var preset in catalog.ClimatePresets.Values) + { + ValidateClimatePreset(preset); + } + foreach (var subject in catalog.Subjects.Values) { ValidateSubject(subject, catalog); @@ -576,6 +581,40 @@ internal static class PeopleDefValidator ValidateNameSet(country.Names ?? new NameSetDef(), catalog, country.DefName); } + private static void ValidateClimatePreset(ClimatePresetDef preset) + { + if (preset.Abstract) + { + return; + } + + if (preset.MonthlyNorms.Count != 12) + { + throw new ContentLoadException( + $"ClimatePresetDef '{preset.DefName}' monthlyNorms must list 12 months."); + } + + if (preset.DaySpread < 0 || preset.HourSpread < 0) + { + throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' spreads cannot be negative."); + } + + if (preset.PrecipitationChance < 0 || preset.PrecipitationChance > 1) + { + throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' precipitationChance must be 0–1."); + } + + if (preset.ComfortHalfWidthC < 0) + { + throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' comfortHalfWidthC cannot be negative."); + } + + if (preset.InsulationPerC < 0) + { + throw new ContentLoadException($"ClimatePresetDef '{preset.DefName}' insulationPerC cannot be negative."); + } + } + private static void ValidateNameSet(NameSetDef names, DefCatalog catalog, string countryDefName) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 999fa00..50e0a56 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -184,6 +184,12 @@ public sealed class TraitDef : Def /// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer. /// public int CommuteMinutes { get; init; } + + /// + /// Shifts the warmth comfort band, in °C. Heat-loving is positive (suffers cold earlier); + /// cold-loving is negative. + /// + public float ComfortTemperatureOffset { get; init; } } public sealed class StaffingDef : Def @@ -309,6 +315,12 @@ public sealed class NeedDef : Def /// restores overnight; hunger does not keep falling at home. /// public bool RestoredOffCampus { get; init; } + + /// + /// When true, campus drain is not per hour. Warmth uses it as the + /// drop per °C of mismatch against the place temperature. + /// + public bool Environmental { get; init; } } public sealed class CaseTable @@ -407,7 +419,7 @@ public sealed class NameSetDef /// /// What the player picks at create: nested names plus climate-preset ids. Weather numbers live -/// on and stay unused until phase 32. +/// on . /// public sealed class CountryDef : Def { @@ -417,7 +429,32 @@ public sealed class CountryDef : Def } /// -/// Outdoor climate a country may roll. Monthly temperatures land in phase 32; the id is enough -/// to persist which preset a school was born with. +/// Outdoor climate a country may roll. Monthly norms, day/hour spread and precipitation chance +/// are the numbers the school uses to sample the street; indoor offset is walls without a technician. /// -public sealed class ClimatePresetDef : Def; +public sealed class ClimatePresetDef : Def +{ + /// Mean outdoor °C for months 1–12. Concrete presets must list all twelve. + public IReadOnlyList MonthlyNorms { get; init; } = []; + + /// How far a day's mean may wander from the monthly norm, °C. + public float DaySpread { get; init; } + + /// How far the hour wanders from that day's mean, °C. Coldest around 03:00, warmest 15:00. + public float HourSpread { get; init; } + + /// Chance of precipitation this hour, 0–1. Below 0 °C the same roll is snow. + public float PrecipitationChance { get; init; } + + /// Added to indoor temperature vs the street. Walls hold heat; this is not comfort. + public float IndoorOffset { get; init; } = 8f; + + /// Centre of the clothing comfort band, °C, before trait offsets. + public float ComfortC { get; init; } = 21f; + + /// Half-width of the comfort band, °C. Inside it warmth barely drops. + public float ComfortHalfWidthC { get; init; } = 3f; + + /// How many °C of protection one insulation point is worth. + public float InsulationPerC { get; init; } = 1f; +} diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index cbc203b..1c92079 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -33,6 +33,8 @@ public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTi /// interpreted as UTC — the game calendar has no time zone. /// is the server's verdict; the client must not recompute it. /// is 0 when skip is refused. +/// is outdoor °C × 10. is +/// . /// public readonly record struct ServerClockMessage( int SchoolId, @@ -40,7 +42,17 @@ public readonly record struct ServerClockMessage( bool Running, byte SpeedIndex, bool SkipAllowed = false, - long SkipTargetUnixMs = 0); + long SkipTargetUnixMs = 0, + short TemperatureTenths = 0, + byte Precipitation = 0); + +/// Outdoor precipitation on the clock frame. Below 0 °C the same weather roll is snow. +public static class PrecipitationKind +{ + public const byte None = 0; + public const byte Rain = 1; + public const byte Snow = 2; +} /// The open school no longer exists (deleted from another tab); the client returns to the menu. public readonly record struct ServerSchoolGoneMessage(int SchoolId); diff --git a/src/HSchool.Protocol/PacketReader.cs b/src/HSchool.Protocol/PacketReader.cs index 63b76db..c02fc5a 100644 --- a/src/HSchool.Protocol/PacketReader.cs +++ b/src/HSchool.Protocol/PacketReader.cs @@ -49,6 +49,14 @@ public ref struct PacketReader(ReadOnlySpan buffer) return value; } + public short ReadInt16() + { + EnsureAvailable(sizeof(short)); + var value = BinaryPrimitives.ReadInt16LittleEndian(_buffer[_position..]); + _position += sizeof(short); + return value; + } + public int ReadInt32() { EnsureAvailable(sizeof(int)); diff --git a/src/HSchool.Protocol/PacketWriter.cs b/src/HSchool.Protocol/PacketWriter.cs index a8395b2..cf58859 100644 --- a/src/HSchool.Protocol/PacketWriter.cs +++ b/src/HSchool.Protocol/PacketWriter.cs @@ -52,6 +52,13 @@ public ref struct PacketWriter(Span buffer) _position += byteCount; } + public void WriteInt16(short value) + { + EnsureRoom(sizeof(short)); + BinaryPrimitives.WriteInt16LittleEndian(_buffer[_position..], value); + _position += sizeof(short); + } + public void WriteInt32(int value) { EnsureRoom(sizeof(int)); diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index fefd8a9..34024fb 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -13,7 +13,7 @@ public static class ProtocolCodec /// Largest fixed-size frame this codec produces. Variable map snapshots and /// presence frames use instead. /// - public const int MaxFrameSize = 24; + public const int MaxFrameSize = 27; public static int WriteHello(Span destination, in ClientHelloMessage message) { @@ -99,6 +99,8 @@ public static class ProtocolCodec writer.WriteByte(message.SpeedIndex); writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0); writer.WriteInt64(message.SkipTargetUnixMs); + writer.WriteInt16(message.TemperatureTenths); + writer.WriteByte(message.Precipitation); return writer.Position; } @@ -317,7 +319,17 @@ public static class ProtocolCodec var speedIndex = reader.ReadByte(); var skipAllowed = reader.ReadByte() != 0; var skipTarget = reader.ReadInt64(); - return new ServerClockMessage(schoolId, gameTime, running, speedIndex, skipAllowed, skipTarget); + var temperatureTenths = reader.ReadInt16(); + var precipitation = reader.ReadByte(); + return new ServerClockMessage( + schoolId, + gameTime, + running, + speedIndex, + skipAllowed, + skipTarget, + temperatureTenths, + precipitation); } public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan source) diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index a2f053f..6abec88 100644 --- a/src/HSchool.Protocol/ProtocolConstants.cs +++ b/src/HSchool.Protocol/ProtocolConstants.cs @@ -4,7 +4,7 @@ namespace HSchool.Protocol; public static class ProtocolConstants { /// Bumped on every breaking change to the binary layout. - public const byte Version = 7; + public const byte Version = 8; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index c7ff754..2fb2ebf 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -1063,7 +1063,9 @@ internal sealed class SchoolWorker school.Clock.IsRunning, (byte)school.Clock.SpeedIndex, skip.Allowed, - skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0)); + skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0, + school.Weather.Tenths, + (byte)school.Weather.Precipitation)); client.TrySend(frame.AsMemory(0, length)); } diff --git a/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc b/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc index 55ca41c..7626f14 100644 --- a/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc +++ b/src/HSchool.Server/mods/core/defs/climates/continental-cold.jsonc @@ -1,3 +1,11 @@ { "defName": "ContinentalCold", + "monthlyNorms": [-16.0, -14.0, -7.0, 2.0, 10.0, 16.0, 18.0, 15.0, 9.0, 1.0, -8.0, -14.0], + "daySpread": 6, + "hourSpread": 5, + "precipitationChance": 0.28, + "indoorOffset": 8, + "comfortC": 21, + "comfortHalfWidthC": 3, + "insulationPerC": 1, } diff --git a/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc b/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc index f65cc2c..4e543b2 100644 --- a/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc +++ b/src/HSchool.Server/mods/core/defs/climates/temperate-continental.jsonc @@ -1,3 +1,12 @@ { "defName": "TemperateContinental", + // Moscow-like monthly means. January mornings sit below zero so the clock can show snow. + "monthlyNorms": [-9.3, -8.0, -2.2, 6.7, 13.2, 17.0, 19.2, 17.0, 11.3, 5.3, -0.5, -6.2], + "daySpread": 5, + "hourSpread": 4, + "precipitationChance": 0.32, + "indoorOffset": 8, + "comfortC": 21, + "comfortHalfWidthC": 3, + "insulationPerC": 1, } diff --git a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc index 4a35cd6..cef13bf 100644 --- a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc +++ b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc @@ -3,4 +3,13 @@ { "defName": "Hunger", "initial": 1, "decayPerHour": 0.2, "min": 0, "max": 1, "restoredOffCampus": true }, { "defName": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 }, { "defName": "Social", "initial": 1, "decayPerHour": 0.08, "min": 0, "max": 1 }, + { + "defName": "Warmth", + "initial": 1, + "decayPerHour": 0.04, + "min": 0, + "max": 1, + "restoredOffCampus": true, + "environmental": true, + }, ] diff --git a/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc b/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc index baf3faa..7dbde4a 100644 --- a/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc +++ b/src/HSchool.Server/mods/core/defs/rooms/circulation.jsonc @@ -1,5 +1,5 @@ [ // Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose. - { "defName": "EntranceHall", "travelMinutes": 1 }, + { "defName": "EntranceHall", "travelMinutes": 1, "outdoor": true }, { "defName": "Stairwell", "travelMinutes": 1.5 }, ] diff --git a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc index f737af4..36b1a9a 100644 --- a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc +++ b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc @@ -80,4 +80,16 @@ { "skill": "Chemistry", "offset": 4 }, ], }, + { + "defName": "HeatLoving", + "weight": 5, + "incompatible": ["ColdLoving"], + "comfortTemperatureOffset": 4, + }, + { + "defName": "ColdLoving", + "weight": 5, + "incompatible": ["HeatLoving"], + "comfortTemperatureOffset": -4, + }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 811d27c..99e00b2 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -144,6 +144,9 @@ "Hunger": "Hunger", "Toilet": "Toilet", "Social": "Social", + "Warmth": "Warmth", + "HeatLoving": "Heat-loving", + "ColdLoving": "Cold-loving", "Russia": "Russia", "TemperateContinental": "Temperate continental", "ContinentalCold": "Cold continental", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 7b8b625..c882198 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -144,6 +144,9 @@ "Hunger": "Голод", "Toilet": "Туалет", "Social": "Общение", + "Warmth": "Тепло", + "HeatLoving": "Теплолюбивый", + "ColdLoving": "Холодолюбивый", "Russia": "Россия", "TemperateContinental": "Умеренно-континентальный", "ContinentalCold": "Континентальный холодный", diff --git a/src/HSchool.Simulation/NeedDecay.cs b/src/HSchool.Simulation/NeedDecay.cs index 0936f27..bccef5d 100644 --- a/src/HSchool.Simulation/NeedDecay.cs +++ b/src/HSchool.Simulation/NeedDecay.cs @@ -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; } diff --git a/src/HSchool.Simulation/OutdoorWeather.cs b/src/HSchool.Simulation/OutdoorWeather.cs new file mode 100644 index 0000000..9ab5215 --- /dev/null +++ b/src/HSchool.Simulation/OutdoorWeather.cs @@ -0,0 +1,20 @@ +namespace HSchool.Simulation; + +/// Street precipitation. Below 0 °C the same roll is snow, above it is rain. +public enum Precipitation : byte +{ + None = 0, + Rain = 1, + Snow = 2, +} + +/// Cached outdoor state shown on the clock and used to drain warmth. +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); +} diff --git a/src/HSchool.Simulation/PersonInsulation.cs b/src/HSchool.Simulation/PersonInsulation.cs new file mode 100644 index 0000000..33bc866 --- /dev/null +++ b/src/HSchool.Simulation/PersonInsulation.cs @@ -0,0 +1,73 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// +/// Worn insulation in °C-equivalent points, summed from apparel currently on the body. +/// Tests that need a bare frost set on the entity explicitly. +/// +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 WornDefs(Person person) + { + var worn = new List(); + 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; + } +} diff --git a/src/HSchool.Simulation/PlaceClimate.cs b/src/HSchool.Simulation/PlaceClimate.cs new file mode 100644 index 0000000..d2c7ee1 --- /dev/null +++ b/src/HSchool.Simulation/PlaceClimate.cs @@ -0,0 +1,57 @@ +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// Street vs indoors. The yard is always outdoor; rooms opt in with +/// 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). +/// +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; + } +} diff --git a/src/HSchool.Simulation/RosterSpawner.cs b/src/HSchool.Simulation/RosterSpawner.cs index 0febeb6..1dc32d1 100644 --- a/src/HSchool.Simulation/RosterSpawner.cs +++ b/src/HSchool.Simulation/RosterSpawner.cs @@ -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(); private static readonly QueryDescription Classes = new QueryDescription().WithAll(); - 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(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 } /// Drops the previous composition and spawns . Called on yearly intake. - 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 NeedsOf(Person person, DefCatalog? catalog) + { + var values = new Dictionary(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) diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 1bd3e16..161e622 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -78,9 +78,12 @@ public sealed class School : IDisposable /// Country used to generate this school's people. Needed again on 1 September. public string? CountryId { get; private set; } - /// Climate preset rolled at birth. Phase 32 reads it; it cannot change on a live school. + /// Climate preset rolled at birth. Weather reads it; it cannot change on a live school. public string? ClimatePresetId { get; private set; } + /// Street temperature and precipitation last committed for the clock and warmth. + public OutdoorWeather Weather { get; private set; } = OutdoorWeather.None; + /// Skill everyone generated for this school speaks natively. 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; } + /// + /// 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. + /// + 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; } diff --git a/src/HSchool.Simulation/WarmthDecay.cs b/src/HSchool.Simulation/WarmthDecay.cs new file mode 100644 index 0000000..ba83837 --- /dev/null +++ b/src/HSchool.Simulation/WarmthDecay.cs @@ -0,0 +1,81 @@ +using Arch.Core; +using HSchool.Ai; +using HSchool.Content; + +namespace HSchool.Simulation; + +/// +/// Drains Warmth 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. +/// +public static class WarmthDecay +{ + private static readonly QueryDescription People = + new QueryDescription().WithAll(); + + 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; + } +} diff --git a/src/HSchool.Simulation/WeatherSampler.cs b/src/HSchool.Simulation/WeatherSampler.cs new file mode 100644 index 0000000..4756684 --- /dev/null +++ b/src/HSchool.Simulation/WeatherSampler.cs @@ -0,0 +1,53 @@ +using HSchool.Content; +using HSchool.People; + +namespace HSchool.Simulation; + +/// +/// 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. +/// +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; +} diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index cb498b6..0965a05 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -428,6 +428,22 @@ public class GameSocketTests(AppHostFixture fixture) Assert.Equal(ClientTime, pong.ClientTimeMs); } + [Fact] + public async Task OldProtocolVersion_IsRejected() + { + using var socket = await ConnectRawAsync(); + + await SendAsync(socket, buffer => ProtocolCodec.WriteHello( + buffer, + new ClientHelloMessage((byte)(ProtocolConstants.Version - 1), ProtocolConstants.LocaleRussian))); + + var buffer = new byte[ProtocolConstants.MaxMessageSize]; + var result = await socket.ReceiveAsync(buffer, TestContext.Current.CancellationToken); + + Assert.Equal(WebSocketMessageType.Close, result.MessageType); + Assert.Equal(WebSocketCloseStatus.ProtocolError, socket.CloseStatus); + } + [Fact] public async Task VersionMismatch_IsRejected() { diff --git a/tests/HSchool.Content.Tests/ClimatePresetTests.cs b/tests/HSchool.Content.Tests/ClimatePresetTests.cs new file mode 100644 index 0000000..69d3ecf --- /dev/null +++ b/tests/HSchool.Content.Tests/ClimatePresetTests.cs @@ -0,0 +1,72 @@ +namespace HSchool.Content.Tests; + +public class ClimatePresetTests +{ + private readonly CatalogLoader _loader = new(); + + [Fact] + public void VanillaPresets_HaveWorkingWeatherNumbers() + { + var catalog = Vanilla(); + var temperate = catalog.ClimatePresets["TemperateContinental"]; + Assert.Equal(12, temperate.MonthlyNorms.Count); + Assert.True(temperate.MonthlyNorms[0] < temperate.MonthlyNorms[6]); + Assert.InRange(temperate.PrecipitationChance, 0f, 1f); + Assert.True(temperate.IndoorOffset > 0); + + var cold = catalog.ClimatePresets["ContinentalCold"]; + Assert.True(cold.MonthlyNorms[0] < temperate.MonthlyNorms[0]); + } + + [Fact] + public void HeatAndColdLoving_AreMutuallyIncompatible_AndOffsetComfort() + { + var catalog = Vanilla(); + var heat = catalog.Traits["HeatLoving"]; + var cold = catalog.Traits["ColdLoving"]; + Assert.Contains("ColdLoving", heat.Incompatible); + Assert.Contains("HeatLoving", cold.Incompatible); + Assert.Contains("ColdLoving", catalog.TraitIncompatibilities("HeatLoving")); + Assert.Contains("HeatLoving", catalog.TraitIncompatibilities("ColdLoving")); + Assert.True(heat.ComfortTemperatureOffset > 0); + Assert.True(cold.ComfortTemperatureOffset < 0); + } + + [Fact] + public void WarmthNeed_RestoresOffCampus_AndIsEnvironmental() + { + var warmth = Vanilla().Needs["Warmth"]; + Assert.True(warmth.RestoredOffCampus); + Assert.True(warmth.Environmental); + Assert.True(warmth.DecayPerHour > 0); + } + + [Fact] + public void Porch_IsOutdoor() + { + Assert.True(Vanilla().Rooms["EntranceHall"].Outdoor); + Assert.False(Vanilla().Rooms["Classroom"].Outdoor); + } + + [Fact] + public void ConcretePresetWithoutMonthlyNorms_FailsTheCatalog() + { + var ex = Assert.Throws(() => _loader.Load( + [CatalogLoader.CorePackId], + [ + PackDocuments.Def( + CatalogLoader.CorePackId, + "climates", + "empty", + """{ "defName": "EmptyClimate" }"""), + ])); + + Assert.Contains("monthlyNorms", ex.Message, StringComparison.Ordinal); + } + + private DefCatalog Vanilla() + { + var root = Path.Combine(AppContext.BaseDirectory, "vanilla"); + return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); + } +} diff --git a/tests/HSchool.Content.Tests/CountryDefTests.cs b/tests/HSchool.Content.Tests/CountryDefTests.cs index 67ba040..c2296d1 100644 --- a/tests/HSchool.Content.Tests/CountryDefTests.cs +++ b/tests/HSchool.Content.Tests/CountryDefTests.cs @@ -36,7 +36,7 @@ public class CountryDefTests CatalogLoader.CorePackId, "climates", "temperate", - """{ "defName": "TemperateContinental" }"""), + """{ "defName": "TemperateContinental", "monthlyNorms": [0,0,0,0,0,0,0,0,0,0,0,0] }"""), PackDocuments.Def( CatalogLoader.CorePackId, "countries", @@ -58,7 +58,7 @@ public class CountryDefTests CatalogLoader.CorePackId, "climates", "temperate", - """{ "defName": "TemperateContinental" }"""), + """{ "defName": "TemperateContinental", "monthlyNorms": [0,0,0,0,0,0,0,0,0,0,0,0] }"""), PackDocuments.Def( CatalogLoader.CorePackId, "countries", diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 4a57c2e..a96a724 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -11,13 +11,15 @@ public class PeopleDefTests var catalog = _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); Assert.True(catalog.Skills.Count >= 10); - Assert.Equal(10, catalog.Traits.Count); - Assert.Equal(4, catalog.Needs.Count); + Assert.Equal(12, catalog.Traits.Count); + Assert.Equal(5, catalog.Needs.Count); Assert.True(catalog.BodyAttributes.ContainsKey("Height")); Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind); Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind); Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0)); Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); + Assert.True(catalog.Needs["Warmth"].RestoredOffCampus); + Assert.True(catalog.Needs["Warmth"].Environmental); Assert.Equal(0.2f, catalog.Needs["Hunger"].DecayPerHour); Assert.True(catalog.Skills["Communication"].Always); Assert.True(catalog.Skills["Agility"].Always); @@ -223,7 +225,7 @@ public class PeopleDefTests CatalogLoader.CorePackId, "climates", "temperate", - """{ "defName": "TemperateContinental" }"""), + """{ "defName": "TemperateContinental", "monthlyNorms": [0,0,0,0,0,0,0,0,0,0,0,0] }"""), PackDocuments.Def( CatalogLoader.CorePackId, "countries", diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index 4fd326a..0f032de 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -121,7 +121,7 @@ public class ProtocolCodecTests } [Fact] - public void Clock_RoundTripsAndIsTwentyFourBytes() + public void Clock_RoundTripsAndIsTwentySevenBytes() { var message = new ServerClockMessage( 7, @@ -129,12 +129,14 @@ public class ProtocolCodecTests Running: true, SpeedIndex: 2, SkipAllowed: true, - SkipTargetUnixMs: 1_333_516_800_000); + SkipTargetUnixMs: 1_333_516_800_000, + TemperatureTenths: -50, + Precipitation: 2); Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; var length = ProtocolCodec.WriteClock(buffer, message); - Assert.Equal(24, length); + Assert.Equal(27, length); Assert.Equal((byte)MessageType.ServerClock, buffer[0]); Assert.Equal(7, BitConverter.ToInt32(buffer[1..5])); Assert.Equal(1_333_432_800_000, BitConverter.ToInt64(buffer[5..13])); @@ -142,6 +144,8 @@ public class ProtocolCodecTests Assert.Equal(2, buffer[14]); Assert.Equal(1, buffer[15]); Assert.Equal(1_333_516_800_000, BitConverter.ToInt64(buffer[16..24])); + Assert.Equal(-50, BitConverter.ToInt16(buffer[24..26])); + Assert.Equal(2, buffer[26]); Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length])); } @@ -154,9 +158,11 @@ public class ProtocolCodecTests var length = ProtocolCodec.WriteClock(buffer, message); var read = ProtocolCodec.ReadClock(buffer[..length]); - Assert.Equal(24, length); + Assert.Equal(27, length); Assert.False(read.SkipAllowed); Assert.Equal(0, read.SkipTargetUnixMs); + Assert.Equal(0, read.TemperatureTenths); + Assert.Equal(PrecipitationKind.None, read.Precipitation); } [Fact] diff --git a/tests/HSchool.Simulation.Tests/WarmthTests.cs b/tests/HSchool.Simulation.Tests/WarmthTests.cs new file mode 100644 index 0000000..788a102 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/WarmthTests.cs @@ -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(); + 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(); + 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(); + 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); + } +} diff --git a/tests/HSchool.Simulation.Tests/WeatherTests.cs b/tests/HSchool.Simulation.Tests/WeatherTests.cs new file mode 100644 index 0000000..e2a57f7 --- /dev/null +++ b/tests/HSchool.Simulation.Tests/WeatherTests.cs @@ -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(); + 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); + } +}