-- Pure simulation functions: no engine state, I/O, network, or mutable clock. local M = {} function M.clamp(n, low, high) return math.max(low, math.min(high, n)) end function M.location(lat, lon) lat, lon = tonumber(lat), tonumber(lon) if not lat or not lon or lat ~= lat or lon ~= lon or math.abs(lat) > 90 or math.abs(lon) > 180 then return nil end return {latitude = lat, longitude = lon} end function M.solar(timestamp, latitude, longitude) local t = os.date("!*t", timestamp) local leap = t.year % 4 == 0 and (t.year % 100 ~= 0 or t.year % 400 == 0) local days = leap and 366 or 365 local utc = t.hour + t.min / 60 + t.sec / 3600 local gamma = 2 * math.pi / days * (t.yday - 1 + (utc - 12) / 24) -- NOAA fractional-year approximation; weather is synthetic, not a forecast. local equation = 229.18 * (0.000075 + 0.001868 * math.cos(gamma) - 0.032077 * math.sin(gamma) - 0.014615 * math.cos(2 * gamma) - 0.040849 * math.sin(2 * gamma)) local declination = 0.006918 - 0.399912 * math.cos(gamma) + 0.070257 * math.sin(gamma) - 0.006758 * math.cos(2 * gamma) + 0.000907 * math.sin(2 * gamma) - 0.002697 * math.cos(3 * gamma) + 0.00148 * math.sin(3 * gamma) local solar_hour = (utc + longitude / 15 + equation / 60) % 24 local lat = math.rad(latitude) local sin_elevation = math.sin(lat) * math.sin(declination) + math.cos(lat) * math.cos(declination) * math.cos(math.rad((solar_hour - 12) * 15)) local elevation = math.deg(math.asin(M.clamp(sin_elevation, -1, 1))) -- Adapt the engine's fixed equinox orbit so its daylight matches this latitude. local engine_time if solar_hour <= 12 then engine_time = (elevation + 90) / 360 else engine_time = (270 - elevation) / 360 end local south = latitude < 0 and 1 or 0 local index = (math.floor((t.month % 12) / 3) + south * 2) % 4 + 1 local season = ({"Winter", "Spring", "Summer", "Autumn"})[index] local annual = math.cos(2 * math.pi * (t.yday - 205) / days) * (south == 1 and -1 or 1) local mean = 27 - math.abs(latitude) * 0.43 local amplitude = 3 + math.abs(latitude) * 0.22 return {hour = solar_hour, timeofday = engine_time, elevation = elevation, daylight = elevation > -0.833, season = season, temperature = mean + annual * amplitude + math.sin(math.rad(elevation)) * 4, date = os.date("!%Y-%m-%d %H:%M UTC", timestamp)} end function M.weather(timestamp, latitude, longitude) -- UTC-indexed deterministic six-hour fronts: restart/unloading do not reroll. local front = math.floor(timestamp / 21600) local wave = math.sin(front * 12.9898 + latitude * 0.1 + longitude * 0.03) * 43758.5453 return wave - math.floor(wave) > 0.68 and "rain" or "clear" end function M.needs(state, env, dt) dt = M.clamp(dt, 0, 2) -- stalled frames never replay lethal elapsed time local s = {} for k, v in pairs(state) do s[k] = v end local shelter = M.clamp(env.shelter or 0, 0, 1) local exposed = env.rain and shelter < 0.6 local wet_rate = exposed and (env.clothing and 0.35 or 1.2) or (env.drying and -1.5 or -0.35) s.wet = M.clamp(s.wet + wet_rate * dt, 0, 100) local feels = env.temperature - s.wet * 0.09 + (env.heat or 0) * 14 + shelter * 5 + (env.clothing and 4 or 0) local target = M.clamp(60 + feels * 1.5, 0, 100) s.warmth = M.clamp(s.warmth + M.clamp(target - s.warmth, -0.16, 0.3) * dt, 0, 100) s.food = M.clamp(s.food - (env.sprinting and 0.035 or 0.012) * dt, 0, 100) s.water = M.clamp(s.water - (env.sprinting and 0.06 or 0.025) * dt, 0, 100) s.stamina = M.clamp(s.stamina + (env.sprinting and -13 or (s.food > 0 and s.water > 0 and 9 or 2)) * dt, 0, 100) return s, (s.warmth < 8 or s.food <= 0 or s.water <= 0) and dt or 0 end function M.fuel_remaining(deadline, now) return M.clamp(deadline - now, 0, 600) end return M