diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c562ec8 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 + run: python -m unittest discover -v -p 'test_*.py' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26b5282 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +.venv/ +venv/ diff --git a/README.md b/README.md index c32c3b1..e47afc1 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/expansions/scared-sacred/cards/README.md b/base-game/sets/fifty-three-days/cards/README.md similarity index 100% rename from expansions/scared-sacred/cards/README.md rename to base-game/sets/fifty-three-days/cards/README.md diff --git a/expansions/scared-sacred/cards/agendas/g01.json b/base-game/sets/fifty-three-days/cards/agendas/g01.json similarity index 100% rename from expansions/scared-sacred/cards/agendas/g01.json rename to base-game/sets/fifty-three-days/cards/agendas/g01.json diff --git a/expansions/scared-sacred/cards/agendas/g02.json b/base-game/sets/fifty-three-days/cards/agendas/g02.json similarity index 100% rename from expansions/scared-sacred/cards/agendas/g02.json rename to base-game/sets/fifty-three-days/cards/agendas/g02.json diff --git a/expansions/scared-sacred/cards/agendas/g03.json b/base-game/sets/fifty-three-days/cards/agendas/g03.json similarity index 100% rename from expansions/scared-sacred/cards/agendas/g03.json rename to base-game/sets/fifty-three-days/cards/agendas/g03.json diff --git a/expansions/scared-sacred/cards/agendas/g04.json b/base-game/sets/fifty-three-days/cards/agendas/g04.json similarity index 100% rename from expansions/scared-sacred/cards/agendas/g04.json rename to base-game/sets/fifty-three-days/cards/agendas/g04.json diff --git a/expansions/scared-sacred/cards/agendas/g05.json b/base-game/sets/fifty-three-days/cards/agendas/g05.json similarity index 100% rename from expansions/scared-sacred/cards/agendas/g05.json rename to base-game/sets/fifty-three-days/cards/agendas/g05.json diff --git a/expansions/scared-sacred/cards/agendas/g06.json b/base-game/sets/fifty-three-days/cards/agendas/g06.json similarity index 100% rename from expansions/scared-sacred/cards/agendas/g06.json rename to base-game/sets/fifty-three-days/cards/agendas/g06.json diff --git a/expansions/scared-sacred/cards/arcana/a00.json b/base-game/sets/fifty-three-days/cards/arcana/a00.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a00.json rename to base-game/sets/fifty-three-days/cards/arcana/a00.json diff --git a/expansions/scared-sacred/cards/arcana/a02.json b/base-game/sets/fifty-three-days/cards/arcana/a02.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a02.json rename to base-game/sets/fifty-three-days/cards/arcana/a02.json diff --git a/expansions/scared-sacred/cards/arcana/a08.json b/base-game/sets/fifty-three-days/cards/arcana/a08.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a08.json rename to base-game/sets/fifty-three-days/cards/arcana/a08.json diff --git a/expansions/scared-sacred/cards/arcana/a09.json b/base-game/sets/fifty-three-days/cards/arcana/a09.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a09.json rename to base-game/sets/fifty-three-days/cards/arcana/a09.json diff --git a/expansions/scared-sacred/cards/arcana/a10.json b/base-game/sets/fifty-three-days/cards/arcana/a10.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a10.json rename to base-game/sets/fifty-three-days/cards/arcana/a10.json diff --git a/expansions/scared-sacred/cards/arcana/a12.json b/base-game/sets/fifty-three-days/cards/arcana/a12.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a12.json rename to base-game/sets/fifty-three-days/cards/arcana/a12.json diff --git a/expansions/scared-sacred/cards/arcana/a13.json b/base-game/sets/fifty-three-days/cards/arcana/a13.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a13.json rename to base-game/sets/fifty-three-days/cards/arcana/a13.json diff --git a/expansions/scared-sacred/cards/arcana/a16.json b/base-game/sets/fifty-three-days/cards/arcana/a16.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a16.json rename to base-game/sets/fifty-three-days/cards/arcana/a16.json diff --git a/expansions/scared-sacred/cards/arcana/a20.json b/base-game/sets/fifty-three-days/cards/arcana/a20.json similarity index 100% rename from expansions/scared-sacred/cards/arcana/a20.json rename to base-game/sets/fifty-three-days/cards/arcana/a20.json diff --git a/expansions/scared-sacred/cards/index.json b/base-game/sets/fifty-three-days/cards/index.json similarity index 100% rename from expansions/scared-sacred/cards/index.json rename to base-game/sets/fifty-three-days/cards/index.json diff --git a/expansions/scared-sacred/cards/machine/m01.json b/base-game/sets/fifty-three-days/cards/machine/m01.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m01.json rename to base-game/sets/fifty-three-days/cards/machine/m01.json diff --git a/expansions/scared-sacred/cards/machine/m02.json b/base-game/sets/fifty-three-days/cards/machine/m02.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m02.json rename to base-game/sets/fifty-three-days/cards/machine/m02.json diff --git a/expansions/scared-sacred/cards/machine/m03.json b/base-game/sets/fifty-three-days/cards/machine/m03.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m03.json rename to base-game/sets/fifty-three-days/cards/machine/m03.json diff --git a/expansions/scared-sacred/cards/machine/m04.json b/base-game/sets/fifty-three-days/cards/machine/m04.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m04.json rename to base-game/sets/fifty-three-days/cards/machine/m04.json diff --git a/expansions/scared-sacred/cards/machine/m05.json b/base-game/sets/fifty-three-days/cards/machine/m05.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m05.json rename to base-game/sets/fifty-three-days/cards/machine/m05.json diff --git a/expansions/scared-sacred/cards/machine/m06.json b/base-game/sets/fifty-three-days/cards/machine/m06.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m06.json rename to base-game/sets/fifty-three-days/cards/machine/m06.json diff --git a/expansions/scared-sacred/cards/machine/m07.json b/base-game/sets/fifty-three-days/cards/machine/m07.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m07.json rename to base-game/sets/fifty-three-days/cards/machine/m07.json diff --git a/expansions/scared-sacred/cards/machine/m08.json b/base-game/sets/fifty-three-days/cards/machine/m08.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m08.json rename to base-game/sets/fifty-three-days/cards/machine/m08.json diff --git a/expansions/scared-sacred/cards/machine/m09.json b/base-game/sets/fifty-three-days/cards/machine/m09.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m09.json rename to base-game/sets/fifty-three-days/cards/machine/m09.json diff --git a/expansions/scared-sacred/cards/machine/m10.json b/base-game/sets/fifty-three-days/cards/machine/m10.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m10.json rename to base-game/sets/fifty-three-days/cards/machine/m10.json diff --git a/expansions/scared-sacred/cards/machine/m11.json b/base-game/sets/fifty-three-days/cards/machine/m11.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m11.json rename to base-game/sets/fifty-three-days/cards/machine/m11.json diff --git a/expansions/scared-sacred/cards/machine/m12.json b/base-game/sets/fifty-three-days/cards/machine/m12.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m12.json rename to base-game/sets/fifty-three-days/cards/machine/m12.json diff --git a/expansions/scared-sacred/cards/machine/m13.json b/base-game/sets/fifty-three-days/cards/machine/m13.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m13.json rename to base-game/sets/fifty-three-days/cards/machine/m13.json diff --git a/expansions/scared-sacred/cards/machine/m14.json b/base-game/sets/fifty-three-days/cards/machine/m14.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m14.json rename to base-game/sets/fifty-three-days/cards/machine/m14.json diff --git a/expansions/scared-sacred/cards/machine/m15.json b/base-game/sets/fifty-three-days/cards/machine/m15.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m15.json rename to base-game/sets/fifty-three-days/cards/machine/m15.json diff --git a/expansions/scared-sacred/cards/machine/m16.json b/base-game/sets/fifty-three-days/cards/machine/m16.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m16.json rename to base-game/sets/fifty-three-days/cards/machine/m16.json diff --git a/expansions/scared-sacred/cards/machine/m17.json b/base-game/sets/fifty-three-days/cards/machine/m17.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m17.json rename to base-game/sets/fifty-three-days/cards/machine/m17.json diff --git a/expansions/scared-sacred/cards/machine/m18.json b/base-game/sets/fifty-three-days/cards/machine/m18.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m18.json rename to base-game/sets/fifty-three-days/cards/machine/m18.json diff --git a/expansions/scared-sacred/cards/machine/m19.json b/base-game/sets/fifty-three-days/cards/machine/m19.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m19.json rename to base-game/sets/fifty-three-days/cards/machine/m19.json diff --git a/expansions/scared-sacred/cards/machine/m20.json b/base-game/sets/fifty-three-days/cards/machine/m20.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m20.json rename to base-game/sets/fifty-three-days/cards/machine/m20.json diff --git a/expansions/scared-sacred/cards/machine/m21.json b/base-game/sets/fifty-three-days/cards/machine/m21.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m21.json rename to base-game/sets/fifty-three-days/cards/machine/m21.json diff --git a/expansions/scared-sacred/cards/machine/m22.json b/base-game/sets/fifty-three-days/cards/machine/m22.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m22.json rename to base-game/sets/fifty-three-days/cards/machine/m22.json diff --git a/expansions/scared-sacred/cards/machine/m23.json b/base-game/sets/fifty-three-days/cards/machine/m23.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m23.json rename to base-game/sets/fifty-three-days/cards/machine/m23.json diff --git a/expansions/scared-sacred/cards/machine/m24.json b/base-game/sets/fifty-three-days/cards/machine/m24.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m24.json rename to base-game/sets/fifty-three-days/cards/machine/m24.json diff --git a/expansions/scared-sacred/cards/machine/m25.json b/base-game/sets/fifty-three-days/cards/machine/m25.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m25.json rename to base-game/sets/fifty-three-days/cards/machine/m25.json diff --git a/expansions/scared-sacred/cards/machine/m26.json b/base-game/sets/fifty-three-days/cards/machine/m26.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m26.json rename to base-game/sets/fifty-three-days/cards/machine/m26.json diff --git a/expansions/scared-sacred/cards/machine/m27.json b/base-game/sets/fifty-three-days/cards/machine/m27.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m27.json rename to base-game/sets/fifty-three-days/cards/machine/m27.json diff --git a/expansions/scared-sacred/cards/machine/m28.json b/base-game/sets/fifty-three-days/cards/machine/m28.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m28.json rename to base-game/sets/fifty-three-days/cards/machine/m28.json diff --git a/expansions/scared-sacred/cards/machine/m29.json b/base-game/sets/fifty-three-days/cards/machine/m29.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m29.json rename to base-game/sets/fifty-three-days/cards/machine/m29.json diff --git a/expansions/scared-sacred/cards/machine/m30.json b/base-game/sets/fifty-three-days/cards/machine/m30.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m30.json rename to base-game/sets/fifty-three-days/cards/machine/m30.json diff --git a/expansions/scared-sacred/cards/machine/m31.json b/base-game/sets/fifty-three-days/cards/machine/m31.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m31.json rename to base-game/sets/fifty-three-days/cards/machine/m31.json diff --git a/expansions/scared-sacred/cards/machine/m32.json b/base-game/sets/fifty-three-days/cards/machine/m32.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m32.json rename to base-game/sets/fifty-three-days/cards/machine/m32.json diff --git a/expansions/scared-sacred/cards/machine/m33.json b/base-game/sets/fifty-three-days/cards/machine/m33.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m33.json rename to base-game/sets/fifty-three-days/cards/machine/m33.json diff --git a/expansions/scared-sacred/cards/machine/m34.json b/base-game/sets/fifty-three-days/cards/machine/m34.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m34.json rename to base-game/sets/fifty-three-days/cards/machine/m34.json diff --git a/expansions/scared-sacred/cards/machine/m35.json b/base-game/sets/fifty-three-days/cards/machine/m35.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m35.json rename to base-game/sets/fifty-three-days/cards/machine/m35.json diff --git a/expansions/scared-sacred/cards/machine/m36.json b/base-game/sets/fifty-three-days/cards/machine/m36.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m36.json rename to base-game/sets/fifty-three-days/cards/machine/m36.json diff --git a/expansions/scared-sacred/cards/machine/m37.json b/base-game/sets/fifty-three-days/cards/machine/m37.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m37.json rename to base-game/sets/fifty-three-days/cards/machine/m37.json diff --git a/expansions/scared-sacred/cards/machine/m38.json b/base-game/sets/fifty-three-days/cards/machine/m38.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m38.json rename to base-game/sets/fifty-three-days/cards/machine/m38.json diff --git a/expansions/scared-sacred/cards/machine/m39.json b/base-game/sets/fifty-three-days/cards/machine/m39.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m39.json rename to base-game/sets/fifty-three-days/cards/machine/m39.json diff --git a/expansions/scared-sacred/cards/machine/m40.json b/base-game/sets/fifty-three-days/cards/machine/m40.json similarity index 100% rename from expansions/scared-sacred/cards/machine/m40.json rename to base-game/sets/fifty-three-days/cards/machine/m40.json diff --git a/expansions/scared-sacred/cards/reserve/k01.json b/base-game/sets/fifty-three-days/cards/reserve/k01.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k01.json rename to base-game/sets/fifty-three-days/cards/reserve/k01.json diff --git a/expansions/scared-sacred/cards/reserve/k02.json b/base-game/sets/fifty-three-days/cards/reserve/k02.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k02.json rename to base-game/sets/fifty-three-days/cards/reserve/k02.json diff --git a/expansions/scared-sacred/cards/reserve/k03.json b/base-game/sets/fifty-three-days/cards/reserve/k03.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k03.json rename to base-game/sets/fifty-three-days/cards/reserve/k03.json diff --git a/expansions/scared-sacred/cards/reserve/k04.json b/base-game/sets/fifty-three-days/cards/reserve/k04.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k04.json rename to base-game/sets/fifty-three-days/cards/reserve/k04.json diff --git a/expansions/scared-sacred/cards/reserve/k05.json b/base-game/sets/fifty-three-days/cards/reserve/k05.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k05.json rename to base-game/sets/fifty-three-days/cards/reserve/k05.json diff --git a/expansions/scared-sacred/cards/reserve/k06.json b/base-game/sets/fifty-three-days/cards/reserve/k06.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k06.json rename to base-game/sets/fifty-three-days/cards/reserve/k06.json diff --git a/expansions/scared-sacred/cards/reserve/k07.json b/base-game/sets/fifty-three-days/cards/reserve/k07.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k07.json rename to base-game/sets/fifty-three-days/cards/reserve/k07.json diff --git a/expansions/scared-sacred/cards/reserve/k08.json b/base-game/sets/fifty-three-days/cards/reserve/k08.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k08.json rename to base-game/sets/fifty-three-days/cards/reserve/k08.json diff --git a/expansions/scared-sacred/cards/reserve/k09.json b/base-game/sets/fifty-three-days/cards/reserve/k09.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k09.json rename to base-game/sets/fifty-three-days/cards/reserve/k09.json diff --git a/expansions/scared-sacred/cards/reserve/k10.json b/base-game/sets/fifty-three-days/cards/reserve/k10.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k10.json rename to base-game/sets/fifty-three-days/cards/reserve/k10.json diff --git a/expansions/scared-sacred/cards/reserve/k11.json b/base-game/sets/fifty-three-days/cards/reserve/k11.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k11.json rename to base-game/sets/fifty-three-days/cards/reserve/k11.json diff --git a/expansions/scared-sacred/cards/reserve/k12.json b/base-game/sets/fifty-three-days/cards/reserve/k12.json similarity index 100% rename from expansions/scared-sacred/cards/reserve/k12.json rename to base-game/sets/fifty-three-days/cards/reserve/k12.json diff --git a/expansions/scared-sacred/cards/response/r01.json b/base-game/sets/fifty-three-days/cards/response/r01.json similarity index 100% rename from expansions/scared-sacred/cards/response/r01.json rename to base-game/sets/fifty-three-days/cards/response/r01.json diff --git a/expansions/scared-sacred/cards/response/r02.json b/base-game/sets/fifty-three-days/cards/response/r02.json similarity index 100% rename from expansions/scared-sacred/cards/response/r02.json rename to base-game/sets/fifty-three-days/cards/response/r02.json diff --git a/expansions/scared-sacred/cards/response/r03.json b/base-game/sets/fifty-three-days/cards/response/r03.json similarity index 100% rename from expansions/scared-sacred/cards/response/r03.json rename to base-game/sets/fifty-three-days/cards/response/r03.json diff --git a/expansions/scared-sacred/cards/response/r04.json b/base-game/sets/fifty-three-days/cards/response/r04.json similarity index 100% rename from expansions/scared-sacred/cards/response/r04.json rename to base-game/sets/fifty-three-days/cards/response/r04.json diff --git a/expansions/scared-sacred/cards/response/r05.json b/base-game/sets/fifty-three-days/cards/response/r05.json similarity index 100% rename from expansions/scared-sacred/cards/response/r05.json rename to base-game/sets/fifty-three-days/cards/response/r05.json diff --git a/expansions/scared-sacred/cards/response/r06.json b/base-game/sets/fifty-three-days/cards/response/r06.json similarity index 100% rename from expansions/scared-sacred/cards/response/r06.json rename to base-game/sets/fifty-three-days/cards/response/r06.json diff --git a/expansions/scared-sacred/cards/response/r07.json b/base-game/sets/fifty-three-days/cards/response/r07.json similarity index 100% rename from expansions/scared-sacred/cards/response/r07.json rename to base-game/sets/fifty-three-days/cards/response/r07.json diff --git a/expansions/scared-sacred/cards/response/r08.json b/base-game/sets/fifty-three-days/cards/response/r08.json similarity index 100% rename from expansions/scared-sacred/cards/response/r08.json rename to base-game/sets/fifty-three-days/cards/response/r08.json diff --git a/expansions/scared-sacred/cards/response/r09.json b/base-game/sets/fifty-three-days/cards/response/r09.json similarity index 100% rename from expansions/scared-sacred/cards/response/r09.json rename to base-game/sets/fifty-three-days/cards/response/r09.json diff --git a/expansions/scared-sacred/cards/response/r10.json b/base-game/sets/fifty-three-days/cards/response/r10.json similarity index 100% rename from expansions/scared-sacred/cards/response/r10.json rename to base-game/sets/fifty-three-days/cards/response/r10.json diff --git a/expansions/scared-sacred/cards/response/r11.json b/base-game/sets/fifty-three-days/cards/response/r11.json similarity index 100% rename from expansions/scared-sacred/cards/response/r11.json rename to base-game/sets/fifty-three-days/cards/response/r11.json diff --git a/expansions/scared-sacred/cards/response/r12.json b/base-game/sets/fifty-three-days/cards/response/r12.json similarity index 100% rename from expansions/scared-sacred/cards/response/r12.json rename to base-game/sets/fifty-three-days/cards/response/r12.json diff --git a/expansions/scared-sacred/cards/response/r13.json b/base-game/sets/fifty-three-days/cards/response/r13.json similarity index 100% rename from expansions/scared-sacred/cards/response/r13.json rename to base-game/sets/fifty-three-days/cards/response/r13.json diff --git a/expansions/scared-sacred/cards/response/r14.json b/base-game/sets/fifty-three-days/cards/response/r14.json similarity index 100% rename from expansions/scared-sacred/cards/response/r14.json rename to base-game/sets/fifty-three-days/cards/response/r14.json diff --git a/expansions/scared-sacred/cards/response/r15.json b/base-game/sets/fifty-three-days/cards/response/r15.json similarity index 100% rename from expansions/scared-sacred/cards/response/r15.json rename to base-game/sets/fifty-three-days/cards/response/r15.json diff --git a/expansions/scared-sacred/cards/response/r16.json b/base-game/sets/fifty-three-days/cards/response/r16.json similarity index 100% rename from expansions/scared-sacred/cards/response/r16.json rename to base-game/sets/fifty-three-days/cards/response/r16.json diff --git a/expansions/scared-sacred/cards/response/r17.json b/base-game/sets/fifty-three-days/cards/response/r17.json similarity index 100% rename from expansions/scared-sacred/cards/response/r17.json rename to base-game/sets/fifty-three-days/cards/response/r17.json diff --git a/expansions/scared-sacred/cards/setup/x1.json b/base-game/sets/fifty-three-days/cards/setup/x1.json similarity index 100% rename from expansions/scared-sacred/cards/setup/x1.json rename to base-game/sets/fifty-three-days/cards/setup/x1.json diff --git a/expansions/scared-sacred/cards/setup/x2.json b/base-game/sets/fifty-three-days/cards/setup/x2.json similarity index 100% rename from expansions/scared-sacred/cards/setup/x2.json rename to base-game/sets/fifty-three-days/cards/setup/x2.json diff --git a/expansions/scared-sacred/cards/setup/x3.json b/base-game/sets/fifty-three-days/cards/setup/x3.json similarity index 100% rename from expansions/scared-sacred/cards/setup/x3.json rename to base-game/sets/fifty-three-days/cards/setup/x3.json diff --git a/expansions/scared-sacred/decks/v01_deck.md b/base-game/sets/fifty-three-days/decks/v01_deck.md similarity index 100% rename from expansions/scared-sacred/decks/v01_deck.md rename to base-game/sets/fifty-three-days/decks/v01_deck.md diff --git a/expansions/scared-sacred/decks/weimar_53_days_v01.md b/base-game/sets/fifty-three-days/decks/weimar_53_days_v01.md similarity index 100% rename from expansions/scared-sacred/decks/weimar_53_days_v01.md rename to base-game/sets/fifty-three-days/decks/weimar_53_days_v01.md diff --git a/engine/__pycache__/arcana_agendas_v1.cpython-312.pyc b/engine/__pycache__/arcana_agendas_v1.cpython-312.pyc deleted file mode 100644 index 69acaa2..0000000 Binary files a/engine/__pycache__/arcana_agendas_v1.cpython-312.pyc and /dev/null differ diff --git a/engine/__pycache__/arcana_agendas_v1.cpython-314.pyc b/engine/__pycache__/arcana_agendas_v1.cpython-314.pyc deleted file mode 100644 index 01222db..0000000 Binary files a/engine/__pycache__/arcana_agendas_v1.cpython-314.pyc and /dev/null differ diff --git a/engine/__pycache__/cards_v1.cpython-314.pyc b/engine/__pycache__/cards_v1.cpython-314.pyc deleted file mode 100644 index 1629bfa..0000000 Binary files a/engine/__pycache__/cards_v1.cpython-314.pyc and /dev/null differ diff --git a/engine/__pycache__/inertial_engine.cpython-314.pyc b/engine/__pycache__/inertial_engine.cpython-314.pyc deleted file mode 100644 index 25e2288..0000000 Binary files a/engine/__pycache__/inertial_engine.cpython-314.pyc and /dev/null differ diff --git a/engine/__pycache__/politics_runner.cpython-314.pyc b/engine/__pycache__/politics_runner.cpython-314.pyc deleted file mode 100644 index b24c59e..0000000 Binary files a/engine/__pycache__/politics_runner.cpython-314.pyc and /dev/null differ diff --git a/engine/__pycache__/rules_v1.cpython-314.pyc b/engine/__pycache__/rules_v1.cpython-314.pyc deleted file mode 100644 index 78872cf..0000000 Binary files a/engine/__pycache__/rules_v1.cpython-314.pyc and /dev/null differ diff --git a/engine/__pycache__/weimar_data.cpython-312.pyc b/engine/__pycache__/weimar_data.cpython-312.pyc deleted file mode 100644 index 0da8b65..0000000 Binary files a/engine/__pycache__/weimar_data.cpython-312.pyc and /dev/null differ diff --git a/engine/__pycache__/weimar_data.cpython-314.pyc b/engine/__pycache__/weimar_data.cpython-314.pyc deleted file mode 100644 index 045bc4a..0000000 Binary files a/engine/__pycache__/weimar_data.cpython-314.pyc and /dev/null differ diff --git a/engine/arcana_agendas_v1.py b/engine/arcana_agendas_v1.py index 2d9d513..eb8b3b2 100644 --- a/engine/arcana_agendas_v1.py +++ b/engine/arcana_agendas_v1.py @@ -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. @@ -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, @@ -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 === @@ -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 # id: arcana_once_per_game # behavior: a player's second arcanum play never resolves # id: arcana_wayseer_reveals @@ -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 plays_by_pid.setdefault(ev[1], []).append(ev[2]) if ev[0] == "static": laid_by_pid.setdefault(ev[1], []).append(ev[2]) @@ -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)) @@ -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 diff --git a/engine/cards_export.py b/engine/cards_export.py index 41fdfd6..4dff939 100644 --- a/engine/cards_export.py +++ b/engine/cards_export.py @@ -1,16 +1,17 @@ -# 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}/.json plus index.json. Each card carries an "art" block: {"file": ".png", "status": "awaiting"} until validated art exists beside it, then rerun flips status to "present". Prints counts; @@ -18,12 +19,13 @@ # === 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 === @@ -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) @@ -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 @@ -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 diff --git a/engine/cards_v1.py b/engine/cards_v1.py index 0911879..88621d9 100644 --- a/engine/cards_v1.py +++ b/engine/cards_v1.py @@ -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 @@ -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 @@ -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: @@ -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): @@ -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 @@ -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 diff --git a/engine/politics_runner.py b/engine/politics_runner.py index 3911487..6c2e179 100644 --- a/engine/politics_runner.py +++ b/engine/politics_runner.py @@ -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 @@ -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 @@ -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 @@ -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 @@ -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): @@ -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 @@ -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 diff --git a/engine/test_arcana_agendas.py b/engine/test_arcana_agendas.py index 10e4927..a5d5bbf 100644 --- a/engine/test_arcana_agendas.py +++ b/engine/test_arcana_agendas.py @@ -1,11 +1,13 @@ -# ratios: loc_comments=94:21 imports_exports=8:2 calls_definitions=54:10 +# ratios: loc_comments=107:23 imports_exports=8:2 calls_definitions=61:11 """Checks for arcana + agendas + habituation + reclaim pricing. # === CHECKS === # id: check_agendas_verify_at_end # witnesses: agendas_verify_at_end # id: check_organizer_counts_r -# witnesses: agendas_organizer_counts_r +# witnesses: agendas_organizer_counts_r, runner_attributed_r_log +# id: check_arcana_dealt_ownership +# witnesses: arcana_dealt_ownership # id: check_arcana_once # witnesses: arcana_once_per_game # id: check_wayseer @@ -25,7 +27,8 @@ import politics_runner as pr import weimar_data as wd -from arcana_agendas_v1 import ArcanaModule, deal_agendas, verify_agendas +from arcana_agendas_v1 import (ArcanaModule, deal_agendas, deal_arcana, + verify_agendas) from cards_v1 import WeimarMachine from inertial_engine import InertialEngine, weimar_seed from rules_v1 import RulesV1 @@ -58,12 +61,25 @@ def test_check_organizer_counts_r(self): st.tallies["agendas"] = [a for a in __import__("arcana_agendas_v1").AGENDAS if a["name"] == "THE ORGANIZER"] * 2 - st.log += [("action", 0, "X"), ("action", 0, "X"), - ("action", 1, "X")] + st.log += [("action", 0, "X", None, 3), + ("action", 1, "X", None, 1), + ("action", 1, "X", None, 1)] v = verify_agendas(st, "win") - self.assertTrue(v[0]["held"]) # pid 0 strictly greatest + self.assertTrue(v[0]["held"]) # 3 R beats two 1 R actions self.assertFalse(v[1]["held"]) + def test_check_arcana_dealt_ownership(self): + st = opening() + deal_arcana(st, 2, random.Random(3)) + arc = ArcanaModule() + mine = st.tallies["arcana"][0] + other = st.tallies["arcana"][1] + arc.apply(other, st, WeimarMachine(wd.MACHINE_SCRIPT), None, 0, 2) + self.assertEqual([ev for ev in st.log if ev[0] == "arcana"], + []) + arc.apply(mine, st, WeimarMachine(wd.MACHINE_SCRIPT), None, 0, 2) + self.assertTrue(any(ev[0] == "arcana" for ev in st.log)) + def test_check_arcana_once(self): st = opening() arc = ArcanaModule() @@ -130,4 +146,4 @@ def test_check_reclaim_priced(self): if __name__ == "__main__": unittest.main(verbosity=2) -# ratios: loc_comments=94:21 imports_exports=8:2 calls_definitions=54:10 +# ratios: loc_comments=107:23 imports_exports=8:2 calls_definitions=61:11 diff --git a/engine/test_cards_export.py b/engine/test_cards_export.py new file mode 100644 index 0000000..e258b4d --- /dev/null +++ b/engine/test_cards_export.py @@ -0,0 +1,31 @@ +# ratios: loc_comments=16:6 imports_exports=4:1 calls_definitions=7:2 +"""Checks that generated Fifty-Three Days assets stay in the base-game set. + +# === CHECKS === +# id: check_export_namespace +# witnesses: cards_export_v01 +# === END CHECKS === +""" +import tempfile +import unittest +from pathlib import Path + +import cards_export + + +class Checks(unittest.TestCase): + + def test_check_export_namespace(self): + with tempfile.TemporaryDirectory() as td: + root = Path(td) + idx = cards_export.export(str(root)) + base = root / "base-game" / "sets" / "fifty-three-days" / "cards" + wrong = root / "expansions" / "scared-sacred" / "cards" + self.assertEqual(len(idx["cards"]), 87) + self.assertTrue((base / "index.json").is_file()) + self.assertFalse(wrong.exists()) + + +if __name__ == "__main__": + unittest.main(verbosity=2) +# ratios: loc_comments=16:6 imports_exports=4:1 calls_definitions=7:2 diff --git a/engine/test_cards_v1.py b/engine/test_cards_v1.py index 48c3b96..bfcc838 100644 --- a/engine/test_cards_v1.py +++ b/engine/test_cards_v1.py @@ -1,4 +1,4 @@ -# ratios: loc_comments=83:18 imports_exports=6:2 calls_definitions=54:9 +# ratios: loc_comments=97:20 imports_exports=6:2 calls_definitions=62:10 """Checks for cards_v1 + weimar_data. Run: python3 test_cards_v1.py # === CHECKS === @@ -8,6 +8,8 @@ # witnesses: cards_se_gated_by_e # id: check_se_doubles_at_low_m # witnesses: cards_se_doubles_at_low_m +# id: check_se_shield_consumed +# witnesses: cards_se_shield_consumed # id: check_destruction_feeds_prereqs # witnesses: cards_destruction_feeds_prereqs # id: check_courts_flip @@ -69,6 +71,21 @@ def test_check_se_doubles_at_low_m(self): if s.get("name") == "THE PULPITS"][0] self.assertEqual(pulpits["perm_debuff"], expect) + def test_check_se_shield_consumed(self): + card = {"id": "T", "name": "T", "s": 0, + "se": {"a_se": 0, "kind": "perm_debuff", + "target": "THE PULPITS", "amount": 1}} + m = WeimarMachine([card]) + st = opening() + st.tallies["se_shield"] = 1 + st.in_play_statics.append({"name": "THE PULPITS", "passive_r": 1}) + m.resolve_se(m.next_card(st), st) + pulpits = [s for s in st.in_play_statics + if s.get("name") == "THE PULPITS"][0] + self.assertEqual(pulpits.get("perm_debuff", 0), 0) + self.assertEqual(st.tallies["se_shield"], 0) + self.assertIn(("se_shielded", "T"), st.log) + def test_check_destruction_feeds_prereqs(self): m = WeimarMachine(wd.MACHINE_SCRIPT) m.destroy("M35") # Marburg never spoken @@ -115,4 +132,4 @@ def test_check_full_match_runs(self): if __name__ == "__main__": unittest.main(verbosity=2) -# ratios: loc_comments=83:18 imports_exports=6:2 calls_definitions=54:9 +# ratios: loc_comments=97:20 imports_exports=6:2 calls_definitions=62:10 diff --git a/engine/test_politics_runner.py b/engine/test_politics_runner.py index b5ff0f2..ab6c198 100644 --- a/engine/test_politics_runner.py +++ b/engine/test_politics_runner.py @@ -1,4 +1,4 @@ -# ratios: loc_comments=50:17 imports_exports=2:3 calls_definitions=26:12 +# ratios: loc_comments=69:21 imports_exports=2:3 calls_definitions=39:14 """Checks for politics_runner. Run: python3 test_politics_runner.py # === CHECKS === @@ -14,6 +14,10 @@ # witnesses: runner_null_clock_terminates # id: check_rejects_illegal_plays # witnesses: runner_rejects_illegal_plays +# id: check_hand_discard_is_real +# witnesses: runner_hand_law +# id: check_action_log_carries_r +# witnesses: runner_attributed_r_log # === END CHECKS === """ import unittest @@ -81,7 +85,28 @@ def legal(self, state, play): res = self._run(deck(2, s=1), [active], rules=NoRules()) self.assertEqual(res.state.in_play_statics, []) # dropped silently + def test_check_hand_discard_is_real(self): + class Burner: + def __init__(self): + self.done = False + def take_turn(self, state, pid): + if self.done: + return TurnPlays() + self.done = True + return TurnPlays(discard_cards=[state.hands[pid][0]]) + st = GameState(draw_pile=[{"name": "BURN"}, {"name": "DRAW"}]) + res = MatchRunner(LeakyEngine(), ScriptedMachine(deck(2, s=0)), + [Burner()], st, hand_size=1).run() + self.assertIn("BURN", [c["name"] for c in res.state.discard_pile]) + self.assertNotIn("BURN", [c["name"] for c in res.state.hands[0]]) + + def test_check_action_log_carries_r(self): + active = RecordingPlayer(TurnPlays(actions=[{"name": "a", "r": 3}])) + res = self._run(deck(2, s=0), [active]) + action = next(ev for ev in res.state.log if ev[0] == "action") + self.assertEqual(action[4], 3) + if __name__ == "__main__": unittest.main(verbosity=2) -# ratios: loc_comments=50:17 imports_exports=2:3 calls_definitions=26:12 +# ratios: loc_comments=69:21 imports_exports=2:3 calls_definitions=39:14 diff --git a/expansions/scared-sacred/README.md b/expansions/scared-sacred/README.md new file mode 100644 index 0000000..0604a88 --- /dev/null +++ b/expansions/scared-sacred/README.md @@ -0,0 +1,16 @@ +# SCARED SACRED — Expansion Set 1 + +This directory is reserved for the distinctive SCARED SACRED expansion. +POLITICS / FIFTY-THREE DAYS is base-game content and lives under +`base-game/sets/fifty-three-days/`. + +Authoritative expansion doctrine remains in +`canon/canon_v02_scared_sacred.md` until executable expansion content is +promoted here. Do not regenerate the Fifty-Three Days Machine, response, +reserve, agenda, or arcana corpus into this namespace. + +Implemented base-game engine code lives under `engine/`; this expansion +may consume those contracts without owning them. + +hmmm — an expansion directory is allowed to be mostly empty when the +alternative is lying about which game the cards belong to. diff --git a/render/serve.py b/render/serve.py index ee41886..f139b61 100644 --- a/render/serve.py +++ b/render/serve.py @@ -1,10 +1,11 @@ -# ratios: loc_comments=206:51 imports_exports=15:4 calls_definitions=97:15 +# ratios: loc_comments=298:61 imports_exports=15:6 calls_definitions=133:17 """serve — the table. Top-down playable board for POLITICS, stdlib only. Layout per Erin's spec: the population at center (the inertial vector field drawn live — every person a dot, leaning as they lean), the card field ringed around it, machine and player avatars around that, your -hand along the bottom. Real card art from expansions/.../cards/. +hand along the bottom. Fifty-Three Days art is loaded from the base-game +set namespace. No tips, no tricks: the interface enforces legality by refusal and states what you MAY do, never what you should. The tutorial is the game @@ -15,33 +16,39 @@ -------------- cd && python3 render/serve.py # port 5300 # phone browser -> http://localhost:5300 -Solo seat + two null co-seats by default (POLITICS_SEATS=3 to change). +Solo seat + two noisy co-seats by default (POLITICS_SEATS=3 to change). Match: Fifty-Three Days, hand of 5, full mechanics. Refresh-safe: state -lives server-side; the page polls. Ctrl-C ends the republic early. +lives server-side; the page polls. Human reflex windows and the dealt +arcanum are playable from the browser. Ctrl-C ends the republic early. # === MODULE_BUILD === # id: render_serve_v01 # purpose: playable top-down table over the real engine; art live # surfaces: HTTP / (board), /state, /act, /react; HumanSeat # boundaries: no game logic (engine/rules own truth); no advice; -# blocking human input via queue, match on a worker thread -# tests: test_serve.py (state contract; no browser needed) +# blocking human input via queues, match on a worker thread +# tests: test_serve.py (state and request contracts; no browser needed) # rollout: first sit-down vehicle; tutorial == this, per ruling 6 # rollback: delete render/; engine untouched -# hmmm: reaction-window UI is prompt-only v1; multiplayer seats wait -# on matchmaking phase; agenda card shown, arcana button minimal +# hmmm: multiplayer seats wait on matchmaking phase; richer targeting +# waits on a real multiplayer table rather than being guessed here # === END MODULE_BUILD === # === CONTRACTS === # id: serve_state_reports_truth -# behavior: /state returns tracks, board, hand, awaiting flag, and log -# tail straight from the live GameState, nothing derived or advised +# behavior: /state returns tracks, board, hand, identity, reaction, +# awaiting flag, and log tail straight from live state # id: serve_act_enforces_hand_law -# behavior: /act with a card index outside the hand is refused without -# explanation +# behavior: invalid card/burn indexes are refused; valid burns are +# passed as actual card objects for runner hand-law enforcement +# id: serve_reaction_window +# behavior: when a legal human reflex exists, the worker pauses until +# the player chooses a legal reflex+burn pair or explicitly passes +# id: serve_arcana_playable +# behavior: the human seat sees and may play only its dealt arcanum # id: serve_match_thread_completes -# behavior: with null input the worker thread ends the match on the -# null clock +# behavior: with null human input/reactions the worker ends the match +# on the null clock # === END CONTRACTS === """ @@ -57,18 +64,21 @@ import politics_runner as pr # noqa: E402 import weimar_data as wd # noqa: E402 from harness_v1 import NoisyCoopPlayer # noqa: E402 -from arcana_agendas_v1 import ArcanaModule, deal_agendas # noqa: E402 +from arcana_agendas_v1 import (ArcanaModule, deal_agendas, deal_arcana) # noqa: E402 from cards_v1 import WeimarMachine, build_response_pile # noqa: E402 from inertial_engine import InertialEngine, weimar_seed # noqa: E402 from rules_v1 import RulesV1 # noqa: E402 class HumanSeat: - """Blocks the match thread until the browser posts a turn.""" + """Blocks the match thread only when the human actually has a choice.""" - def __init__(self): + def __init__(self, rules): + self.rules = rules self.inbox = queue.Queue() + self.reaction_inbox = queue.Queue() self.awaiting = False + self.reaction = None def take_turn(self, state, pid): self.awaiting = True @@ -77,17 +87,39 @@ def take_turn(self, state, pid): return plays def react(self, state, pid, mcard): - return None # v1: no human reflex UI + hand = state.hands[pid] if state.hands else [] + candidates = [i for i, card in enumerate(hand) + if card.get("reflex") + and self.rules.reflex_legal(state, card, mcard)] + candidates = [i for i in candidates + if any(j != i for j in range(len(hand)))] + if not candidates: + return None + self.reaction = {"incoming": dict(mcard), "candidates": candidates} + choice = self.reaction_inbox.get() + reaction = self.reaction + self.reaction = None + if choice is None: + return None + card_i, burn_i = choice + if (card_i not in reaction["candidates"] or + burn_i == card_i or + not (0 <= burn_i < len(hand))): + return None + return hand[card_i], hand[burn_i] class Table: def __init__(self, seats=3, seed=53): - self.human = HumanSeat() + self.rules = RulesV1() + self.human = HumanSeat(self.rules) st = pr.GameState(**wd.WEIMAR_OPENING) st.in_play_statics.extend(dict(s) for s in wd.SETUP_STATICS) st.draw_pile = build_response_pile(seed) import random - deal_agendas(st, seats, random.Random(seed)) + rng = random.Random(seed) + deal_agendas(st, seats, rng) + deal_arcana(st, seats, rng) self.state = st self.engine = InertialEngine(weimar_seed(100, seed)) import random as _rnd @@ -101,7 +133,7 @@ def __init__(self, seats=3, seed=53): self.runner = pr.MatchRunner( self.engine, WeimarMachine(wd.MACHINE_SCRIPT, reserve=wd.RESERVE_PILE), - players, st, rules=RulesV1(), hand_size=5, + players, st, rules=self.rules, hand_size=5, arcana=ArcanaModule()) self.result = None self.thread = threading.Thread(target=self._run, daemon=True) @@ -113,6 +145,7 @@ def _run(self): def snapshot(self): st = self.state hand = st.hands[0] if st.hands else [] + arcana = (st.tallies.get("arcana") or [{}])[0] return { "tracks": {"population": st.population, "e": round(st.e, 2), "m": st.m, "beat": st.machine_beats}, @@ -129,14 +162,71 @@ def snapshot(self): if e2[0] == "machine_id"), None)} for ev in reversed(st.log) if ev[0] == "machine"), None), "agenda": (st.tallies.get("agendas") or [{}])[0], + "arcana": arcana, + "arcana_used": 0 in self.runner.arcana.used, "awaiting": self.human.awaiting, - "log": [list(map(str, ev)) for ev in st.log[-8:]], + "reaction": self.human.reaction, + "log": [list(map(str, ev)) for ev in st.log[-10:]], "done": None if self.result is None else { "outcome": self.result.outcome, "agendas": self.result.agenda_outcomes}, } +def turn_from_request(table, req): + """Translate browser indexes into owned card objects; reject bad identity.""" + hand = table.state.hands[0] if table.state.hands else [] + kind = req.get("kind") + idx = req.get("card") + if kind in {"static", "action"}: + if not isinstance(idx, int) or not 0 <= idx < len(hand): + return None + card = hand[idx] + else: + card = None + burn_indexes = req.get("burns", []) + if not isinstance(burn_indexes, list): + return None + if len(set(burn_indexes)) != len(burn_indexes): + return None + if any(not isinstance(i, int) or not 0 <= i < len(hand) + for i in burn_indexes): + return None + if idx in burn_indexes: + return None + burns = [hand[i] for i in burn_indexes] + plays = pr.TurnPlays(discard_cards=burns) + if kind == "static": + card["side"] = "player" + plays.static = card + elif kind == "action": + card["declared_target"] = "shared" + plays.actions = [card] + elif kind == "arcana": + dealt = table.state.tallies.get("arcana") or [] + if not dealt: + return None + plays.arcana = dealt[0] + elif kind != "pass": + return None + return plays + + +def reaction_from_request(table, req): + """Return a validated (reflex-index, burn-index) pair or explicit pass.""" + pending = table.human.reaction + if pending is None: + return False, None + if req.get("pass"): + return True, None + card_i, burn_i = req.get("card"), req.get("burn") + hand = table.state.hands[0] if table.state.hands else [] + valid = (isinstance(card_i, int) and isinstance(burn_i, int) + and card_i in pending["candidates"] and burn_i != card_i + and 0 <= burn_i < len(hand)) + return (True, (card_i, burn_i)) if valid else (False, None) + + TABLE = None PAGE = """
-
+
-
+ + + +
""" @@ -237,41 +341,43 @@ def _art(self, ident): ident = ident.replace(".png", "").lower() if not ident.replace("-", "").isalnum(): self.send_response(404); self.end_headers(); return - base = os.path.join(os.path.dirname(__file__), "..", "expansions", - "scared-sacred", "cards") - for deck in ("response", "machine", "setup", "reserve"): + base = os.path.join(os.path.dirname(__file__), "..", "base-game", + "sets", "fifty-three-days", "cards") + for deck in ("response", "machine", "setup", "reserve", "arcana", + "agendas"): png = os.path.join(base, deck, ident + ".png") if os.path.exists(png): self.send_response(200) self.send_header("content-type", "image/png") self.end_headers() - self.wfile.write(open(png, "rb").read()) + with open(png, "rb") as fh: + self.wfile.write(fh.read()) return self.send_response(404); self.end_headers() def do_POST(self): - if self.path != "/act": - self.send_response(404); self.end_headers(); return n = int(self.headers.get("content-length", 0)) req = json.loads(self.rfile.read(n) or "{}") - hand = TABLE.state.hands[0] if TABLE.state.hands else [] - idx = req.get("card") - card = hand[idx] if isinstance(idx, int) and 0 <= idx < len(hand) \ - else None - burns = [hand[b] for b in req.get("burns", []) - if isinstance(b, int) and 0 <= b < len(hand)] - plays = pr.TurnPlays(discards=len(burns)) - if req.get("kind") == "static" and card is not None: - card["side"] = "player" - plays.static = card - elif req.get("kind") == "action" and card is not None: - card["declared_target"] = "shared" - plays.actions = [card] - if TABLE.human.awaiting: + if self.path == "/act": + if not TABLE.human.awaiting or TABLE.human.reaction is not None: + self._json({"ok": False}, 409) + return + plays = turn_from_request(TABLE, req) + if plays is None: + self._json({"ok": False}, 400) + return TABLE.human.inbox.put(plays) self._json({"ok": True}) - else: - self._json({"ok": False}, 409) # not your beat; no explanation + return + if self.path == "/react": + ok, choice = reaction_from_request(TABLE, req) + if not ok: + self._json({"ok": False}, 400 if TABLE.human.reaction else 409) + return + TABLE.human.reaction_inbox.put(choice) + self._json({"ok": True}) + return + self.send_response(404); self.end_headers() def main(): @@ -285,4 +391,4 @@ def main(): if __name__ == "__main__": main() -# ratios: loc_comments=206:51 imports_exports=15:4 calls_definitions=97:15 +# ratios: loc_comments=298:61 imports_exports=15:6 calls_definitions=133:17 diff --git a/render/test_serve.py b/render/test_serve.py index 573952c..a3e7160 100644 --- a/render/test_serve.py +++ b/render/test_serve.py @@ -1,11 +1,13 @@ -# ratios: loc_comments=34:11 imports_exports=4:1 calls_definitions=21:4 +# ratios: loc_comments=59:12 imports_exports=4:1 calls_definitions=34:5 """Checks for the table server (no browser required). # === CHECKS === # id: check_state_reports_truth -# witnesses: serve_state_reports_truth +# witnesses: serve_state_reports_truth, serve_arcana_playable # id: check_act_enforces_hand_law # witnesses: serve_act_enforces_hand_law +# id: check_reaction_request_validation +# witnesses: serve_reaction_window # id: check_match_thread_completes # witnesses: serve_match_thread_completes # === END CHECKS === @@ -25,23 +27,48 @@ def test_check_state_reports_truth(self): s = t.snapshot() self.assertEqual(len(s["field"]), 100) self.assertIn("population", s["tracks"]) - self.assertTrue(s["awaiting"]) # human seat blocks beat one self.assertEqual(len(s["hand"]), 5) - t.human.inbox.put(pr.TurnPlays()) # release the thread + self.assertTrue(s["arcana"].get("name")) + self.assertIn("reaction", s) + if t.human.reaction is not None: + t.human.reaction_inbox.put(None) + if t.human.awaiting: + t.human.inbox.put(pr.TurnPlays()) def test_check_act_enforces_hand_law(self): - t = serve.Table(seats=2) - time.sleep(0.1) - hand_before = list(t.state.hands[0]) - # out-of-range index -> pass turn, nothing leaves the hand - t.human.inbox.put(pr.TurnPlays()) - time.sleep(0.1) - self.assertTrue(all(c in t.state.hands[0] or True - for c in hand_before)) + fake = type("FakeTable", (), {})() + fake.state = pr.GameState( + hands=[[{"name": "A"}, {"name": "B"}, {"name": "C"}]], + tallies={"arcana": [{"name": "THE WAYSEER"}]}) + self.assertIsNone(serve.turn_from_request( + fake, {"kind": "action", "card": 99, "burns": []})) + self.assertIsNone(serve.turn_from_request( + fake, {"kind": "action", "card": 0, "burns": [0]})) + turn = serve.turn_from_request( + fake, {"kind": "action", "card": 0, "burns": [1]}) + self.assertIs(turn.actions[0], fake.state.hands[0][0]) + self.assertEqual(turn.discard_cards, [fake.state.hands[0][1]]) + + def test_check_reaction_request_validation(self): + fake = type("FakeTable", (), {})() + fake.state = pr.GameState(hands=[[{"name": "RX"}, {"name": "BURN"}]]) + fake.human = type("FakeHuman", (), {})() + fake.human.reaction = {"incoming": {"id": "M15"}, "candidates": [0]} + self.assertEqual( + serve.reaction_from_request(fake, {"card": 0, "burn": 1}), + (True, (0, 1))) + self.assertEqual( + serve.reaction_from_request(fake, {"card": 0, "burn": 0}), + (False, None)) + self.assertEqual( + serve.reaction_from_request(fake, {"pass": True}), + (True, None)) def test_check_match_thread_completes(self): t = serve.Table(seats=2) - for _ in range(200): + for _ in range(400): + if t.human.reaction is not None: + t.human.reaction_inbox.put(None) if t.human.awaiting: t.human.inbox.put(pr.TurnPlays()) if t.result is not None: @@ -53,4 +80,4 @@ def test_check_match_thread_completes(self): if __name__ == "__main__": unittest.main(verbosity=2) -# ratios: loc_comments=34:11 imports_exports=4:1 calls_definitions=21:4 +# ratios: loc_comments=59:12 imports_exports=4:1 calls_definitions=34:5