Commit Graph

3124 Commits

Author SHA1 Message Date
filemon 4eaec7fead Rebalance item effects for segmented status model
Tune item stat values so basic items restore roughly 1 baby segment
(+25), medium items provide meaningful upgrades (+35-50), and premium
items have strong multi-stat effects (+70-75).

Food: Apple +15→+25 hunger, Burger +40→+45, Pizza +35→+40,
  Sushi +30→+35, Cake +20→+25.
Toys: Teddy +40→+45 happiness and -15→-5 energy (premium = low cost).
Medicine: Bandage +15→+25, Vitamins +20→+25 health and add +5 energy,
  Elixir +80→+75 health.
Hygiene: Soap +30→+25, Bubble Bath +60→+70 hygiene and +20→+25
  happiness.
Direct actions unchanged (play_music +15, sing +20).

Adds 20 focused tests covering item catalog values, applyItemEffects
clamping, tier relationships, and direct action effects.

No changes to decay rates, sleep behaviour, careState mapping,
segmented UI, item previews, level unlocks, or Nostr persistence.
2026-04-24 15:41:40 -03:00
filemon 4d4d8a43e0 Tune awake decay rates for segment-aligned status model
Baby (4 segments):
- hunger -8/hr, happiness -4.5/hr, hygiene -6/hr, energy -9/hr
- health base -0.4/hr (was -0.75)
- health penalty thresholds aligned to segment boundaries:
  mild at < 50 (attention), strong at < 25 (urgent)
  — was < 70/40, which fired penalties in the 'okay' range
- health regen threshold lowered to 76 (baby good = 4/4 starts at 76)
  — was 80

Adult (10 segments):
- hunger -5/hr, happiness -2.5/hr, hygiene -4/hr, energy -5.5/hr
- health base -0.25/hr (was -0.4)
- penalty thresholds unchanged (already align with 10-segment model)
- regen threshold unchanged at 80

Pacing:
- Baby first 'okay' stat at ~2.7hr, first 'attention' at ~5-6hr
- Adult first 'okay' stat at ~5-6hr, first 'attention' at ~7-8hr
- Growing up feels like increased resilience, not more annoyance

Sleep behaviour, item values, careState mapping, segmented UI
rendering, and Nostr persistence are unchanged. Tests updated to
cover new rates, penalty alignment, and regen threshold.
2026-04-24 13:58:58 -03:00
filemon 63143db9db Render segmented stat rings, fix gap alignment, and make sleep restorative
Part 1 — Segmented rings:
Replace the continuous strokeDasharray progress ring with discrete arc
segments driven by the segment display model. Baby/egg shows 4 segments,
adult shows 10. StatIndicator exports a reusable SegmentedRing component.
When the segments prop is absent, the old continuous ring renders as a
backward-compatible fallback.

Part 2 — Ring gap fix:
Switch from strokeLinecap round to butt so gaps are not consumed by cap
extensions. Increase gapDeg to 16/20 (md/sm), bump muted opacity to 0.12.
Offset the start angle by half a gap so the first gap straddles 12-o'clock,
making the ring visually centred and symmetrical.

Part 3 — Sleep modifiers:
Sleeping is now restorative instead of punitive:
- Energy regen: +40/hr (baby), +35/hr (adult) — up from +6/+5
- Hunger/happiness/hygiene: decay at 20% of awake rates
- Health base decay: pauses (0) while sleeping
- Health penalties: reduced to 25% of awake strength

Awake decay rates, health penalty thresholds, item/action values,
hibernating behaviour, and Nostr persistence are unchanged.

Adds 17 focused unit tests covering baby/adult sleeping, awake decay
baseline, and hibernating-is-not-sleeping.
2026-04-24 13:26:11 -03:00
filemon 08be5e9985 Wire segment display model into stat indicators and care badge
Replace scattered warning/critical status checks with careState from
getBlobbiStatDisplayState in the three UI consumers:

- StatIndicator (shared): new careState prop takes precedence over
  deprecated status prop; badge shown for attention/urgent, pulse for
  urgent only.
- BlobbiRoomHero (inline indicator): same careState-driven logic.
- BlobbiWidget: passes careState instead of getStatStatus result.
- BlobbiPage companion selector: care badge now shows when any stat is
  urgent OR two-plus stats are attention (was: any stat < 40). Eggs are
  always protected so they never trigger the badge.

Old threshold constants and getStatStatus are kept — no deletions.
No changes to decay rates, sleep, items, status-reactions,
needDetection, SVG ring rendering, or Nostr persistence.
2026-04-24 13:03:09 -03:00
filemon 03dcc37083 Add pure segment-display helper for stage-based Blobbi status UI
Introduce getBlobbiStatDisplayState() — a read-only helper that derives
UI segment counts, care states, and badge/pulse flags from internal
1–100 stats without changing any gameplay behaviour.

Egg is always 'protected' with full segments. Baby maps to 4 segments
(urgent/attention/okay/good). Adult maps to 10 segments with wider
threshold bands. Values are clamped to STAT_MIN–STAT_MAX.

Includes 38 unit tests covering all boundary values, clamping, and
flag correctness.
2026-04-24 12:54:34 -03:00
Chad Curtis 846c4f794a Merge branch 'audit/blobbi-adult-color-application-and-generation' into 'main'
Make Blobbi visual identity seed-driven and sync mirror tags

See merge request soapbox-pub/ditto!200
2026-04-24 04:04:48 +00:00
Chad Curtis 3a9f41892f Harden seed derivation: extract readSeedUint32, guard edge cases
- Extract readSeedUint32() for raw 32-bit reads instead of misusing
  deriveIndexFromSeed with max=0x100000000 (which was a no-op modulus).
  deriveIndexFromSeed is now only used for bounded array indexing.
- Guard deriveAdultFormFromSeed against NaN from short/invalid seeds.
- Guard adjustSeedForAdultType against indexOf returning -1 for
  unknown form values.
2026-04-23 22:54:12 -05:00
Chad Curtis 8b0eb97abb Fix redundant layout reflows and sanitize SVG color interpolation
Cache getBoundingClientRect() result in OverstimulationBlockOverlay
to avoid four forced reflows on the same element per activation.

Add sanitizeSvgColor() guard in generateAngerRiseEffect() so that
config.color is validated before interpolation into SVG stop-color
attributes, preventing injection if a future caller passes untrusted
color strings.
2026-04-23 22:39:27 -05:00
Chad Curtis 5463206d84 Fix mobile nav bar rendering during overstimulation zoom
Scroll to top before applying the zoom transform and restore the
saved scroll position after zoom-out completes. The transform on
#root creates a new containing block that breaks sticky positioning
of the mobile top/bottom nav bars when the user is scrolled down.
2026-04-23 22:31:32 -05:00
Chad Curtis ed637bc9df Compact reaction system: inline drain logic, trim verbosity
Delete useReactionDrain (120 lines) — the abstraction added more
complexity than it saved. Each hook now inlines a ~15-line rAF drain.

Rewrite all major files for density:
- useOverstimulationReaction: 375 → 175 lines
- useShakeReaction: 407 → 188 lines (was 205 with shared hook)
- shakeDetection: 222 → 93 lines
- OverstimulationBlockOverlay: 216 → 112 lines

Total branch diff: 1297 → 900 insertions.
2026-04-23 22:27:49 -05:00
Chad Curtis f465cb7347 Extract shared hooks to deduplicate reaction and SVG renderer code
- useFillLevelUpdate: shared recipe fingerprint + imperative gradient
  stop updates, extracted from BlobbiAdultSvgRenderer and
  BlobbiBabySvgRenderer (removed ~60 duplicated lines from each)

- useReactionDrain: shared rAF-based level drain loop with throttled
  React state push, extracted from useOverstimulationReaction and
  useShakeReaction (removed ~170 duplicated lines from each)

Net reduction: ~285 lines across the 4 consumer files.
2026-04-23 22:21:54 -05:00
Chad Curtis 49a5461fbe Fix zoom origin: query companion DOM element directly via data attribute
The previous approach put a ref on the pointer-events-auto wrapper div,
which is a full-width block element -- getBoundingClientRect returned
the page width, not Blobbi's position. Now we query the companion's
actual fixed-position container via [data-blobbi-companion] to get
the true visual bounding rect.
2026-04-23 22:14:06 -05:00
Chad Curtis 75f6283d9b Lower zoom origin to bottom edge of Blobbi's bounding box 2026-04-23 22:09:38 -05:00
Chad Curtis a144193cb4 Lower zoom origin to 80% of Blobbi's height 2026-04-23 22:09:18 -05:00
Chad Curtis 2ec57ad027 Lower zoom origin to 65% of Blobbi's height 2026-04-23 22:08:56 -05:00
Chad Curtis ff412bbb29 Crank overstimulation zoom to 5x for screen-hogging close-up 2026-04-23 22:07:43 -05:00
Chad Curtis 12d299a7ec Replace crumble effect with UI zoom + radial shockwave on overstimulation
When Blobbi hits max overstimulation, the entire UI now zooms toward
Blobbi's face (transform on #root) while a radial shockwave expands
from the companion and a red vignette dims the screen edges. On
recovery the zoom eases back out and the vignette fades.

The overlay is portaled onto document.body so it stays at viewport
scale while #root is scaled. Body overflow is hidden during the zoom
to prevent scrollbar flash.

Removes the canvas-based crumble/debris system (crumbleEngine.ts) in
favor of this simpler CSS-driven approach.
2026-04-23 22:05:30 -05:00
Chad Curtis 8fe0751a67 Add overstimulation visual feedback: shockwave, UI crumble, and debris
Replace the invisible click shield with a dramatic visual sequence when
Blobbi reaches max overstimulation: a radial shockwave expands from
Blobbi, a dark backdrop covers the UI (Blobbi stands alone in the void),
and canvas debris particles rain down like rubble. On recovery the
backdrop fades and debris converges back.

Also fix the debug bypass in useShakeReaction (true || threshold) that
shipped nausea on every shake regardless of hunger stat, and remove the
toast notification replaced by the visual overlay.
2026-04-23 21:55:28 -05:00
Chad Curtis 1b940b262c Merge branch 'main' of gitlab.com:soapbox-pub/ditto into feat/blobbi-click-overstimulation-reaction 2026-04-23 21:21:44 -05:00
Chad Curtis 6ea1d0da2b Merge branch 'fix/blobbi-deterministic-legacy-migration' into 'main'
Make legacy Blobbi migration deterministic

Closes #248

See merge request soapbox-pub/ditto!199
2026-04-24 02:18:58 +00:00
Chad Curtis 4dd487e0b2 Merge branch 'feat/improve-blobbi-companion-transitions' into 'main'
Add generic route-transition reaction for Blobbi companion

Closes #225

See merge request soapbox-pub/ditto!182
2026-04-24 02:09:45 +00:00
Chad Curtis 82f97aa1e2 Merge branch 'feat/blobbi-eyes-feed' into 'main'
Enable Blobbi eye tracking on feed cards (feed-only, with touch support)

Closes #249

See merge request soapbox-pub/ditto!201
2026-04-24 01:56:35 +00:00
Chad Curtis 1be0b3f101 Enable Blobbi eye tracking on detail page
Pass lookMode="follow-pointer" to BlobbiStateCard in PostDetailPage,
matching the feed card behavior so Blobbi eyes follow the cursor on the
detail view as well.
2026-04-23 20:53:35 -05:00
Chad Curtis 360a8c88e3 Merge branch 'fix-crysti' into 'main'
Fix crysti blobbi: add missing sparkle animations and fix pink facet path

Closes #252

See merge request soapbox-pub/ditto!202
2026-04-23 22:21:02 +00:00
Chad Curtis be4a741a73 Add RTL support for Arabic, Hebrew, and other RTL languages
Add dir="auto" to NoteContent, BioContent, and DM message bubbles
so the browser's Unicode Bidirectional Algorithm automatically detects
text direction from the first strong directional character.
2026-04-23 15:29:52 -05:00
Patrick PReis 589fb8ebba Fix crysti facet5 degenerate path (bottom-right purple section)
The path started and ended at the same point (100,105), making it a
triangle instead of a quadrilateral. The bottom-right hexagon vertex
(140,130) was missing entirely. Changed to trace center → right-mid →
bottom-right vertex → inner bottom-right point.

Fixed in all four locations: both .svg source files and both inlined
constants in adult-svg-data.ts.
2026-04-23 17:27:48 -03:00
Chad Curtis 0156a82629 Fix overflow menu rendering inside compose modal
The Poll/Spoiler popover opened side="bottom" from the toolbar, which
is near the bottom edge of the dialog.  Because the dialog container
uses overflow-hidden (needed for flex layout containment) and the
PortalContainerProvider portals content inside the dialog DOM, the
popover was clipped.

Switch to side="top" so the menu opens upward into the visible area
of the modal.
2026-04-23 15:25:44 -05:00
Patrick PReis 8497d87238 Sync crysti fixes into inlined SVG data constants
The app renders Blobbi SVGs from inlined string constants in
adult-svg-data.ts, not from the .svg source files. The previous commits
only fixed the source files. This syncs all three fixes into the
inlined CRYSTI_BASE and CRYSTI_SLEEPING constants:

- Add animateTransform groups to CRYSTI_BASE sparkle circles
- Fix self-intersecting pink facet path in both constants
- Match sleeping facet opacities to base values
2026-04-23 17:00:50 -03:00
Patrick PReis 787e0f6902 Match crysti sleeping facet opacities to base variant
The sleeping SVG had all six facet opacities reduced by 0.2 compared to
the base, making it look washed out. Only the eyes/mouth should differ
between states — the body colors should stay vibrant, consistent with
how bloomi handles its sleeping variant.
2026-04-23 15:27:39 -03:00
Patrick PReis 6ac7bdf826 Fix crysti blobbi: add missing sparkle animations and fix pink facet path
The base SVG sparkle circles were missing animateTransform tags, causing
them to render static instead of orbiting like the sleeping variant.
Wrapped them in two animated groups matching crysti-sleeping.

The top-left pink facet (crystiFacet2) had a self-intersecting path
(bowtie shape) that caused inconsistent fill rendering. Reordered the
vertices to trace a proper convex quadrilateral in both base and sleeping
SVGs.
2026-04-23 15:02:40 -03:00
filemon 2a2ebd6a46 Enable Blobbi eye tracking on feed cards (feed-only, with touch support)
BlobbiStateCard gains an optional lookMode prop (default: 'forward'),
threaded through to BlobbiStageVisual. Only the NoteCard feed call site
passes 'follow-pointer'; post detail, embedded notes, and embedded naddr
keep the default forward gaze.

The global pointer listener in useBlobbiEyes now also tracks touchstart
and touchmove so the effect works on mobile.
2026-04-22 18:58:36 -03:00
filemon 4d00ba9542 Fix duplicate egg creation when adopting another Blobbi
The module-level setupInFlightFor guard had a race condition: the
effect cleanup unconditionally deleted the pubkey from the guard set
even when setup() was already mid-flight. If a parent re-render caused
the component to unmount/remount during the async publish window, the
new instance passed all guards and created a second egg.

Fix: track whether setup() has started in a ref. Cleanup only releases
the guard when the timer was cancelled before setup began; otherwise
setup() releases it in its own finally block.

Also stabilize the eggOnly completion timer by reading onComplete
through a ref, preventing the 1500ms timer from resetting on every
parent re-render that creates a new inline callback reference.
2026-04-22 13:14:42 -03:00
filemon ea99fdf288 Clean up self-review findings: deduplicate hexToHsl, add abort handling, fix comments
- Remove hexToHslLocal from blobbi.ts; reuse shared hexToHsl from
  color-guardrails.ts (eliminates duplicate implementation)
- Add abort flag to useSeedIdentitySync useEffect so the async sync
  loop stops on unmount and never calls updateCompanionEvent after
  teardown
- Replace relative date wording in compat cutoff comment with
  absolute date only
- Fix stale STEP numbering in BlobbiPage.tsx (5/6 → 4/5)
2026-04-22 10:31:31 -03:00
filemon 56650efe74 Fix self-review findings: unify adult derivation, remove dead code, harden sync
- Unify adult-form derivation: replace charCode hash in
  deriveAdultFormFromSeed with the canonical seed-slice algorithm
  (offset [40..48]), and remove the duplicate deriveAdultTypeFromSeed
  from blobbi.ts (all call sites now use the single canonical function)
- Guard unconditional console.log in parseBlobbiEvent behind
  import.meta.env.DEV so it no longer spams production consoles
- Remove dead deriveColorsFromSeed (zero callers, was deprecated on
  arrival) and its stale JSDoc reference in adjustSeedForAdultType
- Replace brute-force loop in adjustSeedForAdultType with a direct
  O(1) computation: since the derivation is parseInt(slice, 16) % len,
  the target index itself is always a valid candidate
- Add fetchFreshEvent to useSeedIdentitySync before each publish,
  matching the project convention for replaceable event mutations and
  preventing stale-cache overwrites on multi-device usage
2026-04-21 23:47:12 -03:00
filemon ef64668fac Add Pandi color customizer: tinted-white body, dark-tinted patches
Pandi now applies baseColor and secondaryColor instead of staying
hardcoded black and white.

Light areas (body, head): a very soft tinted-white derived from
baseColor's hue at L=95 S=min(baseSat,30). Clearly not pure white,
but stays close — preserves the hue family without going full-
strength. Stroke uses the same hue at L=90 S=20.

Dark areas (ear patches, eye patches, inner ears, arms, legs, nose,
mouth): derived from secondaryColor's hue forced to L=20 S=30
(primary dark) and L=27 S=20 (lighter dark for gradients and inner
fills). Maintains proper panda light-vs-dark contrast.

Eye color: unchanged — still applied via pandiPupil3D gradient
replacement in the existing applyPupilGradient path.

No other adult form customizers were modified.
2026-04-21 23:30:42 -03:00
filemon ce4550cae5 Update dev editor to adjust seed when changing adult form
The adult form dropdown now works with the seed-truth model: selecting
a different form calls adjustSeedForAdultType() and writes the adjusted
seed through the normal update path. syncMirrorTagsToSeed then derives
all mirror tags (adult_type, colors, pattern, etc.) from the new seed.

Previously the dropdown wrote adult_type as a raw tag that was
immediately overwritten by the seed-derived value — effectively a no-op.

Also adds a read-only seed display and a note explaining that changing
the form re-derives the visual identity.
2026-04-21 23:19:08 -03:00
filemon d951aab997 Derive adultType from effectiveSeed, rename sync terminology
BlobbiCompanion.adultType now derives from the effective seed for adult
Blobbies instead of reading the (potentially stale) stored tag. Falls
back to the tag only for legacy events without a seed.

Renames to reflect the broadened scope of the sync system:
- needsColorSync -> needsSeedIdentitySync
- eventNeedsColorSync -> eventNeedsSeedIdentitySync

No behavior change beyond making adultType consistent with the seed-
truth model and aligning names with what the code already does.
2026-04-21 23:07:51 -03:00
filemon 3dac492e31 Seed-truth for full visual identity including adult_type, with compat window
Seed now determines the complete visual identity: colors, pattern,
special_mark, size, and adult_type. All corresponding tags are persisted
mirrors that get overwritten on every republish via syncMirrorTagsToSeed.

Key changes:

deriveAdultTypeFromSeed: new derivation at seed offset [40..48], indexing
  into the 16-element ADULT_FORMS array via deriveIndexFromSeed.

deriveSeedIdentity: replaces deriveColorsFromSeed as the single entry
  point for the complete seed-derived visual trait set.

Temporary adult-type compatibility (cutoff: 2026-05-01 UTC):
  For existing adult Blobbies whose stored adult_type doesn't match the
  seed-derived form, adjustSeedForAdultType brute-forces the seed bytes
  at offset [40..48] to produce the stored form. This preserves existing
  adult forms during the transition. After the cutoff, parseBlobbiEvent
  skips this adjustment automatically and the code becomes dead.

eventNeedsColorSync: now checks all mirror tags (colors, pattern, mark,
  size, adult_type for adults), not just colors.

syncMirrorTagsToSeed: expanded to overwrite all mirror tags including
  adult_type on every republish through the merge pipeline.

useSeedIdentitySync hook: new hook wired into BlobbiPage that checks
  filteredCompanions on load and republishes any with stale mirror tags.
  Tracks synced d-tags in a ref to avoid loops. Processes sequentially
  to avoid relay rate-limiting.

BlobbiPage: calls useSeedIdentitySync(filteredCompanions) after the
  existing dedup/filter step, so only visible companions are synced.
2026-04-21 22:54:50 -03:00
filemon 907370e270 Make seed the canonical source of truth for Blobbi colors
When a seed exists, base_color / secondary_color / eye_color are now
always derived from the seed via deriveColorsFromSeed(). Explicit color
tags no longer override seed-derived values -- they are persisted as
mirrors for relay indexing and backward compatibility.

Changes:
- deriveVisualTraits: seed path ignores color tags entirely; no-seed
  legacy path unchanged
- deriveColorsFromSeed: new single entry point for canonical color
  derivation (seed → HSL → guardrails)
- syncColorTagsToSeed: overwrites stale color tags on every republish
  via mergeBlobbiStateTagsForRepublish
- eventNeedsColorSync: detects events whose stored color tags differ
  from seed-derived values
- BlobbiCompanion.needsColorSync: lightweight flag for UI-driven
  republish of stale events

Existing Blobbies with a seed will change appearance on next render
(seed-derived colors replace old palette-indexed tags). Events are
backfilled on their next republish through the merge pipeline.
2026-04-21 22:14:39 -03:00
filemon 1eeaf4c10e Replace fixed color palettes with arbitrary HSL generation from seed
Blobbi colors are now derived as full-spectrum HSL values from the seed
hash instead of indexing into fixed 10/10/8-element palette arrays.

Generation changes:
- deriveBaseColorFromSeed: splits 32-bit seed value into H(0-359),
  S(30-100), L(30-75) via successive division
- deriveSecondaryColorFromSeed: harmonized from base — same saturation,
  hue shifted ±20°, lightness +12..25 above base (guarantees visible
  3D gradient)
- deriveEyeColorFromSeed: independent H(0-359), S(40-100), L(10-55)
  for dark vivid pupils

Both deriveVisualTraits() and buildEggTags() now pipe seed-derived
colors through applyColorGuardrails() before use. Guardrails are never
applied to explicit tag values — the tag-priority rule is preserved.

Legacy palette arrays are marked @deprecated but kept for reference.
No rendering code, customizers, or Pandi behavior changed.
2026-04-21 21:10:54 -03:00
filemon c5140bf118 Add color guardrail utilities for Blobbi visual trait generation
Pure HSL-based validation/adjustment functions that will make arbitrary
color generation safe in a follow-up step. Guardrails ensure:
- base colors stay within a lightness range where the SVG gradient
  pipeline (lighten/darken) produces visible 3D shading
- secondary colors are perceptually distinct from base colors so body
  gradients don't collapse into flat fills
- eye colors have enough contrast to remain visible on white sclera
  and visually distinct from the body

Generation-side only: no rendering code, customizers, or existing
tagged colors are touched.
2026-04-21 20:29:30 -03:00
filemon f0f54d76c5 Merge branch 'main' into fix/blobbi-deterministic-legacy-migration 2026-04-21 19:54:07 -03:00
filemon 819d0a88f1 Make legacy Blobbi migration deterministic
Derive the migration petId from sha256(pubkey + legacyD) instead of
crypto.getRandomValues(). The same legacy Blobbi now always produces the
same canonical d-tag, seed, and visual traits regardless of which device
or session triggers the migration.

The equivalence guard (findCanonicalEquivalent) still runs first, so
pre-existing canonicals from the random-petId era are reused and no
duplicate is created.
2026-04-21 19:45:19 -03:00
Chad Curtis 08e61eea89 Merge branch 'fix/blobbi-adult-polish' into 'main'
Fix adult Blobbi colors, reactions, and eye/eyebrow alignment

Closes #241, #242, #243, #244, #245, and #246

See merge request soapbox-pub/ditto!196
2026-04-21 22:29:35 +00:00
Chad Curtis 273469eda8 Merge branch 'fix/blobbi-progression-task-persistence' into 'main'
Move Blobbi progression missions from kind 11125 to kind 31124

Closes #239

See merge request soapbox-pub/ditto!193
2026-04-21 22:29:05 +00:00
Chad Curtis 97a219aa8c Merge branch 'fix/blobbi-sleep-eye-overlay-initial-render' into 'main'
Fix open-eye flash on first sleep transition

Closes #240

See merge request soapbox-pub/ditto!195
2026-04-21 22:28:31 +00:00
Chad Curtis 5dafdf85f7 Merge branch 'fix/blobbi-legacy-new-deduplication' into 'main'
Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates

Closes #247

See merge request soapbox-pub/ditto!198
2026-04-21 22:28:00 +00:00
filemon 7830269ea1 Collapse canonical Blobbi duplicates created by migration races
filterMigratedLegacyCompanions now runs a second pass that groups
canonical companions by their migrated_from tag. Within each group
only the newest event (highest created_at) is kept; the rest are
hidden from the collection UI. Canonical companions without the tag
are never grouped — they pass through untouched.

This closes the remaining duplicate-in-UI gap left intentionally by
the earlier legacy→canonical dedup work.
2026-04-21 18:37:38 -03:00
filemon 118b0c11ab Strengthen legacy->canonical equivalence with migrated_from and base_color
Replace the name-only equivalence rule with a tiered priority:

1. migrated_from exact match (canonical event's migrated_from tag equals
   the legacy d-tag) — strongest signal, written during migration and
   preserved across all subsequent Blobbi updates.

2. Same normalized name + same raw base_color tag — covers older canonical
   copies created before migrated_from existed, where both events have an
   explicit base_color tag that matches.

3. Same normalized name when the legacy event has no base_color tag —
   weakest fallback for genuinely old bare legacy events with no visual
   tags to compare.

All tiers still require the legacy d-tag to be absent from profile.has
(the migration-completion guard).

Audited that migrated_from survives all Blobbi lifecycle operations:
mergeBlobbiStateTagsForRepublish preserves it as an unknown tag,
validateAndRepairBlobbiTags passes it through (not in schema, not
deprecated), and stage transition cleanup does not touch it.
2026-04-21 17:52:24 -03:00
filemon 4ad0a9cfb4 Fix Blobbi legacy/new-format deduplication to prevent infinite duplicates
Legacy Blobbi events (d=blobbi-{name}) persisted on relays after migration
to canonical format (d=blobbi-{hex}-{hex}), causing them to appear alongside
their canonical copies in the UI. Interacting with a still-visible legacy
Blobbi triggered another migration each time, creating unbounded duplicates.

Three changes:

1. Filter migrated legacy Blobbies from the rendered collection. A legacy
   Blobbi is hidden only when a canonical Blobbi with the same normalized
   name exists AND the legacy d-tag is no longer in profile.has (confirming
   migration already occurred).

2. Guard ensureCanonicalBlobbiBeforeAction against re-migration. Before
   creating a new canonical event, query all companions and look for an
   existing canonical equivalent by normalized name. If found, reuse it
   and fix up profile.has/current_companion instead of migrating again.

3. Store a migrated_from tag on newly migrated events for future stronger
   equivalence lookups (additive, not depended on by current dedup logic).
2026-04-21 17:32:50 -03:00