diff --git a/.gitignore b/.gitignore index 73186ff..cf02269 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ /dist /build +# Durable backup of the same real birth data, outside dist/ so a dist/ +# wipe doesn't lose it — see the Makefile's `scripts` target. Never +# commit this either. +/engine/scripts/user.properties.local + *.o core core.* diff --git a/CLAUDE.md b/CLAUDE.md index aac73d0..0a70328 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,14 +45,20 @@ dist/deck-engine --seed "2026-07-03" \ ``` `dist/user.properties` is seeded once from `engine/scripts/user.properties.template` -with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) and is **never -overwritten by later builds** — `run-engine.sh`/`run-interpreter.sh` -refuse to run (with a clear error) until the placeholders are replaced -with real values. `dist/run-interpreter.sh` is the equivalent daily-use -wrapper for the interpreter: it runs `run-engine.sh`'s same OS-clock -seed/date and `user.properties` birth data through `deck-engine --format -json`, piped straight into `dist/interpreter-cli` — see "Interpretation" -below. +with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) — `run-engine.sh`/ +`run-interpreter.sh` refuse to run (with a clear error) until the +placeholders are replaced with real values. Because `dist/` itself is +disposable (`make clean`, or just deleting the directory, wipes it), the +`scripts` Makefile target also keeps a durable backup outside `dist/`: +once `dist/user.properties` looks filled in (no leftover `YOUR_...`), every +build copies it out to `engine/scripts/user.properties.local` +(gitignored); if `dist/user.properties` is ever missing when a build +runs, it's restored from that backup instead of being reseeded from the +placeholder template. `dist/run-interpreter.sh` is the equivalent +daily-use wrapper for the interpreter: it runs `run-engine.sh`'s same +OS-clock seed/date and `user.properties` birth data through `deck-engine +--format json`, piped straight into `dist/interpreter-cli` — see +"Interpretation" below. Run a single smoke test by editing `engine/tests/smoke_test.c`'s `main()` temporarily, or just read its assertions — there's no test filter flag, @@ -241,12 +247,19 @@ between the two binaries (see the next bullet). Unlike the core engine, it uses `malloc`; `interpreter-cli` was never meant to run on the watch itself. - **`reading_io.c`** walks the parsed JSON down to `"transits"."aspects"` - and fills a `DailyReading` with just that (everything else is left - zeroed - `interpret_daily_reading` doesn't read `natal`/`spread` - either). An aspect naming a planet/aspect-type slug it doesn't - recognize, or a document missing `"transits"."aspects"` entirely (as - opposed to a present-but-empty array, which is a valid "no aspects - today"), makes the whole load fail rather than silently dropping data. + 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` `#include engine/src/reading.h` for the struct/enum *definitions*, but the root `Makefile` never compiles or @@ -254,8 +267,8 @@ between the two binaries (see the next bullet). interpreter's binary or tests — every test in `interpreter/tests/` runs against hand-built JSON text or hand-built `DailyReading` values, with no real ephemeris/tarot-draw - call involved anywhere. Consequently `reading_io.c` (planet/aspect - slugs) and `main.c` (planet/aspect display names) each duplicate a + 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 diff --git a/Makefile b/Makefile index 38d6878..986ca06 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,11 @@ ENGINE_I18N_DIR := engine/i18n ENGINE_SCRIPTS_DIR := engine/scripts RES_IMG_DIR := res/img +# Durable copy of the user's real birth data, outside dist/ (which is +# disposable - `make clean`, or just deleting the directory, wipes it). +# Gitignored: see .gitignore. +USER_PROPS_BACKUP := $(ENGINE_SCRIPTS_DIR)/user.properties.local + # Engine logic shared by the CLI and the test binary (no main()). ENGINE_SRCS := $(ENGINE_SRC_DIR)/rng.c $(ENGINE_SRC_DIR)/tarot.c $(ENGINE_SRC_DIR)/tarot_data.c \ $(ENGINE_SRC_DIR)/astro.c $(ENGINE_SRC_DIR)/reading.c $(ENGINE_SRC_DIR)/i18n.c \ @@ -79,16 +84,27 @@ i18n: cp $(ENGINE_I18N_DIR)/*.lang $(DIST_I18N_DIR)/ # run-engine.sh/run-interpreter.sh are refreshed every build. -# user.properties is only seeded once (from the placeholder template) so -# a rebuild never clobbers the user's own filled-in birth data. +# +# user.properties is backed up/restored across dist/ wipes rather than +# just "seeded once": if dist/user.properties exists and looks filled in +# (no leftover YOUR_... placeholder), it's copied out to +# USER_PROPS_BACKUP; otherwise, if that backup exists (e.g. dist/ was +# just deleted), it's copied back in; otherwise (genuinely first run) +# dist/user.properties is seeded from the placeholder template, same as +# before. scripts: @mkdir -p $(DIST_DIR) cp $(ENGINE_SCRIPTS_DIR)/run-engine.sh $(DIST_DIR)/run-engine.sh chmod +x $(DIST_DIR)/run-engine.sh cp $(ENGINE_SCRIPTS_DIR)/run-interpreter.sh $(DIST_DIR)/run-interpreter.sh chmod +x $(DIST_DIR)/run-interpreter.sh - test -f $(DIST_DIR)/user.properties || \ - cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties + @if [ -f $(DIST_DIR)/user.properties ] && ! grep -q 'YOUR_' $(DIST_DIR)/user.properties; then \ + cp $(DIST_DIR)/user.properties $(USER_PROPS_BACKUP); \ + elif [ -f $(USER_PROPS_BACKUP) ]; then \ + cp $(USER_PROPS_BACKUP) $(DIST_DIR)/user.properties; \ + elif [ ! -f $(DIST_DIR)/user.properties ]; then \ + cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \ + fi test: $(ENGINE_TEST_BINARY) $(SIGNIFICANCE_TEST_BIN) $(JSON_TEST_BIN) ./$(ENGINE_TEST_BINARY) diff --git a/docs/architecture.md b/docs/architecture.md index ae0b46f..3ca31cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -36,8 +36,11 @@ know before changing the engine. This document is the visual overview. - **`dist/`** is generated by the single root `Makefile`: the `deck-engine` and `interpreter-cli` binaries, a copy of the card art under `img/`, a copy of the translation files under `i18n/`, - `run-engine.sh`/`run-interpreter.sh`, and a `user.properties` template - that's seeded once and never overwritten by later builds. + `run-engine.sh`/`run-interpreter.sh`, and `user.properties`. Since + `dist/` itself is disposable, `user.properties` also gets backed up to + `engine/scripts/user.properties.local` (gitignored) once it's filled + in, and restored from there if `dist/` is ever wiped — see + `CLAUDE.md`'s "Build & run". - **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_*`. diff --git a/docs/input-output-format.md b/docs/input-output-format.md index a1be0f8..f3cf20c 100644 --- a/docs/input-output-format.md +++ b/docs/input-output-format.md @@ -55,11 +55,15 @@ use: ``` The file is seeded once from `engine/scripts/user.properties.template` - with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.) and is **never - overwritten by later builds**. `run-engine.sh` checks every birth field - for an empty value or a leftover `YOUR_` placeholder and refuses to run - with a clear error until the file is filled in; `lang` has no such - check since it always has a usable default (`en`). + with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh` + checks every birth field for an empty value or a leftover `YOUR_` + placeholder and refuses to run with a clear error until the file is + filled in; `lang` has no such check since it always has a usable + default (`en`). Since `dist/` itself can be wiped (`make clean`, or + deleting the directory), the build also keeps a durable backup at + `engine/scripts/user.properties.local` (gitignored) once the file + looks filled in, and restores from it if `dist/user.properties` is + ever missing — see `CLAUDE.md`'s "Build & run". - The optional second argument overrides `lang=` for a single run without editing the file, e.g. `dist/run-engine.sh html de`. - `dist/run-interpreter.sh` (no arguments) is the equivalent wrapper for @@ -236,22 +240,26 @@ dist/run-interpreter.sh ``` - **Input**: a file path argument, or stdin if no argument (or `-`) is - given. Only `"transits"."aspects"` is read — `natal` and `spread` may - be present (and are ignored) or omitted entirely, except that - `"transits"."aspects"` itself must be present (an empty array is valid - and means "no notable transits today"; a missing key is treated as - invalid input). + given. `"transits"."aspects"` is required (an empty array is valid and + means "no notable transits today"; a missing key is treated as invalid + input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are + read too, for the sign/house context printed alongside each event below + (not for scoring) — 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`), a "worth a deeper Celtic Cross look" line on `Major` days only, and up to 5 of today's aspects - ranked by score: + 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: ``` Day significance: Significant (a major transit today - worth a deeper Celtic Cross look) Top 3 significant transits: - 1. Transiting Saturn Square natal Sun (orb 0.5°, score 8.10) - 2. Transiting Pluto Opposition natal Moon (orb 0.2°, score 7.92) - 3. Transiting Jupiter Trine natal Venus (orb 2.1°, score 3.40) + 1. Transiting Saturn in Aries (house 2) Square natal Sun in Taurus (house 3) (orb 0.5°, score 8.10) + 2. Transiting Pluto in Aquarius (house 12) Opposition natal Moon in Capricorn (house 11) (orb 0.2°, score 7.92) + 3. Transiting Jupiter in Leo (house 5) Trine natal Venus in Aries (house 2) (orb 2.1°, score 3.40) ``` diff --git a/interpreter/src/main.c b/interpreter/src/main.c index 373068f..918203c 100644 --- a/interpreter/src/main.c +++ b/interpreter/src/main.c @@ -57,6 +57,22 @@ static const char *aspect_display_name(AspectType type) { return names[type]; } +static const char *sign_display_name(ZodiacSign sign) { + static const char *const names[12] = { + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", + }; + return names[sign]; +} + +/* 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) { + if (pos->house < 1 || pos->house > 12) return; + printf(" in %s (house %d)", sign_display_name(pos->sign), pos->house); +} + static const char *day_level_name(DaySignificance level) { switch (level) { case DAY_QUIET: return "Quiet"; @@ -114,10 +130,13 @@ int main(int argc, char **argv) { interp.top_item_count == 1 ? "" : "s"); for (int i = 0; i < interp.top_item_count; i++) { const SignificantItem *item = &interp.top_items[i]; - printf(" %d. Transiting %s %s natal %s (orb %.1f\xc2\xb0, score %.2f)\n", i + 1, - body_display_name(item->aspect.transiting_planet), - aspect_display_name(item->aspect.type), - body_display_name(item->aspect.natal_planet), item->aspect.orb, item->score); + 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); } return 0; } diff --git a/interpreter/src/reading_io.c b/interpreter/src/reading_io.c index 6410ca2..2d8d799 100644 --- a/interpreter/src/reading_io.c +++ b/interpreter/src/reading_io.c @@ -17,6 +17,11 @@ static const char *const k_aspect_slug[5] = { "conjunction", "sextile", "square", "trine", "opposition", }; +static const char *const k_sign_slug[12] = { + "aries", "taurus", "gemini", "cancer", "leo", "virgo", + "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", +}; + static bool body_from_slug(const char *slug, Body *out) { for (int i = 0; i < NUM_BODIES; i++) { if (strcmp(slug, k_body_slug[i]) == 0) { @@ -27,6 +32,41 @@ static bool body_from_slug(const char *slug, Body *out) { return false; } +static bool sign_from_slug(const char *slug, ZodiacSign *out) { + for (int i = 0; i < 12; i++) { + if (strcmp(slug, k_sign_slug[i]) == 0) { + *out = (ZodiacSign)i; + return true; + } + } + return false; +} + +/* Fills out[NUM_BODIES] from a "bodies" JSON array, the shape shared by + * both "natal" and "transits" (see reading_print_json in engine/src/ + * reading.c). Used only for sign/house context in significant-event + * reporting, not for scoring, so this is deliberately lenient: an entry + * with an unrecognized/missing "body" slug is skipped, and a missing + * "house" leaves that body's house at 0 - not a valid whole-sign house + * number (always 1-12), so callers use house == 0 as "no position data + * available" rather than this failing the whole load. */ +static void parse_bodies(const JsonValue *bodies, PlanetPosition out[NUM_BODIES]) { + int count = json_array_count(bodies); + for (int i = 0; i < count; i++) { + const JsonValue *item = json_array_get(bodies, i); + Body body; + if (!body_from_slug(json_as_string(json_object_get(item, "body")), &body)) continue; + + ZodiacSign sign; + if (sign_from_slug(json_as_string(json_object_get(item, "sign")), &sign)) { + out[body].sign = sign; + } + out[body].degree_in_sign = json_as_number(json_object_get(item, "degree_in_sign")); + out[body].ecliptic_longitude = json_as_number(json_object_get(item, "ecliptic_longitude")); + out[body].house = (int)json_as_number(json_object_get(item, "house")); + } +} + static bool aspect_type_from_slug(const char *slug, AspectType *out) { for (int i = 0; i < 5; i++) { if (strcmp(slug, k_aspect_slug[i]) == 0) { @@ -78,6 +118,10 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) { } out->transits.aspect_count = filled; + const JsonValue *natal = json_object_get(root, "natal"); + parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies); + parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies); + json_free(root); return ok; } diff --git a/interpreter/src/reading_io.h b/interpreter/src/reading_io.h index 0cef02a..40bfd0e 100644 --- a/interpreter/src/reading_io.h +++ b/interpreter/src/reading_io.h @@ -10,14 +10,18 @@ /* Parses deck-engine's `--format json` output (text, null-terminated at * text[length]) and fills *out with just enough of a DailyReading for - * interpret_daily_reading() to work on: out->transits.aspects[]/ - * aspect_count. out->natal and out->spread are left zeroed - nothing - * here reads them (see significance.c), so there's no reason to parse - * the rest of the document yet. + * interpret_daily_reading() to work on 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. * * Returns false on malformed JSON, or if "transits"."aspects" isn't * present as an array (an empty array is fine - that's a real "no - * aspects today" reading, not an error). */ + * aspects today" reading, not an error). A missing/unrecognized entry + * in "natal"."bodies"/"transits"."bodies" is not an error - see + * parse_bodies() in reading_io.c. */ bool reading_load_json(const char *text, size_t length, DailyReading *out); #endif diff --git a/interpreter/tests/json_test.c b/interpreter/tests/json_test.c index fe997c6..618e862 100644 --- a/interpreter/tests/json_test.c +++ b/interpreter/tests/json_test.c @@ -41,14 +41,16 @@ static void test_json_parse_rejects_malformed(void) { } /* A trimmed-but-structurally-faithful fixture matching deck-engine's real - * --format json shape: natal/spread sections are present with decoy - * content the loader must skip over without understanding their schema, - * to prove it navigates by key path rather than assuming any position. */ + * --format json shape: "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. */ static const char *k_fixture = "{" - " \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\"}], \"houses\": []}," + " \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []}," " \"transits\": {" - " \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\"}]," + " \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}]," " \"moon_phase\": \"full\"," " \"aspects\": [" " {\"transiting_planet\": \"pluto\", \"natal_planet\": \"moon\", \"type\": \"opposition\", \"orb\": 0.2}," @@ -75,6 +77,37 @@ static void test_reading_load_json_extracts_aspects(void) { printf("PASS test_reading_load_json_extracts_aspects\n"); } +static void test_reading_load_json_extracts_body_sign_and_house(void) { + DailyReading reading; + bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading); + + assert(ok); + assert(reading.natal.bodies[PLANET_SUN].sign == SIGN_TAURUS); + assert(reading.natal.bodies[PLANET_SUN].house == 3); + assert(reading.transits.bodies[PLANET_SUN].sign == SIGN_CANCER); + assert(reading.transits.bodies[PLANET_SUN].house == 5); + + /* Bodies missing from the fixture (e.g. the Moon) get the "no data" + * sentinel rather than a garbage house number. */ + assert(reading.natal.bodies[PLANET_MOON].house == 0); + assert(reading.transits.bodies[PLANET_MOON].house == 0); + + printf("PASS test_reading_load_json_extracts_body_sign_and_house\n"); +} + +static void test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(void) { + const char *text = + "{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]}," + " \"transits\": {\"aspects\": []}}"; + DailyReading reading; + bool ok = reading_load_json(text, strlen(text), &reading); + + assert(ok); + assert(reading.transits.aspect_count == 0); + + printf("PASS test_reading_load_json_unknown_body_slug_is_skipped_not_fatal\n"); +} + static void test_reading_load_json_empty_aspects_is_valid(void) { const char *text = "{\"transits\": {\"aspects\": []}}"; DailyReading reading; @@ -113,6 +146,8 @@ int main(void) { test_json_parse_basic_shapes(); test_json_parse_rejects_malformed(); test_reading_load_json_extracts_aspects(); + test_reading_load_json_extracts_body_sign_and_house(); + test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(); test_reading_load_json_empty_aspects_is_valid(); test_reading_load_json_rejects_missing_transits(); test_reading_load_json_rejects_unknown_slug();