--!strict local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") local SharedFolder = ReplicatedStorage:WaitForChild("Shared") local LibFolder = ReplicatedStorage:WaitForChild("Lib") local UtilFolder = LibFolder:WaitForChild("Util") local PlayerStateStoreModule = require(SharedFolder:WaitForChild("PlayerStateStore")) local EventBusModule = require(SharedFolder:WaitForChild("EventBus")) local RNGUtils = require(UtilFolder:WaitForChild("RNGUtils")) local ServerFolder = ServerScriptService:WaitForChild("Server") local LevelServiceModule = require(ServerFolder:WaitForChild("LevelService")) export type DamageArgs = { attacker: Player?, target: Player, baseDamage: number, source: string?, canCrit: boolean?, critChance: number?, critMultiplier: number?, xpReward: number?, tags: { [string]: boolean }?, } type PlayerState = { Level: number, Experience: number, Coins: number, Items: { [string]: number }, Health: number?, MaxHealth: number?, } local CombatService = {} local _initialized = false local _playerStateStore: any = nil local _eventBus: any = nil local _combatChannel: any = nil local _levelService: any = nil local _rngStream: any = nil local DEFAULT_MAX_HEALTH = 100 local DEFAULT_HEALTH = 100 local function resolveHealthDefaults(state: PlayerState): (number, number) local rawMax = state.MaxHealth if rawMax == nil or rawMax <= 0 then rawMax = DEFAULT_MAX_HEALTH state.MaxHealth = rawMax end local maxHealth: number = rawMax :: number local rawHealth = state.Health if rawHealth == nil or rawHealth <= 0 or rawHealth > maxHealth then local h = DEFAULT_HEALTH if h > maxHealth then h = maxHealth end rawHealth = h state.Health = rawHealth end local health: number = rawHealth :: number return health, maxHealth end local function nextUnitFloat(): number local stream = _rngStream if stream ~= nil then return stream:NextFloat() end return math.random() end local function computeFinalDamage(args: DamageArgs): (number, boolean) local base = args.baseDamage if base <= 0 then return 0, false end local canCrit = if args.canCrit == nil then true else args.canCrit local crit = false local final: number = base local rawCritChance = args.critChance local critChance: number = if rawCritChance ~= nil and rawCritChance > 0 then rawCritChance else 0 if canCrit and critChance > 0 then local r = nextUnitFloat() if r < critChance then crit = true local rawMult = args.critMultiplier local mult: number if rawMult == nil or rawMult <= 1 then mult = 2.0 else mult = rawMult end final = base * mult end end if final < 0 then final = 0 end local rounded = math.floor(final + 0.5) return rounded, crit end function CombatService:Init(playerStateStore: any?, eventBus: any?, levelService: any?) if _initialized then return end _playerStateStore = playerStateStore or PlayerStateStoreModule _eventBus = eventBus or EventBusModule _levelService = levelService or LevelServiceModule if _eventBus == nil then error("CombatService:Init missing EventBus") end _combatChannel = _eventBus:GetChannel("Combat") _rngStream = (RNGUtils :: any).CreateStream(os.time(), 1) _initialized = true end function CombatService:IsInitialized(): boolean return _initialized end function CombatService:ApplyDamage(args: DamageArgs) if not _initialized then warn("CombatService:ApplyDamage called before Init") return end local store = _playerStateStore local channel = _combatChannel if store == nil or channel == nil then warn("CombatService: missing dependencies") return end local target = args.target if typeof(target) ~= "Instance" or not target:IsA("Player") then return end local finalDamage, crit = computeFinalDamage(args) if finalDamage <= 0 then return end local attacker = args.attacker local source = args.source local xpReward = args.xpReward local tags = args.tags local oldHealth = 0 local newHealth = 0 local maxHealth = 0 local wasAlive = false local isAlive = false store:Update(target, function(state: PlayerState): PlayerState local hp, maxHp = resolveHealthDefaults(state) oldHealth = hp maxHealth = maxHp wasAlive = hp > 0 if not wasAlive then newHealth = hp isAlive = false return state end local nh = hp - finalDamage if nh < 0 then nh = 0 end state.Health = nh newHealth = nh isAlive = nh > 0 return state end) if not wasAlive then return end local payload = { source = source, baseDamage = args.baseDamage, finalDamage = finalDamage, crit = crit, oldHealth = oldHealth, newHealth = newHealth, maxHealth = maxHealth, xpReward = xpReward, tags = tags, } channel:Emit("DamageApplied", attacker, target, payload) if not isAlive then channel:Emit("Death", attacker, target, payload) if attacker ~= nil and xpReward ~= nil and xpReward > 0 then local ls = _levelService if ls ~= nil then ls:AwardXP(attacker, xpReward, source or "Combat") end end end end function CombatService:OnDamageApplied(handler: (Player?, Player, any) -> ()) if not _initialized or _combatChannel == nil then error("CombatService:OnDamageApplied called before Init") end return _combatChannel:On("DamageApplied", handler) end function CombatService:OnDeath(handler: (Player?, Player, any) -> ()) if not _initialized or _combatChannel == nil then error("CombatService:OnDeath called before Init") end return _combatChannel:On("Death", handler) end return CombatService