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.
@@ -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,3 @@
1
+
2
+ [test]
3
+ pytest>=7.0.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)