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:
@@ -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');
|
||||
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
|
||||
.toBe('Кабинет 204 (Математика · 5Б)');
|
||||
expect(t('weatherPrecip', { temp: '\u22125', precip: t('precipSnow') })).toBe('\u22125 °C, snow');
|
||||
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
|
||||
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
|
||||
.toBe('Класс 101 (18 · Математика · 5А)');
|
||||
|
||||
@@ -191,6 +191,10 @@ const ru = {
|
||||
mapHeadcount: '{name} ({count})',
|
||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||
skipTo: 'Пропустить до {date}',
|
||||
weatherClear: '{temp} °C',
|
||||
weatherPrecip: '{temp} °C, {precip}',
|
||||
precipRain: 'дождь',
|
||||
precipSnow: 'снег',
|
||||
presenceAt: '{name}',
|
||||
presenceWalking: 'в пути ({name})',
|
||||
presenceAway: 'вне школы',
|
||||
@@ -408,6 +412,10 @@ const en: Messages = {
|
||||
mapHeadcount: '{name} ({count})',
|
||||
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||
skipTo: 'Skip to {date}',
|
||||
weatherClear: '{temp} °C',
|
||||
weatherPrecip: '{temp} °C, {precip}',
|
||||
precipRain: 'rain',
|
||||
precipSnow: 'snow',
|
||||
presenceAt: '{name}',
|
||||
presenceWalking: 'walking ({name})',
|
||||
presenceAway: 'off campus',
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('decodeServerMessage', () => {
|
||||
// 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z
|
||||
const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
|
||||
const skipTargetMs = Date.UTC(2012, 3, 4, 6, 0, 0);
|
||||
const buffer = new ArrayBuffer(24);
|
||||
const buffer = new ArrayBuffer(27);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerClock);
|
||||
view.setInt32(1, 7, true);
|
||||
@@ -107,6 +107,8 @@ describe('decodeServerMessage', () => {
|
||||
view.setUint8(14, 2);
|
||||
view.setUint8(15, 1);
|
||||
view.setBigInt64(16, BigInt(skipTargetMs), true);
|
||||
view.setInt16(24, -50, true);
|
||||
view.setUint8(26, 2);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'clock',
|
||||
@@ -116,6 +118,8 @@ describe('decodeServerMessage', () => {
|
||||
speedIndex: 2,
|
||||
skipAllowed: true,
|
||||
skipTarget: new Date(skipTargetMs),
|
||||
temperatureTenths: -50,
|
||||
precipitation: 2,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 7;
|
||||
export const PROTOCOL_VERSION = 8;
|
||||
|
||||
export const MessageType = {
|
||||
ClientHello: 0x01,
|
||||
@@ -50,6 +50,12 @@ export interface PongMessage {
|
||||
readonly serverTick: number;
|
||||
}
|
||||
|
||||
export const Precipitation = {
|
||||
None: 0,
|
||||
Rain: 1,
|
||||
Snow: 2,
|
||||
} as const;
|
||||
|
||||
export interface ClockMessage {
|
||||
readonly type: 'clock';
|
||||
readonly schoolId: number;
|
||||
@@ -61,6 +67,10 @@ export interface ClockMessage {
|
||||
readonly skipAllowed: boolean;
|
||||
/** UTC instant the skip would land on; null when skip is refused. */
|
||||
readonly skipTarget: Date | null;
|
||||
/** Outdoor temperature in tenths of a °C. */
|
||||
readonly temperatureTenths: number;
|
||||
/** `Precipitation.None` / `Rain` / `Snow`. */
|
||||
readonly precipitation: number;
|
||||
}
|
||||
|
||||
export interface SchoolGoneMessage {
|
||||
@@ -248,7 +258,7 @@ function decodePong(view: DataView): PongMessage {
|
||||
}
|
||||
|
||||
function decodeClock(view: DataView): ClockMessage {
|
||||
ensure(view, 24);
|
||||
ensure(view, 27);
|
||||
const skipTargetMs = Number(view.getBigInt64(16, true));
|
||||
|
||||
return {
|
||||
@@ -259,6 +269,8 @@ function decodeClock(view: DataView): ClockMessage {
|
||||
speedIndex: view.getUint8(14),
|
||||
skipAllowed: view.getUint8(15) !== 0,
|
||||
skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs),
|
||||
temperatureTenths: view.getInt16(24, true),
|
||||
precipitation: view.getUint8(26),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,12 @@ body {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.clock__weather {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.clock__controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PresenceNode,
|
||||
} from '../net/protocol.ts';
|
||||
import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||
import { formatWeather } from '../format/weather.ts';
|
||||
import { getLocale } from '../i18n/locale.ts';
|
||||
import { t } from '../i18n/strings.ts';
|
||||
import { fetchDirectory, type School } from '../net/api.ts';
|
||||
@@ -38,6 +39,7 @@ export class GameScreen {
|
||||
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
|
||||
private readonly date = el('p', { class: 'clock__date' });
|
||||
private readonly weekday = el('p', { class: 'clock__weekday' });
|
||||
private readonly weather = el('p', { class: 'clock__weather' });
|
||||
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
|
||||
private readonly skipButton = el('button', { class: 'button button--small', type: 'button' });
|
||||
private readonly speedButtons: HTMLButtonElement[];
|
||||
@@ -86,6 +88,8 @@ export class GameScreen {
|
||||
private lastSpeedIndex = 0;
|
||||
private skipAllowed = false;
|
||||
private skipTarget: Date | null = null;
|
||||
private lastTemperatureTenths: number | null = null;
|
||||
private lastPrecipitation: number | null = null;
|
||||
private inspected: 'location' | 'person' = 'location';
|
||||
|
||||
constructor(options: GameScreenOptions) {
|
||||
@@ -112,7 +116,7 @@ export class GameScreen {
|
||||
'div',
|
||||
{ class: 'clockbar__now' },
|
||||
this.time,
|
||||
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
|
||||
el('div', { class: 'clockbar__labels' }, this.date, this.weekday, this.weather),
|
||||
),
|
||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons, this.skipButton),
|
||||
),
|
||||
@@ -184,7 +188,15 @@ export class GameScreen {
|
||||
this.paintSelection();
|
||||
|
||||
if (this.lastGameTime !== null) {
|
||||
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex, this.skipAllowed, this.skipTarget);
|
||||
this.applyClock(
|
||||
this.lastGameTime,
|
||||
this.running,
|
||||
this.lastSpeedIndex,
|
||||
this.skipAllowed,
|
||||
this.skipTarget,
|
||||
this.lastTemperatureTenths,
|
||||
this.lastPrecipitation,
|
||||
);
|
||||
} else {
|
||||
this.playPauseButton.title = t('resume');
|
||||
}
|
||||
@@ -208,7 +220,7 @@ export class GameScreen {
|
||||
this.skipAllowed = false;
|
||||
this.skipTarget = null;
|
||||
this.rebuildTree();
|
||||
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null);
|
||||
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null, null, null);
|
||||
this.people.show(school.id);
|
||||
this.people.setLocate((id) => this.placeOf(id));
|
||||
this.management.setLocate((id) => this.placeOf(id));
|
||||
@@ -251,7 +263,15 @@ export class GameScreen {
|
||||
}
|
||||
|
||||
update(clock: ClockMessage): void {
|
||||
this.applyClock(clock.gameTime, clock.running, clock.speedIndex, clock.skipAllowed, clock.skipTarget);
|
||||
this.applyClock(
|
||||
clock.gameTime,
|
||||
clock.running,
|
||||
clock.speedIndex,
|
||||
clock.skipAllowed,
|
||||
clock.skipTarget,
|
||||
clock.temperatureTenths,
|
||||
clock.precipitation,
|
||||
);
|
||||
}
|
||||
|
||||
private rebuildTree(): void {
|
||||
@@ -358,16 +378,24 @@ export class GameScreen {
|
||||
speedIndex: number,
|
||||
skipAllowed: boolean,
|
||||
skipTarget: Date | null,
|
||||
temperatureTenths: number | null,
|
||||
precipitation: number | null,
|
||||
): void {
|
||||
this.running = running;
|
||||
this.lastGameTime = gameTime;
|
||||
this.lastSpeedIndex = speedIndex;
|
||||
this.skipAllowed = skipAllowed;
|
||||
this.skipTarget = skipTarget;
|
||||
this.lastTemperatureTenths = temperatureTenths;
|
||||
this.lastPrecipitation = precipitation;
|
||||
|
||||
this.time.textContent = formatGameTimeOfDay(gameTime);
|
||||
this.date.textContent = formatGameDate(gameTime);
|
||||
this.weekday.textContent = formatGameWeekday(gameTime);
|
||||
this.weather.textContent =
|
||||
temperatureTenths === null || precipitation === null
|
||||
? ''
|
||||
: formatWeather(temperatureTenths, precipitation);
|
||||
|
||||
this.playPauseButton.textContent = running ? '⏸' : '▶';
|
||||
this.playPauseButton.title = running ? t('pause') : t('resume');
|
||||
|
||||
Reference in New Issue
Block a user