diff --git a/README.md b/README.md index fac9441a5..6494d8de0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ **A 3D office your team shares with its coding agents.** +The optional [ambient Dunder Mifflin cast](docs/ambient-office.md) works in an adjoining [Scranton replica suite](docs/scranton-extension.md), with its own working day, conversations and occasional celebrations. Coworkers face their conversation partners, gesture in speaking turns, react according to their relationships, and perform coordinated group moments. Wave near an idle coworker to get an acknowledgment. Enter through the **Dunder Mifflin →** doorway on the east wall between the Services board and TV, then follow the hallway. Use the **Dunder Mifflin** clock or **E** at a character for conversation, Pam's local task notes or a work brief; hide dialogue or coworkers for quiet work. + Sit **Claude Code**, **Codex**, **OpenCode**, **Grok**, **Muse**, **DeepSeek Harness** and **Cursor** workers at desks, watch each one's terminal on the laptop in front of it, and jump into any of them together. Every GitHub repo is a floor of the building. diff --git a/docs/ambient-office.md b/docs/ambient-office.md new file mode 100644 index 000000000..e5cee02bc --- /dev/null +++ b/docs/ambient-office.md @@ -0,0 +1,43 @@ +# Ambient Dunder Mifflin coworkers + +Back to the [README](../README.md) · [Features](features.md) + +The 3D office includes an independent background cast based on the supplied *Ambient NPC & Workplace Character Bible*. The staff work, chat, eat lunch, gather and go home without asking you to participate. They do not consume worker seats or start agent sessions. There are no quests, attendance requirements, notifications or camera takeovers. + +## Cast and era + +The fixed Season 6 ensemble includes Michael, Dwight, Jim, Pam, Ryan, Kelly, Angela, Kevin, Oscar, Stanley, Phyllis, Meredith, Creed, Andy, Toby and Erin. Darryl comes upstairs for shipments and breaks. Holly visits from Nashua; David Wallace visits from corporate. Pam works in administration and Erin handles reception. Jim and Pam are married; Dwight and Angela's private conversations do not disclose their history to the rest of the branch. The visitor beats are original simulation scenes, not claims about a particular episode. + +The bible's historical and later-era supporting cast (Jan, Karen, Roy, Jo, Gabe, Packer, Bob, Hank, Mose, the warehouse ensemble and Season 7–9 additions) is reference material for future era-specific scenes; those characters are not spawned in this ensemble. The component uses the existing cartoon character rigs, distinct clothing colors, hairstyles, nameplates and original dialogue rather than copied television scripts or audio. + +## Use it + +The cast works in its own [Scranton replica extension](scranton-extension.md). Walk through **Dunder Mifflin →** on the original office's east wall, between the Services board and TV, and follow the hallway north, then left into the annex. Reception, the bullpen, accounting, Michael's office, the conference room, kitchen and break room are all connected. These are dedicated character workstations, separate from the coding-agent desks. + +Click the **Dunder Mifflin** clock near the top left to open the coworker directory, or look at a coworker and press **E**. Say hello, ask about their day, or use their optional work scratchpad. **✕** and **Esc** close the window through the office's standard focus handling and restore mouse-look. + +**Show coworkers** hides or reveals the cast. **Quiet work** hides conversation bubbles while keeping staff visible. Both choices are saved in this browser. Ambient dialogue does not enter the shared chat or generate sound. Characters appear only on the regular office floor, not on the roof, in the garage, during elevator travel or on custom maps. The directory remains available during their off shift, without summoning absent staff. + +**Pam and Erin can organize notes into a task checklist.** Your notes are private to this browser and floor and are saved as you type. Each other coworker can prepare a role-appropriate work brief for you to copy to a connected worker. This is a local preparation tool, not an AI answer to the underlying request. The component cannot send messages, create reminders, schedule appointments, modify spreadsheets or act on external accounts. It explicitly reports that no external action occurred. If browser storage is blocked, the window tells you to copy your notes before closing. Clearing browser storage removes the notes and preferences. + +Helping pauses that present character's movement locally until you close the window or select another coworker. Everyone else continues their day. No cast member is hired as a coding agent, and no model calls or additional credentials are needed. + +## Independent working day + +The displayed clock runs a fictional 8:30–17:30 workday in **40 real minutes**. Arrivals are staggered; most time is ordinary work, with staggered lunches and departures. Stanley leaves at 17:00. The branch has a short after-hours interval before the next simulated day. These times are simulation choices, not canonical dates or the user's local clock. + +Scenes occupy part of a two-minute slot, leaving quiet intervals between conversations. Staff gather using the office's existing navigation grid and disperse back to their routines. Scenes cover reception handoffs, client calls, Jim/Dwight pranks, accounting checks, Kelly/Ryan misunderstandings, party planning, supplier orders, presentations, HR forms, a sales briefing and private conversations. Pretzel Day and a Dundies rehearsal occur on different virtual days, with most slots reserved for ordinary life. No fictional birthday date is labeled canon. + +Personalities also choose visible actions. Each exchange has a gathering interval, six-second speaking/reaction turns, a coordinated closing moment and a pause before everyone returns to work. Speakers and listeners turn toward each other once both arrive. Speech ends before the listener's reply gesture: Jim facepalms at Dwight's investigation; Dwight answers Jim by pointing; Pam and Erin acknowledge each other; Michael and Toby react awkwardly to each other. Stanley withholds applause during ordinary meetings but acknowledges Pretzel Day. The Dundies participants clap together, and Andy dances at the end of musical conversations while his coworkers respond in their own ways. These are original procedural sequences using the office's cartoon rigs, not imported animations or assets from the inspiration games. + +Use the existing player **Wave** emote within 3.5 meters of an idle coworker to get a visible acknowledgment. The nearest available coworker turns to you and waves or gives a thumbs up; Stanley simply looks over. There is a ten-second acknowledgment cooldown, and coworkers in an exchange, walking or helping you keep their attention on that activity. This acknowledgment is local to your browser. Quiet work hides dialogue while retaining body gestures, and hiding coworkers hides all of their performance. + +All gesture phases come from the same scheduled clock, so a late observer joins the current pose rather than restarting the gesture. Walking or helping takes priority over gestures. A speaker waits for a stationary, present conversation partner before displaying their bubble or gesture; missed beats are skipped rather than talking at an absent or moving partner. Shared timing does not make client-local movement server authoritative, and out-of-sync device clocks can still show different moments. + +The schedule is a pure function of wall-clock time and floor ID. Browsers with synchronized clocks on the same floor see the same scheduled conversations; movement and assistance are local, not server-authoritative multiplayer NPC positions. Reloading, hiding the cast or leaving a floor does not restart its schedule. The directory's **Around the branch** section reconstructs the last three completed scene outcomes, including the previous virtual day. This is bounded deterministic continuity, not open-ended character memory or a persistent narrative service. + +## Implementation and checks + +The module is `src/client/features/ambient-office/`: `cast.ts` describes characters; `scenes.ts` contains original group dialogue; `simulation.ts` schedules independent routines and outcomes; `choreography.ts` chooses relationship reactions and coordinated phases; `performance.ts` seeks the existing rig to the current gesture; `world.ts` animates the existing rigs and navigation; `ui.ts` and `notes.ts` implement optional conversation and local assistance. A space dependency from `features/scranton/` relocates routines into the replica and seats staff at their assigned workstations. The feature registers world ticks, moving usables and the `ambientnpc` interaction. No protocol, server or core-store changes are needed. + +`tests/ambient-office.test.ts` verifies deterministic schedules, offscreen continuity, autonomous group events, departures, rare rituals, cast identities and walkable paths, and meaningful checklist and handoff behavior. Validate changes with `npm run typecheck`, `npm test`, `npm run build` and a headless browser screenshot of the cast and directory. diff --git a/docs/features.md b/docs/features.md index f320d9b99..458390897 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,6 +2,8 @@ Everything in the office, room by room. Back to the [README](../README.md). +- **Ambient Dunder Mifflin coworkers.** Nineteen characters form a Season 6 ensemble with their own routines, staggered lunch breaks, group conversations, occasional Pretzel Day and Dundies rehearsals, and departures. They work in a connected [Scranton replica suite](scranton-extension.md), with reception, sales, accounting, Michael's office, conference room, kitchen and annex. Enter through **Dunder Mifflin →** between the Services board and TV, then follow the hallway. Michael, Jim, Dwight, Pam and the rest continue their day independently of the player. **E** at a coworker or the **Dunder Mifflin** clock opens optional conversation and a local work scratchpad. Pam and Erin turn notes into checklists; other staff prepare role-specific briefs for a connected worker. Notes are saved privately in your browser for each floor; no external scheduling or messaging occurs. The directory can hide coworkers or dialogue for quiet work. See [Ambient office](ambient-office.md) for the cast, simulated clock, capability boundaries and multiplayer behavior. + - **A floor per project.** The first time the office starts in a terminal it walks you through picking your first project (see [Run locally](../README.md#run-locally)). Otherwise you start inside the elevator, and it asks for one: pick one of the repositories your `gh` login can see (or type `owner/name`) and the office clones it into the workspace folder, `~/agent-office//` unless you pick another (**📁 Change folder** right there, for admins). While it clones, its floor on the panel says how far along it is (*Downloading 64% · 231 MiB · 1.5 MiB/s*), and you can close the panel and carry on: everyone hears when the floor opens. Whoever added it, or an admin, can stop it with the **⏹️** beside it. A clone that hears nothing from GitHub for 3 minutes is stopped as stalled, one that would have to ask something (an ssh host key, a password) fails straight away and says what to run on the office's machine, and restarting the office doesn't stop one. To add another project, or go to one, walk into the elevator on the north wall and press **E**. Or click the project name in the top-left corner for the list of floors: pick one and you're there in a blink, standing on the same spot in the office. From outside it (the balcony, the street, the garage) it takes you in, and you step out of that floor's elevator. Each floor has its own desks, workers, issues and PR boards, task queue, services and pictures, and its own wall and floor colors, so you always know where you are. You only see and hear the people on your floor. The elevator panel shows how many workers are busy or waiting on someone on each floor, and you get a heads-up when a worker on another floor starts waiting. An admin can take a project off the building with the **🗑** beside its floor in the elevator: its workers stop, and everyone on it rides the elevator to another floor. Nothing is deleted. The checkout stays on disk with its workers, queue and pictures, and adding the same repository again moves back into it (as long as the workspace folder hasn't moved since). That goes for the project the office was started in (`agent-office `) too: it stays off after a restart, and the office keeps its own data in that folder as before. - **Walk around.** Use WASD, Space to jump, and drag the mouse to orbit the camera. Everyone in the office sees everyone else move in real time. - **A quiet screen.** The office stays in view: the top bar holds just the floor you're on, **🤖 Workers** and the **☰** menu (or **Tab**). The menu opens every window (issues, PRs, the queue, services, the whiteboard, search, voice, screen sharing, settings) and lists what can show on screen: the workers, who's in the office, spend, Claude limits, the chat and the floor details. Turn each one on or off there, or hide a panel with its **✕**. Pin anything you use a lot to the top bar. The choices are kept in your browser. Chat lines fade after a few seconds; hover the chat or press **T** to read back. What needs you shows up on the top bar by itself: the workers waiting on someone (click to go to them, like **N**), the mute button while you're in voice, **Sharing** while you share your screen, and **⬆️ Update** when there's a new version. diff --git a/docs/scranton-extension.md b/docs/scranton-extension.md new file mode 100644 index 000000000..f390bd4d2 --- /dev/null +++ b/docs/scranton-extension.md @@ -0,0 +1,34 @@ +# The Scranton room extension + +Back to the [README](../README.md) · [Ambient office](ambient-office.md) + +The Dunder Mifflin cast has a dedicated replica of its television workspace adjoining the original Agent Office. The existing coding-agent desks, boards, lounge, elevator, garage, rooftop and expandable back office remain available. The new suite is permanent on the regular office map and does not occupy any hiring slots. + +## Find it + +On the east wall, between the **Services** board and the **TV**, walk through the doorway labeled **Dunder Mifflin →**. Follow the enclosed hallway north, then turn left into the annex. From there, the kitchen connects to the sales bullpen, reception, Michael's office and the conference room. Walk back through the same hallway to return to your coding agents. There is no map switch or teleport. + +## Where the staff works + +- **Reception:** a curved wooden counter, desk phone, computer, waiting couch and Dunder Mifflin sign. Erin staffs reception in the Season 6 ensemble. +- **Sales bullpen:** Jim and Dwight sit on opposing sides of their pod; Andy and Pam occupy the next pod. Stanley and Phyllis sit together, with Meredith and Creed in the copier/quality-assurance area. +- **Accounting:** Angela, Oscar and Kevin have individual desks beside one another. +- **Michael's office:** a private workspace beside reception, with glass frontage, horizontal blinds, his nameplate and boss mug. +- **Conference room:** beside Michael's office, with glass and blinds, rows of chairs and a whiteboard. Staff take their seats for ambient meetings here. +- **Kitchen and break room:** counters, fridge, tables and vending machines; staff meet here during breaks and office celebrations. +- **Annex:** HR and customer-service workstations for Toby and Kelly, with a visiting workspace for Darryl. Ryan has a small closet office off the kitchen. +- **Building details:** the stairwell core and restroom partitions complete the recognizable room arrangement. The stair core is scenery; travel between project floors still uses the original elevator. + +The room layout uses [RoomSketcher's published Dunder Mifflin floor plan](https://www.roomsketcher.com/blog/american-the-office-floor-plan/) as a visual reference, alongside the supplied character bible. The reconstruction uses original procedural geometry, scaled to the game's avatars and navigation clearances rather than surveyed set measurements. It contains no downloaded television imagery or video. Furniture, signs, desktop computers, blinds, fluorescent fittings and props are created in code. + +## Routines and interaction + +Character homes, lunch spots and scene destinations all point into the replica. During ordinary work they sit at their assigned desks; meetings use the conference chairs. The original ambient schedule and optional work scratchpad continue unchanged. The **Dunder Mifflin** clock stays available on the main office floor, so you can open the directory without walking over. Quiet mode hides dialogue; hiding coworkers leaves the replica room in place. + +Player collision uses the extension's floor, ceiling, partitions and furniture. Its navigation grid uses those same wall and furniture footprints. The camera recognizes the suite and narrow connecting corridor as indoor spaces and restores the original room bounds on return. Indoor lighting and sound also follow the visitor into the extension. Staff movement and assistance remain local to each browser; other real players still share their usual multiplayer positions. + +## Code and verification + +The feature is `src/client/features/scranton/`: `world.ts` is an office fixture, `props.ts` makes the furniture, `batch.ts` batches solid static geometry while retaining lettering textures and transparent panes, `routines.ts` adapts the cast's spatial dependency, and `index.ts` registers camera and indoor-context handling. Shared physical data is in `src/shared/scranton.ts`. The fixture joins the floor-build registry, its east doorway joins `src/shared/office-openings.ts`, and `src/shared/office-footprints.ts` keeps scenery trees out of the extension. The ambient feature accepts the room through a small space dependency. + +`tests/scranton.test.ts` checks that every character has a reachable workstation, room connections remain open, seating matches the chosen era, and all autonomous scenes stay within the replica. Headless-browser verification walks through the actual original-office doorway and corridor, checks grounded entry and camera restoration, and captures the replica's floor plan and bullpen. Run `npm run typecheck`, `npm test` and `npm run build` as usual. diff --git a/src/client/features/ambient-office/cast.ts b/src/client/features/ambient-office/cast.ts new file mode 100644 index 000000000..d01ca641b --- /dev/null +++ b/src/client/features/ambient-office/cast.ts @@ -0,0 +1,47 @@ +import type { Look } from '../../../shared/avatar'; + +export interface Coworker { + id: string; + name: string; + job: string; + personality: string; + routine: readonly string[]; + greeting: string; + utility: string; + color: string; + look: Look; + home: [number, number]; + visitor?: boolean; + birthday: null; + dessert?: string; +} + +function person(id: string, name: string, job: string, personality: string, routine: string[], greeting: string, utility: string, color: string, look: Look, home: [number, number], extra: Partial = {}): Coworker { + return { id, name, job, personality, routine, greeting, utility, color, look, home, birthday: null, ...extra }; +} + +/** Season 6 ensemble: Pam in administration, Erin at reception, Jim and Pam married. + * Holly visits from Nashua; David visits from corporate. No invented canon birthdays. */ +export const CAST: readonly Coworker[] = [ + person('michael', 'Michael Scott', 'Regional manager', 'Theatrical, affectionate and insecure; a capable relationship salesman who wants a family at work.', ['Calling a longtime client', 'Preparing an unnecessary morale speech', 'Waiting for a corporate callback'], 'Welcome to our very productive family. I was just about to prove the productive part.', 'Brainstorming and morale', '#374867', { skin: 0, hair: 1, style: 0 }, [1, -1]), + person('dwight', 'Dwight Schrute', 'Sales · assistant to the regional manager', 'Literal, competitive and industrious; protective of coworkers, suspicious of Jim, privately tender with Angela.', ['Following up a paper order', 'Inspecting the supply checklist', 'Checking a message from the farm'], 'State your purpose. A clear purpose saves both of us valuable time.', 'Procedural checklists and preparation', '#c3a24c', { skin: 0, hair: 2, style: 0 }, [-4, -5.8]), + person('jim', 'Jim Halpert', 'Sales representative', 'Perceptive, understated and mischievous; cares about Pam, clients and life outside work.', ['Returning a sales call', 'Checking a customer quote', 'Glancing at Dwight’s supplies'], 'Hey. If this is about the new paperclip policy, I also have questions.', 'Clear communication and summaries', '#91b1ce', { skin: 0, hair: 2, style: 3 }, [-6.6, -5.8]), + person('pam', 'Pam Beesly', 'Office administration · your optional PA', 'Warm, organized and artistic; diplomatic with Michael and quietly witty with Jim.', ['Passing Michael a callback note', 'Sketching between supply orders', 'Checking the office calendar'], 'Hi! I can keep your notes and help organize what needs doing. Michael’s messages can wait a minute.', 'Personal notes and task organization', '#ddb7c7', { skin: 0, hair: 2, style: 5 }, [1, -7.5]), + person('ryan', 'Ryan Howard', 'Office employee', 'Detached, image-conscious and ambitious; evasive with Kelly, more confident than his plans warrant.', ['Checking his phone', 'Revising an elevator pitch', 'Looking occupied'], 'I’m thinking about the next big thing. There may eventually be a document.', 'Marketing concepts and pitches', '#677989', { skin: 0, hair: 0, style: 0 }, [-8, 6.2]), + person('kelly', 'Kelly Kapoor', 'Customer service', 'Expressive, socially perceptive and dramatic; romantic reassurance and pop culture are never far away.', ['Answering a customer question', 'Discussing a celebrity relationship', 'Deciding what Ryan’s message means'], 'Hi! Tell me everything. Start with the interesting part, then we can do the practical part.', 'Customer tone and social copy', '#e6719c', { skin: 4, hair: 0, style: 1 }, [-10, 6.2]), + person('angela', 'Angela Martin', 'Senior accountant', 'Meticulous, severe and controlled; loves cats, dislikes disorder and guards her private life.', ['Reviewing the expense ledger', 'Correcting a decoration order', 'Checking on her cats'], 'If you have a request, please make it specific. And keep the crumbs away from the paperwork.', 'Expense preparation and careful formatting', '#c7c6af', { skin: 0, hair: 3, style: 2 }, [-14, -1.5]), + person('kevin', 'Kevin Malone', 'Accountant', 'Relaxed, friendly and food-loving; a musician with occasional playful insight, not a financial authority.', ['Entering receipts', 'Discussing band practice', 'Considering a snack break'], 'Hey. We can make the complicated part smaller. Then maybe take a break.', 'Simple ideas and low-stakes morale', '#a3b287', { skin: 0, hair: 2, style: 6 }, [-12.5, -1.5]), + person('oscar', 'Oscar Martinez', 'Accountant', 'Rational, articulate and occasionally smug; patient with Kevin and skeptical of unsupported claims.', ['Reconciling an invoice', 'Checking a budget assumption', 'Reading during a quiet moment'], 'Happy to look at the assumptions. It helps if we start with the numbers we actually have.', 'Analysis and budget comparisons', '#ab9cb5', { skin: 3, hair: 0, style: 0 }, [-11, -1.5]), + person('stanley', 'Stanley Hudson', 'Sales representative', 'Private, weary and direct; values good sales, crosswords, dessert and going home on time.', ['Returning a client call', 'Working on a crossword', 'Watching the clock'], 'If it’s work, get to the point. If it’s a meeting, tell me when it ends.', 'Focused routine review', '#b59683', { skin: 6, hair: 0, style: 6 }, [-14, -6.5], { dessert: 'Cheesecake' }), + person('phyllis', 'Phyllis Vance', 'Sales representative', 'Soft-spoken, sociable and quietly political; fond of Bob and capable of a sharp second meaning.', ['Calling a regular client', 'Knitting between calls', 'Discussing lunch with Bob'], 'Hello, dear. A thoughtful follow-up can do a lot. Bob always remembers the little details.', 'Client follow-ups and hospitality', '#c292a5', { skin: 0, hair: 2, style: 4 }, [-12, -6.5]), + person('meredith', 'Meredith Palmer', 'Supplier relations', 'Frank, resilient and unembarrassed; practical about suppliers and uninterested in being judged.', ['Checking a supplier delivery', 'Making weekend plans', 'Sorting purchase records'], 'Tell me what you need from the supplier. We can skip the fancy part.', 'Supplier preparation and blunt feedback', '#bd7863', { skin: 0, hair: 4, style: 1 }, [-6, 6.2], { dessert: 'Devil’s food cake' }), + person('creed', 'Creed Bratton', 'Quality assurance', 'Cryptic and unpredictable; protects his privacy and has unexplained interests in ordinary objects.', ['Inspecting a mysterious envelope', 'Sorting unusual objects', 'Doing something vaguely like quality assurance'], 'Good to see you. I’ve put the ordinary objects in a very safe ordinary place.', 'Surreal creative prompts', '#838d78', { skin: 0, hair: 5, style: 0 }, [-4, 6.2], { dessert: 'Peach pie or cobbler (his claim)' }), + person('andy', 'Andy Bernard', 'Sales representative', 'Eager, musical and approval-seeking; proud of Cornell and more sensitive than his showmanship suggests.', ['Following up a sales lead', 'Humming an original warm-up', 'Rehearsing a presentation'], 'Ready for a rehearsal? I can be an audience of one with the enthusiasm of a much larger audience.', 'Presentation rehearsal', '#dfb69b', { skin: 0, hair: 2, style: 0 }, [-2, 6.2], { dessert: 'Fudgie the Whale ice-cream cake' }), + person('toby', 'Toby Flenderson', 'Human resources', 'Quiet, patient and cautious; persists with paperwork despite Michael’s hostility.', ['Filing employee forms', 'Reviewing an HR notice', 'Working on a writing draft'], 'Hi. We can probably find a considerate way to say it. Let’s start with what happened.', 'Sensitive communication and HR checklists', '#b5ab93', { skin: 0, hair: 2, style: 0 }, [-15, 2]), + person('darryl', 'Darryl Philbin', 'Warehouse foreman', 'Grounded, succinct and competent; values safe shipments, practical plans and music.', ['Checking the shipment manifest', 'Coordinating a delivery downstairs', 'Reviewing a loading schedule'], 'Give me the deadline and what needs moving. Then we can make a plan that actually fits.', 'Logistics and workflow preparation', '#648399', { skin: 6, hair: 0, style: 0 }, [7, -2], { visitor: true }), + person('erin', 'Erin Hannon', 'Receptionist', 'Cheerful, earnest and emotionally perceptive; likes clear instructions and being included.', ['Transferring a caller to sales', 'Welcoming a delivery', 'Writing down a message'], 'Hello! I can write that down. Is there an order you want the steps in?', 'Message intake and basic organization', '#8fbfa5', { skin: 0, hair: 2, style: 1 }, [3, -7.5]), + person('holly', 'Holly Flax', 'HR · visiting from Nashua', 'Compassionate and playful; shares Michael’s strange humor while taking people seriously.', ['Visiting the branch', 'Checking in with a colleague', 'Trading a quiet joke with Michael'], 'Hi! We can take the people part seriously and still leave room for a little humor.', 'People-focused drafting', '#d6bd77', { skin: 0, hair: 3, style: 1 }, [3, -1], { visitor: true }), + person('david', 'David Wallace', 'Corporate CFO · occasional visitor', 'Calm, capable and diplomatic; asks practical questions and gives Michael unusual latitude.', ['Reviewing the branch figures', 'Discussing client retention', 'Waiting for Michael’s explanation'], 'What decision are we trying to make, and what information would change it?', 'Executive prioritization and business review', '#465368', { skin: 0, hair: 1, style: 0 }, [5, -1], { visitor: true }), +]; + +export const CAST_BY_ID = new Map(CAST.map((p) => [p.id, p])); diff --git a/src/client/features/ambient-office/choreography.ts b/src/client/features/ambient-office/choreography.ts new file mode 100644 index 000000000..8e9668421 --- /dev/null +++ b/src/client/features/ambient-office/choreography.ts @@ -0,0 +1,75 @@ +import { EMOTE_BY_ID, type EmoteId } from '../../../shared/emotes'; +import type { Scene } from './scenes'; + +/** Behavioral choices, not biography: each coworker initiates and responds differently. */ +export const MANNER: Readonly> = { + michael: { speaks: 'wave', responds: 'clap' }, dwight: { speaks: 'point', responds: 'point' }, + jim: { speaks: 'thumbs', responds: 'facepalm' }, pam: { speaks: 'point', responds: 'thumbs' }, + ryan: { speaks: 'point', responds: 'facepalm' }, kelly: { speaks: 'wave', responds: 'point' }, + angela: { speaks: 'point', responds: 'facepalm' }, kevin: { speaks: 'wave', responds: 'clap' }, + oscar: { speaks: 'point', responds: 'point' }, stanley: { speaks: 'point', responds: null }, + phyllis: { speaks: 'thumbs', responds: 'thumbs' }, meredith: { speaks: 'thumbs', responds: 'wave' }, + creed: { speaks: 'wave', responds: 'thumbs' }, andy: { speaks: 'wave', responds: 'clap' }, + toby: { speaks: 'point', responds: 'thumbs' }, darryl: { speaks: 'point', responds: 'thumbs' }, + erin: { speaks: 'wave', responds: 'thumbs' }, holly: { speaks: 'wave', responds: 'clap' }, + david: { speaks: 'point', responds: 'thumbs' }, +}; + +const REPLIES: Readonly> = { + 'dwight:jim': 'facepalm', 'jim:dwight': 'point', 'michael:stanley': null, + 'michael:toby': 'facepalm', 'toby:michael': 'facepalm', 'ryan:kelly': 'point', + 'kelly:ryan': 'facepalm', 'pam:erin': 'thumbs', 'erin:pam': 'thumbs', + 'angela:kevin': 'facepalm', 'kevin:angela': 'facepalm', 'holly:michael': 'clap', +}; + +export interface GestureCue { key: string; emote: EmoteId; age: number } +export interface Performance { + stage: 'gather' | 'dialogue' | 'reaction' | 'ensemble' | 'settle'; + speaker?: string; + listener?: string; + beat: number; + cues: Readonly>; +} + +export const TALK_AT = 35000; +export const BEAT_MS = 6000; +export const RESPONSE_AT = 3600; + +/** One shared clock drives the whole exchange, including reloads in the middle of a gesture. */ +export function perform(scene: Scene, elapsed: number, instance: number): Performance { + const members = [...new Set(scene.beats.map(b => b.who))]; + const beat = Math.floor((elapsed - TALK_AT) / BEAT_MS); + const cues: Record = {}; + const cue = (who: string, emote: EmoteId | null, age: number, suffix: string) => { + if (emote && age >= 0 && age < EMOTE_BY_ID.get(emote)!.seconds) { + cues[who] = { key: `${instance}:${scene.id}:${suffix}:${who}`, emote, age }; + } + }; + if (elapsed < TALK_AT) return { stage: 'gather', beat: -1, cues }; + if (beat < scene.beats.length) { + const speaker = scene.beats[beat].who; + const listener = [...scene.beats.slice(beat + 1), ...scene.beats.slice(0, beat)].find(b => b.who !== speaker)?.who; + const within = (elapsed - TALK_AT) % BEAT_MS; + const responding = within >= RESPONSE_AT; + if (!responding) cue(speaker, MANNER[speaker].speaks, within / 1000, `${beat}:speak`); + else if (listener) { + const pair = `${speaker}:${listener}`; + const reply = Object.hasOwn(REPLIES, pair) ? REPLIES[pair] : MANNER[listener].responds; + cue(listener, reply, (within - RESPONSE_AT) / 1000, `${beat}:reply`); + } + return { stage: responding ? 'reaction' : 'dialogue', speaker, listener, beat, cues }; + } + const age = (elapsed - TALK_AT - scene.beats.length * BEAT_MS) / 1000; + if (age < 4) { + for (const id of members) { + const gesture = scene.id === 'music' || scene.id === 'rehearsal' + ? (id === 'andy' ? 'dance' : MANNER[id].responds) + : scene.id === 'dundies' ? (id === 'michael' ? 'wave' : 'clap') + : scene.id === 'pretzels' ? (id === 'stanley' ? 'thumbs' : 'clap') + : MANNER[id].responds; + cue(id, gesture, age, 'ensemble'); + } + return { stage: 'ensemble', beat, speaker: members[0], cues }; + } + return { stage: 'settle', beat, cues }; +} diff --git a/src/client/features/ambient-office/index.ts b/src/client/features/ambient-office/index.ts new file mode 100644 index 000000000..28b0d753d --- /dev/null +++ b/src/client/features/ambient-office/index.ts @@ -0,0 +1,54 @@ +import type { Ctx } from '../../core/context'; +import { aside, hintTitle, key, onE } from '../../core/hint'; +import { store } from '../../state'; +import { CAST_BY_ID } from './cast'; +import { readPreferences } from './notes'; +import { sampleOffice } from './simulation'; +import { ambientPanel } from './ui'; +import { AmbientWorld, type AmbientSpace } from './world'; + +declare module '../../world/types' { + interface InteractKinds { + ambientnpc: true; + } +} + +export function installAmbientOffice(ctx: Ctx, space?: AmbientSpace) { + const preferences = readPreferences(); + const world = new AmbientWorld(space); + ctx.scene.add(world.root); + let busy: string | null = null; + const sample = () => { + const next = sampleOffice(Date.now(), store.floor ?? 'office'); + return space?.adapt(next) ?? next; + }; + let snapshot = sample(); + let sampledAt = 0; + const panel = ambientPanel({ snapshot: () => snapshot, floor: () => store.floor ?? 'office', preferences, busy: (id) => { busy = id; } }); + + ctx.usables.add({ usable: () => world.usable(), pickable: () => world.root }); + ctx.interactions.define('ambientnpc', { + reach: 3.5, + hint: (it) => { + const p = CAST_BY_ID.get(it.decorId ?? ''); + return { k: `ambient:${p?.id}`, parts: [hintTitle(p?.name ?? 'Coworker'), aside('optional · their day continues'), key('E', 'Say hello / work scratchpad')] }; + }, + use: onE((it) => panel.open(it.decorId)), + }); + store.on('floor', () => { + panel.close(); + sampledAt = 0; + busy = null; + }); + ctx.ticks.add('world', ({ dt, t }) => { + const now = Date.now(); + if (now - sampledAt >= 300 || sampledAt === 0) { + sampledAt = now; + snapshot = sample(); + panel.paint(); + } + const available = !!store.floor && ctx.inOffice() && !ctx.upTop() && !ctx.trip() && ctx.player.pos.y > -1; + panel.badge.hidden = !available; + world.update(snapshot, dt, t, available && preferences.enabled, preferences.quiet, busy, ctx.player.pos, ctx.me.emoteId); + }); +} diff --git a/src/client/features/ambient-office/notes.ts b/src/client/features/ambient-office/notes.ts new file mode 100644 index 000000000..32c27a91d --- /dev/null +++ b/src/client/features/ambient-office/notes.ts @@ -0,0 +1,31 @@ +import type { Coworker } from './cast'; + +export interface Preferences { enabled: boolean; quiet: boolean } +const PREF_KEY = 'agent-office.ambient.preferences'; +export function readPreferences(): Preferences { + try { + const p = JSON.parse(localStorage.getItem(PREF_KEY) ?? '{}'); + return { enabled: p?.enabled !== false, quiet: p?.quiet === true }; + } catch { return { enabled: true, quiet: false }; } +} +export function savePreferences(p: Preferences) { + try { localStorage.setItem(PREF_KEY, JSON.stringify(p)); } catch { /* Browser storage can be blocked. */ } +} +const noteKey = (floor: string) => `agent-office.ambient.notes.${floor}`; +export function readNotes(floor: string): string { + try { return (localStorage.getItem(noteKey(floor)) ?? '').slice(0, 12000); } catch { return ''; } +} +export function saveNotes(floor: string, value: string): boolean { + try { localStorage.setItem(noteKey(floor), value.slice(0, 12000)); return true; } catch { return false; } +} + +/** An actual local task organizer, not an assertion that an external action happened. */ +export function checklist(text: string): string { + return text.split('\n').map((line) => line.trim().replace(/^(?:[-*•]|\d+[.)]|\[[ xX]\])\s*/, '').trim()) + .filter(Boolean).map((line) => `☐ ${line}`).join('\n'); +} + +/** Prepare a handoff the user can copy to an existing worker. No pretend AI answer. */ +export function workBrief(p: Coworker, text: string): string { + return `Work brief · ${p.utility}\n\nRequest:\n${text.trim()}\n\nApproach:\n${p.id === 'dwight' ? 'Turn the request into an ordered checklist. Check prerequisites, missing information and completion criteria.' : p.id === 'jim' ? 'Use concise, plain language. State the main point and concrete next steps.' : p.id === 'oscar' ? 'Separate facts from assumptions. Show calculations and identify missing inputs.' : p.id === 'angela' ? 'Check categories, consistent formatting and required records. Flag uncertain entries.' : p.id === 'stanley' ? 'Keep the review focused. Identify only the changes needed to finish the task.' : p.id === 'toby' || p.id === 'holly' ? 'Use considerate language, protect private information and ask about relevant policy rather than inventing it.' : p.id === 'darryl' ? 'List dependencies, owners, deadlines and a practical order of operations.' : p.id === 'creed' ? 'Explore unusual creative ideas. Label speculation; do not treat this as compliance advice.' : p.id === 'kevin' ? 'Simplify the idea into a few approachable steps. Keep this low-stakes; do not use it as financial advice.' : p.id === 'ryan' ? 'Explore marketing concepts and pitches. Verify factual claims before using them.' : p.id === 'david' ? 'Clarify the decision, priorities, tradeoffs and the evidence that would change the recommendation.' : p.id === 'andy' ? 'Prepare a concise presentation outline and rehearsal questions. Check that the pitch matches the evidence.' : `Prepare a practical draft using ${p.utility.toLowerCase()}. Ask for missing details.`}\n\nExternal actions require a connected worker and your authorization. This scratchpad has not sent messages, scheduled events or changed external records.`; +} diff --git a/src/client/features/ambient-office/performance.ts b/src/client/features/ambient-office/performance.ts new file mode 100644 index 000000000..c9a4e8c50 --- /dev/null +++ b/src/client/features/ambient-office/performance.ts @@ -0,0 +1,22 @@ +import type { Person } from '../../world/character'; +import type { GestureCue } from './choreography'; + +/** Seek the existing rig through its public API; late observers join the current shared phase. */ +export class GesturePlayer { + private key = ''; + private age = 0; + + update(person: Person, cue: GestureCue | undefined, dt: number, t: number, moving: boolean) { + if (!cue) { + if (this.key) person.update(5, t, moving, false); // Finish a canceled emote before the base pose. + this.key = ''; + person.update(dt, t, moving, false); + return; + } + const restart = this.key !== cue.key || cue.age < this.age; + if (restart) person.emote(cue.emote); + person.update(restart ? cue.age : cue.age - this.age, t, moving, false); + this.key = cue.key; + this.age = cue.age; + } +} diff --git a/src/client/features/ambient-office/scenes.ts b/src/client/features/ambient-office/scenes.ts new file mode 100644 index 000000000..51e10d3ac --- /dev/null +++ b/src/client/features/ambient-office/scenes.ts @@ -0,0 +1,37 @@ +export interface Beat { who: string; text: string } +export interface Scene { + id: string; + title: string; + place: 'reception' | 'bullpen' | 'kitchen' | 'meeting' | 'annex'; + beats: readonly Beat[]; + aftermath: string; + ritual?: boolean; +} + +const scene = (id: string, title: string, place: Scene['place'], aftermath: string, lines: [string, string][], ritual = false): Scene => ({ + id, title, place, aftermath, beats: lines.map(([who, text]) => ({ who, text })), ritual, +}); + +/** Original dialogue. Small work conversations dominate; big rituals occur on different virtual days. */ +export const SCENES: readonly Scene[] = [ + scene('callback', 'The callback note', 'reception', 'Michael finally returned the customer’s call.', [['pam', 'Michael, your customer called again. I put the number on the yellow note.'], ['michael', 'Excellent. Yellow means important. Or cheerful. Both apply.'], ['erin', 'I kept the line free for the callback.'], ['pam', 'Good. Let’s try important first.']]), + scene('paperclips', 'Supply investigation', 'bullpen', 'Dwight is still counting paperclips after Jim’s explanation.', [['dwight', 'My paperclips are now sorted by size. Someone has interfered with my system.'], ['jim', 'Could the system have improved itself?'], ['dwight', 'Improvement requires written authorization.'], ['jim', 'I’ll let the paperclips know.']]), + scene('ledger', 'Accounting checks', 'bullpen', 'Oscar helped Kevin reconcile the invoice; Angela checked it again.', [['angela', 'This invoice is not a place for a coffee cup.'], ['kevin', 'I was holding the page down. Very stable.'], ['oscar', 'The total is right. The coffee ring is a separate problem.'], ['angela', 'They are both now my problem.']]), + scene('client', 'A regular customer', 'bullpen', 'Phyllis and Stanley finished the client follow-up without another meeting.', [['phyllis', 'They reordered the same stock. I remembered their delivery day.'], ['stanley', 'Good. Call done, customer happy.'], ['phyllis', 'Bob says remembering is half the job.'], ['stanley', 'The other half is letting me finish mine.']]), + scene('ryan-message', 'A message with punctuation', 'annex', 'Kelly is reconsidering the meaning of Ryan’s very short text.', [['kelly', 'He put a period after okay. That is a whole emotional decision.'], ['ryan', 'It is punctuation. I have a call.'], ['phyllis', 'Perhaps he really does have a call.'], ['kelly', 'That would be a new interpretation.']]), + scene('shipment', 'The shipment window', 'reception', 'Darryl got the order numbers, and the shipment stayed on schedule.', [['darryl', 'The truck leaves at two. I need the order numbers, not a theme.'], ['michael', 'What if the theme is excellent shipping?'], ['erin', 'I have the order numbers right here.'], ['darryl', 'That’s the theme I can use.']]), + scene('hr-form', 'A very ordinary HR form', 'annex', 'Toby left the form for Michael to sign after the client call.', [['toby', 'It’s just a signature confirming you received the notice.'], ['michael', 'Why does receiving something involve so much receiving?'], ['toby', 'One signature.'], ['pam', 'I’ll put it with the callbacks.']]), + scene('rehearsal', 'Presentation practice', 'bullpen', 'Andy shortened his opening and saved the singing for later.', [['andy', 'What if I open the sales pitch with a small musical welcome?'], ['jim', 'How small?'], ['andy', 'Smaller than the full arrangement.'], ['oscar', 'Try the customer’s question first.']]), + scene('supplier', 'Supplier follow-up', 'kitchen', 'Meredith confirmed the delivery before anyone planned a celebration.', [['meredith', 'The cartons arrive tomorrow. I asked the actual supplier.'], ['creed', 'Tomorrow is an excellent day for cartons. Nobody suspects tomorrow.'], ['oscar', 'Do we have a confirmation number?'], ['meredith', 'Yes. That’s why I asked the actual supplier.']]), + scene('lunch', 'The cake box', 'kitchen', 'Kevin left the cake for the party; Oscar found a clean fork.', [['kevin', 'I was checking that the cake was still cake.'], ['angela', 'The box did not require opening.'], ['oscar', 'It can remain cake until tomorrow.'], ['kevin', 'That is a long assignment for cake.']]), + scene('sketch', 'A quiet minute', 'reception', 'Pam went back to her sketch after Jim’s client call.', [['jim', 'Is that the office, or the office after we finally get a quiet afternoon?'], ['pam', 'The quiet version. I had to use my imagination.'], ['jim', 'Looks nice. My customer is calling back.'], ['pam', 'Good luck. Save me a quiet minute later.']]), + scene('committee', 'Party planning', 'kitchen', 'Phyllis kept the decoration list; Angela kept the revised budget.', [['angela', 'Two colors. No glitter. A reasonable quantity of napkins.'], ['phyllis', 'I wrote down everyone’s requests, too.'], ['andy', 'Mine includes an ice-cream whale.'], ['meredith', 'And mine still says devil’s food.']]), + scene('meeting', 'A short sales briefing', 'meeting', 'The sales briefing ended when a customer called; everyone returned to work.', [['michael', 'Today’s agenda is a stronger sense of togetherness. And paper.'], ['dwight', 'I prepared the paper totals.'], ['oscar', 'Which decision needs those totals?'], ['stanley', 'The decision to end the meeting.'], ['michael', 'A customer is calling. That is also togetherness.']]), + scene('pretzels', 'Pretzel Day', 'kitchen', 'Stanley’s unusually good mood lasted through his afternoon calls.', [['stanley', 'The pretzel cart is here.'], ['phyllis', 'I thought you looked happier.'], ['michael', 'This is the kind of morale initiative I fully support.'], ['stanley', 'Let the line keep moving.']], true), + scene('dundies', 'A small Dundies rehearsal', 'meeting', 'Michael left his practice award on the counter and thanked Pam sincerely.', [['michael', 'Tonight’s practice award is for outstanding message-taking.'], ['pam', 'Thank you. You still have two messages.'], ['dwight', 'I can certify the award results.'], ['michael', 'Actually, Pam, thank you for keeping everything going.']], true), + scene('corporate', 'Corporate visit', 'reception', 'David asked for the client figures; Michael delivered them after a long introduction.', [['david', 'How are the repeat customers doing?'], ['michael', 'Very repeat. We have built actual relationships.'], ['jim', 'The retention figures are ready.'], ['david', 'Good. Let’s look at those together.']]), + scene('holly', 'An HR visit', 'annex', 'Holly and Michael traded a joke before she headed back to Nashua.', [['holly', 'I brought the form. It is tragically lacking a funny voice.'], ['michael', 'I have several available for immediate employment.'], ['holly', 'Maybe one after we finish the form.'], ['toby', 'That would be helpful.']]), + scene('private', 'A quiet supply question', 'bullpen', 'Dwight and Angela exchanged a private note; coworkers learned nothing new.', [['dwight', 'The item you requested is in the usual place.'], ['angela', 'Then there is nothing else to discuss here.'], ['dwight', 'Understood.'], ['angela', 'Thank you.']]), + scene('music', 'A band question', 'kitchen', 'Kevin and Darryl made plans to compare chord changes after work.', [['kevin', 'We changed the ending. It sounds better now.'], ['darryl', 'Leave some space before the last chord.'], ['andy', 'I could add a harmony.'], ['darryl', 'Try the space first.']]), + scene('welcome', 'Reception handoff', 'reception', 'Erin handled the visitor message and Pam returned to the supply list.', [['erin', 'I wrote the visitor’s name and who they want to see.'], ['pam', 'Perfect. Now let sales know they’re here.'], ['erin', 'In that order. Got it.'], ['pam', 'You’ve got this.']]), +]; diff --git a/src/client/features/ambient-office/simulation.ts b/src/client/features/ambient-office/simulation.ts new file mode 100644 index 000000000..c21c94d11 --- /dev/null +++ b/src/client/features/ambient-office/simulation.ts @@ -0,0 +1,101 @@ +import { CAST, type Coworker } from './cast'; +import { SCENES, type Scene } from './scenes'; +import { perform, type Performance, type GestureCue } from './choreography'; + +export const DAY_MS = 40 * 60 * 1000; +export const SLOT_MS = 120 * 1000; +const START = 8 * 60 + 30; +const WORK_MINUTES = 9 * 60; +export const PLACES = { + reception: [2, -7.5], bullpen: [-7.5, 0], kitchen: [-13.5, 9.6], + meeting: [10.7, 9.1], annex: [-8, 7.4], +} satisfies Record; + +export interface AmbientState { + person: Coworker; + present: boolean; + activity: string; + target: [number, number]; + line?: string; + seat?: { hips: number; facing: number }; + attention?: string; + gesture?: GestureCue; +} +export interface Snapshot { + sampledAt: number; + day: number; + minute: number; + clock: string; + phase: 'arrival' | 'work' | 'lunch' | 'departure' | 'after-hours'; + scene?: Scene; + performance?: Performance; + people: AmbientState[]; + memories: string[]; +} + +function hash(seed: string): number { + let n = 2166136261; + for (const c of seed) n = Math.imul(n ^ c.charCodeAt(0), 16777619); + return n >>> 0; +} + +export function sceneFor(day: number, slot: number, floor: string): Scene { + // One occasional ritual per five days; no fabricated birthday or holiday dates. + if (slot === 11 && day % 5 === 0) return SCENES.find((s) => s.id === 'pretzels')!; + if (slot === 11 && day % 5 === 3) return SCENES.find((s) => s.id === 'dundies')!; + const ordinary = SCENES.filter((s) => !s.ritual); + return ordinary[(hash(floor) + day * 7 + slot * 5) % ordinary.length]; +} + +/** Pure, wall-clock driven schedule: works offscreen, survives reloads and costs no model calls. + * All browsers on a floor get the same beats; assistance and visual movement stay local. */ +export function sampleOffice(timestamp: number, floor: string): Snapshot { + const day = Math.floor(timestamp / DAY_MS); + const within = ((timestamp % DAY_MS) + DAY_MS) % DAY_MS; + const minute = START + within / DAY_MS * WORK_MINUTES; + const slot = Math.floor(within / SLOT_MS); + const elapsed = within % SLOT_MS; + const phase = minute < 9 * 60 ? 'arrival' : minute < 12 * 60 ? 'work' : minute < 13 * 60 ? 'lunch' : minute < 16 * 60 + 45 ? 'work' : minute < 17 * 60 + 15 ? 'departure' : 'after-hours'; + const selected = sceneFor(day, slot, floor); + const event = (phase === 'work' || phase === 'lunch') && elapsed < 82000 ? selected : undefined; + const participants = event ? [...new Set(event.beats.map((b) => b.who))] : []; + const performance = event ? perform(event, elapsed, day * 20 + slot) : undefined; + const beat = event && elapsed >= 35000 && elapsed < 35000 + event.beats.length * 6000 ? event.beats[Math.floor((elapsed - 35000) / 6000)] : undefined; + const people = CAST.map((p, i): AmbientState => { + const member = participants.indexOf(p.id); + const arrived = minute >= START + i * 0.7; + const left = minute >= 17 * 60 + (p.id === 'stanley' ? 0 : i * 0.55); + const present = arrived && !left && (!p.visitor || member >= 0 || (p.id === 'darryl' && slot % 4 === 2)); + let activity = p.routine[(day + slot + i) % p.routine.length]; + let target: [number, number] = [...p.home]; + if (member >= 0 && event) { + const center = PLACES[event.place]; + const angle = member / participants.length * Math.PI * 2; + target = [center[0] + Math.cos(angle) * 1.1, center[1] + Math.sin(angle) * 1.1]; + activity = event.title; + } else if (phase === 'lunch' && (slot + i) % 3 === 0) { + target = [PLACES.kitchen[0] + (i % 3 - 1) * 1.2, PLACES.kitchen[1] - Math.floor(i % 6 / 3) * 1.3]; + activity = 'Taking a lunch break'; + } else if (phase === 'arrival') activity = 'Settling in for the day'; + else if (phase === 'departure') { + activity = 'Wrapping up and heading home'; + target = [7.8 + (i % 3 - 1) * 0.65, -9.4 + (i % 4) * 0.6]; + } + if (!present) activity = p.visitor ? (p.id === 'darryl' ? 'Working downstairs' : 'Away from the branch') : 'Off shift'; + const attention = member < 0 ? undefined : performance?.speaker === p.id ? performance.listener : performance?.speaker; + return { person: p, present, activity, target, line: performance?.stage === 'dialogue' && beat?.who === p.id ? beat.text : undefined, + attention, gesture: performance?.cues[p.id] }; + }); + const memories: string[] = []; + // Completed work slots, including yesterday: continuity does not depend on watching a scene. + for (let n = 0; n < 25 && memories.length < 3; n++) { + const absoluteSlot = day * 20 + slot - n; + const d = Math.floor(absoluteSlot / 20); + const s = ((absoluteSlot % 20) + 20) % 20; + const m = START + (s * SLOT_MS + 82000) / DAY_MS * WORK_MINUTES; + if (m < 9 * 60 || m >= 16 * 60 + 45 || (n === 0 && elapsed < 82000)) continue; + memories.push(sceneFor(d, s, floor).aftermath); + } + const clock = `${Math.floor(minute / 60)}:${String(Math.floor(minute % 60)).padStart(2, '0')}`; + return { sampledAt: timestamp, day, minute, clock, phase, scene: event, performance, people, memories }; +} diff --git a/src/client/features/ambient-office/ui.css b/src/client/features/ambient-office/ui.css new file mode 100644 index 000000000..28c345074 --- /dev/null +++ b/src/client/features/ambient-office/ui.css @@ -0,0 +1,43 @@ +.ambient-badge { + position: fixed; + top: 76px; + left: 18px; + z-index: 12; + border: 1px solid #62755c; + border-radius: 8px; + background: #f7f1e5ed; + color: #394733; + padding: 7px 12px; + font: 700 12px system-ui, sans-serif; + cursor: pointer; + box-shadow: 0 2px 6px #0002; +} +.panel.ambient-panel { width: min(920px, 94vw); max-height: 88vh; overflow-y: auto; padding: 22px; } +.ambient-panel > header { display: flex; align-items: center; gap: 18px; padding: 12px 0; position: sticky; top: -22px; z-index: 2; background: var(--paper); border-bottom: 1px solid #8884; } +.ambient-panel > header h2 { margin: 0; flex: 1; } +.ambient-status { color: var(--muted); font-size: 12px; margin-right: 12px; } +.ambient-controls { display: flex; gap: 24px; flex-wrap: wrap; padding-top: 16px; } +.ambient-controls label { display: flex; gap: 7px; align-items: center; cursor: pointer; } +.ambient-intro, .ambient-limit { color: var(--muted); font-size: 13px; line-height: 1.5; } +.ambient-columns { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 24px; margin-top: 20px; } +.ambient-roster { display: flex; flex-direction: column; gap: 4px; max-height: 530px; overflow: auto; } +.ambient-roster button { border: 1px solid transparent; padding: 8px 10px; background: transparent; color: inherit; text-align: left; border-radius: 6px; cursor: pointer; } +.ambient-roster button.selected { background: #66826022; border-color: #8ba582; font-weight: bold; } +.ambient-roster button.away::after { content: ' · away'; font-size: 10px; opacity: .6; } +.ambient-detail h3 { margin: 0 0 4px; font-size: 22px; } +.ambient-job, .ambient-current, .ambient-utility { color: var(--muted); font-size: 12px; } +.ambient-current { color: #749868; } +.ambient-reply { border-left: 3px solid #8ba582; padding: 10px 14px; line-height: 1.5; background: #66826013; } +.ambient-actions { display: flex; gap: 8px; margin-bottom: 18px; } +.ambient-detail textarea { display: block; box-sizing: border-box; width: 100%; margin: 8px 0; resize: vertical; padding: 12px; line-height: 1.5; border: 1px solid #aaa; border-radius: 6px; background: white; color: var(--ink); } +.ambient-detail textarea[hidden] { display: none; } +.ambient-save { font-size: 11px; min-height: 16px; color: var(--muted); } +.ambient-continuity { margin-top: 20px; border-top: 1px solid #8884; padding-top: 16px; } +.ambient-continuity summary { cursor: pointer; } +.ambient-memories { line-height: 1.6; color: var(--muted); font-size: 13px; } +@media (max-width: 650px) { + .ambient-columns { grid-template-columns: 1fr; } + .ambient-roster { flex-direction: row; flex-wrap: wrap; max-height: 130px; } + .ambient-panel > header { flex-wrap: wrap; } + .ambient-status { order: 3; width: 100%; } +} diff --git a/src/client/features/ambient-office/ui.ts b/src/client/features/ambient-office/ui.ts new file mode 100644 index 000000000..9a8c8572c --- /dev/null +++ b/src/client/features/ambient-office/ui.ts @@ -0,0 +1,106 @@ +import { h, openModal, type Modal } from '../../ui/dom'; +import { CAST, CAST_BY_ID } from './cast'; +import { checklist, readNotes, saveNotes, savePreferences, workBrief, type Preferences } from './notes'; +import type { Snapshot } from './simulation'; +import './ui.css'; + +export function ambientPanel(deps: { + snapshot: () => Snapshot; + floor: () => string; + preferences: Preferences; + busy: (id: string | null) => void; +}) { + let modal: Modal | null = null; + let selected = 'pam'; + let status: HTMLElement | null = null; + let current: HTMLElement | null = null; + let roster: HTMLElement | null = null; + let memories: HTMLElement | null = null; + let lines: HTMLElement | null = null; + const badge = h('button.ambient-badge', { type: 'button', title: 'Open the ambient office · optional coworkers', onclick: () => open() }, 'Dunder Mifflin'); + document.body.append(badge); + + function paint() { + const snapshot = deps.snapshot(); + badge.textContent = `Dunder Mifflin · ${snapshot.clock}${deps.preferences.enabled ? '' : ' · hidden'}`; + if (!modal) return; + status!.textContent = `${snapshot.clock} · ${snapshot.phase} · Season 6 ensemble`; + current!.textContent = snapshot.people.find((p) => p.person.id === selected)?.activity ?? ''; + for (const button of roster!.querySelectorAll('button')) { + const state = snapshot.people.find((p) => p.person.id === button.dataset.id)!; + button.classList.toggle('selected', selected === state.person.id); + button.classList.toggle('away', !state.present); + button.title = `${state.person.job} · ${state.activity}`; + } + memories!.replaceChildren(...snapshot.memories.map((text) => h('li', {}, text))); + } + + function open(id = 'pam') { + if (modal) return; + selected = CAST_BY_ID.has(id) ? id : 'pam'; + const floor = deps.floor(); + let note = readNotes(floor); + let saveMessage = ''; + let response = ''; + status = h('p.ambient-status'); + current = h('p.ambient-current'); + memories = h('ul.ambient-memories'); + lines = h('div.ambient-detail'); + roster = h('nav.ambient-roster', { 'aria-label': 'Coworkers' }, ...CAST.map((p) => h('button', { type: 'button', 'data-id': p.id, onclick: () => { selected = p.id; response = ''; render(); paint(); } }, p.name))); + const enabled = h('input', { type: 'checkbox', checked: deps.preferences.enabled }); + const quiet = h('input', { type: 'checkbox', checked: deps.preferences.quiet }); + for (const [input, field] of [[enabled, 'enabled'], [quiet, 'quiet']] as const) input.addEventListener('change', () => { + deps.preferences[field] = input.checked; + savePreferences(deps.preferences); + paint(); + }); + const panel = h('section.panel.ambient-panel', { 'aria-label': 'Ambient office' }, + h('header', {}, h('h2', {}, 'Dunder Mifflin'), status), + h('div.ambient-controls', {}, h('label', {}, enabled, ' Show coworkers'), h('label', {}, quiet, ' Quiet work · hide dialogue')), + h('p.ambient-intro', {}, 'The branch has its own day. You can work, ignore the conversations, or say hello whenever you like.'), + h('div.ambient-columns', {}, roster, lines), + h('details.ambient-continuity', {}, h('summary', {}, 'Around the branch'), memories), + ); + modal = openModal(panel, { doing: 'Ambient office · optional scratchpad', onClose: () => { modal = null; deps.busy(null); } }); + + function render() { + const p = CAST_BY_ID.get(selected)!; + // Opening the directory does not summon an absent character. Busy only while explicitly helping. + deps.busy(null); + const reply = h('p.ambient-reply', { role: 'status' }, response || p.greeting); + const saved = h('p.ambient-save', { role: 'status' }, saveMessage); + const notes = h('textarea', { rows: 5, maxlength: 12000, 'aria-label': 'Private work notes', placeholder: 'Write notes or one task per line…' }, note); + notes.addEventListener('input', () => { + note = notes.value; + saveMessage = saveNotes(floor, note) ? 'Saved in this browser for this floor.' : 'Storage unavailable. Copy your notes before closing.'; + saved.textContent = saveMessage; + }); + const result = h('textarea.ambient-output', { rows: 6, readonly: true, 'aria-label': 'Prepared work brief or checklist' }); + result.hidden = true; + const help = () => { + if (!note.trim()) { reply.textContent = 'Add your notes or task first.'; return; } + if (deps.snapshot().people.find((s) => s.person.id === p.id)?.present) deps.busy(p.id); + result.value = p.id === 'pam' || p.id === 'erin' ? checklist(note) : workBrief(p, note); + result.hidden = false; + reply.textContent = p.id === 'pam' || p.id === 'erin' ? 'Your task list is ready below. Nothing has been sent or scheduled.' : 'Your work brief is ready below. Copy it to a worker to carry out the request.'; + result.focus(); + result.select(); + }; + lines!.replaceChildren( + h('h3', {}, p.name), h('p.ambient-job', {}, p.job), current!, h('p', {}, p.personality), + h('p.ambient-utility', {}, `Available scratchpad: ${p.utility}`), reply, + h('div.ambient-actions', {}, + h('button.btn', { type: 'button', onclick: () => { deps.busy(null); reply.textContent = p.greeting; } }, 'Say hello'), + h('button.btn', { type: 'button', onclick: () => { deps.busy(null); reply.textContent = `${deps.snapshot().people.find((s) => s.person.id === p.id)?.activity}. ${deps.snapshot().memories[0] ?? ''}`; } }, 'Ask about the day')), + h('label', {}, 'Private notes · kept in your browser', notes), saved, + h('button.btn.primary', { type: 'button', onclick: help }, p.id === 'pam' || p.id === 'erin' ? 'Organize my task list' : 'Prepare a work brief'), + result, + h('p.ambient-limit', {}, 'Local notes and preparation only. Scheduling, reminders, messaging and agent work require connected capabilities. Staff resume their routine when you close this window.'), + ); + } + render(); + paint(); + } + + return { open, paint, close: () => modal?.close(), badge }; +} diff --git a/src/client/features/ambient-office/world.ts b/src/client/features/ambient-office/world.ts new file mode 100644 index 000000000..195eaefed --- /dev/null +++ b/src/client/features/ambient-office/world.ts @@ -0,0 +1,168 @@ +import * as THREE from 'three'; +import { nearestWalkable, route, type Pt } from '../../../shared/nav'; +import { Person } from '../../world/character'; +import { cardSprite, disposeSprite } from '../../world/toon'; +import type { Interactable } from '../../world/types'; +import { CAST } from './cast'; +import type { Snapshot } from './simulation'; +import { EMOTE_BY_ID, type EmoteId } from '../../../shared/emotes'; +import { MANNER, type GestureCue } from './choreography'; +import { GesturePlayer } from './performance'; + +/** A feature can supply a dedicated work space without changing the character bible or schedule. */ +export interface AmbientSpace { + nav: { nearestWalkable(p: Pt): Pt; route(from: Pt, to: Pt): Pt[] }; + homes: Readonly>; + adapt(snapshot: Snapshot): Snapshot; +} + +interface Actor { + person: Person; + it: Interactable; + path: Pt[]; + destination: string; + line: string; + label?: THREE.Sprite; + bubble?: THREE.Sprite; + gestures: GesturePlayer; + greeting?: { at: number; cue?: GestureCue }; +} + +/** Ambient cast never takes a worker seat, collider, socket or agent session. */ +export class AmbientWorld { + readonly root = new THREE.Group(); + readonly actors = new Map(); + private viewerEmote: EmoteId | null = null; + private greetedAt = -Infinity; + + constructor(private space?: AmbientSpace) { + this.root.name = 'ambient-office'; + for (const p of CAST) { + const person = new Person(p.name, p.color, p.look); + person.setLabel(p.name, null); + const label = person.root.children.find((child): child is THREE.Sprite => child instanceof THREE.Sprite); + label?.scale.multiplyScalar(0.75); + person.root.name = `ambient-${p.id}`; + const home = space?.homes[p.id] ?? p.home; + const at = space ? space.nav.nearestWalkable(home) : nearestWalkable(home); + person.root.position.set(at[0], 0, at[1]); + const it: Interactable = { kind: 'ambientnpc', decorId: p.id, x: at[0], z: at[1], radius: 2.2, off: true }; + person.root.userData.interact = it; + // Simple costume cues on the existing chibi rigs, without copying model assets. + if (['michael', 'dwight', 'jim', 'david', 'andy'].includes(p.id)) { + const material = new THREE.MeshToonMaterial({ color: p.id === 'dwight' ? '#72543b' : '#34415c' }); + const geometry = new THREE.BoxGeometry(0.065, 0.28, 0.03); + const tie = new THREE.Mesh(geometry, material); + tie.position.set(0, 1.13, 0.205); + person.root.add(tie); + } + this.root.add(person.root); + this.actors.set(p.id, { person, it, path: [], destination: '', line: '', label, gestures: new GesturePlayer() }); + } + } + + usable(): Interactable[] { + return this.root.visible ? [...this.actors.values()].filter((a) => !a.it.off).map((a) => a.it) : []; + } + + update(snapshot: Snapshot, dt: number, t: number, show: boolean, quiet: boolean, busy: string | null, viewer: { x: number; z: number }, emote: EmoteId | null = null) { + this.root.visible = show; + const now = Date.now(); + const drift = Math.max(0, now - snapshot.sampledAt) / 1000; + if (show && emote === 'wave' && this.viewerEmote !== emote && now - this.greetedAt > 10000) { + const candidate = snapshot.people.filter(s => s.present && !s.attention && !s.gesture && s.person.id !== busy && !snapshot.scene?.beats.some(b => b.who === s.person.id)) + .map(s => ({ state: s, actor: this.actors.get(s.person.id)! })) + .filter(({ actor }) => !actor.path.length && actor.person.root.position.distanceTo(new THREE.Vector3(viewer.x, 0, viewer.z)) < 3.5) + .sort((a, b) => Math.hypot(a.actor.it.x - viewer.x, a.actor.it.z - viewer.z) - Math.hypot(b.actor.it.x - viewer.x, b.actor.it.z - viewer.z))[0]; + if (candidate) { + const id = candidate.state.person.id; + candidate.actor.greeting = { at: now, cue: MANNER[id].responds === null ? undefined : { key: `hello:${now}:${id}`, emote: MANNER[id].responds === 'facepalm' ? 'thumbs' : 'wave', age: 0 } }; + this.greetedAt = now; + } + } + this.viewerEmote = emote; + for (const state of snapshot.people) { + const actor = this.actors.get(state.person.id)!; + actor.person.root.visible = state.present || busy === state.person.id; + actor.it.off = !show || !actor.person.root.visible; + if (actor.it.off) { + this.say(actor, ''); + continue; + } + const pos = actor.person.root.position; + const occupied = busy === state.person.id; + const destination = state.target.join(','); + if (!occupied && destination !== actor.destination) { + actor.destination = destination; + const goal = this.space ? this.space.nav.nearestWalkable(state.target) : nearestWalkable(state.target); + actor.path = this.space ? this.space.nav.route([pos.x, pos.z], goal) : route([pos.x, pos.z], goal); + } + let budget = Math.min(dt, 0.1) * 1.9; + const moving = !occupied && actor.path.length > 0; + while (budget > 0 && !occupied && actor.path.length) { + const [x, z] = actor.path[0]; + const distance = Math.hypot(x - pos.x, z - pos.z); + if (distance > 0.01) actor.person.root.rotation.y = Math.atan2(x - pos.x, z - pos.z); + if (distance <= budget) { + pos.set(x, 0, z); + actor.path.shift(); + budget -= distance; + } else { + pos.x += (x - pos.x) / distance * budget; + pos.z += (z - pos.z) / distance * budget; + break; + } + } + actor.it.x = pos.x; + actor.it.z = pos.z; + const seated = !moving && !occupied ? state.seat : undefined; + actor.person.sit(seated?.hips ?? null); + if (seated) actor.person.root.rotation.y = seated.facing; + const other = state.attention ? this.actors.get(state.attention) : undefined; + const engaged = !moving && !occupied && !!other && !other.it.off && !other.path.length && busy !== state.attention; + let cue = !moving && !occupied && (!state.attention || engaged) && state.gesture ? { ...state.gesture, age: state.gesture.age + drift } : undefined; + if (cue && cue.age >= EMOTE_BY_ID.get(cue.emote)!.seconds) cue = undefined; + let focus = engaged ? other.person.root.position : undefined; + if (actor.greeting) { + const age = (now - actor.greeting.at) / 1000; + if (age >= 2.2 || moving || occupied || state.attention || state.gesture) actor.greeting = undefined; + else { + focus = new THREE.Vector3(viewer.x, 0, viewer.z); + cue = actor.greeting.cue ? { ...actor.greeting.cue, age } : undefined; + } + } + if (focus) { + const angle = Math.atan2(focus.x - pos.x, focus.z - pos.z); + const delta = Math.atan2(Math.sin(angle - actor.person.root.rotation.y), Math.cos(angle - actor.person.root.rotation.y)); + actor.person.root.rotation.y += delta * Math.min(1, dt * 8); + } + actor.person.root.userData.performance = { stage: snapshot.performance?.stage, attention: engaged ? state.attention : undefined, gesture: cue?.emote, age: cue?.age }; + const distance = Math.hypot(pos.x - viewer.x, pos.z - viewer.z); + if (actor.label) actor.label.visible = distance < 10; + actor.person.setDoing(distance < 6 ? (occupied ? 'Helping you' : state.activity) : undefined); + actor.person.holdMug(!moving && !cue && (state.activity.includes('lunch') || state.activity.includes('break'))); + actor.person.read(!moving && (state.activity.includes('Reading') || state.activity.includes('crossword'))); + // Quiet work has no speech bubbles; only the current speaker in a scene talks. + this.say(actor, quiet || occupied || actor.path.length || distance > 10 || (state.attention && !engaged) ? '' : state.line ?? ''); + actor.person.emojiLift = actor.bubble ? actor.bubble.position.y + actor.bubble.scale.y - 2.42 + 0.55 : 0; + actor.person.setVoiceLevel(actor.line ? 0.07 + Math.sin(t * 15) * 0.035 : 0); + actor.gestures.update(actor.person, cue, dt, t, moving); + } + } + + private say(actor: Actor, line: string) { + if (actor.line === line) return; + actor.line = line; + if (actor.bubble) { + actor.person.root.remove(actor.bubble); + disposeSprite(actor.bubble); + actor.bubble = undefined; + } + if (!line) return; + const bubble = cardSprite({ title: CAST.find(p => p.id === actor.it.decorId)!.name, body: line, bg: '#fff9e9', maxWidth: 420 }); + bubble.position.y = actor.person.bubbleY + 0.18; + bubble.scale.multiplyScalar(Math.min(1, 3.2 / bubble.scale.x)); + actor.person.root.add(bubble); + actor.bubble = bubble; + } +} diff --git a/src/client/features/scranton/batch.ts b/src/client/features/scranton/batch.ts new file mode 100644 index 000000000..af22a08e6 --- /dev/null +++ b/src/client/features/scranton/batch.ts @@ -0,0 +1,29 @@ +import * as THREE from 'three'; +import { mergeByMaterial } from '../../world/toon'; + +/** Keep lettering UVs, transparent panes and lights; batch the many static desk/wall pieces. */ +export function batchStatic(root: THREE.Group) { + root.updateMatrixWorld(true); + const inverse = root.matrixWorld.clone().invert(); + const candidates: THREE.Mesh[] = []; + root.traverse((object) => { + const m = object as THREE.Mesh; + if (!m.isMesh || Array.isArray(m.material)) return; + const mat = m.material as THREE.MeshToonMaterial; + if (mat.map || mat.transparent || mat.side !== THREE.FrontSide) return; + candidates.push(m); + }); + const into = new THREE.Group(); + for (const m of candidates) { + const copy = new THREE.Mesh(m.geometry, m.material); + copy.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inverse, m.matrixWorld)); + copy.castShadow = m.castShadow; + into.add(copy); + } + const merged = mergeByMaterial(into); + merged.name = 'scranton-static-surfaces'; + root.add(merged); + const geometries = new Set(); + for (const m of candidates) { m.removeFromParent(); geometries.add(m.geometry); } + for (const geometry of geometries) geometry.dispose(); +} diff --git a/src/client/features/scranton/index.ts b/src/client/features/scranton/index.ts new file mode 100644 index 000000000..572924b7a --- /dev/null +++ b/src/client/features/scranton/index.ts @@ -0,0 +1,37 @@ +import type { Ctx } from '../../core/context'; +import type { Room } from '../../player/camera'; +import { SCRANTON, SCRANTON_HEIGHT, CORRIDORS, inScranton } from '../../../shared/scranton'; +import { scrantonRoutines } from './routines'; + +/** Physical space and lighting are supplied by the fixture; this handles the visitor's camera. */ +export function installScranton(ctx: Ctx) { + let previous: Room | null = null; + let applied: Room | null = null; + let sheltered = false; + + ctx.ticks.add('pre', () => { + const p = ctx.player.pos; + const inside = ctx.inOffice() && !ctx.upTop() && p.y > -1 && p.y < SCRANTON_HEIGHT && inScranton(p.x, p.z); + if (inside) { + if (ctx.player.room !== applied) previous = ctx.player.room; + const area = p.x < SCRANTON.maxX && p.z < SCRANTON.maxZ ? SCRANTON : (() => { + const r = CORRIDORS.find(([x0, x1, z0, z1]) => p.x > x0 && p.x < x1 && p.z > z0 && p.z < z1)!; + return { minX: r[0], maxX: r[1], minZ: r[2], maxZ: r[3] }; + })(); + // The camera follows within the adjoining room, instead of clamping back to the old walls. + applied = { ...area, wall: 0.18, enclosed: true }; + ctx.player.room = applied; + } else if (applied) { + if (ctx.player.room === applied && previous) ctx.player.room = previous; + applied = null; + previous = null; + } + if (inside === sheltered) return; + sheltered = inside; + ctx.sky.setIndoors(inside || (!ctx.upTop() && ctx.world().room.enclosed)); + const acoustics = ctx.world().acoustics; + ctx.sound.setHall(inside ? { bounds: { ...SCRANTON, maxX: 22.8, maxZ: -2.85 }, gong: null, windows: [] } : acoustics ? { bounds: ctx.plan().bounds, gong: acoustics.gong ?? null, windows: acoustics.windows ?? [], vacuum: acoustics.vacuum } : null); + }); + + return scrantonRoutines(ctx.office.scranton.nav); +} diff --git a/src/client/features/scranton/props.ts b/src/client/features/scranton/props.ts new file mode 100644 index 000000000..26b20f52a --- /dev/null +++ b/src/client/features/scranton/props.ts @@ -0,0 +1,125 @@ +import * as THREE from 'three'; +import { mesh, roundedBox, textPlane, toon } from '../../world/toon'; +import { chair } from '../../world/office/seats'; +import { point, type Furnishing, type Workstation } from '../../../shared/scranton'; + +const wood = () => toon('#ad8860'); +const metal = () => toon('#555f62'); +const box = (w: number, h: number, d: number) => new THREE.BoxGeometry(w, h, d); + +export function plaque(text: string, width: number): THREE.Mesh { + const label = textPlane(text, { bg: '#f4f0df', color: '#35484c', border: '#a6a99c', size: 42 }); + const factor = width / label.scale.x; + label.scale.multiplyScalar(factor); + return label; +} + +/** Ordinary mid-2000s desk equipment, with no agent laptop, terminal or hiring marker. */ +export function desktop(): THREE.Group { + const g = new THREE.Group(); + const casing = toon('#dbd8c9'); + g.add(mesh(box(0.48, 0.42, 0.36), casing, 0, 1.03, -0.23)); + g.add(mesh(box(0.43, 0.33, 0.025), toon('#233b3b'), 0, 1.04, -0.025)); + const screen = textPlane('DUNDER MIFFLIN', { color: '#a8c6b7', bg: '#233b3b', size: 32 }); + screen.scale.set(0.4, 0.07, 1); + screen.position.set(0, 1.02, -0.01); + g.add(screen); + g.add(mesh(box(0.36, 0.035, 0.22), casing, 0, 0.79, 0.22)); + for (let r = 0; r < 3; r++) for (let c = 0; c < 8; c++) g.add(mesh(box(0.03, 0.009, 0.04), toon('#7f837b'), -0.14 + c * 0.04, 0.811, 0.15 + r * 0.065)); + // A desk phone and a small stack of paper. + g.add(mesh(box(0.23, 0.05, 0.22), toon('#3a4142'), -0.65, 0.81, 0.22)); + g.add(mesh(roundedBox(0.25, 0.06, 0.07, 0.03), toon('#3a4142'), -0.65, 0.86, 0.2)); + g.add(mesh(box(0.25, 0.05, 0.33), toon('#f6f2e4'), 0.65, 0.81, -0.1)); + return g; +} + +export function workstation(s: Workstation): THREE.Group { + const g = new THREE.Group(); + g.name = `scranton-desk-${s.id}`; + g.position.set(s.desk[0], 0, s.desk[1]); + g.rotation.y = s.facing - Math.PI; + g.add(mesh(roundedBox(2.05, 0.08, 1.05, 0.035), wood(), 0, 0.74, 0)); + g.add(mesh(box(0.4, 0.66, 0.86), toon('#a69d8c'), -0.72, 0.35, 0)); + for (const x of [-0.78, 0.78]) g.add(mesh(box(0.055, 0.7, 0.8), metal(), x, 0.35, 0)); + g.add(desktop()); + const tag = plaque(s.id === 'michael' ? 'MICHAEL SCOTT · REGIONAL MANAGER' : s.id.toUpperCase(), 0.7); + tag.position.set(0.55, 0.89, 0.39); + g.add(tag); + if (s.id === 'michael') { + g.add(mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.13, 12), toon('#fafafa'), 0.75, 0.85, 0.24)); + const boss = plaque('WORLD’S BEST BOSS', 0.18); + boss.position.set(0.75, 0.86, 0.312); + g.add(boss); + } + if (s.id === 'dwight') { + g.add(mesh(new THREE.SphereGeometry(0.06, 10, 8), toon('#e3b791'), 0.7, 1.0, 0.26)); + g.add(mesh(box(0.07, 0.13, 0.06), toon('#c4a25b'), 0.7, 0.89, 0.26)); + } + if (s.id === 'angela') { + const cats = plaque('CATS', 0.25); + cats.position.set(0.65, 0.96, -0.1); + g.add(cats); + } + const seat = chair('#535d61'); + seat.position.z = 1.15; + g.add(seat); + return g; +} + +export function furnishing(f: Furnishing): THREE.Group { + const g = new THREE.Group(); + g.position.set(f.at[0], 0, f.at[1]); + if (f.kind === 'table') { + const round = f.width === 1; + g.add(mesh(round ? new THREE.CylinderGeometry(0.5, 0.5, 0.06, 24) : box(f.width, 0.06, f.depth), wood(), 0, f.height - 0.03, 0)); + g.add(mesh(new THREE.CylinderGeometry(0.07, 0.13, f.height - 0.06, 10), metal(), 0, (f.height - 0.06) / 2, 0)); + for (const dx of [-0.8, 0.8]) { + const seat = chair('#8c8874'); + seat.position.x = dx; + seat.rotation.y = dx < 0 ? -Math.PI / 2 : Math.PI / 2; + g.add(seat); + } + } else if (f.kind === 'vending') { + g.add(mesh(box(f.width, f.height, f.depth), toon('#343e43'), 0, f.height / 2, 0)); + g.add(mesh(box(0.025, 1.1, 0.95), toon('#284f59'), -f.width / 2 - 0.01, 1.1, 0)); + for (let i = 0; i < 4; i++) for (let row = 0; row < 3; row++) g.add(mesh(box(0.035, 0.18, 0.15), toon(['#ce8b57', '#d9bd5a', '#b65850', '#658f68'][i]), -f.width / 2 - 0.035, 0.7 + row * 0.31, -0.36 + i * 0.24)); + } else if (f.kind === 'sofa') { + g.add(mesh(roundedBox(f.width, 0.4, f.depth, 0.08), toon('#888979'), 0, 0.35, 0)); + g.add(mesh(box(0.15, 0.7, f.depth), toon('#7f816f'), -f.width / 2 + 0.07, 0.58, 0)); + } else { + g.add(mesh(box(f.width, f.height, f.depth), f.kind === 'counter' ? wood() : toon(f.kind === 'copier' ? '#d9d8cd' : '#bcb8a9'), 0, f.height / 2, 0)); + if (f.kind === 'counter') g.add(mesh(box(f.width + 0.03, 0.045, f.depth + 0.03), toon('#e1ddcf'), 0, f.height + 0.02, 0)); + if (f.kind === 'copier') g.add(mesh(box(0.64, 0.06, 0.7), toon('#454f52'), 0, f.height + 0.03, 0)); + if (f.kind === 'cabinet') for (let y = 0.35; y < f.height; y += 0.35) g.add(mesh(box(f.width * 0.4, 0.02, 0.03), metal(), 0, y, f.depth / 2 + 0.02)); + if (f.kind === 'fridge') g.add(mesh(box(0.035, 0.5, 0.035), metal(), -0.26, 0.95, f.depth / 2 + 0.02)); + } + return g; +} + +export function reception(): THREE.Group { + const g = new THREE.Group(); + g.name = 'scranton-reception'; + const at = point(2, 6); + g.position.set(at[0], 0, at[1]); + const shape = new THREE.Shape(); + shape.absarc(0, 0, 1.65, 0, Math.PI, false); + shape.absarc(0, 0, 1.14, Math.PI, 0, true); + shape.closePath(); + const top = new THREE.ExtrudeGeometry(shape, { depth: 0.09, bevelEnabled: false, curveSegments: 24 }); + top.rotateX(Math.PI / 2); + g.add(mesh(top, wood(), 0, 1.13, 0)); + const front = new THREE.CylinderGeometry(1.58, 1.58, 1.03, 32, 1, true, -Math.PI / 2, Math.PI); + const finish = toon('#b29972').clone(); + finish.side = THREE.DoubleSide; + g.add(mesh(front, finish, 0, 0.52, 0)); + const equipment = desktop(); + equipment.position.set(0.1, 0, -0.6); + g.add(equipment); + const seat = chair('#596362'); + seat.position.set(0.25, 0, -0.25); + g.add(seat); + const sign = plaque('DUNDER MIFFLIN', 2.4); + sign.position.set(0, 1.4, 1.48); + g.add(sign); + return g; +} diff --git a/src/client/features/scranton/routines.ts b/src/client/features/scranton/routines.ts new file mode 100644 index 000000000..a5be28aed --- /dev/null +++ b/src/client/features/scranton/routines.ts @@ -0,0 +1,46 @@ +import { SCRANTON, HOMES, WORKSTATIONS, CONFERENCE_CHAIRS, ROOMS, point } from '../../../shared/scranton'; +import type { NavGrid, Pt } from '../../../shared/nav'; +import type { Snapshot } from '../ambient-office/simulation'; +import type { AmbientSpace } from '../ambient-office/world'; + +const PLACES = { + reception: point(4.6, 5.8), bullpen: point(7.5, 11.1), kitchen: point(18.8, 9.1), + meeting: ROOMS.conference.at, annex: point(25, 10.5), +}; + +/** Adapt the ambient simulator through its space dependency; no worker seats are reused. */ +export function scrantonRoutines(nav: NavGrid): AmbientSpace { + return { nav, homes: HOMES, adapt: (snapshot: Snapshot): Snapshot => { + const members = snapshot.scene ? [...new Set(snapshot.scene.beats.map((b) => b.who))] : []; + return { ...snapshot, people: snapshot.people.map((state) => { + const p = state.person; + const home = HOMES[p.id]; + const member = members.indexOf(p.id); + const routine = state.target[0] === p.home[0] && state.target[1] === p.home[1]; + let target: Pt = home; + let seat: typeof state.seat; + const desk = WORKSTATIONS.find((s) => s.id === p.id); + if (routine && desk) seat = { hips: 0.84, facing: desk.facing }; + if (routine && p.id === 'erin') seat = { hips: 0.84, facing: Math.PI }; + if (member >= 0 && snapshot.scene) { + const center = PLACES[snapshot.scene.place]; + const angle = member / members.length * Math.PI * 2; + target = [center[0] + Math.cos(angle) * 0.85, center[1] + Math.sin(angle) * 0.85]; + seat = undefined; + if (snapshot.scene.place === 'meeting') { + target = CONFERENCE_CHAIRS[member % CONFERENCE_CHAIRS.length]; + seat = { hips: 0.84, facing: Math.PI }; + } + } else if (state.activity === 'Taking a lunch break') { + const i = snapshot.people.indexOf(state); + target = point(18.5 + i % 3, 9.0 + (i % 2) * 0.55); + seat = undefined; + } else if (snapshot.phase === 'departure') { + target = point(14.0, 5.8); + seat = undefined; + } + const center: Pt = [SCRANTON.minX + Math.floor((target[0] - SCRANTON.minX) / 0.5) * 0.5 + 0.25, SCRANTON.minZ + Math.floor((target[1] - SCRANTON.minZ) / 0.5) * 0.5 + 0.25]; + return { ...state, target: nav.nearestWalkable(center), seat }; + }) }; + } }; +} diff --git a/src/client/features/scranton/world.ts b/src/client/features/scranton/world.ts new file mode 100644 index 000000000..c29968cd9 --- /dev/null +++ b/src/client/features/scranton/world.ts @@ -0,0 +1,124 @@ +import * as THREE from 'three'; +import { SCRANTON, SCRANTON_HEIGHT, CORRIDORS, PARTITIONS, WORKSTATIONS, FURNISHINGS, RECEPTION_BLOCKS, CONFERENCE_CHAIRS, ROOMS, point, partitionRect, furnitureRect, deskRect, scrantonNav, type Partition } from '../../../shared/scranton'; +import type { Rect, NavGrid } from '../../../shared/nav'; +import type { Collider } from '../../world/types'; +import type { Fixture } from '../../world/office/fixture'; +import { chair } from '../../world/office/seats'; +import { glassPane } from '../../world/office/materials'; +import { mesh, toon } from '../../world/toon'; +import { plaque, furnishing, reception, workstation } from './props'; +import { batchStatic } from './batch'; + +export interface ScrantonView { root: THREE.Group; nav: NavGrid } +declare module '../../world/types' { + interface OfficeHandles { scranton: ScrantonView } +} +const box = (w: number, h: number, d: number) => new THREE.BoxGeometry(w, h, d); +function collider(r: Rect, top: number, bottom?: number): Collider { + return { minX: r[0], maxX: r[1], minZ: r[2], maxZ: r[3], top, bottom }; +} + +function partition(p: Partition): THREE.Group { + const g = new THREE.Group(); + const width = p.to - p.from; + g.position.set(p.axis === 'x' ? (p.from + p.to) / 2 : p.at, 0, p.axis === 'x' ? p.at : (p.from + p.to) / 2); + if (p.axis === 'z') g.rotation.y = Math.PI / 2; + const wall = toon('#c9cbb5'); + const trim = toon('#8b9580'); + if (!p.glass) g.add(mesh(box(width, SCRANTON_HEIGHT, 0.18), wall, 0, SCRANTON_HEIGHT / 2, 0)); + else { + g.add(mesh(box(width, 0.78, 0.18), wall, 0, 0.39, 0)); + g.add(mesh(box(width, 0.35, 0.18), wall, 0, SCRANTON_HEIGHT - 0.175, 0)); + const sections = Math.max(1, Math.ceil(width / 2.6)); + const w = width / sections; + for (let i = 0; i < sections; i++) { + const x = -width / 2 + w * (i + 0.5); + const pane = glassPane(w - 0.1, 2.18); + pane.position.set(x, 1.88, 0); + g.add(pane); + g.add(mesh(box(0.07, 2.2, 0.14), trim, x - w / 2 + 0.035, 1.88, 0)); + // Horizontal blinds are a defining part of Michael's office and the conference room. + if (!p.outer && p.glass) for (let y = 1.02; y < 2.98; y += 0.2) { + const slat = mesh(box(w - 0.1, 0.023, 0.1), toon('#bcbcad'), x, y, 0.04, false); + slat.rotation.x = -0.25; + g.add(slat); + } + } + for (const y of [0.8, 2.99]) g.add(mesh(box(width, 0.06, 0.15), trim, 0, y, 0)); + } + g.add(mesh(box(width, 0.12, 0.2), trim, 0, 0.06, 0)); + return g; +} + +/** A permanent adjoining suite: its furniture cannot hire or displace coding workers. */ +export const scranton: Fixture<'scranton'> = (site) => { + const root = new THREE.Group(); + root.name = 'scranton-extension'; + const colliders: Collider[] = []; + const suite: Rect = [SCRANTON.minX, SCRANTON.maxX, SCRANTON.minZ, SCRANTON.maxZ]; + const carpet = toon('#8e9290').clone(); + carpet.fog = false; + for (const r of [suite, ...CORRIDORS]) { + root.add(mesh(box(r[1] - r[0], 0.25, r[3] - r[2]), carpet, (r[0] + r[1]) / 2, -0.125, (r[2] + r[3]) / 2)); + colliders.push(collider(r, 0)); + // Visible from below, with the top open for inspecting the floor plan in third person. + const ceiling = mesh(new THREE.PlaneGeometry(r[1] - r[0], r[3] - r[2]), new THREE.MeshBasicMaterial({ color: '#e6e5db', fog: false }), (r[0] + r[1]) / 2, SCRANTON_HEIGHT, (r[2] + r[3]) / 2, false); + ceiling.rotation.x = Math.PI / 2; + root.add(ceiling); + colliders.push(collider(r, SCRANTON_HEIGHT + 0.1, SCRANTON_HEIGHT)); + } + for (const p of PARTITIONS) { + root.add(partition(p)); + colliders.push(collider(partitionRect(p), 99)); + } + for (const s of WORKSTATIONS) { + root.add(workstation(s)); + colliders.push(collider(deskRect(s), 0.78)); + } + root.add(reception()); + colliders.push(...RECEPTION_BLOCKS.map((r) => collider(r, 1.13))); + for (const f of FURNISHINGS) { + root.add(furnishing(f)); + colliders.push(collider(furnitureRect(f), f.height)); + } + for (const at of CONFERENCE_CHAIRS) { + const seat = chair('#74796b'); + seat.position.set(at[0], 0, at[1]); + root.add(seat); + } + const boardAt = point(10.5, 0.15); + const screen = mesh(box(2.7, 1.35, 0.035), toon('#faf9f1'), boardAt[0], 1.85, boardAt[1]); + root.add(screen); + const logo = plaque('DUNDER MIFFLIN · SCRANTON', 3.5); + const logoAt = point(1.2, 2.0); + logo.position.set(logoAt[0], 2.6, logoAt[1]); + logo.rotation.y = Math.PI / 2; + root.add(logo); + for (const key of ['michael', 'conference', 'kitchen', 'annex', 'breakroom', 'ryan'] as const) { + const r = ROOMS[key]; + const label = plaque(r.label.toUpperCase(), key === 'annex' ? 2.6 : 1.7); + label.position.set(r.at[0], 2.95, r.at[1] - 1.0); + root.add(label); + } + // Fluorescent fittings and restrained local lighting preserve the original office's illumination. + for (const at of [point(6, 8), point(18.5, 9), point(26, 11), [21.5, -15] as [number, number]]) { + root.add(mesh(box(1.4, 0.04, 0.36), new THREE.MeshBasicMaterial({ color: '#fffde4', fog: false }), at[0], 3.27, at[1], false)); + const light = new THREE.PointLight('#fff7de', 13, 13, 2); + light.position.set(at[0], 2.9, at[1]); + root.add(light); + } + // The enclosed building stair core, pictured through its landing window. + const stairs = new THREE.Group(); + stairs.name = 'scranton-stairwell'; + const at = point(18.5, 3.2); + for (let i = 0; i < 12; i++) stairs.add(mesh(box(3.2, 0.16, 0.38), toon('#a9aaa6'), at[0], -0.05 + i * 0.08, at[1] + 2.1 - i * 0.38)); + root.add(stairs); + colliders.push(collider(furnitureRect({ at, width: 7.2, depth: 6.0 }), 99)); + const wayfinding = plaque('DUNDER MIFFLIN →', 1.6); + wayfinding.position.set(17.92, 2.94, -4.2); + wayfinding.rotation.y = -Math.PI / 2; + root.add(wayfinding); + site.wall('east', -4.2, 1.6, 1.9, 3.2); + batchStatic(root); + return { group: root, colliders, handle: { scranton: { root, nav: scrantonNav() } } }; +}; diff --git a/src/client/main.ts b/src/client/main.ts index 7cc7da7d8..7ef4f5cb0 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -64,6 +64,8 @@ import { installWalking } from './features/walking'; import { installWhiteboard } from './features/whiteboard'; import { installWorkerActions } from './features/workers/actions'; import { installWorkerViews } from './features/workers/views'; +import { installAmbientOffice } from './features/ambient-office'; +import { installScranton } from './features/scranton'; // The loading screen stays up until there's an office to see (see boot and whoami at the end). const loading = loadingScreen(onModelsProgress); @@ -179,6 +181,7 @@ installChat(ctx); parts.talk = installVoice(ctx, { tv: parts.tv }); installDictation(ctx); parts.hud = installHud(ctx, core, parts); +installAmbientOffice(ctx, installScranton(ctx)); // ---- Main loop --------------------------------------------------------------------------------------- fitWindow(ctx); diff --git a/src/client/ui/help.ts b/src/client/ui/help.ts index d1d9fc809..61f77f0b7 100644 --- a/src/client/ui/help.ts +++ b/src/client/ui/help.ts @@ -4,6 +4,7 @@ import { IS_MAC } from './termkeys'; export const HELP_ROWS: readonly (readonly [string, string])[] = [ + ['Dunder Mifflin', 'The TV cast works in the Scranton suite: enter through the signed doorway between the Services board and TV, then follow the hallway north and left. E at a coworker or the Dunder Mifflin clock opens their optional conversation and local work scratchpad. Hide coworkers or dialogue there for quiet work'], ['W A S D', 'Walk (hold Shift to run)'], ['Space', 'Jump'], ['☕', 'Press E at the coffee machine in the kitchen for a minute of quicker walking and higher jumps. Three cups in a row gives you the jitters'], diff --git a/src/client/world/office/build.ts b/src/client/world/office/build.ts index 15c0e6237..8c3075215 100644 --- a/src/client/world/office/build.ts +++ b/src/client/world/office/build.ts @@ -28,6 +28,7 @@ import { beanbags, desks, kiosks } from './seats'; import { meetingRoom } from './meeting-room'; import { loft } from './loft'; import type { Fixture, Gives, Site } from './fixture'; +import { scranton } from '../../features/scranton/world'; // The office floor, put together from its fixtures (see fixture.ts): the room and its walls, the desks // and everything else in it, the balcony, the loft and the meeting room under it, the back office, and @@ -72,6 +73,7 @@ function floorPlan() { hoop, whiteboard, clearOfStairs, + scranton, ] as const; } diff --git a/src/client/world/office/shell.ts b/src/client/world/office/shell.ts index 3bafeb424..12cade07e 100644 --- a/src/client/world/office/shell.ts +++ b/src/client/world/office/shell.ts @@ -5,6 +5,7 @@ import { mergeByMaterial, mesh, textPlane, toon } from '../toon'; import type { Collider } from '../types'; import type { Fixture } from './fixture'; import { GLASS, PALETTE, box, glassPane, onWall, type Looks } from './materials'; +import { OFFICE_OPENINGS } from '../../../shared/office-openings'; // The office's shell: its outside walls, the windows in them, and the doors out (the exit and the // balcony's), which open by themselves. @@ -315,7 +316,7 @@ export function wallRun(into: THREE.Group, cols: Collider[], axis: 'x' | 'z', at /** Outside walls, with real windows you see out of and a door out, and the glass doors out to the balcony. */ export const walls: Fixture = (site) => { const night = site.get('night'); - const openings = [...WINDOWS, EXIT_DOOR, BALCONY_DOOR]; + const openings = [...WINDOWS, EXIT_DOOR, BALCONY_DOOR, ...OFFICE_OPENINGS]; buildWalls(site.group, site.colliders, openings, site.looks); const glazing = new THREE.Group(); for (const o of WINDOWS) { diff --git a/src/client/world/outside.ts b/src/client/world/outside.ts index 36c4505c7..41254f3c5 100644 --- a/src/client/world/outside.ts +++ b/src/client/world/outside.ts @@ -6,6 +6,7 @@ import type { Collider } from './types'; import type { Fixture, StreetSite } from './office/fixture'; import { canvasTexture } from './texture'; import { mergeByMaterial, mesh, textPlane, toon, toonUnique } from './toon'; +import { reservedOfficeSpace } from '../../shared/office-footprints'; const G = STREET_Y; /** The building's footprint, walls included. */ @@ -358,6 +359,7 @@ export function buildStreet(group: THREE.Group, colliders: Collider[], night: Ni [23, -19, 1.1], ]; for (const [x, z, s] of trees) { + if (reservedOfficeSpace(x, z, 2.5 * s)) continue; const t = tree(s); t.position.set(x, G, z); forest.add(t); diff --git a/src/shared/office-footprints.ts b/src/shared/office-footprints.ts new file mode 100644 index 000000000..e5b79c326 --- /dev/null +++ b/src/shared/office-footprints.ts @@ -0,0 +1,10 @@ +import { SCRANTON, CORRIDORS } from './scranton.js'; +import type { Rect } from './nav.js'; + +/** Feature extensions reserve their ground projection so scenery cannot grow through the rooms. */ +export const OFFICE_FOOTPRINTS: readonly Rect[] = [ + [SCRANTON.minX, SCRANTON.maxX, SCRANTON.minZ, SCRANTON.maxZ], ...CORRIDORS, +]; +export function reservedOfficeSpace(x: number, z: number, radius = 0): boolean { + return OFFICE_FOOTPRINTS.some(([x0, x1, z0, z1]) => x + radius > x0 && x - radius < x1 && z + radius > z0 && z - radius < z1); +} diff --git a/src/shared/office-openings.ts b/src/shared/office-openings.ts new file mode 100644 index 000000000..be6763351 --- /dev/null +++ b/src/shared/office-openings.ts @@ -0,0 +1,5 @@ +import type { Opening } from './layout.js'; +import { SCRANTON_DOOR } from './scranton.js'; + +/** Extra feature doorways cut by the office shell; their fixtures supply the adjoining rooms. */ +export const OFFICE_OPENINGS: readonly Opening[] = [SCRANTON_DOOR]; diff --git a/src/shared/scranton.ts b/src/shared/scranton.ts new file mode 100644 index 000000000..dee6f78d1 --- /dev/null +++ b/src/shared/scranton.ts @@ -0,0 +1,144 @@ +import type { Opening } from './layout.js'; +import { NavGrid, type Bounds, type Pt, type Rect } from './nav.js'; + +/** Scaled to the existing game characters; room adjacencies follow the Scranton set. */ +export const SCRANTON: Bounds = { minX: -18, maxX: 12, minZ: -31, maxZ: -15 }; +export const SCRANTON_HEIGHT = 3.4; +export const SCRANTON_DOOR: Opening = { wall: 'east', u: -4.2, width: 1.7, y0: 0, y1: 2.6 }; +export const SCRANTON_ENTRY: Pt = [17.5, -4.2]; +export const CORRIDORS: readonly Rect[] = [ + [17.3, 22.8, -5.55, -2.85], + [20.1, 22.8, -25.2, -2.85], + [12, 22.8, -25.2, -22.5], +]; +export const point = (u: number, v: number): Pt => [SCRANTON.minX + u, SCRANTON.minZ + v]; + +export interface Partition { + axis: 'x' | 'z'; + at: number; + from: number; + to: number; + glass?: boolean; + outer?: boolean; +} +const alongX = (v: number, a: number, b: number, glass = false): Partition => ({ axis: 'x', at: point(0, v)[1], from: point(a, 0)[0], to: point(b, 0)[0], glass }); +const alongZ = (u: number, a: number, b: number, glass = false): Partition => ({ axis: 'z', at: point(u, 0)[0], from: point(0, a)[1], to: point(0, b)[1], glass }); + +/** Door gaps are real gaps in both the visual partitions and collision/navigation geometry. */ +export const PARTITIONS: readonly Partition[] = [ + // Outer shell, with the extra connection through the annex's east wall. + { ...alongX(0, 0, 30, true), outer: true }, + { ...alongX(16, 0, 30, true), outer: true }, + { ...alongZ(0, 0, 16, true), outer: true }, + { ...alongZ(30, 0, 5.8, true), outer: true }, + { ...alongZ(30, 8.5, 16, true), outer: true }, + // Michael's office and the conference room beside it, facing the sales bullpen. + alongZ(2.6, 0, 4.6), alongZ(6.8, 0, 4.6), alongZ(14.2, 0, 3.7), + alongX(4.6, 2.6, 3.8, true), alongX(4.6, 5.3, 6.8, true), + alongX(4.6, 6.8, 7.2, true), alongX(4.6, 8.5, 14.2, true), + // Building stairwell (closed to this suite), with a landing beside the main entrance. + alongZ(14.6, 0, 5.0), alongZ(22.4, 0, 7.0), alongX(6.9, 14.6, 22.4), + // Kitchen links the main room and the annex through two open doorways. + alongZ(14.2, 7.4, 8.0), alongZ(14.2, 9.5, 13.6), alongZ(14.2, 14.8, 16), + alongX(7.4, 14.2, 22.4), alongX(10.7, 14.2, 17.0), alongX(10.7, 18.3, 18.7), alongX(10.7, 20.2, 22.4), + alongZ(22.4, 7.4, 8.0), alongZ(22.4, 9.5, 10.7), + // Ryan's small closet office, plus the restroom block below the kitchen. + alongZ(18.7, 10.7, 12.8), alongX(12.8, 14.2, 15.0), alongX(12.8, 16.4, 18.7), + alongX(13.1, 18.7, 19.4), alongX(13.1, 20.7, 22.4), alongZ(18.7, 13.1, 16), + alongZ(22.4, 10.7, 16), + // Copier/quality-assurance area at the far end of the bullpen. + alongX(11.2, 10.0, 14.2), alongZ(10.0, 11.2, 11.9), alongZ(10.0, 13.7, 16), + // Break room in the far corner of the annex. + alongZ(25.5, 0, 4.9), alongX(5.4, 25.5, 26.0), alongX(5.4, 27.4, 30), + // Hallway walls: the first gap goes through the original office's east wall. + { axis: 'x', at: -2.85, from: 18.3, to: 22.8 }, + { axis: 'x', at: -5.55, from: 18.3, to: 20.1 }, + { axis: 'z', at: 22.8, from: -25.2, to: -2.85, glass: true }, + { axis: 'z', at: 20.1, from: -22.5, to: -5.55, glass: true }, + { axis: 'x', at: -25.2, from: 12, to: 22.8 }, + { axis: 'x', at: -22.5, from: 12, to: 20.1 }, +]; + +export const ROOMS = { + reception: { label: 'Reception', at: point(2.0, 7.9) }, + bullpen: { label: 'Sales bullpen', at: point(7.2, 11.2) }, + michael: { label: 'Michael’s office', at: point(4.7, 3.0) }, + conference: { label: 'Conference room', at: point(10.5, 3.1) }, + accounting: { label: 'Accounting', at: point(4.6, 12.0) }, + kitchen: { label: 'Kitchen', at: point(18.8, 9.2) }, + annex: { label: 'Annex · customer service & HR', at: point(25.0, 11.4) }, + breakroom: { label: 'Break room', at: point(27.4, 2.65) }, + ryan: { label: 'Ryan’s closet office', at: point(16.3, 12.0) }, +} satisfies Record; + +export interface Workstation { id: string; at: Pt; facing: number; desk: Pt } +const station = (id: string, u: number, v: number, facing = Math.PI): Workstation => { + const at = point(Math.round((u - 0.25) / 0.5) * 0.5 + 0.25, Math.round((v - 0.25) / 0.5) * 0.5 + 0.25); + return { id, at, facing, desk: [at[0] + Math.sin(facing) * 1.15, at[1] + Math.cos(facing) * 1.15] }; +}; +export const WORKSTATIONS: readonly Workstation[] = [ + station('michael', 4.7, 1.5, 0), + station('jim', 6.0, 6.3, 0), station('dwight', 6.0, 10.0), + station('andy', 10.0, 6.3, 0), station('pam', 10.0, 10.0), + station('angela', 1.4, 12.0), station('oscar', 3.7, 12.0), station('kevin', 1.4, 14.6), + station('stanley', 6.0, 14.6), station('phyllis', 8.5, 14.6), + station('meredith', 12.4, 13.1), station('creed', 13.0, 14.6, -Math.PI / 2), + station('ryan', 16.75, 11.7, -Math.PI / 2), + station('toby', 28.6, 11.5, -Math.PI / 2), station('kelly', 27.5, 15.25), + station('darryl', 24.4, 11.2), +]; +export const HOMES: Record = Object.fromEntries(WORKSTATIONS.map((s) => [s.id, s.at])); +HOMES.erin = point(2.25, 5.75); +HOMES.holly = point(24.75, 13.25); +HOMES.david = point(5.25, 3.75); + +export interface Furnishing { kind: 'cabinet' | 'counter' | 'copier' | 'vending' | 'fridge' | 'table' | 'sofa'; at: Pt; width: number; depth: number; height: number } +const item = (kind: Furnishing['kind'], u: number, v: number, width: number, depth: number, height: number): Furnishing => ({ kind, at: point(u, v), width, depth, height }); +export const FURNISHINGS: readonly Furnishing[] = [ + item('cabinet', 0.8, 1.2, 1.0, 1.8, 1.25), + item('sofa', 0.6, 4.7, 0.8, 2.0, 0.75), + item('copier', 13.6, 5.4, 0.85, 0.9, 1.0), + item('cabinet', 12.6, 4.0, 2.2, 0.5, 1.0), + item('counter', 17.8, 7.9, 5.0, 0.7, 0.92), + item('fridge', 14.8, 7.9, 0.85, 0.85, 1.8), + item('table', 20.8, 9.0, 1.0, 1.0, 0.73), + item('cabinet', 23.0, 1.5, 0.75, 2.5, 1.4), + item('vending', 29.4, 2.0, 0.8, 1.2, 1.85), + item('vending', 29.4, 3.4, 0.8, 1.2, 1.85), + item('table', 27.1, 1.6, 1.0, 1.0, 0.73), + item('table', 27.1, 3.8, 1.0, 1.0, 0.73), + item('counter', 28.4, 5.0, 2.0, 0.5, 0.92), + item('table', 25.8, 8.0, 1.8, 1.0, 0.76), + item('cabinet', 23.0, 14.2, 0.6, 2.4, 1.3), +]; +/** The curved reception counter is approximated conservatively by three collision boxes. */ +export const RECEPTION_BLOCKS: readonly Rect[] = [ + [point(0.2, 0)[0], point(0.6, 0)[0], point(0, 6.0)[1], point(0, 7.7)[1]], + [point(3.4, 0)[0], point(3.8, 0)[0], point(0, 6.0)[1], point(0, 7.7)[1]], + [point(0.6, 0)[0], point(3.4, 0)[0], point(0, 7.0)[1], point(0, 7.7)[1]], +]; +export const CONFERENCE_CHAIRS: readonly Pt[] = [5.8, 7.1, 8.4].flatMap((v) => [point(9.1, v - 4.6), point(11.4, v - 4.6)]); + +export function partitionRect(p: Partition, thickness = 0.18): Rect { + return p.axis === 'x' ? [p.from, p.to, p.at - thickness / 2, p.at + thickness / 2] : [p.at - thickness / 2, p.at + thickness / 2, p.from, p.to]; +} +export function furnitureRect(f: { at: Pt; width: number; depth: number }): Rect { + return [f.at[0] - f.width / 2, f.at[0] + f.width / 2, f.at[1] - f.depth / 2, f.at[1] + f.depth / 2]; +} +export function deskRect(s: Workstation): Rect { + const side = Math.abs(Math.sin(s.facing)) > 0.5; + return furnitureRect({ at: s.desk, width: side ? 1.05 : 2.05, depth: side ? 2.05 : 1.05 }); +} +const inside = (r: Rect, x: number, z: number, m: number) => x > r[0] + m && x < r[1] - m && z > r[2] + m && z < r[3] - m; +export function inScranton(x: number, z: number): boolean { + return inside([SCRANTON.minX, SCRANTON.maxX, SCRANTON.minZ, SCRANTON.maxZ], x, z, 0) || CORRIDORS.some((r) => inside(r, x, z, 0)); +} + +/** Navigation and player colliders use the same solid footprints. */ +export function scrantonNav(): NavGrid { + const bounds = { ...SCRANTON, maxX: 22.8, maxZ: -2.85 }; + const rects: Rect[] = [...PARTITIONS.map((p) => partitionRect(p)), ...WORKSTATIONS.map(deskRect), ...FURNISHINGS.map(furnitureRect), ...RECEPTION_BLOCKS]; + // The stair core is scenery, reached from the external building stairs rather than the suite. + rects.push(furnitureRect({ at: point(18.5, 3.2), width: 7.2, depth: 6.0 })); + return new NavGrid(bounds, { rects, circles: [] }, (x, z) => inScranton(x, z)); +} diff --git a/tests/ambient-choreography.test.ts b/tests/ambient-choreography.test.ts new file mode 100644 index 000000000..be8db879a --- /dev/null +++ b/tests/ambient-choreography.test.ts @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CAST } from '../src/client/features/ambient-office/cast.js'; +import { SCENES } from '../src/client/features/ambient-office/scenes.js'; +import { MANNER, perform, TALK_AT, BEAT_MS, RESPONSE_AT } from '../src/client/features/ambient-office/choreography.js'; +import { DAY_MS, SLOT_MS, sampleOffice } from '../src/client/features/ambient-office/simulation.js'; +import { GesturePlayer } from '../src/client/features/ambient-office/performance.js'; +import { EMOTE_BY_ID } from '../src/shared/emotes.js'; +import type { Person } from '../src/client/world/character/person.js'; + +test('every coworker has an executable speaking and reaction manner', () => { + assert.deepEqual(Object.keys(MANNER).sort(), CAST.map(p => p.id).sort()); + for (const p of CAST) { + assert.ok(EMOTE_BY_ID.has(MANNER[p.id].speaks)); + assert.ok(MANNER[p.id].responds === null || EMOTE_BY_ID.has(MANNER[p.id].responds!)); + } + assert.notEqual(MANNER.jim.responds, MANNER.dwight.responds); + assert.equal(MANNER.stanley.responds, null); +}); + +test('all exchanges gather, speak, react, perform together and settle in a shared order', () => { + for (const scene of SCENES) { + assert.equal(perform(scene, 10000, 1).stage, 'gather'); + for (let beat = 0; beat < scene.beats.length; beat++) { + const at = TALK_AT + beat * BEAT_MS; + const speak = perform(scene, at + 500, 1); + assert.equal(speak.speaker, scene.beats[beat].who); + assert.equal(speak.stage, 'dialogue'); + assert.equal(speak.cues[speak.speaker!].age, 0.5); + assert.notEqual(speak.listener, speak.speaker); + const reply = perform(scene, at + RESPONSE_AT + 500, 1); + assert.equal(reply.stage, 'reaction'); + assert.ok(!reply.cues[reply.speaker!]); + for (const who of Object.keys(reply.cues)) assert.equal(who, reply.listener); + } + const end = TALK_AT + scene.beats.length * BEAT_MS; + assert.equal(perform(scene, end + 500, 1).stage, 'ensemble'); + assert.deepEqual(perform(scene, end + 500, 1), perform(scene, end + 500, 1)); + assert.equal(perform(scene, end + 8000, 1).stage, 'settle'); + assert.deepEqual(perform(scene, end + 8000, 1).cues, {}); + } +}); + +test('relationship replies and ensemble moments preserve individual personalities', () => { + const prank = SCENES.find(s => s.id === 'paperclips')!; + assert.equal(perform(prank, TALK_AT + RESPONSE_AT + 200, 2).cues.jim.emote, 'facepalm'); + assert.equal(perform(prank, TALK_AT + BEAT_MS + RESPONSE_AT + 200, 2).cues.dwight.emote, 'point'); + const briefing = SCENES.find(s => s.id === 'meeting')!; + const end = TALK_AT + briefing.beats.length * BEAT_MS; + assert.ok(!perform(briefing, end + 500, 2).cues.stanley); + const awards = SCENES.find(s => s.id === 'dundies')!; + const group = perform(awards, TALK_AT + awards.beats.length * BEAT_MS + 500, 2); + assert.equal(group.cues.michael.emote, 'wave'); + assert.equal(group.cues.pam.emote, 'clap'); + assert.equal(group.cues.dwight.age, group.cues.pam.age); +}); + +test('the snapshot separates speech from reactions and supplies partner attention', () => { + const start = 20 * DAY_MS + 4 * SLOT_MS; + const talking = sampleOffice(start + TALK_AT + 500, 'office'); + const speaking = talking.people.find(p => p.line)!; + assert.ok(speaking.gesture && speaking.attention); + const listener = talking.people.find(p => p.person.id === speaking.attention)!; + assert.equal(listener.attention, speaking.person.id); + const reply = sampleOffice(start + TALK_AT + RESPONSE_AT + 500, 'office'); + assert.equal(reply.people.filter(p => p.line).length, 0); + assert.equal(reply.performance!.stage, 'reaction'); + assert.equal(sampleOffice(start + 95000, 'office').performance, undefined); +}); + +test('animation seeks on late joins, does not restart each frame, and cancels on interruption', () => { + const starts: string[] = [], deltas: number[] = []; + const person = { emote: (id: string) => starts.push(id), update: (dt: number) => deltas.push(dt) } as unknown as Person; + const player = new GesturePlayer(); + player.update(person, { key: 'one', emote: 'clap', age: 1.1 }, 0.016, 0, false); + player.update(person, { key: 'one', emote: 'clap', age: 1.3 }, 0.016, 0, false); + assert.deepEqual(starts, ['clap']); + assert.equal(deltas[0], 1.1); + assert.ok(Math.abs(deltas[1] - 0.2) < 1e-10); + player.update(person, undefined, 0.016, 0, true); + assert.deepEqual(deltas.slice(-2), [5, 0.016]); + player.update(person, { key: 'one', emote: 'clap', age: 1.8 }, 0.016, 0, false); + assert.equal(starts.length, 2); + assert.equal(deltas.at(-1), 1.8); +}); diff --git a/tests/ambient-office.test.ts b/tests/ambient-office.test.ts new file mode 100644 index 000000000..aef568b8d --- /dev/null +++ b/tests/ambient-office.test.ts @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CAST, CAST_BY_ID } from '../src/client/features/ambient-office/cast.js'; +import { SCENES } from '../src/client/features/ambient-office/scenes.js'; +import { DAY_MS, SLOT_MS, sampleOffice, sceneFor } from '../src/client/features/ambient-office/simulation.js'; +import { checklist, workBrief } from '../src/client/features/ambient-office/notes.js'; +import { nearestWalkable, route, walkable } from '../src/shared/nav.js'; + +const atMinute = (minute: number, day = 20) => day * DAY_MS + (minute - 510) / 540 * DAY_MS; + +test('the ensemble has distinct identities, era-correct jobs and no fabricated canon birthdays', () => { + assert.equal(CAST.length, 19); + assert.equal(CAST_BY_ID.size, CAST.length); + assert.match(CAST_BY_ID.get('erin')!.job, /Receptionist/); + assert.match(CAST_BY_ID.get('pam')!.job, /administration/); + assert.match(CAST_BY_ID.get('holly')!.job, /Nashua/); + assert.equal(CAST_BY_ID.get('stanley')!.dessert, 'Cheesecake'); + for (const p of CAST) { + assert.equal(p.birthday, null); + assert.ok(p.routine.length >= 3); + assert.ok(p.personality && p.utility && p.greeting); + } +}); + +test('the floor schedule is deterministic, observer-independent and progresses without input', () => { + const timestamp = atMinute(600); + assert.deepEqual(sampleOffice(timestamp, 'a'), sampleOffice(timestamp, 'a')); + const first = sampleOffice(timestamp, 'a'); + const later = sampleOffice(timestamp + SLOT_MS * 3, 'a'); + assert.notEqual(first.clock, later.clock); + assert.notDeepEqual(first.people, later.people); + const reloaded = sampleOffice(timestamp + SLOT_MS * 3, 'a'); + assert.deepEqual(reloaded.memories, later.memories); + assert.equal(later.memories.length, 3); + assert.notEqual(sceneFor(20, 4, 'a').id, sceneFor(20, 4, 'b').id); +}); + +test('arrivals, staggered lunches, Stanley’s prompt departure and after-hours absence', () => { + assert.ok(sampleOffice(atMinute(510), 'a').people.filter((p) => p.present).length < 5); + const noon = sampleOffice(atMinute(12 * 60 + 20), 'a'); + assert.equal(noon.phase, 'lunch'); + const afternoon = sampleOffice(atMinute(15 * 60), 'a'); + assert.ok(afternoon.people.find((p) => p.person.id === 'stanley')!.present); + const five = sampleOffice(atMinute(17 * 60 + 0.1), 'a'); + assert.equal(five.people.find((p) => p.person.id === 'stanley')!.present, false); + const evening = sampleOffice(atMinute(17 * 60 + 20), 'a'); + assert.equal(evening.phase, 'after-hours'); + assert.ok(evening.people.every((p) => !p.present)); +}); + +test('group scenes have valid participants and speak in turns, with quiet recovery intervals', () => { + for (const scene of SCENES) { + assert.ok(new Set(scene.beats.map((b) => b.who)).size >= 2); + for (const beat of scene.beats) assert.ok(CAST_BY_ID.has(beat.who), beat.who); + } + const slot = 4; + const timestamp = 20 * DAY_MS + slot * SLOT_MS; + assert.ok(sampleOffice(timestamp + 36000, 'a').scene); + assert.equal(sampleOffice(timestamp + 36000, 'a').people.filter((p) => p.line).length, 1); + assert.equal(sampleOffice(timestamp + 95000, 'a').scene, undefined); + assert.equal(sampleOffice(timestamp + 95000, 'a').people.filter((p) => p.line).length, 0); + assert.ok(sampleOffice(timestamp + 95000, 'a').memories.includes(sceneFor(20, slot, 'a').aftermath)); +}); + +test('visitors are present for their own scenes and away during ordinary routines', () => { + let visitorScenes = 0; + for (let day = 20; day < 25; day++) for (let slot = 2; slot < 18; slot++) { + const snapshot = sampleOffice(day * DAY_MS + slot * SLOT_MS + 36000, 'a'); + if (!snapshot.scene) continue; + for (const id of ['holly', 'david']) { + const state = snapshot.people.find((p) => p.person.id === id)!; + const participates = snapshot.scene.beats.some((b) => b.who === id); + assert.equal(state.present, participates); + if (participates) visitorScenes++; + } + } + assert.ok(visitorScenes > 0); +}); + +test('rituals are infrequent and recent outcomes carry over from yesterday without observation', () => { + let count = 0; + for (let day = 20; day < 25; day++) for (let slot = 0; slot < 20; slot++) if (sceneFor(day, slot, 'a').ritual) count++; + assert.equal(count, 2); + assert.equal(sceneFor(20, 11, 'a').id, 'pretzels'); + assert.equal(sceneFor(23, 11, 'a').id, 'dundies'); + assert.equal(sampleOffice(atMinute(511), 'a').memories.length, 3); +}); + +test('home and gathering destinations are reachable through furniture-safe navigation', () => { + const positions = new Map(); + for (const p of CAST) positions.set(p.id, nearestWalkable(p.home)); + for (let slot = 2; slot < 18; slot++) { + const snapshot = sampleOffice(20 * DAY_MS + slot * SLOT_MS + 36000, 'a'); + for (const state of snapshot.people.filter((p) => p.present)) { + const from = positions.get(state.person.id)!; + const goal = nearestWalkable(state.target); + assert.ok(walkable(...goal)); + const path = route(from, goal); + assert.ok(path.length > 0, `${state.person.id}: ${from} to ${goal}`); + for (const point of path) assert.ok(walkable(...point), `${state.person.id}: ${point}`); + positions.set(state.person.id, goal); + } + } +}); + +test('Pam organizes actual user tasks and briefs clearly describe unperformed external work', () => { + assert.equal(checklist(' - Call Sam\n\n2. Review draft\n• Send notes'), '☐ Call Sam\n☐ Review draft\n☐ Send notes'); + assert.equal(checklist(' \n '), ''); + const brief = workBrief(CAST_BY_ID.get('dwight')!, 'Audit the shipment list'); + assert.match(brief, /Audit the shipment list/); + assert.match(brief, /prerequisites/); + assert.match(brief, /has not sent messages, scheduled events/); + assert.match(workBrief(CAST_BY_ID.get('ryan')!, 'Pitch'), /Verify factual claims/); + assert.match(workBrief(CAST_BY_ID.get('kevin')!, 'Explain'), /low-stakes/); +}); diff --git a/tests/client-registry.test.ts b/tests/client-registry.test.ts index 09487039e..be411d9dd 100644 --- a/tests/client-registry.test.ts +++ b/tests/client-registry.test.ts @@ -358,7 +358,7 @@ test('interactions: one definition per kind, with its reach, hint and use', () = function clientSources(): { file: string; src: string }[] { const root = path.join(import.meta.dirname, '../src/client'); const code = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); - return (readdirSync(root, { recursive: true }) as string[]).filter((rel) => rel.endsWith('.ts')).map((file) => ({ file, src: code(readFileSync(path.join(root, file), 'utf8')) })); + return (readdirSync(root, { recursive: true }) as string[]).filter((rel) => rel.endsWith('.ts')).map((file) => ({ file: file.replaceAll('\\', '/'), src: code(readFileSync(path.join(root, file), 'utf8')) })); } /** diff --git a/tests/scranton.test.ts b/tests/scranton.test.ts new file mode 100644 index 000000000..ff8144bc2 --- /dev/null +++ b/tests/scranton.test.ts @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CAST } from '../src/client/features/ambient-office/cast.js'; +import { DAY_MS, SLOT_MS, sampleOffice } from '../src/client/features/ambient-office/simulation.js'; +import { scrantonRoutines } from '../src/client/features/scranton/routines.js'; +import { SCRANTON, SCRANTON_ENTRY, SCRANTON_DOOR, HOMES, WORKSTATIONS, PARTITIONS, ROOMS, scrantonNav, inScranton, partitionRect, deskRect, point } from '../src/shared/scranton.js'; +import { OFFICE_OPENINGS } from '../src/shared/office-openings.js'; +import { OFFICE_FOOTPRINTS, reservedOfficeSpace } from '../src/shared/office-footprints.js'; + +const nav = scrantonNav(); +const space = scrantonRoutines(nav); +const contains = (r: number[], x: number, z: number) => x >= r[0] && x <= r[1] && z >= r[2] && z <= r[3]; + +test('the Scranton suite is additional space with a registered doorway in the original east wall', () => { + assert.ok(SCRANTON.maxZ < -13); + assert.ok(OFFICE_OPENINGS.includes(SCRANTON_DOOR)); + assert.equal(SCRANTON_DOOR.wall, 'east'); + assert.ok(inScranton(...SCRANTON_ENTRY)); + assert.equal(inScranton(0, 0), false); +}); + +test('the replica clears neighboring buildings and reserves space against trees growing through it', async () => { + const { neighbourBoxes } = await import('../src/client/world/outside.js'); + for (const r of OFFICE_FOOTPRINTS) for (const b of neighbourBoxes()) { + assert.ok(b.maxX <= r[0] || b.minX >= r[1] || b.maxZ <= r[2] || b.minZ >= r[3], 'an exterior building intrudes into the room'); + } + assert.ok(reservedOfficeSpace(-12, -22, 3)); + assert.ok(reservedOfficeSpace(4, -24, 2.5)); + assert.equal(reservedOfficeSpace(-34, 22, 3), false); +}); + +test('every cast member has a reachable home within the suite, outside desk and wall footprints', () => { + assert.equal(Object.keys(HOMES).length, CAST.length); + for (const p of CAST) { + const home = HOMES[p.id]; + assert.ok(home, p.id); + assert.ok(home[0] > SCRANTON.minX && home[0] < SCRANTON.maxX && home[1] > SCRANTON.minZ && home[1] < SCRANTON.maxZ); + assert.ok(nav.walkable(...home), p.id); + assert.ok(PARTITIONS.every((wall) => !contains(partitionRect(wall), ...home)), p.id); + assert.ok(WORKSTATIONS.every((desk) => !contains(deskRect(desk), ...home)), p.id); + const path = nav.route(SCRANTON_ENTRY, home); + assert.deepEqual(path.at(-1), home); + for (let i = 1; i < path.length; i++) assert.ok(nav.clearLine(path[i - 1], path[i]), `${p.id}: route cannot cross partitions`); + } +}); + +test('the main office links through the corridor to reception, Michael, conference, kitchen and annex', () => { + for (const id of ['reception', 'michael', 'conference', 'kitchen', 'annex', 'breakroom', 'ryan'] as const) { + const goal = nav.nearestWalkable(ROOMS[id].at); + const path = nav.route(SCRANTON_ENTRY, goal); + for (let i = 1; i < path.length; i++) assert.ok(nav.clearLine(path[i - 1], path[i]), `${id}: unreachable room`); + } +}); + +test('the era-correct desks put Erin at reception, Pam in sales/admin, and HR/customer service in the annex', () => { + assert.ok(HOMES.erin[0] < HOMES.jim[0]); + assert.ok(HOMES.jim[0] === HOMES.dwight[0]); + assert.ok(HOMES.jim[1] < HOMES.dwight[1]); + assert.ok(HOMES.pam[0] > HOMES.jim[0]); + assert.ok(HOMES.toby[0] > point(22.4, 0)[0]); + assert.ok(HOMES.kelly[0] > point(22.4, 0)[0]); +}); + +test('quiet work seats the cast at their own workstations without changing the independent schedule', () => { + const original = sampleOffice(20 * DAY_MS + 4 * SLOT_MS + 95000, 'scranton'); + const adapted = space.adapt(original); + assert.equal(adapted.clock, original.clock); + assert.deepEqual(adapted.memories, original.memories); + for (const state of adapted.people) { + assert.deepEqual(state.target, HOMES[state.person.id]); + if (WORKSTATIONS.some((s) => s.id === state.person.id) || state.person.id === 'erin') assert.ok(state.seat); + } + assert.equal(original.people[0].seat, undefined); +}); + +test('all autonomous scenes and lunch breaks move through the replica rather than the coding floor', () => { + for (let day = 20; day < 25; day++) for (let slot = 2; slot < 18; slot++) { + const snapshot = space.adapt(sampleOffice(day * DAY_MS + slot * SLOT_MS + 36000, 'scranton')); + for (const state of snapshot.people) { + assert.ok(inScranton(...state.target)); + assert.ok(nav.walkable(...state.target)); + const path = nav.route(HOMES[state.person.id], state.target); + for (let i = 1; i < path.length; i++) assert.ok(nav.clearLine(path[i - 1], path[i]), `${snapshot.scene?.id ?? snapshot.phase}/${state.person.id} has a blocked route`); + } + } +});