Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests with pytest
run: python -m pytest tests/ -v --tb=short

code-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Check code formatting and linting with Ruff
run: |
ruff check src/ tests/
ruff format --check src/ tests/

- name: Type checking with mypy
run: mypy src/

install-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Test package installation
run: |
python -m pip install --upgrade pip
pip install .

- name: Test basic import
run: python -c "import grimoire; print('Import successful')"
24 changes: 24 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Release Please

on:
push:
branches:
- main

permissions:
contents: write
pull-requests: write

jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
55 changes: 55 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Release

on:
release:
types: [published]

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"

- name: Run tests
run: python -m pytest tests/ -v

build-and-publish:
needs: test
runs-on: ubuntu-latest
environment: release
permissions:
Comment thread
wyrdbound marked this conversation as resolved.
contents: read
id-token: write # required for PyPI trusted publishing

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build

- name: Build package
run: python -m build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# GRIMOIRE

**GRIMOIRE** (Generic Rule Implementation Model for Omniversal Interactive Roleplaying Engines) is a Python library for loading, validating, and working with structured tabletop RPG system definitions.

It provides a declarative YAML-based specification format for encoding game systems — covering models, flows, tables, compendiums, prompts, and sources — and a Python loader that parses and validates those definitions into typed Python objects.

GRIMOIRE is system-agnostic and not tied to any particular game or application. It is designed to be used as a foundation for tools that need to reason about RPG rules programmatically: AI game masters, character generators, rule validators, or any application that needs to load and execute structured game logic.

---

## Installation

To add to a uv-managed project:

```bash
uv add grimoire-spec
```

Or to install directly into an environment:

```bash
uv pip install grimoire-spec
```

## Quick Start

```python
from pathlib import Path
from grimoire.loader import SystemLoader

loader = SystemLoader()
system = loader.load(Path("systems/knave-1e"))

print(system.name) # "Knave (1st Edition)"
print(len(system.models)) # number of loaded models
print(len(system.flows)) # number of loaded flows

errors = system.validate()
if not errors:
print("System is valid")
```

See the [`examples/`](examples/) directory for more usage patterns.

## System Definition Format

A GRIMOIRE system is a directory containing YAML files organised by type:

```
my-system/
system.yaml # root metadata, currency, attribution
models/ # data model definitions
flows/ # rule sequences and game mechanics
tables/ # random tables
compendiums/ # item/entity catalogues
prompts/ # AI prompt templates
sources/ # source material attribution
```

Full specification for each file type is in [`spec/`](spec/).

## Development

This project uses [`uv`](https://github.com/astral-sh/uv) for environment and dependency management.

### Setup

```bash
uv sync --extra dev
```

### Run tests

```bash
uv run pytest
```

### Linting and formatting

```bash
uv run ruff check src/ tests/
uv run ruff format src/ tests/
```

### Type checking

```bash
uv run mypy src/
```

### Run an example

```bash
uv run python examples/load_system.py
```

## Project Structure

```
src/grimoire/ # library source
loader.py # SystemLoader — entry point for loading a system directory
models/ # typed Python models for each definition type
spec/ # YAML format specification documents
systems/ # bundled example systems (knave-1e, wyrdbound-quickstart-1e)
examples/ # usage examples
tests/ # test suite
```

## Contributing

Contributions are welcome. Please follow the existing code style (enforced by Ruff) and ensure all tests pass before submitting a pull request. New features should be accompanied by tests and an example in `examples/`.

## License

See [LICENSE](LICENSE) for details.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "grimoire"
version = "0.1.0"
name = "grimoire-spec"
version = "0.0.0"
description = "GRIMOIRE system definition loader and validator for tabletop RPG systems"
requires-python = ">=3.11"
dependencies = [
Expand Down
11 changes: 11 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "python",
"packages": {
".": {
"release-type": "python",
"package-name": "grimoire-spec",
"version-file": "pyproject.toml"
}
}
}
7 changes: 5 additions & 2 deletions src/grimoire/models/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ def get_compendium(self, compendium_id: str) -> CompendiumDefinition | None:
"""Get a compendium definition by ID."""
return self.compendiums.get(compendium_id)

def find_entry(self, entry_id: str) -> dict | None:
"""Search all compendiums for an entry by ID. Returns first match with metadata."""
def find_entry(self, entry_id: str) -> dict[str, object] | None:
"""Search all compendiums for an entry by ID.

Returns first match with metadata.
"""
for comp in self.compendiums.values():
entry = comp.get_entry(entry_id)
if entry is not None:
Expand Down
24 changes: 13 additions & 11 deletions tests/test_system_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from grimoire.models.system import System

SYSTEMS_DIR = Path(__file__).parent.parent / "systems"
KNAVE_DIR = SYSTEMS_DIR / "knave_1e"
KNAVE_DIR = SYSTEMS_DIR / "knave-1e"
WYRDBOUND_DIR = SYSTEMS_DIR / "wyrdbound-quickstart-1e"


Expand Down Expand Up @@ -88,8 +88,8 @@ def test_knave_character_model_name(self, knave: System) -> None:
assert knave.models["character"].name == "Knave"

def test_wyrdbound_loads_all_models(self, wyrdbound: System) -> None:
# 5 model YAML files in systems/wyrdbound-quickstart-1e/models/
assert len(wyrdbound.models) == 5
# 7 model YAML files in systems/wyrdbound-quickstart-1e/models/
assert len(wyrdbound.models) == 7

def test_wyrdbound_has_character_model(self, wyrdbound: System) -> None:
assert "character" in wyrdbound.models
Expand All @@ -114,8 +114,9 @@ def test_knave_armor_table_kind(self, knave: System) -> None:
def test_knave_armor_table_has_entries(self, knave: System) -> None:
assert len(knave.tables["armor"].entries) > 0

def test_wyrdbound_has_no_tables(self, wyrdbound: System) -> None:
assert len(wyrdbound.tables) == 0
def test_wyrdbound_loads_all_tables(self, wyrdbound: System) -> None:
# 14 table YAML files in systems/wyrdbound-quickstart-1e/tables/ (recursive)
assert len(wyrdbound.tables) == 14


# ---------------------------------------------------------------------------
Expand All @@ -140,8 +141,8 @@ def test_knave_melee_compendium_entries_are_dict(self, knave: System) -> None:
assert "dagger" in entries

def test_wyrdbound_loads_all_compendiums(self, wyrdbound: System) -> None:
# 4 compendium YAML files in systems/wyrdbound-quickstart-1e/compendiums/
assert len(wyrdbound.compendiums) == 4
# 5 compendium YAML files in systems/wyrdbound-quickstart-1e/compendiums/
assert len(wyrdbound.compendiums) == 5

def test_wyrdbound_weapons_compendium_entries_keyed_by_id(
self, wyrdbound: System
Expand Down Expand Up @@ -169,8 +170,8 @@ def test_knave_character_creation_flow_has_steps(self, knave: System) -> None:
assert len(knave.flows["character_creation"].steps) > 0

def test_wyrdbound_loads_all_flows(self, wyrdbound: System) -> None:
# 3 flow YAML files in systems/wyrdbound-quickstart-1e/flows/ (recursive)
assert len(wyrdbound.flows) == 3
# 6 flow YAML files in systems/wyrdbound-quickstart-1e/flows/ (recursive)
assert len(wyrdbound.flows) == 6

def test_wyrdbound_has_character_creation_flow(self, wyrdbound: System) -> None:
assert "character_creation" in wyrdbound.flows
Expand All @@ -192,8 +193,9 @@ def test_knave_has_fix_json_prompt(self, knave: System) -> None:
def test_knave_fix_json_prompt_has_template(self, knave: System) -> None:
assert knave.prompts["fix_json"].prompt_template

def test_wyrdbound_has_no_prompts(self, wyrdbound: System) -> None:
assert len(wyrdbound.prompts) == 0
def test_wyrdbound_loads_all_prompts(self, wyrdbound: System) -> None:
# 1 prompt YAML file in systems/wyrdbound-quickstart-1e/prompts/
assert len(wyrdbound.prompts) == 1


# ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading