diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..02ba5c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/node_modules/ +/mods/hearth/textures/ +/mods/hearth/models/ +*.log diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100755 index 0000000..afe5d7d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python-envs.pythonProjects": [ + { + "path": ".", + "envManager": "ms-python.python:system", + "packageManager": "ms-python.python:pip" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100755 index 0000000..38cd8f8 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,26 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Play The Last Hearth M1 course", + "type": "shell", + "command": "& \"$env:LOCALAPPDATA\\wikideck-runtime\\node-v24.22.0-win-x64\\node.exe\" scripts/run.mjs course", + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Play The Last Hearth", + "type": "shell", + "command": "& \"$env:LOCALAPPDATA\\wikideck-runtime\\node-v24.22.0-win-x64\\node.exe\" scripts/run.mjs play", + "isBackground": true, + "problemMatcher": [] + }, + { + "label": "Play expanded Last Hearth world", + "type": "shell", + "command": "$env:HEARTH_ENGINE = 'C:\\Users\\ermeehan\\AppData\\Local\\last-hearth-runtime\\luanti-5.18.0-win64\\bin\\luanti.exe'; $env:HEARTH_HOME = 'C:\\Users\\ermeehan\\AppData\\Local\\last-hearth-landscape-20261102-200501'; & 'C:\\Users\\ermeehan\\AppData\\Local\\wikideck-runtime\\node-v24.22.0-win-x64\\node.exe' scripts/run.mjs play", + "isBackground": true, + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index ec6eb1e..85d6e80 100755 --- a/README.md +++ b/README.md @@ -1,160 +1,333 @@ # The Last Hearth An original, standalone Luanti survival prototype: gather flint and sticks, make -tools, build a supported shelter, fuel a hearth, cook forage, and boil water. -This is an **environment simulation prototype, not a full physics simulator**. -It does not depend on Minetest Game (MTG), nor claim feature parity with another -survival game. Original game code and original procedural assets are MIT-licensed; -the separately distributed Luanti engine retains its own licenses. +tools, build a supported shelter, fuel a hearth, cook forage and boil water. +It does not depend on Minetest Game (MTG). This is an **environment simulation +prototype, not a full physics simulator or a launch-ready public service**. +Original game code and procedural assets are MIT-licensed; the separately +distributed engine retains its own licenses. -Season VI planning: [launch feature checklist](../../doc/season-vi-launch-features.md) -and [implementation roadmap](../../doc/season-vi-roadmap.md). The prototype is the -baseline, not a release candidate. +## Status and planning -## Local Windows launcher +**M0 technical gates are complete. M1 implementation/preflight and M2 gameplay / +verification are complete; their human acceptance gates remain open. M3 +implementation and automated verification are complete; its two-player acceptance +gate remains open.** M4 ecology, preservation, pottery, metalworking and bounded +offline process work has passed automated technical verification; its week-long +group acceptance remains open. M5 is now beginning with an offline-tested private +staging deployment contract, not a live deployment. See [M0 evidence](docs/m0-verification.md), +[M1 playtest gate](docs/m1-verification.md), [M2 evidence](docs/m2-verification.md), +[M3 implementation and verification](docs/m3-verification.md), and [M4 verification](docs/m4-verification.md). -Requires Node.js 22+ (built-in modules only) and the official Luanti 5.18.0 Windows -64-bit distribution already installed under -`%LOCALAPPDATA%/last-hearth-runtime/luanti-5.18.0-win64/`. -Its executable is `bin/luanti.exe` within that runtime. The previously verified -distribution archive SHA-256 is -`bebce52b58555d8208bf16767d0717788479fef23c35ec8d7bf287774d641835`. -This is an archive digest, **not** an executable digest. The launcher does not -download software or pretend to reverify the archive from its extracted files. -Keep the runtime directory writable only by trusted local users. +The newer living-ecosystem and botanical field-journal/art integration is an +additional implementation pass, **pending main integrated validation and native +visual review**. Historical M4 results do not certify this new pass. No version +or save-schema bump is part of the journal work. -From this game directory: +### Woodland traveler appearance + +Use `/character`, or **Character appearance** in the inventory, to choose muted +woodland clothing, skin, face, hair and details with three equal-mechanics body +profiles. Four tabs and a rotatable 3D model preview keep changes in a private +draft: **Save / Apply** persists them; **Cancel** keeps the original. Joining +restores saved appearance without prompting. Corrupt appearance data is retained +with a safe-default warning, never a kick. Existing location forms retain priority. +See [character creator and recovery contract](docs/character-creator.md). +Focused offline tests do not certify native visuals or client interaction. + +### Offline species reader + +Use `/inspect` while pointing at a known plant/tree within six blocks, or +`/inspect oak` / `/inspect Quercus robur` for an exact catalogue ID or Latin name. +Field notes include **Inspect target**; `/botany` offers a **Species article** +selector for each ten-row page. Harvesting and building controls are unchanged. + +The **Article** tab contains 78 cached plaintext Wikipedia introductions, labelled +**Wikipedia excerpt (offline)**, with copyable canonical/revision/history URLs, +contributor attribution and **CC BY-SA 4.0** licensing. The other two species have +explicit original CC0 availability notes, not counterfeit articles. **Current +effects** distinguishes simulated game policy from descriptive natural history. +This is not a full wiki, embedded browser, or real-world identification/foraging +guide. Nothing is fetched or automatically opened; inspection writes no world or +player data. Text is escaped and bounded to 8,000 characters, 4,000 per page. +See [source coverage, licenses and reader contract](docs/species-references.md). +Focused isolated tests cover the reader; native visual/client integration remains +pending. Full-engine tests were not run for this change. + +The [landscape/content expansion](docs/content-expansion.md) now wires ten +communities, ten tree species, an 80-species botanical catalogue (eight categories +of ten) and ordered geology into the seasonal ecosystem. The trees are oak, +birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce. All ten logs +fuel hearths for 180 seconds and craft four matching planks; buildings/planks +are not direct fuel. Copper charges/ingots/picks, copper smelting, coal fuel and +a three-quartz alternate window recipe are integrated; native validation of the +latest botanical pass remains pending. New launcher worlds use a broad-relief v7 preset with +a ±4096 bound; **existing saved mapgen/seed/bounds are not retuned**. Old chunks +are never regenerated; future unexplored chunks can change and leave seams. +Focused registration/tooling tests do not certify new-seed safety or engine visuals. + +**Human acceptance remains open across M1–M4.** The actual M1 course has not been evidenced as +traversed/repeated by several testers, and there are no measured FPS, frame-time, +multiplayer, latency or hardware-envelope results. M2 implementation is proceeding +under the user's explicit direction; this does not waive those gates or claim the +M2 unfamiliar-tester, M3 two-player cabin, or M4 week-long group gate has passed. + +- [Roadmap: M0–M6 and M1 acceptance](docs/roadmap.md) +- [Launch feature checklist: F01–F19](docs/launch-features.md) +- [M0 decisions, provisional budgets, risks and evidence](docs/m0-decisions.md) +- [Architecture and save/module contracts](docs/architecture.md) + +The exact development contract is **Luanti 5.18.0, game 0.5.0-m4, save schema 1**. +See [release.json](release.json) and [package.json](package.json). Native-first: +Windows is natively verified on the existing development host. Linux/macOS path +selection has unit coverage only; native execution is unverified on both. No clean +machine was tested. A browser client +is **not promised launch-ready**. There is no committed public launch date. + +## Run and test locally + +Requires Node.js 22+ (built-in modules only), a separately installed stock Luanti +**5.18.0**, and a trusted writable runtime directory. The launcher checks the +engine's reported version; it does not download the engine or authenticate it. +On non-Windows systems set `HEARTH_ENGINE` to the absolute executable path. On +Windows it defaults to the existing Luanti 5.18.0 win64 runtime's bin directory; +setting `HEARTH_ENGINE` explicitly also works there. A headless-only server binary +can run checks but cannot provide graphical play. + +From this repository, using Node on PATH: ```text node scripts/run.mjs check node scripts/run.mjs smoke node scripts/run.mjs test +node scripts/run.mjs test:seeds +npm test node scripts/run.mjs play +node scripts/run.mjs test:course +node scripts/run.mjs course ``` -- **check:** start a disposable headless engine world and compile all game and - test Lua files using the engine's Lua implementation. Loading the game also - catches startup errors. Standalone mocked tests are compiled, not executed. -- **smoke:** additionally verify the real `hearth` global, standalone content and - MTG absence, registered recipes and actual engine craft results, biome aliases, - and asynchronous map emergence with real registered terrain, required starting - resources, and functional enclosure checks. Model and content unit suites run - in isolated Lua environments: the mocked `core` and `os.time` never replace - globals in the running game. -- **test:** run both engine checks, including 19 content regressions and 11 - solar/climate/needs tests. No separate Lua interpreter is required. If - `HEARTH_LUA` is provided, also execute the standalone harnesses with that local - interpreter. It must be an absolute executable path. -- **play:** launch the graphical engine directly into a persistent singleplayer - world. This is the only command that opens gameplay; checks never launch it. - The normal engine camera, mouse and keyboard controls apply. Consult the - engine's Controls menu for bindings. +- **check:** disposable headless world, game startup and compilation of the Lua + sources/specs. Compilation alone does not execute the unit assertions. +- **smoke:** real-engine registry/crafting/terrain/resource/enclosure checks and + isolated execution of the discovered Lua unit specs. Mocked globals do not + replace globals in the live game. +- **test:** check plus smoke; with `HEARTH_LUA` set to an absolute Lua 5.1+/LuaJIT + executable, also runs the standalone Lua harnesses. No separate Lua is otherwise + required. Do not rely on historical test counts: record named suites/results. +- **test:seeds:** fresh real-engine worlds for eight fixed map seeds; checks safe + arrival surfaces and required starting resources without touching the play world. +- **M3:** structure removal is refused if it would strand connected construction. + `/claim share` manages household access and `/claim show` briefly marks boundaries. + See [M3 rules and remaining gates](docs/m3-verification.md). +- **npm test:** Node tooling tests only, not the engine suite. The `test:engine` + package script runs the engine suite; `test:recovery` is a separate, private + offline recovery exercise described below. +- **play:** persistent local singleplayer world. Uses normal engine controls. + The expanded v7 preset applies only at creation. Use a separate empty + `HEARTH_HOME` to try it without migrating/deleting the current world. See + [new-world settings and existing-world transitions](docs/content-expansion.md#broad-relief-v7-preset-new-worlds-only). +- **course:** a fresh disposable summer course; it never opens or changes + worlds/last_hearth. Wait for arrival, then run `/course` to generate the route + and receive a temporary kit. The course route covers half-steps, a one-wide + doorway, water, two controlled falls, gathering, construction and an arena. + Run `/course report` for checkpoint progress and server-step/Lua-callback + samples—**not** client FPS. `/spar` opts into a bounded moving sparring opponent; + `/practice` uses the stationary reference target. PvP remains disabled. +- **test:course:** native headless enrollment/staging and no-consent preflight. + It intentionally does not generate terrain, forge a player, or claim traversal. -The main game files must exist before these commands can run; incomplete parallel -development produces an explicit missing-file error, not a passing result. +Engine checks require zero exit status **and** a unique per-run success marker. +Their outer deadline is 90 seconds; standalone Lua invocations are bounded to +30 seconds. Graphical play has an eight-hour safety bound. Quit normally to save; +interrupts/deadlines kill the child and are not graceful-save guarantees. Session +configuration and logs are temporary and removed at session cleanup; retain +sanitized evidence separately when validating. See [scripts/run.mjs](scripts/run.mjs). -## Location, privacy and survival +## Data locations and isolation -## First play +`HEARTH_HOME` overrides the runtime root and must be absolute. Without it: -- Press **I** for the crafting grid and field notes; `/guide` reopens the guide. -- Gather flint, sticks, berries and thatching rushes. Make an axe to harvest logs. -- Build supported walls/roof and fuel a stone hearth with sticks or logs. -- Fill a carved cup at water, then use it on a fueled hearth before drinking. -- Hold **Aux1** (normally E) while moving forward to sprint. -- Use `/practice` in local play to place the opt-in sparring target. Strike to - start, step away to evade, or hold right-click with a spear to brace. -- `/claim` protects one homestead; `/claim remove` releases it. +| Platform | Default root (literal environment notation) | +| --- | --- | +| Windows | %LOCALAPPDATA%/last-hearth-runtime | +| Linux | $XDG_DATA_HOME/last-hearth-runtime, or $HOME/.local/share/last-hearth-runtime | +| macOS (runner unverified) | $HOME/Library/Application Support/last-hearth-runtime | -There is deliberately no automatically granted equipment, fast travel, PvP, -quest system, or downloaded mod collection. +The **old Windows runtime and world location remain unchanged**. Within the root, +the persistent world is under worlds/last_hearth and retained snapshots are under +backups. Each invocation packages a generated game into a temporary session's +games directory and sets `LUANTI_GAME_PATH`; it does **not** overwrite the engine +installation or its games. See [scripts/launcher.mjs](scripts/launcher.mjs). -### Known prototype limits +Use a **different absolute `HEARTH_HOME` for staging**, with an explicit +`HEARTH_ENGINE` when the engine lives outside it. Changing the root does not copy +or migrate the old world. Production must use a separate external deployment, +storage and configuration, outside this checkout, packages and webroots. The +local launcher is not a dedicated-server deployment tool. It binds loopback, +disables announcements, PvP and creative mode, and keeps mod security enabled. -Movement uses engine prediction and modest sprint overrides; this is **not** a -custom animated combat controller. The practice opponent is a stationary, -non-lethal sparring device, not wildlife AI. Tool silhouettes, material tiles and -the simple player mesh are original generated assets, not final character art. -Cooking consumes fuel immediately; needs and heat are game approximations. -Structural support is checked on wall/roof placement only; blocks do not collapse -when supports are removed, and generic terrain/planks are not structural physics. -No food spoilage, crops, insulation clothing, positional audio or animal hunting -is implemented. Atmospheric daylight approximates solar elevation through the -engine's fixed orbit; calendar labels are meteorological seasons. Real-world civil -timezones/DST display is not implemented: UTC and local solar time are explicit. +## Package, back up and restore -The launcher generates PNG textures and an OBJ mesh from original deterministic -code into its managed runtime copy. If installing elsewhere, first stage with -this launcher or call the exported asset generator for that destination. +Examples below use absolute Windows destinations; substitute your own trusted +paths (POSIX absolute paths on Linux/macOS). **Parents must already exist and +each destination must be new. No command overwrites an existing destination.** -### Location selection - -Optionally supply **both** `HEARTH_LATITUDE` and `HEARTH_LONGITUDE` as decimal -degrees before launch. The launcher validates latitude −90…90 and longitude -−180…180, then writes the game's `hearth_latitude` and `hearth_longitude` -settings. Coordinates are user-selected, not automatically detected. Neither -the launcher nor this setup uses IP geolocation or a remote location service. -Coordinates are stored in the local generated configuration; avoid publishing it. - -Without coordinates, use the singleplayer game's location prompt or `/location` -command. A server deployment must explicitly select its location rather than -silently assuming a player's location. This launcher is **not** a public server -deployment tool. Location controls the environment model's local solar time, -daylight, seasons and temperature; these are approximations, not live weather. -Disposable checks explicitly select a synthetic 45°N, 0°E test location when the -environment variables are absent; that fixture is never used for play. - -Damage and survival are enabled; creative mode and PvP are disabled. Needs do not -decay while the player is offline. Hearth fuel can still expire by real elapsed -time; that is distinct from offline player-needs decay. This prototype does not -provide accounts, credentials, federation, or public matchmaking. - -## Runtime storage and safety - -The launcher recursively copies the game into the runtime engine's -`games/last_hearth/` search directory, omitting `scripts`, `tests`, and `.git`. -It uses regular-file copying rather than `fs.cp` to support the repository's -Windows SMB location. Symlinks and junctions are rejected. Only copies marked as -launcher-managed can be replaced; an existing unmanaged installation is left -untouched. A runtime lock prevents simultaneous launchers from replacing a game -while it is running. After a crash, remove the lock only after confirming the -old launcher and engine have stopped. - -Persistent play data lives under -`%LOCALAPPDATA%/last-hearth-runtime/worlds/last_hearth/`. -Back up this directory before changing game versions. Launch configuration is -regenerated on every play run; environment settings are not silently inherited -from an old configuration. The launcher never deletes the persistent world. - -Checks stage their test mod under a disposable world's -`worldmods/hearth_smoke/init.lua`, then delete that world's files on completion -or failure. Test success requires **both** a zero engine exit status and a unique -per-run success marker. Lua assertions, startup errors, missing markers, process -failures, or deadlines cause a nonzero launcher exit. Engine checks are bounded -to 90 seconds each, their internal watchdog to 60 seconds, and standalone tests -to 30 seconds each. Play has an eight-hour safety limit; exit normally before it -to avoid a forced termination. Ctrl+C terminates the child process; normal in-game -Quit is preferable for saving a play session. UTF-8 output is streamed with a -bounded diagnostic tail; the persistent play log remains in the runtime root. - -Generated settings include: - -```ini -enable_damage = true -enable_pvp = false -creative_mode = false -bind_address = 127.0.0.1 -server_announce = false -enable_ipv6 = false -time_speed = 0 -movement_speed_walk = 4 -movement_speed_fast = 6 -movement_speed_jump = 6.5 -secure.enable_security = true +```text +node scripts/run.mjs package C:/HearthArtifacts/game-0.1.0-m0 +node scripts/run.mjs verify C:/HearthArtifacts/game-0.1.0-m0 +node scripts/run.mjs backup C:/HearthPrivate/worlds/last_hearth C:/HearthPrivate/backups/manual-001 --offline +node scripts/run.mjs restore C:/HearthPrivate/backups/manual-001 C:/HearthPrivate/worlds/restored-001 --offline ``` -Smoke/check use a randomly selected high UDP port, loopback binding, and zero -allowed users. No public bind, remote inputs, shell commands, credentials, or -trusted-mod security bypasses are configured. A port collision fails visibly; -it is never treated as success. Local filesystem permissions remain those of the -current Windows account; the launcher does not claim to provision Windows ACLs. +Without a destination, `package` uses the dist/last_hearth directory, still +requiring it not to exist. Packaging generates assets and includes release, +configuration, license, mod and JSON fixture inputs plus an exact SHA-256 +inventory. It is an engine-independent **game directory**, not an engine/client +bundle or installer; it excludes saves, tooling and the full test suite. +`verify` verifies a **game package**, not a world backup. Restore verifies a +backup before and after copying; [scripts/backup.mjs](scripts/backup.mjs) also +exports `verifyBackup()` for programmatic verification. + +**Before backup, restore, recovery, or launching an existing managed world, stop +ALL engines that could access it, including external engines on other machines.** +`--offline` is the operator's confirmation, not a mechanism that stops them. +Launcher/world locks and SQLite sidecar checks cannot prove remote idleness. +Do not delete locks/sidecars merely to bypass a refusal. Clear a stale launcher +lock only after confirming every relevant process has stopped. + +The launcher takes a full retained prelaunch snapshot of an existing managed +world; it has no automatic retention deletion. Backups include private map, +player, authentication, inventory and mod data: use access-controlled, preferably +encrypted storage, never a public artifact directory. Permissions remain the +operator's responsibility; portable file modes do not provision Windows ACLs. +SHA-256 detects **corruption, not authenticity**; it is not signing or encryption. +Preserve the original snapshot and restore to a new directory before considering +any operator-controlled cutover. See [scripts/package.mjs](scripts/package.mjs), +[scripts/files.mjs](scripts/files.mjs) and [scripts/backup.mjs](scripts/backup.mjs). + +### Private old-save recovery evidence + +The `test:recovery` package script runs [scripts/recovery.mjs](scripts/recovery.mjs). +Stop all engines and other Hearth tooling first. `HEARTH_RECOVERY_SOURCE` selects +an absolute existing SQLite world; otherwise it selects the legacy Windows world +under `LOCALAPPDATA`. **`HEARTH_HOME` changes recovery's working/backup root, not +that default source.** Non-Windows operators normally need an explicit source. + +Recovery retains an untouched snapshot, verifies byte-exact restores, boots only +instrumented disposable copies twice, and checks migration/restart behavior. +Temporary copies omit old worldmods and use synthetic staging coordinates. A +sanitized report is retained beside, not inside, the immutable snapshot. The +original world is never launched. The retained successful report records 79 seed +and 81 restart assertions, two byte-exact restores, two engine shutdowns and an +unchanged source. It tested an earlier package, not the final export; its source +had zero claims, so claim-owner/protection coverage uses regression fixtures and +mocks, not preservation of existing source claims. This is not a real-client +recovery playtest or public-service disaster recovery certification. See the +[recovery evidence and qualifications](docs/m0-verification.md#retained-recovery-evidence). + +## First play and current rules + +- **I** opens crafting and field notes; `/guide` reopens help. +- **Ecosystem survey** in the field journal, or `/ecosystem`, observes one small + loaded habitat patch and reports global active hare/deer/fox totals. It does not + grow plants, spawn animals, force-load terrain or change world state. +- **Botanical guide** in the journal/survey, or `/botany [search]`, browses the + actual catalogue, ten species per page. Search is literal, escaped and limited + to 48 characters; it includes common/scientific names, category, habitat, + seasons and ecological roles. Toxicity flags are game labels, **not real-world + food-safety advice**; unflagged does not mean edible. Missing catalogue APIs + display an unavailable message. See the [category list and role limits](docs/content-expansion.md#eighty-species-botanical-field-guide). +- Gather flint, sticks, berries and reeds. Make an axe to harvest timber; build + supported walls/roof and fuel a stone hearth with sticks/logs. +- Fallen logs are ordinary sideways full cubes. An axe yields **one canonical + log**, not two sticks gathered by hand. Existing full-block trees/deadwood are + retained; this journal/process pass does not replace their geometry. +- Use a cup on water, then use untreated water on a fueled hearth before drinking. +- **Aux1** (normally E) plus forward sprints; release after exhaustion and recover + stamina before sprinting again. FOV is unchanged by default; `/comfort fov 75` + applies a session-only static preference. +- `/practice` places a stationary target. In local play or privileged staging, + `/spar` opts into one moving opponent. Strike to begin a round; step back/aside + to evade or face the attacker and hold right-click with a spear to brace. +- The controlled route exists only in the disposable launcher-created course + world. `/course retry` restarts its run. A settings flag alone cannot authorize + course generation in a survival/production world. +- `/claim` protects one 25 × 25 area, extending ±24 vertically. `/claim share` + grants resident (build/use) or storekeeper (storage-only) rights; `/claim unshare`, + `/claim list`, `/claim show`, and `/claim remove` manage membership and bounds. + +Death preserves inventory, food, water and wetness; respawn only limits stamina +and warmth before safe home/fallback arrival. A home must be inside the player's +own claim and is revalidated at use. Offline player needs pause; hearth fuel and +queued cooking/boiling use real-time deadlines, while completed output waits for +collection. Food cooks in six seconds, water boils in four, and fuel cost is +reserved when a job starts. A craftable bedroll grants a small warmth recovery +after five uninterrupted sheltered seconds; it does not skip world time. +M4 adds seasonal crops and preservation; general food spoilage remains disabled. +PvP is disabled. See the [M2 rules and limits](docs/m2-verification.md) and +[M4 offline contract](docs/m4-verification.md). + +### A living landscape, a botanical field journal + +The [living ecosystem model](docs/ecosystems.md) is an original implementation +inspired by Dynamic Life's ecological ideas, not copied datapack code or assets. +Light, shade, moisture and competition shape meadow grass, wildflowers, ferns and +slow tree succession. Litter, fungi and dung recycle finite nutrients; grazing +opens gaps and wildlife links habitat to the food web. Wild succession is bounded, +loaded-only, pauses across unloading/restart and never replays an offline backlog. +Claims, placed plants, crops and construction are excluded from autonomous changes; +existing crop and planted-sapling clocks keep their separate rules. Survey counts +cover a fixed 5×5×5 patch at your feet, not the whole landscape; unloaded cells are +explicitly unknown. Wildlife totals describe all currently active animals, not a +local census or saved population. There is no compulsory ecological quest. + +The [moonlit botanical art direction](docs/art-direction.md) pairs original pixel +materials with the eom.dev Mocha interface: base `#1e1e2e`, mantle `#181825`, raised +`#292940`, border `#3c3d56`, pale `#cdd6f4` text and `#a6adc8` annotations. Mauve +`#cba6f7`, teal `#94e2d5` and yellow `#f9e2af` are restrained navigation, botanical +and warmth accents. The eight-page guide, botanical browser, recipes, satchel/crafting and location +forms share controls, dark fields, list colors, tooltips and pressed-sprig dividers. +HUD needs use labeled percentages and colored tracks on a dark panel rather than +ASCII bars. The ten-slot hotbar repeats the authored single-slot texture and uses +the authored selection outline; standard crosshair asset integration belongs to +the generated-art pass. Client scaling, contrast, long text and artwork still need +native visual acceptance; automated checks are not a claim of polished gameplay. + +### Location and privacy + +Optionally set **both** `HEARTH_LATITUDE` and `HEARTH_LONGITUDE` (−90…90 and +−180…180). Local worlds can instead use the prompt or `/location`; an unchosen +local world shows a disclosed neutral equatorial preview. There is no IP lookup +or external location service. Configured/stored coordinates are private data. + +Dedicated servers require explicit administrator-selected `hearth_latitude` and +`hearth_longitude`, even with a stored location. The community location is **not +chosen**: the user/operator must approve it before M2 climate staging. The M0 +45°N, 0°E synthetic fixture is **not a production location**. The host UTC clock +drives a synthetic seasonal model and an approximate fixed-orbit sky, not live +weather. Labels are UTC and local solar time, not civil timezone/DST. + +`HEARTH_FIXTURE` selects `first-session`, `summer`, or `winter` for isolated local +or staging work. First-session fixes seed 731104, not the date/location; summer +and winter fix UTC/weather/45°N, 0°E. Use a separate staging root/world; never +apply fixtures to valued play data. Production rejects preview/test settings. +See [tests/fixtures/scenarios.json](tests/fixtures/scenarios.json) and +[settingtypes.txt](settingtypes.txt). + +## Known limits, not fixes + +M1 code now implements smoothed stamina/sprint transitions, collision/course test +geometry, animated held tools, material feedback and moving sparring. Manual +feel/collision evaluation, voluntary repeat testing and performance/network +measurements remain open; see [M1 verification](docs/m1-verification.md). This +is not a custom engine/client controller; the newer bounded wildlife work is +documented separately above. Stock Luanti remains the +M1 baseline unless human measurements reveal a specific blocking engine limitation. +M2 replaced the old below-world teleport with safe bounded recovery. M3 now provides +bounded room/vent-aware comfort, a small building palette, refuse-unsafe-removal, +shared resident/storekeeper roles and protected storage. This is not a full structural +physics engine or persistent smoke simulation; two-player grief/comfort acceptance +is still open. Generated art remains procedural prototype art. Public operations, +moderation, identity and disaster-recovery acceptance remain later gates; no account, +browser parity or multiplayer-hardening approval is implied. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100755 index 0000000..8fa55e9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,157 @@ +# Architecture and module contracts + +Updated: 2026-11-02. Describes the actual M0–M3 source organization, **not proof +of human acceptance**. See [M0 decisions/evidence](m0-decisions.md), +[M1 evidence](m1-verification.md), [M2 evidence](m2-verification.md), [M3 evidence](m3-verification.md), +[roadmap](roadmap.md) and [operator procedures](../README.md). + +## Bootstrap and dependency direction + +[mods/hearth/init.lua](../mods/hearth/init.lua) creates `hearth.players` (validated +needs) and `hearth.sessions` (ephemeral UI/movement/timers), loads the pure model, +sets texture/notification helpers, obtains mod storage, loads the schema and +constructs the persistence adapter. It loads validated claims and wraps +`core.is_protected` while retaining the prior engine/mod protection function. + +The actual top-level order is: + +1. [model](../mods/hearth/model.lua) → [schema](../mods/hearth/schema.lua) → + [persistence factory](../mods/hearth/persistence.lua). +2. Claims/protection and `/claim` in init. +3. [content](../mods/hearth/content.lua), which constructs + [construction](../mods/hearth/construction.lua), + [interactions](../mods/hearth/interactions.lua) and + [processes](../mods/hearth/processes.lua) helper factories. +4. [interface](../mods/hearth/interface.lua) → [presentation](../mods/hearth/presentation.lua) + → [feedback](../mods/hearth/feedback.lua) → [clock](../mods/hearth/clock.lua) + → [environment](../mods/hearth/environment.lua) + → [players](../mods/hearth/players.lua) + → [runtime](../mods/hearth/runtime.lua) + → [encounters](../mods/hearth/encounters.lua) → [course](../mods/hearth/course.lua). + +UI/content callbacks reference later-installed APIs at invocation time. Do not +start scheduling needs before clock/environment/player lifecycle is installed. + +## Responsibility and API map + +| Module | Contract / boundary | +| --- | --- | +| [init](../mods/hearth/init.lua) | Bootstrap, shared globals, claim/home/access registries, role-aware protection wrapper, `/claim` membership and visible bounds; one 25 × 25 ±24-Y claim per owner. | +| [schema](../mods/hearth/schema.lua) | Pure `decode(kind, value)` returns schema-1 normalized envelope and migration boolean; `defaults()` returns a fresh needs copy. Kinds include needs, claims, homes, access, location and spawn. Access grants are limited to 16 valid player names per owner and roles `resident`/`storekeeper`. No JSON, I/O or engine. Reject unknown fields/kinds/versions and malformed/out-of-range data. | +| [persistence](../mods/hearth/persistence.lua) | Factory `(schema, core, storage)` → `load_world`, `save_world`, `load_player`, `save_player`. Owns serialization, legacy byte backups, readback checks, best-effort rollback/quarantine. World failures are fatal; callers must stop/kick on player failures. Access is an additive schema-1 world record; missing access defaults empty. | +| [model](../mods/hearth/model.lua) | Pure `location`, `solar`, `weather`, `needs`, `fuel_remaining`, `clamp`. Time is an input. Needs return a new table and danger contribution; elapsed needs step clamps to 0–2 seconds, not an offline catch-up engine. | +| [content](../mods/hearth/content.lua) | Declarative nodes/items/tools/recipes, terrain aliases/biomes/decorations, fire/bedroll callbacks, M3 building palette, claim-protected storage/drying rack and wearable cloak. | +| [construction](../mods/hearth/construction.lua) | Factory `(hearth, core)` returns `near`, action-aware `allowed`, `can_dig`, `place`, `place_building`, `owner`, `preview`, `removal_safe`. Six-node reach; support graph bounded to two horizontal links and sixteen vertical levels. Removal is refused if connected structures would lose support; unknown/unloaded and cross-household dependencies deny. No cascading collapse. | +| [interactions](../mods/hearth/interactions.lua) | Factory `(hearth)` returns `eat`: consume/replace a stack only when an applicable need can improve, update bounded session-owned needs. Not a new inventory persistence layer. | +| [processes](../mods/hearth/processes.lua) | Factory `(hearth, core, construction)` returns `use_fire`, `fire_tick`, `refresh_fire`, `fuel_left`, `ignite_fire` and `use_firestarter`. Access checks, real-time fuel/job deadlines, queue/reload reconciliation, one pending output and safe inventory collection. Cooking is six seconds; boiling is four; their heat cost is reserved on start. | +| [interface](../mods/hearth/interface.lua) | Inventory, searchable recipe/process guide, location forms, field handlers and HUD via `inventory`, `guide`, `recipes`, `location_form`, `make_hud`, `update_hud`. UI is not a substitute for server-side validation. | +| [clock](../mods/hearth/clock.lua) | Validate environment and preview settings, load/validate stored location even if overridden, enforce dedicated coordinates. Exports `set_location`, `clock_update`, `clock`, `weather`, `has_location`. Configured location takes precedence; singleplayer may use stored choice; unchosen local preview is disclosed. | +| [environment](../mods/hearth/environment.lua) | `environment(pos)` returns roof/shelter/enclosed/ventilated/open_sides/heat/smoke/rain/temperature. Flood-fill is bounded to 7 × 7 × 5 cells, cached for one second, and invalidated by room-geometry edits. Open door is an opening; a nearby roof vent exhausts smoke. Unknown/unloaded probes fail exposed. It is an approximation, not persistent smoke simulation. | +| [players](../mods/hearth/players.lua) | `player_join`, `player_leave`, `player_save`, `player_respawn`, `arrive`, `safe_arrival`: bounded emergence/recovery checks (fallback scans ±64 nodes), versioned homes, safe `/sethome` inside the owner's claim, home revalidation, safe-spawn scan and retained-inventory partial-needs death recovery. Clear sessions before kicking on load/save failure. | +| [runtime](../mods/hearth/runtime.lua) | Globalsteps coordinate sprint/brace speed, safe bedroll rest, cloak warmth/rain protection, sheltered heated/rack drying, 1-Hz needs/HUD/damage, ~10-second clock/save cadence and bounded local rain. Waiting/dead sessions do not accrue needs. | +| [movement](../mods/hearth/movement.lua) | Pure bounded-dt movement/stamina model; smooth walk/sprint/swim/brace speed and exhaustion release lock. Engine retains physical collision/camera/prediction. | +| [combat](../mods/hearth/combat.lua) | Pure server attack/guard validation: weapon-specific cadence, reach, aim, claims, stamina, line-of-sight and short displacement plausibility. Not a complete anti-cheat or lag-compensation system. | +| [presentation](../mods/hearth/presentation.lua) | Ephemeral whitelisted held-tool meshes attached to right hand; cleanup on weapon change, death, disconnect and shutdown. Cosmetic entities are not saved. | +| [feedback](../mods/hearth/feedback.lua) | Animated player state, material step/dig/landing and attack feedback, 10-Hz contextual target/placement HUD; static FOV is opt-in. | +| [encounters](../mods/hearth/encounters.lua) | Stationary reference and bounded opt-in moving sparring; deterministic wind-up/strike/recovery, safe steps, claim leash and no drops/lethal hits. Not wildlife AI or competitive netcode. | +| [course](../mods/hearth/course.lua) | Consent-gated local/staging-only fixed-volume movement route; mock generation/checkpoint tests and native no-consent preflight. `/course report` gives server-step/callback time, not FPS. | + +## Save and migration contract + +Hearth schema **1** is an envelope `{schema = 1, kind = ..., data = ...}` for +`needs`, `claims`, `homes`, `access`, `location` and `spawn`. Needs contain exactly food/water/warmth/ +wet/stamina, each 0–100. Coordinates are finite bounded values; claim positions +are integer nodes. Complete valid legacy unversioned records migrate; malformed +or unknown future records fail rather than resetting silently. + +| Storage owner | Current key / payload | +| --- | --- | +| Engine mod storage | `claims`, `homes`, `access`, `location`, `spawn` schema envelopes; legacy originals at `m0_backup:`. | +| Engine player metadata | `hearth:needs` envelope; immutable legacy bytes at `hearth:m0_backup:needs`; `hearth:arrived` remains a separate engine metadata flag. | +| Engine node metadata | Fire absolute `fuel_until`, job fields and pending output; structure owner and protected storage inventories; player metadata `hearth:cloak` stores cloak-worn state. These node/player fields are engine-owned and not envelope transactions. | +| Engine SQLite backends | World/map, player/auth/inventory data remain engine-managed; full world backup copies them rather than reimplementing their formats. | +| Memory only | Session HUD IDs, sprint/brace state, damage/save timers and arrival waiters; not durable schema records. | + +Adapter load/save validates actual JSON serialization, suppresses raw parser +logging, saves original legacy bytes before migration and verifies writes by +readback. Failed commit attempts byte-exact rollback; failed rollback quarantines +that store/key for the adapter lifetime. Player metadata wrappers are cached to +prevent bypassing quarantine. These are **not transactions, CAS, or guaranteed +crash-durable writes**. Missing needs alone get defaults; absent claims get an +empty registry; absent location/spawn remain unset. Invalid existing records do +not become defaults. Startup world errors abort; player errors clear the session, +emit sanitized diagnostics and kick rather than write over corrupt state. + +Full offline world snapshots complement per-key backups. Restore requires a new +destination and all writers stopped. No live-world migration/recovery success is +asserted without the [evidence ledger](m0-decisions.md#verification-and-acceptance-ledger). + +## Clock, fuel and current behavior preservation + +`model.solar()` derives UTC/date, local solar hour, seasonal temperature and a +disclosed engine-orbit approximation. Synthetic weather is deterministically +indexed to six-hour UTC fronts; it is not a remote forecast and can change +abruptly. Production follows host time. Fixed preview dates/weather are local or +staging-only; explicit dedicated coordinates are required even if storage has a +location. The 45°N, 0°E check fixture never selects a production location. + +Fuel and queued jobs use real `os.time()` rather than the preview clock: sticks add +45 seconds, logs 180, capped reserve 600. Fire requires a reusable firestarter; +fuel does not self-ignite. Cooking takes six seconds and reserves 20 seconds of +fuel; boiling takes four and reserves 10. M4 smelting takes 60 seconds, firing +pottery 120, drying food 300, and making charcoal 60; each reserves 30 fuel +seconds before consuming its charge. Absolute deadlines continue during server +absence, so fuel may expire, while completed output remains queued until collected. +Deadlines are validated decimal strings; invalid job metadata fails closed and +timer/LBM reconciliation commits overdue output once. Offline player needs pause. +M4 crop growth samples historical local solar climate in six-hour intervals, pauses +below 3°C, slows below 10°C and caps replay at 30 days. Berry bushes renew after +24 hours; planted saplings attempt bounded, claim-safe tree growth after three days. +Hares are passive, non-dropping, nonpersistent and capped. Food has no general +spoilage; dried rations are shelf-stable. Death preserves inventory and most needs, +limiting stamina/warmth on respawn. M3 room checks are bounded to 7 × 7 × 5 voxels, cached one +second and invalidated by room-node edits; support removal refuses dependent work +rather than cascading. Shared claims add 16-member resident/storekeeper roles, +protected storage, and structural dependencies cannot cross household boundaries. +See [M2 verification](m2-verification.md) and [M3 verification](m3-verification.md). + +## Tooling boundaries + +| Script | Contract | +| --- | --- | +| [run](../scripts/run.mjs) | CLI dispatch; validate exact engine before staging/world access; launcher lease; temporary isolated `LUANTI_GAME_PATH`; persistent SP world with prelaunch snapshot or disposable tests. Not production deployment. | +| [launcher](../scripts/launcher.mjs) | Platform roots/absolute overrides, engine-version check, coordinate/fixture/environment validation, prelaunch snapshot. `HEARTH_HOME` preserves the old default root unless explicitly overridden. | +| [package](../scripts/package.mjs) | Read release contract; copy fixed config/license/mod payload, generate assets, copy JSON fixtures, write/verify exact SHA-256 manifest. New directory only; no engine or saves. | +| [assets](../scripts/assets.mjs) | Original deterministic procedural PNG/OBJ generation into package payload, not downloaded media. | +| [files](../scripts/files.mjs) | Absolute safe paths, regular-file inventories including empty directories, link/junction/case-collision rejection, trusted-parent exclusive staging/rename, manifest and launcher leases. No hostile-parent/portable ACL guarantee. | +| [backup](../scripts/backup.mjs) | Offline world snapshot/verification/restore with disjoint new destinations and lock/SQLite sidecar refusal; caller must stop remote/external writers. SHA-256 integrity, not authenticity. | +| [recovery](../scripts/recovery.mjs) | Private old-save copy-only exercise: retained snapshot, two restores/engine boots, synthetic staging probe and sanitized report. Source world is not booted. No connected-client/public-readiness certification. | + +Package/backup formats are separate from save schema. The generated manifest +covers exact files/directories, not cryptographic publisher identity. Temporary +logs/config/worlds are cleaned up, retained snapshots are not automatically +pruned. Production data and backup custody remain operator responsibilities. + +## Verification boundaries and next work + +[Node tooling tests](../tests/tooling.test.mjs) and Lua +[schema](../tests/schema_spec.lua), [persistence](../tests/persistence_spec.lua), +[clock](../tests/clock_spec.lua), [model](../tests/model_spec.lua) and +[content](../tests/content_spec.lua), [access policy](../tests/access_spec.lua), +[room model](../tests/environment_spec.lua), [claim service](../tests/init_spec.lua), +[M2 timed processes](../tests/m2_processes_spec.lua), [M2 rest](../tests/m2_rest_spec.lua) +and [recipe index](../tests/recipe_spec.lua) +harnesses exist. The +[engine smoke mod](../tests/engine_smoke.lua) executes isolated Lua specs and +real registry/crafting/emergence checks; it does not play as a human client. +[Legacy fixture](../tests/fixtures/legacy-save.lua), +[scenario definitions](../tests/fixtures/scenarios.json) and +[recovery probe](../tests/recovery_probe.lua) support evidence collection, not +automatic milestone approval. + +Known limitations are deliberately assigned, **not reported fixed**: M1 human +feel/repeat/performance gate; M2 unfamiliar-player and operator acceptance, and +community-location selection before shared climate staging; M3 two-player cabin, +comfort and grief-resistance acceptance; M4 renewal/preservation and other offline +simulation; M5 operational hardening. M3 room and support rules are bounded +prototypes, not general physics, and operational/public-service readiness remains open. diff --git a/docs/art-direction.md b/docs/art-direction.md new file mode 100755 index 0000000..1fe666b --- /dev/null +++ b/docs/art-direction.md @@ -0,0 +1,983 @@ +# The Last Hearth — woodland travelers and botanical pixel catalogue + +## Authoritative character art contract (2026-11-03) + +This section supersedes earlier **player** appearance and package-total statements +below; botanical art itself is unchanged. Character work owns only +[scripts/media.mjs](../scripts/media.mjs), +[scripts/assets.mjs](../scripts/assets.mjs), +[scripts/character-art.mjs](../scripts/character-art.mjs), +[tests/character-art.test.mjs](../tests/character-art.test.mjs), +[tests/assets.test.mjs](../tests/assets.test.mjs) and this document. +No Lua, gameplay, collision, camera, engine, dependency or source-media edits. +All drawings are original procedural MIT work, with no downloaded art, network, +image service, third-party model, global tint or extra character-layer entities. + +### Woodland traveler grammar and unchanged UV contract + +Quiet mineral cloth, undyed linen, worn walnut leather and natural hair replace +the old flat blockperson skin. Small collars, rolled cuffs, stitched hems, +plackets, leather panels, coat openings, boot laces/soles and sparse linework give +the traveler a grounded, practical silhouette. Faces remain readable at eight +pixels: two iris landmarks, restrained brows, nose and mouth, no oversized white +cartoon eyes. Skin choices are independent of facial features and clothes. + +Every runtime layer is **64×64 straight RGBA**, nearest-neighbor, with **0/255 +alpha** and zero RGB behind transparent pixels. The atlas is still eight columns +of eight-pixel tiles, with the original half-texel UV inset. Tile coordinates: + +| Row | Column 0 | Column 1 | Column 2 | +| --- | --- | --- | --- | +| 0 | face | hair (head back/top) | side (both head sides) | +| 1 | tunic (torso front/ends/sides) | back | sleeve | +| 2 | trousers | boot | skin (hands/head underside) | +| 3 | metal | wood | wrap | + +The six base skins are opaque over **all 4096 pixels**, including a linen +underlayer and fallback trousers/boots. Skin tone changes exactly the four +exposed head/hand tiles, **256 pixels**, not the clothing or tool swatches. +Face overlays are skin-neutral lines, not light-colored face masks. Hair paints +the scalp, sides and hairline; long hair and braids also cover upper back and +shoulder pixels. Garments and accents paint only torso/back/sleeve tiles; +trousers and boots paint only their own tiles. No overlay touches tool tiles or +unused atlas space. The former metal/wood/wrap pixels are **byte-exact** in all +base skins and in the default **hearth_person.png**, so existing held-tool OBJ +materials keep their appearance. Existing tool geometry and icon art are retained. + +### Exact IDs and generated layer manifest + +`CHARACTER_OPTIONS` exports these frozen allowlists, without aliases: + +| Group | IDs | +| --- | --- | +| bodies | compact, balanced, broad | +| skin | porcelain, sand, honey, olive, umber, ebony | +| faces | calm, angular, soft, weathered | +| eyes | hazel, brown, green, blue, grey | +| hairStyles | cropped, sidepart, wavy, braids, long, shaved, none | +| hairColors | ash, flax, chestnut, auburn, black, silver | +| facialHair | none, stubble, moustache, short_beard, full_beard | +| garments | tunic, workshirt, jerkin, coat | +| clothColors | sage, teal, slate, mauve, ochre, rust, linen, charcoal | +| trousers | earth, slate, moss, charcoal | +| boots | walnut, black, tan | +| accents | none, scarf, apron, sash, vest | +| details | none, freckles, scar, freckles_scar | + +The **170** entries in `CHARACTER_ART_MANIFEST` use these exact filename +templates. Each row includes every Cartesian product, even transparent `none`: + +| Filename template | Count | +| --- | ---: | +| hearth_char_skin_<skin>.png | 6 | +| hearth_char_face_<face>.png | 4 | +| hearth_char_eyes_<eyes>.png | 5 | +| hearth_char_hair_<style>_<hairColor>.png | 42 | +| hearth_char_beard_<style>_<hairColor>.png | 30 | +| hearth_char_garment_<style>_<cloth>.png | 32 | +| hearth_char_trousers_<trousers>.png | 4 | +| hearth_char_boots_<boots>.png | 3 | +| hearth_char_accent_<style>_<accentColor>.png | 40 | +| hearth_char_detail_<detail>.png | 4 | + +Accent colors use the same eight `clothColors`; beard colors use the same six +`hairColors`. Exactly **21** images are intentionally empty: six hair-none, six +beard-none, eight accent-none and one detail-none. All **149 nonempty layers** +are distinct; equivalent empty-none color variants are deliberately identical. +Each manifest record includes `filename`, `kind`, `id`, `color` (null if absent), +`width`, `height`, `empty`, `license`, `author`. + +`generateAssets()` writes the layers into the staged texture directory, and a +root-level **character-art.json** provenance document with `format:1`, `options`, +`meshes` and `images`. All outputs use the existing guarded `writeAsset`, +`relativeName` and `safePath`; no source-worktree assets are generated. +The document and every layer/mesh are included in the existing SHA256 package +inventory. It adds **171 PNGs** (170 layers + portrait sheet) and **two B3Ds** +to previous totals: **576 fixed PNGs**, plus source-dependent compatibility +swatches, **37 OBJ + three B3D**. The two-swatch fixture totals **578 PNGs**. + +### Pure methods and Lua composition handoff + +[The renderer module](../scripts/character-art.mjs) exports: + +- `characterLayer(filename)` → fresh `{width:64,height:64,data:Uint8Array}`; + unknown filenames fail closed. `characterArt()` → deterministic filename Map + of all 170 fresh images. +- `characterLayers(options={})` → ten exact filenames in compositing order; + `characterTexture(options={})` → those names joined by `^`, ready for one + Luanti mesh texture. Only allowlisted fields/values are accepted; no path, + prototype key, inherited options object or arbitrary file input. +- `compositeCharacter(options={})` → pure fresh opaque 64×64 RGBA atlas using + the same crisp source-over order. No I/O or global recoloring. +- `characterMesh(body='balanced')` → exact mesh filename. +- `portraitSheet()` → opaque **512×64** RGBA contact sheet, eight assembled + 64px portrait cells. `CHARACTER_PORTRAITS` gives the exact left-to-right + selections. Portraits sample actual composited face, tunic, sleeve, skin, + trousers and boot tiles; they are paper dolls, **not atlas swatches or 3D + engine-render screenshots**. Staging writes **hearth_character_contact_sheet.png**. + +Selection fields and defaults (`DEFAULT_CHARACTER`): `body:balanced`, +`skin:sand`, `face:calm`, `eyes:hazel`, `hair:sidepart`, `hairColor:chestnut`, +`beard:none`, `garment:tunic`, `cloth:teal`, `trousers:earth`, `boots:walnut`, +`accent:scarf`, `accentColor:sage`, `detail:none`. The old **hearth_person.png** +is now this complete default composite, not a removed compatibility material. + +**Required left-to-right texture order:** skin → face → eyes → garment → +trousers → boots → accent → hair → beard → detail. In filename form: +hearth_char_skin_<skin>.png ^ hearth_char_face_<face>.png ^ +hearth_char_eyes_<eyes>.png ^ hearth_char_garment_<garment>_<cloth>.png ^ +hearth_char_trousers_<trousers>.png ^ hearth_char_boots_<boots>.png ^ +hearth_char_accent_<accent>_<accentColor>.png ^ +hearth_char_hair_<hair>_<hairColor>.png ^ +hearth_char_beard_<beard>_<hairColor>.png ^ hearth_char_detail_<detail>.png. +Remove the displayed spaces around carets when composing. Clothing **must precede +hair**, or long locks disappear under the upper-back/shoulder garment pixels. +Runtime appearance UI, persistence and attachment application belong to the Lua +owner; this work only supplies assets and the exact builder contract. + +### Mesh profiles, rig and attachment invariants + +`playerB3D(profile='balanced')` accepts exactly `compact`, `balanced`, `broad`. +Mesh filenames: **hearth_person.b3d** (balanced), **hearth_person_compact.b3d**, +**hearth_person_broad.b3d**. Each retains one textured mesh, **240 vertices, +120 outward-wound triangles**, rigid weight 1 per vertex, original UV assignments +and **17.5 model-unit height** (feet 0, head top 17.5 = 1.75 nodes). No collision, +camera/eye-height, reach, global scale or root-motion change. + +Balanced head changes from X ±1.75 / Z ±1.5 to **X ±1.6 / Z ±1.4**; +Y remains 13.5–17.5. Torso shoulder half-width 2.2 → **2.15**, half-depth +1.25 → **1.18**; bottom waist is 86% width/depth. Arms half-width .85 → **.78**, +depth .9 → **.84**, tapering to 90% width/95% depth at the wrist. Legs half-width +.95 → **.91**, depth 1 → **.95**, tapering to 90% width/94% depth at the ankle. +Normals are recomputed from actual tapered face planes. Hands, boots and all +vertical extents stay unchanged. These are subtle tapered ten-part solids, not +additional joints or extra geometry. + +Compact/broad multiply torso, upper-arm and leg widths by **.92 / 1.08**. +Broad uses a slightly fuller 90% waist width; all waist depths stay 86%. +Head, hands, boots, depths, height and rig pivots remain identical across profiles. +No profile moves an attachment hand to accommodate a wider body. + +All nine bones, hierarchy and bind positions remain exactly unchanged: `Root`, +`Body`, `Head`, `Arm_Right`, `Hand_Right`, `Arm_Left`, `Hand_Left`, `Leg_Right`, +`Leg_Left`. Arm pivots X ±3.1/Y13; hands local [0,-4.8,0] under their arms. +All bones retain the exact 100 keyed poses, B3D frames 1–100 / engine 0–99. +Clips unchanged: idle 0–19@12, walk 20–39@24, run 40–59@32, strike 60–69@28, +guard 70–79@12, swim 80–99@20. Held grip stays at `Hand_Right`, local origin, +rotation {x=90,y=0,z=0}; the shared tool atlas is retained. + +### Validation and security boundary + +CodeGuard was read and applied: no credentials, secrets, certificates or crypto +protocols introduced; existing SHA256 stays integrity-only. Positive enum/key +validation, bounded allocations, pure offline drawing and existing link/junction +write refusal are retained. No shell/eval, new dependencies or downloads. +Tests independently parse generated PNG CRC/data and all three B3D chunk trees, +check every exact option, semantic mask, all 170 encoded layers, 149 nonempty +distinct images, meaningful skin differences, 21 intentional empty layers, +paper-doll samples, deterministic regeneration/package hashes, rig/keys/weights, +head/waist geometry and byte-exact held-tool swatches. +No engine launch, client rendering, FPS, collision-course or live-world claim. + +Final focused character + asset run: **24 passed, 0 failed, 0 cancelled, +0 skipped**, exit 0, empty stderr, **65.60 seconds**, Windows Node **24.22.0**. +All five JavaScript syntax checks passed. All six owned files were read back +from disk for trailing whitespace and completion; the eight-portrait sheet was +visually reviewed. Optional full audio codec regeneration was not configured; +existing audio framing/CRC/hash checks passed. Directory-junction refusal passed; +the file-symlink subcase could not run without Windows symlink privilege. +Git diff checking was unavailable from this subprocess environment, so no Git +diff-clean claim is made. Runtime Lua appearance integration is not tested here. + +## Authoritative catalogue-80 art contract (2026-11-02) + +This section supersedes all folded historical counts and mesh integration advice. +Only [scripts/world-art.mjs](../scripts/world-art.mjs), +[scripts/flora-art.mjs](../scripts/flora-art.mjs), +[scripts/assets.mjs](../scripts/assets.mjs), +[tests/world-art.test.mjs](../tests/world-art.test.mjs), +[tests/flora-art.test.mjs](../tests/flora-art.test.mjs) and this document are owned. +The actual [botany catalogue](../mods/hearth/botany.lua), +[biomes](../mods/hearth/biomes.lua) and +[diversity](../mods/hearth/diversity.lua) were read, never edited. No engine tests +or client launch are part of this art work; runtime cube/season closure belongs +to the content and seasons owners. + +### Original botanical grammar + +All art is original procedural drawing under the [MIT license](../LICENSE.txt). +No copyrighted third-party images, downloads, fonts, random noise, dependencies +or generated-image service. Every specimen is drawn at **32×32**, straight RGBA, +nearest-neighbour with **crisp 0/255 alpha**, zero hidden RGB and one-pixel empty +borders. Small natural sage, bark, buff and mineral palettes dominate; flowers, +fruit and reproductive details are restrained accents. These are stylized field +journal specimens, **not identification, toxicity or eating advice**. No plate, +meal, bite, food badge or edibility permission is depicted, including toxic taxa. + +- **Trees**: oak rounded lobes and stalked acorns; birch serrate diamonds and + catkins; pine long paired needle sprays; willow pendant narrow blades; alder + round notched leaves and conelets; beech oval parallel-veined leaves; aspen + scalloped round leaves and petioles; rowan pinnate leaflets and clustered pomes; + hazel heart-shaped leaves and husked nuts; spruce short radial needles and long + pendant cones. All ten get inventory/fieldguide specimens independently of cubes. +- **Shrubs**: low branching twig clusters, arching thorny bramble/rose canes, + upright raspberry, opposite compound elder, rigid blackthorn, lobed hawthorn, + spiny evergreen holly, juniper needle whorls, three-lobed guelder rose. Fruit + attachment, clusters and leaves differ, not just hue. +- **Grasses and graminoids**: dense tussocks below diagnostic panicles/spikes; + sedge has angular triangular culm faces, reedmace cylindrical brown heads, + cock's-foot one-sided clumps, timothy a tight long spike, vernal-grass short + golden ovals, Yorkshire-fog soft pink panicles, hair-grass airy rays, reed tall + jointed stems and broad blades, rush lateral inflorescences on smooth culms. +- **Herbs**: trifoliate clover and stolon, serrate opposite nettle, heather scale + sprays, one-sided bluebell bells, yarrow flat corymb and divided leaves, + foxglove tubular bells and rosette, meadowsweet cream froth, garlic star umbel, + cowslip nodding yellow tubes, anemone single flower over divided-leaf whorl. +- **Ferns/mosses**: triangular three-part bracken, fine lady-fern, robust male-fern, + entire hart's-tongue straps, polypody pinnae/sori versus low moss cushions: + sphagnum capitula, haircap shoots/capsules, plait-moss feathers, swept fork-moss + leaves, broad-leaved star-moss shoots. They do not share a generic bush glyph. +- **Fungi**: warted fly-agaric, scabrous birch-bolete stem, chestnut bay cushion, + folded chanterelle funnel, lilac blewit, shaggy cylindrical inkcap, banded turkey + tail shelves, lateral oyster fans, tufted ringed honey caps, kidney polypore. + No invented puffball is substituted: none occurs among these ten catalogue IDs. +- **Aquatic**: duckweed small paired fronds/roots; **round or oval notched lily + pads**, yellow cup versus many white tepals (not lotus horns); crowfoot divided + foliage, mint globe, forget-me-not curled cyme, brooklime raceme, starwort + terminal rosette, rootless hornwort forked whorls, long-petioled pondweed ovals. +- **Lichens**: fruticose reindeer forks, flattened oakmoss straps, hanging beard + filaments, map-lichen crust islands, broad dog-lichen lobes/rhizines, lungwort + reticulation, orange sunburst discs, grey hammered shields, distinct slender + mealy and stout pebbled pixie cups. Crust, foliose and branched forms differ. + +Inactive images are **per ID**, not one shared dormant swatch: blooms/fruit are +omitted, leaves reduced or twigs exposed, fungi collapse to stems/shelves, growth +settles into a smaller dry silhouette. Persistent evergreen/moss/lichen images +also have an explicit inactive fallback, but **do not imply those species must +become dormant in Winter**; the seasonal registrar decides when each is used. + +### Exact image and provenance manifests + +`BOTANY_CATALOGUE` lists **80 unique exact IDs and 80 unique Latin names**, +transcribed from the actual Lua `add()` records, in catalogue order. Eight groups +of ten: trees, shrubs, grasses, herbs, ferns_mosses, fungi, aquatic, lichens. +Each record includes `id`, `latin`, `group`, diagnostic `morphology`. +`BOTANY_ART_NAMES` contains those exact 80 IDs; `BOTANY_SPRITE_NAMES` expands +them to **160** stems. Each has prefix `hearth_botany_` and PNG extension: +the ID alone for active art, the ID plus `_dormant` for inactive art. + +`BOTANY_ART_MANIFEST` annotates **each of the 160 PNG outputs** with exact +`filename`, `id`, **`botanicalLatin`**, `group`, `morphology`, `state`, dimensions, +license and authorship. Generation writes this deterministically as a root-level +JSON provenance document named **botany-art.json** inside the private output tree. +It is included in the hash-verified package. No Lua evaluation, source-loop +guessing, network or platform-specific botanical database is needed. Tests parse +the actual plain Lua catalogue independently and compare every ID/Latin pair. + +`worldArt()` now returns **360 distinct images**: **160 world/ecology tiles, +34 item icons, three UI tiles, three animal atlases, 160 catalogue specimens**. +This adds **192** images to the previous 168: twenty new tree faces, twelve new +deciduous seasonal leaf faces, and 160 catalogue specimens. + +Generated package outputs: **405 fixed PNGs**, plus source-dependent compatibility +swatches. The minimal fixture has two swatches, thus **407 PNGs**. The fixed set +is 360 sprites + **38 opaque 64×32 atlases** (four generic, 34 species) + contact +sheet + four tool icons + player skin + crosshair alias. There remain **29 legacy +flora OBJ files**, **37 OBJ total plus one animated player B3D**. No new tree +meshes were authored; player, tools, wildlife and fifteen Ogg files are unchanged. + +### Exact ten-tree cube material and season contract + +All stems in this table take prefix `hearth_` and PNG extension. Each row +explicitly gives log end, log side, leaves and crafted boards in that order. + +| Species / botanical identity | Log top | Log side | Leaves | Planks | Additional leaf seasons | +| --- | --- | --- | --- | --- | --- | +| oak / Quercus robur | oak_log_top | oak_log_side | oak_leaves | oak_planks | spring, autumn, winter | +| birch / Betula pendula | birch_log_top | birch_log_side | birch_leaves | birch_planks | spring, autumn, winter | +| pine / Pinus sylvestris | pine_log_top | pine_log_side | pine_leaves | pine_planks | none; evergreen | +| willow / Salix alba | willow_log_top | willow_log_side | willow_leaves | willow_planks | spring, autumn, winter | +| alder / Alnus glutinosa | alder_log_top | alder_log_side | alder_leaves | alder_planks | spring, autumn, winter | +| beech / Fagus sylvatica | beech_log_top | beech_log_side | beech_leaves | beech_planks | spring, autumn, winter | +| aspen / Populus tremula | aspen_log_top | aspen_log_side | aspen_leaves | aspen_planks | spring, autumn, winter | +| rowan / Sorbus aucuparia | rowan_log_top | rowan_log_side | rowan_leaves | rowan_planks | spring, autumn, winter | +| hazel / Corylus avellana | hazel_log_top | hazel_log_side | hazel_leaves | hazel_planks | spring, autumn, winter | +| spruce / Picea abies | spruce_log_top | spruce_log_side | spruce_leaves | spruce_planks | none; evergreen | + +Season images append `_spring`, `_autumn`, `_winter` to the **leaf stem**. +Beech/aspen/rowan/hazel add twelve seasonal faces; spruce is evergreen like pine. +Forty base faces plus 24 seasonal leaf faces are explicit outputs. New bark +conveys smooth grey beech, diamond-scarred pale aspen, lenticellate rowan/hazel +and scaly spruce; endgrain is eccentric rings and boards show restrained joints. +All older generic/species leaf drawing code and encoded PNG bytes are preserved; +tests pin the six approved base leaf PNG hashes, plus the original generic RGBA +SHA256 `6ca3e11b7e8e98f46220db92a4dbe729515835c3ae39c76ac3003fa5494f3f4c`. + +**New roots/stages use cubes**, no irregular canopy OBJ. Log face order is top, +top, side, side, side, side; existing `facedir` controls axis orientation. Each +seasonal woody-stage copy must retain that cube tile list and must not switch to +an atlas/mesh. Canopy cubes use the species/season leaf faces; tree seedlings, +inventory and guide can use catalogue sprites. Runtime geometry, migration and +seasonal swaps are other owners' work, not implemented by this art generator. + +`LEGACY_MESH_SPECIES` freezes the original five species. Existing generic and +species OBJ exports are **deprecated compatibility assets only**, retained until +the native-closure owner excludes unused files. Do not infer use from packaging +or add five new sculptural models. `SPECIES_ATLAS_NAMES` has all ten species: +eight deciduous × four seasons + two evergreen × one = 34. Generic four + 34 = 38. +New atlases are compatibility/review bytes, **not cube stage tiles**. Each keeps +own bark byte-exact at left and own seasonal foliage at right with opaque +underpaint. Evergreen non-summer atlas requests fail closed. + +### Bounded sorted review layout + +`contactSheet()` accepts **1–512** validated RGBA 32px tiles: **16 columns**, +512px wide, height `ceil(count/16)*32`, maximum 1024px. Current **360-image +512×736** sheet has **23 rows**, eight unused final cells. Native pixels are not +resampled. Full filenames sort in codepoint order regardless of Map insertion +order; zero-based sorted index `i` maps to column `i%16`, row `floor(i/16)`. +Cells are composited on the journal checker, not opaque placeholder squares. + +This formula and the complete named/Latin manifest are the authoritative legend, +not an obsolete manually maintained 10-column table. A separate botanical review +can select the 160 `hearth_botany_` images: the same sorted 16-column layout makes +**512×320** (ten rows), placing active and dormant specimens next to each other. + +### Safety and validation boundary + +Every PNG, retained OBJ, review sheet and provenance output passes the existing +`writeAsset` / `relativeName` / `safePath` path guards in private staging. Links +and junctions are refused; tile count, dimensions and decode allocations are +bounded. Source scanning remains mod-Lua-only. No secrets, cryptographic protocol +or external certificates are introduced; SHA256 is package integrity, not trust. + +Focused Node suites check the actual 80 ID/Latin pairs, 160 unique images, +80 unique active and 80 unique inactive alpha masks, palette/occupancy/padding, +diagnostic botanical landmarks, preserved leaf PNGs, all cube faces and atlases, +determinism, independent PNG CRC/decode, exact package bytes and output link +refusal. Existing legacy topology/UV budgets remain checked without claiming the +models are used. **No engine rendering, mapgen, seasonal runtime closure or +performance certification is claimed.** + +Final focused run: **39 passed, 0 failed, 0 cancelled, 0 skipped**, exit 0, +empty stderr, **80.84 seconds**, Windows Node **24.22.0**. All three asset suites +were included; optional external audio codec regeneration was not configured. +The **512×736** complete sheet and **512×320** botanical sheet (also enlarged +nearest-neighbour 2× for inspection) were generated under the system temporary +review directory. Windows directory-junction refusal and exact fixture-package +bytes passed. Existing leaf PNG fingerprints passed without changing their art. + +
+Historical art milestones — superseded counts and tree-mesh advice + +# The Last Hearth — moonlit botanical field journal + +## Current botanical extension — authoritative contract (2026-11-02) + +This section supersedes the folded historical snapshot below. Only +[world-art.mjs](../scripts/world-art.mjs), +[flora-art.mjs](../scripts/flora-art.mjs), +[assets.mjs](../scripts/assets.mjs), +[world-art.test.mjs](../tests/world-art.test.mjs), +[flora-art.test.mjs](../tests/flora-art.test.mjs) and this document are owned. +The exact runtime contracts were read from +[biomes.lua](../mods/hearth/biomes.lua), +[diversity.lua](../mods/hearth/diversity.lua), +[world-generation.md](world-generation.md) and [ecosystems.md](ecosystems.md). +No gameplay, engine, package metadata or live-world edits are included. + +### Original realistic botanical pixel grammar + +All drawings and sculptures are original procedural source under the project's +[MIT license](../LICENSE.txt). No downloaded assets, image/model libraries, +network calls or random/hash noise. Mocha journal shadows support muted natural +mineral, loam, sage and timber colours; flowers and metal highlights remain small. +These are stylized natural materials, not photographic scans or plant-ID advice. + +- **Base stone is unjointed parent rock**: irregular connected mineral microfacets, + not masonry, mortar outlines or a brick tessellation. Granite has feldspar and + quartz grains; basalt tight dark vesicles; limestone pale fossil/lens marks; + slate oblique cleavage; sandstone interrupted bedding and compact grains. +- **Soil is granular aggregate**: small discontinuous compacted lenses, crumbs and + organic marks, never full-width cartoon stripes. Forest floor has fragmented + litter, podzol needle litter, alluvium fine sediment/rounded grains, peat dark + organic fibres, alpine turf low mineral-interspersed growth. +- **Grass top is dense, non-symbolic tuft mosaic** across the whole surface, not + isolated outlined blades. Grass side has a variable organic cap and fine roots + into the same mineral soil body. Sand uses low-contrast ripples; gravel has + rounded clasts, scree angular fragments, snow sparse compressed wind marks. +- **Bark has wandering interrupted fissures** and branch scars, not repeated + straight planks. Endgrain rings are eccentric, warped and split. Birch is pale + with dark horizontal lenticels; pine scaly warm bark; willow fine dark furrows; + alder compact gray-brown bark. Planks remain visibly crafted with subdued joints + and flowing grain. Only foundations/weirs and named rock bricks use masonry. +- The approved generic **leaf texture is byte-for-byte unchanged**. Its RGBA + SHA256 is `6ca3e11b7e8e98f46220db92a4dbe729515835c3ae39c76ac3003fa5494f3f4c`. + New species leaves have distinct shapes: lobed oak, small airy birch, pine + needle sprays, narrow hanging willow and rounded alder. Spring adds sparse buds; + Autumn copper/ochre/wine; Winter open woody forks with no green leaf fill. +- Copper seams show oxidized green beside dull copper; quartz crystalline milky + facets; coal broken dark seams. Resource and tool icons have their own padded + silhouettes. The copper charge retains ore with the existing binding/tag grammar. + +Every drawing is native 32×32 straight-alpha RGBA, nearest-neighbor, with an +explicit restrained palette. Natural rock/substrate and ore-host faces are opaque; +leaves/understory/items use crisp 0/255 alpha and zero hidden RGB. Item borders +remain empty. Existing window alpha-95 glass, water seams and legacy interfaces +are retained. Sand is horizontally seamless; water is seamless on both axes. +Different materials use different geometry rather than one recoloured tessellation. + +### Exact output inventory + +`worldArt()` returns **168 distinct 32×32 images**: **128 world/ecology tiles, +34 item icons, three UI tiles and three animal atlases**. The increase from 95 is +73 sprites: 41 biome faces, 12 deciduous seasonal leaf faces, 14 understory sprites +and six resource/tool icons. `WORLD_NAMES`, `BIOME_WORLD_NAMES`, `SPECIES_NAMES`, +`ROCK_NAMES`, `SPECIES_SEASONAL_NAMES`, `UNDERSTORY_NAMES`, +`DORMANT_UNDERSTORY_NAMES`, `PLANT_NAMES`, `ITEM_NAMES` are explicit bounded manifests. + +All stems below take `hearth_` plus the PNG extension: + +- For each of granite, basalt, limestone, slate, sandstone: the rock stem and + its `_bricks` stem (10); forest_floor, podzol, alluvium, peat, alpine_turf, + gravel, scree, snow (8); quartz_vein, copper_ore_vein, coal_ore_vein (3). +- For each oak, birch, pine, willow, alder: `_log_top`, `_log_side`, `_leaves`, + `_planks` (20). These **41 faces plus item_quartz, item_copper_ore, item_coal** + implement the exact 44-PNG biome contract, not aliases or compatibility swatches. +- Deciduous oak/birch/willow/alder `_leaves_spring`, `_leaves_autumn`, + `_leaves_winter` (12). Pine has no seasonal leaf/atlas/mesh duplicates. +- clover, nettle, heather, bluebell, sedge, bulrush, moss, lichen; the first six + also have `_dormant` (14). Dormant sprites lose flowers/green and reduce growth; + bluebell reuses the dormant sprite outside Spring. Moss/lichen remain persistent. +- Additional `item_copper_ingot`, `item_copper_charge`, `item_copper_pick` (3). + Quartz crystal, copper ore, coal, ingot, bound charge and pick are six distinct + icons, including when generated from a minimal source with no asset references. + +`generateAssets()` additionally writes **21 opaque 64×32 tree atlases** (four +generic plus 17 species), the **320×544 contact sheet**, four existing tool icons, +the 64×64 player skin and engine crosshair alias. Thus there are **196 named PNG +outputs plus the separately source-dependent compatibility swatches**. No output +discovery depends on expanding Lua loops. Existing sound/player/tool generation +and fifteen Ogg files remain unchanged. + +`FLORA_NAMES` contains **29 OBJ meshes**: six retained generic flora models plus +23 species models. Together with three animals, four held tools and the fallback +player this is **37 OBJ files**, plus the existing animated player B3D. + +### Species sculpture, scale and seasonal contract + +All flora uses 10 model units/node, node-centre origin, ground exactly Y=-5 +model units (-0.5 nodes), `visual_scale=1`. Mature height is at most five nodes +and horizontal footprint at most ±1.5; young forms fit 2.5 high and ±0.75. +Seedlings are below one node high and ±0.3. Collision/placement/growth policy is +the ecosystem owner's responsibility, not inferred from the visual canopy. + +Oak has broad spreading forks and an asymmetric multi-lobed crown. Birch has a +slim elevated bole and separated small airy lobes. Pine has tapered branch whorls +and a narrow leader, with daylight between tiers. Willow has long descending +terminal wood and six pendant leaf curtains under a spreading crown. Alder has +multiple upright leaders and a compact rounded riparian crown. These are closed +volumes, not crossed planes, leaf billboards or oversized cubes. + +For every species generate `_seedling`, `_young`, `_mature` OBJ stems. Deciduous +species also generate `_young_winter`, `_mature_winter`; all original woody parts +are retained exactly and all foliage omitted. Seedlings retain their ordinary +mesh in Winter as runtime specifies. Pine stays evergreen. Generic deadwood stays +shared; species provenance is metadata, not extra deadwood art. + +| Species | Seedling vertices / triangles | Young and mature vertices / triangles | Each winter young/mature | +| --- | --- | --- | --- | +| Oak | 84 / 152 | 332 / 596 | 176 / 308 | +| Birch | 84 / 152 | 300 / 540 | 144 / 252 | +| Pine | 110 / 200 | 546 / 988 | none | +| Willow | 84 / 152 | 558 / 1004 | 272 / 476 | +| Alder | 84 / 152 | 274 / 492 | 144 / 252 | + +`SPECIES_MESH_NAMES` and `SPECIES_ATLAS_NAMES` enumerate exact outputs. Each +species uses its own `_atlas` PNG; deciduous trees additionally use +`_atlas_spring`, `_atlas_autumn`, `_atlas_winter`. `treeAtlas(art, season, species)` +preserves the original two-argument API. Unsupported species/seasons fail closed; +seasonal pine requests are rejected. Left half is own-species bark, byte-identical +through seasons; right half own-season foliage over opaque semantic underpaint. +Half-texel inset UVs and explicit outward normals are retained. Winter faces sample +only bark; canopy holes are gaps between lobes, never torn transparent faces. + +### Sorted contact-sheet legend + +`contactSheet()` sorts by complete filename using codepoint order, independent of +input Map insertion order. It accepts **1–320 validated 32px RGBA tiles**, always +10 columns, height `ceil(count/10)*32` (maximum 1024px). The current 168-tile sheet +is **320×544** with two unused checker cells. It contains every sprite, not the +separate 64×32 tree atlases. Native pixels are not resampled. Rows and columns +below are one-based; stems retain `item_` and omit only `hearth_` / PNG extension. + +| Row | Columns 1 → 10 | +| --- | --- | +| 1 | alder_leaves · alder_leaves_autumn · alder_leaves_spring · alder_leaves_winter · alder_log_side · alder_log_top · alder_planks · alluvium · alpine_turf · autumn_mushrooms | +| 2 | autumn_mushrooms_dormant · basalt · basalt_bricks · bedroll · berry_bush · berry_bush_bare · birch_leaves · birch_leaves_autumn · birch_leaves_spring · birch_leaves_winter | +| 3 | birch_log_side · birch_log_top · birch_planks · bluebell · bluebell_dormant · bulrush · bulrush_dormant · clay_bank · clover · clover_dormant | +| 4 | coal_ore_vein · copper_ore_vein · crop_1 · crop_2 · crop_3 · crop_4 · crosshair · deadwood · deer · door | +| 5 | drying_rack · dung · fern · fern_dormant · fire · fire_lit · fish_trap · forest_floor · foundation · fox | +| 6 | frame · fungi · grain_patch · granite · granite_bricks · grass_side · grass_top · gravel · hare · heather | +| 7 | heather_dormant · hotbar · iron_ore_vein · item_autumn_mushrooms · item_berries · item_charcoal · item_charcoal_charge · item_clay · item_clay_jar · item_clay_jar_charge | +| 8 | item_cloak · item_coal · item_cooked_fish · item_cooked_food · item_copper_charge · item_copper_ingot · item_copper_ore · item_copper_pick · item_dried_fish · item_dried_food | +| 9 | item_dried_food_charge · item_firestarter · item_fish_bait · item_fish_drying_charge · item_grain · item_grain_seed · item_iron_axe · item_iron_ingot · item_iron_ore · item_quartz | +| 10 | item_raw_fish · item_raw_food · item_smelt_charge · item_spring_greens · item_vessel · item_water_clean · item_water_raw · leaves · leaves_autumn · leaves_spring | +| 11 | leaves_winter · lichen · limestone · limestone_bricks · litter · log_side · log_top · loose_stone · mature_tree · meadow_grass | +| 12 | meadow_grass_dormant · moss · nettle · nettle_dormant · oak_leaves · oak_leaves_autumn · oak_leaves_spring · oak_leaves_winter · oak_log_side · oak_log_top | +| 13 | oak_planks · pantry · peat · pine_leaves · pine_log_side · pine_log_top · pine_planks · planks · podzol · quartz_vein | +| 14 | reeds · roof · sand · sandstone · sandstone_bricks · sapling · scree · sedge · sedge_dormant · selected | +| 15 | slate · slate_bricks · snow · soil · spring_greens · spring_greens_dormant · stairs · sticks · stone · storage | +| 16 | thatch · torch · tree_seedling · vent · wall · water · weir_stone · wildflowers · wildflowers_dormant · willow_leaves | +| 17 | willow_leaves_autumn · willow_leaves_spring · willow_leaves_winter · willow_log_side · willow_log_top · willow_planks · window · young_tree · (two unused) | + +### Packaging, security and verification boundary + +Every named PNG/OBJ still passes through `writeAsset`, `relativeName`, `safePath` +and the existing guarded staging directories. Input/output links and junctions +remain refused; sheet and PNG memory are bounded. Minimal-source generation and +integrity-verified packaging check every new atlas and every mesh byte. No new +dependencies, secrets or certificates; SHA256 is integrity, PNG CRC32 a checksum, +neither an authenticity guarantee. This applies CodeGuard secret avoidance and +bounded safe-file handling without introducing a network/security subsystem. + +The focused suites now cover exact independent manifests, actual content icon +coverage, approved leaf hash, rock versus brick structure, distributed turf, +aggregate soil/root transitions, multi-radius growth rings, unique palettes/masks, +understory dormancy, species dimensions/architecture, all 29 flora meshes' finite +indexed outward nondegenerate triangles, flat normals, closed oriented manifold +parts, positive volume, UV insets, every seasonal woody-part preservation, exact +atlas source pixels, complete package bytes, independent PNG decode and sorted +sheet cells. Larger species have explicit 640-vertex/1200-triangle ceilings; +legacy budgets and all topology assertions remain. The bounds are real serialized +geometry, not assumed from profile labels. + +Node-only validation and contact-sheet inspection do **not** certify engine +rendering, growth, seasonal swaps, mapgen, collision, performance or live-world +acceptance. No engine was launched. Optional codec regeneration requires its +existing configured tool; Ogg framing/CRC/hash checks remain part of the unchanged +media suite. + +Latest extension verification: **35 passed, 0 failed, 0 cancelled, 0 skipped**, +exit 0, empty stderr, **43.87 seconds** with the absolute Node **24.22.0** +executable. All five JavaScript/test modules passed syntax checks. The generated +**168-image 320×544** sheet was inspected separately. Optional codec +decode/regeneration was not configured; Ogg framing/CRC/hash checks passed. +Windows file-symlink privilege was unavailable; directory-junction refusal passed. + +
+Historical pre-extension art snapshot (superseded counts, geometry and sheet order) + +The following records the earlier 95-sprite/six-mesh milestone only. In particular +its old masonry base stone, broad earth bands, 160-tile limit, contact-sheet order, +unchanged generic bark statement and latest-run counts are no longer current. +Use the authoritative extension contract above for generation and integration. + +## Direction and authorship + +An original, quiet 32px pixel-art system: a naturalist's field journal left open +beside a warm hearth. Warm bark, layered earth, weathered stone and sage foliage +form the world; moonlit Mocha shadows hold it together. Mauve, teal and pale gold +are small points of interest, **not neon terrain fills**. + +The design was checked against eom.dev's local +[Catppuccin stylesheet](../../www/htdocs/static/catppuccin.css) and +[home stylesheet](../../www/htdocs/static/home.css): Mocha base/mantle, +restrained raised surfaces, mauve accents and pale text. These are color/design +references, not borrowed illustrations. All pixel silhouettes, material motifs, +meshes and UV layouts here are newly authored in +[world-art.mjs](../scripts/world-art.mjs), under the project's +[MIT license](../LICENSE.txt). No downloaded art, animal model, font, image +library, runtime dependency, network fetch, random seed or generative-image +output is involved. + +### Palette hierarchy + +| Role | Palette anchors | Use | +| --- | --- | --- | +| Journal shadows | `#181825`, `#1e1e2e`, `#313244`, `#45475a` | Outlines, deep joints, interface | +| Warm ground | `#443a38`, `#665044`, `#876957`, `#ad7e68` | Earth strata, clay banks, plaster | +| Bark and cut timber | `#433b36`, `#685346`, `#92765a`, `#ad916b`, `#c6ad83` | Fissures, growth rings, sawn boards | +| Botanical greens | `#384c43`, `#556e55`, `#7d9570`, `#a4b28b` | Sage leaves with dark undersides | +| Spring growth | `#526b50`, `#8fa67c`, `#bac59a`, `#c19caa` | Fresh sage blades, sparse dusty-pink buds | +| Autumn canopy | `#64454c`, `#a26c49`, `#b59458`, `#845365` | Muted wine shadows, copper and ochre clusters; no green | +| Thatch and grain | `#766848`, `#a39061`, `#c6b581` | Overlapping straw and ripe seed heads | +| Water and iron | `#334952`, `#425e66`, `#638187`, `#909f9f` | Quiet ripples and small cool metal edges | +| Reserved accents | `#cba6f7`, `#94e2d5`, `#f9e2af`, `#fab387` | Flowers, selection, small sparks; never biome fills | + +The complete immutable `PALETTE` export is authoritative. Each tile uses a small +subset. Plain dark pixels are intentional visual rest, not missing detail. + +### Pixel grammar + +- Exactly 32×32 logical pixels; nearest-neighbor filtering, no antialiasing. +- Connected 2–8px color clusters instead of independent hash/static noise. +- Material-specific geometry: irregular stone courses, broad soil layers, long + bark fissures, concentric end-grain, peg-and-joint planks, timber braces, + plaster cracks, tied overlapping thatch, inset storage and pantry jars. +- Light comes from upper left; edges catch warm parchment or cool moonlight. +- Plants have stems, paired leaves, distinct negative spaces and intentional + asymmetric silhouettes. Fruit is a mauve cluster, not a glowing block. +- Food and tools read by silhouette first. Charges retain the input ingredient + and add a diagonal binding and pale-gold paper tag; do not confuse a queued + process with a finished resource. Clean water has a crisp sparkle; raw water + has muted suspended marks as well as a different color. +- Water is static, fully opaque source art with sparse horizontal wavelets. Its + opposite edges match on both axes. Apply the game's desired liquid alpha in + node configuration. Sand/clay strata match left/right edges. Not every + decorative building tile promises identical edge pixels on all four sides. + +## Generation and contracts + +[generateAssets](../scripts/assets.mjs) always bundles the complete named atlas, +three animal meshes, and the contact sheet. It does **not** discover named assets +by parsing real gameplay declarations or opening named source binaries. Minimal +fixture trees containing only mod Lua still package correctly. Existing bounded +Lua color scanning remains for compatibility swatches. Those now use subdued +earth-tinted clusters, not noisy neon squares; semantic names below are the +intended integration target. + +Every output still passes through `writeAsset`, `relativeName` and `safePath`. +Source symlinks/junctions, linked output ancestors and unsafe relative paths +remain refused. PNG dimensions/pixels and review-sheet tile count are bounded. +No credentials, certificates, insecure cryptography or new downloads were +introduced; PNG CRC32 is a file checksum, not a security primitive. This follows +the reviewed CodeGuard secret avoidance and bounded/safe-file-handling rules. + +### Public pure exports + +- `worldArt()` returns a fresh ordered `Map`. + `data` is straight-alpha RGBA `Uint8Array`; all tiles are 32×32. +- `WORLD_NAMES`, `PLANT_NAMES`, `SEASONAL_PLANT_NAMES`, `ITEM_NAMES`, `ANIMAL_NAMES`, `PALETTE` and `SIZE` + expose the fixed manifest and palette without accessing the filesystem. +- `animalOBJ(species)` returns deterministic original OBJ text; unknown species + are rejected. `animalBounds(species)` returns node-unit `min`, `max` and the + expected `visualSize`. +- `contactSheet()` returns a 320px-wide RGBA review atlas at **full native 32px cell + resolution**, not downsampled thumbnails. Height is `ceil(tileCount / 10) × 32` + (currently 320px). It accepts 1–160 validated tiles, including every seasonal + sprite and both picked-food icons. The 64×32 tree atlases remain separate; + their seasonal leaf drawings are reviewed in the corresponding sprite cells. +- `artContactSheetPNG()` in the asset generator exports its encoded PNG bytes. + `png(width, pixelCallback, height = width)` now supports bounded rectangular + output while preserving square callers. + +### Output locations + +Under any generated destination: + +- `mods/hearth/textures/hearth_.png` +- `mods/hearth/textures/hearth_item_.png` +- `mods/hearth/textures/hearth_{hare,fox,deer}.png` +- `mods/hearth/models/hearth_{hare,fox,deer}.obj` +- `mods/hearth/textures/hearth_{hotbar,selected,crosshair}.png` +- `mods/hearth/textures/hearth_art_contact_sheet.png` + +No binary assets need to be checked into the source tree. Normal packaging calls +the generator and includes these outputs in its existing integrity manifest. + +## Complete named-art manifest / contact-sheet legend + +The review sheet contains **95 distinct images**, plus the generated sheet +itself. There are ten columns, ten rows; rows/columns below are **one-based**. +Unused cells in the last row are checker paper. World identifiers use +`hearth_` + identifier + `.png`; item identifiers use `hearth_item_` + identifier +followed by `.png`. Interface and animal identifiers use `hearth_` + identifier +followed by `.png`. + +| Row | Columns 1 → 10 | +| --- | --- | +| 1 — world | stone · soil · grass_top · grass_side · sand · log_top · log_side · leaves · planks · wall | +| 2 — construction | roof · thatch · foundation · frame · stairs · door · window · vent · storage · pantry | +| 3 — plants / clay | reeds · berry_bush · berry_bush_bare · sapling · grain_patch · crop_1 · crop_2 · crop_3 · crop_4 · clay_bank | +| 4 — resources / equipment | iron_ore_vein · water · fire · fire_lit · torch · sticks · loose_stone · bedroll · drying_rack · fish_trap | +| 5 — ecology | weir_stone · meadow_grass · wildflowers · fern · litter · fungi · deadwood · tree_seedling · young_tree · mature_tree | +| 6 — seasonal world | dung · leaves_spring · leaves_autumn · leaves_winter · meadow_grass_dormant · fern_dormant · wildflowers_dormant · spring_greens · spring_greens_dormant · autumn_mushrooms | +| 7 — patch, then items | **world:** autumn_mushrooms_dormant · **items:** berries · grain · grain_seed · raw_food · cooked_food · fish_bait · raw_fish · cooked_fish · fish_drying_charge | +| 8 — items | dried_fish · iron_ore · iron_ingot · charcoal · charcoal_charge · smelt_charge · clay · clay_jar · clay_jar_charge · dried_food | +| 9 — items, then UI | **items:** dried_food_charge · cloak · vessel · water_raw · water_clean · firestarter · iron_axe · spring_greens · autumn_mushrooms · **UI:** hotbar | +| 10 — UI / animal UV atlases | selected · crosshair · hare · fox · deer · (five unused cells) | + +Counts: **61 world/ecology textures, 28 craftitem icons, 3 interface textures, +and 3 animal UV atlases: 95 total**. Existing four tool icons, the 64px player skin, +color-compatibility textures, original player/tool models and audio remain +separate; they are not silently included in this sheet's numbering. + +### Alpha and main-agent node integration + +- `leaves`, `reeds`, `berry_bush`, `berry_bush_bare`, `sapling`, `grain_patch`, + all four `crop_*`, `meadow_grass`, `wildflowers`, `fern`, `litter`, `fungi`, + `deadwood`, `tree_seedling`, `young_tree`, `mature_tree`, `dung`: crisp 0/255 + alpha, zero RGB beneath transparent pixels. Use clip-compatible leaf/glasslike + or appropriate plantlike drawtypes; ground litter/deadwood may suit a low mesh + or nodebox better. Mature-tree art is a silhouette, **not** a new tree-building + algorithm. Ecology controls growth, placement and drops independently. +- All craftitems: crisp 0/255 alpha and empty outer borders. Set + `inventory_image = "hearth_item_.png"` individually rather than tinting + one color square. Processing charges have their own distinct icons. +- `window`: empty outside, opaque frame/mullions, alpha-95 glass. Use blend + alpha and a suitable glass/nodebox drawtype, not opaque rendering. +- `fire`, `fire_lit`, `torch`, `sticks`, `loose_stone`, `bedroll`, `drying_rack`, + `fish_trap`: transparent silhouettes. Select clip-compatible rendering or use + them as inventory art for physical nodeboxes; an opaque cube will look wrong. + Fire is one static drawing, not an animation strip. Emission/light and flame + particles remain gameplay decisions. +- Remaining world materials and animal atlases: fully opaque. `water` source + art is opaque deliberately; liquid transparency must be configured by main. +- Six cube face ordering for logs: use `log_top` on the two ends and `log_side` + on four sides; grass uses `grass_top`, `soil` bottom and `grass_side` sides. + Do not continue multiplying these authored textures by saturated old tints. +- `hearth_hotbar.png` is a **single slot tile**, not a prebuilt eight-slot strip. + Main must repeat it into its chosen hotbar layout or use individual slot HUD + images; directly stretching it across the whole bar would stretch the border. + `hearth_selected.png` is a transparent gold/mauve slot outline. + `hearth_crosshair.png` has sparse parchment marks and a Mocha contrast outline. + Source pixels remain 32px; HUD scale/offset is main's responsibility. + +## Original wildlife meshes — integration handoff + +No external model or flat animal billboard. Hare has deep haunches, grounded +hind feet, long upright ears and a small cream tail. Fox has dark slim legs, +pointed ears, cream bib/muzzle and a long tapered, pale-tipped brush. Deer has +long separated legs, dark hooves, raised neck, broad ears and branched antlers. +These are static low-poly models, **not animated B3D rigs**; movement/idle logic +must not request player animation frames on them. + +Select `visual="mesh"`, the species OBJ and the matching PNG texture, with +`visual_size={x=1,y=1}`. Mesh coordinates use **10 model units per node**, just +like the existing player contract. Feet are Y=0, origin at ground center, +forward is **+Z**. Do **not** apply another 10× scale. Each model is one texture, +with no external MTL/image references. Eight semantic 8px atlas patches use +half-texel inset UVs; stable indexed, outward-wound triangles avoid sampling +neighboring material patches. Use nearest-neighbor texture filtering. + +| Model / matching texture stem | Vertex / triangle count | Min bounds (nodes) | Max bounds (nodes) | +| --- | --- | --- | --- | +| `hearth_hare` | 112 / 168 | (-0.185, 0, -0.370) | (0.185, 0.848159, 0.429500) | +| `hearth_fox` | 128 / 192 | (-0.155, 0, -0.820375) | (0.155, 0.786649, 0.592000) | +| `hearth_deer` | 192 / 288 | (-0.280, 0, -0.492526) | (0.280, 1.510000, 0.675000) | + +`animalBounds()` is authoritative at full precision. These are **visual bounds**, +including ears/tails/antlers, not prescribed gameplay collision boxes. Main +should choose comfortable body collision/selection boxes, ground-origin spawn +offsets and facing/yaw conversion. Review the import orientation in-engine before +claiming a verified visual integration. This work does not edit wildlife Lua. + +### Preserved player/media contract + +The original 64×64 player skin, B3D bytes, skeleton, UVs and six animation clips +are unchanged. Existing held tools, compatibility OBJ and fifteen Ogg assets +are unchanged. The new animals do not reuse or disturb the player's atlas. + +## Verification + +Focused Node-only checks live in [world-art.test.mjs](../tests/world-art.test.mjs), +[flora-art.test.mjs](../tests/flora-art.test.mjs) +and the unchanged [assets.test.mjs](../tests/assets.test.mjs). No engine tests, +live worlds, native client launch or full application test gates are required or +claimed by this art task. + +The tests cover exact named manifests; all 95 distinct byte-deterministic tiles; +restricted palettes; color-independent material geometry and contiguous clusters; +plant/item occupancy, negative space, alpha and inventory padding; monotonic +crop growth; berries versus bare foliage; water seams and sand/clay strata seams; +independent PNG signature/chunk/CRC/RGBA decoding; rectangular atlas dimensions +and exact unscaled cells; bounded PNG/contact-sheet input rejection; animal +topology, finite coordinates, nondegenerate outward triangles, UV patch bounds, +counts, species proportions and node-unit bounds; minimal fixture bundling; and +linked-output refusal. Existing tests additionally verify deterministic real-source +regeneration, fixture package integrity, source traversal limits, player B3D and +audio contracts. + +Validation limitations remain explicit: optional codec decode/regeneration is not +run without its configured tool; Ogg CRC/framing/hash checks still run. File +symlink creation may be unavailable on Windows; directory-junction refusal is +always checked. Geometry tests and the pixel contact sheet are not a claim that +animal models have been rendered or that Lua references have been migrated. + +Historical pre-season baselines were 19 tests for world/media art and 24 with +volumetric flora. The seasonal verification section below supersedes those +counts. No Lua or other source files are owned by this art task. + +## Volumetric ecology trees — refinement + +The newer ecology trees supersede the billboard recommendation above for +`tree_seedling`, `young_tree`, `mature_tree` and `deadwood` only. Original pure +generation lives in [flora-art.mjs](../scripts/flora-art.mjs), under the same MIT +license. No downloaded vegetation, tree-block construction, external MTL or new +dependency is used. Meadow grass, wildflowers, ferns and other ground sprites +keep their existing plantlike presentation. + +`floraOBJ(name)` generates six deterministic OBJ models; `floraBounds(name)` +returns their actual serialized vertex bounds in node units. `FLORA_NAMES` is +the fixed manifest (four original models plus two bare winter trees). Each leafy +standing tree has a tapered eight-sided stem, +separate visible boughs and intersecting closed broadleaf lobes: two for the +seedling, four for the young tree, five for the mature tree. Three uneven rings +and offset tips per lobe produce asymmetric faceted silhouettes from every +direction, rather than a giant cube or crossed planes. Deadwood has a low fallen +trunk, two broken side branches and a fork. Every face is triangulated with an +explicit outward flat normal. Nearest-neighbor filtering preserves the pixel +marks on the three-dimensional surfaces. + +### Texture and packaging contract + +`treeAtlas(art = worldArt(), season = 'summer')` returns fresh **64×32 straight-alpha RGBA** data. The left 32px +patch is the original `log_side` bark drawing; the right patch is the original +`leaves` drawing over opaque `leafDark` underpainting. All alpha bytes are 255 +deliberately: spaces between volumetric lobes, not punched-out polygon surfaces, +define the canopy. Each triangle stays within its bark/leaf patch with half-texel +UV insets. One texture and no `usemtl`/`mtllib` loading are required. + +[generateAssets](../scripts/assets.mjs) always writes these through the existing +guarded `writeAsset`, including when the source has only minimal mod Lua: + +- `mods/hearth/models/hearth_tree_seedling.obj` +- `mods/hearth/models/hearth_young_tree.obj` +- `mods/hearth/models/hearth_mature_tree.obj` +- `mods/hearth/models/hearth_deadwood.obj` +- `mods/hearth/models/hearth_young_tree_winter.obj` +- `mods/hearth/models/hearth_mature_tree_winter.obj` +- `mods/hearth/textures/hearth_tree_atlas.png` +- `mods/hearth/textures/hearth_tree_atlas_spring.png` +- `mods/hearth/textures/hearth_tree_atlas_autumn.png` +- `mods/hearth/textures/hearth_tree_atlas_winter.png` + +Normal packaging includes all ten in its integrity manifest. They are generated +outputs, not checked-in binaries; the review sheet grows dynamically with sprites. + +### Scale, ground and exact visual bounds + +Coordinates use **10 model units per node**, node-center origin, with ground +exactly **Y = -5 model units = -0.5 nodes**. Use `drawtype="mesh"`, +`visual_scale=1`, the corresponding OBJ and `tiles={"hearth_tree_atlas.png"}`. +No additional scale or vertical offset is needed. + +| Model stem | Vertices / triangles | Minimum (nodes) | Maximum (nodes) | Height (nodes) | +| --- | --- | --- | --- | --- | +| `hearth_tree_seedling` | 84 / 152 | (-0.25, -0.5, -0.175) | (0.241, 0.2, 0.1375) | 0.7 | +| `hearth_young_tree` | 168 / 304 | (-0.61, -0.5, -0.56) | (0.626, 1.5, 0.45) | 2.0 | +| `hearth_mature_tree` | 210 / 380 | (-1.04, -0.5, -1.04) | (1.064, 3.0, 1.064) | 3.5 | +| `hearth_deadwood` | 64 / 112 | (-0.4044987, -0.5, -0.8019998) | (0.4034683, -0.0422183, 0.8016249) | 0.4577817 | + +These are visual bounds, not canopy-sized obstacles. The ecology registration +loop alone selects mesh rendering and disallows replacement for all four. +Young and mature trees are walkable with narrow trunk collision/selection boxes: +(-0.12, -0.5, -0.12) to (0.12, 0.6, 0.12) for young trees and to +(0.12, 1.2, 0.12) for mature trees. Deadwood has a low central-trunk box; +seedlings remain nonwalkable. The semantic-art registration helper preserves +explicit `def.tiles`; the loop supplies its mesh atlas directly. No +`override_item` API or mock is required. + +Growth clearance, succession logic and canopy obstruction policy remain outside +this art change; no changes were made to the succession module. + +## Seasonal flora — exact integration contract + +The authoritative runtime declarations are +[seasons.lua](../mods/hearth/seasons.lua) and +[content.lua](../mods/hearth/content.lua), documented in +[ecosystems.md](ecosystems.md). All following files are generated into the +destination's texture/model directories, never written into source by packaging. +`TREE_SEASONS` exports `summer`, `spring`, `autumn`, `winter`; unsupported values +fail closed. Summer's atlas pixels and all four original meshes are unchanged. + +### Trees and legacy leaf nodes + +| Season | Mesh-tree texture | Legacy leaf-node texture | Young / mature mesh | +| --- | --- | --- | --- | +| Spring | `hearth_tree_atlas_spring.png` | `hearth_leaves_spring.png` | Existing leafy meshes | +| Summer | `hearth_tree_atlas.png` | `hearth_leaves.png` | Existing leafy meshes | +| Autumn | `hearth_tree_atlas_autumn.png` | `hearth_leaves_autumn.png` | Existing leafy meshes | +| Winter | `hearth_tree_atlas_winter.png` | `hearth_leaves_winter.png` | `hearth_young_tree_winter.obj`, `hearth_mature_tree_winter.obj` | + +Every atlas is **64×32, fully opaque RGBA**: unchanged bark left, seasonal +botanical clusters right. Spring uses fresh sage with sparse dusty buds; Summer +retains deep sage; Autumn uses copper, ochre and muted wine with **no green**. +Winter's right patch contains warm woody twigs over bark-dark underpaint, not +green or snow-painted foliage. Legacy 32×32 leaf textures retain crisp 0/255 +alpha; Winter is sparse interlacing twig forks with transparent negative space. + +Both winter meshes contain **only trunk, boughs and lateral woody forks**. Every +original trunk/bough vertex, UV, normal and triangle is retained exactly; crowns +are omitted and each original bough gains one outward lateral fork. They stay +within the original leafy clearance envelopes, at ground Y=-0.5 nodes, scale 1, +10 model units/node. Every winter triangle samples only the left bark half. + +| Model | Vertices | Triangles | Leafy triangles | +| --- | --- | --- | --- | +| Young winter | 112 | 196 | 304 | +| Mature winter | 144 | 252 | 380 | + +Seedlings intentionally reuse `hearth_tree_seedling.obj` in **all seasons**, with +the corresponding atlas, exactly as runtime requests. There is no unreferenced +winter seedling mesh. Deadwood remains on its original mesh and summer atlas. +No additional summer variant, snow overlay or tree-block construction is added. + +### Ground flora and seasonal food + +All sprites/icons below are **32×32 straight-alpha RGBA**, with crisp 0/255 +alpha and zero hidden RGB. Picked-item icons additionally have empty outer +borders. Dormant flora uses straw/bark/earth, **no green and no blooms**, and has +reduced, visibly different silhouettes rather than a palette-only recolour. + +| Runtime node(s) | Generated texture(s) | Appearance / availability | +| --- | --- | --- | +| `meadow_grass_winter` | `hearth_meadow_grass_dormant.png` | Bent dry grass blades in Winter; original living art otherwise | +| `fern_winter` | `hearth_fern_dormant.png` | Curled spent fronds in Winter; original living art otherwise | +| `wildflowers_autumn`, `wildflowers_winter` | `hearth_wildflowers_dormant.png` | Spent stalks/seed heads, no bloom; original bloom in Spring/Summer | +| `spring_greens_patch` | `hearth_spring_greens_dormant.png` | Low dry rosette outside Spring | +| `spring_greens_patch_spring` | `hearth_spring_greens.png` | Broad fresh basal blades with veins; available only in Spring | +| `autumn_mushrooms_patch` | `hearth_autumn_mushrooms_dormant.png` | Low litter/mycelial trace without pickable caps outside Autumn | +| `autumn_mushrooms_patch_autumn` | `hearth_autumn_mushrooms.png` | Three staggered copper/ochre caps and pale stalks; available only in Autumn | + +Picked craftitems `hearth:spring_greens` and `hearth:autumn_mushrooms` use +`hearth_item_spring_greens.png` and `hearth_item_autumn_mushrooms.png` respectively: +a bound greens bundle and gathered mushrooms. **Patch node identifiers are not +texture stems**: no `_patch.png` aliases are generated. Existing `fungi` remains +visually distinct decomposer art, not seasonal edible forage. These are original +fictional food drawings, not real-world plant/mushroom identification guidance. + +The generator adds **15 PNGs and two OBJs**: three seasonal atlases, three leaf +textures, three dormant ground textures, four forage patch textures, two item +icons and two winter meshes. All names are bundled even from minimal Lua-only +fixture sources and included in the existing SHA256 package integrity inventory. +No Lua callbacks, harvest cooldowns, seasonal authority, metadata or placement +rules are changed. There are no new dependencies, downloads, credentials or +external material paths; existing bounded safe-path writers are retained. + +### Seasonal verification + +[flora-art.test.mjs](../tests/flora-art.test.mjs) checks all six models with the +unchanged finite/indexed/nondegenerate/outward-normal, positive-volume and closed +oriented-manifold-per-part validations. Added checks cover wood-only winter UVs, +fewer triangles, outward lateral forks, original trunk/bough geometry preservation, +clearance envelopes, all four opaque atlases and byte-exact bark. Generated PNG +bytes, exact OBJ text and complete package integrity are verified from fixtures. +The current Lua explicit-tile wiring and separate patch/item stem contract are +read-only assertions, not a Lua execution or engine test. + +[world-art.test.mjs](../tests/world-art.test.mjs) checks the exact 95-image +manifest, determinism/uniqueness, restrained palettes, alpha/masks/padding, +Spring buds, non-green Autumn leaves, bare Winter twigs, bloom-free dormancy, +forage/decomposer silhouette differences, dynamic sheet dimensions and every +seasonal sprite cell. Existing material, animal, PNG and safe-output tests stay. + +Run all three focused asset suites, including the unchanged +[assets.test.mjs](../tests/assets.test.mjs), with the absolute Node 24.22.0 +executable. Optional codec regeneration is not required: existing Ogg +framing/CRC/hash tests remain active. These checks validate pixels, topology and +packaging, **not an in-engine seasonal visual acceptance pass**. No full engine +tests or concurrent engine launches are part of this art task. + +Latest seasonal run: **27 passed, 0 failed, 0 cancelled, 0 skipped**, exit 0, +empty stderr, **37.37 seconds**, using the absolute Windows Node 24.22.0 +executable. All five owned JavaScript/test modules passed `node --check` with +exit 0 and empty stderr. The 95-image **320×320** generated contact sheet was +inspected separately. Optional codec decode/regeneration was not configured; +Ogg framing/CRC/hash checks passed. Windows file-symlink privilege was unavailable; +directory-junction refusal passed (the suite reported no skipped tests). + +
+ +
diff --git a/docs/botany.md b/docs/botany.md new file mode 100755 index 0000000..657aa79 --- /dev/null +++ b/docs/botany.md @@ -0,0 +1,241 @@ +# Botany: 80 temperate taxa and integration contract + +## Scope and evidence + +The catalogue in [mods/hearth/botany.lua](../mods/hearth/botany.lua) has **80 distinct real species**, partitioned into **eight ordered groups of ten**. It represents a north-west/central European temperate landscape mosaic: deciduous woods, conifer woods, hedgerows, acid heath, wet meadow, bog and freshwater margins. It does **not** imply that all species grow together in one patch, or that all are native to every British locality. Norway spruce is native to continental northern/central Europe and widely planted in Britain. Hazel is counted only among trees; heather only among herbs; rushes and reedmace only among grasses/sedges. Lichens are lichen-forming fungi but have their own disjoint gameplay category, not a second count in fungi. + +Taxonomic/reference authorities for maintaining these names and broad habitats: + +- **P**: [BSBI/UKCEH Plant Atlas 2020](https://plantatlas2020.org/) for vascular-plant regional distributions, habitat and nomenclature; [Kew Plants of the World Online](https://powo.science.kew.org/) for name/synonym checks. Kew blocked automated access during this implementation; no claim of an automated 80-record authority audit is made. +- **T**: [Woodland Trust British tree accounts](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/british-trees/a-z-of-british-trees/) and [plant accounts](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/plants/), complementary common names and woodland niches. +- **W**: [The Wildlife Trusts wildflower accounts](https://www.wildlifetrusts.org/wildlife-explorer/wildflowers), including freshwater plants. +- **M**: British Bryological Society, *Mosses and Liverworts of Britain and Ireland: A Field Guide* (2010), and *Atlas of British & Irish Bryophytes* (2014), for the five moss names/niches. Online BBS access also blocked automated retrieval; these are bibliographic authorities, not downloaded records. +- **F**: [British Mycological Society English names](https://www.britmycolsoc.org.uk/resources/english-names), [Index Fungorum](https://www.indexfungorum.org/), and [First Nature fungal accounts](https://www.first-nature.com/fungi/). Particularly [fly agaric](https://www.first-nature.com/fungi/amanita-muscaria.php) for root-host associations, [birch polypore](https://www.first-nature.com/fungi/piptoporus-betulinus.php) for birch/deadwood associations and its modern name, and [wood blewit](https://www.first-nature.com/fungi/lepista-nuda.php) for litter decomposition and late fruiting. +- **L**: [British Lichen Society species accounts and maps](https://britishlichensociety.org.uk/resources/species-accounts) and its [Lichens of Great Britain and Ireland](https://britishlichensociety.org.uk/identification/lgbi3). Names and species boundaries evolve; common lichen names are not universally standardized. + +The numeric light/moisture/pH/depth windows below are **explicit game approximations**, not measured ecological tolerances, identification keys, conservation advice or a scientific simulation. The catalogue selects named species rather than inventing generic “magic herbs”. *Rubus plicatus* is one real blackberry species, not the *Rubus fruticosus* aggregate counted as a species. Meadow fescue *Schedonorus pratensis* is also encountered as *Festuca pratensis* / *Lolium pratense*. Wood blewit *Collybia nuda* is widely recorded as *Lepista nuda* / *Clitocybe nuda*. Birch polypore *Fomitopsis betulina* replaces the familiar *Piptoporus betulinus*. These synonyms are not extra entries. + +**No real-world foraging, medicinal or consumption claims are provided.** Only three fictional bush outputs use the existing game item `hearth:berries`. This is a gameplay simplification, not a statement that a wild plant or lookalike is safe. Every fungus, herb, tree and lichen has `food=false`; toxic/stinging examples are flagged but `toxic=false` is **not** a safety certification. `browse` describes wildlife relationships, not player edibility. No new food items, recipes, poisoning system or damage to player plants are registered. + +## Exact ID manifest (art, trees and ecology handoff) + +`id` is the canonical catalogue key. Every non-tree node is exactly `hearth:`. Every tree's initial node is `hearth:_seedling`; tree stages/resources belong to the diversity/resources owner. **Do not create a second generic berry bush or alias a generic patch into this count.** Existing generic `berry_bush`, `fern`, `wildflowers` and `meadow_grass` remain outside the 80. + +Owner flags: **E** = existing node, never registered here; **D** = tree diversity owner, never registered here; **B** = new botany-owned node. The 13 existing mappings are retained verbatim: five trees plus clover, nettle, heather, bluebell, sedge, bulrush, moss and lichen. “Sedge”, “moss” and “lichen” receive a specific catalogue interpretation; their old IDs do not change. + +Art contract for **all 80**: `hearth_botany_.png`. This module uses that exact tile for each of its **62 new understory nodes**. Trees use species bark cubes and full-block leaf crowns supplied by diversity/terrain; no seasonal tree mesh is introduced on a cube. Seasonal copying preserves bark/end-grain faces, `facedir`, callbacks and drops; foliage gets its own species-season texture. Assets and real-engine visual validation remain the art/main integration owners' responsibility. + +### trees (10; P/T) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| oak | Pedunculate oak | Quercus robur | E/D | Mesic woodland; ectomycorrhizal roots | +| birch | Silver birch | Betula pendula | E/D | Light acid pioneer woods | +| pine | Scots pine | Pinus sylvestris | E/D | Open acid woods; resin | +| willow | White willow | Salix alba | E/D | Wet alluvial woods; spring flowers | +| alder | Common alder | Alnus glutinosa | E/D | Wet woods; Frankia-associated nitrogen fixation | +| beech | European beech | Fagus sylvatica | D | Well-drained woodland, avoiding saturation | +| aspen | European aspen | Populus tremula | D | Light woodland gaps | +| rowan | Rowan | Sorbus aucuparia | D | Cool acid woodland/edges; flowers | +| hazel | Common hazel | Corylus avellana | D | Woodland understorey; ectomycorrhiza | +| spruce | Norway spruce | Picea abies | D | Moist acid conifer woods; resin | + +### shrubs (10; P/T) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| bilberry | Bilberry | Vaccinium myrtillus | B | Acid heath/woodland; ericoid, not ectomycorrhizal | +| blackberry | Pleated blackberry | Rubus plicatus | B | Hedgerows/wood edges | +| raspberry | Red raspberry | Rubus idaeus | B | Woodland clearings | +| elder | Black elder | Sambucus nigra | B | Hedgerows; no game food | +| blackthorn | Blackthorn | Prunus spinosa | B | Thorny hedges; spring flowers | +| hawthorn | Common hawthorn | Crataegus monogyna | B | Hedgerows; spring flowers | +| dog_rose | Dog rose | Rosa canina | B | Open scrub; summer pollen | +| holly | European holly | Ilex aquifolium | B | Evergreen woodland cover; no game food | +| juniper | Common juniper | Juniperus communis | B | Open dry heath/scrub; resin | +| guelder_rose | Guelder rose | Viburnum opulus | B | Damp hedges/wet edges; no game food | + +### grasses (10; includes sedges, rushes and reedmace; P) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| sedge | Greater tussock-sedge | Carex paniculata | E | Wet bank tussocks | +| bulrush | Bulrush (great reedmace) | Typha latifolia | E | Reedbed margin; bank-cell approximation | +| meadow_fescue | Meadow fescue | Schedonorus pratensis | B | Moist neutral grassland | +| cocksfoot | Cock's-foot | Dactylis glomerata | B | Mesic meadow/edge | +| timothy | Timothy | Phleum pratense | B | Moist meadow | +| sweet_vernal_grass | Sweet vernal-grass | Anthoxanthum odoratum | B | Acid grassland | +| yorkshire_fog | Yorkshire-fog | Holcus lanatus | B | Mesic grassland | +| tufted_hairgrass | Tufted hair-grass | Deschampsia cespitosa | B | Wet meadow tussocks | +| common_reed | Common reed | Phragmites australis | B | Wet banks/reedbed edges | +| soft_rush | Soft rush | Juncus effusus | B | Wet pasture/bog margin | + +### herbs (10; P/T/W) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| clover | White clover | Trifolium repens | E | Open grassland; rhizobial nitrogen fixation | +| nettle | Common nettle | Urtica dioica | E | Nutrient-rich damp edges; larval host, not nectar | +| heather | Heather | Calluna vulgaris | E | Acid heath; late-season flowers | +| bluebell | English bluebell | Hyacinthoides non-scripta | E | Spring woodland floor | +| yarrow | Yarrow | Achillea millefolium | B | Meadow; summer/autumn flowers | +| foxglove | Common foxglove | Digitalis purpurea | B | Acid clearings; no game food | +| meadowsweet | Meadowsweet | Filipendula ulmaria | B | Wet meadow; pollen resource | +| wild_garlic | Wild garlic | Allium ursinum | B | Spring moist woodland; no game food | +| cowslip | Cowslip | Primula veris | B | Base-rich open grassland | +| wood_anemone | Wood anemone | Anemone nemorosa | B | Spring woodland floor; no game food | + +### ferns_mosses (10; five ferns P/T, five mosses M) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| bracken | Bracken | Pteridium aquilinum | B | Acid slopes/clearings; competition descriptor | +| lady_fern | Lady-fern | Athyrium filix-femina | B | Damp shaded woods | +| male_fern | Male-fern | Dryopteris filix-mas | B | Shaded woods | +| harts_tongue | Hart's-tongue fern | Asplenium scolopendrium | B | Damp base-rich shade | +| polypody | Common polypody | Polypodium vulgare | B | Acid rock/bark/woodland | +| sphagnum | Blunt-leaved bog-moss | Sphagnum palustre | B | Saturated acid bog/wet woodland | +| haircap_moss | Common haircap | Polytrichum commune | B | Damp acid soils | +| moss | Cypress-leaved plait-moss | Hypnum cupressiforme | E | Shady soil, wood, bark and rock | +| fork_moss | Broom fork-moss | Dicranum scoparium | B | Acid woodland/heath | +| star_moss | Swan's-neck thyme-moss | Mnium hornum | B | Damp woodland banks | + +### fungi (10; F) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| fly_agaric | Fly agaric | Amanita muscaria | B | Ectomycorrhizal: birch/pine/spruce | +| birch_bolete | Brown birch bolete | Leccinum scabrum | B | Ectomycorrhizal: birch only in this model | +| bay_bolete | Bay bolete | Imleria badia | B | Ectomycorrhizal: conifers/beech/oak | +| chanterelle | Chanterelle | Cantharellus cibarius | B | Ectomycorrhizal woodland roots | +| wood_blewit | Wood blewit | Collybia nuda | B | Litter saprotroph; autumn/winter | +| shaggy_inkcap | Shaggy inkcap | Coprinus comatus | B | Grassland-soil saprotroph | +| turkey_tail | Turkey tail | Trametes versicolor | B | Broadleaf deadwood saprotroph | +| oyster_fungus | Oyster mushroom | Pleurotus ostreatus | B | Broadleaf deadwood; late-season fruiting | +| honey_fungus | Honey fungus | Armillaria mellea | B | Deadwood here; parasitism descriptive only | +| birch_polypore | Birch polypore | Fomitopsis betulina | B | Birch deadwood here; parasitism descriptive only | + +### aquatic (10; P/W) + +| id | Common name | Scientific name | Owner | Placement niche | +|---|---|---|---|---| +| duckweed | Common duckweed | Lemna minor | B | Surface; 1–6 depth units | +| yellow_waterlily | Yellow water-lily | Nuphar lutea | B | Surface; 1–3, mud anchor | +| white_waterlily | White water-lily | Nymphaea alba | B | Surface; 1–3, mud anchor | +| water_crowfoot | Common water-crowfoot | Ranunculus aquatilis | B | Surface abstraction; 1–3, mud anchor | +| water_mint | Water mint | Mentha aquatica | B | Wet bank, dry destination | +| water_forgetmenot | Water forget-me-not | Myosotis scorpioides | B | Wet bank, dry destination | +| brooklime | Brooklime | Veronica beccabunga | B | Wet bank, dry destination | +| water_starwort | Common water-starwort | Callitriche stagnalis | B | Submerged abstraction; 1–4, mud anchor | +| hornwort | Rigid hornwort | Ceratophyllum demersum | B | Submerged/rootless; 1–4, water substrate | +| pondweed | Broad-leaved pondweed | Potamogeton natans | B | Surface; 1–3, mud anchor | + +### lichens (10; L) + +| id | Common name | Scientific name | Owner | Habitat emphasis | +|---|---|---|---|---| +| reindeer_lichen | Reindeer lichen | Cladonia rangiferina | B | Open acid heath; wildlife browse | +| oakmoss | Oakmoss | Evernia prunastri | B | Bark in light woodland | +| old_mans_beard | Old man's beard | Usnea subfloridana | B | Humid bark/branches | +| lichen | Map lichen | Rhizocarpon geographicum | E | Acid siliceous rock, not generic soil | +| dog_lichen | Dog lichen | Peltigera canina | B | Mossy soil; cyanobacterial fixation | +| lungwort_lichen | Lungwort lichen | Lobaria pulmonaria | B | Humid woodland bark; cyanobacterial cephalodia | +| sunburst_lichen | Common orange lichen | Xanthoria parietina | B | Well-lit nutrient-enriched bark/base-rich stone | +| hammered_shield | Hammered shield lichen | Parmelia sulcata | B | Woodland bark | +| cup_lichen | Mealy pixie-cup | Cladonia chlorophaea | B | Acid soil/humus, distinct species below | +| pixie_cup | Pebbled pixie-cup | Cladonia pyxidata | B | Soil/humus; not a duplicate of chlorophaea | + +## Exact public interface + +Loading the file returns **`factory(hearth, api) -> B`**. The factory has no registration side effects and publishes no globals. Main assigns `hearth.botany` before loading succession. Botany installs no scheduler, globalstep, ABM, LBM, mapgen callback, timer or liquid transformation. [mods/hearth/succession.lua](../mods/hearth/succession.lua) consumes this policy in its existing eight-probe loop. + +### Catalogue/query APIs + +- `B.groups`, `B.group_order`: ordered arrays `trees, shrubs, grasses, herbs, ferns_mosses, fungi, aquatic, lichens`. +- `B.catalogue`: ordered 80-record snapshot, group order then manifest row order. +- `B.get(value) -> record|nil`; `B.list(group_or_nil) -> ordered records`; `B.group_of(value) -> group|nil`; `B.kind(value) -> id|nil`. +- `value` may be a bare ID, full `hearth:` node name or `{name=...}`. Trees also resolve canonical `_seedling`, `_young`, `_mature`, `_leaves`, `_log`, `_planks`. Seasonal names resolve through `hearth.seasons.kind` when available. Generic patches and foreign namespaces do not resolve. +- Every returned record is a detached copy. Editing a snapshot cannot change internal policy. +- Record fields: `id`, `common`, `latin`, `group`, `node`, `texture`, `existing`, `food`, `toxic`, `seasons` (boolean set), `tags` (boolean set), `niche`; flowering taxa also have `flowering`, the three game-fruit taxa have `fruiting` (both boolean sets). +- `niche` fields: `substrates` set, inclusive `light={min,max}` on 0–15, `moisture={min,max}` on 0–1, `ph={min,max}` in pH units, `water={min,max}` in game node-depth units, `aquatic` string. Some also require `hosts` set or `nutrients` minimum. + +### Read-only habitat selection + +`B.suitable(value, h, season) -> boolean` and `B.choose(p, h, season) -> bare_node_kind, record | nil` use only supplied observations. `p` must contain finite integer `x,y,z` within ±30990; `h` must have `loaded=true` and finite `light`, `moisture`, `ph`. Missing/invalid facts do not receive optimistic defaults. `season` is exactly `Spring`, `Summer`, `Autumn` or `Winter`. + +`h.ground` is a canonical bare/full support node or node table. `h.substrate`, when supplied, overrides the direct substrate for land/bark/deadwood niches. Bark and deadwood are **semantic observations**, not assertions that arbitrary planks/logs are safe recruitment sites. The ecological owner must derive those observations from unmodified natural substrate and enforce owner/protection checks. Do not treat crafted timber or planted trees as free fungal substrate. + +`h.host` is a nearby identified tree ID/stage/root association, mandatory for host-specific fungi. Its search radius and finite sampling budget belong to the existing ecological scheduler. Presence of an unrelated tree does not satisfy a birch specialist. `h.nutrients` must be at least one for nettle. pH is a game support-class observation; callers lacking it should explicitly derive a documented approximation from the substrate, not silently infer that all plants fit. + +`h.aquatic` is `land`, `bank`, `surface` or `submerged`; land may omit it. `h.water` is **numeric column depth**, not a boolean or permission to replace a water node; land/bank may omit it (zero). The selection represents a candidate only: + +| Mode | Required observations | Safe external placement rule | +|---|---|---| +| land | Depth zero; accepted substrate | Loaded air destination above accepted natural substrate | +| bank | Explicit `bank`, depth zero, wet support | Loaded air destination beside real freshwater, never inside/over it | +| surface | Explicit `surface`, `freshwater=true`, allowed positive column depth; accepted mud bottom | Flat pad in a distinct air cell above the actual surface; source node below stays unchanged | +| submerged | Explicit `submerged`, `freshwater=true`, positive column depth; rooted starwort needs mud bottom, rootless hornwort needs `substrate=water` | Succession places a **qualitative shallow-water stand sprite in the separate air cell above conserved source water**. It is not literally rendered inside the liquid. No source replacement, fake liquid, water clearing/refilling or flood-producing liquid flora | + +Surface depth ranges/anchors are intentionally coarse: even floating duckweed uses a valid freshwater bottom observation rather than an unbounded “water somewhere” fallback. Succession checks up to six consecutive loaded `hearth:water_source` cells and an accepted bottom, with authority/markers checked on every cell. Flowing water, unknown bottoms, over-depth or unloaded columns fail closed. Surface/submerged mode for a new stand is position-deterministic; established stands retain their species mode. Thus all ten aquatic taxa have establishment/spread/age pathways, but **hornwort/starwort use above-water stand markers, not scientifically exact submerged geometry**. `register_nodes` keeps direct source-water item placement disabled. Bank flora occupies dry cells beside actual water. + +`choose` considers all 80 records in a deterministic position-hashed order, skipping trees unless `h.include_trees=true`. It returns an unprefixed actual node kind, e.g. `bluebell`, `water_mint` or `beech_seedling`, plus a detached descriptor. It never writes the map, chooses an occupied cell, checks claim ownership itself, consumes nutrients or rescans the world. The caller must independently verify loaded, empty, unmodified destination/support and authorization **immediately before any write**, using its existing bounded scheduler. No plant replaces another, spreads water or creates nutrients here. + +### Interactions and finite effects + +- `B.has_tag(value, tag) -> boolean`. +- `B.interactions(value) -> tag-keyed descriptor copies` with a `description`, and where applicable `cap` or `enabled=false`. +- `B.active(value, season) -> boolean`: establishment/visible-season gate, **not** an instruction to destroy winter-dormant perennial plants. Four spring woodland herbs gate to Spring; evergreen trees, mosses/lichens and persistent bracket fungi have broader windows. +- `B.pollinator(value, season) -> boolean`: actual flowering tag **and** flowering season. Oak, wind-pollinated grasses, nettle, mosses and fungi do not become nectar sources. Meadowsweet and dog rose represent pollen as well as nectar relations. +- `B.edible(value, season) -> boolean`: **game berry permission only**, independent of wildlife browse; only bilberry/raspberry in Summer and blackberry in Summer/Autumn return true. +- `B.browse(value, season) -> boolean`: active wildlife relationship; not a universal diet rule. Wildlife owner must still choose an appropriate animal diet. +- `B.resin(value) -> boolean`: only the catalogue's pine/spruce/juniper resin tag. It grants no item or extraction callback. +- `B.nitrogen(value, current) -> suggested_delta`: only nitrogen-fixer-tagged taxa; `min(1,max(0,2-current))`, zero for invalid/negative current. Alder/Frankia, clover/rhizobia and cyanobacterial lichen partners are distinct biological associations. **Ecology owner must apply a per-site contribution/cooldown and metadata cap of two; repeatedly calling this pure query is not permission for unlimited fertilizer.** +- `B.root_fungi(value, tree, budget) -> transfer_ceiling`: only ectomycorrhizal **fungi** matching the supplied host, at most one and at most the finite positive existing budget. A nearby host can receive a bounded establishment advantage through that owner's already-budgeted resource transfer. Caller debits the same budget; the function creates nothing and never independently raises soil nitrogen. Tree ectomycorrhizal tags alone are not free fungal boosts. Saprotrophs receive no root-fungus boost. + +`saprotroph` describes recycling existing litter/wood: any later benefit must consume existing substrate, not mint nutrients. `engineer` describes physical substrate/water retention or aquatic cover; it does not grant terraforming. `nursery` means conditional cover/microhabitat. `parasite` and `allelopathy` are **disabled descriptive interactions**: honey fungus/birch polypore do not infect player trees and bracken never kills neighboring plants. The module mutates no ecological state. + +### Registration and harvest + +`B.register_nodes(register_node)` accepts the content registrar **taking bare names**. It is idempotent and skips known existing IDs, all ten trees and any already present `api.registered_nodes[full_name]`. In an otherwise empty registry it registers **62** nodes (70 non-tree taxa minus eight existing understory IDs). It registers no recipes, aliases, items or tree stages/resources. Existing flora definitions are never overridden. Main should load this factory after seasons and call registration after legacy diversity registration. + +New terrestrial plants/fungi/shrubs are conventional crossed `plantlike` sprites with no meshes/sculptures. Surface aquatics are thin nodeboxes wholly inside ±0.5 of the node. They are not water; submerged sprites are not given liquid behavior or water groups. All are non-walkable and `buildable_to=false`. Ordinary herbs, mosses, fungi and lichens dig to their own node item. **All new shrubs dig to `hearth:sticks`**, so digging/replanting cannot erase a berry cooldown and mint a fresh fruit-bearing bush. Placement marks `player_placed=1`; normal construction/protection handling is supplied by the existing registrar. There is no new growth timer. + +`B.harvest(pos, player) -> success_boolean, count_or_reason` is the only resource mutation API. Berry callbacks return the original wielded stack. It: + +1. Validates finite integer bounded coordinates and a real player; looks up **current** `hearth.construction.allowed(pos, player, "use")` at callback time. Missing policy denies access; no permissive fallback. Existing policy owns reach, claim and protection checks. +2. Re-reads the loaded node and requires its exact canonical three-bush ID, then current `hearth.climate_at().season` and game fruiting season. +3. Checks persisted string metadata `botany_ready_at`, rejecting malformed/negative/nonfinite values. Missing metadata means initial fruit stock; future timestamps deny until ready. +4. Requires inventory room for `hearth:berries 2`; full inventory neither changes stock nor starts cooldown. +5. Locks the bush by writing a finite timestamp **one real day** ahead before inserting at most two berries. This blocks callback reentrancy. A completely rejected insertion restores the old timestamp; partial delivery consumes that cycle, never exceeds two or drops overflow into the world. Unexpected insertion results/errors leave the lock in place rather than duplicate stock. + +The factory may precede construction publication, but harvest cannot. Time uses optional injected `api.now()` (numeric Unix seconds, for tests), otherwise `os.time()`. No offline catch-up or accumulating fruit: one ready bush gives at most two per cooldown even after months away, and cooldown metadata survives module reload. New callbacks are installed only on `bilberry`, `blackberry`, `raspberry`; generic existing berry-bush behavior is untouched. + +Reasons: `invalid`, `denied`, `species`, `season`, `clock`, `metadata`, `cooldown`, `inventory`; success returns delivered count 1 or 2. No player health changes, medicinal effects, wild-consumption guidance or automatic fertilizer is added. + +## Ecology80 integration and bounds + +The single succession loop owns recruitment, local spread, dormancy, senescence and decomposition for all 80 catalogue entries. It uses the **actual node kind returned by `B.choose`**, including species seedling suffixes, and rechecks `B.suitable` against destination facts before writes. Trees enter older gaps only after 7,200 active seconds; other recruits establish after 180 active seconds (90 with existing nutrients). No second scheduler, unbounded radius, map emergence or offline replay is introduced. Existing eight column probes/second, radius 12, twelve sampled heights, 8,192-entry age cache, 60-second durable attempt cooldown and 1,200-second maximum continuity gap remain. + +`S.habitat(pos)` is read-only. Its 5×5×5 stencil now exposes exact catalogue dimensions: finite `light`, `moisture`, `ph`, `nutrients`, depth-valued `water`, separate `near_water`, `ground`, semantic `substrate`, `freshwater`, `aquatic`, `host`, active browse count (`edible`, retained for wildlife compatibility), seasonal `pollination`, `nursery` and host-fungus IDs. pH is an explicit **game support-class approximation**: peat 4.5; podzol/granite 5; alpine turf/slate/sandstone/scree/stone/forest floor/bark/deadwood/litter 5.5; grass/soil/alluvium/sand/basalt 6.5; limestone 7.5. Missing support observations fail closed. Nursery-tagged natural cover slows accumulated habitat stress by 25%; it creates neither water nor nutrients. Fungal host observations use only nearby identified natural trees or recorded natural wood species, never player timber. + +Non-tree active lifetimes (seconds): shrubs/lichens 86,400; grasses/aquatic 21,600; herbs 14,400; ferns/mosses 43,200; fungal fruit bodies 2,400. Dormant perennials occupy their cells and pause, not disappear at first frost. Soil-supported vegetation becomes litter; rock/bark/wood/litter/water stands disappear without replacing their support. Fungal fruit bodies **do not independently mint nutrients through litter**. Saprotrophs advance only an existing, natural, authorized litter/deadwood clock by at most 60 seconds per attempt. The substrate's eventual consumed decomposition pays once using the existing capped nutrient path. + +All nitrogen-fixer tags use a persisted two-unit **soil support** budget (`succ_fixed_nitrogen`), shared across death/recolonization/restart; a bark-dwelling fixer without a soil support gets no fertilizer effect. Matching ectomycorrhizal fungi reduce seedling establishment age from 21,600 to 16,200 only with a positive existing soil resource budget; successful growth debits at most one from that same budget. Failed growth creates/debits nothing. `parasite` and `allelopathy` remain deliberately **disabled descriptors**; no tree infection or neighboring-plant damage. `engineer`/`resin` stay habitat/resource descriptors, not autonomous terraforming/item generation. Pollination is exposed for wildlife/harvest integration, **not a berry-yield multiplier**: the unchanged botany harvest policy still returns at most two berries per real-day cooldown. Succession preserves a valid `botany_ready_at`; other non-succession provenance metadata still opts plants out. + +### Owned full-block tree lifecycle + +`D.tree_plan(species, stage)` supplies fresh full-block plans for all ten species. Succession validates integer offsets within ±4 horizontally and 0–9 vertically, at most 810 entries, no duplicate/foreign names, and a root. **One global tree-plan reservation per wall-clock second**, including denied attempts, covers plan construction/preflight/application regardless of player count. Old/new plan union work is bounded (at most two plans); there are no dynamic per-frame world tree registries. + +Every destination and its immediate support must be loaded, unclaimed, externally unprotected and unmarked. New components require air; growth never silently replaces even natural groundcover. Existing components must match the root-key `succ_component`, species/stage expected node (season-canonicalized) and rotation. `succ_tree_stage` and `succ_species` persist on the root; components carry its plain coordinate key. Components never independently age or pay nutrients. A tree's own owned leaves do not shade its own root into automatic starvation; neighbouring crowns still shade recruits. Pine **and spruce** retain winter shade. + +Senescence atomically clears only the root's matching components and leaves **one ground-supported full-cube fallen log**, `param2=12`; existing content supplies the axe requirement and one-log drop. No floating standing log pile, sculpted tree, or chain of extra resource logs is created. Any protected/marked/foreign component blocks collapse rather than being destroyed. Natural epiphytes losing their owned substrate are removed in the same bounded preflight, while foreign attachments block it. + +`S.tree_removed(pos, oldnode) -> boolean` is the optional after-dig handoff. A standard `register_on_dignode` hook calls it when available. It validates an empty loaded root, old stage identity and every remaining matching owned component; it cannot clear unrelated mapgen/player wood. External removals that do not invoke this hook must explicitly call the handoff; a denied/unloaded cleanup is not queued/replayed. Unmanaged pre-existing mapgen tree canopies are not retroactively adopted as managed trees. The independently registered generic deadwood adoption remains intact. + +## Focused validation and integration boundary + +[tests/botany_spec.lua](../tests/botany_spec.lua) is a standalone Lua 5.1+ mocked-API suite. It checks all 80 positive niche vectors and each light/moisture/pH/substrate rejection; 8×10 disjoint counts; unique scientific names; exact legacy mappings; seasonal/host/water distinctions; texture and registration ownership; bounded interactions; toxic/food exclusion; denied/full/partial/reentrant harvest; reload/cooldown and invalid clocks/metadata. + +Standalone focused suites: [tests/botany_spec.lua](../tests/botany_spec.lua), [tests/succession_spec.lua](../tests/succession_spec.lua) and [tests/seasons_spec.lua](../tests/seasons_spec.lua). They include all 80 real selector outputs and recruitment/spread/lifecycle pathways with supplied species niche vectors, separately tested **actual** water/wood/pH habitat sampling, water preservation/authority, atomic real tree plans, owned cleanup/restart, malformed plans, concurrency bounds, finite fungal/nitrogen effects and cube-safe seasonal registration. Verified markers: `BOTANY_SPEC_PASS 32`, `LAST_HEARTH_SUCCESSION_PASS 63`, `LAST_HEARTH_SEASONS_PASS 12` (107 named cases; the all-80 case additionally loops every taxon). Controlled niche-vector coverage is not evidence that all species spawn in one real-world patch. Require each final marker, all named passes and **empty stderr**; Fengari exit zero alone can hide assertion failures. On the network workspace, module source was read from disk once and executed in-memory to avoid repeated SMB reads timing out; tests/production modules were not rewritten for staging. No full engine or persistent world is run for this integration. Visual correctness, real-engine harvest/dig callback wiring and empirical gameplay/performance acceptance remain to be verified by main. + +Security review followed CodeGuard: centralized runtime authorization, deny-by-default missing policy, finite validated positions/time, persistent bounded harvest, inventory preflight and reentrancy lock. No credentials, certificates, cryptography, network operations or dynamic executable inputs are introduced. diff --git a/docs/character-creator.md b/docs/character-creator.md new file mode 100755 index 0000000..32b2e34 --- /dev/null +++ b/docs/character-creator.md @@ -0,0 +1,98 @@ +# Woodland traveler character creator + +Use `/character` at any time while connected, or open **Character appearance** +from the inventory. Creation is opt-in: joining restores the saved appearance +without opening the creator. Existing location selection and field-note join +forms retain priority. + +## A quiet traveler, not a costume catalogue + +All choices are inclusive and independent, with no gender, identity or stat +fields. Compact, balanced and broad silhouettes have identical mechanics, +height, camera, collision, reach, movement and attachments. Natural hair, muted +mineral cloth, undyed linen and worn leather fit the woodland world. + +Four tabs keep the 14 × 11 form manageable: + +| Tab | Choices | +| --- | --- | +| Face | 3 body shapes, 6 skin tones, 4 face-line patterns, 5 eye colors | +| Hair | 7 styles, 6 shared hair/facial-hair colors, 5 facial-hair styles | +| Clothing | 4 garments, 8 cloth colors, 4 trousers, 3 boot leathers | +| Finishing | 5 accents, 8 accent colors, 4 face-detail patterns | + +Dropdowns use readable names, not gender categories or free-form colors. Cloth +and accent palettes are sage, teal, slate, mauve, ochre, rust, linen and charcoal. +Transparent `none` layers and colors under absent hair/accents mean the raw +selection combinations are not all visually distinct; no uniqueness count is +claimed. + +## Preview and explicit consent + +The form contains a real Luanti `model[]` preview, not a screenshot, external +browser or URL. It uses one composited 64 × 64 texture and the selected B3D. +The preview turns continuously and supports mouse dragging; Front, Profile and +Back reset its camera angle. These controls never rotate the actual player. + +Changing a dropdown, switching tabs, **Randomize** and **Reset draft** affect +only a private session draft. Randomize chooses from every fixed allowlist using +bounded `math.random`; it is not security randomness and introduces no secrets. +Reset copies the defaults rather than modifying shared state. **Cancel** or +Escape discards the draft, preserving both the real appearance and saved bytes. +Only **Save / Apply** commits. No need, inventory, identity or physics values +are changed. Drafts disappear when the session ends. + +## Version-1 storage and recovery + +The only new persistent value is the engine-owned player metadata string +`hearth:appearance`. It is independent of the existing global save schema and +world/mod storage. A complete flat JSON object contains exactly `version:1` and +these string selections: + +`body`, `skin`, `face`, `eyes`, `hair`, `hairColor`, `beard`, `garment`, `cloth`, +`trousers`, `boots`, `accent`, `accentColor`, `detail`. + +Defaults match the [authoritative art contract](art-direction.md): balanced, +sand, calm, hazel, sidepart, chestnut, none, tunic, teal, earth, walnut, scarf, +sage, none. An absent value uses defaults without writing metadata. Invalid, +unknown-version, missing/extra-field, malformed, nested or over-2048-byte values +show safe defaults and a fixed warning. Original corrupt bytes are **not +overwritten**, and appearance errors never kick a player. Only an explicit +successful Apply replaces that value. Back up the world before manual recovery. + +Encoding and decoding use `core.write_json` / `core.parse_json(raw,nil,true)`; +parser errors do not log player payloads. There is no executable deserialization, +custom path, uploaded texture or free-form hexadecimal color. Every mesh and +layer filename is built solely from fixed IDs. Compositing order is skin, face, +eyes, garment, trousers, boots, accent, hair, beard, detail: clothing must stay +below long hair. Face overlays are skin-neutral; there is no global color tint. +The held-tool material remains the existing compatibility texture. + +Apply serializes and checks the roundtrip, then writes metadata **before** +publishing `mesh` and `textures`. A thrown/false metadata write or serialization +failure keeps the previous published appearance and saved session selection, +leaving the draft available to retry. Engine metadata durability still follows +normal engine player saving; this is not a new disk transaction service. + +## Integration and validation boundary + +[The factory](../mods/hearth/character.lua) receives `(hearth, api)`, registers +its own command/receive-fields callback, and exports `apply`, `on_join`, `show`, +`handle`, `defaults`, `options`, `validate`, `decode`, `encode`, `preview` and +snapshot-only `state`. Bootstrap instantiates it before the interface and +players; the player join handler calls it after base property initialization. +Only the current engine-supplied connected player object and its own session can +edit an open draft. Target names, arbitrary fields, hidden-tab selections and +invalid dropdown indices are rejected as a whole event. All displayed data is +formspec-escaped; text and input sizes are bounded. + +CodeGuard input/storage guidance was applied: allowlisted keys and selections, +bounded parsing, session ownership, preserved corrupt data and sanitized errors. +No credentials, certificate handling, cryptography, network calls or dependencies +are introduced. No authentication or database infrastructure is changed. + +Focused offline Lua harnesses cover character behavior, player join integration, +inventory navigation and bootstrap order. Mocks model native JSON through +registered exact fixture strings, not executable Lua. Native engine rendering, +mouse behavior and visual fit still require client review; no full-engine or +live-world test is claimed by this change. diff --git a/docs/content-expansion.md b/docs/content-expansion.md new file mode 100755 index 0000000..7434be8 --- /dev/null +++ b/docs/content-expansion.md @@ -0,0 +1,374 @@ +# Landscape and ecosystem integration + +This integrates the independent [terrain factory](../mods/hearth/biomes.lua), +[diversity policy](../mods/hearth/diversity.lua), and named botanical art into +[content.lua](../mods/hearth/content.lua). No save-schema/version change, engine +fork, new simulation scheduler, global API wrapper or runtime mapgen editing is +introduced. The precise upstream contracts remain [world generation](world-generation.md), +[ecosystems](ecosystems.md), and [original art](art-direction.md). + +## Registration order and API + +1. Construct/install `hearth.seasons`, then `hearth.botany` and `hearth.diversity` + **before** creating succession. Both extend seasonal policy; succession reads + `hearth.biomes` lazily when it needs habitat information. +2. Register all original nodes, ecological flora, seasonal food patches, resource + inputs, tools and recipes through the existing private registration facade. +3. Call the lazy biome factory with `(hearth, api, register_node, construction)`; + assign `hearth.biomes = biomes.register()`. This is the sole registration of + new terrain, full-block species trees, geology, aliases and decorations. +4. Call `diversity.register_nodes(register_node)`, then + `botany.register_nodes(register_node)`, then `seasons.register()`. + Species leaves and full-block tree stages now exist for seasonal copies. +5. Instantiate wildlife after these registrations. + +The entire old mapgen section is removed, including its aliases, two biome +definitions, simple floor iron decoration, old weir/tree schematics and seep +registration. The biome module retains the canonical temperate/riverbank names, +canonical meadow timber and gathering routes, while supplying ten communities, +ten tree species and ordered real-host ore registrations. Its aliases are +**non-forcing** and honor existing registrations; nothing clears engine tables. +Its instance registration and diversity node registration are idempotent. + +The private node helper honors explicit `def.tiles`. Species logs therefore keep +their top/top/side faces; soils keep their authored top and canonical earth sides; +full-block trees retain their species faces. Seasonal copies retain base placement, +protection, ownership and room-invalidation callbacks. Plantlike silhouettes use +their supplied texture rather than a guessed node-name image. All resource +craftitems use the existing `hearth_item_.png` naming convention. The new +pick uses **hearth_item_copper_pick.png**, not a legacy `hearth_icon_` tool name. +No duplicate ore, wood, leaf or seasonal nodes are intentionally registered. + +## Resource utilities and process handoff + +| Resource | Content integration | Process-owner boundary | +| --- | --- | --- | +| Quartz | Three `hearth:quartz` shapelessly craft one existing `hearth:window` | No new furnace/glass process or scientific manufacturing claim | +| Copper | One `hearth:copper_ore` plus one `hearth:charcoal` shapelessly craft one `hearth:copper_charge` | A lit hearth processes one charge for **45 real seconds**, reserving **30 fuel seconds**, yielding **one** `hearth:copper_ingot` | +| Copper ingot | Three ingots across the top row and two central sticks craft one `hearth:copper_pick` | Ingot/charge registration alone does not implement smelting | +| Coal | Biome-owned coal seams drop two `hearth:coal` items | Each coal adds **240 real fuel seconds**, subject to the same **600-second** reserve cap and household authorization | +| Rock and timber | One granite/basalt/limestone/slate/sandstone crafts **four matching bricks**; **two matching rocks → two canonical stones**. One oak/birch/pine/willow/alder/beech/aspen/rowan/hazel/spruce log crafts **four matching planks**; **two matching species logs → two canonical logs**. One species plank crafts **one canonical plank** | All these conversions are shapeless; each pair uses two separate crafting slots, preserving mass (2:2). No single-log or single-rock canonical conversion; mixed types do not convert together | + +The original reed/plank window recipe remains available; quartz is an alternate +mineral supply route to the same protected building node, not free reeds or a +new unsupported window. Clay keeps its existing pottery and charcoal roles. + +### Exact process and thermal contract + +The copper pick recipe is shaped: three `hearth:copper_ingot` across the top row, +one `hearth:sticks` in the center of each remaining row, empty side cells; output +is one pick. Quartz windows are shapeless: three quartz, one existing window. +The original window uses two identical rows of plank / reed / plank (four +canonical planks and two reeds). Each quartz/copper/coal seam drops two matching +items, but each copper charge consumes exactly one ore and one charcoal. + +The direct exact-name fuel table accepts sticks **45s**, canonical logs **180s**, +oak/birch/pine/willow/alder/beech/aspen/rowan/hazel/spruce logs **180s each**, +charcoal **90s**, coal **240s**. +Fuel does not ignite itself. A whole fuel item is refused without consumption if +it would exceed 600 seconds. Planks, building nodes, raw ore, quartz and arbitrary +tree groups are not fuels. Coal is a geological, nonrenewable supply; its higher +heat value is not a renewable conversion loop. + +Copper requires **75 seconds available at queue time** (45 duration + 30 cost). +The existing JOBS/RESULTS allowlists validate its distinct `smelt_copper` kind, +30 cost and copper-ingot output. Queueing reserves 30 seconds immediately and +consumes one charge; the absolute UTC fuel deadline also burns 45 seconds during +processing. Coal cannot replace the charcoal ingredient in the charge. Iron's +separate charge still takes 60 seconds with 30 reserved. + +One canonical log + one clay shapelessly make one charcoal charge; species logs +must first convert in matching pairs: two same-species logs in separate crafting +slots yield two canonical logs, not a single-log conversion. The charcoal job takes 60 seconds and +reserves 30, producing one charcoal worth 90 seconds. There is no planks-to-log +recipe and no fuel gain from converting timber to charcoal. No recipe is added +by the process/UI integration; it exposes the content owner's actual recipes. + +One hearth holds one job or one finished output. Queueing, fuel, ignition and +collection retain the same claim/external-protection, household-use and six-block +reach checks. Repeated timer/load reconciliation after restart or long absence +finishes the paid job only once, even after fuel expires; it never creates an +inventory item until authorized empty-handed collection. A full inventory or +rejected insertion leaves the one output waiting and blocks replacement jobs. + +### Field notes and observational survey + +The Mocha journal retains its original seven page indices and adds an eighth +regional geology/timber page. It describes all ten registered communities: +temperate meadow, oak woodland, birch forest, pine taiga, wet forest, floodplain, +dry scrub, alpine meadow, rocky peaks and riverbank. Recipe search includes both +window routes, copper charge/pick, all ten species planks, canonical log/plank/ +stone conversions and all five regional rock bricks. Copper-ingot search also +finds its real-time process instructions; ingots are not direct crafting outputs. + +The survey reads the actual `succession.habitat()` result once: support `ground`, +canopy/light/moisture, `pollination` (supporting plants, **not an insect census**) +and `nutrients` (bounded reserve out of eight). Missing optional observations are +shown as unavailable, not invented zeros. Ground labels are escaped for formspecs. +Pine and spruce retain evergreen winter shade; the other eight tree species +thin in winter. The API +does not expose separate evergreen/deciduous canopy counts, so the survey does +not invent them. Global active wildlife totals remain labeled as nonlocal. + +Seasonal guidance explains pioneer birch, pine on podzol/alpine turf, wet-soil +willow/alluvium and alder, clover/flower pollinator support, spring bluebells, +autumn heather, wet sedge/bulrush, shade moss/ferns, rock lichen and nettle's +nutrient requirement. Clover adds at most two nutrient units per support across +replacement/restart while reserves are low; alder has a nitrogen-fixer habitat +profile by itself, **not an independent fertilizer process**; with botany installed, +the shared nitrogen-fixer policy also covers alder and other tagged species, +subject to the same finite support budget. Decomposers +recycle finite material. Habitat moisture is a bounded substrate/water/canopy +estimate, not fully simulated hydrology, watersheds, erosion or flooding. The +shared synthetic climate HUD and altitude-based environment remain unchanged. +Survey observation never advances succession, spawns wildlife or loads terrain. + +### Eighty-species botanical field guide + +The current catalogue is **80 named species, eight categories of ten each**. +It is a game catalogue of temperate north-west European organisms, **not a +real-world identification, toxicity or foraging guide**. Category membership is +an exclusive gameplay classification (for example, hazel is counted with trees, +and sedges/rushes with grasses), not a claim about taxonomic rank. +Names below follow the actual `hearth.botany.list()` records, including their +chosen scientific-name synonyms: + +| Category (10 each) | Common name — scientific name | +| --- | --- | +| Trees | Pedunculate oak — *Quercus robur*; Silver birch — *Betula pendula*; Scots pine — *Pinus sylvestris*; White willow — *Salix alba*; Common alder — *Alnus glutinosa*; European beech — *Fagus sylvatica*; European aspen — *Populus tremula*; Rowan — *Sorbus aucuparia*; Common hazel — *Corylus avellana*; Norway spruce — *Picea abies* | +| Shrubs | Bilberry — *Vaccinium myrtillus*; Pleated blackberry — *Rubus plicatus*; Red raspberry — *Rubus idaeus*; Black elder — *Sambucus nigra*; Blackthorn — *Prunus spinosa*; Common hawthorn — *Crataegus monogyna*; Dog rose — *Rosa canina*; European holly — *Ilex aquifolium*; Common juniper — *Juniperus communis*; Guelder rose — *Viburnum opulus* | +| Grasses, sedges and rushes | Greater tussock-sedge — *Carex paniculata*; Bulrush (great reedmace) — *Typha latifolia*; Meadow fescue — *Schedonorus pratensis*; Cock's-foot — *Dactylis glomerata*; Timothy — *Phleum pratense*; Sweet vernal-grass — *Anthoxanthum odoratum*; Yorkshire-fog — *Holcus lanatus*; Tufted hair-grass — *Deschampsia cespitosa*; Common reed — *Phragmites australis*; Soft rush — *Juncus effusus* | +| Herbs | White clover — *Trifolium repens*; Common nettle — *Urtica dioica*; Heather — *Calluna vulgaris*; English bluebell — *Hyacinthoides non-scripta*; Yarrow — *Achillea millefolium*; Common foxglove — *Digitalis purpurea*; Meadowsweet — *Filipendula ulmaria*; Wild garlic — *Allium ursinum*; Cowslip — *Primula veris*; Wood anemone — *Anemone nemorosa* | +| Ferns and mosses | Bracken — *Pteridium aquilinum*; Lady-fern — *Athyrium filix-femina*; Male-fern — *Dryopteris filix-mas*; Hart's-tongue fern — *Asplenium scolopendrium*; Common polypody — *Polypodium vulgare*; Blunt-leaved bog-moss — *Sphagnum palustre*; Common haircap — *Polytrichum commune*; Cypress-leaved plait-moss — *Hypnum cupressiforme*; Broom fork-moss — *Dicranum scoparium*; Swan's-neck thyme-moss — *Mnium hornum* | +| Fungi | Fly agaric — *Amanita muscaria*; Brown birch bolete — *Leccinum scabrum*; Bay bolete — *Imleria badia*; Chanterelle — *Cantharellus cibarius*; Wood blewit — *Collybia nuda*; Shaggy inkcap — *Coprinus comatus*; Turkey tail — *Trametes versicolor*; Oyster mushroom — *Pleurotus ostreatus*; Honey fungus — *Armillaria mellea*; Birch polypore — *Fomitopsis betulina* | +| Aquatic plants | Common duckweed — *Lemna minor*; Yellow water-lily — *Nuphar lutea*; White water-lily — *Nymphaea alba*; Common water-crowfoot — *Ranunculus aquatilis*; Water mint — *Mentha aquatica*; Water forget-me-not — *Myosotis scorpioides*; Brooklime — *Veronica beccabunga*; Common water-starwort — *Callitriche stagnalis*; Rigid hornwort — *Ceratophyllum demersum*; Broad-leaved pondweed — *Potamogeton natans* | +| Lichens | Reindeer lichen — *Cladonia rangiferina*; Oakmoss — *Evernia prunastri*; Old man's beard — *Usnea subfloridana*; Map lichen — *Rhizocarpon geographicum*; Dog lichen — *Peltigera canina*; Lungwort lichen — *Lobaria pulmonaria*; Common orange lichen — *Xanthoria parietina*; Hammered shield lichen — *Parmelia sulcata*; Mealy pixie-cup — *Cladonia chlorophaea*; Pebbled pixie-cup — *Cladonia pyxidata* | + +**How do I browse it?** Use `/botany [search]`, or the Botanical guide button in +the existing journal or ecosystem survey. The journal remains eight pages; no +ninth sidebar pane is added. The browser has ten species rows per page in +catalogue order, scrollable readable notes, Previous/Next, search, survey and +field-note controls. The ecosystem journal page also lists all eight categories +and their real common/scientific names. Missing or empty optional botany APIs +produce an unavailable message, not fictional substitute species. + +**What does search mean?** Up to 48 characters of literal text, never Lua patterns. +It matches common/scientific names, IDs, category, habitat, seasons and roles. +Control characters become spaces; queries and all displayed catalogue text are +formspec-escaped. Page inputs are floored/clamped, including NaN and infinities; +no match and empty catalogue each retain one navigable page. New search resets +to page one; paging preserves the sanitized query. + +**Which interactions are active?** The browser's substrate/light/moisture/pH, +host and aquatic-depth ranges are **game niche windows**, not measured field +data. Seasonal flowering supports the pollinator index, not spawned insects; +browse supports wildlife, not human food permission. Nursery cover reduces +recruit stress conditionally. Nitrogen-fixer policy consumes a finite two-unit +support budget while reserves are low. Host-compatible root fungi can accelerate +tree establishment while spending existing nutrient reserve. Saprotrophs can +advance an existing natural litter/deadwood clock; they do not create a second +nutrient payout. These require the appropriate season, habitat, natural origin, +protection checks and loaded simulation; a tag is not an unconditional aura. +Habitat engineering and resin-bearing labels are descriptive here, not a new +hydrology simulator or extraction recipe. Parasitism and allelopathy are explicitly +descriptor-only: no damage to player plants and no chemical killing effect. + +**Does a missing toxic flag mean edible?** No. Toxic flags are catalogue labels, +not an implemented general poisoning system or real-world safety assessment. +Only bilberry, pleated blackberry and red raspberry have the catalogue's +seasonal game berry-harvest permission; this does not add food interactions for +other named fungi/herbs. Existing generic seasonal forage is a separate system. +Never use these notes to identify or eat real organisms. + +**What does the survey count?** It reads the actual habitat API, not an 80-species +local census: grass/flower-support cover and combined shrub/fern/aquatic/tree +cover, canopy, light and moisture. Optional pH, nursery support, aquatic niche, +water depth, pollinator support and nutrients are displayed only when supplied; +missing optional fields say unavailable. In the botanical API, nearby water +cells (`near_water`) and aquatic depth (`water`) are different observations; +the legacy API's `water` cell count is not presented as depth. The pH is a coarse +substrate-class estimate, not a soil measurement. No species abundance is invented. + +**How do fallen logs work now?** They remain ordinary full cubes laid sideways, +with an axe required and exactly one canonical log as the drop. There is no +hand-to-two-sticks harvest. The existing metadata-preserving orientation fix, +tree stages and ten-tree botany implementation are retained unchanged by this +process/journal pass. Claims and player-placed material remain protected from +autonomous succession. Voice work is explicitly deferred. + +The copper pick mines `cracky=3` in **1.4 seconds**, with **70 uses**, versus +flint's 2.4 seconds and 35 uses. It retains mining `maxlevel=1`, drop level 1, +damage 3 and punch interval 1.2; it adds no tougher-rock capability and no wood +capability. It is not a replacement for the separate iron axe progression. +The exact new icons are `hearth_item_copper_ingot.png`, +`hearth_item_copper_charge.png`, and `hearth_item_copper_pick.png`; ore, quartz +and coal icons/nodes are registered by the biome owner only. + +## Broad-relief v7 preset: new worlds only + +[terrain.mjs](../scripts/terrain.mjs) exports pure `terrainSettings(newWorld)` +and `isTerrainSetting(line)` helpers. [run.mjs](../scripts/run.mjs) exports +`writeWorld()` for direct disposable tests; importing it does not invoke its CLI. +The launcher checks for existing world metadata **before** creating the world +identity. Either existing world.mt or map_meta.txt suppresses the new preset. +This also protects a legacy world without map metadata and an orphan map metadata +file. Every play still gets a fresh, exclusive session configuration, but existing +worlds get **no mapgen, seed, water-level or mapgen-limit overrides**, including +from an inherited fixture. Saved map metadata is neither parsed nor rewritten. + +[Game defaults](../minetest.conf) and the launcher agree on v7, water level 1, +**mapgen limit ±4096**, caves, light, decorations, biomes and ores. Dungeons and +floatlands are disabled; mountains, ridges and caverns are enabled. The launcher +retains seed 731104 for fresh worlds, with explicit fresh-world fixture/seed-test +overrides taking precedence. Existing seed and mapgen remain engine-owned. + +This applies the [source-reviewed recommendation](world-generation.md#recommended-v7-preset--new-worlds-only) +with three deliberate adjustments: terrain-base scale **45** instead of 65, +mount-height offset **250** instead of 230, and world bound **4096** instead of +512. Base offset 12 and alternate scale 28 keep gentler accessible terrain among +the relief. Hills have 384-node spread, mountains/ridge-water fields 700-node +spread and climate fields 256-node spread. Mountain density keeps the −0.6 bias +and 180×280×180 spread. Mount-height noise is a density-gradient input, **not an +absolute peak-height cap**. Neither the fixed seed nor these inputs guarantees +a low, dry or resource-complete starting location. + +The goal is broad hills, grounded massifs, river channels/canyon walls, woodland +and alpine transitions—not floating islands or voxel confetti. This is native +v7 noise and registered geology, **not scientific erosion or watershed simulation**. +Small native overhangs/disconnected remnants remain possible despite nofloatlands. +There is no global per-frame voxel rewriting or custom terrain postprocessor. + +### Existing worlds and transitions + +Use a separate empty runtime root/new world to try the coherent preset. Do not +delete the current save or metadata to obtain it. Managed play retains its normal +prelaunch full backup and embedded-game identity checks. Existing generated chunks +remain untouched; the world bound and mapgen parameters are not expanded in place. +New biome/ore/decoration registrations can affect **future ungenerated chunks** of +an old world and create visible seams against old terrain. Existing mapgen noise +does not imply old biome registration tables are restored. There is no migration, +automatic regeneration, chunk deletion or retrofit of old geology/vegetation. +The operator must explicitly decide between a separate fresh world for coherent +terrain and accepting new-biome boundaries in unexplored parts of an old world; +this integration makes no automatic migration decision for existing saves. + +## Focused validation and remaining native gates + +### Botanical process/journal follow-up — 2026-11-03 + +**72 focused standalone checks passed:** hearth processes **34**, recipe/journal/ +survey **38**. Both used in-memory snapshots of the actual on-disk modules and +required the exact named-check total, final suite marker and empty stderr. +The journal fixture loads the actual botanical factory and `list()` method, +checking all 80 common/scientific names across eight ten-row pages and category +counts. Coverage includes missing/empty botany APIs, all ten timber recipe/fuel +routes, building-fuel rejection, bounded literal/escaped queries, pagination, +chat/buttons/exit/session routing, footer/sidebar bounds, honest role/toxic +labels, optional survey fields and axe-only fallen-log guidance. + +Only the seven requested process/UI/test/documentation files changed in this +follow-up. No voice work, full engine run, persistent-world launch or native +visual certification was performed. Historical results below remain historical; +they do not certify the newer 80-species pass. + +### Final integrated verification — 2026-11-02 + +- Full pinned Luanti 5.18.0 check/smoke passed with **25 isolated Lua suites**; + the expanded suites cover 466 checks. The final invocation exited zero. +- Native registration/crafting checks resolved **63 recipes / 47 outputs**. + Species logs and rocks use distinct matching-pair conversion recipes; no + single-input canonical-conversion ambiguity remains. +- Native media closure checked **807 references**, with **zero missing files**. +- All **eight fixed seeds** passed in one complete run without seed retries. + Each had exposed water, food, flint, sticks, rushes, side-accessible timber, + and dry natural landing patches (412–658 sampled patches per seed). + Timber access checks inspect horizontal air beside trunks: a leaf canopy + above a trunk does not make it unreachable, while buried logs do not count. +- Four separated bounded worldgen probes observed four surface biomes and + granite/slate/limestone/sandstone strata plus quartz, copper, coal and iron. + This is sampled evidence, not proof every biome occurs in every seed. +- All **59 Node tests** passed, including original artwork, geometry, + deterministic packaging, terrain presets and saved-world preservation. + Final native, seed and Node commands each exited zero with empty stderr. +- Earlier integration checks exposed ambiguous conversion recipes and an + upland-rush generation ordering problem; both were fixed, not waived. Springs + now generate before adjacent water-dependent rushes. The original overhead-air + timber assertion was replaced with a genuine side-accessibility check. + +These are disposable headless worlds. No persistent play world was opened or +regenerated. Native appearance, performance, ecological balance and human +acceptance remain unverified; noise-based relief is not a geological simulator. +Optional complete audio codec regeneration was not configured. + +[Content tests](../tests/content_spec.lua) preserve all previous checks, add +`register_ore` to the mock, reject duplicate nodes/items/decorations, verify ten +unique biome registrations and nine ordered ore definitions, check seasonal +species order and idempotent registration, resolve every recipe/drop and validate +named mineral/species/understory images. Tool capabilities and both window routes +are checked. Art counts are checked by manifest roles rather than assuming every +inventory image is a craftitem (the copper pick is a tool). + +[Tooling tests](../tests/tooling.test.mjs) compare game defaults to the pure preset, +verify new-world config emission and seed precedence, check repeat config writes +are exclusive, and assert byte-for-byte unchanged existing world identity/map +metadata. They also cover missing-map-metadata and orphan-metadata opt-outs. These +are Node-only disposable fixtures, not a launch of the original play world. + +Previously recorded terrain/content integration results: **136 Lua checks passed** (content 33, +biomes 23, seasons 11, ecology 9, succession 49, diversity 11), with each suite's +expected final marker and empty stderr. The content suite also passed all 33 +checks with a numeric flattened target and remapped sibling loader. **24 Node +tooling tests passed**, with zero failures, cancellations or skips and empty +stderr. All three changed JavaScript/test modules passed syntax checks. Editor +diagnostics and the owned tracked-file whitespace check were clean. No previous +content or tooling test was removed. + +Resource process/journal follow-up: **54 focused standalone checks passed**: +[hearth processes](../tests/m2_processes_spec.lua) **29** and +[recipe/journal/survey](../tests/recipe_spec.lua) **25**. Both required their exact +named-check count and final suite marker, with empty stderr. Coverage includes +every species fuel, coal capacity/refusal, copper duration and reserve boundaries, +one-output collection, restart/long-offline idempotency, unauthorized interactions, +full/rejecting inventories, invalid metadata and raw-item rejection. UI checks +cover all five species/rock routes, both windows, copper crafting/process search, +eight-page bounds, seasonal guidance, optional/hostile habitat fields and Mocha. +The process fixture's notification closure was corrected to reference its local +hearth instance; no production authorization behavior was bypassed. No full engine +tests were launched for this follow-up, concurrently or otherwise. These mock +tests do not certify new terrain, native rendering or real seed reachability. + +Standalone Fengari execution requires named checks **and** the final suite marker +with empty stderr; exit zero alone is insufficient. The content harness resolves +siblings through its mock modpath/dofile boundary, not a replacement of live core. +The recipe-collision regression keeps all **63 registered recipes / 47 distinct +outputs** and verifies exact output names/counts, unique shapeless ingredient +multisets (including repeated inputs), and shaped layouts, including potential +shaped/shapeless overlap. All **25 terrain recipes** remain: single logs/rocks +produce four species planks/bricks; matching pairs produce two canonical logs/ +stones. The native smoke must still exercise every recipe through +`get_craft_result`; standalone signature checks are not a native engine pass. +Collision-fix validation passed **84 focused Fengari checks**: biomes **24**, +content **35**, and recipe/journal **25**, with exact named totals, each suite's +actual final marker (content uses `1..35`), and empty stderr. In-memory negative +probes separately restored the old single-log and single-rock conversions; +both were rejected by their regression contracts. No engine was launched and +no running player session was changed for this fix. +Native isolated runners flatten Lua filenames: sibling lookup must use a directory +plus explicit sibling basename, not `gsub("content.lua", ...)` on a numeric staged +filename. Focused registration tests do not generate or certify actual terrain. + +No full engine tests were run for this integration. Main must still validate +saved preset values in a disposable native world, all seed starts including +731104, safe spawn heights, actual tree anchoring/media, and water/flint/sticks/ +timber/forage/reeds/clay reachability. Existing emergence/resource fixtures limited +to Y=96 can miss high slopes/peaks; canonical-only soil/log searches can miss +species terrain. Adapt native probes and spawn policies rather than declaring +these mock passes proof of seed safety or handing out supplies. Finish native +process and visual/performance review separately. Historical +M4 eight-seed results do **not** certify the new terrain preset. + +Security: no secrets, network calls, dependencies, cryptography or arbitrary Lua +execution are added. Bounded declarative registrations retain normal claim and +external-protection checks. Existing guarded filesystem paths, exclusive config +creation, snapshots and mod security remain in use; saved map metadata is read +only for existence and never rewritten by this integration. diff --git a/docs/ecosystems.md b/docs/ecosystems.md new file mode 100755 index 0000000..d5da4c3 --- /dev/null +++ b/docs/ecosystems.md @@ -0,0 +1,498 @@ +# Living ecosystems + +The Last Hearth's original succession model takes ecological inspiration from +[Dynamic Life](https://www.planetminecraft.com/data-pack/dynamic-life-1-21-5-and-above/). +No datapack code, species implementation, textures or other assets were copied. +This is a runtime simulation, not extra random map decorations or world regeneration. + +## What changes + +Unclaimed, unmodified ecological ground automatically recruits niche-appropriate +groundcover, including the eight understory plants described below. +Sunny, moderately grassy sites support wildflowers; dense grass competes with +flowers. Moist shaded sites support ferns. Trees reduce local light and retain +moisture; prolonged shade kills sun-loving ground cover. Rivers and biome humidity +also influence moisture. Established sparse meadow can recruit a tree seedling; +seedlings become young trees, then mature seed-bearing trees and finally deadwood. +Living plants disperse to one adjacent suitable gap per eligible attempt. Mature +trees shed litter and disperse seedlings. Competition and occupation reject seeds; +seedlings never replace flowers, crops, another tree, or even existing meadow grass. + +Wildlife can consume active natural grass, ferns, clover, sedge and nettles and +leave dung. Dormant winter plants cannot supply food or breeding habitat. Grazing opens gaps +but imposes a five-minute recolonization cooldown. Dung, litter and deadwood +decompose; moist, shaded organic patches seed fungi. Decomposition releases finite +nutrients into the supporting soil (capped at eight). Fertile soil recruits ground +cover sooner and successful establishment consumes one nutrient. Fungi are +not edible forage; generic `fungi` decomposes, while botanical fungi have +host-specific root associations or saprotrophic substrate effects below. +These feedbacks permit shifting meadow/flower/ +woodland patches rather than a single guaranteed final biome. + +Fallen logs (`hearth:deadwood`) are **ordinary full wood cubes laid sideways**, not +irregular branch meshes. Their label is **Fallen log (axe; yields timber)**. Each +requires an axe and drops **one `hearth:log`**, exactly like standing timber: `tree=1`, +`choppy=2`, no hand-diggable `snappy` group. Default full-cube collision and selection +apply. Normal reach, claim/external protection and construction support-removal +checks remain; there is no right-click bypass. Loose `hearth:sticks` remain +hand-gatherable, preserving the first-tool path. Stone still needs a mining tool. + +New woodland mapgen places sparse three-cube fallen logs, never force-placing them. +`paramtype2="facedir"`, `param2=12` points end grain along +X; a random schematic +rotation can align the line along Z (`param2=4` is +Z). The name `hearth:deadwood` +is retained for decomposition and species metadata. The `hearth:block_deadwood` +load callback rotates legacy vertical deadwood once, marks `succ_block_deadwood=1`, +and leaves already-horizontal orientation intact. It rechecks exact loaded node +identity, respects external protection, and uses `swap_node` to retain owner/player +markers, natural flags, age, metadata inventories and timers. It does not convert +other timber, rewrite surrounding nodes, or regenerate existing terrain. + +### Active-time scales (minimums, not wall-clock promises) + +| Process | Observed active time | +| --- | --- | +| Bare soil recruits ground cover | 3 minutes; 90 seconds with nutrients | +| Grass/flower/fern dispersal eligibility | 15 minutes; attempt every third evaluation | +| Severe niche stress to litter/deadwood | 10 minutes | +| Old meadow recruits a seedling | 2 hours; 4 in dense unfertilized grass, only suitable spatial niches | +| Seedling to young tree | 6 hours | +| Young to mature tree | 24 additional hours | +| Mature tree death | 72 additional hours | +| Mature seed dispersal | After 30 minutes; every tenth evaluation | +| Wildflower / grass and fern senescence | 4 / 6 hours | +| Dung / litter / fungi / deadwood decay | 10 / 30 / 40 / 120 minutes | + +New tree generations therefore take sessions, not seconds. Each stage resets its +own age. Cold weather below 3°C suppresses establishment and living reproduction/ +stage transitions. Decomposition and stress remain possible. This is a practical +niche model, not a botanical or hydrological solver. Young/mature identities are +ordinary full-cube log roots; **a mature tree needs an actual multi-node block trunk +and leaf canopy**, not one bare root cube. The read-only `diversity.tree_plan` +interface below shares the same geometry used by mapgen. Applying it, validating +every occupied destination and support, and tracking generated components belong +to the ecological growth owner. This content change installs no geometry-writing +scheduler or automatic load-time expansion. Do not retain the old 5×5×6 mesh-only +clearance assumption: mature profiles reach 9×10×9 and must be preflighted in full. +Unknown/player plants, water, unloaded nodes, claims and markers must block growth; +the plan's omitted air cells are never permission to clear or replace anything. +Existing mapgen log/leaves trees contribute shade but are not aged or destroyed. +Player-planted saplings, crops and berry bushes retain their existing time contract. + +## Species diversity and understory + +Shared tree identities are exactly **oak, birch, pine, willow, alder, beech, aspen, +rowan, hazel, spruce**, matching the ten trees in [botany.md](botany.md). Each has +canonical `hearth:_seedling`, `hearth:_young`, and +`hearth:_mature` nodes. The six-hour/24-hour/72-hour stage durations above +remain unchanged. Growth and seed dispersal preserve the parent's species, and +death records `succ_species` on generic deadwood (retained through its litter +transition). Each young/mature root drops **one** of its species logs, never three +logs from one cube. Additional generated trunk cubes are separately harvestable. +Roots are hidden from creative inventory, use species log top/end and bark side +textures, `drawtype="normal"`, `tree=1`, `choppy=2`, and default cube collision. +Seedlings are conventional `plantlike` sprites. Mapgen full-block species +logs/leaves/planks belong to the biome module; +logs/leaves contribute canopy without being adopted as killable succession trees. + +Recruitment is deterministic, not an all-oak fallback: + +- Pine favors cold podzol and alpine turf. Warm podzol permits pioneer birch. +- Birch pioneers dry/open grass, earth and woodland gaps. +- Oak favors stable forest floor with an existing canopy. +- Beech has a broad domed crown and avoids saturation; aspen is tall and columnar; + rowan has an open crown; hazel is low coppice; spruce has a narrow evergreen spire. + All ten have distinct mature block silhouettes and deterministic recruitment. +- Willow favors moist alluvium/floodplain; alder favors peat and wet woodland. + Riparian recruitment requires moisture ≥0.65. Species soil support is delegated + to `hearth.biomes.supports_species(species, surface)` when present; its decision + is authoritative. A standalone soil policy works without the biome module. +- Ecological soils are grass, soil, forest floor, podzol, alluvium, peat and alpine + turf. Biome `surface(node)` moisture hints blend with humidity/water/canopy. + This is **not** an expansion of the separate crop-planting soil policy. + +| Understory | Niche and feedback | +| --- | --- | +| Clover | Sunny, moderately moist gaps; edible; flowering pollinator habitat; fixes bounded nitrogen | +| Nettle | Moist, already fertile ground; edible natural browse, not an inventory food item | +| Heather | Dry, open acidic podzol/alpine turf; autumn pollinator habitat | +| Bluebell | Moist shade; blooms only in Spring, occupied and dormant otherwise | +| Sedge | Saturated, lit riparian soil; edible when active | +| Bulrush | Saturated, lit riparian soil; tall wetland cover, not edible | +| Moss | Moist shade; persistent through Winter, not edible | +| Lichen | Dry exposed rock/open ground; persistent through Winter, not edible | + +These are active succession species: they establish, compete, spread into **air**, +undergo stress, and senesce. They do not merely decorate a fixed map. Suitable +understory appears on the first ordinary 90/180-second establishment cycle, rather +than waiting for tree maturation. Wet herbs can recruit riparian trees after two +hours; heather can recruit pine. Saturated sites cannot fall back to meadow grass, +and meadow grass cannot disperse into them. Exposed rock admits lichen only; +neither recruitment nor decay replaces rock with soil or fertilizes it. + +Clover can add at most **two nitrogen units per supporting soil node**, recorded +as `succ_fixed_nitrogen` across restart, grazing and recolonization; additions stop +at local fertility four. The universal nutrient cap remains eight, and successful +establishment consumes one. Clover has no harvest/item drop or timer, and cannot +create an endless fertilizer-item loop. Habitat reads clamp corrupt/excessive +nutrient metadata to eight without rewriting it. + +Natural blooming clover, wildflowers, spring bluebells and autumn heather increase +the read-only `habitat(pos).pollination` count. This exposes a bounded habitat input +for berry-regrowth consumers; this module does **not** change berry timers or grant +extra berries. `edible` is a separate active-natural-forage count. Pine and spruce +are evergreen in the catalogue and seasonal node policy; deciduous trees +retain only 15% canopy influence. + +Known unmarked worldgen diversity herbs, species stage roots and generic deadwood are naturalized by +`diversity.bootstrap(pos, node?)` from a load callback or the existing column probe. +Bootstrap checks the plant and support for ownership, ambiguous claims, external +protection and placement/unknown metadata. Hand placement marks `player_placed=1`. +It never adopts species/generic logs, leaves, planks, crops or berry bushes. There +is no general adoption of arbitrary flora, generic litter or fungi, and no new +scanning scheduler. Adopted deadwood needs loaded natural ecological soil and +decays to litter after **two observed active hours**, releasing three nutrients +(soil cap eight); subsequent litter decay releases one more, once. Adoption is +idempotent and grants no age or absence catch-up. As with +legacy unmarked soil, provenance cannot distinguish third-party placements that +deliberately omit every marker; those integrations must mark or claim their flora. + +## Seasonal flora and food + +Appearance and availability use `hearth.climate_at(os.time()).season`: the same +actual-epoch/latitude-aware climate as ecology, **not the machine's local month** +and not a replay of an optional preview clock. Southern latitude reverses the +calendar through the existing solar model. Explicit test climates can preview it. + +| Flora | Spring | Summer | Autumn | Winter | +| --- | --- | --- | --- | --- | +| Deciduous block leaves | Spring foliage | Original foliage | Autumn colours | Twig canopy texture | +| Young/mature block trunks | Bark/end grain | Bark/end grain | Bark/end grain | Same full cubes; no mesh swap | +| Pine/spruce leaves | Evergreen | Evergreen | Evergreen | Evergreen | +| Clover / nettle / heather / sedge / bulrush | Active | Active | Active | Dormant | +| Bluebell | Bloom | Dormant | Dormant | Dormant | +| Moss / lichen | Active | Active | Active | Active | +| Meadow grass / fern | Living | Living | Living | Dormant perennial | +| Wildflowers | Bloom | Bloom | Dormant | Dormant | +| Spring greens patch | Pickable | Dormant | Dormant | Dormant | +| Autumn mushroom patch | Dormant | Dormant | Pickable | Dormant | + +Frost and seasonal dormancy pause living age, stress and reproduction; they do not +kill perennials at first frost. Spring resumes the same ecological age. Deadwood, +litter, dung and decomposer fungi continue returning nutrients in winter; mature +trees can continue shedding litter. First-night berry availability stays year-round. + +Spring greens recruit and spread into moist (≥0.55), light ≥9 gaps in Spring. +Autumn mushrooms recruit and spread into moist (≥0.6), shaded (light ≤10) gaps in +Autumn. Deterministic sparse recruitment niches prevent every gap becoming food. +Dormant patches remain occupied: neither seeds nor normal placement replace them. +The existing decomposer `fungi` is still not edible. + +Right-click a patch (or dig to pick) within six nodes with normal construction +`use` authorization. One picked greens item restores **12 food** when eaten. +One picked mushroom can be crafted shapelessly into existing `raw_food`, then +cooked at a lit, fuelled hearth. Raw mushrooms have no eat callback. These are +original game foods, not advice about identifying or eating real wild plants. +Picking never removes a patch. An empty inventory slot is required; one successful +item transfer writes `succ_harvest_ready_at = now + 86400` (one real day). Rejected +delivery grants nothing and writes no cooldown. The durable deadline survives +restart, unload and every seasonal toggle; returning to Spring/Autumn cannot refill +it. Harvest writes no age, owner or placement metadata and bypasses no claim. + +`hearth.seasons.kind(node_or_name)` canonicalizes only registered controlled +variants; succession, grazing and habitat see the original kind and natural flag. +`name_for(kind, epoch)` selects appearance. `reconcile(pos, node?, epoch?)` swaps +only a loaded controlled node, preserves param1/param2 and all metadata, and does +not call construction/destruction callbacks or reset timers. Appearance-only +swaps may recolour claimed/hand-placed plants without changing permission or +provenance; external protection still blocks them. Ecological mutations retain +the stricter claim/placement exclusions below. No save schema/version change. + +## Reviewed species effects and evidence + +[species_effects.lua](../mods/hearth/species_effects.lua) is an offline policy +factory, `effects(hearth, api)`. It never fetches Wikipedia, interprets article +prose, registers a scheduler, or writes nodes/metadata. Succession uses an injected +`hearth.species_effects`, or loads this fixed sibling in the engine before its +existing globalstep is registered. Minimal standalone fixtures may omit it. +Content/bootstrap integration can initialize it before succession. + +`E.describe(record_or_id)` returns an ordered list of +`{title, status, game, source_species, source_url, ...}`. Status is exactly +`simulated` or `descriptive`: simulated means the named bounded game mechanism +exists, **not** that all real-world consequences or field rates are simulated. +`E.source(id)` and `E.sources()` expose identity references for all 80 catalogue +IDs. Bundled Wikipedia page URLs/revision IDs are used when available; without +the bundle, Latin-title links are only identity pointers, not verified evidence. +Unreviewed records explicitly say that no species-effect evidence was reviewed +here. `identity_revision` / `identity_revision_url` refer to the bundled +introduction snapshot, **not** a claim that its excerpt contains the ecological +body section reviewed below. Effect review date: **2026-11-03**. No invented +revision IDs, biological rates, or undisclosed effect generation from tags. + +### Implemented mechanics, not just catalogue descriptions + +| Species / relationship | Exact game effect | Evidence and limits | +| --- | --- | --- | +| Common alder (*Alnus glutinosa*), white clover (*Trifolium repens*) | After 900 warm active seconds, add at most one nutrient per eligible evaluation, never above local fertility four. **Two units total per supporting soil node**, recorded in `succ_fixed_nitrogen`, shared across species, recolonization and restart; soil's universal cap remains eight. Alder seedling, young and mature roots resolve through the same species ID; generated logs/leaves do not earn another contribution. | [Alder, Ecology](https://en.wikipedia.org/wiki/Alnus_glutinosa#Ecology) describes *Frankia* nodules and fertility; [white clover, Forage](https://en.wikipedia.org/wiki/Trifolium_repens#Forage) describes symbiotic fixation. The finite lifetime cap is game balance, not an annual nitrogen flux or physiological exhaustion claim. | +| Dog lichen (*Peltigera canina*) | Uses the same existing finite nitrogen budget, never a fertilizer item or independent annual allowance. The pre-existing lungwort-lichen nitrogen tag retains that shared policy; its species-effect evidence is not independently reviewed here. | [Dog lichen, Description](https://en.wikipedia.org/wiki/Peltigera_canina#Description) identifies *Nostoc*; [Peltigera, Description](https://en.wikipedia.org/wiki/Peltigera#Description) explains nitrogen fixation. Explicit species identity plus genus-level mechanism, not a measured species rate. | +| Blunt-leaved bog-moss (*Sphagnum palustre*) | A natural, active mat on loaded, permitted peat/alluvium at the sample's ground level adds **0.10 moisture** and subtracts **0.20 pH** from the local **land/bank habitat estimate**, capped at **two mats** (+0.20 / -0.40); moisture clamps to 0..1, pH to 3.5..8.5. This changes actual recruitment/stress niche tests. | [S. palustre](https://en.wikipedia.org/wiki/Sphagnum_palustre) describes water absorption; [Sphagnum](https://en.wikipedia.org/wiki/Sphagnum) explains water retention and acidification by cation exchange. Acidification evidence is genus-level. Coefficients are game approximations, not measured pH changes per plant. No permanent chemistry, peat creation, terrain conversion or global hydrology. | +| Common duckweed (*Lemna minor*) | Each natural active mat immediately above conserved source water at the sample's stand level reduces nearby **submerged freshwater** habitat light by **1.5**, capped at four mats / six light units. It never brightens a dark site, and never lowers light below two. Surface plants and terrestrial sites are not shaded by this rule. | [L. minor, Cultivation](https://en.wikipedia.org/wiki/Lemna_minor#Cultivation) describes floating carpets; [Lemnoideae, Duckweed in natural environments](https://en.wikipedia.org/wiki/Lemnoideae#Duckweed_in_natural_environments) describes shade. This is a local group-level shading approximation, not an algae, oxygen, contaminant-removal or nutrient-purification simulation. | +| Host-associated fungi, including fly agaric (*Amanita muscaria*) | An active compatible nearby fungus permits a tree seedling's young-stage transition at **16,200 rather than 21,600 active seconds**. Only a successful transition consumes **at most one existing soil nutrient**. Multiple fungi select the maximum allowance, not a sum; zero nutrients means no early growth. | [Fly agaric, Distribution and ecology](https://en.wikipedia.org/wiki/Amanita_muscaria#Distribution_and_ecology) describes ectomycorrhizal associations. Game host lists are narrower than the article. The existing growth debit is not nutrient creation, a full carbon-for-mineral ledger, or simulated water transfer. | +| Saprotrophic wood fungi, including birch polypore (*Fomitopsis betulina*) | On suitable natural deadwood/litter only, advance its existing decay age by at most **60 seconds per eligible evaluation**, capped at 7,200 / 1,800. The ordinary substrate transition consumes deadwood to litter to air, paying **three then one** soil nutrients once, subject to cap eight. Removing the support removes its natural fungal occupant. The fungus itself has no extra nutrient payout. Soil-growing saprotrophs receive no special nutrient effect. | [Birch polypore, Distribution, habitat and ecology](https://en.wikipedia.org/wiki/Fomitopsis_betulina) describes birch-specific brown rot. Existing host suitability remains authoritative. Natural parasitism on weakened living birches is **not simulated**. No attacks on living/player trees. | + +The sampler collects `species_counts` (including canonical alder roots, moss and +duckweed) within its existing **5x5x5** stencil. Mat feedback uses only the +sample's stand level, with natural/protection/placement checks on the mat and its +immediate support; claimed, planted, protected, unsupported or unloaded sources +do not contribute. A partially unloaded sample grants no derived mat effects. +`E.habitat(base, observations)` returns a copy and exposes `source_moisture`, +`source_ph`, and `source_light`. Succession calls it **once after deriving fresh +base moisture, support-class pH and aquatic classification**. Repeated habitat +reads cannot accumulate acidification or retention and never change metadata. +Consumers must not feed an already-derived habitat back as a new base sample. +All aquatic stands still occupy air above water: no rule clears, replaces, +refills or converts a water-source node. These habitat estimates drive botanical +niches, not the renderer's actual node light or a global pH map. + +Existing season-gated browse, flowering/pollination and nursery counts are also +reported, as is canopy shade/moisture (pine/spruce evergreen; deciduous Winter +influence 15%). Nursery cover reduces stress accrual to 75%; pollination is an +input, not a new berry-yield promise. Engineer tags on other species remain +**descriptive / not simulated**: no automatic bank stabilization, erosion or rock +weathering. Resin, allelopathy, toxicity attacks and living-tree parasitism are +explicitly not simulated by this module. Article food/medicinal statements never +grant food permission, poison attacks, healing or recipes. + +Focused evidence is in [species_effects_spec.lua](../tests/species_effects_spec.lua) +and [succession_spec.lua](../tests/succession_spec.lua): all-80 source mapping, +descriptor honesty, quantitative mat caps and changed real niche eligibility, +read-only/repeated sampling, source/support protection, shared durable nitrogen, +no duplicate fungal nutrients, and source-water preservation. These are standalone +Lua tests, not a claim of full-engine or multiplayer validation. + +## Safety and execution contract + +- One scheduler runs once per second, doing **at most eight column probes total**, + shared round-robin across connected players (not eight per player). Columns scan + a 25×25 square and twelve vertical nodes (+6..−5) around a player, seeking existing natural + ground. No connected players means no ecological work. +- Each probe checks at most twelve heights and one site. Encountered foliage, + including old unmarked mapgen leaves, is reconciled on that same probe: **at most + twelve appearance swaps per column, 96 per second total**, not a second player + radius scan. An LBM reconciles each controlled flora node on load without + emergence or a world registry; that load-driven work is one swap per callback, + separate from the scheduler's per-second budget. Habitat uses a fixed + 5×5×5 stencil; site safety uses a fixed 3×3×3 stencil. Mutations per evaluated + site are at most two (mature seed plus litter); other stages write at most one. + Wildlife calls scan at most nine candidate sites and mutate at most one. +- Only `get_node_or_nil` reads are used. Missing/ignore nodes fail closed. There + is no emerge, force-load, mapgen edit, ABM, per-node timer or offline replay. +- Ages accrue from consecutive observations, not from absolute planting dates. + First observation after restart, load, cache eviction, disconnect, clock rewind, + or a gap longer than 1,200 seconds only establishes a baseline. The load callback + invalidates observations when vegetation or its natural support reloads. A stalled + server step gets one fixed budget, never a backlog of simulation steps. +- A bounded 8,192-entry in-memory observation cache is disposable (enough for ten + disjoint stationary-player scan squares). Age, stress, + next-attempt timestamp, natural-origin flag and attempt serial live in node + metadata; soil stores only its nutrient count. There is no global save schema. + Durable attempt timestamps/serials prevent repeated evaluation or rerolling + a blocked seed by reconnecting/restarting. Restarting pauses growth; it cannot + accelerate it. Ages are sampled approximations between visits, not per-tick + accounting; very busy/many-player worlds may deliberately progress more slowly. +- Source, destination and natural supports must all be unclaimed, externally + unprotected and free of placement metadata. All normal content placement marks + `player_placed=1`, including hand-placed soil/turf and soil left by crop harvest. + Recognized legacy markers are + `owner`, `planter`, `placed_by`, `hearth_placed`, `hearth_player_placed` and + `planted_at`. Unknown third-party non-succession metadata also opts out + conservatively. Any nonempty/nonzero marker opts out. Nearby markers/builds/farms + also block succession conservatively. Only explicitly natural simulation plants + may be consumed, replaced or decayed. Never replace water or arbitrary + `buildable_to` nodes. Old unmarked player earth cannot be distinguished from + mapgen earth: claim it to keep nature out. + +## Wildlife interface + +`hearth.succession` is installed by the content registrar: + +- `forage(pos) -> boolean`: consume exactly one nearby active natural + `meadow_grass`, `fern`, `clover`, `sedge` or `nettle` on safe natural support. + Does not eat dormant flora, flowers, crops or player plants. +- `deposit(pos) -> boolean`: place exactly one dung nutrient patch in nearby + **air** over safe natural support. Never replaces existing plants or water. + Nutrients arrive through active decomposition, not immediately on deposit. +- `habitat(pos) -> table`: read-only counts `grass`, `flowers`, `cover`, `water`, + `edible`, `pollination`, plus `ground`, `nutrients` (0..8), biome `heat`, `canopy`, + estimated `light`, `moisture` and `loaded`. Counts use the fixed + local stencil; wildlife should reject an incomplete (`loaded=false`) sample. +- `step(dtime) -> number`: registered scheduler; returns columns processed. + Exposed constants document the scheduler's fixed production limits, not settings. + +Positions must be finite near-world-range vectors; APIs round to node coordinates. +Callers supply animal ground-cover height (the air/plant cell above soil), not the +supporting soil or the animal's eye height. Calls return false when no safe target +exists. Habitat has no writes, timers or protection-violation reports. + +## Assets and verification + +### Exact registered node / art integration contract + +All names below have the `hearth:` namespace. Existing canonical nodes retain their +names and callbacks/groups/drops. Summer always uses the existing canonical tree +and leaves node; no redundant summer variant is registered. + +- Legacy `tree_seedling`, `young_tree`, `mature_tree`, `leaves` retain `_spring`, + `_autumn`, `_winter` IDs for save compatibility (12 variants). The content + registrar removes old mesh/atlas references from every legacy stage variant: + seedlings use `hearth_sapling.png`; young/mature trees use log top/side textures + and remain normal cubes. Seasonal appearance belongs to their separate leaves. +- Deadwood uses `hearth_log_top.png` on both ends and `hearth_log_side.png` on + the remaining faces, with no mesh, custom collision/selection or seasonal variant. +- Mapgen leaf variants use `hearth_leaves_spring.png`, `hearth_leaves_autumn.png`, + `hearth_leaves_winter.png`; canonical Summer uses existing `hearth_leaves.png`. +- `meadow_grass_winter`, `fern_winter`, `wildflowers_autumn`, `wildflowers_winter` + use `hearth_meadow_grass_dormant.png`, `hearth_fern_dormant.png` and + `hearth_wildflowers_dormant.png`. Other seasons use existing canonical nodes/art. +- `spring_greens_patch` and `autumn_mushrooms_patch` are dormant canonical nodes; + `spring_greens_patch_spring`, `autumn_mushrooms_patch_autumn` are active variants. + Textures: `hearth_spring_greens.png`, `hearth_spring_greens_dormant.png`, + `hearth_autumn_mushrooms.png`, `hearth_autumn_mushrooms_dormant.png`. +- Picked craftitems (not nodes): `hearth:spring_greens`, `hearth:autumn_mushrooms`; + icons `hearth_item_spring_greens.png`, `hearth_item_autumn_mushrooms.png`. + +The legacy seasonal set adds **20 nodes total**, including both canonical food patches. All variants +are hidden from creative inventory, retain base callbacks and drop definitions, +and cannot become duplicate harvest sources by toggling. The registrar honours +explicit mesh tiles directly; it does not require `override_item` in engine mocks. + +### Main-agent wiring and exact diversity interface + +The blockshape change owns only diversity, content, biomes, their three focused +tests and this document. Succession, ecology, seasons, botany and generated assets +remain other owners' responsibility. Exact integrated order: + +1. Create/install `hearth.seasons = dofile(modpath .. "/seasons.lua")(hearth, api)`. +2. Create/install `hearth.botany = dofile(modpath .. "/botany.lua")(hearth, api)`. +3. **Before succession setup**, create/install + `hearth.diversity = dofile(modpath .. "/diversity.lua")(hearth, api)`. + This factory immediately extends `seasons.add_variants` with species policy. +4. Instantiate succession using its factory and assign `hearth.succession`. +5. After normal content, register/install biomes (ten species' logs/leaves/planks). +6. Call `hearth.diversity.register_nodes(register_node)` with the bare-name wrapper: + 30 species stage nodes, eight existing understory nodes and the bootstrap LBM. +7. Call `hearth.botany.register_nodes(register_node)`: exactly 62 new non-tree taxa, + with all explicit tiles and inventory/wield images preserved. The ten trees + and eight existing understory nodes are not registered twice. +8. Call `seasons.register()` after every canonical node, then instantiate wildlife. + +The returned diversity table exposes: + +- `species_order`, `plants`: ordered ten/eight canonical short names. +- `grounds`, `rocks`: exact short-name membership sets; `is_ground(node_or_name)`, + `is_rock(node_or_name)` and `supports_ground(kind, support)` are boolean policies. +- `tree_info(kind_or_node) -> species, stage`: two Lua return values, or nil; + stages are `seedling`, `young`, `mature`. Seasonal names canonicalize first. +- `family(kind_or_node) -> canonical_family`: species stages map to legacy + `tree_seedling`, `young_tree`, `mature_tree`; species leaves/logs map to + `leaves`/`log`; herbs retain their own canonical kind. +- `supports_species(species, surface)`, `suitable(kind, habitat, season?)`: + support/niche policy with optional biome integration. +- `choose_recruit(pos, habitat, season) -> understory_kind_or_nil`; + `choose_tree(pos, habitat) -> species_seedling_kind_or_nil`. Habitats supply + `ground` as a short name and normalized `moisture`, `light`, `loaded`, + `nutrients`, `canopy` and optionally biome `heat`. +- `edible(kind, epoch?)`, `pollinator(kind, season)`; + `is_worldgen_flora(kind)`, `bootstrap(pos, node?) -> boolean`; + `register_nodes(register_node)`, idempotent node registration. + +### Geometry handoff to ecological growth + +`diversity.tree_plan(species, stage?) -> plan | nil` is read-only. Species is one +of the ten IDs or `"generic"` for legacy trees; stage is `"young"` or `"mature"` +(default mature). Invalid IDs/stages or missing biome integration return nil. + +`plan` is a fresh dense array of `{x, y, z, name, param2}` records: integer offsets +relative to the stage root at `(0,0,0)`, fully qualified node names, and facedir. +`plan.size={x,y,z}` gives the bounding schematic dimensions. There are **no air +records**. The root is `hearth:_`; remaining trunk/branch and canopy +records use `hearth:_log` and `hearth:_leaves`. Generic plans use +oak geometry with `young_tree`/`mature_tree` roots and canonical `log`/`leaves`. +Every non-root occupied cell exactly matches +`biomes.tree_schematic(species, canonical?, stage?)` (x-fastest, then y, then z). +Young plans are at most 3×5×3; mature plans at most 9×10×9. Mutating a returned +plan cannot change later plans or mapgen geometry. + +**Ecology owner:** preflight the complete bounded plan, loaded supports and all +destination claims/protection/provenance before writing. Only the exact existing +natural root and verifiably owned tree components may be reused on stage growth; +never overwrite water, player nodes, farms or unrelated flora. Track component +ownership/root association and stale callbacks; reject partial growth, coordinate +overflow, unloaded cells and blocked transitions. Preserve species identity and +handle component removal/death within the existing scheduler budget. This API +neither applies geometry nor migrates/expands old roots on load. Mature roots +must not be treated as finished trees without their real block canopies. +When ecological death creates fresh `hearth:deadwood`, supply `param2=12` (or `4` +for a Z-oriented log) immediately; the load migration is for legacy saves, not +a substitute for orienting newly created fallen timber. + +`seasons.add_variants(kind, definitions, active_seasons?)` is available immediately +after factory creation and before registration. Definitions map `Spring`, `Summer`, +`Autumn`, `Winter` to `{texture=..., mesh=...}`. An optional active-season boolean +set controls availability. Missing base nodes in mocks/optional integrations are +skipped without an assertion; a later registration call can register their variants. +No original 12-month climate calculation or preview-clock semantics changed. + +### Exact species/understory art contract + +For **all ten** species in `species_order`: + +- Seedlings use `hearth_botany_.png` on a plantlike sprite. Young/mature + roots and species timber use `hearth__log_top.png` on both ends and + `hearth__log_side.png` elsewhere. Planks use `hearth__planks.png`. + No ecological tree definition references a mesh or atlas. +- Biome-owned full-block leaves use `hearth__leaves.png`. Deciduous + seasonal variants use `hearth__leaves_spring.png`, + `hearth__leaves_autumn.png`, `hearth__leaves_winter.png`. + Pine **and spruce** leaves retain the base texture throughout the year. +- Deciduous stage/leaf variants append `_spring`, `_autumn`, `_winter`; Summer + restores the canonical node. Seedling variants keep the botany tile. Young/mature + seasonal IDs are retained for existing saves but have the **same bark cubes**, + never foliage atlases. Their availability still pauses in Winter for deciduous + species. Diversity registers those multi-face cube variants before the seasonal + foliage copier; seasons remains responsible for name selection and swapping. + +Understory base textures are `hearth_clover.png`, `hearth_nettle.png`, +`hearth_heather.png`, `hearth_bluebell.png`, `hearth_sedge.png`, +`hearth_bulrush.png`, `hearth_moss.png`, `hearth_lichen.png`. Clover, nettle, +heather, bluebell, sedge and bulrush additionally use +`hearth__dormant.png` in Winter (nodes append `_winter`). Bluebell also +has `_summer` and `_autumn` node variants using its same dormant texture. +Moss and lichen have no dormant art. No art is generated or modified here. + +With all biome leaves installed, diversity adds **142 nodes**: 38 canonical nodes, +72 deciduous stage variants, 24 deciduous full-block leaf variants and eight +understory variants. Pine and spruce add no seasonal variants. Botany independently +adds 62 nodes. Species provenance on generic deadwood remains metadata. + +The standalone succession fixture tests growth, niches, shade, competition, +seedlings, seeds, nutrient feedback, dung/fungi/decay, grazing, source/destination/ +support protection, placement markers, unloaded nodes, work/read budgets, cold, +restart cooldowns and no-catchup behavior. Content tests check node registration, +named textures, wildlife API wiring and placement marking. Seasonal fixtures add +all four seasons, northern/southern calendars, seasonal foliage/restoration, +metadata/age conservation, forage cooldowns/restart, niches/occupation, protected +and unloaded no-write cases, target-volume clearance and the 96-swap bound. +The blockshape-focused standalone suites are diversity, biomes and content; +botany has its separate catalogue suite. They check exact ten-species ordering, +distinct mature schematics, bounded young canopies, shared rooted geometry, +metadata-safe deadwood migration, axe-only harvesting, all 78 unique recipes, +62 additional botany nodes and preserved aquatic icons/placement guards. +Engine visuals, +multiplayer ecology balance and long-session woodland density still require +in-engine playtesting; unit fixtures are not a performance benchmark. + +Security review: no credentials, cryptography, certificates, network requests, +deserialization or unbounded external inputs were introduced. Mutation authority +is least-privilege nature (no player identity); loaded/protection/marker checks +fail closed before writes. diff --git a/docs/launch-features.md b/docs/launch-features.md new file mode 100755 index 0000000..1a32829 --- /dev/null +++ b/docs/launch-features.md @@ -0,0 +1,266 @@ +# Season VI launch feature checklist — The Last Hearth + +Updated: 2026-11-02. Carried forward from the engine-checkout checklist, +doc/season-vi-launch-features.md, with standalone-repository links. Status: +**proposed launch scope, not accepted features**. M0 technical gates and M1/M2/M3 +implementations with automated preflight are complete; M4 technical implementation +and automated verification are complete. Human M1 repeat/performance, M2 +first-night, M3 two-player, and M4 week-long acceptance remain open. No launch checkbox is +approved. See [M1](m1-verification.md), [M2](m2-verification.md), and [M3](m3-verification.md) evidence. + +The working contract is exact Luanti **5.18.0**, game **0.5.0-m4**, save schema +**1**, native-first Windows/Linux intended development, macOS portable runner +unverified; browser launch readiness is not promised. User-directed M2/M3 +implementation does not waive earlier human acceptance gates. The +[provisional hardware/network targets](m0-decisions.md#provisional-performance-acceptance-targets--not-measurements) +are not measurements or certified minimum specifications. The user/operator +must approve community location before shared climate staging; M0 synthetic 45°N, +0°E is not production selection. + +Initial M1 slices exist for F01 (controller/course), F02 (tool/interact feedback), +F09 (opt-in practice/sparring), F14 (context/route onboarding), and F15 (generated +assets/audio). Their launch checkboxes stay open until the full criteria below, +including human and real-client evaluation, are evidenced. + +## The launch promise + +**An enjoyable first evening, a home worth improving, and a reason to return.** + +Players should gather, prepare, build, and survive through decisions rather than +repetitive clicking or opaque recipes. Real-world days and seasons shape those +decisions. Cooperative settlement life provides the initial long-term purpose; +scripted adventures are not required. + +The [prototype](../README.md) establishes plumbing and a small +interaction loop, not a finished survival game. Existing unit and engine tests +do not establish that it is fun, secure for public hosting, or performant at scale. +See the [implementation roadmap](roadmap.md) for ordering and gates. + +## Priority and completion rules + +- **Required:** blocks the public Season VI launch. +- **Conditional:** decide at the stated milestone; do not silently grow the scope. +- **Later:** explicitly outside the launch requirement. + +Every required feature needs automated tests where appropriate, a documented +save-data contract, player-facing feedback, and a multiplayer playtest. A checkbox +stays open until its acceptance criteria are demonstrated, not merely coded. +All entries remain unchecked, including replacements for existing prototype behavior. + +## Required gameplay features + +### F01 — Responsive movement and camera +- [ ] Predictable walking, sprinting, jumping, stepping, slopes and swimming; + smooth speed transitions rather than stamina-threshold oscillation. +- [ ] Surface-aware footfalls, landing feedback, readable exertion, and settings + for camera motion/FOV. Avoid forced head bob or camera shake. +- [ ] A test course covering narrow doors, stairs, water edges, rough ground, + low ceilings, falling, and ordinary network latency. +**Acceptance:** no reproducible collision traps on the course; movement remains +pleasant without upgrades, at supported frame rates and the chosen latency envelope. + +### F02 — Satisfying tools and physical interactions +- [ ] Recognizable held tools and first-person use feedback; distinct cutting, + chopping, digging, harvesting, placing and repair actions. +- [ ] Sensible reach, durability, material requirements, yields and pickup behavior. +- [ ] Context prompts and clear reasons when an action is impossible. +**Acceptance:** gathering and building feel substantially different from punching +untextured cubes; players understand tool choice without reading source code. + +### F03 — Discoverable crafting and process progression +- [ ] Searchable recipe/process guide with ingredient sources, workstation needs, + quantities, alternatives and locked/unavailable explanations. +- [ ] A small complete progression: improvised tools → timber shelter/workbench → + fired pottery and storage → one useful early metalworking tier. +- [ ] Functional workstations, timed processing and repair/replacement routes. + Processing permits other activities instead of holding a button for minutes. +**Acceptance:** the first tool, meal, shelter and later production upgrade can +all be achieved without administrator help or a wiki; no circular recipe locks. + +### F04 — Construction that creates useful homes +- [ ] A coherent palette: foundations, timber frames, walls, floors, roofs, + stairs, doors, windows and a simple chimney/vent. +- [ ] Placement preview, rotation, support feedback, recoverable mistakes and repair. +- [ ] Bounded support rules applied consistently to structural pieces, including + removal of supports. Choose refusal or controlled local failure, not arbitrary + survival of floating roofs or unbounded collapse cascades. +**Acceptance:** a small home is attractive, usable and realistically supported; + dirt, planks or decorative nodes cannot accidentally bypass the stated rules. + +### F05 — Reliable shelter, heat and weather protection +- [ ] Replace four-ray enclosure with a bounded room/roof model that recognizes + doors, openings, material protection and ventilation. +- [ ] Explain heat, wetness, wind/rain exposure and drying through useful feedback. +- [ ] Predictable hearth heating and smoke risk; no damage through solid barriers + or unexplained penalties for a properly vented room. +**Acceptance:** exposed camp, roof-only lean-to and enclosed vented cabin have + different, understandable outcomes; performance remains bounded in a large house. + +### F06 — Fire, lighting and cooking +- [ ] Fire-starting tools, tinder, fuel types, ignition feedback and clear burn time. +- [ ] Timed cooking/boiling with safe input/output handling and a visible process. +- [ ] Craftable torches or lamps so real-world nighttime is viable immediately. +- [ ] A deliberate initial fire policy: no uncontrolled spread across claims; + remove and refuel operations cannot duplicate resources. +**Acceptance:** lighting a first fire is satisfying, darkness is navigable, and + leaving/rejoining cannot refresh fuel or duplicate cooked output. + +### F07 — Food, water and a sustainable resource loop +- [ ] Renewable forage, freshwater collection, and a few crops with seasonal behavior. +- [ ] At least two viable food strategies, including one that does not require combat. +- [ ] Useful cooking and preservation; simple pantry/storage benefits. +- [ ] Resource renewal rules for plants, timber and arrival-area depletion. +**Acceptance:** an established village remains viable without endlessly migrating + for berries; late joiners are not stranded by earlier harvesting. + +### F08 — Needs, clothing, recovery and fair consequences +- [ ] Tune hunger/thirst/warmth/stamina as interacting systems, not simultaneous chores. +- [ ] A small functional clothing set with wetness, warmth and wear; rest/bedrolls + support recovery without skipping the shared world clock. +- [ ] Basic treatment and clear injury feedback; remove prototype death-as-free-refill. +- [ ] Pick a recoverable death policy, safe home respawn and fallback arrival, + with item protection/recovery and no unavoidable repeated deaths. +**Acceptance:** preparation changes survival outcomes; mistakes hurt without + routinely ending a player's participation. Initial launch does not require permadeath. + +### F09 — Real combat and a small living world +- [ ] Replace the stationary target as the main demonstration with a moving opponent. +- [ ] Readable anticipation, contact and recovery; weapon reach, aim, defense, + stamina and positioning matter. Add audiovisual hit/miss feedback. +- [ ] A small wildlife set: a passive/foraging animal and one dangerous encounter, + with navigation, avoidance, bounded population and clear disengagement behavior. +- [ ] Server-side validation of attacks/resources, including reach, obstruction, + timing and plausible movement; test prediction/correction under latency. +**Acceptance:** one encounter is exciting repeatedly and has counterplay other + than repeated clicking or permanent stun lock. PvP remains off for initial launch. + +### F10 — An interesting, fair starting world +- [ ] Distinct local terrain, vegetation and material distributions that reward + ordinary exploration without needing authored dungeons. +- [ ] Test arrival-area resources across many seeds, not only the fixed demo seed. +- [ ] Safe asynchronous arrival and revalidation of saved spawn/home locations. +- [ ] Remove the prototype's blanket teleport below Y=-100; distinguish actual + out-of-world recovery from legitimate underground travel. +**Acceptance:** valid starts provide a feasible path to water, tools, food and + shelter; emergence failures and modified spawn terrain do not trap players. + +## Required calendar and persistence features + +### F11 — Trustworthy real-world calendar and climate +- [ ] Player-selected approximate location for local worlds; explicit shared + community location for a dedicated server, with no silent production fallback. +- [ ] One real hour per world hour, correct hemisphere/day length and a documented + sky approximation or tested custom rendering improvement. +- [ ] Configured civil timezone display with UTC available; DST changes labels, + not elapsed time. Clearly distinguish solar time, clock time and season labels. +- [ ] Continuous seasonal temperature/weather with altitude effects and readable + forecasts/signs; avoid abrupt rerolled six-hour fronts. +- [ ] Test seasonal extremes, leap days, clock corrections and restarts using + isolated staging fixtures unavailable to ordinary production players. +**Acceptance:** a same-date restart preserves conditions, nighttime play is fun, + and players can survive the actual launch season rather than a hidden spring fixture. + +### F12 — Explicit offline and unloaded-area simulation +- [ ] Publish per-system rules: absent players' needs pause; food/crops/fuel use + bounded, consistent elapsed-time accounting independent of loaded mapblocks. +- [ ] Preserve age through splitting, merging, transfers and logout; conservation + tests cover crafting, storage and process completion. +- [ ] Preservation/storage makes occasional attendance viable before severe decay + is enabled. No surprise irreversible backlog simulation after long downtime. +- [ ] Versioned save schemas, validated inputs, backups before migration, and + restart-safe process completion without duplicated output. +**Acceptance:** identical elapsed time produces equivalent outcomes in loaded and + unloaded areas; a week away does not kill a character or silently reset progress. + +## Required community and presentation features + +### F13 — Shared homes, storage and cooperative permissions +- [ ] Protected containers and workstations; explicit shared access and claim roles. +- [ ] Visible claim boundaries and understandable build/access denial. +- [ ] Protection across harvesting, fluid changes, construction failure, fire, + containers and combat—not just placement and digging callbacks. +**Acceptance:** two players can run a household together while an unauthorized + player cannot take its contents or indirectly destroy it. + +### F14 — Onboarding and usable survival interface +- [ ] Short optional hands-on introduction, contextual help and craft discovery. +- [ ] Useful item icons, inventory sorting/transfers, stack feedback and hotbar flows. +- [ ] Needs explain causes/remedies, not only bars; scalable UI, readable contrast, + remappable controls and non-color-only warnings. +- [ ] Late-winter arrival and reconnect flows; pause needs during protected initial + setup where appropriate, without an exploitable permanent survival shield. +**Acceptance:** unfamiliar testers complete a first meal/tool/shelter route without + coaching. Suggested first-session budget: 30–60 minutes, adjusted after observation. + +### F15 — Cohesive visual and sound identity +- [ ] Original or properly licensed terrain, tools, plants, character model and + movement/use animations; consistent scale and recognizable silhouettes. +- [ ] Positional material sounds, fire, water, weather, wildlife and combat feedback. +- [ ] Restrained original music with independent volume/accessibility controls. +**Acceptance:** a short gameplay clip looks and sounds like one authored game; + critical actions are not silent and feedback is not audio-only. + +### F16 — A simple eom.dev entrance and social connection +- [ ] A clear Play page: premise, screenshots, supported clients, join instructions, + rules, privacy notice and troubleshooting; no misleading universal-browser claim. +- [ ] Account-linking path to platform identity, with control proven on both sides, + no username/email-only merges, and recovery/unlink policy. +- [ ] Optional Matrix bridge with public-chat disclosure, moderation/rate limits, + loop prevention and graceful failure; Discourse season thread and feedback links. +**Acceptance:** a new visitor knows how to join; external service failure cannot + stall gameplay or grant game privileges. No paid survival advantages. + +## Required release engineering + +### F17 — Reproducible client/game releases +- [ ] Stable game packaging outside the engine source lifecycle; pinned engine, + game, dependencies, assets, license manifest and versioned client contract. +- [ ] Repeatable install/update flow on supported systems and clean-machine testing. +- [ ] Decide native-first versus browser-at-launch early. If browser is advertised, + test actual hardware/input/JSPI constraints, security headers and proxy isolation. +**Acceptance:** a clean machine can install and join the exact tested release; + incompatible clients get clear errors instead of corrupt gameplay. + +### F18 — Safe dedicated-server operation +- [ ] Staging and production isolation, persistent storage, graceful saves, + health/readiness checks, resource limits and protected configuration/secrets. +- [ ] Consistent backups and demonstrated full restoration of map, players, claims, + inventories and process state; an actual rollback procedure. +- [ ] Moderation/reporting tools, access/rate limits, dependency review and + production defaults that disable test/debug powers. +- [ ] Operational dashboards/logging without credentials or precise player-location + leakage; published maintenance and incident expectations. +**Acceptance:** host a closed community beta safely and recover it from a deliberately + simulated failure before production launch. Existing server role is not assumed ready. + +### F19 — Performance, exploit and retention validation +- [ ] Functional, conservation, migration and restart tests; real clients in + multiplayer, not only fake-engine unit harnesses. +- [ ] Measure frame times, server step latency, corrections, memory, media transfer, + entity counts and unloaded catch-up cost against declared hardware/player targets. +- [ ] Test reach/cadence abuse, inventory duplication, claim bypass, clock changes, + disconnect/reconnect, full storage, and prolonged process interruptions. +- [ ] First-night, next-session and week-away playtests; record where people stop + enjoying themselves. Use opt-in feedback and minimal aggregate metrics. +**Acceptance:** no unresolved critical loss/duplication/security defect; returning + testers choose a self-directed next goal rather than only following instructions. + +## Conditional features: decide, do not accumulate + +| Feature | Decision point | Default if not justified | +| --- | --- | --- | +| Custom movement/combat engine fork | First feel prototype (M1) | Use stock engine only if it meets the feel target; otherwise fund a bounded fork spike, not a silent quality cut. | +| Exact latitude-aware sky orbit | Clock validation (M2) | A disclosed, validated approximation is acceptable; keep real calendar/day length. | +| Browser client at public launch | Platform contract (M0), proof in M1 | Native-first, with an honest supported-client list. Browser becomes required if promoted as launch-ready. | +| Dynamic local collapse | Building prototype (M3) | Refuse unsafe support removal with clear feedback; no global physics simulation. | +| Detailed nutrition, illness and tool-specific injuries | Survival playtests (M3/M4) | A small understandable needs/treatment model. | + +## Explicitly after launch + +Vehicles/moving cities, elaborate metallurgy beyond the first useful tier, +authored ruins/dungeons, supernatural threats, AI residents, unrestricted PvP, +faction economies, world-restoration machinery, complex breeding/ecosystems, +shared currencies with other services, and automatic scheduled world wipes. + +No feature count makes the season ready by itself. The test is whether the +survival/building loop is enjoyable, understandable and reliable with real people. diff --git a/docs/m0-decisions.md b/docs/m0-decisions.md new file mode 100755 index 0000000..03ffde4 --- /dev/null +++ b/docs/m0-decisions.md @@ -0,0 +1,129 @@ +# M0 decisions and verification ledger + +Updated: 2026-11-02. **Historical M0 record:** M0 completed against its verified +Windows technical gates. M1 implementation and automated verification are now +complete; human route/repeat/performance acceptance remains pending (see +[M1 evidence](m1-verification.md)). This is the M0 working rules/engineering +baseline, not user/operator sign-off of policy or deployment. See +[roadmap](roadmap.md) and [launch acceptance checklist](launch-features.md). + +## Version and platform contract + +| Decision | Baseline / qualification | +| --- | --- | +| Engine | Exact stock Luanti **5.18.0**, checked by launcher version response. The broad minimum in [game.conf](../game.conf) does not widen this tested contract. | +| Game | **0.5.0-m4** current development release candidate, independent game repository/package; engine stays separately installed. M0/M1/M2/M3 versions below are historical evidence snapshots. | +| Save | **Schema 1** envelopes for needs, claims, homes, access, location and spawn; legacy validation/migration, not a promise to accept arbitrary future data. | +| Tooling | Node.js **22+**, built-in modules; release/package/backup format **1**. See [release metadata](../release.json). | +| Clients | Native-first; Windows integrated and GUI checks verified on the existing development host. Linux/macOS path selection unit-tested only; native execution unverified. No clean-machine certification. | +| Browser | Not promised launch-ready. Advertise only after an explicit scope decision and actual M1 input/network/graphics/performance proof. | +| Engine changes | Stock baseline first; only a bounded M1 spike justified by measured feel/API limitations. No fork assumed. | + +## Rules retained through M0 + +| Area | Current rule / next decision | +| --- | --- | +| Death | Preserve inventory; respawn resets needs to existing defaults (food 85, water 85, warmth 80, wet 0, stamina 100) and attempts arrival. This temporary free-refill behavior remains a known limitation until M2; no final fair-recovery approval. | +| Offline | Logged-out player needs do not decay. Fuel uses absolute real-time expiry (maximum stored reserve read as 600 seconds). No queued cooking, crops or food spoilage yet; no invented offline crop/food contract. M4 owns bounded process accounting. | +| Claims | One per owner; 25 × 25 inclusive footprint (±12 X/Z), ±24 Y; up to 16 shared residents/storekeepers, action-specific access and claim bounds shown on request. Placement centers must be at least 26 blocks apart on an X or Z axis. General indirect-fluid/fire grief and public multiplayer authorization remain limited. | +| Progression ceiling | Preserve improvised flint tools, small timber/thatch palette, hearth, cup and forage loop. Pottery/storage and one useful metal tier are planned M4 scope, not M0 features. | +| Calendar | Host real UTC, local solar time and meteorological season labels; disclosed sky/climate approximation. No civil timezone/DST support yet (M2). Rest does not skip the shared clock. | +| Location | Local choice stays local; dedicated server needs explicit valid admin settings. No geolocation request. **User/operator must approve the community location before M2 climate staging.** No location is chosen by this document. | +| Fixtures | Seed 731104; synthetic 45°N, 0°E for M0 checks and fixed summer/winter. This is not production configuration or community-location approval. | + +For deployment, administrators must explicitly set `hearth_environment` to +`staging` or `production` and set `hearth_latitude` and `hearth_longitude` to +approved values. Dedicated defaults to production; dedicated `local` is rejected. +Production rejects nonempty `hearth_preview_date`, weather other than `auto`, and +any presence of `hearth_launcher_mode` or `hearth_smoke_token`. Remove those test +keys, rather than assuming a false/empty launcher flag is safe. Local managed +play explicitly uses `local`. These guards are not a complete server deployment. + +## Provisional performance acceptance targets — not measurements + +These are **implementation decisions to test**, not measured results, certified +minimum hardware, or accepted launch budgets. They may be revised explicitly in +M1 after evidence, with user/operator review of any scope impact. + +| Target | Provisional acceptance envelope | +| --- | --- | +| Native client | 4-core CPU, 8 GB RAM, integrated GPU supporting OpenGL 3.3; 1920 × 1080, low settings, view range 96 nodes. Target 60 FPS and p95 frame time ≤25 ms. | +| Server | 4 vCPU, 8 GB RAM, **10 concurrent real clients**; p95 server step ≤50 ms in the declared course/load scenario. | +| Network | Test RTT **0 / 80 / 150 ms**, jitter **20 ms**, packet loss **1%**; record actual achieved conditions and an unshaped reference. | + +FPS and p95 frame time are separate checks; neither is inferred from the other. +Record CPU/GPU models, OS/driver, engine/game revision, settings, route duration, +warmup, sample count, client count, shaping method/direction and actual latency. +Report memory, corrections, media transfer and stalls as observations before +inventing additional limits. No benchmark or 10-player result was produced by +this M0 record. The [M1 fixture plan](roadmap.md#m1-course-fixture-specification-implemented-human-runs-pending) +defines the next controlled experiment. + +## Storage, migration and recovery decisions + +- `HEARTH_HOME` selects the local runtime root. Keep the existing Windows root + and world unchanged; use a different absolute override for staging and an + explicit `HEARTH_ENGINE` if needed. Production data/configuration belong to a + separately operated external deployment, not the repository or game package. +- Packaging is a new game directory with deterministic generated assets and + exact SHA-256 inventory, not an installer or engine bundle. Package/backup/ + restore refuse existing destinations and unsafe paths/links. +- All relevant local/external/remote engines must be stopped before snapshots + or restore. `--offline` is mandatory for CLI backup/restore. Lock checks are + conservative signals, not proof of remote idleness. +- An existing managed play world gets a full prelaunch snapshot. Legacy records + also get immutable per-key backup bytes before schema migration. Per-key + rollback is best-effort, not an engine metadata transaction or crash-durability + guarantee. Full offline snapshots remain necessary. +- Backups contain private authentication/player/world data. The operator owns + access control, encryption, capacity/retention and off-host copies. SHA-256 + detects corruption, not authenticity. No automatic retention deletion exists. +- Restore to a new destination, verify and rehearse before an explicit cutover; + never overwrite the sole old world. Recovery tooling boots copies only. See + [README procedures](../README.md#package-back-up-and-restore) and + [persistence architecture](architecture.md#save-and-migration-contract). + +## Verification and acceptance ledger + +**M0 technical verification is complete within the Windows scope below.** No +user/operator approval is recorded. The [final verification record](m0-verification.md) +contains current counts, package digests, environment, commands, bounded logs and +the distinct earlier recovery report. Private logs/snapshots remain outside the +repository; neither source paths nor source-world contents are published. + +| Gate | Owner | Current evidence / status | +| --- | --- | --- | +| Extracted game loads all modules, exact release contract | Technical agent | Complete: Luanti 5.18.0, game 0.1.0-m0, schema 1; current check/smoke and GUI require the verified package-load marker. | +| Node tooling and integrated Lua/engine suites | Technical agent | Complete: 17 Node tests; 123 Lua checks in seven isolated suites; 24 Lua files compiled; check/smoke/test markers and zero exits. | +| Fresh package verifies and repeats; tampered/missing/extra content rejected | Technical agent | Complete: two new external exports, identical manifests, 57 payload files; tamper/missing/extra/empty-directory rejection covered by passing tooling tests. Stale default dist was not reused. | +| Original old save unchanged, immutable snapshot, two restores and copy-only restart/migration | Technical agent | Complete retained evidence: 160 assertions (79 seed + 81 restart), two byte-exact restores and two successful shutdowns. Earlier package digest explicitly retained; not a new final-package recovery run. Source had zero claims; fixture/mock protection checks are not preservation of source claims. | +| Clean package and GUI play on verified development setup | Technical agent | Complete Windows technical scope: fresh external export verifies; current play loads the identical manifest digest in disposable HEARTH_HOME, client joins/leaves, normal shutdown and embedded-copy cleanup. Not a new-machine/clean-checkout certification. Linux/macOS native unverified. | +| First-session, summer/winter, production rejection, invalid/future schema refusal | Technical agent | Complete: current tooling fixture/production test and integrated schema, persistence, clock, runtime and player suites. | +| Play continuity and staging isolation; storage custody | Technical agent; user/operator for storage | Current isolated GUI pass; earlier launcher-agent two-launch regression verified metadata/sentinel continuity, two snapshots and cleanup. Original world not opened. Private ACL/encryption/retention and production authorization remain operator duties, not claimed certifications. | +| Rules, provisional scope, community location and acceptance | User/operator; technical agent for record | Rules, provisional review targets and risk owners recorded, completing M0 technical decision capture. No user approval fabricated. M1 human review and community-location approval before shared climate staging remain outstanding. | + +M0 technical work is complete; the initial M1 implementation and automated +verification are complete, but its human acceptance gate remains open. This does not +accept the full launch checklist F17–F19, authorize production, certify a new +machine or expand native support beyond the observed Windows run. Final product +and deployment acceptance stays with the user/operator. Provisional budgets and +later location approval retain their explicit milestone owners. + +## Risk register and ownership + +“Technical agent” owns implementation/investigation and evidence, not permission +to approve deployment. “User/operator” owns policy, infrastructure and acceptance. + +| Risk / known limit | Owner | Milestone / mitigation | +| --- | --- | --- | +| Integration differs from isolated tests; regression counts become stale | Technical agent | M0 checks complete and digests recorded; rerun regression suites for each M1 code change. | +| Legacy migration/crash loses state; metadata writes lack transactions | Technical agent; user/operator for backup custody | M0 evidence and M5 recovery rehearsal; preserve original, verify copy-only restore, never claim atomic durable migration. | +| Shared-world writer defeats offline assumptions; private backup leakage/storage exhaustion | User/operator; technical agent for tooling safeguards | M0 onward: stop all engines, protected/encrypted external storage, capacity/retention review. | +| Linux/native or macOS portability/clean-machine assumptions | Technical agent | M0/M1: real host evidence before expanding supported-client claims. | +| Sprint oscillation, collision/camera problems; stationary combat not compelling | Technical agent | M1: course, feedback and bounded stock-versus-custom decision. Not fixed by module extraction. | +| Unsafe saved arrival, failed emergence or death refill | Technical agent | M2 technical implementation and eight fixed-seed engine verification complete; blanket Y=-100 teleport is replaced by map-bound safe recovery, ±64-node fallback, owned-claim homes and partial needs recovery. Unfamiliar-tester acceptance remains open; see [M2 evidence](m2-verification.md). | +| Wrong community location, seasonal imbalance, timezone or sky misunderstandings | User/operator for location; technical agent for model/validation | Approval before M2 climate staging; synthetic fixtures never imply production selection. | +| Bounded room/support model and shared roles remain prototype-level | Technical agent; user/operator for acceptance | M3 implementation and automated verification complete; two-player cabin, comfort and grief-resistance acceptance remains open. See [M3 evidence](m3-verification.md). | +| Resource depletion and incomplete offline processes | Technical agent | M4: renewal/preservation before consequential spoilage and bounded idempotent accounting. | +| Public auth/moderation/deployment not hardened | User/operator for access policy/operations; technical agent for implementation | M5/M6: security/recovery review and real closed alpha before announcement. | +| Provisional budgets fail or browser scope expands silently | Technical agent for measurements; user/operator for scope | M1: report actual hardware/network evidence; revise targets explicitly, no invented measurements. | diff --git a/docs/m0-verification.md b/docs/m0-verification.md new file mode 100755 index 0000000..e634513 --- /dev/null +++ b/docs/m0-verification.md @@ -0,0 +1,150 @@ +# M0 final technical verification + +Local verification date: **2026-11-01**; retained run timestamps cross into +**2026-11-02 UTC**. **Point-in-time M0 record:** M0 was complete against its +Windows technical exit gates; M1 had not started at this verification. M1 source +and later verification are recorded separately in [m1-verification.md](m1-verification.md). +This M0 evidence is not user/operator approval, +production authorization, clean-machine certification or a performance report. +See [decisions and ledger](m0-decisions.md#verification-and-acceptance-ledger) +and [roadmap](roadmap.md). + +## Environment and evidence handling + +- Existing Windows development host; absolute Node.js **v24.22.0** executable, + Node built-ins only; separately installed official Luanti **5.18.0** selected + by explicit absolute `HEARTH_ENGINE`. The launcher verifies reported version, + not distribution authenticity. Game **0.1.0-m0**, save schema **1**. +- Final verification uses the M0-era uncommitted working tree, identified below + by exact package digests, not an invented clean commit/revision. +- Fresh check/smoke and GUI runs use distinct disposable `HEARTH_HOME` roots + outside the repository. No original world was opened by this final pass. +- Absolute Node invokes bounded subprocesses through `execFileSync`; tooling + deadline 180 seconds, combined engine deadline 240 seconds, package builds + 90 seconds each, verification 30 seconds. Both test output streams are retained + in complete external logs, not inferred from a truncated terminal preview. +- Evidence root: `%TEMP%/hearth-m0-final-VB7xU2`; tooling, engine, package and GUI + logs remain there. `%TEMP%/hearth-m0-final-latest.json` records run metadata. + These are local evidence locations, not public/downloadable artifacts. +- Only README and the three M0/roadmap documentation files are changed by this + pass. No gameplay, scripts, fixtures or default dist export is edited. +- Final validation: every captured non-documentation input hash remained + unchanged; all four documents were confirmed saved and their local file links + resolved. Editor diagnostics reported no errors. Repository-wide + `git -c core.whitespace=cr-at-eol diff --check` completed successfully with + empty output (the setting accommodates Windows CRLF without rewriting files). + +## Current automated and package results + +Commands below identify existing entry points; every invocation used the +absolute Node executable, not an assumed PATH runtime. + +| Entry point / observation | Actual result | +| --- | --- | +| `--test tests/tooling.test.mjs` | **17 passed, 0 failed, 0 skipped**; 17 named tests, not an empty-file pass. | +| `scripts/run.mjs test` | **PASS**; check, smoke and overall test markers, verified package-load markers and zero exits. No separate `HEARTH_LUA` interpreter was used. | +| Engine compilation | **24 Lua files** compiled; compilation is not counted as unit execution. | +| Isolated content suite | **19** checks. | +| Schema suite | **24** checks. | +| Runtime scheduler safety suite | **10** checks. | +| Player lifecycle safety suite | **16** checks. | +| Persistence suite | **23** checks. | +| Model suite | **11** checks. | +| Clock suite | **20** checks. | +| Integrated unit total | **123 checks across seven suites**; live-engine registry/crafting/terrain/resources/enclosure smoke also passed. | +| `scripts/run.mjs package ` twice; `verify ` | **PASS**, identical exact manifests; **57 payload files / 62 inventory entries** (manifest itself excluded). | + +The 17 tooling tests include repeatable assets/manifests, existing-output refusal, +atomic build cleanup, missing/extra/tampered file and empty-directory rejection, +binary backup/restore, offline locks and SQLite sidecars, prelaunch snapshots, +unmanaged-world refusal, links/junctions/traversal, portable path selection, +exact engine version checks, first-session/summer/winter/production guards and +CLI backup/restore/package verification. Integrated suites additionally cover +legacy/invalid/future schema behavior and runtime/player safety. + +### Exact fresh package identity + +The stale default dist export was neither trusted nor overwritten. Two entirely +new temporary destinations were built and verified. SHA-256 values: + +| Definition | Digest | +| --- | --- | +| Complete manifest file bytes | `cfd0efdc6e21d99c9f861575a65222bb52838fad38f4d61a5dd77ca034438bc9` | +| Compact `JSON.stringify(manifest)`; launcher package-load marker | `167682853c55cda4a5b5e960ec5e2cf0ef0c2f69b604fdf28e8020efbb3e009d` | +| Pretty-printed `manifest.entries` plus final LF; recovery-style inventory digest | `c5979ccbbaf3f2e245073927d43a44316ad2ca4f04b782658f1bbeb8219436f8` | + +These are different digest definitions, not contradictory checksums. The package +is a game directory, not an archive, engine bundle or installer. It excludes +worlds, launcher scripts and the full test suite. Documentation-only edits do not +affect its payload inventory. + +## Current GUI verification + +`scripts/run.mjs play` used a fresh disposable external home and explicit engine, +not the user's original world. The launcher repackaged the current inputs; its +required package-load marker matched the **exact compact manifest digest above**. +Thus GUI evidence covers identical verified payload bytes, not a direct launch +of an exported directory containing no launcher tooling. + +The log records client join, client leave and server shutdown. An external, +bounded process called `CloseMainWindow` only after join output; the launcher +exited successfully and its owned embedded game copy was removed. No sleep or +gameplay/script modification was used. This is a startup/save/shutdown check, +not a movement, fun, long-session or performance playtest. + +Earlier launcher-agent evidence separately records **two** GUI launches of a +disposable persistent-style world: metadata stayed byte-exact, a sentinel was +retained, two prelaunch snapshots were retained and embedded copies were cleaned. +That is earlier regression evidence, not two additional launches claimed here. + +## Retained recovery evidence + +The newest sanitized report was read from the external runtime backups area; +recovery was **not rerun against the original world** during this final pass. +Report identity: timestamp **2026-11-02T01:43:35.559Z**, token +`fa103b35-9f98-45e5-b429-2ecb6f38c29c`; publication mtime +**2026-11-02T01:43:56.319Z**. It is a sibling report outside the immutable snapshot. +Only sanitized summary fields were exposed; no source paths, raw records, +coordinates, credentials or player identities are reproduced here. + +| Retained fact | Report result | +| --- | --- | +| Engine / public readiness | 5.18.0 / `publicReady: false`. | +| Source preservation | `sourceUnchanged: true`; nine source files. This is the earlier runner's inventory comparison, not a new source inspection. | +| Snapshot integrity | `backupVerified: true`; immutable legacy backup bytes verified. | +| Restores / engine shutdowns | **2 byte-exact restores**, **2 successful engine shutdowns**, no clients. | +| Assertions | **79 seed + 81 restart = 160**. | +| Existing / legacy records | **2 existing keys, 2 legacy keys**; migrated records preserved. | +| Existing source claims | **0**. No assertion that nonempty source claim ownership was preserved. | +| Additional fixture evidence | Saved spawn, protection checks, synthetic player adapter, node metadata/inventory and absolute fire expiry verified. | + +That report identifies **57 package files** and recovery-style package inventory +SHA-256 `a8c1c4e1459f37ed49ca882d6b7aa68a6ddc1e2bfd0ad66c7c534b6566163ed1`. +It **differs from the fresh final inventory digest**. The report therefore proves +the earlier recovery run only; it is not relabeled as recovery of the final +export. Current final code is covered by the fresh 123 integrated checks, +17 tooling tests and GUI/package evidence above. No unsupported claim of byte +identity between earlier and final gameplay inputs is made. + +Because the source had zero claims, claim-owner integration/protection evidence +comes from regression fixtures and mocked coverage, not preservation of actual +source claims. Synthetic player-adapter and node fixtures are not a live-client +recovery rehearsal. Neither this report nor current tests promise transactional +metadata writes or crash durability. Private backup custody/ACLs, encryption, +retention, capacity and an eventual operator-controlled cutover remain separate. + +## Scope closed at M0; work retained for later milestones + +- **Windows:** current native automated and GUI verification passed on the + existing development host. No clean checkout on a new machine was tested. +- **Linux/macOS:** portable path selection has unit coverage only; native + execution remains unverified. No broader support certification is claimed. +- **M1:** not started. Hardware/network figures remain provisional review + targets, not measured/accepted budgets. No FPS, p95, multiplayer load or + latency measurements were taken. Measure and review them in M1. +- **Before M2:** the user/operator must approve community coordinates before + climate staging. Synthetic fixtures do not choose that location and the + decision is not a blocker to completed M0 technical work. +- **Approval and release:** no user sign-off is fabricated. Browser parity, + production hardening, full F17–F19 launch acceptance and clean-machine + certification are not implied by M0 completion. diff --git a/docs/m1-verification.md b/docs/m1-verification.md new file mode 100755 index 0000000..005114f --- /dev/null +++ b/docs/m1-verification.md @@ -0,0 +1,76 @@ +# M1 implementation and evidence — The Last Hearth + +Updated: 2026-11-02. Scope: M1 implementation and available automated/native +checks. **Not a human-playtest report or milestone acceptance.** M2 implementation +is proceeding under explicit user direction while M1 human acceptance stays open. +M0 evidence remains in [m0-verification.md](m0-verification.md); current sequence +is summarized in the [roadmap](roadmap.md). + +## M1 implementation present + +| Area | Implemented | Boundary | +| --- | --- | --- | +| Movement | `mods/hearth/movement.lua` applies bounded frame-time steps, smooth speed transitions, sprint intent/recovery lockout, stamina, wet slowdown, water pace, and spear brace. Runtime samples support/water/vertical velocity and changes only the engine speed multiplier. | Engine still owns character physics, camera, collision, prediction and networking. This is not a custom movement controller or full anticheat. | +| Tool interaction | Original B3D player rig with six animated clips and a right-hand bone; fixed-whitelist attached knife, axe, pick and spear visuals; contextual target/dig/placement preview; material step/dig/landing sounds. | First-person/third-person visibility depends on the native Luanti renderer. Camera motion is opt-in static FOV only; no imposed camera bob. | +| Combat | Weapon-specific reach, cooldown, stamina, wear, aim cone, line of sight and short recent-displacement plausibility check; three-phase opponent wind-up, strike and recovery; opt-in evasion/brace; non-lethal two-health floor, arena leash and disengagement. Production refuses developer spawn commands. | The moving opponent is a deterministic one-step sparring opponent, not wildlife AI or a network-robust competitive combat system. No true latency compensation. PvP stays disabled. | +| Course | Fixed-volume movement/interaction route with halves, doorway, pool, falls, material/tool and build stations, arena and ordered checkpoints. Generator exists only in a fresh nonce-marked local/staging course world. The engine-load preflight rejects non-live users and observed all 41,041 course-volume nodes unchanged. Mock tests exercise consenting generation/checkpoints; a live player must opt in with `/course`. | The native preflight intentionally did not generate or traverse the course. Its timing report is server-step intervals and Lua callback wall duration, **not** client FPS or CPU profiling. | +| Assets/audio | Deterministic generated B3D animation, four held-tool meshes, skin/material PNG and hash-pinned original mono Vorbis sounds. | Optional FFmpeg decode/regeneration test was not run; the package's Ogg framing/CRC/hash checks did run. No third-party sound/mesh was downloaded. | +| Feedback | Readable action/status HUD, contextual placement/tool hints, hit/counter/brace notices and material-specific sound feedback. | Individual comfort/accessibility and readability still need external playtesting. | + +## Verification performed on the current M1 source + +- **Node:** 31 tooling/asset tests pass, 0 fail. This includes reproducible assets, + packaged Ogg validation, animation/model geometry, safe staging/course launcher + behavior, production fixture denial, and packaging/backup path tests. +- **Lua in real Luanti 5.18.0:** 36 Lua files compile; 13 isolated suites pass, + **228 total checks** covering clock, combat, construction, content, course + security/geometry, encounters, movement, persistence, player lifecycle, + presentation cleanup, runtime safety, schema and the pure model. +- **Engine smoke:** pass. Real game bootstrap, M1 API/entity registration, recipes, + terrain/resources and enclosure test pass. Package identity marker matches the + exact staged package. No persistent survival world is opened by `test`. +- **Native `test:course`:** pass. Fresh local/staging nonce checks, native module + registration and no-consent refusal verified; 41,041 ordinary map nodes remain + byte/param-identical before and after the probe. It does not create terrain, grant + a kit, emulate a player or claim movement/combat/client rendering was exercised. +- **Interactive GUI:** one disposable summer course session has been launched on + the current Windows host for hands-on testing. A normal screenshot/engine startup + is not evidence that the route was traversed or that movement/combat is fun. +- **Environment:** current host uses Luanti 5.18.0 and integrated graphics driver + NVIDIA 596.47. No clean-machine, other-OS, multiplayer-client, FPS, frame-time, + memory, network-latency, server-load, controller, audio-listener or user-study + measurement is claimed. + +## Manual M1 acceptance still required + +1. In the disposable course, wait for the guide/arrival to finish and run + **`/course`**. This explicitly generates the bounded layout and gives a temporary + kit; until that command, terrain is deliberately untouched. +2. Traverse the ten ordered route checkpoints at walking and sprint pace. Repeat + troublesome stations and note terrain, slope, doorway, water, falling and + collision-snags with exact reproduction steps. +3. Try each held tool and attack a target. Run `/spar` for the moving opponent; + strike to consent to a round, step aside/back to evade, and face + hold RMB with + the spear to brace. Compare this with stationary `/practice`. +4. Use **`/course report`**; interpret the displayed values only as server globalstep + interval and course-callback duration. Capture client FPS/frame time separately + with actual client instrumentation; do not infer them from the report. +5. Invite several real testers. Ask whether they voluntarily repeat any movement, + interaction, or encounter. Record controls, host/device, frame-rate settings, + sample period, achieved latency/jitter/loss, player count, defects and observations. +6. Make a written, evidence-based decision: stock Luanti is adequate for this slice, + or a narrowly scoped engine API/client spike addresses a reproduced blocker. + +The course uses a synthetic **45°N, 0°E summer/clear fixture only**; this is not the +community-world location. Production remains separate. Current `/spar` is allowed +only in local play or server-privileged staging; never in production. + +## M1 disposition + +Implementation, packaging/asset tests and automated/native preflight gates are +complete for this M1 scope. The **roadmap exit gate is not accepted yet**: several +testers have not been evidenced as voluntarily repeating play, there is no measured +performance/network matrix, and the stock-versus-custom decision lacks human +measurement. Subsequent M2 implementation does not change this status. No player +telemetry is automatically uploaded; share only voluntarily and omit private +location/account data from reports. diff --git a/docs/m2-verification.md b/docs/m2-verification.md new file mode 100755 index 0000000..bbd3638 --- /dev/null +++ b/docs/m2-verification.md @@ -0,0 +1,67 @@ +# M2 implementation and verification — The Last Hearth + +Updated: 2026-11-02. Scope: first-night gameplay implementation and technical +verification. This document does **not** claim unfamiliar-player acceptance, M1 +human acceptance, community-location approval or production readiness. See the +[roadmap](roadmap.md), [M1 evidence](m1-verification.md), [architecture](architecture.md) +and [launch checklist](launch-features.md). + +## M2 implementation present + +| Area | Implemented behavior | Boundary | +| --- | --- | --- | +| Safe arrival and home | Bounded emergence and safe-surface checks reject unloaded/liquid/hazard/occupied/claimed/recently lethal locations. `/sethome` requires a safe point in the owner's claim. `/home` revalidates the saved point before trying a safe world spawn or a 64-node bounded rescan. | No automatic geolocation. The old persistent play world is not used by tests. | +| Death | Inventory, food, water and wetness persist; respawn recovers at a verified safe point with only stamina and warmth limited. | No item-recovery corpse or final player-approved death policy. | +| Fire and light | Cold fuel needs a reusable firestarter; fuel is not self-igniting. Craftable torch supports nighttime activity. | No uncontrolled fire spread, lamps or light-quality playtest. | +| Cooking and water | Six-second cooking and four-second boiling reserve their fuel charge at queue time. Absolute deadlines continue during server absence, fuel can expire, and completed output remains until safe collection. | Offline player needs remain paused. No crops, spoilage or generalized offline simulation. | +| Rest | A placed bedroll inside a claim requires five still seconds under sheltered, ventilated cover. Movement, damage, unsafe cover or permission changes interrupt; completion adds modest warmth and has a 30-second cooldown. | Does not advance or pause the shared world clock; environmental shelter remains M1's four-ray approximation until M3. | +| Discovery | `/recipes`, the recipe-index button and guide page provide paginated plain-text search across recipes/ingredients plus process durations and sources. | Existing recipes and the progression ceiling remain intentionally small; tester-led balance is still required. | +| Persistence | Save schema 1 now validates the `homes` world-storage map; hearth job metadata is validated/reconciled on timer/load and output completion is idempotent. | Engine node metadata is not an envelope-schema transaction or crash-durable database. | +| Map starts | `test:seeds` creates fresh real-engine worlds for eight fixed map seeds and checks required resources plus a dry, supported safe-arrival surface in the same bounds as fallback spawn. | This is deterministic fixture coverage, not exhaustive world-generation proof or human assessment of start quality. | + +## Automated verification on the current Windows host + +- **M2 historical contract:** Node.js 24.22.0; stock Luanti 5.18.0; game 0.3.0-m2; save schema 1. M3 release supersedes it. +- **Engine integration:** `scripts/run.mjs test` passed. The pinned real engine compiled + 39 Lua files, ran 16 isolated suites totaling **254 checks**, and passed registry, + recipes, terrain/resources/enclosure, and native gravity/collision/animation smoke. + The native smoke explicitly does not prove rendered animation, audible playback, + movement feel or a client playtest. +- **Node tooling/assets:** 30 named tests passed across `tests/tooling.test.mjs` and + `tests/assets.test.mjs`. Optional full codec decode/regeneration was not run; + Ogg framing/CRC/hash checks did run. Windows did not grant file-symlink privilege; + directory-junction protection was exercised. +- **Multi-seed:** `scripts/run.mjs test:seeds` passed on eight fresh worlds for seeds + 731104, 19027, 2026, 987654321, 271828, 314159, 42 and 8675309. Every world had + all required starting-resource types and a dry, supported safe-arrival surface; + the smallest checked fallback area had 229 such surfaces (seed 42). The aggregate + `LAST_HEARTH_SEED_TEST_PASS` marker was emitted. Seed 42 initially exposed that the + ±32-node fallback bound had no dry arrival; arrival/emergence checks and the fixture + were aligned and widened to ±64, after which all seeds passed. +- No separate Lua interpreter, clean-machine run, Linux/macOS native run or test of + the persistent play world is claimed. The exact M1 human gate remains open. + +## Manual M2 acceptance still required + +1. Use a disposable local `HEARTH_HOME`; never use a fixture in the preserved play + world. Test both first-night summer and winter fixtures, without revealing recipe + answers to the tester. +2. Ask five unfamiliar testers to attempt 30–60 minutes without coaching. Observe + whether each can identify local resources, craft a useful tool, ignite a hearth, + produce food and clean water, make a usable shelter/bedroll, and recover from an + unsafe spawn or death. +3. Record time-to-goal, failed/dead-end recipes, fuel/output loss, nighttime visibility, + recovery fairness and actionable feedback. Do not collect location/account data. +4. Capture exact game/engine/fixture, seed, host/device, controls and reproducible + defects. Do not present synthetic 45°N/0°E fixture runs as community-location + approval or climate balance. +5. The user/operator must explicitly choose and configure community coordinates + before shared climate staging. M1 voluntary repeat-play and real-client + performance/network evaluation also remain outstanding. + +## Disposition + +M2 gameplay implementation is in place and the available automated integration and +Node test gates passed at its release. M3 has since been implemented; M2 is still +**not human-accepted** until the unfamiliar-tester and operator gates above are evidenced. +No claim is made that M2 completes the launch checklist or authorizes production. diff --git a/docs/m3-verification.md b/docs/m3-verification.md new file mode 100755 index 0000000..6118ca5 --- /dev/null +++ b/docs/m3-verification.md @@ -0,0 +1,64 @@ +# M3 implementation and verification — The Last Hearth + +Updated: 2026-11-02. Scope: the M3 home-building systems and automated evidence. +This record does **not** claim a two-player acceptance playtest, a general physics +simulation, production readiness, or completion of any launch checklist checkbox. +See the [roadmap](roadmap.md), [M2 evidence](m2-verification.md), +[architecture](architecture.md), and [launch criteria](launch-features.md). + +## M3 implementation present + +| Area | Behavior | Boundary | +| --- | --- | --- | +| Room and shelter | Flood-fill from the player's head is limited to 7 × 7 × 5 cells (245 max), with a one-second, 256-entry cache. Building/room edits invalidate geometry. Closed doors block weather; open doors expose a side; roofs and structural openings change shelter. Unknown/unloaded probes fail exposed. | Approximate room model, not material-specific insulation, wind simulation, or persistent smoke diffusion. No exact guarantee on large spaces beyond the bounded region. | +| Ventilation and fire | A closed room is sheltered but unventilated; a roof vent or open door admits exhaust. An enclosed, unvented lit hearth reports smoke and applies the existing bounded damage rule. | No chimney path simulation or stored smoke concentration. The vent is the M3 exhaust control. | +| Building palette | Craftable stone foundation, timber frame, wall, roof, stairs, open/closed door, reed-lattice window, and roof vent. Doors are reversible; rotation is supported through native facedir metadata where applicable. Recipe index includes M3 items. | Window art is a procedural placeholder; no polished building textures or animation are claimed. | +| Support changes | Existing placement graph limits remain: two horizontal construction links and 16 vertical blocks. `can_dig` refuses removing a support if bounded connected construction would lose a valid support; unknown/unloaded dependency probes fail closed. Dependencies cannot cross household claim ownership. | Refusal instead of collapse. This is intentionally conservative, bounded support validation—not a general structural solver. If an engine/mod forcibly deletes nodes outside callbacks, no cascade guarantee is claimed. | +| Household roles | Versioned schema-1 `access` world record; missing record defaults empty. Owners may grant up to 16 named `resident` (build/dig/workstation use) or `storekeeper` (storage only) members using `/claim share `. `/claim unshare`, `/claim list`, and `/claim remove` manage grants. Invalid roles/usernames and ambiguous overlapping claims deny. | No new numeric schema version; this is an additive record kind. Member accounts are not authenticated/linked by this prototype. | +| Protected storage | Craftable 24-slot storage can be placed only within a claimed homestead. Every open and inventory put/take/move is authorized. Storekeeper is limited to storage; removal requires empty inventory and owner/resident authority. Existing container owner metadata prevents a later claim from adopting another household's contents. | Node inventories use engine metadata; no transactional guarantee beyond Luanti's normal persistence. No shared ownership transfer protocol. | +| Comfort | Craftable rain cloak toggles persisted player metadata; it slows wetting and adds modest warmth. A sheltered drying rack accelerates drying; hearth heat also dries. | Small prototype values, not balance acceptance; clothing wear, item durability, and broader clothing progression remain future work. | +| Claim visibility | `/claim show` reports coordinates and emits temporary player-local boundary markers. | Markers are a convenience, not a complete 3D boundary renderer or client-independent map overlay. | + +## Automated verification on this Windows host + +- **Contract:** Node.js 24.22.0, Luanti 5.18.0, game **0.4.0-m3**, save schema 1. +- **Real-engine integration:** `scripts/run.mjs test` passed after M3 integration. The + pinned engine compiled **43 Lua files**, ran **19 isolated suites / 281 assertions**, + and passed native registry/craft/resource/enclosure and stationary actor smoke. + The tests exercise role policy, claim commands, room/vent behavior, storage and + removal denial; they do not emulate two real players or prove appeal/comfort. +- **Node tooling/assets:** `node --test tests/tooling.test.mjs tests/assets.test.mjs` + passed **31/31** against the bumped release. It covers reproducible package and + asset inventory/path protections. Optional full codec decoding was not run; Ogg + framing/CRC/hash checks did run. Windows did not grant file-symlink privilege; + directory-junction protections were tested. +- **Diff hygiene:** run the existing line-ending-aware `git diff --check` workflow. +- No separate Lua interpreter, clean-machine run, Linux/macOS native certification, + browser client, sustained multiplayer load, or production run is claimed. + +## M3 two-player acceptance still required + +1. In a disposable world, have two real clients create separated homesteads. Owner A + builds a cabin with foundation, frame, wall, roof, door, window, vent and storage; + owner B shares as resident, then as storekeeper in a separate check. +2. Verify resident can build/use and access household storage; storekeeper can use + storage but cannot place/dig blocks or use other workstations. Revoke each role + while clients remain online and verify access denies immediately. An outsider + must not open, move, take, place, dig, or indirectly access either household's + assets. +3. Check `/claim show`, release and restore a claim, reconnect, server restart, and + a nonempty storage refusal. Verify old worlds with no access map load unchanged. +4. Build a lean-to and a small enclosed cabin. Compare displayed exposure, wetness, + cloak and drying-rack outcomes. Test closed door, open door, vented hearth and + unvented hearth; feedback must explain comfort/smoke differences. +5. Attempt to remove a foundation supporting a wall/roof; removal must be refused + without changing nodes. Then remove an independent block; it should succeed. + Test cross-claim supports, unloaded edges/restarts and ordinary natural terrain. +6. Record exact engine/game, save fixture, client count, actions and reproducible + defects. Do not claim a human pass from engine harnesses or screenshots. + +M1 voluntary repeat-play and performance/network acceptance, M2 unfamiliar-first-night +acceptance, and operator approval of shared climate coordinates also remain separate +open gates. M3 implementation supports beginning M4 engineering by explicit user +direction, but M3 is **not human-accepted** until the two-player cabin and permission +checks are observed and recorded. diff --git a/docs/m4-verification.md b/docs/m4-verification.md new file mode 100755 index 0000000..b47a7b0 --- /dev/null +++ b/docs/m4-verification.md @@ -0,0 +1,141 @@ +# M4 implementation and verification — The Last Hearth + +Updated: 2026-11-02. Scope: renewable ecology, food preservation, pottery, +small-tier metalworking and bounded offline accounting. This record separates +automated evidence from the required group playtest. It does not claim public +service readiness or human acceptance. + +## Offline contract + +| System | Rule | +| --- | --- | +| Player needs | Pause while the player is logged out. | +| Hearth fuel | Uses absolute UTC epoch deadlines from `os.time()`, not preview/solar time. Sticks add 45 s, logs 180 s; stored reserve is capped at 600 s. Fuel may expire during absence. | +| Hearth jobs | Cooking 6 s / 20 reserved fuel seconds; boiling 4 s / 10; smelting 60 s / 30; charcoal production 60 s / 30; pottery firing 120 s / 30; drying 300 s / 30. Inputs are consumed only after a valid job and timer are established. A completed result stays queued until an authorized player collects it. Timer and load reconciliation are idempotent. | +| Charcoal | One log plus clay forms a charcoal charge. The process consumes 60 s plus its 30 s reserved cost and yields charcoal worth 90 s; remaining heat plus output cannot exceed the source log's 180 s. | +| Crops | Planting time is stored as a decimal epoch string. Growth replays local historical solar temperature in six-hour samples, pauses below 3°C, runs at half rate below 10°C and caps replay at 30 days. Stages are derived from planting time, not timer count. | +| Berries | Harvested bushes use one absolute 24-hour regrowth deadline; no repeated yield while bare. | +| Saplings | Planted trees wait three real days, then grow only if a bounded 5×6×5 volume is loaded, clear and allowed by claim/protection rules. No placement beyond map bounds. | +| Wildlife | Hares are passive, non-dropping, nonpersistent, have a 900-second loaded lifetime, and are limited to two near a player and 24 globally. | +| Food age | General spoilage is disabled. Dried rations are shelf-stable. No hidden or irreversible item-age simulation exists. | + +## Automated verification + +- **Verified technical contract:** Luanti 5.18.0, game 0.5.0-m4, save schema 1. + The automated technical gates listed below have passed; human M4 acceptance + remains open. +- **Engine:** run `scripts/run.mjs test` using the pinned Luanti 5.18.0 runtime. It + creates fresh disposable worlds; it must never access the persistent play world. +- **Map seeds:** run `scripts/run.mjs test:seeds` after ecology/mapgen changes. +- **Node tooling/assets:** run `node --test tests/tooling.test.mjs tests/assets.test.mjs`. +- **Coverage:** crop historic temperature, catch-up cap, stage derivation, invalid + timestamps; renewal and inventory conservation; charcoal/smelting/drying/pottery + jobs; output collection, offline completion and repeated reconciliation; wildlife + persistence and population bounds; registration and real-engine craft smoke. +- **Results:** Luanti 5.18.0, game 0.5.0-m4: final `scripts/run.mjs test` passed + check, native smoke and all 21 isolated suites, including six ecology tests, + fourteen process tests and three wildlife tests. Eight fixed-seed runs then + passed safe arrival and required resource checks after tree density was raised + to address a no-log seed. The final full engine run passed on the updated tree. + Node tooling/assets tests passed. A server warning reported maximum lag peak + around 1.23 seconds during the bounded smoke workload; this is not a client-FPS + or performance certification. No human acceptance is inferred from automated + tests. + +## M4 group acceptance remains required + +Have a small group play several sessions across at least one real week in a +disposable/staging world. Record returns after absences, crop and regrowth outcomes, +resource availability near arrival, pantry usefulness, process completion and +whether the group chooses new goals without daily chores. Compare loaded and +unloaded outcomes over equal elapsed time. Check harvesting under claim sharing, +full inventories and ordinary latency. Preserve test world identity, exact release, +actions, and reproducible defects. Do not enable general spoilage as part of this +test. + +M1 voluntary repeat-play/performance, M2 unfamiliar-first-night, M3 two-player +cabin/permissions, and operator approval of shared climate coordinates remain +separate open gates. M4 technical completion permits M5 engineering to proceed by +explicit user direction; it does not mark M4 human-accepted or waive earlier gates. + +## Botanical journal and ecosystem survey — pending main validation + +2026-11-02 implementation addendum. The preceding evidence is retained unchanged +and describes its earlier tested tree. This newer ecosystem/art/UI increment does +not inherit that full-engine certification and does not bump the game version or +save schema. See [living ecosystems](ecosystems.md) and [art direction](art-direction.md). + +Implemented in the owned interface and focused recipe/journal harness: + +- Shared eom.dev Mocha base/mantle/raised/border and text/muted anchors; restrained + mauve, teal and yellow accents, pressed-sprig dividers, consistent buttons, + hover/pressed states, fields, inventory lists and tooltips across guide, recipe, + inventory, location and survey forms. Seven guide pages stay inside 14×10; + Living ecosystems replaces the optional Riverward goal without removing fishing + recipes/process discoverability or changing fishing nodes/mechanics. +- Read-only `/ecosystem`, guide button and refresh: one call to the actual + `hearth.succession.habitat(player:get_pos())` Lua API plus one + `hearth.wildlife.status()` snapshot. Plant counts are a local 5×5×5 natural-origin + sample, with fern/tree cover combined; incomplete/unloaded cells are disclosed. + Fauna counts are global active hare/deer/fox totals, explicitly not a local + census. No scheduler, forage, deposit, metadata write, entity scan/spawn, + emergence or force-load is invoked by the survey. +- Current wildlife status reports global cap 32, local cap 6 and per-player cap 6; + this supersedes the older hare-only contract above for the working ecosystem + implementation, not for that historic verification run. Wild succession is + loaded-only without offline replay; crop/berry/planted-sapling clocks remain + separate. Dynamic Life is inspiration only; implementation and art are original. +- HUD panel with labeled percentages, color-coded reserve tracks and low-reserve + warnings; established `title`, `clock`, `needs`, `context`, `help` session keys + remain. The authored hotbar slot is repeated using `[combine:320x32` at ten + 32-pixel offsets (no leading parenthesis), with `hearth_selected.png`. Image + setters are optional for older mocks. Standard crosshair texture override is + part of the main generated-asset integration, outside the owned files here. +- Inventory craft/result/main actions and listrings are unchanged. Location writes + remain singleplayer-only with finite-coordinate validation and 16-character + input bounds. Recipe searches remain literal, escaped and 48-character bounded. + No credentials, cryptography, certificates or network access are introduced; + CodeGuard secret avoidance and bounded/escaped input handling were applied. + +**Focused validation actually run:** the existing Fengari runner under Node +24.22.0 completed all **13 recipe/journal tests**, including survey read-only +wiring, escaped/bounded fields, guide bounds, inventory actions, graphical needs +and exact ten-slot combine syntax. It also completed **27 succession tests**, +including the real habitat API's read-only/loaded contract. Both emitted their +expected completion markers, exit 0 and empty stderr. Earlier focused attempts +exposed two harness issues (a missing heat fixture value and an escaped-comma +expectation); both were corrected before this successful journal run. + +A related standalone wildlife-suite invocation was attempted, but the terminal +did not return a confirmed completion result; it is **not counted as passing** +here. Editor diagnostics reported no errors in the five owned files. No main +integrated/full-engine tests or native rendering checks were run by this UI task. +Generated-asset/native client rendering, UI scaling, contrast and main validation +remain pending. M1–M4 human gates, long-session ecological balance and operator +acceptance remain open. + +## Integrated ecosystem, art and seasons verification + +2026-11-02 final integration addendum (supersedes the pending automated status +above, not the historical evidence or human gates): + +- Pinned Luanti 5.18.0: final disposable-world check/smoke passed; 52 Lua files + compiled and all **23 isolated suites / 377 checks** completed. Includes nine + seasonal, 36 succession, 18 wildlife, 31 content and 13 journal/recipe checks. +- Final eight fixed-seed run completed all eight individual success markers and + the aggregate seed PASS marker for starting resources and safe arrival. +- All **48 Node tests** passed, covering tooling, original art, models, seasonal + atlases, deterministic regeneration and package integrity. Optional full audio + codec regeneration was not configured; framing/hash/CRC checks still passed. +- Earlier validation attempts encountered a seed timeout and socket-bind failure; + these are not counted as passes. Subsequent complete runs exited zero. +- Generated pixel-art contact sheet inspected. In-game meshes, materials, alpha, + seasonal transitions and HUD scaling have **not** received native visual or + human acceptance. Headless checks cannot certify appearance, enjoyment or FPS. +- Seasonal leaf loss also reduces simulated canopy shade; dormant plants preserve + ecological age, and seasonal food patches preserve their harvest cooldown. +- Game contract remains 0.5.0-m4 / save schema 1; user-undone release changes were + not reinstated. No persistent play world was opened or regenerated for tests. + +Native visual review, long-session population balance, multiplayer protection and +the existing human/operator gates remain open. diff --git a/docs/m5-verification.md b/docs/m5-verification.md new file mode 100755 index 0000000..66e50b9 --- /dev/null +++ b/docs/m5-verification.md @@ -0,0 +1,60 @@ +# M5 engineering — private staging deployment contract + +Updated: 2026-11-02. This is the first bounded M5 engineering slice. It defines +and offline-tests a private Kubernetes staging workload in `ansible-role-luanti`; +it is not a live cluster deployment, closed alpha, or production authorization. +The game payload remains 0.5.0-m4 / Luanti 5.18.0 / save schema 1. + +## Staging contract implemented + +- One `Recreate` Deployment, one retained standalone world PVC, ConfigMap with + explicit staging settings, UDP-only `ClusterIP` service and ingress + NetworkPolicy restricted to explicit IPv4 client CIDRs. +- Deployment image must be an immutable `@sha256:<64 lowercase hex>` reference. + This repository does **not** build or publish the Luanti server image. The + operator-provided image must contain Luanti 5.18.0, the verified game under + `/opt/luanti/games/last_hearth`, and the documented non-root/volume/entrypoint + contract. A digest is pinning/integrity metadata, not publisher authentication. +- Production mode, mutable image tags, missing coordinates, empty/wildcard client + access, malformed CIDRs and invalid names fail validation before Kubernetes API + access. PvP, creative mode and announcements are disabled; Luanti mod security is + enabled; default player privileges are limited. The pod runs non-root with a + read-only root filesystem, dropped capabilities, no service-account token, and + bounded CPU/memory. +- No public Ingress, `NodePort`, or `LoadBalancer` is rendered; apply operations + use `state: present` without pruning/deletion. The cluster CNI must enforce + NetworkPolicy, and the VPN/routed client source addresses must be confirmed by + the operator. +- Admin bootstrap is a controlled staging procedure: initially allow only the + operator's VPN address, initialize the native account with a unique staging-only + password, verify privileges and a second ordinary account, then deliberately + widen the allowlist for invited testers. Never reuse platform passwords or put + passwords/secrets in role variables, ConfigMaps, or this repository. +- PVC encryption, backup custody/retention, stopping remote writers and offline + restore remain operator duties. This role does not create backups or prove + storage encryption. + +## Offline checks + +The role test script renders Kubernetes YAML locally, validates required resource +shape and safety invariants, and checks pinning, policy, storage, privilege and +exposure. Negative cases prove production mode, mutable image references, empty or +public client CIDRs and invalid coordinates are rejected. The portable test target +has no kubeconfig and must not contact a cluster. If Ansible CLI is available, the +script additionally runs role syntax, Ansible rendering and negative task tests. +**Local result:** direct Jinja render and all resource-shape/security invariants +passed, along with seven fail-closed configuration cases. The Docker daemon was +unavailable, and Ansible CLI cannot run under the configured Windows Python +console in this environment, so full Ansible task syntax/render validation remains +unverified. No Kubernetes API was contacted. + +## Remaining M5 work + +No server image/artifact has been built or scanned, no staging cluster has been +configured or contacted, and no invited client has joined. Still required: +artifact build/signing/provenance, cluster-specific access and encrypted storage +review, moderator/reporting policy, client and server telemetry, restart/outage/ +disconnect tests, sustained multiplayer and concurrent workstation/storage tests, +and a witnessed backup-break-restore drill with measured recovery objectives. +Earlier M1–M4 human acceptance gates remain open. No production rollout or M5 +exit-gate pass is claimed. diff --git a/docs/render-distance.md b/docs/render-distance.md new file mode 100755 index 0000000..a65d851 --- /dev/null +++ b/docs/render-distance.md @@ -0,0 +1,101 @@ +# Full-detail render distance + +The managed launcher requests **512 nodes** of terrain visibility by default. +This applies to both new and existing play worlds, disposable courses, and +engine-check/test configs. It is a bounded full-detail terrain preset, **not +level of detail (LOD)**. Minecraft Distant Horizons and Sodium are conceptual +references for longer horizons and efficient rendering, not Luanti addons or +dependencies. This change implements neither their renderer nor their features. + +## Settings and units + +For Luanti 5.18.0, the engine's default `viewing_range` is 200 nodes (engine +maximum 4000), `max_block_send_distance` is 13 mapblocks, and +`max_block_generate_distance` is 10 mapblocks. A mapblock is 16 nodes per side. +Increasing only the client range would leave server-side terrain delivery and +generation as bottlenecks, including in single-player's local server. + +The launcher writes the following five settings exactly once per config: + +| Setting | Default preset | Purpose | +| --- | --- | --- | +| `viewing_range` | 512 | Client terrain viewing range in nodes | +| `max_block_send_distance` | 34 | Server terrain send limit in mapblocks | +| `max_block_generate_distance` | 34 | Server terrain generation limit in mapblocks | +| `client_mesh_chunk` | 2 | Groups mapblocks into larger client mesh chunks; can reduce draw overhead but increases mesh rebuild granularity | +| `fog_start` | 0.65 | Starts distance fog at 65% of the viewing range; does not replace terrain with cheaper geometry | + +Both block limits use `ceil(distance / 16) + 2`, bounded at 66 mapblocks. The two +extra blocks provide headroom around the requested range; they are not a promise +that every block inside that radius is immediately generated or visible. + +| Requested nodes | Send blocks | Generate blocks | +| --- | --- | --- | +| 128 | 10 | 10 | +| 512 (default) | 34 | 34 | +| 768 | 50 | 50 | +| 1024 | 66 | 66 | + +## Environment override and config lifetime + +Set `HEARTH_VIEW_DISTANCE` in the environment inherited by the launcher before +starting play, course, or tests. For example, the value `768` requests the +768-node row above; `1024` requests the maximum supported preset. Any integer +from **128 through 1024 inclusive** is accepted, not just the table entries. +Unset the variable to restore 512. Use 128 or 256 as lower-cost fallbacks. + +Only canonical unsigned decimal strings are accepted. Empty values, whitespace, +leading zeros, signs, fractions, exponent/hex notation, out-of-range values, +newlines, and appended config syntax are rejected, not clamped or interpolated. +Validation occurs before engine invocation, staging, backups, or world/config +writes on launch commands. The shared writer also validates independently before +filesystem mutations. Supplying these five managed keys via extra config lines +is rejected so they cannot duplicate or override the preset. Package, verify, +backup and restore commands do not consume the graphics environment variable. + +The config is regenerated for each invocation in the launcher's private session +directory under the runtime selected by `runtimeHome()` / `HEARTH_HOME`. It is +**not a persistent user config**: normal exit or failure removes the session +directory, including its config. Editing that temporary config is not a durable +way to tune graphics. Persist the environment override in the launching shell or +your own launch environment instead. No personal profile path is hardcoded and +no installed engine config is edited. In-game changes to these managed settings +are replaced by the preset on the next launch. + +## World safety and simulation scope + +Existing worlds receive the graphics preset without rewriting their saved +mapgen, seed, boundary, world identity, or database files. A saved world detected +by either its world metadata or map metadata continues to suppress creation-time +terrain overrides. New worlds retain the existing creation-time terrain preset. +Seeing farther does not expand an old world's saved mapgen boundary or recreate +its terrain; terrain beyond that boundary remains unavailable. + +No active-block range, object activation range, simulation cadence, entity +distance, or gameplay radius is increased. Frustum/occlusion culling is not +disabled or otherwise changed. Larger send/generate limits still mean **more +terrain work**: generation, loading, lighting, mesh building, transfer, storage, +and memory can all increase even without a larger active simulation radius. +Newly generated terrain is ordinary saved world data, not a disposable LOD cache. + +## Performance and verification limits + +The 512-node default requests 2.56 times the engine's default linear viewing +range. That does **not** imply only 2.56 times the cost: visible area, terrain +volume, foliage/mesh complexity, hardware, camera direction, and exploration +speed all matter. 768 and 1024 are more demanding options, not certified quality +tiers. Fog and mesh chunking are pragmatic settings, not measured FPS guarantees. + +Node-only tooling tests cover the default, every allowed integer, malformed and +injected inputs, 768/1024 config output, unique graphics keys, and new/existing +play/course/test config paths with saved mapgen bytes preserved. These tests do +not start Luanti, measure GPU/CPU/RAM use, verify visual loading distances, or +certify performance. No native engine or asynchronous game launch is needed for +this validation. + +Before claiming a performance improvement, compare the same world, camera route, +resolution, weather and hardware at 128/256/512 and optional 768/1024. Measure +client frame-time percentiles, memory, mesh rebuild stalls and generation/loading +latency separately for cold exploration and already-generated terrain. Course +Lua callback/server-step telemetry is not client FPS or GPU profiling. Lower the +override if frame pacing or memory use is unacceptable. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100755 index 0000000..4436dec --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,430 @@ +# Season VI implementation roadmap — The Last Hearth + +Updated: 2026-11-02. Carried forward from the authoritative engine-checkout +document, doc/season-vi-roadmap.md, into this standalone game repository. Links +are adapted locally; the delivery strategy, M0–M6 scope, dependencies and gates +are retained. Status distinguishes implementation, automated evidence and +operator/human acceptance; it does not invent tester feedback or approval. +Feature IDs refer to the [launch checklist](launch-features.md). + +**Current state: M0 technical gates complete; M1 implementation and automated / +native preflight complete, human acceptance pending; M2 implementation and +automated technical verification complete, human acceptance pending; M3 +implementation and automated verification complete, two-player acceptance +pending; M4 technical implementation and automated verification are complete, +with its week-long group acceptance pending.** M2/M3 progress does not waive the +open M1 human gate. See +the [M0 evidence ledger](m0-decisions.md#verification-and-acceptance-ledger), +[M1 evidence](m1-verification.md), [M2 record](m2-verification.md) and [M3 record](m3-verification.md). +Windows checks ran on the existing development host, not a clean machine. +Linux/macOS paths are unit-tested only; native execution is unverified. Browser +launch readiness is not promised. Current release candidate contract is Luanti +**5.18.0**, game **0.5.0-m4**, save schema **1**. M4's human exit gate and all +earlier human/operator gates remain open. + +**New ecosystem and journal/art pass:** original Dynamic Life-inspired succession +and the eom.dev Mocha botanical field journal are implemented as a further working +increment, not a new release. Main integrated tests and native visual validation +for this increment remain pending; earlier automated results below are historical +evidence, not validation of the new tree. See [living ecosystems](ecosystems.md), +[art direction](art-direction.md), and the [append-only M4 update](m4-verification.md#botanical-journal-and-ecosystem-survey--pending-main-validation). + +## Delivery strategy + +Build small playable increments, with a working saved world at each milestone. +First establish feel and a satisfying evening; then progression and sustainability; +then a safe public service. Presentation, accessibility, tests and performance are +part of each increment, not a final cleanup phase. + +There is **no launch date commitment** yet. The breadth of original art, animation, +game systems and possible engine changes warrants milestone-based planning. +Estimate calendar dates after M1 establishes scope and available contributor time. +Treat effort as implementation + assets + tests + playtest revision, not Lua alone. + +## Current baseline: M0 foundation through M3 home-building slice + +Already present: standalone game loading, gathering/crafting, first-night needs and +queued hearth jobs, a versioned home and safe arrival, bounded room/vent shelter, +a small structural palette with refused unsafe support removal, shared claim roles +and protected storage, smooth movement/stamina, animated player/tools, material +feedback, opt-in sparring, a disposable movement course, and approximate solar/ +climate behavior. M1 and M3 human acceptance and performance measurement remain open. + +M0 adds an independent versioned game package, split module responsibilities, +schema validation/legacy migration, offline snapshot/restore/recovery tooling, +runtime isolation and fixture/production guards. See the +[actual module contracts](architecture.md) and [decisions](m0-decisions.md). +Integrated checks, fresh exports and Windows GUI play are verified; retained +old-save recovery evidence is qualified by its earlier package digest. M0 technical +completion is not operator acceptance or launch readiness. + +Not established: fun progression, production-quality controls/combat/art, renewable +settlement economy, complete offline simulation, public multiplayer security, +browser parity, or clean-machine releases. Do not equate passing tests with launch +readiness. + +## M0 — Establish a maintainable game and a shared rules contract + +**Goal:** make subsequent work reproducible and prevent expensive incompatible choices. + +Primary features: F11, F12, F17–F19. Prerequisite: P0. + +1. Extract the game into its own content repository/package while preserving history + and keeping a reproducible engine integration. Do not discard the current world. +2. Pin the stock engine baseline and document local/staging/production data paths; + make the launcher/install process work on the intended development platforms. +3. Split responsibilities: player state, clock/climate, construction, interactions, + inventory/processes, UI, encounters and persistence. Preserve behavior with tests. +4. Introduce schema/version identifiers, validation and migrations before new saved + item/process formats. Add pre-migration backup and restore fixtures. +5. Agree on minimum supported hardware, concurrent-player test target, client + platforms, browser launch status, and measured latency/performance budgets. +6. Record decisions on death/recovery, initial progression ceiling, calendar display, + offline processing and claim boundaries. Select the community location before + staging climate balancing; local worlds retain their own selection. +7. Preserve a deterministic first-session fixture and explicit summer/winter/date + fixtures. Production configuration cannot accidentally enable test shortcuts. + +**Exit gate:** a clean checkout runs tests and opens a versioned prototype; +the old save restores; scope decisions and unresolved risks have owners. + +**Status qualification:** M0 technical exit gates are complete for the verified +Windows development path: integrated tests, clean temporary package exports, +isolated GUI play, retained copy-only recovery and owned decisions/risks. This is +not a clean-checkout/new-machine or Linux/macOS native certification, and no +user/operator sign-off is invented. Hardware/network figures are +[provisional acceptance targets, not measurements](m0-decisions.md#provisional-performance-acceptance-targets--not-measurements) +and remain M1 measurement/review work, not an open M0 performance gate. +User/operator approval of the community location is required before shared-world +climate staging; the M0 synthetic 45°N, 0°E fixture is not production selection. +M2 changes do not alter the historical M0 evidence or approval status. + +## M1 — Make five minutes of interaction feel good + +**Goal:** make moving, gathering, placing and one encounter enjoyable before adding grind. + +Primary features: F01, F02, F09, F14, F15; F17 client decision. Prerequisite: M0. + +**Implementation:** movement/stamina model, fresh isolated course, original skinned +and animated player/tool meshes, material/attack audio, contextual target and +placement feedback, and opt-in mobile sparring are implemented. The stationary +practice target remains for comparison. The stock engine is the deliberate M1 +baseline: current features do not justify a custom client/protocol fork, though +future measured playtest evidence may change that decision. Browser launch remains +unpromised. + +**Automated/native preflight evidence:** 31 Node tooling/asset tests pass; the real +engine compiles 36 Lua files and passes 13 isolated suites totalling 228 checks; +native check/smoke passes. The separate headless course preflight verifies staging +and observes 41,041 untouched volume nodes before player consent. Mock course tests +verify generator geometry/checkpoints. Neither constitutes human traversal or fun. + +**Human acceptance remains open:** the user/regular testers should use the +[M1 playtest instructions and evidence](m1-verification.md), run the route and +moving spar repeatedly, and record actual FPS, server-step, corrections and +latency on the documented envelope. No group playtest or performance measurement +is claimed. M2 implementation proceeds only by explicit user direction; this does +not mark M1 accepted. + +## M2 — Make the first night a complete game + +**Goal:** a satisfying arrival → tools → fire → food/water → temporary shelter loop. + +Primary features: F03, F06, F08, F10, F11, F14, F15. Prerequisite: M1. + +**Implementation:** bounded safe-arrival/home revalidation; home points restricted +to the player's own claim; inventory-preserving death with partial needs recovery; +firestarter ignition, craftable torch, queued six-second cooking/four-second +boiling, persistent output collection, bedroll rest, searchable recipe/process +guide and an eight-seed real-engine mapgen check were delivered in **0.3.0-m2**. +Player needs remain paused offline; fuel/jobs expire by absolute real-time deadline. +No crop, spoilage, comprehensive shelter, or civil-timezone system is implied. +The M1 human gate remains open; implementation proceeds under explicit user direction. + +**Technical verification:** compilation, integrated real-engine checks, Node tooling +tests and all eight fixed-seed starts pass on the documented Windows setup. Named +results and limits are in [M2 verification](m2-verification.md). + +1. Implement safe arrival/home validation and multi-seed resource checks. Remove + inappropriate underground auto-teleport and handle pending/failed emergence. +2. Add contextual harvesting and a searchable recipe/process guide; rebalance + ingredient costs from observed play rather than arbitrary scarcity. +3. Add ignition, torches/lamps, timed cooking and boiling with visible process state. +4. Make a lean-to, bedroll, first meal and clean water achievable; include useful + nighttime activities because real-world time is not accelerated. +5. Tune need rates and recovery, initial grace and death consequences. No free + survival refill exploit and no punitive tutorial-death loop. +6. Validate date/location, season extremes, sky approximation and timezone labeling; + add weather transitions and clearly disclosed synthetic conditions. + +**Exit gate:** five unfamiliar testers attempt the first 30–60 minutes without +coaching. Most can identify a danger and correct it; failures reveal actionable +feedback, not hidden recipes. Summer and winter versions both have feasible starts. + +**Operator prerequisite:** approve shared community coordinates and explicitly +configure `hearth_latitude`/`hearth_longitude` before community climate staging. +Do not silently promote M0 fixture coordinates. Civil timezone policy/display +also needs an explicit decision when implemented; none is invented here. + +**Human acceptance remains open:** five unfamiliar testers must attempt the first +30–60 minutes without coaching, across summer/winter starts, and report actionable +feedback. M2 implementation supports moving into M3 engineering, but M2 is not +accepted until that gate and operator review are recorded. Community coordinates +still require explicit operator selection before shared climate staging. + +## M3 — Make a home worth improving + +**Goal:** construction solves practical survival problems and supports personal expression. + +Primary features: F04, F05, F08, F13, F15. Prerequisite: M2. + +**Implementation:** bounded 7 × 7 × 5 room flood-fill with one-second cache and +structure-edit invalidation; roof/door/vent shelter and smoke classification; +stone foundations, timber frames, stairs, doors, windows, roof vents; support-aware +refusal of unsafe removals; household resident/storekeeper roles; private storage, +drying rack and a reusable rain cloak. The historical M3 release was **0.4.0-m3**. Details and +named technical evidence are in [M3 verification](m3-verification.md). + +**Technical verification:** the pinned real-engine smoke, isolated room/access/ +construction/storage/schema/persistence suites, and Node tooling/assets tests pass. +M3 adds versioned access data under schema 1; old saves receive an empty access +map. No separate migration format is claimed. + +Decision boundaries: support removal is refused, not cascaded. Room probing fails +exposed for unloaded/unknown cells and is limited to 245 voxels. Windows and +materials are prototype approximations, not a fully simulated cabin or structure. + +**Human/operator acceptance remains open:** the exit gate needs two real players to +build and share a cabin, confirm room/vent feedback and verify outsider denial; it +has not been performed in this technical session. The M1 repeat-play/performance +gate remains separate. This implementation proceeds under the user's explicit +direction so M4 engineering may begin, but M3 is not marked accepted until observed +two-player results are recorded. + +1. Introduce foundations/frames and the small complete architectural palette. + Add preview, rotation, stairs, doors, windows and a functional vent/chimney. +2. Replace the ray heuristic with bounded room/roof detection and cached invalidation + when relevant blocks change. Verify correctness before adding more simulation. +3. Implement useful material/weather protection, clothing and drying, and a + dependable relationship between ventilation, fire and indoor comfort. +4. Apply support rules to additions/removals consistently. Prevent hazardous + cross-claim effects and test restart/unloaded structural changes. +5. Add protected storage/workstations, household roles and visible claim boundaries. +6. Complete the first cohesive set of building textures, sounds and animation. + +**Exit gate:** two players build and share a small functional cabin without an admin; +its protection and comfort outperform a lean-to for explainable reasons. Another +player cannot steal, flood or undermine it through an unhandled interaction. + +## M4 — Make the next week worth playing + +**Goal:** complete a small, sustainable survival progression rather than an infinite checklist. + +Primary features: F03, F07–F09, F11, F12; extend F15. Prerequisite: M3. + +1. Add renewable forage/timber, a few crops, wildlife population limits and a + noncombat food path. Test community harvesting pressure near arrival. +2. Implement pottery/storage and preservation before enabling consequential + spoilage. Preserve age and quantities through every inventory operation. +3. Add the first useful metal tier: find source material → prepare fuel → process + at a workstation → make a visibly better tool. Keep the chain short enough + to finish and maintain without mandatory specialist classes. +4. Add modest material/climate variation to make ordinary journeys worthwhile; + give players preparation and route choices without authored adventure content. +5. Finalize bounded offline/unloaded processing, historic climate accounting and + process-completion idempotency. Publish the exact offline contract. +6. Tune temporary shelter, established home and seasonal preparation together. + Do not require daily maintenance or real months to test winter. + +**Exit gate:** a small group plays several sessions over at least a week, returns +after absences and chooses new personal goals. Preservation works before harsh +decay is enabled. Loaded/unloaded outcomes agree and economic conservation holds. + +**Technical status:** implementation and automated engine, ecology/process, +tooling/assets and eight fixed-seed checks are complete on the existing Windows +host. See [M4 evidence and the offline contract](m4-verification.md). This permits +M5 engineering to begin under the user's direction; it does not mark M4 human- +accepted or claim the loaded/unloaded group observations have occurred. + +**Further ecosystem/presentation implementation (validation pending):** the guide +now prioritizes Living ecosystems over the optional Riverward goal. Original +loaded-only succession connects light/moisture competition, meadow/flower/fern +niches, slow tree stages and nutrient/decomposer feedback to bounded wildlife. +`/ecosystem` and the guide's Ecosystem survey observe a fixed habitat patch and +active global species totals, without mutation, spawning or force-loading. +Fishing recipes/process entries remain searchable for compatibility; fishing +mechanics and nodes are unchanged by this UI pass. + +The shared journal treatment follows local eom.dev Mocha anchors with restrained +mauve/teal/yellow accents, pressed-sprig dividers, consistent buttons/fields/lists/ +tooltips, seven bounded sidebar entries and readable content/footer spacing. +Inventory lists, transfer rings and permissions retain their behavior. The HUD +adds labeled percentages and colored reserve tracks on a dark panel; the hotbar +tiles one authored slot ten times and uses the matching selection outline. +See [ecosystem contracts](ecosystems.md) and [original art contracts](art-direction.md). +Focused journal regression results are recorded in the M4 appendix; full main +validation, native visual review and every human acceptance gate remain open. + +**Landscape/diversity integration:** the [content expansion](content-expansion.md) +installs seasons → diversity → succession, then biome-owned terrain/species +registrations → diversity canonical nodes → seasonal variants → wildlife. +The old duplicate mapgen block is replaced, not stacked. Copper tool inputs and +a quartz window alternative extend resource utility; coal fuel/copper smelting +remain with the process integration owner. New worlds receive explicit broad +v7 relief and a ±4096 bound; saved mapgen/seed/bounds and generated chunks are +not rewritten. New ungenerated chunks in old worlds may have visible seams. +Focused tests cover registration/art/recipe contracts and unchanged saved metadata. +The earlier eight-seed evidence is historical, not certification of this preset: +native high-altitude emergence, species-aware resource/spawn checks, saved preset +verification, visual review and human acceptance remain required. + +## M5 — Closed community alpha on the real deployment path + +**Engineering status:** the first M5 slice now defines a private, digest-pinned +Kubernetes staging contract in [ansible-role-luanti](../../ansible-role-luanti/README.md). +Its direct Jinja render and fail-closed configuration checks pass offline. No +server image has been built, no cluster has been contacted, and no deployment is +claimed. See [M5 engineering verification](m5-verification.md). + +**Goal:** prove the game can be shared, operated and recovered—not merely run locally. + +Primary features: F12, F13, F16–F19. Prerequisite: M4. + +1. Build pinned game/server/client artifacts and a non-destructive staging + deployment. Adapt or replace the old server role rather than assuming it fits. +2. Configure shared coordinates/timezone and access policy; remove debug grants + and test-only privileges. Preserve native game credentials initially if linking + is not yet ready; never ask players to reuse platform passwords. +3. Add moderation/reporting, production validation, security review, performance + telemetry and resource budgets. Exercise invalid/malicious client inputs. +4. Test restart, outage, disconnect, concurrent workstation use, full storage, + migrations and representative sustained multiplayer load. +5. Implement platform account linking and optional moderated Matrix bridge with + outage/loop tests. Add a minimal Play page and season feedback thread. +6. Take a consistent backup, deliberately break a staging instance, and restore + the complete world/player/process state. Record measured recovery objectives. + +**Exit gate:** invited regulars can install/join from clean clients; no critical +data-loss/duplication/protection defect; recovery is demonstrated, not theoretical. + +## M6 — Release candidate and public Season VI launch + +**Goal:** ship an honest, polished and supportable experience. + +Primary features: acceptance of all required F01–F19. Prerequisite: M5. + +1. Freeze features; fix blockers, balance and usability rather than introduce new + mechanics. Complete asset attribution, controls, accessibility and UI review. +2. Repeat first-night, nighttime, winter-arrival, week-away and clean-install + tests on the release artifacts, not just development builds. +3. Publish supported hardware/clients, installation/join guide, survival/offline + rules, moderation/privacy policies and incident/support channels. +4. Prepare screenshots and a short authentic gameplay trailer. Schedule an + optional community first-evening stream; the game must not depend on the stream. +5. Verify production storage/backups, rollback, secrets and location configuration. + Keep the previous season archived/available under an explicit transition policy. +6. Announce launch only after signing off the checklist with recorded evidence. + +**Exit gate:** no unresolved critical defects; a self-directed fun first session; +repeat-session evidence; measured performance at the declared target; a rehearsed +operational recovery. A calendar deadline never overrides those gates. + +## Dependency summary and safe parallel work + +**Critical path:** M0 → M1 → M2 → M3 → M4 → M5 → M6. + +- Art/audio and UI can progress alongside each milestone using the current playable + loop; do not produce a large asset library before scale/style are validated. +- Deployment/backup scaffolding starts in M0 and can develop alongside gameplay; + public-server signoff waits for actual state/process schemas and exploits tests. +- Identity/Play-page work can begin after M0's platform decision, but integrations + must not delay proving that survival is fun or become gameplay dependencies. +- Conservation/persistence testing starts in M0 and expands with every new item, + process and permission; it is not deferred until M5. +- Defer worldgen-breaking changes until a migration/new-world decision is documented. + Do not silently regenerate regions containing player work. + +## M1 status and remaining acceptance + +M1 implementation and automated verification are complete for this slice. See +[M1 evidence and playtest instructions](m1-verification.md). The 31 Node tests, +228 Lua checks, pinned-engine smoke, and native no-consent course preflight pass. +M1 is **not human-accepted**: real route traversal, several voluntary repeat +testers, measured frame-time/network/load data, and the stock/custom decision +remain outstanding. M2 implementation is proceeding by explicit user direction; +this does not mark M1 accepted. M0 extraction and save/recovery work are not +repeated. + +## M1 course fixture specification (implemented; human runs pending) + +**Fixture identity:** a versioned, isolated staging course, separate from M0 +seed/date scenarios in [scenario definitions](../tests/fixtures/scenarios.json). +The bounded generator, consent command and checkpoint/timing callbacks are +implemented in [course.lua](../mods/hearth/course.lua). The launcher always creates +a fresh nonce-marked `course` world; production and the preserved play world are +not eligible. Headless `test:course` is a no-consent preflight only. To generate +the route and kit, launch `node scripts/run.mjs course` and opt in with `/course`. +Never build the course in the preserved world. + +- **Isolation and repeatability:** separate absolute `HEARTH_HOME`, explicit engine + executable, dedicated `staging` configuration when network testing, fixed seed + 731104, recorded layout revision and exact start/end positions. Use synthetic + 45°N, 0°E clear summer conditions for the reference run; repeat winter/rain as + a variant. This choice is only a course fixture, not community location approval. + Fuel still follows real time even when climate preview time is fixed. +- **Reset contract:** the launcher creates a fresh world and nonce for each course + session; `/course retry` resets only the current participant's timer/kit/route. + For *measured repeats*, launch a fresh course world to avoid terrain/entity + accumulation. No original survival-world snapshot is used or modified. Fixture + gear and test shortcuts remain unavailable in production. Record cold asset load + separately from warm traversal. +- **Route (about five minutes, not a measured result):** level walk/start/stop and + turning; long sprint to exhaustion/recovery; jump and 0.6-step-height boundary; + rough terrain/slopes; one-node and two-node doorway widths, corners, stairs and + low ceilings; water entry/exit/swimming and shore edges; controlled short falls + and recovery; repeat the route in reverse. Label geometry dimensions and record + any expected obstruction separately from a collision trap. Doors/stairs here + are test geometry, not a claim that new craftable building parts exist. +- **Interaction stations:** gather flint/stick/timber samples; compare + knife/axe/spear use, targeting/reach/occlusion, supported and intentionally + unsupported placement, protected-edge denials and full-inventory behavior. + Use the stationary target as a control and opt in to `/spar` for the moving + training opponent. Neither one is wildlife AI or a PvP mode. +- **Controls/accessibility:** record bindings, sensitivity, camera/FOV settings, + camera-motion preferences and input devices. Evaluate keyboard/mouse first; + additional devices/platforms get explicit results, not assumed parity. +- **Load/network matrix:** run an unshaped reference and RTT 0/80/150 ms with + 20 ms jitter and 1% loss; document actual achieved shaping and direction. + Traverse alone, then with up to 10 concurrent real clients on 4 vCPU/8 GB. + Fake sessions do not satisfy multiplayer evidence. Use the provisional + 4-core/8 GB/integrated OpenGL 3.3 client at 1080p low/view 96; target 60 FPS, + p95 frame time ≤25 ms and p95 server step ≤50 ms. These are targets, not data. +- **Evidence:** record revision/package inventory, host/OS/GPU/driver/settings, + warmup and timed sample duration, frame-time distribution, server steps, memory, + corrections/stalls and invalid action handling. Keep an issue log with route + station, reproduction, observed versus expected behavior and severity. Use + opt-in anonymized observations; private coordinates/account data stay out of + public reports. Report failed and incomplete runs, not just averages. +- **Telemetry limit:** `/course report` exposes checkpoint/time progress, engine + globalstep `dt` p95 and course-callback wall duration p95. It explicitly does + **not** profile client FPS, rendered frames, or CPU usage. Obtain those using + client instrumentation and record the actual network shaper configuration/results. +- **Acceptance:** several testers choose to repeat the loop; no known collision + traps or timing exploits, readable distinct tool/encounter feedback, and a + written stock-versus-custom decision supported by measurements. Performance + target revisions must be explicit, not relabeled passes. Browser testing is + conditional on a separate launch-scope decision, never assumed from native tests. +No human route run or performance matrix is currently recorded. The M1 evidence +document explains safe use of `/course`, `/course report` limitations and the +remaining user/operator gate. + +## How progress is tracked + +For each feature, record: owner, milestone, dependency IDs, code/asset changes, +save impact, automated test evidence, playtest observations and open defects. +Statuses: planned → implementation → automated verification → playtest → accepted. + +After every milestone, decide **continue, revise, or reduce scope**. Reduce breadth +(creature count, material count, decorative variants) before cutting movement feel, +clear feedback, sustainable play or save safety. Any change to a required feature +must be reflected in the checklist, not hidden by marking the milestone complete. diff --git a/docs/species-references.md b/docs/species-references.md new file mode 100755 index 0000000..153099c --- /dev/null +++ b/docs/species-references.md @@ -0,0 +1,129 @@ +# Offline species Wikipedia references + +## Shipped source coverage + +The bundle follows **all 80 actual Latin names** in [the botany catalogue](../mods/hearth/botany.lua), not their sometimes ambiguous common names. The maintenance run on **2026-11-03 UTC** queried all 80 names using eight MediaWiki API batches of ten, with at most two requests in flight. + +**78 entries contain real English Wikipedia plaintext introductions. Two entries contain original availability notes, not Wikipedia quotations:** + +| Game ID | Exact catalogue Latin name | Source status | +| --- | --- | --- | +| `old_mans_beard` | *Usnea subfloridana* | [Exact species page](https://en.wikipedia.org/wiki/Usnea_subfloridana) missing | +| `cup_lichen` | *Cladonia chlorophaea* | [Exact species page](https://en.wikipedia.org/wiki/Cladonia_chlorophaea) missing | + +For both missing species, the API explicitly returned `missing: true`; separate exact-page and REST-summary checks returned HTTP 404. No genus, species complex, similarly named lichen, invented revision, or synthetic Wikipedia prose was substituted. Consequently **80 actual species article excerpts are blocked by source availability**. The original notes make this visible to offline players. Refresh can pick up an article if Wikipedia creates one later. + +Five catalogue names resolved through Wikipedia's own title redirects in this snapshot: + +| Catalogue Latin name | Recorded source title | +| --- | --- | +| *Schedonorus pratensis* | Lolium pratense | +| *Phleum pratense* | Timothy (grass) | +| *Calluna vulgaris* | Calluna | +| *Anemone nemorosa* | Anemonoides nemorosa | +| *Evernia prunastri* | Oakmoss | + +These are API-reported redirects, not guessed mappings or search results. The catalogue's current *Collybia nuda* already matches its current Wikipedia title; the script does not silently replace it with an older *Lepista* name. `Calluna` is the article reached by Wikipedia's exact *Calluna vulgaris* redirect, not a separately selected genus fallback. + +## Lua/UI data contract + +[mods/hearth/wiki_articles.lua](../mods/hearth/wiki_articles.lua) is a **plain Lua module returning a table keyed by botany ID**. It has no factory, global assignments, registrations, dependencies, HTTP requests or gameplay side effects. [Content initialization](../mods/hearth/content.lua) loads this table into `hearth.wiki_articles` and constructs `hearth.species_effects` after botany and before succession. [The interface](../mods/hearth/interface.lua) constructs the injected [reader factory](../mods/hearth/species_reader.lua) and owns its command/field callbacks. + +## Reading in game + +- `/inspect` observes the first pointed node within **six blocks of the player's eye**, using a bounded ray and `get_node_or_nil`. Unknown or unloaded first surfaces stop inspection; nothing is emerged, force-loaded, harvested, placed or written. Species-specific seasonal plants, tree stems/stages, logs and leaves resolve through the actual `botany.kind()` API. Generic unnamed vegetation is not assigned an invented species. +- `/inspect oak` or `/inspect Quercus robur` opens an exact catalogue ID or canonical Latin name. Matching is exact and case-sensitive (surrounding whitespace is allowed), not fuzzy, common-name search or an arbitrary URL. `/botany [search]` remains the literal-search catalogue browser. +- Field notes offer **Inspect target**. The Botanical guide keeps its ten-row pages and adds a **Species article** dropdown/button for those rows. Selections are bounded numeric indices into the server session's current-page IDs, never submitted IDs or URLs. A HUD hint on known botanical targets advertises `/inspect species` (replace “species” with an ID, or use bare `/inspect` for the target). No harvesting or building callbacks are hijacked. +- The **Article** tab displays **Wikipedia excerpt (offline)** for the 78 real introductions. The two missing species use **No Wikipedia article cached (offline)** and show their original CC0 availability notes, never fake Wikipedia prose or revisions. No HTML, embedded browser or full-article availability is claimed. +- **Current effects** comes directly from `species_effects.describe(record)`: **SIMULATED** and **DESCRIPTIVE (not simulated)** are separate labels. Reviewed evidence notes and evidence URLs are distinct from snapshot identity provenance. The excerpt may omit the body section supporting an effect; numbers, timings, niche thresholds and budgets remain game policy, not measurements from Wikipedia. +- Body text is limited to **8,000 Unicode code points**, in pages of **4,000**. All current introductions fit one page unchanged (maximum 3,417). Any reader-side cap is disclosed. Unnamed read-only, selectable textareas show source title, attribution, license/link, canonical URL, permanent revision URL, contributor history, revision/retrieval timestamps and changes. Links are copyable plain text, never fetched or automatically opened. +- Closing the reader clears only session navigation. Article/effect reading never changes needs, inventory, node metadata, protection, succession, food permission or persistent state. Submitted article text, source URLs, species IDs and page numbers are ignored; tabs and buttons use validated server-held state. No privileges, credentials, dynamic evaluation or runtime network access are added. + +This remains **not identification, foraging, food preparation or medical advice**. Native Luanti formspec, visual and connected-player integration validation of this reader is pending; isolated tests do not certify those surfaces. + +Each Wikipedia entry exports: + +| Field | Type / meaning | +| --- | --- | +| table key | Stable species ID, e.g. `oak`, `wood_blewit`, `old_mans_beard` | +| `latin` | Exact catalogue binomial, retained even after a redirect | +| `title` | Actual resolved English Wikipedia source title; never HTML `displaytitle` | +| `url` | Validated canonical HTTPS article URL; copyable full-article link | +| `pageid` | Positive, finite JavaScript-safe integer identifying the source page | +| `revision` | Positive, finite safe integer revision ID; must equal API `lastrevid` | +| `revision_url` | Permanent source URL using `oldid` | +| `history_url` | Article history URL identifying Wikipedia contributors/authorship | +| `revision_timestamp` | API source revision UTC timestamp | +| `retrieved` | UTC ISO timestamp captured after the batch response | +| `text` | Verbatim plaintext introduction prefix, at most 8,000 Unicode code points | +| `kind` | `wikipedia_intro` | +| `excerpt` | `true`: this is an introduction, **not the full article** | +| `truncated` | Whether the introduction itself was shortened to meet the cap | +| `attribution` | `Wikipedia contributors, ""` | +| `license` | `CC BY-SA 4.0` | +| `license_url` | `https://creativecommons.org/licenses/by-sa/4.0/` | +| `changes` | Describes plaintext introduction extraction, body omission and any shortening; no translation | + +For an unavailable species, `kind = 'original_notes'`, `excerpt = false`, and `truncated = false`. `text` explicitly says it is an original availability note, **not a Wikipedia excerpt**. `pageid`, `revision`, `revision_url`, and `revision_timestamp` are **nil/absent**, not zero, strings, or fake revision numbers. `url` and `history_url` retain the exact missing species destination; they are not claims that an article exists. The original availability notes are dedicated to **CC0 1.0**, with their own `attribution`, `license`, `license_url` and `changes` fields. Never display them under a Wikipedia-excerpt heading or the Wikipedia license. + +### Presentation and attribution requirements + +- Display `title`, `latin`, `text`, and an explicit **Wikipedia introduction excerpt** label for `wikipedia_intro`. An availability note must get its own distinct label. +- Display the stored attribution, license/link, retrieval date, permanent revision link, history link and changes notice with the excerpt. The permanent revision and linked page history provide access to the contributing authors; no author usernames or personal information are downloaded into the bundle. +- Render **plain text only**. Never interpret HTML, wikitext, Markdown, inline links or article text as executable Lua or formspec syntax. Apply the UI's formspec escaping to every displayed field; byte escaping in the Lua source does not replace presentation-layer escaping. +- Offer `url` and `revision_url` as copyable text. Do not fetch them, prefetch link previews, auto-open a browser, or follow links found in `text` at runtime. +- Keep the warning that Wikipedia and game descriptions are **not identification, foraging, food preparation or medical advice**. Wikipedia can describe toxic species, traditional uses, disputed findings, or processing methods; copying its introduction does not endorse them or enable a game effect. +- Preserve Wikipedia attribution and [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) when redistributing or adapting Wikipedia excerpts. Share adaptations under the applicable ShareAlike terms and describe modifications. This source-text license is distinct from the game's original code/art licenses. No images, page HTML or full article bodies are copied. + +The shipped 78 introductions have a maximum of **3,417 Unicode code points**; **zero** required additional shortening. Extraction omits article bodies and uses MediaWiki's `exintro=1` / `explaintext=1`; whitespace is trimmed only at the ends. Future overlong introductions are cut on a paragraph, sentence, or word boundary without adding invented continuation text, and set `truncated = true`. + +## What the real sources support about effects + +The actual text is available for in-game reading, not just a source-name label. These examples were reviewed against the cached introductions and their exact source revisions: + +| Species / cached ID | Natural-history observation actually present in the cached introduction | Source revision | +| --- | --- | --- | +| *Quercus robur* / `oak` | Near-neutral lowland soils; supports herbivorous insects, acorn-eating birds/mammals and fungi. Supports a qualitative habitat/food-web explanation, not a numeric resource multiplier. | [Wikipedia revision 1377452916](https://en.wikipedia.org/w/index.php?oldid=1377452916) | +| *Alnus glutinosa* / `alder` | Wet habitats, association with *Frankia alni* in poor soils, wildlife food/shelter, and light-demanding pioneer regeneration. The introduction does not quantify a game nutrient budget. | [Wikipedia revision 1379114226](https://en.wikipedia.org/w/index.php?oldid=1379114226) | +| *Trifolium repens* / `clover` | Widely introduced and cultivated as a forage crop. This supports a forage description, not human edibility or a quantified nitrogen-fixation rate. | [Wikipedia revision 1376551191](https://en.wikipedia.org/w/index.php?oldid=1376551191) | +| *Sphagnum palustre* / `sphagnum` | Strong water absorption and wet-forest habitat. The article's reported absorption capacity is a natural-history statement, not a measured in-game moisture transfer. | [Wikipedia revision 1241630727](https://en.wikipedia.org/w/index.php?oldid=1241630727) | +| *Amanita muscaria* / `fly_agaric` | Tree symbioses and poisoning risk. Supports a qualitative mycorrhizal/toxicity explanation; does not establish a safe consumption procedure or specify all permitted game hosts. | [Wikipedia revision 1380994403](https://en.wikipedia.org/w/index.php?oldid=1380994403) | +| *Ceratophyllum demersum* / `hornwort` | Submerged, free-floating aquatic growth. Supports the catalogue's qualitative rootless/submerged explanation, not a numerical light or water-depth window. | [Wikipedia revision 1366317776](https://en.wikipedia.org/w/index.php?oldid=1366317776) | + +**Do not claim that all existing botany effects are scientifically verified by these introductions.** For example, the cached *Pteridium aquilinum* introduction does not establish the catalogue's allelopathy descriptor; the cached *Peltigera canina* introduction does not establish its nitrogen-fixer descriptor. The white-clover introduction likewise does not establish nitrogen-fixation rates. Those claims need separately reviewed species-specific sources or relevant article sections before an effects UI may call them sourced. Historical medicinal uses and preliminary extract studies are not proof of clinical benefit. + +Existing game niche thresholds, nutrient caps, timing, protection rules, edibility decisions, host lists and conservation budgets remain **game policy**, not quotations or measurements from Wikipedia. This bundle neither derives those constants from prose nor changes mechanics. Disabled descriptive effects stay disabled. Reading an article never grants food permission, healing, buffs, poisoning, resource creation, or plant damage. The source observations above provide real evidence for explanatory text while keeping the unverified and numerical parts explicitly separate. + +## Explicit maintenance script + +[scripts/wiki-species.mjs](../scripts/wiki-species.mjs) uses Node.js 22+ built-ins only. Arguments are deliberately limited to exactly one of: + +- `--refresh`: read the fixed botany catalogue, query all 80 Latin names, validate complete results, then replace the fixed Lua output. Explicit missing pages become original availability notes. Print exact source counts and batch bounds. +- `--check`: parse and validate the generated bundle and its agreement with the current catalogue **offline**. Print source counts and `network requests: 0`. +- `--help`: print maintenance usage, without reading articles or making requests. + +Invoke Node with this script path and one of those flags. There are **no URL, title, credential, output-path, retry, concurrency or timeout override arguments**. Importing the script does not run maintenance or contact Wikipedia. Tests can inject `fetchImpl` and `now` into exported pure/testable helpers; normal maintenance uses built-in `fetch`. Catalogue parsing is bounded literal-row parsing, **never Lua execution**. + +### Security and failure behavior + +- Only `https://en.wikipedia.org/w/api.php` is fetched. Latin names go through a strict binomial allowlist and `URLSearchParams`, never string-interpolated query syntax. Queries contain only the public catalogue Latin names, not player names or private data. No credentials, API keys, cookies or authorization headers are used. The descriptive `LastHearthSpeciesBundle/1.0` user agent identifies this manual offline-bundle use. +- Scope: at most 80 titles, ten per batch, two concurrent batches. A complete normal refresh makes **eight API requests**. No search queries, crawl, pagination, automatic retries, external links, or per-player network activity. +- HTTP redirect mode is manual. At most two redirects per batch are accepted, only to the same allowlisted HTTPS origin, exact API path and unchanged query. Credentials, non-default ports, fragments, other hosts (including mobile subdomains), other paths and query changes are rejected. Article redirects are API data, not HTTP fetch destinations. +- Each batch has a **15-second total deadline**, including its redirects and response body. The complete run has a **90-second deadline**. Failed workers abort the remaining work. Responses are limited to **2 MiB of decoded response bytes**, checked against both Content-Length and streamed bytes; JSON MIME and valid UTF-8 are required. TLS validation is left enabled; no certificates or secrets are embedded or bypassed. +- Reject API errors/warnings, incomplete batches, missing response records, HTML/wikitext in introductions, disambiguation pages, namespace/language mismatches, invented aliases, title/canonical-URL mismatches, and invalid or inconsistent page/revision metadata. Only explicit `missing: true` for the exact species gets an original note. A timeout, HTTP error or validation failure is **not** turned into an availability note. +- Excerpts are associated with the API's current revision metadata, with `lastrevid` and `revisions[0].revid` required to agree. MediaWiki's current-page extract service is not an immutable old-revision extractor; a concurrent edit or upstream cache issue remains an upstream consistency limitation. The bundle records the exact metadata and retrieval time returned by that query, rather than claiming an independently verified historical text reconstruction. +- Every string, including IDs, titles and external text, is serialized into fixed-width decimal **UTF-8 byte escapes** inside Lua quoted literals. Article text cannot inject Lua quotes, comments, long strings, or code. Offline checking uses a strict generated-data parser, not `eval`, `load` or a Lua interpreter. +- The catalogue path and output path are fixed relative to the script. Existing `safePath` checks reject symlink/junction components, and `O_NOFOLLOW` is used where supported. Trusted local parent directories and one maintenance writer are required, as with the project's other file helpers. No CLI path can redirect writes. +- The existing cache is not opened for writing until every batch and the complete generated-data round trip validate. Network/validation failure leaves its bytes unchanged. The final fixed-file write is not crash-atomic: interruption/disk failure during writing requires restoring the tracked bundle or rerunning a successful refresh. It creates no sidecar or temporary workspace files. + +## Focused offline verification + +[tests/species_reader_spec.lua](../tests/species_reader_spec.lua) exercises the actual cached bundle, botany and effects APIs with injected read-only mocks. [tests/recipe_spec.lua](../tests/recipe_spec.lua) preserves its original 38 checks and adds command, dropdown, navigation, target and forged-field regressions. These are isolated Lua suites, not full-engine tests. The new reader suite follows the existing same-name module discovery convention; no launcher or engine-harness edits are needed. + +The focused reader run passed **19 reader/HUD checks**, **43 recipe/journal checks (38 original + 5 new)** and **38 existing content checks**, with exact final totals and empty stderr under Fengari. The content probe additionally verified that sources/effects exist before succession construction. All six owned Lua sources/specs compiled. These results do not replace native LuaJIT/client integration, visual review or the full-engine gates; no full-engine tests were run for this change. + +[tests/wiki-species.test.mjs](../tests/wiki-species.test.mjs) runs independently with Node's test runner. It does not launch Luanti or use the full-engine suite. + +The verified run has **21 passing tests, 0 failures, 0 real network requests, and 33 mocked transport requests**. Coverage includes URL/query injection, SSRF redirects, redirect limits, timeout/abort cleanup, MIME/UTF-8/size rejection, page/revision validation, source-title redirects, missing-page honesty, Unicode/boundary clipping, malicious-text Lua serialization, exactly eight mocked batches at concurrency two, offline CLI checks, and byte-for-byte cache preservation after an HTTP failure. + +Snapshot assertions intentionally expect **78 Wikipedia excerpts + 2 original availability notes**. After a successful future refresh, review changed text, source identities, license/attribution, missing-page status and any effects claims; update the snapshot expectations and this document when coverage changes. Never replace a failing source with made-up text merely to make the count reach 80. diff --git a/docs/world-generation.md b/docs/world-generation.md new file mode 100755 index 0000000..b91a363 --- /dev/null +++ b/docs/world-generation.md @@ -0,0 +1,294 @@ +# World generation: watersheds, woodland, and bedrock + +## Integration boundary + +[biomes.lua](../mods/hearth/biomes.lua) is a lazy factory: + +```lua +local worldgen = dofile(modpath .. "/biomes.lua")(hearth, api, register_node, construction) +hearth.biomes = worldgen.register() +``` + +The main content owner must replace the **entire old mapgen section**, from its +mapgen alias registrations through its old tree decoration, with this call. +Keep the subsequent wildlife load. Call after base nodes, resources, ecological +flora, and seasonal patches have registered, using content's **private** +`register_node` helper. Do not retain duplicate old biomes/decorations/iron seams. +This module does not load itself or edit the content/bootstrap/launcher/config. + +`register()` returns the same module and is idempotent on that instance. Create +one instance per mod load, not one per player. Duplicate terrain node names fail +rather than override existing definitions. Nothing clears global registrations; +existing mapgen aliases/items retain precedence. Coexisting mods may therefore +alter the final landscape. Alias precedence is coexistence, not a promise that a +foreign mapgen-stone binding is compatible with this game's geology. + +### Shared ecosystem API + +- `species_order`: exactly `oak`, `birch`, `pine`, `willow`, `alder`, `beech`, + `aspen`, `rowan`, `hazel`, `spruce` (ten species). +- `profiles[species]`: height, width, crown, approximate heat/humidity preferences, + altitude limits, soils, biome names, shade and litter hints. Alder also has + `nitrogen_fixer=true`; willow and alder have `riparian=true`. +- `surface(short_name | qualified_name | {name=qualified_name})`: habitat table + or `nil`. Moisture, fertility and drainage are normalized 0–1 **preferences**, + not actual sampled soil water or mutable nutrient stores. +- `supports_species(species, surface)`: soil compatibility only; `false` for + unknown inputs. The ecology owner must also check climate, light, water, + claims/protection, loadedness and player-placed status before any growth. +- `tree_schematic(species [, canonical])`: fresh bounded table schematic. + `canonical=true` uses existing generic logs/leaves (legacy meadow resource + probes); normal calls use species blocks. Unknown species raises an error. +- `biome_profiles`, `surfaces`, `rocks`: declarative read-only-by-convention data. + +This owner registers only species **log/leaves/planks**, not species seedling, +young, mature, seed, seasonal-variant or decay nodes. Ecological growth and +seasonal behavior belong to the ecosystem owner. Mapgen places full-sized trees, +not oversized plant sprites; existing understory nodes provide habitats, litter, +deadwood, fungi and seasonal forage for the ecological simulation. Registering a +canopy group alone does not make an older ecology module recognize species: +that owner must consume `profiles`/`hearth_canopy` and recognize these soils. +Seasonal retinting/leaf shedding of species canopies is likewise not implemented +by this registration module. + +## Ten connected landscape communities + +Heat and humidity below are **Voronoi centers**, not hard rectangular ranges. +The nearest eligible center wins; altitude limits are hard. These heat numbers +are mapgen climate coordinates, not Celsius or geographic latitude. Runtime +weather/seasons remain owned by the climate system. + +| Registered biome | Y | Heat / humidity | Surface / bedrock | Community | +|---|---:|---:|---|---| +| `hearth_temperate` | 2–110 | 52 / 45 | Grass / limestone | Open meadow, grain, flowers, greens, sparse oak and canonical timber | +| `hearth_oak_woodland` | 3–110 | 65 / 62 | Forest floor / limestone | Oak, domed beech, coppice hazel, fern, litter, fungi, fallen wood | +| `hearth_birch_forest` | 6–150 | 36 / 58 | Forest floor / granite | Birch, columnar aspen, rowan and spruce; woodland forage | +| `hearth_pine_taiga` | 2–180 | 18 / 38 | Podzol / granite | Pine, spired spruce and rowan; acidic nutrient-poor litter, mushrooms | +| `hearth_wet_forest` | 2–65 | 42 / 90 | Peat, alluvium / slate | Moist fern/decomposer community; alder only at actual water edges | +| `hearth_floodplain` | 2–8 | 68 / 90 | Alluvium / sandstone | Fertile flood terraces, grain, greens, willow/alder beside water | +| `hearth_dry_scrub` | 2–135 | 83 / 18 | Grass, shallow earth / sandstone | Open grass/flower scrub, berries; no artificial woodland or springs | +| `hearth_alpine_meadow` | 111–210 | 30 / 48 | Alpine turf, scree / slate | Short grass/flowers above most woodland; occasional rowan through Y=180 | +| `hearth_rocky_peaks` | 211–31000 | 25 / 45 | Permanent snow, scree / granite | Snowline across all climates, exposed structurally unsupported rock | +| `hearth_riverbank` | −31000–1 | 50 / 50 | Sand, gravel / sandstone | Shore/channel substrate, riparian gathering and rare old weirs | + +The last biome is also the underground altitude fallback, not a claim that the +deep world is literally a river. Explicit geological beds below correct that. +There is an eligible biome at every altitude. Woodland-to-alpine overlap allows +climate-dependent treelines through Y=180; above 210 there is only the peak +biome, with zero vertical blend. Snow is a stable full-block cap, **not** seasonal +snowfall or a falling node. Surface/altitude alone cannot distinguish every +steep slope: stock v7's structural-support checks expose bedrock where the node +below is air/water; this is not a custom slope/erosion simulator. + +### Full-block trees + +| Species | Schematic width × height × depth | Shape | +|---|---|---| +| Oak | 7 × 7 × 7 | Broad layered crown with lateral log branches | +| Birch | 5 × 8 × 5 | Narrow elevated diamond crown and slender trunk | +| Pine | 7 × 9 × 7 | Wide low branches narrowing to a single leafy tip | +| Willow | 9 × 7 × 9 | Spreading branches, hanging curtains down to the lower trunk | +| Alder | 5 × 6 × 5 | Short riparian trunk and compact upright crown | +| Beech | 9 × 8 × 9 | Broad domed crown | +| Aspen | 5 × 9 × 5 | Tall columnar crown | +| Rowan | 7 × 6 × 7 | Open crown | +| Hazel | 7 × 5 × 7 | Low coppice form | +| Spruce | 5 × 10 × 5 | Narrow spired evergreen crown | + +All occupied cells use probability 255; empty cells use zero. Schematics are +x-fastest, then y, then z, centered horizontally with base offset +1 above the +support. They do not force placement or cut into terrain. Canopies register +before ground flora, preventing grass from blocking the base trunk. These are +distinct authored forms, not random blocks. Trees at steep/obstructed boundaries +can still be clipped by native non-forcing schematic placement; there is no +Lua post-generation scan/rebuild of partial crowns. + +Fallen deadwood is an ordinary sideways full cube, not a plant sprite or a mesh. +It requires an axe and drops one canonical `hearth:log`; hands do not yield +sticks. Its existing metadata-preserving orientation fix and ecological +substrate identity remain separate from this documentation/process/UI pass. +The named botanical catalogue adds 80 species in eight categories of ten; +`/botany [search]` reads their real common/scientific names, habitat windows, +seasons, game toxicity flags and conditional roles. See the +[complete category list and caveats](content-expansion.md#eighty-species-botanical-field-guide). + +Reeds, clay and weirs require nearby source/flowing water (at least one of the +native eight neighbors at support Y or Y−1). Willow/alder require the same +check as well as biome, altitude and soil compatibility. Wet climate alone is +not proof of a river. Floodplain selection itself is climate/altitude based, +not hydrological flood simulation. + +## Geology and resource progression + +Surface bedrock follows biome geology, then registered ores run in this order: + +1. Replace sandstone/canonical stone with **granite basement below −80**, + **slate from −79 to −24**, and **limestone from −23 to −8**. Above that, + shallow sandstone and biome-specific rocks remain. +2. Rare coherent 24-node basalt intrusions in bedrock below −24 (48³ scarcity; + correlated 3D noise, not scattered basalt confetti). +3. Nested scatter clusters in the resulting actual host stones: + +| Resource node | Allowed hosts | Y maximum | Cluster / spacing | +|---|---|---:|---| +| `hearth:quartz_vein` | Granite, slate | 160 | 6 attempts in 3³, scarcity 11³ | +| `hearth:copper_ore_vein` | Basalt, limestone, slate | 32 | 5 in 3³, scarcity 13³ | +| `hearth:coal_ore_vein` | Sandstone, slate, limestone | 70 | 9 in 3³, scarcity 10³ | +| Existing `hearth:iron_ore_vein` | Basalt, granite, limestone, canonical stone | −8 | 6 in 3³, scarcity 11³ | +| Same iron, sparse exposed/shallow seams | Same hosts | −7 through 85 | 3 in 2³, scarcity 19³ | + +All deep ranges start at −31000 (actual mapgen limit still bounds the world). +Scarcity and cluster counts are expectations, not guaranteed yields. Ores +replace their host, never air/soil/leaves. They can be exposed by terrain/caves, +but are not fake simple decorations placed on cavern floors. + +All ten species logs drop themselves; **one log crafts four matching species planks**. +Each is accepted by exact name as **180 seconds** of direct hearth fuel. +Planks, buildings and arbitrary `tree` groups are not direct fuels. +For canonical timber, place **two logs of the same species in separate crafting +slots**, shapelessly, to produce **two canonical `hearth:log`**. There is no +single-log canonical conversion. Species planks convert 1:1 to `hearth:planks`. This +preserves axe requirements and the existing charcoal-charge, sticks, wall, +storage and other exact-name recipes without destructive node aliases. There +is no reverse conversion that duplicates timber. + +Every raw rock drops itself; **one rock produces four matching rock-brick +building blocks**. Place **two rocks of the same type in separate crafting +slots**, shapelessly, to produce **two canonical `hearth:stone`** for existing +foundation recipes. There is no single-rock canonical conversion. Both pair +conversions preserve mass (2:2), and cannot shadow the single-input plank/brick +recipes. Mixed species or rock types do not convert together. +Masonry uses `hearth_building=1` plus construction placement/ownership hooks, +so the private registration helper retains room invalidation and protection. +There are no new stair/wall variants or alternate protection callbacks here. +Loose gravel/scree drops canonical loose flint. New soils drop canonical soil; +hand placing that soil does not restore an intact woodland ecosystem. + +Quartz/copper/coal ore nodes each drop two `hearth:quartz`, `hearth:copper_ore`, +or `hearth:coal` craftitems. Downstream integration supplies three-quartz windows, +copper charges (45-second smelt with 30 seconds reserved) and 240-second coal +fuel under the 600-second reserve cap. These are process/content contracts, not +additional biome-factory recipes. Existing iron definitions are reused. + +Loose flint registers on **all** listed surfaces, and dry sticks on those +surfaces through Y=210. Meadow canonical grass/logs remain for old resource +probes; woodland berries, river reeds/clay and limited upland seeps retain +early-survival routes. This is coverage, not a verified guarantee for seed +731104 or every start. Main must update species/soil-aware spawn checks and +validate water, flint, sticks, timber, forage, reeds and clay after integration. +Do not silently hand players supplies or claim native seed validation from +mocked registration tests. + +## Terrain material art contract: 64 PNGs + +Every file has prefix **hearth_** and suffix **.png**. No downloads, texture +aliases, temporary inventory silhouettes or engine-default art are required. + +**61 world tiles** (expand each listed brace alternative): + +- `hearth_{granite,basalt,limestone,slate,sandstone}.png` — 5 opaque rock faces. +- `hearth_{granite,basalt,limestone,slate,sandstone}_bricks.png` — 5 opaque, + coursed masonry faces, one material per full block. +- `hearth_{forest_floor,podzol,alluvium,peat,alpine_turf}.png` — 5 opaque soil + **top** faces. Bottom and four sides reuse existing `hearth_soil.png`. +- `hearth_{gravel,scree,snow}.png` — 3 opaque full-block faces. +- `hearth_{quartz_vein,copper_ore_vein,coal_ore_vein}.png` — 3 complete opaque + ore-bearing rock faces (not transparent overlays). +- For **each** oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce: `hearth__log_top.png`, + `hearth__log_side.png`, `hearth__leaves.png`, + `hearth__planks.png` — 40 tiles total. Logs use top/top/side; + leaves use clipped alpha and full-block `allfaces_optional` rendering. + +**3 inventory images:** `hearth_item_quartz.png`, +`hearth_item_copper_ore.png`, `hearth_item_coal.png`. + +Canonical grass/log/leaves, iron ore, water, flora, seasonal forage, sand, dirt, +loose flint and weir textures are **existing** art. This module owns no art +generation scripts/assets. Species ecological-stage textures/meshes remain +the ecosystem/art owners' separate contract. Dynamic Lua tile names must be +explicitly enumerated by the art owner if its scanner cannot expand loops. + +## Recommended v7 preset — NEW WORLDS ONLY + +The following is a source-grounded **recommendation**, not an applied or +engine-playtested preset. Main should place it in game defaults and its +launcher **new-world creation** path, preserving existing saved mapgen params. +The current launcher writes `mg_name=v7` and seed 731104, but does not yet emit +these tuned noise values. Merely changing game defaults is not sufficient to +prove that managed-launcher new worlds received them. + +```conf +mg_name = v7 +water_level = 1 +mapgen_limit = 512 +mg_flags = caves,nodungeons,light,decorations,biomes,ores +mgv7_spflags = mountains,ridges,nofloatlands,caverns +mgv7_mount_zero_level = 0 +mgv7_np_terrain_base = 12, 65, (384, 384, 384), 82341, 5, 0.6, 2.0 +mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0 +mgv7_np_terrain_persist = 0.6, 0.1, (1200, 1200, 1200), 539, 3, 0.6, 2.0 +mgv7_np_height_select = -8, 16, (320, 320, 320), 4213, 6, 0.7, 2.0 +mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 0.7, 2.0 +mgv7_np_mount_height = 230, 100, (700, 700, 700), 72449, 3, 0.6, 2.0 +mgv7_np_mountain = -0.6, 1, (180, 280, 180), 5333, 5, 0.63, 2.0 +mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 2.0 +mgv7_np_ridge = 0, 1, (100, 100, 100), 6467, 4, 0.75, 2.0 +mg_biome_np_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0 +mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0 +``` + +Why these parameters: + +- Base/alternate terrain is lifted from stock offset 4 to 12, while retaining + an accessible low-relief alternate surface. This reduces ocean dominance + without pretending noise can guarantee a dry, safe spawn. +- Broad 384-node hills and 700-node mountain/river structure fit the current + small bounded world. Mountain density retains stock −0.6 bias; its longer + vertical than horizontal spread favors grounded massifs over spiky rubble. + Mount-height noise controls a density gradient, **not** an absolute peak cap. +- Ridges retain v7's native water-level channels and canyon walls. Neither + arbitrary block decorations nor exaggerated height offsets manufacture + cliffs. The river-width threshold remains the stock C++ value. +- 256-node climate fields make multiple coherent communities plausible inside + the ±512 limit; stock 1000-node climate fields can hide most variety there. + Existing small-scale heat/humidity blend noises can remain stock. +- Floatlands are explicitly disabled; native mountain noise can still produce + small overhangs/disconnected remnants. This is stock v7, not a guarantee of + mathematically erosion-correct terrain. Dungeons are disabled so unknown + mapgen dungeon aliases do not create unrelated structures. + +Never call `settings:set_default`, change mapgen at runtime, rewrite existing +map metadata, delete chunks, or automatically regenerate a player's world. +New biome registrations can still change **future ungenerated** chunks in an +existing world; they do not retrofit old terrain and may create seams. Offer a +backed-up new world for the coherent preset and explicitly disclose seams for +existing saves. Keep existing world identity and backup protections intact. + +## Validation and limitations + +The standalone [biome tests](../tests/biomes_spec.lua) exercise an allowlisted +registration-only API mock, all material references and drops, canonical +crafting paths, non-forcing aliases, repeat registration, altitude coverage, +ordered geology, species silhouettes, riparian checks and art names. They do +not assert that mocked noise produced terrain. + +Engine source/API reviewed locally: `MapgenV7Params::readParams`, +`MapgenV7::baseTerrainLevelAtPoint`, `getMountainTerrainAtPoint`, +`getRiverChannelFromMap`, `makeChunk`, `MapgenBasic::generateBiomes`, +`OreScatter::generate`, `OreStratum::generate`, and the documented biome/ore/ +decoration/schematic definitions. In particular v7 generates biomes, caves, +ordered ores, then decorations; biome transitions must not be assumed to +extrude surface rock unchanged to the bottom of the map. + +No full engine was run for this owner task. Main's remaining native gate is a +fresh disposable world with explicit saved preset verification, multiple seed +starts (including 731104), resource reachability, actual tree anchoring/media +loading and representative valley/forest/alpine views. Do not test by opening +the user's persistent world. Neither performance nor safe spawning is certified +by the standalone checks. + +Security review: no credentials, cryptography, certificates, network access, +filesystem writes, deserialization or player-supplied executable input are +introduced. Schematics are fixed-size bounded data; private node registration +preserves the caller's protection/construction hooks. Existing player worlds +and engine-global API functions are not mutated. diff --git a/minetest.conf b/minetest.conf index ddfafb4..49972da 100755 --- a/minetest.conf +++ b/minetest.conf @@ -6,5 +6,22 @@ movement_speed_walk = 4 movement_speed_jump = 6.5 movement_acceleration_default = 4 movement_acceleration_air = 2 -mapgen_limit = 512 +# Creation defaults only: Luanti persists mapgen parameters in map_meta.txt. +# No runtime retuning or regeneration; managed existing worlds receive no overrides. +mg_name = v7 +water_level = 1 +mapgen_limit = 4096 +mg_flags = caves,nodungeons,light,decorations,biomes,ores mgv7_spflags = mountains,ridges,nofloatlands,caverns +mgv7_mount_zero_level = 0 +mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0 +mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0 +mgv7_np_terrain_persist = 0.6, 0.1, (1200, 1200, 1200), 539, 3, 0.6, 2.0 +mgv7_np_height_select = -8, 16, (320, 320, 320), 4213, 6, 0.7, 2.0 +mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 0.7, 2.0 +mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0 +mgv7_np_mountain = -0.6, 1, (180, 280, 180), 5333, 5, 0.63, 2.0 +mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 2.0 +mgv7_np_ridge = 0, 1, (100, 100, 100), 6467, 4, 0.75, 2.0 +mg_biome_np_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0 +mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0 diff --git a/mods/hearth/access.lua b/mods/hearth/access.lua new file mode 100755 index 0000000..5fe25d3 --- /dev/null +++ b/mods/hearth/access.lua @@ -0,0 +1,39 @@ +-- Pure claim relationship checks. Unknown roles/actions and ambiguous overlaps deny. +return function(claims, access) + local function owner_at(pos) + local found + for owner, center in pairs(claims) do + if math.abs(pos.x - center.x) <= 12 and math.abs(pos.z - center.z) <= 12 + and math.abs(pos.y - center.y) <= 24 then + if found and found ~= owner then return nil, true end + found = owner + end + end + return found, false + end + + local function role_at(pos, name) + local owner, ambiguous = owner_at(pos) + if ambiguous or not owner then return nil end + if owner == name then return "owner" end + return access[owner] and access[owner][name] or nil + end + + local function allowed(pos, name, action) + if type(pos) ~= "table" or type(pos.x) ~= "number" or type(pos.y) ~= "number" + or type(pos.z) ~= "number" or type(name) ~= "string" or name == "" then return false end + action = action or "build" + if action ~= "build" and action ~= "dig" and action ~= "use" + and action ~= "storage" and action ~= "arrival" and action ~= "combat" then return false end + local owner, ambiguous = owner_at(pos) + if ambiguous then return false end + if not owner then return true end + if owner == name then return true end + local role = access[owner] and access[owner][name] + if role == "resident" then return action ~= "arrival" and action ~= "combat" end + if role == "storekeeper" then return action == "storage" end + return false + end + + return {owner_at = owner_at, role_at = role_at, allowed = allowed} +end diff --git a/mods/hearth/biomes.lua b/mods/hearth/biomes.lua new file mode 100755 index 0000000..a8510f3 --- /dev/null +++ b/mods/hearth/biomes.lua @@ -0,0 +1,322 @@ +-- Original terrain content. Registration only: never rewrites generated land, +-- changes world settings, clears registries, or owns ecological growth timers. +return function(hearth, api, register_node, construction) + assert(type(hearth) == "table" and type(api) == "table") + assert(type(register_node) == "function" and type(construction) == "table") + local M = {} + local function node(name) return "hearth:" .. name end + local function tile(name) return "hearth_" .. name .. ".png" end + local function names(list) + local out = {} + for i, name in ipairs(list) do out[i] = node(name) end + return out + end + M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"} + M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"} + -- Moisture/fertility/drainage are normalized habitat preferences, NOT player + -- climate values. The succession owner decides how to consume these hints. + M.surfaces = { + grass = {moisture=0.45, fertility=0.7, drainage=0.7, vegetated=true}, + forest_floor = {moisture=0.6, fertility=0.9, drainage=0.6, vegetated=true}, + podzol = {moisture=0.4, fertility=0.35, drainage=0.8, vegetated=true}, + alluvium = {moisture=0.85, fertility=1, drainage=0.3, vegetated=true}, + peat = {moisture=1, fertility=0.3, drainage=0.1, vegetated=true}, + alpine_turf = {moisture=0.35, fertility=0.3, drainage=0.9, vegetated=true}, + soil = {moisture=0.45, fertility=0.5, drainage=0.6, vegetated=true}, + sand = {moisture=0.2, fertility=0.1, drainage=1}, + gravel = {moisture=0.2, fertility=0.1, drainage=1}, + scree = {moisture=0.1, fertility=0.05, drainage=1}, + snow = {moisture=0.1, fertility=0, drainage=0.5}, + } + for _, rock in ipairs(M.rocks) do + M.surfaces[rock] = {moisture=0.1, fertility=0, drainage=1} + end + M.surfaces.stone = {moisture=0.1, fertility=0, drainage=1} + function M.surface(value) + local name = type(value) == "table" and value.name or value + if type(name) ~= "string" then return nil end + return M.surfaces[(name:gsub("^hearth:", ""))] + end + M.profiles = { + oak = {height=7, width=7, crown="broad", heat={35,80}, humidity={30,75}, + y_min=3, y_max=100, soils={"grass", "forest_floor"}, + biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.8, litter=0.9}, + birch = {height=8, width=5, crown="slender", heat={20,65}, humidity={35,80}, + y_min=6, y_max=145, soils={"forest_floor", "grass", "podzol"}, + biomes={"hearth_birch_forest"}, shade=0.45, litter=0.7}, + pine = {height=9, width=7, crown="conical", heat={0,45}, humidity={15,70}, + y_min=8, y_max=175, soils={"podzol", "alpine_turf"}, + biomes={"hearth_pine_taiga"}, shade=0.65, litter=0.45}, + willow = {height=7, width=9, crown="drooping", heat={35,85}, humidity={70,100}, + y_min=2, y_max=8, soils={"alluvium", "peat"}, + biomes={"hearth_floodplain"}, shade=0.85, litter=0.8, riparian=true}, + alder = {height=6, width=5, crown="riparian", heat={20,65}, humidity={75,100}, + y_min=2, y_max=45, soils={"alluvium", "peat", "forest_floor"}, + biomes={"hearth_wet_forest", "hearth_floodplain"}, shade=0.6, litter=1, + riparian=true, nitrogen_fixer=true}, + beech = {height=8, width=9, crown="domed", heat={35,70}, humidity={35,70}, + y_min=4, y_max=115, soils={"forest_floor", "grass"}, + biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.95, litter=1}, + aspen = {height=9, width=5, crown="columnar", heat={20,65}, humidity={35,75}, + y_min=5, y_max=140, soils={"grass", "forest_floor", "alluvium"}, + biomes={"hearth_birch_forest", "hearth_temperate"}, shade=0.45, litter=0.7}, + rowan = {height=6, width=7, crown="open", heat={10,55}, humidity={30,80}, + y_min=10, y_max=180, soils={"podzol", "alpine_turf", "forest_floor", "grass"}, + biomes={"hearth_birch_forest", "hearth_pine_taiga", "hearth_alpine_meadow"}, shade=0.4, litter=0.6}, + hazel = {height=5, width=7, crown="coppice", heat={35,75}, humidity={35,75}, + y_min=3, y_max=95, soils={"forest_floor", "grass", "soil"}, + biomes={"hearth_oak_woodland", "hearth_temperate"}, shade=0.7, litter=0.8}, + spruce = {height=10, width=5, crown="spire", heat={0,40}, humidity={40,85}, + y_min=8, y_max=185, soils={"podzol", "forest_floor", "alpine_turf"}, + biomes={"hearth_pine_taiga", "hearth_birch_forest"}, shade=0.85, litter=0.5}, + } + function M.supports_species(species, surface) + local p = M.profiles[species] + local name = type(surface) == "table" and surface.name or surface + if not p or type(name) ~= "string" then return false end + name = name:gsub("^hearth:", "") + for _, soil in ipairs(p.soils) do if soil == name then return true end end + return false + end + -- x fastest, then y, then z. Empty cells have probability zero, so these + -- bounded full-block schematics cannot carve slopes or erase other trees. + function M.tree_schematic(species, canonical, stage) + local p = assert(M.profiles[species], "unknown tree species") + assert(stage == nil or stage == "mature" or stage == "young", "unknown tree stage") + local w, h = p.width, p.height + if stage == "young" then w, h = 3, (species == "pine" or species == "spruce") and 5 or 4 end + local c, data = math.floor(w / 2), {} + for z = 0, w - 1 do for y = 0, h - 1 do for x = 0, w - 1 do + local dx, dz = math.abs(x-c), math.abs(z-c) + local r = math.max(dx, dz) + local trunk = dx == 0 and dz == 0 and y < h-1 + local leaf = false + if stage == "young" then + leaf = y >= h-3 and dx+dz <= (y == h-1 and 0 or 1) + elseif species == "oak" then + leaf = y >= 3 and r <= (y == h-1 and 1 or (y == 3 and 2 or 3)) + and dx+dz <= (y == h-1 and 2 or 4) + trunk = trunk or (y == 3 and dx+dz <= 2 and (dx == 0 or dz == 0)) + elseif species == "birch" then + leaf = y >= 4 and dx+dz <= (y == h-1 and 1 or 2) + elseif species == "pine" then + local radius = ({[2]=3,[3]=2,[4]=2,[5]=1,[6]=1,[7]=1,[8]=0})[y] + leaf = radius ~= nil and r <= radius and dx+dz <= radius+1 + elseif species == "willow" then + leaf = (y >= 4 and r <= (y == h-1 and 2 or 3) and dx+dz <= 5) + or (y >= 1 and y <= 4 and r == 3 and (dx+dz)%2 == 0) + trunk = trunk or (y == 4 and dx+dz <= 3 and (dx == 0 or dz == 0)) + elseif species == "alder" then + leaf = y >= 2 and dx+dz <= (y == h-1 and 1 or 3) + elseif species == "beech" then + leaf = y >= 3 and r <= (y == h-1 and 2 or 4) and dx+dz <= (y == h-1 and 3 or 6) + trunk = trunk or (y == 3 and dx+dz <= 3 and (dx == 0 or dz == 0)) + elseif species == "aspen" then + leaf = y >= 3 and r <= (y == h-1 and 1 or 2) and dx+dz <= 2 + elseif species == "rowan" then + leaf = y >= 2 and dx+dz <= (y == h-1 and 1 or 3) and not (dx == dz and r == 2) + trunk = trunk or (y == 2 and dx+dz == 1) + elseif species == "hazel" then + leaf = y >= 1 and dx+dz <= (y == h-1 and 2 or 4) + trunk = trunk or (y <= 2 and dx+dz == 1) + elseif species == "spruce" then + local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1 + leaf = r <= radius and dx+dz <= radius+1 + end + local stem = canonical and "log" or species .. "_log" + local leaves = canonical and "leaves" or species .. "_leaves" + data[#data+1] = {name=trunk and node(stem) or (leaf and node(leaves) or "air"), + prob=(trunk or leaf) and 255 or 0, param2=0} + end end end + return {size={x=w, y=h, z=w}, data=data} + end + -- Heat/humidity are Voronoi centers, not hard climate bounds. Altitude bands + -- are hard bounds; all climate points converge on one permanent snow biome. + M.biome_profiles = { + {name="hearth_temperate", top="grass", filler="soil", rock="limestone", heat=52, humidity=45, min=2, max=110}, + {name="hearth_oak_woodland", top="forest_floor", filler="soil", rock="limestone", heat=65, humidity=62, min=3, max=110}, + {name="hearth_birch_forest", top="forest_floor", filler="soil", rock="granite", heat=36, humidity=58, min=6, max=150}, + {name="hearth_pine_taiga", top="podzol", filler="soil", rock="granite", heat=18, humidity=38, min=2, max=180}, + {name="hearth_wet_forest", top="peat", filler="alluvium", rock="slate", heat=42, humidity=90, min=2, max=65}, + {name="hearth_floodplain", top="alluvium", filler="alluvium", rock="sandstone", heat=68, humidity=90, min=2, max=8}, + {name="hearth_dry_scrub", top="grass", filler="soil", rock="sandstone", heat=83, humidity=18, min=2, max=135}, + {name="hearth_alpine_meadow", top="alpine_turf", filler="scree", rock="slate", heat=30, humidity=48, min=111, max=210}, + {name="hearth_rocky_peaks", top="snow", filler="scree", rock="granite", heat=25, humidity=45, min=211, max=31000}, + {name="hearth_riverbank", top="sand", filler="gravel", rock="sandstone", heat=50, humidity=50, min=-31000, max=1}, + } + local registered = false + function M.register() + if registered then return M end + -- Fail closed on an integration collision instead of silently overriding + -- another owner's node definitions or publishing partly compatible art. + local function add(name, def) + assert(not (api.registered_nodes or {})[node(name)], "duplicate terrain node: " .. name) + def.tiles = def.tiles or {tile(name)} + register_node(name, def) + end + for _, rock in ipairs(M.rocks) do + add(rock, {description=rock:gsub("^%l", string.upper) .. " bedrock", + groups={cracky=3, stone=1, hearth_geology=1}}) + add(rock .. "_bricks", {description=rock:gsub("^%l", string.upper) .. " masonry", + -- Brick uses a derived world texture, not an inventory silhouette. + groups={cracky=3, stone=1, hearth_building=1}, + on_place=construction.place_building, after_place_node=construction.owner}) + api.register_craft({type="shapeless", output=node(rock .. "_bricks") .. " 4", recipe={node(rock)}}) + -- A pair preserves mass without shadowing the single-rock brick recipe. + api.register_craft({type="shapeless", output=node("stone") .. " 2", recipe={node(rock), node(rock)}}) + end + for _, soil in ipairs({"forest_floor", "podzol", "alluvium", "peat", "alpine_turf"}) do + add(soil, {description=soil:gsub("_", " "):gsub("^%l", string.upper), + tiles={tile(soil), tile("soil"), tile("soil")}, + groups={crumbly=3, soil=1, hearth_habitat=1}, drop=node("soil")}) + end + for _, loose in ipairs({"gravel", "scree", "snow"}) do + add(loose, {description=loose:gsub("^%l", string.upper), + groups={crumbly=3, falling_node=loose == "snow" and nil or 1}, + drop=loose == "snow" and node("snow") or node("loose_stone")}) + end + for _, species in ipairs(M.species_order) do + local title = species:gsub("^%l", string.upper) + add(species .. "_log", {description=title .. " log (requires an axe)", paramtype2="facedir", + tiles={tile(species .. "_log_top"), tile(species .. "_log_top"), tile(species .. "_log_side")}, + groups={tree=1, choppy=2, flammable=2, hearth_canopy=1}}) + add(species .. "_leaves", {description=title .. " leaves", drawtype="allfaces_optional", + paramtype="light", sunlight_propagates=true, use_texture_alpha="clip", waving=1, + groups={snappy=3, leaves=1, flammable=2, hearth_canopy=1}, + -- Species recruitment/seed nodes belong to the ecosystem module. + drop=node("sticks")}) + add(species .. "_planks", {description=title .. " planks", + groups={choppy=3, wood=1, flammable=2}}) + api.register_craft({type="shapeless", output=node(species .. "_planks") .. " 4", recipe={node(species .. "_log")}}) + -- Separate slots, not a counted stack: one log must still split to planks. + api.register_craft({type="shapeless", output=node("log") .. " 2", recipe={node(species .. "_log"), node(species .. "_log")}}) + api.register_craft({type="shapeless", output=node("planks"), recipe={node(species .. "_planks")}}) + end + for _, resource in ipairs({{"quartz", "quartz_vein", "Quartz crystal"}, + {"copper_ore", "copper_ore_vein", "Copper ore"}, {"coal", "coal_ore_vein", "Bituminous coal"}}) do + assert(not (api.registered_items or {})[node(resource[1])], "duplicate resource item") + api.register_craftitem(node(resource[1]), {description=resource[3], + inventory_image="hearth_item_" .. resource[1] .. ".png"}) + add(resource[2], {description=resource[3] .. " seam (requires a pick)", + groups={cracky=3, stone=1}, drop=node(resource[1]) .. " 2"}) + end + -- Non-forcing source aliases only. Existing game/mapgen bindings win. + for _, alias in ipairs({{"mapgen_stone", "stone"}, {"mapgen_water_source", "water_source"}, + {"mapgen_river_water_source", "water_source"}}) do + if not (api.registered_aliases or {})[alias[1]] and not (api.registered_items or {})[alias[1]] then + api.register_alias(alias[1], node(alias[2])) + end + end + for _, p in ipairs(M.biome_profiles) do + api.register_biome({name=p.name, node_top=node(p.top), depth_top=1, + node_filler=node(p.filler), depth_filler=p.name == "hearth_rocky_peaks" and 1 or 3, + node_stone=node(p.rock), node_water=node("water_source"), + node_river_water=node("water_source"), node_riverbed=node("gravel"), depth_riverbed=2, + y_min=p.min, y_max=p.max, heat_point=p.heat, humidity_point=p.humidity, + vertical_blend=0}) + end + -- Ordered geological replacement precedes ores: coherent deep intrusions, + -- then nested ore clusters restricted to their actual host lithologies. + -- v7 reselects biomes at lower altitude limits. Below the riverbank band + -- the initial host is sandstone, NOT the surface woodland's node_stone. + -- Explicit beds keep all deep resources reachable in that real engine case. + for _, bed in ipairs({{"granite", -31000, -80}, {"slate", -79, -24}, {"limestone", -23, -8}}) do + api.register_ore({name="hearth:" .. bed[1] .. "_bed", ore_type="stratum", ore=node(bed[1]), + -- A zero cluster size is valid for strata and avoids Ore::placeOre + -- skipping a one-node slice where a bed meets a mapchunk boundary. + wherein=names({"sandstone", "stone"}), y_min=bed[2], y_max=bed[3], clust_size=0, + clust_scarcity=1}) + end + api.register_ore({name="hearth:basalt_intrusions", ore_type="blob", ore=node("basalt"), + wherein=names({"granite", "stone", "slate", "limestone", "sandstone"}), clust_scarcity=48*48*48, + clust_size=24, y_min=-31000, y_max=-24, noise_threshold=0.15, + noise_params={offset=0, scale=1, spread={x=96,y=64,z=96}, seed=7131, octaves=3, persistence=0.6}}) + local function ore(name, hosts, distance, count, size, ymin, ymax) + api.register_ore({name="hearth:" .. name .. "_" .. tostring(ymax), ore_type="scatter", + ore=node(name), wherein=names(hosts), clust_scarcity=distance^3, + clust_num_ores=count, clust_size=size, y_min=ymin, y_max=ymax}) + end + ore("quartz_vein", {"granite", "slate"}, 11, 6, 3, -31000, 160) + ore("copper_ore_vein", {"basalt", "limestone", "slate"}, 13, 5, 3, -31000, 32) + ore("coal_ore_vein", {"sandstone", "slate", "limestone"}, 10, 9, 3, -31000, 70) + ore("iron_ore_vein", {"basalt", "granite", "limestone", "stone"}, 11, 6, 3, -31000, -8) + ore("iron_ore_vein", {"basalt", "granite", "limestone", "stone"}, 19, 3, 2, -7, 85) + local surfaces = {} + for name in pairs(M.surfaces) do surfaces[#surfaces+1] = node(name) end + table.sort(surfaces) + local function deco(id, plant, ground, ratio, ymin, ymax, biomes, extra) + local def = {name="hearth:" .. id, deco_type="simple", place_on=ground, + sidelen=16, fill_ratio=ratio, y_min=ymin, y_max=ymax, biomes=biomes, + decoration=node(plant)} + for k, v in pairs(extra or {}) do def[k] = v end + api.register_decoration(def) + end + local wet = {"hearth_wet_forest", "hearth_floodplain", "hearth_riverbank"} + local woods = {"hearth_oak_woodland", "hearth_birch_forest", "hearth_pine_taiga", "hearth_wet_forest"} + local meadow = {"hearth_temperate", "hearth_dry_scrub", "hearth_alpine_meadow"} + local near_water = {spawn_by=names({"water_source", "water_flowing"}), num_spawn_by=1, check_offset=-1} + -- Establish real canopies before ground flora can occupy the trunk cell. + for _, species in ipairs(M.species_order) do + local p = M.profiles[species] + local def = {name="hearth:" .. species .. "_trees", deco_type="schematic", place_on=names(p.soils), + sidelen=16, fill_ratio=species == "willow" and 0.004 or 0.012, + y_min=p.y_min, y_max=p.y_max, biomes=p.biomes, schematic=M.tree_schematic(species), + rotation="random", flags="place_center_x,place_center_z", place_offset_y=1} + if p.riparian then + def.spawn_by, def.num_spawn_by, def.check_offset = near_water.spawn_by, 1, -1 + end + api.register_decoration(def) + end + -- Canonical timber remains discoverable by legacy starting-resource probes. + api.register_decoration({name="hearth:temperate_trees", deco_type="schematic", + place_on=names({"grass"}), sidelen=16, fill_ratio=0.012, + y_min=3, y_max=110, biomes={"hearth_temperate"}, schematic=M.tree_schematic("oak", true), + rotation="random", flags="place_center_x,place_center_z", place_offset_y=1}) + -- Sparse guaranteed-compatible gathering substrate, not guaranteed spawns. + -- No soil type can strand progression merely because grass is absent. + deco("loose_stone", "loose_stone", surfaces, 0.035, 1, 31000) + deco("sticks", "sticks", surfaces, 0.03, 1, 210) + deco("berry_bush", "berry_bush", names({"grass", "forest_floor", "podzol", "alluvium"}), 0.018, 2, 130) + deco("grain_patch", "grain_patch", names({"grass", "alluvium"}), 0.009, 2, 100, + {"hearth_temperate", "hearth_floodplain"}) + -- Springs precede water-dependent plants: decorations see earlier writes, + -- so upland rushes can establish at the newly generated seep edges. + deco("seep_springs", "water_source", names({"grass", "forest_floor", "podzol"}), 0.0015, 3, 150, + {"hearth_temperate", "hearth_oak_woodland", "hearth_birch_forest", "hearth_pine_taiga"}, + {place_offset_y=-1, flags="force_placement"}) + deco("reeds", "reeds", names({"sand", "gravel", "alluvium", "peat", "grass", "forest_floor", "podzol"}), 0.09, 1, 150, nil, near_water) + deco("clay_bank", "clay_bank", names({"sand", "gravel", "alluvium"}), 0.04, -2, 8, wet, near_water) + deco("meadow_grasses", "meadow_grass", names({"grass", "alpine_turf"}), 0.15, 2, 210, meadow) + deco("meadow_flowers", "wildflowers", names({"grass", "alpine_turf"}), 0.025, 2, 210, meadow) + deco("woodland_ferns", "fern", names({"forest_floor", "peat", "alluvium"}), 0.06, 2, 145, woods) + deco("woodland_litter", "litter", names({"forest_floor", "podzol", "peat"}), 0.08, 2, 180, woods) + deco("woodland_fungi", "fungi", names({"forest_floor", "peat"}), 0.012, 2, 130, woods) + -- Three ordinary log cubes on their sides; top grain faces +X (12/4=3). + -- Random schematic rotation also permits Z-aligned logs. Never force-place. + api.register_decoration({name="hearth:woodland_deadwood", deco_type="schematic", + place_on=names({"forest_floor", "podzol", "peat"}), sidelen=16, fill_ratio=0.007, + y_min=2, y_max=160, biomes=woods, rotation="random", place_offset_y=1, + flags="place_center_x,place_center_z", schematic={size={x=3,y=1,z=1},data={ + {name=node("deadwood"),prob=255,param2=12}, {name=node("deadwood"),prob=255,param2=12}, + {name=node("deadwood"),prob=255,param2=12}}}}) + deco("spring_foraging", "spring_greens_patch", names({"grass", "alluvium"}), 0.009, 2, 100, + {"hearth_temperate", "hearth_floodplain"}) + deco("autumn_foraging", "autumn_mushrooms_patch", names({"forest_floor", "podzol"}), 0.012, 2, 145, woods) + -- Retain the old landmark contract without force-writing banks/water. + local weir = {size={x=7,y=3,z=5}, data={}} + for z=0,4 do for y=0,2 do for x=0,6 do + local edge = z == 0 or z == 4 or x == 0 or x == 6 + local wall = edge and (y == 0 or ((z == 0 or z == 4) and y == 1) + or ((x == 0 or x == 6) and z >= 1 and z <= 3)) + weir.data[#weir.data+1] = {name=wall and node("weir_stone") or "air", prob=wall and 255 or 0} + end end end + api.register_decoration({name="hearth:old_river_weir", deco_type="schematic", + place_on=names({"sand", "gravel", "alluvium"}), sidelen=16, fill_ratio=0.001, + y_min=0, y_max=8, biomes=wet, schematic=weir, rotation="random", + flags="place_center_x,place_center_z", place_offset_y=1, + spawn_by=near_water.spawn_by, num_spawn_by=1, check_offset=-1}) + registered = true + return M + end + return M +end diff --git a/mods/hearth/botany.lua b/mods/hearth/botany.lua new file mode 100755 index 0000000..abc4e22 --- /dev/null +++ b/mods/hearth/botany.lua @@ -0,0 +1,359 @@ +-- Temperate NW-European catalogue and read-only niche policy. No scheduler/map writes. +-- Registration uses the content registrar's BARE-name convention. +return function(hearth, api) + local B = {groups={"trees","shrubs","grasses","herbs","ferns_mosses","fungi","aquatic","lichens"}} + local function copy(t) + if type(t)~="table" then return t end + local r={}; for k,v in pairs(t) do r[k]=copy(v) end; return r + end + local function set(text) + local r={}; for s in text:gmatch("[^,]+") do r[s]=true end; return r + end + local records, index, seasonal_nodes = {}, {}, {} + local soil="grass,soil,forest_floor,alluvium" + local acid="grass,soil,forest_floor,podzol,peat,alpine_turf" + local wet="alluvium,peat,soil,grass" + local rock="stone,granite,basalt,limestone,slate,sandstone,scree" + local year="Spring,Summer,Autumn,Winter" + local grow="Spring,Summer,Autumn" + local profiles={ + wood={soil,4,15,.3,.85,4.5,7.5}, + acid={acid,8,15,.2,.7,3.5,6.5}, + wetwood={wet,6,15,.65,1,4,7.5}, + shrub={soil,6,15,.3,.8,4.5,7.5}, + meadow={"grass,soil,alluvium",9,15,.3,.8,5,7.5}, + bank={wet,7,15,.75,1,4.5,8}, + shade={"forest_floor,soil,alluvium",3,11,.45,.9,4.5,7.5}, + moss={acid,2,10,.6,1,3.5,7}, + litter={"forest_floor,litter",2,12,.45,.85,4,7.5}, + deadwood={"deadwood",2,13,.4,.95,3.5,7.5}, + water={"alluvium,soil,peat,sand",7,15,.8,1,5,8.5}, + bark={"bark",5,13,.4,.9,4,7}, + lichenrock={rock,9,15,.1,.6,3.5,7.5}, + } + -- Each row owns exactly one group. Interactions describe ecology, not edibility. + local group + local function add(id,common,latin,profile,seasons,tags,extra) + local p=assert(profiles[profile]) + local r={id=id,common=common,latin=latin,group=group,seasons=set(seasons),tags=set(tags), + niche={substrates=set(p[1]),light={p[2],p[3]},moisture={p[4],p[5]},ph={p[6],p[7]}, + aquatic="land",water={0,0}},toxic=false,food=false,existing=false} + for k,v in pairs(extra or {}) do r[k]=v end + r.node="hearth:"..id..(group=="trees" and "_seedling" or "") + r.texture="hearth_botany_"..id..".png" + records[#records+1]=r; assert(not index[id],id); index[id]=r + end + group="trees" + add("oak","Pedunculate oak","Quercus robur","wood",grow,"browse,ectomycorrhiza,nursery",{existing=true}) + add("birch","Silver birch","Betula pendula","acid",grow,"browse,ectomycorrhiza,nursery",{existing=true}) + add("pine","Scots pine","Pinus sylvestris","acid",year,"ectomycorrhiza,nursery,resin",{existing=true}) + add("willow","White willow","Salix alba","wetwood",grow,"pollinator,browse,ectomycorrhiza,engineer",{existing=true}) + add("alder","Common alder","Alnus glutinosa","wetwood",grow,"nitrogenfixer,ectomycorrhiza,engineer",{existing=true}) + add("beech","European beech","Fagus sylvatica","wood",grow,"browse,ectomycorrhiza,nursery") + add("aspen","European aspen","Populus tremula","wood",grow,"browse,ectomycorrhiza,nursery") + add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery") + add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery") + add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin") + group="shrubs" + add("bilberry","Bilberry","Vaccinium myrtillus","acid",grow,"pollinator,browse,nursery",{food=true}) + add("blackberry","Pleated blackberry","Rubus plicatus","shrub",grow,"pollinator,browse,nursery",{food=true}) + add("raspberry","Red raspberry","Rubus idaeus","shrub",grow,"pollinator,browse,nursery",{food=true}) + add("elder","Black elder","Sambucus nigra","shrub",grow,"pollinator,browse,nursery",{toxic=true}) + add("blackthorn","Blackthorn","Prunus spinosa","shrub",grow,"pollinator,browse,nursery") + add("hawthorn","Common hawthorn","Crataegus monogyna","shrub",grow,"pollinator,browse,nursery") + add("dog_rose","Dog rose","Rosa canina","shrub",grow,"pollinator,browse,nursery") + add("holly","European holly","Ilex aquifolium","wood",year,"pollinator,browse,nursery",{toxic=true}) + add("juniper","Common juniper","Juniperus communis","acid",year,"browse,nursery,resin") + add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true}) + group="grasses" + add("sedge","Greater tussock-sedge","Carex paniculata","bank",grow,"browse,engineer,nursery",{existing=true}) + add("bulrush","Bulrush (great reedmace)","Typha latifolia","bank",grow,"engineer,nursery",{existing=true}) + add("meadow_fescue","Meadow fescue","Schedonorus pratensis","meadow",grow,"browse,engineer") + add("cocksfoot","Cock's-foot","Dactylis glomerata","meadow",grow,"browse,engineer") + add("timothy","Timothy","Phleum pratense","meadow",grow,"browse,engineer") + add("sweet_vernal_grass","Sweet vernal-grass","Anthoxanthum odoratum","acid",grow,"browse,engineer") + add("yorkshire_fog","Yorkshire-fog","Holcus lanatus","meadow",grow,"browse,engineer") + add("tufted_hairgrass","Tufted hair-grass","Deschampsia cespitosa","bank",grow,"browse,engineer,nursery") + add("common_reed","Common reed","Phragmites australis","bank",grow,"engineer,nursery") + add("soft_rush","Soft rush","Juncus effusus","bank",grow,"engineer,nursery") + group="herbs" + add("clover","White clover","Trifolium repens","meadow",grow,"nitrogenfixer,pollinator,browse",{existing=true}) + add("nettle","Common nettle","Urtica dioica","shade",grow,"browse,nursery",{existing=true,toxic=true}) + add("heather","Heather","Calluna vulgaris","acid",grow,"pollinator,browse,nursery",{existing=true}) + add("bluebell","English bluebell","Hyacinthoides non-scripta","shade","Spring","pollinator",{existing=true,toxic=true}) + add("yarrow","Yarrow","Achillea millefolium","meadow",grow,"pollinator") + add("foxglove","Common foxglove","Digitalis purpurea","acid",grow,"pollinator",{toxic=true}) + add("meadowsweet","Meadowsweet","Filipendula ulmaria","bank",grow,"pollinator,nursery") + add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator") + add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator") + add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true}) + group="ferns_mosses" + add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true}) + add("lady_fern","Lady-fern","Athyrium filix-femina","shade",grow,"nursery") + add("male_fern","Male-fern","Dryopteris filix-mas","shade",grow,"nursery",{toxic=true}) + add("harts_tongue","Hart's-tongue fern","Asplenium scolopendrium","shade",year,"nursery") + add("polypody","Common polypody","Polypodium vulgare","shade",year,"nursery") + add("sphagnum","Blunt-leaved bog-moss","Sphagnum palustre","moss",year,"engineer,nursery") + add("haircap_moss","Common haircap","Polytrichum commune","moss",year,"engineer,nursery") + add("moss","Cypress-leaved plait-moss","Hypnum cupressiforme","moss",year,"engineer,nursery",{existing=true}) + add("fork_moss","Broom fork-moss","Dicranum scoparium","moss",year,"nursery") + add("star_moss","Swan's-neck thyme-moss","Mnium hornum","moss",year,"nursery") + group="fungi" + add("fly_agaric","Fly agaric","Amanita muscaria","litter","Summer,Autumn","ectomycorrhiza",{toxic=true}) + add("birch_bolete","Brown birch bolete","Leccinum scabrum","litter","Summer,Autumn","ectomycorrhiza") + add("bay_bolete","Bay bolete","Imleria badia","litter","Summer,Autumn","ectomycorrhiza") + add("chanterelle","Chanterelle","Cantharellus cibarius","litter","Summer,Autumn","ectomycorrhiza") + add("wood_blewit","Wood blewit","Collybia nuda","litter","Autumn,Winter","saprotroph") + add("shaggy_inkcap","Shaggy inkcap","Coprinus comatus","meadow","Summer,Autumn","saprotroph") + add("turkey_tail","Turkey tail","Trametes versicolor","deadwood",year,"saprotroph") + add("oyster_fungus","Oyster mushroom","Pleurotus ostreatus","deadwood","Autumn,Winter","saprotroph") + add("honey_fungus","Honey fungus","Armillaria mellea","deadwood","Autumn","saprotroph,parasite",{toxic=true}) + add("birch_polypore","Birch polypore","Fomitopsis betulina","deadwood",year,"saprotroph,parasite") + group="aquatic" + add("duckweed","Common duckweed","Lemna minor","water",grow,"nursery,engineer") + add("yellow_waterlily","Yellow water-lily","Nuphar lutea","water",grow,"pollinator,nursery",{toxic=true}) + add("white_waterlily","White water-lily","Nymphaea alba","water",grow,"pollinator,nursery") + add("water_crowfoot","Common water-crowfoot","Ranunculus aquatilis","water",grow,"pollinator,nursery",{toxic=true}) + add("water_mint","Water mint","Mentha aquatica","bank",grow,"pollinator,nursery") + add("water_forgetmenot","Water forget-me-not","Myosotis scorpioides","bank",grow,"pollinator,nursery") + add("brooklime","Brooklime","Veronica beccabunga","bank",grow,"pollinator,nursery") + add("water_starwort","Common water-starwort","Callitriche stagnalis","water",grow,"nursery") + add("hornwort","Rigid hornwort","Ceratophyllum demersum","water",grow,"nursery,engineer") + add("pondweed","Broad-leaved pondweed","Potamogeton natans","water",grow,"nursery") + group="lichens" + add("reindeer_lichen","Reindeer lichen","Cladonia rangiferina","acid",year,"browse,nursery") + add("oakmoss","Oakmoss","Evernia prunastri","bark",year,"nursery") + add("old_mans_beard","Old man's beard","Usnea subfloridana","bark",year,"nursery") + add("lichen","Map lichen","Rhizocarpon geographicum","lichenrock",year,"engineer",{existing=true}) + add("dog_lichen","Dog lichen","Peltigera canina","moss",year,"nitrogenfixer,engineer") + add("lungwort_lichen","Lungwort lichen","Lobaria pulmonaria","bark",year,"nitrogenfixer,nursery") + add("sunburst_lichen","Common orange lichen","Xanthoria parietina","bark",year,"engineer") + add("hammered_shield","Hammered shield lichen","Parmelia sulcata","bark",year,"nursery") + add("cup_lichen","Mealy pixie-cup","Cladonia chlorophaea","acid",year,"engineer") + add("pixie_cup","Pebbled pixie-cup","Cladonia pyxidata","acid",year,"engineer") + -- Species-level refinements; ranges are game niche windows, not field measurements. + local function niche(id,changes) for k,v in pairs(changes) do index[id].niche[k]=v end end + niche("beech",{moisture={.3,.7},ph={5,8}}) + niche("rowan",{light={5,15},moisture={.3,.85}}) + niche("spruce",{moisture={.4,.85},light={4,15}}) + niche("hazel",{ph={5,8}}) + niche("juniper",{ph={4,8},moisture={.15,.6}}) + niche("nettle",{light={5,15},nutrients=1}) + niche("cowslip",{ph={6,8},moisture={.2,.65}}) + niche("harts_tongue",{ph={6,8}}) + niche("polypody",{substrates=set("forest_floor,soil,bark,stone,granite,slate"),ph={4,7}}) + niche("sphagnum",{substrates=set("peat,alluvium"),moisture={.85,1},ph={3.5,6}}) + niche("haircap_moss",{light={4,15},ph={3.5,6}}) + niche("moss",{substrates=set(acid..",bark,deadwood,"..rock)}) + niche("dog_lichen",{light={5,13},moisture={.4,.85},ph={5,8}}) + niche("lungwort_lichen",{light={3,10},moisture={.65,1},ph={5,7.5}}) + niche("sunburst_lichen",{substrates=set("bark,limestone,stone"),light={9,15},moisture={.2,.75},ph={5.5,8.5}}) + niche("lichen",{substrates=set("stone,granite,slate,sandstone,scree"),ph={3.5,6.5}}) + local hosts={fly_agaric="birch,pine,spruce",birch_bolete="birch",bay_bolete="pine,spruce,beech,oak", + chanterelle="oak,beech,birch,pine,spruce",birch_polypore="birch", + turkey_tail="oak,birch,willow,alder,beech,aspen,rowan,hazel", + oyster_fungus="beech,willow,aspen,oak,birch",honey_fungus="oak,birch,willow,alder,beech,aspen,rowan,hazel,spruce"} + for id,text in pairs(hosts) do niche(id,{hosts=set(text)}) end + for _,id in ipairs({"fly_agaric","birch_bolete","bay_bolete","chanterelle"}) do + niche(id,{substrates=set("forest_floor,soil,podzol"),ph={3.5,6.5}}) + end + for _,id in ipairs({"duckweed","yellow_waterlily","white_waterlily","water_crowfoot","pondweed"}) do + niche(id,{aquatic="surface",water={1,id=="duckweed" and 6 or 3}}) + end + for _,id in ipairs({"water_starwort","hornwort"}) do niche(id,{aquatic="submerged",water={1,4}}) end + for _,id in ipairs({"water_mint","water_forgetmenot","brooklime","sedge","bulrush","common_reed","soft_rush","tufted_hairgrass"}) do + niche(id,{aquatic="bank",water={0,0}}) + end + index.hornwort.niche.substrates=set("water") -- rootless: never requires a mud anchor + local flowers={willow="Spring",rowan="Spring,Summer",bilberry="Spring,Summer",blackberry="Summer", + raspberry="Summer",elder="Summer",blackthorn="Spring",hawthorn="Spring",dog_rose="Summer", + holly="Spring,Summer",guelder_rose="Spring,Summer",clover="Spring,Summer",heather="Summer,Autumn", + bluebell="Spring",yarrow="Summer,Autumn",foxglove="Summer",meadowsweet="Summer", + wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer", + white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn", + water_forgetmenot="Spring,Summer",brooklime="Spring,Summer"} + for id,text in pairs(flowers) do index[id].flowering=set(text) end + for _,id in ipairs({"bilberry","blackberry","raspberry"}) do + index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer") + end + local descriptors={ + nitrogenfixer={description="Symbionts fix atmospheric nitrogen; bounded soil contribution, never an item source.",cap=2}, + browse={description="Wildlife forage or cover relation; not human food permission."}, + pollinator={description="Flower-season pollen/nectar resource; not a year-round aura."}, + ectomycorrhiza={description="Host-specific root exchange of existing mineral/water resources for tree carbon.",cap=1}, + saprotroph={description="Decomposes existing litter or deadwood; any transfer must consume that substrate."}, + parasite={description="Natural-history descriptor only; never damages player plants.",enabled=false}, + engineer={description="Roots or mats retain substrate/water, or aquatic cover changes light; bounded local effect."}, + nursery={description="Shelter/microhabitat for wildlife or recruits, conditional on appropriate habitat."}, + allelopathy={description="Bracken chemical competition is context-dependent; descriptor only, no killing.",enabled=false}, + resin={description="Resin-bearing conifer; resource extraction belongs to tree/resource policy."}, + } + local function finite(n) return type(n)=="number" and n==n and math.abs(n)=range[1] and n<=range[2] end + function B.suitable(value,h,season) + local r=record(value) + if not r or type(h)~="table" or h.loaded~=true or not B.active(value,season) then return false end + local n=r.niche + if not within(h.light,n.light) or not within(h.moisture,n.moisture) or not within(h.ph,n.ph) then return false end + if n.nutrients and (not finite(h.nutrients) or h.nutrients30990 or p[axis]%1~=0 then return nil end end + local start=math.abs(p.x*17+p.y*7+p.z*31)%#records + for i=1,#records do + local r=records[(start+i-1)%#records+1] + if (r.group~="trees" or h.include_trees==true) and B.suitable(r.id,h,season) then + return r.node:sub(8),copy(r) + end + end + end + local DAY=86400 + local function now() + local n + if api.now then n=api.now() else n=os.time() end + if not finite(n) or n<0 or n>9007199254740991-DAY then return nil end + return math.floor(n) + end + function B.harvest(pos,player) + if type(pos)~="table" then return false,"invalid" end + for _,a in ipairs({"x","y","z"}) do if not finite(pos[a]) or math.abs(pos[a])>30990 or pos[a]%1~=0 then return false,"invalid" end end + local construction=hearth.construction -- late lookup: content constructs this later + if not player or not player.is_player or not player:is_player() or not construction + or not construction.allowed or not construction.allowed(pos,player,"use") then return false,"denied" end + local node=api.get_node_or_nil(pos) + local r=node and record(node) + -- Canonicalization alone is not authority: accept only the base or an + -- exact seasonal name installed above, never arbitrary suffix aliases. + if not r or not r.food or (node.name~=r.node and seasonal_nodes[node.name]~=r.id) then return false,"species" end + local time=now(); if not time then return false,"clock" end + local climate=hearth.climate_at and hearth.climate_at(time) + if not climate or not B.active(r.id,climate.season) or not B.edible(r.id,climate.season) then return false,"season" end + local meta=api.get_meta(pos) + local stored=meta:get_string("botany_ready_at") + local ready=0 + if stored~="" then ready=tonumber(stored) end + if not finite(ready) or ready<0 then return false,"metadata" end + if ready>time then return false,"cooldown" end + local inv=player:get_inventory() + if not inv or not inv:room_for_item("main","hearth:berries 2") then return false,"inventory" end + -- Lock before inventory callbacks: reentrant harvest cannot duplicate fruit. + meta:set_string("botany_ready_at",tostring(time+DAY)) + local leftover=inv:add_item("main","hearth:berries 2") + local count=leftover and leftover:get_count() + if not finite(count) or count<0 or count>2 then return false,"inventory" end + if count==2 then meta:set_string("botany_ready_at",stored); return false,"inventory" end + return true,2-count + end + local registered=false + function B.register_nodes(register_node) + if registered then return end + assert(type(register_node)=="function","botany needs a bare-name node registrar") + for _,r in ipairs(records) do + if r.group~="trees" and not r.existing and not (api.registered_nodes or {})[r.node] then + local id=r.id + local def={description=r.common.." ("..r.latin..")",drawtype="plantlike",tiles={r.texture}, + inventory_image=r.texture,wield_image=r.texture,paramtype="light",sunlight_propagates=true, + use_texture_alpha="clip",walkable=false,buildable_to=false, + groups={snappy=3,flora=1,hearth_ecosystem=1},drop="hearth:"..id, + after_place_node=function(pos) api.get_meta(pos):set_string("player_placed","1") end} + -- A dug bush cannot reset its fruit cooldown by becoming a fresh item. + if r.group=="shrubs" then def.drop="hearth:sticks" end + if r.niche.aquatic=="surface" then + def.drawtype="nodebox"; def.node_box={type="fixed",fixed={-.45,-.49,-.45,.45,-.44,.45}} + def.selection_box=copy(def.node_box) + elseif r.niche.aquatic=="submerged" then + -- A sprite is NOT water: never give it liquid groups/source behavior. + -- External placement must retain a separate real water column. + def.groups.hearth_submerged=1 + end + if r.niche.aquatic=="surface" or r.niche.aquatic=="submerged" then + -- Default item_place would replace buildable-to water. Safe external + -- ecology placement must reserve a distinct air cell instead. + def.on_place=function(stack) return stack end + end + if r.food then def.on_rightclick=function(pos,_,player,stack) B.harvest(pos,player); return stack end end + register_node(id,def) + end + end + registered=true + end + B.catalogue=B.list(); B.group_order=copy(B.groups) + return B +end diff --git a/mods/hearth/character.lua b/mods/hearth/character.lua new file mode 100755 index 0000000..c8afc0c --- /dev/null +++ b/mods/hearth/character.lua @@ -0,0 +1,227 @@ +-- Cosmetic-only factory. No global access, world storage, identity or physics. +return function(hearth, api) + local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"} + local key = "hearth:appearance" + local options = { + body = {"compact", "balanced", "broad"}, + skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"}, + face = {"calm", "angular", "soft", "weathered"}, + eyes = {"hazel", "brown", "green", "blue", "grey"}, + hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"}, + hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"}, + beard = {"none", "stubble", "moustache", "short_beard", "full_beard"}, + garment = {"tunic", "workshirt", "jerkin", "coat"}, + cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, + trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"}, + accent = {"none", "scarf", "apron", "sash", "vest"}, + accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, + detail = {"none", "freckles", "scar", "freckles_scar"}, + } + local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment", + "cloth", "trousers", "boots", "accent", "accentColor", "detail"} + local defaults = {version = 1, body = "balanced", skin = "sand", face = "calm", eyes = "hazel", + hair = "sidepart", hairColor = "chestnut", beard = "none", garment = "tunic", cloth = "teal", + trousers = "earth", boots = "walnut", accent = "scarf", accentColor = "sage", detail = "none"} + local function copy(t) + local out = {}; for k, v in pairs(t) do out[k] = v end; return out + end + function C.defaults() return copy(defaults) end + function C.options() + local out = {}; for k, list in pairs(options) do out[k] = copy(list) end; return out + end + local sets = {} + for k, list in pairs(options) do + sets[k] = {}; for _, id in ipairs(list) do sets[k][id] = true end + end + function C.validate(value) + if type(value) ~= "table" or getmetatable(value) ~= nil or value.version ~= 1 then return nil end + for k in pairs(value) do if k ~= "version" and not options[k] then return nil end end + local out = {version = 1} + for _, k in ipairs(order) do + local id = value[k] + if type(id) ~= "string" or #id > 24 or not sets[k][id] then return nil end + out[k] = id + end + return out + end + -- Reject nesting/arrays before invoking the engine parser (bounded stack as + -- well as bytes). The parser, not this scanner, validates JSON grammar. + local function flat_json(raw) + if type(raw) ~= "string" or #raw == 0 or #raw > C.MAX_JSON then return false end + local quoted, escaped, depth = false, false, 0 + for i = 1, #raw do + local c = raw:sub(i,i) + if quoted then + if escaped then escaped = false + elseif c == "\\" then escaped = true + elseif c == '"' then quoted = false end + elseif c == '"' then quoted = true + elseif c == "[" or c == "]" then return false + elseif c == "{" then depth = depth + 1; if depth > 1 then return false end + elseif c == "}" then depth = depth - 1; if depth < 0 then return false end end + end + return not quoted and depth == 0 + end + function C.decode(raw) + if not flat_json(raw) then return nil end + local ok, value = pcall(api.parse_json, raw, nil, true) + return ok and C.validate(value) or nil + end + function C.encode(value) + local valid = C.validate(value) + if not valid then return nil end + local ok, raw = pcall(api.write_json, valid) + if not ok or type(raw) ~= "string" or #raw > C.MAX_JSON then return nil end + local roundtrip = C.decode(raw) + if not roundtrip then return nil end + for k, v in pairs(valid) do if roundtrip[k] ~= v then return nil end end + return raw + end + function C.preview(value) + local p = C.validate(value) + if not p then return nil end + local function name(stem) return "hearth_char_" .. stem .. ".png" end + local texture = table.concat({name("skin_" .. p.skin), name("face_" .. p.face), name("eyes_" .. p.eyes), + name("garment_" .. p.garment .. "_" .. p.cloth), name("trousers_" .. p.trousers), name("boots_" .. p.boots), + name("accent_" .. p.accent .. "_" .. p.accentColor), name("hair_" .. p.hair .. "_" .. p.hairColor), + name("beard_" .. p.beard .. "_" .. p.hairColor), name("detail_" .. p.detail)}, "^") + return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture + end + local function publish(player, value) + local mesh, texture = C.preview(value) + player:set_properties({mesh = mesh, textures = {texture}}) + end + local function session(player) + if not player then return nil end + local name = player:get_player_name() + if api.get_player_by_name(name) ~= player then return nil end + return hearth.sessions[name] + end + local corrupt = "Saved appearance is unreadable. Showing defaults; original data is retained until you Apply." + local function load(player) + local ok, raw = pcall(function() return player:get_meta():get_string(key) end) + if ok and raw == "" then return C.defaults() end + local valid = ok and C.decode(raw) + if valid then return valid end + return C.defaults(), corrupt + end + function C.apply(player) + local s = session(player); if not s then return false end + local value, warning = load(player) + s.character = {player = player, saved = value, warning = warning} + publish(player, value) + if warning and hearth.notify then hearth.notify(player, warning) end + return true + end + C.on_join = C.apply + local tabs = {{"Face", "body", "skin", "face", "eyes"}, {"Hair", "hair", "hairColor", "beard"}, + {"Clothing", "garment", "cloth", "trousers", "boots"}, {"Finishing", "accent", "accentColor", "detail"}} + local labels = {body = "Body shape (equal mechanics)", skin = "Skin tone", face = "Face lines", eyes = "Eyes", + hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment", + cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent", + accentColor = "Accent color", detail = "Face details"} + local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar"} + local function title(id) + return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper)) + end + local function state(player) + local s = session(player) + local st = s and s.character + return st and st.player == player and st or nil + end + function C.state(player) + local st = state(player) + return st and {saved = copy(st.saved), draft = st.draft and copy(st.draft), warning = st.warning, + tab = st.tab, yaw = st.yaw} or nil + end + local function render(player, st) + local esc = api.formspec_escape + local mesh, texture = C.preview(st.draft) + local f = {"formspec_version[6]size[14,11]bgcolor[#1e1e2e;true]", + "style_type[label,textarea;textcolor=#cdd6f4]style_type[button;bgcolor=#292940;textcolor=#cdd6f4]", + "label[0.6,0.5;THE LAST HEARTH / WOODLAND TRAVELER]", + "label[0.6,1;Quiet cloth, worn leather, a face of your own.]", + "box[0.5,1.5;5.4,7.6;#181825]", + -- lua_api.md: camera x,y; continuous rotation; mouse control; frames; FPS. + "model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture) + .. ";0," .. st.yaw .. ";true;true;0,19;12]", + "button[0.7,8.25;1.55,0.6;front;Front]button[2.4,8.25;1.55,0.6;profile;Profile]button[4.1,8.25;1.55,0.6;back;Back]", + "tooltip[traveler;Drag to turn the preview. Only this model rotates.]"} + for i, tab in ipairs(tabs) do + f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]" + end + for i = 2, #tabs[st.tab] do + local k, y = tabs[st.tab][i], 2.8 + (i-2)*1.45 + local choices, selected = {}, 1 + for j, id in ipairs(options[k]) do + choices[j] = esc(title(id)); if id == st.draft[k] then selected = j end + end + f[#f+1] = "label[6.3," .. y .. ";" .. esc(labels[k]) .. "]dropdown[6.3," .. (y+0.3) + .. ";7,0.7;pick_" .. k .. ";" .. table.concat(choices,",") .. ";" .. selected .. ";true]" + end + f[#f+1] = "textarea[6.2,8.7;7.2,0.8;;;" .. esc(st.warning or "Preview only. Nothing changes until Save / Apply.") .. "]" + f[#f+1] = "button[0.7,9.85;2.6,0.65;randomize;Randomize]button[3.5,9.85;2.6,0.65;reset;Reset draft]" + .. "button[7.1,9.85;2.7,0.65;cancel;Cancel]button[10,9.85;3.3,0.65;apply;Save / Apply]" + api.show_formspec(player:get_player_name(), C.FORM, table.concat(f)) + end + function C.show(player) + if not session(player) then return false end + local st = state(player) + if not st then C.apply(player); st = state(player) end + st.draft, st.tab, st.yaw = copy(st.saved), 1, 180 + render(player, st); return true + end + local controls = {quit = true, cancel = true, apply = true, reset = true, randomize = true, + front = true, profile = true, back = true, tab_1 = true, tab_2 = true, tab_3 = true, tab_4 = true} + function C.handle(player, fields) + local st = state(player) + if not st or not st.draft or type(fields) ~= "table" then return false end + -- No target name/ID is ever accepted. The engine-supplied PlayerRef owns + -- this draft, tied to the current session and connected object identity. + local visible, draft = {}, copy(st.draft) + for i = 2, #tabs[st.tab] do visible["pick_" .. tabs[st.tab][i]] = tabs[st.tab][i] end + local count = 0 + for k, v in pairs(fields) do + count = count + 1; if count > 20 then return false end + if visible[k] then + local n = type(v) == "string" and #v <= 1 and v:match("^[1-9]$") and tonumber(v) + if not n or not options[visible[k]][n] then return false end + draft[visible[k]] = options[visible[k]][n] + elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean") + or (type(v) == "string" and #v > 32) then return false end + end + if fields.quit or fields.cancel then + st.draft = nil; api.close_formspec(player:get_player_name(), C.FORM); return true + end + st.draft = draft + if fields.apply then + local raw = C.encode(draft) + local ok, result = false, false + if raw then ok, result = pcall(function() return player:get_meta():set_string(key, raw) end) end + if not ok or result == false then + st.warning = "Appearance could not be saved. Your previous appearance is unchanged; try again." + render(player, st); return true + end + st.saved, st.draft, st.warning = copy(draft), nil, nil + publish(player, draft) + api.close_formspec(player:get_player_name(), C.FORM); return true + elseif fields.reset then st.draft = C.defaults() + elseif fields.randomize then + for _, k in ipairs(order) do st.draft[k] = options[k][math.random(1, #options[k])] end + end + for i = 1, 4 do if fields["tab_" .. i] then st.tab = i; break end end + if fields.front then st.yaw = 180 elseif fields.profile then st.yaw = 90 elseif fields.back then st.yaw = 0 end + render(player, st); return true + end + api.register_on_player_receive_fields(function(player, form, fields) + if form ~= C.FORM then return end + C.handle(player, fields); return true + end) + api.register_chatcommand("character", {description = "Preview and save your woodland traveler appearance (cosmetic only)", + func = function(name) + local player = api.get_player_by_name(name) + if not player or not C.show(player) then return false, "Join the world first." end + return true + end}) + return C +end diff --git a/mods/hearth/clock.lua b/mods/hearth/clock.lua new file mode 100755 index 0000000..d9c8072 --- /dev/null +++ b/mods/hearth/clock.lua @@ -0,0 +1,63 @@ +-- Loaded after content/interface; schema and the persistence instance come from init. +local C, M, P = core, hearth.model, hearth.persistence +local singleplayer = C.is_singleplayer() +local deployment = C.settings:get("hearth_environment") +if deployment == nil then deployment = singleplayer and "local" or "production" end +if deployment ~= "local" and deployment ~= "staging" and deployment ~= "production" then + error("Last Hearth: hearth_environment must be local, staging, or production.", 0) +end +if not singleplayer and deployment == "local" then + error("Last Hearth: dedicated servers cannot use the local environment.", 0) +end + +local date_setting = C.settings:get("hearth_preview_date") +local weather_setting = C.settings:get("hearth_preview_weather") or "auto" +local function nonempty(value) return value ~= nil and value ~= "" end +if deployment == "production" and (nonempty(date_setting) or weather_setting ~= "auto" + or C.settings:get("hearth_launcher_mode") ~= nil or C.settings:get("hearth_smoke_token") ~= nil + or C.settings:get("hearth_m1_course") ~= nil or C.settings:get("hearth_m1_course_nonce") ~= nil + or C.settings:get("hearth_m1_course_snag") ~= nil or C.settings:get("hearth_course_token") ~= nil) then + error("Last Hearth: production rejects fixture and launcher test settings.", 0) +end +local preview_date +if nonempty(date_setting) then + preview_date = tonumber(date_setting) + if not preview_date or preview_date ~= preview_date or preview_date < 946684800 + or preview_date > 4102444800 or preview_date % 1 ~= 0 then + error("Last Hearth: hearth_preview_date must be an integer UTC timestamp from 2000 through 2100.", 0) + end +end +if weather_setting ~= "auto" and weather_setting ~= "clear" and weather_setting ~= "rain" then + error("Last Hearth: hearth_preview_weather must be auto, clear, or rain.", 0) +end + +local configured = M.location(C.settings:get("hearth_latitude"), C.settings:get("hearth_longitude")) +if (deployment == "production" or not singleplayer) and not configured then + error("Last Hearth: server/production requires explicit valid hearth_latitude and hearth_longitude settings.", 0) +end +-- Always validate stored data, even when settings take precedence. Corrupt saves +-- must fail startup, not disappear behind a configured location or neutral preview. +local stored = P.load_world("location") +local selected = configured or (singleplayer and stored) +hearth.has_location = selected ~= nil and selected ~= false +-- The existing location form and HUD disclose this unchosen neutral preview. +local location = selected or {latitude = 0, longitude = 0} +function hearth.climate_at(timestamp) + if type(timestamp) ~= "number" or timestamp ~= timestamp or timestamp < 0 then return nil end + return M.solar(math.floor(timestamp), location.latitude, location.longitude) +end +function hearth.set_location(lat, lon) + assert(singleplayer, "Only local worlds can select location in game") + local value = assert(M.location(lat, lon), "Invalid reference coordinates") + -- Publish only after a successful save; never silently replace a failed record. + P.save_world("location", value) + location, hearth.has_location = value, true +end +function hearth.clock_update() + local now = preview_date or os.time() + hearth.clock = M.solar(now, location.latitude, location.longitude) + hearth.weather = M.weather(now, location.latitude, location.longitude) + if weather_setting ~= "auto" then hearth.weather = weather_setting end + C.set_timeofday(hearth.clock.timeofday) +end +C.after(0, hearth.clock_update) diff --git a/mods/hearth/combat.lua b/mods/hearth/combat.lua new file mode 100755 index 0000000..c70cb52 --- /dev/null +++ b/mods/hearth/combat.lua @@ -0,0 +1,59 @@ +-- Pure server combat rules: no engine, clock, random, persistence or regeneration. +local M = {} +local weapons = { + ["hearth:axe"] = {reach = 2.5, cadence = 1.1, stamina = 12, damage = 4, wear = 655, sound = "hearth_swing_axe"}, + ["hearth:knife"] = {reach = 1.8, cadence = 0.7, stamina = 8, damage = 3, wear = 437, sound = "hearth_swing_knife"}, + ["hearth:spear"] = {reach = 3.4, cadence = 1.2, stamina = 14, damage = 5, wear = 546, sound = "hearth_swing_spear"}, +} +local function finite(n) return type(n) == "number" and n == n and math.abs(n) < math.huge end +local function position(p) return type(p) == "table" and finite(p.x) and finite(p.y) and finite(p.z) end +function M.distance(a, b) + return math.sqrt((a.x - b.x)^2 + (a.y - b.y)^2 + (a.z - b.z)^2) +end +function M.facing(origin, target, look, threshold) + if not position(origin) or not position(target) or not position(look) then return false end + local distance, length = M.distance(origin, target), math.sqrt(look.x^2 + look.y^2 + look.z^2) + if distance < 0.001 or length < 0.001 then return false end + return ((target.x - origin.x) * look.x + (target.y - origin.y) * look.y + + (target.z - origin.z) * look.z) / (distance * length) + 1e-9 >= threshold +end +-- Recent displacement check, NOT a full teleport/flight anticheat. +function M.sample(previous, pos, now) + if not position(pos) or not finite(now) then return previous end + local blocked = previous and previous.blocked_until or 0 + if previous then + local dt = now - previous.time + if dt < 0 then return previous end + if dt <= 0.5 and M.distance(previous.pos, pos) > 1.25 + 8 * dt then blocked = now + 0.5 end + end + return {pos = {x = pos.x, y = pos.y, z = pos.z}, time = now, blocked_until = blocked} +end +function M.valid_attack(c) + local s, state, attack = c.session, c.state, weapons[c.weapon] + if c.live ~= true or type(s) ~= "table" or type(state) ~= "table" or s.waiting + or not finite(c.hp) or c.hp <= 0 then return nil, "inactive" end + if not finite(c.now) or c.now < (s.combat_now or 0) then return nil, "clock" end + if not attack then return nil, "weapon" end + if c.now < (s.attack_ready_at or 0) or c.now < (s.spar_ready_at or 0) then return nil, "cadence" end + if not finite(state.stamina) or state.stamina < attack.stamina then return nil, "stamina" end + if c.allowed ~= true then return nil, "claim" end + if not position(c.origin) or not position(c.eye) or not position(c.target) then return nil, "position" end + if M.distance(c.eye, c.target) > attack.reach then return nil, "reach" end + if not M.facing(c.eye, c.target, c.look, 0.35) then return nil, "aim" end + if c.clear ~= true then return nil, "obstructed" end + local sample = s.combat_sample + if sample then + local dt = c.now - sample.time + if dt < 0 or c.now < (sample.blocked_until or 0) then return nil, "movement" end + if dt <= 0.5 and M.distance(sample.pos, c.origin) > 1.25 + 8 * dt then return nil, "movement" end + end + local result = {}; for k, v in pairs(attack) do result[k] = v end + return result +end +function M.valid_guard(c) + return c.live == true and c.session ~= nil and not c.session.waiting and c.state ~= nil + and finite(c.hp) and c.hp > 0 and finite(c.state.stamina) and c.state.stamina >= 8 + and c.weapon == "hearth:spear" and c.controls ~= nil and c.controls.RMB == true + and M.facing(c.eye, c.target, c.look, 0.4) +end +return M diff --git a/mods/hearth/construction.lua b/mods/hearth/construction.lua new file mode 100755 index 0000000..b240780 --- /dev/null +++ b/mods/hearth/construction.lua @@ -0,0 +1,228 @@ +-- Construction helpers; dependencies are supplied by the content registrar. +return function(hearth, api) + local MAX_REACH = 6 + local directions = { + {x = 1, y = 0, z = 0}, {x = -1, y = 0, z = 0}, + {x = 0, y = 0, z = 1}, {x = 0, y = 0, z = -1}, + } + + local function near(player, pos) + if not player or not player:is_player() then return false end + local p = player:get_pos() + if not p then return false end + local dx, dy, dz = p.x - pos.x, p.y - pos.y, p.z - pos.z + return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH + end + + local function allowed(pos, player, action) + if not near(player, pos) then return false end + local name = player:get_player_name() + local protected = hearth.external_protected or api.is_protected + if (protected and protected(pos, name)) or not hearth.claim_allowed(pos, name, action or "build") then + api.record_protection_violation(pos, name) + return false + end + return true + end + + local removal_safe + local function can_dig(pos, player) + if not allowed(pos, player, "dig") then return false end + if removal_safe(pos, player) then return true end + if player and hearth.feedback then + hearth.feedback(player, "Keep the connected structure supported and within one homestead before removing this block.", "warning") + elseif player then hearth.notify(player, "Keep connected construction supported before removing this block.") end + return false + end + + local function node_def(pos) + local node = api.get_node_or_nil(pos) + return node and api.registered_nodes[node.name], node + end + + local function placement_pos(pointed) + if not pointed or pointed.type ~= "node" then return nil end + local under = node_def(pointed.under) + local above = node_def(pointed.above) + if not under or not above then return nil end + if under.buildable_to then return pointed.under end + if above.buildable_to then return pointed.above end + end + + local function place(stack, player, pointed) + if not pointed or pointed.type ~= "node" then return stack end + local def = node_def(pointed.under) + -- item_place dispatches right-click callbacks before attempting placement. + if def and def.on_rightclick and player and not player:get_player_control().sneak then + local action = def.groups and (def.groups.hearth_storage or 0) > 0 and "storage" or "use" + if not allowed(pointed.under, player, action) then return stack end + if action == "storage" then + return def.on_rightclick(pointed.under, api.get_node(pointed.under), player, stack, pointed) or stack + end + return api.item_place(stack, player, pointed) + end + local pos = placement_pos(pointed) + if not pos or not allowed(pos, player) then return stack end + return api.item_place(stack, player, pointed) + end + + local function is_building(name) + return name == "hearth:wall" or name == "hearth:roof" or name == "hearth:foundation" + or name == "hearth:frame" or name == "hearth:stairs" or name == "hearth:door" + or name == "hearth:door_open" or name == "hearth:window" or name == "hearth:vent" + end + + local function solid(def) + return def and def.walkable ~= false and not def.buildable_to + and (not def.liquidtype or def.liquidtype == "none") + and not (def.groups and (def.groups.leaves or def.groups.hearth_non_support)) + end + + local function same_pos(a, b) + return a.x == b.x and a.y == b.y and a.z == b.z + end + local function household_owner(pos) + return hearth.claim_owner_at and hearth.claim_owner_at(pos) or nil + end + local function same_household(target, support) + local target_owner = household_owner(target) + local support_owner = household_owner(support) + return support_owner == nil or support_owner == target_owner + end + + local function supported(pos, ignore) + -- Search existing construction only: at most two horizontal links and + -- sixteen blocks down. The horizontal budget never resets at a new block, + -- so sequential placement cannot extend a cantilever indefinitely. + local queue = {{pos = pos, span = 0, depth = 0}} + local seen, index, unloaded = {}, 1, false + while index <= #queue do + local entry = queue[index] + index = index + 1 + local p = entry.pos + local key = p.x .. ":" .. p.y .. ":" .. p.z .. ":" .. entry.span + if not seen[key] then + seen[key] = true + local below = {x = p.x, y = p.y - 1, z = p.z} + local def, node + if ignore and same_pos(below, ignore) then + def, node = api.registered_nodes.air or {walkable = false, buildable_to = true}, {name = "air"} + else def, node = node_def(below) end + if not def or node.name == "ignore" then unloaded = true end + if solid(def) and node.name ~= "ignore" and same_household(pos, below) then + if not is_building(node.name) then return true end + if entry.depth < 15 then + queue[#queue + 1] = {pos = below, span = entry.span, + depth = entry.depth + 1} + end + end + if entry.span < 2 then + for _, d in ipairs(directions) do + local adjacent = {x = p.x + d.x, y = p.y, z = p.z + d.z} + local _, n + if ignore and same_pos(adjacent, ignore) then n = {name = "air"} + else _, n = node_def(adjacent) end + if n and is_building(n.name) and same_household(pos, adjacent) then + queue[#queue + 1] = {pos = adjacent, span = entry.span + 1, + depth = entry.depth} + end + end + end + end + end + return false, unloaded and "unloaded" or "support" + end + + -- Refuse support removal rather than cascading destruction. Check the maximum + -- span/height a placement may legally depend on, and fail closed across claims. + removal_safe = function(pos, player) + local target = api.get_node_or_nil(pos) + if not target or target.name == "ignore" then return false end + local target_def = api.registered_nodes[target.name] + if not solid(target_def) then return true end + local name = player:get_player_name() + local removed_owner = household_owner(pos) + for y = 0, 16 do + for dx = -2, 2 do for dz = -2, 2 do + local candidate = {x = pos.x + dx, y = pos.y + y, z = pos.z + dz} + if not same_pos(candidate, pos) then + local node = api.get_node_or_nil(candidate) + if not node or node.name == "ignore" then return false end + if is_building(node.name) then + local dependent_owner = household_owner(candidate) + if dependent_owner and dependent_owner ~= removed_owner then return false end + if not hearth.claim_allowed(candidate, name, "dig") then return false end + local ok = supported(candidate, pos) + if not ok then return false end + end + end + end end + end + return true + end + + -- pos is the resolved destination, not a pointed_thing. This read-only + -- query deliberately does not call allowed(): previews must never record a + -- violation, consume an ItemStack, play sound, or create node metadata. + local function preview(pos, player, item) + if type(pos) ~= "table" then return false, "invalid" end + for _, axis in ipairs({"x", "y", "z"}) do + local n = pos[axis] + if type(n) ~= "number" or n ~= n or math.abs(n) > 31000 or n % 1 ~= 0 then + return false, "invalid" + end + end + if not near(player, pos) then return false, "distance" end + local name = player:get_player_name() + if api.is_protected(pos, name) or not hearth.claim_allowed(pos, name) then + return false, "protected" + end + local def, node = node_def(pos) + if not def or node.name == "ignore" then return false, "unloaded" end + if not def.buildable_to then return false, "occupied" end + local itemname = type(item) == "string" and item + if not itemname and item and (type(item) == "table" or type(item) == "userdata") + and type(item.get_name) == "function" then itemname = item:get_name() end + if type(itemname) ~= "string" or not api.registered_nodes[itemname] then + return false, "item" + end + if is_building(itemname) then + local ok, reason = supported(pos) + if not ok then return false, reason end + end + return true, "ok" + end + + local function hint(player, message) + -- Retain chat compatibility for existing callers/tests without feedback. + if hearth.feedback then hearth.feedback(player, message, "warning") + else hearth.notify(player, message) end + if hearth.feedback_sound then hearth.feedback_sound("hearth_denial", player, 0.2) end + end + + local function place_building(stack, player, pointed) + if not pointed or pointed.type ~= "node" then return stack end + local under = node_def(pointed.under) + if under and under.on_rightclick and player and not player:get_player_control().sneak then + if not allowed(pointed.under, player) then return stack end + return api.item_place(stack, player, pointed) + end + local pos = placement_pos(pointed) + if not pos or not allowed(pos, player) then return stack end + if not supported(pos) then + hint(player, "Needs solid support within two horizontal blocks (maximum height 16).") + return stack + end + return api.item_place(stack, player, pointed) + end + + local function owner(pos, player) + if player and player:is_player() then + api.get_meta(pos):set_string("owner", player:get_player_name()) + end + end + + return {near = near, allowed = allowed, can_dig = can_dig, place = place, + place_building = place_building, owner = owner, preview = preview, + removal_safe = removal_safe} +end diff --git a/mods/hearth/content.lua b/mods/hearth/content.lua index 43afd6e..0fb204c 100755 --- a/mods/hearth/content.lua +++ b/mods/hearth/content.lua @@ -1,163 +1,134 @@ --- Original, asset-free content for The Last Hearth (Luanti 5.18). +-- Original, generated-art content for The Last Hearth (Luanti 5.18). -- Survival state is owned/persisted by the caller; numeric needs use 0..100. -local api = core -local MAX_REACH, MAX_FUEL = 6, 600 -local FUEL = { ["hearth:sticks"] = 45, ["hearth:log"] = 180 } -local COOK_COST, BOIL_COST = 20, 10 -local directions = { - {x = 1, y = 0, z = 0}, {x = -1, y = 0, z = 0}, - {x = 0, y = 0, z = 1}, {x = 0, y = 0, z = -1}, -} - -local function texture(color) - return hearth.texture(color) -end - -local function lowpart(percent, color) - -- Texture-name arguments must escape modifiers/colons, including [fill. - local overlay = texture(color):gsub("[\\^:]", function(c) return "\\" .. c end) - return "^[lowpart:" .. percent .. ":" .. overlay -end - -local function near(player, pos) - if not player or not player:is_player() then return false end - local p = player:get_pos() - if not p then return false end - local dx, dy, dz = p.x - pos.x, p.y - pos.y, p.z - pos.z - return dx * dx + dy * dy + dz * dz <= MAX_REACH * MAX_REACH -end - -local function allowed(pos, player) - if not near(player, pos) then return false end - local name = player:get_player_name() - if api.is_protected(pos, name) or not hearth.claim_allowed(pos, name) then - api.record_protection_violation(pos, name) - return false - end - return true -end - -local function can_dig(pos, player) - return allowed(pos, player) -end - -local function node_def(pos) - local node = api.get_node_or_nil(pos) - return node and api.registered_nodes[node.name], node -end - -local function placement_pos(pointed) - if not pointed or pointed.type ~= "node" then return nil end - local under = node_def(pointed.under) - local above = node_def(pointed.above) - if not under or not above then return nil end - if under.buildable_to then return pointed.under end - if above.buildable_to then return pointed.above end -end - -local function place(stack, player, pointed) - if not pointed or pointed.type ~= "node" then return stack end - local def = node_def(pointed.under) - -- item_place dispatches right-click callbacks before attempting placement. - if def and def.on_rightclick and player and not player:get_player_control().sneak then - if not allowed(pointed.under, player) then return stack end - return api.item_place(stack, player, pointed) - end - local pos = placement_pos(pointed) - if not pos or not allowed(pos, player) then return stack end - return api.item_place(stack, player, pointed) -end - -local function is_building(name) - return name == "hearth:wall" or name == "hearth:roof" -end - -local function solid(def) - return def and def.walkable ~= false and not def.buildable_to - and (not def.liquidtype or def.liquidtype == "none") - and not (def.groups and def.groups.leaves) -end - -local function supported(pos) - -- Search existing construction only: at most two horizontal links and - -- sixteen blocks down. The horizontal budget never resets at a new block, - -- so sequential placement cannot extend a cantilever indefinitely. - local queue = {{pos = pos, span = 0, depth = 0}} - local seen, index = {}, 1 - while index <= #queue do - local entry = queue[index] - index = index + 1 - local p = entry.pos - local key = p.x .. ":" .. p.y .. ":" .. p.z .. ":" .. entry.span - if not seen[key] then - seen[key] = true - local below = {x = p.x, y = p.y - 1, z = p.z} - local def, node = node_def(below) - if solid(def) then - if not is_building(node.name) then return true end - if entry.depth < 15 then - queue[#queue + 1] = {pos = below, span = entry.span, - depth = entry.depth + 1} - end - end - if entry.span < 2 then - for _, d in ipairs(directions) do - local adjacent = {x = p.x + d.x, y = p.y, z = p.z + d.z} - local _, n = node_def(adjacent) - if n and is_building(n.name) then - queue[#queue + 1] = {pos = adjacent, span = entry.span + 1, - depth = entry.depth} - end - end - end +local function world_image(name) return "hearth_" .. name .. ".png" end +local function item_image(name) return "hearth_item_" .. name .. ".png" end +-- A private facade, not a mutation of core or its registration tables. Missing +-- optional registered_* fields in standalone mocks remain missing, as in core. +local api = setmetatable({ + register_node = function(name, def) + -- Legacy seasonal definitions still mention mesh atlases. Normalize them + -- at registration without mutating core tables or the seasons owner. + local kind=name + for _,season in ipairs({"spring","autumn","winter"}) do kind=kind:gsub("_"..season.."$","") end + if kind=="hearth:young_tree" or kind=="hearth:mature_tree" then + def.mesh = nil + def.tiles = {world_image("log_top"), world_image("log_top"), world_image("log_side")} + elseif kind=="hearth:tree_seedling" then + def.mesh = nil + def.tiles = {world_image("sapling")} end - end - return false -end + return core.register_node(name, def) + end, + register_craftitem = function(name, def) + def.inventory_image = item_image(assert(name:match("^hearth:(.+)$"))) + return core.register_craftitem(name, def) + end, +}, {__index = core}) +local modpath = api.get_modpath("hearth") +local construction = dofile(modpath .. "/construction.lua")(hearth, api) +local seasons = dofile(modpath .. "/seasons.lua")(hearth, api) +hearth.seasons = seasons +local botany = dofile(modpath .. "/botany.lua")(hearth, api) +hearth.botany = botany +hearth.wiki_articles = dofile(modpath .. "/wiki_articles.lua") +-- Succession captures the effect policy at construction time. +hearth.species_effects = dofile(modpath .. "/species_effects.lua")(hearth, api) +local diversity = dofile(modpath .. "/diversity.lua")(hearth, api) +hearth.diversity = diversity +local ecology = dofile(modpath .. "/ecology.lua")(hearth, api, construction) +local succession = dofile(modpath .. "/succession.lua")(hearth, api) +hearth.succession = succession +local fishery = dofile(modpath .. "/fishery.lua")(hearth, api, construction) +hearth.ecology = ecology +hearth.fishery = fishery +hearth.construction = construction +local interactions = dofile(modpath .. "/interactions.lua")(hearth) +local processes = dofile(modpath .. "/processes.lua")(hearth, api, construction) +local allowed, can_dig, place = construction.allowed, construction.can_dig, construction.place +local place_building, owner = construction.place_building, construction.owner +local eat = interactions.eat +local use_fire, fire_tick, refresh_fire = processes.use_fire, processes.fire_tick, processes.refresh_fire -local function place_building(stack, player, pointed) - if not pointed or pointed.type ~= "node" then return stack end - local under = node_def(pointed.under) - if under and under.on_rightclick and player and not player:get_player_control().sneak then - if not allowed(pointed.under, player) then return stack end - return api.item_place(stack, player, pointed) +-- Semantic exceptions to the default one-name/one-tile manifest contract. +-- Nodeboxes need opaque physical materials, not stretched inventory silhouettes. +local node_material = { + door_open = "door", loose_stone = "stone", sticks = "log_side", + fire = "stone", fire_lit = "stone", drying_rack = "planks", fish_trap = "planks", +} +local function node_art(name, def) + local image = world_image(name == "door_open" and "door" or name) + def.tiles = def.tiles or {world_image(node_material[name] or name)} + if name == "grass" then + def.tiles = {world_image("grass_top"), world_image("soil"), world_image("grass_side")} + elseif name == "log" then + def.tiles = {world_image("log_top"), world_image("log_top"), world_image("log_side")} + elseif name == "water_source" or name == "water_flowing" then + local water = world_image("water") .. "^[opacity:185" + def.tiles = {water} + def.special_tiles = {{name = water, backface_culling = false}, + {name = water, backface_culling = true}} + elseif name == "bedroll" then + -- The opaque central cloth panel, excluding the silhouette's empty border. + def.tiles = {image .. "^[sheet:4x4:1,2"} end - local pos = placement_pos(pointed) - if not pos or not allowed(pos, player) then return stack end - if not supported(pos) then - hearth.notify(player, "Needs solid support within two horizontal blocks (maximum height 16).") - return stack + if name == "berry_bush" or name == "berry_bush_bare" then def.drawtype = "plantlike" end + if def.drawtype == "plantlike" or name == "leaves" then + def.use_texture_alpha, def.waving = "clip", 1 + elseif name == "torch" then + def.use_texture_alpha = "clip" end - return api.item_place(stack, player, pointed) -end - -local function owner(pos, player) - if player and player:is_player() then - api.get_meta(pos):set_string("owner", player:get_player_name()) + if def.drawtype == "plantlike" or def.drawtype == "nodebox" or name == "torch" then + if def.drawtype == "plantlike" then image = def.tiles[1] end + def.inventory_image, def.wield_image = def.inventory_image or image, def.wield_image or image end end local function register_node(name, def) + node_art(name, def) def.on_place = def.on_place or place - def.can_dig = can_dig + def.can_dig = def.can_dig or can_dig + local g = def.groups or {} + local affects_room = g.hearth_building or g.hearth_vent + local function changed(callback) + return function(pos, ...) + local result + if callback then result = callback(pos, ...) end + if hearth.invalidate_environment then hearth.invalidate_environment(pos) end + return result + end + end + if affects_room then + def.on_construct = changed(def.on_construct) + def.after_place_node = changed(def.after_place_node) + def.after_destruct = changed(def.after_destruct) + end + -- Distinguish hand-placed natural-looking ground/flora from mapgen terrain. + local after_place = def.after_place_node + def.after_place_node = function(pos, ...) + api.get_meta(pos):set_string("player_placed", "1") + if after_place then return after_place(pos, ...) end + end + local material = (g.wood or g.tree or g.choppy) and "wood" + or ((g.stone or g.cracky) and "stone" or "soil") + def.sounds = def.sounds or {dug = {name = "hearth_dig_" .. material, gain = 0.35}, + place = {name = "hearth_dig_" .. material, gain = 0.2}} api.register_node("hearth:" .. name, def) end register_node("stone", { - description = "Weathered stone", tiles = {texture("#79766b") .. "^[contrast:12"}, + description = "Weathered stone", groups = {cracky = 3, stone = 1}, drop = "hearth:loose_stone 3", }) register_node("soil", { - description = "Earth", tiles = {texture("#68513c")}, + description = "Earth", groups = {crumbly = 3, soil = 1}, }) register_node("grass", { - description = "Meadow turf", tiles = {texture("#66764a"), texture("#68513c"), - texture("#68513c") .. lowpart(25, "#66764a")}, + description = "Meadow turf", groups = {crumbly = 3, soil = 1}, drop = "hearth:soil", }) register_node("sand", { - description = "River sand", tiles = {texture("#b7a47a")}, + description = "River sand", groups = {crumbly = 3, falling_node = 1, sand = 1}, }) @@ -166,11 +137,6 @@ for _, flowing in ipairs({false, true}) do register_node(name, { description = flowing and "Flowing river water" or "River water (unsafe to drink)", drawtype = flowing and "flowingliquid" or "liquid", - tiles = {texture("#527e82") .. "^[opacity:185"}, - special_tiles = { - {name = texture("#527e82") .. "^[opacity:185", backface_culling = false}, - {name = texture("#527e82") .. "^[opacity:185", backface_culling = true}, - }, paramtype = "light", paramtype2 = flowing and "flowingliquid" or nil, use_texture_alpha = "blend", walkable = false, pointable = false, diggable = false, buildable_to = true, drowning = 1, @@ -185,95 +151,462 @@ end register_node("log", { description = "Timber log (requires an axe)", - tiles = {texture("#a18a62"), texture("#a18a62"), texture("#564534")}, paramtype2 = "facedir", groups = {tree = 1, choppy = 2, flammable = 2}, }) register_node("leaves", { description = "Canopy leaves", drawtype = "allfaces_optional", - tiles = {texture("#536744")}, paramtype = "light", sunlight_propagates = true, - groups = {snappy = 3, leaves = 1, flammable = 2}, drop = "hearth:sticks", + paramtype = "light", sunlight_propagates = true, + groups = {snappy = 3, leaves = 1, flammable = 2}, + drop = {items = {{items = {"hearth:sticks"}, rarity = 1}, + {items = {"hearth:sapling"}, rarity = 8}}}, }) register_node("loose_stone", { description = "Loose flint stone", drawtype = "nodebox", - tiles = {texture("#96938b")}, inventory_image = texture("#96938b"), - wield_image = texture("#96938b"), paramtype = "light", walkable = false, + paramtype = "light", walkable = false, node_box = {type = "fixed", fixed = {-0.25, -0.5, -0.2, 0.25, -0.32, 0.2}}, selection_box = {type = "fixed", fixed = {-0.3, -0.5, -0.3, 0.3, -0.2, 0.3}}, groups = {dig_immediate = 3, flint = 1}, }) register_node("sticks", { - description = "Dry sticks", drawtype = "nodebox", tiles = {texture("#8e7451")}, - inventory_image = texture("#8e7451"), wield_image = texture("#8e7451"), + description = "Dry sticks", drawtype = "nodebox", paramtype = "light", walkable = false, node_box = {type = "fixed", fixed = {-0.4, -0.48, -0.06, 0.4, -0.4, 0.06}}, groups = {dig_immediate = 3, flammable = 2}, }) - -local function eat(amounts, replacement) - return function(stack, player) - if not player or not player:is_player() then return stack end - local state = hearth.players[player:get_player_name()] - if not state then return stack end - local needed = false - for key, value in pairs(amounts) do - if type(state[key]) == "number" and state[key] < 100 and value > 0 then - needed = true - end - end - if not needed then return stack end - for key, value in pairs(amounts) do - if type(state[key]) == "number" then - state[key] = math.max(0, math.min(100, state[key] + value)) - end - end - if replacement then stack:replace(replacement) else stack:take_item() end - return stack - end -end - -api.register_craftitem("hearth:berries", { - description = "Wild berries", inventory_image = texture("#995a65"), - on_use = eat({food = 12, water = 4}), -}) -api.register_craftitem("hearth:raw_food", { - description = "Foraged food (cook at a hearth)", inventory_image = texture("#ad8263"), -}) -api.register_craftitem("hearth:cooked_food", { - description = "Hearth-cooked forage", inventory_image = texture("#bf985b"), - on_use = eat({food = 35, stamina = 10}), +register_node("torch", { + description = "Reed torch", drawtype = "torchlike", + paramtype = "light", paramtype2 = "wallmounted", sunlight_propagates = true, walkable = false, + light_source = 12, groups = {dig_immediate = 3}, drop = "hearth:torch", }) register_node("berry_bush", { description = "Wild berry bush", drawtype = "allfaces_optional", - tiles = {texture("#63734b") .. lowpart(20, "#995a65")}, paramtype = "light", walkable = false, sunlight_propagates = true, - groups = {snappy = 3, flora = 1, flammable = 2}, drop = "hearth:berries 3", + groups = {snappy = 3, flora = 1, flammable = 2}, drop = "", + can_dig = function(pos, player) return construction.allowed(pos, player, "use") end, + on_dig = ecology.berry_dig, + on_rightclick = function(pos, _, player) ecology.harvest_berries(pos, player) end, +}) +register_node("berry_bush_bare", { + description = "Berry bush (regrowing)", drawtype = "allfaces_optional", + paramtype = "light", walkable = false, + sunlight_propagates = true, drop = "", + groups = {snappy = 3, flora = 1, flammable = 2, not_in_creative_inventory = 1}, + can_dig = function(pos, player) return construction.allowed(pos, player, "use") end, + on_dig = ecology.berry_dig, + on_rightclick = function(pos, _, player) ecology.harvest_berries(pos, player) end, + on_timer = function(pos) return ecology.refresh_bush(pos) end, +}) +register_node("sapling", { + description = "Young tree sapling", drawtype = "plantlike", + paramtype = "light", sunlight_propagates = true, walkable = false, + groups = {snappy = 3, flora = 1, flammable = 2}, + on_place = ecology.plant_sapling, + on_timer = ecology.grow_sapling, +}) +-- Ecological sprites and ordinary log-cube roots; multi-node tree placement is +-- owned by ecology through diversity.tree_plan, not these node definitions. +-- Named textures are generated by the game's original asset pipeline. +for _, plant in ipairs({ + {"meadow_grass", "Living meadow grass", 0.8}, + {"wildflowers", "Meadow wildflowers", 1}, + {"fern", "Shade fern", 1}, + {"tree_seedling", "Naturally seeded tree", 0.65}, + {"young_tree", "Young woodland tree", 1.5}, + {"mature_tree", "Mature seed-bearing tree", 2.6}, + {"fungi", "Decomposer fungi (not edible)", 0.5}, + {"litter", "Decomposing leaf litter", 0.35}, + {"deadwood", "Fallen log (axe; yields timber)", 1}, + {"dung", "Wildlife dung (returns nutrients to the soil)", 0.25}, +}) do + local name, description, scale = plant[1], plant[2], plant[3] + local woody = name == "young_tree" or name == "mature_tree" or name == "deadwood" + register_node(name, { + description = description, drawtype = woody and "normal" or "plantlike", visual_scale = woody and 1 or scale, + tiles = woody and {world_image("log_top"),world_image("log_top"),world_image("log_side")} + or {world_image(name == "tree_seedling" and "sapling" or name)}, + paramtype = not woody and "light" or nil, paramtype2 = woody and "facedir" or nil, + sunlight_propagates = not woody, walkable = woody, + use_texture_alpha = not woody and "clip" or nil, buildable_to = not woody and name ~= "tree_seedling", + groups = woody and {tree=1, choppy=2, flammable=2, hearth_ecosystem=1, not_in_creative_inventory=1} + or {snappy=3, flora=1, flammable=2, hearth_ecosystem=1}, + drop = woody and "hearth:log" or "", + }) +end +-- Cosmetic migration of this one legacy node only; swap preserves provenance, +-- ecological age, inventories and timers. No terrain generation or conversion +-- to canonical timber (which would lose the decomposer's identity). +api.register_lbm({name="hearth:block_deadwood", label="Lay legacy fallen log cubes sideways", + nodenames={"hearth:deadwood"}, run_at_every_load=true, action=function(pos) + local current=api.get_node_or_nil(pos) + if not current or current.name~="hearth:deadwood" then return end + local protect=hearth.external_protected or api.is_protected + if protect and protect(pos, "") then return end + local meta=api.get_meta(pos) + if meta:get_string("succ_block_deadwood")=="1" then return end + local axis=math.floor((current.param2 or 0)/4) + if axis<1 or axis>4 then + api.swap_node(pos,{name=current.name,param1=current.param1,param2=12}) + end + meta:set_string("succ_block_deadwood","1") + end}) +for _, forage in ipairs({{"spring_greens", "Spring greens"}, {"autumn_mushrooms", "Autumn mushrooms"}}) do + local kind = forage[1] + register_node(kind .. "_patch", { + description = forage[2] .. " patch (pick in season)", drawtype = "plantlike", + tiles = {"hearth_" .. kind .. "_dormant.png"}, + paramtype = "light", sunlight_propagates = true, walkable = false, + -- Occupied, even when dormant: neither placement nor succession overwrites it. + buildable_to = false, drop = "", + groups = {snappy=3, flora=1, flammable=2, hearth_ecosystem=1}, + can_dig = function(pos, player) return construction.allowed(pos, player, "use") end, + on_dig = function(pos, _, player) ecology.harvest_seasonal(pos, player) end, + on_rightclick = function(pos, _, player) ecology.harvest_seasonal(pos, player) end, + }) +end +for stage = 1, 4 do + register_node("crop_" .. stage, { + description = stage == 4 and "Ripe grain crop (harvest for food and seed)" or "Growing grain crop", + drawtype = "plantlike", + paramtype = "light", sunlight_propagates = true, walkable = false, drop = "", + groups = {snappy = 3, flora = 1, flammable = 2, + not_in_creative_inventory = stage > 1 and 1 or nil}, + can_dig = function(pos, player) return construction.allowed(pos, player, "use") end, + on_dig = ecology.crop_dig, + on_place = ecology.plant_crop, + on_rightclick = function(pos, _, player) ecology.harvest_crop(pos, player) end, + on_construct = function(pos) api.get_meta(pos):set_int("planted_at", os.time()) end, + on_timer = function(pos) return ecology.update_crop(pos) end, + }) +end +api.register_lbm({ + name = "hearth:crop_growth", label = "Reconcile seasonal crop growth", + nodenames = {"hearth:crop_1", "hearth:crop_2", "hearth:crop_3", "hearth:crop_4"}, + run_at_every_load = true, action = ecology.reconcile_crop, +}) +api.register_lbm({ + name = "hearth:berry_regrowth", label = "Reconcile berry-bush regrowth", + nodenames = {"hearth:berry_bush_bare"}, run_at_every_load = true, + action = ecology.reconcile_bush, +}) +api.register_lbm({ + name = "hearth:sapling_growth", label = "Reconcile bounded tree regrowth", + nodenames = {"hearth:sapling"}, run_at_every_load = true, + action = ecology.reconcile_sapling, +}) +api.register_lbm({ + name = "hearth:fish_trap_reconcile", label = "Reconcile baited river traps", + nodenames = {"hearth:fish_trap"}, run_at_every_load = true, action = fishery.reconcile, +}) +api.register_craftitem("hearth:berries", { + description = "Wild berries", + on_use = eat({food = 12, water = 4}), +}) +api.register_craftitem("hearth:spring_greens", { + description = "Picked spring greens", on_use = eat({food = 12}), +}) +api.register_craftitem("hearth:autumn_mushrooms", { + description = "Picked autumn mushrooms (craft to forage, then cook at a hearth)", +}) +api.register_craftitem("hearth:grain", { + description = "Harvested grain (reserve one to replant)", +}) +api.register_craftitem("hearth:grain_seed", { + description = "Grain seed", + on_place = ecology.plant_crop, +}) +api.register_craftitem("hearth:raw_food", { + description = "Foraged food (cook at a hearth)", +}) +api.register_craftitem("hearth:cooked_food", { + description = "Hearth-cooked forage", + on_use = eat({food = 35, stamina = 10}), +}) +api.register_craftitem("hearth:fish_bait", { + description = "Grain-and-berry fish bait", +}) +api.register_craftitem("hearth:raw_fish", { + description = "Raw river fish (cook or dry before eating)", +}) +api.register_craftitem("hearth:cooked_fish", { + description = "Hearth-roasted river fish", + on_use = eat({food = 32, stamina = 6}), +}) +api.register_craftitem("hearth:fish_drying_charge", { + description = "Roasted fish and reeds ready for drying", +}) +api.register_craftitem("hearth:dried_fish", { + description = "Dried river fish (shelf-stable)", + on_use = eat({food = 26, water = -1}), +}) +api.register_craftitem("hearth:iron_ore", {description = "Iron ore"}) +api.register_craftitem("hearth:iron_ingot", {description = "Hearth-smelted iron ingot"}) +api.register_craftitem("hearth:copper_ingot", {description = "Hearth-smelted copper ingot"}) +api.register_craftitem("hearth:copper_charge", { + description = "Copper ore and charcoal ready for hearth smelting", +}) +api.register_craftitem("hearth:charcoal", {description = "Prepared charcoal fuel"}) +api.register_craftitem("hearth:charcoal_charge", { + description = "Log and clay ready for a charcoal burn", +}) +api.register_craftitem("hearth:smelt_charge", { + description = "Iron ore and charcoal ready for hearth smelting", +}) +api.register_craftitem("hearth:clay", {description = "River clay"}) +api.register_craftitem("hearth:clay_jar", {description = "Fired grain jar"}) +api.register_craftitem("hearth:clay_jar_charge", { + description = "Unfired clay jar (fire at a hearth)", +}) +api.register_craftitem("hearth:dried_food", { + description = "Dried preserved ration (shelf-stable)", + on_use = eat({food = 28, water = -2}), +}) +api.register_craftitem("hearth:dried_food_charge", { + description = "Cooked forage ready for the drying rack process", +}) +register_node("grain_patch", { + description = "Wild grain", drawtype = "plantlike", + paramtype = "light", sunlight_propagates = true, walkable = false, + groups = {snappy = 3, flora = 1, flammable = 2}, drop = "hearth:grain_seed", +}) +register_node("clay_bank", { + description = "Exposed river clay", + groups = {crumbly = 2, clay = 1}, drop = "hearth:clay 2", +}) +register_node("iron_ore_vein", { + description = "Iron-bearing rock (requires a pick)", + groups = {cracky = 3, stone = 1}, drop = "hearth:iron_ore 2", +}) +register_node("weir_stone", { + description = "Old river-weir masonry", + groups = {cracky = 3, river_landmark = 1}, drop = "", +}) +register_node("fish_trap", { + description = "Reed-and-stick river trap", drawtype = "nodebox", + paramtype = "light", sunlight_propagates = true, walkable = false, + node_box = {type = "fixed", fixed = {{-0.42,-0.5,-0.42,0.42,-0.34,0.42}, + {-0.36,-0.34,-0.36,-0.28,0.18,-0.28},{0.28,-0.34,-0.36,0.36,0.18,-0.28}, + {-0.36,-0.34,0.28,-0.28,0.18,0.36},{0.28,-0.34,0.28,0.36,0.18,0.36}}}, + selection_box = {type = "fixed", fixed = {-0.45,-0.5,-0.45,0.45,0.2,0.45}}, + groups = {choppy = 3, wood = 1, flammable = 2, hearth_non_support = 1}, drop = "hearth:fish_trap", + on_place = fishery.plant_trap, + on_construct = function(pos) + local meta = api.get_meta(pos) + meta:set_string("state", "idle"); meta:set_string("ready_at", "0") + meta:set_int("catch_count", 0); meta:set_string("infotext", "River trap: ready for bait beside water.") + end, + after_place_node = owner, can_dig = fishery.can_dig, + on_rightclick = fishery.rightclick, on_timer = fishery.on_timer, }) register_node("reeds", { - description = "Thatching rushes", drawtype = "plantlike", tiles = {texture("#a89965")}, - inventory_image = texture("#a89965"), wield_image = texture("#a89965"), + description = "Thatching rushes", drawtype = "plantlike", paramtype = "light", walkable = false, sunlight_propagates = true, groups = {snappy = 3, flora = 1, flammable = 2}, }) register_node("planks", { - description = "Split timber planks", tiles = {texture("#a48a61")}, + description = "Split timber planks", groups = {choppy = 3, wood = 1, flammable = 2}, }) register_node("wall", { - description = "Timber wall", tiles = {texture("#947853")}, - groups = {choppy = 3, wood = 1, flammable = 2}, + description = "Timber wall", + groups = {choppy = 3, wood = 1, flammable = 2, hearth_building = 1}, on_place = place_building, after_place_node = owner, }) register_node("roof", { - description = "Thatch roof", tiles = {texture("#b4a16b")}, - groups = {snappy = 3, flammable = 3}, + description = "Thatch roof", + groups = {snappy = 3, flammable = 3, hearth_building = 1}, on_place = place_building, after_place_node = owner, }) +register_node("foundation", { + description = "Stone foundation", + groups = {cracky = 3, stone = 1, hearth_building = 1}, + on_place = place_building, after_place_node = owner, +}) +register_node("frame", { + description = "Timber frame", + groups = {choppy = 3, wood = 1, flammable = 2, hearth_building = 1}, + on_place = place_building, after_place_node = owner, +}) +register_node("stairs", { + description = "Timber stairs", drawtype = "nodebox", + paramtype = "light", paramtype2 = "facedir", + node_box = {type = "fixed", fixed = { + {-0.5,-0.5,-0.5, 0.5,0,0.5}, {-0.5,0,-0.5, 0.5,0.5,0}, + }}, + groups = {choppy = 3, wood = 1, flammable = 2, hearth_building = 1}, + on_place = place_building, after_place_node = owner, +}) +local function toggle_door(pos, node, player) + if not construction.allowed(pos, player, "use") then return end + local next_name = node.name == "hearth:door_open" and "hearth:door" or "hearth:door_open" + if next_name == "hearth:door" and api.get_objects_inside_radius then + for _, object in ipairs(api.get_objects_inside_radius(pos, 0.8)) do + if object:is_player() then hearth.notify(player, "Stand clear before closing the door."); return end + end + end + api.swap_node(pos, {name = next_name, param2 = node.param2}) + if hearth.invalidate_environment then hearth.invalidate_environment(pos) end +end +register_node("door", { + description = "Timber door (right-click to open)", drawtype = "nodebox", + paramtype = "light", paramtype2 = "4dir", node_box = {type = "fixed", fixed = {-0.5,-0.5,-0.08,0.5,0.5,0.08}}, + groups = {choppy = 3, wood = 1, flammable = 2, hearth_building = 1}, + on_place = place_building, after_place_node = owner, on_rightclick = toggle_door, +}) +register_node("door_open", { + description = "Open timber door", drawtype = "nodebox", + paramtype = "light", paramtype2 = "4dir", walkable = false, + node_box = {type = "fixed", fixed = {-0.5,-0.5,-0.08,-0.42,0.5,0.08}}, + groups = {choppy = 3, wood = 1, flammable = 2, hearth_building = 1, not_in_creative_inventory = 1}, + drop = "hearth:door", on_place = place_building, can_dig = can_dig, on_rightclick = toggle_door, + allow_metadata_inventory_put = function() return 0 end, + allow_metadata_inventory_take = function() return 0 end, + allow_metadata_inventory_move = function() return 0 end, +}) +register_node("window", { + description = "Reed-lattice window", drawtype = "glasslike", paramtype = "light", + use_texture_alpha = "blend", sunlight_propagates = true, + groups = {cracky = 3, hearth_building = 1}, + on_place = place_building, after_place_node = owner, +}) +register_node("vent", { + description = "Roof vent (opens a room to the air)", + groups = {choppy = 3, wood = 1, flammable = 1, hearth_building = 1, hearth_vent = 1}, + on_place = place_building, after_place_node = owner, +}) +local function claimed_utility_place(stack, player, pointed) + if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end + local under = api.get_node_or_nil(pointed.under) + local above = api.get_node_or_nil(pointed.above) + local under_def = under and api.registered_nodes[under.name] + local above_def = above and api.registered_nodes[above.name] + local pos = under_def and under_def.buildable_to and pointed.under + or (above_def and above_def.buildable_to and pointed.above) + if not pos or not hearth.claim_owner_at(pos) + or not hearth.claim_allowed(pos, player:get_player_name(), "build") then + hearth.notify(player, "Place this utility inside a homestead you own or share.") + return stack + end + return place(stack, player, pointed) +end +local function storage_allowed(pos, player) + if not player or not player:is_player() then return false end + local name = player:get_player_name() + local stored_owner = api.get_meta(pos):get_string("owner") + local claim_owner = hearth.claim_owner_at(pos) + -- In a claimed household, current claim membership is authoritative so the + -- owner can still administer storage after a resident leaves. Unclaimed legacy + -- storage remains private to its recorded placer. + if not claim_owner and stored_owner ~= name then return false end + return construction.allowed(pos, player, "storage") +end +local function storage_can_dig(pos, player) + if not player or not player:is_player() then return false end + local name = player:get_player_name() + local stored_owner = api.get_meta(pos):get_string("owner") + local claim_owner = hearth.claim_owner_at(pos) + local role = claim_owner and type(hearth.claim_role_at) == "function" + and hearth.claim_role_at(pos, name) or nil + local may_remove = claim_owner and (role == "owner" or role == "resident") + or (not claim_owner and stored_owner == name) + return may_remove and can_dig(pos, player) and api.get_meta(pos):get_inventory():is_empty("main") +end +local function utility_formspec(pos, title, rows) + local id = pos.x .. "," .. pos.y .. "," .. pos.z + local player_y = 1.25 * rows + 1.25 + return "formspec_version[6]size[11," .. (player_y + 6) .. "]bgcolor[#1e1e2e;true]" + .. "style_type[label;textcolor=#cdd6f4]" + .. "style_type[button;bgcolor=#313244;textcolor=#cdd6f4;border=false]" + .. "style[close;bgcolor=#cba6f7;textcolor=#1e1e2e]" + .. "listcolors[#313244;#45475a;#cba6f7;#181825;#cdd6f4]" + .. "box[0.25,0.2;10.5,0.55;#313244]label[0.5,0.45;" .. title .. "]" + .. "list[nodemeta;" .. id .. ";main;0.5,1;8," .. rows .. ";]" + .. "list[current_player;main;0.5," .. player_y .. ";8,4;]" + .. "button_exit[8," .. (player_y + 5.1) .. ";2.25,0.65;close;Close]" + .. "listring[nodemeta;" .. id .. ";main]listring[current_player;main]" +end +register_node("storage", { + description = "Homestead storage", + groups = {choppy = 3, wood = 1, flammable = 2, hearth_storage = 1, hearth_non_support = 1}, + on_place = claimed_utility_place, after_place_node = owner, + on_construct = function(pos) + local meta = api.get_meta(pos) + meta:get_inventory():set_size("main", 24) + meta:set_string("infotext", "Homestead storage (24 slots)") + end, + can_dig = storage_can_dig, + on_rightclick = function(pos, _, player) + if not storage_allowed(pos, player) then return end + api.show_formspec(player:get_player_name(), "hearth:storage", + utility_formspec(pos, "HOMESTEAD STORAGE", 3)) + end, + allow_metadata_inventory_put = function(pos, list, index, item, player) + return storage_allowed(pos, player) and item:get_count() or 0 + end, + allow_metadata_inventory_take = function(pos, list, index, item, player) + return storage_allowed(pos, player) and item:get_count() or 0 + end, + allow_metadata_inventory_move = function(pos, from_list, from_index, to_list, to_index, count, player) + return storage_allowed(pos, player) and count or 0 + end, +}) +register_node("pantry", { + description = "Fired-clay pantry (48 slots)", + groups = {cracky = 3, hearth_storage = 1, hearth_non_support = 1}, + on_place = claimed_utility_place, after_place_node = owner, + on_construct = function(pos) + local meta = api.get_meta(pos) + meta:get_inventory():set_size("main", 48) + meta:set_string("infotext", "Fired-clay pantry (48 slots; store shelf-stable food here)") + end, + can_dig = storage_can_dig, + on_rightclick = function(pos, _, player) + if not storage_allowed(pos, player) then return end + api.show_formspec(player:get_player_name(), "hearth:storage", + utility_formspec(pos, "FIRED-CLAY PANTRY", 6)) + end, + allow_metadata_inventory_put = function(pos, list, index, item, player) + return storage_allowed(pos, player) and item:get_count() or 0 + end, + allow_metadata_inventory_take = function(pos, list, index, item, player) + return storage_allowed(pos, player) and item:get_count() or 0 + end, + allow_metadata_inventory_move = function(pos, from_list, from_index, to_list, to_index, count, player) + return storage_allowed(pos, player) and count or 0 + end, +}) +register_node("drying_rack", { + description = "Sheltered drying rack", drawtype = "nodebox", + paramtype = "light", walkable = false, sunlight_propagates = true, + node_box = {type = "fixed", fixed = {{-0.45,-0.5,-0.08,0.45,-0.38,0.08},{-0.4,-0.4,-0.06,0.4,0.35,0.06}}}, + groups = {choppy = 3, wood = 1, flammable = 2}, on_place = claimed_utility_place, after_place_node = owner, +}) +api.register_craftitem("hearth:cloak", { + description = "Woven rain cloak (click to wear or remove)", stack_max = 1, + on_use = function(stack, player) + local meta = player:get_meta() + local worn = meta:get_int("hearth:cloak") ~= 1 + meta:set_int("hearth:cloak", worn and 1 or 0) + hearth.notify(player, worn and "You wear the rain cloak; exposure wets you more slowly." or "You remove the rain cloak.") + return stack + end, +}) +register_node("bedroll", { + description = "Woven bedroll (rest under cover)", drawtype = "nodebox", + paramtype = "light", paramtype2 = "facedir", sunlight_propagates = true, + walkable = false, node_box = {type = "fixed", fixed = {-0.42, -0.5, -0.34, 0.42, -0.38, 0.34}}, + selection_box = {type = "fixed", fixed = {-0.48, -0.5, -0.42, 0.48, -0.36, 0.42}}, + groups = {snappy = 3, flammable = 2}, + on_rightclick = function(pos, _, player) + if hearth.start_rest then hearth.start_rest(player, pos) end + end, + after_place_node = owner, +}) -- No hand choppy/cracky capability: timber needs an axe, stone a pick. api.register_item(":", { - type = "none", wield_image = texture("#c7aa83"), wield_scale = {x = 1, y = 1, z = 1}, + type = "none", wield_image = hearth.texture("#c7aa83"), wield_scale = {x = 1, y = 1, z = 1}, tool_capabilities = { full_punch_interval = 1, max_drop_level = 0, groupcaps = { @@ -300,90 +633,45 @@ tool("pick", "Flint pick", "#8e9292", { cracky = {times = {[3] = 2.4}, uses = 35, maxlevel = 1}, }, 3, 1.2) tool("spear", "Flint spear", "#aa9a7a", {}, 5, 1.2) - -local function fuel_left(pos, now) - local deadline = tonumber(api.get_meta(pos):get_string("fuel_until")) or 0 - -- Corrupt/future metadata cannot produce an unbounded fuel reserve. - if deadline ~= deadline then return 0 end - return math.max(0, math.min(MAX_FUEL, deadline - now)) -end - -local function refresh_fire(pos, remaining, now) - local meta = api.get_meta(pos) - meta:set_string("fuel_until", tostring(remaining > 0 and now + remaining or 0)) - meta:set_string("infotext", remaining > 0 - and ("Last Hearth: " .. math.ceil(remaining) .. "s fuel; feed sticks/logs, cook forage or boil water") - or "Cold hearth: feed dry sticks or a log") - local node = api.get_node(pos) - local name = remaining > 0 and "hearth:fire_lit" or "hearth:fire" - if node.name ~= name then - api.swap_node(pos, {name = name, param2 = node.param2}) - end - if remaining > 0 then - api.get_node_timer(pos):start(math.min(5, remaining)) - else - api.get_node_timer(pos):stop() - end -end - -local function fire_tick(pos) - local now = os.time() - refresh_fire(pos, fuel_left(pos, now), now) - return false -- refresh_fire schedules the next absolute-deadline check. -end - -local function use_fire(pos, _, player, stack) - if not allowed(pos, player) or stack:is_empty() then return stack end - local now = os.time() - local remaining = fuel_left(pos, now) - local item = stack:get_name() - local burn = FUEL[item] - if burn then - if remaining + burn <= MAX_FUEL then - stack:take_item(1) - remaining = remaining + burn - else - hearth.notify(player, "The hearth has no room for that much fuel yet.") - end - else - local cost, result - if item == "hearth:raw_food" then - cost, result = COOK_COST, "hearth:cooked_food" - elseif item == "hearth:water_raw" then - cost, result = BOIL_COST, "hearth:water_clean" - end - if cost then - local inv = player:get_inventory() - -- A multi-item cooking stack needs room before either input or fuel - -- is touched. Single-item inputs are replaced directly in the hand. - if remaining < cost then - hearth.notify(player, "Add fuel before cooking or boiling water.") - elseif stack:get_count() == 1 then - stack:replace(result) - remaining = remaining - cost - elseif inv and inv:room_for_item("main", result) then - local leftover = inv:add_item("main", result) - if leftover:is_empty() then - stack:take_item(1) - remaining = remaining - cost - end - else - hearth.notify(player, "Make inventory space for the cooked food.") - end - end - end - refresh_fire(pos, remaining, now) - return stack -end +api.register_tool("hearth:iron_axe", { + description = "Iron axe", inventory_image = item_image("iron_axe"), + tool_capabilities = {full_punch_interval = 0.9, max_drop_level = 2, + groupcaps = {choppy = {times = {[3] = 0.35, [2] = 0.8, [1] = 1.4}, uses = 120, maxlevel = 2}}, + damage_groups = {fleshy = 6}}, +}) +api.register_tool("hearth:copper_pick", { + description = "Copper pick", inventory_image = item_image("copper_pick"), + tool_capabilities = {full_punch_interval = 1.2, max_drop_level = 1, + groupcaps = {cracky = {times = {[3] = 1.4}, uses = 70, maxlevel = 1}}, + damage_groups = {fleshy = 3}}, +}) +api.register_tool("hearth:firestarter", { + description = "Flint firestarter (20 ignitions)", inventory_image = item_image("firestarter"), + tool_capabilities = {full_punch_interval = 1, max_drop_level = 0, + groupcaps = {}, damage_groups = {fleshy = 1}}, + on_use = processes.use_firestarter, + on_place = processes.use_firestarter, + on_secondary_use = processes.use_firestarter, +}) for _, lit in ipairs({false, true}) do register_node(lit and "fire_lit" or "fire", { description = "Stone hearth", drawtype = "nodebox", paramtype = "light", - tiles = {texture(lit and "#dd9952" or "#6e695e")}, node_box = {type = "fixed", fixed = {-0.5, -0.5, -0.5, 0.5, -0.15, 0.5}}, light_source = lit and 10 or 0, drop = "hearth:fire", - groups = {cracky = 3, dig_immediate = 2, not_in_creative_inventory = lit and 1 or nil}, - on_construct = function(pos) refresh_fire(pos, 0, os.time()) end, + groups = {cracky = 3, dig_immediate = 2, hearth_non_support = 1, + not_in_creative_inventory = lit and 1 or nil}, + can_dig = function(pos, player) + if not can_dig(pos, player) then return false end + local meta = api.get_meta(pos) + return meta:get_string("job_kind") == "" + and meta:get_string("job_result") == "" + end, + on_construct = function(pos) + local meta = api.get_meta(pos) + meta:set_int("lit", 0) + refresh_fire(pos, 0, os.time()) + end, after_place_node = owner, on_rightclick = use_fire, on_timer = fire_tick, -- There is deliberately no hearth inventory/formspec. Deny injected -- inventory actions as well, including actions against legacy metadata. @@ -393,14 +681,14 @@ for _, lit in ipairs({false, true}) do }) end api.register_lbm({ - name = "hearth:expire_fire", label = "Reconcile real-time hearth fuel", + name = "hearth:expire_fire", label = "Reconcile hearth fuel and queued processes", nodenames = {"hearth:fire", "hearth:fire_lit"}, run_at_every_load = true, action = fire_tick, }) api.register_craftitem("hearth:vessel", { description = "Carved stone cup (place on water to fill)", - inventory_image = texture("#a19a88"), stack_max = 1, liquids_pointable = true, + stack_max = 1, liquids_pointable = true, on_place = function(stack, player, pointed) if not pointed or pointed.type ~= "node" then return stack end local pos = pointed.under @@ -414,7 +702,7 @@ api.register_craftitem("hearth:vessel", { }) api.register_craftitem("hearth:water_raw", { description = "Stone cup of untreated water (boil at a hearth)", - inventory_image = texture("#527e82"), stack_max = 1, + stack_max = 1, -- Primary use/placement on a hearth also works without targeting its top. on_use = function(stack, player, pointed) if pointed and pointed.type == "node" then @@ -436,7 +724,7 @@ api.register_craftitem("hearth:water_raw", { end, }) api.register_craftitem("hearth:water_clean", { - description = "Stone cup of boiled water", inventory_image = texture("#82adb0"), + description = "Stone cup of boiled water", stack_max = 1, on_use = eat({water = 40}, "hearth:vessel"), }) @@ -444,11 +732,24 @@ local recipes = { {"hearth:knife", {{"hearth:loose_stone", "hearth:loose_stone"}, {"", "hearth:sticks"}}}, {"hearth:axe", {{"hearth:loose_stone", "hearth:loose_stone"}, {"hearth:loose_stone", "hearth:sticks"}, {"", "hearth:sticks"}}}, {"hearth:pick", {{"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}, {"", "hearth:sticks", ""}, {"", "hearth:sticks", ""}}}, + {"hearth:copper_pick", {{"hearth:copper_ingot", "hearth:copper_ingot", "hearth:copper_ingot"}, {"", "hearth:sticks", ""}, {"", "hearth:sticks", ""}}}, {"hearth:spear", {{"hearth:loose_stone"}, {"hearth:sticks"}, {"hearth:sticks"}}}, {"hearth:vessel", {{"hearth:loose_stone", "", "hearth:loose_stone"}, {"", "hearth:loose_stone", ""}}}, {"hearth:fire", {{"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}, {"hearth:loose_stone", "", "hearth:loose_stone"}, {"hearth:loose_stone", "hearth:loose_stone", "hearth:loose_stone"}}}, {"hearth:wall 4", {{"hearth:planks", "hearth:planks"}, {"hearth:planks", "hearth:planks"}}}, {"hearth:roof 4", {{"hearth:reeds", "hearth:reeds", "hearth:reeds"}, {"hearth:sticks", "", "hearth:sticks"}}}, + {"hearth:bedroll", {{"hearth:planks", "hearth:planks", "hearth:planks"}, {"hearth:reeds", "hearth:reeds", "hearth:reeds"}}}, + {"hearth:foundation 4", {{"hearth:stone", "hearth:stone"}, {"hearth:stone", "hearth:stone"}}}, + {"hearth:frame 4", {{"hearth:planks", "hearth:sticks"}, {"hearth:sticks", "hearth:planks"}}}, + {"hearth:stairs 4", {{"hearth:planks", "", ""}, {"hearth:planks", "hearth:planks", ""}, {"hearth:planks", "hearth:planks", "hearth:planks"}}}, + {"hearth:door", {{"hearth:planks", "hearth:planks"}, {"hearth:planks", "hearth:planks"}, {"hearth:planks", "hearth:planks"}}}, + {"hearth:window", {{"hearth:planks", "hearth:reeds", "hearth:planks"}, {"hearth:planks", "hearth:reeds", "hearth:planks"}}}, + {"hearth:vent", {{"hearth:planks", "hearth:reeds", "hearth:planks"}, {"hearth:planks", "", "hearth:planks"}}}, + {"hearth:storage", {{"hearth:planks", "hearth:planks", "hearth:planks"}, {"hearth:planks", "", "hearth:planks"}, {"hearth:planks", "hearth:planks", "hearth:planks"}}}, + {"hearth:pantry", {{"hearth:planks", "hearth:clay_jar", "hearth:planks"}, {"hearth:planks", "", "hearth:planks"}, {"hearth:planks", "hearth:planks", "hearth:planks"}}}, + {"hearth:drying_rack", {{"hearth:sticks", "hearth:sticks", "hearth:sticks"}, {"hearth:reeds", "", "hearth:reeds"}}}, + {"hearth:iron_axe", {{"hearth:iron_ingot", "hearth:iron_ingot"}, {"hearth:iron_ingot", "hearth:sticks"}, {"", "hearth:sticks"}}}, + {"hearth:fish_trap", {{"hearth:sticks", "hearth:reeds", "hearth:sticks"}, {"hearth:reeds", "", "hearth:reeds"}}}, } for _, recipe in ipairs(recipes) do api.register_craft({output = recipe[1], recipe = recipe[2]}) @@ -456,65 +757,43 @@ end api.register_craft({type = "shapeless", output = "hearth:planks 4", recipe = {"hearth:log"}}) api.register_craft({type = "shapeless", output = "hearth:sticks 4", recipe = {"hearth:planks"}}) api.register_craft({type = "shapeless", output = "hearth:raw_food", recipe = {"hearth:berries", "hearth:berries"}}) +api.register_craft({type = "shapeless", output = "hearth:raw_food", recipe = {"hearth:autumn_mushrooms"}}) +api.register_craft({type = "shapeless", output = "hearth:firestarter", + recipe = {"hearth:loose_stone", "hearth:sticks"}}) +api.register_craft({type = "shapeless", output = "hearth:torch 2", + recipe = {"hearth:sticks", "hearth:reeds"}}) +api.register_craft({type = "shapeless", output = "hearth:cloak", + recipe = {"hearth:reeds", "hearth:reeds", "hearth:reeds", "hearth:reeds", "hearth:planks"}}) +api.register_craft({type = "shapeless", output = "hearth:grain_seed", + recipe = {"hearth:grain"}}) +api.register_craft({type = "shapeless", output = "hearth:raw_food", + recipe = {"hearth:grain", "hearth:grain"}}) +api.register_craft({type = "shapeless", output = "hearth:charcoal_charge", + recipe = {"hearth:log", "hearth:clay"}}) +api.register_craft({type = "shapeless", output = "hearth:smelt_charge", + recipe = {"hearth:iron_ore", "hearth:charcoal"}}) +api.register_craft({type = "shapeless", output = "hearth:copper_charge", + recipe = {"hearth:copper_ore", "hearth:charcoal"}}) +-- A mineral route to the same window, not a second node or free reeds. +api.register_craft({type = "shapeless", output = "hearth:window", + recipe = {"hearth:quartz", "hearth:quartz", "hearth:quartz"}}) +api.register_craft({type = "shapeless", output = "hearth:dried_food_charge", + recipe = {"hearth:cooked_food", "hearth:reeds"}}) +api.register_craft({type = "shapeless", output = "hearth:fish_bait 2", + recipe = {"hearth:grain", "hearth:berries"}}) +api.register_craft({type = "shapeless", output = "hearth:fish_drying_charge", + recipe = {"hearth:cooked_fish", "hearth:reeds"}}) +api.register_craft({output = "hearth:clay_jar_charge", recipe = { + {"hearth:clay", "hearth:clay", "hearth:clay"}, {"hearth:clay", "", "hearth:clay"}, + {"", "hearth:clay", ""}}}) --- Do not clear other mods' registrations or change global protection/mapgen. -api.register_alias("mapgen_stone", "hearth:stone") -api.register_alias("mapgen_water_source", "hearth:water_source") -api.register_alias("mapgen_river_water_source", "hearth:water_source") -api.register_biome({ - name = "hearth_temperate", node_top = "hearth:grass", depth_top = 1, - node_filler = "hearth:soil", depth_filler = 3, node_stone = "hearth:stone", - node_water_top = "hearth:water_source", depth_water_top = 1, - node_water = "hearth:water_source", node_river_water = "hearth:water_source", - node_riverbed = "hearth:sand", depth_riverbed = 2, - y_min = 2, y_max = 31000, heat_point = 50, humidity_point = 50, -}) -api.register_biome({ - name = "hearth_riverbank", node_top = "hearth:sand", depth_top = 1, - node_filler = "hearth:sand", depth_filler = 2, node_stone = "hearth:stone", - node_water = "hearth:water_source", node_river_water = "hearth:water_source", - node_riverbed = "hearth:sand", depth_riverbed = 2, - y_min = -31000, y_max = 1, heat_point = 50, humidity_point = 50, -}) +-- One owner for aliases, terrain nodes, biomes, ores and decorations. Succession +-- reads this shared habitat API lazily; do not retain the old registrations. +local biomes = dofile(modpath .. "/biomes.lua")(hearth, api, register_node, construction) +hearth.biomes = biomes.register() +diversity.register_nodes(register_node) +botany.register_nodes(register_node) +-- Species leaves and canonical ecological cubes/sprites must precede variants. +seasons.register() -local function decoration(name, surfaces, ratio, ymin, ymax) - api.register_decoration({ - name = "hearth:" .. name, deco_type = "simple", place_on = surfaces, - sidelen = 16, fill_ratio = ratio, y_min = ymin, y_max = ymax, - decoration = "hearth:" .. name, - }) -end -decoration("loose_stone", {"hearth:grass", "hearth:sand"}, 0.035, 1, 200) -decoration("sticks", {"hearth:grass"}, 0.03, 2, 200) -decoration("berry_bush", {"hearth:grass"}, 0.018, 2, 120) -decoration("reeds", {"hearth:sand", "hearth:grass"}, 0.025, 1, 200) - --- Small seep springs make upland starts viable without handing out supplies. --- Replace one surface block: surrounding earth contains water where level; --- natural slopes can create a short flowing seep instead. -api.register_decoration({ - name = "hearth:seep_springs", deco_type = "simple", place_on = {"hearth:grass"}, - sidelen = 16, fill_ratio = 0.0015, y_min = 3, y_max = 150, - decoration = "hearth:water_source", place_offset_y = -1, flags = "force_placement", -}) - --- Table schematics are x-fastest, then y, then z; no external assets. -local tree = {size = {x = 5, y = 6, z = 5}, data = {}} -for z = 0, 4 do - for y = 0, 5 do - for x = 0, 4 do - local trunk = x == 2 and z == 2 and y < 5 - local canopy = y >= 3 and math.abs(x - 2) + math.abs(z - 2) <= (y == 5 and 1 or 3) - tree.data[#tree.data + 1] = { - name = trunk and "hearth:log" or (canopy and "hearth:leaves" or "air"), - prob = (trunk or canopy) and 255 or 0, - } - end - end -end -api.register_decoration({ - name = "hearth:temperate_trees", deco_type = "schematic", - place_on = {"hearth:grass"}, sidelen = 16, fill_ratio = 0.006, - y_min = 3, y_max = 150, schematic = tree, rotation = "random", - flags = "place_center_x,place_center_z", -}) +hearth.wildlife = dofile(modpath .. "/wildlife.lua")(hearth, api) diff --git a/mods/hearth/course.lua b/mods/hearth/course.lua new file mode 100755 index 0000000..ffc8d06 --- /dev/null +++ b/mods/hearth/course.lua @@ -0,0 +1,261 @@ +-- Disposable M1 acceptance course. MAIN loads this before any player joins. +-- Launcher contract (not implemented here): fresh HEARTH_HOME/course world, +-- hearth_m1_course=true, hearth_m1_course_nonce=, and an exclusive +-- world/.hearth-m1-course-nonce file containing that UUID plus optional LF. +-- Use the summer fixture (UTC 1782043200, clear, latitude 45, longitude 0). +-- A setting alone NEVER authorizes editing an existing survival world. +local C, H = core, hearth +if H.course then return H.course end -- One command, generation and sampler. +local course = {} +H.course = course +local LOW, HIGH = {x = 0, y = 48, z = 0}, {x = 90, y = 58, z = 40} +local START = {x = 5, y = 50.5, z = 5} +local route = { + {15, 51, 5, "Half steps / walk without jumping"}, + {26, 50.5, 5, "One-wide, two-high doorway"}, + {39, 49.5, 10, "Pool / enter, swim, exit both ends"}, + {50, 50.5, 7, "Two-node fall / compare landing"}, + {62, 50.5, 7, "Four-node fall / compare landing"}, + {71, 50.5, 10, "Logs and flint / axe and hand gathering"}, + {79, 50.5, 20, "Construction / wall, roof, support preview"}, + {82, 50.5, 33, "Safe arena / opt in with /practice or /spar"}, + {60, 50.5, 33, "Target lane / measure reach, dodge and brace"}, + {8, 50.5, 33, "Finish / /course report; /course retry"}, +} +local kit = {"hearth:axe", "hearth:knife", "hearth:spear", "hearth:planks 64", + "hearth:wall 64", "hearth:roof 64", "hearth:berries 12", "hearth:water_clean 6"} +local runs, pending = {}, {} +local phase, generation_failed, generation = "idle", false, 0 +local nonce, enrolled +local function finite(n) return type(n) == "number" and n == n and math.abs(n) < math.huge end +local function time() return C.get_us_time() / 1000000 end +local function feedback(player, message, kind) + if H.feedback then H.feedback(player, message, kind or "info") end +end +local function environment_ok(name) + local env = C.settings:get("hearth_environment") + if env == nil then env = C.is_singleplayer() and "local" or "production" end + if env == "production" then return false end -- Even server admins. + if env == "local" then return C.is_singleplayer() == true end + return env == "staging" and (name == nil or C.check_player_privs(name, {server = true}) == true) +end +local function uuid(value) + return type(value) == "string" and #value == 36 + and value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil +end +local function marker_matches(value) + local world = C.get_worldpath():gsub("\\", "/"):gsub("/+$", "") + if world:match("([^/]+)$") ~= "course" then return false end + -- No user-controlled filename, deserialization, code loading or unbounded read. + local ok, result = pcall(function() + local f = io.open(world .. "/.hearth-m1-course-nonce", "rb") + if not f then return false end + local bytes = f:read(65); f:close() + return bytes == value or bytes == value .. "\n" + end) + return ok and result == true +end +local function configured() + return C.settings:get_bool("hearth_m1_course") == true and environment_ok(nil) + and uuid(nonce) and C.settings:get("hearth_m1_course_nonce") == nonce +end +-- Enroll ONLY on a fresh world's first mod load, before normal spawn/claims +-- migration. Never mint a marker from /course or infer one from empty claims. +nonce = C.settings:get("hearth_m1_course_nonce") +if configured() and marker_matches(nonce) and H.storage then + local storage = H.storage + local existing = storage:get_string("m1_course_world_nonce") + if existing == "" and not next(H.players) and not next(H.sessions) then + local fresh = true + for _, key in ipairs({"spawn", "claims", "location", "m0_backup:spawn", + "m0_backup:claims", "m0_backup:location"}) do + if storage:get_string(key) ~= "" then fresh = false end + end + if fresh then storage:set_string("m1_course_world_nonce", nonce) end + end + enrolled = storage:get_string("m1_course_world_nonce") == nonce +end +local function authorized(name) + -- Recheck settings and privileges after asynchronous emergence as well. + return enrolled == true and configured() and environment_ok(name) + and H.storage:get_string("m1_course_world_nonce") == nonce +end +local function live(player, session) + if not player or not player:is_player() then return false end + local name = player:get_player_name() + return C.get_player_by_name(name) == player and H.sessions[name] == session + and H.players[name] ~= nil and session ~= nil and not session.waiting + and player:get_hp() > 0 +end +local function inside(p) + return p and finite(p.x) and finite(p.y) and finite(p.z) + and p.x >= LOW.x and p.x <= HIGH.x and p.y >= LOW.y and p.y <= HIGH.y + and p.z >= LOW.z and p.z <= HIGH.z +end +local function put(x, y, z, node) + local p = {x = x, y = y, z = z} + assert(inside(p), "Course write outside fixed bounds") + C.set_node(p, {name = node}) +end +local slab = "hearth:m1_course_half_step" +if enrolled then + C.register_node(slab, {description = "Disposable course half step", drawtype = "nodebox", + tiles = {H.texture("#a48a61")}, paramtype = "light", walkable = true, + node_box = {type = "fixed", fixed = {-0.5, -0.5, -0.5, 0.5, 0, 0.5}}, + groups = {choppy = 3, not_in_creative_inventory = 1}, drop = ""}) +end +local function generate() + -- Full loaded preflight BEFORE the first write. The volume is fixed (41041 + -- nodes); no terrain search, on_generated callback, or per-player generation. + for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do + local n = C.get_node_or_nil({x = x, y = y, z = z}) + if not n or n.name == "ignore" then return false end + end end end + for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do + local edge = x == 0 or x == 90 or z == 0 or z == 40 + put(x, y, z, (y <= 50 or (edge and y <= 53)) and "hearth:stone" or "air") + end end end + -- .5-node rises, full-node steps and a descending ramp with no autojump need. + for x = 11, 18 do for z = 4, 6 do + local height = math.min(x - 10, 19 - x) + for y = 51, 50 + math.ceil(height / 2) do + put(x, y, z, height % 2 == 1 and y == 50 + math.ceil(height / 2) and slab or "hearth:planks") + end + end end + -- Exactly one node wide, two clear nodes high; optional separate snag. + for z = 2, 8 do for y = 51, 53 do + if z ~= 5 or y == 53 then put(26, y, z, "hearth:wall") end + end end + if C.settings:get_bool("hearth_m1_course_snag") == true then + for x = 28, 30 do put(x, 52, 9, "hearth:roof") end + end + -- Contained two-deep pool, one-deep entry/exit shelves. + for x = 34, 44 do for z = 7, 13 do + for y = (x == 34 or x == 44) and 50 or 49, 50 do put(x, y, z, "hearth:water_source") end + end end + -- Approach ramps to fall platforms, then open landing pads at floor level. + for _, fall in ipairs({{46, 2}, {56, 4}}) do + for i = 0, fall[2] do for z = 6, 8 do + for y = 51, 50 + i do put(fall[1] + i, y, z, "hearth:planks") end + end end + end + for x = 68, 72, 2 do put(x, 51, 10, "hearth:log"); put(x, 51, 12, "hearth:loose_stone") end + -- Distinct construction anchor; arena/target lane remain clear and dry. + put(77, 51, 20, "hearth:wall") + for x = 8, 66, 2 do put(x, 50, 33, "hearth:planks") end + for x = 73, 87 do for z = 28, 38 do put(x, 50, z, "hearth:planks") end end + return true +end +local function append(buffer, value) + buffer.index = buffer.index % 600 + 1 + buffer[buffer.index] = value + buffer.count = math.min(600, buffer.count + 1) +end +local function samples() return {index = 0, count = 0} end +local function percentile(buffer) + if buffer.count == 0 then return "n/a" end + local sorted = {} + for i = 1, buffer.count do sorted[i] = buffer[i] end + table.sort(sorted) + return string.format("%.2f", sorted[math.ceil(#sorted * 0.95)]) +end +function course.report(name) + local run = runs[name] + if not run then return "No course run in this session. Use /course." end + return string.format("Course %s: %.1fs; checkpoints %d/%d. Engine globalstep dt p95 %sms (last %d samples); course Lua callback wall duration p95 %sus. Not client FPS or CPU profiling. Hits: not instrumented.", + run.finished and "complete" or "running", (run.finished or time()) - run.started, + run.checkpoint, #route, percentile(run.dt), run.dt.count, percentile(run.lua)) +end +local function begin(player, session) + local name = player:get_player_name() + if not authorized(name) or not live(player, session) then return end + -- Intentionally replaces the kit/needs only in the enrolled disposable world. + player:get_inventory():set_list("main", kit) + for key, value in pairs({food = 100, water = 100, warmth = 75, wet = 0, stamina = 100}) do + H.players[name][key] = value + end + player:set_hp(20, {type = "set_hp", from = "mod"}) + player:set_pos({x = START.x, y = START.y, z = START.z}) + player:set_velocity({x = 0, y = 0, z = 0}) + -- Keep session combat cooldowns intact; teleport sampling is handled by combat. + runs[name] = {player = player, session = session, started = time(), checkpoint = 0, + dt = samples(), lua = samples(), next_check = 0} + feedback(player, "Course started / east: half steps, doorway, pool; /course report", "success") + C.chat_send_player(name, "Disposable course kit. Walk east through steps/doorway/pool, 2/4-node falls, logs/flint, build pad, arena then west along target lane. Use /practice or /spar explicitly; no automatic opponent. /course report shows server dt, not FPS; /course retry resets kit/needs/timer. Optional snag is at (29,51,9).") +end +local function request(player, session) + local name = player:get_player_name() + if phase == "ready" then begin(player, session); return true, "Course started. /course report for telemetry." end + if phase == "failed" then return false, "Course generation failed; relaunch a fresh disposable course world." end + pending[name] = {player = player, session = session} + if phase == "loading" then return true, "Course emergence pending." end + phase, generation_failed = "loading", false + generation = generation + 1 + local ticket = generation + C.emerge_area(LOW, HIGH, function(_, action, left) + if phase ~= "loading" or ticket ~= generation then return end + if action == (C.EMERGE_CANCELLED or 0) or action == (C.EMERGE_ERRORED or 1) then generation_failed = true end + if left ~= 0 then return end + -- An opt-in request must still be live and authorized before ANY writes. + local consent = false + for n, entry in pairs(pending) do + if authorized(n) and live(entry.player, entry.session) then consent = true end + end + if not consent then pending, phase = {}, "idle"; return end + local ok, built = false, false + if not generation_failed then ok, built = pcall(generate) end + phase = ok and built and "ready" or "failed" + local queued = pending; pending = {} + for n, entry in pairs(queued) do + if authorized(n) and live(entry.player, entry.session) then + if phase == "ready" then begin(entry.player, entry.session) + else C.chat_send_player(n, "Course generation failed; relaunch a fresh disposable course world.") end + end + end + end) + return true, "Preparing bounded disposable course; inventory changes only after emergence." +end +C.register_chatcommand("course", { + params = "[retry|report]", description = "Opt in to the disposable M1 course (never production)", + func = function(name, param) + if not authorized(name) then return false, "Course denied: launch a fresh marked local/staging course world; staging requires server privilege." end + local player, session = C.get_player_by_name(name), H.sessions[name] + if not live(player, session) then return false, "Join, finish arrival and be alive before opting in." end + if param == "report" then return true, course.report(name) end + if param ~= "" and param ~= "retry" then return false, "Use /course, /course retry or /course report." end + local count = 0 + for _ in pairs(runs) do count = count + 1 end + for n in pairs(pending) do if not runs[n] then count = count + 1 end end + if not runs[name] and not pending[name] and count >= 16 then return false, "Course session limit reached (16)." end + return request(player, session) + end, +}) +C.register_globalstep(function(dt) + if not finite(dt) or dt <= 0 then return end + for name, run in pairs(runs) do + if not authorized(name) or not live(run.player, run.session) then runs[name] = nil + elseif not run.finished then + local before = C.get_us_time() + local t = before / 1000000 + append(run.dt, dt * 1000) -- Engine-provided step interval, NOT Lua CPU time. + if t - run.started > 1800 then runs[name] = nil + elseif t >= run.next_check then + run.next_check = t + 0.1 -- No catch-up loop or terrain work in globalstep. + local p, checkpoint = run.player:get_pos(), route[run.checkpoint + 1] + if inside(p) and (p.x-checkpoint[1])^2 + (p.y-checkpoint[2])^2 + (p.z-checkpoint[3])^2 <= 25 then + run.checkpoint = run.checkpoint + 1 + feedback(run.player, "Checkpoint " .. run.checkpoint .. "/" .. #route .. " / " .. checkpoint[4], "success") + if run.checkpoint == #route then run.finished = t end + end + end + append(run.lua, math.max(0, C.get_us_time() - before)) + end + end +end) +C.register_on_leaveplayer(function(player) + local name = player:get_player_name(); runs[name], pending[name] = nil, nil +end) +C.register_on_dieplayer(function(player) + local name = player:get_player_name(); runs[name], pending[name] = nil, nil +end) +return course diff --git a/mods/hearth/diversity.lua b/mods/hearth/diversity.lua new file mode 100755 index 0000000..e9c0773 --- /dev/null +++ b/mods/hearth/diversity.lua @@ -0,0 +1,230 @@ +-- Species/niche policy only. Succession owns the one fixed-eight scheduler. +return function(hearth, api) + local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce"}, + plants={"clover","nettle","heather","bluebell","sedge","bulrush","moss","lichen"}, + grounds={grass=true,soil=true,forest_floor=true,podzol=true,alluvium=true,peat=true,alpine_turf=true}, + rocks={stone=true,granite=true,basalt=true,limestone=true,slate=true,sandstone=true,scree=true}} + local species, flora = {}, {} + local function kind(value) + if hearth.seasons then local k=hearth.seasons.kind(value); if k then return k end end + local name=type(value)=="table" and value.name or value + return type(name)=="string" and (name:gsub("^hearth:","")) or nil + end + for _, s in ipairs(D.species_order) do species[s]=true end + for _, plant in ipairs(D.plants) do flora[plant]=true end + function D.tree_info(value) + local k=kind(value) + local s,stage=(k or ""):match("^([a-z]+)_([a-z]+)$") + if species[s] and ({seedling=true,young=true,mature=true})[stage] then return s,stage end + end + function D.family(value) + local k=kind(value) + local s,stage=D.tree_info(k) + if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end + local wood,part=(k or ""):match("^([a-z]+)_(leaves)$") + if species[wood] then return part end + wood,part=(k or ""):match("^([a-z]+)_(log)$") + return species[wood] and part or k + end + function D.is_worldgen_flora(value) return flora[kind(value)]==true or D.tree_info(value)~=nil end + function D.is_ground(value) return D.grounds[kind(value)]==true end + function D.is_rock(value) return D.rocks[kind(value)]==true end + function D.supports_ground(value, support) + if kind(value)=="lichen" then return D.is_rock(support) or D.is_ground(support) end + return D.is_ground(support) + end + local soils={oak={grass=true,soil=true,forest_floor=true}, + birch={grass=true,soil=true,forest_floor=true,podzol=true}, + pine={podzol=true,alpine_turf=true}, willow={alluvium=true,peat=true}, + alder={alluvium=true,peat=true,forest_floor=true}, + beech={grass=true,soil=true,forest_floor=true}, aspen={grass=true,forest_floor=true,alluvium=true}, + rowan={grass=true,forest_floor=true,podzol=true,alpine_turf=true}, + hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true}} + function D.supports_species(s, support) + if not species[s] then return false end + local b=hearth.biomes + if b and b.supports_species then return b.supports_species(s,support)==true end + return soils[s][kind(support)]==true + end + function D.suitable(value, h, season) + local k=kind(value) + local light,moisture=h.light or 15,h.moisture or 0.45 + local s=D.tree_info(k) + if s then + if not D.supports_species(s,h.ground) then return false end + if s=="pine" then return moisture<0.8 and light>=5 end + if s=="spruce" then return moisture>=0.4 and moisture<=0.85 and light>=4 end + if s=="beech" then return moisture>=0.3 and moisture<=0.7 and light>=4 end + if s=="willow" or s=="alder" then return moisture>=0.65 and light>=5 end + return moisture>=0.25 and moisture<0.85 and light>=5 + end + if not D.supports_ground(k,h.ground) then return false end + if k=="lichen" then return light>=11 and moisture<0.6 end + if k=="clover" then return light>=10 and moisture>=0.3 and moisture<0.75 end + if k=="nettle" then return light>=6 and moisture>=0.5 and moisture<0.9 and (h.nutrients or 0)>0 end + if k=="heather" then return (h.ground=="podzol" or h.ground=="alpine_turf") and light>=9 and moisture<0.6 end + if k=="bluebell" then return light>=4 and light<=11 and moisture>=0.45 and moisture<0.85 end + if k=="sedge" or k=="bulrush" then return moisture>=0.75 and light>=7 end + if k=="moss" then return light<=9 and moisture>=0.6 end + return true + end + function D.edible(value, now) + local k=kind(value) + local available=not hearth.seasons or hearth.seasons.available(k,now) + return available and ({meadow_grass=true,fern=true,clover=true,sedge=true,nettle=true})[k]==true + end + function D.pollinator(value, season) + local k=kind(value) + return (season=="Spring" or season=="Summer") and (k=="clover" or k=="wildflowers" + or (k=="bluebell" and season=="Spring")) or (k=="heather" and season=="Autumn") + end + function D.choose_tree(p,h) + local choices + if h.ground=="podzol" or h.ground=="alpine_turf" then + choices=(h.ground=="podzol" and (h.heat or 20)>55) and {"birch","pine"} or {"pine","birch"} + elseif (h.moisture or 0)>=0.75 or h.ground=="alluvium" or h.ground=="peat" then + choices=(h.ground=="peat" or (p.x+p.z)%2~=0) and {"alder","willow"} or {"willow","alder"} + elseif h.ground=="forest_floor" and (h.canopy or 0)>=0.5 then choices={"oak","birch"} + else choices={"birch","oak"} end + -- Retain the old pioneer niches at the origin while admitting all ten + -- species deterministically; no random reroll or extra world scan. + local extra=({"beech","aspen","rowan","hazel","spruce"})[math.abs(p.x*7+p.z*11)%10] + if extra and D.suitable(extra.."_seedling",h) then return extra.."_seedling" end + for _,s in ipairs(choices) do if D.suitable(s.."_seedling",h) then return s.."_seedling" end end + end + -- Read-only geometry handoff. Fresh offset records, root at (0,0,0), no air + -- writes. The ecology owner MUST preflight every destination/support before + -- applying a plan, track its own components and refuse foreign/player nodes. + -- "generic" uses canonical wood with the oak silhouette for legacy trees. + function D.tree_plan(s,stage) + stage=stage or "mature" + if (not species[s] and s~="generic") or (stage~="young" and stage~="mature") then return nil end + local b=hearth.biomes + if not b or not b.tree_schematic then return nil end + local schematic=b.tree_schematic(s=="generic" and "oak" or s,s=="generic",stage) + local plan={size={x=schematic.size.x,y=schematic.size.y,z=schematic.size.z}} + local cx,cz=math.floor(schematic.size.x/2),math.floor(schematic.size.z/2) + local root=s=="generic" and (stage=="young" and "young_tree" or "mature_tree") or s.."_"..stage + for z=0,schematic.size.z-1 do for y=0,schematic.size.y-1 do for x=0,schematic.size.x-1 do + local cell=schematic.data[1+x+schematic.size.x*(y+schematic.size.y*z)] + if cell.prob==255 and cell.name~="air" then + plan[#plan+1]={x=x-cx,y=y,z=z-cz,name=(x==cx and y==0 and z==cz) and "hearth:"..root or cell.name, + param2=cell.param2 or 0} + end + end end end + return plan + end + -- Deterministic niches give useful diversity on first active establishment; + -- not a random reroll, tree replacement, or a second mapgen/scanning pass. + function D.choose_recruit(p,h,season) + if not h.loaded or season=="Winter" then return nil end + local hash=math.abs(p.x*3+p.z*5)%8 + local candidates + if D.rocks[h.ground] then candidates={"lichen"} + elseif h.moisture>=0.75 then candidates={hash%2==0 and "sedge" or "bulrush","moss"} + elseif h.light<=9 then candidates={season=="Spring" and "bluebell" or "moss","moss"} + elseif h.ground=="podzol" or h.ground=="alpine_turf" then candidates={"heather","lichen"} + else candidates={({[1]="clover",[2]="nettle",[3]="bluebell",[4]="clover"})[hash]} end + for _,candidate in ipairs(candidates) do + if D.suitable(candidate,h,season) and (candidate~="bluebell" or season=="Spring") then return candidate end + end + end + local function unmarked(p) + local m=api.get_meta(p) + if m.to_table then for field,value in pairs((m:to_table() or {}).fields or {}) do + if field:sub(1,5)~="succ_" and value~="" and tostring(value)~="0" then return false end + end end + for _,field in ipairs({"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at"}) do + local v=m:get_string(field); if v~="" and v~="0" then return false end + end + if hearth.claim_owner_at then local owner,ambiguous=hearth.claim_owner_at(p); if owner or ambiguous then return false end end + local protect=hearth.external_protected or api.is_protected + return not (protect and protect(p,"")) + end + function D.bootstrap(p,n) + if type(p)~="table" then return false end + for _,axis in ipairs({"x","y","z"}) do + local v=p[axis] + if type(v)~="number" or v~=v or math.abs(v)>30990 then return false end + end + local current=api.get_node_or_nil(p) + if not current or (n and current.name~=n.name) then return false end + n=current + -- Only fallen logs join the existing flora adoption policy; generic + -- litter/fungi and standing log/leaves trees are not newly adopted. + if not n or not (D.is_worldgen_flora(n) or n.name=="hearth:deadwood") or not unmarked(p) then return false end + local below={x=p.x,y=p.y-1,z=p.z} + local support=api.get_node_or_nil(below) + if not support or not D.supports_ground(n,support) or not unmarked(below) then return false end + local m=api.get_meta(p) + if m:get_string("succ_natural")=="1" then return false end + m:set_string("succ_natural","1") + return true + end + local seasons=hearth.seasons + if seasons and seasons.add_variants then + for _,s in ipairs(D.species_order) do + local evergreen=s=="pine" or s=="spruce" + for _,stage in ipairs({"seedling","young","mature","leaves"}) do + local definitions={} + if not evergreen then for _,season in ipairs({"Spring","Autumn","Winter"}) do + definitions[season]={texture=stage=="leaves" and ("hearth_"..s.."_leaves_"..season:lower()..".png") + or (stage=="seedling" and ("hearth_botany_"..s..".png") or ("hearth_"..s.."_log_top.png"))} + end end + seasons.add_variants(s.."_"..stage,definitions,evergreen and {Spring=true,Summer=true,Autumn=true,Winter=true} + or {Spring=true,Summer=true,Autumn=true}) + end + end + for _,plant in ipairs(D.plants) do + local definitions,active={},nil + if plant~="moss" and plant~="lichen" then + definitions.Winter={texture="hearth_"..plant.."_dormant.png"} + if plant=="bluebell" then + definitions.Summer={texture="hearth_bluebell_dormant.png"} + definitions.Autumn={texture="hearth_bluebell_dormant.png"}; active={Spring=true} + end + end + seasons.add_variants(plant,definitions,active) + end + end + local registered=false + function D.register_nodes(register_node) + if registered then return end + registered=true + local function placed(p) api.get_meta(p):set_string("player_placed","1") end + for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do + local woody=stage~="seedling" + local def={description=s:gsub("^%l",string.upper).." "..stage.." tree", + drawtype=woody and "normal" or "plantlike", + tiles=woody and {"hearth_"..s.."_log_top.png","hearth_"..s.."_log_top.png","hearth_"..s.."_log_side.png"} + or {"hearth_botany_"..s..".png"}, + paramtype=not woody and "light" or nil,paramtype2=woody and "facedir" or nil, + sunlight_propagates=not woody,use_texture_alpha=not woody and "clip" or nil,walkable=woody,buildable_to=false, + groups=woody and {tree=1,choppy=2,flammable=2,hearth_ecosystem=1,not_in_creative_inventory=1} + or {snappy=3,flora=1,flammable=2,hearth_ecosystem=1}, + drop=woody and "hearth:"..s.."_log" or "",after_place_node=placed} + register_node(s.."_"..stage,def) + -- Preserve existing seasonal stage IDs in saves. Register bark cubes + -- here so the foliage-only seasonal copier never flattens their three + -- face textures into one atlas/tile. Seasons still owns name selection. + if seasons and woody and s~="pine" and s~="spruce" then + for _,season in ipairs({"spring","autumn","winter"}) do + local variant={}; for k,v in pairs(def) do variant[k]=v end + register_node(s.."_"..stage.."_"..season,variant) + end + end + end end + for _,plant in ipairs(D.plants) do + register_node(plant,{description=plant:gsub("^%l",string.upper),drawtype="plantlike",tiles={"hearth_"..plant..".png"}, + paramtype="light",sunlight_propagates=true,use_texture_alpha="clip",walkable=false,buildable_to=false, + groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed}) + end + local names={"hearth:deadwood"} + for k in pairs(flora) do names[#names+1]="hearth:"..k end + for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do names[#names+1]="hearth:"..s.."_"..stage end end + if seasons then for _,n in ipairs(seasons.names()) do if D.is_worldgen_flora(n) then names[#names+1]=n end end end + api.register_lbm({name="hearth:diversity_natural_flora",label="Mark unmodified species worldgen flora", + nodenames=names,run_at_every_load=true,action=function(p,n) D.bootstrap(p,n) end}) + end + return D +end diff --git a/mods/hearth/ecology.lua b/mods/hearth/ecology.lua new file mode 100755 index 0000000..02f6dc3 --- /dev/null +++ b/mods/hearth/ecology.lua @@ -0,0 +1,230 @@ +-- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops. +return function(hearth, api, construction) + local DAY, MAX_CATCHUP = 86400, 30 * 86400 + local CROP_STEP, CROP_MATURE_HOURS = 21600, 60 + local function finite(value) + value = tonumber(value) + if not value or value ~= value or value == math.huge or value == -math.huge then return nil end + return value + end + local function safe_now() + local now = os.time() + if type(now) ~= "number" or now ~= now or now < 0 or now == math.huge or now == -math.huge then return 0 end + return math.floor(now) + end + local function node(pos) + local n = api.get_node_or_nil(pos) + if not n or n.name == "ignore" then return nil end + return n + end + local function free_crop(pos, player) + local below, here = node({x=pos.x,y=pos.y-1,z=pos.z}), node(pos) + local here_def = here and api.registered_nodes[here.name] + if not below or not here_def or not here_def.buildable_to then return false end + if below.name ~= "hearth:grass" and below.name ~= "hearth:soil" then return false end + return construction.allowed(pos, player, "build") + end + local function climate_progress(planted, now) + planted, now = finite(planted), finite(now) + if not planted or not now or planted < 0 or now < 0 then return 0 end + planted, now = math.floor(planted), math.floor(now) + now = math.min(math.floor(now), planted + MAX_CATCHUP) + if planted <= 0 or now <= planted then return 0 end + local progress, cursor = 0, planted + while cursor < now and progress < CROP_MATURE_HOURS do + local finish = math.min(now, cursor + CROP_STEP) + local sample = cursor + math.floor((finish - cursor) / 2) + local climate = hearth.climate_at and hearth.climate_at(sample) + local temperature = finite(climate and climate.temperature) or 12 + local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1) + progress = progress + (finish - cursor) / 3600 * rate + cursor = finish + end + return math.min(CROP_MATURE_HOURS, progress) + end + local function crop_stage(planted, now) + local growth = climate_progress(planted, now) + if growth >= CROP_MATURE_HOURS then return 4 end + if growth >= 36 then return 3 end + if growth >= 12 then return 2 end + return 1 + end + local function update_crop(pos) + local current = node(pos) + if not current or not current.name:match("^hearth:crop_[1234]$") then return false end + local meta, now = api.get_meta(pos), safe_now() + local planted = tonumber(meta:get_string("planted_at")) or 0 + if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end + local stage = crop_stage(planted, now) + local target = "hearth:crop_" .. stage + if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end + return stage < 4 + end + local function reconcile_crop(pos) + if update_crop(pos) then api.get_node_timer(pos):start(CROP_STEP) end + end + local function plant_crop(stack, player, pointed) + if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end + local pos = pointed.above + if not free_crop(pos, player) then return stack end + api.set_node(pos, {name = "hearth:crop_1"}) + api.get_meta(pos):set_string("planted_at", tostring(safe_now())) + api.get_meta(pos):set_string("planter", player:get_player_name()) + api.get_node_timer(pos):start(CROP_STEP) + stack:take_item(1) + return stack + end + local function harvest_crop(pos, player) + if not construction.allowed(pos, player, "use") then return end + local n = node(pos) + if not n or n.name ~= "hearth:crop_4" then + hearth.notify(player, "The crop is still growing; seasons and temperature affect its progress.") + return + end + local output = ItemStack("hearth:grain 4") + local inv = player:get_inventory() + if not inv or not inv:room_for_item("main", output) then + hearth.notify(player, "Make room for grain before harvesting.") + return + end + local requested = output:get_count() + local leftover = inv:add_item("main", output) + if not leftover or leftover:get_count() >= requested then + hearth.notify(player, "Harvest stopped; make inventory space and try again.") + return + end + api.set_node(pos, {name = "hearth:soil"}) + -- Harvested farmland is player-created terrain, not a fresh native meadow. + api.get_meta(pos):set_string("player_placed", "1") + end + local function harvest_berries(pos, player) + if not construction.allowed(pos, player, "use") then return end + local n = node(pos) + if not n then return end + if n.name == "hearth:berry_bush_bare" then + hearth.notify(player, "The berry bush is regrowing; return after one real day.") + return + end + local inv = player:get_inventory() + local output = ItemStack("hearth:berries 3") + if not inv or not inv:room_for_item("main", output) then + hearth.notify(player, "Make inventory space before picking these berries.") + return + end + local requested = output:get_count() + local leftover = inv:add_item("main", output) + if not leftover or leftover:get_count() >= requested then return end + api.set_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2}) + local ready = safe_now() + DAY + api.get_meta(pos):set_string("ready_at", tostring(ready)) + api.get_meta(pos):set_string("infotext", "Berry bush: regrowing (one real day)") + api.get_node_timer(pos):start(DAY) + if hearth.invalidate_environment then hearth.invalidate_environment(pos) end + end + local function harvest_seasonal(pos, player) + if not construction.allowed(pos, player, "use") then return false end + local n = node(pos) + local seasons = hearth.seasons + local kind = n and seasons and seasons.kind(n) + local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind] + if not item then return false end + local now, meta = safe_now(), api.get_meta(pos) + local ready = finite(meta:get_string("succ_harvest_ready_at")) or 0 + if not seasons.available(kind, now) or ready > now then + hearth.notify(player, "This patch rests outside its season or until one real day after picking.") + return false + end + local inv, output = player:get_inventory(), ItemStack(item) + if not inv or not inv:room_for_item("main", output) then return false end + -- One item only: no partial multi-item harvest can lose or duplicate food. + local leftover = inv:add_item("main", output) + if not leftover or leftover:get_count() ~= 0 then return false end + meta:set_string("succ_harvest_ready_at", tostring(now + DAY)) + return true + end + local function refresh_bush(pos) + local n = node(pos) + if not n or n.name ~= "hearth:berry_bush_bare" then return false end + local meta, now = api.get_meta(pos), safe_now() + local ready = tonumber(meta:get_string("ready_at")) or 0 + if ready <= now then + api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2}) + meta:set_string("ready_at", "0"); meta:set_string("infotext", "") + api.get_node_timer(pos):stop() + return false + end + return true + end + local grow_sapling + local function reconcile_sapling(pos) + if grow_sapling(pos) then + local planted = tonumber(api.get_meta(pos):get_string("planted_at")) or safe_now() + api.get_node_timer(pos):start(math.max(60, math.min(DAY, planted + 3 * DAY - safe_now()))) + end + end + local function reconcile_bush(pos) + if refresh_bush(pos) then + local ready = tonumber(api.get_meta(pos):get_string("ready_at")) or safe_now() + DAY + api.get_node_timer(pos):start(math.max(1, math.min(DAY, ready - safe_now()))) + end + end + local function tree_clear(pos, owner) + local limit = tonumber(api.settings and api.settings:get("mapgen_limit")) or 512 + if math.abs(pos.x) > limit - 4 or math.abs(pos.z) > limit - 4 or pos.y < -31000 or pos.y > limit - 6 then return false end + for y = 0, 5 do for x = -2, 2 do for z = -2, 2 do + local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z} + local n = node(p) + if not n then return false end + local kind = hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$") + if not (x == 0 and y == 0 and z == 0) and n.name ~= "air" and kind ~= "leaves" then return false end + if not hearth.claim_allowed(p, owner or "", "build") + or (hearth.external_protected and hearth.external_protected(p, owner or "")) then return false end + end end end + return true + end + grow_sapling = function(pos) + local n = node(pos) + if not n or n.name ~= "hearth:sapling" then return false end + local meta, now = api.get_meta(pos), safe_now() + local planted = tonumber(meta:get_string("planted_at")) or 0 + if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end + local owner = meta:get_string("planter") + if now - planted < 3 * DAY or not tree_clear(pos, owner) then return true end + for y = 0, 3 do api.set_node({x=pos.x,y=pos.y+y,z=pos.z},{name="hearth:log"}) end + for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do + local radius = y == 5 and 1 or 2 + if math.abs(x) + math.abs(z) <= radius and not (x == 0 and z == 0 and y < 5) then + local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z} + if api.get_node(p).name == "air" then api.set_node(p, {name="hearth:leaves"}) end + end + end end end + return false + end + local function plant_sapling(stack, player, pointed) + if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end + local pos = pointed.above + if not free_crop(pos, player) then return stack end + api.set_node(pos, {name = "hearth:sapling"}) + local meta = api.get_meta(pos) + meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name()) + api.get_node_timer(pos):start(3 * DAY) + stack:take_item(1) + return stack + end + local function berry_dig(pos, node, digger) + if digger and digger:is_player() then harvest_berries(pos, digger) end + end + local function crop_dig(pos, node, digger) + if digger and digger:is_player() then harvest_crop(pos, digger) end + end + + return {plant_crop = plant_crop, update_crop = update_crop, crop_stage = crop_stage, + climate_progress = climate_progress, + harvest_crop = harvest_crop, crop_dig = crop_dig, harvest_berries = harvest_berries, + berry_dig = berry_dig, + harvest_seasonal = harvest_seasonal, + refresh_bush = refresh_bush, reconcile_bush = reconcile_bush, + reconcile_crop = reconcile_crop, plant_sapling = plant_sapling, + grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling, + MAX_CATCHUP = MAX_CATCHUP, CROP_MATURE_HOURS = CROP_MATURE_HOURS} +end diff --git a/mods/hearth/encounters.lua b/mods/hearth/encounters.lua index 60c701f..45fa182 100755 --- a/mods/hearth/encounters.lua +++ b/mods/hearth/encounters.lua @@ -1,75 +1,270 @@ -local C = core --- A deliberately artificial sparring target: opt-in, persistent, non-lethal. --- No claim of production combat animation/prediction: validates the first loop. -C.register_entity("hearth:practice", { - initial_properties = {physical = true, collide_with_objects = false, - collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = "cube", - visual_size = {x = 0.6, y = 1.6, z = 0.6}, - textures = {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"), - hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")}, - pointable = true, hp_max = 100, static_save = true, - nametag = "PRACTICE / strike to spar · step back to stop", nametag_color = "#e5c890"}, - on_activate = function(self) - self.object:set_armor_groups({immortal = 1}) - self.object:set_acceleration({x = 0, y = -9.81, z = 0}) - self.health, self.cooldowns = 30, {} - end, - on_punch = function(self, puncher) - if not puncher or not puncher:is_player() then return true end - local pos, p = self.object:get_pos(), puncher:get_pos() - if not pos or not p or vector.distance(pos, p) > 3.5 then return true end - if not C.line_of_sight(vector.offset(p, 0, 1.2, 0), vector.offset(pos, 0, 0.8, 0), 0.25) then return true end - local name = puncher:get_player_name() - local state = hearth.players[name] - if not state then return true end - local tool = puncher:get_wielded_item():get_name() - local attacks = {['hearth:spear'] = {5, 1.2}, ['hearth:axe'] = {4, 1.1}, ['hearth:knife'] = {3, 0.7}} - local attack = attacks[tool] or {1, 1} - local now = C.get_us_time() / 1000000 - if now - (self.cooldowns[name] or -100) < attack[2] or state.stamina < 10 then return true end - self.cooldowns[name] = now - state.stamina = state.stamina - 10 - self.health = self.health - attack[1] - -- A faster weapon cannot postpone an already committed counter forever. - if not self.target then self.target, self.delay = name, 0.75 end - self.object:set_properties({nametag = "COUNTER INCOMING / brace or step away", nametag_color = "#f38ba8"}) - if self.health <= 0 then - self.health, self.target = 30, nil - self.object:set_properties({nametag = "ROUND COMPLETE / ready again", nametag_color = "#a6e3a1"}) - hearth.notify(puncher, "Practice complete. Good footwork saves stamina; right-click your spear to brace.") - else hearth.notify(puncher, "Hit confirmed / target " .. self.health .. "/30") end - return true - end, - on_step = function(self, dt) - if not self.target then return end - self.delay = self.delay - math.min(dt, 0.1) - if self.delay > 0 then return end - local player = C.get_player_by_name(self.target) - if player and player:get_hp() > 2 then - local p, pos = player:get_pos(), self.object:get_pos() - if vector.distance(p, pos) < 2.5 and C.line_of_sight(vector.offset(pos, 0, 1, 0), vector.offset(p, 0, 1, 0), 0.25) then - local state, session = hearth.players[self.target], hearth.sessions[self.target] - local blocking = state and session and session.blocking and state.stamina >= 8 - if blocking then state.stamina = state.stamina - 8 end - player:set_hp(math.max(2, player:get_hp() - (blocking and 1 or 3)), {type = "set_hp", from = "mod"}) - hearth.notify(player, blocking and "Braced / reduced impact" or "Counter landed / step back beyond reach") +-- Opt-in, non-lethal training only. No wildlife, drops, node edits or PvP. +local C, M = core, assert(hearth.combat, "Install hearth.combat before encounters") +local moving = {} -- One ephemeral opponent per consenting player; never serialized. +local function now() return C.get_us_time() / 1000000 end +local function offset(p, y) return {x = p.x, y = p.y + y, z = p.z} end +local function copy(p) return {x = p.x, y = p.y, z = p.z} end +local function feedback(player, message, kind) + if hearth.feedback then hearth.feedback(player, message, kind) end +end +local function sound(name, player) + if hearth.feedback_sound then hearth.feedback_sound(name, player, 0.25) + else C.sound_play(name, {pos = player:get_pos(), gain = 0.25, max_hear_distance = 12}, true) end +end +local function live(player) + if not player or not player:is_player() then return false end + local name = player:get_player_name() + return C.get_player_by_name(name) == player and hearth.players[name] ~= nil + and hearth.sessions[name] ~= nil and not hearth.sessions[name].waiting and player:get_hp() > 0 +end +local function allowed(pos, name) + return pos ~= nil and hearth.claim_allowed(pos, name, "combat") == true and not C.is_protected(pos, name) +end +local function eye(player) + return offset(player:get_pos(), player:get_properties().eye_height or 1.55) +end +local function stop(self) + local v = self.object:get_velocity() + self.object:set_velocity({x = 0, y = v.y, z = 0}) +end +local animations = {idle = {0, 19, 12}, approach = {20, 39, 24}, windup = {70, 79, 12}, + strike = {60, 69, 28}, recover = {0, 19, 12}, cooldown = {0, 19, 12}} +local function phase(self, value, t, duration) + self.phase, self.deadline = value, t + (duration or 0) + if value == "approach" then self.approach_until = t + 8 end + if self.mobile then + local f = animations[value] + self.object:set_animation({x = f[1], y = f[2]}, f[3], 0.12, value ~= "strike") + end + local label = value == "windup" and "COUNTER INCOMING / step aside or face + brace" + or (value == "cooldown" and "ROUND COMPLETE / rest 3 seconds" or "PRACTICE / " .. self.health .. "/30") + self.object:set_properties({nametag = label}) +end +local function finish(self, t, player, detached) + if self.phase == "cooldown" then return end + local s = self.target and hearth.sessions[self.target] + if s then + s.spar_ready_at = math.max(s.spar_ready_at or 0, t + 3) + if s.spar_entity == self then s.spar_entity = nil end + end + self.target, self.round_complete, self.direction = nil, false, nil + if not detached then stop(self); phase(self, "cooldown", t, 3) end + if player then feedback(player, "Round complete / rest, then strike to opt in again", "success") end +end +local function begin(self, player, t) + if not live(player) then return false end + local name, s = player:get_player_name(), hearth.sessions[player:get_player_name()] + if t < (s.spar_ready_at or 0) or (s.spar_entity and s.spar_entity ~= self) then return false end + self.target, s.spar_entity = name, self + self.stuck, self.perception, self.last_ai_pos, self.direction = 0, 0, nil, nil + if self.mobile then phase(self, "approach", t) else phase(self, "windup", t, 0.75) end + return true +end +local function punch(self, player) + -- Never trust client damage, elapsed punch time, direction or tool capabilities. + if not live(player) or self.phase == "cooldown" then return true end + local name, t = player:get_player_name(), now() + if (self.mobile and self.owner ~= name) or (self.target and self.target ~= name) then return true end + local state, s, pos = hearth.players[name], hearth.sessions[name], self.object:get_pos() + if not pos or (s.spar_entity and s.spar_entity ~= self) then return true end + local head, center, stack = eye(player), offset(pos, 0.9), player:get_wielded_item() + local attack = M.valid_attack({live = true, hp = player:get_hp(), state = state, session = s, + now = t, weapon = stack:get_name(), origin = player:get_pos(), eye = head, target = center, + look = player:get_look_dir(), allowed = allowed(pos, name) and allowed(player:get_pos(), name), + clear = M.distance(head, center) <= 3.4 and C.line_of_sight(head, center, 0.1) == true}) + if not attack or self.round_complete then return true end + if not self.target and not begin(self, player, t) then return true end + state.stamina = state.stamina - attack.stamina + s.combat_now, s.attack_ready_at, s.strike_until = t, t + attack.cadence, t + 10 / 28 + stack:add_wear(attack.wear); player:set_wielded_item(stack) + self.health = math.max(0, self.health - attack.damage) + sound(attack.sound, player); sound("hearth_impact", player) + feedback(player, "Hit confirmed / target " .. self.health .. "/30", "success") + -- Damage NEVER cancels or restarts a committed windup, even the final hit. + if self.health == 0 then self.round_complete = true end + return true +end +local function strike(self, player) + if not live(player) then return end + local pos, p, name = self.object:get_pos(), player:get_pos(), player:get_player_name() + if not pos or not allowed(pos, name) or not allowed(p, name) then return end + local center, head = offset(pos, 0.9), eye(player) + if M.distance(center, head) > 2.2 or not C.line_of_sight(center, head, 0.1) then + feedback(player, "Counter evaded", "info"); return + end + local state, s = hearth.players[name], hearth.sessions[name] + local guard = M.valid_guard({live = true, hp = player:get_hp(), state = state, session = s, + weapon = player:get_wielded_item():get_name(), controls = player:get_player_control(), + eye = head, target = center, look = player:get_look_dir()}) + if guard then state.stamina = state.stamina - 8 end + if player:get_hp() > 2 then player:set_hp(math.max(2, player:get_hp() - (guard and 1 or 3)), {type = "set_hp", from = "mod"}) end + sound(guard and "hearth_block" or "hearth_impact", player) + feedback(player, guard and "Braced / reduced impact" or "Counter landed / step back or aside", guard and "success" or "warning") +end +local function node_ok(pos, ground) + local node = C.get_node_or_nil(pos) + local def = node and C.registered_nodes[node.name] + if not def or node.name == "ignore" or (def.liquidtype and def.liquidtype ~= "none") + or ((def.groups or {}).water or 0) > 0 or ((def.groups or {}).liquid or 0) > 0 then return false end + return ground and def.walkable == true or (not ground and def.walkable == false) +end +local function safe_step(self, pos, dx, dz) + local nextpos = {x = pos.x + dx * 0.7, y = pos.y, z = pos.z + dz * 0.7} + return M.distance(nextpos, self.home) <= 12 and allowed(nextpos, self.target) + and node_ok(offset(nextpos, -0.2), true) and node_ok(offset(nextpos, 0.1), false) + and node_ok(offset(nextpos, 1.3), false) + and C.line_of_sight(offset(pos, 0.9), offset(nextpos, 0.9), 0.1) == true +end +local function perceive(self, pos, destination, dt) + local dx, dz = destination.x - pos.x, destination.z - pos.z + local length = math.sqrt(dx * dx + dz * dz) + self.direction = nil + if length < 0.1 then return end + dx, dz = dx / length, dz / length + if self.last_ai_pos and M.distance(self.last_ai_pos, pos) < 0.08 then self.stuck = self.stuck + dt + else self.stuck = 0 end + self.last_ai_pos = copy(pos) + if self.stuck > 3 then return end + if self.stuck > 1.5 then + dx, dz = self.home.x - pos.x, self.home.z - pos.z + length = math.sqrt(dx * dx + dz * dz) + if length < 0.1 then return end + dx, dz = dx / length, dz / length + end + for _, d in ipairs({{dx, dz}, {-dz, dx}, {dz, -dx}}) do + if safe_step(self, pos, d[1], d[2]) then self.direction = d; return end + end +end +local function step(self, dt) + dt = math.max(0, math.min(tonumber(dt) or 0, 0.1)) + local t, pos = now(), self.object:get_pos() + if not pos then return end + if self.phase == "cooldown" then + stop(self) + if t >= self.deadline then self.health = 30; phase(self, "idle", t) end + return + end + if not self.target then stop(self); return end + local player = C.get_player_by_name(self.target) + if not live(player) or not allowed(pos, self.target) or not allowed(player:get_pos(), self.target) + or M.distance(player:get_pos(), self.home) > 12 or M.distance(pos, self.home) > 12 then + finish(self, t); return + end + if self.phase == "windup" then + stop(self) + if t >= self.deadline then phase(self, "strike", t, 0.12); strike(self, player) end + elseif self.phase == "strike" then + stop(self); if t >= self.deadline then phase(self, "recover", t, 0.9) end + elseif self.phase == "recover" then + stop(self) + if t >= self.deadline then + if self.round_complete then finish(self, t, player) + elseif self.mobile then phase(self, "approach", t) + else + local s = hearth.sessions[self.target]; if s and s.spar_entity == self then s.spar_entity = nil end + self.target = nil; phase(self, "idle", t) end end - self.target = nil - self.object:set_properties({nametag = "PRACTICE / " .. self.health .. "/30 · strike to spar", nametag_color = "#e5c890"}) - end, -}) + elseif self.phase == "approach" then + -- A moving-but-unproductive avoidance loop also has a hard budget. + if self.round_complete or t >= self.approach_until then finish(self, t, player); return end + self.perception = self.perception + dt + if self.perception >= 0.2 then + self.perception = 0 + local p = player:get_pos() + self.object:set_yaw(C.dir_to_yaw({x = p.x - pos.x, y = 0, z = p.z - pos.z})) + if M.distance(offset(pos, 0.9), eye(player)) <= 2.0 + and C.line_of_sight(offset(pos, 0.9), eye(player), 0.1) then + stop(self); phase(self, "windup", t, 0.75) + feedback(player, "Counter incoming / step aside or face + brace", "warning"); return + end + perceive(self, pos, player:get_pos(), 0.2) + if self.stuck > 3 then finish(self, t, player); return end + end + local d, v = self.direction, self.object:get_velocity() + self.object:set_velocity({x = d and d[1] * 2.2 or 0, y = v.y, z = d and d[2] * 2.2 or 0}) + end +end +local function definition(mobile) + return { + initial_properties = {physical = true, collide_with_objects = false, + collisionbox = {-0.3, 0, -0.3, 0.3, 1.6, 0.3}, visual = mobile and "mesh" or "cube", + mesh = "hearth_person.b3d", textures = mobile and {"hearth_person.png"} + or {hearth.texture("#c7aa83"), hearth.texture("#947853"), hearth.texture("#947853"), + hearth.texture("#947853"), hearth.texture("#947853"), hearth.texture("#947853")}, + visual_size = mobile and {x = 1, y = 1} or {x = 0.6, y = 1.6, z = 0.6}, + pointable = true, hp_max = 100, static_save = not mobile, + nametag = "PRACTICE / strike to opt in", nametag_color = "#e5c890"}, + on_activate = function(self) + self.mobile, self.health, self.perception, self.stuck = mobile, 30, 0, 0 + self.target, self.owner, self.round_complete, self.direction = nil, nil, false, nil + self.last_ai_pos = nil + self.home = copy(self.object:get_pos()) + self.object:set_armor_groups({immortal = 1}) + self.object:set_acceleration({x = 0, y = -9.81, z = 0}) + stop(self); phase(self, "idle", now()) -- Ignore old staticdata; keep identity, reset health. + end, + on_punch = punch, on_step = step, + on_deactivate = function(self) + if self.owner and moving[self.owner] == self then moving[self.owner] = nil end + if self.target then finish(self, now(), nil, true) end + end, + } +end +C.register_entity("hearth:practice", definition(false)) +C.register_entity("hearth:sparring", definition(true)) -C.register_chatcommand("practice", {description = "Place one practice opponent near you (local play only)", - func = function(name) - if not C.is_singleplayer() then return false, "Practice placement is local-play only." end - local player = C.get_player_by_name(name) - if not player then return false end - local pos = vector.round(vector.add(player:get_pos(), vector.multiply(player:get_look_dir(), 3))) +-- Independent movement observations, including before the first hit. +local sample_elapsed = 0 +C.register_globalstep(function(dt) + sample_elapsed = sample_elapsed + math.max(0, math.min(dt, 0.1)) + if sample_elapsed < 0.1 then return end + sample_elapsed = 0 + local t = now() + for _, player in ipairs(C.get_connected_players()) do + if live(player) then + local s = hearth.sessions[player:get_player_name()] + s.combat_sample = M.sample(s.combat_sample, player:get_pos(), t) + end + end +end) +local function deployment_allowed(name, practice) + local single = C.is_singleplayer() + local deployment = C.settings:get("hearth_environment") or (single and "local" or "production") + if deployment == "production" then return false end -- No developer spawning, even for admins. + if practice then return single and deployment == "local" end + return (single and deployment == "local") + or (deployment == "staging" and C.check_player_privs(name, {server = true}) == true) +end +local function spawn(name, mobile) + if not deployment_allowed(name, not mobile) then return false, "Training placement requires local play or privileged staging /spar." end + local player, t = C.get_player_by_name(name), now() + if not live(player) then return false, "Join and finish arrival first." end + local s = hearth.sessions[name] + if t < (s.spar_ready_at or 0) or s.spar_entity then return false, "Finish your round and rest first." end + if mobile and moving[name] and moving[name].object:get_pos() then return false, "Your sparring opponent is already placed; strike it to opt in again." end + local p, look = player:get_pos(), player:get_look_dir() + local length = math.sqrt(look.x^2 + look.z^2) + if length < 0.01 then return false, "Look towards open ground." end + local pos = {x = p.x + look.x / length * 3, y = p.y, z = p.z + look.z / length * 3} + if not allowed(p, name) or not allowed(pos, name) or not node_ok(offset(pos, -0.2), true) + or not node_ok(offset(pos, 0.1), false) or not node_ok(offset(pos, 1.3), false) + or not C.line_of_sight(eye(player), offset(pos, 0.9), 0.1) then return false, "Look at clear, loaded, dry ground outside other claims." end + if not mobile then for _, obj in ipairs(C.get_objects_inside_radius(pos, 30)) do local e = obj:get_luaentity() if e and e.name == "hearth:practice" then return false, "A practice target is already nearby." end end - if C.get_node(pos).name ~= "air" or C.is_protected(pos, name) then return false, "Look at an open space nearby." end - return C.add_entity(pos, "hearth:practice") ~= nil, "Practice target placed. Strike it to start; step away to stop." - end}) + end + local object = C.add_entity(pos, mobile and "hearth:sparring" or "hearth:practice") + if not object then return false, "Could not place training target." end + if mobile then + local e = object:get_luaentity(); e.owner, moving[name] = name, e + begin(e, player, t) + end + return true, mobile and "Sparring started / step back or aside; face with spear + RMB to brace." or "Practice target placed. Strike it to start." +end +C.register_chatcommand("practice", {description = "Place a stationary practice target (local play only)", + func = function(name) return spawn(name, false) end}) +C.register_chatcommand("spar", {description = "Opt in to one moving sparring opponent (local or server-privileged staging only)", + func = function(name) return spawn(name, true) end}) diff --git a/mods/hearth/environment.lua b/mods/hearth/environment.lua new file mode 100755 index 0000000..c8ae277 --- /dev/null +++ b/mods/hearth/environment.lua @@ -0,0 +1,139 @@ +-- Bounded room flood-fill. Revisions invalidate cached geometry on building edits. +local C, M = core, hearth.model +local ROOM_RADIUS, ROOM_BELOW, ROOM_ABOVE = 3, 1, 3 +local MAX_CELLS = (ROOM_RADIUS * 2 + 1) ^ 2 * (ROOM_BELOW + ROOM_ABOVE + 1) +local CACHE_SECONDS, CACHE_LIMIT = 1, 256 +local cache, cache_count, revision = {}, 0, 0 + +local function key(pos) + return math.floor(pos.x + 0.5) .. ":" .. math.floor(pos.y + 0.5) .. ":" .. math.floor(pos.z + 0.5) +end + +local function read(pos) + local node = C.get_node_or_nil(pos) + if not node or node.name == "ignore" then return nil, nil, true end + local def = C.registered_nodes[node.name] + if not def then return node, nil, false end + return node, def, false +end + +local function room_geometry(pos) + local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z}) + local cache_key = key(head) + local now = C.get_us_time() / 1000000 + local old = cache[cache_key] + if old and old.revision == revision and now - old.at < CACHE_SECONDS then + return old.geometry + end + + local minx, maxx = head.x - ROOM_RADIUS, head.x + ROOM_RADIUS + local minz, maxz = head.z - ROOM_RADIUS, head.z + ROOM_RADIUS + local miny, maxy = head.y - ROOM_BELOW, head.y + ROOM_ABOVE + local first_node, first_def, unloaded = read(head) + local geometry = {roof = false, shelter = 0, enclosed = false, ventilated = false, + open_sides = 4, unloaded = unloaded} + local function blocks_room(node, def) + if not def then return true end -- Unknown nodes fail closed as room boundaries. + if node.name == "hearth:door_open" then return false end + return def.walkable ~= false + end + local function has_vent_near(p) + for _, d in ipairs({{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}}) do + local node, def, missing = read({x = p.x + d[1], y = p.y + d[2], z = p.z + d[3]}) + unloaded = unloaded or missing + if node and def and def.groups and (def.groups.hearth_vent or 0) > 0 then return true end + end + return false + end + + -- Flood only passable nodes inside a fixed 7 x 7 x 5 volume. Reaching a + -- horizontal boundary means that side admits weather; an upward/downward + -- escape means the room lacks a ceiling/floor. This never searches map-scale. + local queue, seen, index = {}, {}, 1 + if first_node and first_def and not blocks_room(first_node, first_def) then + queue[1] = head + else + geometry.unloaded = unloaded + local roof = false + for y = 1, 5 do + local _, def, missing = read({x = head.x, y = head.y + y, z = head.z}) + unloaded = unloaded or missing + if def and def.walkable ~= false then roof = true; break end + end + geometry.roof, geometry.unloaded = roof, unloaded + return geometry + end + + local open_sides, ventilated = {}, false + local directions = {{1,0,0},{-1,0,0},{0,1,0},{0,-1,0},{0,0,1},{0,0,-1}} + while index <= #queue and index <= MAX_CELLS do + local p = queue[index]; index = index + 1 + local cell_key = key(p) + if not seen[cell_key] then + seen[cell_key] = true + if p.x == minx then open_sides.west = true end + if p.x == maxx then open_sides.east = true end + if p.z == minz then open_sides.north = true end + if p.z == maxz then open_sides.south = true end + if p.y == maxy then open_sides.sky = true end + if p.y == miny then open_sides.floor = true end + ventilated = ventilated or has_vent_near(p) + for _, d in ipairs(directions) do + local npos = {x = p.x + d[1], y = p.y + d[2], z = p.z + d[3]} + if npos.x >= minx and npos.x <= maxx and npos.z >= minz and npos.z <= maxz + and npos.y >= miny and npos.y <= maxy then + local nkey = key(npos) + if not seen[nkey] then + local node, def, missing = read(npos) + unloaded = unloaded or missing + if node and not blocks_room(node, def) then queue[#queue + 1] = npos end + end + end + end + end + end + if index > MAX_CELLS then open_sides.sky = true end + local horizontal_open = (open_sides.west and 1 or 0) + (open_sides.east and 1 or 0) + + (open_sides.north and 1 or 0) + (open_sides.south and 1 or 0) + local roof = not open_sides.sky + local enclosed = roof and not open_sides.floor and horizontal_open == 0 + local roof_def + for y = 1, 5 do + local _, def, missing = read({x = head.x, y = head.y + y, z = head.z}) + unloaded = unloaded or missing + if def and def.walkable ~= false then roof_def = true; break end + end + roof = roof_def == true + local closed_sides = 4 - horizontal_open + local shelter = enclosed and roof and 1 or (roof and (0.25 + closed_sides * 0.1875) or 0) + if unloaded then enclosed, shelter, ventilated = false, 0, true end + geometry = {roof = roof, shelter = math.max(0, math.min(1, shelter)), enclosed = enclosed, + ventilated = not enclosed or ventilated, open_sides = horizontal_open, unloaded = unloaded} + if cache_count >= CACHE_LIMIT then cache, cache_count = {}, 0 end + cache[cache_key] = {revision = revision, at = now, geometry = geometry} + cache_count = cache_count + 1 + return geometry +end + +function hearth.invalidate_environment() + revision = revision + 1 + cache, cache_count = {}, 0 +end + +function hearth.environment(pos) + local room = room_geometry(pos) + local fire = C.find_node_near(pos, 4, {"hearth:fire_lit"}) + local heat = 0 + if fire then + local deadline = tonumber(C.get_meta(fire):get_string("fuel_until")) or 0 + if M.fuel_remaining(deadline, os.time()) > 0 then + local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z}) + local from = vector.offset(fire, 0, 0.6, 0) + if C.line_of_sight(from, head, 0.5) then heat = 1 - math.min(0.8, vector.distance(pos, fire) / 5) end + end + end + return {roof = room.roof, shelter = room.shelter, enclosed = room.enclosed, + ventilated = room.ventilated, open_sides = room.open_sides, heat = heat, + smoke = heat > 0 and room.enclosed and not room.ventilated, + rain = hearth.weather == "rain", temperature = hearth.clock.temperature - math.max(0, pos.y) * 0.0065} +end diff --git a/mods/hearth/feedback.lua b/mods/hearth/feedback.lua new file mode 100755 index 0000000..26d724c --- /dev/null +++ b/mods/hearth/feedback.lua @@ -0,0 +1,143 @@ +-- M1 presentation only: no metadata, persisted preferences, chat or camera bob. +local C = core +local frames = {idle = {0, 19, 12}, walk = {20, 39, 24}, run = {40, 59, 32}, + strike = {60, 69, 28}, guard = {70, 79, 12}, swim = {80, 99, 20}} +local function now() return C.get_us_time() / 1000000 end +local function session(player) return hearth.sessions[player:get_player_name()] end +local function text(value) + -- Bound HUD payloads and strip control/escape sequences from item descriptions. + return tostring(value or ""):gsub("\27%([^)]*%)", ""):gsub("[%c]", " "):sub(1, 200) +end +function hearth.feedback_sound(name, player, gain) + local spec = {pos = player:get_pos(), gain = gain or 0.25, max_hear_distance = 12} + -- Optional sound adapter contract: sound(name, SoundParameters, ephemeral). + if hearth.sound then return hearth.sound(name, spec, true) end + return C.sound_play(name, spec, true) +end +local colors = {info = 0xcdd6f4, success = 0xa6e3a1, warning = 0xf9e2af, error = 0xf38ba8} +function hearth.feedback(player, message, kind) + local s = session(player); if not s then return end + s.feedback_text, s.feedback_kind, s.feedback_until = text(message), kind, now() + 3 +end +local function target_text(player) + local pos, look = player:get_pos(), player:get_look_dir() + local props = player:get_properties() + local start = {x = pos.x, y = pos.y + (props.eye_height or 1.55), z = pos.z} + local finish = {x = start.x + look.x * 5, y = start.y + look.y * 5, z = start.z + look.z * 5} + -- No object hits and no liquids: water never obscures useful node targeting. + for hit in C.raycast(start, finish, false, false) do + if hit.type == "node" then + local node = C.get_node_or_nil and C.get_node_or_nil(hit.under) or nil + if not node or node.name == "ignore" then return "" end + local def = C.registered_nodes[node.name] + if not def then return "" end + if node.name == "hearth:fire" or node.name == "hearth:fire_lit" then + local status = C.get_meta(hit.under):get_string("infotext") + return text(def.description) .. (status ~= "" and (" | " .. text(status)) or "") + end + local B = hearth.botany + local known = B and B.kind and B.kind(node.name) + local inspect_hint = known and " | /inspect species" or "" + local wield = player:get_wielded_item() + local caps = wield:get_tool_capabilities() + local dig = C.get_dig_params(def.groups or {}, caps, wield:get_wear()) + local wield_def = C.registered_nodes[wield:get_name()] + if wield_def and hearth.construction then + local destination = def.buildable_to and hit.under or hit.above + local ok, reason = hearth.construction.preview(destination, player, wield:get_name()) + local reasons = {ok = "Ready to place", support = "Needs nearby structural support", + protected = "Protected homestead", unloaded = "Terrain not loaded", occupied = "Space occupied", + distance = "Move closer", item = "Not a building material", invalid = "Invalid position"} + return text(wield_def.description):sub(1,120) .. " | " .. text(reasons[reason] or (ok and "Ready to place" or "Cannot place here")) .. inspect_hint + end + return text(def.description):sub(1,140) + .. (dig.diggable and " | Tool suitable" or " | Different tool needed") + .. inspect_hint + end + end + return "" +end +local function surface(player, s) + if s.swimming then return "water" end + local p = player:get_pos() + local feet = C.registered_nodes[C.get_node({x = p.x, y = p.y + 0.1, z = p.z}).name] or {} + if feet.groups and (feet.groups.water or 0) > 0 then return "water" end + local def = C.registered_nodes[C.get_node({x = p.x, y = p.y - 0.1, z = p.z}).name] or {} + local g = def.groups or {} + if (g.water or 0) > 0 then return "water" end + if (g.wood or 0) > 0 or (g.tree or 0) > 0 then return "wood" end + if (g.stone or 0) > 0 or (g.cracky or 0) > 0 then return "stone" end + return "soil" +end +function hearth.feedback_step(player, dt) + if hearth.presentation_step then hearth.presentation_step(player) end + local s = session(player); if not s or s.waiting or player:get_hp() <= 0 then return end + dt = hearth.movement.delta(dt) + local t, pos, velocity = now(), player:get_pos(), player:get_velocity() + local controls = player:get_player_control() + -- Rising edge only; holding dig never replays a strike every frame. Combat + -- may set session.strike_until using the same monotonic seconds clock. + if controls.LMB and not s.feedback_lmb then + s.strike_until = t + 10 / 28 + local swing = ({["hearth:knife"] = "hearth_swing_knife", + ["hearth:axe"] = "hearth_swing_axe", ["hearth:spear"] = "hearth_swing_spear"}) + local sound = swing[player:get_wielded_item():get_name()] + if sound then hearth.feedback_sound(sound, player, 0.18) end + end + s.feedback_lmb = controls.LMB + local animation = s.swimming and "swim" or (s.blocking and "guard" + or ((s.strike_until or 0) > t and "strike" or (s.moving and (s.sprinting and "run" or "walk") or "idle"))) + if s.animation ~= animation then + local f = frames[animation] + player:set_animation({x = f[1], y = f[2]}, f[3], 0.12, animation ~= "strike") + s.animation = animation + end + -- FOV opt-in only. Default comfort_fov=nil never touches client settings. + if s.comfort_fov ~= nil and s.applied_fov ~= s.comfort_fov then + player:set_fov(s.comfort_fov, false, 0.2); s.applied_fov = s.comfort_fov + end + local previous = s.feedback_pos + s.step_time = (s.step_time or 0) + dt + if s.grounded and s.feedback_grounded == false and (s.feedback_fall or 0) < -3 then + hearth.feedback_sound("hearth_land", player, 0.35) + s.step_time, s.step_distance = 0, 0 + elseif previous and s.grounded and s.feedback_grounded and s.moving then + local distance = math.sqrt((pos.x - previous.x)^2 + (pos.z - previous.z)^2) + -- Ignore teleport deltas rather than bursting queued footfalls. + if distance <= 2 then s.step_distance = (s.step_distance or 0) + distance end + if s.step_time >= (s.sprinting and 0.28 or 0.4) and (s.step_distance or 0) >= 1.4 then + hearth.feedback_sound("hearth_step_" .. surface(player, s), player) + s.step_time, s.step_distance = 0, 0 + end + elseif not s.moving or not s.grounded then s.step_distance = 0 end + if s.grounded then s.feedback_fall = 0 + else s.feedback_fall = math.min(s.feedback_fall or 0, velocity.y) end + s.feedback_pos = {x = pos.x, y = pos.y, z = pos.z} + s.feedback_grounded = s.grounded + -- Bounded single-entry target cache; all raycasts/HUD traffic <= 10 Hz. + if t < (s.feedback_next or 0) then return end + s.feedback_next = t + 0.1 + local message = (s.feedback_until or 0) > t and s.feedback_text or target_text(player) + local color = (s.feedback_until or 0) > t and (colors[s.feedback_kind] or colors.info) or colors.info + if not s.feedback_hud then + s.feedback_hud = player:hud_add({type = "text", position = {x = 0.5, y = 0.72}, + offset = {x = 0, y = 0}, alignment = {x = 0, y = 0}, number = color, text = message}) + elseif message ~= s.feedback_display then player:hud_change(s.feedback_hud, "text", message) end + if s.feedback_color and color ~= s.feedback_color then player:hud_change(s.feedback_hud, "number", color) end + s.feedback_display, s.feedback_color = message, color +end +C.register_chatcommand("comfort", { + params = "[default|fov <60..100>]", description = "Session-only static FOV; no imposed camera bob", + func = function(name, param) + local player = C.get_player_by_name(name) + local s = player and session(player) + if not s then return false, "Player session unavailable." end + if param == "" then return true, "FOV unchanged by default. Use /comfort fov 75 or /comfort default." end + local value = param:match("^fov (%d+)$") + value = value and tonumber(value) + if param == "default" then value = 0 + elseif not value or value < 60 or value > 100 then return false, "Use default or fov 60..100." end + s.comfort_fov = value + return true, "Session camera preference updated." + end, +}) diff --git a/mods/hearth/fishery.lua b/mods/hearth/fishery.lua new file mode 100755 index 0000000..e4b29e1 --- /dev/null +++ b/mods/hearth/fishery.lua @@ -0,0 +1,162 @@ +-- Bounded, bait-funded riverside fishing. A trap can hold one catch at a time. +return function(hearth, api, construction) + local CATCH_SECONDS = 600 + local function finite(value) + value = tonumber(value) + if not value or value ~= value or value == math.huge or value == -math.huge then return nil end + return value + end + local function safe_now() + local value = os.time() + if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge or value == -math.huge then return 0 end + return math.floor(value) + end + local function node(pos) + local value = api.get_node_or_nil(pos) + if not value or value.name == "ignore" then return nil end + return value + end + local function has_water(pos) + for y = -1, 1 do for x = -3, 3 do for z = -3, 3 do + local nearby = node({x = pos.x + x, y = pos.y + y, z = pos.z + z}) + if nearby and (nearby.name == "hearth:water_source" or nearby.name == "hearth:water_flowing") then + return true + end + end end end + return false + end + local function valid_site(pos) + local here = node(pos) + local below = node({x = pos.x, y = pos.y - 1, z = pos.z}) + local here_def = here and api.registered_nodes[here.name] + return here_def and here_def.buildable_to == true and below + and (below.name == "hearth:sand" or below.name == "hearth:grass") + and has_water(pos) + end + local function clear_job(meta) + meta:set_string("state", "idle") + meta:set_string("ready_at", "0") + meta:set_int("catch_count", 0) + end + local function valid_catch(meta) + local count = meta:get_int("catch_count") + return count == 1 or count == 2 + end + local function finish(pos, meta, completed_at) + local season = hearth.climate_at and hearth.climate_at(completed_at) + local count = season and season.season == "Spring" and 2 or 1 + meta:set_string("state", "ready") + meta:set_string("ready_at", "0") + meta:set_int("catch_count", count) + meta:set_string("infotext", count == 2 + and "River trap: spring run caught two fish; right-click empty-handed to collect." + or "River trap: catch ready; right-click empty-handed to collect.") + api.get_node_timer(pos):stop() + end + local function reconcile(pos, now) + local current = node(pos) + if not current or current.name ~= "hearth:fish_trap" then return false end + local meta = api.get_meta(pos) + local state = meta:get_string("state") + if state == "ready" then + if not valid_catch(meta) then clear_job(meta) end + return false + end + if state ~= "baited" then + clear_job(meta) + return false + end + local ready = finite(meta:get_string("ready_at")) + now = now or safe_now() + if not ready or ready <= 0 or ready > now + CATCH_SECONDS then + clear_job(meta) + meta:set_string("infotext", "River trap: ready for bait beside water.") + api.get_node_timer(pos):stop() + return false + end + if ready <= now then + finish(pos, meta, ready) + return false + end + api.get_node_timer(pos):start(math.max(1, ready - now)) + meta:set_string("infotext", "River trap: baited; catch in " .. math.ceil(ready - now) .. " seconds.") + return true + end + local function plant_trap(stack, player, pointed) + if not pointed or pointed.type ~= "node" or not player or not player:is_player() then return stack end + local pos = pointed.above + if not valid_site(pos) then + hearth.notify(player, "Set a fish trap on firm riverbank within three blocks of water.") + return stack + end + if not construction.allowed(pos, player, "build") then return stack end + api.set_node(pos, {name = "hearth:fish_trap"}) + local def = api.registered_nodes["hearth:fish_trap"] + if def and def.after_place_node then def.after_place_node(pos, player) end + stack:take_item(1) + return stack + end + local function collect(pos, player, meta) + local count = meta:get_int("catch_count") + if count ~= 1 and count ~= 2 then clear_job(meta); return end + local inventory = player:get_inventory() + local output = ItemStack("hearth:raw_fish " .. count) + if not inventory or not inventory:room_for_item("main", output) then + hearth.notify(player, "Make room for the catch before emptying the river trap.") + return + end + local leftover = inventory:add_item("main", output) + if leftover and not leftover:is_empty() then + meta:set_int("catch_count", leftover:get_count()) + hearth.notify(player, "Some fish were collected; make room for the rest.") + return + end + clear_job(meta) + meta:set_string("infotext", "River trap: ready for bait beside water.") + end + local function rightclick(pos, _, player, stack) + if not player or not player:is_player() or not construction.allowed(pos, player, "use") then return stack end + local meta = api.get_meta(pos) + local state = meta:get_string("state") + if state == "ready" then + if stack:is_empty() then collect(pos, player, meta) else + hearth.notify(player, "Collect the waiting catch before baiting this trap again.") + end + return stack + end + if stack:get_name() ~= "hearth:fish_bait" then + local remaining = state == "baited" + and math.max(0, (finite(meta:get_string("ready_at")) or safe_now()) - safe_now()) or nil + hearth.notify(player, remaining and ("River trap: waiting " .. remaining .. " seconds.") + or "River trap: add fish bait to set it for ten minutes.") + return stack + end + if state == "baited" then + hearth.notify(player, "This trap is already baited; return after the catch is ready.") + return stack + end + if not has_water(pos) then + hearth.notify(player, "This trap is too far from water to catch fish.") + return stack + end + local now = safe_now() + meta:set_string("state", "baited") + meta:set_string("ready_at", tostring(now + CATCH_SECONDS)) + meta:set_int("catch_count", 0) + meta:set_string("baiter", player:get_player_name()) + api.get_node_timer(pos):start(CATCH_SECONDS) + meta:set_string("infotext", "River trap: baited; catch in " .. CATCH_SECONDS .. " seconds.") + stack:take_item(1) + return stack + end + local function on_timer(pos) + return reconcile(pos) + end + local function can_dig(pos, player) + return construction.allowed(pos, player, "dig") + and api.get_meta(pos):get_string("state") == "idle" + end + return {plant_trap = plant_trap, rightclick = rightclick, reconcile = reconcile, + on_timer = on_timer, can_dig = can_dig, has_water = has_water, + CATCH_SECONDS = CATCH_SECONDS} +end diff --git a/mods/hearth/init.lua b/mods/hearth/init.lua index e26c97f..81ac5d4 100755 --- a/mods/hearth/init.lua +++ b/mods/hearth/init.lua @@ -10,27 +10,152 @@ function hearth.notify(player, message) end local storage = core.get_mod_storage() hearth.storage = storage +hearth.schema = dofile(path .. "/schema.lua") +hearth.persistence = dofile(path .. "/persistence.lua")(hearth.schema, core, storage) +hearth.movement = dofile(path .. "/movement.lua") +hearth.combat = dofile(path .. "/combat.lua") -- A small claim registry is persisted in mod storage; never deserialize code. -local claim_data = storage:get_string("claims") -local claims = claim_data ~= "" and core.parse_json(claim_data) or {} -function hearth.claim_allowed(pos, name) - for owner, p in pairs(claims) do - if owner ~= name and math.abs(pos.x - p.x) <= 12 and math.abs(pos.z - p.z) <= 12 - and math.abs(pos.y - p.y) <= 24 then return false end +local claims = hearth.persistence.load_world("claims") +local homes = hearth.persistence.load_world("homes") +local claim_access = hearth.persistence.load_world("access") +local access_policy = dofile(path .. "/access.lua")(claims, claim_access) +hearth.claim_owner_at = access_policy.owner_at +hearth.claim_role_at = access_policy.role_at +hearth.claim_allowed = access_policy.allowed +function hearth.home_for(name) + local home = homes[name] + return home and {x = home.x, y = home.y, z = home.z} or nil +end +function hearth.store_home(name, pos) + if type(name) ~= "string" or name == "" or not hearth.in_own_claim + or not hearth.in_own_claim(pos, name) then + error("Last Hearth: home save denied", 0) + end + local replacement = {} + for owner, home in pairs(homes) do replacement[owner] = {x = home.x, y = home.y, z = home.z} end + replacement[name] = {x = pos.x, y = pos.y, z = pos.z} + hearth.persistence.save_world("homes", replacement) + homes = replacement +end +function hearth.in_own_claim(pos, name) + return claims[name] ~= nil and hearth.claim_owner_at(pos) == name +end +local old_protected = core.is_protected +hearth.external_protected = old_protected +core.is_protected = function(pos, name) + return not hearth.claim_allowed(pos, name, "build") or old_protected(pos, name) +end + +local function clone_positions(source) + local result = {} + for owner, pos in pairs(source) do result[owner] = {x = pos.x, y = pos.y, z = pos.z} end + return result +end +local function replace_map(target, source) + for key in pairs(target) do target[key] = nil end + for key, value in pairs(source) do + if type(value) == "table" then + local copy = {} + for k, v in pairs(value) do copy[k] = v end + target[key] = copy + else target[key] = value end + end +end +local function commit_claims(replacement) + hearth.persistence.save_world("claims", replacement) + replace_map(claims, replacement) +end +local function commit_access(replacement) + hearth.persistence.save_world("access", replacement) + replace_map(claim_access, replacement) +end +local function valid_player_name(value) + return type(value) == "string" and #value > 0 and #value <= 32 + and value:match("^[%w_%-]+$") ~= nil +end +local function draw_claim(player, center) + if not core.add_particle then return false end + local name = player:get_player_name() + local function mark(x, z) + core.add_particle({pos = {x = x, y = center.y + 0.2, z = z}, + velocity = {x = 0, y = 0.15, z = 0}, acceleration = {x = 0, y = 0, z = 0}, + expirationtime = 6, size = 1.8, texture = hearth.texture("#e5c890"), + glow = 8, playername = name}) + end + for offset = -12, 12, 2 do + mark(center.x + offset, center.z - 12); mark(center.x + offset, center.z + 12) + if offset > -12 and offset < 12 then + mark(center.x - 12, center.z + offset); mark(center.x + 12, center.z + offset) + end end return true end -local old_protected = core.is_protected -core.is_protected = function(pos, name) - return not hearth.claim_allowed(pos, name) or old_protected(pos, name) -end core.register_chatcommand("claim", { - description = "Protect a 25 x 25 homestead here (one per player). /claim remove to release.", + params = "[show|list|share |unshare |remove]", + description = "Protect and manage a homestead; /claim show briefly marks its boundary.", func = function(name, param) local player = core.get_player_by_name(name) if not player then return false, "Join the world first." end - if param == "remove" then - claims[name] = nil + if param == "show" or param == "list" then + local center = claims[name] + if not center then + center = hearth.claim_owner_at(player:get_pos()) and claims[hearth.claim_owner_at(player:get_pos())] + end + if not center then return false, "No homestead is under you. Use /claim to create one." end + if param == "show" then + if not hearth.claim_allowed(center, name, "use") then return false, "This homestead is private." end + draw_claim(player, center) + return true, string.format("Homestead bounds: X %d..%d, Z %d..%d, Y %d..%d.", + center.x - 12, center.x + 12, center.z - 12, center.z + 12, center.y - 24, center.y + 24) + end + if not claims[name] then return false, "Only the homestead owner can list its members." end + local members = claim_access[name] or {} + local names = {} + for member, role in pairs(members) do names[#names + 1] = member .. " (" .. role .. ")" end + table.sort(names) + return true, #names == 0 and "No shared members. Use /claim share ." or table.concat(names, ", ") + elseif param == "remove" then + if not claims[name] then return false, "You have no homestead to release." end + local new_access = {} + for owner, members in pairs(claim_access) do if owner ~= name then new_access[owner] = members end end + commit_access(new_access) -- Revoke first; a later failure cannot leave stale grants. + local new_homes = {} + for owner, home in pairs(homes) do if owner ~= name then new_homes[owner] = home end end + hearth.persistence.save_world("homes", new_homes); replace_map(homes, new_homes) + local new_claims = clone_positions(claims); new_claims[name] = nil + commit_claims(new_claims) + return true, "Homestead released; shared access and its saved home were removed." + elseif param:match("^share%s+") then + local member, role = param:match("^share%s+(%S+)%s+(%S+)$") + if not claims[name] then return false, "Create your homestead before sharing it." end + if not valid_player_name(member) or member == name or claims[member] then + return false, "Choose another valid player name; claim owners cannot be assigned as members." + end + if role ~= "resident" and role ~= "storekeeper" then + return false, "Role must be resident or storekeeper." + end + local new_access = {} + for owner, members in pairs(claim_access) do + new_access[owner] = {}; for who, value in pairs(members) do new_access[owner][who] = value end + end + new_access[name] = new_access[name] or {} + if not new_access[name][member] then + local count = 0; for _ in pairs(new_access[name]) do count = count + 1 end + if count >= 16 then return false, "A homestead can have at most 16 shared members." end + end + new_access[name][member] = role + commit_access(new_access) + return true, member .. " may access this homestead as " .. role .. "." + elseif param:match("^unshare%s+") then + local member = param:match("^unshare%s+(%S+)$") + if not claims[name] or not valid_player_name(member) then return false, "Use /claim unshare from your own homestead." end + local new_access = {} + for owner, members in pairs(claim_access) do + new_access[owner] = {}; for who, value in pairs(members) do if not (owner == name and who == member) then new_access[owner][who] = value end end + if not next(new_access[owner]) then new_access[owner] = nil end + end + commit_access(new_access) + return true, member .. " no longer has access to your homestead." elseif param == "" then local p = vector.round(player:get_pos()) if claims[name] then return false, "Release your existing claim before moving it." end @@ -39,13 +164,48 @@ core.register_chatcommand("claim", { return false, "Too close to another homestead. Move at least 26 blocks away." end end - claims[name] = {x = p.x, y = p.y, z = p.z} - else return false, "Use /claim or /claim remove." end - storage:set_string("claims", core.write_json(claims)) - return true, param == "remove" and "Homestead released." or "Homestead protected. Others cannot build or harvest here." + local new_claims = clone_positions(claims) + new_claims[name] = {x = p.x, y = p.y, z = p.z} + commit_claims(new_claims) + draw_claim(player, p) + return true, "Homestead protected. Use /claim show to see its temporary boundary markers." + else return false, "Use /claim, /claim show, /claim list, /claim share, /claim unshare, or /claim remove." end + end, +}) +local function homes_disabled() + return core.settings and core.settings.get_bool + and core.settings:get_bool("hearth_m1_course", false) == true +end +core.register_chatcommand("sethome", { + description = "Save a safe home inside your own homestead claim.", + func = function(name) + if homes_disabled() then return false, "Homes are disabled in the disposable M1 course. Use /course retry." end + local player = core.get_player_by_name(name) + if not player then return false, "Join the world first." end + if not hearth.set_home then return false, "Home services are still starting." end + return hearth.set_home(player) + end, +}) +core.register_chatcommand("home", { + description = "Return to your verified personal home.", + func = function(name) + if homes_disabled() then return false, "Homes are disabled in the disposable M1 course. Use /course retry." end + local player = core.get_player_by_name(name) + if not player then return false, "Join the world first." end + if not hearth.go_home then return false, "Home services are still starting." end + return hearth.go_home(player) end, }) dofile(path .. "/content.lua") +hearth.character = dofile(path .. "/character.lua")(hearth, core) dofile(path .. "/interface.lua") +dofile(path .. "/presentation.lua") +dofile(path .. "/feedback.lua") +dofile(path .. "/clock.lua") +dofile(path .. "/environment.lua") +dofile(path .. "/players.lua") dofile(path .. "/runtime.lua") dofile(path .. "/encounters.lua") +-- Enroll the fresh, nonce-marked fixture after core systems exist but before +-- mods-loaded / player callbacks. Course creation remains explicit /course consent. +dofile(path .. "/course.lua") diff --git a/mods/hearth/interactions.lua b/mods/hearth/interactions.lua new file mode 100755 index 0000000..e893303 --- /dev/null +++ b/mods/hearth/interactions.lua @@ -0,0 +1,26 @@ +-- Player interactions; survival state remains owned by the caller. +return function(hearth) + local function eat(amounts, replacement) + return function(stack, player) + if not player or not player:is_player() then return stack end + local state = hearth.players[player:get_player_name()] + if not state then return stack end + local needed = false + for key, value in pairs(amounts) do + if type(state[key]) == "number" and state[key] < 100 and value > 0 then + needed = true + end + end + if not needed then return stack end + for key, value in pairs(amounts) do + if type(state[key]) == "number" then + state[key] = math.max(0, math.min(100, state[key] + value)) + end + end + if replacement then stack:replace(replacement) else stack:take_item() end + return stack + end + end + + return {eat = eat} +end diff --git a/mods/hearth/interface.lua b/mods/hearth/interface.lua index 55f36a6..309f656 100755 --- a/mods/hearth/interface.lua +++ b/mods/hearth/interface.lua @@ -1,79 +1,475 @@ local C = core local esc = C.formspec_escape -local bg, text, dim, gold = "#1e1e2e", "#cdd6f4", "#a6adc8", "#e5c890" +-- eom.dev Mocha anchors: shared by every journal surface, not terrain tints. +local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56", + text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"} +local function label(x, y, value, color) + return "label[" .. x .. "," .. y .. ";" .. esc(C.colorize(color or ink.text, value)) .. "]" +end +local function divider(x, y, width) + -- A little pressed sprig: a stem, paired leaves, then quiet ruled paper. + return "box[" .. x .. "," .. y .. ";" .. width .. ",0.02;" .. ink.border .. "]" + .. "box[" .. x .. "," .. (y-0.01) .. ";0.72,0.04;" .. ink.teal .. "]" + .. "box[" .. (x+0.16) .. "," .. (y-0.09) .. ";0.16,0.07;" .. ink.teal .. "]" + .. "box[" .. (x+0.39) .. "," .. (y+0.04) .. ";0.16,0.07;" .. ink.teal .. "]" +end +local function journal(width, height) + return "formspec_version[6]size[" .. width .. "," .. height .. "]bgcolor[" .. ink.base .. ";true]" + .. "box[0,0;" .. width .. "," .. height .. ";" .. ink.base .. "]" + .. "style_type[label;textcolor=" .. ink.text .. "]" + .. "style_type[button,button_exit;border=false;bgimg=" .. esc("[fill:1x1:#ffffff") .. ";bgcolor=" .. ink.raised .. ";textcolor=" .. ink.text .. "]" + .. "style_type[button:hovered,button_exit:hovered;bgcolor=" .. ink.border .. ";textcolor=" .. ink.teal .. "]" + .. "style_type[button:pressed,button_exit:pressed;bgcolor=" .. ink.mauve .. ";textcolor=" .. ink.mantle .. "]" + .. "style_type[field,textarea;textcolor=" .. ink.text .. ";border=false]" + .. "style_type[list;size=1,1;spacing=0.12,0.12]" + .. "listcolors[" .. ink.raised .. ";" .. ink.border .. ";" .. ink.border .. ";" .. ink.mantle .. ";" .. ink.text .. "]" +end +local function index(value, maximum) + value = tonumber(value) + if not value or value ~= value then return 1 end + return math.max(1, math.min(maximum, math.floor(value))) +end +local reader = dofile(C.get_modpath("hearth") .. "/species_reader.lua")(hearth, C, journal) +hearth.species_reader = reader +local function inspect(player, param) + if not hearth.sessions[player:get_player_name()] then return false, "Join the world first." end + local r + if param == "" then r = reader.look(player) else r = reader.resolve(param) end + if not r then return false, "Look at a known species within six blocks, or use /inspect ." end + return reader.show(player,r.id) +end local pages = { {"First light", "YOUR FIRST HEARTH", "Start with what the valley gives you.", - "Collect loose flint and dry sticks from the ground. Gather berries for food and leaves for extra sticks.\n\nOpen your inventory with I. Make a flint axe, then fell timber. Split logs into planks; craft walls and a thatch roof.\n\nA roof keeps the rain off. Walls stop exposure. A fueled hearth dries and warms you. Leave a small opening: enclosed fires need ventilation.\n\nThere is no quest timer. Your first success is a home that works."}, - {"Craft & build", "WORK WITH YOUR HANDS", "All recipes use the 3 x 3 inventory grid.", - "AXE: two flints across the top; flint + stick below; a stick beneath the handle.\nKNIFE: two flints above one stick (right column).\nSPEAR: flint above two sticks.\nCUP: flint at top-left and top-right, flint at center.\nHEARTH: eight flints around an empty center.\n\nOne log -> four planks. One plank -> four sticks. Four planks in a square -> four walls.\nROOF: three reeds across the top, two sticks at the ends of the next row.\n\nWalls and roofs need support within two horizontal blocks. Columns may be up to 16 blocks high. Existing unsupported blocks do not collapse in this prototype."}, + "Collect loose flint and dry sticks from the ground. Gather berries for food and leaves for extra sticks.\n\nOpen your inventory with I. Make a flint axe, then fell timber. Split logs into planks; craft walls and a thatch roof.\n\nA roof keeps the rain off. Walls stop exposure. Fuel a cold hearth, then ignite it with the reusable flint firestarter. A torch brings portable light to the long real-world night.\n\nUse /claim to protect your homestead, then /sethome from a safe, dry position inside the claim.\n\nThere is no quest timer. Your first success is a home that works."}, + {"Craft & build", "WORK WITH YOUR HANDS", "Search recipes in the recipe index.", + "Open the recipe index from any field-note page, or type /recipes in chat. Search checks both ingredients and results. The inventory crafting preview shows the actual matching result.\n\nBuild from stone foundations and timber frames to walls, roofs, stairs, doors and reed-lattice windows. Right-click a door to open or close it. Roof vents keep enclosed hearth rooms breathable.\n\nHarvest wild grain and replant seed. Berries regrow on their bush; saplings can regrow a tree when planted in clear space. Crude iron ore is found in deep stone; combine a log and clay into a charcoal charge and process it at a lit hearth. Combine charcoal and ore into a smelting charge for a fueled hearth; an iron axe upgrades timber work.\n\nDry cooked food with reeds into shelf-stable rations; fire a clay jar and combine it with planks to make a pantry. General spoilage remains disabled."}, {"Fire & food", "A FIRE WORTH KEEPING", "Gather. Prepare. Eat. Recover.", - "Place your stone hearth. Right-click it with sticks or logs to add fuel. Each stick burns for 45 real seconds; a log for three minutes.\n\nCraft two berries into forage. Right-click a fueled hearth with forage to cook; left-click with cooked food to eat. Berries can also be eaten raw.\n\nUse the stone cup on river water to fill it. Right-click a fueled hearth with untreated water to boil it. Left-click to drink boiled water.\n\nCooking is a fuel-consuming instant prototype, not a realistic timed process. No spoilage, farming or pottery yet."}, + "Place a cold stone hearth. Right-click with sticks or logs to add fuel, then use a flint firestarter on it. Fuel burns in real time: one stick is 45 seconds; a log is three minutes. A torch is a permanent, portable light.\n\nCraft two berries into forage. Right-click the lit hearth with one raw portion to start a six-second cook. One untreated cup of river water takes four seconds to boil. The full heat cost is reserved on start; fuel continues to burn during the work time.\n\nWhen the infotext says the output is ready, right-click the hearth empty-handed to collect it. The output remains there if your inventory is full. Left-click cooked food or clean water to eat/drink. Smelting takes 60 seconds, pottery firing 120, and drying 300; each reserves its heat cost before consuming the charge.\n\nProcesses use real elapsed seconds and reconcile once after absence; absent players do not need to tend them. General food spoilage is disabled; dried rations are shelf-stable."}, {"Read the world", "SHELTER IS A SYSTEM", "The HUD tells you why you feel the way you do.", - "A covered roof plus nearby walls reduces exposure. Wetness rises in rain and falls under cover; a hearth helps you dry. Check the shelter indicator as you build.\n\nA very enclosed fire carries a smoke warning and causes harm: provide ventilation. This is a bounded enclosure approximation, not smoke simulation.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace against the practice opponent; bracing costs stamina and reduces walking speed.\n\nApproach the practice target near arrival. It retaliates only after you strike it. Step outside its short reach to recover. PvP is disabled."}, + "Shelter is measured by a bounded room probe, not just four wall rays. Closed doors and windows stop weather; an open doorway exposes that side. A roof vent or an open door lets smoke escape. An enclosed room with an unvented hearth is dangerous.\n\nRain wets exposed clothing. A woven cloak slows wetting and adds warmth. Dry indoors beside a lit hearth or at a drying rack. The room probe is bounded and cached; very large or unloaded spaces fail safely as exposed.\n\nAux1 (usually E) sprints. Right-click while holding a spear to brace; bracing costs stamina and reduces walking speed.\n\nOn the disposable M1 course, use /course to create and start the marked movement route. The stationary /practice target or opt-in moving /spar opponent starts combat only when you choose. Face the opponent and strike; step aside or back to evade, or face it and brace with spear + RMB. PvP is disabled."}, {"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.", - "Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. One claim per person. /claim remove releases it. Stand at least 26 blocks from another claim.\n\nYour inventory and needs persist between sessions. Logged-out characters do not starve or freeze. Fuel continues to expire in real time.\n\nDeath keeps your inventory and returns you to arrival, with needs partially restored. This forgiving prototype policy will be revisited after playtests.\n\nThis is not yet a hardened public server: moderation, shared claim membership and all grief scenarios need further testing."}, + "Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. Use /claim show for temporary boundary markers. Share with /claim share resident (build and household use) or /claim share storekeeper (storage only). /claim unshare revokes access; /claim list shows members; /claim remove releases the claim and grants.\n\nPlace homestead storage inside a claim. Its inventory stays private to the owner, residents and storekeepers; non-empty storage cannot be removed. Workstations and construction require resident permissions.\n\nUse /sethome in your own safe claim; /home rechecks that position before moving you and safely falls back to the world arrival if the home is blocked or hazardous. Your inventory, food, water and wetness persist through death.\n\nLogged-out player needs pause. Hearth processes and fuel use real elapsed time; completed output remains stored until claimed. Food does not spoil in this slice. This remains a prototype, not a hardened public service."}, + {"Living ecosystems", "NOTHING GROWS ALONE", "Watch a meadow change; there is no quest to complete.", + "An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 80 named species: eight categories of ten each. Use the Botanical guide button or /botany [search] for common and scientific names, habitat, seasons, toxicity labels and roles. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous oak, birch, willow, alder, beech, aspen, rowan and hazel thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The survey counts supporting plants, not insects. Hares and deer graze natural cover; foxes pursue hares. Use /ecosystem for actual ground, light, moisture, canopy, nutrient reserve and active wildlife totals. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors, not effects guaranteed for every species: parasitism and allelopathy cause no plant damage, and a resin tag is not an extraction recipe. Wild succession advances only around connected players in loaded areas. It pauses across unloading and restart, with no offline catch-up. Claims, placed plants, crops and construction are excluded from autonomous changes. Stages take active sessions, not a daily login. Crops and planted saplings keep separate growth rules."}, {"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.", "Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."}, + {"Geology & timber", "READ THE REGIONAL MATERIALS", "Ten biomes; shared sky, different ground.", + "Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."}, } -function hearth.guide(player, page) - page = math.max(1, math.min(#pages, page or 1)) - hearth.sessions[player:get_player_name()].page = page - local p = pages[page] - local f = {"formspec_version[6]size[14,10]bgcolor[", bg, ";true]box[0,0;14,10;#1e1e2eff]", - "style_type[button;border=false;bgcolor=#313244;textcolor=", text, "]", - "box[0,0;3.3,10;#181825]label[0.45,0.65;", esc("eom.dev / SEASON VI"), "]", - "label[0.45,1.3;", esc("THE LAST HEARTH"), "]"} - for i, pg in ipairs(pages) do - f[#f + 1] = "button[0.35," .. (2 + i * 0.72) .. ";2.6,0.58;page_" .. i .. ";" .. esc(pg[1]) .. "]" +local botanical_groups = { + {"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"}, + {"herbs", "Herbs"}, {"ferns_mosses", "Ferns & mosses"}, {"fungi", "Fungi"}, + {"aquatic", "Aquatic plants"}, {"lichens", "Lichens"}, +} +local function catalogue() + local botany = hearth.botany + return botany and type(botany.list) == "function" and botany.list() or {} +end +local function botanical_summary() + local records, lines = catalogue(), {} + if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end + lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight categories of ten in the full catalogue.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles." + for _, group in ipairs(botanical_groups) do + local names = {} + for _, r in ipairs(records) do + if r.group == group[1] then names[#names+1] = r.common .. " (" .. r.latin .. ")" end + end + lines[#lines+1] = group[2] .. " / " .. #names .. ": " .. table.concat(names, "; ") end - f[#f + 1] = "label[3.85,0.7;" .. esc("FIELD NOTES / " .. string.format("%02d", page)) .. "]" - f[#f + 1] = "style[title;textcolor=" .. gold .. "]label[3.85,1.35;" .. esc(p[2]) .. "]" - f[#f + 1] = "label[3.85,2.0;" .. esc(p[3]) .. "]" - f[#f + 1] = "textarea[3.8,2.7;9.65,5.6;;;" .. esc(p[4]) .. "]" - f[#f + 1] = "button_exit[10.5,8.85;2.8,0.65;close;Back to the valley]" + lines[#lines+1] = "Game-only field notes, not a real-world forage guide. Toxic is a catalogue flag; an unflagged species is NOT a food-safety recommendation. Roles describe conditional policy, not universal active effects." + return table.concat(lines, "\n\n") +end +function hearth.guide(player, page) + local session = hearth.sessions[player:get_player_name()] + if not session then return end + page = index(page, #pages) + session.page = page + local p = pages[page] + local f = {journal(14, 10), "box[0,0;3.3,10;" .. ink.mantle .. "]", + label(0.4,0.55,"eom.dev / SEASON VI",ink.mauve), label(0.4,1.15,"THE LAST HEARTH"), + divider(0.4,1.65,2.5), label(0.4,2.05,"FIELD JOURNAL",ink.muted)} + for i, pg in ipairs(pages) do + if i == page then f[#f+1] = "style[page_" .. i .. ";bgcolor=" .. ink.border .. ";textcolor=" .. ink.teal .. "]" end + f[#f + 1] = "button[0.35," .. (2.45 + (i-1)*0.68) .. ";2.6,0.56;page_" .. i .. ";" .. esc(pg[1]) .. "]" + end + f[#f+1] = label(0.4,8.95,"Observe. Make. Belong.",ink.muted) + f[#f + 1] = label(3.85,0.55,"FIELD NOTES / " .. string.format("%02d / %02d",page,#pages),ink.mauve) + f[#f + 1] = label(3.85,1.2,p[2],ink.yellow) .. label(3.85,1.85,p[3],ink.muted) + f[#f + 1] = divider(3.85,2.3,9.45) + local notes = p[4] .. (page == 6 and "\n\n" .. botanical_summary() or "") + f[#f + 1] = "textarea[3.8,2.65;9.6,5.6;;;" .. esc(notes) .. "]" + f[#f + 1] = "button[3.85,8.15;2.3,0.55;inspect_target;Inspect target]" + f[#f + 1] = "button[3.85,8.85;2.3,0.65;ecosystem_survey;Ecosystem survey]" + .. "tooltip[ecosystem_survey;Read a loaded habitat patch and global active wildlife. No world changes.;" .. ink.mantle .. ";" .. ink.text .. "]" + f[#f + 1] = "button[6.3,8.85;2.1,0.65;botany_index;Botanical guide]" + f[#f + 1] = "button[8.55,8.85;2.1,0.65;recipe_index;Recipe index]" + .. "tooltip[recipe_index;Search ingredients, results and real-time processes.;" .. ink.mantle .. ";" .. ink.text .. "]" + f[#f + 1] = "button_exit[10.8,8.85;2.5,0.65;close;Back to the valley]" C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f)) end +local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spear", "hearth:iron_axe", + "hearth:copper_charge", "hearth:copper_pick", "hearth:log", "hearth:stone", + "hearth:oak_planks", "hearth:birch_planks", "hearth:pine_planks", "hearth:willow_planks", "hearth:alder_planks", + "hearth:beech_planks", "hearth:aspen_planks", "hearth:rowan_planks", "hearth:hazel_planks", "hearth:spruce_planks", + "hearth:granite_bricks", "hearth:basalt_bricks", "hearth:limestone_bricks", "hearth:slate_bricks", "hearth:sandstone_bricks", + "hearth:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall", + "hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door", + "hearth:window", "hearth:vent", "hearth:storage", "hearth:pantry", "hearth:drying_rack", "hearth:cloak", + "hearth:grain_seed", "hearth:charcoal_charge", "hearth:smelt_charge", "hearth:clay_jar_charge", "hearth:dried_food_charge", + "hearth:bedroll", "hearth:planks", "hearth:sticks", "hearth:fish_trap", "hearth:fish_bait", + "hearth:fish_drying_charge", "hearth:pantry", "hearth:raw_food"} +local process_notes = { + {"Seasonal forage", "Spring greens grow in moist sunny gaps only in Spring and can be eaten. Autumn mushrooms appear in moist shade only in Autumn; craft one into raw food and cook it. Patches rest one real day after picking; changing seasons never resets the harvest."}, + {"Deciduous woodland", "Spring buds become green summer leaves, copper and gold autumn foliage, then bare winter branches. Leaf fall admits more light. Perennial groundcover rests through winter; wildflowers bloom in Spring and Summer. Use /ecosystem for a read-only survey."}, + {"Cooked forage", "One portion raw food + lit hearth: 6 real seconds; 20 fuel seconds reserved at start."}, + {"Boiled water", "One cup untreated water + lit hearth: 4 real seconds; 10 fuel seconds reserved at start."}, + {"Smelting", "One iron ore/charcoal charge + lit hearth: 60 real seconds; 30 fuel seconds reserved at start. Copper uses its separate 45-second charge."}, + {"Copper smelting", "One hearth:copper_charge (one hearth:copper_ore + one hearth:charcoal) + lit hearth: 45 real seconds; 30 fuel seconds reserved at start; one hearth:copper_ingot output. Requires at least 75 fuel seconds to queue. Collect empty-handed; a full inventory leaves the ingot waiting. Copper pick: three ingots across the top and two central sticks."}, + {"Regional geology", "Quartz in granite/slate: three hearth:quartz craft one window; the reed/plank route remains. Copper ore in basalt/limestone/slate; coal in sandstone/slate/limestone. Each seam drops two items. One granite, basalt, limestone, slate or sandstone crafts four matching bricks. Convert two matching rocks to two canonical stones in separate crafting slots (shapeless); no single-rock canonical conversion."}, + {"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel or spruce log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."}, + {"Finite nitrogen", "Nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."}, + {"Fallen logs", "Ordinary full-cube fallen logs lie sideways. Use an axe to harvest one canonical hearth:log; hands do not yield sticks. They also support decomposer habitat. Player-placed and claimed material is excluded from autonomous succession."}, + {"Pottery firing", "One shaped clay-jar charge + lit hearth: 120 real seconds; 30 fuel seconds reserved at start."}, + {"Food drying", "One cooked-food/reed charge + lit hearth: 300 real seconds; 30 fuel seconds reserved at start."}, + {"River fish trap", "Craft from three sticks and three reeds. Place on firm sand or grass within three blocks of river water, then use one bait to set it for 600 real seconds. One catch per trap; a full inventory leaves the catch waiting."}, + {"Fish preparation", "Roast one raw fish at a lit hearth for 8 real seconds; reserve 20 fuel seconds. Combine cooked fish with reeds into a drying charge, then dry for 300 seconds with 30 fuel seconds reserved. Dried fish is shelf-stable."}, + {"Spring fish run", "A trap finishing during spring returns two fish; other seasons return one. The catch is determined by its scheduled deadline, even if you are away."}, + {"Light a hearth", "Fuel first, then use a flint firestarter. Each starter has 20 successful ignitions."}, + {"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."}, + {"Charcoal", "Combine one log with river clay, then burn the charge at a lit hearth for 60 seconds with 30 fuel seconds reserved. The resulting charcoal stores 90 seconds; total fuel recovered never exceeds the log's 180 seconds."}, + {"Bedroll rest", "Craft from three planks and three reeds. Right-click beneath a roof; remain still and safe for five seconds."}, + {"Crop growth", "Plant grain seed on grass or soil. Growth samples historical local solar climate; cold weather pauses it. Harvest ripe crops for grain, then reserve one kernel to replant."}, + {"Renewal", "Berry bushes return after one real day. A planted sapling grows after three days when its bounded tree volume is clear. No resource can regrow above map limits or through claims."}, + {"Preservation", "Combine cooked food with reeds into a drying charge, then process it at a lit hearth. Dried rations are shelf-stable; general food spoilage is currently off."}, + {"Spring fish", "Spring catches can produce two fish per baited trap. Search this index for fish preparation; it is seasonal but not time-critical."}, + {"Pottery", "Shape six river clay into an unfired jar, then fire it at a lit hearth. Use the fired jar with planks to make a larger pantry."}, + {"Ironworking", "Find deep iron-bearing rock, prepare charcoal from a log, then combine ore and charcoal into a smelting charge. A fueled hearth smelts it into an ingot for a stronger iron axe."}, +} +local function item_label(item) + if type(item) ~= "string" or item == "" then return "" end + local name, count = item:match("^(%S+)%s*(%d*)$") + local def = name and C.registered_items[name] + local label = def and def.description or name or item + if count and count ~= "" and tonumber(count) and tonumber(count) > 1 then label = count .. " x " .. label end + return label +end +local function recipe_rows(query) + local rows, needle = {}, query:lower() + for _, output_name in ipairs(recipe_outputs) do + local output = C.registered_items[output_name] + local recipes = C.get_all_craft_recipes and C.get_all_craft_recipes(output_name) or nil + if output and recipes then + for _, recipe in ipairs(recipes) do + if recipe.method == "normal" then + local ingredients, source_items = {}, {} + for _, item in pairs(recipe.items or {}) do + if item and item ~= "" then ingredients[#ingredients + 1] = item_label(item) end + if item and item ~= "" then source_items[#source_items + 1] = item end + end + table.sort(ingredients) + local result = item_label(recipe.output or output_name) + local row = result .. " <- " .. (#ingredients > 0 and table.concat(ingredients, " + ") or "no ingredients") + local searchable = (row .. " " .. output_name .. " " .. table.concat(source_items, " ")):lower() + if needle == "" or searchable:find(needle, 1, true) then + rows[#rows + 1] = row + end + end + end + end + end + for _, note in ipairs(process_notes) do + local row = note[1] .. " / " .. note[2] + if needle == "" or row:lower():find(needle, 1, true) then rows[#rows + 1] = row end + end + table.sort(rows) + return rows +end +function hearth.recipes(player, query, page) + query = tostring(query or ""):sub(1, 48):gsub("[%c]", " ") + page = index(page, 1000) + local all = recipe_rows(query) + local page_size = 8 + local pages_count = math.max(1, math.ceil(#all / page_size)) + page = math.min(page, pages_count) + local lines = {} + for i = (page - 1) * page_size + 1, math.min(#all, page * page_size) do + lines[#lines + 1] = all[i] + end + if #lines == 0 then lines[1] = "No recipe or process matches that search." end + local name = player:get_player_name() + local session = hearth.sessions[name] + if not session then return end + session.recipe_query, session.recipe_page = query, page + C.show_formspec(name, "hearth:recipes", journal(14,10) + .. label(0.65,0.55,"eom.dev / THE LAST HEARTH",ink.mauve) + .. label(0.65,1.15,"RECIPE & PROCESS INDEX",ink.yellow) .. divider(0.65,1.65,12.7) + .. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]" + .. "field[0.7,2.25;9.2,0.7;query;Result or ingredient (plain text);" .. esc(query) .. "]field_close_on_enter[query;false]" + .. "button[10.25,2.25;3.1,0.7;search;Search]" + .. "tooltip[query;Up to 48 characters. Search matches literal text, not patterns.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted) + .. "textarea[0.65,3.7;12.7,4.6;;;" .. esc(table.concat(lines, "\n\n")) .. "]" + .. (page > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "") + .. (page < pages_count and "button[3.7,8.85;2.8,0.65;next;Next]" or "") + .. "button[10.05,8.85;3.3,0.65;close;Back to field notes]") +end + +local function words(set) + local out = {} + for key, enabled in pairs(set or {}) do + if enabled then out[#out+1] = key:gsub("_", " ") end + end + table.sort(out) + return #out > 0 and table.concat(out, ", ") or "not specified" +end +local function season_words(set) + local out = {} + for _, season in ipairs({"Spring", "Summer", "Autumn", "Winter"}) do + if set and set[season] then out[#out+1] = season end + end + return #out > 0 and table.concat(out, ", ") or "not specified" +end +local role_names = {browse="wildlife browse", pollinator="flower-season pollinator support", + nitrogenfixer="bounded nitrogen-fixer policy", ectomycorrhiza="host-linked root exchange", + saprotroph="finite-substrate decomposition", nursery="habitat shelter", + engineer="habitat engineering (descriptive tag)", resin="resin-bearing (descriptor; no extraction here)", + parasite="parasitism (descriptor only; no plant damage)", allelopathy="chemical competition (descriptor only; no killing)"} +local function botanical_row(r) + local niche, roles = r.niche or {}, {} + for tag, enabled in pairs(r.tags or {}) do if enabled then roles[#roles+1] = role_names[tag] or tag end end + table.sort(roles) + local group_name = r.group + for _, g in ipairs(botanical_groups) do if g[1] == r.group then group_name = g[2] end end + local function range(value, scale) + return value and tostring(value[1]*(scale or 1)) .. "-" .. tostring(value[2]*(scale or 1)) or "unspecified" + end + return r.common .. " (" .. r.latin .. ") / " .. group_name .. " / " .. r.id + .. "\nHabitat: " .. words(niche.substrates) .. "; " .. (niche.aquatic or "land") + .. "; light " .. range(niche.light) .. "/15; moisture " .. range(niche.moisture,100) + .. "%; pH " .. range(niche.ph) .. (niche.hosts and "; hosts: " .. words(niche.hosts) or "") + .. (niche.nutrients and "; nutrient minimum " .. niche.nutrients or "") + .. (niche.aquatic ~= "land" and niche.water and "; water depth " .. range(niche.water) or "") + .. "\nActive seasons: " .. season_words(r.seasons) + .. (r.flowering and "; flowers: " .. season_words(r.flowering) or "") + .. (r.fruiting and "; fruit: " .. season_words(r.fruiting) or "") + .. "; toxic flag: " .. (r.toxic and "YES (catalogue label)" or "not flagged (NOT food permission)") + .. (r.food and "; seasonal game berry harvest" or "; no catalogue food harvest") + .. "\nRoles / conditional policy or descriptors: " .. table.concat(roles, ", ") +end +function hearth.botanical_guide(player, query, page) + local name = player:get_player_name() + local session = hearth.sessions[name] + if not session then return end + query = tostring(query or ""):sub(1,48):gsub("[%c]", " ") + local all, ids, records, needle = {}, {}, catalogue(), query:lower() + for _, r in ipairs(records) do + local row = botanical_row(r) + if needle == "" or (row .. " " .. r.group .. " " .. words(r.tags)):lower():find(needle,1,true) then + all[#all+1], ids[#ids+1] = row, r.id + end + end + local count = math.max(1,math.ceil(#all/10)) + page = index(page,count) + local lines, choices = {}, {} + session.botany_ids, session.botany_selected = {}, 1 + for i=(page-1)*10+1,math.min(#all,page*10) do + lines[#lines+1] = all[i] + session.botany_ids[#choices+1] = ids[i] + choices[#choices+1] = esc(ids[i]) + end + if #lines == 0 then lines[1] = #records == 0 and "Botanical catalogue unavailable in this runtime." + or "No species matches that literal search." end + session.botany_query, session.botany_page = query, page + C.show_formspec(name,"hearth:botany",journal(14,10) + .. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve) + .. label(0.65,1.15,#records .. " named species / eight categories of ten in the full catalogue",ink.yellow) + .. divider(0.65,1.65,12.7) + .. "field[0.7,1.95;9.2,0.7;query;Species, scientific name, category, habitat or role;" .. esc(query) .. "]field_close_on_enter[query;false]" + .. "button[10.25,1.95;3.1,0.7;search;Search]" + .. label(0.7,2.95,"Page " .. page .. " / " .. count .. " / " .. #all .. " matches / up to 10 species per page",ink.muted) + .. "textarea[0.65,3.3;12.7,4.35;;;" .. esc(table.concat(lines,"\n\n")) .. "]" + .. "textarea[0.65,7.7;12.7,0.55;;;" .. esc("Game-only notes, NOT a real-world forage guide. Unflagged does not mean safe to eat. Roles are conditional, not universal effects.") .. "]" + .. (#choices > 0 and "dropdown[0.7,8.3;5,0.45;species_choice;" .. table.concat(choices,",") .. ";1;true]" + .. "tooltip[species_choice;Species article]button[6,8.3;3.3,0.45;species_article;Species article]" or "") + .. (page > 1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "") + .. (page < count and "button[3.05,8.85;2.2,0.65;next;Next]" or "") + .. "button[6,8.85;3.3,0.65;ecosystem_survey;Ecosystem survey]" + .. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]") +end + function hearth.inventory(player) - player:set_inventory_formspec("formspec_version[6]size[12.4,11.3]bgcolor[" .. bg .. ";true]box[0,0;12.4,11.3;#1e1e2eff]" - .. "label[0.5,0.45;eom.dev / THE LAST HEARTH]label[0.5,1.05;Prepare something worth carrying.]" - .. "listcolors[#313244;#45475a;#cdd6f4;#181825;#cdd6f4]" + player:set_inventory_formspec(journal(12.4,11.3) + .. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve) + .. label(0.6,1.05,"Prepare something worth carrying.",ink.muted) .. divider(0.6,1.45,11.2) .. "list[current_player;craft;0.6,1.8;3,3;]list[current_player;craftpreview;4.8,2.9;1,1;]" - .. "label[4.9,2.2;RESULT]label[6.5,1.8;GATHER / BUILD / ENDURE]" - .. "textarea[6.5,2.4;5.4,2.6;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]" + .. label(4.8,2.2,"RESULT",ink.teal) .. label(6.5,1.95,"GATHER / BUILD / BELONG",ink.yellow) + .. "textarea[6.5,2.4;5.2,1.7;;;Collect flint and sticks first.\nA roof keeps rain off.\nA hearth makes shelter a home.]" .. "button[7.1,4.35;3.8,0.6;field_guide;Open field notes]" + .. "tooltip[field_guide;Read survival notes, recipes and the ecosystem survey.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. "button[7.1,3.65;3.8,0.6;character_creator;Character appearance]" + .. "tooltip[character_creator;Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes.]" + .. label(0.6,5.25,"SATCHEL / first row is your hotbar",ink.muted) .. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]") end function hearth.location_form(player, error_text) C.show_formspec(player:get_player_name(), "hearth:location", - "formspec_version[6]size[10.8,7.3]bgcolor[" .. bg .. ";true]box[0,0;10.8,7.3;#1e1e2eff]" - .. "label[0.65,0.6;THE LAST HEARTH / SET YOUR WORLD'S LOCATION]" - .. "textarea[0.65,1.2;9.5,1.7;;;" .. esc("Choose approximate coordinates near you. No location is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]" - .. "field[0.65,3.2;4.4,0.8;latitude;Latitude (-90 to 90);]" - .. "field[5.6,3.2;4.4,0.8;longitude;Longitude (-180 to 180);]" - .. "label[0.65,4.5;" .. esc(error_text or "No location chosen: neutral equatorial preview until configured.") .. "]" - .. "button[0.65,5.3;4.4,0.75;save_location;Use this location]" - .. "button_exit[5.6,5.3;4.4,0.75;later;Choose later]") + journal(10.8,7.3) .. label(0.65,0.55,"eom.dev / WORLD REFERENCE",ink.mauve) + .. label(0.65,1.15,"A PLACE UNDER THE SAME SKY",ink.yellow) .. divider(0.65,1.65,9.5) + .. "textarea[0.65,1.95;9.5,1.5;;;" .. esc("Choose approximate coordinates, never your address. Nothing is collected automatically.\nThey stay in this world; no internet lookup is performed.\nExample only: 45 latitude, 0 longitude.") .. "]" + .. "box[0.6,3.95;4.5,0.8;" .. ink.mantle .. "]box[5.55,3.95;4.5,0.8;" .. ink.mantle .. "]" + .. "field[0.65,4;4.4,0.7;latitude;Latitude (-90 to 90);]field_close_on_enter[latitude;false]" + .. "field[5.6,4;4.4,0.7;longitude;Longitude (-180 to 180);]field_close_on_enter[longitude;false]" + .. "tooltip[latitude;Approximate decimal degrees, at most 16 characters.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. "tooltip[longitude;Local world only. Shared servers use administrator-selected coordinates.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. "textarea[0.65,4.95;9.5,0.75;;;" .. esc(C.colorize(error_text and ink.danger or ink.muted, + tostring(error_text or "No location chosen: neutral equatorial preview until configured."):sub(1,180))) .. "]" + .. "button[0.65,6;4.4,0.7;save_location;Use this location]" + .. "button_exit[5.6,6;4.4,0.7;later;Choose later]") +end + +-- Observational only: one bounded habitat stencil, one in-memory fauna snapshot. +-- Do not call step/forage/deposit, scan entities, emerge terrain or write metadata. +function hearth.ecosystem(player) + if not hearth.sessions[player:get_player_name()] then return end + local succession, wildlife = hearth.succession, hearth.wildlife + local h = succession and succession.habitat and succession.habitat(player:get_pos()) or nil + local w = wildlife and wildlife.status and wildlife.status() or nil + local function number(value) + value = tonumber(value) + if not value or value ~= value or math.abs(value) == math.huge then return 0 end + return math.max(0, math.min(1000000, value)) + end + local plants = h and string.format("Natural grass cover: %d Flower-support cover: %d\nShrub / fern / aquatic / tree cover: %d Nearby water cells: %d\nCanopy influence: %.1f Estimated light: %.1f / 15 Moisture: %.0f%%", + number(h.grass),number(h.flowers),number(h.cover),number(h.near_water or h.water),number(h.canopy),number(h.light),number(h.moisture)*100) + or "Succession observations are not available in this runtime." + if h then + -- The habitat API returns the actual support kind, not a guessed biome. + local ground = type(h.ground) == "string" and h.ground:sub(1,80) or nil + local ground_name = ground and item_label(ground:find(":",1,true) and ground or "hearth:" .. ground) + plants = "Ground: " .. (ground_name or "unknown") .. "\n" .. plants + .. "\nPollinator-support plants: " .. (h.pollination ~= nil and tostring(number(h.pollination)) or "unavailable") + .. " Nutrient reserve: " .. (h.nutrients ~= nil and tostring(number(h.nutrients)) .. " / 8" or "unavailable") + .. "\nShade: pine and spruce stay evergreen; deciduous canopy thins in Winter." + local function optional(value) return value ~= nil and tostring(number(value)) or "unavailable" end + plants = plants .. "\nGame substrate pH: " .. optional(h.ph) + .. " Nursery-support plants: " .. optional(h.nursery) + .. "\nAquatic niche: " .. (type(h.aquatic) == "string" and h.aquatic:sub(1,32) or "unavailable") + .. " Water depth: " .. (h.near_water ~= nil and optional(h.water) or "unavailable") + end + local fauna = w and string.format("Hares: %d Deer: %d Foxes: %d\nActive total: %d / %d globally Local cap: %d Per-player cap: %d", + number(w.hare),number(w.deer),number(w.fox),number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap)) + or "Wildlife observations are not available in this runtime." + C.show_formspec(player:get_player_name(), "hearth:ecosystem", journal(14,10) + .. label(0.65,0.55,"eom.dev / LIVING ECOSYSTEMS",ink.mauve) + .. label(0.65,1.15,"A MOMENT IN THE MEADOW",ink.yellow) .. divider(0.65,1.65,12.7) + .. label(0.7,2.15,"LOCAL PATCH / 5 x 5 x 5 cells at your feet",ink.teal) + .. "textarea[0.65,2.55;12.7,2.2;;;" .. esc(plants) .. "]" + .. label(0.7,4.85,h and (h.loaded and "Loaded sample / natural-origin plant counts; ground and shade include terrain" or "Incomplete sample / unloaded cells are unknown, not empty") or "Sample unavailable", + h and h.loaded and ink.muted or ink.yellow) + .. label(0.7,5.15,"WILDLIFE / all currently active animals, not a local census",ink.teal) + .. "textarea[0.65,5.55;12.7,1.25;;;" .. esc(fauna) .. "]" + .. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nNatural succession pauses while unloaded; crops and planted saplings follow separate rules.") .. "]" + .. "button[0.7,8.85;2.8,0.65;refresh;Observe again]" + .. "tooltip[refresh;Read one fresh sample without changing the world.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]" + .. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]") end C.register_on_player_receive_fields(function(player, form, fields) - if form == "hearth:guide" then + if form == "hearth:species" then + reader.handle(player,fields) + return true + elseif form == "hearth:botany" then + local session = hearth.sessions[player:get_player_name()] + if not session then return true end + if fields.species_choice ~= nil then + local value = fields.species_choice + local n = type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value) + if not n or n<1 or n>#(session.botany_ids or {}) then return true end + session.botany_selected = n + end + if fields.field_notes then hearth.guide(player,6) + elseif fields.ecosystem_survey then hearth.ecosystem(player) + elseif fields.species_article then + local id = (session.botany_ids or {})[session.botany_selected or 1] + if id then reader.show(player,id) end + elseif fields.search or fields.key_enter then hearth.botanical_guide(player,fields.query,1) + elseif fields.next then hearth.botanical_guide(player,session.botany_query,(session.botany_page or 1)+1) + elseif fields.previous then hearth.botanical_guide(player,session.botany_query,(session.botany_page or 1)-1) end + return true + elseif form == "hearth:ecosystem" then + if not hearth.sessions[player:get_player_name()] then return true end + if fields.field_notes then hearth.guide(player,6) + elseif fields.botany_index then hearth.botanical_guide(player,"",1) + elseif fields.refresh then hearth.ecosystem(player) end + return true + elseif form == "hearth:recipes" then + local session = hearth.sessions[player:get_player_name()] + if not session then return true end + if fields.close then hearth.guide(player, 2) + elseif fields.search or fields.key_enter then hearth.recipes(player, fields.query, 1) + elseif fields.next then hearth.recipes(player, session.recipe_query, (session.recipe_page or 1) + 1) + elseif fields.previous then hearth.recipes(player, session.recipe_query, (session.recipe_page or 1) - 1) end + return true + elseif form == "hearth:guide" then + if not hearth.sessions[player:get_player_name()] then return true end for i = 1, #pages do if fields["page_" .. i] then hearth.guide(player, i); return true end end + if fields.recipe_index then hearth.recipes(player, "", 1); return true end + if fields.ecosystem_survey then hearth.ecosystem(player); return true end + if fields.botany_index then hearth.botanical_guide(player,"",1); return true end + if fields.inspect_target then + local ok, message = inspect(player, "") + if not ok and hearth.notify then hearth.notify(player,message) end + return true + end elseif form == "hearth:location" and fields.save_location then if not C.is_singleplayer() then return true end local lat, lon = fields.latitude or "", fields.longitude or "" - if #lat > 16 or #lon > 16 or not hearth.model.location(lat, lon) then + if type(lat) ~= "string" or type(lon) ~= "string" or #lat > 16 or #lon > 16 or not hearth.model.location(lat, lon) then hearth.location_form(player, "Enter valid finite coordinates within the ranges above.") else hearth.set_location(tonumber(lat), tonumber(lon)) hearth.guide(player) end return true + elseif form == "" and fields.character_creator then + if hearth.character then hearth.character.show(player) end + return true elseif form == "" and fields.field_guide then hearth.guide(player); return true end end) C.register_chatcommand("guide", {description = "Open the Last Hearth field notes", func = function(name) local p = C.get_player_by_name(name); if p then hearth.guide(p) end; return true end}) +C.register_chatcommand("recipes", {params = "[search words]", description = "Search known crafting recipes and hearth processes", + func = function(name, param) + local player = C.get_player_by_name(name) + if not player or not hearth.sessions[name] then return false, "Join the world first." end + hearth.recipes(player, param or "", 1) + return true + end}) +C.register_chatcommand("ecosystem", {description = "Observe loaded natural habitat and active wildlife without changing the world", + func = function(name) + local player = C.get_player_by_name(name) + if not player or not hearth.sessions[name] then return false, "Join the world first." end + hearth.ecosystem(player) + return true + end}) +C.register_chatcommand("botany", {params = "[search]", description = "Browse named botanical species and game-only ecological field notes", + func = function(name, param) + local player = C.get_player_by_name(name) + if not player or not hearth.sessions[name] then return false, "Join the world first." end + hearth.botanical_guide(player,param or "",1) + return true + end}) +C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]", + description = "Read a targeted species' offline Wikipedia excerpt and current game effects", + func = function(name, param) + local player = C.get_player_by_name(name) + if not player then return false, "Join the world first." end + return inspect(player,param or "") + end}) C.register_chatcommand("location", {description = "Choose local-world reference coordinates", func = function(name) if not C.is_singleplayer() then return false, "Server coordinates are selected by its administrator." end @@ -83,19 +479,42 @@ C.register_chatcommand("location", {description = "Choose local-world reference function hearth.make_hud(player) local s = hearth.sessions[player:get_player_name()] s.hud = {} - s.hud.title = player:hud_add({type = "text", position = {x = 0.025, y = 0.04}, - alignment = {x = 1, y = 0}, text = "THE LAST HEARTH / eom.dev", number = 0xe5c890, size = {x = 1, y = 1}}) - s.hud.clock = player:hud_add({type = "text", position = {x = 0.025, y = 0.085}, - alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}}) - s.hud.needs = player:hud_add({type = "text", position = {x = 0.025, y = 0.14}, - alignment = {x = 1, y = 0}, text = "", number = 0xcdd6f4, size = {x = 1, y = 1}}) + local function panel(key, texture, x, y) + s.hud[key] = player:hud_add({type="image",position={x=0,y=0},offset={x=x,y=y}, + alignment={x=1,y=1},scale={x=1,y=1},text=texture,z_index=-10}) + end + local function note(key, value, x, y, color) + s.hud[key] = player:hud_add({type="text",position={x=0,y=0},offset={x=x,y=y}, + alignment={x=1,y=0},text=value,number=color,size={x=1,y=1},z_index=1}) + end + panel("journal_panel","[fill:424x240:" .. ink.mantle .. "e8",12,12) + panel("journal_accent","[fill:3x240:" .. ink.teal,12,12) + note("title","THE LAST HEARTH / eom.dev",28,34,0xcba6f7) + note("clock","",28,68,0xa6adc8) + -- Keep the established session keys; runtime may replace context/help text. + note("needs","CONDITION / reserves",28,108,0xf9e2af) + for i, need in ipairs({"warmth","water","food","stamina"}) do + local y = 136+(i-1)*28 + note(need .. "_label","",28,y,0xcdd6f4) + panel(need .. "_track","[fill:220x6:" .. ink.border,196,y-3) + panel(need .. "_fill","[fill:220x6:" .. ink.teal,196,y-3) + end + s.hud.context_panel = player:hud_add({type="image",position={x=0.5,y=0.77}, + alignment={x=0,y=0},text="[fill:680x36:" .. ink.mantle .. "dd",scale={x=1,y=1},z_index=-10}) s.hud.context = player:hud_add({type = "text", position = {x = 0.5, y = 0.77}, - alignment = {x = 0, y = 0}, text = "", number = 0xe5c890, size = {x = 1, y = 1}}) - s.hud.help = player:hud_add({type = "text", position = {x = 0.975, y = 0.04}, - alignment = {x = -1, y = 0}, text = "I Craft & field notes | E Sprint | /claim Homestead", + alignment = {x = 0, y = 0}, text = "", number = 0xf9e2af, size = {x = 1, y = 1}}) + s.hud.help = player:hud_add({type = "text", position = {x = 1, y = 0},offset={x=-20,y=34}, + alignment = {x = -1, y = 0}, text = "I Field journal / E Sprint\n/claim Homestead / /ecosystem Survey", number = 0xa6adc8, size = {x = 1, y = 1}}) player:hud_set_flags({minimap = false}) player:hud_set_hotbar_itemcount(10) + -- The authored PNG is ONE slot; [combine has no leading parenthesis. + if player.hud_set_hotbar_image then + local slots = {"[combine:320x32"} + for i=0,9 do slots[#slots+1] = ":" .. (i*32) .. ",0=hearth_hotbar.png" end + player:hud_set_hotbar_image(table.concat(slots)) + end + if player.hud_set_hotbar_selected_image then player:hud_set_hotbar_selected_image("hearth_selected.png") end end function hearth.update_hud(player, env) @@ -105,12 +524,17 @@ function hearth.update_hud(player, env) local clock = hearth.clock local hour = math.floor(clock.hour) player:hud_change(s.hud.clock, "text", clock.season .. " / " .. clock.date - .. string.format(" / Solar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60), - math.floor(env.temperature), hearth.has_location and "" or " [LOCATION NOT SET]")) - local function bar(n) return string.rep("|", math.floor(n / 10)) .. string.rep(".", 10 - math.floor(n / 10)) end - player:hud_change(s.hud.needs, "text", "WARMTH " .. bar(state.warmth) - .. "\nWATER " .. bar(state.water) .. "\nFOOD " .. bar(state.food) - .. "\nSTAMINA " .. bar(state.stamina)) + .. string.format("\nSolar %02d:%02d / %d C%s", hour, math.floor(clock.hour % 1 * 60), + math.floor(env.temperature), hearth.has_location and "" or " / location unset")) + for _, need in ipairs({"warmth","water","food","stamina"}) do + local n = tonumber(state[need]) or 0 + if n ~= n then n = 0 end + n = math.max(0,math.min(100,n)) + local color = n < 20 and ink.danger or (n < 40 and ink.yellow or ink.teal) + player:hud_change(s.hud[need .. "_label"],"text",string.upper(need) .. " " .. math.floor(n) .. "%") + player:hud_change(s.hud[need .. "_label"],"number",tonumber(color:sub(2),16)) + player:hud_change(s.hud[need .. "_fill"],"text","[fill:" .. math.max(1,math.floor(n*2.2)) .. "x6:" .. (n==0 and ink.mantle .. "00" or color)) + end local context = env.shelter >= 0.6 and "SHELTERED" or (env.roof and "ROOFED / add walls" or "EXPOSED / find cover") context = context .. (env.rain and " | RAIN" or " | CLEAR") if state.wet > 25 then context = context .. " | WET CLOTHING" end @@ -118,5 +542,5 @@ function hearth.update_hud(player, env) if env.smoke then context = "SMOKE / open a wall or roof vent" end if state.warmth < 20 then context = context .. " | COLD: shelter and fire needed" end player:hud_change(s.hud.context, "text", context) - player:hud_change(s.hud.context, "number", (env.smoke or state.warmth < 20) and 0xf38ba8 or 0xe5c890) + player:hud_change(s.hud.context, "number", (env.smoke or state.warmth < 20) and 0xf38ba8 or 0xf9e2af) end diff --git a/mods/hearth/model.lua b/mods/hearth/model.lua index e12f652..2b8dde4 100755 --- a/mods/hearth/model.lua +++ b/mods/hearth/model.lua @@ -55,8 +55,12 @@ function M.needs(state, env, dt) local s = {} for k, v in pairs(state) do s[k] = v end local shelter = M.clamp(env.shelter or 0, 0, 1) - s.wet = M.clamp(s.wet + ((env.rain and shelter < 0.6) and 1.2 or -0.35) * dt, 0, 100) + local exposed = env.rain and shelter < 0.6 + local wet_rate = exposed and (env.clothing and 0.35 or 1.2) + or (env.drying and -1.5 or -0.35) + s.wet = M.clamp(s.wet + wet_rate * dt, 0, 100) local feels = env.temperature - s.wet * 0.09 + (env.heat or 0) * 14 + shelter * 5 + + (env.clothing and 4 or 0) local target = M.clamp(60 + feels * 1.5, 0, 100) s.warmth = M.clamp(s.warmth + M.clamp(target - s.warmth, -0.16, 0.3) * dt, 0, 100) s.food = M.clamp(s.food - (env.sprinting and 0.035 or 0.012) * dt, 0, 100) diff --git a/mods/hearth/movement.lua b/mods/hearth/movement.lua new file mode 100755 index 0000000..cc7daa9 --- /dev/null +++ b/mods/hearth/movement.lua @@ -0,0 +1,43 @@ +-- Pure M1 controller. Only ephemeral session fields and state.stamina mutate. +-- No engine access: the runtime supplies weapon, velocity, ground and water. +local M = {} +function M.delta(dt) + if type(dt) ~= "number" or dt ~= dt or dt <= 0 then return 0 end + return math.min(dt, 0.1) -- never replay stalls as movement/exhaustion +end +function M.step(session, state, controls, dt, env) + controls, env = controls or {}, env or {} + dt = M.delta(dt) + local velocity = env.velocity or {x = 0, y = 0, z = 0} + local grounded = env.grounded == true and math.abs(velocity.y or 0) < 0.2 + local swimming = env.swimming == true + local moving = (velocity.x or 0)^2 + (velocity.z or 0)^2 > 0.04 + if state.stamina <= 8 and not session.exhausted then + session.exhausted = true; session.sprint_released = false + end + if session.exhausted and not controls.aux1 then session.sprint_released = true end + if session.exhausted and session.sprint_released and state.stamina >= 30 then + session.exhausted, session.sprint_released = false, false + end + local blocking = env.weapon == "hearth:spear" and controls.RMB == true and not swimming + local sprinting = controls.aux1 == true and controls.up == true and not controls.down + and not controls.sneak and not blocking and grounded and not swimming + and not session.exhausted and state.stamina > 8 + -- Keep survival rates identical to model.needs; runtime restores its duplicate + -- stamina result but retains that model's sprint hunger/thirst expenditure. + local rate = sprinting and -13 or ((state.food > 0 and state.water > 0) and 9 or 2) + state.stamina = math.max(0, math.min(100, state.stamina + rate * dt)) + if sprinting and state.stamina <= 8 then + session.exhausted, session.sprint_released, sprinting = true, false, false + end + local target = swimming and 0.75 or (blocking and 0.6 or (sprinting and 1.45 or 1)) + if not swimming and not blocking and (state.wet or 0) >= 60 then target = target * 0.95 end + local speed = session.movement_speed or 1 + local change = math.max(-3 * dt, math.min(3 * dt, target - speed)) + speed = speed + change + session.movement_speed, session.sprinting, session.blocking = speed, sprinting, blocking + session.grounded, session.swimming, session.moving = grounded, swimming, moving + return {speed = speed, sprinting = sprinting, blocking = blocking, + grounded = grounded, swimming = swimming, moving = moving} +end +return M diff --git a/mods/hearth/persistence.lua b/mods/hearth/persistence.lua new file mode 100755 index 0000000..93a36d0 --- /dev/null +++ b/mods/hearth/persistence.lua @@ -0,0 +1,126 @@ +-- Engine adapter. dofile returns factory(schema, core, mod_storage). +-- Callers must fail startup on world errors and catch/kick on player load errors. +-- No logging of JSON, player identities, parser errors, or location data. +return function(schema, core, storage) + local P = {} + local world_kinds = {claims = true, homes = true, access = true, location = true, spawn = true} + local blocked = {} + local player_stores = setmetatable({}, {__mode = "k"}) + local function fail() + error("Last Hearth: persistence failed; save retained for recovery", 0) + end + local function call(fn, ...) + local ok, value, second = pcall(fn, ...) + if not ok then fail() end + return value, second + end + local function read(store, key) + if blocked[store] and blocked[store][key] then fail() end + local raw = call(store.get_string, store, key) + if type(raw) ~= "string" then fail() end + return raw + end + local function decode(kind, raw) + local value = call(core.parse_json, raw, nil, true) + if type(value) ~= "table" then fail() end + return call(schema.decode, kind, value) + end + local function equal(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not equal(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true + end + local function encode(kind, envelope) + local raw = call(core.write_json, envelope) + if type(raw) ~= "string" or raw == "" then fail() end + -- Validate the actual serialized result before touching either key. + local roundtrip, migrated = decode(kind, raw) + if migrated ~= false or not equal(envelope, roundtrip) then fail() end + return raw + end + local function backup(store, key, original) + -- Nonempty backup bytes are immutable, even if they are not valid JSON. + if read(store, key) ~= "" then return end + local result = call(store.set_string, store, key, original) + if result == false or read(store, key) ~= original then fail() end + end + local function commit(store, key, original, raw) + local ok = pcall(function() + local result = call(store.set_string, store, key, raw) + if result == false or read(store, key) ~= raw then fail() end + end) + if ok then return end + -- Engine metadata offers no transaction/CAS or crash-durability guarantee. + -- Best-effort byte-exact rollback must itself be verified. On failure the + -- key is quarantined for this adapter's lifetime; caller must stop/kick. + local restored = pcall(function() + if read(store, key) ~= original then + local result = call(store.set_string, store, key, original) + if result == false then fail() end + end + if read(store, key) ~= original then fail() end + end) + if not restored then + blocked[store] = blocked[store] or {} + blocked[store][key] = true + end + fail() + end + local function load(store, key, backup_key, kind) + local original = read(store, key) + if original == "" then + if kind == "needs" then return call(schema.defaults) end + if kind == "claims" or kind == "homes" or kind == "access" then return {} end + return nil + end + local envelope, migrated = decode(kind, original) + if migrated then + local raw = encode(kind, envelope) + backup(store, backup_key, original) + commit(store, key, original, raw) + end + return envelope.data + end + local function save(store, key, backup_key, kind, data) + local envelope = call(schema.decode, kind, {schema = 1, kind = kind, data = data}) + local raw = encode(kind, envelope) + local original = read(store, key) + local migrated = false + if original ~= "" then + local previous, legacy = decode(kind, original) + if not previous then fail() end + migrated = legacy + end + if migrated then backup(store, backup_key, original) end + commit(store, key, original, raw) + end + local function world_kind(kind) + if type(kind) ~= "string" or not world_kinds[kind] then fail() end + end + local function metadata(player) + -- get_meta() may return a fresh userdata wrapper. Keep the same reference + -- for a player's session so failed-rollback quarantine cannot be bypassed. + if player_stores[player] then return player_stores[player] end + local meta = call(player.get_meta, player) + if meta == nil then fail() end + player_stores[player] = meta + return meta + end + function P.load_world(kind) + world_kind(kind) + return load(storage, kind, "m0_backup:" .. kind, kind) + end + function P.save_world(kind, data) + world_kind(kind) + save(storage, kind, "m0_backup:" .. kind, kind, data) + end + function P.load_player(player) + return load(metadata(player), "hearth:needs", "hearth:m0_backup:needs", "needs") + end + function P.save_player(player, needs) + save(metadata(player), "hearth:needs", "hearth:m0_backup:needs", "needs", needs) + end + return P +end diff --git a/mods/hearth/players.lua b/mods/hearth/players.lua new file mode 100755 index 0000000..0d72f88 --- /dev/null +++ b/mods/hearth/players.lua @@ -0,0 +1,377 @@ +local C, P = core, hearth.persistence +local map_limit = tonumber(C.settings and C.settings:get("mapgen_limit")) or 512 +local max_coord = math.min(31000, map_limit + 32) +local spawn_radius = 64 +-- Two clear nodes above the highest surface stay inside the shared emerge box. +local MAX_SURFACE_Y = 318 +local spawn = P.load_world("spawn") +local waiting, emerging = {}, {} + +local function stop_player(player, operation) + local name = player:get_player_name() + hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil + C.log("error", "[hearth] Player " .. operation .. " failed; save retained for recovery.") + C.kick_player(name, "Last Hearth could not " .. operation .. " your data. Please contact the administrator for recovery.") +end +function hearth.player_save(player) + local state = hearth.players[player:get_player_name()] + if not state then return false end + local ok, result = pcall(P.save_player, player, state) + if not ok or result == false then stop_player(player, "save"); return false end + return true +end + +local function node_at(pos) + local node = C.get_node_or_nil and C.get_node_or_nil(pos) or nil + if not node or node.name == "ignore" then return nil, true end + local def = C.registered_nodes[node.name] + if not def then return nil, false end + return def, false +end +local function liquid(def) + local groups = def.groups or {} + return (def.liquidtype and def.liquidtype ~= "none") + or (groups.liquid or 0) > 0 or (groups.water or 0) > 0 or (groups.lava or 0) > 0 +end +local function clear_node(pos) + local def, unloaded = node_at(pos) + if unloaded then return false, true end + if not def or def.walkable ~= false or liquid(def) or (def.damage_per_second or 0) > 0 then return false, false end + return true, false +end +local function safe_exact(pos, name, avoid) + if type(pos) ~= "table" or type(pos.x) ~= "number" or type(pos.y) ~= "number" or type(pos.z) ~= "number" + or math.abs(pos.x) > max_coord or math.abs(pos.y) > max_coord or math.abs(pos.z) > max_coord then + return false, false + end + local foot = {x = math.floor(pos.x + 0.5), y = math.floor(pos.y + 0.5), z = math.floor(pos.z + 0.5)} + local support, support_unloaded = node_at({x = foot.x, y = foot.y - 1, z = foot.z}) + local feet_ok, feet_unloaded = clear_node(foot) + local head_ok, head_unloaded = clear_node({x = foot.x, y = foot.y + 1, z = foot.z}) + if not support or support.walkable ~= true or liquid(support) or (support.damage_per_second or 0) > 0 + or not feet_ok or not head_ok then + return false, support_unloaded or feet_unloaded or head_unloaded + end + local candidate = {x = foot.x, y = foot.y, z = foot.z} + if name and hearth.claim_allowed and not hearth.claim_allowed(candidate, name, "arrival") then return false, false end + if avoid then + for _, center in ipairs(avoid) do + if center and math.abs(candidate.x - center.x) <= 12 and math.abs(candidate.y - center.y) <= 8 + and math.abs(candidate.z - center.z) <= 12 then return false, false end + end + end + for _, other in ipairs(C.get_connected_players()) do + if other:get_player_name() ~= name then + local p = other:get_pos() + if math.abs(candidate.x - p.x) < 1 and math.abs(candidate.y - p.y) < 2 + and math.abs(candidate.z - p.z) < 1 then return false, false end + end + end + if C.get_objects_inside_radius then + local self_object = name and C.get_player_by_name(name) + for _, object in ipairs(C.get_objects_inside_radius(candidate, 1.5)) do + if object ~= self_object then return false, false end + end + end + return candidate, false +end + +local function natural_surface(def) + if not def or def.walkable ~= true or liquid(def) or (def.damage_per_second or 0) > 0 then return false end + local groups = def.groups or {} + for _, group in ipairs({"tree", "leaves", "wood", "hearth_canopy", "hearth_building", "hearth_non_support", "flora", "hearth_ecosystem"}) do + if (groups[group] or 0) > 0 then return false end + end + -- Includes biome soils/geology and loose snow, scree, gravel and clay. + return (groups.soil or 0) > 0 or (groups.stone or 0) > 0 + or (groups.sand or 0) > 0 or (groups.crumbly or 0) > 0 +end +local function safe_surface(pos, name, avoid) + local safe, missing = safe_exact(pos, name, avoid) + if not safe then return nil, missing end + -- A small dry landing patch, not a trunk, canopy, pillar or cliff edge. + for dx = -1, 1 do + for dz = -1, 1 do + local x, z = safe.x + dx, safe.z + dz + local support = node_at({x = x, y = safe.y - 1, z = z}) + if not natural_surface(support) then + local clear = clear_node({x = x, y = safe.y - 1, z = z}) + if not clear or not natural_surface(node_at({x = x, y = safe.y - 2, z = z})) then return nil, false end + end + if not clear_node({x = x, y = safe.y, z = z}) + or not clear_node({x = x, y = safe.y + 1, z = z}) then return nil, false end + end + end + return safe, false +end + +local function offsets(radius) + local result = {{0, 0}} + for distance = 1, radius do + for dx = -distance, distance do + local dz = distance - math.abs(dx) + result[#result + 1] = {dx, dz} + if dz ~= 0 then result[#result + 1] = {dx, -dz} end + end + end + return result +end +local horizontal_offsets = offsets(5) +local vertical_offsets = {0, 1, -1, 2, -2, 3, -3, 4, -4, 5, -5, 6, -6} +local function nearby_safe(desired, name, avoid, surface) + local x, y, z = math.floor(desired.x + 0.5), math.floor(desired.y + 0.5), math.floor(desired.z + 0.5) + local unloaded = false + for _, delta in ipairs(vertical_offsets) do + for _, offset in ipairs(horizontal_offsets) do + local check = surface and safe_surface or safe_exact + local pos, missing = check({x = x + offset[1], y = y + delta, z = z + offset[2]}, name, avoid) + if pos then return pos, false end + unloaded = unloaded or missing + end + end + return nil, unloaded +end + +local function set_waiting(name, value) + local session = hearth.sessions[name] + if session then session.waiting = value end +end +local function connected_waiter(name, entry) + local player = C.get_player_by_name(name) + return player and hearth.sessions[name] == entry.session and player == entry.player and player +end +local function finish_waiter(name, entry, position, reason) + if waiting[name] ~= entry then return end + local player = connected_waiter(name, entry) + waiting[name] = nil + if not player then return end + if not position then + set_waiting(name, true) + if entry.message then hearth.notify(player, reason or "No safe spawn found. Please choose another world seed.") end + return + end + local verified = safe_exact(position, name, entry.avoid) + if not verified then + set_waiting(name, true) + if entry.message then hearth.notify(player, "That arrival became unsafe; try /home again.") end + return + end + local ok = pcall(function() + player:set_pos(verified) + player:set_velocity({x = 0, y = 0, z = 0}) + if entry.initial then player:get_meta():set_int("hearth:arrived", 1) end + end) + if not ok then set_waiting(name, true); return end + set_waiting(name, false) + entry.session.recovery_avoid = {x = verified.x, y = verified.y, z = verified.z, + until_us = C.get_us_time() + 30000000} + if entry.message then hearth.notify(player, entry.success or "You arrived at a verified safe location.") end +end + +local function save_spawn(position) + if not spawn or spawn.x ~= position.x or spawn.y ~= position.y or spawn.z ~= position.z then + local ok = pcall(P.save_world, "spawn", position) + if not ok then return false end + spawn = {x = position.x, y = position.y, z = position.z} + end + return true +end +local function bounded_spawn(name, avoid) + local candidates = {} + for x = -spawn_radius, spawn_radius, 4 do + for z = -spawn_radius, spawn_radius, 4 do + for ground_y = MAX_SURFACE_Y, 0, -1 do + local def, missing = node_at({x = x, y = ground_y, z = z}) + if missing or not def then break end + local groups = def.groups or {} + if natural_surface(def) then + local safe + if math.abs(x) < spawn_radius and math.abs(z) < spawn_radius then + safe = safe_surface({x = x, y = ground_y + 1, z = z}, name, avoid) + end + if safe then candidates[#candidates + 1] = safe end + -- Never descend through terrain to an arbitrary cave floor. + break + elseif liquid(def) or (def.damage_per_second or 0) > 0 + or (def.walkable ~= false and (groups.tree or 0) <= 0 + and (groups.leaves or 0) <= 0 and (groups.hearth_canopy or 0) <= 0) then break end + end + end + end + if #candidates == 0 then return nil end + return candidates[(math.floor(C.get_us_time() / 1000) % #candidates) + 1] +end + +local process_all +local function mark_emerged(key) + for _, entry in pairs(waiting) do + if entry.emerged[key] == "pending" then entry.emerged[key] = "done" end + end +end +local function emerge_once(key, minimum, maximum) + if emerging[key] then return end + emerging[key] = true + C.emerge_area(minimum, maximum, function(_, action, remaining) + if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then remaining = 0 end + if remaining ~= 0 then return end + emerging[key] = nil + mark_emerged(key) + C.after(0, process_all) + end) +end +local function target_key(pos) + return math.floor(pos.x + 0.5) .. ":" .. math.floor(pos.y + 0.5) .. ":" .. math.floor(pos.z + 0.5) +end +local function schedule_process(name, entry) + if entry.scheduled then return end + entry.scheduled = true + C.after(0, function() + if waiting[name] == entry then entry.scheduled = false; process_all() end + end) +end +process_all = function() + for name, entry in pairs(waiting) do + if not connected_waiter(name, entry) then + waiting[name] = nil + else + local target = entry.targets[entry.index] + if target then + local resolved, missing + if target.kind == "rescan" then + -- Probe the entire bounded volume, not just the old low-altitude hint. + missing = entry.emerged["bounded-spawn-rescan"] ~= "done" + else resolved, missing = nearby_safe(target.pos, name, entry.avoid, target.kind == "spawn") end + if resolved then + if (target.kind == "spawn" or target.kind == "rescan") and not save_spawn(resolved) then + finish_waiter(name, entry, nil, "Safe arrival storage failed; your data was retained.") + else finish_waiter(name, entry, resolved) end + elseif target.kind == "rescan" then + local key = "bounded-spawn-rescan" + if missing and entry.emerged[key] == "pending" then + -- A shared emergence is already servicing this waiter. + elseif missing and entry.emerged[key] ~= "done" then + entry.emerged[key] = "pending" + emerge_once(key, {x = -spawn_radius, y = 0, z = -spawn_radius}, + {x = spawn_radius, y = MAX_SURFACE_Y + 2, z = spawn_radius}) + else + local candidate = bounded_spawn(name, entry.avoid) + if candidate and save_spawn(candidate) then finish_waiter(name, entry, candidate) + elseif candidate then finish_waiter(name, entry, nil, "Safe arrival storage failed; your data was retained.") + else finish_waiter(name, entry, nil, "No safe arrival was found in the bounded spawn area. Stay safe and try /home again.") end + end + elseif missing then + local key = target_key(target.pos) + if entry.emerged[key] == "done" then + entry.index = entry.index + 1 + schedule_process(name, entry) + elseif not entry.emerged[key] then + entry.emerged[key] = "pending" + emerge_once(key, + {x = target.pos.x - 8, y = target.pos.y - 8, z = target.pos.z - 8}, + {x = target.pos.x + 8, y = target.pos.y + 8, z = target.pos.z + 8}) + end + elseif target.kind ~= "rescan" then + entry.index = entry.index + 1 + schedule_process(name, entry) + end + else + finish_waiter(name, entry, nil) + end + end + end +end + +local function begin_recovery(player, initial, message, prefer_home, avoid_current) + local name = player:get_player_name() + local session = hearth.sessions[name] + if not session then return false end + local now, avoid = C.get_us_time(), {} + if avoid_current then + local current = player:get_pos() + avoid[#avoid + 1] = {x = current.x, y = current.y, z = current.z} + end + if avoid_current and session.recovery_avoid and now < session.recovery_avoid.until_us then + avoid[#avoid + 1] = session.recovery_avoid + end + local targets = {} + if prefer_home then + local home = hearth.home_for(name) + if home then targets[#targets + 1] = {kind = "home", pos = home} end + end + if spawn then targets[#targets + 1] = {kind = "spawn", pos = {x = spawn.x, y = spawn.y, z = spawn.z}} end + targets[#targets + 1] = {kind = "rescan", pos = {x = 0, y = 32, z = 0}} + local entry = {player = player, session = session, initial = initial, message = message, + success = "You arrived at a verified safe location.", avoid = avoid, + targets = targets, index = 1, emerged = {}} + waiting[name] = entry + set_waiting(name, true) + process_all() + return true +end +function hearth.safe_arrival(player) + return begin_recovery(player, false, false, true, true) +end +function hearth.set_home(player) + local name = player:get_player_name() + local session = hearth.sessions[name] + if not session or session.waiting then return false, "Wait until your arrival is safe before setting a home." end + local pos = player:get_pos() + local point = {x = math.floor(pos.x + 0.5), y = math.floor(pos.y + 0.5), z = math.floor(pos.z + 0.5)} + if not hearth.in_own_claim(point, name) then return false, "Stand inside your own homestead claim before setting a home." end + local safe = safe_exact(point, name) + if not safe then return false, "Home must be dry, grounded, supported, and clear above your claim." end + hearth.store_home(name, safe) + return true, "Safe home saved inside your homestead." +end +function hearth.go_home(player) + local name = player:get_player_name() + if not hearth.sessions[name] then return false, "Join the world first." end + begin_recovery(player, false, true, true, false) + if hearth.home_for(name) then return true, "Checking your home and safe fallback before moving you." end + return true, "No personal home is saved; checking world spawn and safe fallback." +end + +function hearth.player_join(player) + local name = player:get_player_name() + local ok, saved = pcall(P.load_player, player) + if not ok or type(saved) ~= "table" then stop_player(player, "load"); return end + local meta = player:get_meta() + hearth.players[name], hearth.sessions[name] = saved, {page = 1, damage = 0, save = 0, ready = false} + local session = hearth.sessions[name] + local inv = player:get_inventory() + inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3) + inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1) + player:set_properties({visual = "mesh", mesh = "hearth_person.b3d", textures = {"hearth_person.png"}, + visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3}, + stepheight = 0.6, eye_height = 1.55, hp_max = 20}) + if hearth.character then hearth.character.on_join(player) end + player:set_armor_groups({fleshy = 100}) + hearth.inventory(player); hearth.make_hud(player) + player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}}) + if meta:get_int("hearth:arrived") == 0 then begin_recovery(player, true, false, true, false) end + C.after(1, function() + local current = C.get_player_by_name(name) + if not current or hearth.sessions[name] ~= session then return end + if not hearth.has_location and C.is_singleplayer() then hearth.location_form(current) + else hearth.guide(current) end + end) +end +function hearth.player_leave(player) + hearth.player_save(player) + local name = player:get_player_name() + hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil +end +function hearth.player_respawn(player) + local name = player:get_player_name() + local state, session = hearth.players[name], hearth.sessions[name] + if not state or not session then return true end + state.stamina = 35 + state.warmth = math.min(state.warmth, 60) + begin_recovery(player, false, false, true, true) + return true +end +C.register_on_joinplayer(hearth.player_join) +C.register_on_leaveplayer(hearth.player_leave) +C.register_on_shutdown(function() for _, player in ipairs(C.get_connected_players()) do hearth.player_save(player) end end) +C.register_on_respawnplayer(hearth.player_respawn) +C.register_on_punchplayer(function() return true end) diff --git a/mods/hearth/presentation.lua b/mods/hearth/presentation.lua new file mode 100755 index 0000000..9876b9f --- /dev/null +++ b/mods/hearth/presentation.lua @@ -0,0 +1,114 @@ +-- Ephemeral third-person visuals only. Main loads this once before runtime. +-- No ItemStacks, inventories, metadata, static data, attacks or file paths from input. +local C, H = core, hearth +local meshes = { + ["hearth:knife"] = "hearth_knife.obj", + ["hearth:axe"] = "hearth_axe.obj", + ["hearth:pick"] = "hearth_pick.obj", + ["hearth:spear"] = "hearth_spear.obj", +} +local records, pending = {}, nil +local entity_name = "hearth:held_visual" + +local function live(record) + local p, name = record.player, record.name + return C.get_player_by_name(name) == p and H.sessions[name] == record.session + and H.players[name] ~= nil and not record.session.waiting + and p:get_pos() ~= nil and p:get_hp() > 0 + and p:get_wielded_item():get_name() == record.item +end +local function remove(record) + -- Clear ownership BEFORE remove/on_deactivate; an old callback cannot clear + -- a replacement belonging to a same-name reconnect or a different weapon. + if records[record.name] == record then records[record.name] = nil end + local object = record.object + record.object = nil + if object and object:get_pos() then object:remove() end +end +local function attached(record) + local object = record.object + if not object or not object:get_pos() then return false end + local parent, bone, pos, rotation, forced = object:get_attach() + return parent == record.player and bone == "Hand_Right" and pos + and pos.x == 0 and pos.y == 0 and pos.z == 0 and rotation + and rotation.x == 90 and rotation.y == 0 and rotation.z == 0 and forced == false +end + +C.register_entity(entity_name, { + initial_properties = { + visual = "mesh", mesh = "hearth_knife.obj", textures = {"hearth_person.png"}, + -- The generated B3D and held OBJ both use model units (10 per node). + visual_size = {x = 1, y = 1, z = 1}, is_visible = false, + physical = false, collide_with_objects = false, pointable = false, + collisionbox = {0, 0, 0, 0, 0, 0}, selectionbox = {0, 0, 0, 0, 0, 0}, + static_save = false, hp_max = 1, makes_footstep_sound = false, + }, + on_activate = function(self, staticdata) + -- Only synchronous creation by presentation_step may bind an entity. + -- Restored/foreign/unowned entities disappear rather than becoming drops. + local record = pending + if staticdata ~= "" or not record or record.object or not live(record) then + self.object:remove(); return + end + record.object, self._held_record = self.object, record + self.object:set_armor_groups({immortal = 1}) + end, + get_staticdata = function() return "" end, + on_step = function(self) + local record = self._held_record + if not record then self.object:remove(); return end + if records[record.name] ~= record or not live(record) or not attached(record) then remove(record) end + end, + on_deactivate = function(self) + local record = self._held_record + self._held_record = nil + if record then + if records[record.name] == record then records[record.name] = nil end + record.object = nil + end + end, + -- Cosmetic, non-pointable, immortal; even a direct punch has no gameplay effect. + on_punch = function() return true end, + on_rightclick = function() end, +}) + +function H.presentation_step(player) + local name = player:get_player_name() + local record, session = records[name], H.sessions[name] + local item = player:get_wielded_item():get_name() + local mesh = meshes[item] + if record then + if record.player == player and live(record) and attached(record) then return end + -- A deferred call on an old PlayerRef must not destroy the live replacement. + if record.player ~= player and C.get_player_by_name(name) ~= player then return end + remove(record) + end + if not mesh or not session then return end + record = {name = name, player = player, session = session, item = item} + if not live(record) then return end + records[name], pending = record, record + local ok, object = pcall(C.add_entity, player:get_pos(), entity_name) + pending = nil + if not ok or not object or object ~= record.object or not object:get_pos() then + remove(record); return + end + object:set_properties({mesh = mesh}) + -- forced_visible is the FIFTH argument: false hides this from its owner in + -- first person, but permits third-person rendering. Never toggle is_visible + -- from a guessed server-side camera mode (it would hide it for observers). + object:set_attach(player, "Hand_Right", {x = 0, y = 0, z = 0}, {x = 90, y = 0, z = 0}, false) + if not attached(record) then remove(record); return end + object:set_properties({is_visible = true}) +end +function H.presentation_cleanup(player) + local record = records[player:get_player_name()] + if record and record.player == player then remove(record) end +end +C.register_on_leaveplayer(H.presentation_cleanup) +C.register_on_dieplayer(H.presentation_cleanup) +C.register_on_shutdown(function() + local snapshot = {}; for _, record in pairs(records) do snapshot[#snapshot + 1] = record end + for _, record in ipairs(snapshot) do remove(record) end +end) + +return H.presentation_step diff --git a/mods/hearth/processes.lua b/mods/hearth/processes.lua new file mode 100755 index 0000000..f3916c0 --- /dev/null +++ b/mods/hearth/processes.lua @@ -0,0 +1,258 @@ +-- Real-time hearth processes; construction supplies the shared access check. +return function(hearth, api, construction) + local allowed = construction.allowed + local MAX_FUEL = 600 + -- Exact names only: building timber, charges and arbitrary tree groups are not fuel. + local FUEL = { ["hearth:sticks"] = 45, ["hearth:log"] = 180, ["hearth:charcoal"] = 90, + ["hearth:coal"] = 240, ["hearth:oak_log"] = 180, ["hearth:birch_log"] = 180, + ["hearth:pine_log"] = 180, ["hearth:willow_log"] = 180, ["hearth:alder_log"] = 180, + ["hearth:beech_log"] = 180, ["hearth:aspen_log"] = 180, ["hearth:rowan_log"] = 180, + ["hearth:hazel_log"] = 180, ["hearth:spruce_log"] = 180 } + local JOBS = { + ["hearth:raw_food"] = {kind = "cook", duration = 6, cost = 20, + result = "hearth:cooked_food"}, + ["hearth:raw_fish"] = {kind = "roast", duration = 8, cost = 20, + result = "hearth:cooked_fish"}, + ["hearth:water_raw"] = {kind = "boil", duration = 4, cost = 10, + result = "hearth:water_clean"}, + ["hearth:smelt_charge"] = {kind = "smelt", duration = 60, cost = 30, + result = "hearth:iron_ingot"}, + ["hearth:copper_charge"] = {kind = "smelt_copper", duration = 45, cost = 30, + result = "hearth:copper_ingot"}, + ["hearth:dried_food_charge"] = {kind = "dry", duration = 300, cost = 30, + result = "hearth:dried_food"}, + ["hearth:clay_jar_charge"] = {kind = "fire", duration = 120, cost = 30, + result = "hearth:clay_jar"}, + ["hearth:charcoal_charge"] = {kind = "char", duration = 60, cost = 30, + result = "hearth:charcoal"}, + ["hearth:fish_drying_charge"] = {kind = "dry_fish", duration = 300, cost = 30, + result = "hearth:dried_fish"}, + } + local RESULTS = { ["hearth:cooked_food"] = true, ["hearth:water_clean"] = true, + ["hearth:iron_ingot"] = true, ["hearth:copper_ingot"] = true, + ["hearth:dried_food"] = true, ["hearth:clay_jar"] = true, + ["hearth:charcoal"] = true, ["hearth:cooked_fish"] = true, ["hearth:dried_fish"] = true } + local function is_fire(pos) + local name = api.get_node(pos).name + return name == "hearth:fire" or name == "hearth:fire_lit" + end + local function fuel_left(pos, now) + local deadline = tonumber(api.get_meta(pos):get_string("fuel_until")) or 0 + if deadline ~= deadline or deadline == math.huge or deadline == -math.huge then return 0 end + return math.max(0, math.min(MAX_FUEL, deadline - now)) + end + local function job_deadline(meta) + local raw = meta:get_string("job_until") + if raw == "" then return 0 end + local value = tonumber(raw) + if not value or value ~= value or value == math.huge or value == -math.huge then return nil end + return math.floor(value) + end + + local function valid_job(meta) + local kind = meta:get_string("job_kind") + if kind == "" then return nil end + local until_time, cost = job_deadline(meta), meta:get_int("job_cost") + local result = meta:get_string("job_result") + if not until_time or until_time <= 0 then return nil end + for _, job in pairs(JOBS) do + if kind == job.kind and cost == job.cost and result == job.result then + return {kind = kind, until_time = until_time, cost = cost, result = result} + end + end + return nil + end + + local function clear_job(meta) + meta:set_string("job_kind", "") + meta:set_string("job_until", "0") + meta:set_int("job_cost", 0) + end + + local function pending_output(meta) + local result = meta:get_string("job_result") + if meta:get_string("job_kind") == "" and job_deadline(meta) == 0 + and meta:get_int("job_cost") == 0 and RESULTS[result] then + return result + end + return "" + end + + local function schedule_timer(pos, remaining, now) + local meta = api.get_meta(pos) + local wait + if remaining > 0 then wait = remaining end + local job = valid_job(meta) + if job then + local job_wait = job.until_time - now + if job_wait <= 0 then job_wait = 0.1 end + wait = wait and math.min(wait, job_wait) or job_wait + end + local timer = api.get_node_timer(pos) + if wait then + timer:start(math.max(0.1, wait)) + else + timer:stop() + end + end + + local function refresh_fire(pos, remaining, now) + local meta = api.get_meta(pos) + now = now or os.time() + remaining = remaining == nil and fuel_left(pos, now) or remaining + if remaining <= 0 then + remaining = 0 + meta:set_string("fuel_until", "0") + meta:set_int("lit", 0) + end + local job = valid_job(meta) + local output = pending_output(meta) + local lit = meta:get_int("lit") == 1 and remaining > 0 + meta:set_int("lit", lit and 1 or 0) + if output ~= "" and not job then + meta:set_string("infotext", "Last Hearth: finished " .. output .. " ready to collect") + elseif job then + local verb = ({cook = "cooking", roast = "roasting fish", boil = "boiling", smelt = "smelting iron", + smelt_copper = "smelting copper", + dry = "drying food", dry_fish = "drying fish", fire = "firing", char = "making charcoal"})[job.kind] or "processing" + meta:set_string("infotext", "Last Hearth: " .. verb .. "; " .. math.max(0, + math.ceil(job.until_time - now)) .. "s remaining") + elseif lit then + meta:set_string("infotext", "Last Hearth: lit; " .. math.ceil(remaining) + .. "s fuel; feed sticks/logs/charcoal/coal or process a charge") + elseif remaining > 0 then + meta:set_string("infotext", "Fueled but cold hearth: ignite with a firestarter") + else + meta:set_string("infotext", "Cold hearth: feed dry sticks or a log") + end + local node = api.get_node(pos) + local name = lit and "hearth:fire_lit" or "hearth:fire" + if node.name ~= name then + api.swap_node(pos, {name = name, param2 = node.param2}) + end + schedule_timer(pos, remaining, now) + end + + local function finish_job(_, meta, job) + clear_job(meta) -- Clear the reservation before committing its one output. + meta:set_string("job_result", job.result) + end + + local function reconcile_job(pos, now) + local meta = api.get_meta(pos) + local kind = meta:get_string("job_kind") + if kind == "" then + local output = meta:get_string("job_result") + if (output ~= "" and pending_output(meta) == "") + or job_deadline(meta) ~= 0 or meta:get_int("job_cost") ~= 0 then + clear_job(meta) + meta:set_string("job_result", "") + end + return + end + local job = valid_job(meta) + if not job or job.until_time <= 0 then + clear_job(meta) + meta:set_string("job_result", "") + return + end + if job.until_time <= now then + finish_job(pos, meta, job) + end + end + + local function fire_tick(pos) + if not is_fire(pos) then return false end + local now = os.time() + reconcile_job(pos, now) + refresh_fire(pos, fuel_left(pos, now), now) + return false -- refresh_fire schedules only the next job/fuel deadline. + end + + local function use_fire(pos, _, player, stack) + if not is_fire(pos) or not allowed(pos, player, "use") then return stack end + local now = os.time() + local remaining = fuel_left(pos, now) + local meta = api.get_meta(pos) + if stack:is_empty() then + local result = pending_output(meta) + if result ~= "" then + local inv = player:get_inventory() + local output = ItemStack(result) + if inv and inv:room_for_item("main", output) then + local leftover = inv:add_item("main", output) + if leftover:is_empty() then meta:set_string("job_result", "") end + else + hearth.notify(player, "Make inventory space to collect the hearth output.") + end + end + refresh_fire(pos, remaining, now) + return stack + end + local item = stack:get_name() + local burn = FUEL[item] + if burn then + if remaining + burn <= MAX_FUEL then + stack:take_item(1) + remaining = remaining + burn + meta:set_string("fuel_until", tostring(now + remaining)) + else + hearth.notify(player, "The hearth has no room for that much fuel yet.") + end + else + local job = JOBS[item] + if job then + if meta:get_string("job_kind") ~= "" or meta:get_string("job_result") ~= "" then + hearth.notify(player, "Collect the finished hearth output first.") + elseif meta:get_int("lit") ~= 1 then + hearth.notify(player, "Ignite the fueled hearth before cooking or boiling.") + elseif remaining < job.duration + job.cost then + hearth.notify(player, "Add more fuel to reserve the full hearth process.") + else + meta:set_string("job_kind", job.kind) + meta:set_string("job_until", tostring(now + job.duration)) + meta:set_int("job_cost", job.cost) + meta:set_string("job_result", job.result) + -- Reserve heat cost at queue time; elapsed processing burns + -- naturally against the absolute UTC deadline. Paused servers do + -- not grant free fuel, but a completed offline job remains queued. + remaining = remaining - job.cost + meta:set_string("fuel_until", tostring(now + remaining)) + -- Start the timer before consuming input; queued output is persistent. + schedule_timer(pos, remaining, now) + stack:take_item(1) + end + end + end + refresh_fire(pos, remaining, now) + return stack + end + + local function ignite_fire(pos, player) + if not is_fire(pos) or not allowed(pos, player, "use") then return false end + local meta = api.get_meta(pos) + local now = os.time() + local remaining = fuel_left(pos, now) + if meta:get_int("lit") == 1 or remaining <= 0 then + refresh_fire(pos, remaining, now) + return false + end + meta:set_int("lit", 1) + refresh_fire(pos, remaining, now) + if api.sound_play then + api.sound_play("hearth_fire", {pos = pos, gain = 0.5, max_hear_distance = 16}) + end + return true + end + + local function use_firestarter(stack, player, pointed) + if pointed and pointed.type == "node" and ignite_fire(pointed.under, player) then + stack:add_wear(math.ceil(65535 / 20)) + end + return stack + end + + return {use_fire = use_fire, fire_tick = fire_tick, + refresh_fire = refresh_fire, fuel_left = fuel_left, ignite_fire = ignite_fire, + use_firestarter = use_firestarter} +end diff --git a/mods/hearth/runtime.lua b/mods/hearth/runtime.lua index 58e9989..0a56bac 100755 --- a/mods/hearth/runtime.lua +++ b/mods/hearth/runtime.lua @@ -1,177 +1,110 @@ local C, M = core, hearth.model -local lat_setting, lon_setting = C.settings:get("hearth_latitude"), C.settings:get("hearth_longitude") -local configured = M.location(lat_setting, lon_setting) -local location_data = hearth.storage:get_string("location") -local stored = location_data ~= "" and C.parse_json(location_data) or nil -local selected = configured or (C.is_singleplayer() and stored and M.location(stored.latitude, stored.longitude)) -if not C.is_singleplayer() and not configured then - error("Last Hearth server requires explicit hearth_latitude and hearth_longitude settings.") -end -hearth.has_location = selected ~= nil -local location = selected or {latitude = 0, longitude = 0} -function hearth.set_location(lat, lon) - assert(C.is_singleplayer(), "Only local worlds can select location in game") - local value = assert(M.location(lat, lon), "Invalid reference coordinates") - location, hearth.has_location = value, true - hearth.storage:set_string("location", C.write_json(value)) -end -local function timestamp() - -- Date fixtures are only accepted for local play, never a public server. - if C.is_singleplayer() then - local fixture = tonumber(C.settings:get("hearth_preview_date")) - if fixture and fixture >= 946684800 and fixture <= 4102444800 then return fixture end - end - return os.time() -end -local function clock_update() - local now = timestamp() - hearth.clock = M.solar(now, location.latitude, location.longitude) - hearth.weather = M.weather(now, location.latitude, location.longitude) - if C.is_singleplayer() then - local weather = C.settings:get("hearth_preview_weather") - if weather == "rain" or weather == "clear" then hearth.weather = weather end - end - C.set_timeofday(hearth.clock.timeofday) -end -C.after(0, clock_update) - -local function solid(pos) - local n = C.get_node_or_nil(pos) - if not n then return false end - local def = C.registered_nodes[n.name] - return def and def.walkable ~= false and not def.buildable_to - and C.get_item_group(n.name, "leaves") == 0 -end -function hearth.environment(pos) - local head = vector.round({x = pos.x, y = pos.y + 1, z = pos.z}) - local roof = false - for y = 1, 5 do if solid(vector.offset(head, 0, y, 0)) then roof = true; break end end - local walls = 0 - for _, d in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do - for r = 1, 3 do - if solid(vector.offset(head, d[1] * r, 0, d[2] * r)) then walls = walls + 1; break end - end - end - local shelter = roof and (0.25 + walls * 0.1875) or 0 - local fire = C.find_node_near(pos, 4, {"hearth:fire_lit"}) - local heat = 0 - if fire then - local deadline = tonumber(C.get_meta(fire):get_string("fuel_until")) or 0 - if M.fuel_remaining(deadline, os.time()) > 0 then - -- A wall between the hearth and player blocks its warmth. - local from = vector.offset(fire, 0, 0.6, 0) - if C.line_of_sight(from, head, 0.5) then heat = 1 - math.min(0.8, vector.distance(pos, fire) / 5) end - end - end - return {roof = roof, shelter = shelter, heat = heat, smoke = heat > 0 and roof and walls == 4, - rain = hearth.weather == "rain", temperature = hearth.clock.temperature - math.max(0, pos.y) * 0.0065} -end - -local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100} -local function save(player) - local s = hearth.players[player:get_player_name()] - if s then player:get_meta():set_string("hearth:needs", C.write_json(s)) end -end -local spawn_data = hearth.storage:get_string("spawn") -local spawn = spawn_data ~= "" and C.parse_json(spawn_data) or nil -local spawn_pending = false -local waiting = {} -local function arrive(player) - if spawn then - player:set_pos(spawn) - player:set_velocity({x = 0, y = 0, z = 0}) - end -end -local function locate_spawn(player) - if spawn then arrive(player); return end - waiting[player:get_player_name()] = true - if spawn_pending then return end - spawn_pending = true - C.emerge_area({x = -48, y = -16, z = -48}, {x = 48, y = 96, z = 48}, function(_, _, left) - if left ~= 0 then return end - local best, best_score - for x = -44, 44, 4 do for z = -44, 44, 4 do - for y = 78, 3, -1 do - local n = C.get_node({x = x, y = y, z = z}) - if n.name == "hearth:grass" then - local p = {x = x, y = y + 1, z = z} - if C.get_node(p).name == "air" and C.get_node(vector.offset(p, 0, 1, 0)).name == "air" then - local score = math.abs(x) + math.abs(z) + y * 0.2 - if not best_score or score < best_score then best, best_score = p, score end - end - break - end - end - end end - if not best then - C.log("error", "[hearth] No safe arrival in initial area; select a different world seed.") - for name in pairs(waiting) do C.kick_player(name, "No safe spawn found. Please choose another world seed.") end - waiting, spawn_pending = {}, false - return - end - spawn = best - hearth.storage:set_string("spawn", C.write_json(spawn)) - for name in pairs(waiting) do local p = C.get_player_by_name(name); if p then arrive(p) end end - waiting = {} - end) -end - -C.register_on_joinplayer(function(player) - local name, meta = player:get_player_name(), player:get_meta() - local saved_data = meta:get_string("hearth:needs") - local saved = saved_data ~= "" and C.parse_json(saved_data) or {} - local state = {} - for key, value in pairs(defaults) do state[key] = M.clamp(tonumber(saved[key]) or value, 0, 100) end - hearth.players[name], hearth.sessions[name] = state, {page = 1, damage = 0, save = 0, ready = false} - local inv = player:get_inventory() - inv:set_size("main", 40); inv:set_size("craft", 9); inv:set_width("craft", 3) - inv:set_size("craftpreview", 1); inv:set_size("craftresult", 1) - player:set_properties({visual = "mesh", mesh = "hearth_person.obj", textures = {hearth.texture("#947853")}, - visual_size = {x = 1, y = 1}, collisionbox = {-0.3, 0, -0.3, 0.3, 1.75, 0.3}, - stepheight = 0.6, eye_height = 1.55, hp_max = 20}) - player:set_armor_groups({fleshy = 100}) - hearth.inventory(player); hearth.make_hud(player) - player:set_clouds({density = 0.35, color = "#bec4c8e0", height = 140, speed = {x = 2, z = -1}}) - if meta:get_int("hearth:arrived") == 0 then - meta:set_int("hearth:arrived", 1) - locate_spawn(player) - end - C.after(1, function() - local p = C.get_player_by_name(name) - if not p then return end - if not hearth.has_location and C.is_singleplayer() then hearth.location_form(p) - else hearth.guide(p) end - end) -end) -C.register_on_leaveplayer(function(player) - save(player) +local map_limit = tonumber(C.settings and C.settings:get("mapgen_limit")) or 512 +local recovery_limit = map_limit + 32 +-- Clock, environment, and lifecycle modules are installed before scheduling. +-- Main installs hearth.movement before this module; feedback is optional. +function hearth.movement_step(player, dt) local name = player:get_player_name() - hearth.players[name], hearth.sessions[name], waiting[name] = nil, nil, nil -end) -C.register_on_shutdown(function() for _, p in ipairs(C.get_connected_players()) do save(p) end end) -C.register_on_respawnplayer(function(player) - local s = hearth.players[player:get_player_name()] - if s then for k, v in pairs(defaults) do s[k] = v end end - locate_spawn(player) - return true -end) --- PvP is off regardless of a deployment accidentally changing enable_pvp. -C.register_on_punchplayer(function() return true end) + local s, state = hearth.sessions[name], hearth.players[name] + if not s or not state or s.waiting or player:get_hp() <= 0 then return end + local pos, velocity = player:get_pos(), player:get_velocity() + local function definition(y) + local node = C.get_node({x = pos.x, y = pos.y + y, z = pos.z}) + return C.registered_nodes[node.name] or {} + end + local below, feet, body = definition(-0.1), definition(0.1), definition(0.9) + local function water(def) return def.groups and (def.groups.water or 0) > 0 end + local result = hearth.movement.step(s, state, player:get_player_control(), dt, { + weapon = player:get_wielded_item():get_name(), velocity = velocity, + grounded = below.walkable ~= false and next(below) ~= nil, + swimming = water(body) or (water(feet) and below.walkable == false), + }) + -- Only override speed: leave other mods' jump/gravity and camera untouched. + if not s.speed or math.abs(result.speed - s.speed) >= 0.005 then + player:set_physics_override({speed = result.speed}); s.speed = result.speed + end + return result +end +local REST_SECONDS, REST_COOLDOWN = 5, 30 +function hearth.start_rest(player, pos) + if not player or not player:is_player() then return false end + local name = player:get_player_name() + local session, state = hearth.sessions[name], hearth.players[name] + if not session or not state or session.waiting or player:get_hp() <= 0 then return false end + if not hearth.construction.allowed(pos, player, "use") then return false end + local node = C.get_node_or_nil(pos) + if not node or node.name ~= "hearth:bedroll" then return false end + local now = C.get_us_time() / 1000000 + if now < (session.rest_ready_at or 0) then + hearth.feedback(player, "Rest again in " .. math.ceil(session.rest_ready_at - now) .. "s", "warning") + return false + end + if session.resting then hearth.feedback(player, "Already resting", "info"); return false end + if now < (session.attack_ready_at or 0) or now < (session.spar_ready_at or 0) then + hearth.feedback(player, "Recover from the encounter before resting", "warning") + return false + end + local p, velocity = player:get_pos(), player:get_velocity() + local cover = hearth.environment(p) + if math.abs(velocity.x) + math.abs(velocity.z) > 0.25 or cover.shelter < 0.6 or cover.smoke then + hearth.feedback(player, "Stay still on the bedroll in sheltered, ventilated space to rest", "warning") + return false + end + session.resting = {pos = {x = pos.x, y = pos.y, z = pos.z}, + player_pos = {x = p.x, y = p.y, z = p.z}, hp = player:get_hp(), elapsed = 0} + hearth.feedback(player, "Resting / remain still under cover for five seconds", "info") + return true +end + +function hearth.rest_step(player, dt, controls) + local name = player:get_player_name() + local session, state = hearth.sessions[name], hearth.players[name] + local rest = session and session.resting + if not rest then return false end + local p, velocity = player:get_pos(), player:get_velocity() + local moved = math.abs(p.x - rest.player_pos.x) + math.abs(p.y - rest.player_pos.y) + + math.abs(p.z - rest.player_pos.z) + local now = C.get_us_time() / 1000000 + local interrupted = not state or not controls or controls.up or controls.down + or controls.left or controls.right or controls.jump or controls.sneak + or moved > 0.2 or math.abs(velocity.x) + math.abs(velocity.z) > 0.25 + or player:get_hp() < rest.hp + -- Shelter/claim checks are spatial queries; cache them briefly instead of + -- rescanning roof/fire geometry at the full render-step frequency. + if now >= (rest.validate_at or 0) then + local bed = C.get_node_or_nil(rest.pos) + local cover = hearth.environment(p) + rest.valid = bed and bed.name == "hearth:bedroll" + and hearth.construction.allowed(rest.pos, player, "use") + and cover.shelter >= 0.6 and not cover.smoke + rest.validate_at = now + 0.25 + end + interrupted = interrupted or not rest.valid + if interrupted then + session.resting = nil + hearth.feedback(player, "Rest interrupted / stay still in shelter with ventilation", "warning") + return false + end + rest.elapsed = rest.elapsed + math.min(math.max(dt, 0), 0.1) + if rest.elapsed + 0.000001 >= REST_SECONDS then + session.rest_warmth_pending = 8 + session.resting, session.rest_ready_at = nil, C.get_us_time() / 1000000 + REST_COOLDOWN + hearth.feedback(player, "Rest complete / +8 warmth", "success") + return true + end + return true +end local elapsed, clock_elapsed = 0, 0 C.register_globalstep(function(dt) elapsed, clock_elapsed = elapsed + dt, clock_elapsed + dt - if clock_elapsed >= 10 then clock_update(); clock_elapsed = 0 end + if clock_elapsed >= 10 then hearth.clock_update(); clock_elapsed = 0 end -- Small bounded steps for smooth sprint state; needs/HUD at 1 Hz. for _, player in ipairs(C.get_connected_players()) do - local name = player:get_player_name() - local s, state = hearth.sessions[name], hearth.players[name] - if s then - local controls = player:get_player_control() - s.sprinting = controls.aux1 and controls.up and not controls.sneak and state.stamina > 8 - s.blocking = player:get_wielded_item():get_name() == "hearth:spear" and controls.RMB - local speed = s.blocking and 0.6 or (s.sprinting and 1.45 or 1) - if speed ~= s.speed then player:set_physics_override({speed = speed, jump = 1, gravity = 1}); s.speed = speed end + local result = hearth.movement_step(player, dt) + if result then + if hearth.feedback_step then hearth.feedback_step(player, dt) end + hearth.rest_step(player, dt, player:get_player_control()) end end if elapsed < 1 then return end @@ -179,20 +112,32 @@ C.register_globalstep(function(dt) for _, player in ipairs(C.get_connected_players()) do local name, pos = player:get_player_name(), player:get_pos() local s, state = hearth.sessions[name], hearth.players[name] - if s and not waiting[name] and player:get_hp() > 0 then - local env = hearth.environment(pos) - env.sprinting = s.sprinting - local updated, danger = M.needs(state, env, step) - if env.heat > 0 then updated.wet = math.max(0, updated.wet - env.heat * step) end - hearth.players[name] = updated - s.damage = s.damage + danger + (env.smoke and step or 0) - if s.damage >= 4 then - player:set_hp(player:get_hp() - 1, {type = "set_hp", from = "mod"}); s.damage = 0 + if s and state and not s.waiting and player:get_hp() > 0 then + if pos.y < -recovery_limit then + hearth.safe_arrival(player) + else + local env = hearth.environment(pos) + env.sprinting = s.sprinting + env.clothing = player:get_meta():get_int("hearth:cloak") == 1 + local rack = env.shelter >= 0.6 and C.find_node_near(pos, 4, {"hearth:drying_rack"}) + env.drying = env.heat > 0 or (env.shelter >= 0.6 and rack ~= nil) + local updated, danger = M.needs(state, env, step) + updated.stamina = state.stamina -- movement owns continuous stamina, not needs + local rest_completed = s.rest_warmth_pending ~= nil + if rest_completed then + updated.warmth = math.min(100, updated.warmth + s.rest_warmth_pending) + s.rest_warmth_pending = nil + end + hearth.players[name] = updated + if rest_completed then hearth.player_save(player) end + s.damage = s.damage + danger + (env.smoke and step or 0) + if s.damage >= 4 then + player:set_hp(player:get_hp() - 1, {type = "set_hp", from = "mod"}); s.damage = 0 + end + hearth.update_hud(player, env) + s.save = s.save + step + if s.save >= 10 then hearth.player_save(player); s.save = 0 end end - if pos.y < -100 then arrive(player) end - hearth.update_hud(player, env) - s.save = s.save + step - if s.save >= 10 then save(player); s.save = 0 end end end end) diff --git a/mods/hearth/schema.lua b/mods/hearth/schema.lua new file mode 100755 index 0000000..948b7b1 --- /dev/null +++ b/mods/hearth/schema.lua @@ -0,0 +1,105 @@ +-- Pure save validation: no engine, JSON, filesystem, or global game state. +local S = {} +local needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100} +local kinds = {needs = true, claims = true, homes = true, access = true, location = true, spawn = true} + +local function invalid() + error("Last Hearth: invalid save schema or data", 0) +end + +local function record(value, fields) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end + for key in pairs(value) do + if not fields[key] then invalid() end + end +end + +local function number(value, low, high, integer) + if type(value) ~= "number" or value ~= value or value < low or value > high + or (integer and value % 1 ~= 0) then invalid() end + return value +end + +local function position(value, integer) + record(value, {x = true, y = true, z = true}) + return {x = number(value.x, -31000, 31000, integer), + y = number(value.y, -31000, 31000, integer), + z = number(value.z, -31000, 31000, integer)} +end + +local function player_name(value) + return type(value) == "string" and #value > 0 and #value <= 32 + and value:match("^[%w_%-]+$") ~= nil +end + +local function normalize(kind, value) + if kind == "needs" then + record(value, needs) + local result = {} + for key in pairs(needs) do result[key] = number(value[key], 0, 100) end + return result + elseif kind == "location" then + record(value, {latitude = true, longitude = true}) + return {latitude = number(value.latitude, -90, 90), + longitude = number(value.longitude, -180, 180)} + elseif kind == "spawn" then + return position(value, false) + elseif kind == "access" then + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end + local result = {} + for owner, members in pairs(value) do + if not player_name(owner) or type(members) ~= "table" or getmetatable(members) ~= nil then invalid() end + local normalized, count = {}, 0 + for member, role in pairs(members) do + if not player_name(member) or member == owner + or (role ~= "resident" and role ~= "storekeeper") then invalid() end + count = count + 1 + if count > 16 then invalid() end + normalized[member] = role + end + result[owner] = normalized + end + return result + end + -- JSON objects and empty arrays can both become {} in the engine. Nonempty + -- arrays are rejected by the string-key rule; an empty table is valid maps. + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid() end + local result = {} + for owner, pos in pairs(value) do + if type(owner) ~= "string" or owner == "" then invalid() end + result[owner] = position(pos, true) + end + return result +end + +-- value is already parsed JSON, NOT JSON text. Missing storage is the adapter's +-- responsibility. Legacy records must also be complete and valid: never clamp, +-- coerce strings, silently fill fields, or downgrade an unrecognized envelope. +function S.decode(kind, value) + if type(kind) ~= "string" or not kinds[kind] or type(value) ~= "table" + or getmetatable(value) ~= nil then invalid() end + local migrated = true + local envelope = value.schema ~= nil or value.kind ~= nil or value.data ~= nil + if kind == "claims" or kind == "homes" or kind == "access" then + -- Players may legitimately be named "schema", "kind", or "data". + -- Their legacy claim values are positions, never envelope scalars. + envelope = (value.schema ~= nil and type(value.schema) ~= "table") + or (value.kind ~= nil and type(value.kind) ~= "table") + or (value.data ~= nil and type(value.data) ~= "table") + end + if envelope then + record(value, {schema = true, kind = true, data = true}) + if type(value.schema) ~= "number" or value.schema ~= 1 or value.kind ~= kind then invalid() end + value, migrated = value.data, false + end + return {schema = 1, kind = kind, data = normalize(kind, value)}, migrated +end + +-- Each call owns its copy; callers cannot mutate future defaults. +function S.defaults() + local result = {} + for key, value in pairs(needs) do result[key] = value end + return result +end + +return S diff --git a/mods/hearth/seasons.lua b/mods/hearth/seasons.lua new file mode 100755 index 0000000..39d6c19 --- /dev/null +++ b/mods/hearth/seasons.lua @@ -0,0 +1,113 @@ +-- Original bounded seasonal appearance. No timers, world lists or clock replay. +return function(hearth, api) + local S = {COLUMN_HEIGHT = 12, MAX_COLUMN_SWAPS = 12} + local variants, canonical, availability = {}, {}, {} + local function variant(kind, season, texture, mesh) + variants[kind] = variants[kind] or {} + local short = kind .. "_" .. season:lower() + variants[kind][season] = {name="hearth:"..short, texture=texture, mesh=mesh} + canonical["hearth:"..short] = kind + end + for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree", "leaves"}) do + for _, season in ipairs({"Spring", "Autumn", "Winter"}) do + local suffix = season:lower() + local mesh = kind ~= "leaves" and ("hearth_"..kind + .. (season == "Winter" and kind ~= "tree_seedling" and "_winter" or "") .. ".obj") or nil + variant(kind, season, "hearth_" .. (kind == "leaves" and "leaves" or "tree_atlas") .. "_" .. suffix .. ".png", mesh) + end + end + for _, kind in ipairs({"meadow_grass", "fern", "wildflowers"}) do + variant(kind, "Winter", "hearth_"..kind.."_dormant.png") + if kind == "wildflowers" then variant(kind, "Autumn", "hearth_wildflowers_dormant.png") end + end + -- Patch nodes and picked food items have different names: never overwrite a + -- registered node with a craftitem, and never let picked food reset a patch. + variant("spring_greens_patch", "Spring", "hearth_spring_greens.png") + variant("autumn_mushrooms_patch", "Autumn", "hearth_autumn_mushrooms.png") + for kind in pairs(variants) do canonical["hearth:"..kind] = kind end + -- Extensions are installed by botany/diversity before node registration. Missing + -- bases in minimal mocks/optional content are skipped, never asserted. + function S.add_variants(kind, definitions, active_seasons) + assert(type(kind)=="string" and kind:match("^[a-z_]+$"), "invalid flora kind") + canonical["hearth:"..kind] = kind + for season, def in pairs(definitions or {}) do + assert(({Spring=true,Summer=true,Autumn=true,Winter=true})[season], "invalid season") + variant(kind, season, assert(def.texture), def.mesh) + end + availability[kind] = active_seasons + end + function S.kind(n) + local name = type(n) == "table" and n.name or n + return type(name) == "string" and (canonical[name] or name:match("^hearth:(.+)$")) or nil + end + function S.season(now) + local climate = hearth.climate_at and hearth.climate_at(now or os.time()) + return climate and climate.season or "Summer" + end + function S.name_for(kind, now) + kind = S.kind(kind) or kind + local v = variants[kind] and variants[kind][S.season(now)] + return v and v.name or "hearth:"..kind + end + function S.available(kind, now) + kind = S.kind(kind) or kind + local season = S.season(now) + if availability[kind] then return availability[kind][season] == true end + if kind == "spring_greens_patch" then return season == "Spring" end + if kind == "autumn_mushrooms_patch" then return season == "Autumn" end + if kind == "wildflowers" then return season == "Spring" or season == "Summer" end + return not variants[kind] or season ~= "Winter" + end + function S.names() + local names = {} + for name in pairs(canonical) do names[#names+1] = name end + return names + end + function S.reconcile(p, n, now) + -- Appearance alone can change a claimed/hand-placed plant, but never its + -- provenance, age, harvest deadline, dig permission, drops or inventory. + n = n or api.get_node_or_nil(p) + if not n or not canonical[n.name] then return false end + local protection = hearth.external_protected or api.is_protected + if protection and protection(p, "") then return false end + local target = S.name_for(canonical[n.name], now) + if n.name == target then return false end + -- Recheck loaded identity before swapping; no set_node metadata erasure. + local current = api.get_node_or_nil(p) + if not current or current.name ~= n.name then return false end + api.swap_node(p, {name=target, param1=current.param1, param2=current.param2}) + return true + end + function S.register() + local names = {} + for kind, seasons in pairs(variants) do + local base_name = "hearth:"..kind + local base = (api.registered_nodes or {})[base_name] + if base then + names[#names+1] = base_name + for _, v in pairs(seasons) do + local def = {} + for k, value in pairs(base) do def[k] = value end + def.groups = {} + for k, value in pairs(base.groups or {}) do def.groups[k] = value end + def.groups.not_in_creative_inventory = 1 + -- A stage may now be a bark cube rather than the old sculpted + -- tree. Recolour foliage/sprites only; never flatten end-grain + -- and bark faces or reintroduce an obsolete mesh on a cube. + if base.drawtype ~= "normal" then + def.tiles = {v.texture} + if base.inventory_image then def.inventory_image = v.texture end + if base.wield_image then def.wield_image = v.texture end + end + if base.drawtype == "mesh" and v.mesh then def.mesh = v.mesh end + def.drop = base.drop == nil and base_name or base.drop + if not api.registered_nodes[v.name] then api.register_node(v.name, def) end + names[#names+1] = v.name + end + end + end + api.register_lbm({name="hearth:seasonal_flora", label="Reconcile seasonal flora appearance", + nodenames=names, run_at_every_load=true, action=function(p) S.reconcile(p) end}) + end + return S +end diff --git a/mods/hearth/sounds/hearth_block.ogg b/mods/hearth/sounds/hearth_block.ogg new file mode 100755 index 0000000..10dfbf1 Binary files /dev/null and b/mods/hearth/sounds/hearth_block.ogg differ diff --git a/mods/hearth/sounds/hearth_denial.ogg b/mods/hearth/sounds/hearth_denial.ogg new file mode 100755 index 0000000..7c3f342 Binary files /dev/null and b/mods/hearth/sounds/hearth_denial.ogg differ diff --git a/mods/hearth/sounds/hearth_dig_soil.ogg b/mods/hearth/sounds/hearth_dig_soil.ogg new file mode 100755 index 0000000..7d4f2af Binary files /dev/null and b/mods/hearth/sounds/hearth_dig_soil.ogg differ diff --git a/mods/hearth/sounds/hearth_dig_stone.ogg b/mods/hearth/sounds/hearth_dig_stone.ogg new file mode 100755 index 0000000..42871f5 Binary files /dev/null and b/mods/hearth/sounds/hearth_dig_stone.ogg differ diff --git a/mods/hearth/sounds/hearth_dig_wood.ogg b/mods/hearth/sounds/hearth_dig_wood.ogg new file mode 100755 index 0000000..f082878 Binary files /dev/null and b/mods/hearth/sounds/hearth_dig_wood.ogg differ diff --git a/mods/hearth/sounds/hearth_fire.ogg b/mods/hearth/sounds/hearth_fire.ogg new file mode 100755 index 0000000..8885bd1 Binary files /dev/null and b/mods/hearth/sounds/hearth_fire.ogg differ diff --git a/mods/hearth/sounds/hearth_impact.ogg b/mods/hearth/sounds/hearth_impact.ogg new file mode 100755 index 0000000..a250356 Binary files /dev/null and b/mods/hearth/sounds/hearth_impact.ogg differ diff --git a/mods/hearth/sounds/hearth_land.ogg b/mods/hearth/sounds/hearth_land.ogg new file mode 100755 index 0000000..ee880f4 Binary files /dev/null and b/mods/hearth/sounds/hearth_land.ogg differ diff --git a/mods/hearth/sounds/hearth_step_soil.ogg b/mods/hearth/sounds/hearth_step_soil.ogg new file mode 100755 index 0000000..6b635b0 Binary files /dev/null and b/mods/hearth/sounds/hearth_step_soil.ogg differ diff --git a/mods/hearth/sounds/hearth_step_stone.ogg b/mods/hearth/sounds/hearth_step_stone.ogg new file mode 100755 index 0000000..bfaf128 Binary files /dev/null and b/mods/hearth/sounds/hearth_step_stone.ogg differ diff --git a/mods/hearth/sounds/hearth_step_water.ogg b/mods/hearth/sounds/hearth_step_water.ogg new file mode 100755 index 0000000..80c078b Binary files /dev/null and b/mods/hearth/sounds/hearth_step_water.ogg differ diff --git a/mods/hearth/sounds/hearth_step_wood.ogg b/mods/hearth/sounds/hearth_step_wood.ogg new file mode 100755 index 0000000..cdc7536 Binary files /dev/null and b/mods/hearth/sounds/hearth_step_wood.ogg differ diff --git a/mods/hearth/sounds/hearth_swing_axe.ogg b/mods/hearth/sounds/hearth_swing_axe.ogg new file mode 100755 index 0000000..ae0ed14 Binary files /dev/null and b/mods/hearth/sounds/hearth_swing_axe.ogg differ diff --git a/mods/hearth/sounds/hearth_swing_knife.ogg b/mods/hearth/sounds/hearth_swing_knife.ogg new file mode 100755 index 0000000..870d943 Binary files /dev/null and b/mods/hearth/sounds/hearth_swing_knife.ogg differ diff --git a/mods/hearth/sounds/hearth_swing_spear.ogg b/mods/hearth/sounds/hearth_swing_spear.ogg new file mode 100755 index 0000000..2f6bc76 Binary files /dev/null and b/mods/hearth/sounds/hearth_swing_spear.ogg differ diff --git a/mods/hearth/species_effects.lua b/mods/hearth/species_effects.lua new file mode 100755 index 0000000..778ec76 --- /dev/null +++ b/mods/hearth/species_effects.lua @@ -0,0 +1,116 @@ +-- Reviewed policy, not executable Wikipedia prose. No network, scheduler or map writes. +return function(hearth, api) + local E = {} + local B = assert(hearth.botany, "species effects require botany") + local articles=hearth.wiki_articles + if not articles and api.get_modpath then + articles=dofile(api.get_modpath("hearth").."/wiki_articles.lua") + end + local function copy(t) + if type(t)~="table" then return t end + local out={}; for k,v in pairs(t) do out[k]=copy(v) end; return out + end + local function finite(n) return type(n)=="number" and n==n and math.abs(n)0 then + if finite(h.moisture) then out.moisture=math.max(0,math.min(1,h.moisture+mats*0.1)) end + if finite(h.ph) then out.ph=math.max(3.5,math.min(8.5,h.ph-mats*0.2)) end + end + elseif h.freshwater==true and h.aquatic=="submerged" and finite(h.light) then + out.light=h.light-math.min(count(seen.duckweed,4)*1.5,math.max(0,h.light-2)) + end + return out + end + return E +end diff --git a/mods/hearth/species_reader.lua b/mods/hearth/species_reader.lua new file mode 100755 index 0000000..e62258c --- /dev/null +++ b/mods/hearth/species_reader.lua @@ -0,0 +1,138 @@ +-- Read-only offline UI. Injected APIs; no registrations, network or map writes. +return function(hearth, api, journal) + local R = {} + local esc = api.formspec_escape + local function session(player) + return player and hearth.sessions and hearth.sessions[player:get_player_name()] + end + local function finite(n) return type(n)=="number" and n==n and math.abs(n)120 then return nil end + value=value:match("^%s*(.-)%s*$") + -- Explicit IDs or exact canonical Latin names only. No node names, URLs, + -- substrings, common-name guessing or network fallback in chat input. + local B=hearth.botany + if not B or type(B.list)~="function" then return nil end + for _,r in ipairs(B.list()) do + if value==r.id or value==r.latin then return record(r.id) end + end + end + function R.look(player) + if not player or not player.get_pos or not player.get_look_dir or not player.get_properties + or not api.raycast or not api.get_node_or_nil then return nil end + local p,d=player:get_pos(),player:get_look_dir() + if not position(p) or not position(d) then return nil end + local height=(player:get_properties() or {}).eye_height or 1.55 + if not finite(height) or height<0 or height>3 then return nil end + local length=math.sqrt(d.x*d.x+d.y*d.y+d.z*d.z) + if length<0.001 then return nil end + local eye={x=p.x,y=p.y+height,z=p.z} + local finish={x=eye.x+d.x/length*6,y=eye.y+d.y/length*6,z=eye.z+d.z/length*6} + for hit in api.raycast(eye,finish,false,false) do + if hit.type=="node" then + local at=hit.intersection_point + if not position(hit.under) or not position(at) + or (at.x-eye.x)^2+(at.y-eye.y)^2+(at.z-eye.z)^2>36.000001 then return nil end + local node=api.get_node_or_nil(hit.under) + -- Stop at the first surface, even if unknown/unloaded. Never see through it. + if not node or node.name=="ignore" then return nil end + local B=hearth.botany + local id=B and B.kind and B.kind(node.name) + return id and record(id) or nil + end + end + end + local function effects(r) + local E=hearth.species_effects + local lines={"Current effects are game policy, not measured Wikipedia constants. Natural-history tags are not all modeled."} + local rows=E and E.describe and E.describe(r) or {} + if #rows==0 then lines[#lines+1]="Effect descriptions unavailable in this runtime." end + for i,row in ipairs(rows) do + if i>16 then break end + lines[#lines+1]=(clip(row.title,80)).." / "..(row.status=="simulated" and "SIMULATED" or "DESCRIPTIVE (not simulated)") + .."\n"..(clip(row.game,900)).."\nEvidence: "..(clip(row.note,600)) + .."\n"..(clip(row.source_url,512)).."\n"..(clip(row.evidence_url,512)) + end + return table.concat(lines,"\n\n") + end + function R.show(player, value, tab, page) + local s=session(player) + local r=record(value) + if not s or not r then return false,"Unknown species. Use an exact catalogue ID or Latin name." end + tab=tab==2 and 2 or 1 + local a=hearth.wiki_articles and hearth.wiki_articles[r.id] + local intro=type(a)=="table" and a.kind=="wikipedia_intro" + local missing=type(a)=="table" and a.kind=="original_notes" + local title=intro and "Wikipedia excerpt (offline)" or "No Wikipedia article cached (offline)" + local body=tab==2 and effects(r) or ((intro or missing) and a.text + or "No article is available in this runtime. No replacement Wikipedia text has been invented.") + local bounded,cut=clip(body,8000) + local second=clip(bounded,4000,4000) + local pages=second~="" and 2 or 1 + page=type(page)=="number" and page==page and math.max(1,math.min(pages,math.floor(page))) or 1 + s.species_reader={id=r.id,tab=tab,page=page} + local provenance={} + if intro or missing then + for _,pair in ipairs({{"Source title",a.title},{"Attribution",a.attribution},{"License",a.license}, + {"License URL",a.license_url},{"Canonical URL",a.url},{"Permanent revision",a.revision_url}, + {"History / contributors",a.history_url},{"Revision UTC",a.revision_timestamp}, + {"Retrieved UTC",a.retrieved},{"Changes",a.changes}}) do + provenance[#provenance+1]=pair[1]..": "..(clip(pair[2],512)) + end + end + local f=(journal and journal(14,12) or "formspec_version[6]size[14,12]bgcolor[#1e1e2e;true]") + .."label[0.6,0.5;"..safe(r.common.." ("..r.latin..")").."]" + .."label[0.6,1;"..safe(title).."]" + .."tabheader[0.6,1.8;reader_tab;Article,Current effects;"..tab..";false;false]" + -- Empty field names make these textareas read-only but copyable. + .."textarea[0.6,2.1;12.8,4.7;;;"..safe(clip(bounded,4000,(page-1)*4000),4000).."]" + .."label[0.6,6.9;Page "..page.." / "..pages..(cut and " / display limited to 8000 characters" or "").."]" + .."textarea[0.6,7.3;12.8,2.65;;;"..safe(table.concat(provenance,"\n"),6000).."]" + .."textarea[0.6,10;12.8,0.7;;;"..safe("Introduction only, not the full wiki. Plain text; URLs are copyable, never opened automatically. Not identification, foraging, food preparation or medical advice.").."]" + ..(page>1 and "button[0.6,11;2,0.6;reader_previous;Previous]" or "") + ..(page 30990 then return nil end + end + return {x=math.floor(p.x+0.5), y=math.floor(p.y+0.5), z=math.floor(p.z+0.5)} + end + local function node(p) + local n = api.get_node_or_nil(p) + if n and n.name ~= "ignore" then return n end + end + local function kind(n) + local name=type(n)=="table" and n.name or n + if type(name)~="string" then return nil end + return hearth.seasons and hearth.seasons.kind(name) or name:match("^hearth:(.+)$") + end + local function family(k) return diversity and diversity.family(k) or k end + local function number(meta, field, maximum) + local n = tonumber(meta:get_string(field)) + if not n or n ~= n or n < 0 or n == math.huge then return 0 end + return math.min(maximum or 1000000000000, n) + end + local markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"} + local function marked(p) + local meta = api.get_meta(p) + -- Unknown third-party placement/provenance metadata is not ours to erase. + -- Keep the named fallback for minimal API mocks and older integrations. + if meta.to_table then + local value = meta:to_table() + for field, text in pairs(value and value.fields or {}) do + local harvest=botany and field=="botany_ready_at" and tonumber(text) + and tonumber(text)>=0 and tonumber(text)0 then + local r=botany.get(node(p)) + h.aquatic=r and r.niche.aquatic or ((math.abs(p.x*17+p.z*31)%2==0) and "surface" or "submerged") + h.moisture=1 + elseif h.near_water>0 and h.moisture>=0.65 then h.aquatic="bank" end + end + return effects and effects.habitat(h,h.species_counts) or h + end + local function active(p, now) + local k = key(p) + local previous = seen[k] + if not previous then + local evicted = ring[ring_index] + if evicted then seen[evicted]=nil end + ring[ring_index]=k; ring_index=ring_index%S.CACHE_LIMIT+1 + end + seen[k]=now + -- Restart, cache eviction, clock rewind and long absences only rebaseline. + if not previous or now <= previous or now-previous > S.MAX_GAP then return 0 end + return now-previous + end + local function tree_clear(p) + -- Species mature geometry reaches five nodes/±1.5. Conservative + -- 5x5x6 target volume, checked loaded/protected/marked, never emerged. + for x=-2,2 do for z=-2,2 do for y=0,5 do + local q = at(p,x,y,z) + local n = node(q) + if not n or not permitted(q) then return false end + if not (x==0 and y==0 and z==0) and n.name ~= "air" then + if not natural(q,n) or trees[kind(n)] then return false end + end + end end end + return true + end + local tree_epoch + local function plan_for(species,stage) + local plan=diversity and diversity.tree_plan and diversity.tree_plan(species,stage) + if type(plan)~="table" or #plan==0 or #plan>S.MAX_TREE_CELLS then return nil end + local root,seen_cells=false,{} + for _,c in ipairs(plan) do + if type(c)~="table" then return nil end + for _,axis in ipairs({"x","y","z"}) do + local v=c[axis] + if type(v)~="number" or v~=v or v%1~=0 or math.abs(v)>(axis=="y" and 9 or 4) or (axis=="y" and v<0) then return nil end + end + local k=kind(c.name) + local f=family(k) + if f~="log" and f~="leaves" and f~="young_tree" and f~="mature_tree" then return nil end + local rootkind=species=="generic" and (stage=="young" and "young_tree" or "mature_tree") or species.."_"..stage + local wood=species=="generic" and "log" or species.."_log" + local leaves=species=="generic" and "leaves" or species.."_leaves" + if k~=rootkind and k~=wood and k~=leaves then return nil end + local rotation=c.param2 or 0 + if type(rotation)~="number" or rotation~=rotation or rotation%1~=0 or rotation<0 or rotation>23 then return nil end + if c.name:sub(1,7)~=PREFIX or seen_cells[key(c)] then return nil end + seen_cells[key(c)]=true + if c.x==0 and c.y==0 and c.z==0 then root=true end + end + return root and plan or nil + end + local function owned(p,root,expected) + local n=node(p) + local m=api.get_meta(p) + return n and natural(p,n) and m:get_string("succ_component")==root + and kind(n)==kind(expected.name) and (n.param2 or 0)==(expected.param2 or 0) + end + local function components(p,species,stage) + local plan=plan_for(species,stage) + if not plan then return nil end + local cells={} + for _,c in ipairs(plan) do cells[key(at(p,c.x,c.y,c.z))]=c end + return cells,plan + end + local function clear_component(q) + api.swap_node(q,{name="air"}) + local m=api.get_meta(q) + for _,f in ipairs({"succ_natural","succ_component","succ_species","succ_age","succ_stress"}) do m:set_string(f,"") end + end + local function tree_transition(p,target,removed) + -- Reserve before generating even the read-only geometry, so eight + -- competing roots cannot each construct a full plan in one second. + local epoch=os.time() + if tree_epoch==epoch then return false end + tree_epoch=epoch + local m=api.get_meta(p) + local current=removed or kind(node(p)) + local species,stage=diversity.tree_info(current) + species=species or "generic" + stage=stage or ({young_tree="young",mature_tree="mature"})[current] + local _,next_stage=diversity.tree_info(target) + next_stage=next_stage or ({young_tree="young",mature_tree="mature"})[target] + local previous=m:get_string("succ_tree_stage") + if removed then previous=stage end + if previous=="" then previous=nil end + local old,oldplan={},{} + if previous then + old,oldplan=components(p,species,previous) + if not old then return false end + end + local plan={} + if next_stage then plan=plan_for(species,next_stage); if not plan then return false end end + local root=key(p) + local destinations,extras={},{} + for _,c in ipairs(oldplan) do destinations[key(at(p,c.x,c.y,c.z))]={c=c,old=true} end + for _,c in ipairs(plan) do + local id=key(at(p,c.x,c.y,c.z)) + destinations[id]=destinations[id] or {}; destinations[id].next=c + end + -- One full plan preflight/application per wall-clock second globally, + -- including failed attempts. Never multiply tree work by player count. + for id,cell in pairs(destinations) do + local c=cell.c or cell.next + local q=at(p,c.x,c.y,c.z) + local n=node(q) + local support=at(q,0,-1,0) + if not position(q) or not n or not node(support) or not permitted(q) or not permitted(support) then return false end + if id~=root then + if cell.old then + if n.name~="air" and not owned(q,root,old[id]) then return false end + elseif n.name~="air" then return false end + elseif removed then + if n.name~="air" then return false end + elseif not natural(q,n) then return false end + -- Epiphytes are not tree components. Refuse growth into them; on + -- death remove only explicitly natural occupants losing their wood. + if not next_stage and cell.old and family(kind(c.name))=="log" then + local above=at(q,0,1,0) + local a=node(above) + if not a or not permitted(above) then return false end + if a.name~="air" and not destinations[key(above)] and not (botany and botany.kind(a) and natural(above,a)) then return false end + if botany and botany.kind(a) and natural(above,a) and not destinations[key(above)] then extras[key(above)]=above end + end + end + -- All destinations passed before the first write. Lua callbacks execute + -- synchronously; no yielding, inventory callbacks or map emergence here. + for _,q in pairs(extras) do clear_component(q) end + for id,cell in pairs(destinations) do + local c=cell.c or cell.next + local q=at(p,c.x,c.y,c.z) + if id~=root then + if cell.next then + local nextcell=cell.next + local name=hearth.seasons and hearth.seasons.name_for(kind(nextcell.name)) or nextcell.name + api.swap_node(q,{name=name,param2=nextcell.param2 or 0}) + local cm=api.get_meta(q) + cm:set_string("succ_natural","1"); cm:set_string("succ_component",root) + cm:set_string("succ_species",species) + elseif node(q).name~="air" then clear_component(q) end + end + end + m:set_string("succ_tree_stage",next_stage or "") + if removed then + m:set_string("succ_component",""); m:set_string("succ_natural","") + if hearth.invalidate_environment then hearth.invalidate_environment(p) end + return true + end + m:set_string("succ_component",next_stage and root or "") + return true + end + local function change(p, target) + -- All callers preflight; recheck source and natural support immediately before writing. + if not site(p) then return false end + local h=botany and S.habitat(p) + if botany then + if not h.loaded then return false end + if target and botany.kind(target) then + if not botany.suitable(target,h,hearth.seasons and hearth.seasons.season() or "Summer") then return false end + elseif target and (h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then return false end + elseif diversity and target and not diversity.supports_ground(target,node(at(p,0,-1,0))) then return false end + local expanding=family(target)=="young_tree" or family(target)=="mature_tree" + local managed=api.get_meta(p):get_string("succ_tree_stage")~="" + if diversity and (expanding or managed) and (botany or managed or hearth.biomes) then + if not tree_transition(p,target) then return false end + elseif expanding and not tree_clear(p) then return false end + -- A wood/litter substrate can carry one natural fungus/epiphyte above + -- it. Do not leave that occupant hanging when its substrate is spent. + if botany and (kind(node(p))=="deadwood" or kind(node(p))=="litter") then + local above=at(p,0,1,0) + local a=node(above) + if not a or not permitted(above) then return false end + if a.name~="air" then + if not natural(above,a) or not botany.kind(a) then return false end + clear_component(above) + end + end + local origin=diversity and diversity.tree_info(kind(node(p))) + local name = target and (hearth.seasons and hearth.seasons.name_for(target) or PREFIX..target) or "air" + api.swap_node(p, {name=name,param2=target=="deadwood" and 12 or 0}) + local m = api.get_meta(p) + local species=diversity and diversity.tree_info(target) + if species or origin then m:set_string("succ_species",species or origin) + elseif target~="deadwood" and target~="litter" then m:set_string("succ_species","") end + m:set_string("succ_natural", target and "1" or "") + m:set_string("succ_age", "0"); m:set_string("succ_stress", "0") + if hearth.invalidate_environment then hearth.invalidate_environment(p) end + return true + end + -- Optional after-dig handoff. Root identity comes from the old node, never + -- executable serialized metadata. Only this root's matching components go. + function S.tree_removed(raw,oldnode) + local p=position(raw) + if not p or not diversity or not oldnode then return false end + local k=kind(oldnode) + local f=family(k) + if f~="young_tree" and f~="mature_tree" then return false end + if not node(p) or node(p).name~="air" or not permitted(p) or not permitted(at(p,0,-1,0)) then return false end + return tree_transition(p,nil,k) + end + if api.register_on_dignode then api.register_on_dignode(function(p,oldnode) S.tree_removed(p,oldnode) end) end + local function nutrients(p, amount) + local below = at(p,0,-1,0) + if not ground(below) or not permitted(below) then return end + if diversity and not diversity.is_ground(node(below)) then return end + local m = api.get_meta(below) + m:set_string("succ_nutrients", tostring(math.max(0,math.min(8,number(m,"succ_nutrients",8)+amount)))) + return true + end + local function disperse(p, target, serial) + local d = offsets[serial%#offsets+1] + local q = at(p,d[1],0,d[2]) + if not site(p) or not site(q) or node(q).name ~= "air" then return false end + local dest = S.habitat(q) + if not dest.loaded then return false end + if botany and botany.kind(target) then + if not botany.suitable(target,dest,hearth.seasons and hearth.seasons.season() or "Summer") then return false end + elseif diversity and not diversity.suitable(target,dest) then return false end + if family(target) == "tree_seedling" and (dest.canopy >= 1.5 or dest.grass+dest.flowers >= 15) then return false end + if target == "meadow_grass" and (dest.light < 9 or dest.moisture>=0.75) then return false end + if target == "wildflowers" and (dest.light < 11 or dest.grass >= 10) then return false end + if target == "fern" and (dest.light > 12 or dest.moisture < 0.55) then return false end + if target == "spring_greens_patch" and (dest.light < 9 or dest.moisture < 0.55) then return false end + if target == "autumn_mushrooms_patch" and (dest.light > 10 or dest.moisture < 0.6) then return false end + if hearth.seasons and not hearth.seasons.available(target) then return false end + local m = api.get_meta(q) + -- Destination cooldown survives grazing, decay and restart; no repeated seed rerolls. + local now = os.time() + if number(m,"succ_next") > now then return false end + if not change(q,target) then return false end + m:set_string("succ_next", tostring(now+PERIOD)) + nutrients(q,-1) + return true + end + local function observe(p, now) + if not site(p) then seen[key(p)]=nil; return false end + local component=api.get_meta(p):get_string("succ_component") + if component~="" and component~=key(p) then return false end + local dt = active(p,now) + local m, n = api.get_meta(p), node(p) + if dt == 0 then return false end + local h = S.habitat(p) + if not h.loaded then seen[key(p)]=nil; return false end + local k = kind(n) + local f=family(k) + local species=diversity and diversity.tree_info(k) + local record=botany and botany.get(k) + local season=hearth.seasons and hearth.seasons.season(now) or "Summer" + local climate = hearth.climate_at and hearth.climate_at(now) + local warm = not climate or (climate.temperature or 12) >= 3 + local decomposer = k=="dung" or k=="litter" or k=="deadwood" or k=="fungi" + local dormant = (record and not botany.active(k,season)) or (hearth.seasons and not hearth.seasons.available(k,now)) + local age_dt = (not decomposer and (not warm or dormant)) and 0 or dt + local age = number(m,"succ_age",1000000)+age_dt + m:set_string("succ_age", tostring(age)) + if number(m,"succ_next") > now then return false end + -- Commit the attempt BEFORE evaluating it, including failed competition/dispersal. + m:set_string("succ_next", tostring(now+PERIOD)) + local serial = number(m,"succ_serial",1000000000)+1 + m:set_string("succ_serial", tostring(serial)) + local fertility = number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8) + if n.name == "air" then + if warm and (botany or not hearth.seasons or hearth.seasons.season(now)~="Winter") + and age >= (fertility>0 and 90 or 180) and h.light >= (botany and 2 or 5) then + local target = h.light<10 and h.moisture>=0.55 and "fern" or "meadow_grass" + if h.light>=11 and h.grass>=2 and h.grass<10 and (p.x+p.z)%4==0 then target="wildflowers" end + if hearth.seasons then + if hearth.seasons.season(now)=="Autumn" and target=="wildflowers" then target="meadow_grass" end + if hearth.seasons.available("spring_greens_patch",now) and h.light>=9 and h.moisture>=0.55 + and (p.x*3+p.z*5)%7==0 then target="spring_greens_patch" end + if hearth.seasons.available("autumn_mushrooms_patch",now) and h.light<=10 and h.moisture>=0.6 + and (p.x+p.z)%5==0 then target="autumn_mushrooms_patch" end + end + if botany then + -- No optimistic meadow fallback for bark, saturated banks, + -- water or a missing botanical niche. Use the actual returned + -- node kind, never the descriptor ID (trees have stage IDs). + h.include_trees=age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 + target=botany.choose(p,h,season) + if not target then return false end + elseif diversity then + target=diversity.choose_recruit(p,h,hearth.seasons and hearth.seasons.season(now) or "Summer") or target + -- Saturated riparian soils cannot silently fall back to meadow; + -- exposed bedrock admits lichen only, never soil replacement. + if not diversity.supports_ground(target,node(at(p,0,-1,0))) + or (target=="meadow_grass" and h.moisture>=0.75) then return false end + end + if h.light<9 and target=="meadow_grass" then return false end + if change(p,target) then nutrients(p,-1); return true end + end + return false + end + -- Perennials rest rather than die at first frost. Autumn litter keeps + -- entering the nutrient cycle in winter; decomposition remains active. + if not decomposer and (not warm or dormant) then + if f=="mature_tree" and serial%5==0 then disperse(p,"litter",serial) end + return false + end + local harsh = (k=="meadow_grass" and h.light<8) or (k=="wildflowers" and (h.light<10 or h.grass>=12)) + or (k=="fern" and (h.moisture<0.5 or h.light>13)) or (trees[k] and (h.moisture<0.25 or h.light<4)) + if record then + if not botany.suitable(k,h,season) then harsh=true end + elseif diversity and diversity.is_worldgen_flora(k) and not diversity.suitable(k,h) then harsh=true end + local stress_dt=botany and h.nursery>0 and dt*0.75 or dt + local stress = harsh and number(m,"succ_stress",100000)+stress_dt or 0 + m:set_string("succ_stress",tostring(stress)) + if stress>=600 then + local remains=trees[k] and "deadwood" or "litter" + if botany and ((record and record.group=="fungi") or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then remains=nil end + if diversity and diversity.is_rock(node(at(p,0,-1,0))) then remains=nil end + return change(p,remains) + end + if decomposer then + local lifetime = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k] + if age>=lifetime then + local target = k=="deadwood" and "litter" or nil + if change(p,target) then nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or 1)); return true end + elseif k~="fungi" and h.moisture>=0.55 and h.light<=12 and age>=300 then + disperse(p,"fungi",serial) + end + return false + end + if not warm then return false end + if record and age>=900 and record.tags.nitrogenfixer then + local below=at(p,0,-1,0) + local soil=api.get_meta(below) + local fixed=number(soil,"succ_fixed_nitrogen",2) + local delta=math.min(botany.nitrogen(k,fixed),math.max(0,4-fertility)) + if delta>0 and fertility<4 and permitted(p) and permitted(below) and nutrients(p,delta) then + soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta)) + end + end + local seed_age=21600 + local exchange=0 + if botany and species and f=="tree_seedling" then + for _,fungus in ipairs(h.root_fungi) do exchange=math.max(exchange,botany.root_fungi(fungus,species,fertility)) end + if exchange>0 then seed_age=16200 end + end + if f=="tree_seedling" and age>=seed_age and h.canopy<1.5 then + if change(p,species and species.."_young" or "young_tree") then + if exchange>0 then nutrients(p,-exchange) end + return true + end + return false + end + if f=="young_tree" and age>=86400 and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end + if f=="mature_tree" then + if age>=259200 then return change(p,"deadwood") end + if age>=1800 and serial%10==0 then disperse(p,species and species.."_seedling" or "tree_seedling",serial) end + if serial%5==0 then disperse(p,"litter",serial) end + elseif record and not trees[k] then + local lifetime=({shrubs=86400,grasses=21600,herbs=14400,ferns_mosses=43200,fungi=2400,aquatic=21600,lichens=86400})[record.group] + if record.tags.saprotroph and (h.substrate=="deadwood" or h.substrate=="litter") + and botany.suitable(k,h,season) then + -- Advance only an existing natural substrate's finite clock; + -- never grant the fungus a second nutrient payout of its own. + local below=at(p,0,-1,0) + if natural(below,node(below)) and permitted(below) then + local wood=api.get_meta(below) + local limit=h.substrate=="deadwood" and 7200 or 1800 + wood:set_string("succ_age",tostring(math.min(limit,number(wood,"succ_age",limit)+math.min(dt,PERIOD)))) + end + end + if age>=lifetime then + local remains="litter" + if record.group=="fungi" or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter" + or (diversity and diversity.is_rock(node(at(p,0,-1,0)))) then remains=nil end + return change(p,remains) + end + if age>=7200 and h.water==0 and ground(at(p,0,-1,0)) and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then + local th={}; for field,value in pairs(h) do th[field]=value end; th.include_trees=true + -- The same catalogue selector is the source of all ten trees; + -- skip non-tree results without changing its deterministic order. + local recruit=botany.choose(p,th,season) + if recruit and trees[recruit] then return change(p,recruit) end + end + if age>=900 and serial%3==0 then disperse(p,k,serial) end + elseif diversity and diversity.is_worldgen_flora(k) and not trees[k] then + if age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then + local recruit=diversity.choose_tree(p,h) + if recruit then return change(p,recruit) end + end + if k=="clover" and age>=900 then + local below=at(p,0,-1,0) + local soil=api.get_meta(below) + local fixed=number(soil,"succ_fixed_nitrogen",2) + -- At most two nitrogen additions per support, across deaths and + -- restart. No endlessly farmable item or per-tick fertilizer loop. + if fixed<2 and fertility<4 and permitted(p) and permitted(below) then + nutrients(p,1); soil:set_string("succ_fixed_nitrogen",tostring(fixed+1)) + end + end + if age>=21600 then + if diversity.is_rock(node(at(p,0,-1,0))) then return change(p,nil) end + return change(p,"litter") + end + if age>=900 and serial%3==0 then disperse(p,k,serial) end + elseif k=="spring_greens_patch" or k=="autumn_mushrooms_patch" then + if age>=900 and serial%3==0 then disperse(p,k,serial) end + elseif k=="meadow_grass" or k=="wildflowers" or k=="fern" then + -- Occasional native woody recruitment in old meadows, not an instant forest. + local recruitment_age = h.grass>=12 and fertility==0 and 14400 or 7200 + if k=="meadow_grass" and age>=recruitment_age and h.canopy<(diversity and 1.5 or 0.5) and h.moisture>=0.4 + and (p.x*7+p.z*11)%23==0 then + local recruit=diversity and diversity.choose_tree(p,h) or (not diversity and "tree_seedling") + if recruit then return change(p,recruit) end + end + if age>=(k=="wildflowers" and 14400 or 21600) then return change(p,"litter") end + if age>=900 and serial%3==0 then disperse(p,k,serial) end + end + return false + end + local function nearby(p, fn) + -- Nine cells, with one mutation at most. API input is never an unbounded radius. + for x=-1,1 do for z=-1,1 do + local q = at(p,x,0,z) + if fn(q) then return true end + end end + return false + end + function S.forage(raw) + local p = position(raw) + if not p or not node(p) or not permitted(p) then return false end + return nearby(p,function(q) + local n = node(q) + local k=kind(n) + if not natural(q,n) then return false end + if botany and botany.kind(k) then + if not botany.browse(k,hearth.seasons and hearth.seasons.season() or "Summer") + or (trees[k] and family(k)~="tree_seedling") then return false end + elseif diversity then if not diversity.edible(k) then return false end + elseif k~="meadow_grass" and k~="fern" then return false end + if hearth.seasons and not hearth.seasons.available(k) then return false end + if not change(q,nil) then return false end + api.get_meta(q):set_string("succ_next",tostring(os.time()+300)) + return true + end) + end + function S.deposit(raw) + local p = position(raw) + if not p or not node(p) or not permitted(p) then return false end + return nearby(p,function(q) + local n = node(q) + if not n or n.name~="air" then return false end + if number(api.get_meta(q),"succ_next")>os.time() then return false end + if not change(q,"dung") then return false end + api.get_meta(q):set_string("succ_next",tostring(os.time()+PERIOD)) + return true + end) + end + function S.step(dtime) + if type(dtime)~="number" or dtime~=dtime or dtime<0 then return 0 end + elapsed=math.min(S.INTERVAL,elapsed+dtime) + if elapsed30900 then return false end + end + return true + end + local function at(p,x,y,z) return {x=p.x+x,y=p.y+y,z=p.z+z} end + local function cell(p) return {x=math.floor(p.x+0.5),y=math.floor(p.y+0.5),z=math.floor(p.z+0.5)} end + local function distance(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end + local function permitted(p) + if hearth.claim_owner_at then + local owner, ambiguous=hearth.claim_owner_at(p) + if owner or ambiguous then return false end + end + local protect=hearth.external_protected or api.is_protected + return type(protect)=="function" and not protect(p,"") + end + local function node(p) + if not valid(p) or not permitted(p) then return nil end + local n=api.get_node_or_nil(p) + if n and n.name~="ignore" then return n end + end + local function natural(p,n) + if not n or api.get_meta(p):get_string("succ_natural")~="1" then return false end + local k=hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$") + if hearth.seasons and not hearth.seasons.available(k) then return false end + if hearth.diversity then return hearth.diversity.edible(k) end + return k=="meadow_grass" or k=="fern" + end + local function clear(p) + local n=node(p) + return n and (n.name=="air" or natural(p,n) or n.name=="hearth:dung") + end + local function safe(p,kind) + if not valid(p) or not permitted(p) then return false end + local s=species[kind] + -- A yaw-independent square body envelope; only flat natural terrain. + local r=math.max(s.radius,s.length) + for _, x in ipairs({-r,0,r}) do for _, z in ipairs({-r,0,r}) do + local n=node(cell(at(p,x,-0.05,z))) + if not n or not (n.name=="hearth:grass" or n.name=="hearth:soil" + or (hearth.diversity and hearth.diversity.is_ground(n))) then return false end + for _, y in ipairs({0.05,1.1,s.height,2.1}) do + if not clear(cell(at(p,x,y,z))) then return false end + end + end end + return true + end + local function path(a,b,kind) + if not valid(a) or not valid(b) or distance(a,b)>2.26 then return false end + for i=0,6 do + local t=i/6 + if not safe({x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t},kind) then return false end + end + return true + end + local function habitat(p) + local s=hearth.succession + if not s or not s.habitat then return false end + local c=cell(at(p,0,0.1,0)) + local h=s.habitat(c) + if not h or h.loaded~=true or ((h.grass or 0)<=0 and (h.cover or 0)<=0 and (h.edible or 0)<=0) then return false end + -- cover also counts trees: a forest without edible meadow/fern is not + -- a recruitment or breeding site. Read at most nine loaded cells. + for x=-1,1 do for z=-1,1 do + local q=at(c,x,0,z) + if natural(q,node(q)) then return true end + end end + return false + end + local function nearby(p,r,kind,exclude) + local n=0 + for e in pairs(active) do + local q=e.object:get_pos() + if e~=exclude and valid(q) and (not kind or e.kind==kind) and distance(p,q)<=r*r then n=n+1 end + end + return n + end + local function room(p,kind) + if total>=GLOBAL or counts[kind]>=species[kind].cap or nearby(p,16)>=LOCAL then return false end + local close=false + for _, player in ipairs(api.get_connected_players()) do + local q=player:get_pos() + if valid(q) and distance(p,q)<=RADIUS*RADIUS then + close=true + if nearby(q,RADIUS)>=PLAYER then return false end + end + end + return close + end + local function forget(e) + if active[e] then active[e]=nil; total=total-1; counts[e.kind]=counts[e.kind]-1 end + end + local function remove(e) + forget(e) -- release before remove/on_deactivate: each prey counts once + e.object:remove() + end + local function spawn(p,kind) + if not safe(p,kind) or not habitat(p) or not room(p,kind) then return false end + local obj=api.add_entity(p,"hearth:"..kind) + local e=obj and obj:get_luaentity() + return e and active[e] or false + end + local function callback_site(e) + local p=e.object:get_pos() + if not safe(p,e.kind) then return nil end + local c=cell(at(p,0,0.1,0)) + -- forage/deposit may mutate ANY cell of their 3x3 stencil. + for x=-1,1 do for z=-1,1 do + if not safe({x=c.x+x,y=c.y-0.5,z=c.z+z},e.kind) then return nil end + end end + return c + end + local function move(e,dx,dz) + local a=e.object:get_pos() + local len=math.sqrt(dx*dx+dz*dz) + if len<0.001 then return false end + local stride=e.kind=="fox" and 1.2 or 0.9 + local b=at(a,dx/len*stride,0,dz/len*stride) + if not path(a,b,e.kind) then return false end + if nearby(b,16,nil,e)>=LOCAL then return false end + for _, player in ipairs(api.get_connected_players()) do + local p=player:get_pos() + if valid(p) and distance(b,p)<=RADIUS*RADIUS and nearby(p,RADIUS,nil,e)>=PLAYER then return false end + end + e.object:set_velocity({x=0,y=0,z=0}) + e.object:set_pos(b) -- short swept ground stride; gravity remains enabled + local atan2=math.atan2 or function(y,x) return math.atan(y,x) end + e.object:set_yaw(atan2(-dx,dz)) -- authored forward +Z + return true + end + local function threat(e,p) + local closest,best=nil,64 + for _, player in ipairs(api.get_connected_players()) do + local q=player:get_pos() + if valid(q) and distance(p,q)35 or not habitat(p) then return false end + for mate in pairs(active) do + local q=mate.object:get_pos() + if mate~=e and mate.kind==e.kind and mate.age>=60 and mate.food>=needed + and mate.hunger<=35 and clock>=mate.breed_at and valid(q) and path(p,q,e.kind) then + for _, d in ipairs(directions) do + local b=at(p,d[1]*0.8,0,d[2]*0.8) + if path(p,b,e.kind) and spawn(b,e.kind) then + e.food=e.food-needed; mate.food=mate.food-needed + e.breed_at=clock+90; mate.breed_at=clock+90 + births=births+1; return true + end + end + return false + end + end + return false + end + local function think(e) + local p=e.object:get_pos() + if not safe(p,e.kind) then remove(e); return end + local danger=threat(e,p) + if danger then + if not move(e,p.x-danger.x,p.z-danger.z) then move(e,-(p.z-danger.z),p.x-danger.x) end + return + end + local site=callback_site(e) + if e.dung_at and clock>=e.dung_at and site then + local s=hearth.succession + if s and s.deposit and s.deposit(site) then dung=dung+1 end + e.dung_at=nil -- one attempt per meal, never a growing queue + return + end + if breed(e,p) then return end + if e.kind=="fox" then + local prey,best=nil,144 + for other in pairs(active) do + local q=other.object:get_pos() + if other.kind=="hare" and valid(q) and distance(p,q)=15 then + local q=prey.object:get_pos() + if best<=1.44 and clock>=e.feed_at and path(p,q,"fox") and safe(q,"hare") then + remove(prey); meals=meals+1; e.food=math.min(6,e.food+1) + e.hunger=math.max(0,e.hunger-35); e.feed_at=clock+20; e.dung_at=clock+8 + else move(e,q.x-p.x,q.z-p.z) end + return + end + elseif e.hunger>=15 and clock>=e.feed_at and site and habitat(p) then + local edible=false + for x=-1,1 do for z=-1,1 do + local q=at(site,x,0,z) + if natural(q,node(q)) then edible=true end + end end + local s=hearth.succession + if edible and s and s.forage and s.forage(site) then + e.food=math.min(6,e.food+1); e.hunger=math.max(0,e.hunger-30) + e.feed_at=clock+12; e.dung_at=clock+8; grazed=grazed+1; return + end + end + for i=1,3 do + local d=directions[(e.id+e.turn+i)%#directions+1] + local q=at(p,d[1]*0.9,0,d[2]*0.9) + if habitat(q) and move(e,d[1],d[2]) then e.turn=e.turn+1; return end + end + e.turn=e.turn+1 + end + for _, kind in ipairs(kinds) do + local s=species[kind] + api.register_entity("hearth:"..kind, { + initial_properties={physical=true,collide_with_objects=true, + collisionbox={-s.radius,0,-s.length,s.radius,s.body,s.length}, + visual="mesh",mesh="hearth_"..kind..".obj",textures={"hearth_"..kind..".png"}, + visual_size={x=1,y=1},static_save=false,pointable=false,makes_footstep_sound=false}, + on_activate=function(self,staticdata,dtime_s) + if active[self] then return end + local p=self.object:get_pos() + -- add_entity may activate synchronously; enforce caps centrally here. + if (staticdata and staticdata~="") or (dtime_s and dtime_s~=0) + or not safe(p,kind) or not habitat(p) or not room(p,kind) then self.object:remove(); return end + serial=serial+1; self.id=serial; self.kind=kind; self.age=0; self.turn=0 + self.hunger=20; self.food=0; self.feed_at=clock+8; self.breed_at=clock+60 + self.next_ai=clock+2; active[self]=true; total=total+1; counts[kind]=counts[kind]+1 + self.object:set_armor_groups({immortal=1}) + self.object:set_acceleration({x=0,y=-9.81,z=0}) + self.object:set_velocity({x=0,y=0,z=0}) + end, + on_step=function(self,dt) + if not active[self] then return end + local t=delta(dt) + self.age=self.age+t; self.hunger=math.min(100,self.hunger+t*0.6) + if self.age>=900 or self.hunger>=100 then remove(self); return end + local p=self.object:get_pos() + if not valid(p) or not permitted(p) or not node(cell(p)) then remove(self) end + end, + on_punch=function() return true end, + on_deactivate=forget, + }) + end + api.register_globalstep(function(dt) + local t=delta(dt) + clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET); recruitment=recruitment+t + local list={} + for e in pairs(active) do list[#list+1]=e end + table.sort(list,function(a,b) return a.id=e.next_ai then + credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e) + end + end + if recruitment<30 or credit<1 then return end + recruitment=0; credit=credit-1; actions=actions+1 + local players=api.get_connected_players() + if #players==0 then return end + player_cursor=player_cursor%#players+1; recruit_kind=recruit_kind%#kinds+1 + local center=players[player_cursor]:get_pos() + if not valid(center) then return end + local kind=kinds[recruit_kind] + if kind=="fox" and nearby(center,RADIUS,"hare")<2 then return end + for _, d in ipairs(offsets) do for y=2,-3,-1 do + local p={x=math.floor(center.x+d[1]+0.5),y=math.floor(center.y+0.5)+y-0.5,z=math.floor(center.z+d[2]+0.5)} + if spawn(p,kind) then return end + end end + end) + return {species="hearth:hare",global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET, + status=function() + return {total=total,hare=counts.hare,deer=counts.deer,fox=counts.fox, + global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL, + actions=actions,births=births,meals=meals,grazed=grazed,dung=dung} + end} +end diff --git a/package.json b/package.json new file mode 100755 index 0000000..9c43af0 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "last-hearth", + "version": "0.5.0-m4", + "private": true, + "type": "module", + "engines": { "node": ">=22" }, + "scripts": { + "test": "node --test tests/*.test.mjs", + "test:engine": "node scripts/run.mjs test", + "test:seeds": "node scripts/run.mjs test:seeds", + "test:course": "node scripts/run.mjs test:course", + "test:recovery": "node scripts/recovery.mjs", + "play": "node scripts/run.mjs play", + "course": "node scripts/run.mjs course", + "package": "node scripts/run.mjs package" + } +} diff --git a/release.json b/release.json new file mode 100755 index 0000000..7fb80cb --- /dev/null +++ b/release.json @@ -0,0 +1,9 @@ +{ + "format": 1, + "gameId": "last_hearth", + "version": "0.5.0-m4", + "engine": { "name": "Luanti", "version": "5.18.0" }, + "node": ">=22", + "packageFormat": 1, + "backupFormat": 1 +} diff --git a/scripts/assets.mjs b/scripts/assets.mjs index fe4cc22..b8c6043 100755 --- a/scripts/assets.mjs +++ b/scripts/assets.mjs @@ -2,6 +2,11 @@ import { deflateSync } from 'node:zlib'; import * as fs from 'node:fs/promises'; import path from 'node:path'; +import { ensureDirectory, relativeName, safePath } from './files.mjs'; +import { BODY_PROFILES, playerB3D, soundOGG, SOUND_PROFILES, TOOL_NAMES, toolOBJ } from './media.mjs'; +import { ANIMAL_NAMES, BOTANY_CATALOGUE, animalOBJ, contactSheet, legacyPixel, worldArt } from './world-art.mjs'; +import { FLORA_NAMES, TREE_SEASONS, SPECIES_ATLAS_NAMES, floraOBJ, treeAtlas } from './flora-art.mjs'; +import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterArt, characterMesh, compositeCharacter, portraitSheet } from './character-art.mjs'; function crc32(data) { let crc = 0xffffffff; @@ -17,52 +22,130 @@ function chunk(type, data) { size.writeUInt32BE(data.length); crc.writeUInt32BE(crc32(body)); return Buffer.concat([size, body, crc]); } -function png(size, pixel) { - const rows = Buffer.alloc(size * (size * 4 + 1)); - for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { - const color = pixel(x, y), offset = y * (size * 4 + 1) + 1 + x * 4; +export function png(width, pixel, height = width) { + if (!Number.isInteger(width) || !Number.isInteger(height) || width<1 || height<1 || + width>2048 || height>2048 || width*height>1024*1024 || typeof pixel!=='function') + throw new Error('Invalid bounded PNG dimensions or pixel callback.'); + const rows = Buffer.alloc(height * (width * 4 + 1)); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const color = pixel(x, y), offset = y * (width * 4 + 1) + 1 + x * 4; + if (!color || color.length!==4 || !Array.from(color).every(n=>Number.isInteger(n) && n>=0 && n<=255)) + throw new Error('Invalid RGBA pixel.'); rows.set(color, offset); } const header = Buffer.alloc(13); - header.writeUInt32BE(size); header.writeUInt32BE(size, 4); header[8] = 8; header[9] = 6; + header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 6; return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]), chunk('IHDR', header), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]); } -const icons = { - knife: (x,y) => (Math.abs(x + y - 28) < 3 && x > 9 && x < 24) ? [180,185,173,255] - : (Math.abs(x + y - 28) < 2 && x >= 23) ? [119,86,57,255] : null, - axe: (x,y) => Math.abs(x-y) < 2 && x > 7 && x < 27 ? [139,103,65,255] - : x > 5 && x < 20 && y > 4 && y < 14 && x+y < 29 ? [157,164,151,255] : null, - pick: (x,y) => Math.abs(x-y) < 2 && x > 10 && x < 28 ? [139,103,65,255] - : y > 5 && y < 10 && x > 4 && x < 25 ? [157,164,151,255] : null, - spear: (x,y) => Math.abs(x+y-31) < 2 && x > 5 && x < 27 ? [157,124,80,255] - : x > 22 && y < 10 && x-y > 18 ? [185,188,177,255] : null, +function inside(x, y, polygon) { + let hit = false; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [a,b] = polygon[i], [c,d] = polygon[j]; + if ((b > y) !== (d > y) && x < (c-a) * (y-b) / (d-b) + a) hit = !hit; + } + return hit; +} +const blades = { + knife: [[12,17],[24,3],[23,12],[16,21]], + axe: [[5,7],[14,3],[22,9],[17,17],[12,16],[11,11],[6,12]], + pick: [[4,10],[9,5],[18,5],[27,11],[20,9],[13,9],[8,11]], + spear: [[21,11],[28,2],[27,12],[23,15]], }; +function icon(name, x, y) { + const diagonal = name === 'knife' || name === 'spear'; + const shaft = diagonal ? [[4,26],[6,28],[25,9],[23,7]] : [[11,10],[14,9],[27,27],[24,29]]; + let color = null; + if (inside(x+.5,y+.5,shaft)) color = (x+y)%5 === 0 ? [88,57,39] : [157,112,65]; + if (inside(x+.5,y+.5,blades[name])) color = (x+y)%4 === 0 ? [227,233,220] : [159,181,180]; + if (color && ((diagonal && x<12) || (!diagonal && y>21)) && (x+y)%3 === 0) color = [211,183,128]; + return color ? [...color,255] : [0,0,0,0]; +} + +// Only mod Lua is an input. In particular never traverse repository scripts, +// generated assets, tests or symlink/junction targets. Bounds cap accidental input. +async function sourceColors(source) { + const colors = new Set(['c7aa83','947853']); + let entries = 0; + async function visit(directory, depth = 0) { + if (depth > 32) throw new Error('Mod directory nesting limit exceeded.'); + await safePath(directory); + for (const name of (await fs.readdir(directory)).sort()) { + relativeName(name); + if (++entries > 10000) throw new Error('Mod source entry limit exceeded.'); + const filename = path.join(directory, name), stat = await fs.lstat(filename); + if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction in mod assets input.'); + if (stat.isDirectory()) await visit(filename, depth+1); + else if (!stat.isFile()) throw new Error('Mod assets input must contain regular files.'); + else if (name.endsWith('.lua')) { + if (stat.size > 2*1024*1024) throw new Error('Mod Lua input exceeds size limit.'); + const text = await fs.readFile(await safePath(filename), 'utf8'); + for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase()); + if (colors.size > 4096) throw new Error('Mod texture color limit exceeded.'); + } + } + } + await visit(path.join(source, 'mods')); + return [...colors].sort(); +} + +async function writeAsset(directory, name, bytes) { + // Packaging supplies a private staging tree in a trusted parent; guard output + // links too, so direct invocation cannot overwrite a linked external asset. + const filename = await safePath(path.join(directory, relativeName(name))); + await fs.writeFile(filename, bytes, {mode: 0o600}); +} + +// Per-image provenance is included in the hash-verified package, not just in a +// source comment. No inference from filename/common-name or unsafe Lua execution. +export const BOTANY_ART_MANIFEST = Object.freeze(BOTANY_CATALOGUE.flatMap(({id,latin,group,morphology})=> + [false,true].map(dormant=>Object.freeze({id,botanicalLatin:latin,group,morphology, + state:dormant?'dormant':'active',filename:`hearth_botany_${id}${dormant?'_dormant':''}.png`, + width:32,height:32,license:'MIT',author:'The Last Hearth original procedural art'})))); export async function generateAssets(destination, source) { + const colors = await sourceColors(source); const textures = path.join(destination, 'mods/hearth/textures'); - await fs.mkdir(textures, {recursive:true}); - const files = ['content.lua','encounters.lua','runtime.lua']; - const colors = new Set(['c7aa83','947853']); - for (const file of files) { - const text = await fs.readFile(path.join(source, 'mods/hearth', file), 'utf8'); - for (const match of text.matchAll(/#[0-9a-fA-F]{6}\b/g)) colors.add(match[0].slice(1).toLowerCase()); - } + await ensureDirectory(textures); for (const color of colors) { - const rgb = [0,2,4].map(i=>parseInt(color.slice(i,i+2),16)); - const image = png(32, (x,y)=> { - const noise = ((x*73+y*151+x*y*7) % 19)-9; - const grain = (y % 8 === 0) ? -12 : (x % 13 === 0 ? 5 : 0); - return [...rgb.map(c=>Math.max(0,Math.min(255,c+noise+grain))),255]; - }); - await fs.writeFile(path.join(textures, `hearth_${color}.png`), image); + const image = png(32, (x,y)=>legacyPixel(color,x,y)); + await writeAsset(textures, `hearth_${color}.png`, image); } - for (const [name, shape] of Object.entries(icons)) { - await fs.writeFile(path.join(textures, `hearth_icon_${name}.png`), png(32,(x,y)=>shape(x,y)||[0,0,0,0])); + const art = worldArt(); + await writeAsset(destination, 'botany-art.json', JSON.stringify({format:1,images:BOTANY_ART_MANIFEST},null,2)+'\n'); + for (const season of TREE_SEASONS) { + const trees = treeAtlas(art, season), suffix = season==='summer'?'':`_${season}`; + await writeAsset(textures, `hearth_tree_atlas${suffix}.png`, png(trees.width, + (x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height)); } + for (const name of SPECIES_ATLAS_NAMES) { + const [species, , season='summer'] = name.split('_'), trees=treeAtlas(art,season,species); + await writeAsset(textures, `hearth_${name}.png`, png(trees.width, + (x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height)); + } + for (const [name, image] of art) await writeAsset(textures, name, + png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height)); + // The client uses this engine-defined filename, not the journal's named tile. + const crosshair = art.get('hearth_crosshair.png'); + await writeAsset(textures, 'crosshair.png', png(crosshair.width, + (x,y)=>crosshair.data.subarray((y*crosshair.width+x)*4,(y*crosshair.width+x+1)*4), crosshair.height)); + await writeAsset(textures, 'hearth_art_contact_sheet.png', artContactSheetPNG(art)); + for (const name of TOOL_NAMES) { + await writeAsset(textures, `hearth_icon_${name}.png`, png(32,(x,y)=>icon(name,x,y))); + } + const encode = image => png(image.width, + (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height); + for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image)); + await writeAsset(textures,'hearth_person.png',encode(compositeCharacter())); + await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet())); + await writeAsset(destination,'character-art.json',JSON.stringify({format:1,options:CHARACTER_OPTIONS, + meshes:BODY_PROFILES.map(body=>({body,filename:characterMesh(body)})),images:CHARACTER_ART_MANIFEST},null,2)+'\n'); // A low-poly original player silhouette; stable engine mesh instead of a billboard. + // Kept for older callers; M1 uses hearth_person.b3d and hearth_person.png. const models = path.join(destination, 'mods/hearth/models'); - await fs.mkdir(models, {recursive:true}); + await ensureDirectory(models); + for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name)); + for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name)); let vertices = 0; const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1']; function box(x0,y0,z0,x1,y1,z1) { @@ -75,5 +158,25 @@ export async function generateAssets(destination, source) { box(-2.3,8,-1.3,2.3,14,1.3); box(-2,14,-2,2,18,2); box(-4,8,-1,-2.3,14,1); box(2.3,8,-1,4,14,1); box(-2.2,0,-1.2,-0.2,8,1.2); box(0.2,0,-1.2,2.2,8,1.2); - await fs.writeFile(path.join(models,'hearth_person.obj'), obj.join('\n')+'\n'); + await writeAsset(models, 'hearth_person.obj', obj.join('\n')+'\n'); + for (const body of BODY_PROFILES) await writeAsset(models,characterMesh(body),playerB3D(body)); + for (const name of TOOL_NAMES) await writeAsset(models, `hearth_${name}.obj`, toolOBJ(name)); + const sounds = path.join(destination, 'mods/hearth/sounds'); + await ensureDirectory(sounds); + for (const name of Object.keys(SOUND_PROFILES).sort()) { + await writeAsset(sounds, name+'.ogg', await soundOGG(name)); + // Retire only our former generated WAVs when regenerating an existing tree. + const legacy = await safePath(path.join(sounds, name+'.wav')); + try { + if (!(await fs.lstat(legacy)).isFile()) throw new Error('Legacy sound must be a regular file.'); + await fs.unlink(legacy); + } catch (error) { if (error.code !== 'ENOENT') throw error; } + } +} + +// Pure export for review tooling; generateAssets writes it via the same guarded +// output path as runtime textures. It never writes into the source worktree. +export function artContactSheetPNG(art = worldArt()) { + const image = contactSheet(art); + return png(image.width, (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4), image.height); } diff --git a/scripts/backup.mjs b/scripts/backup.mjs new file mode 100755 index 0000000..c6950f6 --- /dev/null +++ b/scripts/backup.mjs @@ -0,0 +1,70 @@ +// OFFLINE ONLY. Sidecar/launcher locks are conservative signals, not proof that +// an external/local/remote engine is stopped. The operator must stop all engines. +// Snapshots contain private world data: keep them on an access-controlled volume. +// SHA256 detects corruption, not malicious replacement; it is not a signature. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { absolute, atomicDirectory, copyTree, disjoint, exists, inventory, json, safePath, + validLease, verifyManifest, withLauncherLock, writeManifest } from './files.mjs'; + +export async function assertWorldOffline(world) { + const entries = await inventory(world); + if (!entries.some(entry => entry.path === 'world.mt' && entry.type === 'file')) throw new Error('Not a world: missing world.mt.'); + if (entries.some(entry => /(?:^|\/)(?:lock|\.lock|world\.lock|launcher\.lock|.*\.lock)$|\.sqlite(?:3)?-(?:wal|shm|journal)$/i.test(entry.path))) { + throw new Error('World/SQLite lock or sidecar exists; stop all engines and resolve it before copying.'); + } + return entries; +} + +async function offlineOperation(options, action) { + if (options.offline !== true) throw new Error('Explicit --offline confirmation required. Stop every external engine; locks cannot prove remote idleness.'); + const runtime = absolute(options.runtime); + if (validLease(options.lease, runtime)) return action(); + return withLauncherLock(runtime, action); +} + +export async function backupWorld(world, destination, options = {}) { + world = absolute(world); destination = absolute(destination); + disjoint(world, destination); + return offlineOperation(options, async () => { + const before = await assertWorldOffline(world); + return atomicDirectory(destination, async stage => { + await copyTree(world, path.join(stage, 'world')); + const after = await assertWorldOffline(world); + if (json(before) !== json(after) || json(before) !== json(await inventory(path.join(stage, 'world')))) { + throw new Error('World changed during snapshot; stop all engines.'); + } + await fs.writeFile(path.join(stage, 'backup.json'), json({ format: 1, gameId: 'last_hearth', engineVersion: '5.18.0' }), { flag: 'wx', mode: 0o600 }); + await writeManifest(stage, 'world-backup'); + await verifyBackup(stage); + }); + }); +} + +export async function verifyBackup(backup) { + backup = await safePath(absolute(backup)); + const manifest = await verifyManifest(backup, 'world-backup'); + const metadata = JSON.parse(await fs.readFile(path.join(backup, 'backup.json'), 'utf8')); + if (metadata.format !== 1 || metadata.gameId !== 'last_hearth' || metadata.engineVersion !== '5.18.0' || + manifest.entries.some(entry => entry.path !== 'backup.json' && entry.path !== 'world' && !entry.path.startsWith('world/'))) { + throw new Error('Unsupported world backup.'); + } + await assertWorldOffline(path.join(backup, 'world')); + return manifest; +} + +export async function restoreWorld(backup, destination, options = {}) { + backup = absolute(backup); destination = absolute(destination); + disjoint(backup, destination); + return offlineOperation(options, async () => { + await verifyBackup(backup); + if (await exists(destination)) throw new Error('Restore requires a new world destination; existing worlds are never overwritten.'); + return atomicDirectory(destination, async stage => { + await copyTree(path.join(backup, 'world'), stage, { intoEmpty: true }); + const expected = await inventory(path.join(backup, 'world')); + if (json(await inventory(stage)) !== json(expected)) throw new Error('Restored world checksum mismatch.'); + await verifyBackup(backup); + await assertWorldOffline(stage); + }); + }); +} diff --git a/scripts/character-art.mjs b/scripts/character-art.mjs new file mode 100755 index 0000000..fcea841 --- /dev/null +++ b/scripts/character-art.mjs @@ -0,0 +1,264 @@ +// Original MIT woodland-traveler pixel drawing. Pure, bounded, offline RGBA; +// the shared 64px atlas and named bones remain the runtime attachment contract. +import { ATLAS, BODY_PROFILES } from './media.mjs'; + +export const CHARACTER_OPTIONS = Object.freeze(Object.fromEntries(Object.entries({ + bodies: BODY_PROFILES, + skin: ['porcelain','sand','honey','olive','umber','ebony'], + faces: ['calm','angular','soft','weathered'], + eyes: ['hazel','brown','green','blue','grey'], + hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none'], + hairColors: ['ash','flax','chestnut','auburn','black','silver'], + facialHair: ['none','stubble','moustache','short_beard','full_beard'], + garments: ['tunic','workshirt','jerkin','coat'], + clothColors: ['sage','teal','slate','mauve','ochre','rust','linen','charcoal'], + trousers: ['earth','slate','moss','charcoal'], boots: ['walnut','black','tan'], + accents: ['none','scarf','apron','sash','vest'], + details: ['none','freckles','scar','freckles_scar'], +}).map(([key, values])=>[key,Object.freeze([...values])]))); + +const palette = entries => Object.freeze(Object.fromEntries(Object.entries(entries) + .map(([key, value])=>[key,Object.freeze(value)]))); +const SKIN = palette({porcelain:[225,190,166],sand:[199,157,120],honey:[177,126,79], + olive:[141,119,88],umber:[114,78,56],ebony:[72,49,42]}); +const HAIR = palette({ash:[106,99,87],flax:[177,150,95],chestnut:[94,60,43], + auburn:[131,66,43],black:[35,33,32],silver:[174,177,168]}); +const CLOTH = palette({sage:[101,119,88],teal:[57,100,98],slate:[87,101,117], + mauve:[125,94,113],ochre:[164,128,64],rust:[149,78,53],linen:[187,174,144],charcoal:[59,63,64]}); +const PANTS = palette({earth:[99,80,61],slate:[74,83,94],moss:[78,88,60],charcoal:[48,50,53]}); +const BOOTS = palette({walnut:[79,53,37],black:[37,35,34],tan:[147,109,66]}); +const EYES = palette({hazel:[49,59,35],brown:[55,37,29],green:[47,77,53],blue:[52,81,104],grey:[92,100,100]}); +const INK = [74,56,48], THREAD = [184,166,126], LEATHER = [85,61,40]; +const tint = (color, amount) => color.map(n=>Math.max(0,Math.min(255,n+amount))); +const image = () => ({width:64,height:64,data:new Uint8Array(64*64*4)}); +function dot(out, tile, x, y, color) { + const [tx,ty] = ATLAS[tile]; + out.data.set([...color,255],((ty*8+y)*64+tx*8+x)*4); +} +function paint(out, tile, draw) { + for (let y=0;y<8;y++) for (let x=0;x<8;x++) { + const color = draw(x,y); if (color) dot(out,tile,x,y,color); + } +} +// Deliberate planes, seam highlights and broken weave, not global colorization. +function fabric(color,x,y) { + return tint(color,(x===0 || x===7 ? -13 : x===1 ? 7 : 0) + + ((y===2 || y===5) && x%3===1 ? 4 : 0)); +} +function skinLayer(id) { + const out = image(); + for (let y=0;y<64;y++) for (let x=0;x<64;x++) out.data.set([57,88,92,255],(y*64+x)*4); + for (const tile of ['face','hair','side','skin']) paint(out,tile,(x,y)=> + tint(SKIN[id],(x===0 || x===7 ? -9 : 0)+(y===7 ? -6 : y===1 ? 4 : 0))); + for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=>fabric(CLOTH.linen,x,y)); + paint(out,'trousers',(x,y)=>fabric(PANTS.earth,x,y)); + paint(out,'boot',(x,y)=>fabric(BOOTS.walnut,x,y)); + // Byte-exact former tool swatches: shared held OBJ UVs must not change. + for (const [tile,color] of [['metal',[177,195,191]],['wood',[146,100,59]],['wrap',[201,169,116]]]) + paint(out,tile,(x,y)=>tint(color,(((ATLAS[tile][0]*8+x)*13+(ATLAS[tile][1]*8+y)*7)%5)-2)); + return out; +} +function faceLayer(id) { + const out = image(); + // Skin-neutral linework: never a light painted face over darker skin. + const marks = { + calm: [[2,2],[5,2],[4,4],[3,6],[4,6]], + angular: [[1,2],[2,2],[5,2],[6,1],[4,4],[4,5],[2,6],[3,6]], + soft: [[2,2],[5,2],[3,5],[4,5],[3,6]], + weathered: [[1,2],[2,2],[5,2],[6,2],[1,4],[6,4],[4,4],[2,5],[5,5],[3,6],[4,6]], + }; + for (const [x,y] of marks[id]) dot(out,'face',x,y,y===6 ? [112,70,58] : INK); + return out; +} +function eyeLayer(id) { + const out = image(); + for (const x of [2,5]) dot(out,'face',x,3,EYES[id]); + return out; +} +function hairLayer(style, color) { + const out = image(); if (style==='none') return out; + const base = HAIR[color]; + const strand = (x,y)=>tint(base,(x+y*2)%4===0 ? 13 : x%3===0 ? -9 : 0); + const fringe = (x,y) => { + if (style==='shaved') return y===0 && x%2===0; + if (style==='cropped') return y===0 || (y===1 && x%3!==1); + if (style==='sidepart') return y<1 || (y===1 && x<6) || (y===2 && x<2); + if (style==='wavy') return y===0 || (y===1 && x%3!==0) || (y===2 && x===0); + if (style==='braids') return y<1 || (x===0 || x===7) && y<6; + return y<1 || (x<1 || x>6) && y<7; + }; + paint(out,'face',(x,y)=>fringe(x,y) ? strand(x,y) : null); + paint(out,'hair',(x,y)=>style==='shaved' && (x+y)%2 ? null : strand(x,y)); + paint(out,'side',(x,y)=> { + const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1}[style]; + if (y>=limit || (y>=3 && x>=3 && x<=5)) return null; // exposed ear + if (style==='shaved' && x%2) return null; + return strand(x,y); + }); + if (style==='long' || style==='braids') { + paint(out,'back',(x,y)=>y<4 && (style==='long' ? x>0 && x<7 : x===1 || x===6) + ? strand(x,y) : null); + paint(out,'sleeve',(x,y)=>y<2 && (x===0 || x===7) ? strand(x,y) : null); + if (style==='braids') for (const x of [0,7]) dot(out,'face',x,5,THREAD); + } + return out; +} +function beardLayer(style,color) { + const out = image(); if (style==='none') return out; + paint(out,'face',(x,y)=> { + const moustache = y===5 && x>=2 && x<=5; + const lower = y>=6 && x>=1 && x<=6; + const mask = style==='moustache' ? moustache : style==='stubble' ? + (lower && (x+y)%2===0) : style==='short_beard' ? moustache || (lower && x>=2 && x<=5) : + moustache || lower || (y===4 && (x===1 || x===6)); + return mask ? tint(HAIR[color],(x+y)%3===0 ? 9 : -4) : null; + }); + if (style==='full_beard') paint(out,'side',(x,y)=>y>=5 && x<3 ? HAIR[color] : null); + return out; +} +function garmentLayer(style,color) { + const out = image(), base = CLOTH[color]; + for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> { + let c = fabric(tint(base,tile==='back' ? -8 : 0),x,y); + if (y===7 || tile==='sleeve' && y===6) c=tint(base,-21); // hem/cuff + if (tile==='tunic') { + if (y===0 && (x===2 || x===5) || y===1 && (x===3 || x===4)) c=THREAD; + if (style==='tunic' && y===6) c=LEATHER; + if (style==='workshirt' && x===4 && y>1 && y<6) c=y%2 ? THREAD : tint(base,-22); + if (style==='workshirt' && y===3 && x>=5 && x<=6) c=tint(base,-25); + if (style==='jerkin' && (x===1 || x===6 || y===5)) c=LEATHER; + if (style==='jerkin' && y===2 && (x===3 || x===4)) c=THREAD; + if (style==='coat' && (x===3 || x===4)) c=tint(base,-28); + if (style==='coat' && x===5 && [2,4,6].includes(y)) c=THREAD; + if (style==='coat' && y===0 && (x===1 || x===6)) c=tint(base,17); + } + if (tile==='back' && (style==='coat' && x===4 || style==='jerkin' && y===5)) c=tint(base,-23); + if (tile==='sleeve' && style==='jerkin' && y>=3) c=fabric(CLOTH.linen,x,y); + if (tile==='sleeve' && style==='workshirt' && y===5) c=THREAD; + return c; + }); + return out; +} +function trousersLayer(id) { + const out=image(); paint(out,'trousers',(x,y)=> + y===0 ? LEATHER : x===2 && y>3 ? tint(PANTS[id],-17) : fabric(PANTS[id],x,y)); + return out; +} +function bootsLayer(id) { + const out=image(); paint(out,'boot',(x,y)=>y===7 ? [30,28,26] : + y===1 ? tint(BOOTS[id],18) : x===3 && y>1 && y<5 ? THREAD : fabric(BOOTS[id],x,y)); + return out; +} +function accentLayer(style,color) { + const out=image(); if (style==='none') return out; + const base=CLOTH[color]; + for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> { + let mask=false; + if (style==='scarf') mask=y===0 || tile==='tunic' && x===2 && y<4; + if (style==='apron') mask=tile==='tunic' ? x>=1 && x<=6 && y>=2 : tile==='back' && y===3; + if (style==='sash') mask=tile!=='sleeve' && (x===y || x===y+1); + if (style==='vest') mask=tile!=='sleeve' && (tile==='back' || x<=2 || x>=5); + return mask ? fabric(base,x,y) : null; + }); + if (style==='apron') for (const x of [3,4,5]) dot(out,'tunic',x,5,tint(base,-23)); + if (style==='vest') for (const y of [2,4]) dot(out,'tunic',5,y,THREAD); + return out; +} +function detailLayer(id) { + const out=image(); + if (id.includes('freckles')) for (const [x,y] of [[1,4],[2,5],[5,5],[6,4]]) dot(out,'face',x,y,[119,76,51]); + if (id.includes('scar')) for (const [x,y] of [[6,2],[6,3],[7,4]]) dot(out,'face',x,y,[158,109,91]); + return out; +} + +const definitions = [ + ['skin','skin',null,skinLayer], ['face','faces',null,faceLayer], ['eyes','eyes',null,eyeLayer], + ['hair','hairStyles','hairColors',hairLayer], ['beard','facialHair','hairColors',beardLayer], + ['garment','garments','clothColors',garmentLayer], ['trousers','trousers',null,trousersLayer], + ['boots','boots',null,bootsLayer], ['accent','accents','clothColors',accentLayer], + ['detail','details',null,detailLayer], +]; +export const CHARACTER_ART_MANIFEST = Object.freeze(definitions.flatMap(([kind,choices,colors])=> + CHARACTER_OPTIONS[choices].flatMap(id=>(colors ? CHARACTER_OPTIONS[colors] : [null]).map(color=> + Object.freeze({filename:`hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`,kind,id,color, + width:64,height:64,empty:id==='none',license:'MIT',author:'The Last Hearth original procedural art'}))))); +const records = new Map(CHARACTER_ART_MANIFEST.map(record=>[record.filename,record])); +export function characterLayer(filename) { + const record=records.get(filename); + if (!record) throw new Error('Unknown character layer.'); + const draw=definitions.find(([kind])=>kind===record.kind)[3]; + return draw(record.id,record.color); +} +export function characterArt() { + return new Map(CHARACTER_ART_MANIFEST.map(({filename})=>[filename,characterLayer(filename)])); +} + +export const DEFAULT_CHARACTER = Object.freeze({body:'balanced',skin:'sand',face:'calm',eyes:'hazel', + hair:'sidepart',hairColor:'chestnut',beard:'none',garment:'tunic',cloth:'teal', + trousers:'earth',boots:'walnut',accent:'scarf',accentColor:'sage',detail:'none'}); +const fields = Object.freeze({body:'bodies',skin:'skin',face:'faces',eyes:'eyes',hair:'hairStyles', + hairColor:'hairColors',beard:'facialHair',garment:'garments',cloth:'clothColors',trousers:'trousers', + boots:'boots',accent:'accents',accentColor:'clothColors',detail:'details'}); +function selection(options) { + if (!options || typeof options!=='object' || Array.isArray(options) || + ![Object.prototype,null].includes(Object.getPrototypeOf(options))) throw new Error('Invalid character options.'); + for (const key of Reflect.ownKeys(options)) if (!Object.hasOwn(fields,key)) throw new Error('Unknown character option.'); + const result={...DEFAULT_CHARACTER,...options}; + for (const [field,group] of Object.entries(fields)) + if (!CHARACTER_OPTIONS[group].includes(result[field])) throw new Error('Unknown character choice.'); + return result; +} +export function characterLayers(options = {}) { + const p=selection(options), name=stem=>`hearth_char_${stem}.png`; + // Clothing precedes long hair: shoulder locks must not be erased by tunics. + return [name(`skin_${p.skin}`),name(`face_${p.face}`),name(`eyes_${p.eyes}`), + name(`garment_${p.garment}_${p.cloth}`),name(`trousers_${p.trousers}`),name(`boots_${p.boots}`), + name(`accent_${p.accent}_${p.accentColor}`),name(`hair_${p.hair}_${p.hairColor}`), + name(`beard_${p.beard}_${p.hairColor}`),name(`detail_${p.detail}`)]; +} +export function characterTexture(options = {}) { return characterLayers(options).join('^'); } +export function characterMesh(body = 'balanced') { + if (!CHARACTER_OPTIONS.bodies.includes(body)) throw new Error('Unknown character body profile.'); + return `hearth_person${body==='balanced' ? '' : '_'+body}.b3d`; +} +// Straight RGBA source-over simplifies to a pixel copy: all layers are 0/255. +// Fresh output every call, no writes, caches, global tint, downloads or entities. +export function compositeCharacter(options = {}) { + const out=image(); + for (const filename of characterLayers(options)) { + const layer=characterLayer(filename); + for (let i=0;iObject.freeze({...p}))); +export function portraitSheet() { + const out={width:512,height:64,data:new Uint8Array(512*64*4)}; + for (let i=0;i { + const atlas=compositeCharacter(options); + // Front-facing assembled paper-doll portraits, NOT unwrapped atlas squares + // or an engine/3D-render claim. Nearest-neighbor sampling of actual layers. + const blit=(tile,dx,dy,w,h)=> { + const [tx,ty]=ATLAS[tile]; + for (let y=0;y JSON.stringify(value, null, 2) + '\n'; + +export async function exists(filename) { + try { await fs.lstat(filename); return true; } + catch (error) { if (error.code === 'ENOENT') return false; throw error; } +} + +export function absolute(filename) { + if (typeof filename !== 'string' || !path.isAbsolute(filename) || filename.includes('\0') || + filename.split(/[\\/]/).includes('..')) throw new Error('An absolute path without traversal is required.'); + return path.normalize(filename); +} + +export function relativeName(name) { + if (typeof name !== 'string' || !name || name.includes('\\') || name.split('/').some(part => + !part || part === '.' || part === '..' || /[\x00-\x1f<>:"|?*]/.test(part) || /[. ]$/.test(part) || + /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe portable relative path.'); + return name; +} + +export async function safePath(filename) { + filename = absolute(filename); + const root = path.parse(filename).root; + let current = root; + for (const part of filename.slice(root.length).split(path.sep).filter(Boolean)) { + current = path.join(current, part); + try { + if ((await fs.lstat(current)).isSymbolicLink()) throw new Error('Refusing symbolic link or junction.'); + } catch (error) { if (error.code !== 'ENOENT') throw error; } + } + return filename; +} + +export async function ensureDirectory(directory) { + await safePath(directory); + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + await safePath(directory); + if (!(await fs.lstat(directory)).isDirectory()) throw new Error('Expected directory.'); +} + +export function disjoint(a, b) { + const key = value => process.platform === 'win32' ? value.toLowerCase() : value; + a = key(absolute(a)); b = key(absolute(b)); + const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep); + if (inside(a, b) || inside(b, a)) throw new Error('Source and destination must not overlap.'); +} + +export async function inventory(root, omitManifest = false) { + await safePath(root); + if (!(await fs.lstat(root)).isDirectory()) throw new Error('Expected directory.'); + const entries = []; + const folded = new Set(); + async function visit(directory, prefix = '') { + for (const name of (await fs.readdir(directory)).sort()) { + const relative = relativeName(prefix + name); + if (omitManifest && relative === MANIFEST) continue; + const foldedName = relative.toLowerCase(); + if (folded.has(foldedName)) throw new Error('Case-colliding paths are not portable.'); + folded.add(foldedName); + const filename = path.join(root, ...relative.split('/')); + const stat = await fs.lstat(filename); + if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction.'); + if (stat.isDirectory()) { + entries.push({ path: relative, type: 'directory' }); + await visit(filename, relative + '/'); + } else if (stat.isFile()) { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filename, { flags: constants.O_RDONLY | (constants.O_NOFOLLOW || 0) })) hash.update(chunk); + entries.push({ path: relative, type: 'file', size: stat.size, sha256: hash.digest('hex') }); + } else throw new Error('Only regular files and directories are supported.'); + } + } + await visit(root); + return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); +} + +export async function copyTree(from, to, { intoEmpty = false, exclude = new Set() } = {}) { + const entries = await inventory(from); + await safePath(to); + if (intoEmpty) { + if (!(await fs.lstat(to)).isDirectory() || (await fs.readdir(to)).length) throw new Error('Copy target must be an empty private stage.'); + } else await fs.mkdir(to, { mode: 0o700 }); + for (const entry of entries) { + if ([...exclude].some(prefix => entry.path === prefix || entry.path.startsWith(prefix + '/'))) continue; + const target = path.join(to, ...entry.path.split('/')); + if (entry.type === 'directory') await fs.mkdir(target, { mode: 0o700 }); + else { + await fs.copyFile(path.join(from, ...entry.path.split('/')), target, constants.COPYFILE_EXCL); + await fs.chmod(target, 0o600); + } + } + return entries; +} + +export async function writeManifest(root, kind) { + const manifest = { format: 1, algorithm: 'sha256', kind, entries: await inventory(root, true) }; + await fs.writeFile(path.join(root, MANIFEST), json(manifest), { flag: 'wx', mode: 0o600 }); + return manifest; +} + +export async function verifyManifest(root, kind) { + await safePath(path.join(absolute(root), MANIFEST)); + const stat = await fs.lstat(path.join(root, MANIFEST)); + if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error('Invalid manifest file.'); + const manifest = JSON.parse(await fs.readFile(path.join(root, MANIFEST), 'utf8')); + if (manifest.format !== 1 || manifest.algorithm !== 'sha256' || manifest.kind !== kind || !Array.isArray(manifest.entries)) { + throw new Error('Unsupported SHA256 manifest.'); + } + for (const entry of manifest.entries) relativeName(entry.path); + if (json(manifest.entries) !== json(await inventory(root, true))) throw new Error('SHA256 manifest mismatch: corrupt, missing or extra files/directories.'); + return manifest; +} + +// Exclusive sibling guard serializes our writers. Parent must not be writable by +// adversaries: Node has no portable rename-no-replace primitive for directories. +export async function atomicDirectory(destination, build) { + destination = await safePath(destination); + if (path.parse(destination).root === destination) throw new Error('Refusing filesystem root destination.'); + const parent = path.dirname(destination); + await safePath(parent); + if (!(await fs.lstat(parent)).isDirectory()) throw new Error('Destination parent must exist.'); + const guard = destination + '.hearth-write-lock'; + const handle = await fs.open(guard, 'wx', 0o600); + let stage; + try { + if (await exists(destination)) throw new Error('Refusing to overwrite an existing or unmanaged destination.'); + stage = await fs.mkdtemp(path.join(parent, '.hearth-stage-')); + await fs.chmod(stage, 0o700); + await build(stage); + // Windows virus scanners/indexers can briefly hold newly generated PNGs. + // Retry only transient sharing failures, rechecking nonexistence each time; + // never fall back to copying into or removing an existing destination. + for (let attempt = 0; ; attempt++) { + await safePath(destination); + if (await exists(destination)) throw new Error('Destination appeared during operation; refusing overwrite.'); + try { await fs.rename(stage, destination); break; } + catch (error) { + if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 5) throw error; + await retryDelay(25 * 2 ** attempt); + } + } + stage = undefined; + return destination; + } finally { + if (stage) await fs.rm(stage, { recursive: true, force: true }); + await handle.close(); + await fs.unlink(guard); + } +} + +const leases = new WeakSet(); +export async function withLauncherLock(runtime, action) { + await ensureDirectory(runtime); + const filename = path.join(runtime, 'launcher.lock'); + let handle; + try { handle = await fs.open(filename, 'wx', 0o600); } + catch (error) { + if (error.code === 'EEXIST') throw new Error('Launcher lock exists. Stop the engine before manually clearing a stale lock.'); + throw error; + } + const lease = Object.freeze({ runtime: absolute(runtime), id: randomUUID() }); + leases.add(lease); + try { await handle.writeFile(`${process.pid}\n`); return await action(lease); } + finally { leases.delete(lease); await handle.close(); await fs.unlink(filename); } +} + +export function validLease(lease, runtime) { + return lease && leases.has(lease) && lease.runtime === absolute(runtime); +} diff --git a/scripts/flora-art.mjs b/scripts/flora-art.mjs new file mode 100755 index 0000000..418d0ce --- /dev/null +++ b/scripts/flora-art.mjs @@ -0,0 +1,249 @@ +// Original static broadleaf sculptures. Pure, bounded, deterministic; no MTL, +// external assets, random state or filesystem access. Ten model units per node. +import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from './world-art.mjs'; + +// Deprecated compatibility exports only. New trees are built from cube nodes; +// do not spend geometry on new OBJ files or infer runtime usage from this list. +export const LEGACY_MESH_SPECIES = Object.freeze(['oak','birch','pine','willow','alder']); +export const SPECIES_MESH_NAMES = Object.freeze(LEGACY_MESH_SPECIES.flatMap(s=>[ + `${s}_seedling`,`${s}_young`,`${s}_mature`,...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])])); +export const FLORA_NAMES = Object.freeze(['tree_seedling', 'young_tree', 'mature_tree', 'deadwood', + 'young_tree_winter', 'mature_tree_winter',...SPECIES_MESH_NAMES]); +export const TREE_SEASONS = Object.freeze(['summer','spring','autumn','winter']); +export const SPECIES_ATLAS_NAMES = Object.freeze(SPECIES_NAMES.flatMap(s=>[ + `${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(season=>`${s}_atlas_${season}`))])); +const sub = (a,b) => a.map((n,i)=>n-b[i]); +const cross = (a,b) => [a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]; +const dot = (a,b) => a.reduce((s,n,i)=>s+n*b[i],0); +const unit = a => a.map(n=>n/Math.hypot(...a)); +const directions = [[1,0],[Math.SQRT1_2,Math.SQRT1_2],[0,1],[-Math.SQRT1_2,Math.SQRT1_2], + [-1,0],[-Math.SQRT1_2,-Math.SQRT1_2],[0,-1],[Math.SQRT1_2,-Math.SQRT1_2]]; +const number = n => Number(n.toFixed(6)); + +class Sculpture { + vertices=[]; uv=[]; normals=[]; faces=[]; groups=[]; + solid(name, points, triangles, material) { + const center=[0,1,2].map(i=>points.reduce((s,p)=>s+p[i],0)/points.length); + const start=this.vertices.length; + this.groups.push({name, start, count:points.length, faceStart:this.faces.length}); + this.vertices.push(...points.map(p=>p.map(number))); + // Each face has an explicit flat normal and its own inset UV triangle. + // OBJ V is bottom-up; both halves use the full 32px material height. + const u0=(material==='bark'?0.5:32.5)/64, u1=(material==='bark'?31.5:63.5)/64; + for(let ids of triangles) { + ids=[...ids]; + let [a,b,c]=ids.map(i=>this.vertices[start+i]); + let normal=cross(sub(b,a),sub(c,a)); + if(dot(normal,sub(a,center))<0) { + [ids[1],ids[2]]=[ids[2],ids[1]]; + [a,b,c]=ids.map(i=>this.vertices[start+i]); normal=cross(sub(b,a),sub(c,a)); + } + if(Math.hypot(...normal)<1e-8) throw new Error('Degenerate flora triangle'); + const uvStart=this.uv.length; + this.uv.push([u0,0.5/32],[u1,0.5/32],[(u0+u1)/2,31.5/32]); + this.normals.push(unit(normal).map(number)); + this.faces.push(ids.map((id,i)=>[start+id+1,uvStart+i+1,this.normals.length])); + } + } + branch(name, a,b,r0,r1) { + const axis=unit(sub(b,a)), side=unit(cross(axis,Math.abs(axis[1])<.9?[0,1,0]:[0,0,1])); + const up=cross(axis,side), points=[]; + for(const [center,r] of [[a,r0],[b,r1]]) for(const [x,z] of directions) + points.push(center.map((n,i)=>n+r*(side[i]*x+up[i]*z))); + // A leaning stem still seats on a horizontal node-bottom cut, not below it. + if(a[1]===-5) for(let i=0;i<8;i++) points[i][1]=-5; + const triangles=[]; + for(let i=0;i<8;i++) { + const j=(i+1)%8; + triangles.push([i,j,8+j],[i,8+j,8+i]); + } + for(let i=1;i<7;i++) triangles.push([0,i+1,i],[8,8+i,8+i+1]); + this.solid(name,points,triangles,'bark'); + } + crown(name, center, radii, phase=0, shape='broad') { + // Three uneven eight-sided rings and off-axis tips make a lobed volume, + // not a cube, crossed plane or radially identical stack of spheres. + const profile=[1,.88,.94,1,.9,.98,1,.86], points=[]; + const rings=shape==='conifer'?[[-.65,.88],[-.4,1],[.25,.48]]:[[-.55,.72],[0,1],[.55,.65]]; + for(const [level,width] of rings) directions.forEach(([x,z],i)=> { + const bulge=profile[(i+phase)%8]; + points.push([center[0]+x*radii[0]*width*bulge,center[1]+level*radii[1],center[2]+z*radii[2]*width*bulge]); + }); + points.push([center[0]-.12*radii[0],center[1]-radii[1],center[2]+.08*radii[2]], + [center[0]+.08*radii[0],center[1]+radii[1],center[2]-.1*radii[2]]); + const triangles=[]; + for(let i=0;i<8;i++) { + const j=(i+1)%8; + triangles.push([24,j,i],[25,16+i,16+j]); + for(let ring=0;ring<2;ring++) { + const k=ring*8; triangles.push([k+i,k+j,k+8+j],[k+i,k+8+j,k+8+i]); + } + } + this.solid(name,points,triangles,'leaf'); + } + obj() { + const lines=['# Original Last Hearth volumetric ecology; 10 units/node; ground Y=-5','s off']; + for(const v of this.vertices) lines.push(`v ${v.join(' ')}`); + for(const t of this.uv) lines.push(`vt ${t.join(' ')}`); + for(const n of this.normals) lines.push(`vn ${n.join(' ')}`); + for(let i=0;ig.faceStart===i); + if(group) lines.push(`g ${group.name}`); + lines.push(`f ${this.faces[i].map(p=>p.join('/')).join(' ')}`); + } + return lines.join('\n')+'\n'; + } +} + +// Every species has an authored branching architecture, not a rescaled generic +// crown. Mature bounds fit the ecosystem's ±1.5 by five-node clearance. Young +// forms are half-size around ground Y=-5, hence ≤±.75 by 2.5 nodes. +function speciesSculpture(species,stage,winter) { + const m=new Sculpture(), conifer=species==='pine'; + if(stage==='seedling') { + const index=SPECIES_NAMES.indexOf(species), lean=(index-2)*.22; + m.branch('stem',[0,-5,0],[lean,.8+index*.2,.2],.22,.07); + m.branch('twig',[0,-2.5,0],[-1.3,-.2,index*.2],.12,.04); + if(conifer) for(let i=0;i<3;i++) + m.crown(`leaf_tier_${i}`,[0,-1.7+i*1.6,0],[2-i*.5,1.6,1.9-i*.5],i,'conifer'); + else { + m.crown('leaf_left',[-1.2,-.4,index*.15],[1.3,1.2,1.1],index); + m.crown('leaf_right',[.9,.7+index*.2,-.3],[1.2,conifer?2:1.5,1],index+2); + } + return m; + } + let limbs,clusters; + if(species==='oak') { + m.branch('trunk',[0,-5,0],[.7,23,.4],1.65,.65); + limbs=[[[0,8,0],[-9,24,2]],[[.2,12,0],[9,27,-2]],[[.5,18,0],[-2,35,3]], + [[.3,13,0],[2,25,-9]],[[.3,15,.2],[3,27,9]]]; + clusters=[[[-8,26,2],[6.7,7,6]],[[8,28,-2],[6.3,7,6]],[[0,35,2],[7,8.5,6.7]], + [[1,26,-8],[6,6.5,6]],[[3,28,8],[6.2,6,6.4]],[[0,28,0],[6,7,6]]]; + } else if(species==='birch') { + m.branch('trunk',[0,-5,0],[1.2,38,-.7],.85,.22); + limbs=[[[.3,16,0],[-4.8,27,1]],[[.6,23,-.2],[5.3,33,-2]], + [[1,29,-.5],[-3,38,2.4]],[[.5,21,-.3],[1.5,29,-5]]]; + clusters=[[[-4.5,29,1],[3.6,5.3,3.8]],[[5.2,34,-2],[3.5,5,3.3]], + [[-3,38,2],[3.5,5.3,3.5]],[[1,40,-.8],[3.2,4,3]],[[1,30,-5],[3.5,5,3.3]], + [[2,25,4],[3.3,4,3.4]]]; + } else if(conifer) { + m.branch('trunk',[0,-5,0],[.25,43,.1],1.05,.08); + limbs=[]; + for(let i=0;i<4;i++) for(const [j,[x,z]] of [[-1,.2],[.6,.8],[.4,-1]].entries()) { + const length=10-i*2.2, y=8+i*8; + limbs.push([[0,y,0],[x*length,y+1,z*length]]); + // Separate tapered whorls, daylight gaps, and drooped lower skirts. + m.crown(`foliage_tier_${i}_${j}`,[x*length*.45,y+3,z*length*.45], + [length*.68,5.8-i*.3,length*.64],i+j,'conifer'); + } + m.crown('foliage_leader',[.2,39,.1],[3.3,5.3,3.1],2,'conifer'); + clusters=[]; + } else if(species==='willow') { + m.branch('trunk',[0,-5,0],[-1,20,.7],1.4,.75); + limbs=[[[0,7,0],[-9,27,1]],[[0,10,0],[9,29,-2]],[[0,14,0],[0,34,3]], + [[0,12,0],[1,26,-9]],[[0,13,0],[3,26,9]]]; + clusters=[[[-7,28,1],[6,5,6]],[[7,30,-2],[6,5,6]],[[0,34,2],[6,6,5]], + [[0,27,-7],[6,5,6]],[[3,28,7],[6,5,6]]]; + for(const [i,[x,y,z]] of [[-11,23,1],[11,25,-2],[-5,24,-10],[3,23,11],[8,24,8],[-9,23,8]].entries()) { + m.branch(`pendant_${i}`,[x*.75,y+3,z*.75],[x,y-11,z],.17,.045); + if(!winter) m.crown(`foliage_curtain_${i}`,[x,y-4,z],[2.5,9,2.3],i); + } + } else { // Alder: upright multiple leaders and a compact rounded riparian crown. + m.branch('trunk',[0,-5,0],[.3,18,.2],1.15,.5); + limbs=[[[0,2,0],[-5,27,1]],[[0,5,0],[5.8,28,-1]],[[0,10,0],[0,32,3]], + [[0,9,0],[1,25,-5]]]; + clusters=[[[-5,27,1],[5,7,4.7]],[[5.5,28,-1],[5,7,5]],[[0,32,3],[5.5,7,5]], + [[1,26,-5],[5,6,5]],[[0,24,1],[6,6,5]]]; + } + limbs.forEach(([a,b],i)=> { + m.branch(`bough_${i}`,a,b,conifer?.28:species==='birch'?.3:.6,conifer?.08:.15); + if(!conifer) { + const start=a.map((n,j)=>n+(b[j]-n)*.7), sign=Math.sign(b[0]) || (i%2?-1:1); + m.branch(`twig_${i}`,start,[b[0]+sign*1.8,b[1]+1,b[2]+(i%2?-1.5:1.5)],.15,.035); + } + }); + if(!winter) clusters.forEach(([c,r],i)=>m.crown(`foliage_${i}`,c,r,i+SPECIES_NAMES.indexOf(species))); + if(stage==='young') m.vertices=m.vertices.map(([x,y,z])=>[number(x*.5),number((y+5)*.5-5),number(z*.5)]); + return m; +} + +function sculpture(name) { + if(!FLORA_NAMES.includes(name)) throw new Error('Unknown flora mesh'); + const match=name.match(/^(oak|birch|pine|willow|alder)_(seedling|young|mature)(_winter)?$/); + if(match) return speciesSculpture(match[1],match[2],Boolean(match[3])); + const m=new Sculpture(); + if(name==='deadwood') { + m.branch('fallen_trunk',[0,-3.4,-8],[.2,-3.4,8],1.6,1.3); + m.branch('broken_left',[0,-3.1,-2],[-3.9,-1.4,-4.8],.6,.24); + m.branch('broken_right',[.1,-3,2],[3.9,-.6,4.7],.55,.2); + m.branch('fork',[2.2,-1.7,3.5],[3.3,-1.2,6.6],.3,.12); + return m; + } + if(name==='tree_seedling') { + m.branch('stem',[0,-5,0],[.12,.1,-.05],.22,.09); + m.branch('twig', [0,-2.8,0],[-1.2,-1,0],.12,.06); + m.crown('leaf_left',[-1.15,-1.1,.2],[1.5,1.4,1.25]); + m.crown('leaf_right',[1,-.1,-.35],[1.5,2.1,1.4],2); + return m; + } + const winter=name.endsWith('_winter'), mature=name.startsWith('mature_tree'); + m.branch('trunk',[0,-5,0],[.15,mature?15:7,.1],mature?1.2:.85,mature?.7:.35); + const limbs=mature ? [ + [[0,5,0],[-5,16,1],.75,.28],[[0,8,0],[5.7,19,-1],.65,.24], + [[0,11,0],[-1,23,2.5],.55,.2],[[0,8,0],[1,17,-5],.55,.2], + ] : [ + [[0,0,0],[-2.8,6,.5],.36,.13],[[0,2,0],[3,8,-.4],.3,.12], + [[0,4,0],[0,10,1.2],.24,.09], + ]; + limbs.forEach(([a,b,r0,r1],i)=>m.branch(`bough_${i}`,a,b,r0,r1)); + if(winter) { + // Preserve every original trunk/bough vertex, UV and normal. Add woody + // lateral forks along the boughs, never a recoloured canopy or leaf planes. + limbs.forEach(([a,b],i)=> { + const start=a.map((n,j)=>n+(b[j]-n)*.62), sign=Math.sign(b[0]-a[0]) || (i%2?-1:1); + const tip=[b[0]+sign*(mature?2.2:1.3),b[1]-.8,b[2]+sign*(mature?1.8:1)]; + m.branch(`winter_fork_${i}`,start,tip,mature?.22:.11,mature?.06:.035); + }); + return m; + } + const clusters=mature ? [ + [[-5,16,1],[6,6,6]],[[5,19,-1],[6,6,6]],[[0,23,1],[6,7,6]], + [[0,16,-5],[6,5.5,6]],[[1,15,5],[6,5,6]], + ] : [ + [[-2.5,6,.5],[4,4,4]],[[2.5,8,-.4],[4,4.5,4]], + [[0,10.5,1],[3.5,4.5,3.5]],[[.3,6,-2],[3.5,3.5,4]], + ]; + clusters.forEach(([c,r],i)=>m.crown(`foliage_${i}`,c,r,i*2)); + return m; +} + +export function floraOBJ(name) { return sculpture(name).obj(); } +export function floraBounds(name) { + const {vertices}=sculpture(name); + return {min:[0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10), + max:[0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)}; +} + +export function treeAtlas(art=worldArt(), season='summer', species=null) { + if(!TREE_SEASONS.includes(season)) throw new Error('Unknown tree season'); + if(species!==null && !SPECIES_NAMES.includes(species)) throw new Error('Unknown tree species'); + if(EVERGREEN_NAMES.includes(species) && season!=='summer') throw new Error('Conifer uses its evergreen summer atlas'); + // Two 32px patches, no external MTL. Leaves keep a dark opaque underpaint: + // silhouette holes come from gaps between 3D lobes, never torn polygon faces. + const data=new Uint8Array(64*32*4); + const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`; + const underpaint=season==='summer' && species? + {oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark', + beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[species]: + {summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]; + for(const [half,name] of [`${prefix}log_side`,leaves].entries()) { + const image=art.get(`hearth_${name}.png`); + if(!image || image.width!==32 || image.height!==32 || image.data.length!==4096) + throw new Error('Tree atlas requires 32px RGBA bark and leaves'); + for(let y=0;y<32;y++) for(let x=0;x<32;x++) { + const p=image.data.subarray((y*32+x)*4,(y*32+x+1)*4); + data.set(p[3]===255?p:[...PALETTE[underpaint],255],(y*64+half*32+x)*4); + } + } + return {width:64,height:32,data}; +} diff --git a/scripts/launcher.mjs b/scripts/launcher.mjs new file mode 100755 index 0000000..7a3a514 --- /dev/null +++ b/scripts/launcher.mjs @@ -0,0 +1,140 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { absolute, atomicDirectory, ensureDirectory, exists, safePath } from './files.mjs'; +import { assertWorldOffline, backupWorld } from './backup.mjs'; +import { readRelease, SOURCE } from './package.mjs'; + +const exec = promisify(execFile); + +export function runtimeHome(env = process.env, platform = process.platform, home = os.homedir()) { + if (env.HEARTH_HOME !== undefined) return absolute(env.HEARTH_HOME); + if (platform === 'win32') { + if (!env.LOCALAPPDATA) throw new Error('LOCALAPPDATA or HEARTH_HOME is required.'); + return path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime'); + } + if (platform === 'darwin') return path.join(absolute(home), 'Library/Application Support/last-hearth-runtime'); + return path.join(env.XDG_DATA_HOME ? absolute(env.XDG_DATA_HOME) : path.join(absolute(home), '.local/share'), 'last-hearth-runtime'); +} + +export function engineExecutable(env = process.env, platform = process.platform, runtime = runtimeHome(env, platform)) { + if (env.HEARTH_ENGINE !== undefined) return absolute(env.HEARTH_ENGINE); + if (platform !== 'win32') throw new Error('Set HEARTH_ENGINE to an absolute Luanti 5.18.0 executable.'); + return path.join(runtime, 'luanti-5.18.0-win64/bin/luanti.exe'); +} + +export function parseEngineVersion(text) { + const match = /^Luanti ([0-9]+\.[0-9]+\.[0-9]+)(?=\s|$)/m.exec(text); + if (!match) throw new Error('Unrecognized Luanti --version response.'); + return match[1]; +} + +export function assertEngineVersion(text, expected = '5.18.0') { + const actual = parseEngineVersion(text); + if (actual !== expected) throw new Error(`Luanti version mismatch: expected ${expected}, received ${actual}.`); + return actual; +} + +export async function validateEngine(executable, { source = SOURCE } = {}) { + executable = await safePath(absolute(executable)); + if (!(await fs.lstat(executable)).isFile()) throw new Error('Engine must be a regular executable file.'); + const release = await readRelease(source); + const { stdout } = await exec(executable, ['--version'], { + shell: false, windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024, encoding: 'utf8', + }); + return assertEngineVersion(stdout, release.engine.version); +} + +export async function snapshotBeforePlay(runtime, lease) { + runtime = absolute(runtime); + const world = path.join(runtime, 'worlds/last_hearth'); + await safePath(world); + if (await exists(path.join(world, 'world.mt'))) { + await assertWorldOffline(world); + const backups = path.join(runtime, 'backups'); + await ensureDirectory(backups); + const destination = path.join(backups, `prelaunch-${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID()}`); + await backupWorld(world, destination, { offline: true, runtime, lease }); + return destination; + } + if (await exists(world) && (await fs.readdir(world)).length) throw new Error('Refusing to initialize a nonempty unmanaged world directory.'); + return undefined; +} + +// Full-detail terrain distance, not LOD or a simulation/activation radius. +export function graphicsSettings(env = process.env) { + const raw = env.HEARTH_VIEW_DISTANCE; + // Validate the entire string (including trailing newlines) before config I/O. + if (raw !== undefined && (typeof raw !== 'string' || !/^[1-9][0-9]{2,3}$/.test(raw) || /[\r\n]/.test(raw))) { + throw new Error('HEARTH_VIEW_DISTANCE must be an integer from 128 to 1024 nodes.'); + } + const distance = raw === undefined ? 512 : Number(raw); + if (!Number.isSafeInteger(distance) || distance < 128 || distance > 1024) { + throw new Error('HEARTH_VIEW_DISTANCE must be an integer from 128 to 1024 nodes.'); + } + const blocks = Math.min(66, Math.ceil(distance / 16) + 2); + return [ + `viewing_range = ${distance}`, `max_block_send_distance = ${blocks}`, + `max_block_generate_distance = ${blocks}`, 'client_mesh_chunk = 2', 'fog_start = 0.65', + ]; +} + +export function coordinates(env = process.env) { + const latitude = env.HEARTH_LATITUDE, longitude = env.HEARTH_LONGITUDE; + if (latitude === undefined && longitude === undefined) return []; + if (latitude === undefined || longitude === undefined) throw new Error('Provide both HEARTH_LATITUDE and HEARTH_LONGITUDE, or neither.'); + const parse = (value, limit) => { + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value) || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > limit) { + throw new Error('Invalid geographic coordinate.'); + } + return Number(value); + }; + return [`hearth_latitude = ${parse(latitude, 90)}`, `hearth_longitude = ${parse(longitude, 180)}`]; +} + +export async function fixtureSettings(name, environment, source = SOURCE) { + if (!['local', 'staging', 'production'].includes(environment)) throw new Error('Invalid hearth_environment.'); + if (name === undefined) return []; + if (environment === 'production') throw new Error('Production rejects fixture/test settings.'); + if (!['first-session', 'summer', 'winter'].includes(name)) throw new Error('Unknown HEARTH_FIXTURE.'); + const filename = await safePath(path.join(source, 'tests/fixtures/scenarios.json')); + const data = JSON.parse(await fs.readFile(filename, 'utf8')); + const fixture = Object.hasOwn(data.scenarios ?? {}, name) ? data.scenarios[name] : undefined; + if (data.format !== 1 || !fixture || fixture.seed !== 731104 || + !['real-date', 'fixed'].includes(fixture.clock)) throw new Error('Invalid scenario contract.'); + const settings = [`fixed_map_seed = ${fixture.seed}`]; + if (fixture.clock === 'fixed') { + if (!Number.isSafeInteger(fixture.utc) || fixture.utc <= 0 || !['clear', 'rain'].includes(fixture.weather)) throw new Error('Invalid scenario date/weather.'); + settings.push(`hearth_preview_date = ${fixture.utc}`, `hearth_preview_weather = ${fixture.weather}`, + ...coordinates({ HEARTH_LATITUDE: String(fixture.latitude), HEARTH_LONGITUDE: String(fixture.longitude) })); + } + return settings; +} + +// The parent is the launcher's private, fresh session stage, never the persistent +// worlds directory. Refuse even an empty pre-existing course; no reuse/reset/rm. +// The Lua module enrolls this marker before clock/player code can save anything. +export async function prepareCourse(stage, { environment = 'local', source = SOURCE } = {}) { + if (!['local', 'staging'].includes(environment)) throw new Error('Course requires local/staging, never production.'); + stage = await safePath(absolute(stage)); + if (!(await fs.lstat(stage)).isDirectory()) throw new Error('Course stage must be an existing private directory.'); + const fixture = await fixtureSettings('summer', environment, source); + const world = path.join(stage, 'course'), nonce = randomUUID(); + await atomicDirectory(world, async directory => { + await fs.writeFile(path.join(directory, '.hearth-m1-course-nonce'), nonce + '\n', { flag: 'wx', mode: 0o600 }); + }); + return { world, nonce, settings: [...fixture, 'hearth_m1_course = true', `hearth_m1_course_nonce = ${nonce}`] }; +} + +// Dedicated engines default to production; the managed SP launcher opts into local. +export function environmentSettings(environment = 'production', extra = []) { + if (!['local', 'staging', 'production'].includes(environment)) throw new Error('Invalid hearth_environment.'); + if (extra.some(line => /[\r\n]/.test(line) || /^\s*hearth_environment\s*=/.test(line))) throw new Error('Invalid configuration setting.'); + if (environment === 'production' && extra.some(line => /^\s*hearth_(?:preview_|launcher_mode|smoke_token|m1_course|recovery|debug|course_probe)/.test(line))) { + throw new Error('Production rejects fixture/test settings.'); + } + return [`hearth_environment = ${environment}`, ...extra]; +} diff --git a/scripts/media.mjs b/scripts/media.mjs new file mode 100755 index 0000000..63be8d9 --- /dev/null +++ b/scripts/media.mjs @@ -0,0 +1,379 @@ +// Original procedural geometry and audio. Runtime packaging needs no codec. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash, randomInt } from 'node:crypto'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import { safePath } from './files.mjs'; + +// Luanti renders mesh coordinates directly: 10 model units = one world node. +export const ANIMATIONS = Object.freeze(Object.fromEntries(Object.entries({ + idle: [0, 19, 12], walk: [20, 39, 24], run: [40, 59, 32], + strike: [60, 69, 28], guard: [70, 79, 12], swim: [80, 99, 20], +}).map(([name, range]) => [name, Object.freeze(range)]))); +export const PLAYER_HEIGHT = 17.5; +export const SAMPLE_RATE = 22050; + +const i32 = (...values) => { + const bytes = Buffer.alloc(values.length * 4); + values.forEach((value, i) => bytes.writeInt32LE(value, i * 4)); + return bytes; +}; +const f32 = (...values) => { + const bytes = Buffer.alloc(values.length * 4); + values.forEach((value, i) => bytes.writeFloatLE(value, i * 4)); + return bytes; +}; +const string = value => Buffer.from(value + '\0', 'ascii'); +const chunk = (tag, ...parts) => { + const data = Buffer.concat(parts); + return Buffer.concat([Buffer.from(tag, 'ascii'), i32(data.length), data]); +}; + +// One 64px atlas, 8px tiles, half-texel inset. Front faces look toward +Z. +export const ATLAS = Object.freeze({face: [0, 0], hair: [1, 0], side: [2, 0], + tunic: [0, 1], back: [1, 1], sleeve: [2, 1], trousers: [0, 2], + boot: [1, 2], skin: [2, 2], metal: [0, 3], wood: [1, 3], wrap: [2, 3]}); +function uv(tile) { + const [x, y] = ATLAS[tile]; + return [[0.5, 7.5], [7.5, 7.5], [7.5, 0.5], [0.5, 0.5]] + .map(([u, v]) => [(x * 8 + u) / 64, (y * 8 + v) / 64]); +} +const faces = [ + [[0, 3, 2, 1], [0, 0, -1]], [[4, 5, 6, 7], [0, 0, 1]], + [[0, 1, 5, 4], [0, -1, 0]], [[3, 7, 6, 2], [0, 1, 0]], + [[0, 4, 7, 3], [-1, 0, 0]], [[1, 2, 6, 5], [1, 0, 0]], +]; +function box(vertices, triangles, weights, bone, bounds, tiles, taper = [1, 1]) { + const [x0, y0, z0, x1, y1, z1] = bounds; + const corners = [[x0,y0,z0],[x1,y0,z0],[x1,y1,z0],[x0,y1,z0], + [x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]]; + for (const corner of corners) if (corner[1] === y0) { + corner[0] = (x0+x1)/2 + (corner[0]-(x0+x1)/2)*taper[0]; + corner[2] = (z0+z1)/2 + (corner[2]-(z0+z1)/2)*taper[1]; + } + faces.forEach(([indices], face) => { + const [a,b,c] = indices.map(i => corners[i]); + const u = b.map((n,i)=>n-a[i]), v = c.map((n,i)=>n-a[i]); + const cross = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]; + const normal = cross.map(n=>n/Math.hypot(...cross)); + const first = vertices.length, coords = uv(Array.isArray(tiles) ? tiles[face] : tiles); + indices.forEach((index, i) => { + vertices.push([...corners[index], ...normal, ...coords[i]]); + weights[bone].push(first + i); + }); + triangles.push([first, first + 1, first + 2], [first, first + 2, first + 3]); + }); +} + +// Rigidly weighted low-poly limbs are skeletal animation, not vertex snapshots. +// Child hand bones carry held meshes through the same strike/guard animation. +const skeleton = [ + {name: 'Root', parent: null, pos: [0,0,0]}, + {name: 'Body', parent: 'Root', pos: [0,8,0]}, + {name: 'Head', parent: 'Body', pos: [0,5.5,0]}, + {name: 'Arm_Right', parent: 'Body', pos: [-3.1,5,0]}, + {name: 'Hand_Right', parent: 'Arm_Right', pos: [0,-4.8,0]}, + {name: 'Arm_Left', parent: 'Body', pos: [3.1,5,0]}, + {name: 'Hand_Left', parent: 'Arm_Left', pos: [0,-4.8,0]}, + {name: 'Leg_Right', parent: 'Root', pos: [-1.15,8,0]}, + {name: 'Leg_Left', parent: 'Root', pos: [1.15,8,0]}, +]; +function pose(name, frame) { + const [clip, [start, end]] = Object.entries(ANIMATIONS).find(([, [a, b]]) => frame >= a && frame <= b); + const t = (frame - start) / (end - start), cycle = Math.sin(t * Math.PI * 2); + let x = 0, y = 0, z = 0, lift = 0; + const right = name.endsWith('Right'), limb = right ? 1 : -1; + if (clip === 'idle') { + if (name === 'Body') lift = 0.055 * (1 - Math.cos(t * Math.PI * 2)); + if (name === 'Head') y = 0.045 * cycle; + } else if (clip === 'walk' || clip === 'run') { + const amplitude = clip === 'run' ? 0.9 : 0.5; + if (name.startsWith('Leg_')) x = cycle * amplitude * limb; + if (name.startsWith('Arm_')) x = -cycle * amplitude * limb; + if (name === 'Body') { lift = Math.abs(cycle) * (clip === 'run' ? 0.3 : 0.12); x = clip === 'run' ? -0.12 : 0; } + } else if (clip === 'strike') { + const swing = Math.sin(Math.PI * t); + if (name === 'Arm_Right') { x = -2.1 * swing; z = -0.25 * swing; } + if (name === 'Body') y = 0.25 * swing; + if (name === 'Arm_Left') x = -0.35 * swing; + } else if (clip === 'guard') { + if (name.startsWith('Arm_')) { x = -1.15 + 0.035 * cycle; z = -limb * 0.35; } + if (name === 'Body') x = -0.1; + } else if (clip === 'swim') { + if (name === 'Body') x = -0.95; + if (name.startsWith('Arm_')) { x = -1.5 + cycle * limb * 0.65; z = -limb * 0.25; } + if (name.startsWith('Leg_')) x = cycle * limb * 0.35; + } + // Euler XYZ -> quaternion (B3D order WXYZ). No scale keys, no root motion. + const [cx, cy, cz] = [x,y,z].map(a => Math.cos(a / 2)); + const [sx, sy, sz] = [x,y,z].map(a => Math.sin(a / 2)); + return {lift, rotation: [cx*cy*cz + sx*sy*sz, sx*cy*cz - cx*sy*sz, + cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz]}; +} + +export const BODY_PROFILES = Object.freeze(['compact', 'balanced', 'broad']); +export function playerB3D(profile = 'balanced') { + if (!BODY_PROFILES.includes(profile)) throw new Error('Unknown character body profile.'); + const width = {compact: .92, balanced: 1, broad: 1.08}[profile]; + const vertices = [], triangles = [], weights = Object.fromEntries(skeleton.map(b => [b.name, []])); + box(vertices, triangles, weights, 'Body', [-2.15*width,8,-1.18,2.15*width,13.5,1.18], + ['back','tunic','tunic','tunic','tunic','tunic'], [profile === 'broad' ? .9 : .86, .86]); + box(vertices, triangles, weights, 'Head', [-1.6,13.5,-1.4,1.6,PLAYER_HEIGHT,1.4], ['hair','face','skin','hair','side','side']); + for (const [side, sign] of [['Right', -1], ['Left', 1]]) { + const x = sign * 3.1; + box(vertices, triangles, weights, `Arm_${side}`, [x-.78*width,8.6,-.84,x+.78*width,13,.84], 'sleeve', [.9,.95]); + box(vertices, triangles, weights, `Hand_${side}`, [x-.7,7.6,-.8,x+.7,8.6,.8], 'skin'); + const leg = sign * 1.15; + box(vertices, triangles, weights, `Leg_${side}`, [leg-.91*width,1.5,-.95,leg+.91*width,8,.95], 'trousers', [.9,.94]); + box(vertices, triangles, weights, `Leg_${side}`, [leg-1,0,-1.1,leg+1,1.5,1.5], 'boot'); + } + const mesh = chunk('MESH', i32(0), + chunk('VRTS', i32(1, 1, 2), ...vertices.map(v => f32(...v))), + chunk('TRIS', i32(0), ...triangles.map(t => i32(...t)))); + function node(bone) { + const keys = []; + for (let frame = 0; frame < 100; frame++) { + const {lift, rotation} = pose(bone.name, frame); + // Irrlicht subtracts one from B3D KEYS frame indices. Lua uses 0..99. + keys.push(i32(frame + 1), f32(bone.pos[0], bone.pos[1] + lift, bone.pos[2], ...rotation)); + } + return chunk('NODE', string(bone.name), f32(...bone.pos, 1,1,1, 1,0,0,0), + ...(bone.name === 'Root' ? [mesh, chunk('ANIM', i32(0,100), f32(24))] : []), + ...(weights[bone.name].length ? [chunk('BONE', ...weights[bone.name].map(id => Buffer.concat([i32(id), f32(1)])))] : []), + chunk('KEYS', i32(5), ...keys), ...skeleton.filter(b => b.parent === bone.name).map(node)); + } + return chunk('BB3D', i32(1), + chunk('TEXS', string('hearth_person.png'), i32(1,2), f32(0,0,1,1,0)), + chunk('BRUS', i32(1), string('Hearth cloth and skin'), f32(1,1,1,1,0), i32(1,0,0)), + node(skeleton[0])); +} + +export const TOOL_NAMES = Object.freeze(['knife', 'axe', 'pick', 'spear']); +export function toolOBJ(name) { + if (!TOOL_NAMES.includes(name)) throw new Error('Unknown original tool.'); + // Grip at origin; +Y is the blade/shaft axis. Attach to Hand_Right with + // rotation {x=90,y=0,z=0} to point along +Z in the neutral player pose. + const vertices = [], triangles = [], weights = {tool: []}; + const add = (bounds, tile) => box(vertices, triangles, weights, 'tool', bounds, tile); + const length = {knife: 1.6, axe: 5.2, pick: 5.2, spear: 10}[name]; + add([-.16,-1,-.16,.16,length,.16], 'wood'); + add([-.22,-.8,-.22,.22,.8,.22], 'wrap'); + if (name === 'axe') add([-1.9,3.8,-.22,.5,5.4,.22], 'metal'); + if (name === 'pick') add([-2.2,4.4,-.17,2.2,5,.17], 'metal'); + if (name === 'knife' || name === 'spear') { + // Tapered, double-sided pointed blade, not a blunt rectangular icon. + const first = vertices.length, base = length - .2, tip = length + (name === 'spear' ? 2.4 : 2.2); + const coords = uv('metal'); + for (const z of [-.08,.08]) for (const [i, p] of [[-.5,base],[.5,base],[0,tip]].entries()) + vertices.push([p[0],p[1],z,0,0,Math.sign(z),...coords[i]]); + triangles.push([first,first+2,first+1],[first+3,first+4,first+5], + [first,first+1,first+4],[first,first+4,first+3], + [first+1,first+2,first+5],[first+1,first+5,first+4], + [first+2,first,first+3],[first+2,first+3,first+5]); + } + const lines = [`# Original Last Hearth held ${name}; units=10/node; grip=0,0,0; atlas=hearth_person.png`]; + vertices.forEach(v => lines.push(`v ${v.slice(0,3).join(' ')}`)); + vertices.forEach(v => lines.push(`vt ${v[6]} ${1-v[7]}`)); + triangles.forEach(t => lines.push(`f ${t.map(i => `${i+1}/${i+1}`).join(' ')}`)); + return lines.join('\n') + '\n'; +} + +// Fixed profiles form the public Lua sound-name contract (no random variants). +export const SOUND_PROFILES = Object.freeze({ + hearth_step_soil: [.13, 90, .75, .24], hearth_step_wood: [.12, 180, .35, .28], + hearth_step_stone: [.10, 620, .50, .23], hearth_step_water: [.22, 300, .90, .20], + hearth_dig_soil: [.20, 80, .90, .28], hearth_dig_wood: [.18, 210, .50, .30], + hearth_dig_stone: [.20, 930, .45, .26], hearth_land: [.24, 65, .60, .34], + hearth_swing_knife: [.12, 700, .95, .18], hearth_swing_axe: [.24, 260, .95, .24], + hearth_swing_spear: [.18, 440, .90, .20], hearth_impact: [.16, 140, .60, .30], + hearth_block: [.20, 820, .35, .24], hearth_denial: [.16, 170, .04, .16], + hearth_fire: [.50, 95, .95, .13], +}); + +export function soundWAV(name) { + if (!Object.hasOwn(SOUND_PROFILES, name)) throw new Error('Unknown original sound.'); + const [duration, frequency, mix, gain] = SOUND_PROFILES[name]; + const count = Math.round(SAMPLE_RATE * duration), pcm = Buffer.alloc(count * 2); + // FNV-like name seed and xorshift are reproducible synthesis, NOT security RNG. + let state = 2166136261; + for (const char of name) state = Math.imul(state ^ char.charCodeAt(0), 16777619) >>> 0; + let low = 0; + for (let i = 0; i < count; i++) { + state ^= state << 13; state ^= state >>> 17; state ^= state << 5; + const noise = (state >>> 0) / 2147483648 - 1; + low += .24 * (noise - low); + const t = i / SAMPLE_RATE, phase = i / (count - 1); + const envelope = Math.min(1, i / 80, (count - 1 - i) / 160) * Math.exp(-4 * phase); + const tone = Math.sin(2 * Math.PI * frequency * t) * .75 + + Math.sin(2 * Math.PI * frequency * 1.63 * t) * .25; + const air = name.startsWith('hearth_swing_') ? noise : low; + const crackle = name === 'hearth_fire' && (state >>> 0) % 97 < 3 ? noise : 0; + const sample = gain * envelope * ((1-mix) * tone + mix * air + crackle * .3); + pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample)) * 32767), i * 2); + } + const header = Buffer.alloc(44); + header.write('RIFF'); header.writeUInt32LE(36 + pcm.length, 4); header.write('WAVEfmt ', 8); + header.writeUInt32LE(16, 16); header.writeUInt16LE(1, 20); header.writeUInt16LE(1, 22); + header.writeUInt32LE(SAMPLE_RATE, 24); header.writeUInt32LE(SAMPLE_RATE * 2, 28); + header.writeUInt16LE(2, 32); header.writeUInt16LE(16, 34); + header.write('data', 36); header.writeUInt32LE(pcm.length, 40); + return Buffer.concat([header, pcm]); +} + +// Generated sound-source inventory / minimal build-tool SBOM. The synthesized +// recordings are original MIT-licensed work (repository LICENSE.txt), not audio +// taken from FFmpeg. No third-party encoder code is shipped in the game. +export const AUDIO_PROVENANCE = Object.freeze({ + assetLicense: 'MIT', tool: '@ffmpeg/core', version: '0.12.10', + toolLicense: 'GPL-2.0-or-later', + repository: 'https://github.com/ffmpegwasm/ffmpeg.wasm', + tarball: 'https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz', + integrity: 'sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==', + javascriptSHA256: 'b266ab5b952555881dd6310663986994a182acb2b7ff25cf10a25f7a37ac2b21', + wasmSHA256: '9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7', +}); +export const SOUND_HASHES = Object.freeze({ + hearth_block: '604a7761f20c998c4c32e7a6d2beabd9333802636103a78ba7d9c866e2149b20', + hearth_denial: '491257912169f7fc45c2bacf59e5f8031ad786d9fed423d76fb12d668e9c3b5b', + hearth_dig_soil: '2b9509045b1da605af4a9d52ecc96cfa63f088dead8239dddc603185c2d29c9e', + hearth_dig_stone: '624790a3398b7708602e1f8ca1c0d4c18ff034491cb1ef9be6de4644b85b1ceb', + hearth_dig_wood: '6e827a1d916f9fd2b1ce09681eb433af949388febed55ca395827bb081a7d0cd', + hearth_fire: '811175440918ea29efd79e414a3391e1e0f504b3729268a3a48ef62b173c1d15', + hearth_impact: 'd051cb7e57d5cf497d1d40b3995d295fc5cd150db6578632bf597f171ce91bee', + hearth_land: '14dd5b1c818f74ff8007828ccd9b5addf09e5d0c2a251be5de67ef76c498631e', + hearth_step_soil: '110726200d18155c7a08c6981121a2e63b75227061ded7a77c5641c1dc4157b0', + hearth_step_stone: '333b1e92466909bdcee1214cd416983abe1a088b407f4f6a11efb7e1fddde1a8', + hearth_step_water: '194ce0984bcade40b050db0a76955737c5925593a1916218e56edcf4b0a7de09', + hearth_step_wood: 'ea594a486f8fbea66412521725e65e77103306def95fe6e1c8c54f0fef7a9a02', + hearth_swing_axe: 'c80d750f1b14860565435d321fc898506e8781c013b5645fffbc999115359795', + hearth_swing_knife: '31bc8ef920db76e333e6f6cc46293998ff15792063c73e42092b37407ecee4a2', + hearth_swing_spear: 'e8db0d5239834ff1426af118ca9575b957017ba7b504baa5ba0891e66a2d03e4', +}); +const soundDirectory = fileURLToPath(new URL('../mods/hearth/sounds/', import.meta.url)); +const sha256 = bytes => createHash('sha256').update(bytes).digest('hex'); + +export async function soundOGG(name) { + if (!Object.hasOwn(SOUND_PROFILES, name)) throw new Error('Unknown original sound.'); + const filename = await safePath(path.join(soundDirectory, name + '.ogg')); + const stat = await fs.lstat(filename); + if (!stat.isFile() || stat.size > 65536) throw new Error('Invalid original sound source.'); + const bytes = await fs.readFile(filename); + if (sha256(bytes) !== SOUND_HASHES[name]) throw new Error('Original Ogg source hash mismatch: ' + name); + return bytes; +} + +// Optional developer-only codec. Set HEARTH_FFMPEG_CORE to the absolute +// dist/umd/ffmpeg-core.js from the pinned package above, installed separately +// with npm --ignore-scripts. Verify BOTH files before executing any JS/WASM. +// This never downloads, invokes a shell, or changes project dependencies. +export async function loadVorbisCodec(filename) { + if (typeof filename !== 'string' || !path.isAbsolute(filename) || path.basename(filename) !== 'ffmpeg-core.js') + throw new Error('HEARTH_FFMPEG_CORE must name the absolute pinned UMD ffmpeg-core.js.'); + const javascript = await fs.readFile(await safePath(filename)); + const wasmBinary = await fs.readFile(await safePath(filename.replace(/\.js$/, '.wasm'))); + if (sha256(javascript) !== AUDIO_PROVENANCE.javascriptSHA256 || sha256(wasmBinary) !== AUDIO_PROVENANCE.wasmSHA256) + throw new Error('Pinned audio toolchain hash mismatch.'); + // The upstream web-worker bundle needs only location during initialization. + const oldSelf = globalThis.self; + let core; + try { + globalThis.self = {location: {href: 'file:///ffmpeg-core.js'}}; + core = await createRequire(import.meta.url)(filename)({wasmBinary}); + } finally { + if (oldSelf === undefined) delete globalThis.self; + else globalThis.self = oldSelf; + } + const messages = []; + core.setLogger(({message}) => { + // This pinned Emscripten wrapper logs exactly Aborted() for exit(0) too. + // Check exec's exit status AND all actual FFmpeg error-level diagnostics. + if (message !== 'Aborted()') messages.push(message); + if (messages.length > 200) messages.shift(); + }); + function convert(bytes, input, output, options) { + messages.length = 0; + core.FS.writeFile(input, bytes); + try { + const code = core.exec('-hide_banner', '-loglevel', 'error', '-nostdin', '-y', + '-xerror', '-err_detect', 'explode', '-i', input, ...options, output); + if (code !== 0 || messages.length) throw new Error('Audio conversion failed: ' + messages.join('\n')); + return Buffer.from(core.FS.readFile(output)); + } finally { + core.reset(); + for (const file of [input, output]) try { core.FS.unlink(file); } catch { /* absent after failure */ } + } + } + return { + encode: bytes => convert(bytes, 'source.wav', 'sound.ogg', ['-map_metadata', '-1', + '-ac', '1', '-ar', String(SAMPLE_RATE), '-c:a', 'libvorbis', '-q:a', '3', + '-fflags', '+bitexact', '-flags:a', '+bitexact', '-f', 'ogg']), + decode: bytes => convert(bytes, 'sound.ogg', 'decoded.pcm', ['-map', '0:a:0', '-c:a', 'pcm_s16le', '-f', 's16le']), + }; +} + +async function encodeSounds() { + const codec = await loadVorbisCodec(process.env.HEARTH_FFMPEG_CORE); + await fs.mkdir(await safePath(soundDirectory), {recursive: true}); + const hashes = {}; + for (const name of Object.keys(SOUND_PROFILES).sort()) { + const bytes = codec.encode(soundWAV(name)); + if (!bytes.equals(codec.encode(soundWAV(name)))) throw new Error('Non-deterministic Vorbis encoding.'); + hashes[name] = sha256(bytes); + if (SOUND_HASHES[name] && hashes[name] !== SOUND_HASHES[name]) throw new Error('Regeneration hash mismatch: ' + name); + await fs.writeFile(await safePath(path.join(soundDirectory, name + '.ogg')), bytes, {mode: 0o600}); + } + console.log(JSON.stringify(hashes, null, 2)); +} + +async function verifyEngineSounds() { + // Headless discovery is NOT client playback. Never open a persistent world. + const executable = process.env.HEARTH_ENGINE; + if (!executable || !path.isAbsolute(executable)) throw new Error('HEARTH_ENGINE must be an absolute native Luanti executable.'); + const version = spawnSync(await safePath(executable), ['--version'], {encoding: 'utf8', timeout: 10000, windowsHide: true}); + if (version.status !== 0 || !/^Luanti 5\.18\.0(?=\s|$)/m.test(version.stdout)) + throw new Error('Expected native Luanti 5.18.0: ' + JSON.stringify({status:version.status,stdout:version.stdout,stderr:version.stderr})); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'hearth-ogg-native-')); + try { + const world = path.join(root, 'world'), game = path.join(world, 'game'); + const { packageGame } = await import('./package.mjs'); + await fs.mkdir(world); + await packageGame(game); + const probe = path.join(world, 'worldmods/hearth_audio_probe'); + await fs.mkdir(probe, {recursive:true}); + await fs.writeFile(path.join(probe, 'mod.conf'), 'name = hearth_audio_probe\ndepends = hearth\n'); + await fs.writeFile(path.join(probe, 'init.lua'), [ + 'core.after(0.1, function()', + ' core.log("action", "LAST_HEARTH_AUDIO_DISCOVERY_PASS")', + ' core.request_shutdown("audio discovery complete", false, 0)', + 'end)', '', + ].join('\n')); + await fs.writeFile(path.join(world, 'world.mt'), 'gameid = hearth_audio_probe\nbackend = sqlite3\nplayer_backend = sqlite3\nauth_backend = sqlite3\nmod_storage_backend = sqlite3\n'); + const config = path.join(root, 'minetest.conf'); + await fs.writeFile(config, 'hearth_environment = staging\nhearth_latitude = 45\nhearth_longitude = 0\nbind_address = 127.0.0.1\nserver_announce = false\nmax_users = 0\n'); + const result = spawnSync(executable, ['--server', '--info', '--verbose', '--world', world, + '--config', config, '--port', String(randomInt(49152,65536)), '--logfile', path.join(root, 'engine.log')], { + cwd:root, encoding:'utf8', timeout:90000, maxBuffer:8*1024*1024, windowsHide:true, + env:{...process.env, LUANTI_GAME_PATH:root, MINETEST_GAME_PATH:''}, + }); + const log = result.stdout + '\n' + result.stderr; + const rejected = log.split(/\r?\n/).filter(line=>/ignor|unsupported|disallowed|failed|error/i.test(line) && /hearth_.*\.(?:ogg|wav)/i.test(line)); + const accepted = Object.keys(SOUND_PROFILES).filter(name=>new RegExp('Server: [a-f0-9]+ is '+name+'\\.ogg \\(').test(log)); + if (result.status !== 0 || !log.includes('LAST_HEARTH_AUDIO_DISCOVERY_PASS') || rejected.length || accepted.length !== 15) + throw new Error('Native audio discovery failed: ' + JSON.stringify({status:result.status,error:result.error?.message,accepted,rejected,tail:log.slice(-8000)})); + console.log(JSON.stringify({engine:'Luanti 5.18.0',headless:true,acceptedOgg:accepted.length,rejectedSounds:rejected.length, + playbackVerified:false,marker:'LAST_HEARTH_AUDIO_DISCOVERY_PASS'})); + } finally { await fs.rm(root, {recursive:true,force:true}); } +} + +// Reproduce sources or verify discovery only on explicit developer commands. +// Normal packaging/imports are read-only and need neither codec nor engine. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + // Do not await at module scope: packageGame imports assets -> media too. + const action = process.argv[2] === '--encode-sounds' ? encodeSounds + : process.argv[2] === '--verify-engine-sounds' ? verifyEngineSounds : null; + if (!action) throw new Error('Expected --encode-sounds or --verify-engine-sounds.'); + action().catch(error => { console.error(error); process.exitCode = 1; }); +} diff --git a/scripts/package.mjs b/scripts/package.mjs new file mode 100755 index 0000000..8c559af --- /dev/null +++ b/scripts/package.mjs @@ -0,0 +1,62 @@ +// Engine-independent, reproducible game-directory packaging; no downloads. +import * as fs from 'node:fs/promises'; +import { constants } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateAssets } from './assets.mjs'; +import { absolute, atomicDirectory, copyTree, disjoint, exists, json, relativeName, safePath, verifyManifest, writeManifest } from './files.mjs'; + +export const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export async function readRelease(source = SOURCE) { + await safePath(path.join(absolute(source), 'release.json')); + const release = JSON.parse(await fs.readFile(path.join(source, 'release.json'), 'utf8')); + if (release.format !== 1 || release.gameId !== 'last_hearth' || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(release.version) || + release.engine?.name !== 'Luanti' || release.engine?.version !== '5.18.0' || release.node !== '>=22' || + release.packageFormat !== 1 || release.backupFormat !== 1) throw new Error('Unsupported release contract.'); + return release; +} + +export async function packageGame(destination, { source = SOURCE } = {}) { + source = absolute(source); destination = absolute(destination); + // dist/ within the repository is supported; no payload source may overlap it. + for (const entry of ['mods', 'fixtures', 'tests', 'scripts']) disjoint(path.join(source, entry), destination); + const release = await readRelease(source); + return atomicDirectory(destination, async stage => { + for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) { + const filename = await safePath(path.join(source, name)); + if (!(await fs.lstat(filename)).isFile()) throw new Error('Package input must be a regular file.'); + await fs.copyFile(filename, path.join(stage, name), constants.COPYFILE_EXCL); + } + await copyTree(path.join(source, 'mods'), path.join(stage, 'mods'), { + exclude: new Set(['hearth/textures', 'hearth/models']), + }); + for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) { + if (!await exists(path.join(stage, 'mods/hearth', name + '.lua'))) throw new Error('Game payload is incomplete.'); + } + // Generate from the copied inputs, not a changing worktree. Refuse links first. + await generateAssets(stage, stage); + await fs.mkdir(path.join(stage, 'fixtures')); + await safePath(path.join(source, 'tests/fixtures/scenarios.json')); + const scenarios = JSON.parse(await fs.readFile(path.join(source, 'tests/fixtures/scenarios.json'), 'utf8')); + await fs.writeFile(path.join(stage, 'fixtures/scenarios.json'), json(scenarios), { flag: 'wx' }); + // Runtime fixture schemas are owned by the game agent; copy JSON contracts + // verbatim, including config.json and first-session/summer/winter fixtures. + for (const name of (await fs.readdir(path.join(source, 'tests/fixtures'))).sort()) { + if (name === 'scenarios.json' || !name.endsWith('.json')) continue; + relativeName(name); + const fixture = await safePath(path.join(source, 'tests/fixtures', name)); + if (!(await fs.lstat(fixture)).isFile()) throw new Error('Fixture must be a regular JSON file.'); + JSON.parse(await fs.readFile(fixture, 'utf8')); + await fs.copyFile(fixture, path.join(stage, 'fixtures', name), constants.COPYFILE_EXCL); + } + await fs.writeFile(path.join(stage, 'release.json'), json(release), { flag: 'wx' }); + await writeManifest(stage, 'game'); + await verifyPackage(stage); + }); +} + +export async function verifyPackage(directory) { + const manifest = await verifyManifest(directory, 'game'); + await readRelease(directory); + return manifest; +} diff --git a/scripts/recovery.mjs b/scripts/recovery.mjs new file mode 100755 index 0000000..68c8e20 --- /dev/null +++ b/scripts/recovery.mjs @@ -0,0 +1,301 @@ +// Offline, private M0 recovery evidence, NOT a public-readiness certification. +// Only copies are booted. Stop external engines first; locks/process discovery +// cannot prove that a remote machine has not opened a shared world directory. +// SHA256 is integrity evidence, not authentication or encryption. Runtime/backups +// must be on an access-controlled (preferably encrypted) volume, outside webroots. +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { createHash, randomUUID } from 'node:crypto'; +import dgram from 'node:dgram'; +import { absolute, disjoint, ensureDirectory, inventory, json, safePath, withLauncherLock } from './files.mjs'; +import { assertWorldOffline, backupWorld, restoreWorld, verifyBackup } from './backup.mjs'; +import { packageGame, SOURCE, verifyPackage } from './package.mjs'; +import { engineExecutable, runtimeHome, validateEngine } from './launcher.mjs'; + +const exec = promisify(execFile); +const digest = value => createHash('sha256').update(json(value)).digest('hex'); +const same = (left, right) => { + if (json(left) !== json(right)) throw new Error('Recovery SHA256 inventory mismatch.'); +}; +const privateWrite = (filename, value) => fs.writeFile(filename, value, { flag: 'wx', mode: 0o600 }); +class RecoveryError extends Error { + constructor(category) { super(category); this.category = category; } +} +const failure = category => new RecoveryError(category); + +async function assertNoEnginesOrWriters() { + // Fail closed on process-discovery errors. Never print command lines: they can + // contain private paths, locations or credentials belonging to other apps. + if (process.platform === 'win32') { + // Image names/PIDs only; no nested shell or private command-line discovery. + // The exclusive runtime lease serializes managed Node writers, not unrelated + // Node previews. External/shared-world writers must still be stopped by owner. + const executable = path.join(absolute(process.env.SystemRoot || 'C:\\Windows'), 'System32/tasklist.exe'); + const { stdout } = await exec(executable, ['/fo', 'csv', '/nh'], { + shell: false, windowsHide: true, encoding: 'utf8', timeout: 15_000, maxBuffer: 4 * 1024 * 1024, + }); + let rows = 0; + for (const line of stdout.trim().split(/\r?\n/)) { + const match = /^"([^"\r\n]+)","(\d+)",/.exec(line); + if (!match) throw failure('process-discovery-invalid'); + rows++; + if (/^(?:luanti|minetest)(?:-?server)?\.exe$/i.test(match[1])) throw failure('engine-process-active'); + } + if (!rows) throw failure('process-discovery-empty'); + } else { + const { stdout } = await exec('/bin/ps', ['-eo', 'pid=,comm=,args='], { + shell: false, encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, + }); + for (const line of stdout.split('\n')) { + const match = /^\s*(\d+)\s+(\S+)\s+(.*)$/.exec(line); + if (!match || Number(match[1]) === process.pid) continue; + if (/^(?:luanti|minetest)(?:-?server)?$/i.test(path.basename(match[2])) || + (/^node(?:js)?$/i.test(path.basename(match[2])) && /scripts\/(?:run|backup|package|recovery)\.mjs/.test(match[3]))) { + throw new Error('Stop all Luanti engines and other Hearth tooling before recovery.'); + } + } + } +} + +function parseWorld(text) { + const settings = new Map(); + for (const line of text.split(/\r?\n/)) { + if (!line.trim() || /^\s*#/.test(line)) continue; + const match = /^\s*([a-zA-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line); + if (!match || settings.has(match[1])) throw new Error('Ambiguous world settings; recovery refused.'); + settings.set(match[1], match[2]); + } + if (settings.get('gameid') !== 'last_hearth' || + ['backend', 'player_backend', 'auth_backend', 'mod_storage_backend'].some(key => settings.get(key) !== 'sqlite3')) { + throw new Error('Recovery requires an existing last_hearth world with local SQLite backends.'); + } + return settings; +} + +async function isolatedWorld(world, gameId) { + // Byte equality is checked BEFORE these copy-only changes. Do not execute old + // worldmods (including stale hearth_smoke), or inherit arbitrary world settings. + await assertWorldOffline(world); + parseWorld(await fs.readFile(path.join(world, 'world.mt'), 'utf8')); + await fs.rm(path.join(world, 'worldmods'), { recursive: true, force: true }); + const mod = path.join(world, 'worldmods/hearth_recovery'); + await ensureDirectory(mod); + await privateWrite(path.join(mod, 'mod.conf'), 'name = hearth_recovery\ndepends = hearth\ndescription = Private offline M0 recovery probe\n'); + await fs.copyFile(await safePath(path.join(SOURCE, 'tests/recovery_probe.lua')), path.join(mod, 'probe.lua')); + await privateWrite(path.join(mod, 'init.lua'), 'dofile(core.get_modpath("hearth_recovery") .. "/probe.lua")("probe")\n'); + await fs.writeFile(path.join(world, 'world.mt'), [ + `gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3', + 'auth_backend = sqlite3', 'mod_storage_backend = sqlite3', + 'load_mod_hearth = true', 'load_mod_hearth_recovery = true', '', + ].join('\n'), { mode: 0o600 }); +} + +async function availablePort() { + const socket = dgram.createSocket('udp4'); + try { + await new Promise((resolve, reject) => { + socket.once('error', reject); + socket.bind(0, '127.0.0.1', resolve); + }); + return socket.address().port; + } finally { socket.close(); } +} + +async function boot(executable, stage, world, phase, token, env, gameId) { + await assertNoEnginesOrWriters(); + await assertWorldOffline(world); + const config = path.join(stage, `recovery-${phase}.conf`); + await privateWrite(config, [ + 'hearth_environment = staging', 'hearth_latitude = 45', 'hearth_longitude = 0', + `hearth_recovery_phase = ${phase}`, `hearth_recovery_token = ${token}`, + `hearth_recovery_gameid = ${gameId}`, + 'bind_address = 127.0.0.1', `port = ${await availablePort()}`, 'enable_ipv6 = false', + 'max_users = 0', 'server_announce = false', 'enable_pvp = false', + 'secure.enable_security = true', 'secure.trusted_mods =', 'secure.http_mods =', + 'enable_mod_channels = false', 'time_speed = 0', 'dedicated_server_step = 0.05', '', + ].join('\n')); + // Deliberately do not stream raw engine output. It can contain private player + // names, world positions, parser data and URLs. Keep it bounded in memory only; + // the private temporary logfile is deleted with the stage even after failures. + let result; + try { + result = await exec(executable, ['--server', '--world', world, '--gameid', gameId, + '--config', config, '--logfile', path.join(stage, `engine-${phase}.log`)], { + cwd: stage, env: { ...env, LUANTI_GAME_PATH: path.join(stage, 'games') }, + shell: false, windowsHide: true, encoding: 'utf8', timeout: 90_000, + killSignal: 'SIGKILL', maxBuffer: 4 * 1024 * 1024, + }); + } catch (error) { + const output = String(error.stdout || '') + '\n' + String(error.stderr || ''); + throw failure(`${phase}-${probeFailure(output) || (error.killed ? 'engine-timeout' : + error.code === 'ENOENT' ? 'engine-not-found' : 'engine-exit')}`); + } + const output = result.stdout + '\n' + result.stderr; + const marker = `LAST_HEARTH_RECOVERY_PASS ${phase} ${token}`; + if (output.split(marker).length !== 2 || output.includes('LAST_HEARTH_RECOVERY_FAIL')) { + throw failure(`${phase}-${probeFailure(output) || 'shutdown-marker-missing'}`); + } + await assertWorldOffline(world); + const summary = JSON.parse(await fs.readFile(path.join(world, `m0-recovery-${phase}.json`), 'utf8')); + if (summary.format !== 1 || summary.phase !== phase || summary.token !== token || + !Number.isSafeInteger(summary.existingKeys) || summary.existingKeys < 1 || summary.existingKeys > 4 || + !Number.isSafeInteger(summary.legacyKeys) || summary.legacyKeys < 1 || summary.legacyKeys > 4 || + !Number.isSafeInteger(summary.claimCount) || summary.claimCount < 0 || + summary.noClients !== true || summary.fixture !== true || summary.spawn !== true || + summary.claims !== true || summary.homes !== true) { + throw new Error('Recovery probe summary is incomplete.'); + } + if (!Number.isSafeInteger(summary.verifiedAssertions) || summary.verifiedAssertions < 1) throw failure(`${phase}-assertion-count-missing`); + return summary; +} + +function probeFailure(output) { + // Only accept a static assertion/checkpoint label, never parser strings, + // filenames, records, or arbitrary engine errors as a diagnostic label. + const match = /LAST_HEARTH_RECOVERY_FAIL (checkpoint-[a-z-]{1,96})(?=\s|$)/.exec(output); + return match ? `probe-${match[1]}` : undefined; +} + +export async function recover({ env = process.env } = {}) { + let step = 'runtime-preflight'; + try { + const runtime = await safePath(runtimeHome(env)); + // HEARTH_HOME changes tooling storage, never which original world is selected. + if (!env.HEARTH_RECOVERY_SOURCE && !env.LOCALAPPDATA) { + throw new Error('Set HEARTH_RECOVERY_SOURCE or LOCALAPPDATA.'); + } + const source = await safePath(absolute(env.HEARTH_RECOVERY_SOURCE || + path.join(absolute(env.LOCALAPPDATA), 'last-hearth-runtime/worlds/last_hearth'))); + const executable = engineExecutable(env, process.platform, runtime); + step = 'process-discovery'; + await assertNoEnginesOrWriters(); + step = 'engine-validation'; + const engineVersion = await validateEngine(executable); + // Refuse to run against partially wired code. The Lua probe also checks the + // actual loaded API: this textual gate alone is not evidence of integration. + const init = await fs.readFile(path.join(SOURCE, 'mods/hearth/init.lua'), 'utf8'); + if (!/hearth\.persistence\s*=/.test(init)) throw new Error('Recovery deferred: init must wire hearth.persistence first.'); + step = 'runtime-lock'; + return await withLauncherLock(runtime, async lease => { + step = 'source-offline-inventory'; + await assertNoEnginesOrWriters(); + const original = await assertWorldOffline(source); + parseWorld(await fs.readFile(path.join(source, 'world.mt'), 'utf8')); + for (const name of ['m0-recovery-expected.json', 'm0-recovery-fixture.json']) { + if (original.some(entry => entry.path === name)) throw new Error('Source already contains recovery instrumentation.'); + } + const backups = path.join(runtime, 'backups'); + disjoint(source, backups); + await ensureDirectory(backups); + const token = randomUUID(); + // Installed games precede LUANTI_GAME_PATH in Luanti's search order. + // A unique disposable ID prevents an old installed game shadowing this + // verified package, without touching the installation or original world. + const gameId = `last_hearth_recovery_${token.replaceAll('-', '')}`; + const retained = path.join(backups, `m0-validation-${new Date().toISOString().replace(/[:.]/g, '-')}-${token}`); + const stage = await fs.mkdtemp(path.join(runtime, 'recovery-')); + await fs.chmod(stage, 0o700); + disjoint(source, stage); + const options = { offline: true, runtime, lease }; + try { + step = 'original-backup'; + // Retain the untouched original snapshot, even if a later boot fails. + await backupWorld(source, retained, options); + await verifyBackup(retained); + same(original, await inventory(path.join(retained, 'world'))); + const first = path.join(stage, 'restored-first'); + step = 'first-restore'; + await verifyBackup(retained); // Supplied backup SHA256, BEFORE restore. + await restoreWorld(retained, first, options); + same(original, await inventory(first)); // Includes original player DB bytes. + await ensureDirectory(path.join(stage, 'games')); + const game = path.join(stage, 'games', gameId); + const inputs = await inventory(path.join(SOURCE, 'mods')); + step = 'game-package'; + await packageGame(game); + same(inputs, await inventory(path.join(SOURCE, 'mods'))); + const packaged = await verifyPackage(game); + // Instrument only the disposable package, before its real hearth init. + // This reads the engine's own ModStorageRef in the hearth mod context, + // capturing exact pre-migration bytes without Node SQLite or global mocks. + const gameInit = path.join(game, 'mods/hearth/init.lua'); + await fs.writeFile(gameInit, + 'dofile(core.get_worldpath() .. "/worldmods/hearth_recovery/probe.lua")("capture")\n' + + await fs.readFile(gameInit, 'utf8'), { mode: 0o600 }); + await isolatedWorld(first, gameId); + step = 'seed-boot'; + const seeded = await boot(executable, stage, first, 'seed', token, env, gameId); + step = 'second-backup-restore'; + const afterFirst = await assertWorldOffline(first); + const secondBackup = path.join(stage, 'after-first-backup'); + await backupWorld(first, secondBackup, options); + await verifyBackup(secondBackup); + same(afterFirst, await inventory(path.join(secondBackup, 'world'))); + const second = path.join(stage, 'restored-second'); + await restoreWorld(secondBackup, second, options); + same(afterFirst, await inventory(second)); + step = 'restart-boot'; + const restarted = await boot(executable, stage, second, 'restart', token, env, gameId); + if (seeded.existingKeys !== restarted.existingKeys || seeded.legacyKeys !== restarted.legacyKeys || + seeded.claimCount !== restarted.claimCount) { + throw new Error('Restart migration evidence mismatch.'); + } + step = 'final-integrity-verification'; + await assertNoEnginesOrWriters(); + same(original, await assertWorldOffline(source)); + await verifyBackup(retained); + same(original, await inventory(path.join(retained, 'world'))); + // No raw records, player names, filesystem paths, coordinates or URLs in + // the retained report. Credentials are neither generated nor required. + const report = { + format: 1, milestone: 'M2', publicReady: false, engineVersion, + sourceUnchanged: true, backupVerified: true, restoresByteExact: 2, + successfulEngineShutdowns: 2, noClients: true, + verifiedAssertions: { seed: seeded.verifiedAssertions, restart: restarted.verifiedAssertions, + total: seeded.verifiedAssertions + restarted.verifiedAssertions }, + existingKeys: seeded.existingKeys, legacyKeys: seeded.legacyKeys, claimCount: seeded.claimCount, + migratedRecordsPreserved: true, immutableLegacyBackups: true, + savedSpawnVerified: true, claimsProtectionVerified: true, homesVerified: true, + playerAdapterFixture: true, nodeMetadataInventoryRestored: true, absoluteFireExpiry: true, + sourceFiles: original.filter(entry => entry.type === 'file').length, + sourceInventorySha256: digest(original), + packageFiles: packaged.entries.filter(entry => entry.type === 'file').length, + packageInventorySha256: digest(packaged.entries), + firstBootInventorySha256: digest(afterFirst), + }; + // Never add files to the immutable snapshot, even transiently: interruption + // while publishing a report must not invalidate its exact SHA256 manifest. + step = 'report-publication'; + await privateWrite(retained + '-report.json', json(report)); + return report; + } finally { + // Never delete source, retained backup, or arbitrary shared paths. An + // engine child is synchronously reaped before this private stage is removed. + try { same(original, await assertWorldOffline(source)); } + finally { + await safePath(stage); + await fs.rm(stage, { recursive: true, force: true }); + } + } + }); + } catch (error) { + if (error instanceof RecoveryError) throw error; + const code = ['EEXIST', 'ENOENT', 'EACCES', 'EPERM', 'EBUSY', 'ETIMEDOUT'].includes(error.code) ? `-${error.code}` : ''; + throw failure(step + code); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + if (process.argv.length !== 2) throw new Error('Recovery accepts no arguments; use HEARTH_RECOVERY_SOURCE.'); + const report = await recover(); + process.stdout.write(json(report)); + } catch (error) { + // Error objects/engine stderr can carry source paths and original save data. + process.stderr.write(`LAST_HEARTH_RECOVERY_FAIL: ${error instanceof RecoveryError ? error.category : 'invalid-invocation'}; original world was never launched.\n`); + process.exitCode = 1; + } +} diff --git a/scripts/run.mjs b/scripts/run.mjs index d7e14f6..e6b7fcf 100755 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -1,39 +1,100 @@ #!/usr/bin/env node // Original MIT-licensed launcher. Node built-ins only; never invokes a shell. import { spawn } from 'node:child_process'; -import { randomInt, randomUUID } from 'node:crypto'; -import { constants } from 'node:fs'; +import { createHash, randomInt, randomUUID } from 'node:crypto'; import * as fs from 'node:fs/promises'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { generateAssets } from './assets.mjs'; +import { pathToFileURL } from 'node:url'; +import { backupWorld, restoreWorld } from './backup.mjs'; +import { absolute, atomicDirectory, copyTree, ensureDirectory, exists, safePath, withLauncherLock } from './files.mjs'; +import { packageGame, SOURCE, verifyPackage } from './package.mjs'; +import { coordinates, engineExecutable, environmentSettings, fixtureSettings, graphicsSettings, prepareCourse, runtimeHome, snapshotBeforePlay, validateEngine } from './launcher.mjs'; +import { isTerrainSetting, terrainSettings } from './terrain.mjs'; -const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const source = SOURCE; const command = process.argv[2]; -const excluded = new Set(['scripts', 'tests', '.git']); -const marker = '.last-hearth-launcher-managed'; const TEST_TIMEOUT = 90_000; const PLAY_TIMEOUT = 8 * 60 * 60 * 1000; +const EMBEDDED_MARKER = '.last-hearth-embedded-session.json'; -async function exists(filename) { - try { await fs.access(filename, constants.F_OK); return true; } - catch (error) { if (error.code === 'ENOENT') return false; throw error; } +// JSON escapes are not Lua escapes (notably \uXXXX). Encode UTF-8 bytes with +// fixed-width decimal escapes, including quotes, backslashes and control bytes. +const luaString = value => '"' + [...Buffer.from(value)].map(byte => `\\${String(byte).padStart(3, '0')}`).join('') + '"'; + +async function pinPackage(game, gameId, verified, requireCourse = false) { + const manifest = verified ?? await verifyPackage(game); + const release = JSON.parse(await fs.readFile(path.join(game, 'release.json'), 'utf8')); + const digest = createHash('sha256').update(JSON.stringify(manifest)).digest('hex'); + const token = randomUUID(); + const marker = `LAST_HEARTH_PACKAGE_LOADED ${token} ${release.version} ${digest}`; + const init = path.join(game, 'mods/hearth/init.lua'); + const original = await fs.readFile(init); + // Only the private runtime copy is instrumented. The exact payload was verified + // above; path checks run BEFORE its code and the marker follows mod loading. + // A stale installed game or a worldmod overriding hearth cannot emit this token. + const normalize = value => value.replaceAll('\\', '/').replace(/\/+$/, ''); + await fs.writeFile(init, Buffer.concat([Buffer.from([ + 'do', + ' local function normalized(p) return (p:gsub("\\\\", "/"):gsub("/+$", "")) end', + ' local info = core.get_game_info()', + ` assert(info.id == ${luaString(gameId)} and normalized(info.path) == ${luaString(normalize(game))}, "Launcher game path/id mismatch")`, + ` assert(normalized(core.get_modpath("hearth")) == ${luaString(normalize(path.join(game, 'mods/hearth')))}, "Launcher hearth path mismatch")`, + ' core.register_on_mods_loaded(function()', + ...(requireCourse ? [ + ' assert(hearth.course and type(hearth.course.report) == "function", "Course module must be wired by the game bootstrap")', + ' assert(hearth.storage:get_string("m1_course_world_nonce") == core.settings:get("hearth_m1_course_nonce"), "Course must enroll before clock/players on first load")', + ] : []), + ` core.log("action", ${luaString(marker)})`, + ' end)', + 'end', '', + ].join('\n')), original])); + return marker; } -// copyFile avoids fs.cp's realpath failures on Windows SMB shares. Refuse -// symlinks/junctions rather than following them out of the selected source. -async function copyTree(from, to, skip = new Set()) { - const stat = await fs.lstat(from); - if (stat.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${from}`); - if (stat.isDirectory()) { - await fs.mkdir(to, { recursive: true }); - for (const entry of await fs.readdir(from)) { - if (!skip.has(entry)) await copyTree(path.join(from, entry), path.join(to, entry), skip); - } - } else if (stat.isFile()) { - await fs.copyFile(from, to); - } else { - throw new Error(`Not a regular file or directory: ${from}`); +async function embeddedOwner(world, runtime) { + const game = await safePath(path.join(world, 'game')); + if (!await exists(game)) return undefined; + const marker = await safePath(path.join(game, EMBEDDED_MARKER)); + if (!(await fs.lstat(game)).isDirectory() || !await exists(marker) || !(await fs.lstat(marker)).isFile() || + (await fs.stat(marker)).size > 4096) throw new Error('Refusing to overwrite an unmanaged embedded world game.'); + const owner = JSON.parse(await fs.readFile(marker, 'utf8')); + if (owner.format !== 1 || owner.owner !== 'last-hearth-runner' || owner.world !== world || owner.runtime !== runtime || + typeof owner.token !== 'string' || !/^[0-9a-f-]{36}$/.test(owner.token)) { + throw new Error('Refusing an unrecognized embedded world game marker.'); + } + return owner; +} + +async function playGame(world, runtime, packaged, action) { + // Embedded games take precedence in findWorldSubgame(), including the GUI + // client, which otherwise ignores --gameid for an existing world. Never alias + // or rewrite world.mt: its logical last_hearth identity survives even SIGKILL. + const text = await fs.readFile(await safePath(path.join(world, 'world.mt')), 'utf8'); + const ids = [...text.matchAll(/^\s*gameid\s*=\s*([^\r\n]*?)\s*$/gm)]; + if (ids.length !== 1 || ids[0][1] !== 'last_hearth') throw new Error('Play requires logical world gameid last_hearth.'); + const game = path.join(world, 'game'); + if (await embeddedOwner(world, runtime)) { + // A hard-killed launcher can leave its private package behind. Snapshot was + // taken already; preserve rather than delete it (including any added files). + await fs.rename(game, path.join(runtime, `preserved-embedded-game-${randomUUID()}`)); + } + const token = randomUUID(); + let verified; + await atomicDirectory(game, async directory => { + await copyTree(packaged, directory, { intoEmpty: true }); + verified = await verifyPackage(directory); + await fs.writeFile(path.join(directory, EMBEDDED_MARKER), JSON.stringify({ + format: 1, owner: 'last-hearth-runner', token, world, runtime, + }), { flag: 'wx', mode: 0o600 }); + }); + try { + // Keep ownership present continuously, including if instrumentation is + // interrupted. Only this verified private copy gains launcher-only files. + const marker = await pinPackage(game, 'last_hearth', verified); + await action(marker); + } finally { + if ((await embeddedOwner(world, runtime))?.token !== token) throw new Error('Embedded game ownership changed; cleanup refused.'); + await fs.rm(game, { recursive: true, force: true }); } } @@ -41,7 +102,7 @@ async function luaFiles(directory, prefix = '') { const result = []; for (const entry of await fs.readdir(directory, { withFileTypes: true })) { if (entry.isSymbolicLink()) throw new Error(`Refusing symbolic link: ${entry.name}`); - if (entry.name === '.git' || entry.name === 'scripts') continue; + if (['.git', 'scripts', 'dist', 'node_modules'].includes(entry.name) || entry.name.startsWith('.nfs')) continue; const relative = prefix + entry.name; if (entry.isDirectory()) result.push(...await luaFiles(path.join(directory, entry.name), relative + '/')); else if (entry.isFile() && entry.name.endsWith('.lua')) result.push(relative); @@ -49,36 +110,21 @@ async function luaFiles(directory, prefix = '') { return result.sort(); } -function coordinates() { - const latitude = process.env.HEARTH_LATITUDE; - const longitude = process.env.HEARTH_LONGITUDE; - if (latitude === undefined && longitude === undefined) return []; - if (latitude === undefined || longitude === undefined) { - throw new Error('Provide both HEARTH_LATITUDE and HEARTH_LONGITUDE, or neither.'); - } - const parse = (value, limit, name) => { - if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(value)) throw new Error(`Invalid ${name}.`); - const number = Number(value); - if (!Number.isFinite(number) || Math.abs(number) > limit) throw new Error(`${name} out of range.`); - return number; - }; - return [ - `hearth_latitude = ${parse(latitude, 90, 'latitude')}`, - `hearth_longitude = ${parse(longitude, 180, 'longitude')}`, - ]; -} - -function run(executable, args, cwd, timeout, successMarker) { +function run(executable, args, cwd, timeout, successMarker, games) { return new Promise((resolve, reject) => { const child = spawn(executable, args, { - cwd, shell: false, windowsHide: command !== 'play', stdio: ['ignore', 'pipe', 'pipe'], + cwd, shell: false, windowsHide: !['play', 'course'].includes(command), stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, ...(games ? { LUANTI_GAME_PATH: games, MINETEST_GAME_PATH: '' } : {}) }, }); - let tail = '', timedOut = false, interrupted = false; + const pending = new Set([successMarker].flat().filter(Boolean)); + let timedOut = false, interrupted = false; const capture = stream => { + let tail = ''; stream.setEncoding('utf8'); stream.on('data', text => { // Bound memory even if the engine emits excessive diagnostics. tail = (tail + text).slice(-1024 * 1024); + for (const marker of pending) if (tail.includes(marker)) pending.delete(marker); process.stdout.write(text); }); }; @@ -99,60 +145,44 @@ function run(executable, args, cwd, timeout, successMarker) { if (timedOut) reject(new Error(`Process exceeded ${timeout / 1000}s timeout.`)); else if (interrupted) reject(new Error('Interrupted; child process stopped.')); else if (code !== 0) reject(new Error(`Process failed (exit ${code}, signal ${signal || 'none'}).`)); - else if (successMarker && !tail.includes(successMarker)) reject(new Error('Engine exited without the required success marker.')); + else if (pending.size) reject(new Error('Engine exited without the required package-load/test success marker.')); else resolve(); }); }); } -async function stageGame(engine) { - for (const required of ['game.conf', 'mods/hearth/init.lua', 'mods/hearth/model.lua', 'mods/hearth/runtime.lua', 'mods/hearth/content.lua']) { - if (!await exists(path.join(source, required))) throw new Error(`Game is incomplete: missing ${required}.`); +export async function writeWorld(world, config, location, extra = [], environment = 'production', gameId = 'last_hearth', env = process.env) { + const graphics = graphicsSettings(env); + const settings = environmentSettings(environment, [...location, ...extra]); + const graphicsKeys = new Set(graphics.map(line => line.split('=')[0].trim())); + if (settings.some(line => graphicsKeys.has(line.split('=')[0].trim()))) { + throw new Error('Graphics settings are launcher-managed; use HEARTH_VIEW_DISTANCE.'); } - const games = path.join(engine, 'games'); - await fs.mkdir(games, { recursive: true }); - const destination = path.join(games, 'last_hearth'); - if (await exists(destination)) { - if ((await fs.lstat(destination)).isSymbolicLink() || !await exists(path.join(destination, marker))) { - throw new Error(`Refusing to overwrite an unmanaged game: ${destination}`); - } - } - const stage = await fs.mkdtemp(path.join(games, '.last-hearth-stage-')); - const backup = path.join(games, `.last-hearth-backup-${randomUUID()}`); - let backedUp = false; - try { - await copyTree(source, stage, excluded); - await generateAssets(stage, source); - await fs.writeFile(path.join(stage, marker), 'Managed copy; edit the repository, not this directory.\n'); - if (await exists(destination)) { await fs.rename(destination, backup); backedUp = true; } - try { await fs.rename(stage, destination); } - catch (error) { if (backedUp) await fs.rename(backup, destination); throw error; } - if (backedUp) await fs.rm(backup, { recursive: true, force: true }); - } finally { - await fs.rm(stage, { recursive: true, force: true }); - } -} - -async function writeWorld(world, config, location, extra = []) { - await fs.mkdir(world, { recursive: true }); + await ensureDirectory(world); const worldFile = path.join(world, 'world.mt'); - if (!await exists(worldFile)) { + // Decide before writing world.mt. Saved mapgen parameters are engine-owned; + // even a legacy/orphan map_meta.txt opts out of creation-time overrides. + const worldExists = await exists(worldFile); + const newWorld = !worldExists && !await exists(path.join(world, 'map_meta.txt')); + if (!worldExists) { await fs.writeFile(worldFile, [ - 'gameid = last_hearth', 'backend = sqlite3', 'player_backend = sqlite3', + `gameid = ${gameId}`, 'backend = sqlite3', 'player_backend = sqlite3', 'auth_backend = sqlite3', 'mod_storage_backend = sqlite3', 'load_mod_hearth = true', '', ].join('\n'), { flag: 'wx' }); } + await safePath(config); await fs.writeFile(config, [ 'enable_damage = true', 'enable_pvp = false', 'creative_mode = false', 'bind_address = 127.0.0.1', 'server_announce = false', 'enable_ipv6 = false', 'time_speed = 0', 'movement_speed_walk = 4', 'movement_speed_fast = 6', 'movement_speed_jump = 6.5', 'secure.enable_security = true', - 'mg_name = v7', 'fixed_map_seed = 731104', - ...location, ...extra, '', - ].join('\n'), { mode: 0o600 }); + ...graphics, + ...terrainSettings(newWorld), + ...settings.filter(line => newWorld || !isTerrainSetting(line)), '', + ].join('\n'), { flag: 'wx', mode: 0o600 }); } -async function engineTest(mode, runtime, engine, executable, location) { +async function engineTest(mode, runtime, games, executable, location, gameId, packageMarker, options = {}) { const temporary = await fs.mkdtemp(path.join(runtime, `${mode}-`)); try { const world = path.join(temporary, 'world'); @@ -178,11 +208,13 @@ async function engineTest(mode, runtime, engine, executable, location) { await writeWorld(world, config, testLocation, [ 'load_mod_hearth_smoke = true', `hearth_launcher_mode = ${mode}`, `hearth_smoke_token = ${token}`, 'max_users = 0', - ]); + ...(Number.isSafeInteger(options.seed) && options.seed >= 0 ? [`fixed_map_seed = ${options.seed}`] : []), + ...await fixtureSettings(options.fixture === false ? undefined : process.env.HEARTH_FIXTURE, 'staging'), + ], 'staging', gameId); await run(executable, [ - '--server', '--world', world, '--gameid', 'last_hearth', '--config', config, + '--server', '--world', world, '--gameid', gameId, '--config', config, '--port', String(randomInt(49152, 65536)), '--logfile', path.join(temporary, 'engine.log'), - ], engine, TEST_TIMEOUT, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`); + ], runtime, TEST_TIMEOUT, [packageMarker, `LAST_HEARTH_${mode.toUpperCase()}_PASS ${token}`], games); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -191,13 +223,14 @@ async function engineTest(mode, runtime, engine, executable, location) { async function standaloneTests(runtime) { const candidates = process.env.HEARTH_LUA ? [process.env.HEARTH_LUA] - : ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe'].map(name => path.join(runtime, name)); + : (process.platform === 'win32' ? ['luajit.exe', 'lua.exe', 'bin/luajit.exe', 'bin/lua.exe'] + : ['luajit', 'lua', 'bin/luajit', 'bin/lua']).map(name => path.join(runtime, name)); let lua; for (const candidate of candidates) { - if (!path.isAbsolute(candidate) || path.extname(candidate).toLowerCase() !== '.exe') { - throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable (.exe).'); + if (!path.isAbsolute(candidate)) { + throw new Error('HEARTH_LUA must be an absolute path to a local Lua executable.'); } - if (await exists(candidate)) { lua = candidate; break; } + if (await exists(candidate)) { lua = await safePath(candidate); break; } } if (!lua) throw new Error('Standalone Lua unavailable: engine checks passed, but unit tests were NOT run. Install Lua 5.1+/LuaJIT locally and set HEARTH_LUA to its absolute executable path.'); const specs = (await fs.readdir(path.join(source, 'tests'))).filter(name => name.endsWith('_spec.lua')).sort(); @@ -210,48 +243,118 @@ async function standaloneTests(runtime) { } } -async function main() { - if (!['check', 'smoke', 'play', 'test'].includes(command) || process.argv.length !== 3) { - throw new Error('Usage: node scripts/run.mjs '); +async function launchCourse(stage, runtime, games, packaged, gameId, executable, headless) { + // No original world, snapshot, restored copy, or inherited location/fixture. + // Every invocation gets a new stage/course, marker and UUID, removed on exit. + const { world, nonce, settings } = await prepareCourse(stage, { environment: headless ? 'staging' : 'local' }); + const extra = [...settings]; + if (headless) { + const probe = await safePath(path.join(source, 'tests/course_engine.lua')); + if (!await exists(probe)) throw new Error('test:course requires the main-owned tests/course_engine.lua native probe; no fake player or exported build API is assumed.'); + const mod = path.join(world, 'worldmods/hearth_course_probe'); + await fs.mkdir(mod, { recursive: true }); + await fs.copyFile(probe, path.join(mod, 'init.lua')); + await fs.writeFile(path.join(mod, 'mod.conf'), 'name = hearth_course_probe\ndepends = hearth\n', { flag: 'wx' }); + // Probe contract: native API/enrollment checks, request_shutdown on success, + // and LAST_HEARTH_COURSE_PASS . Generation requires live + // /course consent; course.report is the only exported API, not course.build. + extra.push('load_mod_hearth_course_probe = true', 'hearth_launcher_mode = course', + `hearth_smoke_token = ${nonce}`, 'max_users = 0'); } - if (process.platform !== 'win32' || !process.env.LOCALAPPDATA || !path.isAbsolute(process.env.LOCALAPPDATA)) { - throw new Error('This launcher requires Windows and an absolute LOCALAPPDATA path.'); - } - const location = coordinates(); - const runtime = path.join(process.env.LOCALAPPDATA, 'last-hearth-runtime'); - const engine = path.join(runtime, 'luanti-5.18.0-win64'); - const executable = path.join(engine, 'bin', 'luanti.exe'); - if (!await exists(executable)) throw new Error(`Official Luanti 5.18.0 runtime not found: ${executable}`); - const lockPath = path.join(runtime, 'launcher.lock'); - let lock; - try { lock = await fs.open(lockPath, 'wx', 0o600); } - catch (error) { - if (error.code === 'EEXIST') throw new Error('Another launcher may be active. If it crashed, close its engine before removing LOCALAPPDATA/last-hearth-runtime/launcher.lock.'); - throw error; - } - try { - await lock.writeFile(`${process.pid}\n`); - await stageGame(engine); - if (command === 'play') { - const world = path.join(runtime, 'worlds', 'last_hearth'); - const config = path.join(runtime, 'last-hearth-play.conf'); - await writeWorld(world, config, location); - await run(executable, [ - '--go', '--world', world, '--gameid', 'last_hearth', '--config', config, - '--port', String(randomInt(49152, 65536)), '--logfile', path.join(runtime, 'last-hearth-play.log'), - ], engine, PLAY_TIMEOUT); - } else if (command === 'test') { - await engineTest('check', runtime, engine, executable, location); - await engineTest('smoke', runtime, engine, executable, location); - if (process.env.HEARTH_LUA) await standaloneTests(runtime); - console.log('LAST_HEARTH_TEST_PASS: real-engine integration and isolated model/content suites completed.'); - } else { - await engineTest(command, runtime, engine, executable, location); - } - } finally { - await lock.close(); - await fs.unlink(lockPath); + const config = path.join(stage, 'course.conf'); + await writeWorld(world, config, [], extra, headless ? 'staging' : 'local', gameId); + const packageMarker = await pinPackage(packaged, gameId, undefined, true); + if (!headless) { + console.log('Disposable summer course: use /course after arrival to generate and receive the kit. /course retry resets kit/needs/timer; /course report shows server step intervals and Lua callback wall duration, NOT FPS or CPU profiling. No automatic opponents. Original play world is not opened.'); } + await run(executable, [ + headless ? '--server' : '--go', '--world', world, '--gameid', gameId, '--config', config, + '--port', String(randomInt(49152, 65536)), '--logfile', path.join(stage, 'course.log'), + ], runtime, headless ? TEST_TIMEOUT : PLAY_TIMEOUT, + headless ? [packageMarker, `LAST_HEARTH_COURSE_PASS ${nonce}`] : packageMarker, games); } -main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; }); +async function main() { + if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ is required.'); + const args = process.argv.slice(3); + if (command === 'package' && args.length <= 1) { + const destination = args.length ? absolute(args[0]) : path.join(source, 'dist/last_hearth'); + if (!args.length) await ensureDirectory(path.dirname(destination)); + await packageGame(destination); + console.log('LAST_HEARTH_PACKAGE_PASS: verified game directory and SHA256 manifest.'); + return; + } + if (command === 'verify' && args.length === 1) { + await verifyPackage(absolute(args[0])); + console.log('LAST_HEARTH_PACKAGE_VERIFY_PASS'); + return; + } + if (['backup', 'restore'].includes(command) && args.length === 3 && args[2] === '--offline') { + const options = { offline: true, runtime: runtimeHome() }; + await (command === 'backup' ? backupWorld : restoreWorld)(absolute(args[0]), absolute(args[1]), options); + console.log(`LAST_HEARTH_${command.toUpperCase()}_PASS`); + return; + } + if (!['check', 'smoke', 'play', 'test', 'test:seeds', 'course', 'test:course'].includes(command) || args.length) { + throw new Error('Usage: node scripts/run.mjs |backup --offline|restore --offline>. OFFLINE: stop ALL external engines; lock checks cannot prove remote idleness. Backups contain private data: use a protected/encrypted volume.'); + } + const course = ['course', 'test:course'].includes(command); + // Reject malformed graphics input before engine execution, staging or backups. + graphicsSettings(); + const location = course || command === 'test:seeds' ? [] : coordinates(); + const runtime = runtimeHome(); + const executable = engineExecutable(process.env, process.platform, runtime); + // Fail before staging or touching worlds when the engine is absent/wrong. + await validateEngine(executable); + await withLauncherLock(runtime, async lease => { + // Never install into or overwrite the user's engine/game directories. + const stage = await fs.mkdtemp(path.join(runtime, 'session-')); + try { + const games = path.join(stage, 'games'); + await fs.mkdir(games); + // Installed/share games precede LUANTI_GAME_PATH; never reuse their ID. + const gameId = `last_hearth_session_${randomUUID().replaceAll('-', '')}`; + const packaged = path.join(games, gameId); + await packageGame(packaged); + if (course) { + await launchCourse(stage, runtime, games, packaged, gameId, executable, command === 'test:course'); + } else if (command === 'play') { + const fixture = await fixtureSettings(process.env.HEARTH_FIXTURE, 'local'); + const world = path.join(runtime, 'worlds', 'last_hearth'); + await embeddedOwner(world, runtime); // Refuse unmanaged content before touching the world. + if (await snapshotBeforePlay(runtime, lease)) { + console.log('LAST_HEARTH_PRELAUNCH_BACKUP_PASS: full snapshot retained outside the game package.'); + } + const config = path.join(stage, 'play.conf'); + await writeWorld(world, config, location, fixture, 'local'); + await playGame(world, runtime, packaged, packageMarker => run(executable, [ + '--go', '--world', world, '--gameid', gameId, '--config', config, + '--port', String(randomInt(49152, 65536)), '--logfile', path.join(stage, 'play.log'), + ], runtime, PLAY_TIMEOUT, packageMarker, games)); + } else if (command === 'test:seeds') { + const seeds = [731104, 19027, 2026, 987654321, 271828, 314159, 42, 8675309]; + const packageMarker = await pinPackage(packaged, gameId); + await engineTest('check', runtime, games, executable, location, gameId, packageMarker); + for (const seed of seeds) { + await engineTest('seed', runtime, games, executable, location, gameId, packageMarker, + {seed, fixture: false}); + } + console.log(`LAST_HEARTH_SEED_TEST_PASS: ${seeds.length} fixed seeds, required starting resources and safe arrival surfaces.`); + } else { + const packageMarker = await pinPackage(packaged, gameId); + if (command === 'test') { + await engineTest('check', runtime, games, executable, location, gameId, packageMarker); + await engineTest('smoke', runtime, games, executable, location, gameId, packageMarker); + if (process.env.HEARTH_LUA) await standaloneTests(runtime); + console.log('LAST_HEARTH_TEST_PASS: real-engine integration and isolated model/content suites completed.'); + } else { + await engineTest(command, runtime, games, executable, location, gameId, packageMarker); + } + } + } finally { await fs.rm(stage, { recursive: true, force: true }); } + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + main().catch(error => { console.error(`Last Hearth: ${error.message}`); process.exitCode = 1; }); +} diff --git a/scripts/terrain.mjs b/scripts/terrain.mjs new file mode 100755 index 0000000..920890b --- /dev/null +++ b/scripts/terrain.mjs @@ -0,0 +1,29 @@ +// Original v7 preset: creation-time inputs, never a saved-world migration. +// Keep in sync with game defaults; map_meta.txt belongs exclusively to Luanti. +const NEW_WORLD = Object.freeze([ + 'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 1', + 'mapgen_limit = 4096', + 'mg_flags = caves,nodungeons,light,decorations,biomes,ores', + 'mgv7_spflags = mountains,ridges,nofloatlands,caverns', + 'mgv7_mount_zero_level = 0', + 'mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0', + 'mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0', + 'mgv7_np_terrain_persist = 0.6, 0.1, (1200, 1200, 1200), 539, 3, 0.6, 2.0', + 'mgv7_np_height_select = -8, 16, (320, 320, 320), 4213, 6, 0.7, 2.0', + 'mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 0.7, 2.0', + 'mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0', + 'mgv7_np_mountain = -0.6, 1, (180, 280, 180), 5333, 5, 0.63, 2.0', + 'mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 2.0', + 'mgv7_np_ridge = 0, 1, (100, 100, 100), 6467, 4, 0.75, 2.0', + 'mg_biome_np_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0', + 'mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0', +]); + +export function terrainSettings(newWorld) { + if (typeof newWorld !== 'boolean') throw new TypeError('newWorld must be a boolean.'); + return newWorld ? [...NEW_WORLD] : []; +} + +export function isTerrainSetting(line) { + return /^\s*(?:mg\w*|fixed_map_seed|water_level|mapgen_limit)\s*=/.test(line); +} diff --git a/scripts/wiki-species.mjs b/scripts/wiki-species.mjs new file mode 100755 index 0000000..90322dd --- /dev/null +++ b/scripts/wiki-species.mjs @@ -0,0 +1,355 @@ +// Explicit maintenance only: importing this module never uses the network or writes. +import * as fs from 'node:fs/promises'; +import { constants } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { safePath } from './files.mjs'; + +const ROOT = fileURLToPath(new URL('../', import.meta.url)); +const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua'); +const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua'); +export const LIMITS = Object.freeze({ species: 80, batch: 10, concurrency: 2, + responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024, + characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 }); +export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)'; +const ORIGIN = 'https://en.wikipedia.org'; +const LICENSE = 'CC BY-SA 4.0'; +const LICENSE_URL = 'https://creativecommons.org/licenses/by-sa/4.0/'; +const NOTE_LICENSE = 'CC0 1.0'; +const NOTE_LICENSE_URL = 'https://creativecommons.org/publicdomain/zero/1.0/'; +const HEADER = '-- Generated by scripts/wiki-species.mjs; Wikipedia contributors, CC BY-SA 4.0.\n' + + '-- Plaintext introduction excerpts, not full articles. See each revision/history and docs/species-references.md.\n'; +const FIELDS = Object.freeze(['latin', 'title', 'url', 'pageid', 'revision', 'revision_url', + 'history_url', 'revision_timestamp', 'retrieved', 'text', 'kind', 'excerpt', 'truncated', + 'attribution', 'license', 'license_url', 'changes']); + +function requireValue(ok, message) { if (!ok) throw new Error(message); } +function latinName(value) { + requireValue(typeof value === 'string' && value.length <= 100 && + /^[A-Z][a-z]+ [a-z]+(?:-[a-z]+)*$/.test(value), 'Invalid Latin name (not a URL or query).'); + return value; +} +function validateCatalogue(records) { + requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..80.'); + const ids = new Set(), names = new Set(); + for (const r of records) { + requireValue(r && typeof r.id === 'string' && /^[a-z][a-z_]{0,39}$/.test(r.id) && + !['constructor', 'prototype', '__proto__'].includes(r.id) && !ids.has(r.id), 'Invalid or duplicate species ID.'); + latinName(r.latin); + requireValue(!names.has(r.latin), 'Duplicate Latin name.'); + ids.add(r.id); names.add(r.latin); + } + return records; +} + +// Parse only literal leading add(id,common,latin,...) rows. Never evaluate Lua. +export function parseCatalogue(source) { + requireValue(typeof source === 'string' && Buffer.byteLength(source) <= LIMITS.catalogueBytes, 'Catalogue too large.'); + const rows = source.split(/\r?\n/).filter(line => /^\s*add\(/.test(line)); + const records = rows.map(line => { + const m = /^\s*add\("([a-z_]+)","[^"\\\r\n]{1,100}","([^"\\\r\n]+)",/.exec(line); + requireValue(m, 'Unsupported catalogue literal.'); + return { id: m[1], latin: m[2] }; + }); + requireValue(records.length === LIMITS.species, 'Expected exactly 80 botany catalogue rows.'); + return validateCatalogue(records); +} + +export function apiURL(names) { + requireValue(Array.isArray(names) && names.length > 0 && names.length <= LIMITS.batch, 'Invalid batch size.'); + names.forEach(latinName); + const u = new URL('/w/api.php', ORIGIN); + u.search = new URLSearchParams({ action: 'query', format: 'json', formatversion: '2', + prop: 'extracts|info|revisions|pageprops', exintro: '1', explaintext: '1', + inprop: 'url', rvprop: 'ids|timestamp', redirects: '1', titles: names.join('|'), maxlag: '5' }); + return u; +} + +export function allowedURL(value) { + const u = new URL(value); + requireValue(u.origin === ORIGIN && u.protocol === 'https:' && !u.username && !u.password && + !u.hash && !u.port, 'Only credential-free HTTPS en.wikipedia.org URLs are allowed.'); + return u; +} +function allowedRequest(value, expected) { + const u = allowedURL(value); + requireValue(u.pathname === '/w/api.php' && u.search === expected.search, 'Unexpected API redirect or query.'); + return u; +} +function abortable(promise, signal) { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); +} +async function boundedJSON(response, signal) { + requireValue(/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') || ''), 'Expected JSON response.'); + const length = response.headers.get('content-length'); + requireValue(length === null || (/^\d+$/.test(length) && Number(length) <= LIMITS.responseBytes), 'Response exceeds byte limit.'); + requireValue(response.body && typeof response.body.getReader === 'function', 'Missing response stream.'); + const reader = response.body.getReader(), chunks = []; + let bytes = 0, done = false; + try { + while (true) { + const part = await abortable(reader.read(), signal); + if (part.done) { done = true; break; } + bytes += part.value.byteLength; + requireValue(bytes <= LIMITS.responseBytes, 'Response exceeds byte limit.'); + chunks.push(part.value); + } + return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))); + } finally { + if (!done) void reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} +export async function fetchBatch(names, { fetchImpl = globalThis.fetch, signal: parent } = {}) { + const expected = apiURL(names); + const signal = AbortSignal.any([AbortSignal.timeout(LIMITS.requestMs), ...(parent ? [parent] : [])]); + let u = expected; + for (let hop = 0; hop <= LIMITS.redirects; hop++) { + allowedRequest(u, expected); + signal.throwIfAborted(); + const pending = Promise.resolve(fetchImpl(u.href, { redirect: 'manual', credentials: 'omit', + referrerPolicy: 'no-referrer', signal, headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' } })).then(response => { + if (signal.aborted) { + void response.body?.cancel().catch(() => {}); + signal.throwIfAborted(); + } + return response; + }); + const response = await abortable(pending, signal); + if (response.url) allowedRequest(response.url, expected); + if ([301, 302, 303, 307, 308].includes(response.status)) { + void response.body?.cancel().catch(() => {}); + requireValue(hop < LIMITS.redirects && response.headers.get('location'), 'Too many or invalid redirects.'); + u = allowedRequest(new URL(response.headers.get('location'), u).href, expected); + continue; + } + if (response.status !== 200) { + void response.body?.cancel().catch(() => {}); + throw new Error(`Wikipedia HTTP ${response.status}; no cache written.`); + } + return boundedJSON(response, signal); + } +} + +function positiveInteger(n, label) { + requireValue(Number.isSafeInteger(n) && n > 0, `Invalid ${label}.`); + return n; +} +function timestamp(s) { + requireValue(typeof s === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(s) && + Number.isFinite(Date.parse(s)) && new Date(s).toISOString() === s.replace(/Z$/, s.includes('.') ? 'Z' : '.000Z'), 'Invalid timestamp.'); + return s; +} +export function excerpt(text) { + requireValue(typeof text === 'string' && text.trim().length > 30 && + !/[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]/u.test(text) && + !/<\/?[A-Za-z][^>]*>|\[\[|\]\]/u.test(text), 'Missing or non-plaintext introduction.'); + text = text.trim(); + const points = Array.from(text); + if (points.length <= LIMITS.characters) return { text, truncated: false }; + const head = points.slice(0, LIMITS.characters).join(''); + // Keep the copied prefix verbatim; prefer paragraph, sentence, then word boundary. + const paragraph = head.lastIndexOf('\n'); + const sentences = [...head.matchAll(/[.!?](?=\s)/g)]; + let end = paragraph >= head.length / 2 ? paragraph : (sentences.at(-1)?.index ?? -1) + 1; + if (end < head.length / 2) end = head.lastIndexOf(' '); + requireValue(end > 0, 'No safe excerpt boundary.'); + return { text: head.slice(0, end).trimEnd(), truncated: true }; +} +function articleTitle(s) { + requireValue(typeof s === 'string' && s.length > 0 && s.length <= 200 && + !/[\x00-\x1f\x7f<>\[\]{}|#]/.test(s) && !s.includes(':'), 'Invalid article title.'); + return s; +} +function wikiTitle(s) { return encodeURIComponent(s.replaceAll(' ', '_')); } +function verifiedArticleURL(value, title) { + const u = allowedURL(value); + requireValue(u.pathname.startsWith('/wiki/') && !u.search && + decodeURIComponent(u.pathname.slice(6)).replaceAll('_', ' ') === title, 'Canonical title/URL mismatch.'); + return u.href; +} +function missingEntry(r, title, url, retrieved) { + return { id: r.id, latin: r.latin, title, url, + history_url: `${ORIGIN}/w/index.php?title=${wikiTitle(title)}&action=history`, retrieved, + text: `No English Wikipedia species article was available for ${r.latin} when this bundle was retrieved. This is an original availability note, not a Wikipedia excerpt. No broader taxon or similarly named species has been substituted. The game catalogue is not evidence for real-world ecological, food or medicinal effects.`, + kind: 'original_notes', excerpt: false, truncated: false, + attribution: 'The Last Hearth contributors (original availability note)', + license: NOTE_LICENSE, license_url: NOTE_LICENSE_URL, + changes: 'Original availability note dedicated to CC0; no Wikipedia article text copied.' }; +} + +export function entriesFromResponse(records, data, retrieved) { + validateCatalogue(records); timestamp(retrieved); + requireValue(data && !data.error && !data.warnings && data.batchcomplete === true && + Array.isArray(data.query?.pages) && data.query.pages.length <= records.length, 'Incomplete or invalid Wikipedia response.'); + const q = data.query, aliases = new Map(), pages = new Map(); + for (const rule of [...(q.normalized || []), ...(q.redirects || [])]) { + articleTitle(rule.from); articleTitle(rule.to); + requireValue(!aliases.has(rule.from) && !rule.tofragment, 'Ambiguous or fragment redirect.'); + aliases.set(rule.from, rule.to); + } + requireValue(aliases.size <= records.length * 3, 'Too many title aliases.'); + for (const page of q.pages) { + articleTitle(page.title); + requireValue(!pages.has(page.title), 'Duplicate article title.'); + pages.set(page.title, page); + } + return records.map(r => { + let title = r.latin; + const seen = new Set(); + while (aliases.has(title)) { + requireValue(!seen.has(title) && seen.size < 5, 'Cyclic or excessive title redirects.'); + seen.add(title); title = aliases.get(title); + } + const p = pages.get(title); + requireValue(p && p.ns === 0 && !('invalid' in p) && + !Object.hasOwn(p.pageprops || {}, 'disambiguation') && p.contentmodel === 'wikitext' && + p.pagelanguage === 'en', `Unverified species article: ${r.latin}.`); + const url = verifiedArticleURL(p.canonicalurl, title); + if (p.missing === true) { + requireValue(!p.pageid && !p.revisions && title === r.latin, 'Ambiguous missing article.'); + return missingEntry(r, title, url, retrieved); + } + requireValue(!('missing' in p), 'Unexpected missing marker.'); + const pageid = positiveInteger(p.pageid, 'page ID'); + requireValue(Array.isArray(p.revisions) && p.revisions.length === 1, 'Expected one source revision.'); + const rev = p.revisions[0], revision = positiveInteger(rev.revid, 'revision'); + requireValue(p.lastrevid === revision, 'Revision mismatch; retry maintenance later.'); + timestamp(rev.timestamp); + requireValue(Date.parse(rev.timestamp) <= Date.parse(retrieved), 'Source revision is in the future.'); + const copied = excerpt(p.extract); + return { id: r.id, latin: r.latin, title, url, pageid, revision, + revision_url: `${ORIGIN}/w/index.php?oldid=${revision}`, + history_url: `${ORIGIN}/w/index.php?title=${wikiTitle(title)}&action=history`, + revision_timestamp: rev.timestamp, retrieved, ...copied, + kind: 'wikipedia_intro', excerpt: true, attribution: `Wikipedia contributors, "${title}"`, + license: LICENSE, license_url: LICENSE_URL, + changes: copied.truncated ? 'Plaintext introduction; shortened at a text boundary. No translation.' + : 'Plaintext introduction only; article body omitted. No translation.' }; + }); +} + +export async function fetchEntries(records, { fetchImpl = globalThis.fetch, now = () => new Date() } = {}) { + validateCatalogue(records); + const batches = []; + for (let i = 0; i < records.length; i += LIMITS.batch) batches.push(records.slice(i, i + LIMITS.batch)); + const controller = new AbortController(); + const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(LIMITS.totalMs)]); + const results = new Array(batches.length); + let cursor = 0; + async function worker() { + while (!signal.aborted) { + const i = cursor++; + if (i >= batches.length) return; + const batch = batches[i]; + const data = await fetchBatch(batch.map(r => r.latin), { fetchImpl, signal }); + results[i] = entriesFromResponse(batch, data, now().toISOString()); + } + signal.throwIfAborted(); + } + try { await Promise.all(Array.from({ length: Math.min(LIMITS.concurrency, batches.length) }, worker)); } + catch (error) { controller.abort(error); throw error; } + return results.flat(); +} + +// Every UTF-8 byte is a fixed-width Lua decimal escape (Lua 5.1+). Article text +// cannot close a quote, inject a comment/long-string delimiter or execute code. +export function luaString(value) { + requireValue(typeof value === 'string', 'Expected string.'); + return '"' + [...Buffer.from(value, 'utf8')].map(b => '\\' + String(b).padStart(3, '0')).join('') + '"'; +} +export function serializeBundle(entries) { + validateCatalogue(entries); + return HEADER + 'return {\n' + entries.map(e => `\t[${luaString(e.id)}] = {\n` + + FIELDS.map(key => { + const value = e[key]; + const absent = e.kind === 'original_notes' && ['pageid', 'revision', 'revision_url', 'revision_timestamp'].includes(key) && value === undefined; + requireValue(absent || (['pageid', 'revision'].includes(key) ? Number.isSafeInteger(value) && value > 0 + : ['excerpt', 'truncated'].includes(key) ? typeof value === 'boolean' : typeof value === 'string'), `Invalid bundle field ${key}.`); + return `\t\t${key} = ${value === undefined ? 'nil' : typeof value === 'string' ? luaString(value) : value},\n`; + }).join('') + '\t},\n').join('') + '}\n'; +} + +// This narrow parser reads ONLY our generated data grammar, not general Lua. +export function parseBundle(source) { + requireValue(typeof source === 'string' && Buffer.byteLength(source) <= 16 * 1024 * 1024 && + source.startsWith(HEADER + 'return {\n') && source.endsWith('}\n'), 'Invalid generated bundle.'); + const decode = s => new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from( + [...s.matchAll(/\\(\d{3})/g)].map(m => { const n = Number(m[1]); requireValue(n <= 255, 'Invalid Lua byte.'); return n; }))); + const lines = source.slice((HEADER + 'return {\n').length, -2).split('\n'); + lines.pop(); + const entries = []; + let i = 0; + while (i < lines.length) { + const start = /^\t\["((?:\\\d{3})+)"\] = \{$/.exec(lines[i++]); + requireValue(start, 'Invalid Lua record.'); + const entry = { id: decode(start[1]) }; + for (const key of FIELDS) { + const m = new RegExp(`^\\t\\t${key} = ("(?:\\\\\\d{3})*"|true|false|nil|[1-9]\\d*),$`).exec(lines[i++]); + requireValue(m, 'Invalid Lua field.'); + if (m[1] !== 'nil') entry[key] = m[1].startsWith('"') ? decode(m[1]) : m[1] === 'true' ? true : m[1] === 'false' ? false : Number(m[1]); + } + requireValue(lines[i++] === '\t},', 'Invalid Lua record end.'); + entries.push(entry); + } + validateCatalogue(entries); + requireValue(serializeBundle(entries) === source, 'Noncanonical generated bundle.'); + return entries; +} + +async function readCatalogue() { + await safePath(CATALOGUE); + const stat = await fs.stat(CATALOGUE); + requireValue(stat.isFile() && stat.size <= LIMITS.catalogueBytes, 'Invalid catalogue file.'); + return parseCatalogue(await fs.readFile(CATALOGUE, 'utf8')); +} +export async function main(args = process.argv.slice(2)) { + requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]), + 'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)'); + if (args[0] === '--help') { + console.log('--refresh: query all 80 species; replace the fixed Lua cache with verified intros or explicit missing-page notes after full validation.\n--check: verify fixed offline cache/catalogue without network.'); + return; + } + const records = await readCatalogue(); + if (args[0] === '--check') { + await safePath(OUTPUT); + requireValue((await fs.stat(OUTPUT)).size <= 16 * 1024 * 1024, 'Bundle too large.'); + const entries = parseBundle(await fs.readFile(OUTPUT, 'utf8')); + requireValue(JSON.stringify(entries.map(({ id, latin }) => ({ id, latin }))) === JSON.stringify(records), 'Catalogue/cache mismatch.'); + for (const e of entries) { + if (e.kind === 'original_notes') { + timestamp(e.retrieved); verifiedArticleURL(e.url, e.latin); + requireValue(serializeBundle([e]) === serializeBundle([missingEntry(e, e.latin, e.url, e.retrieved)]), 'Invalid original availability note.'); + continue; + } + entriesFromResponse([{ id: e.id, latin: e.latin }], { batchcomplete: true, query: { + redirects: e.title === e.latin ? [] : [{ from: e.latin, to: e.title }], + pages: [{ ns: 0, pageid: e.pageid, title: e.title, canonicalurl: e.url, contentmodel: 'wikitext', + pagelanguage: 'en', lastrevid: e.revision, revisions: [{ revid: e.revision, timestamp: e.revision_timestamp }], extract: e.text }] } }, e.retrieved); + requireValue(e.kind === 'wikipedia_intro' && e.excerpt === true && e.license === LICENSE && e.license_url === LICENSE_URL && + e.revision_url === `${ORIGIN}/w/index.php?oldid=${e.revision}` && + e.history_url === `${ORIGIN}/w/index.php?title=${wikiTitle(e.title)}&action=history` && + e.attribution === `Wikipedia contributors, "${e.title}"` && Array.from(e.text).length <= LIMITS.characters, 'Invalid cached attribution/excerpt.'); + } + console.log(`Verified ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes; network requests: 0.`); + return; + } + const entries = await fetchEntries(records); + const source = serializeBundle(entries); + requireValue(parseBundle(source).length === LIMITS.species, 'Incomplete generated bundle.'); + // Fixed output only; trusted local parents required, consistent with files.mjs. + // All network/validation work finishes BEFORE opening/truncating the old cache. + await safePath(OUTPUT); + const handle = await fs.open(OUTPUT, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), 0o644); + try { await handle.writeFile(source, 'utf8'); } finally { await handle.close(); } + console.log(`Cached ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes (${entries.filter(e => e.truncated).length} shortened); ${Math.ceil(records.length / LIMITS.batch)} API batches, concurrency <=2.`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/world-art.mjs b/scripts/world-art.mjs new file mode 100755 index 0000000..b31fa0e --- /dev/null +++ b/scripts/world-art.mjs @@ -0,0 +1,1525 @@ +// Original pixel drawings: moonlit botanical field journal. No random/hash noise, +// external art, fonts, network access, or file-system writes in this module. +export const SIZE = 32; +export const PALETTE = Object.freeze(Object.fromEntries(Object.entries({ + ink: '#1e1e2e', deep: '#181825', outline: '#313244', slate: '#45475a', + stone: '#747782', stoneLight: '#9b9b9c', moon: '#cdd6f4', mauve: '#cba6f7', + teal: '#94e2d5', gold: '#f9e2af', peach: '#fab387', rose: '#b67f96', + earthDark: '#443a38', earth: '#665044', earthLight: '#876957', clay: '#ad7e68', + barkDark: '#433b36', bark: '#685346', barkLight: '#92765a', wood: '#ad916b', + woodLight: '#c6ad83', strawDark: '#766848', straw: '#a39061', strawLight: '#c6b581', + leafDark: '#384c43', leaf: '#556e55', sage: '#7d9570', leafLight: '#a4b28b', + springDark: '#526b50', springLeaf: '#8fa67c', springLight: '#bac59a', bud: '#c19caa', + autumnDark: '#64454c', copper: '#a26c49', ochre: '#b59458', wine: '#845365', + waterDark: '#334952', water: '#425e66', waterLight: '#638187', glass: '#78959a', + berry: '#92627f', berryLight: '#c393aa', rust: '#9a7660', iron: '#909f9f', + fur: '#92816d', cream: '#d1c5a5', fox: '#b3815d', deer: '#93755a', + mineral: '#777980', mineralShade: '#696c73', mineralGlow: '#898b90', feldspar: '#a79792', + basalt: '#4d5358', basaltShade: '#42494e', basaltGlow: '#60686b', + limestone: '#a19c88', limeShade: '#8e8a79', limeGlow: '#b4af99', + slateBlue: '#697681', slateShade: '#58636e', slateGlow: '#82909a', + sand: '#b1a181', sandShade: '#a39476', sandGlow: '#c0b292', + loam: '#655347', loamShade: '#57473e', loamGlow: '#766052', humus: '#49413a', + turf: '#637653', turfShade: '#536649', turfGlow: '#778764', + birch: '#bbb8a6', birchShade: '#9e9d8e', birchGlow: '#d0ccba', + pine: '#405c51', pineShade: '#344b44', pineGlow: '#648074', + willow: '#81907a', willowShade: '#5e7567', willowGlow: '#a2ad8b', + snow: '#bfcbd1', snowShade: '#acbbc4', snowGlow: '#d5dfe0', + copperMetal: '#ba8560', copperShade: '#8c634d', copperGlow: '#d5a77d', verdigris: '#638d7b', +}).map(([name, hex]) => [name, Object.freeze([1,3,5].map(i => parseInt(hex.slice(i,i+2),16)))]))); + +const rgba = (color, alpha = 255) => [...(PALETTE[color] || color), alpha]; +class Drawing { + constructor(background) { + this.width = SIZE; this.height = SIZE; this.data = new Uint8Array(SIZE*SIZE*4); + if (background) this.rect(0,0,32,32,background); + } + dot(x,y,color,alpha=255) { + x = Math.round(x); y = Math.round(y); + if (x>=0 && y>=0 && x<32 && y<32) this.data.set(rgba(color,alpha),(y*32+x)*4); + return this; + } + rect(x,y,w,h,color,alpha=255) { + for (let j=y;jy+.5)!==(d>y+.5) && x+.5<(c-a)*(y+.5-b)/(d-b)+a) hit=!hit; + } + if(hit) this.dot(x,y,color); + } + return this; + } + oval(cx,cy,rx,ry,color) { + for(let y=Math.floor(cy-ry);y<=cy+ry;y++) for(let x=Math.floor(cx-rx);x<=cx+rx;x++) + if(((x-cx)/rx)**2+((y-cy)/ry)**2<=1) this.dot(x,y,color); + return this; + } +} + +// Authored clustered marks wrap as whole shapes, not individual random pixels. +function pebbles(d, dark='earthDark', light='earthLight') { + for(const [x,y,w] of [[3,5,4],[19,3,3],[11,14,5],[25,20,4],[2,27,3],[19,30,4]]) { + d.rect(x,y,w,2,dark).rect(x+1,y,w-1,1,light); + } +} +function earth(d) { + d.rect(0,0,32,32,'loam'); + // Discontinuous lenses of compacted aggregates, never full-width cartoon bands. + for(const [x,y,w,h] of [[2,2,6,3],[15,0,5,2],[24,5,7,3],[8,9,8,2],[19,12,5,3], + [0,17,5,2],[10,20,7,3],[23,23,8,2],[3,28,8,3],[18,30,4,2]]) + d.oval(x,y,w/2,h,'loamShade'); + for(const [i,[x,y]] of GRAINS.entries()) { + d.rect(x,y,i%3===0?3:2,i%4===0?2:1,i%3===0?'loamGlow':'loamShade'); + if(i%5===0) d.dot(x+1,y-1,'earthLight'); + } + d.line(6,14,9,16,'humus').line(23,28,25,30,'humus'); +} +function grass(d,side=false) { + if(side) { + earth(d); + for(let x=0;x<32;x++) { + const depth=Math.round(5+1.6*Math.sin(x*.43)+Math.sin(x*.91)); + d.rect(x,0,1,depth,'turfShade'); + if(x%3!==0) d.line(x,depth-1,x-1,depth+2+(x%4),'humus'); + if(x%5===1) d.line(x,depth+2,x+2,depth+6,'loamGlow'); + } + for(const [i,[x,y]] of GRAINS.entries()) if(y<8) + d.rect(x,y/2,2,1,i%2?'turf':'turfGlow'); + return; + } + d.rect(0,0,32,32,'turf'); + for(const [i,[x,y]] of GRAINS.entries()) { + d.poly([[x,y],[x+3,y-1],[x+4,y+1],[x+1,y+2]],i%3?'turfShade':'leaf'); + d.rect(x+1,y-1,2,1,'turfGlow'); + const u=(x+13)%32,v=(y+7)%32; + d.line(u,v,u+(i%2?1:-1),v+2,i%4?'turfShade':'sage'); + } +} +const GRAINS = [[1,1],[9,3],[18,2],[27,1],[4,6],[14,7],[23,5],[30,8],[1,11],[8,13], + [17,11],[26,12],[4,17],[12,16],[22,18],[29,16],[0,22],[8,24],[17,22],[25,24], + [4,28],[13,30],[21,28],[29,30],[6,2],[20,8],[15,26],[30,21],[10,19],[2,31], + [24,14],[11,10],[18,17],[5,21],[27,27],[15,5]]; + +function mineral(d,name='stone') { + const colors={stone:['mineral','mineralShade','mineralGlow'],granite:['mineral','mineralShade','feldspar'], + basalt:['basalt','basaltShade','basaltGlow'],limestone:['limestone','limeShade','limeGlow'], + slate:['slateBlue','slateShade','slateGlow'],sandstone:['sand','sandShade','sandGlow']}[name]; + const [base,dark,light]=colors; d.rect(0,0,32,32,base); + if(name==='slate' || name==='sandstone') { + for(const [i,[x,y]] of GRAINS.entries()) if(i%2===0) { + const length=name==='slate'?7:5, slope=name==='slate'?-2:1; + d.line(x,y,x+length,y+slope,dark).line(x+1,y-1,x+length-2,y+slope-1,light); + } + if(name==='slate') d.line(3,29,12,24,dark).line(12,24,16,25,dark); + if(name==='sandstone') GRAINS.forEach(([x,y],i)=> { + d.rect((x+11)%31,y,2,1,i%3?dark:light); + if(i%4===0) d.dot((x+14)%31,y+1,light); + }); + } else { + // Unjointed microfacets sit inside continuous parent rock, not grout cells. + GRAINS.forEach(([x,y],i)=> { + const w=name==='granite'?2:3+(i%3), h=name==='basalt'?2:3; + d.poly([[x,y],[x+w-1,y-1],[x+w,y+1],[x+1,y+h]],i%3?dark:light); + if(i%4===0) d.line(x,y,x+1,y-1,light); + }); + if(name==='limestone') { + d.line(2,20,7,18,dark).line(7,18,10,19,dark); + d.line(25,2,24,7,dark).line(24,7,27,10,dark); + d.oval(19,25,3,2,'limeShade').line(17,24,20,24,'limeGlow'); + } + if(name==='granite') for(const [x,y] of [[6,4],[22,10],[12,27],[29,19]]) + d.rect(x,y,2,2,'stoneLight').dot(x+2,y+2,'slate'); + if(name==='basalt') for(const [x,y] of [[8,7],[23,19],[14,28]]) + d.oval(x,y,2,1,'basaltShade').dot(x-1,y-1,'basaltGlow'); + } +} +function masonry(d,foundation=false) { + d.rect(0,0,32,32,'outline'); + for(let row=0;row<3;row++) { + const y=row*11-1, shift=row%2 ? -7 : 0; + for(let x=shift;x<32;x+=16) { + d.poly([[x+2,y+1],[x+13,y+1],[x+15,y+4],[x+14,y+10],[x+1,y+10],[x,y+6]],foundation?'earthLight':'stone'); + d.line(x+3,y+2,x+11,y+2,foundation?'barkLight':'stoneLight'); + d.line(x+2,y+9,x+12,y+9,'slate'); + } + } + if(!foundation) d.line(8,4,10,7,'slate').line(10,7,9,10,'slate'); +} +function planks(d,vertical=false,species='oak') { + const [base,dark,light]={oak:['wood','barkLight','woodLight'],birch:['birch','birchShade','birchGlow'], + pine:['straw','strawDark','strawLight'],willow:['willow','willowShade','willowGlow'], + alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'], + rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'], + spruce:['strawLight','wood','cream']}[species]; + const put=(x,y,c)=>d.dot(vertical?y:x,vertical?x:y,c); + for(let y=0;y<32;y++) for(let x=0;x<32;x++) { + const board=Math.floor(y/8), grain=Math.sin(x*.31+board*2+y*1.6)+.45*Math.sin(x*.73-y); + put(x,y,y%8===0?dark:(grain>1.05?light:base)); + } + for(const [x,y] of [[11,1],[25,17]]) for(let j=0;j<6;j++) put(x,y+j,dark); + for(const [x,y] of [[3,4],[22,20]]) { put(x,y,dark); put(x+1,y,light); } +} +function bark(d,species='oak') { + const [base,dark,light]={oak:['bark','barkDark','barkLight'],birch:['birch','birchShade','birchGlow'], + pine:['barkLight','bark','wood'],willow:['humus','barkDark','loamGlow'], + alder:['loamShade','humus','earthLight'],beech:['mineralShade','slateShade','mineralGlow'], + aspen:['birchShade','willowShade','birchGlow'],rowan:['earthLight','bark','birchShade'], + hazel:['barkLight','loamShade','willow'],spruce:['earth','humus','rust']}[species]; + d.rect(0,0,32,32,base); + if(['beech','aspen','rowan','hazel','spruce'].includes(species)) { + // Smooth beech, diamond-scarred aspen, lenticellate rowan/hazel and scaly spruce. + for(const [i,[x,y]] of GRAINS.entries()) { + if(species==='beech') { + if(i%3===0) d.line(x,y,x+1,y+5,dark).line(x+2,y+1,x+2,y+4,light); + } else if(species==='aspen') { + if(i%3===0) d.poly([[x,y-2],[x+3,y],[x,y+2],[x-2,y]],dark).dot(x,y-1,light); + } else if(species==='spruce') { + d.poly([[x,y-2],[x+3,y-1],[x+2,y+3],[x-1,y+2]],dark).line(x,y-1,x+2,y-1,light); + } else if(i%2===0) d.line(x,y,x+(species==='hazel'?4:2),y,dark).dot(x+1,y-1,light); + } + if(species==='beech') d.oval(20,18,3,5,dark).oval(20,17,2,3,base).line(18,14,21,12,light); + return; + } + if(species==='birch') { + for(const [i,[x,y]] of GRAINS.entries()) if(i%2===0) + d.line(x,y,x+2+i%4,y,i%3?dark:'barkDark').line(x+1,y-1,x+2,y-1,light); + d.poly([[24,0],[27,0],[26,9],[24,12],[25,4]],dark); + d.line(25,2,25,7,'bark'); return; + } + const fissures=species==='pine'?[[2,1],[12,4],[23,-2],[30,3]]: + species==='willow'?[[3,0],[9,2],[16,-3],[22,1],[29,5]]:[[1,0],[8,4],[18,-3],[27,1]]; + for(const [i,[x,phase]] of fissures.entries()) for(let y=0;y<32;y++) { + const u=x+Math.round(Math.sin((y+phase)*.24+i)*1.5+Math.sin(y*.57)*.5); + if((y+phase+32)%13<11) d.dot(u,y,dark).dot(u+1,y,dark); + if((y+i)%9<6) d.dot(u+2,y,light); + if(y===11+i*3) d.line(u,y,u-2,y-4,dark); + } + if(species==='pine') for(const [x,y] of [[6,9],[17,21],[29,4]]) + d.line(x,y,x+4,y-1,'bark').line(x+1,y-2,x+3,y-2,'wood'); + else d.oval(14,19,2,4,dark).line(14,16,14,20,light).dot(15,21,base); +} +function endgrain(d,species='oak') { + const [base,ring,light]={oak:['wood','barkLight','woodLight'],birch:['birch','birchShade','birchGlow'], + pine:['straw','barkLight','strawLight'],willow:['willow','willowShade','willowGlow'], + alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'], + rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'], + spruce:['strawLight','wood','cream']}[species]; + const phase=SPECIES_NAMES.indexOf(species)*.7; + for(let y=0;y<32;y++) for(let x=0;x<32;x++) { + const a=Math.atan2(y-16,x-14), r=Math.hypot((x-14)*1.01,(y-16)*.96); + const warped=r+.6*Math.sin(a*3+phase)+.35*Math.sin(a*5+r*.4); + const band=(warped+32)%3.7; + d.dot(x,y,r>14.3+.5*Math.sin(a*7)?'barkDark':r>13.1?'bark':band<.7?ring:band<1.3?light:base); + } + d.line(14,16,17,22,ring).line(17,22,21,28,ring); + d.line(4,8,7,10,'bark').line(7,10,9,13,ring); +} +function straw(d) { + d.rect(0,0,32,32,'strawDark'); + for(let y=-8;y<32;y+=8) for(let x=-4;x<36;x+=6) { + d.poly([[x,y],[x+5,y],[x+8,y+8],[x+2,y+8]],'straw'); + d.line(x+1,y+1,x+4,y+6,'strawLight').line(x+5,y+3,x+6,y+6,'strawDark'); + } + d.line(0,15,31,15,'bark').line(0,16,31,16,'wood'); +} +function leaf(d,x,y,flip=false,color='sage') { + const s=flip?-1:1; + d.poly([[x,y],[x+s*7,y-4],[x+s*9,y-3],[x+s*7,y],[x+s*2,y+2]],'leafDark'); + d.line(x+s*2,y,x+s*7,y-2,color); +} +function twig(d,x=16,y=29,height=21) { + d.line(x,y,x-1,y-height,'bark',2).line(x,y-9,x-7,y-15,'bark'); + d.line(x,y-14,x+7,y-19,'barkLight'); +} +function seasonalPlant(d,name) { + if(name.startsWith('leaves_')) { + if(name==='leaves_winter') { + // Open, interlacing woody forks: no green fill or snow-painted foliage. + for(const [x,y,s] of [[5,27,1],[19,20,-1],[25,30,1]]) { + d.line(x,y,x+s*3,y-17,'bark').line(x+s*2,y-9,x-s*5,y-15,'barkLight'); + d.line(x+s*3,y-17,x+s*7,y-22,'barkDark'); + d.line(x-s*2,y-12,x-s*3,y-19,'bark'); + } + return; + } + const spring=name==='leaves_spring'; + for(const [x,y] of [[4,7],[18,4],[28,10],[10,19],[24,24],[0,28]]) { + d.oval(x,y,6,4,spring?'springDark':'autumnDark') + .oval(x-1,y-1,5,3,spring?'springLeaf':'copper'); + d.line(x-2,y,x+3,y-2,spring?'springLight':'ochre'); + if(spring) d.rect(x+3,y-4,2,2,'bud').dot(x+3,y-4,'cream'); + else d.line(x-3,y+1,x,y+2,'wine'); + } + return; + } + if(name==='spring_greens' || name==='spring_greens_dormant') { + const dormant=name.endsWith('_dormant'); + d.line(16,30,15,20,dormant?'bark':'springDark'); + for(const [x,y,tipX,tipY] of [[14,26,4,17],[16,23,11,8],[17,25,26,13],[15,28,25,24]]) { + // Broad, uneven basal blades with a central vein, not grass needles. + const ty=dormant?tipY+9:tipY; + d.poly([[x,y],[tipX-3,ty+2],[tipX,ty],[tipX+3,ty+3],[x+2,y]],dormant?'strawDark':'springDark'); + d.line(x,y,tipX,ty+2,dormant?'straw':'springLeaf'); + if(!dormant) d.line(x,y-1,tipX,ty+3,'springLight'); + } + return; + } + if(name==='autumn_mushrooms' || name==='autumn_mushrooms_dormant') { + const dormant=name.endsWith('_dormant'); + if(dormant) { + // Occupied litter/mycelial trace, conspicuously without harvestable caps. + d.line(5,28,24,25,'earthDark',2).line(9,27,16,24,'barkLight'); + d.line(16,26,21,29,'strawDark').line(18,26,26,23,'bark'); + d.line(10,28,7,25,'wood').line(22,25,24,28,'earthLight'); + } else for(const [x,y,r] of [[10,18,6],[23,23,5],[17,12,4]]) { + d.rect(x-1,y,2,28-y,'cream').line(x+1,y+1,x+1,27,'barkLight'); + d.oval(x,y,r,2,'autumnDark'); + d.poly([[x-r,y-1],[x-r+2,y-4],[x,y-5],[x+r-1,y-3],[x+r,y-1]],'copper'); + d.line(x-r+2,y-2,x,y-4,'ochre').line(x-2,y+1,x+2,y+1,'woodLight'); + } + return; + } + if(name==='fern_dormant') { + for(const [x,y,s] of [[5,23,1],[26,22,-1],[10,17,1]]) { + d.line(16,30,x,y,'strawDark').line(x,y,x+s*2,y-4,'barkLight'); + for(let j=0;j<3;j++) d.line(x+s*j,y+j,x+s*(j+3),y+j-2,'straw'); + } + return; + } + for(const [x,top,bend] of (name==='wildflowers_dormant'?[[8,15,-3],[17,12,2],[24,20,3]]: + [[5,20,-2],[11,12,-4],[17,17,2],[23,14,4],[27,23,2]])) { + d.line(x,30,x+bend,top,'strawDark').line(x,27,x-3,22,'barkLight'); + if(name==='wildflowers_dormant') { + d.line(x+bend,top,x+bend-2,top+2,'bark'); + d.dot(x+bend,top,'earthLight'); // Spent seed head, never a bloom. + } else d.line(x,29,x+bend+2,top+3,'straw'); + } +} +function plant(d,name) { + if(SEASONAL_PLANT_NAMES.includes(name)) { seasonalPlant(d,name); return; } + if(name==='leaves') { + for(const [x,y] of [[4,7],[18,4],[28,10],[10,19],[24,24],[0,28]]) { + d.oval(x,y,6,4,'leafDark').oval(x-1,y-1,5,3,'leaf'); leaf(d,x-3,y,false); + } + return; + } + if(name==='berry_bush' || name==='berry_bush_bare') { + twig(d); d.line(16,26,24,17,'bark',2); + for(const [x,y] of [[8,18],[10,10],[21,9],[25,18],[16,21]]) { + d.oval(x,y,5,4,'leafDark').oval(x-1,y-1,4,3,'leaf'); leaf(d,x,y,true); + } + if(name==='berry_bush') for(const [x,y] of [[7,16],[19,8],[24,18],[14,22]]) + d.oval(x,y,2,2,'berry').dot(x-1,y-1,'berryLight'); + return; + } + if(['sapling','tree_seedling','young_tree','mature_tree'].includes(name)) { + const mature=name==='mature_tree', young=name==='young_tree', seedling=name==='tree_seedling'; + if(seedling) { + d.line(16,30,15,20,'barkLight'); leaf(d,15,23,true); leaf(d,15,21,false,'leafLight'); + return; + } + twig(d,16,30,mature?24:young?22:14); + if(mature || young) for(const [x,y] of [[10,10],[22,11],[16,6],[7,17],[24,18]]) + d.oval(x,y,mature?7:5,5,'leafDark').oval(x-1,y-1,mature?6:4,3,'leaf'); + for(const [x,y,f] of [[14,20,true],[16,14,false],[14,11,true]]) leaf(d,x,y,f); + return; + } + if(name==='fern') { + for(const direction of [-1,1]) for(let i=0;i<4;i++) { + const x=16+direction*(i*3), y=26-i*5; + d.line(16,29,x,y-4,'sage'); + for(let j=0;j<3;j++) { + d.line(x,y-j*2,x+direction*4,y-j*2-3,'leaf'); + d.line(x,y-j*2,x-direction*2,y-j*2-2,'sage'); + } + } + return; + } + if(name==='fungi') { + for(const [x,y,r] of [[10,19,7],[23,24,5]]) { + d.rect(x-1,y,3,9,'cream').line(x+2,y+1,x+2,y+7,'barkLight'); + d.oval(x,y,r,3,'barkDark').poly([[x-r,y],[x-r+2,y-4],[x,y-6],[x+r-2,y-3],[x+r,y]],'clay'); + d.line(x-r+3,y-3,x,y-4,'woodLight'); + } + return; + } + if(name==='litter' || name==='dung' || name==='deadwood') { + if(name==='deadwood') { + d.poly([[4,24],[6,19],[25,13],[29,17],[27,23],[9,29]],'barkDark'); + d.line(7,22,25,16,'barkLight',3).line(11,26,27,20,'bark'); + d.oval(7,24,3,4,'wood').oval(7,24,1,2,'bark'); d.line(18,18,16,10,'bark',2); + } else if(name==='dung') { + d.oval(16,26,10,3,'earthDark').oval(16,23,7,4,'earth').oval(15,20,4,3,'earthLight'); + d.line(12,24,17,24,'barkDark'); + } else for(const [x,y,f] of [[5,23,false],[19,27,false],[24,17,true],[12,16,true]]) { + leaf(d,x,y,f,'straw'); d.line(x,y,x+(f?-5:5),y-2,'barkLight'); + } + return; + } + const crop=name==='grain_patch'?4:Number(name.match(/^crop_([1-4])$/)?.[1] || 0); + for(const [x,top,bend] of [[7,9,-3],[15,4,1],[24,7,3],[19,15,-2]]) { + const y=crop ? 28-crop*5+(top%4) : top; + d.line(x,30,x+bend,y,crop>=3?'straw':'leaf',1); + if(name==='reeds') { + d.line(x+bend,y+4,x+bend,y-3,'bark',2).line(x+2,28,x+5,16,'sage'); + } else { + d.line(x,28,x-4,20,'sage').line(x,26,x+4,18,'leaf'); + if(crop>=3) for(let i=0;i<4;i++) { + d.line(x+bend,y+i*2,x+bend-2,y+i*2-2,crop===4?'strawLight':'sage'); + d.line(x+bend,y+i*2+1,x+bend+2,y+i*2-1,crop===4?'straw':'leafLight'); + } + if(name==='wildflowers') { + d.oval(x+bend,y,3,1,'rose').oval(x+bend,y,1,3,'mauve').dot(x+bend,y,'gold'); + } + } + } + if(name==='grain_patch') { + d.line(3,30,2,23,'strawDark').line(28,30,29,25,'straw'); + d.line(8,10,5,4,'strawLight').line(16,9,18,3,'strawLight'); + } +} + +export const SEASONAL_PLANT_NAMES = Object.freeze(['leaves_spring','leaves_autumn','leaves_winter', + 'meadow_grass_dormant','fern_dormant','wildflowers_dormant','spring_greens','spring_greens_dormant', + 'autumn_mushrooms','autumn_mushrooms_dormant']); +export const SPECIES_NAMES = Object.freeze(['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce']); +export const EVERGREEN_NAMES = Object.freeze(['pine','spruce']); +export const ROCK_NAMES = Object.freeze(['granite','basalt','limestone','slate','sandstone']); +export const UNDERSTORY_NAMES = Object.freeze(['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen']); +export const DORMANT_UNDERSTORY_NAMES = Object.freeze(UNDERSTORY_NAMES.slice(0,6).map(n=>`${n}_dormant`)); +// Explicit expansion of the biome/diversity contracts; no source-scanner guesses. +export const BIOME_WORLD_NAMES = Object.freeze([...ROCK_NAMES,...ROCK_NAMES.map(n=>`${n}_bricks`), + 'forest_floor','podzol','alluvium','peat','alpine_turf','gravel','scree','snow', + 'quartz_vein','copper_ore_vein','coal_ore_vein', + ...SPECIES_NAMES.flatMap(s=>['log_top','log_side','leaves','planks'].map(n=>`${s}_${n}`))]); +export const SPECIES_SEASONAL_NAMES = Object.freeze(SPECIES_NAMES.filter(s=>!EVERGREEN_NAMES.includes(s)) + .flatMap(s=>['spring','autumn','winter'].map(season=>`${s}_leaves_${season}`))); +export const PLANT_NAMES = Object.freeze(['leaves','reeds','berry_bush','berry_bush_bare','sapling', + 'grain_patch','crop_1','crop_2','crop_3','crop_4','meadow_grass','wildflowers','fern','litter', + 'fungi','deadwood','tree_seedling','young_tree','mature_tree','dung',...SEASONAL_PLANT_NAMES, + ...SPECIES_NAMES.map(s=>`${s}_leaves`),...SPECIES_SEASONAL_NAMES,...UNDERSTORY_NAMES,...DORMANT_UNDERSTORY_NAMES]); +export const WORLD_NAMES = Object.freeze(['stone','soil','grass_top','grass_side','sand','log_top','log_side', + 'leaves','planks','wall','roof','thatch','foundation','frame','stairs','door','window','vent','storage','pantry', + 'reeds','berry_bush','berry_bush_bare','sapling','grain_patch','crop_1','crop_2','crop_3','crop_4', + 'clay_bank','iron_ore_vein','water','fire','fire_lit','torch','sticks','loose_stone','bedroll', + 'drying_rack','fish_trap','weir_stone',...PLANT_NAMES.slice(10,30),...BIOME_WORLD_NAMES, + ...SPECIES_SEASONAL_NAMES,...UNDERSTORY_NAMES,...DORMANT_UNDERSTORY_NAMES]); +export const ITEM_NAMES = Object.freeze(['berries','grain','grain_seed','raw_food','cooked_food','fish_bait', + 'raw_fish','cooked_fish','fish_drying_charge','dried_fish','iron_ore','iron_ingot','charcoal', + 'charcoal_charge','smelt_charge','clay','clay_jar','clay_jar_charge','dried_food','dried_food_charge', + 'cloak','vessel','water_raw','water_clean','firestarter','iron_axe','spring_greens','autumn_mushrooms', + 'quartz','copper_ore','coal','copper_ingot','copper_charge','copper_pick']); + +function speciesLeaves(d,species,season='summer') { + const index=SPECIES_NAMES.indexOf(species); + if(index>=5) { + const winter=season==='winter', spring=season==='spring', autumn=season==='autumn'; + const dark=winter?'barkDark':spring?'springDark':autumn?'autumnDark':'leafDark'; + const base=winter?'bark':spring?'springLeaf':autumn?'copper':species==='spruce'?'pine':'sage'; + const light=winter?'barkLight':spring?'springLight':autumn?'ochre':'leafLight'; + for(const [i,[x,y]] of [[5,7],[20,5],[28,19],[12,21],[2,30]].entries()) { + d.line(x-3,y+4,x+3,y-4,winter?'bark':'barkLight'); + if(winter) { + d.line(x,y,x-3-index%4,y-4-index%3,dark).line(x+2,y-3,x+index-5,y-7-index%4,light); + continue; + } + if(species==='spruce') { + for(let j=0;j<6;j++) d.line(x-3+j,y+3-j,x-6+j,y-j,base) + .line(x-2+j,y+2-j,x+2+j,y+3-j,j%2?light:dark); + } else if(species==='rowan') { + for(let j=0;j<4;j++) d.line(x-2+j,y+3-j,x-5+j,y+2-j,dark,2) + .line(x-1+j,y+2-j,x+2+j,y+3-j,base,2).dot(x+2+j,y+3-j,light); + } else if(species==='aspen') { + d.oval(x,y,4,4,dark).oval(x-1,y-1,3,3,base).line(x-2,y+1,x+2,y-2,light); + } else if(species==='hazel') { + d.poly([[x,y+4],[x-5,y],[x-4,y-4],[x-1,y-4],[x,y-2],[x+3,y-4],[x+5,y],[x,y+4]],dark) + .line(x,y+2,x-2,y-2,base,2).line(x,y,x+3,y-2,light); + } else { + d.poly([[x-4,y+2],[x-3,y-2],[x,y-5],[x+4,y-2],[x+3,y+2],[x,y+4]],dark) + .line(x-2,y+1,x+1,y-3,base,2).line(x-1,y,x+2,y-1,light); + } + if(spring && i%2===0) d.dot(x+3,y-4,'bud'); + if(autumn) d.line(x-2,y+3,x,y+3,'wine'); + } + return; + } + if(season==='winter') { + // Species-dependent open tracery. No leaf polygons on a winter canopy. + for(let i=0;i<3;i++) { + const x=6+i*9, y=29-i*3, s=i%2?-1:1, bend=index-2; + d.line(x,y,x+bend,y-15,'bark').line(x+bend,y-15,x+s*5,y-23,'barkLight'); + d.line(x,y-7,x-s*5,y-14-index,'barkDark'); + d.line(x-s*3,y-11,x-s*4,y-18,'bark'); + } + return; + } + const colors=season==='spring'?['springDark','springLeaf','springLight']: + season==='autumn'?['autumnDark',index%2?'ochre':'copper',index%2?'copper':'ochre']: + {oak:['leafDark','leaf','sage'],birch:['leaf','sage','leafLight'],pine:['pineShade','pine','pineGlow'], + willow:['willowShade','willow','willowGlow'],alder:['leafDark','turfShade','turfGlow']}[species]; + const [dark,base,light]=colors; + for(const [i,[x0,y0]] of [[4,7],[16,5],[27,9],[7,20],[22,22],[1,30]].entries()) { + const x=x0+(index%2),y=y0+(i%2?index%3:0); + if(species==='pine') { + d.line(x-4,y+3,x+4,y-4,'bark'); + for(let j=0;j<5;j++) d.line(x-3+j,y+1-j,x-6+j,y-1-j,base) + .line(x-2+j,y+1-j,x+1+j,y+3-j,j%2?light:dark); + } else if(species==='willow') { + d.line(x,y-5,x-1,y+6,'barkLight'); + for(let j=0;j<3;j++) d.poly([[x,y-3+j*3],[x+5,y-4+j*3],[x+3,y+j*3]],base) + .line(x,y-3+j*3,x+3,y-3+j*3,light); + d.line(x-1,y,x-5,y+5,dark); + } else if(species==='birch') { + for(const [a,b] of [[-2,0],[3,-3],[3,4]]) + d.poly([[x+a-3,y+b],[x+a,y+b-3],[x+a+3,y+b],[x+a,y+b+3]],dark) + .line(x+a-1,y+b,x+a+1,y+b-1,light).dot(x+a,y+b+1,base); + } else { + d.oval(x,y,species==='oak'?5:4,3,dark).oval(x-1,y-1,4,2,base); + d.line(x-3,y,x+2,y-2,light); + if(species==='oak') d.rect(x+3,y-2,2,2,base).rect(x-4,y+1,2,2,base); + else d.line(x-1,y-2,x+1,y-2,light); + } + if(season==='spring' && i%2===0) d.rect(x+2,y-4,2,2,'bud'); + if(season==='autumn') d.line(x-2,y+2,x,y+2,'wine'); + } +} + +function understory(d,name) { + const dormant=name.endsWith('_dormant'), kind=name.replace(/_dormant$/,''), + stem=dormant?'strawDark':'leafDark', mid=dormant?'straw':'sage', light=dormant?'barkLight':'leafLight'; + if(kind==='moss' || kind==='lichen') { + for(const [i,[x,y]] of [[6,26],[12,23],[20,25],[26,28],[16,29]].entries()) { + if(kind==='moss') { + d.oval(x,y,4,2,'leafDark'); + for(let j=0;j<3;j++) d.line(x-2+j*2,y,x-1+j*2,y-3-i%2,j%2?'sage':'leaf'); + } else { + d.line(x-2,y,x+2,y-3,'stoneLight',2).line(x,y-2,x-2,y-5,'willow'); + d.line(x+2,y-3,x+4,y-2,'cream').dot(x-3,y-4,'sage'); + } + } + return; + } + if(kind==='clover') { + for(const [i,[x,y]] of [[7,24],[14,17],[24,22],[18,26]].entries()) { + d.line(16,30,x,dormant?y+3:y,stem); + if(dormant) { d.line(x,y+3,x+3,y+4,mid); continue; } + for(const [a,b] of [[-2,0],[1,-2],[3,1]]) d.oval(x+a,y+b,2,2,'leaf').dot(x+a,y+b-1,'sage'); + if(i===1) d.oval(x,y-5,2,2,'cream').dot(x,y-6,'rose'); + } + } else if(kind==='nettle') { + for(const [x,top] of [[10,12],[19,5],[25,17]]) { + d.line(x,30,x-1,dormant?top+9:top,stem); + for(let y=dormant?23:top+4;y<29;y+=5) { + if(dormant) d.line(x,y,x+3,y-2,mid); + else for(const s of [-1,1]) d.poly([[x,y],[x+s*5,y-4],[x+s*4,y-1],[x+s*6,y],[x+s*2,y+2]],'leaf') + .line(x,y,x+s*4,y-2,light); + } + } + } else if(kind==='heather') { + for(const [x,y,bend] of [[6,17,-2],[12,10,-3],[18,8,2],[25,15,3]]) { + d.line(16,30,x,y+5,stem).line(x,y+5,x+bend,dormant?y+3:y-4,stem); + for(let j=0;j<(dormant?2:5);j++) { + d.line(x,y+j*2,x-2,y+j*2+1,mid); + if(!dormant) d.rect(x+bend,y-4+j*2,2,2,j%2?'rose':'wine').dot(x+bend,y-4+j*2,'bud'); + } + } + } else if(kind==='bluebell') { + for(const [x,y,s] of [[9,11,1],[19,5,1],[24,17,-1]]) { + d.line(x,30,x,dormant?y+13:y+2,stem); + d.line(x,28,x-4,dormant?25:19,mid); + if(!dormant) { + d.line(x,y+2,x+s*4,y,mid).line(x+s*4,y,x+s*6,y+3,mid); + for(let j=0;j<3;j++) d.poly([[x+s*5,y+3+j*5],[x+s*8,y+3+j*5],[x+s*9,y+7+j*5], + [x+s*4,y+7+j*5]],'slateBlue').line(x+s*5,y+4+j*5,x+s*5,y+6+j*5,'mauve'); + } else d.dot(x,dormant?y+13:y,'bark'); + } + } else { // Sedge fans versus erect, cylindrical bulrush heads. + for(const [i,[x,top,bend]] of [[6,12,-3],[12,6,-2],[17,9,2],[23,4,1],[27,17,2]].entries()) { + const tip=dormant?top+10:top; + d.line(x,30,x+bend,tip,stem).line(x+1,29,x+bend+1,tip+2,mid); + if(kind==='bulrush' && i%2===1) { + d.line(x+bend,tip+2,x+bend,tip-3,'bark',2).dot(x+bend,tip-3,light); + if(dormant) d.line(x+bend,tip-1,x+bend+3,tip+1,'earthLight'); + } else if(!dormant && i%2===0) d.line(x+bend,tip,x+bend+2,tip+3,'leafLight'); + } + } +} + +function habitatTile(d,name) { + if(name==='snow') { + d.rect(0,0,32,32,'snow'); + for(const [i,[x,y]] of GRAINS.entries()) if(i%3===0) + d.line(x,y,x+5,y-1,'snowShade').line(x+1,y-1,x+4,y-2,'snowGlow'); + return; + } + if(name==='gravel' || name==='scree') { + d.rect(0,0,32,32,name==='gravel'?'loamShade':'slateShade'); + GRAINS.forEach(([x,y],i)=> { + if(name==='gravel') d.oval(x,y,2+i%2,1.5,'mineralShade').line(x-1,y-1,x+1,y-1,i%3?'mineralGlow':'sand'); + else d.poly([[x-2,y+2],[x,y-3],[x+4,y-2],[x+2,y+2]],i%2?'slateBlue':'mineral') + .line(x,y-2,x+2,y-2,'slateGlow'); + }); return; + } + earth(d); + if(name==='alpine_turf') { + grass(d); + for(const [i,[x,y]] of GRAINS.entries()) if(i%3===0) + d.oval(x,y,2,1,'slateShade').line(x-1,y-1,x+1,y-1,'mineralGlow'); + return; + } + if(name==='alluvium') { + for(const [i,[x,y]] of GRAINS.entries()) if(i%2===0) + d.line(x,y,x+4,y-1,'earthLight').line(x+1,y+1,x+3,y,'loamGlow'); + d.oval(23,16,3,2,'mineralShade').line(22,15,24,15,'sand'); return; + } + if(name==='peat') d.rect(0,0,32,32,'humus'); + for(const [i,[x,y]] of GRAINS.entries()) { + if(name==='podzol') d.line(x,y,x+3,y+2,i%3?'bark':'strawDark').line(x+1,y,x+3,y+1,'loamGlow'); + else if(name==='peat') d.line(x,y,x+2,y-2,i%3?'loamShade':'bark').dot(x+3,y-3,'loamGlow'); + else { // Forest floor: fragmentary fallen leaves in humus, no giant symbols. + d.poly([[x-1,y],[x+2,y-2],[x+4,y],[x+1,y+2]],i%3?'humus':'barkLight'); + if(i%3===0) d.line(x,y,x+2,y-1,'strawDark'); + } + } +} + +function worldTile(name) { + const d=new Drawing(); + const speciesMatch=name.match(/^(oak|birch|pine|willow|alder|beech|aspen|rowan|hazel|spruce)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/); + if(speciesMatch) { + const [,species,part,season]=speciesMatch; + if(part==='leaves') speciesLeaves(d,species,season); + else if(part==='log_top') endgrain(d,species); + else if(part==='log_side') bark(d,species); + else planks(d,false,species); + if(species==='oak' && part!=='leaves') { + if(part==='log_top') d.line(21,5,20,10,'barkLight'); + else if(part==='log_side') d.line(23,14,24,18,'barkDark').dot(25,15,'barkLight'); + else d.oval(20,12,2,1,'barkLight').dot(20,12,'wood'); + } + return d; + } + if(UNDERSTORY_NAMES.includes(name.replace(/_dormant$/,''))) { understory(d,name); return d; } + if(ROCK_NAMES.includes(name)) { mineral(d,name); return d; } + if(name.endsWith('_bricks') && ROCK_NAMES.includes(name.slice(0,-7))) { + const rock=name.slice(0,-7); mineral(d,rock); + const joint={granite:'mineralShade',basalt:'basaltShade',limestone:'limeShade',slate:'slateShade',sandstone:'sandShade'}[rock]; + const edge={granite:'mineralGlow',basalt:'basaltGlow',limestone:'limeGlow',slate:'slateGlow',sandstone:'sandGlow'}[rock]; + for(const [row,y] of [0,11,22].entries()) { + d.line(0,y,31,y,joint).line(1,y+1,30,y+1,edge); + for(const x of row%2?[8,25]:[0,17]) d.line(x,y,x,y+10,joint); + } + return d; + } + if(['forest_floor','podzol','alluvium','peat','alpine_turf','gravel','scree','snow'].includes(name)) { + habitatTile(d,name); return d; + } + if(['quartz_vein','copper_ore_vein','coal_ore_vein'].includes(name)) { + mineral(d,name==='quartz_vein'?'granite':name==='coal_ore_vein'?'sandstone':'basalt'); + if(name==='quartz_vein') { + d.line(1,28,12,16,'stoneLight',2).line(12,16,19,14,'stoneLight',2).line(19,14,30,1,'stoneLight',2); + for(const [x,y] of [[5,25],[13,16],[22,10],[28,4]]) + d.poly([[x-1,y],[x,y-4],[x+3,y-3],[x+3,y+1]],'cream').line(x,y-3,x+1,y,'moon'); + } else if(name==='copper_ore_vein') for(const [x,y] of [[4,7],[10,11],[15,17],[20,20],[27,27]]) + d.poly([[x-2,y],[x,y-3],[x+3,y-2],[x+2,y+2]],'copperShade').line(x,y-2,x+2,y-2,'copperMetal') + .line(x-1,y+1,x+1,y+1,'verdigris'); + else { + d.poly([[0,11],[7,10],[14,14],[23,13],[32,17],[32,21],[24,18],[14,19],[6,14],[0,15]],'ink'); + d.line(2,12,9,13,'slate').line(16,16,22,15,'slate').line(26,18,30,19,'slate'); + } + return d; + } + if(PLANT_NAMES.includes(name)) { plant(d,name); return d; } + switch(name) { + case 'stone': mineral(d); break; + case 'foundation': case 'weir_stone': + masonry(d,name==='foundation'); + if(name==='weir_stone') d.line(0,25,31,25,'waterLight').line(2,27,12,27,'water'); + break; + case 'soil': earth(d); break; + case 'grass_top': grass(d); break; + case 'grass_side': grass(d,true); break; + case 'sand': + d.rect(0,0,32,32,'sand'); + for(let y=0;y<32;y++) for(let x=0;x<32;x++) { + const u=x===31?0:x, ripple=y+1.4*Math.sin(u*Math.PI*2/31)+.45*Math.sin(u*Math.PI*4/31); + const phase=(ripple+32)%8; + if(phase<.8) d.dot(x,y,'sandShade'); + else if(phase>6.9) d.dot(x,y,'sandGlow'); + } + for(const [x,y] of [[8,11],[21,25],[15,4],[27,17]]) d.rect(x,y,2,1,'sandShade'); + break; + case 'log_top': endgrain(d); break; + case 'log_side': bark(d); break; + case 'planks': planks(d); break; + case 'wall': + d.rect(0,0,32,32,'clay'); + for(const y of [4,14,25]) d.line(1,y,11,y,'earthLight').line(20,y+2,29,y+2,'wood'); + d.rect(0,0,3,32,'bark').rect(29,0,3,32,'bark').rect(3,27,26,3,'wood'); + d.line(8,8,11,11,'earth').line(11,11,9,16,'earth'); break; + case 'roof': case 'thatch': straw(d); if(name==='thatch') d.line(0,30,31,30,'strawLight'); break; + case 'frame': + d.rect(0,0,32,32,'earthDark').rect(4,4,24,24,'clay'); + for(const x of [0,27]) d.rect(x,0,5,32,'bark').line(x+1,1,x+1,30,'wood'); + for(const y of [0,27]) d.rect(0,y,32,5,'barkLight').line(2,y+1,29,y+1,'woodLight'); + d.line(5,26,26,5,'bark',3).line(6,26,26,6,'wood'); break; + case 'stairs': + planks(d); for(const y of [0,8,16,24]) d.rect(0,y,32,2,'barkDark').line(0,y+2,31,y+2,'woodLight'); break; + case 'door': + planks(d,true); d.rect(1,2,30,3,'barkDark').rect(1,26,30,3,'barkDark'); + d.line(3,24,27,7,'bark',3).line(4,24,28,7,'wood'); + d.rect(24,16,3,4,'outline').dot(25,17,'gold'); break; + case 'window': + d.rect(3,3,26,26,'glass',95).line(7,13,12,8,'moon').line(19,24,24,19,'waterLight'); + for(const x of [1,15,29]) d.rect(x,1,2,30,'barkLight'); + for(const y of [1,15,29]) d.rect(1,y,30,2,'wood'); break; + case 'vent': + planks(d); d.rect(5,5,22,22,'barkDark'); + for(const y of [6,12,18,24]) d.rect(6,y,20,2,'wood').line(7,y,25,y,'woodLight'); break; + case 'storage': case 'pantry': + planks(d,true); d.rect(1,2,30,4,'barkDark').rect(1,25,30,5,'barkDark'); + d.line(2,3,29,3,'woodLight').rect(14,12,5,9,'outline').rect(16,14,1,3,'gold'); + if(name==='pantry') { + d.rect(3,7,26,16,'earthDark'); + for(const x of [7,15,23]) d.oval(x,16,3,5,'clay').rect(x-2,10,4,2,'wood'); + d.line(3,22,28,22,'woodLight'); + } + break; + case 'clay_bank': + d.rect(0,0,32,32,'clay'); + for(const y of [3,13,24]) d.poly([[0,y],[10,y+2],[20,y],[32,y],[32,y+3],[20,y+3],[10,y+5],[0,y+3]],'earthLight'); + d.line(4,8,10,9,'wood').line(21,19,28,18,'wood'); break; + case 'iron_ore_vein': + mineral(d); d.poly([[1,27],[8,18],[12,19],[18,10],[23,11],[29,2],[31,6],[25,15],[21,15],[15,24],[9,23],[4,30]],'earthDark'); + for(const [x,y] of [[5,25],[11,20],[18,14],[24,9],[28,4]]) + d.poly([[x-2,y],[x,y-3],[x+3,y-2],[x+2,y+2]],'rust').line(x,y-2,x+2,y-2,'iron'); break; + case 'water': + d.rect(0,0,32,32,'water'); + // Opposite edge pixels agree; small, separated horizontal wavelets. + for(const [x,y,w] of [[4,5,8],[19,13,9],[7,23,11]]) + d.line(x,y,x+w,y,'waterLight').line(x+2,y+1,x+w+2,y+1,'waterDark'); + break; + case 'fire': case 'fire_lit': + d.oval(16,22,14,8,'outline').oval(16,21,11,5,'earthDark'); + for(const [x,y] of [[5,19],[10,26],[22,26],[27,19],[16,15]]) d.oval(x,y,3,2,'stone').dot(x-1,y-1,'stoneLight'); + d.line(9,24,24,18,'bark',3).line(8,18,23,24,'barkLight',2); + if(name==='fire_lit') flame(d,16,20,1); break; + case 'torch': + d.line(13,29,18,12,'barkDark',3).line(14,28,19,12,'wood'); + d.line(16,15,21,16,'straw',2); flame(d,19,12,.65); break; + case 'sticks': + d.line(5,25,24,6,'barkDark',3).line(6,25,25,6,'wood').line(11,11,25,26,'bark',3).line(12,11,26,26,'barkLight'); + d.line(14,17,9,12,'bark',2); break; + case 'loose_stone': + d.poly([[5,24],[7,16],[16,12],[25,17],[28,24],[22,28],[10,28]],'slate'); + d.poly([[7,20],[10,16],[16,14],[23,17],[20,23],[12,24]],'stone').line(11,17,16,16,'stoneLight'); break; + case 'bedroll': + d.poly([[7,7],[25,7],[28,27],[4,27]],'outline').rect(7,8,18,17,'leaf'); + d.rect(8,10,16,3,'sage').line(9,16,23,16,'leafDark').line(9,22,23,22,'leafDark'); + d.oval(16,7,9,4,'barkLight').oval(16,6,8,3,'wood').line(6,27,26,27,'wood'); break; + case 'drying_rack': + d.line(5,30,8,4,'bark',3).line(24,4,27,30,'bark',3).line(6,5,25,5,'wood',2); + for(const x of [11,17,23]) { d.line(x,6,x,12,'straw'); fish(d,x,18,.45,'wood'); } break; + case 'fish_trap': + d.poly([[4,11],[18,5],[28,13],[28,24],[14,29],[4,22]],'barkDark'); + for(let x=5;x<29;x+=4) d.line(x,12,x,24,'straw'); + for(const y of [12,16,20,24]) d.line(5,y,27,y,'wood'); + d.oval(15,10,10,5,'wood').oval(15,10,7,3,'earthDark').oval(15,10,3,2,'bark'); break; + default: throw new Error('Unknown world art name.'); + } + return d; +} +function flame(d,x,y,s) { + const points=[[0,2],[-7,-1],[-6,-7],[-2,-10],[0,-19],[4,-12],[4,-6],[8,-9],[7,-2],[3,2]]; + d.poly(points.map(([a,b])=>[x+a*s,y+b*s]),'clay'); + d.poly([[-4,0],[-3,-7],[0,-12],[2,-5],[4,-2],[2,1]].map(([a,b])=>[x+a*s,y+b*s]),'peach'); + d.poly([[-2,0],[0,-7],[2,-1],[1,1]].map(([a,b])=>[x+a*s,y+b*s]),'gold'); +} +function fish(d,x=16,y=16,s=1,color='iron') { + d.poly([[x-9*s,y],[x-14*s,y-5*s],[x-14*s,y+5*s]],'waterLight'); + d.oval(x,y,10*s,5*s,'outline').oval(x,y-s,9*s,4*s,color); + d.line(x-4*s,y-3*s,x+4*s,y-3*s,color==='iron'?'moon':'woodLight'); + d.dot(x+6*s,y-s,'ink').line(x-s,y+2*s,x+2*s,y+2*s,'bark'); +} +function jar(d,water=null) { + d.poly([[11,8],[21,8],[21,13],[25,18],[24,27],[8,27],[7,18],[11,13]],'earthDark'); + d.poly([[12,9],[20,9],[19,14],[23,19],[22,25],[10,25],[9,19],[13,14]],'clay'); + d.line(11,18,11,23,'woodLight').rect(10,7,12,3,'barkLight').rect(12,7,8,1,'earthDark'); + if(water) d.rect(12,18,8,5,water).line(13,18,18,18,'waterLight'); +} +function lump(d,color='clay') { + d.poly([[5,22],[8,13],[16,9],[25,14],[28,23],[20,28],[9,27]],'outline'); + d.poly([[7,21],[10,14],[16,11],[24,15],[25,22],[19,25],[10,25]],color); + d.poly([[10,14],[16,11],[22,15],[16,18],[9,19]],color==='clay'?'wood':'slate'); +} +function food(d,cooked=false,dried=false) { + d.poly([[5,21],[8,11],[17,7],[26,14],[25,24],[17,28],[8,26]],'earthDark'); + d.poly([[7,21],[10,12],[17,10],[24,15],[23,23],[16,25],[9,24]],cooked?'barkLight':'rose'); + d.line(12,14,19,20,dried?'straw':'cream',2).line(11,20,15,23,cooked?'earth':'berry'); + if(dried) for(const x of [10,15,20]) d.line(x,16,x+2,21,'barkDark'); +} +function itemTile(name) { + const d=new Drawing(); + if(name.endsWith('_charge')) { + const base={fish_drying_charge:'raw_fish',charcoal_charge:'charcoal',smelt_charge:'iron_ore', + clay_jar_charge:'clay_jar',dried_food_charge:'raw_food',copper_charge:'copper_ore'}[name]; + d.data.set(itemTile(base).data); + d.line(6,25,25,7,'straw',2).line(7,7,25,25,'woodLight'); + d.rect(20,22,9,7,'outline').rect(21,23,7,5,'gold').line(22,24,26,24,'bark'); + return d; + } + switch(name) { + case 'quartz': + d.poly([[7,25],[6,14],[10,9],[14,12],[16,5],[21,3],[25,8],[24,24],[17,28]],'slateBlue'); + d.poly([[8,23],[8,14],[10,11],[13,14],[14,24]],'cream'); + d.poly([[15,24],[17,6],[21,4],[22,9],[21,25]],'moon'); + d.poly([[22,9],[24,9],[23,23],[21,25]],'stoneLight'); + d.line(18,9,17,21,'snowGlow'); break; + case 'copper_ore': + lump(d,'basalt'); + for(const [x,y] of [[11,18],[18,14],[21,23]]) d.poly([[x-2,y],[x,y-3],[x+3,y-1],[x+1,y+2]],'copperShade') + .line(x-1,y-1,x+1,y-2,'copperMetal').dot(x+2,y+1,'verdigris'); break; + case 'coal': + lump(d,'ink'); + d.poly([[8,21],[14,13],[17,19],[13,25]],'basaltShade').line(14,13,16,16,'slateBlue'); + d.line(19,13,22,17,'mineralShade').line(20,23,23,21,'basaltGlow'); break; + case 'copper_ingot': + d.poly([[5,16],[20,10],[28,15],[25,23],[10,28],[5,24]],'copperShade'); + d.poly([[5,16],[20,10],[28,15],[11,22]],'copperMetal').line(7,16,20,11,'copperGlow'); + d.line(12,24,24,19,'copperMetal'); break; + case 'copper_pick': + d.line(8,27,22,9,'barkDark',3).line(9,27,23,9,'wood'); + d.poly([[4,12],[8,7],[15,5],[22,7],[28,12],[23,11],[16,9],[10,10]],'copperShade'); + d.line(7,10,14,7,'copperGlow').line(14,7,21,8,'copperMetal').line(21,8,26,10,'copperMetal'); + d.line(10,24,13,26,'straw'); break; + case 'spring_greens': + seasonalPlant(d,name); + d.line(12,26,21,25,'straw',2).line(14,29,20,28,'wood'); break; + case 'autumn_mushrooms': + seasonalPlant(d,name); + d.line(9,28,24,28,'bark').line(12,29,21,29,'wood'); break; + case 'berries': + d.line(8,14,20,7,'barkLight').line(20,7,23,13,'bark'); leaf(d,17,10,false); + for(const [x,y] of [[9,18],[18,16],[23,23],[13,25]]) + d.oval(x,y,5,5,'outline').oval(x,y-1,4,4,'berry').rect(x-2,y-3,2,2,'berryLight'); break; + case 'grain': + for(const x of [10,16,22]) { + d.line(15,28,x,6,'strawDark'); + for(let y=7;y<18;y+=3) d.line(x,y,x-3,y-2,'straw',2).line(x+1,y+1,x+3,y-1,'strawLight'); + } + d.line(11,23,20,22,'bark',2); break; + case 'grain_seed': + for(const [x,y] of [[10,12],[20,10],[15,19],[8,25],[24,23]]) + d.oval(x,y,3,2,'strawDark').line(x-1,y-1,x+1,y-1,'strawLight'); break; + case 'raw_food': food(d); break; + case 'cooked_food': food(d,true); break; + case 'dried_food': food(d,true,true); break; + case 'fish_bait': + d.line(7,21,11,25,'earthDark',4).line(11,25,17,20,'clay',3).line(17,20,17,13,'clay',3); + d.line(17,13,23,10,'rose',3).line(23,10,26,14,'rose',2).dot(25,12,'woodLight'); break; + case 'raw_fish': fish(d); break; + case 'cooked_fish': fish(d,16,16,1,'clay'); d.line(12,13,14,18,'earth').line(17,13,19,18,'earth'); break; + case 'dried_fish': fish(d,16,16,1,'straw'); for(const x of [11,16,21]) d.line(x,13,x,18,'bark'); break; + case 'iron_ore': lump(d,'stone'); d.line(10,23,22,13,'rust',3).line(12,23,23,14,'iron'); break; + case 'iron_ingot': + d.poly([[5,16],[20,10],[28,15],[25,23],[10,28],[5,24]],'slate'); + d.poly([[5,16],[20,10],[28,15],[11,22]],'iron').line(7,16,20,11,'moon'); + d.poly([[11,22],[28,15],[25,23],[10,28]],'waterLight'); break; + case 'charcoal': lump(d,'ink'); d.line(11,15,16,18,'slate').line(18,13,22,20,'barkDark'); break; + case 'clay': lump(d); break; + case 'clay_jar': jar(d); break; + case 'vessel': + d.oval(25,17,5,6,'barkLight').oval(25,17,2,3,'outline'); + d.poly([[7,10],[23,10],[22,26],[9,26]],'bark').rect(9,11,12,13,'wood'); + d.oval(15,10,8,3,'woodLight').oval(15,10,6,1,'earthDark'); break; + case 'water_raw': jar(d,'water'); d.dot(14,21,'sage').dot(18,20,'earth'); break; + case 'water_clean': jar(d,'waterLight'); d.line(16,15,16,21,'moon').line(13,18,19,18,'moon'); break; + case 'cloak': + d.poly([[12,4],[20,4],[23,11],[29,27],[20,26],[16,29],[12,26],[3,27],[9,11]],'outline'); + d.poly([[13,5],[19,5],[21,12],[26,25],[19,24],[16,27],[12,24],[6,25],[11,12]],'leaf'); + d.line(12,13,8,23,'sage').line(18,14,21,23,'leafDark').oval(16,8,3,3,'barkDark').dot(17,12,'gold'); break; + case 'firestarter': + d.poly([[5,24],[7,14],[13,10],[18,16],[15,25]],'slate').line(8,16,13,12,'stoneLight'); + d.line(17,26,25,8,'bark',3).line(18,26,26,8,'wood'); + d.line(18,7,18,11,'gold').line(13,5,15,7,'peach'); break; + case 'iron_axe': + d.line(9,26,22,8,'barkDark',4).line(10,26,23,8,'wood',2); + d.poly([[8,6],[16,3],[25,8],[24,15],[17,17],[13,12],[8,12]],'outline'); + d.poly([[9,7],[16,5],[23,9],[22,13],[17,15],[14,10],[9,10]],'iron').line(9,7,16,5,'moon'); + d.line(12,23,15,25,'straw',2); break; + default: throw new Error('Unknown item art name.'); + } + return d; +} + +function uiTile(name) { + const d=new Drawing(); + if(name==='crosshair') { + for(const [x,y,w,h] of [[15,9,3,5],[15,19,3,5],[9,15,5,3],[19,15,5,3]]) + d.rect(x,y,w,h,'deep'); + d.line(16,10,16,12,'cream').line(16,20,16,22,'cream').line(10,16,12,16,'cream').line(20,16,22,16,'cream'); + } else { + if(name==='hotbar') d.rect(1,1,30,30,'deep',220).rect(3,3,26,26,'ink',200); + for(const [x,y,w,h] of [[1,1,30,1],[1,30,30,1],[1,1,1,30],[30,1,1,30]]) + d.rect(x,y,w,h,name==='selected'?'gold':'slate'); + if(name==='selected') for(const [x,y] of [[3,3],[25,3],[3,25],[25,25]]) + d.line(x,y,x+3,y,'mauve').line(x,y,x,y+3,'mauve'); + else d.line(4,3,27,3,'stone').line(3,4,3,27,'slate'); + } + return d; +} + +// Atlas patches are 8x8, UV-inset by half a pixel. Every face has a semantic +// patch, never an unstable automatically-unwrapped coordinate or external MTL. +const FUR_PATCHES = ['coat','belly','dark','nose','eye','ear','horn','hoof']; +function animalTexture(species) { + const d=new Drawing('outline'), coat={hare:'fur',fox:'fox',deer:'deer'}[species]; + FUR_PATCHES.forEach((patch,i)=> { + const x=(i%4)*8, y=Math.floor(i/4)*8; + const base={coat,belly:'cream',dark:'barkDark',nose:'ink',eye:'ink',ear:'rose',horn:'wood',hoof:'barkDark'}[patch]; + d.rect(x,y,8,8,base); + if(patch==='coat') d.rect(x+1,y+1,5,2,species==='fox'?'clay':'barkLight').rect(x+4,y+5,3,2,'bark'); + if(patch==='belly') d.line(x+1,y+6,x+6,y+6,'woodLight'); + if(patch==='eye') d.rect(x+1,y+1,2,2,'cream'); + if(patch==='ear') d.rect(x,y,2,8,coat).line(x+6,y,x+6,y+7,coat); + if(patch==='horn') d.line(x+2,y,x+2,y+7,'woodLight'); + }); + return d; +} + +export const ANIMAL_NAMES = Object.freeze(['hare','fox','deer']); +// All model coordinates use Luanti's BS=10 convention. Origin is ground level, +// forward +Z, visual_size={x=1,y=1}; bounds below are in actual node units. +function animalParts(species) { + const parts=[]; + const add=(name,center,size,patch='coat',tilt=0,taper=1)=>parts.push({name,center,size,patch,tilt,taper}); + if(species==='hare') { + add('haunch',[0,2.4,-.7],[3.3,3.8,4.2]); add('chest',[0,2.5,1.3],[2.5,2.8,2.4]); + add('head',[0,4,2.4],[2.6,2.4,2.5]); add('muzzle',[0,3.5,3.65],[1.5,1,1.2],'belly',0,.65); + add('ear_left',[-.8,6.5,2],[.7,3.5,.75],'ear',-.12,.6); add('ear_right',[.8,6.6,2],[.7,3.7,.75],'ear',.12,.6); + add('tail',[0,2.7,-3],[1.4,1.4,1.4],'belly'); + for(const s of [-1,1]) { + add(`hind_foot_${s}`,[s*1.25,.4,-.6],[1.2,.8,2.7],'belly'); + add(`foreleg_${s}`,[s*.85,.8,2],[.65,1.6,.8]); + add(`eye_${s}`,[s*1.32,4.35,2.8],[.16,.5,.5],'eye'); + } + add('nose',[0,3.8,4.22],[.5,.4,.15],'nose'); + } else if(species==='fox') { + add('body',[0,3.2,0],[3.1,3.2,6]); add('bib',[0,3.2,2.2],[2.6,2.7,1.7],'belly'); + add('neck',[0,4.4,2.4],[2.4,2.3,2]); add('head',[0,5.2,3.4],[2.7,2.1,2.4]); + add('muzzle',[0,4.8,4.9],[1.7,1.3,1.8],'belly',0,.4); + for(const s of [-1,1]) { + add(`ear_${s}`,[s*.85,6.9,3.2],[1,1.8,.85],'dark',s*.2,.2); + add(`eye_${s}`,[s*1.37,5.5,3.85],[.16,.45,.6],'eye'); + for(const z of [-2,1.9]) add(`leg_${s}_${z}`,[s*1.05,1.1,z],[.65,2.2,.8],'dark'); + } + add('tail',[0,3,-4.8],[2.4,2.3,4.2],'coat',-.28,.6); + add('tail_tip',[0,2.2,-7.2],[1.8,1.7,1.6],'belly',-.28,.4); + add('nose',[0,4.8,5.83],[.7,.55,.18],'nose'); + } else if(species==='deer') { + add('body',[0,6,0],[4,4,7.5]); add('belly',[0,4.6,.3],[3.3,1.5,6],'belly'); + add('neck',[0,8.5,3.2],[2,5.2,2.4],'coat',.28,.7); + add('head',[0,11,4.4],[2,2.1,3.4],'coat',0,.7); + add('muzzle',[0,10.5,6],[1.4,1.2,1.5],'dark',0,.8); + add('tail',[0,6.7,-4.1],[1.1,1.8,1.1],'belly',-.35); + for(const s of [-1,1]) { + for(const z of [-2.7,2.4]) { + add(`leg_${s}_${z}`,[s*1.35,2.4,z],[.65,4.8,.8]); + add(`hoof_${s}_${z}`,[s*1.35,.35,z+.1],[.8,.7,1],'hoof'); + } + add(`ear_${s}`,[s*1.7,11.8,3.9],[2.2,.65,1.1],'ear',s*.2,.6); + add(`eye_${s}`,[s*1.02,11.3,5],[.14,.45,.55],'eye'); + add(`antler_${s}`,[s*.85,13.3,3.9],[.35,3.2,.4],'horn',s*.15,.7); + add(`tine_${s}`,[s*1.4,13.8,4.1],[1.4,.3,.35],'horn'); + add(`point_${s}`,[s*2,14.4,4.1],[.25,1.4,.3],'horn'); + } + } else throw new Error('Unknown animal art name.'); + return parts; +} +function partVertices({center:[cx,cy,cz],size:[w,h,l],tilt,taper}) { + return [[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]].map(([x,y,z])=> { + const a=x*w/2*(y>0?taper:1), b=y*h/2, c=z*l/2; + return [cx+a,cy+b*Math.cos(tilt)-c*Math.sin(tilt),cz+b*Math.sin(tilt)+c*Math.cos(tilt)]; + }); +} +export function animalOBJ(species) { + const lines=[`# Original Last Hearth ${species}; BS=10; +Z forward; feet Y=0`]; + let count=0; + for(const part of animalParts(species)) { + lines.push(`g ${part.name}`); + for(const v of partVertices(part)) lines.push(`v ${v.map(n=>Number(n.toFixed(6))).join(' ')}`); + const tile=FUR_PATCHES.indexOf(part.patch), x=(tile%4)*8, y=Math.floor(tile/4)*8; + for(const [u,v] of [[.5,7.5],[7.5,7.5],[7.5,.5],[.5,.5]]) lines.push(`vt ${(x+u)/32} ${1-(y+v)/32}`); + const faces=[[1,4,3,2],[5,6,7,8],[1,2,6,5],[4,8,7,3],[1,5,8,4],[2,3,7,6]]; + const uv=count/8*4; + for(const f of faces) for(const tri of [[0,1,2],[0,2,3]]) + lines.push(`f ${tri.map(i=>`${count+f[i]}/${uv+i+1}`).join(' ')}`); + count+=8; + } + return lines.join('\n')+'\n'; +} +export function animalBounds(species) { + const vertices=animalParts(species).flatMap(partVertices); + return Object.freeze({min: Object.freeze([0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10)), + max: Object.freeze([0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)), + visualSize: Object.freeze({x:1,y:1})}); +} + +// Transcribed from the actual botany.lua catalogue, not guessed from common +// names. Latin identity and diagnostic morphology travel with every sprite. +const BOTANY_GROUPS = { + trees: [ + ['oak','Quercus robur','rounded leaf lobes; stalked acorns'], + ['birch','Betula pendula','serrate diamonds; pendant catkins'], + ['pine','Pinus sylvestris','paired long needles; rounded cones'], + ['willow','Salix alba','pendant lanceolate leaves; catkins'], + ['alder','Alnus glutinosa','notched round leaves; woody conelets'], + ['beech','Fagus sylvatica','oval parallel-veined leaves; pointed buds'], + ['aspen','Populus tremula','scalloped round leaves; long petioles'], + ['rowan','Sorbus aucuparia','pinnate leaflets; clustered red pomes'], + ['hazel','Corylus avellana','heart-shaped serrate leaves; husked nuts'], + ['spruce','Picea abies','short radial needles; pendant long cones'], + ], + shrubs: [ + ['bilberry','Vaccinium myrtillus','angular green twigs; small ovals; solitary blue berries'], + ['blackberry','Rubus plicatus','arching prickly cane; palmate leaves; dark aggregate fruit'], + ['raspberry','Rubus idaeus','upright canes; pale pinnate leaves; red aggregate fruit'], + ['elder','Sambucus nigra','opposite pinnate leaves; broad dark fruit corymb'], + ['blackthorn','Prunus spinosa','rigid spines; small leaves; blue sloes'], + ['hawthorn','Crataegus monogyna','short thorns; deeply lobed leaves; red haws'], + ['dog_rose','Rosa canina','curved prickles; five petals; elongated hips'], + ['holly','Ilex aquifolium','spiny glossy evergreen leaves; red berries'], + ['juniper','Juniperus communis','needle whorls of three; blue berry-like cones'], + ['guelder_rose','Viburnum opulus','three-lobed opposite leaves; translucent red fruit cluster'], + ], + grasses: [ + ['sedge','Carex paniculata','triangular culms; dense branched panicle; tussock'], + ['bulrush','Typha latifolia','flat strap leaves; cylindrical brown reedmace'], + ['meadow_fescue','Schedonorus pratensis','narrow blades; open nodding panicle'], + ['cocksfoot','Dactylis glomerata','one-sided clustered panicle; folded blades'], + ['timothy','Phleum pratense','long tight cylindrical green flower spike'], + ['sweet_vernal_grass','Anthoxanthum odoratum','short golden ovoid spike; sparse leaves'], + ['yorkshire_fog','Holcus lanatus','soft broad blades; dense pink-tinged panicle'], + ['tufted_hairgrass','Deschampsia cespitosa','fine radiating airy panicle; dense basal tuft'], + ['common_reed','Phragmites australis','tall jointed culm; broad leaves; feathery plume'], + ['soft_rush','Juncus effusus','smooth round stems; lateral brown inflorescence'], + ], + herbs: [ + ['clover','Trifolium repens','creeping stolon; trifoliate leaves; round white head'], + ['nettle','Urtica dioica','opposite toothed leaves; drooping green flower strings'], + ['heather','Calluna vulgaris','woody branching sprays; tiny scale leaves; pink bells'], + ['bluebell','Hyacinthoides non-scripta','arching one-sided raceme; drooping blue bells'], + ['yarrow','Achillea millefolium','finely divided leaves; flat cream corymb'], + ['foxglove','Digitalis purpurea','basal rosette; tall one-sided tubular purple bells'], + ['meadowsweet','Filipendula ulmaria','pinnate serrate leaves; frothy cream plume'], + ['wild_garlic','Allium ursinum','broad basal lance leaves; white six-tepal star umbel'], + ['cowslip','Primula veris','wrinkled basal rosette; nodding yellow tubular umbel'], + ['wood_anemone','Anemone nemorosa','whorl of divided leaves; single pale six-tepal flower'], + ], + ferns_mosses: [ + ['bracken','Pteridium aquilinum','three-part triangular divided frond'], + ['lady_fern','Athyrium filix-femina','delicate tapering bipinnate frond'], + ['male_fern','Dryopteris filix-mas','robust lanceolate pinnate frond; basal crown'], + ['harts_tongue','Asplenium scolopendrium','undivided glossy strap fronds; parallel sori'], + ['polypody','Polypodium vulgare','deeply lobed pinnate frond; round gold sori'], + ['sphagnum','Sphagnum palustre','starry capitula; dangling branch fascicles'], + ['haircap_moss','Polytrichum commune','upright leafy shoots; tall reddish capsule setae'], + ['moss','Hypnum cupressiforme','creeping feather-like plaits; hooked tips'], + ['fork_moss','Dicranum scoparium','cushion of swept sickle-shaped leaves'], + ['star_moss','Mnium hornum','upright shoots; broad oval leaves; nodding capsules'], + ], + fungi: [ + ['fly_agaric','Amanita muscaria','red white-warted cap; ring; bulbous base'], + ['birch_bolete','Leccinum scabrum','brown convex cap; pale scabrous stem'], + ['bay_bolete','Imleria badia','chestnut cushion cap; stout buff stem'], + ['chanterelle','Cantharellus cibarius','wavy golden funnel; decurrent folds'], + ['wood_blewit','Collybia nuda','lilac broad cap; stout lilac stem'], + ['shaggy_inkcap','Coprinus comatus','tall shaggy cylindrical cap; black deliquescent rim'], + ['turkey_tail','Trametes versicolor','thin overlapping banded fan brackets'], + ['oyster_fungus','Pleurotus ostreatus','lateral overlapping oyster caps; decurrent gills'], + ['honey_fungus','Armillaria mellea','tufted honey caps; rings; shared woody base'], + ['birch_polypore','Fomitopsis betulina','thick pale kidney bracket; dark attachment'], + ], + aquatic: [ + ['duckweed','Lemna minor','tiny floating paired oval fronds; single dangling roots'], + ['yellow_waterlily','Nuphar lutea','notched oval floating pads; small yellow cup'], + ['white_waterlily','Nymphaea alba','round notched pads; many white pointed tepals'], + ['water_crowfoot','Ranunculus aquatilis','lobed floating leaves; divided submerged leaves; five white petals'], + ['water_mint','Mentha aquatica','opposite serrate leaves; terminal lilac globe'], + ['water_forgetmenot','Myosotis scorpioides','scorpioid cyme; tiny blue five-lobed flowers'], + ['brooklime','Veronica beccabunga','fleshy opposite oval leaves; lateral blue racemes'], + ['water_starwort','Callitriche stagnalis','opposite narrow submerged leaves; oval terminal rosette'], + ['hornwort','Ceratophyllum demersum','rootless submerged whorls of forked needle leaves'], + ['pondweed','Potamogeton natans','elongated floating ovals; long petioles; emergent spike'], + ], + lichens: [ + ['reindeer_lichen','Cladonia rangiferina','pale repeatedly forked shrubby fruticose tufts'], + ['oakmoss','Evernia prunastri','flattened branching straps with pale undersides'], + ['old_mans_beard','Usnea subfloridana','hanging hair-fine branching filaments'], + ['lichen','Rhizocarpon geographicum','flat yellow-green crust islands divided by black map lines'], + ['dog_lichen','Peltigera canina','broad grey lobes; raised brown margins; underside rhizines'], + ['lungwort_lichen','Lobaria pulmonaria','large lobed green thallus; ridged reticulate surface'], + ['sunburst_lichen','Xanthoria parietina','orange foliose rosette; orange disc apothecia'], + ['hammered_shield','Parmelia sulcata','grey foliose shield; pale hammered ridge network'], + ['cup_lichen','Cladonia chlorophaea','slender mealy stalks; flared shallow pixie cups'], + ['pixie_cup','Cladonia pyxidata','stout pebbled stalks; broad cups; basal squamules'], + ], +}; +export const BOTANY_CATALOGUE = Object.freeze(Object.entries(BOTANY_GROUPS).flatMap(([group,rows])=> + rows.map(([id,latin,morphology])=>Object.freeze({id,latin,group,morphology})))); +export const BOTANY_ART_NAMES = Object.freeze(BOTANY_CATALOGUE.map(r=>r.id)); +export const BOTANY_SPRITE_NAMES = Object.freeze(BOTANY_ART_NAMES.flatMap(id=>[`botany_${id}`,`botany_${id}_dormant`])); + +// A specimen grammar, not generic coloured markers. Each taxon specifies its +// own leaf outline, branching, reproductive structure and habitat growth form. +function specimenLeaf(d,x,y,shape='oval',s=1,color='sage') { + const p=(a,b)=>[x+a*s,y+b]; + if(shape==='needle') { + d.line(x,y,x+s*5,y-6,'leafDark').line(x,y,x+s*2,y-7,color); return; + } + const points={ + oval:[[0,1],[-3,-1],[-3,-4],[0,-6],[3,-4],[3,-1]], + round:[[0,2],[-4,0],[-5,-3],[-3,-6],[1,-7],[4,-4],[4,-1]], + lance:[[0,1],[-2,-3],[0,-9],[2,-3]], + diamond:[[0,1],[-4,-3],[0,-8],[3,-4],[2,-2]], + heart:[[0,2],[-5,-2],[-4,-6],[-1,-6],[0,-4],[2,-6],[5,-4],[4,-1]], + lobe:[[0,2],[-3,0],[-2,-2],[-5,-3],[-4,-5],[-2,-5],[-3,-7],[0,-9],[2,-7],[1,-5],[4,-5],[4,-3],[2,-2],[3,0]], + thorn:[[0,2],[-3,0],[-2,-2],[-5,-3],[-2,-4],[-3,-7],[0,-6],[2,-8],[3,-4],[5,-3],[2,-1]], + }[shape]; + d.poly(points.map(([a,b])=>p(a,b)),'leafDark').line(x,y,x,y-(shape==='lance'?6:4),color); + if(shape!=='lance') d.line(x,y-2,x+s*2,y-4,'leafLight'); +} +function flower(d,x,y,color='cream',petals=5,r=3) { + for(let j=0;jr[0]===id), arch=['blackberry','dog_rose'].includes(id); + const tips=arch?[[5,18],[10,id==='dog_rose'?12:9],[22,10],[28,id==='dog_rose'?22:18]]: + [[6,13+k%4],[14,6+k%3],[23,11+k%2],[27,20+k%5]]; + d.line(13,29,18,19,'bark',2); + tips.forEach(([x,y],i)=> { + d.line(15,28,x,y,id==='bilberry'?'leafDark':'bark').line(x,y,x+(i%2?3:-2),y-2,'barkLight'); + if(['blackthorn','hawthorn','blackberry','dog_rose'].includes(id)) d.line(x,y+5,x-3,y+3,'barkDark'); + if(dormant && !['holly','juniper'].includes(id)) return; + if(['elder','raspberry','dog_rose'].includes(id)) compound(d,x,y+8,id==='elder'?9:6,i%2?-1:1); + else if(id==='juniper') for(let j=0;j<3;j++) { + d.line(x,y+9-j*3,x-4,y+4-j*3,'pineGlow').line(x,y+9-j*3,x+4,y+5-j*3,'pine'); + d.line(x,y+9-j*3,x,y+3-j*3,'sage'); + } + else specimenLeaf(d,x,y+8,{holly:'thorn',hawthorn:'lobe',guelder_rose:'lobe',blackberry:'diamond', + blackthorn:'oval',bilberry:'oval'}[id],i%2?-1:1); + if(dormant) return; + if(id==='dog_rose') { if(i===1) flower(d,x,y,'rose',5,3); else d.oval(x,y+1,1,2,'rust'); } + else if(id==='elder' || id==='guelder_rose') { + for(let j=-2;j<=2;j++) d.line(x,y+3,x+j*2,y,'barkLight').oval(x+j*2,y,1,1,id==='elder'?'slate':'rust'); + } else if(id==='blackberry' || id==='raspberry') { + for(const [a,b] of [[0,0],[2,0],[1,2],[0,3]]) d.dot(x+a,y+b,id==='blackberry'?'slate':'wine'); + d.dot(x+1,y,'berryLight'); + } else d.oval(x,y,1.5,1.5,['holly','hawthorn'].includes(id)?'rust':id==='bilberry'?'slateBlue':'berry'); + }); +} +function botanicalGrass(d,id,dormant) { + const k=BOTANY_GROUPS.grasses.findIndex(r=>r[0]===id), stem=dormant?'strawDark':'leafDark'; + for(let i=0;i<7;i++) { + const x=5+i*3,tip=17+(i*3+k)%9; + d.line(16,29,x,tip,stem).line(16,29,x+1,tip+2,dormant?'straw':'sage'); + } + for(let i=0;i<3;i++) { + const x=9+i*7,y=5+(i*3+k)%7; + d.line(x,29,x-1,y,stem); + if(id==='sedge') { + // Three lit faces on an angular culm and a branching tussock panicle. + d.poly([[x-1,28],[x-2,y+5],[x,y+3],[x+1,28]],'sage').line(x,y+3,x,27,'leafLight'); + for(let j=0;j<4;j++) d.line(x,y+7-j*2,x+(j%2?4:-3),y+3-j,'strawDark',2); + } else if(id==='bulrush') d.rect(x-2,y,3,8,dormant?'earthLight':'bark').line(x-1,y-2,x-1,y,'straw'); + else if(id==='timothy' || id==='sweet_vernal_grass') { + const h=id==='timothy'?9:5; + d.oval(x-1,y+3,1.5,h/2,id==='timothy'?'sage':'straw').line(x-2,y+1,x-2,y+4,'strawLight'); + } else if(id==='soft_rush') { + d.line(x-1,y,x-1,y+5,'sage'); + for(let j=0;j<3;j++) d.line(x,y+8,x+2+j,y+6+j,'strawDark').dot(x+3+j,y+6+j,'barkLight'); + } else { + const wide=id==='tufted_hairgrass'?7:id==='common_reed'?5:4; + for(let j=0;j<5;j++) { + const s=(j%2?-1:1), xx=x+s*(wide-j/2), yy=y+2+j*2; + d.line(x-1,yy+3,xx,yy,'strawDark'); + if(id==='cocksfoot') d.oval(xx+1,yy,2,1.5,'sage'); + else if(id==='yorkshire_fog') d.oval(xx,yy,2,2,'rose').dot(xx-1,yy-1,'cream'); + else if(id==='common_reed') d.line(xx,yy,xx+s*2,yy-3,'barkLight').line(xx,yy,xx+s*2,yy+2,'straw'); + else d.line(xx,yy,xx+1,yy-2,id==='meadow_fescue'?'sage':'strawLight'); + } + } + if(id==='common_reed') d.poly([[x,24],[x+7,18],[x+5,23]],'sage').line(x,20,x-5,16,'leafLight'); + } +} +function botanicalHerb(d,id,dormant) { + if(['clover','nettle','heather','bluebell'].includes(id)) { + understory(d,id+(dormant?'_dormant':'')); + // Catalogue specimen includes diagnostic stolon/flower strings/scale sprays. + if(id==='clover') d.line(6,29,25,28,'leafDark'); + if(id==='nettle' && !dormant) for(let j=0;j<4;j++) d.line(19,12+j,23+j,15+j,'sage'); + if(id==='heather') d.line(3,28,9,24,'barkLight'); + if(id==='bluebell') d.line(4,29,7,18,'sage'); + return; + } + const k=BOTANY_GROUPS.herbs.findIndex(r=>r[0]===id), x=15+k%3; + d.line(x,29,x-1,7,'leafDark'); + if(id==='yarrow' || id==='meadowsweet') { + for(let y=19;y<28;y+=4) compound(d,x,y,id==='yarrow'?6:9,y%2?-1:1); + if(!dormant) for(let j=-3;j<=3;j++) { + const y=id==='yarrow'?9:6+Math.abs(j)*2; + d.line(x,17,x+j*3,y,'leafDark'); flower(d,x+j*3,y,'cream',4,id==='yarrow'?1:2); + } + } else if(id==='foxglove') { + for(const [a,b] of [[-5,28],[5,26],[-7,25],[7,29]]) specimenLeaf(d,x+a,b,'oval'); + if(!dormant) for(let j=0;j<5;j++) { + const y=5+j*4,w=2+j%2; + d.line(x-1,y,x+3,y+1,'sage').poly([[x+2,y],[x+2+w,y],[x+4+w,y+4],[x+2,y+4]],'rose') + .line(x+3,y+3,x+3+w,y+3,'mauve').dot(x+3,y+1,'wine'); + } + } else if(id==='wild_garlic') { + specimenLeaf(d,10,29,'lance'); specimenLeaf(d,21,28,'lance'); + d.poly([[11,28],[5,17],[8,14],[13,22]],'sage'); + if(!dormant) for(const [a,b] of [[-5,1],[-2,-3],[3,-3],[6,1],[1,4]]) { + d.line(x,14,x+a,8+b,'sage'); flower(d,x+a,8+b,'cream',6,2); + } + } else if(id==='cowslip') { + for(const [a,b] of [[-6,29],[-3,27],[4,28],[8,30]]) specimenLeaf(d,x+a,b,'oval'); + if(!dormant) for(let j=-2;j<=2;j++) d.line(x,7,x+j*3,8+Math.abs(j),'sage') + .oval(x+j*3,11+Math.abs(j),1.5,3,'ochre').dot(x+j*3,13+Math.abs(j),'gold'); + } else { // Anemone: one flower above a whorl of three divided leaves. + for(const s of [-1,0,1]) specimenLeaf(d,x+s*7,23+(s===0?2:0),'lobe',s||1); + if(!dormant) flower(d,x-1,8,'cream',6,4); + } + if(dormant) d.line(x-1,7,x+2,10,'straw'); +} +function botanicalFernMoss(d,id,dormant) { + const k=BOTANY_GROUPS.ferns_mosses.findIndex(r=>r[0]===id); + if(k<5) { + if(id==='harts_tongue') { + for(const [x,y,s] of [[7,28,-1],[13,29,1],[20,28,1],[25,29,-1]]) { + d.poly([[x,y],[x-3,y-11],[x-2,y-20],[x+1,y-22],[x+3,y-18],[x+2,y-9]],'leaf') + .line(x,y-2,x,y-19,'leafLight'); + for(let j=5;j<18;j+=3) d.line(x,y-j,x+s*2,y-j-1,'barkLight'); + } + } else { + const tips=id==='bracken'?[[5,10],[16,3],[27,12]]:id==='lady_fern'?[[6,13],[15,4],[25,9]]: + id==='male_fern'?[[5,17],[12,7],[20,5],[27,18]]:[[7,19],[15,9],[25,14]]; + for(const [i,[x,y]] of tips.entries()) { + d.line(16,29,x,y,'leafDark'); + const n=id==='lady_fern'?7:5; + for(let j=1;jr[0]===id); + if(['turkey_tail','oyster_fungus','birch_polypore'].includes(id)) { + d.line(7,28,8,9,'bark',3).line(8,24,25,26,'barkDark',2); + const tiers=id==='birch_polypore'?[[15,17,9,5]]:id==='turkey_tail'?[[15,23,8,4],[20,18,8,4],[13,12,7,3]]: + [[15,23,10,4],[20,17,8,4],[15,10,7,3]]; + for(const [x,y,rx,ry] of tiers) { + d.oval(x,y,rx,dormant?1:ry,id==='oyster_fungus'?'stone':'barkLight'); + if(!dormant) { + d.oval(x,y-1,rx-2,ry-1,id==='turkey_tail'?'cream':'birchGlow'); + if(id==='turkey_tail') d.oval(x,y-2,rx-4,1,'earth').line(x-4,y,x+4,y,'rust'); + else if(id==='oyster_fungus') for(let j=-4;j<5;j+=2) d.line(8,y+1,x+j,y+1,'slateShade'); + else d.line(x-5,y+3,x+5,y+3,'cream'); + } + } + } else { + const cluster=id==='honey_fungus'?[[8,21,5],[16,14,6],[24,20,5]]:[[16,13,k===5?5:k===2?10:9]]; + for(const [x,y,w] of cluster) { + d.line(x,29,x,y+3,k===4?'rose':'cream',k===2?4:3); + if(dormant) { + d.oval(x,y+9+k%3,w/2,1+k%2,'earthLight').line(x-3,29,x+4,28,'barkLight'); + if(id==='fly_agaric') d.oval(x,28,3,2,'birchGlow'); + if(id==='birch_bolete') for(let j=0;j<4;j++) d.dot(x+j%2,20+j*2,'barkDark'); + continue; + } + const color={fly_agaric:'rust',birch_bolete:'bark',bay_bolete:'earth',chanterelle:'ochre',wood_blewit:'rose', + shaggy_inkcap:'cream',honey_fungus:'straw'}[id]; + if(id==='shaggy_inkcap') { + d.oval(x,12,5,10,'cream').rect(x-5,18,11,3,'slate'); + for(let j=0;j<4;j++) d.line(x-3+j*2,6+j%2,x-4+j*2,15+j%3,'barkLight'); + d.line(x+4,20,x+4,25,'ink'); + } else if(id==='chanterelle') { + d.poly([[x-10,y-2],[x-6,y],[x-2,y-2],[x+3,y],[x+9,y-3],[x+7,y+3],[x+2,y+7],[x-2,y+7],[x-7,y+2]],color) + .line(x-6,y+1,x,y+8,'strawLight').line(x+5,y+1,x+1,y+8,'strawDark'); + } else { + d.oval(x,y,w,k===4?3:5,color).line(x-w+1,y+(k===4?2:3),x+w-1,y+(k===4?2:3),k===4?'berryLight':'woodLight'); + if(id==='fly_agaric') { + for(const [a,b] of [[-5,0],[-2,-3],[2,-2],[5,1]]) d.rect(x+a,y+b,2,1,'cream'); + d.line(x-3,21,x+4,21,'cream').oval(x,28,3,2,'birchGlow'); + } + if(id==='birch_bolete') for(let j=0;j<5;j++) d.dot(x+j%2,20+j*2,'barkDark'); + if(id==='honey_fungus') d.line(x-2,y+8,x+3,y+8,'wood'); + } + } + } +} +function botanicalAquatic(d,id,dormant) { + if(id==='yellow_waterlily' || id==='white_waterlily') { + // True floating round/oval notched pads, never horns or lotus seed pods. + for(const [x,y,rx,ry] of (id==='white_waterlily'?[[10,23,7,6],[23,16,6,5]]:[[10,23,8,5],[23,16,6,4]])) { + d.oval(x,y,rx,ry,'leafDark').oval(x-1,y-1,rx-1,ry-1,'sage'); + for(let j=0;j<5;j++) for(let a=0;a<=j/2;a++) d.dot(x+a,y+j,[0,0,0],0); + d.line(x-4,y-1,x-1,y-2,'leafLight'); + } + if(!dormant) { + if(id==='white_waterlily') flower(d,12,13,'cream',8,5); + else d.oval(13,13,3,3,'ochre').oval(13,12,2,1,'gold').line(13,16,13,20,'leaf'); + } + } else if(id==='duckweed') { + for(const [i,[x,y]] of [[5,10],[15,6],[25,13],[10,21],[23,24]].entries()) { + d.oval(x,y,3,2,'leaf').oval(x+3,y+2,2,2,'sage').dot(x-1,y,'leafLight'); + d.line(x,y+2,x+(i%2?1:-1),y+6,'willowShade'); + } + } else if(id==='hornwort' || id==='water_starwort') { + d.line(12,28,19,5,'leafDark'); + if(id==='hornwort') { + for(let j=0;j<5;j++) for(const s of [-1,1]) { + const x=13+j,y=25-j*4; + d.line(x,y,x+s*7,y-4,'sage').line(x+s*4,y-2,x+s*4,y-5,'leaf') + .line(x+s*7,y-4,x+s*8,y-7,'leafLight'); + } + } else { + for(let j=0;j<5;j++) d.line(13+j,26-j*4,7+j,24-j*4,'sage') + .line(13+j,26-j*4,21+j,25-j*4,'leaf'); + for(const [x,y] of [[15,7],[20,8],[18,12],[23,11]]) d.oval(x,y,2,3,'sage').dot(x,y-1,'leafLight'); + } + } else if(id==='pondweed') { + d.line(15,29,17,8,'barkLight'); + for(const [x,y] of [[7,23],[24,19],[10,12]]) d.line(16,y+5,x,y,'leafDark') + .oval(x,y,5,2,'leaf').line(x-3,y,x+3,y,'leafLight'); + if(!dormant) d.line(18,14,19,5,'leafDark').oval(19,5,1.5,4,'strawDark'); + } else if(id==='water_crowfoot') { + d.line(14,29,17,10,'leafDark'); + for(const [x,y] of [[6,23],[23,25],[10,16]]) { + d.line(15,26,x,y,'sage'); + for(const s of [-1,0,1]) d.line(x,y,x+s*4,y-4,'leafLight').line(x+s*4,y-4,x+s*5,y-6,'leaf'); + } + if(!dormant) flower(d,18,9,'cream',5,3); + } else { + const x=id==='brooklime'?14:17; + d.line(x,29,x,9,'leafDark'); + for(let j=0;j<3;j++) for(const s of [-1,1]) specimenLeaf(d,x+s*(4+j%2),28-j*5, + id==='water_mint'?'diamond':'oval',s); + if(!dormant) { + if(id==='water_mint') d.oval(x,8,4,3,'rose').line(x-2,7,x+2,7,'mauve').dot(x,9,'cream'); + else { + d.line(x,18,x+7,8,'sage'); + for(let j=0;j<4;j++) flower(d,x+6+(j%2?2:0),8+j*4,'slateGlow',5,1); + if(id==='water_forgetmenot') d.line(x+7,8,x+5,5,'sage').oval(x+4,5,1,1,'slateBlue'); + } + } + } +} +function botanicalLichen(d,id,dormant) { + const k=BOTANY_GROUPS.lichens.findIndex(r=>r[0]===id); + if(k<3) { + if(id==='old_mans_beard') { + d.line(5,5,27,8,'barkLight'); + for(let j=0;j<6;j++) { + const x=6+j*4; + d.line(x,6,x-2,16+j%3,'willow').line(x-2,16+j%3,x+1,26-j%2,'sage') + .line(x-1,13,x+3,20,'leafLight').line(x-1,19,x-4,25,'willow'); + } + } else for(let j=0;j<5;j++) { + const x=5+j*5,y=28-j%2*3; + d.line(16,29,x,y-8,'willow',id==='oakmoss'?2:1); + for(const s of [-1,1]) { + d.line(x,y-8,x+s*4,y-15,'leafLight',id==='oakmoss'?2:1); + d.line(x+s*3,y-13,x+s*6,y-16-j%3,'sage').line(x+s*3,y-13,x+s*2,y-19,'cream'); + if(id==='reindeer_lichen') d.line(x+s*2,y-17,x+s*5,y-21,'willow'); + } + } + } else if(id==='cup_lichen' || id==='pixie_cup') { + for(const [i,[x,y]] of [[7,21],[15,13],[24,18]].entries()) { + const w=id==='pixie_cup'?4:3; + d.line(x,29,x,y+3,'willow',id==='pixie_cup'?3:2) + .poly([[x-w,y],[x+w,y],[x+1,y+5],[x-1,y+5]],'sage') + .line(x-w,y,x+w,y,'cream').line(x-w+1,y+1,x+w-1,y+1,'leafDark'); + if(id==='pixie_cup') for(let j=0;j<3;j++) d.dot(x-1+j%2,y+6+j*2,'cream'); + d.oval(x+(i%2?3:-2),28,3,1,'willow'); + } + } else { + const crust=id==='lichen', color=id==='sunburst_lichen'?'ochre':id==='lungwort_lichen'?'leaf':'willow'; + for(const [i,[x,y]] of [[7,15],[15,10],[24,16],[11,24],[22,25]].entries()) { + const r=crust?4:id==='dog_lichen'?6:id==='sunburst_lichen'?4:5; + d.poly([[x-r,y],[x-2,y-r],[x+2,y-r+1],[x+r,y-1],[x+r-1,y+3],[x+1,y+r-1],[x-3,y+3]], + crust?'barkDark':'leafDark'); + d.oval(x,y,r-1,crust?2:3,color); + if(crust) d.line(x-2,y,x+2,y-1,'sage').dot(x+2,y+1,'straw'); + else if(id==='sunburst_lichen') d.oval(x,y,2,2,'copper').dot(x,y,'ochre'); + else if(id==='dog_lichen') d.line(x-3,y+2,x,y+3,'earth').line(x,y+3,x-1,y+5,'cream'); + else d.line(x-3,y,x+2,y-2,'leafLight').line(x,y-1,x+2,y+2,'leafLight') + .line(x+2,y+2,x-1,y+3,id==='hammered_shield'?'cream':'sage'); + if(id==='lungwort_lichen') d.poly([[x+2,y+1],[x+5,y+4],[x+3,y+7],[x,y+5]],'leaf').line(x+2,y+2,x+2,y+5,'sage'); + if(id==='hammered_shield') d.rect(x-4,y-3,3,2,'willow').dot(x-4,y-3,'cream'); + if(dormant && i===2) d.dot(x,y,'barkLight'); + } + } +} +function botanySprite(record,dormant=false) { + const d=new Drawing(); + ({trees:botanicalTree,shrubs:botanicalShrub,grasses:botanicalGrass,herbs:botanicalHerb, + ferns_mosses:botanicalFernMoss,fungi:botanicalFungus,aquatic:botanicalAquatic,lichens:botanicalLichen})[record.group](d,record.id,dormant); + // Cutouts have one-pixel journal padding. Inactive specimens lose bloom/fruit + // above and settle to a reduced dry silhouette, not a tinted active sprite. + const out=new Drawing(); + for(let y=1;y<31;y++) for(let x=1;x<31;x++) { + const p=d.data.subarray((y*32+x)*4,(y*32+x+1)*4); + if(!p[3]) continue; + if(dormant) { + const luminance=p[0]+p[1]+p[2]; + const dry=luminance>470?'strawLight':luminance>350 || (p[1]>p[0] && luminance>310)?'straw':'barkLight'; + out.dot(16+Math.round((x-16)*.86),29-Math.round((29-y)*.72),dry); + } else out.dot(x,y,[...p.subarray(0,3)]); + } + return out; +} + +export function worldArt() { + const images=new Map(); + for(const name of WORLD_NAMES) images.set(`hearth_${name}.png`,worldTile(name)); + for(const name of ITEM_NAMES) images.set(`hearth_item_${name}.png`,itemTile(name)); + for(const name of ['hotbar','selected','crosshair']) images.set(`hearth_${name}.png`,uiTile(name)); + for(const name of ANIMAL_NAMES) images.set(`hearth_${name}.png`,animalTexture(name)); + for(const r of BOTANY_CATALOGUE) for(const dormant of [false,true]) + images.set(`hearth_botany_${r.id}${dormant?'_dormant':''}.png`,botanySprite(r,dormant)); + return images; +} + +// Sorted filename order is also the contact sheet's legend: zero-based row-major, +// 16 columns, unscaled 32px cells. Alpha composited over journal checker paper. +export function contactSheet(images=worldArt()) { + if(!(images instanceof Map) || images.size<1 || images.size>512 || [...images.values()].some(image=> + image?.width!==32 || image?.height!==32 || !(image.data instanceof Uint8Array) || image.data.length!==4096)) + throw new Error('Contact sheet requires 1..512 RGBA 32px tiles.'); + const columns=16, width=columns*32, height=Math.ceil(images.size/columns)*32; + const data=new Uint8Array(width*height*4); + for(let y=0;yString(a)String(b)?1:0).forEach(([,image],i)=> { + for(let y=0;y<32;y++) for(let x=0;x<32;x++) { + const from=(y*32+x)*4, to=((Math.floor(i/columns)*32+y)*width+(i%columns)*32+x)*4; + const alpha=image.data[from+3]/255; + for(let c=0;c<3;c++) data[to+c]=Math.round(image.data[from+c]*alpha+data[to+c]*(1-alpha)); + } + }); + return {width,height,data}; +} + +// Compatibility swatches are intentionally quiet clustered soil, not neon/hash +// noise. Named materials above are the integration target; no Lua parsing needed. +export function legacyPixel(hex,x,y) { + const rgb=[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16)); + const shade=(y>=19 && y<=22)?-8:((x>=5 && x<=10 && y>=6 && y<=8)?8:0); + return [...rgb.map((v,i)=>Math.round(v*.35+PALETTE.earth[i]*.65)+shade),255]; +} diff --git a/settingtypes.txt b/settingtypes.txt index 9bd0721..8e8ba20 100755 --- a/settingtypes.txt +++ b/settingtypes.txt @@ -1,5 +1,7 @@ # Coordinates stay local. Servers must choose one shared location in configuration. +# No static default: clock.lua selects local for singleplayer, production otherwise. +hearth_environment (Environment: local/staging/production; unset defaults by engine mode) string hearth_latitude (World reference latitude; leave unset for local setup) string hearth_longitude (World reference longitude; leave unset for local setup) string -hearth_preview_weather (Private playtest weather) enum auto auto,clear,rain -hearth_preview_date (Private playtest UTC timestamp; empty follows real time) string +hearth_preview_weather (Local/staging fixture weather; production requires auto) enum auto auto,clear,rain +hearth_preview_date (Local/staging integer UTC timestamp 946684800..4102444800; empty follows real time) string diff --git a/tests/access_spec.lua b/tests/access_spec.lua new file mode 100755 index 0000000..f3333dd --- /dev/null +++ b/tests/access_spec.lua @@ -0,0 +1,55 @@ +-- Pure claim-role authorization matrix; no engine globals or storage. +local factory = dofile(assert(arg[1], "access.lua path required")) +local claims = {owner = {x = 0, y = 0, z = 0}, other = {x = 40, y = 0, z = 0}} +local memberships = {owner = {resident = "resident", keeper = "storekeeper", stale = "invented"}} +local policy = factory(claims, memberships) +local p = {x = 0, y = 0, z = 0} +local passed = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)) + passed = passed + 1 + print("ok " .. passed .. " - " .. name) +end + +test("owner retains full authority and outside land remains usable", function() + assert(policy.owner_at(p) == "owner" and policy.role_at(p, "owner") == "owner") + for _, action in ipairs({"build", "dig", "use", "storage", "arrival", "combat"}) do + assert(policy.allowed(p, "owner", action)) + assert(policy.allowed({x = 20, y = 0, z = 0}, "outsider", action)) + end +end) +test("resident can build and use but cannot attack or arrive in another home", function() + assert(policy.allowed(p, "resident", "build")) + assert(policy.allowed(p, "resident", "dig")) + assert(policy.allowed(p, "resident", "use")) + assert(policy.allowed(p, "resident", "storage")) + assert(not policy.allowed(p, "resident", "arrival")) + assert(not policy.allowed(p, "resident", "combat")) +end) +test("storekeeper may use only protected storage", function() + assert(policy.allowed(p, "keeper", "storage")) + for _, action in ipairs({"build", "dig", "use", "arrival", "combat"}) do + assert(not policy.allowed(p, "keeper", action), action) + end +end) +test("unknown roles, invalid actions, and ambiguous overlaps fail closed", function() + for _, action in ipairs({"build", "dig", "use", "storage", "arrival", "combat"}) do + assert(not policy.allowed(p, "stale", action)) + end + assert(not policy.allowed(p, "resident", "manage")) + claims.overlap = {x = 10, y = 0, z = 0} + assert(policy.owner_at(p) == nil and not policy.allowed(p, "owner", "build")) + assert(not policy.allowed({}, "owner", "build")) + claims.overlap = nil +end) +test("claim release and reassignment do not transfer the former owner's membership", function() + claims.owner = nil + assert(policy.allowed(p, "resident", "build"), "released land is no longer reserved by stale membership") + assert(policy.allowed(p, "outsider", "build")) + memberships.owner = nil + claims.owner = {x = -30, y = 0, z = 0} + local reassigned = {x = -30, y = 0, z = 0} + assert(policy.owner_at(reassigned) == "owner" and not policy.allowed(reassigned, "resident", "build")) +end) +print("PASS " .. passed .. " claim-role authorization tests") diff --git a/tests/assets.test.mjs b/tests/assets.test.mjs new file mode 100755 index 0000000..6835324 --- /dev/null +++ b/tests/assets.test.mjs @@ -0,0 +1,478 @@ +// Asset-only checks: no engine launch and no persistent worlds. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { realpath } from 'node:fs'; +import { promisify } from 'node:util'; +import os from 'node:os'; +import path from 'node:path'; +import { inflateSync } from 'node:zlib'; +import { generateAssets } from '../scripts/assets.mjs'; +import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterLayer, characterMesh, compositeCharacter } from '../scripts/character-art.mjs'; +import { ANIMATIONS, ATLAS, PLAYER_HEIGHT, playerB3D, SAMPLE_RATE, SOUND_HASHES, SOUND_PROFILES, soundWAV, soundOGG, loadVorbisCodec, TOOL_NAMES, toolOBJ } from '../scripts/media.mjs'; +import { inventory } from '../scripts/files.mjs'; +import { packageGame, SOURCE, verifyPackage } from '../scripts/package.mjs'; + +async function temporary(t) { + const root = await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()), 'hearth-assets-')); + t.after(() => fs.rm(root, {recursive: true, force: true})); + return root; +} +async function sourceTree(root) { + const source = path.join(root, 'source'); + await fs.mkdir(path.join(source, 'mods/hearth/nested'), {recursive: true}); + await fs.mkdir(path.join(source, 'mods/another'), {recursive: true}); + await fs.mkdir(path.join(source, 'scripts')); + for (const [file, text] of Object.entries({ + 'mods/hearth/players.lua': '-- skin #A1B2C3', + 'mods/hearth/nested/feedback.lua': '-- new feedback #01abef', + 'mods/another/init.lua': '-- another mod #39D1A2', + 'scripts/ignored.lua': '-- not mod source #fedcba', + 'mods/hearth/nested/ignored.txt': '#123abc', + })) await fs.writeFile(path.join(source, file), text); + return source; +} + +// Independent bounded reader: never trust the serializer's own chunk helpers. +function parseB3D(bytes) { + let vertexData, triangleData, animation; + const bones = new Map(), chunks = []; + function cstring(offset, end) { + const zero = bytes.indexOf(0, offset); + assert.ok(zero >= offset && zero < end, 'unterminated chunk string'); + return [bytes.toString('ascii', offset, zero), zero+1]; + } + function floats(offset, count, end) { + assert.ok(offset + count*4 <= end, 'float record escapes chunk'); + const result = Array.from({length: count}, (_, i) => bytes.readFloatLE(offset+i*4)); + assert.ok(result.every(Number.isFinite)); + return result; + } + function walk(start, end, parent = null) { + let offset = start; + while (offset < end) { + assert.ok(offset+8 <= end, 'truncated chunk header'); + const tag = bytes.toString('ascii', offset, offset+4), length = bytes.readInt32LE(offset+4); + const data = offset+8, limit = data+length; + assert.ok(length >= 0 && limit <= end, `${tag} escapes parent bounds`); + chunks.push(tag); + if (tag === 'BB3D') { + assert.equal(bytes.readInt32LE(data), 1); walk(data+4, limit); + } else if (tag === 'NODE') { + const [name, next] = cstring(data, limit); + assert.ok(!bones.has(name)); + const transform = floats(next, 10, limit); + bones.set(name, {parent, position: transform.slice(0,3), transform, weights: [], keys: []}); + walk(next+40, limit, name); + } else if (tag === 'MESH') { + assert.equal(bytes.readInt32LE(data), 0); walk(data+4, limit, parent); + } else if (tag === 'VRTS') { + assert.deepEqual([0,4,8].map(n => bytes.readInt32LE(data+n)), [1,1,2]); + assert.equal((length-12)%32, 0); + vertexData = Array.from({length: (length-12)/32}, (_, i) => floats(data+12+i*32, 8, limit)); + } else if (tag === 'TRIS') { + assert.equal(bytes.readInt32LE(data), 0); assert.equal((length-4)%12, 0); + triangleData = Array.from({length: (length-4)/12}, (_, i) => + [0,4,8].map(n => bytes.readInt32LE(data+4+i*12+n))); + } else if (tag === 'BONE') { + assert.equal(length%8, 0); + for (let p = data; p < limit; p += 8) bones.get(parent).weights.push([bytes.readInt32LE(p), ...floats(p+4,1,limit)]); + } else if (tag === 'KEYS') { + assert.equal(bytes.readInt32LE(data), 5); assert.equal((length-4)%32, 0); + for (let p = data+4; p < limit; p += 32) bones.get(parent).keys.push({ + frame: bytes.readInt32LE(p)-1, position: floats(p+4,3,limit), rotation: floats(p+16,4,limit), + }); + } else if (tag === 'ANIM') { + assert.equal(length, 12); + animation = [bytes.readInt32LE(data), bytes.readInt32LE(data+4), ...floats(data+8,1,limit)]; + } else if (tag === 'TEXS') { + const [texture, next] = cstring(data, limit); + assert.equal(texture, 'hearth_person.png'); assert.equal(next+28, limit); + assert.deepEqual([bytes.readInt32LE(next),bytes.readInt32LE(next+4)], [1,2]); + assert.deepEqual(floats(next+8,5,limit), [0,0,1,1,0]); + } else if (tag === 'BRUS') { + assert.equal(bytes.readInt32LE(data), 1); + const [, next] = cstring(data+4, limit); + assert.equal(next+32, limit); assert.deepEqual(floats(next,5,limit), [1,1,1,1,0]); + assert.deepEqual([20,24,28].map(n => bytes.readInt32LE(next+n)), [1,0,0]); + } else assert.fail(`unexpected chunk ${tag}`); + offset = limit; + } + assert.equal(offset, end); + } + assert.equal(bytes.toString('ascii',0,4), 'BB3D'); + assert.equal(bytes.readInt32LE(4), bytes.length-8); + walk(0, bytes.length); + return {vertices: vertexData, triangles: triangleData, bones, animation, chunks}; +} + +function crc32(bytes) { + let value = 0xffffffff; + for (const b of bytes) { + value ^= b; + for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + } + return (value ^ 0xffffffff) >>> 0; +} +function parsePNG(bytes) { + assert.deepEqual([...bytes.subarray(0,8)], [137,80,78,71,13,10,26,10]); + let width, height, offset = 8, ended = false; + const compressed = []; + while (offset < bytes.length) { + assert.ok(offset+12 <= bytes.length); + const size = bytes.readUInt32BE(offset), end = offset+8+size; + assert.ok(end+4 <= bytes.length); + const tag = bytes.toString('ascii',offset+4,offset+8), data = bytes.subarray(offset+8,end); + assert.equal(crc32(bytes.subarray(offset+4,end)), bytes.readUInt32BE(end)); + if (tag === 'IHDR') { + assert.equal(size,13); width = data.readUInt32BE(0); height = data.readUInt32BE(4); + assert.deepEqual([...data.subarray(8)], [8,6,0,0,0]); + } else if (tag === 'IDAT') compressed.push(data); + else { assert.equal(tag,'IEND'); assert.equal(size,0); ended = true; } + offset = end+4; + } + assert.ok(ended); assert.equal(offset,bytes.length); + const raw = inflateSync(Buffer.concat(compressed), {maxOutputLength: 1024*1024}); + assert.equal(raw.length,height*(width*4+1)); + for (let y = 0; y < height; y++) assert.equal(raw[y*(width*4+1)],0); + return {width,height,pixel: (x,y) => [...raw.subarray(y*(width*4+1)+1+x*4,y*(width*4+1)+5+x*4)]}; +} + +for (const profile of CHARACTER_OPTIONS.bodies) test(`B3D ${profile} has bounded chunks, one textured mesh, valid triangles/normals/UVs and normalized bone weights`, () => { + const model = parseB3D(playerB3D(profile)); + assert.deepEqual(model.animation,[0,100,24]); + assert.equal(model.chunks.filter(c => c==='MESH').length,1); + assert.equal(model.vertices.length,240); assert.equal(model.triangles.length,120); + assert.equal(Math.max(...model.vertices.map(v => v[1])),PLAYER_HEIGHT); + assert.equal(Math.min(...model.vertices.map(v => v[1])),0); + assert.equal(PLAYER_HEIGHT/10,1.75); + const totals = Array(model.vertices.length).fill(0), used = new Set(); + for (const triangle of model.triangles) { + assert.equal(new Set(triangle).size,3); + triangle.forEach(id => { assert.ok(id>=0 && id model.vertices[id]); + const u = b.slice(0,3).map((v,i)=>v-a[i]), v = c.slice(0,3).map((v,i)=>v-a[i]); + const normal = [u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]; + assert.ok(normal.reduce((sum,n,i)=>sum+n*a[3+i],0)>0,'outward winding'); + } + assert.equal(used.size,model.vertices.length); + for (const vertex of model.vertices) { + assert.ok(Math.abs(Math.hypot(...vertex.slice(3,6))-1)<1e-6); + assert.ok(vertex.slice(6).every(n => n>0 && n<1)); + } + for (const bone of model.bones.values()) for (const [id, weight] of bone.weights) { + assert.ok(id>=0 && id n===1)); + assert.equal(model.bones.get('Hand_Right').parent,'Arm_Right'); + assert.equal(model.bones.get('Hand_Left').parent,'Arm_Left'); + assert.ok(model.bones.get('Hand_Right').weights.length>0); +}); + +test('all body profiles share exact rig, rigid assignments, UVs and 100 keys; only torso and limb widths change', () => { + const balanced = parseB3D(playerB3D()), widths=[]; + assert.deepEqual(playerB3D(),playerB3D('balanced')); + assert.deepEqual([...balanced.bones].map(([name,b])=>[name,b.parent,b.position]),[ + ['Root',null,[0,0,0]],['Body','Root',[0,8,0]],['Head','Body',[0,5.5,0]], + ['Arm_Right','Body',[-Math.fround(3.1),5,0]],['Hand_Right','Arm_Right',[0,-Math.fround(4.8),0]], + ['Arm_Left','Body',[Math.fround(3.1),5,0]],['Hand_Left','Arm_Left',[0,-Math.fround(4.8),0]], + ['Leg_Right','Root',[-Math.fround(1.15),8,0]],['Leg_Left','Root',[Math.fround(1.15),8,0]], + ]); + const headTiles=['hair','face','skin','hair','side','side']; + for (let face=0;face<6;face++) for (let corner=0;corner<4;corner++) { + const vertex=balanced.vertices[24+face*4+corner]; + assert.deepEqual(vertex.slice(6).map(n=>Math.floor(n*8)),ATLAS[headTiles[face]],'unchanged semantic head UVs'); + } + for (const profile of CHARACTER_OPTIONS.bodies) { + const model=parseB3D(playerB3D(profile)); + assert.deepEqual(model.bones,balanced.bones,'bind transforms, hands, weights and all animation keys'); + assert.deepEqual(model.triangles,balanced.triangles); + assert.deepEqual(model.vertices.map(v=>v.slice(6)),balanced.vertices.map(v=>v.slice(6))); + assert.deepEqual(model.vertices.map(v=>v[1]),balanced.vertices.map(v=>v[1])); + for (const name of ['Head','Hand_Right','Hand_Left']) for (const [id] of model.bones.get(name).weights) + assert.deepEqual(model.vertices[id],balanced.vertices[id],name+' remains exactly aligned'); + const torso=model.bones.get('Body').weights.map(([id])=>model.vertices[id]); + widths.push(Math.max(...torso.map(v=>v[0]))); + assert.ok(Math.max(...torso.filter(v=>v[1]===8).map(v=>v[0]))balanced.vertices[id][0]))-1.6)<1e-6); + for (const bad of ['toString','../broad','BALANCED',null,{},0]) assert.throws(()=>playerB3D(bad)); +}); + +test('B3D keys use engine frames 0..99, all six clips move weighted bones, loops close, and root motion is absent', () => { + const {bones} = parseB3D(playerB3D()); + assert.deepEqual(ANIMATIONS, {idle:[0,19,12],walk:[20,39,24],run:[40,59,32],strike:[60,69,28],guard:[70,79,12],swim:[80,99,20]}); + for (const bone of bones.values()) { + assert.equal(bone.keys.length,100); + bone.keys.forEach((key,i) => { + assert.equal(key.frame,i); assert.ok(Math.abs(Math.hypot(...key.rotation)-1)<1e-6); + }); + } + for (const [clip,[start,end]] of Object.entries(ANIMATIONS)) { + assert.ok([...bones.values()].some(b => b.weights.length && + b.keys.slice(start+1,end).some(k => JSON.stringify(k.rotation)!==JSON.stringify(b.keys[start].rotation) + || k.position.some((v,i)=>Math.abs(v-b.keys[start].position[i])>1e-5))),`${clip} must animate`); + if (clip!=='strike') for (const bone of bones.values()) { + [...bone.keys[start].position,...bone.keys[start].rotation].forEach((n,i) => + assert.ok(Math.abs(n-[...bone.keys[end].position,...bone.keys[end].rotation][i])<1e-5,`${clip} seam`)); + } + } + assert.ok(bones.get('Root').keys.every(k => k.position.every(n=>n===0))); + const arm = bones.get('Arm_Right'); + assert.ok(Math.abs(arm.keys[64].rotation[1])>.7,'strike is substantial, not a static placeholder'); + assert.ok(Math.abs(arm.keys[70].rotation[1])>.4,'guard raises the arm'); + assert.deepEqual(playerB3D(),playerB3D()); +}); + +// Independent Ogg page/packet reader: verify framing, lacing, CRC, sequence, +// Vorbis headers, granule length and EOS; magic bytes alone cannot pass. +function parseOgg(bytes) { + assert.ok(bytes.length > 100 && bytes.length <= 65536); + let offset = 0, sequence = 0, serial, ended = false, granule = 0n; + const packets = [], pieces = []; + while (offset < bytes.length) { + assert.ok(!ended && offset + 27 <= bytes.length, 'page after EOS or truncated header'); + assert.equal(bytes.toString('ascii',offset,offset+4),'OggS'); + assert.equal(bytes[offset+4],0); + const flags = bytes[offset+5], segments = bytes[offset+26]; + assert.equal(flags & ~7,0); + assert.equal(Boolean(flags & 1),pieces.length>0,'packet continuation'); + assert.equal(Boolean(flags & 2),sequence===0,'BOS only on first page'); + assert.ok(offset+27+segments<=bytes.length); + const lacing = bytes.subarray(offset+27,offset+27+segments); + const end = offset+27+segments+lacing.reduce((a,b)=>a+b,0); + assert.ok(end<=bytes.length,'page body within bounds'); + const page = Buffer.from(bytes.subarray(offset,end)), storedCRC = page.readUInt32LE(22); + page.writeUInt32LE(0,22); + let crc = 0; + for (const byte of page) { + crc ^= byte << 24; + for (let bit=0;bit<8;bit++) crc = ((crc<<1) ^ ((crc & 0x80000000) ? 0x04c11db7 : 0)) >>> 0; + } + assert.equal(crc,storedCRC,'Ogg page CRC'); + serial ??= bytes.readUInt32LE(offset+14); + assert.equal(bytes.readUInt32LE(offset+14),serial,'single logical stream'); + assert.equal(bytes.readUInt32LE(offset+18),sequence++,'contiguous page sequence'); + const position = bytes.readBigUInt64LE(offset+6); + if (position !== 0xffffffffffffffffn) { assert.ok(position>=granule); granule=position; } + let start = offset+27+segments; + for (const size of lacing) { + pieces.push(bytes.subarray(start,start+size)); start+=size; + if (size<255) { packets.push(Buffer.concat(pieces)); pieces.length=0; } + } + ended = Boolean(flags & 4); offset=end; + } + assert.ok(ended && pieces.length===0 && packets.length>3,'complete stream with audio packets'); + for (const [i,type] of [1,3,5].entries()) { + assert.equal(packets[i][0],type); + assert.equal(packets[i].toString('ascii',1,7),'vorbis'); + } + const identification = packets[0]; + assert.equal(identification.length,30); assert.equal(identification.readUInt32LE(7),0); + assert.equal(identification[11],1); assert.equal(identification.readUInt32LE(12),SAMPLE_RATE); + assert.equal(identification[29],1); + const small = identification[28] & 15, large = identification[28] >>> 4; + assert.ok(small>=6 && large>=small && large<=13); + const comments = packets[1], vendorLength = comments.readUInt32LE(7); + let cursor = 11+vendorLength; + assert.ok(cursor+4p.length>0 && (p[0] & 1)===0),'Vorbis audio packets'); + return {granule:Number(granule),blockSize:2**large}; +} + +test('original Ogg Vorbis sources are hash-pinned, complete mono 22050Hz streams; synthesis and optional full decode stay bounded', async t => { + assert.equal(Object.keys(SOUND_PROFILES).length,15); + assert.deepEqual(Object.keys(SOUND_HASHES).sort(),Object.keys(SOUND_PROFILES).sort()); + const codec = process.env.HEARTH_FFMPEG_CORE ? await loadVorbisCodec(process.env.HEARTH_FFMPEG_CORE) : null; + if (!codec) t.diagnostic('Codec decode/regeneration NOT run: set HEARTH_FFMPEG_CORE to the pinned optional tool. Ogg framing/CRC/hash checks still run.'); + const unique = new Set(); + for (const [name,[duration]] of Object.entries(SOUND_PROFILES)) { + assert.match(name,/^[a-z0-9_]+$/,'engine-supported unnumbered sound name'); + const ogg = await soundOGG(name), {granule,blockSize} = parseOgg(ogg); + assert.deepEqual(ogg,await soundOGG(name)); unique.add(ogg.toString('base64')); + const bytes = soundWAV(name), count = Math.round(duration*SAMPLE_RATE); + assert.equal(granule,count,'EOS granule equals original duration'); + if (codec) { + assert.deepEqual(codec.encode(bytes),ogg,'pinned regeneration is byte-identical'); + const pcm = codec.decode(ogg); + assert.equal(pcm.length%2,0); + // Vorbis overlap/padding differs from source PCM; EOS is checked exactly + // above. Decode the entire stream, not just its identification header. + assert.ok(Math.abs(pcm.length/2-count)<=blockSize,'bounded decoded overlap/padding'); + const decoded = Array.from({length:pcm.length/2},(_,i)=>pcm.readInt16LE(i*2)); + assert.ok(Math.max(...decoded.map(Math.abs))>300,'decoded audio is non-silent'); + assert.ok(decoded.every(n=>Math.abs(n)<32767),'no decoded clipping'); + assert.ok(Math.abs(decoded.reduce((a,b)=>a+b,0)/decoded.length)<150,'decoded DC offset'); + } + assert.deepEqual(bytes,soundWAV(name)); + assert.equal(bytes.toString('ascii',0,4),'RIFF'); assert.equal(bytes.readUInt32LE(4),bytes.length-8); + assert.equal(bytes.toString('ascii',8,16),'WAVEfmt '); assert.equal(bytes.readUInt32LE(16),16); + assert.equal(bytes.readUInt16LE(20),1); assert.equal(bytes.readUInt16LE(22),1); + assert.equal(bytes.readUInt32LE(24),22050); assert.equal(bytes.readUInt32LE(28),44100); + assert.equal(bytes.readUInt16LE(32),2); assert.equal(bytes.readUInt16LE(34),16); + assert.equal(bytes.toString('ascii',36,40),'data'); assert.equal(bytes.readUInt32LE(40),count*2); + assert.equal(bytes.length,44+count*2); assert.ok(duration>=.08 && duration<=.5); + const samples = Array.from({length:count},(_,i)=>bytes.readInt16LE(44+i*2)); + assert.equal(samples[0],0); assert.equal(samples.at(-1),0); + assert.ok(Math.max(...samples.map(Math.abs))>300); + assert.ok(samples.every(n=>Math.abs(n)<32767)); + assert.ok(Math.abs(samples.reduce((a,b)=>a+b,0)/count)<150,'bounded DC offset'); + } + assert.equal(unique.size,15); assert.throws(()=>soundWAV('../anything')); + assert.throws(()=>soundWAV('toString')); + await assert.rejects(soundOGG('../anything')); await assert.rejects(soundOGG('toString')); + const valid = await soundOGG('hearth_block'); + assert.throws(()=>parseOgg(valid.subarray(0,valid.length-1)),'truncation rejected'); + const corrupt = Buffer.from(valid); corrupt[corrupt.length-1]^=1; + assert.throws(()=>parseOgg(corrupt),/CRC/); + assert.throws(()=>parseOgg(soundWAV('hearth_block')),'renamed WAV rejected'); + if (codec) { + assert.throws(()=>codec.decode(valid.subarray(0,80)),/conversion failed/,'decoder rejects incomplete setup'); + t.diagnostic('All 15 sounds fully decoded and regenerated byte-identically with hash-verified @ffmpeg/core@0.12.10.'); + } +}); + +test('held tool meshes have deterministic indexed geometry, grip origins and atlas UVs', () => { + const unique = new Set(); + for (const name of TOOL_NAMES) { + const obj = toolOBJ(name); assert.equal(obj,toolOBJ(name)); unique.add(obj); + const vertices = [], coords = [], faces = []; + for (const line of obj.split('\n')) { + if (line.startsWith('v ')) vertices.push(line.slice(2).split(' ').map(Number)); + if (line.startsWith('vt ')) coords.push(line.slice(3).split(' ').map(Number)); + if (line.startsWith('f ')) faces.push(line.slice(2).split(' ')); + } + assert.ok(vertices.length>=48); assert.equal(coords.length,vertices.length); + assert.ok(vertices.flat().every(Number.isFinite)); + assert.ok(coords.flat().every(n=>Number.isFinite(n) && n>0 && n<1)); + assert.equal(Math.min(...vertices.map(v=>v[1])),-1); + assert.ok(Math.max(...vertices.map(v=>v[1]))>3); + for (const face of faces) { + assert.equal(face.length,3); + for (const item of face) for (const id of item.split('/').map(Number)) assert.ok(id>=1 && id<=vertices.length); + } + } + assert.equal(unique.size,4); assert.throws(()=>toolOBJ('../knife')); +}); + +test('all mod Lua colors are scanned recursively; scripts and non-Lua are not inputs; PNG icons retain crisp alpha and metal/handle colors', async t => { + const root = await temporary(t), source = await sourceTree(root), destination = path.join(root,'output'); + await generateAssets(destination,source); + const textures = path.join(destination,'mods/hearth/textures'), names = await fs.readdir(textures); + for (const color of ['a1b2c3','01abef','39d1a2']) assert.ok(names.includes(`hearth_${color}.png`)); + for (const color of ['fedcba','123abc']) assert.ok(!names.includes(`hearth_${color}.png`)); + for (const name of TOOL_NAMES) { + const image = parsePNG(await fs.readFile(path.join(textures,`hearth_icon_${name}.png`))); + assert.equal(image.width,32); assert.equal(image.height,32); + let opaque = 0, metal = 0, wood = 0; + for (let y=0;y<32;y++) for (let x=0;x<32;x++) { + const [r,g,b,a] = image.pixel(x,y); assert.ok(a===0 || a===255); + if (a) { opaque++; if (g>r && b>r) metal++; if (r>g && g>b) wood++; } + else assert.deepEqual([r,g,b],[0,0,0]); + } + assert.ok(opaque>35 && opaque<400); assert.ok(metal>5); assert.ok(wood>5); + assert.equal(image.pixel(0,0)[3],0); assert.equal(image.pixel(31,31)[3],0); + } + const skin = parsePNG(await fs.readFile(path.join(textures,'hearth_person.png'))); + assert.equal(skin.width,64); assert.equal(skin.height,64); + assert.ok(skin.pixel(2,3)[0]<60,'visible eyes'); assert.ok(skin.pixel(3,4)[0]>150,'skin'); + assert.notDeepEqual(skin.pixel(2,3),skin.pixel(3,4)); + const tunic = ATLAS.tunic.map(n=>n*8+3); + assert.ok(skin.pixel(...tunic)[1]>skin.pixel(...tunic)[0],'distinct clothes'); + for (let y=0;y<64;y++) for (let x=0;x<64;x++) assert.equal(skin.pixel(x,y)[3],255); + const expected=compositeCharacter(); + for (let y=0;y<64;y++) for (let x=0;x<64;x++) + assert.deepEqual(skin.pixel(x,y),[...expected.data.subarray((y*64+x)*4,(y*64+x+1)*4)]); + const manifest=JSON.parse(await fs.readFile(path.join(destination,'character-art.json'),'utf8')); + assert.deepEqual(manifest.images,CHARACTER_ART_MANIFEST); + assert.deepEqual(manifest.options,CHARACTER_OPTIONS); + assert.deepEqual(manifest.meshes,CHARACTER_OPTIONS.bodies.map(body=>({body,filename:characterMesh(body)}))); + assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,170); + for (const {filename} of CHARACTER_ART_MANIFEST) { + const decoded=parsePNG(await fs.readFile(path.join(textures,filename))), layer=characterLayer(filename); + assert.equal(decoded.width,64); assert.equal(decoded.height,64); + for (let y=0;y<64;y++) for (let x=0;x<64;x++) + assert.deepEqual(decoded.pixel(x,y),[...layer.data.subarray((y*64+x)*4,(y*64+x+1)*4)],filename); + } + const portraits=parsePNG(await fs.readFile(path.join(textures,'hearth_character_contact_sheet.png'))); + assert.equal(portraits.width,512); assert.equal(portraits.height,64); + for (const body of CHARACTER_OPTIONS.bodies) + assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/models',characterMesh(body))),playerB3D(body)); +}); + +test('real-source assets regenerate byte-identically including B3D, tools, textures and sounds', async t => { + const root = await temporary(t), a = path.join(root,'a'), b = path.join(root,'b'); + await generateAssets(a,SOURCE); await generateAssets(b,SOURCE); + const first = await inventory(a); assert.deepEqual(first,await inventory(b)); + await fs.writeFile(path.join(a,'mods/hearth/sounds/hearth_block.wav'),soundWAV('hearth_block')); + await generateAssets(a,SOURCE); assert.deepEqual(first,await inventory(a)); + for (const name of ['models/hearth_person.b3d','textures/hearth_person.png', + ...TOOL_NAMES.map(n=>`models/hearth_${n}.obj`),...Object.keys(SOUND_PROFILES).map(n=>`sounds/${n}.ogg`)]) { + assert.ok(first.some(entry=>entry.path==='mods/hearth/'+name && entry.size>0),name); + } +}); + +test('original Ogg media is part of the verified package manifest without a runtime encoder or WAV leftovers', async t => { + const root = await temporary(t), source = await sourceTree(root); + await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true}); + for (const name of ['game.conf','minetest.conf','settingtypes.txt','LICENSE.txt','release.json','tests/fixtures/scenarios.json']) + await fs.copyFile(path.join(SOURCE,name),path.join(source,name)); + for (const name of ['init','model','runtime','content','encounters']) await fs.writeFile(path.join(source,'mods/hearth',name+'.lua'),'-- fixture\n'); + const destination = path.join(root,'package'); await packageGame(destination,{source}); + const {entries} = await verifyPackage(destination); + for (const name of Object.keys(SOUND_PROFILES)) { + const entry = entries.find(e=>e.path===`mods/hearth/sounds/${name}.ogg`); + assert.ok(entry,name); assert.equal(entry.sha256,SOUND_HASHES[name]); + parseOgg(await fs.readFile(path.join(destination,entry.path))); + } + assert.ok(!entries.some(e=>e.path.endsWith('.wav'))); + assert.ok(entries.some(e=>e.path==='mods/hearth/models/hearth_person.b3d')); + assert.ok(entries.some(e=>e.path==='mods/hearth/textures/hearth_person.png')); + assert.ok(entries.some(e=>e.path==='character-art.json')); + for (const {filename} of CHARACTER_ART_MANIFEST) + assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${filename}`),filename); + for (const body of CHARACTER_OPTIONS.bodies) + assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`)); +}); + +test('asset walker refuses source directory junctions/symlinks without following or writing outside', async t => { + const root = await temporary(t), source = await sourceTree(root), outside = path.join(root,'outside'); + await fs.mkdir(outside); await fs.writeFile(path.join(outside,'secret.lua'),'#c0ffee'); + await fs.symlink(outside,path.join(source,'mods/linked'),process.platform==='win32'?'junction':'dir'); + await assert.rejects(generateAssets(path.join(root,'output'),source),/symbolic link|junction/); + assert.deepEqual(await fs.readdir(outside),['secret.lua']); + await assert.rejects(fs.stat(path.join(root,'output')), {code:'ENOENT'}); +}); + +test('asset writer refuses linked output directories and files', async t => { + const root = await temporary(t), source = await sourceTree(root), outside = path.join(root,'outside'); + const destination = path.join(root,'output'), textures = path.join(destination,'mods/hearth/textures'); + await fs.mkdir(outside); await fs.writeFile(path.join(outside,'keep'),'unchanged'); + await fs.mkdir(path.dirname(textures),{recursive:true}); + await fs.symlink(outside,textures,process.platform==='win32'?'junction':'dir'); + await assert.rejects(generateAssets(destination,source),/symbolic link|junction/); + assert.deepEqual(await fs.readdir(outside),['keep']); + await fs.unlink(textures); await fs.mkdir(textures); + try { await fs.symlink(path.join(outside,'keep'),path.join(textures,'hearth_01abef.png'),'file'); } + catch (error) { + if (['EPERM','EACCES'].includes(error.code)) { t.diagnostic('File symlink privilege unavailable; directory junction protection verified.'); return; } + throw error; + } + await assert.rejects(generateAssets(destination,source),/symbolic link|junction/); + assert.equal(await fs.readFile(path.join(outside,'keep'),'utf8'),'unchanged'); +}); + +test('asset source Lua has bounded read size', async t => { + const root = await temporary(t), source = await sourceTree(root); + await fs.writeFile(path.join(source,'mods/hearth/large.lua'),Buffer.alloc(2*1024*1024+1,32)); + await assert.rejects(generateAssets(path.join(root,'output'),source),/size limit/); +}); diff --git a/tests/biomes_spec.lua b/tests/biomes_spec.lua new file mode 100755 index 0000000..6fa3050 --- /dev/null +++ b/tests/biomes_spec.lua @@ -0,0 +1,363 @@ +-- Standalone Lua 5.1+; registration contract, no world or engine is launched. +local target = assert(arg[1], "biomes.lua path required") +local factory = dofile(target) +local passed = 0 +local function contains(list, value) + for _, entry in ipairs(list or {}) do if entry == value then return true end end + return false +end +local function count(t) local n=0; for _ in pairs(t) do n=n+1 end; return n end +local function fixture(existing_alias) + local nodes, items, biomes, ores, decos, crafts = {}, {}, {}, {}, {}, {} + local aliases = {external_alias="external:stone"} + if existing_alias then aliases.mapgen_stone = "external:stone" end + for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", + "log", "leaves", "planks", "sticks", "loose_stone", "berry_bush", "reeds", "grain_patch", + "clay_bank", "iron_ore_vein", "weir_stone", "meadow_grass", "wildflowers", "fern", "litter", + "fungi", "deadwood", "spring_greens_patch", "autumn_mushrooms_patch"}) do + nodes["hearth:"..name] = {sentinel=true}; items["hearth:"..name] = nodes["hearth:"..name] + end + for _, name in ipairs({"iron_ore", "charcoal", "charcoal_charge", "clay", "foundation"}) do + items["hearth:"..name] = {sentinel=true} + end + local external = {sentinel=true} + biomes.external = external + local placed, owned = function() end, function() end + local construction = {place_building=placed, owner=owned} + local api = {registered_nodes=nodes, registered_items=items, registered_aliases=aliases} + function api.register_alias(name, value) + assert(aliases[name] == nil, "alias overwritten"); aliases[name] = value + end + function api.register_craftitem(name, def) + assert(not items[name]); items[name] = def + end + function api.register_biome(def) assert(not biomes[def.name]); biomes[def.name] = def end + function api.register_ore(def) ores[#ores+1] = def end + function api.register_decoration(def) + assert(not decos[def.name]); decos[def.name] = def; decos[#decos+1] = def + end + function api.register_craft(def) crafts[#crafts+1] = def end + -- Any attempt to clear registries, force aliases, change settings, install + -- globalsteps or write world nodes is a test failure, not a permissive noop. + setmetatable(api, {__index=function(_, key) error("unapproved engine API: " .. key) end}) + local registrations = 0 + local function private_register(name, def) + assert(not name:find(":"), "private helper takes short names") + local full="hearth:"..name + assert(not nodes[full], "base node overwritten") + registrations=registrations+1; nodes[full]=def; items[full]=def + end + local hearth = {sentinel=external} + local m = factory(hearth, api, private_register, construction) + return {m=m, nodes=nodes, items=items, biomes=biomes, ores=ores, decos=decos, crafts=crafts, + aliases=aliases, api=api, hearth=hearth, construction=construction, external=external, + registrations=function() return registrations end} +end +local function test(label, fn) + local ok, err=pcall(fn) + assert(ok, label .. ": " .. tostring(err)); passed=passed+1 + print("ok " .. passed .. " - " .. label) +end +local function loaded(existing) local f=fixture(existing); assert(f.m.register()==f.m); return f end +local function recipe(f, input, output) + local inputs = type(input) == "table" and input or {input} + local matches = 0 + for _, craft in ipairs(f.crafts) do + if craft.output == output and craft.type == "shapeless" and #craft.recipe == #inputs then + local same = true + for i, value in ipairs(inputs) do if craft.recipe[i] ~= value then same = false end end + if same then matches = matches + 1 end + end + end + return matches == 1 +end +local function cell(s, x,y,z) return s.data[1+x+s.size.x*(y+s.size.y*z)] end + +test("factory is lazy and does not publish globals or mutate hearth", function() + local f=fixture() + assert(f.registrations()==0 and #f.ores==0 and #f.decos==0 and #f.crafts==0) + assert(count(f.hearth)==1 and f.hearth.sentinel==f.external) + assert(type(f.m.register)=="function" and type(f.m.surface)=="function") +end) +test("registration is idempotent and preserves external registries", function() + local f=loaded(); local n, ores, decos, crafts=f.registrations(), #f.ores, #f.decos, #f.crafts + assert(f.m.register()==f.m) + assert(n==f.registrations() and ores==#f.ores and decos==#f.decos and crafts==#f.crafts) + assert(f.biomes.external==f.external and f.nodes["hearth:grass"].sentinel) + assert(f.aliases.external_alias=="external:stone") +end) +test("mapgen aliases are non-forcing and keep canonical stone identity", function() + local f=loaded() + assert(f.aliases.mapgen_stone=="hearth:stone") + assert(f.aliases.mapgen_water_source=="hearth:water_source") + assert(f.aliases.mapgen_river_water_source=="hearth:water_source") + assert(loaded(true).aliases.mapgen_stone=="external:stone") +end) +test("alias with a registered item is not overwritten", function() + local f=fixture(); f.items.mapgen_stone={sentinel=true}; f.m.register() + assert(not f.aliases.mapgen_stone and f.items.mapgen_stone.sentinel) +end) +test("terrain node collision fails rather than overwriting", function() + local f=fixture(); local old={sentinel=true}; f.nodes["hearth:granite"]=old + assert(not pcall(f.m.register)); assert(f.nodes["hearth:granite"]==old) +end) +test("ten biomes preserve meadow and river names and resolve all materials", function() + local f=loaded(); assert(count(f.biomes)==11) + assert(f.biomes.hearth_temperate.node_top=="hearth:grass") + assert(f.biomes.hearth_riverbank.node_top=="hearth:sand") + for name, b in pairs(f.biomes) do if name~="external" then + assert(b.y_min<=b.y_max and b.heat_point>=0 and b.heat_point<=100) + assert(b.humidity_point>=0 and b.humidity_point<=100) + for key, value in pairs(b) do if key:match("^node_") then assert(f.nodes[value], value) end end + end end +end) +test("altitude coverage has no missing climate-independent fallback band", function() + local f=loaded() + for y=-512,512 do + local coverage=false + for _, b in ipairs(f.m.biome_profiles) do if y>=b.min and y<=b.max then coverage=true end end + assert(coverage, "altitude "..y) + end + local peak=f.biomes.hearth_rocky_peaks + assert(peak.y_min==211 and peak.node_top=="hearth:snow" and peak.vertical_blend==0) + assert(f.biomes.hearth_floodplain.y_max==8) +end) +test("habitat helper resolves qualified nodes and rejects unknown surfaces", function() + local m=loaded().m + assert(m.surface("hearth:peat").moisture > m.surface("podzol").moisture) + assert(m.surface({name="hearth:alluvium"}).fertility==1) + assert(not m.surface("other:grass") and not m.surface(false) and not m.surface({})) + assert(m.supports_species("willow", "hearth:alluvium")) + assert(not m.supports_species("willow", "hearth:podzol")) + assert(not m.supports_species("unknown", "grass")) + assert(not m.supports_species("oak", false)) +end) +test("ten exact catalogue species have real full-block trunks, leaves, and timber", function() + local f=loaded(); assert(table.concat(f.m.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce") + for _, species in ipairs(f.m.species_order) do + local log=f.nodes["hearth:"..species.."_log"] + local leaf=f.nodes["hearth:"..species.."_leaves"] + assert(log.groups.tree==1 and log.groups.choppy==2 and log.drawtype==nil) + assert(log.tiles[1]=="hearth_"..species.."_log_top.png") + assert(log.tiles[3]=="hearth_"..species.."_log_side.png") + assert(leaf.drawtype=="allfaces_optional" and leaf.groups.leaves==1) + assert(f.nodes["hearth:"..species.."_planks"].groups.wood==1) + assert(not f.nodes["hearth:"..species.."_seedling"]) + end +end) +test("species timber converts losslessly into canonical progression ingredients", function() + local f=loaded() + for _, species in ipairs(f.m.species_order) do + local log="hearth:"..species.."_log" + assert(recipe(f,{log,log},"hearth:log 2")) + assert(not recipe(f,log,"hearth:log") and not recipe(f,log,"hearth:log 2")) + assert(recipe(f,"hearth:"..species.."_log","hearth:"..species.."_planks 4")) + assert(recipe(f,"hearth:"..species.."_planks","hearth:planks")) + end + -- Existing recipes accept these exact names for charcoal, walls and sticks. + assert(f.items["hearth:log"] and f.items["hearth:planks"] and f.items["hearth:clay"]) +end) +test("geological stone remains useful masonry and canonical foundations", function() + local f=loaded() + for _, rock in ipairs(f.m.rocks) do + local raw, brick="hearth:"..rock, "hearth:"..rock.."_bricks" + assert(f.nodes[raw].groups.cracky==3 and f.nodes[raw].drop==nil) + assert(recipe(f,raw,brick.." 4") and recipe(f,{raw,raw},"hearth:stone 2")) + assert(not recipe(f,raw,"hearth:stone") and not recipe(f,raw,"hearth:stone 2")) + assert(f.nodes[brick].groups.hearth_building==1) + assert(f.nodes[brick].on_place==f.construction.place_building) + assert(f.nodes[brick].after_place_node==f.construction.owner) + end +end) +test("all 40 terrain recipes have unique shapeless inputs including multiplicity", function() + local f=loaded(); assert(#f.crafts==40) + local seen={} + for _, craft in ipairs(f.crafts) do + assert(craft.type=="shapeless") + local inputs={} + for i, input in ipairs(craft.recipe) do inputs[i]=input end + table.sort(inputs) + local key=table.concat(inputs,",") + assert(not seen[key], "colliding terrain recipe: "..craft.output.." and "..tostring(seen[key])) + seen[key]=craft.output + end +end) +test("all registered material drops and crafting outputs resolve", function() + local f=loaded() + local function resolved(stack) + local name=stack:match("^([^ ]+)"); assert(f.items[name], "unresolved "..stack) + end + for _, def in pairs(f.nodes) do if type(def.drop)=="string" and def.drop~="" then resolved(def.drop) end end + for _, craft in ipairs(f.crafts) do + resolved(craft.output); for _, input in ipairs(craft.recipe) do resolved(input) end + end +end) +test("resource items and ore nodes use precise original art names", function() + local f=loaded() + for item, vein in pairs({quartz="quartz_vein", copper_ore="copper_ore_vein", coal="coal_ore_vein"}) do + assert(f.items["hearth:"..item].inventory_image=="hearth_item_"..item..".png") + assert(f.nodes["hearth:"..vein].tiles[1]=="hearth_"..vein..".png") + assert(f.nodes["hearth:"..vein].drop=="hearth:"..item.." 2") + end + assert(f.nodes["hearth:iron_ore_vein"].sentinel) +end) +test("geology runs before nested restricted scatter ore registration", function() + local f=loaded(); assert(#f.ores==9) + assert(f.ores[4].ore_type=="blob" and f.ores[4].ore=="hearth:basalt") + assert(f.ores[4].noise_params and f.ores[4].y_max<0) + local generated={} + for _, ore in ipairs(f.ores) do + assert(f.nodes[ore.ore] and ore.clust_scarcity>0 and ore.y_min<=ore.y_max) + for _, host in ipairs(ore.wherein) do + assert(f.nodes[host] and f.nodes[host].groups and f.nodes[host].groups.stone==1 or host=="hearth:stone") + assert(not contains({"hearth:soil", "hearth:grass", "air"},host)) + end + if ore.ore_type=="scatter" then + assert(ore.clust_num_ores<=ore.clust_size^3) + generated[ore.ore]=true + end + end + for _, name in ipairs({"quartz_vein", "copper_ore_vein", "coal_ore_vein", "iron_ore_vein"}) do assert(generated["hearth:"..name]) end + assert(contains(f.ores[5].wherein,"hearth:granite") and not contains(f.ores[5].wherein,"hearth:sandstone")) + assert(contains(f.ores[6].wherein,"hearth:basalt")) + assert(contains(f.ores[7].wherein,"hearth:sandstone") and not contains(f.ores[7].wherein,"hearth:granite")) +end) +test("v7 underground riverbank stone receives ordered resource-bearing beds", function() + local f=loaded() + for i=1,3 do assert(f.ores[i].clust_size==0 and f.ores[i].clust_scarcity==1) end + local function host_at(y) + local host=f.biomes.hearth_riverbank.node_stone + for _, bed in ipairs(f.ores) do + if bed.ore_type=="stratum" and y>=bed.y_min and y<=bed.y_max and contains(bed.wherein,host) then host=bed.ore end + end + return host + end + assert(host_at(-150)=="hearth:granite" and host_at(-80)=="hearth:granite") + assert(host_at(-79)=="hearth:slate" and host_at(-24)=="hearth:slate") + assert(host_at(-23)=="hearth:limestone" and host_at(-8)=="hearth:limestone") + assert(host_at(-7)=="hearth:sandstone") + for _, ore in ipairs(f.ores) do if ore.ore_type=="scatter" and ore.y_min==-31000 then + local found=false + for y=-150,-8 do if contains(ore.wherein,host_at(y)) and y<=ore.y_max then found=true end end + assert(found,"unreachable ore host "..ore.ore) + end end +end) +test("schematic dimensions, probabilities and trunk anchoring are valid", function() + local f=loaded() + for _, species in ipairs(f.m.species_order) do + local s=f.m.tree_schematic(species); local c=math.floor(s.size.x/2) + assert(s.size.y>=5 and s.size.y<=10 and s.size.x<=9 and #s.data==s.size.x*s.size.y*s.size.z) + for y=0,s.size.y-2 do assert(cell(s,c,y,c).name=="hearth:"..species.."_log") end + local leaves, air=0,0 + for _, n in ipairs(s.data) do + assert(n.param2==0 and not n.force_place) + if n.name=="air" then assert(n.prob==0); air=air+1 + else assert(f.nodes[n.name] and n.prob==255); if n.name:match("_leaves$") then leaves=leaves+1 end end + end + assert(leaves>10 and air>10) + end + assert(not pcall(f.m.tree_schematic,"unknown")) +end) +test("all ten silhouettes are geometrically distinct and young canopies stay bounded", function() + local m=loaded().m; local shapes={} + for _,species in ipairs(m.species_order) do + local s=m.tree_schematic(species); local shape={s.size.x..":"..s.size.y..":"..s.size.z} + for _,n in ipairs(s.data) do shape[#shape+1]=n.name=="air" and "." or (n.name:match("_log$") and "T" or "L") end + local signature=table.concat(shape,",") + assert(not shapes[signature],species.." duplicates "..tostring(shapes[signature])); shapes[signature]=species + local young=m.tree_schematic(species,false,"young"); local leaves=0 + assert(young.size.x==3 and young.size.y<=5 and #young.data<=45) + for _,n in ipairs(young.data) do + assert(not n.force_place) + if n.name:match("_leaves$") then leaves=leaves+1 end + end + assert(leaves>=5) + end + assert(not pcall(m.tree_schematic,"oak",false,"invalid")) +end) +test("fallen logs are three full cubes with horizontal facedir and non-forcing mapgen only", function() + local f=loaded(); local d=f.decos["hearth:woodland_deadwood"] + assert(d.deco_type=="schematic" and d.rotation=="random" and d.place_offset_y==1) + assert(not d.flags:find("force_placement",1,true) and not d.force_placement) + assert(d.schematic.size.x==3 and d.schematic.size.y==1 and d.schematic.size.z==1) + assert(#d.schematic.data==3 and d.y_min==2 and d.y_max==160) + for _,n in ipairs(d.schematic.data) do + assert(n.name=="hearth:deadwood" and n.prob==255 and n.param2==12 and not n.force_place) + -- Luanti facedir / 4 = axis direction; axis 3 is +X, axis 1 is +Z. + assert(math.floor(n.param2/4)==3) + end + for _,ground in ipairs(d.place_on) do assert(ground~="hearth:water_source") end +end) +test("species silhouettes are distinct broad slender conical drooping riparian", function() + local m=loaded().m + local oak, birch, pine, willow, alder=m.tree_schematic("oak"),m.tree_schematic("birch"),m.tree_schematic("pine"),m.tree_schematic("willow"),m.tree_schematic("alder") + assert(oak.size.x>birch.size.x and birch.size.y>oak.size.y) + assert(cell(oak,6,4,3).name=="hearth:oak_leaves") + assert(cell(pine,0,2,3).name=="hearth:pine_leaves") + assert(cell(pine,0,7,3).name=="air" and cell(pine,3,8,3).name=="hearth:pine_leaves") + assert(cell(willow,7,1,7).name=="hearth:willow_leaves") + assert(cell(willow,4,1,4).name=="hearth:willow_log") + assert(alder.size.y==6 and m.profiles.alder.nitrogen_fixer) +end) +test("tree decorations are habitat-restricted and cannot erase terrain", function() + local f=loaded() + for _, species in ipairs(f.m.species_order) do + local d=f.decos["hearth:"..species.."_trees"] + assert(d.deco_type=="schematic" and #d.biomes>=1 and #d.place_on>=1) + assert(d.place_offset_y==1 and d.rotation=="random") + assert(not d.flags:find("force_placement",1,true)) + if species=="willow" or species=="alder" then + assert(d.num_spawn_by==1 and contains(d.spawn_by,"hearth:water_source")) + end + end +end) +test("all canopy registrations precede ground flora", function() + local f=loaded(); local first_plant=math.huge + for i,d in ipairs(f.decos) do if d.deco_type=="simple" then first_plant=math.min(first_plant,i) end end + for i,d in ipairs(f.decos) do if d.name:match("_trees$") then assert(i=1,name) + for _, image in ipairs(def.tiles) do + assert(image:match("^hearth_[a-z_]+%.png$"),image); textures[image]=true + end + end end + -- Five extra woods add log top/side, leaves and planks (20 textures). + assert(count(textures)==62, "unexpected world tile count "..count(textures)) + assert(f.registrations()==51, "unexpected private node count "..f.registrations()) +end) +print("PASS " .. passed .. " biome/geology checks") diff --git a/tests/botany_spec.lua b/tests/botany_spec.lua new file mode 100755 index 0000000..346f822 --- /dev/null +++ b/tests/botany_spec.lua @@ -0,0 +1,380 @@ +-- Standalone Lua 5.1+; mocked APIs only, never starts Luanti. +local target=assert(arg[1],"botany.lua path required") +local factory=dofile(target) +local passed=0 +local B,H,A,world,fields,defs,time,season,allowed,room,leftover,added,auth,writes +local policies,seasonal_ids,clock_reads,climate_calls +local p={x=2,y=1,z=3} +local function key(q) return q.x..":"..q.y..":"..q.z end +local function reset() + world,fields,defs={},{},{} + policies,seasonal_ids,clock_reads,climate_calls={},{},0,0 + time,season,allowed,room,leftover,added,auth,writes=100000,"Summer",true,true,0,0,0,0 + A={registered_nodes=defs,now=function() clock_reads=clock_reads+1; return time end} + function A.get_node_or_nil(q) return world[key(q)] end + function A.get_meta(q) + fields[key(q)]=fields[key(q)] or {}; local f=fields[key(q)] + return {get_string=function(_,k) return f[k] or "" end, + set_string=function(_,k,v) writes=writes+1; f[k]=v end} + end + -- Missing construction at factory time is deliberate: content wires it later. + H={climate_at=function(epoch) + assert(type(epoch)=="number" and epoch==epoch and epoch==math.floor(time)) + climate_calls=climate_calls+1; return {season=season} + end, seasons={ + add_variants=function(id,definitions,active) + assert(not policies[id],id); policies[id]={definitions=definitions,active=active} + for s in pairs(definitions) do seasonal_ids["hearth:"..id.."_"..s:lower()]=id end + end, + kind=function(name) return seasonal_ids[name] or name:match("^hearth:(.+)$") end, + }} + B=factory(H,A) + H.construction={allowed=function(q,player,action) + auth=auth+1; assert(q==p and player:is_player() and action=="use"); return allowed + end} +end +local inv={room_for_item=function(_,list,item) assert(list=="main" and item=="hearth:berries 2"); return room end, + add_item=function(_,list,item) + assert(list=="main" and item=="hearth:berries 2"); added=added+2-leftover + return {get_count=function() return leftover end} + end} +local player={is_player=function() return true end,get_inventory=function() return inv end} +local function test(label,fn) + reset(); local ok,err=pcall(fn); assert(ok,label..": "..tostring(err)) + passed=passed+1; print("PASS botany "..passed.." - "..label) +end +local function habitat(id) + local r=assert(B.get(id)); local n=r.niche + local ground=next(n.substrates) + if n.aquatic=="surface" or n.aquatic=="submerged" then ground="alluvium" end + return {loaded=true,ground=ground,substrate=id=="hornwort" and "water" or nil, + light=(n.light[1]+n.light[2])/2,moisture=(n.moisture[1]+n.moisture[2])/2, + ph=(n.ph[1]+n.ph[2])/2,water=n.water[1],aquatic=n.aquatic, + freshwater=true,nutrients=2,host=n.hosts and next(n.hosts) or nil} +end +local function register() + B.register_nodes(function(id,def) assert(not defs["hearth:"..id],id); defs["hearth:"..id]=def end) +end +local function bush(id) world[key(p)]={name="hearth:"..(id or "bilberry")} end +test("exactly eighty disjoint real taxa in eight ordered groups of ten",function() + assert(#B.groups==8 and #B.catalogue==80 and #B.list()==80) + assert(table.concat(B.groups,",")=="trees,shrubs,grasses,herbs,ferns_mosses,fungi,aquatic,lichens") + local ids,latin={},{},{} + for _,g in ipairs(B.groups) do + assert(#B.list(g)==10,g) + for _,r in ipairs(B.list(g)) do + assert(not ids[r.id] and not latin[r.latin]); ids[r.id]=true; latin[r.latin]=true + assert(r.common~="" and r.latin:match("^[A-Z][a-z]+ [a-z%-]+$"),r.latin) + assert(r.group==g and B.group_of(r.node)==g and B.kind(r.node)==r.id) + end + end +end) +test("legacy IDs map exactly without counting generic bushes or patches",function() + local expected={oak="Quercus robur",birch="Betula pendula",pine="Pinus sylvestris",willow="Salix alba", + alder="Alnus glutinosa",clover="Trifolium repens",nettle="Urtica dioica",heather="Calluna vulgaris", + bluebell="Hyacinthoides non-scripta",sedge="Carex paniculata",bulrush="Typha latifolia", + moss="Hypnum cupressiforme",lichen="Rhizocarpon geographicum"} + for id,latin in pairs(expected) do local r=B.get(id); assert(r.existing and r.latin==latin) end + for _,id in ipairs({"berry_bush","fern","meadow_grass","wildflowers","tree_seedling"}) do assert(not B.get(id)) end + assert(not B.get("foreign:oak") and not B.kind(false)) +end) +test("tree stages and resources resolve through seasons without registering them",function() + H.seasons={kind=function(value) return value:gsub("_winter$",""):gsub("^hearth:","") end} + for _,r in ipairs(B.list("trees")) do + for _,part in ipairs({"seedling","young","mature","leaves","log","planks"}) do + assert(B.kind("hearth:"..r.id.."_"..part.."_winter")==r.id) + end + end + register() + for _,r in ipairs(B.list("trees")) do assert(not defs[r.node]) end +end) +test("catalogue snapshots and getters cannot alter internal policy",function() + B.catalogue[1].latin="broken"; B.get("oak").niche.light[1]=100 + local list=B.list(); list[1].tags.pollinator=true + assert(B.get("oak").latin=="Quercus robur" and not B.has_tag("oak","pollinator")) + assert(B.get("oak").niche.light[1]==4) +end) +test("all eighty species have a passing season niche vector",function() + for _,r in ipairs(B.list()) do assert(B.suitable(r.id,habitat(r.id),next(r.seasons)),r.id) end +end) +test("each niche dimension rejects out-of-window values for all species",function() + for _,r in ipairs(B.list()) do + local s=next(r.seasons) + for _,field in ipairs({"light","moisture","ph"}) do + local h=habitat(r.id); h[field]=r.niche[field][1]-.01 + assert(not B.suitable(r.id,h,s),r.id..field) + h[field]=r.niche[field][2]+.01; assert(not B.suitable(r.id,h,s)) + end + local h=habitat(r.id); h.ground="lava"; h.substrate="lava"; assert(not B.suitable(r.id,h,s)) + end +end) +test("unloaded, malformed and nonfinite habitats fail closed",function() + for _,field in ipairs({"light","moisture","ph","water"}) do + for _,bad in ipairs({0/0,math.huge,-math.huge,"5"}) do + local h=habitat("clover"); h[field]=bad; assert(not B.suitable("clover",h,"Summer")) + end + end + local h=habitat("clover"); h.loaded=false; assert(not B.suitable("clover",h,"Summer")) + h.loaded=true; h.ph=nil; assert(not B.suitable("clover",h,"Summer")) + assert(not B.suitable("clover",nil,"Summer") and not B.suitable("unknown",{},"Summer")) +end) +test("spring ephemerals and fungal fruit seasons are not year-round",function() + for _,id in ipairs({"bluebell","wild_garlic","cowslip","wood_anemone"}) do + assert(B.suitable(id,habitat(id),"Spring")); assert(not B.suitable(id,habitat(id),"Summer")) + end + assert(B.active("wood_blewit","Winter") and not B.active("fly_agaric","Winter")) + assert(B.active("moss","Winter") and not B.active("oak","Winter")) +end) +test("host-specific root fungi reject unrelated or absent tree roots",function() + local h=habitat("birch_bolete"); assert(B.suitable("birch_bolete",h,"Autumn")) + h.host="oak"; assert(not B.suitable("birch_bolete",h,"Autumn")) + h.host=nil; assert(not B.suitable("birch_bolete",h,"Autumn")) + assert(B.root_fungi("birch_bolete","birch_seedling",2)==1) + assert(B.root_fungi("birch_bolete","oak",2)==0) +end) +test("deadwood decomposers require deadwood rather than live bark",function() + local h=habitat("birch_polypore"); assert(B.suitable("birch_polypore",h,"Autumn")) + h.ground="bark"; assert(not B.suitable("birch_polypore",h,"Autumn")) + h.ground="deadwood"; h.host="pine"; assert(not B.suitable("birch_polypore",h,"Autumn")) + assert(B.has_tag("honey_fungus","parasite") and not B.interactions("honey_fungus").parasite.enabled) +end) +test("lichens distinguish acid stone, bark and humid old-woodland niches",function() + local h=habitat("lichen"); assert(B.suitable("lichen",h,"Winter")) + h.ground="limestone"; assert(not B.suitable("lichen",h,"Winter")) + h=habitat("oakmoss"); h.ground="soil"; assert(not B.suitable("oakmoss",h,"Winter")) + h=habitat("lungwort_lichen"); h.moisture=.2; assert(not B.suitable("lungwort_lichen",h,"Winter")) +end) +test("surface plants need freshwater, depth and appropriate bottom",function() + for _,id in ipairs({"duckweed","yellow_waterlily","white_waterlily","water_crowfoot","pondweed"}) do + local h=habitat(id); assert(B.suitable(id,h,"Summer")) + h.freshwater=false; assert(not B.suitable(id,h,"Summer")); h.freshwater=true + h.water=0; assert(not B.suitable(id,h,"Summer")); h.water=1 + h.aquatic="submerged"; assert(not B.suitable(id,h,"Summer")) + end +end) +test("submerged rootless hornwort and rooted starwort have separate supports",function() + local h=habitat("hornwort"); assert(B.suitable("hornwort",h,"Summer")) + h.substrate=nil; assert(not B.suitable("hornwort",h,"Summer")) + h=habitat("water_starwort"); assert(B.suitable("water_starwort",h,"Summer")) + h.ground="stone"; assert(not B.suitable("water_starwort",h,"Summer")) +end) +test("wet banks are dry destination cells, not overwritten water sources",function() + for _,id in ipairs({"sedge","bulrush","water_mint","brooklime","water_forgetmenot"}) do + local h=habitat(id); assert(h.water==0 and B.suitable(id,h,"Summer")) + h.aquatic="land"; assert(not B.suitable(id,h,"Summer")); h.aquatic="bank" + h.water=1; assert(not B.suitable(id,h,"Summer")) + end +end) +test("deterministic choose returns only a suitable candidate and never mutates",function() + local h=habitat("clover"); local a,r=B.choose(p,h,"Summer") + assert(a and a==B.choose(p,h,"Summer") and B.suitable(a,h,"Summer")) + assert(r.node=="hearth:"..a and B.group_of(a)~="trees" and writes==0 and next(world)==nil) + h.loaded=false; assert(not B.choose(p,h,"Summer")) + assert(not B.choose({x=0/0,y=1,z=2},h,"Summer") and not B.choose(nil,h,"Summer")) +end) +test("choose respects surface and submerged niches without map operations",function() + for _,id in ipairs({"duckweed","hornwort"}) do + local h=habitat(id); local k=B.choose(p,h,"Summer") + assert(k and B.group_of(k)=="aquatic" and B.suitable(k,h,"Summer")) + end + assert(writes==0) +end) +test("pollinator support follows flowers, not wind-pollinated grasses or fungi",function() + assert(B.pollinator("bluebell","Spring") and not B.pollinator("bluebell","Summer")) + assert(B.pollinator("heather","Autumn") and not B.pollinator("heather","Spring")) + for _,id in ipairs({"sedge","oak","birch","nettle","fly_agaric","bracken","moss"}) do + assert(not B.pollinator(id,"Summer")) + end +end) +test("only three fictional game berry outputs are food, never toxic taxa or fungi",function() + local count=0 + for _,r in ipairs(B.list()) do + if r.food then count=count+1; assert(({bilberry=true,blackberry=true,raspberry=true})[r.id] and not r.toxic) end + if r.toxic or r.group=="fungi" then assert(not r.food and not B.edible(r.id,"Summer")) end + end + assert(count==3 and B.edible("blackberry","Autumn") and not B.edible("raspberry","Winter")) + assert(not B.edible("clover","Summer") and B.browse("clover","Summer")) +end) +test("interaction descriptors cover every actual tag without mutation",function() + local seen={} + for _,r in ipairs(B.list()) do + local d=B.interactions(r.id) + for tag in pairs(r.tags) do assert(d[tag] and #d[tag].description>20); seen[tag]=true end + end + for _,tag in ipairs({"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"}) do assert(seen[tag],tag) end + local d=B.interactions("bracken"); d.allelopathy.enabled=true + assert(B.interactions("bracken").allelopathy.enabled==false and writes==0) +end) +test("nitrogen is capped and fungi transfer only an existing positive budget",function() + assert(B.nitrogen("alder",0)==1 and B.nitrogen("clover",1.5)==.5 and B.nitrogen("clover",2)==0) + assert(B.nitrogen("oak",0)==0 and B.nitrogen("alder",0/0)==0) + assert(B.root_fungi("fly_agaric","pine",.25)==.25 and B.root_fungi("fly_agaric","pine",0)==0) + assert(B.root_fungi("turkey_tail","oak",2)==0 and B.root_fungi("fly_agaric","pine",math.huge)==0) + assert(B.resin("spruce") and B.resin("pine_log") and not B.resin("oak")) + assert(writes==0) +end) +test("register exactly sixty-two missing understory nodes, never legacy or trees",function() + register(); local count=0 + for name,def in pairs(defs) do + count=count+1; local r=B.get(name) + assert(r and not r.existing and r.group~="trees") + assert(def.tiles[1]=="hearth_botany_"..r.id..".png" and not def.mesh) + assert(def.description==r.common.." ("..r.latin..")") + assert(not def.walkable and not def.buildable_to and def.groups.hearth_ecosystem==1) + end + assert(count==62); register(); local again=0; for _ in pairs(defs) do again=again+1 end; assert(again==count) +end) +test("existing registered definitions are preserved and construction stays late-bound",function() + local marker={description="external owner"}; defs["hearth:bilberry"]=marker + register(); assert(defs["hearth:bilberry"]==marker) + H.construction=nil; bush(); assert(not B.harvest(p,player) and added==0) +end) +test("normal plants dig as nodes; shrubs drop sticks preventing cooldown-reset replant",function() + register() + assert(defs["hearth:yarrow"].drop=="hearth:yarrow") + for _,r in ipairs(B.list("shrubs")) do assert(defs[r.node].drop=="hearth:sticks") end + defs["hearth:bilberry"].after_place_node(p) + assert(fields[key(p)].player_placed=="1") +end) +test("aquatic sprites and flat pads remain inside one block and are never liquids",function() + register() + for _,r in ipairs(B.list("aquatic")) do + local d=defs[r.node]; assert(not d.groups.water and not d.liquidtype) + if r.niche.aquatic=="surface" then + assert(d.drawtype=="nodebox") + for _,v in ipairs(d.node_box.fixed) do assert(v>=-.5 and v<=.5) end + else assert(d.drawtype=="plantlike") end + if r.niche.aquatic=="surface" or r.niche.aquatic=="submerged" then + local stack={}; assert(d.on_place(stack,player,{})==stack and writes==0) + end + end +end) +test("harvest checks permission before metadata or inventory and returns at most two",function() + bush(); allowed=false; local ok,reason=B.harvest(p,player) + assert(not ok and reason=="denied" and auth==1 and writes==0 and added==0) + allowed=true; ok,reason=B.harvest(p,player) + assert(ok and reason==2 and added==2 and tonumber(fields[key(p)].botany_ready_at)==time+86400) +end) +test("cooldown has no catchup and survives a new module instance",function() + bush(); assert(B.harvest(p,player)); assert(not B.harvest(p,player) and added==2) + policies={} -- a fresh factory may reinstall the same seasonal policy + B=factory(H,A); assert(not B.harvest(p,player)); time=time+86400*30 + assert(B.harvest(p,player) and added==4); assert(not B.harvest(p,player) and added==4) +end) +test("full inventory does not consume stock or start cooldown",function() + bush(); room=false; local ok,reason=B.harvest(p,player) + assert(not ok and reason=="inventory" and writes==0 and added==0) + room=true; leftover=2; assert(not B.harvest(p,player) and fields[key(p)].botany_ready_at=="") + leftover=1; ok,reason=B.harvest(p,player); assert(ok and reason==1 and added==1) + assert(not B.harvest(p,player)) +end) +test("harvest rejects wrong node, season, unloaded and nonplayer callers",function() + for _,id in ipairs({"elder","holly","foxglove","fly_agaric","berry_bush","bilberry_fake"}) do + bush(id); assert(not B.harvest(p,player) and added==0) + end + bush(); season="Winter"; assert(not B.harvest(p,player)) + season="Summer"; world[key(p)]=nil; assert(not B.harvest(p,player)) + assert(not B.harvest(p,nil) and not B.harvest(p,{is_player=function() return false end})) + assert(writes==0) +end) +test("malformed clocks and cooldown metadata fail closed",function() + bush() + for _,t in ipairs({0/0,math.huge,-math.huge,-1,"clock",9007199254740991}) do + time=t; local ok,reason=B.harvest(p,player); assert(not ok and reason=="clock") + end + time=nil; local ok,reason=B.harvest(p,player); assert(not ok and reason=="clock") + assert(climate_calls==0 and writes==0 and next(fields)==nil) + time=100000; A.get_meta(p):set_string("botany_ready_at","broken"); assert(not B.harvest(p,player)) + A.get_meta(p):set_string("botany_ready_at","-2"); assert(not B.harvest(p,player)) + assert(added==0) +end) +test("rightclick returns item stack and reentrant inventory cannot duplicate",function() + register(); bush(); local original=inv.add_item + inv.add_item=function(self,list,item) + assert(not B.harvest(p,player)); return original(self,list,item) + end + local stack={}; assert(defs["hearth:bilberry"].on_rightclick(p,{},player,stack)==stack) + inv.add_item=original; assert(added==2) +end) +test("the actual position selector can return each of all eighty exact node IDs",function() + for index,r in ipairs(B.list()) do + local h=habitat(r.id); h.include_trees=true + local x=0 + while (x*17+7)%80~=index-1 do x=x+1; assert(x<80) end + local k,selected=B.choose({x=x,y=1,z=0},h,next(r.seasons)) + assert(k==r.node:sub(8) and selected.id==r.id,r.id) + assert(B.suitable(k,h,next(r.seasons))) + end + assert(writes==0) +end) +test("submerged candidates work as separate air-cell stands, never as liquid node definitions",function() + register() + for _,id in ipairs({"water_starwort","hornwort"}) do + local r=B.get(id); local h=habitat(id) + assert(B.suitable(id,h,"Summer") and h.aquatic=="submerged" and h.water>=1) + local def=defs[r.node] + assert(def.groups.hearth_submerged==1 and not def.groups.water and not def.liquidtype) + assert(not def.buildable_to and not def.walkable and def.drawtype=="plantlike") + end +end) +test("all sixty-two new understory policies match actual activity and dormant art",function() + local count=0 + for _,r in ipairs(B.list()) do + local policy=policies[r.id] + if r.existing or r.group=="trees" then assert(not policy,r.id) + else + count=count+1; assert(policy,r.id) + for _,s in ipairs({"Spring","Summer","Autumn","Winter"}) do + assert((policy.active[s]==true)==B.active(r.id,s)) + local v=policy.definitions[s] + assert((v~=nil)==not B.active(r.id,s),r.id..s) + if v then + assert(v.texture=="hearth_botany_"..r.id.."_dormant.png") + assert(B.kind(r.node.."_"..s:lower())==r.id) + end + end + end + end + assert(count==62) + policies.holly.active.Winter=false; assert(B.active("holly","Winter")) + assert(next(policies.juniper.definitions)==nil and next(policies.holly.definitions)==nil) +end) +test("optional seasons mocks without add_variants still support base harvesting",function() + for _,mock in ipairs({{}, {kind=function(name) return name:match("^hearth:(.+)$") end}}) do + H.seasons=mock; B=factory(H,A); bush(); fields={} + assert(B.harvest(p,player)) + end + H.seasons=nil; B=factory(H,A); fields={}; assert(B.harvest(p,player)) +end) +test("harvest samples one validated epoch before climate and uses the same cooldown clock",function() + time=1782043200.75; bush() + H.climate_at=function(epoch) + climate_calls=climate_calls+1 + assert(clock_reads==1 and epoch==1782043200) + return {season="Summer"} + end + assert(B.harvest(p,player) and clock_reads==1 and climate_calls==1) + assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+86400) +end) +test("only exact owned seasonal aliases harvest in an active fruit season",function() + bush("bilberry_winter"); season="Winter" + local ok,reason=B.harvest(p,player); assert(not ok and reason=="season" and writes==0) + -- A loaded appearance can lag the climate; harvesting never resets its stock. + season="Summer"; assert(B.harvest(p,player)) + bush(); assert(not B.harvest(p,player) and added==2) + H.seasons.kind=function() return "bilberry" end + for _,id in ipairs({"bilberry_fake","bilberry_summer","bilberry_winter_extra"}) do + bush(id); ok,reason=B.harvest(p,player); assert(not ok and reason=="species") + end + world[key(p)]={name="foreign:bilberry"}; ok,reason=B.harvest(p,player) + assert(not ok and reason=="species" and added==2) +end) +test("southern berry seasons use the solar model at the validated epoch",function() + local model=dofile(target:gsub("[^/\\]+$","model.lua")) + H.climate_at=function(epoch) assert(epoch==time); return model.solar(epoch,-45,0) end + bush(); time=1782043200 + local ok,reason=B.harvest(p,player); assert(not ok and reason=="season" and writes==0) + time=1797854400; assert(B.harvest(p,player) and added==2) + assert(tonumber(fields[key(p)].botany_ready_at)==time+86400) +end) +print("BOTANY_SPEC_PASS "..passed) diff --git a/tests/character-art.test.mjs b/tests/character-art.test.mjs new file mode 100755 index 0000000..ba94c9e --- /dev/null +++ b/tests/character-art.test.mjs @@ -0,0 +1,194 @@ +// Pure asset checks only. No engine, external art, network or live-world writes. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { ATLAS } from '../scripts/media.mjs'; +import { CHARACTER_OPTIONS as O, CHARACTER_ART_MANIFEST as M, DEFAULT_CHARACTER, + CHARACTER_PORTRAITS, characterArt, characterLayer, characterLayers, characterTexture, + characterMesh, compositeCharacter, portraitSheet } from '../scripts/character-art.mjs'; + +const hash = image=>createHash('sha256').update(image.data).digest('hex'); +const pixel = (im,tile,x,y)=> { + const [tx,ty]=ATLAS[tile], i=((ty*8+y)*im.width+tx*8+x)*4; + return [...im.data.subarray(i,i+4)]; +}; +const alpha = im=>im.data.reduce((n,value,i)=>n+(i%4===3 && value===255 ? 1 : 0),0); + +test('exact public option IDs and 170 deterministic filenames include every Cartesian combination',()=> { + assert.deepEqual(O,{ + bodies:['compact','balanced','broad'],skin:['porcelain','sand','honey','olive','umber','ebony'], + faces:['calm','angular','soft','weathered'],eyes:['hazel','brown','green','blue','grey'], + hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none'], + hairColors:['ash','flax','chestnut','auburn','black','silver'], + facialHair:['none','stubble','moustache','short_beard','full_beard'], + garments:['tunic','workshirt','jerkin','coat'],clothColors:['sage','teal','slate','mauve','ochre','rust','linen','charcoal'], + trousers:['earth','slate','moss','charcoal'],boots:['walnut','black','tan'], + accents:['none','scarf','apron','sash','vest'],details:['none','freckles','scar','freckles_scar'], + }); + const specs=[['skin',O.skin],['face',O.faces],['eyes',O.eyes],['hair',O.hairStyles,O.hairColors], + ['beard',O.facialHair,O.hairColors],['garment',O.garments,O.clothColors],['trousers',O.trousers], + ['boots',O.boots],['accent',O.accents,O.clothColors],['detail',O.details]]; + const expected=specs.flatMap(([kind,ids,colors])=>ids.flatMap(id=>(colors||[null]).map(color=> + `hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`))); + assert.equal(M.length,170); assert.equal(new Set(expected).size,170); + assert.deepEqual(M.map(r=>r.filename),expected); + assert.ok(Object.isFrozen(O) && Object.values(O).every(Object.isFrozen) && M.every(Object.isFrozen)); + assert.deepEqual([...characterArt().keys()],expected); + for (const r of M) { + assert.match(r.filename,/^hearth_char_[a-z_]+\.png$/); assert.equal(r.width,64); assert.equal(r.height,64); + assert.equal(r.license,'MIT'); assert.match(r.author,/original/); + } +}); + +test('all layers are native 64 RGBA, crisp alpha, no hidden RGB; only named none layers are empty',()=> { + for (const r of M) { + const im=characterLayer(r.filename); + assert.equal(im.width,64); assert.equal(im.height,64); assert.equal(im.data.length,16384); + assert.equal(hash(im),hash(characterLayer(r.filename))); + for (let i=0;ir.empty).length,21); +}); + +test('overlays paint only their semantic UV tiles, never held metal, wood, wrap or unused atlas space',()=> { + const allowed={face:['face'],eyes:['face'],hair:['face','hair','side','back','sleeve'], + beard:['face','side'],garment:['tunic','back','sleeve'],trousers:['trousers'],boots:['boot'], + accent:['tunic','back','sleeve'],detail:['face']}; + for (const r of M.filter(r=>r.kind!=='skin')) { + const im=characterLayer(r.filename), tiles=new Set(allowed[r.kind].map(t=>ATLAS[t].join(','))); + for (let y=0;y<64;y++) for (let x=0;x<64;x++) if (im.data[(y*64+x)*4+3]) + assert.ok(tiles.has([Math.floor(x/8),Math.floor(y/8)].join(',')),r.filename); + } +}); + +test('all six skins differ meaningfully on every exposed tile, not clothes or tools',()=> { + const layers=O.skin.map(id=>characterLayer(`hearth_char_skin_${id}.png`)); + for (let i=1;in!==b[c])) { changed++; delta+=a.reduce((n,v,c)=>n+Math.abs(v-b[c]),0); } + } + assert.equal(changed,256,'four exposed 8px head/hand tiles'); assert.ok(delta/changed>50); + for (const tile of ['face','hair','side','skin']) assert.notDeepEqual(pixel(layers[i],tile,3,4),pixel(layers[i-1],tile,3,4)); + for (const tile of ['tunic','boot','trousers','metal','wood','wrap']) + assert.deepEqual(pixel(layers[i],tile,3,4),pixel(layers[0],tile,3,4)); + } +}); + +test('all nonempty style/color layers are distinct; every appearance axis changes the composed character',()=> { + const seen=new Set(); + for (const r of M.filter(r=>!r.empty)) { + const key=hash(characterLayer(r.filename)); assert.ok(!seen.has(key),r.filename); seen.add(key); + } + const axes={skin:O.skin,face:O.faces,eyes:O.eyes,hair:O.hairStyles,hairColor:O.hairColors, + beard:O.facialHair,garment:O.garments,cloth:O.clothColors,trousers:O.trousers,boots:O.boots, + accent:O.accents,accentColor:O.clothColors,detail:O.details}; + for (const [key,values] of Object.entries(axes)) + assert.equal(new Set(values.map(id=>hash(compositeCharacter({[key]:id})))).size,values.length,key); +}); + +test('face linework is skin-neutral, eyes keep visible 8px landmarks, and no-hair scalp really uses chosen skin',()=> { + for (const id of O.faces) { + const im=characterLayer(`hearth_char_face_${id}.png`); + assert.deepEqual(pixel(im,'face',3,4),[0,0,0,0]); + assert.ok(alpha(im)>=5 && alpha(im)<=12,'linework, not a painted skin mask'); + assert.equal(pixel(im,'face',2,3)[3],0,'iris reserved'); + } + for (const id of O.eyes) { + const im=characterLayer(`hearth_char_eyes_${id}.png`); assert.equal(alpha(im),2); + assert.equal(pixel(im,'face',2,3)[3],255); assert.equal(pixel(im,'face',5,3)[3],255); + } + for (const skin of O.skin) { + const im=compositeCharacter({skin,hair:'none'}), base=characterLayer(`hearth_char_skin_${skin}.png`); + for (const tile of ['hair','side','skin']) assert.deepEqual(pixel(im,tile,4,4),pixel(base,tile,4,4)); + } +}); + +test('layer names and order match Luanti composition; garments cannot erase long shoulder hair',()=> { + const names=characterLayers({hair:'long',garment:'coat',cloth:'rust'}); + assert.deepEqual(names,[ + 'hearth_char_skin_sand.png','hearth_char_face_calm.png','hearth_char_eyes_hazel.png', + 'hearth_char_garment_coat_rust.png','hearth_char_trousers_earth.png','hearth_char_boots_walnut.png', + 'hearth_char_accent_scarf_sage.png','hearth_char_hair_long_chestnut.png', + 'hearth_char_beard_none_chestnut.png','hearth_char_detail_none.png']); + assert.equal(characterTexture({hair:'long',garment:'coat',cloth:'rust'}),names.join('^')); + const im=compositeCharacter({hair:'long',garment:'coat',cloth:'rust'}), hair=characterLayer(names[7]); + assert.deepEqual(pixel(im,'back',3,2),pixel(hair,'back',3,2)); + assert.deepEqual(pixel(im,'sleeve',0,1),pixel(hair,'sleeve',0,1)); + assert.equal(alpha(im),4096); + const independent=new Uint8Array(16384); + for (const name of names) { + const data=characterLayer(name).data; + for (let i=0;i { + for (const skin of O.skin) { + const base=characterLayer(`hearth_char_skin_${skin}.png`), combined=compositeCharacter({skin}); + for (const [tile,rgb] of [['metal',[177,195,191]],['wood',[146,100,59]],['wrap',[201,169,116]]]) + for (let y=0;y<8;y++) for (let x=0;x<8;x++) { + const [tx,ty]=ATLAS[tile], grain=(((tx*8+x)*13+(ty*8+y)*7)%5)-2; + const expected=[...rgb.map(n=>n+grain),255]; + assert.deepEqual(pixel(base,tile,x,y),expected); assert.deepEqual(pixel(combined,tile,x,y),expected); + } + } +}); + +test('clothing has tailored collars, rolled cuffs, leather seams, plackets, boot soles and accent masks',()=> { + const garment=id=>characterLayer(`hearth_char_garment_${id}_teal.png`); + assert.notDeepEqual(pixel(garment('tunic'),'tunic',2,0),pixel(garment('tunic'),'tunic',2,2),'collar'); + assert.notDeepEqual(pixel(garment('workshirt'),'sleeve',3,5),pixel(garment('workshirt'),'sleeve',3,4),'rolled cuff'); + assert.notDeepEqual(pixel(garment('jerkin'),'tunic',1,3),pixel(garment('jerkin'),'tunic',2,3),'leather panel seam'); + assert.notDeepEqual(pixel(garment('coat'),'tunic',3,3),pixel(garment('coat'),'tunic',2,3),'coat opening'); + const masks=O.accents.map(style=>characterLayer(`hearth_char_accent_${style}_sage.png`).data.filter((_,i)=>i%4===3)); + assert.equal(new Set(masks.map(bytes=>createHash('sha256').update(bytes).digest('hex'))).size,5); + for (const id of O.boots) assert.deepEqual(pixel(characterLayer(`hearth_char_boots_${id}.png`),'boot',0,7),[30,28,26,255]); +}); + +test('eight contact-sheet cells are assembled clothed portraits sampled from actual composited atlases',()=> { + const sheet=portraitSheet(); assert.equal(sheet.width,512); assert.equal(sheet.height,64); + assert.equal(CHARACTER_PORTRAITS.length,8); assert.equal(alpha(sheet),512*64); + assert.equal(hash(sheet),hash(portraitSheet())); + const cells=new Set(); + CHARACTER_PORTRAITS.forEach((p,index)=> { + const im=compositeCharacter(p), cell=[]; + for (let y=0;y<64;y++) for (let x=0;x<64;x++) { + const offset=(y*512+index*64+x)*4; cell.push(...sheet.data.subarray(offset,offset+4)); + } + cells.add(createHash('sha256').update(new Uint8Array(cell)).digest('hex')); + const sample=(x,y)=>[...sheet.data.subarray((y*512+index*64+x)*4,(y*512+index*64+x+1)*4)]; + assert.deepEqual(sample(28,12),pixel(im,'face',2,4)); + assert.deepEqual(sample(32,32),pixel(im,'tunic',4,4)); + assert.deepEqual(sample(0,0),[30,34,32,255]); + }); + assert.equal(cells.size,8); +}); + +test('pure renderers return fresh buffers and allowlisted choices reject traversal and prototype keys',()=> { + const first=compositeCharacter(), digest=hash(first); first.data.fill(0); + assert.equal(hash(compositeCharacter()),digest); + const layer=characterLayer(M[0].filename), original=hash(layer); layer.data.fill(0); + assert.equal(hash(characterLayer(M[0].filename)),original); + assert.equal(characterMesh(),'hearth_person.b3d'); + assert.equal(characterMesh('compact'),'hearth_person_compact.b3d'); + assert.equal(characterMesh('broad'),'hearth_person_broad.b3d'); + for (const bad of ['../sand','toString','constructor','__proto__','',null,{},0]) { + assert.throws(()=>characterLayer(bad)); assert.throws(()=>characterMesh(bad)); + assert.throws(()=>compositeCharacter({skin:bad})); + } + for (const bad of [null,[],1,'sand',{unknown:'sand'},JSON.parse('{"__proto__":{}}'), + Object.create({skin:'sand'}),{skin:undefined},{[Symbol('skin')]:'sand'}]) + assert.throws(()=>characterLayers(bad)); + assert.deepEqual(DEFAULT_CHARACTER,{body:'balanced',skin:'sand',face:'calm',eyes:'hazel',hair:'sidepart', + hairColor:'chestnut',beard:'none',garment:'tunic',cloth:'teal',trousers:'earth',boots:'walnut', + accent:'scarf',accentColor:'sage',detail:'none'}); +}); diff --git a/tests/character_spec.lua b/tests/character_spec.lua new file mode 100755 index 0000000..06cce90 --- /dev/null +++ b/tests/character_spec.lua @@ -0,0 +1,216 @@ +-- Offline Lua 5.1+ factory tests. Engine JSON is represented by exact JSON +-- fixture strings, not executable deserialization or a fake browser service. +local factory = dofile(assert(arg[1], "character.lua path required")) +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn); assert(ok, name .. ": " .. tostring(err)) + total = total + 1; print("PASS " .. name) +end +local function copy(t) local o = {}; for k,v in pairs(t) do o[k] = v end; return o end +local function harness() + local h = {sessions = {}, messages = {}, screens = {}, props = {}, closed = {}, commands = {}, connected = {}, json = {}, parses = 0} + local api = {} + function api.write_json(value) + if h.encode_fail then error("PRIVATE encoder detail") end + local fields = {} + for k,v in pairs(value) do fields[#fields+1] = '"' .. k .. '":' .. (type(v)=="number" and v or '"' .. tostring(v) .. '"') end + table.sort(fields) + local raw = "{" .. table.concat(fields,",") .. "}" + h.json[raw] = copy(value); return raw + end + function api.parse_json(raw, null, return_error) + h.parses = h.parses + 1; assert(null == nil and return_error == true) + if h.parse_throw then error("PRIVATE parse detail") end + return h.json[raw] and copy(h.json[raw]) or nil + end + function api.formspec_escape(s) + return (s:gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,")) + end + function api.get_player_by_name(name) return h.connected[name] end + function api.register_on_player_receive_fields(fn) h.fields = fn end + function api.register_chatcommand(name, def) h.commands[name] = def end + function api.show_formspec(name, form, spec) h.screens[#h.screens+1] = {name=name,form=form,spec=spec} end + function api.close_formspec(name, form) h.closed[#h.closed+1] = {name,form} end + function h.notify(_, message) h.messages[#h.messages+1] = message end + function h.player(name) + local meta = {raw = "", writes = 0} + function meta:get_string(k) assert(k == "hearth:appearance"); if self.read_fail then error("PRIVATE read") end; return self.raw end + function meta:set_string(k, v) + assert(k == "hearth:appearance"); self.writes = self.writes + 1 + if self.fail then error("PRIVATE write") end + if self.reject then return false end + self.raw = v + end + local p = {name=name or "alice", meta=meta, properties={}} + function p:get_player_name() return self.name end + function p:get_meta() return self.meta end + function p:set_properties(props) + for k in pairs(props) do assert(k == "mesh" or k == "textures", "cosmetics cannot alter " .. k) end + h.props[#h.props+1] = props; self.properties = props + end + h.sessions[p.name], h.connected[p.name] = {}, p + return p, meta + end + h.api, h.C = api, factory(h,api) + return h +end +test("factory exports fresh defaults and exact independent option lists", function() + local h = harness(); local C = h.C + local counts = {body=3,skin=6,face=4,eyes=5,hair=7,hairColor=6,beard=5,garment=4,cloth=8, + trousers=4,boots=3,accent=5,accentColor=8,detail=4} + for k,n in pairs(counts) do assert(#C.options()[k]==n,k) end + local d, o = C.defaults(), C.options(); d.skin="BAD"; o.skin[1]="BAD" + assert(C.defaults().skin=="sand" and C.options().skin[1]=="porcelain" and C.VERSION==1) +end) +test("strict schema rejects missing extra typed version and untrusted selections", function() + local C = harness().C + for _,bad in ipairs({false, 1, "{}", {}}) do assert(not C.validate(bad)) end + for _,k in ipairs({"version","body","skin","face","eyes","hair","hairColor","beard","garment","cloth","trousers","boots","accent","accentColor","detail"}) do + local d = C.defaults(); d[k]=nil; assert(not C.validate(d),k) + end + for _,v in ipairs({2,"1",false,{}}) do local d=C.defaults(); d.version=v; assert(not C.validate(d)) end + for _,v in ipairs({"../skin.png","#ffffff","sand^other.png", "];button[0,0;", "https://invalid.example/skin", "", false, {}}) do + local d=C.defaults(); d.skin=v; assert(not C.validate(d)) + end + for _,k in ipairs({"name","player","texture","__index",1}) do local d=C.defaults(); d[k]="x"; assert(not C.validate(d)) end + assert(not C.validate(setmetatable(C.defaults(),{}))) +end) +test("all option values validate and map only to fixed mesh and layer filenames", function() + local C=harness().C + for k,list in pairs(C.options()) do for _,id in ipairs(list) do + local d=C.defaults(); d[k]=id; assert(C.validate(d)) + local mesh, texture=C.preview(d) + assert(mesh:match("^hearth_person[_%a]*%.b3d$")) + local n=0; for layer in texture:gmatch("[^%^]+") do assert(layer:match("^hearth_char_[%a_]+%.png$")); n=n+1 end + assert(n==10) + end end +end) +test("preview exactly follows opaque skin face eyes clothing accent hair beard detail order", function() + local C=harness().C; local mesh,texture=C.preview(C.defaults()) + assert(mesh=="hearth_person.b3d") + assert(texture=="hearth_char_skin_sand.png^hearth_char_face_calm.png^hearth_char_eyes_hazel.png^hearth_char_garment_tunic_teal.png^hearth_char_trousers_earth.png^hearth_char_boots_walnut.png^hearth_char_accent_scarf_sage.png^hearth_char_hair_sidepart_chestnut.png^hearth_char_beard_none_chestnut.png^hearth_char_detail_none.png") + assert(not C.preview({})) +end) +test("bounded silent JSON roundtrip refuses malformed oversize nested and invalid data", function() + local h=harness(); local C=h.C; local d=C.defaults(); local raw=assert(C.encode(d)) + assert(#raw<2048 and C.decode(raw).hair==d.hair) + for _,raw_bad in ipairs({"", "{oops", "null", "[]", '{"skin":{}}', string.rep("{",1000), string.rep("x",2049)}) do + assert(not C.decode(raw_bad)) + end + local before=h.parses; assert(not C.decode(string.rep("x",2049))); assert(h.parses==before) + d.extra=true; assert(not C.decode(h.api.write_json(d))) + h.parse_throw=true; assert(not C.decode(raw)) + h.parse_throw=false; h.encode_fail=true; assert(not C.encode(C.defaults())) +end) +test("join applies defaults without saving or opening a form", function() + local h=harness(); local p,m=h.player(); assert(h.C.on_join(p)) + assert(m.writes==0 and #h.screens==0 and #h.messages==0 and #h.props==1) + assert(not h.C.state(p).warning) +end) +test("saved JSON restores selected profile and appearance without a rewrite", function() + local h=harness(); local p,m=h.player(); local d=h.C.defaults(); d.body="broad"; d.skin="ebony" + m.raw=assert(h.C.encode(d)); assert(h.C.apply(p)) + assert(p.properties.mesh=="hearth_person_broad.b3d" and p.properties.textures[1]:find("skin_ebony",1,true)) + assert(m.writes==0 and not h.C.state(p).warning and #h.messages==0) +end) +test("corrupt saved bytes and read errors use defaults warn safely and never kick or overwrite", function() + for _,raw in ipairs({"PRIVATE malformed",string.rep("x",2049)}) do + local h=harness(); local p,m=h.player(); m.raw=raw; assert(h.C.on_join(p)) + assert(m.raw==raw and m.writes==0 and #h.messages==1 and h.C.state(p).warning) + assert(not h.messages[1]:find("PRIVATE",1,true)); h.C.show(p) + assert(h.screens[1].spec:find("original data is retained",1,true)) + end + local h=harness(); local p,m=h.player(); m.read_fail=true; assert(h.C.on_join(p) and m.writes==0) +end) +test("command requires connected own session and other forms are ignored", function() + local h=harness(); assert(not h.commands.character.func("offline")) + local p=h.player(); assert(h.commands.character.func("alice")) + assert(h.screens[1].name=="alice" and h.screens[1].form==h.C.FORM) + assert(h.fields(p,"hearth:location",{apply=true})==nil) + h.sessions.alice=nil; assert(not h.commands.character.func("alice")) +end) +test("four tab layout contains real escaped one-texture model and bounded controls", function() + local h=harness(); local p=h.player(); h.C.show(p) + for i=1,4 do + h.C.handle(p,{["tab_"..i]=true}); local spec=h.screens[#h.screens].spec + assert(spec:find("size[14,11]",1,true) and spec:find("model[0.65,1.65;5.1,6.4;traveler;hearth_person.b3d;",1,true)) + assert(spec:find(";0,180;true;true;0,19;12]",1,true)) + assert(spec:find("Face]",1,true) and spec:find("Hair]",1,true) and spec:find("Clothing]",1,true) and spec:find("Finishing]",1,true)) + assert(not spec:find("http",1,true) and not spec:find("hearth_person.png",1,true)) + for x,y,w,ht in spec:gmatch("button%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);") do + assert(tonumber(x)+tonumber(w)<=14 and tonumber(y)+tonumber(ht)<=11) + end + end + assert(h.screens[#h.screens].spec:find("Freckles and scar",1,true)) + -- Escape is applied to texture names too, rather than assuming safe output. + local calls=0; local original=h.api.formspec_escape + h.api.formspec_escape=function(s) calls=calls+1; return original(s) end + h.C.show(p); assert(calls>10) +end) +test("draft tabs and rotations never publish player properties or metadata", function() + local h=harness(); local p,m=h.player(); h.C.show(p); local before=#h.props + assert(h.C.handle(p,{pick_body="1",pick_skin="6",tab_2=true})) + assert(h.C.handle(p,{pick_hair="5",pick_hairColor="6",pick_beard="5"})) + assert(h.C.handle(p,{back=true}) and h.C.state(p).yaw==0) + assert(h.C.handle(p,{profile=true}) and h.C.state(p).yaw==90) + assert(h.C.handle(p,{front=true}) and h.C.state(p).yaw==180) + assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).draft.hair=="long") + assert(#h.props==before and m.writes==0) +end) +test("cancel and Escape discard draft preserving saved appearance and persistence", function() + for _,field in ipairs({"cancel","quit"}) do + local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1] + h.C.handle(p,{pick_skin="6"}); h.C.handle(p,{[field]=true}) + assert(not h.C.state(p).draft and m.writes==0 and p.properties.textures[1]==old) + h.C.show(p); assert(h.C.state(p).draft.skin=="sand") + end +end) +test("apply writes first then publishes and saved data survives a new session", function() + local h=harness(); local p,m=h.player(); h.C.show(p) + local original=p.set_properties + p.set_properties=function(self,props) assert(m.writes==1 and h.C.decode(m.raw).skin=="ebony"); original(self,props) end + assert(h.C.handle(p,{pick_skin="6",pick_body="3",apply=true})) + assert(m.writes==1 and not h.C.state(p).draft and p.properties.mesh=="hearth_person_broad.b3d") + p.set_properties=original; h.sessions.alice={}; h.C.on_join(p) + assert(h.C.state(p).saved.skin=="ebony" and m.writes==1 and not h.C.state(p).warning) +end) +test("failed metadata or serialization leaves old published appearance and saved bytes", function() + for _,mode in ipairs({"throw","false","encode"}) do + local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1] + m.fail=mode=="throw"; m.reject=mode=="false"; h.encode_fail=mode=="encode" + h.C.handle(p,{pick_skin="6",apply=true}) + assert(m.raw=="" and p.properties.textures[1]==old and h.C.state(p).saved.skin=="sand") + assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).warning:find("could not be saved",1,true)) + assert(not h.screens[#h.screens].spec:find("PRIVATE",1,true)) + end +end) +test("corrupt record is replaced only by explicit successful Apply", function() + local h=harness(); local p,m=h.player(); m.raw="PRIVATE bad" + h.C.show(p); h.C.handle(p,{reset=true}); h.C.handle(p,{cancel=true}); assert(m.raw=="PRIVATE bad") + h.C.show(p); h.C.handle(p,{apply=true}); assert(h.C.decode(m.raw) and m.writes==1 and not h.C.state(p).warning) +end) +test("reset and randomization use every allowlist without mutating defaults or publishing", function() + local h=harness(); local p,m=h.player(); h.C.show(p); local before=#h.props + for i=1,40 do h.C.handle(p,{randomize=true}); assert(h.C.validate(h.C.state(p).draft)) end + h.C.handle(p,{reset=true}); local d=h.C.state(p).draft + for k,v in pairs(h.C.defaults()) do assert(d[k]==v) end + assert(m.writes==0 and #h.props==before) + d.skin="BAD"; assert(h.C.state(p).draft.skin=="sand") +end) +test("forged fields indices hidden selections and target identities reject whole event", function() + local h=harness(); local p,m=h.player(); local q,n=h.player("bob"); h.C.show(p); h.C.show(q) + local cases={{pick_skin="0"},{pick_skin="7"},{pick_skin="01"},{pick_skin="1.0"},{pick_skin="NaN"}, + {pick_skin={}}, {pick_skin=string.rep("1",2049)}, {pick_skin="];button[0,0;"}, + {player="bob",apply=true,pick_skin="6"}, {pick_hair="1",apply=true}, {texture="x",apply=true}} + for _,f in ipairs(cases) do assert(not h.C.handle(p,f)); assert(h.C.state(p).draft.skin=="sand") end + assert(m.writes==0 and n.writes==0 and h.C.state(q).draft.skin=="sand") + assert(not h.C.handle({get_player_name=function() return "alice" end},{apply=true})) +end) +test("stale sessions reconnects closed drafts and disconnected objects cannot save", function() + local h=harness(); local p,m=h.player(); h.C.show(p); h.sessions.alice={} + assert(not h.C.handle(p,{apply=true}) and m.writes==0) + h.C.show(p); h.connected.alice=nil; assert(not h.C.handle(p,{apply=true})) + h.connected.alice=p; h.C.show(p); h.C.handle(p,{cancel=true}); assert(not h.C.handle(p,{apply=true})) +end) +assert(total==18,"Unexpected character test count") +print("PASS " .. total .. " character creator tests") diff --git a/tests/clock_spec.lua b/tests/clock_spec.lua new file mode 100755 index 0000000..232f5f6 --- /dev/null +++ b/tests/clock_spec.lua @@ -0,0 +1,239 @@ +local target = assert(arg[1], "clock.lua path required") +local total = 0 +local function test(name, fn) + local old_core, old_hearth, old_time = core, hearth, os.time + local ok, err = pcall(fn) + core, hearth, os.time = old_core, old_hearth, old_time + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +-- Contract doubles keep this suite independent of the engine, filesystem, +-- schema implementation, and real calendar/timezone. model_spec tests the math; +-- persistence_spec tests validation/migration of the stored records themselves. +local function harness(settings, singleplayer, stored) + local calls = {loads = 0, saves = 0, deferred = {}, solar = {}} + local C = {settings = {get = function(_, key) return settings[key] end}} + C.is_singleplayer = function() return singleplayer end + C.after = function(delay, fn) calls.deferred[#calls.deferred + 1] = {delay = delay, fn = fn} end + C.set_timeofday = function(value) calls.timeofday = value end + local P = {} + P.load_world = function(kind) + assert(kind == "location"); calls.loads = calls.loads + 1 + if calls.load_error then error("Last Hearth: persistence failed; save retained for recovery", 0) end + return stored + end + P.save_world = function(kind, value) + assert(kind == "location"); calls.saves = calls.saves + 1 + if calls.save_error then error("Last Hearth: persistence failed; save retained for recovery", 0) end + calls.saved = value + end + local M = {} + M.location = function(lat, lon) + lat, lon = tonumber(lat), tonumber(lon) + if not lat or not lon or lat ~= lat or lon ~= lon or math.abs(lat) > 90 or math.abs(lon) > 180 then return end + return {latitude = lat, longitude = lon} + end + M.solar = function(now, lat, lon) + local value = {now = now, latitude = lat, longitude = lon, timeofday = 0.5} + calls.solar[#calls.solar + 1] = value + return value + end + M.weather = function(now, lat, lon) + calls.weather = {now = now, latitude = lat, longitude = lon} + return "clear" + end + core, hearth = C, {model = M, persistence = P} + os.time = function() return calls.now or 1800000000 end + return calls +end +local function rejects(settings, singleplayer, text, stored) + local calls = harness(settings, singleplayer, stored) + local ok, message = pcall(dofile, target) + assert(not ok and type(message) == "string" and message:find(text, 1, true), "Expected configuration rejection") + assert(#calls.deferred == 0 and calls.saves == 0, "Rejected configuration must not start clock or save") + return calls +end +local function configured(deployment) + return {hearth_environment = deployment, hearth_latitude = "45", hearth_longitude = "0"} +end +test("singleplayer defaults local with disclosed neutral preview and deferred initialization", function() + local calls = harness({}, true) + dofile(target) + assert(hearth.has_location == false and hearth.clock == nil and hearth.weather == nil) + assert(calls.loads == 1 and calls.saves == 0 and #calls.deferred == 1) + assert(calls.deferred[1].delay == 0 and calls.deferred[1].fn == hearth.clock_update) + calls.deferred[1].fn() + assert(hearth.clock.latitude == 0 and hearth.clock.longitude == 0) + assert(hearth.clock.now == 1800000000 and hearth.weather == "clear" and calls.timeofday == 0.5) + assert(hearth.has_location == false) +end) +test("dedicated defaults production and requires explicit coordinates", function() + rejects({}, false, "requires explicit") + local calls = harness(configured(nil), false) + dofile(target); calls.deferred[1].fn() + assert(hearth.has_location and hearth.clock.latitude == 45) +end) +test("only the three environment enum values are accepted", function() + for _, value in ipairs({"", "dev", "LOCAL", "Production", " staging", "PRIVATE\nfixture"}) do + rejects({hearth_environment = value}, true, "must be local, staging, or production") + end +end) +test("dedicated local is forbidden even with coordinates", function() + rejects(configured("local"), false, "dedicated servers cannot") +end) +test("production rejects every nonempty date before parsing or persistence", function() + for _, value in ipairs({"1782043200", "bad", " ", "0", "nan"}) do + local settings = configured("production"); settings.hearth_preview_date = value + assert(rejects(settings, true, "production rejects").loads == 0) + end +end) +test("production rejects all weather settings except auto", function() + for _, value in ipairs({"rain", "clear", "", "bad", "AUTO"}) do + local settings = configured("production"); settings.hearth_preview_weather = value + rejects(settings, false, "production rejects") + end +end) +test("production rejects launcher mode and smoke token independently", function() + for _, key in ipairs({"hearth_launcher_mode", "hearth_smoke_token"}) do + for _, value in ipairs({"", "PRIVATE\nfixture"}) do + local settings = configured("production"); settings[key] = value + local calls = harness(settings, false) + local ok, message = pcall(dofile, target) + assert(not ok and message == "Last Hearth: production rejects fixture and launcher test settings.") + assert(calls.loads == 0 and calls.saves == 0 and #calls.deferred == 0) + end + end +end) +test("production rejects every course setting including empty and disabled strings before persistence", function() + for _, singleplayer in ipairs({true, false}) do + for _, key in ipairs({"hearth_m1_course", "hearth_m1_course_nonce", + "hearth_m1_course_snag", "hearth_course_token"}) do + for _, value in ipairs({"", "false", "0", "true", "PRIVATE\nfixture"}) do + local settings = configured("production"); settings[key] = value + local calls = harness(settings, singleplayer, {latitude = 45, longitude = 0}) + local ok, message = pcall(dofile, target) + assert(not ok and message == "Last Hearth: production rejects fixture and launcher test settings.", key) + assert(calls.loads == 0 and calls.saves == 0 and #calls.deferred == 0) + assert(#calls.solar == 0 and calls.weather == nil and calls.timeofday == nil) + assert(hearth.clock_update == nil and hearth.set_location == nil) + end + end + end +end) +test("implicit dedicated production also rejects course flags before loading stored location", function() + for _, key in ipairs({"hearth_m1_course", "hearth_m1_course_nonce", + "hearth_m1_course_snag", "hearth_course_token"}) do + local settings = configured(nil); settings[key] = "false" + assert(rejects(settings, false, "production rejects", {latitude = 45, longitude = 0}).loads == 0) + end +end) +test("local and staging clocks accept course fixtures without granting enrollment or starting a run", function() + for _, singleplayer in ipairs({true, false}) do + local settings = configured(singleplayer and "local" or "staging") + settings.hearth_m1_course, settings.hearth_m1_course_nonce = "true", "public-clock-fixture" + settings.hearth_m1_course_snag, settings.hearth_course_token = "false", "public-clock-fixture" + settings.hearth_preview_date, settings.hearth_preview_weather = "1782043200", "clear" + settings.hearth_launcher_mode = "course" + local calls = harness(settings, singleplayer) + dofile(target); calls.deferred[1].fn() + assert(calls.loads == 1 and calls.saves == 0 and #calls.deferred == 1) + assert(hearth.clock.now == 1782043200 and hearth.clock.latitude == 45 and hearth.clock.longitude == 0) + assert(hearth.weather == "clear" and hearth.course == nil and hearth.sessions == nil) + end +end) +test("production allows empty date and automatic weather and advances real UTC", function() + local settings = configured("production") + settings.hearth_preview_date, settings.hearth_preview_weather = "", "auto" + local calls = harness(settings, false) + dofile(target); hearth.clock_update() + assert(hearth.clock.now == 1800000000) + calls.now = 1800000010; hearth.clock_update() + assert(hearth.clock.now == 1800000010 and calls.weather.now == 1800000010) +end) +test("production requires configured valid coordinates even in singleplayer with a stored location", function() + for _, singleplayer in ipairs({true, false}) do + for _, coords in ipairs({{}, {"45"}, {"bad", "0"}, {"91", "0"}, {"0", "181"}}) do + rejects({hearth_environment = "production", hearth_latitude = coords[1], hearth_longitude = coords[2]}, + singleplayer, "requires explicit", {latitude = 45, longitude = 0}) + end + end +end) +test("dedicated staging preserves the explicit coordinate requirement", function() + rejects({hearth_environment = "staging"}, false, "requires explicit", {latitude = 45, longitude = 0}) +end) +test("staging accepts integer fixture dates including bounds on dedicated engines", function() + for _, date in ipairs({"946684800", "1782043200", "1797854400", "4102444800"}) do + local settings = configured("staging") + settings.hearth_preview_date, settings.hearth_preview_weather = date, "rain" + settings.hearth_launcher_mode, settings.hearth_smoke_token = "smoke", "fixture" + local calls = harness(settings, false) + dofile(target); hearth.clock_update() + assert(hearth.clock.now == tonumber(date) and calls.weather.now == tonumber(date)) + assert(hearth.weather == "rain" and calls.timeofday == 0.5) + end +end) +test("local fixtures and all supported weather choices work", function() + for _, weather in ipairs({"auto", "clear", "rain"}) do + local calls = harness({hearth_preview_date = "1782043200", hearth_preview_weather = weather}, true) + dofile(target); hearth.clock_update() + assert(hearth.clock.now == 1782043200 and calls.weather.now == 1782043200) + assert(hearth.weather == (weather == "auto" and "clear" or weather)) + end +end) +test("nonproduction rejects fractional out of range and nonfinite dates", function() + for _, deployment in ipairs({"local", "staging"}) do + for _, date in ipairs({"946684799", "4102444801", "1782043200.5", "bad", "nan", "inf", "1e309", " "}) do + local settings = configured(deployment); settings.hearth_preview_date = date + rejects(settings, deployment == "local", "must be an integer UTC timestamp") + end + end +end) +test("nonproduction rejects invalid weather instead of ignoring it", function() + for _, weather in ipairs({"", "snow", "Rain"}) do + rejects({hearth_preview_weather = weather}, true, "must be auto, clear, or rain") + end +end) +test("singleplayer stored location is loaded exclusively through persistence", function() + local calls = harness({}, true, {latitude = -30, longitude = 120}) + dofile(target); hearth.clock_update() + assert(calls.loads == 1 and calls.saves == 0 and hearth.has_location) + assert(hearth.clock.latitude == -30 and hearth.clock.longitude == 120) +end) +test("configured location takes precedence but still validates stored location", function() + local calls = harness(configured("local"), true, {latitude = -30, longitude = 120}) + dofile(target); hearth.clock_update() + assert(calls.loads == 1 and hearth.clock.latitude == 45 and hearth.clock.longitude == 0) + calls = harness(configured("production"), false); calls.load_error = true + local ok = pcall(dofile, target) + assert(not ok and calls.loads == 1 and calls.saves == 0 and #calls.deferred == 0) +end) +test("stored location errors never silently become neutral previews", function() + local calls = harness({}, true); calls.load_error = true + assert(not pcall(dofile, target)) + assert(calls.saves == 0 and #calls.deferred == 0) +end) +test("set_location saves before publishing and next update uses the new coordinates", function() + local calls = harness({}, true) + dofile(target) + hearth.set_location(-45, 70) + assert(calls.saves == 1 and calls.saved.latitude == -45 and calls.saved.longitude == 70) + assert(hearth.has_location) + hearth.clock_update(); assert(hearth.clock.latitude == -45 and hearth.clock.longitude == 70) +end) +test("failed location save retains unchosen and previously selected state", function() + local calls = harness({}, true) + dofile(target); calls.save_error = true + assert(not pcall(hearth.set_location, 45, 10) and not hearth.has_location) + hearth.clock_update(); assert(hearth.clock.latitude == 0 and hearth.clock.longitude == 0) + calls.save_error = false; hearth.set_location(20, 30); calls.save_error = true + assert(not pcall(hearth.set_location, 45, 10) and hearth.has_location) + hearth.clock_update(); assert(hearth.clock.latitude == 20 and hearth.clock.longitude == 30) +end) +test("invalid location input and dedicated selection never write", function() + local calls = harness({}, true) + dofile(target) + assert(not pcall(hearth.set_location, 91, 0) and calls.saves == 0) + calls = harness(configured("staging"), false) + dofile(target) + assert(not pcall(hearth.set_location, 0, 0) and calls.saves == 0) +end) +print("PASS " .. total .. " clock tests") diff --git a/tests/combat_spec.lua b/tests/combat_spec.lua new file mode 100755 index 0000000..84d9e78 --- /dev/null +++ b/tests/combat_spec.lua @@ -0,0 +1,95 @@ +-- Standalone Lua 5.1+; also runs in the engine's isolated unit environment. +local M = dofile(assert(arg[1], "combat.lua path required")) +local count = 0 +local function test(name, fn) + local ok, err = pcall(fn); assert(ok, name .. ": " .. tostring(err)); count = count + 1 +end +local function context() + return {live = true, hp = 20, state = {stamina = 100}, session = {}, now = 10, + weapon = "hearth:axe", allowed = true, clear = true, + origin = {x = 0, y = 0, z = 0}, eye = {x = 0, y = 1.55, z = 0}, + target = {x = 0, y = 1.55, z = 2}, look = {x = 0, y = 0, z = 1}} +end +local function denied(c, reason) + local hit, why = M.valid_attack(c); assert(not hit and why == reason, tostring(why)) +end +test("weapon reach damage cadence cost and durability are distinct", function() + local c = context() + for _, spec in ipairs({{"hearth:axe", 2.5, 4, 1.1, 12}, {"hearth:knife", 1.8, 3, 0.7, 8}, {"hearth:spear", 3.4, 5, 1.2, 14}}) do + c.weapon, c.target.z = spec[1], spec[2] + local a = assert(M.valid_attack(c)); assert(a.damage == spec[3] and a.cadence == spec[4] and a.stamina == spec[5] and a.wear > 0) + c.target.z = spec[2] + 0.001; denied(c, "reach") + a.damage = 999; c.target.z = spec[2]; assert(M.valid_attack(c).damage == spec[3]) + end +end) +test("head center ray obstruction and full vertical distance", function() + local c = context(); c.clear = false; denied(c, "obstructed") + c.clear = nil; denied(c, "obstructed") + c.clear = true; c.target.y = 5; denied(c, "reach") + c.target.y, c.target.z = 3.55, 0; c.look = {x = 0, y = 1, z = 0}; assert(M.valid_attack(c)) +end) +test("aim cone is normalized three dimensional and required even nearby", function() + local c = context(); c.target.z = 0.2 + c.look = {x = 0, y = 0, z = -1}; denied(c, "aim") + c.look = {x = math.sqrt(1 - 0.35^2), y = 0, z = 0.35}; assert(M.valid_attack(c)) + c.look.z = 0.349; denied(c, "aim") + c.look = {x = 0, y = 100, z = 0}; denied(c, "aim") + c.look = {x = 0, y = 0, z = 0}; denied(c, "aim") +end) +test("one session deadline survives weapon swap and target swap", function() + local c = context(); local a = assert(M.valid_attack(c)) + c.session.attack_ready_at, c.session.combat_now = c.now + a.cadence, c.now + c.weapon, c.target.z, c.now = "hearth:knife", 1, 10.8; denied(c, "cadence") + c.weapon, c.target.z = "hearth:spear", 3; denied(c, "cadence") + c.now = 11.1; assert(M.valid_attack(c)) + c.now = 9; denied(c, "clock") +end) +test("exhaustion never regenerates on rejected or accepted validation", function() + local c = context(); c.state.stamina = 11.99 + for _ = 1, 100 do denied(c, "stamina") end + assert(c.state.stamina == 11.99 and c.session.attack_ready_at == nil) + c.state.stamina = 12; assert(M.valid_attack(c)); assert(c.state.stamina == 12) + c.state.stamina = 0 / 0; denied(c, "stamina") +end) +test("live state session dead waiting and explicit claims fail closed", function() + local c = context(); c.live = false; denied(c, "inactive") + c.live, c.hp = true, 0; denied(c, "inactive") + c.hp, c.session.waiting = 20, true; denied(c, "inactive") + c.session, c.state = {}, nil; denied(c, "inactive") + c.state, c.session = {stamina = 100}, nil; denied(c, "inactive") + c.session, c.allowed = {}, false; denied(c, "claim") + c.allowed = nil; denied(c, "claim") +end) +test("unsupported tools malformed positions and clocks cannot hit", function() + local c = context(); c.weapon = "hearth:pick"; denied(c, "weapon") + c.weapon, c.now = "hearth:axe", math.huge; denied(c, "clock") + c.now, c.target.x = 10, 0 / 0; denied(c, "position") +end) +test("short sprint displacement accepted but recent teleport rejected", function() + local c = context(); c.session.combat_sample = M.sample(nil, c.origin, 9.9) + c.origin.x = 0.8; assert(M.valid_attack(c)) + c.origin.x = 5; denied(c, "movement") + local previous = c.session.combat_sample + local updated = M.sample(previous, c.origin, 10) + assert(updated ~= previous and previous.pos.x == 0 and updated.blocked_until == 10.5) + c.session.combat_sample = updated; denied(c, "movement") + c.now = 10.5; assert(M.valid_attack(c)) + assert(M.sample(updated, c.origin, 9) == updated) +end) +test("round cooldown is independent of attack cooldown and entity reset", function() + local c = context(); c.session.spar_ready_at = 13 + denied(c, "cadence"); c.now = 13; assert(M.valid_attack(c)) +end) +test("brace requires fresh RMB spear facing and eight stamina not cached blocking", function() + local c = context(); c.weapon, c.controls = "hearth:spear", {RMB = true} + c.state.stamina = 8; assert(M.valid_guard(c)); assert(c.state.stamina == 8) + c.session.blocking, c.controls.RMB = true, false; assert(not M.valid_guard(c)) + c.controls.RMB, c.look.z = true, -1; assert(not M.valid_guard(c)) + c.look = {x = math.sqrt(1 - 0.4^2), y = 0, z = 0.4}; assert(M.valid_guard(c)) + c.look.z = 0.399; assert(not M.valid_guard(c)) + c.look = {x = 0, y = 0, z = 1}; c.state.stamina = 7.99; assert(not M.valid_guard(c)) + c.state.stamina, c.weapon = 8, "hearth:knife"; assert(not M.valid_guard(c)) + c.weapon, c.session.waiting = "hearth:spear", true; assert(not M.valid_guard(c)) + c.session.waiting, c.hp = false, 0; assert(not M.valid_guard(c)) +end) +print("PASS " .. count .. " combat checks") diff --git a/tests/construction_spec.lua b/tests/construction_spec.lua new file mode 100755 index 0000000..f81a855 --- /dev/null +++ b/tests/construction_spec.lua @@ -0,0 +1,150 @@ +-- Standalone, mocked engine only. arg[1] = construction.lua. +local factory = dofile(assert(arg[1], "construction.lua path required")) +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)) + total = total + 1; print("ok " .. total .. " - " .. name) +end +local function harness() + local h = {world = {}, violations = 0, writes = 0, placements = 0, notices = 0, sounds = 0} + local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end + function h.put(x, y, z, name) h.world[key({x = x, y = y, z = z})] = name end + local api = {registered_nodes = { + air = {walkable = false, buildable_to = true}, ignore = {}, + stone = {}, ["hearth:wall"] = {}, ["hearth:roof"] = {}, ["hearth:planks"] = {}, + ["hearth:foundation"] = {}, ["hearth:frame"] = {}, ["hearth:stairs"] = {}, + ["hearth:door"] = {}, ["hearth:door_open"] = {walkable = false}, + ["hearth:window"] = {}, ["hearth:vent"] = {}, + water = {walkable = false, buildable_to = true, liquidtype = "source"}, + leaves = {groups = {leaves = 1}}, + }} + function api.get_node_or_nil(p) + local name = h.world[key(p)] + if name == false then return nil end + return {name = name or "air"} + end + function api.is_protected() return h.protected == true end + function api.record_protection_violation() h.violations = h.violations + 1 end + function api.get_meta() h.writes = h.writes + 1; error("Preview wrote metadata") end + function api.item_place(stack) h.placements = h.placements + 1; return stack, "placed" end + local hearth = {claim_allowed = function() return not h.denied end, + claim_owner_at = function(p) return h.owners and h.owners[key(p)] end, + notify = function() h.notices = h.notices + 1 end} + local player = {pos = {x = 0, y = 0, z = 0}} + function player:is_player() return true end + function player:get_pos() return self.pos end + function player:get_player_name() return "tester" end + function player:get_player_control() return {sneak = false} end + h.api, h.hearth, h.player = api, hearth, player + h.key = key + h.module = factory(hearth, api) + function h.preview(item) return h.module.preview({x = 0, y = 0, z = 0}, player, item or "hearth:wall") end + return h +end +test("supported previews do not mutate inventory, metadata, notifications or placement", function() + local h = harness(); h.put(0, -1, 0, "stone") + local stack = {get_name = function() return "hearth:wall" end, + take_item = function() error("Consumed preview") end} + for _ = 1, 20 do local ok, reason = h.preview(stack); assert(ok and reason == "ok") end + assert(h.violations == 0 and h.writes == 0 and h.placements == 0 and h.notices == 0) +end) +test("distance is six blocks inclusive", function() + local h = harness(); h.put(0, -1, 0, "stone") + h.player.pos.x = 6; assert(h.preview()) + h.player.pos.x = 6.01; local ok, reason = h.preview(); assert(not ok and reason == "distance") +end) +test("protected and claim-denied preview never records a violation", function() + local h = harness(); h.put(0, -1, 0, "stone") + h.protected = true; local ok, reason = h.preview(); assert(not ok and reason == "protected") + h.protected, h.denied = false, true + ok, reason = h.preview(); assert(not ok and reason == "protected" and h.violations == 0) + assert(not h.module.allowed({x = 0, y = 0, z = 0}, h.player) and h.violations == 1) +end) +test("nil, ignore and unregistered target nodes fail unloaded", function() + for _, value in ipairs({false, "ignore", "missing"}) do + local h = harness(); h.put(0, 0, 0, value) + local ok, reason = h.preview(); assert(not ok and reason == "unloaded") + end +end) +test("occupied target is not buildable", function() + local h = harness(); h.put(0, 0, 0, "stone") + local ok, reason = h.preview(); assert(not ok and reason == "occupied") +end) +test("water replacement follows ordinary placement, but water is not an anchor", function() + local h = harness(); h.put(0, 0, 0, "water"); h.put(0, -1, 0, "water") + assert(h.preview("hearth:planks")) + local ok, reason = h.preview(); assert(not ok and reason == "support") +end) +test("unloaded support is distinguished from unsupported air or leaves", function() + local h = harness(); h.put(0, -1, 0, false) + local ok, reason = h.preview(); assert(not ok and reason == "unloaded") + h.put(0, -1, 0, "ignore"); ok, reason = h.preview(); assert(not ok and reason == "unloaded") + h.put(0, -1, 0, "leaves"); ok, reason = h.preview(); assert(not ok and reason == "support") +end) +test("two horizontal roof links allowed, third rejected", function() + local h = harness(); h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall") + for x = 1, 3 do + local ok, reason = h.module.preview({x = x, y = 0, z = 0}, h.player, "hearth:roof") + assert(ok == (x <= 2)); if x == 3 then assert(reason == "support") end + h.put(x, 0, 0, "hearth:roof") + end +end) +test("sixteen-block vertical limit is identical in preview", function() + local h = harness(); h.put(0, -1, 0, "stone") + for y = 0, 16 do + h.player.pos.y = y + local ok, reason = h.module.preview({x = 0, y = y, z = 0}, h.player, "hearth:wall") + assert(ok == (y < 16)); if y == 16 then assert(reason == "support") end + h.put(0, y, 0, "hearth:wall") + end +end) +test("invalid coordinate, player and item inputs fail closed", function() + local h = harness() + for _, p in ipairs({{}, {x = 0/0, y = 0, z = 0}, {x = 0.1, y = 0, z = 0}}) do + local ok, reason = h.module.preview(p, h.player, "hearth:wall"); assert(not ok and reason == "invalid") + end + assert(not h.module.preview({x = 0, y = 0, z = 0}, nil, "hearth:wall")) + for _, item in ipairs({{}, 123, "missing"}) do + local ok, reason = h.preview(item); assert(not ok and reason == "item") + end +end) +test("legacy unsupported placement still notifies without feedback", function() + local h = harness(); local stack = {} + local p = {type = "node", under = {x = 0, y = 0, z = 0}, above = {x = 0, y = 1, z = 0}} + assert(h.module.place_building(stack, h.player, p) == stack) + assert(h.notices == 1 and h.placements == 0) +end) +test("optional feedback and denial sound replace only chat presentation", function() + local h = harness(); local messages = 0 + h.hearth.feedback = function(_, _, kind) assert(kind == "warning"); messages = messages + 1 end + h.hearth.feedback_sound = function(name) assert(name == "hearth_denial"); h.sounds = h.sounds + 1 end + local p = {type = "node", under = {x = 0, y = 0, z = 0}, above = {x = 0, y = 1, z = 0}} + h.module.place_building({}, h.player, p) + assert(messages == 1 and h.sounds == 1 and h.notices == 0 and h.placements == 0) +end) +test("support removal refuses foundations that would leave dependent walls or roofs floating", function() + local h = harness(); h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall") + assert(not h.module.can_dig({x = 0, y = -1, z = 0}, h.player)) + assert(h.module.can_dig({x = 0, y = 0, z = 0}, h.player)) + h.put(1, -1, 0, "stone"); h.put(1, 0, 0, "hearth:wall") + assert(h.module.can_dig({x = 0, y = -1, z = 0}, h.player), "alternate natural support should preserve the wall") +end) +test("support removal checks dependent structures and fails closed at unloaded edges", function() + local h = harness(); h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall") + h.put(0, 1, 0, "hearth:roof") + assert(not h.module.can_dig({x = 0, y = 0, z = 0}, h.player)) + h.put(2, 0, 0, false) + assert(not h.module.can_dig({x = 0, y = -1, z = 0}, h.player)) +end) +test("placement and removal cannot create structural dependencies across homesteads", function() + local h = harness(); h.owners = {} + h.put(0, -1, 0, "stone"); h.owners[h.key({x=0,y=-1,z=0})] = "neighbor" + h.owners[h.key({x=0,y=0,z=0})] = "builder" + local ok, reason = h.preview("hearth:wall") + assert(not ok and reason == "support") + h.put(0, -1, 0, "stone"); h.put(0, 0, 0, "hearth:wall") + h.owners[h.key({x=0,y=0,z=0})] = "neighbor" + assert(not h.module.can_dig({x=0,y=-1,z=0}, h.player)) +end) +print("PASS " .. total .. " construction checks") diff --git a/tests/content_spec.lua b/tests/content_spec.lua index 2d00aec..64ed38c 100755 --- a/tests/content_spec.lua +++ b/tests/content_spec.lua @@ -1,8 +1,13 @@ -- Standalone Lua 5.1+ regression harness; no engine or MTG required. -- Pass the content module's path as the first command-line argument. -local nodes, items, crafts, biomes, decorations, lbms = {}, {}, {}, {}, {}, {} -local world, metas, timers, messages, state = {}, {}, {}, {}, {} -local now, protected, claim, violations = 100000, false, true, 0 +assert(arg and arg[1], "Pass the path to mods/hearth/content.lua") +local modpath = assert(arg[1]:match("^(.*)[/\\][^/\\]+$"), "Content path needs a directory") +local nodes, items, crafts, biomes, decorations, lbms, entities, globalsteps = {}, {}, {}, {}, {}, {}, {}, {} +local craftitems, tools, texture_calls = {}, {}, {} +local ores, registrations = {}, {} +local world, metas, timers, messages, sounds, state, formspecs = {}, {}, {}, {}, {}, {}, {} +local now, game_now, protected, claim, violations = 100000, 500, false, true, 0 +local claim_owner, member_role = "survivor", "owner" local player local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end local function pos(x, y, z) return {x = x or 0, y = y or 0, z = z or 0} end @@ -26,16 +31,36 @@ function Stack:replace(text) local new = stack(text) self.name, self.count = new.name, new.count end +function Stack:add_wear(amount) + self.wear = (self.wear or 0) + amount + if self.wear >= 65535 then self.name, self.count = "", 0 end +end +_G.ItemStack = stack core = {registered_nodes = nodes} -function core.register_node(name, def) nodes[name], items[name] = def, def end +function core.get_modpath(name) return name == "hearth" and modpath or nil end +function core.register_node(name, def) + assert(not nodes[name], "Duplicate node: " .. name) + nodes[name], items[name] = def, def + registrations[#registrations + 1] = name +end function core.register_item(name, def) items[name] = def end -function core.register_tool(name, def) items[name] = def end -function core.register_craftitem(name, def) items[name] = def end +function core.register_tool(name, def) items[name], tools[name] = def, def end +function core.register_craftitem(name, def) + assert(not items[name], "Duplicate item: " .. name) + items[name], craftitems[name] = def, def +end function core.register_craft(def) crafts[#crafts + 1] = def end function core.register_biome(def) biomes[#biomes + 1] = def end function core.register_decoration(def) decorations[#decorations + 1] = def end +function core.register_ore(def) ores[#ores + 1] = def end function core.register_lbm(def) lbms[#lbms + 1] = def end +function core.register_entity(name, def) entities[name] = def end +function core.register_globalstep(fn) globalsteps[#globalsteps + 1] = fn end +function core.get_connected_players() return {} end +function core.get_objects_inside_radius() return {} end +function core.get_gametime() return game_now end +function core.sound_play(name, params) sounds[#sounds + 1] = {name = name, params = params} end local aliases = {} function core.register_alias(name, target) aliases[name] = target end function core.get_node(p) return world[key(p)] or {name = "air"} end @@ -44,15 +69,30 @@ function core.get_node_or_nil(p) return core.get_node(p) end function core.swap_node(p, node) world[key(p)] = node end +function core.set_node(p, node) + local k = key(p) + world[k], metas[k], timers[k] = node, nil, nil + local def = nodes[node.name] + if def and def.on_construct then def.on_construct(p) end +end function core.is_protected() return protected end function core.record_protection_violation() violations = violations + 1 end +function core.show_formspec(name, form, spec) + formspecs[#formspecs + 1] = {name = name, form = form, spec = spec} +end function core.get_meta(p) local k = key(p) if not metas[k] then local fields = {} + local node_inventory = {items = {}, size = 0} + function node_inventory:set_size(_, size) self.size = size end + function node_inventory:is_empty() return #self.items == 0 end metas[k] = { set_string = function(_, name, value) fields[name] = value end, get_string = function(_, name) return fields[name] or "" end, + set_int = function(_, name, value) fields[name] = math.floor(value) end, + get_int = function(_, name) return tonumber(fields[name]) or 0 end, + get_inventory = function() return node_inventory end, } end return metas[k] @@ -67,6 +107,7 @@ function core.get_node_timer(p) end return timers[k] end +function core.get_gametime() return game_now end function core.item_place(s, who, pointed) local under = core.get_node(pointed.under) local def = nodes[under.name] @@ -85,34 +126,55 @@ end nodes.air = {buildable_to = true, walkable = false} hearth = { model = {}, players = {survivor = state}, - texture = function(color) return "[fill:16x16:" .. color end, - claim_allowed = function() return claim end, + texture = function(color) + texture_calls[#texture_calls + 1] = color + return "[fill:16x16:" .. color + end, + claim_owner_at = function() return claim and claim_owner or nil end, + claim_role_at = function(_, name) return name == claim_owner and "owner" or member_role end, + external_protected = function() return protected end, + claim_allowed = function(_, name, action) + if not claim then return false end + if name == claim_owner then return true end + if member_role == "resident" then return action ~= "arrival" and action ~= "combat" end + return member_role == "storekeeper" and action == "storage" + end, notify = function(_, message) messages[#messages + 1] = message end, } local inventory = {room = true, contents = {}} function inventory:room_for_item() return self.room end function inventory:add_item(_, name) - self.contents[#self.contents + 1] = name + self.contents[#self.contents + 1] = type(name) == "table" and name:get_name() or name return stack() end player = {position = pos(), sneak = false} +local player_fields = {} +local player_meta = { + get_int = function(_, name) return player_fields[name] or 0 end, + set_int = function(_, name, value) player_fields[name] = math.floor(value) end, +} function player:is_player() return true end function player:get_pos() return self.position end function player:get_player_name() return "survivor" end function player:get_inventory() return inventory end function player:get_player_control() return {sneak = self.sneak} end +function player:get_meta() return player_meta end local real_time = os.time os.time = function() return now end -assert(arg and arg[1], "Pass the path to mods/hearth/content.lua") -dofile(arg[1]) +-- Keep the runner's dofile: isolated engine runs map permitted sibling modules +-- into this same environment; standalone runs use Lua's normal file loader. +local core_before = {} +for name, value in pairs(core) do core_before[name] = value end +_G.dofile(arg[1]) local passed = 0 local function eq(actual, expected) assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual)) end local function reset() - world, metas, timers, messages = {}, {}, {}, {} - now, protected, claim, violations = 100000, false, true, 0 + world, metas, timers, messages, sounds, formspecs = {}, {}, {}, {}, {}, {} + now, game_now, protected, claim, violations = 100000, 500, false, true, 0 + claim_owner, member_role, player_fields = "survivor", "owner", {} player.position, player.sneak = pos(), false inventory.room, inventory.contents = true, {} state.food, state.water, state.stamina = 100, 100, 100 @@ -129,6 +191,18 @@ local function fire() put(firepos, "hearth:fire") nodes["hearth:fire"].on_construct(firepos) end +local function ignite() + local starter = stack("hearth:firestarter") + items["hearth:firestarter"].on_use(starter, player, point(firepos, pos(0, 1))) + return starter +end +local function advance(seconds) + now, game_now = now + seconds, game_now + seconds + local node = core.get_node(firepos) + if nodes[node.name] and nodes[node.name].on_timer then + nodes[node.name].on_timer(firepos, seconds) + end +end local function use(text) local s = stack(text) local node = core.get_node(firepos) @@ -140,10 +214,17 @@ local function deadline() return tonumber(core.get_meta(firepos):get_string("fue test("standalone registrations, recipes and mapgen resolve", function() for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", "log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks", "wall", - "roof", "fire", "fire_lit", "berries", "raw_food", "cooked_food", "vessel", - "water_raw", "water_clean", "knife", "axe", "pick", "spear"}) do + "roof", "foundation", "frame", "stairs", "door", "door_open", "window", "vent", "storage", "pantry", + "drying_rack", "bedroll", "torch", "fish_trap", "weir_stone", "sapling", "berry_bush_bare", "crop_1", "crop_2", "crop_3", "crop_4", + "grain_patch", "clay_bank", "iron_ore_vein", "fire", "fire_lit", "berries", "grain", "grain_seed", + "raw_food", "cooked_food", "fish_bait", "raw_fish", "cooked_fish", "fish_drying_charge", "dried_fish", + "vessel", "water_raw", "water_clean", "iron_ore", "iron_ingot", "charcoal", + "clay", "clay_jar", "clay_jar_charge", "dried_food", "dried_food_charge", "smelt_charge", "charcoal_charge", "knife", "axe", "iron_axe", + "pick", "spear", "firestarter", "cloak"}) do assert(items["hearth:" .. name], name) end + eq(nodes["hearth:torch"].light_source, 12) + eq(nodes["hearth:torch"].on_timer, nil) for _, recipe in ipairs(crafts) do assert(items[stack(recipe.output):get_name()]) for _, row in ipairs(recipe.recipe) do @@ -152,16 +233,255 @@ test("standalone registrations, recipes and mapgen resolve", function() end end end - eq(#biomes, 2) - eq(#decorations, 6) + assert(#biomes >= 2, "meadow and highland biomes remain available") + assert(#decorations > 0, "resource distribution remains available") + for _, name in ipairs({"meadow_grass", "wildflowers", "fern", "litter", "fungi", "deadwood", + "tree_seedling", "young_tree", "mature_tree", "dung"}) do + local def = assert(nodes["hearth:" .. name], name) + local wood = name=="young_tree" or name=="mature_tree" or name=="deadwood" + eq(def.tiles[1], wood and "hearth_log_top.png" or "hearth_" .. (name=="tree_seedling" and "sapling" or name) .. ".png") + eq(def.mesh, nil) + assert(def.groups.hearth_ecosystem and def.after_place_node and def.can_dig) + end + assert(hearth.succession and hearth.succession.forage and hearth.succession.deposit and hearth.succession.habitat) eq(aliases.mapgen_river_water_source, "hearth:water_source") for _, deco in ipairs(decorations) do if deco.schematic then - eq(#deco.schematic.data, 150) + eq(#deco.schematic.data, deco.schematic.size.x * deco.schematic.size.y * deco.schematic.size.z) for _, entry in ipairs(deco.schematic.data) do assert(nodes[entry.name]) end else assert(nodes[deco.decoration]) end end end) +test("biome and diversity integration is unique, complete and ordered before seasonal variants", function() + assert(hearth.seasons and hearth.botany and hearth.diversity and hearth.biomes and hearth.succession and hearth.wildlife) + eq(#biomes, #hearth.biomes.biome_profiles) + local seen = {} + for _, biome in ipairs(biomes) do + assert(not seen[biome.name], "duplicate biome: " .. biome.name) + seen[biome.name] = true + for _, field in ipairs({"node_top", "node_filler", "node_stone", "node_water", "node_riverbed"}) do + assert(nodes[biome[field]], biome[field]) + end + end + assert(seen.hearth_temperate and seen.hearth_riverbank and seen.hearth_rocky_peaks) + local index = {} + for i, name in ipairs(registrations) do index[name] = i end + eq(#hearth.biomes.species_order,10) + eq(table.concat(hearth.biomes.species_order,","),table.concat(hearth.diversity.species_order,",")) + for _, species in ipairs(hearth.biomes.species_order) do + for _, part in ipairs({"log", "leaves", "planks", "seedling", "young", "mature"}) do + local name = species .. "_" .. part + assert(nodes["hearth:" .. name], name) + if part == "leaves" or part == "seedling" or part == "young" or part == "mature" then + for _, season in ipairs({"spring", "autumn", "winter"}) do + local variant = "hearth:" .. name .. "_" .. season + if species == "pine" or species == "spruce" then eq(nodes[variant], nil) + else + assert(index[variant] > index["hearth:" .. name], variant) + eq(hearth.seasons.kind(variant), name) + end + end + end + end + end + assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"]) + assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"]) + eq(#hearth.botany.catalogue,80) + local botany_nodes=0 + for _,record in ipairs(hearth.botany.catalogue) do + assert(nodes[record.node],record.node) + if record.group~="trees" and not record.existing then + botany_nodes=botany_nodes+1 + assert(index[record.node]>index["hearth:lichen"] and index[record.node] flint.groupcaps.cracky.uses) + eq(copper.groupcaps.cracky.maxlevel, flint.groupcaps.cracky.maxlevel) + eq(copper.groupcaps.cracky.times[2], nil); eq(copper.groupcaps.choppy, nil) + eq(copper.damage_groups.fleshy, flint.damage_groups.fleshy) + eq(tools["hearth:copper_pick"].inventory_image, "hearth_item_copper_pick.png") +end) +test("all species and rocks keep single-item products and pair-only mass-preserving conversions", function() + local function exact(output, inputs) + local matches=0 + for _, def in ipairs(crafts) do + if def.type=="shapeless" and def.output==output and #def.recipe==#inputs then + local same=true + for i, input in ipairs(inputs) do if def.recipe[i]~=input then same=false end end + if same then matches=matches+1 end + end + end + eq(matches,1) + end + for _, species in ipairs(hearth.biomes.species_order) do + local log, planks="hearth:"..species.."_log", "hearth:"..species.."_planks" + exact(planks.." 4", {log}) + exact("hearth:log 2", {log,log}) + exact("hearth:planks", {planks}) + end + for _, rock in ipairs(hearth.biomes.rocks) do + local raw="hearth:"..rock + exact(raw.."_bricks 4", {raw}) + exact("hearth:stone 2", {raw,raw}) + end + exact("hearth:planks 4", {"hearth:log"}) + exact("hearth:sticks 4", {"hearth:planks"}) +end) +test("all 78 registered recipes close uniquely with exact output names and counts", function() + -- Exact-name registrations only. Compare shaped bounding boxes, and compare + -- ingredient multisets whenever either recipe is shapeless. This catches + -- single-input collisions even across craft types without pretending to be + -- the native resolver (the native smoke still calls get_craft_result). + eq(#crafts,78) + local signatures, outputs={},{} + for i, def in ipairs(crafts) do + assert(def.type==nil or def.type=="shapeless", "unsupported craft type") + local flat, rows, width={}, {}, 0 + if def.type=="shapeless" then rows={def.recipe} + else rows=def.recipe end + for _, row in ipairs(rows) do width=math.max(width,#row) end + local xmin,ymin,xmax,ymax=width+1,#rows+1,0,0 + for y, row in ipairs(rows) do for x, input in ipairs(row) do + if input~="" then + assert(items[input], "unknown or non-exact ingredient: "..input) + flat[#flat+1]=input + xmin,ymin=math.min(xmin,x),math.min(ymin,y) + xmax,ymax=math.max(xmax,x),math.max(ymax,y) + end + end end + assert(#flat>0 and #flat<=9) + table.sort(flat) + local grid={} + for y=ymin,ymax do for x=xmin,xmax do grid[#grid+1]=rows[y][x] or "" end end + local result=stack(def.output) + assert(items[result:get_name()] and result:get_count()>0) + outputs[result:get_name()]=true + signatures[i]={bag=table.concat(flat,","), shape=(xmax-xmin+1)..":"..table.concat(grid,","), + shapeless=def.type=="shapeless", name=result:get_name(), count=result:get_count()} + end + local output_count=0; for _ in pairs(outputs) do output_count=output_count+1 end + eq(output_count,52) + for i, expected in ipairs(signatures) do + local matches=0 + for j, candidate in ipairs(signatures) do + local same=expected.bag==candidate.bag and + (expected.shapeless or candidate.shapeless or expected.shape==candidate.shape) + if same then + eq(candidate.name,expected.name); eq(candidate.count,expected.count) + assert(i==j, "duplicate recipe for "..expected.name) + matches=matches+1 + end + end + eq(matches,1) + end +end) +test("legacy seasonal variants retain callbacks and cube geometry without mesh atlases", function() + local seasons = hearth.seasons + for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree", "leaves"}) do + local base = nodes["hearth:"..kind] + for _, season in ipairs({"spring", "autumn", "winter"}) do + local variant = assert(nodes["hearth:"..kind.."_"..season]) + eq(seasons.kind("hearth:"..kind.."_"..season), kind) + eq(variant.can_dig, base.can_dig) + eq(variant.after_place_node, base.after_place_node) + eq(variant.drop, base.drop) + eq(variant.collision_box, base.collision_box) + eq(variant.mesh,nil) + if kind=="young_tree" or kind=="mature_tree" then + eq(variant.drawtype,"normal"); eq(variant.tiles[1],"hearth_log_top.png") + eq(variant.tiles[2],"hearth_log_top.png"); eq(variant.tiles[3],"hearth_log_side.png") + elseif kind=="tree_seedling" then eq(variant.tiles[1],"hearth_sapling.png") + else eq(variant.tiles[1],"hearth_leaves_"..season..".png") + end + end + end + for _, kind in ipairs({"spring_greens", "autumn_mushrooms"}) do + local base = assert(nodes["hearth:"..kind.."_patch"]) + assert(base.on_rightclick and base.on_dig and not base.buildable_to) + eq(base.drop, "") + eq(base.tiles[1], "hearth_"..kind.."_dormant.png") + eq(items["hearth:"..kind].inventory_image, "hearth_item_"..kind..".png") + end + assert(items["hearth:spring_greens"].on_use) + eq(items["hearth:autumn_mushrooms"].on_use,nil) +end) +test("player placement marks even natural-looking terrain and ecological plants", function() + for _, name in ipairs({"grass", "soil", "meadow_grass", "mature_tree"}) do + local p = pos(2, 1) + nodes["hearth:" .. name].after_place_node(p, player) + eq(core.get_meta(p):get_string("player_placed"), "1") + end +end) +test("harvested crop soil remains marked farmland instead of native recruitment ground", function() + local p = pos(2, 1) + put(p, "hearth:crop_4") + hearth.ecology.harvest_crop(p, player) + eq(core.get_node(p).name, "hearth:soil") + eq(core.get_meta(p):get_string("player_placed"), "1") +end) +test("seasonal forage uses normal reach and protection authorization", function() + local original_climate=hearth.climate_at + hearth.climate_at=function() return {season="Spring",temperature=15} end + local p=pos(2,1) + put(p,"hearth:spring_greens_patch_spring") + local pick=nodes[core.get_node(p).name].on_rightclick + player.position=pos(20,1); pick(p,nil,player) + eq(#inventory.contents,0) + player.position=pos(); protected=true; pick(p,nil,player) + eq(#inventory.contents,0) + protected=false; pick(p,nil,player); eq(#inventory.contents,1) + eq(inventory.contents[1],"hearth:spring_greens") + pick(p,nil,player); eq(#inventory.contents,1) + state.food=50 + items["hearth:spring_greens"].on_use(stack("hearth:spring_greens"),player) + eq(state.food,62) + hearth.climate_at=original_climate +end) test("hand gathers soil and shrubs but cannot harvest trees or stone", function() local caps = items[":"].tool_capabilities.groupcaps assert(caps.crumbly and caps.snappy) @@ -170,14 +490,86 @@ test("hand gathers soil and shrubs but cannot harvest trees or stone", function( assert(items["hearth:axe"].tool_capabilities.groupcaps.choppy) assert(items["hearth:pick"].tool_capabilities.groupcaps.cracky) end) -test("fire consumes exactly one fuel and retains metadata through swaps", function() +test("fallen logs are axe-only ordinary timber cubes; loose sticks remain hand-gatherable", function() + local def = nodes["hearth:deadwood"] + eq(def.description, "Fallen log (axe; yields timber)") + eq(def.groups.snappy, nil); eq(def.groups.choppy, 2); eq(def.groups.tree,1) + eq(def.drop, "hearth:log") + eq(def.can_dig, hearth.construction.can_dig) + eq(def.on_dig, nil); eq(def.on_rightclick, nil) + eq(def.mesh, nil); eq(def.walkable, true); eq(def.drawtype,"normal"); eq(def.paramtype2,"facedir") + eq(def.selection_box,nil); eq(def.collision_box,nil); eq(def.node_box,nil) + eq(def.tiles[1],"hearth_log_top.png"); eq(def.tiles[2],def.tiles[1]); eq(def.tiles[3],"hearth_log_side.png") + eq(nodes["hearth:sticks"].groups.dig_immediate,3) + for _, kind in ipairs({"log", "young_tree", "mature_tree", "oak_log", "birch_log", "pine_log", "willow_log", "alder_log", "stone"}) do + eq(nodes["hearth:" .. kind].groups.snappy, nil) + end +end) +test("fallen log digging retains reach, claims, external protection and construction support checks", function() + local p = pos() + put(p, "hearth:deadwood") + local def = nodes["hearth:deadwood"] + eq(def.can_dig(p, player), true) + protected = true; eq(def.can_dig(p, player), false) + protected, claim = false, false; eq(def.can_dig(p, player), false) + claim, player.position = true, pos(7); eq(def.can_dig(p, player), false) + player.position = pos() + put(pos(0, 1), "hearth:wall") + eq(def.can_dig(p, player), false) + put(pos(0, 1), "air"); world[key(pos(2, 1))] = false + eq(def.can_dig(p, player), false) + eq(core.get_node(p).name, "hearth:deadwood") + eq(#inventory.contents, 0) +end) +test("deadwood migration rotates only legacy logs once and preserves provenance and timers", function() + local loader + for _,lbm in ipairs(lbms) do if lbm.name=="hearth:block_deadwood" then assert(not loader); loader=lbm end end + assert(loader and loader.run_at_every_load and #loader.nodenames==1) + eq(loader.nodenames[1],"hearth:deadwood") + local p=pos(2,1); put(p,"hearth:deadwood") + world[key(p)].param1=11 + local meta=core.get_meta(p) + meta:set_string("owner","survivor"); meta:set_string("player_placed","1") + meta:set_string("succ_natural","1"); meta:set_string("succ_age","345") + core.get_node_timer(p):start(77) + loader.action(p) + eq(core.get_node(p).name,"hearth:deadwood"); eq(core.get_node(p).param2,12); eq(core.get_node(p).param1,11) + eq(meta:get_string("owner"),"survivor"); eq(meta:get_string("player_placed"),"1") + eq(meta:get_string("succ_natural"),"1"); eq(meta:get_string("succ_age"),"345") + eq(meta:get_string("succ_block_deadwood"),"1"); eq(core.get_node_timer(p).timeout,77) + world[key(p)].param2=0; loader.action(p); eq(core.get_node(p).param2,0) -- once per node + for _,facedir in ipairs({4,8,12,16}) do + metas[key(p)]=nil; put(p,"hearth:deadwood"); world[key(p)].param2=facedir + loader.action(p); eq(core.get_node(p).param2,facedir) + end + for _,name in ipairs({"air","hearth:log","hearth:oak_log","hearth:water_source","ignore"}) do + metas[key(p)]=nil; put(p,name); loader.action(p,{name="hearth:deadwood"}) + eq(core.get_node(p).name,name); eq(core.get_node(p).param2,2); eq(metas[key(p)],nil) + end + put(p,"hearth:deadwood"); metas[key(p)]=nil; protected=true; loader.action(p) + eq(core.get_node(p).param2,2); eq(metas[key(p)],nil) + world[key(p)]=false; protected=false; loader.action(p); eq(world[key(p)],false) +end) +test("fuel stays cold until a reusable firestarter ignites it", function() fire() core.get_meta(firepos):set_string("owner", "survivor") eq(use("hearth:sticks 3"):get_count(), 2) eq(deadline(), now + 45) + eq(core.get_node(firepos).name, "hearth:fire") + local starter = ignite() + eq(starter.wear, math.ceil(65535 / 20)) + eq(sounds[1].name, "hearth_fire") eq(core.get_node(firepos).name, "hearth:fire_lit") eq(core.get_node(firepos).param2, 2) eq(core.get_meta(firepos):get_string("owner"), "survivor") + local second = pos(2) + put(second, "hearth:fire") + nodes["hearth:fire"].on_construct(second) + nodes["hearth:fire"].on_rightclick(second, core.get_node(second), player, stack("hearth:sticks")) + items["hearth:firestarter"].on_use(starter, player, point(second, pos(2, 1))) + eq(starter.wear, math.ceil(65535 / 20) * 2) + eq(core.get_node(second).name, "hearth:fire_lit") + eq(#sounds, 2) eq(use("hearth:log"):get_count(), 0) eq(deadline(), now + 225) end) @@ -189,39 +581,58 @@ test("full hearth rejects fuel rather than granting or wasting it", function() eq(use("hearth:sticks"):get_count(), 0) eq(use("hearth:sticks"):get_count(), 1) eq(deadline(), now + 585) + eq(core.get_node(firepos).name, "hearth:fire") end) test("timer and reload expire offline fuel without resetting the clock", function() fire() use("hearth:sticks") + ignite() now = now + 10 nodes["hearth:fire_lit"].on_timer(firepos, 999) - eq(deadline(), 100045) + eq(deadline(), now + 35) now = now + 1000 - lbms[1].action(firepos) + local fire_lbm + for _, lbm in ipairs(lbms) do if lbm.name == "hearth:expire_fire" then fire_lbm = lbm end end + assert(fire_lbm, "Missing hearth restart reconciler") + fire_lbm.action(firepos) eq(core.get_node(firepos).name, "hearth:fire") eq(deadline(), 0) eq(core.get_node_timer(firepos).timeout, nil) eq(use("hearth:raw_food"):get_name(), "hearth:raw_food") end) -test("single food cooks in hand and debits fuel", function() +test("food cooks for six real seconds after reserving the heat cost", function() fire() use("hearth:sticks") - eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food") + ignite() + eq(use("hearth:raw_food"):get_count(), 0) + eq(core.get_meta(firepos):get_string("job_kind"), "cook") + eq(nodes["hearth:fire_lit"].can_dig(firepos, player), false) eq(deadline(), now + 25) - eq(use("hearth:raw_food"):get_name(), "hearth:cooked_food") - eq(deadline(), now + 5) - eq(use("hearth:raw_food"):get_name(), "hearth:raw_food") -end) -test("full inventory does not destroy cooking input or fuel", function() - fire() - use("hearth:sticks") - inventory.room = false - eq(use("hearth:raw_food 4"):get_count(), 4) - eq(deadline(), now + 45) - inventory.room = true - eq(use("hearth:raw_food 4"):get_count(), 3) + advance(5) + eq(core.get_meta(firepos):get_string("job_kind"), "cook") + eq(deadline(), now + 20) + advance(1) + eq(core.get_meta(firepos):get_string("job_result"), "hearth:cooked_food") + eq(deadline(), now + 19) + use("") eq(inventory.contents[1], "hearth:cooked_food") - eq(deadline(), now + 25) + eq(core.get_meta(firepos):get_string("job_result"), "") + eq(nodes["hearth:fire_lit"].can_dig(firepos, player), true) +end) +test("full inventory preserves completed output for a later safe claim", function() + fire() + use("hearth:sticks") + ignite() + use("hearth:raw_food 4") + advance(6) + inventory.room = false + use("") + eq(core.get_meta(firepos):get_string("job_result"), "hearth:cooked_food") + eq(deadline(), now + 19) + inventory.room = true + use("") + eq(inventory.contents[1], "hearth:cooked_food") + eq(core.get_meta(firepos):get_string("job_result"), "") end) test("protected, claim-denied and distant fire interactions change nothing", function() fire() @@ -255,9 +666,13 @@ test("boiling requires fuel and drinking returns the carved cup only when thirst fire() eq(use("hearth:water_raw"):get_name(), "hearth:water_raw") use("hearth:sticks") - local water = use("hearth:water_raw") - eq(water:get_name(), "hearth:water_clean") - eq(deadline(), now + 35) + ignite() + use("hearth:water_raw") + eq(core.get_meta(firepos):get_string("job_kind"), "boil") + advance(4) + eq(core.get_meta(firepos):get_string("job_result"), "hearth:water_clean") + eq(deadline(), now + 31) + local water = stack("hearth:water_clean") local drink = items["hearth:water_clean"].on_use drink(water, player) eq(water:get_name(), "hearth:water_clean") @@ -322,6 +737,7 @@ test("node-item rightclick feeds fire even when the space above is occupied", fu nodes["hearth:sticks"].on_place(s, player, point(firepos, pos(0, 1))) eq(s:get_count(), 1) eq(deadline(), now + 45) + eq(core.get_node(firepos).name, "hearth:fire") end) test("construction height is limited to sixteen blocks above an anchor", function() @@ -345,9 +761,13 @@ test("water primary-use boiling obeys claim and distance checks", function() items["hearth:water_raw"].on_use(s, player, pointed) eq(s:get_name(), "hearth:water_raw") player.position = pos() + ignite() items["hearth:water_raw"].on_use(s, player, pointed) - eq(s:get_name(), "hearth:water_clean") - eq(deadline(), now + 35) + eq(s:get_name(), "") + eq(core.get_meta(firepos):get_string("job_kind"), "boil") + advance(4) + eq(core.get_meta(firepos):get_string("job_result"), "hearth:water_clean") + eq(deadline(), now + 31) end) test("all content nodes enforce claim-aware digging and placement", function() put(pos(0, -1), "hearth:stone") @@ -361,9 +781,291 @@ test("all content nodes enforce claim-aware digging and placement", function() end end end) -test("nested procedural fill texture arguments escape colons", function() - assert(nodes["hearth:grass"].tiles[3]:find("[fill\\:16x16\\:", 1, true)) - assert(nodes["hearth:berry_bush"].tiles[1]:find("[fill\\:16x16\\:", 1, true)) +test("every registration uses the named art manifest with no generic swatches", function() + local world_names = { + "stone", "soil", "grass_top", "grass_side", "sand", "log_top", "log_side", "leaves", + "planks", "wall", "roof", "thatch", "foundation", "frame", "stairs", "door", "window", + "vent", "storage", "pantry", "reeds", "berry_bush", "berry_bush_bare", "sapling", + "grain_patch", "crop_1", "crop_2", "crop_3", "crop_4", "clay_bank", "iron_ore_vein", + "water", "fire", "fire_lit", "torch", "sticks", "loose_stone", "bedroll", "drying_rack", + "fish_trap", "weir_stone", "meadow_grass", "wildflowers", "fern", "litter", "fungi", + "deadwood", "tree_seedling", "young_tree", "mature_tree", "dung", + "tree_atlas", "tree_atlas_spring", "tree_atlas_autumn", "tree_atlas_winter", + "leaves_spring", "leaves_autumn", "leaves_winter", + "meadow_grass_dormant", "fern_dormant", "wildflowers_dormant", + "spring_greens", "spring_greens_dormant", "autumn_mushrooms", "autumn_mushrooms_dormant", + } + local item_names = { + "berries", "grain", "grain_seed", "raw_food", "cooked_food", "fish_bait", "raw_fish", + "cooked_fish", "fish_drying_charge", "dried_fish", "iron_ore", "iron_ingot", "charcoal", + "charcoal_charge", "smelt_charge", "clay", "clay_jar", "clay_jar_charge", "dried_food", + "dried_food_charge", "cloak", "vessel", "water_raw", "water_clean", "firestarter", "iron_axe", + "spring_greens", "autumn_mushrooms", + "quartz", "copper_ore", "coal", "copper_ingot", "copper_charge", "copper_pick", + } + local manifest = {} + for _, name in ipairs(world_names) do manifest["hearth_" .. name .. ".png"] = true end + for _, name in ipairs({"granite", "basalt", "limestone", "slate", "sandstone"}) do + manifest["hearth_" .. name .. ".png"] = true + manifest["hearth_" .. name .. "_bricks.png"] = true + end + for _, name in ipairs({"forest_floor", "podzol", "alluvium", "peat", "alpine_turf", "gravel", "scree", "snow", + "quartz_vein", "copper_ore_vein", "coal_ore_vein"}) do manifest["hearth_" .. name .. ".png"] = true end + for _, species in ipairs(hearth.biomes.species_order) do + for _, part in ipairs({"log_top", "log_side", "planks", "leaves", "atlas"}) do + manifest["hearth_" .. species .. "_" .. part .. ".png"] = true + end + if species ~= "pine" and species ~= "spruce" then + for _, season in ipairs({"spring", "autumn", "winter"}) do + for _, part in ipairs({"leaves", "atlas"}) do + manifest["hearth_" .. species .. "_" .. part .. "_" .. season .. ".png"] = true + end + end + end + end + for _,record in ipairs(hearth.botany.catalogue) do + manifest[record.texture]=true + manifest["hearth_botany_" .. record.id .. "_dormant.png"]=true + end + for _, plant in ipairs({"clover", "nettle", "heather", "bluebell", "sedge", "bulrush", "moss", "lichen"}) do + manifest["hearth_" .. plant .. ".png"] = true + if plant ~= "moss" and plant ~= "lichen" then manifest["hearth_" .. plant .. "_dormant.png"] = true end + end + for _, name in ipairs(item_names) do manifest["hearth_item_" .. name .. ".png"] = true end + for _, name in ipairs({"knife", "axe", "pick", "spear"}) do + manifest["hearth_icon_" .. name .. ".png"] = true + end + local function check(image) + assert(type(image) == "string" and image ~= "", "missing registration art") + local source = image:match("^([^%^]+)") + assert(manifest[source], "unmanifested image: " .. image) + assert(not image:find("[fill", 1, true) and not image:find("^[multiply", 1, true) + and not image:find("^[lowpart", 1, true) and not image:find("^[contrast", 1, true), image) + end + for name, def in pairs(nodes) do + if name:match("^hearth:") then + assert(def.tiles and #def.tiles > 0, name) + for _, tile in ipairs(def.tiles) do check(type(tile) == "table" and tile.name or tile) end + for _, tile in ipairs(def.special_tiles or {}) do check(tile.name) end + end + end + for name, def in pairs(items) do + if name:match("^hearth:") then + if not nodes[name] then check(def.inventory_image) end + if def.inventory_image then check(def.inventory_image) end + if def.wield_image then check(def.wield_image) end + end + end + local count = 0 + for name, def in pairs(craftitems) do + count = count + 1 + eq(def.inventory_image, "hearth_item_" .. name:sub(8) .. ".png") + end + eq(count, #item_names - 3) -- firestarter, iron_axe and copper_pick are tools + for _, name in ipairs({"knife", "axe", "pick", "spear"}) do + eq(tools["hearth:" .. name].inventory_image, "hearth_icon_" .. name .. ".png") + end + for _, name in ipairs({"iron_axe", "firestarter", "copper_pick"}) do + eq(tools["hearth:" .. name].inventory_image, "hearth_item_" .. name .. ".png") + end + eq(#texture_calls, 1) + eq(texture_calls[1], "#c7aa83") -- compatibility swatch is exclusively the skin hand +end) +test("terrain faces, water and physical furniture use semantic materials", function() + for i, image in ipairs({"grass_top", "soil", "grass_side"}) do + eq(nodes["hearth:grass"].tiles[i], "hearth_" .. image .. ".png") + end + for i, image in ipairs({"log_top", "log_top", "log_side"}) do + eq(nodes["hearth:log"].tiles[i], "hearth_" .. image .. ".png") + end + for _, name in ipairs({"water_source", "water_flowing"}) do + local def = nodes["hearth:" .. name] + eq(def.tiles[1], "hearth_water.png^[opacity:185") + eq(#def.special_tiles, 2) + for i, tile in ipairs(def.special_tiles) do + eq(tile.name, def.tiles[1]); eq(tile.backface_culling, i == 2) + end + eq(def.use_texture_alpha, "blend") + end + local overrides = {door_open = "door", loose_stone = "stone", sticks = "log_side", + fire = "stone", fire_lit = "stone", drying_rack = "planks", fish_trap = "planks", + tree_seedling="sapling", young_tree="log_top", mature_tree="log_top", deadwood="log_top", + meadow_grass_winter="meadow_grass_dormant", fern_winter="fern_dormant", + wildflowers_autumn="wildflowers_dormant", wildflowers_winter="wildflowers_dormant", + spring_greens_patch="spring_greens_dormant", spring_greens_patch_spring="spring_greens", + autumn_mushrooms_patch="autumn_mushrooms_dormant", autumn_mushrooms_patch_autumn="autumn_mushrooms"} + for _, kind in ipairs({"tree_seedling", "young_tree", "mature_tree"}) do + for _, season in ipairs({"spring","autumn","winter"}) do overrides[kind.."_"..season]=kind=="tree_seedling" and "sapling" or "log_top" end + end + for _, species in ipairs(hearth.biomes.species_order) do + overrides[species .. "_log"] = species .. "_log_top" + for _, stage in ipairs({"seedling", "young", "mature"}) do + overrides[species .. "_" .. stage] = stage=="seedling" and "botany_"..species or species .. "_log_top" + if species ~= "pine" and species ~= "spruce" then + for _, season in ipairs({"spring", "autumn", "winter"}) do + overrides[species .. "_" .. stage .. "_" .. season] = stage=="seedling" and "botany_"..species or species.."_log_top" + end + end + end + end + for _,record in ipairs(hearth.botany.catalogue) do + if record.group~="trees" and not record.existing then + overrides[record.id]="botany_"..record.id + for _, season in ipairs({"Spring", "Summer", "Autumn", "Winter"}) do + if not hearth.botany.active(record.id,season) then + overrides[record.id .. "_" .. season:lower()]="botany_"..record.id.."_dormant" + end + end + end + end + for _, plant in ipairs({"clover", "nettle", "heather", "bluebell", "sedge", "bulrush"}) do + overrides[plant .. "_winter"] = plant .. "_dormant" + end + overrides.bluebell_summer, overrides.bluebell_autumn = "bluebell_dormant", "bluebell_dormant" + for name, def in pairs(nodes) do + local short = name:match("^hearth:(.+)$") + if short and short ~= "grass" and short ~= "log" and short ~= "bedroll" + and short ~= "water_source" and short ~= "water_flowing" then + eq(def.tiles[1], "hearth_" .. (overrides[short] or short) .. ".png") + end + if short and def.drawtype == "nodebox" then + local record=hearth.botany.get(name) + local image = record and not record.existing and record.group~="trees" + and ("hearth_" .. (overrides[short] or ("botany_"..record.id)) .. ".png") + or "hearth_" .. (short == "door_open" and "door" or short) .. ".png" + eq(def.inventory_image, image); eq(def.wield_image, image) + end + end + eq(nodes["hearth:bedroll"].tiles[1], "hearth_bedroll.png^[sheet:4x4:1,2") + eq(nodes["hearth:window"].tiles[1], "hearth_window.png") + eq(nodes["hearth:window"].use_texture_alpha, "blend") + eq(nodes["hearth:window"].paramtype, "light") +end) +test("botanical silhouettes have crisp alpha and waving without losing ecosystem art", function() + for _, name in ipairs({"leaves", "berry_bush", "berry_bush_bare", "sapling", "grain_patch", + "reeds", "crop_1", "crop_2", "crop_3", "crop_4", "meadow_grass", "wildflowers", "fern", + "litter", "fungi", "deadwood", "tree_seedling", "young_tree", "mature_tree", "dung"}) do + local def = nodes["hearth:" .. name] + local wood=name=="deadwood" or name=="young_tree" or name=="mature_tree" + eq(def.mesh,nil) + if wood then + eq(def.drawtype,"normal"); eq(def.tiles[1],"hearth_log_top.png") + eq(def.tiles[3],"hearth_log_side.png"); eq(def.use_texture_alpha,nil) + eq(def.paramtype,nil); eq(def.sunlight_propagates,false); eq(def.waving,nil) + else + eq(def.tiles[1], "hearth_" .. (name=="tree_seedling" and "sapling" or name) .. ".png") + eq(def.drawtype, name == "leaves" and "allfaces_optional" or "plantlike") + eq(def.use_texture_alpha, "clip"); eq(def.paramtype, "light") + eq(def.waving, 1) + eq(def.sunlight_propagates, true) + if name ~= "leaves" then + eq(def.inventory_image, def.tiles[1]); eq(def.wield_image, def.tiles[1]) + end + end + end + eq(nodes["hearth:torch"].use_texture_alpha, "clip") +end) +test("fauna register the original species meshes at node scale", function() + for _, kind in ipairs({"hare", "fox", "deer"}) do + local def = assert(entities["hearth:" .. kind], kind) + local props = def.initial_properties + eq(props.visual, "mesh"); eq(props.mesh, "hearth_" .. kind .. ".obj") + eq(#props.textures, 1); eq(props.textures[1], "hearth_" .. kind .. ".png") + eq(props.visual_size.x, 1); eq(props.visual_size.y, 1) + eq(props.collisionbox[2], 0); eq(props.static_save, false) + end +end) +test("local art registrars do not wrap global APIs or require optional mock registries", function() + for name, value in pairs(core_before) do eq(core[name], value) end + for name, value in pairs(core) do eq(value, core_before[name]) end + eq(core.registered_items, nil); eq(core.registered_tools, nil); eq(core.registered_craftitems, nil) +end) +test("storage and pantry share Mocha styling and retain gated inventory access", function() + for _, entry in ipairs({{"storage", 3, 24, "HOMESTEAD STORAGE"}, {"pantry", 6, 48, "FIRED-CLAY PANTRY"}}) do + local p, def = pos(4, 2, 1), nodes["hearth:" .. entry[1]] + def.on_construct(p) + eq(core.get_meta(p):get_inventory().size, entry[3]) + def.on_rightclick(p, {name = "hearth:" .. entry[1]}, player) + local form = formspecs[#formspecs] + eq(form.form, "hearth:storage") + for _, fragment in ipairs({"bgcolor[#1e1e2e;true]", "box[0.25,0.2;10.5,0.55;#313244]", + "style_type[button;bgcolor=#313244;textcolor=#cdd6f4;border=false]", + "style[close;bgcolor=#cba6f7;textcolor=#1e1e2e]", + "listcolors[#313244;#45475a;#cba6f7;#181825;#cdd6f4]", entry[4], + "list[nodemeta;4,2,1;main;0.5,1;8," .. entry[2] .. ";]", "button_exit[", + "listring[nodemeta;4,2,1;main]listring[current_player;main]"}) do + assert(form.spec:find(fragment, 1, true), fragment) + end + claim_owner, member_role = "homesteader", "storekeeper" + eq(def.allow_metadata_inventory_put(p, "main", 1, stack("hearth:grain 3"), player), 3) + eq(def.allow_metadata_inventory_take(p, "main", 1, stack("hearth:grain 2"), player), 2) + eq(def.allow_metadata_inventory_move(p, "main", 1, "main", 2, 3, player), 3) + eq(def.can_dig(p, player), false) + member_role = nil + local count = #formspecs + def.on_rightclick(p, {name = "hearth:" .. entry[1]}, player) + eq(#formspecs, count) + eq(def.allow_metadata_inventory_put(p, "main", 1, stack("hearth:grain"), player), 0) + eq(def.allow_metadata_inventory_take(p, "main", 1, stack("hearth:grain"), player), 0) + eq(def.allow_metadata_inventory_move(p, "main", 1, "main", 2, 1, player), 0) + claim_owner, member_role = "survivor", "owner" + end +end) +test("doors toggle as passable openings only for household-use permission", function() + local doorpos = pos() + put(doorpos, "hearth:door") + nodes["hearth:door"].on_rightclick(doorpos, core.get_node(doorpos), player) + eq(core.get_node(doorpos).name, "hearth:door_open") + nodes["hearth:door_open"].on_rightclick(doorpos, core.get_node(doorpos), player) + eq(core.get_node(doorpos).name, "hearth:door") + claim_owner, member_role = "homesteader", "storekeeper" + nodes["hearth:door"].on_rightclick(doorpos, core.get_node(doorpos), player) + eq(core.get_node(doorpos).name, "hearth:door") +end) +test("storage is claim-bound, form-gated, role-checked, and empty before removal", function() + local ground, chest = pos(0, -1, 0), pos() + put(ground, "hearth:stone") + local stack_item = stack("hearth:storage") + items["hearth:storage"].on_place(stack_item, player, point(ground, chest)) + eq(stack_item:get_count(), 0) + local def = nodes["hearth:storage"] + local inv = core.get_meta(chest):get_inventory() + eq(inv.size, 24) + claim_owner, member_role = "homesteader", "storekeeper" + def.on_rightclick(chest, core.get_node(chest), player) + eq(formspecs[#formspecs].form, "hearth:storage") + eq(def.allow_metadata_inventory_put(chest, "main", 1, stack("hearth:planks 3"), player), 3) + eq(def.can_dig(chest, player), false) + member_role = nil + local form_count = #formspecs + def.on_rightclick(chest, core.get_node(chest), player) + eq(#formspecs, form_count) + claim_owner = "new-homesteader" + eq(def.allow_metadata_inventory_take(chest, "main", 1, stack("hearth:planks"), player), 0) + eq(def.can_dig(chest, player), false) + claim_owner = "homesteader" + claim_owner, member_role = "homesteader", "resident" + inv.items[1] = "hearth:planks" + eq(def.can_dig(chest, player), false) + inv.items = {} + eq(def.can_dig(chest, player), true) +end) +test("drying racks require a shared claim and cloaks toggle persistent player protection", function() + local ground, rack = pos(0, -1, 0), pos() + put(ground, "hearth:stone") + claim_owner, member_role = "homesteader", "storekeeper" + local stack_item = stack("hearth:drying_rack") + items["hearth:drying_rack"].on_place(stack_item, player, point(ground, rack)) + eq(stack_item:get_count(), 1) + member_role = "resident" + items["hearth:drying_rack"].on_place(stack_item, player, point(ground, rack)) + eq(stack_item:get_count(), 0) + local cloak = stack("hearth:cloak") + items["hearth:cloak"].on_use(cloak, player) + eq(player_meta:get_int("hearth:cloak"), 1) + eq(cloak:get_count(), 1) + items["hearth:cloak"].on_use(cloak, player) + eq(player_meta:get_int("hearth:cloak"), 0) end) os.time = real_time diff --git a/tests/course_engine.lua b/tests/course_engine.lua new file mode 100755 index 0000000..b8db97b --- /dev/null +++ b/tests/course_engine.lua @@ -0,0 +1,132 @@ +-- Native headless M1 preflight, staged ONLY in a new disposable course world. +-- No fake players, no command privileges granted, no exported generator calls. +-- Enrollment/registration is eligibility, NOT proof of consent or generation. +local C, H = core, hearth +local token = C.settings:get("hearth_smoke_token") +local nonce = C.settings:get("hearth_m1_course_nonce") +assert(C.settings:get("hearth_launcher_mode") == "course", "Invalid course probe mode") +assert(type(token) == "string" and #token == 36 + and token:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") + and token == nonce, "Missing/mismatched launcher course token") +local finished = false +local LOW, HIGH = {x = 0, y = 48, z = 0}, {x = 90, y = 58, z = 40} +local no_run = "No course run in this session. Use /course." +local function finish(ok, message) + if finished then return end + finished = true + if ok then C.log("action", "LAST_HEARTH_COURSE_PASS " .. token) + else C.log("error", "LAST_HEARTH_COURSE_FAIL: " .. tostring(message)) end + C.request_shutdown("Last Hearth course preflight complete", false, 0) +end +local function guarded(fn) + if finished then return end + local ok, message = pcall(fn) + if not ok then finish(false, message) end +end +local function registry() + assert(C.settings:get("hearth_environment") == "staging" and not C.is_singleplayer()) + assert(C.settings:get_bool("hearth_m1_course") == true, "Course flag disabled") + assert(C.settings:get_bool("hearth_m1_course_snag") ~= true, "Unexpected optional snag") + assert(C.settings:get("max_users") == "0" and #C.get_connected_players() == 0) + assert(C.settings:get("bind_address") == "127.0.0.1" and C.settings:get_bool("server_announce") == false) + assert(C.settings:get_bool("enable_pvp") == false and C.settings:get_bool("secure.enable_security") == true) + assert(C.settings:get("hearth_preview_date") == "1782043200" + and C.settings:get("hearth_preview_weather") == "clear", "Course must use summer fixture") + assert(tonumber(C.settings:get("hearth_latitude")) == 45 and tonumber(C.settings:get("hearth_longitude")) == 0) + local world = C.get_worldpath():gsub("\\", "/"):gsub("/+$", "") + assert(world:match("([^/]+)$") == "course", "Not a disposable course world") + -- A bounded read of the fixed launcher-owned filename; never mint a marker. + local marker = assert(io.open(world .. "/.hearth-m1-course-nonce", "rb"), "Course marker missing") + local bytes = marker:read(65); marker:close() + assert(bytes == nonce or bytes == nonce .. "\n", "Course file nonce mismatch") + assert(type(H) == "table" and H.storage:get_string("m1_course_world_nonce") == nonce, + "Real course did not enroll before normal persistence initialization") + assert(next(H.players) == nil and next(H.sessions) == nil, "Headless course must not invent player sessions") + for module, methods in pairs({movement = {"step", "delta"}, combat = {"valid_attack", "valid_guard", "sample"}, + construction = {"preview"}, course = {"report"}}) do + assert(type(H[module]) == "table", "Missing wired module: " .. module) + for _, method in ipairs(methods) do assert(type(H[module][method]) == "function", module .. "." .. method) end + end + for _, method in ipairs({"feedback", "feedback_step", "feedback_sound", "environment", "player_join", "clock_update"}) do + assert(type(H[method]) == "function", "Missing wired API: " .. method) + end + assert(H.course.report("") == no_run, "Enrollment incorrectly created a course run") + local summer = H.model.solar(1782043200, 45, 0) + assert(H.clock and H.clock.date == summer.date and H.clock.season == "Summer" + and H.clock.timeofday == summer.timeofday and H.clock.elevation == summer.elevation + and H.weather == "clear", "Real clock does not match the fixed summer fixture") + local slab = assert(C.registered_nodes["hearth:m1_course_half_step"], "Enrolled course half step not registered") + assert(slab.drawtype == "nodebox" and slab.walkable == true and slab.drop == "") + assert(slab.groups.not_in_creative_inventory == 1 and slab.node_box.type == "fixed") + local bounds = slab.node_box.fixed + assert(#bounds == 6 and bounds[1] == -0.5 and bounds[2] == -0.5 and bounds[3] == -0.5 + and bounds[4] == 0.5 and bounds[5] == 0 and bounds[6] == 0.5, "Half step bounds mismatch") + for _, name in ipairs({"stone", "planks", "wall", "roof", "water_source", "log", "loose_stone"}) do + assert(C.registered_nodes["hearth:" .. name], "Missing course material: " .. name) + end + for _, name in ipairs({"axe", "knife", "spear", "berries", "water_clean"}) do + assert(C.registered_items["hearth:" .. name], "Missing course kit item: " .. name) + end + local sparring = assert(C.registered_entities["hearth:sparring"]).initial_properties + assert(sparring.visual == "mesh" and sparring.mesh == "hearth_person.b3d" + and sparring.textures[1] == "hearth_person.png" and sparring.static_save == false) + assert(C.registered_entities["hearth:practice"].initial_properties.visual == "cube") + for _, name in ipairs({"course", "practice", "spar", "comfort"}) do + assert(type(assert(C.registered_chatcommands[name], "Missing M1 command: " .. name).func) == "function") + end +end +local function terrain() + local result = {} + for x = LOW.x, HIGH.x do for z = LOW.z, HIGH.z do for y = LOW.y, HIGH.y do + local node = C.get_node_or_nil({x = x, y = y, z = z}) + assert(node and node.name ~= "ignore", "Course volume was not loaded for observation") + assert(node.name ~= "hearth:m1_course_half_step", "Half steps generated without live consent") + result[#result + 1] = node.name .. ":" .. node.param1 .. ":" .. node.param2 + end end end + assert(#result == 41041) + return result +end +local function denied() + -- Empty name is the real nonlive console context, not a fabricated account. + -- Staging may deny at privilege or arrival checks; never bypass either gate. + assert(C.get_player_by_name("") == nil) + for _, param in ipairs({"", "retry", "report"}) do + local ok, message = C.registered_chatcommands.course.func("", param) + assert(ok == false and type(message) == "string", "Course accepted a nonlive caller") + if C.check_player_privs("", {server = true}) then + assert(message == "Join, finish arrival and be alive before opting in.", "Live-arrival gate missing") + else + assert(message:find("Course denied:", 1, true) == 1, "Staging privilege gate missing") + end + end + assert(H.course.report("") == no_run and next(H.players) == nil and next(H.sessions) == nil) + assert(H.storage:get_string("m1_course_world_nonce") == nonce, "Command changed enrollment") +end +C.after(1, function() + guarded(function() + registry() + -- Emergence loads ordinary terrain for observation, NOT course generation. + C.emerge_area(LOW, HIGH, function(_, action, remaining) + guarded(function() + assert(action ~= C.EMERGE_ERRORED and action ~= C.EMERGE_CANCELLED, "Course observation emergence failed") + if remaining ~= 0 then return end + C.after(0, function() + guarded(function() + local before = terrain() + denied() + C.after(0.5, function() + guarded(function() + denied() + local after = terrain() + for i, value in ipairs(before) do assert(after[i] == value, "Nonlive command changed observed terrain") end + C.log("action", "Last Hearth: native course enrollment/registry and nonlive consent guards passed; 41041 observed nodes unchanged. No course generation, player traversal, client FPS, rendered mesh or audible playback proven; mock course_spec owns generation checks.") + finish(true) + end) + end) + end) + end) + end) + end) + end) +end) +C.after(60, function() finish(false, "Course preflight deadline exceeded") end) diff --git a/tests/course_spec.lua b/tests/course_spec.lua new file mode 100755 index 0000000..edf80bc --- /dev/null +++ b/tests/course_spec.lua @@ -0,0 +1,273 @@ +-- Mock-only security/geometry/telemetry checks; never starts an engine. +local target = assert(arg[1], "course.lua path required") +local total = 0 +local NONCE = "12345678-1234-4234-8234-123456789abc" -- Public test fixture, not a credential. +local function test(name, fn) + local old_core, old_hearth, old_io = core, hearth, io + local ok, err = pcall(fn) + core, hearth, io = old_core, old_hearth, old_io + assert(ok, name .. ": " .. tostring(err)) + total = total + 1; print("ok " .. total .. " - " .. name) +end +local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end +local function harness(options) + local h = {settings = {hearth_environment = "local", hearth_m1_course = true, + hearth_m1_course_nonce = NONCE}, storage = {}, marker = NONCE .. "\n", worldpath = "C:/isolated/course", + single = true, time = 10, commands = {}, steps = {}, players = {}, messages = {}, + registered = {}, nodes = {}, writes = 0, reads = 0, emerges = 0, storage_writes = 0, + priv_checks = 0, io_reads = 0} + for k, v in pairs(options or {}) do h[k] = v end + local C = { + settings = {get = function(_, k) return h.settings[k] end, + get_bool = function(_, k) return h.settings[k] == true end}, + get_us_time = function() return h.time * 1000000 end, + get_worldpath = function() return h.worldpath end, + is_singleplayer = function() return h.single end, + check_player_privs = function(name, privs) + assert(name and privs.server == true); h.priv_checks = h.priv_checks + 1 + return h.privileged == true + end, + register_chatcommand = function(name, def) h.commands[name] = def end, + register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end, + register_on_leaveplayer = function(fn) h.leave = fn end, + register_on_dieplayer = function(fn) h.die = fn end, + register_node = function(name, def) h.registered[name] = def end, + get_player_by_name = function(name) return h.players[name] end, + chat_send_player = function(name, msg) h.messages[#h.messages + 1] = {name, msg} end, + get_node_or_nil = function(p) + h.reads = h.reads + 1 + if h.unloaded and p.x == 90 and p.y == 58 and p.z == 40 then return nil end + if h.ignore and p.x == 90 and p.y == 58 and p.z == 40 then return {name = "ignore"} end + return {name = h.nodes[key(p)] or "air"} + end, + set_node = function(p, n) + assert(p.x >= 0 and p.x <= 90 and p.y >= 48 and p.y <= 58 and p.z >= 0 and p.z <= 40) + h.writes = h.writes + 1; assert(h.writes < 45000, "Unbounded generation") + h.nodes[key(p)] = n.name + end, + emerge_area = function(a, b, cb) + assert(a.x == 0 and a.y == 48 and a.z == 0 and b.x == 90 and b.y == 58 and b.z == 40) + h.emerges = h.emerges + 1; h.callback = cb + end, + } + local H = {players = {}, sessions = {}, texture = function(color) return "texture:" .. color end, + storage = {get_string = function(_, k) return h.storage[k] or "" end, + set_string = function(_, k, v) h.storage_writes = h.storage_writes + 1; h.storage[k] = v end}, + feedback = function(_, msg) h.feedback = msg end} + local fakeio = {open = function(path, mode) + assert(path == h.worldpath:gsub("\\", "/"):gsub("/+$", "") .. "/.hearth-m1-course-nonce" and mode == "rb") + if h.marker == false then return nil end + return {read = function(_, size) assert(size == 65); h.io_reads = h.io_reads + 1; return h.marker:sub(1, size) end, + close = function() h.closed = true end} + end} + function h.load() + core, hearth, io = C, H, fakeio + h.module = dofile(target); return h.module + end + function h.player(name) + local p = {name = name or "alice", hp = 20, pos = {x = 0, y = 0, z = 0}, grants = 0, teleports = 0} + function p:is_player() return true end + function p:get_player_name() return self.name end + function p:get_hp() return self.hp end + function p:get_pos() return self.pos end + function p:set_hp(n) self.hp = n end + function p:set_pos(pos) self.pos = pos; self.teleports = self.teleports + 1 end + function p:set_velocity(v) assert(v.x == 0 and v.y == 0 and v.z == 0) end + function p:get_inventory() + return {set_list = function(_, list, items) + assert(list == "main"); self.grants = self.grants + 1; self.kit = items + end} + end + h.players[p.name] = p; H.players[p.name] = {food = 13, water = 12}; H.sessions[p.name] = {} + return p, H.sessions[p.name] + end + function h.command(name, param) return h.commands.course.func(name or "alice", param or "") end + function h.complete() h.callback({x = 0, y = 0, z = 0}, 4, 0) end + function h.tick(dt) + h.time = h.time + dt; for _, step in ipairs(h.steps) do step(dt) end + end + function h.node(x, y, z) return h.nodes[key({x = x, y = y, z = z})] end + h.C, h.H = C, H + return h +end +test("default boot is inert before join and generation requires opt-in", function() + local h = harness(); h.load() + assert(h.storage.m1_course_world_nonce == NONCE and h.closed and h.io_reads == 1) + assert(h.emerges == 0 and h.writes == 0) + assert(not h.command()); assert(h.emerges == 0) + h.player(); h.tick(1); assert(h.emerges == 0 and h.writes == 0) +end) +test("disabled setting cannot enroll or generate even with matching marker", function() + local h = harness(); h.settings.hearth_m1_course = false; h.load(); h.player() + assert(not h.command() and h.storage_writes == 0 and h.io_reads == 0 and h.emerges == 0) +end) +test("production hard-denies server admins and never touches marker or storage", function() + local h = harness({privileged = true}); h.settings.hearth_environment = "production" + h.load(); h.player(); assert(not h.command()) + assert(h.writes == 0 and h.storage_writes == 0 and h.io_reads == 0 and h.emerges == 0) +end) +test("invalid and dedicated-local deployments fail closed", function() + for _, env in ipairs({"typo", "local"}) do + local h = harness({single = false, privileged = true}); h.settings.hearth_environment = env + h.load(); h.player(); assert(not h.command() and h.storage_writes == 0) + end +end) +test("staging explicitly checks server privilege at command and completion", function() + local h = harness({single = false}); h.settings.hearth_environment = "staging" + h.load(); local p = h.player(); assert(not h.command() and h.priv_checks > 0) + h.privileged = true; assert(h.command()); assert(h.emerges == 1 and p.grants == 0) + h.privileged = false; h.complete(); assert(h.writes == 0 and p.grants == 0) +end) +test("missing environment defaults to production for a dedicated server", function() + local h = harness({single = false, privileged = true}); h.settings.hearth_environment = nil + h.load(); h.player(); assert(not h.command() and h.storage_writes == 0) +end) +test("settings alone cannot bless normal world names", function() + for _, path in ipairs({"C:/worlds/last_hearth", "C:/worlds/course-not-really"}) do + local h = harness({worldpath = path}); h.load(); h.player() + assert(not h.command() and h.io_reads == 0 and h.storage_writes == 0) + end +end) +test("missing, mismatched and oversized world marker files deny enrollment", function() + for _, marker in ipairs({false, "wrong", NONCE .. string.rep("x", 100)}) do + local h = harness({marker = marker}); h.load(); h.player() + assert(not h.command() and h.storage_writes == 0 and h.emerges == 0) + end +end) +test("malformed nonce fails without file access", function() + local h = harness(); h.settings.hearth_m1_course_nonce = "../course" + h.load(); h.player(); assert(not h.command() and h.io_reads == 0) +end) +test("saved spawn, claims, location or migration backups prevent first enrollment", function() + for _, k in ipairs({"spawn", "claims", "location", "m0_backup:spawn", "m0_backup:claims", "m0_backup:location"}) do + local h = harness(); h.storage[k] = "{}"; h.load(); h.player() + assert(not h.command() and h.storage_writes == 0 and h.emerges == 0) + end +end) +test("matching enrolled world may restart after normal spawn save, mismatched storage denies", function() + local h = harness(); h.storage.m1_course_world_nonce = NONCE; h.storage.spawn = "saved" + h.load(); h.player(); assert(h.command() and h.storage_writes == 0) + local other = harness(); other.storage.m1_course_world_nonce = "other" + other.load(); other.player(); assert(not other.command() and other.storage_writes == 0) +end) +test("loading after a player session exists cannot enroll an old world", function() + local h = harness(); h.player(); h.load(); assert(not h.command() and h.storage_writes == 0) +end) +test("no join kit or teleport before all emergence callbacks finish", function() + local h = harness(); h.load(); local p = h.player(); assert(h.command()) + h.callback({}, 4, 1); assert(h.writes == 0 and p.grants == 0 and p.teleports == 0) + h.complete(); assert(h.writes > 41041 and p.grants == 1 and p.teleports == 1) + assert(p.pos.x == 5 and p.pos.y == 50.5 and p.pos.z == 5 and h.H.players.alice.food == 100) +end) +test("generation is single-instance across opt-ins, retries and duplicate callbacks", function() + local h = harness(); h.load(); local a, b = h.player("alice"), h.player("bob") + assert(h.command("alice") and h.command("bob") and h.emerges == 1) + h.complete(); local writes = h.writes; h.complete() + assert(a.grants == 1 and b.grants == 1 and h.writes == writes) + assert(h.command("alice", "retry")); assert(h.emerges == 1 and h.writes == writes and a.grants == 2) + assert(dofile(target) == h.module and #h.steps == 1) +end) +test("loaded preflight fails before the first write", function() + for _, mode in ipairs({"unloaded", "ignore"}) do + local h = harness(); h[mode] = true; h.load(); local p = h.player() + assert(h.command()); h.complete(); assert(h.writes == 0 and p.grants == 0) + assert(not h.command()) + end +end) +test("cancelled or errored emergence does not generate or grant", function() + for action = 0, 1 do + local h = harness(); h.load(); local p = h.player(); h.command() + h.callback({}, action, 1); h.complete(); assert(h.writes == 0 and p.grants == 0) + end +end) +test("revoked settings and storage identity prevent deferred writes", function() + for _, mode in ipairs({"production", "disabled", "nonce", "storage"}) do + local h = harness(); h.load(); local p = h.player(); h.command() + if mode == "production" then h.settings.hearth_environment = "production" + elseif mode == "disabled" then h.settings.hearth_m1_course = false + elseif mode == "nonce" then h.settings.hearth_m1_course_nonce = "changed" + else h.storage.m1_course_world_nonce = "changed" end + h.complete(); assert(h.writes == 0 and p.grants == 0) + end +end) +test("disconnect/rejoin cannot inherit deferred consent or a kit", function() + local h = harness(); h.load(); local old = h.player(); h.command() + local stale_callback = h.callback + h.leave(old); local new = h.player(); h.complete() + assert(h.writes == 0 and old.grants == 0 and new.grants == 0) + assert(h.command()); stale_callback({}, 4, 0) + assert(h.writes == 0 and new.grants == 0) + h.complete(); assert(h.writes > 0 and new.grants == 1) +end) +test("death, stale sessions and pending arrival invalidate requests", function() + local h = harness(); h.load(); local p, session = h.player() + session.waiting = true; assert(not h.command()); session.waiting = false + p.hp = 0; assert(not h.command()); p.hp = 20 + h.command(); h.H.sessions.alice = {}; h.complete(); assert(h.writes == 0 and p.grants == 0) + h.command(); h.die(p); h.complete(); assert(h.writes == 0) +end) +test("terrain geometry has bounded floor, half steps, doorway, pool, falls and resources", function() + local h = harness(); h.load(); h.player(); h.command(); h.complete() + assert(h.reads == 41041 and h.writes < 45000) + local half = h.registered["hearth:m1_course_half_step"] + assert(half.node_box.fixed[2] == -0.5 and half.node_box.fixed[5] == 0) + assert(h.node(11,51,5) == "hearth:m1_course_half_step" and h.node(12,51,5) == "hearth:planks") + assert(h.node(26,51,5) == "air" and h.node(26,52,5) == "air" and h.node(26,53,5) == "hearth:wall") + assert(h.node(26,51,4) == "hearth:wall" and h.node(26,51,6) == "hearth:wall") + assert(h.node(39,48,10) == "hearth:stone" and h.node(39,49,10) == "hearth:water_source") + assert(h.node(34,49,10) == "hearth:stone" and h.node(34,50,10) == "hearth:water_source") + assert(h.node(48,52,7) == "hearth:planks" and h.node(49,51,7) == "air") + assert(h.node(60,54,7) == "hearth:planks" and h.node(61,51,7) == "air") + assert(h.node(70,51,10) == "hearth:log" and h.node(70,51,12) == "hearth:loose_stone") + assert(h.node(82,50,33) == "hearth:planks" and h.node(82,51,33) == "air") + assert(h.node(29,52,9) == "air") +end) +test("optional snag fixture is explicit and bounded", function() + local h = harness(); h.settings.hearth_m1_course_snag = true + h.load(); h.player(); h.command(); h.complete(); assert(h.node(29,52,9) == "hearth:roof") +end) +test("staged kit is limited to disposable course and known content names", function() + local h = harness(); h.load(); local p = h.player(); h.command(); h.complete() + assert(#p.kit == 8 and p.kit[1] == "hearth:axe" and p.kit[2] == "hearth:knife" and p.kit[3] == "hearth:spear") + assert(p.kit[4] == "hearth:planks 64" and p.kit[5] == "hearth:wall 64" and p.kit[6] == "hearth:roof 64") +end) +test("checkpoints progress in order within five nodes, never from mere elapsed time", function() + local h = harness(); h.load(); local p = h.player(); h.command(); h.complete() + p.pos = {x = 26, y = 50.5, z = 5}; h.tick(1) + assert(h.module.report("alice"):find("checkpoints 0/10", 1, true)) + p.pos = {x = 20.01, y = 51, z = 5}; h.tick(0.1) + assert(h.module.report("alice"):find("checkpoints 0/10", 1, true)) + p.pos = {x = 20, y = 51, z = 5}; h.tick(0.1) + assert(h.module.report("alice"):find("checkpoints 1/10", 1, true)) + local points = {{26,50.5,5},{39,49.5,10},{50,50.5,7},{62,50.5,7},{71,50.5,10}, + {79,50.5,20},{82,50.5,33},{60,50.5,33},{8,50.5,33}} + for _, v in ipairs(points) do p.pos = {x=v[1],y=v[2],z=v[3]}; h.tick(0.2) end + local result = h.module.report("alice"); assert(result:find("complete",1,true) and result:find("checkpoints 10/10",1,true)) + h.tick(10); assert(h.module.report("alice") == result) +end) +test("telemetry uses bounded real step samples with honest p95 labels and retry reset", function() + local h = harness(); h.load(); h.player(); h.command(); h.complete() + for _ = 1, 650 do h.tick(0.02) end + local ok, report = h.command("alice", "report") + assert(ok and report:find("p95 20.00ms (last 600 samples)",1,true)) + assert(report:find("Not client FPS or CPU profiling",1,true) and report:find("p95 0.00us",1,true)) + h.command("alice", "retry"); report = h.module.report("alice") + assert(report:find("0.0s; checkpoints 0/10",1,true) and report:find("last 0 samples",1,true)) + for _, dt in ipairs({0/0, math.huge, -1, 0}) do h.steps[1](dt) end + assert(h.module.report("alice") == report) +end) +test("leave/death discard telemetry; inactive players get no sampling or grants", function() + local h = harness(); h.load(); local a, b = h.player("alice"), h.player("bob") + h.command(); h.complete(); h.tick(1) + assert(b.grants == 0 and h.module.report("bob"):find("No course run",1,true)) + h.die(a); assert(h.module.report("alice"):find("No course run",1,true)) + h.command(); h.leave(a); assert(h.module.report("alice"):find("No course run",1,true)) +end) +test("session count and run duration are bounded", function() + local h = harness(); h.load() + for i = 1, 16 do h.player("p"..i); assert(h.command("p"..i)) end + h.player("overflow"); assert(not h.command("overflow")); h.complete() + h.tick(1801); assert(h.module.report("p1"):find("No course run",1,true)) + assert(h.command("overflow")) +end) +print("PASS " .. total .. " course checks") diff --git a/tests/diversity_spec.lua b/tests/diversity_spec.lua new file mode 100755 index 0000000..0ce1932 --- /dev/null +++ b/tests/diversity_spec.lua @@ -0,0 +1,279 @@ +-- Focused Lua 5.1+ policy/registration tests; siblings also work flattened. +local target=assert(arg[1],"diversity.lua path required") +local passed=0 +local api,hearth,D,season,world,fields,claims,protected,missing,lbms,writes +local p={x=0,y=1,z=0} +local function key(q) return q.x..":"..q.y..":"..q.z end +local function meta(q) + local k=key(q); fields[k]=fields[k] or {}; local f=fields[k] + return {get_string=function(_,n) return f[n] or "" end, + set_string=function(_,n,v) writes=writes+1; f[n]=tostring(v) end, + to_table=function() return {fields=f} end} +end +local function reset() + season,world,fields,claims,protected,missing,lbms,writes="Summer",{},{},{},{},{},{},0 + api={registered_nodes={}} + function api.get_meta(q) return meta(q) end + function api.get_node_or_nil(q) + if missing[key(q)] then return nil end + return world[key(q)] or {name=q.y==0 and "hearth:grass" or "air"} + end + function api.swap_node(q,n) world[key(q)]=n end + function api.register_node(n,def) assert(not api.registered_nodes[n],n); api.registered_nodes[n]=def end + function api.register_lbm(def) lbms[def.name]=def end + hearth={climate_at=function() return {season=season} end, + claim_owner_at=function(q) return claims[key(q)] and "owner",claims.ambiguous end, + claim_allowed=function() error("nature must not call claim_allowed") end, + external_protected=function(q) return protected[key(q)] end} + hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api) + D=dofile(target)(hearth,api); hearth.diversity=D +end +local function test(label,fn) + reset(); local ok,err=pcall(fn); assert(ok,label..": "..tostring(err)) + passed=passed+1; print("ok "..passed.." - "..label) +end +local function habitat(ground,light,moisture) + return {ground=ground,light=light or 15,moisture=moisture or 0.5,canopy=0,loaded=true,nutrients=2,heat=20} +end +local function register() + D.register_nodes(function(n,def) api.register_node("hearth:"..n,def) end) + for _,s in ipairs(D.species_order) do api.register_node("hearth:"..s.."_leaves",{tiles={"hearth_"..s.."_leaves.png"},groups={leaves=1},drop="hearth:sticks"}) end + hearth.seasons.register() +end +test("exact ten species and eight understory plants have cube trunks and sprite seedlings",function() + register() + assert(table.concat(D.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce") + assert(#D.plants==8) + for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do + local k=s.."_"..stage; local def=assert(api.registered_nodes["hearth:"..k]) + assert(not def.mesh and not def.selection_box and not def.collision_box) + if stage=="seedling" then + assert(def.drawtype=="plantlike" and def.tiles[1]=="hearth_botany_"..s..".png") + else + assert(def.drawtype=="normal" and def.paramtype2=="facedir" and def.walkable) + assert(def.tiles[1]=="hearth_"..s.."_log_top.png" and def.tiles[2]==def.tiles[1]) + assert(def.tiles[3]=="hearth_"..s.."_log_side.png" and #def.tiles==3) + assert(def.groups.tree==1 and def.groups.choppy==2 and not def.groups.snappy) + assert(def.groups.not_in_creative_inventory==1 and not def.sunlight_propagates) + end + assert(def.groups.hearth_ecosystem==1 and not def.buildable_to) + local a,b=D.tree_info(k); assert(a==s and b==stage) + assert(D.family(k)==({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage]) + if stage~="seedling" then assert(def.drop=="hearth:"..s.."_log") end + end end + for _,k in ipairs(D.plants) do + local def=assert(api.registered_nodes["hearth:"..k]); assert(def.tiles[1]=="hearth_"..k..".png" and def.drop=="") + end + assert(not D.tree_info("hearth:pine_log") and D.family("hearth:pine_leaves")=="leaves") + assert(D.family("hearth:willow_log")=="log" and not D.is_worldgen_flora("hearth:oak_leaves")) +end) +test("seasonal foliage changes never replace bark with atlases or meshes; both conifers stay evergreen",function() + register() + for _,s in ipairs(D.species_order) do + for _,stage in ipairs({"seedling","young","mature","leaves"}) do + for _,time in ipairs({"Spring","Autumn","Winter"}) do + season=time; local k=s.."_"..stage + local name=hearth.seasons.name_for(k) + assert(hearth.seasons.kind(name)==k) + if s=="pine" or s=="spruce" then + assert(name=="hearth:"..k and not api.registered_nodes[name.."_winter"]) + assert(hearth.seasons.available(k)) + elseif stage=="young" or stage=="mature" then + assert(name=="hearth:"..k.."_"..time:lower()) + assert(api.registered_nodes[name].tiles[1]=="hearth_"..s.."_log_top.png") + assert(api.registered_nodes[name].tiles[3]=="hearth_"..s.."_log_side.png" and not api.registered_nodes[name].mesh) + assert(hearth.seasons.available(k)==(time~="Winter")) + else + local def=assert(api.registered_nodes[name]) + assert(def.tiles[1]==(stage=="leaves" and "hearth_"..s.."_leaves_"..time:lower()..".png" or "hearth_botany_"..s..".png")) + assert(not def.mesh) + end + end + end + end +end) +test("all ten species can recruit deterministically on their supported habitat",function() + local found={} + for x=0,30 do for z=0,10 do + for _,g in ipairs({"grass","forest_floor","podzol","alpine_turf","alluvium","peat"}) do + for _,m in ipairs({0.4,0.6,0.9}) do + local h=habitat(g,10,m); h.canopy=g=="forest_floor" and 1 or 0 + local q={x=x,y=1,z=z}; local k=D.choose_tree(q,h) + if k then + local s=D.tree_info(k); found[s]=true + assert(D.suitable(k,h) and D.choose_tree(q,h)==k) + end + end + end + end end + for _,s in ipairs(D.species_order) do assert(found[s],s) end +end) +test("tree plans share mapgen geometry with a canonical rooted identity and no world writes",function() + assert(not D.tree_plan("oak","mature")) -- missing biome integration fails closed + hearth.biomes=dofile(target:gsub("[^/\\]+$","biomes.lua"))(hearth,api,function() error("unexpected registration") end,{}) + local before=writes + for _,s in ipairs(D.species_order) do for _,stage in ipairs({"young","mature"}) do + local plan=assert(D.tree_plan(s,stage)); local schematic=hearth.biomes.tree_schematic(s,false,stage) + assert(plan.size.x==schematic.size.x and plan.size.y==schematic.size.y and #plan>5) + local roots,leaves,trunks=0,0,0 + local cx,cz=math.floor(plan.size.x/2),math.floor(plan.size.z/2) + for _,n in ipairs(plan) do + assert(n.name~="air" and n.param2==0) + local cell=schematic.data[1+n.x+cx+plan.size.x*(n.y+plan.size.y*(n.z+cz))] + if n.x==0 and n.y==0 and n.z==0 then + roots=roots+1; assert(n.name=="hearth:"..s.."_"..stage) + else assert(n.name==cell.name and cell.prob==255) end + if n.name:match("_leaves$") then leaves=leaves+1 else trunks=trunks+1 end + end + assert(roots==1 and leaves>=5 and trunks>=3) + plan[1].name="foreign:node"; plan.size.x=999 + assert(D.tree_plan(s,stage)[1].name~="foreign:node" and D.tree_plan(s,stage).size.x<10) + end end + local generic=D.tree_plan("generic","mature"); local root + for _,n in ipairs(generic) do if n.x==0 and n.y==0 and n.z==0 then root=n.name end end + assert(root=="hearth:mature_tree" and writes==before) + assert(not D.tree_plan("unknown","mature") and not D.tree_plan("oak","seedling")) +end) +test("tree recruitment uses soils, cold podzol, pioneer gaps, woodland and river niches",function() + assert(D.choose_tree(p,habitat("podzol",15,0.4))=="pine_seedling") + assert(D.choose_tree(p,habitat("alpine_turf",15,0.35))=="pine_seedling") + local warm=habitat("podzol",15,0.4); warm.heat=60 + assert(D.choose_tree(p,warm)=="birch_seedling") + assert(D.choose_tree(p,habitat("grass",15,0.3))=="birch_seedling") + local forest=habitat("forest_floor",10,0.6); forest.canopy=1 + assert(D.choose_tree(p,forest)=="oak_seedling") + assert(D.choose_tree(p,habitat("alluvium",12,0.9))=="willow_seedling") + assert(D.choose_tree(p,habitat("peat",12,0.9))=="alder_seedling") + assert(not D.choose_tree(p,habitat("stone",15,0.1))) +end) +test("optional biome species policy is authoritative without requiring the biome module",function() + hearth.biomes={supports_species=function(s,ground) return s=="alder" and ground=="grass" end} + assert(not D.supports_species("oak","grass")) + assert(D.supports_species("alder","grass")) + assert(not D.choose_tree(p,habitat("podzol",15,0.4))) +end) +test("all eight understory niches recruit deterministically on the first establishment cycle",function() + local cases={clover={habitat("grass",15,0.5),{x=3,y=1,z=0},"Summer"}, + nettle={habitat("grass",15,0.6),{x=6,y=1,z=0},"Summer"}, + heather={habitat("podzol",15,0.4),p,"Summer"}, + bluebell={habitat("forest_floor",8,0.6),p,"Spring"}, + sedge={habitat("alluvium",15,0.9),p,"Summer"}, + bulrush={habitat("peat",15,0.9),{x=3,y=1,z=0},"Summer"}, + moss={habitat("forest_floor",8,0.7),p,"Summer"}, + lichen={habitat("granite",15,0.1),p,"Summer"}} + for k,c in pairs(cases) do + season=c[3]; local recruit=D.choose_recruit(c[2],c[1],season) + assert(recruit==k,k.." got "..tostring(recruit)) + assert(D.choose_recruit(c[2],c[1],season)==recruit) + assert(not D.choose_recruit(c[2],c[1],"Winter")) + end + assert(not D.suitable("clover",habitat("peat",15,1))) + assert(not D.suitable("heather",habitat("forest_floor",15,0.4))) + assert(not D.suitable("nettle",{ground="grass",light=12,moisture=0.6,nutrients=0})) +end) +test("dormancy gates herbivore food and spring bluebells while pollination is exposed",function() + register(); season="Spring" + assert(D.edible("hearth:clover") and D.edible("hearth:sedge")) + assert(not D.edible("hearth:bluebell") and D.pollinator("bluebell",season)) + for _,s in ipairs({"Summer","Autumn","Winter"}) do + season=s; assert(not hearth.seasons.available("bluebell")) + assert(api.registered_nodes[hearth.seasons.name_for("bluebell")].tiles[1]=="hearth_bluebell_dormant.png") + end + assert(not D.edible("hearth:clover_winter") and not D.edible("hearth:sedge_winter")) + assert(hearth.seasons.available("moss") and hearth.seasons.available("lichen")) + assert(not D.pollinator("bluebell","Summer") and D.pollinator("heather","Autumn")) +end) +test("load bootstrap and scheduler adoption mark only known unmodified species flora",function() + register(); world[key(p)]={name="hearth:alder_seedling"} + assert(D.bootstrap(p) and meta(p):get_string("succ_natural")=="1") + local before=writes; assert(not D.bootstrap(p) and writes==before) + for _,k in ipairs({"oak_log","oak_leaves","log","leaves","wall","berry_bush"}) do + fields={}; world[key(p)]={name="hearth:"..k}; assert(not D.bootstrap(p),k) + end + fields={}; world[key(p)]={name="hearth:clover"} + lbms["hearth:diversity_natural_flora"].action(p,world[key(p)]) + assert(meta(p):get_string("succ_natural")=="1") +end) +test("player markers, claims, ambiguity, support protection and unload prevent adoption",function() + register() + for _,field in ipairs({"owner","planter","player_placed","placed_by","planted_at","third_party:placed"}) do + fields={}; world[key(p)]={name="hearth:oak_mature"}; meta(p):set_string(field,"1") + local before=writes; assert(not D.bootstrap(p) and writes==before,field) + end + for _,mode in ipairs({"claim","ambiguous","protected","support","unloaded"}) do + reset(); world[key(p)]={name="hearth:clover"} + if mode=="claim" then claims[key(p)]=true end + if mode=="ambiguous" then claims.ambiguous=true end + if mode=="protected" then protected[key(p)]=true end + if mode=="support" then protected["0:0:0"]=true end + if mode=="unloaded" then missing["0:0:0"]=true end + assert(not D.bootstrap(p) and writes==0,mode) + end + reset(); register(); world[key(p)]={name="hearth:clover"} + api.registered_nodes["hearth:clover"].after_place_node(p) + assert(not D.bootstrap(p) and meta(p):get_string("player_placed")=="1") +end) +test("deadwood load adoption is idempotent and does not broaden generic flora policy",function() + register(); world[key(p)]={name="hearth:deadwood",param2=2} + local loader=lbms["hearth:diversity_natural_flora"] + local count=0 + for _,n in ipairs(loader.nodenames) do if n=="hearth:deadwood" then count=count+1 end end + assert(count==1 and loader.run_at_every_load) + loader.action(p,world[key(p)]) + assert(meta(p):get_string("succ_natural")=="1" and writes==1) + loader.action(p,world[key(p)]); assert(writes==1) + assert(world[key(p)].name=="hearth:deadwood" and world[key(p)].param2==2) + assert(meta(p):get_string("succ_age")=="") + for _,k in ipairs({"litter","fungi","log","oak_log","leaves","oak_leaves","planks"}) do + fields={}; world[key(p)]={name="hearth:"..k} + local before=writes; assert(not D.bootstrap(p) and writes==before,k) + end +end) +test("deadwood adoption fails closed for source and support provenance, authority, identity and unload",function() + local below={x=0,y=0,z=0} + for _,q in ipairs({p,below}) do + for _,mode in ipairs({"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at","third_party:placed", + "claim","ambiguous","protected","unloaded","ignore"}) do + reset(); world[key(p)]={name="hearth:deadwood"} + if mode=="claim" then claims[key(q)]=true + elseif mode=="ambiguous" then claims.ambiguous=true + elseif mode=="protected" then protected[key(q)]=true + elseif mode=="unloaded" then missing[key(q)]=true + elseif mode=="ignore" then world[key(q)]={name="ignore"} + else meta(q):set_string(mode,"1") end + local before=writes + assert(not D.bootstrap(p) and writes==before,mode.." at "..key(q)) + end + end + for _,support in ipairs({"air","hearth:stone","hearth:log","hearth:water_source"}) do + reset(); world[key(p)]={name="hearth:deadwood"}; world[key(below)]={name=support} + assert(not D.bootstrap(p) and writes==0,support) + end + reset(); world[key(p)]={name="air"} + assert(not D.bootstrap(p,{name="hearth:deadwood"}) and writes==0) +end) +test("ground policy is separate from crops and lichen never needs to replace its rock",function() + for _,k in ipairs({"grass","soil","forest_floor","podzol","alluvium","peat","alpine_turf"}) do assert(D.is_ground("hearth:"..k)) end + assert(not D.is_ground("hearth:granite") and D.is_rock("hearth:granite")) + assert(not D.supports_ground("clover","hearth:granite")) + assert(D.supports_ground("lichen","hearth:granite")) + assert(not D.is_ground("hearth:crop_4") and not D.is_ground("ignore")) +end) +test("optional missing bases do not panic seasonal registration or install a scheduler",function() + local steps=0; api.register_globalstep=function() steps=steps+1 end + hearth.seasons.register() + assert(steps==0) + D.register_nodes(function(n,def) api.register_node("hearth:"..n,def) end) + hearth.seasons.register() + assert(api.registered_nodes["hearth:oak_seedling_winter"] and api.registered_nodes["hearth:oak_mature"].drawtype=="normal" and steps==0) +end) +test("bootstrap rechecks loaded source identity and explicit recruitment season wins over clock",function() + world[key(p)]={name="air"} + assert(not D.bootstrap(p,{name="hearth:clover"}) and writes==0) + world[key(p)]={name="hearth:clover"}; missing[key(p)]=true + assert(not D.bootstrap(p,{name="hearth:clover"}) and writes==0) + for _,bad in ipairs({{}, {x=0/0,y=1,z=0},{x=math.huge,y=1,z=0}}) do assert(not D.bootstrap(bad)) end + season="Winter" + assert(D.choose_recruit(p,habitat("forest_floor",8,0.6),"Spring")=="bluebell") +end) +print("LAST_HEARTH_DIVERSITY_PASS "..passed) diff --git a/tests/ecology_spec.lua b/tests/ecology_spec.lua new file mode 100755 index 0000000..a7fcdd8 --- /dev/null +++ b/tests/ecology_spec.lua @@ -0,0 +1,192 @@ +-- Bounded, deterministic M4 ecology accounting tests. +local target = assert(arg[1], "ecology.lua path required") +local now, nodes, metadata, timers = 100000, {}, {}, {} +local real_time = os.time +os.time = function() return now end +local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end +local function meta(pos) + local k = key(pos); metadata[k] = metadata[k] or {} + local fields = metadata[k] + return { + get_string = function(_, name) return fields[name] or "" end, + set_string = function(_, name, value) fields[name] = tostring(value) end, + get_int = function(_, name) return tonumber(fields[name]) or 0 end, + set_int = function(_, name, value) fields[name] = math.floor(value) end, + from_table = function(_, value) fields = value.fields or {}; metadata[k] = fields end, + } +end +local api = { + registered_nodes = {air = {buildable_to = true}, ["hearth:grass"] = {}, ["hearth:soil"] = {}, + ["hearth:crop_1"] = {}, ["hearth:crop_2"] = {}, ["hearth:crop_3"] = {}, ["hearth:crop_4"] = {}, + ["hearth:berry_bush"] = {}, ["hearth:berry_bush_bare"] = {}, ["hearth:sapling"] = {}, + ["hearth:log"] = {}, ["hearth:leaves"] = {}}, + settings = {get = function() return "512" end}, +} +function api.get_node_or_nil(pos) return nodes[key(pos)] or {name="air"} end +function api.get_node(pos) return api.get_node_or_nil(pos) end +function api.set_node(pos, node) nodes[key(pos)] = node; metadata[key(pos)] = {}; timers[key(pos)] = nil end +function api.swap_node(pos, node) nodes[key(pos)] = node end +function api.get_meta(pos) return meta(pos) end +function api.get_node_timer(pos) + local k = key(pos); timers[k] = timers[k] or {} + return {start = function(_, seconds) timers[k].timeout = seconds end, + stop = function() timers[k].timeout = nil end} +end +local permitted, inventory_room, delivered, partial, climate_temperature, season = true, true, 0, false, 0, "Summer" +hearth = {climate_at = function() return {temperature = climate_temperature, season=season} end, + claim_allowed = function() return permitted end, + notify = function(_, message) hearth.last_notice = message end} +local construction = {allowed = function() return permitted end} +hearth.seasons = dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api) +local ecology = dofile(target)(hearth, api, construction) +local function make_stack(text) + local name, count = (text or ""):match("^(%S+)%s*(%d*)$") + return {name = name or "", count = name and (tonumber(count) or 1) or 0, + get_count = function(self) return self.count end, + get_name = function(self) return self.name end, + is_empty = function(self) return self.count == 0 end, + take_item = function(self, amount) self.count = math.max(0, self.count - (amount or 1)) end} +end +ItemStack = make_stack +local inventory = { + room_for_item = function() return inventory_room end, + add_item = function(_, _, item) + local amount = partial and math.max(0, item:get_count() - 1) or item:get_count() + delivered = delivered + amount + return make_stack(amount == item:get_count() and "" or (item:get_name() .. " " .. (item:get_count() - amount))) + end, +} +local player = {is_player = function() return true end, get_player_name = function() return "gardener" end, + get_inventory = function() return inventory end} + +local passed = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)); passed = passed + 1 + print("ok " .. passed .. " - " .. name) +end +local planted_at = 100000 + +test("crop climate uses historical samples, freezing pauses growth, and catch-up is capped", function() + local cold_days = 4 * 86400 + assert(ecology.climate_progress(planted_at, planted_at + cold_days) == 0) + climate_temperature = 15 + local warm_days = 6 * 86400 + assert(ecology.crop_stage(planted_at, planted_at + warm_days) == 4) + assert(ecology.climate_progress(planted_at, planted_at + 90 * 86400) + == ecology.climate_progress(planted_at, planted_at + 30 * 86400)) + assert(ecology.climate_progress("nan", planted_at + 1) == 0) + assert(ecology.climate_progress(planted_at, math.huge) == 0) +end) +test("crop stages derive from a durable planting timestamp independent of update count", function() + local p = {x=0,y=1,z=0} + nodes[key({x=0,y=0,z=0})] = {name="hearth:grass"} + nodes[key(p)] = {name="hearth:crop_1"} + meta(p):set_string("planted_at", tostring(planted_at)) + local stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400) + assert(stage >= 2 and stage <= 4) + local same_late_stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400) + assert(stage == same_late_stage) + now = planted_at + 30 * 86400 + assert(ecology.update_crop(p) == false and nodes[key(p)].name == "hearth:crop_4") +end) +test("berry regrowth is a single absolute deadline and harvest does not destroy the bush", function() + local p = {x=2,y=1,z=0} + nodes[key(p)] = {name="hearth:berry_bush"} + inventory_room = false + ecology.harvest_berries(p, player) + assert(nodes[key(p)].name == "hearth:berry_bush" and delivered == 0) + inventory_room, partial = true, false + ecology.harvest_berries(p, player) + assert(nodes[key(p)].name == "hearth:berry_bush_bare" and delivered == 3) + local ready = tonumber(meta(p):get_string("ready_at")) + assert(ready == now + 86400) + ecology.harvest_berries(p, player) + assert(delivered == 3, "bare bush must not yield repeated berries") + assert(ecology.refresh_bush(p) == true and nodes[key(p)].name == "hearth:berry_bush_bare") + now = ready + assert(ecology.refresh_bush(p) == false and nodes[key(p)].name == "hearth:berry_bush") +end) +test("partial inventory harvest cannot duplicate crops or berries", function() + local crop, bush = {x=4,y=1,z=0}, {x=5,y=1,z=0} + nodes[key(crop)] = {name="hearth:crop_4"} + nodes[key(bush)] = {name="hearth:berry_bush"} + partial, delivered = true, 0 + ecology.harvest_crop(crop, player) + assert(nodes[key(crop)].name == "hearth:soil" and delivered == 3) + ecology.harvest_berries(bush, player) + assert(nodes[key(bush)].name == "hearth:berry_bush_bare" and delivered == 5) + partial = false +end) +test("immature or unauthorized crops cannot be harvested", function() + local p = {x=6,y=1,z=0} + nodes[key(p)] = {name="hearth:crop_1"} + local before = delivered + ecology.harvest_crop(p, player) + assert(nodes[key(p)].name == "hearth:crop_1" and delivered == before) + nodes[key(p)] = {name="hearth:crop_4"} + permitted = false + ecology.harvest_crop(p, player) + assert(nodes[key(p)].name == "hearth:crop_4" and delivered == before) + permitted = true +end) +test("sapling growth waits, respects claims, and writes a bounded tree once", function() + now = 500000 + local p = {x=8,y=1,z=8} + nodes[key({x=8,y=0,z=8})] = {name="hearth:grass"} + nodes[key(p)] = {name="hearth:sapling"} + meta(p):set_string("planted_at", tostring(now - 3 * 86400)) + meta(p):set_string("planter", "gardener") + permitted = false + assert(ecology.grow_sapling(p) == true and nodes[key(p)].name == "hearth:sapling") + permitted = true + assert(ecology.grow_sapling(p) == false and nodes[key(p)].name == "hearth:log") + assert(nodes[key({x=8,y=4,z=8})].name == "hearth:log") + assert(ecology.grow_sapling(p) == false, "grown tree must not be produced twice") +end) +test("seasonal harvest authorization, empty inventory room and rejected delivery cause no cooldown or yield", function() + local p={x=20,y=1,z=0} + nodes[key(p)]={name="hearth:spring_greens_patch_spring"} + season="Spring"; delivered=0; permitted=false + assert(not ecology.harvest_seasonal(p,player) and delivered==0) + permitted=true; inventory_room=false + assert(not ecology.harvest_seasonal(p,player) and delivered==0) + inventory_room=true; partial=true + assert(not ecology.harvest_seasonal(p,player) and delivered==0) + assert(meta(p):get_string("succ_harvest_ready_at")=="") + partial=false +end) +test("seasonal picking is one food per day without erasing ecological age or provenance", function() + local p={x=21,y=1,z=0} + nodes[key(p)]={name="hearth:spring_greens_patch_spring",param2=2} + meta(p):set_string("succ_age","1200"); meta(p):set_string("succ_natural","1") + meta(p):set_string("owner","gardener") + delivered=0; season="Spring" + assert(ecology.harvest_seasonal(p,player) and delivered==1) + local ready=tonumber(meta(p):get_string("succ_harvest_ready_at")) + assert(ready==now+86400 and not ecology.harvest_seasonal(p,player)) + assert(meta(p):get_string("succ_age")=="1200" and meta(p):get_string("succ_natural")=="1") + assert(meta(p):get_string("owner")=="gardener") + season="Winter"; hearth.seasons.reconcile(p) + season="Spring"; hearth.seasons.reconcile(p) + assert(not ecology.harvest_seasonal(p,player) and delivered==1) + now=ready; assert(ecology.harvest_seasonal(p,player) and delivered==2) + assert(tonumber(meta(p):get_string("succ_harvest_ready_at"))==now+86400) +end) +test("forage availability checks the calendar even before appearance has reconciled", function() + local greens,mushrooms={x=22,y=1,z=0},{x=23,y=1,z=0} + nodes[key(greens)]={name="hearth:spring_greens_patch_spring"} + nodes[key(mushrooms)]={name="hearth:autumn_mushrooms_patch"} + delivered=0 + for _,s in ipairs({"Summer","Winter","Autumn"}) do + season=s; assert(not ecology.harvest_seasonal(greens,player)) + end + season="Spring"; assert(not ecology.harvest_seasonal(mushrooms,player)) + season="Autumn"; assert(ecology.harvest_seasonal(mushrooms,player) and delivered==1) + -- New adapter instance models restart; its deadline remains in node metadata. + local reload=dofile(target)(hearth,api,construction) + assert(not reload.harvest_seasonal(mushrooms,player) and delivered==1) +end) + +os.time = real_time +print("PASS " .. passed .. " bounded ecology tests") diff --git a/tests/encounters_spec.lua b/tests/encounters_spec.lua new file mode 100755 index 0000000..2e0d40a --- /dev/null +++ b/tests/encounters_spec.lua @@ -0,0 +1,274 @@ +-- Engine-independent adapter tests. Real pure model; complete isolated core mocks. +local target = assert(arg[1], "encounters.lua path required") +local inherited_combat = hearth and hearth.combat +local combat = inherited_combat or dofile(target:gsub("[^/\\]+$", "combat.lua")) +local total = 0 +local function test(name, fn) + local old_core, old_hearth = core, hearth + local ok, err = pcall(fn); core, hearth = old_core, old_hearth + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +local function harness() + local h = {time = 10, entities = {}, commands = {}, steps = {}, players = {}, objects = {}, + sounds = {}, messages = {}, rays = {}, scans = 0, nodes = 0, single = true} + core = { + get_us_time = function() return h.time * 1000000 end, + register_entity = function(name, def) h.entities[name] = def end, + register_chatcommand = function(name, def) h.commands[name] = def end, + register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end, + get_player_by_name = function(name) return h.players[name] end, + get_connected_players = function() local out = {}; for _, p in pairs(h.players) do out[#out + 1] = p end; return out end, + is_singleplayer = function() return h.single end, + settings = {get = function(_, key) assert(key == "hearth_environment"); return h.deployment end}, + check_player_privs = function(_, privs) assert(privs.server); return h.privileged == true end, + dir_to_yaw = function(v) return (math.atan2 or math.atan)(-v.x, v.z) end, + is_protected = function() return h.protected == true end, + line_of_sight = function(a, b, step) + assert(step == 0.1); h.rays[#h.rays + 1] = {a = a, b = b} + return not h.obstructed and (not h.ray_test or h.ray_test(a, b)) + end, + registered_nodes = {air = {walkable = false, liquidtype = "none"}, ground = {walkable = true}, + water = {walkable = false, liquidtype = "source"}, stone = {walkable = true}}, + get_node_or_nil = function(pos) + h.nodes = h.nodes + 1 + if h.unloaded then return nil end + if h.node_test then return h.node_test(pos) end + return {name = h.liquid and "water" or (pos.y < 0 and "ground" or "air")} + end, + get_objects_inside_radius = function(_, radius) assert(radius == 30); h.scans = h.scans + 1; return h.objects end, + sound_play = function(name, spec, ephemeral) assert(ephemeral and spec.pos); h.sounds[#h.sounds + 1] = name end, + } + hearth = {combat = combat, players = {}, sessions = {}, + claim_allowed = function(pos, name) return not h.denied and (not h.claim_test or h.claim_test(pos, name)) end, + texture = function(color) return "texture:" .. color end, + feedback = function(p, msg, kind) h.messages[#h.messages + 1] = {p = p, msg = msg, kind = kind} end} + function h.player(name) + local p = {name = name or "alice", hp = 20, pos = {x = 0, y = 0, z = 0}, + look = {x = 0, y = 0, z = 1}, controls = {}, item = "hearth:axe", wear = 0, damage = 0} + function p:is_player() return true end + function p:get_player_name() return self.name end + function p:get_pos() return self.pos end + function p:get_hp() return self.hp end + function p:get_look_dir() return self.look end + function p:get_properties() return {eye_height = 1.55} end + function p:get_player_control() return self.controls end + function p:get_wielded_item() + return {get_name = function() return p.item end, add_wear = function(_, n) p.wear = p.wear + n end} + end + function p:set_wielded_item(stack) assert(stack.get_name); self.wield_updates = (self.wield_updates or 0) + 1 end + function p:set_hp(n, reason) assert(reason.type == "set_hp" and reason.from == "mod"); self.hp = n; self.damage = self.damage + 1 end + h.players[p.name] = p; hearth.players[p.name] = {stamina = 100}; hearth.sessions[p.name] = {} + return p, hearth.players[p.name], hearth.sessions[p.name] + end + function h.entity(name, pos) + local def = assert(h.entities[name]); local e = setmetatable({name = name}, {__index = def}) + local o = {pos = pos or {x = 0, y = 0, z = 1.5}, velocity = {x = 0, y = 0, z = 0}, properties = {}, animations = {}} + function o:get_pos() return self.pos end + function o:get_velocity() return self.velocity end + function o:set_velocity(v) assert(math.sqrt(v.x^2 + v.z^2) <= 2.20001); self.velocity = v end + function o:set_yaw(yaw) self.yaw = yaw end + function o:set_acceleration(v) self.acceleration = v end + function o:set_armor_groups(v) assert(v.immortal == 1) end + function o:set_properties(v) for k, value in pairs(v) do self.properties[k] = value end end + function o:set_animation(range, speed, blend, loop) self.animations[#self.animations + 1] = {range = range, speed = speed, loop = loop} end + function o:get_luaentity() return e end + e.object = o; h.objects[#h.objects + 1] = o; e:on_activate("old target data ignored") + return e + end + core.add_entity = function(pos, name) local e = h.entity(name, pos); return e.object end + dofile(target) + function h.tick(e, dt, physics) + h.time = h.time + dt; e:on_step(dt) + if physics then + local p, v = e.object.pos, e.object.velocity + p.x, p.z = p.x + v.x * math.min(dt, 0.1), p.z + v.z * math.min(dt, 0.1) + end + end + function h.counter(e) h.tick(e, 0.75); assert(e.phase == "strike") end + return h +end +test("stationary identity persists and moving model is ephemeral animated B3D", function() + local h = harness(); local a, b = h.entities["hearth:practice"], h.entities["hearth:sparring"] + assert(a.initial_properties.static_save and not b.initial_properties.static_save) + assert(b.initial_properties.mesh == "hearth_person.b3d" and b.initial_properties.textures[1] == "hearth_person.png") + local e = h.entity("hearth:practice"); e.health, e.target = 0, "stale"; e:on_activate("bad json") + assert(e.health == 30 and e.target == nil and e.phase == "idle" and e.name == "hearth:practice") +end) +test("practice local-only spar staging requires server privilege production always denies", function() + local h = harness(); h.player() + h.single, h.deployment = false, "staging" + assert(not h.commands.practice.func("alice") and not h.commands.spar.func("alice")) + h.privileged = true; assert(h.commands.spar.func("alice")); assert(#h.objects == 1) + assert(not h.commands.spar.func("alice")) + h = harness(); h.player(); h.deployment, h.privileged = "production", true + assert(not h.commands.spar.func("alice") and not h.commands.practice.func("alice") and #h.objects == 0) + h.single, h.deployment = false, nil; assert(not h.commands.spar.func("alice")) +end) +test("local practice preserved nearby duplicate refusal and no tick scans", function() + local h = harness(); h.player(); assert(h.commands.practice.func("alice")); assert(not h.commands.practice.func("alice")) + local scans = h.scans; h.tick(h.objects[1]:get_luaentity(), 0.1); assert(h.scans == scans) +end) +test("spawn rejects protected claims unloaded liquid obstructed and waiting arrival", function() + for _, flag in ipairs({"denied", "protected", "unloaded", "liquid", "obstructed"}) do + local h = harness(); h.player(); h[flag] = true; assert(not h.commands.spar.func("alice") and #h.objects == 0) + end + local h = harness(); local _, _, s = h.player(); s.waiting = true; assert(not h.commands.spar.func("alice")) +end) +test("confirmed hit spends exact stamina wears current inventory and ignores client damage", function() + local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice") + assert(e:on_punch(p, 999, {damage_groups = {fleshy = 999}}, nil, 999) == true) + assert(e.health == 26 and state.stamina == 88 and p.wear == 655 and p.wield_updates == 1) + assert(s.attack_ready_at == 11.1 and s.strike_until > h.time and s.spar_entity == e) + assert(h.sounds[1] == "hearth_swing_axe" and h.sounds[2] == "hearth_impact") + local ray = h.rays[1]; assert(ray.a.y == 1.55 and ray.b.y == 0.9) +end) +test("weapon swap other targets and entity activation never reset session cadence", function() + local h = harness(); local p, state, s = h.player(); local a, b = h.entity("hearth:practice"), h.entity("hearth:practice") + a:on_punch(p); local ready = s.attack_ready_at; p.item = "hearth:knife" + a:on_activate(""); b:on_punch(p); assert(b.health == 30 and state.stamina == 88 and s.attack_ready_at == ready) + -- Simulate entity removal releasing its lock, without touching session cadence. + s.spar_entity = nil; h.time = 10.8; b:on_punch(p); assert(b.health == 30) + h.time = ready; b:on_punch(p); assert(b.health == 27 and state.stamina == 80) +end) +test("invalid hits never wear spend regenerate start a round or feedback spam", function() + for _, mode in ipairs({"stamina", "waiting", "dead", "offline", "claim", "wall", "aim", "reach", "state", "session"}) do + local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice") + if mode == "stamina" then state.stamina = 1 elseif mode == "waiting" then s.waiting = true + elseif mode == "dead" then p.hp = 0 elseif mode == "offline" then h.players.alice = nil + elseif mode == "claim" then h.denied = true elseif mode == "wall" then h.obstructed = true + elseif mode == "aim" then p.look.z = -1 elseif mode == "reach" then e.object.pos.y = 5 + elseif mode == "state" then hearth.players.alice = nil elseif mode == "session" then hearth.sessions.alice = nil end + local before = state.stamina; for _ = 1, 20 do assert(e:on_punch(p)) end + assert(e.health == 30 and e.target == nil and p.wear == 0 and state.stamina == before and #h.messages == 0, mode) + end +end) +test("independent movement samples reject sprint-impossible recent displacement", function() + local h = harness(); local p, _, s = h.player(); h.steps[1](0.1) + p.pos.z = 8; local e = h.entity("hearth:practice", {x = 0, y = 0, z = 9.5}) + h.time = h.time + 0.1; e:on_punch(p); assert(e.health == 30) + h.steps[1](0.1); assert(s.combat_sample.blocked_until > h.time); e:on_punch(p); assert(e.health == 30) +end) +test("fresh facing spear RMB guard ignores stale session blocking and weapon swap", function() + for _, mode in ipairs({"guard", "back", "released", "swapped", "tired"}) do + local h = harness(); local p, state, s = h.player(); p.item = "hearth:spear" + local e = h.entity("hearth:practice"); e:on_punch(p); s.blocking = true; p.controls.RMB = true + if mode == "back" then p.look.z = -1 elseif mode == "released" then p.controls.RMB = false + elseif mode == "swapped" then p.item = "hearth:axe" elseif mode == "tired" then state.stamina = 7 end + local before = state.stamina; h.counter(e) + assert(p.hp == (mode == "guard" and 19 or 17), mode) + assert(state.stamina == before - (mode == "guard" and 8 or 0), mode) + end +end) +test("backstep lateral vertical and obstruction evade counter", function() + for _, mode in ipairs({"back", "side", "vertical", "wall"}) do + local h = harness(); local p = h.player(); local e = h.entity("hearth:practice"); e:on_punch(p) + if mode == "back" then p.pos.z = -2 elseif mode == "side" then p.pos.x = 3 + elseif mode == "vertical" then p.pos.y = 4 else h.obstructed = true end + h.counter(e); assert(p.hp == 20, mode) + end +end) +test("committed windup cannot be reset or cancelled even by finishing hit", function() + local h = harness(); local p, state = h.player(); p.item = "hearth:knife" + local e = h.entity("hearth:practice"); e.health = 6; e:on_punch(p); local deadline = e.deadline + h.time = h.time + 0.7; e:on_punch(p) + assert(e.health == 0 and e.round_complete and e.phase == "windup" and e.deadline == deadline) + local before = state.stamina; e:on_punch(p); assert(state.stamina == before) + h.tick(e, 0.05); assert(e.phase == "strike" and p.hp == 17) + h.tick(e, 0.12); assert(e.phase == "recover"); h.tick(e, 0.91) + assert(e.phase == "cooldown" and hearth.sessions.alice.spar_ready_at == h.time + 3) +end) +test("round rest persists across reset other targets spawn attempts and no regen", function() + local h = harness(); local p, state, s = h.player(); local e = h.entity("hearth:practice") + e.health = 4; e:on_punch(p); h.counter(e); h.tick(e, 0.13); h.tick(e, 0.91) + local ready, stamina = s.spar_ready_at, state.stamina; assert(math.abs(ready - h.time - 3) < 0.000001) + e:on_activate(""); local other = h.entity("hearth:practice"); other:on_punch(p) + assert(other.health == 30 and not h.commands.spar.func("alice") and state.stamina == stamina) + h.time = ready + 0.000001; other:on_punch(p); assert(other.health == 26 and s.spar_ready_at == ready) +end) +test("only consenting participant can be hurt never bystanders or PvP", function() + local h = harness(); local p = h.player(); local other = h.player("bob"); local e = h.entity("hearth:practice") + e:on_punch(p); e:on_punch(other); assert(e.health == 26 and e.target == "alice") + h.counter(e); assert(p.hp == 17 and other.hp == 20 and other.damage == 0) + h = harness(); p = h.player(); p.hp = 3; e = h.entity("hearth:practice"); e:on_punch(p); h.counter(e); assert(p.hp == 2) +end) +test("death disconnect waiting or claim denial disarms without damage", function() + for _, mode in ipairs({"death", "offline", "waiting", "claim"}) do + local h = harness(); local p, _, s = h.player(); local e = h.entity("hearth:practice"); e:on_punch(p) + if mode == "death" then p.hp = 0 elseif mode == "offline" then h.players.alice = nil + elseif mode == "waiting" then s.waiting = true else h.denied = true end + h.tick(e, 1); assert(e.target == nil and e.phase == "cooldown" and p.damage == 0) + end +end) +test("moving opt in approaches at bounded speed perception five Hz and locks owner", function() + local h = harness(); local p = h.player(); local other = h.player("bob"); assert(h.commands.spar.func("alice")) + local e = h.objects[1]:get_luaentity(); assert(e.owner == "alice" and e.phase == "approach") + local before = h.nodes; h.tick(e, 0.05, true); assert(h.nodes == before) + for _ = 1, 3 do h.tick(e, 0.05, true) end + assert(e.object.velocity.z < 0 and math.abs(e.object.velocity.z) <= 2.2) + other.pos.z = 2; other.look.z = 1; e:on_punch(other); assert(e.health == 30) + for _ = 1, 30 do h.tick(e, 0.05, true); if e.phase == "windup" then break end end + assert(e.phase == "windup" and e.object.velocity.z == 0 and h.scans == 0) + local f = e.object.animations[#e.object.animations]; assert(f.range.x == 70 and f.range.y == 79) + h.counter(e); f = e.object.animations[#e.object.animations]; assert(f.range.x == 60 and not f.loop and p.hp == 17) +end) +test("terrain side avoidance loaded dry ground requirement and bounded stuck exit", function() + local h = harness(); h.player(); assert(h.commands.spar.func("alice")); local e = h.objects[1]:get_luaentity() + h.ray_test = function(a, b) return math.abs(b.x - a.x) > 0.1 end + for _ = 1, 2 do h.tick(e, 0.1) end + assert(math.abs(e.object.velocity.x) == 2.2 and e.object.velocity.z == 0) + -- No physics movement: repeated side attempts must terminate, not spin forever. + for _ = 1, 40 do h.tick(e, 0.1) end + assert(e.phase == "cooldown" and e.target == nil) + for _, flag in ipairs({"unloaded", "liquid"}) do + h = harness(); h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity(); h[flag] = true + for _ = 1, 2 do h.tick(e, 0.1) end + assert(e.object.velocity.x == 0 and e.object.velocity.z == 0) + end +end) +test("leash disconnect deactivate and huge dt never pursue or catch up damage", function() + local h = harness(); local p, _, s = h.player(); assert(h.commands.spar.func("alice")); local e = h.objects[1]:get_luaentity() + p.pos.z = -10; h.tick(e, 100, true); assert(e.phase == "cooldown" and e.target == nil and p.damage == 0) + assert(s.spar_entity == nil and s.spar_ready_at > h.time) + h = harness(); p = h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity() + h.players.alice = nil; h.tick(e, 0.1); assert(e.target == nil and e.object.velocity.z == 0) + h = harness(); p, _, s = h.player(); assert(h.commands.spar.func("alice")); e = h.objects[1]:get_luaentity() + e:on_deactivate(); assert(s.spar_entity == nil and s.spar_ready_at > h.time) +end) +test("optional feedback adapter sounds explicit names no chat fallback", function() + local h = harness(); local p = h.player(); hearth.feedback = nil + local calls = {}; hearth.feedback_sound = function(name, player, gain) assert(player == p and gain == 0.25); calls[#calls + 1] = name end + local e = h.entity("hearth:practice"); e:on_punch(p); h.counter(e) + assert(#h.messages == 0 and #h.sounds == 0 and calls[1] == "hearth_swing_axe" and calls[3] == "hearth_impact") +end) +test("unowned moving developer entity is inert and distant client punches do not trace world", function() + local h = harness(); local p = h.player(); local e = h.entity("hearth:sparring") + e:on_punch(p); assert(e.health == 30 and e.target == nil) + e = h.entity("hearth:practice", {x = 0, y = 0, z = 10000}) + e:on_punch(p); assert(e.health == 30 and #h.rays == 0) +end) +test("moving rest and reopt in reset stuck memory but not player attack deadlines", function() + local h = harness(); local p, _, s = h.player(); assert(h.commands.spar.func("alice")) + local e = h.objects[1]:get_luaentity() + for _ = 1, 40 do h.tick(e, 0.1) end + assert(e.phase == "cooldown" and e.stuck > 3) + h.tick(e, 3.01); assert(e.phase == "idle") + p.pos.z = 1.5; p.look.z = 1; e:on_punch(p) + assert(e.target == "alice" and e.stuck == 0 and e.last_ai_pos == nil and s.attack_ready_at > h.time) + assert(not h.commands.spar.func("alice") and #h.objects == 1) +end) +test("deactivation clears consent and preserves rest without touching removed object", function() + local h = harness(); local _, _, s = h.player(); assert(h.commands.spar.func("alice")) + local e = h.objects[1]:get_luaentity() + e.object.get_velocity = function() error("removed ObjectRef accessed") end + e.object.set_properties = function() error("removed ObjectRef accessed") end + e:on_deactivate(); assert(s.spar_entity == nil and s.spar_ready_at > h.time) +end) +test("knife and spear consume durability only for confirmed in-reach hits", function() + for _, spec in ipairs({{"hearth:knife", 3, 8, 437}, {"hearth:spear", 5, 14, 546}}) do + local h = harness(); local p, state = h.player(); p.item = spec[1] + local e = h.entity("hearth:practice"); e:on_punch(p) + assert(e.health == 30 - spec[2] and state.stamina == 100 - spec[3] and p.wear == spec[4]) + e:on_punch(p); assert(p.wear == spec[4] and p.wield_updates == 1) + end +end) +print("PASS " .. total .. " encounters checks") diff --git a/tests/engine_smoke.lua b/tests/engine_smoke.lua index 6d9d3c3..236cd83 100755 --- a/tests/engine_smoke.lua +++ b/tests/engine_smoke.lua @@ -2,7 +2,7 @@ -- never copy this into the persistent play world. No engine globals are mocked. local mode = core.settings:get("hearth_launcher_mode") local token = core.settings:get("hearth_smoke_token") -assert(mode == "check" or mode == "smoke", "Invalid launcher test mode") +assert(mode == "check" or mode == "smoke" or mode == "seed", "Invalid launcher test mode") assert(token and token:match("^[%x%-]+$"), "Missing launcher test token") local modpath = core.get_modpath(core.get_current_modname()) local finished = false @@ -37,6 +37,47 @@ local function registry_check() assert(type(hearth) == "table", "Real hearth global was not loaded") assert(type(hearth.model) == "table", "hearth.model missing") assert(type(hearth.players) == "table", "hearth.players missing") + assert(type(hearth.movement.step) == "function", "Movement controller not loaded") + assert(type(hearth.combat.valid_attack) == "function", "Combat rules not loaded") + assert(type(hearth.presentation_step) == "function", "Held-tool presentation not loaded") + assert(type(hearth.feedback_step) == "function", "Movement/target feedback not loaded") + assert(type(hearth.course.report) == "function", "Disposable course not loaded") + assert(type(hearth.home_for) == "function" and type(hearth.store_home) == "function", "Home service not loaded") + assert(type(hearth.start_rest) == "function" and type(hearth.recipes) == "function", "M2 first-night services not loaded") + for module, methods in pairs({movement = {"step", "delta"}, + combat = {"valid_attack", "valid_guard", "sample"}, + construction = {"preview"}, course = {"report"}}) do + assert(type(hearth[module]) == "table", "Missing M1 module: " .. module) + for _, method in ipairs(methods) do + assert(type(hearth[module][method]) == "function", "Missing M1 API: " .. module .. "." .. method) + end + end + for _, method in ipairs({"feedback", "feedback_step", "feedback_sound"}) do + assert(type(hearth[method]) == "function", "Missing feedback API: " .. method) + end + assert(next(hearth.players) == nil and next(hearth.sessions) == nil, "Headless smoke must not invent players") + assert(#core.get_connected_players() == 0, "Headless smoke unexpectedly has live players") + assert(hearth.course.report("") == "No course run in this session. Use /course.") + assert(core.settings:get_bool("hearth_m1_course") ~= true, "Ordinary smoke must not enable course mode") + assert(hearth.storage:get_string("m1_course_world_nonce") == "", "Ordinary smoke enrolled a course world") + assert(not core.registered_nodes["hearth:m1_course_half_step"], "Course-only node leaked into ordinary smoke") + for _, name in ipairs({"course", "practice", "spar", "comfort", "home", "sethome", "recipes"}) do + local command = assert(core.registered_chatcommands[name], "Missing gameplay command: " .. name) + assert(type(command.func) == "function") + assert(command.func("", "") == false, "Command accepted a nonlive console caller: " .. name) + end + local stationary = assert(core.registered_entities["hearth:practice"], "Stationary target missing") + local moving = assert(core.registered_entities["hearth:sparring"], "Moving sparring target missing") + assert(stationary.initial_properties.visual == "cube" and stationary.initial_properties.static_save == true) + assert(moving.initial_properties.visual == "mesh" and moving.initial_properties.static_save == false) + assert(moving.initial_properties.mesh == "hearth_person.b3d") + assert(moving.initial_properties.textures[1] == "hearth_person.png") + for _, def in ipairs({stationary, moving}) do + assert(def.initial_properties.physical == true and def.initial_properties.collide_with_objects == false) + for _, method in ipairs({"on_activate", "on_step", "on_punch", "on_deactivate"}) do + assert(type(def[method]) == "function", "Missing target lifecycle: " .. method) + end + end assert(core.get_modpath("hearth"), "hearth mod missing") for _, name in ipairs({"default", "doors", "farming", "fire", "stairs", "wool", "sfinv"}) do assert(not core.get_modpath(name), "Unexpected MTG dependency: " .. name) @@ -45,16 +86,94 @@ local function registry_check() assert(not name:match("^default:"), "MTG item leaked into standalone game: " .. name) end for _, name in ipairs({"stone", "soil", "grass", "sand", "water_source", "water_flowing", - "log", "leaves", "loose_stone", "sticks", "berry_bush", "reeds", "planks", - "wall", "roof", "fire", "fire_lit"}) do + "log", "leaves", "loose_stone", "sticks", "berry_bush", "berry_bush_bare", "sapling", + "grain_patch", "crop_1", "crop_2", "crop_3", "crop_4", "clay_bank", "iron_ore_vein", "fish_trap", "weir_stone", + "reeds", "planks", "wall", "roof", "foundation", "frame", "stairs", "door", "door_open", "window", + "vent", "storage", "pantry", "drying_rack", "bedroll", "torch", "fire", "fire_lit"}) do assert(core.registered_nodes["hearth:" .. name], "Missing node: " .. name) end - for _, name in ipairs({"berries", "raw_food", "cooked_food", "vessel", "water_raw", - "water_clean", "knife", "axe", "pick", "spear"}) do + for _, name in ipairs({"berries", "grain", "grain_seed", "raw_food", "cooked_food", "fish_bait", "raw_fish", + "cooked_fish", "fish_drying_charge", "dried_fish", "vessel", "water_raw", + "water_clean", "iron_ore", "iron_ingot", "charcoal", "charcoal_charge", "smelt_charge", "clay", "clay_jar", "clay_jar_charge", + "dried_food", "dried_food_charge", "fish_bait", "raw_fish", "cooked_fish", "fish_drying_charge", "dried_fish", + "firestarter", "cloak", "knife", "axe", "iron_axe", "pick", "spear"}) do assert(core.registered_items["hearth:" .. name], "Missing item: " .. name) end - for _, name in ipairs({"knife", "axe", "pick", "spear", "vessel", "fire", "wall", - "roof", "planks", "sticks", "raw_food"}) do + for name, expected in pairs({knife = {3, 0.7}, axe = {4, 1.1}, iron_axe = {6, 0.9}, pick = {3, 1.2}, spear = {5, 1.2}}) do + local def = assert(core.registered_tools["hearth:" .. name]) + local caps = ItemStack("hearth:" .. name):get_tool_capabilities() + assert(caps.damage_groups.fleshy == expected[1] and math.abs(caps.full_punch_interval - expected[2]) < 0.0001, + "Weapon capabilities mismatch: " .. name) + if name ~= "iron_axe" then + assert(def.inventory_image == "hearth_icon_" .. name .. ".png", "Weapon icon missing: " .. name) + else assert(def.inventory_image:find("hearth_", 1, true), "Iron axe image missing") end + end + for name, expected in pairs({soil = {"crumbly", "soil"}, stone = {"cracky", "stone"}, + log = {"choppy", "wood"}, planks = {"choppy", "wood"}, wall = {"choppy", "wood"}, roof = {"snappy", "soil"}}) do + local def = core.registered_nodes["hearth:" .. name] + assert((def.groups[expected[1]] or 0) > 0, "Missing material group: " .. name) + for _, event in ipairs({"dug", "place"}) do + local spec = assert(def.sounds[event], "Missing node sound: " .. name .. "." .. event) + assert(spec.name == "hearth_dig_" .. expected[2] and type(spec.gain) == "number" + and spec.gain > 0 and spec.gain <= 1, "Invalid node sound: " .. name .. "." .. event) + end + end + assert(core.get_item_group("hearth:water_source", "water") > 0) + assert(core.get_item_group("hearth:water_flowing", "liquid") > 0) + assert(core.get_item_group("hearth:loose_stone", "flint") > 0) + assert(core.get_dig_params(core.registered_nodes["hearth:log"].groups, + ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest course logs") + local hand = ItemStack(""):get_tool_capabilities() + local deadwood = assert(core.registered_nodes["hearth:deadwood"]) + assert(not core.get_dig_params(deadwood.groups, hand).diggable, "Bare hands unexpectedly harvest fallen logs") + assert(core.get_dig_params(deadwood.groups, ItemStack("hearth:axe"):get_tool_capabilities()).diggable, + "Axe cannot harvest fallen logs") + local drops = core.get_node_drops("hearth:deadwood", "hearth:axe") + assert(#drops == 1 and ItemStack(drops[1]):get_name() == "hearth:log" + and ItemStack(drops[1]):get_count() == 1, "Fallen logs must drop exactly one timber with an axe") + assert(deadwood.can_dig == hearth.construction.can_dig, "Fallen logs bypass construction permissions") + local timber = {"log", "stone"} + assert(#hearth.diversity.species_order == 10, "Expected ten timber species") + for _, species in ipairs(hearth.diversity.species_order) do + local name = species .. "_log" + timber[#timber + 1] = name + assert(core.get_dig_params(assert(core.registered_nodes["hearth:" .. name]).groups, + ItemStack("hearth:axe"):get_tool_capabilities()).diggable, "Axe cannot harvest " .. name) + end + for _, name in ipairs(timber) do + assert(not core.get_dig_params(core.registered_nodes["hearth:" .. name].groups, hand).diggable, + "Bare hands unexpectedly harvest " .. name) + end + local catalogue = hearth.botany.list() + assert(#catalogue == 80, "Expected all 80 botanical species") + for _, record in ipairs(catalogue) do + assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node) + end + local trees, cubes = 0, 0 + for name, def in pairs(core.registered_nodes) do + local kind = hearth.seasons.kind(name) + if name:match("^hearth:") and (hearth.botany.group_of(name) == "trees" + or ({log=true, leaves=true, sapling=true, tree_seedling=true, young_tree=true, + mature_tree=true, deadwood=true})[kind]) then + assert(def.drawtype ~= "mesh", "Tree still uses mesh drawtype: " .. name) + trees = trees + 1 + if (def.groups.tree or 0) > 0 then + assert(def.drawtype == "normal" and def.walkable == true and def.paramtype2 == "facedir" + and (def.visual_scale or 1) == 1, "Timber must be a full facedir cube: " .. name) + for _, box in ipairs({"node_box", "selection_box", "collision_box"}) do + assert(not def[box] or def[box].type == "regular", "Non-full timber box: " .. name .. "." .. box) + end + cubes = cubes + 1 + end + end + end + assert(deadwood.drawtype == "normal" and deadwood.paramtype2 == "facedir", "Fallen log must be a full facedir cube") + core.log("action", "Last Hearth native botany/wood: species=80, timber_species=10, nonmesh_tree_nodes=" + .. trees .. ", fullcube_timber_nodes=" .. cubes .. "; fallen hand=false, axe=true, drop=hearth:log 1") + for _, name in ipairs({"knife", "axe", "iron_axe", "pick", "spear", "firestarter", "vessel", "fire", + "wall", "roof", "foundation", "frame", "stairs", "door", "window", "vent", "storage", "pantry", + "drying_rack", "bedroll", "fish_trap", "fish_bait", "fish_drying_charge", "torch", "cloak", + "charcoal_charge", "clay_jar_charge", "planks", "sticks", "raw_food"}) do local output = "hearth:" .. name local recipe = core.get_craft_recipe(output) assert(recipe.items and next(recipe.items), "Missing craft: " .. output) @@ -67,47 +186,326 @@ local function registry_check() end local width = recipe.width > 0 and recipe.width or #inputs local result = core.get_craft_result({method = "normal", width = width, items = inputs}) - assert(result.item:get_name() == output, "Engine craft result mismatch: " .. output) + assert(result.item:get_name() == output, "Engine craft result mismatch: " .. output + .. " (method=" .. tostring(recipe.method) .. ", width=" .. tostring(recipe.width) + .. ", got=" .. result.item:get_name() .. " x" .. result.item:get_count() .. ")") assert(result.item:get_count() == ItemStack(recipe.output):get_count(), "Craft quantity mismatch: " .. output) end assert(core.registered_aliases.mapgen_stone == "hearth:stone", "Missing mapgen stone alias") assert(core.registered_aliases.mapgen_water_source == "hearth:water_source", "Missing water alias") assert(core.registered_biomes.hearth_temperate, "Missing temperate biome") assert(core.registered_biomes.hearth_riverbank, "Missing riverbank biome") + assert(core.registered_entities["hearth:held_visual"], "Missing held-tool visual entity") + assert(core.registered_entities["hearth:sparring"], "Missing moving sparring entity") + assert(core.registered_entities["hearth:hare"], "Missing passive bounded wildlife") assert(core.settings:get_bool("enable_pvp") == false, "PvP must be disabled") assert(core.settings:get_bool("secure.enable_security") == true, "Mod security must be enabled") assert(core.settings:get("bind_address") == "127.0.0.1", "Server must bind loopback only") end +-- Check the actual packaged media, not source-art names or a mocked registry. +local function media_check() + local files, missing, references = {}, {}, 0 + local path = assert(core.get_modpath("hearth")) + for _, directory in ipairs({"textures", "models", "sounds"}) do + for _, name in ipairs(core.get_dir_list(path .. "/" .. directory, false)) do + files[name] = directory + end + end + local function reference(name, source) + references = references + 1 + if not files[name] then missing[#missing + 1] = source .. " -> " .. name end + end + local function images(value, source) + if type(value) == "string" then + -- Modifiers (combine, inventorycube, sheet, opacity, etc.) retain + -- their component filenames. Never interpret them as disk paths. + for name in value:gmatch("[%w_%-]+%.png") do reference(name, source) end + elseif type(value) == "table" then + for key, entry in pairs(value) do images(entry, source .. "." .. tostring(key)) end + end + end + local items, entities = 0, 0 + local function definition(def, source) + for _, key in ipairs({"tiles", "overlay_tiles", "special_tiles", "inventory_image", + "wield_image", "inventory_overlay", "wield_overlay", "textures"}) do + images(def[key], source .. "." .. key) + end + if def.mesh and def.mesh ~= "" then reference(def.mesh, source .. ".mesh") end + for event, spec in pairs(def.sounds or {}) do + local name = type(spec) == "table" and spec.name or spec + if type(name) == "string" and name ~= "" then reference(name .. ".ogg", source .. ".sounds." .. event) end + end + end + for name, def in pairs(core.registered_items) do + if name:match("^hearth:") then items = items + 1; definition(def, name) end + end + for name, def in pairs(core.registered_entities) do + if name:match("^hearth:") then entities = entities + 1; definition(def.initial_properties or {}, name) end + end + -- Original generated B3D/OBJ files can also carry atlas references. + for name, directory in pairs(files) do + if directory == "models" then + local file = assert(io.open(path .. "/models/" .. name, "rb")) + local bytes = file:read(2 * 1024 * 1024 + 1); file:close() + assert(#bytes <= 2 * 1024 * 1024, "Unexpectedly large packaged mesh: " .. name) + images(bytes, "models/" .. name) + end + end + table.sort(missing) + assert(#missing == 0, "Packaged media closure failed (" .. #missing .. "):\n" .. table.concat(missing, "\n")) + assert(items > 0 and entities > 0 and references > 0, "Empty media closure check") + core.log("action", "Last Hearth media closure: " .. items .. " items, " .. entities .. " entities, " .. references .. " references; missing=0") +end + +local function craft_closure_check() + local names, count, outputs = {}, 0, 0 + for name in pairs(core.registered_items) do names[#names + 1] = name end + table.sort(names) + local function ingredient(value) + if value:sub(1, 6) ~= "group:" then + assert(value == "" or core.registered_items[ItemStack(value):get_name()], "Unknown craft ingredient: " .. value) + return value + end + for _, name in ipairs(names) do + local matches = true + for group in value:sub(7):gmatch("[^,]+") do + if core.get_item_group(name, group) == 0 then matches = false end + end + if matches then return name end + end + error("No registered item satisfies craft ingredient: " .. value) + end + for _, name in ipairs(names) do + if name:match("^hearth:") then + local recipes = core.get_all_craft_recipes(name) or {} + if #recipes > 0 then outputs = outputs + 1 end + for _, recipe in ipairs(recipes) do + local inputs = {} + for i = 1, (recipe.width == 0 and #recipe.items or recipe.width * 3) do + inputs[i] = ItemStack(ingredient(recipe.items[i] or "")) + end + local result = core.get_craft_result({method = recipe.method, + width = recipe.width > 0 and recipe.width or #inputs, items = inputs}) + local expected = ItemStack(recipe.output) + assert(result.item:get_name() == expected:get_name() and result.item:get_count() == expected:get_count(), + "Craft closure mismatch: " .. recipe.output .. " got " .. result.item:to_string()) + count = count + 1 + end + end + end + assert(count > 0 and outputs > 0, "Empty craft closure check") + core.log("action", "Last Hearth craft closure: " .. count .. " recipes, " .. outputs .. " outputs") +end + +local function worldgen_registry_check() + local profiles = assert(hearth.biomes and hearth.biomes.biome_profiles, "Biome profiles not wired") + assert(#profiles == 10, "Expected ten biome profiles") + for _, p in ipairs(profiles) do + local def = assert(core.registered_biomes[p.name], "Missing native biome: " .. p.name) + assert(def.node_top == "hearth:" .. p.top and def.node_filler == "hearth:" .. p.filler + and def.node_stone == "hearth:" .. p.rock and def.y_min == p.min and def.y_max == p.max, + "Native biome/profile mismatch: " .. p.name) + end + local ores, count = {}, 0 + for _, def in pairs(core.registered_ores) do + if def.name and def.name:match("^hearth:") then + assert(core.registered_nodes[def.ore], "Unknown ore: " .. tostring(def.ore)) + for _, host in ipairs(type(def.wherein) == "table" and def.wherein or {def.wherein}) do + assert(core.registered_nodes[host] and core.get_item_group(host, "stone") > 0, + "Invalid native ore host: " .. tostring(host)) + end + ores[def.ore], count = true, count + 1 + end + end + assert(count == 9, "Expected nine native geological/ore registrations; got " .. count) + for _, ore in ipairs({"quartz_vein", "copper_ore_vein", "coal_ore_vein", "iron_ore_vein"}) do + assert(ores["hearth:" .. ore], "Missing ore registration: " .. ore) + end + for setting, expected in pairs({mg_name="v7", mapgen_limit="4096", water_level="1", mgv7_mount_zero_level="0"}) do + assert(core.get_mapgen_setting(setting) == expected, "Saved mapgen setting mismatch: " .. setting) + end + for setting, expected in pairs({mgv7_np_terrain_base={12,45,384}, mgv7_np_terrain_alt={12,28,384}, + mgv7_np_mount_height={250,100,700}, mgv7_np_mountain={-0.6,1,180}}) do + local actual = assert(core.get_mapgen_setting_noiseparams(setting), "Missing saved mapgen noise: " .. setting) + assert(math.abs(actual.offset - expected[1]) < 0.0001 and math.abs(actual.scale - expected[2]) < 0.0001 + and actual.spread.x == expected[3], "Saved terrain scale mismatch: " .. setting) + end + core.log("action", "Last Hearth worldgen registry: biomes=10, ores=9; saved v7 terrain base=12/45, alt=12/28, mount=250/100, mapgen_limit=4096") +end + local function unit_check() -- Each standalone harness receives its own globals and os table. Mocked core, -- hearth and os.time never replace globals of the running game. local manifest = dofile(modpath .. "/syntax_manifest.lua") local lookup = {} for _, entry in ipairs(manifest) do lookup[entry.label] = modpath .. "/syntax/" .. entry.file end - local count = 0 - for label, filename in pairs(lookup) do + local count, labels = 0, {} + for label in pairs(lookup) do labels[#labels + 1] = label end + table.sort(labels) + for _, module in ipairs({"model", "content", "schema", "persistence", "clock", "players", "runtime", + "access", "environment", "init", "ecology", "seasons", "succession", "wildlife", + "movement", "combat", "construction", "course", "encounters", "presentation", "biomes", "diversity", + "m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character"}) do + assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module) + end + local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"} + local real_globals = {}; for k, v in pairs(_G) do real_globals[k] = v end + for _, label in ipairs(labels) do + local filename = lookup[label] if label:match("_spec%.lua$") then local module = label:match("tests/(.+)_spec%.lua$") - local target = assert(lookup["mods/hearth/" .. module .. ".lua"], "Missing test module") - local env = setmetatable({arg = {target}, os = {}}, {__index = _G}) - for k, v in pairs(os) do env.os[k] = v end + local target = assert(lookup["mods/hearth/" .. (test_modules[module] or module) .. ".lua"], "Missing test module") + local env = setmetatable({arg = {target}, hearth = {}, core = {}, minetest = {}}, {__index = _G}) + -- Do not inherit the live hearth.combat/schema via __index. Sibling + -- modules execute in this suite's environment, never against live APIs. + for _, name in ipairs({"os", "io", "math", "table", "string", "vector"}) do + env[name] = {}; for k, v in pairs(_G[name]) do env[name][k] = v end + end env._G = env env.dofile = function(path) - assert(path == target, "Unit harness attempted unexpected file access") + local permitted = false + for _, mapped in pairs(lookup) do if mapped == path then permitted = true; break end end + assert(permitted, "Unit harness attempted unexpected file access") local chunk = assert(loadfile(path)); setfenv(chunk, env); return chunk() end + -- Content factories load by engine modpath; remap only known source files + -- into their isolated staged copies, never the live game or arbitrary paths. + local isolated_dofile = env.dofile + env.dofile = function(path) + local live = core.get_modpath("hearth") .. "/" + if path:sub(1, #live) == live then + path = assert(lookup["mods/hearth/" .. path:sub(#live + 1)], "Unmapped module") + end + -- Harness derives sibling paths from its flattened target filename. + if not path:match("/%d+%.lua$") then + local sibling = path:match("([^/\\]+%.lua)$") + path = lookup["mods/hearth/" .. (sibling or "")] or path + end + return isolated_dofile(path) + end local chunk = assert(loadfile(filename)); setfenv(chunk, env); chunk() + for k, v in pairs(real_globals) do assert(_G[k] == v, "Unit suite replaced live global: " .. tostring(k)) end + for k in pairs(_G) do assert(real_globals[k] ~= nil, "Unit suite leaked live global: " .. tostring(k)) end + assert(next(hearth.players) == nil and next(hearth.sessions) == nil, "Unit suite leaked live player state") count = count + 1 end end - assert(count >= 2, "Expected model and content regression suites") + assert(count == 29, "Expected all 29 regression suites; got " .. count) core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed") end +-- Four separated, narrow columns, not a huge continuous exploration volume. +-- Their deep slices exercise actual ore hosts; surface samples need not expose +-- every biome (and no individual biome is required at the arrival point). +local function worldgen_check(done) + if mode == "seed" then done(); return end + local positions = {{-1200,-800}, {1200,800}, {-800,1200}, {800,-1200}} + local totals, biomes, index = {}, {}, 0 + local wanted = {"hearth:granite", "hearth:slate", "hearth:limestone", "hearth:sandstone", + "hearth:quartz_vein", "hearth:copper_ore_vein", "hearth:coal_ore_vein", "hearth:iron_ore_vein"} + local function next_probe() + index = index + 1 + if index > #positions then + local report, biome_count = {}, 0 + for _, name in ipairs(wanted) do + assert((totals[name] or 0) > 0, "Broad worldgen probes missing material: " .. name) + report[#report + 1] = name .. "=" .. totals[name] + end + for _ in pairs(biomes) do biome_count = biome_count + 1 end + assert(biome_count >= 2, "Broad worldgen probes found fewer than two surface biomes") + core.log("action", "Last Hearth broad worldgen: probes=4, surface_biomes=" .. biome_count .. "; " .. table.concat(report, ", ")) + done(); return + end + local p = positions[index] + local minimum, maximum = {x=p[1]-8,y=-112,z=p[2]-8}, {x=p[1]+8,y=320,z=p[2]+8} + core.emerge_area(minimum, maximum, function(_, action, remaining) + if finished then return end + guarded(function() + assert(action ~= core.EMERGE_ERRORED and action ~= core.EMERGE_CANCELLED, + "Broad worldgen emergence failed at " .. p[1] .. "," .. p[2]) + if remaining ~= 0 then return end + core.after(0, function() + guarded(function() + local _, counts = core.find_nodes_in_area(minimum, maximum, wanted) + for name, count in pairs(counts) do totals[name] = (totals[name] or 0) + count end + for x = minimum.x, maximum.x, 8 do for z = minimum.z, maximum.z, 8 do + for y = 318, 0, -1 do + local pos = {x=x,y=y,z=z} + local name = core.get_node(pos).name + if hearth.biomes.surface(name) then + local data = assert(core.get_biome_data(pos), "Missing native biome data") + biomes[assert(core.get_biome_name(data.biome))] = true + break + end + end + end end + core.log("action", "Last Hearth worldgen probe: x=" .. p[1] .. ", z=" .. p[2] .. ", y=-112..320") + next_probe() + end) + end) + end) + end) + end + next_probe() +end + +local function actor_check() + -- Native objects, not PlayerRef doubles. This checks gravity/collision and + -- animation API state only: no consenting player means no chase or combat. + local block = {x = 40, y = 85, z = 40} + assert(core.forceload_block(block, true), "Cannot hold native actor test block active") + local objects = {} + local function cleanup() + for _, object in ipairs(objects) do if object:get_pos() then object:remove() end end + core.forceload_free_block(block, true) + end + core.register_on_shutdown(cleanup) + for x = 37, 43 do for z = 37, 43 do + core.set_node({x = x, y = 84, z = z}, {name = "hearth:stone"}) + for y = 85, 90 do core.set_node({x = x, y = y, z = z}, {name = "air"}) end + end end + for i, name in ipairs({"hearth:practice", "hearth:sparring"}) do + local object = assert(core.add_entity({x = 34 + i * 4, y = 87, z = 40}, name), "Native actor spawn failed") + objects[#objects + 1] = object + local entity = assert(object:get_luaentity()) + assert(not object:is_player() and entity.name == name and entity.health == 30 and entity.phase == "idle") + assert(entity.target == nil and entity.owner == nil and entity.mobile == (i == 2)) + assert(object:get_acceleration().y < -9 and object:get_properties().physical == true) + assert(object:get_armor_groups().immortal == 1) + end + local animated = objects[2] + local idle, speed, blend, loop = animated:get_animation() + assert(idle.x == 0 and idle.y == 19 and speed == 12 and loop == true, "Native idle animation mismatch") + for _, frame in ipairs({{0,19,12,true}, {20,39,24,true}, {40,59,32,true}, + {60,69,28,false}, {70,79,12,true}, {80,99,20,true}}) do + animated:set_animation({x = frame[1], y = frame[2]}, frame[3], 0.12, frame[4]) + local range, rate, transition, looping = animated:get_animation() + assert(range.x == frame[1] and range.y == frame[2] and range.x >= 0 and range.y <= 99) + assert(rate == frame[3] and math.abs(transition - 0.12) < 0.0001 and looping == frame[4]) + end + animated:set_animation({x = 0, y = 19}, 12, 0.12, true) + core.after(2, function() + guarded(function() + for i, object in ipairs(objects) do + local pos, velocity = assert(object:get_pos()), object:get_velocity() + assert(math.abs(pos.x - (34 + i * 4)) < 0.02 and math.abs(pos.z - 40) < 0.02, + "Unowned actor moved horizontally without player consent") + assert(pos.y >= 84.49 and pos.y <= 84.7 and math.abs(velocity.y) < 0.2, + "Native gravity/collision did not settle actor on the test floor") + local entity = object:get_luaentity() + assert(entity.phase == "idle" and entity.target == nil and entity.health == 30) + end + cleanup() + core.log("action", "Last Hearth: native stationary/sparring gravity, collision and animation API checks passed; no player chase, rendered animation or audible playback proven") + finish(true) + end) + end) +end + local function emerge_check() local callbacks = 0 - core.emerge_area({x = -64, y = -32, z = -64}, {x = 64, y = 96, z = 64}, + core.emerge_area({x = -64, y = -32, z = -64}, {x = 64, y = 320, z = 64}, function(_, action, remaining) if finished then return end guarded(function() @@ -133,23 +531,142 @@ local function emerge_check() end end assert(material, "Mapgen produced no hearth terrain in the test volume") - local resources = {} - for _, name in ipairs({"grass", "loose_stone", "sticks", "berry_bush", "reeds", "log", "water_source"}) do - local nodes = core.find_nodes_in_area({x = -64, y = -16, z = -64}, {x = 64, y = 96, z = 64}, {"hearth:" .. name}) - resources[#resources + 1] = name .. "=" .. #nodes - assert(#nodes > 0, "Starting region missing required resource: " .. name) + local resources, missing = {}, {} + local minimum, maximum = {x = -64, y = 0, z = -64}, {x = 64, y = 318, z = 64} + local function side_air(p) + for _, offset in ipairs({{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) do + local node = core.get_node_or_nil({x = p.x + offset[1], y = p.y, z = p.z + offset[2]}) + local def = node and core.registered_nodes[node.name] + if node and node.name == "air" and def and def.walkable == false then return true end + end + return false end - core.log("action", "Last Hearth starting resources: " .. table.concat(resources, ", ")) + for _, entry in ipairs({{"ground", "group:soil", "hearth:sand", "hearth:gravel", "hearth:scree", "hearth:snow", "group:stone"}, + {"flint", "group:flint"}, {"sticks", "hearth:sticks"}, {"berries", "hearth:berry_bush"}, + {"reeds", "hearth:reeds"}, {"log", "hearth:log", "hearth:oak_log", "hearth:birch_log", + "hearth:pine_log", "hearth:willow_log", "hearth:alder_log"}, {"water", "group:water"}}) do + local name, selectors = entry[1], {} + for i = 2, #entry do selectors[#selectors + 1] = entry[i] end + -- Canopies cover trunk tops, but an axe can reach their sides. + -- Require loaded air at the same height: buried logs do not count. + local nodes, raw_count + if name == "log" then + local logs = core.find_nodes_in_area(minimum, maximum, selectors) + nodes, raw_count = {}, #logs + for _, pos in ipairs(logs) do + if side_air(pos) then nodes[#nodes + 1] = pos end + end + else + -- Buried water/ground still cannot prove surface access. + nodes = core.find_nodes_in_area_under_air(minimum, maximum, selectors) + end + resources[#resources + 1] = name .. "=" .. #nodes .. (raw_count and " (total=" .. raw_count .. ")" or "") + if #nodes == 0 then missing[#missing + 1] = name end + end + core.log("action", "Last Hearth starting resources: seed=" .. tostring(core.get_mapgen_setting("seed")) + .. "; " .. table.concat(resources, ", ")) + if #missing > 0 then + local biomes, report = {}, {} + for x = -64, 64, 4 do for z = -64, 64, 4 do + for y = 318, 0, -1 do + local pos = {x = x, y = y, z = z} + if hearth.biomes.surface(core.get_node(pos).name) then + local data = core.get_biome_data(pos) + local name = data and core.get_biome_name(data.biome) or "unknown" + biomes[name] = (biomes[name] or 0) + 1 + break + end + end + end end + for name, count in pairs(biomes) do report[#report + 1] = name .. "=" .. count end + table.sort(report) + core.log("error", "Last Hearth starting biome surface samples (4-node grid): " .. table.concat(report, ", ")) + error("Starting region missing required resource: " .. table.concat(missing, ", ")) + end + if mode == "seed" then + local dry_spawn = 0 + local function dry_clear(p) + local node = core.get_node_or_nil({x = p.x, y = p.y, z = p.z}) + if not node or node.name == "ignore" then return false end + local def = core.registered_nodes[node.name] + local groups = def and def.groups or {} + return def and def.walkable == false + and (not def.liquidtype or def.liquidtype == "none") + and (groups.water or 0) == 0 and (groups.liquid or 0) == 0 + and (groups.lava or 0) == 0 and (def.damage_per_second or 0) == 0 + end + local function natural_support(p) + local node = core.get_node_or_nil(p) + local support = node and node.name ~= "ignore" and core.registered_nodes[node.name] + local groups = support and support.groups or {} + if not (support and support.walkable == true + and (not support.liquidtype or support.liquidtype == "none") + and (groups.water or 0) == 0 and (groups.liquid or 0) == 0 + and (groups.lava or 0) == 0 and (support.damage_per_second or 0) == 0) then return false end + for _, group in ipairs({"tree", "leaves", "wood", "hearth_canopy", "hearth_building", "hearth_non_support", "flora", "hearth_ecosystem"}) do + if (groups[group] or 0) > 0 then return false end + end + return (groups.soil or 0) > 0 or (groups.stone or 0) > 0 + or (groups.sand or 0) > 0 or (groups.crumbly or 0) > 0 + end + local function landing_patch(x, y, z) + for dx = -1, 1 do for dz = -1, 1 do + local p = {x = x + dx, y = y, z = z + dz} + if not natural_support(p) and not (dry_clear(p) + and natural_support({x = p.x, y = y - 1, z = p.z})) then return false end + if not dry_clear({x = p.x, y = y + 1, z = p.z}) + or not dry_clear({x = p.x, y = y + 2, z = p.z}) then return false end + end end + return true + end + -- Match the player's first natural surface and 3x3 dry patch; + -- never descend through terrain/water to count a cave floor. + for x = -64, 64, 4 do for z = -64, 64, 4 do + for ground_y = 318, 0, -1 do + local p = {x = x, y = ground_y, z = z} + local node = core.get_node_or_nil(p) + local def = node and node.name ~= "ignore" and core.registered_nodes[node.name] + if not def then break end + local groups = def.groups or {} + if natural_support(p) then + if math.abs(x) < 64 and math.abs(z) < 64 and landing_patch(x, ground_y, z) then + dry_spawn = dry_spawn + 1 + end + break + elseif (def.liquidtype and def.liquidtype ~= "none") + or (groups.water or 0) > 0 or (groups.liquid or 0) > 0 or (groups.lava or 0) > 0 + or (def.damage_per_second or 0) > 0 or (def.walkable ~= false and (groups.tree or 0) <= 0 + and (groups.leaves or 0) <= 0 and (groups.hearth_canopy or 0) <= 0) then break end + end + end end + assert(dry_spawn > 0, "Starting seed has no dry, supported spawn surface") + core.log("action", "Last Hearth seed arrival surfaces: " .. dry_spawn) + end + worldgen_check(function() local p = {x = 0, y = 85, z = 0} - core.set_node(vector.offset(p, 0, 3, 0), {name = "hearth:roof"}) - for _, d in ipairs({{1,0},{-1,0},{0,1},{0,-1}}) do - core.set_node(vector.offset(p, d[1]*2, 1, d[2]*2), {name = "hearth:wall"}) - end + -- Clear this artificial fixture: dramatic terrain can now + -- occupy its old fixed altitude. This is not spawn evidence. + for x = -2, 2 do for z = -2, 2 do for y = 86, 88 do + core.set_node({x=x,y=y,z=z}, {name="air"}) + end end end + for x = -3, 3 do for z = -3, 3 do + core.set_node({x = x, y = 85, z = z}, {name = "hearth:stone"}) + core.set_node({x = x, y = 89, z = z}, {name = "hearth:roof"}) + end end + for y = 86, 88 do for side = -3, 3 do + core.set_node({x = -3, y = y, z = side}, {name = "hearth:wall"}) + core.set_node({x = 3, y = y, z = side}, {name = "hearth:wall"}) + core.set_node({x = side, y = y, z = -3}, {name = "hearth:wall"}) + core.set_node({x = side, y = y, z = 3}, {name = "hearth:wall"}) + end end local env = hearth.environment(p) - assert(env.roof and env.shelter == 1, "A roof and four walls must enclose a shelter") - core.remove_node(vector.offset(p, 0, 3, 0)) + assert(env.enclosed and env.roof and env.shelter == 1 and not env.ventilated, + "A closed room must be sheltered but unventilated") + core.remove_node({x = 0, y = 89, z = 0}) + hearth.invalidate_environment() assert(hearth.environment(p).shelter == 0, "Walls without a roof are exposed") - finish(true) + actor_check() + end) end) end) end) @@ -161,9 +678,12 @@ core.after(1, function() syntax_check() if mode == "check" then finish(true); return end registry_check() - unit_check() + media_check() + craft_closure_check() + worldgen_registry_check() + if mode ~= "seed" then unit_check() end emerge_check() end) end) -core.after(60, function() finish(false, "Engine test deadline exceeded") end) +core.after(80, function() finish(false, "Engine test deadline exceeded (80s; launcher limit 90s)") end) diff --git a/tests/environment_spec.lua b/tests/environment_spec.lua new file mode 100755 index 0000000..e3b76f7 --- /dev/null +++ b/tests/environment_spec.lua @@ -0,0 +1,90 @@ +-- Bounded room/ventilation tests with an isolated fake node map. +local target = assert(arg[1], "environment.lua path required") +local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end +local h = {world = {}, reads = 0, clock = 0, fire = nil, fuel_until = 100, roof_center = nil} +local function put(x, y, z, name) h.world[key({x = x, y = y, z = z})] = name end +local function room() + h.world = {} + for x = -3, 3 do for z = -3, 3 do + put(x, 0, z, "stone") + put(x, 4, z, x == 0 and z == 0 and (h.roof_center or "roof") or "roof") + end end + for y = 1, 3 do for c = -3, 3 do + put(-3, y, c, "wall"); put(3, y, c, "wall") + put(c, y, -3, "wall"); put(c, y, 3, "wall") + end end +end + +core = {registered_nodes = { + air = {walkable = false, buildable_to = true}, + stone = {walkable = true}, roof = {walkable = true}, wall = {walkable = true}, + ["hearth:door_open"] = {walkable = false}, + ["hearth:vent"] = {walkable = true, groups = {hearth_vent = 1}}, +}} +function core.get_node_or_nil(pos) + h.reads = h.reads + 1 + local name = h.world[key(pos)] + if name == false then return nil end + return {name = name or "air"} +end +function core.get_us_time() return h.clock * 1000000 end +function core.find_node_near() return h.fire end +function core.get_meta() + return {get_string = function(_, name) return name == "fuel_until" and tostring(h.fuel_until) or "" end} +end +function core.line_of_sight() return true end +vector = { + round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end, + offset = function(p, x, y, z) return {x = p.x + x, y = p.y + y, z = p.z + z} end, + distance = function(a, b) return math.sqrt((a.x-b.x)^2 + (a.y-b.y)^2 + (a.z-b.z)^2) end, +} +hearth = {model = {fuel_remaining = function(deadline) return math.max(0, deadline - 1) end}, + weather = "clear", clock = {temperature = 10}} +dofile(target) + +local passed = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)) + passed = passed + 1 + print("ok " .. passed .. " - " .. name) +end +local player_pos = {x = 0, y = 0, z = 0} + +test("closed room is fully sheltered and flood-fill work is bounded", function() + h.roof_center, h.fire = nil, nil; room(); h.reads = 0 + local env = hearth.environment(player_pos) + assert(env.enclosed and env.roof and env.shelter == 1 and not env.ventilated) + assert(h.reads < 2500, "room probe exceeded its fixed-volume budget") +end) +test("open door marks one exposed side without losing all roof shelter", function() + h.roof_center, h.fire = nil, nil; room(); put(0, 1, 3, "hearth:door_open") + hearth.invalidate_environment() + local env = hearth.environment(player_pos) + assert(not env.enclosed and env.roof and env.open_sides == 1) + assert(math.abs(env.shelter - 0.8125) < 0.00001 and env.ventilated) +end) +test("a roof vent preserves weather shelter and safely exhausts hearth smoke", function() + h.roof_center, h.fire = "hearth:vent", {x = 0, y = 1, z = 0}; room() + hearth.invalidate_environment() + local env = hearth.environment(player_pos) + assert(env.enclosed and env.shelter == 1 and env.ventilated and env.heat > 0 and not env.smoke) + h.roof_center, h.fire = nil, {x = 0, y = 1, z = 0}; room(); hearth.invalidate_environment() + env = hearth.environment(player_pos) + assert(env.enclosed and env.heat > 0 and env.smoke) +end) +test("changed structure invalidates cached room geometry", function() + h.roof_center, h.fire = nil, nil; room(); hearth.invalidate_environment() + assert(hearth.environment(player_pos).enclosed) + put(0, 1, 3, "hearth:door_open") + assert(hearth.environment(player_pos).enclosed, "cache should hold until an edit invalidates it") + hearth.invalidate_environment() + assert(not hearth.environment(player_pos).enclosed) +end) +test("unloaded room probes fail closed instead of granting shelter", function() + h.roof_center, h.fire = nil, nil; room(); h.world["3:1:0"] = false + hearth.invalidate_environment() + local env = hearth.environment(player_pos) + assert(env.shelter == 0 and not env.enclosed) +end) +print("PASS " .. passed .. " room and ventilation tests") diff --git a/tests/fixtures/legacy-save.lua b/tests/fixtures/legacy-save.lua new file mode 100755 index 0000000..8643c50 --- /dev/null +++ b/tests/fixtures/legacy-save.lua @@ -0,0 +1,21 @@ +-- Synthetic pre-M0 records. raw is byte-sensitive migration evidence, not Lua +-- source to evaluate. Only this trusted fixture module is loaded by the harness. +return { + needs = { + raw = ' { "food":85, "water":85, "warmth":80, "wet":0, "stamina":100 }\n', + data = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}, + }, + claims = { + raw = '{ "fixture_player" : {"x":-12,"y":4,"z":8} }', + data = {fixture_player = {x = -12, y = 4, z = 8}}, + }, + location = { + raw = ' {"latitude":45.25, "longitude":-73.5} ', + data = {latitude = 45.25, longitude = -73.5}, + }, + spawn = { + raw = '{"x":0.5,"y":5.25,"z":-0.125}\n', + data = {x = 0.5, y = 5.25, z = -0.125}, + }, + empty_claims = {raw = '{}', data = {}}, +} diff --git a/tests/fixtures/scenarios.json b/tests/fixtures/scenarios.json new file mode 100755 index 0000000..5c977e9 --- /dev/null +++ b/tests/fixtures/scenarios.json @@ -0,0 +1,8 @@ +{ + "format": 1, + "scenarios": { + "first-session": { "seed": 731104, "clock": "real-date" }, + "summer": { "seed": 731104, "clock": "fixed", "utc": 1782043200, "weather": "clear", "latitude": 45, "longitude": 0 }, + "winter": { "seed": 731104, "clock": "fixed", "utc": 1797854400, "weather": "rain", "latitude": 45, "longitude": 0 } + } +} diff --git a/tests/flora-art.test.mjs b/tests/flora-art.test.mjs new file mode 100755 index 0000000..5e91d1b --- /dev/null +++ b/tests/flora-art.test.mjs @@ -0,0 +1,372 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { realpath } from 'node:fs'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import os from 'node:os'; +import { inflateSync } from 'node:zlib'; +import { FLORA_NAMES, TREE_SEASONS, LEGACY_MESH_SPECIES, SPECIES_MESH_NAMES, SPECIES_ATLAS_NAMES, floraOBJ, floraBounds, treeAtlas } from '../scripts/flora-art.mjs'; +import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from '../scripts/world-art.mjs'; +import { BOTANY_ART_MANIFEST, generateAssets, png } from '../scripts/assets.mjs'; +import { packageGame, verifyPackage } from '../scripts/package.mjs'; +import { CHARACTER_ART_MANIFEST } from '../scripts/character-art.mjs'; + +const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]; +const sub=(a,b)=>a.map((v,i)=>v-b[i]); +const dot=(a,b)=>a.reduce((s,n,i)=>s+n*b[i],0); +function parse(text) { + const v=[],vt=[],vn=[],groups=[]; let group; + for(const line of text.trim().split('\n')) { + const [kind,...p]=line.split(' '); + assert.ok(['#','s','v','vt','vn','g','f'].includes(kind),'no external material references'); + if(kind==='v') v.push(p.map(Number)); + if(kind==='vt') vt.push(p.map(Number)); + if(kind==='vn') vn.push(p.map(Number)); + if(kind==='g') { group={name:p[0],faces:[]}; groups.push(group); } + if(kind==='f') group.faces.push(p.map(t=>t.split('/').map(Number))); + } + return {v,vt,vn,groups}; +} +test('all original deterministic, closed volumetric meshes have finite indexed triangles, outward normals and inset material UVs',()=> { + const distinct=new Set(); + for(const name of FLORA_NAMES) { + const text=floraOBJ(name); assert.equal(text,floraOBJ(name)); distinct.add(text); + const {v,vt,vn,groups}=parse(text); + const species=SPECIES_MESH_NAMES.includes(name), triangles=groups.flatMap(g=>g.faces).length; + assert.ok(v.length>50 && v.length<=(species?640:300),`${name}: bounded per-species topology`); + assert.ok(triangles<=(species?1200:600),`${name}: explicit triangle budget`); + for(const values of [v,vt,vn]) assert.ok(values.flat().every(Number.isFinite)); + const referenced=new Set(); + for(const group of groups) { + const ids=[...new Set(group.faces.flat().map(p=>p[0]))]; + const center=[0,1,2].map(i=>ids.reduce((s,id)=>s+v[id-1][i],0)/ids.length); + const edges=new Map(); let volume=0; + for(const f of group.faces) { + assert.equal(f.length,3); assert.equal(new Set(f.map(p=>p[0])).size,3); + for(const [a,t,n] of f) { + assert.ok(a>=1 && a<=v.length && t>=1 && t<=vt.length && n>=1 && n<=vn.length); + referenced.add(a); + const [u,w]=vt[t-1]; assert.ok(w>=.5/32 && w<=31.5/32); + assert.ok(group.name.startsWith('leaf') || group.name.startsWith('foliage') + ? u>=32.5/64 && u<=63.5/64 : u>=.5/64 && u<=31.5/64); + } + const [a,b,c]=f.map(p=>v[p[0]-1]), normal=cross(sub(b,a),sub(c,a)); + assert.ok(Math.hypot(...normal)>1e-5); + assert.ok(dot(normal,sub(a,center))>0,'outward winding'); + assert.ok(dot(normal,vn[f[0][2]-1])/Math.hypot(...normal)>.99999); + volume+=dot(a,cross(b,c))/6; + for(let i=0;i<3;i++) { + const a=f[i][0], b=f[(i+1)%3][0], key=[Math.min(a,b),Math.max(a,b)].join(); + const entry=edges.get(key)||[0,0]; entry[0]++; entry[1]+=a.001,'solid 3D volume, not a billboard'); + for(const edge of edges.values()) assert.deepEqual(edge,[2,0],'closed oriented manifold per lobe/branch'); + } + assert.equal(referenced.size,v.length); + const bound=floraBounds(name); + for(let i=0;i<3;i++) { + assert.equal(Math.min(...v.map(p=>p[i]))/10,bound.min[i]); + assert.equal(Math.max(...v.map(p=>p[i]))/10,bound.max[i]); + } + assert.equal(bound.min[1],-.5,'ground is node bottom'); + assert.ok(bound.max[0]!==-bound.min[0] || bound.max[2]!==-bound.min[2],'authored asymmetry'); + } + assert.equal(distinct.size,FLORA_NAMES.length); + for(const bad of ['../young_tree','constructor','oak',null]) { + assert.throws(()=>floraOBJ(bad),/Unknown flora/); assert.throws(()=>floraBounds(bad),/Unknown flora/); + } +}); + +test('winter trees are forked wood-only sculptures, with fewer faces and every original trunk/bough preserved exactly',()=> { + const geometry=(mesh,group)=>group.faces.map(face=>face.map(([v,t,n])=>[mesh.v[v-1],mesh.vt[t-1],mesh.vn[n-1]])); + for(const name of ['young_tree','mature_tree']) { + const summer=parse(floraOBJ(name)), winter=parse(floraOBJ(`${name}_winter`)); + assert.ok(winter.groups.every(g=>!/^(leaf|foliage)/.test(g.name)),'no foliage geometry'); + assert.ok(winter.groups.flatMap(g=>g.faces).lengthg.faces).length); + const woody=summer.groups.filter(g=>!/^(leaf|foliage)/.test(g.name)); + for(const group of woody) { + const retained=winter.groups.find(g=>g.name===group.name); + assert.ok(retained); assert.deepEqual(geometry(winter,retained),geometry(summer,group),group.name); + } + const forks=winter.groups.filter(g=>g.name.startsWith('winter_fork_')); + assert.equal(forks.length,woody.length-1,'one lateral fork per bough'); + for(const group of forks) { + const points=geometry(winter,group).flat().map(p=>p[0]); + assert.ok(Math.max(...points.map(p=>p[0]))-Math.min(...points.map(p=>p[0]))>1,'lateral silhouette'); + } + const a=floraBounds(name), b=floraBounds(`${name}_winter`); + assert.equal(b.min[1],-.5); assert.ok(b.max[1]> (name==='mature_tree'?2: .9)); + for(let i=0;i<3;i++) assert.ok(b.min[i]>=a.min[i] && b.max[i]<=a.max[i],'within existing clearance envelope'); + assert.ok(winter.vt.every(([u])=>u<=31.5/64),'all faces sample bark only'); + } + assert.ok(!FLORA_NAMES.includes('tree_seedling_winter'),'runtime reuses seedling mesh'); +}); + +test('tree heights, footprints and distinct overlapping foliage lobes respect gameplay scale',()=> { + for(const [name,maxHeight,x,z] of [['tree_seedling',.7,.3,.3],['young_tree',2.2,.65,.65], + ['mature_tree',3.5,1.1,1.1],['deadwood',.5,.45,.9]]) { + const {min,max}=floraBounds(name); + assert.ok(max[1]-min[1]<=maxHeight+1e-6,name); + assert.ok(Math.max(Math.abs(min[0]),Math.abs(max[0]))<=x,name); + assert.ok(Math.max(Math.abs(min[2]),Math.abs(max[2]))<=z,name); + } + assert.ok(floraBounds('young_tree').max[1]>=1.3); + assert.equal(floraBounds('mature_tree').max[1],3); + for(const [name,count] of [['tree_seedling',2],['young_tree',4],['mature_tree',5]]) { + const {groups}=parse(floraOBJ(name)); + assert.equal(groups.filter(g=>/^(leaf|foliage)/.test(g.name)).length,count); + assert.ok(groups.some(g=>/^(stem|trunk)$/.test(g.name))); + } +}); + +test('single bark/leaf atlas is deterministic 64x32 straight RGBA with solid canopy underpaint and exact encoded pixels',()=> { + const image=treeAtlas(); assert.deepEqual(image,treeAtlas()); + assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]); + assert.ok(image.data.filter((_,i)=>i%4===3).every(a=>a===255),'opaque lobes do not expose torn faces'); + assert.notDeepEqual(image.data.slice(0,128),image.data.slice(128,256)); + const bytes=png(64,(x,y)=>image.data.subarray((y*64+x)*4,(y*64+x+1)*4),32); + assert.equal(bytes[25],6,'PNG RGBA color type'); + const compressed=[]; + for(let i=8;itreeAtlas(new Map()),/requires/); +}); + +test('all four seasonal atlases preserve byte-exact bark and opaque 64x32 materials with season-specific leaf underpaint',()=> { + const art=worldArt(), summer=treeAtlas(art), unique=new Set(); + assert.deepEqual(TREE_SEASONS,['summer','spring','autumn','winter']); + for(const season of TREE_SEASONS) { + const image=treeAtlas(art,season), leaves=art.get(`hearth_leaves${season==='summer'?'':`_${season}`}.png`); + assert.deepEqual(image,treeAtlas(art,season)); + assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]); + unique.add(Buffer.from(image.data).toString('hex')); + for(let y=0;y<32;y++) { + assert.deepEqual(image.data.slice(y*256,y*256+128),summer.data.slice(y*256,y*256+128),'unchanged bark'); + for(let x=0;x<32;x++) { + const from=(y*32+x)*4, to=(y*64+x+32)*4; + const color=[...image.data.slice(to,to+4)]; assert.equal(color[3],255); + if(leaves.data[from+3]===255) assert.deepEqual(color,[...leaves.data.slice(from,from+4)]); + else assert.deepEqual(color,[...PALETTE[{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]],255]); + } + } + } + assert.equal(unique.size,TREE_SEASONS.length); + for(const bad of ['Summer','../spring','constructor',null]) assert.throws(()=>treeAtlas(art,bad),/Unknown tree season/); + for(const season of TREE_SEASONS) assert.throws(()=>treeAtlas(new Map(),season),/requires/); +}); + +test('deprecated 23-species-mesh inventory stays frozen; all ten cube species have 34 compatibility atlases',()=> { + const species=['oak','birch','pine','willow','alder']; + const meshes=species.flatMap(s=>[`${s}_seedling`,`${s}_young`,`${s}_mature`, + ...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]); + const atlases=SPECIES_NAMES.flatMap(s=>[`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(n=>`${s}_atlas_${n}`))]); + assert.deepEqual(LEGACY_MESH_SPECIES,species); + assert.deepEqual(SPECIES_MESH_NAMES,meshes); assert.deepEqual(SPECIES_ATLAS_NAMES,atlases); + assert.equal(meshes.length,23); assert.equal(atlases.length,34); assert.equal(FLORA_NAMES.length,29); + for(const s of SPECIES_NAMES.slice(5)) for(const stage of ['seedling','young','mature','young_winter','mature_winter']) + assert.throws(()=>floraOBJ(`${s}_${stage}`),/Unknown flora/,'new species must not grow unused irregular meshes'); + for(const s of species) assert.ok(!FLORA_NAMES.includes(`${s}_deadwood`)); + for(const bad of ['pine_mature_winter','oak_seedling_winter','oak_mature_spring','../oak_mature']) + assert.throws(()=>floraOBJ(bad),/Unknown flora/); +}); + +const points=(mesh,group)=>[...new Set(group.faces.flat().map(p=>p[0]))].sort((a,b)=>a-b).map(i=>mesh.v[i-1]); +const extents=ps=>[0,1,2].map(i=>Math.max(...ps.map(p=>p[i]))-Math.min(...ps.map(p=>p[i]))); +test('species architectures convey broad oak, airy slim birch, tapered pine whorls, drooping willow and rounded alder within clearance',()=> { + const meshes=Object.fromEntries(LEGACY_MESH_SPECIES.map(s=>[s,parse(floraOBJ(`${s}_mature`))])); + for(const s of LEGACY_MESH_SPECIES) for(const stage of ['seedling','young','mature']) { + const {min,max}=floraBounds(`${s}_${stage}`), half=stage==='mature'?1.5:stage==='young'?.75:.3; + assert.equal(min[1],-.5); + assert.ok(max[1]-min[1]<=(stage==='mature'?5:stage==='young'?2.5:1)); + for(const i of [0,2]) assert.ok(min[i]>=-half && max[i]<=half,`${s}_${stage}: footprint`); + const mesh=parse(floraOBJ(`${s}_${stage}`)); + assert.ok(mesh.groups.some(g=>/^(stem|trunk)$/.test(g.name))); + assert.ok(mesh.groups.filter(g=>/^(leaf|foliage)/.test(g.name)).length>=2); + } + const oak=extents(meshes.oak.v), birch=extents(meshes.birch.v), alder=extents(meshes.alder.v); + assert.ok(oak[0]>birch[0]*1.5,'spreading oak versus slim birch'); + assert.ok(birch[1]/birch[0]>2.5,'birch is tall and narrow'); + assert.ok(oak[0]>alder[0] && alder[1]extents(points(meshes[s],meshes[s].groups.find(g=>g.name==='trunk')))[0]; + assert.ok(trunkWidth('birch')g.name===`foliage_tier_${i}_0`), upper=pine.groups.find(g=>g.name===`foliage_tier_${i+1}_0`); + assert.ok(extents(points(pine,lower))[0]>extents(points(pine,upper))[0],'evergreen whorls taper upward'); + } + assert.ok(pine.groups.some(g=>g.name==='foliage_leader')); + const willow=meshes.willow, curtains=willow.groups.filter(g=>g.name.startsWith('foliage_curtain_')); + assert.equal(curtains.length,6); + for(const g of curtains) { + const e=extents(points(willow,g)); assert.ok(e[1]>e[0]*3 && e[1]>e[2]*3,'long pendant leaf curtains'); + } + const bough=willow.groups.find(g=>g.name==='pendant_0'), ps=points(willow,bough); + const a=ps.slice(0,8).reduce((n,p)=>n+p[1],0)/8, b=ps.slice(8).reduce((n,p)=>n+p[1],0)/8; + assert.ok(a>b+10,'willow terminal wood droops, not only its painted leaves'); +}); + +test('deciduous winter species preserve all woody parts, omit every crown and sample bark only; pine stays evergreen',()=> { + const geometry=(m,g)=>g.faces.map(f=>f.map(([v,t,n])=>[m.v[v-1],m.vt[t-1],m.vn[n-1]])); + for(const s of LEGACY_MESH_SPECIES.filter(s=>s!=='pine')) for(const stage of ['young','mature']) { + const leafy=parse(floraOBJ(`${s}_${stage}`)), bare=parse(floraOBJ(`${s}_${stage}_winter`)); + const woody=leafy.groups.filter(g=>!g.name.startsWith('foliage')); + assert.deepEqual(bare.groups.map(g=>g.name),woody.map(g=>g.name),'all forks and pendants retained'); + for(const g of woody) assert.deepEqual(geometry(bare,bare.groups.find(b=>b.name===g.name)),geometry(leafy,g)); + assert.ok(bare.vt.every(([u])=>u<=31.5/64)); + assert.ok(bare.groups.flatMap(g=>g.faces).lengthg.faces).length); + const a=floraBounds(`${s}_${stage}`), b=floraBounds(`${s}_${stage}_winter`); + for(let i=0;i<3;i++) assert.ok(b.min[i]>=a.min[i] && b.max[i]<=a.max[i]); + } + assert.ok(parse(floraOBJ('pine_mature')).groups.some(g=>g.name.startsWith('foliage'))); + assert.throws(()=>treeAtlas(worldArt(),'winter','pine'),/evergreen/); +}); + +test('all species atlases use exact own bark and own seasonal leaves, opaque underpaint and half-texel inset UVs',()=> { + const art=worldArt(), distinct=new Set(); + for(const s of SPECIES_NAMES) { + const summer=treeAtlas(art,'summer',s), seasons=EVERGREEN_NAMES.includes(s)?['summer']:TREE_SEASONS; + for(const season of seasons) { + const image=treeAtlas(art,season,s), bark=art.get(`hearth_${s}_log_side.png`), + leaf=art.get(`hearth_${s}_leaves${season==='summer'?'':`_${season}`}.png`); + assert.deepEqual(image,treeAtlas(art,season,s)); + assert.deepEqual([image.width,image.height,image.data.length],[64,32,8192]); + distinct.add(Buffer.from(image.data).toString('hex')); + for(let y=0;y<32;y++) for(let x=0;x<32;x++) { + const from=(y*32+x)*4, left=(y*64+x)*4, right=left+128; + assert.deepEqual(image.data.slice(left,left+4),bark.data.slice(from,from+4)); + assert.deepEqual(image.data.slice(left,left+4),summer.data.slice(left,left+4)); + assert.equal(image.data[right+3],255); + if(leaf.data[from+3]) assert.deepEqual(image.data.slice(right,right+4),leaf.data.slice(from,from+4)); + else { + const color=season==='summer'?{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark', + beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[s]: + {spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]; + assert.deepEqual([...image.data.slice(right,right+4)],[...PALETTE[color],255]); + } + } + } + } + assert.equal(distinct.size,34); + for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter']) + assert.throws(()=>treeAtlas(art,season,s),/evergreen/); + for(const bad of ['constructor','../oak','maple','']) assert.throws(()=>treeAtlas(art,'summer',bad),/Unknown tree species/); +}); + +test('minimal fixture generation and integrity-verified package include every seasonal texture, atlas and model; linked outputs refused',async t=> { + const root=await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()),'hearth-flora-')); + t.after(()=>fs.rm(root,{recursive:true,force:true})); + const source=path.join(root,'source'), out=path.join(root,'out'); + await fs.mkdir(path.join(source,'mods/hearth'),{recursive:true}); + for(const name of ['init','model','runtime','content','encounters']) + await fs.writeFile(path.join(source,`mods/hearth/${name}.lua`),'-- minimal fixture\n'); + await generateAssets(out,source); + async function check(destination) { + for(const name of FLORA_NAMES) assert.equal(await fs.readFile(path.join(destination,`mods/hearth/models/hearth_${name}.obj`),'utf8'),floraOBJ(name)); + const art=worldArt(); + for(const season of TREE_SEASONS) { + const atlas=treeAtlas(art,season), expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32); + assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_tree_atlas${season==='summer'?'':`_${season}`}.png`)),expected); + } + for(const name of SPECIES_ATLAS_NAMES) { + const [s,,season='summer']=name.split('_'), atlas=treeAtlas(art,season,s); + const expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32); + assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_${name}.png`)),expected); + } + for(const [name,image] of art) assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures',name)), + png(32,(x,y)=>image.data.subarray((y*32+x)*4,(y*32+x+1)*4))); + const expectedTextures=[...art.keys(),...TREE_SEASONS.map(s=>`hearth_tree_atlas${s==='summer'?'':`_${s}`}.png`), + ...SPECIES_ATLAS_NAMES.map(n=>`hearth_${n}.png`), + ...['knife','axe','pick','spear'].map(n=>`hearth_icon_${n}.png`), + ...CHARACTER_ART_MANIFEST.map(entry=>entry.filename), + 'hearth_character_contact_sheet.png', + 'hearth_person.png','crosshair.png','hearth_art_contact_sheet.png','hearth_c7aa83.png','hearth_947853.png']; + assert.equal(expectedTextures.length,578,'includes all 170 character layers and their review sheet'); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(destination,'botany-art.json'),'utf8')), + {format:1,images:BOTANY_ART_MANIFEST},'all 160 botanical image identities are packaged'); + assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/textures'))).sort(),expectedTextures.sort()); + const expectedModels=[...FLORA_NAMES,...['hare','fox','deer','knife','axe','pick','spear','person']] + .map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d'); + assert.equal(expectedModels.length,40,'37 OBJ files plus three animated player builds'); + assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/models'))).sort(),expectedModels.sort()); + } + await check(out); + for(const name of ['game.conf','minetest.conf','settingtypes.txt','LICENSE.txt']) await fs.writeFile(path.join(source,name),'fixture\n'); + await fs.writeFile(path.join(source,'release.json'),JSON.stringify({format:1,gameId:'last_hearth',version:'0.4.0-m3', + engine:{name:'Luanti',version:'5.18.0'},node:'>=22',packageFormat:1,backupFormat:1})); + await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true}); + await fs.writeFile(path.join(source,'tests/fixtures/scenarios.json'),'{}'); + const packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged); + const outside=path.join(root,'outside'); await fs.mkdir(outside); + await fs.rm(path.join(out,'mods/hearth/models'),{recursive:true}); + await fs.symlink(outside,path.join(out,'mods/hearth/models'),process.platform==='win32'?'junction':'dir'); + await assert.rejects(generateAssets(out,source),/link|junction|unsafe/i); + assert.deepEqual(await fs.readdir(outside),[]); +}); + +test('read-only cube diversity and biome contracts cover ten exact species; review layout is documented',async()=> { + const source=await fs.readFile(new URL('../mods/hearth/diversity.lua',import.meta.url),'utf8'); + const species=source.match(/species_order=\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1)); + assert.deepEqual(species,SPECIES_NAMES); + assert.match(source,/drawtype=woody and "normal" or "plantlike"/); + assert.match(source,/tiles=woody and \{"hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_side.png"\}/); + assert.ok(!source.includes('.obj') && !source.includes('_atlas'),'no runtime dependency on deprecated irregular tree meshes'); + assert.match(source,/evergreen=s=="pine" or s=="spruce"/); + assert.match(source,/plant~="moss" and plant~="lichen"/,'persistent groundcover has no dormant alias'); + const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8'); + const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1)); + assert.deepEqual(biomeSpecies,SPECIES_NAMES); + const art=worldArt(); + for(const s of species) for(const face of ['log_top','log_side','leaves','planks']) + assert.ok(art.has(`hearth_${s}_${face}.png`)); + const docs=await fs.readFile(new URL('../docs/art-direction.md',import.meta.url),'utf8'); + const current=docs.slice(0,docs.indexOf('
')); + assert.match(current,/512×736/); assert.match(current,/1–512/); assert.match(current,/16 columns/); + assert.match(current,/ceil\(count\/16\)\*32/); + assert.match(current,/360/); assert.match(current,/160/); assert.match(current,/botanicalLatin/); + for(const s of SPECIES_NAMES) assert.ok(current.includes(s)); +}); + +test('actual seasonal content uses exact generated stems, explicit cube tiles and separate patch/craftitem naming',async()=> { + const text=await fs.readFile(new URL('../mods/hearth/content.lua',import.meta.url),'utf8'); + const loop=text.slice(text.indexOf('for _, plant in ipairs({'),text.indexOf('-- Cosmetic migration')); + assert.match(loop,/drawtype = woody and "normal" or "plantlike"/); + assert.match(loop,/visual_scale = woody and 1 or scale/); + assert.match(loop,/walkable = woody/); + assert.match(loop,/buildable_to = not woody/); + assert.match(loop,/tiles = woody and \{world_image\("log_top"\),world_image\("log_top"\),world_image\("log_side"\)\}/); + assert.ok(!loop.includes('.obj'),'retained OBJ exports are not the runtime tree contract'); + assert.match(text,/def\.tiles = def\.tiles or/,'registrar preserves explicit mesh tiles without override_item'); + for(const name of ['meadow_grass','wildflowers','fern']) assert.ok(loop.includes(`{"${name}"`)); + const seasons=await fs.readFile(new URL('../mods/hearth/seasons.lua',import.meta.url),'utf8'); + const docs=await fs.readFile(new URL('../docs/ecosystems.md',import.meta.url),'utf8'); + const art=worldArt(); + for(const stem of ['meadow_grass_dormant','fern_dormant','wildflowers_dormant', + 'spring_greens','spring_greens_dormant','autumn_mushrooms','autumn_mushrooms_dormant', + 'item_spring_greens','item_autumn_mushrooms']) { + assert.ok(art.has(`hearth_${stem}.png`)); assert.ok(docs.includes(`hearth_${stem}.png`)); + } + for(const kind of ['tree_seedling','young_tree','mature_tree','leaves']) for(const season of ['spring','autumn','winter']) { + const mesh=kind==='leaves'?null:`${kind}${season==='winter' && kind!=='tree_seedling'?'_winter':''}`; + if(mesh) assert.ok(FLORA_NAMES.includes(mesh)); + if(kind==='leaves') assert.ok(art.has(`hearth_leaves_${season}.png`)); + else assert.ok(TREE_SEASONS.includes(season)); + } + // Seasonal runtime cube-copy semantics are tested by the seasons owner; this + // suite verifies available bytes, not a stale assertion requiring OBJ usage. + for(const kind of ['spring_greens','autumn_mushrooms']) { + assert.ok(text.includes(`{"${kind}",`)); + assert.ok(seasons.includes(`variant("${kind}_patch",`)); + assert.ok(text.includes(`api.register_craftitem("hearth:${kind}",`)); + assert.ok(!art.has(`hearth_${kind}_patch.png`),'patch node name is not a texture stem'); + } +}); diff --git a/tests/init_spec.lua b/tests/init_spec.lua new file mode 100755 index 0000000..73d3557 --- /dev/null +++ b/tests/init_spec.lua @@ -0,0 +1,122 @@ +-- Isolated tests for real claim commands, persistence, and wrapper protection. +local target = assert(arg[1], "init.lua path required") +local saved = {claims = {}, homes = {}, access = {}} +local writes, commands, particles = {}, {}, {} +local players = {} +local function copy(value) + if type(value) ~= "table" then return value end + local result = {}; for k, v in pairs(value) do result[k] = copy(v) end; return result +end +for _, name in ipairs({"owner", "housemate", "keeper", "outsider", "another-owner"}) do + local player = {name = name, pos = {x = 0, y = 10, z = 0}} + function player:get_player_name() return self.name end + function player:get_pos() return self.pos end + players[name] = player +end +core = { + registered_nodes = {}, + get_modpath = function() return "/hearth" end, + get_mod_storage = function() return {} end, + get_player_by_name = function(name) return players[name] end, + register_chatcommand = function(name, def) commands[name] = def end, + is_protected = function() return false end, + record_protection_violation = function() end, + chat_send_player = function() end, + add_particle = function(spec) particles[#particles + 1] = spec end, +} +vector = {round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end} +local persistence = { + load_world = function(kind) return copy(saved[kind]) end, + save_world = function(kind, value) saved[kind] = copy(value); writes[#writes + 1] = kind end, +} +local original_dofile = dofile +local loaded, character_marker = {}, {} +dofile = function(path) + local name = path:match("([^/\\]+%.lua)$") + loaded[#loaded + 1] = name + if name == "character.lua" then return function(h, api) + assert(h == hearth and api == core); return character_marker + end end + if name == "interface.lua" or name == "players.lua" then + assert(hearth.character == character_marker, "Character factory must precede UI and players") + end + if name == "model.lua" then return {location = function() return true end} end + if name == "schema.lua" then return {} end + if name == "persistence.lua" then return function() return persistence end end + if name == "access.lua" then + local source = path:match("[/\\]init%.lua$") and path:gsub("init%.lua$", "access.lua") or "/hearth/access.lua" + return original_dofile(source) + end + if name == "movement.lua" or name == "combat.lua" then return {} end + return true -- Remaining bootstrap modules are intentionally inert in this suite. +end + +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)) + total = total + 1 + print("ok " .. total .. " - " .. name) +end +local function command(param, name) + return commands.claim.func(name or "owner", param or "") +end +original_dofile(target) + +test("character factory loads once before interface and player initialization", function() + local positions = {} + for i, name in ipairs(loaded) do + assert(not positions[name], "Duplicate module load: " .. name); positions[name] = i + end + assert(hearth.character == character_marker) + assert(positions["character.lua"] < positions["interface.lua"] and positions["character.lua"] < positions["players.lua"]) + assert(positions["presentation.lua"] < positions["players.lua"]) +end) +test("claim creation, boundaries, and world persistence are owner-scoped", function() + local ok = command("") + assert(ok and saved.claims.owner.x == 0 and saved.claims.owner.y == 10) + assert(hearth.claim_role_at({x = 0, y = 10, z = 0}, "owner") == "owner") + assert(hearth.claim_allowed({x = 12, y = 34, z = 12}, "owner", "build")) + assert(not hearth.claim_allowed({x = 12, y = 10, z = 0}, "outsider", "build")) + local shown, message = command("show") + assert(shown and message:find("X %-12%.%.12") and #particles >= 40) + local listed = command("list") + assert(listed and #writes == 1) +end) +test("resident and storekeeper roles are least-privilege and immediately persisted", function() + assert(command("share housemate resident")) + assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "build")) + assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "dig")) + assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "use")) + assert(not hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "combat")) + assert(command("share keeper storekeeper")) + assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "keeper", "storage")) + for _, action in ipairs({"build", "dig", "use", "combat"}) do + assert(not hearth.claim_allowed({x = 0, y = 10, z = 0}, "keeper", action), action) + end + local ok, members = command("list") + assert(ok and members:find("housemate %(resident%)") and members:find("keeper %(storekeeper%)")) +end) +test("invalid grants, outsider mutations, and revoked permissions fail closed", function() + players["another-owner"].pos = {x = 100, y = 10, z = 0} + assert(command("", "another-owner")) + for _, param in ipairs({"share bad/name resident", "share outsider admin", "share owner resident", + "share another-owner resident", "unshare bad/name"}) do + assert(not command(param), param) + end + assert(not command("share outsider resident", "outsider")) + assert(command("unshare housemate")) + assert(not hearth.claim_allowed({x = 0, y = 10, z = 0}, "housemate", "build")) + assert(not command("share " .. string.rep("a", 33) .. " resident")) + for i = 1, 15 do assert(command("share crew" .. i .. " resident")) end + assert(not command("share overflow resident")) +end) +test("claim release revokes grants and removes the owner's saved home", function() + assert(hearth.in_own_claim({x = 0, y = 10, z = 0}, "owner")) + hearth.store_home("owner", {x = 0, y = 10, z = 0}) + assert(hearth.home_for("owner")) + assert(command("remove")) + assert(not saved.claims.owner and not saved.access.owner and not hearth.home_for("owner")) + assert(hearth.claim_allowed({x = 0, y = 10, z = 0}, "outsider", "build")) +end) +print("PASS " .. total .. " claim service tests") diff --git a/tests/m2_processes_spec.lua b/tests/m2_processes_spec.lua new file mode 100755 index 0000000..5081345 --- /dev/null +++ b/tests/m2_processes_spec.lua @@ -0,0 +1,581 @@ +-- Isolated M2 hearth process tests; pass mods/hearth/processes.lua as argument. +assert(arg and arg[1], "Pass the path to mods/hearth/processes.lua") +local now, game_now = 100000, 500 +local protected, claimed, distant, violations = false, true, false, 0 +local world, metadata, timers, sounds = {}, {}, {}, {} +local function key(pos) return pos.x .. ":" .. pos.y .. ":" .. pos.z end +local hearth_pos = {x = 0, y = 0, z = 0} + +local function stack(text) + local name, count = (text or ""):match("^(%S+)%s*(%d*)$") + return setmetatable({name = name or "", count = name and (tonumber(count) or 1) or 0, + wear = 0}, { + __index = { + get_name = function(self) return self.name end, + get_count = function(self) return self.count end, + is_empty = function(self) return self.count == 0 end, + take_item = function(self, amount) + self.count = math.max(0, self.count - (amount or 1)) + if self.count == 0 then self.name = "" end + end, + add_wear = function(self, amount) + self.wear = self.wear + amount + if self.wear >= 65535 then self.name, self.count = "", 0 end + end, + }, + }) +end +ItemStack = stack + +local function meta_for(pos) + local k = key(pos) + if not metadata[k] then + local fields = {} + metadata[k] = { + set_string = function(_, name, value) fields[name] = tostring(value) end, + get_string = function(_, name) return fields[name] or "" end, + set_int = function(_, name, value) fields[name] = math.floor(value) end, + get_int = function(_, name) return tonumber(fields[name]) or 0 end, + } + end + return metadata[k] +end + +local api = {} +function api.get_meta(pos) return meta_for(pos) end +function api.get_node(pos) return world[key(pos)] or {name = "air"} end +function api.swap_node(pos, node) world[key(pos)] = node end +function api.get_gametime() return game_now end +function api.get_node_timer(pos) + local k = key(pos) + if not timers[k] then + timers[k] = { + start = function(self, timeout) self.timeout = timeout end, + stop = function(self) self.timeout = nil end, + } + end + return timers[k] +end +function api.sound_play(name, params) sounds[#sounds + 1] = {name = name, params = params} end + +local player +local function allowed(pos, who) + local dx = pos.x - who.position.x + local dy = pos.y - who.position.y + local dz = pos.z - who.position.z + if protected or not claimed or distant or dx * dx + dy * dy + dz * dz > 36 then + violations = violations + 1 + return false + end + return true +end +local construction = {allowed = allowed} +local hearth = {} +function hearth.notify(_, message) hearth.last_notice = message end +local processes = dofile(arg[1])(hearth, api, construction) + +local inventory = {room = true, added = {}, reject_next = false} +function inventory:room_for_item(_, item) return self.room and not item:is_empty() end +function inventory:add_item(_, item) + if self.reject_next then + self.reject_next = false + return stack(item:get_name()) + end + self.added[#self.added + 1] = item:get_name() + return stack("") +end +player = {position = {x = 0, y = 0, z = 0}} +function player:get_inventory() return inventory end + +local real_time = os.time +os.time = function() return now end +local function eq(actual, expected) + assert(actual == expected, "expected " .. tostring(expected) .. ", got " .. tostring(actual)) +end +local passed = 0 +local function reset() + now, game_now = 100000, 500 + protected, claimed, distant, violations = false, true, false, 0 + world, metadata, timers, sounds = {}, {}, {}, {} + inventory.room, inventory.added, inventory.reject_next = true, {}, false + player.position = {x = 0, y = 0, z = 0} + hearth.last_notice = nil + world[key(hearth_pos)] = {name = "hearth:fire", param2 = 2} + local meta = api.get_meta(hearth_pos) + meta:set_int("lit", 0) + meta:set_string("fuel_until", "0") +end +local function test(name, fn) + reset() + local ok, err = pcall(fn) + if not ok then error(name .. ": " .. tostring(err), 0) end + passed = passed + 1 + print("ok " .. passed .. " - " .. name) +end +local function use(text) + local item = stack(text) + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, item) + return item +end +local function starter(item, pos) + return processes.use_firestarter(item, player, {type = "node", under = pos or hearth_pos}) +end +local function deadline(pos) + return tonumber(api.get_meta(pos or hearth_pos):get_string("fuel_until")) or 0 +end +local function advance(seconds) + now, game_now = now + seconds, game_now + seconds + local timer = api.get_node_timer(hearth_pos) + if timer.timeout then + timer.timeout = timer.timeout - seconds + if timer.timeout <= 0 then processes.fire_tick(hearth_pos) end + end +end + +test("fuel does not ignite itself and a firestarter is reusable", function() + local firestarter = stack("hearth:firestarter") + starter(firestarter) + eq(firestarter.wear, 0) + eq(api.get_node(hearth_pos).name, "hearth:fire") + eq(use("hearth:sticks"):get_count(), 0) + eq(api.get_node(hearth_pos).name, "hearth:fire") + eq(api.get_meta(hearth_pos):get_int("lit"), 0) + starter(firestarter) + eq(api.get_node(hearth_pos).name, "hearth:fire_lit") + eq(firestarter.wear, math.ceil(65535 / 20)) + local second = {x = 1, y = 0, z = 0} + world[key(second)] = {name = "hearth:fire", param2 = 0} + api.get_meta(second):set_int("lit", 0) + api.get_meta(second):set_string("fuel_until", "0") + processes.use_fire(second, api.get_node(second), player, stack("hearth:sticks")) + starter(firestarter, second) + eq(firestarter.wear, math.ceil(65535 / 20) * 2) + for _ = 3, 20 do + local meta = api.get_meta(hearth_pos) + meta:set_int("lit", 0) + processes.refresh_fire(hearth_pos, processes.fuel_left(hearth_pos, now), now) + starter(firestarter) + end + eq(firestarter.wear, math.ceil(65535 / 20) * 20) + eq(firestarter:is_empty(), true) + eq(#sounds, 20) + eq(sounds[1].name, "hearth_fire") +end) + +test("cooking waits six real seconds and reserves the heat cost at start", function() + use("hearth:sticks") + starter(stack("hearth:firestarter")) + local input = use("hearth:raw_food 2") + eq(input:get_count(), 1) + eq(api.get_meta(hearth_pos):get_int("job_until"), now + 6) + eq(api.get_meta(hearth_pos):get_int("job_cost"), 20) + eq(deadline(), now + 25) + eq(api.get_node_timer(hearth_pos).timeout, 6) + advance(5) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "cook") + eq(deadline(), now + 20) + advance(1) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "") + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") + eq(deadline(), now + 19) + processes.fire_tick(hearth_pos) + eq(deadline(), now + 19) +end) + +test("boiling waits four gameplay seconds and charges its own fuel cost", function() + use("hearth:sticks") + starter(stack("hearth:firestarter")) + use("hearth:water_raw") + eq(api.get_meta(hearth_pos):get_int("job_until"), now + 4) + eq(api.get_meta(hearth_pos):get_int("job_cost"), 10) + advance(3) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "boil") + eq(deadline(), now + 32) + advance(1) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean") + eq(deadline(), now + 31) +end) + +test("process start requires the complete duration-plus-cost fuel reserve", function() + local meta = api.get_meta(hearth_pos) + meta:set_int("lit", 1) + meta:set_string("fuel_until", tostring(now + 25)) + local too_little = use("hearth:raw_food") + eq(too_little:get_count(), 1) + eq(meta:get_string("job_kind"), "") + meta:set_string("fuel_until", tostring(now + 27)) + local enough = use("hearth:raw_food") + eq(enough:get_count(), 0) + eq(meta:get_string("job_kind"), "cook") + advance(6) + eq(deadline(), now + 1) + eq(api.get_node(hearth_pos).name, "hearth:fire_lit") +end) + +test("inventory rejection preserves completed output until a successful claim", function() + use("hearth:sticks") + starter(stack("hearth:firestarter")) + use("hearth:raw_food") + advance(6) + inventory.room = false + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") + inventory.room, inventory.reject_next = true, true + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") + eq(#inventory.added, 0) + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) + eq(api.get_meta(hearth_pos):get_string("job_result"), "") + eq(#inventory.added, 1) + eq(inventory.added[1], "hearth:cooked_food") +end) + +test("a queued job and pending output block replacement or a second queue", function() + use("hearth:sticks") + starter(stack("hearth:firestarter")) + use("hearth:raw_food") + eq(use("hearth:water_raw"):get_count(), 1) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "cook") + advance(6) + eq(use("hearth:water_raw"):get_count(), 1) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") +end) + +test("load reconciliation resumes remaining time and completes overdue jobs once", function() + use("hearth:sticks") + starter(stack("hearth:firestarter")) + use("hearth:water_raw") + game_now, now = game_now + 2, now + 2 -- Simulate shutdown without timer callbacks. + processes.fire_tick(hearth_pos) -- The registered LBM callback. + eq(api.get_node_timer(hearth_pos).timeout, 2) + advance(2) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean") + local charged = deadline() + game_now, now = game_now + 20, now + 20 + processes.fire_tick(hearth_pos) + processes.fire_tick(hearth_pos) + eq(deadline(), charged) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:water_clean") +end) + +test("charcoal sustains M4 iron-smelting charges and produces one ingot", function() + use("hearth:charcoal") + starter(stack("hearth:firestarter")) + local charge = use("hearth:smelt_charge") + eq(charge:get_count(), 0) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "smelt") + eq(deadline(), now + 60) + advance(60) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:iron_ingot") + use("") + eq(inventory.added[1], "hearth:iron_ingot") + eq(api.get_meta(hearth_pos):get_string("job_result"), "") +end) + +for _, species in ipairs({"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"}) do + test(species .. " logs fuel directly for 180 seconds without ignition or overflow", function() + eq(use("hearth:" .. species .. "_log 2"):get_count(), 1) + eq(deadline(), now + 180) + eq(api.get_meta(hearth_pos):get_int("lit"), 0) + use("hearth:" .. species .. "_log") + use("hearth:" .. species .. "_log") + eq(deadline(), now + 540) + eq(use("hearth:" .. species .. "_log"):get_count(), 1) + eq(deadline(), now + 540) + end) +end + +test("coal adds 240 seconds and rejects overflow without consuming an item", function() + eq(use("hearth:coal 3"):get_count(), 2) + eq(deadline(), now + 240) + eq(api.get_meta(hearth_pos):get_int("lit"), 0) + use("hearth:coal") + eq(deadline(), now + 480) + eq(use("hearth:coal"):get_count(), 1) + eq(deadline(), now + 480) + advance(120) + eq(use("hearth:coal"):get_count(), 0) + eq(processes.fuel_left(hearth_pos, now), 600) + eq(use("hearth:sticks"):get_count(), 1) +end) + +test("coal fuels copper smelting for 45 seconds with 30 reserved and one ingot", function() + use("hearth:coal") + starter(stack("hearth:firestarter")) + eq(use("hearth:copper_charge 2"):get_count(), 1) + local meta = api.get_meta(hearth_pos) + eq(meta:get_string("job_kind"), "smelt_copper") + eq(meta:get_int("job_until"), now + 45) + eq(meta:get_int("job_cost"), 30) + eq(deadline(), now + 210) + eq(api.get_node_timer(hearth_pos).timeout, 45) + assert(meta:get_string("infotext"):find("smelting copper", 1, true)) + advance(44) + eq(meta:get_string("job_kind"), "smelt_copper") + eq(use(""):get_count(), 0) + eq(#inventory.added, 0) + advance(1) + eq(meta:get_string("job_kind"), "") + eq(meta:get_string("job_result"), "hearth:copper_ingot") + eq(processes.fuel_left(hearth_pos, now), 165) + use(""); use(""); processes.fire_tick(hearth_pos) + eq(#inventory.added, 1) + eq(inventory.added[1], "hearth:copper_ingot") + eq(meta:get_string("job_result"), "") +end) + +test("copper needs ignition and exactly duration plus cost before consuming its charge", function() + use("hearth:coal") + eq(use("hearth:copper_charge"):get_count(), 1) + starter(stack("hearth:firestarter")) + local meta = api.get_meta(hearth_pos) + meta:set_string("fuel_until", tostring(now + 74)) + eq(use("hearth:copper_charge"):get_count(), 1) + eq(meta:get_string("job_kind"), "") + eq(deadline(), now + 74) + meta:set_string("fuel_until", tostring(now + 75)) + eq(use("hearth:copper_charge"):get_count(), 0) + eq(deadline(), now + 45) + advance(45) + eq(meta:get_string("job_result"), "hearth:copper_ingot") + eq(processes.fuel_left(hearth_pos, now), 0) +end) + +test("copper reload resumes then completes long-offline output idempotently", function() + use("hearth:pine_log") + starter(stack("hearth:firestarter")) + use("hearth:copper_charge") + local charged = deadline() + now = now + 20 -- No timer callback while unloaded. + local reloaded = dofile(arg[1])(hearth, api, construction) + reloaded.fire_tick(hearth_pos) + eq(api.get_node_timer(hearth_pos).timeout, 25) + eq(deadline(), charged) + now = now + 86400 * 30 + for _ = 1, 3 do reloaded.fire_tick(hearth_pos) end + local meta = api.get_meta(hearth_pos) + eq(meta:get_string("job_result"), "hearth:copper_ingot") + eq(meta:get_int("job_cost"), 0) + eq(meta:get_string("job_kind"), "") + eq(api.get_node(hearth_pos).name, "hearth:fire") + eq(use("hearth:copper_charge"):get_count(), 1) -- Pending output cannot be overwritten. + eq(use("hearth:smelt_charge"):get_count(), 1) + use("") + reloaded.fire_tick(hearth_pos); use("") + eq(#inventory.added, 1) + eq(inventory.added[1], "hearth:copper_ingot") +end) + +test("full inventory and insertion rejection retain copper until exactly one collection", function() + use("hearth:coal"); starter(stack("hearth:firestarter")); use("hearth:copper_charge") + eq(use("hearth:copper_charge"):get_count(), 1) + eq(use("hearth:smelt_charge"):get_count(), 1) + advance(45) + inventory.room = false + use("") + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot") + inventory.room, inventory.reject_next = true, true + use("") + eq(#inventory.added, 0) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot") + use(""); use("") + eq(#inventory.added, 1) + eq(inventory.added[1], "hearth:copper_ingot") +end) + +test("authorization blocks new fuels copper queue and copper output before mutation", function() + local function deny(mode) + protected, claimed, distant = mode == "protected", mode ~= "claim", mode == "distance" + end + for _, mode in ipairs({"protected", "claim", "distance"}) do + deny(mode) + for _, fuel in ipairs({"coal", "oak_log", "birch_log", "pine_log", "willow_log", "alder_log", + "beech_log", "aspen_log", "rowan_log", "hazel_log", "spruce_log"}) do + eq(use("hearth:" .. fuel):get_count(), 1) + end + eq(deadline(), 0) + eq(starter(stack("hearth:firestarter")).wear, 0) + end + protected, claimed, distant = false, true, false + use("hearth:coal"); starter(stack("hearth:firestarter")) + local fuel_deadline = deadline() + for _, mode in ipairs({"protected", "claim", "distance"}) do + deny(mode) + eq(use("hearth:copper_charge"):get_count(), 1) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "") + eq(deadline(), fuel_deadline) + end + protected, claimed, distant = false, true, false + use("hearth:copper_charge"); advance(45) + for _, mode in ipairs({"protected", "claim", "distance"}) do + deny(mode); use("") + eq(#inventory.added, 0) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:copper_ingot") + end + protected, claimed, distant = false, true, false + use("") + eq(#inventory.added, 1) +end) + +test("raw minerals planks and unknown species cannot act as fuel or charges", function() + use("hearth:coal"); starter(stack("hearth:firestarter")) + local fuel_deadline = deadline() + for _, item in ipairs({"copper_ore", "quartz", "copper_ingot", "oak_planks", "beech_planks", "aspen_planks", + "rowan_planks", "hazel_planks", "spruce_planks", "wall", "frame", "roof", "door", "storage", + "deadwood", "spruce_mature", "beech_leaves", "granite_bricks", "unknown_log", "group:tree"}) do + eq(use("hearth:" .. item):get_count(), 1) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "") + eq(deadline(), fuel_deadline) + end +end) + +test("copper result cannot validate a forged iron job or invalid cost", function() + local meta = api.get_meta(hearth_pos) + for _, kind in ipairs({"smelt", "smelt_copper"}) do + meta:set_string("job_kind", kind) + meta:set_string("job_until", tostring(now - 1)) + meta:set_int("job_cost", kind == "smelt" and 30 or 29) + meta:set_string("job_result", "hearth:copper_ingot") + processes.fire_tick(hearth_pos); use("") + eq(meta:get_string("job_result"), "") + eq(#inventory.added, 0) + end +end) + +test("drying charges preserve food with bounded offline completion", function() + for _ = 1, 4 do use("hearth:charcoal") end + starter(stack("hearth:firestarter")) + local charge = use("hearth:dried_food_charge") + eq(charge:get_count(), 0) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "dry") + game_now, now = game_now + 300, now + 300 -- Simulate absence; LBM/timer reconciliation catches up once. + processes.fire_tick(hearth_pos) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_food") + processes.fire_tick(hearth_pos) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_food") + use("") + eq(inventory.added[1], "hearth:dried_food") +end) + +test("pottery fires once and preserves its output across repeated reconciliation", function() + use("hearth:charcoal") + use("hearth:charcoal") + starter(stack("hearth:firestarter")) + local charge = use("hearth:clay_jar_charge") + eq(charge:get_count(), 0) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "fire") + eq(deadline(), now + 150) + game_now, now = game_now + 120, now + 120 + processes.fire_tick(hearth_pos) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:clay_jar") + processes.fire_tick(hearth_pos) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:clay_jar") + use("") + eq(inventory.added[1], "hearth:clay_jar") +end) + +test("one crafted charcoal preserves exactly one log of hearth fuel", function() + use("hearth:log") + starter(stack("hearth:firestarter")) + local charge = use("hearth:charcoal_charge") + eq(charge:get_count(), 0) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "char") + advance(60) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:charcoal") + use("") + eq(inventory.added[1], "hearth:charcoal") + eq(deadline(), now + 90) + use("hearth:charcoal") + eq(deadline(), now + 180) +end) + +test("raw river fish roasts once and its output is collected safely", function() + use("hearth:charcoal") + starter(stack("hearth:firestarter")) + local raw = use("hearth:raw_fish") + eq(raw:get_count(), 0) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "roast") + advance(8) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_fish") + use("") + eq(inventory.added[1], "hearth:cooked_fish") + processes.fire_tick(hearth_pos) + eq(inventory.added[1], "hearth:cooked_fish") +end) + +test("dried river fish completes offline and stays shelf-stable output", function() + use("hearth:log") + use("hearth:log") + starter(stack("hearth:firestarter")) + local charge = use("hearth:fish_drying_charge") + eq(charge:get_count(), 0) + eq(api.get_meta(hearth_pos):get_string("job_kind"), "dry_fish") + game_now, now = game_now + 300, now + 300 + processes.fire_tick(hearth_pos) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_fish") + processes.fire_tick(hearth_pos) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:dried_fish") + use("") + eq(inventory.added[1], "hearth:dried_fish") +end) + +test("invalid job metadata fails closed without producing or charging output", function() + local meta = api.get_meta(hearth_pos) + meta:set_int("lit", 1) + meta:set_string("fuel_until", tostring(now + 90)) + meta:set_string("job_kind", "cook") + meta:set_int("job_until", game_now + 4) + meta:set_int("job_cost", 999) + meta:set_string("job_result", "hearth:cooked_food") + processes.fire_tick(hearth_pos) + eq(meta:get_string("job_kind"), "") + eq(meta:get_string("job_result"), "") + eq(deadline(), now + 90) + processes.fire_tick(hearth_pos) + eq(meta:get_string("job_result"), "") + meta:set_string("job_result", "hearth:cooked_food") + meta:set_int("job_cost", 20) + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) + eq(#inventory.added, 0) + processes.fire_tick(hearth_pos) + eq(meta:get_string("job_result"), "") +end) + +test("protection, claim and six-block checks cover fuel, jobs, ignition and output", function() + protected = true + eq(use("hearth:sticks"):get_count(), 1) + eq(starter(stack("hearth:firestarter")).wear, 0) + eq(violations, 2) + protected, claimed = false, false + eq(use("hearth:sticks"):get_count(), 1) + eq(use("hearth:raw_food"):get_count(), 1) + claimed, distant = true, true + eq(starter(stack("hearth:firestarter")).wear, 0) + eq(violations, 5) + distant = false + use("hearth:sticks") + starter(stack("hearth:firestarter")) + use("hearth:raw_food") + advance(6) + protected = true + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) + eq(#inventory.added, 0) + eq(api.get_meta(hearth_pos):get_string("job_result"), "hearth:cooked_food") +end) + +test("destroyed hearth callbacks cannot recreate nodes or claim vanished output", function() + use("hearth:sticks") + starter(stack("hearth:firestarter")) + use("hearth:raw_food") + advance(6) + world[key(hearth_pos)] = {name = "air"} + metadata[key(hearth_pos)], timers[key(hearth_pos)] = nil, nil + local before = #inventory.added + processes.use_fire(hearth_pos, api.get_node(hearth_pos), player, stack("")) + eq(processes.fire_tick(hearth_pos), false) + eq(api.get_node(hearth_pos).name, "air") + eq(#inventory.added, before) +end) + +os.time = real_time +print("1.." .. passed) diff --git a/tests/m2_rest_spec.lua b/tests/m2_rest_spec.lua new file mode 100755 index 0000000..66c7c76 --- /dev/null +++ b/tests/m2_rest_spec.lua @@ -0,0 +1,118 @@ +-- M2 bedroll interaction and scheduler tests; isolated engine interface. +local target = assert(arg[1], "runtime.lua path required") +local total = 0 +local function test(name, fn) + local old_core, old_hearth, old_vector = core, hearth, vector + local ok, err = pcall(fn) + core, hearth, vector = old_core, old_hearth, old_vector + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +local function harness() + local h = {steps = {}, connected = {}, saved = 0, messages = {}, clock = 0, + nodes = {ground = {walkable = true}, air = {walkable = false}, + ["hearth:bedroll"] = {walkable = false, groups = {snappy = 3}}}} + core = { + settings = {get = function(_, k) if k == "mapgen_limit" then return "512" end end}, + registered_nodes = h.nodes, + get_node = function(p) return {name = p.x == 0 and p.y == 3 and p.z == 0 + and "hearth:bedroll" or (p.y < 4 and "ground" or "air")} end, + get_node_or_nil = function(p) return {name = p.x == 0 and p.y == 3 and p.z == 0 + and "hearth:bedroll" or (p.y < 4 and "ground" or "air")} end, + get_connected_players = function() return h.connected end, + register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end, + get_us_time = function() return h.clock * 1000000 end, + find_node_near = function() return nil end, + add_particlespawner = function() end, + } + vector = {offset = function(p, x, y, z) return {x=p.x+x,y=p.y+y,z=p.z+z} end} + hearth = {players = {}, sessions = {}, weather = "clear"} + hearth.movement = {delta = function(dt) return math.max(0, math.min(dt, .1)) end, + step = function(s, state, controls, dt) + s.sprinting = false; s.blocking = false + return {speed = 1, moving = false, grounded = true, swimming = false} + end} + hearth.clock_update = function() end + hearth.model = {needs = function(state) + local copy = {}; for k,v in pairs(state) do copy[k] = v end + return copy, 0 + end} + hearth.environment = function() + return {temperature=15,shelter=h.shelter or .75,heat=0,smoke=false,rain=h.rain or false,roof=true} + end + hearth.construction = {allowed = function(pos, p) + return not h.denied and p:get_player_name() == "resting-player" + and math.abs(pos.x-p:get_pos().x) <= 6 + end} + hearth.feedback = function(_, msg, kind) h.messages[#h.messages+1]={msg=msg,kind=kind} end + hearth.feedback_step = function() end + hearth.update_hud = function() end + hearth.player_save = function() h.saved=h.saved+1; return true end + hearth.safe_arrival = function() end + hearth.texture = function(c) return c end + function h.player() + local meta = {values={}} + function meta:set_string(k,v) self.values[k]=v end + function meta:set_int(k,v) self.values[k]=v end + function meta:get_int(k) return self.values[k] or 0 end + local p = {pos={x=0,y=4,z=0}, velocity={x=0,y=0,z=0}, hp=20, controls={}} + function p:is_player() return true end + function p:get_player_name() return "resting-player" end + function p:get_pos() return self.pos end + function p:get_velocity() return self.velocity end + function p:get_hp() return self.hp end + function p:get_player_control() return self.controls end + function p:get_wielded_item() return {get_name=function() return "" end} end + function p:get_meta() return meta end + function p:set_physics_override() end + function p:set_clouds() end + h.connected={p}; hearth.players[p:get_player_name()]={food=80,water=80,warmth=50,wet=0,stamina=20} + hearth.sessions[p:get_player_name()]={save=0,damage=0,waiting=false} + return p, meta + end + dofile(target) + function h.tick(dt) + h.clock=h.clock+dt + for _,step in ipairs(h.steps) do step(dt) end + end + return h +end + +test("rest requires a living joined player, permitted placed bedroll and roof", function() + local h=harness(); local p=h.player() + assert(hearth.start_rest(p,{x=0,y=3,z=0})) + hearth.sessions["resting-player"].resting=nil; h.denied=true + assert(not hearth.start_rest(p,{x=0,y=3,z=0})) + h.denied=false; h.shelter=0 + assert(not hearth.start_rest(p,{x=0,y=3,z=0})) + h.shelter=.5; h.rain=true + assert(not hearth.start_rest(p,{x=0,y=3,z=0})) +end) +test("five uninterrupted sheltered seconds grant warmth without directly changing stamina", function() + local h=harness(); local p=h.player(); local s=hearth.players[p:get_player_name()] + assert(hearth.start_rest(p,{x=0,y=3,z=0})) + for _=1,49 do h.tick(.1) end + assert(hearth.sessions[p:get_player_name()].resting and s.stamina==20) + h.tick(.1) + assert(not hearth.sessions[p:get_player_name()].resting) + for _=1,10 do h.tick(.1) end -- warmth is applied on the bounded needs tick + s = hearth.players[p:get_player_name()] + assert(s.stamina==20 and s.warmth==58) + assert(h.saved==1 and h.messages[#h.messages].kind=="success") +end) +test("movement, unsafe weather and damage interrupt without granting rest", function() + for _,mode in ipairs({"move","rain","damage"}) do + local h=harness(); local p=h.player(); local s=hearth.players[p:get_player_name()] + assert(hearth.start_rest(p,{x=0,y=3,z=0})) + for _=1,10 do h.tick(.1) end + if mode=="move" then p.pos.x=1 elseif mode=="rain" then h.shelter=.25; h.rain=true else p.hp=19 end + for _=1,3 do h.tick(.1) end -- spatial shelter is revalidated at most every 250ms + assert(not hearth.sessions[p:get_player_name()].resting and s.stamina<38 and s.warmth<58) + end +end) +test("rest cooldown and completion cannot be used as free spam", function() + local h=harness(); local p=h.player() + assert(hearth.start_rest(p,{x=0,y=3,z=0})) + for _=1,50 do h.tick(.1) end + assert(not hearth.start_rest(p,{x=0,y=3,z=0})) +end) +print("PASS "..total.." M2 bedroll rest tests") diff --git a/tests/model_spec.lua b/tests/model_spec.lua index fc78dd1..8917709 100755 --- a/tests/model_spec.lua +++ b/tests/model_spec.lua @@ -40,6 +40,14 @@ test("shelter dries while exposed rain wets", function() assert(dry.wet < initial.wet and wet.wet > initial.wet) assert(initial.wet == 20) -- no hidden mutation end) +test("rain cloak slows wetting, adds warmth, and a rack accelerates sheltered drying", function() + local cold = {food = 80, water = 80, warmth = 68, wet = 20, stamina = 70} + local bare = M.needs(cold, {temperature = 5, shelter = 0, rain = true}, 1) + local coated = M.needs(cold, {temperature = 5, shelter = 0, rain = true, clothing = true}, 1) + assert(coated.wet < bare.wet and coated.warmth > bare.warmth) + local rack = M.needs(initial, {temperature = 5, shelter = 1, rain = true, drying = true}, 1) + assert(rack.wet < M.needs(initial, {temperature = 5, shelter = 1, rain = true}, 1).wet) +end) test("rest recovers sprint reserve", function() local rest = M.needs(initial, {temperature = 20}, 1) local run = M.needs(initial, {temperature = 20, sprinting = true}, 1) diff --git a/tests/movement_spec.lua b/tests/movement_spec.lua new file mode 100755 index 0000000..9630241 --- /dev/null +++ b/tests/movement_spec.lua @@ -0,0 +1,91 @@ +local M = dofile(assert(arg[1], "movement.lua path required")) +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +local function fixture(stamina) + return {}, {stamina = stamina or 100, food = 80, water = 80, wet = 0}, + {aux1 = true, up = true}, {grounded = true, velocity = {x = 3, y = 0, z = 0}} +end +local function near(a, b) assert(math.abs(a - b) < 0.00001, tostring(a) .. " ~= " .. tostring(b)) end +test("zero negative and invalid dt cannot change speed or stamina", function() + local s, state, c, e = fixture() + for _, dt in ipairs({0, -1, 0 / 0}) do + local r = M.step(s, state, c, dt, e); near(r.speed, 1); near(state.stamina, 100) + end +end) +test("stall clamps speed and stamina without replay", function() + local s, state, c, e = fixture() + local r = M.step(s, state, c, 100, e) + near(r.speed, 1.3); near(state.stamina, 98.7) +end) +test("speed smoothly reaches target without overshoot", function() + local s, state, c, e = fixture() + local r = M.step(s, state, c, 0.05, e); near(r.speed, 1.15) + for _ = 1, 8 do r = M.step(s, state, c, 0.05, e); assert(r.speed <= 1.45) end + near(r.speed, 1.45) + c.aux1 = false; r = M.step(s, state, c, 0.05, e); near(r.speed, 1.3) +end) +test("exhaustion cannot stutter while aux remains held", function() + local s, state, c, e = fixture(8.5) + assert(not M.step(s, state, c, 0.1, e).sprinting and s.exhausted) + for _ = 1, 90 do assert(not M.step(s, state, c, 0.1, e).sprinting) end + assert(state.stamina >= 30) + c.aux1 = false; M.step(s, state, c, 0.1, e) + c.aux1 = true; assert(M.step(s, state, c, 0.1, e).sprinting) +end) +test("release remembers intent but still requires thirty reserve", function() + local s, state, c, e = fixture(8) + M.step(s, state, c, 0, e); c.aux1 = false; M.step(s, state, c, 0.1, e) + c.aux1 = true; state.stamina = 29.99 + assert(not M.step(s, state, c, 0, e).sprinting) + state.stamina = 30; assert(M.step(s, state, c, 0, e).sprinting) + -- A release at zero must survive subsequent held frames below the threshold. + s, state, c, e = fixture(0); c.aux1 = false; M.step(s, state, c, 0.1, e) + c.aux1 = true + for _ = 1, 34 do M.step(s, state, c, 0.1, e) end + assert(M.step(s, state, c, 0, e).sprinting) +end) +test("backward diagonal backwards and sneak never sprint", function() + local s, state, c, e = fixture() + c.down = true; assert(not M.step(s, state, c, 0.1, e).sprinting) + c.down, c.up = false, false; assert(not M.step(s, state, c, 0.1, e).sprinting) + c.up, c.sneak = true, true; assert(not M.step(s, state, c, 0.1, e).sprinting) +end) +test("spear brace requires wield and takes priority over sprint", function() + local s, state, c, e = fixture(); c.RMB = true; e.weapon = "hearth:spear" + local r = M.step(s, state, c, 0.1, e) + assert(r.blocking and not r.sprinting); near(r.speed, 0.7) + r = M.step(s, state, c, 0.1, e); near(r.speed, 0.6) + e.weapon = "hearth:knife"; r = M.step(s, state, c, 0.1, e) + assert(not r.blocking and r.sprinting) +end) +test("real vertical velocity and support determine grounded", function() + local s, state, c, e = fixture(); e.velocity.y = 4 + assert(not M.step(s, state, c, 0.1, e).grounded) + e.velocity.y = -4; assert(not M.step(s, state, c, 0.1, e).sprinting) + e.velocity.y, e.grounded = 0, false; assert(not M.step(s, state, c, 0.1, e).sprinting) +end) +test("wet speed does not change wetness or other survival values", function() + local s, state, c, e = fixture(); state.wet = 75; c.aux1 = false + local r = M.step(s, state, c, 0.1, e); near(r.speed, 0.95) + assert(state.wet == 75 and state.food == 80 and state.water == 80) +end) +test("swimming forbids sprint brace and uses swim pace", function() + local s, state, c, e = fixture(); e.swimming, e.weapon, c.RMB = true, "hearth:spear", true + local r = M.step(s, state, c, 0.1, e) + assert(r.swimming and not r.sprinting and not r.blocking); near(r.speed, 0.75) +end) +test("distance motion is velocity derived not a held direction", function() + local s, state, c, e = fixture(); e.velocity.x = 0 + assert(not M.step(s, state, c, 0.1, e).moving) + e.velocity.z = 1; c.up = false; assert(M.step(s, state, c, 0.1, e).moving) +end) +test("recovery stays bounded and respects depleted needs", function() + local s, state, c, e = fixture(50); c.aux1 = false; state.food = 0 + M.step(s, state, c, 0.1, e); near(state.stamina, 50.2) + state.stamina, state.food = 99.9, 80; M.step(s, state, c, 0.1, e); near(state.stamina, 100) +end) +assert(total == 12, "Unexpected movement regression count") +print("PASS " .. total .. " movement tests") diff --git a/tests/persistence_spec.lua b/tests/persistence_spec.lua new file mode 100755 index 0000000..bf0a814 --- /dev/null +++ b/tests/persistence_spec.lua @@ -0,0 +1,320 @@ +local factory = dofile(assert(arg[1], "persistence.lua path required")) +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +local function clone(value) + if type(value) ~= "table" then return value end + local result = {}; for k, v in pairs(value) do result[k] = clone(v) end; return result +end +local defaults = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100} +-- Adapter unit tests inject this contract double, not a second schema +-- implementation. schema_spec owns numeric/field validation. An explicit second +-- argument (or a real hearth.schema in integration smoke) uses the real module. +local schema = { + defaults = function() return clone(defaults) end, + decode = function(kind, value) + assert(type(value) == "table", "PRIVATE schema error") + local migrated = value.schema == nil + local data = value + if not migrated then + assert(value.schema == 1 and value.kind == kind, "PRIVATE schema error") + data = value.data + end + assert(type(data) == "table" and not data.invalid, "PRIVATE schema error") + return {schema = 1, kind = kind, data = clone(data)}, migrated + end, +} +if arg[2] then schema = dofile(arg[2]) +elseif hearth and hearth.schema then schema = hearth.schema end +local fixtures = { + needs = {raw = ' { "food":85, "water":85, "warmth":80, "wet":0, "stamina":100 }\n', data = defaults}, + claims = {raw = '{ "fixture_player" : {"x":-12,"y":4,"z":8} }', + data = {fixture_player = {x = -12, y = 4, z = 8}}}, + homes = {raw = '{ "fixture_player" : {"x":-12,"y":5,"z":8} }', + data = {fixture_player = {x = -12, y = 5, z = 8}}}, + access = {raw = '{ "fixture_player" : {"housemate":"resident"} }', + data = {fixture_player = {housemate = "resident"}}}, + location = {raw = ' {"latitude":45.25, "longitude":-73.5} ', data = {latitude = 45.25, longitude = -73.5}}, + spawn = {raw = '{"x":0.5,"y":5.25,"z":-0.125}\n', data = {x = 0.5, y = 5.25, z = -0.125}}, +} +if arg[3] then + local historic = dofile(arg[3]) + for kind, fixture in pairs(historic) do fixtures[kind] = fixture end +end +local function fingerprint(value) + if type(value) ~= "table" then return type(value) .. ":" .. tostring(value) end + local keys, parts = {}, {} + for key in pairs(value) do keys[#keys + 1] = key end + table.sort(keys) + for _, key in ipairs(keys) do parts[#parts + 1] = key .. "=" .. fingerprint(value[key]) end + return "{" .. table.concat(parts, ";") .. "}" +end +local function harness() + local json = {} + for _, fixture in pairs(fixtures) do json[fixture.raw] = clone(fixture.data) end + json.future = {schema = 2, kind = "claims", data = {}} + json.invalid = {schema = 1, kind = "needs", data = {invalid = true}} + json.scalar = false + local C = {} + function C.parse_json(raw) + if raw == "throw" then error("PRIVATE parser input") end + return clone(json[raw]) + end + function C.write_json(value) + local raw = "encoded:" .. fingerprint(value) + json[raw] = clone(value); return raw + end + local function store() + local s = {values = {}, writes = {}} + function s:get_string(key) + if self.read_hook then self.read_hook(self, key) end + return self.values[key] or "" + end + function s:set_string(key, value) + self.writes[#self.writes + 1] = {key = key, value = value} + if self.hook and self.hook(self, key, value) then return end + self.values[key] = value + end + return s + end + local world, meta = store(), store() + local player = {get_meta = function() return meta end} + return factory(schema, C, world), world, meta, player, C, json +end +local function rejects(fn) + local ok, err = pcall(fn) + assert(not ok and err == "Last Hearth: persistence failed; save retained for recovery", + "Expected sanitized fail-closed persistence error") +end +test("missing records return independent defaults without writes", function() + local p, w, m, player = harness() + assert(next(p.load_world("claims")) == nil) + assert(next(p.load_world("access")) == nil) + assert(p.load_world("location") == nil and p.load_world("spawn") == nil) + local a = p.load_player(player); assert(a.food == 85 and a.stamina == 100) + a.food = 0; assert(p.load_player(player).food == 85) + assert(#w.writes == 0 and #m.writes == 0) +end) +for _, kind in ipairs({"claims", "homes", "access", "location", "spawn", "needs"}) do + test(kind .. " backs up exact legacy bytes before migration and reloads read-only", function() + local p, w, m, player, C = harness() + local s = kind == "needs" and m or w + local key = kind == "needs" and "hearth:needs" or kind + local bk = kind == "needs" and "hearth:m0_backup:needs" or "m0_backup:" .. kind + s.values[key] = fixtures[kind].raw + local function load() return kind == "needs" and p.load_player(player) or p.load_world(kind) end + assert(fingerprint(load()) == fingerprint(fixtures[kind].data)) + assert(#s.writes == 2 and s.writes[1].key == bk and s.writes[2].key == key) + assert(s.values[bk] == fixtures[kind].raw) + local envelope = C.parse_json(s.values[key]) + assert(envelope.schema == 1 and envelope.kind == kind) + assert(fingerprint(load()) == fingerprint(fixtures[kind].data) and #s.writes == 2) + -- A restored legacy primary must never replace the first migration backup. + s.values[key] = fixtures[kind].raw + load(); assert(#s.writes == 3 and s.values[bk] == fixtures[kind].raw) + end) +end +test("existing backup remains immutable even if it is not JSON", function() + local p, w = harness() + w.values.claims, w.values["m0_backup:claims"] = fixtures.claims.raw, "PRIVATE recovery bytes" + p.load_world("claims") + assert(#w.writes == 1 and w.values["m0_backup:claims"] == "PRIVATE recovery bytes") +end) +test("empty legacy claims migrate without being mistaken for absent storage", function() + local p, w, _, _, _, json = harness() + json["{}"] = {}; w.values.claims = "{}" + assert(next(p.load_world("claims")) == nil and #w.writes == 2) + assert(w.values["m0_backup:claims"] == "{}") +end) +test("malformed scalar and future world records fail load and save with zero writes", function() + for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future"}) do + local p, w = harness(); w.values.claims = raw + rejects(function() p.load_world("claims") end) + rejects(function() p.save_world("claims", {}) end) + assert(w.values.claims == raw and #w.writes == 0) + end +end) +test("invalid player saves remain byte exact on both load and save", function() + for _, raw in ipairs({"malformed PRIVATE", "throw", "scalar", "future", "invalid"}) do + local p, _, m, player = harness(); m.values["hearth:needs"] = raw + rejects(function() p.load_player(player) end) + rejects(function() p.save_player(player, clone(defaults)) end) + assert(m.values["hearth:needs"] == raw and #m.writes == 0) + end +end) +test("invalid new data and unknown world kinds never write", function() + local p, w, m, player = harness() + rejects(function() p.save_world("claims", {invalid = true}) end) + rejects(function() p.save_player(player, {invalid = true}) end) + for _, kind in ipairs({"needs", "PRIVATE", false, {}}) do + rejects(function() p.load_world(kind) end) + rejects(function() p.save_world(kind, {}) end) + end + assert(#w.writes == 0 and #m.writes == 0) +end) +test("all world kinds and player needs save current envelopes", function() + local p, w, m, player, C = harness() + for _, kind in ipairs({"claims", "homes", "access", "location", "spawn"}) do + p.save_world(kind, fixtures[kind].data) + local value = C.parse_json(w.values[kind]) + assert(value.schema == 1 and value.kind == kind) + assert(w.values["m0_backup:" .. kind] == nil) + end + p.save_player(player, clone(defaults)) + assert(C.parse_json(m.values["hearth:needs"]).kind == "needs" and #m.writes == 1) + p.save_player(player, clone(defaults)); assert(#m.writes == 2) +end) +test("direct save of legacy state still backs up the old bytes", function() + local p, w, m, player = harness() + w.values.claims = fixtures.claims.raw + p.save_world("claims", {}) + assert(w.values["m0_backup:claims"] == fixtures.claims.raw and #w.writes == 2) + m.values["hearth:needs"] = fixtures.needs.raw + local updated = clone(defaults); updated.food = 25 + p.save_player(player, updated) + assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw) + assert(p.load_player(player).food == 25 and #m.writes == 2) +end) +test("writer failure nonstrings and invalid roundtrips never touch originals or backups", function() + for _, mode in ipairs({"throw", "nil", "number", "empty", "malformed", "legacy", "changed", "future"}) do + local p, w, _, _, C, json = harness(); w.values.claims = fixtures.claims.raw + C.write_json = function(value) + if mode == "throw" then error("PRIVATE writer error") end + if mode == "nil" then return nil end + if mode == "number" then return 42 end + if mode == "empty" then return "" end + if mode == "legacy" then return fixtures.claims.raw end + if mode == "future" then return "future" end + if mode == "changed" then + json.changed = {schema = 1, kind = "claims", data = {}} + return "changed" + end + return "malformed PRIVATE" + end + rejects(function() p.load_world("claims") end) + rejects(function() p.save_world("claims", fixtures.claims.data) end) + assert(w.values.claims == fixtures.claims.raw and #w.writes == 0) + end +end) +test("backup failure prevents primary writes", function() + for _, mode in ipairs({"throw", "ignore", "corrupt"}) do + local p, w = harness(); w.values.claims = fixtures.claims.raw + w.hook = function(s, key) + assert(key == "m0_backup:claims") + if mode == "throw" then error("PRIVATE storage error") end + if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end + return true + end + rejects(function() p.load_world("claims") end) + assert(w.values.claims == fixtures.claims.raw and #w.writes == 1) + end +end) +test("primary corruption or throw-after-write restores original and retains backup", function() + for _, mode in ipairs({"corrupt", "throw", "ignore"}) do + local p, w = harness(); w.values.claims = fixtures.claims.raw + local once = true + w.hook = function(s, key, value) + if key == "claims" and once then + once = false + if mode == "throw" then s.values[key] = value; error("PRIVATE storage error") end + if mode == "corrupt" then s.values[key] = "PRIVATE truncated" end + return true + end + end + rejects(function() p.load_world("claims") end) + assert(w.values.claims == fixtures.claims.raw) + assert(w.values["m0_backup:claims"] == fixtures.claims.raw) + end +end) +test("failed rollback quarantines key even if damaged readback looks valid", function() + local p, w, _, _, C = harness(); w.values.claims = fixtures.claims.raw + local replacement = C.write_json({schema = 1, kind = "claims", data = {}}) + w.hook = function(s, key) + if key == "claims" then s.values[key] = replacement; error("PRIVATE persistent failure") end + end + rejects(function() p.load_world("claims") end) + local count = #w.writes; w.hook = nil + rejects(function() p.load_world("claims") end) + rejects(function() p.save_world("claims", {}) end) + assert(#w.writes == count and w.values["m0_backup:claims"] == fixtures.claims.raw) +end) +test("player migration write failure rolls back only player metadata", function() + local p, w, m, player = harness(); m.values["hearth:needs"] = fixtures.needs.raw + local once = true + m.hook = function(s, key) + if key == "hearth:needs" and once then once = false; s.values[key] = "PRIVATE partial"; return true end + end + rejects(function() p.load_player(player) end) + assert(m.values["hearth:needs"] == fixtures.needs.raw) + assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw and #w.writes == 0) +end) +test("failed first save restores missing storage and creates no migration backup", function() + local p, w = harness(); local once = true + w.hook = function(s, key) + if once then once = false; s.values[key] = "PRIVATE partial"; return true end + end + rejects(function() p.save_world("claims", {}) end) + assert(w.values.claims == "" and w.values["m0_backup:claims"] == nil) +end) +test("failed current-schema save restores exact prior bytes without a migration backup", function() + local p, w, _, _, C = harness() + local original = C.write_json({schema = 1, kind = "claims", data = fixtures.claims.data}) + w.values.claims = original + local once = true + w.hook = function(s, key) + if once then once = false; s.values[key] = "PRIVATE truncated"; return true end + end + rejects(function() p.save_world("claims", {}) end) + assert(w.values.claims == original and w.values["m0_backup:claims"] == nil) +end) +test("explicit false setter result fails and rolls back", function() + local p, w = harness(); local setter = w.set_string; local once = true + w.set_string = function(self, key, value) + setter(self, key, value) + if once then once = false; return false end + end + rejects(function() p.save_world("claims", {}) end) + assert(w.values.claims == "" and #w.writes == 2) +end) +test("readback exception triggers verified rollback", function() + local p, w = harness(); local once = true + w.read_hook = function(s, key) + if key == "claims" and #s.writes == 1 and once then + once = false; error("PRIVATE readback error") + end + end + rejects(function() p.save_world("claims", {}) end) + assert(w.values.claims == "" and #w.writes == 2) +end) +test("player quarantine survives fresh metadata wrappers", function() + local p, _, m, _, C = harness() + m.values["hearth:needs"] = fixtures.needs.raw + local calls = 0 + local player = {get_meta = function() + calls = calls + 1 + return {get_string = function(_, key) return m:get_string(key) end, + set_string = function(_, key, value) return m:set_string(key, value) end} + end} + local changed = clone(defaults); changed.food = 25 + local replacement = C.write_json({schema = 1, kind = "needs", data = changed}) + m.hook = function(s, key) + if key == "hearth:needs" then s.values[key] = replacement; error("PRIVATE write error") end + end + rejects(function() p.load_player(player) end) + local count = #m.writes; m.hook = nil + rejects(function() p.load_player(player) end) + rejects(function() p.save_player(player, clone(defaults)) end) + assert(calls == 1 and #m.writes == count) + assert(m.values["hearth:m0_backup:needs"] == fixtures.needs.raw) +end) +test("read errors and metadata errors are sanitized without writes", function() + local p, w, m = harness() + w.read_hook = function() error("PRIVATE read error") end + rejects(function() p.load_world("claims") end) + rejects(function() p.save_world("claims", {}) end) + rejects(function() p.load_player({get_meta = function() error("PRIVATE player error") end}) end) + assert(#w.writes == 0 and #m.writes == 0) +end) +print("PASS " .. total .. " persistence tests") diff --git a/tests/players_spec.lua b/tests/players_spec.lua new file mode 100755 index 0000000..42c3d11 --- /dev/null +++ b/tests/players_spec.lua @@ -0,0 +1,409 @@ +-- Standalone Lua 5.1+ lifecycle regressions; arg[1] is players.lua. +local target = assert(arg[1], "players.lua path required") +local root = assert(target:match("^(.*[/\\])"), "module directory required") +local schema = dofile(root .. "schema.lua") +local persistence = dofile(root .. "persistence.lua") +local total = 0 +local function test(name, fn) + local old_core, old_hearth, old_vector = core, hearth, vector + local ok, message = pcall(fn) + core, hearth, vector = old_core, old_hearth, old_vector + assert(ok, name .. ": " .. tostring(message)); total = total + 1 +end +local function copy(value) + if type(value) ~= "table" then return value end + local result = {}; for k, v in pairs(value) do result[k] = copy(v) end; return result +end +local spawn = {x = 4, y = 4, z = -8} +local function harness(saved_spawn) + local h = {callbacks = {}, after = {}, logs = {}, kicks = {}, connected = {}, moves = {}, gui = {}, messages = {}, + loaded = true, ground = true, clock = 1000000, emerges = {}, homes = {}} + local function store() + local m = {values = {}, ints = {}, writes = 0} + function m:get_string(key) + if self.read_fail then error("PRIVATE read payload") end + return self.values[key] or "" + end + function m:set_string(key, value) + self.writes = self.writes + 1 + if self.write_fail then error("PRIVATE save payload") end + self.values[key] = value + end + function m:get_int(key) return self.ints[key] or 0 end + function m:set_int(key, value) + if key == "hearth:arrived" then h.moves[#h.moves + 1] = "arrived" end + self.ints[key] = value + end + return m + end + local json, serial = {}, 0 + local C = {registered_nodes = { + air = {walkable = false}, ground = {walkable = true, groups = {soil = 1}}, + leaves = {walkable = true, groups = {leaves = 1, hearth_canopy = 1}}, + log = {walkable = true, groups = {tree = 1}}, + floor = {walkable = true, groups = {stone = 1, hearth_building = 1}}, + water = {walkable = false, liquidtype = "source", groups = {water = 1}}, + fire = {walkable = false, damage_per_second = 4}, + }} + function C.write_json(value) serial = serial + 1; local raw = "fixture:" .. serial; json[raw] = copy(value); return raw end + function C.parse_json(raw, _, return_error) + assert(return_error == true, "Persistence must suppress parser payload logging"); return copy(json[raw]) + end + function C.get_node_or_nil(pos) + if not h.loaded then return nil end + if h.node_at then return h.node_at(pos) end + if h.water_at and h.water_at(pos) then return {name = "water"} end + if h.fire_at and h.fire_at(pos) then return {name = "fire"} end + if h.ground and pos.y == 3 then return {name = "ground"} end + return {name = "air"} + end + function C.get_mod_storage() return h.world end + function C.register_on_joinplayer(fn) h.callbacks.join = fn end + function C.register_on_leaveplayer(fn) h.callbacks.leave = fn end + function C.register_on_shutdown(fn) h.callbacks.shutdown = fn end + function C.register_on_respawnplayer(fn) h.callbacks.respawn = fn end + function C.register_on_punchplayer(fn) h.callbacks.punch = fn end + C.get_connected_players = function() return h.connected end + C.get_player_by_name = function(name) for _, p in ipairs(h.connected) do if p.name == name then return p end end end + C.log = function(level, text) assert(level == "error"); h.logs[#h.logs + 1] = text end + C.kick_player = function(name, text) h.kicks[#h.kicks + 1] = text end + C.after = function(delay, fn) h.after[#h.after + 1] = {delay = delay, fn = fn} end + C.get_us_time = function() h.clock = h.clock + 1000; return h.clock end + C.settings = {get = function(_, key) if key == "mapgen_limit" then return "512" end end} + C.emerge_area = function(minimum, maximum, fn) + h.emerges[#h.emerges + 1] = {minimum = copy(minimum), maximum = copy(maximum), callback = fn} + end + h.world = store() + if saved_spawn then h.world.values.spawn = C.write_json({schema = 1, kind = "spawn", data = saved_spawn}) end + local function claim_allowed() return true end + core, vector = C, {round = function(p) return {x = math.floor(p.x + 0.5), y = math.floor(p.y + 0.5), z = math.floor(p.z + 0.5)} end} + hearth = {players = {}, sessions = {}, schema = schema, has_location = true, claim_allowed = claim_allowed} + hearth.persistence = persistence(schema, C, h.world) + hearth.home_for = function(name) local p = h.homes[name]; return p and copy(p) end + hearth.in_own_claim = function(pos, name) return h.claim_owner == name and h.in_claim ~= false end + hearth.store_home = function(name, pos) assert(hearth.in_own_claim(pos, name)); h.homes[name] = copy(pos); h.home_saves = (h.home_saves or 0) + 1 end + hearth.notify = function(_, message) h.messages[#h.messages + 1] = message end + hearth.texture = function() return "texture.png" end + hearth.inventory = function(p) p.inventory_calls = p.inventory_calls + 1 end + hearth.make_hud = function(p) p.hud_calls = p.hud_calls + 1 end + hearth.guide = function(p) h.gui[#h.gui + 1] = {kind = "guide", player = p} end + hearth.location_form = function(p) h.gui[#h.gui + 1] = {kind = "location", player = p} end + function h.player(name) + local meta = store() + local p = {name = name or "fixture-player", meta = meta, pos = {x = 0, y = 4, z = 0}, + inventory_calls = 0, hud_calls = 0, sizes = {}, widths = {}, hp = 20} + function p:get_player_name() return self.name end + function p:get_meta() return self.meta end + function p:get_pos() return self.pos end + function p:get_inventory() + return {set_size = function(_, key, value) self.sizes[key] = value end, + set_width = function(_, key, value) self.widths[key] = value end} + end + function p:set_properties(value) + self.property_calls = (self.property_calls or 0) + 1 + self.properties = self.properties or {} + for k, v in pairs(value) do self.properties[k] = copy(v) end + end + function p:set_armor_groups(value) self.armor = value end + function p:set_clouds(value) self.clouds = value end + function p:set_pos(value) self.pos = copy(value); h.moves[#h.moves + 1] = "pos" end + function p:set_velocity(value) self.velocity = copy(value); h.moves[#h.moves + 1] = "velocity" end + function p:get_hp() return self.hp end + function p:set_hp(value) self.hp = value end + h.connected[#h.connected + 1] = p + return p, meta + end + function h.drain_zero() + local count = 0 + while count < 100 do + local item + for i, queued in ipairs(h.after) do if queued.delay == 0 then item = table.remove(h.after, i); break end end + if not item then return end + count = count + 1; item.fn() + end + error("Unbounded scheduled callback loop") + end + dofile(target) + return h +end +local function no_private(h) + for _, messages in ipairs({h.logs, h.kicks}) do + for _, message in ipairs(messages) do assert(not message:find("PRIVATE", 1, true)) end + end +end + +test("lifecycle callbacks expose safe handlers and disable PvP", function() + local h = harness(spawn) + assert(h.callbacks.join == hearth.player_join and h.callbacks.leave == hearth.player_leave) + assert(h.callbacks.respawn == hearth.player_respawn and h.callbacks.punch() == true) + assert(type(h.callbacks.shutdown) == "function" and type(hearth.safe_arrival) == "function") +end) +test("initial arrival validates spawn and only then marks arrived", function() + local h = harness(spawn); local p, meta = h.player(); h.callbacks.join(p) + assert(meta:get_int("hearth:arrived") == 1 and p.pos.x == spawn.x and p.pos.y == spawn.y) + assert(table.concat(h.moves, ",") == "pos,velocity,arrived") + assert(not hearth.sessions[p.name].waiting and #h.kicks == 0) +end) +test("normal reconnect preserves saved location without home/spawn teleport", function() + local h = harness(spawn); local p, meta = h.player(); meta.ints["hearth:arrived"] = 1 + p.pos = {x = 123, y = -40, z = 456}; h.callbacks.join(p) + assert(#h.moves == 0 and p.pos.x == 123 and p.pos.y == -40 and p.pos.z == 456) + assert(not hearth.sessions[p.name].waiting) +end) +test("initial arrival waits for emergence and does not set arrived early", function() + local h = harness(); h.loaded = false + local p, meta = h.player(); h.callbacks.join(p) + assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting and #h.moves == 0) + assert(#h.emerges == 1 and h.emerges[1].minimum.x == -64 and h.emerges[1].minimum.z == -64 + and h.emerges[1].maximum.x == 64 and h.emerges[1].maximum.z == 64 + and h.emerges[1].minimum.y == 0 and h.emerges[1].maximum.y == 320, + "Bounded arrival must emerge surfaces through 318 plus feet/head clearance") + h.loaded, h.ground = true, true + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(meta:get_int("hearth:arrived") == 1 and not hearth.sessions[p.name].waiting) + assert(p.pos.y == 4 and #h.kicks == 0) +end) +test("shared emergence resolves every waiting player without kicking", function() + local h = harness(); h.loaded = false + local p, m = h.player("one"); local q, n = h.player("two") + h.callbacks.join(p); h.callbacks.join(q) + assert(#h.emerges == 1 and hearth.sessions.one.waiting and hearth.sessions.two.waiting) + h.loaded, h.ground = true, true + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(m:get_int("hearth:arrived") == 1 and n:get_int("hearth:arrived") == 1) + assert(not hearth.sessions.one.waiting and not hearth.sessions.two.waiting and #h.kicks == 0) + assert(p.pos.x ~= q.pos.x or p.pos.z ~= q.pos.z, "Arrival must not overlap another player") +end) +test("leaving during emergence removes that waiter and stale callback cannot move them", function() + local h = harness(); h.loaded = false + local p, meta = h.player(); h.callbacks.join(p); h.callbacks.leave(p) + h.loaded = true; h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and not hearth.sessions[p.name]) +end) +test("no safe bounded location leaves player waiting and preserves metadata", function() + local h = harness(); h.loaded = false; h.ground = false + local p, meta = h.player(); h.callbacks.join(p) + h.loaded = true; h.ground = false; h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) + assert(#h.kicks == 0 and #h.moves == 0 and hearth.players[p.name].food == 85) +end) +test("high altitude patch is found without settling on leaves or logs", function() + local h = harness(); local p, meta = h.player() + h.node_at = function(pos) + assert(math.abs(pos.x) <= 64 and math.abs(pos.z) <= 64 and pos.y <= 320, + "Surface search must remain bounded") + if pos.y == 318 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 then return {name = "ground"} end + if pos.y == 290 then return {name = pos.x < 0 and "leaves" or "log"} end + return {name = "air"} + end + h.callbacks.join(p) + assert(#h.moves == 0 and #h.emerges == 1) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 319 and p.pos.x == 0 and p.pos.z == 0) +end) +test("natural biome surface groups support high altitude arrival", function() + for _, groups in ipairs({{soil = 1, hearth_habitat = 1}, {stone = 1, hearth_geology = 1}, + {sand = 1, falling_node = 1}, {crumbly = 3}, {crumbly = 3, falling_node = 1}, {clay = 1, crumbly = 2}}) do + local h = harness(); core.registered_nodes.variant = {walkable = true, groups = groups} + h.node_at = function(pos) return {name = pos.y == 250 and "variant" or "air"} end + local p, meta = h.player(); h.callbacks.join(p) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(meta:get_int("hearth:arrived") == 1 and p.pos.y == 251) + end +end) +test("canopy or log only worlds never fabricate a safe fallback", function() + for _, node in ipairs({"leaves", "log", "floor"}) do + local h = harness() + h.node_at = function(pos) return {name = pos.y == 250 and node or "air"} end + local p, meta = h.player(); h.callbacks.join(p) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) + end +end) +test("isolated high cliff pillar is not a landing patch", function() + local h = harness() + h.node_at = function(pos) + return {name = pos.x == 0 and pos.z == 0 and pos.y <= 250 and "ground" or "air"} + end + local p, meta = h.player(); h.callbacks.join(p) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) +end) +test("unsafe upper surfaces cannot fall through to underground cave floors", function() + local h = harness() + h.node_at = function(pos) + if pos.y >= 260 or pos.y == 200 then return {name = "ground"} end + return {name = "air"} + end + local p, meta = h.player(); h.callbacks.join(p) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) +end) +test("high altitude wet and damaging surfaces remain unsafe", function() + for _, hazard in ipairs({"water", "fire"}) do + local h = harness() + h.node_at = function(pos) + if pos.y == 250 then return {name = "ground"} end + return {name = pos.y == 251 and hazard or "air"} + end + local p, meta = h.player(); h.callbacks.join(p) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(#h.moves == 0 and meta:get_int("hearth:arrived") == 0 and hearth.sessions[p.name].waiting) + end +end) +test("bounded arrivals authorize unclaimed land but reject foreign claims", function() + for _, claimed in ipairs({false, true}) do + local h = harness() + local claims = claimed and {owner = {x = 0, y = 251, z = 0}} or {} + local policy = dofile(root .. "access.lua")(claims, {owner = {newcomer = "resident"}}) + local checks = 0 + hearth.claim_allowed = function(pos, name, action) + assert(action == "arrival" and name == "newcomer"); checks = checks + 1 + return policy.allowed(pos, name, action) + end + h.node_at = function(pos) + return {name = pos.y == 250 and math.abs(pos.x) <= 2 and math.abs(pos.z) <= 2 and "ground" or "air"} + end + local p, meta = h.player("newcomer"); h.callbacks.join(p) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(checks > 0) + assert(meta:get_int("hearth:arrived") == (claimed and 0 or 1)) + assert(hearth.sessions[p.name].waiting == claimed) + end +end) +test("saved home retains safe constructed floor support", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + h.claim_owner = p.name + h.node_at = function(pos) return {name = pos.y == 249 and "floor" or "air"} end + p.pos = {x = 20, y = 250, z = 20} + assert(hearth.set_home(p)) + p.pos = {x = 100, y = 4, z = 100} + assert(hearth.go_home(p)); h.drain_zero() + assert(p.pos.x == 20 and p.pos.y == 250 and p.pos.z == 20) +end) +test("sethome requires own claim and dry grounded support, then stores safe integer position", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + assert(not hearth.set_home(p) and (h.home_saves or 0) == 0) + p.pos = {x = 0, y = 4, z = 0} + h.claim_owner = p.name + local ok, message = hearth.set_home(p) + assert(ok and message:find("Safe home", 1, true) and h.home_saves == 1) + assert(h.homes[p.name].x == 0 and h.homes[p.name].y == 4 and h.homes[p.name].z == 0) + h.water_at = function(pos) return pos.y == 4 end + assert(not hearth.set_home(p) and h.home_saves == 1) +end) +test("sethome rejects foreign claim and unsupported or obstructed positions", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + h.claim_owner = "another-owner"; assert(not hearth.set_home(p)) + h.claim_owner = p.name; h.ground = false; assert(not hearth.set_home(p)) + h.ground = true; h.node_at = function(pos) + if pos.y == 3 then return {name = "ground"} end + if pos.y == 5 then return {name = "ground"} end + return {name = "air"} + end + assert(not hearth.set_home(p)) +end) +test("home command revalidates home and moves only on explicit request", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + h.homes[p.name] = {x = 20, y = 4, z = 20} + p.pos = {x = 100, y = 4, z = 100} + local before = #h.moves + local ok = hearth.go_home(p) + assert(ok and #h.moves == before + 2 and p.pos.x == 20 and p.pos.z == 20) + assert(h.messages[#h.messages]:find("verified safe", 1, true)) +end) +test("home command without a saved home still performs safe world-spawn recovery", function() + local h = harness(spawn); local p = h.player("new-player"); h.callbacks.join(p) + p.pos = {x = 100, y = 4, z = 100} + local ok, message = hearth.go_home(p) + h.drain_zero() + assert(ok and message:find("No personal home", 1, true)) + assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z) +end) +test("unsafe saved home falls back to validated spawn without teleporting into fluid", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + h.homes[p.name] = {x = 20, y = 4, z = 20} + h.water_at = function(pos) return pos.x >= 15 and pos.x <= 25 and pos.z >= 15 and pos.z <= 25 + and (pos.y == 4 or pos.y == 5) end + assert(hearth.go_home(p)); h.drain_zero() + assert(p.pos.x == spawn.x and p.pos.y == spawn.y and p.pos.z == spawn.z) +end) +test("respawn preserves food, water, wetness and inventory while limiting recovery needs", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + local state = hearth.players[p.name] + state.food, state.water, state.wet, state.warmth, state.stamina = 19, 27, 44, 35, 2 + p.pos = {x = 200, y = 4, z = 200} + assert(h.callbacks.respawn(p) == true) + h.drain_zero() + assert(#h.emerges == 1 and hearth.sessions[p.name].waiting) + h.emerges[1].callback(nil, nil, 0); h.drain_zero() + assert(state.food == 19 and state.water == 27 and state.wet == 44) + assert(state.warmth == 35 and state.stamina == 35 and p.inventory_calls == 1) + assert(p.pos.y == 4 and not hearth.sessions[p.name].waiting and #h.kicks == 0) +end) +test("death recovery avoids a recently lethal home and uses emergence-safe fallback", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + h.homes[p.name] = {x = 20, y = 4, z = 20}; p.pos = {x = 20, y = 4, z = 20} + hearth.sessions[p.name].recovery_avoid = {x = 20, y = 4, z = 20, until_us = h.clock + 10000000} + h.callbacks.respawn(p) + h.drain_zero() + assert((p.pos.x ~= 20 or p.pos.z ~= 20) and not hearth.sessions[p.name].waiting) +end) +test("failed player load is sanitized and cannot establish a session", function() + local h = harness(spawn); local p, meta = h.player(); meta.read_fail = true; h.callbacks.join(p) + assert(not hearth.players[p.name] and not hearth.sessions[p.name] and #h.kicks == 1) + assert(#h.moves == 0 and meta.writes == 0); no_private(h) +end) +test("saved spawn and home never accept unloaded, liquid, unsupported, or damaging nodes", function() + local h = harness(spawn); local p = h.player("owner"); h.callbacks.join(p) + h.node_at = function(pos) + if pos.y == 3 then return {name = "ground"} end + if pos.x == spawn.x and pos.z == spawn.z and (pos.y == 4 or pos.y == 5) then return {name = "water"} end + return {name = "air"} + end + h.homes[p.name] = {x = spawn.x, y = spawn.y, z = spawn.z} + p.pos = {x = 100, y = 4, z = 100} + assert(hearth.go_home(p)); h.drain_zero() + assert(p.pos.x ~= spawn.x or p.pos.z ~= spawn.z) + assert(#h.kicks == 0) +end) +test("character join hook runs after session and base properties without taking form priority", function() + local h = harness(spawn); local p, meta = h.player() + local calls = 0 + hearth.character = {on_join = function(player) + assert(player == p and hearth.sessions[p.name] and p.properties.visual == "mesh") + assert(p.properties.mesh == "hearth_person.b3d" and p.properties.eye_height == 1.55) + calls = calls + 1 + player:set_properties({mesh = "hearth_person_compact.b3d", textures = {"fixture_appearance.png"}}) + end} + h.callbacks.join(p) + assert(calls == 1 and p.properties.mesh == "hearth_person_compact.b3d") + assert(p.properties.eye_height == 1.55 and p.properties.visual_size.x == 1 + and p.properties.collisionbox[5] == 1.75 and p.properties.hp_max == 20) + assert(meta:get_int("hearth:arrived") == 1 and #h.gui == 0) + hearth.has_location = false; core.is_singleplayer = function() return true end + for _, entry in ipairs(h.after) do if entry.delay == 1 then entry.fn() end end + assert(#h.gui == 1 and h.gui[1].kind == "location", "Location must retain join form priority") +end) +test("real character factory restores saved metadata during player initialization", function() + local h = harness(spawn); local p, meta = h.player() + core.register_on_player_receive_fields = function() end + core.register_chatcommand = function() end + hearth.character = dofile(root .. "character.lua")(hearth, core) + local d = hearth.character.defaults(); d.body = "broad"; d.skin = "umber" + meta.values["hearth:appearance"] = assert(hearth.character.encode(d)) + h.callbacks.join(p) + assert(p.properties.mesh == "hearth_person_broad.b3d" and p.property_calls == 2) + assert(p.properties.textures[1]:find("hearth_char_skin_umber.png",1,true)) + assert(p.properties.eye_height == 1.55 and p.properties.stepheight == 0.6) + assert(meta.writes == 0, "Joining with saved appearance must not rewrite metadata") + assert(#h.kicks == 0 and not hearth.character.state(p).warning) +end) +test("malformed saved world spawn fails module startup without overwrite", function() + local h = harness(spawn); h.world.values.spawn = "PRIVATE malformed" + local ok, message = pcall(dofile, target) + assert(not ok and not tostring(message):find("PRIVATE", 1, true)) + assert(h.world.values.spawn == "PRIVATE malformed" and h.world.writes == 0); no_private(h) +end) +assert(total == 27, "Unexpected lifecycle regression count") +print("PASS " .. total .. " player lifecycle safety tests") diff --git a/tests/presentation_spec.lua b/tests/presentation_spec.lua new file mode 100755 index 0000000..fce7d5b --- /dev/null +++ b/tests/presentation_spec.lua @@ -0,0 +1,183 @@ +-- Standalone Lua 5.1+; no real core/hearth mutation leaks into engine suites. +local target = assert(arg[1], "presentation.lua path required") +local total = 0 +local function test(name, fn) + local old_core, old_hearth = core, hearth + local ok, message = pcall(fn) + core, hearth = old_core, old_hearth + assert(ok, name .. ": " .. tostring(message)) + total = total + 1; print("PASS " .. name) +end +local function harness() + local h = {objects = {}, connected = {}, leaves = {}, deaths = {}, shutdowns = {}} + core = { + register_entity = function(name, def) h.entity_name, h.def = name, def end, + get_player_by_name = function(name) return h.connected[name] end, + register_on_leaveplayer = function(fn) h.leaves[#h.leaves + 1] = fn end, + register_on_dieplayer = function(fn) h.deaths[#h.deaths + 1] = fn end, + register_on_shutdown = function(fn) h.shutdowns[#h.shutdowns + 1] = fn end, + } + hearth = {players = {}, sessions = {}} + function h.player(name) + local p = {name = name or "fixture", item = "hearth:knife", hp = 20} + function p:get_player_name() return self.name end + function p:get_wielded_item() return {get_name = function() return self.item end} end + function p:get_pos() return {x = 1, y = 52, z = 3} end + function p:get_hp() return self.hp end + h.connected[p.name] = p + hearth.players[p.name] = {food = 85, stamina = 100} + hearth.sessions[p.name] = {} + return p + end + core.add_entity = function(pos, name, staticdata) + assert(name == h.entity_name and pos.y == 52) + if h.fail then return nil end + if h.throw then error("fixture failure") end + local obj = {props = {}, writes = 0} + local lua = {object = obj}; obj.lua = lua + function obj:get_pos() if not self.removed then return pos end end + function obj:remove() + assert(not self.removed, "duplicate removal") + self.removed = true; h.def.on_deactivate(lua) + end + function obj:set_properties(props) + self.writes = self.writes + 1 + for k, v in pairs(props) do self.props[k] = v end + end + function obj:set_armor_groups(groups) self.armor = groups end + function obj:set_attach(parent, bone, position, rotation, forced) + if h.refuse_attach then return end + self.parent, self.bone, self.position, self.rotation, self.forced = parent, bone, position, rotation, forced + end + function obj:get_attach() return self.parent, self.bone, self.position, self.rotation, self.forced end + h.objects[#h.objects + 1] = obj + h.def.on_activate(lua, staticdata or "", 0) + return obj + end + function h.count() + local n = 0; for _, obj in ipairs(h.objects) do if not obj.removed then n = n + 1 end end; return n + end + function h.tick_objects() + for _, obj in ipairs(h.objects) do if not obj.removed then h.def.on_step(obj.lua, 0.1) end end + end + dofile(target) + return h +end + +test("fixed whitelist creates all four generated UV meshes with correct bone transform", function() + local h = harness(); local p = h.player() + for _, tool in ipairs({"knife", "axe", "pick", "spear"}) do + p.item = "hearth:" .. tool; hearth.presentation_step(p) + local obj = h.objects[#h.objects] + assert(h.count() == 1 and obj.props.mesh == "hearth_" .. tool .. ".obj") + assert(obj.parent == p and obj.bone == "Hand_Right" and obj.forced == false) + assert(obj.position.x == 0 and obj.position.y == 0 and obj.position.z == 0) + assert(obj.rotation.x == 90 and obj.rotation.y == 0 and obj.rotation.z == 0) + assert(obj.props.is_visible == true and obj.armor.immortal == 1) + end + assert(h.def.initial_properties.textures[1] == "hearth_person.png") + assert(h.def.initial_properties.visual_size.x == 1 and h.def.initial_properties.visual_size.y == 1) +end) +test("steady owner has exactly one entity and no repeated property traffic", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + local obj = h.objects[1]; local writes = obj.writes + for _ = 1, 200 do hearth.presentation_step(p); h.tick_objects() end + assert(#h.objects == 1 and h.count() == 1 and obj.writes == writes) +end) +test("weapon change removes old entity before creating replacement", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + p.item = "hearth:axe"; hearth.presentation_step(p) + assert(h.objects[1].removed and h.count() == 1 and #h.objects == 2) + h.def.on_deactivate(h.objects[1].lua); hearth.presentation_step(p) + assert(h.count() == 1 and #h.objects == 2) +end) +test("empty unknown and path-like names remove visuals without accepting paths", function() + local h = harness(); local p = h.player() + for _, name in ipairs({"", "hearth:log", "../hearth_spear.obj", "hearth:knife.obj", "other:spear"}) do + p.item = "hearth:knife"; hearth.presentation_step(p) + p.item = name; hearth.presentation_step(p); assert(h.count() == 0) + end +end) +test("entity self-cleans on weapon change even without feedback tick", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + p.item = ""; h.tick_objects(); assert(h.count() == 0) +end) +test("disconnect callback removes immediately and reconnect never inherits owner", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + h.connected[p.name] = nil; h.leaves[1](p); assert(h.count() == 0) + local replacement = h.player(); hearth.presentation_step(replacement) + h.leaves[1](p); hearth.presentation_step(p) + assert(h.count() == 1 and #h.objects == 2 and h.objects[2].parent == replacement) +end) +test("session replacement invalidates old entity even on same PlayerRef", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + hearth.sessions[p.name] = {}; h.tick_objects(); assert(h.count() == 0) + hearth.presentation_step(p); assert(h.count() == 1 and #h.objects == 2) +end) +test("lost player state session or connected identity self-cleans", function() + for _, missing in ipairs({"state", "session", "connection"}) do + local h = harness(); local p = h.player(); hearth.presentation_step(p) + if missing == "state" then hearth.players[p.name] = nil + elseif missing == "session" then hearth.sessions[p.name] = nil + else h.connected[p.name] = nil end + h.tick_objects(); assert(h.count() == 0); hearth.presentation_step(p); assert(h.count() == 0) + end +end) +test("waiting dead and death callback cleanup are idempotent", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + hearth.sessions[p.name].waiting = true; h.tick_objects(); assert(h.count() == 0) + hearth.presentation_step(p); assert(h.count() == 0) + hearth.sessions[p.name].waiting = false; hearth.presentation_step(p) + p.hp = 0; h.tick_objects(); h.deaths[1](p); h.leaves[1](p); assert(h.count() == 0) + hearth.presentation_step(p); assert(h.count() == 0) +end) +test("foreign activation and static restoration are rejected with no saved payload", function() + local h = harness(); h.player() + for _, data in ipairs({"", "inventory payload", "{owner=fixture}"}) do + local obj = core.add_entity({y = 52}, h.entity_name, data) + assert(obj.removed and h.def.get_staticdata(obj.lua) == "") + end + assert(h.count() == 0) +end) +test("harmless entity cannot collide select attack or create inventory", function() + local h = harness(); local p = h.player(); hearth.presentation_step(p) + local def, obj = h.def, h.objects[1]; local props = def.initial_properties + assert(props.static_save == false and props.physical == false and props.pointable == false) + assert(props.collide_with_objects == false and props.is_visible == false) + assert(props.makes_footstep_sound == false and obj.lua.inventory == nil) + assert(def.on_punch(obj.lua, p) == true); def.on_rightclick(obj.lua, p) + assert(p.hp == 20 and h.count() == 1 and hearth.players[p.name].stamina == 100) +end) +test("detached redirected and removed objects are replaced without duplicates", function() + for _, mode in ipairs({"detach", "redirect", "remove"}) do + local h = harness(); local p = h.player(); hearth.presentation_step(p) + local obj = h.objects[1] + if mode == "remove" then obj:remove() + elseif mode == "detach" then obj.parent = nil + else obj.bone = "Head" end + hearth.presentation_step(p); assert(h.count() == 1 and #h.objects == 2) + end +end) +test("failed creation and silent attach failure leave no orphan", function() + for _, failure in ipairs({"fail", "throw", "refuse_attach"}) do + local h = harness(); local p = h.player(); h[failure] = true + hearth.presentation_step(p); assert(h.count() == 0) + h[failure] = false; hearth.presentation_step(p); assert(h.count() == 1) + end +end) +test("multiple owners are bounded independently and shutdown cleans all", function() + local h = harness(); local a, b = h.player("a"), h.player("b") + for _ = 1, 100 do hearth.presentation_step(a); hearth.presentation_step(b) end + assert(h.count() == 2 and #h.objects == 2) + h.shutdowns[1](); h.shutdowns[1](); assert(h.count() == 0) +end) +test("presentation never writes persisted state or session fields", function() + local h = harness(); local p = h.player() + local state, session = hearth.players[p.name], hearth.sessions[p.name] + setmetatable(state, {__newindex = function() error("state write") end}) + setmetatable(session, {__newindex = function() error("session write") end}) + for _, item in ipairs({"hearth:knife", "hearth:spear", ""}) do p.item = item; hearth.presentation_step(p) end + h.leaves[1](p); assert(next(session) == nil and state.food == 85 and state.stamina == 100) + assert(hearth.players[p.name] == state and hearth.sessions[p.name] == session) +end) +print("PASS " .. total .. " presentation tests") diff --git a/tests/recipe_spec.lua b/tests/recipe_spec.lua new file mode 100755 index 0000000..40a46a6 --- /dev/null +++ b/tests/recipe_spec.lua @@ -0,0 +1,570 @@ +-- Isolated journal / recipe / HUD tests; no engine or real player required. +local target = assert(arg[1], "interface.lua path required") +local sibling = target:match("^(.*[/\\])") or "" +local items = { + ["hearth:knife"] = {description="Flint knife"}, ["hearth:loose_stone"] = {description="Loose flint"}, + ["hearth:firestarter"] = {description="Firestarter"}, ["hearth:torch"] = {description="Reed torch"}, + ["hearth:bedroll"] = {description="Bedroll"}, ["hearth:reeds"] = {description="Reeds"}, + ["hearth:planks"] = {description="Planks"}, ["hearth:fire"] = {description="Stone hearth"}, + ["hearth:quartz"] = {description="Quartz crystal"}, ["hearth:window"] = {description="Reed-lattice window"}, + ["hearth:copper_ore"] = {description="Copper ore"}, ["hearth:charcoal"] = {description="Prepared charcoal"}, + ["hearth:copper_ingot"] = {description="Copper ingot"}, ["hearth:copper_charge"] = {description="Copper charge"}, + ["hearth:copper_pick"] = {description="Copper pick"}, ["hearth:sticks"] = {description="Dry sticks"}, + ["hearth:log"] = {description="Timber log"}, ["hearth:stone"] = {description="Weathered stone"}, + ["hearth:alluvium"] = {description="Alluvium"}, +} +local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"} +local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"} +for _, s in ipairs(species) do + items["hearth:"..s.."_log"] = {description=s.." log"} + items["hearth:"..s.."_planks"] = {description=s.." planks"} +end +for _, rock in ipairs(rocks) do + items["hearth:"..rock] = {description=rock.." bedrock"} + items["hearth:"..rock.."_bricks"] = {description=rock.." masonry"} +end +local registered, screens, commands = {}, {}, {} +core = {registered_items=items, is_singleplayer=function() return true end} +function core.get_modpath() return sibling end +function core.formspec_escape(s) + return (tostring(s):gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,")) +end +function core.colorize(color, s) return "\27(c@" .. color .. ")" .. s .. "\27(c@#ffffff)" end +function core.register_on_player_receive_fields(fn) registered.fields=fn end +function core.register_chatcommand(name, def) commands[name]=def end +function core.get_all_craft_recipes(output) + local recipes = { + ["hearth:knife"] = {{method="normal",width=3,items={"hearth:loose_stone","hearth:loose_stone",nil,nil,"hearth:loose_stone"},output="hearth:knife"}}, + ["hearth:firestarter"] = {{method="normal",width=0,items={"hearth:loose_stone","hearth:sticks"},output="hearth:firestarter"}}, + ["hearth:torch"] = {{method="normal",width=0,items={"hearth:sticks","hearth:reeds"},output="hearth:torch 2"}}, + ["hearth:bedroll"] = {{method="normal",width=3,items={"hearth:planks","hearth:planks","hearth:planks","hearth:reeds","hearth:reeds","hearth:reeds"},output="hearth:bedroll"}}, + ["hearth:fire"] = {{method="normal",width=3,items={"hearth:loose_stone"},output="hearth:fire"}}, + ["hearth:copper_charge"] = {{method="normal",width=0,items={"hearth:copper_ore","hearth:charcoal"},output="hearth:copper_charge"}}, + ["hearth:copper_pick"] = {{method="normal",width=3,items={"hearth:copper_ingot","hearth:copper_ingot","hearth:copper_ingot","","hearth:sticks","","","hearth:sticks",""},output="hearth:copper_pick"}}, + ["hearth:window"] = { + {method="normal",width=0,items={"hearth:quartz","hearth:quartz","hearth:quartz"},output="hearth:window"}, + {method="normal",width=3,items={"hearth:planks","hearth:reeds","hearth:planks","hearth:planks","hearth:reeds","hearth:planks"},output="hearth:window"}}, + ["hearth:log"] = {}, ["hearth:planks"] = {}, ["hearth:stone"] = {}, + } + for _, s in ipairs(species) do + recipes["hearth:"..s.."_planks"] = {{method="normal",width=0,items={"hearth:"..s.."_log"},output="hearth:"..s.."_planks 4"}} + table.insert(recipes["hearth:log"], {method="normal",width=0,items={"hearth:"..s.."_log","hearth:"..s.."_log"},output="hearth:log 2"}) + table.insert(recipes["hearth:planks"], {method="normal",width=0,items={"hearth:"..s.."_planks"},output="hearth:planks"}) + end + for _, rock in ipairs(rocks) do + recipes["hearth:"..rock.."_bricks"] = {{method="normal",width=0,items={"hearth:"..rock},output="hearth:"..rock.."_bricks 4"}} + table.insert(recipes["hearth:stone"], {method="normal",width=0,items={"hearth:"..rock,"hearth:"..rock},output="hearth:stone 2"}) + end + return recipes[output] +end +function core.show_formspec(name, form, spec) screens[#screens+1]={name=name,form=form,spec=spec} end +hearth = {sessions={alice={}}, model={location=function() end}} +dofile(target) +local player={get_player_name=function() return "alice" end} +function player:get_pos() return {x=10,y=4,z=-2} end +function player:set_inventory_formspec(spec) self.inventory_spec=spec end +local hud = {} +function player:hud_add(def) hud[#hud+1]=def; return #hud end +function player:hud_change(id, field, value) assert(hud[id],"unknown HUD id"); hud[id][field]=value end +function player:hud_set_flags(flags) self.flags=flags end +function player:hud_set_hotbar_itemcount(count) self.hotbar_count=count end +function player:hud_set_hotbar_image(texture) self.hotbar_texture=texture end +function player:hud_set_hotbar_selected_image(texture) self.hotbar_selected=texture end +function core.get_player_by_name(name) return name=="alice" and player or nil end +local total=0 +local function test(name, fn) + local ok, err=pcall(fn); assert(ok, name..": "..tostring(err)); total=total+1 + print("PASS " .. name) +end +local function latest() return screens[#screens] end +test("search lists craft recipes and elapsed real-time process instructions", function() + hearth.recipes(player,"",1) + local spec=latest().spec + assert(latest().form=="hearth:recipes" and spec:find("Page 1 /",1,true)) + hearth.recipes(player,"knife",1) + assert(latest().spec:find("Flint knife",1,true)) + hearth.recipes(player,"Cooked forage",1) + assert(latest().spec:find("6 real seconds",1,true), latest().spec) + hearth.recipes(player,"Boiled water",1) + assert(latest().spec:find("4 real seconds",1,true), latest().spec) + hearth.recipes(player,"Pottery firing",1) + assert(latest().spec:find("120 real seconds",1,true), latest().spec) + hearth.recipes(player,"River fish trap",1) + assert(latest().spec:find("600 real seconds",1,true), latest().spec) + hearth.recipes(player,"Spring fish run",1) + assert(latest().spec:find("scheduled deadline",1,true), latest().spec) + hearth.recipes(player,"River fish trap",1) + assert(latest().spec:find("600 real seconds",1,true), latest().spec) + hearth.recipes(player,"Spring fish run",1) + assert(latest().spec:find("scheduled deadline",1,true), latest().spec) + hearth.recipes(player,"firestarter",1) + assert(latest().spec:find("Firestarter",1,true)) + hearth.recipes(player,"bedroll",1) + assert(latest().spec:find("Bedroll",1,true)) +end) +test("plain text searches match ingredients and result names", function() + hearth.recipes(player,"Seasonal forage",1) + assert(latest().spec:find("Spring greens",1,true) and latest().spec:find("Autumn mushrooms",1,true)) + hearth.recipes(player,"loose_stone",1) + assert(latest().spec:find("Flint knife",1,true)) + hearth.recipes(player,"torch",1) + assert(latest().spec:find("Reed torch",1,true)) +end) +test("search is bounded and formspec escaped, unknown term reports no matches", function() + local query=string.rep("x",80).."];button[0,0;" + hearth.recipes(player,query,1) + local spec=latest().spec + assert(spec:find("x",1,true) and not spec:find(query,1,true)) + hearth.recipes(player,"no-such-item",1) + assert(latest().spec:find("No recipe or process matches",1,true)) +end) +test("guide navigation and chat command enter the real recipe index", function() + assert(commands.recipes and commands.recipes.func("alice","reeds")) + assert(latest().form=="hearth:recipes" and latest().spec:find("Reed torch",1,true)) + registered.fields(player,"hearth:guide",{recipe_index=true}) + assert(latest().form=="hearth:recipes") + registered.fields(player,"hearth:recipes",{close=true}) + assert(latest().form=="hearth:guide") +end) +test("all journal surfaces share Mocha controls, lists and botanical dividers", function() + local function themed(spec) + for _,color in ipairs({"#1e1e2e","#181825","#292940","#3c3d56","#cdd6f4","#cba6f7","#94e2d5"}) do + assert(spec:find(color,1,true), "missing palette anchor "..color) + end + assert(spec:find("style_type[button:hovered,button_exit:hovered",1,true)) + assert(spec:find("bgimg="..core.formspec_escape("[fill:1x1:#ffffff"),1,true),"borderless buttons need visible backing") + assert(spec:find("style_type[field,textarea;",1,true)) + assert(spec:find("listcolors[#292940;#3c3d56;#3c3d56;#181825;#cdd6f4]",1,true)) + assert(spec:find("0.16,0.07;#94e2d5]",1,true),"pressed-sprig divider missing") + end + hearth.guide(player,1); themed(latest().spec) + hearth.recipes(player,"",1); themed(latest().spec) + hearth.inventory(player); themed(player.inventory_spec) + hearth.location_form(player); themed(latest().spec) +end) +test("guide keeps eight pages and every sidebar button inside 14 by 10", function() + for page=1,8 do + hearth.guide(player,page) + local spec,count=latest().spec,0 + assert(spec:find("size[14,10]",1,true)) + assert(spec:find(string.format("%02d / 08",page),1,true)) + for x,y,w,h in spec:gmatch("button%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);page_%d+;") do + assert(tonumber(x)+tonumber(w)<=3.3 and tonumber(y)+tonumber(h)<8.5) + count=count+1 + end + assert(count==8 and not spec:find("page_9",1,true)) + end + hearth.guide(player,6) + assert(latest().spec:find("Living ecosystems",1,true)) + assert(latest().spec:find("Dynamic Life",1,true)) + assert(not latest().spec:find("Riverward field season",1,true)) + assert(latest().spec:find("no offline catch-up",1,true)) + hearth.guide(player,0/0); assert(hearth.sessions.alice.page==1) + hearth.guide(player,math.huge); assert(hearth.sessions.alice.page==8) +end) +test("resource searches show quartz windows copper charges picks and process contract", function() + for _, query in ipairs({"quartz", "hearth:quartz"}) do + hearth.recipes(player,query,1) + assert(latest().spec:find("Reed-lattice window <- Quartz crystal + Quartz crystal + Quartz crystal",1,true)) + end + hearth.recipes(player,"window",1) + assert(latest().spec:find("Planks + Planks + Planks + Planks + Reeds + Reeds",1,true)) + hearth.recipes(player,"copper_ore",1) + assert(latest().spec:find("Copper charge <- Copper ore + Prepared charcoal",1,true)) + hearth.recipes(player,"copper_pick",1) + assert(latest().spec:find("Copper pick <- Copper ingot + Copper ingot + Copper ingot + Dry sticks + Dry sticks",1,true)) + hearth.recipes(player,"Copper smelting",1) + for _, text in ipairs({"45 real seconds", "30 fuel seconds", "75 fuel seconds", "one hearth:copper_ingot", "full inventory"}) do + assert(latest().spec:find(text,1,true),text) + end + hearth.recipes(player,"Fire fuel",1) + for _, text in ipairs({"180", "90", "240", "600", "nonrenewable", "not consumed"}) do + assert(latest().spec:find(text,1,true),text) + end +end) +for _, s in ipairs(species) do + test(s.." search discovers matching planks and canonical timber conversions", function() + hearth.recipes(player,"hearth:"..s.."_log",1) + assert(latest().spec:find("4 x "..s.." planks <- "..s.." log",1,true)) + assert(latest().spec:find("2 x Timber log <- "..s.." log + "..s.." log",1,true)) + assert(not latest().spec:find("\nTimber log <- "..s.." log",1,true)) + hearth.recipes(player,"hearth:"..s.."_planks",1) + assert(latest().spec:find("Planks <- "..s.." planks",1,true)) + end) +end +for _, rock in ipairs(rocks) do + test(rock.." search discovers four bricks and canonical stone", function() + hearth.recipes(player,"hearth:"..rock,1) + assert(latest().spec:find("4 x "..rock.." masonry <- "..rock.." bedrock",1,true)) + assert(latest().spec:find("2 x Weathered stone <- "..rock.." bedrock + "..rock.." bedrock",1,true)) + assert(not latest().spec:find("\nWeathered stone <- "..rock.." bedrock",1,true)) + end) +end +test("regional guide covers ten biomes conversions and operator-controlled old-world seams", function() + hearth.guide(player,8) + for _, text in ipairs({"Temperate meadow", "oak woodland", "birch forest", "pine taiga", "Wet forest", "floodplain", + "Dry scrub", "alpine meadow", "rocky peaks", "riverbank", "canonical log", "45 real seconds", "240 seconds", + "fresh worlds only", "unexplored old-world boundaries", "Operators must explicitly choose", "climate HUD remains shared"}) do + assert(latest().spec:find(text,1,true),text) + end + for _, text in ipairs({"two matching species logs to two canonical logs", "two matching rocks convert to two canonical stones", + "separate crafting slots", "no single-log or single-rock canonical conversion"}) do + assert(latest().spec:find(text,1,true),text) + end + registered.fields(player,"hearth:guide",{page_8=true}) + assert(hearth.sessions.alice.page==8) + hearth.guide(player,6) + for _,text in ipairs({"Birch pioneers", "Evergreen pine and spruce", "alluvium/peat", "two nutrient units", "Nitrogen-fixer policy", + "not a separate fertilizer process", "not fully simulated hydrology", "no offline catch-up"}) do + assert(latest().spec:find(text,1,true),text) + end + hearth.recipes(player,"Species timber",1) + assert(latest().spec:find("convert species logs in matching pairs first",1,true)) + assert(latest().spec:find("two canonical hearth:log",1,true)) + hearth.recipes(player,"Regional geology",1) + assert(latest().spec:find("two matching rocks to two canonical stones",1,true)) + hearth.recipes(player,"Finite nitrogen",1) + assert(latest().spec:find("at most two nutrient units",1,true)) +end) + +test("inventory retains crafting, preview, ten by four storage and transfer rings", function() + hearth.inventory(player) + assert(player.inventory_spec:find("style_type[list;size=1,1;spacing=0.12,0.12]",1,true)) + assert(0.6+10+9*0.12<12.4 and 5.7+4+3*0.12<11.3,"inventory grid must fit the paper") + for _,element in ipairs({"list[current_player;craft;0.6,1.8;3,3;]", + "list[current_player;craftpreview;4.8,2.9;1,1;]", "list[current_player;main;0.6,5.7;10,4;]", + "listring[current_player;main]listring[current_player;craft]", "field_guide;Open field notes]"}) do + assert(player.inventory_spec:find(element,1,true),element) + end + registered.fields(player,"",{field_guide=true}); assert(latest().form=="hearth:guide") +end) +test("recipe query escaping, literal matching and pagination remain bounded", function() + hearth.recipes(player,"];button[0,0;1,1;attack;Injected]",0/0) + assert(latest().spec:find(core.formspec_escape("];button[0,0;1,1;attack;Injected]"),1,true)) + assert(not latest().spec:find(";attack;Injected]",1,true)) + hearth.recipes(player,"%[.*",1) + assert(latest().spec:find("No recipe or process matches",1,true)) + hearth.recipes(player,"\n\tfood",1); assert(hearth.sessions.alice.recipe_query==" food") + hearth.recipes(player,"",1) + registered.fields(player,"hearth:recipes",{next=true}); assert(hearth.sessions.alice.recipe_page==2) + registered.fields(player,"hearth:recipes",{previous=true}); assert(hearth.sessions.alice.recipe_page==1) + registered.fields(player,"hearth:recipes",{key_enter=true,query="torch"}) + assert(latest().spec:find("Reed torch",1,true)) +end) +test("ecosystem command and guide button only read one habitat and status snapshot", function() + local reads, snapshots=0,0 + local function forbidden() error("observations must never mutate or load the map") end + for _,name in ipairs({"set_node","swap_node","emerge_area","forceload_block","add_entity","get_objects_inside_radius","get_meta"}) do + core[name]=forbidden + end + hearth.succession={habitat=function(pos) + assert(pos.x==10 and pos.y==4 and pos.z==-2); reads=reads+1 + return {grass=3,flowers=2,cover=4,water=1,canopy=1.5,moisture=0.65,light=10,loaded=true, + ground="alluvium",pollination=7,nutrients=2} + end,step=forbidden,forage=forbidden,deposit=forbidden} + hearth.wildlife={status=function() + snapshots=snapshots+1 + return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6} + end} + assert(commands.ecosystem.func("alice")) + assert(reads==1 and snapshots==1 and latest().form=="hearth:ecosystem") + for _,text in ipairs({"Natural grass cover: 3", "Flower-support cover: 2", "Shrub / fern / aquatic / tree cover: 4", + "Moisture: 65%", "Hares: 3", "Deer: 2", "Foxes: 1", "Active total: 6 / 32 globally", + "not a local census", "Loaded sample", "No terrain is loaded", "Ground: Alluvium", + "Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins"}) do + assert(latest().spec:find(text,1,true),text) + end + registered.fields(player,"hearth:guide",{ecosystem_survey=true}) + assert(reads==2 and snapshots==2 and latest().form=="hearth:ecosystem") + registered.fields(player,"hearth:ecosystem",{refresh=true}) + assert(reads==3 and snapshots==3) + registered.fields(player,"hearth:ecosystem",{field_notes=true}) + assert(hearth.sessions.alice.page==6 and reads==3 and snapshots==3) + assert(not commands.ecosystem.func("offline")) +end) +test("survey discloses unloaded and unavailable data without changing sessions or world", function() + hearth.succession.habitat=function() return {loaded=false,grass=1} end + hearth.ecosystem(player) + assert(latest().spec:find(core.formspec_escape("Incomplete sample / unloaded cells are unknown, not empty"),1,true)) + assert(latest().spec:find("Natural grass cover: 1",1,true)) + assert(latest().spec:find("Ground: unknown",1,true)) + assert(latest().spec:find("Pollinator-support plants: unavailable",1,true)) + assert(latest().spec:find("Nutrient reserve: unavailable",1,true)) + hearth.succession.habitat=function() + return {loaded=true,ground="];button[0,0;1,1;attack;Injected]",pollination=0/0,nutrients=math.huge} + end + hearth.ecosystem(player) + assert(not latest().spec:find(";attack;Injected]",1,true)) + assert(latest().spec:find(core.formspec_escape("hearth:];button[0,0;1,1;attack;Injected]"),1,true)) + assert(latest().spec:find("Pollinator-support plants: 0",1,true)) + assert(latest().spec:find("Nutrient reserve: 0 / 8",1,true)) + hearth.succession,hearth.wildlife=nil,nil + hearth.ecosystem(player) + assert(latest().spec:find("Succession observations are not available",1,true)) + assert(latest().spec:find("Wildlife observations are not available",1,true)) + local session,count=hearth.sessions.alice,#screens + hearth.sessions.alice=nil + assert(not commands.ecosystem.func("alice")) + registered.fields(player,"hearth:guide",{ecosystem_survey=true}) + registered.fields(player,"hearth:ecosystem",{refresh=true}) + assert(#screens==count) + hearth.sessions.alice=session +end) +test("botanical guide tolerates absent empty and partial optional catalogue APIs", function() + for _, mock in ipairs({false, {}, {list=function() return {} end}}) do + hearth.botany=mock or nil + hearth.botanical_guide(player,"",math.huge) + assert(latest().form=="hearth:botany" and latest().spec:find("catalogue unavailable",1,true)) + assert(hearth.sessions.alice.botany_page==1) + hearth.guide(player,6) + assert(latest().spec:find("catalogue unavailable",1,true)) + end +end) +-- Actual catalogue factory/methods, not an invented list of test species. +local botany_path = target:match("^(.*[/\\])") or "" +local actual_botany = dofile(botany_path .. "botany.lua")(hearth,{}) +test("actual botanical catalogue displays eighty species in eight ten-row pages", function() + hearth.botany=actual_botany + local records, groups = actual_botany.list(), {} + assert(#records==80) + for page=1,8 do + hearth.botanical_guide(player,"",page) + local spec,rows=latest().spec,0 + assert(spec:find("Page "..page.." / 8 / 80 matches",1,true)) + for _ in spec:gmatch("Habitat:") do rows=rows+1 end + assert(rows==10,"each page must contain ten species rows") + for i=(page-1)*10+1,page*10 do + local r=records[i]; groups[r.group]=(groups[r.group] or 0)+1 + assert(spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) + assert(spec:find("Active seasons:",1,true) and spec:find("Roles / conditional policy or descriptors:",1,true)) + end + end + local count=0; for _,n in pairs(groups) do assert(n==10); count=count+1 end; assert(count==8) + hearth.guide(player,6) + for _,r in ipairs(records) do assert(latest().spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) end +end) +test("botanical searches use real scientific names niches seasons and honest role labels", function() + for _,query in ipairs({"Fagus sylvatica", "beech"}) do + hearth.botanical_guide(player,query,1) + assert(latest().spec:find("European beech (Fagus sylvatica)",1,true)) + end + hearth.botanical_guide(player,"fly_agaric",1) + for _,text in ipairs({"Amanita muscaria", "toxic flag: YES", "Summer", "Autumn", "hosts:", "no catalogue food harvest"}) do + assert(latest().spec:find(text,1,true),text) + end + hearth.botanical_guide(player,"hornwort",1) + for _,text in ipairs({"Ceratophyllum demersum", "submerged", "water depth 1-4", "NOT food permission", "habitat engineering (descriptive tag)"}) do + assert(latest().spec:find(core.formspec_escape(text),1,true),text) + end + hearth.botanical_guide(player,"bracken",1) + assert(latest().spec:find("descriptor only",1,true) and latest().spec:find("no killing",1,true)) + hearth.botanical_guide(player,"honey_fungus",1) + assert(latest().spec:find("no plant damage",1,true)) + hearth.botanical_guide(player,"bilberry",1) + assert(latest().spec:find("seasonal game berry harvest",1,true)) + assert(latest().spec:find("NOT a real-world forage guide",1,true)) +end) +test("botanical query escaping literal search and finite pagination bounds", function() + local query="];button[0,0;1,1;attack;Injected]" + hearth.botanical_guide(player,query,0/0) + assert(latest().spec:find(core.formspec_escape(query),1,true)) + assert(not latest().spec:find(";attack;Injected]",1,true)) + hearth.botanical_guide(player,"%[.*",1) + assert(latest().spec:find("No species matches that literal search",1,true)) + hearth.botanical_guide(player,string.rep("x",70),999) + assert(#hearth.sessions.alice.botany_query==48 and hearth.sessions.alice.botany_page==1) + hearth.botanical_guide(player,"\n\tlichen",-math.huge) + assert(hearth.sessions.alice.botany_query==" lichen" and hearth.sessions.alice.botany_page==1) + for _,p in ipairs({0/0,-8,"bad",0}) do hearth.botanical_guide(player,"",p); assert(hearth.sessions.alice.botany_page==1) end + hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==8) + local record=actual_botany.list()[1]; record.common=query + hearth.botany={list=function() return {record} end} + hearth.botanical_guide(player,"",1) + assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true)) + hearth.botany=actual_botany +end) +test("botanical commands buttons pagination and exits route without world access", function() + assert(commands.botany.func("alice","Quercus robur")) + assert(latest().form=="hearth:botany" and latest().spec:find("Pedunculate oak",1,true)) + assert(not commands.botany.func("offline","")) + registered.fields(player,"hearth:guide",{botany_index=true}) + assert(latest().form=="hearth:botany") + registered.fields(player,"hearth:botany",{next=true,query="ignored"}); assert(hearth.sessions.alice.botany_page==2) + registered.fields(player,"hearth:botany",{previous=true}); assert(hearth.sessions.alice.botany_page==1) + registered.fields(player,"hearth:botany",{key_enter=true,query="Picea abies"}) + assert(latest().spec:find("Norway spruce",1,true)) + registered.fields(player,"hearth:botany",{search=true,query="Betula pendula"}) + assert(latest().spec:find("Silver birch",1,true) and hearth.sessions.alice.botany_page==1) + registered.fields(player,"hearth:botany",{field_notes=true}); assert(latest().form=="hearth:guide" and hearth.sessions.alice.page==6) + registered.fields(player,"hearth:ecosystem",{botany_index=true}); assert(latest().form=="hearth:botany") + registered.fields(player,"hearth:botany",{ecosystem_survey=true}); assert(latest().form=="hearth:ecosystem") + local session,count=hearth.sessions.alice,#screens; hearth.sessions.alice=nil + assert(not commands.botany.func("alice","")) + hearth.botanical_guide(player,"",1) + registered.fields(player,"hearth:botany",{next=true}) + registered.fields(player,"hearth:ecosystem",{botany_index=true}) + registered.fields(player,"hearth:guide",{botany_index=true}) + assert(#screens==count); hearth.sessions.alice=session +end) +test("botanical navigation fits existing panels and does not add a ninth journal page", function() + hearth.guide(player,6) + assert(latest().spec:find("botany_index;Botanical guide",1,true) and not latest().spec:find("page_9",1,true)) + local guide=latest().spec + hearth.ecosystem(player); local survey=latest().spec + hearth.botanical_guide(player,"",4); local browser=latest().spec + for _,spec in ipairs({guide,survey,browser}) do + local right=0 + for x,y,w,h in spec:gmatch("button[%w_]*%[([%d.]+),([%d.]+);([%d.]+),([%d.]+);") do + x,y,w,h=tonumber(x),tonumber(y),tonumber(w),tonumber(h) + assert(x+w<=14 and y+h<=10) + if y==8.85 then assert(x>=right,"footer buttons overlap"); right=x+w end + end + end +end) +test("survey exposes actual optional botanical habitat fields without inventing counts", function() + hearth.succession={habitat=function() return {loaded=true,ground="peat",grass=2,flowers=1,cover=4, + near_water=8,water=2,aquatic="submerged",ph=4.5,nursery=3} end} + hearth.ecosystem(player) + for _,text in ipairs({"Nearby water cells: 8", "Water depth: 2", "Game substrate pH: 4.5", "Nursery-support plants: 3", "Aquatic niche: submerged"}) do + assert(latest().spec:find(text,1,true),text) + end + hearth.succession.habitat=function() return {loaded=false} end + hearth.ecosystem(player) + for _,text in ipairs({"Game substrate pH: unavailable", "Nursery-support plants: unavailable", "Water depth: unavailable", "Aquatic niche: unavailable"}) do + assert(latest().spec:find(text,1,true),text) + end +end) +test("fallen log guidance matches existing axe-only sideways cube behavior", function() + hearth.recipes(player,"Fallen logs",1) + for _,text in ipairs({"sideways", "full-cube", "axe", "one canonical hearth:log", "hands do not yield sticks"}) do + assert(latest().spec:find(text,1,true),text) + end +end) +test("location stays local-only, bounded and escaped", function() + local writes, checks=0,0 + hearth.set_location=function(lat,lon) assert(lat==45 and lon==0); writes=writes+1 end + hearth.model.location=function(lat,lon) checks=checks+1; return lat=="45" and lon=="0" end + core.is_singleplayer=function() return false end + registered.fields(player,"hearth:location",{save_location=true,latitude="45",longitude="0"}) + assert(writes==0 and checks==0 and not commands.location.func("alice")) + core.is_singleplayer=function() return true end + for _,value in ipairs({string.rep("1",17), "];button[0,0;", 42}) do + registered.fields(player,"hearth:location",{save_location=true,latitude=value,longitude="0"}) + assert(writes==0 and latest().form=="hearth:location") + end + hearth.location_form(player,"];button[0,0;1,1;attack;Injected]") + assert(latest().spec:find(core.formspec_escape("];button[0,0;1,1;attack;Injected]"),1,true)) + assert(not latest().spec:find(";attack;Injected]",1,true)) + registered.fields(player,"hearth:location",{save_location=true,latitude="45",longitude="0"}) + assert(writes==1 and latest().form=="hearth:guide") +end) +test("HUD preserves runtime keys, tiles ten hotbar slots and uses readable graphical reserves", function() + hearth.make_hud(player) + local ids=hearth.sessions.alice.hud + for _,key in ipairs({"title","clock","needs","context","help"}) do assert(hud[ids[key]],key) end + assert(player.hotbar_count==10 and player.flags.minimap==false) + local expected="[combine:320x32" + for i=0,9 do expected=expected..":"..(i*32)..",0=hearth_hotbar.png" end + assert(player.hotbar_texture==expected and player.hotbar_selected=="hearth_selected.png") + hearth.clock={hour=13.5,season="Autumn",date="2026-11-02"} + hearth.players={alice={warmth=10,water=35,food=75,stamina=100,wet=30}} + hearth.update_hud(player,{temperature=8,shelter=1,rain=true,heat=1,smoke=true}) + assert(hud[ids.clock].text:find("Solar 13:30",1,true)) + assert(hud[ids.warmth_label].text=="WARMTH 10%" and hud[ids.warmth_label].number==0xf38ba8) + assert(hud[ids.water_label].number==0xf9e2af and hud[ids.food_label].number==0x94e2d5) + assert(hud[ids.food_fill].text=="[fill:165x6:#94e2d5") + assert(hud[ids.context].text:find("SMOKE / open a wall or roof vent",1,true)) + assert(hud[ids.context].number==0xf38ba8) + hearth.players.alice.food=0; hearth.players.alice.stamina=200 + hearth.update_hud(player,{temperature=8,shelter=0,roof=true,heat=0}) + assert(hud[ids.food_fill].text=="[fill:1x6:#18182500") + assert(hud[ids.stamina_label].text=="STAMINA 100%") +end) +test("HUD accepts legacy mocks without optional hotbar image setters", function() + local image,selected=player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image + player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=nil,nil + hearth.make_hud(player) + assert(hearth.sessions.alice.hud.needs) + player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=image,selected +end) +assert(total==38,"The original 38 recipe/journal checks must remain intact") +test("inspect command uses exact species and rejects absent session unknown and URL input",function() + hearth.botany=actual_botany + hearth.wiki_articles=dofile(sibling.."wiki_articles.lua") + hearth.species_effects=dofile(sibling.."species_effects.lua")(hearth,{}) + assert(commands.inspect and not commands.inspect.privs) + assert(commands.inspect.func("alice","Quercus robur")) + assert(latest().form=="hearth:species" and latest().spec:find("Wikipedia excerpt (offline)",1,true)) + assert(not commands.inspect.func("alice","https://en.wikipedia.org/wiki/Oak")) + assert(not commands.inspect.func("alice","Quercus")) + assert(not commands.inspect.func("offline","oak")) +end) +test("botanical article selection uses trusted current-page numeric rows only",function() + hearth.botanical_guide(player,"",2) + assert(latest().spec:find("tooltip[species_choice;Species article]",1,true)) + registered.fields(player,"hearth:botany",{species_choice="2",species_article=true,id="oak"}) + assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="blackberry") + for _,v in ipairs({"oak","0","11","2.5","1e0","999",{},string.rep("1",1000)}) do + hearth.botanical_guide(player,"",2); local n=#screens + registered.fields(player,"hearth:botany",{species_choice=v,species_article=true}) + assert(#screens==n,"forged choice opened article") + end + hearth.botanical_guide(player,"no-such-species",1); local n=#screens + registered.fields(player,"hearth:botany",{species_article=true,id="oak"}); assert(#screens==n) +end) +test("article navigation retains server species and returns to selected botanical page",function() + hearth.botanical_guide(player,"",3) + registered.fields(player,"hearth:botany",{species_choice="2",species_article=true}) + local id=hearth.sessions.alice.species_reader.id + registered.fields(player,"hearth:species",{reader_tab="2",id="pine",url="bad",text="edited"}) + assert(hearth.sessions.alice.species_reader.id==id and latest().spec:find("SIMULATED",1,true)) + registered.fields(player,"hearth:species",{reader_botany=true}) + assert(latest().form=="hearth:botany" and hearth.sessions.alice.botany_page==3) + registered.fields(player,"hearth:botany",{species_choice="1",species_article=true}) + registered.fields(player,"hearth:species",{quit=true}); local n=#screens + registered.fields(player,"hearth:species",{reader_tab="1"}); assert(#screens==n) +end) +test("fieldnotes inspect target uses loaded eye ray without changing other actions",function() + core.get_node_or_nil=function() return {name="hearth:oak_leaves"} end + function player:get_properties() return {eye_height=1.6} end + function player:get_look_dir() return {x=0,y=0,z=1} end + core.raycast=function(start,finish,objects,liquids) + assert(start.y==5.6 and finish.z==4 and not objects and not liquids) + local used=false + return function() + if used then return nil end; used=true + return {type="node",under={x=10,y=6,z=0},intersection_point={x=10,y=5.6,z=0}} + end + end + hearth.guide(player,6); assert(latest().spec:find("inspect_target;Inspect target",1,true)) + registered.fields(player,"hearth:guide",{inspect_target=true}) + assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="oak") + assert(commands.inspect.func("alice","")) + core.get_node_or_nil=function() return nil end + assert(not commands.inspect.func("alice","")) +end) +test("dropdown labels escape cached IDs and absent sessions reject article fields",function() + local r=actual_botany.list()[1]; r.id="];button[0,0;1,1;attack;Injected]" + hearth.botany={list=function() return {r} end} + hearth.botanical_guide(player,"",1) + assert(latest().spec:find(core.formspec_escape(r.id),1,true) and not latest().spec:find(";attack;Injected]",1,true)) + hearth.botany=actual_botany + local s,n=hearth.sessions.alice,#screens; hearth.sessions.alice=nil + registered.fields(player,"hearth:botany",{species_choice="1",species_article=true}) + registered.fields(player,"hearth:species",{reader_tab="2"}) + assert(not commands.inspect.func("alice","oak") and #screens==n) + hearth.sessions.alice=s +end) +test("inventory appearance entry opens only the requesting player's creator", function() + local called = 0 + hearth.character = {show = function(p) assert(p == player); called = called + 1 end} + hearth.inventory(player) + assert(player.inventory_spec:find("character_creator;Character appearance]",1,true)) + assert(player.inventory_spec:find("Cosmetic only",1,true)) + registered.fields(player,"",{character_creator=true}); assert(called == 1) + registered.fields(player,"hearth:recipes",{character_creator=true}); assert(called == 1) + hearth.character = nil + registered.fields(player,"",{character_creator=true}); assert(called == 1) +end) +assert(total==44,"Unexpected recipe/journal/character integration count") +print("PASS "..total.." recipe discovery tests") diff --git a/tests/recovery_probe.lua b/tests/recovery_probe.lua new file mode 100755 index 0000000..62bbd25 --- /dev/null +++ b/tests/recovery_probe.lua @@ -0,0 +1,303 @@ +-- Private two-boot recovery integration probe. Executed ONLY in restored copies. +-- "capture" runs in the hearth mod context before its real init; "probe" runs +-- as a dependent worldmod after startup. No engine globals or live players mocked. +return function(mode) +local C = core +local native_assert = assert +local verified_assertions = 0 +local failure_label = "checkpoint-probe-start" +-- Assertion labels below are source-code constants, never engine/save input. +-- Never expose pcall errors, original records or parser diagnostics. +local function assert(value, label) + label = label or "Unlabelled engine serialization" + if not value then + failure_label = "checkpoint-" .. label:lower():gsub("[^a-z]+", "-"):gsub("%-$", ""):sub(1, 96) + C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) + return native_assert(value, failure_label) + end + verified_assertions = verified_assertions + 1 + return value +end +local phase = C.settings:get("hearth_recovery_phase") +local token = C.settings:get("hearth_recovery_token") +local root = C.get_worldpath() +local kinds = {"claims", "homes", "access", "location", "spawn"} +assert(phase == "seed" or phase == "restart", "Invalid recovery phase") +assert(token and token:match("^[%x%-]+$"), "Invalid recovery nonce") + +local function equal(a, b) + if type(a) ~= type(b) then return false end + if type(a) ~= "table" then return a == b end + for k, v in pairs(a) do if not equal(v, b[k]) then return false end end + for k in pairs(b) do if a[k] == nil then return false end end + return true +end +local function decode(raw) + local value = C.parse_json(raw, nil, true) + assert(type(value) == "table", "Invalid recovery JSON") + return value +end +local function read(name) + local file = assert(io.open(root .. "/" .. name, "rb"), "Missing recovery evidence") + local raw = file:read("*a") + file:close() + return decode(raw) +end +local function write(name, value) + assert(C.safe_file_write(root .. "/" .. name, assert(C.write_json(value))), "Evidence write failed") +end + +if mode == "capture" then + assert(C.get_current_modname() == "hearth", "Capture requires real hearth storage context") + if phase == "seed" then + local storage = C.get_mod_storage() + local expected = {format = 1, token = token, keys = {}, backups = {}} + for _, kind in ipairs(kinds) do + expected.keys[kind] = storage:get_string(kind) + expected.backups[kind] = storage:get_string("m0_backup:" .. kind) + end + write("m0-recovery-expected.json", expected) + else + local expected = read("m0-recovery-expected.json") + assert(expected.format == 1 and expected.token == token, "Original evidence missing on restart") + end + return +end +assert(mode == "probe", "Invalid recovery operation") +local finished, ready = false, false +local report = {format = 1, phase = phase, token = token, existingKeys = 0, legacyKeys = 0} +-- The game's mapgen_limit is 512. Stay inside complete generatable chunks, +-- high above terrain; refuse occupied space rather than overwrite any copy data. +local fixture_pos = {x = 320, y = 320, z = 320} +local expired_pos = {x = 321, y = 320, z = 320} +local live_pos = {x = 322, y = 320, z = 320} + +local function no_clients() + assert(#C.get_connected_players() == 0, "Recovery forbids live clients") +end +local function fail() + if finished then return end + finished = true + -- Never include assertion/parser strings: original records may be private. + C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) + C.request_shutdown("Private recovery failed", false, 0) +end +local function guard(fn) + if finished then return end + local ok = pcall(fn) + if not ok then fail() end +end +local function envelope(kind, raw) + local parsed = decode(raw) + assert(parsed.schema == 1 and parsed.kind == kind and type(parsed.data) == "table", "Missing schema-1 envelope") + local result, migrated = hearth.schema.decode(kind, parsed) + assert(migrated == false and equal(result, parsed), "Envelope did not roundtrip") + return parsed.data +end + +local function world_check() + failure_label = "checkpoint-world-check" + assert(C.get_game_info().id == C.settings:get("hearth_recovery_gameid"), "Disposable game selection mismatch") + assert(type(hearth) == "table" and type(hearth.persistence) == "table", "Persistence not wired") + assert(type(hearth.schema) == "table" and type(hearth.storage) == "userdata", "Real storage/schema missing") + assert(not C.get_modpath("hearth_smoke"), "Legacy smoke mod loaded") + assert(C.settings:get("hearth_environment") == "staging", "Not a staging boot") + assert(C.settings:get("bind_address") == "127.0.0.1" and C.settings:get("max_users") == "0", "Client isolation missing") + assert(C.settings:get_bool("secure.enable_security") == true, "Mod security disabled") + no_clients() + local expected = read("m0-recovery-expected.json") + assert(expected.format == 1 and expected.token == token, "Wrong original evidence") + local saved = {} + for _, kind in ipairs(kinds) do + local raw, before_backup = expected.keys[kind], expected.backups[kind] + assert(type(raw) == "string" and type(before_backup) == "string", "Missing pre-migration bytes") + local backup_key = "m0_backup:" .. kind + local backup = hearth.storage:get_string(backup_key) + if raw ~= "" then + report.existingKeys = report.existingKeys + 1 + local normalized, legacy = hearth.schema.decode(kind, decode(raw)) + if legacy then report.legacyKeys = report.legacyKeys + 1 end + -- Inspect BEFORE load_world: a lazy probe must not hide missing startup migration. + local actual = envelope(kind, hearth.storage:get_string(kind)) + assert(equal(actual, normalized.data), "Stored original data changed during migration") + local expected_backup = before_backup ~= "" and before_backup or (legacy and raw or "") + assert(backup == expected_backup, "Original backup bytes changed") + if legacy then + local recovered, was_legacy = hearth.schema.decode(kind, decode(backup)) + assert(was_legacy and equal(recovered, normalized), "Legacy backup cannot recover original data") + end + saved[kind] = hearth.persistence.load_world(kind) + assert(equal(saved[kind], normalized.data), "Adapter changed original world data") + hearth.persistence.save_world(kind, saved[kind]) + assert(hearth.storage:get_string(backup_key) == backup, "Save replaced immutable backup") + else + assert(backup == before_backup, "Missing record created a backup") + end + end + assert(report.existingKeys > 0 and report.legacyKeys > 0, "Not an existing legacy save") + assert(saved.spawn, "Original saved spawn required") + assert(equal(hearth.persistence.load_world("spawn"), saved.spawn), "Saved spawn changed") + local claims, homes = saved.claims or {}, saved.homes or {} + assert(type(hearth.claim_allowed) == "function", "Claims protection missing") + assert(type(hearth.home_for) == "function", "Home lookup missing") + for owner, home in pairs(homes) do + local exposed = hearth.home_for(owner) + assert(equal(exposed, home), "Persisted home lookup changed") + exposed.x = exposed.x + 1 + assert(equal(hearth.home_for(owner), home), "Home lookup exposed mutable storage") + end + report.homes = true + local outsider = "__hearth_recovery_outsider" + while claims[outsider] do outsider = outsider .. "_" end + report.claimCount = 0 + for owner, pos in pairs(claims) do + report.claimCount = report.claimCount + 1 + assert(not hearth.claim_allowed(pos, outsider) and C.is_protected(pos, outsider), "Original claim lost protection") + local allowed = true + for other, center in pairs(claims) do + if other ~= owner and math.abs(pos.x - center.x) <= 12 and math.abs(pos.z - center.z) <= 12 + and math.abs(pos.y - center.y) <= 24 then allowed = false end + end + assert(hearth.claim_allowed(pos, owner) == allowed, "Claim ownership changed") + end + report.claims = true + return saved.spawn +end + +local original_needs = {food = 72, water = 63, warmth = 54, wet = 9, stamina = 81} +local saved_needs = {food = 61, water = 52, warmth = 43, wet = 8, stamina = 79} +local function fixture_check() + failure_label = "checkpoint-fixture-check" + local meta = C.get_meta(fixture_pos) + -- The deterministic synthetic player only adapts get_meta; its backing store + -- is REAL engine node metadata, and the adapter uses REAL engine JSON. + local player = {get_meta = function() return meta end} + if phase == "seed" then + for _, pos in ipairs({fixture_pos, expired_pos, live_pos}) do + assert(C.get_node(pos).name == "air", "Isolated fixture space occupied") + end + C.set_node(fixture_pos, {name = "hearth:wall", param2 = 2}) + meta = C.get_meta(fixture_pos) + local original_raw = assert(C.write_json(original_needs)) + meta:set_string("hearth:needs", original_raw) + assert(equal(hearth.persistence.load_player(player), original_needs), "Real-JSON player migration failed") + assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player legacy bytes not backed up") + hearth.persistence.save_player(player, saved_needs) + assert(equal(envelope("needs", meta:get_string("hearth:needs")), saved_needs), "Player fixture not saved") + assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player save overwrote backup") + meta:set_string("recovery_token", token) + local inv = meta:get_inventory() + inv:set_size("main", 4) + local stack = ItemStack("hearth:axe") + stack:set_wear(1379) + stack:get_meta():set_string("recovery", token) + inv:set_stack("main", 1, stack) + inv:set_stack("main", 2, ItemStack("hearth:berries 3")) + local now = os.time() + local fixture = {format = 1, token = token, originalNeeds = original_raw, + liveDeadline = now + 300, expiredDeadline = now - 60, + stack = stack:to_string(), berries = inv:get_stack("main", 2):to_string()} + for _, pos in ipairs({expired_pos, live_pos}) do C.set_node(pos, {name = "hearth:fire_lit"}) end + C.get_meta(expired_pos):set_string("fuel_until", tostring(fixture.expiredDeadline)) + C.get_meta(live_pos):set_string("fuel_until", tostring(fixture.liveDeadline)) + hearth.storage:set_string("m0_recovery:marker", token) + write("m0-recovery-fixture.json", fixture) + else + local fixture = read("m0-recovery-fixture.json") + assert(fixture.format == 1 and fixture.token == token, "Fixture evidence not restored") + assert(hearth.storage:get_string("m0_recovery:marker") == token, "Real mod storage marker lost") + local node = C.get_node(fixture_pos) + assert(node.name == "hearth:wall" and node.param2 == 2, "Map node did not survive backup/restore") + assert(meta:get_string("recovery_token") == token, "Real node metadata lost") + assert(equal(hearth.persistence.load_player(player), saved_needs), "Player fixture did not survive restart") + assert(meta:get_string("hearth:m0_backup:needs") == fixture.originalNeeds, "Player immutable backup changed on restart") + assert(equal(decode(fixture.originalNeeds), original_needs), "Player original fixture changed") + local inv = meta:get_inventory() + assert(inv:get_size("main") == 4 and inv:get_stack("main", 1):to_string() == fixture.stack + and inv:get_stack("main", 2):to_string() == fixture.berries + and inv:get_stack("main", 3):is_empty() and inv:get_stack("main", 4):is_empty(), "Real inventory bytes changed") + assert(inv:get_stack("main", 1):get_wear() == 1379 + and inv:get_stack("main", 1):get_meta():get_string("recovery") == token, "ItemStack metadata lost") + -- Forceload activation must execute the game's real run-at-every-load LBM; + -- do not invoke its callback manually or replace the engine clock. + assert(C.get_node(expired_pos).name == "hearth:fire" + and C.get_meta(expired_pos):get_string("fuel_until") == "0", "Expired fire was not reconciled by LBM") + assert(fixture.liveDeadline > os.time(), "Fixture live deadline exceeded") + assert(C.get_node(live_pos).name == "hearth:fire_lit" + and tonumber(C.get_meta(live_pos):get_string("fuel_until")) == fixture.liveDeadline, "Absolute fire deadline extended or lost") + end + report.fixture = true +end + +local function emerge(minimum, maximum, done, context) + local complete = false + C.emerge_area(minimum, maximum, function(_, action, remaining) + guard(function() + if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then + failure_label = "checkpoint-" .. context .. (action == C.EMERGE_ERRORED and "-emerge-errored" or "-emerge-cancelled") + fail() + return + end + if remaining == 0 and not complete then + complete = true + C.after(0, function() guard(done) end) + end + end) + end) +end + +local function await_activation() + guard(function() + failure_label = "checkpoint-fixture-activation" + no_clients() + -- Forceload activation follows the engine's active-block management + -- interval, not necessarily the emerge callback or the next server step. + -- Wait for actual LBM effects; the independent 75s watchdog bounds this. + if not C.compare_block_status(fixture_pos, "active") then + C.after(0.1, await_activation) + return + end + if phase == "restart" and (C.get_node(expired_pos).name ~= "hearth:fire" + or C.get_meta(expired_pos):get_string("fuel_until") ~= "0") then + C.after(0.1, await_activation) + return + end + fixture_check() + ready = true + C.request_shutdown("Private recovery complete", false, 0) + end) +end + +C.register_on_prejoinplayer(function() return "Private offline recovery does not accept clients" end) +C.register_on_joinplayer(function() fail() end) +C.register_on_shutdown(function() + if not ready or finished then return end + local ok = pcall(function() + failure_label = "checkpoint-shutdown-evidence" + no_clients() + report.noClients = true + report.verifiedAssertions = verified_assertions + write("m0-recovery-" .. phase .. ".json", report) + end) + finished = true + if ok then C.log("action", "LAST_HEARTH_RECOVERY_PASS " .. phase .. " " .. token) + else C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) end +end) +C.after(0, function() + guard(function() + local spawn = world_check() + local p = vector.round(spawn) + emerge(vector.offset(p, -2, -2, -2), vector.offset(p, 2, 2, 2), function() + local node = C.get_node_or_nil(p) + assert(node and node.name ~= "ignore" and C.registered_nodes[node.name], "Saved spawn map unavailable") + assert(equal(hearth.persistence.load_world("spawn"), spawn), "Spawn changed during emergence") + report.spawn = true + assert(C.forceload_block(fixture_pos, true), "Cannot activate fixture mapblock") + emerge(fixture_pos, vector.offset(fixture_pos, 3, 1, 1), function() + C.after(0, await_activation) + end, "fixture") + end, "spawn") + end) +end) +C.after(75, function() if not ready then fail() end end) +end diff --git a/tests/runtime_spec.lua b/tests/runtime_spec.lua new file mode 100755 index 0000000..cffa124 --- /dev/null +++ b/tests/runtime_spec.lua @@ -0,0 +1,299 @@ +-- Standalone Lua 5.1+ scheduler regressions; arg[1] is runtime.lua. +-- Lifecycle persistence and respawn callbacks are exercised by players_spec. +local target = assert(arg[1], "runtime.lua path required") +local total = 0 +local function test(name, fn) + local old_core, old_hearth, old_vector = core, hearth, vector + local ok, message = pcall(fn) + core, hearth, vector = old_core, old_hearth, old_vector + assert(ok, name .. ": " .. tostring(message)); total = total + 1 +end +local function copy(value) + local result = {}; for k, v in pairs(value) do result[k] = v end; return result +end +local function harness(map_limit) + local h = {steps = {}, connected = {}, needs = {}, hud = {}, saves = {}, arrivals = {}, particles = {}, clocks = 0, + mapgen_limit = map_limit} + core = { + register_globalstep = function(fn) h.steps[#h.steps + 1] = fn end, + settings = {get = function(_, key) if key == "mapgen_limit" then return h.mapgen_limit end end}, + registered_nodes = {ground = {walkable = true}, air = {walkable = false}, + water = {walkable = false, groups = {water = 3}}}, + get_node = function(pos) + h.node_calls = (h.node_calls or 0) + 1 + return {name = h.water and "water" or (pos.y < 4 and "ground" or "air")} + end, + get_connected_players = function() return h.connected end, + find_node_near = function() return nil end, + add_particlespawner = function(value) h.particles[#h.particles + 1] = value end, + } + core.get_node_or_nil = core.get_node + vector = {offset = function(p, x, y, z) return {x = p.x + x, y = p.y + y, z = p.z + z} end} + hearth = {players = {}, sessions = {}, weather = "clear", model = {}} + hearth.movement = dofile(target:gsub("[^/\\]+$", "movement.lua")) + hearth.clock_update = function() h.clocks = h.clocks + 1 end + hearth.environment = function(pos) + h.environment_calls = (h.environment_calls or 0) + 1 + h.last_pos = pos + return {temperature = 20, shelter = 0, heat = h.heat or 0, smoke = h.smoke, roof = h.roof} + end + hearth.model.needs = function(state, env, dt) + h.needs[#h.needs + 1] = {state = state, env = env, dt = dt} + local updated = copy(state); updated.food = state.food - dt + updated.wet = math.max(0, state.wet + (env.drying and -1.5 or -0.35) * dt) + return updated, h.danger or 0 + end + hearth.update_hud = function(p, env) h.hud[#h.hud + 1] = {player = p, env = env} end + hearth.player_save = function(p) h.saves[#h.saves + 1] = p; return true end + hearth.safe_arrival = function(p) h.arrivals[#h.arrivals + 1] = p; p.pos = {x = 4, y = 5, z = 8} end + hearth.texture = function(value) return "texture:" .. value end + function h.player(name) + local meta = {get_int = function() return 0 end} + local p = {name = name or "fixture", hp = 20, pos = {x = 0, y = 4, z = 0}, controls = {}, + item = "", physics = {}, damage = {}, clouds = {}} + function p:get_player_name() return self.name end + function p:get_player_control() return self.controls end + function p:get_wielded_item() return {get_name = function() return self.item end} end + function p:get_pos() return self.pos end + function p:get_velocity() return self.velocity or {x = 0, y = 0, z = 0} end + function p:get_hp() return self.hp end + function p:get_meta() return meta end + function p:set_hp(value, reason) self.hp = value; self.damage[#self.damage + 1] = reason end + function p:set_physics_override(value) self.physics[#self.physics + 1] = value end + function p:set_clouds(value) self.clouds[#self.clouds + 1] = value end + h.connected[#h.connected + 1] = p + hearth.players[p.name] = {food = 85, water = 85, warmth = 80, wet = 10, stamina = 100} + hearth.sessions[p.name] = {damage = 0, save = 0, ready = false} + return p, hearth.players[p.name], hearth.sessions[p.name] + end + dofile(target) + return h +end +local function feedback_harness() + local h = harness(); local p, state, s = h.player() + h.time, h.sounds, h.animations, h.fovs, h.messages, h.commands, h.rays = 0, {}, {}, {}, {}, {}, 0 + core.get_us_time = function() return h.time * 1000000 end + core.sound_play = function(name, spec, ephemeral) + h.sounds[#h.sounds + 1] = {name = name, spec = spec}; assert(ephemeral) + end + core.register_chatcommand = function(name, def) h.commands[name] = def end + core.get_player_by_name = function(name) if name == p.name then return p end end + core.get_dig_params = function() return {diggable = h.suitable ~= false} end + core.registered_nodes.ground.description = "Soil" + core.raycast = function(start, finish, objects, liquids) + h.rays = h.rays + 1 + assert(not objects and not liquids and finish.z - start.z == 5) + local used = false + return function() + if used or h.no_target then return end + used = true; return {type = "node", under = {x = 0, y = 3, z = 0}} + end + end + function p:get_look_dir() return {x = 0, y = 0, z = 1} end + function p:get_properties() return {eye_height = 1.55} end + function p:get_wielded_item() + return {get_name = function() return self.item end, get_tool_capabilities = function() return {} end, + get_wear = function() return 0 end} + end + function p:set_animation(range, speed, blend, loop) + h.animations[#h.animations + 1] = {range = range, speed = speed, loop = loop} + end + function p:set_fov(fov, multiplier, transition) + assert(not multiplier and transition == 0.2); h.fovs[#h.fovs + 1] = fov + end + function p:hud_add(def) h.messages[#h.messages + 1] = def.text; return 42 end + function p:hud_change(id, field, value) + assert(id == 42); if field == "text" then h.messages[#h.messages + 1] = value end + end + dofile(target:gsub("[^/\\]+$", "feedback.lua")) + function h.tick(dt) h.time = h.time + dt; hearth.feedback_step(p, dt) end + return h, p, state, s +end + +test("scheduler registers two callbacks and clocks advance without connected players", function() + local h = harness(); assert(#h.steps == 2) + h.steps[1](9); assert(h.clocks == 0) + h.steps[1](1); assert(h.clocks == 1) + h.steps[1](100); assert(h.clocks == 2, "Clock must not replay unbounded catch-up updates") + assert(#h.needs == 0 and #h.saves == 0) +end) +test("waiting arrival never evaluates needs damages saves HUD or fall recovery", function() + local h = harness(); local p, state, session = h.player(); session.waiting = true + state.food = 0; p.pos.y = -101; h.danger = 100; h.smoke = true + for _ = 1, 5 do h.steps[1](1) end + assert(hearth.players[p.name] == state and state.food == 0 and p.hp == 20) + assert(session.damage == 0 and session.save == 0 and h.environment_calls == nil) + assert(#h.needs == 0 and #h.saves == 0 and #h.hud == 0 and #h.arrivals == 0) + session.waiting = false; p.pos.y = 4; h.danger = 0; h.smoke = false; h.steps[1](1) + assert(#h.needs == 1 and #h.hud == 1 and session.save == 1) +end) +test("subsecond controls gate sprint brace and avoid redundant physics writes", function() + local h = harness(); local p, state = h.player() + p.controls = {aux1 = true, up = true}; h.steps[1](0.1); assert(p.physics[1].speed == 1.3) + h.steps[1](0.1); assert(p.physics[2].speed == 1.45) + h.steps[1](0.1); assert(#p.physics == 2) + p.item = "hearth:spear"; p.controls.RMB = true + for _ = 1, 3 do h.steps[1](0.1) end + assert(math.abs(p.physics[#p.physics].speed - 0.6) < 0.001) + assert(hearth.sessions[p.name].blocking and not hearth.sessions[p.name].sprinting) + p.item = ""; p.controls.sneak = true + for _ = 1, 2 do h.steps[1](0.1) end + assert(p.physics[#p.physics].speed == 1) + p.controls.sneak = false; state.stamina = 8; h.steps[1](0.1) + state.stamina = 31; h.steps[1](0.01); assert(not hearth.sessions[p.name].sprinting) + assert(p.physics[#p.physics].jump == nil and p.physics[#p.physics].gravity == nil and #h.needs == 0) +end) +test("one Hz needs caps elapsed step at two seconds and applies bounded heat drying", function() + local h = harness(); local p, state = h.player(); p.controls = {aux1 = true, up = true} + h.heat = 3; h.wet = 10 + h.steps[1](0.4); assert(#h.needs == 0) + h.steps[1](0.6) + assert(#h.needs == 1 and h.needs[1].dt == 1 and h.needs[1].state == state and h.needs[1].env.sprinting) + assert(hearth.players[p.name] ~= state and hearth.players[p.name].wet == 8.5 and state.wet == 10) + h.steps[1](100) + assert(#h.needs == 2 and h.needs[2].dt == 2 and hearth.players[p.name].food == 82) + assert(hearth.players[p.name].wet == 5.5 and hearth.sessions[p.name].save == 3) + hearth.players[p.name].wet = 1 + h.heat = 20; h.steps[1](1); assert(hearth.players[p.name].wet == 0) +end) +test("danger smoke accumulation damages at threshold with at most one hit per capped step", function() + local h = harness(); local p, _, session = h.player(); h.danger = 1; h.smoke = true + h.steps[1](1); assert(p.hp == 20 and session.damage == 2) + h.steps[1](1); assert(p.hp == 19 and session.damage == 0 and #p.damage == 1) + assert(p.damage[1].type == "set_hp" and p.damage[1].from == "mod") + h.danger = 100; h.steps[1](100) + assert(p.hp == 18 and #p.damage == 2 and session.damage == 0 and h.needs[3].dt == 2) +end) +test("autosave uses bounded active simulation time and resets its interval", function() + local h = harness(); local p, _, session = h.player() + for _ = 1, 9 do h.steps[1](1) end + assert(#h.saves == 0 and session.save == 9) + h.steps[1](1); assert(#h.saves == 1 and h.saves[1] == p and session.save == 0) + h.steps[1](100); assert(#h.saves == 1 and session.save == 2) + for _ = 1, 8 do h.steps[1](1) end + assert(#h.saves == 2 and session.save == 0 and #h.hud == 19) +end) +test("fall recovery uses mapgen_limit plus margin and does not teleport valid caves", function() + local h = harness(); local p = h.player(); p.pos.y = -100 + h.steps[1](1); assert(#h.arrivals == 0) + p.pos.y = -544; h.steps[1](1); assert(#h.arrivals == 0) + p.pos.y = -545; h.steps[1](1) + assert(#h.arrivals == 1 and h.arrivals[1] == p and p.pos.y == 5) + h = harness(1000); p = h.player(); p.pos.y = -1032; h.steps[1](1) + assert(#h.arrivals == 0); p.pos.y = -1033; h.steps[1](1); assert(#h.arrivals == 1) +end) +test("dead and left players skip needs while a fresh respawn or rejoin remains schedulable", function() + local h = harness(); local p, _, old_session = h.player(); p.hp = 0 + h.steps[1](1); assert(#h.needs == 0 and #h.hud == 0 and old_session.save == 0) + -- Engine may still enumerate a just-left player; lifecycle removes both maps. + hearth.players[p.name], hearth.sessions[p.name] = nil, nil + h.steps[1](1); assert(#h.needs == 0 and #h.saves == 0) + h.connected = {}; local q, fresh, session = h.player(p.name) + h.steps[1](1) + assert(h.needs[1].state == fresh and #h.hud == 1 and session.save == 1 and old_session.save == 0) + assert(q.hp == 20 and hearth.players[q.name].food == 84) +end) +test("autosave teardown is safe for the current tick later players and subsequent ticks", function() + local h = harness(); local p, _, session = h.player("one"); local q = h.player("two") + session.save = 9 + hearth.player_save = function(player) + h.saves[#h.saves + 1] = player + hearth.players[player.name], hearth.sessions[player.name] = nil, nil + return false + end + h.steps[1](1) + assert(#h.saves == 1 and h.saves[1] == p and not hearth.sessions[p.name] and not hearth.players[p.name]) + assert(hearth.players[q.name].food == 84 and #h.needs == 2) + h.steps[1](1) + assert(#h.needs == 3 and #h.saves == 1 and hearth.players[q.name].food == 83) +end) +test("rain callback is throttled player local roof aware and independent of needs", function() + local h = harness(); local p = h.player(); hearth.weather = "rain" + h.steps[2](2); assert(#p.clouds == 0 and #h.particles == 0) + h.steps[2](1) + assert(p.clouds[1].density == 0.65 and #h.particles == 1) + local rain = h.particles[1] + assert(rain.playername == p.name and rain.amount == 80 and rain.time == 3) + assert(rain.minpos.x == -7 and rain.minpos.y == 11 and rain.maxpos.z == 7) + assert(rain.collisiondetection and rain.collision_removal and #h.needs == 0) + h.roof = true; h.steps[2](3); assert(#h.particles == 1) + hearth.weather = "clear"; h.steps[2](3) + assert(#h.particles == 1 and p.clouds[3].density == 0.25) +end) +test("continuous stamina is not drained twice by needs and sprint food costs remain", function() + local h = harness(); local p, state = h.player(); p.controls = {aux1 = true, up = true} + local original = hearth.model.needs + hearth.model.needs = function(s, env, dt) + local updated, danger = original(s, env, dt) + updated.stamina = s.stamina - 13 * dt + return updated, danger + end + h.steps[1](0.5); assert(math.abs(state.stamina - 98.7) < 0.001) + h.steps[1](0.5) + assert(math.abs(hearth.players[p.name].stamina - 97.4) < 0.001) + assert(#h.needs == 1 and h.needs[1].env.sprinting) +end) +test("water velocity and lifecycle gate movement and optional per frame feedback", function() + local h = harness(); local p, _, s = h.player(); local calls = 0 + hearth.feedback_step = function(player, dt) assert(player == p and dt == 0.1); calls = calls + 1 end + p.controls = {aux1 = true, up = true}; h.water = true; h.steps[1](0.1) + assert(s.swimming and not s.sprinting and calls == 1) + h.water = false; p.velocity = {x = 3, y = -5, z = 0}; h.steps[1](0.1) + assert(not s.grounded and not s.sprinting and s.moving and calls == 2) + s.waiting = true; local nodes = h.node_calls; h.steps[1](0.1) + assert(h.node_calls == nodes and calls == 2) + s.waiting = false; p.hp = 0; h.steps[1](0.1); assert(calls == 2) + p.hp = 20; hearth.players[p.name] = nil; h.steps[1](0.1); assert(calls == 2) +end) +test("feedback animates only state transitions and never imposes default FOV", function() + local h, p, _, s = feedback_harness() + h.tick(0.01); h.tick(0.01) + assert(#h.animations == 1 and h.animations[1].range.x == 0 and #h.fovs == 0) + s.moving = true; h.tick(0.01); assert(h.animations[2].range.x == 20) + s.sprinting = true; h.tick(0.01); assert(h.animations[3].range.x == 40) + s.blocking = true; h.tick(0.01); assert(h.animations[4].range.x == 70) + s.swimming = true; h.tick(0.01); assert(h.animations[5].range.x == 80) + s.swimming, s.blocking, s.moving = false, false, false; p.controls.LMB = true + h.tick(0.01); assert(h.animations[6].range.x == 60 and not h.animations[6].loop) + h.tick(0.4); assert(h.animations[7].range.x == 0) + assert(h.commands.comfort.func(p.name, "fov 75")); h.tick(0.01); h.tick(0.01) + assert(#h.fovs == 1 and h.fovs[1] == 75) + assert(not h.commands.comfort.func(p.name, "fov 999")) + assert(h.commands.comfort.func(p.name, "default")); h.tick(0.01) + assert(#h.fovs == 2 and h.fovs[2] == 0) +end) +test("feedback lazily caches targeting and expires bounded notifications monotonically", function() + local h, p, _, s = feedback_harness() + h.tick(0.01); assert(s.feedback_hud == 42 and #h.messages == 1 and h.rays == 1) + for _ = 1, 5 do h.tick(0.01) end + assert(h.rays == 1 and #h.messages == 1) + h.suitable = false; h.tick(0.1) + assert(h.messages[#h.messages]:find("Different tool needed", 1, true)) + hearth.feedback(p, string.rep("x", 300) .. "\n", "warning") + assert(#s.feedback_text == 200 and s.feedback_until > h.time) + h.tick(0.1); assert(h.messages[#h.messages] == s.feedback_text) + local rays = h.rays; h.tick(0.1); assert(h.rays == rays) + h.no_target = true; h.tick(3.1); assert(h.messages[#h.messages] == "") + assert(#h.sounds == 0) +end) +test("footfalls require grounded distance and cadence with one landing not per frame audio", function() + local h, p, _, s = feedback_harness() + s.grounded, s.moving = true, true; h.tick(0.1) + for _ = 1, 5 do h.tick(0.1) end; assert(#h.sounds == 0) + for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end + assert(#h.sounds == 1 and h.sounds[1].name == "hearth_step_soil") + assert(h.sounds[1].spec.pos == p.pos and h.sounds[1].spec.max_hear_distance == 12) + s.grounded = false; p.velocity = {x = 2, y = -5, z = 0} + for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end + assert(#h.sounds == 1) + s.grounded = true; p.velocity.y = 0; h.tick(0.1); h.tick(0.1) + assert(#h.sounds == 2 and h.sounds[2].name == "hearth_land") + p.pos.x = p.pos.x + 100; h.tick(0.1); assert(#h.sounds == 2) + for index, material in ipairs({"wood", "stone", "water"}) do + core.registered_nodes.ground.groups = {[material] = 1} + for _ = 1, 5 do p.pos.x = p.pos.x + 0.4; h.tick(0.1) end + assert(#h.sounds == index + 2 and h.sounds[#h.sounds].name == "hearth_step_" .. material) + end +end) +assert(total == 15, "Unexpected scheduler regression count") +print("PASS " .. total .. " runtime scheduler safety tests") diff --git a/tests/schema_spec.lua b/tests/schema_spec.lua new file mode 100755 index 0000000..5726d75 --- /dev/null +++ b/tests/schema_spec.lua @@ -0,0 +1,164 @@ +local S = dofile(assert(arg[1], "schema.lua path required")) +local total = 0 +local function test(name, fn) + local ok, err = pcall(fn) + assert(ok, name .. ": " .. tostring(err)); total = total + 1 +end +local function reject(kind, value) + local ok, err = pcall(S.decode, kind, value) + assert(not ok and err == "Last Hearth: invalid save schema or data", "Expected sanitized rejection") +end +local function v1(kind, data) + return {schema = 1, kind = kind, data = data} +end +local samples = { + needs = {food = 85, water = 85, warmth = 80, wet = 0, stamina = 100}, + claims = {fixture_player = {x = -12, y = 4, z = 8}}, + homes = {fixture_player = {x = -12, y = 5, z = 8}}, + access = {fixture_player = {housemate = "resident", keeper = "storekeeper"}}, + location = {latitude = 45.25, longitude = -73.5}, + spawn = {x = 0.5, y = 5.25, z = -0.125}, +} +test("defaults are exact independent needs copies", function() + local a, b = S.defaults(), S.defaults() + for key, value in pairs(samples.needs) do assert(a[key] == value and b[key] == value) end + a.food = 0; assert(b.food == 85 and S.defaults().food == 85) +end) +for _, kind in ipairs({"needs", "claims", "homes", "access", "location", "spawn"}) do + test(kind .. " migrates without mutating or aliasing input", function() + local source = samples[kind] + local envelope, migrated = S.decode(kind, source) + assert(migrated == true and envelope.schema == 1 and envelope.kind == kind) + assert(envelope.data ~= source and source.schema == nil) + if kind == "claims" or kind == "homes" or kind == "access" then assert(envelope.data.fixture_player ~= source.fixture_player) end + local current, again = S.decode(kind, envelope) + assert(again == false and current ~= envelope and current.data ~= envelope.data) + end) + test(kind .. " rejects future versions, wrong kinds and extra envelope fields", function() + for _, version in ipairs({0, -1, 2, 1.5, "1", false, math.huge, 0 / 0}) do + reject(kind, {schema = version, kind = kind, data = samples[kind]}) + end + reject(kind, {schema = 1, kind = "unknown", data = samples[kind]}) + reject(kind, {schema = 1, kind = kind}) + reject(kind, {schema = 1, data = samples[kind]}) + reject(kind, {kind = kind, data = samples[kind]}) + reject(kind, {schema = 1, kind = kind, data = samples[kind], private = "PRIVATE"}) + end) + test(kind .. " rejects scalar values and missing data", function() + reject(kind, nil) + for _, value in ipairs({false, 0, "PRIVATE", function() end}) do + reject(kind, value); reject(kind, v1(kind, value)) + end + end) +end +test("unknown kinds and metatables fail without executing input", function() + reject("PRIVATE", {}) + reject({}, {}) + local hostile = setmetatable({}, {__index = function() error("PRIVATE") end}) + reject("needs", hostile); reject("needs", v1("needs", hostile)) + reject("claims", {owner = hostile}) +end) +test("needs require all five fields and reject unknown fields", function() + for key in pairs(samples.needs) do + local data = S.defaults(); data[key] = nil; reject("needs", data) + end + local data = S.defaults(); data.private = "PRIVATE"; reject("needs", v1("needs", data)) + reject("needs", {}); reject("needs", {85, 85, 80, 0, 100}) +end) +test("needs boundaries and fractional finite numbers are valid", function() + local data = {food = 0, water = 100, warmth = 0.125, wet = 99.5, stamina = 50} + assert(S.decode("needs", v1("needs", data)).data.warmth == 0.125) +end) +test("every needs field rejects nonnumeric nonfinite or out of range values", function() + for key in pairs(samples.needs) do + for _, value in ipairs({"85", true, {}, -0.001, 100.001, math.huge, -math.huge, 0 / 0}) do + local data = S.defaults(); data[key] = value + reject("needs", v1("needs", data)); reject("needs", data) + end + end +end) +for _, kind in ipairs({"claims", "homes", "access"}) do + test("empty " .. kind .. " and envelope-field player names remain valid", function() + assert(next(S.decode(kind, {}).data) == nil) + assert(next(S.decode(kind, v1(kind, {})).data) == nil) + local claims + if kind == "access" then claims = {schema = {}, kind = {}, data = {}} + else claims = {schema = {x = 0, y = 0, z = 0}, kind = {x = 1, y = 2, z = 3}, + data = {x = -1, y = -2, z = -3}} end + local decoded, migrated = S.decode(kind, claims) + assert(migrated) + if kind == "access" then assert(decoded.data.schema and decoded.data.kind and decoded.data.data) + else assert(decoded.data.schema.x == 0 and decoded.data.data.z == -3) end + end) + if kind ~= "access" then test(kind .. " require nonempty string owners and integer complete positions", function() + reject(kind, {{x = 0, y = 0, z = 0}}) + reject(kind, {[""] = {x = 0, y = 0, z = 0}}) + reject(kind, {owner = {x = 0.5, y = 0, z = 0}}) + reject(kind, {owner = {x = 0, y = 0}}) + reject(kind, {owner = {x = 0, y = 0, z = 0, private = true}}) + reject(kind, {owner = "PRIVATE"}) + end) end +end +test("access map accepts only bounded usernames and explicit household roles", function() + local access = {owner = {resident = "resident", keeper = "storekeeper"}} + local decoded = S.decode("access", access) + assert(decoded.data.owner.resident == "resident" and decoded.data.owner.keeper == "storekeeper") + for _, value in ipairs({ + {[""] = {}}, {owner = {bad = "admin"}}, {owner = {"resident"}}, + {owner = {owner = "resident"}}, {owner = {['bad/name'] = "resident"}}, + {owner = "resident"}, {owner = setmetatable({}, {})}, + }) do reject("access", value); reject("access", v1("access", value)) end + local long_name = string.rep("a", 33) + reject("access", {[long_name] = {member = "resident"}}) + local too_many = {owner = {}} + for i = 1, 17 do too_many.owner["member" .. i] = "resident" end + reject("access", too_many) +end) +for _, kind in ipairs({"spawn", "claims", "homes"}) do + test(kind .. " enforces all coordinate bounds and numeric types", function() + local function wrap(pos) return (kind == "claims" or kind == "homes") and {owner = pos} or pos end + assert(S.decode(kind, wrap({x = -31000, y = 31000, z = 0}))) + for _, axis in ipairs({"x", "y", "z"}) do + for _, value in ipairs({"0", true, {}, -31000.001, 31000.001, math.huge, -math.huge, 0 / 0}) do + local pos = {x = 0, y = 0, z = 0}; pos[axis] = value + reject(kind, wrap(pos)); reject(kind, v1(kind, wrap(pos))) + end + local pos = {x = 0, y = 0, z = 0}; pos[axis] = nil; reject(kind, wrap(pos)) + end + end) +end +test("spawn supports floats but rejects unknown fields", function() + assert(S.decode("spawn", samples.spawn).data.y == 5.25) + reject("spawn", {x = 0, y = 0, z = 0, private = true}) +end) +test("location accepts boundaries and rejects unknown or missing fields", function() + assert(S.decode("location", {latitude = -90, longitude = 180})) + assert(S.decode("location", {latitude = 90, longitude = -180})) + reject("location", {latitude = 0}); reject("location", {longitude = 0}) + reject("location", {latitude = 0, longitude = 0, private = "PRIVATE"}) +end) +test("every location coordinate rejects strings nonfinite values and overflow", function() + for _, key in ipairs({"latitude", "longitude"}) do + local limit = key == "latitude" and 90 or 180 + for _, value in ipairs({"0", false, {}, limit + 0.001, -limit - 0.001, math.huge, -math.huge, 0 / 0}) do + local data = {latitude = 0, longitude = 0}; data[key] = value + reject("location", data); reject("location", v1("location", data)) + end + end +end) +-- Optional explicit fixture path for standalone/integration runners. The engine's +-- restricted one-module smoke harness exercises the same values above without +-- needing secondary file access or changing live globals. +if arg[2] then + local fixtures = dofile(arg[2]) + test("historic fixture records migrate to schema1", function() + for kind in pairs(fixtures) do + if samples[kind] then + local decoded, migrated = S.decode(kind, fixtures[kind].data) + assert(migrated and decoded.kind == kind and type(fixtures[kind].raw) == "string") + end + end + assert(next(S.decode("claims", fixtures.empty_claims.data).data) == nil) + end) +end +print("PASS " .. total .. " schema tests") diff --git a/tests/seasons_spec.lua b/tests/seasons_spec.lua new file mode 100755 index 0000000..ddd74ff --- /dev/null +++ b/tests/seasons_spec.lua @@ -0,0 +1,244 @@ +-- Standalone Lua 5.1+: controlled names, actual-epoch climate and lossless swaps. +local target = assert(arg[1], "seasons.lua path required") +local real_time, now, passed = os.time, 1782043200, 0 +os.time = function() return now end +local season, world, data, missing, protected, swaps, meta_writes, lbm +local p = {x=0,y=1,z=0} +local hearth, api, seasons +local function key(q) return q.x..":"..q.y..":"..q.z end +local function reset() + season, world, data, missing, protected, swaps, meta_writes = "Summer", {}, {}, false, false, 0, 0 + api = {registered_nodes={}} + api.get_node_or_nil = function(q) return not missing and world[key(q)] or nil end + api.swap_node = function(q,n) swaps=swaps+1; world[key(q)]=n end + api.get_meta = function() error("appearance must not access metadata") end + api.register_node = function(name, def) assert(not api.registered_nodes[name]); api.registered_nodes[name]=def end + api.register_lbm = function(def) lbm=def end + hearth = {climate_at=function(epoch) assert(epoch==now); return {season=season} end, + external_protected=function() return protected end, + claim_owner_at=function() return "builder" end} + seasons = dofile(target)(hearth,api) + hearth.seasons = seasons + for _, name in ipairs(seasons.names()) do + local kind=seasons.kind(name) + local base="hearth:"..kind + api.registered_nodes[base] = api.registered_nodes[base] or { + drawtype=(kind=="young_tree" or kind=="mature_tree" or kind=="tree_seedling") and "mesh" or "plantlike", + groups={flora=1}, drop="hearth:sticks", tiles={"base.png"}, + can_dig=function() return false end, on_rightclick=function() end, + after_place_node=function() end, collision_box={type="fixed"}, + } + end + seasons.register() +end +local function put(name) + world[key(p)]={name="hearth:"..name,param1=7,param2=3} +end +local function name() return world[key(p)].name end +local function test(label,fn) + reset() + local ok,err=pcall(fn); assert(ok,label..": "..tostring(err)) + passed=passed+1; print("ok "..passed.." - "..label) +end +test("all seasons use explicit variants and summer restores the canonical tree", function() + put("mature_tree") + for _, s in ipairs({"Spring","Autumn","Winter","Summer"}) do + season=s; assert(seasons.reconcile(p)) + assert(name()=="hearth:mature_tree"..(s=="Summer" and "" or "_"..s:lower())) + assert(world[key(p)].param1==7 and world[key(p)].param2==3) + assert(not seasons.reconcile(p), "same-season appearance is idempotent") + end + assert(swaps==4) +end) +test("winter bare mesh returns to the base mesh in spring", function() + for _, kind in ipairs({"young_tree","mature_tree"}) do + local winter=api.registered_nodes["hearth:"..kind.."_winter"] + local spring=api.registered_nodes["hearth:"..kind.."_spring"] + assert(winter.mesh=="hearth_"..kind.."_winter.obj") + assert(spring.mesh=="hearth_"..kind..".obj") + assert(winter.tiles[1]=="hearth_tree_atlas_winter.png") + end +end) +test("legacy mapgen leaves recolor without changing timber or destroying canopy", function() + put("leaves"); season="Autumn"; lbm.action(p) + assert(name()=="hearth:leaves_autumn") + season="Winter"; lbm.action(p); assert(name()=="hearth:leaves_winter") + put("log"); assert(not seasons.reconcile(p) and name()=="hearth:log") + assert(not seasons.kind("hearth:foreign_tree_winter"):match("^mature_tree$")) +end) +test("perennials and flowers rest in the correct seasons without adding needless variants", function() + for _, kind in ipairs({"meadow_grass","fern","wildflowers"}) do + put(kind); season="Winter"; assert(seasons.reconcile(p)) + assert(api.registered_nodes[name()].tiles[1]=="hearth_"..kind.."_dormant.png") + season="Spring"; assert(seasons.reconcile(p) and name()=="hearth:"..kind) + end + put("wildflowers"); season="Autumn"; assert(seasons.reconcile(p)) + assert(name()=="hearth:wildflowers_autumn" and not seasons.available("wildflowers")) + assert(not api.registered_nodes["hearth:fern_spring"]) + assert(seasons.available("litter") and seasons.available("fungi")) +end) +test("food patches only bloom in their season and stay occupied when dormant", function() + for _, s in ipairs({"Spring","Summer","Autumn","Winter"}) do + season=s + assert(seasons.available("spring_greens_patch")== (s=="Spring")) + assert(seasons.available("autumn_mushrooms_patch")== (s=="Autumn")) + assert(seasons.name_for("spring_greens_patch")=="hearth:spring_greens_patch"..(s=="Spring" and "_spring" or "")) + end +end) +test("callbacks, authorization, drops and geometry survive every variant", function() + for _, name in ipairs(seasons.names()) do + local kind=seasons.kind(name) + local base,def=api.registered_nodes["hearth:"..kind],api.registered_nodes[name] + assert(def.can_dig==base.can_dig and def.on_rightclick==base.on_rightclick) + assert(def.after_place_node==base.after_place_node and def.collision_box==base.collision_box) + assert(def.drop==base.drop and def.groups.flora==1) + end +end) +test("claimed handplaced flora can recolor with no metadata access or harvest refill", function() + data={succ_age="8412",succ_natural="1",succ_harvest_ready_at=tostring(now+86400),player_placed="1",owner="builder"} + local reference=data + put("spring_greens_patch"); season="Spring"; assert(seasons.reconcile(p)) + season="Winter"; assert(seasons.reconcile(p)) + assert(data==reference and data.succ_age=="8412" and data.player_placed=="1") + assert(data.succ_harvest_ready_at==tostring(now+86400) and meta_writes==0) +end) +test("unloaded, ignore, protected and uncontrolled names cause no writes", function() + season="Winter"; put("leaves"); missing=true + assert(not seasons.reconcile(p)); missing=false; protected=true + assert(not seasons.reconcile(p)); protected=false + world[key(p)]={name="ignore"}; assert(not seasons.reconcile(p)) + put("foreign_tree"); assert(not seasons.reconcile(p)) + assert(swaps==0) +end) +test("hemisphere seasons come from the existing solar model, not the host month", function() + local model=dofile(target:gsub("[^/\\]+$","model.lua")) + local south=model.solar(now,-45,0) + local north=model.solar(now,45,0) + assert(north.season=="Summer" and south.season=="Winter") + hearth.climate_at=function(epoch) assert(epoch==now); return model.solar(epoch,-45,0) end + assert(seasons.season()=="Winter") + put("young_tree"); assert(seasons.reconcile(p)) + assert(name()=="hearth:young_tree_winter") + now=1797854400 -- December, southern summer. + assert(seasons.reconcile(p) and name()=="hearth:young_tree") +end) +test("extension is available before registration and missing optional bases are safe", function() + seasons.add_variants("optional_plant",{Winter={texture="optional_dormant.png"}}) + seasons.register() + assert(not api.registered_nodes["hearth:optional_plant_winter"]) + api.registered_nodes["hearth:optional_plant"]={groups={flora=1},drop="",tiles={"optional.png"}} + seasons.register() + assert(api.registered_nodes["hearth:optional_plant_winter"].tiles[1]=="optional_dormant.png") + assert(seasons.kind("hearth:optional_plant_winter")=="optional_plant") +end) +test("full block stage variants retain bark faces and never acquire sculpted meshes", function() + local stage={drawtype="normal",tiles={"end.png","end.png","bark.png"},paramtype2="facedir",groups={tree=1},drop="hearth:log"} + for _,kind in ipairs({"young_tree","mature_tree","oak_young","oak_mature"}) do + api.registered_nodes["hearth:"..kind]=stage + seasons.add_variants(kind,{Winter={texture="canopy_atlas.png",mesh="obsolete_tree.obj"}}) + api.registered_nodes["hearth:"..kind.."_winter"]=nil + end + seasons.register() + for _,kind in ipairs({"young_tree","mature_tree","oak_young","oak_mature"}) do + local d=api.registered_nodes["hearth:"..kind.."_winter"] + assert(d.drawtype=="normal" and d.mesh==nil and d.tiles==stage.tiles) + assert(d.tiles[3]=="bark.png" and d.paramtype2=="facedir" and d.drop=="hearth:log") + end +end) +test("all twelve original calendar months retain hemisphere-aware seasons", function() + local model=dofile(target:gsub("[^/\\]+$","model.lua")) + local north={"Winter","Winter","Spring","Spring","Spring","Summer","Summer","Summer","Autumn","Autumn","Autumn","Winter"} + local opposite={Winter="Summer",Spring="Autumn",Summer="Winter",Autumn="Spring"} + for month=1,12 do + now=real_time({year=2026,month=month,day=15,hour=12,min=0,sec=0}) + for _,latitude in ipairs({45,-45}) do + hearth.climate_at=function(epoch) assert(epoch==now); return model.solar(epoch,latitude,0) end + local expected=latitude>0 and north[month] or opposite[north[month]] + assert(seasons.season()==expected) + assert(seasons.available("wildflowers")== (expected=="Spring" or expected=="Summer")) + end + end +end) +local function install_botany() + local botany=dofile(target:gsub("[^/\\]+$","botany.lua"))(hearth,api) + botany.register_nodes(function(id,def) api.register_node("hearth:"..id,def) end) + seasons.register() + return botany +end +test("actual botany factory installs all new policies before seasonal registration", function() + local b=install_botany(); local count=0 + for _,r in ipairs(b.list()) do + if not r.existing and r.group~="trees" then + count=count+1 + for _,s in ipairs({"Spring","Summer","Autumn","Winter"}) do + season=s; local active=b.active(r.id,s) + local expected=r.node..(active and "" or "_"..s:lower()) + assert(seasons.name_for(r.id,now)==expected,r.id..s) + assert(seasons.available(r.id,now)==active,r.id..s) + local def=assert(api.registered_nodes[expected]) + assert(def.description==r.common.." ("..r.latin..")") + assert(def.tiles[1]=="hearth_botany_"..r.id..(active and "" or "_dormant")..".png") + assert(seasons.kind(expected)==r.id and b.kind(expected)==r.id) + end + end + end + assert(count==62) +end) +test("botany leaves diversity-owned taxa and block tree policies untouched", function() + local diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api) + -- Production shares exactly this seasonal service with both factories. + assert(diversity.plants and #diversity.plants==8) + local before={} + for _,id in ipairs(diversity.plants) do + before[id]={} + for _,s in ipairs({"Spring","Summer","Autumn","Winter"}) do + season=s; before[id][s]={seasons.name_for(id,now),seasons.available(id,now)} + end + end + install_botany() + for id,states in pairs(before) do for s,state in pairs(states) do + season=s; assert(seasons.name_for(id,now)==state[1] and seasons.available(id,now)==state[2]) + end end + for _,id in ipairs({"holly","juniper","harts_tongue","polypody","turkey_tail","oakmoss"}) do + season="Winter"; assert(seasons.available(id,now) and seasons.name_for(id,now)=="hearth:"..id) + assert(not api.registered_nodes["hearth:"..id.."_winter"]) + end +end) +test("dormant aquatic variants retain geometry callbacks and separate real water", function() + local b=install_botany() + for _,r in ipairs(b.list("aquatic")) do + local base=api.registered_nodes[r.node] + world[key(p)]={name=r.node,param1=7,param2=3} + world["0:0:0"]={name="hearth:water",param2=0} + local water=world["0:0:0"] + season="Winter"; assert(seasons.reconcile(p)) + local def=api.registered_nodes[name()] + assert(def.tiles[1]=="hearth_botany_"..r.id.."_dormant.png") + assert(def.inventory_image==def.tiles[1] and def.wield_image==def.tiles[1]) + assert(def.drawtype==base.drawtype and def.node_box==base.node_box and def.selection_box==base.selection_box) + assert(def.on_place==base.on_place and def.after_place_node==base.after_place_node) + assert(def.drop==base.drop and not def.walkable and not def.buildable_to) + assert(not def.liquidtype and not def.groups.water) + assert(def.groups.hearth_submerged==base.groups.hearth_submerged) + if def.on_place then local stack={}; assert(def.on_place(stack,nil,{})==stack) end + assert(world["0:0:0"]==water and water.name=="hearth:water") + assert(world[key(p)].param1==7 and world[key(p)].param2==3) + season="Summer"; assert(seasons.reconcile(p) and name()==r.node) + end +end) +test("berry seasonal swaps preserve cooldown provenance and harvest authorization", function() + install_botany() + local base=api.registered_nodes["hearth:bilberry"] + data={botany_ready_at=tostring(now+86400),player_placed="1",owner="builder"} + local reference=data + put("bilberry"); season="Winter"; assert(seasons.reconcile(p)) + local def=api.registered_nodes[name()] + assert(def.drop=="hearth:sticks" and def.on_rightclick==base.on_rightclick) + assert(def.after_place_node==base.after_place_node and def.groups.not_in_creative_inventory==1) + assert(not seasons.available(name(),now)) + season="Summer"; assert(seasons.reconcile(p) and name()=="hearth:bilberry") + assert(data==reference and data.botany_ready_at==tostring(now+86400) and data.owner=="builder") + assert(data.player_placed=="1" and meta_writes==0) +end) +os.time=real_time +print("LAST_HEARTH_SEASONS_PASS "..passed) diff --git a/tests/species_effects_spec.lua b/tests/species_effects_spec.lua new file mode 100755 index 0000000..d081b22 --- /dev/null +++ b/tests/species_effects_spec.lua @@ -0,0 +1,89 @@ +-- Standalone Lua 5.1+; policy/source checks, no engine and no network. +local target=assert(arg[1],"species_effects.lua path required") +local root=target:gsub("[^/\\]+$","") +local hearth={} +hearth.botany=dofile(root.."botany.lua")(hearth,{}) +hearth.wiki_articles=dofile(root.."wiki_articles.lua") +local E=dofile(target)(hearth,setmetatable({}, {__index=function(_,k) error("unexpected API access: "..k) end})) +local passed=0 +local function test(label,fn) fn(); passed=passed+1; print("ok "..passed.." - "..label) end +local function near(a,b) assert(math.abs(a-b)<0.000001,tostring(a).." != "..tostring(b)) end +local function entry(value,title) + for _,row in ipairs(E.describe(value)) do if row.title==title then return row end end + error("missing effect "..title) +end +test("all80 identity sources are mapped without claiming unreviewed trait evidence",function() + local sources=E.sources(); local total=0 + for _,r in ipairs(hearth.botany.list()) do + local s=assert(sources[r.id]); total=total+1 + assert(s.source_species==r.latin and s.source_url:match("^https://en%.wikipedia%.org/wiki/")) + assert(s.identity_revision==hearth.wiki_articles[r.id].revision) + assert(s.identity_revision_url==hearth.wiki_articles[r.id].revision_url) + if not s.reviewed then assert(s.note:find("no species%-effect evidence reviewed")) end + for _,row in ipairs(E.describe(r)) do + assert(row.status=="simulated" or row.status=="descriptive") + assert(row.game~="" and row.source_species==r.latin and row.source_url==s.source_url) + end + end + assert(total==80) + assert(E.source("unknown")==nil and #E.describe({id="unknown",tags={engineer=true}})==0) +end) +test("reviewed mechanisms cite species and explicitly identify broader evidence",function() + for _,id in ipairs({"alder","clover","dog_lichen","sphagnum","duckweed","birch_polypore","fly_agaric"}) do + local s=E.source(id); assert(s.reviewed and s.reviewed_on=="2026-11-03" and s.evidence_url) + end + assert(E.source("sphagnum").evidence_url:match("/Sphagnum$")) + assert(E.source("duckweed").evidence_url:match("/Lemnoideae$")) + assert(E.source("dog_lichen").evidence_url:match("/Peltigera$")) + assert(entry("fly_agaric","ectomycorrhiza").game:find("debits at most 1 existing soil nutrient")) +end) +test("descriptors do not silently become engineer, parasite, resin or poison mechanics",function() + for _,pair in ipairs({{"willow","engineer"},{"bracken","allelopathy"},{"honey_fungus","parasite"},{"pine","resin"},{"foxglove","toxicity"}}) do + assert(entry(pair[1],pair[2]).status=="descriptive") + end + for _,id in ipairs({"alder","clover","dog_lichen"}) do assert(entry(id,"nitrogenfixer").status=="simulated") end + assert(entry("pine","canopy").game:find("15%%")) + assert(entry("birch_polypore","saprotroph").status=="simulated") + assert(entry("yarrow","pollinator").status=="simulated") +end) +test("sphagnum changes only a fresh local land estimate with two-mat quantitative ceilings",function() + local h={loaded=true,water=0,aquatic="bank",moisture=0.65,ph=6.5,light=10,nutrients=3,nested={value=1}} + local out=E.habitat(h,{sphagnum=10000,duckweed=10000,willow=10000}) + near(out.moisture,0.85); near(out.ph,6.1); assert(out.light==10 and out.nutrients==3) + assert(h.ph==6.5 and h.moisture==0.65 and out.source_ph==6.5 and out.source_moisture==0.65) + out.nested.value=2; assert(h.nested.value==1) + for _=1,20 do near(E.habitat(h,{sphagnum=2}).ph,6.1) end + out=E.habitat({loaded=true,water=0,moisture=0.99,ph=3.6},{sphagnum=2}) + assert(out.moisture==1 and out.ph==3.5) + local cowslip={loaded=true,ground="grass",substrate="grass",water=0,aquatic="land",light=12,moisture=0.3,ph=6} + assert(hearth.botany.suitable("cowslip",cowslip,"Spring")) + assert(not hearth.botany.suitable("cowslip",E.habitat(cowslip,{sphagnum=2}),"Spring"),"acidification changes real niche eligibility") +end) +test("floating mats shade only submerged freshwater, with a six-light ceiling and no brightening",function() + for _,base in ipairs({15,7,3,1,0}) do + local h={loaded=true,water=2,aquatic="submerged",freshwater=true,light=base,moisture=1,ph=6.5} + local out=E.habitat(h,{duckweed=10000,sphagnum=10000}) + assert(out.light==base-math.min(6,math.max(0,base-2)) and h.light==base) + assert(out.water==2 and out.ph==6.5 and out.moisture==1) + end + for _,mode in ipairs({"surface","land","salt"}) do + local h={loaded=true,water=mode=="land" and 0 or 2,aquatic=mode=="salt" and "submerged" or mode,freshwater=mode~="salt",light=10} + assert(E.habitat(h,{duckweed=4}).light==10) + end +end) +test("unknown, malformed and unloaded observations grant no effects",function() + local h={loaded=true,water=0,moisture=0.5,ph=6.5,light=10} + for _,n in ipairs({-1,math.huge,0/0,"2",false}) do + local out=E.habitat(h,{sphagnum=n,duckweed=n}); assert(out.ph==h.ph and out.moisture==h.moisture) + end + assert(E.habitat(h,{unknown=999,alder=999}).ph==6.5) + h.loaded=false; assert(E.habitat(h,{sphagnum=2}).ph==6.5) + assert(E.habitat(nil)==nil) +end) +test("source and description results cannot mutate the reviewed policy",function() + local row=entry("sphagnum","engineer"); row.status="descriptive"; row.source_url="wrong" + assert(entry("sphagnum","engineer").status=="simulated") + local sources=E.sources(); sources.alder.note="wrong" + assert(E.source("alder").note~="wrong") +end) +print("LAST_HEARTH_SPECIES_EFFECTS_PASS "..passed) diff --git a/tests/species_reader_spec.lua b/tests/species_reader_spec.lua new file mode 100755 index 0000000..18b6ce9 --- /dev/null +++ b/tests/species_reader_spec.lua @@ -0,0 +1,223 @@ +-- Focused injected reader tests; no engine, network or metadata required. +local target=assert(arg[1],"species_reader.lua path required") +local path=target:match("^(.*[/\\])") or "" +local factory=dofile(target) +local total=0 +local function test(name,fn) + local ok,err=pcall(fn); assert(ok,name..": "..tostring(err)) + total=total+1; print("PASS "..name) +end +local function forbidden() error("Reader attempted a side effect") end +local screens, hits, node, reads, rays={}, {}, {name="hearth:oak_log"}, 0, 0 +local H={sessions={alice={}},players={alice={food=73,water=61}},clock={season="Autumn"}} +H.botany=dofile(path.."botany.lua")(H,{}) +H.wiki_articles=dofile(path.."wiki_articles.lua") +H.species_effects=dofile(path.."species_effects.lua")(H,{}) +local api={set_node=forbidden,swap_node=forbidden,get_meta=forbidden, + emerge_area=forbidden,forceload_block=forbidden,request_http_api=forbidden, + show_url=forbidden,register_chatcommand=forbidden,register_on_player_receive_fields=forbidden} +function api.formspec_escape(s) + return (s:gsub("\\","\\\\"):gsub("%[","\\["):gsub("%]","\\]"):gsub(";","\\;"):gsub(",","\\,")) +end +function api.show_formspec(name,form,spec) screens[#screens+1]={name=name,form=form,spec=spec} end +function api.get_node_or_nil() reads=reads+1; return node end +local eye_height=1.6 +local player={get_player_name=function() return "alice" end, + get_pos=function() return {x=0,y=0,z=0} end, + get_properties=function() return {eye_height=eye_height} end, + get_look_dir=function() return {x=0,y=0,z=2} end, + get_meta=forbidden,set_pos=forbidden,get_inventory=forbidden} +local function hit(z,name) + return {type="node",under={x=0,y=2,z=z},intersection_point={x=0,y=1.6,z=z},name=name} +end +function api.raycast(start,finish,objects,liquids) + rays=rays+1 + assert(start.x==0 and start.y==1.6 and start.z==0,"not eye origin") + assert(finish.y==start.y and finish.z==6,"reach not normalized to six") + assert(objects==false and liquids==false) + local i=0 + return function() i=i+1; assert(i<=3,"unbounded scan"); return hits[i] end +end +local R=factory(H,api) +local function latest() return screens[#screens].spec end +test("factory installs no global callbacks or API mutations",function() + assert(type(R.show)=="function" and type(R.look)=="function" and type(R.resolve)=="function" and type(R.handle)=="function") + assert(api.set_node==forbidden and H.species_reader==nil) +end) +test("exact IDs and canonical Latin resolve without fuzzy URL fallback",function() + assert(R.resolve("oak").latin=="Quercus robur") + assert(R.resolve(" Quercus robur ").id=="oak") + for _,v in ipairs({"Quercus","quercus robur","Pedunculate oak","hearth:oak_log","https://en.wikipedia.org/wiki/Oak",string.rep("x",121),42}) do + assert(not R.resolve(v)) + end + assert(not R.resolve(nil)) +end) +test("actual botanical seasonal stages logs leaves and copy API resolve",function() + local S=dofile(path.."seasons.lua")(H,{}) + H.seasons=S + -- The real diversity factory installs the existing seasonal tree mappings. + H.diversity=dofile(path.."diversity.lua")(H,{}) + for _,v in ipairs({"hearth:oak_seedling","hearth:oak_young","hearth:oak_mature","hearth:oak_log","hearth:oak_leaves","hearth:oak_leaves_winter","hearth:clover_winter"}) do + local r=H.botany.get(v); assert(r,v) + assert(R.show(player,r.id)) + end + local r=R.resolve("oak"); r.common="changed"; assert(R.resolve("oak").common~="changed") +end) +test("ray uses normalized six-block eye reach and loaded-node read only",function() + hits={hit(6)}; node={name="hearth:oak_log"}; reads=0 + assert(R.look(player).id=="oak" and reads==1) +end) +test("ray rejects hits beyond reach without node reads",function() + hits={hit(6.01)}; reads=0; assert(not R.look(player) and reads==0) +end) +test("unknown and unloaded first surfaces cannot be inspected through",function() + hits={hit(2),hit(3)} + for _,v in ipairs({false,{name="ignore"},{name="hearth:stone"},{name="foreign:oak_log"}}) do + node=v or nil; reads=0; assert(not R.look(player) and reads==1) + end +end) +test("empty ray and invalid hit positions do not read map",function() + hits={}; reads=0; assert(not R.look(player) and reads==0) + hits={{type="node",under={x=0,y=0,z=0}}}; assert(not R.look(player) and reads==0) + hits={hit(3)}; hits[1].under.x=0/0; assert(not R.look(player) and reads==0) + eye_height=math.huge; local n=rays; assert(not R.look(player) and rays==n); eye_height=1.6 +end) +test("all eighty entries render with honest seventy-eight excerpt and two note headings",function() + local excerpts,notes=0,0 + for _,r in ipairs(H.botany.list()) do + assert(R.show(player,r.id)); local a=H.wiki_articles[r.id] + assert(latest():find(api.formspec_escape(r.latin),1,true)) + if a.kind=="wikipedia_intro" then + excerpts=excerpts+1; assert(latest():find("Wikipedia excerpt (offline)",1,true)) + else + notes=notes+1 + assert(r.id=="old_mans_beard" or r.id=="cup_lichen") + assert(latest():find("No Wikipedia article cached (offline)",1,true)) + assert(not latest():find("Wikipedia excerpt (offline)",1,true)) + assert(latest():find("CC0",1,true) and not latest():find("CC BY-SA 4.0",1,true)) + assert(not a.revision and latest():find(api.formspec_escape(a.text),1,true)) + end + end + assert(excerpts==78 and notes==2) +end) +test("excerpt retains full shipped introduction and attribution revision history license",function() + assert(R.show(player,"oak")); local a=H.wiki_articles.oak + for _,key in ipairs({"title","text","attribution","license","license_url","url","revision_url","history_url","revision_timestamp","retrieved","changes"}) do + assert(latest():find(api.formspec_escape(a[key]),1,true),key) + end + assert(latest():find("not the full wiki",1,true) and latest():find("not",1,true)) + assert(latest():find("medical advice",1,true)) +end) +test("every shipped introduction fits and is displayed in full",function() + for _,r in ipairs(H.botany.list()) do + assert(R.show(player,r.id)) + assert(latest():find(api.formspec_escape(H.wiki_articles[r.id].text),1,true),r.id) + end +end) +test("current effects use actual describe records with simulated descriptive separation",function() + assert(R.show(player,"honey_fungus",2)) + assert(latest():find("SIMULATED",1,true) and latest():find("DESCRIPTIVE (not simulated)",1,true)) + assert(latest():find("no infection or attack",1,true)) + assert(R.show(player,"alder",2)) + assert(latest():find("900 warm active seconds",1,true) and latest():find("Frankia",1,true)) + assert(latest():find("not measured Wikipedia constants",1,true)) +end) +test("article textarea and provenance are unnamed read-only copyable fields",function() + assert(R.show(player,"oak")) + local count=0 + for body in latest():gmatch("textarea%[([^\n]-);;;") do count=count+1 end + assert(count==3 and not latest():find("field[",1,true)) + assert(latest():find("URLs are copyable",1,true)) +end) +test("forged IDs source URLs text and pages never replace session article",function() + assert(R.show(player,"oak")) + local before=#screens + assert(R.handle(player,{id="pine",url="javascript:boom",text="replacement",page="999",reader_tab="99"})) + assert(#screens==before and H.sessions.alice.species_reader.id=="oak") + R.handle(player,{reader_tab="2",id="pine"}); assert(H.sessions.alice.species_reader.id=="oak") + R.handle(player,{reader_tab="1",text="replacement"}); assert(latest():find(api.formspec_escape(H.wiki_articles.oak.text),1,true)) +end) +test("malicious cached text source and URLs are plain escaped inert data",function() + local a=H.wiki_articles.oak + local payload="];button[0,0;1,1;attack;Injected]" + H.wiki_articles.oak={kind="wikipedia_intro",text=payload,url=payload,attribution=payload,license=payload} + assert(R.show(player,"oak")) + assert(latest():find(api.formspec_escape(payload),1,true) and not latest():find(";attack;Injected]",1,true)) + assert(not latest():find("hypertext[",1,true) and not latest():find("button_url[",1,true)) + H.wiki_articles.oak=a +end) +test("Unicode body caps at eight thousand with four-thousand character pages",function() + local a=H.wiki_articles.oak + local char=string.char(195,169) + H.wiki_articles.oak={kind="wikipedia_intro",text=string.rep(char,9000)} + assert(R.show(player,"oak")) + assert(latest():find(string.rep(char,4000),1,true) and not latest():find(string.rep(char,4001),1,true)) + assert(latest():find("display limited to 8000",1,true)) + R.handle(player,{reader_next=true}); assert(H.sessions.alice.species_reader.page==2) + assert(latest():find(string.rep(char,4000),1,true)) + R.handle(player,{reader_next=true}); assert(H.sessions.alice.species_reader.page==2) + R.handle(player,{reader_previous=true}); assert(H.sessions.alice.species_reader.page==1) + assert(#latest()<20000) + H.wiki_articles.oak=a +end) +test("absent optional catalogue sources and effects are explicit not counterfeit",function() + local B,A,E=H.botany,H.wiki_articles,H.species_effects + H.wiki_articles=nil; assert(R.show(player,"oak")); assert(latest():find("No article is available",1,true)) + H.species_effects=nil; assert(R.show(player,"oak",2)); assert(latest():find("Effect descriptions unavailable",1,true)) + H.botany=nil; assert(not R.resolve("oak") and not R.show(player,"oak")) + H.botany,H.wiki_articles,H.species_effects=B,A,E +end) +test("player close confirmation clears reader but changes no gameplay or persistent data",function() + local needs=H.players.alice + assert(R.show(player,"oak")); local old=H.sessions.alice.species_reader + R.handle(player,{text="modified"}); assert(H.sessions.alice.species_reader==old) + R.handle(player,{quit=true}); assert(not H.sessions.alice.species_reader) + assert(H.players.alice==needs and needs.food==73 and needs.water==61) + assert(not R.handle(player,{reader_next=true})) +end) +test("missing session rejects forms and forged follow-up packets",function() + local s=H.sessions.alice; H.sessions.alice=nil; local n=#screens + assert(not R.show(player,"oak") and not R.handle(player,{reader_tab="2"})) + assert(#screens==n); H.sessions.alice=s +end) +test("actual feedback HUD hints known species with hand or building and fails closed unloaded",function() + local saved_core,saved_hearth=core,hearth + local ok,err=pcall(function() + local t,wield_name,message=1,"","" + local s={} + hearth={sessions={alice=s},botany=H.botany,movement={delta=function(d) return d end}, + construction={preview=function() return true,"ok" end}} + core={get_us_time=function() return t*1000000 end, + register_chatcommand=function() end,raycast=function(start,finish) + assert(start.y==1.6 and finish.z==5) + local i=0; return function() i=i+1; return hits[i] end + end,get_node_or_nil=api.get_node_or_nil, + get_meta=forbidden,set_node=forbidden,swap_node=forbidden,sound_play=forbidden, + get_dig_params=function() return {diggable=true} end, + registered_nodes={["hearth:oak_log"]={description="Oak log",groups={tree=1}}, + ["hearth:stone"]={description="Stone",groups={stone=1}}, + ["hearth:wall"]={description="Wall",groups={wood=1}}}} + local p={get_player_name=player.get_player_name,get_pos=player.get_pos, + get_properties=player.get_properties,get_look_dir=function() return {x=0,y=0,z=1} end, + get_hp=function() return 20 end,get_velocity=function() return {x=0,y=0,z=0} end, + get_player_control=function() return {} end,set_animation=function() end, + hud_add=function(_,def) message=def.text; return 1 end, + hud_change=function(_,_,key,value) if key=="text" then message=value end end} + function p:get_wielded_item() + return {get_name=function() return wield_name end,get_wear=function() return 0 end, + get_tool_capabilities=function() return {} end} + end + dofile(path.."feedback.lua") + hits={hit(3)}; node={name="hearth:oak_log"} + hearth.feedback_step(p,0); assert(message:find("/inspect species",1,true)) + wield_name="hearth:wall"; t=2 + hearth.feedback_step(p,0) + assert(message:find("Ready to place",1,true) and message:find("/inspect species",1,true)) + wield_name=""; node={name="hearth:stone"}; t=3 + hearth.feedback_step(p,0); assert(not message:find("/inspect",1,true)) + node=nil; t=4; hearth.feedback_step(p,0); assert(message=="") + end) + core,hearth=saved_core,saved_hearth + assert(ok,err) +end) +print("PASS "..total.." species reader tests") diff --git a/tests/succession_spec.lua b/tests/succession_spec.lua new file mode 100755 index 0000000..95106c2 --- /dev/null +++ b/tests/succession_spec.lua @@ -0,0 +1,938 @@ +-- Standalone Lua 5.1+; deterministic loaded-world fixture, no engine required. +local target = assert(arg[1], "succession.lua path required") +local original_time, passed = os.time, 0 +local now, world, fields, protected, claims, missing, light, humidity, temperature, season +local api, hearth, sim, probes, writes, reads, players, lbm, real_positions +local p = {x=0,y=1,z=0} +local function key(q) return q.x .. ":" .. q.y .. ":" .. q.z end +local function pos(x,y,z) return {x=x or 0,y=y or 1,z=z or 0} end +local function metadata(q) + local k=key(q); fields[k]=fields[k] or {} + local f=fields[k] + return {get_string=function(_,name) return f[name] or "" end, + set_string=function(_,name,value) f[name]=tostring(value) end, + to_table=function() return {fields=f} end} +end +local function put(q,k,natural) + world[key(q)]={name=k=="air" and k or "hearth:"..k} + if natural then metadata(q):set_string("succ_natural","1") end +end +local function name(q) return api.get_node_or_nil(q).name end +local function load() + probes=0 + sim=dofile(target)(hearth,api) +end +local function reset() + p=pos(0,1,0) + now,world,fields,protected,claims,missing=1000000,{},{},{},{},{} + light,humidity,temperature,season=15,60,15,"Summer" + writes,reads,players,real_positions=0,0,true,false + api={} + function api.get_meta(q) return metadata(q) end + function api.get_node_or_nil(q) + reads=reads+1 + if missing[key(q)] then return nil end + return world[key(q)] or {name=q.y==0 and "hearth:grass" or "air"} + end + function api.swap_node(q,n) writes=writes+1; world[key(q)]=n end + function api.get_node_light() return light end + function api.get_biome_data() return {humidity=humidity} end + function api.register_globalstep(fn) api.step=fn end + function api.register_lbm(def) lbm=def end + function api.get_connected_players() + if not players then return {} end + return {{get_pos=function() + -- Follow the deterministic scan so its first column is our test plot. + -- Subsequent probes of the same node at the same timestamp earn no time. + local index=probes%625; probes=probes+1 + if real_positions then return pos(0,1,0) end + return pos(p.x+12-index%25,p.y,p.z+12-math.floor(index/25)) + end}} + end + hearth={claim_owner_at=function(q) return claims[key(q)] and "owner" end, + claim_allowed=function(q, who) return who ~= "" and not claims[key(q)] end, + external_protected=function(q) return protected[key(q)] end, + climate_at=function() return {temperature=temperature,season=season} end} + hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api) + os.time=function() return now end + load() +end +local function advance(dt) + now=now+(dt or 60) + assert(sim.step(1)==sim.BUDGET) +end +local function baseline(k,age) + if k then put(p,k,true) end + sim.step(1) + if age then metadata(p):set_string("succ_age",age) end +end +local function field(q,f) return tonumber(metadata(q):get_string(f)) or 0 end +local function test(label,fn) + reset() + local ok,err=pcall(fn) + assert(ok,label..": "..tostring(err)); passed=passed+1 + print("ok "..passed.." - "..label) +end + +test("existing natural ground recruits native grass without regeneration",function() + baseline() + advance(60); advance(60) + assert(name(p)=="air") + advance(60) + assert(name(p)=="hearth:meadow_grass") + assert(metadata(p):get_string("succ_natural")=="1") +end) +test("nutrients accelerate establishment and are consumed rather than copied",function() + metadata(pos(0,0,0)):set_string("succ_nutrients","4") + baseline(); advance(100) + assert(name(p)=="hearth:meadow_grass") + assert(field(pos(0,0,0),"succ_nutrients")==3) +end) +test("sunny mixed meadow recruits wildflowers, dense grass excludes them",function() + put(pos(1),"meadow_grass",true); put(pos(-1),"meadow_grass",true) + baseline(); advance(100); advance(100) + assert(name(p)=="hearth:wildflowers") + for x=-2,2 do for z=-2,2 do + if x~=0 or z~=0 then put(pos(x,1,z),"meadow_grass",true) end + end end + for _=1,6 do advance(100) end + assert(name(p)=="hearth:litter") +end) +test("mature canopy shades out grasses and favors moist ferns",function() + put(pos(1),"mature_tree",true); put(pos(-1),"mature_tree",true); put(pos(0,1,1),"mature_tree",true) + baseline("meadow_grass") + assert(sim.habitat(p).light==6) + for _=1,6 do advance(100) end + assert(name(p)=="hearth:litter") + put(p,"air"); metadata(p):set_string("succ_age","180") + advance(100) + assert(name(p)=="hearth:fern") +end) +test("dry exposed fern dies, while river moisture changes its niche",function() + light,humidity=14,20 + baseline("fern") + for _=1,6 do advance(100) end + assert(name(p)=="hearth:litter") + put(pos(1,0,0),"water_source") + assert(sim.habitat(p).water==1 and sim.habitat(p).moisture>0.2) +end) +test("tree stages require active age, then mature trees become decomposing wood",function() + baseline("tree_seedling",21500); advance(100) + assert(name(p)=="hearth:young_tree" and field(p,"succ_age")==0) + metadata(p):set_string("succ_age","86300"); advance(100) + assert(name(p)=="hearth:mature_tree") + metadata(p):set_string("succ_age","259100"); advance(100) + assert(name(p)=="hearth:deadwood") + metadata(p):set_string("succ_age","7100"); advance(100) + assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3) +end) +test("established sparse meadow eventually recruits a native tree",function() + baseline("meadow_grass",7100); advance(100) + assert(name(p)=="hearth:tree_seedling") +end) +test("dense meadow delays rather than permanently preventing woodland recruitment",function() + for x=-2,2 do for z=-2,2 do put(pos(x,1,z),"meadow_grass",true) end end + baseline("meadow_grass",7100); advance(100) + assert(name(p)=="hearth:meadow_grass") + metadata(p):set_string("succ_age","14300"); advance(100) + assert(name(p)=="hearth:tree_seedling") +end) +test("mature tree disperses one seed on a durable attempt schedule",function() + baseline("mature_tree",2000) + metadata(p):set_string("succ_serial","9") + advance(100) + local seed=pos(-1,1,0) -- serial 10 selects offset 3 + assert(name(seed)=="hearth:tree_seedling") + local before=writes + sim.step(99999) + assert(writes==before and field(p,"succ_serial")==10) +end) +test("grass spreads locally but never replaces occupied flower or crop cells",function() + baseline("meadow_grass",1000) + metadata(p):set_string("succ_serial","2") + put(pos(0,1,-1),"wildflowers",true) -- serial 3 selects offset 4 + advance(100) + assert(name(pos(0,1,-1))=="hearth:wildflowers") + metadata(p):set_string("succ_serial","2") + put(pos(0,1,-1),"air"); advance(100) + assert(name(pos(0,1,-1))=="hearth:meadow_grass") +end) +test("moist shaded litter seeds fungi, then returns finite nutrients",function() + light=10 + baseline("litter",300); advance(100) + assert(name(pos(0,1,1))=="hearth:fungi") -- first attempt selects offset 2 + metadata(p):set_string("succ_age","1700"); advance(100) + assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1) + advance(1) + assert(field(pos(0,0,0),"succ_nutrients")==1) +end) +test("dung decomposes, fertilizes soil and does not instantly sprout a forest",function() + assert(sim.deposit(p)) + local patch=pos(-1,1,-1) + assert(name(patch)=="hearth:dung") + -- Move the patch to the scheduler's plot without resetting its natural marker. + put(p,"dung",true); baseline(nil,500); advance(100) + assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) +end) +test("grazing consumes exactly one natural grass or fern, not flowers or planted nodes",function() + put(p,"meadow_grass",true); put(pos(1),"fern",true) + assert(sim.forage(p)) + assert(name(p)=="air" and name(pos(1))=="hearth:fern") + assert(field(p,"succ_next")==now+300) + put(pos(1),"wildflowers",true) + assert(not sim.forage(p)) + put(p,"meadow_grass",false); metadata(p):set_string("succ_natural","") + assert(not sim.forage(p)) +end) +test("grazing cooldown prevents immediate seed recolonization",function() + baseline("meadow_grass") + assert(sim.forage(p)) + advance(100); advance(100) + assert(name(p)=="air") + advance(100) + assert(name(p)=="hearth:meadow_grass") +end) +test("source claims and external protection block all simulation writes",function() + baseline("mature_tree",259100) + protected[key(p)]=true; advance(100) + assert(name(p)=="hearth:mature_tree" and writes==0) + protected={}; claims[key(p)]=true; advance(100) + assert(writes==0 and not sim.deposit(p) and not sim.forage(p)) +end) +test("support protection and markers block source decay and grazing",function() + baseline("meadow_grass",21600) + protected[key(pos(0,0,0))]=true; advance(100) + assert(writes==0 and not sim.forage(p)) + protected={}; metadata(pos(0,0,0)):set_string("placed_by","builder"); advance(100) + assert(writes==0 and not sim.forage(p)) +end) +test("seed dispersal respects destination AND destination-support protection",function() + baseline("mature_tree",2000) + local seed=pos(-1,1,0) + protected[key(seed)]=true + metadata(p):set_string("succ_serial","9"); advance(100) + assert(name(seed)=="air") + protected={}; claims[key(pos(-1,0,0))]=true + metadata(p):set_string("succ_serial","9"); advance(100) + assert(name(seed)=="air") +end) +test("any supported placement marker opts the plot out of nature",function() + for _,fieldname in ipairs({"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at","third_party:hand_placed"}) do + fields={}; metadata(p):set_string(fieldname,"1") + baseline(); advance(100); advance(100) + assert(name(p)=="air",fieldname) + end +end) +test("builds, farms, water and nonnatural plants are never overwritten",function() + for _,k in ipairs({"wall","crop_1","water_source","sapling","berry_bush"}) do + world,fields={},{} + put(p,k); baseline(); advance(100); advance(100) + assert(name(p)=="hearth:"..k,k) + end + world,fields={},{}; put(pos(1),"crop_4"); baseline(); advance(100); advance(100) + assert(name(p)=="air", "adjacent farm buffer") +end) +test("unloaded source, support, or destination never cause writes or force loading",function() + baseline("mature_tree",2000) + missing[key(p)]=true; advance(100); assert(writes==0) + missing={}; missing[key(pos(0,0,0))]=true; advance(100); assert(writes==0) + missing={}; missing[key(pos(-1,1,0))]=true + metadata(p):set_string("succ_serial","9"); advance(100) + assert(writes==0) +end) +test("work and node reads remain bounded after enormous frame stalls",function() + real_positions=true + reads=0; local work=sim.step(1000000000) + assert(work==8 and probes==8) + assert(reads<3000,"bounded stencil reads: "..reads) + players=false; reads=0 + assert(sim.step(999999)==0 and reads==0) +end) +test("fixed total budget visits every column fairly across multiple players",function() + local columns, counts, current, first = {{},{}}, {0,0}, 0, false + api.get_connected_players=function() + local result={} + for i=1,2 do + local who=i + result[i]={get_pos=function() current=who; first=true; counts[who]=counts[who]+1; return pos(who*100,1,0) end} + end + return result + end + api.get_node_or_nil=function(q) + if first then columns[current][key(q)]=true; first=false end + return nil + end + for _=1,157 do assert(sim.step(1)==8) end + for i=1,2 do + local count=0; for _ in pairs(columns[i]) do count=count+1 end + assert(count==625 and counts[i]==628) + end + assert(sim.CACHE_LIMIT>=625*10 and sim.MAX_GAP>=625*10/sim.BUDGET) +end) +test("vegetation reload alone invalidates age even if its supporting block stayed loaded",function() + baseline("mature_tree",259100) + lbm.action(p); advance(100) + assert(name(p)=="hearth:mature_tree" and field(p,"succ_age")==259100) +end) +test("no catchup on long absence, disconnect, unload, restart or clock rewind",function() + baseline("tree_seedling",21500) + advance(864000); assert(name(p)=="hearth:tree_seedling" and field(p,"succ_age")==21500) + players=false; sim.step(1); players=true + advance(100); assert(field(p,"succ_age")==21500) + lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==21500) + load(); advance(100); assert(field(p,"succ_age")==21500) + advance(-100); assert(field(p,"succ_age")==21500) + advance(100); assert(name(p)=="hearth:young_tree") +end) +test("failed dispersal attempts survive restart without reroll or advancing age",function() + baseline("mature_tree",2000) + metadata(p):set_string("succ_serial","9") + protected[key(pos(-1,1,0))]=true; advance(100) + assert(field(p,"succ_serial")==10) + local age,due=field(p,"succ_age"),field(p,"succ_next") + protected={}; load(); sim.step(1) + assert(field(p,"succ_serial")==10 and field(p,"succ_next")==due) + advance(30) + assert(field(p,"succ_serial")==10 and name(pos(-1,1,0))=="air") + assert(field(p,"succ_age")==age+30) +end) +test("cold weather pauses recruitment and stage growth without catchup",function() + temperature=-5; baseline(); advance(100); advance(100) + assert(name(p)=="air") + put(p,"tree_seedling",true); metadata(p):set_string("succ_age","21600"); advance(100) + assert(name(p)=="hearth:tree_seedling") +end) +test("habitat is read only and malformed API coordinates fail closed",function() + put(p,"meadow_grass",true); put(pos(1),"wildflowers",true); put(pos(-1),"fern",true) + local h=sim.habitat(p) + assert(h.grass==1 and h.flowers==1 and h.cover==1 and h.loaded) + assert(writes==0 and metadata(p):get_string("succ_age")=="") + for _,invalid in ipairs({{}, {x=0/0,y=1,z=0}, {x=math.huge,y=1,z=0}, {x=999999,y=1,z=0}}) do + assert(not sim.forage(invalid) and not sim.deposit(invalid) and not sim.habitat(invalid).loaded) + end +end) + +test("season swaps preserve natural identity and active age; winter pauses perennial senescence",function() + baseline("meadow_grass",1000) + season,temperature="Winter",-5 + advance(100) + assert(name(p)=="hearth:meadow_grass_winter" and field(p,"succ_age")==1000) + assert(metadata(p):get_string("succ_natural")=="1" and sim.habitat(p).grass==1) + for _=1,7 do advance(100) end + assert(name(p)=="hearth:meadow_grass_winter" and field(p,"succ_age")==1000) + season,temperature="Spring",15; advance(100) + assert(name(p)=="hearth:meadow_grass" and field(p,"succ_age")==1100) +end) +test("spring greens recruit moist sunny gaps, mushrooms autumn shade; dormant plants are occupied",function() + season="Spring"; baseline(); advance(100); advance(100) + assert(name(p)=="hearth:spring_greens_patch_spring") + metadata(p):set_string("succ_harvest_ready_at",tostring(now+86400)) + season="Summer"; advance(100) + assert(name(p)=="hearth:spring_greens_patch" and field(p,"succ_harvest_ready_at")>now) + assert(not sim.deposit(p) or name(p)=="hearth:spring_greens_patch") + reset(); season,light,humidity="Autumn",8,70 + baseline(); advance(100); advance(100) + assert(name(p)=="hearth:autumn_mushrooms_patch_autumn") + reset(); season,light,humidity="Autumn",15,70 + baseline(); advance(100); advance(100) + assert(name(p)=="hearth:meadow_grass") +end) +test("forage dispersal respects niches, occupation and durable harvest metadata",function() + season="Spring"; baseline("spring_greens_patch_spring",1000) + metadata(p):set_string("succ_harvest_ready_at",tostring(now+86400)) + metadata(p):set_string("succ_serial","2") + advance(100) + assert(name(pos(0,1,-1))=="hearth:spring_greens_patch_spring") + assert(field(p,"succ_harvest_ready_at")>now) + metadata(p):set_string("succ_serial","2"); advance(100) + assert(name(pos(0,1,-1))=="hearth:spring_greens_patch_spring") +end) +test("tree mesh growth denies foreign, marked, claimed and unloaded target volume",function() + for _, obstacle in ipairs({"foreign","placed","claimed","unloaded"}) do + reset(); baseline("young_tree",86300) + local q=pos(2,4,0) + if obstacle=="foreign" then put(q,"wall") end + if obstacle=="placed" then metadata(q):set_string("player_placed","1") end + if obstacle=="claimed" then claims[key(q)]=true end + if obstacle=="unloaded" then missing[key(q)]=true end + advance(100) + assert(name(p)=="hearth:young_tree",obstacle) + end +end) +test("leafless winter canopy admits more light than summer foliage",function() + put(p,"mature_tree",true) + local summer=sim.habitat(p) + season="Winter" + local winter=sim.habitat(p) + assert(winter.canopysummer.light) + assert(math.abs(winter.canopy-0.15)<0.0001) +end) +test("winter mature canopy still sheds litter and decomposition returns nutrients",function() + season,temperature="Winter",-5; baseline("mature_tree",2000) + metadata(p):set_string("succ_serial","4"); advance(100) + assert(name(pos(-2,1,-1))=="hearth:litter") + put(p,"litter",true); metadata(p):set_string("succ_age","1700") + advance(100) + assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1) +end) +test("seasonal probes reconcile old canopy and handplaced plants within twelve heights",function() + season="Autumn" + put(pos(0,5,0),"leaves"); put(p,"wildflowers") + metadata(p):set_string("player_placed","1") + metadata(p):set_string("succ_age","912") + sim.step(1) + assert(name(pos(0,5,0))=="hearth:leaves_autumn") + assert(name(p)=="hearth:wildflowers_autumn" and field(p,"succ_age")==912) + assert(metadata(p):get_string("player_placed")=="1") +end) +test("season appearance has a fixed 96 swap ceiling after a stalled frame",function() + season="Winter"; real_positions=true + api.get_node_or_nil=function(q) + reads=reads+1 + return world[key(q)] or {name="hearth:leaves"} + end + assert(sim.step(999999)==8) + assert(writes==96 and reads<400) +end) +test("incomplete habitat and protected source write no ecological metadata",function() + baseline("tree_seedling",1000) + local age,serial,due=field(p,"succ_age"),field(p,"succ_serial"),field(p,"succ_next") + missing[key(pos(2,4,2))]=true + advance(100) + assert(field(p,"succ_age")==age and field(p,"succ_serial")==serial and field(p,"succ_next")==due) + assert(writes==0) + missing={}; protected[key(p)]=true; advance(100) + assert(field(p,"succ_age")==age and field(p,"succ_serial")==serial and field(p,"succ_next")==due) + assert(writes==0) +end) + +local function diverse() + hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api) + load() +end +test("unmarked mapgen deadwood is adopted and decays after two active hours with finite capped nutrients",function() + diverse(); humidity=20; put(p,"deadwood"); baseline() + assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==0) + for _=1,71 do advance(100) end + assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100) + assert(field(pos(0,0,0),"succ_nutrients")==0) + advance(100) + assert(name(p)=="hearth:litter" and field(p,"succ_age")==0) + assert(field(pos(0,0,0),"succ_nutrients")==3) + local before=writes; sim.step(999999) + assert(writes==before and field(pos(0,0,0),"succ_nutrients")==3) + for _=1,18 do advance(100) end + assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) + advance(1); assert(field(pos(0,0,0),"succ_nutrients")==4) + reset(); diverse(); humidity=20 + metadata(pos(0,0,0)):set_string("succ_nutrients","7") + put(p,"deadwood"); baseline(nil,7100); advance(100) + assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==8) +end) +test("adopted deadwood never catches up across absence, disconnect, reload, restart or clock rewind",function() + diverse(); humidity=20; put(p,"deadwood"); baseline(nil,7100) + advance(864000); assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100) + players=false; sim.step(1); players=true; advance(100) + assert(field(p,"succ_age")==7100) + lbm.action(p); advance(100); assert(field(p,"succ_age")==7100) + lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==7100) + load(); advance(100); assert(field(p,"succ_age")==7100) + advance(-100); assert(field(p,"succ_age")==7100) + assert(field(pos(0,0,0),"succ_nutrients")==0) + advance(100) + assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3) +end) +test("species stages keep birch identity and deadwood records its originating species",function() + diverse(); baseline("birch_seedling",21500); advance(100) + assert(name(p)=="hearth:birch_young" and metadata(p):get_string("succ_species")=="birch") + metadata(p):set_string("succ_age","30000"); advance(100) + assert(name(p)=="hearth:birch_young","young trees must not take herb senescence") + metadata(p):set_string("succ_age","86300"); advance(100) + assert(name(p)=="hearth:birch_mature") + metadata(p):set_string("succ_age","259100"); advance(100) + assert(name(p)=="hearth:deadwood" and metadata(p):get_string("succ_species")=="birch") + metadata(p):set_string("succ_age","7100"); advance(100) + assert(name(p)=="hearth:litter" and metadata(p):get_string("succ_species")=="birch") +end) +test("species seeds retain parent identity and reject an incompatible destination soil",function() + diverse(); baseline("birch_mature",2000) + metadata(p):set_string("succ_serial","9"); advance(100) + assert(name(pos(-1,1,0))=="hearth:birch_seedling") + reset(); diverse(); put(pos(0,0,0),"podzol"); put(pos(-1,0,0),"peat") + baseline("pine_mature",2000); metadata(p):set_string("succ_serial","9"); advance(100) + assert(name(pos(-1,1,0))~="hearth:pine_seedling") -- litter may share the attempt +end) +test("pine is evergreen while species deciduous canopies lose winter shade",function() + diverse(); put(p,"pine_mature",true); put(pos(1,2,0),"pine_leaves") + local summer=sim.habitat(p); season="Winter" + local winter=sim.habitat(p); assert(winter.canopy==summer.canopy) + put(p,"oak_mature",true); put(pos(1,2,0),"oak_leaves_winter") + assert(math.abs(sim.habitat(p).canopy-0.1725)<0.0001) +end) +test("species growth checks the full five-by-five-by-six target volume",function() + for _,obstacle in ipairs({"foreign","placed","claimed","unloaded","external"}) do + reset(); diverse(); baseline("birch_young",86300) + local q=pos(2,6,0) + if obstacle=="foreign" then put(q,"wall") end + if obstacle=="placed" then metadata(q):set_string("player_placed","1") end + if obstacle=="claimed" then claims[key(q)]=true end + if obstacle=="unloaded" then missing[key(q)]=true end + if obstacle=="external" then protected[key(q)]=true end + advance(100); assert(name(p)=="hearth:birch_young",obstacle) + end +end) +test("forest, acidic, alpine and saturated soils recruit live understory without hours of waiting",function() + local cases={{"podzol",15,30,"heather"},{"alluvium",15,90,"sedge"}, + {"peat",15,90,"sedge"},{"forest_floor",8,70,"moss"},{"alpine_turf",15,30,"heather"}, + {"granite",15,10,"lichen"}} + for _,c in ipairs(cases) do + reset(); diverse(); put(pos(0,0,0),c[1]); light,humidity=c[2],c[3] + baseline(); advance(100); advance(100) + assert(name(p)=="hearth:"..c[4],c[1].." got "..name(p)) + assert(name(pos(0,0,0))=="hearth:"..c[1],"never rewrite supporting terrain") + end +end) +test("wet understory recruits willow and alder instead of silently turning into oak",function() + for _,c in ipairs({{"alluvium","willow"},{"peat","alder"},{"podzol","pine"}}) do + reset(); diverse(); put(pos(0,0,0),c[1]); humidity=c[1]=="podzol" and 30 or 90 + baseline(c[1]=="podzol" and "heather" or "sedge",7100); advance(100) + assert(name(p)=="hearth:"..c[2].."_seedling",name(p)) + end +end) +test("scheduler adopts worldgen herbs but never player blocks or placed species trees",function() + diverse(); put(p,"clover"); baseline(); advance(100) + assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==100) + for _,k in ipairs({"birch_log","birch_leaves","birch_mature"}) do + reset(); diverse(); put(p,k) + if k=="birch_mature" then metadata(p):set_string("player_placed","1") end + baseline(); advance(100) + assert(metadata(p):get_string("succ_natural")=="" and field(p,"succ_age")==0,k) + end +end) +test("clover fixes only two finite nitrogen units per support across restart and recolonization",function() + diverse(); baseline("clover",900) + for _=1,5 do advance(100) end + local support=pos(0,0,0) + assert(field(support,"succ_nutrients")==2 and field(support,"succ_fixed_nitrogen")==2) + metadata(support):set_string("succ_nutrients","0") + load(); sim.step(1); advance(100) + assert(field(support,"succ_nutrients")==0) + metadata(p):set_string("succ_natural",""); put(p,"clover"); advance(100) + assert(field(support,"succ_nutrients")==0 and field(support,"succ_fixed_nitrogen")==2) +end) +test("natural clover and sedge feed wildlife but dormant variants, flowers and player plants do not",function() + diverse(); put(p,"clover",true); assert(sim.forage(p)) + put(pos(1,0,0),"alluvium"); put(pos(1),"sedge",true); assert(sim.forage(p)) + season="Winter"; put(p,"clover_winter",true); put(pos(1),"sedge_winter",true) + assert(not sim.forage(p)) + season="Summer"; put(p,"bluebell",true); put(pos(1),"clover") + metadata(pos(1)):set_string("succ_natural",""); assert(not sim.forage(p)) +end) +test("habitat exposes bounded nutrients and pollination without changing metadata",function() + diverse(); put(p,"clover",true); put(pos(1),"wildflowers",true) + metadata(pos(0,0,0)):set_string("succ_nutrients","999999") + local h=sim.habitat(p) + assert(h.nutrients==8 and h.pollination==2 and h.edible==1 and h.ground=="grass") + assert(field(pos(0,0,0),"succ_nutrients")==999999 and writes==0) +end) +test("species foliage respects the same total eight-column budget and ninety-six appearance swaps",function() + diverse(); season="Autumn"; real_positions=true + api.get_node_or_nil=function(q) reads=reads+1; return world[key(q)] or {name="hearth:alder_leaves"} end + assert(sim.step(999999)==8 and writes==96) +end) +test("species seeds respect source, destination and support authority without empty-name claim access",function() + for _,mode in ipairs({"source","destination","support","external","marker","ambiguous"}) do + reset(); diverse(); baseline("birch_mature",2000) + hearth.claim_allowed=function() error("forbidden empty-name player access") end + local seed=pos(-1,1,0) + if mode=="source" then claims[key(p)]=true end + if mode=="destination" then claims[key(seed)]=true end + if mode=="support" then claims[key(pos(-1,0,0))]=true end + if mode=="external" then protected[key(seed)]=true end + if mode=="marker" then metadata(seed):set_string("third_party:placed","1") end + if mode=="ambiguous" then hearth.claim_owner_at=function(q) return nil,key(q)==key(seed) end end + metadata(p):set_string("succ_serial","9"); advance(100) + assert(name(seed)~="hearth:birch_seedling",mode) + end +end) +test("species age pauses across winter unload and restart without losing identity",function() + diverse(); baseline("oak_seedling",21500) + season,temperature="Winter",-5; advance(100) + assert(name(p)=="hearth:oak_seedling_winter" and field(p,"succ_age")==21500) + load(); advance(100); assert(field(p,"succ_age")==21500) + lbm.action(p); season,temperature="Spring",15; advance(100) + assert(name(p)=="hearth:oak_seedling_spring" and field(p,"succ_age")==21500) + advance(100) + assert(name(p)=="hearth:oak_young_spring" and metadata(p):get_string("succ_species")=="oak") +end) + +local function catalogue() + hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api) + hearth.botany=dofile(target:gsub("[^/\\]+$","botany.lua"))(hearth,api) + hearth.species_effects=dofile(target:gsub("[^/\\]+$","species_effects.lua"))(hearth,api) + hearth.biomes=dofile(target:gsub("[^/\\]+$","biomes.lua"))(hearth,api,function() end,{}) + -- Exercise one plot per frame; the other seven still consume the global + -- budget but have no valid player position. Separate tests cover fairness. + api.get_connected_players=function() + return {{get_pos=function() + local index=probes%625; local visit=probes%8==0; probes=probes+1 + if not visit then return nil end + return pos(p.x+12-index%25,p.y,p.z+12-math.floor(index/25)) + end}} + end + load() +end +local function vector(r) + local n=r.niche + local soil=next(n.substrates) + if n.aquatic=="surface" or n.aquatic=="submerged" then soil="alluvium" end + return {loaded=true,ground=soil,substrate=r.id=="hornwort" and "water" or soil, + light=(n.light[1]+n.light[2])/2,moisture=(n.moisture[1]+n.moisture[2])/2, + ph=(n.ph[1]+n.ph[2])/2,water=n.water[1],aquatic=n.aquatic,freshwater=true, + nutrients=2,host=n.hosts and next(n.hosts) or nil,canopy=0,grass=0,flowers=0,nursery=0,root_fungi={}} +end +local function support(q,h) + local b=pos(q.x,q.y-1,q.z) + if h.water>0 then + for i=1,h.water do put(pos(q.x,q.y-i,q.z),"water_source") end + put(pos(q.x,q.y-h.water-1,q.z),h.ground) + elseif h.substrate=="bark" then + put(b,(h.host or "oak").."_young",true) + elseif h.substrate=="deadwood" or h.substrate=="litter" then + put(b,h.substrate,true); metadata(b):set_string("succ_species",h.host or "oak") + else put(b,h.ground) end +end +local function supplied_habitat(h) + -- Controlled niche vectors isolate the integration handoff from the + -- independently tested real bounded habitat sampler. Selection is REAL. + sim.habitat=function(q) + local out={}; for k,v in pairs(h) do out[k]=v end + if q.y~=p.y then out.loaded=false end + return out + end +end +test("ecology80 recruits every real returned candidate, spreads it and completes its finite lifecycle",function() + catalogue() + local records=hearth.botany.list() + for index,r in ipairs(records) do + reset(); catalogue() + local h=vector(r) + season=next(r.seasons) + -- Exact position-hash start at this record, also meeting the old + -- woody-gap gate. No fake choose(), random rerolls or ID aliases. + local x=0 + while (x*17+7)%80~=index-1 or (x*7)%23~=0 do x=x+1; assert(x<1840) end + p=pos(x,1,0); support(p,h); supplied_habitat(h) + baseline(nil,7100); advance(100) + local candidate=r.group=="trees" and r.id.."_seedling" or r.id + assert(hearth.seasons.kind(name(p))==candidate,r.id.." recruit got "..name(p)) + assert(metadata(p):get_string("succ_natural")=="1",r.id) + local q=pos(p.x,p.y,p.z-1); support(q,h) + metadata(p):set_string("succ_age","1000"); metadata(p):set_string("succ_serial","2") + if r.group=="trees" then + -- A mature parent disperses the same species seed, not a + -- generic oak. The subsequent expansion uses real block plans. + put(p,r.id.."_mature",true); metadata(p):set_string("succ_age","2000"); metadata(p):set_string("succ_serial","9") + q=pos(p.x-1,p.y,p.z); support(q,h); advance(100) + assert(hearth.seasons.kind(name(q))==candidate,r.id.." tree spread") + put(q,"air"); metadata(q):set_string("succ_natural","") + put(p,candidate,true); metadata(p):set_string("succ_age","21600"); advance(100) + assert(metadata(p):get_string("succ_tree_stage")=="young",r.id.." young got "..name(p).." age "..field(p,"succ_age").." stress "..field(p,"succ_stress")) + metadata(p):set_string("succ_age","86400"); advance(100) + assert(metadata(p):get_string("succ_tree_stage")=="mature",r.id.." mature") + metadata(p):set_string("succ_age","259200"); advance(100) + assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12,r.id.." fallen") + for id,n in pairs(world) do + if id~=key(p) then assert(metadata((function() + local a,b,c=id:match("([^:]+):([^:]+):([^:]+)"); return pos(tonumber(a),tonumber(b),tonumber(c)) + end)()):get_string("succ_component")=="",r.id.." orphan component "..n.name) end + end + else + advance(100) + assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q)) + metadata(p):set_string("succ_age","1000000"); advance(100) + assert(name(p)=="air" or name(p)=="hearth:litter",r.id.." senescence") + if name(p)=="hearth:litter" then + metadata(p):set_string("succ_age","1800"); advance(100); assert(name(p)=="air",r.id.." recycling") + end + end + end +end) +test("real habitat distinguishes bank depth, source columns, pH, host and bark without writes",function() + catalogue() + put(pos(1,0,0),"water_source"); put(pos(0,0,0),"alluvium"); humidity=90 + local h=sim.habitat(p) + assert(h.water==0 and h.near_water==1 and h.aquatic=="bank" and h.ph==6.5) + put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"water_source"); put(pos(0,-2,0),"alluvium") + h=sim.habitat(p) + assert(h.water==2 and h.ground=="alluvium" and h.substrate=="water" and h.freshwater and h.moisture==1) + put(p,"hornwort",true); assert(sim.habitat(p).aquatic=="submerged") + put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch") + h=sim.habitat(p); assert(h.substrate=="deadwood" and h.host=="birch") + put(pos(0,0,0),"oak_young",true) + h=sim.habitat(p); assert(h.substrate=="bark" and h.host=="oak" and h.water==0) + metadata(pos(0,0,0)):set_string("player_placed","1") + assert(not sim.habitat(p).loaded and writes==0) +end) +test("real source-water recruitment and submerged stand decay never clear or refill water",function() + for _,mode in ipairs({"surface","submerged"}) do + reset(); catalogue(); p=pos(mode=="surface" and 0 or 1,1,0) + put(pos(p.x,0,0),"water_source"); put(pos(p.x,-1,0),"alluvium") + baseline(); advance(100); advance(100) + local r=hearth.botany.get(name(p)); assert(r and r.group=="aquatic" and r.niche.aquatic==mode) + assert(name(pos(p.x,0,0))=="hearth:water_source") + metadata(p):set_string("succ_age","21600"); advance(100) + assert(name(p)=="air" and name(pos(p.x,0,0))=="hearth:water_source") + assert(field(pos(p.x,-1,0),"succ_nutrients")==0) + end +end) +test("water placement denies flowing, too deep, unloaded, marked, claimed and protected columns",function() + for _,mode in ipairs({"flowing","deep","unloaded","marked","claimed","external","bottom"}) do + reset(); catalogue() + put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium") + if mode=="flowing" then put(pos(0,0,0),"water_flowing") end + if mode=="deep" then for y=0,-6,-1 do put(pos(0,y,0),"water_source") end end + if mode=="unloaded" then missing[key(pos(0,-1,0))]=true end + if mode=="marked" then metadata(pos(0,0,0)):set_string("placed_by","builder") end + if mode=="claimed" then claims[key(pos(0,0,0))]=true end + if mode=="external" then protected[key(pos(0,0,0))]=true end + if mode=="bottom" then claims[key(pos(0,-1,0))]=true end + baseline(); advance(100); advance(100) + assert(name(p)=="air" and writes==0,mode) + end +end) +test("real block plans expand atomically, survive restart and collapse only owned cubes",function() + catalogue(); humidity=45 + baseline("birch_seedling",21600); advance(100) + assert(metadata(p):get_string("succ_tree_stage")=="young") + local count=0 + for _,c in ipairs(hearth.diversity.tree_plan("birch","young")) do + local q=pos(p.x+c.x,p.y+c.y,p.z+c.z) + assert(metadata(q):get_string("succ_component")==key(p)); count=count+1 + end + assert(count>10) + load(); sim.step(1); metadata(p):set_string("succ_age","86400"); advance(100) + assert(metadata(p):get_string("succ_tree_stage")=="mature") + local before=writes + local q=pos(0,4,0); metadata(q):set_string("owner","builder") + metadata(p):set_string("succ_age","259200"); advance(100) + assert(name(p)=="hearth:birch_mature" and writes==before) + metadata(q):set_string("owner",""); advance(100) + assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12) + assert(name(pos(0,7,0))=="air") +end) +test("every tree destination and its support fail closed before any plan write",function() + for _,mode in ipairs({"foreign","natural_plant","marker","claim","external","unloaded","support"}) do + reset(); catalogue(); humidity=45; baseline("oak_seedling",21600) + local q=pos(0,4,0) + if mode=="foreign" then put(q,"wall") end + if mode=="natural_plant" then put(q,"yarrow",true) end + if mode=="marker" then metadata(q):set_string("player_placed","1") end + if mode=="claim" then claims[key(q)]=true end + if mode=="external" then protected[key(q)]=true end + if mode=="unloaded" then missing[key(q)]=true end + if mode=="support" then claims[key(pos(0,3,0))]=true end + advance(100) + assert(name(p)=="hearth:oak_seedling" and writes==0,mode) + end +end) +test("chopped ecology root cleans matching components but never mapgen or foreign timber",function() + catalogue(); humidity=45; baseline("oak_seedling",21600); advance(100) + local old={name=name(p)}; put(p,"air"); now=now+1 + assert(sim.tree_removed(p,old)); assert(name(pos(0,3,0))=="air") + put(pos(0,3,0),"oak_log"); now=now+1 + assert(not sim.tree_removed(p,{name="hearth:oak_young"})) + assert(name(pos(0,3,0))=="hearth:oak_log") +end) +test("catalogue nitrogen caps survive restart and root fungi debit existing soil on faster growth",function() + catalogue(); humidity=45; baseline("clover",1000) + for _=1,5 do advance(100) end + assert(field(pos(0,0,0),"succ_fixed_nitrogen")==2 and field(pos(0,0,0),"succ_nutrients")==2) + load(); sim.step(1); advance(100); assert(field(pos(0,0,0),"succ_nutrients")==2) + reset(); catalogue(); humidity=45; put(pos(0,0,0),"forest_floor") + put(pos(1),"birch_bolete",true); metadata(pos(0,0,0)):set_string("succ_nutrients","1") + baseline("birch_seedling",16100); advance(100) + assert(name(p)=="hearth:birch_young" and field(pos(0,0,0),"succ_nutrients")==0) + reset(); catalogue(); humidity=45; put(pos(1),"birch_bolete",true) + metadata(pos(0,0,0)):set_string("succ_nutrients","1") + baseline("oak_seedling",16100); advance(100) + assert(name(p)=="hearth:oak_seedling" and field(pos(0,0,0),"succ_nutrients")==1) +end) +test("saprotrophs age existing deadwood without minting nutrients or attacking natural or player trees",function() + catalogue(); humidity=70; light=10 + put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch") + baseline("birch_polypore",1000); advance(100) + assert(field(pos(0,0,0),"succ_age")>0 and field(pos(0,0,0),"succ_nutrients")==0) + assert(field(p,"succ_stress")==0) + metadata(pos(0,0,0)):set_string("owner","builder"); local age=field(pos(0,0,0),"succ_age") + advance(100); assert(field(pos(0,0,0),"succ_age")==age) + reset(); catalogue(); humidity=70; light=10 + put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","oak") + baseline("birch_polypore",1000); advance(100) + assert(field(pos(0,0,0),"succ_age")==0,"birch-only fungus must not accelerate oak decay") +end) +test("flowering, browse and nursery use botanical tags while harvested bushes retain cooldowns",function() + catalogue(); humidity=45; put(p,"blackberry",true); put(pos(1),"yarrow",true) + local h=sim.habitat(p); assert(h.pollination==2 and h.edible==1 and h.nursery==1) + metadata(p):set_string("botany_ready_at",tostring(now+86400)) + baseline(nil,1000); advance(100); assert(field(p,"succ_age")==1100 and field(p,"botany_ready_at")>now) + assert(sim.forage(p)); assert(name(p)=="air" and field(p,"botany_ready_at")>now) + put(p,"foxglove",true); assert(not sim.forage(p)) + season="Spring"; put(p,"rowan_mature",true) + metadata(p):set_string("succ_component",key(p)); metadata(p):set_string("succ_tree_stage","mature") + put(pos(1),"rowan_leaves",true); metadata(pos(1)):set_string("succ_component",key(p)) + h=sim.habitat(p) + assert(h.pollination==1 and h.nursery==1 and h.edible==1,"one managed root, not a benefit per leaf") +end) + +test("one global tree-plan reservation bounds eight concurrent roots and malformed geometry writes nothing",function() + catalogue(); humidity=45 + local positions={pos(0),pos(20),pos(40),pos(60),pos(80),pos(100),pos(120),pos(140)} + local i,visits=0,0 + api.get_connected_players=function() + return {{get_pos=function() + i=i%8+1 + local index=visits%625; visits=visits+1 + local root=positions[i] + return pos(root.x+12-index%25,1,root.z+12-math.floor(index/25)) + end}} + end + for _,root in ipairs(positions) do put(root,"oak_seedling",true) end + sim.step(1) + for _,root in ipairs(positions) do metadata(root):set_string("succ_age","21600") end + local calls=0; local original=hearth.diversity.tree_plan + hearth.diversity.tree_plan=function(...) calls=calls+1; return original(...) end + advance(100); assert(calls==1) + local grown=0 + for _,root in ipairs(positions) do if metadata(root):get_string("succ_tree_stage")=="young" then grown=grown+1 end end + assert(grown==1 and writes<=sim.MAX_TREE_CELLS) + for _,mode in ipairs({"missing","oversize","foreign","duplicate","offset"}) do + reset(); catalogue(); humidity=45; baseline("oak_seedling",21600) + hearth.diversity.tree_plan=function() + if mode=="missing" then return nil end + local plan={{x=0,y=0,z=0,name="hearth:oak_young"}} + if mode=="oversize" then for _=1,sim.MAX_TREE_CELLS do plan[#plan+1]=plan[1] end end + if mode=="foreign" then plan[1].name="foreign:log" end + if mode=="duplicate" then plan[2]=plan[1] end + if mode=="offset" then plan[1].x=100 end + return plan + end + advance(100); assert(name(p)=="hearth:oak_seedling" and writes==0,mode) + end +end) +test("water stands and botanical understory retain loaded-only ages across restart and reload",function() + for _,plant in ipairs({"duckweed","yarrow"}) do + reset(); catalogue(); humidity=45 + if plant=="duckweed" then put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium") end + baseline(plant,1000); advance(864000); assert(field(p,"succ_age")==1000) + load(); advance(100); assert(field(p,"succ_age")==1000) + lbm.action(p); advance(100); assert(field(p,"succ_age")==1000) + advance(100); assert(field(p,"succ_age")==1100) + end +end) +test("real moss mat sampler changes local moisture and pH once without map or metadata writes",function() + catalogue(); humidity=80; light=10 + assert(hearth.botany.suitable("bracken",sim.habitat(p),"Summer")) + put(pos(1,0,0),"alluvium"); put(pos(1),"sphagnum",true) + local before=sim.habitat(p) + assert(before.species_counts.sphagnum==1) + assert(not hearth.botany.suitable("bracken",before,"Summer"),"retention changes a real neighbour niche") + assert(math.abs(before.moisture-before.source_moisture-0.1)<0.000001) + assert(math.abs(before.ph-6.3)<0.000001 and before.source_ph==6.5) + put(pos(-1,0,0),"peat"); put(pos(-1),"sphagnum",true) + put(pos(0,0,1),"peat"); put(pos(0,1,1),"sphagnum",true) + local function snapshot() + local out={}; for id,f in pairs(fields) do for k,v in pairs(f) do out[#out+1]=id..k..v end end + table.sort(out); return table.concat(out,"|") + end + local h=sim.habitat(p); local saved=snapshot() + for _=1,5 do + h=sim.habitat(p) + assert(h.species_counts.sphagnum==3 and math.abs(h.ph-6.1)<0.000001) + assert(math.abs(h.moisture-h.source_moisture-0.2)<0.000001) + end + assert(snapshot()==saved and writes==0 and name(pos(1,0,0))=="hearth:alluvium") +end) +test("moss effects exclude planted protected claimed unsupported and unloaded sources",function() + for _,mode in ipairs({"planted","protected","claimed","support","unsupported","unloaded"}) do + reset(); catalogue(); light=10 + local q=pos(1); put(pos(1,0,0),"peat"); put(q,"sphagnum",true) + if mode=="planted" then metadata(q):set_string("player_placed","1") end + if mode=="protected" then protected[key(q)]=true end + if mode=="claimed" then claims[key(q)]=true end + if mode=="support" then claims[key(pos(1,0,0))]=true end + if mode=="unsupported" then put(pos(1,0,0),"air") end + if mode=="unloaded" then missing[key(q)]=true end + local h=sim.habitat(p) + assert(not h.species_counts.sphagnum and h.ph==6.5 and writes==0,mode) + end +end) +test("real floating duckweed shades submerged neighbours but not itself or land and preserves water",function() + catalogue(); light=12 + put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium"); put(p,"hornwort",true) + for _,x in ipairs({-1,1,2}) do + put(pos(x,0,0),"water_source"); put(pos(x,-1,0),"alluvium"); put(pos(x),"duckweed",true) + end + local h=sim.habitat(p) + assert(h.aquatic=="submerged" and h.species_counts.duckweed==3 and h.light==7.5 and h.source_light==12) + assert(sim.habitat(pos(1)).light==12) + assert(sim.habitat(pos(0,1,1)).light==12) + season="Winter"; assert(sim.habitat(p).light==12) + season="Summer"; protected[key(pos(1))]=true + assert(sim.habitat(p).light==9) + for _,x in ipairs({-1,0,1,2}) do assert(name(pos(x,0,0))=="hearth:water_source") end + assert(writes==0) +end) +test("alder all living stages and dog lichen share the durable finite nitrogen budget",function() + for _,id in ipairs({"alder_seedling","alder_young","alder_mature","dog_lichen"}) do + reset(); catalogue() + local r=hearth.botany.get(id); local h=vector(r) + support(p,h); supplied_habitat(h); baseline(id,1000) + for _=1,5 do advance(100) end + local below=pos(0,0,0) + assert(field(below,"succ_fixed_nitrogen")==2 and field(below,"succ_nutrients")==2,id) + metadata(below):set_string("succ_nutrients","0") + load(); supplied_habitat(h); sim.step(1); advance(100) + assert(field(below,"succ_nutrients")==0,id.." reload") + put(p,"clover",true); supplied_habitat(vector(hearth.botany.get("clover"))) + advance(100); assert(field(below,"succ_nutrients")==0,id.." shared cap") + end +end) +test("nitrogen never exceeds local four ceiling or writes through source or support protection",function() + for _,mode in ipairs({"fraction","full","source","support"}) do + reset(); catalogue(); humidity=45 + local below=pos(0,0,0) + metadata(below):set_string("succ_nutrients",mode=="fraction" and "3.75" or (mode=="full" and "8" or "0")) + baseline("clover",1000) + if mode=="source" then protected[key(p)]=true end + if mode=="support" then claims[key(below)]=true end + advance(100) + local expected=mode=="fraction" and 4 or (mode=="full" and 8 or 0) + assert(field(below,"succ_nutrients")==expected,mode) + assert(field(below,"succ_fixed_nitrogen")== (mode=="fraction" and 0.25 or 0),mode) + end +end) +test("multiple host fungi cannot copy nutrients and exhausted soil cannot fund early growth",function() + for _,budget in ipairs({0,1}) do + reset(); catalogue(); humidity=45 + put(pos(0,0,0),"forest_floor"); metadata(pos(0,0,0)):set_string("succ_nutrients",budget) + put(pos(1),"fly_agaric",true); put(pos(-1),"birch_bolete",true) + baseline("birch_seedling",16100); advance(100) + assert(field(pos(0,0,0),"succ_nutrients")==0) + assert(name(p)==(budget==1 and "hearth:birch_young" or "hearth:birch_seedling")) + advance(100); assert(field(pos(0,0,0),"succ_nutrients")==0) + end +end) +test("birch wood fungus exhausts supporting deadwood then litter with exactly one conserved payout each",function() + catalogue(); humidity=70; light=10 + put(p,"deadwood",true); metadata(p):set_string("succ_species","birch") + put(pos(0,2,0),"birch_polypore",true) + baseline(nil,7100); advance(100) + assert(name(p)=="hearth:litter" and name(pos(0,2,0))=="air") + assert(field(pos(0,0,0),"succ_nutrients")==3) + metadata(p):set_string("succ_age","1800"); advance(100) + assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) + advance(60); assert(field(pos(0,0,0),"succ_nutrients")==4) +end) +os.time=original_time +print("LAST_HEARTH_SUCCESSION_PASS "..passed) diff --git a/tests/tooling.test.mjs b/tests/tooling.test.mjs new file mode 100755 index 0000000..75024dc --- /dev/null +++ b/tests/tooling.test.mjs @@ -0,0 +1,537 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { realpath } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { packageGame, readRelease, SOURCE, verifyPackage } from '../scripts/package.mjs'; +import { backupWorld, restoreWorld, verifyBackup } from '../scripts/backup.mjs'; +import { absolute, atomicDirectory, disjoint, exists, inventory, MANIFEST, relativeName, withLauncherLock } from '../scripts/files.mjs'; +import { assertEngineVersion, coordinates, engineExecutable, environmentSettings, fixtureSettings, graphicsSettings, parseEngineVersion, prepareCourse, runtimeHome, snapshotBeforePlay, validateEngine } from '../scripts/launcher.mjs'; +import { writeWorld } from '../scripts/run.mjs'; +import { isTerrainSetting, terrainSettings } from '../scripts/terrain.mjs'; + +const exec = promisify(execFile); +const cli = path.join(SOURCE, 'scripts/run.mjs'); +const binary = Buffer.from([0, 255, 254, 128, 10, 13, 0, 42]); + +async function temporary(t) { + // macOS /var is normally a symlink; resolve the OS-owned temp root before + // testing the deliberately symlink-rejecting filesystem API. + const root = await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()), 'hearth-tooling-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} + +async function sourceTree(root) { + const source = path.join(root, 'source'); + await fs.mkdir(path.join(source, 'mods/hearth'), { recursive: true }); + await fs.mkdir(path.join(source, 'tests/fixtures'), { recursive: true }); + for (const filename of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt', 'release.json', 'tests/fixtures/scenarios.json']) { + await fs.copyFile(path.join(SOURCE, filename), path.join(source, filename)); + } + for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) { + await fs.writeFile(path.join(source, 'mods/hearth', name + '.lua'), '-- fixture #abcdef\n'); + } + await fs.writeFile(path.join(source, 'mods/hearth/mod.conf'), 'name = hearth\n'); + await fs.writeFile(path.join(source, 'secret.env'), 'not part of payload\n'); + await fs.writeFile(path.join(source, 'mods/hearth/data.png'), binary); + await fs.mkdir(path.join(source, 'mods/hearth/textures')); + await fs.writeFile(path.join(source, 'mods/hearth/textures/stale.png'), binary); + return source; +} + +async function worldTree(root) { + const world = path.join(root, 'old-world'); + await fs.mkdir(path.join(world, 'players/empty'), { recursive: true }); + await fs.mkdir(path.join(world, 'copy')); + await fs.writeFile(path.join(world, 'world.mt'), 'gameid = last_hearth\nbackend = sqlite3\n'); + await fs.writeFile(path.join(world, 'map.sqlite'), binary); + await fs.writeFile(path.join(world, 'players/player.bin'), binary); + return world; +} + +function options(root) { return { offline: true, runtime: path.join(root, 'runtime') }; } + +test('package twice: identical full inventory and manifest hashes, original assets, no engine', async t => { + const root = await temporary(t), source = await sourceTree(root); + const a = path.join(root, 'a'), b = path.join(root, 'b'); + await packageGame(a, { source }); + await packageGame(b, { source }); + assert.deepEqual(await inventory(a), await inventory(b)); + const hash = data => createHash('sha256').update(data).digest('hex'); + assert.equal(hash(await fs.readFile(path.join(a, MANIFEST))), hash(await fs.readFile(path.join(b, MANIFEST)))); + const manifest = await verifyPackage(a); + assert.ok(manifest.entries.some(entry => entry.path === 'release.json')); + for (const absent of ['scripts', 'tests', '.git', 'README.md', 'secret.env', 'mods/hearth/textures/stale.png']) assert.equal(await exists(path.join(a, absent)), false); + assert.deepEqual(await fs.readFile(path.join(a, 'mods/hearth/data.png')), binary); + assert.deepEqual((await fs.readFile(path.join(a, 'mods/hearth/textures/hearth_abcdef.png'))).subarray(0, 8), Buffer.from([137,80,78,71,13,10,26,10])); + assert.match(await fs.readFile(path.join(a, 'mods/hearth/models/hearth_person.obj'), 'utf8'), /^# Original Last Hearth/); + assert.equal((await readRelease(a)).engine.version, '5.18.0'); + assert.equal((await readRelease(a)).version, '0.5.0-m4'); +}); + +test('optional runtime fixture contract is copied verbatim', async t => { + const root = await temporary(t), source = await sourceTree(root); + const bytes = Buffer.from('{"owned_by":"runtime"}\n'); + await fs.writeFile(path.join(source, 'tests/fixtures/config.json'), bytes); + await fs.writeFile(path.join(source, 'tests/fixtures/first-session.json'), bytes); + const destination = path.join(root, 'game'); + await packageGame(destination, { source }); + assert.deepEqual(await fs.readFile(path.join(destination, 'fixtures/config.json')), bytes); + assert.deepEqual(await fs.readFile(path.join(destination, 'fixtures/first-session.json')), bytes); +}); + +test('package refuses existing/unmanaged output without changing it', async t => { + const root = await temporary(t), source = await sourceTree(root); + const destination = path.join(root, 'shared'); + await fs.mkdir(destination); + await fs.writeFile(path.join(destination, 'keep'), binary); + const before = await inventory(destination); + await assert.rejects(packageGame(destination, { source }), /overwrite/); + assert.deepEqual(await inventory(destination), before); + await assert.rejects(packageGame(path.join(source, 'mods/output'), { source }), /overlap/); +}); + +test('atomic build failure leaves no destination, staging directory or guard', async t => { + const root = await temporary(t), destination = path.join(root, 'new'); + await assert.rejects(atomicDirectory(destination, async stage => { + await fs.writeFile(path.join(stage, 'partial'), binary); + throw new Error('injected failure'); + }), /injected/); + assert.deepEqual(await fs.readdir(root), []); +}); + +test('package manifest rejects missing, extra, corrupted files and extra empty dirs', async t => { + const root = await temporary(t), source = await sourceTree(root); + for (const kind of ['missing', 'extra', 'corrupt', 'directory']) { + const destination = path.join(root, kind); + await packageGame(destination, { source }); + if (kind === 'missing') await fs.unlink(path.join(destination, 'game.conf')); + if (kind === 'extra') await fs.writeFile(path.join(destination, 'extra'), binary); + if (kind === 'corrupt') await fs.appendFile(path.join(destination, 'game.conf'), 'bad'); + if (kind === 'directory') await fs.mkdir(path.join(destination, 'empty-extra')); + await assert.rejects(verifyPackage(destination), /manifest mismatch/); + } +}); + +test('offline full backup/restore preserves binary bytes, directories and original world', async t => { + const root = await temporary(t), world = await worldTree(root); + const before = await inventory(world), backup = path.join(root, 'snapshot'), restored = path.join(root, 'restored'); + await backupWorld(world, backup, options(root)); + await verifyBackup(backup); + await restoreWorld(backup, restored, options(root)); + assert.deepEqual(await inventory(world), before); + assert.deepEqual(await inventory(restored), before); + assert.deepEqual(await fs.readFile(path.join(restored, 'map.sqlite')), binary); + assert.equal(await exists(path.join(restored, MANIFEST)), false); + await assert.rejects(restoreWorld(backup, world, options(root)), /new world/); + await assert.rejects(backupWorld(world, backup, options(root)), /overwrite/); + assert.deepEqual(await inventory(world), before); +}); + +test('restore rejects corruption, missing/extra files, empty dirs and unsafe manifest paths atomically', async t => { + const root = await temporary(t), world = await worldTree(root), before = await inventory(world); + for (const kind of ['corrupt', 'missing', 'extra', 'directory', 'traversal']) { + const backup = path.join(root, 'backup-' + kind), restored = path.join(root, 'restore-' + kind); + await backupWorld(world, backup, options(root)); + if (kind === 'corrupt') await fs.writeFile(path.join(backup, 'world/map.sqlite'), Buffer.from('broken')); + if (kind === 'missing') await fs.unlink(path.join(backup, 'world/map.sqlite')); + if (kind === 'extra') await fs.writeFile(path.join(backup, 'extra'), binary); + if (kind === 'directory') await fs.mkdir(path.join(backup, 'unexpected')); + if (kind === 'traversal') { + const filename = path.join(backup, MANIFEST), manifest = JSON.parse(await fs.readFile(filename, 'utf8')); + manifest.entries[0].path = '../escape'; + await fs.writeFile(filename, JSON.stringify(manifest)); + } + await assert.rejects(restoreWorld(backup, restored, options(root))); + assert.equal(await exists(restored), false); + assert.equal(await exists(restored + '.hearth-write-lock'), false); + } + assert.deepEqual(await inventory(world), before); +}); + +test('offline confirmation and own launcher lock required; active lease permits internal snapshot', async t => { + const root = await temporary(t), world = await worldTree(root), opt = options(root); + await assert.rejects(backupWorld(world, path.join(root, 'no-confirmation'), { runtime: opt.runtime }), /offline/); + await withLauncherLock(opt.runtime, async lease => { + await assert.rejects(backupWorld(world, path.join(root, 'blocked'), opt), /Launcher lock/); + await assert.rejects(backupWorld(world, path.join(root, 'forged'), { ...opt, lease: { runtime: opt.runtime } }), /Launcher lock/); + await backupWorld(world, path.join(root, 'internal'), { ...opt, lease }); + }); + assert.equal(await exists(path.join(opt.runtime, 'launcher.lock')), false); +}); + +test('world lock files and SQLite sidecars fail closed, env_meta remains normal data', async t => { + const root = await temporary(t), world = await worldTree(root); + await fs.writeFile(path.join(world, 'env_meta.txt'), 'normal engine metadata'); + for (const name of ['world.lock', '.lock', 'world.mt.lock', 'map.sqlite-wal', 'players.sqlite-shm', 'auth.sqlite-journal']) { + await fs.writeFile(path.join(world, name), ''); + await assert.rejects(backupWorld(world, path.join(root, 'blocked'), options(root)), /lock or sidecar/); + await fs.unlink(path.join(world, name)); + } + await backupWorld(world, path.join(root, 'allowed'), options(root)); +}); + +test('every managed play snapshots existing world without deleting prior snapshots or world data', async t => { + const root = await temporary(t), world = await worldTree(root), runtime = path.join(root, 'runtime'); + await fs.mkdir(path.join(runtime, 'worlds'), { recursive: true }); + const managed = path.join(runtime, 'worlds/last_hearth'); + await fs.rename(world, managed); + const original = await inventory(managed); + await withLauncherLock(runtime, async lease => { + const first = await snapshotBeforePlay(runtime, lease); + await fs.appendFile(path.join(managed, 'world.mt'), 'some_new_setting = true\n'); + const second = await snapshotBeforePlay(runtime, lease); + assert.notEqual(first, second); + assert.deepEqual(await inventory(path.join(first, 'world')), original); + assert.deepEqual(await inventory(path.join(second, 'world')), await inventory(managed)); + assert.equal((await fs.readdir(path.join(runtime, 'backups'))).length, 2); + await verifyBackup(first); + await verifyBackup(second); + }); + await restoreWorld((await fs.readdir(path.join(runtime, 'backups'))).map(name => path.join(runtime, 'backups', name)).sort()[0], + path.join(root, 'restored-before-migration'), options(root)); + assert.deepEqual(await inventory(path.join(root, 'restored-before-migration')), original); +}); + +test('new play world is not snapshotted; nonempty unmanaged world fails closed', async t => { + const root = await temporary(t), runtime = path.join(root, 'runtime'); + await withLauncherLock(runtime, async lease => { + assert.equal(await snapshotBeforePlay(runtime, lease), undefined); + assert.equal(await exists(path.join(runtime, 'backups')), false); + await fs.mkdir(path.join(runtime, 'worlds/last_hearth'), { recursive: true }); + await fs.writeFile(path.join(runtime, 'worlds/last_hearth/keep'), binary); + await assert.rejects(snapshotBeforePlay(runtime, lease), /unmanaged/); + assert.deepEqual(await fs.readFile(path.join(runtime, 'worlds/last_hearth/keep')), binary); + }); +}); + +test('symlink/junction input, parent and destination rejected', async t => { + const root = await temporary(t), source = await sourceTree(root), world = await worldTree(root); + const outside = path.join(root, 'outside'); + await fs.mkdir(outside); + const link = path.join(root, 'linked'); + // Windows junctions do not require developer mode or administrator privileges. + await fs.symlink(outside, link, process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(packageGame(path.join(link, 'game'), { source }), /link|junction/); + await assert.rejects(packageGame(link, { source }), /link|junction/); + await fs.symlink(outside, path.join(source, 'mods/hearth/escape'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(packageGame(path.join(root, 'game'), { source }), /link|junction/); + await fs.symlink(outside, path.join(world, 'escape'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(backupWorld(world, path.join(root, 'snapshot'), options(root)), /link|junction/); + assert.deepEqual(await fs.readdir(outside), []); +}); + +test('absolute paths, portable names and overlap validation reject traversal', () => { + for (const name of ['../escape', '/absolute', 'a\\b', 'a/../b', 'a//b', 'C:ads', 'CON.txt', 'x.']) assert.throws(() => relativeName(name)); + assert.throws(() => absolute('relative')); + assert.throws(() => absolute(path.join(os.tmpdir(), 'a') + path.sep + '..' + path.sep + 'b')); + assert.throws(() => disjoint(os.tmpdir(), path.join(os.tmpdir(), 'world'))); +}); + +test('runtime defaults preserve legacy Windows world path and support HEARTH_HOME on all platforms', () => { + const home = path.resolve(os.tmpdir(), 'person'), local = path.join(home, 'Local'); + const legacy = runtimeHome({ LOCALAPPDATA: local }, 'win32', home); + assert.equal(legacy, path.join(local, 'last-hearth-runtime')); + assert.equal(engineExecutable({ LOCALAPPDATA: local }, 'win32'), path.join(legacy, 'luanti-5.18.0-win64/bin/luanti.exe')); + for (const platform of ['win32', 'linux', 'darwin']) { + assert.equal(runtimeHome({ HEARTH_HOME: home }, platform), home); + assert.equal(engineExecutable({ HEARTH_ENGINE: process.execPath }, platform, home), process.execPath); + } + assert.match(runtimeHome({}, 'darwin', home), /Application Support/); + assert.equal(runtimeHome({ XDG_DATA_HOME: local }, 'linux', home), path.join(local, 'last-hearth-runtime')); + assert.throws(() => engineExecutable({}, 'linux', home), /HEARTH_ENGINE/); + assert.throws(() => engineExecutable({ HEARTH_ENGINE: 'relative' }, 'linux', home), /absolute/); +}); + +test('engine version parsing is exact and actual non-engine executable fails closed', async () => { + assert.equal(parseEngineVersion('Luanti 5.18.0 (Windows)\nUsing LuaJIT\n'), '5.18.0'); + assert.equal(parseEngineVersion('Luanti 5.19.0\n'), '5.19.0'); + assert.equal(assertEngineVersion('Luanti 5.18.0 (Windows)\n'), '5.18.0'); + for (const version of ['5.17.0', '5.19.0', '5.18.1']) assert.throws(() => assertEngineVersion(`Luanti ${version}\n`), /version mismatch/); + for (const text of ['Luanti 5.18.0-dev', 'Minetest 5.18.0', 'prefix Luanti 5.18.0', 'Luanti 5.18.00x', 'v24.0.0']) assert.throws(() => parseEngineVersion(text)); + await assert.rejects(validateEngine(process.execPath), /Unrecognized Luanti/); +}); + +test('fixture contract: first-session real clock, summer/winter fixed UTC/location/weather, production rejects', async () => { + assert.deepEqual(await fixtureSettings('first-session', 'local'), ['fixed_map_seed = 731104']); + for (const [name, utc, weather] of [['summer', 1782043200, 'clear'], ['winter', 1797854400, 'rain']]) { + const settings = await fixtureSettings(name, 'staging'); + for (const expected of [`hearth_preview_date = ${utc}`, `hearth_preview_weather = ${weather}`, 'hearth_latitude = 45', 'hearth_longitude = 0']) assert.ok(settings.includes(expected)); + } + await assert.rejects(fixtureSettings('summer', 'production'), /Production/); + await assert.rejects(fixtureSettings('__proto__', 'local'), /Unknown/); + assert.deepEqual(environmentSettings(), ['hearth_environment = production']); + assert.deepEqual(environmentSettings('staging', ['hearth_launcher_mode = smoke']), ['hearth_environment = staging', 'hearth_launcher_mode = smoke']); + for (const line of ['hearth_preview_date = 1', 'hearth_preview_weather = clear', 'hearth_launcher_mode = smoke', 'hearth_smoke_token = token']) assert.throws(() => environmentSettings('production', [line]), /Production/); + assert.throws(() => environmentSettings('local', ['a = 1\nb = 2']), /Invalid/); + assert.throws(() => coordinates({ HEARTH_LATITUDE: '45\nfoo=bar', HEARTH_LONGITUDE: '0' })); +}); + +test('release and npm commands retain M0-M3 tooling entry points', async () => { + const pkg = JSON.parse(await fs.readFile(path.join(SOURCE, 'package.json'), 'utf8')); + assert.equal(pkg.version, '0.5.0-m4'); + assert.equal((await readRelease()).version, pkg.version); + assert.equal(pkg.scripts.course, 'node scripts/run.mjs course'); + assert.equal(pkg.scripts['test:course'], 'node scripts/run.mjs test:course'); + assert.equal(pkg.scripts['test:seeds'], 'node scripts/run.mjs test:seeds'); + assert.equal(pkg.scripts.test, 'node --test tests/*.test.mjs'); + assert.equal(pkg.scripts.play, 'node scripts/run.mjs play'); + assert.equal(pkg.scripts['test:recovery'], 'node scripts/recovery.mjs'); +}); + +test('course creates only fresh stage/course with matching exclusive UUID marker and summer fixture', async t => { + const root = await temporary(t), world = await worldTree(root); + const original = await inventory(world); + const stage = path.join(root, 'session'), secondStage = path.join(root, 'second-session'); + await fs.mkdir(stage); await fs.mkdir(secondStage); + const first = await prepareCourse(stage); + const second = await prepareCourse(secondStage, { environment: 'staging' }); + assert.equal(first.world, path.join(stage, 'course')); + assert.equal(path.basename(second.world), 'course'); + assert.match(first.nonce, /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + assert.notEqual(first.nonce, second.nonce); + assert.deepEqual(first.settings, [...await fixtureSettings('summer', 'local'), + 'hearth_m1_course = true', `hearth_m1_course_nonce = ${first.nonce}`]); + assert.deepEqual(environmentSettings('local', first.settings).filter(line => /^hearth_environment/.test(line)), ['hearth_environment = local']); + for (const entry of [first, second]) { + assert.deepEqual(await fs.readdir(entry.world), ['.hearth-m1-course-nonce']); + assert.equal(await fs.readFile(path.join(entry.world, '.hearth-m1-course-nonce'), 'utf8'), entry.nonce + '\n'); + assert.ok(entry.settings.includes('hearth_preview_date = 1782043200')); + assert.ok(entry.settings.includes('hearth_preview_weather = clear')); + assert.ok(entry.settings.includes('hearth_latitude = 45')); + assert.ok(entry.settings.includes('hearth_longitude = 0')); + } + const before = await inventory(stage); + await assert.rejects(prepareCourse(stage), /overwrite/); + assert.deepEqual(await inventory(stage), before); + assert.deepEqual(await inventory(world), original); + assert.equal(await exists(path.join(root, 'backups')), false); +}); + +test('course refuses existing empty/unmanaged worlds, linked stages, and production without mutation', async t => { + const root = await temporary(t), stage = path.join(root, 'session'); + await fs.mkdir(path.join(stage, 'course'), { recursive: true }); + await assert.rejects(prepareCourse(stage), /overwrite/); + await fs.writeFile(path.join(stage, 'course/keep'), binary); + const before = await inventory(stage); + await assert.rejects(prepareCourse(stage), /overwrite/); + for (const environment of ['production', 'invalid']) await assert.rejects(prepareCourse(stage, { environment }), /local\/staging/); + assert.deepEqual(await inventory(stage), before); + const link = path.join(root, 'linked-stage'); + await fs.symlink(stage, link, process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(prepareCourse(link), /link|junction/); + assert.deepEqual(await inventory(stage), before); +}); + +test('production rejects presence of all course, recovery, debug and launcher settings, including false/empty', () => { + for (const key of ['hearth_m1_course', 'hearth_m1_course_nonce', 'hearth_m1_course_snag', + 'hearth_m1_course_future', 'hearth_recovery_phase', 'hearth_recovery_token', 'hearth_recovery_gameid', + 'hearth_debug', 'hearth_debug_future', 'hearth_course_probe_token', 'hearth_launcher_mode', 'hearth_smoke_token']) { + for (const value of ['', 'false', '0', 'test']) { + const line = ` ${key} = ${value}`; + assert.throws(() => environmentSettings('production', [line]), /Production/, key); + assert.deepEqual(environmentSettings('staging', [line]), ['hearth_environment = staging', line]); + } + } + assert.deepEqual(environmentSettings('production', ['hearth_latitude = 45', 'hearth_longitude = 0']), + ['hearth_environment = production', 'hearth_latitude = 45', 'hearth_longitude = 0']); +}); + +test('CLI package/verify work with absent engine and invalid runtime env; backup/restore offline CLI', async t => { + const root = await temporary(t), destination = path.join(root, 'game'); + const env = { ...process.env, HEARTH_ENGINE: 'not-an-absolute-engine', HEARTH_HOME: 'not-a-runtime' }; + const packaged = await exec(process.execPath, [cli, 'package', destination], { env, timeout: 90_000 }); + assert.match(packaged.stdout, /PACKAGE_PASS/); + const verified = await exec(process.execPath, [cli, 'verify', destination], { env, timeout: 30_000 }); + assert.match(verified.stdout, /PACKAGE_VERIFY_PASS/); + const world = await worldTree(root), backup = path.join(root, 'backup'), restored = path.join(root, 'restored'); + env.HEARTH_HOME = path.join(root, 'runtime'); + await exec(process.execPath, [cli, 'backup', world, backup, '--offline'], { env, timeout: 30_000 }); + await exec(process.execPath, [cli, 'restore', backup, restored, '--offline'], { env, timeout: 30_000 }); + assert.deepEqual(await inventory(world), await inventory(restored)); + await assert.rejects(exec(process.execPath, [cli, 'backup', world, path.join(root, 'unconfirmed')], { env, timeout: 10_000 }), /OFFLINE/); + // Version check happens before creating any runtime data or touching the world. + env.HEARTH_ENGINE = process.execPath; + const before = await inventory(world); + await assert.rejects(exec(process.execPath, [cli, 'play'], { env, timeout: 10_000 }), /Unrecognized Luanti/); + for (const command of ['course', 'test:course']) { + await assert.rejects(exec(process.execPath, [cli, command], { + env: { ...env, HEARTH_FIXTURE: 'winter', HEARTH_LATITUDE: 'invalid' }, timeout: 10_000, + }), /Unrecognized Luanti/); // Course ignores inherited fixture/coordinates, but never engine pinning. + } + assert.deepEqual(await inventory(world), before); + assert.deepEqual(await fs.readdir(env.HEARTH_HOME), []); +}); + +test('new-world terrain preset is pure, explicit, bounded and matches packaged game defaults', async () => { + const first = terrainSettings(true); + assert.deepEqual(terrainSettings(false), []); + assert.throws(() => terrainSettings('true'), /boolean/); + assert.ok(first.includes('mapgen_limit = 4096')); + assert.ok(first.includes('mgv7_spflags = mountains,ridges,nofloatlands,caverns')); + assert.ok(first.includes('mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0')); + assert.ok(first.includes('mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0')); + const defaults = (await fs.readFile(path.join(SOURCE, 'minetest.conf'), 'utf8')) + .split(/\r?\n/).filter(isTerrainSetting).sort(); + assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed')).sort(), defaults); + assert.equal(new Set(first.map(line => line.split('=')[0].trim())).size, first.length); + first[0] = 'mg_name = flat'; first.push('untrusted = true'); + assert.equal(terrainSettings(true)[0], 'mg_name = v7'); + for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1', + 'fixed_map_seed = 42', 'mapgen_limit = 512', 'water_level = 1']) assert.ok(isTerrainSetting(line), line); + assert.equal(isTerrainSetting('hearth_latitude = 45'), false); +}); + +test('world creation writes terrain once; subsequent ephemeral configs never retune or reseed saved worlds', async t => { + const root = await temporary(t), world = path.join(root, 'world'), config = path.join(root, 'first.conf'); + await writeWorld(world, config, ['hearth_latitude = 45', 'hearth_longitude = 0'], + ['fixed_map_seed = 42'], 'local'); + const first = await fs.readFile(config, 'utf8'); + for (const line of terrainSettings(true)) assert.ok(first.includes(line + '\n'), line); + assert.ok(first.indexOf('fixed_map_seed = 42') > first.indexOf('fixed_map_seed = 731104')); + const worldFile = await fs.readFile(path.join(world, 'world.mt')); + // An old world may have an entirely different mapgen, seed and boundary. + const saved = Buffer.from('mg_name = valleys\nseed = 99\nmapgen_limit = 512\n[end_of_params]\n'); + await fs.writeFile(path.join(world, 'map_meta.txt'), saved); + const before = await inventory(world); + const secondConfig = path.join(root, 'second.conf'); + await writeWorld(world, secondConfig, ['hearth_latitude = 45'], [ + ...await fixtureSettings('first-session', 'local'), 'mg_name = flat', + 'mgv7_np_mountain = 1', 'water_level = 30', 'mapgen_limit = 4096', + ], 'local'); + const second = await fs.readFile(secondConfig, 'utf8'); + assert.deepEqual(second.split(/\r?\n/).filter(isTerrainSetting), []); + assert.match(second, /hearth_latitude = 45/); + assert.match(second, /secure.enable_security = true/); + assert.deepEqual(await fs.readFile(path.join(world, 'world.mt')), worldFile); + assert.deepEqual(await fs.readFile(path.join(world, 'map_meta.txt')), saved); + assert.deepEqual(await inventory(world), before); + await assert.rejects(writeWorld(world, secondConfig, [], [], 'local'), /EEXIST/); + assert.deepEqual(await inventory(world), before); +}); + +test('world.mt without map metadata and orphan map metadata each suppress creation overrides', async t => { + const root = await temporary(t); + for (const name of ['world.mt', 'map_meta.txt']) { + const world = path.join(root, name === 'world.mt' ? 'legacy' : 'orphan'); + await fs.mkdir(world); + const bytes = Buffer.from(name === 'world.mt' ? 'gameid = last_hearth\n' : 'seed = 123\n[end_of_params]\n'); + await fs.writeFile(path.join(world, name), bytes); + const config = path.join(root, path.basename(world) + '.conf'); + await writeWorld(world, config, [], ['fixed_map_seed = 731104'], 'local'); + assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), []); + assert.deepEqual(await fs.readFile(path.join(world, name)), bytes); + } +}); + +test('graphics preset defaults to 512 nodes with bounded full-detail send/generate distances', () => { + const expected = distance => [ + `viewing_range = ${distance}`, `max_block_send_distance = ${Math.ceil(distance / 16) + 2}`, + `max_block_generate_distance = ${Math.ceil(distance / 16) + 2}`, + 'client_mesh_chunk = 2', 'fog_start = 0.65', + ]; + assert.deepEqual(graphicsSettings({}), expected(512)); + for (let distance = 128; distance <= 1024; distance++) { + const settings = graphicsSettings({ HEARTH_VIEW_DISTANCE: String(distance) }); + assert.deepEqual(settings, expected(distance)); + assert.ok(Number(settings[1].split('=')[1]) <= 66); + assert.equal(new Set(settings.map(line => line.split('=')[0].trim())).size, settings.length); + assert.deepEqual(settings.filter(isTerrainSetting), []); + assert.ok(settings.every(line => !/active|activation|culling|occlusion|simulation/.test(line))); + } + const changed = graphicsSettings({}); + changed[0] = 'viewing_range = 1'; changed.push('untrusted = true'); + assert.deepEqual(graphicsSettings({}), expected(512)); +}); + +const invalidViewDistances = ['', '127', '1025', '4000', '0', '-512', '+512', '0512', + '512.0', '5e2', '0x200', 'NaN', 'Infinity', ' 512', '512 ', '512\n', '512\r', + '512\nactive_block_range = 100', '512\r\nsecure.enable_security = false', + '512; enable_occlusion_culling = false', '512', '9'.repeat(1000), null, 512, true, {}]; + +test('graphics rejects malformed, injected and out-of-range values before any world/config write', async t => { + const root = await temporary(t), existing = await worldTree(root); + const before = await inventory(existing); + for (const value of invalidViewDistances) { + const env = { HEARTH_VIEW_DISTANCE: value }; + assert.throws(() => graphicsSettings(env), /HEARTH_VIEW_DISTANCE.*128.*1024/); + for (const world of [path.join(root, 'absent'), existing]) { + await assert.rejects(writeWorld(world, path.join(root, 'invalid.conf'), [], [], 'local', 'last_hearth', env), + /HEARTH_VIEW_DISTANCE.*128.*1024/); + } + assert.equal(await exists(path.join(root, 'absent')), false); + assert.equal(await exists(path.join(root, 'invalid.conf')), false); + assert.deepEqual(await inventory(existing), before); + } +}); + +test('play/course/test configs include unique graphics keys for new and existing worlds without mapgen rewrite', async t => { + const root = await temporary(t); + for (const mode of ['play', 'course', 'test']) { + for (const distance of [512, 768, 1024]) { + for (const existing of [false, true]) { + const directory = path.join(root, `${mode}-${distance}-${existing}`); + await fs.mkdir(directory); + const world = path.join(directory, 'world'), config = path.join(directory, `${mode}.conf`); + if (existing) { + await fs.mkdir(world); + await fs.writeFile(path.join(world, 'world.mt'), 'gameid = last_hearth\nbackend = sqlite3\n'); + await fs.writeFile(path.join(world, 'map_meta.txt'), 'mg_name = valleys\nseed = 99\nmapgen_limit = 512\n[end_of_params]\n'); + await fs.writeFile(path.join(world, 'map.sqlite'), binary); + } + const before = existing ? await inventory(world) : undefined; + const env = distance === 512 ? {} : { HEARTH_VIEW_DISTANCE: String(distance) }; + const extra = mode === 'course' ? [...await fixtureSettings('summer', 'local'), 'hearth_m1_course = true'] + : mode === 'test' ? ['hearth_launcher_mode = check', 'max_users = 0'] : []; + await writeWorld(world, config, [], extra, mode === 'test' ? 'staging' : 'local', 'last_hearth', env); + const lines = (await fs.readFile(config, 'utf8')).split(/\r?\n/); + for (const setting of graphicsSettings(env)) { + assert.ok(lines.includes(setting), setting); + const key = setting.split('=')[0].trim(); + assert.equal(lines.filter(line => line.split('=')[0].trim() === key).length, 1, key); + } + assert.ok(lines.includes('secure.enable_security = true')); + if (existing) { + assert.deepEqual(lines.filter(isTerrainSetting), []); + assert.deepEqual(await inventory(world), before); + } else { + for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting); + } + } + } + } +}); + +test('managed graphics overrides cannot duplicate preset keys or bypass the environment bounds', async t => { + const root = await temporary(t), world = path.join(root, 'world'), config = path.join(root, 'play.conf'); + for (const line of graphicsSettings({})) { + for (const location of [false, true]) { + await assert.rejects(writeWorld(world, config, location ? [` ${line}`] : [], + location ? [] : [` ${line}`], 'local', 'last_hearth', {}), /launcher-managed/); + assert.equal(await exists(world), false); + assert.equal(await exists(config), false); + } + } +}); + +test('CLI validates graphics before any engine check or runtime/world writes in every launch mode', async t => { + const root = await temporary(t), runtime = path.join(root, 'runtime'); + const env = { ...process.env, HEARTH_HOME: runtime, HEARTH_ENGINE: process.execPath, + HEARTH_VIEW_DISTANCE: '512\nsecure.enable_security = false' }; + for (const mode of ['play', 'course', 'test:course', 'check', 'smoke', 'test', 'test:seeds']) { + await assert.rejects(exec(process.execPath, [cli, mode], { env, timeout: 10_000 }), error => { + assert.equal(error.code, 1); + assert.match(error.stderr, /HEARTH_VIEW_DISTANCE.*128.*1024/); + assert.doesNotMatch(error.stderr, /Unrecognized Luanti/); + return true; + }); + assert.equal(await exists(runtime), false); + } +}); + +// Node-only tooling regression suite: never opens a GUI or the original world. diff --git a/tests/wiki-species.test.mjs b/tests/wiki-species.test.mjs new file mode 100755 index 0000000..20f697b --- /dev/null +++ b/tests/wiki-species.test.mjs @@ -0,0 +1,308 @@ +// Offline only. No test contacts Wikipedia or starts an engine. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { setImmediate } from 'node:timers/promises'; +import { LIMITS, USER_AGENT, parseCatalogue, apiURL, allowedURL, fetchBatch, + entriesFromResponse, fetchEntries, excerpt, luaString, serializeBundle, parseBundle, main } from '../scripts/wiki-species.mjs'; + +const catalogue = parseCatalogue(await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8')); +const cached = parseBundle(await readFile(new URL('../mods/hearth/wiki_articles.lua', import.meta.url), 'utf8')); +const retrieved = '2026-11-03T08:00:00.000Z'; +const record = catalogue[0]; +const intro = 'Quercus robur is a species of flowering plant. This fixture is original test text, not a cached Wikipedia quotation.'; +let mockedRequests = 0; +function page(title, changes = {}) { + return { pageid: 201415, ns: 0, title, extract: intro, canonicalurl: 'https://en.wikipedia.org/wiki/' + encodeURIComponent(title.replaceAll(' ', '_')), + lastrevid: 12345, revisions: [{ revid: 12345, timestamp: '2026-11-01T10:00:00Z' }], + contentmodel: 'wikitext', pagelanguage: 'en', ...changes }; +} +function payload(pages = [page(record.latin)], changes = {}) { + return { batchcomplete: true, query: { pages, ...changes } }; +} +function jsonResponse(data, headers = {}) { + return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json; charset=utf-8', ...headers } }); +} +function mock(fn) { return async (...args) => { mockedRequests++; return fn(...args); }; } + +test('bounded non-executing Lua parser finds the actual 80 Latin names and IDs', async () => { + assert.equal(catalogue.length, 80); + assert.equal(new Set(catalogue.map(r => r.id)).size, 80); + assert.deepEqual(catalogue.find(r => r.id === 'wood_blewit'), { id: 'wood_blewit', latin: 'Collybia nuda' }); + const source = await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8'); + for (const bad of [source.replace('Quercus robur', 'https://127.0.0.1/'), + source.replace('Quercus robur', 'Quercus robur|User:Private'), + source.replace('Quercus robur', 'Quercus robur&action=edit'), + source.replace('add("oak"', 'add("constructor"'), + source.replace('add("birch"', 'add("oak"'), + source.replace('"Betula pendula"', '"Quercus robur"'), + source.replace('add("oak",', 'add(variable,'), source + '\nadd("extra","Extra","Abies alba",', + source.repeat(10), 'os.execute("not executed")']) assert.throws(() => parseCatalogue(bad)); + assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 80); +}); + +test('URL construction rejects URL/query/title injection before any request', async () => { + for (const name of ['https://evil.example/', '//127.0.0.1/', '../Quercus robur', + 'Quercus robur|Betula pendula', 'Quercus robur&action=edit', 'Quercus robur#x', + 'Quercus robur\n', 'Quercus%20robur', 'User:Somebody', 'Quercus robur?x=1']) { + assert.throws(() => apiURL([name])); + await assert.rejects(fetchBatch([name], { fetchImpl: () => { throw new Error('must not fetch'); } }), /Invalid Latin/); + } + assert.throws(() => apiURL([])); + assert.throws(() => apiURL(Array(11).fill('Quercus robur'))); + const u = apiURL(['Quercus robur', 'Collybia nuda']); + assert.equal(u.origin, 'https://en.wikipedia.org'); + assert.equal(u.pathname, '/w/api.php'); + assert.equal(u.searchParams.get('titles'), 'Quercus robur|Collybia nuda'); + assert.equal(u.searchParams.get('explaintext'), '1'); + assert.equal(u.searchParams.get('exintro'), '1'); + assert.equal(u.searchParams.get('rvprop'), 'ids|timestamp'); +}); + +test('strict HTTPS allowlist excludes credentials, ports, fragments and lookalike hosts', () => { + for (const u of ['http://en.wikipedia.org/wiki/X', 'https://en.wikipedia.org.evil.example/', + 'https://en.wikipedia.org@evil.example/', 'https://user:pass@en.wikipedia.org/', + 'https://en.wikipedia.org:8443/', 'https://en.wikipedia.org/#x', + 'https://127.0.0.1/', 'https://en.m.wikipedia.org/', 'file:///etc/passwd', + 'https://evil.example/?next=https://en.wikipedia.org']) assert.throws(() => allowedURL(u)); + assert.equal(allowedURL('https://en.wikipedia.org/wiki/Quercus_robur').hostname, 'en.wikipedia.org'); +}); + +test('built-in fetch contract uses manual redirects, omitted credentials and bounded signals', async () => { + const result = await fetchBatch([record.latin], { fetchImpl: mock((url, options) => { + assert.equal(new URL(url).searchParams.get('titles'), record.latin); + assert.equal(options.redirect, 'manual'); + assert.equal(options.credentials, 'omit'); + assert.equal(options.referrerPolicy, 'no-referrer'); + assert.equal(options.headers['User-Agent'], USER_AGENT); + assert.match(USER_AGENT, /LastHearth.*offline/i); + assert.equal(Object.keys(options.headers).length, 2); + assert.ok(options.signal instanceof AbortSignal); + return jsonResponse(payload()); + }) }); + assert.equal(result.query.pages[0].pageid, 201415); + assert.equal(LIMITS.requestMs, 15000); + assert.equal(LIMITS.totalMs, 90000); +}); + +test('redirect SSRF and query changes are rejected without a second request', async () => { + for (const location of ['https://evil.example/', 'http://en.wikipedia.org/w/api.php', + '//127.0.0.1/admin', 'https://en.wikipedia.org@127.0.0.1/', + '/wiki/Quercus_robur', '/w/api.php?action=edit', 'https://u:p@en.wikipedia.org/w/api.php']) { + let calls = 0; + await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => { + calls++; return new Response(null, { status: 302, headers: { location } }); + }) })); + assert.equal(calls, 1); + } +}); + +test('same allowed endpoint redirects are bounded to two, sharing one timeout', async () => { + let calls = 0; + const signals = new Set(); + await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock((url, options) => { + calls++; signals.add(options.signal); + return new Response(null, { status: 307, headers: { location: url } }); + }) }), /Too many/); + assert.equal(calls, 3); + assert.equal(signals.size, 1); +}); + +test('unexpected automatic redirect response URL is rejected', async () => { + await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => { + const r = jsonResponse(payload()); + Object.defineProperty(r, 'url', { value: 'https://evil.example/' }); return r; + }) }), /Only credential-free/); +}); + +test('HTTP errors, API warnings, malformed JSON and HTML are failures, not notes', async () => { + for (const r of [new Response('rate limit', { status: 429 }), new Response('error', { status: 503 }), + new Response('error', { headers: { 'content-type': 'text/html' } }), + new Response('{bad', { headers: { 'content-type': 'application/json' } }), + new Response(new Uint8Array([0xff]), { headers: { 'content-type': 'application/json' } })]) { + await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => r) })); + } + for (const bad of [{ error: { code: 'maxlag' } }, { ...payload(), warnings: { extracts: 'failure' } }, + { ...payload(), batchcomplete: false }, { query: { pages: [] } }]) { + assert.throws(() => entriesFromResponse([record], bad, retrieved)); + } +}); + +test('advertised and streamed response limits both enforce 2 MiB', async () => { + await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => jsonResponse(payload(), { + 'content-length': String(LIMITS.responseBytes + 1) })) }), /byte limit/); + let cancelled = false; + const stream = new ReadableStream({ start(c) { c.enqueue(new Uint8Array(LIMITS.responseBytes + 1)); }, cancel() { cancelled = true; } }); + await assert.rejects(fetchBatch([record.latin], { fetchImpl: mock(() => new Response(stream, { + headers: { 'content-type': 'application/json' } })) }), /byte limit/); + assert.equal(cancelled, true); +}); + +test('aborting a stalled fetch or stalled response body is bounded and cancels body', async () => { + const a = new AbortController(); + const request = fetchBatch([record.latin], { signal: a.signal, fetchImpl: mock(() => new Promise(() => {})) }); + a.abort(new Error('bounded request')); await assert.rejects(request, /bounded request/); + const b = new AbortController(); let cancelled = false; + const body = new ReadableStream({ pull() { b.abort(new Error('bounded body')); }, cancel() { cancelled = true; } }, { highWaterMark: 0 }); + await assert.rejects(fetchBatch([record.latin], { signal: b.signal, fetchImpl: mock(() => new Response(body, { + headers: { 'content-type': 'application/json' } })) }), /bounded body/); + assert.equal(cancelled, true); +}); + +test('source title must match exact Latin or a returned redirect chain; no guessed aliases', () => { + const r = { id: 'wood_blewit', latin: 'Collybia nuda' }; + const data = payload([page('Lepista nuda')], { redirects: [{ from: 'Collybia nuda', to: 'Lepista nuda' }] }); + const [e] = entriesFromResponse([r], data, retrieved); + assert.equal(e.latin, 'Collybia nuda'); assert.equal(e.title, 'Lepista nuda'); + assert.equal(e.url, 'https://en.wikipedia.org/wiki/Lepista_nuda'); + assert.equal(e.revision_url, 'https://en.wikipedia.org/w/index.php?oldid=12345'); + assert.match(e.history_url, /title=Lepista_nuda&action=history$/); + assert.equal(e.attribution, 'Wikipedia contributors, "Lepista nuda"'); + assert.equal(e.license, 'CC BY-SA 4.0'); + assert.throws(() => entriesFromResponse([r], payload([page('Lepista nuda')]), retrieved), /Unverified/); + for (const redirects of [[{ from: r.latin, to: 'Lepista nuda', tofragment: 'Species' }], + [{ from: r.latin, to: 'Lepista nuda' }, { from: 'Lepista nuda', to: r.latin }], + [{ from: r.latin, to: 'Lepista nuda' }, { from: r.latin, to: 'Clitocybe nuda' }]]) { + assert.throws(() => entriesFromResponse([r], payload([page('Lepista nuda')], { redirects }), retrieved)); + } +}); + +test('disambiguation, non-mainspace, language, title and canonical URL mismatch fail closed', () => { + for (const changes of [{ pageprops: { disambiguation: '' } }, { ns: 1 }, { invalid: true }, + { pagelanguage: 'fr' }, { contentmodel: 'json' }, { title: 'Quercus' }, + { canonicalurl: 'https://evil.example/wiki/Quercus_robur' }, + { canonicalurl: 'https://en.wikipedia.org/wiki/Betula_pendula' }, + { canonicalurl: 'https://en.wikipedia.org/wiki/Quercus_robur?x=1' }, + { canonicalurl: 'https://en.wikipedia.org/wiki/Quercus_robur#section' }]) { + assert.throws(() => entriesFromResponse([record], payload([page(record.latin, changes)]), retrieved)); + } +}); + +test('page IDs and revisions must be positive finite safe integers with matching current revision', () => { + for (const n of [0, -1, 1.5, NaN, Infinity, '12345', null, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => entriesFromResponse([record], payload([page(record.latin, { pageid: n })]), retrieved)); + assert.throws(() => entriesFromResponse([record], payload([page(record.latin, { revisions: [{ revid: n, timestamp: '2026-11-01T10:00:00Z' }] })]), retrieved)); + } + for (const changes of [{ lastrevid: 54321 }, { revisions: [] }, { revisions: [{ revid: 12345, timestamp: 'not a date' }] }, + { revisions: [{ revid: 12345, timestamp: '2026-02-31T10:00:00Z' }] }, + { revisions: [{ revid: 12345, timestamp: '2027-01-01T10:00:00Z' }] }]) { + assert.throws(() => entriesFromResponse([record], payload([page(record.latin, changes)]), retrieved)); + } +}); + +test('only an explicit missing species page becomes original_notes, never a fake excerpt/revision', () => { + const r = { id: 'old_mans_beard', latin: 'Usnea subfloridana' }; + const missing = { ns: 0, title: r.latin, missing: true, canonicalurl: 'https://en.wikipedia.org/wiki/Usnea_subfloridana', contentmodel: 'wikitext', pagelanguage: 'en' }; + const [e] = entriesFromResponse([r], payload([missing]), retrieved); + assert.equal(e.kind, 'original_notes'); assert.equal(e.excerpt, false); + assert.equal(e.revision, undefined); assert.equal(e.pageid, undefined); assert.equal(e.revision_url, undefined); + assert.match(e.text, /not a Wikipedia excerpt/); assert.equal(e.license, 'CC0 1.0'); + assert.throws(() => entriesFromResponse([r], payload([]), retrieved)); + assert.throws(() => entriesFromResponse([r], payload([{ ...missing, missing: 'true' }]), retrieved)); + assert.deepEqual(parseBundle(serializeBundle([e])), [e]); +}); + +test('plaintext excerpt cap preserves exact Unicode prefix on a boundary and labels shortening', () => { + const full = 'A botanical observation.\n' + 'A longer sentence about a species. '.repeat(500); + const e = excerpt(full); + assert.ok(Array.from(e.text).length <= 8000); assert.equal(e.truncated, true); + assert.ok(full.startsWith(e.text)); assert.match(e.text, /[.!?]$/); + const unicode = 'Species ' + String.fromCodePoint(0x1f333).repeat(1000) + ' ecology. '.repeat(1000); + const u = excerpt(unicode); + assert.ok(Array.from(u.text).length <= 8000 && !u.text.includes('\ufffd')); + assert.equal(excerpt(intro).text, intro); assert.equal(excerpt(intro).truncated, false); + for (const bad of ['', '' + intro, intro + '\0', intro + ' [[article]]']) assert.throws(() => excerpt(bad)); +}); + +test('untrusted Wikipedia text is exclusively UTF-8 byte literals, never executable Lua', () => { + const attack = 'Species text " }; os.execute("bad"); --\n]=] [[ return function() end \\123 ' + String.fromCodePoint(0xe9, 0x1f333); + const entry = entriesFromResponse([record], payload(), retrieved)[0]; + // Exercise the serializer directly even for text rejected by plaintext policy. + entry.text = attack; + const source = serializeBundle([entry]); + assert.match(luaString(attack), /^"(?:\\\d{3})*"$/); + assert.ok(!source.includes('os.execute') && !source.includes('return function')); + assert.equal(parseBundle(source)[0].text, attack); + assert.equal(serializeBundle(parseBundle(source)), source); + assert.throws(() => parseBundle(source.replace('return {', 'os.execute("bad")\nreturn {'))); + assert.throws(() => parseBundle(source.replace('\\083', '\\999'))); + assert.throws(() => serializeBundle([{ ...entry, revision: Infinity }])); + assert.throws(() => serializeBundle([{ ...entry, revision: '12345' }])); +}); + +test('80 titles use exactly eight mocked batches at concurrency <=2 and stable catalogue order', async () => { + let active = 0, peak = 0, calls = 0; + const seen = []; + const entries = await fetchEntries(catalogue, { now: () => new Date(retrieved), fetchImpl: mock(async url => { + calls++; active++; peak = Math.max(peak, active); + const names = new URL(url).searchParams.get('titles').split('|'); seen.push(...names); + assert.ok(names.length <= 10); await setImmediate(); active--; + return jsonResponse(payload(names.map(name => page(name)))); + }) }); + assert.equal(calls, 8); assert.equal(peak, 2); + assert.deepEqual(seen, catalogue.map(r => r.latin)); + assert.deepEqual(entries.map(r => r.id), catalogue.map(r => r.id)); + assert.equal(entries.length, 80); + for (const records of [[], [...catalogue, { id: 'extra', latin: 'Abies alba' }]]) { + await assert.rejects(fetchEntries(records, { fetchImpl: () => { throw new Error('must not fetch'); } }), /scope/); + } +}); + +test('batch failure aborts other workers and never launches the remaining catalogue', async () => { + let calls = 0, otherSignal; + await assert.rejects(fetchEntries(catalogue, { fetchImpl: mock((url, options) => { + calls++; + if (calls === 1) return new Response('error', { status: 503 }); + otherSignal = options.signal; return new Promise(() => {}); + }) }), /HTTP 503/); + assert.equal(calls, 2); assert.equal(otherSignal.aborted, true); +}); + +test('shipped bundle contains 78 real intros and two honest missing-page notes for all 80 species', () => { + assert.equal(cached.length, 80); + assert.deepEqual(cached.map(({ id, latin }) => ({ id, latin })), catalogue); + assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 78); + assert.deepEqual(cached.filter(e => e.kind === 'original_notes').map(e => e.latin), ['Usnea subfloridana', 'Cladonia chlorophaea']); + for (const e of cached) { + assert.ok(Array.from(e.text).length <= 8000); + assert.ok(!/<\/?[A-Za-z][^>]*>/.test(e.text)); + if (e.kind === 'wikipedia_intro') { + assert.equal(e.license, 'CC BY-SA 4.0'); + assert.ok(Number.isSafeInteger(e.pageid) && e.pageid > 0); + assert.ok(Number.isSafeInteger(e.revision) && e.revision > 0); + assert.ok(e.history_url.endsWith('&action=history')); + assert.ok(e.revision_url.endsWith('?oldid=' + e.revision)); + assert.match(e.attribution, /^Wikipedia contributors/); + } + } + assert.equal(cached.find(e => e.id === 'meadow_fescue').title, 'Lolium pratense'); + assert.equal(cached.find(e => e.id === 'wood_blewit').title, 'Collybia nuda'); +}); + +test('import, help, invalid CLI arguments and offline --check perform zero network requests', async () => { + const original = globalThis.fetch; + let network = 0; + globalThis.fetch = () => { network++; throw new Error('Offline test attempted network'); }; + try { + await import('../scripts/wiki-species.mjs?offline-import-test'); + await main(['--help']); + for (const args of [[], ['--refresh', '--url', 'https://evil.example/'], ['https://evil.example/'], ['--output=../other.lua']]) { + await assert.rejects(main(args), /Usage/); + } + await main(['--check']); + assert.equal(network, 0); + } finally { globalThis.fetch = original; } +}); + +test('CLI refresh HTTP failure preserves the existing cache byte for byte', async () => { + const url = new URL('../mods/hearth/wiki_articles.lua', import.meta.url); + const before = await readFile(url), original = globalThis.fetch; + let calls = 0; + globalThis.fetch = mock(() => { calls++; return new Response('unavailable', { status: 503 }); }); + try { await assert.rejects(main(['--refresh']), /HTTP 503/); } + finally { globalThis.fetch = original; } + assert.ok(calls > 0 && calls <= 2); + assert.deepEqual(await readFile(url), before); +}); + +test.after(() => console.log(`WIKI_SPECIES_OFFLINE_COUNTS: 21 tests; real network requests: 0; mocked requests: ${mockedRequests}.`)); diff --git a/tests/wildlife_spec.lua b/tests/wildlife_spec.lua new file mode 100755 index 0000000..8585365 --- /dev/null +++ b/tests/wildlife_spec.lua @@ -0,0 +1,298 @@ +-- Standalone Lua 5.1/LuaJIT (also Lua 5.3) fauna contract tests. +local target=assert(arg[1],"wildlife.lua path required") +local passed=0 +local function test(name,fn) + local ok,err=pcall(fn) + assert(ok,name..": "..tostring(err)); passed=passed+1 + print("ok "..passed.." - "..name) +end +local function fixture() + local h={defs={},objects={},players={},steps={},habitat=true,plants=true,natural=true,forages=0,deposits=0, + blocked=function() return false end,terrain=function(p) return p.y<=3 and "hearth:grass" or "air" end} + local api={} + function api.register_entity(n,d) h.defs[n]=d end + function api.register_globalstep(fn) h.steps[#h.steps+1]=fn end + function api.get_connected_players() return h.players end + function api.get_node_or_nil(p) + if h.unloaded and h.unloaded(p) then return nil end + local n=h.terrain(p) + if n=="air" and p.y==4 and h.plants then n="hearth:meadow_grass" end + return {name=n} + end + function api.get_meta() return {get_string=function(_,key) return key=="succ_natural" and h.natural and "1" or "" end} end + function api.is_protected(p) return h.blocked(p) end + function api.add_item() error("wildlife must never drop items") end + function h.player(x,z) + local p={pos={x=x or 0,y=4,z=z or 0}} + function p:get_pos() return self.pos end + function p:punch() error("animals must never punch players") end + h.players[#h.players+1]=p; return p + end + function h.add(kind,x,z,payload,restore) + local def=assert(h.defs["hearth:"..kind]) + local e={name="hearth:"..kind} + local obj={pos={x=x or 12,y=3.5,z=z or 0},entity=e} + e.object=obj + function obj:get_pos() return not self.removed and self.pos or nil end + function obj:get_luaentity() return self.entity end + function obj:set_pos(p) self.pos=p; self.moves=(self.moves or 0)+1 end + function obj:set_velocity(v) self.velocity=v end + function obj:set_acceleration(v) self.acceleration=v end + function obj:set_armor_groups(v) self.armor=v end + function obj:set_yaw(y) self.yaw=y end + function obj:remove() self.removed=true; def.on_deactivate(e) end + h.objects[#h.objects+1]=obj + def.on_activate(e,payload or "",restore or 0) + return e,obj + end + function api.add_entity(p,name) + local _,obj=h.add(name:match(":(.+)$"),p.x,p.z) + return obj + end + local hearth={claim_owner_at=function(p) + if h.ambiguous and h.ambiguous(p) then return nil,true end + if h.claim and h.claim(p) then return "owner",false end + return nil,false + end,claim_allowed=function() error("empty-name claim_allowed must never be used") end} + hearth.succession={habitat=function(p) + local available=h.habitat and (not h.habitat_at or h.habitat_at(p)) + return {loaded=not h.habitat_unloaded,grass=available and 8 or 0,cover=0} + end, + forage=function(p) + assert(not h.blocked(p)); h.forages=h.forages+1 + return h.feed_ok~=false + end, + deposit=function(p) assert(not h.blocked(p)); h.deposits=h.deposits+1; return true end} + h.hearth=hearth; h.api=api; h.result=dofile(target)(hearth,api) + function h.tick(n,dt) + for _=1,n do + local snapshot={} + for _,o in ipairs(h.objects) do snapshot[#snapshot+1]=o end + for _,o in ipairs(snapshot) do if not o.removed then h.defs[o.entity.name].on_step(o.entity,dt or 1) end end + for _,fn in ipairs(h.steps) do fn(dt or 1) end + end + end + function h.stats() return h.result.status() end + return h +end + +test("three original grounded meshes, body collision, gravity, immortal and nonpersistent",function() + local h=fixture(); h.player() + for _,kind in ipairs({"hare","deer","fox"}) do + local d=h.defs["hearth:"..kind]; local p=d.initial_properties + assert(p.visual=="mesh" and p.mesh=="hearth_"..kind..".obj" and p.textures[1]=="hearth_"..kind..".png") + assert(p.visual_size.x==1 and p.visual_size.y==1 and p.collisionbox[2]==0) + assert(p.physical and p.collide_with_objects and p.static_save==false and p.pointable==false) + local e,o=h.add(kind,12) + assert(not o.removed and o.acceleration.y<0 and o.armor.immortal==1) + assert(d.on_punch(e,h.players[1])==true and not o.removed) + end +end) +test("activation rejects payloads, restores, invalid positions and duplicate accounting",function() + local h=fixture(); h.player() + local _,a=h.add("hare",12,0,"forged"); assert(a.removed) + local _,b=h.add("hare",12,0,"",1); assert(b.removed) + local _,c=h.add("hare",0/0); assert(c.removed) + local e=h.add("hare",12); h.defs[e.name].on_activate(e,"") + assert(h.stats().total==1) +end) +test("synchronous native activation enforces player and local caps",function() + local h=fixture(); h.player() + for _=1,20 do h.add("hare",12) end + assert(h.stats().total==6) + -- No player nearby is not an unlimited admission path. + local _,o=h.add("deer",1000); assert(o.removed) + local snap=h.stats(); snap.hare=999; assert(h.stats().hare==6) + local j=fixture(); j.player(-30); j.player(30) + for _=1,10 do j.add("deer",0) end + assert(j.stats().deer==6,"local cap spans overlapping player neighborhoods") +end) +test("global and species caps include distant active creatures until deactivation",function() + local h=fixture() + for i=1,12 do h.player(i*120) end + for i=1,30 do h.add("hare",math.ceil(i/3)*120+12) end + assert(h.stats().hare==16) + for i=1,20 do h.add("deer",math.ceil(i/3)*120+12) end + assert(h.stats().deer==10) + for i=1,12 do h.add("fox",(i+5)*120+12) end + assert(h.stats().total==32 and h.stats().fox==6) + for _,o in ipairs(h.objects) do if not o.removed then o.pos.x=o.pos.x+10000 end end + h.add("fox",132); assert(h.stats().total==32) + local e=h.objects[1].entity + h.defs[e.name].on_deactivate(e); h.defs[e.name].on_deactivate(e) + assert(h.stats().total==31) +end) +test("habitat recruitment is slow, empty or unloaded habitat admits nothing",function() + local h=fixture(); h.player(); h.tick(29); assert(h.stats().total==0) + h.tick(1); assert(h.stats().hare==1) + local j=fixture(); j.player(); j.habitat=false; j.tick(120); assert(j.stats().total==0) + j.habitat=true; j.habitat_unloaded=true; j.tick(60); assert(j.stats().total==0) + local k=fixture(); k.player(); k.plants=false + k.hearth.succession.habitat=function() return {loaded=true,grass=0,cover=8} end + k.tick(60); assert(k.stats().total==0,"tree cover without edible plants must not recruit") +end) +test("natural grazing earns food then delayed dung; planted lookalikes never graze",function() + local h=fixture(); h.player(); local e=h.add("deer",12) + h.tick(7); assert(h.forages==0 and h.deposits==0) + h.tick(1); assert(h.forages==1 and e.food==1 and e.hunger<15) + h.tick(7); assert(h.deposits==0) + h.tick(1); assert(h.deposits==1 and h.stats().dung==1) + local j=fixture(); j.player(); j.add("hare",12); j.natural=false; j.tick(20) + assert(j.forages==0 and j.deposits==0) +end) +test("two fed mature animals breed once, consume both parents' food, and cool down",function() + local h=fixture(); h.player(); local a=h.add("deer",12); local b=h.add("deer",13) + -- Time and meals are established explicitly; no instant birth on activation. + h.tick(2); assert(h.stats().births==0) + for _,e in ipairs({a,b}) do e.age=60; e.food=3; e.hunger=0; e.breed_at=0 end + h.tick(2); assert(h.stats().births==1 and h.stats().deer==3) + assert(a.food==0 and b.food==0) + h.tick(2); assert(h.stats().births==1) +end) +test("empty habitat and unsuccessful forage cannot fund breeding",function() + local h=fixture(); h.player(); local a=h.add("hare",12); local b=h.add("hare",13) + for _,e in ipairs({a,b}) do e.age=60;e.food=3;e.hunger=0;e.breed_at=0 end + h.habitat=false; h.tick(10); assert(h.stats().births==0 and h.forages==0) + local j=fixture(); j.player(); j.feed_ok=false + local c=j.add("deer",12); local d=j.add("deer",13) + c.age=60;d.age=60; j.tick(25) + assert(c.food==0 and d.food==0 and j.stats().births==0 and j.deposits==0) +end) +test("predation consumes finite registered hares once and has a meal cooldown",function() + local h=fixture(); h.player(); local fox=h.add("fox",12); local prey,o=h.add("hare",12.4) + -- Hold prey until the predator's turn to isolate accounting from flight. + prey.next_ai=999; fox.feed_at=0 + h.tick(2); assert(o.removed and h.stats().meals==1 and h.stats().hare==0 and fox.food==1) + h.defs[prey.name].on_deactivate(prey); assert(h.stats().hare==0) + h.tick(10); assert(h.stats().meals==1 and h.stats().births==0) + local prey2,o2=h.add("hare",fox.object.pos.x+0.4,fox.object.pos.z); prey2.next_ai=999 + fox.hunger=30; h.tick(2); assert(not o2.removed and h.stats().meals==1) +end) +test("predator reproduction requires two meals from each mature parent",function() + local h=fixture(); h.player(); local a=h.add("fox",12); local b=h.add("fox",13) + for _,e in ipairs({a,b}) do e.age=60;e.breed_at=0;e.hunger=0 end + h.tick(2); assert(h.stats().births==0) + a.food=2;b.food=1; h.tick(2); assert(h.stats().births==0) + b.food=2; h.tick(2); assert(h.stats().births==1 and a.food==0 and b.food==0) +end) +test("players and predators trigger safe flight with correct +Z mesh yaw",function() + local h=fixture(); h.player(10); local _,o=h.add("hare",12) + h.tick(2); assert(o.pos.x>12 and math.abs(o.yaw+math.pi/2)<0.001) + assert(o.velocity.x==0 and o.velocity.y==0) + local j=fixture(); j.player(); local _,p=j.add("deer",12); j.add("fox",10) + j.tick(2); assert(p.pos.x>12) +end) +test("all callback neighbors deny claims, ambiguous overlaps and external protection",function() + for _,mode in ipairs({"claim","ambiguous","blocked"}) do + local h=fixture(); h.player(); local e=h.add("hare",12) + h[mode]=function(p) return p.x>=13 end + e.feed_at=0; e.dung_at=0; h.tick(2) + assert(h.forages==0 and h.deposits==0,"callback crossed "..mode) + local _,o=h.add("fox",14); assert(o.removed) + end + local h=fixture(); h.player(); h.hearth.external_protected=function() return false end + h.api.is_protected=function() error("must prefer external protection over wrapped claim API") end + local _,o=h.add("hare",12); assert(not o.removed) +end) +test("water, cliffs, buildings, unloaded cells and newly claimed sources stop movement",function() + for _,mode in ipairs({"water","cliff","building","unloaded"}) do + local h=fixture(); h.player(10); local _,o=h.add("hare",12) + local original=h.terrain + if mode=="unloaded" then h.unloaded=function(p) return p.x>=13 end + else h.terrain=function(p) + if p.x>=13 then + if mode=="water" then return "hearth:water_source" end + if mode=="cliff" then return "air" end + if mode=="building" and p.y>=4 then return "hearth:wall" end + end + return original(p) + end end + h.tick(4); assert(o.pos.x<12.5,"unsafe crossing: "..mode) + end + local h=fixture(); h.player(); local _,o=h.add("deer",12) + h.claim=function() return true end; h.tick(1); assert(o.removed and h.forages==0) +end) +test("protected prey cannot be consumed through a boundary",function() + local h=fixture(); h.player(); local fox=h.add("fox",12); local prey,o=h.add("hare",13) + prey.next_ai=999; fox.feed_at=0; h.claim=function(p) return p.x>=13 end + -- Predator AI only: the prey's own guard would despawn it, not feed the fox. + for _=1,2 do h.steps[1](1) end + assert(not o.removed and h.stats().meals==0 and fox.food==0) +end) +test("loaded lifetime, gradual hunger and deactivation release slots without drops",function() + local h=fixture(); h.player(); local e,o=h.add("hare",12) + for _,dt in ipairs({-1,0/0,math.huge,"bad"}) do h.defs[e.name].on_step(e,dt) end + assert(e.age==0 and e.hunger==20) + h.defs[e.name].on_step(e,100000); assert(e.age==1 and not o.removed) + e.age=899; h.defs[e.name].on_step(e,1); assert(o.removed and h.stats().total==0) + local a,b=h.add("deer",12); a.hunger=99 + h.defs[a.name].on_step(a,1); assert(not b.removed) + h.defs[a.name].on_step(a,1); assert(b.removed and h.stats().total==0) +end) +test("shared AI plus recruitment never exceeds 12 actions per second or catches up",function() + local h=fixture() + for i=1,8 do h.player(i*120) end + for i=1,16 do h.add("hare",math.ceil(i/3)*120+12) end + for i=1,10 do h.add("deer",math.ceil(i/3)*120+12) end + for _=1,10 do + local before=h.stats().actions; h.tick(1); assert(h.stats().actions-before<=12) + end + local before=h.stats().actions + h.steps[1](0/0); h.steps[1](math.huge); h.steps[1](-2) + assert(h.stats().actions==before) + h.steps[1](100000); assert(h.stats().actions-before<=12) + assert(h.stats().actions>20,"shared scheduler must make progress") +end) +test("earned forage and four finite prey meals fund births rather than free reproduction",function() + local h=fixture(); h.player(); local a=h.add("hare",12); local b=h.add("hare",13) + -- Keep the mating pair in one patch; the AI still earns every meal itself. + for _,e in ipairs({a,b}) do + e.object.set_pos=function() end + end + h.tick(65) + assert(h.stats().grazed>=6 and h.stats().births==1 and h.stats().hare>=3) + assert(a.food==0 and b.food==0,"both parents must pay from earned forage") + local j=fixture(); j.player(); local f=j.add("fox",12); local g=j.add("fox",13) + for _,e in ipairs({f,g}) do e.age=60;e.breed_at=0;e.next_ai=999 end + for _,hunter in ipairs({f,f,g,g}) do + local p=hunter.object.pos + local prey=j.add("hare",p.x+0.2,p.z); prey.next_ai=999 + hunter.next_ai=0; hunter.feed_at=0; hunter.hunger=30 + j.tick(2); hunter.next_ai=999 + end + assert(j.stats().meals==4 and j.stats().hare==0 and f.food==2 and g.food==2) + f.next_ai=0; f.hunger=0; g.hunger=0; j.tick(2) + assert(j.stats().births==1 and j.stats().fox==3 and f.food==0 and g.food==0) +end) +test("wandering selects loaded habitat and refused capped births conserve parent food",function() + local h=fixture(); h.player(); local e,o=h.add("deer",12) + h.habitat_at=function(p) return p.z>=1 end + h.tick(2); assert(o.pos.z>0 and o.moves==1) + local j=fixture(); j.player(); local a=j.add("hare",12); local b=j.add("hare",13) + for _=1,4 do j.add("deer",15) end + for _,parent in ipairs({a,b}) do parent.age=60;parent.food=3;parent.hunger=0;parent.breed_at=0 end + j.tick(2) + assert(j.stats().births==0 and j.stats().total==6 and a.food==3 and b.food==3) +end) +test("canonical species ecosystem herbs feed on expanded ground but not in dormant winter",function() + for _,ground in ipairs({"forest_floor","podzol","alluvium","peat","alpine_turf"}) do + local h=fixture(); local season="Summer" + h.hearth.climate_at=function() return {season=season} end + h.hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(h.hearth,h.api) + h.hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(h.hearth,h.api) + h.terrain=function(p) return p.y<=3 and "hearth:"..ground or (p.y==4 and "hearth:clover" or "air") end + h.hearth.succession.habitat=function() return {loaded=true,grass=0,cover=8,edible=8} end + h.player(); local _,o=h.add("hare",12); assert(not o.removed,ground) + h.tick(8); assert(h.forages==1,ground) + season="Winter" + h.terrain=function(p) return p.y<=3 and "hearth:"..ground or (p.y==4 and "hearth:clover_winter" or "air") end + h.tick(20); assert(h.forages==1,"dormant clover grazed on "..ground) + end +end) +test("winter canonical grass cannot be admitted as a free meal or breeding habitat",function() + local h=fixture(); h.hearth.climate_at=function() return {season="Winter"} end + h.hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(h.hearth,h.api) + h.player(); local _,o=h.add("hare",12); assert(o.removed) + h.tick(60); assert(h.forages==0 and h.stats().total==0) +end) +print("PASS "..passed.." bounded wildlife tests") diff --git a/tests/world-art.test.mjs b/tests/world-art.test.mjs new file mode 100755 index 0000000..1541675 --- /dev/null +++ b/tests/world-art.test.mjs @@ -0,0 +1,470 @@ +// Engine-independent art contracts. Decode the actual PNG bytes independently; +// check geometry/masks, not just filenames, magic bytes or color diversity. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import { realpath } from 'node:fs'; +import { promisify } from 'node:util'; +import os from 'node:os'; +import path from 'node:path'; +import { inflateSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; +import { BOTANY_ART_MANIFEST, generateAssets, png, artContactSheetPNG } from '../scripts/assets.mjs'; +import { PALETTE, PLANT_NAMES, SEASONAL_PLANT_NAMES, WORLD_NAMES, ITEM_NAMES, ANIMAL_NAMES, + BIOME_WORLD_NAMES, SPECIES_NAMES, ROCK_NAMES, SPECIES_SEASONAL_NAMES, UNDERSTORY_NAMES, + DORMANT_UNDERSTORY_NAMES, BOTANY_CATALOGUE, BOTANY_ART_NAMES, BOTANY_SPRITE_NAMES, EVERGREEN_NAMES, + worldArt, contactSheet, animalOBJ, animalBounds } from '../scripts/world-art.mjs'; + +const digest = data => createHash('sha256').update(data).digest('hex'); +const pixel = (image,x,y) => [...image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4)]; +const pixels = image => Array.from({length:image.width*image.height},(_,i)=>pixel(image,i%image.width,Math.floor(i/image.width))); +const tile = (art,name) => art.get(`hearth_${name}.png`); + +function crc32(bytes) { + let crc=0xffffffff; + for(const b of bytes) { + crc^=b; + for(let i=0;i<8;i++) crc=(crc>>>1)^((crc&1)?0xedb88320:0); + } + return (crc^0xffffffff)>>>0; +} +function decodePNG(bytes) { + assert.deepEqual([...bytes.subarray(0,8)],[137,80,78,71,13,10,26,10]); + let offset=8, width, height; + const chunks=[], compressed=[]; + while(offset { + const art=worldArt(); + const requiredWorld='stone soil grass_top grass_side sand log_top log_side leaves planks wall roof thatch foundation frame stairs door window vent storage pantry reeds berry_bush berry_bush_bare sapling grain_patch crop_1 crop_2 crop_3 crop_4 clay_bank iron_ore_vein water fire fire_lit torch sticks loose_stone bedroll drying_rack fish_trap weir_stone meadow_grass wildflowers fern litter fungi deadwood tree_seedling young_tree mature_tree dung leaves_spring leaves_autumn leaves_winter meadow_grass_dormant fern_dormant wildflowers_dormant spring_greens spring_greens_dormant autumn_mushrooms autumn_mushrooms_dormant'.split(' '); + const requiredItems='berries grain grain_seed raw_food cooked_food fish_bait raw_fish cooked_fish fish_drying_charge dried_fish iron_ore iron_ingot charcoal charcoal_charge smelt_charge clay clay_jar clay_jar_charge dried_food dried_food_charge cloak vessel water_raw water_clean firestarter iron_axe spring_greens autumn_mushrooms'.split(' '); + const species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce'], rocks=['granite','basalt','limestone','slate','sandstone']; + const understory=['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen']; + const biome=[...rocks,...rocks.map(n=>`${n}_bricks`), + ...'forest_floor podzol alluvium peat alpine_turf gravel scree snow quartz_vein copper_ore_vein coal_ore_vein'.split(' '), + ...species.flatMap(s=>['log_top','log_side','leaves','planks'].map(n=>`${s}_${n}`))]; + const seasonal=species.filter(s=>s!=='pine' && s!=='spruce').flatMap(s=>['spring','autumn','winter'].map(n=>`${s}_leaves_${n}`)); + const dormant=understory.slice(0,6).map(n=>`${n}_dormant`); + assert.deepEqual(SPECIES_NAMES,species); assert.deepEqual(ROCK_NAMES,rocks); + assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,61); + assert.deepEqual(UNDERSTORY_NAMES,understory); assert.deepEqual(DORMANT_UNDERSTORY_NAMES,dormant); + assert.deepEqual(SPECIES_SEASONAL_NAMES,seasonal); + assert.deepEqual([...WORLD_NAMES].sort(),[...requiredWorld,...biome,...seasonal,...understory,...dormant].sort()); + assert.deepEqual([...ITEM_NAMES].sort(),[...requiredItems,'quartz','copper_ore','coal','copper_ingot','copper_charge','copper_pick'].sort()); + const names=[...WORLD_NAMES.map(n=>`hearth_${n}.png`),...ITEM_NAMES.map(n=>`hearth_item_${n}.png`), + 'hearth_hotbar.png','hearth_selected.png','hearth_crosshair.png',...ANIMAL_NAMES.map(n=>`hearth_${n}.png`), + ...BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)]; + assert.equal(new Set(names).size,names.length); assert.deepEqual([...art.keys()],names); + assert.equal(art.size,360); assert.equal(WORLD_NAMES.length,160); assert.equal(ITEM_NAMES.length,34); + for(const [name,image] of art) { + assert.match(name,/^hearth_[a-z0-9_]+\.png$/); assert.equal(image.width,32); assert.equal(image.height,32); + assert.equal(image.data.length,4096); assert.ok(image.data.some(n=>n>0)); + } +}); + +test('all 80 exact catalogue IDs and Latin identities match the actual Lua records, with two manifest states each',async()=> { + const source=await fs.readFile(new URL('../mods/hearth/botany.lua',import.meta.url),'utf8'); + const rows=[...source.matchAll(/^\s*add\("([a-z_]+)","[^"]+","([^"]+)"/gm)].map(([,id,latin])=>({id,latin})); + assert.equal(rows.length,80); assert.equal(new Set(rows.map(r=>r.id)).size,80); + assert.equal(new Set(rows.map(r=>r.latin)).size,80); + assert.deepEqual(BOTANY_ART_NAMES,rows.map(r=>r.id)); + assert.deepEqual(BOTANY_CATALOGUE.map(({id,latin})=>({id,latin})),rows); + const groups=['trees','shrubs','grasses','herbs','ferns_mosses','fungi','aquatic','lichens']; + for(const [i,g] of groups.entries()) assert.deepEqual(BOTANY_CATALOGUE.slice(i*10,i*10+10).map(r=>r.group),Array(10).fill(g)); + assert.deepEqual(BOTANY_ART_NAMES.slice(0,10),SPECIES_NAMES); + assert.equal(BOTANY_ART_MANIFEST.length,160); + assert.deepEqual(BOTANY_ART_MANIFEST.map(r=>r.filename),BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)); + for(const r of BOTANY_ART_MANIFEST) { + assert.equal(r.botanicalLatin,rows.find(row=>row.id===r.id).latin); + assert.match(r.morphology,/[a-z].*[; ]/); assert.equal(r.license,'MIT'); + assert.deepEqual([r.width,r.height],[32,32]); assert.ok(['active','dormant'].includes(r.state)); + } +}); + +test('80 recognizable botanical cutouts have unique silhouettes, crisp padded alpha, and 80 distinct bloom-free inactive drawings',()=> { + const art=worldArt(), activeMasks=new Set(), dormantMasks=new Set(), allBytes=new Set(); + for(const id of BOTANY_ART_NAMES) { + const active=tile(art,`botany_${id}`), dormant=tile(art,`botany_${id}_dormant`); + const mask=image=>Buffer.from(pixels(image).map(p=>p[3])); + activeMasks.add(digest(mask(active))); dormantMasks.add(digest(mask(dormant))); + assert.notDeepEqual(mask(active),mask(dormant),`${id}: changed growth form, not just RGB`); + for(const [state,image] of [['active',active],['dormant',dormant]]) { + const ps=pixels(image), count=ps.filter(p=>p[3]).length; + assert.ok(count>=15 && count<800,`${id} ${state}: cutout, not a square (${count})`); + assert.ok(ps.every(p=>p[3]===0 || p[3]===255)); + for(let j=0;j<32;j++) for(const [x,y] of [[j,0],[j,31],[0,j],[31,j]]) assert.equal(pixel(image,x,y)[3],0); + const colors=new Set(ps.filter(p=>p[3]).map(p=>p.slice(0,3).join())); + assert.ok(colors.size>=2 && colors.size<=14,`${id} ${state}: small botanical palette`); + allBytes.add(digest(image.data)); + if(state==='dormant') assert.ok(ps.every(p=>!p[3] || !(p[1]>p[0] && p[1]>p[2])),`${id}: dry inactive state`); + } + } + assert.equal(activeMasks.size,80,'no two taxa masquerade as the same recoloured silhouette'); + assert.equal(dormantMasks.size,80,'each inactive species retains its own structure'); + assert.equal(allBytes.size,160); +}); + +test('botanical diagnostic landmarks distinguish flowers, pinnate ferns, moss cushions, fungi and true floating lily pads',()=> { + const art=worldArt(), image=id=>tile(art,`botany_${id}`), has=(id,color)=>pixels(image(id)).some(p=>p[3] && p.slice(0,3).join()===PALETTE[color].join()); + for(const id of ['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce']) + assert.ok(pixels(image(id)).some((p,i)=>p[3] && Math.floor(i/32)<10),'tree fieldguide specimen has diagnostic upper growth'); + assert.ok(has('bluebell','mauve')); assert.ok(has('foxglove','wine')); assert.ok(has('cowslip','gold')); + assert.ok(has('fly_agaric','cream') && has('fly_agaric','rust')); + assert.ok(has('shaggy_inkcap','ink')); assert.ok(has('turkey_tail','earth')); + assert.ok(has('polypody','ochre')); assert.ok(has('haircap_moss','copper')); + for(const id of ['yellow_waterlily','white_waterlily']) { + const pad=image(id); + assert.equal(pixel(pad,5,23)[3],255,'broad floating pad, not horns'); + assert.equal(pixel(pad,10,26)[3],0,'radial basal notch in the pad'); + assert.equal(pixel(pad,1,1)[3],0,'no painted water square'); + } + assert.ok(has('white_waterlily','cream') && has('yellow_waterlily','gold')); + assert.ok(has('sunburst_lichen','copper')); assert.ok(has('lichen','barkDark')); + assert.ok(pixels(image('hornwort')).filter(p=>p[3]).length>100,'rootless forked submerged whorls'); +}); + +test('approved leaf PNG bytes survive the catalogue extension without rewriting the existing leaf designs',()=> { + const hashes={ + leaves:'0fdd70c561c7a9be4c7a2d6cae08dfc7b3467cf767d2ee84c9aa17fe4e52d1c4', + oak_leaves:'af2dfa295be79d2bc714aa57ae10c55f688d053c9b02cbbcf5bc84c8385dc582', + birch_leaves:'3ee8bedf16d9f7caae1b1b86d3160ac1057f935f77e89b6721c25fb108fcfdc2', + pine_leaves:'f38ab9e868e7b954edf94e0d8673e57d8ffffbf2dd2e7699aa43c5abb1b8fb22', + willow_leaves:'f9ac6a8d21976e42ff680d6eaf08b339b88f22b6ac1d8e6846eaa278be6caf73', + alder_leaves:'8fc4d476ddb52ddafa9dc662b5ba9e4bb509e9104da0f51f33e153697c3eb08d', + }, art=worldArt(); + for(const [name,hash] of Object.entries(hashes)) { + const image=tile(art,name); + assert.equal(digest(png(32,(x,y)=>pixel(image,x,y))),hash,name); + } +}); + +test('every tile is deterministic original clustered palette art, not independent speckle noise or a recolored square',()=> { + const first=worldArt(), second=worldArt(); + const allowed=new Set(Object.values(PALETTE).map(c=>c.join(','))), unique=new Set(); + for(const [name,image] of first) { + assert.deepEqual(image.data,second.get(name).data); unique.add(digest(image.data)); + const colors=new Set(); + for(const p of pixels(image)) if(p[3]) { + assert.ok(allowed.has(p.slice(0,3).join(',')),`${name}: explicit palette only`); colors.add(p.slice(0,3).join(',')); + } else assert.deepEqual(p,[0,0,0,0],`${name}: no hidden RGB in cutout texels`); + assert.ok(colors.size>=2 && colors.size<=14,`${name}: restrained per-tile palette`); + } + assert.equal(unique.size,first.size,'no identical stand-in art'); + // Color-independent boundary masks prove different material geometries, rather + // than the same noisy grid with swapped RGB values. + const structures=new Set(); + for(const name of ['stone','soil','grass_top','sand','log_top','log_side','planks','wall','roof','clay_bank','water']) { + const image=tile(first,name), edges=[]; let equal=0, comparisons=0; + for(let y=0;y<31;y++) for(let x=0;x<31;x++) { + for(const [dx,dy] of [[1,0],[0,1]]) { + const same=pixel(image,x,y).join()===pixel(image,x+dx,y+dy).join(); + edges.push(same?0:1); equal+=Number(same); comparisons++; + } + } + assert.ok(equal/comparisons>.48,`${name}: contiguous hand-authored clusters, not hash noise`); + structures.add(digest(Buffer.from(edges))); + } + assert.equal(structures.size,11); +}); + +test('approved leaf pixels are preserved; natural ground has no masonry grout, broad stripes or outlined blade symbols',()=> { + const art=worldArt(); + assert.equal(digest(tile(art,'leaves').data),'6ca3e11b7e8e98f46220db92a4dbe729515835c3ae39c76ac3003fa5494f3f4c'); + const rows=image=>Array.from({length:32},(_,y)=>Array.from({length:32},(_,x)=>pixel(image,x,y).join())); + for(const name of ['stone',...ROCK_NAMES]) { + const image=tile(art,name); + assert.ok(pixels(image).every(p=>p[3]===255)); + assert.ok(!pixels(image).some(p=>p.slice(0,3).join()===PALETTE.outline.join()),`${name}: no mortar outlines`); + assert.ok(rows(image).filter(row=>new Set(row).size===1).length<4,`${name}: interrupted mineral microfacets`); + if(name!=='stone') { + const brick=tile(art,`${name}_bricks`); + assert.equal(new Set(rows(brick)[11]).size,1,'crafted masonry has an explicit bed joint'); + assert.notDeepEqual(image.data,brick.data); + } + } + const soil=tile(art,'soil'), turf=tile(art,'grass_top'); + for(const name of ['loamShade','loamGlow']) { + const color=PALETTE[name].join(); + assert.ok(rows(soil).every(row=>row.filter(p=>p.startsWith(color+',')).length<24),'soil lenses never form broad solid strata'); + } + const greens=new Set(['turf','turfShade','turfGlow','leaf','sage'].map(c=>PALETTE[c].join())); + assert.ok(pixels(turf).every(p=>p[3]===255 && greens.has(p.slice(0,3).join())),'dense unoutlined natural turf only'); + for(const [x0,y0] of [[0,0],[16,0],[0,16],[16,16]]) { + const colors=new Set(); + for(let y=y0;y=4,'mosaic is distributed across every quadrant, not isolated giant blades'); + } + const side=tile(art,'grass_side'), humus=PALETTE.humus.join(); + assert.ok(Array.from({length:10},(_,i)=>i+5).some(y=>rows(side)[y].some(p=>p.startsWith(humus+','))),'roots extend below the turf cap'); + assert.deepEqual(pixel(side,16,31),pixel(soil,16,31),'mineral soil continues below organic transition'); +}); + +test('understory and species leaves have botanical identities, leafless winters and non-blooming dormant silhouettes',()=> { + const art=worldArt(), opaque=name=>pixels(tile(art,name)).filter(p=>p[3]), + has=(name,color)=>opaque(name).some(p=>p.slice(0,3).join()===PALETTE[color].join()); + for(const name of DORMANT_UNDERSTORY_NAMES) { + const active=name.replace(/_dormant$/,''); + assert.ok(opaque(name).length!(p[1]>p[0] && p[1]>p[2])),`${name}: spent straw/bark, not green`); + for(const color of ['mauve','bud','rose','wine','gold']) assert.ok(!has(name,color),`${name}: no flowers`); + } + assert.ok(has('clover','cream')); assert.ok(has('bluebell','mauve')); assert.ok(has('heather','bud')); + assert.ok(has('nettle','leafLight')); assert.ok(has('bulrush','bark')); + assert.ok(!art.has('hearth_moss_dormant.png') && !art.has('hearth_lichen_dormant.png')); + for(const s of SPECIES_NAMES.filter(s=>!EVERGREEN_NAMES.includes(s))) { + const winter=`${s}_leaves_winter`; + assert.ok(opaque(winter).length!(p[1]>p[0] && p[1]>p[2]))); + assert.ok(has(`${s}_leaves_spring`,'bud')); + assert.ok(has(`${s}_leaves_autumn`,'wine')); + } + for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter']) assert.ok(!art.has(`hearth_${s}_leaves_${season}.png`)); + for(const name of ['quartz_vein','copper_ore_vein','coal_ore_vein']) + assert.ok(pixels(tile(art,name)).every(p=>p[3]===255),'complete opaque host rock, not an overlay'); + assert.ok(has('copper_ore_vein','verdigris')); assert.ok(has('item_copper_ore','verdigris')); + assert.ok(has('item_copper_ingot','copperGlow')); assert.ok(has('item_copper_pick','copperGlow')); + assert.notDeepEqual(tile(art,'item_copper_charge').data,tile(art,'item_copper_ore').data); +}); + +test('actual content craftitems and explicit material texture references have generated art, not compatibility swatches',async()=> { + const text=await fs.readFile(new URL('../mods/hearth/content.lua',import.meta.url),'utf8'), art=worldArt(); + for(const match of text.matchAll(/api\.register_craftitem\("hearth:([a-z0-9_]+)"/g)) + assert.ok(art.has(`hearth_item_${match[1]}.png`),`craftitem ${match[1]} has an authored icon`); + for(const match of text.matchAll(/item_image\("([a-z0-9_]+)"\)/g)) + assert.ok(art.has(`hearth_item_${match[1]}.png`),`explicit icon ${match[1]}`); + const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8'); + for(const name of ROCK_NAMES) assert.ok(biomes.includes(`"${name}"`) && art.has(`hearth_${name}_bricks.png`)); + for(const name of ['quartz','copper_ore','coal']) assert.ok(art.has(`hearth_item_${name}.png`)); +}); + +test('plant and item masks have empty borders/corners, crisp alpha and recognizable non-square negative space',()=> { + const art=worldArt(), masks=new Set(); + for(const name of [...PLANT_NAMES,...ITEM_NAMES.map(n=>`item_${n}`)]) { + const image=tile(art,name), ps=pixels(image), opaque=ps.filter(p=>p[3]===255).length; + assert.ok(ps.every(p=>p[3]===0 || p[3]===255),name); + assert.ok(opaque>=15 && opaque<800,`${name}: silhouette occupancy ${opaque}`); + assert.equal(pixel(image,0,0)[3],0,name); assert.equal(pixel(image,31,31)[3],0,name); + const mask=Buffer.from(ps.map(p=>p[3])); masks.add(digest(mask)); + if(name.startsWith('item_')) { + for(let i=0;i<32;i++) for(const [x,y] of [[i,0],[0,i],[31,i],[i,31]]) + assert.equal(pixel(image,x,y)[3],0,`${name}: inventory padding`); + } + } + assert.ok(masks.size>=32,'different plant/item silhouettes, not generic masked squares'); + const bush=tile(art,'berry_bush'), bare=tile(art,'berry_bush_bare'); + assert.deepEqual(pixels(bush).map(p=>p[3]),pixels(bare).map(p=>p[3]),'berries change fruit, not foliage silhouette'); + assert.notDeepEqual(bush.data,bare.data); + const growth=[1,2,3,4].map(n=>pixels(tile(art,`crop_${n}`)).filter(p=>p[3]).length); + assert.ok(growth[0] { + const art=worldArt(); + assert.equal(pixel(tile(art,'log_top'),0,0).slice(0,3).join(),PALETTE.barkDark.join()); + const cut=tile(art,'log_top'); + for(const [dx,dy] of [[1,0],[0,1],[-1,0],[0,-1]]) { + const ray=Array.from({length:13},(_,r)=>pixel(cut,14+dx*r,16+dy*r).join()); + const changes=ray.slice(1).filter((color,i)=>color!==ray[i]).length; + assert.ok(changes>=5,'multiple growth rings on every radius, not an arbitrary pair of unequal pixels'); + } + assert.deepEqual(pixel(tile(art,'planks'),0,0).slice(0,3),PALETTE.barkLight,'subdued plank joints'); + assert.deepEqual(pixel(tile(art,'door'),25,17).slice(0,3),PALETTE.gold,'door latch'); + for(const [a,b] of [['stone','iron_ore_vein'],['fire','fire_lit'],['grass_top','grass_side'], + ['storage','pantry'],['item_water_raw','item_water_clean'],['item_raw_fish','item_dried_fish']]) + assert.notDeepEqual(tile(art,a).data,tile(art,b).data,`${a} vs ${b}`); + const window=tile(art,'window'), alphas=new Set(pixels(window).map(p=>p[3])); + assert.deepEqual([...alphas].sort((a,b)=>a-b),[0,95,255]); + assert.equal(pixel(window,0,0)[3],0); assert.equal(pixel(window,5,5)[3],95); +}); + +test('seasonal botanical silhouettes show sage buds, copper/wine autumn, bare winter twigs and bloom-free dormancy',()=> { + const art=worldArt(), opaque=name=>pixels(tile(art,name)).filter(p=>p[3]); + const has=(name,color)=>opaque(name).some(p=>p.slice(0,3).join()===PALETTE[color].join()); + const green=p=>p[1]>p[0] && p[1]>p[2]; + for(const name of SEASONAL_PLANT_NAMES) assert.ok(art.has(`hearth_${name}.png`)); + for(const color of ['springDark','springLeaf','springLight','bud']) assert.ok(has('leaves_spring',color)); + const buds=opaque('leaves_spring').filter(p=>p.slice(0,3).join()===PALETTE.bud.join()).length; + assert.ok(buds>=6 && buds<30,'buds are a subtle accent, not a pink canopy'); + for(const color of ['copper','ochre','wine']) assert.ok(has('leaves_autumn',color)); + for(const name of ['leaves_autumn','leaves_winter','meadow_grass_dormant','fern_dormant', + 'wildflowers_dormant','spring_greens_dormant','autumn_mushrooms_dormant']) { + assert.ok(opaque(name).every(p=>!green(p)),`${name}: no living green`); + for(const color of ['rose','mauve','gold','bud']) assert.ok(!has(name,color),`${name}: no bloom`); + } + const mask=name=>Buffer.from(pixels(tile(art,name)).map(p=>p[3])); + assert.ok(opaque('leaves_winter').length { + const art=worldArt(), water=tile(art,'water'); + for(let i=0;i<32;i++) { + assert.deepEqual(pixel(water,0,i),pixel(water,31,i)); + assert.deepEqual(pixel(water,i,0),pixel(water,i,31)); + } + const base=PALETTE.water.join(), ripples=pixels(water).filter(p=>p.slice(0,3).join()!==base).length; + assert.ok(ripples>30 && ripples<100); assert.ok(pixels(water).every(p=>p[3]===255)); + for(const name of ['sand','clay_bank']) for(let y=0;y<32;y++) + assert.deepEqual(pixel(tile(art,name),0,y),pixel(tile(art,name),31,y),`${name}: continuous strata`); + // Exposed grass must keep a soil body and a foliage cap on every side. + const side=tile(art,'grass_side'); + assert.ok([PALETTE.turfShade,PALETTE.turf,PALETTE.turfGlow].some(c=>c.join()===pixel(side,16,0).slice(0,3).join())); + assert.ok(pixel(side,16,31)[0]>pixel(side,16,31)[1]); +}); + +function parseOBJ(text) { + const vertices=[], uv=[], faces=[], groups=new Map(); let current; + for(const line of text.trim().split('\n')) { + const [kind,...parts]=line.split(' '); + if(kind==='g') { current={vertices:[],faces:[]}; groups.set(parts[0],current); } + if(kind==='v') { const v=parts.map(Number); vertices.push(v); current.vertices.push(v); } + if(kind==='vt') uv.push(parts.map(Number)); + if(kind==='f') { + const face=parts.map(p=>p.split('/').map(Number)); faces.push(face); current.faces.push(face); + } + assert.ok(['#','g','v','vt','f'].includes(kind),'no material/path loading directives'); + } + return {vertices,uv,faces,groups}; +} +test('wildlife meshes have outward nondegenerate indexed triangles, semantic UVs, bounded counts and stable node bounds',()=> { + const unique=new Set(); + for(const name of ANIMAL_NAMES) { + const text=animalOBJ(name); assert.equal(text,animalOBJ(name)); unique.add(text); + const {vertices,uv,faces,groups}=parseOBJ(text), bounds=animalBounds(name); + assert.ok(vertices.length>=100 && vertices.length<=300); assert.ok(faces.length>=150 && faces.length<=450); + assert.ok(vertices.flat().every(Number.isFinite)); assert.equal(uv.length,vertices.length/2); + assert.ok(uv.flat().every(n=>Number.isFinite(n) && n>0 && n<1)); + assert.equal(Math.min(...vertices.map(v=>v[1])),0,'feet are exactly at ground'); + for(let i=0;i<3;i++) { + assert.ok(Math.abs(Math.min(...vertices.map(v=>v[i]))/10-bounds.min[i])<1e-6); + assert.ok(Math.abs(Math.max(...vertices.map(v=>v[i]))/10-bounds.max[i])<1e-6); + assert.ok(bounds.max[i]-bounds.min[i]<2,'less than two nodes on every axis'); + } + assert.deepEqual(bounds.visualSize,{x:1,y:1}); + const referenced=new Set(); + for(const group of groups.values()) { + const center=[0,1,2].map(i=>group.vertices.reduce((s,v)=>s+v[i],0)/group.vertices.length); + for(const face of group.faces) { + assert.equal(face.length,3); assert.equal(new Set(face.map(p=>p[0])).size,3); + for(const [v,t] of face) { + assert.ok(v>=1 && v<=vertices.length && t>=1 && t<=uv.length); referenced.add(v); + } + const [a,b,c]=face.map(([id])=>vertices[id-1]), u=b.map((n,i)=>n-a[i]), v=c.map((n,i)=>n-a[i]); + const normal=[u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]]; + assert.ok(Math.hypot(...normal)>1e-4,'nonzero triangle area'); + assert.ok(normal.reduce((sum,n,i)=>sum+n*(a[i]-center[i]),0)>0,'outward face winding'); + // All triangle UVs stay inside the same 8px semantic atlas patch. + const cells=face.map(([,id])=>uv[id-1].map(n=>Math.floor(n*4)).join()); + assert.equal(new Set(cells).size,1,'no UV crossing into another material'); + } + } + assert.equal(referenced.size,vertices.length); + } + assert.equal(unique.size,3); + for(const bad of ['../hare','toString','constructor','wolf']) { + assert.throws(()=>animalOBJ(bad),/Unknown animal/); assert.throws(()=>animalBounds(bad),/Unknown animal/); + } +}); + +test('species-specific silhouettes retain hare long ears, fox long bushy tail, deer hooves/antlers and leg clearance',()=> { + const hare=parseOBJ(animalOBJ('hare')), fox=parseOBJ(animalOBJ('fox')), deer=parseOBJ(animalOBJ('deer')); + const extent=(group,axis)=>Math.max(...group.vertices.map(v=>v[axis]))-Math.min(...group.vertices.map(v=>v[axis])); + assert.ok(extent(hare.groups.get('ear_left'),1)>3*extent(hare.groups.get('ear_left'),0)); + assert.ok(extent(fox.groups.get('tail'),2)>3.5); + assert.ok(extent(fox.groups.get('tail'),0)>2*extent(fox.groups.get('leg_1_1.9'),0)); + assert.ok(deer.groups.has('antler_1') && deer.groups.has('point_-1') && deer.groups.has('hoof_1_2.4')); + assert.ok(Math.min(...deer.groups.get('body').vertices.map(v=>v[1]))>=4); + assert.ok(animalBounds('deer').max[1]>animalBounds('hare').max[1]*1.6); +}); + +test('PNG writer supports rectangular contact sheets, validates bounded dimensions/RGBA, and independently decodes exact atlas cells',()=> { + const art=worldArt(), sheet=contactSheet(art), pngBytes=artContactSheetPNG(art), decoded=decodePNG(pngBytes); + assert.equal(sheet.width,512); assert.equal(sheet.height,Math.ceil(art.size/16)*32); + assert.equal(decoded.width,sheet.width); assert.equal(decoded.height,sheet.height); assert.deepEqual(decoded.data,sheet.data); + const ordered=[...art.entries()].sort(([a],[b])=>ab?1:0); + ordered.forEach(([,image],i)=> { + for(const [x,y] of [[0,0],[15,15],[31,31]]) { + const p=pixel(image,x,y), output=pixel(decoded,(i%16)*32+x,Math.floor(i/16)*32+y); + assert.equal(output[3],255); + if(p[3]===255) assert.deepEqual(output,p,'opaque art copied without scaling/filtering'); + } + }); + // Every seasonal tile is included, not merely present in the named manifest. + for(const name of SEASONAL_PLANT_NAMES) { + const index=ordered.findIndex(([key])=>key===`hearth_${name}.png`), image=tile(art,name); + for(let y=0;y<32;y++) for(let x=0;x<32;x++) if(pixel(image,x,y)[3]===255) + assert.deepEqual(pixel(decoded,index%16*32+x,Math.floor(index/16)*32+y),pixel(image,x,y)); + } + assert.deepEqual(pngBytes,artContactSheetPNG()); + const small=decodePNG(png(3,()=>[1,2,3,4],5)); assert.equal(small.width,3); assert.equal(small.height,5); + for(const [w,h] of [[0,1],[1,-1],[1.5,2],[Infinity,1],[2049,1],[2048,2048]]) + assert.throws(()=>png(w,()=>[0,0,0,0],h),/dimensions/); + for(const color of [[1,2,3],[-1,0,0,255],[256,0,0,255],[0,0,NaN,255],[0,0,0,.5]]) + assert.throws(()=>png(1,()=>color),/RGBA/); + for(const images of [new Map(),new Map([['bad',{width:32,height:32,data:new Uint8Array(4)}]]), + new Map(Array.from({length:513},(_,i)=>[i,art.values().next().value])),{},null]) + assert.throws(()=>contactSheet(images),/1\.\.512 RGBA 32px tiles/); + assert.equal(contactSheet(new Map(Array.from({length:512},(_,i)=>[String(i),art.values().next().value]))).height,1024); + assert.deepEqual(contactSheet(new Map([...art].reverse())),sheet,'filename sorting makes the review legend stable'); +}); + +test('generateAssets bundles every named art/model and contact sheet from a minimal fixture source with guarded output paths',async t=> { + const root=await fs.mkdtemp(path.join(await promisify(realpath)(os.tmpdir()),'hearth-world-art-')); + t.after(()=>fs.rm(root,{recursive:true,force:true})); + const source=path.join(root,'source'), destination=path.join(root,'output'); + await fs.mkdir(path.join(source,'mods/hearth'),{recursive:true}); + await fs.writeFile(path.join(source,'mods/hearth/init.lua'),'-- fixture: no named asset references or source binaries\n'); + await generateAssets(destination,source); + const textures=path.join(destination,'mods/hearth/textures'), art=worldArt(); + for(const [name,image] of art) { + const decoded=decodePNG(await fs.readFile(path.join(textures,name))); + assert.equal(decoded.width,32); assert.equal(decoded.height,32); assert.deepEqual(decoded.data,image.data,name); + } + assert.deepEqual(await fs.readFile(path.join(textures,'hearth_art_contact_sheet.png')),artContactSheetPNG()); + for(const name of ANIMAL_NAMES) + assert.equal(await fs.readFile(path.join(destination,`mods/hearth/models/hearth_${name}.obj`),'utf8'),animalOBJ(name)); + // New named assets use the existing safePath writer, including direct calls. + const outside=path.join(root,'outside'); await fs.mkdir(outside); + await fs.writeFile(path.join(outside,'sentinel'),'unchanged'); + const linked=path.join(root,'linked'), models=path.join(linked,'mods/hearth/models'); + await fs.mkdir(path.dirname(models),{recursive:true}); + await fs.symlink(outside,models,process.platform==='win32'?'junction':'dir'); + await assert.rejects(generateAssets(linked,source),/symbolic link|junction/); + assert.deepEqual(await fs.readdir(outside),['sentinel']); + assert.equal(await fs.readFile(path.join(outside,'sentinel'),'utf8'),'unchanged'); +});