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
+7 -3
View File
@@ -1,4 +1,4 @@
# Wire protocol v7 # Wire protocol v8
The client talks to the server two ways: The client talks to the server two ways:
@@ -624,11 +624,13 @@ The first frame the client receives.
| 1 | `i64` | client clock, echoed unchanged | | 1 | `i64` | client clock, echoed unchanged |
| 9 | `u32` | server tick when the ping was handled | | 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. 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. `skipAllowed` is the server's verdict; the client must not recompute it.
`skipTargetUnixMs` is 0 when skip is refused. `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 | | 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 | | 14 | `u8` | speed index |
| 15 | `u8` | `1` skip allowed, `0` refused | | 15 | `u8` | `1` skip allowed, `0` refused |
| 16 | `i64` | skip target, milliseconds since the Unix epoch, UTC; `0` if 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 ### `0x84` SchoolGone — 5 bytes
@@ -724,7 +728,7 @@ Each person:
the oldest, because a stale clock is worthless once a newer one exists. 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. - 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 — 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. the tree and the location panel are filtered on the client from the snapshot plus presence.
@@ -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');
});
});
+23
View File
@@ -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 });
}
@@ -28,6 +28,7 @@ describe('t', () => {
.toBe('Начальные классы (1–4) — 3 short'); .toBe('Начальные классы (1–4) — 3 short');
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' })) expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
.toBe('Кабинет 204 (Математика · 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('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' })) expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
.toBe('Класс 101 (18 · Математика · 5А)'); .toBe('Класс 101 (18 · Математика · 5А)');
+8
View File
@@ -191,6 +191,10 @@ const ru = {
mapHeadcount: '{name} ({count})', mapHeadcount: '{name} ({count})',
mapHeadcountActivity: '{name} ({count} · {activity})', mapHeadcountActivity: '{name} ({count} · {activity})',
skipTo: 'Пропустить до {date}', skipTo: 'Пропустить до {date}',
weatherClear: '{temp} °C',
weatherPrecip: '{temp} °C, {precip}',
precipRain: 'дождь',
precipSnow: 'снег',
presenceAt: '{name}', presenceAt: '{name}',
presenceWalking: 'в пути ({name})', presenceWalking: 'в пути ({name})',
presenceAway: 'вне школы', presenceAway: 'вне школы',
@@ -408,6 +412,10 @@ const en: Messages = {
mapHeadcount: '{name} ({count})', mapHeadcount: '{name} ({count})',
mapHeadcountActivity: '{name} ({count} · {activity})', mapHeadcountActivity: '{name} ({count} · {activity})',
skipTo: 'Skip to {date}', skipTo: 'Skip to {date}',
weatherClear: '{temp} °C',
weatherPrecip: '{temp} °C, {precip}',
precipRain: 'rain',
precipSnow: 'snow',
presenceAt: '{name}', presenceAt: '{name}',
presenceWalking: 'walking ({name})', presenceWalking: 'walking ({name})',
presenceAway: 'off campus', presenceAway: 'off campus',
+5 -1
View File
@@ -98,7 +98,7 @@ describe('decodeServerMessage', () => {
// 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z // 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z
const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0); const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
const skipTargetMs = Date.UTC(2012, 3, 4, 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); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerClock); view.setUint8(0, MessageType.ServerClock);
view.setInt32(1, 7, true); view.setInt32(1, 7, true);
@@ -107,6 +107,8 @@ describe('decodeServerMessage', () => {
view.setUint8(14, 2); view.setUint8(14, 2);
view.setUint8(15, 1); view.setUint8(15, 1);
view.setBigInt64(16, BigInt(skipTargetMs), true); view.setBigInt64(16, BigInt(skipTargetMs), true);
view.setInt16(24, -50, true);
view.setUint8(26, 2);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'clock', type: 'clock',
@@ -116,6 +118,8 @@ describe('decodeServerMessage', () => {
speedIndex: 2, speedIndex: 2,
skipAllowed: true, skipAllowed: true,
skipTarget: new Date(skipTargetMs), skipTarget: new Date(skipTargetMs),
temperatureTenths: -50,
precipitation: 2,
}); });
}); });
+14 -2
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian. * 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 = { export const MessageType = {
ClientHello: 0x01, ClientHello: 0x01,
@@ -50,6 +50,12 @@ export interface PongMessage {
readonly serverTick: number; readonly serverTick: number;
} }
export const Precipitation = {
None: 0,
Rain: 1,
Snow: 2,
} as const;
export interface ClockMessage { export interface ClockMessage {
readonly type: 'clock'; readonly type: 'clock';
readonly schoolId: number; readonly schoolId: number;
@@ -61,6 +67,10 @@ export interface ClockMessage {
readonly skipAllowed: boolean; readonly skipAllowed: boolean;
/** UTC instant the skip would land on; null when skip is refused. */ /** UTC instant the skip would land on; null when skip is refused. */
readonly skipTarget: Date | null; readonly skipTarget: Date | null;
/** Outdoor temperature in tenths of a °C. */
readonly temperatureTenths: number;
/** `Precipitation.None` / `Rain` / `Snow`. */
readonly precipitation: number;
} }
export interface SchoolGoneMessage { export interface SchoolGoneMessage {
@@ -248,7 +258,7 @@ function decodePong(view: DataView): PongMessage {
} }
function decodeClock(view: DataView): ClockMessage { function decodeClock(view: DataView): ClockMessage {
ensure(view, 24); ensure(view, 27);
const skipTargetMs = Number(view.getBigInt64(16, true)); const skipTargetMs = Number(view.getBigInt64(16, true));
return { return {
@@ -259,6 +269,8 @@ function decodeClock(view: DataView): ClockMessage {
speedIndex: view.getUint8(14), speedIndex: view.getUint8(14),
skipAllowed: view.getUint8(15) !== 0, skipAllowed: view.getUint8(15) !== 0,
skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs), skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs),
temperatureTenths: view.getInt16(24, true),
precipitation: view.getUint8(26),
}; };
} }
+6
View File
@@ -266,6 +266,12 @@ body {
text-transform: capitalize; text-transform: capitalize;
} }
.clock__weather {
margin: 0;
color: var(--text-muted);
font-size: 13px;
}
.clock__controls { .clock__controls {
display: flex; display: flex;
align-items: center; align-items: center;
+32 -4
View File
@@ -8,6 +8,7 @@ import {
type PresenceNode, type PresenceNode,
} from '../net/protocol.ts'; } from '../net/protocol.ts';
import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
import { formatWeather } from '../format/weather.ts';
import { getLocale } from '../i18n/locale.ts'; import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts'; import { t } from '../i18n/strings.ts';
import { fetchDirectory, type School } from '../net/api.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 time = el('p', { class: 'clock__time', text: '--:--' });
private readonly date = el('p', { class: 'clock__date' }); private readonly date = el('p', { class: 'clock__date' });
private readonly weekday = el('p', { class: 'clock__weekday' }); 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 playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
private readonly skipButton = el('button', { class: 'button button--small', type: 'button' }); private readonly skipButton = el('button', { class: 'button button--small', type: 'button' });
private readonly speedButtons: HTMLButtonElement[]; private readonly speedButtons: HTMLButtonElement[];
@@ -86,6 +88,8 @@ export class GameScreen {
private lastSpeedIndex = 0; private lastSpeedIndex = 0;
private skipAllowed = false; private skipAllowed = false;
private skipTarget: Date | null = null; private skipTarget: Date | null = null;
private lastTemperatureTenths: number | null = null;
private lastPrecipitation: number | null = null;
private inspected: 'location' | 'person' = 'location'; private inspected: 'location' | 'person' = 'location';
constructor(options: GameScreenOptions) { constructor(options: GameScreenOptions) {
@@ -112,7 +116,7 @@ export class GameScreen {
'div', 'div',
{ class: 'clockbar__now' }, { class: 'clockbar__now' },
this.time, 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), el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons, this.skipButton),
), ),
@@ -184,7 +188,15 @@ export class GameScreen {
this.paintSelection(); this.paintSelection();
if (this.lastGameTime !== null) { 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 { } else {
this.playPauseButton.title = t('resume'); this.playPauseButton.title = t('resume');
} }
@@ -208,7 +220,7 @@ export class GameScreen {
this.skipAllowed = false; this.skipAllowed = false;
this.skipTarget = null; this.skipTarget = null;
this.rebuildTree(); 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.show(school.id);
this.people.setLocate((id) => this.placeOf(id)); this.people.setLocate((id) => this.placeOf(id));
this.management.setLocate((id) => this.placeOf(id)); this.management.setLocate((id) => this.placeOf(id));
@@ -251,7 +263,15 @@ export class GameScreen {
} }
update(clock: ClockMessage): void { 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 { private rebuildTree(): void {
@@ -358,16 +378,24 @@ export class GameScreen {
speedIndex: number, speedIndex: number,
skipAllowed: boolean, skipAllowed: boolean,
skipTarget: Date | null, skipTarget: Date | null,
temperatureTenths: number | null,
precipitation: number | null,
): void { ): void {
this.running = running; this.running = running;
this.lastGameTime = gameTime; this.lastGameTime = gameTime;
this.lastSpeedIndex = speedIndex; this.lastSpeedIndex = speedIndex;
this.skipAllowed = skipAllowed; this.skipAllowed = skipAllowed;
this.skipTarget = skipTarget; this.skipTarget = skipTarget;
this.lastTemperatureTenths = temperatureTenths;
this.lastPrecipitation = precipitation;
this.time.textContent = formatGameTimeOfDay(gameTime); this.time.textContent = formatGameTimeOfDay(gameTime);
this.date.textContent = formatGameDate(gameTime); this.date.textContent = formatGameDate(gameTime);
this.weekday.textContent = formatGameWeekday(gameTime); this.weekday.textContent = formatGameWeekday(gameTime);
this.weather.textContent =
temperatureTenths === null || precipitation === null
? ''
: formatWeather(temperatureTenths, precipitation);
this.playPauseButton.textContent = running ? '⏸' : '▶'; this.playPauseButton.textContent = running ? '⏸' : '▶';
this.playPauseButton.title = running ? t('pause') : t('resume'); this.playPauseButton.title = running ? t('pause') : t('resume');
+6
View File
@@ -142,6 +142,12 @@ public sealed class RoomDef : Def
/// <summary>Game minutes spent occupying this room when walking through it.</summary> /// <summary>Game minutes spent occupying this room when walking through it.</summary>
public float TravelMinutes { get; init; } public float TravelMinutes { get; init; }
/// <summary>
/// Outdoor like the yard: porch, crossing between buildings. Indoor rooms stay warmer than
/// the street by the climate preset's wall offset.
/// </summary>
public bool Outdoor { get; init; }
} }
public sealed class BuildingDef : Def; public sealed class BuildingDef : Def;
+39
View File
@@ -30,6 +30,11 @@ internal static class PeopleDefValidator
ValidateCountry(country, catalog); ValidateCountry(country, catalog);
} }
foreach (var preset in catalog.ClimatePresets.Values)
{
ValidateClimatePreset(preset);
}
foreach (var subject in catalog.Subjects.Values) foreach (var subject in catalog.Subjects.Values)
{ {
ValidateSubject(subject, catalog); ValidateSubject(subject, catalog);
@@ -576,6 +581,40 @@ internal static class PeopleDefValidator
ValidateNameSet(country.Names ?? new NameSetDef(), catalog, country.DefName); 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 01.");
}
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) private static void ValidateNameSet(NameSetDef names, DefCatalog catalog, string countryDefName)
{ {
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
+41 -4
View File
@@ -184,6 +184,12 @@ public sealed class TraitDef : Def
/// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer. /// Extra minutes of commute slack. Positive arrives earlier; negative cuts it closer.
/// </summary> /// </summary>
public int CommuteMinutes { get; init; } public int CommuteMinutes { get; init; }
/// <summary>
/// Shifts the warmth comfort band, in °C. Heat-loving is positive (suffers cold earlier);
/// cold-loving is negative.
/// </summary>
public float ComfortTemperatureOffset { get; init; }
} }
public sealed class StaffingDef : Def public sealed class StaffingDef : Def
@@ -309,6 +315,12 @@ public sealed class NeedDef : Def
/// restores overnight; hunger does not keep falling at home. /// restores overnight; hunger does not keep falling at home.
/// </summary> /// </summary>
public bool RestoredOffCampus { get; init; } public bool RestoredOffCampus { get; init; }
/// <summary>
/// When true, campus drain is not <see cref="DecayPerHour"/> per hour. Warmth uses it as the
/// drop per °C of mismatch against the place temperature.
/// </summary>
public bool Environmental { get; init; }
} }
public sealed class CaseTable public sealed class CaseTable
@@ -407,7 +419,7 @@ public sealed class NameSetDef
/// <summary> /// <summary>
/// What the player picks at create: nested names plus climate-preset ids. Weather numbers live /// What the player picks at create: nested names plus climate-preset ids. Weather numbers live
/// on <see cref="ClimatePresetDef"/> and stay unused until phase 32. /// on <see cref="ClimatePresetDef"/>.
/// </summary> /// </summary>
public sealed class CountryDef : Def public sealed class CountryDef : Def
{ {
@@ -417,7 +429,32 @@ public sealed class CountryDef : Def
} }
/// <summary> /// <summary>
/// Outdoor climate a country may roll. Monthly temperatures land in phase 32; the id is enough /// Outdoor climate a country may roll. Monthly norms, day/hour spread and precipitation chance
/// to persist which preset a school was born with. /// are the numbers the school uses to sample the street; indoor offset is walls without a technician.
/// </summary> /// </summary>
public sealed class ClimatePresetDef : Def; public sealed class ClimatePresetDef : Def
{
/// <summary>Mean outdoor °C for months 112. Concrete presets must list all twelve.</summary>
public IReadOnlyList<float> MonthlyNorms { get; init; } = [];
/// <summary>How far a day's mean may wander from the monthly norm, °C.</summary>
public float DaySpread { get; init; }
/// <summary>How far the hour wanders from that day's mean, °C. Coldest around 03:00, warmest 15:00.</summary>
public float HourSpread { get; init; }
/// <summary>Chance of precipitation this hour, 01. Below 0 °C the same roll is snow.</summary>
public float PrecipitationChance { get; init; }
/// <summary>Added to indoor temperature vs the street. Walls hold heat; this is not comfort.</summary>
public float IndoorOffset { get; init; } = 8f;
/// <summary>Centre of the clothing comfort band, °C, before trait offsets.</summary>
public float ComfortC { get; init; } = 21f;
/// <summary>Half-width of the comfort band, °C. Inside it warmth barely drops.</summary>
public float ComfortHalfWidthC { get; init; } = 3f;
/// <summary>How many °C of protection one insulation point is worth.</summary>
public float InsulationPerC { get; init; } = 1f;
}
+13 -1
View File
@@ -33,6 +33,8 @@ public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTi
/// interpreted as UTC — the game calendar has no time zone. /// interpreted as UTC — the game calendar has no time zone.
/// <paramref name="SkipAllowed"/> is the server's verdict; the client must not recompute it. /// <paramref name="SkipAllowed"/> is the server's verdict; the client must not recompute it.
/// <paramref name="SkipTargetUnixMs"/> is 0 when skip is refused. /// <paramref name="SkipTargetUnixMs"/> is 0 when skip is refused.
/// <paramref name="TemperatureTenths"/> is outdoor °C × 10. <paramref name="Precipitation"/> is
/// <see cref="PrecipitationKind"/>.
/// </summary> /// </summary>
public readonly record struct ServerClockMessage( public readonly record struct ServerClockMessage(
int SchoolId, int SchoolId,
@@ -40,7 +42,17 @@ public readonly record struct ServerClockMessage(
bool Running, bool Running,
byte SpeedIndex, byte SpeedIndex,
bool SkipAllowed = false, bool SkipAllowed = false,
long SkipTargetUnixMs = 0); long SkipTargetUnixMs = 0,
short TemperatureTenths = 0,
byte Precipitation = 0);
/// <summary>Outdoor precipitation on the clock frame. Below 0 °C the same weather roll is snow.</summary>
public static class PrecipitationKind
{
public const byte None = 0;
public const byte Rain = 1;
public const byte Snow = 2;
}
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary> /// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
public readonly record struct ServerSchoolGoneMessage(int SchoolId); public readonly record struct ServerSchoolGoneMessage(int SchoolId);
+8
View File
@@ -49,6 +49,14 @@ public ref struct PacketReader(ReadOnlySpan<byte> buffer)
return value; return value;
} }
public short ReadInt16()
{
EnsureAvailable(sizeof(short));
var value = BinaryPrimitives.ReadInt16LittleEndian(_buffer[_position..]);
_position += sizeof(short);
return value;
}
public int ReadInt32() public int ReadInt32()
{ {
EnsureAvailable(sizeof(int)); EnsureAvailable(sizeof(int));
+7
View File
@@ -52,6 +52,13 @@ public ref struct PacketWriter(Span<byte> buffer)
_position += byteCount; _position += byteCount;
} }
public void WriteInt16(short value)
{
EnsureRoom(sizeof(short));
BinaryPrimitives.WriteInt16LittleEndian(_buffer[_position..], value);
_position += sizeof(short);
}
public void WriteInt32(int value) public void WriteInt32(int value)
{ {
EnsureRoom(sizeof(int)); EnsureRoom(sizeof(int));
+14 -2
View File
@@ -13,7 +13,7 @@ public static class ProtocolCodec
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots and /// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots and
/// presence frames use <see cref="ProtocolConstants.MaxMessageSize"/> instead. /// presence frames use <see cref="ProtocolConstants.MaxMessageSize"/> instead.
/// </summary> /// </summary>
public const int MaxFrameSize = 24; public const int MaxFrameSize = 27;
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message) public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
{ {
@@ -99,6 +99,8 @@ public static class ProtocolCodec
writer.WriteByte(message.SpeedIndex); writer.WriteByte(message.SpeedIndex);
writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0); writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0);
writer.WriteInt64(message.SkipTargetUnixMs); writer.WriteInt64(message.SkipTargetUnixMs);
writer.WriteInt16(message.TemperatureTenths);
writer.WriteByte(message.Precipitation);
return writer.Position; return writer.Position;
} }
@@ -317,7 +319,17 @@ public static class ProtocolCodec
var speedIndex = reader.ReadByte(); var speedIndex = reader.ReadByte();
var skipAllowed = reader.ReadByte() != 0; var skipAllowed = reader.ReadByte() != 0;
var skipTarget = reader.ReadInt64(); 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<byte> source) public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants public static class ProtocolConstants
{ {
/// <summary>Bumped on every breaking change to the binary layout.</summary> /// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 7; public const byte Version = 8;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary> /// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024; public const int MaxMessageSize = 8 * 1024;
+3 -1
View File
@@ -1063,7 +1063,9 @@ internal sealed class SchoolWorker
school.Clock.IsRunning, school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex, (byte)school.Clock.SpeedIndex,
skip.Allowed, 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)); client.TrySend(frame.AsMemory(0, length));
} }
@@ -1,3 +1,11 @@
{ {
"defName": "ContinentalCold", "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,
} }
@@ -1,3 +1,12 @@
{ {
"defName": "TemperateContinental", "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,
} }
@@ -3,4 +3,13 @@
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0.2, "min": 0, "max": 1, "restoredOffCampus": true }, { "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": "Toilet", "initial": 1, "decayPerHour": 0.15, "min": 0, "max": 1 },
{ "defName": "Social", "initial": 1, "decayPerHour": 0.08, "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,
},
] ]
@@ -1,5 +1,5 @@
[ [
// Walkable rooms that exist to connect the graph: lobby, stairs. Empty on purpose. // 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 }, { "defName": "Stairwell", "travelMinutes": 1.5 },
] ]
@@ -80,4 +80,16 @@
{ "skill": "Chemistry", "offset": 4 }, { "skill": "Chemistry", "offset": 4 },
], ],
}, },
{
"defName": "HeatLoving",
"weight": 5,
"incompatible": ["ColdLoving"],
"comfortTemperatureOffset": 4,
},
{
"defName": "ColdLoving",
"weight": 5,
"incompatible": ["HeatLoving"],
"comfortTemperatureOffset": -4,
},
] ]
@@ -144,6 +144,9 @@
"Hunger": "Hunger", "Hunger": "Hunger",
"Toilet": "Toilet", "Toilet": "Toilet",
"Social": "Social", "Social": "Social",
"Warmth": "Warmth",
"HeatLoving": "Heat-loving",
"ColdLoving": "Cold-loving",
"Russia": "Russia", "Russia": "Russia",
"TemperateContinental": "Temperate continental", "TemperateContinental": "Temperate continental",
"ContinentalCold": "Cold continental", "ContinentalCold": "Cold continental",
@@ -144,6 +144,9 @@
"Hunger": "Голод", "Hunger": "Голод",
"Toilet": "Туалет", "Toilet": "Туалет",
"Social": "Общение", "Social": "Общение",
"Warmth": "Тепло",
"HeatLoving": "Теплолюбивый",
"ColdLoving": "Холодолюбивый",
"Russia": "Россия", "Russia": "Россия",
"TemperateContinental": "Умеренно-континентальный", "TemperateContinental": "Умеренно-континентальный",
"ContinentalCold": "Континентальный холодный", "ContinentalCold": "Континентальный холодный",
+1 -1
View File
@@ -46,7 +46,7 @@ public static class NeedDecay
foreach (var def in catalog.Needs.Values) 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; continue;
} }
+20
View File
@@ -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;
}
}
+57
View File
@@ -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;
}
}
+26 -5
View File
@@ -1,6 +1,7 @@
using Arch.Core; using Arch.Core;
using HSchool.People;
using HSchool.Ai; using HSchool.Ai;
using HSchool.Content;
using HSchool.People;
namespace HSchool.Simulation; namespace HSchool.Simulation;
@@ -10,7 +11,7 @@ public static class RosterSpawner
private static readonly QueryDescription People = new QueryDescription().WithAll<PersonIdentity>(); private static readonly QueryDescription People = new QueryDescription().WithAll<PersonIdentity>();
private static readonly QueryDescription Classes = new QueryDescription().WithAll<ClassIdentity>(); 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) foreach (var schoolClass in roster.Classes)
{ {
@@ -30,7 +31,8 @@ public static class RosterSpawner
new PersonBody(person.Numbers, person.Choices), new PersonBody(person.Numbers, person.Choices),
new PersonSkills(person.Skills.ToDictionary(pair => pair.Key, pair => (float)pair.Value, StringComparer.Ordinal)), new PersonSkills(person.Skills.ToDictionary(pair => pair.Key, pair => (float)pair.Value, StringComparer.Ordinal)),
new PersonTraits(person.Traits), 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( new PersonRoles(
person.IsStudent, person.IsStudent,
person.IsStaff, 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> /// <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, People);
DestroyAll(world, Classes); 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) private static void DestroyAll(World world, QueryDescription query)
+43 -4
View File
@@ -78,9 +78,12 @@ public sealed class School : IDisposable
/// <summary>Country used to generate this school's people. Needed again on 1 September.</summary> /// <summary>Country used to generate this school's people. Needed again on 1 September.</summary>
public string? CountryId { get; private set; } 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; } 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> /// <summary>Skill everyone generated for this school speaks natively.</summary>
public string? NativeLanguage { get; private set; } public string? NativeLanguage { get; private set; }
@@ -132,11 +135,12 @@ public sealed class School : IDisposable
ClimatePresetId = climatePresetId; ClimatePresetId = climatePresetId;
NativeLanguage = nativeLanguage; NativeLanguage = nativeLanguage;
Applicants = applicants; Applicants = applicants;
RosterSpawner.Spawn(World, roster); RosterSpawner.Spawn(World, roster, Catalog);
PlanDay = null; PlanDay = null;
LastDecisionSlot = null; LastDecisionSlot = null;
Plans.Clear(); Plans.Clear();
DecisionQueue.Clear(); DecisionQueue.Clear();
SyncWeather(force: true);
} }
public bool TryStartAction(string personId, string actionId) public bool TryStartAction(string personId, string actionId)
@@ -208,6 +212,7 @@ public sealed class School : IDisposable
PlanDay = null; PlanDay = null;
LastDecisionSlot = null; LastDecisionSlot = null;
NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes); NeedDecay.Apply(World, Catalog, (next.Value - before).TotalMinutes);
SyncWeather(force: true);
return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged); return new SkipEmptyResult(SkipEmptyError.None, next.Value, peopleChanged);
} }
@@ -224,7 +229,7 @@ public sealed class School : IDisposable
Roster = roster; Roster = roster;
Applicants = applicants; Applicants = applicants;
var snapshot = PresenceSystem.Capture(this); var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, roster); RosterSpawner.Replace(World, roster, Catalog);
PresenceSystem.Restore(this, snapshot); PresenceSystem.Restore(this, snapshot);
TimetableDirty = true; TimetableDirty = true;
} }
@@ -274,16 +279,50 @@ public sealed class School : IDisposable
if (Catalog is not null) if (Catalog is not null)
{ {
var below = PresenceSystem.BelowThreshold(this); var below = PresenceSystem.BelowThreshold(this);
SyncWeather(force: false);
NeedDecay.Apply(World, Catalog, gameMinutes); NeedDecay.Apply(World, Catalog, gameMinutes);
WarmthDecay.Apply(this, gameMinutes);
PresenceSystem.EnqueueNewlyUrgent(this, below); PresenceSystem.EnqueueNewlyUrgent(this, below);
PresenceSystem.DrainDecisions(this); PresenceSystem.DrainDecisions(this);
LessonLearningSystem.Apply(this, gameMinutes); LessonLearningSystem.Apply(this, gameMinutes);
} }
} }
else
{
SyncWeather(force: false);
}
return peopleChanged; 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) private bool TryYearlyIntake(DateTime before, DateTime after)
{ {
if (Roster is null || Catalog is null || CountryId is null) if (Roster is null || Catalog is null || CountryId is null)
@@ -301,7 +340,7 @@ public sealed class School : IDisposable
if (changed) if (changed)
{ {
var snapshot = PresenceSystem.Capture(this); var snapshot = PresenceSystem.Capture(this);
RosterSpawner.Replace(World, Roster); RosterSpawner.Replace(World, Roster, Catalog);
PresenceSystem.Restore(this, snapshot); PresenceSystem.Restore(this, snapshot);
TimetableDirty = true; TimetableDirty = true;
} }
+81
View File
@@ -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;
}
}
+53
View File
@@ -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;
}
@@ -428,6 +428,22 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Equal(ClientTime, pong.ClientTimeMs); 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] [Fact]
public async Task VersionMismatch_IsRejected() public async Task VersionMismatch_IsRejected()
{ {
@@ -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<ContentLoadException>(() => _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));
}
}
@@ -36,7 +36,7 @@ public class CountryDefTests
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"climates", "climates",
"temperate", "temperate",
"""{ "defName": "TemperateContinental" }"""), """{ "defName": "TemperateContinental", "monthlyNorms": [0,0,0,0,0,0,0,0,0,0,0,0] }"""),
PackDocuments.Def( PackDocuments.Def(
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"countries", "countries",
@@ -58,7 +58,7 @@ public class CountryDefTests
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"climates", "climates",
"temperate", "temperate",
"""{ "defName": "TemperateContinental" }"""), """{ "defName": "TemperateContinental", "monthlyNorms": [0,0,0,0,0,0,0,0,0,0,0,0] }"""),
PackDocuments.Def( PackDocuments.Def(
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"countries", "countries",
@@ -11,13 +11,15 @@ public class PeopleDefTests
var catalog = _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root)); var catalog = _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
Assert.True(catalog.Skills.Count >= 10); Assert.True(catalog.Skills.Count >= 10);
Assert.Equal(10, catalog.Traits.Count); Assert.Equal(12, catalog.Traits.Count);
Assert.Equal(4, catalog.Needs.Count); Assert.Equal(5, catalog.Needs.Count);
Assert.True(catalog.BodyAttributes.ContainsKey("Height")); Assert.True(catalog.BodyAttributes.ContainsKey("Height"));
Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind); Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind);
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind); Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind);
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0)); Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); 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.Equal(0.2f, catalog.Needs["Hunger"].DecayPerHour);
Assert.True(catalog.Skills["Communication"].Always); Assert.True(catalog.Skills["Communication"].Always);
Assert.True(catalog.Skills["Agility"].Always); Assert.True(catalog.Skills["Agility"].Always);
@@ -223,7 +225,7 @@ public class PeopleDefTests
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"climates", "climates",
"temperate", "temperate",
"""{ "defName": "TemperateContinental" }"""), """{ "defName": "TemperateContinental", "monthlyNorms": [0,0,0,0,0,0,0,0,0,0,0,0] }"""),
PackDocuments.Def( PackDocuments.Def(
CatalogLoader.CorePackId, CatalogLoader.CorePackId,
"countries", "countries",
@@ -121,7 +121,7 @@ public class ProtocolCodecTests
} }
[Fact] [Fact]
public void Clock_RoundTripsAndIsTwentyFourBytes() public void Clock_RoundTripsAndIsTwentySevenBytes()
{ {
var message = new ServerClockMessage( var message = new ServerClockMessage(
7, 7,
@@ -129,12 +129,14 @@ public class ProtocolCodecTests
Running: true, Running: true,
SpeedIndex: 2, SpeedIndex: 2,
SkipAllowed: true, SkipAllowed: true,
SkipTargetUnixMs: 1_333_516_800_000); SkipTargetUnixMs: 1_333_516_800_000,
TemperatureTenths: -50,
Precipitation: 2);
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(buffer, message); var length = ProtocolCodec.WriteClock(buffer, message);
Assert.Equal(24, length); Assert.Equal(27, length);
Assert.Equal((byte)MessageType.ServerClock, buffer[0]); Assert.Equal((byte)MessageType.ServerClock, buffer[0]);
Assert.Equal(7, BitConverter.ToInt32(buffer[1..5])); Assert.Equal(7, BitConverter.ToInt32(buffer[1..5]));
Assert.Equal(1_333_432_800_000, BitConverter.ToInt64(buffer[5..13])); 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(2, buffer[14]);
Assert.Equal(1, buffer[15]); Assert.Equal(1, buffer[15]);
Assert.Equal(1_333_516_800_000, BitConverter.ToInt64(buffer[16..24])); 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])); Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
} }
@@ -154,9 +158,11 @@ public class ProtocolCodecTests
var length = ProtocolCodec.WriteClock(buffer, message); var length = ProtocolCodec.WriteClock(buffer, message);
var read = ProtocolCodec.ReadClock(buffer[..length]); var read = ProtocolCodec.ReadClock(buffer[..length]);
Assert.Equal(24, length); Assert.Equal(27, length);
Assert.False(read.SkipAllowed); Assert.False(read.SkipAllowed);
Assert.Equal(0, read.SkipTargetUnixMs); Assert.Equal(0, read.SkipTargetUnixMs);
Assert.Equal(0, read.TemperatureTenths);
Assert.Equal(PrecipitationKind.None, read.Precipitation);
} }
[Fact] [Fact]
@@ -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);
}
}