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:
@@ -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
|
||||
|
||||

|
||||
|
||||
- **`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
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 |
@@ -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.
|
||||
Reference in New Issue
Block a user