Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Role Distribution: Add options for "derandomization" for perceptually fairer distributions #1561

Merged
merged 21 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ All notable changes to TTT2 will be documented here. Inspired by [keep a changel
- Made sure this new function is used in our whole codebase for all admin checks
- Added `ENTITY:IsPlayerRagdoll` to check if a corpse is a real player ragdoll (by @TimGoll)
- Added the `SWEP.DryFireSound` field to the weapon base to allow the dryfire sound to be easily changed (by @TW1STaL1CKY)
- Added role derandomization options for perceptually fairer role distribution

### Changed

Expand Down
8 changes: 8 additions & 0 deletions gamemodes/terrortown/gamemode/server/sv_player_ext.lua
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,9 @@ function plymeta:InitialSpawn()
-- We never have weapons here, but this inits our equipment state
self:StripAll()

-- Initialize role weights
roleselection.InitializeRoleWeights(self)

-- set spawn position
local spawnPoint = plyspawn.GetRandomSafePlayerSpawnPoint(self)

Expand Down Expand Up @@ -758,6 +761,11 @@ function plymeta:UnSpectate()
self:SetNoTarget(false)
end

---
-- @accessor table A table containing the weights to use when selecting roles, if enabled.
-- @realm server
AccessorFunc(plymeta, "role_weights", "RoleWeightTable")

---
-- Returns whether a @{Player} is able to select a specific @{ROLE}
-- @param ROLE roleData
Expand Down
88 changes: 81 additions & 7 deletions gamemodes/terrortown/gamemode/server/sv_roleselection.lua
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,20 @@ roleselection.cv = {
---
-- @realm server
-- stylua: ignore
ttt_max_baseroles_pct = CreateConVar("ttt_max_baseroles_pct", "0", {FCVAR_NOTIFY, FCVAR_ARCHIVE}, "Maximum amount of different baseroles based on player amount. ttt_max_baseroles needs to be 0")
,
ttt_max_baseroles_pct = CreateConVar("ttt_max_baseroles_pct", "0", {FCVAR_NOTIFY, FCVAR_ARCHIVE}, "Maximum amount of different baseroles based on player amount. ttt_max_baseroles needs to be 0"),

---
-- @realm server
-- stylua: ignore
ttt_role_derandomize_mode = CreateConVar("ttt_role_derandomize_mode", "0", {FCVAR_NOTIFY, FCVAR_ARCHIVE}, "The mode to use for role selection derandomization", ROLE_DERAND_NONE, ROLE_DERAND_BOTH),

---
-- NOTE: Currently the minimum is 1. In theory, it could be set to 0, which would mean that players cannot get the same role (or subrole, according to the mode)
-- twice in a row. I suspect we'd need some special handling in role distribution to make that not get stuck in an infinite loop or have some other undesirable
-- behavior in certain cases.
-- @realm server
-- stylua: ignore
ttt_role_derandomize_min_weight = CreateConVar("ttt_role_derandomize_min_weight", "1", {FCVAR_NOTIFY, FCVAR_ARCHIVE}, "The minimum weight a player can have with derandomize on", 1),
}

-- saving and loading
Expand Down Expand Up @@ -189,6 +201,28 @@ function roleselection.SaveLayers()
end
end

---
-- Initializes the player's role weights to be their minimum value.
--
-- @param Player ply
-- @realm server
function roleselection.InitializeRoleWeights(ply)
-- Initialize the weight table
local minWeight = roleselection.cv.ttt_role_derandomize_min_weight:GetInt()
local roleWeightTable = {}
local roleList = roles.GetList()

for i = 1, #roleList do
local roleData = roleList[i]

if not roleData.isAbstract then
roleWeightTable[roleData.index] = minWeight
end
end

ply:SetRoleWeightTable(roleWeightTable)
end

nike4613 marked this conversation as resolved.
Show resolved Hide resolved
---
-- Returns the current amount of selected/already selected @{ROLE}s.
--
Expand Down Expand Up @@ -633,16 +667,31 @@ local function SetSubRoles(plys, availableRoles, selectableRoles, selectedForced
local plysAmount = #plys
local availableRolesAmount = #availableRoles
local tmpSelectableRoles = table.Copy(selectableRoles)
local modeDerandomize = roleselection.cv.ttt_role_derandomize_mode:GetInt()
local derand = modeDerandomize == ROLE_DERAND_SUBROLE or modeDerandomize == ROLE_DERAND_BOTH

while plysAmount > 0 and availableRolesAmount > 0 do
local pick = math.random(plysAmount)
local ply = plys[pick]
local minWeight = roleselection.cv.ttt_role_derandomize_min_weight:GetInt()

while plysAmount > 0 and availableRolesAmount > 0 do
local rolePick = math.random(availableRolesAmount)
local subrole = availableRoles[rolePick]
local roleData = roles.GetByIndex(subrole)
local roleCount = tmpSelectableRoles[subrole]

local pick
if not derand then
-- select random index in plys table
pick = math.random(plysAmount)
else
-- use a weighted sum to select the player
pick = math.WeightedRandom(plys, function(ply)
local weightTbl = ply:GetRoleWeightTable()
return weightTbl[subrole] or minWeight
end)
end

local ply = plys[pick]

if selectedForcedRoles[subrole] then
roleCount = roleCount - selectedForcedRoles[subrole]
end
Expand Down Expand Up @@ -847,10 +896,23 @@ local function SelectBaseRolePlayers(plys, subrole, roleAmount)
local curRoles = 0
local plysList = {}
local roleData = roles.GetByIndex(subrole)
local modeDerandomize = roleselection.cv.ttt_role_derandomize_mode:GetInt()
local derand = modeDerandomize == ROLE_DERAND_BASEROLE or modeDerandomize == ROLE_DERAND_BOTH

local minWeight = roleselection.cv.ttt_role_derandomize_min_weight:GetInt()

while curRoles < roleAmount and #plys > 0 do
-- select random index in plys table
local pick = math.random(#plys)
local pick
if not derand then
-- select random index in plys table
pick = math.random(#plys)
else
-- use a weighted sum to select the player
pick = math.WeightedRandom(plys, function(ply)
local weightTbl = ply:GetRoleWeightTable()
return weightTbl[subrole] or minWeight
end)
end

-- the player we consider
local ply = plys[pick]
Expand Down Expand Up @@ -981,12 +1043,24 @@ function roleselection.SelectRoles(plys, maxPlys)
-- stylua: ignore
hook.Run("TTT2ModifyFinalRoles", roleselection.finalRoles)

local minWeight = roleselection.cv.ttt_role_derandomize_min_weight:GetInt()

for i = 1, #plys do
local ply = plys[i]
local subrole = roleselection.finalRoles[ply] or ROLE_INNOCENT

ply:SetRole(subrole, nil, true)

local baserole = roles.GetByIndex(subrole):GetBaseRole()
local roleWeightTable = ply:GetRoleWeightTable()
-- increment all role weights for the player
for r, w in pairs(roleWeightTable) do
roleWeightTable[r] = w + 1
end
-- reset the weights for the final role and its baserole
roleWeightTable[subrole] = minWeight
roleWeightTable[baserole] = minWeight

-- store a steamid -> role map
GAMEMODE.LastRole[ply:SteamID64()] = subrole
end
Expand Down
5 changes: 5 additions & 0 deletions gamemodes/terrortown/gamemode/shared/sh_role_module.lua
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
ROLE_DERAND_NONE = 0
ROLE_DERAND_BASEROLE = 1
ROLE_DERAND_SUBROLE = 2
ROLE_DERAND_BOTH = 3

-- load roles
local rolesPre = "terrortown/entities/roles/"
local rolesFiles = file.Find(rolesPre .. "*.lua", "LUA")
Expand Down
35 changes: 35 additions & 0 deletions lua/terrortown/lang/en.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2305,3 +2305,38 @@ L.label_button_level_reset = "reset level"
L.loadingscreen_round_restart_title = "Starting new round"
L.loadingscreen_round_restart_subtitle = "you're playing on {map}"
L.loadingscreen_round_restart_subtitle_limits = "you're playing on {map} for another {rounds} round(s) or {time}"

-- 2024-06-23
L.header_roles_derandomize = "Role Derandomization"

L.help_roles_derandomize = [[
Role derandomization can be used to make role distribution feel more fair over the course of a session.
saibotk marked this conversation as resolved.
Show resolved Hide resolved

In essence, when it is enabled, a player's chance of receiving a role increases while they have not been assigned that role. While this can feel more fair, this also enables metagaming, where a player can guess that another will be traitor-aligned based on the fact that they have not been traitor aligned in several rounds. Do not enable this option if this is undesirable.

There are 4 modes:

mode 0: Disabled - No derandomization is done. This is the default.

mode 1: Base roles only - Derandomization is performed for base roles only. Sub-roles will be selected randomly. These are roles like Innocent and Traitor.

mode 2: Sub-roles only - Derandomization is performed for sub-roles only. Base roles will be selected randomly. Note that sub-roles are only assigned to players which have already been selected for their base role.

mode 3: Base roles AND sub-roles - Derandomization is performed for both base roles and sub-roles.]]
L.label_roles_derandomize_mode = "Derandomization mode"
L.label_roles_derandomize_mode_none = "mode 0: Disabled"
L.label_roles_derandomize_mode_base_only = "mode 1: Base roles only"
L.label_roles_derandomize_mode_sub_only = "mode 2: Sub-roles only"
L.label_roles_derandomize_mode_base_and_sub = "mode 3: Base roles AND sub-roles"

L.help_roles_derandomize_min_weight = [[
Derandomization is performed by making the random player selections during role distribution use a weight associated with each role for each player, and that weight increases by 1 each time the player does not get assigned that role. These weights are not persisted between connections, or across maps.

Each time a player is assigned a role, the corresponding weight is reset to this minimum weight. This weight does not have any absolute meaning; it can only be interpreted with respect to other weights.

For example, given player A with a weight of 1, and player B with a weight of 5, player B is 5 times more likely than player A to be selected. However, if player A had a weight of 4, player B is only 5/4 times more likely to be selected.

The minimum weight, therefore, effectively controls how much each round affects a player's chance at being selected, with higher values causing it to be affected less. The default value of 1 means that each round causes a fairly significant increase in chance, and conversely, that it is extremely unlikely that a player will get the same role twice in a row.

Changes to this value will not take effect until players reconnect or the map changes.]]
L.label_roles_derandomize_min_weight = "Derandomization minimum weight"
71 changes: 58 additions & 13 deletions lua/terrortown/menus/gamemode/administration/roles.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ CLGAMEMODESUBMENU.base = "base_gamemodesubmenu"
CLGAMEMODESUBMENU.priority = 97
CLGAMEMODESUBMENU.title = "submenu_administration_roles_general_title"

local TryT = LANG.TryTranslation

function CLGAMEMODESUBMENU:Populate(parent)
local form = vgui.CreateTTT2Form(parent, "header_roles_additional")

Expand Down Expand Up @@ -64,69 +66,112 @@ function CLGAMEMODESUBMENU:Populate(parent)
master = masterEnb,
})

local form2 = vgui.CreateTTT2Form(parent, "header_roles_reward_credits")
local form2 = vgui.CreateTTT2Form(parent, "header_roles_derandomize")

form2:MakeHelp({
label = "help_roles_award_info",
label = "help_roles_derandomize",
})

local masterDerand = form2:MakeComboBox({
serverConvar = "ttt_role_derandomize_mode",
label = "label_roles_derandomize_mode",
choices = {
{
title = TryT("label_roles_derandomize_mode_none"),
value = ROLE_DERAND_NONE,
},
{
title = TryT("label_roles_derandomize_mode_base_only"),
value = ROLE_DERAND_BASEROLE,
},
{
title = TryT("label_roles_derandomize_mode_sub_only"),
value = ROLE_DERAND_SUBROLE,
},
{
title = TryT("label_roles_derandomize_mode_base_and_sub"),
value = ROLE_DERAND_BOTH,
},
},
})

form2:MakeHelp({
label = "help_roles_derandomize_min_weight",
master = masterDerand,
})

form2:MakeSlider({
serverConvar = "ttt_role_derandomize_min_weight",
label = "label_roles_derandomize_min_weight",
master = masterDerand,
min = 1,
max = 50,
decimal = 0,
})

local form3 = vgui.CreateTTT2Form(parent, "header_roles_reward_credits")

form3:MakeHelp({
label = "help_roles_award_info",
})

form3:MakeSlider({
serverConvar = "ttt_credits_award_size",
label = "label_roles_credits_award_size",
min = 0,
max = 5,
decimal = 0,
})

form2:MakeHelp({
form3:MakeHelp({
label = "help_roles_award_pct",
})

form2:MakeSlider({
form3:MakeSlider({
serverConvar = "ttt_credits_award_pct",
label = "label_roles_credits_award_pct",
min = 0,
max = 1,
decimal = 2,
})

form2:MakeHelp({
form3:MakeHelp({
label = "help_roles_award_repeat",
})

form2:MakeCheckBox({
form3:MakeCheckBox({
serverConvar = "ttt_credits_award_repeat",
label = "label_roles_credits_award_repeat",
})

form2:MakeHelp({
form3:MakeHelp({
label = "help_roles_credits_award_kill",
})

form2:MakeSlider({
form3:MakeSlider({
serverConvar = "ttt_credits_award_kill",
label = "label_roles_credits_award_kill",
min = 0,
max = 10,
decimal = 0,
})

local form3 = vgui.CreateTTT2Form(parent, "header_roles_special_settings")
local form4 = vgui.CreateTTT2Form(parent, "header_roles_special_settings")

form3:MakeHelp({
form4:MakeHelp({
label = "help_detective_hats",
})

form3:MakeCheckBox({
form4:MakeCheckBox({
serverConvar = "ttt_detective_hats",
label = "label_detective_hats",
})

form3:MakeHelp({
form4:MakeHelp({
label = "help_inspect_credits_always",
})

form3:MakeCheckBox({
form4:MakeCheckBox({
serverConvar = "ttt2_inspect_credits_always_visible",
label = "label_inspect_credits_always",
})
Expand Down
45 changes: 45 additions & 0 deletions lua/ttt2/extensions/math.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,48 @@ function math.ExponentialDecay(halflife, dt)
-- ln(0.5) = -0.69..
return exp((-0.69314718 / halflife) * dt)
end

---
-- Gets the index of an item in the provided table, weighted according to the weights (derived from getWeight).
-- @param table tbl The array of items to find a weighted item in.
-- @param function getWeight Called as getWeight(item, index). Must return number.
-- @return number
-- @realm shared
function math.WeightedRandom(tbl, getWeight)
-- There are several possible ways to get a weighted item. The most obvious is to simply include an item in
-- the table N times, where N is an integer proportional to the weight. This, however, requires maintaining
-- that table, which may be undesirable.
--
-- For a more friendly API, we instead compute the sum, generate a random number from 0 to that sum, then
-- take the first item whose weight prefix-sum is greater than that random number. This requires 2 passes
-- over the table, but enables processing on a normal table, with arbitrary weight storage.

-- Special case short arrays, because they may otherwise cause problems
if #tbl == 0 then
return nil
end
if #tbl == 1 then
return 1
end

-- first, compute the sum weight
local sum = 0
for k, v in pairs(tbl) do
sum = sum + getWeight(v, k)
end

-- get the random number
local rand = math.Rand(0, sum)

-- now do the prefix-sum for the final value
sum = 0
for k, v in pairs(tbl) do
sum = sum + getWeight(v, k)
if sum >= rand then
return k
end
end

-- it SHOULD be impossible to reach here, but just in case, we'll do a simple random selection
return math.random(#tbl)
end
Loading