sightfern / core.js
CyberMax-tools's picture
Deploy sightfern
e2c452c verified
Raw History Blame Contribute Delete
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));