Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
11 KiB
happyplanting: fixing Continuous Delivery lock-in
Status: plan only. Nothing in this file is implemented. Written 2026-10-17 from a read of the code, not from a run of the game.
Problem (verified in code)
Continuous Delivery (autoreplant Mastery upgrade) replants the crop that was just harvested, immediately, whenever it is affordable.
store/actions.py::harvestand_auto_harvestcall_try_autoreplantafter crediting. The harvest pays before the replant is checked, so "affordable" is true in practice and the plot is never empty.plant()refuses any plot that already holds a crop. There is no uproot, swap or cancel.upgrade_masteryis one-way: no refund, no toggle. Mastery survives Refactor._try_autoreplantchecks cost only. It ignores market saturation and crop locks.- Only exits today: Refactor (clears plots, costs a prestige-scaled fee) or running below the planting cost (rare).
Goals
- A per-plot "stop after this harvest" toggle, so the player gets one free crop choice on that plot.
- Do not replant into a bad market.
Part A: per-plot "stop after harvest" (ship this)
Design
New column game_plots.stop_after_harvest (int, default 0, always read with COALESCE). Added to the existing plot column ensure-block in database/schema.py (next to raided_fraction, ~line 1508) with example value 0.
Semantics: one-shot, per plot. While set, the next harvest of that plot does not replant. The plot ends empty and the player plants whatever they want.
Why this is the race-free shape
The flag is read by _try_autoreplant from its own fresh _plot_at(...) re-read, which already happens AFTER clear_plot wins. Rules that make that safe:
- The toggle endpoint is one conditional UPDATE:
SET stop_after_harvest = :v WHERE crop_key != '' AND crop_key IS NOT NULL, checked viarowcount(conditional_update_row). It cannot touch an already-cleared plot. - So the toggle either lands before
clear_plot(the post-clear re-read sees it, no replant) or after (the WHERE fails, loud "plot is empty" error). No window where the harvest uses a stale flag. clear_plotis NOT changed. A leftover1on an empty plot is harmless because every path that puts a crop on a plot writesstop_after_harvest = 0in the same claim UPDATE:plant()and_try_autoreplant(both already useconditional_update_rowwith a SET string), and theprestige()plot reset.- Never reset the flag inside
clear_plot: that would erase it before_try_autoreplantcan read it.
Changes (fan-out, all four faces)
| Layer | File | Change |
|---|---|---|
| Schema | database/schema.py |
ensure game_plots.stop_after_harvest |
| Data | services/game/store/actions.py |
_try_autoreplant: return early if COALESCE(plot.stop_after_harvest, 0); add stop_after_harvest = 0 to the claim SET in plant() and _try_autoreplant; add the column to the prestige() plot reset |
| Data | services/game/store/ (new fn in actions.py) |
set_plot_stop(user, slot, stop: bool): owner only, requires mastery_autoreplant level > 0, requires a crop on the plot, GameError otherwise |
| Form | models.py |
GamePlotStopForm(slot: int ge=0 le=64, stop: bool), modeled on GameSlotForm |
| Route | routers/game/index.py |
POST /game/plot/stop, require_user, via _respond_action, audit game.plot.stop |
| Audit | services/audit/events.md + category_for |
add game.plot.stop (same category as game.plant) |
| Schema out | schemas/game.py |
GamePlotOut.stop_after_harvest: bool = False (owner only, default safe); serialize_plot sets it |
| View | templates/_game_grid.html and static/js/GameFarm.js::_plotHtml |
a small toggle button on growing/ready plots, shown only when the viewer is the owner and owns Continuous Delivery; label "Stop after harvest" / "Resume auto-replant". Server render and JS mirror must match (the file already warns about flash on divergence). Reuse the existing data-game-action form delegation, no new JS class |
| Agent | services/devii/actions/catalog/game.py |
game_plot_stop Action (requires_auth=True, params slot, stop). Not confirm-gated: reversible and costs nothing |
| API docs | docs_api/groups/game.py |
endpoint() entry with sample_response |
| Prose docs | templates/docs/code-farm.html |
Continuous Delivery row: document the toggle and that Mastery has no refund |
| Notes | services/game/CLAUDE.md, README.md |
document the flag and the "plant/replant claim resets it" invariant |
Gaps found in the second review (all must be in the change)
prestige()resets plots with an explicit dict in_plots().update({...})(actions.py ~611). It does not touch unknown columns, so the dict must add"stop_after_harvest": 0or a flag survives a Refactor.- A stale
1on an EMPTY plot (left after a no-replant harvest) must never be shown:serialize_plotgatesstop_after_harvestoncrop_keypresent AND viewer is owner ANDmastery_autoreplantowned. - Writers that put a crop on a plot are exactly two:
plant()and_try_autoreplant(bothconditional_update_rowclaims). Both SETs getstop_after_harvest = 0._create_plotinserts need no change (column default via ensure-block, read with COALESCE). - Existing tests that enumerate things and will fail until updated:
tests/unit/services/devii/actions/catalog.py(game action name list),locustfile.py(route coverage, the locust-maintainer agent owns it, never run a load test),events.md(audit catalogue),README.mdtool list (line ~223) and thecode-farm.htmlendpoint table (~line 433). - The toggle button is a new element in a dense plot card: needs CSS tokens only (no literals), must fit the smallest phone width, and needs a
screenshotskill visual check in both server-rendered and JS-rendered forms. notify_farmalready runs in_respond_action, so the live refresh needs no extra wiring.- Interactions checked and unaffected: steal (never clears a plot), water, fertilize, CI Bot
_auto_harvest(flag honoured the same way, via the post-clear re-read), Era/leaderboards, treasury/grant.
Not in scope for Part A
No farm-wide off switch and no refund of Mastery points. The per-plot toggle is the requested fix.
Part B: do not replant into a bad market (REVISED after second review: do not build the original)
Measurement
Computed with the real economy.realizable_harvest_coins at the saturation floor (market_factor 0.4), payout minus planting cost:
| Prestige | shell | api | kernel | distsys | mlpipe | secfort |
|---|---|---|---|---|---|---|
| 0 | -1 | 0 | +240 | -480 | -3600 | -10800 |
| 50 | +54 | +1120 | +18240 | +56020 | +101400 | +229200 |
| 60 | +65 | +1344 | +21840 | +67320 | +122400 | +277200 |
Continuous Delivery needs prestige 50 (MASTERY_UNLOCK_PRESTIGE), where prestige_multiplier is 13.5. At that point even a fully saturated crop pays 11x or more its planting cost, for every crop in the catalog. Consequences:
- The "profit floor" (skip when payout <= cost) can never fire for a real owner. It is dead code. Drop it.
- A plain "skip when saturated" always makes the owner poorer: the idle plot earns 0, and a skipped replant also forfeits that cycle's XP, quest progress, weekly harvest count (grant eligibility,
harvestsandfair_playboards) and the market tick. Idle is never better than planting a saturated crop. - So the original Part B would be collateral damage, not a fix.
What to do instead
- Ship Part A only. It already solves the stated problem: one free crop choice per plot, whenever the player wants it, with zero economic downside.
- Optional, only if you still want saturation handling: an opt-in farm setting "when saturated, replant the best unsaturated unlocked crop instead". It never idles a plot. It needs a new pure function in
economy.pythat picks the crop (must define "best", for example highest realizable coins per second, among crops passingcrop_lock_reason), used by both the serializer preview and_try_autoreplant(one-quantity-one-function rule). It changes which crop grows and its grow time, so it needs its own design pass and the Layer 1/2 property sweeps. Not recommended to bundle with Part A.
Risk list
- Behavior change on deploy: Part A defaults to 0 for every existing plot, so nothing changes until a player clicks. There is no Part B behavior change (see revision above).
- Missing column on first plant: the claim SET names
stop_after_harvest, so a DB whereinit_dbhas not added the column would fail every plant.init_dbruns before serving, and tests start from a fresh DB through it; verify with the layer-3 setup that uses a genuinely fresh, un-pre-seeded farm. - NULL trap: pre-existing plot rows will have
stop_after_harvestNULL. Every read and every WHERE usesCOALESCE(stop_after_harvest, 0). - Prestige reset: forget the column there and a stale
1survives a Refactor. Covered by the claim-SET rule, but the reset dict must also write 0. - Visitors: the flag must never be exposed to non-owners (
serialize_plotgates it onviewer == owner). - JSON vs template: a context key not on
GamePlotOutis silently dropped from JSON. Add the field to the schema first.
Verification plan
Persisted tests (matching tiers; the suite is run by the orchestrating session, serial):
tests/api/game/mutations.py: toggle on, harvest, plot stays empty; toggle off, harvest, plot replanted; toggle on an empty plot is a 400; toggle without Continuous Delivery is a 400; a visitor cannot toggle;plantafter a stop resets the flag; Refactor resets it.tests/api/game/farm.py: visitor view of an owner's plots never showsstop_after_harvest.tests/e2e/game/index.py: the toggle button renders and flips on click; server-rendered and JS-rendered markup agree.
Disposable scripts (scratch DB via DEVPLACE_DATABASE_URL and DEVPLACE_DATA_DIR, never production):
- Layer 3 race: real OS processes firing toggle against harvest on the same plot, from a genuinely fresh farm (flag NULL, not pre-seeded to 0). Assert the replant happens exactly when the toggle landed after
clear_plot, never when it landed before. - Layer 4:
pyflakes/ruff checkon every touched file.
Then the standard gates: clean from devplacepy.main import app import, em-dash grep on touched files, make test (all tiers) green, commit, and verify live on 127.0.0.1:10500.
Residual risk after the second review
- Part A is a small, additive change with one new column, one new route and one guarded early return. I found no economic, concurrency or permission collateral in it. "No collateral damage" is a claim about the plan, not a measurement: it is proven only by the race script and the full suite above, neither of which has been run.
- One accepted behavior: toggling "stop" on a plot that harvests in the same instant either stops the replant or returns "plot is empty". Both outcomes are correct and visible.
Decisions needed from you
- Part A: one-shot (recommended, flag cleared when the plot is next planted) or sticky until turned off?
- Part B: drop it (recommended), or commission the "replant best unsaturated crop" option as a separate piece of work?
- Fold in the crop-lock check fix in
_try_autoreplant(docs promise it, code lacks it)?