{"version":3,"sources":["loc-share.mjs"],"sourcesContent":["// LOCATION SHARING (the owner's request, Oct 12): \"allow friends to share locations with their friends in game — the\n// other should receive an actionable toast/notification inviting the other to travel to them\".\n//\n// Byte-identical in public/ and functions/lib/ (generate-version-hash.js holds them equal). The functions read it to\n// decide what a share may say and who it may go to (functions/loc-share.js); the page reads it to build the stance it\n// sends, and to read back the invites and shares the server leaves on its own user document. Pure, so\n// tests/loc-share.test.mjs pins every rule without a server.\n//\n// THE OWNER'S ANSWERS, which everything below follows:\n//   1. ACCEPTING OPENS SCOUT FIRST. The invite's action opens Scout (look only) on the shared spot, as a ?pos link does.\n//      From there the friend uses the card's ordinary Travel here and then Land (split Oct 12), with the ordinary fare\n//      and energy — and the Land after an invite's Scout lands AT the shared stance rather than wherever the camera points.\n//   2. THE EXACT SPOT AS IT WAS. On foot, the stance at the moment of sharing ({addr, pos, fwd, pitch}); in orbit, the\n//      world and the vantage. It does not follow the sharer afterwards: it is a place, not a tracker.\n//   3. PICK FRIENDS, UNTIL CANCELLED. One or several accepted friends; each invite stays open until the sharer cancels\n//      it, shares again (which replaces it with the new spot) or logs off — signing out ends them, and so does being\n//      away for LOC_SHARE_AWAY_MS (the server's sweep). Unfriending ends them both ways. The friend's \"Not now\" hides\n//      it for that friend only: the sharer's share stands, and the friend can still open it from Friends.\n//   4. AWAY FRIENDS GET A PUSH (push-core.mjs kind 'locShare', on by default), whose tap opens the game straight into\n//      the Scout on the spot. In the game it is an actionable toast instead. No email.\n//\n// THE RECORDS, all written by functions/loc-share.js alone. No rule in firestore.rules matches loc_shares or\n// loc_share_limits (the catch-all deny keeps clients out), and the two maps ride on the user document each player\n// already listens to and may not write (they are not in its client-writable keys), so this needed no rules change:\n//   loc_shares/{id}              { from, to, fromIgn, toIgn, stance, place, at }   one open share; deleted when it ends\n//   users/{to}.locInvites[from]  { id, fromIgn, stance, place, at, dismissed? }    what the friend's game toasts\n//   users/{from}.locSharing[to]  { id, toIgn, place, at }                          what the sharer's chip counts\n//   loc_share_limits/{from}      { stamps: [ms, …] }                               the rate limit\n// Keyed by the other player, so a second share to the same friend replaces the first by construction.\nimport { normAddr, bodyOf, atSameBody, sameSystem } from './whereabouts.mjs';\n\nexport const LOC_SHARE_MAX_TO = 25;                // friends one share may go to\nexport const LOC_SHARE_RATE_N = 20;                // shares (calls, whatever the number of friends in each)…\nexport const LOC_SHARE_RATE_MS = 10 * 60e3;        // …in any ten minutes\nexport const LOC_SHARE_AWAY_MS = 15 * 60e3;        // a sharer with no presence and no play report for this long has gone\nexport const LOC_SHARE_ID_RE = /^[A-Za-z0-9]{16,40}$/;\nconst UID_RE = /^[A-Za-z0-9_-]{6,128}$/;\n\nconst num = (v, lim) => (typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= lim) ? v : null;\nconst r = (v, p) => Math.round(v * p) / p;\nfunction vec(v, lim, p) {\n  if (!v || typeof v !== 'object') return null;\n  const x = num(v.x, lim), y = num(v.y, lim), z = num(v.z, lim);\n  return (x == null || y == null || z == null) ? null : { x: r(x, p), y: r(y, p), z: r(z, p) };\n}\n// A direction: near enough to unit length to be one (a client's rounding), and made exactly one here.\nfunction unit(v) {\n  const u = vec(v, 1.5, 1e6); if (!u) return null;\n  const l = Math.hypot(u.x, u.y, u.z);\n  if (!(l > 0.5 && l < 1.5)) return null;\n  return { x: r(u.x / l, 1e3), y: r(u.y / l, 1e3), z: r(u.z / l, 1e3) };\n}\n\n// THE STANCE A SHARE CARRIES, checked for shape and nothing else (where it may be is stanceProblem's). Null when it is\n// not one. A world or a moon only, never a framing (bodyOf collapses `x.0`), a system or a passing rock: Scout opens a\n// world, and a landing needs one.\n//   on foot   { addr, walking: true, pos, fwd, pitch }   pos in the world's own frame, as the walker and presence keep it\n//   in orbit  { addr, walking: false, odir, d }         the vantage (a world-space direction, radii out), either may be null\nexport function stanceOf(raw) {\n  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;\n  const addr = bodyOf(raw.addr);\n  if (!addr) return null;\n  if (raw.walking === true) {\n    const pos = vec(raw.pos, 1000, 100), fwd = unit(raw.fwd);\n    const pitch = num(raw.pitch == null ? 0 : raw.pitch, 1.6);\n    if (!pos || !fwd || pitch == null) return null;\n    return { addr, walking: true, pos, fwd, pitch: r(pitch, 1e3) };\n  }\n  const odir = raw.odir == null ? null : unit(raw.odir);\n  if (raw.odir != null && !odir) return null;\n  const d = raw.d == null ? null : num(raw.d, 100);\n  if (raw.d != null && !(d >= 1)) return null;\n  return { addr, walking: false, odir, d: d == null ? null : r(d, 100) };\n}\n\n// WHERE A SHARE MAY BE: where the server has you, never where the page says (the owner: \"don't trust a client addr that\n// differs from server whereabouts\"). `at` is the sharer's whereabouts (whereabouts.mjs whereaboutsOf); `world` is the\n// world resolved from its address on the server ({ category, diameter }, share.js claimableWorld), or null for an\n// address no world has. Returns the sentence a refusal is told in, or null.\n//   on foot   you are on that very world (the granularity whereabouts keep once you land)\n//   in orbit  that world, when you are on one; any world of your system, when whereabouts name only the system (in orbit\n//             they do: a world becomes your whereabouts by landing on it, and taking off gives it back to the system)\nexport function stanceProblem(st, at, world) {\n  if (!st) return 'That is not a place that can be shared.';\n  const here = normAddr(at);\n  if (!here) return 'The game does not know where you are yet. Travel somewhere first.';\n  if (st.walking) { if (!atSameBody(here, st.addr)) return 'You can only share the world you are standing on.'; }\n  else if (bodyOf(here) ? !atSameBody(here, st.addr) : !sameSystem(here, st.addr)) return 'You can only share where you are.';\n  if (!world) return 'There is no world there.';\n  // A STAR IS NOT A PLACE TO MEET: nothing lands on one, and an address past a system's last body resolves to its star.\n  if (world.category === 'Star') return 'Share a world or a moon, not a star.';\n  if (st.walking) {\n    // ABOARD A VESSEL (Oct 12) is on foot too, in the vessel's own frame: `world` is then { category: 'Vessel', diameter }\n    // from its record (functions/loc-share.js), and the reach below holds the spot to its grid.\n    if (world.category !== 'Rocky' && world.category !== 'Vessel') return 'Nobody can stand on that world.';\n    const reach = (Number(world.diameter) > 0 ? Number(world.diameter) : 96) * 1.5 + 8;   // the ground, its air and what stands on it\n    if (Math.hypot(st.pos.x, st.pos.y, st.pos.z) > reach) return 'That spot is not on the world.';\n  }\n  return null;\n}\n\n// WHO A SHARE GOES TO: one to LOC_SHARE_MAX_TO accepted friends, each once, never yourself. `friends` is the sharer's\n// accepted friends as the server reads them (a Set or an array of uids). { to: [uid, …] } or { why }.\nexport function recipientsOf(to, from, friends) {\n  if (!Array.isArray(to) || !to.length) return { why: 'Choose a friend to share with.' };\n  const out = [...new Set(to)];\n  if (out.length > LOC_SHARE_MAX_TO) return { why: `Share with up to ${LOC_SHARE_MAX_TO} friends at once.` };\n  if (out.some((u) => typeof u !== 'string' || !UID_RE.test(u))) return { why: 'That is not a player.' };\n  if (out.includes(from)) return { why: 'You cannot share your location with yourself.' };\n  const ok = friends instanceof Set ? friends : new Set(friends || []);\n  if (out.some((u) => !ok.has(u))) return { why: 'You can only share your location with your friends.' };\n  return { to: out };\n}\n\n// THE RATE LIMIT: the share's own moment added to the ones still inside the window, or a refusal with how long to wait.\nexport function rateAfter(stamps, nowMs) {\n  const kept = (Array.isArray(stamps) ? stamps : []).filter((t) => typeof t === 'number' && t > nowMs - LOC_SHARE_RATE_MS && t <= nowMs + 60e3);\n  if (kept.length >= LOC_SHARE_RATE_N) return { ok: false, waitMs: Math.max(1000, Math.min(...kept) + LOC_SHARE_RATE_MS - nowMs) };\n  return { ok: true, stamps: [...kept, nowMs].slice(-LOC_SHARE_RATE_N) };\n}\n\n// GONE AWAY (decision 3, \"or logs off\"): no presence heartbeat (presence/{uid}.updatedAt, every few seconds while the game\n// is in use) and no play report (users/{uid}.lastActiveAt, every five minutes of play) for LOC_SHARE_AWAY_MS. Both are\n// what the game already keeps for \"online\" (app.js PRESENCE_STALE_MS, play-time.js), read rather than invented.\nexport function sharerAway(lastSeenMs, nowMs) {\n  const seen = Math.max(0, ...(Array.isArray(lastSeenMs) ? lastSeenMs : [lastSeenMs]).map((t) => Number(t) || 0));\n  return !(seen > nowMs - LOC_SHARE_AWAY_MS);\n}\n\nconst msOf = (t) => (t && typeof t.toMillis === 'function') ? t.toMillis() : (t && typeof t.seconds === 'number') ? t.seconds * 1000 : Number(t) || 0;\nconst clipName = (s, n) => String(s == null ? '' : s).replace(/[\\u0000-\\u001f]/g, ' ').trim().slice(0, n);\n\n// THE INVITES ON A PLAYER'S OWN DOCUMENT (users/{uid}.locInvites), read back: each one checked, newest first.\n// [{ from, id, fromIgn, stance, place, atMs, dismissed }]\nexport function invitesOf(user) {\n  const m = user && user.locInvites;\n  if (!m || typeof m !== 'object' || Array.isArray(m)) return [];\n  const out = [];\n  for (const [from, v] of Object.entries(m)) {\n    if (!v || typeof v !== 'object' || !UID_RE.test(from) || !LOC_SHARE_ID_RE.test(String(v.id || ''))) continue;\n    const stance = stanceOf(v.stance);\n    if (!stance) continue;\n    out.push({ from, id: String(v.id), fromIgn: clipName(v.fromIgn, 30) || 'A friend', stance,\n               place: clipName(v.place, 40) || stance.addr, atMs: msOf(v.at), dismissed: v.dismissed === true });\n  }\n  return out.sort((a, b) => b.atMs - a.atMs);\n}\n// …AND THE SHARES A PLAYER HAS OUT (users/{uid}.locSharing), newest first: [{ to, id, toIgn, place, atMs }]\nexport function sharingOf(user) {\n  const m = user && user.locSharing;\n  if (!m || typeof m !== 'object' || Array.isArray(m)) return [];\n  const out = [];\n  for (const [to, v] of Object.entries(m)) {\n    if (!v || typeof v !== 'object' || !UID_RE.test(to) || !LOC_SHARE_ID_RE.test(String(v.id || ''))) continue;\n    out.push({ to, id: String(v.id), toIgn: clipName(v.toIgn, 30) || 'A friend', place: clipName(v.place, 40), atMs: msOf(v.at) });\n  }\n  return out.sort((a, b) => b.atMs - a.atMs);\n}\n// The invite with this id, or null (ended, replaced by a newer share, or never this player's).\nexport const inviteById = (user, id) => invitesOf(user).find((v) => v.id === id) || null;\n\n// THE WORDS, the same in the toast and the notification: \"ArktikTern shared their location · Moss Hollow\".\nexport const inviteTitle = (fromIgn) => `${clipName(fromIgn, 30) || 'A friend'} shared their location`;\nexport const inviteBody = (place, walking) => `${clipName(place, 40) || 'A world'} · Scout it, then travel there and land to join them`\n  + (walking ? ' on their spot' : '');\n// \"Tester\", \"Tester and Bob\", \"Tester, Bob and 2 more\": who you are sharing with, for the chip and the Friends panel.\nexport function namesLine(names, max = 2) {\n  const n = (names || []).map((s) => clipName(s, 30) || 'a friend');\n  if (n.length <= max) return n.length < 2 ? (n[0] || '') : n.slice(0, -1).join(', ') + ' and ' + n[n.length - 1];\n  return n.slice(0, max).join(', ') + ' and ' + (n.length - max) + ' more';\n}\n"],"mappings":"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","names":[],"file":"loc-share.mjs"}