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

Massively improves dynamic lowpop threat distribution. #78276

Merged
merged 2 commits into from Sep 13, 2023

Conversation

Timberpoes
Copy link
Member

@Timberpoes Timberpoes commented Sep 11, 2023

About The Pull Request

Let's take an example of a threat roll of 50 on 0 pop.

threat_level = 50
low_pop_maximum_threat = 40
max_threat_level = 100
SSticker.totalPlayersReady = 0
low_pop_player_threshold = 20

threat_level = min(threat_level, LERP(low_pop_maximum_threat, max_threat_level, SSticker.totalPlayersReady / low_pop_player_threshold))

Subbing in, we get

threat_level = min(50, LERP(40, 100, 0 / 20))

What does the LERP churn out?

#define LERP(a, b, amount) ( amount ? ((a) + ((b) - (a)) * (amount)) : a )

a = low_pop_maximum_threat = 40
b = max_threat_level = 100
amount = SSticker.totalPlayersReady / low_pop_player_threshold = 0 / 20

LERP(40, 100, 0 / 20)

( 0 ? ((40) + ((100) - (40)) * (0 / 20)) : 40 )

So, for 0 pop - since 0 is FALSEY, it always returns 40 threat no matter what. This effectively means all threat rolls > 40 on 0 pop get clamped down to 40.

And how about 10 pop, somewhere in the middle of that and the 20 lowpop limit?

( 10 ? ((40) + ((100) - (40)) * (10 / 20)) : 40 )

Which means we're using ((40) + ((100) - (40)) * (10 / 20). Which equals 70. So all threat rolls > 70 get clamped to 70.

So all LERP(low_pop_maximum_threat, max_threat_level, SSticker.totalPlayersReady / low_pop_player_threshold) is doing is LERPing the max_threat level.

Of course I didn't need to really break it down, but I felt it was useful to showcase that yes the LERP is LERPing as expected. It's fed two max threat levels, an amount and lerps between them.

So we're not really scaling the threat in any way for low pops, we're just clamping it to this LERP'd max threat value. It massively biases the high threat ranges at lowpop. How does this look in practice?

Well, I quickly mocked up a dynamic sim in Python. Here's what I found:
0 pop, current dynamic config, old code
Old-0-pop

10 pop, current dynamic config, old code
Old-10-pop

20 pop, current dynamic config, old code
Old-20-pop

Instead of using the LERP'd max threat to clamp after we've calculated everything, I think it would be better to use the LERP'd max threat in the Lorentz calculation itself to create more predictable lowpop threat curves.

This is my simulated output using the LERP'd max threat to generate the Lorentz curve:
0 pop, current dynamic config, new code
New-0-pop

10 pop, current dynamic config, new code
New-10-pop

20 pop, current dynamic config, new code
New-20-pop

And just for good measure, 100 pop, current dynamic config, new code
New-100-pop

This should lead to WAY more sensible threat scaling for dynamic at low and especially extreme low pop levels.

Why It's Good For The Game

Current low pop threat distribution is really bad and biases towards the highest threat dynamic could possibly role for that pop level the farther away from the lowpop limit the shift starts as.

This really shouldn't be the intent and it makes balancing lowpop threat basically impossible. By generating a more constrained Lortentz curve, dynamic threat scales across all population levels in a more sensible and balanced way.

Changelog

🆑
fix: Fix poor dynamic threat distribution at lower population levels, causing dynamic to generate better threat curves at lower population levels than it did before.
/:cl:

@tgstation-server tgstation-server added the Fix Rewrites a bug so it appears in different circumstances label Sep 11, 2023
@EuSouAFazer
Copy link
Contributor

If I got the math this mean that the amount of lowpop rounds where everyone/near everyone is an antag is gonna diminish, right? I'm asking since this is a lot of math and I don't feel qualified to interpret it alone

@Maurukas
Copy link
Member

This looks nice and I also see graphs, which makes me happy. The distribution looks better and it will hopefully address a bit of Bagil being a quite literal syndiestation by the end of each shift, if only because the threat is being spent on something better.

@private-tristan
Copy link
Contributor

no more 15 pop rounds where 10 players roll traitor 😔

@Timberpoes
Copy link
Member Author

Timberpoes commented Sep 11, 2023

In response to all the comments, yes!

This change generates a lorentz curve constrained within the boundaries of the pop-adjusted max threat level, instead of generating the curve based on maxpop and then clamping the threat value if it's over the max threat level for that pop count.

So it will globally decrease average threat levels across lowpop. There will still be higher threat shifts but there will now be a LOT more lower threat shifts.

@Ghommie
Copy link
Member

Ghommie commented Sep 12, 2023

So that's fucking why we had so many shifts capped at 40-50 threat! I should have known better and acted while working on dynamic threat instead of ignoring (and later forgetting about) it.

Tho, it's kinda weird how these python simulations show a standard bell curve when it should be inverted, as far as I know having tested it a few times (at least roundstart threat).

@Timberpoes
Copy link
Member Author

Timberpoes commented Sep 12, 2023

Tho, it's kinda weird how these python simulations show a standard bell curve when it should be inverted, as far as I know having tested it a few times (at least roundstart threat).

@Ghommie

Python Code

This is the python code I used for my simulator to do the graphs pre-code. I basically ported the dynamic threat generation code over to Python.

I am not a good Python dev.

Is there something else at play or is my simulator gerfuckened?

import random
import math
import matplotlib.pyplot as plt

MAXIMUM_DYN_DISTANCE = 5
PI = 3.1416

LOW_POP_PLAYER_THRESHOLD = 20
LOW_POP_MAX_THREAT_LEVEL = 40
MAX_THREAT_LEVEL = 100

THREAT_CURVE_CENTRE = 0.5
THREAT_CURVE_WIDTH = 1.8
TOTAL_PLAYERS_READY = 60

#define LORENTZ_CUMULATIVE_DISTRIBUTION(x, y, s) ( (1/PI)*TORADIANS(arctan((x-(y))/s)) + 1/2 )

def lorentz_cumulative_distribution(x, y, s):
    return ( (1/PI)*to_radians(math.atan((x-(y))/s)) + 1/2 )

def to_radians(degrees):
    return ((degrees) * 0.0174532925)

def lerp(a, b, amount):
    return ((a) + ((b) - (a)) * (amount)) if amount else a

def lorentz_to_amount(centre = 0, scale = 1.8, max_threat = 100, interval = 1):
    location = random.randint(-MAXIMUM_DYN_DISTANCE, MAXIMUM_DYN_DISTANCE) * random.random()
    lorentz_result = lorentz_cumulative_distribution(centre, location, scale)
    std_threat = lorentz_result * max_threat

    lower_deviation = max(std_threat * (location-centre)/MAXIMUM_DYN_DISTANCE, 0)
    upper_deviation = max((max_threat - std_threat) * (centre-location)/MAXIMUM_DYN_DISTANCE, 0)
    
    return max(0, min(round(std_threat + upper_deviation - lower_deviation, interval), 100))

def generate_random_shift(pop_count):
    threat_level = lorentz_to_amount(THREAT_CURVE_CENTRE, THREAT_CURVE_WIDTH, MAX_THREAT_LEVEL)

    #for(var/datum/station_trait/station_trait in GLOB.dynamic_station_traits)
    #    threat_level = max(threat_level - GLOB.dynamic_station_traits[station_trait], 0)
    #    log_dynamic("Threat reduced by [GLOB.dynamic_station_traits[station_trait]]. Source: [type].")

    if (pop_count < LOW_POP_PLAYER_THRESHOLD):
        return min(threat_level, lerp(LOW_POP_MAX_THREAT_LEVEL, MAX_THREAT_LEVEL, pop_count / LOW_POP_PLAYER_THRESHOLD))

    return threat_level

def round_to(x, to):
    return round(x/to)*to

results = []

for x in range(1, 10000):
    results.append(generate_random_shift(10))

grouped_results = {'0':0, '10':0, '20':0, '30':0, '40':0, '50':0, '60':0, '70':0, '80':0, '90':0, '100':0}

for result in results:
    grouped_results[f'{round_to(result, 10)}'] += 1

final_data = [
    grouped_results['0'],
    grouped_results['10'],
    grouped_results['20'],
    grouped_results['30'],
    grouped_results['40'],
    grouped_results['50'],
    grouped_results['60'],
    grouped_results['70'],
    grouped_results['80'],
    grouped_results['90'],
    grouped_results['100'],
]

threat_tiers = ['0', '10', '20', '30', '40', '50', '60', '70', '80', '90', '100']
plt.bar(threat_tiers, final_data)
plt.title('Threat Vs Number of Shifts')
plt.xlabel('Threat')
plt.ylabel('Shifts')
plt.show()

@Ghommie
Copy link
Member

Ghommie commented Sep 12, 2023

Don't sweat it. If the script works fine, then it probably ain't wrong either. shrugs

@Ghommie Ghommie merged commit 2de73c0 into tgstation:master Sep 13, 2023
19 checks passed
comfyorange added a commit that referenced this pull request Sep 13, 2023
github-actions bot added a commit that referenced this pull request Sep 13, 2023
Jolly-66 pushed a commit to TaleStation/TaleStation that referenced this pull request Sep 13, 2023
…bution. (#7731)

Original PR: tgstation/tgstation#78276
-----

## About The Pull Request

Let's take an example of a threat roll of 50 on 0 pop.

```
threat_level = 50
low_pop_maximum_threat = 40
max_threat_level = 100
SSticker.totalPlayersReady = 0
low_pop_player_threshold = 20

threat_level = min(threat_level, LERP(low_pop_maximum_threat, max_threat_level, SSticker.totalPlayersReady / low_pop_player_threshold))
```

Subbing in, we get

```
threat_level = min(50, LERP(40, 100, 0 / 20))
```

What does the LERP churn out?

```
#define LERP(a, b, amount) ( amount ? ((a) + ((b) - (a)) * (amount)) : a )

a = low_pop_maximum_threat = 40
b = max_threat_level = 100
amount = SSticker.totalPlayersReady / low_pop_player_threshold = 0 / 20

LERP(40, 100, 0 / 20)
```

( 0 ? ((40) + ((100) - (40)) * (0 / 20)) : 40 )

So, for 0 pop - since 0 is FALSEY, it always returns 40 threat no matter
what. This effectively means all threat rolls > 40 on 0 pop get clamped
down to 40.

And how about 10 pop, somewhere in the middle of that and the 20 lowpop
limit?

( 10 ? ((40) + ((100) - (40)) * (10 / 20)) : 40 )

Which means we're using ((40) + ((100) - (40)) * (10 / 20). Which equals
70. So all threat rolls > 70 get clamped to 70.

So all LERP(low_pop_maximum_threat, max_threat_level,
SSticker.totalPlayersReady / low_pop_player_threshold) is doing is
LERPing the max_threat level.

Of course I didn't need to really break it down, but I felt it was
useful to showcase that yes the LERP is LERPing as expected. It's fed
two max threat levels, an amount and lerps between them.

So we're not really **scaling** the threat in any way for low pops,
we're just clamping it to this LERP'd max threat value. It massively
biases the high threat ranges at lowpop. How does this look in practice?

Well, I quickly mocked up a dynamic sim in Python. Here's what I found:
0 pop, current dynamic config, old code

![Old-0-pop](https://github.com/tgstation/tgstation/assets/24975989/bf2be209-b0f7-4606-88e6-9d66cad76862)

10 pop, current dynamic config, old code

![Old-10-pop](https://github.com/tgstation/tgstation/assets/24975989/ac4f185d-91de-4bf2-9681-f82e173cf086)

20 pop, current dynamic config, old code

![Old-20-pop](https://github.com/tgstation/tgstation/assets/24975989/4f96ad17-16a8-4315-b3bb-8044bd66ba70)

Instead of using the LERP'd max threat to clamp after we've calculated
everything, I think it would be better to use the LERP'd max threat **in
the Lorentz calculation itself** to create more predictable lowpop
threat curves.

This is my simulated output using the LERP'd max threat to generate the
Lorentz curve:
0 pop, current dynamic config, new code

![New-0-pop](https://github.com/tgstation/tgstation/assets/24975989/f66fa2a9-8c58-4a76-a173-5fc0a7b6cd67)

10 pop, current dynamic config, new code

![New-10-pop](https://github.com/tgstation/tgstation/assets/24975989/a5ddd9e6-3469-48bf-94a8-c8a59e949639)

20 pop, current dynamic config, new code

![New-20-pop](https://github.com/tgstation/tgstation/assets/24975989/4204981a-74af-4fba-8cd4-d278f9345711)

And just for good measure, 100 pop, current dynamic config, new code

![New-100-pop](https://github.com/tgstation/tgstation/assets/24975989/8915b6e7-3748-4c03-af58-c659a4e580f6)

This should lead to WAY more sensible threat scaling for dynamic at low
and **especially** extreme low pop levels.
## Why It's Good For The Game

Current low pop threat distribution is really bad and biases towards the
highest threat dynamic could possibly role for that pop level the
farther away from the lowpop limit the shift starts as.

This really shouldn't be the intent and it makes balancing lowpop threat
basically impossible. By generating a more constrained Lortentz curve,
dynamic threat scales across all population levels in a more sensible
and balanced way.
## Changelog
:cl:
fix: Fix poor dynamic threat distribution at lower population levels,
causing dynamic to generate better threat curves at lower population
levels than it did before.
/:cl:

---------

Co-authored-by: Timberpoes <silent_insomnia_pp@hotmail.co.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Fix Rewrites a bug so it appears in different circumstances
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants