512 lines
23 KiB
Lua
512 lines
23 KiB
Lua
local ____lualib = require("lualib_bundle")
|
||
local __TS__ArrayMap = ____lualib.__TS__ArrayMap
|
||
local __TS__Class = ____lualib.__TS__Class
|
||
local ____exports = {}
|
||
local ____battle_pass_server = require("battle_pass_server")
|
||
local BattlePassServer = ____battle_pass_server.BattlePassServer
|
||
local ____difficulty_manager = require("difficulty_manager")
|
||
local Difficulty = ____difficulty_manager.Difficulty
|
||
local ____game_stats_tracker = require("game_stats_tracker")
|
||
local GameStatsTracker = ____game_stats_tracker.GameStatsTracker
|
||
local ____match_end_combat_stats = require("match_end_combat_stats")
|
||
local MatchEndCombatStats = ____match_end_combat_stats.MatchEndCombatStats
|
||
local ____store_manager = require("store_manager")
|
||
local StoreManager = ____store_manager.StoreManager
|
||
local ____ArsenalCatalog = require("arsenal.ArsenalCatalog")
|
||
local ARSENAL_ITEMS = ____ArsenalCatalog.ARSENAL_ITEMS
|
||
local ____ArsenalStats = require("arsenal.ArsenalStats")
|
||
local contractRewardMultToDropStatBias01 = ____ArsenalStats.contractRewardMultToDropStatBias01
|
||
local ____ArsenalManager = require("arsenal.ArsenalManager")
|
||
local grantArsenalItemDetailed = ____ArsenalManager.grantArsenalItemDetailed
|
||
local flushArsenalInventoryToClientAndApi = ____ArsenalManager.flushArsenalInventoryToClientAndApi
|
||
local ____luck = require("utils.luck")
|
||
local rollLuckChance = ____luck.rollLuckChance
|
||
local getLuck = ____luck.getLuck
|
||
--- За каждые N единиц удачи героя — доп. предмет арсенала: на Death Sentence любое качество, иначе только epic и ниже.
|
||
local ARSENAL_BONUS_DROP_LUCK_PER_ITEM = 100
|
||
local ARSENAL_FULL_RANDOM_QUALITIES = {
|
||
"common",
|
||
"rare",
|
||
"epic",
|
||
"legendary",
|
||
"mythic"
|
||
}
|
||
--- Бонус за удачу вне DS: только common / rare / epic.
|
||
local ARSENAL_LUCK_BONUS_CAP_EPIC_QUALITIES = {"common", "rare", "epic"}
|
||
--- Качество бонусного дропа за удачу (полный спектр только на смертельном приговоре).
|
||
local function rollArsenalLuckBonusDropQuality(self)
|
||
if Difficulty.leader == "death_sentence" then
|
||
return ARSENAL_FULL_RANDOM_QUALITIES[RandomInt(0, #ARSENAL_FULL_RANDOM_QUALITIES - 1) + 1]
|
||
end
|
||
return ARSENAL_LUCK_BONUS_CAP_EPIC_QUALITIES[RandomInt(0, #ARSENAL_LUCK_BONUS_CAP_EPIC_QUALITIES - 1) + 1]
|
||
end
|
||
--- Фиксированное число строк таблицы (слоты Radiant 0–3).
|
||
____exports.MATCH_END_PLAYER_SLOTS = 4
|
||
--- Множитель к итоговой награде за победу, если у игрока куплен Battle Pass Premium (+50%).
|
||
local MATCH_END_PREMIUM_REWARD_MULT = 1.5
|
||
--- Случайный шаблон из каталога (любой слот); редкость экземпляра задаётся отдельно при выдаче.
|
||
local function pickRandomArsenalTemplateItemName(self)
|
||
local n = #ARSENAL_ITEMS
|
||
if n <= 0 then
|
||
return nil
|
||
end
|
||
local idx = RandomInt(0, n - 1)
|
||
local ____opt_0 = ARSENAL_ITEMS[idx + 1]
|
||
return ____opt_0 and ____opt_0.itemName or nil
|
||
end
|
||
local function rollArsenalDropQuality(self, playerId)
|
||
local hero = PlayerResource:GetSelectedHeroEntity(playerId)
|
||
if Difficulty.leader == "easy" then
|
||
if hero and rollLuckChance(nil, hero, 0.12) then
|
||
return "rare"
|
||
end
|
||
return "common"
|
||
end
|
||
if Difficulty.leader == "normal" then
|
||
if hero and rollLuckChance(nil, hero, 0.3) then
|
||
return "rare"
|
||
end
|
||
return "common"
|
||
end
|
||
if Difficulty.leader == "hard" then
|
||
if hero and rollLuckChance(nil, hero, 0.18) then
|
||
return "epic"
|
||
end
|
||
return "rare"
|
||
end
|
||
if Difficulty.leader == "impossible" then
|
||
if hero and rollLuckChance(nil, hero, 0.3) then
|
||
return "legendary"
|
||
end
|
||
return "epic"
|
||
end
|
||
local snap = Difficulty:getWinningContractSnapshot()
|
||
local rarity = snap and snap.rarity or "epic"
|
||
local rewardMult = snap and snap.rewardMultiplier or Difficulty:getDeathSentenceContractRewardMultiplier()
|
||
local multBias = contractRewardMultToDropStatBias01(nil, rewardMult)
|
||
local mythicChance = 8
|
||
if rarity == "common" then
|
||
mythicChance = 2
|
||
end
|
||
if rarity == "rare" then
|
||
mythicChance = 4
|
||
end
|
||
if rarity == "epic" then
|
||
mythicChance = 10
|
||
end
|
||
if rarity == "legendary" then
|
||
mythicChance = 18
|
||
end
|
||
if rarity == "mythic" then
|
||
mythicChance = 26
|
||
end
|
||
mythicChance = math.min(
|
||
32,
|
||
mythicChance + math.floor(multBias * 6)
|
||
)
|
||
if hero and rollLuckChance(nil, hero, mythicChance / 100) then
|
||
return "mythic"
|
||
end
|
||
local legendaryRoll = math.min(0.72, 0.55 + multBias * 0.22)
|
||
if hero and rollLuckChance(nil, hero, legendaryRoll) then
|
||
return "legendary"
|
||
end
|
||
return "epic"
|
||
end
|
||
local function getArsenalWinDropCount(self)
|
||
local diffMult = Difficulty:getNpcStatScale()
|
||
local scaled = math.ceil(diffMult * 0.75)
|
||
local base = math.max(1, scaled)
|
||
local bonusTiers = math.floor(diffMult / 5)
|
||
local bonus = bonusTiers > 0 and bonusTiers or 0
|
||
return math.max(1, base + bonus)
|
||
end
|
||
local function grantArsenalWinDrops(self, playerId)
|
||
local granted = {}
|
||
local deferredPersistence = false
|
||
local diffMult = Difficulty:getNpcStatScale()
|
||
local dropCount = getArsenalWinDropCount(nil)
|
||
local arsenalRollContext = Difficulty.leader == "death_sentence" and ({contractDropStatBias = contractRewardMultToDropStatBias01(
|
||
nil,
|
||
Difficulty:getDeathSentenceContractRewardMultiplier()
|
||
)}) or nil
|
||
local baseScaled = math.ceil(diffMult * 0.75)
|
||
local baseOnly = math.max(1, baseScaled)
|
||
local bonusTiers = math.floor(diffMult / 5)
|
||
print((((((((((("[MatchEndRewards] player=" .. tostring(playerId)) .. " roll arsenal drops: count=") .. tostring(dropCount)) .. " (base=") .. tostring(baseOnly)) .. ", bonus_tiers_5=") .. tostring(bonusTiers)) .. "), difficulty=") .. Difficulty.leader) .. ", mult=") .. tostring(diffMult))
|
||
do
|
||
local i = 0
|
||
while i < dropCount do
|
||
local quality = rollArsenalDropQuality(nil, playerId)
|
||
local itemName = pickRandomArsenalTemplateItemName(nil)
|
||
if itemName then
|
||
deferredPersistence = true
|
||
local detailed = grantArsenalItemDetailed(
|
||
nil,
|
||
playerId,
|
||
itemName,
|
||
1,
|
||
quality,
|
||
arsenalRollContext,
|
||
{deferPersistence = true}
|
||
)
|
||
local created = detailed[1]
|
||
if created ~= nil then
|
||
granted[#granted + 1] = {item_name = created.itemName, instance_id = created.instanceId, quality = quality}
|
||
end
|
||
print((((((((("[MatchEndRewards] player=" .. tostring(playerId)) .. " drop ") .. tostring(i + 1)) .. "/") .. tostring(dropCount)) .. ": quality=") .. quality) .. ", item=") .. itemName)
|
||
else
|
||
print((((((("[MatchEndRewards] player=" .. tostring(playerId)) .. " drop ") .. tostring(i + 1)) .. "/") .. tostring(dropCount)) .. ": empty pool for quality=") .. quality)
|
||
end
|
||
i = i + 1
|
||
end
|
||
end
|
||
local hero = PlayerResource:GetSelectedHeroEntity(playerId)
|
||
local bonusFromLuck = hero and math.floor(getLuck(nil, hero) / ARSENAL_BONUS_DROP_LUCK_PER_ITEM) or 0
|
||
if bonusFromLuck > 0 then
|
||
print(((((("[MatchEndRewards] player=" .. tostring(playerId)) .. " luck bonus arsenal drops: ") .. tostring(bonusFromLuck)) .. " (luck=") .. tostring(hero and getLuck(nil, hero) or 0)) .. ")")
|
||
end
|
||
do
|
||
local b = 0
|
||
while b < bonusFromLuck do
|
||
local quality = rollArsenalLuckBonusDropQuality(nil)
|
||
local itemName = pickRandomArsenalTemplateItemName(nil)
|
||
if itemName then
|
||
deferredPersistence = true
|
||
local detailed = grantArsenalItemDetailed(
|
||
nil,
|
||
playerId,
|
||
itemName,
|
||
1,
|
||
quality,
|
||
arsenalRollContext,
|
||
{deferPersistence = true}
|
||
)
|
||
local created = detailed[1]
|
||
if created ~= nil then
|
||
granted[#granted + 1] = {item_name = created.itemName, instance_id = created.instanceId, quality = quality}
|
||
end
|
||
print((((((((("[MatchEndRewards] player=" .. tostring(playerId)) .. " luck-bonus drop ") .. tostring(b + 1)) .. "/") .. tostring(bonusFromLuck)) .. ": quality=") .. quality) .. ", item=") .. itemName)
|
||
end
|
||
b = b + 1
|
||
end
|
||
end
|
||
if deferredPersistence then
|
||
flushArsenalInventoryToClientAndApi(nil, playerId)
|
||
end
|
||
print((("[MatchEndRewards] player=" .. tostring(playerId)) .. " arsenal drops result: ") .. (#granted > 0 and table.concat(
|
||
__TS__ArrayMap(
|
||
granted,
|
||
function(____, x) return ((x.item_name .. "(") .. x.instance_id) .. ")" end
|
||
),
|
||
", "
|
||
) or "none"))
|
||
return granted
|
||
end
|
||
--- Итог награды за матч: одно округление в конце (синхронно с клиентом по payload).
|
||
function ____exports.computeMatchRewardAmount(self, difficultyScale, creepKills, deaths)
|
||
local preScale = 50 + creepKills * 0.1
|
||
local scaled = preScale * difficultyScale
|
||
local deathMultiplier = math.max(0, 1 - deaths * 0.1)
|
||
local raw = scaled * deathMultiplier
|
||
return math.floor(math.max(0, raw))
|
||
end
|
||
--- Только имя из движка; пустую строку отдаём в payload — отображение добирает клиент (GetPlayerInfo).
|
||
local function getPlayerDisplayName(self, playerId)
|
||
local raw = PlayerResource:GetPlayerName(playerId)
|
||
if raw ~= nil and raw ~= nil and tostring(raw) ~= "" then
|
||
return tostring(raw)
|
||
end
|
||
return ""
|
||
end
|
||
local function buildEmptyRow(self, slot, _outcome, _grantsBlocked)
|
||
local rewardAmount = 0
|
||
return {
|
||
player_id = slot,
|
||
player_name = "",
|
||
hero_name = "",
|
||
creep_kills = 0,
|
||
deaths = 0,
|
||
damage_dealt = 0,
|
||
damage_taken = 0,
|
||
heal_to_allies = 0,
|
||
free_currency = 0,
|
||
bp_level = 1,
|
||
bp_experience = 0,
|
||
quests_completed_count = 0,
|
||
reward_amount = rewardAmount,
|
||
slot_empty = true,
|
||
breakdown = {
|
||
base_linear = 0,
|
||
creep_term = 0,
|
||
quest_term = 0,
|
||
death_penalty = 0,
|
||
total = rewardAmount
|
||
}
|
||
}
|
||
end
|
||
____exports.MatchEndRewards = __TS__Class()
|
||
local MatchEndRewards = ____exports.MatchEndRewards
|
||
MatchEndRewards.name = "MatchEndRewards"
|
||
MatchEndRewards.____file_path = "scripts/vscripts/match_end_rewards.lua"
|
||
function MatchEndRewards.prototype.____constructor(self)
|
||
end
|
||
function MatchEndRewards.buildPayload(self, outcome)
|
||
--- В DS итоговый множик награды = контракт (полосы 3–7+ по редкости); иначе — шкала сложности.
|
||
local difficultyScale = Difficulty.leader == "death_sentence" and Difficulty:getDeathSentenceContractRewardMultiplier() or Difficulty:getNpcStatScale()
|
||
local difficultyKey = Difficulty.leader or "normal"
|
||
local stats = GameStatsTracker:getInstance()
|
||
local grantsBlocked = stats:shouldBlockMatchEndRewards()
|
||
local rows = {}
|
||
local store = StoreManager:getInstance()
|
||
local bp = BattlePassServer:getInstance()
|
||
local combat = MatchEndCombatStats:getInstance()
|
||
do
|
||
local slot = 0
|
||
while slot < ____exports.MATCH_END_PLAYER_SLOTS do
|
||
do
|
||
local playerId = slot
|
||
local key = tostring(slot)
|
||
if not PlayerResource:IsValidPlayerID(playerId) or not PlayerResource:IsValidPlayer(playerId) or PlayerResource:IsFakeClient(playerId) then
|
||
rows[key] = buildEmptyRow(nil, slot, outcome, grantsBlocked)
|
||
goto __continue40
|
||
end
|
||
local hero = PlayerResource:GetSelectedHeroEntity(playerId)
|
||
if not hero then
|
||
rows[key] = buildEmptyRow(nil, slot, outcome, grantsBlocked)
|
||
goto __continue40
|
||
end
|
||
local creepKills = PlayerResource:GetNearbyCreepDeaths(playerId)
|
||
local deaths = PlayerResource:GetDeaths(playerId)
|
||
--- Как defension: реальный урон через DamageFilter (match_end_combat_stats).
|
||
local damageDealt = combat:getOutgoingDamageSum(playerId)
|
||
local damageTaken = combat:getIncomingDamageSum(playerId)
|
||
local healToAllies = combat:getHealToAlliesSum(playerId)
|
||
local questsCompleted = bp:getNpcQuestsCompletedForPlayer(playerId)
|
||
local snap = bp:getBattlePassSnapshotForMatchEnd(playerId)
|
||
local baseLinear = 50
|
||
local creepTerm = creepKills * 0.1
|
||
local questTerm = 0
|
||
local deathMultiplier = math.max(0, 1 - deaths * 0.1)
|
||
local totalBeforeDeathPenalty = (baseLinear + creepTerm + questTerm) * difficultyScale
|
||
local deathPenalty = math.max(0, totalBeforeDeathPenalty - totalBeforeDeathPenalty * deathMultiplier)
|
||
local computedTotal = ____exports.computeMatchRewardAmount(nil, difficultyScale, creepKills, deaths)
|
||
local rewardAmount = computedTotal
|
||
if outcome == "loss" or grantsBlocked then
|
||
rewardAmount = 0
|
||
end
|
||
local hasPremium = snap.has_premium == true
|
||
local totalBeforePremium = nil
|
||
if outcome == "win" and not grantsBlocked and rewardAmount > 0 and hasPremium then
|
||
totalBeforePremium = rewardAmount
|
||
rewardAmount = math.floor(rewardAmount * MATCH_END_PREMIUM_REWARD_MULT)
|
||
end
|
||
rows[key] = {
|
||
player_id = playerId,
|
||
player_name = getPlayerDisplayName(nil, playerId),
|
||
hero_name = hero:GetUnitName(),
|
||
creep_kills = creepKills,
|
||
deaths = deaths,
|
||
damage_dealt = damageDealt,
|
||
damage_taken = damageTaken,
|
||
heal_to_allies = healToAllies,
|
||
free_currency = store:getFreeCurrency(playerId),
|
||
bp_level = snap.level,
|
||
bp_experience = snap.experience,
|
||
quests_completed_count = questsCompleted,
|
||
reward_amount = rewardAmount,
|
||
has_battle_pass_premium = hasPremium,
|
||
slot_empty = false,
|
||
breakdown = {
|
||
base_linear = baseLinear,
|
||
creep_term = creepTerm,
|
||
quest_term = questTerm,
|
||
death_penalty = deathPenalty,
|
||
total_before_premium = totalBeforePremium,
|
||
total = rewardAmount
|
||
}
|
||
}
|
||
end
|
||
::__continue40::
|
||
slot = slot + 1
|
||
end
|
||
end
|
||
return {outcome = outcome, difficulty_scale = difficultyScale, difficulty_key = difficultyKey, players = rows}
|
||
end
|
||
function MatchEndRewards.previewForDevPlayer(self, playerId, outcome)
|
||
if outcome == nil then
|
||
outcome = "win"
|
||
end
|
||
if not IsServer() then
|
||
return
|
||
end
|
||
do
|
||
local function ____catch()
|
||
return true
|
||
end
|
||
local ____try, ____hasReturned, ____returnValue = pcall(function()
|
||
local tools = _G.IsInToolsMode
|
||
if not tools or not tools(nil) then
|
||
return true
|
||
end
|
||
end)
|
||
if not ____try then
|
||
____hasReturned, ____returnValue = ____catch()
|
||
end
|
||
if ____hasReturned then
|
||
return ____returnValue
|
||
end
|
||
end
|
||
local player = PlayerResource:GetPlayer(playerId)
|
||
if not player then
|
||
return
|
||
end
|
||
local payload = ____exports.MatchEndRewards:buildPayload(outcome)
|
||
CustomGameEventManager:Send_ServerToPlayer(player, "match_end_screen", {outcome = outcome, payload = payload})
|
||
end
|
||
function MatchEndRewards.registerStateListenerForMatchEnd(self)
|
||
ListenToGameEvent(
|
||
"game_rules_state_change",
|
||
function()
|
||
if GameRules:State_Get() == DOTA_GAMERULES_STATE_GAME_IN_PROGRESS then
|
||
____exports.MatchEndRewards.dispatched = false
|
||
end
|
||
end,
|
||
nil
|
||
)
|
||
end
|
||
function MatchEndRewards.dispatch(self, outcome, onFullyDispatched)
|
||
if not IsServer() then
|
||
return
|
||
end
|
||
if ____exports.MatchEndRewards.dispatched then
|
||
return
|
||
end
|
||
____exports.MatchEndRewards.dispatched = true
|
||
local stats = GameStatsTracker:getInstance()
|
||
local grantsBlocked = stats:shouldBlockMatchEndRewards()
|
||
local payload = ____exports.MatchEndRewards:buildPayload(outcome)
|
||
local screenSent = false
|
||
local function sendScreen()
|
||
if screenSent then
|
||
return
|
||
end
|
||
screenSent = true
|
||
CustomGameEventManager:Send_ServerToAllClients("match_end_screen", {outcome = outcome, payload = payload})
|
||
end
|
||
local function finish()
|
||
sendScreen(nil)
|
||
if onFullyDispatched then
|
||
onFullyDispatched(nil)
|
||
end
|
||
end
|
||
do
|
||
local function ____catch(e)
|
||
print("[MatchEndRewards] reward dispatch error: " .. tostring(e))
|
||
end
|
||
local ____try, ____hasReturned, ____returnValue = pcall(function()
|
||
if outcome == "loss" and not grantsBlocked and Difficulty.leader == "death_sentence" then
|
||
Difficulty:applyDeathSentenceContractDurabilityOnLoss(function()
|
||
finish(nil)
|
||
end)
|
||
return true
|
||
end
|
||
if outcome == "win" and not grantsBlocked then
|
||
local store = StoreManager:getInstance()
|
||
local bp = BattlePassServer:getInstance()
|
||
local hasAnyRealWinner = false
|
||
local winnersBySlot = {}
|
||
do
|
||
local slot = 0
|
||
while slot < ____exports.MATCH_END_PLAYER_SLOTS do
|
||
do
|
||
local row = payload.players[tostring(slot)]
|
||
if not row or row.slot_empty then
|
||
goto __continue65
|
||
end
|
||
hasAnyRealWinner = true
|
||
winnersBySlot[#winnersBySlot + 1] = slot
|
||
local pid = row.player_id
|
||
local amt = row.reward_amount
|
||
if amt > 0 then
|
||
bp:addExperience(pid, amt)
|
||
store:grantFreeCurrencyMatchEndReward(pid, amt)
|
||
end
|
||
row.arsenal_drops = grantArsenalWinDrops(nil, pid)
|
||
print((((((("[MatchEndRewards] payload row slot=" .. tostring(slot)) .. " pid=") .. tostring(pid)) .. ": reward=") .. tostring(amt)) .. ", arsenal_drops=") .. (row.arsenal_drops and #row.arsenal_drops > 0 and table.concat(
|
||
__TS__ArrayMap(
|
||
row.arsenal_drops,
|
||
function(____, x) return ((x.item_name .. "(") .. x.instance_id) .. ")" end
|
||
),
|
||
", "
|
||
) or "none"))
|
||
end
|
||
::__continue65::
|
||
slot = slot + 1
|
||
end
|
||
end
|
||
if hasAnyRealWinner then
|
||
local pendingContractSaves = 0
|
||
for ____, slot in ipairs(winnersBySlot) do
|
||
do
|
||
local row = payload.players[tostring(slot)]
|
||
if not row or row.slot_empty then
|
||
goto __continue70
|
||
end
|
||
local pid = row.player_id
|
||
local contract = Difficulty:grantMatchEndContractIfEligible(pid)
|
||
if not contract then
|
||
goto __continue70
|
||
end
|
||
local info = {
|
||
instance_id = contract.instanceId,
|
||
serial = contract.serial,
|
||
rarity = contract.rarity,
|
||
title_index = contract.titleIndex,
|
||
reward_multiplier = contract.rewardMultiplier,
|
||
trait_id = contract.traitId,
|
||
complication_ids = {unpack(contract.complicationIds)},
|
||
durability = contract.durability,
|
||
durability_max = contract.durabilityMax
|
||
}
|
||
pendingContractSaves = pendingContractSaves + 1
|
||
Difficulty:saveContractRosterForPlayer(
|
||
pid,
|
||
function(____, ok)
|
||
if ok then
|
||
row.contract_drop = info
|
||
print((((((("[MatchEndRewards] payload row slot=" .. tostring(slot)) .. ": contract_drop saved id=") .. info.instance_id) .. ", rarity=") .. info.rarity) .. ", title=") .. tostring(info.title_index))
|
||
else
|
||
print((((("[MatchEndRewards] contract_drop save failed slot=" .. tostring(slot)) .. " pid=") .. tostring(row.player_id)) .. " id=") .. info.instance_id)
|
||
end
|
||
pendingContractSaves = pendingContractSaves - 1
|
||
if pendingContractSaves <= 0 then
|
||
finish(nil)
|
||
end
|
||
end
|
||
)
|
||
end
|
||
::__continue70::
|
||
end
|
||
if pendingContractSaves > 0 then
|
||
return true
|
||
end
|
||
print("[MatchEndRewards] no contract drop: difficulty=" .. Difficulty.leader)
|
||
end
|
||
end
|
||
end)
|
||
if not ____try then
|
||
____hasReturned, ____returnValue = ____catch(____hasReturned)
|
||
end
|
||
if ____hasReturned then
|
||
return ____returnValue
|
||
end
|
||
end
|
||
finish(nil)
|
||
end
|
||
MatchEndRewards.dispatched = false
|
||
if IsServer() then
|
||
____exports.MatchEndRewards:registerStateListenerForMatchEnd()
|
||
end
|
||
return ____exports
|