Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: repository-gates

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Compile source
run: python -m compileall -q engine render
- name: Engine tests
working-directory: engine
run: python -m unittest discover -v -p 'test_*.py'
- name: Table tests
working-directory: render
env:
PYTHONPATH: ../engine
POLITICS_BOTS: null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Quote the null bot selector

In YAML, this unquoted value is a null scalar rather than the literal string "null", while Table selects NullPlayer only when os.environ.get("POLITICS_BOTS") == "null". Consequently the table job either receives an empty value and runs the noisy-bot branch or fails workflow validation, so it does not exercise the intended deterministic null-bot context.

Useful? React with 👍 / 👎.

run: python -m unittest discover -v -p 'test_*.py'
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.pytest_cache/
.coverage
htmlcov/
.venv/
venv/
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ guarantees total conversion absent human play) / L3 TABLE (players; the
only source of repair in the universe).

- canon/ base canon v0.3 + Scared Sacred expansion canon v0.2
- base-game/ play rules v0.3 (consolidated) + arcana v0.2
- expansions/scared-sacred/ Expansion Set 1 (fundamentalism block)
- base-game/ play rules v0.3, arcana v0.2, executable engine data, and sets/fifty-three-days/
- expansions/scared-sacred/ Expansion Set 1 boundary; its distinctive expansion content remains separate from Fifty-Three Days
- engine/ executable rules, Machine, inertial field, agendas/arcana, and balance harness
- render/ playable browser table over the real engine
- funding/ GoFundMe entry
- .agents/skills/ vendored from skill-lib (if present)

Expand All @@ -18,6 +20,6 @@ VIRTUAL ONLY — no physical printing of any set, ever.
The Litany (never satirical):
I'm sorry. I forgive you. You are not alone. I love you.

Status: pre-playtest. All balance numbers conjectural.
Status: executable base ruleset and automated balance harness are implemented and test-backed; human playtest and store/mobile delivery remain pending. Some explicitly logged card effects remain unresolved and must not be represented as complete.

hmmm — fear and the holy are the same six letters.
hmmm — fear and the holy are the same six letters; the base game and its first expansion now also have separate addresses.
Binary file removed engine/__pycache__/arcana_agendas_v1.cpython-312.pyc
Binary file not shown.
Binary file removed engine/__pycache__/arcana_agendas_v1.cpython-314.pyc
Binary file not shown.
Binary file removed engine/__pycache__/cards_v1.cpython-314.pyc
Binary file not shown.
Binary file removed engine/__pycache__/inertial_engine.cpython-314.pyc
Binary file not shown.
Binary file removed engine/__pycache__/politics_runner.cpython-314.pyc
Binary file not shown.
Binary file removed engine/__pycache__/rules_v1.cpython-314.pyc
Binary file not shown.
Binary file removed engine/__pycache__/weimar_data.cpython-312.pyc
Binary file not shown.
Binary file removed engine/__pycache__/weimar_data.cpython-314.pyc
Binary file not shown.
42 changes: 29 additions & 13 deletions engine/arcana_agendas_v1.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ratios: loc_comments=119:51 imports_exports=2:3 calls_definitions=45:5
# ratios: loc_comments=130:54 imports_exports=2:4 calls_definitions=54:6
"""arcana_agendas_v1 — secret agendas and the nine trumps. Build step 5c.

AGENDAS: dealt one per player at setup, secret, verified at match end.
Expand All @@ -11,11 +11,12 @@

Usage Guidance
--------------
from arcana_agendas_v1 import (deal_agendas, verify_agendas,
ARCANA, ArcanaModule)
deal_agendas(state, players=3, rng)
from arcana_agendas_v1 import (deal_agendas, deal_arcana,
verify_agendas, ARCANA, ArcanaModule)
deal_agendas(state, players=3, rng=rng)
deal_arcana(state, players=3, rng=rng)
runner = MatchRunner(..., arcana=ArcanaModule())
plays = TurnPlays(arcana={"name": "THE WAYSEER"}) # from your deal
plays = TurnPlays(arcana=state.tallies["arcana"][pid])
result.agenda_outcomes # per-player verdicts at match end
Filibuster and the Witness Rule share the suspension primitive
(GameState.suspend_beats): the world stopping is sequence-level plumbing,
Expand All @@ -24,12 +25,13 @@
# === MODULE_BUILD ===
# id: arcana_agendas_v1
# purpose: non-alignment (secret agendas) and rule-class trumps
# surfaces: AGENDAS, deal_agendas, verify_agendas, ARCANA, ArcanaModule
# surfaces: AGENDAS, deal_agendas, deal_arcana, verify_agendas, ARCANA,
# ArcanaModule
# boundaries: no sequence (runner hooks), no field math (engine);
# arcana effects mutate state through declared primitives only
# tests: test_arcana_agendas.py
# rollout: step 5c; completes the ruleset
# rollback: omit arcana= param; skip deal_agendas
# rollback: omit arcana= param; skip deal_agendas/deal_arcana
# hmmm: FIRST-TIME VOTER's blind-draw-as-R deferred (SE shield only);
# AUDIT compatibility is a coarse bool pending richer agenda algebra
# === END MODULE_BUILD ===
Expand All @@ -39,8 +41,9 @@
# behavior: every dealt agenda is verified at match end against state
# and log; ashes-mode verifies on loss as well as win
# id: agendas_organizer_counts_r
# behavior: the organizer agenda holds only for the player with the
# strictly greatest attributed r tally
# behavior: organizer uses resolved attributed R, not action count
# id: arcana_dealt_ownership
# behavior: a player may resolve only the arcanum dealt to that seat
Comment on lines 43 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Regenerate the msdmd collection after contract changes

This commit adds and changes module-local contracts and checks, but the committed canonical collection point scared-sacred_msdmd.ts was not regenerated: it omits arcana_dealt_ownership, cards_se_shield_consumed, runner_attributed_r_log, the new renderer contracts and their checks, and still contains the previous Organizer behavior. Running the repository's msdmd.collect generator shows those missing declarations and edges, so collection consumers and visualizers receive stale contract coverage.

Useful? React with 👍 / 👎.

# id: arcana_once_per_game
# behavior: a player's second arcanum play never resolves
# id: arcana_wayseer_reveals
Expand Down Expand Up @@ -84,7 +87,8 @@ def verify_agendas(state, outcome):
laid_by_pid = {}
for ev in state.log:
if ev[0] == "action":
r_by_pid[ev[1]] = r_by_pid.get(ev[1], 0) + 1
resolved_r = ev[4] if len(ev) > 4 else 0
r_by_pid[ev[1]] = r_by_pid.get(ev[1], 0) + resolved_r
Comment on lines 89 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include reflex repair in Organizer totals

The newly playable reflex path contributes reflex_card["r"] to the engine but logs a reflex event rather than an action event (politics_runner.py:244-247). This verifier scans only actions, so a player holding THE ORGANIZER receives no credit for repair supplied through reflexes and can incorrectly lose the agenda despite having the greatest attributed R.

Useful? React with 👍 / 👎.

plays_by_pid.setdefault(ev[1], []).append(ev[2])
if ev[0] == "static":
laid_by_pid.setdefault(ev[1], []).append(ev[2])
Expand Down Expand Up @@ -126,16 +130,28 @@ def verify_agendas(state, outcome):
]


def deal_arcana(state, players, rng):
pool = list(ARCANA)
rng.shuffle(pool)
state.tallies["arcana"] = [dict(pool[i % len(pool)])
for i in range(players)]


class ArcanaModule:
"""Applies trumps. Public, attributed, once per player per game."""
"""Applies trumps. Public, attributed, dealt one per seat, once per game."""

def __init__(self):
self.used = set()

def apply(self, arcanum, state, machine, engine, pid, players):
name = arcanum.get("name")
dealt = state.tallies.get("arcana")
if dealt:
if pid >= len(dealt) or dealt[pid].get("name") != name:
state.log.append(("arcana_refused", pid, name, "not_dealt"))
return
if pid in self.used:
state.log.append(("arcana_refused", pid, name))
state.log.append(("arcana_refused", pid, name, "already_used"))
return
self.used.add(pid)
state.log.append(("arcana", pid, name))
Expand Down Expand Up @@ -187,4 +203,4 @@ def apply(self, arcanum, state, machine, engine, pid, players):
else -incompat)))
state.log.append(("audit", [a["name"] for a in ag]))

# ratios: loc_comments=119:51 imports_exports=2:3 calls_definitions=45:5
# ratios: loc_comments=130:54 imports_exports=2:4 calls_definitions=54:6
32 changes: 19 additions & 13 deletions engine/cards_export.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
# ratios: loc_comments=38:26 imports_exports=5:1 calls_definitions=23:2
"""cards_export — emit every card as a repo-resident JSON asset.
# ratios: loc_comments=40:28 imports_exports=5:1 calls_definitions=24:3
"""cards_export — emit every Fifty-Three Days card as a repo-resident JSON asset.

Single source of truth stays weimar_data.py / arcana_agendas_v1.py; this
exporter renders each card into its own file so art lands beside data:
grok's m07.png drops next to m07.json and the render layer needs no
lookup table. Regenerate after any data change; hand-editing the JSON is
the anti-pattern (the export overwrites without mercy).
exporter renders the POLITICS base set into its own namespace so art lands
beside data without occupying the Scared Sacred expansion boundary.
Grok's m07.png drops next to m07.json and the render layer needs no lookup
table. Regenerate after any data change; hand-editing the JSON is the
anti-pattern (the export overwrites without mercy).

Usage Guidance
--------------
python3 cards_export.py [repo_root] # default ..
Writes expansions/scared-sacred/cards/{setup,machine,reserve,response,
Writes base-game/sets/fifty-three-days/cards/{setup,machine,reserve,response,
arcana,agendas}/<id>.json plus index.json. Each card carries an "art"
block: {"file": "<id>.png", "status": "awaiting"} until validated art
exists beside it, then rerun flips status to "present". Prints counts;
a count mismatch against index.json is a bug, full stop.

# === MODULE_BUILD ===
# id: cards_export_v01
# purpose: cards as first-class repo files; art drop-in slots
# purpose: Fifty-Three Days cards as first-class repo files; art drop-in slots
# surfaces: export(root) -> index dict; __main__ CLI
# boundaries: read-only over data modules; overwrites cards/ tree
# boundaries: read-only over data modules; overwrites only the base-game
# Fifty-Three Days cards tree; never writes an expansion namespace
# tests: self-verifying counts on run (index vs files written)
# rollout: run per data change; committed output is canon-derived
# rollback: delete cards/ tree; data modules unaffected
# rollback: delete generated cards/ tree; data modules unaffected
# hmmm: art status flips by file presence, not by validation record --
# the validation ledger is a later, honest upgrade
# === END MODULE_BUILD ===
Expand All @@ -37,8 +39,12 @@
from arcana_agendas_v1 import AGENDAS, ARCANA


def _cards_root(root):
return os.path.join(root, "base-game", "sets", "fifty-three-days", "cards")


def _write(root, deck, ident, card):
d = os.path.join(root, "expansions", "scared-sacred", "cards", deck)
d = os.path.join(_cards_root(root), deck)
os.makedirs(d, exist_ok=True)
png = ident.lower() + ".png"
card = dict(card)
Expand All @@ -65,7 +71,7 @@ def export(root=".."):
index["cards"].append(_write(root, "arcana", f"a{c['num']:02d}", c))
for i, c in enumerate(AGENDAS):
index["cards"].append(_write(root, "agendas", f"g{i+1:02d}", c))
d = os.path.join(root, "expansions", "scared-sacred", "cards")
d = _cards_root(root)
with open(os.path.join(d, "index.json"), "w") as f:
json.dump(index, f, indent=1)
return index
Expand All @@ -74,4 +80,4 @@ def export(root=".."):
if __name__ == "__main__":
idx = export(sys.argv[1] if len(sys.argv) > 1 else "..")
print(f"exported {len(idx['cards'])} cards")
# ratios: loc_comments=38:26 imports_exports=5:1 calls_definitions=23:2
# ratios: loc_comments=40:28 imports_exports=5:1 calls_definitions=24:3
23 changes: 19 additions & 4 deletions engine/cards_v1.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ratios: loc_comments=160:65 imports_exports=4:4 calls_definitions=80:13
# ratios: loc_comments=171:68 imports_exports=4:4 calls_definitions=85:14
"""cards_v1 — secret effects, interaction links, the M15 hinge, deck law.

WeimarMachine plays the scripted set with its mechanics live: SE gating
Expand Down Expand Up @@ -44,6 +44,9 @@
# chain with delay_if_gated fires on the following beat instead
# id: cards_se_doubles_at_low_m
# behavior: at m <= 1 stacking effects apply twice
# id: cards_se_shield_consumed
# behavior: an active SE shield prevents one otherwise-resolving secret
# effect and is consumed exactly once, including delayed effects
# id: cards_destruction_feeds_prereqs
# behavior: destroying a machine card or static causes every later
# card with that prereq to be skipped
Expand Down Expand Up @@ -129,6 +132,14 @@ def _tick_timed(self, state):
if s["timed_beats"] <= 0:
s["timed_debuff"] = 0

def _consume_se_shield(self, state, card_id):
shield = state.tallies.get("se_shield", 0)
if shield <= 0:
return False
state.tallies["se_shield"] = shield - 1
state.log.append(("se_shielded", card_id))
return True

def _apply_effect(self, se, state, card):
kind = se.get("kind")
if kind in NOOP_EFFECTS:
Expand Down Expand Up @@ -171,6 +182,8 @@ def _resolve_se(self, card, state):
return
gate = se.get("a_se", 0)
if state.e >= gate:
if self._consume_se_shield(state, card["id"]):
return
times = 2 if (state.m <= 1 and se.get("kind") not in
{"chain"} | NOOP_EFFECTS) else 1
for _ in range(times):
Expand Down Expand Up @@ -203,10 +216,12 @@ def next_card(self, state):
for s in prey[:eaters]:
state.in_play_statics.remove(s)
state.log.append(("gleichgeschaltet", s.get("name")))
for se in self.delayed:
for se in list(self.delayed):
if state.e >= se.get("a_se", 0):
self._apply_effect(se, state, {"id": se["card_id"]})
self.delayed.remove(se)
if self._consume_se_shield(state, se["card_id"]):
continue
self._apply_effect(se, state, {"id": se["card_id"]})
while self.cursor < len(self.script):
card = self.script[self.cursor]
self.cursor += 1
Expand Down Expand Up @@ -249,4 +264,4 @@ def resolve_se(self, played, state, countered=False):
return
self._resolve_se(card, state)

# ratios: loc_comments=160:65 imports_exports=4:4 calls_definitions=80:13
# ratios: loc_comments=171:68 imports_exports=4:4 calls_definitions=85:14
27 changes: 21 additions & 6 deletions engine/politics_runner.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# ratios: loc_comments=193:103 imports_exports=3:8 calls_definitions=95:20
# ratios: loc_comments=203:108 imports_exports=3:8 calls_definitions=101:20
"""politics_runner — turn-sequence runner for POLITICS (base game).

The runner owns the ORDER of play and nothing else. All game truth lives
Expand Down Expand Up @@ -30,7 +30,9 @@
print(result.outcome, result.machine_beats, result.state.population)

Swap NullPlayer for any object with take_turn(state, pid) -> TurnPlays to
seat a human UI, an a0 agent, or an older-model playtester. Swap
seat a human UI, an a0 agent, or an older-model playtester. In hand mode,
discard_cards must contain the actual cards being burned; an integer
discard claim without cards does not buy tempo or reach. Swap
ScriptedMachine for a policy machine in later sets; Weimar ships scripted.
Integration: this module is build-order step 1; the engine here is the
step-2 stub already carrying the real leaky-integrator math so the null
Expand Down Expand Up @@ -81,6 +83,9 @@
# behavior: in hand mode, a play resolves only from the hand holding
# it; played and burned cards move to the discard pile; the turn ends
# with one draw while the pile lasts
# id: runner_attributed_r_log
# behavior: every resolved action log records the actual R contributed
# after habituation so agenda accounting can consume evidence
# id: runner_suspension_primitive
# behavior: while suspend_beats > 0 a machine beat consumes the
# suspension instead of a card: no card, no pmr, population unchanged
Expand Down Expand Up @@ -120,6 +125,7 @@ class TurnPlays:
static: dict | None = None
actions: list = field(default_factory=list)
discards: int = 0
discard_cards: list = field(default_factory=list)
arcana: dict | None = None


Expand Down Expand Up @@ -270,6 +276,14 @@ def _machine_beat(self):

def _player_beat(self, pid):
plays = self.players[pid].take_turn(self.state, pid)
if self.hand_mode:
paid = []
for card in list(getattr(plays, "discard_cards", [])):
if self._take_from_hand(pid, card):
paid.append(card)
self.state.log.append(("discard", pid, card.get("name")))
plays.discard_cards = paid
plays.discards = len(paid)
if hasattr(self.rules, "validate_turn"):
plays = self.rules.validate_turn(self.state, plays)
if self.arcana and getattr(plays, "arcana", None):
Expand All @@ -287,14 +301,15 @@ def _player_beat(self, pid):
for action in plays.actions:
if self.rules.legal(self.state, action):
if hasattr(self.rules, "effective_r"):
r_total += self.rules.effective_r(self.state, action)
resolved_r = self.rules.effective_r(self.state, action)
else:
r_total += action.get("r", 0)
resolved_r = action.get("r", 0)
r_total += resolved_r
n = self.state.tallies.get("played:" + action.get("name", ""), 0)
self.state.tallies["played:" + action.get("name", "")] = n + 1
self._targets.append(action.get("declared_target"))
self.state.log.append(("action", pid, action.get("name"),
action.get("target")))
action.get("target"), resolved_r))
if hasattr(self.rules, "interference"):
ds, dm = self.rules.interference(self.state, action)
s_extra += ds
Expand Down Expand Up @@ -335,4 +350,4 @@ def run(self, max_beats=500):
return MatchResult(outcome, self.state.machine_beats, self.state,
verdicts)

# ratios: loc_comments=193:103 imports_exports=3:8 calls_definitions=95:20
# ratios: loc_comments=203:108 imports_exports=3:8 calls_definitions=101:20
Loading