Files
eranos/src/blobbi/dev/useBlobbiDevUpdate.ts
T
filemon bf6788c141 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.
2026-04-18 07:03:52 -03:00

201 lines
7.0 KiB
TypeScript

/**
* useBlobbiDevUpdate - DEV MODE ONLY
*
* Hook for applying direct Blobbi state updates during development.
* Uses the standard update/publish flow to ensure state consistency.
*
* IMPORTANT: This hook should only be used in development mode.
*/
import { useMutation } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { toast } from '@/hooks/useToast';
import type { BlobbiCompanion, BlobbiStage } from '@/blobbi/core/lib/blobbi';
import { KIND_BLOBBI_STATE, updateBlobbiTags, getLocalDayString } from '@/blobbi/core/lib/blobbi';
import type { BlobbiDevUpdates } from './BlobbiDevEditor';
// ─── Types ────────────────────────────────────────────────────────────────────
interface UseBlobbiDevUpdateParams {
companion: BlobbiCompanion | null;
/** Update companion event in local cache */
updateCompanionEvent: (event: NostrEvent) => void;
}
interface DevUpdateResult {
previousStage: BlobbiStage;
newStage: BlobbiStage;
changedFields: string[];
}
// ─── Content Helper ───────────────────────────────────────────────────────────
/**
* Generate the content string for a Blobbi at a given stage.
* Format: "{name} is a {stage} Blobbi."
*/
function generateBlobbiContent(name: string, stage: BlobbiStage): string {
const article = stage === 'egg' ? 'an' : 'a';
return `${name} is ${article} ${stage} Blobbi.`;
}
// ─── Hook Implementation ──────────────────────────────────────────────────────
export function useBlobbiDevUpdate({
companion,
updateCompanionEvent,
}: UseBlobbiDevUpdateParams) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
return useMutation({
mutationFn: async (updates: BlobbiDevUpdates): Promise<DevUpdateResult> => {
// ─── Validation ───
if (!user?.pubkey) {
throw new Error('You must be logged in');
}
if (!companion) {
throw new Error('No companion selected');
}
// ─── Build Tag Updates ───
const tagUpdates: Record<string, string> = {};
const changedFields: string[] = [];
const now = Math.floor(Date.now() / 1000);
// Stage change
if (updates.stage !== undefined) {
tagUpdates.stage = updates.stage;
changedFields.push('stage');
}
// State change
if (updates.state !== undefined) {
tagUpdates.state = updates.state;
changedFields.push('state');
// 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');
}
}
// Adult type (only valid for adult stage)
const effectiveStage = updates.stage ?? companion.stage;
if (effectiveStage === 'adult' && updates.adultType !== undefined) {
tagUpdates.adult_type = updates.adultType;
changedFields.push('adult_type');
}
// Stats
if (updates.stats) {
if (updates.stats.hunger !== undefined) {
tagUpdates.hunger = updates.stats.hunger.toString();
changedFields.push('hunger');
}
if (updates.stats.happiness !== undefined) {
tagUpdates.happiness = updates.stats.happiness.toString();
changedFields.push('happiness');
}
if (updates.stats.health !== undefined) {
tagUpdates.health = updates.stats.health.toString();
changedFields.push('health');
}
if (updates.stats.hygiene !== undefined) {
tagUpdates.hygiene = updates.stats.hygiene.toString();
changedFields.push('hygiene');
}
if (updates.stats.energy !== undefined) {
tagUpdates.energy = updates.stats.energy.toString();
changedFields.push('energy');
}
}
// Other properties
if (updates.experience !== undefined) {
tagUpdates.experience = updates.experience.toString();
changedFields.push('experience');
}
if (updates.careStreak !== undefined) {
tagUpdates.care_streak = updates.careStreak.toString();
// Also update the streak metadata when manually setting streak
tagUpdates.care_streak_last_at = now.toString();
tagUpdates.care_streak_last_day = getLocalDayString();
changedFields.push('care_streak');
}
if (updates.breedingReady !== undefined) {
tagUpdates.breeding_ready = updates.breedingReady ? 'true' : 'false';
changedFields.push('breeding_ready');
}
if (updates.generation !== undefined) {
tagUpdates.generation = updates.generation.toString();
changedFields.push('generation');
}
// Always update last_interaction and last_decay_at
tagUpdates.last_interaction = now.toString();
tagUpdates.last_decay_at = now.toString();
// ─── Merge Tags ───
const newTags = updateBlobbiTags(companion.allTags, tagUpdates);
// ─── Generate Content ───
const newStage = updates.stage ?? companion.stage;
const content = generateBlobbiContent(companion.name, newStage);
// ─── Publish Event ───
if (import.meta.env.DEV) {
console.log('[DevUpdate] Publishing Blobbi update:', {
changedFields,
tagUpdates,
stage: newStage,
});
}
const event = await publishEvent({
kind: KIND_BLOBBI_STATE,
content,
tags: newTags,
});
// ─── Update Caches ───
updateCompanionEvent(event);
return {
previousStage: companion.stage,
newStage,
changedFields,
};
},
onSuccess: ({ changedFields, previousStage, newStage }) => {
const stageChanged = previousStage !== newStage;
const description = stageChanged
? `Stage: ${previousStage}${newStage}. Updated: ${changedFields.join(', ')}`
: `Updated: ${changedFields.join(', ')}`;
toast({
title: 'Blobbi state updated (DEV)',
description,
});
},
onError: (error: Error) => {
console.error('[DevUpdate] Failed:', error);
toast({
title: 'Failed to update Blobbi',
description: error.message,
variant: 'destructive',
});
},
});
}