Compare commits
11 Commits
41422adf98
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a3e437277f | |||
| ff2b43f1e0 | |||
| 4e83ebbf50 | |||
| 7a77c4a07d | |||
| 1ebbfebe31 | |||
| b94e76931a | |||
| 1a654da3e9 | |||
| d9b6e2889b | |||
| 83248561cc | |||
| e522b4d6f5 | |||
| 1662d550c7 |
@@ -0,0 +1,84 @@
|
|||||||
|
name: build
|
||||||
|
|
||||||
|
# Build a versioned Linux CLI release tarball (see `make package`) and the
|
||||||
|
# Pebble watchapp (see `make watchapp`) on every push/PR, plus on-demand
|
||||||
|
# via the Gitea "Run workflow" button. Runs inside build-image (see
|
||||||
|
# ../../build-image/Dockerfile, built/pushed via ../../build-image.sh and
|
||||||
|
# ../../upload-image.sh), which bundles the Pebble SDK alongside the C
|
||||||
|
# toolchain.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# Runner defaults `run:` steps to `sh`, which doesn't understand
|
||||||
|
# `set -o pipefail` used below — force bash explicitly.
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
# Must match a label your act_runner is registered with.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
container:
|
||||||
|
image: cr.ladkau.de/deck-in-a-dash/builder:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# `make package`/`make watchapp`'s version comes from `git
|
||||||
|
# describe --tags` - needs full history/tags, not
|
||||||
|
# actions/checkout's default shallow single-commit clone.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Preflight
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v cc >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: no C compiler (cc) in PATH" >&2; exit 1; }
|
||||||
|
command -v make >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: make not in PATH" >&2; exit 1; }
|
||||||
|
command -v pebble >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: pebble not in PATH" >&2; exit 1; }
|
||||||
|
|
||||||
|
- name: Build and test
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
make test
|
||||||
|
make package
|
||||||
|
make watchapp
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
# v4 uses the newer @actions/artifact backend, which this Gitea
|
||||||
|
# instance's artifact storage doesn't support (GHESNotSupportedError) — v3 works.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: deck-in-a-dash
|
||||||
|
path: dist/deck-in-a-dash-*-linux-*.tar.gz
|
||||||
|
|
||||||
|
- name: Upload watchapp artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: deck-in-a-dash-watchapp
|
||||||
|
path: dist/deck-in-a-dash-*.pbw
|
||||||
|
|
||||||
|
- name: Publish to dl.ladkau.de
|
||||||
|
# Uploads the tarball and .pbw over SFTP instead of using
|
||||||
|
# actions/upload-artifact (whose zip wrapping can't be disabled).
|
||||||
|
# Only runs on push so PR builds don't publish.
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TARBALL="$(ls dist/deck-in-a-dash-*-linux-*.tar.gz)"
|
||||||
|
PBW="$(ls dist/deck-in-a-dash-*.pbw)"
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||||
|
chmod 600 ~/.ssh/dl_sftp_key
|
||||||
|
sftp -i ~/.ssh/dl_sftp_key -P 2223 \
|
||||||
|
-o StrictHostKeyChecking=accept-new \
|
||||||
|
uploader@dl.ladkau.de <<EOF
|
||||||
|
-mkdir files/deck-in-a-dash
|
||||||
|
put $TARBALL files/deck-in-a-dash/$(basename "$TARBALL")
|
||||||
|
put $PBW files/deck-in-a-dash/$(basename "$PBW")
|
||||||
|
EOF
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
# data once filled in — never commit it.
|
# data once filled in — never commit it.
|
||||||
/dist
|
/dist
|
||||||
/build
|
/build
|
||||||
|
/watch/build
|
||||||
|
/watch/src/c/i18n_tables.auto.c
|
||||||
|
/watch/resources/img
|
||||||
|
/watch/resources/app_icon.png
|
||||||
|
|
||||||
|
# waf's own build lock file - regenerated every `pebble build`, holds
|
||||||
|
# absolute machine paths and a full dump of the invoking shell's
|
||||||
|
# environment variables. Was tracked accidentally; see the commit that
|
||||||
|
# added this .gitignore line for the git rm --cached that untracked it.
|
||||||
|
/watch/.lock-waf_linux_build
|
||||||
|
|
||||||
# Durable backup of the same real birth data, outside dist/ so a dist/
|
# 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
|
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
|
||||||
@@ -13,3 +23,7 @@ core
|
|||||||
core.*
|
core.*
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Container registry credentials for build-image.sh/run-image.sh/
|
||||||
|
# upload-image.sh — never commit; copy registry.env.example instead.
|
||||||
|
/registry.env
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Project goal
|
## Project goal
|
||||||
|
|
||||||
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
||||||
Cross) and astrology (natal chart + transits) reading. Currently only the
|
Cross) and astrology (natal chart + transits) reading. This repository
|
||||||
**core engine** exists: a plain-C, dependency-free library plus a
|
holds the **core engine**: a plain-C, dependency-free library plus a
|
||||||
standalone CLI binary (`dist/deck-engine`) that can be built and run
|
standalone CLI binary (`dist/deck-engine`) that can be built and run
|
||||||
without any Pebble SDK or emulator. The watchapp itself (Pebble C/UI,
|
without any Pebble SDK or emulator, deliberately built so it links
|
||||||
resource packs) has not been started yet — the engine is deliberately
|
straight into Pebble C/UI watch code without rework (see "Portability to
|
||||||
built so it can be linked straight into it later without rework (see
|
the watch" below).
|
||||||
"Portability to the watch" below).
|
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
@@ -20,6 +19,10 @@ make # builds dist/{deck-engine,interpreter-cli}, dist/img/, dist/i18n/
|
|||||||
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
|
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
|
||||||
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
|
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
|
||||||
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
|
make clean # removes build/ and the generated binaries/img/i18n (never touches dist/user.properties)
|
||||||
|
make package # builds a versioned Linux CLI tarball at dist/ - see "Packaging" below
|
||||||
|
make watchapp # builds the Pebble watchapp and copies a versioned .pbw to
|
||||||
|
# dist/ - see "Packaging" below. Requires the Pebble SDK
|
||||||
|
# (not needed for anything else above); not run by CI.
|
||||||
```
|
```
|
||||||
|
|
||||||
A single `Makefile` at the repo root builds both `engine/` and
|
A single `Makefile` at the repo root builds both `engine/` and
|
||||||
@@ -64,6 +67,87 @@ 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,
|
temporarily, or just read its assertions — there's no test filter flag,
|
||||||
the whole suite runs in milliseconds.
|
the whole suite runs in milliseconds.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
`make package` (a `Makefile` target, not a separate script) builds a
|
||||||
|
fresh `all` and bundles it into a versioned, self-contained Linux CLI
|
||||||
|
tarball at `dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz` -
|
||||||
|
`deck-engine`, `interpreter-cli`, `run-engine.sh`/`run-interpreter.sh`,
|
||||||
|
`img/`, `i18n/`, `LICENSE`, `README.md`, `docs/reading.md` (renamed
|
||||||
|
`READING.md`), and a placeholder `user.properties` (from
|
||||||
|
`engine/scripts/user.properties.template`, **never**
|
||||||
|
`dist/user.properties` itself - see below). Extracting the tarball
|
||||||
|
anywhere and running the scripts/binaries from inside it works exactly
|
||||||
|
like running them from `dist/` in place, since `run-engine.sh`/
|
||||||
|
`run-interpreter.sh` already resolve every path relative to their own
|
||||||
|
location (`SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"`), not the
|
||||||
|
caller's working directory.
|
||||||
|
|
||||||
|
**Version**: defaults to the current commit's git tag, matching
|
||||||
|
`vX.Y.Z` (the `v` is stripped); if `HEAD` isn't exactly on such a tag,
|
||||||
|
it falls back to a `0.0.0-dev+<short-sha>` placeholder with a warning on
|
||||||
|
stderr. Override either with `make package VERSION=1.2.3`. Push a
|
||||||
|
`vX.Y.Z` tag to drive a real release version.
|
||||||
|
|
||||||
|
**Never packages `dist/user.properties`**: the staging step copies
|
||||||
|
files into the package by explicit name only - never a wildcard or
|
||||||
|
recursive copy of `dist/` itself - specifically so a filled-in
|
||||||
|
`dist/user.properties` (real birth data, gitignored, private) can never
|
||||||
|
end up in a distributable package. The package always gets the
|
||||||
|
placeholder template instead, identical to what a first-time `make`
|
||||||
|
seeds `dist/user.properties` with.
|
||||||
|
|
||||||
|
**CI** (`.gitea/workflows/build.yml`): runs `make test && make package`
|
||||||
|
on every push/PR (plus manual dispatch). The resulting tarball is both
|
||||||
|
kept as a Gitea Actions run artifact and, on `push` only (not PRs),
|
||||||
|
published over SFTP to `dl.ladkau.de` under `files/deck-in-a-dash/` -
|
||||||
|
requires a `DL_SFTP_KEY` secret configured on this repo (or inherited
|
||||||
|
from the org/instance level).
|
||||||
|
|
||||||
|
### Watch app packaging (`make watchapp`)
|
||||||
|
|
||||||
|
A separate target, deliberately **not** a prerequisite of `all` - it
|
||||||
|
shells out to `pebble build` inside `watch/` (via `watch/wscript`'s own
|
||||||
|
Pebble SDK/waf tooling, not this Makefile's plain-`cc` rules) and copies
|
||||||
|
the resulting `watch/build/watch.pbw` to
|
||||||
|
`dist/deck-in-a-dash-<version>.pbw`, using the exact same version
|
||||||
|
resolution as `make package` above (current git tag, or `make watchapp
|
||||||
|
VERSION=1.2.3` to override - see that section, not duplicated here).
|
||||||
|
Requires the Pebble SDK (`pebble` on `PATH`, plus the Python 3 + Pillow
|
||||||
|
`watch/wscript` needs for its resource-generation scripts) - a machine
|
||||||
|
without it can still build/test/package the CLI via every other target
|
||||||
|
in this file.
|
||||||
|
|
||||||
|
`.gitea/workflows/build.yml` **does** run `make watchapp` on every
|
||||||
|
push/PR, alongside `make test`/`make package` - the whole job runs
|
||||||
|
inside a container image (`build-image/`, built and pushed with
|
||||||
|
`build-image.sh`/`upload-image.sh`, see "Build image" below) that has
|
||||||
|
the Pebble SDK baked in, so the CI runner itself needs no toolchain
|
||||||
|
beyond Docker access to pull that image. Both the CLI tarball and the
|
||||||
|
`.pbw` are uploaded as build artifacts and published to
|
||||||
|
`dl.ladkau.de/files/deck-in-a-dash/` on push.
|
||||||
|
|
||||||
|
### Build image
|
||||||
|
|
||||||
|
`build-image/Dockerfile` pins the exact toolchain versions (Pebble
|
||||||
|
Tool, Pebble SDK core, Node.js) needed for `make test`, `make package`,
|
||||||
|
and `make watchapp`, so builds are reproducible independent of whatever
|
||||||
|
happens to be installed on a given machine. Three root-level scripts
|
||||||
|
drive it, each reading registry settings from a gitignored
|
||||||
|
`registry.env` (copy `registry.env.example` to create it - never commit
|
||||||
|
the real file, since it holds registry credentials) and the image tag
|
||||||
|
from `build-image/VERSION`:
|
||||||
|
|
||||||
|
- `build-image.sh` - builds and locally tags the image (`:VERSION` and
|
||||||
|
`:latest`), no push.
|
||||||
|
- `run-image.sh [VERSION]` - runs `make test package watchapp` inside
|
||||||
|
the locally-built image against a live bind-mount of the repo, wiping
|
||||||
|
generated build artifacts first for CI parity. Use this to validate a
|
||||||
|
`build-image/Dockerfile` change before pushing the image anywhere.
|
||||||
|
- `upload-image.sh` - logs in and pushes the image (`:VERSION` and
|
||||||
|
`:latest`) to the registry; `.gitea/workflows/build.yml` pulls
|
||||||
|
`:latest` to run its build job (see "Watch app packaging" above).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -92,19 +176,20 @@ deck_in_a_dash/
|
|||||||
tests/smoke_test.c
|
tests/smoke_test.c
|
||||||
scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
|
scripts/run-engine.sh, scripts/run-interpreter.sh, scripts/user.properties.template
|
||||||
interpreter/ Separate module + binary; see "Interpretation" below.
|
interpreter/ Separate module + binary; see "Interpretation" below.
|
||||||
dist/ Build output (gitignored-style; see Build & run).
|
dist/ Build output (gitignored-style; see Build & run) and,
|
||||||
Makefile Single root Makefile, builds both engine/ and interpreter/.
|
after `make package`, versioned release tarballs.
|
||||||
|
Makefile Single root Makefile, builds both engine/ and
|
||||||
|
interpreter/, and packages a release (see Packaging).
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data flow / the real API
|
### Data flow / the real API
|
||||||
|
|
||||||
`reading_generate(seed, birth, utc_moment, &DailyReading)` in
|
`reading_generate(seed, birth, utc_moment, &DailyReading)` in
|
||||||
`reading.h` is the single entry point that matters — it's what the
|
`reading.h` is the single entry point that matters — the real API
|
||||||
watchapp will call once it exists. It populates a plain struct
|
surface. It populates a plain struct (`NatalChart` + `DailyTransits` +
|
||||||
(`NatalChart` + `DailyTransits` + `CelticCrossSpread`) that the caller
|
`CelticCrossSpread`) that a caller walks directly to build a UI.
|
||||||
walks directly to build a UI. `reading_print_text`/`reading_print_html`
|
`reading_print_text`/`reading_print_html` are desktop-only text/HTML
|
||||||
are desktop-only conveniences for inspecting a reading (used by the CLI);
|
renderers of that same struct, used by the CLI.
|
||||||
the watchapp will never call them.
|
|
||||||
|
|
||||||
### Determinism (`rng.c`)
|
### Determinism (`rng.c`)
|
||||||
|
|
||||||
@@ -162,6 +247,23 @@ more common (but less original) ordering; don't use it as a reference for
|
|||||||
the position enum order. Card and position text in `tarot_data.c` is
|
the position enum order. Card and position text in `tarot_data.c` is
|
||||||
condensed from Waite's own wording in Part III §3 (public domain).
|
condensed from Waite's own wording in Part III §3 (public domain).
|
||||||
|
|
||||||
|
**Gender** (`Gender` in `tarot.h`: `GENDER_UNSPECIFIED`/`GENDER_MALE`/
|
||||||
|
`GENDER_FEMALE`) is who the reading is for — it only affects the
|
||||||
|
pronouns in the 9 Celtic Cross position names/descriptions that have one
|
||||||
|
(Waite addresses the querent as "him" throughout; every other position
|
||||||
|
text in this codebase — card meanings, narrative, guidance — is already
|
||||||
|
pronoun-neutral or direct "you" address, so nothing else varies with
|
||||||
|
it). `DailyReading.gender` is set by `reading_generate()` the same way
|
||||||
|
`utc_moment` is, `deck-engine`'s `--gender male|female|unspecified`
|
||||||
|
flag/`dist/user.properties`' `gender=` field/the watch config page's
|
||||||
|
Gender field all feed it, and `reading_print_json`/`reading_load_json`
|
||||||
|
carry it across the `deck-engine` → `interpreter-cli` JSON boundary
|
||||||
|
(`tarot_gender_slug()`) the same way `date` does. `GENDER_UNSPECIFIED`
|
||||||
|
is the default and renders pronoun-neutral phrasing (singular "they" in
|
||||||
|
English; German rephrases around a noun like "die fragende Person"
|
||||||
|
rather than a pronoun, since German has no equivalent singular-they
|
||||||
|
construction in common use).
|
||||||
|
|
||||||
### Translations (`i18n.c`, `engine/i18n/*.lang`)
|
### Translations (`i18n.c`, `engine/i18n/*.lang`)
|
||||||
|
|
||||||
All display text (planet/sign/moon-phase/aspect names, tarot card names
|
All display text (planet/sign/moon-phase/aspect names, tarot card names
|
||||||
@@ -179,51 +281,67 @@ Keys are namespaced by the *slug* tables in `tarot_data.c`/`astro.c`
|
|||||||
`moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
|
`moonphase.<slug>`, `aspect.<slug>`), plus `ui.*` keys for `reading.c`'s
|
||||||
own labels — deliberately keyed off separate slug strings rather than
|
own labels — deliberately keyed off separate slug strings rather than
|
||||||
the C enum names, so renaming an enumerator can never silently break a
|
the C enum names, so renaming an enumerator can never silently break a
|
||||||
`.lang` file. **To add a language**: copy `engine/i18n/en.lang` to
|
`.lang` file. `position.<slug>.name/desc` additionally take an optional
|
||||||
|
`.male`/`.female` suffix (e.g. `position.attitude.name.male`) for the
|
||||||
|
`Gender`-varying wording (see "Tarot" above); the unsuffixed key is
|
||||||
|
looked up first as the fallback for a missing gendered key, so a
|
||||||
|
position whose wording doesn't vary by gender only needs the one
|
||||||
|
unsuffixed key. **To add a language**: copy `engine/i18n/en.lang` to
|
||||||
`<code>.lang` in the same directory (keys must match exactly) and
|
`<code>.lang` in the same directory (keys must match exactly) and
|
||||||
translate the values — no source changes needed, `make` picks up any
|
translate the values — no source changes needed, `make` picks up any
|
||||||
`*.lang` file there automatically.
|
`*.lang` file there automatically.
|
||||||
|
|
||||||
`i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and
|
`i18n_load` uses stdio (`fopen`/`fgets`), so — like `main.c` and
|
||||||
`reading_print_*` — it's a desktop-only entry point; `i18n_get` itself
|
`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
|
is a pure fixed-size-array lookup with no heap allocation, so it links
|
||||||
to port to the watch once it has its own (non-file-based) way to
|
into the watch as-is given a non-file-based way to populate the catalog,
|
||||||
populate the catalog, e.g. from a compiled-in resource.
|
e.g. from a compiled-in resource.
|
||||||
|
|
||||||
### Portability to the watch
|
### Portability to the watch
|
||||||
|
|
||||||
`rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate`
|
`rng`, `tarot`, `tarot_data`, `astro`, `i18n_get`, and `reading_generate`
|
||||||
are kept free of `stdio`/CLI assumptions specifically so they can link
|
are kept free of `stdio`/CLI assumptions specifically so they link into
|
||||||
into the Pebble watchapp unchanged later. Only `reading_print_text`/
|
the Pebble watchapp unchanged. Only `reading_print_text`/`_html` (text
|
||||||
`_html` (text formatting), `main.c` (CLI arg parsing), and `i18n_load`
|
formatting), `main.c` (CLI arg parsing), and `i18n_load` (reads a file
|
||||||
(reads a file from disk) are desktop-only and won't port as-is.
|
from disk) are desktop-only and don't port as-is.
|
||||||
|
|
||||||
## Interpretation (`interpreter/`)
|
## Interpretation (`interpreter/`)
|
||||||
|
|
||||||
A second, separate top-level module and **its own binary**
|
A second, separate top-level module and **its own binary**
|
||||||
(`dist/interpreter-cli`, sibling to `dist/deck-engine`) that scores *how
|
(`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
|
significant a day's transits are*, prints a short narrative sentence for
|
||||||
a short narrative sentence about what that transit classically means —
|
each of the top items about what that transit classically means, reads
|
||||||
the first piece of a larger "turn the raw reading into an actual
|
out the full Celtic Cross spread with every card's meaning, and finally
|
||||||
narrative" effort described in project chat history; the Celtic Cross
|
prints guidance tying the day's top transit to the spread's
|
||||||
spread itself isn't woven into that narrative yet (see
|
Attitude/Outcome cards; see `docs/reading.md`. Like `deck-engine`, it
|
||||||
`docs/reading.md`). `deck-engine` itself is deliberately unchanged by
|
supports `--format text|html|json` and `--lang <code>`/`--i18n-dir
|
||||||
this beyond gaining `--format json`; the two binaries compose over that
|
<path>` for its own output - see `main.c`'s bullets below for both.
|
||||||
JSON, never by linking together:
|
`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
|
```bash
|
||||||
make # -> dist/deck-engine (unchanged; --format json is new) and dist/interpreter-cli
|
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
|
make test # builds and runs both engine/tests/*_test.c and interpreter/tests/*_test.c
|
||||||
|
|
||||||
dist/deck-engine ... --format json | dist/interpreter-cli
|
dist/deck-engine ... --format json | dist/interpreter-cli [--format text|html|json] [--lang <code>]
|
||||||
# or: dist/deck-engine ... --format json > reading.json && dist/interpreter-cli reading.json
|
# or: 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):
|
# 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
|
Note `--lang` means something different on each side of the pipe:
|
||||||
separate compilation units throughout — no object file is ever shared
|
`deck-engine`'s own `--lang` (if passed at all) is irrelevant here, since
|
||||||
between the two binaries (see the next bullet).
|
`--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
|
- `interpret_daily_reading(reading, &out)` (`significance.h/.c`) scores
|
||||||
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
|
every aspect in `reading->transits.aspects[]`, keeps the **top 5** by
|
||||||
@@ -234,17 +352,15 @@ between the two binaries (see the next bullet).
|
|||||||
purely so callers can print the level as a rank, e.g. `main.c`'s
|
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
|
`"%s (%d/%d)"` → `"Notable (2/4)"` — `interp.day_level + 1` out of
|
||||||
`DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true
|
`DAY_SIGNIFICANCE_COUNT`. `interpretation_deserves_framing()` is true
|
||||||
only at `DAY_MAJOR` — the intended hook for giving the Celtic Cross
|
only at `DAY_MAJOR` — `main.c` uses it solely to decide whether to
|
||||||
reading a "this matters" intro sentence naming `top_items[0]` on days
|
print an extra "worth a deeper Celtic Cross look" line; it's not a
|
||||||
that earn it; that rendering doesn't exist yet, `main.c`'s report is
|
gate on `guidance.c` (below), which runs on every day level.
|
||||||
plain text only.
|
|
||||||
- Score = `transiting-planet weight (slow/outer planets score higher) ×
|
- Score = `transiting-planet weight (slow/outer planets score higher) ×
|
||||||
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
|
orb tightness (1.0 = exact, ~0 = at the edge) × natal-luminary bonus
|
||||||
(transits to natal Sun/Moon score higher than to other planets)`. The
|
(transits to natal Sun/Moon score higher than to other planets)`. The
|
||||||
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
|
weights and the `DAY_QUIET`/`DAY_NOTABLE`/`DAY_SIGNIFICANT`/`DAY_MAJOR`
|
||||||
thresholds in `significance.c` are hand-tuned, not derived from
|
thresholds in `significance.c` are hand-tuned, not derived from
|
||||||
anything physical — expect to retune them once real readings are
|
anything physical.
|
||||||
compared against how "major" a day actually feels.
|
|
||||||
- **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence
|
- **`narrative.c`/`narrative.h`** supplies `main.c`'s one-sentence
|
||||||
description printed under each top item, condensed from Sepharial's
|
description printed under each top item, condensed from Sepharial's
|
||||||
*Transits and Planetary Periods* (1920, public domain —
|
*Transits and Planetary Periods* (1920, public domain —
|
||||||
@@ -267,7 +383,81 @@ between the two binaries (see the next bullet).
|
|||||||
source (same status as the sign/aspect names already in `astro.c`).
|
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`
|
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
|
uses elsewhere) omits that framing and prints the planet's narrative on
|
||||||
its own.
|
its own. Every string here (`k_narratives[]`'s `base`/`harmonious`/
|
||||||
|
`discordant`, `k_house_area[]`, and the "In matters of ..." framing
|
||||||
|
template itself) is looked up via `i18n_get()` under `narrative.*` keys
|
||||||
|
before falling back to this English text - `engine/i18n/de.lang` has a
|
||||||
|
full German translation under the same keys.
|
||||||
|
- `main.c` calls `i18n_load()` once at startup (same pattern as
|
||||||
|
`engine/src/main.c`, including the same `default_i18n_path()` helper,
|
||||||
|
duplicated rather than shared since the two binaries are never linked
|
||||||
|
- see the compilation-units bullet above) before producing any output,
|
||||||
|
so every `i18n_get()` call anywhere in the interpreter - not just
|
||||||
|
`tarot_data.c`'s, but `narrative.c`'s/`guidance.c`'s own (below) -
|
||||||
|
respects `--lang`. `--format text`'s report is printed in a fixed
|
||||||
|
order: significance level → top significant transits (each with its
|
||||||
|
`narrative.c` sentence) → the full Celtic Cross spread
|
||||||
|
(`print_celtic_cross_text()`, every position in Waite's own drawing
|
||||||
|
order, each with its card, orientation, position description, and
|
||||||
|
card meaning — via the real `tarot_position_name()`/`tarot_card_name()`/
|
||||||
|
`tarot_position_description()`/`tarot_card_meaning()` accessors, not a
|
||||||
|
duplicated table, since `tarot_data.c` is linked into this binary) →
|
||||||
|
`guidance.c`'s paragraph, deliberately printed **last**, so it reads
|
||||||
|
as the closing takeaway after the reader has seen the full spread it
|
||||||
|
references. `--format html` mirrors `deck-engine`'s own HTML styling
|
||||||
|
and reuses the same `img/<file>` card-art convention (`tarot_card_
|
||||||
|
image_file()`); `--format json` embeds the same localized narrative/
|
||||||
|
guidance prose as `--format text` (in whatever `--lang` was loaded)
|
||||||
|
*alongside* stable, language-independent slugs (`transiting_planet`,
|
||||||
|
`aspect`, `card`, `position`, etc., reusing the same small local slug
|
||||||
|
tables `reading_io.c` needs for parsing) — a deliberate departure from
|
||||||
|
`reading_print_json`'s "slugs only, never prose" policy (next bullet),
|
||||||
|
justified because rendering that prose *is* this module's job, unlike
|
||||||
|
`deck-engine`'s JSON which is purely a machine interchange format.
|
||||||
|
`narrative_print()`/`guidance_print()` only know how to write to a
|
||||||
|
`FILE *`, so `--format json` captures their output into a heap string
|
||||||
|
via POSIX `open_memstream()` (`capture_narrative()`/`capture_guidance()`)
|
||||||
|
rather than changing either module's public API just for this one
|
||||||
|
caller.
|
||||||
|
- **`guidance.c`/`guidance.h`** is the combined-storytelling piece: it
|
||||||
|
ties `interp->top_items[0]` (the day's single most significant
|
||||||
|
transit) to the Celtic Cross spread and prints a short "how to meet
|
||||||
|
the day" paragraph on *every* day — `main.c` calls it unconditionally
|
||||||
|
after every `interpret_daily_reading()`, with no `day_level` gate. The
|
||||||
|
core guidance keys off two things: the top transit's aspect character
|
||||||
|
(harmonious/discordant/neutral, same classification `narrative.c`
|
||||||
|
uses) crossed with whether the spread's *Attitude* position — Waite's
|
||||||
|
"Himself: his position or attitude in the circumstances", the
|
||||||
|
position most directly about how the reader is meeting the day — fell
|
||||||
|
upright or reversed (3 × 2 = 6 combinations); this text stays valid
|
||||||
|
regardless of the day's intensity. What *does* vary with
|
||||||
|
`interp->day_level` is only the sentence introducing the top transit
|
||||||
|
(`k_intro[DAY_SIGNIFICANCE_COUNT]`, one `%s` each) — e.g. "With Saturn
|
||||||
|
as today's dominant influence" on `DAY_MAJOR` vs. "Saturn is only
|
||||||
|
faintly active today, but for what it's worth" on `DAY_QUIET`. A day
|
||||||
|
with no aspects in orb at all (`top_item_count == 0`, always
|
||||||
|
`DAY_QUIET`) has no transiting planet to introduce, so it falls back
|
||||||
|
to `k_no_transit_stance`, keyed on the Attitude card's orientation
|
||||||
|
alone. All of this guidance text is original, written for this
|
||||||
|
project, since neither Waite nor Sepharial discuss combining astrology
|
||||||
|
and tarot. The paragraph also names the Attitude and Outcome cards and
|
||||||
|
states their actual meaning via `tarot_card_meaning()` (e.g. what
|
||||||
|
Wheel of Fortune reversed means) — the real Waite text, not a
|
||||||
|
duplicated table, for the same reason as `print_celtic_cross_text()`
|
||||||
|
above. Every string here (stance/intro/no-transit/label text, plus a
|
||||||
|
`guidance.planet.*` table used only for the intro sentence's planet
|
||||||
|
name - separate from `body.*` because "the Sun"/"the Moon" take a
|
||||||
|
definite article the other eight planets don't, in both English and
|
||||||
|
German) is looked up via `i18n_get()` under `guidance.*` keys before
|
||||||
|
falling back to this English text - `engine/i18n/de.lang` has a full
|
||||||
|
German translation. The German `guidance.intro.*` templates are
|
||||||
|
deliberately phrased so the planet name placeholder is always the
|
||||||
|
sentence's grammatical subject (nominative case) across all four day
|
||||||
|
levels, sidestepping the case-agreement problems a naive word-for-word
|
||||||
|
translation of the English templates would hit (e.g. "with Saturn"
|
||||||
|
needs dative in German, but "Saturn is only faintly active" needs
|
||||||
|
nominative - the four German templates are worded so the placeholder
|
||||||
|
never needs to change case).
|
||||||
- **`reading_print_json`** (`engine/src/reading.c`) is the wire format:
|
- **`reading_print_json`** (`engine/src/reading.c`) is the wire format:
|
||||||
the full `DailyReading` (natal, transits, spread), using the
|
the full `DailyReading` (natal, transits, spread), using the
|
||||||
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
|
language-independent `astro_*_slug()`/`tarot_*_slug()` accessors
|
||||||
@@ -283,31 +473,37 @@ between the two binaries (see the next bullet).
|
|||||||
and fills a `DailyReading` with those (used for scoring), plus
|
and fills a `DailyReading` with those (used for scoring), plus
|
||||||
`"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign
|
`"natal"."bodies"`/`"transits"."bodies"` (each body's sign + whole-sign
|
||||||
house, via `parse_bodies()` — used only for the sign/house context
|
house, via `parse_bodies()` — used only for the sign/house context
|
||||||
`main.c` prints alongside each significant event, never for scoring).
|
`main.c` prints alongside each significant event, never for scoring)
|
||||||
`"spread"` is left zeroed entirely - nothing reads it. An aspect naming
|
and `"spread"."positions"` (each position's card + reversed flag, via
|
||||||
a planet/aspect-type slug it doesn't recognize, or a document missing
|
`parse_spread()` — used by `main.c`'s full Celtic Cross readout and by
|
||||||
`"transits"."aspects"` entirely (as opposed to a present-but-empty
|
`guidance.c`'s Attitude/Outcome framing, never for scoring). An
|
||||||
array, which is a valid "no aspects today"), makes the whole load fail;
|
aspect naming a planet/aspect-type slug it doesn't recognize, or a
|
||||||
by contrast an unrecognized body slug or missing `house` inside
|
document missing `"transits"."aspects"` entirely (as opposed to a
|
||||||
`"bodies"` is silently skipped (that body's `house` stays `0`, an
|
present-but-empty array, which is a valid "no aspects today"), makes
|
||||||
invalid whole-sign house number used as "no data" - `main.c` checks for
|
the whole load fail; by contrast an unrecognized body/card/position
|
||||||
it before printing sign/house), since that's supplementary display
|
slug, a missing `house` inside `"bodies"`, or a missing `"spread"` key
|
||||||
context rather than something scoring depends on.
|
entirely is silently skipped/zeroed (`main.c` checks for the body
|
||||||
- **Deliberately header-only dependency on the engine, throughout**:
|
"no data" sentinel before printing sign/house), since all of that is
|
||||||
`significance.h`/`reading_io.h`/`narrative.h` `#include` engine headers
|
supplementary display context rather than something scoring depends
|
||||||
(`reading.h`/`astro.h`) for the struct/enum *definitions*, but the root
|
on.
|
||||||
`Makefile` never compiles or links any engine `.c` file (or the
|
- **Mostly header-only dependency on the engine**: `significance.h`/
|
||||||
vendored Astronomy Engine) into the interpreter's binary or tests —
|
`reading_io.h`/`narrative.h`/`guidance.h` `#include` engine headers
|
||||||
every
|
(`reading.h`/`astro.h`/`tarot.h`) for the struct/enum *definitions*,
|
||||||
test in `interpreter/tests/` runs against hand-built JSON text or
|
and the root `Makefile` never compiles or links `astro.c`, `tarot.c`,
|
||||||
hand-built `DailyReading` values, with no real ephemeris/tarot-draw
|
`rng.c`, `reading.c`, or the vendored Astronomy Engine into the
|
||||||
call involved anywhere. Consequently `reading_io.c` (planet/aspect/sign
|
interpreter's binary or tests — every test in `interpreter/tests/`
|
||||||
slugs) and `main.c` (planet/aspect/sign display names) each duplicate a
|
runs against hand-built JSON text or hand-built `DailyReading`/
|
||||||
small local table rather than linking `astro.c` to reuse its own -
|
`DailyInterpretation` values, with no real ephemeris/tarot-draw call
|
||||||
keep those in sync if the engine's slugs/names ever change.
|
involved anywhere. Consequently `reading_io.c` (planet/aspect/sign/
|
||||||
- Only `Aspect`s compete for the top 5 so far (`SignificantItemKind` is
|
card/position slugs) and `main.c` (planet/aspect/sign display names)
|
||||||
currently just `ITEM_ASPECT`); moon phase and house ingresses are
|
each duplicate a small local table rather than linking `astro.c` to
|
||||||
candidate future item kinds but aren't scored yet.
|
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
|
## Licensing
|
||||||
|
|
||||||
@@ -317,11 +513,28 @@ between the two binaries (see the next bullet).
|
|||||||
`VENDORED.md` for the pinned upstream commit.
|
`VENDORED.md` for the pinned upstream commit.
|
||||||
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
|
- Card/spread text in `tarot_data.c`/`engine/i18n/en.lang`: condensed
|
||||||
from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
|
from A. E. Waite's *The Pictorial Key to the Tarot* (1911), public
|
||||||
domain.
|
domain. The `GENDER_MALE` variant of the Celtic Cross position text
|
||||||
|
keeps Waite's own "him"/"his" wording verbatim; the
|
||||||
|
`GENDER_UNSPECIFIED`/`GENDER_FEMALE` variants are original paraphrases
|
||||||
|
written for this project, not Waite's own text (see "Tarot" above).
|
||||||
- `engine/i18n/de.lang`'s card/spread text is an original translation of
|
- `engine/i18n/de.lang`'s card/spread text is an original translation of
|
||||||
that same public-domain English text, made for this project — not
|
that same public-domain English text, made for this project — not
|
||||||
taken from any specific published German edition.
|
taken from any specific published German edition. Its `Gender`-varying
|
||||||
|
position text follows the same policy as the English original: `.male`
|
||||||
|
is a direct translation of Waite's own wording, `.female`/unsuffixed
|
||||||
|
are original paraphrases for this project.
|
||||||
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
|
- Transit narrative text in `interpreter/src/narrative.c`: condensed from
|
||||||
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
Sepharial's *Transits and Planetary Periods* (1920, public domain —
|
||||||
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
`res/Transits_and_Planetary_Periods.pdf`), except the Moon and Pluto
|
||||||
entries, which are original (see narrative.c's own comment for why).
|
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).
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
CC ?= cc
|
CC ?= cc
|
||||||
CFLAGS ?= -std=c99 -Wall -Wextra -O2
|
CFLAGS ?= -std=c99 -Wall -Wextra -O2
|
||||||
|
# Static by default so dist/deck-engine and dist/interpreter-cli (and the
|
||||||
|
# `package` tarball built from them) don't depend on the build machine's
|
||||||
|
# glibc version at runtime - a binary linked against a newer glibc than a
|
||||||
|
# user's system has refuses to run at all ("GLIBC_2.3x not found"), which
|
||||||
|
# is exactly the kind of thing a downloaded release package must not hit.
|
||||||
|
# Override with `LDFLAGS= make` if static libc/libm aren't available.
|
||||||
|
LDFLAGS ?= -static
|
||||||
LDLIBS := -lm
|
LDLIBS := -lm
|
||||||
|
|
||||||
BUILD_DIR := build
|
BUILD_DIR := build
|
||||||
@@ -36,41 +43,66 @@ ENGINE_TEST_BINARY := $(BUILD_DIR)/smoke_test
|
|||||||
|
|
||||||
# ---- interpreter ----
|
# ---- interpreter ----
|
||||||
|
|
||||||
# Only ever compiles this module's own sources - significance.h/
|
# Mostly compiles only this module's own sources - significance.h/
|
||||||
# reading_io.h reach into engine/src/ for struct/enum *definitions*
|
# reading_io.h/narrative.h reach into engine/src/ for struct/enum
|
||||||
# (plain #includes), but no engine/src/*.c (or the vendored Astronomy
|
# *definitions* (plain #includes) without linking any engine/src/*.c (or
|
||||||
# Engine) is compiled or linked here. That's what keeps this module (and
|
# the vendored Astronomy Engine). That's what keeps those modules (and
|
||||||
# its tests) independent of the engine's actual ephemeris/tarot-draw
|
# their tests) independent of the engine's actual ephemeris/tarot-draw
|
||||||
# implementation - see significance.c's and reading_io.c's own comments
|
# implementation - see significance.c's and reading_io.c's own comments
|
||||||
# for why each duplicates a small table instead of linking astro.c.
|
# 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_SRC_DIR := interpreter/src
|
||||||
INTERP_TEST_DIR := interpreter/tests
|
INTERP_TEST_DIR := interpreter/tests
|
||||||
|
|
||||||
INTERP_LIB_SRCS := $(INTERP_SRC_DIR)/significance.c $(INTERP_SRC_DIR)/json.c \
|
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_LIB_OBJS := $(patsubst %.c,$(OBJ_DIR)/%.o,$(INTERP_LIB_SRCS))
|
||||||
INTERP_MAIN_OBJ := $(OBJ_DIR)/$(INTERP_SRC_DIR)/main.o
|
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
|
INTERP_BINARY := $(DIST_DIR)/interpreter-cli
|
||||||
|
|
||||||
SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/significance_test.o
|
SIGNIFICANCE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/significance_test.o
|
||||||
JSON_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/json_test.o
|
JSON_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/json_test.o
|
||||||
NARRATIVE_TEST_OBJ := $(OBJ_DIR)/$(INTERP_TEST_DIR)/narrative_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
|
SIGNIFICANCE_TEST_BIN := $(BUILD_DIR)/significance_test
|
||||||
JSON_TEST_BIN := $(BUILD_DIR)/json_test
|
JSON_TEST_BIN := $(BUILD_DIR)/json_test
|
||||||
NARRATIVE_TEST_BIN := $(BUILD_DIR)/narrative_test
|
NARRATIVE_TEST_BIN := $(BUILD_DIR)/narrative_test
|
||||||
|
GUIDANCE_TEST_BIN := $(BUILD_DIR)/guidance_test
|
||||||
|
|
||||||
.PHONY: all test clean images i18n scripts
|
# Linux CLI release tarball - see the `package` target below.
|
||||||
|
ARCH := $(shell uname -m)
|
||||||
|
PACKAGE_DIR := $(BUILD_DIR)/package
|
||||||
|
|
||||||
|
# ---- watch app (Pebble) ----
|
||||||
|
|
||||||
|
WATCH_DIR := watch
|
||||||
|
WATCH_PBW := $(WATCH_DIR)/build/watch.pbw
|
||||||
|
|
||||||
|
.PHONY: all test clean images i18n scripts package watchapp
|
||||||
|
|
||||||
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
|
all: $(ENGINE_BINARY) $(INTERP_BINARY) images i18n scripts
|
||||||
|
|
||||||
$(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ)
|
$(ENGINE_BINARY): $(ENGINE_OBJS) $(ENGINE_MAIN_OBJ)
|
||||||
@mkdir -p $(DIST_DIR)
|
@mkdir -p $(DIST_DIR)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
|
$(CC) $(CFLAGS) $(LDFLAGS) -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)
|
@mkdir -p $(DIST_DIR)
|
||||||
$(CC) $(CFLAGS) -o $@ $^
|
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||||
|
|
||||||
# The HTML report expects card art at img/<file> next to deck-engine, so
|
# The HTML report expects card art at img/<file> next to deck-engine, so
|
||||||
# every build refreshes a copy alongside it (cheap: 22 small JPEGs).
|
# every build refreshes a copy alongside it (cheap: 22 small JPEGs).
|
||||||
@@ -109,29 +141,104 @@ scripts:
|
|||||||
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \
|
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template $(DIST_DIR)/user.properties; \
|
||||||
fi
|
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)
|
./$(ENGINE_TEST_BINARY)
|
||||||
./$(SIGNIFICANCE_TEST_BIN)
|
./$(SIGNIFICANCE_TEST_BIN)
|
||||||
./$(JSON_TEST_BIN)
|
./$(JSON_TEST_BIN)
|
||||||
./$(NARRATIVE_TEST_BIN)
|
./$(NARRATIVE_TEST_BIN)
|
||||||
|
./$(GUIDANCE_TEST_BIN)
|
||||||
|
|
||||||
$(ENGINE_TEST_BINARY): $(ENGINE_OBJS) $(ENGINE_TEST_OBJ)
|
$(ENGINE_TEST_BINARY): $(ENGINE_OBJS) $(ENGINE_TEST_OBJ)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)
|
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(LDLIBS)
|
||||||
|
|
||||||
$(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ)
|
$(SIGNIFICANCE_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/significance.o $(SIGNIFICANCE_TEST_OBJ)
|
||||||
$(CC) $(CFLAGS) -o $@ $^
|
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^
|
||||||
|
|
||||||
$(JSON_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/json.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/reading_io.o $(JSON_TEST_OBJ)
|
$(JSON_TEST_BIN): $(OBJ_DIR)/$(INTERP_SRC_DIR)/json.o $(OBJ_DIR)/$(INTERP_SRC_DIR)/reading_io.o $(JSON_TEST_OBJ)
|
||||||
$(CC) $(CFLAGS) -o $@ $^
|
$(CC) $(CFLAGS) $(LDFLAGS) -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 $@ $^
|
$(CC) $(CFLAGS) $(LDFLAGS) -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) $(LDFLAGS) -o $@ $^
|
||||||
|
|
||||||
$(OBJ_DIR)/%.o: %.c
|
$(OBJ_DIR)/%.o: %.c
|
||||||
@mkdir -p $(dir $@)
|
@mkdir -p $(dir $@)
|
||||||
$(CC) $(CFLAGS) -c -o $@ $<
|
$(CC) $(CFLAGS) -c -o $@ $<
|
||||||
|
|
||||||
|
# Builds a versioned Linux CLI release tarball at
|
||||||
|
# dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz. Version defaults to
|
||||||
|
# the current git tag (vX.Y.Z, tag prefix stripped) - push a tag to drive
|
||||||
|
# a release. Override with `make package VERSION=1.2.3`, or just run it
|
||||||
|
# untagged for a local dev build (gets a 0.0.0-dev+<sha> placeholder
|
||||||
|
# version, with a warning).
|
||||||
|
#
|
||||||
|
# Copies files into the staging dir by explicit name only - never a
|
||||||
|
# wildcard/recursive copy of dist/ itself - so a real, filled-in
|
||||||
|
# dist/user.properties (birth data, gitignored, private) can never
|
||||||
|
# accidentally end up in a package. The package gets the placeholder
|
||||||
|
# template instead, same as a first-time `make` produces.
|
||||||
|
package: all
|
||||||
|
@V="$(VERSION)"; \
|
||||||
|
if [ -z "$$V" ]; then \
|
||||||
|
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
|
||||||
|
V=$${TAG#v}; \
|
||||||
|
else \
|
||||||
|
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
|
||||||
|
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
|
||||||
|
fi; \
|
||||||
|
fi; \
|
||||||
|
case "$$V" in \
|
||||||
|
[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||||
|
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
|
||||||
|
esac; \
|
||||||
|
PKG_NAME="deck-in-a-dash-$$V-linux-$(ARCH)"; \
|
||||||
|
PKG_STAGE="$(PACKAGE_DIR)/$$PKG_NAME"; \
|
||||||
|
echo "Packaging $$PKG_NAME"; \
|
||||||
|
rm -rf "$$PKG_STAGE"; \
|
||||||
|
mkdir -p "$$PKG_STAGE"; \
|
||||||
|
cp $(ENGINE_BINARY) $(INTERP_BINARY) "$$PKG_STAGE/"; \
|
||||||
|
cp $(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh "$$PKG_STAGE/"; \
|
||||||
|
cp -r $(DIST_IMG_DIR) $(DIST_I18N_DIR) "$$PKG_STAGE/"; \
|
||||||
|
cp $(ENGINE_SCRIPTS_DIR)/user.properties.template "$$PKG_STAGE/user.properties"; \
|
||||||
|
cp LICENSE README.md "$$PKG_STAGE/"; \
|
||||||
|
cp docs/reading.md "$$PKG_STAGE/READING.md"; \
|
||||||
|
tar -czf "$(DIST_DIR)/$$PKG_NAME.tar.gz" -C "$(PACKAGE_DIR)" "$$PKG_NAME"; \
|
||||||
|
rm -rf "$(PACKAGE_DIR)"; \
|
||||||
|
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
|
rm -rf $(BUILD_DIR) $(ENGINE_BINARY) $(INTERP_BINARY) $(DIST_IMG_DIR) $(DIST_I18N_DIR) \
|
||||||
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
|
$(DIST_DIR)/run-engine.sh $(DIST_DIR)/run-interpreter.sh
|
||||||
# user.properties holds the user's own data - never removed by clean.
|
# user.properties holds the user's own data - never removed by clean.
|
||||||
|
|
||||||
|
# Builds the Pebble watchapp (`pebble build` in watch/, via its own
|
||||||
|
# wscript/pebble_sdk waf tooling - not a plain C toolchain, so this is a
|
||||||
|
# separate target from `all` rather than one of its prerequisites) and
|
||||||
|
# copies the resulting .pbw into dist/, versioned the same way `package`
|
||||||
|
# above versions the CLI tarball (current git tag, or
|
||||||
|
# `make watchapp VERSION=1.2.3` to override). Requires the Pebble SDK
|
||||||
|
# (`pebble` on PATH, plus Python 3 + Pillow for watch/scripts/*.py - see
|
||||||
|
# watch/wscript) - this is NOT checked by the root `make`/`make test`,
|
||||||
|
# so a machine without the Pebble SDK can still build/test the CLI.
|
||||||
|
watchapp:
|
||||||
|
cd $(WATCH_DIR) && pebble build
|
||||||
|
@mkdir -p $(DIST_DIR)
|
||||||
|
@V="$(VERSION)"; \
|
||||||
|
if [ -z "$$V" ]; then \
|
||||||
|
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
|
||||||
|
V=$${TAG#v}; \
|
||||||
|
else \
|
||||||
|
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
|
||||||
|
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
|
||||||
|
fi; \
|
||||||
|
fi; \
|
||||||
|
case "$$V" in \
|
||||||
|
[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||||
|
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
|
||||||
|
esac; \
|
||||||
|
PBW_NAME="deck-in-a-dash-$$V.pbw"; \
|
||||||
|
cp $(WATCH_PBW) "$(DIST_DIR)/$$PBW_NAME"; \
|
||||||
|
echo "Wrote $(DIST_DIR)/$$PBW_NAME"
|
||||||
|
|||||||
@@ -7,11 +7,10 @@
|
|||||||
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
A Pebble smartwatch app that produces a daily personalized tarot (Celtic
|
||||||
Cross) and astrology (natal chart + transits) reading.
|
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
|
library and standalone CLI (`dist/deck-engine`) that computes a full
|
||||||
reading without needing a Pebble device, emulator, or SDK. The watchapp
|
reading without needing a Pebble device, emulator, or SDK, designed so
|
||||||
itself hasn't been built yet; the engine is designed so it can be linked
|
it links straight into Pebble watch code without rework.
|
||||||
into it later without rework.
|
|
||||||
|
|
||||||
## What it computes
|
## What it computes
|
||||||
|
|
||||||
@@ -45,12 +44,28 @@ engine/
|
|||||||
the user.properties template.
|
the user.properties template.
|
||||||
dist/ Build output (gitignored) — see below.
|
dist/ Build output (gitignored) — see below.
|
||||||
interpreter/ Separate module + binary (dist/interpreter-cli):
|
interpreter/ Separate module + binary (dist/interpreter-cli):
|
||||||
scores how significant a day's transits are
|
scores how significant a day's transits are,
|
||||||
and prints a short narrative note for each,
|
reads out the full Celtic Cross spread with
|
||||||
reading deck-engine's --format json output
|
every card's meaning, and closes with guidance
|
||||||
(see CLAUDE.md).
|
(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).
|
||||||
|
watch/ The actual Pebble watchapp - links engine/src/
|
||||||
|
and interpreter/src/ in directly (see CLAUDE.md's
|
||||||
|
"Portability to the watch"), plus its own
|
||||||
|
persistence, reading recompute, UI, daily
|
||||||
|
notification, and config page. Built separately
|
||||||
|
from the rest of this repo (`make watchapp`,
|
||||||
|
below) - requires the Pebble SDK.
|
||||||
docs/ Architecture/dataflow diagrams, input/output format reference.
|
docs/ Architecture/dataflow diagrams, input/output format reference.
|
||||||
Makefile Single root Makefile, builds both engine/ and interpreter/.
|
build-image/ Dockerfile for the reproducible build environment
|
||||||
|
(C toolchain + Pebble SDK) used by CI and by the
|
||||||
|
build-image.sh/run-image.sh/upload-image.sh scripts
|
||||||
|
at the repo root (see "Building" below).
|
||||||
|
Makefile Single root Makefile, builds both engine/ and
|
||||||
|
interpreter/, and packages a release (`make package`).
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`CLAUDE.md`](CLAUDE.md) for the detailed architecture and the sharp
|
See [`CLAUDE.md`](CLAUDE.md) for the detailed architecture and the sharp
|
||||||
@@ -66,10 +81,37 @@ struct format reference
|
|||||||
make # -> dist/deck-engine, dist/interpreter-cli, dist/img/, dist/i18n/,
|
make # -> dist/deck-engine, dist/interpreter-cli, dist/img/, dist/i18n/,
|
||||||
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
|
# dist/run-engine.sh, dist/run-interpreter.sh, dist/user.properties
|
||||||
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
|
make test # builds and runs engine/tests/smoke_test.c and interpreter/tests/*_test.c
|
||||||
|
make package # -> dist/deck-in-a-dash-<version>-linux-<arch>.tar.gz, a
|
||||||
|
# self-contained release bundle (binaries, scripts, img/, i18n/,
|
||||||
|
# LICENSE, docs) - version from the current git tag, or
|
||||||
|
# VERSION=1.2.3 make package to override
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires only a C99 compiler and `make` — no other dependencies.
|
Requires only a C99 compiler and `make` — no other dependencies.
|
||||||
|
|
||||||
|
**Building the watch app itself** (`watch/`, the actual Pebble app)
|
||||||
|
needs the separate Pebble SDK (`pebble` on `PATH`), so it's a separate
|
||||||
|
target, not part of `make`/`make test`/`make package` above:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make watchapp # -> dist/deck-in-a-dash-<version>.pbw, same version
|
||||||
|
# resolution as `make package`
|
||||||
|
```
|
||||||
|
|
||||||
|
**CI runs `make watchapp` too** — `.gitea/workflows/build.yml` builds
|
||||||
|
and tests everything inside a container built from `build-image/`,
|
||||||
|
which bundles the Pebble SDK alongside the C toolchain so the runner
|
||||||
|
itself doesn't need either installed. To build/test that image
|
||||||
|
yourself, copy `registry.env.example` to `registry.env` and fill in
|
||||||
|
your registry credentials, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build-image.sh # build and locally tag the image
|
||||||
|
./run-image.sh # run `make test package watchapp` inside it, against
|
||||||
|
# this checkout, for a CI-equivalent local build
|
||||||
|
./upload-image.sh # push the image so CI can pull it
|
||||||
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
**Daily use**, via `dist/run-engine.sh`: pulls today's date and the
|
**Daily use**, via `dist/run-engine.sh`: pulls today's date and the
|
||||||
@@ -94,16 +136,23 @@ dist/deck-engine --seed "2026-07-03" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Interpreter** (`interpreter/`, built by the same root `make`):
|
**Interpreter** (`interpreter/`, built by the same root `make`):
|
||||||
scores how significant a day's transits are, reading `deck-engine`'s
|
scores how significant a day's transits are, reads out the full Celtic
|
||||||
`--format json` output — the two binaries compose over that JSON, they're
|
Cross spread with every card's meaning, and closes with guidance tying
|
||||||
never linked together. `dist/run-interpreter.sh` is the daily-use
|
the day's biggest transit to the Attitude/Outcome cards - reading
|
||||||
equivalent of `run-engine.sh` (same OS clock + `user.properties` inputs),
|
`deck-engine`'s `--format json` output. Its own output supports the same
|
||||||
piping straight into `interpreter-cli`:
|
`--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
|
```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:
|
# 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
|
## License
|
||||||
@@ -124,3 +173,10 @@ Transit narrative text in `interpreter/src/narrative.c` is condensed
|
|||||||
from Sepharial's *Transits and Planetary Periods* (1920), also in the
|
from Sepharial's *Transits and Planetary Periods* (1920), also in the
|
||||||
public domain, except the Moon and Pluto entries, which are original
|
public domain, except the Moon and Pluto entries, which are original
|
||||||
(see the file's own comment).
|
(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.
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the release build environment image (build-image/Dockerfile)
|
||||||
|
# locally, tagged with build-image/VERSION and :latest. Does not push -
|
||||||
|
# test it with ./run-image.sh first, then publish with ./upload-image.sh.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/registry.env" ] \
|
||||||
|
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
|
||||||
|
|
||||||
|
echo "== Building $REGISTRY_IMAGE:$VERSION =="
|
||||||
|
docker build \
|
||||||
|
-t "$REGISTRY_IMAGE:$VERSION" \
|
||||||
|
-t "$REGISTRY_IMAGE:latest" \
|
||||||
|
"$ROOT/build-image"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $REGISTRY_IMAGE:$VERSION (and :latest)"
|
||||||
|
echo "Test it with ./run-image.sh, then publish with ./upload-image.sh"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Build environment for deck_in_a_dash release artifacts: a plain C
|
||||||
|
# toolchain for the desktop deck-engine/interpreter-cli binaries (`make
|
||||||
|
# test`, `make package`) plus the Pebble SDK for the watch app (`make
|
||||||
|
# watchapp`), pinned to the versions validated on the maintainer's dev
|
||||||
|
# machine. Rebuild with ../build-image.sh whenever a version below
|
||||||
|
# changes.
|
||||||
|
#
|
||||||
|
# Does NOT contain any secrets - registry/SFTP credentials are injected
|
||||||
|
# at job runtime from Gitea Actions secrets, never baked into this image.
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
ARG PEBBLE_TOOL_VERSION=5.0.39
|
||||||
|
ARG PEBBLE_SDK_CORE_VERSION=4.17
|
||||||
|
ARG NODE_VERSION=24.16.0
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
PATH=/root/.local/bin:${PATH}
|
||||||
|
|
||||||
|
# build-essential: gcc/make for the desktop deck-engine/interpreter-cli
|
||||||
|
# binaries (`make test`, `make package`).
|
||||||
|
# python3/python3-venv/python3-pip: `pebble sdk install` below creates a
|
||||||
|
# venv per SDK version; Pillow (installed below) is needed at `pebble
|
||||||
|
# build` time by watch/scripts/gen_card_images.py and gen_app_icon.py.
|
||||||
|
# git/curl/ca-certificates/unzip/tar/xz-utils: checkout, tool downloads,
|
||||||
|
# and `make package`/`make watchapp`'s `git describe --tags` versioning.
|
||||||
|
# openssh-client: the build workflow's `sftp` publish to dl.ladkau.de.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential python3 python3-venv python3-pip \
|
||||||
|
git curl ca-certificates unzip tar xz-utils openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN pip3 install --no-cache-dir Pillow
|
||||||
|
|
||||||
|
# Node.js: `pebble sdk install` below runs `npm install` for the SDK
|
||||||
|
# core's bundled webpack tooling - it does not bring its own node/npm.
|
||||||
|
RUN curl -sSL -o /tmp/node.tar.xz \
|
||||||
|
"https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
|
||||||
|
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
|
||||||
|
&& rm /tmp/node.tar.xz
|
||||||
|
|
||||||
|
# Pebble SDK: pebble-tool (via uv, pipx-style isolated install) + the
|
||||||
|
# sdk-core toolchain/webpack deps for PEBBLE_SDK_CORE_VERSION, both
|
||||||
|
# fully resolved here so no `docker run` of this image needs network
|
||||||
|
# access to install anything before `pebble build` works.
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||||
|
&& uv tool install "pebble-tool==${PEBBLE_TOOL_VERSION}" \
|
||||||
|
&& pebble sdk install "${PEBBLE_SDK_CORE_VERSION}"
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1
|
||||||
@@ -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`.
|
(MIT), pinned to a specific commit — see its `VENDORED.md`.
|
||||||
- **`engine/src/`** is the core engine. Everything except `main.c`,
|
- **`engine/src/`** is the core engine. Everything except `main.c`,
|
||||||
`reading.c`'s `reading_print_text`/`reading_print_html` functions, and
|
`reading.c`'s `reading_print_text`/`reading_print_html` functions, and
|
||||||
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions,
|
`i18n.c`'s file-loading half is free of `stdio`/CLI assumptions, so it
|
||||||
specifically so it can be linked into the Pebble watchapp later without
|
links straight into a Pebble watchapp without rework.
|
||||||
rework.
|
|
||||||
- `rng.c` — deterministic string-seeded PRNG (FNV-1a + splitmix64).
|
- `rng.c` — deterministic string-seeded PRNG (FNV-1a + splitmix64).
|
||||||
- `tarot.c` / `tarot_data.c` — the Celtic Cross draw and its content.
|
- `tarot.c` / `tarot_data.c` — the Celtic Cross draw and its content.
|
||||||
- `astro.c` — natal chart + daily transits, built on the vendored
|
- `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
|
`engine/scripts/user.properties.local` (gitignored) once it's filled
|
||||||
in, and restored from there if `dist/` is ever wiped — see
|
in, and restored from there if `dist/` is ever wiped — see
|
||||||
`CLAUDE.md`'s "Build & run".
|
`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
|
## Dataflow
|
||||||
|
|
||||||
@@ -64,9 +60,10 @@ computations and combines their results:
|
|||||||
with position and upright/reversed orientation, fully determined by
|
with position and upright/reversed orientation, fully determined by
|
||||||
the seed string alone.
|
the seed string alone.
|
||||||
|
|
||||||
These three results are combined into one `DailyReading` struct, which is
|
These three results are combined into one `DailyReading` struct — the
|
||||||
either rendered by `reading_print_text`/`reading_print_html` (used by the
|
real API surface. `reading_print_text`/`reading_print_html` (used by the
|
||||||
CLI) or, in the future, walked directly by watchapp UI code. See
|
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
|
[`input-output-format.md`](input-output-format.md) for the exact fields
|
||||||
and output formats.
|
and output formats.
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,6 @@ digraph architecture {
|
|||||||
{ rank=same; binary; distimg; disti18n; runsh; props; }
|
{ 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];
|
pdf -> tarot_data [label="sourced from", style=dotted, color=gray40];
|
||||||
|
|
||||||
tarot -> rng;
|
tarot -> rng;
|
||||||
@@ -78,6 +72,4 @@ digraph architecture {
|
|||||||
images -> distimg [label="`make images`\ncopies", style=dotted, color=gray40];
|
images -> distimg [label="`make images`\ncopies", style=dotted, color=gray40];
|
||||||
langfiles -> disti18n [label="`make i18n`\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];
|
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];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ digraph dataflow {
|
|||||||
|
|
||||||
print_text [label="reading_print_text()", fillcolor="#cfe8fb"];
|
print_text [label="reading_print_text()", fillcolor="#cfe8fb"];
|
||||||
print_html [label="reading_print_html()", 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_text [label="stdout: plain-text report", shape=parallelogram, fillcolor="#c8f0c8"];
|
||||||
out_html [label="stdout: reading.html\n(<img src=\"img/...\"> per card)", shape=parallelogram, fillcolor="#c8f0c8"];
|
out_html [label="stdout: reading.html\n(<img src=\"img/...\"> per card)", shape=parallelogram, fillcolor="#c8f0c8"];
|
||||||
out_watch [label="watch screens\n(future)", shape=parallelogram, fillcolor=white, color=firebrick, fontcolor=firebrick, style="dashed"];
|
|
||||||
|
|
||||||
cli -> main;
|
cli -> main;
|
||||||
runsh -> main;
|
runsh -> main;
|
||||||
@@ -59,9 +57,7 @@ digraph dataflow {
|
|||||||
|
|
||||||
daily_reading -> print_text;
|
daily_reading -> print_text;
|
||||||
daily_reading -> print_html;
|
daily_reading -> print_html;
|
||||||
daily_reading -> watch_fn [style=dashed, color=firebrick];
|
|
||||||
|
|
||||||
print_text -> out_text;
|
print_text -> out_text;
|
||||||
print_html -> out_html;
|
print_html -> out_html;
|
||||||
watch_fn -> out_watch [style=dashed, color=firebrick];
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 236 KiB |
@@ -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`
|
There are two ways to provide input (direct CLI flags, or `run-engine.sh`
|
||||||
+ `user.properties`) and three output formats (`text`, `html`, `json`); the
|
+ `user.properties`) and three output formats (`text`, `html`, `json`); the
|
||||||
`DailyReading` struct is the programmatic form all three are rendered
|
`DailyReading` struct is the programmatic form all three are rendered
|
||||||
from, and the one the future watchapp will consume directly. `json` is
|
from, and the one watch UI code consumes directly. `json` is also
|
||||||
also `interpreter-cli`'s input format — see "The interpreter" at the
|
`interpreter-cli`'s input format — see "The interpreter" at the bottom
|
||||||
bottom of this file.
|
of this file, which supports the same three output formats (and the
|
||||||
|
same `--lang`) for its own report, independently of `deck-engine`.
|
||||||
|
|
||||||
## Input
|
## Input
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ bottom of this file.
|
|||||||
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
||||||
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
--birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>
|
||||||
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
|
[--date YYYY-MM-DDTHH:MM] [--format text|html|json]
|
||||||
|
[--gender male|female|unspecified]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Flag | Required | Format | Meaning |
|
| Flag | Required | Format | Meaning |
|
||||||
@@ -28,6 +30,7 @@ deck-engine --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM
|
|||||||
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
| `--birth-lon` | yes | decimal degrees | Birth longitude, east positive. |
|
||||||
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
| `--date` | no | `YYYY-MM-DDTHH:MM[:SS]` | UTC moment used for today's transits and as the moment the tarot seed is drawn against. Defaults to the current system time. |
|
||||||
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
|
| `--format` | no | `text` \| `html` \| `json` | Output format, defaults to `text`. `json` always uses language-independent identifiers, ignoring `--lang`. |
|
||||||
|
| `--gender` | no | `male` \| `female` \| `unspecified` | Who the reading is for. Only affects the Celtic Cross position text's pronouns (e.g. "His House" vs. "Her House" vs. "Their House") - never the chart/draw itself. Defaults to `unspecified` (pronoun-neutral phrasing). |
|
||||||
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
|
| `--lang` | no | language code, e.g. `en`, `de` | Reading language, defaults to `en`. Matches a file named `<code>.lang` in `--i18n-dir` (see "Translations" below). Unknown codes or missing files fall back to English with a warning on stderr. |
|
||||||
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
|
| `--i18n-dir` | no | path | Directory to look for `<lang>.lang` in. Defaults to an `i18n/` directory next to the binary itself (resolved from `argv[0]`, so `dist/deck-engine` finds `dist/i18n/` regardless of the caller's working directory). |
|
||||||
|
|
||||||
@@ -51,6 +54,7 @@ use:
|
|||||||
birth_utc_offset=2
|
birth_utc_offset=2
|
||||||
birth_lat=52.5200
|
birth_lat=52.5200
|
||||||
birth_lon=13.4050
|
birth_lon=13.4050
|
||||||
|
gender=unspecified
|
||||||
lang=en
|
lang=en
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -58,8 +62,8 @@ use:
|
|||||||
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
|
with placeholder values (`YOUR_BIRTH_DATE_HERE`, etc.). `run-engine.sh`
|
||||||
checks every birth field for an empty value or a leftover `YOUR_`
|
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
|
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
|
filled in; `gender` and `lang` have no such check since they always
|
||||||
default (`en`). Since `dist/` itself can be wiped (`make clean`, or
|
have a usable default (`unspecified`/`en`). Since `dist/` itself can be wiped (`make clean`, or
|
||||||
deleting the directory), the build also keeps a durable backup at
|
deleting the directory), the build also keeps a durable backup at
|
||||||
`engine/scripts/user.properties.local` (gitignored) once the file
|
`engine/scripts/user.properties.local` (gitignored) once the file
|
||||||
looks filled in, and restores from it if `dist/user.properties` is
|
looks filled in, and restores from it if `dist/user.properties` is
|
||||||
@@ -80,6 +84,14 @@ catalog with the built-in English text as the fallback for any key the
|
|||||||
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
|
catalog doesn't have. See `CLAUDE.md`'s "Translations" section for the
|
||||||
key-naming convention and how to add a new language.
|
key-naming convention and how to add a new language.
|
||||||
|
|
||||||
|
Celtic Cross position text additionally varies with `--gender`: the
|
||||||
|
plain key (e.g. `position.present.desc`) is the pronoun-neutral wording
|
||||||
|
used for `unspecified`, and `.male`/`.female`-suffixed keys (e.g.
|
||||||
|
`position.present.desc.male`) hold the gendered wording where it
|
||||||
|
differs. A position whose wording doesn't vary by gender (e.g. "The
|
||||||
|
Outcome") simply has no `.male`/`.female` keys - the neutral key's
|
||||||
|
translation is used for every `--gender` value in that case.
|
||||||
|
|
||||||
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
|
`engine/i18n/en.lang` and `de.lang` are the shipped translation files;
|
||||||
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
|
`make` copies `engine/i18n/*.lang` to `dist/i18n/` (refreshed on every
|
||||||
build, like `dist/img/`). Adding a language is just dropping another
|
build, like `dist/img/`). Adding a language is just dropping another
|
||||||
@@ -90,8 +102,8 @@ build, like `dist/img/`). Adding a language is just dropping another
|
|||||||
### `DailyReading` (the real output — `reading.h`)
|
### `DailyReading` (the real output — `reading.h`)
|
||||||
|
|
||||||
`reading_generate()` is the actual API; `text`/`html` below are just two
|
`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
|
ways of rendering the struct it fills in. This is what watch UI code
|
||||||
read directly once it exists.
|
reads directly.
|
||||||
|
|
||||||
```c
|
```c
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -145,7 +157,7 @@ Transiting Sun Opposition natal Moon (orb 3.7°)
|
|||||||
|
|
||||||
===== Celtic Cross =====
|
===== Celtic Cross =====
|
||||||
The Present The High Priestess (Reversed)
|
The Present The High Priestess (Reversed)
|
||||||
This covers him: the general influence affecting the matter.
|
This covers them: the general influence affecting the matter.
|
||||||
Passion, moral or physical ardour, conceit, surface knowledge.
|
Passion, moral or physical ardour, conceit, surface knowledge.
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
@@ -170,8 +182,8 @@ cards rendered as an image:
|
|||||||
|
|
||||||
### `--format json`
|
### `--format json`
|
||||||
|
|
||||||
A single JSON object mirroring `DailyReading` exactly — `natal`,
|
A single JSON object mirroring `DailyReading` exactly — `date`, `gender`,
|
||||||
`transits`, `spread` — using the **slug** accessors
|
`natal`, `transits`, `spread` — using the **slug** accessors
|
||||||
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
|
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
|
||||||
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
|
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
|
||||||
rather than the display-name ones, so the output is identical regardless
|
rather than the display-name ones, so the output is identical regardless
|
||||||
@@ -181,6 +193,8 @@ read.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"date": "2026-07-16",
|
||||||
|
"gender": "unspecified",
|
||||||
"natal": {
|
"natal": {
|
||||||
"bodies": [
|
"bodies": [
|
||||||
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
|
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
|
||||||
@@ -209,6 +223,17 @@ read.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `date` is the UTC calendar date of `--date` (or the current system time
|
||||||
|
if `--date` was omitted) — the day this reading is *for*, not
|
||||||
|
necessarily the day it was generated on. `reading_generate()` stores
|
||||||
|
the full moment in `DailyReading.utc_moment`; only the date part is
|
||||||
|
serialized here since that's the only thing the rest of this format
|
||||||
|
(and the interpreter's own reports, which read this field back — see
|
||||||
|
"The interpreter" below) ever display.
|
||||||
|
- `gender` is `--gender`'s value (`male`/`female`/`unspecified`,
|
||||||
|
`tarot_gender_slug()`) — who the reading is for. Only affects the
|
||||||
|
Celtic Cross position text's pronouns, nothing else in this document.
|
||||||
|
The interpreter reads this field back the same way it reads `date`.
|
||||||
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
|
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
|
||||||
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
|
||||||
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
`CelticCrossPosition` enum order (Present, Challenge, Crown,
|
||||||
@@ -227,16 +252,39 @@ read.
|
|||||||
`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s
|
`dist/interpreter-cli` is a separate binary — see `CLAUDE.md`'s
|
||||||
"Interpretation" section for the full architecture — that reads a
|
"Interpretation" section for the full architecture — that reads a
|
||||||
`--format json` document and scores how significant today's transits
|
`--format json` document and scores how significant today's transits
|
||||||
are. It never links the engine; it only depends on the JSON shape
|
are. It never links `astro.c`/`tarot.c`/`reading.c` (or the vendored
|
||||||
above.
|
Astronomy Engine) — only `engine/src/tarot_data.c`/`i18n.c` are linked
|
||||||
|
in, for real card/position names and Waite meaning text (see
|
||||||
|
`CLAUDE.md`'s `INTERP_TAROT_TEXT_OBJS` note). Otherwise it depends only
|
||||||
|
on the JSON shape above.
|
||||||
|
|
||||||
|
Like `deck-engine`, its own output supports `--format text|html|json`
|
||||||
|
and `--lang <code>`/`--i18n-dir <path>`:
|
||||||
|
|
||||||
|
| Flag | Required | Format | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `[reading.json]` | no | file path or `-` | Input file; stdin (or `-`) if omitted. |
|
||||||
|
| `--format` | no | `text` \| `html` \| `json` | This binary's **own output** format, defaults to `text`. Entirely independent of the `--format json` that produced its *input* - see below. |
|
||||||
|
| `--lang` | no | language code, e.g. `en`, `de` | This binary's **own output** language, defaults to `en`. Same `<code>.lang`/`--i18n-dir` convention as `deck-engine` (see "Translations" above) - `main.c` calls `i18n_load()` itself, so `narrative.c`'s/`guidance.c`'s own text (in `engine/i18n/*.lang` under `narrative.*`/`guidance.*` keys) is translated too, not just the card/position/body/sign/aspect vocabulary it shares with `deck-engine`. |
|
||||||
|
| `--i18n-dir` | no | path | Same convention as `deck-engine`'s: defaults to an `i18n/` directory next to this binary. |
|
||||||
|
|
||||||
|
**This `--lang` is unrelated to whatever `--lang` `deck-engine` was run
|
||||||
|
with to produce the `--format json` input** - that JSON is always
|
||||||
|
language-independent (slugs only, see above), so it doesn't matter what
|
||||||
|
language `deck-engine` printed anything in, or whether it was even asked
|
||||||
|
to print anything at all (`--format json` never touches `--lang`
|
||||||
|
either). The two `--lang` flags, on the two sides of the pipe, are
|
||||||
|
independent settings.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dist/deck-engine ... --format json | dist/interpreter-cli
|
dist/deck-engine ... --format json | dist/interpreter-cli --format html --lang de
|
||||||
# or:
|
# or:
|
||||||
dist/deck-engine ... --format json > reading.json
|
dist/deck-engine ... --format json > reading.json
|
||||||
dist/interpreter-cli reading.json
|
dist/interpreter-cli --format json reading.json
|
||||||
# or, for daily use (OS clock + user.properties, same inputs as run-engine.sh):
|
# or, for daily use (OS clock + user.properties, same inputs as run-engine.sh,
|
||||||
dist/run-interpreter.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
|
- **Input**: a file path argument, or stdin if no argument (or `-`) is
|
||||||
@@ -244,30 +292,208 @@ dist/run-interpreter.sh
|
|||||||
means "no notable transits today"; a missing key is treated as invalid
|
means "no notable transits today"; a missing key is treated as invalid
|
||||||
input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are
|
input) and drives scoring. `"natal"."bodies"`/`"transits"."bodies"` are
|
||||||
read too, for the sign/house context printed alongside each event below
|
read too, for the sign/house context printed alongside each event below
|
||||||
(not for scoring) — an entry naming an unrecognized body, or a missing
|
(not for scoring), and `"spread"."positions"` is read for the full
|
||||||
`house`, is silently skipped rather than failing the load (see
|
Celtic Cross readout and the guidance paragraph below — an entry naming
|
||||||
`parse_bodies()` in `reading_io.c`); `spread` is always ignored.
|
an unrecognized body/card/position, a missing `house`, or a missing
|
||||||
- **Output**: a plain-text report — the day's overall significance level
|
`"spread"` key entirely is silently skipped/zeroed rather than failing
|
||||||
(`Quiet`/`Notable`/`Significant`/`Major`, plus its rank out of the 4
|
the load (see `parse_bodies()`/`parse_spread()` in `reading_io.c`).
|
||||||
defined levels, e.g. `Notable (2/4)`), a "worth a deeper Celtic Cross
|
- **Input**: same for all three output formats above - only how the
|
||||||
look" line on `Major` days only, and up to 5 of today's aspects ranked
|
parsed data gets rendered differs.
|
||||||
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
|
### `--format text`
|
||||||
aspecting, followed by a short narrative sentence about what that
|
|
||||||
transiting planet classically signifies *and which area of life its
|
A plain-text report, printed in a fixed order —
|
||||||
current house governs* (see `CLAUDE.md`'s `narrative.c` bullet) —
|
1. the day this reading is for (`Reading for: 2026-07-16`), from the
|
||||||
omitted only if the source material has nothing to say for that
|
input JSON's top-level `date` field (see `--format json` above) —
|
||||||
planet/aspect combination:
|
not necessarily the day the report is actually being read on, if
|
||||||
|
`reading.json` was generated earlier and piped into
|
||||||
|
`interpreter-cli` later;
|
||||||
|
2. 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;
|
||||||
|
3. 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;
|
||||||
|
4. 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;
|
||||||
|
5. 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: Significant (3/4)
|
Reading for: 2026-07-16
|
||||||
|
|
||||||
|
Day significance: Major (4/4)
|
||||||
(a major transit today - worth a deeper Celtic Cross look)
|
(a major transit today - worth a deeper Celtic Cross look)
|
||||||
|
|
||||||
Top 3 significant transits:
|
Top 5 significant transits:
|
||||||
1. Transiting Saturn in Aries (house 2) Square natal Sun in Taurus (house 3) (orb 0.5°, score 8.10)
|
1. Transiting Pluto in Aquarius (house 3) Conjunction natal Moon in Aquarius (house 3) (orb 2.9°, score 9.60)
|
||||||
In matters of money, possessions, and personal values (house 2). Brings depression, stagnation, hindrances and obstacles, and some deprivation of the usual benefits.
|
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 Pluto in Aquarius (house 12) Opposition natal Moon in Capricorn (house 11) (orb 0.2°, score 7.92)
|
2. Transiting Neptune in Aries (house 5) Sextile natal Moon in Aquarius (house 3) (orb 2.5°, score 9.25)
|
||||||
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.
|
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 Jupiter in Leo (house 5) Trine natal Venus in Aries (house 2) (orb 2.1°, score 3.40)
|
3. Transiting Uranus in Gemini (house 7) Trine natal Moon in Aquarius (house 3) (orb 2.0°, score 8.96)
|
||||||
In matters of romance, creativity, and children (house 5). Brings increase and expansion - fullness of fortune and health, and a generally fortunate time.
|
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 them: the general influence affecting the matter.
|
||||||
|
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
|
||||||
|
2. The Challenge: The Devil
|
||||||
|
This crosses them: the nature of the obstacle in the matter.
|
||||||
|
Ravage, violence, vehemence, extraordinary efforts, force, fatality.
|
||||||
|
...
|
||||||
|
7. Themself: The Magician
|
||||||
|
Their 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):
|
||||||
|
|
||||||
|
```
|
||||||
|
Lesung für: 2026-07-16
|
||||||
|
|
||||||
|
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 die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
|
Wohlwollen, Mitgefühl, Ansehen; auch Verwirrung der Feinde, Behinderung, Unreife.
|
||||||
|
...
|
||||||
|
|
||||||
|
Pluto ist heute der bestimmende Einfluss: Der heutige Tag verstärkt, was du bereits
|
||||||
|
mitbringst - deine derzeitige Herangehensweise wird verstärkt, also stelle sicher,
|
||||||
|
dass es die richtige ist.
|
||||||
|
Deine Haltungskarte ist Der Magier: Geschick, Diplomatie, Gewandtheit, Feinsinn; Selbstvertrauen, Wille.
|
||||||
|
Das wahrscheinliche Ergebnis des Blatts ist Kraft: Macht, Energie, Tatkraft, Mut, Großmut; voller Erfolg und Ehren.
|
||||||
|
```
|
||||||
|
|
||||||
|
### `--format html`
|
||||||
|
|
||||||
|
A single self-contained HTML page, same inline-CSS/no-JS approach as
|
||||||
|
`deck-engine`'s own `--format html`, and the same three sections as text
|
||||||
|
(significant transits as a table, the full Celtic Cross as an image
|
||||||
|
grid, guidance as a closing callout) - reusing the exact same
|
||||||
|
`img/<file>` card-art convention (`tarot_card_image_file()`), so it
|
||||||
|
expects to sit next to an `img/` directory just like `deck-engine`'s
|
||||||
|
HTML output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="card">
|
||||||
|
<div class="position">The Present</div>
|
||||||
|
<img class="reversed" src="img/RWS_Tarot_04_Emperor.jpeg" alt="The Emperor">
|
||||||
|
<div class="name">The Emperor (Reversed)</div>
|
||||||
|
<div class="desc">This covers them: the general influence affecting the matter.</div>
|
||||||
|
<div class="meaning">Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The guidance section renders as one `<p>` per line of the same text
|
||||||
|
`--format text` prints last (intro sentence, Attitude line, Outcome
|
||||||
|
line), inside a `<div class="guidance">`.
|
||||||
|
|
||||||
|
### `--format json`
|
||||||
|
|
||||||
|
Unlike `deck-engine`'s own `--format json` - which is deliberately
|
||||||
|
**language-independent** (slugs only, no `--lang`-dependent prose, since
|
||||||
|
it's a machine interchange format between the two binaries) -
|
||||||
|
`interpreter-cli`'s `--format json` embeds the *same localized
|
||||||
|
narrative/guidance prose* `--format text` prints, in whatever `--lang`
|
||||||
|
was loaded, **alongside** stable, language-independent slug fields. This
|
||||||
|
is a deliberate difference: rendering that prose is this module's whole
|
||||||
|
job, so a JSON consumer that wants it doesn't have to reimplement
|
||||||
|
`narrative.c`/`guidance.c`'s logic itself - but a consumer that only
|
||||||
|
wants structured data (e.g. watch UI code with its own rendering) still
|
||||||
|
has the slugs to work with directly.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"date": "2026-07-16",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `date` is copied straight through from the input JSON's own top-level
|
||||||
|
`date` field (see `--format json` above) - always language-independent
|
||||||
|
regardless of `--lang`, same as every other slug in this document.
|
||||||
|
- `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.
|
||||||
|
|||||||
@@ -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
|
moment you were born — your **natal chart**. Everything else the app
|
||||||
does is compared against that one fixed reference point.
|
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
|
## What happens every day
|
||||||
|
|
||||||
Each day, the app does two independent things and then combines them:
|
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
|
(Sepharial's *Transits and Planetary Periods*), the same way the tarot
|
||||||
card meanings are condensed from Waite's 1911 book.
|
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
|
## What a day's reading actually gives you
|
||||||
|
|
||||||
Put together, one day's reading contains: your natal chart (for
|
Put together, one day's reading contains: your natal chart (for
|
||||||
reference), today's sky and how it's speaking to your chart, how
|
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
|
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
|
main events driving that), your full 10-card Celtic Cross spread read
|
||||||
day. Right now these sit side by side — the astrology tells you *how
|
out card by card with each card's meaning, and — every day, not just
|
||||||
eventful* today is and *where* to pay attention, and the tarot spread
|
Major ones — a closing paragraph of guidance tying the day's biggest
|
||||||
gives you the actual guidance to sit with. They don't yet get woven
|
transit to your Attitude and Outcome cards specifically. Attitude and
|
||||||
into a single narrative (e.g. a spread explained *in light of* today's
|
Outcome get that treatment in the closing guidance; the other eight
|
||||||
significant transit) — that combined storytelling is the next piece to
|
positions are read in light of your natal chart and today's sky, the
|
||||||
build, not something you get today.
|
way the worked example below does by hand.
|
||||||
|
|
||||||
*(This page, like that piece, is still a work in progress — expect it
|
|
||||||
to grow as the reading itself does.)*
|
|
||||||
|
|
||||||
## A worked example
|
## A worked example
|
||||||
|
|
||||||
@@ -123,7 +159,7 @@ Sagittarius.
|
|||||||
are forming aspects back to the natal chart above — see below for which
|
are forming aspects back to the natal chart above — see below for which
|
||||||
ones actually matter.
|
ones actually matter.
|
||||||
|
|
||||||
**How big is today.**
|
**How big is today, and the significant transits.**
|
||||||
|
|
||||||
```
|
```
|
||||||
Day significance: Major (4/4)
|
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
|
from the 7th. Communication, romance, and relationships are all
|
||||||
quietly under pressure at once.
|
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.
|
Celtic Cross:
|
||||||
- *The Challenge* — The Devil: force, ravage, an obstacle that isn't
|
1. The Present: The Emperor (Reversed)
|
||||||
easily reasoned with.
|
This covers him: the general influence affecting the matter.
|
||||||
- *The Near Future* — The World, reversed: stagnation, things staying
|
Benevolence, compassion, credit; also confusion to enemies, obstruction, immaturity.
|
||||||
fixed in place rather than resolving.
|
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 —
|
Read side by side, the astrology and the tarot happen to rhyme here —
|
||||||
both point toward something that wants to shift (Pluto's "hidden
|
both point toward something that wants to shift (Pluto's "hidden
|
||||||
transformation," The Devil's "force") running into something that
|
transformation," The Devil's "force") running into something that
|
||||||
feels stuck (a reversed World, an afflicted Moon) — but that's the
|
feels stuck (a reversed World, an afflicted Moon). The closing guidance
|
||||||
reader's own synthesis for now, not something the app does for you (see
|
picks up part of that thread automatically — an upright Attitude card
|
||||||
above).
|
(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.
|
||||||
|
```
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ ui.transiting=Transit
|
|||||||
ui.natal=natal
|
ui.natal=natal
|
||||||
ui.orb=Orbis
|
ui.orb=Orbis
|
||||||
|
|
||||||
|
# --- Nur die Watch-App (watch/src/c/main.c) - wird vor der ersten
|
||||||
|
# Einrichtung ueber die Konfigurationsseite gezeigt, wenn noch keine
|
||||||
|
# Geburtsdaten fuer eine Deutung vorliegen ---
|
||||||
|
ui.setup_required_title=Einrichtung erforderlich
|
||||||
|
ui.setup_required_body=Öffne die Einstellungen dieser App in der Pebble-App, um deine Geburtsdaten einzugeben, und starte Deck in a Dash danach erneut.
|
||||||
|
|
||||||
# --- Planeten (astro.c) ---
|
# --- Planeten (astro.c) ---
|
||||||
body.sun=Sonne
|
body.sun=Sonne
|
||||||
body.moon=Mond
|
body.moon=Mond
|
||||||
@@ -65,25 +71,53 @@ aspect.square=Quadrat
|
|||||||
aspect.trine=Trigon
|
aspect.trine=Trigon
|
||||||
aspect.opposition=Opposition
|
aspect.opposition=Opposition
|
||||||
|
|
||||||
# --- Positionen des Keltischen Kreuzes (tarot_data.c) ---
|
# --- Positionen des Keltischen Kreuzes (tarot_data.c). Waite spricht die
|
||||||
|
# fragende Person durchgehend mit "ihn/ihm/sein" an; der unsuffigierte
|
||||||
|
# Schlüssel ist eine geschlechtsneutrale Umschreibung (über "die fragende
|
||||||
|
# Person"/"die eigene", ohne Pronomen), die bei nicht angegebenem
|
||||||
|
# Geschlecht verwendet wird, ".male" ist Waites eigener Wortlaut
|
||||||
|
# unverändert, ".female" eine für dieses Projekt geschriebene Umschreibung -
|
||||||
|
# siehe tarot_data.c's Kommentar zu Gender. ---
|
||||||
position.present.name=Die Gegenwart
|
position.present.name=Die Gegenwart
|
||||||
position.present.desc=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
position.present.desc=Dies bedeckt die fragende Person: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
|
position.present.desc.male=Dies bedeckt ihn: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
|
position.present.desc.female=Dies bedeckt sie: der allgemeine Einfluss, der die Angelegenheit betrifft.
|
||||||
position.challenge.name=Die Herausforderung
|
position.challenge.name=Die Herausforderung
|
||||||
position.challenge.desc=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
position.challenge.desc=Dies kreuzt die fragende Person: die Art des Hindernisses in der Angelegenheit.
|
||||||
|
position.challenge.desc.male=Dies kreuzt ihn: die Art des Hindernisses in der Angelegenheit.
|
||||||
|
position.challenge.desc.female=Dies kreuzt sie: die Art des Hindernisses in der Angelegenheit.
|
||||||
position.crown.name=Die Krone
|
position.crown.name=Die Krone
|
||||||
position.crown.desc=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
position.crown.desc=Dies krönt die fragende Person: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||||
|
position.crown.desc.male=Dies krönt ihn: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||||
|
position.crown.desc.female=Dies krönt sie: das Ziel oder Ideal, das Beste, was erreicht werden kann.
|
||||||
position.foundation.name=Das Fundament
|
position.foundation.name=Das Fundament
|
||||||
position.foundation.desc=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
position.foundation.desc=Dies liegt unter der fragenden Person: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||||
|
position.foundation.desc.male=Dies liegt unter ihm: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||||
|
position.foundation.desc.female=Dies liegt unter ihr: die Grundlage der Angelegenheit, bereits Wirklichkeit.
|
||||||
position.recent_past.name=Die jüngste Vergangenheit
|
position.recent_past.name=Die jüngste Vergangenheit
|
||||||
position.recent_past.desc=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
position.recent_past.desc=Dies liegt hinter der fragenden Person: der Einfluss, der gerade vergeht.
|
||||||
|
position.recent_past.desc.male=Dies liegt hinter ihm: der Einfluss, der gerade vergeht.
|
||||||
|
position.recent_past.desc.female=Dies liegt hinter ihr: der Einfluss, der gerade vergeht.
|
||||||
position.near_future.name=Die nahe Zukunft
|
position.near_future.name=Die nahe Zukunft
|
||||||
position.near_future.desc=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
position.near_future.desc=Dies liegt vor der fragenden Person: der Einfluss, der jetzt wirksam wird.
|
||||||
position.attitude.name=Er selbst
|
position.near_future.desc.male=Dies liegt vor ihm: der Einfluss, der jetzt wirksam wird.
|
||||||
position.attitude.desc=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
position.near_future.desc.female=Dies liegt vor ihr: der Einfluss, der jetzt wirksam wird.
|
||||||
position.environment.name=Sein Haus
|
position.attitude.name=Die fragende Person selbst
|
||||||
position.environment.desc=Sein Umfeld und die darin wirkenden Tendenzen.
|
position.attitude.name.male=Er selbst
|
||||||
|
position.attitude.name.female=Sie selbst
|
||||||
|
position.attitude.desc=Die eigene Haltung oder Einstellung in den gegebenen Umständen.
|
||||||
|
position.attitude.desc.male=Seine Haltung oder Einstellung in den gegebenen Umständen.
|
||||||
|
position.attitude.desc.female=Ihre Haltung oder Einstellung in den gegebenen Umständen.
|
||||||
|
position.environment.name=Das eigene Haus
|
||||||
|
position.environment.name.male=Sein Haus
|
||||||
|
position.environment.name.female=Ihr Haus
|
||||||
|
position.environment.desc=Das eigene Umfeld und die darin wirkenden Tendenzen.
|
||||||
|
position.environment.desc.male=Sein Umfeld und die darin wirkenden Tendenzen.
|
||||||
|
position.environment.desc.female=Ihr Umfeld und die darin wirkenden Tendenzen.
|
||||||
position.hopes_and_fears.name=Hoffnungen und Ängste
|
position.hopes_and_fears.name=Hoffnungen und Ängste
|
||||||
position.hopes_and_fears.desc=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
position.hopes_and_fears.desc=Die eigenen Hoffnungen oder Ängste in der Angelegenheit.
|
||||||
|
position.hopes_and_fears.desc.male=Seine Hoffnungen oder Ängste in der Angelegenheit.
|
||||||
|
position.hopes_and_fears.desc.female=Ihre Hoffnungen oder Ängste in der Angelegenheit.
|
||||||
position.outcome.name=Das Ergebnis
|
position.outcome.name=Das Ergebnis
|
||||||
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
|
position.outcome.desc=Was kommen wird: das endgültige Ergebnis der Angelegenheit.
|
||||||
|
|
||||||
@@ -154,3 +188,108 @@ card.judgement.reversed=Schwäche, Kleinmut, Einfalt; auch Überlegung, Entschei
|
|||||||
card.world.name=Die Welt
|
card.world.name=Die Welt
|
||||||
card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Flucht, Ortswechsel.
|
card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Flucht, Ortswechsel.
|
||||||
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
|
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
|
||||||
|
|
||||||
|
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
|
||||||
|
interp.reading_date=Lesung für:
|
||||||
|
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
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ ui.transiting=Transiting
|
|||||||
ui.natal=natal
|
ui.natal=natal
|
||||||
ui.orb=orb
|
ui.orb=orb
|
||||||
|
|
||||||
|
# --- Watch app only (watch/src/c/main.c) - shown before the first
|
||||||
|
# config-page submission, when there's no birth data yet to compute a
|
||||||
|
# reading from ---
|
||||||
|
ui.setup_required_title=Setup Required
|
||||||
|
ui.setup_required_body=Open this app's settings from the Pebble mobile app to add your birth details, then reopen Deck in a Dash.
|
||||||
|
|
||||||
# --- Planets/luminaries (astro.c) ---
|
# --- Planets/luminaries (astro.c) ---
|
||||||
body.sun=Sun
|
body.sun=Sun
|
||||||
body.moon=Moon
|
body.moon=Moon
|
||||||
@@ -73,25 +79,52 @@ aspect.trine=Trine
|
|||||||
aspect.opposition=Opposition
|
aspect.opposition=Opposition
|
||||||
|
|
||||||
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
|
# --- Celtic Cross position names/descriptions (tarot_data.c), Waite's
|
||||||
# own wording from "An Ancient Celtic Method of Divination" ---
|
# own wording from "An Ancient Celtic Method of Divination". Waite
|
||||||
|
# addresses the querent as "him" throughout; the unsuffixed key is a
|
||||||
|
# pronoun-neutral paraphrase (singular "they") used when no gender is
|
||||||
|
# configured, the ".male" key is Waite's own wording verbatim, and the
|
||||||
|
# ".female" key is an original paraphrase - see tarot_data.c's own
|
||||||
|
# comment on Gender. ---
|
||||||
position.present.name=The Present
|
position.present.name=The Present
|
||||||
position.present.desc=This covers him: the general influence affecting the matter.
|
position.present.desc=This covers them: the general influence affecting the matter.
|
||||||
|
position.present.desc.male=This covers him: the general influence affecting the matter.
|
||||||
|
position.present.desc.female=This covers her: the general influence affecting the matter.
|
||||||
position.challenge.name=The Challenge
|
position.challenge.name=The Challenge
|
||||||
position.challenge.desc=This crosses him: the nature of the obstacle in the matter.
|
position.challenge.desc=This crosses them: the nature of the obstacle in the matter.
|
||||||
|
position.challenge.desc.male=This crosses him: the nature of the obstacle in the matter.
|
||||||
|
position.challenge.desc.female=This crosses her: the nature of the obstacle in the matter.
|
||||||
position.crown.name=The Crown
|
position.crown.name=The Crown
|
||||||
position.crown.desc=This crowns him: the aim or ideal, the best that can be achieved.
|
position.crown.desc=This crowns them: the aim or ideal, the best that can be achieved.
|
||||||
|
position.crown.desc.male=This crowns him: the aim or ideal, the best that can be achieved.
|
||||||
|
position.crown.desc.female=This crowns her: the aim or ideal, the best that can be achieved.
|
||||||
position.foundation.name=The Foundation
|
position.foundation.name=The Foundation
|
||||||
position.foundation.desc=This is beneath him: the basis of the matter, already actual.
|
position.foundation.desc=This is beneath them: the basis of the matter, already actual.
|
||||||
|
position.foundation.desc.male=This is beneath him: the basis of the matter, already actual.
|
||||||
|
position.foundation.desc.female=This is beneath her: the basis of the matter, already actual.
|
||||||
position.recent_past.name=The Recent Past
|
position.recent_past.name=The Recent Past
|
||||||
position.recent_past.desc=This is behind him: the influence that is just passing away.
|
position.recent_past.desc=This is behind them: the influence that is just passing away.
|
||||||
|
position.recent_past.desc.male=This is behind him: the influence that is just passing away.
|
||||||
|
position.recent_past.desc.female=This is behind her: the influence that is just passing away.
|
||||||
position.near_future.name=The Near Future
|
position.near_future.name=The Near Future
|
||||||
position.near_future.desc=This is before him: the influence now coming into action.
|
position.near_future.desc=This is before them: the influence now coming into action.
|
||||||
position.attitude.name=Himself
|
position.near_future.desc.male=This is before him: the influence now coming into action.
|
||||||
position.attitude.desc=His position or attitude in the circumstances.
|
position.near_future.desc.female=This is before her: the influence now coming into action.
|
||||||
position.environment.name=His House
|
position.attitude.name=Themself
|
||||||
position.environment.desc=His environment and the tendencies at work therein.
|
position.attitude.name.male=Himself
|
||||||
|
position.attitude.name.female=Herself
|
||||||
|
position.attitude.desc=Their position or attitude in the circumstances.
|
||||||
|
position.attitude.desc.male=His position or attitude in the circumstances.
|
||||||
|
position.attitude.desc.female=Her position or attitude in the circumstances.
|
||||||
|
position.environment.name=Their House
|
||||||
|
position.environment.name.male=His House
|
||||||
|
position.environment.name.female=Her House
|
||||||
|
position.environment.desc=Their environment and the tendencies at work therein.
|
||||||
|
position.environment.desc.male=His environment and the tendencies at work therein.
|
||||||
|
position.environment.desc.female=Her environment and the tendencies at work therein.
|
||||||
position.hopes_and_fears.name=Hopes and Fears
|
position.hopes_and_fears.name=Hopes and Fears
|
||||||
position.hopes_and_fears.desc=His hopes or fears in the matter.
|
position.hopes_and_fears.desc=Their hopes or fears in the matter.
|
||||||
|
position.hopes_and_fears.desc.male=His hopes or fears in the matter.
|
||||||
|
position.hopes_and_fears.desc.female=Her hopes or fears in the matter.
|
||||||
position.outcome.name=The Outcome
|
position.outcome.name=The Outcome
|
||||||
position.outcome.desc=What will come: the final result of the matter.
|
position.outcome.desc=What will come: the final result of the matter.
|
||||||
|
|
||||||
@@ -163,3 +196,105 @@ card.judgement.reversed=Weakness, pusillanimity, simplicity; also deliberation,
|
|||||||
card.world.name=The World
|
card.world.name=The World
|
||||||
card.world.upright=Assured success, recompense, voyage, route, emigration, flight, change of place.
|
card.world.upright=Assured success, recompense, voyage, route, emigration, flight, change of place.
|
||||||
card.world.reversed=Inertia, fixity, stagnation, permanence.
|
card.world.reversed=Inertia, fixity, stagnation, permanence.
|
||||||
|
|
||||||
|
# --- Interpreter UI labels (interpreter/src/main.c) ---
|
||||||
|
interp.reading_date=Reading for:
|
||||||
|
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
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ birth_time=""
|
|||||||
birth_utc_offset=""
|
birth_utc_offset=""
|
||||||
birth_lat=""
|
birth_lat=""
|
||||||
birth_lon=""
|
birth_lon=""
|
||||||
|
gender="unspecified"
|
||||||
lang="en"
|
lang="en"
|
||||||
|
|
||||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||||
@@ -43,6 +44,7 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
|
|||||||
birth_utc_offset) birth_utc_offset="$value" ;;
|
birth_utc_offset) birth_utc_offset="$value" ;;
|
||||||
birth_lat) birth_lat="$value" ;;
|
birth_lat) birth_lat="$value" ;;
|
||||||
birth_lon) birth_lon="$value" ;;
|
birth_lon) birth_lon="$value" ;;
|
||||||
|
gender) [ -n "$value" ] && gender="$value" ;;
|
||||||
lang) [ -n "$value" ] && lang="$value" ;;
|
lang) [ -n "$value" ] && lang="$value" ;;
|
||||||
esac
|
esac
|
||||||
done <"$PROPERTIES_FILE"
|
done <"$PROPERTIES_FILE"
|
||||||
@@ -77,5 +79,6 @@ exec "$BINARY" \
|
|||||||
--birth-lon "$birth_lon" \
|
--birth-lon "$birth_lon" \
|
||||||
--date "$transit_date" \
|
--date "$transit_date" \
|
||||||
--format "$format" \
|
--format "$format" \
|
||||||
|
--gender "$gender" \
|
||||||
--lang "$lang" \
|
--lang "$lang" \
|
||||||
--i18n-dir "$SCRIPT_DIR/i18n"
|
--i18n-dir "$SCRIPT_DIR/i18n"
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
# "today": same OS-clock seed/transit-moment and user.properties birth
|
# "today": same OS-clock seed/transit-moment and user.properties birth
|
||||||
# data as run-engine.sh, but reports how significant today's transits are
|
# data as run-engine.sh, but reports how significant today's transits are
|
||||||
# instead of printing the full reading. Usage: ./run-interpreter.sh
|
# 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
|
# Requires both dist/deck-engine (built by engine/'s Makefile) and
|
||||||
# dist/interpreter-cli (built by interpreter/'s own Makefile) to already
|
# dist/interpreter-cli (built by interpreter/'s own Makefile) to already
|
||||||
@@ -35,6 +41,8 @@ birth_time=""
|
|||||||
birth_utc_offset=""
|
birth_utc_offset=""
|
||||||
birth_lat=""
|
birth_lat=""
|
||||||
birth_lon=""
|
birth_lon=""
|
||||||
|
gender="unspecified"
|
||||||
|
lang="en"
|
||||||
|
|
||||||
while IFS='=' read -r key value || [ -n "$key" ]; do
|
while IFS='=' read -r key value || [ -n "$key" ]; do
|
||||||
key="$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
key="$(printf '%s' "$key" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||||
@@ -48,6 +56,8 @@ while IFS='=' read -r key value || [ -n "$key" ]; do
|
|||||||
birth_utc_offset) birth_utc_offset="$value" ;;
|
birth_utc_offset) birth_utc_offset="$value" ;;
|
||||||
birth_lat) birth_lat="$value" ;;
|
birth_lat) birth_lat="$value" ;;
|
||||||
birth_lon) birth_lon="$value" ;;
|
birth_lon) birth_lon="$value" ;;
|
||||||
|
gender) [ -n "$value" ] && gender="$value" ;;
|
||||||
|
lang) [ -n "$value" ] && lang="$value" ;;
|
||||||
esac
|
esac
|
||||||
done <"$PROPERTIES_FILE"
|
done <"$PROPERTIES_FILE"
|
||||||
|
|
||||||
@@ -67,10 +77,13 @@ check_set birth_lon "$birth_lon"
|
|||||||
|
|
||||||
# OS-provided inputs: today's date is the tarot seed (stable all day, a
|
# 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
|
# new spread each day), current UTC time drives today's transits. The
|
||||||
# tarot seed doesn't matter for --format json (only "transits" is read
|
# tarot seed still matters for --format json now that "spread" is read
|
||||||
# downstream) but it's kept identical to run-engine.sh's for consistency.
|
# downstream too (guidance.c's Attitude/Outcome framing) - kept identical
|
||||||
|
# to run-engine.sh's for consistency either way.
|
||||||
seed="$(date +%Y-%m-%d)"
|
seed="$(date +%Y-%m-%d)"
|
||||||
transit_date="$(date -u +%Y-%m-%dT%H:%M)"
|
transit_date="$(date -u +%Y-%m-%dT%H:%M)"
|
||||||
|
format="${1:-text}"
|
||||||
|
lang="${2:-$lang}"
|
||||||
|
|
||||||
"$ENGINE_BINARY" \
|
"$ENGINE_BINARY" \
|
||||||
--seed "$seed" \
|
--seed "$seed" \
|
||||||
@@ -81,4 +94,5 @@ transit_date="$(date -u +%Y-%m-%dT%H:%M)"
|
|||||||
--birth-lon "$birth_lon" \
|
--birth-lon "$birth_lon" \
|
||||||
--date "$transit_date" \
|
--date "$transit_date" \
|
||||||
--format json \
|
--format json \
|
||||||
| exec "$INTERPRETER_BINARY"
|
--gender "$gender" \
|
||||||
|
| exec "$INTERPRETER_BINARY" --format "$format" --lang "$lang" --i18n-dir "$SCRIPT_DIR/i18n"
|
||||||
|
|||||||
@@ -11,6 +11,12 @@
|
|||||||
# (e.g. 2 for Central European Summer Time)
|
# (e.g. 2 for Central European Summer Time)
|
||||||
# birth_lat Birth location latitude, decimal degrees, north positive
|
# birth_lat Birth location latitude, decimal degrees, north positive
|
||||||
# birth_lon Birth location longitude, decimal degrees, east positive
|
# birth_lon Birth location longitude, decimal degrees, east positive
|
||||||
|
# gender Who the reading is for - male, female, or unspecified.
|
||||||
|
# Only affects the Celtic Cross position text's
|
||||||
|
# pronouns (e.g. "His House" vs. "Her House" vs.
|
||||||
|
# "Their House"), never the chart/draw itself.
|
||||||
|
# Optional, defaults to "unspecified"; unrecognized
|
||||||
|
# values fall back to "unspecified" too.
|
||||||
# lang Reading language code - matches a file named
|
# lang Reading language code - matches a file named
|
||||||
# <code>.lang in dist/i18n/ (see engine/i18n/ for the
|
# <code>.lang in dist/i18n/ (see engine/i18n/ for the
|
||||||
# source files, currently "en" and "de"). Optional,
|
# source files, currently "en" and "de"). Optional,
|
||||||
@@ -22,4 +28,5 @@ birth_time=YOUR_BIRTH_TIME_HERE
|
|||||||
birth_utc_offset=YOUR_UTC_OFFSET_HERE
|
birth_utc_offset=YOUR_UTC_OFFSET_HERE
|
||||||
birth_lat=YOUR_LATITUDE_HERE
|
birth_lat=YOUR_LATITUDE_HERE
|
||||||
birth_lon=YOUR_LONGITUDE_HERE
|
birth_lon=YOUR_LONGITUDE_HERE
|
||||||
|
gender=unspecified
|
||||||
lang=en
|
lang=en
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
#define _POSIX_C_SOURCE 200809L /* for gmtime_r */
|
|
||||||
|
|
||||||
#include "astro.h"
|
#include "astro.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
|
|
||||||
|
/* The watch build (PBL_SDK_3) uses a compact low-precision ephemeris
|
||||||
|
* instead of the vendored Astronomy Engine, which at ~127KB of compiled
|
||||||
|
* code doesn't fit a whole Pebble app's ~64KB budget - see
|
||||||
|
* lowprec_ephemeris.h's own doc comment. AstroTime abstracts over the
|
||||||
|
* two representations of "a moment in time" this file can be built
|
||||||
|
* against: a Julian Date (double) for the watch, or the vendored
|
||||||
|
* library's own astro_time_t on desktop. Every function below that takes
|
||||||
|
* or returns a time branches internally on PBL_SDK_3, but keeps the same
|
||||||
|
* name/signature/call sites either way, so the two implementations stay
|
||||||
|
* easy to compare line-by-line. */
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
#include "lowprec_ephemeris.h"
|
||||||
|
typedef double AstroTime;
|
||||||
|
#else
|
||||||
#include "../third_party/astronomy/astronomy.h"
|
#include "../third_party/astronomy/astronomy.h"
|
||||||
|
typedef astro_time_t AstroTime;
|
||||||
#include <math.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <string.h>
|
|
||||||
|
|
||||||
/* DEG2RAD / RAD2DEG come from astronomy.h. */
|
|
||||||
|
|
||||||
static const astro_body_t k_astro_body[NUM_BODIES] = {
|
static const astro_body_t k_astro_body[NUM_BODIES] = {
|
||||||
[PLANET_SUN] = BODY_SUN,
|
[PLANET_SUN] = BODY_SUN,
|
||||||
@@ -22,6 +30,20 @@ static const astro_body_t k_astro_body[NUM_BODIES] = {
|
|||||||
[PLANET_NEPTUNE] = BODY_NEPTUNE,
|
[PLANET_NEPTUNE] = BODY_NEPTUNE,
|
||||||
[PLANET_PLUTO] = BODY_PLUTO,
|
[PLANET_PLUTO] = BODY_PLUTO,
|
||||||
};
|
};
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Not M_PI - see lowprec_ephemeris.c's own comment on why. Only needed
|
||||||
|
* (and only defined) on the watch path; astronomy.h supplies these on
|
||||||
|
* desktop, as before. */
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
#define DECK_PI 3.14159265358979323846
|
||||||
|
#define DEG2RAD (DECK_PI / 180.0)
|
||||||
|
#define RAD2DEG (180.0 / DECK_PI)
|
||||||
|
#endif
|
||||||
|
|
||||||
static double normalize_degrees(double deg) {
|
static double normalize_degrees(double deg) {
|
||||||
double d = fmod(deg, 360.0);
|
double d = fmod(deg, 360.0);
|
||||||
@@ -36,45 +58,91 @@ static void longitude_to_position(double longitude, PlanetPosition *out) {
|
|||||||
|
|
||||||
/* Mean obliquity of the ecliptic (IAU low-precision polynomial), matching
|
/* Mean obliquity of the ecliptic (IAU low-precision polynomial), matching
|
||||||
* what Astronomy Engine computes internally in its (non-exported)
|
* what Astronomy Engine computes internally in its (non-exported)
|
||||||
* mean_obliq(). T is Julian centuries since J2000.0. */
|
* mean_obliq(). T is Julian centuries since J2000.0. A slow, purely
|
||||||
|
* time-based polynomial, not subject to orbital approximation error -
|
||||||
|
* shared unchanged by both ephemeris implementations. */
|
||||||
static double mean_obliquity_deg(double t_centuries) {
|
static double mean_obliquity_deg(double t_centuries) {
|
||||||
double t = t_centuries;
|
double t = t_centuries;
|
||||||
return 23.4392911 - 0.0130042 * t - 0.00000016 * t * t + 0.000000504 * t * t * t;
|
return 23.4392911 - 0.0130042 * t - 0.00000016 * t * t + 0.000000504 * t * t * t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static double julian_centuries_since_j2000(const AstroTime *time) {
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
return (*time - 2451545.0) / 36525.0;
|
||||||
|
#else
|
||||||
|
return time->tt / 36525.0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static double sidereal_time_hours(AstroTime *time) {
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
return lowprec_gmst_hours(*time);
|
||||||
|
#else
|
||||||
|
return Astronomy_SiderealTime(time);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/* Ascendant (rising ecliptic degree), via the standard RAMC/obliquity/
|
/* Ascendant (rising ecliptic degree), via the standard RAMC/obliquity/
|
||||||
* latitude identity (Duffett-Smith & Zwart, "Practical Astronomy with
|
* latitude identity (Duffett-Smith & Zwart, "Practical Astronomy with
|
||||||
* your Calculator or Spreadsheet"). Astronomy Engine has no built-in
|
* your Calculator or Spreadsheet"). Neither ephemeris implementation has
|
||||||
* Ascendant function. */
|
* a built-in Ascendant function. */
|
||||||
static double compute_ascendant(astro_time_t *time, double latitude_deg,
|
static double compute_ascendant(AstroTime *time, double latitude_deg,
|
||||||
double longitude_deg) {
|
double longitude_deg) {
|
||||||
double gast_hours = Astronomy_SiderealTime(time);
|
double gast_hours = sidereal_time_hours(time);
|
||||||
double ramc_deg = normalize_degrees(gast_hours * 15.0 + longitude_deg);
|
double ramc_deg = normalize_degrees(gast_hours * 15.0 + longitude_deg);
|
||||||
double eps_deg = mean_obliquity_deg(time->tt / 36525.0);
|
double eps_deg = mean_obliquity_deg(julian_centuries_since_j2000(time));
|
||||||
|
|
||||||
double ramc = ramc_deg * DEG2RAD;
|
double ramc = ramc_deg * DEG2RAD;
|
||||||
double eps = eps_deg * DEG2RAD;
|
double eps = eps_deg * DEG2RAD;
|
||||||
double lat = latitude_deg * DEG2RAD;
|
double lat = latitude_deg * DEG2RAD;
|
||||||
|
|
||||||
|
/* tan(lat) as sin(lat)/cos(lat) - no <math.h> tan() needed. */
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
double y = -lowprec_cos(ramc);
|
||||||
|
double x = lowprec_sin(ramc) * lowprec_cos(eps) +
|
||||||
|
(lowprec_sin(lat) / lowprec_cos(lat)) * lowprec_sin(eps);
|
||||||
|
return normalize_degrees(lowprec_atan2(y, x) * RAD2DEG);
|
||||||
|
#else
|
||||||
double y = -cos(ramc);
|
double y = -cos(ramc);
|
||||||
double x = sin(ramc) * cos(eps) + tan(lat) * sin(eps);
|
double x = sin(ramc) * cos(eps) + (sin(lat) / cos(lat)) * sin(eps);
|
||||||
return normalize_degrees(atan2(y, x) * RAD2DEG);
|
return normalize_degrees(atan2(y, x) * RAD2DEG);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static astro_time_t time_from_birth(const BirthData *birth) {
|
static AstroTime time_from_birth(const BirthData *birth) {
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
double jd = lowprec_julian_date(birth->year, birth->month, birth->day,
|
||||||
|
birth->hour, birth->minute, 0.0);
|
||||||
|
return jd - birth->utc_offset_hours / 24.0;
|
||||||
|
#else
|
||||||
astro_time_t local = Astronomy_MakeTime(birth->year, birth->month, birth->day,
|
astro_time_t local = Astronomy_MakeTime(birth->year, birth->month, birth->day,
|
||||||
birth->hour, birth->minute, 0.0);
|
birth->hour, birth->minute, 0.0);
|
||||||
return Astronomy_AddDays(local, -birth->utc_offset_hours / 24.0);
|
return Astronomy_AddDays(local, -birth->utc_offset_hours / 24.0);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static astro_time_t time_from_unix(time_t utc_moment) {
|
/* Plain gmtime() (not the POSIX-only gmtime_r()) - deliberately: this
|
||||||
struct tm tm_utc;
|
* project is single-threaded everywhere it runs, including on the watch,
|
||||||
gmtime_r(&utc_moment, &tm_utc);
|
* where only the non-reentrant classic C library functions are provided
|
||||||
return Astronomy_MakeTime(tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday,
|
* (see astro.h's own include-guard comment). Used immediately, so its
|
||||||
tm_utc.tm_hour, tm_utc.tm_min, (double)tm_utc.tm_sec);
|
* static internal buffer is never a hazard here. */
|
||||||
|
static AstroTime time_from_unix(time_t utc_moment) {
|
||||||
|
struct tm *tm_utc = gmtime(&utc_moment);
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
return lowprec_julian_date(tm_utc->tm_year + 1900, tm_utc->tm_mon + 1, tm_utc->tm_mday,
|
||||||
|
tm_utc->tm_hour, tm_utc->tm_min, (double)tm_utc->tm_sec);
|
||||||
|
#else
|
||||||
|
return Astronomy_MakeTime(tm_utc->tm_year + 1900, tm_utc->tm_mon + 1, tm_utc->tm_mday,
|
||||||
|
tm_utc->tm_hour, tm_utc->tm_min, (double)tm_utc->tm_sec);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
static void compute_body_positions(astro_time_t time, PlanetPosition out[NUM_BODIES]) {
|
static void compute_body_positions(AstroTime time, PlanetPosition out[NUM_BODIES]) {
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
for (int b = 0; b < NUM_BODIES; b++) {
|
||||||
|
longitude_to_position(lowprec_geocentric_longitude((Body)b, time), &out[b]);
|
||||||
|
}
|
||||||
|
#else
|
||||||
/* Astronomy_EclipticLongitude() computes *heliocentric* longitude (and
|
/* Astronomy_EclipticLongitude() computes *heliocentric* longitude (and
|
||||||
* outright rejects BODY_SUN) - wrong for astrology, which needs the
|
* outright rejects BODY_SUN) - wrong for astrology, which needs the
|
||||||
* apparent geocentric position. Astronomy_GeoVector() + Astronomy_Ecliptic()
|
* apparent geocentric position. Astronomy_GeoVector() + Astronomy_Ecliptic()
|
||||||
@@ -84,6 +152,7 @@ static void compute_body_positions(astro_time_t time, PlanetPosition out[NUM_BOD
|
|||||||
astro_ecliptic_t eclip = Astronomy_Ecliptic(geo);
|
astro_ecliptic_t eclip = Astronomy_Ecliptic(geo);
|
||||||
longitude_to_position(eclip.elon, &out[b]);
|
longitude_to_position(eclip.elon, &out[b]);
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Whole-sign house number (1-12) for `sign`, counting from `ascendant_sign`
|
/* Whole-sign house number (1-12) for `sign`, counting from `ascendant_sign`
|
||||||
@@ -101,7 +170,7 @@ static void assign_houses(PlanetPosition bodies[NUM_BODIES], ZodiacSign ascendan
|
|||||||
}
|
}
|
||||||
|
|
||||||
void astro_compute_natal_chart(const BirthData *birth, NatalChart *out) {
|
void astro_compute_natal_chart(const BirthData *birth, NatalChart *out) {
|
||||||
astro_time_t time = time_from_birth(birth);
|
AstroTime time = time_from_birth(birth);
|
||||||
|
|
||||||
compute_body_positions(time, out->bodies);
|
compute_body_positions(time, out->bodies);
|
||||||
out->ascendant_longitude = compute_ascendant(&time, birth->latitude, birth->longitude);
|
out->ascendant_longitude = compute_ascendant(&time, birth->latitude, birth->longitude);
|
||||||
@@ -142,13 +211,23 @@ static double orb_for_pair(Body transiting, Body natal) {
|
|||||||
|
|
||||||
void astro_compute_daily_transits(time_t utc_moment, const NatalChart *natal,
|
void astro_compute_daily_transits(time_t utc_moment, const NatalChart *natal,
|
||||||
DailyTransits *out) {
|
DailyTransits *out) {
|
||||||
astro_time_t time = time_from_unix(utc_moment);
|
AstroTime time = time_from_unix(utc_moment);
|
||||||
|
|
||||||
compute_body_positions(time, out->bodies);
|
compute_body_positions(time, out->bodies);
|
||||||
assign_houses(out->bodies, natal->houses[0]);
|
assign_houses(out->bodies, natal->houses[0]);
|
||||||
|
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
/* Sun-to-Moon angle directly from the positions just computed above -
|
||||||
|
* same convention as Astronomy_MoonPhase() (0 = new, 90 = first
|
||||||
|
* quarter, 180 = full, 270 = last quarter), so moon_phase_from_angle()
|
||||||
|
* below needs no platform-specific branch of its own. */
|
||||||
|
double phase_angle = normalize_degrees(out->bodies[PLANET_MOON].ecliptic_longitude -
|
||||||
|
out->bodies[PLANET_SUN].ecliptic_longitude);
|
||||||
|
#else
|
||||||
astro_angle_result_t phase = Astronomy_MoonPhase(time);
|
astro_angle_result_t phase = Astronomy_MoonPhase(time);
|
||||||
out->moon_phase = moon_phase_from_angle(phase.angle);
|
double phase_angle = phase.angle;
|
||||||
|
#endif
|
||||||
|
out->moon_phase = moon_phase_from_angle(phase_angle);
|
||||||
|
|
||||||
out->aspect_count = 0;
|
out->aspect_count = 0;
|
||||||
for (int t = 0; t < NUM_BODIES; t++) {
|
for (int t = 0; t < NUM_BODIES; t++) {
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
#ifndef DECK_ASTRO_H
|
#ifndef DECK_ASTRO_H
|
||||||
#define DECK_ASTRO_H
|
#define DECK_ASTRO_H
|
||||||
|
|
||||||
|
/* Pebble's SDK pre-defines <time.h>'s own include guard (so a real
|
||||||
|
* #include <time.h> is a silent no-op) and instead declares struct
|
||||||
|
* tm/time_t/gmtime()/etc. itself, inside pebble.h - see PBL_SDK_3,
|
||||||
|
* predefined by the Pebble build for every target platform. Desktop
|
||||||
|
* builds (deck-engine, the interpreter, and this file's own tests) use
|
||||||
|
* plain <time.h> as normal. */
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
#include <pebble.h>
|
||||||
|
#else
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#define NUM_BODIES 10
|
#define NUM_BODIES 10
|
||||||
#define MAX_ASPECTS 100
|
#define MAX_ASPECTS 100
|
||||||
|
|||||||
@@ -1,12 +1,33 @@
|
|||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
/* Fixed-size, no heap allocation - deliberately (see i18n.h): keeps this
|
/* i18n_load_table()'s catalog: just three pointers, always compiled -
|
||||||
* usable in a small embedded environment later, not just on desktop.
|
* negligible RAM regardless of platform. Deliberately zero-copy: the
|
||||||
* ~140 keys are shipped today (see engine/i18n/en.lang); this leaves
|
* watch's compiled-in translation tables already live in flash as
|
||||||
* plenty of headroom for more languages/keys without growing the format. */
|
* `static const char *const` arrays, so i18n_get() reads them in place
|
||||||
|
* rather than copying ~140 keys/values into RAM the watch doesn't have
|
||||||
|
* (see i18n_load()'s g_entries[] below, sized for desktop use only). A
|
||||||
|
* later i18n_load_table() call (e.g. switching language) simply
|
||||||
|
* re-points these, no free() needed since nothing was ever copied. */
|
||||||
|
static const char *const *g_table_keys;
|
||||||
|
static const char *const *g_table_values;
|
||||||
|
static int g_table_count = 0;
|
||||||
|
|
||||||
|
void i18n_load_table(const char *const *keys, const char *const *values, int count) {
|
||||||
|
g_table_keys = keys;
|
||||||
|
g_table_values = values;
|
||||||
|
g_table_count = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifndef PBL_SDK_3
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* Fixed-size, no heap allocation. ~140 keys are shipped today (see
|
||||||
|
* engine/i18n/en.lang); this leaves plenty of headroom for more
|
||||||
|
* languages/keys without growing the format. Desktop-only: at ~140KB
|
||||||
|
* this is far too large for the watch's own RAM budget, which is why
|
||||||
|
* the watch instead uses i18n_load_table()'s zero-copy path above. */
|
||||||
#define I18N_MAX_ENTRIES 320
|
#define I18N_MAX_ENTRIES 320
|
||||||
#define I18N_MAX_KEY 48
|
#define I18N_MAX_KEY 48
|
||||||
#define I18N_MAX_VALUE 400
|
#define I18N_MAX_VALUE 400
|
||||||
@@ -67,10 +88,16 @@ bool i18n_load(const char *path) {
|
|||||||
fclose(f);
|
fclose(f);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
#endif /* PBL_SDK_3 */
|
||||||
|
|
||||||
const char *i18n_get(const char *key, const char *fallback) {
|
const char *i18n_get(const char *key, const char *fallback) {
|
||||||
|
for (int i = 0; i < g_table_count; i++) {
|
||||||
|
if (strcmp(g_table_keys[i], key) == 0) return g_table_values[i];
|
||||||
|
}
|
||||||
|
#ifndef PBL_SDK_3
|
||||||
for (int i = 0; i < g_entry_count; i++) {
|
for (int i = 0; i < g_entry_count; i++) {
|
||||||
if (strcmp(g_entries[i].key, key) == 0) return g_entries[i].value;
|
if (strcmp(g_entries[i].key, key) == 0) return g_entries[i].value;
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,27 @@
|
|||||||
* file can't be opened (the catalog is left as it was in that case).
|
* file can't be opened (the catalog is left as it was in that case).
|
||||||
*
|
*
|
||||||
* This uses stdio, so - like main.c and reading_print_text/_html - it's
|
* This uses stdio, so - like main.c and reading_print_text/_html - it's
|
||||||
* a desktop-only entry point, not something the Pebble watchapp will
|
* a desktop-only entry point: Pebble's SDK doesn't provide fopen/fgets
|
||||||
* call as-is. i18n_get() below is a pure table lookup, so it's fine to
|
* at all (a compile-time error, not just a runtime failure), so this
|
||||||
* port; the watchapp will just need its own (non-file-based) way to
|
* function isn't even compiled when building for the watch (guarded
|
||||||
* populate the catalog, e.g. from a compiled-in resource. */
|
* #ifndef PBL_SDK_3 in i18n.c) - see i18n_load_table() below, its
|
||||||
|
* watch-side equivalent. */
|
||||||
|
#ifndef PBL_SDK_3
|
||||||
bool i18n_load(const char *path);
|
bool i18n_load(const char *path);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* The watch's non-file-based equivalent of i18n_load(): points i18n_get()
|
||||||
|
* at two parallel arrays (keys[i] -> values[i], `count` entries) instead
|
||||||
|
* of parsing a file - for a compiled-in translation table generated from
|
||||||
|
* engine/i18n's .lang files at watch-app build time (see the watch
|
||||||
|
* app's own build tooling). Zero-copy - just stores the three pointers -
|
||||||
|
* so `keys`/`values` must outlive the call (a `static const char *const`
|
||||||
|
* array is the expected caller, living in flash/ROM, not a local/heap
|
||||||
|
* buffer). This is what keeps the watch from needing i18n_load()'s
|
||||||
|
* ~140KB g_entries[] catalog, which its RAM budget can't afford.
|
||||||
|
* Available on every platform, since it has no platform-specific
|
||||||
|
* dependencies, but only the watch actually calls it today. */
|
||||||
|
void i18n_load_table(const char *const *keys, const char *const *values, int count);
|
||||||
|
|
||||||
/* Returns the translation for `key` from the loaded catalog, or
|
/* Returns the translation for `key` from the loaded catalog, or
|
||||||
* `fallback` if no catalog is loaded or `key` isn't in it. Every caller
|
* `fallback` if no catalog is loaded or `key` isn't in it. Every caller
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#include "lowprec_ephemeris.h"
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
/* Classic "low precision" planetary position formulas - Keplerian
|
||||||
|
* orbital elements (with a linear rate of change per day) solved via
|
||||||
|
* Kepler's equation, the same method described in Paul Schlyter's "How
|
||||||
|
* to compute planetary positions" and (independently, since it's a
|
||||||
|
* standard technique with no single canonical source) in Jean Meeus'
|
||||||
|
* "Astronomical Algorithms". Elements are as of epoch J2000.0.
|
||||||
|
*
|
||||||
|
* Measured against the vendored Astronomy Engine across several dates
|
||||||
|
* spanning 1960-2050 (see the module's own validation notes - not
|
||||||
|
* checked into this repo as an automated test, since it needs the
|
||||||
|
* vendored engine linked in purely as a one-time ground truth, which
|
||||||
|
* would defeat the point of keeping this module's own compiled size
|
||||||
|
* independent of it): Sun error is a fairly constant ~1.4-1.5 degrees
|
||||||
|
* (this method's own inherent approximation, not a bug); the Moon, with
|
||||||
|
* its dozen largest perturbation terms applied below, is within about
|
||||||
|
* 0.1-0.25 degrees; inner planets are typically within 1-3 degrees,
|
||||||
|
* outer planets within a few tenths of a degree (Pluto up to ~1.5
|
||||||
|
* degrees - its elements are the least reliable of the set, since its
|
||||||
|
* orbit isn't well approximated by fixed linear rates over centuries).
|
||||||
|
* GMST (lowprec_gmst_hours(), for the Ascendant) matched to within
|
||||||
|
* 0.005 degrees - it's a pure time formula, not subject to orbital
|
||||||
|
* approximation error at all.
|
||||||
|
*
|
||||||
|
* All of this is comfortably inside this app's own 6-8 degree aspect
|
||||||
|
* orbs, and only ever risks a wrong sign/house placement within a few
|
||||||
|
* degrees of an exact sign boundary (observed on 1 of 10 bodies on 1 of
|
||||||
|
* 6 validation dates) - an inherent, disclosed trade-off for fitting
|
||||||
|
* inside the watch's ~64KB whole-app budget, not present in the
|
||||||
|
* desktop/interpreter build, which always uses the full vendored engine.
|
||||||
|
*
|
||||||
|
* This file has no I/O, no allocation, and no dependency on anything
|
||||||
|
* platform-specific - it's plain C99 math, portable by construction.
|
||||||
|
*
|
||||||
|
* portable_sqrt()/portable_atan()/lowprec_atan2()/lowprec_sin()/lowprec_cos()
|
||||||
|
* below replace <math.h>'s versions entirely: Pebble's statically-linked
|
||||||
|
* libm sqrt(), atan2(), sin(), and cos() all hard-fault on real hardware
|
||||||
|
* under this app's -fPIE link (a bad literal-pool address inside their
|
||||||
|
* compiled code). fmod() is unaffected and used freely. */
|
||||||
|
|
||||||
|
/* Not M_PI - it's a BSD/POSIX math.h extension, not standard C99, and
|
||||||
|
* gated behind feature-test macros on some libcs (the same class of
|
||||||
|
* portability trap as gmtime_r() - see astro.c's own comment). */
|
||||||
|
#define DECK_PI 3.14159265358979323846
|
||||||
|
#define DEG2RAD (DECK_PI / 180.0)
|
||||||
|
#define RAD2DEG (180.0 / DECK_PI)
|
||||||
|
|
||||||
|
static double normalize_deg(double deg) {
|
||||||
|
double d = fmod(deg, 360.0);
|
||||||
|
return d < 0.0 ? d + 360.0 : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Range-reduces to (-pi, pi], the domain the Taylor series below are
|
||||||
|
* evaluated over. */
|
||||||
|
static double reduce_to_pi(double rad) {
|
||||||
|
double r = fmod(rad, 2.0 * DECK_PI);
|
||||||
|
if (r < 0.0) r += 2.0 * DECK_PI;
|
||||||
|
if (r > DECK_PI) r -= 2.0 * DECK_PI;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
double lowprec_sin(double rad) {
|
||||||
|
double x = reduce_to_pi(rad);
|
||||||
|
double x2 = x * x;
|
||||||
|
double term = x;
|
||||||
|
double sum = term;
|
||||||
|
term *= -x2 / (2.0 * 3.0); sum += term; /* -x^3/3! */
|
||||||
|
term *= -x2 / (4.0 * 5.0); sum += term; /* +x^5/5! */
|
||||||
|
term *= -x2 / (6.0 * 7.0); sum += term; /* -x^7/7! */
|
||||||
|
term *= -x2 / (8.0 * 9.0); sum += term; /* +x^9/9! */
|
||||||
|
term *= -x2 / (10.0 * 11.0); sum += term; /* -x^11/11! */
|
||||||
|
term *= -x2 / (12.0 * 13.0); sum += term; /* +x^13/13! */
|
||||||
|
term *= -x2 / (14.0 * 15.0); sum += term; /* -x^15/15! */
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
double lowprec_cos(double rad) {
|
||||||
|
double x = reduce_to_pi(rad);
|
||||||
|
double x2 = x * x;
|
||||||
|
double term = 1.0;
|
||||||
|
double sum = term;
|
||||||
|
term *= -x2 / (1.0 * 2.0); sum += term; /* -x^2/2! */
|
||||||
|
term *= -x2 / (3.0 * 4.0); sum += term; /* +x^4/4! */
|
||||||
|
term *= -x2 / (5.0 * 6.0); sum += term; /* -x^6/6! */
|
||||||
|
term *= -x2 / (7.0 * 8.0); sum += term; /* +x^8/8! */
|
||||||
|
term *= -x2 / (9.0 * 10.0); sum += term; /* -x^10/10! */
|
||||||
|
term *= -x2 / (11.0 * 12.0); sum += term; /* +x^12/12! */
|
||||||
|
term *= -x2 / (13.0 * 14.0); sum += term; /* -x^14/14! */
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
double lowprec_julian_date(int year, int month, int day, int hour, int minute, double second) {
|
||||||
|
int y = year, m = month;
|
||||||
|
if (m <= 2) {
|
||||||
|
y -= 1;
|
||||||
|
m += 12;
|
||||||
|
}
|
||||||
|
int a = y / 100;
|
||||||
|
int b = 2 - a + a / 4;
|
||||||
|
double day_fraction = (hour + minute / 60.0 + second / 3600.0) / 24.0;
|
||||||
|
return (double)(int)(365.25 * (y + 4716)) + (double)(int)(30.6001 * (m + 1)) +
|
||||||
|
day + day_fraction + b - 1524.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
double lowprec_gmst_hours(double jd) {
|
||||||
|
/* Meeus 12.4, dropping the T^2/T^3 terms (fractions of a second even
|
||||||
|
* over centuries - far below this module's own precision floor). */
|
||||||
|
double gmst_deg = normalize_deg(280.46061837 + 360.98564736629 * (jd - 2451545.0));
|
||||||
|
return gmst_deg / 15.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* N = longitude of ascending node, i = inclination, w = argument of
|
||||||
|
* perihelion, a = semi-major axis (AU; Earth radii for the Moon), e =
|
||||||
|
* eccentricity, M = mean anomaly - each "<x>0 + <x>d * d" where d is
|
||||||
|
* days since J2000.0. The Sun's "orbit" here is really Earth's own
|
||||||
|
* heliocentric orbit (N = i = 0, so the Sun's geocentric position falls
|
||||||
|
* straight out of the same flat 2-body solver used for everything
|
||||||
|
* else); Earth's own heliocentric position for geocentrizing the other
|
||||||
|
* planets is simply the Sun's position negated. */
|
||||||
|
typedef struct {
|
||||||
|
double N0, Nd;
|
||||||
|
double i0, id;
|
||||||
|
double w0, wd;
|
||||||
|
double a0, ad;
|
||||||
|
double e0, ed;
|
||||||
|
double M0, Md;
|
||||||
|
} OrbitalElements;
|
||||||
|
|
||||||
|
static const OrbitalElements k_elements[NUM_BODIES] = {
|
||||||
|
[PLANET_SUN] = {
|
||||||
|
0.0, 0.0, 0.0, 0.0,
|
||||||
|
282.9404, 4.70935e-5,
|
||||||
|
1.000000, 0.0,
|
||||||
|
0.016709, -1.151e-9,
|
||||||
|
356.0470, 0.9856002585,
|
||||||
|
},
|
||||||
|
[PLANET_MOON] = {
|
||||||
|
125.1228, -0.0529538083,
|
||||||
|
5.1454, 0.0,
|
||||||
|
318.0634, 0.1643573223,
|
||||||
|
60.2666, 0.0,
|
||||||
|
0.054900, 0.0,
|
||||||
|
134.9634, 13.0649929509,
|
||||||
|
},
|
||||||
|
[PLANET_MERCURY] = {
|
||||||
|
48.3313, 3.24587e-5,
|
||||||
|
7.0047, 5.00e-8,
|
||||||
|
29.1241, 1.01444e-5,
|
||||||
|
0.387098, 0.0,
|
||||||
|
0.205635, 5.59e-10,
|
||||||
|
168.6562, 4.0923344368,
|
||||||
|
},
|
||||||
|
[PLANET_VENUS] = {
|
||||||
|
76.6799, 2.46590e-5,
|
||||||
|
3.3946, 2.75e-8,
|
||||||
|
54.8910, 1.38374e-5,
|
||||||
|
0.723330, 0.0,
|
||||||
|
0.006773, -1.302e-9,
|
||||||
|
48.0052, 1.6021302244,
|
||||||
|
},
|
||||||
|
[PLANET_MARS] = {
|
||||||
|
49.5574, 2.11081e-5,
|
||||||
|
1.8497, -1.78e-8,
|
||||||
|
286.5016, 2.92961e-5,
|
||||||
|
1.523688, 0.0,
|
||||||
|
0.093405, 2.516e-9,
|
||||||
|
18.6021, 0.5240207766,
|
||||||
|
},
|
||||||
|
[PLANET_JUPITER] = {
|
||||||
|
100.4542, 2.76854e-5,
|
||||||
|
1.3030, -1.557e-7,
|
||||||
|
273.8777, 1.64505e-5,
|
||||||
|
5.20256, 0.0,
|
||||||
|
0.048498, 4.469e-9,
|
||||||
|
19.8950, 0.0830853001,
|
||||||
|
},
|
||||||
|
[PLANET_SATURN] = {
|
||||||
|
113.6634, 2.38980e-5,
|
||||||
|
2.4886, -1.081e-7,
|
||||||
|
339.3939, 2.97661e-5,
|
||||||
|
9.55475, 0.0,
|
||||||
|
0.055546, -9.499e-9,
|
||||||
|
316.9670, 0.0334442282,
|
||||||
|
},
|
||||||
|
[PLANET_URANUS] = {
|
||||||
|
74.0005, 1.3978e-5,
|
||||||
|
0.7733, 1.9e-8,
|
||||||
|
96.6612, 3.0565e-5,
|
||||||
|
19.18171, -1.55e-8,
|
||||||
|
0.047318, 7.45e-9,
|
||||||
|
142.5905, 0.011725806,
|
||||||
|
},
|
||||||
|
[PLANET_NEPTUNE] = {
|
||||||
|
131.7806, 3.0173e-5,
|
||||||
|
1.7700, -2.55e-7,
|
||||||
|
272.8461, -6.027e-6,
|
||||||
|
30.05826, 3.313e-8,
|
||||||
|
0.008606, 2.15e-9,
|
||||||
|
260.2471, 0.005995147,
|
||||||
|
},
|
||||||
|
/* Approximate fixed elements (not accurate as fixed linear rates over
|
||||||
|
* long spans, but Pluto is only ever used as a slow outer planet with
|
||||||
|
* a wide orb here). */
|
||||||
|
[PLANET_PLUTO] = {
|
||||||
|
110.30347, 0.0,
|
||||||
|
17.14175, 0.0,
|
||||||
|
113.76329, 0.0,
|
||||||
|
39.48168677, 0.0,
|
||||||
|
0.24880766, 0.0,
|
||||||
|
14.53, 0.00396,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Newton-Raphson sqrt. Fixed iteration count rather than a
|
||||||
|
* convergence-check loop, so it can't ever fail to terminate. */
|
||||||
|
static double portable_sqrt(double x) {
|
||||||
|
if (x <= 0.0) return 0.0;
|
||||||
|
double guess = (x < 1.0) ? 1.0 : x;
|
||||||
|
for (int i = 0; i < 50; i++) {
|
||||||
|
guess = 0.5 * (guess + x / guess);
|
||||||
|
}
|
||||||
|
return guess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* atan(z) for z in [-1,1] via a minimax polynomial (Abramowitz & Stegun
|
||||||
|
* 4.4.49-style coefficients), max error ~1.2e-5 radians. */
|
||||||
|
static double portable_atan(double z) {
|
||||||
|
double z2 = z * z;
|
||||||
|
return z * (0.9998660 +
|
||||||
|
z2 * (-0.3302995 +
|
||||||
|
z2 * (0.1801410 +
|
||||||
|
z2 * (-0.0851330 + z2 * 0.0208351))));
|
||||||
|
}
|
||||||
|
|
||||||
|
double lowprec_atan2(double y, double x) {
|
||||||
|
if (x == 0.0 && y == 0.0) return 0.0;
|
||||||
|
|
||||||
|
double ax = x < 0.0 ? -x : x;
|
||||||
|
double ay = y < 0.0 ? -y : y;
|
||||||
|
|
||||||
|
double angle;
|
||||||
|
if (ax >= ay) {
|
||||||
|
angle = portable_atan(ay / ax);
|
||||||
|
if (x < 0.0) angle = DECK_PI - angle;
|
||||||
|
} else {
|
||||||
|
angle = DECK_PI / 2.0 - portable_atan(ax / ay);
|
||||||
|
if (x < 0.0) angle = DECK_PI - angle;
|
||||||
|
}
|
||||||
|
return (y < 0.0) ? -angle : angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solves Kepler's equation for the given elements at day-number `d`
|
||||||
|
* (days since J2000.0), returning rectangular heliocentric (geocentric
|
||||||
|
* for the Sun/Moon "orbits") ecliptic coordinates in AU (Earth radii for
|
||||||
|
* the Moon). */
|
||||||
|
static void solve_orbit(const OrbitalElements *el, double d, double *x, double *y) {
|
||||||
|
double N = (el->N0 + el->Nd * d) * DEG2RAD;
|
||||||
|
double i = (el->i0 + el->id * d) * DEG2RAD;
|
||||||
|
double w = (el->w0 + el->wd * d) * DEG2RAD;
|
||||||
|
double a = el->a0 + el->ad * d;
|
||||||
|
double e = el->e0 + el->ed * d;
|
||||||
|
double M = normalize_deg(el->M0 + el->Md * d) * DEG2RAD;
|
||||||
|
|
||||||
|
double E = M + e * lowprec_sin(M) * (1.0 + e * lowprec_cos(M));
|
||||||
|
for (int iter = 0; iter < 8; iter++) {
|
||||||
|
double delta = (E - e * lowprec_sin(E) - M) / (1.0 - e * lowprec_cos(E));
|
||||||
|
E -= delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
double xv = a * (lowprec_cos(E) - e);
|
||||||
|
double yv = a * (portable_sqrt(1.0 - e * e) * lowprec_sin(E));
|
||||||
|
double v = lowprec_atan2(yv, xv);
|
||||||
|
double r = portable_sqrt(xv * xv + yv * yv);
|
||||||
|
|
||||||
|
double vw = v + w;
|
||||||
|
*x = r * (lowprec_cos(N) * lowprec_cos(vw) - lowprec_sin(N) * lowprec_sin(vw) * lowprec_cos(i));
|
||||||
|
*y = r * (lowprec_sin(N) * lowprec_cos(vw) + lowprec_cos(N) * lowprec_sin(vw) * lowprec_cos(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dozen largest lunar perturbation terms (Schlyter's "more
|
||||||
|
* accurate" Moon correction), applied as a direct correction in degrees
|
||||||
|
* to the Moon's mean-orbit longitude - brings the Moon from several
|
||||||
|
* degrees of error down to a few arcminutes, worth the modest extra
|
||||||
|
* code given how often the Moon matters here (a natal luminary, and the
|
||||||
|
* fastest-moving transiting body). */
|
||||||
|
static double moon_longitude_correction(double d) {
|
||||||
|
double Ms = normalize_deg(356.0470 + 0.9856002585 * d) * DEG2RAD; /* Sun mean anomaly */
|
||||||
|
double Mm = normalize_deg(134.9634 + 13.0649929509 * d) * DEG2RAD; /* Moon mean anomaly */
|
||||||
|
double Nm = normalize_deg(125.1228 - 0.0529538083 * d) * DEG2RAD; /* Moon's node */
|
||||||
|
double ws = normalize_deg(282.9404 + 4.70935e-5 * d) * DEG2RAD; /* Sun's perihelion */
|
||||||
|
double wm = normalize_deg(318.0634 + 0.1643573223 * d) * DEG2RAD; /* Moon's perihelion */
|
||||||
|
|
||||||
|
double Ls = ws + Ms; /* Sun's mean longitude */
|
||||||
|
double Lm = Nm + wm + Mm; /* Moon's mean longitude */
|
||||||
|
double D = Lm - Ls; /* elongation */
|
||||||
|
double F = Lm - Nm; /* argument of latitude */
|
||||||
|
|
||||||
|
double corr = 0.0;
|
||||||
|
corr += -1.274 * lowprec_sin(Mm - 2.0 * D);
|
||||||
|
corr += 0.658 * lowprec_sin(2.0 * D);
|
||||||
|
corr += -0.186 * lowprec_sin(Ms);
|
||||||
|
corr += -0.059 * lowprec_sin(2.0 * Mm - 2.0 * D);
|
||||||
|
corr += -0.057 * lowprec_sin(Mm - 2.0 * D + Ms);
|
||||||
|
corr += 0.053 * lowprec_sin(Mm + 2.0 * D);
|
||||||
|
corr += 0.046 * lowprec_sin(2.0 * D - Ms);
|
||||||
|
corr += 0.041 * lowprec_sin(Mm - Ms);
|
||||||
|
corr += -0.035 * lowprec_sin(D);
|
||||||
|
corr += -0.031 * lowprec_sin(Mm + Ms);
|
||||||
|
corr += -0.015 * lowprec_sin(2.0 * F - 2.0 * D);
|
||||||
|
corr += 0.011 * lowprec_sin(Mm - 4.0 * D);
|
||||||
|
return corr;
|
||||||
|
}
|
||||||
|
|
||||||
|
double lowprec_geocentric_longitude(Body body, double jd) {
|
||||||
|
double d = jd - 2451545.0;
|
||||||
|
|
||||||
|
double sun_x, sun_y;
|
||||||
|
solve_orbit(&k_elements[PLANET_SUN], d, &sun_x, &sun_y);
|
||||||
|
|
||||||
|
if (body == PLANET_SUN) {
|
||||||
|
return normalize_deg(lowprec_atan2(sun_y, sun_x) * RAD2DEG);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body == PLANET_MOON) {
|
||||||
|
double moon_x, moon_y;
|
||||||
|
solve_orbit(&k_elements[PLANET_MOON], d, &moon_x, &moon_y);
|
||||||
|
double lon = lowprec_atan2(moon_y, moon_x) * RAD2DEG + moon_longitude_correction(d);
|
||||||
|
return normalize_deg(lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Earth's own heliocentric position is the Sun's geocentric one,
|
||||||
|
* negated (both "orbits" share the same ecliptic plane by
|
||||||
|
* definition here, i.e. i = 0 for the Sun's elements). */
|
||||||
|
double earth_x = -sun_x, earth_y = -sun_y;
|
||||||
|
|
||||||
|
double planet_x, planet_y;
|
||||||
|
solve_orbit(&k_elements[body], d, &planet_x, &planet_y);
|
||||||
|
|
||||||
|
double geo_x = planet_x - earth_x;
|
||||||
|
double geo_y = planet_y - earth_y;
|
||||||
|
return normalize_deg(lowprec_atan2(geo_y, geo_x) * RAD2DEG);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#ifndef DECK_LOWPREC_EPHEMERIS_H
|
||||||
|
#define DECK_LOWPREC_EPHEMERIS_H
|
||||||
|
|
||||||
|
#include "astro.h"
|
||||||
|
|
||||||
|
/* A compact, self-contained low-precision ephemeris for the watch build
|
||||||
|
* only (PBL_SDK_3) - the vendored Astronomy Engine (astro.c's normal
|
||||||
|
* desktop path) compiles to ~127KB of code, well over a whole Pebble
|
||||||
|
* app's entire 64KB code+data+bss budget, so it can't be linked into the
|
||||||
|
* watchapp at all. This module replaces it there via classic Keplerian
|
||||||
|
* orbital-element formulas (the widely-published "low precision"
|
||||||
|
* planetary position method - see lowprec_ephemeris.c's own comment for
|
||||||
|
* accuracy and sourcing). Not used, and not compiled, on desktop - see
|
||||||
|
* astro.c's AstroTime typedef and the Makefile's ENGINE_SRCS, which never
|
||||||
|
* includes this file. */
|
||||||
|
|
||||||
|
/* Julian Date (TT and UT are treated as identical at this precision -
|
||||||
|
* their few-tens-of-seconds difference is far below this method's own
|
||||||
|
* ~1 arcminute-to-a-few-degrees accuracy) for the given UTC calendar
|
||||||
|
* date/time. */
|
||||||
|
double lowprec_julian_date(int year, int month, int day, int hour, int minute, double second);
|
||||||
|
|
||||||
|
/* Geocentric apparent ecliptic longitude of `body` at Julian Date `jd`,
|
||||||
|
* in degrees [0, 360). */
|
||||||
|
double lowprec_geocentric_longitude(Body body, double jd);
|
||||||
|
|
||||||
|
/* Greenwich Mean Sidereal Time at Julian Date `jd`, in hours [0, 24) -
|
||||||
|
* the watch's replacement for Astronomy_SiderealTime(), used the same
|
||||||
|
* way (RAMC = gmst*15 + geographic longitude) to compute the Ascendant. */
|
||||||
|
double lowprec_gmst_hours(double jd);
|
||||||
|
|
||||||
|
/* atan2()/sin()/cos() replacements for the watch build - see
|
||||||
|
* lowprec_ephemeris.c's file-level comment. Exported (not file-static)
|
||||||
|
* since astro.c's compute_ascendant() also needs them. Same signature/
|
||||||
|
* semantics as their libm counterparts, in radians. */
|
||||||
|
double lowprec_atan2(double y, double x);
|
||||||
|
double lowprec_sin(double rad);
|
||||||
|
double lowprec_cos(double rad);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -15,12 +15,16 @@ static void print_usage(const char *prog) {
|
|||||||
"Usage: %s --seed <string> --birth-date YYYY-MM-DD --birth-time HH:MM\n"
|
"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"
|
" --birth-utc-offset <hours> --birth-lat <deg> --birth-lon <deg>\n"
|
||||||
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
|
" [--date YYYY-MM-DDTHH:MM] [--format text|html|json]\n"
|
||||||
|
" [--gender male|female|unspecified]\n"
|
||||||
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
" [--lang <code>] [--i18n-dir <path>]\n\n"
|
||||||
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
" --seed Tarot seed string; same seed -> same Celtic Cross spread.\n"
|
||||||
" --birth-date/time Birth date/time in local clock time.\n"
|
" --birth-date/time Birth date/time in local clock time.\n"
|
||||||
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
|
" --birth-utc-offset Hours to subtract from local birth time to get UTC (e.g. 2 for CEST).\n"
|
||||||
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
|
" --birth-lat/lon Birth location in decimal degrees (north/east positive).\n"
|
||||||
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
" --date UTC moment for today's transits/tarot draw. Defaults to now.\n"
|
||||||
|
" --gender Who the reading is for - only affects the Celtic Cross\n"
|
||||||
|
" position text's pronouns, never the chart/draw itself.\n"
|
||||||
|
" Defaults to unspecified (pronoun-neutral phrasing).\n"
|
||||||
" --format Output format, defaults to text. html links card art via\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"
|
" img/<file>, i.e. it expects to be saved next to the img/\n"
|
||||||
" directory that `make images` copies into dist/. json uses\n"
|
" directory that `make images` copies into dist/. json uses\n"
|
||||||
@@ -63,6 +67,7 @@ int main(int argc, char **argv) {
|
|||||||
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
|
const char *birth_utc_offset = NULL, *birth_lat = NULL, *birth_lon = NULL;
|
||||||
const char *date_str = NULL, *format_str = "text";
|
const char *date_str = NULL, *format_str = "text";
|
||||||
const char *lang = "en", *i18n_dir_arg = NULL;
|
const char *lang = "en", *i18n_dir_arg = NULL;
|
||||||
|
const char *gender_str = "unspecified";
|
||||||
|
|
||||||
for (int i = 1; i < argc; i++) {
|
for (int i = 1; i < argc; i++) {
|
||||||
const char *arg = argv[i];
|
const char *arg = argv[i];
|
||||||
@@ -75,6 +80,7 @@ int main(int argc, char **argv) {
|
|||||||
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
|
else if (strcmp(arg, "--birth-lon") == 0) rc = require_arg(argc, argv, &i, arg, &birth_lon);
|
||||||
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
|
else if (strcmp(arg, "--date") == 0) rc = require_arg(argc, argv, &i, arg, &date_str);
|
||||||
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
|
else if (strcmp(arg, "--format") == 0) rc = require_arg(argc, argv, &i, arg, &format_str);
|
||||||
|
else if (strcmp(arg, "--gender") == 0) rc = require_arg(argc, argv, &i, arg, &gender_str);
|
||||||
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
|
else if (strcmp(arg, "--lang") == 0) rc = require_arg(argc, argv, &i, arg, &lang);
|
||||||
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
|
else if (strcmp(arg, "--i18n-dir") == 0) rc = require_arg(argc, argv, &i, arg, &i18n_dir_arg);
|
||||||
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
else if (strcmp(arg, "--help") == 0 || strcmp(arg, "-h") == 0) {
|
||||||
@@ -136,6 +142,15 @@ int main(int argc, char **argv) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Gender gender;
|
||||||
|
if (strcmp(gender_str, "unspecified") == 0) gender = GENDER_UNSPECIFIED;
|
||||||
|
else if (strcmp(gender_str, "male") == 0) gender = GENDER_MALE;
|
||||||
|
else if (strcmp(gender_str, "female") == 0) gender = GENDER_FEMALE;
|
||||||
|
else {
|
||||||
|
fprintf(stderr, "Invalid --gender, expected male, female, or unspecified\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
char i18n_path[512];
|
char i18n_path[512];
|
||||||
if (i18n_dir_arg) {
|
if (i18n_dir_arg) {
|
||||||
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
|
snprintf(i18n_path, sizeof i18n_path, "%s/%s.lang", i18n_dir_arg, lang);
|
||||||
@@ -148,7 +163,7 @@ int main(int argc, char **argv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DailyReading reading;
|
DailyReading reading;
|
||||||
reading_generate(seed, &birth, utc_moment, &reading);
|
reading_generate(seed, &birth, gender, utc_moment, &reading);
|
||||||
|
|
||||||
if (format == FORMAT_HTML) {
|
if (format == FORMAT_HTML) {
|
||||||
reading_print_html(&reading, stdout);
|
reading_print_html(&reading, stdout);
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
|
|
||||||
#include <math.h>
|
#include <math.h>
|
||||||
|
|
||||||
|
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||||
|
time_t utc_moment, DailyReading *out) {
|
||||||
|
out->utc_moment = utc_moment;
|
||||||
|
out->gender = gender;
|
||||||
|
astro_compute_natal_chart(birth, &out->natal);
|
||||||
|
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
|
||||||
|
tarot_draw_celtic_cross(tarot_seed, &out->spread);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Everything below uses fprintf, which Pebble's SDK blocks at compile
|
||||||
|
* time (see pebble_warn_unsupported_functions.h) - not compiled at all
|
||||||
|
* for the watch, which only ever calls reading_generate() above. */
|
||||||
|
#ifndef PBL_SDK_3
|
||||||
|
|
||||||
/* UI-label keys ("ui.*") are looked up here rather than in i18n.h/.c
|
/* UI-label keys ("ui.*") are looked up here rather than in i18n.h/.c
|
||||||
* because they're specific to these two renderers, not part of the
|
* because they're specific to these two renderers, not part of the
|
||||||
* portable astro/tarot vocabulary. */
|
* portable astro/tarot vocabulary. */
|
||||||
@@ -10,13 +24,6 @@ static const char *ui(const char *key, const char *fallback) {
|
|||||||
return i18n_get(key, fallback);
|
return i18n_get(key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
|
||||||
time_t utc_moment, DailyReading *out) {
|
|
||||||
astro_compute_natal_chart(birth, &out->natal);
|
|
||||||
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
|
|
||||||
tarot_draw_celtic_cross(tarot_seed, &out->spread);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void print_position_deg(FILE *out, const PlanetPosition *p) {
|
static void print_position_deg(FILE *out, const PlanetPosition *p) {
|
||||||
fprintf(out, "%5.1f%s %-11s (%s %d)", p->degree_in_sign, "\xc2\xb0",
|
fprintf(out, "%5.1f%s %-11s (%s %d)", p->degree_in_sign, "\xc2\xb0",
|
||||||
astro_sign_name(p->sign), ui("ui.house", "House"), p->house);
|
astro_sign_name(p->sign), ui("ui.house", "House"), p->house);
|
||||||
@@ -60,10 +67,10 @@ void reading_print_text(const DailyReading *r, FILE *out) {
|
|||||||
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
|
fprintf(out, "===== %s =====\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
const TarotDraw *draw = &r->spread.positions[i];
|
const TarotDraw *draw = &r->spread.positions[i];
|
||||||
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i),
|
fprintf(out, "%-16s %s%s%s\n", tarot_position_name((CelticCrossPosition)i, r->gender),
|
||||||
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
||||||
draw->reversed ? ui("ui.reversed", "(Reversed)") : "");
|
draw->reversed ? ui("ui.reversed", "(Reversed)") : "");
|
||||||
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i));
|
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, r->gender));
|
||||||
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,12 +139,12 @@ void reading_print_html(const DailyReading *r, FILE *out) {
|
|||||||
" <div class=\"desc\">%s</div>\n"
|
" <div class=\"desc\">%s</div>\n"
|
||||||
" <div class=\"meaning\">%s</div>\n"
|
" <div class=\"meaning\">%s</div>\n"
|
||||||
"</div>\n",
|
"</div>\n",
|
||||||
tarot_position_name((CelticCrossPosition)i),
|
tarot_position_name((CelticCrossPosition)i, r->gender),
|
||||||
draw->reversed ? "reversed" : "",
|
draw->reversed ? "reversed" : "",
|
||||||
tarot_card_image_file(draw->card), tarot_card_name(draw->card),
|
tarot_card_image_file(draw->card), tarot_card_name(draw->card),
|
||||||
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
tarot_card_name(draw->card), draw->reversed ? " " : "",
|
||||||
draw->reversed ? ui("ui.reversed", "(Reversed)") : "",
|
draw->reversed ? ui("ui.reversed", "(Reversed)") : "",
|
||||||
tarot_position_description((CelticCrossPosition)i),
|
tarot_position_description((CelticCrossPosition)i, r->gender),
|
||||||
tarot_card_meaning(draw->card, draw->reversed));
|
tarot_card_meaning(draw->card, draw->reversed));
|
||||||
}
|
}
|
||||||
fprintf(out, "</div>\n</body></html>\n");
|
fprintf(out, "</div>\n</body></html>\n");
|
||||||
@@ -155,6 +162,10 @@ static void print_body_position_json(FILE *out, const char *indent, Body body,
|
|||||||
void reading_print_json(const DailyReading *r, FILE *out) {
|
void reading_print_json(const DailyReading *r, FILE *out) {
|
||||||
fprintf(out, "{\n");
|
fprintf(out, "{\n");
|
||||||
|
|
||||||
|
struct tm *utc = gmtime(&r->utc_moment);
|
||||||
|
fprintf(out, " \"date\": \"%04d-%02d-%02d\",\n", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
fprintf(out, " \"gender\": \"%s\",\n", tarot_gender_slug(r->gender));
|
||||||
|
|
||||||
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
|
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
|
||||||
for (int b = 0; b < NUM_BODIES; b++) {
|
for (int b = 0; b < NUM_BODIES; b++) {
|
||||||
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
|
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
|
||||||
@@ -194,3 +205,5 @@ void reading_print_json(const DailyReading *r, FILE *out) {
|
|||||||
}
|
}
|
||||||
fprintf(out, " ]\n }\n}\n");
|
fprintf(out, " ]\n }\n}\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif /* !PBL_SDK_3 */
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
#ifndef DECK_READING_H
|
#ifndef DECK_READING_H
|
||||||
#define DECK_READING_H
|
#define DECK_READING_H
|
||||||
|
|
||||||
|
/* See astro.h's own comment on this same #ifdef - Pebble's SDK
|
||||||
|
* pre-defines <time.h>'s include guard and declares time_t/struct tm
|
||||||
|
* itself inside pebble.h. */
|
||||||
|
#ifdef PBL_SDK_3
|
||||||
|
#include <pebble.h>
|
||||||
|
#else
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
#include "astro.h"
|
#include "astro.h"
|
||||||
#include "tarot.h"
|
#include "tarot.h"
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
/* The moment passed to reading_generate() - the day this reading is
|
||||||
|
* for, not when it happened to be generated. Set by reading_generate()
|
||||||
|
* itself, so every caller (CLI, watch, reading_load_json() on the
|
||||||
|
* interpreter side) gets it for free without threading it through
|
||||||
|
* separately. */
|
||||||
|
time_t utc_moment;
|
||||||
|
/* Who the reading is for - only affects the pronouns in the Celtic
|
||||||
|
* Cross position text (see tarot.h's Gender), set by reading_generate()
|
||||||
|
* the same way utc_moment is, so every caller gets it for free. */
|
||||||
|
Gender gender;
|
||||||
NatalChart natal;
|
NatalChart natal;
|
||||||
DailyTransits transits;
|
DailyTransits transits;
|
||||||
CelticCrossSpread spread;
|
CelticCrossSpread spread;
|
||||||
@@ -15,11 +32,14 @@ typedef struct {
|
|||||||
|
|
||||||
/* The real API: this is the only function the watchapp needs to call.
|
/* The real API: this is the only function the watchapp needs to call.
|
||||||
* It walks the returned struct directly to lay out its own screens. */
|
* It walks the returned struct directly to lay out its own screens. */
|
||||||
void reading_generate(const char *tarot_seed, const BirthData *birth,
|
void reading_generate(const char *tarot_seed, const BirthData *birth, Gender gender,
|
||||||
time_t utc_moment, DailyReading *out);
|
time_t utc_moment, DailyReading *out);
|
||||||
|
|
||||||
/* Desktop-only output formats for inspecting a reading before any watch
|
/* Desktop-only output formats for inspecting a reading before any watch
|
||||||
* UI exists. Not used by (and not needed by) the watchapp. */
|
* UI exists - use fprintf, which Pebble's SDK blocks at compile time, so
|
||||||
|
* these aren't even declared (and reading.c doesn't define them) when
|
||||||
|
* building for the watch. Not used by (and not needed by) the watchapp. */
|
||||||
|
#ifndef PBL_SDK_3
|
||||||
void reading_print_text(const DailyReading *r, FILE *out);
|
void reading_print_text(const DailyReading *r, FILE *out);
|
||||||
void reading_print_html(const DailyReading *r, FILE *out);
|
void reading_print_html(const DailyReading *r, FILE *out);
|
||||||
|
|
||||||
@@ -28,5 +48,6 @@ void reading_print_html(const DailyReading *r, FILE *out);
|
|||||||
* tarot_*_name()) so the output is identical regardless of --lang. This
|
* tarot_*_name()) so the output is identical regardless of --lang. This
|
||||||
* is the interchange format interpreter/ reads - see its json.c. */
|
* is the interchange format interpreter/ reads - see its json.c. */
|
||||||
void reading_print_json(const DailyReading *r, FILE *out);
|
void reading_print_json(const DailyReading *r, FILE *out);
|
||||||
|
#endif
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ typedef struct {
|
|||||||
bool reversed;
|
bool reversed;
|
||||||
} TarotDraw;
|
} TarotDraw;
|
||||||
|
|
||||||
|
/* Who the reading is for - affects only the pronouns in the Celtic
|
||||||
|
* Cross position text below (Waite's original wording addresses the
|
||||||
|
* querent as "him"), never anything about the tarot draw or astrology
|
||||||
|
* math itself. GENDER_UNSPECIFIED is the default and renders
|
||||||
|
* pronoun-neutral phrasing (singular "they" in English). */
|
||||||
|
typedef enum {
|
||||||
|
GENDER_UNSPECIFIED = 0,
|
||||||
|
GENDER_MALE,
|
||||||
|
GENDER_FEMALE
|
||||||
|
} Gender;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
/* Indexed by CelticCrossPosition. */
|
/* Indexed by CelticCrossPosition. */
|
||||||
TarotDraw positions[TAROT_SPREAD_SIZE];
|
TarotDraw positions[TAROT_SPREAD_SIZE];
|
||||||
@@ -67,8 +78,8 @@ const char *tarot_card_name(TarotCard card);
|
|||||||
const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */
|
const char *tarot_card_image_file(TarotCard card); /* matches res/img/ */
|
||||||
const char *tarot_card_meaning(TarotCard card, bool reversed);
|
const char *tarot_card_meaning(TarotCard card, bool reversed);
|
||||||
|
|
||||||
const char *tarot_position_name(CelticCrossPosition position);
|
const char *tarot_position_name(CelticCrossPosition position, Gender gender);
|
||||||
const char *tarot_position_description(CelticCrossPosition position);
|
const char *tarot_position_description(CelticCrossPosition position, Gender gender);
|
||||||
|
|
||||||
/* Stable, language-independent identifiers (e.g. "high_priestess",
|
/* Stable, language-independent identifiers (e.g. "high_priestess",
|
||||||
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
|
* "wheel_of_fortune", "recent_past") - the same strings i18n.c's lookup
|
||||||
@@ -76,5 +87,6 @@ const char *tarot_position_description(CelticCrossPosition position);
|
|||||||
* (reading_print_json) that must stay identical regardless of --lang. */
|
* (reading_print_json) that must stay identical regardless of --lang. */
|
||||||
const char *tarot_card_slug(TarotCard card);
|
const char *tarot_card_slug(TarotCard card);
|
||||||
const char *tarot_position_slug(CelticCrossPosition position);
|
const char *tarot_position_slug(CelticCrossPosition position);
|
||||||
|
const char *tarot_gender_slug(Gender gender); /* "unspecified"/"male"/"female" */
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -130,52 +130,97 @@ static const TarotCardInfo k_cards[TAROT_DECK_SIZE] = {
|
|||||||
|
|
||||||
/* Position names and one-line descriptions follow Waite's own account
|
/* Position names and one-line descriptions follow Waite's own account
|
||||||
* of "An Ancient Celtic Method of Divination" (Part III §7), in his
|
* of "An Ancient Celtic Method of Divination" (Part III §7), in his
|
||||||
* original drawing order. */
|
* original drawing order. Waite addresses the querent as "him"
|
||||||
|
* throughout; that wording is kept verbatim as the GENDER_MALE variant
|
||||||
|
* below, and GENDER_FEMALE/GENDER_UNSPECIFIED are original paraphrases
|
||||||
|
* written for this project (not Waite's own text) so the reading can
|
||||||
|
* address the person it's actually for. Each array is indexed by
|
||||||
|
* Gender (tarot.h): [GENDER_UNSPECIFIED], [GENDER_MALE], [GENDER_FEMALE]. */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char *name;
|
const char *name[3];
|
||||||
const char *description;
|
const char *description[3];
|
||||||
} PositionInfo;
|
} PositionInfo;
|
||||||
|
|
||||||
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
|
static const PositionInfo k_positions[TAROT_SPREAD_SIZE] = {
|
||||||
[POSITION_PRESENT] = {
|
[POSITION_PRESENT] = {
|
||||||
"The Present",
|
{ "The Present", "The Present", "The Present" },
|
||||||
"This covers him: the general influence affecting the matter."
|
{
|
||||||
|
"This covers them: the general influence affecting the matter.",
|
||||||
|
"This covers him: the general influence affecting the matter.",
|
||||||
|
"This covers her: the general influence affecting the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_CHALLENGE] = {
|
[POSITION_CHALLENGE] = {
|
||||||
"The Challenge",
|
{ "The Challenge", "The Challenge", "The Challenge" },
|
||||||
"This crosses him: the nature of the obstacle in the matter."
|
{
|
||||||
|
"This crosses them: the nature of the obstacle in the matter.",
|
||||||
|
"This crosses him: the nature of the obstacle in the matter.",
|
||||||
|
"This crosses her: the nature of the obstacle in the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_CROWN] = {
|
[POSITION_CROWN] = {
|
||||||
"The Crown",
|
{ "The Crown", "The Crown", "The Crown" },
|
||||||
"This crowns him: the aim or ideal, the best that can be achieved."
|
{
|
||||||
|
"This crowns them: the aim or ideal, the best that can be achieved.",
|
||||||
|
"This crowns him: the aim or ideal, the best that can be achieved.",
|
||||||
|
"This crowns her: the aim or ideal, the best that can be achieved.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_FOUNDATION] = {
|
[POSITION_FOUNDATION] = {
|
||||||
"The Foundation",
|
{ "The Foundation", "The Foundation", "The Foundation" },
|
||||||
"This is beneath him: the basis of the matter, already actual."
|
{
|
||||||
|
"This is beneath them: the basis of the matter, already actual.",
|
||||||
|
"This is beneath him: the basis of the matter, already actual.",
|
||||||
|
"This is beneath her: the basis of the matter, already actual.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_RECENT_PAST] = {
|
[POSITION_RECENT_PAST] = {
|
||||||
"The Recent Past",
|
{ "The Recent Past", "The Recent Past", "The Recent Past" },
|
||||||
"This is behind him: the influence that is just passing away."
|
{
|
||||||
|
"This is behind them: the influence that is just passing away.",
|
||||||
|
"This is behind him: the influence that is just passing away.",
|
||||||
|
"This is behind her: the influence that is just passing away.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_NEAR_FUTURE] = {
|
[POSITION_NEAR_FUTURE] = {
|
||||||
"The Near Future",
|
{ "The Near Future", "The Near Future", "The Near Future" },
|
||||||
"This is before him: the influence now coming into action."
|
{
|
||||||
|
"This is before them: the influence now coming into action.",
|
||||||
|
"This is before him: the influence now coming into action.",
|
||||||
|
"This is before her: the influence now coming into action.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_ATTITUDE] = {
|
[POSITION_ATTITUDE] = {
|
||||||
"Himself",
|
{ "Themself", "Himself", "Herself" },
|
||||||
"His position or attitude in the circumstances."
|
{
|
||||||
|
"Their position or attitude in the circumstances.",
|
||||||
|
"His position or attitude in the circumstances.",
|
||||||
|
"Her position or attitude in the circumstances.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_ENVIRONMENT] = {
|
[POSITION_ENVIRONMENT] = {
|
||||||
"His House",
|
{ "Their House", "His House", "Her House" },
|
||||||
"His environment and the tendencies at work therein."
|
{
|
||||||
|
"Their environment and the tendencies at work therein.",
|
||||||
|
"His environment and the tendencies at work therein.",
|
||||||
|
"Her environment and the tendencies at work therein.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_HOPES_AND_FEARS] = {
|
[POSITION_HOPES_AND_FEARS] = {
|
||||||
"Hopes and Fears",
|
{ "Hopes and Fears", "Hopes and Fears", "Hopes and Fears" },
|
||||||
"His hopes or fears in the matter."
|
{
|
||||||
|
"Their hopes or fears in the matter.",
|
||||||
|
"His hopes or fears in the matter.",
|
||||||
|
"Her hopes or fears in the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[POSITION_OUTCOME] = {
|
[POSITION_OUTCOME] = {
|
||||||
"The Outcome",
|
{ "The Outcome", "The Outcome", "The Outcome" },
|
||||||
"What will come: the final result of the matter."
|
{
|
||||||
|
"What will come: the final result of the matter.",
|
||||||
|
"What will come: the final result of the matter.",
|
||||||
|
"What will come: the final result of the matter.",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -229,13 +274,44 @@ static const char *card_field(const char *slug, const char *field, const char *f
|
|||||||
return i18n_get(key, fallback);
|
return i18n_get(key, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
static const char *position_field(const char *slug, const char *field, const char *fallback) {
|
/* Suffix appended to the i18n key for the male/female variants - the
|
||||||
char key[64];
|
* unspecified/neutral variant uses the plain, unsuffixed key so it
|
||||||
strcpy(key, "position.");
|
* matches the field name callers already know (e.g. "position.
|
||||||
strcat(key, slug);
|
* attitude.name"), same convention as card_field()'s upright/reversed
|
||||||
strcat(key, ".");
|
* field names above. */
|
||||||
strcat(key, field);
|
static const char *const k_gender_suffix[3] = {
|
||||||
return i18n_get(key, fallback);
|
[GENDER_UNSPECIFIED] = "",
|
||||||
|
[GENDER_MALE] = ".male",
|
||||||
|
[GENDER_FEMALE] = ".female",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *const k_gender_slug[3] = {
|
||||||
|
[GENDER_UNSPECIFIED] = "unspecified",
|
||||||
|
[GENDER_MALE] = "male",
|
||||||
|
[GENDER_FEMALE] = "female",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *position_field(const char *slug, const char *field, Gender gender,
|
||||||
|
const char *fallback) {
|
||||||
|
char neutral_key[80];
|
||||||
|
strcpy(neutral_key, "position.");
|
||||||
|
strcat(neutral_key, slug);
|
||||||
|
strcat(neutral_key, ".");
|
||||||
|
strcat(neutral_key, field);
|
||||||
|
if (gender == GENDER_UNSPECIFIED) return i18n_get(neutral_key, fallback);
|
||||||
|
|
||||||
|
/* Not every position's text actually varies by gender (e.g. "The
|
||||||
|
* Outcome" has no pronoun) - the .lang files only define a ".male"/
|
||||||
|
* ".female" key where the wording differs. So look up the neutral key
|
||||||
|
* first and use *that* (translated, if a catalog is loaded) as the
|
||||||
|
* fallback for the gendered key, rather than jumping straight past a
|
||||||
|
* loaded translation to the hardcoded English `fallback` whenever the
|
||||||
|
* gendered key happens to be absent. */
|
||||||
|
const char *neutral_text = i18n_get(neutral_key, fallback);
|
||||||
|
char key[88];
|
||||||
|
strcpy(key, neutral_key);
|
||||||
|
strcat(key, k_gender_suffix[gender]);
|
||||||
|
return i18n_get(key, neutral_text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_card_name(TarotCard card) {
|
const char *tarot_card_name(TarotCard card) {
|
||||||
@@ -252,13 +328,15 @@ const char *tarot_card_meaning(TarotCard card, bool reversed) {
|
|||||||
reversed ? k_cards[card].reversed : k_cards[card].upright);
|
reversed ? k_cards[card].reversed : k_cards[card].upright);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_position_name(CelticCrossPosition position) {
|
const char *tarot_position_name(CelticCrossPosition position, Gender gender) {
|
||||||
return position_field(k_position_slug[position], "name", k_positions[position].name);
|
return position_field(k_position_slug[position], "name", gender, k_positions[position].name[gender]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_position_description(CelticCrossPosition position) {
|
const char *tarot_position_description(CelticCrossPosition position, Gender gender) {
|
||||||
return position_field(k_position_slug[position], "desc", k_positions[position].description);
|
return position_field(k_position_slug[position], "desc", gender,
|
||||||
|
k_positions[position].description[gender]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
|
const char *tarot_card_slug(TarotCard card) { return k_card_slug[card]; }
|
||||||
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
|
const char *tarot_position_slug(CelticCrossPosition position) { return k_position_slug[position]; }
|
||||||
|
const char *tarot_gender_slug(Gender gender) { return k_gender_slug[gender]; }
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ static void test_reading_generate_smoke(void) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
DailyReading r1, r2;
|
DailyReading r1, r2;
|
||||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r1);
|
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r1);
|
||||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r2);
|
reading_generate("smoke-test-seed", &birth, GENDER_UNSPECIFIED, 1751500000, &r2);
|
||||||
|
|
||||||
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
|
assert(r1.spread.positions[i].card == r2.spread.positions[i].card);
|
||||||
@@ -103,7 +103,7 @@ static void test_reading_print_json_shape(void) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
DailyReading r;
|
DailyReading r;
|
||||||
reading_generate("smoke-test-seed", &birth, 1751500000, &r);
|
reading_generate("smoke-test-seed", &birth, GENDER_FEMALE, 1751500000, &r);
|
||||||
|
|
||||||
FILE *tmp = tmpfile();
|
FILE *tmp = tmpfile();
|
||||||
assert(tmp);
|
assert(tmp);
|
||||||
@@ -120,6 +120,8 @@ static void test_reading_print_json_shape(void) {
|
|||||||
* a stray astro_*_name()/tarot_*_name() call leaking translated text
|
* a stray astro_*_name()/tarot_*_name() call leaking translated text
|
||||||
* into what must stay language-independent regardless of --lang). */
|
* into what must stay language-independent regardless of --lang). */
|
||||||
assert(buf[0] == '{');
|
assert(buf[0] == '{');
|
||||||
|
assert(strstr(buf, "\"date\": \"2025-07-02\"") != NULL); /* UTC date of the fixed 1751500000 seed */
|
||||||
|
assert(strstr(buf, "\"gender\": \"female\"") != NULL);
|
||||||
assert(strstr(buf, "\"natal\"") != NULL);
|
assert(strstr(buf, "\"natal\"") != NULL);
|
||||||
assert(strstr(buf, "\"transits\"") != NULL);
|
assert(strstr(buf, "\"transits\"") != NULL);
|
||||||
assert(strstr(buf, "\"spread\"") != NULL);
|
assert(strstr(buf, "\"spread\"") != NULL);
|
||||||
@@ -154,7 +156,7 @@ static void test_i18n_fallback_and_translation(void) {
|
|||||||
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
|
assert(strcmp(astro_moon_phase_name(MOON_FULL), "Vollmond") == 0);
|
||||||
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
|
assert(strcmp(astro_aspect_name(ASPECT_TRINE), "Trigon") == 0);
|
||||||
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
|
assert(strcmp(tarot_card_name(CARD_WORLD), "Die Welt") == 0);
|
||||||
assert(strcmp(tarot_position_name(POSITION_OUTCOME), "Das Ergebnis") == 0);
|
assert(strcmp(tarot_position_name(POSITION_OUTCOME, GENDER_UNSPECIFIED), "Das Ergebnis") == 0);
|
||||||
|
|
||||||
/* Loading a file that doesn't exist fails and leaves the previously
|
/* Loading a file that doesn't exist fails and leaves the previously
|
||||||
* loaded catalog in place, rather than silently clearing it. */
|
* loaded catalog in place, rather than silently clearing it. */
|
||||||
@@ -169,12 +171,45 @@ static void test_i18n_fallback_and_translation(void) {
|
|||||||
printf("PASS test_i18n_fallback_and_translation\n");
|
printf("PASS test_i18n_fallback_and_translation\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_gendered_position_text(void) {
|
||||||
|
/* English catalog (loaded by the previous test): "Attitude" is the one
|
||||||
|
* position whose name itself carries a pronoun. */
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED), "Themself") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Himself") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Herself") == 0);
|
||||||
|
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_UNSPECIFIED), "them") != NULL);
|
||||||
|
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_MALE), "him") != NULL);
|
||||||
|
assert(strstr(tarot_position_description(POSITION_PRESENT, GENDER_FEMALE), "her") != NULL);
|
||||||
|
|
||||||
|
/* The Outcome position has no pronoun in Waite's own text - identical
|
||||||
|
* across all three genders. */
|
||||||
|
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
|
||||||
|
tarot_position_description(POSITION_OUTCOME, GENDER_FEMALE)) == 0);
|
||||||
|
assert(strcmp(tarot_position_description(POSITION_OUTCOME, GENDER_MALE),
|
||||||
|
tarot_position_description(POSITION_OUTCOME, GENDER_UNSPECIFIED)) == 0);
|
||||||
|
|
||||||
|
bool loaded = i18n_load("engine/i18n/de.lang");
|
||||||
|
assert(loaded);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_MALE), "Er selbst") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_FEMALE), "Sie selbst") == 0);
|
||||||
|
assert(strcmp(tarot_position_name(POSITION_ATTITUDE, GENDER_UNSPECIFIED),
|
||||||
|
"Die fragende Person selbst") == 0);
|
||||||
|
assert(strcmp(tarot_position_description(POSITION_ENVIRONMENT, GENDER_MALE),
|
||||||
|
"Sein Umfeld und die darin wirkenden Tendenzen.") == 0);
|
||||||
|
|
||||||
|
loaded = i18n_load("engine/i18n/en.lang"); /* leave the catalog in a known state */
|
||||||
|
assert(loaded);
|
||||||
|
|
||||||
|
printf("PASS test_gendered_position_text\n");
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_tarot_determinism();
|
test_tarot_determinism();
|
||||||
test_natal_sun_sign();
|
test_natal_sun_sign();
|
||||||
test_reading_generate_smoke();
|
test_reading_generate_smoke();
|
||||||
test_reading_print_json_shape();
|
test_reading_print_json_shape();
|
||||||
test_i18n_fallback_and_translation();
|
test_i18n_fallback_and_translation();
|
||||||
|
test_gendered_position_text();
|
||||||
printf("All smoke tests passed.\n");
|
printf("All smoke tests passed.\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
#include "guidance.h"
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* i18n_get() only - see narrative.c's own comment on why this one extra
|
||||||
|
* engine header is needed despite guidance.h's otherwise header-only
|
||||||
|
* engine dependency. */
|
||||||
|
#include "../../engine/src/i18n.h"
|
||||||
|
|
||||||
|
/* Slugs for building "guidance.planet.<slug>"/i18n keys - mirrors
|
||||||
|
* reading_io.c's own k_body_slug, same duplication policy (see
|
||||||
|
* significance.c's comment for why each independently-testable module
|
||||||
|
* keeps its own small table rather than linking astro.c). */
|
||||||
|
static const char *const k_body_slug[NUM_BODIES] = {
|
||||||
|
"sun", "moon", "mercury", "venus", "mars",
|
||||||
|
"jupiter", "saturn", "uranus", "neptune", "pluto",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* English fallback only - "the Sun"/"the Moon" take a definite article
|
||||||
|
* that the other eight planets don't; see engine/i18n/en.lang's own
|
||||||
|
* comment on the guidance.planet.* keys this backs. */
|
||||||
|
static const char *const k_planet_name_fallback[NUM_BODIES] = {
|
||||||
|
"the Sun", "the Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *planet_display_name(Body body) {
|
||||||
|
char key[32];
|
||||||
|
snprintf(key, sizeof key, "guidance.planet.%s", k_body_slug[body]);
|
||||||
|
return i18n_get(key, k_planet_name_fallback[body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
CHAR_HARMONIOUS = 0,
|
||||||
|
CHAR_DISCORDANT,
|
||||||
|
CHAR_NEUTRAL,
|
||||||
|
NUM_TRANSIT_CHARACTERS
|
||||||
|
} TransitCharacter;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char *slug; /* builds "guidance.stance.<slug>.*" keys */
|
||||||
|
const char *upright; /* Attitude card fell upright (English fallback) */
|
||||||
|
const char *reversed; /* Attitude card fell reversed (English fallback) */
|
||||||
|
} AttitudeStance;
|
||||||
|
|
||||||
|
/* Original guidance text for this project - see guidance.h's doc
|
||||||
|
* comment for why. Fallback (English) text only; German translations
|
||||||
|
* live in engine/i18n/de.lang under the matching "guidance.*" keys.
|
||||||
|
* Indexed by TransitCharacter. */
|
||||||
|
static const AttitudeStance k_stance[NUM_TRANSIT_CHARACTERS] = {
|
||||||
|
[CHAR_HARMONIOUS] = {
|
||||||
|
"harmonious",
|
||||||
|
"You're already meeting the day in the right spirit - lean into "
|
||||||
|
"it rather than second-guessing a favorable position.",
|
||||||
|
"The day itself is working in your favor even though you don't "
|
||||||
|
"feel settled yet - trust the momentum more than your current "
|
||||||
|
"doubts.",
|
||||||
|
},
|
||||||
|
[CHAR_DISCORDANT] = {
|
||||||
|
"discordant",
|
||||||
|
"Your instincts are sound, but the day is testing them - hold "
|
||||||
|
"your position without forcing the issue.",
|
||||||
|
"Both the day and your own footing are unsettled right now - "
|
||||||
|
"this is a moment to steady yourself before pushing forward.",
|
||||||
|
},
|
||||||
|
[CHAR_NEUTRAL] = {
|
||||||
|
"neutral",
|
||||||
|
"Today concentrates whatever you already bring to it - your "
|
||||||
|
"current approach will be amplified, so make sure it's the one "
|
||||||
|
"you want.",
|
||||||
|
"Today intensifies things, including whatever is currently "
|
||||||
|
"unresolved in your own approach - worth sorting out before "
|
||||||
|
"acting.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
static TransitCharacter classify_transit(AspectType type) {
|
||||||
|
if (type == ASPECT_TRINE || type == ASPECT_SEXTILE) return CHAR_HARMONIOUS;
|
||||||
|
if (type == ASPECT_SQUARE || type == ASPECT_OPPOSITION) return CHAR_DISCORDANT;
|
||||||
|
return CHAR_NEUTRAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *stance_text(TransitCharacter character, bool reversed) {
|
||||||
|
const AttitudeStance *s = &k_stance[character];
|
||||||
|
char key[48];
|
||||||
|
snprintf(key, sizeof key, "guidance.stance.%s.%s", s->slug, reversed ? "reversed" : "upright");
|
||||||
|
return i18n_get(key, reversed ? s->reversed : s->upright);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* How to introduce the top transit, tailored to how strongly it's
|
||||||
|
* scoring (interp->day_level) - independent of the stance sentence
|
||||||
|
* itself (k_stance, above), which is written to stay true regardless of
|
||||||
|
* intensity. Each entry takes exactly one %s: the transiting planet's
|
||||||
|
* display name, always as the sentence's grammatical subject (matters
|
||||||
|
* for German, where the other three day levels put the planet name in
|
||||||
|
* non-nominative position in a naive word-for-word translation - see
|
||||||
|
* engine/i18n/de.lang's guidance.intro.* entries, which are phrased to
|
||||||
|
* avoid that). Fallback (English) text only. */
|
||||||
|
static const char *const k_intro_slug[DAY_SIGNIFICANCE_COUNT] = {
|
||||||
|
[DAY_QUIET] = "quiet",
|
||||||
|
[DAY_NOTABLE] = "notable",
|
||||||
|
[DAY_SIGNIFICANT] = "significant",
|
||||||
|
[DAY_MAJOR] = "major",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *const k_intro_fallback[DAY_SIGNIFICANCE_COUNT] = {
|
||||||
|
[DAY_QUIET] = "%s is only faintly active today, but for what it's worth:",
|
||||||
|
[DAY_NOTABLE] = "With a mild touch from %s today:",
|
||||||
|
[DAY_SIGNIFICANT] = "With %s clearly active today:",
|
||||||
|
[DAY_MAJOR] = "With %s as today's dominant influence:",
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *intro_template(DaySignificance level) {
|
||||||
|
char key[32];
|
||||||
|
snprintf(key, sizeof key, "guidance.intro.%s", k_intro_slug[level]);
|
||||||
|
return i18n_get(key, k_intro_fallback[level]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for a day with no aspects in orb at all (top_item_count ==
|
||||||
|
* 0, always DAY_QUIET) - there's no transiting planet to name, so the
|
||||||
|
* guidance falls back to the Attitude card's orientation alone. */
|
||||||
|
static const char *no_transit_text(bool reversed) {
|
||||||
|
static const char *const upright =
|
||||||
|
"There's no standout transit today - it's an astrologically quiet "
|
||||||
|
"day. That leaves things mostly about steadiness: trust your "
|
||||||
|
"current footing and use the calm to make headway.";
|
||||||
|
static const char *const reversed_text =
|
||||||
|
"There's no standout transit today - it's an astrologically quiet "
|
||||||
|
"day. That's a good moment to quietly re-settle your own "
|
||||||
|
"footing, since nothing external is forcing the pace.";
|
||||||
|
return i18n_get(reversed ? "guidance.no_transit.reversed" : "guidance.no_transit.upright",
|
||||||
|
reversed ? reversed_text : upright);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *reversed_marker(bool reversed) {
|
||||||
|
static char buf[32];
|
||||||
|
if (!reversed) return "";
|
||||||
|
snprintf(buf, sizeof buf, " %s", i18n_get("ui.reversed", "(Reversed)"));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
void guidance_write(char *buf, size_t buf_size, const char *indent, const DailyInterpretation *interp,
|
||||||
|
const CelticCrossSpread *spread) {
|
||||||
|
if (buf_size == 0) return;
|
||||||
|
buf[0] = '\0';
|
||||||
|
|
||||||
|
const TarotDraw *attitude = &spread->positions[POSITION_ATTITUDE];
|
||||||
|
const TarotDraw *outcome = &spread->positions[POSITION_OUTCOME];
|
||||||
|
|
||||||
|
/* Chained snprintf() calls, each bounds-checked against the running
|
||||||
|
* length before the next one runs - see narrative_write()'s matching
|
||||||
|
* comment for why (never fprintf/sprintf/vsnprintf). */
|
||||||
|
size_t len;
|
||||||
|
if (interp->top_item_count == 0) {
|
||||||
|
len = (size_t)snprintf(buf, buf_size, "%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));
|
||||||
|
len = (size_t)snprintf(buf, buf_size, "%s%s %s\n", indent, intro, stance);
|
||||||
|
}
|
||||||
|
if (len >= buf_size) return;
|
||||||
|
|
||||||
|
len += (size_t)snprintf(buf + len, buf_size - len, "%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));
|
||||||
|
if (len >= buf_size) return;
|
||||||
|
|
||||||
|
snprintf(buf + len, buf_size - len, "%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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#ifndef DECK_GUIDANCE_H
|
||||||
|
#define DECK_GUIDANCE_H
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* Header-only dependency on the engine for struct/enum *definitions*,
|
||||||
|
* same policy as narrative.h/significance.h - see their comments for
|
||||||
|
* why. Unlike those, though, the root Makefile *does* link
|
||||||
|
* engine/src/tarot_data.c's *implementation* (tarot_card_name()/
|
||||||
|
* tarot_card_meaning()) into the interpreter binary - a deliberate,
|
||||||
|
* narrow exception; see the Makefile's own comment on
|
||||||
|
* INTERP_TAROT_TEXT_OBJS for why that doesn't compromise this module's
|
||||||
|
* independent testability. */
|
||||||
|
#include "../../engine/src/tarot.h"
|
||||||
|
#include "significance.h"
|
||||||
|
|
||||||
|
/* Generous upper bound on guidance_write()'s output (intro/stance line
|
||||||
|
* + Attitude line + Outcome line, each ending in a newline, including
|
||||||
|
* the longest tarot_card_meaning() strings) across every language this
|
||||||
|
* project ships a translation for - see narrative.h's NARRATIVE_TEXT_MAX
|
||||||
|
* for the same reasoning. */
|
||||||
|
#define GUIDANCE_TEXT_MAX 1024
|
||||||
|
|
||||||
|
/* 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()/i18n_load_table() (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.
|
||||||
|
*
|
||||||
|
* Writes into `buf` (a caller-supplied buffer of `buf_size` bytes,
|
||||||
|
* always left null-terminated - GUIDANCE_TEXT_MAX is a safe size)
|
||||||
|
* instead of a FILE*, using only snprintf() internally (never
|
||||||
|
* fprintf/sprintf/vsnprintf, all of which Pebble's SDK blocks at compile
|
||||||
|
* time), so this function links into the watch app unchanged. */
|
||||||
|
void guidance_write(char *buf, size_t buf_size, const char *indent, const DailyInterpretation *interp,
|
||||||
|
const CelticCrossSpread *spread);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -3,23 +3,59 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "../../engine/src/i18n.h"
|
||||||
|
#include "guidance.h"
|
||||||
#include "narrative.h"
|
#include "narrative.h"
|
||||||
#include "reading_io.h"
|
#include "reading_io.h"
|
||||||
#include "significance.h"
|
#include "significance.h"
|
||||||
|
|
||||||
|
typedef enum { FORMAT_TEXT, FORMAT_HTML, FORMAT_JSON } OutputFormat;
|
||||||
|
|
||||||
static void print_usage(const char *prog) {
|
static void print_usage(const char *prog) {
|
||||||
fprintf(stderr,
|
fprintf(stderr,
|
||||||
"Usage: %s [reading.json]\n\n"
|
"Usage: %s [--format text|html|json] [--lang <code>] [--i18n-dir <path>]\n"
|
||||||
|
" [reading.json]\n\n"
|
||||||
"Reads a DailyReading in deck-engine's --format json shape - from the\n"
|
"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"
|
"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"
|
"prints a full report: the day's overall significance level, the\n"
|
||||||
"aspects driving it, each with a short narrative note.\n\n"
|
"top aspects driving it (each with a short narrative note), a full\n"
|
||||||
|
"readout of the Celtic Cross spread with every card's meaning, and\n"
|
||||||
|
"finally guidance tying the day's top transit to the spread's\n"
|
||||||
|
"Attitude/Outcome cards.\n\n"
|
||||||
|
" --format Output format, defaults to text. html links card art via\n"
|
||||||
|
" img/<file>, same convention as deck-engine's own html\n"
|
||||||
|
" output - save/run this next to the img/ directory\n"
|
||||||
|
" `make images` copies into dist/. json embeds the same\n"
|
||||||
|
" narrative/guidance prose as text (in whatever --lang was\n"
|
||||||
|
" loaded) alongside stable, language-independent slugs.\n"
|
||||||
|
" --lang Reading language code, defaults to en. Matches a file\n"
|
||||||
|
" named <code>.lang in --i18n-dir (see engine/i18n/) -\n"
|
||||||
|
" note this is this binary's OWN output language, not\n"
|
||||||
|
" related to whatever --lang deck-engine was run with to\n"
|
||||||
|
" produce its --format json input (which is always\n"
|
||||||
|
" language-independent - see docs/input-output-format.md).\n"
|
||||||
|
" --i18n-dir Directory to look up <lang>.lang in. Defaults to the\n"
|
||||||
|
" i18n/ directory next to this binary (`make i18n`\n"
|
||||||
|
" copies engine/i18n/ there as dist/i18n/).\n\n"
|
||||||
"Typical use:\n"
|
"Typical use:\n"
|
||||||
" dist/deck-engine ... --format json | %s\n"
|
" dist/deck-engine ... --format json | %s\n"
|
||||||
" dist/deck-engine ... --format json > reading.json && %s reading.json\n",
|
" dist/deck-engine ... --format json > reading.json && %s reading.json\n",
|
||||||
prog, prog, prog);
|
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) {
|
static char *read_all(FILE *f, size_t *out_length) {
|
||||||
size_t cap = 4096;
|
size_t cap = 4096;
|
||||||
size_t len = 0;
|
size_t len = 0;
|
||||||
@@ -39,63 +75,463 @@ static char *read_all(FILE *f, size_t *out_length) {
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Display names for the report - deliberately a small local table rather
|
static const char *ui(const char *key, const char *fallback) { return i18n_get(key, fallback); }
|
||||||
* than linking engine/src/astro.c's astro_body_name()/astro_aspect_name()
|
|
||||||
* (which would pull in the vendored Astronomy Engine just for cosmetic
|
/* Slugs for i18n key building (body.<slug>/sign.<slug>/aspect.<slug>,
|
||||||
* text). Same policy as reading_io.c's slug tables. */
|
* 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 *body_display_name(Body body) {
|
||||||
static const char *const names[NUM_BODIES] = {
|
static const char *const fallback[NUM_BODIES] = {
|
||||||
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
"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 *aspect_display_name(AspectType type) {
|
||||||
static const char *const names[5] = {
|
static const char *const fallback[5] = {
|
||||||
"Conjunction", "Sextile", "Square", "Trine", "Opposition",
|
"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 *sign_display_name(ZodiacSign sign) {
|
||||||
static const char *const names[12] = {
|
static const char *const fallback[12] = {
|
||||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
"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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "YYYY-MM-DD" for reading->utc_moment - the day this reading is for
|
||||||
|
* (see reading_load_json()/parse_date() in reading_io.c), not today's
|
||||||
|
* real date if the two ever differ (e.g. reading.json was generated
|
||||||
|
* earlier and interpreted later). */
|
||||||
|
static void format_date(time_t utc_moment, char *out, size_t out_size) {
|
||||||
|
struct tm *utc = gmtime(&utc_moment);
|
||||||
|
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/* 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
|
* 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. */
|
* 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;
|
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) {
|
/* Captures narrative_write()/guidance_write()'s output into a heap
|
||||||
switch (level) {
|
* string, for embedding into JSON - a stack buffer plus a single
|
||||||
case DAY_QUIET: return "Quiet";
|
* malloc()+memcpy() copy, since main.c doesn't need to stream this
|
||||||
case DAY_NOTABLE: return "Notable";
|
* anywhere. Strips a single trailing newline; caller must free() the
|
||||||
case DAY_SIGNIFICANT: return "Significant";
|
* result. */
|
||||||
case DAY_MAJOR: return "Major";
|
static char *capture_narrative(const Aspect *aspect, int house) {
|
||||||
|
char stack_buf[NARRATIVE_TEXT_MAX];
|
||||||
|
narrative_write(stack_buf, sizeof stack_buf, "", aspect, house);
|
||||||
|
size_t len = strlen(stack_buf);
|
||||||
|
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
|
||||||
|
char *buf = malloc(len + 1);
|
||||||
|
memcpy(buf, stack_buf, len + 1);
|
||||||
|
return buf;
|
||||||
}
|
}
|
||||||
return "Unknown";
|
|
||||||
|
static char *capture_guidance(const DailyInterpretation *interp, const CelticCrossSpread *spread) {
|
||||||
|
char stack_buf[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(stack_buf, sizeof stack_buf, "", interp, spread);
|
||||||
|
size_t len = strlen(stack_buf);
|
||||||
|
if (len > 0 && stack_buf[len - 1] == '\n') stack_buf[--len] = '\0';
|
||||||
|
char *buf = malloc(len + 1);
|
||||||
|
memcpy(buf, stack_buf, len + 1);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prints one significant-transit item's full detail (title line with
|
||||||
|
* sign/house/orb/score, plus its narrative sentence) - shared between
|
||||||
|
* the "Significant Transits" list and the day's top item, which is
|
||||||
|
* repeated in full just above the guidance paragraph (see
|
||||||
|
* interp_print_text) so the reader has that same detail in view right
|
||||||
|
* next to the guidance text it informs. `index` is 1-based and prefixes
|
||||||
|
* the line with "N. "; pass 0 to omit it, for the repeated top item,
|
||||||
|
* which isn't part of the numbered list. */
|
||||||
|
static void print_transit_item_text(FILE *out, const DailyReading *reading,
|
||||||
|
const SignificantItem *item, int index) {
|
||||||
|
const Aspect *a = &item->aspect;
|
||||||
|
if (index > 0) fprintf(out, " %d. ", index);
|
||||||
|
fprintf(out, "%s %s", 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);
|
||||||
|
|
||||||
|
char narrative_buf[NARRATIVE_TEXT_MAX];
|
||||||
|
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
|
||||||
|
reading->transits.bodies[a->transiting_planet].house);
|
||||||
|
fputs(narrative_buf, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== --format text ===== */
|
||||||
|
|
||||||
|
static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread, Gender gender) {
|
||||||
|
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, gender),
|
||||||
|
tarot_card_name(draw->card), reversed_marker(draw->reversed));
|
||||||
|
fprintf(out, " %s\n", tarot_position_description((CelticCrossPosition)i, gender));
|
||||||
|
fprintf(out, " %s\n", tarot_card_meaning(draw->card, draw->reversed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
print_transit_item_text(out, reading, &interp->top_items[0], 0);
|
||||||
|
}
|
||||||
|
char guidance_buf[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
|
||||||
|
fputs(guidance_buf, out);
|
||||||
|
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++) {
|
||||||
|
print_transit_item_text(out, reading, &interp->top_items[i], i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fprintf(out, "\n");
|
||||||
|
|
||||||
|
print_celtic_cross_text(out, &reading->spread, reading->gender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== --format html ===== */
|
||||||
|
|
||||||
|
/* HTML counterpart of print_transit_item_text() - same shared-between-
|
||||||
|
* the-repeated-top-item-and-the-full-list purpose, one table row (title
|
||||||
|
* + orb/score) plus a narrative row if non-empty. `index` is 1-based;
|
||||||
|
* pass 0 to omit the "N." cell, for the repeated top item. */
|
||||||
|
static void print_transit_item_html(FILE *out, const DailyReading *reading,
|
||||||
|
const SignificantItem *item, int index) {
|
||||||
|
const Aspect *a = &item->aspect;
|
||||||
|
char index_buf[16] = "";
|
||||||
|
if (index > 0) snprintf(index_buf, sizeof index_buf, "%d.", index);
|
||||||
|
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
|
||||||
|
|
||||||
|
fprintf(out, "<tr><td>%s</td><td>%s %s %s %s %s</td>"
|
||||||
|
"<td>%s %.1f°, %s %.2f</td></tr>\n",
|
||||||
|
index_buf, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
|
||||||
|
aspect_display_name(a->type), ui("ui.natal", "natal"), body_display_name(a->natal_planet),
|
||||||
|
ui("ui.orb", "orb"), a->orb, ui("interp.score", "score"), item->score);
|
||||||
|
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
|
||||||
|
free(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One Celtic Cross card as a ".card" div - shared between the
|
||||||
|
* Attitude/Outcome preview (right after guidance) and the full spread
|
||||||
|
* further down, so both render identically. */
|
||||||
|
static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw,
|
||||||
|
Gender gender) {
|
||||||
|
fprintf(out,
|
||||||
|
"<div class=\"card\">\n"
|
||||||
|
" <div class=\"position\">%s</div>\n"
|
||||||
|
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
|
||||||
|
" <div class=\"name\">%s%s</div>\n"
|
||||||
|
" <div class=\"desc\">%s</div>\n"
|
||||||
|
" <div class=\"meaning\">%s</div>\n"
|
||||||
|
"</div>\n",
|
||||||
|
tarot_position_name(position, gender), 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(position, gender),
|
||||||
|
tarot_card_meaning(draw->card, draw->reversed));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||||
|
fprintf(out,
|
||||||
|
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
|
||||||
|
"<title>%s</title>\n"
|
||||||
|
"<style>\n"
|
||||||
|
"body { font-family: sans-serif; max-width: 900px; margin: 2em auto; }\n"
|
||||||
|
"table { border-collapse: collapse; margin-bottom: 1.5em; }\n"
|
||||||
|
"td { padding: 2px 10px 2px 0; }\n"
|
||||||
|
"h1, h2 { border-bottom: 1px solid #ccc; }\n"
|
||||||
|
".spread { display: flex; flex-wrap: wrap; gap: 1.5em; }\n"
|
||||||
|
".card { width: 160px; }\n"
|
||||||
|
".card img { width: 140px; display: block; }\n"
|
||||||
|
".card img.reversed { transform: rotate(180deg); }\n"
|
||||||
|
".card .position { font-weight: bold; }\n"
|
||||||
|
".card .name { font-style: italic; }\n"
|
||||||
|
".guidance { background: #f6f6f6; padding: 1em; border-radius: 6px; }\n"
|
||||||
|
"</style></head><body>\n",
|
||||||
|
ui("interp.page_title", "Deck in a Dash - Interpretation"));
|
||||||
|
|
||||||
|
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
|
||||||
|
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, "<p class=\"reading-date\">%s %s</p>\n",
|
||||||
|
ui("interp.reading_date", "Reading for:"), date_buf);
|
||||||
|
|
||||||
|
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
|
||||||
|
ui("interp.heading_day_significance", "Day Significance"),
|
||||||
|
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
|
||||||
|
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||||
|
if (interpretation_deserves_framing(interp)) {
|
||||||
|
fprintf(out, "<br>%s", ui("interp.major_framing",
|
||||||
|
"(a major transit today - worth a deeper Celtic Cross look)"));
|
||||||
|
}
|
||||||
|
fprintf(out, "</p>\n");
|
||||||
|
|
||||||
|
char *guidance = capture_guidance(interp, &reading->spread);
|
||||||
|
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
|
||||||
|
|
||||||
|
fprintf(out, "<div class=\"spread attitude-outcome\">\n");
|
||||||
|
print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE], reading->gender);
|
||||||
|
print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME], reading->gender);
|
||||||
|
fprintf(out, "</div>\n");
|
||||||
|
|
||||||
|
fprintf(out, "<div class=\"guidance\">\n");
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
fprintf(out, "<table>\n");
|
||||||
|
print_transit_item_html(out, reading, &interp->top_items[0], 0);
|
||||||
|
fprintf(out, "</table>\n");
|
||||||
|
}
|
||||||
|
const char *start = guidance;
|
||||||
|
for (const char *p = guidance; ; p++) {
|
||||||
|
if (*p == '\n' || *p == '\0') {
|
||||||
|
if (p > start) fprintf(out, "<p>%.*s</p>\n", (int)(p - start), start);
|
||||||
|
if (*p == '\0') break;
|
||||||
|
start = p + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
free(guidance);
|
||||||
|
fprintf(out, "</div>\n");
|
||||||
|
|
||||||
|
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
|
||||||
|
if (interp->top_item_count == 0) {
|
||||||
|
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
|
||||||
|
} else {
|
||||||
|
fprintf(out, "<table>\n");
|
||||||
|
for (int i = 0; i < interp->top_item_count; i++) {
|
||||||
|
print_transit_item_html(out, reading, &interp->top_items[i], i + 1);
|
||||||
|
}
|
||||||
|
fprintf(out, "</table>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i], reading->gender);
|
||||||
|
}
|
||||||
|
fprintf(out, "</div>\n");
|
||||||
|
|
||||||
|
fprintf(out, "</body></html>\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== --format json ===== */
|
||||||
|
|
||||||
|
static void json_string(FILE *out, const char *s) {
|
||||||
|
fputc('"', out);
|
||||||
|
for (; *s; s++) {
|
||||||
|
unsigned char c = (unsigned char)*s;
|
||||||
|
switch (c) {
|
||||||
|
case '"': fputs("\\\"", out); break;
|
||||||
|
case '\\': fputs("\\\\", out); break;
|
||||||
|
case '\n': fputs("\\n", out); break;
|
||||||
|
case '\r': fputs("\\r", out); break;
|
||||||
|
case '\t': fputs("\\t", out); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) fprintf(out, "\\u%04x", c);
|
||||||
|
else fputc((char)c, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fputc('"', out);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
|
||||||
|
fprintf(out, "{\n");
|
||||||
|
|
||||||
|
char date_buf[32];
|
||||||
|
format_date(reading->utc_moment, date_buf, sizeof date_buf);
|
||||||
|
fprintf(out, " \"date\": \"%s\",\n", date_buf);
|
||||||
|
|
||||||
|
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) {
|
int main(int argc, char **argv) {
|
||||||
if (argc > 2 || (argc == 2 && (strcmp(argv[1], "--help") == 0 || strcmp(argv[1], "-h") == 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]);
|
print_usage(argv[0]);
|
||||||
return argc > 2 ? 1 : 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;
|
FILE *in = stdin;
|
||||||
bool opened_file = false;
|
bool opened_file = false;
|
||||||
if (argc == 2 && strcmp(argv[1], "-") != 0) {
|
if (input_path && strcmp(input_path, "-") != 0) {
|
||||||
in = fopen(argv[1], "r");
|
in = fopen(input_path, "r");
|
||||||
if (!in) {
|
if (!in) {
|
||||||
fprintf(stderr, "error: could not open %s\n", argv[1]);
|
fprintf(stderr, "error: could not open %s\n", input_path);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
opened_file = true;
|
opened_file = true;
|
||||||
@@ -116,30 +552,12 @@ int main(int argc, char **argv) {
|
|||||||
DailyInterpretation interp;
|
DailyInterpretation interp;
|
||||||
interpret_daily_reading(&reading, &interp);
|
interpret_daily_reading(&reading, &interp);
|
||||||
|
|
||||||
printf("Day significance: %s (%d/%d)\n", day_level_name(interp.day_level),
|
if (format == FORMAT_HTML) {
|
||||||
interp.day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
interp_print_html(stdout, &reading, &interp);
|
||||||
if (interpretation_deserves_framing(&interp)) {
|
} else if (format == FORMAT_JSON) {
|
||||||
printf("(a major transit today - worth a deeper Celtic Cross look)\n");
|
interp_print_json(stdout, &reading, &interp);
|
||||||
}
|
} else {
|
||||||
printf("\n");
|
interp_print_text(stdout, &reading, &interp);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,103 @@
|
|||||||
#include "narrative.h"
|
#include "narrative.h"
|
||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* i18n_get() only - narrative.h already pulls in astro.h; this is the
|
||||||
|
* one additional engine header needed to route k_narratives/
|
||||||
|
* k_house_area through the same translation catalog tarot_data.c uses
|
||||||
|
* (see the Makefile's INTERP_TAROT_TEXT_OBJS comment for why i18n.c is
|
||||||
|
* linked into this binary). */
|
||||||
|
#include "../../engine/src/i18n.h"
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
const char *slug; /* builds "narrative.<slug>.*" i18n keys */
|
||||||
const char *base; /* always relevant, may be "" (see the Sun) */
|
const char *base; /* always relevant, may be "" (see the Sun) */
|
||||||
const char *harmonious; /* shown only under a trine/sextile; may be "" */
|
const char *harmonious; /* shown only under a trine/sextile; may be "" */
|
||||||
const char *discordant; /* shown only under a square/opposition; may be "" */
|
const char *discordant; /* shown only under a square/opposition; may be "" */
|
||||||
} TransitNarrative;
|
} TransitNarrative;
|
||||||
|
|
||||||
/* See narrative.h's doc comment for sourcing (Sepharial, "Transits and
|
/* Fallback (English) text, also the source of truth when no translation
|
||||||
* Planetary Periods", 1920, Chapter VIII) and the Moon/Pluto exception. */
|
* catalog is loaded - see narrative.h's doc comment for sourcing
|
||||||
|
* (Sepharial, "Transits and Planetary Periods", 1920, Chapter VIII) and
|
||||||
|
* the Moon/Pluto exception. German translations live in
|
||||||
|
* engine/i18n/de.lang under the same "narrative.<slug>.*" keys. */
|
||||||
static const TransitNarrative k_narratives[NUM_BODIES] = {
|
static const TransitNarrative k_narratives[NUM_BODIES] = {
|
||||||
[PLANET_SUN] = {
|
[PLANET_SUN] = {
|
||||||
.base = "",
|
"sun", "",
|
||||||
.harmonious = "Benefits from superiors and advancement in your sphere of "
|
"Benefits from superiors and advancement in your sphere of "
|
||||||
"life and work - honours, emoluments, and successful new "
|
"life and work - honours, emoluments, and successful new "
|
||||||
"associations.",
|
"associations.",
|
||||||
.discordant = "Degradation and dishonour, loss of position, and adverse "
|
"Degradation and dishonour, loss of position, and adverse "
|
||||||
"judgement from superiors.",
|
"judgement from superiors.",
|
||||||
},
|
},
|
||||||
[PLANET_MOON] = {
|
[PLANET_MOON] = {
|
||||||
/* Original text, not from Sepharial - see narrative.h. */
|
/* Original text, not from Sepharial - see narrative.h. */
|
||||||
.base = "Colours the everyday and domestic sphere of life, often "
|
"moon",
|
||||||
|
"Colours the everyday and domestic sphere of life, often "
|
||||||
"coinciding with the opening of new avenues.",
|
"coinciding with the opening of new avenues.",
|
||||||
.harmonious = "These changes tend to be advantageous.",
|
"These changes tend to be advantageous.",
|
||||||
.discordant = "These changes tend to be adverse, with some indisposition "
|
"These changes tend to be adverse, with some indisposition "
|
||||||
"or domestic friction.",
|
"or domestic friction.",
|
||||||
},
|
},
|
||||||
[PLANET_MERCURY] = {
|
[PLANET_MERCURY] = {
|
||||||
.base = "Affects writings, journeys, commerce, and everyday activities - "
|
"mercury",
|
||||||
|
"Affects writings, journeys, commerce, and everyday activities - "
|
||||||
"a neutral messenger whose effect follows the nature of the "
|
"a neutral messenger whose effect follows the nature of the "
|
||||||
"aspect it makes.",
|
"aspect it makes.",
|
||||||
.harmonious = "",
|
"", "",
|
||||||
.discordant = "",
|
|
||||||
},
|
},
|
||||||
[PLANET_VENUS] = {
|
[PLANET_VENUS] = {
|
||||||
.base = "Brings domestic and social affairs to the fore - happiness, "
|
"venus",
|
||||||
|
"Brings domestic and social affairs to the fore - happiness, "
|
||||||
"comforts, and favours.",
|
"comforts, and favours.",
|
||||||
.harmonious = "Success in love affairs and artistic pursuits is likely.",
|
"Success in love affairs and artistic pursuits is likely.",
|
||||||
.discordant = "Grief and disappointment are more likely.",
|
"Grief and disappointment are more likely.",
|
||||||
},
|
},
|
||||||
[PLANET_MARS] = {
|
[PLANET_MARS] = {
|
||||||
.base = "A strenuous time of quarrels, contention, strife and anger, with "
|
"mars",
|
||||||
|
"A strenuous time of quarrels, contention, strife and anger, with "
|
||||||
"some risk of hurts or injuries depending on the sign it "
|
"some risk of hurts or injuries depending on the sign it "
|
||||||
"occupies.",
|
"occupies.",
|
||||||
.harmonious = "Can bring benefits from doctors, surgeons, or new projects "
|
"Can bring benefits from doctors, surgeons, or new projects "
|
||||||
"and enterprises.",
|
"and enterprises.",
|
||||||
.discordant = "",
|
"",
|
||||||
},
|
},
|
||||||
[PLANET_JUPITER] = {
|
[PLANET_JUPITER] = {
|
||||||
.base = "Brings increase and expansion - fullness of fortune and health, "
|
"jupiter",
|
||||||
|
"Brings increase and expansion - fullness of fortune and health, "
|
||||||
"and a generally fortunate time.",
|
"and a generally fortunate time.",
|
||||||
.harmonious = "",
|
"", "",
|
||||||
.discordant = "",
|
|
||||||
},
|
},
|
||||||
[PLANET_SATURN] = {
|
[PLANET_SATURN] = {
|
||||||
.base = "Brings depression, stagnation, hindrances and obstacles, and "
|
"saturn",
|
||||||
|
"Brings depression, stagnation, hindrances and obstacles, and "
|
||||||
"some deprivation of the usual benefits.",
|
"some deprivation of the usual benefits.",
|
||||||
.harmonious = "Favours from older connections and past associations are "
|
"Favours from older connections and past associations are "
|
||||||
"still possible.",
|
"still possible.",
|
||||||
.discordant = "",
|
"",
|
||||||
},
|
},
|
||||||
[PLANET_URANUS] = {
|
[PLANET_URANUS] = {
|
||||||
.base = "Brings separations, estrangements, sudden dislocations and "
|
"uranus",
|
||||||
|
"Brings separations, estrangements, sudden dislocations and "
|
||||||
"violent upsets.",
|
"violent upsets.",
|
||||||
.harmonious = "Success through official or civic channels, and "
|
"Success through official or civic channels, and "
|
||||||
"beneficial changes or appointments, are possible.",
|
"beneficial changes or appointments, are possible.",
|
||||||
.discordant = "",
|
"",
|
||||||
},
|
},
|
||||||
[PLANET_NEPTUNE] = {
|
[PLANET_NEPTUNE] = {
|
||||||
.base = "Brings a state of chaos and confusion - an involved, uncertain "
|
"neptune",
|
||||||
|
"Brings a state of chaos and confusion - an involved, uncertain "
|
||||||
"condition of affairs, with plots, subtlety, or unseen "
|
"condition of affairs, with plots, subtlety, or unseen "
|
||||||
"influences at work.",
|
"influences at work.",
|
||||||
.harmonious = "",
|
"", "",
|
||||||
.discordant = "",
|
|
||||||
},
|
},
|
||||||
[PLANET_PLUTO] = {
|
[PLANET_PLUTO] = {
|
||||||
/* Original text, not from Sepharial - see narrative.h. */
|
/* Original text, not from Sepharial - see narrative.h. */
|
||||||
.base = "Brings deep, often hidden transformation - the surfacing or "
|
"pluto",
|
||||||
|
"Brings deep, often hidden transformation - the surfacing or "
|
||||||
"dismantling of something that has outgrown its old form.",
|
"dismantling of something that has outgrown its old form.",
|
||||||
.harmonious = "",
|
"", "",
|
||||||
.discordant = "",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +113,8 @@ static bool aspect_is_discordant(AspectType type) {
|
|||||||
* house governs. This is centuries-old, uncredited astrological
|
* house governs. This is centuries-old, uncredited astrological
|
||||||
* convention (the same status as the sign/aspect names already baked
|
* convention (the same status as the sign/aspect names already baked
|
||||||
* into engine/src/astro.c), not text drawn from Sepharial or any other
|
* 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] = {
|
static const char *const k_house_area[12] = {
|
||||||
"your sense of self, identity, and outward appearance",
|
"your sense of self, identity, and outward appearance",
|
||||||
"money, possessions, and personal values",
|
"money, possessions, and personal values",
|
||||||
@@ -112,22 +130,53 @@ static const char *const k_house_area[12] = {
|
|||||||
"solitude, the subconscious, and hidden matters",
|
"solitude, the subconscious, and hidden matters",
|
||||||
};
|
};
|
||||||
|
|
||||||
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
|
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_write(char *buf, size_t buf_size, const char *indent, const Aspect *aspect,
|
||||||
int transiting_house) {
|
int transiting_house) {
|
||||||
|
if (buf_size == 0) return;
|
||||||
|
buf[0] = '\0';
|
||||||
|
|
||||||
const TransitNarrative *n = &k_narratives[aspect->transiting_planet];
|
const TransitNarrative *n = &k_narratives[aspect->transiting_planet];
|
||||||
|
const char *base = narrative_field(n->slug, "base", n->base);
|
||||||
|
|
||||||
const char *extra = "";
|
const char *extra = "";
|
||||||
if (aspect_is_harmonious(aspect->type)) extra = n->harmonious;
|
if (aspect_is_harmonious(aspect->type)) extra = narrative_field(n->slug, "harmonious", n->harmonious);
|
||||||
else if (aspect_is_discordant(aspect->type)) extra = n->discordant;
|
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;
|
||||||
|
|
||||||
|
/* Chained snprintf() calls, each bounds-checked against the running
|
||||||
|
* length before the next one runs - never fprintf/sprintf/vsnprintf,
|
||||||
|
* all of which Pebble's SDK blocks at compile time (see narrative.h's
|
||||||
|
* doc comment). Once `len` reaches buf_size (truncated), every
|
||||||
|
* subsequent call is skipped rather than computing buf_size - len,
|
||||||
|
* which would underflow. */
|
||||||
|
size_t len = (size_t)snprintf(buf, buf_size, "%s", indent);
|
||||||
|
if (len >= buf_size) return;
|
||||||
|
|
||||||
fprintf(out, "%s", indent);
|
|
||||||
if (transiting_house >= 1 && transiting_house <= 12) {
|
if (transiting_house >= 1 && transiting_house <= 12) {
|
||||||
fprintf(out, "In matters of %s (house %d). ",
|
char house_key[24];
|
||||||
k_house_area[transiting_house - 1], transiting_house);
|
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);
|
||||||
|
len += (size_t)snprintf(buf + len, buf_size - len, "%s ", frame);
|
||||||
|
if (len >= buf_size) return;
|
||||||
}
|
}
|
||||||
if (n->base[0] != '\0') fprintf(out, "%s", n->base);
|
if (base[0] != '\0') {
|
||||||
if (extra[0] != '\0') fprintf(out, "%s%s", n->base[0] != '\0' ? " " : "", extra);
|
len += (size_t)snprintf(buf + len, buf_size - len, "%s", base);
|
||||||
fprintf(out, "\n");
|
if (len >= buf_size) return;
|
||||||
|
}
|
||||||
|
if (extra[0] != '\0') {
|
||||||
|
len += (size_t)snprintf(buf + len, buf_size - len, "%s%s", base[0] != '\0' ? " " : "", extra);
|
||||||
|
if (len >= buf_size) return;
|
||||||
|
}
|
||||||
|
snprintf(buf + len, buf_size - len, "\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
#ifndef DECK_NARRATIVE_H
|
#ifndef DECK_NARRATIVE_H
|
||||||
#define DECK_NARRATIVE_H
|
#define DECK_NARRATIVE_H
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stddef.h>
|
||||||
|
|
||||||
/* Header-only dependency on the engine, same policy as significance.h -
|
/* Header-only dependency on the engine, same policy as significance.h -
|
||||||
* see its comment for why. */
|
* see its comment for why. */
|
||||||
#include "../../engine/src/astro.h"
|
#include "../../engine/src/astro.h"
|
||||||
|
|
||||||
|
/* Generous upper bound on narrative_write()'s output (indent + house
|
||||||
|
* framing + base + harmonious/discordant addendum + newline) across
|
||||||
|
* every language this project ships a translation for - callers on
|
||||||
|
* constrained platforms (the watch) are free to size their own stack
|
||||||
|
* buffer smaller if they know their own strings are shorter; this is
|
||||||
|
* just a safe default for a caller that doesn't want to think about it. */
|
||||||
|
#define NARRATIVE_TEXT_MAX 512
|
||||||
|
|
||||||
/* Condensed, public-domain descriptions of what each transiting planet
|
/* Condensed, public-domain descriptions of what each transiting planet
|
||||||
* classically signifies, sourced from Sepharial's "Transits and
|
* classically signifies, sourced from Sepharial's "Transits and
|
||||||
* Planetary Periods" (1920, public domain - res/Transits_and_Planetary_
|
* Planetary Periods" (1920, public domain - res/Transits_and_Planetary_
|
||||||
@@ -22,15 +30,19 @@
|
|||||||
* narrative.c is original, written for this project in a matching
|
* narrative.c is original, written for this project in a matching
|
||||||
* voice, not drawn from Sepharial.
|
* voice, not drawn from Sepharial.
|
||||||
*
|
*
|
||||||
* Prints a narrative sentence for `aspect`'s transiting planet to `out`,
|
* Writes a narrative sentence for `aspect`'s transiting planet into
|
||||||
* indented with `indent` and followed by a newline - or prints nothing
|
* `buf` (a caller-supplied buffer of `buf_size` bytes, always left
|
||||||
* if the source material has no text applicable to this planet/aspect
|
* null-terminated - NARRATIVE_TEXT_MAX is a safe size), indented with
|
||||||
|
* `indent` and followed by a newline - or writes an empty string if the
|
||||||
|
* source material has no text applicable to this planet/aspect
|
||||||
* combination (e.g. an exactly neutral conjunction to a planet whose
|
* combination (e.g. an exactly neutral conjunction to a planet whose
|
||||||
* only text is a "well/badly aspected" addendum, like the Sun). A
|
* only text is a "well/badly aspected" addendum, like the Sun). A
|
||||||
* conjunction is treated as neutral - neither the "harmonious" nor the
|
* conjunction is treated as neutral - neither the "harmonious" nor the
|
||||||
* "discordant" addendum is shown for it - since Sepharial's own text
|
* "discordant" addendum is shown for it - since Sepharial's own text
|
||||||
* says a conjunction "takes the nature of what it conjoins," something
|
* says a conjunction "takes the nature of what it conjoins," something
|
||||||
* this data doesn't model.
|
* this data doesn't model. Uses only snprintf() internally (never
|
||||||
|
* fprintf/sprintf/vsnprintf, all of which Pebble's SDK blocks at compile
|
||||||
|
* time), so this function links into the watch app unchanged.
|
||||||
*
|
*
|
||||||
* `transiting_house` is the whole-sign house (1-12) the transiting
|
* `transiting_house` is the whole-sign house (1-12) the transiting
|
||||||
* planet currently occupies in the natal chart - i.e.
|
* planet currently occupies in the natal chart - i.e.
|
||||||
@@ -39,8 +51,14 @@
|
|||||||
* personalized way transits are read (see astro.h's own house-field
|
* 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`
|
* 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
|
* 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.
|
||||||
void narrative_print(FILE *out, const char *indent, const Aspect *aspect,
|
*
|
||||||
|
* 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()/
|
||||||
|
* i18n_load_table() (main.c does so before calling this). */
|
||||||
|
void narrative_write(char *buf, size_t buf_size, const char *indent, const Aspect *aspect,
|
||||||
int transiting_house);
|
int transiting_house);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
#define _DEFAULT_SOURCE /* for timegm, parsing "date" back into utc_moment */
|
||||||
|
|
||||||
#include "reading_io.h"
|
#include "reading_io.h"
|
||||||
#include "json.h"
|
#include "json.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
|
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
|
||||||
@@ -22,6 +25,26 @@ static const char *const k_sign_slug[12] = {
|
|||||||
"libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
|
"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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Mirrors tarot_data.c's own k_gender_slug, same duplication policy as
|
||||||
|
* k_card_slug/k_position_slug above (no reverse lookup exposed there). */
|
||||||
|
static const char *const k_gender_slug[3] = {
|
||||||
|
"unspecified", "male", "female",
|
||||||
|
};
|
||||||
|
|
||||||
static bool body_from_slug(const char *slug, Body *out) {
|
static bool body_from_slug(const char *slug, Body *out) {
|
||||||
for (int i = 0; i < NUM_BODIES; i++) {
|
for (int i = 0; i < NUM_BODIES; i++) {
|
||||||
if (strcmp(slug, k_body_slug[i]) == 0) {
|
if (strcmp(slug, k_body_slug[i]) == 0) {
|
||||||
@@ -77,6 +100,89 @@ static bool aspect_type_from_slug(const char *slug, AspectType *out) {
|
|||||||
return false;
|
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 gender_from_slug(const char *slug, Gender *out) {
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
if (strcmp(slug, k_gender_slug[i]) == 0) {
|
||||||
|
*out = (Gender)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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parses the top-level "date" field ("YYYY-MM-DD", see reading_print_json
|
||||||
|
* in engine/src/reading.c) back into out->utc_moment, as midnight UTC of
|
||||||
|
* that date - reading_print_json only ever wrote the calendar date, not a
|
||||||
|
* time of day, so that's the only value that round-trips through
|
||||||
|
* gmtime() to the same date. Lenient like parse_bodies()/parse_spread():
|
||||||
|
* a missing or malformed "date" just leaves out->utc_moment at 0 (from
|
||||||
|
* reading_load_json's own memset), not a load failure - nothing here
|
||||||
|
* depends on it for scoring, only for display. */
|
||||||
|
static void parse_date(const JsonValue *date, DailyReading *out) {
|
||||||
|
const char *date_str = date ? json_as_string(date) : NULL;
|
||||||
|
if (!date_str) return;
|
||||||
|
|
||||||
|
struct tm tm_date = {0};
|
||||||
|
if (sscanf(date_str, "%d-%d-%d", &tm_date.tm_year, &tm_date.tm_mon, &tm_date.tm_mday) != 3) return;
|
||||||
|
tm_date.tm_year -= 1900;
|
||||||
|
tm_date.tm_mon -= 1;
|
||||||
|
out->utc_moment = timegm(&tm_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parses the top-level "gender" field (see reading_print_json in
|
||||||
|
* engine/src/reading.c) into out->gender. Lenient like parse_date(): a
|
||||||
|
* missing or unrecognized value just leaves out->gender at
|
||||||
|
* GENDER_UNSPECIFIED (0, from reading_load_json's own memset), not a
|
||||||
|
* load failure - it only affects Celtic Cross position text pronouns,
|
||||||
|
* nothing scoring depends on it. */
|
||||||
|
static void parse_gender(const JsonValue *gender, DailyReading *out) {
|
||||||
|
const char *gender_str = gender ? json_as_string(gender) : NULL;
|
||||||
|
if (!gender_str) return;
|
||||||
|
gender_from_slug(gender_str, &out->gender);
|
||||||
|
}
|
||||||
|
|
||||||
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
||||||
memset(out, 0, sizeof(*out));
|
memset(out, 0, sizeof(*out));
|
||||||
|
|
||||||
@@ -118,10 +224,16 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
|
|||||||
}
|
}
|
||||||
out->transits.aspect_count = filled;
|
out->transits.aspect_count = filled;
|
||||||
|
|
||||||
|
parse_date(json_object_get(root, "date"), out);
|
||||||
|
parse_gender(json_object_get(root, "gender"), out);
|
||||||
|
|
||||||
const JsonValue *natal = json_object_get(root, "natal");
|
const JsonValue *natal = json_object_get(root, "natal");
|
||||||
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
|
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
|
||||||
parse_bodies(json_object_get(transits, "bodies"), out->transits.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);
|
json_free(root);
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,26 @@
|
|||||||
|
|
||||||
/* Parses deck-engine's `--format json` output (text, null-terminated at
|
/* Parses deck-engine's `--format json` output (text, null-terminated at
|
||||||
* text[length]) and fills *out with just enough of a DailyReading for
|
* text[length]) and fills *out with just enough of a DailyReading for
|
||||||
* interpret_daily_reading() to work on and for reporting sign/house
|
* interpret_daily_reading() to work on, for reporting sign/house context
|
||||||
* context around each significant event: out->transits.aspects[]/
|
* around each significant event, and for guidance_print()'s tarot
|
||||||
* aspect_count (used for scoring - see significance.c), and
|
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
|
||||||
* out->natal.bodies[]/out->transits.bodies[] (sign + house per body,
|
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
|
||||||
* used only for display). out->natal.ascendant_longitude/houses[] and
|
* house per body, used only for display), out->spread.positions[]
|
||||||
* out->spread are left zeroed - nothing reads them yet.
|
* (card + reversed per Celtic Cross position, used only by
|
||||||
|
* guidance.c), out->utc_moment (the top-level "date" field, parsed
|
||||||
|
* back to midnight UTC of that date - used only for display, e.g. main.c
|
||||||
|
* printing which day a reading is for), and out->gender (the top-level
|
||||||
|
* "gender" field - used only for the Celtic Cross position text's
|
||||||
|
* pronouns, e.g. tarot_position_name()/tarot_position_description()).
|
||||||
|
* out->natal.ascendant_longitude/houses[] are left zeroed - nothing
|
||||||
|
* reads them yet.
|
||||||
*
|
*
|
||||||
* Returns false on malformed JSON, or if "transits"."aspects" isn't
|
* 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
|
* present as an array (an empty array is fine - that's a real "no
|
||||||
* aspects today" reading, not an error). A missing/unrecognized entry
|
* aspects today" reading, not an error). A missing/unrecognized entry in
|
||||||
* in "natal"."bodies"/"transits"."bodies" is not an error - see
|
* "natal"."bodies"/"transits"."bodies"/"spread"."positions", or a
|
||||||
* parse_bodies() in reading_io.c. */
|
* 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);
|
bool reading_load_json(const char *text, size_t length, DailyReading *out);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "../../engine/src/i18n.h"
|
||||||
|
#include "../src/guidance.h"
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
assert(strstr(text, "clearly active today") != NULL);
|
||||||
|
assert(strstr(text, "Venus") != NULL);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
assert(strstr(text, "mild touch from Mars") != NULL);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
assert(strstr(text, "only faintly active today") != NULL);
|
||||||
|
assert(strstr(text, "Mercury") != NULL);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
assert(strstr(text, "quietly re-settle your own footing") != NULL);
|
||||||
|
|
||||||
|
printf("PASS test_no_aspects_at_all_respects_reversed_attitude\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Proves guidance_write() 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);
|
||||||
|
char text[GUIDANCE_TEXT_MAX];
|
||||||
|
guidance_write(text, sizeof text, "", &interp, &spread);
|
||||||
|
|
||||||
|
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 */
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
#include "../src/json.h"
|
#include "../src/json.h"
|
||||||
#include "../src/reading_io.h"
|
#include "../src/reading_io.h"
|
||||||
@@ -41,13 +42,15 @@ static void test_json_parse_rejects_malformed(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* A trimmed-but-structurally-faithful fixture matching deck-engine's real
|
/* A trimmed-but-structurally-faithful fixture matching deck-engine's real
|
||||||
* --format json shape: "spread" is present with decoy content the loader
|
* --format json shape. "natal"/"transits" bodies are real (if partial) -
|
||||||
* must skip over without understanding its schema, to prove it navigates
|
* reading_load_json parses sign/house from them for display, alongside
|
||||||
* by key path rather than assuming any position. "natal"/"transits"
|
* the aspects used for scoring. "spread" only fills in the Attitude and
|
||||||
* bodies are real (if partial) - reading_load_json now parses sign/house
|
* Outcome positions (the two guidance.c reads); the other eight are
|
||||||
* from them for display, alongside the aspects used for scoring. */
|
* absent entirely, to prove partial spread data doesn't fail the load. */
|
||||||
static const char *k_fixture =
|
static const char *k_fixture =
|
||||||
"{"
|
"{"
|
||||||
|
" \"date\": \"2026-07-16\","
|
||||||
|
" \"gender\": \"female\","
|
||||||
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
|
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
|
||||||
" \"transits\": {"
|
" \"transits\": {"
|
||||||
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
|
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
|
||||||
@@ -57,7 +60,10 @@ static const char *k_fixture =
|
|||||||
" {\"transiting_planet\": \"saturn\", \"natal_planet\": \"sun\", \"type\": \"square\", \"orb\": 0.5}"
|
" {\"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) {
|
static void test_reading_load_json_extracts_aspects(void) {
|
||||||
@@ -77,6 +83,62 @@ static void test_reading_load_json_extracts_aspects(void) {
|
|||||||
printf("PASS test_reading_load_json_extracts_aspects\n");
|
printf("PASS test_reading_load_json_extracts_aspects\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_extracts_date(void) {
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
struct tm *utc = gmtime(&reading.utc_moment);
|
||||||
|
assert(utc->tm_year + 1900 == 2026);
|
||||||
|
assert(utc->tm_mon + 1 == 7);
|
||||||
|
assert(utc->tm_mday == 16);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_extracts_date\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_missing_date_defaults_to_zero(void) {
|
||||||
|
const char *text = "{\"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.utc_moment == 0);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_missing_date_defaults_to_zero\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_extracts_gender(void) {
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.gender == GENDER_FEMALE);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_extracts_gender\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_missing_gender_defaults_to_unspecified(void) {
|
||||||
|
const char *text = "{\"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.gender == GENDER_UNSPECIFIED);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_missing_gender_defaults_to_unspecified\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_reading_load_json_unrecognized_gender_defaults_to_unspecified(void) {
|
||||||
|
const char *text = "{\"gender\": \"xenu\", \"transits\": {\"aspects\": []}}";
|
||||||
|
DailyReading reading;
|
||||||
|
bool ok = reading_load_json(text, strlen(text), &reading);
|
||||||
|
|
||||||
|
assert(ok);
|
||||||
|
assert(reading.gender == GENDER_UNSPECIFIED);
|
||||||
|
|
||||||
|
printf("PASS test_reading_load_json_unrecognized_gender_defaults_to_unspecified\n");
|
||||||
|
}
|
||||||
|
|
||||||
static void test_reading_load_json_extracts_body_sign_and_house(void) {
|
static void test_reading_load_json_extracts_body_sign_and_house(void) {
|
||||||
DailyReading reading;
|
DailyReading reading;
|
||||||
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
|
||||||
@@ -95,6 +157,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");
|
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) {
|
static void test_reading_load_json_unknown_body_slug_is_skipped_not_fatal(void) {
|
||||||
const char *text =
|
const char *text =
|
||||||
"{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]},"
|
"{\"natal\": {\"bodies\": [{\"body\": \"xenu\", \"sign\": \"taurus\", \"house\": 3}]},"
|
||||||
@@ -146,7 +237,14 @@ int main(void) {
|
|||||||
test_json_parse_basic_shapes();
|
test_json_parse_basic_shapes();
|
||||||
test_json_parse_rejects_malformed();
|
test_json_parse_rejects_malformed();
|
||||||
test_reading_load_json_extracts_aspects();
|
test_reading_load_json_extracts_aspects();
|
||||||
|
test_reading_load_json_extracts_date();
|
||||||
|
test_reading_load_json_missing_date_defaults_to_zero();
|
||||||
|
test_reading_load_json_extracts_gender();
|
||||||
|
test_reading_load_json_missing_gender_defaults_to_unspecified();
|
||||||
|
test_reading_load_json_unrecognized_gender_defaults_to_unspecified();
|
||||||
test_reading_load_json_extracts_body_sign_and_house();
|
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_unknown_body_slug_is_skipped_not_fatal();
|
||||||
test_reading_load_json_empty_aspects_is_valid();
|
test_reading_load_json_empty_aspects_is_valid();
|
||||||
test_reading_load_json_rejects_missing_transits();
|
test_reading_load_json_rejects_missing_transits();
|
||||||
|
|||||||
@@ -1,117 +1,128 @@
|
|||||||
|
#define _POSIX_C_SOURCE 200809L /* for mkstemp/fdopen */
|
||||||
|
|
||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include "../../engine/src/i18n.h"
|
||||||
#include "../src/narrative.h"
|
#include "../src/narrative.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 void test_hard_aspect_shows_base_but_not_harmonious_bonus(void) {
|
static void test_hard_aspect_shows_base_but_not_harmonious_bonus(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
||||||
.type = ASPECT_SQUARE, .orb = 1.0 };
|
.type = ASPECT_SQUARE, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 0);
|
narrative_write(text, sizeof text, " ", &a, 0);
|
||||||
const char *text = slurp(f);
|
|
||||||
|
|
||||||
assert(strstr(text, "depression") != NULL);
|
assert(strstr(text, "depression") != NULL);
|
||||||
assert(strstr(text, "past associations") == NULL);
|
assert(strstr(text, "past associations") == NULL);
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n");
|
printf("PASS test_hard_aspect_shows_base_but_not_harmonious_bonus\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_soft_aspect_adds_harmonious_bonus(void) {
|
static void test_soft_aspect_adds_harmonious_bonus(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
|
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
|
||||||
.type = ASPECT_TRINE, .orb = 1.0 };
|
.type = ASPECT_TRINE, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 0);
|
narrative_write(text, sizeof text, " ", &a, 0);
|
||||||
const char *text = slurp(f);
|
|
||||||
|
|
||||||
assert(strstr(text, "depression") != NULL);
|
assert(strstr(text, "depression") != NULL);
|
||||||
assert(strstr(text, "past associations") != NULL);
|
assert(strstr(text, "past associations") != NULL);
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_soft_aspect_adds_harmonious_bonus\n");
|
printf("PASS test_soft_aspect_adds_harmonious_bonus\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_conjunction_is_neutral_prints_base_only(void) {
|
static void test_conjunction_is_neutral_prints_base_only(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
|
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_SUN,
|
||||||
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
|
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 0);
|
narrative_write(text, sizeof text, " ", &a, 0);
|
||||||
const char *text = slurp(f);
|
|
||||||
|
|
||||||
assert(strstr(text, "depression") != NULL);
|
assert(strstr(text, "depression") != NULL);
|
||||||
assert(strstr(text, "past associations") == NULL);
|
assert(strstr(text, "past associations") == NULL);
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_conjunction_is_neutral_prints_base_only\n");
|
printf("PASS test_conjunction_is_neutral_prints_base_only\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Sun has no unconditional base text (see narrative.c) - a neutral
|
/* The Sun has no unconditional base text (see narrative.c) - a neutral
|
||||||
* conjunction to it should print nothing at all. */
|
* conjunction to it should write nothing at all. */
|
||||||
static void test_empty_base_and_neutral_aspect_prints_nothing(void) {
|
static void test_empty_base_and_neutral_aspect_prints_nothing(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
|
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
|
||||||
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
|
.type = ASPECT_CONJUNCTION, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 0);
|
narrative_write(text, sizeof text, " ", &a, 0);
|
||||||
long len = ftell(f);
|
|
||||||
|
|
||||||
assert(len == 0);
|
assert(text[0] == '\0');
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n");
|
printf("PASS test_empty_base_and_neutral_aspect_prints_nothing\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_discordant_aspect_on_empty_base_planet(void) {
|
static void test_discordant_aspect_on_empty_base_planet(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
|
Aspect a = { .transiting_planet = PLANET_SUN, .natal_planet = PLANET_MOON,
|
||||||
.type = ASPECT_OPPOSITION, .orb = 1.0 };
|
.type = ASPECT_OPPOSITION, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 0);
|
narrative_write(text, sizeof text, " ", &a, 0);
|
||||||
const char *text = slurp(f);
|
|
||||||
|
|
||||||
assert(strstr(text, "Degradation") != NULL);
|
assert(strstr(text, "Degradation") != NULL);
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_discordant_aspect_on_empty_base_planet\n");
|
printf("PASS test_discordant_aspect_on_empty_base_planet\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_known_house_adds_area_framing(void) {
|
static void test_known_house_adds_area_framing(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
||||||
.type = ASPECT_SQUARE, .orb = 1.0 };
|
.type = ASPECT_SQUARE, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 3);
|
narrative_write(text, sizeof text, " ", &a, 3);
|
||||||
const char *text = slurp(f);
|
|
||||||
|
|
||||||
assert(strstr(text, "house 3") != NULL);
|
assert(strstr(text, "house 3") != NULL);
|
||||||
assert(strstr(text, "communication") != NULL);
|
assert(strstr(text, "communication") != NULL);
|
||||||
assert(strstr(text, "depression") != NULL);
|
assert(strstr(text, "depression") != NULL);
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_known_house_adds_area_framing\n");
|
printf("PASS test_known_house_adds_area_framing\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_unknown_house_omits_area_framing(void) {
|
static void test_unknown_house_omits_area_framing(void) {
|
||||||
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
Aspect a = { .transiting_planet = PLANET_SATURN, .natal_planet = PLANET_MOON,
|
||||||
.type = ASPECT_SQUARE, .orb = 1.0 };
|
.type = ASPECT_SQUARE, .orb = 1.0 };
|
||||||
FILE *f = tmpfile();
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
narrative_print(f, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
|
narrative_write(text, sizeof text, " ", &a, 0); /* 0 = "no data" sentinel, not a real house */
|
||||||
const char *text = slurp(f);
|
|
||||||
|
|
||||||
assert(strstr(text, "house") == NULL);
|
assert(strstr(text, "house") == NULL);
|
||||||
assert(strstr(text, "depression") != NULL);
|
assert(strstr(text, "depression") != NULL);
|
||||||
|
|
||||||
fclose(f);
|
|
||||||
printf("PASS test_unknown_house_omits_area_framing\n");
|
printf("PASS test_unknown_house_omits_area_framing\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Proves narrative_write() 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 };
|
||||||
|
char text[NARRATIVE_TEXT_MAX];
|
||||||
|
narrative_write(text, sizeof text, "", &a, 3);
|
||||||
|
|
||||||
|
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 */
|
||||||
|
|
||||||
|
printf("PASS test_translation_catalog_overrides_fallback_text\n");
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_hard_aspect_shows_base_but_not_harmonious_bonus();
|
test_hard_aspect_shows_base_but_not_harmonious_bonus();
|
||||||
test_soft_aspect_adds_harmonious_bonus();
|
test_soft_aspect_adds_harmonious_bonus();
|
||||||
@@ -120,6 +131,7 @@ int main(void) {
|
|||||||
test_discordant_aspect_on_empty_base_planet();
|
test_discordant_aspect_on_empty_base_planet();
|
||||||
test_known_house_adds_area_framing();
|
test_known_house_adds_area_framing();
|
||||||
test_unknown_house_omits_area_framing();
|
test_unknown_house_omits_area_framing();
|
||||||
|
test_translation_catalog_overrides_fallback_text();
|
||||||
printf("All narrative tests passed.\n");
|
printf("All narrative tests passed.\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to registry.env (gitignored, never commit the real values) and
|
||||||
|
# fill in your cr.ladkau.de credentials. Used by build-image.sh,
|
||||||
|
# run-image.sh, and upload-image.sh.
|
||||||
|
REGISTRY=cr.ladkau.de
|
||||||
|
REGISTRY_IMAGE=cr.ladkau.de/deck-in-a-dash/builder
|
||||||
|
REGISTRY_USER=
|
||||||
|
REGISTRY_PASSWORD=
|
||||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Runs `make test package watchapp` inside the local build-image
|
||||||
|
# container, mirroring what the Gitea Actions build workflow does -
|
||||||
|
# useful for testing build-image changes locally before pushing
|
||||||
|
# anything to cr.ladkau.de.
|
||||||
|
#
|
||||||
|
# Usage: ./run-image.sh [VERSION]
|
||||||
|
# VERSION is passed through to `make` (see the Makefile's `package`/
|
||||||
|
# `watchapp` targets); omit it for their own git-tag-based default.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/registry.env" ] \
|
||||||
|
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||||
|
|
||||||
|
IMAGE_TAG="$(<"$ROOT/build-image/VERSION")"
|
||||||
|
[ -n "$IMAGE_TAG" ] || fail "build-image/VERSION is empty"
|
||||||
|
IMAGE="$REGISTRY_IMAGE:$IMAGE_TAG"
|
||||||
|
|
||||||
|
docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||||
|
|| fail "$IMAGE not found locally — run ./build-image.sh first"
|
||||||
|
|
||||||
|
VERSION="${1:-${VERSION:-}}"
|
||||||
|
|
||||||
|
# CI always starts from a fresh checkout, but this script bind-mounts the
|
||||||
|
# live host repo — so build output left over from a previous local run
|
||||||
|
# (build/, dist/, watch/build, and the generated watch resources below)
|
||||||
|
# would make this run skip work a real fresh checkout wouldn't, silently
|
||||||
|
# hiding bugs that only show up in CI. Wipe them first so every run
|
||||||
|
# exercises a true from-scratch build, same as CI.
|
||||||
|
echo "== Cleaning generated build artifacts for a fresh build =="
|
||||||
|
rm -rf \
|
||||||
|
"$ROOT/build" "$ROOT/dist" "$ROOT/watch/build" \
|
||||||
|
"$ROOT/watch/resources/img" "$ROOT/watch/resources/app_icon.png" \
|
||||||
|
"$ROOT/watch/src/c/i18n_tables.auto.c"
|
||||||
|
|
||||||
|
echo "== Running make test package watchapp inside $IMAGE =="
|
||||||
|
# The container runs as root (needed for the toolchain baked into the
|
||||||
|
# image), so anything it writes into this bind mount — dist/, build/,
|
||||||
|
# watch/build, etc. — would otherwise come back owned by root, leaving
|
||||||
|
# the host repo unusable without sudo. Chown everything back to the host
|
||||||
|
# user on exit, whether the build succeeds or fails.
|
||||||
|
docker run --rm \
|
||||||
|
-v "$ROOT:/workspace" \
|
||||||
|
-w /workspace \
|
||||||
|
-e VERSION="$VERSION" \
|
||||||
|
-e HOST_UID="$(id -u)" \
|
||||||
|
-e HOST_GID="$(id -g)" \
|
||||||
|
"$IMAGE" \
|
||||||
|
bash -c '
|
||||||
|
git config --global --add safe.directory /workspace
|
||||||
|
trap "chown -R \"$HOST_UID:$HOST_GID\" /workspace" EXIT
|
||||||
|
make test package watchapp
|
||||||
|
'
|
||||||
|
|
||||||
|
echo "== Done — artifacts in dist/ =="
|
||||||
|
ls -la "$ROOT/dist"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pushes the build environment image — already built locally with
|
||||||
|
# ./build-image.sh, and ideally verified with ./run-image.sh — to
|
||||||
|
# cr.ladkau.de. The Gitea Actions build workflow pulls this image to run
|
||||||
|
# `make test package watchapp`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
|
||||||
|
fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 \
|
||||||
|
|| fail "docker not found in PATH"
|
||||||
|
|
||||||
|
[ -f "$ROOT/registry.env" ] \
|
||||||
|
|| fail "registry.env not found — copy registry.env.example to registry.env and fill in your cr.ladkau.de credentials"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$ROOT/registry.env"
|
||||||
|
|
||||||
|
: "${REGISTRY:?registry.env must set REGISTRY}"
|
||||||
|
: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}"
|
||||||
|
: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}"
|
||||||
|
: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}"
|
||||||
|
|
||||||
|
VERSION="$(<"$ROOT/build-image/VERSION")"
|
||||||
|
[ -n "$VERSION" ] || fail "build-image/VERSION is empty"
|
||||||
|
|
||||||
|
docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \
|
||||||
|
|| fail "$REGISTRY_IMAGE:$VERSION not found locally — run ./build-image.sh first"
|
||||||
|
|
||||||
|
echo "== Logging in to $REGISTRY =="
|
||||||
|
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
|
||||||
|
|
||||||
|
echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest =="
|
||||||
|
docker push "$REGISTRY_IMAGE:$VERSION"
|
||||||
|
docker push "$REGISTRY_IMAGE:latest"
|
||||||
|
|
||||||
|
echo "== Done =="
|
||||||
|
echo "Image: $REGISTRY_IMAGE:$VERSION"
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Deck in a Dash - Pebble watch app
|
||||||
|
|
||||||
|
The actual watchapp: shows the same daily tarot + astrology reading as
|
||||||
|
`dist/run-interpreter.sh`, computed entirely on-device (no companion
|
||||||
|
app, no server - birth data, language, and notification settings are
|
||||||
|
entered via a Pebble config page and persisted on-watch).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
watch/
|
||||||
|
package.json Pebble app manifest (uuid, targetPlatforms, messageKeys, resources)
|
||||||
|
wscript waf build rules - see its own top-of-file comment for
|
||||||
|
why this app's C sources are NOT a plain src/c/**/*.c
|
||||||
|
glob (most of the actual logic lives in ../engine/src/,
|
||||||
|
shared byte-for-byte with the desktop deck-engine/
|
||||||
|
interpreter-cli binaries)
|
||||||
|
src/c/ Watch-only C: UI, persisted config, on-device reading
|
||||||
|
orchestration, daily notification wakeup
|
||||||
|
src/pkjs/ pkjs config webview (birth data/language/notification
|
||||||
|
settings) - a self-contained `data:` URI form, no
|
||||||
|
server
|
||||||
|
resources/ Bitmap resources (card art, app icon) - resources/img/
|
||||||
|
is generated (gitignored), see below
|
||||||
|
scripts/ Build-time codegen: gen_i18n_tables.py (translation
|
||||||
|
tables from ../engine/i18n/*.lang) and
|
||||||
|
gen_card_images.py (downsized card art from
|
||||||
|
../res/img/*.jpeg) - both run automatically by
|
||||||
|
wscript on every `pebble build` (see its own comment
|
||||||
|
for why that stays cheap on repeat builds)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires the Pebble SDK/tool (`pebble` CLI) and Pillow (`pip install
|
||||||
|
Pillow`, used only by gen_card_images.py) - neither is part of this
|
||||||
|
repo's own `make`/`make test` toolchain, which only builds the desktop
|
||||||
|
`deck-engine`/`interpreter-cli` binaries. From this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pebble build # -> build/watch.pbw
|
||||||
|
```
|
||||||
|
|
||||||
|
The root Makefile's `make watchapp` target wraps this and copies the
|
||||||
|
resulting `.pbw` into `dist/`, versioned the same way as `make package` -
|
||||||
|
see the root CLAUDE.md's "Packaging" section.
|
||||||
|
|
||||||
|
## Platform support
|
||||||
|
|
||||||
|
`targetPlatforms` deliberately excludes **aplite** (original Pebble /
|
||||||
|
Pebble Steel). A Pebble app's entire code+data+bss footprint shares one
|
||||||
|
64KB region on basalt-class platforms, but only 24KB on aplite - and
|
||||||
|
even this project's minimal skeleton (blank window + one
|
||||||
|
`reading_generate()` call, no UI/config/persistence/notification code
|
||||||
|
yet) already uses 19KB of it, leaving only ~5.5KB of headroom. That's
|
||||||
|
not enough room for the multi-screen UI, on-watch config persistence,
|
||||||
|
and wakeup-based daily notification this app still needs to add -
|
||||||
|
basalt and newer (chalk/diorite/emery/flint/gabbro) all have comfortable
|
||||||
|
46-112KB of free heap for the same skeleton.
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
{
|
||||||
|
"name": "deck_in_a_dash_watch",
|
||||||
|
"author": "Matthias Ladkau",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"keywords": [
|
||||||
|
"pebble-app"
|
||||||
|
],
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {},
|
||||||
|
"pebble": {
|
||||||
|
"displayName": "Deck in a Dash",
|
||||||
|
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
|
||||||
|
"sdkVersion": "3",
|
||||||
|
"enableMultiJS": true,
|
||||||
|
"capabilities": [
|
||||||
|
"configurable"
|
||||||
|
],
|
||||||
|
"targetPlatforms": [
|
||||||
|
"basalt",
|
||||||
|
"chalk",
|
||||||
|
"diorite",
|
||||||
|
"emery",
|
||||||
|
"flint",
|
||||||
|
"gabbro"
|
||||||
|
],
|
||||||
|
"watchapp": {
|
||||||
|
"watchface": false
|
||||||
|
},
|
||||||
|
"messageKeys": [
|
||||||
|
"BIRTH_YEAR",
|
||||||
|
"BIRTH_MONTH",
|
||||||
|
"BIRTH_DAY",
|
||||||
|
"BIRTH_HOUR",
|
||||||
|
"BIRTH_MINUTE",
|
||||||
|
"BIRTH_UTC_OFFSET_MINUTES",
|
||||||
|
"BIRTH_LAT_MICRODEG",
|
||||||
|
"BIRTH_LON_MICRODEG",
|
||||||
|
"GENDER",
|
||||||
|
"LANG",
|
||||||
|
"NOTIFY_ENABLED",
|
||||||
|
"NOTIFY_HOUR",
|
||||||
|
"NOTIFY_MINUTE"
|
||||||
|
],
|
||||||
|
"resources": {
|
||||||
|
"media": [
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMAGE_APP_ICON",
|
||||||
|
"file": "app_icon.png",
|
||||||
|
"menuIcon": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_FOOL",
|
||||||
|
"file": "img/fool.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_MAGICIAN",
|
||||||
|
"file": "img/magician.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HIGH_PRIESTESS",
|
||||||
|
"file": "img/high_priestess.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_EMPRESS",
|
||||||
|
"file": "img/empress.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_EMPEROR",
|
||||||
|
"file": "img/emperor.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HIEROPHANT",
|
||||||
|
"file": "img/hierophant.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_LOVERS",
|
||||||
|
"file": "img/lovers.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_CHARIOT",
|
||||||
|
"file": "img/chariot.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_STRENGTH",
|
||||||
|
"file": "img/strength.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HERMIT",
|
||||||
|
"file": "img/hermit.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_WHEEL_OF_FORTUNE",
|
||||||
|
"file": "img/wheel_of_fortune.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_JUSTICE",
|
||||||
|
"file": "img/justice.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_HANGED_MAN",
|
||||||
|
"file": "img/hanged_man.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_DEATH",
|
||||||
|
"file": "img/death.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_TEMPERANCE",
|
||||||
|
"file": "img/temperance.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_DEVIL",
|
||||||
|
"file": "img/devil.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_TOWER",
|
||||||
|
"file": "img/tower.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_STAR",
|
||||||
|
"file": "img/star.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_MOON",
|
||||||
|
"file": "img/moon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_SUN",
|
||||||
|
"file": "img/sun.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_JUDGEMENT",
|
||||||
|
"file": "img/judgement.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bitmap",
|
||||||
|
"name": "IMG_CARD_WORLD",
|
||||||
|
"file": "img/world.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downsizes res/app_icon_50x50_64_color.png into watch/resources/app_icon.png.
|
||||||
|
|
||||||
|
Pebble's own build tooling (process_sdk_resources.py) hard-fails any
|
||||||
|
"menuIcon": true resource larger than 25x25px - the fixed size Pebble OS
|
||||||
|
renders app icons at in the watch's own app launcher list (and, synced
|
||||||
|
from the watch, in the mobile app's installed-apps list too). The source
|
||||||
|
art in res/ is 50x50 (square, so it downsamples to exactly 25x25 with no
|
||||||
|
letterboxing) - the color variant is used, per the same "prefer color"
|
||||||
|
choice as the rest of this app's card art, over the companion
|
||||||
|
*_gray.png fallback also present in res/.
|
||||||
|
|
||||||
|
Generated output, like i18n_tables.auto.c and resources/img/*.png - not
|
||||||
|
committed (see .gitignore), regenerate by running this script directly
|
||||||
|
or via `pebble build` (wired into wscript alongside gen_card_images.py).
|
||||||
|
Requires Pillow (`pip install Pillow`), not part of this repo's own
|
||||||
|
desktop build/test toolchain.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SRC_PATH = os.path.normpath(
|
||||||
|
os.path.join(SCRIPT_DIR, "..", "..", "res", "app_icon_50x50_64_color.png")
|
||||||
|
)
|
||||||
|
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "app_icon.png"))
|
||||||
|
|
||||||
|
MAX_ICON_SIZE = 25 # Pebble's own hard cap for "menuIcon": true resources.
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.path.exists(OUT_PATH) and os.path.getmtime(OUT_PATH) >= os.path.getmtime(SRC_PATH):
|
||||||
|
print(f"{OUT_PATH} already up to date", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
|
||||||
|
with Image.open(SRC_PATH) as img:
|
||||||
|
ratio = MAX_ICON_SIZE / max(img.width, img.height)
|
||||||
|
size = (round(img.width * ratio), round(img.height * ratio))
|
||||||
|
resized = img.convert("RGBA").resize(size, Image.LANCZOS)
|
||||||
|
resized.save(OUT_PATH, "PNG", optimize=True)
|
||||||
|
|
||||||
|
print(f"wrote {OUT_PATH} ({size[0]}x{size[1]})", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Downsizes res/img/*.jpeg into watch/resources/img/*.png for Pebble.
|
||||||
|
|
||||||
|
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
|
||||||
|
suitable for the desktop HTML reading (an <img> tag pointed at the file
|
||||||
|
as-is) but far too large for a Pebble app's own resource budget. This
|
||||||
|
script resizes each to CARD_WIDTH px wide (preserving aspect ratio) and
|
||||||
|
writes a PNG per card, named after tarot_data.c's own k_card_slug table
|
||||||
|
so watch/package.json's "resources.media" entries (IMG_CARD_<SLUG
|
||||||
|
UPPERCASE>, file img/<slug>.png) line up without a separate mapping
|
||||||
|
table anywhere.
|
||||||
|
|
||||||
|
CARD_WIDTH=72 (-> 72x120) is a hardware-verified ceiling, not a design
|
||||||
|
choice: full-display-width images (144-260px depending on platform) were
|
||||||
|
tried first and reliably crash real hardware with "PNG memory allocation
|
||||||
|
failed" - a runtime PNG-decoder memory constraint independent of the
|
||||||
|
app's own heap budget (lazy-loading only one image at a time didn't help
|
||||||
|
either). 72px is the size the original per-card screens used
|
||||||
|
successfully all session before that experiment.
|
||||||
|
|
||||||
|
Generated output, like i18n_tables.auto.c - not committed (see
|
||||||
|
.gitignore), regenerate by running this script directly or via the root
|
||||||
|
Makefile's `make watchapp` (task/target that also runs `pebble build`).
|
||||||
|
Requires Pillow (`pip install Pillow`), not part of this repo's own
|
||||||
|
desktop build/test toolchain.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SRC_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "res", "img"))
|
||||||
|
OUT_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "img"))
|
||||||
|
|
||||||
|
CARD_WIDTH = 72
|
||||||
|
|
||||||
|
# (source file in res/img/, slug matching engine/src/tarot_data.c's
|
||||||
|
# k_card_slug table) - in TarotCard enum order (CARD_FOOL = 0 ... CARD_WORLD).
|
||||||
|
CARDS = [
|
||||||
|
("RWS_Tarot_00_Fool.jpeg", "fool"),
|
||||||
|
("RWS_Tarot_01_Magician.jpeg", "magician"),
|
||||||
|
("RWS_Tarot_02_High_Priestess.jpeg", "high_priestess"),
|
||||||
|
("RWS_Tarot_03_Empress.jpeg", "empress"),
|
||||||
|
("RWS_Tarot_04_Emperor.jpeg", "emperor"),
|
||||||
|
("RWS_Tarot_05_Hierophant.jpeg", "hierophant"),
|
||||||
|
("RWS_Tarot_06_Lovers.jpeg", "lovers"),
|
||||||
|
("RWS_Tarot_07_Chariot.jpeg", "chariot"),
|
||||||
|
("RWS_Tarot_08_Strength.jpeg", "strength"),
|
||||||
|
("RWS_Tarot_09_Hermit.jpeg", "hermit"),
|
||||||
|
("RWS_Tarot_10_Wheel_of_Fortune.jpeg", "wheel_of_fortune"),
|
||||||
|
("RWS_Tarot_11_Justice.jpeg", "justice"),
|
||||||
|
("RWS_Tarot_12_Hanged_Man.jpeg", "hanged_man"),
|
||||||
|
("RWS_Tarot_13_Death.jpeg", "death"),
|
||||||
|
("RWS_Tarot_14_Temperance.jpeg", "temperance"),
|
||||||
|
("RWS_Tarot_15_Devil.jpeg", "devil"),
|
||||||
|
("RWS_Tarot_16_Tower.jpeg", "tower"),
|
||||||
|
("RWS_Tarot_17_Star.jpeg", "star"),
|
||||||
|
("RWS_Tarot_18_Moon.jpeg", "moon"),
|
||||||
|
("RWS_Tarot_19_Sun.jpeg", "sun"),
|
||||||
|
("RWS_Tarot_20_Judgement.jpeg", "judgement"),
|
||||||
|
("RWS_Tarot_21_World.jpeg", "world"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
written = 0
|
||||||
|
for src_name, slug in CARDS:
|
||||||
|
src_path = os.path.join(SRC_DIR, src_name)
|
||||||
|
out_path = os.path.join(OUT_DIR, f"{slug}.png")
|
||||||
|
|
||||||
|
# wscript runs this on every `pebble build` (like gen_i18n_tables.py)
|
||||||
|
# so a full 22-image resize must be cheap on the common no-op case -
|
||||||
|
# skip any card whose output is already newer than its source JPEG.
|
||||||
|
if os.path.exists(out_path) and os.path.getmtime(out_path) >= os.path.getmtime(src_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with Image.open(src_path) as img:
|
||||||
|
ratio = CARD_WIDTH / img.width
|
||||||
|
height = round(img.height * ratio)
|
||||||
|
resized = img.convert("RGB").resize((CARD_WIDTH, height), Image.LANCZOS)
|
||||||
|
resized.save(out_path, "PNG", optimize=True)
|
||||||
|
written += 1
|
||||||
|
|
||||||
|
print(f"wrote {written}/{len(CARDS)} card images to {OUT_DIR}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generates watch/src/c/i18n_tables.auto.c from engine/i18n/*.lang.
|
||||||
|
|
||||||
|
Compiles each non-English .lang file into a `static const char *const`
|
||||||
|
keys[]/values[] pair for i18n_load_table() (see engine/src/i18n.h's own
|
||||||
|
doc comment on why the watch needs a zero-copy, compiled-in table
|
||||||
|
instead of i18n_load()'s file-based parsing). English is deliberately
|
||||||
|
skipped: the fallback text already baked into tarot_data.c/astro.c/
|
||||||
|
narrative.c/guidance.c *is* English, so shipping a redundant English
|
||||||
|
catalog would roughly double this app's translation-data footprint
|
||||||
|
(which counts against the same ~64KB whole-app budget as everything
|
||||||
|
else - see watch/README.md) for zero behavioural benefit.
|
||||||
|
|
||||||
|
This is a generated file (like message_keys.auto.c, resource_ids.auto.c
|
||||||
|
elsewhere in a Pebble project) - not meant to be hand-edited or
|
||||||
|
committed; watch/wscript regenerates it on every build. Parsing rules
|
||||||
|
mirror engine/src/i18n.c's i18n_load() exactly: '#'-prefixed and blank
|
||||||
|
lines are skipped, the first '=' splits key/value, and both sides are
|
||||||
|
trimmed of surrounding spaces/tabs only (not quotes - values are raw
|
||||||
|
text, same as the desktop file loader).
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
I18N_DIR = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "..", "engine", "i18n"))
|
||||||
|
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "src", "c", "i18n_tables.auto.c"))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lang_file(path):
|
||||||
|
entries = []
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
for raw_line in f:
|
||||||
|
line = raw_line.rstrip("\r\n")
|
||||||
|
stripped = line.strip(" \t")
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip(" \t")
|
||||||
|
value = value.strip(" \t")
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
entries.append((key, value))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def c_string_literal(s):
|
||||||
|
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
return '"' + escaped + '"'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
lang_files = sorted(glob.glob(os.path.join(I18N_DIR, "*.lang")))
|
||||||
|
languages = []
|
||||||
|
for path in lang_files:
|
||||||
|
code = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
if code == "en":
|
||||||
|
continue # see module doc comment - English is never a compiled table
|
||||||
|
languages.append((code, parse_lang_file(path)))
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
lines.append("/* Generated by watch/scripts/gen_i18n_tables.py from engine/i18n's .lang")
|
||||||
|
lines.append(" * files - do not edit by hand, and do not commit (see .gitignore). */")
|
||||||
|
lines.append("")
|
||||||
|
lines.append('#include "i18n_tables.h"')
|
||||||
|
lines.append("")
|
||||||
|
lines.append('#include "../../../engine/src/i18n.h"')
|
||||||
|
lines.append("")
|
||||||
|
lines.append("#include <stddef.h>")
|
||||||
|
lines.append("#include <string.h>")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
for code, entries in languages:
|
||||||
|
lines.append(f"static const char *const k_keys_{code}[] = {{")
|
||||||
|
for key, _ in entries:
|
||||||
|
lines.append(f" {c_string_literal(key)},")
|
||||||
|
lines.append("};")
|
||||||
|
lines.append(f"static const char *const k_values_{code}[] = {{")
|
||||||
|
for _, value in entries:
|
||||||
|
lines.append(f" {c_string_literal(value)},")
|
||||||
|
lines.append("};")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("void watch_i18n_load(const char *lang_code) {")
|
||||||
|
for code, entries in languages:
|
||||||
|
lines.append(f' if (lang_code && strcmp(lang_code, "{code}") == 0) {{')
|
||||||
|
lines.append(f" i18n_load_table(k_keys_{code}, k_values_{code}, {len(entries)});")
|
||||||
|
lines.append(" return;")
|
||||||
|
lines.append(" }")
|
||||||
|
lines.append(" i18n_load_table(NULL, NULL, 0); /* \"en\" or unrecognized: use built-in English */")
|
||||||
|
lines.append("}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
|
||||||
|
with open(OUT_PATH, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(lines))
|
||||||
|
|
||||||
|
print(f"wrote {OUT_PATH} ({', '.join(code for code, _ in languages)})", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#include "app_message.h"
|
||||||
|
|
||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
/* AppMessage's Dictionary has no float tuple type, so latitude/
|
||||||
|
* longitude/utc_offset_hours (all doubles in BirthData) cross the wire
|
||||||
|
* as scaled int32s - microdegrees for lat/lon (~0.11m precision, far
|
||||||
|
* finer than this app's astrology needs), minutes for the UTC offset
|
||||||
|
* (covers every real-world offset, including the half/quarter-hour
|
||||||
|
* ones) - see config.c's own persisted representation, which uses the
|
||||||
|
* same scaling so no conversion happens twice. */
|
||||||
|
|
||||||
|
static ConfigUpdatedCallback s_callback;
|
||||||
|
|
||||||
|
static bool read_int(DictionaryIterator *iter, uint32_t key, int32_t *out) {
|
||||||
|
Tuple *t = dict_find(iter, key);
|
||||||
|
if (!t) return false;
|
||||||
|
*out = t->value->int32;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void inbox_received_handler(DictionaryIterator *iter, void *context) {
|
||||||
|
WatchConfig cfg;
|
||||||
|
config_load(&cfg); /* start from existing settings - a field the page
|
||||||
|
* doesn't send (e.g. it only changed the language)
|
||||||
|
* keeps its previous value rather than resetting. */
|
||||||
|
|
||||||
|
int32_t v;
|
||||||
|
bool got_birth = false;
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_YEAR, &v)) { cfg.birth.year = v; got_birth = true; }
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_MONTH, &v)) { cfg.birth.month = v; got_birth = true; }
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_DAY, &v)) { cfg.birth.day = v; got_birth = true; }
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_HOUR, &v)) { cfg.birth.hour = v; got_birth = true; }
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_MINUTE, &v)) { cfg.birth.minute = v; got_birth = true; }
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_UTC_OFFSET_MINUTES, &v)) {
|
||||||
|
cfg.birth.utc_offset_hours = v / 60.0;
|
||||||
|
got_birth = true;
|
||||||
|
}
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_LAT_MICRODEG, &v)) {
|
||||||
|
cfg.birth.latitude = v / 1000000.0;
|
||||||
|
got_birth = true;
|
||||||
|
}
|
||||||
|
if (read_int(iter, MESSAGE_KEY_BIRTH_LON_MICRODEG, &v)) {
|
||||||
|
cfg.birth.longitude = v / 1000000.0;
|
||||||
|
got_birth = true;
|
||||||
|
}
|
||||||
|
if (read_int(iter, MESSAGE_KEY_GENDER, &v)) cfg.gender = (Gender)v;
|
||||||
|
|
||||||
|
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
|
||||||
|
if (lang_tuple) {
|
||||||
|
strncpy(cfg.lang, lang_tuple->value->cstring, sizeof(cfg.lang) - 1);
|
||||||
|
cfg.lang[sizeof(cfg.lang) - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (read_int(iter, MESSAGE_KEY_NOTIFY_ENABLED, &v)) cfg.notify_enabled = (v != 0);
|
||||||
|
if (read_int(iter, MESSAGE_KEY_NOTIFY_HOUR, &v)) cfg.notify_hour = v;
|
||||||
|
if (read_int(iter, MESSAGE_KEY_NOTIFY_MINUTE, &v)) cfg.notify_minute = v;
|
||||||
|
|
||||||
|
/* Birth data is the one thing that makes a config submission "complete" -
|
||||||
|
* language/notification-only re-submissions (e.g. from a later re-open
|
||||||
|
* of the settings page) must not un-configure an already-set-up watch. */
|
||||||
|
if (got_birth) cfg.configured = true;
|
||||||
|
|
||||||
|
config_log(&cfg); /* confirms what was actually received/parsed */
|
||||||
|
config_save(&cfg);
|
||||||
|
if (s_callback) s_callback(&cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void inbox_dropped_handler(AppMessageResult reason, void *context) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_ERROR, "AppMessage dropped: reason %d", (int)reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* app_message_*_size_maximum() return the largest buffer the platform
|
||||||
|
* *could* hand out (8200 bytes each) - not a size to actually request.
|
||||||
|
* The real payload is ~150-200 bytes; 256 bytes each leaves headroom
|
||||||
|
* without eating into this app's ~12KB heap budget (see watch/README.md). */
|
||||||
|
#define INBOX_SIZE 256
|
||||||
|
#define OUTBOX_SIZE 256
|
||||||
|
|
||||||
|
void app_message_init(ConfigUpdatedCallback on_config_updated) {
|
||||||
|
s_callback = on_config_updated;
|
||||||
|
app_message_register_inbox_received(inbox_received_handler);
|
||||||
|
app_message_register_inbox_dropped(inbox_dropped_handler);
|
||||||
|
app_message_open(INBOX_SIZE, OUTBOX_SIZE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#ifndef WATCH_APP_MESSAGE_H
|
||||||
|
#define WATCH_APP_MESSAGE_H
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
/* Invoked with the freshly-saved config whenever a settings submission
|
||||||
|
* arrives from the config page (src/pkjs/index.js, task #43) - main.c
|
||||||
|
* uses this to recompute the reading and refresh/show the menu. */
|
||||||
|
typedef void (*ConfigUpdatedCallback)(const WatchConfig *cfg);
|
||||||
|
|
||||||
|
/* Registers the AppMessage inbox handler and opens the message buffers.
|
||||||
|
* Call once at startup, before app_event_loop(). Message keys
|
||||||
|
* (BIRTH_YEAR, BIRTH_UTC_OFFSET_MINUTES, LANG, NOTIFY_ENABLED, etc.) are
|
||||||
|
* declared in watch/package.json's "messageKeys" and turn into
|
||||||
|
* MESSAGE_KEY_* constants in the auto-generated message_keys.auto.h -
|
||||||
|
* see app_message.c's own comment on why latitude/longitude/utc offset
|
||||||
|
* travel as scaled integers rather than floats. */
|
||||||
|
void app_message_init(ConfigUpdatedCallback on_config_updated);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* Persist keys. BirthData's latitude/longitude/utc_offset_hours are
|
||||||
|
* doubles, but Pebble's persist API has no float storage - stored as
|
||||||
|
* scaled int32s instead (microdegrees / minutes), matching how
|
||||||
|
* AppMessage carries the same fields from the config page - see
|
||||||
|
* app_message.c. */
|
||||||
|
enum {
|
||||||
|
PKEY_CONFIGURED = 0,
|
||||||
|
PKEY_BIRTH_YEAR,
|
||||||
|
PKEY_BIRTH_MONTH,
|
||||||
|
PKEY_BIRTH_DAY,
|
||||||
|
PKEY_BIRTH_HOUR,
|
||||||
|
PKEY_BIRTH_MINUTE,
|
||||||
|
PKEY_BIRTH_UTC_OFFSET_MINUTES,
|
||||||
|
PKEY_BIRTH_LAT_MICRODEG,
|
||||||
|
PKEY_BIRTH_LON_MICRODEG,
|
||||||
|
PKEY_GENDER,
|
||||||
|
PKEY_LANG,
|
||||||
|
PKEY_NOTIFY_ENABLED,
|
||||||
|
PKEY_NOTIFY_HOUR,
|
||||||
|
PKEY_NOTIFY_MINUTE,
|
||||||
|
};
|
||||||
|
|
||||||
|
void config_load(WatchConfig *out) {
|
||||||
|
memset(out, 0, sizeof *out);
|
||||||
|
|
||||||
|
out->configured = persist_exists(PKEY_CONFIGURED) && persist_read_bool(PKEY_CONFIGURED);
|
||||||
|
if (!out->configured) {
|
||||||
|
strcpy(out->lang, "en");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out->birth.year = (int)persist_read_int(PKEY_BIRTH_YEAR);
|
||||||
|
out->birth.month = (int)persist_read_int(PKEY_BIRTH_MONTH);
|
||||||
|
out->birth.day = (int)persist_read_int(PKEY_BIRTH_DAY);
|
||||||
|
out->birth.hour = (int)persist_read_int(PKEY_BIRTH_HOUR);
|
||||||
|
out->birth.minute = (int)persist_read_int(PKEY_BIRTH_MINUTE);
|
||||||
|
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
|
||||||
|
out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0;
|
||||||
|
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
|
||||||
|
out->gender = (Gender)persist_read_int(PKEY_GENDER); /* defaults to 0 = GENDER_UNSPECIFIED */
|
||||||
|
|
||||||
|
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
|
||||||
|
strcpy(out->lang, "en");
|
||||||
|
}
|
||||||
|
|
||||||
|
out->notify_enabled = persist_read_bool(PKEY_NOTIFY_ENABLED);
|
||||||
|
out->notify_hour = (int)persist_read_int(PKEY_NOTIFY_HOUR);
|
||||||
|
out->notify_minute = (int)persist_read_int(PKEY_NOTIFY_MINUTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void config_save(const WatchConfig *cfg) {
|
||||||
|
persist_write_bool(PKEY_CONFIGURED, cfg->configured);
|
||||||
|
|
||||||
|
persist_write_int(PKEY_BIRTH_YEAR, cfg->birth.year);
|
||||||
|
persist_write_int(PKEY_BIRTH_MONTH, cfg->birth.month);
|
||||||
|
persist_write_int(PKEY_BIRTH_DAY, cfg->birth.day);
|
||||||
|
persist_write_int(PKEY_BIRTH_HOUR, cfg->birth.hour);
|
||||||
|
persist_write_int(PKEY_BIRTH_MINUTE, cfg->birth.minute);
|
||||||
|
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
|
||||||
|
persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0));
|
||||||
|
persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
|
||||||
|
persist_write_int(PKEY_GENDER, (int32_t)cfg->gender);
|
||||||
|
|
||||||
|
persist_write_string(PKEY_LANG, cfg->lang);
|
||||||
|
|
||||||
|
persist_write_bool(PKEY_NOTIFY_ENABLED, cfg->notify_enabled);
|
||||||
|
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
|
||||||
|
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support
|
||||||
|
* floating-point format specifiers. */
|
||||||
|
void config_log(const WatchConfig *cfg) {
|
||||||
|
if (!cfg->configured) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin",
|
||||||
|
cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
|
||||||
|
(int)(cfg->birth.utc_offset_hours * 60));
|
||||||
|
APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 gender=%d lang=%s",
|
||||||
|
(long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000),
|
||||||
|
(int)cfg->gender, cfg->lang);
|
||||||
|
APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
|
||||||
|
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#ifndef WATCH_CONFIG_H
|
||||||
|
#define WATCH_CONFIG_H
|
||||||
|
|
||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
#include "../../../engine/src/astro.h"
|
||||||
|
#include "../../../engine/src/tarot.h" /* Gender */
|
||||||
|
|
||||||
|
/* Everything the watch needs that isn't computed fresh each day: birth
|
||||||
|
* data (the same fields as dist/user.properties), display language, and
|
||||||
|
* daily notification settings. Persisted on-watch via Pebble's own
|
||||||
|
* key-value persist API (see config.c) - no companion app, no server,
|
||||||
|
* matching the rest of this project's architecture. */
|
||||||
|
typedef struct {
|
||||||
|
bool configured; /* false until the config page has been submitted once */
|
||||||
|
BirthData birth;
|
||||||
|
Gender gender; /* who the reading is for - only affects Celtic Cross
|
||||||
|
* position text pronouns, see engine/src/tarot.h.
|
||||||
|
* Defaults to GENDER_UNSPECIFIED (0). */
|
||||||
|
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
|
||||||
|
bool notify_enabled;
|
||||||
|
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
|
||||||
|
* localtime(), not birth.utc_offset_hours - the
|
||||||
|
* user's current timezone, synced from their phone,
|
||||||
|
* not necessarily their birth location's). */
|
||||||
|
int notify_minute; /* 0-59 */
|
||||||
|
} WatchConfig;
|
||||||
|
|
||||||
|
/* Fills `out` with the persisted config, or a safe all-zero/`configured
|
||||||
|
* = false` default if nothing has been saved yet (first run). */
|
||||||
|
void config_load(WatchConfig *out);
|
||||||
|
|
||||||
|
void config_save(const WatchConfig *cfg);
|
||||||
|
|
||||||
|
/* Prints `cfg` via APP_LOG - the same fields a reading is computed from,
|
||||||
|
* so callers can confirm on-device what values a shown reading is
|
||||||
|
* actually based on (there's no way to read them back from the watch
|
||||||
|
* otherwise). Prints "not configured yet" instead if !cfg->configured. */
|
||||||
|
void config_log(const WatchConfig *cfg);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#ifndef WATCH_I18N_TABLES_H
|
||||||
|
#define WATCH_I18N_TABLES_H
|
||||||
|
|
||||||
|
/* Points i18n_get() (engine/src/i18n.h) at the compiled-in translation
|
||||||
|
* table for `lang_code`, generated at build time from engine/i18n's
|
||||||
|
* .lang files by watch/scripts/gen_i18n_tables.py into
|
||||||
|
* i18n_tables.auto.c - see that script's own doc comment for the format
|
||||||
|
* and why English is deliberately not one of the compiled tables.
|
||||||
|
* Passing "en", NULL, or any code with no matching .lang file resets to
|
||||||
|
* no table at all, so every i18n_get() call falls through to its
|
||||||
|
* built-in English fallback text - the correct behavior when a user
|
||||||
|
* switches the watch's language setting back to English at runtime,
|
||||||
|
* not just on first load. */
|
||||||
|
void watch_i18n_load(const char *lang_code);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
#include "../../../engine/src/i18n.h"
|
||||||
|
#include "app_message.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include "i18n_tables.h"
|
||||||
|
#include "notify.h"
|
||||||
|
#include "reading_state.h"
|
||||||
|
#include "ui_report_window.h"
|
||||||
|
#include "ui_text_window.h"
|
||||||
|
|
||||||
|
static WatchConfig s_config;
|
||||||
|
|
||||||
|
static void show_setup_required(void) {
|
||||||
|
text_window_push(i18n_get("ui.setup_required_title", "Setup Required"),
|
||||||
|
i18n_get("ui.setup_required_body",
|
||||||
|
"Open this app's settings from the Pebble mobile app to add your "
|
||||||
|
"birth details, then reopen Deck in a Dash."));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_config_updated(const WatchConfig *cfg) {
|
||||||
|
bool was_configured = s_config.configured;
|
||||||
|
s_config = *cfg;
|
||||||
|
watch_i18n_load(s_config.lang);
|
||||||
|
|
||||||
|
if (!s_config.configured) return;
|
||||||
|
|
||||||
|
config_log(&s_config); /* the values this newly-recomputed reading is based on */
|
||||||
|
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||||
|
notify_reschedule(&s_config); /* settings may have changed notify_enabled/hour/minute */
|
||||||
|
if (was_configured) {
|
||||||
|
report_window_reload();
|
||||||
|
} else {
|
||||||
|
report_window_push();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fires only if the scheduled wakeup goes off while this app is already
|
||||||
|
* open (notify_was_wakeup_launch() below covers the far more common
|
||||||
|
* case: the wakeup launching the app fresh). Refreshes the same way a
|
||||||
|
* new day's config submission would, since the report window is already
|
||||||
|
* on screen and the reading may now be for a new day. */
|
||||||
|
static void on_wakeup(WakeupId wakeup_id, int32_t cookie) {
|
||||||
|
(void)wakeup_id;
|
||||||
|
(void)cookie;
|
||||||
|
vibes_double_pulse();
|
||||||
|
if (!s_config.configured) return;
|
||||||
|
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||||
|
report_window_reload();
|
||||||
|
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm for tomorrow */
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
config_load(&s_config);
|
||||||
|
watch_i18n_load(s_config.lang);
|
||||||
|
app_message_init(on_config_updated);
|
||||||
|
notify_subscribe(on_wakeup);
|
||||||
|
|
||||||
|
config_log(&s_config); /* the values this session's reading (if any) is based on */
|
||||||
|
|
||||||
|
if (s_config.configured) {
|
||||||
|
reading_state_recompute(&s_config.birth, s_config.gender);
|
||||||
|
report_window_push();
|
||||||
|
notify_reschedule(&s_config); /* wakeup_schedule() is one-shot - re-arm on every launch */
|
||||||
|
if (notify_was_wakeup_launch()) {
|
||||||
|
vibes_double_pulse();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
show_setup_required();
|
||||||
|
}
|
||||||
|
|
||||||
|
app_event_loop();
|
||||||
|
|
||||||
|
report_window_deinit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "notify.h"
|
||||||
|
|
||||||
|
/* Only one kind of wakeup event exists in this app, so the cookie value
|
||||||
|
* itself carries no information - it exists only because
|
||||||
|
* wakeup_schedule()/wakeup_get_launch_event() require one. */
|
||||||
|
#define NOTIFY_WAKEUP_COOKIE 0
|
||||||
|
|
||||||
|
void notify_reschedule(const WatchConfig *cfg) {
|
||||||
|
wakeup_cancel_all();
|
||||||
|
if (!cfg->notify_enabled) return;
|
||||||
|
|
||||||
|
time_t now = time(NULL);
|
||||||
|
struct tm target_tm = *localtime(&now);
|
||||||
|
target_tm.tm_hour = cfg->notify_hour;
|
||||||
|
target_tm.tm_min = cfg->notify_minute;
|
||||||
|
target_tm.tm_sec = 0;
|
||||||
|
|
||||||
|
time_t target = mktime(&target_tm);
|
||||||
|
if (target <= now) target += SECONDS_PER_DAY; /* today's time already passed */
|
||||||
|
|
||||||
|
WakeupId id = wakeup_schedule(target, NOTIFY_WAKEUP_COOKIE, true);
|
||||||
|
if (id < 0) {
|
||||||
|
APP_LOG(APP_LOG_LEVEL_ERROR, "notify: wakeup_schedule failed: %d", (int)id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool notify_was_wakeup_launch(void) {
|
||||||
|
return launch_reason() == APP_LAUNCH_WAKEUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
void notify_subscribe(WakeupHandler handler) {
|
||||||
|
wakeup_service_subscribe(handler);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#ifndef WATCH_NOTIFY_H
|
||||||
|
#define WATCH_NOTIFY_H
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
|
||||||
|
/* (Re)schedules the daily notification wakeup from `cfg`'s notify_*
|
||||||
|
* fields: cancels any wakeup this app previously scheduled, then - if
|
||||||
|
* cfg->notify_enabled - schedules a new one-shot wakeup_schedule() for
|
||||||
|
* the next occurrence of notify_hour:notify_minute in the watch's own
|
||||||
|
* local time (today if that time hasn't passed yet, tomorrow otherwise).
|
||||||
|
* If !cfg->notify_enabled, this only cancels - no new wakeup is
|
||||||
|
* scheduled.
|
||||||
|
*
|
||||||
|
* Safe to call any time, and needs to be: once per app launch (Pebble's
|
||||||
|
* wakeup_schedule() is one-shot - each firing consumes itself, so the
|
||||||
|
* next day's has to be scheduled again after it fires or after a wakeup
|
||||||
|
* that arrives while the app happens to already be open - see
|
||||||
|
* notify_subscribe()) and on every config submission (settings may have
|
||||||
|
* changed notify_enabled/notify_hour/notify_minute). */
|
||||||
|
void notify_reschedule(const WatchConfig *cfg);
|
||||||
|
|
||||||
|
/* True if this app launch was triggered by the daily notification wakeup
|
||||||
|
* firing rather than the user/phone opening it normally - main.c uses
|
||||||
|
* this to vibrate on launch, since the report window it pushes either
|
||||||
|
* way already leads with day significance and the top transit (the
|
||||||
|
* "quick digest" this notification promises), so no separate screen is
|
||||||
|
* needed for it. */
|
||||||
|
bool notify_was_wakeup_launch(void);
|
||||||
|
|
||||||
|
/* Subscribes `handler` to fire if the scheduled wakeup goes off while
|
||||||
|
* this app is already running in the foreground - the only case
|
||||||
|
* notify_was_wakeup_launch() can't see, since no fresh launch happens
|
||||||
|
* then (see wakeup_service_subscribe()'s own doc comment in pebble.h).
|
||||||
|
* Call once, early in main(). */
|
||||||
|
void notify_subscribe(WakeupHandler handler);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#include "reading_state.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
static DailyReading s_reading;
|
||||||
|
static DailyInterpretation s_interp;
|
||||||
|
|
||||||
|
void reading_state_recompute(const BirthData *birth, Gender gender) {
|
||||||
|
time_t now = time(NULL);
|
||||||
|
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
|
||||||
|
* same justification as astro.c's own time_from_unix(). */
|
||||||
|
struct tm *utc = gmtime(&now);
|
||||||
|
|
||||||
|
char seed[16];
|
||||||
|
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
|
||||||
|
reading_generate(seed, birth, gender, now, &s_reading);
|
||||||
|
interpret_daily_reading(&s_reading, &s_interp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyReading *reading_state_get_reading(void) {
|
||||||
|
return &s_reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DailyInterpretation *reading_state_get_interpretation(void) {
|
||||||
|
return &s_interp;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#ifndef WATCH_READING_STATE_H
|
||||||
|
#define WATCH_READING_STATE_H
|
||||||
|
|
||||||
|
#include "../../../engine/src/reading.h"
|
||||||
|
#include "../../../interpreter/src/significance.h"
|
||||||
|
|
||||||
|
/* Recomputes today's reading (astro + tarot + interpretation) from
|
||||||
|
* `birth`/`gender`, using the watch's current clock: today's UTC date as
|
||||||
|
* the tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
|
||||||
|
* "Build & run") and the current moment for the transit snapshot. Safe
|
||||||
|
* to call again later (e.g. the day rolled over while the app stayed
|
||||||
|
* open) - overwrites the previous result in place. */
|
||||||
|
void reading_state_recompute(const BirthData *birth, Gender gender);
|
||||||
|
|
||||||
|
/* Valid only after at least one reading_state_recompute() call. */
|
||||||
|
const DailyReading *reading_state_get_reading(void);
|
||||||
|
const DailyInterpretation *reading_state_get_interpretation(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#include "resource_map.h"
|
||||||
|
|
||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
uint32_t card_resource_id(TarotCard card) {
|
||||||
|
switch (card) {
|
||||||
|
case CARD_FOOL: return RESOURCE_ID_IMG_CARD_FOOL;
|
||||||
|
case CARD_MAGICIAN: return RESOURCE_ID_IMG_CARD_MAGICIAN;
|
||||||
|
case CARD_HIGH_PRIESTESS: return RESOURCE_ID_IMG_CARD_HIGH_PRIESTESS;
|
||||||
|
case CARD_EMPRESS: return RESOURCE_ID_IMG_CARD_EMPRESS;
|
||||||
|
case CARD_EMPEROR: return RESOURCE_ID_IMG_CARD_EMPEROR;
|
||||||
|
case CARD_HIEROPHANT: return RESOURCE_ID_IMG_CARD_HIEROPHANT;
|
||||||
|
case CARD_LOVERS: return RESOURCE_ID_IMG_CARD_LOVERS;
|
||||||
|
case CARD_CHARIOT: return RESOURCE_ID_IMG_CARD_CHARIOT;
|
||||||
|
case CARD_STRENGTH: return RESOURCE_ID_IMG_CARD_STRENGTH;
|
||||||
|
case CARD_HERMIT: return RESOURCE_ID_IMG_CARD_HERMIT;
|
||||||
|
case CARD_WHEEL_OF_FORTUNE: return RESOURCE_ID_IMG_CARD_WHEEL_OF_FORTUNE;
|
||||||
|
case CARD_JUSTICE: return RESOURCE_ID_IMG_CARD_JUSTICE;
|
||||||
|
case CARD_HANGED_MAN: return RESOURCE_ID_IMG_CARD_HANGED_MAN;
|
||||||
|
case CARD_DEATH: return RESOURCE_ID_IMG_CARD_DEATH;
|
||||||
|
case CARD_TEMPERANCE: return RESOURCE_ID_IMG_CARD_TEMPERANCE;
|
||||||
|
case CARD_DEVIL: return RESOURCE_ID_IMG_CARD_DEVIL;
|
||||||
|
case CARD_TOWER: return RESOURCE_ID_IMG_CARD_TOWER;
|
||||||
|
case CARD_STAR: return RESOURCE_ID_IMG_CARD_STAR;
|
||||||
|
case CARD_MOON: return RESOURCE_ID_IMG_CARD_MOON;
|
||||||
|
case CARD_SUN: return RESOURCE_ID_IMG_CARD_SUN;
|
||||||
|
case CARD_JUDGEMENT: return RESOURCE_ID_IMG_CARD_JUDGEMENT;
|
||||||
|
case CARD_WORLD: return RESOURCE_ID_IMG_CARD_WORLD;
|
||||||
|
}
|
||||||
|
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t card_image_height_for_width(int16_t width) {
|
||||||
|
return (int16_t)((width * 1810 + 543) / 1086); /* +543 = round, not truncate */
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#ifndef WATCH_RESOURCE_MAP_H
|
||||||
|
#define WATCH_RESOURCE_MAP_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "../../../engine/src/tarot.h"
|
||||||
|
|
||||||
|
/* Maps a TarotCard to its compiled-in bitmap resource ID. Each of
|
||||||
|
* watch/package.json's "resources.media" entries is named
|
||||||
|
* IMG_CARD_<SLUG UPPERCASE>, matching engine/src/tarot_data.c's own
|
||||||
|
* k_card_slug table (see watch/scripts/gen_card_images.py, which
|
||||||
|
* generates the underlying PNGs with the same slugs) - Pebble's build
|
||||||
|
* turns each into a RESOURCE_ID_IMG_CARD_<SLUG> macro in the
|
||||||
|
* auto-generated resource_ids.auto.h, which this function is the one
|
||||||
|
* place in the app that switches over. */
|
||||||
|
uint32_t card_resource_id(TarotCard card);
|
||||||
|
|
||||||
|
/* Height in pixels of a card image resized to `width` wide, preserving
|
||||||
|
* the source art's fixed 1086x1810 aspect ratio - matches
|
||||||
|
* gen_card_images.py's own resize math, so this stays correct for
|
||||||
|
* whichever per-platform width variant package.json resolves to. */
|
||||||
|
int16_t card_image_height_for_width(int16_t width);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
#include "ui_report_window.h"
|
||||||
|
|
||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
#include "../../../engine/src/i18n.h"
|
||||||
|
#include "../../../interpreter/src/guidance.h"
|
||||||
|
#include "../../../interpreter/src/narrative.h"
|
||||||
|
#include "reading_state.h"
|
||||||
|
#include "resource_map.h"
|
||||||
|
|
||||||
|
#define MARGIN 6
|
||||||
|
|
||||||
|
/* Matches gen_card_images.py's own CARD_WIDTH - a hardware-verified
|
||||||
|
* ceiling, not a design choice: full-display-width images reliably
|
||||||
|
* crashed the real PNG decoder ("PNG memory allocation failed", a
|
||||||
|
* firmware-level constraint independent of this app's own heap budget,
|
||||||
|
* confirmed unaffected by lazy-loading only one image at a time). */
|
||||||
|
#define IMAGE_WIDTH 72
|
||||||
|
|
||||||
|
/* fonts_get_system_font() needs Pebble's runtime initialized, so these
|
||||||
|
* are resolved once in window_load() rather than at static-init time. */
|
||||||
|
static GFont s_font_section;
|
||||||
|
static GFont s_font_subhead;
|
||||||
|
static GFont s_font_body;
|
||||||
|
|
||||||
|
/* Static buffers for every dynamically-built string this window shows -
|
||||||
|
* tarot_position_description()/tarot_card_meaning()/i18n_get() fallback
|
||||||
|
* text all point straight at flash/ROM data (see i18n.h's own comment on
|
||||||
|
* i18n_load_table()), so only the pieces actually assembled at runtime
|
||||||
|
* (via snprintf/narrative_write/guidance_write) need their own storage.
|
||||||
|
* Static rather than malloc'd - fixed, known-in-advance sizes, and it
|
||||||
|
* keeps report_window_reload() from having to reason about freeing the
|
||||||
|
* previous run's buffers before reusing them. */
|
||||||
|
static char s_reading_date[48];
|
||||||
|
static char s_day_title[64];
|
||||||
|
static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96];
|
||||||
|
static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX];
|
||||||
|
static char s_position_titles[TAROT_SPREAD_SIZE][96];
|
||||||
|
static char s_guidance[GUIDANCE_TEXT_MAX];
|
||||||
|
|
||||||
|
/* Loads/frees each position's GBitmap as scroll_offset_changed() sees it
|
||||||
|
* enter/leave the visible viewport, rather than decoding all ten Celtic
|
||||||
|
* Cross images up front - a defensive habit left over from an earlier,
|
||||||
|
* larger IMAGE_WIDTH (see that constant's own comment); at 72px this
|
||||||
|
* struct's images are small enough that it's no longer strictly
|
||||||
|
* necessary, but it's harmless to keep. */
|
||||||
|
typedef struct {
|
||||||
|
TarotCard card;
|
||||||
|
int16_t y;
|
||||||
|
int16_t height;
|
||||||
|
BitmapLayer *bitmap_layer;
|
||||||
|
GBitmap *bitmap;
|
||||||
|
} ImageSlot;
|
||||||
|
|
||||||
|
static ImageSlot s_slots[TAROT_SPREAD_SIZE];
|
||||||
|
|
||||||
|
/* Attitude (0) and Outcome (1) preview slots, drawn at half screen
|
||||||
|
* width each so the two sit side by side - the BitmapLayer's own bounds
|
||||||
|
* (narrower than IMAGE_WIDTH on 144px-wide platforms) clip the centered
|
||||||
|
* image automatically, same as GAlignCenter already does for the
|
||||||
|
* full-width slots above, no separate scaling logic needed. */
|
||||||
|
#define REPEAT_SLOT_COUNT 2
|
||||||
|
static ImageSlot s_repeat_slots[REPEAT_SLOT_COUNT];
|
||||||
|
|
||||||
|
#define MAX_TEXT_LAYERS 50
|
||||||
|
static TextLayer *s_text_layers[MAX_TEXT_LAYERS];
|
||||||
|
static int s_text_layer_count;
|
||||||
|
|
||||||
|
static Window *s_window;
|
||||||
|
static ScrollLayer *s_scroll_layer;
|
||||||
|
static int16_t s_screen_height;
|
||||||
|
|
||||||
|
static const char *day_level_name(DaySignificance level) {
|
||||||
|
switch (level) {
|
||||||
|
case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
|
||||||
|
case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
|
||||||
|
case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
|
||||||
|
case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static const char *reversed_marker(bool reversed) {
|
||||||
|
return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static int16_t add_text_at(int16_t x, int16_t width, int16_t y, const char *text, GFont font) {
|
||||||
|
TextLayer *tl = text_layer_create(GRect(x, y, width, 4000));
|
||||||
|
text_layer_set_font(tl, font);
|
||||||
|
text_layer_set_text(tl, text);
|
||||||
|
text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap);
|
||||||
|
|
||||||
|
GSize size = text_layer_get_content_size(tl);
|
||||||
|
size.w = width;
|
||||||
|
size.h += 4;
|
||||||
|
text_layer_set_size(tl, size);
|
||||||
|
|
||||||
|
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(tl));
|
||||||
|
s_text_layers[s_text_layer_count++] = tl;
|
||||||
|
return y + size.h + MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
|
||||||
|
return add_text_at(0, width, y, text, font);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void teardown_content(void) {
|
||||||
|
for (int i = 0; i < s_text_layer_count; i++) {
|
||||||
|
text_layer_destroy(s_text_layers[i]);
|
||||||
|
}
|
||||||
|
s_text_layer_count = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
if (s_slots[i].bitmap) {
|
||||||
|
gbitmap_destroy(s_slots[i].bitmap);
|
||||||
|
s_slots[i].bitmap = NULL;
|
||||||
|
}
|
||||||
|
if (s_slots[i].bitmap_layer) {
|
||||||
|
bitmap_layer_destroy(s_slots[i].bitmap_layer);
|
||||||
|
s_slots[i].bitmap_layer = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
|
||||||
|
if (s_repeat_slots[i].bitmap) {
|
||||||
|
gbitmap_destroy(s_repeat_slots[i].bitmap);
|
||||||
|
s_repeat_slots[i].bitmap = NULL;
|
||||||
|
}
|
||||||
|
if (s_repeat_slots[i].bitmap_layer) {
|
||||||
|
bitmap_layer_destroy(s_repeat_slots[i].bitmap_layer);
|
||||||
|
s_repeat_slots[i].bitmap_layer = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void update_slot_visibility(ImageSlot *slot, int16_t visible_top, int16_t visible_bottom) {
|
||||||
|
bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top;
|
||||||
|
|
||||||
|
if (visible && !slot->bitmap) {
|
||||||
|
slot->bitmap = gbitmap_create_with_resource(card_resource_id(slot->card));
|
||||||
|
bitmap_layer_set_bitmap(slot->bitmap_layer, slot->bitmap);
|
||||||
|
} else if (!visible && slot->bitmap) {
|
||||||
|
bitmap_layer_set_bitmap(slot->bitmap_layer, NULL);
|
||||||
|
gbitmap_destroy(slot->bitmap);
|
||||||
|
slot->bitmap = NULL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void update_visible_images(void) {
|
||||||
|
GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
|
||||||
|
int16_t visible_top = -offset.y;
|
||||||
|
int16_t visible_bottom = visible_top + s_screen_height;
|
||||||
|
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
update_slot_visibility(&s_slots[i], visible_top, visible_bottom);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
|
||||||
|
update_slot_visibility(&s_repeat_slots[i], visible_top, visible_bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) {
|
||||||
|
update_visible_images();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void build_content(int16_t width) {
|
||||||
|
const DailyReading *reading = reading_state_get_reading();
|
||||||
|
const DailyInterpretation *interp = reading_state_get_interpretation();
|
||||||
|
|
||||||
|
int16_t y = MARGIN;
|
||||||
|
|
||||||
|
/* The day this reading is for (reading->utc_moment, set by
|
||||||
|
* reading_generate() - see reading_state_recompute()), not necessarily
|
||||||
|
* "today" if the report is viewed right at a UTC-midnight boundary
|
||||||
|
* before the next recompute. Plain gmtime(), same justification as
|
||||||
|
* reading_state.c's own use of it. */
|
||||||
|
struct tm *utc = gmtime(&reading->utc_moment);
|
||||||
|
snprintf(s_reading_date, sizeof s_reading_date, "%s %04d-%02d-%02d",
|
||||||
|
i18n_get("interp.reading_date", "Reading for:"), utc->tm_year + 1900,
|
||||||
|
utc->tm_mon + 1, utc->tm_mday);
|
||||||
|
y = add_text(width, y, s_reading_date, s_font_body);
|
||||||
|
|
||||||
|
y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section);
|
||||||
|
snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level),
|
||||||
|
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
|
||||||
|
y = add_text(width, y, s_day_title, s_font_body);
|
||||||
|
|
||||||
|
/* Build every transit's title + narrative up front (used both for the
|
||||||
|
* repeated top-item line below and the full list further down), so
|
||||||
|
* neither buffer is computed twice for item 0. */
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
for (int i = 0; i < interp->top_item_count; i++) {
|
||||||
|
const Aspect *a = &interp->top_items[i].aspect;
|
||||||
|
snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s",
|
||||||
|
i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet),
|
||||||
|
astro_aspect_name(a->type), i18n_get("ui.natal", "natal"),
|
||||||
|
astro_body_name(a->natal_planet));
|
||||||
|
narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
|
||||||
|
reading->transits.bodies[a->transiting_planet].house);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
|
||||||
|
|
||||||
|
/* Attitude/Outcome preview, side by side, image only - no per-card
|
||||||
|
* caption text. A "Position: Card Name (Reversed)" label doesn't fit
|
||||||
|
* a half-width column without wrapping into 3+ short lines, and with
|
||||||
|
* both columns wrapping independently the reader ends up reading
|
||||||
|
* fragments across columns rather than down one column at a time
|
||||||
|
* (confirmed on-device: it reads as one garbled interleaved sentence,
|
||||||
|
* not two). Captions are unnecessary anyway - the guidance text
|
||||||
|
* printed immediately below already names and quotes both cards in
|
||||||
|
* full ("Deine Haltungskarte ist ..." / "Das wahrscheinliche Ergebnis
|
||||||
|
* ... ist ..."), and both reappear with their full position/name/
|
||||||
|
* meaning again in the Celtic Cross walkthrough further down. */
|
||||||
|
{
|
||||||
|
int16_t half_w = width / 2;
|
||||||
|
const TarotDraw *attitude = &reading->spread.positions[POSITION_ATTITUDE];
|
||||||
|
const TarotDraw *outcome = &reading->spread.positions[POSITION_OUTCOME];
|
||||||
|
int16_t img_height = card_image_height_for_width(IMAGE_WIDTH);
|
||||||
|
|
||||||
|
s_repeat_slots[0].card = attitude->card;
|
||||||
|
s_repeat_slots[0].y = y;
|
||||||
|
s_repeat_slots[0].height = img_height;
|
||||||
|
s_repeat_slots[0].bitmap_layer = bitmap_layer_create(GRect(0, y, half_w, img_height));
|
||||||
|
bitmap_layer_set_alignment(s_repeat_slots[0].bitmap_layer, GAlignCenter);
|
||||||
|
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[0].bitmap_layer));
|
||||||
|
|
||||||
|
s_repeat_slots[1].card = outcome->card;
|
||||||
|
s_repeat_slots[1].y = y;
|
||||||
|
s_repeat_slots[1].height = img_height;
|
||||||
|
s_repeat_slots[1].bitmap_layer =
|
||||||
|
bitmap_layer_create(GRect(half_w, y, width - half_w, img_height));
|
||||||
|
bitmap_layer_set_alignment(s_repeat_slots[1].bitmap_layer, GAlignCenter);
|
||||||
|
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[1].bitmap_layer));
|
||||||
|
|
||||||
|
y += img_height + MARGIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interp->top_item_count > 0) {
|
||||||
|
y = add_text(width, y, s_transit_titles[0], s_font_subhead);
|
||||||
|
y = add_text(width, y, s_narratives[0], s_font_body);
|
||||||
|
}
|
||||||
|
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
|
||||||
|
y = add_text(width, y, s_guidance, s_font_body);
|
||||||
|
|
||||||
|
y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section);
|
||||||
|
if (interp->top_item_count == 0) {
|
||||||
|
y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body);
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < interp->top_item_count; i++) {
|
||||||
|
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
|
||||||
|
y = add_text(width, y, s_narratives[i], s_font_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
y = add_text(width, y, i18n_get("ui.celtic_cross", "Celtic Cross"), s_font_section);
|
||||||
|
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
|
||||||
|
const TarotDraw *draw = &reading->spread.positions[i];
|
||||||
|
|
||||||
|
snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s",
|
||||||
|
tarot_position_name((CelticCrossPosition)i, reading->gender), tarot_card_name(draw->card),
|
||||||
|
reversed_marker(draw->reversed));
|
||||||
|
y = add_text(width, y, s_position_titles[i], s_font_subhead);
|
||||||
|
|
||||||
|
s_slots[i].card = draw->card;
|
||||||
|
s_slots[i].y = y;
|
||||||
|
s_slots[i].height = card_image_height_for_width(IMAGE_WIDTH);
|
||||||
|
int16_t image_x = (width - IMAGE_WIDTH) / 2;
|
||||||
|
s_slots[i].bitmap_layer = bitmap_layer_create(GRect(image_x, y, IMAGE_WIDTH, s_slots[i].height));
|
||||||
|
bitmap_layer_set_alignment(s_slots[i].bitmap_layer, GAlignCenter);
|
||||||
|
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer));
|
||||||
|
y += s_slots[i].height + MARGIN;
|
||||||
|
|
||||||
|
y = add_text(width, y, tarot_position_description((CelticCrossPosition)i, reading->gender), s_font_body);
|
||||||
|
y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body);
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll_layer_set_content_size(s_scroll_layer, GSize(width, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void window_load(Window *window) {
|
||||||
|
s_font_section = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD);
|
||||||
|
s_font_subhead = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
|
||||||
|
s_font_body = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
|
||||||
|
|
||||||
|
Layer *window_layer = window_get_root_layer(window);
|
||||||
|
GRect bounds = layer_get_bounds(window_layer);
|
||||||
|
s_screen_height = bounds.size.h;
|
||||||
|
|
||||||
|
s_scroll_layer = scroll_layer_create(bounds);
|
||||||
|
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
|
||||||
|
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks){
|
||||||
|
.content_offset_changed_handler = scroll_offset_changed,
|
||||||
|
});
|
||||||
|
layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
|
||||||
|
|
||||||
|
build_content(bounds.size.w);
|
||||||
|
update_visible_images();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void window_unload(Window *window) {
|
||||||
|
teardown_content();
|
||||||
|
scroll_layer_destroy(s_scroll_layer);
|
||||||
|
s_scroll_layer = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void report_window_push(void) {
|
||||||
|
if (!s_window) {
|
||||||
|
s_window = window_create();
|
||||||
|
window_set_window_handlers(s_window, (WindowHandlers){
|
||||||
|
.load = window_load,
|
||||||
|
.unload = window_unload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window_stack_push(s_window, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void report_window_reload(void) {
|
||||||
|
if (!s_window || !s_scroll_layer) return;
|
||||||
|
Layer *window_layer = window_get_root_layer(s_window);
|
||||||
|
int16_t width = layer_get_bounds(window_layer).size.w;
|
||||||
|
teardown_content();
|
||||||
|
build_content(width);
|
||||||
|
update_visible_images();
|
||||||
|
}
|
||||||
|
|
||||||
|
void report_window_deinit(void) {
|
||||||
|
if (s_window) window_destroy(s_window);
|
||||||
|
s_window = NULL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#ifndef WATCH_UI_REPORT_WINDOW_H
|
||||||
|
#define WATCH_UI_REPORT_WINDOW_H
|
||||||
|
|
||||||
|
/* The app's only real screen: one continuously scrollable page mirroring
|
||||||
|
* the interpreter's own --format text/html report - day significance,
|
||||||
|
* each significant transit's narrative, the full Celtic Cross spread
|
||||||
|
* (each position's card art at the watch's own display width, name,
|
||||||
|
* description, and meaning), and the guidance paragraph last - see
|
||||||
|
* reading_state.h for where all of this data comes from.
|
||||||
|
*
|
||||||
|
* Card art is loaded lazily as it scrolls into view and freed once it
|
||||||
|
* scrolls back out - a full-display-width card image is too large to
|
||||||
|
* keep all ten resident at once on this app's RAM budget (see
|
||||||
|
* ui_report_window.c's own comment) - so unlike a plain ScrollLayer of
|
||||||
|
* static content, this needs report_window_push()'s window to actually
|
||||||
|
* be on-screen to drive that loading; call report_window_reload() (not
|
||||||
|
* push() again) to refresh already-built content after the day rolls
|
||||||
|
* over or new birth data is submitted. */
|
||||||
|
void report_window_push(void);
|
||||||
|
void report_window_reload(void);
|
||||||
|
void report_window_deinit(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#include "ui_text_window.h"
|
||||||
|
|
||||||
|
#include <pebble.h>
|
||||||
|
|
||||||
|
#define TITLE_HEIGHT 30
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char *title;
|
||||||
|
char *body;
|
||||||
|
TextLayer *title_layer;
|
||||||
|
ScrollLayer *scroll_layer;
|
||||||
|
TextLayer *body_layer;
|
||||||
|
} TextWindowState;
|
||||||
|
|
||||||
|
static void window_load(Window *window) {
|
||||||
|
TextWindowState *state = window_get_user_data(window);
|
||||||
|
Layer *window_layer = window_get_root_layer(window);
|
||||||
|
GRect bounds = layer_get_bounds(window_layer);
|
||||||
|
|
||||||
|
state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT));
|
||||||
|
text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
|
||||||
|
text_layer_set_text(state->title_layer, state->title);
|
||||||
|
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
|
||||||
|
layer_add_child(window_layer, text_layer_get_layer(state->title_layer));
|
||||||
|
|
||||||
|
GRect scroll_bounds = GRect(0, TITLE_HEIGHT, bounds.size.w, bounds.size.h - TITLE_HEIGHT);
|
||||||
|
state->scroll_layer = scroll_layer_create(scroll_bounds);
|
||||||
|
scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
|
||||||
|
|
||||||
|
/* Tall placeholder height so text_layer_get_content_size() below can
|
||||||
|
* measure the real wrapped height - standard Pebble ScrollLayer
|
||||||
|
* pattern (a TextLayer taller than its content just clips nothing). */
|
||||||
|
state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
|
||||||
|
text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
|
||||||
|
text_layer_set_text(state->body_layer, state->body);
|
||||||
|
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
|
||||||
|
|
||||||
|
GSize content_size = text_layer_get_content_size(state->body_layer);
|
||||||
|
content_size.w = scroll_bounds.size.w;
|
||||||
|
content_size.h += 4;
|
||||||
|
text_layer_set_size(state->body_layer, content_size);
|
||||||
|
scroll_layer_set_content_size(state->scroll_layer, content_size);
|
||||||
|
|
||||||
|
scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->body_layer));
|
||||||
|
layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void window_unload(Window *window) {
|
||||||
|
TextWindowState *state = window_get_user_data(window);
|
||||||
|
text_layer_destroy(state->title_layer);
|
||||||
|
text_layer_destroy(state->body_layer);
|
||||||
|
scroll_layer_destroy(state->scroll_layer);
|
||||||
|
free(state->title);
|
||||||
|
free(state->body);
|
||||||
|
free(state);
|
||||||
|
window_destroy(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
void text_window_push(const char *title, const char *body) {
|
||||||
|
TextWindowState *state = malloc(sizeof(TextWindowState));
|
||||||
|
memset(state, 0, sizeof *state);
|
||||||
|
state->title = malloc(strlen(title) + 1);
|
||||||
|
strcpy(state->title, title);
|
||||||
|
state->body = malloc(strlen(body) + 1);
|
||||||
|
strcpy(state->body, body);
|
||||||
|
|
||||||
|
Window *window = window_create();
|
||||||
|
window_set_user_data(window, state);
|
||||||
|
window_set_window_handlers(window, (WindowHandlers){
|
||||||
|
.load = window_load,
|
||||||
|
.unload = window_unload,
|
||||||
|
});
|
||||||
|
window_stack_push(window, true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#ifndef WATCH_UI_TEXT_WINDOW_H
|
||||||
|
#define WATCH_UI_TEXT_WINDOW_H
|
||||||
|
|
||||||
|
/* Pushes a scrollable full-screen text window: `title` as a bold header,
|
||||||
|
* `body` below it, word-wrapped and scrollable with the up/down buttons.
|
||||||
|
* Used for every screen that's just prose (Summary/Guidance, a single
|
||||||
|
* transit's narrative) - the Celtic Cross card screens use
|
||||||
|
* ui_card_window.h instead, which also shows the card's image.
|
||||||
|
*
|
||||||
|
* Copies both strings internally (into the pushed window's own heap
|
||||||
|
* state, freed when it's popped), so the caller's buffer doesn't need to
|
||||||
|
* outlive the call - every caller so far builds `title`/`body` in a
|
||||||
|
* short-lived local buffer right before pushing. */
|
||||||
|
void text_window_push(const char *title, const char *body);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#
|
||||||
|
# Pebble app build rules for Deck in a Dash.
|
||||||
|
#
|
||||||
|
# Unlike a typical `pebble new-project` skeleton, this app's C sources
|
||||||
|
# aren't all under src/c/ - most of the actual reading logic lives in
|
||||||
|
# ../engine/src/ (rng, tarot, tarot_data, astro, i18n, reading) and is
|
||||||
|
# shared byte-for-byte with the desktop deck-engine/interpreter-cli
|
||||||
|
# binaries (see the root CLAUDE.md's "Portability to the watch"
|
||||||
|
# section). build() below explicitly lists the engine source files this
|
||||||
|
# app needs and glues them onto src/c/**/*.c's own sources - it does NOT
|
||||||
|
# glob ../engine/src/*.c wholesale, because that directory also contains
|
||||||
|
# reading.c's desktop-only fprintf-based print functions (compiled out
|
||||||
|
# under #ifndef PBL_SDK_3, so harmless to include) but must never pull in
|
||||||
|
# engine/third_party/astronomy/ (the vendored high-precision ephemeris,
|
||||||
|
# ~127KB compiled - alone bigger than a whole Pebble app's 64KB
|
||||||
|
# code+data+bss budget). This app uses lowprec_ephemeris.c instead - see
|
||||||
|
# that file's own header comment.
|
||||||
|
import os.path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
top = '.'
|
||||||
|
out = 'build'
|
||||||
|
|
||||||
|
# Engine source files this app links in, relative to ../engine/src/ -
|
||||||
|
# see the module comment above for why this is an explicit list rather
|
||||||
|
# than a glob.
|
||||||
|
ENGINE_SRCS = [
|
||||||
|
'rng.c',
|
||||||
|
'tarot.c',
|
||||||
|
'tarot_data.c',
|
||||||
|
'astro.c',
|
||||||
|
'i18n.c',
|
||||||
|
'reading.c',
|
||||||
|
'lowprec_ephemeris.c',
|
||||||
|
]
|
||||||
|
|
||||||
|
# interpreter/'s significance/narrative/guidance scoring and text - see
|
||||||
|
# CLAUDE.md's "Interpretation" section. Unlike the desktop interpreter-cli
|
||||||
|
# (which deliberately keeps these header-only against the engine, for
|
||||||
|
# independent testability without a real ephemeris - see significance.h's
|
||||||
|
# own comment), the watch links their real .c implementations straight
|
||||||
|
# into the same binary as the engine above: there's no deck-engine/
|
||||||
|
# interpreter-cli JSON pipe to speak of on a single device, just one
|
||||||
|
# process computing and immediately rendering its own reading. json.c/
|
||||||
|
# reading_io.c (the JSON parser/loader) are never linked - the watch has
|
||||||
|
# no JSON to parse, it calls reading_generate() directly.
|
||||||
|
INTERP_SRCS = [
|
||||||
|
'significance.c',
|
||||||
|
'narrative.c',
|
||||||
|
'guidance.c',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def options(ctx):
|
||||||
|
ctx.load('pebble_sdk')
|
||||||
|
|
||||||
|
|
||||||
|
def configure(ctx):
|
||||||
|
ctx.load('pebble_sdk')
|
||||||
|
|
||||||
|
|
||||||
|
def build(ctx):
|
||||||
|
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang,
|
||||||
|
# resources/img/*.png from res/img/*.jpeg, and resources/app_icon.png
|
||||||
|
# from res/app_icon_50x50_64_color.png, before compiling/bundling
|
||||||
|
# anything - see each script's own doc comment. All three run eagerly
|
||||||
|
# (not as waf Tasks) and must run *before* ctx.load('pebble_sdk')
|
||||||
|
# below, since loading that tool in a build context immediately packs
|
||||||
|
# resources/ into the app bundle - on a fresh checkout with no
|
||||||
|
# generated resources/img/ yet, that packing step would crash before
|
||||||
|
# these scripts ever got a chance to create it. gen_card_images.py/
|
||||||
|
# gen_app_icon.py both skip regenerating anything already up to date,
|
||||||
|
# so this stays cheap on repeat builds. Requires Pillow (`pip install
|
||||||
|
# Pillow`) - see watch/README.md.
|
||||||
|
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
|
||||||
|
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
|
||||||
|
subprocess.check_call([sys.executable, 'scripts/gen_app_icon.py'])
|
||||||
|
|
||||||
|
ctx.load('pebble_sdk')
|
||||||
|
|
||||||
|
engine_dir = ctx.path.parent.find_dir('engine/src')
|
||||||
|
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]
|
||||||
|
interp_dir = ctx.path.parent.find_dir('interpreter/src')
|
||||||
|
interp_nodes = [interp_dir.find_node(name) for name in INTERP_SRCS]
|
||||||
|
|
||||||
|
binaries = []
|
||||||
|
cached_env = ctx.env
|
||||||
|
for platform in ctx.env.TARGET_PLATFORMS:
|
||||||
|
ctx.env = ctx.all_envs[platform]
|
||||||
|
ctx.set_group(ctx.env.PLATFORM_NAME)
|
||||||
|
app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
|
||||||
|
app_sources = ctx.path.ant_glob('src/c/**/*.c') + engine_nodes + interp_nodes
|
||||||
|
ctx.pbl_build(source=app_sources, target=app_elf, bin_type='app')
|
||||||
|
binaries.append({'platform': platform, 'app_elf': app_elf})
|
||||||
|
ctx.env = cached_env
|
||||||
|
|
||||||
|
ctx.set_group('bundle')
|
||||||
|
ctx.pbl_bundle(binaries=binaries,
|
||||||
|
js=ctx.path.ant_glob(['src/pkjs/**/*.js',
|
||||||
|
'src/pkjs/**/*.json']),
|
||||||
|
js_entry_file='src/pkjs/index.js')
|
||||||