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.
This commit is contained in:
ml
2026-07-11 19:39:30 +02:00
parent 41422adf98
commit 1662d550c7
23 changed files with 1894 additions and 296 deletions
+162 -64
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
@@ -99,12 +98,11 @@ deck_in_a_dash/
### 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`)
@@ -186,44 +184,55 @@ 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* and, for each of the top items, prints
a short narrative sentence about what that transit classically means
the first piece of a larger "turn the raw reading into an actual
narrative" effort described in project chat history; the Celtic Cross
spread itself isn't woven into that narrative yet (see
`docs/reading.md`). `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
@@ -234,17 +243,15 @@ between the two binaries (see the next bullet).
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`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.
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 —
@@ -267,7 +274,81 @@ between the two binaries (see the next bullet).
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.
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
@@ -283,31 +364,37 @@ between the two binaries (see the next bullet).
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).
`"spread"` is left zeroed entirely - nothing reads it. 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 slug or missing `house` inside
`"bodies"` is silently skipped (that body's `house` stays `0`, an
invalid whole-sign house number used as "no data" - `main.c` checks for
it before printing sign/house), since that's supplementary display
context rather than something scoring depends on.
- **Deliberately header-only dependency on the engine, throughout**:
`significance.h`/`reading_io.h`/`narrative.h` `#include` engine headers
(`reading.h`/`astro.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/sign
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/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.
`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
@@ -325,3 +412,14 @@ between the two binaries (see the next bullet).
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).
+30 -9
View File
@@ -36,29 +36,45 @@ 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_SRC_DIR)/narrative.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
@@ -68,7 +84,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 $@ $^
@@ -109,11 +125,12 @@ scripts:
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \
fi
test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) $(NARRATIVE_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)
@@ -124,7 +141,11 @@ $(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 $(NARRATIVE_TEST_OBJ)
$(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
+32 -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
@@ -45,10 +44,14 @@ engine/
the user.properties template.
dist/ Build output (gitignored) — see below.
interpreter/ Separate module + binary (dist/interpreter-cli):
scores how significant a day's transits are
and prints a short narrative note for each,
reading deck-engine's --format json output
(see CLAUDE.md).
scores how significant a day's transits are,
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/.
```
@@ -94,16 +97,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
@@ -124,3 +134,10 @@ 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.
+6 -9
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
@@ -41,9 +40,6 @@ know before changing the engine. This document is the visual overview.
`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".
- **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
@@ -64,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

+225 -36
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
@@ -90,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 {
@@ -227,16 +228,39 @@ 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
@@ -244,30 +268,195 @@ dist/run-interpreter.sh
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) an entry naming an unrecognized body, or a missing
`house`, is silently skipped rather than failing the load (see
`parse_bodies()` in `reading_io.c`); `spread` is always ignored.
- **Output**: a plain-text report — the day's overall significance level
(`Quiet`/`Notable`/`Significant`/`Major`, plus its rank out of the 4
defined levels, e.g. `Notable (2/4)`), a "worth a deeper Celtic Cross
look" line on `Major` days only, and 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:
(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.
```
Day significance: Significant (3/4)
(a major transit today - worth a deeper Celtic Cross look)
### `--format text`
Top 3 significant transits:
1. Transiting Saturn in Aries (house 2) Square natal Sun in Taurus (house 3) (orb 0.5°, score 8.10)
In matters of money, possessions, and personal values (house 2). Brings depression, stagnation, hindrances and obstacles, and some deprivation of the usual benefits.
2. Transiting Pluto in Aquarius (house 12) Opposition natal Moon in Capricorn (house 11) (orb 0.2°, score 7.92)
In matters of solitude, the subconscious, and hidden matters (house 12). Brings deep, often hidden transformation - the surfacing or dismantling of something that has outgrown its old form.
3. Transiting Jupiter in Leo (house 5) Trine natal Venus in Aries (house 2) (orb 2.1°, score 3.40)
In matters of romance, creativity, and children (house 5). Brings increase and expansion - fullness of fortune and health, and a generally fortunate time.
```
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: 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.
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.
+132 -21
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:
@@ -95,21 +101,51 @@ 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 (with a plain-language note on each of the
main events driving that), 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.
*(This page, like that piece, is still a work in progress — expect it
to grow as the reading itself does.)*
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.
## A worked example
@@ -123,7 +159,7 @@ Sagittarius.
are forming aspects back to the natal chart above — see below for which
ones actually matter.
**How big is today.**
**How big is today, and the significant transits.**
```
Day significance: Major (4/4)
@@ -149,18 +185,93 @@ 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.
**Tarot spread (excerpt).** Drawn independently, using today's date:
**The Celtic Cross, read out in full** (drawn independently, using
today's date):
- *The Present* — The Emperor, reversed: immaturity or a loss of
authority, rather than confident structure.
- *The Challenge* — The Devil: force, ravage, an obstacle that isn't
easily reasoned with.
- *The Near Future* — The World, reversed: stagnation, things staying
fixed in place rather than resolving.
```
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) — but that's the
reader's own synthesis for now, not something the app does for you (see
above).
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
+400 -52
View File
@@ -1,25 +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, each with a short narrative note.\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;
@@ -39,63 +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 names[12] = {
static const char *const fallback[12] = {
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
};
return names[sign];
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 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";
}
}
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(const PlanetPosition *pos) {
static void print_body_in_sign_text(FILE *out, const PlanetPosition *pos) {
if (pos->house < 1 || pos->house > 12) return;
printf(" in %s (house %d)", sign_display_name(pos->sign), pos->house);
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);
}
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";
/* 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);
}
return "Unknown";
}
/* ===== --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))) {
print_usage(argv[0]);
return argc > 2 ? 1 : 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 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;
@@ -116,30 +482,12 @@ int main(int argc, char **argv) {
DailyInterpretation interp;
interpret_daily_reading(&reading, &interp);
printf("Day significance: %s (%d/%d)\n", day_level_name(interp.day_level),
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT);
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];
const Aspect *a = &item->aspect;
printf(" %d. Transiting %s", i + 1, body_display_name(a->transiting_planet));
print_body_in_sign(&reading.transits.bodies[a->transiting_planet]);
printf(" %s natal %s", aspect_display_name(a->type), body_display_name(a->natal_planet));
print_body_in_sign(&reading.natal.bodies[a->natal_planet]);
printf(" (orb %.1f\xc2\xb0, score %.2f)\n", a->orb, item->score);
narrative_print(stdout, " ", a, reading.transits.bodies[a->transiting_planet].house);
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;
}
+90 -59
View File
@@ -1,86 +1,103 @@
#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;
/* See narrative.h's doc comment for sourcing (Sepharial, "Transits and
* Planetary Periods", 1920, Chapter VIII) and the Moon/Pluto exception. */
/* 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] = {
.base = "",
.harmonious = "Benefits from superiors and advancement in your sphere of "
"life and work - honours, emoluments, and successful new "
"associations.",
.discordant = "Degradation and dishonour, loss of position, and adverse "
"judgement from superiors.",
"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. */
.base = "Colours the everyday and domestic sphere of life, often "
"coinciding with the opening of new avenues.",
.harmonious = "These changes tend to be advantageous.",
.discordant = "These changes tend to be adverse, with some indisposition "
"or domestic friction.",
"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] = {
.base = "Affects writings, journeys, commerce, and everyday activities - "
"a neutral messenger whose effect follows the nature of the "
"aspect it makes.",
.harmonious = "",
.discordant = "",
"mercury",
"Affects writings, journeys, commerce, and everyday activities - "
"a neutral messenger whose effect follows the nature of the "
"aspect it makes.",
"", "",
},
[PLANET_VENUS] = {
.base = "Brings domestic and social affairs to the fore - happiness, "
"comforts, and favours.",
.harmonious = "Success in love affairs and artistic pursuits is likely.",
.discordant = "Grief and disappointment are more likely.",
"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] = {
.base = "A strenuous time of quarrels, contention, strife and anger, with "
"some risk of hurts or injuries depending on the sign it "
"occupies.",
.harmonious = "Can bring benefits from doctors, surgeons, or new projects "
"and enterprises.",
.discordant = "",
"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] = {
.base = "Brings increase and expansion - fullness of fortune and health, "
"and a generally fortunate time.",
.harmonious = "",
.discordant = "",
"jupiter",
"Brings increase and expansion - fullness of fortune and health, "
"and a generally fortunate time.",
"", "",
},
[PLANET_SATURN] = {
.base = "Brings depression, stagnation, hindrances and obstacles, and "
"some deprivation of the usual benefits.",
.harmonious = "Favours from older connections and past associations are "
"still possible.",
.discordant = "",
"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] = {
.base = "Brings separations, estrangements, sudden dislocations and "
"violent upsets.",
.harmonious = "Success through official or civic channels, and "
"beneficial changes or appointments, are possible.",
.discordant = "",
"uranus",
"Brings separations, estrangements, sudden dislocations and "
"violent upsets.",
"Success through official or civic channels, and "
"beneficial changes or appointments, are possible.",
"",
},
[PLANET_NEPTUNE] = {
.base = "Brings a state of chaos and confusion - an involved, uncertain "
"condition of affairs, with plots, subtlety, or unseen "
"influences at work.",
.harmonious = "",
.discordant = "",
"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. */
.base = "Brings deep, often hidden transformation - the surfacing or "
"dismantling of something that has outgrown its old form.",
.harmonious = "",
.discordant = "",
"pluto",
"Brings deep, often hidden transformation - the surfacing or "
"dismantling of something that has outgrown its old form.",
"", "",
},
};
@@ -96,7 +113,8 @@ static bool aspect_is_discordant(AspectType type) {
* 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. */
* 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",
@@ -112,22 +130,35 @@ static const char *const k_house_area[12] = {
"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 = n->harmonious;
else if (aspect_is_discordant(aspect->type)) extra = n->discordant;
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 (n->base[0] == '\0' && extra[0] == '\0') return;
if (base[0] == '\0' && extra[0] == '\0') return;
fprintf(out, "%s", indent);
if (transiting_house >= 1 && transiting_house <= 12) {
fprintf(out, "In matters of %s (house %d). ",
k_house_area[transiting_house - 1], transiting_house);
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 (n->base[0] != '\0') fprintf(out, "%s", n->base);
if (extra[0] != '\0') fprintf(out, "%s%s", n->base[0] != '\0' ? " " : "", extra);
if (base[0] != '\0') fprintf(out, "%s", base);
if (extra[0] != '\0') fprintf(out, "%s%s", base[0] != '\0' ? " " : "", extra);
fprintf(out, "\n");
}
+7 -1
View File
@@ -39,7 +39,13 @@
* 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. */
* 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);
+59
View File
@@ -22,6 +22,20 @@ static const char *const k_sign_slug[12] = {
"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) {
@@ -77,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));
@@ -122,6 +178,9 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
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 -9
View File
@@ -10,18 +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 and for reporting sign/house
* context around each significant event: out->transits.aspects[]/
* aspect_count (used for scoring - see significance.c), and
* out->natal.bodies[]/out->transits.bodies[] (sign + house per body,
* used only for display). out->natal.ascendant_longitude/houses[] and
* out->spread are left zeroed - nothing reads them 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). A missing/unrecognized entry
* in "natal"."bodies"/"transits"."bodies" is not an error - see
* parse_bodies() in reading_io.c. */
* 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
+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;
}
+40 -6
View File
@@ -41,11 +41,11 @@ static void test_json_parse_rejects_malformed(void) {
}
/* A trimmed-but-structurally-faithful fixture matching deck-engine's real
* --format json shape: "spread" is present with decoy content the loader
* must skip over without understanding its schema, to prove it navigates
* by key path rather than assuming any position. "natal"/"transits"
* bodies are real (if partial) - reading_load_json now parses sign/house
* from them for display, alongside the aspects used for scoring. */
* --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\", \"house\": 3}], \"houses\": []},"
@@ -57,7 +57,10 @@ static const char *k_fixture =
" {\"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) {
@@ -95,6 +98,35 @@ static void test_reading_load_json_extracts_body_sign_and_house(void) {
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}]},"
@@ -147,6 +179,8 @@ int main(void) {
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();
+37
View File
@@ -1,7 +1,12 @@
#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) {
@@ -112,6 +117,37 @@ static void test_unknown_house_omits_area_framing(void) {
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();
@@ -120,6 +156,7 @@ int main(void) {
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;
}