{"version":3,"sources":["temperature.mjs"],"sourcesContent":["// THE TEMPERATURE MODEL: sunlight, albedo, and the temperature a body reads. SHARED, byte-identical with\n// functions/lib/temperature.mjs (parity enforced in generate-version-hash.js). Import it, never fork it.\n//\n// Temperature decides more than any other number in the game. It picks which phase a volatile is in (phase.mjs),\n// whether the suit takes heat or cold damage (hazard-engine.js), how much air a world keeps (atmosphere.mjs), and the\n// figure on every read-out. It used to be worked out inside system-view.js's closure, where nothing could run it\n// outside a browser, and the share card kept a second copy of part of it. That copy had drifted: it lit a world by its\n// own star only, and it read a surface's lightness from the sRGB bytes rather than the way three does. Between them\n// the card drew a different atmosphere from the game's on 23% of rocky worlds. Now both ends call this module, and\n// tests/temperature.test.mjs runs it over real systems.\n//\n// DEPENDENCY-FREE ON PURPOSE, for the reason atmosphere.mjs is. The client imports it as ./temperature.mjs and the\n// server as ./lib/temperature.mjs, and an import of the engine would read differently in the two copies, so the\n// constants it shares with system-engine and phase.mjs are repeated here and pinned to them by the test.\n//\n// ── THE RANGE IT PRODUCES ────────────────────────────────────────────────────────────────────────────────────────\n// Measured over every body of three whole galaxies (930718, 1 and 3422: 265,000 bodies), and held by the test:\n//   coldest     -270.4 °C, the cosmic background (CMB_K below). A body reads it when it absorbs less than about\n//               1.3 W/m²: everything in a rogue system, which has no star, and the far lanes of dim stars, about 4% of\n//               all bodies. (A gas giant's fixed greenhouse lifts even that, to -270.1 °C.)\n//   hottest     about +3,800 °C for a world in a fixed orbit: 3,792 °C at 3422.5877.1.1, 3,726 °C at 930718.1148.1.1.\n//               That is the light of the brightest star in the catalogue (an O-class giant, L = 4.62) falling on a\n//               dark crust at the closest a lane can sit, about 81 units out, then stretched by the hot tail below.\n//               About 1% of rocky worlds read above +930 °C.\n//   greenhouse  a thick CH₄/H₂O atmosphere multiplies a world's temperature by up to about 8.5 (atmosphere.mjs, GH_K\n//               and GH_EXP), which can put a world at the habitable-zone centre at +2,000 °C.\n//   passing     asteroids and comets run hotter still at perihelion, up to about +8,000 °C in a near binary of two\n//               bright stars, because nothing keeps a flyby as far out as the innermost lane.\n// The comment this replaces put the extremes at +930 and -263 °C. It was written a few hours before the same day's\n// retune of the layout (FIRST_GAP cut from 85 to 48, luminosity scaled by R², albedo compressed to 0.05-0.37), every\n// part of which runs worlds hotter, and it has not been true since.\n\n// Shared with the engine (system-engine.js HZ_BASE) and phase.mjs; the test holds all three copies together.\nexport const SOLAR_CONSTANT = 1361;   // W/m² at the HZ centre (Earth's value, 1 AU from the Sun)\nexport const SIGMA_SB = 5.670e-8;     // Stefan–Boltzmann constant (W/m²/K⁴)\nexport const HZ_BASE = 220;           // world units from a one-solar-luminosity star to its habitable-zone centre\nexport const K0 = 273.15;             // 0 °C in kelvin\n\n// ── SUNLIGHT ─────────────────────────────────────────────────────────────────────────────────────────────────────\n// Anchored to the habitable zone the engine already defines. A body at its star's HZ centre (HZ_BASE·√L) receives the\n// Earth solar constant, and since S ∝ L/d² that one anchor fixes the scale with no extra calibration. Light from every\n// star in the system adds.\n\n// The stars a system's bodies are lit by. A rogue system's root is a PLANET, so it has none and nothing in it receives\n// any light at all, which is what freezes those worlds out. The test for a star is the engine's own getCategory rule\n// (the class names a star), repeated because this module cannot import the engine.\nexport function starsOf(system) {\n    if (!system || !String(system.class || '').includes('Star')) return [];\n    return system.companion ? [system, system.companion] : [system];\n}\n\n// WHERE A BODY IS, from the generated system alone: { host, orbitDist }. A planet, or the second world of a binary\n// pair, is found in its star's lane table. A moon rides with its planet, so it takes the planet's distance. This is\n// the walk hostStarOf and parentOf make in the renderer, over the same table assignDynamics copies into _orbitDist,\n// and the game and the share card both place a body with it, so the card cannot put a world somewhere the game does\n// not. The renderer also asks it about the stars themselves, for lighting: a binary's second star is placed one\n// separation from the first (assignDynamics gives it that _orbitDist), and anything else it cannot find (the primary,\n// a rogue's root and moons, a transient before it has a mesh) is at distance 0 from the system's first body, which is\n// where the renderer has always put them.\nexport function placeOf(system, body) {\n    if (!system) return { host: null, orbitDist: 0 };\n    if (body && body === system.companion) return { host: system, orbitDist: (system.layout && system.layout.sep) || 0 };\n    if (starsOf(system).length) {\n        const hosts = (system.binaryType === 'far' && system.companion) ? [system, system.companion] : [system];\n        for (const host of hosts) {\n            for (const p of (host.children || [])) {\n                if (p === body || p.companion === body || (p.children || []).includes(body)) {\n                    return { host, orbitDist: (host.laneDistances && host.laneDistances[p.lane]) || 0 };\n                }\n            }\n        }\n    }\n    return { host: system, orbitDist: 0 };\n}\n\n// How far one star is from a body at `place`. A body shares its host star's distance; so does a near binary's second\n// star, which sits at the same barycentre the planets circle. A far binary's other star is taken as one separation\n// away, which is where it spends its time relative to a planet of the first. A transient on a flyby has no orbit to\n// read, so the renderer passes its live distance from the system centre as `liveDist`, and that is used for every star.\nexport function distanceToStar(system, star, place) {\n    const p = place || {};\n    if (p.liveDist != null) return p.liveDist;\n    const orbitR = p.orbitDist || 0;\n    if (!system.companion || star === p.host) return orbitR;\n    if (system.binaryType === 'near') return orbitR;\n    return (system.layout && system.layout.sep) || orbitR;\n}\n\n// Total irradiance (W/m²) on a body at `place`, summed over every star in the system.\nexport function irradianceAt(system, place) {\n    let total = 0;\n    for (const star of starsOf(system)) {\n        const d = distanceToStar(system, star, place);\n        if (d > 0) total += SOLAR_CONSTANT * (star.luminosity ?? 1) * (HZ_BASE / d) ** 2;\n    }\n    return total;\n}\n\n// ── ALBEDO ───────────────────────────────────────────────────────────────────────────────────────────────────────\n// From the body's rendered surface colour, compressed into the range real airless rocky worlds occupy (carbonaceous and\n// Mercury about 0.05-0.09, the Moon 0.11, Mars 0.25, bright surfaces 0.4). The raw render colours are far brighter than\n// real regolith, so used directly they made even close-in planets reflect most of their sunlight away and read below\n// freezing. Lightness only picks where within 0.05-0.37 a body sits; darker surfaces absorb more and run hotter.\nexport const ALBEDO_MIN = 0.05, ALBEDO_SPAN = 0.32;\nexport function albedoFromLightness(l) { return ALBEDO_MIN + ALBEDO_SPAN * Math.max(0, Math.min(1, l)); }\n\n// THE LIGHTNESS IS THREE'S, IN LINEAR SPACE. The renderer reads a surface's lightness as new THREE.Color(hex).getHSL().l,\n// and with colour management on (three's default since r152; this build is r184) that Color holds LINEAR components,\n// which getHSL reads in the linear working space. So #666666 has lightness 0.133, not the 0.40 its sRGB bytes suggest.\n// The share card worked it out from the bytes, gave most worlds a paler surface than the game does, a higher albedo\n// and a colder world, and so a different atmosphere on 20% of rocky worlds. This is three's own decode (SRGBToLinear:\n// the same constants in the same order), so the two agree to the last bit; the test holds it to three for every pair\n// of brightest and darkest channel bytes. A string that is not a six-digit hex reads as mid-grey, as the card always did.\nexport function lightnessOfHex(hex) {\n    const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || ''));\n    if (!m) return 0.5;\n    const n = parseInt(m[1], 16);\n    const lin = (byte) => { const c = byte / 255; return (c < 0.04045) ? c * 0.0773993808 : Math.pow(c * 0.9478672986 + 0.0521327014, 2.4); };\n    const r = lin((n >> 16) & 255), g = lin((n >> 8) & 255), b = lin(n & 255);\n    return (Math.min(r, g, b) + Math.max(r, g, b)) / 2;\n}\nexport function albedoOfHex(hex) { return albedoFromLightness(lightnessOfHex(hex)); }\n\n// ── TEMPERATURE ──────────────────────────────────────────────────────────────────────────────────────────────────\n// Start from the physical blackbody equilibrium, then stretch the tails. At the game's compressed orbit scale the\n// equilibrium of a body with any sunlight at all spans only about -257 to +392 °C, so everything inside the normal\n// band keeps its true blackbody value and the deviation grows quadratically past a hot and a cold knee. The innermost\n// worlds reach lava heat and beyond, the most distant ones approach the cosmic background, and typical planets are\n// left alone: a world at the habitable-zone centre reads between -25 and +2 °C before any greenhouse, depending on how\n// dark its surface is.\nexport const HOT_KNEE = 390, COLD_KNEE = 110;       // K: band edges where the stretch begins (about +117 °C / -163 °C)\nexport const HOT_GAIN = 0.045, COLD_GAIN = 0.0125;  // tail steepness; see THE RANGE IT PRODUCES above for where it leads\n\n// NOTHING IS COLDER THAN THE SKY IT RADIATES INTO. A body with no sunlight at all still sits in the cosmic microwave\n// background, so that is the floor. It used to be 2 K, which put every rogue world 0.7 K colder than empty space.\nexport const CMB_K = 2.725;\n\n// …EXCEPT FOR THE AIR. atmoCore is fed the same temperature floored at 2 K, the floor every world's atmosphere has been\n// generated with. The two differ only below the CMB, but that is exactly where helium stops being frozen out: fed\n// 2.725 K, 687 of the 2,591 rocky worlds below it in galaxy 930718 (27%) would gain a trace of helium they have never\n// had, and 6 more would have their air changed. That is a generated world changing under its players, with its\n// composition read-out and its pressure, so moving this floor is a generation-version change, not a floor fix.\nexport const ATMO_FLOOR_K = 2;\n\n// Gas giants add a fixed greenhouse, T_surf = T·(1+G)^¼. Rocky worlds take theirs from the atmosphere they hold.\nexport const GAS_GIANT_GREENHOUSE = 0.5;\n\n// The physical blackbody equilibrium temperature (K) of a body absorbing (1 − albedo) of this irradiance.\nexport function equilibriumTempK(irradiance, albedo) {\n    return Math.pow((irradiance * (1 - albedo)) / (4 * SIGMA_SB), 0.25);\n}\n\n// The stretched tails, before any floor: the cold side runs below zero kelvin for a body with no light at all.\nexport function stretchTempK(Teq) {\n    if (Teq > HOT_KNEE) return Teq + HOT_GAIN * (Teq - HOT_KNEE) ** 2;    // inner worlds → lava\n    if (Teq < COLD_KNEE) return Teq - COLD_GAIN * (COLD_KNEE - Teq) ** 2; // distant worlds → the cosmic background\n    return Teq;                                                           // normal band: true blackbody\n}\n\n// The temperature a body reads before any greenhouse (K).\nexport function preGreenhouseTempK(irradiance, albedo) {\n    return Math.max(CMB_K, stretchTempK(equilibriumTempK(irradiance, albedo)));\n}\n\n// What atmoCore is given as its pre-greenhouse temperature (K). See ATMO_FLOOR_K for why it is not preGreenhouseTempK.\nexport function atmoInputTempK(irradiance, albedo) {\n    return Math.max(ATMO_FLOOR_K, stretchTempK(equilibriumTempK(irradiance, albedo)));\n}\n\n// The greenhouse multiplier for a body of this category: a fixed one for a gas giant, the atmosphere's own for a rocky\n// world (atmoCore's `greenhouse`), none for anything else.\nexport function greenhouseFactor(category, greenhouse) {\n    if (category === 'Gas') return Math.pow(1 + GAS_GIANT_GREENHOUSE, 0.25);\n    if (category === 'Rocky') return Math.pow(1 + (greenhouse || 0), 0.25);\n    return 1;\n}\n\n// THE TEMPERATURE A BODY READS: the average surface temperature every read-out shows, and the one the hazard engine\n// and the phase model are handed.\nexport function surfaceTempK(irradiance, albedo, category, greenhouse) {\n    return preGreenhouseTempK(irradiance, albedo) * greenhouseFactor(category, greenhouse);\n}\nexport function surfaceTempC(irradiance, albedo, category, greenhouse) {\n    return surfaceTempK(irradiance, albedo, category, greenhouse) - K0;\n}\n"],"mappings":"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","names":[],"file":"temperature.mjs"}