Storage Lockers¶
Status: ✅ Shipped —
StorageLocker/ContractCargoDepositmodels,storage_service.py, and multi-trip contract fulfillment via Contract Board are live on tip. (re-verified 2026-08-21 vs Sectorwars2102 HEAD46bce720.)
A storage locker is a rented cargo buffer at a station that lets a player fulfill a trade contract over multiple trips instead of hauling the full quantity in one load. A ship too small to carry a contract's entire required quantity can still accept it: deposit what fits, come back, deposit the rest.
✅ Shipped. StorageLocker / ContractCargoDeposit models, storage_service.py, and routes/storage.py are live and wired into the contract-completion and contract-expiry-sweep paths. Multi-trip locker fulfillment is player-reachable via the Contract Board (ContractBoardVenue.tsx / storageAPI — rent, deposit installments, list claimable, retrieve).
Overview¶
Storage lockers exist to fix one specific problem: a contract's required quantity is fixed at posting time, but a player's ship cargo capacity varies. Without a locker, a contract that needs more cargo than any one trip can carry is uncompletable by a small ship. With a locker, the player rents a buffer at the contract's destination station and deposits installments across as many trips as it takes; the locker tracks the running total and auto-completes the contract the moment it's reached.
A locker is tied to one (player, contract) pair — renting a locker for a contract you've already rented one for returns the same locker rather than minting a duplicate.
Renting a locker¶
POST /api/v1/storage/lockers with {contract_id}. The locker opens at the contract's own destination_station_id — you cannot choose a different station. Requirements:
- The contract must be in the
acceptedstate. - You must be the contract's
acceptor_player_id.
A second call for the same contract is idempotent and returns the existing locker rather than erroring or creating a second one.
Depositing cargo¶
POST /api/v1/storage/lockers/{locker_id}/deposit with {quantity}. Requirements:
- You must own the locker and be docked at its station.
- The locker must be
activeand tied to a still-acceptedcontract. - Your current ship must be carrying at least
quantityunits of the contract's commodity.
Each deposit writes a ContractCargoDeposit audit row (locker, commodity, quantity, depositor, timestamp) rather than mutating a single running total — the locker's stored quantity is always the live sum of its deposit rows. Deposits from any ship the player is currently piloting count; the ship that delivers the completing installment doesn't have to be the one that carried every earlier trip.
The moment accumulated deposits reach the contract's required quantity, the deposit call auto-completes the contract in the same transaction — crediting payment via the standard contract-completion path (see contracts.md) and flipping the locker to released. No separate "claim" step is needed.
Rent¶
Rent accrues flat, at 1 credit per stored unit per day, charged against the locker owner's credits — not the contract's payment. Settlement is settle-on-access: there is no background rent-collection worker. Every deposit and every retrieve call settles the elapsed rent since the locker's last settlement first.
Two properties worth knowing if you're holding a locker for a while:
- Continuous-accrue, round-once. The theoretical fee accrues continuously (fractional credits included) into a running ledger; only newly-crossed whole credits are actually charged on any given call. A string of small, frequent deposits is never each individually rounded down to a free 0cr charge, and a single long-held locker is never charged in an unpredictable lump — the ledger always reflects the true elapsed-time cost.
- Floor-and-forgive. If you can't fully cover a newly-crossed rent charge, you pay what you can down to 0 credits; the shortfall is forgiven, never carried as debt.
For the deposit that completes a contract specifically, rent for that installment settles after the completion payout lands — not before — so the bill isn't floored to near-zero at the moment you're about to be paid.
Missing the deadline — expiry to claimable¶
If a contract's deadline passes before its locker reaches the required quantity, the contract expires through the normal contract-expiry sweep, and the locker converts from active to claimable in the same pass:
- You keep whatever was deposited. It is not forfeited or returned to the contract issuer.
- Rent keeps ticking against the claimable balance until you retrieve it.
- A locker that has any in-flight deposit at the moment of the sweep is never converted mid-transaction — an in-progress completing deposit always wins the race against an expiring deadline.
Retrieving claimable cargo¶
POST /api/v1/storage/lockers/{locker_id}/retrieve with an optional {quantity}. Requirements:
- The locker must be
claimableand you must own it and be docked at its station. - Rent settles up to the moment of the call before anything is computed.
quantity is optional — omit it to retrieve as much as fits in one trip (up to everything stored). A ship too small to carry the full claimable balance retrieves what fits now and the rest on a later trip; the locker stays claimable with the remainder, still accruing rent. Passing an explicit quantity validates against both what's stored and what your ship's remaining cargo capacity allows. The locker releases automatically once its balance reaches zero.
Locker lifecycle¶
StorageLocker.status ∈ {
active, // rent accruing, tied to a contract_id
claimable, // contract expired before completion; owner keeps cargo, rent keeps ticking
released // emptied and vacated — either the contract completed, or a claimable locker was fully retrieved
}
| From → To | Trigger |
|---|---|
(created) → active |
POST /storage/lockers against an accepted contract |
active → released |
A deposit brings the accumulated total to the contract's required quantity (auto-completes the contract) |
active → claimable |
The tied contract expires before reaching full quantity |
claimable → released |
A retrieve call brings the remaining stored balance to zero |
A contract that lapses and is later replaced by a fresh acceptance always gets a new locker — a claimable locker's contract_id is cleared on conversion, so old claimable deposits can never silently count toward a different contract's completion.
Tier and risk state¶
The schema carries tier (basic / reinforced / vault) and risk_state (secure / watched / targeted / breached) columns on every locker.
✅ Tier ladder shipped 2026-08-04 —
POST /storage/lockersaccepts an optionaltier(basic/reinforced/vault, defaultbasic, preserving prior behavior);storage_service.rent_rate_for_tierapplies the canon rent multiplier (Basic 1×, Reinforced ~2.5×, Vault ~5×) at creation, stored per-locker so a later multiplier tuning never retroactively reprices an already-rented locker.📐 Risk-state ladder remains Design-only — every locker still ships
risk_state = secure; there is no dwell-time/station-security mechanic that moves a locker along the Secure→Watched→Targeted→Breached ladder, and no break-in mechanic reads it. That is the separately-tracked heist S2 system (WO-HEIST-RISK-STATE/-BREAKIN/-CONSEQUENCES,audit/design-briefs/heist-brief.html), which carries its own unresolved design numbers (break-in success formula, spoilage %) — out of scope for the tier-ladder wiring above.
Schema¶
StorageLocker¶
Source: services/gameserver/src/models/storage_locker.py (✅ Shipped).
| name | type | constraints | notes |
|---|---|---|---|
| id | UUID | PK | |
| owner_player_id | UUID FK players.id | not null, CASCADE | The renter. |
| station_id | UUID FK stations.id | not null, CASCADE | Always the tied contract's destination_station_id at creation. |
| contract_id | UUID FK contracts.id | nullable, SET NULL | Null once the locker is standalone claimable storage, no longer tied to the contract that spawned it. |
| status | Enum | not null, default active |
active / claimable / released. |
| tier | Enum | not null, default basic |
basic / reinforced / vault — see Tier and risk state. |
| risk_state | Enum | not null, default secure |
secure / watched / targeted / breached — see Tier and risk state. |
| rent_rate | Numeric(19,2) | not null, default 1 | Credits per unit per day, stored per-locker so a future tier-multiplier change never retroactively reprices an already-rented locker. |
| accrued_fee | Numeric(19,2) | not null, default 0 | Monotonically-increasing full-precision ledger of every period fee ever computed — not "money actually collected." See Rent. |
| last_fee_settled_at | DateTime | not null | Anchor for the next settlement's elapsed-time calculation. |
| created_at | DateTime | not null | Also the S2 dwell-time anchor for a future risk-ladder mechanic. |
rent_rate/accrued_fee use Numeric(19, 2) rather than Player.credits' Integer — intentional, ruled: aligns with Contract's money columns and the ROUND_HALF_UP fee-accrual language, and the S2 tier multipliers (~2.5×/~5×) need fractional precision Integer would truncate. Player.credits staying Integer is a wallet-boundary convention, not a rule every money-shaped column must match. See DECISIONS.md#storage-locker-money-field-type.
Indexes: (owner_player_id), (station_id), (contract_id), unique (owner_player_id, contract_id).
ContractCargoDeposit¶
Source: services/gameserver/src/models/storage_locker.py (✅ Shipped) — one row per delivery installment, an audit trail rather than a mutable running total.
| name | type | constraints | notes |
|---|---|---|---|
| id | UUID | PK | |
| locker_id | UUID FK storage_lockers.id | not null, CASCADE | |
| commodity | String(50) | not null | Matches Contract.commodity_type. |
| quantity | Integer | not null | Units deposited in this installment. |
| deposited_by | UUID FK players.id | nullable, SET NULL | The depositing player; a deleted account never erases the locker's deposit history. |
| deposited_at | DateTime | not null | Retrieval consumes rows oldest-first. |
Index: (locker_id).
Player-facing affordances¶
- ✅ Contract Board multi-trip locker flow —
ContractBoardVenue.tsxon the SpaceDock Contract Board (Mine→ accepted contracts): Deposit callsstorageAPI.rentLocker(contract.id)thenstorageAPI.deposit(lockerId, quantity)with an optional per-contract quantity override; client tracks locker progress and surfaces auto-complete success when the deposit response reportscompleted === true(origin/feat46bce720). - ✅ Claimable locker list + retrieve — third Claimable sub-tab lists player-owned
CLAIMABLElockers viastorageAPI.getClaimable(); Retrieve at the locker's station callsstorageAPI.retrieve(locker.id)with multi-trip remainder copy when cargo does not fit in one hold (origin/feat46bce720). - 📐 Tier selection at rent — server accepts optional
tieronPOST /storage/lockers; the Contract Board deposit path does not expose a tier picker and relies on the server default (basic). - 📐 Risk-state ladder / break-in — every locker ships
risk_state = secure; no player UI for dwell-time risk progression or heist consequences (see Tier and risk state).
Source map¶
| Concern | Path |
|---|---|
| Locker + deposit models | services/gameserver/src/models/storage_locker.py |
| Deposit / rent / expiry-claimable / retrieve service | services/gameserver/src/services/storage_service.py |
| REST routes | services/gameserver/src/api/routes/storage.py |
| Locker-expiry hook (runs inside the contract-expiry sweep) | services/gameserver/src/services/scheduler/contract_sweeps.py |
| Migrations | services/gameserver/alembic/versions/61b7e6f4ff93_add_storage_locker_and_cargo_deposit.py, services/gameserver/alembic/versions/b9a7404a2c20_unique_locker_per_player_contract.py |
| Player UI (locker rent / deposit / claimable / retrieve) | services/player-client/src/components/spacedock/ContractBoardVenue.tsx (storageAPI; SpaceDock Contract Board) — ✅ Shipped |
Cross-references¶
- contracts.md — the contract lifecycle a locker's deposits complete.
- docking-slips.md — the broader station-berth model; a locker rides on the station a contract already targets rather than consuming a docking slip of its own.
- tradedock-shipyard.md — TradeDocks are ordinary
Stationrows and can host contract-tied lockers the same as any other station.