diff --git a/README.md b/README.md index 7a74c8eb..2702e630 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ voice-lab's virtualenv, which loads it, and the first time downloads it, even wi | Space | Jump. Hold it to jump higher, and run to jump further. Walk off a ledge and you land safely on whatever is below. | | ↑ (or W) | Upstairs or up a ladder, from the tile beside its foot. On the lift, up a floor. | | ↓ (or S) | Downstairs or down a ladder, from the tile beside its top. On the lift, down a floor. | -| E | Read the news at a terminal, look at a mural or a painting, or order at a counter. Otherwise go through a door, or talk to the ringed resident. | +| E | Read the news at a terminal, look at a mural or a painting, order at a counter, or pick up what you left when you collapsed. Otherwise go through a door, or talk to the ringed resident. | +| F | Eat, once you're peckish: a meal at home, at the café or the noodle bar, or food you carry. Hunger rises while you play, and if it runs out you collapse. | | Enter | Talk to the ringed resident, then send what you typed. | | Tab | Choose who to talk to. Clicking a resident does too. | | Esc | Close the chat or a panel. | diff --git a/client/src/net/index.test.ts b/client/src/net/index.test.ts index 801b9ca5..990a0ecf 100644 --- a/client/src/net/index.test.ts +++ b/client/src/net/index.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from 'vitest'; -import { server, type LineMessage } from '.'; +import { playerState, server, type LineMessage, type PlayerStateMessage } from '.'; const line: LineMessage = { type: 'line', @@ -14,6 +14,25 @@ const line: LineMessage = { voiced: false, }; +const hungry: PlayerStateMessage = { + type: 'player_state', + player: { + money: 4, + items: [], + carry_limit: 10, + body: { + legs: 'present', + vertebra: 'present', + left_eye: 'present', + right_eye: 'present', + left_kidney: 'present', + right_kidney: 'present', + }, + hunger: 0.6, + }, + meal: { place: 'noodle_bar', name: 'Golden Bowl Noodles', price: 10 }, +}; + it('imports in tests without connecting', () => { expect(server.send({ type: 'player_move', position: { x: 1, y: 2 } })).toBe(false); }); @@ -31,3 +50,13 @@ it('lets tests feed in messages and spy on what is sent', () => { unsubscribe(); send.mockRestore(); }); + +it('keeps the last player_state for scenes that start later', () => { + server.dispatch(JSON.stringify(hungry)); + expect(playerState.latest).toEqual(hungry); + + server.dispatch(JSON.stringify({ ...hungry, meal: undefined, player: { ...hungry.player, hunger: 0.61 } })); + + expect(playerState.latest?.player.hunger).toBe(0.61); + expect(playerState.latest?.meal).toBeUndefined(); +}); diff --git a/client/src/net/index.ts b/client/src/net/index.ts index 45c4a9fe..ef6f1bc8 100644 --- a/client/src/net/index.ts +++ b/client/src/net/index.ts @@ -1,4 +1,5 @@ import { ServerConnection } from './connection'; +import type { PlayerStateMessage } from './protocol'; export { SAMPLE_RATE, type AudioChunk } from './audio'; export type { ServerEvent, ServerEvents } from './connection'; @@ -7,6 +8,10 @@ export type * from './protocol'; /** Messages aren't replayed, so subscribe when your module loads, before the first one arrives. */ export const server = new ServerConnection(); +/** The last `player_state`, for scenes that start after it arrives. */ +export const playerState: { latest?: PlayerStateMessage } = {}; +server.on('player_state', (message) => (playerState.latest = message)); + if (import.meta.env.MODE !== 'test') { server.on('snapshot', (snapshot) => console.info('Snapshot from the sim server', snapshot)); server.connect(); diff --git a/client/src/side/README.md b/client/src/side/README.md index e49474f3..936362c6 100644 --- a/client/src/side/README.md +++ b/client/src/side/README.md @@ -100,6 +100,10 @@ The Phaser-free halves are tested under vitest. lands with a squash over `LANDING_SECONDS`; its shadow stays on the ground under it, fading with height. The camera stays at the height the jump left from, following only a fall below it (`steady`). Anywhere off a whole row, as a snapshot can leave the player, they fall. +- When the player collapses from hunger, `player_collapsed` puts them at home, as through a door, + once the street is shown and any door they're going through is done. It's also where the scene + starts them next (`sideResidents.start`), for a tab that wasn't in the game when they collapsed. After every `snapshot` the + scene sends the player's position again, so a restarted server knows where they are. - The server keeps every position a jump or a fall sends, and ignores one nobody could be at (`simile.street.moves.possible`, `docs/protocol.md`). `server/tests/street/positions.json` is a table of the client's sends and places nobody could be that the server's tests and diff --git a/client/src/side/residents.test.ts b/client/src/side/residents.test.ts index 96070546..6eca2ff0 100644 --- a/client/src/side/residents.test.ts +++ b/client/src/side/residents.test.ts @@ -3,7 +3,8 @@ import type { Entity, EntityMovedMessage, Facing, Pose, Position } from '../net' import { WALKING_SPEED } from '../world/tuning'; import { village } from './content'; import { CLIMB_SPEED, RIDE_SPEED, Walker, WALK_CYCLE_M } from './motion'; -import { Residents } from './residents'; +import { server } from '../net'; +import { Residents, sideResidents } from './residents'; const odalys: Entity = { id: 'odalys', @@ -200,3 +201,15 @@ describe('crossing between floors', () => { expect(all.all.get('odalys')!.under).toBeUndefined(); }); }); + +describe('the player', () => { + it('starts where the snapshot says, or at home once they collapse', () => { + const player = { id: 'player', kind: 'player', name: 'You', position: { x: 51, y: 15, map: 'high_street' } }; + server.dispatch(JSON.stringify({ type: 'snapshot', time: '2026-03-14T09:10:03Z', map_id: 'high_street', entities: [player], conversations: [] })); + expect(sideResidents.start).toEqual({ x: 51, y: 15, map: 'high_street' }); + + server.dispatch(JSON.stringify({ type: 'player_collapsed', position: { x: 13, y: 2, map: 'maker_space_floor_0' } })); + + expect(sideResidents.start).toEqual({ x: 13, y: 2, map: 'maker_space_floor_0' }); + }); +}); diff --git a/client/src/side/residents.ts b/client/src/side/residents.ts index 788837e3..1bc73640 100644 --- a/client/src/side/residents.ts +++ b/client/src/side/residents.ts @@ -143,3 +143,5 @@ export class Residents { export const sideResidents = new Residents(); server.on('snapshot', ({ entities, map_id }) => sideResidents.reset(entities, map_id)); server.on('entity_moved', (message) => sideResidents.move(message)); +// So a tab that wasn't in the game when the player collapsed starts them at home. +server.on('player_collapsed', ({ position: { x, y, map = '' } }) => (sideResidents.start = { x, y, map })); diff --git a/client/src/side/side-scene.ts b/client/src/side/side-scene.ts index ec0c2474..3d1aedea 100644 --- a/client/src/side/side-scene.ts +++ b/client/src/side/side-scene.ts @@ -74,6 +74,8 @@ export class SideScene extends Phaser.Scene { private readonly running = new RunToggle(); private jumped = false; private passing = false; + /** Where they wake once the street is shown and any door they are going through is done. */ + private waking?: MapPosition; private worksChanged = true; private snap = true; /** A tap shorter than a frame still counts. */ @@ -103,7 +105,11 @@ export class SideScene extends Phaser.Scene { positionOf: (id) => this.positionOf(id), listenerPosition: () => this.positionOf(PLAYER_ID), }), - server.on('snapshot', () => this.throttle.forget()), + server.on('snapshot', () => { + this.throttle.forget(); + this.sent.map = ''; + }), + server.on('player_collapsed', ({ position: { x, y, map } }) => this.wake({ x, y, map: map ?? '' })), setMuralView({ wallAt: ({ x, y }) => { const point = this.cameras.main.getWorldPoint(x, y); @@ -129,10 +135,12 @@ export class SideScene extends Phaser.Scene { const { startHarness } = await import('./harness'); startHarness(this, content.village); } - const start = this.startPosition(content.village); + const start = this.waking ?? this.startPosition(content.village); + this.waking = undefined; this.player = new SidePlayer(content.village, start); await this.showStreet(content.village.map(start.map)!.street); this.cameras.main.fadeIn(DOOR_FADE_IN_SECONDS * 1000); + this.wakeIfWaiting(); } /** For the dev harness. */ @@ -238,6 +246,18 @@ export class SideScene extends Phaser.Scene { this.view.setTo(this.centre.x - size.width / 2, this.centre.y - size.height / 2, size.width, size.height); } + /** The server moved them home after they collapsed. */ + private wake(at: MapPosition): void { + if (this.passing || !this.street) this.waking = at; + else this.takeDoorTo(at); + } + + private wakeIfWaiting(): void { + const waking = this.waking; + this.waking = undefined; + if (waking) this.takeDoorTo(waking); + } + private takeDoor(door: Link): void { this.takeDoorTo({ x: door.to.x, y: door.to.y, map: door.to.map }); } @@ -261,6 +281,7 @@ export class SideScene extends Phaser.Scene { } finally { this.passing = false; camera.fadeIn(DOOR_FADE_IN_SECONDS * 1000); + this.wakeIfWaiting(); } }); } diff --git a/client/src/ui/README.md b/client/src/ui/README.md index c5c81648..0dcec6f0 100644 --- a/client/src/ui/README.md +++ b/client/src/ui/README.md @@ -15,7 +15,8 @@ The text side of conversations, drawn as HTML over the game canvas by `DialogueS - a scrollable log of everything the player has heard or said, with an NPC's lines to the player marked "→ You". - the news and residents' writing at a terminal (see *Reading the news*); -- a mural up close (see *Looking at a mural*). +- a mural up close (see *Looking at a mural*); +- how hungry the player is and what they can eat (see *Hunger*). ## Choosing who to talk to @@ -97,14 +98,30 @@ player as they step up and takes the order over `player_say` like any conversati ([`simile.cafe`](../../../server/simile/cafe/README.md)). Other kinds of interactable get their own prompts from their own tasks. +## Hunger + +The top-left corner shows the player's hunger from the last `player_state` (`playerState` in +[`net/`](../net/index.ts)), in [`hunger.ts`](hunger.ts): + +- "You're hungry." from 0.5, and "You're starving. Eat soon, or you'll collapse." from 0.85. The + server collapses them at 1. +- From 0.3, what F eats: the meal on offer where they stand if they can pay ("Press F to eat at + home.", "Press F to eat at Golden Bowl Noodles for 10."), else the first food they carry ("Press F + to eat your pork bun."). F sends `eat`, with the item's id or `null` for the meal. From 0.5, with + nothing F can eat, it says where they could, or what the meal here costs against what they have. +- "Press E to pick up what you left here." within a tile of a pile they left when they collapsed + (`pileInReach` in [`world/reach.ts`](../world/reach.ts)), where E sends `pick_up`. +- "You collapsed from hunger and woke at home." for six seconds after `player_collapsed`. The world + scene puts the player at home. + ## Keys -The game's keys (Enter, Tab, Esc, E, ?) are handled on `window` only when nothing on the page has +The game's keys (Enter, Tab, Esc, E, F, ?) are handled on `window` only when nothing on the page has focus (`onGameKey` in [`keys.ts`](keys.ts)), so a focused link, button or input keeps its own Enter, Tab and Esc. E uses the nearest thing in reach, as `usedByE` in [`world/reach.ts`](../world/reach.ts) finds it: -a terminal, the counter, a painting (the gallery panel's) or a mural. The world scene takes a door +a terminal, the counter, a painting (the gallery panel's), a mural or a pile the player left. The world scene takes a door on E only when none is, so one press never does two things, and then up takes the door. With neither, E opens the chat to the ringed NPC, as Enter does, and their name tag says so: "E: talk to Lachlan" (`eUse` in [`keys.ts`](keys.ts)). Stairs, ladders and the lift are the arrows' alone. A small tag over each door, flight, ladder or lift a key diff --git a/client/src/ui/dialogue-scene.ts b/client/src/ui/dialogue-scene.ts index 364694e7..cf514b2d 100644 --- a/client/src/ui/dialogue-scene.ts +++ b/client/src/ui/dialogue-scene.ts @@ -1,5 +1,5 @@ import Phaser from 'phaser'; -import { server, type Position } from '../net'; +import { playerState, server, type Position } from '../net'; import { maps } from '../world/assets'; import type { Interactable } from '../world/interactables'; import { muralWalls } from '../world/murals'; @@ -11,6 +11,7 @@ import { CounterPrompt, servingAt } from './counter'; import { dialogue } from './dialogue'; import './dialogue.css'; import { HelpPanel } from './help'; +import { HungerHint, toEat } from './hunger'; import { eUse, onGameKey } from './keys'; import { LogPanel } from './log-panel'; import { MuralPanel } from './mural-panel'; @@ -34,6 +35,7 @@ export class DialogueScene extends Phaser.Scene { private ways!: WayPromptLayer; private murals!: MuralPanel; private counter!: CounterPrompt; + private hunger!: HungerHint; private chat!: ChatBox; private readonly targets = new ChatTarget(worldView, dialogue); private fitted = ''; @@ -63,8 +65,10 @@ export class DialogueScene extends Phaser.Scene { this.ways = new WayPromptLayer(this.root); this.murals = new MuralPanel(this.root, muralWalls); this.counter = new CounterPrompt(this.root); + const hunger = (this.hunger = new HungerHint(this.root)); + const stopCollapses = server.on('player_collapsed', () => hunger.collapsed()); const help = new HelpPanel(this.root); - const onKeyDown = onGameKey({ chat, use: () => this.pressE(), help }); + const onKeyDown = onGameKey({ chat, use: () => this.pressE(), eat: () => this.eat(), help }); const onPointerDown = ({ x, y }: Phaser.Input.Pointer) => { const npcId = clicked({ x, y }, worldView()); if (npcId) chat.choose(npcId); @@ -76,6 +80,7 @@ export class DialogueScene extends Phaser.Scene { window.removeEventListener('keydown', onKeyDown); this.input.off(Phaser.Input.Events.POINTER_DOWN, onPointerDown); stopInvitations(); + stopCollapses(); this.news.stop(); this.murals.stop(); log.stop(); @@ -100,6 +105,7 @@ export class DialogueScene extends Phaser.Scene { this.newsHint.hidden = !atTerminal || this.news.isOpen || this.murals.isOpen; this.murals.update(player, used?.kind === 'mural' && !this.news.isOpen); this.counter.update(used?.kind === 'counter' ? used.interactable : undefined, !this.chat.isOpen); + this.hunger.update(playerState.latest, used?.kind === 'pile'); const panelOpen = this.news.isOpen || this.murals.isOpen; const ways = panelOpen ? [] : wayPrompts(used !== undefined); this.ways.render(ways, this.fromGame); @@ -122,6 +128,13 @@ export class DialogueScene extends Phaser.Scene { this.murals.close(); this.news.open(used.terminal.label); } else if (used?.kind === 'mural') this.murals.look(this.player()); + else if (used?.kind === 'pile') server.send({ type: 'pick_up' }); + } + + /** F has the meal on offer here if they can pay, else eats something they carry. */ + private eat(): void { + const eat = toEat(playerState.latest); + if (eat) server.send({ type: 'eat', item: eat.kind === 'item' ? eat.item.id : null }); } /** At a counter, E opens the chat to whoever is serving behind it. */ diff --git a/client/src/ui/dialogue.css b/client/src/ui/dialogue.css index d58fc21b..db54f48c 100644 --- a/client/src/ui/dialogue.css +++ b/client/src/ui/dialogue.css @@ -170,6 +170,14 @@ text-shadow: 0 1px 0 #000; } +.hunger-hint { + position: absolute; + left: 4px; + top: 4px; + max-width: 260px; + text-shadow: 0 1px 0 #000; +} + .help-hint { position: absolute; right: 4px; diff --git a/client/src/ui/help.ts b/client/src/ui/help.ts index 291cd955..63880761 100644 --- a/client/src/ui/help.ts +++ b/client/src/ui/help.ts @@ -10,7 +10,8 @@ export const CONTROLS: readonly (readonly [keys: readonly string[], what: string [['Space'], 'jump, higher while held and further running'], [['↑', 'W'], 'upstairs or up a ladder from beside its foot, or the lift up'], [['↓', 'S'], 'downstairs or down a ladder from beside its top, or the lift down'], - [['E'], 'read the news at a terminal, look at a mural or a painting, order at a counter, go through a door, or talk to the ringed resident'], + [['E'], 'read the news at a terminal, look at a mural or a painting, order at a counter, pick up what you left, go through a door, or talk to the ringed resident'], + [['F'], 'eat: a meal at home, at the café or the noodle bar, or food you carry'], [['Enter'], 'talk to the ringed resident, then send'], [['Tab'], 'choose who to talk to'], [['Esc'], 'close the chat or a panel'], diff --git a/client/src/ui/hunger.test.ts b/client/src/ui/hunger.test.ts new file mode 100644 index 00000000..e3eefeb2 --- /dev/null +++ b/client/src/ui/hunger.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest'; +import type { Item, Meal, PlayerStateMessage } from '../net'; +import { COLLAPSED, eatPrompt, feeling, HungerHint, PICK_UP, toEat } from './hunger'; + +const NOODLES: Meal = { place: 'noodle_bar', name: 'Golden Bowl Noodles', price: 10 }; +const HOME: Meal = { place: 'maker_space', name: 'home', price: 0 }; +const BUN: Item = { id: 'pork_bun', kind: 'food', name: 'Pork bun', weight: 0.2, charge: null, on: null }; +const LAMP: Item = { id: 'lamp', kind: 'lamp', name: 'Lamp', weight: 1, charge: 1, on: false }; + +function state(hunger: number, { money = 0, items = [LAMP], meal }: { money?: number; items?: Item[]; meal?: Meal } = {}): PlayerStateMessage { + const body = { + legs: 'present', + vertebra: 'present', + left_eye: 'present', + right_eye: 'present', + left_kidney: 'present', + right_kidney: 'present', + } as const; + return { type: 'player_state', player: { money, items, carry_limit: 10, body, hunger }, ...(meal && { meal }) }; +} + +describe('feeling', () => { + it('says nothing until they are hungry, then warns them before they collapse', () => { + expect(feeling(0.49)).toBeUndefined(); + expect(feeling(0.5)).toBe("You're hungry."); + expect(feeling(0.85)).toBe("You're starving. Eat soon, or you'll collapse."); + }); +}); + +describe('toEat', () => { + it('has the meal on offer when they can pay, before what they carry', () => { + expect(toEat(state(0.6, { meal: HOME, items: [BUN] }))).toEqual({ kind: 'meal', meal: HOME }); + expect(toEat(state(0.6, { meal: NOODLES, money: 10, items: [BUN] }))).toEqual({ kind: 'meal', meal: NOODLES }); + }); + + it('eats the first food they carry when there is no meal they can pay for', () => { + expect(toEat(state(0.6, { meal: NOODLES, money: 9.5, items: [LAMP, BUN] }))).toEqual({ kind: 'item', item: BUN }); + expect(toEat(state(0.6, { items: [BUN] }))).toEqual({ kind: 'item', item: BUN }); + }); + + it('eats nothing with nothing to eat, or before they are peckish', () => { + expect(toEat(state(0.9, { meal: NOODLES, money: 3 }))).toBeUndefined(); + expect(toEat(state(0.29, { meal: HOME, items: [BUN] }))).toBeUndefined(); + expect(toEat(undefined)).toBeUndefined(); + }); +}); + +describe('eatPrompt', () => { + it('says what F eats', () => { + expect(eatPrompt(state(0.3, { meal: HOME }))).toBe('Press F to eat at home.'); + expect(eatPrompt(state(0.3, { meal: NOODLES, money: 12.5 }))).toBe('Press F to eat at Golden Bowl Noodles for 10.'); + expect(eatPrompt(state(0.3, { items: [BUN] }))).toBe('Press F to eat your pork bun.'); + }); + + it('once they are hungry with nothing F can eat, says where they could', () => { + expect(eatPrompt(state(0.49))).toBeUndefined(); + expect(eatPrompt(state(0.5))).toBe('Eat at home, or at the café or the noodle bar.'); + expect(eatPrompt(state(0.5, { meal: NOODLES, money: 4.25 }))).toBe( + 'A meal at Golden Bowl Noodles is 10, and you have 4.25.', + ); + }); +}); + +describe('HungerHint', () => { + const shown = (root: HTMLElement) => + [...root.querySelectorAll('.hunger-hint div')].filter((line) => !line.hidden).map((line) => line.textContent); + + it('shows how hungry they are, what to eat and what they left in reach', () => { + const root = document.createElement('div'); + const hint = new HungerHint(root); + + hint.update(undefined, false); + expect(shown(root)).toEqual([]); + + hint.update(state(0.2), false); + expect(shown(root)).toEqual([]); + + hint.update(state(0.75, { meal: HOME }), true); + expect(shown(root)).toEqual(["You're hungry.", 'Press F to eat at home.', PICK_UP]); + }); + + it('says they collapsed for a few seconds after it', () => { + const root = document.createElement('div'); + const hint = new HungerHint(root); + + hint.collapsed(1000); + hint.update(state(0.75), false, 6999); + expect(shown(root)[0]).toBe(COLLAPSED); + hint.update(state(0.75), false, 7000); + expect(shown(root)).not.toContain(COLLAPSED); + }); +}); diff --git a/client/src/ui/hunger.ts b/client/src/ui/hunger.ts new file mode 100644 index 00000000..3263af0e --- /dev/null +++ b/client/src/ui/hunger.ts @@ -0,0 +1,82 @@ +import type { Item, Meal, PlayerStateMessage } from '../net'; +import { EAT_KEY, NEWS_KEY } from './tuning'; + +/** Hunger from which F eats. */ +export const PECKISH = 0.3; +export const HUNGRY = 0.5; +/** The server collapses the player at 1. */ +export const STARVING = 0.85; +const COLLAPSED_MS = 6000; + +export type Eat = { kind: 'meal'; meal: Meal } | { kind: 'item'; item: Item }; + +const amount = (money: number) => String(Number(money.toFixed(2))); +const lowerFirst = (text: string) => text.charAt(0).toLowerCase() + text.slice(1); +const key = (name: string) => name.toUpperCase(); + +export function feeling(hunger: number): string | undefined { + if (hunger >= STARVING) return "You're starving. Eat soon, or you'll collapse."; + if (hunger >= HUNGRY) return "You're hungry."; + return undefined; +} + +/** What F eats once they're peckish: the meal on offer here if they can pay, else the first food they carry. */ +export function toEat(state: PlayerStateMessage | undefined): Eat | undefined { + if (!state || state.player.hunger < PECKISH) return undefined; + const { player, meal } = state; + if (meal && meal.price <= player.money) return { kind: 'meal', meal }; + const item = player.items.find(({ kind }) => kind === 'food'); + return item && { kind: 'item', item }; +} + +/** What F eats, or once they're hungry, where they could eat. */ +export function eatPrompt(state: PlayerStateMessage): string | undefined { + const { player, meal } = state; + const eat = toEat(state); + if (eat?.kind === 'item') return `Press ${key(EAT_KEY)} to eat your ${lowerFirst(eat.item.name)}.`; + if (eat) { + const { name, price } = eat.meal; + return `Press ${key(EAT_KEY)} to eat at ${name}${price ? ` for ${amount(price)}` : ''}.`; + } + if (player.hunger < HUNGRY) return undefined; + if (meal) return `A meal at ${meal.name} is ${amount(meal.price)}, and you have ${amount(player.money)}.`; + return 'Eat at home, or at the café or the noodle bar.'; +} + +export const COLLAPSED = 'You collapsed from hunger and woke at home.'; +export const PICK_UP = `Press ${key(NEWS_KEY)} to pick up what you left here.`; + +type Line = 'collapsed' | 'feeling' | 'eat' | 'pile'; + +/** How hungry the player is and what they can eat, in a corner, and where they can pick up what they left. */ +export class HungerHint { + private readonly lines = new Map(); + private collapsedUntil = 0; + + constructor(parent: HTMLElement) { + const root = parent.appendChild(document.createElement('div')); + root.className = 'hunger-hint'; + for (const line of ['collapsed', 'feeling', 'eat', 'pile'] as const) { + const element = root.appendChild(document.createElement('div')); + element.hidden = true; + this.lines.set(line, element); + } + } + + collapsed(now = Date.now()): void { + this.collapsedUntil = now + COLLAPSED_MS; + } + + update(state: PlayerStateMessage | undefined, pile: boolean, now = Date.now()): void { + this.show('collapsed', now < this.collapsedUntil ? COLLAPSED : undefined); + this.show('feeling', state && feeling(state.player.hunger)); + this.show('eat', state && eatPrompt(state)); + this.show('pile', pile ? PICK_UP : undefined); + } + + private show(line: Line, text: string | undefined): void { + const element = this.lines.get(line)!; + element.hidden = !text; + if (text && element.textContent !== text) element.textContent = text; + } +} diff --git a/client/src/ui/keys.test.ts b/client/src/ui/keys.test.ts index 7d92aa34..4de285fa 100644 --- a/client/src/ui/keys.test.ts +++ b/client/src/ui/keys.test.ts @@ -5,8 +5,9 @@ import { eUse, onGameKey } from './keys'; const chat = { open: vi.fn(), clear: vi.fn(), cycle: vi.fn() }; const use = vi.fn(); +const eat = vi.fn(); const help = { toggle: vi.fn(), close: vi.fn() }; -const handler = onGameKey({ chat, use, help }); +const handler = onGameKey({ chat, use, eat, help }); function press(target: EventTarget, key: string, init: KeyboardEventInit = {}): KeyboardEvent { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); @@ -52,6 +53,16 @@ describe('with nothing focused', () => { expect(use).toHaveBeenCalledTimes(2); }); + + it('F eats, but not held down or with a modifier', () => { + expect(press(document.body, 'f').defaultPrevented).toBe(true); + press(document.body, 'F', { shiftKey: true }); + press(document.body, 'f', { repeat: true }); + press(document.body, 'f', { metaKey: true }); + + expect(eat).toHaveBeenCalledTimes(2); + expect(use).not.toHaveBeenCalled(); + }); }); it.each(['input', 'textarea', 'a', 'button'])('leaves keys on a focused %s alone', (tag) => { @@ -61,6 +72,7 @@ it.each(['input', 'textarea', 'a', 'button'])('leaves keys on a focused %s alone const tab = press(element, 'Tab'); press(element, 'Escape'); press(element, 'e'); + press(element, 'f'); press(element, '?'); expect(enter.defaultPrevented).toBe(false); @@ -69,6 +81,7 @@ it.each(['input', 'textarea', 'a', 'button'])('leaves keys on a focused %s alone expect(chat.clear).not.toHaveBeenCalled(); expect(chat.cycle).not.toHaveBeenCalled(); expect(use).not.toHaveBeenCalled(); + expect(eat).not.toHaveBeenCalled(); expect(help.toggle).not.toHaveBeenCalled(); }); diff --git a/client/src/ui/keys.ts b/client/src/ui/keys.ts index 65f8b1bb..c5f73386 100644 --- a/client/src/ui/keys.ts +++ b/client/src/ui/keys.ts @@ -1,11 +1,12 @@ import type { Reachable, WayPrompt } from '../world/reach'; import type { ChatBox } from './chat-box'; import { HELP_KEY, type HelpPanel } from './help'; -import { NEWS_KEY } from './tuning'; +import { EAT_KEY, NEWS_KEY } from './tuning'; export type GameKeys = { chat: Pick; use: () => void; + eat: () => void; help: Pick; }; @@ -25,7 +26,7 @@ export function unfocused({ target }: Event): boolean { ); } -export function onGameKey({ chat, use, help }: GameKeys): (event: KeyboardEvent) => void { +export function onGameKey({ chat, use, eat, help }: GameKeys): (event: KeyboardEvent) => void { return (event) => { if (!unfocused(event)) return; const { key } = event; @@ -45,10 +46,17 @@ export function onGameKey({ chat, use, help }: GameKeys): (event: KeyboardEvent) } else if (pressedNewsKey(event)) { event.preventDefault(); use(); + } else if (pressed(event, EAT_KEY)) { + event.preventDefault(); + eat(); } }; } -export function pressedNewsKey({ key, repeat, ctrlKey, metaKey, altKey }: KeyboardEvent): boolean { - return key.toLowerCase() === NEWS_KEY && !repeat && !(ctrlKey || metaKey || altKey); +export function pressedNewsKey(event: KeyboardEvent): boolean { + return pressed(event, NEWS_KEY); +} + +function pressed({ key, repeat, ctrlKey, metaKey, altKey }: KeyboardEvent, letter: string): boolean { + return key.toLowerCase() === letter && !repeat && !(ctrlKey || metaKey || altKey); } diff --git a/client/src/ui/tuning.ts b/client/src/ui/tuning.ts index 55ba4275..b01a408b 100644 --- a/client/src/ui/tuning.ts +++ b/client/src/ui/tuning.ts @@ -14,3 +14,4 @@ export const LOG_LIMIT = 200; /** Opens the news at a terminal, and closes it. */ export const NEWS_KEY = 'e'; +export const EAT_KEY = 'f'; diff --git a/client/src/world/reach.test.ts b/client/src/world/reach.test.ts index d3b24283..58d5cb25 100644 --- a/client/src/world/reach.test.ts +++ b/client/src/world/reach.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { server, type Wall } from '../net'; +import { server, type Pile, type Wall } from '../net'; import { muralWalls, setMuralView } from './murals'; -import { inReach, reachables, setWayPrompts, usedByE, wayPrompts, type Reachables } from './reach'; +import { inReach, pileInReach, reachables, setWayPrompts, usedByE, wayPrompts, type Reachables } from './reach'; import type { Terminal } from './terminals'; const mural = (id: string, map: string, x: number, width: number): Wall => ({ @@ -84,6 +84,39 @@ describe('inReach', () => { }); }); +describe('a pile the player left', () => { + const lamp = { id: 'lamp', kind: 'lamp', name: 'Lamp', weight: 1, charge: 0.2, on: true } as const; + const pile = (map: string, x: number, y = 15): Pile => ({ at: { map, x, y }, items: [lamp] }); + const left = [pile('kapok_walk', 12), pile('kapok_walk', 20), pile('rooftops', 12, 8)]; + const withPiles: Reachables = { ...reachables, piles: () => left }; + + it('is in reach within a tile on its map, nearest first', () => { + const near = pileInReach(at('kapok_walk', 12.6), left); + expect(near?.pile).toBe(left[0]); + expect(near?.tiles).toBeCloseTo(0.6); + expect(pileInReach(at('kapok_walk', 19.5, 14.5), left)?.pile).toBe(left[1]); + expect(pileInReach(at('kapok_walk', 13.1), left)).toBeUndefined(); + expect(pileInReach(at('high_street', 12), left)).toBeUndefined(); + expect(pileInReach(at('rooftops', 12, 7.2), left)?.pile).toBe(left[2]); + }); + + it('is what E picks up', () => { + expect(inReach(at('kapok_walk', 11.5), withPiles)).toEqual([{ kind: 'pile', tiles: 0.5, pile: left[0] }]); + }); + + it('comes from the last player_state', () => { + expect(reachables.piles()).toEqual([]); + const body = { legs: 'present', vertebra: 'present', left_eye: 'present', right_eye: 'present', left_kidney: 'present', right_kidney: 'present' }; + const player = { money: 0, items: [], carry_limit: 10, body, hunger: 0.75, left }; + server.dispatch(JSON.stringify({ type: 'player_state', player })); + + expect(reachables.piles()).toEqual(left); + expect(usedByE(at('kapok_walk', 20))).toMatchObject({ kind: 'pile', pile: left[1] }); + server.dispatch(JSON.stringify({ type: 'player_state', player: { ...player, left: undefined } })); + expect(reachables.piles()).toEqual([]); + }); +}); + describe('wayPrompts', () => { const lift = { keys: ['E'], action: 'lift to the first floor', x: 10, y: 20 }; diff --git a/client/src/world/reach.ts b/client/src/world/reach.ts index 8a27a8f1..dd65aaa3 100644 --- a/client/src/world/reach.ts +++ b/client/src/world/reach.ts @@ -1,4 +1,4 @@ -import type { Position, Wall } from '../net'; +import { playerState, type Pile, type Position, type Wall } from '../net'; import { maps } from './assets'; import { interactableAt, type Interactable } from './interactables'; import { mapOf } from './map'; @@ -9,12 +9,14 @@ import { terminalAt, terminalsOn, type Terminal } from './terminals'; export type Reachable = | { kind: 'terminal'; tiles: number; terminal: Terminal } | { kind: 'counter' | 'painting'; tiles: number; interactable: Interactable } - | { kind: 'mural'; tiles: number; wall: Wall }; + | { kind: 'mural'; tiles: number; wall: Wall } + | { kind: 'pile'; tiles: number; pile: Pile }; export type Reachables = { terminals: readonly Terminal[]; walls(): readonly Wall[]; interactableAt(map: string, position: Position): Interactable | undefined; + piles(): readonly Pile[]; }; /** Murals only count while a scene draws them, as for the mural panel. */ @@ -22,8 +24,23 @@ export const reachables: Reachables = { terminals: terminalsOn(maps), walls: () => (muralView() ? muralWalls.walls : []), interactableAt, + piles: () => playerState.latest?.player.left ?? [], }; +/** Inside the server's reach of a tile and a half, so a pick-up E offers still reaches. */ +export const PICK_UP_TILES = 1; + +/** The nearest pile the player left on this map, within reach of where they stand. */ +export function pileInReach(position: Position, piles: readonly Pile[]): { pile: Pile; tiles: number } | undefined { + let best: { pile: Pile; tiles: number } | undefined; + for (const pile of piles) { + if (pile.at.map !== mapOf(position)) continue; + const tiles = Math.hypot(pile.at.x - position.x, pile.at.y - position.y); + if (tiles <= PICK_UP_TILES && (!best || tiles < best.tiles)) best = { pile, tiles }; + } + return best; +} + const isUsed = (kind: string): kind is 'counter' | 'painting' => kind === 'counter' || kind === 'painting'; /** What E can use where the player stands, nearest first. */ @@ -43,6 +60,8 @@ export function inReach(position: Position | undefined, from: Reachables = reach } const wall = wallNear(from.walls(), position); if (wall) found.push({ kind: 'mural', tiles: tilesToWall(wall, position), wall }); + const pile = pileInReach(position, from.piles()); + if (pile) found.push({ kind: 'pile', ...pile }); return found.sort((a, b) => a.tiles - b.tiles); } diff --git a/server/simile/player/README.md b/server/simile/player/README.md new file mode 100644 index 00000000..2dcc4207 --- /dev/null +++ b/server/simile/player/README.md @@ -0,0 +1,48 @@ +# simile.player + +S5: the player gets hungry. The player's state is `player_of(app)` (`simile.persistence`), saved +with the world. This module changes it at runtime and tells the client: its `running` hook makes a +`Hunger` and a `PlayerSync` and ticks both on the sim loop. + +## Hunger + +- **It rises** by `rise` (0.25) a sim hour, from fed (0) to collapsing (1) in four hours, but only + while a client is in the game: one that has sent a player position since it connected + (`InGame`). Nobody starves while the game is closed or on the menu or the character creator, and + `simile headless` and `simile behaviour simulate` never change the player. +- **Eating** is the client's `eat` (`docs/protocol.md`): + - **At home**, anywhere in the building, a meal is free and relieves `home_meal` (all of it). + - **At a shop** in `meals`, the café or the noodle bar, anywhere in the building, a meal costs + the economy's meal price for that shop (5 at the Daily Grind, 10 at the noodle bar) and + relieves the shop's `meals` share (0.5 and 1). It's on offer only while the shop would feed a + resident, by B1's `supply` hook: the café while its barista is on shift, the noodle bar while + its keeper is in and their batch lasts. The player pays the keeper through the ledger as from + `OUTSIDE`, since the ledger keeps residents' money and the player isn't one, and the keeper + pays the supplies as for any sale. A noodle-bar meal comes out of the batch. + - **Food they carry** relieves `food` (0.4) an item. + - `meal()` is the meal on offer where they stand, which goes to the client in `player_state` so + its hint can offer it. Ordering a drink at the café's counter is unchanged and free. +- **Collapsing.** At 1 they collapse. Everything they carry but the lamp is left in a `Pile` where + they fell, on the ground below if they were mid-air or partway up stairs or a ladder, at the end + of `Player.left`. They wake at home, the entry of `home`, with hunger `waking` (0.75), and the + client gets `player_collapsed` to put them there. The lamp and their money stay with them, so + they can see to go back for the rest. +- **Picking up.** The client's `pick_up` takes from the nearest pile they left within `reach` (1.5 + tiles) on the map they're on, each item that still fits under their carry limit, in order. Any + that don't fit stay in the pile. + +Home is `PlayerConfig.home`, the maker space: the village has no house for the player, and every +house is someone's. Aris's workshop is the place the story makes theirs until one is added. + +## `player_state` + +`PlayerSync` sends `player_state`, the player and the meal on offer, to every client: + +- to each new client after the snapshot and clock, and after the other modules' greetings, since + it greets from `running`; +- at once after any change but to hunger or a lamp's charge: money, items, a lamp's switch, the + body, a pile, or the meal on offer as they walk in or out of somewhere to eat; +- and for hunger or a lamp's charge alone, once one of them has moved by `step` (0.01) since the + last one sent. That's every 2.4 sim minutes of hunger. + +Every constant is in `PlayerConfig`. diff --git a/server/simile/player/__init__.py b/server/simile/player/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/simile/player/clients.py b/server/simile/player/clients.py new file mode 100644 index 00000000..dda4e259 --- /dev/null +++ b/server/simile/player/clients.py @@ -0,0 +1,17 @@ +import asyncio + + +class InGame: + """The clients in the game: those that have sent a player position since connecting.""" + + def __init__(self) -> None: + self._clients: set[asyncio.Task[object]] = set() + + def moved(self, message: object) -> None: + # The hub hands each client's messages to subscribers on that client's own task. + if (client := asyncio.current_task()) is not None: + self._clients.add(client) + + def __bool__(self) -> bool: + self._clients = {client for client in self._clients if not client.done()} + return bool(self._clients) diff --git a/server/simile/player/config.py b/server/simile/player/config.py new file mode 100644 index 00000000..26c2b894 --- /dev/null +++ b/server/simile/player/config.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, ConfigDict, Field + +from simile.schema import Id + + +class PlayerConfig(BaseModel): + """How the player gets hungry, eats and collapses.""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + home: Id = Field( + default="maker_space", + description="Where they live: a meal there is free, and they wake there after " + "collapsing. The village has no house for them, so it's Aris's workshop.", + ) + rise: float = Field( + default=0.25, + gt=0, + description="How much hungrier they get each sim hour while someone's playing: from fed " + "to collapsing in four hours.", + ) + waking: float = Field( + default=0.75, ge=0, lt=1, description="Their hunger when they wake after collapsing." + ) + home_meal: float = Field(default=1.0, gt=0, description="How much a meal at home relieves.") + meals: dict[Id, float] = Field( + default={"coffee_shop": 0.5, "noodle_bar": 1.0}, + description="How much a meal at each shop relieves, by location id. Only these shops " + "feed the player, at the economy's meal price, and only while they'd feed a resident.", + ) + food: float = Field(default=0.4, gt=0, description="How much a food item they carry relieves.") + reach: float = Field( + default=1.5, gt=0, description="How far from a pile, in tiles, they can pick it up." + ) + step: float = Field( + default=0.01, + gt=0, + description="How far hunger or a lamp's charge moves before a change to it alone is sent.", + ) + + +DEFAULT_CONFIG = PlayerConfig() diff --git a/server/simile/player/hunger.py b/server/simile/player/hunger.py new file mode 100644 index 00000000..90de2fa9 --- /dev/null +++ b/server/simile/player/hunger.py @@ -0,0 +1,193 @@ +import logging +import math +from collections.abc import Callable +from typing import TYPE_CHECKING + +from simile.behaviour import Activity, Goal, Need +from simile.economy import OUTSIDE +from simile.player.config import DEFAULT_CONFIG, PlayerConfig +from simile.protocol import Meal +from simile.schema import PLAYER_ID, Id, ItemKind, Location, MapTile, Pile, Player +from simile.sim import Clock +from simile.world.movement import Movement, Point, cell_of, inside + +if TYPE_CHECKING: + from simile.economy.economy import Economy + +logger = logging.getLogger(__name__) + +HOUR = 3600.0 +HOME = "home" + +type Supply = Callable[[Goal, Need], float] + + +class Hunger: + """The player's hunger, eating, collapsing and picking up. Tick it on the sim loop.""" + + def __init__( + self, + player: Player, + movement: Movement, + clock: Clock, + *, + supply: Supply = lambda goal, need: 1.0, + economy: "Economy | None" = None, + playing: Callable[[], bool] = lambda: True, + collapsed: Callable[[MapTile], None] = lambda home: None, + config: PlayerConfig = DEFAULT_CONFIG, + ) -> None: + self.player = player + self.movement = movement + self.clock = clock + self.supply = supply + """B1's supply hook: whether a shop would feed a resident now.""" + self.economy = economy + self.playing = playing + self.collapsed = collapsed + """Told where they wake after collapsing.""" + self.config = config + + def tick(self, dt: float) -> None: + if not self.playing(): + return + hunger = self.player.hunger + self.config.rise * dt / HOUR + if hunger >= 1: + self.collapse() + else: + self.player.hunger = hunger + + def meal(self) -> Meal | None: + """The meal on offer where they stand, if any.""" + here = self._here() + if here is None: + return None + home = self._location(self.config.home) + if home is not None and inside(home, here): + return Meal(place=home.id, name=HOME, price=0) + for place in self.config.meals: + shop, price = self._location(place), self._price(place) + if shop is None or price is None or not inside(shop, here): + continue + return Meal(place=place, name=shop.name, price=price) if self._serving(place) else None + return None + + def eat(self, item: Id | None) -> bool: + """Eats a carried food item by id, or with None the meal on offer. Whether they ate.""" + return self._eat_meal() if item is None else self._eat_item(item) + + def collapse(self) -> None: + """Leaves all but the lamp where they are, and wakes them at home still hungry.""" + here = self._here() + kept = [item for item in self.player.items if item.kind is ItemKind.LAMP] + dropped = [item for item in self.player.items if item.kind is not ItemKind.LAMP] + if here is not None and dropped: + pile = Pile(at=ground(self.movement, here), items=dropped) + self.player.left = [*self.player.left, pile] + self.player.items = kept + location = self.movement.town.location(self.config.home) + home = MapTile(map=location.map, x=location.entry.x, y=location.entry.y) + self.movement.track(PLAYER_ID, Point(home.x, home.y, home.map)) + self.player.hunger = self.config.waking + logger.info("%s The player collapses from hunger and wakes at home", self.clock) + self.collapsed(home) + + def pick_up(self) -> bool: + """Takes what still fits from the nearest pile in reach. Whether they took anything.""" + here = self._here() + if here is None: + return False + near = [ + (distance, index) + for index, pile in enumerate(self.player.left) + if pile.at.map == here.map + and (distance := math.dist(here, (pile.at.x, pile.at.y))) <= self.config.reach + ] + if not near: + return False + _, index = min(near) + pile, room = self.player.left[index], self.player.carry_limit - self.player.load + taken, kept = [], [] + for item in pile.items: + if item.weight <= room + 1e-9: + taken.append(item) + room -= item.weight + else: + kept.append(item) + if not taken: + return False + left = list(self.player.left) + if kept: + left[index] = Pile(at=pile.at, items=kept) + else: + del left[index] + self.player.items = [*self.player.items, *taken] + self.player.left = left + return True + + def _eat_meal(self) -> bool: + meal = self.meal() + if meal is None or meal.price > self.player.money: + return False + if meal.place == self.config.home: + relief = self.config.home_meal + else: + self._pay(meal) + relief = self.config.meals[meal.place] + self._relieve(relief) + logger.info("%s The player eats at %s", self.clock, meal.name) + return True + + def _eat_item(self, id: Id) -> bool: + items = self.player.items + found = (i for i, item in enumerate(items) if item.id == id and item.kind is ItemKind.FOOD) + index = next(found, None) + if index is None: + return False + self.player.items = items[:index] + items[index + 1 :] + self._relieve(self.config.food) + return True + + def _pay(self, meal: Meal) -> None: + """Pays the keeper through the ledger, as from outside, and takes one from the batch.""" + assert self.economy is not None + self.player.money = max(self.player.money - meal.price, 0.0) + self.economy.buy(OUTSIDE, meal.place, meal.price) + shop, batch = self.economy.config.shops[meal.place], self.economy.batches.get(meal.place) + if shop.batch is not None and batch is not None and batch.left > 0: + batch.left -= 1 + if batch.left == 0: + batch.sold_out = self.clock.now + + def _relieve(self, amount: float) -> None: + self.player.hunger = max(self.player.hunger - amount, 0.0) + + def _serving(self, place: Id) -> bool: + try: + activity = Activity(place) + except ValueError: + return False + return self.supply(Goal(activity, place), Need.HUNGER) > 0 + + def _price(self, place: Id) -> float | None: + shop = self.economy.config.shops.get(place) if self.economy is not None else None + return None if shop is None else shop.meal + + def _location(self, place: Id) -> Location | None: + try: + return self.movement.town.location(place) + except KeyError: + return None + + def _here(self) -> Point | None: + return self.movement.entities().get(PLAYER_ID) + + +def ground(movement: Movement, here: Point) -> MapTile: + """The cell they're on, or the first one below it to stand on.""" + x, y = (max(n, 0) for n in cell_of(here)) + town = movement.maps.get(here.map) + if town is not None: + x, y = min(x, town.width - 1), min(y, town.height - 1) + y = next((row for row in range(y, town.height) if town.standing(x, row)), y) + return MapTile(map=here.map, x=x, y=y) diff --git a/server/simile/player/lifespan.py b/server/simile/player/lifespan.py new file mode 100644 index 00000000..db105d61 --- /dev/null +++ b/server/simile/player/lifespan.py @@ -0,0 +1,62 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette + + +@asynccontextmanager +async def running(app: Starlette) -> AsyncIterator[None]: + from simile.behaviour import behaviour_of + from simile.economy.economy import economy_of + from simile.persistence import player_of + from simile.player.clients import InGame + from simile.player.hunger import Hunger + from simile.player.sync import PlayerSync + from simile.protocol import ( + EatMessage, + PickUpMessage, + PlayerCollapsedMessage, + PlayerMoveMessage, + Position, + hub_of, + ) + from simile.schema import MapTile + from simile.sim import world_of + from simile.world import movement_of + + world, hub, behaviour = world_of(app), hub_of(app), behaviour_of(app) + in_game = InGame() + + def collapsed(home: MapTile) -> None: + hub.broadcast(PlayerCollapsedMessage(position=Position(x=home.x, y=home.y, map=home.map))) + + hunger = Hunger( + player_of(app), + movement_of(app), + world.clock, + supply=lambda goal, need: behaviour.hooks.supply(goal, need), + economy=economy_of(app), + playing=lambda: bool(in_game), + collapsed=collapsed, + ) + sync = PlayerSync(hunger.player, hunger.meal, hub.broadcast) + + def on_eat(message: EatMessage) -> None: + if hunger.eat(message.item): + sync.update() + + def on_pick_up(message: PickUpMessage) -> None: + if hunger.pick_up(): + sync.update() + + stops = [ + world.loop.register(hunger), + world.loop.register(sync), + hub.subscribe(PlayerMoveMessage, in_game.moved), + hub.subscribe(EatMessage, on_eat), + hub.subscribe(PickUpMessage, on_pick_up), + hub.greet(sync.message), + ] + yield + for stop in reversed(stops): + stop() diff --git a/server/simile/player/sync.py b/server/simile/player/sync.py new file mode 100644 index 00000000..c358f9bd --- /dev/null +++ b/server/simile/player/sync.py @@ -0,0 +1,54 @@ +from collections.abc import Callable + +from simile.player.config import DEFAULT_CONFIG +from simile.protocol import Meal, PlayerStateMessage +from simile.schema import Player + + +class PlayerSync: + """Sends `player_state` on each change, and on hunger or charge alone once moved by `step`.""" + + def __init__( + self, + player: Player, + meal: Callable[[], Meal | None], + send: Callable[[PlayerStateMessage], None], + step: float = DEFAULT_CONFIG.step, + ) -> None: + self.player = player + self.meal = meal + self.send = send + self.step = step + self.sent = self.message() + + def message(self) -> PlayerStateMessage: + return PlayerStateMessage(player=self.player.model_copy(deep=True), meal=self.meal()) + + def tick(self, dt: float) -> None: + self.update() + + def update(self) -> None: + """Sends the player's state if it's changed enough since it was last sent.""" + message = self.message() + if self._changed(self.sent, message): + self.send(message) + self.sent = message + + def _changed(self, old: PlayerStateMessage, new: PlayerStateMessage) -> bool: + if _steady(old) != _steady(new): + return True + moved = zip(_moving(old), _moving(new), strict=True) + return any(abs(a - b) >= self.step - 1e-9 for a, b in moved) + + +def _moving(message: PlayerStateMessage) -> list[float]: + player = message.player + return [player.hunger, *(item.charge for item in player.items if item.charge is not None)] + + +def _steady(message: PlayerStateMessage) -> PlayerStateMessage: + """Without hunger or the lamps' charges, for comparing.""" + player = message.player + items = [item.model_copy(update={"charge": None}) for item in player.items] + still = player.model_copy(update={"hunger": 0.0, "items": items}) + return message.model_copy(update={"player": still}) diff --git a/server/tests/player/test_hunger.py b/server/tests/player/test_hunger.py new file mode 100644 index 00000000..eff0ee97 --- /dev/null +++ b/server/tests/player/test_hunger.py @@ -0,0 +1,331 @@ +from datetime import UTC, datetime + +import pytest + +from simile.behaviour import Activity, Behaviour, Goal, Need +from simile.economy import OUTSIDE, Batch, Kind +from simile.economy.economy import Economy +from simile.memory import MemoryStore +from simile.npc import load_npcs +from simile.player.hunger import Hunger +from simile.protocol import Meal +from simile.schema import PLAYER_ID, Item, ItemKind, MapTile, Pay, PayKind, Pile, Player, lamp +from simile.sim import Clock, FakeWallClock, World +from simile.world import Movement, Point +from simile.world.map import load_maps + +MAPS = load_maps() +NOW = datetime(2026, 1, 1, 12, tzinfo=UTC) +ODALYS = load_npcs()[0].model_copy( + update={ + "id": "odalys", + "home": "house_3", + "workplace": "noodle_bar", + "pay": Pay(kind=PayKind.TAKINGS), + "money": 100.0, + } +) +BUN = Item(id="pork_bun", kind=ItemKind.FOOD, name="Pork bun", weight=0.2) +WIRE = Item(id="copper_wire", kind=ItemKind.SALVAGE, name="Copper wire", weight=1.5) +SERVO = Item(id="servo_motor", kind=ItemKind.SALVAGE, name="Servo motor", weight=3.0) +HOME = Point(13, 2, "maker_space_floor_0") +NOODLES = Meal(place="noodle_bar", name="Golden Bowl Noodles", price=10) + + +class Village: + def __init__(self, player: Player | None = None) -> None: + self.world = World(Clock(NOW), FakeWallClock()) + self.movement = Movement(MAPS, self.world.bus) + behaviour = Behaviour( + self.world.clock, + self.world.bus, + self.movement, + [ODALYS], + MemoryStore(":memory:"), + seed=0, + ) + self.economy = Economy(self.world.clock, behaviour) + self.open: set[str] = {"noodle_bar", "coffee_shop"} + self.playing = True + self.woke: list[MapTile] = [] + self.player = player or Player() + self.hunger = Hunger( + self.player, + self.movement, + self.world.clock, + supply=self.supply, + economy=self.economy, + playing=lambda: self.playing, + collapsed=self.woke.append, + ) + + def supply(self, goal: Goal, need: Need) -> float: + assert need is Need.HUNGER + return 1.0 if goal.target in self.open else 0.0 + + def stand(self, x: float, y: float, map: str) -> None: + self.movement.track(PLAYER_ID, Point(x, y, map)) + + def here(self) -> Point: + return self.movement.position(PLAYER_ID) + + +def test_hunger_rises_while_someone_is_playing() -> None: + village = Village() + + village.hunger.tick(3600) + assert village.player.hunger == pytest.approx(0.25) + village.hunger.tick(1800) + assert village.player.hunger == pytest.approx(0.375) + + +def test_hunger_waits_while_nobody_is_playing() -> None: + village = Village(Player(hunger=0.5)) + village.playing = False + + village.hunger.tick(3600 * 24) + + assert village.player.hunger == 0.5 + + +@pytest.mark.parametrize( + ("x", "y", "map"), + [(13, 2, "maker_space_floor_0"), (4, 2, "maker_space_floor_1"), (8, 2, "maker_space_floor_2")], +) +def test_a_meal_at_home_is_free_on_any_floor(x: int, y: int, map: str) -> None: + village = Village(Player(hunger=0.8)) + village.stand(x, y, map) + + assert village.hunger.meal() == Meal(place="maker_space", name="home", price=0) + assert village.hunger.eat(None) + assert village.player.hunger == 0 + assert village.player.money == 0 + + +def test_a_meal_at_the_noodle_bar_is_paid_to_its_keeper_and_taken_from_the_batch() -> None: + village = Village(Player(money=25, hunger=0.9)) + economy = village.economy + economy.batches["noodle_bar"] = Batch(shop="noodle_bar", shift=NOW, cooked=6, left=1) + village.stand(9, 2, "noodle_bar_floor_1") + + assert village.hunger.meal() == NOODLES + assert village.hunger.eat(None) + + assert village.player.hunger == 0 + assert village.player.money == 15 + assert economy.money["odalys"] == 100 + 10 - 3 + purchase = economy.ledger.entries[-2] + assert (purchase.kind, purchase.payer, purchase.payee) == (Kind.PURCHASE, OUTSIDE, "odalys") + assert (purchase.amount, purchase.place) == (10, "noodle_bar") + assert economy.ledger.gap(economy.money) == 0 + batch = economy.batches["noodle_bar"] + assert (batch.left, batch.sold_out) == (0, NOW) + + +def test_the_cafe_serves_a_lighter_meal_for_less() -> None: + village = Village(Player(money=5, hunger=0.9)) + village.stand(9, 2, "coffee_shop_floor_1") + + assert village.hunger.meal() == Meal(place="coffee_shop", name="The Daily Grind", price=5) + assert village.hunger.eat(None) + assert (village.player.money, village.player.hunger) == (0, pytest.approx(0.4)) + + +def test_no_meal_at_a_shop_that_would_not_feed_a_resident() -> None: + village = Village(Player(money=25, hunger=0.9)) + village.open = set() + village.stand(9, 2, "noodle_bar_floor_1") + + assert village.hunger.meal() is None + assert not village.hunger.eat(None) + assert (village.player.money, village.player.hunger) == (25, 0.9) + + +def test_the_noodle_bar_feeds_them_only_while_its_keeper_is_in_and_it_has_meals_left() -> None: + village = Village(Player(money=25, hunger=0.9)) + economy, behaviour = village.economy, village.economy.behaviour + village.hunger.supply = economy.supply + behaviour.start() + village.stand(9, 2, "noodle_bar_floor_1") + assert village.hunger.meal() is None + + work = Goal(Activity.WORK, "noodle_bar") + behaviour.restore("odalys", work, behaviour.needs("odalys"), NOW, None, {}) + economy.batches["noodle_bar"] = Batch(shop="noodle_bar", shift=NOW, cooked=6, left=1) + assert economy.staffed("noodle_bar") + assert village.hunger.eat(None) + + assert village.hunger.meal() is None + assert not village.hunger.eat(None) + assert village.player.money == 15 + + +def test_no_meal_they_cannot_pay_for() -> None: + village = Village(Player(money=9.5, hunger=0.9)) + village.stand(9, 2, "noodle_bar_floor_1") + + assert village.hunger.meal() == NOODLES + assert not village.hunger.eat(None) + assert (village.player.money, village.player.hunger) == (9.5, 0.9) + assert village.economy.ledger.entries == [] + + +@pytest.mark.parametrize( + ("x", "y", "map"), + [(70, 15, "high_street"), (3, 2, "house_3_floor_0"), (5, 2, "gallery_floor_0")], +) +def test_no_meal_anywhere_else(x: int, y: int, map: str) -> None: + village = Village(Player(money=25, hunger=0.9)) + village.stand(x, y, map) + + assert village.hunger.meal() is None + assert not village.hunger.eat(None) + + +def test_no_meal_before_the_server_knows_where_they_are() -> None: + village = Village(Player(hunger=0.9)) + + assert village.hunger.meal() is None + assert not village.hunger.eat(None) + + +def test_eating_food_they_carry_relieves_it_and_uses_it_up() -> None: + village = Village(Player(items=[lamp(), BUN, WIRE, BUN], hunger=0.9)) + + assert village.hunger.eat("pork_bun") + + assert village.player.hunger == pytest.approx(0.5) + assert village.player.items == [lamp(), WIRE, BUN] + + +@pytest.mark.parametrize("item", ["lamp", "copper_wire", "noodles"]) +def test_only_food_they_carry_can_be_eaten(item: str) -> None: + village = Village(Player(items=[lamp(), WIRE], hunger=0.9)) + + assert not village.hunger.eat(item) + assert village.player.items == [lamp(), WIRE] + assert village.player.hunger == 0.9 + + +def test_eating_never_takes_hunger_below_nothing() -> None: + village = Village(Player(items=[BUN], hunger=0.1)) + + assert village.hunger.eat("pork_bun") + assert village.player.hunger == 0 + + +def test_starving_collapses_them_at_home_with_what_they_carried_left_where_they_fell() -> None: + lit = lamp().model_copy(update={"charge": 0.3, "on": True}) + village = Village(Player(items=[WIRE, lit, BUN], hunger=0.999)) + village.stand(12.3, 15, "kapok_walk") + + village.hunger.tick(60) + + assert village.player.hunger == 0.75 + assert village.player.items == [lit] + assert village.player.left == [ + Pile(at=MapTile(map="kapok_walk", x=12, y=15), items=[WIRE, BUN]) + ] + assert village.here() == HOME + assert village.woke == [MapTile(map="maker_space_floor_0", x=13, y=2)] + + +@pytest.mark.parametrize( + ("x", "y", "ground"), + [(17.2, 5.6, (17, 8)), (22.4, 5, (22, 15)), (3, 13.5, (3, 15))], + ids=["over_a_roof", "over_a_gap", "on_a_ladder"], +) +def test_what_they_carried_falls_to_the_ground_below( + x: float, y: float, ground: tuple[int, int] +) -> None: + village = Village(Player(items=[lamp(), WIRE], hunger=1 - 1e-9)) + village.stand(x, y, "rooftops") + + village.hunger.tick(1) + + fell = MapTile(map="rooftops", x=ground[0], y=ground[1]) + assert village.player.left == [Pile(at=fell, items=[WIRE])] + + +@pytest.mark.parametrize("items", [[], [lamp()]], ids=["nothing", "only_the_lamp"]) +def test_collapsing_with_nothing_but_the_lamp_leaves_nothing(items: list[Item]) -> None: + village = Village(Player(items=items, hunger=0.9999)) + village.stand(70, 15, "high_street") + + village.hunger.collapse() + + assert village.player.items == items + assert village.player.left == [] + assert village.here() == HOME + + +def test_each_collapse_leaves_another_pile_after_the_last() -> None: + village = Village(Player(items=[lamp(), WIRE])) + village.stand(70, 15, "high_street") + village.hunger.collapse() + village.player.items = [*village.player.items, BUN] + village.stand(30, 15, "lychee_lane") + + village.hunger.collapse() + + assert [pile.at for pile in village.player.left] == [ + MapTile(map="high_street", x=70, y=15), + MapTile(map="lychee_lane", x=30, y=15), + ] + + +def test_a_collapse_before_the_server_knows_where_they_are_keeps_what_they_carry() -> None: + village = Village(Player(items=[lamp(), WIRE])) + + village.hunger.collapse() + + assert village.player.items == [lamp(), WIRE] + assert village.player.left == [] + assert village.here() == HOME + + +def piles(*at: tuple[str, int]) -> list[Pile]: + return [Pile(at=MapTile(map=map, x=x, y=15), items=[BUN, WIRE]) for map, x in at] + + +def test_picking_up_takes_the_nearest_pile_in_reach() -> None: + village = Village(Player(left=piles(("kapok_walk", 12), ("kapok_walk", 14)))) + village.stand(13.2, 15, "kapok_walk") + + assert village.hunger.pick_up() + + assert village.player.items == [lamp(), BUN, WIRE] + assert village.player.left == piles(("kapok_walk", 12)) + + +def test_picking_up_leaves_what_they_cannot_carry() -> None: + heavy = [Pile(at=MapTile(map="kapok_walk", x=12, y=15), items=[SERVO, WIRE, BUN])] + village = Village(Player(items=[lamp(), SERVO, SERVO], carry_limit=9, left=heavy)) + village.stand(12, 15, "kapok_walk") + + assert village.hunger.pick_up() + + assert village.player.items == [lamp(), SERVO, SERVO, WIRE, BUN] + assert village.player.load == pytest.approx(8.7) + assert village.player.left == [heavy[0].model_copy(update={"items": [SERVO]})] + + +def test_nothing_is_picked_up_with_no_room_for_any_of_it() -> None: + heavy = [Pile(at=MapTile(map="kapok_walk", x=12, y=15), items=[SERVO])] + village = Village(Player(items=[lamp(), SERVO, SERVO], carry_limit=8, left=heavy)) + village.stand(12, 15, "kapok_walk") + + assert not village.hunger.pick_up() + assert village.player.left == heavy + + +@pytest.mark.parametrize( + ("x", "map"), [(13.6, "kapok_walk"), (12, "high_street")], ids=["too_far", "another_map"] +) +def test_nothing_is_picked_up_out_of_reach(x: float, map: str) -> None: + village = Village(Player(items=[], left=piles(("kapok_walk", 12)))) + village.stand(x, 15, map) + + assert not village.hunger.pick_up() + assert village.player.items == [] + assert village.player.left == piles(("kapok_walk", 12)) diff --git a/server/tests/player/test_lifespan.py b/server/tests/player/test_lifespan.py new file mode 100644 index 00000000..e3e4db42 --- /dev/null +++ b/server/tests/player/test_lifespan.py @@ -0,0 +1,146 @@ +import time +from collections.abc import Callable +from datetime import UTC, datetime +from typing import Any + +import pytest +from starlette.applications import Starlette +from starlette.testclient import TestClient + +from simile.app import create_app +from simile.persistence import player_of +from simile.schema import PLAYER_ID, Item, ItemKind +from simile.sim import Clock, World, world_of +from simile.world import Point, movement_of + +MakePackage = Callable[[dict[str, str]], str] + +START = datetime(2026, 1, 1, 10, tzinfo=UTC) +LIFESPANS = ("behaviour", "economy", "memory", "protocol", "world") +HOME = {"place": "maker_space", "name": "home", "price": 0.0} +BUN = Item(id="pork_bun", kind=ItemKind.FOOD, name="Pork bun", weight=0.2) + + +def make_app(make_package: MakePackage) -> Starlette: + files = {f"{m}/lifespan.py": f"from simile.{m}.lifespan import lifespan\n" for m in LIFESPANS} + files["protocol/routes.py"] = "from simile.protocol.routes import routes\n" + files["player/lifespan.py"] = "from simile.player.lifespan import running\n" + app = create_app(make_package(files)) + app.state.world = World(Clock(START)) + return app + + +def next_of(ws: Any, type: str) -> dict[str, Any]: + for _ in range(500): + if (message := ws.receive_json())["type"] == type: + return message + raise AssertionError(f"no {type}") + + +def move(client: TestClient, app: Starlette, ws: Any, x: float, y: float, map: str) -> None: + ws.send_json({"type": "player_move", "position": {"x": x, "y": y, "map": map}}) + movement, deadline = movement_of(app), time.monotonic() + 2 + while client.portal.call(movement.entities).get(PLAYER_ID) != Point(x, y, map): + assert time.monotonic() < deadline, "the server never had the player there" + time.sleep(0.001) + + +def test_new_clients_get_the_players_state_after_the_snapshot_and_clock( + make_package: MakePackage, +) -> None: + app = make_app(make_package) + + with TestClient(app) as client, client.websocket_connect("/ws") as ws: + greeted = [ws.receive_json() for _ in range(3)] + + assert [message["type"] for message in greeted] == ["snapshot", "clock", "player_state"] + assert greeted[2]["player"]["hunger"] == 0 + assert "meal" not in greeted[2] + + +def test_hunger_rises_only_while_a_client_is_in_the_game(make_package: MakePackage) -> None: + app = make_app(make_package) + + with TestClient(app) as client: + player = player_of(app) + + def rise() -> float: + before = player.hunger + for _ in range(10): + client.portal.call(world_of(app).loop.tick) + return player.hunger - before + + with client.websocket_connect("/ws") as menu: + next_of(menu, "player_state") + on_the_menu = rise() + with client.websocket_connect("/ws") as game: + next_of(game, "player_state") + connected = rise() + move(client, app, game, 12, 15, "kapok_walk") + playing = rise() + left = rise() + + assert on_the_menu == connected == left == 0 + assert playing == pytest.approx(0.25 / 3600) + + +def test_the_player_eats_at_home_and_from_what_they_carry(make_package: MakePackage) -> None: + app = make_app(make_package) + + with TestClient(app) as client, client.websocket_connect("/ws") as ws: + tick = world_of(app).loop.tick + player = player_of(app) + next_of(ws, "player_state") + move(client, app, ws, 13, 2, "maker_space_floor_0") + client.portal.call(tick) + at_home = next_of(ws, "player_state") + + client.portal.call(lambda: setattr(player, "hunger", 0.9)) + client.portal.call(tick) + next_of(ws, "player_state") + ws.send_json({"type": "eat", "item": None}) + fed = next_of(ws, "player_state") + + client.portal.call(lambda: setattr(player, "items", [BUN])) + client.portal.call(lambda: setattr(player, "hunger", 0.5)) + client.portal.call(tick) + next_of(ws, "player_state") + ws.send_json({"type": "eat", "item": "pork_bun"}) + snacked = next_of(ws, "player_state") + + assert at_home["meal"] == HOME + assert fed["player"]["hunger"] == 0 + assert snacked["player"]["items"] == [] + assert snacked["player"]["hunger"] < 0.5 + + +def test_starving_wakes_them_at_home_and_they_pick_up_what_they_left( + make_package: MakePackage, +) -> None: + app = make_app(make_package) + + with TestClient(app) as client, client.websocket_connect("/ws") as ws: + tick = world_of(app).loop.tick + player = player_of(app) + move(client, app, ws, 12, 15, "kapok_walk") + client.portal.call(lambda: setattr(player, "items", [*player.items, BUN])) + client.portal.call(lambda: setattr(player, "hunger", 1 - 1e-9)) + client.portal.call(tick) + collapsed = next_of(ws, "player_collapsed") + woken = next_of(ws, "player_state") + home = client.portal.call(movement_of(app).position, PLAYER_ID) + + move(client, app, ws, 12, 15, "kapok_walk") + ws.send_json({"type": "pick_up"}) + picked_up = next_of(ws, "player_state") + + assert collapsed["position"] == {"x": 13, "y": 2, "map": "maker_space_floor_0"} + assert home == Point(13, 2, "maker_space_floor_0") + assert woken["player"]["hunger"] == 0.75 + assert [item["id"] for item in woken["player"]["items"]] == ["lamp"] + assert woken["player"]["left"] == [ + {"at": {"map": "kapok_walk", "x": 12, "y": 15}, "items": [BUN.model_dump(mode="json")]} + ] + assert woken["meal"] == HOME + assert [item["id"] for item in picked_up["player"]["items"]] == ["lamp", "pork_bun"] + assert "left" not in picked_up["player"] diff --git a/server/tests/player/test_sync.py b/server/tests/player/test_sync.py new file mode 100644 index 00000000..6942b150 --- /dev/null +++ b/server/tests/player/test_sync.py @@ -0,0 +1,106 @@ +from collections.abc import Callable + +import pytest + +from simile.player.sync import PlayerSync +from simile.protocol import Meal, PlayerStateMessage +from simile.schema import SOLD, Item, ItemKind, MapTile, Pile, Player, lamp + +HOME = Meal(place="maker_space", name="home", price=0) +BUN = Item(id="pork_bun", kind=ItemKind.FOOD, name="Pork bun", weight=0.2) + + +class Sent: + def __init__(self, player: Player) -> None: + self.messages: list[PlayerStateMessage] = [] + self.meal: Meal | None = None + self.sync = PlayerSync(player, lambda: self.meal, self.messages.append) + + def after(self, change: Callable[[], object]) -> list[PlayerStateMessage]: + change() + self.sync.tick(0.1) + sent = list(self.messages) + self.messages.clear() + return sent + + +def test_new_clients_are_greeted_with_the_player_and_the_meal_on_offer() -> None: + player, sent = Player(hunger=0.3), [] + sync = PlayerSync(player, lambda: HOME, sent.append) + + sync.update() + + assert sent == [] + assert sync.message() == PlayerStateMessage(player=player, meal=HOME) + + +def test_it_sends_a_copy_that_later_changes_leave_alone() -> None: + player = Player() + sent = Sent(player) + [message] = sent.after(lambda: setattr(player, "money", 30)) + + player.money = 40 + + assert message.player.money == 30 + + +def test_nothing_is_sent_while_nothing_changes() -> None: + sent = Sent(Player()) + + assert sent.after(lambda: None) == [] + + +def test_a_change_to_hunger_alone_waits_until_it_has_moved_a_hundredth() -> None: + player = Player(hunger=0.5) + sent = Sent(player) + + assert sent.after(lambda: setattr(player, "hunger", 0.505)) == [] + assert sent.after(lambda: setattr(player, "hunger", 0.5099)) == [] + [message] = sent.after(lambda: setattr(player, "hunger", 0.51)) + assert message.player.hunger == 0.51 + assert sent.after(lambda: setattr(player, "hunger", 0.515)) == [] + assert len(sent.after(lambda: setattr(player, "hunger", 0.5))) == 1 + + +def test_a_lamps_charge_alone_waits_too() -> None: + player = Player() + sent = Sent(player) + + assert sent.after(lambda: setattr(player.items[0], "charge", 0.995)) == [] + [message] = sent.after(lambda: setattr(player.items[0], "charge", 0.99)) + assert message.player.items[0].charge == 0.99 + + +@pytest.mark.parametrize( + "change", + [ + lambda player, sent: setattr(player, "money", 0.01), + lambda player, sent: setattr(player, "items", [*player.items, BUN]), + lambda player, sent: setattr(player, "items", []), + lambda player, sent: setattr(player.items[0], "on", True), + lambda player, sent: setattr(player.body, "left_kidney", SOLD), + lambda player, sent: setattr( + player, "left", [Pile(at=MapTile(map="kapok_walk", x=1, y=15), items=[BUN])] + ), + lambda player, sent: setattr(sent, "meal", HOME), + ], + ids=["money", "an_item", "dropping_everything", "a_switch", "the_body", "a_pile", "a_meal"], +) +def test_any_other_change_is_sent_at_once(change: Callable[[Player, Sent], None]) -> None: + player = Player(hunger=0.5) + sent = Sent(player) + + [message] = sent.after(lambda: change(player, sent)) + + assert message == PlayerStateMessage(player=player, meal=sent.meal) + + +def test_with_a_change_it_sends_hunger_as_it_is() -> None: + player = Player(hunger=0.5) + sent = Sent(player) + player.hunger = 0.503 + + [message] = sent.after(lambda: setattr(player, "items", [lamp(), BUN])) + + assert message.player.hunger == 0.503 + assert sent.after(lambda: setattr(player, "hunger", 0.51)) == [] diff --git a/server/tests/protocol/test_lifespan.py b/server/tests/protocol/test_lifespan.py index e8600ddc..6ead7638 100644 --- a/server/tests/protocol/test_lifespan.py +++ b/server/tests/protocol/test_lifespan.py @@ -156,6 +156,7 @@ def test_clients_receive_events_published_on_the_bus() -> None: assert ws.receive_json()["type"] == "clock" assert ws.receive_json()["type"] == "paintings" assert ws.receive_json()["type"] == "murals" + assert ws.receive_json()["type"] == "player_state" client.portal.call(world_of(app).bus.publish, EntityMoved("suki", 18, 15, "high_street"))