package service import ( "context" "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 } // contains reports whether a local point lies in the region. func (r Region) contains(x, y float64) bool { return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY } // clampTo intersects the region with an object's local bounds (±halfW, ±halfH), // so an oversized caller-supplied region can't make hexCenters loop forever. 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), } } // 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) } // FillRegion lays a hex-packed field of plops of one plant across a region of a // plantable object the actor can edit. Plop radius comes from the plant's spacing // (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2× // radius pitch, kept where the center is inside the region. A candidate is // skipped when its plop would sit entirely inside an existing active plop (so // re-filling doesn't stack duplicates). Returns the plops it created. func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return nil, err } return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride) } // fillLoaded is the shared body of FillRegion/FillNamedRegion given an object // already loaded and authorized (roleEditor). It 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, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { if !o.Plantable { return nil, domain.ErrInvalidInput } plant, err := s.visiblePlant(ctx, actorID, plantID) if err != nil { return nil, err } spacing := plant.SpacingCM if spacingOverride != nil { if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM { return nil, domain.ErrInvalidInput } spacing = *spacingOverride } radius := defaultPlopRadius(spacing) if !isFinite(radius) || radius <= 0 { return nil, domain.ErrInvalidInput } region = region.clampTo(o.WidthCM/2, o.HeightCM/2) centers := hexCenters(region, radius) if len(centers) > 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 } today := s.now().UTC().Format(dateLayout) batch := make([]*domain.Planting, 0, len(centers)) for _, c := range centers { if coveredByExisting(c.x, c.y, radius, existing) { continue } p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today} batch = append(batch, p) existing = append(existing, *p) // so later candidates in THIS fill don't stack on it } 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 whose center lies in the region. // Rows are spaced radius·√3 apart and every other row is offset by radius, the // standard hexagonal packing at a 2×radius pitch. The lattice is anchored one // radius inside the region's min corner so the first plop sits inside it. func hexCenters(r Region, radius float64) []localPoint { if radius <= 0 { return nil } pitch := 2 * radius rowH := pitch * math.Sqrt(3) / 2 const eps = 1e-6 var pts []localPoint row := 0 for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH { xStart := r.MinX + radius if row%2 == 1 { xStart += radius } for x := xStart; x <= r.MaxX+eps; x += pitch { if r.contains(x, y) { pts = append(pts, localPoint{x, y}) } } row++ } return pts } // 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) ([]domain.Planting, error) { o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return nil, err } region, err := NamedRegion(o, regionName) if err != nil { return nil, err } return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride) } // 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) { o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return 0, err } // 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 } ids := make([]int64, 0, len(before)) for i := range before { ids = append(ids, before[i].ID) } today := s.now().UTC().Format(dateLayout) n, err := s.store.ClearObjectPlantings(ctx, objectID, today, 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)) } s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), 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, 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 []DescribePlanting `json:"plantings"` } // DescribePlanting is one plop with a rough compass location, for DescribeResult. type DescribePlanting struct { PlantID int64 `json:"plantId"` Plant string `json:"plant"` Count int `json:"count"` Location string `json:"location"` RadiusCM float64 `json:"radiusCm"` } // DescribeGarden returns a structured summary — dimensions, objects, and each // object's active plantings (plant, effective count, rough location) — 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) 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 _, o := range full.Objects { do := 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: []DescribePlanting{}, } for _, pl := range plopsByObject[o.ID] { count := pl.DerivedCount if pl.Count != nil { count = *pl.Count } do.Plantings = append(do.Plantings, DescribePlanting{ PlantID: pl.PlantID, Plant: plantByID[pl.PlantID].Name, Count: count, Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM, }) } res.Objects = append(res.Objects, do) } return res, nil } // 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" } }