From bf6788c141ad55ce337c2bece080916c24289ca0 Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 06:46:17 -0300 Subject: [PATCH] Separate progression state from activity state to prevent evolution reset Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently destroying evolution progress. The root cause was using a single state tag for two orthogonal concerns: activity (active/sleeping/hibernating) and progression (incubating/evolving). Introduce progression_state and progression_started_at as new tags orthogonal to the activity state tag. Sleep, wake, and hibernation changes now never touch progression. Parser auto-migrates legacy events that stored progression in the state tag on read. --- .../components/BlobbiMissionsModal.tsx | 4 +- .../components/StartEvolutionDialog.tsx | 2 +- .../components/StartIncubationDialog.tsx | 4 +- .../actions/hooks/useActiveTaskProcess.ts | 4 +- .../actions/hooks/useBlobbiDirectAction.ts | 4 +- .../actions/hooks/useBlobbiIncubation.ts | 37 ++++---- .../actions/hooks/useBlobbiStageTransition.ts | 15 ++-- .../hooks/useBlobbiUseInventoryItem.ts | 4 +- src/blobbi/actions/hooks/useEvolveTasks.ts | 2 +- src/blobbi/actions/hooks/useHatchTasks.ts | 2 +- .../companion/interaction/useBlobbiItemUse.ts | 4 +- src/blobbi/core/lib/blobbi-tag-schema.ts | 43 +++++++-- src/blobbi/core/lib/blobbi.ts | 89 +++++++++++++++++-- src/blobbi/core/types/blobbi.ts | 7 +- src/blobbi/dev/useBlobbiDevUpdate.ts | 11 ++- .../components/BlobbiHatchingCeremony.tsx | 10 ++- src/blobbi/onboarding/lib/blobbi-preview.ts | 14 ++- src/pages/BlobbiPage.tsx | 4 +- src/types/blobbi.ts | 7 +- 19 files changed, 201 insertions(+), 66 deletions(-) diff --git a/src/blobbi/actions/components/BlobbiMissionsModal.tsx b/src/blobbi/actions/components/BlobbiMissionsModal.tsx index 1244bc21..8b6aa2ee 100644 --- a/src/blobbi/actions/components/BlobbiMissionsModal.tsx +++ b/src/blobbi/actions/components/BlobbiMissionsModal.tsx @@ -434,8 +434,8 @@ export function BlobbiMissionsModal({ showMissionCard, onToggleMissionCard, }: BlobbiMissionsModalProps) { - const isIncubating = companion.state === 'incubating'; - const isEvolvingState = companion.state === 'evolving'; + const isIncubating = companion.progressionState === 'incubating'; + const isEvolvingState = companion.progressionState === 'evolving'; const isEgg = companion.stage === 'egg'; const isBaby = companion.stage === 'baby'; diff --git a/src/blobbi/actions/components/StartEvolutionDialog.tsx b/src/blobbi/actions/components/StartEvolutionDialog.tsx index 5609d730..e9e41363 100644 --- a/src/blobbi/actions/components/StartEvolutionDialog.tsx +++ b/src/blobbi/actions/components/StartEvolutionDialog.tsx @@ -46,7 +46,7 @@ export function StartEvolutionDialog({ isPending, }: StartEvolutionDialogProps) { // Check if the current Blobbi is already evolving - const isAlreadyEvolving = companion?.state === 'evolving'; + const isAlreadyEvolving = companion?.progressionState === 'evolving'; // Determine title and description based on state const getDialogContent = () => { diff --git a/src/blobbi/actions/components/StartIncubationDialog.tsx b/src/blobbi/actions/components/StartIncubationDialog.tsx index a67a856a..f752c79e 100644 --- a/src/blobbi/actions/components/StartIncubationDialog.tsx +++ b/src/blobbi/actions/components/StartIncubationDialog.tsx @@ -54,14 +54,14 @@ export function StartIncubationDialog({ isPending, }: StartIncubationDialogProps) { // Check if the current Blobbi is already in a task state - const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving'; + const isAlreadyInTaskState = companion?.progressionState === 'incubating' || companion?.progressionState === 'evolving'; // Check if another Blobbi (not this one) is currently incubating const otherIncubatingBlobbi = useMemo(() => { if (!companion) return null; return companions.find(c => c.d !== companion.d && - c.state === 'incubating' && + c.progressionState === 'incubating' && c.stage === 'egg' ) ?? null; }, [companion, companions]); diff --git a/src/blobbi/actions/hooks/useActiveTaskProcess.ts b/src/blobbi/actions/hooks/useActiveTaskProcess.ts index 4cb50beb..723a080a 100644 --- a/src/blobbi/actions/hooks/useActiveTaskProcess.ts +++ b/src/blobbi/actions/hooks/useActiveTaskProcess.ts @@ -139,8 +139,8 @@ export function useActiveTaskProcess( // Determine which process is active const processType = useMemo((): TaskProcessType => { if (!companion) return null; - if (companion.state === 'incubating') return 'hatch'; - if (companion.state === 'evolving') return 'evolve'; + if (companion.progressionState === 'incubating') return 'hatch'; + if (companion.progressionState === 'evolving') return 'evolve'; return null; }, [companion]); diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index 8c83719a..06e2149d 100644 --- a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts +++ b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts @@ -147,9 +147,9 @@ export function useBlobbiDirectAction({ const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = canonical.companion.state; + const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user.pubkey); } diff --git a/src/blobbi/actions/hooks/useBlobbiIncubation.ts b/src/blobbi/actions/hooks/useBlobbiIncubation.ts index e90e6696..b28fb02b 100644 --- a/src/blobbi/actions/hooks/useBlobbiIncubation.ts +++ b/src/blobbi/actions/hooks/useBlobbiIncubation.ts @@ -181,17 +181,18 @@ export function useStartIncubation({ // Apply decay to the other Blobbi const otherDecayResult = applyBlobbiDecay({ stage: 'egg', - state: 'incubating', + state: 'active', stats: otherStats, lastDecayAt: otherLastDecayAt, now, }); - // Remove task tags and state_started_at from the other Blobbi + // Remove task tags and progression timing from the other Blobbi const otherCleanedTags = otherEvent.tags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); const otherNewTags = updateBlobbiTags(otherCleanedTags, { @@ -200,7 +201,7 @@ export function useStartIncubation({ happiness: otherDecayResult.stats.happiness.toString(), hunger: '100', energy: '100', - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); @@ -254,8 +255,8 @@ export function useStartIncubation({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'incubating', - state_started_at: nowStr, + progression_state: 'incubating', + progression_started_at: nowStr, last_interaction: nowStr, last_decay_at: nowStr, }); @@ -375,7 +376,7 @@ export function useStopIncubation({ throw new Error('No companion selected'); } - if (companion.state !== 'incubating') { + if (companion.progressionState !== 'incubating') { throw new Error('This Blobbi is not incubating'); } @@ -398,11 +399,12 @@ export function useStopIncubation({ }); // ─── Build Updated Tags ─── - // Remove task tags and state_started_at + // Remove task tags and progression timing const cleanedTags = canonical.allTags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); // Build stats update with decayed values @@ -417,7 +419,7 @@ export function useStopIncubation({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); @@ -510,7 +512,7 @@ export function useStartEvolution({ throw new Error('Only baby Blobbis can evolve'); } - if (companion.state === 'evolving') { + if (companion.progressionState === 'evolving') { throw new Error('This Blobbi is already evolving'); } @@ -549,8 +551,8 @@ export function useStartEvolution({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'evolving', - state_started_at: nowStr, + progression_state: 'evolving', + progression_started_at: nowStr, last_interaction: nowStr, last_decay_at: nowStr, }); @@ -656,7 +658,7 @@ export function useStopEvolution({ throw new Error('No companion selected'); } - if (companion.state !== 'evolving') { + if (companion.progressionState !== 'evolving') { throw new Error('This Blobbi is not evolving'); } @@ -679,11 +681,12 @@ export function useStopEvolution({ }); // ─── Build Updated Tags ─── - // Remove task tags and state_started_at + // Remove task tags and progression timing const cleanedTags = canonical.allTags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); // Build stats update with decayed values @@ -697,7 +700,7 @@ export function useStopEvolution({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); diff --git a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts index c42d1d96..f9b8107f 100644 --- a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts +++ b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts @@ -201,12 +201,12 @@ export function useBlobbiHatch({ } // ─── Auto-start evolution for newly hatched babies ─── - // Applied AFTER tag validation because cleanupTaskTags repairs - // task-process states to 'active'. We intentionally set 'evolving' - // here so the baby starts its evolution journey immediately. + // Applied AFTER tag validation because cleanupTaskTags clears + // progression tags. We set the new progression_state here so the + // baby starts its evolution journey immediately. const newTags = updateBlobbiTags(repairResult.tags, { - state: 'evolving', - state_started_at: nowStr, + progression_state: 'evolving', + progression_started_at: nowStr, }); // ─── Generate New Content for Baby Stage ─── @@ -355,7 +355,10 @@ export function useBlobbiEvolve({ console.log('[Evolve] Tag repairs applied:', repairResult.repairs); } - const newTags = repairResult.tags; + // Ensure progression is cleared after evolve + const newTags = updateBlobbiTags(repairResult.tags, { + progression_state: 'none', + }); // ─── Generate New Content for Adult Stage ─── // CRITICAL: Content must reflect the new stage diff --git a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts index 6b31f7ca..e7d6dbd7 100644 --- a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts +++ b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts @@ -241,9 +241,9 @@ export function useBlobbiUseInventoryItem({ const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = canonical.companion.state; + const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user?.pubkey); } diff --git a/src/blobbi/actions/hooks/useEvolveTasks.ts b/src/blobbi/actions/hooks/useEvolveTasks.ts index 795ae35c..1e61cb04 100644 --- a/src/blobbi/actions/hooks/useEvolveTasks.ts +++ b/src/blobbi/actions/hooks/useEvolveTasks.ts @@ -90,7 +90,7 @@ export function useEvolveTasks( const { nostr } = useNostr(); const pubkey = user?.pubkey; - const isEvolving = companion?.state === 'evolving'; + const isEvolving = companion?.progressionState === 'evolving'; const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index 1a71c087..400050a4 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -109,7 +109,7 @@ export function useHatchTasks( const { nostr } = useNostr(); const pubkey = user?.pubkey; - const isIncubating = companion?.state === 'incubating'; + const isIncubating = companion?.progressionState === 'incubating'; const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── diff --git a/src/blobbi/companion/interaction/useBlobbiItemUse.ts b/src/blobbi/companion/interaction/useBlobbiItemUse.ts index daa628be..ba2b0f8b 100644 --- a/src/blobbi/companion/interaction/useBlobbiItemUse.ts +++ b/src/blobbi/companion/interaction/useBlobbiItemUse.ts @@ -351,9 +351,9 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = companion.state; + const progressionState = companion.progressionState; const updatedTags = companion.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user?.pubkey); } diff --git a/src/blobbi/core/lib/blobbi-tag-schema.ts b/src/blobbi/core/lib/blobbi-tag-schema.ts index 809591ae..2b2e0f4f 100644 --- a/src/blobbi/core/lib/blobbi-tag-schema.ts +++ b/src/blobbi/core/lib/blobbi-tag-schema.ts @@ -382,8 +382,8 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ persistent: false, source: 'system', regenerable: false, - format: 'active | sleeping | hibernating | incubating | evolving', - notes: 'incubating is for eggs, evolving is for babies. Reset to active after transition.', + format: 'active | sleeping | hibernating', + notes: 'Activity state only. Progression (incubation/evolution) is tracked in progression_state.', }, { tag: 'last_interaction', @@ -414,8 +414,21 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ // TASK SYSTEM TAGS // ═══════════════════════════════════════════════════════════════════════════ { - tag: 'state_started_at', - description: 'Unix timestamp when current state (incubating/evolving) started', + tag: 'progression_state', + description: 'Current progression process state (orthogonal to activity state)', + category: 'task', + required: false, + stages: ['egg', 'baby', 'adult'], + persistent: false, + source: 'system', + regenerable: false, + format: 'none | incubating | evolving', + defaultValue: 'none', + notes: 'Set to incubating/evolving when a progression process starts. Cleared to none after hatch/evolve completes. Survives sleep/wake/hibernation changes.', + }, + { + tag: 'progression_started_at', + description: 'Unix timestamp when current progression (incubating/evolving) started', category: 'task', required: false, stages: ['egg', 'baby', 'adult'], @@ -425,6 +438,18 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ format: 'Unix timestamp (seconds)', notes: 'Set when entering incubating/evolving. REMOVED after hatch/evolve completes.', }, + { + tag: 'state_started_at', + description: '@deprecated Use progression_started_at instead. Unix timestamp when current state (incubating/evolving) started', + category: 'task', + required: false, + stages: ['egg', 'baby', 'adult'], + persistent: false, + source: 'system', + regenerable: false, + format: 'Unix timestamp (seconds)', + notes: 'Legacy tag. New events should use progression_started_at instead.', + }, { tag: 'task', description: 'Task progress tracking', @@ -668,7 +693,7 @@ export function getPersistentTagNames(): Set { * These are task-related and state-specific tags. */ export function getTransitionCleanupTagNames(): Set { - return new Set(['task', 'task_completed', 'state_started_at']); + return new Set(['task', 'task_completed', 'state_started_at', 'progression_started_at', 'progression_state']); } /** @@ -776,13 +801,15 @@ const NEVER_INVENT_TAGS = new Set([ * Valid states for each stage. Used to validate/repair state after transitions. */ const VALID_STATES_BY_STAGE: Record> = { - egg: new Set(['active', 'sleeping', 'hibernating', 'incubating']), - baby: new Set(['active', 'sleeping', 'hibernating', 'evolving']), + egg: new Set(['active', 'sleeping', 'hibernating']), + baby: new Set(['active', 'sleeping', 'hibernating']), adult: new Set(['active', 'sleeping', 'hibernating']), }; /** - * Task-process states that should NOT remain after stage transitions. + * Task-process states that should NOT remain in the `state` tag after stage transitions. + * With the new model, state should never be 'incubating'/'evolving' — but we keep this + * for migration: if a legacy event has them in state, the repair will fix it. */ const TASK_PROCESS_STATES = new Set(['incubating', 'evolving']); diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 276bad25..1163d99a 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -94,7 +94,16 @@ export function getDaysDifference(dayA: string, dayB: string): number { // ─── Types ──────────────────────────────────────────────────────────────────── export type BlobbiStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + * + * 'none' — no progression process active + * 'incubating' — egg is being incubated (hatch tasks) + * 'evolving' — baby is being evolved (evolve tasks) + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; export interface BlobbiStats { hunger: number; @@ -239,8 +248,10 @@ export interface BlobbiCompanion { name: string; /** Lifecycle stage */ stage: BlobbiStage; - /** Activity state */ + /** Activity state (active, sleeping, hibernating — never progression) */ state: BlobbiState; + /** Progression process state (none, incubating, evolving — orthogonal to state) */ + progressionState: BlobbiProgressionState; /** Deterministic identity seed (64-char hex) */ seed: string | undefined; /** Visual traits (derived from seed or legacy tags) */ @@ -277,8 +288,14 @@ export interface BlobbiCompanion { startIncubation: number | undefined; /** Adult evolution form type (adult only) */ adultType: string | undefined; - /** Timestamp when current state (incubating/evolving) started (unix seconds) */ + /** + * @deprecated Use progressionStartedAt instead. + * Timestamp when current state (incubating/evolving) started (unix seconds). + * Kept for migration compatibility. + */ stateStartedAt: number | undefined; + /** Timestamp when current progression (incubating/evolving) started (unix seconds) */ + progressionStartedAt: number | undefined; /** Task progress cache (source of truth is computed from Nostr events) */ tasks: BlobbiTaskProgress[]; /** Completed task names */ @@ -769,6 +786,8 @@ export function isValidBlobbiEvent(event: NostrEvent): boolean { if (!d) return false; if (b !== BLOBBI_ECOSYSTEM_NAMESPACE) return false; if (!stage || !['egg', 'baby', 'adult'].includes(stage)) return false; + // Accept both new states (active/sleeping/hibernating) and legacy states (incubating/evolving) + // for backwards compatibility during migration if (!state || !['active', 'sleeping', 'hibernating', 'incubating', 'evolving'].includes(state)) return false; if (!lastInteraction) return false; @@ -887,9 +906,33 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined const d = getTagValue(tags, 'd')!; const nameTag = getTagValue(tags, 'name'); const stage = getTagValue(tags, 'stage') as BlobbiStage; - const state = getTagValue(tags, 'state') as BlobbiState; + const rawState = getTagValue(tags, 'state')!; const seed = getTagValue(tags, 'seed'); + // ─── Progression state resolution (migration-aware) ─── + // New model: progression lives in progression_state tag. + // Old model: progression lived in the state tag ('incubating', 'evolving'). + // On read we normalise both into the new model. + const progressionStateTag = getTagValue(tags, 'progression_state') as BlobbiProgressionState | undefined; + + let state: BlobbiState; + let progressionState: BlobbiProgressionState; + + if (progressionStateTag) { + // New-format event: progression_state tag is authoritative + state = rawState as BlobbiState; + progressionState = progressionStateTag; + } else if (rawState === 'incubating' || rawState === 'evolving') { + // Legacy event: progression was stored in state tag. + // Normalise: move it to progressionState, set activity state to 'active'. + state = 'active'; + progressionState = rawState as BlobbiProgressionState; + } else { + // No progression + state = rawState as BlobbiState; + progressionState = 'none'; + } + // Resolve name: tag > legacy d-tag derivation > fallback const name = nameTag ?? deriveNameFromLegacyD(d); @@ -933,6 +976,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined name, stage, state, + progressionState, seed, visualTraits, isLegacy, @@ -955,6 +999,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined startIncubation: parseNumericTag(tags, 'start_incubation'), adultType: getTagValue(tags, 'adult_type'), stateStartedAt: parseNumericTag(tags, 'state_started_at'), + progressionStartedAt: parseNumericTag(tags, 'progression_started_at') ?? parseNumericTag(tags, 'state_started_at'), tasks, tasksCompleted, allTags: tags, @@ -1045,6 +1090,7 @@ export function buildEggTags( ['name', name], ['stage', 'egg'], ['state', 'active'], + ['progression_state', 'none'], ['seed', seed], ['generation', '1'], ['breeding_ready', 'false'], @@ -1094,6 +1140,8 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([ 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', // Social/flag tags 'breeding_ready', + // Progression tags (orthogonal to activity state) + 'progression_state', 'progression_started_at', // Task system tags (removed after stage transitions) 'state_started_at', 'task', 'task_completed', // Evolution tags (adult only) @@ -1497,11 +1545,15 @@ export function buildMigrationTags( // Per blobbi-tag-schema.md: Do NOT invent values for tags that don't exist const persistentTagNames = [ // State/lifecycle tags - 'stage', 'state', + 'stage', // Stat tags 'hunger', 'happiness', 'health', 'hygiene', 'energy', // Progression tags 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', + // Progression process tags + 'progression_state', 'progression_started_at', + // Legacy progression timing (also preserve for fallback) + 'state_started_at', // Social/flag tags 'generation', 'breeding_ready', // Personality tags (preserve if they exist, do NOT generate) @@ -1519,6 +1571,33 @@ export function buildMigrationTags( } } + // ─── Normalise legacy state → progression_state ─── + // If the legacy event has state='incubating' or state='evolving', migrate + // to the new split model during migration. + const legacyState = getTagValue(legacyTags, 'state'); + if (legacyState === 'incubating' || legacyState === 'evolving') { + // Set activity state to 'active' (the progression process is tracked separately) + newTags.push(['state', 'active']); + // Only set progression_state if not already set from persistentTagNames + if (!getTagValue(legacyTags, 'progression_state')) { + newTags.push(['progression_state', legacyState]); + // Migrate state_started_at → progression_started_at if present + const startedAt = getTagValue(legacyTags, 'state_started_at'); + if (startedAt && !getTagValue(legacyTags, 'progression_started_at')) { + newTags.push(['progression_started_at', startedAt]); + } + } + } else if (legacyState) { + newTags.push(['state', legacyState]); + // Ensure progression_state is set + if (!getTagValue(legacyTags, 'progression_state')) { + newTags.push(['progression_state', 'none']); + } + } else { + newTags.push(['state', 'active']); + newTags.push(['progression_state', 'none']); + } + // ALWAYS include visual trait tags - derived from seed, with legacy tag fallbacks // This ensures every migrated event has complete visual traits for consistent rendering const visualTraits = deriveVisualTraits(legacyTags, seed); diff --git a/src/blobbi/core/types/blobbi.ts b/src/blobbi/core/types/blobbi.ts index 836fa515..beb5e0fb 100644 --- a/src/blobbi/core/types/blobbi.ts +++ b/src/blobbi/core/types/blobbi.ts @@ -16,7 +16,12 @@ * ────────────────────────────────────────────────────────────────────────── */ export type BlobbiLifeStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; /* ────────────────────────────────────────────────────────────────────────── * * Visual traits diff --git a/src/blobbi/dev/useBlobbiDevUpdate.ts b/src/blobbi/dev/useBlobbiDevUpdate.ts index 51cb3948..4c57f44d 100644 --- a/src/blobbi/dev/useBlobbiDevUpdate.ts +++ b/src/blobbi/dev/useBlobbiDevUpdate.ts @@ -79,9 +79,14 @@ export function useBlobbiDevUpdate({ tagUpdates.state = updates.state; changedFields.push('state'); - // If changing to evolving/incubating, set state_started_at - if (updates.state === 'evolving' || updates.state === 'incubating') { - tagUpdates.state_started_at = now.toString(); + // If changing to evolving/incubating (legacy dev support), set progression tags + if ((updates.state as string) === 'evolving' || (updates.state as string) === 'incubating') { + // Dev editor: treat these as progression, not activity state + tagUpdates.progression_state = updates.state; + tagUpdates.progression_started_at = now.toString(); + // Override: don't put progression in the state tag + tagUpdates.state = 'active'; + changedFields.push('progression_state'); } } diff --git a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx index 787a1c2b..fcfca748 100644 --- a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx +++ b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx @@ -188,7 +188,7 @@ export function BlobbiHatchingCeremony({ // Baby companion (same visual data but stage=baby) const babyCompanion = useMemo((): BlobbiCompanion | null => { if (!eggCompanion) return null; - return { ...eggCompanion, stage: 'baby', state: 'evolving' }; + return { ...eggCompanion, stage: 'baby', state: 'active' as const, progressionState: 'evolving' as const }; }, [eggCompanion]); const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b'; @@ -211,7 +211,8 @@ export function BlobbiHatchingCeremony({ ownerPubkey: user?.pubkey ?? '', name: existingCompanion.name, stage: 'egg', - state: (existingCompanion.state === 'incubating' ? 'incubating' : 'active') as 'incubating' | 'active', + state: 'active' as const, + progressionState: (existingCompanion.progressionState === 'incubating' ? 'incubating' : 'none') as 'incubating' | 'none', seed: existingCompanion.seed ?? '', stats: { hunger: existingCompanion.stats.hunger ?? STAT_MAX, @@ -387,8 +388,9 @@ export function BlobbiHatchingCeremony({ const babyTags = updateBlobbiTags(tags, { stage: 'baby', - state: 'evolving', - state_started_at: nowStr, + state: 'active', + progression_state: 'evolving', + progression_started_at: nowStr, hunger: STAT_MAX.toString(), happiness: STAT_MAX.toString(), health: STAT_MAX.toString(), diff --git a/src/blobbi/onboarding/lib/blobbi-preview.ts b/src/blobbi/onboarding/lib/blobbi-preview.ts index 1dac1e89..c5d63974 100644 --- a/src/blobbi/onboarding/lib/blobbi-preview.ts +++ b/src/blobbi/onboarding/lib/blobbi-preview.ts @@ -35,8 +35,10 @@ export interface BlobbiEggPreview { name: string; /** Life stage - always 'egg' for previews */ stage: 'egg'; - /** Activity state - new eggs start incubating; older eggs may be 'active' */ - state: 'incubating' | 'active'; + /** Activity state */ + state: 'active'; + /** Progression state - new eggs start incubating; older eggs may be 'none' */ + progressionState: 'incubating' | 'none'; /** Visual traits derived from seed */ visualTraits: BlobbiVisualTraits; /** Default stats for a new egg */ @@ -79,7 +81,8 @@ export function generateEggPreview( seed, name, stage: 'egg', - state: 'incubating', + state: 'active', + progressionState: 'incubating', visualTraits, stats: { ...DEFAULT_EGG_STATS }, createdAt, @@ -134,6 +137,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] { ['name', preview.name], ['stage', preview.stage], ['state', preview.state], + ['progression_state', preview.progressionState], ['seed', preview.seed], ['generation', '1'], ['breeding_ready', 'false'], @@ -148,7 +152,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] { ['energy', preview.stats.energy.toString()], ['last_interaction', now], ['last_decay_at', now], - ['state_started_at', now], + ['progression_started_at', now], // Visual trait tags - ensures deterministic rendering ['base_color', visualTraits.baseColor], ['secondary_color', visualTraits.secondaryColor], @@ -192,7 +196,9 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) { adultType: undefined, // Eggs don't have adult type // Task-related fields + progressionState: preview.progressionState, stateStartedAt: preview.createdAt, + progressionStartedAt: preview.createdAt, tasks: [], tasksCompleted: [], diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index fdd0347f..8088b78f 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1026,8 +1026,8 @@ function BlobbiDashboard({ // State detection for tasks // Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state - const isIncubating = companion.state === 'incubating'; - const isEvolvingState = companion.state === 'evolving'; + const isIncubating = companion.progressionState === 'incubating'; + const isEvolvingState = companion.progressionState === 'evolving'; const isBaby = companion.stage === 'baby'; const canStartIncubation = isEgg && !isIncubating && !isEvolvingState; const canStartEvolution = isBaby && !isEvolvingState && !isIncubating; diff --git a/src/types/blobbi.ts b/src/types/blobbi.ts index 836fa515..beb5e0fb 100644 --- a/src/types/blobbi.ts +++ b/src/types/blobbi.ts @@ -16,7 +16,12 @@ * ────────────────────────────────────────────────────────────────────────── */ export type BlobbiLifeStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; /* ────────────────────────────────────────────────────────────────────────── * * Visual traits