From 0dd15a7f104eba12ef763f832f3f9ca140ed0c5f Mon Sep 17 00:00:00 2001 From: webdevcody Date: Tue, 29 Sep 2026 21:10:00 -0400 Subject: [PATCH] Loop the day and night outside every hour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sky used to follow the office machine's real clock, so a day took a day. Now the office's clock is sped up 24× for the sky (skyTime in shared/sun.ts): midnight on the hour, sunrise around a quarter past, noon at half past, sunset around a quarter to. It stays on today's date, so a --city office still gets that place's season's day length. Every browser keeps time by the office's clock, so everyone sees the same sky, and ⚙️ shows the sky's time of day. Co-Authored-By: Claude Opus 5.5 --- docs/azure.md | 2 +- docs/features.md | 2 +- docs/how-it-works.md | 2 +- src/client/main.ts | 4 ++-- src/client/ui/settings.ts | 2 +- src/client/world/sky.ts | 25 ++++++++++++++----------- src/server/config.ts | 9 +++++---- src/shared/sun.ts | 18 +++++++++++++++++- tests/sun.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 tests/sun.test.ts diff --git a/docs/azure.md b/docs/azure.md index 42d8f739..569a89dd 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -101,7 +101,7 @@ Useful options for `up`: **Running `up` again** re-provisions the VM, which is how `--size`, `--project` or a new `--claude-token` get applied. Without `--claude-token` or `--anthropic-api-key`, it keeps the Claude sign-in it was given before. -**Settings.** Like on AWS, office settings go in `/etc/agent-office/env` on the VM (`deploy/azure.sh ssh`, then `sudo nano /etc/agent-office/env` and `sudo systemctl restart agent-office`). The VM's clock is UTC, so set `AGENT_OFFICE_CITY="Portland, Oregon"` there for the office's sky to follow your time of day. +**Settings.** Like on AWS, office settings go in `/etc/agent-office/env` on the VM (`deploy/azure.sh ssh`, then `sudo nano /etc/agent-office/env` and `sudo systemctl restart agent-office`). The VM's clock is UTC, so set `AGENT_OFFICE_CITY="Portland, Oregon"` there for the office's weather, and its holiday calendar, to be yours. ## When Azure says no diff --git a/docs/features.md b/docs/features.md index 0c260d5c..2ea16a6b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,7 +11,7 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Upstairs, over the garage.** The office is the second floor. Its windows are real glass, so you can look out over the street, the trees and the neighbours. Walk out the EXIT door in the west wall and down the stairs to the street. Underneath is an open garage full of Lambos and Ferraris, which you can jump up onto or drive (see below). The elevator goes down there too (**Garage**, under floor 1 on its panel), stopping at the garage's back wall, and back up from there to your floor or any other. - **Drive the supercars.** Walk up to one of the Lambos or Ferraris in the garage (or the one parked out front) and press **E** to get behind the wheel. **W** is the gas, **S** brakes and then reverses, **A** and **D** steer, **Space** brakes and **H** honks. Take it out of the garage, round the lots and along the street either way. Columns, lamp posts, trees and the other cars stop you, and anyone standing in your way gets knocked aside. Everyone on your floor sees the car go by with you in it (the roof comes off, so they can see who's driving), and hears its engine and horn. Press **E** at a car someone else is driving to ride along beside them. **E** gets you out, and the car stays parked wherever you leave it until the office restarts, when every car goes back to its spot. Like the people you see, the cars are your floor's own: the garage under another floor has its cars where people on that floor left them. - **A tower that grows with your projects.** Every floor is a storey of the same building, so from outside it's as tall as you have projects: add enough of them and it's a skyscraper, a balcony off every floor, windows lit here and there at night and a cornice round the top. Up on a higher floor the street is that many storeys further down. Only the bottom floor has the EXIT door and the stairs down; the floors above it have wall there, so take the elevator down to the garage to leave. -- **Day and night, and weather.** Outside the windows it's the office's own time of day: the sun comes up and goes down with the clock of the machine running it, and at night the street lamps, the balcony's string lights and the windows across the street come on while the office lights warm up. The weather comes and goes by itself (sun, clouds, rain, thunderstorms, fog, and snow in winter), with raindrops on the glass, wet streets and snow settling outside. Start the office with `--city Berlin` and the sky follows that city's sunrise, sunset and live forecast instead. Everyone sees the same sky, and **⚙️** shows what it's doing. +- **Day and night, and weather.** Outside the windows a whole day goes by every hour: midnight on the hour, sunrise around a quarter past, noon at half past and sunset around a quarter to, and at night the street lamps, the balcony's string lights and the windows across the street come on while the office lights warm up. The weather comes and goes by itself (sun, clouds, rain, thunderstorms, fog, and snow in winter), with raindrops on the glass, wet streets and snow settling outside. Start the office with `--city Berlin` and the sky follows that city's live forecast instead, with its days as long or as short as they are there this time of year. Everyone sees the same sky, and **⚙️** shows what it's doing. - **Maps: the office, a castle, or your own.** Under **⚙️** → *Map*, anyone can turn the whole building into another place, for everyone on every floor, and back. The **🏰 Castle** is a great hall with a throne of iron blades at the end: you sit on it, the workers at the long tables down either side get up and line up before you when they're done or need you, and the Hand of the King at your left sends a new one running off to a free seat when you speak to him. Workers there dress as peasants and wear out as they toil, growing long grey beards, getting grubby and stooping, fully after 30 minutes of work. The boards hang on the hall's walls with a scribe at each, and the workers, queue, meetings and PRs all work as in the office. A map is plain JSON, so you can move things about or make your own without touching the code: see [Maps](maps.md). - **Halloween and Christmas.** Under **⚙️** → *Holiday theme*, anyone can dress the whole building up, for everyone on every floor. For Halloween the workers turn into shambling zombies with stitched grins and bandages, your first-person hands become an undead warlock's, bony and clawed with green witch-fire curling round them, everyone wears a crooked warlock's hat, and the dog gets bat wings and a witch's hat. The sky goes creepy, purple overhead and blood orange at the horizon, with a big harvest moon, bats crossing it and circling the building, and the odd far-off flash. Jack-o'-lanterns glow everywhere: on every desk, the window sills, the counter, the balcony rail and down the street, with gravestones on the lawn and cobwebs in the corners. For Christmas the workers are elves, your hands are in mittens, everyone wears a Santa hat, the dog is Rudolph, the potted plants turn into little decorated trees with presents under them, and it snows outside, onto a big lit tree and a few snowmen out front. *By the calendar* (the default) puts up Halloween through October and Christmas through December, by the office's clock. - **Smoke breaks.** Glass doors on the south wall slide open onto a balcony with string lights, a bench and a bistro table. Press **E** at the ashtray to light up. Everyone sees you puffing away until you stub it out or step back inside. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 6364abd3..3c02a762 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -24,7 +24,7 @@ browser ──HTTPS/WSS──▶ agent-office (Node) - **Cost.** Hooks carry no usage, but each one names the session's transcript (`~/.claude/projects//.jsonl`). The office reads what gets appended to it, and to the subagent transcripts next to it: every assistant message records the API's token usage and the model, which the office prices from its own table (cache writes and reads included). When a session ends, Claude Code appends its own tally (`cost-state`), and the worker's numbers snap to that, which also covers calls that never reach the transcript. Per-worker totals are saved with the worker, and `.agent-office/usage.json` keeps the office's all-time and per-day spend, so nothing is lost on a restart or when a worker is sent home. On an office deployed with `deploy/aws.sh`, put `AGENT_OFFICE_BUDGET=20` (and `AGENT_OFFICE_BUDGET_PAUSE=1`) in `/etc/agent-office/env` and restart the service. - **Maps.** A map other than the office is data (`MapConfig`, [`src/shared/maps/`](../src/shared/maps)): the server and every browser work out the same plan from it, with every seat the office has, by the same ids, placed at its tables, so workers, the queue and meetings never notice which map they're on. The server keeps the building's pick (`src/server/maps.ts`, saved in `.agent-office/map.json`) and reads maps of your own from `.agent-office/maps/` when someone joins or opens Settings, sending each one's whole config (what it `extends` filled in) or why it won't load. A browser builds the world for it by its style (`src/client/world/castle.ts`) and swaps it in for the office the way it does the rooftop. Whether a worker is waiting on you is the office's own (done and not yet seen to, or needs input), so the castle's line is worked out in each browser, in the order N goes; how worn out a worker looks comes from `workedMs` and `workingSince`, which the office keeps for every worker. - **Holiday themes.** The server keeps the building's pick (`src/server/theme.ts`, saved in `.agent-office/theme.json`) and, for *By the calendar*, works out the holiday from the office's clock, checking again every ten minutes. Each browser then dresses its own scene: the costumes are in `src/client/world/costumes.ts`, the decorations in `src/client/world/holiday.ts`, and Halloween's sky and Christmas's snow in `src/client/world/sky.ts`. -- **Sky.** The server decides the weather and tells everyone (`src/server/sky.ts`), and each browser works out where the sun is from that and its own clock. Without `--city`, the office sits in the middle of the host's time zone at 40° north (34° south if its clocks go forward in January), and the weather changes every 20 to 50 minutes, with the season's odds. With `--city` (a name, or `lat,lon`), it asks open-meteo.com for the place once and for its current weather every 15 minutes; no key is needed, and nothing but the city's name and coordinates is sent. The office has no roof, so the sun and the sky light the rooms too. At night a few lines added to every lit material put lamplight back in the office and the garage, and pools of light around the lamps outside (`src/client/world/sky.ts`). A server on AWS keeps UTC, so on an office deployed with `deploy/aws.sh`, put `AGENT_OFFICE_CITY="Portland, Oregon"` in `/etc/agent-office/env` and restart the service. +- **Sky.** The server decides the weather and tells everyone (`src/server/sky.ts`), and each browser works out where the sun is from that and the office's clock, sped up so a whole day and night go by every hour (`skyTime` in `src/shared/sun.ts`: midnight on the hour, noon at half past, on today's date so the days are as long as the season's). Without `--city`, the office sits in the middle of the host's time zone at 40° north (34° south if its clocks go forward in January), and the weather changes every 20 to 50 minutes, with the season's odds. With `--city` (a name, or `lat,lon`), it asks open-meteo.com for the place once and for its current weather every 15 minutes; no key is needed, and nothing but the city's name and coordinates is sent. The office has no roof, so the sun and the sky light the rooms too. At night a few lines added to every lit material put lamplight back in the office and the garage, and pools of light around the lamps outside (`src/client/world/sky.ts`). A server on AWS keeps UTC, so on an office deployed with `deploy/aws.sh`, put `AGENT_OFFICE_CITY="Portland, Oregon"` in `/etc/agent-office/env` and restart the service for your own weather and calendar. - **Services.** Every 4 seconds the office lists the TCP ports its user's processes listen on (`ss`, or `lsof` on macOS). It credits each port to the worker whose terminal started it. It goes by the process tree first. For a server that detached from it, it uses the `AGENT_OFFICE_WORKER_ID` the process inherited (Linux), then whether it runs inside that worker's worktree. Ports that answer HTTP are shown. A request for `localhost:` that reaches the office's own port (that's what a service tunnel does) is relayed to that server, WebSockets included, so hot reload works. On a Tailscale office (`AGENT_OFFICE_TAILSCALE_HOST`, set by `deploy/provision.sh --tailscale`), a request for `.ts.net:` is relayed the same way, with `localhost:` as its Host so dev servers that check it let it in. Whenever the list changes, the office has Tailscale Serve point exactly those ports at itself, through the `agent-office-serve` helper (`src/server/tailnet.ts`). - **Pictures.** WebGL can only draw an image from another site if that site sends CORS headers, and most don't. So the office fetches each picture itself (`/api/image`, images up to 15 MB) and serves it from its own origin. Any image link works, and a picture on a worker's dev server does too. Browsers shrink each one to 1024 px before it goes on the wall. - **Whiteboard.** It syncs the way Excalidraw's own live collaboration does. Every change bumps an element's version, each browser sends the elements it changed over the office's socket, and everyone merges what arrives with Excalidraw's `reconcileElements`, keeping the newer copy of each element (equal versions go to the lower random nonce, so every copy agrees). The office applies the same rule to its own copy and saves it in the floor's `.agent-office/whiteboard/elements.json`. Deleted elements are kept for a week so a deletion reaches everyone. Pictures go up once over HTTP (`/api/whiteboard/file`, up to 6 MB each) and are fetched by id, a hash of the picture. Excalidraw, a few MB of JavaScript, only loads when someone opens the whiteboard or a floor has a drawing to show on the board, and its fonts are served by the office rather than a CDN. diff --git a/src/client/main.ts b/src/client/main.ts index e455073e..dd873a22 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -157,7 +157,7 @@ const inOffice = () => world === theOffice; const plan = (): MapPlan => world.plan; /** On a castle-style map: its workers walking between their seats and the line for the throne. */ let court: Court | null = null; -const sky = new Sky(scene, { sun, hemi, ambient }, office.night); +const sky = new Sky(scene, { sun, hemi, ambient }, office.night, () => store.officeNow()); store.on('sky', () => store.sky && sky.set(store.sky)); // Halloween or Christmas decorations, up while the building's dressed up for one (see dressUp). const holiday = new Holiday(office); @@ -4452,7 +4452,7 @@ function showSettings(pane?: SettingsPane) { () => sound.ding('done'), notifier, signOut, - store.sky ? { now: describeSky(store.sky), live: !!store.sky.city } : undefined, + store.sky ? { now: describeSky(store.sky, store.officeNow()), live: !!store.sky.city } : undefined, pane, ); } diff --git a/src/client/ui/settings.ts b/src/client/ui/settings.ts index 0f905c6a..9c71d892 100644 --- a/src/client/ui/settings.ts +++ b/src/client/ui/settings.ts @@ -528,7 +528,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting 'Outside', 'office', h('p.outside-now', {}, outside.now), - h('p.setting-note', {}, outside.live ? 'Everyone sees the same sky: the office’s clock and the live weather where it is.' : 'Everyone sees the same sky: the office’s clock, and weather that comes and goes. Start the office with --city to use a real city’s forecast.'), + h('p.setting-note', {}, outside.live ? 'Everyone sees the same sky: a whole day and night every hour, and the live weather where it is.' : 'Everyone sees the same sky: a whole day and night every hour, and weather that comes and goes. Start the office with --city to use a real city’s forecast.'), ), ] : []), diff --git a/src/client/world/sky.ts b/src/client/world/sky.ts index 21378624..108d154d 100644 --- a/src/client/world/sky.ts +++ b/src/client/world/sky.ts @@ -1,14 +1,14 @@ import * as THREE from 'three'; import { FLOOR, SLAB, STREET_Y, WALL_HEIGHT, WALL_T, WING, wingMinZ } from '../../shared/layout'; import type { SkyState, Theme, Weather } from '../../shared/protocol'; -import { guessPlace, sunPosition } from '../../shared/sun'; +import { guessPlace, skyTime, sunPosition } from '../../shared/sun'; import type { NightParts } from './outside'; /* * Day, night and the weather outside the windows. The server says where the office is and what the - * weather is doing (server/sky.ts). From that and the clock, this works out where the sun is, and - * every frame it sets the sky's color, the fog, the sun (or the moon), the lamps that come on at - * night, and the rain or snow. + * weather is doing (server/sky.ts). From that and the office's clock, sped up so a whole day and + * night go by every hour (see skyTime), this works out where the sun is, and every frame it sets + * the sky's color, the fog, the sun (or the moon), the lamps that come on at night, and the rain or snow. * * The office has no roof, and the sun and the sky light everything, inside and out, so at night the * room would go as dark as the street. A few lines added to every lit material (below) give light @@ -200,13 +200,14 @@ THREE.Material.prototype.onBeforeCompile = function (shader) { const LABEL: Record = { clear: 'Clear', cloudy: 'Cloudy', rain: 'Rain', storm: 'Thunderstorm', snow: 'Snow', fog: 'Fog' }; const ICON: Record = { clear: '☀️', cloudy: '☁️', rain: '🌧️', storm: '⛈️', snow: '🌨️', fog: '🌫️' }; -/** "🌙 Clear · 9:41 PM office time · Berlin, Germany, 11 °C", for Settings. */ +/** "🌙 Clear · 9:41 PM outside · Berlin, Germany, 11 °C", for Settings: the time of day in the sky (see skyTime). */ export function describeSky(s: SkyState, now = Date.now()): string { - const night = sunPosition(now, s.lat, s.lon).el < -4 * DEG; + const sky = skyTime(now, s.utcOffset); + const night = sunPosition(sky, s.lat, s.lon).el < -4 * DEG; const icon = s.weather === 'clear' && night ? '🌙' : ICON[s.weather]; - const time = new Date(now + s.utcOffset * 60_000).toLocaleTimeString([], { timeZone: 'UTC', hour: 'numeric', minute: '2-digit' }); + const time = new Date(sky + s.utcOffset * 60_000).toLocaleTimeString([], { timeZone: 'UTC', hour: 'numeric', minute: '2-digit' }); const where = s.city ? ` · ${s.city}${s.temp !== undefined ? `, ${s.temp} °C` : ''}` : ''; - return `${icon} ${LABEL[s.weather]} · ${time} office time${where}`; + return `${icon} ${LABEL[s.weather]} · ${time} outside${where}`; } const lerp = THREE.MathUtils.lerp; @@ -438,6 +439,8 @@ export class Sky { private scene: THREE.Scene, private lights: SkyLights, private night: NightParts, + /** The office's clock (ms since 1970), which everyone's sky keeps time by. */ + private clock: () => number = Date.now, ) { const here = guessPlace(); this.state = { ...here, utcOffset: -new Date().getTimezoneOffset(), weather: 'clear', intensity: 0 }; @@ -613,12 +616,12 @@ export class Sky { return Math.min(1, Math.max(this.level, lamp * this.lampsOn)); } - /** The office's clock (ms), or the previewed hour today. */ + /** The time of day in the sky (see skyTime), or the previewed hour today. */ private now(): number { const h = this.preview.hour; - if (h === undefined) return Date.now(); + if (h === undefined) return skyTime(this.clock(), this.state.utcOffset); const off = this.state.utcOffset * 60_000; - const midnight = Math.floor((Date.now() + off) / 86_400_000) * 86_400_000; + const midnight = Math.floor((this.clock() + off) / 86_400_000) * 86_400_000; return midnight - off + h * 3_600_000; } diff --git a/src/server/config.ts b/src/server/config.ts index ac9bf2a7..9a17a933 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -142,10 +142,11 @@ Options: needs input or finishes (env AGENT_OFFICE_WEBHOOK). Also settable from ⚙️ Settings in the office; "" turns it off --city Put the office in a real city, e.g. "Berlin" or - "Portland, Oregon" (env AGENT_OFFICE_CITY): day, night - and the weather outside follow its live forecast from - open-meteo.com. Without it the sun follows this - machine's clock and the weather is made up + "Portland, Oregon" (env AGENT_OFFICE_CITY): the sun + keeps its hours of daylight and the weather outside + follows its live forecast from open-meteo.com. + Without it the weather is made up. Either way a + whole day and night go by every hour --weather Pin the weather: clear, cloudy, rain, storm, snow or fog (env AGENT_OFFICE_WEATHER) -h, --help Show this help diff --git a/src/shared/sun.ts b/src/shared/sun.ts index 971f55f0..a3f1f61b 100644 --- a/src/shared/sun.ts +++ b/src/shared/sun.ts @@ -1,7 +1,23 @@ // Where the sun is, for day and night outside the office. Shared: the server guesses where the -// office is, and every browser works out the sun from that and its own clock. +// office is, and every browser works out the sun from that and the office's clock. const RAD = Math.PI / 180; +const DAY = 86_400_000; + +/** How long the sky takes over a whole day and night: an hour, so you see the sun go down and come back up. */ +export const SKY_DAY_MS = 3_600_000; + +/** + * The time of day in the sky at `ms` (Unix time) on the office's clock, `utcOffset` minutes east of + * UTC: it goes round a whole day every SKY_DAY_MS, midnight on the hour and noon at half past. It + * stays on today's date, so the sun rises and sets as early or as late as it does there this time of year. + */ +export function skyTime(ms: number, utcOffset: number): number { + const local = ms + utcOffset * 60_000; + const midnight = Math.floor(local / DAY) * DAY; + const into = (((local % SKY_DAY_MS) + SKY_DAY_MS) % SKY_DAY_MS) / SKY_DAY_MS; + return midnight + into * DAY - utcOffset * 60_000; +} /** * The sun's elevation above the horizon and its azimuth (clockwise from north, so east is +π/2), diff --git a/tests/sun.test.ts b/tests/sun.test.ts new file mode 100644 index 00000000..a08fe1c0 --- /dev/null +++ b/tests/sun.test.ts @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SKY_DAY_MS, skyTime, sunPosition } from '../src/shared/sun.js'; + +const HOUR = 3_600_000; +const DAY = 86_400_000; +/** Hours since midnight in the office's time zone. */ +const hourOfDay = (ms: number, utcOffset: number) => (((ms + utcOffset * 60_000) % DAY) + DAY) % DAY / HOUR; + +test('the sky goes round a whole day every hour: midnight on the hour, noon at half past', () => { + assert.equal(SKY_DAY_MS, HOUR); + for (const utcOffset of [-420, 0, 60, 330]) { + // 2:00 in the afternoon, on the office's clock, on 29 September 2026. + const two = Date.UTC(2026, 8, 29, 14) - utcOffset * 60_000; + assert.equal(hourOfDay(skyTime(two, utcOffset), utcOffset), 0); + assert.equal(hourOfDay(skyTime(two + 15 * 60_000, utcOffset), utcOffset), 6); + assert.equal(hourOfDay(skyTime(two + 30 * 60_000, utcOffset), utcOffset), 12); + assert.equal(hourOfDay(skyTime(two + 45 * 60_000, utcOffset), utcOffset), 18); + // An hour on, it's back where it was. + assert.equal(skyTime(two + 20 * 60_000 + HOUR, utcOffset), skyTime(two + 20 * 60_000, utcOffset)); + // Still today's date, so the sun keeps the season's hours. + assert.equal(Math.floor((skyTime(two + 50 * 60_000, utcOffset) + utcOffset * 60_000) / DAY), Math.floor((two + utcOffset * 60_000) / DAY)); + } +}); + +test("the sky's day runs on smoothly past the office's midnight", () => { + const midnight = Date.UTC(2026, 8, 30) + 7 * HOUR; // midnight in Portland (UTC-7) + const before = skyTime(midnight - 1000, -420); + const after = skyTime(midnight, -420); + assert.ok(after > before && after - before < 30_000, `${after - before} ms of sky between one second and the next`); +}); + +test('the sun is up at half past and down on the hour', () => { + const lat = 40; + const lon = -120; // the middle of UTC-8, with summer time on + const noon = Date.UTC(2026, 8, 29, 17, 30); // 10:30 in Portland, half past the hour + assert.ok(sunPosition(skyTime(noon, -420), lat, lon).el > 0.5); + assert.ok(sunPosition(skyTime(noon - 30 * 60_000, -420), lat, lon).el < -0.5); +});