Export fish to other villages with a Trawler or better - #138
Conversation
Gilbert's shop only spends $750 a day, so a full crew (up to ~120 fish a day) out-produces the village's only buyer and the surplus just piles up. A boat big enough to make the crossing now opens buyers who have no daily budget at all. - Add src/business/export.py with three markets: Saltmarsh (tier 2, x1.2, $25 freight), Kestrel Cove (tier 2, x1.5, $250) and Thornhaven (tier 3, x2.0, $900). Each trades a bigger premium for a bigger freight bill, so which one pays depends on the size of the load - Add exportCapacity to BOAT_TIERS: a Rowboat can't cross at all, a Trawler carries 250 fish per run and a Fishing Fleet 600 - Add "Export Fish to Other Villages" at the docks, showing the load and what it would fetch at each market before the player commits - Charge freight up front so a run can never put the player into debt (money has a schema minimum of 0), and refuse with a reason that says what to do instead - Cost a day per round trip, so exporting isn't a free repeatable action - Extract fish.bestFirst and Player.removeFish, shared by the shop's budget-limited sale and the export hold's capacity-limited one - Track totalFishExported/totalMoneyFromExports/totalShippingPaid with matching schema fields and two new milestones - Rebuild the docks menu as options/actions pairs: with two conditional entries, dispatching on a hardcoded number would eventually mismatch - Unlock a Gilbert question explaining his daily budget, and point at the export markets in the "shop is out of money" message Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dmccoystephenson
left a comment
There was a problem hiding this comment.
Self-review (no configured reviewer on this repo). Read the full diff back; 514 tests green, new modules at 100% coverage. Four notes on decisions a reader would reasonably question, none blocking.
|
|
||
| if input == "1": | ||
| choice = int(input) | ||
| action = actions[choice - 1] |
There was a problem hiding this comment.
This is the riskiest change in the PR — the docks menu no longer dispatches on hardcoded input == "7" strings. It had to change: with two independently-conditional entries, "8" means the crew when only the crew entry is present and the export menu when only that one is, and the old elif input == "8" and self.player.hiredWorkers would silently do nothing in the second case. test_run_menu_positions_hold_when_both_extras_are_present and test_run_crew_action_still_fires_without_the_export_entry pin both arrangements. The fixed seven keep their original numbers, which is why every pre-existing docks test still passes untouched.
| return summary | ||
|
|
||
| cargo = buildCargo(player) | ||
| player.spendMoney(market["shippingCost"]) |
There was a problem hiding this comment.
Freight is deducted here, before the sale, rather than being netted out of the proceeds — deliberate, and not just stylistic. schemas/player.json requires money >= 0, so a losing run that subtracted the fee from the payout could drive the balance negative and produce a player that fails schema validation on the next save. Charging up front means the canAfford guard above is the only place that can block a run, and test_runExport_never_puts_the_player_into_debt covers the worst case the menu allows (one Minnow against Thornhaven's $900).
| # Legacy untyped fish, priced at the middle of the old $3-5 range. | ||
| midpoint = 4.0 | ||
| else: | ||
| midpoint = (fishType["minValue"] + fishType["maxValue"]) / 2 |
There was a problem hiding this comment.
The estimate shown in the menu uses each species' midpoint while the actual sale rolls fishValue per fish, so the two differ by a few percent on a real load (measured ~$6280 estimated vs ~$6230 actual on a 600-fish hold). That's intended — the alternative is either rolling the prices early and holding them, or showing a range — but it's why the option text says "about $X clear" rather than quoting a figure the player could hold us to.
| else: | ||
| # Legacy save with only an aggregate count (no species breakdown). | ||
| queue = [None] * self.player.fishCount | ||
| queue = fish.bestFirst(self.player.fishByType, self.player.fishCount) |
There was a problem hiding this comment.
This replaces ten lines of inline queue-building that were byte-for-byte the logic export needs, so it moved to fish.bestFirst. Worth flagging because it touches the existing sale path rather than only adding to it: the behaviour is identical (same sort key, same legacy [None] * fishCount fallback), and the shop's own budget tests plus the new tests/fish/test_fish.py cases cover it from both sides.
Summary
Gilbert's shop only spends
SHOP_DAILY_BUDGET($750) a day. A maxed-out Fishing Fleet crew lands ~120 fish a day, worth roughly $760 at average species values — so a full crew saturates the village's only buyer exactly, and everything the player catches themselves on top of that just piles up unsellable. This adds a second sales channel gated behind the boat ladder.Three markets, each trading a bigger premium for a bigger freight bill:
Because freight is flat and the premium is proportional, the best market depends on the size of the load — the break-evens land at roughly 120 fish (Saltmarsh → Kestrel Cove) and 205 fish (Kestrel Cove → Thornhaven), so all three have a real niche rather than one dominating. The menu shows what the current hold would fetch at each before the player commits.
What limits a run (since the markets themselves have no daily budget):
exportCapacityis new onBOAT_TIERS: a Rowboat is 0 and can't cross at all, a Trawler carries 250 per run, a Fishing Fleet 600. The best fish load first; the rest wait for the next run.moneyhas a schema minimum of 0, so deducting from proceeds could produce an unsaveable player. A run the player can't fund is refused with a message naming the cost, the shortfall, and what to do instead.increaseDay, so exporting isn't a free repeatable action — the crew fish, wages come due and rent falls while the player is away (eviction is reported in the trip summary, same as after a night at the tavern).Per-day, this works out at roughly 1.7× the shop's ceiling for a Fishing Fleet owner once accumulation time is accounted for — a real answer to the bottleneck without trivialising the $10,000 goal, which a $6,000 boat already puts the player most of the way toward.
Supporting changes
fish.bestFirstandPlayer.removeFish, now shared by the shop's budget-limited sale and the export hold's capacity-limited one (the two had duplicated the same sort-and-decrement logic).manageBusinessalready uses in that file. With two conditional entries ("Talk to Your Crew" and the new export option), dispatching on hardcoded numbers would eventually fire the wrong branch; there's a regression test for exactly that case.totalFishExported,totalMoneyFromExports,totalShippingPaid) with matchingschemas/stats.jsonfields and two milestones (First Export, Coastal Trader).Test plan
python3 -m compileall -q src testsSDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy python3 -m pytest --verbose -vv --cov=src --cov-report=term-missing --cov-report=xml:cov.xml— 514 passed.src/business/export.py,src/fish/fish.py,src/player/player.pyand both stats modules are at 100%;docks.py/shop.pyat 99%, with only pre-existingfish()branches uncovered.black+autoflakeover the changed files only.showOptionsandcurrentPrompt, which all three front-ends already implement — no new primitive, no front-end-specific path. Legacy saves are covered too: an untyped hold (nofishByType) ships and is priced at the original flat range.Docs
README.mdgains an "Exporting to Other Villages" section; the Selling Fish and Milestones sections were updated to match.PLANNING.mdneeded no change.