loom-engine-rpg 2.3.0__py3-none-any.whl
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/__init__.py +39 -0
- loom_engine/narration_contract.py +103 -0
- loom_engine/py.typed +0 -0
- loom_engine/range_bands.py +128 -0
- loom_engine/reaction_economy.py +85 -0
- loom_engine/ruleset.py +170 -0
- loom_engine_rpg-2.3.0.dist-info/METADATA +52 -0
- loom_engine_rpg-2.3.0.dist-info/RECORD +10 -0
- loom_engine_rpg-2.3.0.dist-info/WHEEL +5 -0
- loom_engine_rpg-2.3.0.dist-info/top_level.txt +1 -0
loom_engine/__init__.py
ADDED
|
@@ -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"
|
loom_engine/py.typed
ADDED
|
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"
|
loom_engine/ruleset.py
ADDED
|
@@ -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,10 @@
|
|
|
1
|
+
loom_engine/__init__.py,sha256=-MyEtMTBgIS9ACMtNZVhF6nwUpMkTqK6xPDVRlX28Cg,1972
|
|
2
|
+
loom_engine/narration_contract.py,sha256=enD1U02eFXqOntCxYlXhs4DRj977XQIJRBK64y7ybQ4,3608
|
|
3
|
+
loom_engine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
loom_engine/range_bands.py,sha256=_62e2_ZmU-urE49fkp8fgpz8s7nk2FOzh4MTMrfCcko,4496
|
|
5
|
+
loom_engine/reaction_economy.py,sha256=WMwJgCJDT91aEbCwHf_Us-NHSiVds7EvtX8VksEa-aE,3029
|
|
6
|
+
loom_engine/ruleset.py,sha256=EPhfSS22saOUcqY0l8zxyCACGQIF3i51yiciVBOUmzg,6017
|
|
7
|
+
loom_engine_rpg-2.3.0.dist-info/METADATA,sha256=VE3YZsQ7c0yMWmqwrexe3oyKX0WkOpGrcuYt8yrcuCA,2465
|
|
8
|
+
loom_engine_rpg-2.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
loom_engine_rpg-2.3.0.dist-info/top_level.txt,sha256=BoKxcCuIL36tXd6FJOI8NtTv6xgsbHP_zd2rUJTocxw,12
|
|
10
|
+
loom_engine_rpg-2.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loom_engine
|