- update_seed_lot / delete_seed_lot: correct or drop a recorded purchase
("it was three packets, not two"); the plant a lot is for stays fixed.
- delete_plant: remove a duplicate from the user's catalog. The service
already refuses while plantings (past seasons included) or a lot reference
it; the tool turns that sentinel into words the model can pass on, and
tells it not to clear those references to get its way.
- create_garden: a new place, with the service's defaults; the prompt says a
plan is still a copy_garden.
- describe_garden groups carry readyAround — planting date plus days to
maturity for the plops still in the ground — so "what can I pick this
week?" is a lookup rather than arithmetic the model got wrong live.
Co-Authored-By: Claude Fable 5 <[email protected]>
940 lines
37 KiB
Go
940 lines
37 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
// This file holds pansy's bulk, "natural-language-shaped" operations — the ones
|
||
// an agent drives ("fill the NE corner with garlic", "clear the bed"). They live
|
||
// on *Service like every other operation, so agent tools (internal/agent) and any
|
||
// future REST surface inherit the same ACL enforcement via objectForRole /
|
||
// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the
|
||
// object's center, +x east, +y south, so -y is NORTH).
|
||
|
||
// maxFillPlops bounds a single FillRegion so a huge bed with tiny spacing can't
|
||
// generate a runaway number of inserts. A bed with thousands of plops is already
|
||
// far past any real garden; over the cap we refuse rather than silently truncate.
|
||
const maxFillPlops = 5000
|
||
|
||
// Region is an axis-aligned rectangle in an object's local frame. (Circle/polygon
|
||
// regions are post-v1, like polygon objects; NamedRegion produces only rects.)
|
||
type Region struct {
|
||
MinX, MinY, MaxX, MaxY float64
|
||
}
|
||
|
||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||
// so a fill can't plant outside the object it was aimed at. A region that misses
|
||
// the object entirely comes back empty — see empty().
|
||
func (r Region) clampTo(halfW, halfH float64) Region {
|
||
return Region{
|
||
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
||
MaxX: math.Min(r.MaxX, halfW), MaxY: math.Min(r.MaxY, halfH),
|
||
}
|
||
}
|
||
|
||
// empty reports whether the region encloses nothing.
|
||
//
|
||
// This exists because clampTo expresses "no overlap" by INVERTING the region —
|
||
// Max clamps below Min — rather than by zeroing it, which is not something a
|
||
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
|
||
// that has to care.
|
||
func (r Region) empty() bool {
|
||
return r.MaxX < r.MinX || r.MaxY < r.MinY
|
||
}
|
||
|
||
// rect builds a rectangular region.
|
||
func rect(minX, minY, maxX, maxY float64) Region {
|
||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||
}
|
||
|
||
// NamedRegion resolves a compass name to a Region in the object's local frame.
|
||
// Recognizes the quarter corners "nw|ne|sw|se", the halves
|
||
// "north|south|east|west" and their "top|bottom|left|right" synonyms, and "all".
|
||
// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North
|
||
// is -y (see the file header). Unknown names return ErrInvalidInput.
|
||
func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
|
||
if o == nil {
|
||
return Region{}, domain.ErrInvalidInput
|
||
}
|
||
hw, hh := o.WidthCM/2, o.HeightCM/2
|
||
key := strings.ToLower(strings.TrimSpace(name))
|
||
key = strings.TrimSpace(strings.TrimSuffix(key, "corner"))
|
||
key = strings.TrimSpace(strings.TrimSuffix(key, "half"))
|
||
|
||
switch key {
|
||
case "all":
|
||
return rect(-hw, -hh, hw, hh), nil
|
||
case "north", "top":
|
||
return rect(-hw, -hh, hw, 0), nil
|
||
case "south", "bottom":
|
||
return rect(-hw, 0, hw, hh), nil
|
||
case "east", "right":
|
||
return rect(0, -hh, hw, hh), nil
|
||
case "west", "left":
|
||
return rect(-hw, -hh, 0, hh), nil
|
||
case "nw", "northwest":
|
||
return rect(-hw, -hh, 0, 0), nil
|
||
case "ne", "northeast":
|
||
return rect(0, -hh, hw, 0), nil
|
||
case "sw", "southwest":
|
||
return rect(-hw, 0, 0, hh), nil
|
||
case "se", "southeast":
|
||
return rect(0, 0, hw, hh), nil
|
||
default:
|
||
return Region{}, domain.ErrInvalidInput
|
||
}
|
||
}
|
||
|
||
// defaultPlopRadius is the radius a freshly-placed plop gets from its plant's
|
||
// spacing: max(1.5×spacing, 15cm) — matching the editor's placement default (#15).
|
||
func defaultPlopRadius(spacingCM float64) float64 {
|
||
return math.Max(1.5*spacingCM, 15)
|
||
}
|
||
|
||
// FillLayout selects what a fill packs (#77).
|
||
//
|
||
// A plop is a CLUMP, not a plant, and that abstraction is the right primitive for
|
||
// SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real
|
||
// planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So
|
||
// filling is now two operations. FillClump (the default, unchanged) drops fat
|
||
// clumps for quick coverage; FillGrid lays out individual plants at true spacing,
|
||
// producing a layout you could actually plant from.
|
||
type FillLayout string
|
||
|
||
const (
|
||
// FillClump packs fat clumps (radius 1.5×spacing). Each plop is ~7 plants.
|
||
FillClump FillLayout = "clump"
|
||
// FillGrid packs one plant per plop at true spacing (radius spacing/2, pitch
|
||
// = spacing). A bed becomes rows of individual plants.
|
||
FillGrid FillLayout = "grid"
|
||
)
|
||
|
||
// plopRadiusFor is the plop radius a fill uses, given the plant's spacing and the
|
||
// layout. Grid mode is a plain spacing/2 (so the pitch is one spacing and each
|
||
// plop's derived count is 1); clump mode keeps the 15cm floor that stops a
|
||
// tiny-spacing plant from making invisibly small clumps — a floor grid mode
|
||
// doesn't want, since its whole point is true spacing.
|
||
func plopRadiusFor(spacingCM float64, layout FillLayout) float64 {
|
||
if layout == FillGrid {
|
||
return spacingCM / 2
|
||
}
|
||
return defaultPlopRadius(spacingCM)
|
||
}
|
||
|
||
// edgeInset is how far a plop's CENTRE must stay inside the region edge. It
|
||
// differs by layout because the half-spacing rule is about where the PLANT lands,
|
||
// and the plant sits in a different place within the plop.
|
||
//
|
||
// Spacing is a constraint between neighbouring plants competing for the same soil,
|
||
// light and water; a bed edge is nobody's neighbour, so the outer plant owes it
|
||
// only HALF the spacing — the half it would otherwise share. That is the
|
||
// square-foot-chart arithmetic: garlic at 9-per-square sits 2" from the frame, not
|
||
// 6".
|
||
//
|
||
// - Grid: one plant, at the plop's centre. Put that centre a half-spacing in and
|
||
// the outer row lands exactly where the rule wants it — inset = spacing/2.
|
||
// - Clump: a fat plop (radius 1.5×spacing) whose plants fill out to its RIM.
|
||
// Insetting the whole circle would push the outer row a full 1.5 spacings in,
|
||
// three times the rule. Instead the clump may hang over by a half-spacing (rim
|
||
// at spacing/2 past the edge), landing its outermost plants that same
|
||
// half-spacing in — inset = radius − spacing/2. A grid plop reusing THAT
|
||
// formula would inset by radius − spacing/2 = 0 and plant flush on the edge,
|
||
// which is the bug this split fixes.
|
||
func edgeInset(radius, spacing float64, layout FillLayout) float64 {
|
||
half := math.Max(0, spacing) / 2
|
||
if layout == FillGrid {
|
||
return half
|
||
}
|
||
return math.Max(0, radius-half)
|
||
}
|
||
|
||
// validFillLayout normalizes a layout: empty defaults to clump (so existing
|
||
// callers are unchanged), a known value passes, anything else is rejected.
|
||
func validFillLayout(l FillLayout) (FillLayout, bool) {
|
||
switch l {
|
||
case "", FillClump:
|
||
return FillClump, true
|
||
case FillGrid:
|
||
return FillGrid, true
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
// FillRegion lays a field of plops of one plant across a region of a plantable
|
||
// object the actor can edit. The layout picks the primitive: FillClump drops fat
|
||
// clumps for quick sketching, FillGrid lays out individual plants at true spacing
|
||
// (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride)
|
||
// via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set
|
||
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
|
||
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its
|
||
// plop would sit entirely inside an existing active plop (so re-filling doesn't
|
||
// stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today
|
||
// when nil — the UI always sends its local day, so the default is for API and
|
||
// agent callers. Returns the plops it created.
|
||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||
return s.Fill(ctx, actorID, objectID, FillSpec{
|
||
Region: region, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
|
||
})
|
||
}
|
||
|
||
// FillSpec is everything a fill needs besides the object it fills: where (a
|
||
// compass RegionName, or an explicit Region in the object's local frame when the
|
||
// name is empty), what, and how.
|
||
type FillSpec struct {
|
||
// RegionName is a compass name for NamedRegion ("ne", "south half", "all").
|
||
// When it is empty, Region is used as given.
|
||
RegionName string
|
||
Region Region
|
||
PlantID int64
|
||
// SpacingOverride replaces the plant's own spacing for this fill, in cm.
|
||
SpacingOverride *float64
|
||
// Layout is clump (the default) or grid; see FillLayout.
|
||
Layout FillLayout
|
||
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). nil means the
|
||
// service's UTC today; a caller that knows the person's local day sends it.
|
||
PlantedAt *string
|
||
// SeedLotID attributes every plop to one of the actor's seed lots, so the lot
|
||
// can report what it has left. Optional.
|
||
SeedLotID *int64
|
||
}
|
||
|
||
// Fill plants one plant across part of an object the actor can edit, per spec.
|
||
// FillRegion and FillNamedRegion are the two older spellings of it.
|
||
func (s *Service) Fill(ctx context.Context, actorID, objectID int64, spec FillSpec) ([]domain.Planting, error) {
|
||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
region := spec.Region
|
||
if strings.TrimSpace(spec.RegionName) != "" {
|
||
if region, err = NamedRegion(o, spec.RegionName); err != nil {
|
||
return nil, err
|
||
}
|
||
} else if !(region.MinX < region.MaxX && region.MinY < region.MaxY) {
|
||
// A zero or inverted rectangle is a caller that said nothing about where
|
||
// — not a request for the one plop hexCenters would put at its middle.
|
||
return nil, fmt.Errorf("%w: the fill rectangle is empty", domain.ErrInvalidInput)
|
||
}
|
||
return s.fillLoaded(ctx, actorID, o, region, spec)
|
||
}
|
||
|
||
// fillLoaded is the body of Fill given an object already loaded and authorized
|
||
// (roleEditor) and its region resolved. It validates the layout, rejects a
|
||
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
||
// maxFillPlops, and inserts the whole batch in one transaction rather than one
|
||
// round-trip per plop.
|
||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, spec FillSpec) ([]domain.Planting, error) {
|
||
if !o.Plantable {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
if !validDatePtr(spec.PlantedAt) {
|
||
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||
}
|
||
layout, ok := validFillLayout(spec.Layout)
|
||
if !ok {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
plant, err := s.visiblePlant(ctx, actorID, spec.PlantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Checked before anything is planted, as CreatePlanting does: a lot of the
|
||
// wrong variety, or someone else's, refuses the whole fill.
|
||
if err := s.checkSeedLotForPlanting(ctx, actorID, spec.SeedLotID, spec.PlantID); err != nil {
|
||
return nil, err
|
||
}
|
||
spacing := plant.SpacingCM
|
||
if spec.SpacingOverride != nil {
|
||
if !isFinite(*spec.SpacingOverride) || *spec.SpacingOverride < minPlantSpacingCM || *spec.SpacingOverride > maxPlantSpacingCM {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
spacing = *spec.SpacingOverride
|
||
}
|
||
radius := plopRadiusFor(spacing, layout)
|
||
if !isFinite(radius) || radius <= 0 {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
|
||
// A caller-supplied region is arbitrary floats, and non-finite ones survive
|
||
// everything downstream: clamping keeps them, the inverted-region guard can't
|
||
// see NaN (it compares false both ways), and fitAxis centres on them happily.
|
||
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
|
||
// NULL constraint refuses it — but the caller gets an opaque store error for
|
||
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
|
||
// wrong; say "bad input" here instead.
|
||
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
|
||
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
|
||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||
if region.MaxX <= region.MinX || region.MaxY <= region.MinY {
|
||
// An explicit rectangle that misses the object, or only touches its edge.
|
||
// Planting nothing and reporting success would read as "done" to a caller
|
||
// that aimed at the wrong coordinates (typically the agent mixing up the
|
||
// garden frame and the object's local one) — and a rectangle clamped to a
|
||
// line would get hexCenters' one-plop-in-the-middle rule, on the edge.
|
||
return nil, fmt.Errorf("%w: the region lies outside the object", domain.ErrInvalidInput)
|
||
}
|
||
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
|
||
if total > maxFillPlops {
|
||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||
}
|
||
|
||
existing, err := s.store.ListActivePlantingsForObject(ctx, o.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
plantedOn := s.now().UTC().Format(dateLayout)
|
||
if spec.PlantedAt != nil {
|
||
plantedOn = *spec.PlantedAt
|
||
}
|
||
batch := make([]*domain.Planting, 0, len(centers))
|
||
// Only the plops that were ALREADY here can cover a candidate: every plop this
|
||
// fill makes shares one radius and sits on a distinct lattice point, and a plop
|
||
// is "covered" only when it lies entirely inside another — impossible between
|
||
// two equal-radius circles at different centres. So skip against `existing` as
|
||
// loaded and don't grow it per plop, which made an empty-bed grid fill's check
|
||
// needlessly quadratic.
|
||
for _, c := range centers {
|
||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||
continue
|
||
}
|
||
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: spec.PlantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn, SeedLotID: spec.SeedLotID})
|
||
}
|
||
created, err := s.store.CreatePlantings(ctx, batch)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// One record call with every plop, so a fill auto-scopes into ONE change set
|
||
// with N revisions — undoing a fill is one click, not N.
|
||
changes := make([]change, 0, len(created))
|
||
for i := range created {
|
||
changes = append(changes, changeCreate(domain.EntityPlanting, created[i].ID, &created[i]))
|
||
}
|
||
s.record(ctx, o.GardenID, actorID,
|
||
fmt.Sprintf("Planted %d %s in %s", len(created), plant.Name, objectLabel(o)), changes...)
|
||
for i := range created {
|
||
created[i].DerivedCount = derivedCount(created[i].RadiusCM, spacing)
|
||
}
|
||
return created, nil
|
||
}
|
||
|
||
type localPoint struct{ x, y float64 }
|
||
|
||
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
|
||
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
|
||
// is CENTERED, so the leftover is shared between opposite edges instead of piling
|
||
// up against the far one.
|
||
//
|
||
// # How close to the edge the outer row goes
|
||
//
|
||
// The caller passes `inset`: the margin the outer row keeps from every edge. It
|
||
// encodes the half-spacing rule (spacing is owed between neighbouring plants, and
|
||
// a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
|
||
// derives it. hexCenters just honours it on all four sides.
|
||
//
|
||
// Do not "simplify" the centering back to anchoring at the region's min corner.
|
||
// That is what #75 was: staggered rows start a full pitch in, and the leftover all
|
||
// lands on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||
//
|
||
// # Counting before building
|
||
//
|
||
// hexCenters returns the total alongside the points, and works that total out
|
||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||
// returns (nil, total), so the caller can still refuse with the real number.
|
||
func hexCenters(r Region, radius, inset float64, limit int) ([]localPoint, int) {
|
||
if radius <= 0 {
|
||
return nil, 0
|
||
}
|
||
// An empty region has no inside to plant. The old loop-until-past-MaxX form
|
||
// got this for free by never entering the loop; counting positions up front
|
||
// does not, and would site a plop off the bed.
|
||
if r.empty() {
|
||
return nil, 0
|
||
}
|
||
pitch := 2 * radius
|
||
rowH := pitch * math.Sqrt(3) / 2
|
||
|
||
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||
|
||
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
|
||
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
|
||
// are trying to avoid when the answer is "too many".
|
||
staggered := cols
|
||
if cols > 1 {
|
||
staggered = cols - 1
|
||
}
|
||
total := (rows+1)/2*cols + rows/2*staggered
|
||
if total > limit {
|
||
return nil, total
|
||
}
|
||
|
||
pts := make([]localPoint, 0, total)
|
||
for row := 0; row < rows; row++ {
|
||
y := r.MinY + y0 + float64(row)*rowH
|
||
n, x := cols, r.MinX+x0
|
||
// The stagger falls out of centering: an offset row holds one fewer plop,
|
||
// and centering THAT run puts it exactly half a pitch off its neighbours.
|
||
// A single-column region has nothing to stagger against.
|
||
if row%2 == 1 && cols > 1 {
|
||
n, x = staggered, r.MinX+x0+pitch/2
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
pts = append(pts, localPoint{x + float64(i)*pitch, y})
|
||
}
|
||
}
|
||
return pts, total
|
||
}
|
||
|
||
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
|
||
// at least `inset` from each end, and the offset from the span's start that
|
||
// centers them — so the leftover is split between the two edges rather than all
|
||
// landing on the far one.
|
||
//
|
||
// A span too small to hold even one position at that inset still gets one, in the
|
||
// middle: filling a bed narrower than a single plop with one plop is a better
|
||
// answer than refusing to plant it.
|
||
//
|
||
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
|
||
// caller, returns early unless radius > 0, which makes both steps it passes
|
||
// positive. It stays because dividing by a non-positive step yields ±Inf and then
|
||
// a garbage int conversion, and a helper this small should not require reading
|
||
// its caller to know it is safe. Deliberate, not an oversight.
|
||
func fitAxis(length, step, inset float64) (n int, start float64) {
|
||
if step <= 0 || length < 2*inset {
|
||
return 1, length / 2
|
||
}
|
||
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
|
||
// span at a 30cm step should give 2 positions, not 1 because the division
|
||
// landed on 0.9999999.
|
||
const eps = 1e-9
|
||
n = int(math.Floor((length-2*inset)/step+eps)) + 1
|
||
return n, (length - float64(n-1)*step) / 2
|
||
}
|
||
|
||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||
// entirely inside some existing active plop.
|
||
func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
||
for _, e := range existing {
|
||
if math.Hypot(x-e.XCM, y-e.YCM)+radius <= e.RadiusCM {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||
// hold the object's geometry. It resolves the name against the object, then fills.
|
||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||
if strings.TrimSpace(regionName) == "" {
|
||
// Fill would read a blank name as "use the (zero) Region" and plant
|
||
// nothing; here a blank name is the caller's mistake, as it always was.
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
return s.Fill(ctx, actorID, objectID, FillSpec{
|
||
RegionName: regionName, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
|
||
})
|
||
}
|
||
|
||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||
// UPDATE), returning how many were cleared. Distinct from deleting the object.
|
||
// Unlike FillRegion it does NOT require the object be plantable: an object toggled
|
||
// non-plantable after it was planted must still be clearable (you can always
|
||
// remove existing plops, only not add new ones).
|
||
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
|
||
return s.ClearPlantings(ctx, actorID, objectID, ClearOptions{})
|
||
}
|
||
|
||
// ClearOptions narrows ClearPlantings.
|
||
type ClearOptions struct {
|
||
// PlantID limits the clear to one plant — "pull the beets out, leave the
|
||
// garlic" — nil clears every plant.
|
||
PlantID *int64
|
||
// RemovedAt is the removal date (YYYY-MM-DD). nil means the service's UTC
|
||
// today; a caller that knows the person's local day sends it.
|
||
RemovedAt *string
|
||
}
|
||
|
||
// ClearPlantings is ClearObject with options: all of an object's active plops, or
|
||
// only one plant's. The whole clear is one change set either way.
|
||
func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, opts ClearOptions) (int, error) {
|
||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !validDatePtr(opts.RemovedAt) {
|
||
return 0, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||
}
|
||
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
|
||
// count — then clear exactly those ids. Clearing "every active plop" instead
|
||
// would let a plop created between this read and the UPDATE be removed with no
|
||
// revision recorded: cleared, with no way to undo it.
|
||
before, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
what := "" // names the plant in the summary when the clear is for one plant
|
||
if opts.PlantID != nil {
|
||
only := make([]domain.Planting, 0, len(before))
|
||
for i := range before {
|
||
if before[i].PlantID == *opts.PlantID {
|
||
only = append(only, before[i])
|
||
}
|
||
}
|
||
before = only
|
||
// The summary is read by a person, so name the plant, not its id. A plant
|
||
// that no longer exists just goes unnamed.
|
||
if plant, err := s.store.GetPlant(ctx, *opts.PlantID); err == nil {
|
||
what = plant.Name
|
||
} else if !errors.Is(err, domain.ErrNotFound) {
|
||
return 0, err
|
||
}
|
||
}
|
||
ids := make([]int64, 0, len(before))
|
||
for i := range before {
|
||
ids = append(ids, before[i].ID)
|
||
}
|
||
removedOn := s.now().UTC().Format(dateLayout)
|
||
if opts.RemovedAt != nil {
|
||
removedOn = *opts.RemovedAt
|
||
}
|
||
n, err := s.store.ClearObjectPlantings(ctx, objectID, removedOn, ids)
|
||
if err != nil || n == 0 {
|
||
return n, err
|
||
}
|
||
|
||
// From here the clear HAS happened. A failure to build the history entry must
|
||
// not be reported as a failed clear — the caller would retry an operation that
|
||
// already applied. Log the gap and report success, matching how record()
|
||
// treats its own write failures.
|
||
after, err := s.store.ListPlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
slog.Error("service: clear succeeded but history could not be recorded",
|
||
"error", err, "object", objectID, "cleared", n)
|
||
return n, nil
|
||
}
|
||
afterByID := make(map[int64]*domain.Planting, len(after))
|
||
for i := range after {
|
||
afterByID[after[i].ID] = &after[i]
|
||
}
|
||
changes := make([]change, 0, len(before))
|
||
for i := range before {
|
||
b := before[i]
|
||
a, ok := afterByID[b.ID]
|
||
if !ok {
|
||
continue // deleted outright between the two reads; nothing coherent to record
|
||
}
|
||
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
|
||
}
|
||
summary := fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n)
|
||
if opts.PlantID != nil {
|
||
if what == "" {
|
||
what = "plantings"
|
||
}
|
||
summary = fmt.Sprintf("Removed %s from %s (%d plantings)", what, objectLabel(o), n)
|
||
}
|
||
s.record(ctx, g.ID, actorID, summary, changes...)
|
||
return n, nil
|
||
}
|
||
|
||
// DescribeResult is a structured summary of a garden for prompting an agent.
|
||
// Version and Notes are here for update_garden: the version is its guard, and
|
||
// the notes are the whole text a new note has to be merged into.
|
||
type DescribeResult struct {
|
||
GardenID int64 `json:"gardenId"`
|
||
Name string `json:"name"`
|
||
WidthCM float64 `json:"widthCm"`
|
||
HeightCM float64 `json:"heightCm"`
|
||
UnitPref string `json:"unitPref"`
|
||
GridSizeCM float64 `json:"gridSizeCm"`
|
||
Notes string `json:"notes,omitempty"`
|
||
Version int64 `json:"version"`
|
||
// Year is set on a season view: the plantings are then every plop whose time
|
||
// in the ground overlapped that year, pulled ones included, rather than what
|
||
// is growing now.
|
||
Year *int `json:"year,omitempty"`
|
||
Objects []DescribeObject `json:"objects"`
|
||
}
|
||
|
||
// DescribeObject is one object plus its active plantings grouped by plant, for
|
||
// DescribeResult. Version is included so an agent can move/edit the object (the
|
||
// mutation guard).
|
||
type DescribeObject struct {
|
||
ID int64 `json:"id"`
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Shape string `json:"shape"`
|
||
WidthCM float64 `json:"widthCm"`
|
||
HeightCM float64 `json:"heightCm"`
|
||
XCM float64 `json:"xCm"`
|
||
YCM float64 `json:"yCm"`
|
||
RotationDeg float64 `json:"rotationDeg"`
|
||
Plantable bool `json:"plantable"`
|
||
Version int64 `json:"version"`
|
||
Plantings []DescribeGroup `json:"plantings"`
|
||
}
|
||
|
||
// maxListedPlops is the largest group DescribeGroup.Each spells out plop by plop.
|
||
// Up to it, a group is a handful of placements someone may address one at a time
|
||
// ("pull the basil out of the corner"). Past it — a grid-filled bed is hundreds —
|
||
// the ids are noise that costs a model more than it informs, and the group is
|
||
// addressed as a whole (ClearPlantings) or listed on demand (ListObjectPlantings).
|
||
// The live instance's first describe of a grid-filled garden was ~450 plop
|
||
// entries, on every turn.
|
||
const maxListedPlops = 8
|
||
|
||
// DescribeGroup summarizes every active plop of one plant in an object — the
|
||
// unit a person talks about ("the cucumbers in the west bed") — with the count,
|
||
// a rough location, and when it went in.
|
||
type DescribeGroup struct {
|
||
PlantID int64 `json:"plantId"`
|
||
Plant string `json:"plant"`
|
||
// Plops is how many placements make up the group; Plants the effective plant
|
||
// count across them (explicit counts, else derived from area and spacing).
|
||
Plops int `json:"plops"`
|
||
Plants int `json:"plants"`
|
||
// Where is a rough location: a compass region when the group sits in one
|
||
// ("north half", "NE corner"), "throughout" when it spans the object, a short
|
||
// list of locations, or — for anything else — its bounding box in local cm.
|
||
Where string `json:"where"`
|
||
// PlantedAt is the planting date, or "first…last" when the plops differ.
|
||
PlantedAt string `json:"plantedAt,omitempty"`
|
||
// DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt,
|
||
// enough to say when the harvest is due.
|
||
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
|
||
// ReadyAround is that arithmetic done: planting date plus days to maturity
|
||
// for the plops still in the ground, as one date or "first…last". Absent
|
||
// when the catalog has no days for the plant or nothing is dated. The
|
||
// model was asked "what can I pick this week?" and got the sums wrong.
|
||
ReadyAround string `json:"readyAround,omitempty"`
|
||
// Removed counts the plops in the group that have been pulled, and RemovedAt
|
||
// is when ("first…last" when they differ). Only a season view lists pulled
|
||
// plops, so both are absent from a describe of what is growing now.
|
||
Removed int `json:"removed,omitempty"`
|
||
RemovedAt string `json:"removedAt,omitempty"`
|
||
// Each lists the plops individually (id, version, position, location) only
|
||
// when the group has at most maxListedPlops of them.
|
||
Each []DescribePlanting `json:"each,omitempty"`
|
||
}
|
||
|
||
// DescribePlanting is one plop with its position and a rough compass location.
|
||
// ID + Version let an agent address a single plop — remove it or move it — the
|
||
// same way DescribeObject.Version lets it edit an object. XCM/YCM are in the
|
||
// object's local frame: they are what lets a move keep the layout the plops
|
||
// had, which the compass word alone ("north", "south") cannot.
|
||
type DescribePlanting struct {
|
||
ID int64 `json:"id"`
|
||
Version int64 `json:"version"`
|
||
PlantID int64 `json:"plantId"`
|
||
Plant string `json:"plant"`
|
||
Count int `json:"count"`
|
||
XCM float64 `json:"xCm"`
|
||
YCM float64 `json:"yCm"`
|
||
Location string `json:"location"`
|
||
RadiusCM float64 `json:"radiusCm"`
|
||
PlantedAt string `json:"plantedAt,omitempty"`
|
||
// RemovedAt is set on a pulled plop, which only a season view lists.
|
||
RemovedAt string `json:"removedAt,omitempty"`
|
||
}
|
||
|
||
// DescribeGarden returns a structured summary — dimensions, objects, and each
|
||
// object's plantings grouped by plant (count, rough location, planting date) —
|
||
// for a garden the actor can view. year nil describes what is growing now; a
|
||
// year is the season view, every plop whose time in the ground overlapped it,
|
||
// pulled ones included — what "what was in this bed last year?" needs. Built
|
||
// on GardenFull so it inherits the ACL check and the year's bounds.
|
||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64, year *int) (*DescribeResult, error) {
|
||
full, err := s.GardenFull(ctx, actorID, gardenID, year)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
plantByID := make(map[int64]domain.Plant, len(full.Plants))
|
||
for _, p := range full.Plants {
|
||
plantByID[p.ID] = p
|
||
}
|
||
plopsByObject := make(map[int64][]domain.Planting)
|
||
for _, pl := range full.Plantings {
|
||
plopsByObject[pl.ObjectID] = append(plopsByObject[pl.ObjectID], pl)
|
||
}
|
||
|
||
res := &DescribeResult{
|
||
GardenID: full.Garden.ID,
|
||
Name: full.Garden.Name,
|
||
WidthCM: full.Garden.WidthCM,
|
||
HeightCM: full.Garden.HeightCM,
|
||
UnitPref: full.Garden.UnitPref,
|
||
GridSizeCM: full.Garden.GridSizeCM,
|
||
Notes: full.Garden.Notes,
|
||
Version: full.Garden.Version,
|
||
Year: year,
|
||
Objects: make([]DescribeObject, 0, len(full.Objects)),
|
||
}
|
||
for i := range full.Objects {
|
||
o := &full.Objects[i]
|
||
res.Objects = append(res.Objects, DescribeObject{
|
||
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
|
||
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
|
||
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
|
||
Plantings: describeGroups(o, plopsByObject[o.ID], plantByID),
|
||
})
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// ListObjectPlantings lists an object's active plops one by one — the ids that
|
||
// DescribeGarden summarizes away for a large group. plantID narrows it to one
|
||
// plant. Viewer role, like DescribeGarden.
|
||
func (s *Service) ListObjectPlantings(ctx context.Context, actorID, objectID int64, plantID *int64) ([]DescribePlanting, error) {
|
||
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleViewer); err != nil {
|
||
return nil, err
|
||
}
|
||
plops, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Plants looked up by id, not through the actor's catalog: a plop in a shared
|
||
// garden may be of the owner's private variety, and it still has a name.
|
||
plants := map[int64]domain.Plant{}
|
||
out := make([]DescribePlanting, 0, len(plops))
|
||
for _, pl := range plops {
|
||
if plantID != nil && pl.PlantID != *plantID {
|
||
continue
|
||
}
|
||
plant, ok := plants[pl.PlantID]
|
||
if !ok {
|
||
p, err := s.store.GetPlant(ctx, pl.PlantID)
|
||
if err != nil && !errors.Is(err, domain.ErrNotFound) {
|
||
return nil, err
|
||
}
|
||
if p != nil {
|
||
plant = *p
|
||
}
|
||
plants[pl.PlantID] = plant // a plant that no longer exists lists unnamed, not as an error
|
||
}
|
||
pl.DerivedCount = derivedCount(pl.RadiusCM, plant.SpacingCM)
|
||
out = append(out, describePlanting(pl, plant.Name))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// describeGroups groups an object's active plops by plant, in the order the
|
||
// plants first appear, so the same garden always describes the same way.
|
||
func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID map[int64]domain.Plant) []DescribeGroup {
|
||
byPlant := map[int64][]domain.Planting{}
|
||
var order []int64
|
||
for _, pl := range plops {
|
||
if _, seen := byPlant[pl.PlantID]; !seen {
|
||
order = append(order, pl.PlantID)
|
||
}
|
||
byPlant[pl.PlantID] = append(byPlant[pl.PlantID], pl)
|
||
}
|
||
groups := make([]DescribeGroup, 0, len(order))
|
||
for _, pid := range order {
|
||
members := byPlant[pid]
|
||
plant := plantByID[pid]
|
||
g := DescribeGroup{
|
||
PlantID: pid, Plant: plant.Name, Plops: len(members),
|
||
Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
|
||
DaysToMaturity: plant.DaysToMaturity,
|
||
RemovedAt: dateRangeOf(members, func(pl domain.Planting) *string { return pl.RemovedAt }),
|
||
}
|
||
if plant.DaysToMaturity != nil {
|
||
days := *plant.DaysToMaturity
|
||
g.ReadyAround = dateRangeOf(members, func(pl domain.Planting) *string {
|
||
if pl.RemovedAt != nil {
|
||
return nil // pulled already; its harvest is not ahead of us
|
||
}
|
||
return readyDate(pl.PlantedAt, days)
|
||
})
|
||
}
|
||
for _, pl := range members {
|
||
g.Plants += effectiveCount(pl)
|
||
if pl.RemovedAt != nil {
|
||
g.Removed++
|
||
}
|
||
}
|
||
if len(members) <= maxListedPlops {
|
||
g.Each = make([]DescribePlanting, 0, len(members))
|
||
for _, pl := range members {
|
||
g.Each = append(g.Each, describePlanting(pl, plant.Name))
|
||
}
|
||
}
|
||
groups = append(groups, g)
|
||
}
|
||
return groups
|
||
}
|
||
|
||
func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
|
||
d := DescribePlanting{
|
||
ID: pl.ID, Version: pl.Version, PlantID: pl.PlantID, Plant: plantName,
|
||
Count: effectiveCount(pl), XCM: pl.XCM, YCM: pl.YCM,
|
||
Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM,
|
||
}
|
||
if pl.PlantedAt != nil {
|
||
d.PlantedAt = *pl.PlantedAt
|
||
}
|
||
if pl.RemovedAt != nil {
|
||
d.RemovedAt = *pl.RemovedAt
|
||
}
|
||
return d
|
||
}
|
||
|
||
// readyDate is plantedAt plus days to maturity, or nil when the plop is undated
|
||
// (or its date is not one the store should have accepted).
|
||
func readyDate(plantedAt *string, days int) *string {
|
||
if plantedAt == nil || *plantedAt == "" {
|
||
return nil
|
||
}
|
||
t, err := time.Parse(dateLayout, *plantedAt)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
d := t.AddDate(0, 0, days).Format(dateLayout)
|
||
return &d
|
||
}
|
||
|
||
// effectiveCount is the plant count a plop stands for: its explicit count, else
|
||
// the one derived from its area and the plant's spacing.
|
||
func effectiveCount(pl domain.Planting) int {
|
||
if pl.Count != nil {
|
||
return *pl.Count
|
||
}
|
||
return pl.DerivedCount
|
||
}
|
||
|
||
// dateRange is the planting date shared by a group's plops, "first…last" when
|
||
// they were planted on different days, or "" when none is dated.
|
||
func dateRange(plops []domain.Planting) string {
|
||
return dateRangeOf(plops, func(pl domain.Planting) *string { return pl.PlantedAt })
|
||
}
|
||
|
||
// dateRangeOf summarizes one date field across a group's plops: the one date
|
||
// they share, "first…last" when they differ, or "" when none is set. ISO dates
|
||
// order as strings, so min/max need no parsing.
|
||
func dateRangeOf(plops []domain.Planting, pick func(domain.Planting) *string) string {
|
||
first, last := "", ""
|
||
for _, pl := range plops {
|
||
d := pick(pl)
|
||
if d == nil || *d == "" {
|
||
continue
|
||
}
|
||
if first == "" || *d < first {
|
||
first = *d
|
||
}
|
||
if *d > last {
|
||
last = *d
|
||
}
|
||
}
|
||
if first == last {
|
||
return first
|
||
}
|
||
return first + "…" + last
|
||
}
|
||
|
||
// summarizeWhere names where a group of plops sits in its object, in the words
|
||
// NamedRegion understands when that is exact ("north half", "NE corner"), and
|
||
// otherwise as honestly as it can: "throughout" for a group spanning most of the
|
||
// object, a short list of rough locations, or the bounding box of the plop
|
||
// centres in local cm — which is what a fill needs to put something back there.
|
||
func summarizeWhere(o *domain.GardenObject, plops []domain.Planting) string {
|
||
if len(plops) == 1 {
|
||
return describeLocation(plops[0].XCM, plops[0].YCM)
|
||
}
|
||
minX, maxX := plops[0].XCM, plops[0].XCM
|
||
minY, maxY := plops[0].YCM, plops[0].YCM
|
||
for _, pl := range plops[1:] {
|
||
minX, maxX = math.Min(minX, pl.XCM), math.Max(maxX, pl.XCM)
|
||
minY, maxY = math.Min(minY, pl.YCM), math.Max(maxY, pl.YCM)
|
||
}
|
||
const eps = 1e-6
|
||
// A half is "everything on one side of the centre line, and not just ON it":
|
||
// a column of plops down the middle is neither the west half nor the east.
|
||
north := maxY <= eps && minY < -eps
|
||
south := minY >= -eps && maxY > eps
|
||
west := maxX <= eps && minX < -eps
|
||
east := minX >= -eps && maxX > eps
|
||
switch {
|
||
case north && west:
|
||
return "NW corner"
|
||
case north && east:
|
||
return "NE corner"
|
||
case south && west:
|
||
return "SW corner"
|
||
case south && east:
|
||
return "SE corner"
|
||
case north:
|
||
return "north half"
|
||
case south:
|
||
return "south half"
|
||
case west:
|
||
return "west half"
|
||
case east:
|
||
return "east half"
|
||
}
|
||
// Centres spanning at least 60% of both dimensions is a whole-object fill
|
||
// (the outer row sits half a spacing in from each edge).
|
||
if hw, hh := o.WidthCM/2, o.HeightCM/2; hw > 0 && hh > 0 && maxX-minX >= 1.2*hw && maxY-minY >= 1.2*hh {
|
||
return "throughout"
|
||
}
|
||
var locs []string
|
||
seen := map[string]bool{}
|
||
for _, pl := range plops {
|
||
if l := describeLocation(pl.XCM, pl.YCM); !seen[l] {
|
||
seen[l] = true
|
||
locs = append(locs, l)
|
||
}
|
||
}
|
||
if len(locs) <= 3 {
|
||
return strings.Join(locs, ", ")
|
||
}
|
||
return fmt.Sprintf("x %.0f…%.0f, y %.0f…%.0f cm from the centre", minX, maxX, minY, maxY)
|
||
}
|
||
|
||
// describeLocation reverse-maps a local point to a rough compass location — the
|
||
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
|
||
func describeLocation(x, y float64) string {
|
||
const eps = 1e-6
|
||
ns := ""
|
||
switch {
|
||
case y < -eps:
|
||
ns = "N"
|
||
case y > eps:
|
||
ns = "S"
|
||
}
|
||
ew := ""
|
||
switch {
|
||
case x < -eps:
|
||
ew = "W"
|
||
case x > eps:
|
||
ew = "E"
|
||
}
|
||
switch {
|
||
case ns == "" && ew == "":
|
||
return "center"
|
||
case ns != "" && ew != "":
|
||
return ns + ew + " corner"
|
||
case ns == "N":
|
||
return "north"
|
||
case ns == "S":
|
||
return "south"
|
||
case ew == "E":
|
||
return "east"
|
||
default:
|
||
return "west"
|
||
}
|
||
}
|