Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 57 additions & 10 deletions client/src/net/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export type ClientMessage =
| ListWorksMessage
| ReadWorkMessage
| EatMessage
| PickUpMessage;
| PickUpMessage
| SellMessage;

/**
* The whole world, sent on connect. Replaces anything the client had.
Expand Down Expand Up @@ -370,8 +371,8 @@ export interface Painting {
}
/**
* The player's money, what they carry, their body and their hunger, but never their humanity,
* and the meal on offer where they stand. Sent after the snapshot on connect, and whenever any of
* it changes.
* with the meal on offer and the buyer where they stand, and what's lying in the world for them
* to pick up. Sent after the snapshot on connect, and whenever any of it changes.
*/
export interface PlayerStateMessage {
type: 'player_state';
Expand All @@ -380,6 +381,14 @@ export interface PlayerStateMessage {
* The meal on offer where they stand: at home, or at a shop that's serving. Left out where there's none.
*/
meal?: Meal | null;
/**
* Who buys where they stand, such as the scrap trader at their stall. Left out where there's none.
*/
buyer?: Buyer | null;
/**
* What's lying in the world for them to pick up, on every map, besides what they left (`player.left`): salvage and secrets. Left out while there's none.
*/
piles?: Pile[];
}
/**
* The player's state, saved with the world. Each field defaults to the starting player's, so
Expand All @@ -405,9 +414,15 @@ export interface Player {
*/
hunger: number;
/**
* What they left when they collapsed, oldest first, each on the tile they fell on or the ground below it. Left out while there's none.
* What they left lying in the world, oldest first: what they carried when they collapsed, on the tile they fell on or the ground below it, and anything of a pickup that didn't fit when they took from it. Left out while there's none.
*/
left?: Pile[];
/**
* When they last took from each pickup in the world, in sim time, by pickup id. Salvage lies there again a while after it was taken, but a secret, cash or a ration, stays taken for good. Left out while there's none.
*/
taken?: {
[k: string]: string;
};
}
/**
* Something the player carries.
Expand Down Expand Up @@ -448,15 +463,16 @@ export interface Body {
right_kidney: ('present' | 'sold') | Bionic;
}
/**
* Items lying `at` a tile in the world for the player to pick up, such as what they left
* when they collapsed.
* Items, money or both lying `at` a tile in the world for the player to pick up, such as
* salvage, a secret's cash, or what they left when they collapsed.
*/
export interface Pile {
at: MapTile;
items: Item[];
/**
* @minItems 1
* Cash lying there, which weighs nothing. Left out while there's none.
*/
items: [Item, ...Item[]];
money?: number;
}
/**
* A tile on a given map: the village or an interior, or in side view a street or a floor.
Expand All @@ -483,6 +499,26 @@ export interface Meal {
*/
price: number;
}
/**
* Who buys things where the player stands, by sending `sell`: a stall, or a keeper at their
* place.
*/
export interface Buyer {
/**
* Where: a location id, e.g. "scrap_stall" or "clinic".
*/
place: string;
/**
* The buyer as the player sees it, e.g. "The scrap trader".
*/
name: string;
/**
* What it pays for one of each thing they carry that it buys, by item `id`. Empty when they carry nothing it buys.
*/
prices: {
[k: string]: number;
};
}
/**
* What the player has learned about the incident, in the order they learned it, with the
* leads still open. Sent after the snapshot on connect, and whenever an entry is added or a lead
Expand Down Expand Up @@ -615,9 +651,20 @@ export interface EatMessage {
item: string | null;
}
/**
* Picks up, from the nearest pile within reach of the player, each item that still fits
* under their carry limit, in order. Any that don't fit stay in the pile.
* Picks up, from the nearest pile within reach of the player, its money and each item that
* still fits under their carry limit, in order. Any that don't fit stay in the pile.
*/
export interface PickUpMessage {
type: 'pick_up';
}
/**
* Sells to the buyer where the player stands, `player_state`'s `buyer`, for its price. The
* server ignores it if there's no buyer, or they carry nothing it buys.
*/
export interface SellMessage {
type: 'sell';
/**
* One thing they carry, by its `id`, or null for everything they carry that the buyer buys.
*/
item: string | null;
}
52 changes: 39 additions & 13 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ interiors' ids; this document says where.
- Field names are `snake_case` on both sides.
- Every field is always present. Optional values are `null`, never missing. The exceptions are a
position's `map`, an entity's `pose` and `facing`, a line's `flushed`, an article's
`long_summary`, a wall's `map`, and a player's `left` and `player_state`'s `meal`, below.
`long_summary`, a wall's `map`, a player's `left` and `taken`, a pile's `money`, and
`player_state`'s `meal`, `buyer` and `piles`, below.
- Timestamps are sim time: ISO 8601 in UTC, e.g. `"2026-03-14T09:10:03Z"`. UTC is village time.
- A **position** is `{"x": 4.5, "y": 6, "map": "village"}` in tiles, measured like F2's `Tile`:
`(4, 6)` is standing on tile (4, 6), and fractions are part-way between tiles while walking.
Expand Down Expand Up @@ -224,7 +225,8 @@ it ignore it.
| `carry_limit` | number | The most they can carry, in kg. Picking things up keeps the items' total `weight` within it. |
| `body` | `Body` | What's become of each part of their body that can be sold or replaced. |
| `hunger` | number | From 0, fed, to 1, starving, when they collapse. |
| `left` | `Pile[]` | What they left where they collapsed, oldest first. Left out while there's none. |
| `left` | `Pile[]` | What they left lying in the world, oldest first: what they carried when they collapsed, and anything of a pickup that didn't fit. Left out while there's none. |
| `taken` | object | When they last took from each pickup in the world, a timestamp by pickup id. Salvage lies there again a while after it was taken; a secret, cash or a ration, stays taken for good. Left out while there's none. |

Beside `player`, the message has `meal`: the meal on offer where the player stands, which they can
have by sending [`eat`](#eat) without an item. It's left out where there's none. A `Meal` has its
Expand All @@ -233,25 +235,41 @@ the shop's name, such as `"Golden Bowl Noodles"`; and its `price`, 0 at home. A
while it's serving, as it would feed a resident. The server sends a new `player_state` when the
player walks into or out of a place with a meal.

It also has `buyer`: who buys things where the player stands, which they sell to with
[`sell`](#sell). It's left out where there's none. A `Buyer` is a stall, such as the scrap trader's,
or a keeper at their place, such as the clinic's doctor. It has its `place`, a location id such as
`"scrap_stall"`; its `name`, as the player sees it, such as `"The scrap trader"`; and its `prices`,
what it pays for one of each thing the player carries that it buys, by item `id`:
`{"copper_wire": 6}`. That's empty when they carry nothing it buys. The prices are the server's, and
a resident only voices them. The server sends a new `player_state` when the player walks up to a
buyer or away from one.

And it has `piles`: what's lying in the world for the player to pick up besides what they left,
such as salvage and secrets, as `Pile`s on every map. It's left out while there's none. A pile in
`piles` that they take everything from is gone from the next `player_state`, and one they take only
some of is in their `left` from then on, with what didn't fit. Salvage comes back a while after
it's taken, in a later `player_state`, and a secret never does.

An `Item` has its `id`, what it is, the same for every one of its kind (`"copper_wire"`, `"lamp"`),
which prices salvage; its `kind`, `"salvage"`, `"food"` or `"lamp"`; its `name`, as the player sees
it; its `weight` in kg; `charge`, a lamp's battery from 0 (flat) to 1 (full); and `on`, whether a
lamp is switched on, running its battery down. `charge` and `on` are `null` for anything else.

A `Pile` is items lying on a tile in the world for the player to pick up with
[`pick_up`](#pick_up): its `items`, at least one, and `at`, a `MapTile` (`map`, `x` and `y`, whole
tiles). What they left when they collapsed is one kind, in `left`, each on the tile they fell on or
the ground below it.
A `Pile` is things lying on a tile in the world for the player to pick up with
[`pick_up`](#pick_up): its `items`, `money`, cash that weighs nothing, and `at`, a `MapTile` (`map`,
`x` and `y`, whole tiles). It holds at least one item or some money, and `money` is left out while
it's 0. What they left when they collapsed is one kind, in `left`, each on the tile they fell on or
the ground below it. Salvage and secrets, in `piles`, are another.

A `Body` has a field for each part: `legs`, `vertebra`, `left_eye`, `right_eye`, `left_kidney` and
`right_kidney`. Each is `"present"`, `"sold"`, or the bionic fitted in its place:
`"hydraulic_legs"` in the legs, `"datajack"` in the vertebra, or `"infrared_eye"` in one eye or
the other, never both.

The player's state is saved with the world. A save from before it loads with the starting player:
a whole body, no money and a fully charged lamp, switched off, with nothing left anywhere. Their
humanity is part of it on the server, derived from the bionics fitted (`Player.humanity`), but it's
never sent: the player sees it only in how residents treat them.
a whole body, no money and a fully charged lamp, switched off, with nothing left anywhere and
nothing taken. Their humanity is part of it on the server, derived from the bionics fitted
(`Player.humanity`), but it's never sent: the player sees it only in how residents treat them.

### `player_collapsed`

Expand Down Expand Up @@ -363,10 +381,18 @@ none on offer or that they can't pay for.
### `pick_up`

No fields. Picks up from the nearest `Pile` on the map the player is on, within a tile and a half of
where they stand, such as one in their `left`. It takes each item, in order, that still fits under
their carry limit, and skips any that doesn't, which stays in the pile with anything else left. The
server sends the [`player_state`](#player_state) that follows, and ignores a `pick_up` with nothing
in reach or nothing that fits.
where they stand, one of their `left` or of `player_state`'s `piles`. It takes its money, and each
item, in order, that still fits under their carry limit, and skips any that doesn't, which stays in
the pile with anything else left. The server sends the [`player_state`](#player_state) that follows,
and ignores a `pick_up` with nothing in reach, or with only items that don't fit.

### `sell`

`item`: one thing the player carries, by its `id`, or `null` for everything they carry that the
buyer where they stand buys, `player_state`'s [`buyer`](#player_state). Each is sold for the
buyer's price in `prices`, which goes to their `money`. The server sends the
[`player_state`](#player_state) that follows. It ignores a `sell` where there's no buyer, and one
for an item they don't carry or the buyer doesn't buy.

### `watch_npc`

Expand Down
4 changes: 4 additions & 0 deletions server/simile/protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from simile.protocol.hub import Connection, Hub, hub_of
from simile.protocol.messages import (
Artist,
Buyer,
ClientMessage,
ClockMessage,
ConversationEndedMessage,
Expand Down Expand Up @@ -34,6 +35,7 @@
Position,
ReadNewsMessage,
ReadWorkMessage,
SellMessage,
ServerMessage,
SnapshotMessage,
Wall,
Expand All @@ -46,6 +48,7 @@
"SAMPLE_RATE",
"Artist",
"AudioChunk",
"Buyer",
"ClientMessage",
"ClockMessage",
"Connection",
Expand Down Expand Up @@ -80,6 +83,7 @@
"Position",
"ReadNewsMessage",
"ReadWorkMessage",
"SellMessage",
"ServerMessage",
"SnapshotMessage",
"Wall",
Expand Down
50 changes: 45 additions & 5 deletions server/simile/protocol/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
Key,
Line,
Model,
Pile,
Player,
Pose,
Timestamp,
Expand Down Expand Up @@ -286,10 +287,25 @@ class Meal(Model):
price: float = Field(ge=0, allow_inf_nan=False, description="What it costs. Nothing at home.")


class Buyer(Model):
"""Who buys things where the player stands, by sending `sell`: a stall, or a keeper at their
place."""

place: Id = Field(description='Where: a location id, e.g. "scrap_stall" or "clinic".')
name: str = Field(
min_length=1,
description='The buyer as the player sees it, e.g. "The scrap trader".',
)
prices: dict[Key, Annotated[float, Field(ge=0, allow_inf_nan=False)]] = Field(
description="What it pays for one of each thing they carry that it buys, by item `id`. "
"Empty when they carry nothing it buys."
)


class PlayerStateMessage(Model):
"""The player's money, what they carry, their body and their hunger, but never their humanity,
and the meal on offer where they stand. Sent after the snapshot on connect, and whenever any of
it changes."""
with the meal on offer and the buyer where they stand, and what's lying in the world for them
to pick up. Sent after the snapshot on connect, and whenever any of it changes."""

type: Literal["player_state"] = "player_state"
player: Player
Expand All @@ -299,6 +315,18 @@ class PlayerStateMessage(Model):
description="The meal on offer where they stand: at home, or at a shop that's serving. "
"Left out where there's none.",
)
buyer: Buyer | None = Field(
default=None,
exclude_if=lambda buyer: buyer is None,
description="Who buys where they stand, such as the scrap trader at their stall. Left out "
"where there's none.",
)
piles: list[Pile] = Field(
default=[],
exclude_if=lambda piles: not piles,
description="What's lying in the world for them to pick up, on every map, besides what "
"they left (`player.left`): salvage and secrets. Left out while there's none.",
)


class PlayerCollapsedMessage(Model):
Expand Down Expand Up @@ -373,12 +401,23 @@ class EatMessage(Model):


class PickUpMessage(Model):
"""Picks up, from the nearest pile within reach of the player, each item that still fits
under their carry limit, in order. Any that don't fit stay in the pile."""
"""Picks up, from the nearest pile within reach of the player, its money and each item that
still fits under their carry limit, in order. Any that don't fit stay in the pile."""

type: Literal["pick_up"] = "pick_up"


class SellMessage(Model):
"""Sells to the buyer where the player stands, `player_state`'s `buyer`, for its price. The
server ignores it if there's no buyer, or they carry nothing it buys."""

type: Literal["sell"] = "sell"
item: Id | None = Field(
description="One thing they carry, by its `id`, or null for everything they carry that "
"the buyer buys."
)


type ServerMessage = Annotated[
SnapshotMessage
| ClockMessage
Expand Down Expand Up @@ -406,7 +445,8 @@ class PickUpMessage(Model):
| ListWorksMessage
| ReadWorkMessage
| EatMessage
| PickUpMessage,
| PickUpMessage
| SellMessage,
Field(discriminator="type"),
]

Expand Down
Loading
Loading