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:
+7
-3
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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А)');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 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)
|
private static void ValidateNameSet(NameSetDef names, DefCatalog catalog, string countryDefName)
|
||||||
{
|
{
|
||||||
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
||||||
|
|||||||
@@ -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 1–12. 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, 0–1. 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": "Континентальный холодный",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>Street precipitation. Below 0 °C the same roll is snow, above it is rain.</summary>
|
||||||
|
public enum Precipitation : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Rain = 1,
|
||||||
|
Snow = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cached outdoor state shown on the clock and used to drain warmth.</summary>
|
||||||
|
public readonly record struct OutdoorWeather(float TemperatureC, Precipitation Precipitation)
|
||||||
|
{
|
||||||
|
public static OutdoorWeather None { get; } = new(0f, Precipitation.None);
|
||||||
|
|
||||||
|
public short Tenths => (short)Math.Clamp(
|
||||||
|
Math.Round(TemperatureC * 10d, MidpointRounding.AwayFromZero),
|
||||||
|
short.MinValue,
|
||||||
|
short.MaxValue);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Worn insulation in °C-equivalent points, summed from apparel currently on the body.
|
||||||
|
/// Tests that need a bare frost set <see cref="Naked"/> on the entity explicitly.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PersonInsulation(float Value)
|
||||||
|
{
|
||||||
|
public static PersonInsulation Naked { get; } = new(0f);
|
||||||
|
|
||||||
|
public static PersonInsulation FromWorn(DefCatalog catalog, params string[] defNames)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
var sum = 0f;
|
||||||
|
foreach (var name in defNames)
|
||||||
|
{
|
||||||
|
if (catalog.Things.TryGetValue(name, out var thing) && thing.Layers.Count > 0)
|
||||||
|
{
|
||||||
|
sum += thing.Insulation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PersonInsulation(sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PersonInsulation FromPerson(Person person, DefCatalog? catalog)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(person);
|
||||||
|
if (catalog is null)
|
||||||
|
{
|
||||||
|
return Naked;
|
||||||
|
}
|
||||||
|
|
||||||
|
var worn = WornDefs(person);
|
||||||
|
return worn.Count == 0 ? Naked : FromWorn(catalog, [.. worn]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> WornDefs(Person person)
|
||||||
|
{
|
||||||
|
var worn = new List<string>();
|
||||||
|
if (person.GetType().GetProperty("Items")?.GetValue(person) is not System.Collections.IEnumerable items)
|
||||||
|
{
|
||||||
|
return worn;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (item is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = item.GetType();
|
||||||
|
var def = type.GetProperty("Def")?.GetValue(item) as string;
|
||||||
|
var location = type.GetProperty("Location")?.GetValue(item) as string
|
||||||
|
?? type.GetProperty("Place")?.GetValue(item) as string;
|
||||||
|
if (def is null || location is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.Equals("worn", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
worn.Add(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return worn;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Street vs indoors. The yard is always outdoor; rooms opt in with <see cref="RoomDef.Outdoor"/>
|
||||||
|
/// so the porch matches the street. Indoor temperature is the street plus the preset's wall offset
|
||||||
|
/// — warmer, not yet comfortable (the technician is phase 33).
|
||||||
|
/// </summary>
|
||||||
|
public static class PlaceClimate
|
||||||
|
{
|
||||||
|
public static bool IsOutdoor(DefCatalog catalog, MapLayout? map, string? nodeId)
|
||||||
|
{
|
||||||
|
if (nodeId is null || map is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.Territory is { } territory && territory.Id.Equals(nodeId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var defName = map.NodeDef(nodeId);
|
||||||
|
if (defName is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalog.Territories.ContainsKey(defName))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalog.Rooms.TryGetValue(defName, out var room) && room.Outdoor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static float TemperatureC(School school, string? nodeId)
|
||||||
|
{
|
||||||
|
var outdoor = school.Weather.TemperatureC;
|
||||||
|
var catalog = school.Catalog;
|
||||||
|
if (catalog is null || IsOutdoor(catalog, school.Map, nodeId))
|
||||||
|
{
|
||||||
|
return outdoor;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 8f;
|
||||||
|
if (school.ClimatePresetId is { } presetId
|
||||||
|
&& catalog.ClimatePresets.TryGetValue(presetId, out var preset)
|
||||||
|
&& !preset.Abstract)
|
||||||
|
{
|
||||||
|
offset = preset.IndoorOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return outdoor + offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Arch.Core;
|
using 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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Arch.Core;
|
||||||
|
using HSchool.Ai;
|
||||||
|
using HSchool.Content;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains <c>Warmth</c> from the gap between clothing insulation and the temperature of the
|
||||||
|
/// place the person is in. Off campus the generic need restore already snaps it to max.
|
||||||
|
/// </summary>
|
||||||
|
public static class WarmthDecay
|
||||||
|
{
|
||||||
|
private static readonly QueryDescription People =
|
||||||
|
new QueryDescription().WithAll<PersonNeeds, PersonTraits, PersonInsulation, Presence>();
|
||||||
|
|
||||||
|
public static void Apply(School school, double gameMinutes)
|
||||||
|
{
|
||||||
|
if (gameMinutes <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = school.Catalog;
|
||||||
|
if (catalog is null || !catalog.Needs.TryGetValue("Warmth", out var warmth) || warmth.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var preset = Preset(school, catalog);
|
||||||
|
var hours = gameMinutes / 60d;
|
||||||
|
var world = school.World;
|
||||||
|
world.Query(
|
||||||
|
in People,
|
||||||
|
(ref PersonNeeds needs, ref PersonTraits traits, ref PersonInsulation insulation, ref Presence presence) =>
|
||||||
|
{
|
||||||
|
if (!presence.IsOnCampus || !needs.Values.ContainsKey(warmth.DefName))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var place = PlaceClimate.TemperatureC(school, presence.NodeId);
|
||||||
|
var felt = place + insulation.Value * (preset?.InsulationPerC ?? 1f);
|
||||||
|
var center = (preset?.ComfortC ?? 21f) + TraitOffset(catalog, traits);
|
||||||
|
var halfWidth = preset?.ComfortHalfWidthC ?? 3f;
|
||||||
|
var mismatch = Math.Abs(felt - center) - halfWidth;
|
||||||
|
if (mismatch <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = needs.Values[warmth.DefName] - (float)(mismatch * warmth.DecayPerHour * hours);
|
||||||
|
needs.Values[warmth.DefName] = Math.Clamp(next, warmth.Min, warmth.Max);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClimatePresetDef? Preset(School school, DefCatalog catalog)
|
||||||
|
{
|
||||||
|
if (school.ClimatePresetId is { } id
|
||||||
|
&& catalog.ClimatePresets.TryGetValue(id, out var preset)
|
||||||
|
&& !preset.Abstract)
|
||||||
|
{
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float TraitOffset(DefCatalog catalog, PersonTraits traits)
|
||||||
|
{
|
||||||
|
var offset = 0f;
|
||||||
|
foreach (var id in traits.Ids)
|
||||||
|
{
|
||||||
|
if (catalog.Traits.TryGetValue(id, out var trait))
|
||||||
|
{
|
||||||
|
offset += trait.ComfortTemperatureOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
|
||||||
|
namespace HSchool.Simulation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outdoor temperature and precipitation from a climate preset, the school seed, and the
|
||||||
|
/// calendar. Same inputs always produce the same street. Not a tick accumulator — sample it
|
||||||
|
/// when the clock label or warmth would move.
|
||||||
|
/// </summary>
|
||||||
|
public static class WeatherSampler
|
||||||
|
{
|
||||||
|
private const int DaySalt = 0x57EA11;
|
||||||
|
private const int HourSalt = 0x57EA12;
|
||||||
|
|
||||||
|
public static OutdoorWeather Sample(ClimatePresetDef preset, int schoolSeed, DateTime time)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(preset);
|
||||||
|
|
||||||
|
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
|
||||||
|
var month = utc.Month;
|
||||||
|
var norm = MonthNorm(preset, month);
|
||||||
|
var dayNumber = DateOnly.FromDateTime(utc).DayNumber;
|
||||||
|
var dayNoise = SignedUnit(Seed.Mix(schoolSeed, dayNumber, DaySalt)) * preset.DaySpread;
|
||||||
|
var hour = utc.Hour + utc.Minute / 60d + utc.Second / 3600d;
|
||||||
|
var diurnal = -Math.Cos((hour - 3d) / 24d * 2d * Math.PI) * preset.HourSpread;
|
||||||
|
var temperature = (float)(norm + dayNoise + diurnal);
|
||||||
|
|
||||||
|
var wet = Unit(Seed.Mix(schoolSeed, dayNumber * 24 + utc.Hour, HourSalt)) < preset.PrecipitationChance;
|
||||||
|
var precipitation = !wet
|
||||||
|
? Precipitation.None
|
||||||
|
: temperature < 0f ? Precipitation.Snow : Precipitation.Rain;
|
||||||
|
|
||||||
|
return new OutdoorWeather(temperature, precipitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float MonthNorm(ClimatePresetDef preset, int month)
|
||||||
|
{
|
||||||
|
if (preset.MonthlyNorms.Count != 12)
|
||||||
|
{
|
||||||
|
return 0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset.MonthlyNorms[month - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Unit(int mixed)
|
||||||
|
{
|
||||||
|
return (uint)mixed / (double)uint.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double SignedUnit(int mixed) => Unit(mixed) * 2d - 1d;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user