Refine Blobbi incubation/hatch task system

- Fix Color Moment URL: espy.social -> espy.you
- Improve shape-change task detection: only counts true post-start changes
- Remove duplicate Start Incubation button, integrate into evolve/hatch button
- Extract shared incrementInteractionTaskTags helper for code reuse
- Update floating controls to show incubation action for eggs not yet incubating

The evolve/hatch button now serves as the single entry point:
- Egg (not incubating): Opens incubation dialog
- Egg (incubating): Button hidden, hatch action in HatchTasksPanel
- Baby: Evolves to adult
This commit is contained in:
filemon
2026-03-19 09:04:08 -03:00
parent 3a2571ccc6
commit 06f820d355
6 changed files with 115 additions and 84 deletions
@@ -16,6 +16,7 @@ import {
clampStat,
applyStat,
DIRECT_ACTION_METADATA,
incrementInteractionTaskTags,
type DirectAction,
} from '../lib/blobbi-action-utils';
@@ -144,32 +145,9 @@ export function useBlobbiDirectAction({
const nowStr = now.toString();
// If incubating, increment the interaction counter for hatch tasks
let updatedTags = canonical.allTags;
if (canonical.companion.state === 'incubating') {
// Get current interaction count from task tags
const interactionTag = canonical.allTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const currentCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = currentCount + 1;
// Remove old interaction task tag and add new one
updatedTags = canonical.allTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached 7
if (newCount >= 7) {
// Remove any existing task_completed for interactions
updatedTags = updatedTags.filter(tag =>
!(tag[0] === 'task_completed' && tag[1] === 'interactions')
);
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
}
const updatedTags = canonical.companion.state === 'incubating'
? incrementInteractionTaskTags(canonical.allTags).updatedTags
: canonical.allTags;
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
@@ -25,6 +25,7 @@ import {
applyStat,
hasMedicineEffectForEgg,
hasHygieneEffectForEgg,
incrementInteractionTaskTags,
type InventoryAction,
ACTION_METADATA,
} from '../lib/blobbi-action-utils';
@@ -271,32 +272,9 @@ export function useBlobbiUseInventoryItem({
const nowStr = now.toString();
// If incubating, increment the interaction counter for hatch tasks
let updatedTags = canonical.allTags;
if (canonical.companion.state === 'incubating') {
// Get current interaction count from task tags
const interactionTag = canonical.allTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const currentCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = currentCount + 1;
// Remove old interaction task tag and add new one
updatedTags = canonical.allTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached 7
if (newCount >= 7) {
// Remove any existing task_completed for interactions
updatedTags = updatedTags.filter(tag =>
!(tag[0] === 'task_completed' && tag[1] === 'interactions')
);
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
}
const updatedTags = canonical.companion.state === 'incubating'
? incrementInteractionTaskTags(canonical.allTags).updatedTags
: canonical.allTags;
const blobbiTags = updateBlobbiTags(updatedTags, {
...statsUpdate,
+15 -3
View File
@@ -240,15 +240,27 @@ export function useHatchTasks(
required: 1,
completed: hasColorMoment,
action: 'external_link',
actionTarget: 'https://espy.social/',
actionTarget: 'https://espy.you/',
actionLabel: 'Open espy',
});
// 3. Change Avatar Shape
// Requirements for completion:
// 1. There must be a kind 0 profile update with created_at >= state_started_at
// 2. The shape value must be different from what it was before incubation started
// 3. If there was no profile before start, any shape set after start counts
// 4. If shape is undefined/same after the update, task is NOT complete
const shapeBefore = data?.profileBefore ? extractShapeFromMetadata(data.profileBefore) : undefined;
const shapeAfter = data?.profileAfter ? extractShapeFromMetadata(data.profileAfter) : undefined;
// Task completes if shape changed (and there was an update after start)
const shapeChanged = data?.profileAfter && shapeAfter !== shapeBefore;
// Task completes only if:
// - There is a profile update after incubation started (profileAfter exists)
// - AND the shape after is defined (user actually set a shape)
// - AND the shape is different from before (actual change occurred)
const hasPostStartProfileUpdate = !!data?.profileAfter;
const hasNewShapeValue = shapeAfter !== undefined && shapeAfter !== '';
const shapeActuallyChanged = shapeAfter !== shapeBefore;
const shapeChanged = hasPostStartProfileUpdate && hasNewShapeValue && shapeActuallyChanged;
tasks.push({
id: 'change_shape',
name: 'Change Avatar Shape',
+2
View File
@@ -80,6 +80,7 @@ export {
type ResolvedInventoryItem,
type EggStatPreview,
type ItemUsabilityResult,
type IncrementInteractionResult,
// Constants
ACTION_TO_ITEM_TYPE,
ACTION_METADATA,
@@ -110,4 +111,5 @@ export {
hasHygieneEffectForEgg,
canUseItemForStage,
getActionForItem,
incrementInteractionTaskTags,
} from './lib/blobbi-action-utils';
@@ -545,3 +545,62 @@ export function previewCleanForEgg(
return results;
}
// ─── Interaction Task Helpers ─────────────────────────────────────────────────
/** Required interactions to complete the interactions hatch task */
const INTERACTION_TASK_REQUIRED = 7;
/**
* Result of incrementing interaction task tags
*/
export interface IncrementInteractionResult {
/** Updated tags array */
updatedTags: string[][];
/** New interaction count after increment */
newCount: number;
/** Whether the task is now complete */
isCompleted: boolean;
}
/**
* Increment the interaction task counter in the tags array.
*
* This is used by both useBlobbiDirectAction and useBlobbiUseInventoryItem
* to track progress on the "Interact with Blobbi" hatch task.
*
* Tag format:
* - Progress: ["task", "interactions:N"]
* - Completion: ["task_completed", "interactions"]
*
* @param currentTags - Current tags array from the Blobbi state
* @returns Updated tags array with incremented interaction count
*/
export function incrementInteractionTaskTags(currentTags: string[][]): IncrementInteractionResult {
// Get current interaction count from task tags
const interactionTag = currentTags.find(tag =>
tag[0] === 'task' && tag[1]?.startsWith('interactions:')
);
const currentCount = interactionTag
? parseInt(interactionTag[1].split(':')[1] || '0', 10)
: 0;
const newCount = currentCount + 1;
// Remove old interaction task tag and add new one
let updatedTags = currentTags.filter(tag =>
!(tag[0] === 'task' && tag[1]?.startsWith('interactions:'))
);
updatedTags = [...updatedTags, ['task', `interactions:${newCount}`]];
// Mark as completed if reached required count
const isCompleted = newCount >= INTERACTION_TASK_REQUIRED;
if (isCompleted) {
// Remove any existing task_completed for interactions (avoid duplicates)
updatedTags = updatedTags.filter(tag =>
!(tag[0] === 'task_completed' && tag[1] === 'interactions')
);
updatedTags = [...updatedTags, ['task_completed', 'interactions']];
}
return { updatedTags, newCount, isCompleted };
}
+31 -29
View File
@@ -616,7 +616,7 @@ interface DashboardShellProps {
function DashboardShell({ children }: DashboardShellProps) {
return (
<main className="min-h-[calc(100vh-4rem)] p-4 pb-20">
<div className="container mx-auto max-w-4xl">
<div className="mx-auto w-full max-w-4xl sm:px-6 lg:px-8">
{/* Frosted glass dashboard container */}
<div className="relative rounded-2xl bg-card/80 backdrop-blur-sm border border-border overflow-hidden min-h-[70vh]">
{/* Subtle decorative gradient overlay */}
@@ -935,11 +935,23 @@ function BlobbiDashboard({
onSetAsCompanion={() => console.log('TODO: set as companion')}
onTakePhoto={() => console.log('TODO: take photo')}
onOpenPiP={() => console.log('TODO: open PiP')}
onEvolve={isEgg ? onHatch : onEvolve}
isTransitioning={isHatching || isEvolving}
onEvolve={
// For eggs not yet incubating: show incubation dialog
// For eggs incubating with all tasks complete: hatch action handled in HatchTasksPanel
// For baby: evolve to adult
canStartIncubation
? () => setShowIncubationDialog(true)
: isEgg
? onHatch
: onEvolve
}
isTransitioning={isHatching || isEvolving || isStartingIncubation}
onInfo={() => setShowInfoModal(true)}
// Hide hatch button when incubating (hatch is in HatchTasksPanel instead)
// Hide hatch button only when actively incubating (hatch is in HatchTasksPanel instead)
// Show button for eggs not yet incubating (to start incubation)
hideEvolveButton={isIncubating}
// When canStartIncubation is true, the button triggers incubation start
isIncubationAction={canStartIncubation}
/>
{/* Blobbi Name */}
@@ -1049,20 +1061,7 @@ function BlobbiDashboard({
</div>
)}
{/* Start Incubation Button - for eggs not yet incubating */}
{canStartIncubation && (
<div className="mt-6 flex justify-center">
<Button
onClick={() => setShowIncubationDialog(true)}
variant="outline"
size="lg"
className="gap-2 border-primary/30 hover:border-primary/50 hover:bg-primary/5"
>
<Egg className="size-5" />
Start Incubation
</Button>
</div>
)}
{/* Inline Activity Area - inside padded container for proper spacing above bottom bar */}
{inlineActivity.type === 'music' && (
@@ -1275,7 +1274,6 @@ interface FloatingActionDef {
}
interface BlobbiDashboardFloatingControlsProps {
/** Current Blobbi's life stage - affects evolve button icon */
stage: 'egg' | 'baby' | 'adult';
onBack?: () => void;
onSetAsCompanion: () => void;
@@ -1287,14 +1285,17 @@ interface BlobbiDashboardFloatingControlsProps {
onInfo: () => void;
/** Whether to hide the evolve/hatch button (e.g., when incubating) */
hideEvolveButton?: boolean;
/** Whether the button should show incubation action (for eggs not yet incubating) */
isIncubationAction?: boolean;
}
/**
* Get the appropriate icon for the evolve/hatch button based on stage.
* - egg stage: Egg icon (hatching action)
* Get the appropriate icon for the evolve/hatch button based on stage and incubation state.
* - egg stage (not incubating): Egg icon (start incubation action)
* - egg stage (incubating): Egg icon (hatching action)
* - baby/adult stages: Sparkles icon (evolution/transformation)
*/
function getEvolveIcon(stage: 'egg' | 'baby' | 'adult'): React.ReactNode {
function getEvolveIcon(stage: 'egg' | 'baby' | 'adult', _isIncubationAction?: boolean): React.ReactNode {
if (stage === 'egg') {
return <Egg className="size-4" />;
}
@@ -1303,11 +1304,11 @@ function getEvolveIcon(stage: 'egg' | 'baby' | 'adult'): React.ReactNode {
}
/**
* Get the appropriate tooltip for the evolve/hatch button based on stage.
* Get the appropriate tooltip for the evolve/hatch button based on stage and incubation state.
*/
function getEvolveTooltip(stage: 'egg' | 'baby' | 'adult'): string {
function getEvolveTooltip(stage: 'egg' | 'baby' | 'adult', isIncubationAction?: boolean): string {
if (stage === 'egg') {
return 'Hatch';
return isIncubationAction ? 'Start Incubation' : 'Hatch';
}
return 'Evolve';
}
@@ -1326,6 +1327,7 @@ function BlobbiDashboardFloatingControls({
isTransitioning = false,
onInfo,
hideEvolveButton = false,
isIncubationAction = false,
}: BlobbiDashboardFloatingControlsProps) {
// Left-side buttons
const leftButtons: FloatingActionDef[] = [
@@ -1365,12 +1367,12 @@ function BlobbiDashboardFloatingControls({
},
];
// Evolve/Hatch button (emphasized, at the bottom of right cluster)
// Icon and tooltip are stage-aware
// Evolve/Hatch/Incubation button (emphasized, at the bottom of right cluster)
// Icon and tooltip are stage-aware and incubation-aware
const evolveButton: FloatingActionDef = {
id: 'evolve',
icon: getEvolveIcon(stage),
tooltip: getEvolveTooltip(stage),
icon: getEvolveIcon(stage, isIncubationAction),
tooltip: getEvolveTooltip(stage, isIncubationAction),
onClick: onEvolve,
variant: 'accent',
};