Skip to content

Export fish to other villages with a Trawler or better - #138

Merged
dmccoystephenson merged 1 commit into
mainfrom
feature/export-to-other-villages
Aug 1, 2026
Merged

Export fish to other villages with a Trawler or better#138
dmccoystephenson merged 1 commit into
mainfrom
feature/export-to-other-villages

Conversation

@dmccoystephenson

Copy link
Copy Markdown
Member

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:

Market Boat Price Freight
Saltmarsh Trawler ×1.2 $25
Kestrel Cove Trawler ×1.5 $250
Thornhaven Fishing Fleet ×2.0 $900

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):

  • The hold. exportCapacity is new on BOAT_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.
  • The freight, charged up front rather than netted out of the proceeds — money has 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.
  • A day. The round trip calls 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

  • Extracted fish.bestFirst and Player.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).
  • Rebuilt the docks menu as parallel options/actions lists — the same pattern manageBusiness already 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.
  • New lifetime stats (totalFishExported, totalMoneyFromExports, totalShippingPaid) with matching schemas/stats.json fields and two milestones (First Export, Coastal Trader).
  • Gilbert gets a question — unlocked once the player's boat can actually reach the markets — explaining his daily budget and pointing outward, and the "shop is out of money" message now suggests shipping the rest rather than only waiting a day.

Test plan

  • python3 -m compileall -q src tests
  • SDL_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.py and both stats modules are at 100%; docks.py/shop.py at 99%, with only pre-existing fish() branches uncovered.
  • black + autoflake over the changed files only.
  • Front-end parity. Everything new goes through showOptions and currentPrompt, which all three front-ends already implement — no new primitive, no front-end-specific path. Legacy saves are covered too: an untyped hold (no fishByType) ships and is priced at the original flat range.
  • Played the flow through the real console front-end: a full Fishing Fleet run, a small load reading as a loss, an unaffordable-freight refusal, and an empty hold.

Docs

README.md gains an "Exporting to Other Villages" section; the Selling Fish and Milestones sections were updated to match. PLANNING.md needed no change.

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 dmccoystephenson left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/location/docks.py

if input == "1":
choice = int(input)
action = actions[choice - 1]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/business/export.py
return summary

cargo = buildCargo(player)
player.spendMoney(market["shippingCost"])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread src/business/export.py
# Legacy untyped fish, priced at the middle of the old $3-5 range.
midpoint = 4.0
else:
midpoint = (fishType["minValue"] + fishType["maxValue"]) / 2

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/location/shop.py
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@dmccoystephenson
dmccoystephenson merged commit b93655e into main Aug 1, 2026
1 check passed
@dmccoystephenson
dmccoystephenson deleted the feature/export-to-other-villages branch August 1, 2026 07:42
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.

1 participant