Add JSON output format and a separate interpreter binary

deck-engine gains --format json (language-independent, slug-based)
so a new dist/interpreter-cli can score how significant a day's
transits are without linking the engine itself — it depends only on
the JSON shape, via its own minimal parser. Also documents the full
reading pipeline end to end (CLAUDE.md, README, docs/) and adds
docs/reading.md, a non-technical explanation of what a daily reading
contains and means.
This commit is contained in:
ml
2026-07-05 15:55:43 +02:00
parent 57deb62b51
commit bd739ccfdf
25 changed files with 1497 additions and 11 deletions
+5
View File
@@ -231,3 +231,8 @@ const char *astro_moon_phase_name(MoonPhaseName phase) {
const char *astro_aspect_name(AspectType type) {
return lookup("aspect.", k_aspect_slug[type], k_aspect_names[type]);
}
const char *astro_body_slug(Body body) { return k_body_slug[body]; }
const char *astro_sign_slug(ZodiacSign sign) { return k_sign_slug[sign]; }
const char *astro_moon_phase_slug(MoonPhaseName phase) { return k_moon_phase_slug[phase]; }
const char *astro_aspect_slug(AspectType type) { return k_aspect_slug[type]; }
+9
View File
@@ -104,4 +104,13 @@ const char *astro_sign_name(ZodiacSign sign);
const char *astro_moon_phase_name(MoonPhaseName phase);
const char *astro_aspect_name(AspectType type);
/* Stable, language-independent identifiers (e.g. "sun", "waxing_crescent",
* "square") - the same strings i18n.c's lookup keys are built from. Used
* for machine-readable output (reading_print_json) that must stay
* identical regardless of --lang, unlike astro_*_name() above. */
const char *astro_body_slug(Body body);
const char *astro_sign_slug(ZodiacSign sign);
const char *astro_moon_phase_slug(MoonPhaseName phase);
const char *astro_aspect_slug(AspectType type);
#endif
+9 -4
View File
@@ -8,13 +8,13 @@
#include "reading.h"
#include "i18n.h"
typedef enum { FORMAT_TEXT, FORMAT_HTML } OutputFormat;
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html]\n"
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
" [--lang <code>] [--i18n-dir <path>]\n\n"
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
" --birth-date/time Birth date/time in local clock time.\n"
@@ -23,7 +23,9 @@ static void print_usage(const char *prog) {
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
" --format Output format, defaults to text. html links card art via\n"
" img/<file>, i.e. it expects to be saved next to the img/\n"
" directory that `make images` copies into dist/.\n"
" directory that `make images` copies into dist/. json uses\n"
" stable, language-independent identifiers (not --lang's\n"
" display text) - it's the interpreter/ module's input format.\n"
" --lang Reading language code, defaults to en. Matches a file\n"
" named <code>.lang in --i18n-dir (see engine/i18n/).\n"
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
@@ -128,8 +130,9 @@ int main(int argc, char **argv) {
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 or html\n");
fprintf(stderr, "Invalid --format, expected text, html, or json\n");
return 1;
}
@@ -149,6 +152,8 @@ int main(int argc, char **argv) {
if (format == FORMAT_HTML) {
reading_print_html(&reading, stdout);
} else if (format == FORMAT_JSON) {
reading_print_json(&reading, stdout);
} else {
reading_print_text(&reading, stdout);
}
+52
View File
@@ -142,3 +142,55 @@ void reading_print_html(const DailyReading *r, FILE *out) {
}
fprintf(out, "</div>\n</body></html>\n");
}
static void print_body_position_json(FILE *out, const char *indent, Body body,
const PlanetPosition *p) {
fprintf(out,
"%s{\"body\": \"%s\", \"sign\": \"%s\", \"degree_in_sign\": %.4f, "
"\"ecliptic_longitude\": %.4f, \"house\": %d}",
indent, astro_body_slug(body), astro_sign_slug(p->sign),
p->degree_in_sign, p->ecliptic_longitude, p->house);
}
void reading_print_json(const DailyReading *r, FILE *out) {
fprintf(out, "{\n");
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
for (int b = 0; b < NUM_BODIES; b++) {
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
fprintf(out, "%s\n", b + 1 < NUM_BODIES ? "," : "");
}
fprintf(out, " ],\n \"ascendant_longitude\": %.4f,\n \"houses\": [",
r->natal.ascendant_longitude);
for (int h = 0; h < 12; h++) {
fprintf(out, "\"%s\"%s", astro_sign_slug(r->natal.houses[h]), h + 1 < 12 ? ", " : "");
}
fprintf(out, "]\n },\n");
fprintf(out, " \"transits\": {\n \"bodies\": [\n");
for (int b = 0; b < NUM_BODIES; b++) {
print_body_position_json(out, " ", (Body)b, &r->transits.bodies[b]);
fprintf(out, "%s\n", b + 1 < NUM_BODIES ? "," : "");
}
fprintf(out, " ],\n \"moon_phase\": \"%s\",\n \"aspects\": [\n",
astro_moon_phase_slug(r->transits.moon_phase));
for (int i = 0; i < r->transits.aspect_count; i++) {
const Aspect *a = &r->transits.aspects[i];
fprintf(out,
" {\"transiting_planet\": \"%s\", \"natal_planet\": \"%s\", "
"\"type\": \"%s\", \"orb\": %.4f}%s\n",
astro_body_slug(a->transiting_planet), astro_body_slug(a->natal_planet),
astro_aspect_slug(a->type), a->orb,
i + 1 < r->transits.aspect_count ? "," : "");
}
fprintf(out, " ]\n },\n");
fprintf(out, " \"spread\": {\n \"positions\": [\n");
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &r->spread.positions[i];
fprintf(out, " {\"position\": \"%s\", \"card\": \"%s\", \"reversed\": %s}%s\n",
tarot_position_slug((CelticCrossPosition)i), tarot_card_slug(draw->card),
draw->reversed ? "true" : "false", i + 1 < TAROT_SPREAD_SIZE ? "," : "");
}
fprintf(out, " ]\n }\n}\n");
}
+6
View File
@@ -23,4 +23,10 @@ void reading_generate(const char *tarot_seed, const BirthData *birth,
void reading_print_text(const DailyReading *r, FILE *out);
void reading_print_html(const DailyReading *r, FILE *out);
/* Machine-readable serialization of the full struct, using the
* language-independent *_slug() accessors (never astro_*_name()/
* tarot_*_name()) so the output is identical regardless of --lang. This
* is the interchange format interpreter/ reads - see its json.c. */
void reading_print_json(const DailyReading *r, FILE *out);
#endif
+7
View File
@@ -70,4 +70,11 @@ const char *tarot_card_meaning(TarotCard card, bool reversed);
const char *tarot_position_name(CelticCrossPosition position);
const char *tarot_position_description(CelticCrossPosition position);
/* Stable, language-independent identifiers (e.g. "high_priestess",
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
* keys are built from. Used for machine-readable output
* (reading_print_json) that must stay identical regardless of --lang. */
const char *tarot_card_slug(TarotCard card);
const char *tarot_position_slug(CelticCrossPosition position);
#endif
+3
View File
@@ -259,3 +259,6 @@ const char *tarot_position_name(CelticCrossPosition position) {
const char *tarot_position_description(CelticCrossPosition position) {
return position_field(k_position_slug[position], "desc", k_positions[position].description);
}
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
+44
View File
@@ -94,6 +94,49 @@ static void test_reading_generate_smoke(void) {
printf("PASS test_reading_generate_smoke\n");
}
static void test_reading_print_json_shape(void) {
BirthData birth = {
.year = 1990, .month = 5, .day = 14,
.hour = 14, .minute = 32,
.utc_offset_hours = 2.0,
.latitude = 52.52, .longitude = 13.405,
};
DailyReading r;
reading_generate("smoke-test-seed", &birth, 1751500000, &r);
FILE *tmp = tmpfile();
assert(tmp);
reading_print_json(&r, tmp);
rewind(tmp);
char buf[16384];
size_t n = fread(buf, 1, sizeof buf - 1, tmp);
buf[n] = '\0';
fclose(tmp);
/* Loose structural checks - not a full JSON validator, just enough to
* catch a broken serializer (unbalanced braces, a missing section, or
* a stray astro_*_name()/tarot_*_name() call leaking translated text
* into what must stay language-independent regardless of --lang). */
assert(buf[0] == '{');
assert(strstr(buf, "\"natal\"") != NULL);
assert(strstr(buf, "\"transits\"") != NULL);
assert(strstr(buf, "\"spread\"") != NULL);
assert(strstr(buf, "\"aspects\"") != NULL);
assert(strstr(buf, "\"body\": \"sun\"") != NULL); /* slug, not the display name */
assert(strstr(buf, "\"Sun\"") == NULL); /* never a *_name() value */
int braces = 0;
for (size_t i = 0; i < n; i++) {
if (buf[i] == '{') braces++;
if (buf[i] == '}') braces--;
}
assert(braces == 0);
printf("PASS test_reading_print_json_shape\n");
}
/* Run with cwd == engine/ (as `make test` does), so the shipped
* engine/i18n/ .lang files are reachable as i18n/<code>.lang. */
static void test_i18n_fallback_and_translation(void) {
@@ -130,6 +173,7 @@ int main(void) {
test_tarot_determinism();
test_natal_sun_sign();
test_reading_generate_smoke();
test_reading_print_json_shape();
test_i18n_fallback_and_translation();
printf("All smoke tests passed.\n");
return 0;