gbnet-hs-0.2.2.0: src/GBNet/Replication/Priority.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UndecidableInstances #-}
-- |
-- Module : GBNet.Replication.Priority
-- Description : Priority accumulator for bandwidth-limited entity replication
--
-- 'PriorityAccumulator' tracks per-entity priority that grows over time,
-- ensuring all entities eventually get sent even at low priority.
--
-- Use this to fairly allocate bandwidth when replicating many entities:
--
-- @
-- let acc = newPriorityAccumulator
-- & register playerId 10.0 -- high priority (10 units/sec)
-- & register npcId 2.0 -- low priority (2 units/sec)
--
-- -- Each tick, accumulate priority based on elapsed time
-- let accumulated = accumulate 0.016 acc -- 16ms tick
--
-- -- Drain entities that fit in budget
-- let (selected, acc'') = drainTop 1200 entitySize acc'
-- @
module GBNet.Replication.Priority
( -- * Priority accumulator
PriorityAccumulator,
newPriorityAccumulator,
-- * Entity management
register,
unregister,
-- * Priority operations
accumulate,
applyModifier,
drainTop,
-- * Queries
priorityCount,
priorityIsEmpty,
getPriority,
)
where
import Data.List (sortBy)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Ord (Down (..), comparing)
import Optics ((%~), (&), (.~))
import Optics.TH (makeFieldLabelsNoPrefix)
-- | Per-entity priority tracking entry.
data PriorityEntry = PriorityEntry
{ peBase :: !Float,
peAccumulated :: !Float
}
deriving (Show)
makeFieldLabelsNoPrefix ''PriorityEntry
-- | Accumulates priority per entity and drains the highest-priority entities
-- that fit within a byte budget.
--
-- Type parameter @id@ is the entity identifier type (must be 'Ord').
newtype PriorityAccumulator id = PriorityAccumulator
{ paEntries :: Map id PriorityEntry
}
deriving (Show)
-- | Create an empty priority accumulator.
newPriorityAccumulator :: PriorityAccumulator id
newPriorityAccumulator = PriorityAccumulator Map.empty
-- | Register an entity with a base priority.
--
-- Higher base priority = more frequent sends.
-- Priority accumulates as @base * dt@ each tick.
register :: (Ord id) => id -> Float -> PriorityAccumulator id -> PriorityAccumulator id
register entityId basePriority (PriorityAccumulator entries) =
PriorityAccumulator $
Map.insert
entityId
PriorityEntry {peBase = basePriority, peAccumulated = 0.0}
entries
-- | Remove an entity from tracking.
unregister :: (Ord id) => id -> PriorityAccumulator id -> PriorityAccumulator id
unregister entityId (PriorityAccumulator entries) =
PriorityAccumulator $ Map.delete entityId entries
-- | Advance accumulated priority for all entities by @dt@ seconds.
--
-- Call this once per tick with elapsed time.
accumulate :: Float -> PriorityAccumulator id -> PriorityAccumulator id
accumulate dt (PriorityAccumulator entries) =
PriorityAccumulator $
Map.map
(\e -> e & #peAccumulated %~ (+ (peBase e * dt)))
entries
-- | Apply a priority modifier to a specific entity.
--
-- Use with interest management: closer entities get modifier > 1.0,
-- farther entities get modifier < 1.0.
applyModifier :: (Ord id) => id -> Float -> PriorityAccumulator id -> PriorityAccumulator id
applyModifier entityId modifier (PriorityAccumulator entries) =
PriorityAccumulator $
Map.adjust
(\e -> e & #peAccumulated %~ (* modifier))
entityId
entries
-- | Drain the highest-priority entities that fit within @budgetBytes@.
--
-- @sizeFunc@ returns the serialized size in bytes for a given entity ID.
-- Returns the selected entity IDs in priority order (highest first),
-- and the updated accumulator with those entities' priorities reset.
drainTop ::
(Ord id) =>
-- | Byte budget
Int ->
-- | Size function: id -> bytes
(id -> Int) ->
PriorityAccumulator id ->
([id], PriorityAccumulator id)
drainTop budgetBytes sizeFunc (PriorityAccumulator entries) =
let -- Sort by accumulated priority (descending)
sorted =
sortBy (comparing (Down . peAccumulated . snd)) $
Map.toList entries
-- Select entities that fit in budget
(selected, _remaining) = selectWithinBudget budgetBytes sizeFunc sorted []
-- Reset priority for selected entities
resetEntries =
foldr
(Map.adjust (#peAccumulated .~ 0.0))
entries
selected
in (selected, PriorityAccumulator resetEntries)
-- | Helper to select entities within budget.
selectWithinBudget ::
Int ->
(id -> Int) ->
[(id, PriorityEntry)] ->
[id] ->
([id], Int)
selectWithinBudget remaining _ [] acc = (reverse acc, remaining)
selectWithinBudget remaining sizeFunc ((eid, _) : rest) acc =
let size = sizeFunc eid
in if size > remaining
then selectWithinBudget remaining sizeFunc rest acc
else selectWithinBudget (remaining - size) sizeFunc rest (eid : acc)
-- | Number of tracked entities.
priorityCount :: PriorityAccumulator id -> Int
priorityCount = Map.size . paEntries
-- | Whether the accumulator is empty.
priorityIsEmpty :: PriorityAccumulator id -> Bool
priorityIsEmpty = Map.null . paEntries
-- | Get the current accumulated priority for an entity.
getPriority :: (Ord id) => id -> PriorityAccumulator id -> Maybe Float
getPriority entityId (PriorityAccumulator entries) =
peAccumulated <$> Map.lookup entityId entries