loom-engine-rpg 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loom_engine_rpg-2.3.0/PKG-INFO +52 -0
- loom_engine_rpg-2.3.0/README.md +40 -0
- loom_engine_rpg-2.3.0/loom_engine/__init__.py +39 -0
- loom_engine_rpg-2.3.0/loom_engine/narration_contract.py +103 -0
- loom_engine_rpg-2.3.0/loom_engine/py.typed +0 -0
- loom_engine_rpg-2.3.0/loom_engine/range_bands.py +128 -0
- loom_engine_rpg-2.3.0/loom_engine/reaction_economy.py +85 -0
- loom_engine_rpg-2.3.0/loom_engine/ruleset.py +170 -0
- loom_engine_rpg-2.3.0/loom_engine_rpg.egg-info/PKG-INFO +52 -0
- loom_engine_rpg-2.3.0/loom_engine_rpg.egg-info/SOURCES.txt +18 -0
- loom_engine_rpg-2.3.0/loom_engine_rpg.egg-info/dependency_links.txt +1 -0
- loom_engine_rpg-2.3.0/loom_engine_rpg.egg-info/requires.txt +3 -0
- loom_engine_rpg-2.3.0/loom_engine_rpg.egg-info/top_level.txt +1 -0
- loom_engine_rpg-2.3.0/pyproject.toml +31 -0
- loom_engine_rpg-2.3.0/setup.cfg +4 -0
- loom_engine_rpg-2.3.0/tests/test_golden_vectors.py +93 -0
- loom_engine_rpg-2.3.0/tests/test_narration_contract.py +58 -0
- loom_engine_rpg-2.3.0/tests/test_range_bands.py +88 -0
- loom_engine_rpg-2.3.0/tests/test_reaction_economy.py +72 -0
- loom_engine_rpg-2.3.0/tests/test_ruleset.py +77 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loom-engine-rpg
|
|
3
|
+
Version: 2.3.0
|
|
4
|
+
Summary: Deterministic TTRPG simulation core for AI-run games - the Python surface of loom-engine. Engine-is-truth: seeded, replay-safe rules the AI narrates but cannot invent.
|
|
5
|
+
License-Expression: BUSL-1.1
|
|
6
|
+
Project-URL: Homepage, https://www.npmjs.com/package/loom-engine
|
|
7
|
+
Keywords: ttrpg,rpg,deterministic,ai-game-master,5e,pathfinder,simulation
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
12
|
+
|
|
13
|
+
# loom-engine (Python)
|
|
14
|
+
|
|
15
|
+
The Python surface of [loom-engine](https://www.npmjs.com/package/loom-engine) - a
|
|
16
|
+
deterministic rules core for AI-run tabletop and RPG games.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install loom-engine-rpg # the bare 'loom-engine' name is taken on PyPI
|
|
20
|
+
```
|
|
21
|
+
```python
|
|
22
|
+
import loom_engine # the import name is loom_engine
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The design principle: **the engine is truth.** Dice, action economy, initiative,
|
|
26
|
+
range, conditions, reactions, and validation are resolved by pure, replay-safe
|
|
27
|
+
code. An AI narrator can describe the outcome, but it never gets to invent the
|
|
28
|
+
roll, change the numbers, or rewrite what happened.
|
|
29
|
+
|
|
30
|
+
This package is a **byte-parity** port of the TypeScript engine: the same inputs
|
|
31
|
+
produce identical results in Python (your server/backend) and TypeScript (the
|
|
32
|
+
browser), so a server-authoritative resolution and a client one can never
|
|
33
|
+
disagree. That parity is enforced by a shared cross-language golden-vector suite.
|
|
34
|
+
|
|
35
|
+
## Modules (v2.3.0)
|
|
36
|
+
|
|
37
|
+
- `loom_engine.range_bands` - grid-free Engaged/Near/Far positioning.
|
|
38
|
+
- `loom_engine.reaction_economy` - the per-round "1 reaction per combatant" ceiling.
|
|
39
|
+
- `loom_engine.narration_contract` - `find_invented_number`: reject prose that
|
|
40
|
+
states a mechanics number the engine never produced (numerals **and** number-words).
|
|
41
|
+
- `loom_engine.ruleset` - 5e + PF2e action economy, initiative ordering, conditions.
|
|
42
|
+
|
|
43
|
+
Pure stdlib, zero runtime dependencies. Compatible with the D&D 5e SRD (CC-BY-4.0)
|
|
44
|
+
and the Pathfinder 2e Remaster ruleset (ORC License); see `../NOTICE.md`. Not
|
|
45
|
+
affiliated with or endorsed by Wizards of the Coast or Paizo.
|
|
46
|
+
|
|
47
|
+
## Determinism
|
|
48
|
+
|
|
49
|
+
These modules use ordered dicts + explicit sorts for all logic (never `hash()`
|
|
50
|
+
ordering). For any cross-language hashing, serialize with
|
|
51
|
+
`json.dumps(obj, sort_keys=True, separators=(',', ':'))` and run with
|
|
52
|
+
`PYTHONHASHSEED=0`. Floats are banned in deterministic paths.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# loom-engine (Python)
|
|
2
|
+
|
|
3
|
+
The Python surface of [loom-engine](https://www.npmjs.com/package/loom-engine) - a
|
|
4
|
+
deterministic rules core for AI-run tabletop and RPG games.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install loom-engine-rpg # the bare 'loom-engine' name is taken on PyPI
|
|
8
|
+
```
|
|
9
|
+
```python
|
|
10
|
+
import loom_engine # the import name is loom_engine
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The design principle: **the engine is truth.** Dice, action economy, initiative,
|
|
14
|
+
range, conditions, reactions, and validation are resolved by pure, replay-safe
|
|
15
|
+
code. An AI narrator can describe the outcome, but it never gets to invent the
|
|
16
|
+
roll, change the numbers, or rewrite what happened.
|
|
17
|
+
|
|
18
|
+
This package is a **byte-parity** port of the TypeScript engine: the same inputs
|
|
19
|
+
produce identical results in Python (your server/backend) and TypeScript (the
|
|
20
|
+
browser), so a server-authoritative resolution and a client one can never
|
|
21
|
+
disagree. That parity is enforced by a shared cross-language golden-vector suite.
|
|
22
|
+
|
|
23
|
+
## Modules (v2.3.0)
|
|
24
|
+
|
|
25
|
+
- `loom_engine.range_bands` - grid-free Engaged/Near/Far positioning.
|
|
26
|
+
- `loom_engine.reaction_economy` - the per-round "1 reaction per combatant" ceiling.
|
|
27
|
+
- `loom_engine.narration_contract` - `find_invented_number`: reject prose that
|
|
28
|
+
states a mechanics number the engine never produced (numerals **and** number-words).
|
|
29
|
+
- `loom_engine.ruleset` - 5e + PF2e action economy, initiative ordering, conditions.
|
|
30
|
+
|
|
31
|
+
Pure stdlib, zero runtime dependencies. Compatible with the D&D 5e SRD (CC-BY-4.0)
|
|
32
|
+
and the Pathfinder 2e Remaster ruleset (ORC License); see `../NOTICE.md`. Not
|
|
33
|
+
affiliated with or endorsed by Wizards of the Coast or Paizo.
|
|
34
|
+
|
|
35
|
+
## Determinism
|
|
36
|
+
|
|
37
|
+
These modules use ordered dicts + explicit sorts for all logic (never `hash()`
|
|
38
|
+
ordering). For any cross-language hashing, serialize with
|
|
39
|
+
`json.dumps(obj, sort_keys=True, separators=(',', ':'))` and run with
|
|
40
|
+
`PYTHONHASHSEED=0`. Floats are banned in deterministic paths.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""loom_engine - deterministic TTRPG simulation core (Python surface).
|
|
2
|
+
|
|
3
|
+
Byte-parity Python port of the loom-engine TypeScript package (npm: loom-engine).
|
|
4
|
+
The deterministic primitives - range bands, reaction economy, narration contract,
|
|
5
|
+
5e/PF2e ruleset adapters - produce identical results to the TS engine for the
|
|
6
|
+
same inputs, so a Python-server resolution equals a TS-client one: the basis for
|
|
7
|
+
server-authoritative anti-cheat + honest AI-narrated play.
|
|
8
|
+
|
|
9
|
+
DETERMINISM NOTE: these modules use ordered dicts + explicit sorts for all LOGIC,
|
|
10
|
+
so they never depend on hash() ordering. For any cross-language HASHING, serialize
|
|
11
|
+
with json.dumps(obj, sort_keys=True, separators=(',', ':')) and run with
|
|
12
|
+
PYTHONHASHSEED=0. Ban floats in deterministic paths; use an explicit floor-div
|
|
13
|
+
helper if negative-operand division is ever needed (JS truncates toward zero,
|
|
14
|
+
Python // floors).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "2.3.0"
|
|
18
|
+
|
|
19
|
+
from .range_bands import ( # noqa: F401
|
|
20
|
+
RANGE_BAND_ENGAGED, RANGE_BAND_NEAR, RANGE_BAND_FAR, ENGAGED_MAX_FT,
|
|
21
|
+
NEAR_MAX_FT, band_from_distance_ft, normalize_band, band_within,
|
|
22
|
+
compare_bands, RangeBandField, RESOURCE_RANGE_BANDS,
|
|
23
|
+
)
|
|
24
|
+
from .reaction_economy import ( # noqa: F401
|
|
25
|
+
REACTIONS_PER_ROUND, ReactionLedger, create_reaction_ledger, can_react,
|
|
26
|
+
reactions_remaining, spend_reaction, advance_reaction_round,
|
|
27
|
+
set_reaction_round, prune_stale_spends, clear_reactions,
|
|
28
|
+
reaction_ledger_snapshot, RESOURCE_REACTION_ECONOMY,
|
|
29
|
+
)
|
|
30
|
+
from .narration_contract import ( # noqa: F401
|
|
31
|
+
parse_number_word, extract_candidate_numbers, find_invented_number,
|
|
32
|
+
is_narration_grounded, RESOURCE_NARRATION_CONTRACT,
|
|
33
|
+
)
|
|
34
|
+
from .ruleset import ( # noqa: F401
|
|
35
|
+
RULESET_5E, RULESET_PF2E, start_turn_budget, can_spend, spend,
|
|
36
|
+
initiative_order, create_condition_track, apply_condition,
|
|
37
|
+
remove_condition, has_condition, condition_remaining, tick_conditions,
|
|
38
|
+
active_conditions, DURATION_UNTIL_REMOVED, RESOURCE_RULESET,
|
|
39
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""loom_engine.narration_contract - engine-owns-outcomes (no-invented-number).
|
|
2
|
+
|
|
3
|
+
Byte-parity hand-port of the TypeScript runtime/narration-contract.ts. Given the
|
|
4
|
+
canonical numbers the engine produced this turn, find any mechanics-significant
|
|
5
|
+
number in the prose the engine did NOT produce - catching numerals AND
|
|
6
|
+
number-words. The differentiator vs pure-LLM story apps: the AI may describe the
|
|
7
|
+
engine's outcomes, never invent them. Pure + deterministic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Iterable, List, Optional
|
|
14
|
+
|
|
15
|
+
_ONES = {
|
|
16
|
+
"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
|
|
17
|
+
"seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12,
|
|
18
|
+
"thirteen": 13, "fourteen": 14, "fifteen": 15, "sixteen": 16,
|
|
19
|
+
"seventeen": 17, "eighteen": 18, "nineteen": 19,
|
|
20
|
+
}
|
|
21
|
+
_TENS = {
|
|
22
|
+
"twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60,
|
|
23
|
+
"seventy": 70, "eighty": 80, "ninety": 90,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_NUMERAL_RE = re.compile(r"\d[\d,]*")
|
|
27
|
+
_WORD_SPLIT_RE = re.compile(r"[^a-z]+")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_number_word(token) -> Optional[int]:
|
|
31
|
+
"""Parse a single number-word ('seven', 'twenty', 'twenty-one', 'twenty one')
|
|
32
|
+
to its value, or None."""
|
|
33
|
+
t = str(token or "").strip().lower()
|
|
34
|
+
if not t:
|
|
35
|
+
return None
|
|
36
|
+
if t in _ONES:
|
|
37
|
+
return _ONES[t]
|
|
38
|
+
if t in _TENS:
|
|
39
|
+
return _TENS[t]
|
|
40
|
+
parts = re.split(r"[\s-]+", t)
|
|
41
|
+
if len(parts) == 2 and parts[0] in _TENS and parts[1] in _ONES:
|
|
42
|
+
ones = _ONES[parts[1]]
|
|
43
|
+
if 1 <= ones <= 9:
|
|
44
|
+
return _TENS[parts[0]] + ones
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_candidate_numbers(text) -> List[int]:
|
|
49
|
+
"""Every candidate mechanics number in `text` - numerals (incl. 1,024) and
|
|
50
|
+
number-words (incl. 'twenty-one'), order-preserving with duplicates."""
|
|
51
|
+
out: List[int] = []
|
|
52
|
+
if not text or not isinstance(text, str):
|
|
53
|
+
return out
|
|
54
|
+
for m in _NUMERAL_RE.finditer(text):
|
|
55
|
+
raw = m.group(0).replace(",", "")
|
|
56
|
+
try:
|
|
57
|
+
out.append(int(raw))
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
words = _WORD_SPLIT_RE.split(text.lower())
|
|
61
|
+
i = 0
|
|
62
|
+
while i < len(words):
|
|
63
|
+
w = words[i]
|
|
64
|
+
if not w:
|
|
65
|
+
i += 1
|
|
66
|
+
continue
|
|
67
|
+
if w in _TENS:
|
|
68
|
+
nxt = words[i + 1] if i + 1 < len(words) else ""
|
|
69
|
+
if nxt in _ONES and 1 <= _ONES[nxt] <= 9:
|
|
70
|
+
out.append(_TENS[w] + _ONES[nxt])
|
|
71
|
+
i += 2
|
|
72
|
+
continue
|
|
73
|
+
out.append(_TENS[w])
|
|
74
|
+
elif w in _ONES:
|
|
75
|
+
out.append(_ONES[w])
|
|
76
|
+
i += 1
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_invented_number(text, attested: Iterable[int],
|
|
81
|
+
ignore_at_or_below: int = 2) -> Optional[int]:
|
|
82
|
+
"""First mechanics number in `text` NOT in the engine's attested set, or None.
|
|
83
|
+
Numbers <= ignore_at_or_below are treated as ambiguous flavor (default 2)."""
|
|
84
|
+
allowed = set()
|
|
85
|
+
if attested:
|
|
86
|
+
for a in attested:
|
|
87
|
+
if isinstance(a, (int, float)) and a == a:
|
|
88
|
+
allowed.add(int(a) if isinstance(a, int) else a)
|
|
89
|
+
allowed.add(a)
|
|
90
|
+
for c in extract_candidate_numbers(text):
|
|
91
|
+
if c <= ignore_at_or_below:
|
|
92
|
+
continue
|
|
93
|
+
if c not in allowed:
|
|
94
|
+
return c
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_narration_grounded(text, attested: Iterable[int],
|
|
99
|
+
ignore_at_or_below: int = 2) -> bool:
|
|
100
|
+
return find_invented_number(text, attested, ignore_at_or_below) is None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
RESOURCE_NARRATION_CONTRACT = "narrationContract"
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""loom_engine.range_bands - grid-free relative positioning.
|
|
2
|
+
|
|
3
|
+
PARITY CONTRACT: this is a hand-port of the TypeScript runtime/range-bands.ts.
|
|
4
|
+
The band math + ordering MUST match the TS module byte-for-byte (same
|
|
5
|
+
thresholds, same insertion-ordered iteration via Python's ordered dict), so a
|
|
6
|
+
Python-server result is identical to a TS-client result for the same inputs.
|
|
7
|
+
The shared golden vectors in ../test_vectors/ are the cross-language gate.
|
|
8
|
+
|
|
9
|
+
Pure + deterministic: no RNG, no floats stored (distance is advisory).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
RANGE_BAND_ENGAGED = "engaged"
|
|
18
|
+
RANGE_BAND_NEAR = "near"
|
|
19
|
+
RANGE_BAND_FAR = "far"
|
|
20
|
+
|
|
21
|
+
ENGAGED_MAX_FT = 5
|
|
22
|
+
NEAR_MAX_FT = 30
|
|
23
|
+
|
|
24
|
+
_BAND_ORDER: Dict[str, int] = {"engaged": 0, "near": 1, "far": 2}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def band_from_distance_ft(feet) -> str:
|
|
28
|
+
"""Map a distance in feet to a band. The cross-language contract is INTEGER
|
|
29
|
+
feet (the Rust/WASM core takes i64), so a fractional input is TRUNCATED toward
|
|
30
|
+
zero first - trunc(5.49) == 5 -> Engaged - matching Math.trunc (TS) and
|
|
31
|
+
`feet as i64` (Rust). Negative / NaN / unparseable -> the neutral Near."""
|
|
32
|
+
try:
|
|
33
|
+
d = float(feet)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return RANGE_BAND_NEAR
|
|
36
|
+
if d != d: # NaN
|
|
37
|
+
return RANGE_BAND_NEAR
|
|
38
|
+
if d < 0:
|
|
39
|
+
return RANGE_BAND_NEAR
|
|
40
|
+
if math.isinf(d): # trunc(inf) would raise; +inf is past every threshold
|
|
41
|
+
return RANGE_BAND_FAR
|
|
42
|
+
d = math.trunc(d) # integer-feet contract
|
|
43
|
+
if d <= ENGAGED_MAX_FT:
|
|
44
|
+
return RANGE_BAND_ENGAGED
|
|
45
|
+
if d <= NEAR_MAX_FT:
|
|
46
|
+
return RANGE_BAND_NEAR
|
|
47
|
+
return RANGE_BAND_FAR
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def normalize_band(band) -> Optional[str]:
|
|
51
|
+
return band if band in _BAND_ORDER else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def band_within(band: str, max_band: str) -> bool:
|
|
55
|
+
"""True iff `band` is at least as close as `max_band` (engaged < near < far)."""
|
|
56
|
+
a = _BAND_ORDER.get(band)
|
|
57
|
+
b = _BAND_ORDER.get(max_band)
|
|
58
|
+
if a is None or b is None:
|
|
59
|
+
return False
|
|
60
|
+
return a <= b
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compare_bands(a: str, b: str) -> int:
|
|
64
|
+
ai = _BAND_ORDER.get(a, 99)
|
|
65
|
+
bi = _BAND_ORDER.get(b, 99)
|
|
66
|
+
return ai - bi
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pair_key(source: str, target: str) -> str:
|
|
70
|
+
return source + " " + target
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RangeBandField:
|
|
74
|
+
"""Directed (source -> target) band store for one encounter. set_pair writes
|
|
75
|
+
both directions symmetrically by default. Backed by an insertion-ordered
|
|
76
|
+
dict (Python 3.7+), matching the TS Map iteration order."""
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.bands: Dict[str, str] = {}
|
|
80
|
+
|
|
81
|
+
def set_pair(self, a: str, b: str, band: Optional[str] = None,
|
|
82
|
+
distance_feet=None, symmetric: bool = True) -> str:
|
|
83
|
+
explicit = normalize_band(band) if band is not None else None
|
|
84
|
+
if explicit is not None:
|
|
85
|
+
resolved = explicit
|
|
86
|
+
elif distance_feet is not None:
|
|
87
|
+
resolved = band_from_distance_ft(distance_feet)
|
|
88
|
+
else:
|
|
89
|
+
resolved = RANGE_BAND_NEAR
|
|
90
|
+
if not a or not b or a == b:
|
|
91
|
+
return resolved
|
|
92
|
+
self.bands[_pair_key(a, b)] = resolved
|
|
93
|
+
if symmetric:
|
|
94
|
+
self.bands[_pair_key(b, a)] = resolved
|
|
95
|
+
return resolved
|
|
96
|
+
|
|
97
|
+
def get_band(self, source: str, target: str) -> Optional[str]:
|
|
98
|
+
return self.bands.get(_pair_key(source, target))
|
|
99
|
+
|
|
100
|
+
def is_engaged(self, a: str, b: str) -> bool:
|
|
101
|
+
return self.get_band(a, b) == RANGE_BAND_ENGAGED
|
|
102
|
+
|
|
103
|
+
def targets_within(self, source: str, max_band: str) -> List[str]:
|
|
104
|
+
out: List[str] = []
|
|
105
|
+
prefix = source + " "
|
|
106
|
+
for key, band in self.bands.items():
|
|
107
|
+
if key.startswith(prefix) and band_within(band, max_band):
|
|
108
|
+
out.append(key[len(prefix):])
|
|
109
|
+
# Codex P1: canonical SORTED order (UTF-8 bytes) to match the Rust core's
|
|
110
|
+
# BTreeMap iteration - identical across languages.
|
|
111
|
+
out.sort(key=lambda s: s.encode("utf-8"))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
def engaged_with(self, source: str) -> List[str]:
|
|
115
|
+
return self.targets_within(source, RANGE_BAND_ENGAGED)
|
|
116
|
+
|
|
117
|
+
def clear(self) -> None:
|
|
118
|
+
self.bands.clear()
|
|
119
|
+
|
|
120
|
+
def snapshot(self) -> List[dict]:
|
|
121
|
+
out: List[dict] = []
|
|
122
|
+
for key, band in self.bands.items():
|
|
123
|
+
i = key.index(" ")
|
|
124
|
+
out.append({"source": key[:i], "target": key[i + 1:], "band": band})
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
RESOURCE_RANGE_BANDS = "rangeBands"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""loom_engine.reaction_economy - the per-round reaction ceiling.
|
|
2
|
+
|
|
3
|
+
Byte-parity hand-port of the TypeScript runtime/reaction-economy.ts: exactly ONE
|
|
4
|
+
reaction per combatant per round, each spend round-tagged so a stale prior-round
|
|
5
|
+
record is inert. Pure + deterministic (no RNG, no wall-clock; ordered dict
|
|
6
|
+
matches the TS Map). See ../test_vectors/ for the cross-language gate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Dict, List
|
|
12
|
+
|
|
13
|
+
REACTIONS_PER_ROUND = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReactionLedger:
|
|
17
|
+
"""round is 1-based. spent_in_round maps entity_id -> the round it last spent
|
|
18
|
+
its reaction (ordered-insertion dict, matching the TS Map)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, round_no: int = 1) -> None:
|
|
21
|
+
self.round = int(round_no) if isinstance(round_no, int) and round_no > 0 else 1
|
|
22
|
+
self.spent_in_round: Dict[str, int] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_reaction_ledger(round_no: int = 1) -> ReactionLedger:
|
|
26
|
+
return ReactionLedger(round_no)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def can_react(ledger: ReactionLedger, entity_id: str) -> bool:
|
|
30
|
+
"""True iff entity_id still has its reaction available THIS round."""
|
|
31
|
+
if not entity_id:
|
|
32
|
+
return False
|
|
33
|
+
return ledger.spent_in_round.get(entity_id) != ledger.round
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def reactions_remaining(ledger: ReactionLedger, entity_id: str) -> int:
|
|
37
|
+
return REACTIONS_PER_ROUND if can_react(ledger, entity_id) else 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def spend_reaction(ledger: ReactionLedger, entity_id: str) -> bool:
|
|
41
|
+
"""Spend the reaction. True if spent, False if already spent this round (the
|
|
42
|
+
ceiling refusing a second) or the id is empty."""
|
|
43
|
+
if not entity_id:
|
|
44
|
+
return False
|
|
45
|
+
if ledger.spent_in_round.get(entity_id) == ledger.round:
|
|
46
|
+
return False
|
|
47
|
+
ledger.spent_in_round[entity_id] = ledger.round
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def advance_reaction_round(ledger: ReactionLedger) -> int:
|
|
52
|
+
"""Advance to the next round - everyone's reaction refreshes (prior records
|
|
53
|
+
become stale). Returns the new round number."""
|
|
54
|
+
ledger.round += 1
|
|
55
|
+
return ledger.round
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_reaction_round(ledger: ReactionLedger, round_no: int) -> None:
|
|
59
|
+
if isinstance(round_no, int) and round_no > 0:
|
|
60
|
+
ledger.round = round_no
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def prune_stale_spends(ledger: ReactionLedger) -> int:
|
|
64
|
+
"""Drop spend-records older than the current round (memory bound). Behavior
|
|
65
|
+
unchanged - stale records are already inert. Returns the count removed."""
|
|
66
|
+
stale = [eid for eid, r in ledger.spent_in_round.items() if r < ledger.round]
|
|
67
|
+
for eid in stale:
|
|
68
|
+
del ledger.spent_in_round[eid]
|
|
69
|
+
return len(stale)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def clear_reactions(ledger: ReactionLedger) -> None:
|
|
73
|
+
ledger.spent_in_round.clear()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def reaction_ledger_snapshot(ledger: ReactionLedger) -> dict:
|
|
77
|
+
"""Deterministic, insertion-ordered snapshot for serialization / replay."""
|
|
78
|
+
return {
|
|
79
|
+
"round": ledger.round,
|
|
80
|
+
"spent": [{"entity_id": eid, "round": r}
|
|
81
|
+
for eid, r in ledger.spent_in_round.items()],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
RESOURCE_REACTION_ECONOMY = "reactionEconomy"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""loom_engine.ruleset - 5e + PF2e action economy / initiative / conditions.
|
|
2
|
+
|
|
3
|
+
Byte-parity hand-port of the TypeScript runtime/ruleset.ts. Deterministic +
|
|
4
|
+
content-agnostic (condition NAMES are caller-supplied, so no SRD text is
|
|
5
|
+
reproduced). Compatible with the D&D 5e SRD (CC-BY-4.0) and the Pathfinder 2e
|
|
6
|
+
Remaster ruleset (ORC License) - see ../../NOTICE.md. Pure (no RNG; the d20 is
|
|
7
|
+
rolled by the engine PRNG - this does the ruleset-correct ORDERING).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
RULESET_5E = "5e"
|
|
16
|
+
RULESET_PF2E = "pf2e"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---- action economy ----
|
|
20
|
+
|
|
21
|
+
def start_turn_budget(ruleset: str) -> dict:
|
|
22
|
+
"""5e: 1 action + 1 bonus + 1 reaction. PF2e: 3 actions + 1 reaction."""
|
|
23
|
+
if ruleset == RULESET_PF2E:
|
|
24
|
+
return {"ruleset": RULESET_PF2E, "resources": {"action": 3, "reaction": 1}}
|
|
25
|
+
return {"ruleset": RULESET_5E,
|
|
26
|
+
"resources": {"action": 1, "bonus": 1, "reaction": 1}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def can_spend(budget: dict, resource: str, n: int = 1) -> bool:
|
|
30
|
+
need = n if isinstance(n, int) and n > 0 else 1
|
|
31
|
+
have = budget.get("resources", {}).get(resource)
|
|
32
|
+
return isinstance(have, int) and have >= need
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def spend(budget: dict, resource: str, n: int = 1) -> bool:
|
|
36
|
+
"""Spend n (default 1) of a resource. True if spent, False if insufficient."""
|
|
37
|
+
need = n if isinstance(n, int) and n > 0 else 1
|
|
38
|
+
if not can_spend(budget, resource, need):
|
|
39
|
+
return False
|
|
40
|
+
budget["resources"][resource] -= need
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---- initiative ordering ----
|
|
45
|
+
|
|
46
|
+
def _is_pure_numeric(s: str) -> bool:
|
|
47
|
+
"""An optional '-' then >=1 ASCII digit."""
|
|
48
|
+
rest = s[1:] if s[:1] == "-" else s
|
|
49
|
+
return len(rest) > 0 and all("0" <= c <= "9" for c in rest)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_numeric(s: str):
|
|
53
|
+
neg = s[:1] == "-"
|
|
54
|
+
digits = s[1:] if neg else s
|
|
55
|
+
mag = digits.lstrip("0") or "0"
|
|
56
|
+
return (neg and mag != "0", mag) # -0 is +0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _byte_cmp(a: str, b: str) -> int:
|
|
60
|
+
ba = a.encode("utf-8")
|
|
61
|
+
bb = b.encode("utf-8")
|
|
62
|
+
return -1 if ba < bb else (1 if ba > bb else 0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def compare_ids(a: str, b: str) -> int:
|
|
66
|
+
"""Numeric-aware id comparison (Codex P1 / the shadow-wire finding). Numeric
|
|
67
|
+
ids sort by VALUE (2 < 10), strings lexicographically, numbers before strings.
|
|
68
|
+
No int parsing - sign + digit-length + UTF-8 bytes, so ids beyond i64 (uuids,
|
|
69
|
+
huge numbers) are correct. Byte-identical to the TS + Rust comparators."""
|
|
70
|
+
na = _is_pure_numeric(a)
|
|
71
|
+
nb = _is_pure_numeric(b)
|
|
72
|
+
if na and not nb:
|
|
73
|
+
return -1 # numbers before strings
|
|
74
|
+
if not na and nb:
|
|
75
|
+
return 1
|
|
76
|
+
if not na and not nb:
|
|
77
|
+
return _byte_cmp(a, b)
|
|
78
|
+
a_neg, a_mag = _normalize_numeric(a)
|
|
79
|
+
b_neg, b_mag = _normalize_numeric(b)
|
|
80
|
+
if not a_neg and b_neg:
|
|
81
|
+
return 1 # +a > -b
|
|
82
|
+
if a_neg and not b_neg:
|
|
83
|
+
return -1
|
|
84
|
+
if len(a_mag) != len(b_mag):
|
|
85
|
+
mag = -1 if len(a_mag) < len(b_mag) else 1
|
|
86
|
+
else:
|
|
87
|
+
mag = _byte_cmp(a_mag, b_mag)
|
|
88
|
+
by_value = -mag if a_neg else mag # both negative: larger magnitude is smaller
|
|
89
|
+
if by_value != 0:
|
|
90
|
+
return by_value
|
|
91
|
+
return _byte_cmp(a, b) # math-equal (e.g. "02" vs "2"): raw bytes, total order
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _initiative_cmp(a: dict, b: dict) -> int:
|
|
95
|
+
for key in ("total", "modifier", "d20"):
|
|
96
|
+
av = int(a.get(key, 0) or 0)
|
|
97
|
+
bv = int(b.get(key, 0) or 0)
|
|
98
|
+
if av != bv:
|
|
99
|
+
return -1 if av > bv else 1 # DESC
|
|
100
|
+
return compare_ids(str(a.get("id", "")), str(b.get("id", "")))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def initiative_order(entries: List[dict]) -> List[dict]:
|
|
104
|
+
"""Deterministic order: total DESC, then modifier DESC, then natural d20 DESC,
|
|
105
|
+
then a NUMERIC-AWARE id tiebreak (compare_ids). Correct for BOTH 5e and PF2e
|
|
106
|
+
and for integer ids AND string entity ids. New list; input untouched."""
|
|
107
|
+
return sorted(list(entries), key=functools.cmp_to_key(_initiative_cmp))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---- conditions (content-agnostic duration tracker) ----
|
|
111
|
+
|
|
112
|
+
DURATION_UNTIL_REMOVED = -1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_condition_track() -> dict:
|
|
116
|
+
return {"conditions": {}}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def apply_condition(track: dict, condition_id: str, rounds: Optional[int] = None) -> None:
|
|
120
|
+
"""Apply / refresh a condition. rounds 0 or None -> 'until removed'."""
|
|
121
|
+
if not condition_id:
|
|
122
|
+
return
|
|
123
|
+
r = int(rounds) if isinstance(rounds, int) else DURATION_UNTIL_REMOVED
|
|
124
|
+
if r == 0:
|
|
125
|
+
r = DURATION_UNTIL_REMOVED
|
|
126
|
+
track["conditions"][condition_id] = r
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remove_condition(track: dict, condition_id: str) -> bool:
|
|
130
|
+
if condition_id in track["conditions"]:
|
|
131
|
+
del track["conditions"][condition_id]
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def has_condition(track: dict, condition_id: str) -> bool:
|
|
137
|
+
return condition_id in track["conditions"]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def condition_remaining(track: dict, condition_id: str) -> int:
|
|
141
|
+
return track["conditions"].get(condition_id, 0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def tick_conditions(track: dict) -> List[str]:
|
|
145
|
+
"""Tick every FINITE condition down one round; expire (remove) any reaching 0.
|
|
146
|
+
DURATION_UNTIL_REMOVED never ticks. Returns expired ids (insertion order)."""
|
|
147
|
+
conds = track["conditions"]
|
|
148
|
+
expired: List[str] = []
|
|
149
|
+
for cid, rem in list(conds.items()):
|
|
150
|
+
if rem == DURATION_UNTIL_REMOVED:
|
|
151
|
+
continue
|
|
152
|
+
if rem <= 1:
|
|
153
|
+
expired.append(cid)
|
|
154
|
+
else:
|
|
155
|
+
conds[cid] = rem - 1
|
|
156
|
+
for cid in expired:
|
|
157
|
+
del conds[cid]
|
|
158
|
+
# Codex P1: SORTED by UTF-8 bytes so the result matches the Rust core's
|
|
159
|
+
# BTreeMap order - identical across languages, not insertion-dependent.
|
|
160
|
+
expired.sort(key=lambda s: s.encode("utf-8"))
|
|
161
|
+
return expired
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def active_conditions(track: dict) -> List[str]:
|
|
165
|
+
"""Active condition ids in canonical SORTED order (UTF-8 bytes), matching the
|
|
166
|
+
Rust core's BTreeMap - identical across languages."""
|
|
167
|
+
return sorted(track["conditions"].keys(), key=lambda s: s.encode("utf-8"))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
RESOURCE_RULESET = "ruleset"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loom-engine-rpg
|
|
3
|
+
Version: 2.3.0
|
|
4
|
+
Summary: Deterministic TTRPG simulation core for AI-run games - the Python surface of loom-engine. Engine-is-truth: seeded, replay-safe rules the AI narrates but cannot invent.
|
|
5
|
+
License-Expression: BUSL-1.1
|
|
6
|
+
Project-URL: Homepage, https://www.npmjs.com/package/loom-engine
|
|
7
|
+
Keywords: ttrpg,rpg,deterministic,ai-game-master,5e,pathfinder,simulation
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
12
|
+
|
|
13
|
+
# loom-engine (Python)
|
|
14
|
+
|
|
15
|
+
The Python surface of [loom-engine](https://www.npmjs.com/package/loom-engine) - a
|
|
16
|
+
deterministic rules core for AI-run tabletop and RPG games.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
pip install loom-engine-rpg # the bare 'loom-engine' name is taken on PyPI
|
|
20
|
+
```
|
|
21
|
+
```python
|
|
22
|
+
import loom_engine # the import name is loom_engine
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The design principle: **the engine is truth.** Dice, action economy, initiative,
|
|
26
|
+
range, conditions, reactions, and validation are resolved by pure, replay-safe
|
|
27
|
+
code. An AI narrator can describe the outcome, but it never gets to invent the
|
|
28
|
+
roll, change the numbers, or rewrite what happened.
|
|
29
|
+
|
|
30
|
+
This package is a **byte-parity** port of the TypeScript engine: the same inputs
|
|
31
|
+
produce identical results in Python (your server/backend) and TypeScript (the
|
|
32
|
+
browser), so a server-authoritative resolution and a client one can never
|
|
33
|
+
disagree. That parity is enforced by a shared cross-language golden-vector suite.
|
|
34
|
+
|
|
35
|
+
## Modules (v2.3.0)
|
|
36
|
+
|
|
37
|
+
- `loom_engine.range_bands` - grid-free Engaged/Near/Far positioning.
|
|
38
|
+
- `loom_engine.reaction_economy` - the per-round "1 reaction per combatant" ceiling.
|
|
39
|
+
- `loom_engine.narration_contract` - `find_invented_number`: reject prose that
|
|
40
|
+
states a mechanics number the engine never produced (numerals **and** number-words).
|
|
41
|
+
- `loom_engine.ruleset` - 5e + PF2e action economy, initiative ordering, conditions.
|
|
42
|
+
|
|
43
|
+
Pure stdlib, zero runtime dependencies. Compatible with the D&D 5e SRD (CC-BY-4.0)
|
|
44
|
+
and the Pathfinder 2e Remaster ruleset (ORC License); see `../NOTICE.md`. Not
|
|
45
|
+
affiliated with or endorsed by Wizards of the Coast or Paizo.
|
|
46
|
+
|
|
47
|
+
## Determinism
|
|
48
|
+
|
|
49
|
+
These modules use ordered dicts + explicit sorts for all logic (never `hash()`
|
|
50
|
+
ordering). For any cross-language hashing, serialize with
|
|
51
|
+
`json.dumps(obj, sort_keys=True, separators=(',', ':'))` and run with
|
|
52
|
+
`PYTHONHASHSEED=0`. Floats are banned in deterministic paths.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
loom_engine/__init__.py
|
|
4
|
+
loom_engine/narration_contract.py
|
|
5
|
+
loom_engine/py.typed
|
|
6
|
+
loom_engine/range_bands.py
|
|
7
|
+
loom_engine/reaction_economy.py
|
|
8
|
+
loom_engine/ruleset.py
|
|
9
|
+
loom_engine_rpg.egg-info/PKG-INFO
|
|
10
|
+
loom_engine_rpg.egg-info/SOURCES.txt
|
|
11
|
+
loom_engine_rpg.egg-info/dependency_links.txt
|
|
12
|
+
loom_engine_rpg.egg-info/requires.txt
|
|
13
|
+
loom_engine_rpg.egg-info/top_level.txt
|
|
14
|
+
tests/test_golden_vectors.py
|
|
15
|
+
tests/test_narration_contract.py
|
|
16
|
+
tests/test_range_bands.py
|
|
17
|
+
tests/test_reaction_economy.py
|
|
18
|
+
tests/test_ruleset.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loom_engine
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# pip-install name: 'loom-engine' is blocked on PyPI (too similar to the
|
|
3
|
+
# unrelated existing 'loomengine'). The IMPORT name stays `loom_engine`; the
|
|
4
|
+
# npm package stays `loom-engine`. Like `pip install beautifulsoup4` -> `import bs4`.
|
|
5
|
+
name = "loom-engine-rpg"
|
|
6
|
+
version = "2.3.0" # pinned to track the npm loom-engine version
|
|
7
|
+
description = "Deterministic TTRPG simulation core for AI-run games - the Python surface of loom-engine. Engine-is-truth: seeded, replay-safe rules the AI narrates but cannot invent."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
# SPDX expression (PEP 639) - matches the npm package. The free-text
|
|
11
|
+
# `{ text = ... }` form emits a deprecated Metadata-2.4 `License:` field that
|
|
12
|
+
# PyPI rejects with a 400. Full terms: LICENSE + COMMERCIAL_LICENSE_TERMS.md.
|
|
13
|
+
license = "BUSL-1.1"
|
|
14
|
+
keywords = ["ttrpg", "rpg", "deterministic", "ai-game-master", "5e", "pathfinder", "simulation"]
|
|
15
|
+
dependencies = [] # ZERO runtime deps - the deterministic core is pure stdlib
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
test = ["pytest>=7.0.0"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://www.npmjs.com/package/loom-engine"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["setuptools>=77.0.0"] # PEP 639 SPDX `license = "..."` support
|
|
25
|
+
build-backend = "setuptools.build_meta"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["loom_engine"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
loom_engine = ["py.typed"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Cross-language golden-vector runner (Python side).
|
|
2
|
+
|
|
3
|
+
Loads the SHARED test_vectors/*.json - the same files the TS (and future Rust)
|
|
4
|
+
harnesses load - and asserts the Python implementation produces the canonical
|
|
5
|
+
outputs. If TS and Python both pass against the same vectors, they are proven
|
|
6
|
+
byte-identical for those cases. This is the parity gate.
|
|
7
|
+
|
|
8
|
+
Run: python python/tests/test_golden_vectors.py
|
|
9
|
+
"""
|
|
10
|
+
import functools
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
sys.path.insert(0, os.path.join(HERE, ".."))
|
|
17
|
+
VECTOR_DIR = os.path.join(HERE, "..", "..", "test_vectors")
|
|
18
|
+
|
|
19
|
+
from loom_engine.range_bands import band_from_distance_ft, band_within # noqa: E402
|
|
20
|
+
from loom_engine.narration_contract import find_invented_number # noqa: E402
|
|
21
|
+
from loom_engine.ruleset import initiative_order, compare_ids # noqa: E402
|
|
22
|
+
from loom_engine.reaction_economy import ( # noqa: E402
|
|
23
|
+
create_reaction_ledger, can_react, spend_reaction, advance_reaction_round,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
PASS = 0
|
|
27
|
+
FAIL = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ck(label, cond):
|
|
31
|
+
global PASS, FAIL
|
|
32
|
+
if cond:
|
|
33
|
+
PASS += 1
|
|
34
|
+
print(" OK " + label)
|
|
35
|
+
else:
|
|
36
|
+
FAIL += 1
|
|
37
|
+
print(" FAIL " + label)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_reaction_script(ops):
|
|
41
|
+
"""Replay a scripted op list against a fresh ledger; return the result list."""
|
|
42
|
+
ledger = create_reaction_ledger()
|
|
43
|
+
out = []
|
|
44
|
+
for op in ops:
|
|
45
|
+
kind = op[0]
|
|
46
|
+
if kind == "spend":
|
|
47
|
+
out.append(spend_reaction(ledger, op[1]))
|
|
48
|
+
elif kind == "can_react":
|
|
49
|
+
out.append(can_react(ledger, op[1]))
|
|
50
|
+
elif kind == "advance":
|
|
51
|
+
out.append(advance_reaction_round(ledger))
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
path = os.path.join(VECTOR_DIR, "v2_3_0_primitives.json")
|
|
57
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
58
|
+
vectors = json.load(f)
|
|
59
|
+
|
|
60
|
+
for case in vectors["range_bands.band_from_distance_ft"]:
|
|
61
|
+
got = band_from_distance_ft(case["args"][0])
|
|
62
|
+
ck("band_from_distance_ft%s -> %s" % (case["args"], case["expect"]),
|
|
63
|
+
got == case["expect"])
|
|
64
|
+
|
|
65
|
+
for case in vectors["range_bands.band_within"]:
|
|
66
|
+
got = band_within(case["args"][0], case["args"][1])
|
|
67
|
+
ck("band_within%s -> %s" % (case["args"], case["expect"]),
|
|
68
|
+
got == case["expect"])
|
|
69
|
+
|
|
70
|
+
for case in vectors["narration.find_invented_number"]:
|
|
71
|
+
got = find_invented_number(case["args"][0], case["args"][1])
|
|
72
|
+
ck("find_invented_number(%r,...) -> %s" % (case["args"][0][:24], case["expect"]),
|
|
73
|
+
got == case["expect"])
|
|
74
|
+
|
|
75
|
+
for case in vectors["ruleset.initiative_order_ids"]:
|
|
76
|
+
got = [e["id"] for e in initiative_order(case["entries"])]
|
|
77
|
+
ck("initiative_order_ids -> %s" % case["expect"], got == case["expect"])
|
|
78
|
+
|
|
79
|
+
for case in vectors["ruleset.compare_ids"]:
|
|
80
|
+
got = sorted(case["input"], key=functools.cmp_to_key(compare_ids))
|
|
81
|
+
ck("compare_ids %s -> %s" % (case["input"], case["expect_asc"]),
|
|
82
|
+
got == case["expect_asc"])
|
|
83
|
+
|
|
84
|
+
for case in vectors["reaction.scripted"]:
|
|
85
|
+
got = run_reaction_script(case["ops"])
|
|
86
|
+
ck("reaction.scripted -> %s" % case["expect"], got == case["expect"])
|
|
87
|
+
|
|
88
|
+
print("\npassed=%d failed=%d" % (PASS, FAIL))
|
|
89
|
+
sys.exit(1 if FAIL else 0)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Parity tests for loom_engine.narration_contract (mirror tests/narration-contract.test.ts).
|
|
2
|
+
|
|
3
|
+
Run: python python/tests/test_narration_contract.py
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
9
|
+
|
|
10
|
+
from loom_engine.narration_contract import ( # noqa: E402
|
|
11
|
+
parse_number_word, extract_candidate_numbers, find_invented_number,
|
|
12
|
+
is_narration_grounded, RESOURCE_NARRATION_CONTRACT,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
PASS = 0
|
|
16
|
+
FAIL = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ck(label, cond):
|
|
20
|
+
global PASS, FAIL
|
|
21
|
+
if cond:
|
|
22
|
+
PASS += 1
|
|
23
|
+
print(" OK " + label)
|
|
24
|
+
else:
|
|
25
|
+
FAIL += 1
|
|
26
|
+
print(" FAIL " + label)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ck("RESOURCE key", RESOURCE_NARRATION_CONTRACT == "narrationContract")
|
|
30
|
+
|
|
31
|
+
ck("parse seven", parse_number_word("seven") == 7)
|
|
32
|
+
ck("parse twenty", parse_number_word("twenty") == 20)
|
|
33
|
+
ck("parse twenty-one", parse_number_word("twenty-one") == 21)
|
|
34
|
+
ck("parse twenty one", parse_number_word("twenty one") == 21)
|
|
35
|
+
ck("parse ninety-nine", parse_number_word("ninety-nine") == 99)
|
|
36
|
+
ck("parse banana None", parse_number_word("banana") is None)
|
|
37
|
+
|
|
38
|
+
ck("extract numerals + words", extract_candidate_numbers("you roll 18 and take 7 damage") == [18, 7])
|
|
39
|
+
ck("extract words folded", extract_candidate_numbers("seven damage, a total of twenty-one") == [7, 21])
|
|
40
|
+
ck("extract 1,024", extract_candidate_numbers("1,024 gold pieces") == [1024])
|
|
41
|
+
ck("extract none", extract_candidate_numbers("no numbers here") == [])
|
|
42
|
+
|
|
43
|
+
ck("grounded numeral", find_invented_number("Your blade bites for 7 damage.", [7]) is None)
|
|
44
|
+
ck("invented numeral", find_invented_number("Your blade bites for 9 damage.", [7]) == 9)
|
|
45
|
+
ck("grounded number-word", find_invented_number("You take seven damage.", [7]) is None)
|
|
46
|
+
ck("invented number-word", find_invented_number("You take eight damage.", [7]) == 8)
|
|
47
|
+
|
|
48
|
+
attested = [18, 15, 7]
|
|
49
|
+
ck("full attested exchange grounded",
|
|
50
|
+
is_narration_grounded("You roll 18 against DC 15 and deal 7 damage.", attested) is True)
|
|
51
|
+
ck("one stray invented",
|
|
52
|
+
find_invented_number("You roll 18 against DC 15 and deal twenty-one damage.", attested) == 21)
|
|
53
|
+
|
|
54
|
+
ck("small flavor ignored", find_invented_number("Two guards block the door.", []) is None)
|
|
55
|
+
ck("floor=0 scrutinizes", find_invented_number("Two guards block the door.", [], 0) == 2)
|
|
56
|
+
|
|
57
|
+
print("\npassed=%d failed=%d" % (PASS, FAIL))
|
|
58
|
+
sys.exit(1 if FAIL else 0)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Parity tests for loom_engine.range_bands - assertions mirror the TypeScript
|
|
2
|
+
tests/range-bands.test.ts so the two implementations stay byte-identical.
|
|
3
|
+
|
|
4
|
+
Run: python python/tests/test_range_bands.py
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
10
|
+
|
|
11
|
+
from loom_engine.range_bands import ( # noqa: E402
|
|
12
|
+
band_from_distance_ft,
|
|
13
|
+
normalize_band,
|
|
14
|
+
band_within,
|
|
15
|
+
compare_bands,
|
|
16
|
+
RangeBandField,
|
|
17
|
+
RANGE_BAND_ENGAGED,
|
|
18
|
+
RANGE_BAND_NEAR,
|
|
19
|
+
RANGE_BAND_FAR,
|
|
20
|
+
RESOURCE_RANGE_BANDS,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
PASS = 0
|
|
24
|
+
FAIL = 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ck(label, cond):
|
|
28
|
+
global PASS, FAIL
|
|
29
|
+
if cond:
|
|
30
|
+
PASS += 1
|
|
31
|
+
print(" OK " + label)
|
|
32
|
+
else:
|
|
33
|
+
FAIL += 1
|
|
34
|
+
print(" FAIL " + label)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ck("RESOURCE key stable", RESOURCE_RANGE_BANDS == "rangeBands")
|
|
38
|
+
|
|
39
|
+
# integer-feet contract: fractions truncate toward zero (parity with TS / Rust)
|
|
40
|
+
ck("0 -> engaged", band_from_distance_ft(0) == RANGE_BAND_ENGAGED)
|
|
41
|
+
ck("5 -> engaged", band_from_distance_ft(5) == RANGE_BAND_ENGAGED)
|
|
42
|
+
ck("5.49 -> engaged (trunc 5)", band_from_distance_ft(5.49) == RANGE_BAND_ENGAGED)
|
|
43
|
+
ck("30 -> near", band_from_distance_ft(30) == RANGE_BAND_NEAR)
|
|
44
|
+
ck("30.49 -> near (trunc 30)", band_from_distance_ft(30.49) == RANGE_BAND_NEAR)
|
|
45
|
+
ck("31 -> far", band_from_distance_ft(31) == RANGE_BAND_FAR)
|
|
46
|
+
ck("inf -> far", band_from_distance_ft(float("inf")) == RANGE_BAND_FAR)
|
|
47
|
+
ck("-10 -> near (defensive)", band_from_distance_ft(-10) == RANGE_BAND_NEAR)
|
|
48
|
+
ck("nan -> near (defensive)", band_from_distance_ft(float("nan")) == RANGE_BAND_NEAR)
|
|
49
|
+
ck("junk -> near (defensive)", band_from_distance_ft("xyz") == RANGE_BAND_NEAR)
|
|
50
|
+
|
|
51
|
+
ck("normalize valid", normalize_band("engaged") == RANGE_BAND_ENGAGED)
|
|
52
|
+
ck("normalize junk -> None", normalize_band("sideways") is None)
|
|
53
|
+
|
|
54
|
+
ck("within engaged<=near", band_within("engaged", "near") is True)
|
|
55
|
+
ck("within far<=near False", band_within("far", "near") is False)
|
|
56
|
+
ck("within near<=engaged False", band_within("near", "engaged") is False)
|
|
57
|
+
ck("compare_bands engaged<near", compare_bands("engaged", "near") < 0)
|
|
58
|
+
ck("compare_bands far>near", compare_bands("far", "near") > 0)
|
|
59
|
+
ck("compare_bands near==near", compare_bands("near", "near") == 0)
|
|
60
|
+
|
|
61
|
+
# field: symmetric write + derive from distance
|
|
62
|
+
f = RangeBandField()
|
|
63
|
+
ck("set dist=5 -> engaged", f.set_pair("pc", "goblin", distance_feet=5) == RANGE_BAND_ENGAGED)
|
|
64
|
+
ck("get pc->goblin engaged", f.get_band("pc", "goblin") == RANGE_BAND_ENGAGED)
|
|
65
|
+
ck("get goblin->pc engaged (symmetric)", f.get_band("goblin", "pc") == RANGE_BAND_ENGAGED)
|
|
66
|
+
ck("is_engaged True", f.is_engaged("pc", "goblin") is True)
|
|
67
|
+
|
|
68
|
+
ck("explicit band wins", f.set_pair("pc", "g2", band=RANGE_BAND_FAR, distance_feet=5) == RANGE_BAND_FAR)
|
|
69
|
+
ck("no band/dist -> near", f.set_pair("pc", "g3") == RANGE_BAND_NEAR)
|
|
70
|
+
|
|
71
|
+
f2 = RangeBandField()
|
|
72
|
+
f2.set_pair("pc", "goblin", distance_feet=5)
|
|
73
|
+
f2.set_pair("pc", "archer", distance_feet=20)
|
|
74
|
+
f2.set_pair("pc", "sniper", distance_feet=60)
|
|
75
|
+
ck("targets_within near = engaged+near", sorted(f2.targets_within("pc", "near")) == ["archer", "goblin"])
|
|
76
|
+
ck("engaged_with = [goblin]", f2.engaged_with("pc") == ["goblin"])
|
|
77
|
+
ck("targets_within far = all 3", len(f2.targets_within("pc", "far")) == 3)
|
|
78
|
+
|
|
79
|
+
f3 = RangeBandField()
|
|
80
|
+
f3.set_pair("pc", "g", band=RANGE_BAND_ENGAGED, symmetric=False)
|
|
81
|
+
ck("asymmetric: pc->g set", f3.get_band("pc", "g") == RANGE_BAND_ENGAGED)
|
|
82
|
+
ck("asymmetric: g->pc not set", f3.get_band("g", "pc") is None)
|
|
83
|
+
ck("self-pair no-op", (f3.set_pair("x", "x", band=RANGE_BAND_ENGAGED) and f3.get_band("x", "x")) is None)
|
|
84
|
+
snap = f3.snapshot()
|
|
85
|
+
ck("snapshot shape", len(snap) == 1 and snap[0] == {"source": "pc", "target": "g", "band": RANGE_BAND_ENGAGED})
|
|
86
|
+
|
|
87
|
+
print("\npassed=%d failed=%d" % (PASS, FAIL))
|
|
88
|
+
sys.exit(1 if FAIL else 0)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Parity tests for loom_engine.reaction_economy (mirror tests/reaction-economy.test.ts).
|
|
2
|
+
|
|
3
|
+
Run: python python/tests/test_reaction_economy.py
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
9
|
+
|
|
10
|
+
from loom_engine.reaction_economy import ( # noqa: E402
|
|
11
|
+
REACTIONS_PER_ROUND, create_reaction_ledger, can_react, reactions_remaining,
|
|
12
|
+
spend_reaction, advance_reaction_round, set_reaction_round,
|
|
13
|
+
prune_stale_spends, clear_reactions, reaction_ledger_snapshot,
|
|
14
|
+
RESOURCE_REACTION_ECONOMY,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PASS = 0
|
|
18
|
+
FAIL = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ck(label, cond):
|
|
22
|
+
global PASS, FAIL
|
|
23
|
+
if cond:
|
|
24
|
+
PASS += 1
|
|
25
|
+
print(" OK " + label)
|
|
26
|
+
else:
|
|
27
|
+
FAIL += 1
|
|
28
|
+
print(" FAIL " + label)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ck("constants", REACTIONS_PER_ROUND == 1 and RESOURCE_REACTION_ECONOMY == "reactionEconomy")
|
|
32
|
+
|
|
33
|
+
l = create_reaction_ledger()
|
|
34
|
+
ck("fresh round 1", l.round == 1)
|
|
35
|
+
ck("fresh can_react", can_react(l, "pc") is True)
|
|
36
|
+
ck("reactions_remaining 1", reactions_remaining(l, "pc") == 1)
|
|
37
|
+
ck("spend once True", spend_reaction(l, "pc") is True)
|
|
38
|
+
ck("can_react False after spend", can_react(l, "pc") is False)
|
|
39
|
+
ck("reactions_remaining 0", reactions_remaining(l, "pc") == 0)
|
|
40
|
+
ck("spend twice False (ceiling)", spend_reaction(l, "pc") is False)
|
|
41
|
+
ck("foe independent", can_react(l, "goblin") is True)
|
|
42
|
+
ck("spend foe", spend_reaction(l, "goblin") is True)
|
|
43
|
+
|
|
44
|
+
ck("advance -> round 2", advance_reaction_round(l) == 2)
|
|
45
|
+
ck("refresh pc", can_react(l, "pc") is True)
|
|
46
|
+
ck("refresh goblin", can_react(l, "goblin") is True)
|
|
47
|
+
ck("spend again round 2", spend_reaction(l, "pc") is True)
|
|
48
|
+
|
|
49
|
+
l2 = create_reaction_ledger()
|
|
50
|
+
spend_reaction(l2, "pc") # round 1
|
|
51
|
+
advance_reaction_round(l2) # round 2; round-1 record stale
|
|
52
|
+
ck("stale record inert", can_react(l2, "pc") is True)
|
|
53
|
+
|
|
54
|
+
ck("empty id no-op", spend_reaction(l2, "") is False and can_react(l2, "") is False)
|
|
55
|
+
|
|
56
|
+
l3 = create_reaction_ledger()
|
|
57
|
+
spend_reaction(l3, "pc")
|
|
58
|
+
spend_reaction(l3, "goblin")
|
|
59
|
+
set_reaction_round(l3, 3)
|
|
60
|
+
set_reaction_round(l3, -5) # ignored
|
|
61
|
+
ck("set_reaction_round", l3.round == 3)
|
|
62
|
+
ck("pc free after round set", can_react(l3, "pc") is True)
|
|
63
|
+
ck("prune removes 2 stale", prune_stale_spends(l3) == 2)
|
|
64
|
+
ck("snapshot empty after prune", len(reaction_ledger_snapshot(l3)["spent"]) == 0)
|
|
65
|
+
spend_reaction(l3, "pc")
|
|
66
|
+
snap = reaction_ledger_snapshot(l3)
|
|
67
|
+
ck("snapshot shape", snap["round"] == 3 and snap["spent"] == [{"entity_id": "pc", "round": 3}])
|
|
68
|
+
clear_reactions(l3)
|
|
69
|
+
ck("clear empties", len(reaction_ledger_snapshot(l3)["spent"]) == 0)
|
|
70
|
+
|
|
71
|
+
print("\npassed=%d failed=%d" % (PASS, FAIL))
|
|
72
|
+
sys.exit(1 if FAIL else 0)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Parity tests for loom_engine.ruleset (mirror tests/ruleset.test.ts).
|
|
2
|
+
|
|
3
|
+
Run: python python/tests/test_ruleset.py
|
|
4
|
+
"""
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
9
|
+
|
|
10
|
+
from loom_engine.ruleset import ( # noqa: E402
|
|
11
|
+
start_turn_budget, can_spend, spend, initiative_order,
|
|
12
|
+
create_condition_track, apply_condition, remove_condition, has_condition,
|
|
13
|
+
condition_remaining, tick_conditions, active_conditions,
|
|
14
|
+
DURATION_UNTIL_REMOVED, RESOURCE_RULESET,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
PASS = 0
|
|
18
|
+
FAIL = 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ck(label, cond):
|
|
22
|
+
global PASS, FAIL
|
|
23
|
+
if cond:
|
|
24
|
+
PASS += 1
|
|
25
|
+
print(" OK " + label)
|
|
26
|
+
else:
|
|
27
|
+
FAIL += 1
|
|
28
|
+
print(" FAIL " + label)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
ck("RESOURCE key", RESOURCE_RULESET == "ruleset")
|
|
32
|
+
|
|
33
|
+
b5 = start_turn_budget("5e")
|
|
34
|
+
ck("5e budget", b5["resources"] == {"action": 1, "bonus": 1, "reaction": 1})
|
|
35
|
+
ck("5e spend action", spend(b5, "action") is True)
|
|
36
|
+
ck("5e action exhausted", spend(b5, "action") is False)
|
|
37
|
+
ck("5e bonus ok", spend(b5, "bonus") is True)
|
|
38
|
+
|
|
39
|
+
bp = start_turn_budget("pf2e")
|
|
40
|
+
ck("pf2e budget", bp["resources"] == {"action": 3, "reaction": 1})
|
|
41
|
+
ck("pf2e spend 1", spend(bp, "action") is True)
|
|
42
|
+
ck("pf2e spend 2 more", spend(bp, "action", 2) is True)
|
|
43
|
+
ck("pf2e 0 left", spend(bp, "action") is False)
|
|
44
|
+
ck("pf2e reaction", spend(bp, "reaction") is True and spend(bp, "reaction") is False)
|
|
45
|
+
|
|
46
|
+
order = initiative_order([
|
|
47
|
+
{"id": "c", "total": 18, "modifier": 2, "d20": 16},
|
|
48
|
+
{"id": "a", "total": 18, "modifier": 5, "d20": 13},
|
|
49
|
+
{"id": "b", "total": 12, "modifier": 1, "d20": 11},
|
|
50
|
+
{"id": "d", "total": 18, "modifier": 2, "d20": 16},
|
|
51
|
+
])
|
|
52
|
+
ck("initiative tiebreak total>mod>d20>id", [e["id"] for e in order] == ["a", "c", "d", "b"])
|
|
53
|
+
inp = [{"id": "x", "total": 5}, {"id": "y", "total": 9}]
|
|
54
|
+
out = initiative_order(inp)
|
|
55
|
+
ck("initiative no-mutate", inp[0]["id"] == "x" and [e["id"] for e in out] == ["y", "x"])
|
|
56
|
+
|
|
57
|
+
t = create_condition_track()
|
|
58
|
+
apply_condition(t, "frightened", 3)
|
|
59
|
+
apply_condition(t, "prone")
|
|
60
|
+
ck("has frightened", has_condition(t, "frightened") is True)
|
|
61
|
+
ck("remaining 3", condition_remaining(t, "frightened") == 3)
|
|
62
|
+
ck("prone until removed", condition_remaining(t, "prone") == DURATION_UNTIL_REMOVED)
|
|
63
|
+
ck("absent -> 0", condition_remaining(t, "poisoned") == 0)
|
|
64
|
+
ck("remove prone", remove_condition(t, "prone") is True and has_condition(t, "prone") is False)
|
|
65
|
+
|
|
66
|
+
t2 = create_condition_track()
|
|
67
|
+
apply_condition(t2, "frightened", 2)
|
|
68
|
+
apply_condition(t2, "slowed", 1)
|
|
69
|
+
apply_condition(t2, "doomed")
|
|
70
|
+
ck("tick expires slowed", tick_conditions(t2) == ["slowed"])
|
|
71
|
+
ck("frightened decremented", condition_remaining(t2, "frightened") == 1)
|
|
72
|
+
ck("active after tick", sorted(active_conditions(t2)) == ["doomed", "frightened"])
|
|
73
|
+
ck("tick expires frightened", tick_conditions(t2) == ["frightened"])
|
|
74
|
+
ck("until-removed survives", active_conditions(t2) == ["doomed"])
|
|
75
|
+
|
|
76
|
+
print("\npassed=%d failed=%d" % (PASS, FAIL))
|
|
77
|
+
sys.exit(1 if FAIL else 0)
|