forked from retoor/devplacepy
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>
125 lines
11 KiB
Markdown
125 lines
11 KiB
Markdown
# 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::harvest` and `_auto_harvest` call `_try_autoreplant` after 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_mastery` is one-way: no refund, no toggle. Mastery survives Refactor.
|
|
- `_try_autoreplant` checks 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
|
|
|
|
1. A per-plot "stop after this harvest" toggle, so the player gets one free crop choice on that plot.
|
|
2. 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 via `rowcount` (`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_plot` is NOT changed. A leftover `1` on an empty plot is harmless because every path that puts a crop on a plot writes `stop_after_harvest = 0` in the same claim UPDATE: `plant()` and `_try_autoreplant` (both already use `conditional_update_row` with a SET string), and the `prestige()` plot reset.
|
|
- Never reset the flag inside `clear_plot`: that would erase it before `_try_autoreplant` can 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": 0` or a flag survives a Refactor.
|
|
- A stale `1` on an EMPTY plot (left after a no-replant harvest) must never be shown: `serialize_plot` gates `stop_after_harvest` on `crop_key` present AND viewer is owner AND `mastery_autoreplant` owned.
|
|
- Writers that put a crop on a plot are exactly two: `plant()` and `_try_autoreplant` (both `conditional_update_row` claims). Both SETs get `stop_after_harvest = 0`. `_create_plot` inserts 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.md` tool list (line ~223) and the `code-farm.html` endpoint 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 `screenshot` skill visual check in both server-rendered and JS-rendered forms.
|
|
- `notify_farm` already 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, `harvests` and `fair_play` boards) 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
|
|
|
|
1. 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.
|
|
2. 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.py` that picks the crop (must define "best", for example highest realizable coins per second, among crops passing `crop_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 where `init_db` has not added the column would fail every plant. `init_db` runs 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_harvest` NULL. Every read and every WHERE uses `COALESCE(stop_after_harvest, 0)`.
|
|
- **Prestige reset:** forget the column there and a stale `1` survives 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_plot` gates it on `viewer == owner`).
|
|
- **JSON vs template:** a context key not on `GamePlotOut` is 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; `plant` after a stop resets the flag; Refactor resets it.
|
|
- `tests/api/game/farm.py`: visitor view of an owner's plots never shows `stop_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 check` on 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
|
|
|
|
1. Part A: one-shot (recommended, flag cleared when the plot is next planted) or sticky until turned off?
|
|
2. Part B: drop it (recommended), or commission the "replant best unsaturated crop" option as a separate piece of work?
|
|
3. Fold in the crop-lock check fix in `_try_autoreplant` (docs promise it, code lacks it)?
|