FieldLocalWetness = {}
FieldLocalWetness.name = g_currentModName or "FieldLocalWetness"
FieldLocalWetness.path = g_currentModDirectory or ""

addModEventListener(FieldLocalWetness)

FieldLocalWetness.enabled = false
FieldLocalWetness.debug = false
FieldLocalWetness.gridDebugEnabled = false
FieldLocalWetness.gridDebugRadius = 32
FieldLocalWetness.gridDebugLabels = true

FieldLocalWetness.cellSize = 18.0
FieldLocalWetness.sampleRadius = 24.0
FieldLocalWetness.lowlandStrength = 0.55
FieldLocalWetness.slopeDrainStrength = 0.35
FieldLocalWetness.effectStrength = 0.80
FieldLocalWetness.permaChanceStrength = 1.25
FieldLocalWetness.permaChanceMinWetness = 0.35
FieldLocalWetness.radiusEffectStrength = 0.75

-- Normalized soil-water balance rates used by the persistent GRLE layer.
-- A sustained rain wets the profile over several in-game hours instead of
-- saturating every cell almost immediately.
FieldLocalWetness.rainGainPerHour = 0.22
FieldLocalWetness.snowGainPerHour = 0.045
FieldLocalWetness.hailGainPerHour = 0.09
FieldLocalWetness.dryLossPerHour = 0.018
FieldLocalWetness.baseMoisture = 0.10
FieldLocalWetness.minWetness = 0.04
FieldLocalWetness.maxWetness = 1.00

FieldLocalWetness.scanCellsPerUpdate = 256
FieldLocalWetness.scanOnLoad = true

-- Calibrated model constants. These are intentionally not user settings:
-- terrain resolution, drainage, retention and vehicle response must describe
-- one coherent physical model instead of independent sliders.
FieldLocalWetness.MODEL_PARAMETERS = {
    effectStrength = 0.80,
    cellSize = 18.0,
    sampleRadius = 24.0,
    lowlandStrength = 0.55,
    slopeDrainStrength = 0.35,
    radiusEffectStrength = 0.75,
    permaChanceStrength = 1.25,
    permaChanceMinWetness = 0.35,
    rainGainPerHour = 0.22,
    snowGainPerHour = 0.045,
    hailGainPerHour = 0.09,
    dryLossPerHour = 0.018,
    baseMoisture = 0.10,
    minWetness = 0.04,
    maxWetness = 1.00
}

FieldLocalWetness._cells = {}
FieldLocalWetness._weatherMemory = 0
FieldLocalWetness._scan = nil
FieldLocalWetness._lastGlobalUpdateMs = 0
FieldLocalWetness._gameDayOffsetMs = 0
FieldLocalWetness._lastGameDayTimeMs = nil
FieldLocalWetness._lastWeatherGameTimeMs = nil

local function flwClamp(v, mn, mx)
    v = tonumber(v) or 0
    if v < mn then return mn end
    if v > mx then return mx end
    return v
end

local function flwLerp(a, b, t)
    return a + (b - a) * flwClamp(t, 0, 1)
end

local function flwSmoothstep(edge0, edge1, x)
    local t = flwClamp((x - edge0) / math.max(0.0001, edge1 - edge0), 0, 1)
    return t * t * (3 - 2 * t)
end

local function flwActivePrecipScale(v)
    v = flwClamp(v or 0, 0, 1)
    if v <= 0.01 then
        return 0
    end

    -- FS can report long rain as a small rainfall scale. Once precipitation is active,
    -- keep a realistic soaking floor so local wetness does not crawl upward for hours.
    return math.max(v, 0.34 + flwSmoothstep(0.03, 0.45, v) * 0.46)
end

local function flwTableCount(t)
    local count = 0
    if t ~= nil then
        for _ in pairs(t) do
            count = count + 1
        end
    end
    return count
end

local function flwGetGameTimeMs()
    local env = g_currentMission ~= nil and g_currentMission.environment or nil
    local dayTime = env ~= nil and tonumber(env.dayTime) or nil
    if dayTime == nil and env ~= nil and env.weather ~= nil and env.weather.owner ~= nil then
        dayTime = tonumber(env.weather.owner.dayTime)
    end

    if dayTime == nil then
        return g_time or 0
    end

    dayTime = flwClamp(dayTime, 0, 86400000)

    local last = FieldLocalWetness._lastGameDayTimeMs
    if last ~= nil and dayTime < last - 43200000 then
        FieldLocalWetness._gameDayOffsetMs = (FieldLocalWetness._gameDayOffsetMs or 0) + 86400000
    elseif last ~= nil and dayTime > last + 43200000 then
        FieldLocalWetness._gameDayOffsetMs = math.max(0, (FieldLocalWetness._gameDayOffsetMs or 0) - 86400000)
    end

    FieldLocalWetness._lastGameDayTimeMs = dayTime
    return (FieldLocalWetness._gameDayOffsetMs or 0) + dayTime
end

local function flwGetMissionTerrainSize()
    if g_currentMission ~= nil and tonumber(g_currentMission.terrainSize) ~= nil then
        return tonumber(g_currentMission.terrainSize)
    end
    if tonumber(g_terrainSize) ~= nil then
        return tonumber(g_terrainSize)
    end
    return 2048
end

local function flwGetGrid()
    local size = math.max(6.0, tonumber(FieldLocalWetness.cellSize) or 18.0)
    local terrainSize = math.max(size, flwGetMissionTerrainSize())
    local half = terrainSize * 0.5
    local cols = math.max(1, math.ceil(terrainSize / size))
    return size, -half, half, cols
end

local function flwGetProfileAt(x, z)
    if FieldGroundMudPhysics ~= nil and FieldGroundMudPhysics.getProfileAtWorldPos ~= nil then
        local _, _, prof = FieldGroundMudPhysics:getProfileAtWorldPos(x, z)
        return prof
    end
    return nil
end

local function flwGetProfileWetnessMul(prof)
    if prof == nil then
        return 1.0
    end

    local mud = flwClamp(tonumber(prof.mud) or 0, 0, 1)
    local wetMul = tonumber(prof.wetMul) or 1.0
    local sinkMul = tonumber(prof.sinkMul) or 5.4
    local dirtMul = tonumber(prof.dirtMul) or 1.0

    local mul = 0.86
        + mud * 0.22
        + (wetMul - 1.0) * 0.22
        + (sinkMul - 5.4) * 0.035
        + (dirtMul - 1.0) * 0.06

    if prof.permaStuck == true then
        mul = mul + 0.05
    else
        mul = mul - 0.16
    end

    return flwClamp(mul, 0.62, 1.34)
end

local function flwGetProfilePermaMul(prof)
    if prof == nil then
        return 1.0
    end
    if prof.permaStuck ~= true then
        return 0.35
    end

    local mud = flwClamp(tonumber(prof.mud) or 0, 0, 1)
    local wetMul = tonumber(prof.wetMul) or 1.0
    local sinkMul = tonumber(prof.sinkMul) or 5.4
    local brakeMul = tonumber(prof.brakeMul) or 4.0

    local mul = 0.82
        + mud * 0.30
        + (wetMul - 1.0) * 0.18
        + (sinkMul - 5.4) * 0.065
        + (brakeMul - 4.0) * 0.035

    return flwClamp(mul, 0.70, 1.55)
end

local function flwGetWeather()
    if g_currentMission ~= nil
        and g_currentMission.environment ~= nil
        and g_currentMission.environment.weather ~= nil then
        return g_currentMission.environment.weather
    end
    return nil
end

local function flwGetWeatherState()
    local weather = flwGetWeather()
    if weather == nil then
        return 0, 0, 0, nil, 0
    end

    local rain = 0
    if weather.getRainFallScale ~= nil then
        rain = weather:getRainFallScale() or 0
    end

    local snow = 0
    if weather.getSnowFallScale ~= nil then
        snow = weather:getSnowFallScale() or 0
    end

    local hail = 0
    if weather.getHailFallScale ~= nil then
        hail = weather:getHailFallScale() or 0
    end

    local temp = nil
    if weather.getCurrentTemperature ~= nil then
        temp = weather:getCurrentTemperature()
    end

    local cloud = 0
    if weather.cloudUpdater ~= nil and weather.cloudUpdater.currentClouds ~= nil then
        local c = weather.cloudUpdater.currentClouds
        local precip = tonumber(c.precipitation) or 0
        local cirrus = tonumber(c.cirrusCoverage) or 0
        local dens = tonumber(c.densityScale) or 0
        cloud = math.max(precip, flwClamp(dens * 35.0, 0, 1), flwClamp(cirrus * 1.5, 0, 1))
    end

    return flwActivePrecipScale(rain), flwActivePrecipScale(snow) * 0.72, flwActivePrecipScale(hail), temp, flwClamp(cloud, 0, 1)
end

local function flwTerrainHeight(x, z)
    local terrainNode = g_terrainNode
    if (terrainNode == nil or terrainNode == 0) and g_currentMission ~= nil then
        terrainNode = g_currentMission.terrainRootNode
    end
    if terrainNode == nil or terrainNode == 0 or getTerrainHeightAtWorldPos == nil then
        return 0
    end
    return getTerrainHeightAtWorldPos(terrainNode, x, 0, z) or 0
end

local function flwDebugWorldPosition()
    local mission = g_currentMission
    local vehicle = mission ~= nil and mission.controlledVehicle or nil
    if vehicle ~= nil and vehicle.rootNode ~= nil and vehicle.rootNode ~= 0
        and getWorldTranslation ~= nil then
        return getWorldTranslation(vehicle.rootNode)
    end

    local player = g_localPlayer or (mission ~= nil and mission.player or nil)
    if player ~= nil and player.rootNode ~= nil and player.rootNode ~= 0
        and getWorldTranslation ~= nil then
        return getWorldTranslation(player.rootNode)
    end
    return nil, nil, nil
end

local function flwDrawTerrainEdge(x1, z1, x2, z2, r, g, b)
    if drawDebugLine == nil then
        return
    end
    local dx = x2 - x1
    local dz = z2 - z1
    local segments = flwClamp(math.ceil(math.sqrt(dx * dx + dz * dz) / 2), 1, 16)
    local previousX = x1
    local previousZ = z1
    local previousY = flwTerrainHeight(x1, z1) + 0.08
    for segment = 1, segments do
        local t = segment / segments
        local x = x1 + dx * t
        local z = z1 + dz * t
        local y = flwTerrainHeight(x, z) + 0.08
        drawDebugLine(
            previousX, previousY, previousZ, r, g, b,
            x, y, z, r, g, b
        )
        previousX = x
        previousY = y
        previousZ = z
    end
end

local function flwWetnessDebugColor(wetness)
    wetness = flwClamp(wetness, 0, 1)
    if wetness < 0.5 then
        local t = wetness * 2
        return flwLerp(0.95, 0.15, t), flwLerp(0.45, 0.90, t), flwLerp(0.08, 0.45, t)
    end
    local t = (wetness - 0.5) * 2
    return flwLerp(0.15, 0.05, t), flwLerp(0.90, 0.35, t), flwLerp(0.45, 1.00, t)
end

local function flwHash01(ix, iz)
    local n = math.sin(ix * 12.9898 + iz * 78.233 + 37.719) * 43758.5453
    return n - math.floor(n)
end

local function flwBuildCellFromSamples(ix, iz, size, minCoord)
    local cx = minCoord + (ix + 0.5) * size
    local cz = minCoord + (iz + 0.5) * size
    local r = math.max(size * 0.75, tonumber(FieldLocalWetness.sampleRadius) or 24.0)

    local h = flwTerrainHeight(cx, cz)
    local hN = flwTerrainHeight(cx, cz + r)
    local hS = flwTerrainHeight(cx, cz - r)
    local hE = flwTerrainHeight(cx + r, cz)
    local hW = flwTerrainHeight(cx - r, cz)
    local hNE = flwTerrainHeight(cx + r, cz + r)
    local hNW = flwTerrainHeight(cx - r, cz + r)
    local hSE = flwTerrainHeight(cx + r, cz - r)
    local hSW = flwTerrainHeight(cx - r, cz - r)

    local avgCardinal = (hN + hS + hE + hW) * 0.25
    local avgAll = (hN + hS + hE + hW + hNE + hNW + hSE + hSW) * 0.125
    local high = math.max(hN, hS, hE, hW, hNE, hNW, hSE, hSW)
    local low = math.min(hN, hS, hE, hW, hNE, hNW, hSE, hSW)
    local relief = math.max(0.01, high - low)

    local lowland = flwClamp((avgAll - h) / math.max(0.45, relief), 0, 1)
    local pocket = flwClamp((avgCardinal - h) / math.max(0.28, relief * 0.75), 0, 1)
    local ridge = flwClamp((h - avgAll) / math.max(0.45, relief), 0, 1)
    local slope = flwClamp(relief / math.max(1.0, r * 0.22), 0, 1)
    local noise = flwHash01(ix, iz)

    local retention = 1.0
        + lowland * (tonumber(FieldLocalWetness.lowlandStrength) or 0.55)
        + pocket * 0.42
        - ridge * 0.30
        - slope * (tonumber(FieldLocalWetness.slopeDrainStrength) or 0.35)
        + (noise - 0.5) * 0.14

    retention = flwClamp(retention, 0.42, 2.05)

    local startMoisture = (tonumber(FieldLocalWetness.baseMoisture) or 0.10) * retention
    startMoisture = startMoisture + (FieldLocalWetness._weatherMemory or 0) * retention

    return {
        ix = ix,
        iz = iz,
        x = cx,
        z = cz,
        height = h,
        moisture = flwClamp(startMoisture, 0, 1),
        retention = retention,
        lowland = flwClamp(math.max(lowland, pocket * 0.85), 0, 1),
        pocket = pocket,
        ridge = ridge,
        slope = slope,
        lastGameTimeMs = flwGetGameTimeMs(),
        lastTimeMs = g_time or 0
    }
end

function FieldLocalWetness:getCellKeyFromIndex(ix, iz)
    return tostring(ix) .. ":" .. tostring(iz)
end

function FieldLocalWetness:getCellIndex(x, z)
    local size, minCoord, _, cols = flwGetGrid()
    local ix = math.floor(((x or 0) - minCoord) / size)
    local iz = math.floor(((z or 0) - minCoord) / size)
    ix = flwClamp(ix, 0, cols - 1)
    iz = flwClamp(iz, 0, cols - 1)
    return ix, iz, size, minCoord
end

function FieldLocalWetness:getCellKey(x, z)
    local ix, iz, size = self:getCellIndex(x, z)
    return self:getCellKeyFromIndex(ix, iz), ix, iz, size
end

function FieldLocalWetness:buildCell(ix, iz, size, minCoord)
    return flwBuildCellFromSamples(ix, iz, size, minCoord)
end

function FieldLocalWetness:getCell(x, z)
    if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.getCellAt ~= nil then
        local layerCell = FieldLocalWetnessLayer:getCellAt(x, z)
        if layerCell ~= nil then
            return layerCell
        end
    end

    local key, ix, iz, size = self:getCellKey(x, z)
    local cell = self._cells[key]
    if cell == nil then
        local _, minCoord = flwGetGrid()
        cell = self:buildCell(ix, iz, size, minCoord)
        self._cells[key] = cell
    end
    return cell
end

function FieldLocalWetness:startTerrainScan()
    local size, minCoord, _, cols = flwGetGrid()
    self._cells = {}
    self._scan = {
        size = size,
        minCoord = minCoord,
        cols = cols,
        ix = 0,
        iz = 0,
        done = false,
        startedMs = g_time or 0
    }
end

function FieldLocalWetness:updateTerrainScan(maxCells)
    local scan = self._scan
    if scan == nil or scan.done == true then
        return
    end

    local limit = math.max(1, math.floor(tonumber(maxCells) or self.scanCellsPerUpdate or 96))
    local built = 0
    while built < limit and scan.done ~= true do
        local key = self:getCellKeyFromIndex(scan.ix, scan.iz)
        if self._cells[key] == nil then
            self._cells[key] = self:buildCell(scan.ix, scan.iz, scan.size, scan.minCoord)
        end

        built = built + 1
        scan.ix = scan.ix + 1
        if scan.ix >= scan.cols then
            scan.ix = 0
            scan.iz = scan.iz + 1
            if scan.iz >= scan.cols then
                scan.done = true
                if self.debug then
                    print(string.format("[FieldLocalWetness] terrain scan complete: cells=%d cellSize=%.1f", flwTableCount(self._cells), scan.size))
                end
            end
        end
    end
end

function FieldLocalWetness:updateCell(cell)
    if cell == nil then return nil end

    local now = flwGetGameTimeMs()
    local last = cell.lastGameTimeMs or now
    local dtH = math.max(0, now - last) / 3600000
    if dtH <= 0 then
        return cell.moisture or 0
    end
    if dtH > 2.0 then
        dtH = 2.0
    end

    local rain, snow, hail, temp, cloud = flwGetWeatherState()
    local precipGain =
        rain * (tonumber(self.rainGainPerHour) or 0.82)
        + snow * (tonumber(self.snowGainPerHour) or 0.18)
        + hail * (tonumber(self.hailGainPerHour) or 0.34)

    local tempDryMul = 1.0
    if temp ~= nil then
        if temp <= 0 then
            tempDryMul = 0.22
        elseif temp >= 30 then
            tempDryMul = 1.28
        else
            tempDryMul = flwLerp(0.45, 1.12, flwClamp(temp / 30, 0, 1))
        end
    end

    local cloudDryMul = flwLerp(1.00, 0.45, cloud)
    local memoryDryMul = flwLerp(1.00, 0.55, flwClamp((self._weatherMemory or 0) / 0.50, 0, 1))
    local retention = flwClamp(cell.retention or 1.0, 0.42, 2.05)
    local lowland = flwClamp(cell.lowland or 0, 0, 1)
    local pocket = flwClamp(cell.pocket or 0, 0, 1)
    local ridge = flwClamp(cell.ridge or 0, 0, 1)
    local slope = flwClamp(cell.slope or 0, 0, 1)

    local rainK = flwClamp(rain + snow * 0.45 + hail * 0.65, 0, 1)
    local soakMul = 0.78 + retention * 0.45 + lowland * 0.95 + pocket * 0.55
    soakMul = soakMul * flwLerp(1.0, 1.18 + lowland * 0.26 + pocket * 0.16, rainK)
    local drainMul = flwClamp(1.44 - retention * 0.55 - lowland * 0.24 + ridge * 0.10 + slope * 0.06, 0.28, 1.02)

    local gain = precipGain * soakMul * dtH
    local precipDryMul = flwLerp(1.0, 0.18, rainK)
    local dry = (tonumber(self.dryLossPerHour) or 0.16) * tempDryMul * cloudDryMul * memoryDryMul * drainMul * precipDryMul * dtH

    local moisture = (cell.moisture or 0) + gain - dry
    cell.moisture = flwClamp(moisture, tonumber(self.minWetness) or 0.04, tonumber(self.maxWetness) or 1.0)
    cell.lastGameTimeMs = now
    cell.lastTimeMs = g_time or 0

    return cell.moisture
end

function FieldLocalWetness:getWetnessAt(x, z, dt)
    if self.enabled ~= true or x == nil or z == nil then
        return nil
    end

    if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.getWetnessAt ~= nil then
        return FieldLocalWetnessLayer:getWetnessAt(x, z, self)
    end

    local cell = self:getCell(x, z)
    return self:updateCell(cell), cell
end

function FieldLocalWetness:getEffectiveWetnessAt(x, z, fallbackWetness, dt, prof)
    if self.enabled ~= true then
        return fallbackWetness or 0
    end

    local localWetness = self:getWetnessAt(x, z, dt)
    if localWetness == nil then
        return fallbackWetness or 0
    end

    prof = prof or flwGetProfileAt(x, z)
    local strength = flwClamp(tonumber(self.effectStrength) or 0.80, 0, 1)
    local profileMul = flwLerp(1.0, flwGetProfileWetnessMul(prof), strength)
    return flwClamp(localWetness * profileMul, 0, 1)
end

function FieldLocalWetness:getVehicleWetness(vehicle, fallbackWetness, dt)
    if self.enabled ~= true or vehicle == nil or vehicle.spec_wheels == nil or vehicle.spec_wheels.wheels == nil then
        return fallbackWetness or 0
    end

    local sum, count = 0, 0
    for _, wheel in ipairs(vehicle.spec_wheels.wheels) do
        if wheel ~= nil and FieldGroundMudPhysics ~= nil and FieldGroundMudPhysics.getWheelContactPos ~= nil then
            local x, _, z = FieldGroundMudPhysics:getWheelContactPos(wheel)
            if x ~= nil then
                local prof = nil
                if FieldGroundMudPhysics.getWheelGroundProfile ~= nil then
                    local _mud, _gt
                    _mud, _gt, prof = FieldGroundMudPhysics:getWheelGroundProfile(wheel)
                else
                    prof = flwGetProfileAt(x, z)
                end

                local wet = self:getWetnessAt(x, z, dt)
                if wet ~= nil then
                    local strength = flwClamp(tonumber(self.effectStrength) or 0.80, 0, 1)
                    wet = flwClamp(wet * flwLerp(1.0, flwGetProfileWetnessMul(prof), strength), 0, 1)
                    sum = sum + wet
                    count = count + 1
                end
            end
        end
    end

    if count <= 0 then
        return fallbackWetness or 0
    end

    return flwClamp(sum / count, 0, 1)
end

function FieldLocalWetness:getPermaChanceMultiplierAt(x, z, wetnessEff, dt, prof)
    if self.enabled ~= true then
        return 1.0
    end

    local wet, cell = self:getWetnessAt(x, z, dt)
    if wet == nil then
        wet = wetnessEff or 0
    end

    prof = prof or flwGetProfileAt(x, z)
    wet = flwClamp(wet * flwGetProfileWetnessMul(prof), 0, 1)

    local minWet = flwClamp(tonumber(self.permaChanceMinWetness) or 0.35, 0, 1)
    local wet01 = flwClamp((wet - minWet) / math.max(0.01, 1 - minWet), 0, 1)
    local lowland = (cell ~= nil and cell.lowland) or 0
    local pocket = (cell ~= nil and cell.pocket) or 0
    local ridge = (cell ~= nil and cell.ridge) or 0
    local slope = (cell ~= nil and cell.slope) or 0
    local strength = math.max(0, tonumber(self.permaChanceStrength) or 1.25)

    local landscape = 0.28 + lowland * 1.18 + pocket * 0.60 - ridge * 0.35 - slope * 0.12
    local mult = 1.0 + wet01 * strength * math.max(0.05, landscape) * flwGetProfilePermaMul(prof)
    mult = mult - ridge * 0.20 * wet01
    return flwClamp(mult, 0.25, 4.75)
end

function FieldLocalWetness:getRadiusEffectMultiplierAt(x, z, wetnessEff, dt, prof)
    if self.enabled ~= true then
        return 1.0
    end

    local wet, cell = self:getWetnessAt(x, z, dt)
    if wet == nil then
        wet = wetnessEff or 0
    end

    prof = prof or flwGetProfileAt(x, z)
    wet = flwClamp(wet * flwGetProfileWetnessMul(prof), 0, 1)

    local lowland = (cell ~= nil and cell.lowland) or 0
    local pocket = (cell ~= nil and cell.pocket) or 0
    local ridge = (cell ~= nil and cell.ridge) or 0
    local slope = (cell ~= nil and cell.slope) or 0
    local strength = flwClamp(tonumber(self.radiusEffectStrength) or 0.75, 0, 2.0)

    local wet01 = flwClamp((wet - 0.12) / 0.88, 0, 1)
    local terrainK = 0.70 + lowland * 0.65 + pocket * 0.45 - ridge * 0.20 - slope * 0.10
    local mult = 1.0 + wet01 * strength * flwClamp(terrainK, 0.35, 1.95)

    return flwClamp(mult, 1.0, 2.35)
end

function FieldLocalWetness:onSettingsChanged()
    -- Re-apply the calibrated values after loading settings or receiving a
    -- multiplayer snapshot. Old XML values from versions with sliders can no
    -- longer alter the hydrology model; only 'enabled' remains configurable.
    for key, value in pairs(self.MODEL_PARAMETERS) do
        self[key] = value
    end
end

function FieldLocalWetness:updateWeatherMemory(dt)
    local now = flwGetGameTimeMs()
    local last = self._lastWeatherGameTimeMs or now
    local dtH = math.max(0, now - last) / 3600000
    self._lastWeatherGameTimeMs = now

    if dtH <= 0 then
        return
    end
    if dtH > 2.0 then
        dtH = 2.0
    end

    local rain, snow, hail, temp, cloud = flwGetWeatherState()
    local precipGain =
        rain * (tonumber(self.rainGainPerHour) or 0.82)
        + snow * (tonumber(self.snowGainPerHour) or 0.18)
        + hail * (tonumber(self.hailGainPerHour) or 0.34)

    local tempDryMul = 1.0
    if temp ~= nil then
        if temp <= 0 then
            tempDryMul = 0.25
        elseif temp >= 30 then
            tempDryMul = 1.45
        else
            tempDryMul = flwLerp(0.60, 1.20, flwClamp(temp / 30, 0, 1))
        end
    end

    local cloudDryMul = flwLerp(1.10, 0.50, cloud)
    local rainK = flwClamp(rain + snow * 0.45 + hail * 0.65, 0, 1)
    local dry = (tonumber(self.dryLossPerHour) or 0.16) * tempDryMul * cloudDryMul * flwLerp(1.0, 0.22, rainK) * dtH
    local gain = precipGain * flwLerp(1.0, 1.28, rainK) * dtH

    self._weatherMemory = flwClamp((self._weatherMemory or 0) + gain - dry, 0, 0.75)
end

function FieldLocalWetness:update(dt)
    if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.update ~= nil then
        FieldLocalWetnessLayer:update(self, dt)
        return
    end

    if self.enabled ~= true then return end
    self:updateTerrainScan(self.scanCellsPerUpdate)
    self:updateWeatherMemory(dt)
end

function FieldLocalWetness:draw()
    if self.gridDebugEnabled ~= true or drawDebugLine == nil then
        return
    end

    local layer = FieldLocalWetnessLayer
    if layer == nil or layer.geometryReady ~= true
        or (tonumber(layer.gridSize) or 0) <= 0
        or (tonumber(layer.cellMeters) or 0) < 0.25 then
        return
    end

    local px, _, pz = flwDebugWorldPosition()
    if px == nil or pz == nil then
        return
    end

    local radius = flwClamp(self.gridDebugRadius or 32, 8, 150)
    local cellMeters = layer.cellMeters
    local gridSize = layer.gridSize
    local minCoord = layer.minCoord
    local startX = flwClamp(math.floor((px - radius - minCoord) / cellMeters), 0, gridSize - 1)
    local endX = flwClamp(math.floor((px + radius - minCoord) / cellMeters), 0, gridSize - 1)
    local startZ = flwClamp(math.floor((pz - radius - minCoord) / cellMeters), 0, gridSize - 1)
    local endZ = flwClamp(math.floor((pz + radius - minCoord) / cellMeters), 0, gridSize - 1)
    local currentX = flwClamp(math.floor((px - minCoord) / cellMeters), 0, gridSize - 1)
    local currentZ = flwClamp(math.floor((pz - minCoord) / cellMeters), 0, gridSize - 1)
    local labelRadius = math.min(radius, math.max(10, cellMeters * 1.6))

    for iz = startZ, endZ do
        for ix = startX, endX do
            local x0 = minCoord + ix * cellMeters
            local z0 = minCoord + iz * cellMeters
            local x1 = x0 + cellMeters
            local z1 = z0 + cellMeters
            local cx = (x0 + x1) * 0.5
            local cz = (z0 + z1) * 0.5
            local dx = cx - px
            local dz = cz - pz

            if dx * dx + dz * dz <= (radius + cellMeters) * (radius + cellMeters) then
                local wetness, cell = layer:getWetnessAt(cx, cz, self)
                wetness = flwClamp(wetness or 0, 0, 1)
                local r, g, b = flwWetnessDebugColor(wetness)
                flwDrawTerrainEdge(x0, z0, x1, z0, r, g, b)
                flwDrawTerrainEdge(x1, z0, x1, z1, r, g, b)
                flwDrawTerrainEdge(x1, z1, x0, z1, r, g, b)
                flwDrawTerrainEdge(x0, z1, x0, z0, r, g, b)

                local isCurrent = ix == currentX and iz == currentZ
                if isCurrent then
                    local y = flwTerrainHeight(cx, cz) + 0.1
                    drawDebugLine(cx, y, cz, 1, 1, 1, cx, y + 1.2, cz, 1, 1, 1)
                end

                if self.gridDebugLabels == true and Utils ~= nil
                    and Utils.renderTextAtWorldPosition ~= nil
                    and (isCurrent or dx * dx + dz * dz <= labelRadius * labelRadius) then
                    local y = flwTerrainHeight(cx, cz) + (isCurrent and 1.35 or 0.20)
                    local depressionCm = cell ~= nil
                        and math.max(0, tonumber(cell.microDepressionDepth) or 0) * 100 or 0
                    local text = string.format(
                        "[%d,%d] %d%% | %.2fm | pit %.0fcm",
                        ix,
                        iz,
                        math.floor(wetness * 100 + 0.5),
                        cellMeters,
                        depressionCm
                    )
                    Utils.renderTextAtWorldPosition(
                        cx, y, cz, text, isCurrent and 0.016 or 0.011,
                        0, r, g, b, 1
                    )
                end
            end
        end
    end
end

function FieldLocalWetness:loadMap()
    self._cells = {}
    self._weatherMemory = 0
    self._gameDayOffsetMs = 0
    self._lastGameDayTimeMs = nil
    self._lastWeatherGameTimeMs = flwGetGameTimeMs()
    self:onSettingsChanged()
    if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.load ~= nil then
        FieldLocalWetnessLayer:load(self)
    elseif self.scanOnLoad == true then
        self:startTerrainScan()
    end
    addConsoleCommand("gsFieldWetness", "Field local wetness on/off/debug/reset/scan/status", "consoleCommand", self)
    addConsoleCommand(
        "gsFieldWetnessGrid",
        "Draw local wetness terrain cells: on/off/toggle/status [radius] [on/off labels]",
        "consoleGridCommand",
        self
    )
end

function FieldLocalWetness:deleteMap()
    removeConsoleCommand("gsFieldWetness")
    removeConsoleCommand("gsFieldWetnessGrid")
    if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.delete ~= nil then
        FieldLocalWetnessLayer:delete()
    end
    self._cells = {}
    self._scan = nil
    self._lastWeatherGameTimeMs = nil
end

function FieldLocalWetness:consoleGridCommand(mode, radiusArg, labelsMode, labelsArg)
    mode = string.lower(tostring(mode or "toggle"))
    local numericMode = tonumber(mode)

    if numericMode ~= nil then
        self.gridDebugRadius = flwClamp(numericMode, 8, 150)
        self.gridDebugEnabled = true
    elseif mode == "on" then
        self.gridDebugEnabled = true
    elseif mode == "off" then
        self.gridDebugEnabled = false
    elseif mode == "toggle" or mode == "" then
        self.gridDebugEnabled = not self.gridDebugEnabled
    elseif mode == "radius" then
        local radius = tonumber(radiusArg)
        if radius ~= nil then
            self.gridDebugRadius = flwClamp(radius, 8, 150)
            self.gridDebugEnabled = true
        end
        radiusArg = nil
    elseif mode == "labels" then
        local value = string.lower(tostring(radiusArg or "toggle"))
        if value == "on" then
            self.gridDebugLabels = true
        elseif value == "off" then
            self.gridDebugLabels = false
        else
            self.gridDebugLabels = not self.gridDebugLabels
        end
        radiusArg = nil
    elseif mode ~= "status" then
        return "Usage: gsFieldWetnessGrid on|off|toggle|status [radius] [labels on/off]"
    end

    local radius = tonumber(radiusArg)
    if radius ~= nil then
        self.gridDebugRadius = flwClamp(radius, 8, 150)
    end
    local labelValue = string.lower(tostring(labelsMode or ""))
    if labelValue == "labels" then
        labelValue = string.lower(tostring(labelsArg or ""))
    end
    if labelValue == "on" then
        self.gridDebugLabels = true
    elseif labelValue == "off" then
        self.gridDebugLabels = false
    end

    local layer = FieldLocalWetnessLayer
    local gridSize = layer ~= nil and tonumber(layer.gridSize) or 0
    local cellMeters = layer ~= nil and tonumber(layer.cellMeters) or 0
    local terrainSize = layer ~= nil and tonumber(layer.terrainSize) or 0
    return string.format(
        "FieldLocalWetness grid=%s radius=%.0fm labels=%s terrain=%.0fm hydro=%dx%d cell=%.2fm scan=%s",
        tostring(self.gridDebugEnabled),
        self.gridDebugRadius,
        tostring(self.gridDebugLabels),
        terrainSize,
        gridSize,
        gridSize,
        cellMeters,
        layer ~= nil and layer.getStatus ~= nil and layer:getStatus() or "legacy"
    )
end

function FieldLocalWetness:consoleCommand(arg)
    arg = tostring(arg or "")
    if arg == "on" then
        self.enabled = true
    elseif arg == "off" then
        self.enabled = false
    elseif arg == "debug" then
        self.debug = not self.debug
    elseif arg == "reset" then
        if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.resetMoisture ~= nil then
            FieldLocalWetnessLayer:resetMoisture(self)
        else
            self:startTerrainScan()
        end
    elseif arg == "scan" then
        if FieldLocalWetnessLayer ~= nil and FieldLocalWetnessLayer.startTerrainScan ~= nil then
            if FieldLocalWetnessLayer.flushRows ~= nil then
                FieldLocalWetnessLayer:flushRows(FieldLocalWetnessLayer.mapSize)
            end
            FieldLocalWetnessLayer:startTerrainScan()
        else
            self:startTerrainScan()
            self:updateTerrainScan(1000000000)
        end
    end

    local scan = self._scan
    local scanText = "none"
    if scan ~= nil then
        local done = scan.done == true
        local doneCells = (scan.iz or 0) * (scan.cols or 0) + (scan.ix or 0)
        local total = (scan.cols or 0) * (scan.cols or 0)
        scanText = string.format("%s %d/%d", tostring(done), doneCells, total)
    end

    local rain, snow, hail = flwGetWeatherState()

    local layerStatus = FieldLocalWetnessLayer ~= nil
        and FieldLocalWetnessLayer.getStatus ~= nil
        and FieldLocalWetnessLayer:getStatus() or "legacy"

    local cellCount = FieldLocalWetnessLayer ~= nil
        and FieldLocalWetnessLayer.cells ~= nil
        and #FieldLocalWetnessLayer.cells or flwTableCount(self._cells)

    return string.format("FieldLocalWetness enabled=%s debug=%s cells=%d scan=%s rain=%.2f snow=%.2f hail=%.2f memory=%.2f %s",
        tostring(self.enabled),
        tostring(self.debug),
        cellCount,
        scanText,
        rain or 0,
        snow or 0,
        hail or 0,
        self._weatherMemory or 0,
        layerStatus)
end
