Files
pansy/internal/service/ops.go
T
steveandClaude Fable 5 8b161c5f6d
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 4m29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m29s
describe_garden: list each plop's position, so a move can keep the layout
Asked to move four tomatoes planted in a column "keeping the same spacing",
the live assistant re-laid them as two pairs: the per-plop listing said
"north" and "south" and nothing else. Each listed plop (and list_plantings)
now carries xCm/yCm in the object's local frame.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 00:37:23 -04:00

876 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"strings"
"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.
type DescribeResult struct {
GardenID int64 `json:"gardenId"`
Name string `json:"name"`
WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"`
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"`
// Each lists the plops individually (id, version, 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 (the object's
// local frame) let a move keep the layout the plops had, which "north, south"
// alone cannot: asked to move four tomatoes in a column "keeping the same
// spacing", the live assistant re-laid them as two pairs for want of these.
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"`
}
// DescribeGarden returns a structured summary — dimensions, objects, and each
// object's active plantings grouped by plant (count, rough location, planting
// date) — for a garden the actor can view. Built on GardenFull so it inherits
// the ACL check.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
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,
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,
}
for _, pl := range members {
g.Plants += effectiveCount(pl)
}
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
}
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. ISO dates
// order as strings, so min/max need no parsing.
func dateRange(plops []domain.Planting) string {
first, last := "", ""
for _, pl := range plops {
if pl.PlantedAt == nil || *pl.PlantedAt == "" {
continue
}
if first == "" || *pl.PlantedAt < first {
first = *pl.PlantedAt
}
if *pl.PlantedAt > last {
last = *pl.PlantedAt
}
}
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"
}
}