mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-24 22:16:31 +01:00
feat: add RandomService, seed diagnostics endpoint, and random mode docs (#59)
Some checks are pending
CI / build (push) Waiting to run
Some checks are pending
CI / build (push) Waiting to run
This commit is contained in:
parent
7e5a29dd74
commit
4aa41adb20
10 changed files with 697 additions and 4 deletions
185
docs/random_mode/developer_guide.md
Normal file
185
docs/random_mode/developer_guide.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# Random Mode Developer Guide
|
||||
|
||||
**Updated**: 2026-03-20
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers how to use the seeded RNG infrastructure in new code, how to wire seeds through routes and services, and how to test deterministic builds.
|
||||
|
||||
See [seed_infrastructure.md](seed_infrastructure.md) for the API reference.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Reproduce a build from a seed
|
||||
|
||||
```python
|
||||
from code.random_util import set_seed
|
||||
|
||||
rng = set_seed(12345)
|
||||
value = rng.choice(["a", "b", "c"]) # Always the same for seed 12345
|
||||
```
|
||||
|
||||
### Generate a fresh seed
|
||||
|
||||
```python
|
||||
from code.random_util import generate_seed
|
||||
|
||||
seed = generate_seed() # High-entropy 63-bit int
|
||||
```
|
||||
|
||||
### Use the service (preferred in route handlers)
|
||||
|
||||
```python
|
||||
from code.web.services.random_service import RandomService
|
||||
|
||||
service = RandomService()
|
||||
rng = service.create_rng(seed=12345)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### 1. Seeded random build (headless / CLI)
|
||||
|
||||
```python
|
||||
from code.deck_builder.random_entrypoint import build_random_full_deck
|
||||
|
||||
result = build_random_full_deck(
|
||||
seed=12345,
|
||||
theme="dragons",
|
||||
color_identity=["R", "G"],
|
||||
# ...
|
||||
)
|
||||
print(result.seed) # 12345
|
||||
```
|
||||
|
||||
### 2. Web route handler with seed from request
|
||||
|
||||
```python
|
||||
# code/web/app.py or a route file
|
||||
from code.web.services.random_service import RandomService
|
||||
|
||||
async def my_random_route(request: Request):
|
||||
body = await request.json()
|
||||
raw_seed = body.get("seed") # int, str, or None
|
||||
|
||||
service = RandomService()
|
||||
service.validate_seed(raw_seed) # raises InvalidSeedError on bad input
|
||||
seed = service.derive_seed(raw_seed) if raw_seed is not None else service.generate_seed()
|
||||
|
||||
rng = service.create_rng(seed)
|
||||
# ... pass rng or seed into build function
|
||||
```
|
||||
|
||||
### 3. Builder-level seeding
|
||||
|
||||
```python
|
||||
from code.deck_builder.builder import DeckBuilder
|
||||
|
||||
builder = DeckBuilder(...)
|
||||
builder.set_seed(99999) # builder.rng is now seeded
|
||||
|
||||
cards = builder.rng.sample(card_pool, 10) # Reproducible sample
|
||||
```
|
||||
|
||||
### 4. Reroll (increment seed)
|
||||
|
||||
```python
|
||||
old_seed = session.get("random_seed")
|
||||
new_seed = (old_seed + 1) & ((1 << 63) - 1) # Stay in 63-bit range
|
||||
session["random_seed"] = new_seed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seed Validation Rules
|
||||
|
||||
| Input | Valid? | Notes |
|
||||
|-------|--------|-------|
|
||||
| `42` | Yes | Non-negative int |
|
||||
| `-1` | No | Negative int → `InvalidSeedError` |
|
||||
| `"dragons"` | Yes | String → SHA-256 derivation |
|
||||
| `""` | No | Empty string → `InvalidSeedError` |
|
||||
| `None` | Yes | Triggers auto-generation |
|
||||
| `[]` or `{}` | No | Wrong type → `InvalidSeedError` |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```python
|
||||
from code.exceptions import InvalidSeedError
|
||||
from code.web.services.random_service import RandomService
|
||||
|
||||
service = RandomService()
|
||||
try:
|
||||
service.validate_seed(user_input)
|
||||
except InvalidSeedError as e:
|
||||
return {"error": str(e), "code": e.code}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Deterministic Code
|
||||
|
||||
### Assert two builds produce the same result
|
||||
|
||||
```python
|
||||
def test_build_is_deterministic():
|
||||
result1 = build_random_full_deck(seed=42, ...)
|
||||
result2 = build_random_full_deck(seed=42, ...)
|
||||
assert result1.commander == result2.commander
|
||||
```
|
||||
|
||||
### Assert different seeds produce different results (probabilistic)
|
||||
|
||||
```python
|
||||
def test_different_seeds_differ():
|
||||
result1 = build_random_full_deck(seed=1, ...)
|
||||
result2 = build_random_full_deck(seed=2, ...)
|
||||
# Not guaranteed, but highly likely for large pools
|
||||
assert result1.commander != result2.commander or result1.theme != result2.theme
|
||||
```
|
||||
|
||||
### Test seed derivation stability
|
||||
|
||||
```python
|
||||
from code.random_util import derive_seed_from_string
|
||||
|
||||
def test_string_seed_stable():
|
||||
s1 = derive_seed_from_string("test")
|
||||
s2 = derive_seed_from_string("test")
|
||||
assert s1 == s2
|
||||
|
||||
def test_int_and_string_differ():
|
||||
assert derive_seed_from_string(0) != derive_seed_from_string("0")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Diagnostics
|
||||
|
||||
When `WEB_RANDOM_DIAGNOSTICS=1` is set, the endpoint `/api/random/diagnostics` returns seed derivation test vectors and algorithm metadata. Useful for verifying cross-platform consistency.
|
||||
|
||||
```bash
|
||||
WEB_RANDOM_DIAGNOSTICS=1 curl http://localhost:5000/api/random/diagnostics
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `code/random_util.py` | Core RNG utilities |
|
||||
| `code/web/services/random_service.py` | Service wrapper with validation |
|
||||
| `code/exceptions.py` | `InvalidSeedError` |
|
||||
| `code/deck_builder/random_entrypoint.py` | `build_random_deck`, `build_random_full_deck` |
|
||||
| `code/deck_builder/builder.py` | `DeckBuilder.seed` / `DeckBuilder.rng` |
|
||||
| `docs/random_mode/seed_infrastructure.md` | API reference |
|
||||
| `docs/random_mode/diagnostics.md` | Diagnostics endpoint reference |
|
||||
70
docs/random_mode/diagnostics.md
Normal file
70
docs/random_mode/diagnostics.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Random Mode Diagnostics
|
||||
|
||||
**Endpoint**: `GET /api/random/diagnostics`
|
||||
**Feature flag**: `WEB_RANDOM_DIAGNOSTICS=1`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The diagnostics endpoint exposes seed derivation test vectors and algorithm metadata. It is intended for internal tooling and cross-platform consistency checks — verifying that seed derivation produces identical results across environments, Python versions, or deployments.
|
||||
|
||||
The endpoint returns **404** unless `WEB_RANDOM_DIAGNOSTICS=1` is set in the environment.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
WEB_RANDOM_DIAGNOSTICS=1 curl http://localhost:5000/api/random/diagnostics
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"test_vectors": {
|
||||
"test-seed": 6214070892065607348,
|
||||
"12345": 12345,
|
||||
"zero": 0,
|
||||
"empty-string-rejected": "N/A (empty string raises InvalidSeedError)"
|
||||
},
|
||||
"seed_algorithm": "sha256-63bit",
|
||||
"version": "1.0",
|
||||
"request_id": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field Reference
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `test_vectors` | object | Known-input → expected-output pairs for manual verification |
|
||||
| `seed_algorithm` | string | Algorithm identifier (`sha256-63bit`) |
|
||||
| `version` | string | Diagnostics schema version |
|
||||
| `request_id` | string \| null | Request tracing ID |
|
||||
|
||||
---
|
||||
|
||||
## Seed Algorithm Details
|
||||
|
||||
String seeds are processed as:
|
||||
1. UTF-8 encode the input string
|
||||
2. SHA-256 hash the bytes
|
||||
3. Take the first 8 bytes as a big-endian unsigned integer
|
||||
4. Mask to 63 bits: `n & ((1 << 63) - 1)`
|
||||
|
||||
Integer seeds are normalised as: `abs(n) & ((1 << 63) - 1)`.
|
||||
|
||||
This ensures all seeds are non-negative, platform-independent, and fit within Python's `random.Random.seed()` expectations.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [seed_infrastructure.md](seed_infrastructure.md) — API reference
|
||||
- [developer_guide.md](developer_guide.md) — Integration guide
|
||||
- `code/web/services/random_service.py` — `RandomService.derive_seed()`
|
||||
- `code/random_util.py` — `derive_seed_from_string()`
|
||||
121
docs/random_mode/seed_infrastructure.md
Normal file
121
docs/random_mode/seed_infrastructure.md
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# Seed Infrastructure
|
||||
|
||||
**Module**: `code/random_util.py`
|
||||
**Updated**: 2026-03-20
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Random Mode builds use a deterministic, seeded RNG so that any build can be exactly reproduced from its seed value. Every random operation flows through isolated `random.Random` instances — the module-level PRNG is never mutated.
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
|
||||
### `code/random_util.py`
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `derive_seed_from_string` | `(seed: int \| str) -> int` | Stable 63-bit seed from int or string (SHA-256 for strings) |
|
||||
| `set_seed` | `(seed: int \| str) -> random.Random` | Create a seeded `Random` instance |
|
||||
| `get_random` | `(seed: int \| str \| None) -> random.Random` | Convenience wrapper; unseeded when `None` |
|
||||
| `generate_seed` | `() -> int` | High-entropy 63-bit seed via `secrets.randbits` |
|
||||
|
||||
### `code/web/services/random_service.py`
|
||||
|
||||
Thin service wrapper following the R9 `BaseService` pattern. Adds input validation and a standardised interface for route handlers.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `derive_seed(seed)` | Validated seed derivation (raises `InvalidSeedError` on bad input) |
|
||||
| `create_rng(seed)` | Return seeded or unseeded `random.Random` |
|
||||
| `generate_seed()` | Delegates to `random_util.generate_seed()` |
|
||||
| `validate_seed(seed)` | Validates type and range; raises `InvalidSeedError` |
|
||||
|
||||
---
|
||||
|
||||
## Seed Types
|
||||
|
||||
### Integer seeds
|
||||
- Must be non-negative
|
||||
- Normalised to 63-bit via `abs(n) & ((1 << 63) - 1)`
|
||||
- Zero is a valid, deterministic seed
|
||||
|
||||
### String seeds
|
||||
- Encoded as UTF-8 bytes
|
||||
- Hashed with SHA-256; first 8 bytes taken as big-endian unsigned int
|
||||
- Masked to 63 bits for consistency with int path
|
||||
- Empty string is valid (produces a fixed deterministic seed)
|
||||
|
||||
### None (auto-seed)
|
||||
- `get_random(None)` returns an unseeded `random.Random()`
|
||||
- `generate_seed()` returns a fresh `secrets.randbits(63)` value each call
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### DeckBuilder
|
||||
```python
|
||||
# code/deck_builder/builder.py
|
||||
builder = DeckBuilder(...)
|
||||
builder.set_seed(12345) # Sets builder.seed and recreates builder.rng
|
||||
rng = builder.rng # Seeded random.Random instance
|
||||
```
|
||||
|
||||
### Random entrypoint
|
||||
```python
|
||||
# code/deck_builder/random_entrypoint.py
|
||||
result = build_random_full_deck(
|
||||
seed=12345,
|
||||
theme="dragons",
|
||||
# ...
|
||||
)
|
||||
# result.seed == 12345 (or auto-generated if None was passed)
|
||||
```
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
python code/headless_runner.py --random-seed 12345
|
||||
# or via environment variable
|
||||
RANDOM_SEED=12345 python code/headless_runner.py
|
||||
# or in deck.json config
|
||||
{ "random_seed": 12345 }
|
||||
```
|
||||
Seed resolution order: `--random-seed` CLI arg > `RANDOM_SEED` env var > `random_seed` in JSON config.
|
||||
|
||||
### Web UI
|
||||
- Seed input field on the Random Build page
|
||||
- Seed persisted in session across rerolls
|
||||
- Reroll increments the seed by 1 for deterministic variation (`seed + 1`)
|
||||
- Favorite seeds stored in session for quick reuse
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|----------|-----------|
|
||||
| Negative int seed | Normalised via `abs()` before masking |
|
||||
| `MAX_INT` seed | Masked to 63 bits — stays valid |
|
||||
| Empty string `""` | Valid; SHA-256 hash of empty bytes used |
|
||||
| String with special chars / Unicode | UTF-8 encoded; `errors="strict"`, fallback to `errors="ignore"` |
|
||||
| `None` seed | Unseeded `random.Random()` — non-deterministic |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `code/tests/test_random_util.py` | Core function contracts |
|
||||
| `code/tests/test_random_determinism_comprehensive.py` | End-to-end determinism validation |
|
||||
| `code/tests/test_random_features_comprehensive.py` | Random mode feature integration |
|
||||
| `code/tests/test_random_api_comprehensive.py` | API-level behaviour |
|
||||
| `code/tests/test_random_service.py` | `RandomService` unit tests |
|
||||
|
||||
Run the fast subset:
|
||||
```powershell
|
||||
.venv/Scripts/python.exe -m pytest -q code/tests/test_random_util.py code/tests/test_random_service.py
|
||||
```
|
||||
Loading…
Add table
Add a link
Reference in a new issue