Lots of content

This commit is contained in:
2026-11-03 12:41:09 -05:00
parent 6ebe95b208
commit d34388bf2c
126 changed files with 28076 additions and 1027 deletions
Executable
+5
View File
@@ -0,0 +1,5 @@
/dist/
/node_modules/
/mods/hearth/textures/
/mods/hearth/models/
*.log
Vendored Executable
+9
View File
@@ -0,0 +1,9 @@
{
"python-envs.pythonProjects": [
{
"path": ".",
"envManager": "ms-python.python:system",
"packageManager": "ms-python.python:pip"
}
]
}
Vendored Executable
+26
View File
@@ -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": []
}
]
}
+307 -134
View File
@@ -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.
+157
View File
@@ -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:<kind>`. |
| 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.
+983
View File
@@ -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_&lt;skin&gt;.png | 6 |
| hearth_char_face_&lt;face&gt;.png | 4 |
| hearth_char_eyes_&lt;eyes&gt;.png | 5 |
| hearth_char_hair_&lt;style&gt;_&lt;hairColor&gt;.png | 42 |
| hearth_char_beard_&lt;style&gt;_&lt;hairColor&gt;.png | 30 |
| hearth_char_garment_&lt;style&gt;_&lt;cloth&gt;.png | 32 |
| hearth_char_trousers_&lt;trousers&gt;.png | 4 |
| hearth_char_boots_&lt;boots&gt;.png | 3 |
| hearth_char_accent_&lt;style&gt;_&lt;accentColor&gt;.png | 40 |
| hearth_char_detail_&lt;detail&gt;.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_&lt;skin&gt;.png ^ hearth_char_face_&lt;face&gt;.png ^
hearth_char_eyes_&lt;eyes&gt;.png ^ hearth_char_garment_&lt;garment&gt;_&lt;cloth&gt;.png ^
hearth_char_trousers_&lt;trousers&gt;.png ^ hearth_char_boots_&lt;boots&gt;.png ^
hearth_char_accent_&lt;accent&gt;_&lt;accentColor&gt;.png ^
hearth_char_hair_&lt;hair&gt;_&lt;hairColor&gt;.png ^
hearth_char_beard_&lt;beard&gt;_&lt;hairColor&gt;.png ^ hearth_char_detail_&lt;detail&gt;.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.
<details>
<summary>Historical art milestones — superseded counts and tree-mesh advice</summary>
# 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.
<details>
<summary>Historical pre-extension art snapshot (superseded counts, geometry and sheet order)</summary>
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<filename, {width, height, data}>`.
`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_<world-name>.png`
- `mods/hearth/textures/hearth_item_<item-name>.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_<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).
</details>
</details>
Executable
+241
View File
@@ -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:<id>`. Every tree's initial node is `hearth:<id>_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_<id>.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.
+98
View File
@@ -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.
+374
View File
@@ -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_<name>.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.
+498
View File
@@ -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:<species>_seedling`, `hearth:<species>_young`, and
`hearth:<species>_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:<species>_<stage>`; remaining trunk/branch and canopy
records use `hearth:<species>_log` and `hearth:<species>_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_<species>.png` on a plantlike sprite. Young/mature
roots and species timber use `hearth_<species>_log_top.png` on both ends and
`hearth_<species>_log_side.png` elsewhere. Planks use `hearth_<species>_planks.png`.
No ecological tree definition references a mesh or atlas.
- Biome-owned full-block leaves use `hearth_<species>_leaves.png`. Deciduous
seasonal variants use `hearth_<species>_leaves_spring.png`,
`hearth_<species>_leaves_autumn.png`, `hearth_<species>_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_<plant>_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.
+266
View File
@@ -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.
+129
View File
@@ -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. |
+150
View File
@@ -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 <new-external-destination>` twice; `verify <fresh-package>` | **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.
+76
View File
@@ -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.
+67
View File
@@ -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.
+64
View File
@@ -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 <player> <role>`. `/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.
+141
View File
@@ -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.
+60
View File
@@ -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.
+101
View File
@@ -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.
+430
View File
@@ -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.
+129
View File
@@ -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, "<actual title>"` |
| `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.
+294
View File
@@ -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_<species>_log_top.png`,
`hearth_<species>_log_side.png`, `hearth_<species>_leaves.png`,
`hearth_<species>_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.
+18 -1
View File
@@ -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
+39
View File
@@ -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
+322
View File
@@ -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
+359
View File
@@ -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)<math.huge end
local function raw_kind(value)
local name=type(value)=="table" and value.name or value
if type(name)~="string" then return nil end
if seasonal_nodes[name] then return seasonal_nodes[name] end
if hearth.seasons and hearth.seasons.kind then name=hearth.seasons.kind(name) or name end
return name:gsub("^hearth:","")
end
function B.kind(value)
local k=raw_kind(value)
if index[k] then return k end
local id,part=(k or ""):match("^([a-z]+)_([a-z]+)$")
if index[id] and index[id].group=="trees" and
({seedling=true,young=true,mature=true,leaves=true,log=true,planks=true})[part] then return id end
end
local function record(value) local id=B.kind(value); return id and index[id] end
function B.get(value) return copy(record(value)) end
function B.group_of(value) local r=record(value); return r and r.group end
function B.list(g)
local out={}; for _,r in ipairs(records) do if not g or r.group==g then out[#out+1]=copy(r) end end; return out
end
function B.has_tag(value,tag) local r=record(value); return r~=nil and r.tags[tag]==true end
function B.interactions(value)
local r=record(value); local out={}
if r then for tag in pairs(r.tags) do out[tag]=copy(descriptors[tag]) end end
return out
end
function B.active(value,season) local r=record(value); return r~=nil and r.seasons[season]==true end
-- Install policy at factory time, before content calls seasons.register().
-- Diversity owns the eight existing understory IDs and every tree stage.
local seasons=hearth.seasons
if seasons and seasons.add_variants then
for _,r in ipairs(records) do
if r.group~="trees" and not r.existing then
local definitions={}
for _,season in ipairs({"Spring","Summer","Autumn","Winter"}) do
if not B.active(r.id,season) then
definitions[season]={texture="hearth_botany_"..r.id.."_dormant.png"}
seasonal_nodes[r.node.."_"..season:lower()]=r.id
end
end
-- A copy prevents optional consumers from mutating catalogue policy.
seasons.add_variants(r.id,definitions,copy(r.seasons))
end
end
end
function B.pollinator(value,season)
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[season]==true
end
function B.edible(value,season)
local r=record(value); return r~=nil and r.food==true and not r.toxic and r.fruiting[season]==true
end
function B.browse(value,season) return B.active(value,season) and B.has_tag(value,"browse") end
function B.resin(value) return B.has_tag(value,"resin") end
-- Pure effect suggestions; caller owns metadata, nutrient budgets and protection.
function B.nitrogen(value,current)
if not B.has_tag(value,"nitrogenfixer") or not finite(current) or current<0 then return 0 end
return math.min(1,math.max(0,2-current))
end
function B.root_fungi(value,tree,budget)
local r,t=record(value),B.kind(tree)
if not r or r.group~="fungi" or not r.tags.ectomycorrhiza or not r.niche.hosts[t]
or not finite(budget) or budget<=0 then return 0 end
return math.min(1,budget) -- transfer ceiling, not newly created nutrients
end
local function within(n,range) return finite(n) and 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.nutrients<n.nutrients) then return false end
if n.hosts and not n.hosts[B.kind(h.host)] then return false end
local depth=h.water or 0
if not within(depth,n.water) then return false end
local ground=raw_kind(h.substrate or h.ground)
if n.aquatic=="surface" or n.aquatic=="submerged" then
if h.aquatic~=n.aquatic or h.freshwater~=true then return false end
if r.id=="hornwort" then return ground=="water" end
return n.substrates[raw_kind(h.ground)]==true
end
if h.aquatic and h.aquatic~="land" and h.aquatic~="bank" then return false end
if n.aquatic=="bank" and h.aquatic~="bank" then return false end
return n.substrates[ground]==true
end
function B.choose(p,h,season)
if type(p)~="table" or type(h)~="table" then return nil end
for _,axis in ipairs({"x","y","z"}) do if not finite(p[axis]) or math.abs(p[axis])>30990 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
+227
View File
@@ -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
+63
View File
@@ -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)
+59
View File
@@ -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
+228
View File
@@ -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
+613 -334
View File
File diff suppressed because it is too large Load Diff
+261
View File
@@ -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=<new UUID>, 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
+230
View File
@@ -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
+230
View File
@@ -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
+263 -68
View File
@@ -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})
+139
View File
@@ -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
+143
View File
@@ -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,
})
+162
View File
@@ -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
+177 -17
View File
@@ -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 <player> <resident|storekeeper>|unshare <player>|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 <player> <resident|storekeeper>." 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 <player> 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")
+26
View File
@@ -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
+477 -53
View File
@@ -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 <exact ID or Latin name>." 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 <word> 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 <player> resident (build and household use) or /claim share <player> storekeeper (storage only). /claim unshare <player> 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
+5 -1
View File
@@ -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)
+43
View File
@@ -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
+126
View File
@@ -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
+377
View File
@@ -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)
+114
View File
@@ -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
+258
View File
@@ -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
+124 -179
View File
@@ -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)
+105
View File
@@ -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
+113
View File
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+116
View File
@@ -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)<math.huge end
local function count(n,cap) return finite(n) and math.min(cap,math.max(0,math.floor(n))) or 0 end
local wiki="https://en.wikipedia.org/wiki/"
-- Original summaries of pages reviewed 2026-11-03. Numbers below are game
-- approximations, NOT measurements reported by these articles.
local reviewed={
alder={article="Alnus_glutinosa",note="Ecology: Frankia root nodules fix nitrogen and improve soil fertility."},
clover={article="Trifolium_repens",note="Cultivation and uses / Forage: symbiotic root-nodule nitrogen fixation."},
dog_lichen={article="Peltigera_canina",related="Peltigera",
note="Species Description identifies Nostoc; genus Description explains nitrogen fixation. Genus-level mechanism, not a measured species rate."},
sphagnum={article="Sphagnum_palustre",related="Sphagnum",
note="Species introduction describes water absorption; genus introduction explains cation exchange and acidification. Acidification is genus-level evidence."},
duckweed={article="Lemna_minor",related="Lemnoideae",
note="Species Cultivation describes floating carpets; subfamily Duckweed in natural environments describes shade. Shade is a group-level mechanism."},
birch_polypore={article="Fomitopsis_betulina",
note="Distribution, habitat and ecology: brown rot of birch wood; living-tree parasitism is not simulated."},
fly_agaric={article="Amanita_muscaria",
note="Distribution and ecology: ectomycorrhizal tree associations. Game host list is deliberately narrower; carbon and water exchange are not simulated."},
}
local function record(value)
return B.get(type(value)=="table" and value.id or value)
end
-- Identity references cover all 80 records. An identity link is not evidence
-- that every tagged trait has been reviewed or implemented.
function E.source(value)
local r=record(value); if not r then return nil end
local evidence=reviewed[r.id]
local snapshot=hearth.wiki_articles or articles
local article=type(snapshot)=="table" and snapshot[r.id]
return {source_species=r.latin,source_url=evidence and wiki..evidence.article
or (article and article.url) or wiki..r.latin:gsub(" ","_"),
reviewed=evidence~=nil,reviewed_on=evidence and "2026-11-03" or nil,
note=evidence and evidence.note or "Identity reference only; no species-effect evidence reviewed here.",
evidence_url=evidence and wiki..(evidence.related or evidence.article) or nil,
-- Snapshot revision is identity provenance; the intro may omit the
-- reviewed body section. Never present it as a pinned effect claim.
identity_revision=article and article.revision or nil,
identity_revision_url=article and article.revision_url or nil}
end
function E.sources()
local out={}; for _,r in ipairs(B.list()) do out[r.id]=E.source(r) end; return out
end
local tags={"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"}
local nitrogen="After 900 warm active seconds: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8)."
local descriptions={
browse="Active natural plants count as wildlife forage; grazing consumes eligible plants, not mature trees. Not human food permission.",
pollinator="Only natural plants in their catalogue flowering season add to pollination. This input does not itself change berry timers.",
nursery="Each active natural non-component plant adds nursery cover; any cover reduces succession stress accumulation to 75%. No extra animal spawn is promised.",
parasite="Not simulated: no infection or attack on living trees or player plants.",
allelopathy="Not simulated: no chemical suppression or plant killing.",
resin="Not simulated by species effects: no automatic resin production or extraction.",
}
function E.describe(value)
local r=record(value); if not r then return {} end
local out={}
local function add(title,status,game)
local row=E.source(r); row.title,row.status,row.game=title,status,game
out[#out+1]=row
end
for _,tag in ipairs(tags) do if r.tags[tag] then
local status,game="descriptive",descriptions[tag]
if tag=="nitrogenfixer" then status,game="simulated",nitrogen
elseif tag=="browse" or tag=="pollinator" or tag=="nursery" then status="simulated"
elseif tag=="ectomycorrhiza" then
if r.group=="fungi" then
status,game="simulated","Active compatible nearby fungus permits seedling growth at 16200 instead of 21600 active seconds; successful growth debits at most 1 existing soil nutrient once, regardless of fungus count. No nutrient creation or carbon/water ledger."
else game="Not simulated as a tree aura: only an active compatible nearby fungus can enable the finite seedling nutrient debit." end
elseif tag=="saprotroph" then
status,game="simulated","On suitable natural deadwood/litter only: advance its finite decay age by at most 60 seconds per evaluation, capped at 7200/1800. Only substrate decay pays soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
elseif tag=="engineer" then
if r.id=="sphagnum" then
status,game="simulated","Local land habitat estimate: +0.10 moisture and -0.20 pH per mat, at most 2 mats; moisture clamped 0..1, pH 3.5..8.5. No terrain, water or persistent pH writes."
elseif r.id=="duckweed" then
status,game="simulated","Active floating mats reduce nearby submerged freshwater habitat light by 1.5 each, at most 4 mats / 6 light; never brighten an already-dark site and never lower it below 2. Source-water nodes remain intact."
else game="Not simulated: no species-specific erosion, bank stabilization, rock weathering or hydrology effect for this engineer tag." end
end
add(tag,status,game or "Not simulated: natural-history descriptor only.")
end end
if r.group=="trees" then
add("canopy","simulated","Trunks/crowns reduce local habitat light and contribute moisture through existing canopy sampling; pine/spruce retain full Winter influence, deciduous foliage retains 15%. Not a hydrological solver.")
end
if r.toxic then add("toxicity","descriptive","Not simulated as an attack: this warning never grants a poison aura or human food permission.") end
return out
end
-- Pure derivation from ONE base sample. Callers must supply fresh baseline h
-- on each observation, never feed a derived result back as a new baseline.
function E.habitat(h,observations)
local out=copy(h)
if type(h)~="table" or h.loaded~=true then return out end
local seen=type(observations)=="table" and observations or h.species_counts or {}
out.source_moisture,out.source_ph,out.source_light=h.moisture,h.ph,h.light
if (h.water or 0)==0 and (h.aquatic==nil or h.aquatic=="land" or h.aquatic=="bank") then
local mats=count(seen.sphagnum,2)
if mats>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
+138
View File
@@ -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)<math.huge end
local function position(p)
return type(p)=="table" and finite(p.x) and finite(p.y) and finite(p.z)
and math.abs(p.x)<31000 and math.abs(p.y)<31000 and math.abs(p.z)<31000
end
-- UTF-8 boundaries without relying on Lua 5.3's optional utf8 library.
-- At most 8000 code points / 32000 bytes enter a body; each page is 4000.
local function clip(value, limit, skip)
local s = type(value)=="string" and value or ""
local i, count, first = 1, 0, nil
skip = skip or 0
while i<=#s and count<skip+limit do
if count==skip then first=i end
local b=s:byte(i)
i=i+(b<128 and 1 or b<224 and 2 or b<240 and 3 or 4)
count=count+1
end
local out=first and s:sub(first,i-1) or ""
return out:gsub("[%z\1-\8\11-\31\127]", ""), i<=#s
end
local function safe(value, limit) return esc((clip(value,limit or 512))) end
local function record(value)
local B=hearth.botany
return B and type(B.get)=="function" and B.get(value) or nil
end
function R.resolve(value)
if type(value)~="string" or #value>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<pages and "button[2.8,11;2,0.6;reader_next;Next]" or "")
.."button[6,11;3,0.6;reader_botany;Botanical guide]button_exit[10,11;3.3,0.6;reader_close;Close]"
api.show_formspec(player:get_player_name(),"hearth:species",f)
return true
end
function R.handle(player, fields)
local s=session(player)
local state=s and s.species_reader
if not state then return false end
if fields.quit or fields.reader_close then s.species_reader=nil; return true end
if fields.reader_botany then
s.species_reader=nil
if hearth.botanical_guide then hearth.botanical_guide(player,s.botany_query,s.botany_page) end
elseif fields.reader_tab=="1" or fields.reader_tab=="2" then
R.show(player,state.id,tonumber(fields.reader_tab),1)
elseif fields.reader_next then R.show(player,state.id,state.tab,state.page+1)
elseif fields.reader_previous then R.show(player,state.id,state.tab,state.page-1) end
-- Client-provided text, IDs, URLs and page numbers are never used.
return true
end
return R
end
+758
View File
@@ -0,0 +1,758 @@
-- Original loaded-only succession. No ABMs, map emergence, timers or world registry.
return function(hearth, api)
local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192,
MAX_TREE_CELLS=810, MAX_WATER_DEPTH=6}
local PREFIX, PERIOD = "hearth:", 60
local kinds = {meadow_grass=true, wildflowers=true, fern=true, litter=true, fungi=true,
deadwood=true, young_tree=true, mature_tree=true, tree_seedling=true, dung=true,
spring_greens_patch=true, autumn_mushrooms_patch=true}
local trees = {tree_seedling=true, young_tree=true, mature_tree=true}
local diversity = hearth.diversity
local botany = hearth.botany
local effects = hearth.species_effects
-- Content may inject the factory before succession. Standalone integrations
-- without it keep their old API; the engine loads only this fixed sibling.
if botany and not effects and api.get_modpath then
effects=dofile(api.get_modpath("hearth").."/species_effects.lua")(hearth,api)
hearth.species_effects=effects
end
if botany then
for _,r in ipairs(botany.list()) do
if r.group~="trees" then kinds[r.id]=true end
end
end
if diversity then
for _,k in ipairs(diversity.plants) do kinds[k]=true end
for _,species in ipairs(diversity.species_order) do
for _,stage in ipairs({"seedling","young","mature"}) do
local k=species.."_"..stage; kinds[k]=true; trees[k]=true
end
end
end
local offsets = {{1,0}, {0,1}, {-1,0}, {0,-1}, {2,1}, {-2,-1}, {1,-2}, {-1,2}}
local seen, ring, ring_index, cursor, player_index, elapsed = {}, {}, 1, 0, 0, 0
local function key(p) return p.x .. ":" .. p.y .. ":" .. p.z end
local function at(p, x, y, z) return {x=p.x+x, y=p.y+y, z=p.z+z} end
local function position(p)
if type(p) ~= "table" then return nil end
for _, axis in ipairs({"x", "y", "z"}) do
local n = p[axis]
if type(n) ~= "number" or n ~= n or math.abs(n) > 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)<math.huge
if field:sub(1,5) ~= "succ_" and not harvest and text ~= "" and tostring(text) ~= "0" then return true end
end
end
for _, field in ipairs(markers) do
local value = meta:get_string(field)
if value ~= "" and value ~= "0" then return true end
end
return false
end
local function permitted(p)
-- Autonomous nature has no claim-owner identity, including on shared claims.
if hearth.claim_owner_at then
local owner, ambiguous = hearth.claim_owner_at(p)
if owner or ambiguous then return false end
end
-- Player access deliberately rejects empty names, even on unclaimed ground.
-- Nature must instead explicitly exclude claims and external protection.
local protection = hearth.external_protected or api.is_protected
return not (protection and protection(p, "")) and not marked(p)
end
local function ground(p)
local n = node(p)
return n and (n.name == PREFIX.."grass" or n.name == PREFIX.."soil"
or (diversity and (diversity.is_ground(n) or diversity.is_rock(n))))
end
local function natural(p, n)
return n and (kinds[kind(n)] or (botany and api.get_meta(p):get_string("succ_component")~=""))
and api.get_meta(p):get_string("succ_natural") == "1"
end
-- Semantic substrate observations never replace water, logs or terrain.
-- A water stand occupies AIR above a separately conserved source column.
local function substrate(p)
local below=at(p,0,-1,0)
local n=node(below)
if not n then return nil end
local k=kind(n)
if ground(below) then return {ground=k,substrate=k,water=0,base=below} end
if not botany then return nil end
if k=="water_source" then
for depth=1,S.MAX_WATER_DEPTH do
local q=at(p,0,-depth,0)
local w=node(q)
if not w or kind(w)~="water_source" or not permitted(q) then return nil end
local bottom=at(p,0,-depth-1,0)
local b=node(bottom)
if not b or not permitted(bottom) then return nil end
if kind(b)~="water_source" then
if not ground(bottom) and kind(b)~="sand" then return nil end
return {ground=kind(b),substrate="water",water=depth,base=bottom,freshwater=true}
end
end
elseif natural(below,n) and permitted(below) then
if k=="deadwood" or k=="litter" then
return {ground=k,substrate=k,water=0,base=below,host=api.get_meta(below):get_string("succ_species")}
elseif family(k)=="log" or family(k)=="young_tree" or family(k)=="mature_tree" then
local id=botany.kind(n) or api.get_meta(below):get_string("succ_species")
return {ground="bark",substrate="bark",water=0,base=below,host=id}
end
end
end
local function site(p)
local n, below = node(p), at(p,0,-1,0)
if not n or not substrate(p) or not permitted(p) or not permitted(below) then return false end
if n.name ~= "air" and not natural(p, n) then return false end
if not botany and diversity and n.name~="air" and not diversity.supports_ground(kind(n),node(below)) then return false end
-- A conservative buffer around farms/builds/markers; unknown/unloaded fails closed.
for x=-1,1 do for z=-1,1 do for y=0,2 do
local q = at(p,x,y,z)
local other = node(q)
if not other or marked(q) then return false end
local name = family(kind(other))
if other.name ~= "air" and not natural(q, other)
and name ~= "leaves" and name ~= "log"
and not (botany and kind(other)=="water_source" and permitted(q))
and not (diversity and diversity.is_worldgen_flora(other) and permitted(q)) then return false end
end end end
return true
end
function S.habitat(raw)
local p = position(raw)
local h = {grass=0, flowers=0, cover=0, water=0, canopy=0, loaded=true, moisture=0, light=0,
edible=0,pollination=0,species_counts={}}
if not p then h.loaded=false; return h end
local support=node(at(p,0,-1,0))
h.ground=kind(support)
local sub=substrate(p)
if botany and not sub then h.loaded=false; return h end
if botany then
h.ground,h.substrate,h.water,h.host=sub.ground,sub.substrate,sub.water,sub.host
h.freshwater=sub.freshwater==true
h.aquatic="land"; h.near_water=0; h.nursery=0; h.root_fungi={}
-- Support-class pH is a deliberately coarse game observation.
h.ph=({peat=4.5,podzol=5,alpine_turf=5.5,granite=5,basalt=6.5,
limestone=7.5,slate=5.5,sandstone=5.5,scree=5.5,stone=5.5,
forest_floor=5.5,alluvium=6.5,grass=6.5,soil=6.5,sand=6.5,bark=5.5,deadwood=5.5,litter=5.5})[h.ground]
end
h.nutrients=number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
-- Leafless deciduous branches cast less shade than a summer canopy.
local season=hearth.seasons and hearth.seasons.season() or "Summer"
local foliage = season == "Winter" and 0.15 or 1
-- Fixed 5x5x5 stencil: includes river water, vegetation and overhead canopy.
for x=-2,2 do for z=-2,2 do for y=-1,3 do
local q = at(p,x,y,z)
local n = node(q)
if not n then h.loaded=false
else
local k = kind(n)
local f=family(k)
local evergreen=k=="pine_leaves" or k=="spruce_leaves"
or (diversity and (diversity.tree_info(k)=="pine" or diversity.tree_info(k)=="spruce"))
local shade=evergreen and 1 or foliage
if k == "water_source" or k == "water_flowing" then
if botany then h.near_water=h.near_water+1 else h.water=h.water+1 end
end
-- A tree's own crown shades its neighbours, not its root's
-- establishment test. Otherwise every grown tree self-starves.
if f == "leaves" and not (botany and api.get_meta(q):get_string("succ_component")==key(p)) then h.canopy=h.canopy+0.15*shade end
if natural(q,n) then
local owner_root=api.get_meta(q):get_string("succ_component")
local component=owner_root~="" and owner_root~=key(q)
if botany and permitted(q) then
local r=botany.get(botany.kind(n))
if r then
-- One rooted mat, not every tree component or stacked sprite.
-- Use the existing stencil; never a new radius scan.
if y==0 and not component and botany.active(r.id,season) then
local below=at(q,0,-1,0)
local b=node(below)
local supported=(r.id=="sphagnum" and b and (kind(b)=="peat" or kind(b)=="alluvium"))
or (r.id=="duckweed" and b and kind(b)=="water_source")
or (r.id=="alder" and trees[k] and ground(below))
if supported and permitted(below) then
h.species_counts[r.id]=(h.species_counts[r.id] or 0)+1
end
end
if not h.host and r.group=="trees" then h.host=r.id end
if r.group=="grasses" then h.grass=h.grass+1 end
if botany.pollinator(k,season) and r.group~="trees" then h.flowers=h.flowers+1 end
if r.group=="shrubs" or r.group=="ferns_mosses" or r.group=="aquatic" then h.cover=h.cover+1 end
if botany.browse(k,season) and not component then h.edible=h.edible+1 end
if botany.pollinator(k,season) and not component then h.pollination=h.pollination+1 end
if botany.active(k,season) and r.tags.nursery and not component then h.nursery=h.nursery+1 end
if r.group=="fungi" and r.tags.ectomycorrhiza and botany.active(k,season) then h.root_fungi[#h.root_fungi+1]=r.id end
end
end
if k == "meadow_grass" then h.grass=h.grass+1 end
if k == "wildflowers" then h.flowers=h.flowers+1 end
if k == "fern" or trees[k] or (diversity and diversity.is_worldgen_flora(k)) then h.cover=h.cover+1 end
if f == "mature_tree" and api.get_meta(q):get_string("succ_tree_stage")=="" then h.canopy=h.canopy+shade end
if f == "young_tree" and api.get_meta(q):get_string("succ_tree_stage")=="" then h.canopy=h.canopy+0.5*shade end
if diversity and not botany and diversity.edible(k) then h.edible=h.edible+1 end
if diversity and (not botany or not botany.kind(k)) and diversity.pollinator(k,season) then h.pollination=h.pollination+1 end
end
end
end end end
local light = api.get_node_light and api.get_node_light(p, 0.5)
h.light = math.max(0, math.min(15, light or 15) - h.canopy*3)
local biome = api.get_biome_data and api.get_biome_data(p)
h.heat=biome and biome.heat or 50
h.moisture = math.min(1, (biome and biome.humidity or 45)/100 + math.min(0.5,(h.near_water or h.water)*0.1) + h.canopy*0.08)
local surfaces=hearth.biomes and hearth.biomes.surface
local surface=surfaces and surfaces(support)
local soil_moisture=surface and surface.moisture
or ({forest_floor=0.6,podzol=0.4,alluvium=0.85,peat=1,alpine_turf=0.35})[h.ground]
if soil_moisture then h.moisture=math.min(1,(h.moisture+soil_moisture)/2) end
if botany then
if h.water>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 elapsed<S.INTERVAL then return 0 end
elapsed=0 -- Never replay a stalled frame's backlog.
local players = api.get_connected_players()
if #players==0 then seen,ring,ring_index={},{},1; return 0 end
local now, work = os.time(), 0
local side = S.RADIUS*2+1
for _=1,S.BUDGET do
player_index=player_index%#players+1
local p = position(players[player_index]:get_pos())
if p then
local index = math.floor(cursor/#players)%(side*side)
local x,z = index%side-S.RADIUS, math.floor(index/side)-S.RADIUS
for y=6,-5,-1 do
local q = at(p,x,y,z)
-- At most twelve visual swaps per probe (96/second globally).
-- Includes legacy mapgen leaves above the first natural ground.
if hearth.seasons then
local current = node(q)
if current then hearth.seasons.reconcile(q,current,now) end
end
if diversity then diversity.bootstrap(q,node(q)) end
local sub=botany and substrate(q)
if ground(at(q,0,-1,0)) or sub then
observe(q,now)
-- Visit an epiphyte and its support in the SAME bounded
-- column. Stopping above deadwood would freeze its clock.
if not sub or (sub.substrate~="bark" and sub.substrate~="deadwood" and sub.substrate~="litter") then break end
end
end
end
cursor=(cursor+1)%(side*side*#players)
work=work+1
end
return work
end
api.register_globalstep(S.step)
local reload_nodes = {"hearth:grass", "hearth:soil"}
if botany then reload_nodes[#reload_nodes+1]="hearth:water_source" end
if diversity then
for k in pairs(diversity.grounds) do reload_nodes[#reload_nodes+1]=PREFIX..k end
for k in pairs(diversity.rocks) do reload_nodes[#reload_nodes+1]=PREFIX..k end
if botany then
reload_nodes[#reload_nodes+1]=PREFIX.."log"
for _,species in ipairs(diversity.species_order) do reload_nodes[#reload_nodes+1]=PREFIX..species.."_log" end
end
end
for k in pairs(kinds) do reload_nodes[#reload_nodes+1]=PREFIX..k end
if hearth.seasons then
for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end
end
api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading",
nodenames=reload_nodes, run_at_every_load=true,
action=function(p)
seen[key(p)]=nil
if ground(p) then seen[key(at(p,0,1,0))]=nil end
if botany then
seen[key(at(p,0,1,0))]=nil
local component=api.get_meta(p):get_string("succ_component")
if component~="" then seen[component]=nil end
end
end})
return S
end
+1524
View File
File diff suppressed because one or more lines are too long
+301
View File
@@ -0,0 +1,301 @@
-- Loaded-only fauna: bounded work, no drops, combat, restores or catch-up.
return function(hearth, api)
local GLOBAL, LOCAL, PLAYER, RADIUS, BUDGET = 32, 6, 6, 48, 12
local kinds = {"hare", "deer", "fox"}
local species = {
hare={cap=16,radius=0.23,height=0.85,body=0.48,length=0.32},
deer={cap=10,radius=0.32,height=1.52,body=1.12,length=0.49},
fox={cap=6,radius=0.23,height=0.80,body=0.57,length=0.43},
}
local active, counts, total, serial = {}, {hare=0,deer=0,fox=0}, 0, 0
local clock, credit, recruitment, player_cursor, recruit_kind, cursor = 0, 0, 0, 0, 0, 0
local actions, births, meals, grazed, dung = 0, 0, 0, 0, 0
local directions={{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1},{0,-1},{1,-1}}
local offsets={{8,0},{-8,0},{0,8},{0,-8},{16,8},{-16,-8},{24,0},{0,24}}
local function delta(dt)
if type(dt)~="number" or dt~=dt or dt<0 or dt==math.huge then return 0 end
return math.min(dt,1)
end
local function valid(p)
if type(p)~="table" then return false end
for _, axis in ipairs({"x","y","z"}) do
local n=p[axis]
if type(n)~="number" or n~=n or math.abs(n)>30900 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)<best then closest,best=q,distance(p,q) end
end
if e.kind~="fox" then
for other in pairs(active) do
local q=other.object:get_pos()
if other.kind=="fox" and valid(q) and distance(p,q)<best then closest,best=q,distance(p,q) end
end
end
return closest
end
local function breed(e,p)
local needed=e.kind=="fox" and 2 or 3
if e.age<60 or clock<e.breed_at or e.food<needed or e.hunger>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)<best and safe(q,"hare") then prey,best=other,distance(p,q) end
end
if prey and e.hunger>=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<b.id end)
for _=1,#list do
if credit<1 then break end
cursor=cursor%#list+1
local e=list[cursor]
if active[e] and clock>=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
Executable
+17
View File
@@ -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"
}
}
Executable
+9
View File
@@ -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
}
+135 -32
View File
@@ -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);
}
+70
View File
@@ -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);
});
});
}
+264
View File
@@ -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;i<out.data.length;i+=4) if (layer.data[i+3]) out.data.set(layer.data.subarray(i,i+4),i);
}
return out;
}
export const CHARACTER_PORTRAITS = Object.freeze([
{}, {skin:'porcelain',hair:'braids',hairColor:'flax',garment:'workshirt',cloth:'sage',accent:'apron',accentColor:'linen',detail:'freckles'},
{skin:'honey',hair:'wavy',hairColor:'auburn',garment:'jerkin',cloth:'ochre',beard:'stubble',accent:'sash',accentColor:'mauve'},
{skin:'olive',face:'angular',hair:'long',hairColor:'black',garment:'coat',cloth:'slate',accent:'vest',accentColor:'rust'},
{skin:'umber',face:'weathered',hair:'cropped',hairColor:'silver',garment:'workshirt',cloth:'linen',beard:'full_beard',accent:'none'},
{skin:'ebony',face:'soft',eyes:'blue',hair:'braids',hairColor:'black',garment:'tunic',cloth:'rust',accent:'scarf',accentColor:'ochre'},
{skin:'sand',hair:'shaved',hairColor:'ash',garment:'coat',cloth:'charcoal',beard:'moustache',accent:'sash',accentColor:'teal',detail:'scar'},
{skin:'umber',hair:'none',hairColor:'chestnut',garment:'jerkin',cloth:'mauve',beard:'short_beard',accent:'vest',accentColor:'sage',detail:'freckles_scar'},
].map(p=>Object.freeze({...p})));
export function portraitSheet() {
const out={width:512,height:64,data:new Uint8Array(512*64*4)};
for (let i=0;i<out.data.length;i+=4) out.data.set([30,34,32,255],i);
CHARACTER_PORTRAITS.forEach((options,index)=> {
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<h;y++) for (let x=0;x<w;x++) {
const from=((ty*8+Math.floor(y*8/h))*64+tx*8+Math.floor(x*8/w))*4;
out.data.set(atlas.data.subarray(from,from+4),((dy+y)*out.width+index*64+dx+x)*4);
}
};
blit('face',24,3,16,18); blit('tunic',21,21,22,22);
blit('sleeve',14,22,7,18); blit('sleeve',43,22,7,18);
blit('skin',14,40,7,4); blit('skin',43,40,7,4);
blit('trousers',22,43,9,13); blit('trousers',33,43,9,13);
blit('boot',21,56,10,6); blit('boot',33,56,10,6);
});
return out;
}
+177
View File
@@ -0,0 +1,177 @@
// Local, trusted parent directories are required. No shell, links or shared-tree deletion.
import { constants, createReadStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import path from 'node:path';
import { setTimeout as retryDelay } from 'node:timers/promises';
export const MANIFEST = 'sha256-manifest.json';
export const json = value => 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);
}
+249
View File
@@ -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;i<this.faces.length;i++) {
const group=this.groups.find(g=>g.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};
}
+140
View File
@@ -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];
}
+379
View File
@@ -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; });
}
+62
View File
@@ -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;
}
+301
View File
@@ -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;
}
}
+234 -131
View File
@@ -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 <check|smoke|play|test>');
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 <hearth_smoke_token>. 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 <check|smoke|play|test|test:seeds|course|test:course|package [absolute-new-folder]|verify <absolute-package>|backup <absolute-world> <absolute-new-backup> --offline|restore <absolute-backup> <absolute-new-world> --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; });
}
+29
View File
@@ -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);
}
+355
View File
@@ -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; });
}
+1525
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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
+55
View File
@@ -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")
+478
View File
@@ -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.length); used.add(id); });
const [a,b,c] = triangle.map(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<totals.length); assert.equal(weight,1,'rigid weighting'); totals[id]+=weight;
}
assert.ok(totals.every(n => 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]))<widths.at(-1),'waist taper');
}
assert.ok(widths[0]<widths[1] && widths[1]<widths[2]);
assert.ok(Math.abs(Math.max(...balanced.bones.get('Head').weights.map(([id])=>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+4<comments.length);
const count = comments.readUInt32LE(cursor); cursor+=4;
assert.ok(count<=16);
for (let i=0;i<count;i++) {
assert.ok(cursor+4<comments.length);
const length = comments.readUInt32LE(cursor); cursor+=4+length;
assert.ok(cursor<comments.length);
}
assert.equal(cursor+1,comments.length); assert.equal(comments[cursor],1);
assert.ok(packets.slice(3).every(p=>p.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/);
});
+363
View File
@@ -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<first_plant) end end
end)
test("canonical meadow timber and all-surface gathering remain available", function()
local f=loaded(); local d=f.decos["hearth:temperate_trees"]
assert(contains(d.biomes,"hearth_temperate")); local log=false
for _, n in ipairs(d.schematic.data) do if n.name=="hearth:log" then log=true end end
assert(log)
for name in pairs(f.m.surfaces) do
assert(contains(f.decos["hearth:loose_stone"].place_on,"hearth:"..name))
assert(contains(f.decos["hearth:sticks"].place_on,"hearth:"..name))
end
end)
test("riparian food and clay require water and upland springs are limited", function()
local f=loaded()
for _, id in ipairs({"reeds", "clay_bank", "old_river_weir"}) do
local d=f.decos["hearth:"..id]; assert(d.num_spawn_by==1 and d.check_offset==-1)
assert(contains(d.spawn_by,"hearth:water_source"))
end
assert(f.decos["hearth:clay_bank"].y_max==8)
assert(f.decos["hearth:seep_springs"].y_max==150)
assert(not contains(f.decos["hearth:seep_springs"].biomes,"hearth_dry_scrub"))
end)
test("flora communities and seasonal patches resolve without new growth nodes", function()
local f=loaded()
for _, d in ipairs(f.decos) do
for _, ground in ipairs(d.place_on) do assert(f.nodes[ground],ground) end
for _, biome in ipairs(d.biomes or {}) do assert(f.biomes[biome],biome) end
if d.decoration then assert(f.nodes[d.decoration],d.decoration) end
end
assert(contains(f.decos["hearth:woodland_ferns"].biomes,"hearth_wet_forest"))
assert(not contains(f.decos["hearth:woodland_ferns"].biomes,"hearth_dry_scrub"))
assert(f.decos["hearth:spring_foraging"].decoration=="hearth:spring_greens_patch")
assert(f.decos["hearth:autumn_foraging"].decoration=="hearth:autumn_mushrooms_patch")
end)
test("world texture contract is explicit for every new node", function()
local f=loaded(); local textures={}
for name, def in pairs(f.nodes) do if not def.sentinel then
assert(def.tiles and #def.tiles>=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")
+380
View File
@@ -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)
+194
View File
@@ -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;i<im.data.length;i+=4) {
assert.ok(im.data[i+3]===0 || im.data[i+3]===255,r.filename);
if (!im.data[i+3]) assert.deepEqual([...im.data.subarray(i,i+3)],[0,0,0]);
}
assert.equal(alpha(im)===0,r.empty,r.filename);
if (r.kind==='skin') assert.equal(alpha(im),4096);
else assert.ok(alpha(im)<4096);
}
assert.equal(M.filter(r=>r.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;i<layers.length;i++) {
let changed=0, delta=0;
for (let y=0;y<64;y++) for (let x=0;x<64;x++) {
const offset=(y*64+x)*4, a=[...layers[i-1].data.subarray(offset,offset+3)], b=[...layers[i].data.subarray(offset,offset+3)];
if (a.some((n,c)=>n!==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<data.length;i+=4) if (data[i+3]) independent.set(data.subarray(i,i+4),i);
}
assert.deepEqual(im.data,independent);
});
test('old held-tool swatches are byte-exact in all bases and final characters, without global tint',()=> {
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'});
});
+216
View File
@@ -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")
+239
View File
@@ -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")
+95
View File
@@ -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")
+150
View File
@@ -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")
+746 -44
View File
@@ -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]<index["hearth:oak_leaves_winter"])
local def=nodes[record.node]
eq(def.tiles[1],record.texture); eq(def.inventory_image,record.texture); eq(def.wield_image,record.texture)
end
end
eq(botany_nodes,62)
for _, name in ipairs(hearth.diversity.plants) do assert(nodes["hearth:" .. name]) end
eq(hearth.diversity.supports_species("pine", "podzol"), true)
eq(hearth.diversity.supports_species("oak", "podzol"), false)
eq(#ores, 9)
for _, ore in ipairs(ores) do
assert(nodes[ore.ore], ore.ore)
for _, host in ipairs(ore.wherein) do assert(nodes[host], host) end
end
local decoration_names = {}
for _, deco in ipairs(decorations) do
assert(not decoration_names[deco.name], "duplicate decoration: " .. deco.name)
decoration_names[deco.name] = true
assert(not (deco.name == "hearth:iron_seams"), "legacy fake floor ores retained")
end
local n, b, d, o, c = #registrations, #biomes, #decorations, #ores, #crafts
eq(hearth.biomes.register(), hearth.biomes)
hearth.diversity.register_nodes(function() error("duplicate diversity registration") end)
hearth.botany.register_nodes(function() error("duplicate botany registration") end)
eq(#registrations, n); eq(#biomes, b); eq(#decorations, d); eq(#ores, o); eq(#crafts, c)
end)
test("new minerals have exact utility recipes and copper upgrades only the flint mining tier", function()
local function recipe(output, ingredients, craft_type)
for _, def in ipairs(crafts) do
local flat = {}
for _, row in ipairs(def.recipe) do
for _, value in ipairs(type(row) == "table" and row or {row}) do
if value ~= "" then flat[#flat + 1] = value end
end
end
if def.output == output and def.type == craft_type and table.concat(flat, ",") == table.concat(ingredients, ",") then return true end
end
return false
end
assert(recipe("hearth:copper_charge", {"hearth:copper_ore", "hearth:charcoal"}, "shapeless"))
assert(recipe("hearth:copper_pick", {"hearth:copper_ingot", "hearth:copper_ingot", "hearth:copper_ingot", "hearth:sticks", "hearth:sticks"}))
assert(recipe("hearth:window", {"hearth:quartz", "hearth:quartz", "hearth:quartz"}, "shapeless"))
assert(recipe("hearth:window", {"hearth:planks", "hearth:reeds", "hearth:planks", "hearth:planks", "hearth:reeds", "hearth:planks"}))
for _, entry in ipairs({{"quartz_vein", "quartz"}, {"copper_ore_vein", "copper_ore"}, {"coal_ore_vein", "coal"}}) do
eq(nodes["hearth:" .. entry[1]].drop, "hearth:" .. entry[2] .. " 2")
end
local flint, copper = tools["hearth:pick"].tool_capabilities, tools["hearth:copper_pick"].tool_capabilities
assert(copper.groupcaps.cracky.times[3] < flint.groupcaps.cracky.times[3])
assert(copper.groupcaps.cracky.uses > 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
+132
View File
@@ -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)
+273
View File
@@ -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")
+279
View File
@@ -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)
+192
View File
@@ -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")

Some files were not shown because too many files have changed in this diff Show More