// Sightfern core: the field-journal rules, pure functions (no DOM, no camera), shared by the page and the tests. // A sighting = an animal the on-device detector found in the player's own photo (or one they tagged by hand), its // coat/plumage colour read from the pixels inside the detection box, and the local time of day. // Rarity is a game rule (how hard a colour is to find in everyday streets and parks), not a biological claim. // (c) 2026 CyberMax. All rights reserved. /** Albums. Free: Cats, Dogs, Birds. Fun Pass: Farm and Wild (the other animals the detector knows). */ export const ALBUMS = { cats: { name: 'Cats', icon: '🐈', species: ['cat'], pro: false }, dogs: { name: 'Dogs', icon: '🐕', species: ['dog'], pro: false }, birds: { name: 'Birds', icon: '🐦', species: ['bird'], pro: false }, farm: { name: 'Farm', icon: '🐄', species: ['horse', 'sheep', 'cow'], pro: true }, wild: { name: 'Wild & zoo', icon: '🦒', species: ['bear', 'elephant', 'zebra', 'giraffe'], pro: true }, }; export const SPECIES = Object.fromEntries(Object.entries(ALBUMS).flatMap(([album, a]) => a.species.map((s) => [s, album]))); export const LABEL = { cat: 'Cat', dog: 'Dog', bird: 'Bird', horse: 'Horse', sheep: 'Sheep', cow: 'Cow', bear: 'Bear', elephant: 'Elephant', zebra: 'Zebra', giraffe: 'Giraffe' }; export const COLOURS = { black: { name: 'Black', hex: '#1c1c1f' }, white: { name: 'White', hex: '#f2f0ea' }, grey: { name: 'Grey', hex: '#8d9097' }, brown: { name: 'Brown', hex: '#7a4e2d' }, ginger: { name: 'Ginger', hex: '#d9792b' }, golden: { name: 'Golden', hex: '#e2b55a' }, patched: { name: 'Patched', hex: 'linear-gradient(135deg,#1c1c1f 0 33%,#f2f0ea 33% 66%,#d9792b 66%)' }, red: { name: 'Red', hex: '#c8352e' }, yellow: { name: 'Yellow', hex: '#e8c931' }, green: { name: 'Green', hex: '#4f9a4a' }, blue: { name: 'Blue', hex: '#3d6fd1' }, }; /** Slots per species: which colours can fill the page, and how rare each is (0 common … 3 epic). */ const MAMMAL = { black: 1, white: 1, grey: 0, brown: 0, ginger: 1, golden: 2, patched: 2 }; export const SLOTS = { cat: { ...MAMMAL, golden: 3, patched: 2 }, dog: { ...MAMMAL, ginger: 2, grey: 1 }, bird: { black: 0, grey: 0, brown: 0, white: 1, red: 2, yellow: 2, green: 3, blue: 3 }, horse: { brown: 0, black: 1, white: 2, grey: 1, golden: 2, patched: 2 }, sheep: { white: 0, grey: 1, brown: 1, black: 2 }, cow: { patched: 0, black: 0, brown: 0, white: 1, golden: 2 }, bear: { brown: 0, black: 1, white: 3 }, elephant: { grey: 0 }, zebra: { patched: 0 }, giraffe: { patched: 0, golden: 1 }, }; export const RARITY = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary']; export const TIMES = [ { id: 'dawn', name: 'Dawn', from: 5, to: 8, icon: '🌅' }, { id: 'day', name: 'Day', from: 8, to: 17, icon: '☀️' }, { id: 'dusk', name: 'Dusk', from: 17, to: 21, icon: '🌇' }, { id: 'night', name: 'Night', from: 21, to: 29, icon: '🌙' }, // 21:00-04:59 ]; export function timeBand(hour) { const h = hour < 5 ? hour + 24 : hour; return (TIMES.find((t) => h >= t.from && h < t.to) || TIMES[1]).id; } // ------------------------------------------------------------------ colour from pixels /** RGB 0-255 -> { h 0-360, s 0-1, l 0-1 } */ export function hsl(r, g, b) { r /= 255; g /= 255; b /= 255; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), l = (mx + mn) / 2, d = mx - mn; if (!d) return { h: 0, s: 0, l }; const s = d / (1 - Math.abs(2 * l - 1)); let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4; h = (h * 60 + 360) % 360; return { h, s, l }; } /** One pixel -> colour bucket. */ export function bucket(r, g, b) { const { h, s, l } = hsl(r, g, b); if (l < 0.16) return 'black'; if (l > 0.82 && s < 0.35) return 'white'; if (s < 0.16) return l > 0.62 ? 'white' : l < 0.25 ? 'black' : 'grey'; if (h < 12 || h >= 340) return s > 0.45 && l > 0.25 ? 'red' : 'brown'; if (h < 40) return l < 0.38 || s < 0.38 ? 'brown' : 'ginger'; if (h < 58) return l > 0.55 ? 'golden' : s > 0.55 ? 'yellow' : 'brown'; if (h < 70) return 'yellow'; if (h < 170) return 'green'; if (h < 260) return 'blue'; return 'grey'; } /** * The animal's colour from RGBA pixels of the detection box (centre 60%, every 4th pixel): the biggest bucket, or * "patched" when two contrasting buckets each cover 22%+ (calico, piebald, tuxedo). Only colours the species' page * has are chosen; the rest fall back to the nearest neutral. */ export function coatColour(rgba, w, h, species) { const slots = SLOTS[species] || MAMMAL; const n = {}; let total = 0; const x0 = Math.floor(w * 0.2), x1 = Math.ceil(w * 0.8), y0 = Math.floor(h * 0.2), y1 = Math.ceil(h * 0.8); for (let y = y0; y < y1; y += 2) for (let x = x0; x < x1; x += 2) { const i = (y * w + x) * 4; const k = bucket(rgba[i], rgba[i + 1], rgba[i + 2]); n[k] = (n[k] || 0) + 1; total++; } if (!total) return Object.keys(slots)[0]; const ranked = Object.entries(n).sort((a, b) => b[1] - a[1]); const share = (k) => (n[k] || 0) / total; const CONTRAST = { black: ['white', 'ginger', 'golden'], white: ['black', 'ginger', 'brown'], ginger: ['black', 'white'], brown: ['white'], golden: ['black', 'white'] }; if ('patched' in slots) { const [a, b] = ranked; if (a && b && share(a[0]) >= 0.22 && share(b[0]) >= 0.22 && (CONTRAST[a[0]] || []).includes(b[0])) return 'patched'; } for (const [k] of ranked) if (k in slots) return k; const fallback = { red: 'brown', yellow: 'golden', green: 'grey', blue: 'grey', ginger: 'brown', golden: 'brown', patched: 'brown' }; for (const [k] of ranked) { const f = fallback[k] || k; if (f in slots) return f; } return Object.keys(slots)[0]; } /** Rarity of a sighting: the slot's rarity, +1 at night (night spotters get a moon stamp), capped at Legendary. */ export function rarityOf(species, colour, time) { const base = (SLOTS[species] || {})[colour] ?? 0; return Math.min(4, base + (time === 'night' ? 1 : 0)); } /** A new sighting record (pure; the page adds the photo blob id). */ export function sighting({ species, colour, at = new Date(), manual = false, score = null, coarse = null }) { const time = timeBand(at.getHours()); return { id: `${at.getTime().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`, species, album: SPECIES[species], colour, time, rarity: rarityOf(species, colour, time), day: localDay(at), at: at.toISOString(), manual, score: score == null ? null : Math.round(score * 100) / 100, ...(coarse ? { where: coarse } : {}), }; } export const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; /** Opt-in location is rounded to 1 decimal place (about 11 km) before it is ever stored. */ export const coarsen = (lat, lon) => ({ lat: Math.round(lat * 10) / 10, lon: Math.round(lon * 10) / 10 }); // ------------------------------------------------------------------ album progress, streak, badges export function slotsOf(album) { return ALBUMS[album].species.flatMap((sp) => Object.keys(SLOTS[sp]).map((c) => `${sp}:${c}`)); } export function progress(sightings, album) { const have = new Set(sightings.filter((s) => s.album === album).map((s) => `${s.species}:${s.colour}`)); const all = slotsOf(album); return { found: all.filter((k) => have.has(k)).length, total: all.length, have }; } export function streak(sightings, today) { const days = new Set(sightings.map((s) => s.day)); const d = new Date(`${today}T12:00:00`); if (!days.has(today)) d.setDate(d.getDate() - 1); let k = 0; while (days.has(localDay(d))) { k++; d.setDate(d.getDate() - 1); } return k; } export const BADGES = [ { id: 'first', icon: '👀', name: 'First sighting', test: (s) => s.length >= 1 }, { id: 'ten', icon: '📒', name: '10 sightings', test: (s) => s.length >= 10 }, { id: 'fifty', icon: '📚', name: '50 sightings', test: (s) => s.length >= 50 }, { id: 'trio', icon: '🐾', name: 'A cat, a dog and a bird', test: (s) => ['cat', 'dog', 'bird'].every((sp) => s.some((x) => x.species === sp)) }, { id: 'night', icon: '🌙', name: 'Night spotter', test: (s) => s.some((x) => x.time === 'night') }, { id: 'dawn', icon: '🌅', name: 'Early bird', test: (s) => s.some((x) => x.time === 'dawn') }, { id: 'rare', icon: '💎', name: 'Rare find', test: (s) => s.some((x) => x.rarity >= 2) }, { id: 'epic', icon: '🌟', name: 'Epic find', test: (s) => s.some((x) => x.rarity >= 3) }, { id: 'streak3', icon: '🔥', name: '3-day streak', test: (s, st) => st >= 3 }, { id: 'streak7', icon: '🏅', name: '7-day streak', test: (s, st) => st >= 7 }, { id: 'catpage', icon: '🐈', name: 'Full cat page', test: (s) => progress(s, 'cats').found === progress(s, 'cats').total }, { id: 'birdpage', icon: '🐦', name: 'Full bird page', test: (s) => progress(s, 'birds').found === progress(s, 'birds').total }, ]; export function badges(sightings, today) { const st = streak(sightings, today); return BADGES.map((b) => ({ ...b, got: !!b.test(sightings, st) })); } /** Today's quest, the same for everyone on a date: spot a given animal (or colour) today. */ export function quest(day) { let h = 0x811c9dc5; for (let i = 0; i < day.length; i++) { h ^= day.charCodeAt(i); h = Math.imul(h, 0x01000193); } h >>>= 0; const Q = [ { species: 'bird', text: 'Spot any bird' }, { species: 'cat', text: 'Spot any cat' }, { species: 'dog', text: 'Spot any dog' }, { species: 'cat', colour: 'black', text: 'Spot a black cat' }, { species: 'dog', colour: 'white', text: 'Spot a white dog' }, { species: 'bird', colour: 'black', text: 'Spot a black bird' }, { species: 'cat', colour: 'ginger', text: 'Spot a ginger cat' }, { species: 'dog', colour: 'brown', text: 'Spot a brown dog' }, { species: 'bird', colour: 'grey', text: 'Spot a grey bird' }, ]; return Q[h % Q.length]; } export const questDone = (q, sightings, day) => sightings.some((s) => s.day === day && s.species === q.species && (!q.colour || s.colour === q.colour));