// 赛博宝宝前端（2026-10-21 从 index.html 的内联 <script type="text/babel"> 原样搬出）：墨尔本日出日落（MELB-SUN 块，跟后端逐字一致）、天气、月相、七夕、烟花、宝宝贴图。
// index.html 按顺序加载 app/*.jsx，前面文件定义的全局（函数、常量、组件）后面的文件直接用。
// ═══════════════ 天气/时间 ═══════════════
// rainNearby：墨尔本过去 12 小时或未来 12 小时里任意一小时下过/会下雨。
// 给 C4 这种「雨天触发」的随机剧情用：摸头 4 小时一次、再要求摸的那一秒正好在下雨，
// 小玉从 06-11 播完 C3 起两个半月没等到过 C4（2026-08-28 她提的：改成下雨前后 12 小时都算）
// ⚠️ 2026-09-06 夜间默认修复：昼夜判定必须用 Intl 时区换算，不能再用 (getUTCHours()+10)%24——
// 10 月墨尔本进入夏令时后固定偏移就错了。
// 2026-10-07 起「夜」= 墨尔本真实日出日落（open-meteo daily），拿不到就按月份表兜底（见 MELB-SUN 块），不再写死 18:00–06:00。
// MELB-SUN:BEGIN（前端 index.html 与 api/worker.js 两处必须逐字一致，api/sunset.test.mjs 会比对）
// 墨尔本每月中旬日出/日落的当地钟表时间（分钟数，已含夏令时：4 月初—10 月初 AEST，其余 AEDT）。
// ⚠️ 每月 15 日的真实值（open-meteo archive 2025 取的，4–9 月已换回 AEST），仅在拿不到 open-meteo 当天真实日出日落时兜底（月内不插值）。
// 数值依据：墨尔本（-37.81, 144.96）各月 15 日前后的公布日出日落时刻取整。
const MELB_SUN_TABLE = [
  [6 * 60 + 14, 20 * 60 + 43],  // 1 月
  [6 * 60 + 49, 20 * 60 + 18],  // 2 月
  [7 * 60 + 18, 19 * 60 + 39],  // 3 月（4 月初换回 AEST）
  [6 * 60 + 46, 17 * 60 + 53],  // 4 月（AEST）
  [7 * 60 + 13, 17 * 60 + 19],  // 5 月
  [7 * 60 + 33, 17 * 60 + 7],  // 6 月
  [7 * 60 + 32, 17 * 60 + 19],  // 7 月
  [7 * 60 + 5, 17 * 60 + 44],  // 8 月
  [6 * 60 + 20, 18 * 60 + 10],  // 9 月
  [6 * 60 + 35, 19 * 60 + 37],  // 10 月（AEDT，月初换夏令时）
  [6 * 60 + 0, 20 * 60 + 10],  // 11 月
  [5 * 60 + 52, 20 * 60 + 38],  // 12 月
];
// 墨尔本当地的 { month(1-12), minutes(当天第几分钟), ymd }，用 Intl 换算时区，不手算偏移
function melbLocalParts(date) {
  const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: 'Australia/Melbourne', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(date).map(x => [x.type, x.value]));
  return { month: Number(p.month), minutes: Number(p.hour) * 60 + Number(p.minute), ymd: `${p.year}-${p.month}-${p.day}` };
}
// 现在是不是夜：sun 为 { riseMin, setMin }（真实当天值）；没有就按月份表兜底。⛔ 不再写死 18/6
function melbIsNight(date = new Date(), sun = null) {
  const t = melbLocalParts(date);
  const [riseMin, setMin] = sun || MELB_SUN_TABLE[t.month - 1];
  return t.minutes < riseMin || t.minutes >= setMin;
}
// MELB-SUN:END
// open-meteo 返回的当天真实日出日落（"YYYY-MM-DDTHH:mm" 墨尔本当地时间）→ { ymd, sun:[riseMin,setMin] }；日期不是今天就不用
let _sunCache = null;
function parseSunTimes(sunrise, sunset) {
  const toMin = (s) => { const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})/.exec(s || ''); return m ? { ymd: m[1], min: Number(m[2]) * 60 + Number(m[3]) } : null; };
  const a = toMin(sunrise), b = toMin(sunset);
  return a && b && a.ymd === b.ymd && a.min < b.min ? { ymd: a.ymd, sun: [a.min, b.min] } : null;
}
function isMelbNight() {
  const now = new Date();
  const sun = _sunCache && _sunCache.ymd === melbLocalParts(now).ymd ? _sunCache.sun : null;
  return melbIsNight(now, sun);
}
let _weatherCache = { state: isMelbNight() ? 'night' : 'cloudy', rainNearby: false, windKmh: null, ts: 0 };
function rainWithin12h() { return !!_weatherCache.rainNearby; }
async function checkWeather() {
  if (Date.now() - _weatherCache.ts < 1800000) return _weatherCache.state;
  const fallbackState = isMelbNight() ? 'night' : 'cloudy';
  try {
    // open-meteo 无响应时不能让房间停在白天（2026-09-06 皮皮夜里23:50还是白天景）：
    // 3 秒拿不到就降级成按墨尔本时间判的昼夜，跟 worker.js getWeatherState 同款超时。
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 3000);
    let r;
    try {
      r = await fetch('https://api.open-meteo.com/v1/forecast?latitude=-37.81&longitude=144.96&current=weather_code,wind_speed_10m,wind_gusts_10m&hourly=weather_code&daily=sunrise,sunset&forecast_days=1&past_hours=12&forecast_hours=12&timezone=Australia%2FMelbourne', { signal: ctrl.signal });
    } finally {
      clearTimeout(timer);
    }
    const d = await r.json();
    _sunCache = parseSunTimes(d?.daily?.sunrise?.[0], d?.daily?.sunset?.[0]) || _sunCache;
    const isNight = isMelbNight();   // 拿到真实日出日落后重算
    const code = d?.current?.weather_code || 0;
    const isRainCode = (c) => c >= 51 && c <= 99;
    const isRaining = isRainCode(code);
    const isCloudy = !isRaining && code >= 2;
    let state;
    if (isNight) state = isRaining ? 'night_rain' : 'night';
    else if (isRaining) state = 'rainy';
    else if (isCloudy) state = 'cloudy';
    else state = 'sunny';
    const hourlyCodes = d?.hourly?.weather_code || [];
    const rainNearby = isRaining || hourlyCodes.some(isRainCode);
    // 院子的风（2026-10-05）：真实风速 km/h，院子叶丛的摆幅/快慢跟着走；拿不到就是 null，院子退回按天气状态的默认风力
    const windKmh = yardWindKmh(d?.current?.wind_speed_10m, d?.current?.wind_gusts_10m);
    _weatherCache = { state, rainNearby, windKmh, sunrise: _sunCache?.sun[0] ?? null, sunset: _sunCache?.sun[1] ?? null, ts: Date.now() };
  } catch(e) {
    _weatherCache = { state: fallbackState, rainNearby: false, windKmh: null, ts: Date.now() };
  }
  return _weatherCache.state;
}

// ── 院子里的月亮（2026-08-11 小玉点的：要真实月相）──────────────────────────
// ⚠️ 别用"固定历元 + 29.53058867 天匀速"那种均值朔望公式：拿 2026-08 的月历一对，
//    朔望时刻差到 ±11 小时。这里用低精度天文级数（太阳中心差 ＋ 月亮中心差 / 出差 /
//    二均差 / 周年差），拿 2026-08 四个相位的公布时刻校验过，最大差 21 分钟：
//    下弦 +12 / 朔 +7 / 上弦 -21 / 望 -11。
const MOON_RAD = Math.PI / 180;
function moonPhase(date) {
  const D = date.getTime() / 86400000 + 2440587.5 - 2451545.0;   // 距 J2000 的天数
  const sunG = (357.528 + 0.9856003 * D) * MOON_RAD;             // 太阳平近点角
  const sunLam = 280.460 + 0.9856474 * D
    + 1.915 * Math.sin(sunG) + 0.020 * Math.sin(2 * sunG);       // 太阳视黄经
  const Lm = 218.316 + 13.176396 * D;                            // 月亮平黄经
  const Mm = (134.963 + 13.064993 * D) * MOON_RAD;               // 月亮平近点角
  const Fm = (93.272 + 13.229350 * D) * MOON_RAD;                // 升交角距
  const Del = (Lm - sunLam) * MOON_RAD;                          // 平距角
  const moonLam = Lm
    + 6.289 * Math.sin(Mm)                                       // 中心差
    + 1.274 * Math.sin(2 * Del - Mm)                             // 出差
    + 0.658 * Math.sin(2 * Del)                                  // 二均差
    + 0.214 * Math.sin(2 * Mm)
    - 0.186 * Math.sin(sunG)                                     // 周年差
    - 0.114 * Math.sin(2 * Fm);
  let e = (moonLam - sunLam) % 360;                              // 日月黄经差
  if (e < 0) e += 360;
  const frac = e / 360;                                          // 0 朔、0.5 望
  const illum = (1 - Math.cos(e * MOON_RAD)) / 2;
  let name;
  if (frac < 0.02 || frac > 0.98) name = '新月';
  else if (frac < 0.23) name = '娥眉月';
  else if (frac < 0.27) name = '上弦月';
  else if (frac < 0.48) name = '盈凸月';
  else if (frac < 0.52) name = '满月';
  else if (frac < 0.73) name = '亏凸月';
  else if (frac < 0.77) name = '下弦月';
  else name = '残月';
  return { frac, illum, waxing: frac < 0.5, name };
}

// 亮区用采样多边形画，不拼 SVG 圆弧——large-arc / sweep 两个 flag 在盈亏交界处极易画反。
// ⚠️ 墨尔本在南半球：**蜡月（渐圆）亮的是左边**，跟北半球的图正好镜像。
// ⚠️ 两条边必须共用南北两个极点 (0,±r)，明暗界线才是**竖着**的。
//    只拿面积比对照照亮比查不出画错：面积跟朝向无关，图整个转 90° 面积照样对。
function moonLitPath(frac, r, N = 48) {
  const rx = r * Math.cos(frac * 2 * Math.PI);   // 明暗界线（半椭圆）的半宽，凸月时为负
  const s = frac < 0.5 ? -1 : 1;                 // 南半球：蜡月亮左边、亏月亮右边
  const pts = [];
  // 外缘半圆：(0,-r) → (s·r,0) → (0,r)
  for (let i = 0; i <= N; i++) { const t = -Math.PI / 2 + Math.PI * i / N; pts.push([s * Math.cos(t) * r, Math.sin(t) * r]); }
  // 明暗界线原路折回：(0,r) → (s·rx,0) → (0,-r)
  for (let i = N; i >= 0; i--) { const t = -Math.PI / 2 + Math.PI * i / N; pts.push([s * Math.cos(t) * rx, Math.sin(t) * r]); }
  return 'M' + pts.map(p => p[0].toFixed(2) + ',' + p[1].toFixed(2)).join('L') + 'Z';
}

// ── 七夕（2026-08-11 小玉点的：「挂满灯笼的那种热闹。天上银河也要」）────────────
// 农历日期每年不同，不做农历换算，直接查表。⚠️ 表用完就不过节了（宁可不显示，
// 也不拿算错的日子糊弄），下一只壳记得续。日期按公历，来源：中國日曆在線。
const QIXI_DATES = { 2026: '08-19', 2027: '08-08', 2028: '08-26', 2029: '08-16', 2030: '08-05' };
const QIXI_SPAN_DAYS = 2;        // 前后各两天都算过节，跟真实里灯笼提前挂上一样

// 墨尔本当天（跟 checkWeather 一个口径：UTC+10）
function melbourneToday() {
  return new Date(Date.now() + 10 * 3600000);
}
function qixiDayDiff(now = melbourneToday()) {
  const md = QIXI_DATES[now.getUTCFullYear()];
  if (md == null) return null;
  const [m, d] = md.split('-').map(Number);
  const qixi = Date.UTC(now.getUTCFullYear(), m - 1, d);
  const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
  return Math.round((today - qixi) / 86400000);
}
function isQixi(now = melbourneToday()) {          // 装饰期：前后各两天
  const diff = qixiDayDiff(now);
  return diff !== null && Math.abs(diff) <= QIXI_SPAN_DAYS;
}
// 烟花只放正日子那一晚——她原话「我们给小璋放一晚的烟花吧」，一晚就是一晚
function isQixiNight(now = melbourneToday()) {
  return qixiDayDiff(now) === 0;
}

// 一朵烟花的火星。⚠️ 2026-08-11 第一版她说「好潦草」——角度均分、半径几乎一样，
// 炸出来是一圈摆得整整齐齐的点，不像炸开的。现在：角度打乱、半径分内外两层、大小不一。
function fireworkSparks(n, radius, seed) {
  return Array.from({ length: n }, (_, i) => {
    const jitter = Math.sin(i * 12.9898 + seed * 78.233) * 0.5;       // 定死的角度抖动
    const a = ((i + jitter) / n) * Math.PI * 2 + seed * 0.37;
    const inner = i % 4 === 0;                                        // 每四颗留一颗在里圈
    const r = radius * (inner ? 0.42 : 0.72 + 0.28 * Math.abs(Math.sin(i * 2.3 + seed)));
    return {
      i, tx: Math.cos(a) * r, ty: Math.sin(a) * r,
      // 她 2026-08-11 说「烟花的光点要再大一点」⇒ 从 1.6~3.3px 提到 2.8~5.6px
      size: inner ? 2.8 : 3.6 + 2.0 * Math.abs(Math.cos(i * 1.7 + seed)),
      lag: (Math.abs(Math.sin(i * 3.1 + seed)) * 0.05).toFixed(3),    // 同一朵里也有先后
    };
  });
}
// 拉开间隔＝真的有"一朵一朵放"的节奏，不是六朵一直在那儿闪
const FIREWORKS = [
  { left: 24, top: 19, color: '#FFD27A', n: 26, r: 46, dur: 4.6, delay: 0,     seed: 1 },
  { left: 70, top: 13, color: '#FF9DC0', n: 28, r: 54, dur: 5.2, delay: -2.9,  seed: 2 },
  { left: 45, top: 26, color: '#9FD9FF', n: 24, r: 40, dur: 4.2, delay: -6.1,  seed: 3 },
  { left: 86, top: 22, color: '#FFE9A8', n: 26, r: 43, dur: 4.9, delay: -9.4,  seed: 4 },
  { left: 12, top: 29, color: '#C7B0FF', n: 24, r: 37, dur: 4.4, delay: -12.2, seed: 5 },
];

// 光圈 v3。⭐ 2026-08-11 她给了准话，照这个来，别自己发挥：
//   「放大20-30倍。做成一个圈，就是中间黑只有边缘亮的那种光晕。
//     然后放在院子里，图层在院子上面，小璋下面。大概放7-10个，可以互相重叠。
//     然后给一点点很慢很慢的呼吸动效。天空就只有烟花。」
// ⇒ 一圈一圈的大空心光环（散景那种），落在院子里不在天上，互相叠着，呼吸慢到几乎看不出在动。
// ⚠️ 位置/大小/快慢全从下标算，不许用 Math.random——每次 render 重摇会满院子乱跳。
function qixiGlows() {
  const spots = [
    { left: 18, top: 52, size: 248, hue: 0 },
    { left: 47, top: 44, size: 178, hue: 1 },
    { left: 74, top: 55, size: 215, hue: 0 },
    { left: 31, top: 74, size: 158, hue: 2 },
    { left: 62, top: 80, size: 236, hue: 0 },
    { left: 90, top: 68, size: 140, hue: 1 },
    { left: 6,  top: 84, size: 172, hue: 2 },
    { left: 52, top: 63, size: 116, hue: 0 },
  ];
  return spots.map((s, i) => ({
    key: `g${i}`,
    ...s,
    dur: 15 + ((i * 5) % 7) * 2.2,                       // 15~28 秒一次，很慢很慢
    delay: -((i * 7) % 13) * 1.6,
    dim: 0.55 + 0.45 * Math.abs(Math.cos(i * 1.4)),
  }));
}

// 银河和星星：位置必须固定，不能每次 render 就重新随机——星星会满天乱跳。
// 用一个定死种子的伪随机数，同一颗星永远在同一个地方。
function qixiStars(n, seed = 20260819) {
  let s = seed;
  const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648;
  return Array.from({ length: n }, () => {
    const along = rnd();                       // 沿银河带走多远
    const across = (rnd() + rnd() + rnd()) / 3 - 0.5;  // 向两侧散开，中间密两边疏
    return { x: along * 118 - 9, y: 8 + along * 46 + across * 30, r: 0.5 + rnd() * 1.3, o: 0.35 + rnd() * 0.65 };
  });
}

const WEATHER_VIEW = {
  sunny: 'day_sunny.png',
  cloudy: 'day.png',
  rainy: 'day_rain.png',
  night: 'night.png',
  night_rain: 'night_rain.png',
};

function getWeatherView(weather) {
  return WEATHER_VIEW[weather] || 'day.png';
}

function getWindowLightClass(weather) {
  if (weather === 'night' || weather === 'night_rain') return 'night';
  if (weather === 'rainy') return 'evening';
  return 'day';
}

function getDizzySprite(day) {
  if (day <= 20) return 'dizzy_baby.png';
  if (day <= 60) return 'dizzy_grown.png';
  return 'dizzy_mature.png';
}

function getParticleConfig(weather) {
  if (weather === 'night') return { bg: 'rgba(255,255,220,0.9)', size: '6px', cls: 'star', count: 15 };
  if (weather === 'night_rain') return { bg: 'rgba(160,200,255,0.5)', size: '2px', cls: 'rain', count: 30 };
  if (weather === 'rainy') return { bg: 'rgba(160,200,255,0.6)', size: '2px', cls: 'rain', count: 25 };
  if (weather === 'sunny') return { bg: 'rgba(255,245,200,0.8)', size: '14px', cls: 'sunray', count: 10 };
  return { bg: 'rgba(255,255,255,0.7)', size: '10px', cls: 'mote', count: 8 };
}

// ═══════════════ 小蟑立绘 ═══════════════
const TALL_SPRITES = new Set();

function getPupiSprite(day, cleanliness, happiness) {
  const stage = day <= 20 ? 'baby' : day <= 60 ? 'growing' : 'mature';
  const mood = happiness < 25 ? 'sad' : happiness < 50 ? 'bored' : 'idle';

  const spriteMap = {
    'baby_idle': 'baby_idle_sprite.png',
    'baby_bored': 'baby_bored_sprite.png',
    'baby_sad': 'baby_sad_sprite.png',
    'growing_idle': 'idle_sprite.png',
    'growing_bored': 'bored_growing_sprite.png',
    'growing_sad': 'sad_growing_sprite.png',
    'mature_idle': 'mature_sprite.png',
    'mature_bored': 'bored_mature_sprite.png',
    'mature_sad': 'sad_mature_sprite.png',
  };

  return spriteMap[`${stage}_${mood}`] || 'baby_idle_sprite.png';
}

// 戳一戳的台词。嘴硬心软是 personality 的底色——外壳"哼""别戳"，
// 里子会漏出凑近、翘尾巴、悄悄靠过去。摸头 CD 期间点小璋会随机蹦一句。
const POKE_BUBBLES = [
  '哼，别戳我啦。',
  '又戳……人家又不是玩具。',
  '再戳我可要生气了！（悄悄凑近）',
  '……才、才不是喜欢被戳呢。',
  '诶？你的手指又冷又暖的。',
  '哼，只准戳一下——好吧，两下也行。',
  '你就那么闲吗？（尾巴翘起来了）',
  '不要戳肚子那儿……那里怕痒。',
  '再戳我就告状！',
  '（悄悄靠过去一点）',
  '唔……戳这儿没感觉。要戳就戳到心里嘛。',
  '你今天怎么这么黏人呀。',
];

