diff --git a/CLAUDE.md b/CLAUDE.md index 3fcc67b..63f2d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `/`--i18n-dir +` 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 ] # 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/` 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). diff --git a/Makefile b/Makefile index 7bd7f32..89d7daf 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 709b039..1b7b293 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 3ca31cc..a2a4875 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/diagrams/architecture.dot b/docs/diagrams/architecture.dot index c7f3e5f..c4c5c6a 100644 --- a/docs/diagrams/architecture.dot +++ b/docs/diagrams/architecture.dot @@ -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]; } diff --git a/docs/diagrams/dataflow.dot b/docs/diagrams/dataflow.dot index ba59db8..0d67cd6 100644 --- a/docs/diagrams/dataflow.dot +++ b/docs/diagrams/dataflow.dot @@ -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( 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]; } diff --git a/docs/images/architecture.png b/docs/images/architecture.png index eb1f2e7..0085af3 100644 Binary files a/docs/images/architecture.png and b/docs/images/architecture.png differ diff --git a/docs/images/dataflow.png b/docs/images/dataflow.png index c35b722..0f31bd8 100644 Binary files a/docs/images/dataflow.png and b/docs/images/dataflow.png differ diff --git a/docs/input-output-format.md b/docs/input-output-format.md index 2fe3784..64e401d 100644 --- a/docs/input-output-format.md +++ b/docs/input-output-format.md @@ -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 `/`--i18n-dir `: + +| 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 `.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/` 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 +
+
The Present
+ The Emperor +
The Emperor (Reversed)
+
This covers him: the general influence affecting the matter.
+
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
+
+``` + +The guidance section renders as one `

` per line of the same text +`--format text` prints last (intro sentence, Attitude line, Outcome +line), inside a `

`. + +### `--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. diff --git a/docs/reading.md b/docs/reading.md index 39b83eb..bcabbc6 100644 --- a/docs/reading.md +++ b/docs/reading.md @@ -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. +``` diff --git a/engine/i18n/de.lang b/engine/i18n/de.lang index 7e3ce7c..25d26a8 100644 --- a/engine/i18n/de.lang +++ b/engine/i18n/de.lang @@ -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 diff --git a/engine/i18n/en.lang b/engine/i18n/en.lang index 9797c94..89fddd2 100644 --- a/engine/i18n/en.lang +++ b/engine/i18n/en.lang @@ -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 diff --git a/engine/scripts/run-interpreter.sh b/engine/scripts/run-interpreter.sh index b659840..fd87b12 100644 --- a/engine/scripts/run-interpreter.sh +++ b/engine/scripts/run-interpreter.sh @@ -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" diff --git a/interpreter/src/guidance.c b/interpreter/src/guidance.c new file mode 100644 index 0000000..6ae3327 --- /dev/null +++ b/interpreter/src/guidance.c @@ -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."/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..*" 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)); +} diff --git a/interpreter/src/guidance.h b/interpreter/src/guidance.h new file mode 100644 index 0000000..394878b --- /dev/null +++ b/interpreter/src/guidance.h @@ -0,0 +1,60 @@ +#ifndef DECK_GUIDANCE_H +#define DECK_GUIDANCE_H + +#include + +/* 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 diff --git a/interpreter/src/main.c b/interpreter/src/main.c index 77c29d1..70bb04e 100644 --- a/interpreter/src/main.c +++ b/interpreter/src/main.c @@ -1,25 +1,63 @@ +#define _POSIX_C_SOURCE 200809L /* for open_memstream */ + #include #include #include #include +#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 ] [--i18n-dir ]\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/, 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 .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 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./sign./aspect., + * 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, + "\n\n" + "%s\n" + "\n", + ui("interp.page_title", "Deck in a Dash - Interpretation")); + + fprintf(out, "

%s

\n", ui("interp.heading", "Daily Interpretation")); + + fprintf(out, "

%s

\n

%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, "
%s", ui("interp.major_framing", + "(a major transit today - worth a deeper Celtic Cross look)")); + } + fprintf(out, "

\n"); + + fprintf(out, "

%s

\n", ui("interp.heading_transits", "Significant Transits")); + if (interp->top_item_count == 0) { + fprintf(out, "

%s

\n", ui("interp.no_notable_transits", "No notable transits today.")); + } else { + fprintf(out, "\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, "" + "\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, "\n", text); + free(text); + } + fprintf(out, "
%d.%s %s %s %s %s%s %.1f°, %s %.2f
%s
\n"); + } + + fprintf(out, "

%s

\n
\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, + "
\n" + "
%s
\n" + " \"%s\"\n" + "
%s%s
\n" + "
%s
\n" + "
%s
\n" + "
\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, "
\n"); + + char *guidance = capture_guidance(interp, &reading->spread); + fprintf(out, "

%s

\n
\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, "

%.*s

\n", (int)(p - start), start); + if (*p == '\0') break; + start = p + 1; + } + } + free(guidance); + fprintf(out, "
\n"); + + fprintf(out, "\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; } diff --git a/interpreter/src/narrative.c b/interpreter/src/narrative.c index e195b9e..835f83a 100644 --- a/interpreter/src/narrative.c +++ b/interpreter/src/narrative.c @@ -1,86 +1,103 @@ #include "narrative.h" #include +#include + +/* 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..*" 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..*" 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"); } diff --git a/interpreter/src/narrative.h b/interpreter/src/narrative.h index b78fc9b..c50704d 100644 --- a/interpreter/src/narrative.h +++ b/interpreter/src/narrative.h @@ -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); diff --git a/interpreter/src/reading_io.c b/interpreter/src/reading_io.c index 2d8d799..fbe395f 100644 --- a/interpreter/src/reading_io.c +++ b/interpreter/src/reading_io.c @@ -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; } diff --git a/interpreter/src/reading_io.h b/interpreter/src/reading_io.h index 40bfd0e..8ec2192 100644 --- a/interpreter/src/reading_io.h +++ b/interpreter/src/reading_io.h @@ -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 diff --git a/interpreter/tests/guidance_test.c b/interpreter/tests/guidance_test.c new file mode 100644 index 0000000..d8b6e33 --- /dev/null +++ b/interpreter/tests/guidance_test.c @@ -0,0 +1,219 @@ +#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */ + +#include +#include +#include +#include +#include + +#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; +} diff --git a/interpreter/tests/json_test.c b/interpreter/tests/json_test.c index 618e862..9b324be 100644 --- a/interpreter/tests/json_test.c +++ b/interpreter/tests/json_test.c @@ -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(); diff --git a/interpreter/tests/narrative_test.c b/interpreter/tests/narrative_test.c index ae061ef..84da690 100644 --- a/interpreter/tests/narrative_test.c +++ b/interpreter/tests/narrative_test.c @@ -1,7 +1,12 @@ +#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */ + #include #include +#include #include +#include +#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; }