Spaces:
Running
Running
Download core.js from CyberMax-tools/sightfern: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/CyberMax-tools/sightfern/resolve/main/core.js
- Command line
-
hf download hf://spaces/CyberMax-tools/sightfern/core.js
-
curl -L -o core.js https://huggingface.co/spaces/CyberMax-tools/sightfern/resolve/main/core.js
10.2 kB
| // 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)); | |