Si j'ai bien compris, la demande fondamentale est centrée sur la capacité à itérer (efficacement) sur les variations d'un mécanisme de jeu. Ceux qui sont plus compétents que moi sont mieux placés pour dériver une formule généralisée mathématiquement, mais je peux au moins fournir quelques moyens de bricolage, dans ce cas en utilisant dyce
¹ pour le calcul et anydyce
² pour une interaction rudimentaire.
Vous pouvez l'essayer dans votre navigateur : [ source ]
Bien qu'un peu maladroite, l'interface permet à l'utilisateur de sélectionner un dé polygonal standard, de définir une cible et de choisir une fonction d'ajustement parmi deux méthodes prédéfinies identifiées dans la question initiale. Elle permet également des entrées personnalisées pour les utilisateurs avancés (plus d'informations à ce sujet ci-dessous).
Utilisation dyce
pour modéliser le mécanicien
dyce
utilise le calcul discret, ce qui signifie qu'il atteint la précision sans échantillonnage aléatoire. Il ne prend pas en compte les distributions continues, mais il vous donnera des résultats précis, à condition qu'il puisse modéliser le problème de manière adéquate.
Première tentative - Approche naïve (non performante)
Lorsque l'on définit le mécanicien dans des termes qui dyce
peut comprendre, on pourrait faire quelque chose comme ce qui suit :
from dyce import H
from dyce.evaluation import HResult, foreach
def degrading_target_nonperformant(
die: H,
initial_target: int,
prior_tries: int = 0,
) -> H:
adjusted_target = initial_target - prior_tries
current_tries = prior_tries + 1
def _callback(d_result: HResult):
if d_result.outcome >= adjusted_target:
return current_tries
else:
return degrading_target_nonperformant(d_result.h, initial_target, prior_tries=current_tries)
return foreach(
_callback,
die,
limit=-1, # do not limit recursion
)
Bien que ce qui précède soit techniquement exact, le fait de chercher à savoir si chaque résultat atteint le seuil permet d'obtenir des performances de l'ordre de O( n !). En pratique, cela ne fonctionne que pour les petits dés ou les petites cibles. A mourir de H(20)
y cible de 20
prendra des éons (peut-être littéralement).
d20 with target 3 --> 394 µs ± 4.57 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
d20 with target 4 --> 1.22 ms ± 9.6 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
d20 with target 5 --> 4.63 ms ± 81.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
d20 with target 6 --> 23.3 ms ± 239 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
...
Deuxième tentative - Approche affinée (performante)
La bonne nouvelle, c'est que nous pouvons introduire une astuce de comptage pour nous faciliter la tâche. Plutôt que de faire des branchements sur chaque résultat, nous pouvons faire des branchements sur la probabilité d'atteindre une cible particulière :
def degrading_target_performant(
die: H,
initial_target: int,
prior_tries: int = 0,
) -> H:
adjusted_target = initial_target - prior_tries
current_tries = prior_tries + 1
succeeds_or_fails_at_adjusted_target_h = die.ge(adjusted_target)
def _callback(ge_result: HResult):
if ge_result.outcome == 1:
return current_tries
elif ge_result.outcome == 0:
return degrading_target_performant(die, initial_target, proir_tries=current_tries)
else:
assert False, "should never be here"
return foreach(
_callback,
succeeds_or_fails_at_adjusted_target_h,
limit=-1, # do not limit recursion
)
Notre facteur de branchement passe ainsi de n (le nombre de faces de notre dé) à 2, ce qui conduit à une performance de O( n ), ce qui est tout à fait raisonnable.
---- performant approach ----
d20 with target 3 --> 97.9 µs ± 542 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
d20 with target 6 --> 190 µs ± 1.55 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
d20 with target 9 --> 286 µs ± 2.69 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
d20 with target 12 --> 382 µs ± 4.02 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
d20 with target 15 --> 469 µs ± 7.75 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
d20 with target 18 --> 580 µs ± 4.27 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
...
Cela nous donne ce que nous voulons :
>>> d20 = H(20)
>>> h = degrading_target_performant(d20, 20)
>>> print(h.format(scaled=True))
avg | 5.29
std | 2.59
var | 6.68
1 | 5.00% |#################
2 | 9.50% |################################
3 | 12.83% |############################################
4 | 14.54% |##################################################
5 | 14.54% |##################################################
6 | 13.08% |############################################
7 | 10.68% |####################################
8 | 7.94% |###########################
9 | 5.36% |##################
10 | 3.27% |###########
11 | 1.80% |######
12 | 0.88% |###
13 | 0.38% |#
14 | 0.14% |
15 | 0.05% |
16 | 0.01% |
17 | 0.00% |
18 | 0.00% |
19 | 0.00% |
20 | 0.00% |
Amélioration - Fonction cible ajustée paramétrée
Nous pouvons maintenant passer à l'expérimentation. Nous avons déjà paramétré le dé et la cible que nous utilisons. Les codeurs astucieux noteront que nous pouvons refactoriser ce qui précède pour paramétrer également les moyens par lesquels nous calculons la cible ajustée. Le code source de notre version peut être trouvé à l'adresse suivante ici .
Entrées personnalisées
Pour ceux qui connaissent Python et dyce
Il est possible de saisir son propre histogramme pour le dé (par ex, 2 @ H(6)
pour 2d6) et on peut même définir sa propre fonction d'ajustement de cible personnalisée qui prend deux arguments ( cible y essais_précédents ) et renvoie la cible ajustée. La fonction personnalisée peut être définie comme un lambda
ou il peut contenir du code Python à part entière (à condition qu'il définisse ou assigne un appelable de la signature appropriée à l'expression _
symbole).
Par exemple, pour voir comment le mécanisme fonctionnerait avec un d8 plus un d12 et une fonction d'ajustement qui réduit la cible à chaque nouvel essai, on pourrait utiliser un dé personnalisé de H(8) + H(12)
et une fonction d'ajustement personnalisé de lambda target, prior_tries: target - prior_tries // 2
ou, si l'on préfère, quelque chose comme :
def my_adjustment_func(target: int, prior_tries: int) -> int:
return target - prior_tries // 2
_ = my_adjustment_func # <-- "export" the function by assigning it to the _ variable
Une mise en garde s'impose lorsque vous expérimentez votre propre fonction d'ajustement : elle doit converger, c'est-à-dire qu'elle doit éventuellement avoir 100 % de chances de réussir (par exemple, une cible de la valeur minimale de l'intervalle de dé), ou le calcul ne se terminera jamais. Je laisse au lecteur le soin de réfléchir à la manière d'introduire une mesure de sauvegarde.
Réflexions finales
Je n'ai jamais trouvé qu'une moyenne autonome était un outil utile pour évaluer un mécanisme de jeu. Le d20 et le 4d4 ont tous deux la même moyenne, mais sont des animaux très différents. Dans la conception d'un jeu, les tests de jeu sont essentiels, mais un bon outil de visualisation peut vous aider à éliminer beaucoup de déchets avant même qu'ils n'arrivent sur la table. C'est probablement une question de goût, anydyce
Les graphiques de "l'éclatement" de la Commission sont ma meilleure tentative (jusqu'à présent) d'obtenir une "sensation" pour éclairer ce processus d'élimination. J'espère que cela vous aidera à explorer votre mécanisme plus en profondeur !
¹ dyce
est ma bibliothèque de probabilités de dés en Python.
² anydyce
est ma couche de visualisation pour dyce
est un substitut approximatif d'AnyDice.