diff --git a/client/src/net/protocol.ts b/client/src/net/protocol.ts index d0030994..74c13640 100644 --- a/client/src/net/protocol.ts +++ b/client/src/net/protocol.ts @@ -60,7 +60,8 @@ export type ClientMessage = | ListWorksMessage | ReadWorkMessage | EatMessage - | PickUpMessage; + | PickUpMessage + | SellMessage; /** * The whole world, sent on connect. Replaces anything the client had. @@ -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'; @@ -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 @@ -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. @@ -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. @@ -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 @@ -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; +} diff --git a/docs/protocol.md b/docs/protocol.md index fb4bda9c..2d1b64c2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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. @@ -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 @@ -233,15 +235,31 @@ 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: @@ -249,9 +267,9 @@ A `Body` has a field for each part: `legs`, `vertebra`, `left_eye`, `right_eye`, 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` @@ -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` diff --git a/server/simile/protocol/__init__.py b/server/simile/protocol/__init__.py index 1488d3fe..50e79a47 100644 --- a/server/simile/protocol/__init__.py +++ b/server/simile/protocol/__init__.py @@ -2,6 +2,7 @@ from simile.protocol.hub import Connection, Hub, hub_of from simile.protocol.messages import ( Artist, + Buyer, ClientMessage, ClockMessage, ConversationEndedMessage, @@ -34,6 +35,7 @@ Position, ReadNewsMessage, ReadWorkMessage, + SellMessage, ServerMessage, SnapshotMessage, Wall, @@ -46,6 +48,7 @@ "SAMPLE_RATE", "Artist", "AudioChunk", + "Buyer", "ClientMessage", "ClockMessage", "Connection", @@ -80,6 +83,7 @@ "Position", "ReadNewsMessage", "ReadWorkMessage", + "SellMessage", "ServerMessage", "SnapshotMessage", "Wall", diff --git a/server/simile/protocol/messages.py b/server/simile/protocol/messages.py index 63693b02..02827b7c 100644 --- a/server/simile/protocol/messages.py +++ b/server/simile/protocol/messages.py @@ -13,6 +13,7 @@ Key, Line, Model, + Pile, Player, Pose, Timestamp, @@ -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 @@ -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): @@ -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 @@ -406,7 +445,8 @@ class PickUpMessage(Model): | ListWorksMessage | ReadWorkMessage | EatMessage - | PickUpMessage, + | PickUpMessage + | SellMessage, Field(discriminator="type"), ] diff --git a/server/simile/protocol/protocol.schema.json b/server/simile/protocol/protocol.schema.json index 641e5f4b..ca50a2eb 100644 --- a/server/simile/protocol/protocol.schema.json +++ b/server/simile/protocol/protocol.schema.json @@ -70,6 +70,41 @@ "title": "Body", "type": "object" }, + "Buyer": { + "additionalProperties": false, + "description": "Who buys things where the player stands, by sending `sell`: a stall, or a keeper at their\nplace.", + "properties": { + "place": { + "description": "Where: a location id, e.g. \"scrap_stall\" or \"clinic\".", + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_]*$", + "type": "string" + }, + "name": { + "description": "The buyer as the player sees it, e.g. \"The scrap trader\".", + "minLength": 1, + "type": "string" + }, + "prices": { + "additionalProperties": { + "minimum": 0, + "type": "number" + }, + "description": "What it pays for one of each thing they carry that it buys, by item `id`. Empty when they carry nothing it buys.", + "propertyNames": { + "minLength": 1 + }, + "type": "object" + } + }, + "required": [ + "place", + "name", + "prices" + ], + "title": "Buyer", + "type": "object" + }, "ClientMessage": { "discriminator": { "mapping": { @@ -81,6 +116,7 @@ "player_say": "#/$defs/PlayerSayMessage", "read_news": "#/$defs/ReadNewsMessage", "read_work": "#/$defs/ReadWorkMessage", + "sell": "#/$defs/SellMessage", "watch_npc": "#/$defs/WatchNpcMessage" }, "propertyName": "type" @@ -112,6 +148,9 @@ }, { "$ref": "#/$defs/PickUpMessage" + }, + { + "$ref": "#/$defs/SellMessage" } ] }, @@ -992,7 +1031,7 @@ }, "PickUpMessage": { "additionalProperties": false, - "description": "Picks up, from the nearest pile within reach of the player, each item that still fits\nunder their carry limit, in order. Any that don't fit stay in the pile.", + "description": "Picks up, from the nearest pile within reach of the player, its money and each item that\nstill fits under their carry limit, in order. Any that don't fit stay in the pile.", "properties": { "type": { "const": "pick_up", @@ -1008,7 +1047,7 @@ }, "Pile": { "additionalProperties": false, - "description": "Items lying `at` a tile in the world for the player to pick up, such as what they left\nwhen they collapsed.", + "description": "Items, money or both lying `at` a tile in the world for the player to pick up, such as\nsalvage, a secret's cash, or what they left when they collapsed.", "properties": { "at": { "$ref": "#/$defs/MapTile" @@ -1017,8 +1056,13 @@ "items": { "$ref": "#/$defs/Item" }, - "minItems": 1, "type": "array" + }, + "money": { + "default": 0.0, + "description": "Cash lying there, which weighs nothing. Left out while there's none.", + "minimum": 0, + "type": "number" } }, "required": [ @@ -1063,11 +1107,23 @@ }, "left": { "default": [], - "description": "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.", + "description": "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.", "items": { "$ref": "#/$defs/Pile" }, "type": "array" + }, + "taken": { + "additionalProperties": { + "format": "date-time", + "type": "string" + }, + "default": {}, + "description": "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.", + "propertyNames": { + "minLength": 1 + }, + "type": "object" } }, "required": [ @@ -1149,7 +1205,7 @@ }, "PlayerStateMessage": { "additionalProperties": false, - "description": "The player's money, what they carry, their body and their hunger, but never their humanity,\nand the meal on offer where they stand. Sent after the snapshot on connect, and whenever any of\nit changes.", + "description": "The player's money, what they carry, their body and their hunger, but never their humanity,\nwith the meal on offer and the buyer where they stand, and what's lying in the world for them\nto pick up. Sent after the snapshot on connect, and whenever any of it changes.", "properties": { "type": { "const": "player_state", @@ -1170,6 +1226,26 @@ ], "default": null, "description": "The meal on offer where they stand: at home, or at a shop that's serving. Left out where there's none." + }, + "buyer": { + "anyOf": [ + { + "$ref": "#/$defs/Buyer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Who buys where they stand, such as the scrap trader at their stall. Left out where there's none." + }, + "piles": { + "default": [], + "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.", + "items": { + "$ref": "#/$defs/Pile" + }, + "type": "array" } }, "required": [ @@ -1260,6 +1336,36 @@ "title": "ReadWorkMessage", "type": "object" }, + "SellMessage": { + "additionalProperties": false, + "description": "Sells to the buyer where the player stands, `player_state`'s `buyer`, for its price. The\nserver ignores it if there's no buyer, or they carry nothing it buys.", + "properties": { + "type": { + "const": "sell", + "default": "sell", + "type": "string" + }, + "item": { + "anyOf": [ + { + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_]*$", + "type": "string" + }, + { + "type": "null" + } + ], + "description": "One thing they carry, by its `id`, or null for everything they carry that the buyer buys." + } + }, + "required": [ + "type", + "item" + ], + "title": "SellMessage", + "type": "object" + }, "ServerMessage": { "discriminator": { "mapping": { diff --git a/server/simile/schema/json/Player.json b/server/simile/schema/json/Player.json index 094db36e..1b039ea9 100644 --- a/server/simile/schema/json/Player.json +++ b/server/simile/schema/json/Player.json @@ -170,7 +170,7 @@ }, "Pile": { "additionalProperties": false, - "description": "Items lying `at` a tile in the world for the player to pick up, such as what they left\nwhen they collapsed.", + "description": "Items, money or both lying `at` a tile in the world for the player to pick up, such as\nsalvage, a secret's cash, or what they left when they collapsed.", "properties": { "at": { "$ref": "#/$defs/MapTile" @@ -179,9 +179,15 @@ "items": { "$ref": "#/$defs/Item" }, - "minItems": 1, "title": "Items", "type": "array" + }, + "money": { + "default": 0.0, + "description": "Cash lying there, which weighs nothing. Left out while there's none.", + "minimum": 0, + "title": "Money", + "type": "number" } }, "required": [ @@ -230,12 +236,25 @@ }, "left": { "default": [], - "description": "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.", + "description": "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.", "items": { "$ref": "#/$defs/Pile" }, "title": "Left", "type": "array" + }, + "taken": { + "additionalProperties": { + "format": "date-time", + "type": "string" + }, + "default": {}, + "description": "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.", + "propertyNames": { + "minLength": 1 + }, + "title": "Taken", + "type": "object" } }, "required": [ diff --git a/server/simile/schema/player.py b/server/simile/schema/player.py index 0cd9b2ce..c98b391a 100644 --- a/server/simile/schema/player.py +++ b/server/simile/schema/player.py @@ -3,7 +3,7 @@ from pydantic import Field, model_validator -from simile.schema.common import CHECKED, Id, Model, Unit +from simile.schema.common import CHECKED, Id, Key, Model, Timestamp, Unit from simile.schema.world import MapTile CARRY_LIMIT = 10.0 @@ -60,13 +60,29 @@ def lamp() -> Item: class Pile(Model): - """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.""" model_config = CHECKED at: MapTile - items: list[Item] = Field(min_length=1) + items: list[Item] + money: float = Field( + default=0.0, + ge=0, + allow_inf_nan=False, + strict=True, + exclude_if=lambda money: not money, + description="Cash lying there, which weighs nothing. Left out while there's none.", + ) + + @model_validator(mode="before") + @classmethod + def _something_lies_there(cls, data: Any) -> Any: + # `money` is strict, so a string can't pass this as cash and turn into 0 after it. + if isinstance(data, dict) and not data.get("items") and not data.get("money"): + raise ValueError("a pile holds at least one item or some money") + return data class Bionic(StrEnum): @@ -152,8 +168,16 @@ class Player(Model): left: list[Pile] = Field( default=[], exclude_if=lambda left: not left, - description="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.", + description="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.", + ) + taken: dict[Key, Timestamp] = Field( + default={}, + exclude_if=lambda taken: not taken, + description="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.", ) @property diff --git a/server/tests/protocol/test_messages.py b/server/tests/protocol/test_messages.py index cf194f54..6c6d1a51 100644 --- a/server/tests/protocol/test_messages.py +++ b/server/tests/protocol/test_messages.py @@ -9,6 +9,7 @@ from simile.protocol import ( Artist, + Buyer, ClockMessage, ConversationEndedMessage, ConversationStartedMessage, @@ -38,6 +39,7 @@ Position, ReadNewsMessage, ReadWorkMessage, + SellMessage, Wall, WatchNpcMessage, WorksMessage, @@ -194,6 +196,19 @@ meal=Meal(place="maker_space", name="home", price=0), ) COLLAPSED = PlayerCollapsedMessage(position=Position(x=13, y=2, map="maker_space_floor_0")) +SCRAP_TRADER = Buyer(place="scrap_stall", name="The scrap trader", prices={"copper_wire": 6}) +AT_THE_SCRAP_STALL = DELVING.model_copy(update={"buyer": SCRAP_TRADER}) +SERVO = Item(id="servo_motor", kind=ItemKind.SALVAGE, name="Servo motor", weight=3) +RATION_PACK = Item(id="ration_pack", kind=ItemKind.FOOD, name="Ration pack", weight=0.5) +IN_THE_METRO = STARTING.model_copy( + update={ + "piles": [ + Pile(at=MapTile(map="metro", x=8, y=7), items=[], money=25), + Pile(at=MapTile(map="metro", x=108, y=12), items=[SERVO]), + Pile(at=MapTile(map="metro", x=86, y=8), items=[RATION_PACK]), + ] + } +) FROM_THE_NOTES = JournalEntry( fact_id="notes", @@ -244,6 +259,8 @@ DELVING, AT_THE_NOODLE_BAR, WOKEN, + AT_THE_SCRAP_STALL, + IN_THE_METRO, JournalMessage(entries=[]), JOURNAL, COLLAPSED, @@ -262,6 +279,8 @@ EatMessage(item="noodles"), EatMessage(item=None), PickUpMessage(), + SellMessage(item="copper_wire"), + SellMessage(item=None), ] @@ -690,7 +709,9 @@ def test_player_state_says_what_meal_is_on_offer_where_they_stand() -> None: @pytest.mark.parametrize("message", [STARTING, DELVING], ids=["starting", "delving"]) -def test_player_state_without_a_meal_or_piles_dumps_as_before(message: PlayerStateMessage) -> None: +def test_player_state_without_a_meal_buyer_or_piles_dumps_as_before( + message: PlayerStateMessage, +) -> None: data = json.loads(message.model_dump_json()) assert set(data) == {"type", "player"} @@ -709,13 +730,69 @@ def test_player_state_has_what_they_left_where_they_collapsed() -> None: ] -def test_the_exported_player_state_has_a_meal_and_piles_but_does_not_require_them() -> None: +@pytest.mark.parametrize( + ("model", "field"), + [ + ("PlayerStateMessage", "meal"), + ("PlayerStateMessage", "buyer"), + ("PlayerStateMessage", "piles"), + ("Player", "left"), + ("Player", "taken"), + ("Pile", "money"), + ], +) +def test_the_exported_protocol_has_fields_it_does_not_require(model: str, field: str) -> None: defs = json.loads(SCHEMA_FILE.read_text())["$defs"] - assert "meal" in defs["PlayerStateMessage"]["properties"] - assert "meal" not in defs["PlayerStateMessage"]["required"] - assert "left" in defs["Player"]["properties"] - assert "left" not in defs["Player"]["required"] + assert field in defs[model]["properties"] + assert field not in defs[model]["required"] + + +def test_player_state_says_who_buys_where_they_stand_and_for_what() -> None: + data = json.loads(AT_THE_SCRAP_STALL.model_dump_json()) + + assert data == json.loads(DELVING.model_dump_json()) | { + "buyer": { + "place": "scrap_stall", + "name": "The scrap trader", + "prices": {"copper_wire": 6.0}, + } + } + + +def test_a_buyer_can_buy_nothing_they_carry() -> None: + buyer = SCRAP_TRADER.model_copy(update={"prices": {}}) + + assert json.loads(buyer.model_dump_json())["prices"] == {} + + +@pytest.mark.parametrize( + ("update", "match"), + [ + ({"prices": {"copper_wire": -1}}, "greater than or equal to 0"), + ({"prices": {"copper_wire": float("inf")}}, "finite number"), + ({"prices": {"": 6}}, "at least 1 character"), + ({"name": ""}, "at least 1 character"), + ({"place": "Scrap stall"}, "String should match pattern"), + ], +) +def test_rejects_invalid_buyers(update: dict[str, Any], match: str) -> None: + with pytest.raises(ValidationError, match=match): + Buyer.model_validate(SCRAP_TRADER.model_dump() | update) + + +def test_player_state_has_what_lies_in_the_world_to_pick_up() -> None: + data = json.loads(IN_THE_METRO.model_dump_json()) + + assert set(data) == {"type", "player", "piles"} + assert data["piles"] == [ + {"at": {"map": "metro", "x": 8, "y": 7}, "items": [], "money": 25.0}, + {"at": {"map": "metro", "x": 108, "y": 12}, "items": [json.loads(SERVO.model_dump_json())]}, + { + "at": {"map": "metro", "x": 86, "y": 8}, + "items": [json.loads(RATION_PACK.model_dump_json())], + }, + ] @pytest.mark.parametrize( @@ -745,10 +822,14 @@ def test_a_collapse_says_where_they_wake() -> None: (EatMessage(item="noodles"), {"type": "eat", "item": "noodles"}), (EatMessage(item=None), {"type": "eat", "item": None}), (PickUpMessage(), {"type": "pick_up"}), + (SellMessage(item="copper_wire"), {"type": "sell", "item": "copper_wire"}), + (SellMessage(item=None), {"type": "sell", "item": None}), ], - ids=["item", "meal", "pick_up"], + ids=["item", "meal", "pick_up", "sell_one", "sell_all"], ) -def test_eating_and_picking_up_go_from_client_to_server(message: Any, data: dict[str, Any]) -> None: +def test_eating_picking_up_and_selling_go_from_client_to_server( + message: Any, data: dict[str, Any] +) -> None: assert json.loads(message.model_dump_json()) == data assert client_messages.validate_python(data) == message with pytest.raises(ValidationError): @@ -880,6 +961,9 @@ def test_timestamps_are_utc() -> None: {"type": "eat"}, {"type": "eat", "item": "Noodles"}, {"type": "pick_up", "item": "lamp"}, + {"type": "sell"}, + {"type": "sell", "item": "Copper wire"}, + {"type": "sell", "item": "copper_wire", "price": 6}, ], ) def test_rejects_invalid_client_messages(data: dict[str, Any]) -> None: diff --git a/server/tests/schema/players.json b/server/tests/schema/players.json index e0a72027..8e1f929b 100644 --- a/server/tests/schema/players.json +++ b/server/tests/schema/players.json @@ -86,5 +86,37 @@ ] } ] + }, + { + "money": 25.0, + "items": [ + {"id": "lamp", "kind": "lamp", "name": "Lamp", "weight": 1.0, "charge": 0.6, "on": true}, + {"id": "scrap_steel", "kind": "salvage", "name": "Scrap steel", "weight": 4.0, "charge": null, "on": null}, + {"id": "servo_motor", "kind": "salvage", "name": "Servo motor", "weight": 3.0, "charge": null, "on": null} + ], + "carry_limit": 10.0, + "body": { + "legs": "hydraulic_legs", + "vertebra": "present", + "left_eye": "present", + "right_eye": "present", + "left_kidney": "sold", + "right_kidney": "present" + }, + "hunger": 0.4, + "left": [ + { + "at": {"map": "metro", "x": 77, "y": 12}, + "items": [ + {"id": "copper_wire", "kind": "salvage", "name": "Copper wire", "weight": 1.5, "charge": null, "on": null}, + {"id": "brass_valve", "kind": "salvage", "name": "Brass valve", "weight": 2.0, "charge": null, "on": null} + ] + } + ], + "taken": { + "metro_secret_stairwell": "2026-03-14T09:40:00Z", + "metro_salvage_2": "2026-03-14T10:05:30Z", + "metro_salvage_4": "2026-03-14T10:12:00Z" + } } ] diff --git a/server/tests/schema/test_player.py b/server/tests/schema/test_player.py index f8112a16..c135425a 100644 --- a/server/tests/schema/test_player.py +++ b/server/tests/schema/test_player.py @@ -1,4 +1,5 @@ import json +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -23,7 +24,7 @@ HERE = Path(__file__).parent PLAYERS: list[dict[str, Any]] = json.loads((HERE / "players.json").read_text()) -STARTING, KIDNEY_SOLD, DELVING, FITTED, COLLAPSED = PLAYERS +STARTING, KIDNEY_SOLD, DELVING, FITTED, COLLAPSED, SALVAGING = PLAYERS SALVAGE = DELVING["items"][1] @@ -37,7 +38,7 @@ def rejects(model: type[Player | Item | Body | Pile], data: dict[str, Any], matc @pytest.mark.parametrize( - "data", PLAYERS, ids=["starting", "kidney_sold", "delving", "fitted", "collapsed"] + "data", PLAYERS, ids=["starting", "kidney_sold", "delving", "fitted", "collapsed", "salvaging"] ) def test_fixture_round_trips_through_json(data: dict[str, Any]) -> None: parsed = Player.model_validate_json(json.dumps(data)) @@ -280,10 +281,73 @@ def test_a_player_who_left_nothing_dumps_without_piles() -> None: assert "left" not in schema("Player").schema["required"] +def test_when_they_took_from_each_pickup_is_kept_by_its_id() -> None: + player = Player.model_validate(SALVAGING) + + assert player.taken == { + "metro_secret_stairwell": datetime(2026, 3, 14, 9, 40, tzinfo=UTC), + "metro_salvage_2": datetime(2026, 3, 14, 10, 5, 30, tzinfo=UTC), + "metro_salvage_4": datetime(2026, 3, 14, 10, 12, tzinfo=UTC), + } + assert [item.id for item in player.left[0].items] == ["copper_wire", "brass_valve"] + + +def test_a_player_who_took_nothing_dumps_without_taken() -> None: + assert Player.model_validate(COLLAPSED).taken == {} + assert "taken" not in json.loads(Player(taken={}).model_dump_json()) + assert "taken" not in schema("Player").schema["required"] + + +@pytest.mark.parametrize( + ("taken", "match"), + [ + ({"metro_salvage_1": "2026-03-14T10:05:30"}, "timezone"), + ({"metro_salvage_1": "yesterday"}, "valid datetime"), + ({"": "2026-03-14T10:05:30Z"}, "at least 1 character"), + (["metro_salvage_1"], "valid dictionary"), + ], + ids=["naive", "not_a_time", "no_id", "a_list"], +) +def test_rejects_an_invalid_time_taken(taken: Any, match: str) -> None: + rejects(Player, SALVAGING | {"taken": taken}, match) + + +def test_times_taken_are_utc() -> None: + later = SALVAGING | {"taken": {"metro_salvage_1": "2026-03-14T21:05:30+11:00"}} + + dumped = json.loads(Player.model_validate(later).model_dump_json()) + + assert dumped["taken"] == {"metro_salvage_1": "2026-03-14T10:05:30Z"} + + +def test_a_pile_can_hold_money_and_no_items() -> None: + cash = Pile(at=MapTile(map="metro", x=8, y=7), items=[], money=25) + + assert json.loads(cash.model_dump_json()) == { + "at": {"map": "metro", "x": 8, "y": 7}, + "items": [], + "money": 25.0, + } + assert Player(left=[cash]).left[0].money == 25 + + +def test_a_pile_without_money_dumps_without_it() -> None: + pile = Pile.model_validate(COLLAPSED["left"][0]) + + assert pile.money == 0 + assert "money" not in json.loads(pile.model_dump_json()) + assert "money" not in schema("Player").schema["$defs"]["Pile"]["required"] + + @pytest.mark.parametrize( ("update", "match"), [ - ({"items": []}, "at least 1 item"), + ({"items": []}, "at least one item or some money"), + ({"items": [], "money": 0}, "at least one item or some money"), + ({"money": -1}, "greater than or equal to 0"), + ({"money": float("inf")}, "finite number"), + ({"items": [], "money": "0"}, "valid number"), + ({"items": [], "money": "25"}, "valid number"), ({"at": {"map": "kapok_walk", "x": -1, "y": 15}}, "greater than or equal to 0"), ({"at": None}, "at"), ({"weight": 3}, "Extra inputs are not permitted"), @@ -296,7 +360,14 @@ def test_rejects_invalid_piles(update: dict[str, Any], match: str) -> None: def test_rejects_an_empty_pile_left_on_a_player() -> None: player = Player.model_validate(COLLAPSED) - changes(player.left[0], "items", [], "at least 1 item") + changes(player.left[0], "items", [], "at least one item or some money") + + +@pytest.mark.parametrize("money", [0, "0", "25"]) +def test_rejects_taking_the_money_from_a_pile_of_only_money(money: Any) -> None: + cash = Pile(at=MapTile(map="metro", x=8, y=7), items=[], money=25) + + changes(cash, "money", money, "at least one item or some money|valid number") @pytest.mark.parametrize(