5 Commits

Author SHA1 Message Date
ml 83248561cc Adding gitea action
build / build (push) Successful in 33s
2026-07-11 20:23:39 +02:00
ml e522b4d6f5 Add a make package target for versioned Linux CLI release tarballs
Bundles deck-engine, interpreter-cli, run-engine.sh/run-interpreter.sh,
img/, i18n/, LICENSE, README.md, and docs/reading.md into
dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz. Version defaults to
the current git tag (vX.Y.Z), falling back to a 0.0.0-dev+<sha>
placeholder when untagged
2026-07-11 20:12:50 +02:00
ml 1662d550c7 Give the interpreter real Celtic Cross meanings and --format/--lang support
- guidance.c now prints last, after the full spread; both it and main.c's
  new Celtic Cross readout pull real Waite card meanings via tarot_data.c
  instead of just naming cards.
- interpreter-cli gains --format text|html|json and --lang en|de, matching
  deck-engine - required linking engine/src/tarot_data.c and i18n.c into
  the interpreter binary (a narrow, documented exception to its earlier
  "no engine .c files" policy) and writing German translations for
  narrative.c's/guidance.c's own text.
2026-07-11 19:39:30 +02:00
ml 41422adf98 Add narrative text and a day-significance rank to the interpreter
Each of the day's top significant transits now gets a short sentence
describing what it classically means and which area of life its house
governs, condensed from Sepharial's Transits and Planetary Periods
(1920, public domain) plus a small table of traditional house
significations. The day-level line also shows its rank out of the 4
defined levels, e.g. "Notable (2/4)". Docs (CLAUDE.md, README,
docs/input-output-format.md, docs/reading.md) updated to match,
including a full worked example in docs/reading.md.
2026-07-05 21:05:53 +02:00
ml 1136e3257a Report sign/house in significant transits; back up user.properties outside dist/ 2026-07-05 17:18:20 +02:00
27 changed files with 2556 additions and 222 deletions
+64
View File
@@ -0,0 +1,64 @@
name: build
# Build a versioned Linux CLI release tarball (see `make package`) on
# every push/PR, plus on-demand via the Gitea "Run workflow" button.
on:
push:
pull_request:
workflow_dispatch:
jobs:
build:
# Runner defaults `run:` steps to `sh`, which doesn't understand
# `set -o pipefail` used below — force bash explicitly.
defaults:
run:
shell: bash
# Must match a label your act_runner is registered with.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# `make package`'s version comes from `git describe --tags` (see
# the Makefile's `package` target) - needs full history/tags,
# not actions/checkout's default shallow single-commit clone.
fetch-depth: 0
- name: Preflight
run: |
set -euo pipefail
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
- name: Build and test
run: |
set -euo pipefail
make test
make package
- name: Upload build artifact
# v4 uses the newer @actions/artifact backend, which this Gitea
# instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works.
uses: actions/upload-artifact@v3
with:
name: deck-in-a-dash
path: dist/deck-in-a-dash-*-linux-*.tar.gz
- name: Publish to dl.ladkau.de
# Uploads the tarball over SFTP instead of using
# actions/upload-artifact (whose zip wrapping can't be disabled).
# Only runs on push so PR builds don't publish.
if: gitea.event_name == 'push'
run: |
set -euo pipefail
FILE="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
mkdir -p ~/.ssh
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
chmod 600 ~/.ssh/dl_sftp_key
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
-o StrictHostKeyChecking=accept-new \
uploader@dl.ladkau.de <<EOF
-mkdir files/deck-in-a-dash
put $FILE files/deck-in-a-dash/$(basename "$FILE")
EOF
+5
View File
@@ -3,6 +3,11 @@
/dist
/build
# Durable backup of the same real birth data, outside dist/ so a dist/
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
# commit this either.
/engine/scripts/user.properties.local
*.o
core
core.*
+256 -67
View File
@@ -5,13 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project goal
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
Cross) and astrology (natal chart + transits) reading. Currently only the
**core engine** exists: a plain-C, dependency-free library plus a
Cross) and astrology (natal chart + transits) reading. This repository
holds the **core engine**: a plain-C, dependency-free library plus a
standalone CLI binary (`dist/deck-engine`) that can be built and run
without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI,
resource packs) has not been started yet — the engine is deliberately
built so it can be linked straight into it later without rework (see
"Portability to the watch" below).
without any Pebble SDK or emulator, deliberately built so it links
straight into Pebble C/UI watch code without rework (see "Portability to
the watch" below).
## Build & run
@@ -20,6 +19,7 @@ make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
make package # builds a versioned Linux CLI tarball at dist/ - see "Packaging" below
```
A single `Makefile` at the repo root builds both `engine/` and
@@ -45,19 +45,62 @@ dist/deck-engine --seed "2026-07-03" \
```
`dist/user.properties` is seeded once from `engine/scripts/user.properties.template`
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) and is **never
overwritten by later builds** — `run-engine.sh`/`run-interpreter.sh`
refuse to run (with a clear error) until the placeholders are replaced
with real values. `dist/run-interpreter.sh` is the equivalent daily-use
wrapper for the interpreter: it runs `run-engine.sh`'s same OS-clock
seed/date and `user.properties` birth data through `deck-engine --format
json`, piped straight into `dist/interpreter-cli` — see "Interpretation"
below.
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) `run-engine.sh`/
`run-interpreter.sh` refuse to run (with a clear error) until the
placeholders are replaced with real values. Because `dist/` itself is
disposable (`make clean`, or just deleting the directory, wipes it), the
`scripts` Makefile target also keeps a durable backup outside `dist/`:
once `dist/user.properties` looks filled in (no leftover `YOUR_...`), every
build copies it out to `engine/scripts/user.properties.local`
(gitignored); if `dist/user.properties` is ever missing when a build
runs, it's restored from that backup instead of being reseeded from the
placeholder template. `dist/run-interpreter.sh` is the equivalent
daily-use wrapper for the interpreter: it runs `run-engine.sh`'s same
OS-clock seed/date and `user.properties` birth data through `deck-engine
--format json`, piped straight into `dist/interpreter-cli` — see
"Interpretation" below.
Run a single smoke test by editing `engine/tests/smoke_test.c`'s `main()`
temporarily, or just read its assertions — there's no test filter flag,
the whole suite runs in milliseconds.
## Packaging
`make package` (a `Makefile` target, not a separate script) builds a
fresh `all` and bundles it into a versioned, self-contained Linux CLI
tarball at `dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz` -
`deck-engine`, `interpreter-cli`, `run-engine.sh`/`run-interpreter.sh`,
`img/`, `i18n/`, `LICENSE`, `README.md`, `docs/reading.md` (renamed
`READING.md`), and a placeholder `user.properties` (from
`engine/scripts/user.properties.template`, **never**
`dist/user.properties` itself - see below). Extracting the tarball
anywhere and running the scripts/binaries from inside it works exactly
like running them from `dist/` in place, since `run-engine.sh`/
`run-interpreter.sh` already resolve every path relative to their own
location (`SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`), not the
caller's working directory.
**Version**: defaults to the current commit's git tag, matching
`vX.Y.Z` (the `v` is stripped); if `HEAD` isn't exactly on such a tag,
it falls back to a `0.0.0-dev+<short-sha>` placeholder with a warning on
stderr. Override either with `make package VERSION=1.2.3`. Push a
`vX.Y.Z` tag to drive a real release version.
**Never packages `dist/user.properties`**: the staging step copies
files into the package by explicit name only - never a wildcard or
recursive copy of `dist/` itself - specifically so a filled-in
`dist/user.properties` (real birth data, gitignored, private) can never
end up in a distributable package. The package always gets the
placeholder template instead, identical to what a first-time `make`
seeds `dist/user.properties` with.
**CI** (`.gitea/workflows/build.yml`): runs `make test && make package`
on every push/PR (plus manual dispatch). The resulting tarball is both
kept as a Gitea Actions run artifact and, on `push` only (not PRs),
published over SFTP to `dl.ladkau.de` under `files/deck-in-a-dash/` -
requires a `DL_SFTP_KEY` secret configured on this repo (or inherited
from the org/instance level).
## Architecture
```
@@ -65,7 +108,10 @@ deck_in_a_dash/
res/ Tarot card art (22 RWS Major Arcana JPEGs),
Waite's "Pictorial Key to the Tarot" (PDF,
public domain — source of all card/spread
text), logo, research notes.
text), Sepharial's "Transits and Planetary
Periods" (PDF, public domain — source of
interpreter/src/narrative.c's text), logo,
research notes.
engine/
third_party/astronomy/ Vendored cosinekitty/astronomy C library
(MIT, pinned commit — see VENDORED.md).
@@ -83,19 +129,20 @@ deck_in_a_dash/
tests/smoke_test.c
scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
interpreter/ Separate module + binary; see "Interpretation" below.
dist/ Build output (gitignored-style; see Build & run).
Makefile Single root Makefile, builds both engine/ and interpreter/.
dist/ Build output (gitignored-style; see Build & run) and,
after `make package`, versioned release tarballs.
Makefile Single root Makefile, builds both engine/ and
interpreter/, and packages a release (see Packaging).
```
### Data flow / the real API
`reading_generate(seed, birth, utc_moment, &DailyReading)` in
`reading.h` is the single entry point that matters — it's what the
watchapp will call once it exists. It populates a plain struct
(`NatalChart` + `DailyTransits` + `CelticCrossSpread`) that the caller
walks directly to build a UI. `reading_print_text`/`reading_print_html`
are desktop-only conveniences for inspecting a reading (used by the CLI);
the watchapp will never call them.
`reading.h` is the single entry point that matters — the real API
surface. It populates a plain struct (`NatalChart` + `DailyTransits` +
`CelticCrossSpread`) that a caller walks directly to build a UI.
`reading_print_text`/`reading_print_html` are desktop-only text/HTML
renderers of that same struct, used by the CLI.
### Determinism (`rng.c`)
@@ -177,58 +224,171 @@ translate the values — no source changes needed, `make` picks up any
`i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and
`reading_print_*` — it's a desktop-only entry point; `i18n_get` itself
is a pure fixed-size-array lookup with no heap allocation, so it's fine
to port to the watch once it has its own (non-file-based) way to
populate the catalog, e.g. from a compiled-in resource.
is a pure fixed-size-array lookup with no heap allocation, so it links
into the watch as-is given a non-file-based way to populate the catalog,
e.g. from a compiled-in resource.
### Portability to the watch
`rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate`
are kept free of `stdio`/CLI assumptions specifically so they can link
into the Pebble watchapp unchanged later. Only `reading_print_text`/
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load`
(reads a file from disk) are desktop-only and won't port as-is.
are kept free of `stdio`/CLI assumptions specifically so they link into
the Pebble watchapp unchanged. Only `reading_print_text`/`_html` (text
formatting), `main.c` (CLI arg parsing), and `i18n_load` (reads a file
from disk) are desktop-only and don't port as-is.
## Interpretation (`interpreter/`)
A second, separate top-level module and **its own binary**
(`dist/interpreter-cli`, sibling to `dist/deck-engine`) that scores *how
significant a day's transits are* — the first piece of a larger "turn the
raw reading into an actual narrative" effort described in project chat
history, not yet producing any narrative text. `deck-engine` itself is
deliberately unchanged by this beyond gaining `--format json`; the two
binaries compose over that JSON, never by linking together:
significant a day's transits are*, prints a short narrative sentence for
each of the top items about what that transit classically means, reads
out the full Celtic Cross spread with every card's meaning, and finally
prints guidance tying the day's top transit to the spread's
Attitude/Outcome cards; see `docs/reading.md`. Like `deck-engine`, it
supports `--format text|html|json` and `--lang <code>`/`--i18n-dir
<path>` for its own output - see `main.c`'s bullets below for both.
`deck-engine` itself is deliberately unchanged beyond gaining `--format
json`; the two binaries compose over that JSON, never by linking
`reading_generate()` or any I/O function together:
```bash
make # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli
make test # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c
dist/deck-engine ... --format json | dist/interpreter-cli
dist/deck-engine ... --format json | dist/interpreter-cli [--format text|html|json] [--lang <code>]
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
# or, for daily use (OS clock + user.properties, same as run-engine.sh):
dist/run-interpreter.sh
dist/run-interpreter.sh [text|html|json] [lang]
```
The root `Makefile` builds both from one invocation, but keeps them as
separate compilation units throughout — no object file is ever shared
between the two binaries (see the next bullet).
Note `--lang` means something different on each side of the pipe:
`deck-engine`'s own `--lang` (if passed at all) is irrelevant here, since
`--format json` is deliberately language-independent (see
`docs/input-output-format.md`); `dist/interpreter-cli --lang` controls
*its own* text/html/json output language, independently.
The root `Makefile` builds both from one invocation, and keeps them
independent compilation units for everything ephemeris/tarot-draw/
random-related — the one exception is `engine/src/tarot_data.c` and its
own `i18n.c` dependency (pure, deterministic card/position lookup text,
see `INTERP_TAROT_TEXT_OBJS` in the `Makefile`), whose object files are
built once and linked into *both* binaries.
- `interpret_daily_reading(reading, &out)` (`significance.h/.c`) scores
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
score in `DailyInterpretation.top_items[]` (descending, evicting the
rest), and classifies the day into a `DaySignificance` enum (`DAY_QUIET`
... `DAY_MAJOR`) from the single highest-scoring item.
`interpretation_deserves_framing()` is true only at `DAY_MAJOR` — the
intended hook for giving the Celtic Cross reading a "this matters"
intro sentence naming `top_items[0]` on days that earn it; that
rendering doesn't exist yet, `main.c`'s report is plain text only.
... `DAY_MAJOR`) from the single highest-scoring item. The enum also
carries a trailing `DAY_SIGNIFICANCE_COUNT` sentinel (not a real level)
purely so callers can print the level as a rank, e.g. `main.c`'s
`"%s (%d/%d)"``"Notable (2/4)"``interp.day_level + 1` out of
`DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true
only at `DAY_MAJOR``main.c` uses it solely to decide whether to
print an extra "worth a deeper Celtic Cross look" line; it's not a
gate on `guidance.c` (below), which runs on every day level.
- Score = `transiting-planet weight (slow/outer planets score higher) ×
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
(transits to natal Sun/Moon score higher than to other planets)`. The
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
thresholds in `significance.c` are hand-tuned, not derived from
anything physical — expect to retune them once real readings are
compared against how "major" a day actually feels.
anything physical.
- **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence
description printed under each top item, condensed from Sepharial's
*Transits and Planetary Periods* (1920, public domain —
`res/Transits_and_Planetary_Periods.pdf`), Chapter VIII "Effects of
Transits". Per transiting planet it holds a `base` description plus a
`harmonious`/`discordant` addendum, shown only under a trine/sextile or
square/opposition respectively (either may be `""` if the source gives
none); a conjunction is treated as neutral — base only — since
Sepharial's own text says a conjunction "takes the nature of what it
conjoins," which isn't modelled here. The Moon and Pluto aren't in that
chapter (fast lunar transits were out of scope for a year-ahead
forecasting book; Pluto wasn't discovered until 1930) — their text is
original, written for this project, not drawn from Sepharial. Each
narrative is also framed by *where* it's happening: `narrative_print()`
takes the transiting planet's whole-sign house (1-12, relative to the
natal chart, same as everywhere else in this codebase) and prefixes an
"In matters of ..." line naming that house's traditional area of life,
from a small `k_house_area[12]` table of standard whole-sign house
significations — centuries-old convention, not attributed to any single
source (same status as the sign/aspect names already in `astro.c`).
Passing a house outside 1-12 (the same "no data" sentinel `reading_io.c`
uses elsewhere) omits that framing and prints the planet's narrative on
its own. Every string here (`k_narratives[]`'s `base`/`harmonious`/
`discordant`, `k_house_area[]`, and the "In matters of ..." framing
template itself) is looked up via `i18n_get()` under `narrative.*` keys
before falling back to this English text - `engine/i18n/de.lang` has a
full German translation under the same keys.
- `main.c` calls `i18n_load()` once at startup (same pattern as
`engine/src/main.c`, including the same `default_i18n_path()` helper,
duplicated rather than shared since the two binaries are never linked
- see the compilation-units bullet above) before producing any output,
so every `i18n_get()` call anywhere in the interpreter - not just
`tarot_data.c`'s, but `narrative.c`'s/`guidance.c`'s own (below) -
respects `--lang`. `--format text`'s report is printed in a fixed
order: significance level → top significant transits (each with its
`narrative.c` sentence) → the full Celtic Cross spread
(`print_celtic_cross_text()`, every position in Waite's own drawing
order, each with its card, orientation, position description, and
card meaning — via the real `tarot_position_name()`/`tarot_card_name()`/
`tarot_position_description()`/`tarot_card_meaning()` accessors, not a
duplicated table, since `tarot_data.c` is linked into this binary) →
`guidance.c`'s paragraph, deliberately printed **last**, so it reads
as the closing takeaway after the reader has seen the full spread it
references. `--format html` mirrors `deck-engine`'s own HTML styling
and reuses the same `img/<file>` card-art convention (`tarot_card_
image_file()`); `--format json` embeds the same localized narrative/
guidance prose as `--format text` (in whatever `--lang` was loaded)
*alongside* stable, language-independent slugs (`transiting_planet`,
`aspect`, `card`, `position`, etc., reusing the same small local slug
tables `reading_io.c` needs for parsing) — a deliberate departure from
`reading_print_json`'s "slugs only, never prose" policy (next bullet),
justified because rendering that prose *is* this module's job, unlike
`deck-engine`'s JSON which is purely a machine interchange format.
`narrative_print()`/`guidance_print()` only know how to write to a
`FILE *`, so `--format json` captures their output into a heap string
via POSIX `open_memstream()` (`capture_narrative()`/`capture_guidance()`)
rather than changing either module's public API just for this one
caller.
- **`guidance.c`/`guidance.h`** is the combined-storytelling piece: it
ties `interp->top_items[0]` (the day's single most significant
transit) to the Celtic Cross spread and prints a short "how to meet
the day" paragraph on *every* day — `main.c` calls it unconditionally
after every `interpret_daily_reading()`, with no `day_level` gate. The
core guidance keys off two things: the top transit's aspect character
(harmonious/discordant/neutral, same classification `narrative.c`
uses) crossed with whether the spread's *Attitude* position — Waite's
"Himself: his position or attitude in the circumstances", the
position most directly about how the reader is meeting the day — fell
upright or reversed (3 × 2 = 6 combinations); this text stays valid
regardless of the day's intensity. What *does* vary with
`interp->day_level` is only the sentence introducing the top transit
(`k_intro[DAY_SIGNIFICANCE_COUNT]`, one `%s` each) — e.g. "With Saturn
as today's dominant influence" on `DAY_MAJOR` vs. "Saturn is only
faintly active today, but for what it's worth" on `DAY_QUIET`. A day
with no aspects in orb at all (`top_item_count == 0`, always
`DAY_QUIET`) has no transiting planet to introduce, so it falls back
to `k_no_transit_stance`, keyed on the Attitude card's orientation
alone. All of this guidance text is original, written for this
project, since neither Waite nor Sepharial discuss combining astrology
and tarot. The paragraph also names the Attitude and Outcome cards and
states their actual meaning via `tarot_card_meaning()` (e.g. what
Wheel of Fortune reversed means) — the real Waite text, not a
duplicated table, for the same reason as `print_celtic_cross_text()`
above. Every string here (stance/intro/no-transit/label text, plus a
`guidance.planet.*` table used only for the intro sentence's planet
name - separate from `body.*` because "the Sun"/"the Moon" take a
definite article the other eight planets don't, in both English and
German) is looked up via `i18n_get()` under `guidance.*` keys before
falling back to this English text - `engine/i18n/de.lang` has a full
German translation. The German `guidance.intro.*` templates are
deliberately phrased so the planet name placeholder is always the
sentence's grammatical subject (nominative case) across all four day
levels, sidestepping the case-agreement problems a naive word-for-word
translation of the English templates would hit (e.g. "with Saturn"
needs dative in German, but "Saturn is only faintly active" needs
nominative - the four German templates are worded so the placeholder
never needs to change case).
- **`reading_print_json`** (`engine/src/reading.c`) is the wire format:
the full `DailyReading` (natal, transits, spread), using the
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
@@ -241,26 +401,40 @@ between the two binaries (see the next bullet).
Unlike the core engine, it uses `malloc`; `interpreter-cli` was never
meant to run on the watch itself.
- **`reading_io.c`** walks the parsed JSON down to `"transits"."aspects"`
and fills a `DailyReading` with just that (everything else is left
zeroed - `interpret_daily_reading` doesn't read `natal`/`spread`
either). An aspect naming a planet/aspect-type slug it doesn't
recognize, or a document missing `"transits"."aspects"` entirely (as
opposed to a present-but-empty array, which is a valid "no aspects
today"), makes the whole load fail rather than silently dropping data.
- **Deliberately header-only dependency on the engine, throughout**:
`significance.h`/`reading_io.h` `#include engine/src/reading.h` for the
struct/enum *definitions*, but the root `Makefile` never compiles or
links any engine `.c` file (or the vendored Astronomy Engine) into the
interpreter's binary or tests — every
test in `interpreter/tests/` runs against hand-built JSON text or
hand-built `DailyReading` values, with no real ephemeris/tarot-draw
call involved anywhere. Consequently `reading_io.c` (planet/aspect
slugs) and `main.c` (planet/aspect display names) each duplicate a
small local table rather than linking `astro.c` to reuse its own -
keep those in sync if the engine's slugs/names ever change.
- Only `Aspect`s compete for the top 5 so far (`SignificantItemKind` is
currently just `ITEM_ASPECT`); moon phase and house ingresses are
candidate future item kinds but aren't scored yet.
and fills a `DailyReading` with those (used for scoring), plus
`"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign
house, via `parse_bodies()` — used only for the sign/house context
`main.c` prints alongside each significant event, never for scoring)
and `"spread"."positions"` (each position's card + reversed flag, via
`parse_spread()` — used by `main.c`'s full Celtic Cross readout and by
`guidance.c`'s Attitude/Outcome framing, never for scoring). An
aspect naming a planet/aspect-type slug it doesn't recognize, or a
document missing `"transits"."aspects"` entirely (as opposed to a
present-but-empty array, which is a valid "no aspects today"), makes
the whole load fail; by contrast an unrecognized body/card/position
slug, a missing `house` inside `"bodies"`, or a missing `"spread"` key
entirely is silently skipped/zeroed (`main.c` checks for the body
"no data" sentinel before printing sign/house), since all of that is
supplementary display context rather than something scoring depends
on.
- **Mostly header-only dependency on the engine**: `significance.h`/
`reading_io.h`/`narrative.h`/`guidance.h` `#include` engine headers
(`reading.h`/`astro.h`/`tarot.h`) for the struct/enum *definitions*,
and the root `Makefile` never compiles or links `astro.c`, `tarot.c`,
`rng.c`, `reading.c`, or the vendored Astronomy Engine into the
interpreter's binary or tests — every test in `interpreter/tests/`
runs against hand-built JSON text or hand-built `DailyReading`/
`DailyInterpretation` values, with no real ephemeris/tarot-draw call
involved anywhere. Consequently `reading_io.c` (planet/aspect/sign/
card/position slugs) and `main.c` (planet/aspect/sign display names)
each duplicate a small local table rather than linking `astro.c` to
reuse its own - keep those in sync if the engine's slugs ever change.
`tarot_data.c`/`i18n.c` are the one exception, actually linked in (see
above) - so card/position *names and meanings* are never duplicated;
only the slugs `reading_io.c` needs for JSON parsing (which
`tarot_data.c` doesn't expose a reverse lookup for) still are.
- Only `Aspect`s compete for the top 5 (`SignificantItemKind` is
currently just `ITEM_ASPECT`).
## Licensing
@@ -274,3 +448,18 @@ between the two binaries (see the next bullet).
- `engine/i18n/de.lang`'s card/spread text is an original translation of
that same public-domain English text, made for this project — not
taken from any specific published German edition.
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
Sepharial's *Transits and Planetary Periods* (1920, public domain —
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
entries, which are original (see narrative.c's own comment for why).
- Guidance text in `interpreter/src/guidance.c`: original, written for
this project — no source to condense from, since neither Waite nor
Sepharial discuss combining astrology and tarot. (The Attitude/Outcome
card meanings that same paragraph quotes are Waite's own text via
`tarot_card_meaning()`, covered by the `tarot_data.c` bullet above,
not original text.)
- `engine/i18n/de.lang`'s `narrative.*`/`guidance.*` entries are original
German translations of the above two files' text, made for this
project — same status as `de.lang`'s card/spread text (not taken from
a specific published German edition of Sepharial, since none exists
for this condensed, project-specific wording anyway).
+102 -13
View File
@@ -17,6 +17,11 @@ ENGINE_I18N_DIR := engine/i18n
ENGINE_SCRIPTS_DIR := engine/scripts
RES_IMG_DIR := res/img
# Durable copy of the user's real birth data, outside dist/ (which is
# disposable - `make clean`, or just deleting the directory, wipes it).
# Gitignored: see .gitignore.
USER_PROPS_BACKUP := $(ENGINE_SCRIPTS_DIR)/user.properties.local
# Engine logic shared by the CLI and the test binary (no main()).
ENGINE_SRCS := $(ENGINE_SRC_DIR)/rng.c $(ENGINE_SRC_DIR)/tarot.c $(ENGINE_SRC_DIR)/tarot_data.c \
$(ENGINE_SRC_DIR)/astro.c $(ENGINE_SRC_DIR)/reading.c $(ENGINE_SRC_DIR)/i18n.c \
@@ -31,28 +36,51 @@ ENGINE_TEST_BINARY := $(BUILD_DIR)/smoke_test
# ---- interpreter ----
# Only ever compiles this module's own sources - significance.h/
# reading_io.h reach into engine/src/ for struct/enum *definitions*
# (plain #includes), but no engine/src/*.c (or the vendored Astronomy
# Engine) is compiled or linked here. That's what keeps this module (and
# its tests) independent of the engine's actual ephemeris/tarot-draw
# Mostly compiles only this module's own sources - significance.h/
# reading_io.h/narrative.h reach into engine/src/ for struct/enum
# *definitions* (plain #includes) without linking any engine/src/*.c (or
# the vendored Astronomy Engine). That's what keeps those modules (and
# their tests) independent of the engine's actual ephemeris/tarot-draw
# implementation - see significance.c's and reading_io.c's own comments
# for why each duplicates a small table instead of linking astro.c.
#
# The one deliberate exception is engine/src/tarot_data.c (card/position
# names and Waite meaning text) and its own i18n.c dependency: both are
# pure, deterministic lookup tables with no ephemeris, no randomness, and
# (since the interpreter never calls i18n_load) no file I/O either, so
# linking them doesn't compromise interpreter/tests/'s "no real
# ephemeris/tarot-draw call" independence - it just avoids duplicating
# ~40 short strings of Waite's own card text a second time for
# main.c's/guidance.c's full-spread readout. See CLAUDE.md.
INTERP_SRC_DIR := interpreter/src
INTERP_TEST_DIR := interpreter/tests
INTERP_LIB_SRCS := $(INTERP_SRC_DIR)/significance.c $(INTERP_SRC_DIR)/json.c $(INTERP_SRC_DIR)/reading_io.c
INTERP_LIB_SRCS := $(INTERP_SRC_DIR)/significance.c $(INTERP_SRC_DIR)/json.c \
$(INTERP_SRC_DIR)/reading_io.c $(INTERP_SRC_DIR)/narrative.c \
$(INTERP_SRC_DIR)/guidance.c
INTERP_LIB_OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(INTERP_LIB_SRCS))
INTERP_MAIN_OBJ := $(OBJ_DIR)/$(INTERP_SRC_DIR)/main.o
# Shared with the engine build (same source, same flags - see the
# comment above); reused as-is rather than compiled twice.
INTERP_TAROT_TEXT_OBJS := $(OBJ_DIR)/$(ENGINE_SRC_DIR)/tarot_data.o $(OBJ_DIR)/$(ENGINE_SRC_DIR)/i18n.o
INTERP_BINARY := $(DIST_DIR)/interpreter-cli
SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/significance_test.o
JSON_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/json_test.o
NARRATIVE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/narrative_test.o
GUIDANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/guidance_test.o
SIGNIFICANCE_TEST_BIN := $(BUILD_DIR)/significance_test
JSON_TEST_BIN := $(BUILD_DIR)/json_test
NARRATIVE_TEST_BIN := $(BUILD_DIR)/narrative_test
GUIDANCE_TEST_BIN := $(BUILD_DIR)/guidance_test
.PHONY: all test clean images i18n scripts
# Linux CLI release tarball - see the `package` target below.
ARCH := $(shell uname -m)
PACKAGE_DIR := $(BUILD_DIR)/package
.PHONY: all test clean images i18n scripts package
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
@@ -60,7 +88,7 @@ $(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ)
@mkdir -p $(DIST_DIR)
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
$(INTERP_BINARY): $(INTERP_LIB_OBJS) $(INTERP_MAIN_OBJ)
$(INTERP_BINARY): $(INTERP_LIB_OBJS) $(INTERP_MAIN_OBJ) $(INTERP_TAROT_TEXT_OBJS)
@mkdir -p $(DIST_DIR)
$(CC) $(CFLAGS) -o $@ $^
@@ -79,21 +107,34 @@ i18n:
cp $(ENGINE_I18N_DIR)/*.lang $(DIST_I18N_DIR)/
# run-engine.sh/run-interpreter.sh are refreshed every build.
# user.properties is only seeded once (from the placeholder template) so
# a rebuild never clobbers the user's own filled-in birth data.
#
# user.properties is backed up/restored across dist/ wipes rather than
# just "seeded once": if dist/user.properties exists and looks filled in
# (no leftover YOUR_... placeholder), it's copied out to
# USER_PROPS_BACKUP; otherwise, if that backup exists (e.g. dist/ was
# just deleted), it's copied back in; otherwise (genuinely first run)
# dist/user.properties is seeded from the placeholder template, same as
# before.
scripts:
@mkdir -p $(DIST_DIR)
cp $(ENGINE_SCRIPTS_DIR)/run-engine.sh $(DIST_DIR)/run-engine.sh
chmod +x $(DIST_DIR)/run-engine.sh
cp $(ENGINE_SCRIPTS_DIR)/run-interpreter.sh $(DIST_DIR)/run-interpreter.sh
chmod +x $(DIST_DIR)/run-interpreter.sh
test -f $(DIST_DIR)/user.properties || \
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties
@if [ -f $(DIST_DIR)/user.properties ] && ! grep -q 'YOUR_' $(DIST_DIR)/user.properties; then \
cp $(DIST_DIR)/user.properties $(USER_PROPS_BACKUP); \
elif [ -f $(USER_PROPS_BACKUP) ]; then \
cp $(USER_PROPS_BACKUP) $(DIST_DIR)/user.properties; \
elif [ ! -f $(DIST_DIR)/user.properties ]; then \
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \
fi
test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN)
test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) $(NARRATIVE_TEST_BIN) $(GUIDANCE_TEST_BIN)
./$(ENGINE_TEST_BINARY)
./$(SIGNIFICANCE_TEST_BIN)
./$(JSON_TEST_BIN)
./$(NARRATIVE_TEST_BIN)
./$(GUIDANCE_TEST_BIN)
$(ENGINE_TEST_BINARY): $(ENGINE_OBJS) $(ENGINE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
@@ -104,10 +145,58 @@ $(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o $(SIGNIFIC
$(JSON_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/json.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/reading_io.o $(JSON_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(NARRATIVE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/narrative.o $(INTERP_TAROT_TEXT_OBJS) $(NARRATIVE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(GUIDANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/guidance.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o \
$(INTERP_TAROT_TEXT_OBJS) $(GUIDANCE_TEST_OBJ)
$(CC) $(CFLAGS) -o $@ $^
$(OBJ_DIR)/%.o: %.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c -o $@ $<
# Builds a versioned Linux CLI release tarball at
# dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz. Version defaults to
# the current git tag (vX.Y.Z, tag prefix stripped) - push a tag to drive
# a release. Override with `make package VERSION=1.2.3`, or just run it
# untagged for a local dev build (gets a 0.0.0-dev+<sha> placeholder
# version, with a warning).
#
# Copies files into the staging dir by explicit name only - never a
# wildcard/recursive copy of dist/ itself - so a real, filled-in
# dist/user.properties (birth data, gitignored, private) can never
# accidentally end up in a package. The package gets the placeholder
# template instead, same as a first-time `make` produces.
package: all
@V="$(VERSION)"; \
if [ -z "$$V" ]; then \
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
V=$${TAG#v}; \
else \
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
fi; \
fi; \
case "$$V" in \
[0-9]*.[0-9]*.[0-9]*) ;; \
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
esac; \
PKG_NAME="deck-in-a-dash-$$V-linux-$(ARCH)"; \
PKG_STAGE="$(PACKAGE_DIR)/$$PKG_NAME"; \
echo "Packaging $$PKG_NAME"; \
rm -rf "$$PKG_STAGE"; \
mkdir -p "$$PKG_STAGE"; \
cp $(ENGINE_BINARY) $(INTERP_BINARY) "$$PKG_STAGE/"; \
cp $(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh "$$PKG_STAGE/"; \
cp -r $(DIST_IMG_DIR) $(DIST_I18N_DIR) "$$PKG_STAGE/"; \
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template "$$PKG_STAGE/user.properties"; \
cp LICENSE README.md "$$PKG_STAGE/"; \
cp docs/reading.md "$$PKG_STAGE/READING.md"; \
tar -czf "$(DIST_DIR)/$$PKG_NAME.tar.gz" -C "$(PACKAGE_DIR)" "$$PKG_NAME"; \
rm -rf "$(PACKAGE_DIR)"; \
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
clean:
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
+43 -15
View File
@@ -7,11 +7,10 @@
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
Cross) and astrology (natal chart + transits) reading.
**Status:** only the core engine exists so far — a plain-C, dependency-free
This repository holds the core engine: a plain-C, dependency-free
library and standalone CLI (`dist/deck-engine`) that computes a full
reading without needing a Pebble device, emulator, or SDK. The watchapp
itself hasn't been built yet; the engine is designed so it can be linked
into it later without rework.
reading without needing a Pebble device, emulator, or SDK, designed so
it links straight into Pebble watch code without rework.
## What it computes
@@ -35,7 +34,7 @@ dropping a translated `key=value` file into `engine/i18n/` — see
```
res/ Tarot card art (22 RWS Major Arcana), the
Waite PDF, logo, research notes.
Waite and Sepharial PDFs, logo, research notes.
engine/
third_party/astronomy/ Vendored cosinekitty/astronomy C library (MIT).
src/ Engine source (rng, tarot, astro, i18n, reading, CLI).
@@ -46,10 +45,16 @@ engine/
dist/ Build output (gitignored) — see below.
interpreter/ Separate module + binary (dist/interpreter-cli):
scores how significant a day's transits are,
reading deck-engine's --format json output
(see CLAUDE.md).
reads out the full Celtic Cross spread with
every card's meaning, and closes with guidance
(every day, tailored to how significant it is)
tying the top transit to the spread, reading
deck-engine's --format json output. Supports
--format text|html|json and --lang en|de for
its own output too (see CLAUDE.md).
docs/ Architecture/dataflow diagrams, input/output format reference.
Makefile Single root Makefile, builds both engine/ and interpreter/.
Makefile Single root Makefile, builds both engine/ and
interpreter/, and packages a release (`make package`).
```
See [`CLAUDE.md`](CLAUDE.md) for the detailed architecture and the sharp
@@ -65,6 +70,10 @@ struct format reference
make # -> dist/deck-engine, dist/interpreter-cli, dist/img/, dist/i18n/,
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
make package # -> dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz, a
# self-contained release bundle (binaries, scripts, img/, i18n/,
# LICENSE, docs) - version from the current git tag, or
# VERSION=1.2.3 make package to override
```
Requires only a C99 compiler and `make` — no other dependencies.
@@ -93,16 +102,23 @@ dist/deck-engine --seed "2026-07-03" \
```
**Interpreter** (`interpreter/`, built by the same root `make`):
scores how significant a day's transits are, reading `deck-engine`'s
`--format json` output — the two binaries compose over that JSON, they're
never linked together. `dist/run-interpreter.sh` is the daily-use
equivalent of `run-engine.sh` (same OS clock + `user.properties` inputs),
piping straight into `interpreter-cli`:
scores how significant a day's transits are, reads out the full Celtic
Cross spread with every card's meaning, and closes with guidance tying
the day's biggest transit to the Attitude/Outcome cards - reading
`deck-engine`'s `--format json` output. Its own output supports the same
`--format text|html|json` and `--lang en|de` as `deck-engine` (note this
`--lang` is independent of whatever language `deck-engine` was run
with - `--format json` is always language-independent, see
`docs/input-output-format.md`). `dist/run-interpreter.sh` is the
daily-use equivalent of `run-engine.sh` (same OS clock + `user.properties`
inputs, including `user.properties`' `lang=`), piping straight into
`interpreter-cli`:
```bash
dist/deck-engine ... --format json | dist/interpreter-cli
dist/deck-engine ... --format json | dist/interpreter-cli [--format text|html|json] [--lang en|de]
# or, for daily use:
dist/run-interpreter.sh
dist/run-interpreter.sh # text output, language from user.properties
dist/run-interpreter.sh html de # HTML report with card art, override the language
```
## License
@@ -118,3 +134,15 @@ unmodified) — see its `VENDORED.md` for the pinned commit.
Card and spread text in `engine/src/tarot_data.c` is condensed from A. E.
Waite's *The Pictorial Key to the Tarot* (1911), which is in the public
domain.
Transit narrative text in `interpreter/src/narrative.c` is condensed
from Sepharial's *Transits and Planetary Periods* (1920), also in the
public domain, except the Moon and Pluto entries, which are original
(see the file's own comment).
Guidance text in `interpreter/src/guidance.c` is original, written for
this project.
`engine/i18n/de.lang`'s translations of the above two files' text
(`narrative.*`/`guidance.*` keys) are original German translations made
for this project, same status as its card/spread text.
+11 -11
View File
@@ -16,9 +16,8 @@ know before changing the engine. This document is the visual overview.
(MIT), pinned to a specific commit — see its `VENDORED.md`.
- **`engine/src/`** is the core engine. Everything except `main.c`,
`reading.c`'s `reading_print_text`/`reading_print_html` functions, and
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions,
specifically so it can be linked into the Pebble watchapp later without
rework.
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions, so it
links straight into a Pebble watchapp without rework.
- `rng.c` — deterministic string-seeded PRNG (FNV-1a + splitmix64).
- `tarot.c` / `tarot_data.c` — the Celtic Cross draw and its content.
- `astro.c` — natal chart + daily transits, built on the vendored
@@ -36,11 +35,11 @@ know before changing the engine. This document is the visual overview.
- **`dist/`** is generated by the single root `Makefile`: the
`deck-engine` and `interpreter-cli` binaries, a copy of the card art
under `img/`, a copy of the translation files under `i18n/`,
`run-engine.sh`/`run-interpreter.sh`, and a `user.properties` template
that's seeded once and never overwritten by later builds.
- **The Pebble watchapp does not exist yet.** When it's built, it will
call `reading_generate()` directly and walk the returned struct to lay
out its own screens — it has no reason to touch `reading_print_*`.
`run-engine.sh`/`run-interpreter.sh`, and `user.properties`. Since
`dist/` itself is disposable, `user.properties` also gets backed up to
`engine/scripts/user.properties.local` (gitignored) once it's filled
in, and restored from there if `dist/` is ever wiped — see
`CLAUDE.md`'s "Build & run".
## Dataflow
@@ -61,9 +60,10 @@ computations and combines their results:
with position and upright/reversed orientation, fully determined by
the seed string alone.
These three results are combined into one `DailyReading` struct, which is
either rendered by `reading_print_text`/`reading_print_html` (used by the
CLI) or, in the future, walked directly by watchapp UI code. See
These three results are combined into one `DailyReading` struct — the
real API surface. `reading_print_text`/`reading_print_html` (used by the
CLI) are just one way of consuming it; any caller, including watch UI
code, can walk the struct's fields directly. See
[`input-output-format.md`](input-output-format.md) for the exact fields
and output formats.
-8
View File
@@ -55,12 +55,6 @@ digraph architecture {
{ rank=same; binary; distimg; disti18n; runsh; props; }
}
subgraph cluster_watch {
label="Pebble watchapp — not built yet";
style="dashed"; color=firebrick; fontsize=11; fontcolor=firebrick;
watch [label="watchapp C / UI\n(future)", fillcolor=white, color=firebrick, fontcolor=firebrick, style="rounded,dashed"];
}
pdf -> tarot_data [label="sourced from", style=dotted, color=gray40];
tarot -> rng;
@@ -78,6 +72,4 @@ digraph architecture {
images -> distimg [label="`make images`\ncopies", style=dotted, color=gray40];
langfiles -> disti18n [label="`make i18n`\ncopies", style=dotted, color=gray40];
runsh -> props [label="reads at\nrun time", style=dotted, color=gray40];
watch -> reading [label="will call\nreading_generate()\ndirectly", style=dashed, color=firebrick, fontcolor=firebrick];
}
-4
View File
@@ -33,11 +33,9 @@ digraph dataflow {
print_text [label="reading_print_text()", fillcolor="#cfe8fb"];
print_html [label="reading_print_html()", fillcolor="#cfe8fb"];
watch_fn [label="watchapp UI code\n(future — walks the struct\ndirectly, no print_* call)", fillcolor=white, color=firebrick, fontcolor=firebrick, style="rounded,dashed"];
out_text [label="stdout: plain-text report", shape=parallelogram, fillcolor="#c8f0c8"];
out_html [label="stdout: reading.html\n(<img src=\"img/...\"> per card)", shape=parallelogram, fillcolor="#c8f0c8"];
out_watch [label="watch screens\n(future)", shape=parallelogram, fillcolor=white, color=firebrick, fontcolor=firebrick, style="dashed"];
cli -> main;
runsh -> main;
@@ -59,9 +57,7 @@ digraph dataflow {
daily_reading -> print_text;
daily_reading -> print_html;
daily_reading -> watch_fn [style=dashed, color=firebrick];
print_text -> out_text;
print_html -> out_html;
watch_fn -> out_watch [style=dashed, color=firebrick];
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 236 KiB

+235 -30
View File
@@ -4,9 +4,10 @@ See [`architecture.md`](architecture.md) for how these fit together.
There are two ways to provide input (direct CLI flags, or `run-engine.sh`
+ `user.properties`) and three output formats (`text`, `html`, `json`); the
`DailyReading` struct is the programmatic form all three are rendered
from, and the one the future watchapp will consume directly. `json` is
also `interpreter-cli`'s input format — see "The interpreter" at the
bottom of this file.
from, and the one watch UI code consumes directly. `json` is also
`interpreter-cli`'s input format — see "The interpreter" at the bottom
of this file, which supports the same three output formats (and the
same `--lang`) for its own report, independently of `deck-engine`.
## Input
@@ -55,11 +56,15 @@ use:
```
The file is seeded once from `engine/scripts/user.properties.template`
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) and is **never
overwritten by later builds**. `run-engine.sh` checks every birth field
for an empty value or a leftover `YOUR_` placeholder and refuses to run
with a clear error until the file is filled in; `lang` has no such
check since it always has a usable default (`en`).
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
checks every birth field for an empty value or a leftover `YOUR_`
placeholder and refuses to run with a clear error until the file is
filled in; `lang` has no such check since it always has a usable
default (`en`). Since `dist/` itself can be wiped (`make clean`, or
deleting the directory), the build also keeps a durable backup at
`engine/scripts/user.properties.local` (gitignored) once the file
looks filled in, and restores from it if `dist/user.properties` is
ever missing — see `CLAUDE.md`'s "Build & run".
- The optional second argument overrides `lang=` for a single run without
editing the file, e.g. `dist/run-engine.sh html de`.
- `dist/run-interpreter.sh` (no arguments) is the equivalent wrapper for
@@ -86,8 +91,8 @@ build, like `dist/img/`). Adding a language is just dropping another
### `DailyReading` (the real output — `reading.h`)
`reading_generate()` is the actual API; `text`/`html` below are just two
ways of rendering the struct it fills in. This is what the watchapp will
read directly once it exists.
ways of rendering the struct it fills in. This is what watch UI code
reads directly.
```c
typedef struct {
@@ -223,35 +228,235 @@ read.
`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s
"Interpretation" section for the full architecture — that reads a
`--format json` document and scores how significant today's transits
are. It never links the engine; it only depends on the JSON shape
above.
are. It never links `astro.c`/`tarot.c`/`reading.c` (or the vendored
Astronomy Engine) — only `engine/src/tarot_data.c`/`i18n.c` are linked
in, for real card/position names and Waite meaning text (see
`CLAUDE.md`'s `INTERP_TAROT_TEXT_OBJS` note). Otherwise it depends only
on the JSON shape above.
Like `deck-engine`, its own output supports `--format text|html|json`
and `--lang <code>`/`--i18n-dir <path>`:
| Flag | Required | Format | Meaning |
|---|---|---|---|
| `[reading.json]` | no | file path or `-` | Input file; stdin (or `-`) if omitted. |
| `--format` | no | `text` \| `html` \| `json` | This binary's **own output** format, defaults to `text`. Entirely independent of the `--format json` that produced its *input* - see below. |
| `--lang` | no | language code, e.g. `en`, `de` | This binary's **own output** language, defaults to `en`. Same `<code>.lang`/`--i18n-dir` convention as `deck-engine` (see "Translations" above) - `main.c` calls `i18n_load()` itself, so `narrative.c`'s/`guidance.c`'s own text (in `engine/i18n/*.lang` under `narrative.*`/`guidance.*` keys) is translated too, not just the card/position/body/sign/aspect vocabulary it shares with `deck-engine`. |
| `--i18n-dir` | no | path | Same convention as `deck-engine`'s: defaults to an `i18n/` directory next to this binary. |
**This `--lang` is unrelated to whatever `--lang` `deck-engine` was run
with to produce the `--format json` input** - that JSON is always
language-independent (slugs only, see above), so it doesn't matter what
language `deck-engine` printed anything in, or whether it was even asked
to print anything at all (`--format json` never touches `--lang`
either). The two `--lang` flags, on the two sides of the pipe, are
independent settings.
```bash
dist/deck-engine ... --format json | dist/interpreter-cli
dist/deck-engine ... --format json | dist/interpreter-cli --format html --lang de
# or:
dist/deck-engine ... --format json > reading.json
dist/interpreter-cli reading.json
# or, for daily use (OS clock + user.properties, same inputs as run-engine.sh):
dist/run-interpreter.sh
dist/interpreter-cli --format json reading.json
# or, for daily use (OS clock + user.properties, same inputs as run-engine.sh,
# including user.properties' lang=):
dist/run-interpreter.sh # text output, language from user.properties
dist/run-interpreter.sh html de # override format/language for this run
```
- **Input**: a file path argument, or stdin if no argument (or `-`) is
given. Only `"transits"."aspects"` is read — `natal` and `spread` may
be present (and are ignored) or omitted entirely, except that
`"transits"."aspects"` itself must be present (an empty array is valid
and means "no notable transits today"; a missing key is treated as
invalid input).
- **Output**: a plain-text report — the day's overall significance level
(`Quiet`/`Notable`/`Significant`/`Major`), a "worth a deeper Celtic
Cross look" line on `Major` days only, and up to 5 of today's aspects
ranked by score:
given. `"transits"."aspects"` is required (an empty array is valid and
means "no notable transits today"; a missing key is treated as invalid
input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are
read too, for the sign/house context printed alongside each event below
(not for scoring), and `"spread"."positions"` is read for the full
Celtic Cross readout and the guidance paragraph below — an entry naming
an unrecognized body/card/position, a missing `house`, or a missing
`"spread"` key entirely is silently skipped/zeroed rather than failing
the load (see `parse_bodies()`/`parse_spread()` in `reading_io.c`).
- **Input**: same for all three output formats above - only how the
parsed data gets rendered differs.
### `--format text`
A plain-text report, printed in a fixed order —
1. the day's overall significance level (`Quiet`/`Notable`/
`Significant`/`Major`, plus its rank out of the 4 defined levels,
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
line on `Major` days only;
2. up to 5 of today's aspects ranked by score, each naming the sign
and natal house the transiting planet currently occupies and the
sign and house of the natal planet it's aspecting, followed by a
short narrative sentence about what that transiting planet
classically signifies *and which area of life its current house
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
the source material has nothing to say for that planet/aspect
combination;
3. the full 10-position Celtic Cross spread, in Waite's own drawing
order, each with its card (and orientation), the position's own
description, and that card's actual meaning;
4. finally, a guidance paragraph tying the day's top transit to the
spread's Attitude/Outcome cards, with its opening sentence tailored
to `day_level` (stronger wording on `Major`, softer on
`Quiet`/`Notable`, falling back to a transit-free reading of the
Attitude card alone on a day with no aspects at all) and, for the
Attitude/Outcome cards it names, their actual Waite meaning for the
orientation they landed in — see `CLAUDE.md`'s `guidance.c` bullet.
This is printed last, as the closing takeaway after the reader has
seen the full spread it references.
```
Day significance: Significant
Day significance: Major (4/4)
(a major transit today - worth a deeper Celtic Cross look)
Top 3 significant transits:
1. Transiting Saturn Square natal Sun (orb 0.5°, score 8.10)
2. Transiting Pluto Opposition natal Moon (orb 0.2°, score 7.92)
3. Transiting Jupiter Trine natal Venus (orb 2.1°, score 3.40)
Top 5 significant transits:
1. Transiting Pluto in Aquarius (house 3) Conjunction natal Moon in Aquarius (house 3) (orb 2.9°, score 9.60)
In matters of communication, siblings, and everyday learning (house 3). Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form.
2. Transiting Neptune in Aries (house 5) Sextile natal Moon in Aquarius (house 3) (orb 2.5°, score 9.25)
In matters of romance, creativity, and children (house 5). Brings a state of chaos and confusion - an involved, uncertain condition of affairs, with plots, subtlety, or unseen influences at work.
3. Transiting Uranus in Gemini (house 7) Trine natal Moon in Aquarius (house 3) (orb 2.0°, score 8.96)
In matters of partnerships and close relationships (house 7). Brings separations, estrangements, sudden dislocations and violent upsets. Success through official or civic channels, and beneficial changes or appointments, are possible.
Celtic Cross:
1. The Present: The Emperor (Reversed)
This covers him: the general influence affecting the matter.
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
2. The Challenge: The Devil
This crosses him: the nature of the obstacle in the matter.
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
...
7. Himself: The Magician
His position or attitude in the circumstances.
Skill, diplomacy, address, subtlety; self-confidence, will.
...
10. The Outcome: Strength
What will come: the final result of the matter.
Power, energy, action, courage, magnanimity; complete success and honours.
With Pluto as today's dominant influence: Today concentrates whatever you already
bring to it - your current approach will be amplified, so make sure it's the one
you want.
Your Attitude card is The Magician: Skill, diplomacy, address, subtlety; self-confidence, will.
The spread's likely Outcome is Strength: Power, energy, action, courage, magnanimity; complete success and honours.
```
On a quieter day, only the opening of the guidance paragraph changes:
```
Day significance: Notable (2/4)
...
With a mild touch from Jupiter today: Your instincts are sound, but the day is
testing them - hold your position without forcing the issue.
Your Attitude card is The Sun: Material happiness, fortunate marriage, contentment.
The spread's likely Outcome is Justice: Equity, rightness, probity, executive; triumph of the deserving side in law.
```
The same report with `--lang de` (same input as the Major example
above):
```
Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
Top 5 wichtige Transits:
1. Transit Pluto in Wassermann (Haus 3) Konjunktion natal Mond in Wassermann (Haus 3) (Orbis 2.9°, Punktzahl 9.60)
In Fragen von Kommunikation, Geschwistern und alltäglichem Lernen (Haus 3). Bringt tiefgreifenden, oft verborgenen Wandel - das Auftauchen oder die Auflösung von etwas, das seine alte Form überwachsen hat.
...
Keltisches Kreuz:
1. Die Gegenwart: Der Herrscher (Umgekehrt)
Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
...
Pluto ist heute der bestimmende Einfluss: Der heutige Tag verstärkt, was du bereits
mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher,
dass es die richtige ist.
Deine Haltungskarte ist Der Magier: Geschick, Diplomatie, Gewandtheit, Feinsinn; Selbstvertrauen, Wille.
Das wahrscheinliche Ergebnis des Blatts ist Kraft: Macht, Energie, Tatkraft, Mut, Großmut; voller Erfolg und Ehren.
```
### `--format html`
A single self-contained HTML page, same inline-CSS/no-JS approach as
`deck-engine`'s own `--format html`, and the same three sections as text
(significant transits as a table, the full Celtic Cross as an image
grid, guidance as a closing callout) - reusing the exact same
`img/<file>` card-art convention (`tarot_card_image_file()`), so it
expects to sit next to an `img/` directory just like `deck-engine`'s
HTML output:
```html
<div class="card">
<div class="position">The Present</div>
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
<div class="name">The Emperor (Reversed)</div>
<div class="desc">This covers him: the general influence affecting the matter.</div>
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
</div>
```
The guidance section renders as one `<p>` per line of the same text
`--format text` prints last (intro sentence, Attitude line, Outcome
line), inside a `<div class="guidance">`.
### `--format json`
Unlike `deck-engine`'s own `--format json` - which is deliberately
**language-independent** (slugs only, no `--lang`-dependent prose, since
it's a machine interchange format between the two binaries) -
`interpreter-cli`'s `--format json` embeds the *same localized
narrative/guidance prose* `--format text` prints, in whatever `--lang`
was loaded, **alongside** stable, language-independent slug fields. This
is a deliberate difference: rendering that prose is this module's whole
job, so a JSON consumer that wants it doesn't have to reimplement
`narrative.c`/`guidance.c`'s logic itself - but a consumer that only
wants structured data (e.g. watch UI code with its own rendering) still
has the slugs to work with directly.
```json
{
"day_significance": {
"level": "major",
"rank": 4,
"count": 4,
"deserves_framing": true
},
"significant_transits": [
{
"transiting_planet": "pluto",
"transiting_sign": "aquarius",
"transiting_house": 3,
"natal_planet": "moon",
"natal_sign": "aquarius",
"natal_house": 3,
"aspect": "conjunction",
"orb": 2.8821,
"score": 9.5961,
"narrative": "In matters of communication, siblings, and everyday learning (house 3). Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form."
}
],
"celtic_cross": [
{
"position": "present",
"card": "emperor",
"reversed": true,
"card_name": "The Emperor",
"meaning": "Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity."
}
],
"guidance": "With Pluto as today's dominant influence: Today concentrates whatever you already bring to it - your current approach will be amplified, so make sure it's the one you want.\nYour Attitude card is The Magician: Skill, diplomacy, address, subtlety; self-confidence, will.\nThe spread's likely Outcome is Strength: Power, energy, action, courage, magnanimity; complete success and honours."
}
```
- `significant_transits` has `interp.top_item_count` entries (0-5, same
ranking as `--format text`); `celtic_cross` always has exactly 10, in
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
`spread.positions`).
- `orb`/`score` are `%.4f`, matching `deck-engine`'s own JSON precision
for angles.
- `guidance` is always present and non-empty (unlike
`significant_transits`, which can be `[]` on a genuinely quiet day) -
see `guidance.c`'s no-aspects-at-all fallback in `CLAUDE.md`.
- With `--lang de`, every string value above except the slugs
(`"level"`, `"transiting_planet"`, `"aspect"`, `"card"`, `"position"`,
etc.) changes language; the slugs never do.
+192 -15
View File
@@ -18,6 +18,12 @@ exactly where the planets were, and which zodiac sign was rising, at the
moment you were born — your **natal chart**. Everything else the app
does is compared against that one fixed reference point.
You can also choose a **language** for your reading - English or German.
Every part of the reading described below, including the guidance
paragraph at the end, is available in either; switching languages
doesn't change any of the astrology or the tarot draw itself, only how
it's worded.
## What happens every day
Each day, the app does two independent things and then combines them:
@@ -75,26 +81,197 @@ spread — the same source most modern tarot decks trace back to.
Not every day is equally eventful astrologically. A separate,
optional step can look at today's aspects (part 2, above) and works
out an overall **significance level** for the day — Quiet, Notable,
Significant, or Major — based on which planets are involved, how exact
the angle is, and whether it touches your Sun or Moon (the two most
personally weighted points in a chart). On a genuinely Major day, it
also calls out the single most important thing happening, as a hint
that it's worth paying closer attention to your tarot spread that day
rather than treating it as routine. Quiet days still get a full
Significant, or Major, shown together with its rank out of those four
(for example "Notable (2/4)") — based on which planets are involved,
how exact the angle is, and whether it touches your Sun or Moon (the
two most personally weighted points in a chart). On a genuinely Major
day, it also calls out the single most important thing happening, as a
hint that it's worth paying closer attention to your tarot spread that
day rather than treating it as routine. Quiet days still get a full
reading — this step only adds a "pay attention today" flag, it never
removes anything.
For each of the handful of events that make up today's score, you also
get a plain sentence about what it classically means and *which area
of your life* it's playing out in — drawn from the house it falls in
(see "Today's sky", above). For example, a tense aspect from Saturn
landing in your 6th house reads as a day for your daily work and
routine to feel a bit stuck, not just "Saturn is doing something."
That wording is condensed from a public-domain 1920 astrology book
(Sepharial's *Transits and Planetary Periods*), the same way the tarot
card meanings are condensed from Waite's 1911 book.
Every day — Quiet or Major — the app also reads out your full 10-card
Celtic Cross spread, position by position, each with that card's actual
meaning (drawn from the same Waite text as the rest of the reading), so
you get one complete, readable account of the spread rather than just
a list of card names.
After that spread, you get a short paragraph of actual guidance: how to
meet the day, given both the single biggest transit happening and your
own tarot spread. It looks at that transit's character (is it an easy
angle or a tense one?) together with your *Attitude* card — the
position Waite describes as "his position or attitude in the
circumstances", i.e. how you're currently approaching things — upright
or reversed, and gives a concrete stance to take today. It then names
your *Attitude* and *Outcome* cards again, this time spelling out what
each one actually means for the orientation it landed in — so instead
of just "your Outcome card is the Wheel of Fortune, reversed" you get
that card's real meaning alongside it. This is the one place the app
actually combines the astrology and the tarot into a single piece of
advice, rather than leaving you to read them side by side yourself, and
it's printed last, as the closing takeaway once you've seen the full
spread it's referring to.
The wording flexes with how big the day is: on a Major day it opens
with something like "With Pluto as today's dominant influence"; on a
quieter day with only a mild aspect in play it's softer, e.g. "With a
mild touch from Jupiter today" or, fainter still, "Mercury is only
faintly active today, but for what it's worth"; and on a genuinely
uneventful day with no aspects at all, there's no planet to name, so it
falls back to a plain read of your Attitude card's orientation alone
("There's no standout transit today - it's an astrologically quiet
day..."). The underlying stance advice itself doesn't change with
intensity — only how insistently it's introduced.
## What a day's reading actually gives you
Put together, one day's reading contains: your natal chart (for
reference), today's sky and how it's speaking to your chart, how
significant today is overall, and your 10-card Celtic Cross spread for
the day. Right now these sit side by side — the astrology tells you
*how eventful* today is and *where* to pay attention, and the tarot
spread gives you the actual guidance to sit with. They don't yet get
woven into a single narrative (e.g. a spread explained *in light of*
today's significant transit) — that combined storytelling is the next
piece to build, not something you get today.
significant today is overall (with a plain-language note on each of the
main events driving that), your full 10-card Celtic Cross spread read
out card by card with each card's meaning, and — every day, not just
Major ones — a closing paragraph of guidance tying the day's biggest
transit to your Attitude and Outcome cards specifically. Attitude and
Outcome get that treatment in the closing guidance; the other eight
positions are read in light of your natal chart and today's sky, the
way the worked example below does by hand.
*(This page, like that piece, is still a work in progress — expect it
to grow as the reading itself does.)*
## A worked example
Here's what an actual day's reading looks like end to end, for someone
born 14 March 1988 in Berlin, checking their reading on 5 July 2026.
**Natal chart (excerpt).** Sun in Pisces, Moon in Aquarius, Ascendant in
Sagittarius.
**Today's sky (excerpt).** Moon phase: Waning Gibbous. Several planets
are forming aspects back to the natal chart above — see below for which
ones actually matter.
**How big is today, and the significant transits.**
```
Day significance: Major (4/4)
(a major transit today - worth a deeper Celtic Cross look)
Top 5 significant transits:
1. Transiting Pluto in Aquarius (house 3) Conjunction natal Moon in Aquarius (house 3) (orb 2.9°, score 9.60)
In matters of communication, siblings, and everyday learning (house 3). Brings deep, often
hidden transformation - the surfacing or dismantling of something that has outgrown its old form.
2. Transiting Neptune in Aries (house 5) Sextile natal Moon in Aquarius (house 3) (orb 2.5°, score 9.25)
In matters of romance, creativity, and children (house 5). Brings a state of chaos and confusion -
an involved, uncertain condition of affairs, with plots, subtlety, or unseen influences at work.
3. Transiting Uranus in Gemini (house 7) Trine natal Moon in Aquarius (house 3) (orb 2.0°, score 8.96)
In matters of partnerships and close relationships (house 7). Brings separations, estrangements,
sudden dislocations and violent upsets. Success through official or civic channels, and beneficial
changes or appointments, are possible.
```
In plain terms: today is about as big as this reading gets (Major,
4 out of 4), and the story is squarely about the Moon — a slow-moving
Pluto sitting right on top of it, a chaotic Neptune touching it gently
from the 5th house, and a disruptive-but-possibly-lucky Uranus trine
from the 7th. Communication, romance, and relationships are all
quietly under pressure at once.
**The Celtic Cross, read out in full** (drawn independently, using
today's date):
```
Celtic Cross:
1. The Present: The Emperor (Reversed)
This covers him: the general influence affecting the matter.
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
2. The Challenge: The Devil
This crosses him: the nature of the obstacle in the matter.
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
3. The Crown: The Empress
This crowns him: the aim or ideal, the best that can be achieved.
Fruitfulness, action, initiative, length of days; also difficulty, doubt, ignorance.
4. The Foundation: Justice
This is beneath him: the basis of the matter, already actual.
Equity, rightness, probity, executive; triumph of the deserving side in law.
5. The Recent Past: Temperance
This is behind him: the influence that is just passing away.
Economy, moderation, frugality, management, accommodation.
6. The Near Future: The World (Reversed)
This is before him: the influence now coming into action.
Inertia, fixity, stagnation, permanence.
7. Himself: The Magician
His position or attitude in the circumstances.
Skill, diplomacy, address, subtlety; self-confidence, will.
8. His House: The Hermit (Reversed)
His environment and the tendencies at work therein.
Concealment, disguise, policy, fear, unreasoned caution.
9. Hopes and Fears: The Moon (Reversed)
His hopes or fears in the matter.
Instability, inconstancy, silence, lesser degrees of deception and error.
10. The Outcome: Strength
What will come: the final result of the matter.
Power, energy, action, courage, magnanimity; complete success and honours.
```
**The closing guidance**, tying the day's biggest transit (Pluto,
above) to the Attitude and Outcome cards from the spread just shown:
```
With Pluto as today's dominant influence: Today concentrates whatever you already
bring to it - your current approach will be amplified, so make sure it's the one
you want.
Your Attitude card is The Magician: Skill, diplomacy, address, subtlety; self-confidence, will.
The spread's likely Outcome is Strength: Power, energy, action, courage, magnanimity; complete success and honours.
```
Read side by side, the astrology and the tarot happen to rhyme here —
both point toward something that wants to shift (Pluto's "hidden
transformation," The Devil's "force") running into something that
feels stuck (a reversed World, an afflicted Moon). The closing guidance
picks up part of that thread automatically — an upright Attitude card
(The Magician) paired with this neutral, concentrating Pluto transit
becomes "make sure your current approach is the one you want" — reading
every other position (Challenge, Near Future, and the rest) into that
same story the way this paragraph just did by hand is the reader's own
synthesis (see above).
**A quieter day, for contrast.** Someone else — born 10 October 1990 in
Sydney, checking their reading on 15 January 2026 — gets a much lower-key
version of the same guidance, because the biggest thing happening that
day is a loose, single Jupiter aspect rather than an exact Pluto hit:
```
Day significance: Notable (2/4)
...
With a mild touch from Jupiter today: Your instincts are sound, but the day is
testing them - hold your position without forcing the issue.
Your Attitude card is The Sun: Material happiness, fortunate marriage, contentment.
The spread's likely Outcome is Justice: Equity, rightness, probity, executive; triumph of the deserving side in law.
```
Same stance logic (a tense aspect, an upright Attitude card), but the
opening line matches how much the day actually asks of you.
**The same Major day, in German.** Switching languages doesn't change
anything about the reading itself - same cards, same transits, same
guidance logic - only the words:
```
Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
Pluto ist heute der bestimmende Einfluss: Der heutige Tag verstärkt, was du bereits
mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher,
dass es die richtige ist.
Deine Haltungskarte ist Der Magier: Geschick, Diplomatie, Gewandtheit, Feinsinn; Selbstvertrauen, Wille.
Das wahrscheinliche Ergebnis des Blatts ist Kraft: Macht, Energie, Tatkraft, Mut, Großmut; voller Erfolg und Ehren.
```
+104
View File
@@ -154,3 +154,107 @@ card.judgement.reversed=Schwäche, Kleinmut, Einfalt; auch Überlegung, Entschei
card.world.name=Die Welt
card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Flucht, Ortswechsel.
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
interp.day_significance=Bedeutung des Tages:
interp.day_level.quiet=Ruhig
interp.day_level.notable=Bemerkenswert
interp.day_level.significant=Bedeutend
interp.day_level.major=Einschneidend
interp.major_framing=(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
interp.top_transits.one=Wichtigster Transit:
interp.top_transits.many=Top %d wichtige Transits:
interp.no_notable_transits=Heute keine nennenswerten Transits.
interp.in_sign_house=in %s (Haus %d)
interp.score=Punktzahl
interp.page_title=Deck in a Dash - Interpretation
interp.heading=Tägliche Interpretation
interp.heading_day_significance=Bedeutung des Tages
interp.heading_transits=Wichtige Transits
interp.heading_guidance=Rat für heute
# --- Transit-Deutungen (interpreter/src/narrative.c), sinngemäße
# Übertragung aus Sepharials Transits and Planetary Periods (1920,
# gemeinfrei), Kapitel VIII "Effects of Transits" - eigene Übersetzung
# für dieses Projekt, keine Übernahme aus einer veröffentlichten
# deutschen Ausgabe. Mond und Pluto sind eigener, englischsprachig
# verfasster Text (siehe narrative.c) und hier ebenfalls frei übertragen. ---
narrative.house_frame=In Fragen von %s (Haus %d).
narrative.house.1=deines Selbstbilds, deiner Identität und deines äußeren Auftretens
narrative.house.2=Geld, Besitz und persönlicher Werte
narrative.house.3=Kommunikation, Geschwistern und alltäglichem Lernen
narrative.house.4=Zuhause, Familie und deiner Herkunft
narrative.house.5=Romantik, Kreativität und Kindern
narrative.house.6=täglicher Arbeit, Routine und Gesundheit
narrative.house.7=Partnerschaften und engen Beziehungen
narrative.house.8=gemeinsamen Ressourcen, Nähe und Wandel
narrative.house.9=Reisen, höherer Bildung und Überzeugungen
narrative.house.10=Karriere, Ansehen und öffentlichem Auftreten
narrative.house.11=Freundschaften, Gemeinschaft und Zukunftshoffnungen
narrative.house.12=Rückzug, dem Unbewussten und verborgenen Dingen
narrative.sun.base=
narrative.sun.harmonious=Vorteile durch Vorgesetzte und Aufstieg in deinem Lebens- und Arbeitsbereich - Ehrungen, Vergütungen und erfolgreiche neue Verbindungen.
narrative.sun.discordant=Herabsetzung und Unehre, Verlust der Stellung und ungünstige Beurteilung durch Vorgesetzte.
narrative.moon.base=Färbt den alltäglichen und häuslichen Lebensbereich, oft verbunden mit der Eröffnung neuer Wege.
narrative.moon.harmonious=Diese Veränderungen fallen eher vorteilhaft aus.
narrative.moon.discordant=Diese Veränderungen fallen eher ungünstig aus, mit etwas Unwohlsein oder häuslichen Reibereien.
narrative.mercury.base=Betrifft Schriftliches, Reisen, Handel und alltägliche Angelegenheiten - ein neutraler Bote, dessen Wirkung sich nach der Art des Aspekts richtet, den er bildet.
narrative.mercury.harmonious=
narrative.mercury.discordant=
narrative.venus.base=Rückt häusliche und gesellschaftliche Angelegenheiten in den Vordergrund - Glück, Annehmlichkeiten und Gunstbezeigungen.
narrative.venus.harmonious=Erfolg in Liebesangelegenheiten und künstlerischen Unternehmungen ist wahrscheinlich.
narrative.venus.discordant=Kummer und Enttäuschung sind wahrscheinlicher.
narrative.mars.base=Eine anstrengende Zeit voller Streit, Auseinandersetzungen und Ärger, mit einem gewissen Verletzungsrisiko je nach dem Zeichen, in dem er steht.
narrative.mars.harmonious=Kann Vorteile durch Ärzte, Chirurgen oder neue Projekte und Unternehmungen bringen.
narrative.mars.discordant=
narrative.jupiter.base=Bringt Zuwachs und Ausdehnung - Fülle an Glück und Gesundheit und eine insgesamt günstige Zeit.
narrative.jupiter.harmonious=
narrative.jupiter.discordant=
narrative.saturn.base=Bringt Niedergeschlagenheit, Stillstand, Hindernisse und Erschwernisse sowie eine gewisse Einbuße der gewohnten Vorteile.
narrative.saturn.harmonious=Gunstbezeigungen aus älteren Verbindungen und vergangenen Beziehungen sind weiterhin möglich.
narrative.saturn.discordant=
narrative.uranus.base=Bringt Trennungen, Entfremdungen, plötzliche Verschiebungen und heftige Erschütterungen.
narrative.uranus.harmonious=Erfolg auf amtlichem oder öffentlichem Weg sowie günstige Veränderungen oder Berufungen sind möglich.
narrative.uranus.discordant=
narrative.neptune.base=Bringt einen Zustand von Chaos und Verwirrung - eine verworrene, unsichere Lage der Dinge, mit Intrigen, Heimlichkeiten oder unsichtbaren Einflüssen am Werk.
narrative.neptune.harmonious=
narrative.neptune.discordant=
narrative.pluto.base=Bringt tiefgreifenden, oft verborgenen Wandel - das Auftauchen oder die Auflösung von etwas, das seine alte Form überwachsen hat.
narrative.pluto.harmonious=
narrative.pluto.discordant=
# --- Guidance (interpreter/src/guidance.c), eigener Text für dieses
# Projekt - siehe guidance.c. Bewusst in direkter "Du"-Anrede, anders
# als das "er/ihn" aus Waites eigenem Text oben - entspricht dem
# eigenen Registerwechsel des englischen Originaltexts für diesen
# neuen, unmittelbareren Inhalt. ---
guidance.stance.harmonious.upright=Du begegnest dem Tag bereits mit der richtigen Einstellung - vertraue darauf, statt eine günstige Lage in Frage zu stellen.
guidance.stance.harmonious.reversed=Der Tag selbst spielt dir in die Hände, auch wenn du dich noch nicht gefestigt fühlst - vertraue der Dynamik mehr als deinen derzeitigen Zweifeln.
guidance.stance.discordant.upright=Dein Gespür ist richtig, doch der Tag stellt es auf die Probe - halte deine Position, ohne die Sache zu erzwingen.
guidance.stance.discordant.reversed=Sowohl der Tag als auch dein eigener Stand sind gerade unsicher - jetzt ist der Moment, dich zu sammeln, bevor du weitergehst.
guidance.stance.neutral.upright=Der heutige Tag verstärkt, was du bereits mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher, dass es die richtige ist.
guidance.stance.neutral.reversed=Der heutige Tag verstärkt vieles, auch das, was in deiner eigenen Herangehensweise noch ungeklärt ist - kläre es lieber, bevor du handelst.
guidance.intro.quiet=%s ist heute nur schwach aktiv, aber immerhin:
guidance.intro.notable=%s wirkt heute nur leicht:
guidance.intro.significant=%s ist heute deutlich aktiv:
guidance.intro.major=%s ist heute der bestimmende Einfluss:
guidance.no_transit.upright=Heute gibt es keinen herausragenden Transit - astrologisch ist es ein ruhiger Tag. Das macht ihn vor allem zu einer Frage der Standfestigkeit: vertraue deinem jetzigen Stand und nutze die Ruhe, um voranzukommen.
guidance.no_transit.reversed=Heute gibt es keinen herausragenden Transit - astrologisch ist es ein ruhiger Tag. Das ist ein guter Moment, um in aller Ruhe deinen eigenen Stand neu zu finden, da nichts von außen das Tempo vorgibt.
guidance.attitude_intro=Deine Haltungskarte ist
guidance.outcome_intro=Das wahrscheinliche Ergebnis des Blatts ist
# Planetennamen für guidance.intro.* oben - eine eigene Tabelle
# getrennt von body.*, da Sonne und Mond im Deutschen (wie im
# Englischen) einen bestimmten Artikel erhalten, die übrigen acht
# Planeten aber nicht.
guidance.planet.sun=die Sonne
guidance.planet.moon=der Mond
guidance.planet.mercury=Merkur
guidance.planet.venus=Venus
guidance.planet.mars=Mars
guidance.planet.jupiter=Jupiter
guidance.planet.saturn=Saturn
guidance.planet.uranus=Uranus
guidance.planet.neptune=Neptun
guidance.planet.pluto=Pluto
+101
View File
@@ -163,3 +163,104 @@ card.judgement.reversed=Weakness, pusillanimity, simplicity; also deliberation,
card.world.name=The World
card.world.upright=Assured success, recompense, voyage, route, emigration, flight, change of place.
card.world.reversed=Inertia, fixity, stagnation, permanence.
# --- Interpreter UI labels (interpreter/src/main.c) ---
interp.day_significance=Day significance:
interp.day_level.quiet=Quiet
interp.day_level.notable=Notable
interp.day_level.significant=Significant
interp.day_level.major=Major
interp.major_framing=(a major transit today - worth a deeper Celtic Cross look)
interp.top_transits.one=Top significant transit:
interp.top_transits.many=Top %d significant transits:
interp.no_notable_transits=No notable transits today.
interp.in_sign_house=in %s (house %d)
interp.score=score
interp.page_title=Deck in a Dash - Interpretation
interp.heading=Daily Interpretation
interp.heading_day_significance=Day Significance
interp.heading_transits=Significant Transits
interp.heading_guidance=Guidance
# --- Transit narratives (interpreter/src/narrative.c), condensed from
# Sepharial's Transits and Planetary Periods (1920, public domain),
# Chapter VIII "Effects of Transits" - except Moon and Pluto, which are
# original (see narrative.c's own comment for why) ---
narrative.house_frame=In matters of %s (house %d).
narrative.house.1=your sense of self, identity, and outward appearance
narrative.house.2=money, possessions, and personal values
narrative.house.3=communication, siblings, and everyday learning
narrative.house.4=home, family, and your roots
narrative.house.5=romance, creativity, and children
narrative.house.6=daily work, routine, and health
narrative.house.7=partnerships and close relationships
narrative.house.8=shared resources, intimacy, and transformation
narrative.house.9=travel, higher learning, and beliefs
narrative.house.10=career, reputation, and public standing
narrative.house.11=friendships, community, and hopes for the future
narrative.house.12=solitude, the subconscious, and hidden matters
narrative.sun.base=
narrative.sun.harmonious=Benefits from superiors and advancement in your sphere of life and work - honours, emoluments, and successful new associations.
narrative.sun.discordant=Degradation and dishonour, loss of position, and adverse judgement from superiors.
narrative.moon.base=Colours the everyday and domestic sphere of life, often coinciding with the opening of new avenues.
narrative.moon.harmonious=These changes tend to be advantageous.
narrative.moon.discordant=These changes tend to be adverse, with some indisposition or domestic friction.
narrative.mercury.base=Affects writings, journeys, commerce, and everyday activities - a neutral messenger whose effect follows the nature of the aspect it makes.
narrative.mercury.harmonious=
narrative.mercury.discordant=
narrative.venus.base=Brings domestic and social affairs to the fore - happiness, comforts, and favours.
narrative.venus.harmonious=Success in love affairs and artistic pursuits is likely.
narrative.venus.discordant=Grief and disappointment are more likely.
narrative.mars.base=A strenuous time of quarrels, contention, strife and anger, with some risk of hurts or injuries depending on the sign it occupies.
narrative.mars.harmonious=Can bring benefits from doctors, surgeons, or new projects and enterprises.
narrative.mars.discordant=
narrative.jupiter.base=Brings increase and expansion - fullness of fortune and health, and a generally fortunate time.
narrative.jupiter.harmonious=
narrative.jupiter.discordant=
narrative.saturn.base=Brings depression, stagnation, hindrances and obstacles, and some deprivation of the usual benefits.
narrative.saturn.harmonious=Favours from older connections and past associations are still possible.
narrative.saturn.discordant=
narrative.uranus.base=Brings separations, estrangements, sudden dislocations and violent upsets.
narrative.uranus.harmonious=Success through official or civic channels, and beneficial changes or appointments, are possible.
narrative.uranus.discordant=
narrative.neptune.base=Brings a state of chaos and confusion - an involved, uncertain condition of affairs, with plots, subtlety, or unseen influences at work.
narrative.neptune.harmonious=
narrative.neptune.discordant=
narrative.pluto.base=Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form.
narrative.pluto.harmonious=
narrative.pluto.discordant=
# --- Guidance (interpreter/src/guidance.c), original text written for
# this project - see guidance.c's own comment for why. Deliberately
# direct "you/your" address, unlike the "him/his" register used for
# Waite's own text above - matches the English original's own register
# shift for this new, more conversational content. ---
guidance.stance.harmonious.upright=You're already meeting the day in the right spirit - lean into it rather than second-guessing a favorable position.
guidance.stance.harmonious.reversed=The day itself is working in your favor even though you don't feel settled yet - trust the momentum more than your current doubts.
guidance.stance.discordant.upright=Your instincts are sound, but the day is testing them - hold your position without forcing the issue.
guidance.stance.discordant.reversed=Both the day and your own footing are unsettled right now - this is a moment to steady yourself before pushing forward.
guidance.stance.neutral.upright=Today concentrates whatever you already bring to it - your current approach will be amplified, so make sure it's the one you want.
guidance.stance.neutral.reversed=Today intensifies things, including whatever is currently unresolved in your own approach - worth sorting out before acting.
guidance.intro.quiet=%s is only faintly active today, but for what it's worth:
guidance.intro.notable=With a mild touch from %s today:
guidance.intro.significant=With %s clearly active today:
guidance.intro.major=With %s as today's dominant influence:
guidance.no_transit.upright=There's no standout transit today - it's an astrologically quiet day. That leaves things mostly about steadiness: trust your current footing and use the calm to make headway.
guidance.no_transit.reversed=There's no standout transit today - it's an astrologically quiet day. That's a good moment to quietly re-settle your own footing, since nothing external is forcing the pace.
guidance.attitude_intro=Your Attitude card is
guidance.outcome_intro=The spread's likely Outcome is
# Planet names as used in guidance.intro.* above - a separate table from
# body.* because English (and German) give the Sun/Moon a definite
# article that the other eight planets don't take.
guidance.planet.sun=the Sun
guidance.planet.moon=the Moon
guidance.planet.mercury=Mercury
guidance.planet.venus=Venus
guidance.planet.mars=Mars
guidance.planet.jupiter=Jupiter
guidance.planet.saturn=Saturn
guidance.planet.uranus=Uranus
guidance.planet.neptune=Neptune
guidance.planet.pluto=Pluto
+14 -3
View File
@@ -3,6 +3,12 @@
# "today": same OS-clock seed/transit-moment and user.properties birth
# data as run-engine.sh, but reports how significant today's transits are
# instead of printing the full reading. Usage: ./run-interpreter.sh
# [text|html|json] [lang]
#
# [lang] overrides user.properties' lang= for this run only, same as
# run-engine.sh - but note it only affects interpreter-cli's own output;
# deck-engine is always run without --lang here, since --format json is
# deliberately language-independent (see docs/input-output-format.md).
#
# Requires both dist/deck-engine (built by engine/'s Makefile) and
# dist/interpreter-cli (built by interpreter/'s own Makefile) to already
@@ -35,6 +41,7 @@ birth_time=""
birth_utc_offset=""
birth_lat=""
birth_lon=""
lang="en"
while IFS='=' read -r key value || [ -n "$key" ]; do
key="$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
@@ -48,6 +55,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
birth_utc_offset) birth_utc_offset="$value" ;;
birth_lat) birth_lat="$value" ;;
birth_lon) birth_lon="$value" ;;
lang) [ -n "$value" ] && lang="$value" ;;
esac
done <"$PROPERTIES_FILE"
@@ -67,10 +75,13 @@ check_set birth_lon "$birth_lon"
# OS-provided inputs: today's date is the tarot seed (stable all day, a
# new spread each day), current UTC time drives today's transits. The
# tarot seed doesn't matter for --format json (only "transits" is read
# downstream) but it's kept identical to run-engine.sh's for consistency.
# tarot seed still matters for --format json now that "spread" is read
# downstream too (guidance.c's Attitude/Outcome framing) - kept identical
# to run-engine.sh's for consistency either way.
seed="$(date +%Y-%m-%d)"
transit_date="$(date -u +%Y-%m-%dT%H:%M)"
format="${1:-text}"
lang="${2:-$lang}"
"$ENGINE_BINARY" \
--seed "$seed" \
@@ -81,4 +92,4 @@ transit_date="$(date -u +%Y-%m-%dT%H:%M)"
--birth-lon "$birth_lon" \
--date "$transit_date" \
--format json \
| exec "$INTERPRETER_BINARY"
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
+164
View File
@@ -0,0 +1,164 @@
#include "guidance.h"
/* i18n_get() only - see narrative.c's own comment on why this one extra
* engine header is needed despite guidance.h's otherwise header-only
* engine dependency. */
#include "../../engine/src/i18n.h"
/* Slugs for building "guidance.planet.<slug>"/i18n keys - mirrors
* reading_io.c's own k_body_slug, same duplication policy (see
* significance.c's comment for why each independently-testable module
* keeps its own small table rather than linking astro.c). */
static const char *const k_body_slug[NUM_BODIES] = {
"sun", "moon", "mercury", "venus", "mars",
"jupiter", "saturn", "uranus", "neptune", "pluto",
};
/* English fallback only - "the Sun"/"the Moon" take a definite article
* that the other eight planets don't; see engine/i18n/en.lang's own
* comment on the guidance.planet.* keys this backs. */
static const char *const k_planet_name_fallback[NUM_BODIES] = {
"the Sun", "the Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
};
static const char *planet_display_name(Body body) {
char key[32];
snprintf(key, sizeof key, "guidance.planet.%s", k_body_slug[body]);
return i18n_get(key, k_planet_name_fallback[body]);
}
typedef enum {
CHAR_HARMONIOUS = 0,
CHAR_DISCORDANT,
CHAR_NEUTRAL,
NUM_TRANSIT_CHARACTERS
} TransitCharacter;
typedef struct {
const char *slug; /* builds "guidance.stance.<slug>.*" keys */
const char *upright; /* Attitude card fell upright (English fallback) */
const char *reversed; /* Attitude card fell reversed (English fallback) */
} AttitudeStance;
/* Original guidance text for this project - see guidance.h's doc
* comment for why. Fallback (English) text only; German translations
* live in engine/i18n/de.lang under the matching "guidance.*" keys.
* Indexed by TransitCharacter. */
static const AttitudeStance k_stance[NUM_TRANSIT_CHARACTERS] = {
[CHAR_HARMONIOUS] = {
"harmonious",
"You're already meeting the day in the right spirit - lean into "
"it rather than second-guessing a favorable position.",
"The day itself is working in your favor even though you don't "
"feel settled yet - trust the momentum more than your current "
"doubts.",
},
[CHAR_DISCORDANT] = {
"discordant",
"Your instincts are sound, but the day is testing them - hold "
"your position without forcing the issue.",
"Both the day and your own footing are unsettled right now - "
"this is a moment to steady yourself before pushing forward.",
},
[CHAR_NEUTRAL] = {
"neutral",
"Today concentrates whatever you already bring to it - your "
"current approach will be amplified, so make sure it's the one "
"you want.",
"Today intensifies things, including whatever is currently "
"unresolved in your own approach - worth sorting out before "
"acting.",
},
};
static TransitCharacter classify_transit(AspectType type) {
if (type == ASPECT_TRINE || type == ASPECT_SEXTILE) return CHAR_HARMONIOUS;
if (type == ASPECT_SQUARE || type == ASPECT_OPPOSITION) return CHAR_DISCORDANT;
return CHAR_NEUTRAL;
}
static const char *stance_text(TransitCharacter character, bool reversed) {
const AttitudeStance *s = &k_stance[character];
char key[48];
snprintf(key, sizeof key, "guidance.stance.%s.%s", s->slug, reversed ? "reversed" : "upright");
return i18n_get(key, reversed ? s->reversed : s->upright);
}
/* How to introduce the top transit, tailored to how strongly it's
* scoring (interp->day_level) - independent of the stance sentence
* itself (k_stance, above), which is written to stay true regardless of
* intensity. Each entry takes exactly one %s: the transiting planet's
* display name, always as the sentence's grammatical subject (matters
* for German, where the other three day levels put the planet name in
* non-nominative position in a naive word-for-word translation - see
* engine/i18n/de.lang's guidance.intro.* entries, which are phrased to
* avoid that). Fallback (English) text only. */
static const char *const k_intro_slug[DAY_SIGNIFICANCE_COUNT] = {
[DAY_QUIET] = "quiet",
[DAY_NOTABLE] = "notable",
[DAY_SIGNIFICANT] = "significant",
[DAY_MAJOR] = "major",
};
static const char *const k_intro_fallback[DAY_SIGNIFICANCE_COUNT] = {
[DAY_QUIET] = "%s is only faintly active today, but for what it's worth:",
[DAY_NOTABLE] = "With a mild touch from %s today:",
[DAY_SIGNIFICANT] = "With %s clearly active today:",
[DAY_MAJOR] = "With %s as today's dominant influence:",
};
static const char *intro_template(DaySignificance level) {
char key[32];
snprintf(key, sizeof key, "guidance.intro.%s", k_intro_slug[level]);
return i18n_get(key, k_intro_fallback[level]);
}
/* Fallback for a day with no aspects in orb at all (top_item_count ==
* 0, always DAY_QUIET) - there's no transiting planet to name, so the
* guidance falls back to the Attitude card's orientation alone. */
static const char *no_transit_text(bool reversed) {
static const char *const upright =
"There's no standout transit today - it's an astrologically quiet "
"day. That leaves things mostly about steadiness: trust your "
"current footing and use the calm to make headway.";
static const char *const reversed_text =
"There's no standout transit today - it's an astrologically quiet "
"day. That's a good moment to quietly re-settle your own "
"footing, since nothing external is forcing the pace.";
return i18n_get(reversed ? "guidance.no_transit.reversed" : "guidance.no_transit.upright",
reversed ? reversed_text : upright);
}
static const char *reversed_marker(bool reversed) {
static char buf[32];
if (!reversed) return "";
snprintf(buf, sizeof buf, " %s", i18n_get("ui.reversed", "(Reversed)"));
return buf;
}
void guidance_print(FILE *out, const char *indent, const DailyInterpretation *interp,
const CelticCrossSpread *spread) {
const TarotDraw *attitude = &spread->positions[POSITION_ATTITUDE];
const TarotDraw *outcome = &spread->positions[POSITION_OUTCOME];
if (interp->top_item_count == 0) {
fprintf(out, "%s%s\n", indent, no_transit_text(attitude->reversed));
} else {
const Aspect *top = &interp->top_items[0].aspect;
TransitCharacter character = classify_transit(top->type);
const char *stance = stance_text(character, attitude->reversed);
char intro[192];
snprintf(intro, sizeof(intro), intro_template(interp->day_level),
planet_display_name(top->transiting_planet));
fprintf(out, "%s%s %s\n", indent, intro, stance);
}
fprintf(out, "%s%s %s%s: %s\n", indent, i18n_get("guidance.attitude_intro", "Your Attitude card is"),
tarot_card_name(attitude->card), reversed_marker(attitude->reversed),
tarot_card_meaning(attitude->card, attitude->reversed));
fprintf(out, "%s%s %s%s: %s\n", indent, i18n_get("guidance.outcome_intro", "The spread's likely Outcome is"),
tarot_card_name(outcome->card), reversed_marker(outcome->reversed),
tarot_card_meaning(outcome->card, outcome->reversed));
}
+60
View File
@@ -0,0 +1,60 @@
#ifndef DECK_GUIDANCE_H
#define DECK_GUIDANCE_H
#include <stdio.h>
/* Header-only dependency on the engine for struct/enum *definitions*,
* same policy as narrative.h/significance.h - see their comments for
* why. Unlike those, though, the root Makefile *does* link
* engine/src/tarot_data.c's *implementation* (tarot_card_name()/
* tarot_card_meaning()) into the interpreter binary - a deliberate,
* narrow exception; see the Makefile's own comment on
* INTERP_TAROT_TEXT_OBJS for why that doesn't compromise this module's
* independent testability. */
#include "../../engine/src/tarot.h"
#include "significance.h"
/* Ties the day's single most significant transit
* (interp->top_items[0]) to the Celtic Cross spread, and prints a short
* paragraph of concrete guidance on how to meet the day - the "combined
* storytelling" piece docs/reading.md flags as future work, now built.
* Prints on every day, quiet or major, tailored to interp->day_level -
* callers can invoke it unconditionally after every
* interpret_daily_reading() call.
*
* The core guidance keys off two things: the character of the top
* transit's aspect (harmonious/discordant/neutral, same classification
* narrative.c uses) and whether the spread's Attitude position - Waite's
* "Himself: his position or attitude in the circumstances", the
* position most directly about how the reader is meeting the day - fell
* upright or reversed. That crossing (3 x 2 = 6 combinations) is
* original text written for this project, not drawn from Waite or
* Sepharial, since neither source discusses combining astrology and
* tarot; it stays valid regardless of the day's intensity. What *does*
* vary with interp->day_level is only the sentence introducing the top
* transit (e.g. "With Saturn as today's dominant influence" on a Major
* day vs. "Saturn is only faintly active today, but for what it's
* worth" on a Quiet one). A day with no aspects in orb at all
* (interp->top_item_count == 0, always DAY_QUIET) has no transiting
* planet to introduce, so it falls back to a stance keyed on the
* Attitude card's orientation alone.
*
* The paragraph also names the Attitude and Outcome cards and, via
* tarot_card_meaning(), states their actual Waite meaning for the
* orientation they landed in (e.g. what Wheel of Fortune reversed
* means) - not a duplicated table, the real thing, since tarot_data.c
* is linked (see above).
*
* Every piece of this module's own text (stance/intro/no-transit/label
* strings) is looked up via i18n_get() under "guidance.*" keys
* (engine/i18n's .lang files) before falling back to the English text baked
* into guidance.c, so it respects whatever --lang the caller loaded
* with i18n_load() (main.c does so before calling this). The German
* guidance.intro.* templates are deliberately phrased so the planet
* name placeholder is always the sentence's grammatical subject
* (nominative case) across all four day levels - see guidance.c's own
* comment on k_intro_fallback. */
void guidance_print(FILE *out, const char *indent, const DailyInterpretation *interp,
const CelticCrossSpread *spread);
#endif
+410 -40
View File
@@ -1,24 +1,63 @@
#define _POSIX_C_SOURCE 200809L /* for open_memstream */
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../../engine/src/i18n.h"
#include "guidance.h"
#include "narrative.h"
#include "reading_io.h"
#include "significance.h"
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s [reading.json]\n\n"
"Usage: %s [--format text|html|json] [--lang <code>] [--i18n-dir <path>]\n"
" [reading.json]\n\n"
"Reads a DailyReading in deck-engine's --format json shape - from the\n"
"given file, or from stdin if no file is given (or it is \"-\") - and\n"
"prints a significance report: the day's overall level and the top\n"
"aspects driving it.\n\n"
"prints a full report: the day's overall significance level, the\n"
"top aspects driving it (each with a short narrative note), a full\n"
"readout of the Celtic Cross spread with every card's meaning, and\n"
"finally guidance tying the day's top transit to the spread's\n"
"Attitude/Outcome cards.\n\n"
" --format Output format, defaults to text. html links card art via\n"
" img/<file>, same convention as deck-engine's own html\n"
" output - save/run this next to the img/ directory\n"
" `make images` copies into dist/. json embeds the same\n"
" narrative/guidance prose as text (in whatever --lang was\n"
" loaded) alongside stable, language-independent slugs.\n"
" --lang Reading language code, defaults to en. Matches a file\n"
" named <code>.lang in --i18n-dir (see engine/i18n/) -\n"
" note this is this binary's OWN output language, not\n"
" related to whatever --lang deck-engine was run with to\n"
" produce its --format json input (which is always\n"
" language-independent - see docs/input-output-format.md).\n"
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
" i18n/ directory next to this binary (`make i18n`\n"
" copies engine/i18n/ there as dist/i18n/).\n\n"
"Typical use:\n"
" dist/deck-engine ... --format json | %s\n"
" dist/deck-engine ... --format json > reading.json && %s reading.json\n",
prog, prog, prog);
}
/* Same convention as engine/src/main.c's own default_i18n_path() -
* duplicated rather than shared, since the two binaries are never
* linked together (see CLAUDE.md's "Interpretation" section). */
static void default_i18n_path(const char *argv0, const char *lang, char *out, size_t out_size) {
const char *slash = strrchr(argv0, '/');
if (slash) {
int dir_len = (int)(slash - argv0);
snprintf(out, out_size, "%.*s/i18n/%s.lang", dir_len, argv0, lang);
} else {
snprintf(out, out_size, "i18n/%s.lang", lang);
}
}
static char *read_all(FILE *f, size_t *out_length) {
size_t cap = 4096;
size_t len = 0;
@@ -38,47 +77,391 @@ static char *read_all(FILE *f, size_t *out_length) {
return buf;
}
/* Display names for the report - deliberately a small local table rather
* than linking engine/src/astro.c's astro_body_name()/astro_aspect_name()
* (which would pull in the vendored Astronomy Engine just for cosmetic
* text). Same policy as reading_io.c's slug tables. */
static const char *ui(const char *key, const char *fallback) { return i18n_get(key, fallback); }
/* Slugs for i18n key building (body.<slug>/sign.<slug>/aspect.<slug>,
* mirroring astro.c's own tables) and reused as-is for --format json's
* language-independent fields - deliberately a small local table rather
* than linking astro.c (would pull in the vendored Astronomy Engine just
* for cosmetic text/slugs). Same policy as reading_io.c's own tables. */
static const char *const k_body_slug[NUM_BODIES] = {
"sun", "moon", "mercury", "venus", "mars",
"jupiter", "saturn", "uranus", "neptune", "pluto",
};
static const char *const k_sign_slug[12] = {
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
};
static const char *const k_aspect_slug[5] = {
"conjunction", "sextile", "square", "trine", "opposition",
};
static const char *const k_day_level_slug[DAY_SIGNIFICANCE_COUNT] = {
"quiet", "notable", "significant", "major",
};
static const char *body_display_name(Body body) {
static const char *const names[NUM_BODIES] = {
static const char *const fallback[NUM_BODIES] = {
"Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
};
return names[body];
char key[32];
snprintf(key, sizeof key, "body.%s", k_body_slug[body]);
return ui(key, fallback[body]);
}
static const char *aspect_display_name(AspectType type) {
static const char *const names[5] = {
static const char *const fallback[5] = {
"Conjunction", "Sextile", "Square", "Trine", "Opposition",
};
return names[type];
char key[32];
snprintf(key, sizeof key, "aspect.%s", k_aspect_slug[type]);
return ui(key, fallback[type]);
}
static const char *sign_display_name(ZodiacSign sign) {
static const char *const fallback[12] = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
};
char key[32];
snprintf(key, sizeof key, "sign.%s", k_sign_slug[sign]);
return ui(key, fallback[sign]);
}
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return "Quiet";
case DAY_NOTABLE: return "Notable";
case DAY_SIGNIFICANT: return "Significant";
case DAY_MAJOR: return "Major";
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return ui("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return ui("interp.day_level.significant", "Significant");
case DAY_MAJOR: return ui("interp.day_level.major", "Major");
default: return "Unknown";
}
return "Unknown";
}
static const char *reversed_marker(bool reversed) {
static char buf[32];
if (!reversed) return "";
snprintf(buf, sizeof buf, " %s", ui("ui.reversed", "(Reversed)"));
return buf;
}
/* pos->house is 0 when reading_load_json had no sign/house data for this
* body (see parse_bodies() in reading_io.c) - a valid whole-sign house
* is always 1-12, so this is a safe "unknown, say nothing" sentinel. */
static void print_body_in_sign_text(FILE *out, const PlanetPosition *pos) {
if (pos->house < 1 || pos->house > 12) return;
char buf[64];
snprintf(buf, sizeof buf, ui("interp.in_sign_house", "in %s (house %d)"),
sign_display_name(pos->sign), pos->house);
fprintf(out, " %s", buf);
}
/* Captures narrative_print()/guidance_print()'s output (they only know
* how to write to a FILE*) into a heap string, for embedding into JSON -
* avoids changing either module's public API just for this one caller.
* Strips a single trailing newline; caller must free() the result. */
static char *capture_narrative(const Aspect *aspect, int house) {
char *buf = NULL;
size_t size = 0;
FILE *mem = open_memstream(&buf, &size);
narrative_print(mem, "", aspect, house);
fclose(mem);
if (size > 0 && buf[size - 1] == '\n') buf[size - 1] = '\0';
return buf;
}
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
char *buf = NULL;
size_t size = 0;
FILE *mem = open_memstream(&buf, &size);
guidance_print(mem, "", interp, spread);
fclose(mem);
if (size > 0 && buf[size - 1] == '\n') buf[size - 1] = '\0';
return buf;
}
static void print_top_transits_header(FILE *out, int count) {
if (count == 1) {
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
} else {
char buf[64];
snprintf(buf, sizeof buf, ui("interp.top_transits.many", "Top %d significant transits:"), count);
fprintf(out, "%s\n", buf);
}
}
/* ===== --format text ===== */
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread) {
fprintf(out, "%s:\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &spread->positions[i];
fprintf(out, " %d. %s: %s%s\n", i + 1, tarot_position_name((CelticCrossPosition)i),
tarot_card_name(draw->card), reversed_marker(draw->reversed));
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
}
}
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
fprintf(out, "%s\n", ui("interp.major_framing",
"(a major transit today - worth a deeper Celtic Cross look)"));
}
fprintf(out, "\n");
if (interp->top_item_count == 0) {
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
print_top_transits_header(out, interp->top_item_count);
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
fprintf(out, " %d. %s %s", i + 1, ui("ui.transiting", "Transiting"),
body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
narrative_print(out, " ", a, reading->transits.bodies[a->transiting_planet].house);
}
}
fprintf(out, "\n");
print_celtic_cross_text(out, &reading->spread);
fprintf(out, "\n");
guidance_print(out, "", interp, &reading->spread);
}
/* ===== --format html ===== */
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out,
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
"<title>%s</title>\n"
"<style>\n"
"body { font-family: sans-serif; max-width: 900px; margin: 2em auto; }\n"
"table { border-collapse: collapse; margin-bottom: 1.5em; }\n"
"td { padding: 2px 10px 2px 0; }\n"
"h1, h2 { border-bottom: 1px solid #ccc; }\n"
".spread { display: flex; flex-wrap: wrap; gap: 1.5em; }\n"
".card { width: 160px; }\n"
".card img { width: 140px; display: block; }\n"
".card img.reversed { transform: rotate(180deg); }\n"
".card .position { font-weight: bold; }\n"
".card .name { font-style: italic; }\n"
".guidance { background: #f6f6f6; padding: 1em; border-radius: 6px; }\n"
"</style></head><body>\n",
ui("interp.page_title", "Deck in a Dash - Interpretation"));
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
ui("interp.heading_day_significance", "Day Significance"),
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
fprintf(out, "<br>%s", ui("interp.major_framing",
"(a major transit today - worth a deeper Celtic Cross look)"));
}
fprintf(out, "</p>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
fprintf(out, "<tr><td>%d.</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
i + 1, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet), ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name((CelticCrossPosition)i), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description((CelticCrossPosition)i),
tarot_card_meaning(draw->card, draw->reversed));
}
fprintf(out, "</div>\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n<div class=\"guidance\">\n", ui("interp.heading_guidance", "Guidance"));
const char *start = guidance;
for (const char *p = guidance; ; p++) {
if (*p == '\n' || *p == '\0') {
if (p > start) fprintf(out, "<p>%.*s</p>\n", (int)(p - start), start);
if (*p == '\0') break;
start = p + 1;
}
}
free(guidance);
fprintf(out, "</div>\n");
fprintf(out, "</body></html>\n");
}
/* ===== --format json ===== */
static void json_string(FILE *out, const char *s) {
fputc('"', out);
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
switch (c) {
case '"': fputs("\\\"", out); break;
case '\\': fputs("\\\\", out); break;
case '\n': fputs("\\n", out); break;
case '\r': fputs("\\r", out); break;
case '\t': fputs("\\t", out); break;
default:
if (c < 0x20) fprintf(out, "\\u%04x", c);
else fputc((char)c, out);
}
}
fputc('"', out);
}
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "{\n");
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
fprintf(out, " \"count\": %d,\n", DAY_SIGNIFICANCE_COUNT);
fprintf(out, " \"deserves_framing\": %s\n", interpretation_deserves_framing(interp) ? "true" : "false");
fprintf(out, " },\n");
fprintf(out, " \"significant_transits\": [\n");
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
const PlanetPosition *tp = &reading->transits.bodies[a->transiting_planet];
const PlanetPosition *np = &reading->natal.bodies[a->natal_planet];
char *text = capture_narrative(a, tp->house);
fprintf(out, " {\n");
fprintf(out, " \"transiting_planet\": \"%s\",\n", k_body_slug[a->transiting_planet]);
fprintf(out, " \"transiting_sign\": \"%s\",\n", k_sign_slug[tp->sign]);
fprintf(out, " \"transiting_house\": %d,\n", tp->house);
fprintf(out, " \"natal_planet\": \"%s\",\n", k_body_slug[a->natal_planet]);
fprintf(out, " \"natal_sign\": \"%s\",\n", k_sign_slug[np->sign]);
fprintf(out, " \"natal_house\": %d,\n", np->house);
fprintf(out, " \"aspect\": \"%s\",\n", k_aspect_slug[a->type]);
fprintf(out, " \"orb\": %.4f,\n", a->orb);
fprintf(out, " \"score\": %.4f,\n", item->score);
fprintf(out, " \"narrative\": ");
json_string(out, text);
fprintf(out, "\n }%s\n", i + 1 < interp->top_item_count ? "," : "");
free(text);
}
fprintf(out, " ],\n");
fprintf(out, " \"celtic_cross\": [\n");
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
fprintf(out, " {\"position\": \"%s\", \"card\": \"%s\", \"reversed\": %s, \"card_name\": ",
tarot_position_slug((CelticCrossPosition)i), tarot_card_slug(draw->card),
draw->reversed ? "true" : "false");
json_string(out, tarot_card_name(draw->card));
fprintf(out, ", \"meaning\": ");
json_string(out, tarot_card_meaning(draw->card, draw->reversed));
fprintf(out, "}%s\n", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
}
fprintf(out, " ],\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, " \"guidance\": ");
json_string(out, guidance);
fprintf(out, "\n");
free(guidance);
fprintf(out, "}\n");
}
int main(int argc, char **argv) {
if (argc > 2 || (argc == 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 0))) {
const char *format_str = "text";
const char *lang = "en", *i18n_dir_arg = NULL;
const char *input_path = NULL;
for (int i = 1; i < argc; i++) {
const char *arg = argv[i];
if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
print_usage(argv[0]);
return argc > 2 ? 1 : 0;
return 0;
} else if (strcmp(arg, "--format") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
format_str = argv[i];
} else if (strcmp(arg, "--lang") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
lang = argv[i];
} else if (strcmp(arg, "--i18n-dir") == 0) {
if (++i >= argc) { print_usage(argv[0]); return 1; }
i18n_dir_arg = argv[i];
} else if (arg[0] == '-' && strcmp(arg, "-") != 0) {
fprintf(stderr, "Unknown argument: %s\n", arg);
print_usage(argv[0]);
return 1;
} else if (input_path == NULL) {
input_path = arg;
} else {
print_usage(argv[0]);
return 1;
}
}
OutputFormat format;
if (strcmp(format_str, "text") == 0) format = FORMAT_TEXT;
else if (strcmp(format_str, "html") == 0) format = FORMAT_HTML;
else if (strcmp(format_str, "json") == 0) format = FORMAT_JSON;
else {
fprintf(stderr, "Invalid --format, expected text, html, or json\n");
return 1;
}
char i18n_path[512];
if (i18n_dir_arg) {
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
} else {
default_i18n_path(argv[0], lang, i18n_path, sizeof i18n_path);
}
if (!i18n_load(i18n_path) && strcmp(lang, "en") != 0) {
fprintf(stderr, "warning: could not load translations for '%s' from %s - "
"falling back to built-in English\n", lang, i18n_path);
}
FILE *in = stdin;
bool opened_file = false;
if (argc == 2 && strcmp(argv[1], "-") != 0) {
in = fopen(argv[1], "r");
if (input_path && strcmp(input_path, "-") != 0) {
in = fopen(input_path, "r");
if (!in) {
fprintf(stderr, "error: could not open %s\n", argv[1]);
fprintf(stderr, "error: could not open %s\n", input_path);
return 1;
}
opened_file = true;
@@ -99,25 +482,12 @@ int main(int argc, char **argv) {
DailyInterpretation interp;
interpret_daily_reading(&reading, &interp);
printf("Day significance: %s\n", day_level_name(interp.day_level));
if (interpretation_deserves_framing(&interp)) {
printf("(a major transit today - worth a deeper Celtic Cross look)\n");
}
printf("\n");
if (interp.top_item_count == 0) {
printf("No notable transits today.\n");
return 0;
}
printf("Top %d significant transit%s:\n", interp.top_item_count,
interp.top_item_count == 1 ? "" : "s");
for (int i = 0; i < interp.top_item_count; i++) {
const SignificantItem *item = &interp.top_items[i];
printf(" %d. Transiting %s %s natal %s (orb %.1f\xc2\xb0, score %.2f)\n", i + 1,
body_display_name(item->aspect.transiting_planet),
aspect_display_name(item->aspect.type),
body_display_name(item->aspect.natal_planet), item->aspect.orb, item->score);
if (format == FORMAT_HTML) {
interp_print_html(stdout, &reading, &interp);
} else if (format == FORMAT_JSON) {
interp_print_json(stdout, &reading, &interp);
} else {
interp_print_text(stdout, &reading, &interp);
}
return 0;
}
+164
View File
@@ -0,0 +1,164 @@
#include "narrative.h"
#include <stdbool.h>
#include <stdio.h>
/* i18n_get() only - narrative.h already pulls in astro.h; this is the
* one additional engine header needed to route k_narratives/
* k_house_area through the same translation catalog tarot_data.c uses
* (see the Makefile's INTERP_TAROT_TEXT_OBJS comment for why i18n.c is
* linked into this binary). */
#include "../../engine/src/i18n.h"
typedef struct {
const char *slug; /* builds "narrative.<slug>.*" i18n keys */
const char *base; /* always relevant, may be "" (see the Sun) */
const char *harmonious; /* shown only under a trine/sextile; may be "" */
const char *discordant; /* shown only under a square/opposition; may be "" */
} TransitNarrative;
/* Fallback (English) text, also the source of truth when no translation
* catalog is loaded - see narrative.h's doc comment for sourcing
* (Sepharial, "Transits and Planetary Periods", 1920, Chapter VIII) and
* the Moon/Pluto exception. German translations live in
* engine/i18n/de.lang under the same "narrative.<slug>.*" keys. */
static const TransitNarrative k_narratives[NUM_BODIES] = {
[PLANET_SUN] = {
"sun", "",
"Benefits from superiors and advancement in your sphere of "
"life and work - honours, emoluments, and successful new "
"associations.",
"Degradation and dishonour, loss of position, and adverse "
"judgement from superiors.",
},
[PLANET_MOON] = {
/* Original text, not from Sepharial - see narrative.h. */
"moon",
"Colours the everyday and domestic sphere of life, often "
"coinciding with the opening of new avenues.",
"These changes tend to be advantageous.",
"These changes tend to be adverse, with some indisposition "
"or domestic friction.",
},
[PLANET_MERCURY] = {
"mercury",
"Affects writings, journeys, commerce, and everyday activities - "
"a neutral messenger whose effect follows the nature of the "
"aspect it makes.",
"", "",
},
[PLANET_VENUS] = {
"venus",
"Brings domestic and social affairs to the fore - happiness, "
"comforts, and favours.",
"Success in love affairs and artistic pursuits is likely.",
"Grief and disappointment are more likely.",
},
[PLANET_MARS] = {
"mars",
"A strenuous time of quarrels, contention, strife and anger, with "
"some risk of hurts or injuries depending on the sign it "
"occupies.",
"Can bring benefits from doctors, surgeons, or new projects "
"and enterprises.",
"",
},
[PLANET_JUPITER] = {
"jupiter",
"Brings increase and expansion - fullness of fortune and health, "
"and a generally fortunate time.",
"", "",
},
[PLANET_SATURN] = {
"saturn",
"Brings depression, stagnation, hindrances and obstacles, and "
"some deprivation of the usual benefits.",
"Favours from older connections and past associations are "
"still possible.",
"",
},
[PLANET_URANUS] = {
"uranus",
"Brings separations, estrangements, sudden dislocations and "
"violent upsets.",
"Success through official or civic channels, and "
"beneficial changes or appointments, are possible.",
"",
},
[PLANET_NEPTUNE] = {
"neptune",
"Brings a state of chaos and confusion - an involved, uncertain "
"condition of affairs, with plots, subtlety, or unseen "
"influences at work.",
"", "",
},
[PLANET_PLUTO] = {
/* Original text, not from Sepharial - see narrative.h. */
"pluto",
"Brings deep, often hidden transformation - the surfacing or "
"dismantling of something that has outgrown its old form.",
"", "",
},
};
static bool aspect_is_harmonious(AspectType type) {
return type == ASPECT_TRINE || type == ASPECT_SEXTILE;
}
static bool aspect_is_discordant(AspectType type) {
return type == ASPECT_SQUARE || type == ASPECT_OPPOSITION;
}
/* Traditional whole-sign house significations - the "area of life" each
* house governs. This is centuries-old, uncredited astrological
* convention (the same status as the sign/aspect names already baked
* into engine/src/astro.c), not text drawn from Sepharial or any other
* single source. Indexed house - 1; fallback (English) text only, keyed
* as "narrative.house.<1-12>" in engine/i18n's .lang files. */
static const char *const k_house_area[12] = {
"your sense of self, identity, and outward appearance",
"money, possessions, and personal values",
"communication, siblings, and everyday learning",
"home, family, and your roots",
"romance, creativity, and children",
"daily work, routine, and health",
"partnerships and close relationships",
"shared resources, intimacy, and transformation",
"travel, higher learning, and beliefs",
"career, reputation, and public standing",
"friendships, community, and hopes for the future",
"solitude, the subconscious, and hidden matters",
};
static const char *narrative_field(const char *slug, const char *field, const char *fallback) {
char key[48];
snprintf(key, sizeof key, "narrative.%s.%s", slug, field);
return i18n_get(key, fallback);
}
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
int transiting_house) {
const TransitNarrative *n = &k_narratives[aspect->transiting_planet];
const char *base = narrative_field(n->slug, "base", n->base);
const char *extra = "";
if (aspect_is_harmonious(aspect->type)) extra = narrative_field(n->slug, "harmonious", n->harmonious);
else if (aspect_is_discordant(aspect->type)) extra = narrative_field(n->slug, "discordant", n->discordant);
if (base[0] == '\0' && extra[0] == '\0') return;
fprintf(out, "%s", indent);
if (transiting_house >= 1 && transiting_house <= 12) {
char house_key[24];
snprintf(house_key, sizeof house_key, "narrative.house.%d", transiting_house);
const char *area = i18n_get(house_key, k_house_area[transiting_house - 1]);
char frame[256];
snprintf(frame, sizeof frame, i18n_get("narrative.house_frame", "In matters of %s (house %d)."),
area, transiting_house);
fprintf(out, "%s ", frame);
}
if (base[0] != '\0') fprintf(out, "%s", base);
if (extra[0] != '\0') fprintf(out, "%s%s", base[0] != '\0' ? " " : "", extra);
fprintf(out, "\n");
}
+52
View File
@@ -0,0 +1,52 @@
#ifndef DECK_NARRATIVE_H
#define DECK_NARRATIVE_H
#include <stdio.h>
/* Header-only dependency on the engine, same policy as significance.h -
* see its comment for why. */
#include "../../engine/src/astro.h"
/* Condensed, public-domain descriptions of what each transiting planet
* classically signifies, sourced from Sepharial's "Transits and
* Planetary Periods" (1920, public domain - res/Transits_and_Planetary_
* Periods.pdf), Chapter VIII "Effects of Transits" (pp. 60-61). The
* chapter's own structure - a base description of the planet's transit,
* plus a "well aspected"/"badly aspected" addendum - maps directly onto
* transiting-planet + aspect-type, our existing Aspect fields, so no new
* engine data is needed to use it.
*
* The Moon and Pluto aren't covered by that chapter (fast lunar transits
* were outside a year-ahead-forecasting book's scope; Pluto wasn't
* discovered until 1930, a decade after this book) - their text in
* narrative.c is original, written for this project in a matching
* voice, not drawn from Sepharial.
*
* Prints a narrative sentence for `aspect`'s transiting planet to `out`,
* indented with `indent` and followed by a newline - or prints nothing
* if the source material has no text applicable to this planet/aspect
* combination (e.g. an exactly neutral conjunction to a planet whose
* only text is a "well/badly aspected" addendum, like the Sun). A
* conjunction is treated as neutral - neither the "harmonious" nor the
* "discordant" addendum is shown for it - since Sepharial's own text
* says a conjunction "takes the nature of what it conjoins," something
* this data doesn't model.
*
* `transiting_house` is the whole-sign house (1-12) the transiting
* planet currently occupies in the natal chart - i.e.
* reading->transits.bodies[aspect->transiting_planet].house - and names
* *which area of life* the transit is playing out in, the standard,
* personalized way transits are read (see astro.h's own house-field
* comment). Pass a value outside 1-12 (e.g. the "no data" sentinel `0`
* used elsewhere - see reading_io.c's parse_bodies()) to omit that
* framing and print the planet's narrative on its own.
*
* Every string here is looked up via i18n_get() under "narrative.*"
* keys (engine/i18n's .lang files) before falling back to the English text
* baked into narrative.c, the same catalog tarot_data.c uses - so this
* text respects whatever --lang the caller loaded with i18n_load()
* (main.c does so before calling this). */
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
int transiting_house);
#endif
+103
View File
@@ -17,6 +17,25 @@ static const char *const k_aspect_slug[5] = {
"conjunction", "sextile", "square", "trine", "opposition",
};
static const char *const k_sign_slug[12] = {
"aries", "taurus", "gemini", "cancer", "leo", "virgo",
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
};
/* Mirrors tarot_data.c's own k_card_slug/k_position_slug, same
* duplication policy as k_body_slug above. */
static const char *const k_card_slug[TAROT_DECK_SIZE] = {
"fool", "magician", "high_priestess", "empress", "emperor", "hierophant",
"lovers", "chariot", "strength", "hermit", "wheel_of_fortune", "justice",
"hanged_man", "death", "temperance", "devil", "tower", "star", "moon",
"sun", "judgement", "world",
};
static const char *const k_position_slug[TAROT_SPREAD_SIZE] = {
"present", "challenge", "crown", "foundation", "recent_past",
"near_future", "attitude", "environment", "hopes_and_fears", "outcome",
};
static bool body_from_slug(const char *slug, Body *out) {
for (int i = 0; i < NUM_BODIES; i++) {
if (strcmp(slug, k_body_slug[i]) == 0) {
@@ -27,6 +46,41 @@ static bool body_from_slug(const char *slug, Body *out) {
return false;
}
static bool sign_from_slug(const char *slug, ZodiacSign *out) {
for (int i = 0; i < 12; i++) {
if (strcmp(slug, k_sign_slug[i]) == 0) {
*out = (ZodiacSign)i;
return true;
}
}
return false;
}
/* Fills out[NUM_BODIES] from a "bodies" JSON array, the shape shared by
* both "natal" and "transits" (see reading_print_json in engine/src/
* reading.c). Used only for sign/house context in significant-event
* reporting, not for scoring, so this is deliberately lenient: an entry
* with an unrecognized/missing "body" slug is skipped, and a missing
* "house" leaves that body's house at 0 - not a valid whole-sign house
* number (always 1-12), so callers use house == 0 as "no position data
* available" rather than this failing the whole load. */
static void parse_bodies(const JsonValue *bodies, PlanetPosition out[NUM_BODIES]) {
int count = json_array_count(bodies);
for (int i = 0; i < count; i++) {
const JsonValue *item = json_array_get(bodies, i);
Body body;
if (!body_from_slug(json_as_string(json_object_get(item, "body")), &body)) continue;
ZodiacSign sign;
if (sign_from_slug(json_as_string(json_object_get(item, "sign")), &sign)) {
out[body].sign = sign;
}
out[body].degree_in_sign = json_as_number(json_object_get(item, "degree_in_sign"));
out[body].ecliptic_longitude = json_as_number(json_object_get(item, "ecliptic_longitude"));
out[body].house = (int)json_as_number(json_object_get(item, "house"));
}
}
static bool aspect_type_from_slug(const char *slug, AspectType *out) {
for (int i = 0; i < 5; i++) {
if (strcmp(slug, k_aspect_slug[i]) == 0) {
@@ -37,6 +91,48 @@ static bool aspect_type_from_slug(const char *slug, AspectType *out) {
return false;
}
static bool card_from_slug(const char *slug, TarotCard *out) {
for (int i = 0; i < TAROT_DECK_SIZE; i++) {
if (strcmp(slug, k_card_slug[i]) == 0) {
*out = (TarotCard)i;
return true;
}
}
return false;
}
static bool position_from_slug(const char *slug, CelticCrossPosition *out) {
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
if (strcmp(slug, k_position_slug[i]) == 0) {
*out = (CelticCrossPosition)i;
return true;
}
}
return false;
}
/* Fills out->positions[] from a "spread"."positions" JSON array. Used only
* for guidance_print()'s Attitude/Outcome framing, never for scoring, so
* this is deliberately lenient like parse_bodies(): an entry with an
* unrecognized/missing "position" or "card" slug is skipped, and a
* missing "spread" key entirely just leaves every position zeroed
* (card = the first enum value, reversed = false) rather than failing
* the whole load. */
static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
int count = json_array_count(positions);
for (int i = 0; i < count; i++) {
const JsonValue *item = json_array_get(positions, i);
CelticCrossPosition position;
TarotCard card;
if (!position_from_slug(json_as_string(json_object_get(item, "position")), &position)) continue;
if (!card_from_slug(json_as_string(json_object_get(item, "card")), &card)) continue;
out->positions[position].card = card;
const JsonValue *reversed = json_object_get(item, "reversed");
out->positions[position].reversed = reversed && reversed->type == JSON_BOOL && reversed->as.boolean;
}
}
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
memset(out, 0, sizeof(*out));
@@ -78,6 +174,13 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
}
out->transits.aspect_count = filled;
const JsonValue *natal = json_object_get(root, "natal");
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies);
const JsonValue *spread = json_object_get(root, "spread");
parse_spread(spread ? json_object_get(spread, "positions") : NULL, &out->spread);
json_free(root);
return ok;
}
+12 -5
View File
@@ -10,14 +10,21 @@
/* Parses deck-engine's `--format json` output (text, null-terminated at
* text[length]) and fills *out with just enough of a DailyReading for
* interpret_daily_reading() to work on: out->transits.aspects[]/
* aspect_count. out->natal and out->spread are left zeroed - nothing
* here reads them (see significance.c), so there's no reason to parse
* the rest of the document yet.
* interpret_daily_reading() to work on, for reporting sign/house context
* around each significant event, and for guidance_print()'s tarot
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
* house per body, used only for display), and out->spread.positions[]
* (card + reversed per Celtic Cross position, used only by
* guidance.c). out->natal.ascendant_longitude/houses[] are left zeroed -
* nothing reads them yet.
*
* Returns false on malformed JSON, or if "transits"."aspects" isn't
* present as an array (an empty array is fine - that's a real "no
* aspects today" reading, not an error). */
* aspects today" reading, not an error). A missing/unrecognized entry in
* "natal"."bodies"/"transits"."bodies"/"spread"."positions", or a
* missing "spread" key entirely, is not an error - see parse_bodies()/
* parse_spread() in reading_io.c. */
bool reading_load_json(const char *text, size_t length, DailyReading *out);
#endif
+4 -1
View File
@@ -34,7 +34,10 @@ typedef enum {
DAY_QUIET = 0,
DAY_NOTABLE,
DAY_SIGNIFICANT,
DAY_MAJOR
DAY_MAJOR,
DAY_SIGNIFICANCE_COUNT /* not a real level - the number of levels above,
* for callers that want to show day_level as a
* "rank out of N" (e.g. main.c's report). */
} DaySignificance;
typedef struct {
+219
View File
@@ -0,0 +1,219 @@
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "../../engine/src/i18n.h"
#include "../src/guidance.h"
static const char *slurp(FILE *f) {
static char buf[4096];
long len = ftell(f);
rewind(f);
size_t n = fread(buf, 1, (size_t)len, f);
buf[n] = '\0';
return buf;
}
static DailyInterpretation make_interp(DaySignificance level, AspectType type, Body transiting) {
DailyInterpretation interp;
interp.top_item_count = 1;
interp.day_level = level;
interp.top_items[0].kind = ITEM_ASPECT;
interp.top_items[0].score = 9.5;
interp.top_items[0].aspect.transiting_planet = transiting;
interp.top_items[0].aspect.natal_planet = PLANET_MOON;
interp.top_items[0].aspect.type = type;
interp.top_items[0].aspect.orb = 0.5;
return interp;
}
static DailyInterpretation make_empty_interp(void) {
DailyInterpretation interp;
interp.top_item_count = 0;
interp.day_level = DAY_QUIET;
return interp;
}
static CelticCrossSpread make_spread(TarotCard attitude_card, bool attitude_reversed,
TarotCard outcome_card, bool outcome_reversed) {
CelticCrossSpread spread;
spread.positions[POSITION_ATTITUDE].card = attitude_card;
spread.positions[POSITION_ATTITUDE].reversed = attitude_reversed;
spread.positions[POSITION_OUTCOME].card = outcome_card;
spread.positions[POSITION_OUTCOME].reversed = outcome_reversed;
return spread;
}
static void test_harmonious_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_TRINE, PLANET_JUPITER);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "already meeting the day in the right spirit") != NULL);
assert(strstr(text, "Jupiter") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
assert(strstr(text, "(Reversed)") == NULL);
fclose(f);
printf("PASS test_harmonious_upright\n");
}
static void test_discordant_reversed(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_DEVIL, true, CARD_WORLD, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "Both the day and your own footing are unsettled") != NULL);
assert(strstr(text, "Saturn") != NULL);
assert(strstr(text, "The Devil (Reversed): Evil fatality") != NULL);
assert(strstr(text, "The World: Assured success") != NULL);
fclose(f);
printf("PASS test_discordant_reversed\n");
}
static void test_neutral_conjunction_upright(void) {
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_CONJUNCTION, PLANET_PLUTO);
CelticCrossSpread spread = make_spread(CARD_EMPEROR, false, CARD_MOON, true);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "Today concentrates whatever you already bring to it") != NULL);
assert(strstr(text, "Pluto") != NULL);
assert(strstr(text, "The Emperor: Stability, power") != NULL);
assert(strstr(text, "The Moon (Reversed): Instability, inconstancy") != NULL);
fclose(f);
printf("PASS test_neutral_conjunction_upright\n");
}
/* The stance sentence itself doesn't change with intensity - only the
* sentence introducing the transiting planet does (see k_intro in
* guidance.c). These three cover the remaining day levels below Major
* (which test_harmonious_upright already covers). */
static void test_significant_day_uses_significant_intro(void) {
DailyInterpretation interp = make_interp(DAY_SIGNIFICANT, ASPECT_SEXTILE, PLANET_VENUS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "clearly active today") != NULL);
assert(strstr(text, "Venus") != NULL);
fclose(f);
printf("PASS test_significant_day_uses_significant_intro\n");
}
static void test_notable_day_uses_notable_intro(void) {
DailyInterpretation interp = make_interp(DAY_NOTABLE, ASPECT_SEXTILE, PLANET_MARS);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "mild touch from Mars") != NULL);
fclose(f);
printf("PASS test_notable_day_uses_notable_intro\n");
}
static void test_quiet_day_with_a_faint_item_still_names_it(void) {
DailyInterpretation interp = make_interp(DAY_QUIET, ASPECT_SEXTILE, PLANET_MERCURY);
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "only faintly active today") != NULL);
assert(strstr(text, "Mercury") != NULL);
fclose(f);
printf("PASS test_quiet_day_with_a_faint_item_still_names_it\n");
}
static void test_no_aspects_at_all_falls_back_to_attitude_only(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, false, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "astrologically quiet day") != NULL);
assert(strstr(text, "trust your current footing") != NULL);
assert(strstr(text, "The Star: Loss, theft") != NULL);
assert(strstr(text, "The Sun: Material happiness") != NULL);
fclose(f);
printf("PASS test_no_aspects_at_all_falls_back_to_attitude_only\n");
}
static void test_no_aspects_at_all_respects_reversed_attitude(void) {
DailyInterpretation interp = make_empty_interp();
CelticCrossSpread spread = make_spread(CARD_STAR, true, CARD_SUN, false);
FILE *f = tmpfile();
guidance_print(f, "", &interp, &spread);
const char *text = slurp(f);
assert(strstr(text, "quietly re-settle your own footing") != NULL);
fclose(f);
printf("PASS test_no_aspects_at_all_respects_reversed_attitude\n");
}
/* Proves guidance_print() actually routes through i18n_get() rather
* than just printing the hardcoded fallback. Run last: i18n_load()
* replaces the process-global catalog for the rest of the binary's
* lifetime. */
static void test_translation_catalog_overrides_fallback_text(void) {
char path[] = "/tmp/deck_guidance_test_XXXXXX";
int fd = mkstemp(path);
assert(fd >= 0);
FILE *f = fdopen(fd, "w");
fprintf(f, "guidance.stance.discordant.upright=UEBERSETZTE HALTUNG\n");
fprintf(f, "guidance.intro.major=UEBERSETZTE EINLEITUNG %%s\n");
fprintf(f, "guidance.planet.saturn=UEBERSETZTER SATURN\n");
fprintf(f, "guidance.attitude_intro=UEBERSETZTE HALTUNGSKARTE\n");
fclose(f);
assert(i18n_load(path));
unlink(path);
DailyInterpretation interp = make_interp(DAY_MAJOR, ASPECT_SQUARE, PLANET_SATURN);
CelticCrossSpread spread = make_spread(CARD_SUN, false, CARD_WORLD, false);
FILE *out = tmpfile();
guidance_print(out, "", &interp, &spread);
const char *text = slurp(out);
assert(strstr(text, "UEBERSETZTE HALTUNG") != NULL);
assert(strstr(text, "UEBERSETZTE EINLEITUNG UEBERSETZTER SATURN") != NULL);
assert(strstr(text, "UEBERSETZTE HALTUNGSKARTE") != NULL);
assert(strstr(text, "Your instincts are sound") == NULL); /* English fallback must not leak through */
fclose(out);
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
int main(void) {
test_harmonious_upright();
test_discordant_reversed();
test_neutral_conjunction_upright();
test_significant_day_uses_significant_intro();
test_notable_day_uses_notable_intro();
test_quiet_day_with_a_faint_item_still_names_it();
test_no_aspects_at_all_falls_back_to_attitude_only();
test_no_aspects_at_all_respects_reversed_attitude();
test_translation_catalog_overrides_fallback_text();
printf("All guidance tests passed.\n");
return 0;
}
+75 -6
View File
@@ -41,21 +41,26 @@ static void test_json_parse_rejects_malformed(void) {
}
/* A trimmed-but-structurally-faithful fixture matching deck-engine's real
* --format json shape: natal/spread sections are present with decoy
* content the loader must skip over without understanding their schema,
* to prove it navigates by key path rather than assuming any position. */
* --format json shape. "natal"/"transits" bodies are real (if partial) -
* reading_load_json parses sign/house from them for display, alongside
* the aspects used for scoring. "spread" only fills in the Attitude and
* Outcome positions (the two guidance.c reads); the other eight are
* absent entirely, to prove partial spread data doesn't fail the load. */
static const char *k_fixture =
"{"
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\"}], \"houses\": []},"
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
" \"transits\": {"
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\"}],"
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
" \"moon_phase\": \"full\","
" \"aspects\": ["
" {\"transiting_planet\": \"pluto\", \"natal_planet\": \"moon\", \"type\": \"opposition\", \"orb\": 0.2},"
" {\"transiting_planet\": \"saturn\", \"natal_planet\": \"sun\", \"type\": \"square\", \"orb\": 0.5}"
" ]"
" },"
" \"spread\": {\"positions\": [{\"position\": \"present\", \"card\": \"devil\", \"reversed\": false}]}"
" \"spread\": {\"positions\": ["
" {\"position\": \"attitude\", \"card\": \"devil\", \"reversed\": true},"
" {\"position\": \"outcome\", \"card\": \"world\", \"reversed\": false}"
" ]}"
"}";
static void test_reading_load_json_extracts_aspects(void) {
@@ -75,6 +80,66 @@ static void test_reading_load_json_extracts_aspects(void) {
printf("PASS test_reading_load_json_extracts_aspects\n");
}
static void test_reading_load_json_extracts_body_sign_and_house(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
assert(reading.natal.bodies[PLANET_SUN].sign == SIGN_TAURUS);
assert(reading.natal.bodies[PLANET_SUN].house == 3);
assert(reading.transits.bodies[PLANET_SUN].sign == SIGN_CANCER);
assert(reading.transits.bodies[PLANET_SUN].house == 5);
/* Bodies missing from the fixture (e.g. the Moon) get the "no data"
* sentinel rather than a garbage house number. */
assert(reading.natal.bodies[PLANET_MOON].house == 0);
assert(reading.transits.bodies[PLANET_MOON].house == 0);
printf("PASS test_reading_load_json_extracts_body_sign_and_house\n");
}
static void test_reading_load_json_extracts_spread_positions(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
assert(reading.spread.positions[POSITION_ATTITUDE].card == CARD_DEVIL);
assert(reading.spread.positions[POSITION_ATTITUDE].reversed == true);
assert(reading.spread.positions[POSITION_OUTCOME].card == CARD_WORLD);
assert(reading.spread.positions[POSITION_OUTCOME].reversed == false);
/* Positions absent from the fixture (e.g. Present) get the zeroed
* default (CARD_FOOL, upright) rather than garbage. */
assert(reading.spread.positions[POSITION_PRESENT].card == CARD_FOOL);
assert(reading.spread.positions[POSITION_PRESENT].reversed == false);
printf("PASS test_reading_load_json_extracts_spread_positions\n");
}
static void test_reading_load_json_missing_spread_is_not_fatal(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.spread.positions[POSITION_OUTCOME].card == CARD_FOOL);
printf("PASS test_reading_load_json_missing_spread_is_not_fatal\n");
}
static void test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(void) {
const char *text =
"{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]},"
" \"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.transits.aspect_count == 0);
printf("PASS test_reading_load_json_unknown_body_slug_is_skipped_not_fatal\n");
}
static void test_reading_load_json_empty_aspects_is_valid(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
@@ -113,6 +178,10 @@ int main(void) {
test_json_parse_basic_shapes();
test_json_parse_rejects_malformed();
test_reading_load_json_extracts_aspects();
test_reading_load_json_extracts_body_sign_and_house();
test_reading_load_json_extracts_spread_positions();
test_reading_load_json_missing_spread_is_not_fatal();
test_reading_load_json_unknown_body_slug_is_skipped_not_fatal();
test_reading_load_json_empty_aspects_is_valid();
test_reading_load_json_rejects_missing_transits();
test_reading_load_json_rejects_unknown_slug();
+162
View File
@@ -0,0 +1,162 @@
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "../../engine/src/i18n.h"
#include "../src/narrative.h"
static const char *slurp(FILE *f) {
static char buf[4096];
long len = ftell(f);
rewind(f);
size_t n = fread(buf, 1, (size_t)len, f);
buf[n] = '\0';
return buf;
}
static void test_hard_aspect_shows_base_but_not_harmonious_bonus(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") == NULL);
fclose(f);
printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n");
}
static void test_soft_aspect_adds_harmonious_bonus(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
.type = ASPECT_TRINE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") != NULL);
fclose(f);
printf("PASS test_soft_aspect_adds_harmonious_bonus\n");
}
static void test_conjunction_is_neutral_prints_base_only(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "depression") != NULL);
assert(strstr(text, "past associations") == NULL);
fclose(f);
printf("PASS test_conjunction_is_neutral_prints_base_only\n");
}
/* The Sun has no unconditional base text (see narrative.c) - a neutral
* conjunction to it should print nothing at all. */
static void test_empty_base_and_neutral_aspect_prints_nothing(void) {
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
long len = ftell(f);
assert(len == 0);
fclose(f);
printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n");
}
static void test_discordant_aspect_on_empty_base_planet(void) {
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
.type = ASPECT_OPPOSITION, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0);
const char *text = slurp(f);
assert(strstr(text, "Degradation") != NULL);
fclose(f);
printf("PASS test_discordant_aspect_on_empty_base_planet\n");
}
static void test_known_house_adds_area_framing(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 3);
const char *text = slurp(f);
assert(strstr(text, "house 3") != NULL);
assert(strstr(text, "communication") != NULL);
assert(strstr(text, "depression") != NULL);
fclose(f);
printf("PASS test_known_house_adds_area_framing\n");
}
static void test_unknown_house_omits_area_framing(void) {
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_SQUARE, .orb = 1.0 };
FILE *f = tmpfile();
narrative_print(f, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
const char *text = slurp(f);
assert(strstr(text, "house") == NULL);
assert(strstr(text, "depression") != NULL);
fclose(f);
printf("PASS test_unknown_house_omits_area_framing\n");
}
/* Proves narrative_print() actually routes through i18n_get() rather
* than just printing the hardcoded fallback - loads a translation file
* that overrides one planet's base text and one house's area text, and
* checks the override wins. Run last: i18n_load() replaces the
* process-global catalog for the rest of the test binary's lifetime. */
static void test_translation_catalog_overrides_fallback_text(void) {
char path[] = "/tmp/deck_narrative_test_XXXXXX";
int fd = mkstemp(path);
assert(fd >= 0);
FILE *f = fdopen(fd, "w");
fprintf(f, "narrative.saturn.base=UEBERSETZTER SATURN TEXT\n");
fprintf(f, "narrative.house.3=UEBERSETZTES HAUS DREI\n");
fclose(f);
assert(i18n_load(path));
unlink(path);
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
FILE *out = tmpfile();
narrative_print(out, "", &a, 3);
const char *text = slurp(out);
assert(strstr(text, "UEBERSETZTER SATURN TEXT") != NULL);
assert(strstr(text, "UEBERSETZTES HAUS DREI") != NULL);
assert(strstr(text, "depression") == NULL); /* English fallback must not leak through */
fclose(out);
printf("PASS test_translation_catalog_overrides_fallback_text\n");
}
int main(void) {
test_hard_aspect_shows_base_but_not_harmonious_bonus();
test_soft_aspect_adds_harmonious_bonus();
test_conjunction_is_neutral_prints_base_only();
test_empty_base_and_neutral_aspect_prints_nothing();
test_discordant_aspect_on_empty_base_planet();
test_known_house_adds_area_framing();
test_unknown_house_omits_area_framing();
test_translation_catalog_overrides_fallback_text();
printf("All narrative tests passed.\n");
return 0;
}
Binary file not shown.