Branching system (#10)

This commit enhances the player_monoids library by adding branching support and an improved testing system to make sure it works, as proposed in #9 . These changes enable isolated player state management and improve library reliability. This PR focuses on implementing the branching proposal and adding automated tests.
This commit is contained in:
Gio
2025-05-17 18:10:30 +02:00
committed by GitHub
parent 8d6f1ade93
commit ee81c59f2c
5 changed files with 1164 additions and 288 deletions

274
README.md
View File

@ -1,38 +1,26 @@
# Player Monoids
# Player Monoids Library
This is a small library for managing global player state, so that changes made
from different mods do not result in unexpected behavior. The README gives an
introduction to the mod, but you might want to reference API.md along the way.
This mod, combined with playereffects, deprecates monoidal_effects.
This is a small library for managing global player state in Luanti, ensuring that multiple mods can modify player attributes without conflicts. The README provides an overview of the mod's purpose and functionality. For a detailed breakdown of available functions and usage, refer to **API.md**.
Global Player State
===================
Players have behavior-affecting state that can be modified through mods. A couple
examples are physics overrides and armor groups. If more than one mod tries to
change them, it can result in unexpected results.
This mod introduces **monoids**, which represent specific aspects of player state, such as speed modifiers, jump height, or even custom attributes like corruption levels or reputation systems. Monoids allow multiple mods to apply effects in a structured manner, preventing unintended overrides.
For example, a player could be
under a speed boost effect from a playereffects effect, and then sleep in a bed.
The bed sets the player's speed to 0, and sets it back to 1 when they get out.
Because the beds mod had no way of knowing that the player was supposed to have
a speed boost, it effectively removed it. One hack to "fix" it would be to save
the player's speed and restore it on wakeup, but this would have its own problems
if the effect wears off in bed. The beds mod would restore the boosted speed,
which wouldn't be removed, since the effect already went away. Thus an exploit
allowing a permanent (until log out) speed boost is introduced.
Additionally, the mod now includes **branches**, which allow different states to exist independently. This is useful for features like minigames, temporary effects, or alternate player states that should not interfere with the main game.
Player Monoids manages this by creating layers (monoids) on top of player state,
which can keep track of different changes and combine them usefully.
## Global Player State
Monoids
=======
Player state consists of various properties such as physics overrides, privileges, and other custom attributes. These properties are often modified by different mods, leading to unintended side effects. For example, a mod that grants a temporary speed boost might be overridden when another mod resets the players movement speed, inadvertently removing the boost.
Creation
--------
A monoid in Player Monoids is an interface to one piece of player state. For
example, you could have one monoid covering physics overrides, and another
covering fly privilege. You could define a speed monoids like this:
```
For example, a player could be under a speed boost effect from a `playereffects` mod and then sleep in a bed. If the bed mod resets the players speed, it might remove the boost entirely. Without a structured approach, the interaction between these mods can be unpredictable, potentially leading to exploits such as permanent speed boosts.
Player Monoids prevents this issue by allowing changes to be layered and combined correctly using monoids and branch-based state management.
## Monoids
### Creation
A monoid in Player Monoids is an abstraction over a specific piece of player state. Examples include physics overrides (like speed and gravity), privilege toggles (fly, noclip), and custom attributes (e.g., status effects, corruption levels). You define a monoid like this:
```lua
-- The values in my speed monoid must be speed multipliers (numbers).
mymod.speed_monoid = player_monoids.make_monoid({
combine = function(speed1, speed2)
@ -47,161 +35,133 @@ mymod.speed_monoid = player_monoids.make_monoid({
end,
identity = 1,
apply = function(speed, player)
local override = player:get_physics_override()
override.speed = speed
player:set_physics_override(override)
player:set_physics_override({ speed = speed })
end,
on_change = function() return end,
})
```
This says that two speed multipliers can be combined by multiplication, that
1 can be used as a neutral element, and that the "interpretation" of the speed
multiplier is to set the player's speed physics override to that value. It also
says that nothing in particular needs to happen when the speed changes, other
than applying the new speed multiplier.
This defines how speed multipliers combine, the identity value (`1`, meaning no change), and how the monoid applies its effects to the player.
Use
---
To add or remove change through a monoid, you must use the ```add_change```
and ```del_change``` methods. For example, you could speed the player up
temporarily like this:
```
-- Zoom!
local zoom_id = mymod.speed_monoid:add_change(some_player, 10)
### Use
minetest.after(5,function() mymod.speed_monoid:del_change(some_player, zoom_id) end)
```
You could also specify a string ID to use, instead of the numerical one that
is automatically provided:
```
-- Zoom Mk. II
mymod.speed_monoid:add_change(some_player, 10, "mymod:zoom")
You modify player state using the `add_change` and `del_change` methods:
minetest.after(5,function() mymod.speed_monoid:del_change(some_player, "mymod:zoom") end)
```lua
-- Increase player speed temporarily
local zoom_id = mymod.speed_monoid:add_change(some_player, 2)
minetest.after(5, function() mymod.speed_monoid:del_change(some_player, zoom_id) end)
```
Reading Values
--------------
You can use ```monoid:value(player)``` to read the current value of the monoid,
for that player. This could be useful if it doesn't just represent built-in
player state. For example, it could represent gardening skill, and you might use
it to calculate the chance of success when harvesting spices.
You can also specify a custom string identifier:
Nesting Monoids
---------------
You may have already noticed one limitation of this design. That is, for each
kind of player state, you can only combine state changes in one way. If the
standard speed monoid combines speed multipliers by multiplication, you cannot
change it to instead choose the highest speed multiplier. Unfortunately, there
is currently no way change this - you will have to hope that the given monoid
combines in a useful way. However, it is possible to manage a subset of the
values in a custom way.
Suppose that a speed monoid (```mymod.speed_monoid```) already exists, using
multiplication, but you want to write a mod with speed boosts, and only apply
the strongest boost. Most of it could be done the same way:
```lua
-- Speed boost with named identifier
mymod.speed_monoid:add_change(some_player, 2, "mymod:zoom")
minetest.after(5, function() mymod.speed_monoid:del_change(some_player, "mymod:zoom") end)
```
### Reading Values
You can use `monoid:value(player)` to read the current value of the monoid for that player. This is useful when the monoid represents a derived attribute rather than a direct player state value.
### Branch System
Branches allow state changes to be contained within separate contexts, preventing interference between unrelated modifications. Every player starts in the `"main"` branch, but additional branches can be created and managed separately.
For example:
```lua
local speed_branch = mymod.speed_monoid:new_branch(some_player, "minigame")
speed_branch:add_change(some_player, 2)
```
When switching branches, the new branchs state is immediately applied, while the previous one is preserved but inactive:
```lua
mymod.speed_monoid:checkout_branch(some_player, "minigame")
```
To return to the normal game state:
```lua
mymod.speed_monoid:checkout_branch(some_player, "main")
```
### Nesting Monoids
You may have already noticed one limitation of this design. That is, for each kind of player state, you can only combine state changes in one way. If the standard speed monoid combines speed multipliers by multiplication, you cannot change it to instead choose the highest speed multiplier. Unfortunately, there is currently no way to change this - you will have to hope that the given monoid combines in a useful way. However, it is possible to manage a subset of the values in a custom way.
If you want to manage subsets of a monoid's values separately, you can create a nested monoid that modifies only a portion of the state while keeping compatibility with the parent monoid.
Suppose that a speed monoid (`mymod.speed_monoid`) already exists, using multiplication, but you want to write a mod with speed boosts, and only apply the strongest boost. Most of it could be done the same way:
```lua
-- My speed boosts monoid takes speed multipliers (numbers) that are at least 1.
newmod.speed_boosts = player_monoids.make_monoid({
combine = function(speed1, speed2)
return math.max(speed1, speed2)
end,
fold = function(tab)
local res = 1
for _, speed in pairs(tab) do
res = math.max(res, speed)
end
return res
end,
identity = 1,
apply = ???
on_change = function() return end,
combine = function(speed1, speed2)
return math.max(speed1, speed2)
end,
fold = function(tab)
local res = 1
for _, speed in pairs(tab) do
res = math.max(res, speed)
end
return res
end,
identity = 1,
apply = function(speed, player)
mymod.speed_monoid:add_change(player, speed, "newmod:speed_boosts")
end,
on_change = function() return end,
})
```
But we cannot just change the player speed in ```apply```, otherwise we will
break compatibility with the original speed monoid! The trick here is to use
the original monoid as a proxy for our effects.
```
This means the speed boosts we control can be limited to the strongest boost, but the resulting boost will still play nice with speed effects from other mods.
You could even add another "nested monoid" just for speed maluses, that takes the worst speed drain and applies it as a multiplier.
However, we cannot just change the player speed directly in `apply`, otherwise we will break compatibility with the original speed monoid! The trick here is to use the original monoid as a proxy for our effects.
```lua
apply = function(speed, player)
mymod.speed_monoid:add_change(player, speed, "newmod:speed_boosts")
mymod.speed_monoid:add_change(player, speed, "newmod:speed_boosts")
end
```
This means the speed boosts we control can be limited to the strongest boost, but
the resulting boost will still play nice with speed effects from other mods.
You could even add another "nested monoid" just for speed maluses, that takes
the worst speed drain and applies it as a multiplier.
Standard Monoids
================
In the spirit of compatibility, this mod provides some canonical monoids for
commonly used player state. They combine values in a way that should allow
different mods to affect player state fairly. If you make another monoid handling
the same state as one of these, you will break compatibility with any mods using
the standard monoid.
This ensures that our boost calculation stays separate while still being compatible with other modifications. You could also introduce another nested monoid for handling slow effects, ensuring only the most significant reduction takes effect. 
Physics Overrides
-----------------
These monoids set the multiplier of the override they are named after. All three
take non-negative numbers as values and combine them with multiplication. They
are:
* ```player_monoids.speed```
* ```player_monoids.jump```
* ```player_monoids.gravity```
## Predefined monoids
Privileges
----------
These monoids set privileges that affect the player's ordinary gameplay. They
take booleans as input and combine them with logical or. They are:
* ```player_monoids.fly```
* ```player_monoids.noclip```
### Physics Overrides
Other
-----
* ```player_monoids.collisionbox``` - Sets the player's collisionbox. Values are
3D multiplier vectors, which are combined with component-wise multiplication.
* ```player_monoids.visual_size``` - Sets the player's collisionbox. Values are
2D multiplier vectors (x and y), which are combined with component-wise
multiplication.
These monoids modify physics properties using multipliers:
Use with playereffects
======================
Player Monoids does not provide anything special for persistent effects with
limited lifetime. By using monoids with Wuzzy's playereffects, you can easily
create temporary effects that stack with each other. As an example, an effect
that gives the player 2x speed:
```
local speed = player_monoids.speed
- `player_monoids.speed`
- `player_monoids.jump`
- `player_monoids.gravity`
local function apply(player)
speed:add_change(player, 2, "mymod:2x_speed")
end
### Privileges
local function cancel(player)
speed:del_change(player, "mymod:2x_speed")
end
These monoids toggle player privileges, using boolean logic:
local groups = { "mymod:2x_speed" }
- `player_monoids.fly`
- `player_monoids.noclip`
playereffects.register_effect_type("mymod:2x_speed", "2x Speed", groups, apply, cancel)
```
### Other
Note that this effect does NOT use the "speed" effect group. As long as other
speed effects use the speed monoid, we do not want them to be cancelled, since
the goal is to combine the effects together. It does use a singleton group to
prevent multiple instances of the same effect. I think that playereffects require
effects to belong to at least one group, but I am not sure.
- `player_monoids.collisionbox` - Adjusts the players collision box with component-wise multiplication.
- `player_monoids.visual_size` - Modifies the players visual size as a 2D multiplier vector.
Caveats
=======
* If the global state managed by a monoid is modified by something other than
the monoid, you will have the same problem as when two mods both independently
try to modify global state without going through a monoid.
* This includes playereffects effects that affect global player state without
going through a monoid.
* You will also get problems if you use multiple monoids to manage the same
global state.
* The order that different effects get combined together is based on key order,
which may not be predictable. So you should try to make your monoids commutative
in addition to associative, or at least not care if the order of two changes
is swapped.
## Caveats
- If the global state managed by a monoid is modified by something other than the monoid, you will have the same problem as when two mods both independently try to modify global state without going through a monoid.
- This includes `playereffects` effects that affect global player state without going through a monoid.
- You will also get problems if you use multiple monoids to manage the same global state.
- The order that different effects get combined together is based on key order, which may not be predictable. So you should try to make your monoids commutative in addition to associative, or at least not care if the order of two changes is swapped.
- Mods should account for the fact that the active branch may change at any time - they should not assume that their effects will always be applied to the player.
- If a mod wants to make sure to always be working with the main branch values, it should be doing that through the optional branch_name parameter in the monoid functions (such as `monoid:value(player, "main")`, and/or by implementing branch checks in `on_change()`).
---
For more details, including function signatures and advanced usage, refer to **API.md**.