Skip to content

S6a: Contract: salvage and selling - #372

Merged
jamescrosswell merged 2 commits into
mainfrom
task/371-salvage-contract
Oct 22, 2026
Merged

jamescrosswell merged 2 commits into
mainfrom
task/371-salvage-contract

Conversation

@jamescrosswell

@jamescrosswell jamescrosswell commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

The contract S6 (#353) needs for salvage to be picked up and sold. Nothing sends or handles any of it yet; S6 does.

Client → server

  • sell: {"type": "sell", "item": "copper_wire"} sells one thing the player carries, by its id, to the buyer where they stand. "item": null sells everything they carry that the buyer buys. The server ignores a sell where there's no buyer, or for something they don't carry or the buyer doesn't buy.
  • pick_up is unchanged, but now also takes a pile's money, and works on the world's piles as well as the player's own.

Server → client

  • player_state gains buyer: {"place": "scrap_stall", "name": "The scrap trader", "prices": {"copper_wire": 6}}, who buys where the player stands, a stall or a keeper at their place, and what it pays for one of each thing they carry that it buys. It's left out where there's none, so the client never has to know where the buyers are or what anything's worth. The prices are the server's, and an NPC only voices them.
  • player_state gains piles: what's lying in the world to pick up besides what the player left (player.left), on every map, such as the metro's salvage and secrets. Left out while there's none.

Schema

  • Pile.money: cash lying there, which weighs nothing. A pile holds at least one item or some money, so {"at": ..., "items": [], "money": 25} is the stairwell's secret. items is still always sent. money is strict, so a string such as "0" can't pass the "some money" rule and then become 0.
  • Player.taken: when the player last took from each of the world's pickups, a sim timestamp by pickup id ({"metro_salvage_1": "2026-03-14T10:05:30Z"}), saved with the player. Salvage lies there again a while after it was taken, after a delay S6 sets, and a secret, cash or a ration, stays taken for good. Anything of a pickup that didn't fit goes into left, so a half-taken pickup is saved too, and left's description now covers it.

Additive only

  • money, taken, buyer and piles are left out of dumps while empty (exclude_if) and aren't required in the exported schemas, so every existing message, the players.json fixtures and saves dump exactly as before. A save from before them loads with nothing taken.
  • Pile.items loses minItems: 1 for the cash-only pile, which the validator replaces with "at least one item or some money". Everything valid before is still valid. In protocol.ts it's now Item[] rather than a non-empty tuple; the client still type-checks.
  • sell is only added to the client union. Player.json, protocol.schema.json and protocol.ts are regenerated.

Choices

  • piles and buyer in player_state, not messages of their own. Only the player picks things up, and taking part of a world pile moves the rest into left, so both change in one message. player_state already goes out on every change.
  • Player.taken rather than the world's piles in the save. The ids come from the maps' pickups layer (S3), so a pickup S3 moves or adds is picked up from its map, and the save only says which were taken and when.
  • prices and taken keyed by Key, not Id. An Id-keyed dict makes json-schema-to-typescript write a stray "referenced by undefined" comment into protocol.ts.

Closes #371

🤖 Generated with Claude Code

Adds what S6 needs for salvage to be picked up and sold: `player_state`
gains `piles`, what's lying in the world to pick up, and `buyer`, who buys
where the player stands and for what; `sell` from the client; a `Pile` can
hold `money`, for the metro's cash secrets; and `Player.taken` keeps which
of the world's pickups they've taken from. The new fields are left out of
dumps while empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamescrosswell

Copy link
Copy Markdown
Contributor Author

Review of S6a. Approving.

Checked

  • Scope: only the schema, the protocol, the generated files, docs/protocol.md and their tests.
  • Additive: main's 74 fixtures (test_messages.py and players.json) dump byte for byte the same under this branch. They also validate against the new Player.json and protocol.schema.json. Dropping minItems only loosens the schema. simile schema --check and the protocol export are up to date. Old saves load.
  • Validation: the pile rule runs mode="before", and a rejected items = [] or money = 0 leaves the pile unchanged.
  • No Claude API, no new comments, nothing personal.
  • pytest tests/schema tests/protocol tests/persistence: 824 passed. vitest run src/net: 30 passed.

Design

  • piles in player_state: keep it. Every send carries every pile, the hunger and lamp sends included. At 1x, hunger alone sends every 144 real seconds, and S4's lamp will add some sends. That's about 2 KB with the metro's 9 pickups, and 5 to 10 KB once the sewers have theirs. Building and comparing the message on each tick takes about 0.3 ms with 25 piles. A message of its own would lose the move from piles to left in one message. Sending only the current map's piles would resend on every map change and work differently from left. Neither is worth it at this size.
  • Decision for the user: does salvage respawn? With taken permanent, the world holds a fixed amount of money: the metro has 6 salvage spots and 65 in cash, and each game's sewers add some. S6 and S7 then have to price things so S15's loop can still be finished after meals at the café or the noodle bar. Respawning the whole world at once works with this list: clear it. Respawning each pickup on its own timer would need {id: when}, and that's cheaper to choose now than to migrate saves to later.
  • S7 will need its own contract. sell and buyer cover the doctor buying salvage. They don't cover the kidney: it's a Body part, not a carried item, and both sell.item and prices go by item id. They don't cover fitting bionics either, where the player pays money and a part. That needs something like a part sale and a fit message, with the doctor's offers in player_state. Buyer can take new fields without breaking anything.
  • prices keyed by Key: fine. I confirmed that dict[Id, ...] exports patternProperties, and json-schema-to-typescript then writes the "referenced by undefined" comment. Only the server writes prices, so the id pattern would only be documentation.

Optional nits

  • _something_lies_there checks money before it's converted to a number, so money="0" gets past it, on assignment too. Nothing sends a string today.
  • docs/protocol.md line 270, "nothing taken. Their", needs reflowing.

- Player.taken records when each pickup was last taken, by pickup id, so
  salvage can lie there again after a while. A secret stays taken.
- Pile.money is strict: a string like "0" can't pass the "some money"
  rule and then turn into 0.
- Reflow protocol.md's paragraph on saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jamescrosswell

Copy link
Copy Markdown
Contributor Author

Re-review at 6ca5aab. Approving.

Since 04de26a

  • Still additive. Main's 74 fixtures (test_messages.py and players.json, at the merge base) dump byte for byte the same under this head. They also validate against the new Player.json and protocol.schema.json. simile schema --check and the protocol export are up to date.
  • taken as dict[Key, Timestamp]. It's left out while empty, and it isn't required in either schema. Times come back in UTC. The tests reject a naive time, a time that isn't one, an empty id and a list.
  • Old saves load. A save without taken loads with {}. A save with a list would fail SavedWorld and start afresh, but none can exist. Nothing on main reads or writes taken, and S6 (S6: Salvage, and selling it #373) already writes the dict. No migration is needed.
  • Strict money. Strings and bools are rejected on construction and on assignment, and ints still load from JSON. A rejected assignment leaves the pile unchanged. The new code comment is one line and says something the code can't.
  • The protocol.md paragraph is reflowed.
  • This head merges cleanly into the current main, and S12: Aris and the incident #368 didn't touch the contracts.
  • pytest tests/schema tests/protocol tests/persistence: 832 passed. vitest run src/net: 30 passed.

@jamescrosswell
jamescrosswell marked this pull request as ready for review October 22, 2026 15:47
@jamescrosswell
jamescrosswell merged commit def88f5 into main Oct 22, 2026
7 checks passed
@jamescrosswell
jamescrosswell deleted the task/371-salvage-contract branch October 22, 2026 15:47
This was referenced Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

S6a: Contract: salvage and selling

1 participant