Add core engine: tarot + astrology reading generator with CLI and i18n

Plain-C, dependency-free engine that produces a personalized daily
Celtic Cross tarot spread and natal-chart/transit astrology reading,
plus a standalone CLI (dist/deck-engine) and run.sh wrapper. Includes
English/German output via a simple key=value translation format, and
docs/diagrams describing the architecture and I/O.
This commit is contained in:
ml
2026-07-04 06:49:18 +02:00
commit e4ea31270f
59 changed files with 17134 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
# Architecture
See [`CLAUDE.md`](../CLAUDE.md) for build commands and the sharp edges to
know before changing the engine. This document is the visual overview.
## Components
![Component architecture](images/architecture.png)
- **`res/`** holds the source assets: the 22 Rider-Waite-Smith Major
Arcana card images and A. E. Waite's *Pictorial Key to the Tarot* PDF
(public domain), which `tarot_data.c`'s card and position text is
condensed from.
- **`engine/third_party/astronomy/`** is a vendored, unmodified copy of
[cosinekitty/astronomy](https://github.com/cosinekitty/astronomy)
(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.
- `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
Astronomy Engine.
- `i18n.c` — loads a `key=value` translation file into a small
fixed-size catalog; every display-text lookup elsewhere falls back to
the built-in English string if no catalog is loaded or a key is
missing from it.
- `reading.c` — orchestrates the above into one `DailyReading` and
(desktop-only) renders it as text or HTML.
- `main.c` — CLI argument parsing, the only consumer of `reading_print_*`.
- **`engine/i18n/`** holds the translation source files (`en.lang`,
`de.lang`, ...), one `key=value` file per language — see
[`CLAUDE.md`](../CLAUDE.md) for the key-naming convention.
- **`dist/`** is generated by `make`: the `deck-engine` binary, a copy of
the card art under `img/`, a copy of the translation files under
`i18n/`, `run.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_*`.
## Dataflow
![Dataflow for one reading](images/dataflow.png)
A single reading is produced by one call: `reading_generate(seed, birth,
utc_moment, &out)` in `reading.c`. It fans out to three independent
computations and combines their results:
1. **`astro_compute_natal_chart(birth)`** → `NatalChart` — geocentric
ecliptic sign/degree for the Sun through Pluto, the Ascendant, and
whole-sign houses, all from the birth date/time/location.
2. **`astro_compute_daily_transits(utc_moment, natal_chart)`** →
`DailyTransits` — today's planetary positions, the Moon phase, and
every transiting-to-natal aspect within orb. Depends on the natal
chart (read-only) to compute aspects against it.
3. **`tarot_draw_celtic_cross(seed)`** → `CelticCrossSpread` — ten cards
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
[`input-output-format.md`](input-output-format.md) for the exact fields
and output formats.
Every display string produced along the way (`tarot_data.c`'s card and
position text, `astro.c`'s body/sign/moon-phase/aspect names, and
`reading.c`'s section headers) is looked up in `i18n.c`'s translation
catalog, with the built-in English text as the fallback. `main.c` loads
the catalog from `--lang`/`--i18n-dir` (or `run.sh`'s `lang=` property)
before calling `reading_generate`, so this is orthogonal to the three
computations above — it only affects how their results are rendered as
text, not the underlying `TarotCard`/`ZodiacSign`/etc. enum values.
Both entry points (direct CLI flags, or `run.sh` pulling the seed/date
from the OS clock and birth data from `user.properties`) converge on the
same `main.c` argument parsing before calling `reading_generate` — there
is exactly one code path from parsed input to a reading.
## Regenerating the diagrams
The diagrams are Graphviz sources in `docs/diagrams/`, rendered to PNG:
```bash
cd docs
dot -Tpng -Gdpi=150 diagrams/architecture.dot -o images/architecture.png
dot -Tpng -Gdpi=150 diagrams/dataflow.dot -o images/dataflow.png
```
+83
View File
@@ -0,0 +1,83 @@
digraph architecture {
rankdir=LR;
pad="0.3";
fontname="Helvetica";
node [shape=box, style="rounded,filled", fontname="Helvetica", fontsize=12, margin="0.15,0.1"];
edge [fontname="Helvetica", fontsize=10];
labelloc="t";
label="Deck in a Dash — component architecture";
fontsize=16;
subgraph cluster_res {
label="res/ (source assets)";
style="dashed"; color=gray40; fontsize=11;
images [label="22 RWS Major Arcana\nJPEGs (res/img/)", fillcolor="#fdf3d0"];
pdf [label="Waite's \"Pictorial Key\nto the Tarot\" (PDF,\npublic domain)", fillcolor="#fdf3d0"];
{ rank=same; images; pdf; }
}
subgraph cluster_thirdparty {
label="engine/third_party/astronomy/";
style="dashed"; color=gray40; fontsize=11;
astronomy [label="Astronomy Engine\n(vendored, MIT,\npinned commit)", fillcolor="#fdf3d0"];
}
subgraph cluster_i18nsrc {
label="engine/i18n/ (translation sources)";
style="dashed"; color=gray40; fontsize=11;
langfiles [label="en.lang, de.lang, ...\nkey=value per language", fillcolor="#fdf3d0"];
}
subgraph cluster_engine {
label="engine/src/ (core engine — plain C, no I/O assumptions except reading_print_*/main.c/i18n_load)";
style="solid"; color=black; fontsize=11;
rng [label="rng.c\nstring-seeded PRNG\n(FNV-1a + splitmix64)", fillcolor="#cfe8fb"];
tarot [label="tarot.c\nCeltic Cross draw", fillcolor="#cfe8fb"];
tarot_data [label="tarot_data.c\ncard + position text", fillcolor="#cfe8fb"];
astro [label="astro.c\nnatal chart + transits", fillcolor="#cfe8fb"];
i18n [label="i18n.c\ntranslation catalog\n+ English fallback", fillcolor="#cfe8fb"];
reading [label="reading.c\nDailyReading\norchestration + text/HTML", fillcolor="#c8f0c8"];
main [label="main.c\nCLI arg parsing", fillcolor="#e3e3e3"];
{ rank=same; main; reading; }
{ rank=same; tarot; astro; i18n; }
}
subgraph cluster_dist {
label="dist/ (build output, generated by `make`)";
style="dashed"; color=gray40; fontsize=11;
binary [label="deck-engine", fillcolor="#fde0d0"];
distimg [label="img/\n(copied JPEGs)", fillcolor="#fde0d0"];
disti18n [label="i18n/\n(copied .lang files)", fillcolor="#fde0d0"];
runsh [label="run.sh", fillcolor="#fde0d0"];
props [label="user.properties\n(birth data + lang)", fillcolor="#fde0d0"];
{ 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;
tarot -> tarot_data;
astro -> astronomy;
tarot_data -> i18n [label="looks up\ntranslated text", style=dotted, color=gray40];
astro -> i18n [label="looks up\ntranslated text", style=dotted, color=gray40];
reading -> i18n [label="looks up\nUI labels", style=dotted, color=gray40];
reading -> tarot;
reading -> astro;
main -> reading [label="CLI only"];
main -> i18n [label="i18n_load(\n--lang, --i18n-dir)"];
main -> binary [label="`make` builds", style=dotted, color=gray40];
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];
}
+67
View File
@@ -0,0 +1,67 @@
digraph dataflow {
rankdir=TB;
pad="0.3";
fontname="Helvetica";
node [fontname="Helvetica", fontsize=12, margin="0.15,0.1"];
edge [fontname="Helvetica", fontsize=10];
labelloc="t";
label="Deck in a Dash — dataflow for one reading";
fontsize=16;
node [shape=box, style="rounded,filled"];
subgraph cluster_inputs {
label="Inputs (two equivalent entry points)"; style=dashed; color=gray40; fontsize=11;
cli [label="Direct CLI flags\n--seed --birth-* --date --format", fillcolor="#e3e3e3"];
runsh [label="run.sh:\nOS clock -> seed + --date\nuser.properties -> birth-*", fillcolor="#e3e3e3"];
}
main [label="main.c\nparses args into:\nseed (string), BirthData, utc_moment", fillcolor="#cfe8fb"];
reading_generate [label="reading_generate()\nreading.c", shape=box, style="rounded,filled", fillcolor="#9fd3f5", peripheries=2];
natal_fn [label="astro_compute_natal_chart()\nastro.c", fillcolor="#cfe8fb"];
transit_fn [label="astro_compute_daily_transits()\nastro.c", fillcolor="#cfe8fb"];
tarot_fn [label="tarot_draw_celtic_cross()\ntarot.c", fillcolor="#cfe8fb"];
natal_data [label="NatalChart\nbodies[10]: sign + degree\nascendant_longitude\nhouses[12] (whole-sign)", shape=note, fillcolor="#fdf3d0"];
transit_data [label="DailyTransits\nbodies[10]\nmoon_phase\naspects[]: transiting/natal\nplanet, type, orb", shape=note, fillcolor="#fdf3d0"];
spread_data [label="CelticCrossSpread\npositions[10]: card, reversed", shape=note, fillcolor="#fdf3d0"];
daily_reading [label="DailyReading\n{ natal, transits, spread }", shape=note, fillcolor="#fbe3c8", peripheries=2];
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;
main -> reading_generate [label="seed, birth,\nutc_moment"];
reading_generate -> natal_fn [label="birth"];
natal_fn -> natal_data;
reading_generate -> transit_fn [label="utc_moment,\nNatalChart"];
natal_data -> transit_fn [style=dotted, color=gray40, label="natal chart\nread-only"];
transit_fn -> transit_data;
reading_generate -> tarot_fn [label="seed"];
tarot_fn -> spread_data;
natal_data -> daily_reading;
transit_data -> daily_reading;
spread_data -> daily_reading;
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.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

+155
View File
@@ -0,0 +1,155 @@
# Input & output formats
See [`architecture.md`](architecture.md) for how these fit together.
There are two ways to provide input (direct CLI flags, or `run.sh` +
`user.properties`) and two output formats (`text`, `html`); the
`DailyReading` struct is the programmatic form both outputs are rendered
from, and the one the future watchapp will consume directly.
## Input
### Direct CLI flags (`dist/deck-engine`)
```
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
[--date YYYY-MM-DDTHH:MM] [--format text|html]
```
| Flag | Required | Format | Meaning |
|---|---|---|---|
| `--seed` | yes | any string | Tarot seed. The same seed always produces the same 10-card spread, positions, and orientations (see `rng.c`/`tarot.c`). |
| `--birth-date` | yes | `YYYY-MM-DD` | Birth date, local calendar. |
| `--birth-time` | yes | `HH:MM` (24h) | Birth time, local clock. |
| `--birth-utc-offset` | yes | decimal hours, e.g. `2` or `-5.5` | Hours to **subtract** from local birth time to get UTC (e.g. `2` for CEST). |
| `--birth-lat` | yes | decimal degrees | Birth latitude, north positive. |
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
| `--format` | no | `text` \| `html` | Output format, defaults to `text`. |
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
Birth latitude/longitude only affect the Ascendant/houses — planet signs
are geocentric and location-independent.
### `run.sh` + `user.properties`
`dist/run.sh [text|html] [lang]` wraps the binary for daily use:
- **Seed and `--date`** come from the OS clock: `date +%Y-%m-%d` (stable
all day) and `date -u +%Y-%m-%dT%H:%M`.
- **Birth data and language** are read from `dist/user.properties`, a
`key=value` properties file next to the script (`#`-prefixed lines and
blank lines are comments; whitespace around `=` is trimmed):
```properties
birth_date=1990-05-14
birth_time=14:32
birth_utc_offset=2
birth_lat=52.5200
birth_lon=13.4050
lang=en
```
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.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`).
- The optional second argument overrides `lang=` for a single run without
editing the file, e.g. `dist/run.sh html de`.
### Translations (`--lang`, `dist/i18n/`)
Every piece of display text — planet/sign/moon-phase/aspect names, tarot
card names and meanings, Celtic Cross position names, and the section
headers in the `text`/`html` output — is looked up in a translation
catalog with the built-in English text as the fallback for any key the
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
key-naming convention and how to add a new language.
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
build, like `dist/img/`). Adding a language is just dropping another
`<code>.lang` file into `engine/i18n/` — no code changes needed.
## Output
### `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.
```c
typedef struct {
NatalChart natal;
DailyTransits transits;
CelticCrossSpread spread;
} DailyReading;
```
- **`NatalChart`** (`astro.h`): `bodies[10]` (Sun..Pluto, each a sign +
degree-in-sign + raw ecliptic longitude), `ascendant_longitude`,
`houses[12]` (whole-sign: `houses[0]` is the Ascendant's sign).
- **`DailyTransits`** (`astro.h`): `bodies[10]` (today's positions, same
shape as above), `moon_phase` (one of 8 named phases), `aspects[]` +
`aspect_count` (each aspect names a transiting planet, a natal planet,
an `AspectType` — conjunction/sextile/square/trine/opposition — and
the orb in degrees).
- **`CelticCrossSpread`** (`tarot.h`): `positions[10]`, indexed by
`CelticCrossPosition` (Present, Challenge, Crown, Foundation, Recent
Past, Near Future, Attitude, Environment, Hopes and Fears, Outcome —
Waite's own drawing order), each holding a `TarotCard` and a `reversed`
bool.
### `--format text`
Four `=====`-delimited sections, in order: natal chart (each body's sign
+ degree, then the Ascendant), today's sky (Moon phase, then each body's
transiting position), today's aspects to the natal chart (one line per
aspect, with orb), and the Celtic Cross (one block per position: name,
card, `(Reversed)` if applicable, the position's meaning, then the
card's meaning).
```
===== Natal Chart =====
Sun 23.5° Taurus
Moon 15.2° Capricorn
...
Ascendant 18.4° Pisces
===== Today's Sky =====
Moon phase: Waning Gibbous
Sun 11.5° Cancer
...
===== Today's Aspects to Natal Chart =====
Transiting Sun Opposition natal Moon (orb 3.7°)
...
===== Celtic Cross =====
The Present The High Priestess (Reversed)
This covers him: the general influence affecting the matter.
Passion, moral or physical ardour, conceit, surface knowledge.
...
```
### `--format html`
A single self-contained HTML page (inline `<style>`, no JS, no external
requests) with the same four sections, plus each of the 10 Celtic Cross
cards rendered as an image:
```html
<img class="reversed" src="img/RWS_Tarot_02_High_Priestess.jpeg" alt="The High Priestess">
```
- `src="img/<file>"` is relative to the **HTML file's own location** —
it expects to sit next to an `img/` directory, exactly like
`dist/reading.html` next to `dist/img/` (which `make images` populates
from `res/img/`). Saving the HTML output anywhere else breaks the
image links.
- Reversed cards get `class="reversed"`, styled with `transform:
rotate(180deg)` in the page's inline CSS.