Files
HaBit/lib/avatar-customization-storage.ts
2026-05-24 08:49:37 -06:00

316 lines
8.7 KiB
TypeScript

import { DEFAULT_STARTING_COINS } from "@/lib/coins";
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import {
hexToHsl,
hslToHex,
type HSL,
} from "@/lib/character/color-utils";
import {
assertValidVariants,
normalizeVariantId,
normalizeVariants,
} from "@/lib/character/variant-validation";
import {
buildDefaultVariants,
DEFAULT_GRAY_COLOR,
LAYER_DEFAULT_COLORS,
NONE_VARIANT_ID,
} from "@/lib/character/presets";
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
import {
DEFAULT_ROOM_BACKGROUND,
normalizeRoomBackgroundId,
type RoomBackgroundId,
} from "@/lib/room-backgrounds";
import { createClient } from "@/lib/supabase/client";
export type AvatarCustomization = {
name: string;
colors: LayerColorState;
variants: LayerVariantState;
customized: boolean;
equippedItems: string[];
roomBackground: RoomBackgroundId;
};
type AvatarStateRow = {
avatar_name: string | null;
skin_color: string | null;
pants_style: string | null;
pants_color: string | null;
shoe_style: string | null;
shoe_color: string | null;
torso_style: string | null;
torso_color: string | null;
eye_type: string | null;
eye_color: string | null;
head_style: string | null;
head_color: string | null;
avatar_customized: boolean | null;
equipped_items: string[] | null;
room_background: string | null;
};
export const DEFAULT_AVATAR_NAME = "My Bit";
const DEFAULT_HEX = DEFAULT_GRAY_COLOR.hex;
const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
export const defaultAvatarCustomization: AvatarCustomization = {
name: DEFAULT_AVATAR_NAME,
colors: { ...LAYER_DEFAULT_COLORS },
variants: buildDefaultVariants(),
customized: false,
equippedItems: [],
roomBackground: DEFAULT_ROOM_BACKGROUND,
};
const avatarSelectFields =
"avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items, room_background";
const avatarSelectFieldsLegacy =
"avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items";
function isMissingRoomBackgroundColumn(message: string) {
return /room_background/i.test(message);
}
function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL {
if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) {
return { ...fallback };
}
try {
return hexToHsl(hex);
} catch {
return { ...fallback };
}
}
function normalizeHexColor(color: HSL, fallback: HSL) {
const hex = hslToHex(color);
if (!HEX_COLOR_PATTERN.test(hex)) {
return hslToHex(fallback);
}
return hex.toLowerCase();
}
export function mapRowToAvatarCustomization(
row: AvatarStateRow | null | undefined,
): AvatarCustomization {
if (!row) {
return { ...defaultAvatarCustomization };
}
const defaults = defaultAvatarCustomization;
return {
name: row.avatar_name?.trim() || DEFAULT_AVATAR_NAME,
colors: {
skin: colorFromHex(row.skin_color, defaults.colors.skin),
pants: colorFromHex(row.pants_color, defaults.colors.pants),
shoes: colorFromHex(row.shoe_color, defaults.colors.shoes),
torso: colorFromHex(row.torso_color, defaults.colors.torso),
eyes: colorFromHex(row.eye_color, defaults.colors.eyes),
head: colorFromHex(row.head_color, defaults.colors.head),
},
variants: {
skin: "skin-1",
pants: normalizeVariantId("pants", row.pants_style),
shoes: normalizeVariantId("shoes", row.shoe_style),
torso: normalizeVariantId("torso", row.torso_style),
eyes: normalizeVariantId("eyes", row.eye_type),
head: normalizeVariantId("head", row.head_style),
},
customized: row.avatar_customized === true,
equippedItems: row.equipped_items ?? [],
roomBackground: normalizeRoomBackgroundId(row.room_background),
};
}
export function mapAvatarCustomizationToRow(customization: AvatarCustomization) {
const normalizedVariants = normalizeVariants(customization.variants);
assertValidVariants(normalizedVariants);
const { colors, name } = customization;
const defaults = defaultAvatarCustomization.colors;
return {
avatar_name: name.trim() || DEFAULT_AVATAR_NAME,
skin_color: normalizeHexColor(colors.skin, defaults.skin),
pants_style: normalizedVariants.pants,
pants_color: normalizeHexColor(colors.pants, defaults.pants),
shoe_style: normalizedVariants.shoes,
shoe_color: normalizeHexColor(colors.shoes, defaults.shoes),
torso_style: normalizedVariants.torso,
torso_color: normalizeHexColor(colors.torso, defaults.torso),
eye_type: normalizedVariants.eyes,
eye_color: normalizeHexColor(colors.eyes, defaults.eyes),
head_style: normalizedVariants.head,
head_color: normalizeHexColor(colors.head, defaults.head),
room_background: normalizeRoomBackgroundId(customization.roomBackground),
avatar_customized: true,
updated_at: new Date().toISOString(),
};
}
async function getAuthenticatedUserId() {
const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return null;
}
return user.id;
}
export async function getAvatarCustomization(): Promise<AvatarCustomization> {
const userId = await getAuthenticatedUserId();
if (!userId) {
return { ...defaultAvatarCustomization };
}
const supabase = createClient();
let { data, error } = await supabase
.from("avatar_state")
.select(avatarSelectFields)
.eq("user_id", userId)
.maybeSingle();
if (error && isMissingRoomBackgroundColumn(error.message)) {
const legacy = await supabase
.from("avatar_state")
.select(avatarSelectFieldsLegacy)
.eq("user_id", userId)
.maybeSingle();
if (legacy.error) {
throw new Error(legacy.error.message);
}
data = legacy.data
? ({ ...legacy.data, room_background: null } as AvatarStateRow)
: null;
error = null;
} else if (error) {
throw new Error(error.message);
}
if (!data) {
const { error: insertError } = await supabase.from("avatar_state").insert({
user_id: userId,
coins: DEFAULT_STARTING_COINS,
avatar_name: DEFAULT_AVATAR_NAME,
skin_color: DEFAULT_HEX,
pants_style: NONE_VARIANT_ID,
pants_color: DEFAULT_HEX,
shoe_style: NONE_VARIANT_ID,
shoe_color: DEFAULT_HEX,
torso_style: NONE_VARIANT_ID,
torso_color: DEFAULT_HEX,
eye_type: NONE_VARIANT_ID,
eye_color: DEFAULT_HEX,
head_style: NONE_VARIANT_ID,
head_color: DEFAULT_HEX,
});
if (insertError) {
throw new Error(insertError.message);
}
return { ...defaultAvatarCustomization };
}
return mapRowToAvatarCustomization(data as AvatarStateRow);
}
export async function saveAvatarCustomization(
customization: AvatarCustomization,
options?: { completeOnboarding?: boolean },
) {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to save your avatar.");
}
const supabase = createClient();
const payload = mapAvatarCustomizationToRow(customization);
let { error } = await supabase
.from("avatar_state")
.update(payload)
.eq("user_id", userId);
if (error && isMissingRoomBackgroundColumn(error.message)) {
const { room_background, ...legacyPayload } = payload;
void room_background;
const retry = await supabase
.from("avatar_state")
.update(legacyPayload)
.eq("user_id", userId);
error = retry.error;
}
if (error) {
throw new Error(error.message);
}
if (options?.completeOnboarding) {
const { error: profileError } = await supabase
.from("profiles")
.update({ onboarding_complete: true })
.eq("id", userId);
if (profileError) {
throw new Error(profileError.message);
}
const { error: authError } = await supabase.auth.updateUser({
data: {
onboarding_completed: true,
onboarding_quiz_completed: true,
},
});
if (authError) {
throw new Error(authError.message);
}
}
notifyHabitPetDataUpdated();
return customization;
}
export async function saveAvatarName(name: string) {
const userId = await getAuthenticatedUserId();
if (!userId) {
throw new Error("You must be signed in to rename your avatar.");
}
const trimmed = name.trim() || DEFAULT_AVATAR_NAME;
const supabase = createClient();
const { error } = await supabase
.from("avatar_state")
.update({
avatar_name: trimmed,
updated_at: new Date().toISOString(),
})
.eq("user_id", userId);
if (error) {
throw new Error(error.message);
}
notifyHabitPetDataUpdated();
return trimmed;
}