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));