Spaces:
Running
Running
File size: 10,167 Bytes
e2c452c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | // 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));
|