This commit is contained in:
2026-11-05 09:57:11 -05:00
parent d34388bf2c
commit 89e392cc2e
81 changed files with 12410 additions and 1135 deletions
+43 -10
View File
@@ -42,10 +42,10 @@ Use `/inspect` while pointing at a known plant/tree within six blocks, or
Field notes include **Inspect target**; `/botany` offers a **Species article** Field notes include **Inspect target**; `/botany` offers a **Species article**
selector for each ten-row page. Harvesting and building controls are unchanged. selector for each ten-row page. Harvesting and building controls are unchanged.
The **Article** tab contains 78 cached plaintext Wikipedia introductions, labelled The **Article** tab shows available cached plaintext Wikipedia introductions, labelled
**Wikipedia excerpt (offline)**, with copyable canonical/revision/history URLs, **Wikipedia excerpt (offline)**, with copyable canonical/revision/history URLs,
contributor attribution and **CC BY-SA 4.0** licensing. The other two species have contributor attribution and **CC BY-SA 4.0** licensing. Missing snapshots have
explicit original CC0 availability notes, not counterfeit articles. **Current explicit availability notes, not counterfeit articles; cache expansion is a separate task. **Current
effects** distinguishes simulated game policy from descriptive natural history. effects** distinguishes simulated game policy from descriptive natural history.
This is not a full wiki, embedded browser, or real-world identification/foraging 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 guide. Nothing is fetched or automatically opened; inspection writes no world or
@@ -55,9 +55,12 @@ Focused isolated tests cover the reader; native visual/client integration remain
pending. Full-engine tests were not run for this change. pending. Full-engine tests were not run for this change.
The [landscape/content expansion](docs/content-expansion.md) now wires ten The [landscape/content expansion](docs/content-expansion.md) now wires ten
communities, ten tree species, an 80-species botanical catalogue (eight categories communities, thirteen tree species, a 100-species botanical catalogue and ordered
of ten) and ordered geology into the seasonal ecosystem. The trees are oak, geology into the seasonal ecosystem. Its eight ecological groups are deliberately
birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce. All ten logs uneven: 13 trees, 14 shrubs, 10 grasses, 22 herbs, 11 ferns/mosses, 10 fungi,
10 aquatic taxa and 10 lichens. The additions reflect ecological roles, not a
ten-per-category quota. The trees are oak, birch, pine, willow, alder, beech,
aspen, rowan, hazel, spruce, crab apple, wild cherry and field maple. All thirteen logs
fuel hearths for 180 seconds and craft four matching planks; buildings/planks 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 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 a three-quartz alternate window recipe are integrated; native validation of the
@@ -230,14 +233,19 @@ recovery playtest or public-service disaster recovery certification. See the
- **I** opens crafting and field notes; `/guide` reopens help. - **I** opens crafting and field notes; `/guide` reopens help.
- **Ecosystem survey** in the field journal, or `/ecosystem`, observes one small - **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 loaded habitat patch and reports the actual global active counts for all 23 animal identities. It does not
grow plants, spawn animals, force-load terrain or change world state. grow plants, spawn animals, force-load terrain or change world state.
- **Botanical guide** in the journal/survey, or `/botany [search]`, browses the - **Botanical guide** in the journal/survey, or `/botany [search]`, browses the
actual catalogue, ten species per page. Search is literal, escaped and limited actual catalogue, ten species per page. Search is literal, escaped and limited
to 48 characters; it includes common/scientific names, category, habitat, to 48 characters; it includes common/scientific names, category, habitat,
seasons and ecological roles. Toxicity flags are game labels, **not real-world seasons and ecological roles. Toxicity flags are game labels, **not real-world
food-safety advice**; unflagged does not mean edible. Missing catalogue APIs 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). display an unavailable message. See the [100-taxon category list and role limits](docs/botany.md).
- **Fauna guide** in the journal/botanical guide/survey, or `/fauna [search]`,
browses all 23 animal identities, source URLs and lifecycle status. Field-level
source-reported ranges, unverified fallback policies and **UNKNOWN** are distinct;
identity citations do not establish numeric timing. The existing plant article
reader is unchanged. No runtime network fetch or ecological mutation occurs.
- Gather flint, sticks, berries and reeds. Make an axe to harvest timber; build - Gather flint, sticks, berries and reeds. Make an axe to harvest timber; build
supported walls/roof and fuel a stone hearth with sticks/logs. supported walls/roof and fuel a stone hearth with sticks/logs.
- Fallen logs are ordinary sideways full cubes. An axe yields **one canonical - Fallen logs are ordinary sideways full cubes. An axe yields **one canonical
@@ -274,14 +282,39 @@ The [living ecosystem model](docs/ecosystems.md) is an original implementation
inspired by Dynamic Life's ecological ideas, not copied datapack code or assets. inspired by Dynamic Life's ecological ideas, not copied datapack code or assets.
Light, shade, moisture and competition shape meadow grass, wildflowers, ferns and Light, shade, moisture and competition shape meadow grass, wildflowers, ferns and
slow tree succession. Litter, fungi and dung recycle finite nutrients; grazing 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, debits finite forage and wildlife links habitat to the food web. Calendar organism
loaded-only, pauses across unloading/restart and never replays an offline backlog. ages include the full unloaded interval; ecological work remains bounded and
loaded-only, never replaying an offline backlog. Named trees use species-profile
calendar endpoints, typically years to decades; conservative fallback thresholds
are explicitly not researched timings. Fungi do not accelerate tree maturation.
Claims, placed plants, crops and construction are excluded from autonomous changes; Claims, placed plants, crops and construction are excluded from autonomous changes;
existing crop and planted-sapling clocks keep their separate rules. Survey counts 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 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 explicitly unknown. Wildlife totals describe all currently active animals, not a
local census or saved population. There is no compulsory ecological quest. local census or saved population. There is no compulsory ecological quest.
Content installs the pure lifecycle namespace before biological factories, and
publishes wildlife's exact returned fauna catalogue. Human botanical harvest and
animal frugivory share `botany_ready_at`: after a successful crop debit it rests
one mean real year (**365.2425 days**), then requires its fruit season, not a
24-hour refill. One live pollination credit can add only **one** berry to the
existing two-berry crop; empty or cooling bushes never manufacture fruit. Human
inventory rejection restores fruit and credit; partial delivery spends both once,
and reservations precede inventory callbacks to prevent reentrant duplication.
All 23 animal identities register fixed, originally authored skeletal B3D assets
with 100 frames and five clips (idle, locomotion, feeding, flight/swim and rest)
at 24 fps. Registration/animation contracts are not native visual acceptance.
Established recruitment is immigration, **not an adult birth**. Every incomplete
profile disables reproduction by default; pairing, pregnancy, nesting, egg/larval
development, caste/colony reproduction and delayed implantation remain future
work. Unknown lives and source longevity averages never imply automatic death.
No offline meals, dung, births, forced map loading or missed-season replay occur.
See [calendar evidence and transaction contract](docs/lifecycles.md),
[fauna behavior and limitations](docs/fauna.md) and [ecosystem policies](docs/ecosystems.md).
This integration was focused-test-only: no engine launch, world change, version
bump, wiki-cache edit or claim of complete true-life biology.
The [moonlit botanical art direction](docs/art-direction.md) pairs original pixel The [moonlit botanical art direction](docs/art-direction.md) pairs original pixel
materials with the eom.dev Mocha interface: base `#1e1e2e`, mantle `#181825`, raised materials with the eom.dev Mocha interface: base `#1e1e2e`, mantle `#181825`, raised
`#292940`, border `#3c3d56`, pale `#cdd6f4` text and `#a6adc8` annotations. Mauve `#292940`, border `#3c3d56`, pale `#cdd6f4` text and `#a6adc8` annotations. Mauve
+196
View File
@@ -1,7 +1,203 @@
# The Last Hearth — woodland travelers and botanical pixel catalogue # The Last Hearth — woodland travelers and botanical pixel catalogue
## Authoritative fauna-23 / catalogue-100 art contract (2026-11-03)
This section supersedes earlier catalogue counts and package totals, not the
character-layer or legacy OBJ contracts. Original MIT procedural drawings and
meshes only: no downloaded assets, fonts, libraries or engine launch. Runtime
wildlife integration remains the Lua owner's responsibility; these generators
do not register monsters, change spawning, collision boxes or gameplay.
### Exact animated fauna handoff
[The fauna generator](../scripts/fauna-art.mjs) exports frozen `FAUNA_NAMES`,
`FAUNA_ANIMATIONS`, `FAUNA_ART_MANIFEST`, `FAUNA_MATERIALS` and pure methods
`faunaB3D(id)`, `faunaTexture(id)`, `faunaBounds(id)`, `faunaPortraitSheet(frame=9)`.
Unknown IDs and frames fail closed. Every result is deterministic and fresh.
Exactly these **23** IDs, in manifest/portrait row-major order:
hare, deer, fox, rabbit, red_squirrel, bank_vole, wood_mouse, hedgehog, badger,
roe_deer, stoat, weasel, otter, robin, blackbird, blue_tit, barn_owl, mallard,
common_frog, smooth_newt, brown_trout, bumblebee, earthworm.
Each has **hearth_fauna_<id>.b3d** and
**hearth_fauna_<id>.png**; the latter is an opaque **64×64** nearest-neighbor
semantic atlas. These 23 PNGs are deliberately **not** worldArt tiles. Old
hearth_hare.obj, hearth_fox.obj, hearth_deer.obj and their textures remain byte
compatible and generated; runtime can migrate to the new names independently.
Every model has one root, one textured mesh, outward-wound flat-normal solids,
half-texel inset UVs, normalized rigid bone weights, no scale animation and
**100 real skeletal frames at 24 FPS**. B3D key indices 1–100 map to Luanti
frames 0–99. `visual_size={x=1,y=1}`, **10 model units/node**, feet/lowest bind
surface at **Y=0**, forward +Z. `faunaBounds(id).min/max` are authoritative
**bind-mesh node bounds**, not guessed collision or conservative animated bounds.
They are also serialized per animal; do not infer scale from portrait cells.
Bind bounds in nodes below are rounded to five decimals for review; the exported
values and generated manifest retain full precision. All minimum Y values are 0.
| ID | Minimum X, Y, Z | Maximum X, Y, Z |
| --- | --- | --- |
| hare | -0.13750, 0, -0.34150 | 0.13750, 0.81400, 0.37004 |
| deer | -0.31625, 0, -0.68300 | 0.31625, 1.59470, 0.65081 |
| fox | -0.15000, 0, -0.86240 | 0.15000, 0.68560, 0.50280 |
| rabbit | -0.15400, 0, -0.25954 | 0.15400, 0.55090, 0.31371 |
| red_squirrel | -0.07500, 0, -0.46744 | 0.07500, 0.59240, 0.24266 |
| bank_vole | -0.07000, 0, -0.17039 | 0.07000, 0.19600, 0.17507 |
| wood_mouse | -0.06000, 0, -0.31045 | 0.06000, 0.23860, 0.16250 |
| hedgehog | -0.11000, 0, -0.21173 | 0.11000, 0.25000, 0.23755 |
| badger | -0.19000, 0, -0.45078 | 0.19000, 0.49240, 0.47589 |
| roe_deer | -0.22375, 0, -0.49176 | 0.22375, 1.22850, 0.48443 |
| stoat | -0.06000, 0, -0.45146 | 0.06000, 0.24620, 0.26954 |
| weasel | -0.04250, 0, -0.26846 | 0.04250, 0.18080, 0.19111 |
| otter | -0.12500, 0, -0.70711 | 0.12500, 0.38840, 0.39778 |
| robin | -0.16460, 0, -0.21895 | 0.16460, 0.37210, 0.17388 |
| blackbird | -0.18440, 0, -0.30653 | 0.18440, 0.41750, 0.20140 |
| blue_tit | -0.12500, 0, -0.17516 | 0.12500, 0.31670, 0.13705 |
| barn_owl | -0.28340, 0, -0.27369 | 0.28340, 0.64340, 0.27500 |
| mallard | -0.27350, 0, -0.43790 | 0.27350, 0.47040, 0.25550 |
| common_frog | -0.09900, 0, -0.15026 | 0.09900, 0.14750, 0.22272 |
| smooth_newt | -0.04000, 0, -0.34231 | 0.04000, 0.10140, 0.16295 |
| brown_trout | -0.10450, 0, -0.35520 | 0.10450, 0.30450, 0.24000 |
| bumblebee | -0.14400, 0, -0.06500 | 0.14400, 0.12250, 0.10640 |
| earthworm | -0.02000, 0, -0.19127 | 0.02000, 0.04000, 0.19127 |
| Clip | Inclusive engine frames | Authored action |
| --- | --- | --- |
| idle | 0–19 | breathing, looking, ear/tail attention; segmented worm motion |
| locomotion | 20–39 | linked upper/lower limb strides, tail follow-through; worm joint wave |
| feed | 40–59 | head/neck dip and bird peck; small head/segment sampling motion |
| flight_swim | 60–79 | mirrored wing flaps with secondary flight feathers; aquatic tail beats/fins |
| rest | 80–99 | settled head/limbs, low-amplitude breathing and tail/segment movement |
All five ranges loop with matching endpoint poses. No engine-side fake motion
is needed. The shared `flight_swim` range on non-flying terrestrial mammals
is an **alert looking reserve**, not invented flight/swimming. The rest range
is a settled standing/perched posture, **not a speculative lying-down or curled
skeletal deformation**. Frog/newt limbs paddle; fish have a hinged caudal fan,
paired pectorals and dorsal fin; bees have six legs and thoracic wings; the
earthworm has twelve serially parented body joints. No bones are driven merely
because another species has similarly named appendages.
Hare ears and hind feet, compact rabbit, squirrel's raised brush, vole's short
tail versus mouse's long tail, hedgehog spine blocks, broad badger, tall antlered
deer versus short roe antlers, slender stoat/weasel, low otter with cream muzzle,
robin breast, blackbird bill, blue tit cap, owl facial discs, duck neck/webbed
feet/speculum and amphibian low profiles are authored species landmarks, not a
single body recoloured. The restrained coat/cream/earth/mineral palette matches
the Minecraft/Vintage Story-like block world without copying either game's art.
The sixteen **16×16** material patches occupy four row-major columns:
| Row | 0 | 1 | 2 | 3 |
| --- | --- | --- | --- | --- |
| 0 | coat | belly | face | ear |
| 1 | foot | tail | wing | eye |
| 2 | nose | stripe | feather | beak |
| 3 | fin | spot | horn | membrane |
Badger face stripes, cream otter underside, stoat black tail tip, fox cream tip,
bee bands, trout spots, wing bars and feather veins are semantic markings, not
whole-model tints. All atlas texels have alpha 255 (solid low-poly appendages).
`generateAssets()` writes **fauna-art.json** at package root: format 1,
`animations`, `animals` (ID, exact filenames, dimensions, units, visual size,
bind bounds, posture, clips, MIT provenance), and `portraitSheet` metadata.
Every output uses the existing guarded staging writer and package hash inventory.
The portrait path inside staging is
**mods/hearth/textures/hearth_fauna_contact_sheet.png**: **512×768**, four
columns of 128px cells, 23 animals plus one paper cell, frame 9. It is an actual
CPU triangle rasterization of skinned geometry with atlas sampling and flat
lighting, **not a texture swatch, font illustration or engine screenshot**.
`faunaPortraitSheet(65)` previews the same geometry's flight/swim pose. Each cell
fits its own posed bounds, so cells are an identity review, not relative scale.
### Catalogue 100 and three additional cube-tree material families
The JavaScript art catalogue now matches all **100 exact IDs and Latin names**
in the read-only Lua BOTANY catalogue. Every taxon has active and dormant 32px
sprites: **200** provenance records in **botany-art.json**, each including
`botanicalLatin`, group, morphology, state, filename, dimensions and MIT author.
Group counts: trees 13, shrubs 14, grasses 10, herbs 22, ferns_mosses 11,
fungi 10, aquatic 10, lichens 10. The twenty additions are:
- crab_apple — Malus sylvestris; wild_cherry — Prunus avium;
field_maple — Acer campestre.
- honeysuckle — Lonicera periclymenum; ivy — Hedera helix;
gorse — Ulex europaeus; broom — Cytisus scoparius.
- dandelion — Taraxacum officinale; daisy — Bellis perennis;
red_clover — Trifolium pratense; birdsfoot_trefoil — Lotus corniculatus;
ribwort_plantain — Plantago lanceolata; selfheal — Prunella vulgaris;
knapweed — Centaurea nigra; oxeye_daisy — Leucanthemum vulgare;
field_scabious — Knautia arvensis; teasel — Dipsacus fullonum;
cow_parsley — Anthriscus sylvestris; garlic_mustard — Alliaria petiolata.
- holly_fern — Polystichum setiferum (catalogue contract ID; soft shield-fern).
Original 80 drawings and approved leaf PNG bytes are retained. New silhouettes
have distinct flowerheads, twining honey tubes, climbing ivy rootlets, gorse
spines, broom switches, ligulate dandelion rays, trifoliate clovers, plantain's
anther ring, teasel cones, compound parsley umbels, brassica siliques and forked
fern pinnae. Dormant specimens lose flowers and fruit, keeping species-specific
dry structure. Existing runtime pixel-wind plant waving is unchanged.
All thirteen cube-tree IDs: oak, birch, pine, willow, alder, beech, aspen, rowan,
hazel, spruce, crab_apple, wild_cherry, field_maple. Each has log_top, log_side,
leaves and planks. Deciduous species also have leaves_spring, leaves_autumn and
leaves_winter. Cherry has horizontal lenticels; crab apple fine fissures; maple
interrupted cork diamonds. **46** compatibility bark/leaf atlases cover all
species/seasons, including underscore IDs parsed by the anchored suffix contract
`/_atlas(?:_(spring|autumn|winter))?$/`, never positional underscore splitting.
No new unused irregular-tree OBJ meshes are generated; the 29 legacy flora OBJ
exports and the 23 old species-stage meshes remain frozen.
The world contact sheet now has **421 tiles**, **512×864**, **16 columns**,
sorted filenames as the row-major legend, height **ceil(count/16)*32**. The
bounded **1–512** tile guard remains intact. Its 61 additions are 40 botanical
sprites plus 21 cube-tree materials/seasonal leaves. Fauna never consumes those
cells. The two-swatch minimal fixture closes at **675 PNGs and 63 models**:
**37 OBJ + 26 B3D** (3 players and 23 fauna), with exact manifest-derived
inventories and package-integrity verification, not loose minimum-count checks.
Validation is Node-only: independent B3D chunk parsing, winding, normals, UVs,
weights, every keyed pose, forward-kinematic displacement for all five clips,
loop seams, species landmarks, encoded texture pixels, botanical catalogue
equality, unique silhouettes, approved old leaf hashes, package bytes and output
link refusal. No engine render, performance, live animation or spawn claim.
Final focused run: **60 passed, 0 failed, 0 cancelled, 0 skipped**, Node
**24.22.0**, **160.80 seconds**, exit 0 and empty stderr. Suites: fauna art,
world art, flora art, existing assets and character art. Both actual skinned
mesh sheets (idle frame 9 and flight/swim frame 65) and the 421-tile world sheet
were visually inspected outside the source tree. No manual binary files added.
CodeGuard boundaries: no credentials or certificates introduced; SHA256 is used
only for deterministic integrity checks; positive ID/frame validation, bounded
allocations and existing symlink/junction-safe staging writes are retained.
Optional audio codec decode/regeneration was not configured; existing Ogg
framing/CRC/hash checks passed. Directory-junction refusal passed; the existing
file-symlink subcase lacked Windows symlink privilege.
## Authoritative character art contract (2026-11-03) ## Authoritative character art contract (2026-11-03)
**Revision note (2026-11-04):** the 2026-11-03 option counts and rig/mesh-size
figures in this section are superseded by the expanded character catalogue below.
### Expanded woodland traveler update (2026-11-04)
The current public body allowlist is `compact`, `lean`, `balanced`, `broad`,
`sturdy`; all remain cosmetic, equal-height silhouettes using the same rig and
collision. Added original face linework (`lined`), hair styles (`curly`,
`topknot`, `undercut`), facial hair (`goatee`, `braided_beard`), garments
(`hooded_cloak`, `travel_coat`, `wool_mantle`), accents (`satchel`, `tool_belt`,
`pauldrons`, `hood`), and a `birthmark`. The current generated manifest is 258
distinct validated filenames (including transparent `none` variants), and the
expanded player B3D contains 384 vertices and 192 triangles. The model adds an
original draped shoulder mantle, belt, back satchel, and projecting nose/ears;
no player mechanics or tool attachment names are changed. Current Lua and art
builders must use the exact texture order implemented by `C.preview()` and
`characterLayers()`; hood accents intentionally overlay hair. See
[character-creator.md](character-creator.md) for the current option list.
This section supersedes earlier **player** appearance and package-total statements This section supersedes earlier **player** appearance and package-total statements
below; botanical art itself is unchanged. Character work owns only below; botanical art itself is unchanged. Character work owns only
[scripts/media.mjs](../scripts/media.mjs), [scripts/media.mjs](../scripts/media.mjs),
+63 -15
View File
@@ -1,8 +1,8 @@
# Botany: 80 temperate taxa and integration contract # Botany: 100 temperate taxa and calendar integration
## Scope and evidence ## 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. The catalogue in [mods/hearth/botany.lua](../mods/hearth/botany.lua) has **100 distinct real taxa** in eight disjoint groups: **13 trees, 14 shrubs, 10 grasses, 22 herbs, 11 ferns/mosses, 10 fungi, 10 aquatic taxa and 10 lichens**. It represents a north-west/central European temperate landscape mosaic, not one plant community or a claim that every species is native everywhere. Hazel is counted among trees, heather among herbs, and lichens separately from other fungi. Existing generic gathering patches are not part of this count.
Taxonomic/reference authorities for maintaining these names and broad habitats: Taxonomic/reference authorities for maintaining these names and broad habitats:
@@ -19,11 +19,38 @@ The numeric light/moisture/pH/depth windows below are **explicit game approximat
## Exact ID manifest (art, trees and ecology handoff) ## 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. `id` is the canonical catalogue key. Every non-tree node is exactly `hearth:<id>`. Every tree's initial node is `hearth:<id>_seedling`; diversity/resources own tree stages and materials. Generic `berry_bush`, `fern`, `wildflowers` and `meadow_grass` remain outside the 100.
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. 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. Art contract for **all 100**: `hearth_botany_<id>.png`. Botany registers **79** non-tree nodes (87 non-trees minus eight legacy understory IDs). Diversity/terrain own all thirteen tree stages, bark/end-grain cubes, leaves and planks. No random tint or seasonal mesh substitutes for species materials. Seasonal copying preserves bark faces, `facedir`, callbacks and drops. Assets and native-engine visual validation remain the art/main integration owners' responsibility.
### Exact twenty additions (counts above include these)
| ID | Latin identity from lifecycle contract | Group / ecological emphasis |
| --- | --- | --- |
| dandelion | Taraxacum officinale | herbs; open meadow, spring–autumn flowers |
| daisy | Bellis perennis | herbs; short open grassland flowers |
| red_clover | Trifolium pratense | herbs; summer/autumn flowers, bounded nitrogen fixation |
| birdsfoot_trefoil | Lotus corniculatus | herbs; drier meadow, flowers, bounded nitrogen fixation |
| ribwort_plantain | Plantago lanceolata | herbs; meadow browse, wind-pollinated, no nectar aura |
| selfheal | Prunella vulgaris | herbs; meadow summer/autumn flowers |
| knapweed | Centaurea nigra | herbs; meadow late flowers and cover |
| oxeye_daisy | Leucanthemum vulgare | herbs; summer meadow flowers |
| field_scabious | Knautia arvensis | herbs; drier base-rich meadow flowers |
| teasel | Dipsacus fullonum | herbs; summer flowers and seedhead cover; biennial fallback |
| cow_parsley | Anthriscus sylvestris | herbs; hedge margins, spring/summer flowers |
| garlic_mustard | Alliaria petiolata | herbs; shaded edges, second-season spring flowering policy |
| honeysuckle | Lonicera periclymenum | shrubs; hedge/woodland edge flowers; no food |
| ivy | Hedera helix | shrubs; evergreen cover, autumn flowers, spring seed window; no food |
| gorse | Ulex europaeus | shrubs; open acid scrub, flowers including winter, bounded nitrogen fixation |
| broom | Cytisus scoparius | shrubs; open acid scrub, spring/summer flowers, bounded nitrogen fixation |
| crab_apple | Malus sylvestris | trees; spring flowers, broad round crown |
| wild_cherry | Prunus avium | trees; spring flowers, taller horizontal branch tiers |
| field_maple | Acer campestre | trees; spring flowers, broad lobed crown |
| holly_fern | Polystichum setiferum | ferns_mosses; evergreen damp shade, commonly soft shield-fern |
The historical 80-entry manifest below lists the retained baseline; append these twenty, not twenty per group. No addition grants human food or a new berry output. Nitrogen-fixing *Lotus corniculatus* is birdsfoot trefoil, not an aquatic lotus. Climbing ivy/honeysuckle use stand sprites rather than simulated climbing geometry.
### trees (10; P/T) ### trees (10; P/T)
@@ -152,7 +179,7 @@ Loading the file returns **`factory(hearth, api) -> B`**. The factory has no reg
### Catalogue/query APIs ### Catalogue/query APIs
- `B.groups`, `B.group_order`: ordered arrays `trees, shrubs, grasses, herbs, ferns_mosses, fungi, aquatic, lichens`. - `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.catalogue`: ordered 100-record snapshot, group order then retained and added 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`. - `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. - `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. - Every returned record is a detached copy. Editing a snapshot cannot change internal policy.
@@ -178,7 +205,7 @@ Loading the file returns **`factory(hearth, api) -> B`**. The factory has no reg
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. 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. `choose` considers all 100 records in a deterministic position-hashed order, skipping trees unless `h.include_trees=true`. It returns the actual bare node kind plus a detached descriptor. The caller independently verifies loaded empty unmodified destination/support and authorization immediately before any write. No plant replaces another, spreads water or creates nutrients through this query.
### Interactions and finite effects ### Interactions and finite effects
@@ -186,6 +213,7 @@ Surface depth ranges/anchors are intentionally coarse: even floating duckweed us
- `B.interactions(value) -> tag-keyed descriptor copies` with a `description`, and where applicable `cap` or `enabled=false`. - `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.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.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.seeding(value, season) -> boolean`: qualitative seed-season window; succession additionally requires calendar maturity. This is not a physiological reproduction authorization or a replay queue.
- `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.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.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.resin(value) -> boolean`: only the catalogue's pine/spruce/juniper resin tag. It grants no item or extraction callback.
@@ -196,7 +224,7 @@ Surface depth ranges/anchors are intentionally coarse: even floating duckweed us
### Registration and harvest ### 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. `B.register_nodes(register_node)` accepts the content registrar taking **bare names**. It is idempotent and skips existing IDs, all thirteen trees and already registered definitions. It registers **79** nodes in an empty registry, no recipes/aliases/items/tree resources. Main loads the factory after lifecycle/seasons and calls 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. 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.
@@ -212,30 +240,50 @@ The factory may precede construction publication, but harvest cannot. Time uses
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. 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 ## Calendar integration and bounds (version 1)
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. Main must assign the pure namespace `hearth.lifecycle` **before** botany/content. This change does not edit bootstrap files owned by main. See [the exact lifecycle API and evidence](lifecycles.md). Succession consumes `plant(id)` and `phase(profile, 0, age_seconds)` with conservative upper-endpoint thresholds, retaining field-level evidence/fallback labels. Its root anchors, not the loaded activity cache, determine age. One real day is 86,400 seconds; one mean year is 365.2425 days. No ten-minute years, accelerated stage counters or nutrient age multipliers remain for catalogue plants/trees.
The existing scheduler still performs eight column probes/second, radius 12, twelve sampled heights, at most 96 seasonal swaps and one globally reserved full tree-plan operation per wall-clock second. The 8,192-entry continuity cache and 1,200-second gap still bound **loaded work**, stress and material decomposition; they no longer truncate biological age. Empty-gap colonization remains a coarse 90/180-active-second recruitment opportunity (woody gaps after 7,200), not a claim that a real seed germinates in minutes. New recruits start calendar age zero and must wait their full profile thresholds before young/mature transitions and propagation. Reconciliation never emerges terrain, scans the whole world or replays missed seasons.
### Root persistence and migration
- `succ_birth_at`: validated UTC epoch anchor; for a natural recruit this is actual recruitment time. `succ_lifecycle_version=1` and `succ_lifecycle_policy=calendar_v1_conservative_max` label this format and threshold policy.
- `succ_initial_age_seconds`: separately recorded **inferred establishment age**, zero for `natural_seed`. It avoids inventing negative/pre-epoch birthdays for ancient-looking generated vegetation. Biological age is initial age plus the entire difference between current epoch and birth anchor.
- `succ_lifecycle_origin`: `natural_seed`, `legacy_established` or `worldgen_established`. Legacy `succ_age` is retained unchanged, never multiplied or subtracted into a birthday. Migration anchors once at NOW; stage inference is a documented assumption, not historical observation. Seedlings infer their profile emergence threshold, young roots the juvenile threshold, mature roots and un-staged botanical stands their known maturity threshold. Unknown maturity infers zero, not a fabricated age.
- `succ_lifecycle_profile`, `succ_lifecycle_seed`: exact identity and persistent deterministic position seed. Version 1 intentionally uses profile maxima, not sampled thresholds; the stored seed reserves reproducibility without claiming a measured age distribution.
- `succ_last_observed_at`: durable high-water timestamp. Clock rollback pauses reconciliation until the clock catches up; it never resets birth or subtracts age. Malformed timestamps, seeds, profile identity or unknown versions fail closed without migration/reroll. Stage swaps preserve all root anchors. Components reference the root and do not age separately.
- `succ_seed_epoch`: next allowed present-time seeding opportunity, advanced **before** one attempt (trees one mean year, understory 30 days). Missed opportunities are not queued. Current seed season, maturity, habitat, loaded air and protection still apply. Unknown maturity does not authorize propagation. `succ_litter_epoch` similarly bounds mature litter attempts to 30 days.
Known unmodified botanical stands and soil-supported worldgen log roots adopt `worldgen_established`; they are not converted to seed sprites, assigned fake historical birthdays, expanded or killed on load. Existing mapgen canopies remain unmanaged timber. Species/generic log roots retain their node and mature-stage provenance. Every adoption checks source/support ownership, protection and markers. Load callbacks only mark/invalidate continuity; first bounded root observations initialize calendar anchors.
Winter/cold pauses visible growth transitions, seeding and loaded stress, **not calendar age**. On a later favorable visit an overdue plant may advance one stage; no historical season integration is claimed. Biennial root/rosette policy persists across winter, not instant flowering or death. All lifespan endpoints are informational; there is **no automatic age-based plant death**, including unknown-lived fungi/lichens. Historical growing seasons, seed banks, annual stem death and fungal fruitbody turnover need future species-specific models. Unfavorable habitat records stress but does not kill a calendar-managed organism using the former ten-minute threshold.
`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. `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. Material decomposition (generic dung/litter/deadwood/fungi) retains bounded loaded accounting; these are resource-cycle approximations, not living-organism years. Botanical fungi do not independently mint litter nutrients. Saprotrophs advance only an existing natural authorized substrate clock by at most 60 seconds per attempt; consumed material pays once through the 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. All nitrogen-fixer tags use a persisted two-unit **soil support** budget (`succ_fixed_nitrogen`) shared across restart/recolonization; additions require at least 90 calendar days and stop at local fertility four. New gorse, broom, red clover and birdsfoot trefoil use the same cap. Root fungi may transfer/debit at most one existing soil unit when an independently age-eligible seedling grows; they **never reduce calendar thresholds**. Failed growth debits nothing. Parasite/allelopathy remain disabled descriptors. Pollination is habitat, not a berry multiplier; harvest remains at most two berries per real-day cooldown.
### Owned full-block tree lifecycle ### 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. `D.tree_plan(species, stage)` supplies fresh full-block plans for all thirteen 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. 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. Automatic senescence/collapse from age is disabled. The owned-component cleanup mechanism remains for explicit root removal; it cannot clear unowned mapgen/player timber. Any protected/marked/foreign component blocks cleanup. Natural epiphytes losing an owned substrate participate in the same bounded preflight; 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. `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 ## 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. [tests/botany_spec.lua](../tests/botany_spec.lua) checks all 100 positive niche vectors and dimension rejections, exact uneven group counts, lifecycle Latin identities, legacy mappings, seasonal/host/water distinctions, registration ownership, finite interactions and denied/full/partial/reentrant harvest. Calendar-managed seedlings/juveniles do not supply mature flower habitat: biennial second-season policy needs both maturity and the current flower season.
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. Focused suites cover all 100 real selector outputs with controlled niche vectors, actual water/wood/pH sampling, calendar migration and rollback, no guessed mortality, bounded material effects, atomic block plans and cube-safe seasons. Require all named successes, final suite markers and empty stderr; Fengari exit zero alone can hide failures. Source is read from disk once into an in-memory loader to avoid SMB repeated-read overhead. Native-engine/asset/bootstrap integration and empirical gameplay remain main-owner verification work; controlled habitat vectors are not real-world distribution evidence.
Calendar expansion validation: **178 named passes**, empty stderr, all six final
markers: botany 38, diversity 15, biomes 26, seasons 16, ecology 9, succession 74.
Only the requested owned files were edited. The separately owned lifecycle suite
and schema/content/art/bootstrap integration are not included in this result.
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. 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.
+12 -9
View File
@@ -8,18 +8,19 @@ forms retain priority.
## A quiet traveler, not a costume catalogue ## A quiet traveler, not a costume catalogue
All choices are inclusive and independent, with no gender, identity or stat All choices are inclusive and independent, with no gender, identity or stat
fields. Compact, balanced and broad silhouettes have identical mechanics, fields. Compact, lean, balanced, broad and sturdy silhouettes have identical
height, camera, collision, reach, movement and attachments. Natural hair, muted mechanics, height, camera, collision, reach, movement and held-tool anchors.
mineral cloth, undyed linen and worn leather fit the woodland world. Hair, layered field clothes, cloaks, belt gear and packs use original low-poly
geometry with hand-drawn, muted woodland pixel art.
Four tabs keep the 14 × 11 form manageable: Four tabs keep the 14 × 11 form manageable:
| Tab | Choices | | Tab | Choices |
| --- | --- | | --- | --- |
| Face | 3 body shapes, 6 skin tones, 4 face-line patterns, 5 eye colors | | Face | 5 body silhouettes, 6 skin tones, 5 face-line patterns, 5 eye colors |
| Hair | 7 styles, 6 shared hair/facial-hair colors, 5 facial-hair styles | | Hair | 10 styles, 6 shared hair/facial-hair colors, 7 facial-hair styles |
| Clothing | 4 garments, 8 cloth colors, 4 trousers, 3 boot leathers | | Clothing | 7 garments, 8 cloth colors, 4 trousers, 3 boot leathers |
| Finishing | 5 accents, 8 accent colors, 4 face-detail patterns | | Finishing | 9 accents, 8 accent colors, 5 face-detail patterns |
Dropdowns use readable names, not gender categories or free-form colors. Cloth 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. and accent palettes are sage, teal, slate, mauve, ochre, rust, linen and charcoal.
@@ -64,8 +65,10 @@ 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, parser errors do not log player payloads. There is no executable deserialization,
custom path, uploaded texture or free-form hexadecimal color. Every mesh and 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, layer filename is built solely from fixed IDs. Compositing order is skin, face,
eyes, garment, trousers, boots, accent, hair, beard, detail: clothing must stay eyes, garment, trousers, boots, hair, beard, accent, detail; accents layer above
below long hair. Face overlays are skin-neutral; there is no global color tint. hair so a selected hood frames and covers it. The model includes an original
draped mantle, belt, pack, projecting nose and ears. Face overlays are
skin-neutral; there is no global color tint.
The held-tool material remains the existing compatibility texture. The held-tool material remains the existing compatibility texture.
Apply serializes and checks the roundtrip, then writes metadata **before** Apply serializes and checks the roundtrip, then writes metadata **before**
+188 -53
View File
@@ -13,14 +13,16 @@ Sunny, moderately grassy sites support wildflowers; dense grass competes with
flowers. Moist shaded sites support ferns. Trees reduce local light and retain flowers. Moist shaded sites support ferns. Trees reduce local light and retain
moisture; prolonged shade kills sun-loving ground cover. Rivers and biome humidity moisture; prolonged shade kills sun-loving ground cover. Rivers and biome humidity
also influence moisture. Established sparse meadow can recruit a tree seedling; also influence moisture. Established sparse meadow can recruit a tree seedling;
seedlings become young trees, then mature seed-bearing trees and finally deadwood. seedlings become young trees, then mature seed-bearing trees. Lifespan endpoints
never automatically kill calendar plants or turn them into deadwood.
Living plants disperse to one adjacent suitable gap per eligible attempt. Mature Living plants disperse to one adjacent suitable gap per eligible attempt. Mature
trees shed litter and disperse seedlings. Competition and occupation reject seeds; trees shed litter and disperse seedlings. Competition and occupation reject seeds;
seedlings never replace flowers, crops, another tree, or even existing meadow grass. seedlings never replace flowers, crops, another tree, or even existing meadow grass.
Wildlife can consume active natural grass, ferns, clover, sedge and nettles and 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 leave dung. Dormant winter plants cannot supply browse. Grazing debits one partial
but imposes a five-minute recolonization cooldown. Dung, litter and deadwood canopy ration without removing the perennial; the shared per-root browse receipt
rests seven real days. No stubble rendering is installed by this change. Dung, litter and deadwood
decompose; moist, shaded organic patches seed fungi. Decomposition releases finite decompose; moist, shaded organic patches seed fungi. Decomposition releases finite
nutrients into the supporting soil (capped at eight). Fertile soil recruits ground nutrients into the supporting soil (capped at eight). Fertile soil recruits ground
cover sooner and successful establishment consumes one nutrient. Fungi are cover sooner and successful establishment consumes one nutrient. Fungi are
@@ -47,7 +49,7 @@ identity, respects external protection, and uses `swap_node` to retain owner/pla
markers, natural flags, age, metadata inventories and timers. It does not convert markers, natural flags, age, metadata inventories and timers. It does not convert
other timber, rewrite surrounding nodes, or regenerate existing terrain. other timber, rewrite surrounding nodes, or regenerate existing terrain.
### Active-time scales (minimums, not wall-clock promises) ### Calendar biology versus bounded loaded work
| Process | Observed active time | | Process | Observed active time |
| --- | --- | | --- | --- |
@@ -55,17 +57,22 @@ other timber, rewrite surrounding nodes, or regenerate existing terrain.
| Grass/flower/fern dispersal eligibility | 15 minutes; attempt every third evaluation | | Grass/flower/fern dispersal eligibility | 15 minutes; attempt every third evaluation |
| Severe niche stress to litter/deadwood | 10 minutes | | Severe niche stress to litter/deadwood | 10 minutes |
| Old meadow recruits a seedling | 2 hours; 4 in dense unfertilized grass, only suitable spatial niches | | Old meadow recruits a seedling | 2 hours; 4 in dense unfertilized grass, only suitable spatial niches |
| Seedling to young tree | 6 hours | | Named seedling to young tree | Absolute juvenile-age endpoint from its lifecycle profile (default 5 real years) |
| Young to mature tree | 24 additional hours | | Named young to mature tree | Absolute species maturity endpoint, not an additional stage-local duration |
| Mature tree death | 72 additional hours | | Generic unidentified tree | Explicit fallback: young after 1 real year, mature after 10; lifespan unknown |
| Mature seed dispersal | After 30 minutes; every tenth evaluation | | Living calendar plant death | Never inferred from lifespan endpoints or unknown age |
| Wildflower / grass and fern senescence | 4 / 6 hours | | Mature tree seed opportunity | At most one present eligible attempt per mean real year; no missed-event replay |
| Dung / litter / fungi / deadwood decay | 10 / 30 / 40 / 120 minutes | | Named understory seed opportunity | Requires known maturity and seed season; at most one attempt per 30 days |
| Dung / generic fungi decay | Existing game policies: 10 / 40 loaded minutes |
| Litter / deadwood decay | Conservative game fallbacks: 90 calendar days / one mean real year |
New tree generations therefore take sessions, not seconds. Each stage resets its New tree generations take actual calendar years, not compressed sessions. One
own age. Cold weather below 3°C suppresses establishment and living reproduction/ day is 86,400 seconds, a mean year 365.2425 days. Every named plant uses
stage transitions. Decomposition and stress remain possible. This is a practical [the lifecycle policy](lifecycles.md), including explicitly unverified group
niche model, not a botanical or hydrological solver. Young/mature identities are fallbacks; sourced fields are not proof that all timing fields were researched.
Stage swaps preserve one root age. Cold below 3°C/dormancy suppresses visible
stage transitions and seeding, **not calendar aging**. No exact historical
growing-season integration or mortality model is claimed. Young/mature identities are
ordinary full-cube log roots; **a mature tree needs an actual multi-node block trunk 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` 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 interface below shares the same geometry used by mapgen. Applying it, validating
@@ -76,17 +83,54 @@ clearance assumption: mature profiles reach 9×10×9 and must be preflighted in
Unknown/player plants, water, unloaded nodes, claims and markers must block growth; 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. 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. 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. Player-planted generic saplings now require one real year before their bounded
young block-tree establishment, using an explicitly unidentified family fallback,
not an assumed birch. Generic trees have a ten-year maturity policy and no
automatic death. Player provenance is retained on every newly written timber cell.
Existing mature player/mapgen timber is not retroactively made killable.
Generic game grain has an explicit **120 warm-day approximate parameter**, not a
species/cultivar research claim. Stages require 24/72/120 equivalent warm days;
temperature below 3°C contributes zero, 3–10°C half, warmer one. At most 366
midpoint samples cover the entire planting-to-now interval, with no silent
30-day age cutoff. Long gaps coarsen quadrature, not discard age. This is bounded
climate estimation, not exact historic degree-day integration; stage high-water
prevents display regression. Invalid/future planting clocks and rollback pause,
never reset birthdays. Berry regrowth and food-patch harvest cooldowns remain
game resource policies, not organism lifespans. Legacy unidentified berry bushes
retain their initial standing crop, but picking locks them for one mean real year;
bare bushes then return only in an eligible Summer. This is a conservative annual
game policy, not researched species phenology. Rejected inventory transfer
restores original readiness; partial transfer spends the batch. Node swaps
preserve natural/ownership metadata. Seasonal greens/mushrooms retain their daily
game harvest policy, with reservation before inventory callbacks.
#### Calendar root persistence and migration (version 1)
See [the full metadata contract](botany.md#root-persistence-and-migration).
`succ_birth_at` is the UTC recruitment/adoption epoch, `succ_initial_age_seconds`
is a separate documented initial-stage assumption, and
`succ_last_observed_at` is a durable clock high-water mark.
`succ_lifecycle_version=1`, exact `succ_lifecycle_profile`, position-derived
`succ_lifecycle_seed`, `succ_lifecycle_origin` and policy label persist on roots.
New recruits use `natural_seed` and zero initial age. Existing natural legacy
plants migrate once at NOW with `legacy_established`, preserving `succ_age`
unchanged; it remains old active seconds, never millennia or a birthday.
Generated known botanical stands/soil-supported block log roots adopt
`worldgen_established` without resetting mature canopies into seeds or claiming
historically observed ages. Initial stage ages come from known profile endpoints;
unknown maturity is zero, not invented history. No negative/pre-epoch birthdays.
Malformed metadata, unsupported versions and clock rollback fail closed. No
offline mortality, forced terrain loads, whole-world scans or queued seed replay.
## Species diversity and understory ## Species diversity and understory
Shared tree identities are exactly **oak, birch, pine, willow, alder, beech, aspen, 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 rowan, hazel, spruce, crab_apple, wild_cherry, field_maple**, matching the thirteen trees in [botany.md](botany.md). Each has
canonical `hearth:<species>_seedling`, `hearth:<species>_young`, and canonical `hearth:<species>_seedling`, `hearth:<species>_young`, and
`hearth:<species>_mature` nodes. The six-hour/24-hour/72-hour stage durations above `hearth:<species>_mature` nodes. Growth and seed dispersal preserve the parent's
remain unchanged. Growth and seed dispersal preserve the parent's species, and species and calendar anchor; old accelerated stage/death durations are removed.
death records `succ_species` on generic deadwood (retained through its litter Each young/mature root drops **one** of its species logs, never three
transition). Each young/mature root drops **one** of its species logs, never three
logs from one cube. Additional generated trunk cubes are separately harvestable. 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 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. textures, `drawtype="normal"`, `tree=1`, `choppy=2`, and default cube collision.
@@ -101,7 +145,9 @@ Recruitment is deterministic, not an all-oak fallback:
- Oak favors stable forest floor with an existing canopy. - Oak favors stable forest floor with an existing canopy.
- Beech has a broad domed crown and avoids saturation; aspen is tall and columnar; - 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. 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. Crab apple has a broad round crown, wild cherry has horizontal branch tiers,
and field maple has a broad lobed crown. All thirteen have distinct mature block
silhouettes, species bark/end-grain/leaves/planks and deterministic recruitment.
- Willow favors moist alluvium/floodplain; alder favors peat and wet woodland. - Willow favors moist alluvium/floodplain; alder favors peat and wet woodland.
Riparian recruitment requires moisture ≥0.65. Species soil support is delegated Riparian recruitment requires moisture ≥0.65. Species soil support is delegated
to `hearth.biomes.supports_species(species, surface)` when present; its decision to `hearth.biomes.supports_species(species, surface)` when present; its decision
@@ -138,8 +184,9 @@ nutrient metadata to eight without rewriting it.
Natural blooming clover, wildflowers, spring bluebells and autumn heather increase Natural blooming clover, wildflowers, spring bluebells and autumn heather increase
the read-only `habitat(pos).pollination` count. This exposes a bounded habitat input 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 for consumers; habitat reads never change berry timers or grant extra berries.
extra berries. `edible` is a separate active-natural-forage count. Pine and spruce Transactional nectar debit below is separate from this observation.
`edible` is a separate active-natural-forage count. Pine and spruce
are evergreen in the catalogue and seasonal node policy; deciduous trees are evergreen in the catalogue and seasonal node policy; deciduous trees
retain only 15% canopy influence. retain only 15% canopy influence.
@@ -150,9 +197,13 @@ protection and placement/unknown metadata. Hand placement marks `player_placed=1
It never adopts species/generic logs, leaves, planks, crops or berry bushes. There 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 is no general adoption of arbitrary flora, generic litter or fungi, and no new
scanning scheduler. Adopted deadwood needs loaded natural ecological soil and scanning scheduler. Adopted deadwood needs loaded natural ecological soil and
decays to litter after **two observed active hours**, releasing three nutrients decays to litter after **one mean calendar year**, releasing three nutrients
(soil cap eight); subsequent litter decay releases one more, once. Adoption is (soil cap eight); subsequent litter decay releases one more, once. Adoption is
idempotent and grants no age or absence catch-up. As with idempotent and grants no invented age. Organic decay uses durable
`succ_decay_at`, `succ_decay_initial`, `succ_decay_seen_at` high-water and bounded
`succ_decay_bonus`; elapsed absence changes present eligibility, never queues
missed decomposition or payouts. Litter returns only its unconsumed mineral
budget, not another full unit after earthworm feeding. As with
legacy unmarked soil, provenance cannot distinguish third-party placements that legacy unmarked soil, provenance cannot distinguish third-party placements that
deliberately omit every marker; those integrations must mark or claim their flora. deliberately omit every marker; those integrations must mark or claim their flora.
@@ -176,10 +227,12 @@ calendar through the existing solar model. Explicit test climates can preview it
| Spring greens patch | Pickable | Dormant | Dormant | Dormant | | Spring greens patch | Pickable | Dormant | Dormant | Dormant |
| Autumn mushroom patch | Dormant | Dormant | Pickable | Dormant | | Autumn mushroom patch | Dormant | Dormant | Pickable | Dormant |
Frost and seasonal dormancy pause living age, stress and reproduction; they do not Frost and seasonal dormancy pause visible growth, loaded stress and reproduction,
kill perennials at first frost. Spring resumes the same ecological age. Deadwood, not calendar age; they do not kill perennials at first frost. Spring observes the
same root anchor plus the full elapsed calendar interval. Deadwood,
litter, dung and decomposer fungi continue returning nutrients in winter; mature litter, dung and decomposer fungi continue returning nutrients in winter; mature
trees can continue shedding litter. First-night berry availability stays year-round. trees can continue shedding litter. Initial legacy berry crops remain available;
spent crops no longer refill every real day.
Spring greens recruit and spread into moist (≥0.55), light ≥9 gaps in Spring. 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 mushrooms recruit and spread into moist (≥0.6), shaded (light ≤10) gaps in
@@ -231,14 +284,21 @@ revision IDs, biological rates, or undisclosed effect generation from tags.
### Implemented mechanics, not just catalogue descriptions ### Implemented mechanics, not just catalogue descriptions
Calendar-v1 update: nitrogen eligibility below is now **90 calendar days**, not
the historical 900 loaded seconds, retaining the finite two-unit support cap.
New red clover, birdsfoot trefoil, gorse and broom share it. Their tags are not
independently reviewed species-rate measurements. Separately owned read-only
effect descriptions may still describe old acceleration; runtime policy here
and field-level lifecycle confidence labels take precedence.
| Species / relationship | Exact game effect | Evidence and limits | | 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. | | 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. | | 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. | | 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. | | 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. | | Host-associated fungi, including fly agaric (*Amanita muscaria*) | A compatible natural fungus can transfer/debit **at most one existing soil nutrient** when a tree independently reaches its calendar juvenile threshold. Multiple fungi select a maximum allowance, not a sum. There is **no time reduction**; the former 16,200/21,600-second boost is removed. | [Fly agaric ecology](https://en.wikipedia.org/wiki/Amanita_muscaria#Distribution_and_ecology) describes associations, not a measured growth acceleration. This is not nutrient creation or a full carbon/water ledger. |
| 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. | | Saprotrophic wood fungi, including birch polypore (*Fomitopsis betulina*) | On suitable natural deadwood/litter only, advance its calendar decay bonus by at most **60 seconds per eligible evaluation**, capped at the one-year / 90-day substrate lifetime. The ordinary transition consumes deadwood to litter to air, paying **three then the unconsumed portion of one** soil nutrient once, subject to cap eight. Removing the support removes its natural fungal occupant. The fungus itself has no extra 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. 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 The sampler collects `species_counts` (including canonical alder roots, moss and
duckweed) within its existing **5x5x5** stencil. Mat feedback uses only the duckweed) within its existing **5x5x5** stencil. Mat feedback uses only the
@@ -287,7 +347,8 @@ Lua tests, not a claim of full-engine or multiplayer validation.
Wildlife calls scan at most nine candidate sites and mutate 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 - 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. is no emerge, force-load, mapgen edit, ABM, per-node timer or offline replay.
- Ages accrue from consecutive observations, not from absolute planting dates. - Legacy active counters accrue from consecutive observations. Calendar plant
ages and organic decay use durable absolute anchors, not these counters.
First observation after restart, load, cache eviction, disconnect, clock rewind, 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 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 invalidates observations when vegetation or its natural support reloads. A stalled
@@ -297,9 +358,10 @@ Lua tests, not a claim of full-engine or multiplayer validation.
next-attempt timestamp, natural-origin flag and attempt serial live in node 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. metadata; soil stores only its nutrient count. There is no global save schema.
Durable attempt timestamps/serials prevent repeated evaluation or rerolling Durable attempt timestamps/serials prevent repeated evaluation or rerolling
a blocked seed by reconnecting/restarting. Restarting pauses growth; it cannot a blocked seed by reconnecting/restarting. Restarting cannot accelerate or
accelerate it. Ages are sampled approximations between visits, not per-tick reset birthdays, organic clocks or resource receipts. Calendar eligibility
accounting; very busy/many-player worlds may deliberately progress more slowly. survives absence; only bounded present work occurs on return. Legacy active
processes remain sampled approximations, not per-tick accounting.
- Source, destination and natural supports must all be unclaimed, externally - Source, destination and natural supports must all be unclaimed, externally
unprotected and free of placement metadata. All normal content placement marks 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. `player_placed=1`, including hand-placed soil/turf and soil left by crop harvest.
@@ -316,9 +378,52 @@ Lua tests, not a claim of full-engine or multiplayer validation.
`hearth.succession` is installed by the content registrar: `hearth.succession` is installed by the content registrar:
- `forage(pos) -> boolean`: consume exactly one nearby active natural - `forage(pos) -> boolean`: debit one partial canopy ration from one nearby
`meadow_grass`, `fern`, `clover`, `sedge` or `nettle` on safe natural support. active natural browse plant on safe support. The plant remains rooted;
Does not eat dormant flora, flowers, crops or player plants. `succ_browse_ready_at = now + 7 days` is shared across animals and restarts.
Seven days is an unverified conservative game fallback, not measured biomass
recovery. No dormant flora, nonbrowse flowers, crops or player plants are eaten.
- `pollinate(pos) -> boolean`: exact source, not a new scan. Require botanical
active flowering (`B.pollinator`), known calendar maturity, natural root and
permitted loaded source/support. Atomically reserve
`succ_nectar_ready_at = now + 3600` before any effects. Competing bees share
this receipt. One non-additive boolean `succ_pollinated_until = now + 86400`
is written only when the old credit has expired; repeated visits do not stack
yield or extend the existing credit. One hour replenishment and one day credit
are game approximations, not measured species rates.
- `frugivore(pos, species_id?) -> boolean`: verify exact identity if supplied,
known mature shrub and actual botanical fruiting season. Consume one feeding
unit from the existing batch by spending the **whole two-fruit patch**; discard
the remainder rather than duplicate it. Reserve the human harvest field
`botany_ready_at = now + one mean year`; no items or animal-only fruit account.
- `granivore(pos, species_id?) -> boolean, optional_cache_id`: only mature
oak/hazel rooted trees (including adopted soil-level block roots), in Autumn
and their botanical seeding window. `succ_nuts_year` (UTC calendar year) and
`succ_nuts_remaining` persist a maximum two-nut current crop per root/year.
One nut funds a meal. If a second exists it is **separately debited** and the
second return is the plain species ID expected by wildlife. One remaining
nut returns `true, nil`; no old-year crop replay, reconnect refill or immature
root production. Two nuts is a coarse budget, not a measured yield.
- `disperse_seed(pos, species_id) -> boolean`: plain IDs alone confer no
authority. One in-memory handoff slot records the immediately preceding paid
granivore cache nut, with its source/species/UTC second. Any cache attempt
consumes the slot **before** destination checks; other mutating wildlife
hooks or another granivore call supersede it. Restart loses it, never refunds
nuts. Source identity/protection is rechecked; destination must be nearby
(within two horizontal nodes, same root height), loaded, permitted air on
viable natural support, with valid cooldown metadata. Existing establishment
preflight/niche rules apply. Success creates exactly one calendar-zero
same-species seedling and a one-day destination receipt. This matches the
synchronous squirrel caller; no opaque token or saved animal seed is required.
It is an internal trusted-call contract, not authentication against hostile
mods that can call hooks between them.
- `detritivore(pos) -> boolean`: exact natural litter source; consume 0.1 of
its normalized one-unit `succ_litter_remaining` mass and transfer exactly
0.1 existing mineral budget to permitted soil. At most ten meals per patch,
shared across restart. Keep the patch until the last unit; final removal
preflights any dependent fungus. Full soil refuses without spending material.
Ordinary decay returns only the remainder. No extra fertilizer aura, items
or renewal timer. Normalized units are game accounting, not measured digestion.
- `deposit(pos) -> boolean`: place exactly one dung nutrient patch in nearby - `deposit(pos) -> boolean`: place exactly one dung nutrient patch in nearby
**air** over safe natural support. Never replaces existing plants or water. **air** over safe natural support. Never replaces existing plants or water.
Nutrients arrive through active decomposition, not immediately on deposit. Nutrients arrive through active decomposition, not immediately on deposit.
@@ -334,6 +439,31 @@ Callers supply animal ground-cover height (the air/plant cell above soil), not t
supporting soil or the animal's eye height. Calls return false when no safe target 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. exists. Habitat has no writes, timers or protection-violation reports.
### Integration handoff: botanical fruit effect remains separately owned
Current [botany.lua](../mods/hearth/botany.lua) human harvest still has a fixed
two-berry output and a one-day refill. This owned change does **not** pretend
that `succ_pollinated_until` already increases botanical fruit yield. The botany
owner must apply at most **one extra berry to an existing eligible fruit batch**
when its unexpired boolean credit is present, spend that credit with the shared
harvest transaction, and never give fruit merely for a pollen visit. It must also
replace daily human botanical refill with the next annual fruiting-season policy
and coordinate the same `botany_ready_at` lock used here. Animal fruit consumption
already uses a conservative one-year lock, but a human-first harvest still uses
the separately owned old daily policy until that integration lands. Flowering
may precede fruiting by months: the requested one-day credit is explicitly a
limited game approximation, not an annual pollination/fruit-set simulation.
Runtime signatures were checked against [wildlife.lua](../mods/hearth/wildlife.lua)
and [fauna.md](fauna.md): it consumes literal `true`, calls fruit/nut hooks with
the plain species ID, compares the optional cache return to that ID, and calls
dispersal synchronously only for oak/hazel squirrels. The older fauna contract's
one-day fruit deadline is superseded here by the conservative annual animal
receipt; its owner should align that documentation. No wildlife edits were made.
All resource failures leave node/resource metadata unchanged; paid cache authority
is the intentional exception, lost even on failed caching. No secrets, network
requests, serialization evaluation or cryptographic authorization were introduced.
## Assets and verification ## Assets and verification
### Exact registered node / art integration contract ### Exact registered node / art integration contract
@@ -368,9 +498,13 @@ explicit mesh tiles directly; it does not require `override_item` in engine mock
### Main-agent wiring and exact diversity interface ### Main-agent wiring and exact diversity interface
The blockshape change owns only diversity, content, biomes, their three focused The calendar expansion owns only the six requested flora/ecology modules, their
tests and this document. Succession, ecology, seasons, botany and generated assets six focused tests and the two botany/ecosystem documents. Bootstrap, lifecycle
remain other owners' responsibility. Exact integrated order: policy, content, schema and generated assets remain other owners' responsibility.
Exact integrated order (main owns wiring):
0. Assign `hearth.lifecycle = dofile(modpath .. "/lifecycle.lua")` (plain namespace,
not a factory) **before** botany/content.
1. Create/install `hearth.seasons = dofile(modpath .. "/seasons.lua")(hearth, api)`. 1. Create/install `hearth.seasons = dofile(modpath .. "/seasons.lua")(hearth, api)`.
2. Create/install `hearth.botany = dofile(modpath .. "/botany.lua")(hearth, api)`. 2. Create/install `hearth.botany = dofile(modpath .. "/botany.lua")(hearth, api)`.
@@ -378,17 +512,18 @@ remain other owners' responsibility. Exact integrated order:
`hearth.diversity = dofile(modpath .. "/diversity.lua")(hearth, api)`. `hearth.diversity = dofile(modpath .. "/diversity.lua")(hearth, api)`.
This factory immediately extends `seasons.add_variants` with species policy. This factory immediately extends `seasons.add_variants` with species policy.
4. Instantiate succession using its factory and assign `hearth.succession`. 4. Instantiate succession using its factory and assign `hearth.succession`.
5. After normal content, register/install biomes (ten species' logs/leaves/planks). 5. After normal content, register/install biomes (thirteen species' logs/leaves/planks).
6. Call `hearth.diversity.register_nodes(register_node)` with the bare-name wrapper: 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. 39 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, 7. Call `hearth.botany.register_nodes(register_node)`: exactly 79 new non-tree taxa,
with all explicit tiles and inventory/wield images preserved. The ten trees with all explicit tiles and inventory/wield images preserved. The thirteen trees
and eight existing understory nodes are not registered twice. and eight existing understory nodes are not registered twice.
8. Call `seasons.register()` after every canonical node, then instantiate wildlife. 8. Call `seasons.register()` after every canonical node, then instantiate wildlife.
The returned diversity table exposes: The returned diversity table exposes:
- `species_order`, `plants`: ordered ten/eight canonical short names. - `species_order`, `plants`: ordered thirteen/eight canonical short names (the
latter are legacy registrar-owned understory, not the whole botanical catalogue).
- `grounds`, `rocks`: exact short-name membership sets; `is_ground(node_or_name)`, - `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. `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; - `tree_info(kind_or_node) -> species, stage`: two Lua return values, or nil;
@@ -409,7 +544,7 @@ The returned diversity table exposes:
### Geometry handoff to ecological growth ### Geometry handoff to ecological growth
`diversity.tree_plan(species, stage?) -> plan | nil` is read-only. Species is one `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"` of the thirteen IDs or `"generic"` for legacy trees; stage is `"young"` or `"mature"`
(default mature). Invalid IDs/stages or missing biome integration return nil. (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 `plan` is a fresh dense array of `{x, y, z, name, param2}` records: integer offsets
@@ -445,7 +580,7 @@ No original 12-month climate calculation or preview-clock semantics changed.
### Exact species/understory art contract ### Exact species/understory art contract
For **all ten** species in `species_order`: For **all thirteen** species in `species_order`:
- Seedlings use `hearth_botany_<species>.png` on a plantlike sprite. Young/mature - 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 roots and species timber use `hearth_<species>_log_top.png` on both ends and
@@ -470,10 +605,10 @@ heather, bluebell, sedge and bulrush additionally use
has `_summer` and `_autumn` node variants using its same dormant texture. 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. 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, With all biome leaves installed, diversity adds **187 nodes**: 47 canonical nodes,
72 deciduous stage variants, 24 deciduous full-block leaf variants and eight 99 deciduous stage variants, 33 deciduous full-block leaf variants and eight
understory variants. Pine and spruce add no seasonal variants. Botany independently understory variants. Pine and spruce add no seasonal variants. Botany independently
adds 62 nodes. Species provenance on generic deadwood remains metadata. adds 79 nodes. Species provenance on generic deadwood remains metadata.
The standalone succession fixture tests growth, niches, shade, competition, The standalone succession fixture tests growth, niches, shade, competition,
seedlings, seeds, nutrient feedback, dung/fungi/decay, grazing, source/destination/ seedlings, seeds, nutrient feedback, dung/fungi/decay, grazing, source/destination/
@@ -484,10 +619,10 @@ all four seasons, northern/southern calendars, seasonal foliage/restoration,
metadata/age conservation, forage cooldowns/restart, niches/occupation, protected metadata/age conservation, forage cooldowns/restart, niches/occupation, protected
and unloaded no-write cases, target-volume clearance and the 96-swap bound. and unloaded no-write cases, target-volume clearance and the 96-swap bound.
The blockshape-focused standalone suites are diversity, biomes and content; The blockshape-focused standalone suites are diversity, biomes and content;
botany has its separate catalogue suite. They check exact ten-species ordering, botany has its separate catalogue suite. They check exact thirteen-species ordering,
distinct mature schematics, bounded young canopies, shared rooted geometry, distinct mature schematics, bounded young canopies, shared rooted geometry,
metadata-safe deadwood migration, axe-only harvesting, all 78 unique recipes, metadata-safe deadwood migration, axe-only harvesting, unique material recipes,
62 additional botany nodes and preserved aquatic icons/placement guards. 79 additional botany nodes and preserved aquatic icons/placement guards.
Engine visuals, Engine visuals,
multiplayer ecology balance and long-session woodland density still require multiplayer ecology balance and long-session woodland density still require
in-engine playtesting; unit fixtures are not a performance benchmark. in-engine playtesting; unit fixtures are not a performance benchmark.
Executable
+509
View File
@@ -0,0 +1,509 @@
# Fauna: evidence, calendar time and integration contract
Reviewed 2026-11-04. Scope includes the catalogue, wildlife runtime, direct
in-game interaction panel and focused Lua tests. No engine run or visual-quality
acceptance is claimed by this change.
## Public interfaces
The catalogue is a factory: `dofile(modpath.."/fauna.lua")(hearth,api)`.
It returns `VERSION`, `SECONDS_PER_DAY`, `list()` / `catalog()` (ordered record
arrays), `get(id_or_entity)`, `kind(id_or_entity)`, `source(id)` (URL array),
`phase(id,born_at,utc)`, `breeding_window(id,month)` and
`can_breed(id,born_at,utc,sex,season?)`. Unknown IDs return nil or deny breeding.
All catalogue records and lists are defensive copies. Records expose `id`,
`entity`, `common`, `latin`, `source_url`, `wikipedia_url`, `sources`, `confidence`,
`profile` (unaltered lifecycle evidence), `foods`, `prey`, `medium`, `activity`,
`bounds`, `mesh`, `texture`, `clips`, `fps`, `frames`, `eye_height` and policy caps.
Main may construct `hearth.fauna` before wildlife. Otherwise wildlife loads the
fixed sibling factory via `api.get_modpath("hearth")`; no caller-supplied path or
saved asset name is accepted. The catalogue uses `hearth.lifecycle` or loads the
fixed sibling lifecycle module. Main should inject lifecycle so seeded cohort ages
use its `duration()` selection consistently. No network request occurs at runtime.
Wildlife returns `fauna`, `species` (legacy hare entity), `global_cap`,
`player_cap`, `local_cap`, `ai_budget`, `status()`. Status includes legacy
`total`, `hare`, `deer`, `fox`, plus a copied `counts` dictionary containing all
23 IDs, `species_count=23`, `actions`, `births`, `meals`, `grazed`, `dung`,
`deaths`, `pollinations`, `dispersals`, `detritus`, and the three caps.
It also exposes a defensive `recruitment` diagnostics copy: `interval=5`,
`scan_nodes=648`, `attempts`, `successes`, `scans`, `last_reason`, and `reasons`
(reason-to-count dictionary). The `/ecosystem` survey displays all species plus
admission attempts, successes and the latest outcome; it remains observation-only.
Reasons include `waiting`,
`invalid_player`, `global_cap`, `no_surface`, `observer_near`,
`blocked_or_unloaded`, `habitat_unloaded`, `no_active_food_match`,
`population_cap`, `unsafe_body`, `activation_rejected`, `admitted`,
`registry_unavailable`, `region_budget`, and `region_seeded`.
`status().residents` is a fresh diagnostics table containing `storage_ok`,
`regions`, `region_limit=4096`, `region_size=48`, `founders_per_region=4`, and
the persistent individual `counter`. Counts describe loaded residents, not the
entire unloaded population. Diagnostics do not expose mutable journal records.
## Field journal and ambient grouping
Right-clicking a live, pointable animal records that species in the observing
player's metadata under a versioned, bounded, canonical species-ID list. The
species guide marks recorded and not-yet-encountered identities; opening a
catalogue-only profile never unlocks an encounter. This is a private per-player
field-journal convenience, not a local census, achievement reward, or change to
the animal/world state. Invalid saved journal text fails closed and is never
overwritten. Active robins, blackbirds, blue tits and mallards also use a small,
local cohesion/separation bias when choosing wander direction. It is computed
from loaded same-species residents only—there are no persisted flock IDs,
unbounded entity scans, or forced spawn groups.
These are original adaptations of ambient grouping and encounter journaling
ideas visible in Animalia's public [bird behavior](https://github.com/ElCeejo/animalia/blob/main/mobs/song_bird.lua),
[AI](https://github.com/ElCeejo/animalia/blob/main/api/mob_ai.lua), and
[Libri](https://github.com/ElCeejo/animalia/blob/main/api/libri.lua) source.
Last Hearth does not include that mod's code, models, textures or audio.
## Runtime API integration
The managed package pins the MIT-licensed Creatura 0.2.10 release from ContentDB
release 34130; the downloaded archive SHA-256 and upstream commit are recorded
in [mods/creatura/UPSTREAM.md](../mods/creatura/UPSTREAM.md). Creatura supplies
animal-focused pathfinding, boid algorithms and its ABM spawn scheduler.
Last Hearth supplies species identities, original meshes/textures, habitat and
diet policy, caps, protected/provenance-aware admission, persistent resident
IDs, feeding transactions and player interactions. Creatura spawn callbacks do
not bypass Last Hearth's `on_activate` checks. Ambient ABMs are restricted to
common species, small groups, natural soil types and fixed per-species caps; no
drops, taming, combat or livestock systems are enabled. The bounded Last Hearth
recruitment path remains available as a fallback and does not award food when
vegetation serves only as an ambient habitat signal.
Engine API: entity/globalstep registration, connected players, loaded-only
`get_node_or_nil`, node definitions, node metadata, protection, `add_entity`,
`parse_json` / `write_json`, mod storage (`hearth.storage`, otherwise
`api.get_mod_storage()`), and normal ObjectRef position, velocity, yaw,
acceleration, armor and animation methods. Optional `api.now()` is an integer UTC
epoch test seam; otherwise `os.time()` is used. Climate uses
`hearth.climate_at(utc).season`; qualitative habitat seasons may be accelerated
by climate configuration, but animal ages and activity hours never are.
## Direct in-game interaction
Registered animal entities are natively pointable while retaining their
existing physical collision envelope. Luanti's normal object picking dispatches
right-clicks to `hearth.inspect_fauna(clicker, species_id, snapshot)`; this is
not a HUD raycast or a node-placement change. Wildlife verifies that the entity
is currently active and that the exact clicker is an online player with a
session, then passes a detached snapshot of calendar birth time, modeled
reserve, origin, persistence-ID availability and current rest/awake animation
observation. The callback is looked up at interaction time so wildlife does not
own formspec registration. The interface checks the online player and session
again, and escapes/bounds displayed catalogue and snapshot text. Species-guide
selection can also open a species-only profile; it is not a local animal census.
The panel reports Latin identity and source URLs, lifecycle phase and real
calendar age when available, currently modeled diet and prey, activity/sleep
and winter-rest policy, and whether reproduction is enabled for the profile or
disabled for incomplete evidence. Food reserve is explicitly labelled as a
coarse game policy, not biology. Persistent residency and current animation are
observations only; no position is displayed. A direct animal encounter adds one
species ID to the observing player's bounded field journal; catalogue browsing
does not. Neither action changes animal state, ecology, inventory or terrain.
Punching
continues to trigger flee movement; immortal armor and the lack of damage/drop
handlers prevent combat or item rewards. Collision and crafting/node placement
remain unchanged. Native mesh appearance and animation quality were not
visually verified in an engine session.
## Required succession transaction contract
These are **required transactional hooks**; the current succession supplies them.
Wildlife does not replace or bypass their shared budgets. Missing hooks or anything other than
literal boolean true gives no feeding credit and no ecological-success count.
Wildlife performs no direct vegetation/node mutation or inventory operation.
Main must repeat all loaded/protection/provenance checks inside each transaction.
Forage/deposit's entire 3x3 neighbourhood is guarded by wildlife before invocation.
| Hook | Exact required semantics |
| --- | --- |
| `habitat(pos)` | Loaded observation with `loaded`, `grass`, `cover`, `canopy`, `moisture`, `water`, `near_water`. Unknown/unloaded must fail recruitment. |
| `forage(pos) -> boolean` | Existing metadata-only natural browse debit, finite and seasonal, not inventory/crop consumption. |
| `deposit(pos) -> boolean` | Existing bounded metadata-only dung/nutrient receipt. One pending receipt per earned meal; no item or offline queue. |
| `pollinate(pos) -> boolean` | Atomically debit legitimate nectar from a mature, currently flowering natural plant and apply bounded pollination. Persist shared `succ_nectar_ready_at` before any callback; all bees compete for the same budget. Reject invalid timestamps. Use an explicit nectar replenishment policy; wildlife's 300-second inter-meal throttle is NOT evidence for floral replenishment. No seed generation. |
| `frugivore(pos, species_id) -> boolean` | Verify exact botany identity and fruiting season, then atomically debit the **same `botany_ready_at`** used by human `botany.harvest`. Set its existing annual (`365.2425 * 86400` seconds) resource deadline before callbacks. Never maintain a parallel animal-only berry budget; reject invalid/future readiness, nonnatural/planted nodes and immature shrubs. No inventory output. |
| `detritivore(pos) -> boolean` | Consume a finite unit of existing natural litter and transfer only conserved organic/mineral material. Reject empty substrate. Never an infinite soil fertility aura. |
| `granivore(pos, species_id) -> boolean, optional_cache_id` | Debit an existing finite seasonal seed/nut resource. True grants one meal; optional `"hazel"` or `"oak"` grants a **second separately debited viable nut**, not the eaten seed. No token if the second nut was unavailable. |
| `disperse_seed(pos, species_id) -> boolean` | Accept one immediately preceding, separately debited cache nut; check loaded natural viable destination and bounded seed metadata. Only squirrel hazel/oak caches invoke this hook. Consume authority once even if the destination fails (lost nut, never duplicated). No persistent token, offline planting, pollinator seed aura, or arbitrary new species. |
The cache hook must track immediate granivore authority in succession, rather than
trust arbitrary callers. Both source and destination are guarded; successful
consumption and successful caching are different counters. Seed-eating mice,
voles and birds are not automatically awarded dispersal. Fruit transport/gut
passage is not implemented and must not be presented as simulated.
## Time, provenance and deliberately unknown biology
**Six profiles now enable reproduction:** bank vole, wood mouse, blackbird, mallard, European hare and red fox. The sourced hare profile uses 365-day maturity, 42-day gestation, one leveret per litter and a 30-day care lock; its broad reported season is represented as Winter/Spring/Summer. The fox profile uses 304-day maturity, 53-day typical gestation, one pup per litter, and a conservative 300-day postpartum lock; its coarse breeding season is Winter. ADW reports 56-70 days of fox nursing/weaning, but the existing generic dependent-care loop cannot represent paternal provisioning or fox den care, so this species is not a complete family-behavior simulation.
Registration (`pending=false` for all 23) is independent
of evidence completeness. This is not whole-ecosystem true-life certification.
The following pages were retrieved on 2026-11-03; ranges are original factual
summaries, not bundled article text. Runtime uses conservative upper development
and maturity endpoints and the lower sourced litter/clutch count.
| Species | Maturity | Development to birth/hatch | Count | Sources and limitations |
| --- | --- | --- | --- | --- |
| bank vole | 42-56 d sex envelope; runtime 56 d for both | 17-24 d gestation; runtime 24 d | 3-5, runtime 3 | [ADW](https://animaldiversity.org/accounts/Myodes_glareolus/) gives 17-24 d, 20-25 d weaning, late April-September; [Woodland Trust](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/animals/mammals/bank-vole/) supplies conservative 6/8-week maturity; [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/bank-vole) supplies the chosen 3-5 count. ADW reports a wider 1-10, typically 4-8; these are not silently merged. |
| wood mouse | 65-71 d; runtime 71 d | 21-26 d gestation; runtime 26 d | 4-8, runtime 4 | [ADW](https://animaldiversity.org/accounts/Apodemus_sylvaticus/) gives maturity, gestation and 21 d weaning, but 4-7 young; retained 4-8 is from [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/wood-mouse). [Wikipedia](https://en.wikipedia.org/wiki/Wood_mouse) reports February-October season and 23-27 d gestation, differing from ADW. |
| blackbird | about one year | 13-14 d incubation; runtime 14 d | 3-4, runtime 3 | [ADW](https://animaldiversity.org/accounts/Turdus_merula/) gives first breeding at one year, 13-day prose/14-day summary and 13-14 d leaving nest. [RSPB](https://www.rspb.org.uk/birds-and-wildlife/blackbird) supplies clutch/nesting window. Fledging is not full independence. |
| mallard | 365 d both sexes | 26-28 d incubation; runtime 28 d | 9-13, runtime 9 | [ADW](https://animaldiversity.org/accounts/Anas_platyrhynchos/) gives these values; [Wikipedia](https://en.wikipedia.org/wiki/Mallard) gives 50-60 d to flight and spring nesting. Unknown lifespan does not prevent breeding. |
| European hare | 236-365 d; runtime 365 d | 30-42 d gestation; runtime 42 d | 1-8 leverets, runtime 1 | [ADW](https://animaldiversity.org/accounts/Lepus_europaeus/) reports maturity at 8 months-1 year, one-month weaning, 30-42 d gestation and 1-8 per litter; winter through midsummer breeding. One-month care lock is a conservative policy. |
| red fox | about 304 d; runtime 304 d | 51-53 d typical gestation; runtime 53 d | 1-9 in ADW summary, runtime 1 | [ADW](https://animaldiversity.org/accounts/Vulpes_vulpes/) reports 10-month maturity, winter mating in central-range populations, 51-53 d typical gestation and 56-70 d nursing/weaning. Its prose says 1-13 while its summary table says 1-9; the narrower table range is retained. A 300-day postpartum lock approximates its once-yearly seasonal cycle; this is policy. |
Breeding requires same-species female and male within two nodes, both mature,
safe, recently fed (within twice their feeding throttle), reserve at least 0.7,
and loaded food-bearing habitat. Conception spends 0.2 reserve from each parent;
each delivered young spends another 0.1 from the mother and starts with 0.1.
These are explicit normalized energy policies, **not biological calorimetry**.
Only successful succession transactions earn reserve. There is no feeding-count
shortcut to maturity, free fertility command, item reward or player attack.
`hearth.climate_at(utc).season` (or an explicitly supplied season) determines the
hemisphere-aware season gate: Spring/Summer for the four original profiles,
Winter/Spring/Summer for hare, and Winter for fox. Northern reference months remain research
observations, not a simultaneous northern-month veto on southern spring. These
coarse season policies omit some legitimate early-autumn breeding;
it is not exact local phenology. Missing climate denies new conception. Calendar
age and due times always use real epoch seconds, never climate-speed multipliers.
Pregnancy and incubation use a common persisted deadline field; eggs are a
bounded maternal brood state, **not mammalian pregnancy or separate nest nodes**.
Egg laying, nest construction, exact incubation attendance and clutch failure
are not simulated. Dependent young rest rather than hunt/browse as adults;
nearby loaded mothers transfer 0.05 reserve at the ordinary feeding cadence.
This is coarse care accounting, not milk chemistry or a species-specific chick
diet. Maternal postpartum locks are 25 d (vole), 21 d (mouse), 42 d (blackbird),
81 d (mallard), 30 d (hare) and 300 d (fox) after the last admitted young. Bird locks deliberately include an
extra 21 d beyond the conservative dependency/fledging allowance. They do not
claim measured inter-clutch intervals. Assets still use the authored species
mesh, not visually distinct baby models.
**Seventeen profiles stay reproduction-disabled**, including three legacy slots.
Blue tit now has [BTO](https://www.bto.org/understanding-birds/articles/blue-tit-diary)
8-10 typical eggs, about 14 d incubation and 18-21 d fledging; first breeding is
described as the next spring, not an accepted exact day threshold, and represented
caterpillar provisioning is missing. ADW's Cyanistes URL was missing and its
older Parus URL contained taxonomy only; neither validates numerical overrides.
[Common frog](https://en.wikipedia.org/wiki/Common_frog) adds about 3-year maturity
and 14-21 d incubation, but aquatic eggs/tadpoles and their food remain absent.
[Brown trout](https://en.wikipedia.org/wiki/Brown_trout) needs female mass for its
approximately 2000 eggs/kg, a gravel redd and temperature-dependent development.
[Bumblebee](https://en.wikipedia.org/wiki/Bombus_terrestris) requires queen/worker/
male castes and colony care; a single litter timer would be misleading. Rabbit
maturity, squirrel conception season, roe deer/stoat implantation, other mammal
profiles, robin/barn owl care, newt larvae and worm cocoons remain incomplete.
No missing values are filled with guesses. Lifespan averages never trigger death.
One-time regional founders represent existing wild individuals, **not reproduction**.
Where maturity is sourced, the estimated initial age uses its upper endpoint;
`born_at=utc-estimated_age_days*86400`, `origin="established"` explicitly labels
the estimate. Unknown maturity has no invented age offset and still remains an
established individual, not a purported newborn. `origin="born"` marks actual
admitted young with `born_at=utc` at delivery, including delayed admission after
an overdue pregnancy; it never creates an adult-aged catch-up cohort.
`self.id` remains an in-memory scheduling serial. `state.resident_id` is the
separate durable world-local positive integer allocated from mod storage;
`state.founder_region` identifies the founder's geographical cell, or is empty
for births and legacy-ID migration. Sex, selection seed, food reserve, birth
epoch and reproductive state survive normal unloading and restart. Position is
saved by the engine's static-object system, not copied into a second registry.
Feeding throttles (nectar five minutes, predators two hours, other plant food one
hour), movement speeds except earthworm, and normalized two-day energy reserves
are **coarse simulation policies**, not sourced metabolic measurements. Unknown
starvation durations are false: hunger can accumulate over hours/days but cannot
justify an invented death threshold. Rabbit is the sole enabled starvation
case: the fetched European rabbit account reports winter survival without food
of about 2-8 days; the conservative upper eight-day bound is used, counting
only represented loaded hunger (`hunger_seconds`), never an unloaded absence. This is
not a veterinary prediction for an individual. No free food appears because a
predator lacks prey. There are no modeled insect-larva, mayfly, beetle, parasite,
egg or nestling populations to feed on. Thus adult trout currently have no
supported live-prey recruitment resource; saved trout swim and remain otter prey
without pretending an unimplemented invertebrate biomass exists.
## Persistence and bounds
`static_save=true`. Static state is version-1 JSON, at most 2048 bytes, with only
`version`, `species`, `born_at`, `origin`, `sex`, `foodreserve`, `nextfeed`,
`pregnancy_until`, `last_seen`, `seed`, `last_meal`, `dung_pending`, plus
`conception_at`, `litter_total`, `litter_remaining`, `litter_delivered`,
`nextbreed`, `last_birth_at`, `parent_seed`, `parent_born_at`, `resident_id`,
`founder_region`, `hunger_seconds`. No deserialize,
Lua evaluation, arbitrary asset names or paths. Exact entity/species match,
finite numeric types/ranges and coherent nonfuture observation/birth/meal clocks
are mandatory. Flat/nonnested input is checked before silent JSON decoding.
Pregnancies require an enabled female profile, mature conception, exact sourced
maximum development interval, coherent bounded integer litter counts, and a
deadline no further ahead than that interval. Overdue deadlines remain valid.
The old version-1 empty reproductive state migrates to zero counters; old nonzero
pregnancies without the new schema are refused. Next-feed deadlines are bounded to one day ahead; future birth or
observation data is rejected, never used for credit. Failed JSON encoding emits
an invalid sentinel rather than silently making a fresh cohort next load.
Validated **existing residents always restore**, even above species/global/local/
player caps, with no connected-player API, or on newly claimed, blocked,
foodless, `nil`/`ignore` unloaded terrain. Positive native `dtime_s` is expected
for restoration and is accepted. No habitat or collision read may delete a
validated existing resident merely because its surroundings changed. Invalid
snapshots, unknown fields/species, incoherent clocks or out-of-world coordinates
are still rejected, with bounded reason-only warnings (no raw payload logging).
Movement and ecological transactions stop safely inside new claims, ambiguous
claims, external protection or unknown/unsafe collision terrain. An animal on
still-safe but depleted ground can wander and become hungry rather than vanish.
There is no teleport/terrain-repair escape for an animal trapped by construction.
Deactivation forgets **live counts only**, idempotently; it never records a
death or removes founder membership. Nothing deletes an existing animal to
free admission slots. Dormant mapblock animals stay in the engine save and can
return above the new-admission caps. Births and new founders, unlike restores,
still require all current safety, habitat and cap checks. There is no 900-second
lifetime or walk-away despawn/replenishment path.
Offline **calendar ages/deadlines** advance from saved epochs, but unloaded AI
is frozen, not a whole-world ecological simulation. There are no replayed
offline meals, mating cycles, founder admissions or dung/caching queues.
An existing overdue brood is reconciled on budgeted AI after a player returns:
at most one newborn per attempt, no more than its single stored litter total.
Species/global/local/player caps, safe loaded positions, current habitat and
maternal energy still apply. Full caps delay the original due time without
redating; remaining/delivered counts survive normal save/reload. Failed admission
does not spend a child; callback reservations and activation acknowledgement
prevent synchronous reentry duplication. Once complete, the brood clears and
postpartum lock prevents repeated deliveries. This is normal-save idempotence,
**not a cross-mapblock crash-atomic transaction or authenticated save format**.
Server-local static data is not exposed as client/admin fertility input. Schema
validation rejects giant/incoherent payloads but cannot authenticate a coherently
rewritten world save; operators must protect world files. No such bypass API is added.
Energy/hunger work on each safe loaded observation is bounded to 60 seconds;
restoration preserves reserve and accumulated hunger, resets the observation
clock and clears the pending dung attempt. A successful meal or maternal
feeding resets loaded hunger. Legacy snapshots start hunger at zero because
their historical loaded/unloaded intervals cannot be reconstructed. Unsafe or
player-absent AI turns freeze energy/hunger. No force-loading or emergence.
### One-time, geographically bounded founder registry
The separate mod-storage key is **`wildlife_residents_v1`**. This does not change
the game's world-schema version. Its flat JSON envelope has `version=1`,
`counter` (last allocated individual ID), and sparse `r_<x>_<z>` fields.
Cell coordinates are `floor(world_x/48)`, `floor(world_z/48)`, including negatives;
they do not depend on player, session, Y, current population or visit count.
Each region value is a canonical bounded string:
`attempts/last_attempt_utc/resident_id=species;resident_id=species;...`.
Both the decoder and encoder enforce the 1,048,576-byte registry ceiling;
each region value is at most 256 bytes with at most **four founder memberships**.
The decoder requires exact version, known species, valid bounded cell keys,
finite integral clocks/counter/IDs, unique founder IDs and no extra fields.
Nested/deep JSON is rejected before parsing; no code evaluation, assets, paths,
credentials or client-provided allocation authority are stored.
At most **4096 region records** can ever be admitted automatically. Records
are never evicted, reset on return, silently truncated or recycled after death.
New regions beyond this permanent budget are refused; existing records and
saved animals remain intact. Attempts (saturating at one billion) and last UTC
are persisted even for a loaded search that has no surface, unavailable food,
or unloaded/invalid candidates; these attempts do **not** fill founder quota.
An incomplete cell remains retryable on later valid observations, with no
timeout that marks an empty/unloaded area permanently populated. A full cell
never fills a dead, eaten, migrated-away or currently unloaded animal's slot.
Different eligible species may share its four slots; their IDs and types are
retained permanently, not inferred from live counts. This is a one-time founder
quota, not a continuously restored carrying-capacity target. Natural births
remain independently funded by the existing calendar/food/cap rules and do not
spend or refund founder slots.
Membership and monotonic ID allocation are written/read-verified **before**
the new animal is acknowledged. Storage read/write failure, silent dropped
writes, invalid JSON/version, duplicate/unknown membership, or any exceeded
bound disables all new admissions (including births), without deleting validated
existing residents. Missing storage also disables new admissions. A known saved
ID above a missing/rolled-back counter fails closed instead of reusing its ID.
Legacy snapshots without ID fields receive an ID and empty `founder_region`
when storage is healthy; with failed storage they remain valid residents without
an allocated ID, rather than being destroyed. They can migrate on a later restore
after operator repair. Never clear this key to make animals reappear: restore
world/mod-storage backups together. A process/storage crash after reservation
can conservatively spend a founder slot or burn an ID without creating an animal;
there is no cross-mapblock crash-atomic population transaction or automatic refill.
New-admission global cap is 64; player-neighbourhood cap 16 within 48 nodes;
local cap 12 within 16 nodes. Per-species caps range 2-8. Overlapping player
neighbourhoods are all checked for new founders/births only, never movement or
restoration. Loaded counts can exceed these limits after restoration; this
delays further admissions without erasing any resident. AI and founder searches share 24 actions/second,
maximum one second of accrued work; entities are visited in serial round-robin
order. Recruitment spends one action every five active seconds, with no credit
banked while players are absent. It rotates players and eight search patches,
queries one 9x9x8 box through `find_nodes_in_area_under_air` (648 cells maximum),
and checks one round-robin surface candidate per attempt. This is at most 12
queries / 7,776 queried cells per active minute, not a whole-map scan. Habitat,
body-envelope and ordinary AI observations are additional bounded work, not
included in that terrain-query cell count. No query may emerge unloaded blocks.
An integration without this search API reports `no_surface`, not a guessed Y.
Candidates lie 12-24 horizontal nodes from the selected observer; any observer
within eight nodes vetoes admission. Ground/plant surfaces set the body base
half a node above actual support, not at the player's Y. Water surfaces retain
their own water-cell Y and use the stand above water for succession observations.
The fixed vertical search band is player Y minus four through plus three;
distant cliffs outside this band wait for a closer observer.
One plant-food observation and one succession habitat observation are shared
across cheap rotating catalogue matches. Only currently active, habitat/food-fit,
uncapped species receive body-safety/admission checks. There are no 23 separate
terrain scans or a forced 11.5-minute wait for the next eligible species.
Daylight flowering yarrow can admit a bee, mixed understory a bank vole; nocturnal species
remain nocturnal and predators still require represented fruit or actual prey.
Qualitative herb seeding seasons are not an implemented food budget: only
oak/hazel nuts are recognized as seeds, matching succession's `granivore` debit.
Founder admission does not call `can_breed`, require a completed reproductive profile,
or fabricate an age/maturity threshold for unknown biology.
Unadopted, unmarked, supported worldgen understory is collision-passable without
pretending it was already adopted. At a recruitment candidate, at most nine
ground-level cells use guarded `diversity.bootstrap` (when available). The three
legacy meadow-grass/fern/wildflower sprites receive only `succ_natural` metadata
under the same loaded, natural-support and provenance checks. This closes the
gap between succession's 12-node adoption ring and the wildlife search ring.
No botanical birthday/profile, forage ration, inventory item or plant node is
created. Unknown placement metadata, named planter markers, claims, ambiguous
claims and external protection deny adoption. Botany's presence does not hide
legacy grass/fern browse. Every subsequent meal still requires the existing
finite succession transaction; merely discovering or adopting a plant grants no
nutrition. Existing saved animals activate before the first observer joins,
without bootstrap, safe/room rejection or a current-food requirement; unsafe
terrain freezes subsequent movement/transactions rather than destroying them.
Founders are initialized only as loaded habitat is explored near an observer;
unexplored regions have not yet been instantiated. This unavoidable lazy
initialization is not recurring immigration when walking around a seeded region.
There is no startup world-wide spawn, force-loading or offline execution.
Per-frame safety/velocity checks are bounded per loaded entity and do not count
as AI decisions. Restoration may exceed 64 loaded objects; the shared AI action
ceiling remains 24/second, but per-frame callbacks/list sorting are not a measured
constant CPU bound for an arbitrarily large restored population.
### Absent-animal diagnosis and focused regression
Before this correction, a single hardcoded candidate at player Y was tried for
one species every 30 seconds. All 23 species therefore took 690 seconds (11.5
minutes) to revisit. Slopes failed body support, and unadopted sprites both
blocked clearance and failed food recognition outside succession's adoption
ring. With botany loaded, legacy meadow grass/fern also lost the fallback browse
path. These were admission/resource blockers, not a missing adult-age privilege:
this runtime never gated immigration on known maturity or `can_breed`. The old
danger comparison used squared distance 16 (four nodes); it now uses 64 (eight).
The focused wildlife suite now has 57 named checks. It adds repeated physics steps across AI intervals, approach to reachable food, fine-grained collision sweep, solar activity and dormancy, danger/hunger while resting, winter browse-mode negotiation, and sourced hare/fox reproductive timing.
## Movement and art
Every fixed ID uses `hearth_fauna_<id>.b3d` and `hearth_fauna_<id>.png`.
Five actual ObjectRef animation calls select idle 0-19, locomotion 20-39,
feed 40-59, flight/swim 60-79, rest 80-99, at 24 fps with 0.15-second blend.
The authored asset has 100 frames. Bounds were obtained from `faunaBounds()` in
the existing art generator (10 model units per node), including long fox tails,
squirrel tail height, bird wings and antlers. Eye heights have a 0.15 minimum.
Collision is based on those measured bind bounds; safety uses a yaw-independent
envelope and animation margin, checking bottom, middle and top, source,
destination and a swept path sampled at no more than 0.2 nodes (at most 20
intervals for a four-node segment). No `set_pos` teleport movement.
Velocity ramps at at most 2 nodes/second squared up to a 1.3 nodes/second ceiling;
each command has a 2.5-second lease, exceeding the two-second AI interval.
Safety stops may be abrupt. Land,
low flight, submerged swimming and moist-land invertebrate corridors differ;
amphibians/otters/mallards may inhabit either land or water but **shore transitions
are deliberately refused** until swept bank-ramp traversal is implemented.
Flight can move vertically within a three-node natural-ground corridor.
Unloaded, claimed, ambiguous, protected, building, unsupported and wrong-medium
samples stop movement. This is conservative local movement, not full navigation,
arboreal climbing, digging, seasonal migration or fluid dynamics. Gravity is held
at zero inside these validated corridors to avoid uncontrolled cliff falls.
The earthworm surface speed is the reported 20 metres/hour, not a mammal sprint.
Species activity differs: local solar day/night schedules, twilight activity,
winddown/wakeup transitions, short rest windows for flexible activity profiles,
and winter dormancy for hedgehogs, frogs and newts. Danger is processed before
rest and loaded hunger continues during sleep; dormancy never removes an animal.
Resource search checks a fixed 5x5 horizontal area and follows a reachable food
candidate rather than requiring it beside the next AI tick. Winter browse accepts
only a non-toxic tagged tree/shrub and never implies human edibility. Wildlife
passes `winter_browse` to the shared forage hook, but the current succession
implementation does not accept that mode; winter twig/stem debiting is therefore
not active until a separately scoped succession change. Ordinary shared browse
continues to use its existing seven-day plant cooldown. Also represented are
danger flight and hedgehog stillness, alert pauses, nectar visits,
fruit feeding, nuts/caching, litter consumption and actual finite prey pursuit.
The source accounts describe many additional behaviors; descriptions are not
claims that burrows, dreys, antler shedding, caste systems or parenting are coded.
Art quality requires separate native-engine animation/visual acceptance.
## Research register and implemented nutritional web
Fetched Wikipedia identity/diet accounts (first eight plus earthworm/deer), and
Wildlife Trust/RSPB accounts for the remaining rows, on 2026-11-03. All Wikipedia
URLs are explicit identity links; generic deer is **Cervidae**, not a silently
chosen Latin binomial. Bank vole retains the lifecycle identity *Myodes glareolus*
(synonym *Clethrionomys glareolus* on the retrieved Wikipedia page). Prose below
is original summary; no source media or article text is bundled.
| ID | Identity / Wikipedia URL | Reviewed nutrition and represented behavior |
| --- | --- | --- |
| hare | [Lepus europaeus](https://en.wikipedia.org/wiki/Lepus_europaeus) | Grass/herbs, surface forms, nocturnal escape; represented browse and fox prey. |
| deer | [Cervidae, unidentified legacy deer](https://en.wikipedia.org/wiki/Deer) | Family-level foliage browsing only; no species timing asserted. |
| fox | [Vulpes vulpes](https://en.wikipedia.org/wiki/Vulpes_vulpes) | Hare/rabbit/vole/mouse/squirrel/worm/frog prey, seasonal berries; mostly nocturnal. |
| rabbit | [Oryctolagus cuniculus](https://en.wikipedia.org/wiki/Oryctolagus_cuniculus) | Grass/leaves; fox and stoat prey; no fictitious warren reproduction. |
| red_squirrel | [Sciurus vulgaris](https://en.wikipedia.org/wiki/Sciurus_vulgaris) | Seeds/nuts/fruit; diurnal, canopy-dependent establishment; separately funded oak/hazel cache. |
| bank_vole | [Myodes glareolus](https://en.wikipedia.org/wiki/Myodes_glareolus) | Leaves/seeds/fruit, needs cover; fox/stoat/weasel/owl prey. |
| wood_mouse | [Apodemus sylvaticus](https://en.wikipedia.org/wiki/Apodemus_sylvaticus) | Seeds/fruit and nocturnal covered movement; fox/stoat/weasel/owl prey. |
| hedgehog | [Erinaceus europaeus](https://en.wikipedia.org/wiki/Erinaceus_europaeus) | Finite earthworm prey, nocturnal/winter rest; badger prey. |
| badger | [Meles meles](https://en.wikipedia.org/wiki/Meles_meles) | Worms, rodents, hedgehogs, fruit; nocturnal. No adult rabbit edge: account emphasizes kittens. |
| roe_deer | [Capreolus capreolus](https://en.wikipedia.org/wiki/Capreolus_capreolus) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/mammals/roe-deer): buds/leaves/herbs/grass; delayed implantation not fabricated. |
| stoat | [Mustela erminea](https://en.wikipedia.org/wiki/Mustela_erminea) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/mammals/stoat): rabbit and small rodents; day/night hunting. |
| weasel | [Mustela nivalis](https://en.wikipedia.org/wiki/Mustela_nivalis) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/mammals/weasel): vole/mouse/small birds, low running. |
| otter | [Lutra lutra](https://en.wikipedia.org/wiki/Lutra_lutra) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/mammals/european-otter): salmonids/frogs/newts; aquatic pursuit. |
| robin | [Erithacus rubecula](https://en.wikipedia.org/wiki/Erithacus_rubecula) | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/robin): earthworms, seeds, fruit; hopping/flight. |
| blackbird | [Turdus merula](https://en.wikipedia.org/wiki/Turdus_merula) | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/blackbird): earthworms and seasonal fruit; ground feeding. |
| blue_tit | [Cyanistes caeruleus](https://en.wikipedia.org/wiki/Cyanistes_caeruleus) | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/blue-tit): seeds/nuts/berries; caterpillars are described but absent from simulated food supply. |
| barn_owl | [Tyto alba](https://en.wikipedia.org/wiki/Tyto_alba) | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/barn-owl): finite voles/mice, low hunting flight; no adult-rabbit edge. |
| mallard | [Anas platyrhynchos](https://en.wikipedia.org/wiki/Anas_platyrhynchos) | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/mallard): seeds/berries/plants; water-associated. |
| common_frog | [Rana temporaria](https://en.wikipedia.org/wiki/Rana_temporaria) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/amphibians/common-frog): invertebrates; only registered earthworms modeled; moist habitat/winter shelter. |
| smooth_newt | [Lissotriton vulgaris](https://en.wikipedia.org/wiki/Lissotriton_vulgaris) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/amphibians/smooth-newt): land worms, nocturnal moist habitat; aquatic prey stages unimplemented. |
| brown_trout | [Salmo trutta](https://en.wikipedia.org/wiki/Salmo_trutta) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/freshwater-fish/brown-trout): larvae/small fish/flying insects; no phantom biomass, saved trout are finite otter prey. |
| bumblebee | [Bombus terrestris](https://en.wikipedia.org/wiki/Bombus_terrestris) | [Wildlife Trust](https://www.wildlifetrusts.org/wildlife-explorer/invertebrates/bees-and-wasps/buff-tailed-bumblebee): legitimate nectar/pollen at active flowers; shared pollination/nectar transaction, not seed dispersal. |
| earthworm | [Lumbricus terrestris](https://en.wikipedia.org/wiki/Lumbricus_terrestris) | Natural dead leaves/mineral soil; moist nocturnal surface motion, finite litter debit. Wild lifespan unknown; captive six-year record is not a death deadline. |
Lifecycle's separately attributed numerical references also include
[ADW rabbit](https://animaldiversity.org/accounts/Oryctolagus_cuniculus/),
[ADW squirrel](https://animaldiversity.org/accounts/Sciurus_vulgaris/),
[ADW wood mouse](https://animaldiversity.org/accounts/Apodemus_sylvaticus/) and
[Woodland Trust bank vole](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/animals/mammals/bank-vole/).
Those evidence values retain field-level confidence; unsupported fields stay unknown.
## Focused acceptance
Standalone catalogue and wildlife tests use injected clocks and strict expected
named-test totals; no native engine, background game instance or offline event
replay is involved. They cover catalogue identity/evidence/copies, real-day age,
unknown breeding denial, seasons, JSON hostile payloads and roundtrip, all caps,
startup restore before players, all modeled trophic classes, single-use prey,
feeding cooldowns, protected source/destination stencils, shared floral/fruit
budgets, finite litter/nut authority, velocity bounds/no teleport, runtime
animation calls, cliff/water/unloaded refusal, and scheduler work caps.
The wildlife suite now defines **57 named checks**, including the movement,
activity, dormancy, browse-mode and sourced breeding regressions. These changes
were not executed in this pass. No native engine, full-repository test
run, real-world save or art files are changed. These tests validate mocked
runtime contracts, not native mapblock persistence, rendered young animals,
engine crash recovery or biological completeness; native integration remains
the main agent's responsibility.
+228
View File
@@ -0,0 +1,228 @@
# Calendar lifecycle foundation
## Scope and status
[The policy module](../mods/hearth/lifecycle.lua) returns a **plain, pure Lua namespace**. It does not load itself into `hearth`, call an engine API, read the clock, seed a global RNG, register plants/entities/callbacks, or write metadata, inventories, storage or files. It has no dependencies. [Content integration](../mods/hearth/content.lua) explicitly assigns its return to `hearth.lifecycle` **before botany, ecology and succession are constructed**. **No engine was launched for this integration pass.**
This is **not complete biological simulation**. [Current succession](../mods/hearth/succession.lua) now separates persistent calendar root ages from bounded loaded work. [Current wildlife](../mods/hearth/wildlife.lua) registers 23 animated animal identities with persisted epoch ages; it loads the fixed [fauna factory](../mods/hearth/fauna.lua) when needed. Content publishes that **exact returned catalogue** as `hearth.fauna`, not another independent instance. Six evidence-backed profiles enable guarded reproduction; other incomplete profiles remain disabled. There are no accelerated adult births, lifespan-endpoint deaths, force-loaded simulations or replayed offline meals/seeds/births. Movement, nutrition and timing fallbacks remain deliberately conservative game policies, not a true-life certification.
The runtime catalogue has **100 botanical taxa** and **23 animal identity slots**, including unresolved generic `deer`. Botanical groups are intentionally uneven: **13 trees, 14 shrubs, 10 grasses, 22 herbs, 11 ferns/mosses, 10 fungi, 10 aquatic taxa and 10 lichens**. The twenty additions extend actual ecological roles rather than padding every category to ten. **Every profile now has `pending=false`, reflecting actual registration, not evidence confidence.** The original ten trees and twenty additions carry Latin names in lifecycle; remaining identities come from botany, not invented alternative taxonomy. Actual cross-module identities and all tree-stage IDs, including multi-underscore names, are covered by the focused content/botany tests.
## Units, stages and uncertainty
- `SECONDS_PER_DAY = 86400`: one real elapsed day is always 86,400 seconds. No speed multiplier, growing-season compression, nutrient acceleration or session-time substitution exists.
- `DAYS_PER_YEAR = 365.2425` is a mean Gregorian-year conversion convention, **not a biological constant or a calendar-anniversary calculator**. Month gates take an explicit UK/NW-European month from the caller; no timezone or hemisphere is inferred.
- Every duration is either **`false` (unknown/not applicable)** or a range `{min, max, confidence, source_url, note, statistic}` measured in days. `birth_count` has the same range shape but represents counts, not days. Unknown is never represented by zero or infinity.
- `germination_days` is the policy age at emergence from seed/propagule. It includes a broad waiting allowance in fallback profiles, but is not a measured stratification model. Spores, vegetative recruitment, seed banks and dormancy need species-specific treatment later.
- `juvenile_days` is the **absolute age threshold ending early dependency/seedling establishment**, not a duration to add after germination. Animal notes identify weaning or independence, which are not reproductive maturity.
- `maturity_days` is absolute reproductive-age policy, not full height, timber harvest age, fledging, metamorphosis or an entitlement to breed. Endpoints sometimes span different sexes or different milestones; read `statistic` and `note`.
- `lifespan_days` is contextual longevity information: an average, typical range, or upper-record envelope. It is **never a mandatory death deadline**, a survival curve, or proof that an organism of that age is dead. Colony/genet ages are not stem or fruitbody ages.
- `life_cycle` is `annual`, `biennial`, `perennial`, or `not_applicable`. Most group defaults are explicitly **unverified perennial policy**, not verified species habit. Fungi have `not_applicable`; fern/moss/lichen propagule stages are deliberately coarse. Foxglove and garlic-mustard second-season flowering have evidence. Teasel's biennial representation remains a labelled fallback.
- All profiles, nested ranges/evidence, lists and resolved selections are fresh copies. Altering a returned profile never changes the module's catalogue.
`confidence = partial_species_evidence` means **some** fields have evidence, not that every number is researched. Check each range's own confidence and each `evidence[field]`. `source_reported` means the referenced page actually reported the observation reviewed on **2026-11-03**, not scientific consensus, an exact constant, or independent primary-literature validation. `policy_fallback` means an unverified modelling window. A source on a profile does not validate its fallback germination/maturity numbers. `source_url = false` is intentional where no supporting source is claimed.
### Unverified group baselines
These are broad, non-accelerated **policy placeholders**, not sourced species ranges. They should not authorize reproductive events or automatic mortality. `d` = real days; `y` = mean Gregorian years.
| Group | Germination/establishment | Seedling/dependency end | Maturity | Longevity context |
| --- | --- | --- | --- | --- |
| trees | 14–365 d | 1–5 y | 10–60 y | 60–500 y |
| shrubs | 14–365 d | 1–2 y | 2–10 y | 5–80 y |
| grasses | 7–90 d | 30–90 d | 90–365 d | 2–10 y |
| herbs | 7–90 d | 90–365 d | 365–730 d | 2–10 y |
| ferns/mosses | 30–730 d | 1–2 y | 2–5 y | unknown |
| fungi | unknown | unknown | unknown | unknown |
| aquatic | 7–90 d | 90–365 d | 365–730 d | unknown |
| lichens | unknown | 1–5 y | unknown | unknown |
No uniform group window can describe all species, propagules, sites or genets correctly. In particular these windows do not turn water-starwort into a verified perennial, a fungal fruitbody into a seedling, or all lichens into organisms with a known sexual maturity. Species-specific verification is still needed.
### Ten tree profiles with species-reference review, plus three fallback trees
The playable tree order now contains thirteen IDs. `crab_apple`, `wild_cherry` and `field_maple` use the labelled group baseline (14–365 days emergence, 1–5 years seedling/dependency, 10–60 years maturity, 60–500 years longevity context), **not species-researched numeric timings**. The ten profiles below retain their separately reviewed evidence. An identity source does not upgrade a fallback threshold.
All ten inherit the fallback germination and seedling windows above. **Only oak, beech and rowan currently have accepted source-reported reproductive-age fields.** All other maturity windows below are explicitly unverified policy; do not describe the example birch/alder ages as verified facts.
| ID / species | Maturity window, years | Longevity context, years | Actual reviewed evidence and limits |
| --- | --- | --- | --- |
| `oak` / *Quercus robur* | around 40 | 500–1000 envelope | [Forestry England](https://www.forestryengland.uk/article/english-oak) reports acorns usually around 40; [Wikipedia](https://en.wikipedia.org/wiki/Quercus_robur) describes survivors exceeding 500 and some exceeding 1000. Neither is a death schedule. |
| `birch` / *Betula pendula* | **fallback 10–20** | typical 60–90 | [Trees for Life](https://treesforlife.org.uk/into-the-forest/trees-plants-animals/trees/birch/birch-facts/) covers both silver and downy birch and notes exceptional ages of 150 or more. No numeric maturity accepted. |
| `pine` / *Pinus sylvestris* | **fallback 15–30** | typical 150–300 | [Wikipedia](https://en.wikipedia.org/wiki/Pinus_sylvestris) distinguishes normal longevity from much older records. Cone ripening time is not tree reproductive age. |
| `willow` / *Salix alba* | **fallback 5–15** | **fallback 60–120** | [Wikipedia](https://en.wikipedia.org/wiki/Salix_alba) supports fast growth, comparatively short life and dioecy; the numerical windows here are not sourced. |
| `alder` / *Alnus glutinosa* | **fallback 10–20** | composite 60–160 | [Woodland Trust](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/british-trees/a-z-of-british-trees/alder/) says approximately 60; [Wikipedia](https://en.wikipedia.org/wiki/Alnus_glutinosa) reports up to 160. This is a composite envelope, not a measured typical range or a universal 100-year age. |
| `beech` / *Fagus sylvatica* | about 10 onset to 30 heavy crop | 300–500 envelope | [Wikipedia](https://en.wikipedia.org/wiki/Fagus_sylvatica) distinguishes small early seed quantities from heavy crops and undisturbed longevity from exceptional individuals. |
| `aspen` / *Populus tremula* | **fallback 10–20** | **fallback envelope 80–200** | [Wikipedia](https://en.wikipedia.org/wiki/Populus_tremula) supports upper longevity 200, but not the chosen lower endpoint. It also describes suckering and growth-rate changes, not a verified reproductive threshold. |
| `rowan` / *Sorbus aucuparia* | around 10 | 80–150 envelope | [Wikipedia](https://en.wikipedia.org/wiki/Sorbus_aucuparia) reports maturity around ten, often no older than 80 in warmer regions, with occasional 150-year trees. |
| `hazel` / *Corylus avellana* | **fallback 3–10** | **fallback envelope 60–80** | [Woodland Trust](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/british-trees/a-z-of-british-trees/hazel/) supports uncoppiced upper age 80, not lower age 60. Coppice stools can persist centuries; do not apply stem ages to a stool. |
| `spruce` / *Picea abies* | **fallback 20–40** | **fallback envelope 200–532** | [Wikipedia](https://en.wikipedia.org/wiki/Picea_abies) records a ring-dated 532-year individual, not a typical 200–532 lifespan. Millennia-old clonal roots are not ages for a new stem. |
Oak's 40-year approximate onset is represented as `{min=40y,max=40y}` to retain the source's single approximate value, not manufacture a research range. It is still an approximate observation. A real reproduction engine must consider location, weather, health, sex, pollination and seed production separately.
### Twenty added plant identities (historical policy contract)
| ID | Exact requested Latin name | Policy group |
| --- | --- | --- |
| `dandelion` | *Taraxacum officinale* | herbs |
| `daisy` | *Bellis perennis* | herbs |
| `red_clover` | *Trifolium pratense* | herbs |
| `birdsfoot_trefoil` | *Lotus corniculatus* | herbs |
| `ribwort_plantain` | *Plantago lanceolata* | herbs |
| `selfheal` | *Prunella vulgaris* | herbs |
| `knapweed` | *Centaurea nigra* | herbs |
| `oxeye_daisy` | *Leucanthemum vulgare* | herbs |
| `field_scabious` | *Knautia arvensis* | herbs |
| `teasel` | *Dipsacus fullonum* | herbs |
| `cow_parsley` | *Anthriscus sylvestris* | herbs |
| `garlic_mustard` | *Alliaria petiolata* | herbs |
| `honeysuckle` | *Lonicera periclymenum* | shrubs |
| `ivy` | *Hedera helix* | shrubs |
| `gorse` | *Ulex europaeus* | shrubs |
| `broom` | *Cytisus scoparius* | shrubs |
| `crab_apple` | *Malus sylvestris* | trees |
| `wild_cherry` | *Prunus avium* | trees |
| `field_maple` | *Acer campestre* | trees |
| `holly_fern` | *Polystichum setiferum* | ferns_mosses |
The ID `holly_fern` is retained exactly as requested; *Polystichum setiferum* is generally called soft shield-fern, so this ID must not be used to infer a different Latin species. These group assignments are policy categories, not research claims about precise growth rates. The new twenty are **not all fully researched**. [Garlic mustard](https://www.wildlifetrusts.org/wildlife-explorer/wildflowers/garlic-mustard) has a verified two-season habit with retained winter leaves; its numeric 365–730 day window remains policy, since second spring is not exactly day 365. [Existing foxglove](https://www.wildlifetrusts.org/wildlife-explorer/wildflowers/foxglove) likewise has second-year flower stems. [Teasel](https://www.wildlifetrusts.org/wildlife-explorer/wildflowers/teasel) supports seasonal flowers/seedheads, but that page was not accepted as numerical biennial timing evidence.
## Animal research coverage
The following are real species pages retrieved during implementation, not constructed citation placeholders. Each accepted diet/movement or numeric field retains its own URL and original short evidence note. Average lifespans below are **source-reported averages**, including sources that use a range for that label. They are not hard limits.
| ID / species | Reported average years | Accepted evidence / remaining limits |
| --- | --- | --- |
| `rabbit` / *Oryctolagus cuniculus* | 3 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/rabbit): grazing, warrens, 3–7 young. [ADW](https://animaldiversity.org/accounts/Oryctolagus_cuniculus/): gestation 30–37 d; weaning 22–31 d; year-round capability. **Maturity unknown:** eight-month prose conflicts with an embedded 730-day female value. Thirty days is gestation, not maturity. |
| `red_squirrel` / *Sciurus vulgaris* | 6 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/red-squirrel): tree movement, nuts/seeds, no hibernation. [ADW](https://animaldiversity.org/accounts/Sciurus_vulgaris/): 38–39 d gestation, 56–70 d independence, 296–365.2425 d composite maturity envelope; narratives/sex entries differ. Birth/litter months were **not** converted into conception months. |
| `bank_vole` / *Myodes glareolus* | 0.5–1.5 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/bank-vole): 3–5 young, fruit/nuts/insects. [Woodland Trust](https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/animals/mammals/bank-vole/): about 25 d weaning; female maturity six weeks, male eight (42–56 d sex envelope). Gestation unknown here. |
| `wood_mouse` / *Apodemus sylvaticus* | 1 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/wood-mouse): nocturnal climbing/caching, 4–8 young. [ADW](https://animaldiversity.org/accounts/Apodemus_sylvaticus/): 21–26 d gestation; 21 d weaning; 65/71 d male/female maturity entries. Breeding-month endpoint “early winter” is not encoded as an invented exact month. |
| `hedgehog` / *Erinaceus europaeus* | 2–3 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/european-hedgehog): nocturnal invertebrate feeding, winter hibernation; reproductive timing unknown. |
| `badger` / *Meles meles* | 5–8 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/european-badger): digging, setts, varied diet. January/February births do not establish conception/gestation timing; those fields remain unknown. |
| `roe_deer` / *Capreolus capreolus* | 7 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/roe-deer): July–August mating, delayed implantation, May–June births, 2–3 kids. Numeric maturity/gestation unknown. |
| `stoat` / *Mustela erminea* | 2–5 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/stoat): bounding, small-animal diet, 6–12 kits, summer mating followed by delayed implantation. No invented fixed gestation or exact summer months. |
| `weasel` / *Mustela nivalis* | 2 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/weasel): low running gait, rodent/bird diet, 3–6 kits. Numeric reproductive ages unknown. |
| `otter` / *Lutra lutra* | 5–10 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/mammals/european-otter): swimming, fish and aquatic prey. Young entering water at ten weeks is **not** reproductive maturity. |
| `robin` / *Erithacus rubecula* | unknown | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/robin): territorial resident behaviour, invertebrates/fruit, about 4–5 eggs. “A few years” and record ages were not turned into invented average bounds. |
| `blackbird` / *Turdus merula* | 3 | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/blackbird): ground feeding, 3–4 eggs, UK nesting March–August. Incubation/maturity not supplied by the reviewed page. |
| `blue_tit` / *Cyanistes caeruleus* | 3 | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/blue-tit): clinging/flight, caterpillars/seeds/nuts, UK nesting April–June. “Up to 16” does not establish a lower clutch bound, so no fabricated clutch range. |
| `barn_owl` / *Tyto alba* | unknown | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/barn-owl): low silent hunting flight and small-mammal prey; no accepted numerical lifecycle fields. |
| `mallard` / *Anas platyrhynchos* | unknown | [RSPB](https://www.rspb.org.uk/birds-and-wildlife/mallard): swimming/flight, varied diet, weather-dependent breeding. A February start and “up to 13” ducklings do not supply a complete breeding window or clutch range. |
| `common_frog` / *Rana temporaria* | 5–10 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/amphibians/common-frog): pond spawning, hopping, winter shelter; about 14 weeks **after hatch** to froglet (`larval_days=98`). Not incubation or sexual maturity. |
| `smooth_newt` / *Lissotriton vulgaris* | 6 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/amphibians/smooth-newt): aquatic breeding, nocturnal land feeding, winter shelter; precise reproductive timing unknown. |
| `brown_trout` / *Salmo trutta* | 15–20 | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/freshwater-fish/brown-trout): river swimming, insect/fish diet, external eggs in gravel. Its January–March statement was **not generalized** into a universal salmonid breeding window; local spawning/degree-day data still needed. |
| `bumblebee` / *Bombus terrestris* | unknown | [Wildlife Trusts](https://www.wildlifetrusts.org/wildlife-explorer/invertebrates/bees-and-wasps/buff-tailed-bumblebee): nectar/pollen, flight, underground colony, possible winter activity. ID explicitly models this species, not every bumblebee. Caste-specific life history is required; no single queen/worker lifespan is invented. |
| `earthworm` / *Lumbricus terrestris* | **wild lifespan unknown** | [Wikipedia](https://en.wikipedia.org/wiki/Lumbricus_terrestris): paired hermaphroditic cocoon reproduction, leaf litter and vertical burrows; at least one-year development with reproductive maturity in second year. Six years in captivity is not a wild lifespan. Generic earthworm ID explicitly represents this species only. |
[Existing `hare`](https://www.wildlifetrusts.org/wildlife-explorer/mammals/brown-hare) provisionally represents *Lepus europaeus*: 2–4 reported average years, grazing/bark feeding, surface forms, 2–4 leverets. [Existing `fox`](https://www.wildlifetrusts.org/wildlife-explorer/mammals/red-fox) represents *Vulpes vulpes*: 2–3 reported average years, omnivorous diet and territorial/nocturnal behaviour. **Existing generic `deer` is not silently mapped to `roe_deer`:** Latin identity and every timing field remain unknown.
The table above records the initial research baseline; **the reproductive additions below supersede its unknown gestation/incubation/maturity statements where explicitly listed**. No precise meal frequency or species metabolic model is claimed. Qualitative diet/movement descriptions do not establish numeric physiology.
### Reproductive implementation update (2026-11-03)
Six profiles now set `reproduction_enabled=true`: **bank vole, wood mouse,
blackbird, mallard, European hare and red fox**. All other 17 remain disabled,
not silently completed.
- [European hare ADW](https://animaldiversity.org/accounts/Lepus_europaeus/): maturity 8 months to one year, 30-42 d gestation, about one-month weaning, 1-8 leverets, and breeding from midwinter through midsummer. Runtime uses the conservative 365 d maturity, 42 d gestation and one young; Winter/Spring/Summer is a coarse northern-season mapping and the 30 d care lock is policy.
- [Red fox ADW](https://animaldiversity.org/accounts/Vulpes_vulpes/): about 304 d maturity, typical 51-53 d gestation, 56-70 d nursing/weaning and winter mating in central-range populations. The account's prose count (1-13) differs from its summary table (1-9); runtime uses the narrower table and one pup. A 300 d postpartum lock approximates once-yearly breeding, as a policy. Paternal provisioning and den care are not simulated by the generic dependent-young model.
The [source and implementation register](fauna.md#time-provenance-and-deliberately-unknown-biology)
details values, source disagreements and policy limits.
- [Bank vole ADW](https://animaldiversity.org/accounts/Myodes_glareolus/): 17-24 d gestation,
20-25 d weaning, late April-September season. Existing 42-56 d maturity and 3-5
litter range retain their own Woodland Trust/Wildlife Trusts citations. Runtime
conservatively uses 56 d maturity, 24 d development, three young, 25 d care lock.
- [Wood mouse Wikipedia](https://en.wikipedia.org/wiki/Wood_mouse): February-October
breeding; this differs from ADW's March-early winter. Existing ADW 21-26 d
gestation, 65-71 d maturity and 21 d dependency remain independently attributed;
Wildlife Trusts 4-8 count is retained. Runtime uses 71 d, 26 d, four young and
a 21 d care lock, not the differing Wikipedia gestation range.
- [Blackbird ADW](https://animaldiversity.org/accounts/Turdus_merula/): first breeding
about one year; 13-day incubation prose versus 14-day summary; nest departure
13-14 d, not full independence. Runtime uses one mean Gregorian year and 14 d
incubation; existing RSPB count yields three young. Bird care lock is a labelled
conservative 42 d policy, not a measured reproductive interval.
- [Mallard ADW](https://animaldiversity.org/accounts/Anas_platyrhynchos/): 365 d maturity
both sexes, 26-28 d incubation, 9-13 eggs. [Wikipedia](https://en.wikipedia.org/wiki/Mallard)
supplies 50-60 d to flight and spring nesting. Runtime uses 365 d, 28 d, nine
young; conservative care lock 81 d. Unknown lifespan is **not** a breeding veto.
- [Blue tit BTO diary](https://www.bto.org/understanding-birds/articles/blue-tit-diary):
majority 8-10 eggs, about 14 d incubation, 18-21 d fledging, first breeding the
following spring. Exact maturity day and caterpillar provisioning remain
unresolved, so reproduction stays disabled. ADW's Cyanistes page was missing;
Parus yielded taxonomy only. BTO BirdFacts extraction redirected to an audio
embed; no numerical claim is attributed to that failed extraction.
- [Common frog Wikipedia](https://en.wikipedia.org/wiki/Common_frog): about three-year
sexual maturity, 14-21 d egg incubation. Its 98 d post-hatch larval observation
remains distinct. Eggs/tadpoles/feeding are not represented: still disabled.
- [Brown trout Wikipedia](https://en.wikipedia.org/wiki/Brown_trout): approximately
2000 eggs/kg, not a fixed litter; female mass, gravel redd and temperature-based
development are absent. [Bumblebee](https://en.wikipedia.org/wiki/Bombus_terrestris)
needs caste-specific colony/larva/pupa care. Both stay disabled.
Every added numeric range has field-level source evidence. Spring/Summer breeding
seasons, conservative upper endpoints, minimum litter counts, reserve costs and
postpartum locks are explicitly **simulation policies**, not new source-reported
physiological precision. `fauna.can_breed` uses `hearth.climate_at(utc).season` or
an explicit season, so southern spring is not vetoed by northern reference months.
Unknown climate denies conception. Reproduction requires two mature, healthy,
recently fed, same-species opposite-sex animals, real loaded food-bearing habitat
and space. Lifespan completeness is intentionally unnecessary.
Wildlife persists one pregnancy/incubation and its partial-delivery counters.
Overdue broods can finish once, after safe loaded habitat/caps/energy permit;
there is no offline replay of missed conceptions or multiple litters. One young
per additional AI action shares the 24-action budget. Newborn age starts at actual
admission, never at an invented adult cohort age. Dependent young rest and can
receive debited maternal reserve. This does not claim complete nest, lactation,
species-specific chick diet, mortality, genetic parentage or crash-atomic saving.
Sources were fetched and read, including failed/empty candidates. USDA FEIS requests returned HTTP 403; several guessed species/older bird subpages were missing or unextractable. **Those failures are not citations and contributed no numerical overrides.** Wikipedia and ADW are secondary educational accounts with their own limitations; no downloaded article text or images are bundled here, only original notes and factual references. ADW's rabbit conflict is explicitly retained as an uncertainty, not resolved by cherry-picking a maturity number.
## Exact API for the main integration owner
Content now loads the fixed sibling with `dofile` and explicitly assigns its returned table to `hearth.lifecycle` before constructing any biological consumer. There is no factory argument and no implicit global publication. Wildlife's returned `fauna` is exposed unchanged. `/botany` displays the 100 taxa over ten pages; `/fauna` is an independent observational catalogue with identities, source URLs, field confidence and UNKNOWN values. The plant-only article reader is unchanged.
| Call | Return / contract |
| --- | --- |
| `plant(id, group?)` | Fresh plant profile or `nil, reason`. Bare exact ID only; optional group must match the fixed snapshot. Unknown ID plus a group hint **does not invent a species**. Canonicalize node/stage/season names through botany before calling. |
| `animal(id)` | Fresh animal profile or `nil, reason`. Exact bare ID; no `deer` → `roe_deer` alias. |
| `ids("plant" or "animal")` | Fresh sorted ID list (100 / 23); invalid kind returns `nil, "kind"`. |
| `duration(range, seed, salt)` | Deterministic number in inclusive range, in the range's units. Seed integer 0–2147483646; salt string at most 64 bytes; finite nonnegative bounds with `max <= 1000000`. Invalid inputs return `nil, reason`. Selection is continuous, not a claim of a measured uniform distribution. |
| `resolve(profile, seed)` | Fresh profile retaining original ranges/provenance, plus `selected_days`, `selection_seed`. Uses species/field salts; overlapping germination/dependency/maturity/lifespan selections are clamped upward to preserve stage order, never outside a range. Invalid/impossible ordering returns `nil, reason`. Does not sample counts or month lists. |
| `elapsed(previous, now, work_cap_seconds)` | `{elapsed_seconds, work_seconds, deferred_seconds}`; **full** calendar delta plus a separate capped work allowance. No age is discarded. Cap zero is valid. Invalid inputs return `nil, "clock"` or `nil, "cap"`; reversal returns `nil, "clock_reversed"`. Does not loop through missed events. |
| `phase(profile, born_at, now)` | Observation `{phase, age_days, age_seconds, beyond_lifespan_range, death_due=false, reproductive_ready=false, confidence}` or `nil, reason`. All times are caller-supplied epoch seconds; no clock reads. |
| `seasonal(profile, season)` | `{dormant, death_due=false, confidence="policy_fallback"}` for explicit `Spring`, `Summer`, `Autumn`, `Winter`. Winter dormancy is presentation policy, not a frost/survival calculation; some evergreen-policy profiles retain foliage. |
| `breeding_window(profile, month)` | `{known, within_window, eligible=false}`. Numeric integer month 1–12 only. Unknown window is **not** all-year permission. Known windows here are rabbit capability, roe-deer mating, blackbird nesting and blue-tit nesting. No births are scheduled. |
Clock inputs must be finite numeric seconds in `[0, 9007199254740991]`; strings, NaN, infinities, negatives and larger values fail closed. Fractional seconds are allowed. Equality has zero elapsed age. `now < born_at` and reversed elapsed clocks are explicit errors, not a silent age reset. Callers must also detect reversals relative to a saved `last_observed_at`: this stateless module cannot remember a previously observed future timestamp. Very large valid ages are handled with constant work, not an age-proportional replay loop.
Without resolution, `phase` uses each range's **upper endpoint**; it never redraws durations while reading. Resolved samples can be persisted by the main owner with the policy version so later reads are reproducible. Stages are `seed`, `seedling`, `dependent`, `juvenile`, `mature`, `unknown`. A known dependency threshold can classify an animal as `dependent` even when its sexual maturity is unknown; after dependency ends its phase is `unknown` until a maturity field exists. `beyond_lifespan_range` is informational, never `dead`. Six explicitly enabled animal profiles authorize wildlife reproduction, subject to runtime pairing/resource/season/space checks. **Do not bypass that gate by reading `phase == "mature"`.** The pure observation still returns `reproductive_ready=false` because it cannot know those runtime conditions.
### Runtime persistence, resource transactions and remaining boundaries
1. **Root epochs:** succession persists `succ_birth_at`, separate `succ_initial_age_seconds`, `succ_last_observed_at`, `succ_lifecycle_version=1`, exact profile/seed/origin and `calendar_v1_conservative_max` policy. Natural recruits start at zero. Stage swaps retain the root anchor; components refer to that root. The full contract is in [botany](botany.md#root-persistence-and-migration).
2. **Established assumptions:** legacy natural roots adopt `legacy_established` at the current epoch with explicit stage-based initial age; worldgen stands use `worldgen_established`. These are documented generation/adoption assumptions, not historically observed ages. `succ_age` is not reinterpreted as years or a birth epoch. Unknown thresholds do not invent age.
3. **Full age, bounded work:** calendar age includes the entire unloaded interval. Reconciliation is limited to loaded, eligible natural roots/entities; no force loading, whole-world scan, missed-event queue or offline replay. Invalid metadata/versions, future clocks and rollback fail closed. Cold/dormancy can defer visible stages, never subtract age.
4. **Uncertainty:** source-reported ranges carry field evidence; fallback ranges remain unverified policy; false/not-applicable remains UNKNOWN. Succession uses conservative upper endpoints, not nutrient acceleration. Named trees require their real year/decade thresholds; fungi do not shorten seedling age by 25%. Unknown fungal/lichen maturity does not authorize seeding. Nitrogen-fixer eligibility starts at 90 calendar days; saprotrophic decay bonuses are finite and conserve substrate nutrients.
5. **Shared fruit:** humans and animals debit exactly `botany_ready_at`. A successful botanical harvest locks for `365.2425 * 86400` seconds, not 24 hours; a valid fruit season is still required. No accumulated missed-year payout. The initial existing crop is usable. This is conservative annual resource policy, not researched species phenology or organism lifespan. Animal feeding discards the rest of the batch and yields no inventory.
6. **Pollination credit:** succession reserves `succ_nectar_ready_at = now + 3600` and at most one `succ_pollinated_until = now + 86400` credit, not a stacking counter. Human harvest accepts only finite nonnegative pollen expiry at most one day ahead; a credit is live exactly when `expiry > now`. It adds **one** berry to the existing two-berry crop, never doubles inventory, bypasses fruit readiness or creates seeds. Both budgets are reserved before inventory callbacks. No-space/full-leftover rejection restores both original values; any successful partial transfer spends the batch and credit once. Invalid delivery results retain the lock to prevent unsafe retries.
7. **Fauna:** saved version-1 JSON retains epoch `born_at`, explicit `origin`, sex/seed and observation high-water clocks plus bounded reproductive counters. Establishment is immigration, not an adult birth. Unload advances age but creates no meals, dung or missed mating cycles. An already-persisted overdue brood may finish once on loaded budgeted ticks. Six profiles breed; nests, aquatic larvae, caste/cocoon development and delayed implantation remain incomplete. Numeric mean/record longevity is never an automatic death deadline. Hedgehog/frog/newt winter dormancy is a retained-resident policy, not death or a physiological metabolism model. See [fauna limits and animations](fauna.md).
8. **Seasonal presentation:** winter textures are not death evidence. Biennial second-season flowering, perennial dormancy, annual seed completion and colony/fruitbody age require distinct policies. No complete climate-survival, migration, historical degree-day or mortality model is claimed. No version bump, existing-world launch or save rewrite was performed for this integration.
No credentials, embedded certificates, runtime HTTP, dynamic evaluation or security-sensitive random identifiers are introduced. The deterministic selector is explicitly **not cryptography**. Defensive input bounds and copied read results are the relevant CodeGuard protections here.
## Focused validation
The earlier content integration ran the focused [content](../tests/content_spec.lua), [botany](../tests/botany_spec.lua), [journal/recipe](../tests/recipe_spec.lua), [character](../tests/character_spec.lua) and [species-effects](../tests/species_effects_spec.lua) suites. Checks use actual sibling factories, exact catalogue/schema counts and known UTC epochs. Require exact named counts, final markers and empty stderr: Fengari exit status alone can mask assertion failures. No engine, world, wiki-cache or art-generator changes are part of this pass. Native rendering, skeletal animation acceptance and full integrated validation remain with the main owner. The [lifecycle suite](../tests/lifecycle_spec.lua) now checks the actual 100-entry catalogue, all 23 registration flags and the updated reproductive profiles; all original 31 named contracts are retained and reconciled, not dropped.
Earlier content-integration results: **40 content, 44 botany, 52 journal/recipe, 19 character and 8 species-effects checks (163 total)**. These are historical results, not rerun by this lifecycle change.
Updated specs now define **32 lifecycle, 10 fauna catalogue and 57 wildlife
checks (99 total)**. They were not executed in this pass; prior focused runs
are historical only. Earlier baseline notes reported **31 lifecycle, 9 fauna
catalogue and 31 wildlife checks**, plus 85 succession checks. Tests inject UTC epochs instead of
waiting for real gestation or accelerating ages. Coverage includes funded
pairing, denied juvenile/same-sex/unhealthy/missing mates, incubation, overdue
restoration, capped partial litter restart, unsafe/no-player delivery delays,
hostile pregnancy fields, callback reentry, dependent care and postpartum locks.
No full-engine or full-repository test run is claimed.
+38 -9
View File
@@ -2,16 +2,43 @@
## Shipped source coverage ## 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. The bundle follows **all 100 actual Latin names** in [the botany catalogue](../mods/hearth/botany.lua), not their sometimes ambiguous common names. The approved maintenance `--refresh` run on **2026-11-03 UTC** queried all 100 names using ten MediaWiki API batches of ten, with at most two requests in flight, using the absolute installed Node executable and only the hardcoded English Wikipedia API URL.
**78 entries contain real English Wikipedia plaintext introductions. Two entries contain original availability notes, not Wikipedia quotations:** **98 entries contain real English Wikipedia plaintext introductions (78 previous + 20 new). Two entries contain original availability notes, not Wikipedia quotations:**
| Game ID | Exact catalogue Latin name | Source status | | Game ID | Exact catalogue Latin name | Source status |
| --- | --- | --- | | --- | --- | --- |
| `old_mans_beard` | *Usnea subfloridana* | [Exact species page](https://en.wikipedia.org/wiki/Usnea_subfloridana) missing | | `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 | | `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. For both missing species, the refreshed API explicitly returned `missing: true`; separate exact-page and REST-summary checks in the previous 80-species maintenance run returned HTTP 404. No genus, species complex, similarly named lichen, invented revision, or synthetic Wikipedia prose was substituted. Consequently **100 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. All previous 78 introduction texts and two availability-note texts were preserved exactly (verified by matching SHA-256 over ordered ID/text/kind records before and after refresh); retrieval timestamps were refreshed from actual batch completion times.
### Twenty newly cached species references
All twenty new exact catalogue names resolved directly to the same Wikipedia title, without guessed aliases. Each entry contains an actual plaintext introduction, positive page/revision IDs, the API revision timestamp, actual retrieval timestamp, contributor attribution/history, and CC BY-SA 4.0 licensing. Retrieval occurred between **2026-11-03T19:54:51.978Z** and **2026-11-03T19:54:52.400Z**; per-entry timestamps are retained in the bundle. None of these additions required an availability note or shortening.
| Game ID | Exact catalogue Latin name / Wikipedia source title | Page ID | Permanent source revision |
| --- | --- | --- | --- |
| `crab_apple` | *Malus sylvestris* | 5958504 | [1380105044](https://en.wikipedia.org/w/index.php?oldid=1380105044) |
| `wild_cherry` | *Prunus avium* | 28331736 | [1380998001](https://en.wikipedia.org/w/index.php?oldid=1380998001) |
| `field_maple` | *Acer campestre* | 437923 | [1366038360](https://en.wikipedia.org/w/index.php?oldid=1366038360) |
| `honeysuckle` | *Lonicera periclymenum* | 811057 | [1354922261](https://en.wikipedia.org/w/index.php?oldid=1354922261) |
| `ivy` | *Hedera helix* | 5372245 | [1382798111](https://en.wikipedia.org/w/index.php?oldid=1382798111) |
| `gorse` | *Ulex europaeus* | 2482094 | [1382611269](https://en.wikipedia.org/w/index.php?oldid=1382611269) |
| `broom` | *Cytisus scoparius* | 510118 | [1351345770](https://en.wikipedia.org/w/index.php?oldid=1351345770) |
| `dandelion` | *Taraxacum officinale* | 10783290 | [1371025491](https://en.wikipedia.org/w/index.php?oldid=1371025491) |
| `daisy` | *Bellis perennis* | 1157853 | [1375045201](https://en.wikipedia.org/w/index.php?oldid=1375045201) |
| `red_clover` | *Trifolium pratense* | 1900302 | [1371055360](https://en.wikipedia.org/w/index.php?oldid=1371055360) |
| `birdsfoot_trefoil` | *Lotus corniculatus* | 1117322 | [1379332042](https://en.wikipedia.org/w/index.php?oldid=1379332042) |
| `ribwort_plantain` | *Plantago lanceolata* | 1250244 | [1377190915](https://en.wikipedia.org/w/index.php?oldid=1377190915) |
| `selfheal` | *Prunella vulgaris* | 12058348 | [1380124722](https://en.wikipedia.org/w/index.php?oldid=1380124722) |
| `knapweed` | *Centaurea nigra* | 21631605 | [1366977479](https://en.wikipedia.org/w/index.php?oldid=1366977479) |
| `oxeye_daisy` | *Leucanthemum vulgare* | 567709 | [1381123379](https://en.wikipedia.org/w/index.php?oldid=1381123379) |
| `field_scabious` | *Knautia arvensis* | 12057909 | [1371147715](https://en.wikipedia.org/w/index.php?oldid=1371147715) |
| `teasel` | *Dipsacus fullonum* | 4298215 | [1375022541](https://en.wikipedia.org/w/index.php?oldid=1375022541) |
| `cow_parsley` | *Anthriscus sylvestris* | 2649495 | [1375096601](https://en.wikipedia.org/w/index.php?oldid=1375096601) |
| `garlic_mustard` | *Alliaria petiolata* | 509457 | [1375930171](https://en.wikipedia.org/w/index.php?oldid=1375930171) |
| `holly_fern` | *Polystichum setiferum* | 5025872 | [1296456929](https://en.wikipedia.org/w/index.php?oldid=1296456929) |
Five catalogue names resolved through Wikipedia's own title redirects in this snapshot: Five catalogue names resolved through Wikipedia's own title redirects in this snapshot:
@@ -34,7 +61,7 @@ These are API-reported redirects, not guessed mappings or search results. The ca
- `/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` 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. - `/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. - 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. - The **Article** tab displays **Wikipedia excerpt (offline)** for the 98 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. - **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. - 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. - 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.
@@ -75,7 +102,7 @@ For an unavailable species, `kind = 'original_notes'`, `excerpt = false`, and `t
- 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. - 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. - 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`. The shipped 98 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 ## What the real sources support about effects
@@ -98,7 +125,7 @@ Existing game niche thresholds, nutrient caps, timing, protection rules, edibili
[scripts/wiki-species.mjs](../scripts/wiki-species.mjs) uses Node.js 22+ built-ins only. Arguments are deliberately limited to exactly one of: [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. - `--refresh`: read the fixed botany catalogue, require exactly 100 literal rows, query all 100 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`. - `--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. - `--help`: print maintenance usage, without reading articles or making requests.
@@ -107,7 +134,7 @@ Invoke Node with this script path and one of those flags. There are **no URL, ti
### Security and failure behavior ### 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. - 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. - Scope: at most 100 records in fetcher/serializer/parser helpers, ten titles per batch, two concurrent batches. A complete normal refresh makes **ten 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. - 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. - 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. - 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.
@@ -124,6 +151,8 @@ The focused reader run passed **19 reader/HUD checks**, **43 recipe/journal chec
[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. [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. The verified focused Node run has **21 passing tests, 0 failures, 0 real network requests, and 35 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 ten mocked batches at concurrency two, rejection of 99-row catalogues and 101-record fetch/serialized inputs, all twenty new exact identities, offline CLI checks, and byte-for-byte cache preservation after an HTTP failure. The separate approved `--refresh` above did use the network; tests and `--check` do not.
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. A separate isolated Fengari execution of the actual current botany source compared every runtime `list()` ID/Latin pair with the maintenance parser: **100/100 exact ordered matches**, empty stderr. Runtime group counts were **trees 13, shrubs 14, grasses 10, herbs 22, ferns/mosses 11, fungi 10, aquatic 10, lichens 10**; no equal-ten-per-category assumption was imposed. No catalogue or source-effects code was modified and no full-engine run was launched. Offline `--check` verified **100/100 species: 98 introductions, 2 original notes; network requests: 0**.
Snapshot assertions intentionally expect **98 Wikipedia excerpts (78 previous + 20 new) + 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 100.
+46 -39
View File
@@ -208,54 +208,60 @@ generation scripts/assets. Species ecological-stage textures/meshes remain
the ecosystem/art owners' separate contract. Dynamic Lua tile names must be the ecosystem/art owners' separate contract. Dynamic Lua tile names must be
explicitly enumerated by the art owner if its scanner cannot expand loops. explicitly enumerated by the art owner if its scanner cannot expand loops.
## Recommended v7 preset — NEW WORLDS ONLY ## Epic-scale landscape generator — NEW WORLDS ONLY
The following is a source-grounded **recommendation**, not an applied or New Last Hearth worlds use stock v7 as a cave/ore substrate and then the original
engine-playtested preset. Main should place it in game defaults and its `epic_terrain` Lua mapgen mod reshapes each freshly generated mapchunk with
launcher **new-world creation** path, preserving existing saved mapgen params. seeded, world-coordinate noise. The mod lives in the separate
The current launcher writes `mg_name=v7` and seed 731104, but does not yet emit `luanti-mapgenerator-epic` workspace repository and is bundled into the managed
these tuned noise values. Merely changing game defaults is not sufficient to Last Hearth runtime package. It does not copy Epic Terrain code, JSON density
prove that managed-launcher new worlds received them. functions, textures, or other assets. Its feature mapping is inspired by the
public Epic Terrain description/source, not a byte-for-byte or mathematical
port. The launcher applies the generator preset only when a world is first
created; it never rewrites saved map metadata or generated mapblocks.
```conf ```conf
mg_name = v7 mg_name = v7
water_level = 1 water_level = 32
mapgen_limit = 512 mapgen_limit = 4096
mg_flags = caves,nodungeons,light,decorations,biomes,ores mg_flags = caves,nodungeons,light,biomes,ores
mgv7_spflags = mountains,ridges,nofloatlands,caverns mgv7_spflags = mountains,ridges,nofloatlands,caverns
mgv7_mount_zero_level = 0 mgv7_mount_zero_level = 0
mgv7_np_terrain_base = 12, 65, (384, 384, 384), 82341, 5, 0.6, 2.0 mgv7_np_terrain_base = 12, 45, (900, 384, 900), 82341, 5, 0.6, 2.0
mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0 mgv7_np_terrain_alt = -8, 24, (900, 384, 900), 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_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_height_select = -4, 14, (1400, 320, 1400), 4213, 6, 0.7, 2.0
mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 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_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_mountain = -0.6, 1, (320, 280, 320), 5333, 5, 0.63, 2.0
mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 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 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_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 mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0
``` ```
Why these parameters: Design and source-grounded limits:
- Base/alternate terrain is lifted from stock offset 4 to 12, while retaining - Continentalness is shaped by 5100-node plate and 1750-node warped-shelf fields;
an accessible low-relief alternate surface. This reduces ocean dominance a protected 620-node radius around world origin gives first arrival a dry
without pretending noise can guarantee a dry, safe spawn. meadow, while distant islands, peninsulas and ocean shelves continue to vary
- Broad 384-node hills and 700-node mountain/river structure fit the current by seed.
small bounded world. Mountain density retains stock −0.6 bias; its longer - Broad uplift plus ridged mountain fields creates plains, alpine massifs and
vertical than horizontal spread favors grounded massifs over spiky rubble. high snow surfaces. A separate coast band uses a sharper elevation step for
Mount-height noise controls a density gradient, **not** an absolute peak cap. sea cliffs and indented shore profiles.
- Ridges retain v7's native water-level channels and canyon walls. Neither - Major and minor river corridors are carved using separate warped ridge fields.
arbitrary block decorations nor exaggerated height offsets manufacture Their water levels descend in short quantized steps, creating waterfalls;
cliffs. The river-width threshold remains the stock C++ value. lakes form in closed noise basins. This is intentionally stylized flow, not a
- 256-node climate fields make multiple coherent communities plausible inside global watershed/drainage solver, so a river need not reach the ocean in every
the ±512 limit; stock 1000-node climate fields can hide most variety there. seed.
Existing small-scale heat/humidity blend noises can remain stock. - `epic_terrain` keeps native v7 cave voids and ore nodes where possible, then
- Floatlands are explicitly disabled; native mountain noise can still produce writes its own soil, water, block-cube trees and understory. Native v7
small overhangs/disconnected remnants. This is stock v7, not a guarantee of decorations are disabled for newly created worlds so flora does not float
mathematically erosion-correct terrain. Dungeons are disabled so unknown above the reshaped land.
mapgen dungeon aliases do not create unrelated structures. - Epic Terrain's Minecraft source replaces a density-function graph and custom
surface/rivers rules. Luanti does not expose that graph, so this is a feature-
level parody implemented with the public `register_on_generated`/VoxelManip
path, not a direct port. It is not a physical erosion simulation.
Never call `settings:set_default`, change mapgen at runtime, rewrite existing Never call `settings:set_default`, change mapgen at runtime, rewrite existing
map metadata, delete chunks, or automatically regenerate a player's world. map metadata, delete chunks, or automatically regenerate a player's world.
@@ -280,12 +286,13 @@ decoration/schematic definitions. In particular v7 generates biomes, caves,
ordered ores, then decorations; biome transitions must not be assumed to ordered ores, then decorations; biome transitions must not be assumed to
extrude surface rock unchanged to the bottom of the map. 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 The native `test:seeds` gate creates eight disposable worlds, including seed
fresh disposable world with explicit saved preset verification, multiple seed 731104, and checks startup resources plus safe arrival; it is not a terrain
starts (including 731104), resource reachability, actual tree anchoring/media silhouette or biome-traversal test, and it does not require every seed to start
loading and representative valley/forest/alpine views. Do not test by opening on dry land at the origin. A representative coastal, valley, river and alpine
the user's persistent world. Neither performance nor safe spawning is certified visual survey is still needed before claiming those aesthetics. Never test by
by the standalone checks. opening the user's persistent world. Neither performance nor universal spawn
safety is certified by this preset or by the standalone checks.
Security review: no credentials, cryptography, certificates, network access, Security review: no credentials, cryptography, certificates, network access,
filesystem writes, deserialization or player-supplied executable input are filesystem writes, deserialization or player-supplied executable input are
+1 -1
View File
@@ -3,5 +3,5 @@ author = eom.dev
description = Make shelter. Stay warm and fed. Build a life that can endure. description = Make shelter. Stay warm and fed. Build a life that can endure.
allowed_mapgens = v7 allowed_mapgens = v7
mapgen = v7 mapgen = v7
first_mod = hearth first_mod = creatura
min_minetest_version = 5.18 min_minetest_version = 5.18
+9 -8
View File
@@ -6,21 +6,22 @@ movement_speed_walk = 4
movement_speed_jump = 6.5 movement_speed_jump = 6.5
movement_acceleration_default = 4 movement_acceleration_default = 4
movement_acceleration_air = 2 movement_acceleration_air = 2
# Creation defaults only: Luanti persists mapgen parameters in map_meta.txt. # Stock v7 is the substrate for the separately bundled original epic_terrain
# No runtime retuning or regeneration; managed existing worlds receive no overrides. # generator. Managed launcher enables that callback only when creating a world.
# Saved mapgen parameters remain engine-owned; no runtime retuning/regeneration.
mg_name = v7 mg_name = v7
water_level = 1 water_level = 32
mapgen_limit = 4096 mapgen_limit = 4096
mg_flags = caves,nodungeons,light,decorations,biomes,ores mg_flags = caves,nodungeons,light,biomes,ores
mgv7_spflags = mountains,ridges,nofloatlands,caverns mgv7_spflags = mountains,ridges,nofloatlands,caverns
mgv7_mount_zero_level = 0 mgv7_mount_zero_level = 0
mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0 mgv7_np_terrain_base = 12, 45, (900, 384, 900), 82341, 5, 0.6, 2.0
mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0 mgv7_np_terrain_alt = -8, 24, (900, 384, 900), 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_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_height_select = -4, 14, (1400, 320, 1400), 4213, 6, 0.7, 2.0
mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 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_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_mountain = -0.6, 1, (320, 280, 320), 5333, 5, 0.63, 2.0
mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 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 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_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0
+11
View File
@@ -0,0 +1,11 @@
on: [push, pull_request]
name: build
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: lint
uses: Roang-zero1/factorio-mod-luacheck@master
with:
luacheckrc_url: ""
+15
View File
@@ -0,0 +1,15 @@
max_line_length = 120
globals = {
"minetest",
"VoxelArea",
"creatura",
}
read_globals = {
"vector",
"ItemStack",
table = {fields = {"copy"}}
}
ignore = {"212/self", "212/this"}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 ElCeejo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+13
View File
@@ -0,0 +1,13 @@
# Bundled third-party dependency
This directory contains Creatura 0.2.10 from Luanti ContentDB release 34130.
It is bundled as the wildlife AI/pathfinding/flocking API dependency for Last Hearth.
- ContentDB: https://content.luanti.org/packages/ElCeejo/creatura/
- Upstream source: https://github.com/ElCeejo/creatura/tree/4eb507cf2433f0787691f560842deea79a1666f4
- License: MIT; original LICENSE is preserved alongside this notice.
- ContentDB archive: https://cdn.content.luanti.org/uploads/73b01eb441.zip
- Archive SHA-256: `f384a34e0ed6e03c52b6d6591566e6546a26776c5f7e6e7dce279e7993a4251e`
The archive hash was verified before extraction. No Animalia code, models,
textures, or sound files are included by this dependency.
+557
View File
@@ -0,0 +1,557 @@
--------------
-- Creatura --
--------------
creatura.api = {}
-- Math --
local abs = math.abs
local floor = math.floor
local random = math.random
local function clamp(val, min_n, max_n)
if val < min_n then
val = min_n
elseif max_n < val then
val = max_n
end
return val
end
local vec_dist = vector.distance
local function vec_raise(v, n)
if not v then return end
return {x = v.x, y = v.y + n, z = v.z}
end
---------------
-- Local API --
---------------
local function contains_val(tbl, val)
for _, v in pairs(tbl) do
if v == val then return true end
end
return false
end
----------------------------
-- Registration Functions --
----------------------------
creatura.registered_movement_methods = {}
function creatura.register_movement_method(name, func)
creatura.registered_movement_methods[name] = func
end
creatura.registered_utilities = {}
function creatura.register_utility(name, func)
creatura.registered_utilities[name] = func
end
---------------
-- Utilities --
---------------
function creatura.is_valid(mob)
if not mob then return false end
if type(mob) == "table" then mob = mob.object end
if type(mob) == "userdata" then
if mob:is_player() then
if mob:get_look_horizontal() then return mob end
else
if mob:get_yaw() then return mob end
end
end
return false
end
function creatura.is_alive(mob)
if not creatura.is_valid(mob) then
return false
end
if type(mob) == "table" then
return (mob.hp or mob.health or 0) > 0
end
if mob:is_player() then
return mob:get_hp() > 0
else
local ent = mob:get_luaentity()
return ent and (ent.hp or ent.health or 0) > 0
end
end
------------------------
-- Environment access --
------------------------
local default_node_def = {walkable = true} -- both ignore and unknown nodes are walkable
function creatura.get_node_height_from_def(name)
local def = minetest.registered_nodes[name] or default_node_def
if not def then return 0.5 end
if def.walkable then
if def.drawtype == "nodebox" then
if def.node_box
and def.node_box.type == "fixed" then
if type(def.node_box.fixed[1]) == "number" then
return 0.5 + def.node_box.fixed[5]
elseif type(def.node_box.fixed[1]) == "table" then
return 0.5 + def.node_box.fixed[1][5]
else
return 1
end
else
return 1
end
else
return 1
end
else
return 1
end
end
local get_node = minetest.get_node
function creatura.get_node_def(node) -- Node can be name or pos
if type(node) == "table" then
node = get_node(node).name
end
local def = minetest.registered_nodes[node] or default_node_def
if def.walkable
and creatura.get_node_height_from_def(node) < 0.26 then
def.walkable = false -- workaround for nodes like snow
end
return def
end
local get_node_def = creatura.get_node_def
function creatura.get_ground_level(pos, range)
range = range or 2
local above = vector.round(pos)
local under = {x = above.x, y = above.y - 1, z = above.z}
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
if get_node_def(above).walkable then
for _ = 1, range do
under = above
above = {x = above.x, y = above.y + 1, z = above.z}
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
end
end
if not get_node_def(under).walkable then
for _ = 1, range do
above = under
under = {x = under.x, y = under.y - 1, z = under.z}
if not get_node_def(above).walkable and get_node_def(under).walkable then return above end
end
end
return above
end
function creatura.is_pos_moveable(pos, width, height)
local edge1 = {
x = pos.x - (width + 0.2),
y = pos.y,
z = pos.z - (width + 0.2),
}
local edge2 = {
x = pos.x + (width + 0.2),
y = pos.y,
z = pos.z + (width + 0.2),
}
local base_p = {x = pos.x, y = pos.y, z = pos.z}
local top_p = {x = pos.x, y = pos.y + height, z = pos.z}
for z = edge1.z, edge2.z do
for x = edge1.x, edge2.x do
base_p.x, base_p.z = pos.x + x, pos.z + z
top_p.x, top_p.z = pos.x + x, pos.z + z
local ray = minetest.raycast(base_p, top_p, false, false)
for pointed_thing in ray do
if pointed_thing.type == "node" then
local name = get_node(pointed_thing.under).name
if creatura.get_node_def(name).walkable then
return false
end
end
end
end
end
return true
end
local function is_blocked_thin(pos, height)
local node
local pos2 = {
x = floor(pos.x + 0.5),
y = floor(pos.y + 0.5) - 1,
z = floor(pos.z + 0.5)
}
for _ = 1, height do
pos2.y = pos2.y + 1
node = minetest.get_node_or_nil(pos2)
if not node
or get_node_def(node.name).walkable then
return true
end
end
return false
end
function creatura.is_blocked(pos, width, height)
if width <= 0.5 then
return is_blocked_thin(pos, height)
end
local p1 = {
x = pos.x - (width + 0.2),
y = pos.y,
z = pos.z - (width + 0.2),
}
local p2 = {
x = pos.x + (width + 0.2),
y = pos.y + (height + 0.2),
z = pos.z + (width + 0.2),
}
local node
local pos2 = {}
for z = p1.z, p2.z do
pos2.z = z
for y = p1.y, p2.y do
pos2.y = y
for x = p1.x, p2.x do
pos2.x = x
node = minetest.get_node_or_nil(pos2)
if not node
or get_node_def(node.name).walkable then
return true
end
end
end
end
return false
end
function creatura.fast_ray_sight(pos1, pos2, water)
local ray = minetest.raycast(pos1, pos2, false, water or false)
local pointed_thing = ray:next()
while pointed_thing do
if pointed_thing.type == "node"
and creatura.get_node_def(pointed_thing.under).walkable then
return false, vec_dist(pos1, pointed_thing.intersection_point), pointed_thing.ref, pointed_thing.intersection_point
end
pointed_thing = ray:next()
end
return true, vec_dist(pos1, pos2), false, pos2
end
local fast_ray_sight = creatura.fast_ray_sight
function creatura.sensor_floor(self, range, water)
local pos = self.object:get_pos()
local pos2 = vec_raise(pos, -range)
local _, dist, node = fast_ray_sight(pos, pos2, water or false)
return dist, node
end
function creatura.sensor_ceil(self, range, water)
local pos = vec_raise(self.object:get_pos(), self.height)
local pos2 = vec_raise(pos, range)
local _, dist, node = fast_ray_sight(pos, pos2, water or false)
return dist, node
end
function creatura.get_nearby_player(self, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
for _, object in ipairs(objects) do
if object:is_player()
and creatura.is_alive(object) then
return object
end
end
end
function creatura.get_nearby_players(self, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
local nearby = {}
for _, object in ipairs(objects) do
if object:is_player()
and creatura.is_alive(object) then
table.insert(nearby, object)
end
end
return nearby
end
function creatura.get_nearby_object(self, name, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
for _, object in ipairs(objects) do
local ent = creatura.is_alive(object) and object:get_luaentity()
if ent
and object ~= self.object
and not ent._ignore
and ((type(name) == "table" and contains_val(name, ent.name))
or ent.name == name) then
return object
end
end
end
function creatura.get_nearby_objects(self, name, range)
local pos = self.object:get_pos()
if not pos then return end
local stored = self._nearby_obj or {}
local objects = (#stored > 0 and stored) or self:store_nearby_objects(range)
local nearby = {}
for _, object in ipairs(objects) do
local ent = creatura.is_alive(object) and object:get_luaentity()
if ent
and object ~= self.object
and not ent._ignore
and ((type(name) == "table" and contains_val(name, ent.name))
or ent.name == name) then
table.insert(nearby, object)
end
end
return nearby
end
creatura.get_nearby_entity = creatura.get_nearby_object
creatura.get_nearby_entities = creatura.get_nearby_objects
--------------------
-- Global Mob API --
--------------------
function creatura.default_water_physics(self)
local pos = self.stand_pos
local stand_node = self.stand_node
if not pos or not stand_node then return end
local gravity = self._movement_data.gravity or -9.8
local submergence = self.liquid_submergence or 0.25
local drag = self.liquid_drag or 0.7
if minetest.get_item_group(stand_node.name, "liquid") > 0 then -- In Liquid
local vel = self.object:get_velocity()
if not vel then return end
self.in_liquid = stand_node.name
if submergence < 1 then
local mob_level = pos.y + (self.height * submergence)
-- Find Water Surface
local nodes = minetest.find_nodes_in_area_under_air(
{x = pos.x, y = pos.y, z = pos.z},
{x = pos.x, y = pos.y + 3, z = pos.z},
"group:liquid"
) or {}
local surface_level = (#nodes > 0 and nodes[#nodes].y or pos.y + self.height + 3)
surface_level = floor(surface_level + 0.9)
local height_diff = mob_level - surface_level
-- Apply Bouyancy
if height_diff <= 0 then
local displacement = clamp(abs(height_diff) / submergence, 0.5, 1) * self.width
self.object:set_acceleration({x = 0, y = displacement, z = 0})
else
self.object:set_acceleration({x = 0, y = gravity, z = 0})
end
end
-- Apply Drag
self.object:set_velocity({
x = vel.x * (1 - self.dtime * drag),
y = vel.y * (1 - self.dtime * drag),
z = vel.z * (1 - self.dtime * drag)
})
else
self.in_liquid = nil
self.object:set_acceleration({x = 0, y = gravity, z = 0})
end
end
function creatura.default_vitals(self)
local pos = self.object:get_pos()
local node = minetest.get_node_or_nil(pos)
if not pos or not node then return end
local max_fall = self.max_fall or 3
local in_liquid = self.in_liquid
local on_ground = self.touching_ground
local damage = 0
-- Fall Damage
if max_fall > 0
and not in_liquid then
local fall_start = self._fall_start or (not on_ground and pos.y)
if fall_start
and on_ground then
damage = floor(fall_start - pos.y)
if damage < max_fall then
damage = 0
else
local resist = self.fall_resistance or 0
damage = damage - damage * resist
end
fall_start = nil
end
self._fall_start = fall_start
end
-- Environment Damage
if self:timer(1) then
local stand_def = creatura.get_node_def(node.name)
local max_breath = self.max_breath or 0
-- Suffocation
if max_breath > 0 then
local head_pos = {x = pos.x, y = pos.y + self.height, z = pos.z}
local head_def = creatura.get_node_def(head_pos)
if head_def.groups
and (minetest.get_item_group(head_def.name, "water") > 0
or (head_def.walkable
and head_def.groups.disable_suffocation ~= 1
and head_def.drawtype == "normal")) then
local breath = self._breath
if breath <= 0 then
damage = damage + 1
else
self._breath = breath - 1
self:memorize("_breath", breath)
end
end
end
-- Burning
local fire_resist = self.fire_resistance or 0
if fire_resist < 1
and minetest.get_item_group(stand_def.name, "igniter") > 0
and stand_def.damage_per_second then
local fire_damage = stand_def.damage_per_second - (stand_def.damage_per_second * fire_resist)
damage = (damage or 0) + fire_damage
end
end
-- Apply Damage
if damage > 0 then
self:hurt(damage)
self:indicate_damage()
if random(4) < 2 then
self:play_sound("hurt")
end
end
-- Entity Cramming
if self:timer(5) then
local objects = minetest.get_objects_inside_radius(pos, 0.2)
if #objects > 10 then
self:indicate_damage()
self.hp = self:memorize("hp", -1)
self:death_func()
end
end
end
function creatura.drop_items(self)
if not self.drops then return end
local pos = self.object:get_pos()
if not pos then return end
local drop_def, item_name, min_items, max_items, chance, amount, drop_pos
for i = 1, #self.drops do
drop_def = self.drops[i]
item_name = drop_def.name
if not item_name then return end
chance = drop_def.chance or 1
if random(chance) < 2 then
min_items = drop_def.min or 1
max_items = drop_def.max or 2
amount = random(min_items, max_items)
drop_pos = {
x = pos.x + random(-5, 5) * 0.1,
y = pos.y,
z = pos.z + random(-5, 5) * 0.1
}
local item = minetest.add_item(drop_pos, ItemStack(item_name .. " " .. amount))
if item then
item:add_velocity({
x = random(-2, 2),
y = 1.5,
z = random(-2, 2)
})
end
end
end
end
function creatura.basic_punch_func(self, puncher, tflp, tool_caps, dir)
if not puncher then return end
local tool
local tool_name = ""
local add_wear = false
if puncher:is_player() then
tool = puncher:get_wielded_item()
tool_name = tool:get_name()
add_wear = not minetest.is_creative_enabled(puncher:get_player_name())
end
if (self.immune_to
and contains_val(self.immune_to, tool_name)) then
return
end
local damage = 0
local armor_grps = self.object:get_armor_groups() or self.armor_groups or {}
for group, val in pairs(tool_caps.damage_groups or {}) do
local dmg_x = tflp / (tool_caps.full_punch_interval or 1.4)
damage = damage + val * clamp(dmg_x, 0, 1) * ((armor_grps[group] or 0) / 100.0)
end
if damage > 0 then
local dist = vec_dist(self.object:get_pos(), puncher:get_pos())
dir.y = 0.2
if self.touching_ground then
local power = clamp((damage / dist) * 8, 0, 8)
self:apply_knockback(dir, power)
end
self:hurt(damage)
end
if add_wear then
local wear = floor((tool_caps.full_punch_interval / 75) * 9000)
tool:add_wear(wear)
puncher:set_wielded_item(tool)
end
if (tflp or 0) > 0.5 then
self:play_sound("hit")
if random(2) < 2 then
self:play_sound("hurt")
end
end
self:indicate_damage()
end
local path = minetest.get_modpath("creatura")
dofile(path.."/mob_meta.lua")
+232
View File
@@ -0,0 +1,232 @@
-----------
-- Boids --
-----------
local abs = math.abs
local atan2 = math.atan2
local sin = math.sin
local cos = math.cos
local function average_angle(tbl)
local sum_sin, sum_cos = 0, 0
for _, v in pairs(tbl) do
sum_sin = sum_sin + sin(v)
sum_cos = sum_cos + cos(v)
end
return atan2(sum_sin, sum_cos)
end
local function average(tbl)
local sum = 0
for _,v in pairs(tbl) do -- Get the sum of all numbers in t
sum = sum + v
end
return sum / #tbl
end
local function interp_rad(a, b, w)
local cs = (1 - w) * cos(a) + w * cos(b)
local sn = (1 - w) * sin(a) + w * sin(b)
return atan2(sn, cs)
end
local vec_add = vector.add
local vec_dir = vector.direction
local vec_dist = vector.distance
local vec_divide = vector.divide
local vec_normal = vector.normalize
local function get_average_pos(vectors)
local sum = {x = 0, y = 0, z = 0}
for _, vec in pairs(vectors) do sum = vec_add(sum, vec) end
return vec_divide(sum, #vectors)
end
local function dist_2d(pos1, pos2)
local a = vector.new(
pos1.x,
0,
pos1.z
)
local b = vector.new(
pos2.x,
0,
pos2.z
)
return vec_dist(a, b)
end
local yaw2dir = minetest.yaw_to_dir
local dir2yaw = minetest.dir_to_yaw
-- Get Boid Members --
function creatura.get_boid_cached(self)
local pos = self.object:get_pos()
if not pos then return end
local radius = self.tracking_range * 0.5 or 4
local members = self._movement_data.boids or {}
local max_boids = self.max_boids or 7
if #members > 0 then
for i = #members, 1, -1 do
local object = members[i]
if not object or not object:get_yaw() then members[i] = nil end
end
if #members >= max_boids then return members end
end
local objects = minetest.get_objects_inside_radius(pos, radius)
if #objects < 2 then return {} end
for _, object in ipairs(objects) do
local ent = object and object ~= self.object and object:get_luaentity()
if ent
and ent.name == self.name then
local move_data = ent._movement_data
if move_data
and (not move_data.boids
or #move_data.boids < max_boids) then
table.insert(members, object)
end
end
if #members >= max_boids then break end
end
self._movement_data.boids = members
return members
end
-- Calculate Boid Movement Direction
function creatura.get_boid_dir(self)
local pos = self.object:get_pos()
if not pos then return end
local boids = creatura.get_boid_cached(self)
if #boids < 2 then return end
local pos_no, pos_sum = 0, {x = 0, y = 0, z = 0}
local sum_sin, sum_cos = 0, 0
local lift_no, lift_sum = 0, 0
local vel
local boid_pos
local closest_pos
for _, object in ipairs(boids) do
if object then
boid_pos, vel = object:get_pos(), object:get_velocity()
if boid_pos then
vel = vec_normal(vel)
local obj_yaw = object:get_yaw()
pos_no, pos_sum = pos_no + 1, vec_add(pos_sum, boid_pos)
sum_sin, sum_cos = sum_sin + sin(obj_yaw), sum_cos + cos(obj_yaw)
lift_no, lift_sum = lift_no + 1, lift_sum + vel.y
if not closest_pos
or vec_dist(pos, boid_pos) < vec_dist(pos, closest_pos) then
closest_pos = boid_pos
end
end
end
end
if not closest_pos then return end
local center = vec_divide(pos_sum, pos_no)
local lift = lift_sum / lift_no
local angle_sin, angle_cos
local radius = self.tracking_range * 0.5 or 4
local dist_factor = (radius - vec_dist(pos, closest_pos)) / radius
local alignment = atan2(sum_sin, sum_cos)
local seperation = dir2yaw(vec_dir(closest_pos, pos))
local cohesion = dir2yaw(vec_dir(pos, center))
if dist_factor > 0.9 then
seperation = interp_rad(alignment, seperation, 0.5)
angle_sin, angle_cos = sin(seperation), cos(seperation)
else
angle_sin, angle_cos = sin(cohesion), cos(cohesion)
end
local angle = atan2(angle_sin + sin(alignment), angle_cos + cos(alignment))
local dir = yaw2dir(angle)
dir.y = lift
return vector.normalize(dir), boids
end
-- Deprecated
function creatura.get_boid_members(pos, radius, name)
local objects = minetest.get_objects_inside_radius(pos, radius)
if #objects < 2 then return {} end
local members = {}
local max_boid = minetest.registered_entities[name].max_boids or 7
for i = 1, #objects do
if #members > max_boid then break end
local object = objects[i]
if object:get_luaentity()
and object:get_luaentity().name == name then
table.insert(members, object)
end
end
if #members > 1 then
for _, object in ipairs(members) do
local ent = object and object:get_luaentity()
if ent then
ent._movement_data.boids = members
end
end
end
return members
end
function creatura.get_boid_angle(self, _boids, range)
local pos = self.object:get_pos()
local boids = _boids or creatura.get_boid_members(pos, range or 4, self.name)
if #boids < 3 then return end
local yaw = self.object:get_yaw()
local lift = self.object:get_velocity().y
-- Add Boid data to tables
local closest_pos
local positions = {}
local angles = {}
local lifts = {}
for i = 1, #boids do
local boid = boids[i]
if boid:get_pos() then
local vel = boid:get_velocity()
if boid ~= self.object
and (abs(vel.x) > 0.1
or abs(vel.z) > 0.1) then
local boid_pos = boid:get_pos()
table.insert(positions, boid_pos)
table.insert(lifts, vec_normal(vel).y)
table.insert(angles, boid:get_yaw())
if not closest_pos
or vec_dist(pos, boid_pos) < vec_dist(pos, closest_pos) then
closest_pos = boid_pos
end
end
end
end
if #positions < 3 then return end
local center = get_average_pos(positions)
local dir2closest = vec_dir(pos, closest_pos)
-- Calculate Parameters
local alignment = average_angle(angles)
center = vec_add(center, yaw2dir(alignment))
local dir2center = vec_dir(pos, center)
local seperation = yaw + -(dir2yaw(dir2closest) - yaw)
local cohesion = dir2yaw(dir2center)
local params = {alignment}
if dist_2d(pos, closest_pos) < (self.boid_seperation or self.width * 3) then
table.insert(params, seperation)
elseif dist_2d(pos, center) > (#boids * 0.33) * (self.boid_seperation or self.width * 3) then
table.insert(params, cohesion)
end
-- Vertical Params
local vert_alignment = average(lifts)
local vert_seperation = (self.speed or 2) * -dir2closest.y
local vert_cohesion = (self.speed or 2) * dir2center.y
local vert_params = {vert_alignment}
if math.abs(pos.y - closest_pos.y) < (self.boid_seperation or self.width * 3) then
table.insert(vert_params, vert_seperation)
elseif math.abs(pos.y - closest_pos.y) > 1.5 * (self.boid_seperation or self.width * 3) then
table.insert(vert_params, vert_cohesion + (lift - vert_cohesion) * 0.1)
end
return average_angle(params), average(vert_params)
end
+280
View File
@@ -0,0 +1,280 @@
Registration
------------
creatura.register_mob(name, mob definition)
Mob Definition uses almost all entity definition params
{
max_health = 10 -- Maximum Health
damage = 0 -- Damage dealt by mob
speed = 4 -- Maximum Speed
tracking_range = 16 -- Maximum range for finding entities/blocks
despawn_after = 1500 -- Despawn after being active for this amount of time
max_fall = 8 -- How far a mob can fall before taking damage (set to 0 to disable fall damage)
turn_rate = 7 -- Turn Rate in rad/s
bouyancy_multiplier = 1 -- Multiplier for bouyancy effects (set to 0 to disable bouyancy)
hydrodynamics_multiplier = 1 -- Multiplier for hydroynamic effects (set to 0 to disable hydrodynamics)
hitbox = { -- Hitbox params (Uses custom registration to force get_pos() to always return bottom of box)
width = 0.5, (total width = width * 2. A width of 0.5 results in a box with a total width of 1)
height = 1 (total height of box)
}
animations = {
anim = {range = {x = 1, y = 10}, speed = 30, frame_blend = 0.3, loop = true}
}
drops = {
{name = (itemstring), min = 1, max = 3, chance = 1},
}
follow = {
"farming:seed_wheat",
"farming:seed_cotton"
}
utility_stack = {
-- Every second, all utilities in the stack are evaluated
-- Whichever utilitiy's get_score function returns the highest number will be executed
-- If multiple utilities have the same score, the one with the highest index is executed
[1] = {
`utility` -- name of utility to evaluate
`get_score` -- function (only accepts `self` as an arg) that returns a number
}
}
activate_func = function(self, staticdata, dtime_s) -- called upon activation
step_func = function(self, dtime, moveresult) -- called every server step
death_func = function(self) -- called when mobs health drops to/below 0
}
Lua Entity Methods
------------------
`move(pos, method, speed, animation)`
- `pos`: position to move to
- `method`: method used to move to `pos`
- `speed`: multiplier for `speed`
- `animation`: animation to play while moving
`halt()`
- stops movement
`turn_to(yaw[, turn_rate])`
- `yaw`: yaw (in radians) to turn to
- `turn_rate`: turn rate in rad/s (default: 10) -- likely to be deprecated
`set_gravity(gravity)`
- `gravity`: vertical acceleration rate
`set_forward_velocity(speed)`
- `speed`: rate in m/s to travel forward at
`set_vertical_velocity(speed)`
- `speed`: rate in m/s to travel vertically at
`apply_knockback(dir, power)`
- `dir`: direction vector
- `power`: multiplier for dir
`punch_target(target)`
- applies 'damage' to 'target'
`hurt(damage)`
- `damage`: number to subtract from health (ignores armor)
`heal(health)`
- `health`: number to add to health
`get_center_pos()`
- returns position at center of hitbox
`pos_in_box(pos[, size])`
- returns true if 'pos' is within hitbox
- `size`: width of box to check in (optional)
`animate(anim)`
- sets animation to `anim`
`set_texture(id, tbl)`
- `id`: table index
- `tbl`: table of textures
`set_scale(x)`
- `x`: multiplier for base scale (0.5 sets scale to half, 2 sets scale to double)
`fix_attached_scale(parent)`
- sets scale to appropriate value when attached to 'parent'
- `parent`: object
`memorize(id, val)`
-- stores `val` to staticdata
- `id`: key for table
- `val`: value to store
`forget(id)`
-- removes `id` from staticdata
`recall(id)`
-- returns value of `id` from staticdata
`timer(n)`
-- returns true avery `n` seconds
`get_hitbox()`
-- returns current hitbox
`get_height()`
-- returns current height
`get_visual_size()`
-- returns current visual size
`follow_wielded_item(player)`
-- returns itemstack, item name of `player`s wielded item if item is in 'follow'
`get_target(target)`
-- returns if `target` is alive, if mob has a line of sight with `target`, position of `target`
Utilities
---------
* `creatura.is_valid(mob)`
* Returns false if object doesn't exist, otherwise returns ObjectRef/PlayerRef
* `mob`: Luaentity, ObjectRef, or PlayerRef
* `creatura.is_alive(mob)`
* Returns false if object doesn't exist or is dead, otherwise returns ObjectRef/PlayerRef
* `mob`: Luaentity, ObjectRef, or PlayerRef
Environment access
------------------
* `creatura.get_node_height_from_def(name)`
-- Returns total height of nodebox
-- `name`: Itemstring/Name of node
* `creatura.get_node_def(node)`
-- Returns definition of node
-- `node`: Itemstring/Name of node or position
* `creatura.get_ground_level(pos, max_diff)`
* Returns first position above walkable node within `max_diff`
* `creatura.is_pos_moveable(pos, width, height)`
* Returns true if a box with specified `width` and `height` can fit at `pos`
* `width` should be the largest side of the collision box
* Check from bottom of box
* `creatura.fast_ray_sight(pos1, pos2, water)`
* Checks for line of sight between `pos1 and `pos2`
* Returns bool
* Returns distance to obstruction
* `creatura.sensor_floor(self, range, water)`
* Finds distance to ground from bottom of entities hitbox
* Returns distance to ground or `range` if no ground is found
* `range`: Maximum range
* `water`: If false, water will not be counted as ground
* `creatura.sensor_ceil(self, range, water)`
* Finds distance to ceiling from top of entities hitbox
* Returns distance to ceiling or `range` if no ceiling is found
* `range`: Maximum range
* `water`: If false, water will not be counted as ceiling
* `creatura.get_nearby_player(self)`
* Finds player within `self.tracking_range`
* Returns PlayerRef or nil
* `creatura.get_nearby_players(self)`
* Finds players within `self.tracking_range`
* Returns table of PlayerRefs or empty table
* `creatura.get_nearby_object(self, name)`
* Finds object within `self.tracking_range`
* Returns ObjectRef or nil
* `name`: Name of object to search for
* `creatura.get_nearby_objects(self, name)`
* Finds objects within `self.tracking_range`
* Returns table of ObjectRefs or empty table
* `name`: Name of object to search for
Global Mob API
--------------
* `creatura.default_water_physics(self)`
* Bouyancy and Drag physics used by default on all Mobs
* `creatura.default_vitals(self)`
* Vitals used by default on all Mobs
* Handles suffocation, drowning, fire damage, and fall damage
* `creatura.drop_items(self)`
* Drops items from `self.drops`
* `creatura.basic_punch_func(self, puncher, time_from_last_punch, tool_capabilities, direction, damage)`
* Deals damage
* Applies knockback
* Visualy and audibly indicates damage
Pathfinding
-----------
Creatura's pathfinder uses the A* algorithm for speed, as well as Theta* for decent performance and more natural looking paths.
Both pathfinders will carry out pathfinding over multiple server steps to reduce lag spikes which does result in the path not
being returned immediately, so your code will have to account for this.
The maximum amount of time the pathfinder can spend per-step (in microseconds) can be adjusted in settings.
* `creatura.pathfinder.find_path(self, pos1, pos2, get_neighbors)`
* Finds a path from `pos1` to `pos2`
* `get_neighbors` is a function used to find valid neighbors
* `creatura.pathfinder.get_neighbors_fly` and `creatura.pathfinder.get_neighbors_swim` are bundled by default
* `creatura.pathfinder.find_path_theta(self, pos1, pos2, get_neighbors)`
* Finds a path from `pos1` to `pos2`
* Returns a path with arbitrary angles for natural looking paths at the expense of performance
* `get_neighbors` is a function used to find valid neighbors
* `creatura.pathfinder.get_neighbors_fly` and `creatura.pathfinder.get_neighbors_swim` are bundled by default
Spawning
--------
NOTE: Globalstep spawning from early versions of the API likely won't recieve much/any support going forward. Use ABM Spawning instead.
* `creatura.register_abm_spawn(name, def)`
* `name` of the mob to spawn
* `def` is a table of spawn parameters
* `chance` is the chance of a mob spawning every `interval`
* (a `chance` of 30 and `interval` of 60 would mean a 1 in 30 chance of a mob spawning every 60 seconds)
* `chance_on_load` same as `chance` but for LBM spawning (when a chunk is loaded for the first time)
* `interval` is how often (in seconds) a spawn attempt will happen
* `min_height` is the minimum height that a spawn attempt can happen at
* a `min_height` of 0 would mean the mob cannot spawn below a y coordinate of 0
* `max_height` is the maximum height that a spawn attempt can happen at
* a `max_height` of 128 would mean the mob cannot spawn above a y coordinate of 128
* `min_time` is the minimum time a mob can spawn at
* `max_time` is the maximum time a mob can spawn at
* set `min_time` to 19500 and `max_time` to 4500 to only spawn at night and swap the numbers to only spawn at day
* `min_light` is the minimum light level a mob can spawn at
* `max_light` is the maximum light level a mob can spawn at
* `min_group` is the lowest number of mobs to spawn in a group at a time
* value of 3 means the mob will always spawn with at least 3 mobs together
* `max_group` is the highest number of mobs to spawn in a group at a time
* `block_protected` will block spawning mobs in protected areas if set to true
* `biomes` is a table of biomes the mob can spawn in
* `nodes` is a table of nodes the mob can spawn in/on
* `neighbors` is a table of nodes that must be adjacent to the spawn position
* ex: set to `{"groups:tree"}` to force the mob to spawn next to a tree
* `spawn_on_load` will spawn mobs when a chunk generates if set to true
* `spawn_in_nodes` will spawn mobs inside the node rather than above if set to true
* set this to true for mobs that spawn in water
* `spawn_cap` is the maximum amount of the mob that can spawn within active block range
+25
View File
@@ -0,0 +1,25 @@
creatura = {}
local path = minetest.get_modpath("creatura")
dofile(path.."/api.lua")
dofile(path.."/pathfinding.lua")
dofile(path.."/pathfinder_deprecated.lua")
dofile(path.."/methods.lua")
-- Optional Files --
-- Optional files can be safely removed
-- by game developers who don't need the
-- extra features
local function load_file(filepath, filename)
if io.open(filepath .. "/" .. filename, "r") then
dofile(filepath .. "/" .. filename)
else
minetest.log("action", "[Creatura] The file " .. filename .. " could not be loaded.")
end
end
load_file(path, "boids.lua")
load_file(path, "spawning.lua")
+677
View File
@@ -0,0 +1,677 @@
-------------
-- Methods --
-------------
local pi = math.pi
local abs = math.abs
local ceil = math.ceil
local max = math.max
local random = math.random
local atan2 = math.atan2
local sin = math.sin
local cos = math.cos
local function diff(a, b) -- Get difference between 2 angles
return atan2(sin(b - a), cos(b - a))
end
local function clamp(val, _min, _max)
if val < _min then
val = _min
elseif _max < val then
val = _max
end
return val
end
local vec_add = vector.add
local vec_normal = vector.normalize
local vec_len = vector.length
local vec_dist = vector.distance
local vec_dir = vector.direction
local vec_dot = vector.dot
local vec_multi = vector.multiply
local vec_sub = vector.subtract
local yaw2dir = minetest.yaw_to_dir
local dir2yaw = minetest.dir_to_yaw
--[[local function debugpart(pos, time, tex)
minetest.add_particle({
pos = pos,
texture = tex or "creatura_particle_red.png",
expirationtime = time or 0.1,
glow = 16,
size = 24
})
end]]
---------------------
-- Local Utilities --
---------------------
local get_node_def = creatura.get_node_def
--local get_node_height = creatura.get_node_height_from_def
function creatura.get_collision(self, dir, range)
local pos, yaw = self.object:get_pos(), self.object:get_yaw()
if not pos then return end
local width, height = self.width or 0.5, self.height or 1
dir = dir or yaw2dir(yaw)
pos.x = pos.x + dir.x * width
pos.z = pos.z + dir.z * width
local cos_yaw = cos(yaw)
local sin_yaw = sin(yaw)
local width_i = width / ceil(width)
local height_i = height / ceil(height)
local pos_x, pos_y, pos_z = pos.x, pos.y, pos.z
local dir_x, dir_y, dir_z = dir.x, dir.y, dir.z
local pos2 = {x = pos_x, y = pos_y, z = pos_z}
local collision
pos.y = pos.y + height * 0.5
range = range or 4
local low_score
for _ = 0, range do
if collision then return collision end
pos_x = pos_x + dir_x
pos_y = pos_y + dir_y
pos_z = pos_z + dir_z
pos2.y = pos_y
for x = -width, width, width_i do
pos2.x = cos_yaw * ((pos_x + x) - pos_x) + pos_x
pos2.z = sin_yaw * ((pos_x + x) - pos_x) + pos_z
for y = height, 0, -height_i do
if y < self.stepheight or 1.1 then break end
pos2.y = pos_y + y
if get_node_def(pos2).walkable then
local score = abs(pos.y - pos2.y) * vec_dot(dir, vec_dir(pos, pos2))
if not low_score
or score < low_score then
low_score = score
collision = pos2
end
end
end
end
end
end
creatura.get_collision_ranged = creatura.get_collision
local get_collision = creatura.get_collision
local function get_avoidance_dir(self)
local pos = self.object:get_pos()
if not pos then return end
local _, col_pos = get_collision(self)
if col_pos then
local vel = self.object:get_velocity()
vel.y = 0
local vel_len = vec_len(vel) * (1 + (self.step_delay or 0))
local ahead = vec_add(pos, vec_normal(vel))
local avoidance_force = vec_sub(ahead, col_pos)
avoidance_force.y = 0
avoidance_force = vec_multi(vec_normal(avoidance_force), (vel_len > 1 and vel_len) or 1)
return vec_dir(pos, vec_add(ahead, avoidance_force))
end
end
local function get_collision_single(pos, water)
local pos2 = {x = pos.x, y = pos.y, z = pos.z}
local n_def = get_node_def(pos2)
if n_def.walkable
or (water and (n_def.groups.liquid or 0) > 0) then
pos2.y = pos.y + 1
n_def = get_node_def(pos2)
local col_max = n_def.walkable or (water and (n_def.groups.liquid or 0) > 0)
pos2.y = pos.y - 1
local col_min = col_max and (n_def.walkable or (water and (n_def.groups.liquid or 0) > 0))
if col_min then
return pos
else
pos2.y = pos.y + 1
return pos2
end
end
end
function creatura.get_avoidance_lift(self, pos2, range)
range = ceil(max(range or 1, 0.5))
local height_half = (self.height or 1) * 0.5
local center_y = pos2.y + height_half
local check_pos = {x = pos2.x, y = center_y, z = pos2.z}
-- Find ceiling and floor collisions
local def
local ceil_pos
local floor_pos
for i = 1, range, 0.5 do -- 0.5 increment increases accuracy
if ceil_pos and floor_pos then break end
check_pos.y = center_y + i
def = creatura.get_node_def(check_pos)
if not ceil_pos
and (def.walkable
or minetest.get_item_group(def.name, "liquid") > 0) then
ceil_pos = check_pos
end
check_pos.y = center_y - i
def = creatura.get_node_def(check_pos)
if not floor_pos
and (def.walkable
or minetest.get_item_group(def.name, "liquid") > 0) then
floor_pos = check_pos
end
end
-- Calculate direction to average point of collisions
check_pos.y = center_y
local offset = {x = 0, y = height_half + range, z = 0}
if not ceil_pos then ceil_pos = vec_add(check_pos, offset) end
if not floor_pos then floor_pos = vec_sub(check_pos, offset) end
local dist_up = ceil_pos.y - center_y
local dist_down = floor_pos.y - center_y
local altitude = (dist_up + dist_down) / 2
return ((check_pos.y + altitude) - center_y) / range * 2
end
function creatura.get_avoidance_lift_aquatic(self, pos2, range)
range = ceil(max(range or 1, 0.5))
local height_half = (self.height or 1) * 0.5
local center_y = pos2.y + height_half
local check_pos = {x = pos2.x, y = center_y, z = pos2.z}
-- Find ceiling and floor collisions
local ceil_pos
local floor_pos
for i = 1, range, 0.5 do -- 0.5 increment increases accuracy
if ceil_pos and floor_pos then break end
check_pos.y = center_y + i
if not ceil_pos
and minetest.get_item_group(creatura.get_node_def(check_pos).name, "liquid") < 1 then
ceil_pos = check_pos
end
check_pos.y = center_y - i
if not floor_pos
and minetest.get_item_group(creatura.get_node_def(check_pos).name, "liquid") < 1 then
floor_pos = check_pos
end
end
-- Calculate direction to average point of collisions
check_pos.y = center_y
local offset = {x = 0, y = height_half + range, z = 0}
if not ceil_pos then ceil_pos = vec_add(check_pos, offset) end
if not floor_pos then floor_pos = vec_sub(check_pos, offset) end
local dist_up = ceil_pos.y - center_y
local dist_down = floor_pos.y - center_y
local altitude = (dist_up + dist_down) / 2
return ((check_pos.y + altitude) - center_y) / range * 2
end
----------------------------
-- Context Based Steering --
----------------------------
local steer_directions = {
vec_normal({x = 1, y = 0, z = 0}),
vec_normal({x = 1, y = 0, z = 1}),
vec_normal({x = 0, y = 0, z = 1}),
vec_normal({x = -1, y = 0, z = 0}),
vec_normal({x = -1, y = 0, z = -1}),
vec_normal({x = 0, y = 0, z = -1}),
vec_normal({x = 1, y = 0, z = -1}),
vec_normal({x = -1, y = 0, z = 1})
}
-- Context Methods
function creatura.get_context_default(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
local width, height = self.width or 0.5, self.height or 1
local y_offset = math.min(self.stepheight or 1.1, height)
pos.y = pos.y + y_offset
local collision
local ray = minetest.raycast(pos, vec_add(pos, vec_multi(steer_dir, width + range)), false, false)
local pointed = ray:next()
if pointed
and pointed.type == "node"
and creatura.get_node_def(pointed.under).walkable then
collision = pointed.under
end
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
function creatura.get_context_large(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
local width, height = self.width or 0.5, self.height or 1
local y_offset = math.min(self.stepheight or height)
pos.y = pos.y + y_offset
local collision = creatura.get_collision(self, steer_dir, range)
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
function creatura.get_context_small(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
pos = vector.round(pos)
local width = self.width or 0.5
local collision = get_collision_single(vec_add(pos, steer_dir))
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
function creatura.get_context_small_aquatic(self, goal, steer_dir, interest, danger, range)
local pos = self.object:get_pos()
if not pos then return end
pos = vector.round(pos)
local width = self.width or 0.5
local pos2 = vec_add(pos, steer_dir)
local collision = minetest.get_item_group(get_node_def(pos2).name, "liquid") < 1 and pos2
if collision then
local dir2goal = vec_normal(vec_dir(pos, goal))
local dir2col = vec_normal(vec_dir(pos, collision))
local dist2col = vec_dist(pos, collision) - width
local dot_score = vec_dot(dir2col, dir2goal)
local dist_score = (range - dist2col) / range
interest = interest - dot_score
danger = dist_score
end
return interest, danger
end
-- Calculate Steering
function creatura.calc_steering(self, goal, get_context, range)
if not goal then return end
get_context = get_context or creatura.get_context_default
local pos, yaw = self.object:get_pos(), self.object:get_yaw()
if not pos or not yaw then return end
range = math.max(range or 2, 2)
local dir2goal = vec_normal(vec_dir(pos, goal))
local output_dir = {x = 0, y = dir2goal.y, z = 0}
-- Cached variables
local dir
for _, _dir in ipairs(steer_directions) do
dir = {x = _dir.x, y = dir2goal.y, z = _dir.z}
local score = vec_dot(dir2goal, dir)
local interest = clamp(score, 0, 1)
local danger = 0
if interest > 0 then -- Direction is within 90 degrees of goal
interest, danger = get_context(self, goal, dir, interest, danger, range)
end
score = interest - danger
output_dir = vector.add(output_dir, vector.multiply(dir, score))
end
return vec_normal(output_dir)
end
-- DEPRECATED
function creatura.get_context_steering(self, goal, range, water)
local context = creatura.get_context_default
local width, height = self.width, self.height
if width > 0.5
or height > 1 then
context = creatura.get_context_large
elseif water then
context = creatura.get_context_small_aquatic
end
return creatura.calc_steering(self, goal, context, range)
end
-------------
-- Actions --
-------------
-- Actions are more specific behaviors used
-- to compose a Utility.
-- Move
function creatura.action_move(self, pos2, timeout, method, speed_factor, anim)
local timer = timeout or 4
local function func(_self)
timer = timer - _self.dtime
self:animate(anim or "walk")
local safe = true
if _self.max_fall
and _self.max_fall > 0 then
local pos = self.object:get_pos()
if not pos then return end
safe = _self:is_pos_safe(pos2)
end
if timer <= 0
or not safe
or _self:move_to(pos2, method or "creatura:obstacle_avoidance", speed_factor or 0.5) then
return true
end
end
self:set_action(func)
end
creatura.action_walk = creatura.action_move -- Support for outdated versions
-- Idle
function creatura.action_idle(self, time, anim)
local timer = time or 1
local function func(_self)
_self:set_gravity(-9.8)
_self:halt()
_self:animate(anim or "stand")
timer = timer - _self.dtime
if timer <= 0 then
return true
end
end
self:set_action(func)
end
-- Rotate on Z axis in random direction until 90 degree angle is reached
function creatura.action_fallover(self)
local zrot = 0
local init = false
local dir = 1
local rot = self.object:get_rotation()
local function func(_self)
if not init then
_self:animate("stand")
if random(2) < 2 then
dir = -1
end
init = true
end
rot = _self.object:get_rotation()
local goal = (pi * 0.5) * dir
local step = _self.dtime
if step > 0.5 then step = 0.5 end
zrot = zrot + (pi * dir) * step
_self.object:set_rotation({x = rot.x, y = rot.y, z = zrot})
if (dir > 0 and zrot >= goal)
or (dir < 0 and zrot <= goal) then return true end
end
self:set_action(func)
end
----------------------
-- Movement Methods --
----------------------
-- Pathfinding
--[[local function trim_path(pos, path)
if #path < 2 then return end
local trim = false
local closest
for i = #path, 1, -1 do
if not path[i] then break end
if (closest
and vec_dist(pos, path[i]) > vec_dist(pos, path[closest]))
or trim then
table.remove(path, i)
trim = true
else
closest = i
end
end
return path
end]]
creatura.register_movement_method("creatura:pathfind_theta", function(self)
local path = {}
local steer_to
local steer_int = 0
local arrival_threshold = clamp(self.width, 0.5, 1)
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < arrival_threshold then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
local path_dir = #path > 0 and vec_dir(pos, path[2] or path[1])
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = path_dir or (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
path = (#path > 0 and path) or (creatura.pathfinder.find_path_theta(_self, pos, goal) or {})
if path_dir
and ((path[2] and vec_dist(pos, path[2]) < arrival_threshold)
or vec_dist(pos, path[1]) < arrival_threshold) then
table.remove(path, 1)
end
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:pathfind", function(self)
local path = {}
local steer_to
local steer_int = 0
local arrival_threshold = clamp(self.width, 0.5, 1)
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < arrival_threshold then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
local path_dir = #path > 0 and vec_dir(pos, path[2] or path[1])
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = path_dir or (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
path = (#path > 0 and path) or (creatura.pathfinder.find_path(_self, pos, goal) or {})
if path_dir
and ((path[2] and vec_dist(pos, path[2]) < arrival_threshold + 0.5)
or vec_dist(pos, path[1]) < arrival_threshold) then
table.remove(path, 1)
end
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
-- Steering
creatura.register_movement_method("creatura:steer_small", function(self)
local steer_to
local steer_int = 0
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal)) or steer_to
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:steer_large", function(self)
local steer_to
local steer_int = 0
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal, creatura.get_context_large)) or steer_to
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:walk_simple", function(self)
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
-- Apply Movement
_self:turn_to(dir2yaw(vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
-- Deprecated
creatura.register_movement_method("creatura:context_based_steering", function(self)
local steer_to
local steer_int = 0
self:set_gravity(-9.8)
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos or not goal then return end
if vec_dist(pos, goal) < clamp(self.width, 0.5, 1) then
_self:halt()
return true
end
-- Calculate Movement
local turn_rate = abs(_self.turn_rate or 5)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
steer_int = (steer_int > 0 and steer_int - _self.dtime) or 1 / math.max(speed, 1)
steer_to = (steer_int <= 0 and creatura.calc_steering(_self, goal, creatura.get_context_large)) or steer_to
-- Apply Movement
_self:turn_to(dir2yaw(steer_to or vec_dir(pos, goal)), turn_rate)
_self:set_forward_velocity(speed)
end
return func
end)
creatura.register_movement_method("creatura:obstacle_avoidance", function(self)
local box = clamp(self.width, 0.5, 1.5)
local steer_to
local steer_timer = 0.25
local function func(_self, goal, speed_factor)
local pos = _self.object:get_pos()
if not pos then return end
self:set_gravity(-9.8)
-- Return true when goal is reached
if vec_dist(pos, goal) < box * 1.33 then
_self:halt()
return true
end
steer_timer = (steer_timer > 0 and steer_timer - _self.dtime) or 0.25
-- Get movement direction
steer_to = (steer_timer > 0 and steer_to) or (steer_timer <= 0 and get_avoidance_dir(_self))
local goal_dir = steer_to or vec_dir(pos, goal)
pos.y = pos.y + goal_dir.y
local yaw = _self.object:get_yaw()
local goal_yaw = dir2yaw(goal_dir)
local speed = abs(_self.speed or 2) * speed_factor or 0.5
local turn_rate = abs(_self.turn_rate or 5)
-- Movement
local yaw_diff = abs(diff(yaw, goal_yaw))
if yaw_diff < pi * 0.25
or steer_to then
_self:set_forward_velocity(speed)
else
_self:set_forward_velocity(speed * 0.33)
end
_self:turn_to(goal_yaw, turn_rate)
end
return func
end)
+1248
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
name = creatura
description = A performant, semi-modular mob API
+805
View File
@@ -0,0 +1,805 @@
-----------------
-- Pathfinding --
-----------------
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
local floor = math.floor
local abs = math.abs
local vec_dist, vec_round = vector.distance, vector.round
local moveable = creatura.is_pos_moveable
local function get_distance(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return 14 * distZ + 10 * (distX - distZ)
else
return 14 * distX + 10 * (distZ - distX)
end
end
local function get_distance_to_neighbor(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distY = abs(start_pos.y - end_pos.y)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
else
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
end
end
local function is_on_ground(pos)
local ground = {
x = pos.x,
y = pos.y - 1,
z = pos.z
}
if creatura.get_node_def(ground).walkable then
return true
end
return false
end
local function vec_raise(v, n)
return {x = v.x, y = v.y + n, z = v.z}
end
local function get_line_of_sight(a, b)
local steps = floor(vec_dist(a, b))
local line = {}
for i = 0, steps do
local pos
if steps > 0 then
pos = {
x = a.x + (b.x - a.x) * (i / steps),
y = a.y + (b.y - a.y) * (i / steps),
z = a.z + (b.z - a.z) * (i / steps)
}
else
pos = a
end
table.insert(line, pos)
end
if #line < 1 then
return false
else
for i = 1, #line do
local node = minetest.get_node(line[i])
if creatura.get_node_def(node.name).walkable then
return false
end
end
end
return true
end
-- Find a path from start to goal
--[[local function debugpart(pos, time, tex)
minetest.add_particle({
pos = pos,
texture = tex or "creatura_particle_red.png",
expirationtime = time or 0.1,
glow = 6,
size = 12
})
end]]
local c_air = minetest.get_content_id("air")
local function is_pos_moveable_vm(pos, width, height, area, data)
pos = vector.round(pos)
local pos1 = {
x = pos.x - math.ceil(width),
y = pos.y,
z = pos.z - math.ceil(width)
}
local pos2 = {
x = pos.x + math.ceil(width),
y = pos.y + math.ceil(height),
z = pos.z + math.ceil(width)
}
for z = pos1.z, pos2.z do
for y = pos1.y, pos2.y do
for x = pos1.x, pos2.x do
if not area:contains(x, y, z) then return false end
local vi = area:index(x, y, z)
local c = data[vi]
if c ~= c_air then
local c_name = minetest.get_name_from_content_id(c)
if creatura.get_node_def(c_name).walkable then
return false
end
end
end
end
end
return true
end
local vm_buffer = {}
function creatura.find_lvm_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
if vec_dist(start, goal) > (self.tracking_range or 128) then return {} end
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function get_neighbors(pos, width, height, tbl, open, closed, vm_area, vm_data)
local result = {}
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
if not vm_area or not vm_data or not vm_area:containsp(neighbor) then return end
local can_move = (not swim and get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)) or true
if open[minetest.hash_node_position(neighbor)]
or closed[minetest.hash_node_position(neighbor)] then
can_move = false
end
if can_move then
can_move = is_pos_moveable_vm(neighbor, width, height, vm_area, vm_data)
if not fly and not swim then
if not can_move then -- Step Up
local step = vec_raise(neighbor, 1)
can_move = is_pos_moveable_vm(vec_round(step), width, height, vm_area, vm_data)
neighbor = vec_round(step)
else
local step = creatura.get_ground_level(vector.new(neighbor), 1)
if step.y < neighbor.y
and is_pos_moveable_vm(vec_round(step), width, height, vm_area, vm_data) then
neighbor = step
end
end
end
end
if vector.equals(neighbor, goal) then
can_move = true
end
if can_move
and (not swim
or creatura.get_node_def(neighbor).drawtype == "liquid") then
table.insert(result, neighbor)
end
end
return result
end
local function find_path(_start, _goal)
local us_time = minetest.get_us_time()
_start = {
x = floor(_start.x + 0.5),
y = floor(_start.y + 0.5),
z = floor(_start.z + 0.5)
}
_goal = {
x = floor(_goal.x + 0.5),
y = floor(_goal.y + 0.5),
z = floor(_goal.z + 0.5)
}
if _goal.x == _start.x
and _goal.z == _start.z then -- No path can be found
return nil
end
local vm_area = self._path_data.vm_area
local vm_data = self._path_data.vm_data
if not vm_area
or not vm_data then
local vm_center = vector.add(_start, vector.divide(vector.subtract(_goal, _start), 2))
local vm_size = vec_dist(_goal, _start)
if vm_size < 24 then vm_size = 24 end
local e1 = vector.subtract(vm_center, vm_size)
local e2 = vector.add(vm_center, vm_size)
local vm = minetest.get_voxel_manip(e1, e2)
e1, e2 = vm:read_from_map(e1, e2)
vm_area = VoxelArea:new{MinEdge=e1, MaxEdge=e2}
vm_data = vm:get_data(vm_buffer)
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(_start)
openSet[start_index] = {
pos = _start,
parent = nil,
gScore = 0,
fScore = get_distance(_start, _goal)
}
local count = self._path_data.count or 1
while count > 0 do
-- Initialize ID and data
local current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
self._path_data.open = openSet
self._path_data.closedSet = closedSet
local current_start = vec_round(self._path_data.start)
if closedSet[minetest.hash_node_position(current_start)] then
start_index = minetest.hash_node_position(current_start)
end
-- Reconstruct path if end is reached
if ((is_on_ground(_goal)
or fly)
and current_id == minetest.hash_node_position(_goal))
or (not fly
and not is_on_ground(_goal)
and math.abs(_goal.x - current.pos.x) < 1.1
and math.abs(_goal.z - current.pos.z) < 1.1) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(
current.pos,
obj_width,
obj_height,
path_neighbors,
openSet,
closedSet,
vm_area,
vm_data
)
-- Go through neighboring nodes
if not adjacent or #adjacent < 1 then self._path_data = {} return {} end
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
local temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
local new_gScore = 0
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if (temp_gScore < new_gScore
or not openSet[minetest.hash_node_position(neighbor.pos)])
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
if not openSet[minetest.hash_node_position(neighbor.pos)] then
count = count + 1
end
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
end
end
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = _start,
open = openSet,
closed = closedSet,
count = count,
vm_area = vm_area,
vm_data = vm_data
}
return {}
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(start, goal)
end
function creatura.find_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
start = self._path_data.start or start
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function get_neighbors(pos, width, height, tbl, open, closed)
local result = {}
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
local can_move = (not swim and get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)) or true
if open[minetest.hash_node_position(neighbor)]
or closed[minetest.hash_node_position(neighbor)] then
can_move = false
end
if can_move then
can_move = moveable(neighbor, width, height)
if not fly and not swim then
if not can_move then -- Step Up
local step = vec_raise(neighbor, 1)
can_move = moveable(vec_round(step), width, height)
neighbor = vec_round(step)
else
local step = creatura.get_ground_level(vector.new(neighbor), 1)
if step.y < neighbor.y
and moveable(vec_round(step), width, height) then
neighbor = step
end
end
end
end
if vector.equals(neighbor, goal) then
can_move = true
end
if can_move
and (not swim
or creatura.get_node_def(neighbor).drawtype == "liquid") then
table.insert(result, neighbor)
end
end
return result
end
local function find_path(_start, _goal)
local us_time = minetest.get_us_time()
_start = {
x = floor(_start.x + 0.5),
y = floor(_start.y + 0.5),
z = floor(_start.z + 0.5)
}
_goal = {
x = floor(_goal.x + 0.5),
y = floor(_goal.y + 0.5),
z = floor(_goal.z + 0.5)
}
if _goal.x == _start.x
and _goal.z == _start.z then -- No path can be found
return nil
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(_start)
openSet[start_index] = {
pos = _start,
parent = nil,
gScore = 0,
fScore = get_distance(_start, _goal)
}
local count = self._path_data.count or 1
while count > 0 do
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = _start,
open = openSet,
closed = closedSet,
count = count
}
return
end
-- Initialize ID and data
local current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
self._path_data.open = openSet
self._path_data.closedSet = closedSet
-- Reconstruct path if end is reached
if ((is_on_ground(_goal)
or fly)
and current_id == minetest.hash_node_position(_goal))
or (not fly
and not is_on_ground(_goal)
and _goal.x == current.pos.x
and _goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(current.pos, obj_width, obj_height, path_neighbors, openSet, closedSet)
-- Go through neighboring nodes
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
local temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
local new_gScore = 0
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if (temp_gScore < new_gScore
or not openSet[minetest.hash_node_position(neighbor.pos)])
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
if not openSet[minetest.hash_node_position(neighbor.pos)] then
count = count + 1
end
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
end
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(start, goal)
end
------------
-- Theta* --
------------
function creatura.find_theta_path(self, start, goal, obj_width, obj_height, max_open, climb, fly, swim)
climb = climb or false
fly = fly or false
swim = swim or false
start = self._path_data.start or start
self._path_data.start = start
local path_neighbors = {
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
}
if climb then
table.insert(path_neighbors, {x = 0, y = 1, z = 0})
end
if fly
or swim then
path_neighbors = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
end
local function get_neighbors(pos, width, height, tbl, open, closed)
local result = {}
for i = 1, #tbl do
local neighbor = vector.add(pos, tbl[i])
if neighbor.y == pos.y
and not fly
and not swim then
neighbor = creatura.get_ground_level(neighbor, 1)
end
local can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
if swim then
can_move = true
end
if not moveable(vec_raise(neighbor, -0.49), width, height) then
can_move = false
if neighbor.y == pos.y
and moveable(vec_raise(neighbor, 0.51), width, height) then
neighbor = vec_raise(neighbor, 1)
can_move = true
end
end
if vector.equals(neighbor, goal) then
can_move = true
end
if open[minetest.hash_node_position(neighbor)]
or closed[minetest.hash_node_position(neighbor)] then
can_move = false
end
if can_move
and ((is_on_ground(neighbor)
or (fly or swim))
or (neighbor.x == pos.x
and neighbor.z == pos.z
and climb))
and (not swim
or creatura.get_node_def(neighbor).drawtype == "liquid") then
table.insert(result, neighbor)
end
end
return result
end
local function find_path(_start, _goal)
local us_time = minetest.get_us_time()
_start = {
x = floor(_start.x + 0.5),
y = floor(_start.y + 0.5),
z = floor(_start.z + 0.5)
}
_goal = {
x = floor(_goal.x + 0.5),
y = floor(_goal.y + 0.5),
z = floor(_goal.z + 0.5)
}
if _goal.x == _start.x
and _goal.z == _start.z then -- No path can be found
return nil
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local start_index = minetest.hash_node_position(_start)
openSet[start_index] = {
pos = _start,
parent = nil,
gScore = 0,
fScore = get_distance(_start, _goal)
}
local count = self._path_data.count or 1
while count > 0 do
if minetest.get_us_time() - us_time > theta_star_alloted_time then
self._path_data = {
start = _start,
open = openSet,
closed = closedSet,
count = count
}
return
end
-- Initialize ID and data
local current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
-- Reconstruct path if end is reached
if (is_on_ground(_goal)
and current_id == minetest.hash_node_position(_goal))
or (not is_on_ground(_goal)
and _goal.x == current.pos.x
and _goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
count = count - 1
local adjacent = get_neighbors(current.pos, obj_width, obj_height, path_neighbors, openSet, closedSet)
-- Go through neighboring nodes
for i = 1, #adjacent do
local neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
if not openSet[minetest.hash_node_position(neighbor.pos)]
and not closedSet[minetest.hash_node_position(neighbor.pos)] then
local current_parent = closedSet[current.parent] or closedSet[start_index]
if not current_parent then
current_parent = openSet[current.parent] or openSet[start_index]
end
if current_parent
and get_line_of_sight(current_parent.pos, neighbor.pos) then
local temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
local new_gScore = 999
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if temp_gScore < new_gScore then
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
neighbor.parent = minetest.hash_node_position(current_parent.pos)
if openSet[minetest.hash_node_position(neighbor.pos)] then
openSet[minetest.hash_node_position(neighbor.pos)] = nil
end
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
count = count + 1
end
else
local temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
local new_gScore = 999
if openSet[minetest.hash_node_position(neighbor.pos)] then
new_gScore = openSet[minetest.hash_node_position(neighbor.pos)].gScore
end
if temp_gScore < new_gScore then
local hCost = get_distance_to_neighbor(neighbor.pos, _goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
if openSet[minetest.hash_node_position(neighbor.pos)] then
openSet[minetest.hash_node_position(neighbor.pos)] = nil
end
openSet[minetest.hash_node_position(neighbor.pos)] = neighbor
count = count + 1
end
end
end
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
self._path_data = {}
return nil
end
return find_path(start, goal)
end
+627
View File
@@ -0,0 +1,627 @@
-----------------
-- Pathfinding --
-----------------
local a_star_alloted_time = tonumber(minetest.settings:get("creatura_a_star_alloted_time")) or 500
local theta_star_alloted_time = tonumber(minetest.settings:get("creatura_theta_star_alloted_time")) or 700
creatura.pathfinder = {}
local max_open = 300
-- Math
local floor = math.floor
local abs = math.abs
local vec_add, vec_dist, vec_new, vec_round = vector.add, vector.distance, vector.new, vector.round
local function vec_raise(v, n)
return {x = v.x, y = v.y + n, z = v.z}
end
-- Heuristic
local function get_distance(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return 14 * distZ + 10 * (distX - distZ)
else
return 14 * distX + 10 * (distZ - distX)
end
end
local function get_distance_to_neighbor(start_pos, end_pos)
local distX = abs(start_pos.x - end_pos.x)
local distY = abs(start_pos.y - end_pos.y)
local distZ = abs(start_pos.z - end_pos.z)
if distX > distZ then
return (14 * distZ + 10 * (distX - distZ)) * (distY + 1)
else
return (14 * distX + 10 * (distZ - distX)) * (distY + 1)
end
end
-- Blocked Movement Checks
local is_blocked = creatura.is_blocked
local function get_line_of_sight(a, b)
local steps = floor(vec_dist(a, b))
local line = {}
for i = 0, steps do
local pos
if steps > 0 then
pos = {
x = a.x + (b.x - a.x) * (i / steps),
y = a.y + (b.y - a.y) * (i / steps),
z = a.z + (b.z - a.z) * (i / steps)
}
else
pos = a
end
table.insert(line, pos)
end
if #line < 1 then
return false
else
for i = 1, #line do
local node = minetest.get_node(line[i])
if creatura.get_node_def(node.name).walkable then
return false
end
end
end
return true
end
local function is_on_ground(pos)
local ground = {
x = pos.x,
y = pos.y - 1,
z = pos.z
}
if creatura.get_node_def(ground).walkable then
return true
end
return false
end
-- Neighbor Check Grids
local neighbor_grid = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1}
}
local neighbor_grid_climb = {
{x = 1, y = 0, z = 0},
{x = 1, y = 0, z = 1},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = -1, y = 0, z = -1},
{x = 0, y = 0, z = -1},
{x = 1, y = 0, z = -1},
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
local neighbor_grid_3d = {
-- Central
{x = 1, y = 0, z = 0},
{x = 0, y = 0, z = 1},
{x = -1, y = 0, z = 0},
{x = 0, y = 0, z = -1},
-- Directly Up or Down
{x = 0, y = 1, z = 0},
{x = 0, y = -1, z = 0}
}
-- Get Neighbors
local function get_neighbors(pos, width, height, open, closed, parent, evaluated)
local result = {}
local neighbor
local can_move
local hashed_pos
local step
for i = 1, #neighbor_grid do
neighbor = vec_add(pos, neighbor_grid[i])
can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if parent
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
can_move = false
end
if open[hashed_pos]
or closed[hashed_pos]
or evaluated[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
if not can_move then -- Step Up
step = vec_raise(neighbor, 1)
can_move = not is_blocked(vec_round(step), width, height)
neighbor = vec_round(step)
else
step = creatura.get_ground_level(vec_new(neighbor), 1)
if step.y < neighbor.y
and not is_blocked(vec_round(step), width, height) then
neighbor = step
end
end
end
if can_move then
table.insert(result, neighbor)
end
evaluated[hashed_pos] = true
end
return result
end
function creatura.pathfinder.get_neighbors_climb(pos, width, height, open, closed)
local result = {}
local neighbor
local can_move
local hashed_pos
local step
for i = 1, #neighbor_grid_climb do
neighbor = vec_add(pos, neighbor_grid_climb[i])
can_move = get_line_of_sight({x = pos.x, y = neighbor.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if open[hashed_pos]
or closed[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
if not can_move then -- Step Up
step = vec_raise(neighbor, 1)
can_move = not is_blocked(vec_round(step), width, height)
neighbor = vec_round(step)
elseif i < 9 then
step = creatura.get_ground_level(vec_new(neighbor), 1)
if step.y < neighbor.y
and not is_blocked(vec_round(step), width, height) then
neighbor = step
end
end
end
if can_move then
table.insert(result, neighbor)
end
end
return result
end
function creatura.pathfinder.get_neighbors_fly(pos, width, height, open, closed, parent)
local result = {}
local neighbor
local can_move
local hashed_pos
for i = 1, #neighbor_grid_3d do
neighbor = vec_add(pos, neighbor_grid_3d[i])
can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if parent
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor) then
can_move = false
end
if open[hashed_pos]
or closed[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
end
if can_move then
table.insert(result, neighbor)
end
end
return result, true
end
function creatura.pathfinder.get_neighbors_swim(pos, width, height, open, closed, parent)
local result = {}
local neighbor
local can_move
local hashed_pos
for i = 1, #neighbor_grid_3d do
neighbor = vec_add(pos, neighbor_grid_3d[i])
can_move = get_line_of_sight({x = pos.x, y = pos.y, z = pos.z}, neighbor)
hashed_pos = minetest.hash_node_position(neighbor)
if (parent
and vec_dist(parent, neighbor) < vec_dist(pos, neighbor))
or creatura.get_node_def(neighbor).drawtype ~= "liquid" then
can_move = false
end
if open[hashed_pos]
or closed[hashed_pos] then
can_move = false
elseif can_move then
can_move = not is_blocked(neighbor, width, height)
end
if can_move then
table.insert(result, neighbor)
end
end
return result, true
end
-- A*
function creatura.pathfinder.find_path(self, pos1, pos2, neighbor_func)
local us_time = minetest.get_us_time()
local check_vertical = false
neighbor_func = neighbor_func or get_neighbors
local start = self._path_data.start or {
x = floor(pos1.x + 0.5),
y = floor(pos1.y + 0.5),
z = floor(pos1.z + 0.5)
}
local goal = {
x = floor(pos2.x + 0.5),
y = floor(pos2.y + 0.5),
z = floor(pos2.z + 0.5)
}
self._path_data.start = start
if goal.x == start.x
and goal.z == start.z then -- No path can be found
self._path_data = {}
return
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local evaluated = {}
local start_index = minetest.hash_node_position(start)
openSet[start_index] = {
pos = start,
parent = nil,
gScore = 0,
fScore = get_distance(start, goal)
}
local count = self._path_data.count or 1
local current_id, current
local adjacent
local neighbor
local temp_gScore
local new_gScore
local hCost
local hashed_pos
local parent_open
local parent_closed
while count > 0 do
-- Initialize ID and data
current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
if not current_id then self._path_data = {} return end -- failsafe
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
if ((check_vertical or is_on_ground(goal))
and current_id == minetest.hash_node_position(goal))
or ((not check_vertical and not is_on_ground(goal))
and goal.x == current.pos.x
and goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then self._path_data = {} return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
parent_open = openSet[current.parent]
parent_closed = closedSet[current.parent]
adjacent, check_vertical = neighbor_func(
current.pos,
self.width,
self.height,
openSet,
closedSet,
(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
evaluated
)
-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached
-- Go through neighboring nodes
for i = 1, #adjacent do
neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
temp_gScore = current.gScore + get_distance_to_neighbor(current.pos, neighbor.pos)
new_gScore = 0
hashed_pos = minetest.hash_node_position(neighbor.pos)
if openSet[hashed_pos] then
new_gScore = openSet[hashed_pos].gScore
end
if (temp_gScore < new_gScore
or not openSet[hashed_pos])
and not closedSet[hashed_pos] then
if not openSet[hashed_pos] then
count = count + 1
end
hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[hashed_pos] = neighbor
end
end
if minetest.get_us_time() - us_time > a_star_alloted_time then
self._path_data = {
start = start,
open = openSet,
closed = closedSet,
count = count
}
return
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
end
-- Theta*
function creatura.pathfinder.find_path_theta(self, pos1, pos2, neighbor_func)
local us_time = minetest.get_us_time()
local check_vertical = false
neighbor_func = neighbor_func or get_neighbors
local start = self._path_data.start or {
x = floor(pos1.x + 0.5),
y = floor(pos1.y + 0.5),
z = floor(pos1.z + 0.5)
}
local goal = {
x = floor(pos2.x + 0.5),
y = floor(pos2.y + 0.5),
z = floor(pos2.z + 0.5)
}
self._path_data.start = start
if goal.x == start.x
and goal.z == start.z then -- No path can be found
return
end
local openSet = self._path_data.open or {}
local closedSet = self._path_data.closed or {}
local evaluated = {}
local start_index = minetest.hash_node_position(start)
openSet[start_index] = {
pos = start,
parent = nil,
gScore = 0,
fScore = get_distance(start, goal)
}
local count = self._path_data.count or 1
local current_id, current
local current_parent
local adjacent
local neighbor
local temp_gScore
local new_gScore
local hCost
local hashed_pos
local parent_open
local parent_closed
while count > 0 do
-- Initialize ID and data
current_id, current = next(openSet)
-- Find lowest f cost
for i, v in pairs(openSet) do
if v.fScore < current.fScore then
current_id = i
current = v
end
end
if not current_id then return end -- failsafe
-- Add lowest fScore to closedSet and remove from openSet
openSet[current_id] = nil
closedSet[current_id] = current
if ((check_vertical or is_on_ground(goal))
and current_id == minetest.hash_node_position(goal))
or ((not check_vertical and not is_on_ground(goal))
and goal.x == current.pos.x
and goal.z == current.pos.z) then
local path = {}
local fail_safe = 0
for _ in pairs(closedSet) do
fail_safe = fail_safe + 1
end
repeat
if not closedSet[current_id] then return end
table.insert(path, closedSet[current_id].pos)
current_id = closedSet[current_id].parent
until current_id == start_index or #path >= fail_safe
if not closedSet[current_id] then self._path_data = {} return nil end
table.insert(path, closedSet[current_id].pos)
local reverse_path = {}
repeat table.insert(reverse_path, table.remove(path)) until #path == 0
self._path_data = {}
return reverse_path
end
parent_open = openSet[current.parent]
parent_closed = closedSet[current.parent]
adjacent, check_vertical = neighbor_func(
current.pos,
self.width,
self.height,
openSet,
closedSet,
(parent_closed and parent_closed.pos) or (parent_open and parent_open.pos),
evaluated
)
-- Fly, Swim, and Climb all return true for check_vertical to properly check if goal has been reached
-- Go through neighboring nodes
for i = 1, #adjacent do
neighbor = {
pos = adjacent[i],
parent = current_id,
gScore = 0,
fScore = 0
}
hashed_pos = minetest.hash_node_position(neighbor.pos)
if not openSet[hashed_pos]
and not closedSet[hashed_pos] then
current_parent = closedSet[current.parent] or closedSet[start_index]
if not current_parent then
current_parent = openSet[current.parent] or openSet[start_index]
end
if current_parent
and get_line_of_sight(current_parent.pos, neighbor.pos) then
temp_gScore = current_parent.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
new_gScore = 999
if openSet[hashed_pos] then
new_gScore = openSet[hashed_pos].gScore
end
if temp_gScore < new_gScore then
hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
neighbor.parent = minetest.hash_node_position(current_parent.pos)
openSet[hashed_pos] = neighbor
count = count + 1
end
else
temp_gScore = current.gScore + get_distance_to_neighbor(current_parent.pos, neighbor.pos)
new_gScore = 999
if openSet[hashed_pos] then
new_gScore = openSet[hashed_pos].gScore
end
if temp_gScore < new_gScore then
hCost = get_distance_to_neighbor(neighbor.pos, goal)
neighbor.gScore = temp_gScore
neighbor.fScore = temp_gScore + hCost
openSet[hashed_pos] = neighbor
count = count + 1
end
end
end
end
if minetest.get_us_time() - us_time > theta_star_alloted_time then
self._path_data = {
start = start,
open = openSet,
closed = closedSet,
count = count
}
return
end
if count > (max_open or 100) then
self._path_data = {}
return
end
end
end
+32
View File
@@ -0,0 +1,32 @@
# How mobs step up nodes.
#
# - Simple means mobs use Minetests builtin stepping.
# - Fancy means mobs will step up nodes with a quick hop but can cause lag.
creatura_step_type (Step Type) enum simple simple,fancy
# How often (in seconds) the spawn ABM is called
creatura_spawn_interval (Spawn ABM Interval) int 10
# Allows Mobs to spawn during chunk generation (If dependent mods use spawn_on_gen)
creatura_mapgen_spawning (Mapgen Spawning) bool true
# How many chunks are generated before a Mob can spawn
creatura_mapgen_spawn_interval (Mapgen Spawning Interval) int 64
# How many Mobs can be a in a Mapblock before ABM spawning is blocked
creatura_mapblock_limit (Max Mobs per Mapblock) int 12
# How many Mobs can be within Active Block Send Range of an attempted spawn before stopping attempt
creatura_abr_limit (Max Mobs within ABR) int 24
# Minimum distance to a player for ABM Spawning
creatura_min_abm_dist (Minimum ABM Spawning Distance) int 32
# Allows Mobs to spawn in protected areas
creatura_protected_spawn (Protected Area Spawning) bool true
# Allotted time (in μs) per step for A* pathfinding (lower means less lag but slower pathfinding)
creatura_a_star_alloted_time (A* Pathfinding Alloted time per step) int 500
# Allotted time (in μs) per step for Theta* pathfinding (lower means less lag but slower pathfinding)
creatura_theta_star_alloted_time (Theta* Pathfinding Alloted time per step) int 700
Binary file not shown.
Binary file not shown.
Binary file not shown.
+573
View File
@@ -0,0 +1,573 @@
--------------
-- Spawning --
--------------
creatura.registered_mob_spawns = {}
creatura.registered_on_spawns = {}
-- Math --
local abs = math.abs
local ceil = math.ceil
local pi = math.pi
local random = math.random
local min = math.min
local vec_add, vec_dist, vec_sub = vector.add, vector.distance, vector.subtract
-- Utility Functions --
local function format_name(str)
if str then
if str:match(":") then str = str:split(":")[2] end
return (string.gsub(" " .. str, "%W%l", string.upper):sub(2):gsub("_", " "))
end
end
local function table_contains(tbl, val)
for _, v in pairs(tbl) do
if v == val then
return true
end
end
return false
end
local function pos_meets_params(pos, def)
if not minetest.find_nodes_in_area(pos, pos, def.nodes) then return false end
if not minetest.find_node_near(pos, 1, def.neighbors) then return false end
return true
end
local function can_spawn(pos, width, height)
local pos2
local w_iter = width / ceil(width)
for y = 0, height, height / ceil(height) do
for z = -width, width, w_iter do
for x = -width, width, w_iter do
pos2 = {x = pos.x + x, y = pos.y + y, z = pos.z + z}
local def = creatura.get_node_def(pos2)
if def.walkable then return false end
end
end
end
return true
end
local function do_on_spawn(pos, obj)
local name = obj and obj:get_luaentity().name
if not name then return end
local spawn_functions = creatura.registered_on_spawns[name] or {}
if #spawn_functions > 0 then
for _, func in ipairs(spawn_functions) do
func(obj:get_luaentity(), pos)
if not obj:get_yaw() then break end
end
end
end
----------------
-- Spawn Item --
----------------
local creative = minetest.settings:get_bool("creative_mode")
function creatura.register_spawn_item(name, def)
local inventory_image
if not def.inventory_image
and ((def.col1 and def.col2)
or (def.hex_primary and def.hex_secondary)) then
local primary = def.col1 or def.hex_primary
local secondary = def.col2 or def.hex_secondary
local base = "(creatura_spawning_crystal_primary.png^[multiply:#" .. primary .. ")"
local spots = "(creatura_spawning_crystal_secondary.png^[multiply:#" .. secondary .. ")"
inventory_image = base .. "^" .. spots
end
local mod_name = name:split(":")[1]
local mob_name = name:split(":")[2]
def.description = def.description or "Spawn " .. format_name(name)
def.inventory_image = def.inventory_image or inventory_image
def.on_place = function(itemstack, player, pointed_thing)
-- If the player right-clicks something like a chest or item frame then
-- run the node's on_rightclick callback
local under = pointed_thing.under
local node = minetest.get_node(under)
local node_def = minetest.registered_nodes[node.name]
if node_def and node_def.on_rightclick and
not (player and player:is_player() and
player:get_player_control().sneak) then
return node_def.on_rightclick(under, node, player, itemstack,
pointed_thing) or itemstack
end
-- Otherwise spawn the mob
local pos = minetest.get_pointed_thing_position(pointed_thing, true)
if minetest.is_protected(pos, player and player:get_player_name() or "") then return end
local mobdef = minetest.registered_entities[name]
local spawn_offset = abs(mobdef.collisionbox[2])
pos.y = (pos.y - 0.49) + spawn_offset
if def.antispam then
local objs = minetest.get_objects_in_area(vec_sub(pos, 0.51), vec_add(pos, 0.51))
for _, obj in ipairs(objs) do
if obj
and obj:get_luaentity()
and obj:get_luaentity().name == name then
return
end
end
end
local object = minetest.add_entity(pos, name)
if object then
object:set_yaw(random(0, pi * 2))
object:get_luaentity().last_yaw = object:get_yaw()
if def.on_spawn then
def.on_spawn(object:get_luaentity(), player)
end
end
if not minetest.is_creative_enabled(player:get_player_name())
or def.consume_in_creative then
itemstack:take_item()
return itemstack
end
end
minetest.register_craftitem(def.itemstring or (mod_name .. ":spawn_" .. mob_name), def)
end
function creatura.register_on_spawn(name, func)
if not creatura.registered_on_spawns[name] then
creatura.registered_on_spawns[name] = {}
end
table.insert(creatura.registered_on_spawns[name], func)
end
--------------
-- Spawning --
--------------
--[[creatura.register_abm_spawn("mymod:mymob", {
chance = 3000,
interval = 30,
min_height = 0,
max_height = 128,
min_light = 1,
max_light = 15,
min_group = 1,
max_group = 4,
nodes = {"group:soil", "group:stone"},
neighbors = {"air"},
spawn_on_load = false,
spawn_in_nodes = false,
spawn_cap = 5
})]]
local protected_spawn = minetest.settings:get_bool("creatura_protected_spawn", true)
local abr = (tonumber(minetest.get_mapgen_setting("active_block_range")) or 4) * 16
local max_per_block = tonumber(minetest.settings:get("creatura_mapblock_limit")) or 12
local max_in_abr = tonumber(minetest.settings:get("creatura_abr_limit")) or 24
local min_abm_dist = min(abr / 2, tonumber(minetest.settings:get("creatura_min_abm_dist")) or 32)
local mobs_spawn = minetest.settings:get_bool("mobs_spawn") ~= false
local mapgen_mobs = {}
function creatura.register_abm_spawn(mob, def)
local chance = def.chance or 3000
local interval = def.interval or 30
local min_height = def.min_height or 0
local max_height = def.max_height or 128
local min_time = def.min_time or 0
local max_time = def.max_time or 24000
local min_light = def.min_light or 1
local max_light = def.max_light or 15
local min_group = def.min_group or 1
local max_group = def.max_group or 4
local block_protected = def.block_protected_spawn or false
local biomes = def.biomes or {}
local nodes = def.nodes or {"group:soil", "group:stone"}
local neighbors = def.neighbors or {"air"}
local spawn_on_load = def.spawn_on_load or false
local spawn_in_nodes = def.spawn_in_nodes or false
local spawn_cap = def.spawn_cap or 5
local function spawn_func(pos, aocw)
if not mobs_spawn then
return
end
if not spawn_in_nodes then
pos.y = pos.y + 1
end
if (not protected_spawn
or block_protected)
and minetest.is_protected(pos, "") then
return
end
local tod = (minetest.get_timeofday() or 0) * 24000
local bounds_in = tod >= min_time and tod <= max_time
local bounds_ex = tod >= max_time or tod <= min_time
if (max_time > min_time and not bounds_in)
or (min_time > max_time and not bounds_ex) then
return
end
local light = minetest.get_node_light(pos) or 7
if light > max_light
or light < min_light then
return
end
if aocw
and aocw >= max_per_block then
return
end
if biomes
and #biomes > 0 then
local biome_id = minetest.get_biome_data(pos).biome
local biome_name = minetest.get_biome_name(biome_id)
local is_spawn_biome = false
for _, biome in ipairs(biomes) do
if biome:match("^" .. biome_name) then
is_spawn_biome = true
break
end
end
if not is_spawn_biome then return end
end
local mob_count = 0
local plyr_found = false
local objects = minetest.get_objects_inside_radius(pos, abr)
for _, object in ipairs(objects) do
local ent = object:get_luaentity()
if ent
and ent.name == mob then
mob_count = mob_count + 1
if mob_count > spawn_cap
or mob_count > max_in_abr then
return
end
end
if object:is_player() then
plyr_found = true
if vec_dist(pos, object:get_pos()) < min_abm_dist then
return
end
end
end
if not plyr_found then
return
end
local mob_def = minetest.registered_entities[mob]
local mob_width = mob_def.collisionbox[4]
local mob_height = mob_def.collisionbox[5]
if not can_spawn(pos, mob_width, mob_height) then
return
end
local group_size = random(min_group or 1, max_group or 1)
local obj
if group_size > 1 then
local offset
local spawn_pos
for _ = 1, group_size do
offset = ceil(mob_width)
spawn_pos = creatura.get_ground_level({
x = pos.x + random(-offset, offset),
y = pos.y,
z = pos.z + random(-offset, offset)
}, 3)
if not can_spawn(spawn_pos, mob_width, mob_height) then
spawn_pos = pos
end
obj = minetest.add_entity(spawn_pos, mob)
do_on_spawn(spawn_pos, obj)
end
else
obj = minetest.add_entity(pos, mob)
do_on_spawn(pos, obj)
end
minetest.log("action",
"[Creatura] [ABM Spawning] Spawned " .. group_size .. " " .. mob .. " at " .. minetest.pos_to_string(pos))
end
minetest.register_abm({
label = mob .. " spawning",
nodenames = nodes,
neighbors = neighbors,
interval = interval,
chance = chance,
min_y = min_height,
max_y = max_height,
catch_up = false,
action = function(pos, _, _, aocw)
spawn_func(pos, aocw)
end
})
if spawn_on_load then
table.insert(mapgen_mobs, mob)
end
creatura.registered_mob_spawns[mob] = {
chance = def.chance or 3000,
interval = def.interval or 30,
min_height = def.min_height or 0,
max_height = def.max_height or 128,
min_time = def.min_time or 0,
max_time = def.max_time or 24000,
min_light = def.min_light or 1,
max_light = def.max_light or 15,
min_group = def.min_group or 1,
max_group = def.max_group or 4,
block_protected = def.block_protected_spawn or false,
biomes = def.biomes or {},
nodes = def.nodes or {"group:soil", "group:stone"},
neighbors = def.neighbors or {"air"},
spawn_on_load = def.spawn_on_load or false,
spawn_in_nodes = def.spawn_in_nodes or false,
spawn_cap = def.spawn_cap or 5
}
end
----------------
-- DEPRECATED --
----------------
-- Mapgen --
minetest.register_node("creatura:spawn_node", {
drawtype = "airlike",
groups = {not_in_creative_inventory = 1},
walkable = false
})
local mapgen_spawning = false
local mapgen_spawning_int = tonumber(minetest.settings:get("creatura_mapgen_spawn_interval")) or 64
if mapgen_spawning then
local chunk_delay = 0
local c_air = minetest.get_content_id("air")
local c_spawn = minetest.get_content_id("creatura:spawn_node")
minetest.register_on_generated(function(minp, maxp)
if chunk_delay > 0 then chunk_delay = chunk_delay - 1 end
local meta_queue = {}
local vm, emin, emax = minetest.get_mapgen_object("voxelmanip")
local area = VoxelArea:new{MinEdge = emin, MaxEdge = emax}
local data = vm:get_data()
local min_x, max_x = minp.x, maxp.x
local min_y, max_y = minp.y, maxp.y
local min_z, max_z = minp.z, maxp.z
local def
local center
local current_biome
local spawn_biomes
local current_pos
for _, mob_name in ipairs(mapgen_mobs) do
local mob_spawned = false
def = creatura.registered_mob_spawns[mob_name]
center = {
x = min_x + (max_x - min_x) * 0.5,
y = min_y + (max_y - min_y) * 0.5,
z = min_z + (max_z - min_z) * 0.5
}
current_biome = minetest.get_biome_name(minetest.get_biome_data(center).biome)
spawn_biomes = def.biomes
if not mob_spawned
and (not spawn_biomes
or table_contains(spawn_biomes, current_biome)) then
for z = min_z + 8, max_z - 7, 8 do
if mob_spawned then break end
for x = min_x + 8, max_x - 7, 8 do
if mob_spawned then break end
for y = min_y, max_y do
local vi = area:index(x, y, z)
if data[vi] == c_air
or data[vi] == c_spawn then
break
end
-- Check if position is outside of vertical bounds
if y > def.max_height
or y < def.min_height then
break
end
current_pos = vector.new(x, y, z)
-- Check if position has required nodes
if not pos_meets_params(current_pos, def) then
break
end
if def.spawn_in_nodes then
-- Add Spawn Node to Map
data[vi] = c_spawn
local group_size = random(def.min_group or 1, def.max_group or 1)
table.insert(meta_queue, {pos = current_pos, mob = mob_name, cluster = group_size})
mob_spawned = true
break
elseif data[area:index(x, y + 1, z)] == c_air then
vi = area:index(x, y + 1, z)
current_pos = vector.new(x, y + 1, z)
-- Add Spawn Node to Map
data[vi] = c_spawn
local group_size = random(def.min_group or 1, def.max_group or 1)
table.insert(meta_queue, {pos = current_pos, mob = mob_name, cluster = group_size})
mob_spawned = true
break
end
end
end
end
end
end
if #meta_queue > 0 then
vm:set_data(data)
vm:write_to_map()
for _, unset_meta in ipairs(meta_queue) do
local pos = unset_meta.pos
local mob = unset_meta.mob
local cluster = unset_meta.cluster
local meta = minetest.get_meta(pos)
meta:set_string("mob", mob)
meta:set_int("cluster", cluster)
end
chunk_delay = mapgen_spawning_int
end
end)
local spawn_interval = tonumber(minetest.settings:get("creatura_spawn_interval")) or 10
minetest.register_abm({
label = "Creatura Spawning",
nodenames = {"creatura:spawn_node"},
interval = spawn_interval,
chance = 1,
action = function(pos)
local plyr_found = false
local objects = minetest.get_objects_inside_radius(pos, abr)
for _, object in ipairs(objects) do
if object:is_player() then
plyr_found = true
break
end
end
if not plyr_found then return end
local meta = minetest.get_meta(pos)
local name = meta:get_string("mob") or ""
if name == "" then minetest.remove_node(pos) return end
local amount = meta:get_int("cluster")
local obj
if amount > 0 then
for _ = 1, amount do
obj = minetest.add_entity(pos, name)
do_on_spawn(pos, obj)
end
minetest.log("action",
"[Creatura] Spawned " .. amount .. " " .. name .. " at " .. minetest.pos_to_string(pos))
else
obj = minetest.add_entity(pos, name)
do_on_spawn(pos, obj)
minetest.log("action",
"[Creatura] Spawned a " .. name .. " at " .. minetest.pos_to_string(pos))
end
minetest.remove_node(pos)
end,
})
end
function creatura.register_mob_spawn(name, def)
local spawn_def = {
chance = def.chance or 5,
min_height = def.min_height or 0,
max_height = def.max_height or 128,
min_time = def.min_time or 0,
max_time = def.max_time or 24000,
min_light = def.min_light or 6,
max_light = def.max_light or 15,
min_group = def.min_group or 1,
max_group = def.max_group or 4,
nodes = def.nodes or nil,
biomes = def.biomes or nil,
--spawn_cluster = def.spawn_cluster or false,
spawn_on_load = def.spawn_on_gen or false,
spawn_in_nodes = def.spawn_in_nodes or false,
spawn_cap = def.spawn_cap or 5,
--send_debug = def.send_debug or false
}
--creatura.registered_mob_spawns[name] = spawn_def
creatura.register_abm_spawn(name, spawn_def)
end
function creatura.register_spawn_egg(name, col1, col2, inventory_image)
if col1 and col2 then
local base = "(creatura_spawning_crystal_primary.png^[multiply:#" .. col1 .. ")"
local spots = "(creatura_spawning_crystal_secondary.png^[multiply:#" .. col2 .. ")"
inventory_image = base .. "^" .. spots
end
local mod_name = name:split(":")[1]
local mob_name = name:split(":")[2]
minetest.register_craftitem(mod_name .. ":spawn_" .. mob_name, {
description = "Spawn " .. format_name(name),
inventory_image = inventory_image,
stack_max = 99,
on_place = function(itemstack, _, pointed_thing)
local mobdef = minetest.registered_entities[name]
local spawn_offset = abs(mobdef.collisionbox[2])
local pos = minetest.get_pointed_thing_position(pointed_thing, true)
pos.y = (pos.y - 0.4) + spawn_offset
local object = minetest.add_entity(pos, name)
if object then
object:set_yaw(random(1, 6))
object:get_luaentity().last_yaw = object:get_yaw()
end
if not creative then
itemstack:take_item()
return itemstack
end
end
})
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

+20 -1
View File
@@ -11,7 +11,7 @@ return function(hearth, api, register_node, construction)
for i, name in ipairs(list) do out[i] = node(name) end for i, name in ipairs(list) do out[i] = node(name) end
return out return out
end end
M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"} M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"} M.rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
-- Moisture/fertility/drainage are normalized habitat preferences, NOT player -- Moisture/fertility/drainage are normalized habitat preferences, NOT player
-- climate values. The succession owner decides how to consume these hints. -- climate values. The succession owner decides how to consume these hints.
@@ -69,6 +69,15 @@ return function(hearth, api, register_node, construction)
spruce = {height=10, width=5, crown="spire", heat={0,40}, humidity={40,85}, 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"}, 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}, biomes={"hearth_pine_taiga", "hearth_birch_forest"}, shade=0.85, litter=0.5},
crab_apple = {height=6,width=7,crown="round",heat={35,75},humidity={30,75},
y_min=3,y_max=100,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.65,litter=0.7},
wild_cherry = {height=9,width=7,crown="horizontal",heat={35,75},humidity={30,70},
y_min=4,y_max=120,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.6,litter=0.8},
field_maple = {height=7,width=9,crown="lobed_broad",heat={35,80},humidity={25,75},
y_min=3,y_max=110,soils={"grass","forest_floor","soil"},
biomes={"hearth_temperate","hearth_oak_woodland"},shade=0.8,litter=0.8},
} }
function M.supports_species(species, surface) function M.supports_species(species, surface)
local p = M.profiles[species] local p = M.profiles[species]
@@ -122,6 +131,16 @@ return function(hearth, api, register_node, construction)
elseif species == "spruce" then elseif species == "spruce" then
local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1 local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1
leaf = r <= radius and dx+dz <= radius+1 leaf = r <= radius and dx+dz <= radius+1
elseif species == "crab_apple" then
leaf = y>=2 and dx*dx+dz*dz <= (y==h-1 and 2 or (y==2 and 5 or 10))
trunk = trunk or (y==2 and dx+dz<=2 and (dx==0 or dz==0))
elseif species == "wild_cherry" then
leaf = y>=4 and r<=(y==h-1 and 1 or 3) and dx+dz<=4 and (y%2==0 or r<=1)
trunk = trunk or ((y==4 or y==6) and dz==0 and dx<=2)
elseif species == "field_maple" then
leaf = y>=2 and r<=(y==h-1 and 2 or 4) and dx+dz<=(y==h-1 and 3 or 5)
and not (dx==dz and r>=2)
trunk = trunk or (y==3 and dx+dz<=3 and (dx==0 or dz==0))
end end
local stem = canonical and "log" or species .. "_log" local stem = canonical and "log" or species .. "_log"
local leaves = canonical and "leaves" or species .. "_leaves" local leaves = canonical and "leaves" or species .. "_leaves"
+72 -12
View File
@@ -54,6 +54,9 @@ return function(hearth, api)
add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery") add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery")
add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery") add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery")
add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin") add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin")
add("crab_apple","Crab apple","Malus sylvestris","wood",grow,"pollinator,browse,nursery")
add("wild_cherry","Wild cherry","Prunus avium","wood",grow,"pollinator,browse,nursery")
add("field_maple","Field maple","Acer campestre","wood",grow,"pollinator,browse,nursery")
group="shrubs" group="shrubs"
add("bilberry","Bilberry","Vaccinium myrtillus","acid",grow,"pollinator,browse,nursery",{food=true}) 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("blackberry","Pleated blackberry","Rubus plicatus","shrub",grow,"pollinator,browse,nursery",{food=true})
@@ -65,6 +68,10 @@ return function(hearth, api)
add("holly","European holly","Ilex aquifolium","wood",year,"pollinator,browse,nursery",{toxic=true}) 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("juniper","Common juniper","Juniperus communis","acid",year,"browse,nursery,resin")
add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true}) add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true})
add("honeysuckle","Honeysuckle","Lonicera periclymenum","shrub",grow,"pollinator,nursery",{toxic=true})
add("ivy","Ivy","Hedera helix","shade",year,"pollinator,nursery",{toxic=true})
add("gorse","Gorse","Ulex europaeus","acid",year,"nitrogenfixer,pollinator,nursery",{toxic=true})
add("broom","Broom","Cytisus scoparius","acid",grow,"nitrogenfixer,pollinator,nursery",{toxic=true})
group="grasses" group="grasses"
add("sedge","Greater tussock-sedge","Carex paniculata","bank",grow,"browse,engineer,nursery",{existing=true}) 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("bulrush","Bulrush (great reedmace)","Typha latifolia","bank",grow,"engineer,nursery",{existing=true})
@@ -87,6 +94,18 @@ return function(hearth, api)
add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator") add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator")
add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator") add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator")
add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true}) add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true})
add("dandelion","Dandelion","Taraxacum officinale","meadow",grow,"pollinator,browse")
add("daisy","Daisy","Bellis perennis","meadow",grow,"pollinator")
add("red_clover","Red clover","Trifolium pratense","meadow",grow,"nitrogenfixer,pollinator,browse")
add("birdsfoot_trefoil","Bird's-foot trefoil","Lotus corniculatus","meadow",grow,"nitrogenfixer,pollinator,browse",{toxic=true})
add("ribwort_plantain","Ribwort plantain","Plantago lanceolata","meadow",grow,"browse")
add("selfheal","Selfheal","Prunella vulgaris","meadow",grow,"pollinator")
add("knapweed","Common knapweed","Centaurea nigra","meadow",grow,"pollinator,nursery")
add("oxeye_daisy","Oxeye daisy","Leucanthemum vulgare","meadow",grow,"pollinator")
add("field_scabious","Field scabious","Knautia arvensis","meadow",grow,"pollinator")
add("teasel","Wild teasel","Dipsacus fullonum","meadow",grow,"pollinator,nursery")
add("cow_parsley","Cow parsley","Anthriscus sylvestris","shrub",grow,"pollinator,nursery")
add("garlic_mustard","Garlic mustard","Alliaria petiolata","shade",grow,"pollinator,nursery")
group="ferns_mosses" group="ferns_mosses"
add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true}) add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true})
add("lady_fern","Lady-fern","Athyrium filix-femina","shade",grow,"nursery") add("lady_fern","Lady-fern","Athyrium filix-femina","shade",grow,"nursery")
@@ -98,6 +117,7 @@ return function(hearth, api)
add("moss","Cypress-leaved plait-moss","Hypnum cupressiforme","moss",year,"engineer,nursery",{existing=true}) 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("fork_moss","Broom fork-moss","Dicranum scoparium","moss",year,"nursery")
add("star_moss","Swan's-neck thyme-moss","Mnium hornum","moss",year,"nursery") add("star_moss","Swan's-neck thyme-moss","Mnium hornum","moss",year,"nursery")
add("holly_fern","Soft shield-fern (contract ID holly fern)","Polystichum setiferum","shade",year,"nursery")
group="fungi" group="fungi"
add("fly_agaric","Fly agaric","Amanita muscaria","litter","Summer,Autumn","ectomycorrhiza",{toxic=true}) 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("birch_bolete","Brown birch bolete","Leccinum scabrum","litter","Summer,Autumn","ectomycorrhiza")
@@ -137,6 +157,16 @@ return function(hearth, api)
niche("rowan",{light={5,15},moisture={.3,.85}}) niche("rowan",{light={5,15},moisture={.3,.85}})
niche("spruce",{moisture={.4,.85},light={4,15}}) niche("spruce",{moisture={.4,.85},light={4,15}})
niche("hazel",{ph={5,8}}) niche("hazel",{ph={5,8}})
niche("crab_apple",{light={7,15},moisture={.3,.75},ph={5,8}})
niche("wild_cherry",{light={7,15},moisture={.3,.7},ph={5.5,8}})
niche("field_maple",{light={5,15},moisture={.25,.75},ph={5.5,8}})
niche("gorse",{light={10,15},moisture={.2,.65},ph={3.5,6.5}})
niche("broom",{light={9,15},moisture={.2,.65},ph={4,7}})
niche("ivy",{light={3,15},moisture={.3,.85},ph={5,8}})
niche("birdsfoot_trefoil",{moisture={.2,.7},ph={5,8}})
niche("ribwort_plantain",{moisture={.2,.8}})
niche("field_scabious",{moisture={.2,.65},ph={6,8}})
niche("holly_fern",{light={3,10},moisture={.5,.9},ph={5,8}})
niche("juniper",{ph={4,8},moisture={.15,.6}}) niche("juniper",{ph={4,8},moisture={.15,.6}})
niche("nettle",{light={5,15},nutrients=1}) niche("nettle",{light={5,15},nutrients=1})
niche("cowslip",{ph={6,8},moisture={.2,.65}}) niche("cowslip",{ph={6,8},moisture={.2,.65}})
@@ -171,8 +201,21 @@ return function(hearth, api)
bluebell="Spring",yarrow="Summer,Autumn",foxglove="Summer",meadowsweet="Summer", bluebell="Spring",yarrow="Summer,Autumn",foxglove="Summer",meadowsweet="Summer",
wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer", wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer",
white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn", white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn",
water_forgetmenot="Spring,Summer",brooklime="Spring,Summer"} water_forgetmenot="Spring,Summer",brooklime="Spring,Summer",
crab_apple="Spring",wild_cherry="Spring",field_maple="Spring",
dandelion="Spring,Summer,Autumn",daisy="Spring,Summer,Autumn",red_clover="Summer,Autumn",
birdsfoot_trefoil="Summer",selfheal="Summer,Autumn",knapweed="Summer,Autumn",
oxeye_daisy="Summer",field_scabious="Summer,Autumn",teasel="Summer",
cow_parsley="Spring,Summer",garlic_mustard="Spring",honeysuckle="Summer,Autumn",
ivy="Autumn",gorse="Spring,Summer,Autumn,Winter",broom="Spring,Summer"}
for id,text in pairs(flowers) do index[id].flowering=set(text) end for id,text in pairs(flowers) do index[id].flowering=set(text) end
-- Seed-season windows are qualitative phenology, not permission to replay
-- every missed crop of seeds. Individual maturity is checked by succession.
for _,r in ipairs(records) do
r.seeding=set(r.group=="trees" and "Autumn" or "Summer,Autumn")
if r.id=="ivy" then r.seeding=set("Spring") end
if r.id=="garlic_mustard" or r.id=="cow_parsley" then r.seeding=set("Summer") end
end
for _,id in ipairs({"bilberry","blackberry","raspberry"}) do for _,id in ipairs({"bilberry","blackberry","raspberry"}) do
index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer") index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer")
end end
@@ -199,7 +242,7 @@ return function(hearth, api)
function B.kind(value) function B.kind(value)
local k=raw_kind(value) local k=raw_kind(value)
if index[k] then return k end if index[k] then return k end
local id,part=(k or ""):match("^([a-z]+)_([a-z]+)$") local id,part=(k or ""):match("^([a-z_]+)_([a-z]+)$")
if index[id] and index[id].group=="trees" and 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 ({seedling=true,young=true,mature=true,leaves=true,log=true,planks=true})[part] then return id end
end end
@@ -235,8 +278,9 @@ return function(hearth, api)
end end
end end
function B.pollinator(value,season) function B.pollinator(value,season)
local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering[season]==true local r=record(value); return r~=nil and r.tags.pollinator==true and r.flowering~=nil and r.flowering[season]==true
end end
function B.seeding(value,season) local r=record(value); return r~=nil and r.seeding[season]==true end
function B.edible(value,season) 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 local r=record(value); return r~=nil and r.food==true and not r.toxic and r.fruiting[season]==true
end end
@@ -285,10 +329,11 @@ return function(hearth, api)
end end
end end
local DAY=86400 local DAY=86400
local YEAR=365.2425*DAY -- shared with succession.frugivore; resource policy, not lifespan
local function now() local function now()
local n local n
if api.now then n=api.now() else n=os.time() end 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 if not finite(n) or n<0 or n>9007199254740991-YEAR then return nil end
return math.floor(n) return math.floor(n)
end end
function B.harvest(pos,player) function B.harvest(pos,player)
@@ -309,17 +354,32 @@ return function(hearth, api)
local stored=meta:get_string("botany_ready_at") local stored=meta:get_string("botany_ready_at")
local ready=0 local ready=0
if stored~="" then ready=tonumber(stored) end if stored~="" then ready=tonumber(stored) end
if not finite(ready) or ready<0 then return false,"metadata" end if not finite(ready) or ready<0 or ready>9007199254740991 then return false,"metadata" end
if ready>time then return false,"cooldown" end if ready>time then return false,"cooldown" end
local pollen_stored=meta:get_string("succ_pollinated_until")
local pollen=pollen_stored=="" and 0 or tonumber(pollen_stored)
-- Match succession.pollinate's exact expiry and one-day future bound.
if not finite(pollen) or pollen<0 or pollen>time+DAY then return false,"metadata" end
local amount=2+(pollen>time and 1 or 0)
local inv=player:get_inventory() local inv=player:get_inventory()
if not inv or not inv:room_for_item("main","hearth:berries 2") then return false,"inventory" end if not inv then return false,"inventory" end
-- Lock before inventory callbacks: reentrant harvest cannot duplicate fruit. -- Reserve both budgets before even room_for_item: callbacks may reenter.
meta:set_string("botany_ready_at",tostring(time+DAY)) -- Pollination modifies an existing standing crop; it never creates fruit
local leftover=inv:add_item("main","hearth:berries 2") -- on an empty/cooling bush and is a boolean bonus, not doubled inventory.
meta:set_string("botany_ready_at",tostring(time+YEAR))
meta:set_string("succ_pollinated_until","0")
local function rollback()
meta:set_string("botany_ready_at",stored)
meta:set_string("succ_pollinated_until",pollen_stored)
end
local item="hearth:berries "..amount
if not inv:room_for_item("main",item) then rollback(); return false,"inventory" end
local leftover=inv:add_item("main",item)
local count=leftover and leftover:get_count() local count=leftover and leftover:get_count()
if not finite(count) or count<0 or count>2 then return false,"inventory" end -- An invalid delivery result cannot safely be retried: keep the lock.
if count==2 then meta:set_string("botany_ready_at",stored); return false,"inventory" end if not finite(count) or count%1~=0 or count<0 or count>amount then return false,"inventory" end
return true,2-count if count==amount then rollback(); return false,"inventory" end
return true,amount-count
end end
local registered=false local registered=false
function B.register_nodes(register_node) function B.register_nodes(register_node)
+17 -12
View File
@@ -3,19 +3,19 @@ return function(hearth, api)
local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"} local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"}
local key = "hearth:appearance" local key = "hearth:appearance"
local options = { local options = {
body = {"compact", "balanced", "broad"}, body = {"compact", "lean", "balanced", "broad", "sturdy"},
skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"}, skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"},
face = {"calm", "angular", "soft", "weathered"}, face = {"calm", "angular", "soft", "weathered", "lined"},
eyes = {"hazel", "brown", "green", "blue", "grey"}, eyes = {"hazel", "brown", "green", "blue", "grey"},
hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none"}, hair = {"cropped", "sidepart", "wavy", "braids", "long", "shaved", "none", "curly", "topknot", "undercut"},
hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"}, hairColor = {"ash", "flax", "chestnut", "auburn", "black", "silver"},
beard = {"none", "stubble", "moustache", "short_beard", "full_beard"}, beard = {"none", "stubble", "moustache", "short_beard", "full_beard", "goatee", "braided_beard"},
garment = {"tunic", "workshirt", "jerkin", "coat"}, garment = {"tunic", "workshirt", "jerkin", "coat", "hooded_cloak", "travel_coat", "wool_mantle"},
cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"}, trousers = {"earth", "slate", "moss", "charcoal"}, boots = {"walnut", "black", "tan"},
accent = {"none", "scarf", "apron", "sash", "vest"}, accent = {"none", "scarf", "apron", "sash", "vest", "satchel", "tool_belt", "pauldrons", "hood"},
accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, accentColor = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"},
detail = {"none", "freckles", "scar", "freckles_scar"}, detail = {"none", "freckles", "scar", "freckles_scar", "birthmark"},
} }
local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment", local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment",
"cloth", "trousers", "boots", "accent", "accentColor", "detail"} "cloth", "trousers", "boots", "accent", "accentColor", "detail"}
@@ -83,8 +83,8 @@ return function(hearth, api)
local function name(stem) return "hearth_char_" .. stem .. ".png" 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), 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("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("hair_" .. p.hair .. "_" .. p.hairColor), name("beard_" .. p.beard .. "_" .. p.hairColor),
name("beard_" .. p.beard .. "_" .. p.hairColor), name("detail_" .. p.detail)}, "^") name("accent_" .. p.accent .. "_" .. p.accentColor), name("detail_" .. p.detail)}, "^")
return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture
end end
local function publish(player, value) local function publish(player, value)
@@ -120,7 +120,12 @@ return function(hearth, api)
hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment", hair = "Hair style", hairColor = "Hair & facial hair color", beard = "Facial hair", garment = "Garment",
cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent", cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent",
accentColor = "Accent color", detail = "Face details"} accentColor = "Accent color", detail = "Face details"}
local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar"} local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar",
hooded_cloak = "Hooded cloak", travel_coat = "Travel coat", wool_mantle = "Wool mantle",
tool_belt = "Tool belt", braided_beard = "Braided beard", body="Body shape",
compact="Compact", lean="Lean", balanced="Balanced", broad="Broad", sturdy="Sturdy",
curly="Curly", topknot="Topknot", undercut="Undercut", goatee="Goatee",
birthmark="Birthmark", pauldrons="Shoulder guards"}
local function title(id) local function title(id)
return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper)) return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper))
end end
@@ -146,7 +151,7 @@ return function(hearth, api)
"model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture) "model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture)
.. ";0," .. st.yaw .. ";true;true;0,19;12]", .. ";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]", "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.]"} "tooltip[traveler;" .. esc("Drag to turn the preview. Only this model rotates.") .. "]"}
for i, tab in ipairs(tabs) do 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] .. "]" f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]"
end end
@@ -184,7 +189,7 @@ return function(hearth, api)
for k, v in pairs(fields) do for k, v in pairs(fields) do
count = count + 1; if count > 20 then return false end count = count + 1; if count > 20 then return false end
if visible[k] then if visible[k] then
local n = type(v) == "string" and #v <= 1 and v:match("^[1-9]$") and tonumber(v) local n = type(v) == "string" and #v <= 2 and v:match("^[1-9][0-9]?$") and tonumber(v)
if not n or not options[visible[k]][n] then return false end if not n or not options[visible[k]][n] then return false end
draft[visible[k]] = options[visible[k]][n] draft[visible[k]] = options[visible[k]][n]
elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean") elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean")
+7
View File
@@ -25,6 +25,8 @@ local api = setmetatable({
end, end,
}, {__index = core}) }, {__index = core})
local modpath = api.get_modpath("hearth") local modpath = api.get_modpath("hearth")
-- Plain policy namespace, available before any factory captures calendar APIs.
hearth.lifecycle = dofile(modpath .. "/lifecycle.lua")
local construction = dofile(modpath .. "/construction.lua")(hearth, api) local construction = dofile(modpath .. "/construction.lua")(hearth, api)
local seasons = dofile(modpath .. "/seasons.lua")(hearth, api) local seasons = dofile(modpath .. "/seasons.lua")(hearth, api)
hearth.seasons = seasons hearth.seasons = seasons
@@ -796,4 +798,9 @@ botany.register_nodes(register_node)
-- Species leaves and canonical ecological cubes/sprites must precede variants. -- Species leaves and canonical ecological cubes/sprites must precede variants.
seasons.register() seasons.register()
-- Creatura's MIT-licensed boid and ambient-spawn APIs are optional at runtime;
-- the managed package pins and bundles the ContentDB 0.2.10 release.
if type(creatura) == "table" then hearth.creatura = creatura end
hearth.wildlife = dofile(modpath .. "/wildlife.lua")(hearth, api) hearth.wildlife = dofile(modpath .. "/wildlife.lua")(hearth, api)
-- Wildlife owns factory loading; publish that exact catalogue, not a second one.
hearth.fauna = hearth.wildlife.fauna
+33 -11
View File
@@ -1,6 +1,6 @@
-- Species/niche policy only. Succession owns the one fixed-eight scheduler. -- Species/niche policy only. Succession owns the one fixed-eight scheduler.
return function(hearth, api) return function(hearth, api)
local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce"}, local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"},
plants={"clover","nettle","heather","bluebell","sedge","bulrush","moss","lichen"}, 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}, 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}} rocks={stone=true,granite=true,basalt=true,limestone=true,slate=true,sandstone=true,scree=true}}
@@ -14,19 +14,23 @@ return function(hearth, api)
for _, plant in ipairs(D.plants) do flora[plant]=true end for _, plant in ipairs(D.plants) do flora[plant]=true end
function D.tree_info(value) function D.tree_info(value)
local k=kind(value) local k=kind(value)
local s,stage=(k or ""):match("^([a-z]+)_([a-z]+)$") 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 if species[s] and ({seedling=true,young=true,mature=true})[stage] then return s,stage end
end end
function D.family(value) function D.family(value)
local k=kind(value) local k=kind(value)
local s,stage=D.tree_info(k) local s,stage=D.tree_info(k)
if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end
local wood,part=(k or ""):match("^([a-z]+)_(leaves)$") local wood,part=(k or ""):match("^([a-z_]+)_(leaves)$")
if species[wood] then return part end if species[wood] then return part end
wood,part=(k or ""):match("^([a-z]+)_(log)$") wood,part=(k or ""):match("^([a-z_]+)_(log)$")
return species[wood] and part or k return species[wood] and part or k
end end
function D.is_worldgen_flora(value) return flora[kind(value)]==true or D.tree_info(value)~=nil end function D.is_worldgen_flora(value)
return flora[kind(value)]==true or D.tree_info(value)~=nil
or (hearth.botany and hearth.botany.kind(value)~=nil and D.family(value)~="log" and D.family(value)~="leaves"
and not (kind(value) or ""):match("_planks$")) or false
end
function D.is_ground(value) return D.grounds[kind(value)]==true 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.is_rock(value) return D.rocks[kind(value)]==true end
function D.supports_ground(value, support) function D.supports_ground(value, support)
@@ -39,7 +43,9 @@ return function(hearth, api)
alder={alluvium=true,peat=true,forest_floor=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}, 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}, 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}} hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true},
crab_apple={grass=true,soil=true,forest_floor=true},wild_cherry={grass=true,soil=true,forest_floor=true},
field_maple={grass=true,soil=true,forest_floor=true}}
function D.supports_species(s, support) function D.supports_species(s, support)
if not species[s] then return false end if not species[s] then return false end
local b=hearth.biomes local b=hearth.biomes
@@ -86,9 +92,9 @@ return function(hearth, api)
choices=(h.ground=="peat" or (p.x+p.z)%2~=0) and {"alder","willow"} or {"willow","alder"} 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"} elseif h.ground=="forest_floor" and (h.canopy or 0)>=0.5 then choices={"oak","birch"}
else choices={"birch","oak"} end else choices={"birch","oak"} end
-- Retain the old pioneer niches at the origin while admitting all ten -- Retain the old pioneer niches at the origin while admitting all thirteen
-- species deterministically; no random reroll or extra world scan. -- 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] local extra=({"beech","aspen","rowan","hazel","spruce","crab_apple","wild_cherry","field_maple"})[math.abs(p.x*7+p.z*11)%13]
if extra and D.suitable(extra.."_seedling",h) then return extra.."_seedling" end 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 for _,s in ipairs(choices) do if D.suitable(s.."_seedling",h) then return s.."_seedling" end end
end end
@@ -150,15 +156,24 @@ return function(hearth, api)
local current=api.get_node_or_nil(p) local current=api.get_node_or_nil(p)
if not current or (n and current.name~=n.name) then return false end if not current or (n and current.name~=n.name) then return false end
n=current n=current
-- Only fallen logs join the existing flora adoption policy; generic local root_species=kind(n) and kind(n):match("^([a-z_]+)_log$")
-- litter/fungi and standing log/leaves trees are not newly adopted. local block_root=(species[root_species] or kind(n)=="log") and hearth.botany~=nil
if not n or not (D.is_worldgen_flora(n) or n.name=="hearth:deadwood") or not unmarked(p) then return false end if not n or not (D.is_worldgen_flora(n) or block_root 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 below={x=p.x,y=p.y-1,z=p.z}
local support=api.get_node_or_nil(below) 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 if not support or not D.supports_ground(n,support) or not unmarked(below) then return false end
if block_root and not D.is_ground(support) then return false end
local m=api.get_meta(p) local m=api.get_meta(p)
if m:get_string("succ_natural")=="1" then return false end if m:get_string("succ_natural")=="1" then return false end
m:set_string("succ_natural","1") m:set_string("succ_natural","1")
if block_root then
m:set_string("succ_component",p.x..":"..p.y..":"..p.z)
m:set_string("succ_worldgen_stage","mature")
m:set_string("succ_species",root_species or "generic")
end
-- Succession adopts a known displayed stage on its first bounded visit;
-- bootstrap does not backdate an invented historical birthday.
if block_root or (hearth.botany and hearth.botany.kind(n)) then m:set_string("succ_pending_origin","worldgen_established") end
return true return true
end end
local seasons=hearth.seasons local seasons=hearth.seasons
@@ -220,7 +235,14 @@ return function(hearth, api)
groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed}) groups={snappy=3,flora=1,flammable=2,hearth_ecosystem=1},drop="",after_place_node=placed})
end end
local names={"hearth:deadwood"} local names={"hearth:deadwood"}
if hearth.botany then
names[#names+1]="hearth:log"
for _,s in ipairs(D.species_order) do names[#names+1]="hearth:"..s.."_log" end
end
for k in pairs(flora) do names[#names+1]="hearth:"..k end for k in pairs(flora) do names[#names+1]="hearth:"..k end
if hearth.botany then for _,r in ipairs(hearth.botany.list()) do
if r.group~="trees" and not flora[r.id] then names[#names+1]=r.node end
end 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 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 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", api.register_lbm({name="hearth:diversity_natural_flora",label="Mark unmodified species worldgen flora",
+77 -37
View File
@@ -1,7 +1,8 @@
-- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops. -- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops.
return function(hearth, api, construction) return function(hearth, api, construction)
local DAY, MAX_CATCHUP = 86400, 30 * 86400 local DAY, YEAR = 86400, 365.2425*86400
local CROP_STEP, CROP_MATURE_HOURS = 21600, 60 -- Unidentified game grain, not a researched named cereal cultivar.
local CROP_STEP, CROP_MATURE_HOURS, CROP_SAMPLES = 21600, 120*24, 366
local function finite(value) local function finite(value)
value = tonumber(value) value = tonumber(value)
if not value or value ~= value or value == math.huge or value == -math.huge then return nil end if not value or value ~= value or value == math.huge or value == -math.huge then return nil end
@@ -28,25 +29,26 @@ return function(hearth, api, construction)
planted, now = finite(planted), finite(now) planted, now = finite(planted), finite(now)
if not planted or not now or planted < 0 or now < 0 then return 0 end if not planted or not now or planted < 0 or now < 0 then return 0 end
planted, now = math.floor(planted), math.floor(now) 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 if planted <= 0 or now <= planted then return 0 end
local progress, cursor = 0, planted -- Bounded midpoint quadrature covers the ENTIRE calendar interval. Long
while cursor < now and progress < CROP_MATURE_HOURS do -- gaps coarsen samples, never silently discard all age after day thirty.
local finish = math.min(now, cursor + CROP_STEP) local duration=now-planted
local sample = cursor + math.floor((finish - cursor) / 2) local samples=math.min(CROP_SAMPLES,math.max(1,math.ceil(duration/DAY)))
local progress=0
for i=1,samples do
local sample=planted+math.floor(duration*(i-.5)/samples)
local climate = hearth.climate_at and hearth.climate_at(sample) local climate = hearth.climate_at and hearth.climate_at(sample)
local temperature = finite(climate and climate.temperature) or 12 local temperature = finite(climate and climate.temperature) or 12
local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1) local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1)
progress = progress + (finish - cursor) / 3600 * rate progress = progress + duration/samples/3600*rate
cursor = finish
end end
return math.min(CROP_MATURE_HOURS, progress) return math.min(CROP_MATURE_HOURS,progress,duration/3600)
end end
local function crop_stage(planted, now) local function crop_stage(planted, now)
local growth = climate_progress(planted, now) local growth = climate_progress(planted, now)
if growth >= CROP_MATURE_HOURS then return 4 end if growth >= CROP_MATURE_HOURS then return 4 end
if growth >= 36 then return 3 end if growth >= 72*24 then return 3 end
if growth >= 12 then return 2 end if growth >= 24*24 then return 2 end
return 1 return 1
end end
local function update_crop(pos) local function update_crop(pos)
@@ -54,8 +56,14 @@ return function(hearth, api, construction)
if not current or not current.name:match("^hearth:crop_[1234]$") then return false end 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 meta, now = api.get_meta(pos), safe_now()
local planted = tonumber(meta:get_string("planted_at")) or 0 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 if planted <= 0 or planted > now then return true end
local observed=finite(meta:get_string("crop_last_observed_at")) or planted
if observed>now then return true end
meta:set_string("crop_last_observed_at",tostring(now))
local stage = crop_stage(planted, now) local stage = crop_stage(planted, now)
-- Quadrature refinement is approximate, so an established stage never
-- regresses when a different sample partition straddles a cold front.
stage=math.max(stage,tonumber(current.name:match("(%d)$")))
local target = "hearth:crop_" .. stage local target = "hearth:crop_" .. stage
if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end
return stage < 4 return stage < 4
@@ -98,28 +106,37 @@ return function(hearth, api, construction)
api.get_meta(pos):set_string("player_placed", "1") api.get_meta(pos):set_string("player_placed", "1")
end end
local function harvest_berries(pos, player) local function harvest_berries(pos, player)
if not construction.allowed(pos, player, "use") then return end if not construction.allowed(pos, player, "use") then return false end
local n = node(pos) local n = node(pos)
if not n then return end if not n or (n.name~="hearth:berry_bush" and n.name~="hearth:berry_bush_bare") then return false end
if n.name == "hearth:berry_bush_bare" then if n.name == "hearth:berry_bush_bare" then
hearth.notify(player, "The berry bush is regrowing; return after one real day.") hearth.notify(player, "The berry bush has spent this crop; check a future Summer after its annual rest.")
return return false
end end
local meta,now=api.get_meta(pos),safe_now()
local previous=meta:get_string("ready_at")
local due=previous=="" and 0 or finite(previous)
if not due or due<0 or due>now or now>9007199254740991-YEAR then return false end
local inv = player:get_inventory() local inv = player:get_inventory()
local output = ItemStack("hearth:berries 3") local output = ItemStack("hearth:berries 3")
if not inv or not inv:room_for_item("main", output) then if not inv or not inv:room_for_item("main", output) then
hearth.notify(player, "Make inventory space before picking these berries.") hearth.notify(player, "Make inventory space before picking these berries.")
return return false
end end
local requested = output:get_count() local requested = output:get_count()
-- Shared durable reservation before inventory callbacks, including
-- reentrant picking. A rejected transfer restores exactly the old field.
local ready = now + YEAR
meta:set_string("ready_at",tostring(ready))
local leftover = inv:add_item("main", output) local leftover = inv:add_item("main", output)
if not leftover or leftover:get_count() >= requested then return end local count=leftover and finite(leftover:get_count())
api.set_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2}) if not count or count<0 or count>requested then return false end -- uncertain delivery stays locked
local ready = safe_now() + DAY if count==requested then meta:set_string("ready_at",previous); return false end
api.get_meta(pos):set_string("ready_at", tostring(ready)) api.swap_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2})
api.get_meta(pos):set_string("infotext", "Berry bush: regrowing (one real day)") meta:set_string("infotext", "Berry bush: annual rest; next eligible Summer")
api.get_node_timer(pos):start(DAY) api.get_node_timer(pos):start(DAY)
if hearth.invalidate_environment then hearth.invalidate_environment(pos) end if hearth.invalidate_environment then hearth.invalidate_environment(pos) end
return true
end end
local function harvest_seasonal(pos, player) local function harvest_seasonal(pos, player)
if not construction.allowed(pos, player, "use") then return false end if not construction.allowed(pos, player, "use") then return false end
@@ -129,25 +146,30 @@ return function(hearth, api, construction)
local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind] local item = ({spring_greens_patch="hearth:spring_greens", autumn_mushrooms_patch="hearth:autumn_mushrooms"})[kind]
if not item then return false end if not item then return false end
local now, meta = safe_now(), api.get_meta(pos) local now, meta = safe_now(), api.get_meta(pos)
local ready = finite(meta:get_string("succ_harvest_ready_at")) or 0 local previous=meta:get_string("succ_harvest_ready_at")
if not seasons.available(kind, now) or ready > now then local ready = previous=="" and 0 or finite(previous)
if not ready or ready<0 or 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.") hearth.notify(player, "This patch rests outside its season or until one real day after picking.")
return false return false
end end
local inv, output = player:get_inventory(), ItemStack(item) local inv, output = player:get_inventory(), ItemStack(item)
if not inv or not inv:room_for_item("main", output) then return false end 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. -- 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)) meta:set_string("succ_harvest_ready_at", tostring(now + DAY))
local leftover = inv:add_item("main", output)
local count=leftover and finite(leftover:get_count())
if count==1 then meta:set_string("succ_harvest_ready_at",previous); return false end
if count~=0 then return false end
return true return true
end end
local function refresh_bush(pos) local function refresh_bush(pos)
local n = node(pos) local n = node(pos)
if not n or n.name ~= "hearth:berry_bush_bare" then return false end if not n or n.name ~= "hearth:berry_bush_bare" then return false end
local meta, now = api.get_meta(pos), safe_now() local meta, now = api.get_meta(pos), safe_now()
local ready = tonumber(meta:get_string("ready_at")) or 0 local ready = finite(meta:get_string("ready_at"))
if ready <= now then if not ready or ready<0 then return false end
local climate=hearth.climate_at and hearth.climate_at(now)
if ready <= now and climate and climate.season=="Summer" then
api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2}) api.swap_node(pos, {name = "hearth:berry_bush", param2 = n.param2})
meta:set_string("ready_at", "0"); meta:set_string("infotext", "") meta:set_string("ready_at", "0"); meta:set_string("infotext", "")
api.get_node_timer(pos):stop() api.get_node_timer(pos):stop()
@@ -158,8 +180,7 @@ return function(hearth, api, construction)
local grow_sapling local grow_sapling
local function reconcile_sapling(pos) local function reconcile_sapling(pos)
if grow_sapling(pos) then 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(DAY)
api.get_node_timer(pos):start(math.max(60, math.min(DAY, planted + 3 * DAY - safe_now())))
end end
end end
local function reconcile_bush(pos) local function reconcile_bush(pos)
@@ -187,17 +208,36 @@ return function(hearth, api, construction)
if not n or n.name ~= "hearth:sapling" then return false end if not n or n.name ~= "hearth:sapling" then return false end
local meta, now = api.get_meta(pos), safe_now() local meta, now = api.get_meta(pos), safe_now()
local planted = tonumber(meta:get_string("planted_at")) or 0 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 if planted <= 0 or planted > now then return true end
local previous=finite(meta:get_string("succ_last_observed_at")) or planted
if previous>now then return true end
meta:set_string("succ_birth_at",tostring(planted))
meta:set_string("succ_last_observed_at",tostring(now))
meta:set_string("succ_lifecycle_version","1")
meta:set_string("succ_lifecycle_origin","player_planted")
meta:set_string("succ_lifecycle_profile","generic_tree")
meta:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
local owner = meta:get_string("planter") local owner = meta:get_string("planter")
if now - planted < 3 * DAY or not tree_clear(pos, owner) then return true end if now - planted < YEAR 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 local function plant_wood(q,name)
api.set_node(q,{name=name}); api.get_meta(q):set_string("player_placed","1")
end
for y = 0, 3 do plant_wood({x=pos.x,y=pos.y+y,z=pos.z},"hearth:log") end
for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do for y = 3, 5 do for x = -2, 2 do for z = -2, 2 do
local radius = y == 5 and 1 or 2 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 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} 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 if api.get_node(p).name == "air" then plant_wood(p,"hearth:leaves") end
end end
end end end end end end
-- These are player trees, not a newly generated natural forest. Mark
-- every written cell so later worldgen adoption cannot erase provenance.
local root=api.get_meta(pos)
root:set_string("planted_at",tostring(planted)); root:set_string("planter",owner)
root:set_string("succ_birth_at",tostring(planted)); root:set_string("succ_last_observed_at",tostring(now))
root:set_string("succ_lifecycle_version","1"); root:set_string("succ_lifecycle_origin","player_planted")
root:set_string("succ_lifecycle_profile","generic_tree")
root:set_string("succ_lifecycle_policy","generic_1y_young_10y_mature_unknown_lifespan")
return false return false
end end
local function plant_sapling(stack, player, pointed) local function plant_sapling(stack, player, pointed)
@@ -207,7 +247,7 @@ return function(hearth, api, construction)
api.set_node(pos, {name = "hearth:sapling"}) api.set_node(pos, {name = "hearth:sapling"})
local meta = api.get_meta(pos) local meta = api.get_meta(pos)
meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name()) meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name())
api.get_node_timer(pos):start(3 * DAY) api.get_node_timer(pos):start(DAY)
stack:take_item(1) stack:take_item(1)
return stack return stack
end end
@@ -226,5 +266,5 @@ return function(hearth, api, construction)
refresh_bush = refresh_bush, reconcile_bush = reconcile_bush, refresh_bush = refresh_bush, reconcile_bush = reconcile_bush,
reconcile_crop = reconcile_crop, plant_sapling = plant_sapling, reconcile_crop = reconcile_crop, plant_sapling = plant_sapling,
grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling, grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling,
MAX_CATCHUP = MAX_CATCHUP, CROP_MATURE_HOURS = CROP_MATURE_HOURS} CROP_SAMPLE_LIMIT = CROP_SAMPLES, CROP_MATURE_DAYS = 120, CROP_MATURE_HOURS = CROP_MATURE_HOURS}
end end
+115
View File
@@ -0,0 +1,115 @@
-- Read-only catalogue factory. Biology comes from lifecycle, never game ticks.
return function(hearth, api)
local F={VERSION=1,SECONDS_PER_DAY=86400}
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(s) local r={}; for id in s:gmatch("%S+") do r[id]=true end; return r end
local lifecycle=hearth.lifecycle
if not lifecycle and api.get_modpath then lifecycle=dofile(api.get_modpath("hearth").."/lifecycle.lua") end
-- Bind-pose bounds measured from faunaBounds(), unitsPerNode=10. Runtime
-- collision uses a conservative yaw-independent envelope plus animation margin.
-- id, Latin identity (false for legacy deer), medium, food, activity, cap,
-- speed (presentation policy, nodes/s), width radius, height, rear, front.
local rows={
{"hare","Lepus europaeus","land","browse","night",6,1.3,.1375,.814,.3415,.37004},
{"deer",false,"land","browse","crepuscular",3,1,.31625,1.5947,.683,.65081},
{"fox","Vulpes vulpes","land","predator fruit","night",3,1.2,.15,.6856,.8624,.5028},
{"rabbit","Oryctolagus cuniculus","land","browse","crepuscular",6,1,.154,.5509,.25954,.31371},
{"red_squirrel","Sciurus vulgaris","land","seed fruit","day",4,.9,.075,.5924,.46744,.24266},
{"bank_vole","Myodes glareolus","land","browse seed fruit","any",6,.55,.07,.196,.17039,.17507},
{"wood_mouse","Apodemus sylvaticus","land","seed fruit","night",6,.6,.06,.2386,.31045,.162505},
{"hedgehog","Erinaceus europaeus","land","predator","night",3,.35,.11,.25,.21173,.23755},
{"badger","Meles meles","land","predator fruit","night",2,.65,.19,.4924,.45078,.475885},
{"roe_deer","Capreolus capreolus","land","browse","crepuscular",3,1,.22375,1.2285,.49176,.48443},
{"stoat","Mustela erminea","land","predator","any",2,1.1,.06,.2462,.4514625,.269535},
{"weasel","Mustela nivalis","land","predator","any",2,.85,.0425,.1808,.26846,.19111},
{"otter","Lutra lutra","amphibian","predator","night",2,.9,.125,.3884,.70711,.39778},
{"robin","Erithacus rubecula","fly","predator fruit seed","day",4,.8,.1646,.3721,.21895,.173875},
{"blackbird","Turdus merula","fly","predator fruit","day",4,.8,.1844,.4175,.30653,.2014},
{"blue_tit","Cyanistes caeruleus","fly","seed fruit","day",4,.9,.125,.3167,.17516,.13705},
{"barn_owl","Tyto alba","fly","predator","night",2,1.2,.2834,.6434,.2736875,.275},
{"mallard","Anas platyrhynchos","amphibian","seed browse fruit","day",4,.65,.2735,.4704,.4379,.2555},
{"common_frog","Rana temporaria","amphibian","predator","any",4,.3,.099,.1475,.15026,.22272},
{"smooth_newt","Lissotriton vulgaris","amphibian","predator","night",4,.16,.04,.1014,.3423125,.162945},
{"brown_trout","Salmo trutta","swim","predator","any",4,.65,.1045,.3045,.3552,.24},
{"bumblebee","Bombus terrestris","fly","nectar","day",6,.65,.144,.1225,.065,.1064},
{"earthworm","Lumbricus terrestris","invertebrate","litter","night",8,20/3600,.02,.04,.191266666666667,.191266666666667},
}
local rest_policy={
day={kind="solar_day",sleep_below=-6,winddown_below=6},
night={kind="solar_night",sleep_above=6,winddown_above=-6},
crepuscular={kind="twilight",active_between={-8,8},rest_hours={11,15}},
any={kind="flexible",rest_hours={12,14},winddown_hours={11.5,14.5}},
}
local winter_dormant=set("hedgehog common_frog smooth_newt")
local prey={fox="hare rabbit bank_vole wood_mouse red_squirrel earthworm common_frog",
stoat="rabbit bank_vole wood_mouse",weasel="bank_vole wood_mouse robin blue_tit",
barn_owl="bank_vole wood_mouse",badger="earthworm bank_vole wood_mouse hedgehog",
hedgehog="earthworm",otter="brown_trout common_frog smooth_newt",
robin="earthworm",blackbird="earthworm",common_frog="earthworm",smooth_newt="earthworm",
-- No imaginary mayfly/larva population and no adult-trout cannibalism.
brown_trout=""}
local index,order={},{}
local clips={idle={0,19},locomotion={20,39},feed={40,59},flight_swim={60,79},rest={80,99}}
for _,v in ipairs(rows) do
local id=v[1]; local profile=lifecycle and lifecycle.animal(id) or false
local url="https://en.wikipedia.org/wiki/"..(v[2] and v[2]:gsub(" ","_") or "Deer")
local r={id=id,kind="animal",entity="hearth:"..id,common=id:gsub("_"," "),latin=v[2],
medium=v[3],foods=set(v[4]),activity=v[5],cap=v[6],speed=v[7],
bounds={min={-v[8],0,-v[10]},max={v[8],v[9],v[11]}},
mesh="hearth_fauna_"..id..".b3d",texture="hearth_fauna_"..id..".png",
clips=copy(clips),fps=24,frames=100,eye_height=math.max(.15,v[9]*.8),
prey=set(prey[id] or ""),profile=profile,source_url=url,wikipedia_url=url,
sleep=copy(rest_policy[v[5]] or rest_policy.crepuscular),
winter_dormant=winter_dormant[id]==true,
sources={url},confidence=id=="deer" and "unverified_identity" or "species_account",
-- Numeric feeding cadence/reserve is a coarse energy accounting policy,
-- NOT a claimed species metabolic/starvation measurement.
feed_seconds=(id=="bumblebee" and 300 or (prey[id] and 7200 or 3600)),
reserve_seconds=2*86400,starvation_days=false}
if profile and profile.source_url then r.sources[#r.sources+1]=profile.source_url end
if id=="rabbit" then
r.starvation_days=8; r.starvation_source=url.."#Diet"
end
index[id]=r; order[#order+1]=id
end
function F.kind(value)
local n=type(value)=="table" and value.name or value
if type(n)~="string" or #n>80 then return nil end
n=n:gsub("^hearth:",""); return index[n] and n or nil
end
function F.get(value) return copy(index[F.kind(value)]) end
function F.list() local out={}; for _,id in ipairs(order) do out[#out+1]=copy(index[id]) end; return out end
F.catalog=F.list
function F.source(value) local r=index[F.kind(value)]; return r and copy(r.sources) end
function F.phase(id,born,now)
local r=index[F.kind(id)]
return r and r.profile and lifecycle and lifecycle.phase(r.profile,born,now) or nil
end
function F.breeding_window(id,month)
local r=index[F.kind(id)]
return r and r.profile and lifecycle and lifecycle.breeding_window(r.profile,month) or nil
end
function F.can_breed(id,born,now,sex,current_season)
local r=index[F.kind(id)]; local p=r and r.profile
if not p or p.reproduction_enabled~=true then return false,"incomplete_profile" end
if sex~="female" and sex~="male" then return false,"sex" end
local development=p.reproduction=="egg" and "incubation_days" or "gestation_days"
for _,field in ipairs({"maturity_days",development,"juvenile_days","birth_count"}) do
local t=p[field]
if type(t)~="table" or t.confidence~="source_reported" or not t.source_url then return false,"incomplete_profile" end
end
local phase=F.phase(id,born,now)
if not phase then return false,"age" end
if current_season==nil and hearth.climate_at then
local c=hearth.climate_at(now); current_season=c and c.season
end
-- Climate's season already accounts for the configured hemisphere. Do
-- not intersect it with northern UK months (which breaks southern spring).
return phase.phase=="mature" and type(p.breeding_seasons)=="table"
and p.breeding_seasons[current_season]==true or false
end
return F
end
+269 -25
View File
@@ -1,5 +1,10 @@
local C = core local C = core
local esc = C.formspec_escape local esc = C.formspec_escape
local function tooltip(name,text)
-- Luanti 5.18 requires both tooltip colors when the optional color slots are
-- parsed. Supplying the Mocha pair also keeps high-density creator menus legible.
return "tooltip[" .. esc(name) .. ";" .. esc(text) .. ";#181825;#cdd6f4]"
end
-- eom.dev Mocha anchors: shared by every journal surface, not terrain tints. -- eom.dev Mocha anchors: shared by every journal surface, not terrain tints.
local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56", local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56",
text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"} text="#cdd6f4", muted="#a6adc8", mauve="#cba6f7", teal="#94e2d5", yellow="#f9e2af", danger="#f38ba8"}
@@ -50,11 +55,11 @@ local pages = {
{"Your homestead", "MAKE A PLACE YOURS", "A community without compulsory chores.", {"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. 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."}, "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.", {"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."}, "An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 100 named species in eight uneven ecological groups, not eight groups of ten. Use /botany [search] for names, habitat, seasons, toxicity labels and roles; /fauna lists 23 animal identities with sources and lifecycle status. 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 trees including crab apple, wild cherry and field maple thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support after 90 real calendar days, 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 plant survey counts supporting plants, not insects; wildlife counts include every active species. Animals use finite browse, nectar, fruit, seeds, litter or modeled prey. Use /ecosystem for observations. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors: parasitism and allelopathy cause no plant damage; a resin tag is not an extraction recipe. Calendar ages include unloaded time. Ecological work advances only in loaded areas with no offline catch-up or missed-event replay. Claims, placed plants, crops and construction are excluded from autonomous changes. Named tree stages use real years to decades, not active sessions; fungi do not accelerate growth. Source-reported fields, policy fallbacks and UNKNOWN are distinct. All incomplete animal profiles disable breeding; initial established wildlife are immigration, not adult births. Unknown age and lifespan endpoints never authorize automatic death."},
{"Clock & location", "ONE WORLD. ONE CLOCK.", "A real calendar, not a daily login requirement.", {"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."}, "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.", {"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."}, "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, spruce, crab apple, wild cherry and field maple 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."},
} }
local botanical_groups = { local botanical_groups = {
{"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"}, {"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"},
@@ -68,7 +73,7 @@ end
local function botanical_summary() local function botanical_summary()
local records, lines = catalogue(), {} local records, lines = catalogue(), {}
if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end 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." lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight ecological groups of uneven sizes.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles; /fauna for 23 animal identities and lifecycle evidence."
for _, group in ipairs(botanical_groups) do for _, group in ipairs(botanical_groups) do
local names = {} local names = {}
for _, r in ipairs(records) do for _, r in ipairs(records) do
@@ -99,11 +104,12 @@ function hearth.guide(player, page)
local notes = p[4] .. (page == 6 and "\n\n" .. botanical_summary() or "") 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] = "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.15;2.3,0.55;inspect_target;Inspect target]"
f[#f + 1] = "button[6.3,8.15;2.1,0.55;fauna_index;Fauna guide]"
f[#f + 1] = "button[3.85,8.85;2.3,0.65;ecosystem_survey;Ecosystem survey]" 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 .. "]" .. tooltip("ecosystem_survey","Read a loaded habitat patch and global active wildlife. No world changes.")
f[#f + 1] = "button[6.3,8.85;2.1,0.65;botany_index;Botanical guide]" 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]" 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 .. "]" .. tooltip("recipe_index","Search ingredients, results and real-time processes.")
f[#f + 1] = "button_exit[10.8,8.85;2.5,0.65;close;Back to the valley]" 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)) C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f))
end end
@@ -112,6 +118,7 @@ local recipe_outputs = {"hearth:knife", "hearth:axe", "hearth:pick", "hearth:spe
"hearth:copper_charge", "hearth:copper_pick", "hearth:log", "hearth:stone", "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: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:beech_planks", "hearth:aspen_planks", "hearth:rowan_planks", "hearth:hazel_planks", "hearth:spruce_planks",
"hearth:crab_apple_planks", "hearth:wild_cherry_planks", "hearth:field_maple_planks",
"hearth:granite_bricks", "hearth:basalt_bricks", "hearth:limestone_bricks", "hearth:slate_bricks", "hearth:sandstone_bricks", "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:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall",
"hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door", "hearth:roof", "hearth:foundation", "hearth:frame", "hearth:stairs", "hearth:door",
@@ -127,8 +134,8 @@ local process_notes = {
{"Smelting", "One iron ore/charcoal charge + lit hearth: 60 real seconds; 30 fuel seconds reserved at start. Copper uses its separate 45-second charge."}, {"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."}, {"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."}, {"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."}, {"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry or field_maple 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."}, {"Finite nitrogen", "After 90 real calendar days, 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 and conditions are warm. This cadence is game policy, not a measured species rate. 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."}, {"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."}, {"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."}, {"Food drying", "One cooked-food/reed charge + lit hearth: 300 real seconds; 30 fuel seconds reserved at start."},
@@ -136,11 +143,11 @@ local process_notes = {
{"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."}, {"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."}, {"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."}, {"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."}, {"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel, spruce, crab_apple, wild_cherry and field_maple 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."}, {"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."}, {"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."}, {"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."}, {"Renewal", "Berry crops rest one mean real year (365.2425 days), then require their fruit season; no 24-hour refill or missed-crop replay. Human and animal botanical fruit share one readiness deadline. Pollination adds at most one berry to an existing crop once. A planted generic sapling requires one real year and clear bounded tree volume; named trees use species lifecycle endpoints, often years to decades. Unknown timing remains labelled policy or UNKNOWN, never invented biology. No resource grows through claims or map limits."},
{"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."}, {"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."}, {"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."}, {"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."},
@@ -207,8 +214,8 @@ function hearth.recipes(player, query, page)
.. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]" .. "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]" .. "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]" .. "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("query","Up to 48 characters. Search matches literal text, not patterns.")
.. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]" .. tooltip("search","Search crafting recipes and real-time hearth processes.")
.. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted) .. 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")) .. "]" .. "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 > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "")
@@ -284,7 +291,7 @@ function hearth.botanical_guide(player, query, page)
session.botany_query, session.botany_page = query, page session.botany_query, session.botany_page = query, page
C.show_formspec(name,"hearth:botany",journal(14,10) C.show_formspec(name,"hearth:botany",journal(14,10)
.. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve) .. 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) .. label(0.65,1.15,#records .. " named species / eight ecological groups of uneven sizes",ink.yellow)
.. divider(0.65,1.65,12.7) .. 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]" .. "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]" .. "button[10.25,1.95;3.1,0.7;search;Search]"
@@ -292,13 +299,201 @@ function hearth.botanical_guide(player, query, page)
.. "textarea[0.65,3.3;12.7,4.35;;;" .. esc(table.concat(lines,"\n\n")) .. "]" .. "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.") .. "]" .. "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]" .. (#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 "") .. tooltip("species_choice","Species article") .. "button[6,8.3;3.3,0.45;species_article;Species article]" or "")
.. "button[10.05,8.3;3.3,0.45;fauna_index;Fauna guide]"
.. (page > 1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" 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 "") .. (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[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]") .. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end end
-- Observational catalogue, independent of the plant-only article reader.
local function fauna_observations(player, discovered_id)
local F=hearth.fauna
if not F or type(F.list)~="function" or type(player.get_meta)~="function" then return nil end
local allowed={}; for _,record in ipairs(F.list()) do allowed[record.id]=true end
local ok,meta=pcall(function() return player:get_meta() end)
if not ok or not meta or type(meta.get_string)~="function" or type(meta.set_string)~="function" then return nil end
local key="hearth:fauna_observations_v1"
local read_ok,raw=pcall(meta.get_string,meta,key)
if not read_ok or type(raw)~="string" or #raw>512 then return nil end
local seen,ordered={},{}
if raw~="" then
for id in raw:gmatch("[^,]+") do
if not allowed[id] or seen[id] then return nil end
seen[id]=true; ordered[#ordered+1]=id
end
table.sort(ordered)
if table.concat(ordered,",")~=raw then return nil end
end
local first=discovered_id and not seen[discovered_id] or false
if first then
seen[discovered_id]=true; ordered[#ordered+1]=discovered_id; table.sort(ordered)
local encoded=table.concat(ordered,",")
local write_ok=pcall(meta.set_string,meta,key,encoded)
local verify_ok,verified=pcall(meta.get_string,meta,key)
if not write_ok or not verify_ok or verified~=encoded then return nil end
end
return seen,first
end
function hearth.inspect_fauna(player,id,snapshot)
local ok,name=pcall(function() return player:get_player_name() end)
if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name]
or type(C.get_player_by_name)~="function" then return false end
local online_ok,online=pcall(C.get_player_by_name,name)
if not online_ok or online~=player then return false end
local F=hearth.fauna
if type(id)~="string" or #id>80 or not F or type(F.get)~="function" then return false end
local loaded,record=pcall(F.get,id)
if not loaded or type(record)~="table" or record.id~=id then return false end
local view
if snapshot~=nil then
if type(snapshot)~="table" or getmetatable(snapshot)~=nil then return false end
view={}
for _,key in ipairs({"born_at","origin","foodreserve","resident","observation"}) do
local value=snapshot[key]
if key=="born_at" or key=="foodreserve" then
if type(value)~="number" or value~=value or math.abs(value)==math.huge then return false end
elseif value~=nil and type(value)~="string" and type(value)~="boolean" then return false end
view[key]=value
end
if view.born_at<0 or view.born_at%1~=0 or view.foodreserve<0 or view.foodreserve>1
or type(view.resident)~="boolean"
or (view.origin~="established" and view.origin~="born")
or (view.observation~="rest" and view.observation~="awake") then return false end
end
local sightings,first_sighting=fauna_observations(player,view and id or nil)
local function safe(value,limit)
if type(value)~="string" then return "unknown" end
return value:gsub("[%c]"," "):sub(1,limit or 240)
end
local function safe_words(set)
local out={}
if type(set)=="table" then
for key,enabled in pairs(set) do
if enabled and type(key)=="string" and #out<32 then out[#out+1]=safe(key,40):gsub("_"," ") end
end
end
table.sort(out)
return #out>0 and table.concat(out,", ") or "not specified"
end
local profile=record.profile or {}
local lines={}
local now_ok,now=pcall(os.time)
local phase
if view and now_ok and type(F.phase)=="function" then
local phase_ok,result=pcall(F.phase,id,view.born_at,now)
if phase_ok and type(result)=="table" then phase=result end
end
if phase then
lines[#lines+1]=string.format("Lifecycle: %s / approx %.2f model years; %.1f real calendar days",safe(phase.phase,40),phase.age_days/365.2425,phase.age_days)
else
lines[#lines+1]="Lifecycle age: unavailable for this profile or no individual selected."
end
if view then
local reserve=math.max(0,math.min(1,view.foodreserve))
lines[#lines+1]=string.format("Coarse modeled food reserve (policy, not biology): %.0f%%",reserve*100)
lines[#lines+1]="Current animation observation: "..(view.observation=="rest" and "resting" or "awake / active")
lines[#lines+1]="Residency: "..(view.resident and "persistent registered resident" or "persistent mapblock resident; registry ID unavailable")
.." ("..safe(view.origin,24).." origin); individual position is not shown."
lines[#lines+1]=sightings and (first_sighting and "Field journal: first recorded encounter with this species."
or "Field journal: this species is already recorded.") or "Field journal: unavailable; encounter was not recorded."
else
lines[#lines+1]="Individual observation: none; this is a species profile, not a local census."
lines[#lines+1]="Field journal: browsing this catalogue entry does not count as a wildlife encounter."
end
lines[#lines+1]="Modeled diet: "..safe_words(record.foods)
lines[#lines+1]="Activity / sleep policy: "..safe(record.activity,32).." / "..safe(record.sleep and record.sleep.kind,40)
lines[#lines+1]="Winter rest policy: "..(record.winter_dormant and "coarse dormancy; resident remains persistent" or "no special winter dormancy modeled")
lines[#lines+1]="Modeled behavior: "..safe(profile.movement or "movement details unavailable",220)
lines[#lines+1]="Modeled prey: "..safe_words(record.prey)
if profile.reproduction_enabled==true then
lines[#lines+1]="Reproduction: enabled only within the coarse model; mature compatible pairs, season, food and safe habitat are still required."
else
local evidence=profile.evidence and profile.evidence.reproduction_context
lines[#lines+1]="Reproduction: disabled / incomplete species-specific lifecycle evidence. "
..safe(evidence and evidence.note or "No breeding policy is enabled for this profile.",220)
end
local status=hearth.wildlife
if type(status)=="table" and type(status.status)=="function" then
local status_ok,data=pcall(status.status)
if status_ok and type(data)=="table" and type(data.counts)=="table" then
local n=tonumber(data.counts[id]); if n and n==n and math.abs(n)<math.huge then
lines[#lines+1]="Active loaded animals of this species: "..math.max(0,math.floor(n))
end
end
end
local source=safe(type(record.source_url)=="string" and record.source_url or "source unavailable",300)
local lifecycle_source=safe(type(profile.source_url)=="string" and profile.source_url or "UNKNOWN",300)
C.show_formspec(name,"hearth:fauna_detail",journal(14,10)
..label(0.65,0.55,"eom.dev / FAUNA FIELD NOTES",ink.mauve)
..label(0.65,1.15,safe(record.common,80):upper().." / "..safe(record.latin or "identity unverified",100),ink.yellow)
..divider(0.65,1.65,12.7)
.."textarea[0.65,2.0;12.7,5.7;;;"..esc(table.concat(lines,"\n\n")).."]"
.."textarea[0.65,7.8;12.7,0.8;;;"..esc("Identity source: "..source.."\nLifecycle source: "..lifecycle_source).."]"
.."button[6.15,8.85;3.3,0.65;fauna_index;Back to fauna guide]"
.."button_exit[10.05,8.85;3.3,0.65;close;Close]" )
return true
end
function hearth.fauna_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 F=hearth.fauna
local records=F and type(F.list)=="function" and F.list() or {}
local observations=fauna_observations(player)
local observed_count=0
if observations then for _ in pairs(observations) do observed_count=observed_count+1 end end
local all,all_ids,needle={},{},query:lower()
local function timing(p,field)
local r=p and p[field]
if type(r)~="table" then return "UNKNOWN" end
return tostring(r.min).."-"..tostring(r.max).." real days ["..tostring(r.confidence).."]"
end
for _,r in ipairs(records) do
local p=r.profile
local marker=observations and observations[r.id] and " / recorded encounter" or " / not yet encountered"
local row=r.id.." / "..r.common.." / "..(r.latin or "UNKNOWN species identity (legacy deer)")..marker
.."\nHabitat/movement medium: "..r.medium.."; activity: "..r.activity
.."; food roles: "..words(r.foods)
.."\nMaturity: "..timing(p,"maturity_days").."; longevity context: "..timing(p,"lifespan_days")
.."\nLifecycle evidence: "..(p and p.confidence or "UNKNOWN")
.."; automatic breeding "..(p and p.reproduction_enabled==true and "enabled only for sourced profiles" or "disabled (incomplete profile)")
.."; no lifespan-endpoint death."
.."\nIdentity source: "..r.source_url
.."\nLifecycle source: "..(p and p.source_url or "UNKNOWN")
if needle=="" or row:lower():find(needle,1,true) then all[#all+1]=row; all_ids[#all_ids+1]=r.id end
end
local pages_count=math.max(1,math.ceil(#all/5)); page=index(page,pages_count)
local lines,choices={},{}
session.fauna_ids,session.fauna_selected={},1
for i=(page-1)*5+1,math.min(#all,page*5) do
lines[#lines+1]=all[i]; session.fauna_ids[#session.fauna_ids+1]=all_ids[i]; choices[#choices+1]=esc(all_ids[i])
end
if #lines==0 then lines[1]=#records==0 and "Fauna catalogue unavailable in this runtime."
or "No animal matches that literal search." end
session.fauna_query,session.fauna_page=query,page
C.show_formspec(name,"hearth:fauna",journal(14,10)
..label(0.65,0.55,"eom.dev / FAUNA FIELD GUIDE",ink.mauve)
..label(0.65,1.15,(observations and (observed_count.." / "..#records.." species recorded in your field journal")
or "Field journal unavailable / source evidence is not complete biology"),ink.yellow)
..divider(0.65,1.65,12.7)
.."field[0.7,1.95;9.2,0.7;query;Species, scientific name, food or status;"..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.." / "..pages_count.." / "..#all.." matches",ink.muted)
.."textarea[0.65,3.3;12.7,4.0;;;"..esc(table.concat(lines,"\n\n")).."]"
.."textarea[0.65,7.4;12.7,0.8;;;"..esc("Real calendar age includes unloaded time; no offline meals, births or event replay. Movement and feeding cadence are conservative game policies, not complete true-life behavior. No web requests or world changes.").."]"
..(#choices>0 and "dropdown[0.7,8.25;5.0,0.45;species_choice;"..table.concat(choices,",")..";1;true]"
..tooltip("species_choice","Choose one of the species shown on this page.")
.."button[6,8.2;3.3,0.55;species_details;Species details]" or "")
..(page>1 and "button[0.7,8.85;2.2,0.65;previous;Previous]" or "")
..(page<pages_count and "button[3.05,8.85;2.2,0.65;next;Next]" or "")
.."button[6,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
function hearth.inventory(player) function hearth.inventory(player)
player:set_inventory_formspec(journal(12.4,11.3) player:set_inventory_formspec(journal(12.4,11.3)
.. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve) .. label(0.6,0.45,"eom.dev / THE LAST HEARTH",ink.mauve)
@@ -307,9 +502,9 @@ function hearth.inventory(player)
.. label(4.8,2.2,"RESULT",ink.teal) .. label(6.5,1.95,"GATHER / BUILD / BELONG",ink.yellow) .. 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.]" .. "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]" .. "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 .. "]" .. tooltip("field_guide","Read survival notes, recipes and the ecosystem survey.")
.. "button[7.1,3.65;3.8,0.6;character_creator;Character appearance]" .. "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.]" .. 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) .. 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]") .. "list[current_player;main;0.6,5.7;10,4;]listring[current_player;main]listring[current_player;craft]")
end end
@@ -322,8 +517,8 @@ function hearth.location_form(player, error_text)
.. "box[0.6,3.95;4.5,0.8;" .. ink.mantle .. "]box[5.55,3.95;4.5,0.8;" .. ink.mantle .. "]" .. "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[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]" .. "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("latitude","Approximate decimal degrees, at most 16 characters.")
.. "tooltip[longitude;Local world only. Shared servers use administrator-selected coordinates.;" .. ink.mantle .. ";" .. ink.text .. "]" .. tooltip("longitude","Local world only. Shared servers use administrator-selected coordinates.")
.. "textarea[0.65,4.95;9.5,0.75;;;" .. esc(C.colorize(error_text and ink.danger or ink.muted, .. "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))) .. "]" 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[0.65,6;4.4,0.7;save_location;Use this location]"
@@ -359,9 +554,27 @@ function hearth.ecosystem(player)
.. "\nAquatic niche: " .. (type(h.aquatic) == "string" and h.aquatic:sub(1,32) or "unavailable") .. "\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") .. " Water depth: " .. (h.near_water ~= nil and optional(h.water) or "unavailable")
end 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", local fauna = "Wildlife observations are not available in this runtime."
number(w.hare),number(w.deer),number(w.fox),number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap)) if w then
or "Wildlife observations are not available in this runtime." local counts,lines=w.counts,{}
-- Legacy optional API fallback; production uses every returned species.
counts=type(counts)=="table" and counts or {hare=w.hare,deer=w.deer,fox=w.fox}
local ids={}; for id in pairs(counts) do if type(id)=="string" then ids[#ids+1]=id end end
table.sort(ids)
for i=1,math.min(#ids,64) do
local id=ids[i]
local name=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:sub(1,64):gsub("_"," ")
lines[#lines+1]=name .. ": " .. number(counts[id])
end
fauna=table.concat(lines," / ") .. string.format("\nActive total: %d / %d globally Local cap: %d Per-player cap: %d",
number(w.total),number(w.global_cap),number(w.local_cap),number(w.player_cap))
local recruitment=w.recruitment
if type(recruitment)=="table" then
local reason=type(recruitment.last_reason)=="string" and recruitment.last_reason:sub(1,40):gsub("[%c]"," ") or "unavailable"
fauna=fauna..string.format("\nAdmissions: %d / %d attempts Latest outcome: %s",
number(recruitment.successes),number(recruitment.attempts),reason)
end
end
C.show_formspec(player:get_player_name(), "hearth:ecosystem", journal(14,10) 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,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.65,1.15,"A MOMENT IN THE MEADOW",ink.yellow) .. divider(0.65,1.65,12.7)
@@ -370,11 +583,12 @@ function hearth.ecosystem(player)
.. 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", .. 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) 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) .. 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,5.55;12.7,1.55;;;" .. 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.") .. "]" .. "textarea[0.65,7.15;12.7,1.05;;;" .. esc("Observation does not advance succession or spawn animals. No terrain is loaded.\nCalendar age includes unloaded time; ecological work is bounded and loaded-only. No missed-event replay; unknown breeding is disabled.") .. "]"
.. "button[0.7,8.85;2.8,0.65;refresh;Observe again]" .. "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 .. "]" .. tooltip("refresh","Read one fresh sample without changing the world.")
.. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]" .. "button[3.7,8.85;3.3,0.65;botany_index;Botanical guide]"
.. "button[7.15,8.85;2.6,0.65;fauna_index;Fauna guide]"
.. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]") .. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]")
end end
@@ -382,6 +596,27 @@ C.register_on_player_receive_fields(function(player, form, fields)
if form == "hearth:species" then if form == "hearth:species" then
reader.handle(player,fields) reader.handle(player,fields)
return true return true
elseif form == "hearth:fauna_detail" then
local session=hearth.sessions[player:get_player_name()]
if not session then return true end
if fields.fauna_index then hearth.fauna_guide(player,session.fauna_query or "",session.fauna_page or 1) end
return true
elseif form == "hearth:fauna" then
local session=hearth.sessions[player:get_player_name()]; if not session 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.species_details then
local value=fields.species_choice
local n=type(value)=="string" and #value<=2 and value:match("^%d+$") and tonumber(value)
if n and n>=1 and n<=#(session.fauna_ids or {}) then
session.fauna_selected=n
local id=(session.fauna_ids or {})[n]
if id then hearth.inspect_fauna(player,id,nil) end
end
elseif fields.search or fields.key_enter then hearth.fauna_guide(player,fields.query,1)
elseif fields.next then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)+1)
elseif fields.previous then hearth.fauna_guide(player,session.fauna_query,(session.fauna_page or 1)-1) end
return true
elseif form == "hearth:botany" then elseif form == "hearth:botany" then
local session = hearth.sessions[player:get_player_name()] local session = hearth.sessions[player:get_player_name()]
if not session then return true end if not session then return true end
@@ -391,7 +626,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
if not n or n<1 or n>#(session.botany_ids or {}) then return true end if not n or n<1 or n>#(session.botany_ids or {}) then return true end
session.botany_selected = n session.botany_selected = n
end end
if fields.field_notes then hearth.guide(player,6) if fields.fauna_index then hearth.fauna_guide(player,"",1)
elseif fields.field_notes then hearth.guide(player,6)
elseif fields.ecosystem_survey then hearth.ecosystem(player) elseif fields.ecosystem_survey then hearth.ecosystem(player)
elseif fields.species_article then elseif fields.species_article then
local id = (session.botany_ids or {})[session.botany_selected or 1] local id = (session.botany_ids or {})[session.botany_selected or 1]
@@ -402,7 +638,8 @@ C.register_on_player_receive_fields(function(player, form, fields)
return true return true
elseif form == "hearth:ecosystem" then elseif form == "hearth:ecosystem" then
if not hearth.sessions[player:get_player_name()] then return true end if not hearth.sessions[player:get_player_name()] then return true end
if fields.field_notes then hearth.guide(player,6) if fields.fauna_index then hearth.fauna_guide(player,"",1)
elseif fields.field_notes then hearth.guide(player,6)
elseif fields.botany_index then hearth.botanical_guide(player,"",1) elseif fields.botany_index then hearth.botanical_guide(player,"",1)
elseif fields.refresh then hearth.ecosystem(player) end elseif fields.refresh then hearth.ecosystem(player) end
return true return true
@@ -420,6 +657,7 @@ C.register_on_player_receive_fields(function(player, form, fields)
if fields.recipe_index then hearth.recipes(player, "", 1); return true 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.ecosystem_survey then hearth.ecosystem(player); return true end
if fields.botany_index then hearth.botanical_guide(player,"",1); return true end if fields.botany_index then hearth.botanical_guide(player,"",1); return true end
if fields.fauna_index then hearth.fauna_guide(player,"",1); return true end
if fields.inspect_target then if fields.inspect_target then
local ok, message = inspect(player, "") local ok, message = inspect(player, "")
if not ok and hearth.notify then hearth.notify(player,message) end if not ok and hearth.notify then hearth.notify(player,message) end
@@ -463,6 +701,12 @@ C.register_chatcommand("botany", {params = "[search]", description = "Browse nam
hearth.botanical_guide(player,param or "",1) hearth.botanical_guide(player,param or "",1)
return true return true
end}) end})
C.register_chatcommand("fauna", {params="[search]",description="Observe animal catalogue sources and conservative lifecycle status",
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.fauna_guide(player,param or "",1); return true
end})
C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]", C.register_chatcommand("inspect", {params = "[exact species ID or Latin name]",
description = "Read a targeted species' offline Wikipedia excerpt and current game effects", description = "Read a targeted species' offline Wikipedia excerpt and current game effects",
func = function(name, param) func = function(name, param)
+387
View File
@@ -0,0 +1,387 @@
-- Pure calendar policy, Lua 5.1+. No engine, clock reads, registrations or writes.
-- Ranges describe populations or explicit policy fallbacks, never death deadlines.
local L = {}
local DAY, YEAR, MAX_CLOCK = 86400, 365.2425, 9007199254740991
local function finite(n) return type(n)=="number" and n==n and math.abs(n)<math.huge end
local function clock(n) return finite(n) and n>=0 and n<=MAX_CLOCK 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 range(a,b,url,note,statistic)
return {min=a,max=b,source_url=url or false,confidence=url and "source_reported" or "policy_fallback",
note=note or "Unverified group policy; not a measured species range.",statistic=statistic or "policy_window"}
end
local function years(a,b,url,note,statistic) return range(a*YEAR,b*YEAR,url,note,statistic) end
local WT="https://www.woodlandtrust.org.uk/trees-woods-and-wildlife/"
local WILDLIFE="https://www.wildlifetrusts.org/wildlife-explorer/"
local WIKI="https://en.wikipedia.org/wiki/"
local ADW="https://animaldiversity.org/accounts/"
local RSPB="https://www.rspb.org.uk/birds-and-wildlife/"
local plants, animals = {}, {}
local baseline={
trees={range(14,365),years(1,5),years(10,60),years(60,500)},
shrubs={range(14,365),years(1,2),years(2,10),years(5,80)},
grasses={range(7,90),range(30,90),range(90,365),years(2,10)},
herbs={range(7,90),range(90,365),range(365,730),years(2,10)},
ferns_mosses={range(30,730),years(1,2),years(2,5),false},
fungi={false,false,false,false}, aquatic={range(7,90),range(90,365),range(365,730),false},
lichens={false,years(1,5),false,false},
}
local function plant_record(id,group,latin,pending)
local b=baseline[group]
plants[id]={id=id,kind="plant",group=group,latin=latin or false,pending=pending or false,
germination_days=copy(b[1]),juvenile_days=copy(b[2]),maturity_days=copy(b[3]),lifespan_days=copy(b[4]),
life_cycle=group=="fungi" and "not_applicable" or "perennial",
life_cycle_confidence="policy_fallback",source_url=false,confidence="policy_fallback",
evidence={},seasonal_policy="dormant_not_dead",age_origin="seed_or_propagule"}
end
-- Fixed snapshot of all 100 registered IDs; no catalogue factory is invoked here.
local groups={
trees="oak birch pine willow alder beech aspen rowan hazel spruce",
shrubs="bilberry blackberry raspberry elder blackthorn hawthorn dog_rose holly juniper guelder_rose",
grasses="sedge bulrush meadow_fescue cocksfoot timothy sweet_vernal_grass yorkshire_fog tufted_hairgrass common_reed soft_rush",
herbs="clover nettle heather bluebell yarrow foxglove meadowsweet wild_garlic cowslip wood_anemone",
ferns_mosses="bracken lady_fern male_fern harts_tongue polypody sphagnum haircap_moss moss fork_moss star_moss",
fungi="fly_agaric birch_bolete bay_bolete chanterelle wood_blewit shaggy_inkcap turkey_tail oyster_fungus honey_fungus birch_polypore",
aquatic="duckweed yellow_waterlily white_waterlily water_crowfoot water_mint water_forgetmenot brooklime water_starwort hornwort pondweed",
lichens="reindeer_lichen oakmoss old_mans_beard lichen dog_lichen lungwort_lichen sunburst_lichen hammered_shield cup_lichen pixie_cup",
}
for group,text in pairs(groups) do for id in text:gmatch("%S+") do plant_record(id,group) end end
-- The added twenty are registered by botany, not by this pure module.
local extra_plants={
{"dandelion","Taraxacum officinale"},{"daisy","Bellis perennis"},{"red_clover","Trifolium pratense"},
{"birdsfoot_trefoil","Lotus corniculatus"},{"ribwort_plantain","Plantago lanceolata"},
{"selfheal","Prunella vulgaris"},{"knapweed","Centaurea nigra"},{"oxeye_daisy","Leucanthemum vulgare"},
{"field_scabious","Knautia arvensis"},{"teasel","Dipsacus fullonum"},{"cow_parsley","Anthriscus sylvestris"},
{"garlic_mustard","Alliaria petiolata"},{"honeysuckle","Lonicera periclymenum","shrubs"},
{"ivy","Hedera helix","shrubs"},{"gorse","Ulex europaeus","shrubs"},{"broom","Cytisus scoparius","shrubs"},
{"crab_apple","Malus sylvestris","trees"},{"wild_cherry","Prunus avium","trees"},
{"field_maple","Acer campestre","trees"},{"holly_fern","Polystichum setiferum","ferns_mosses"},
}
for _,r in ipairs(extra_plants) do plant_record(r[1],r[3] or "herbs",r[2],false) end
local function evidence(p,field,url,note)
p.evidence[field]={source_url=url,note=note,reviewed="2026-11-03"}
p.source_url=p.source_url or url; p.confidence="partial_species_evidence"
end
local function field(p,name,value)
p[name]=value; evidence(p,name,value.source_url,value.note)
end
-- All ten trees have distinct profiles. Unsourced timing fields retain their
-- own policy_fallback label even when a different field has species evidence.
local tree_rows={
{"oak","Quercus robur",40,60,500,1000,"https://www.forestryengland.uk/article/english-oak"},
{"birch","Betula pendula",10,20,60,90,"https://treesforlife.org.uk/into-the-forest/trees-plants-animals/trees/birch/birch-facts/"},
{"pine","Pinus sylvestris",15,30,150,300,WIKI.."Pinus_sylvestris"},
{"willow","Salix alba",5,15,60,120,WIKI.."Salix_alba"},
{"alder","Alnus glutinosa",10,20,60,160,WIKI.."Alnus_glutinosa"},
{"beech","Fagus sylvatica",10,30,300,500,WIKI.."Fagus_sylvatica"},
{"aspen","Populus tremula",10,20,80,200,WIKI.."Populus_tremula"},
{"rowan","Sorbus aucuparia",10,10,80,150,WIKI.."Sorbus_aucuparia"},
{"hazel","Corylus avellana",3,10,60,80,WT.."british-trees/a-z-of-british-trees/hazel/"},
{"spruce","Picea abies",20,40,200,532,WIKI.."Picea_abies"},
}
for _,r in ipairs(tree_rows) do
local p=plants[r[1]]; p.latin=r[2]
p.maturity_days=years(r[3],r[4])
p.lifespan_days=years(r[5],r[6])
evidence(p,"natural_history",r[7],"Species reference; only separately attributed fields are verified numerical claims.")
end
field(plants.oak,"maturity_days",years(40,40,tree_rows[1][7],"Acorns usually begin around 40 years; approximate report, not a universal minimum.","approximate_onset"))
field(plants.oak,"lifespan_days",years(500,1000,WIKI.."Quercus_robur","Long-lived survivors often exceed 500 years; some exceed 1000. Not a mortality distribution.","longevity_envelope"))
field(plants.birch,"lifespan_days",years(60,90,tree_rows[2][7],"Typical birch longevity; page covers silver and downy birch and notes exceptional ages above 150.","typical_range"))
field(plants.pine,"lifespan_days",years(150,300,tree_rows[3][7],"Normal reported lifespan; exceptional trees exceed this range.","typical_range"))
field(plants.alder,"lifespan_days",years(60,160,tree_rows[5][7],"Composite envelope: Woodland Trust approximately 60; Wikipedia up to 160. Not typical 60-160 survival.","composite_envelope"))
evidence(plants.alder,"lifespan_context",WT.."british-trees/a-z-of-british-trees/alder/","Approximately 60 years; contrasts with upper longevity reports.")
field(plants.beech,"maturity_days",years(10,30,tree_rows[6][7],"Small seed quantities around 10 years, heavy crops at least 30; different reproductive milestones.","onset_to_heavy_crop"))
field(plants.beech,"lifespan_days",years(300,500,tree_rows[6][7],"Undisturbed lifespan around 300; exceptional individual above 500.","longevity_envelope"))
field(plants.aspen,"lifespan_days",years(80,200,tree_rows[7][7],"Upper endpoint 200 reported; lower endpoint 80 is unverified policy, not a sourced range.","policy_envelope"))
plants.aspen.lifespan_days.confidence="policy_fallback" -- source supports only its upper limit
field(plants.rowan,"maturity_days",years(10,10,tree_rows[8][7],"Reported maturity at about ten years.","approximate_onset"))
field(plants.rowan,"lifespan_days",years(80,150,tree_rows[8][7],"Often not older than 80 in warmer regions; occasional 150.","longevity_envelope"))
field(plants.hazel,"lifespan_days",years(60,80,tree_rows[9][7],"Uncoppiced upper limit 80 reported; lower endpoint 60 is policy. Coppice stools can persist centuries.","policy_envelope"))
plants.hazel.lifespan_days.confidence="policy_fallback"
field(plants.spruce,"lifespan_days",years(200,532,tree_rows[10][7],"532-year ring-dated individual; lower endpoint 200 is policy, not sourced. Never use clonal root ages as stem ages.","policy_envelope"))
plants.spruce.lifespan_days.confidence="policy_fallback"
for _,id in ipairs({"pine","spruce","holly","ivy","gorse","holly_fern","harts_tongue","polypody"}) do
plants[id].seasonal_policy="retain_foliage_policy"
end
for _,id in ipairs({"fungi","lichens","ferns_mosses"}) do
for _,p in pairs(plants) do if p.group==id then p.age_origin="propagule_or_colony" end end
end
local garlic=WILDLIFE.."wildflowers/garlic-mustard"
plants.garlic_mustard.life_cycle="biennial"; plants.garlic_mustard.life_cycle_confidence="source_reported"
evidence(plants.garlic_mustard,"life_cycle",garlic,"First-season leaves retained over winter; flowers in second spring.")
plants.garlic_mustard.maturity_days=range(365,730,false,"Second-season policy window; not an exact source-measured number of days.")
plants.garlic_mustard.lifespan_days=range(365,730,false,"Two-season policy; do not kill at the first frost.")
local foxglove=WILDLIFE.."wildflowers/foxglove"
plants.foxglove.life_cycle="biennial"; plants.foxglove.life_cycle_confidence="source_reported"
evidence(plants.foxglove,"life_cycle",foxglove,"Flower stems in second year; biennial representation, not a guaranteed death date.")
plants.foxglove.maturity_days=copy(plants.garlic_mustard.maturity_days)
-- Registered teasel remains explicitly unverified rather than inventing a citation.
plants.teasel.life_cycle="biennial"
local function animal_record(id,latin,path,diet,movement,life_a,life_b)
local url=WILDLIFE..path
local p={id=id,latin=latin,kind="animal",pending=false,
maturity_days=false,gestation_days=false,incubation_days=false,juvenile_days=false,lifespan_days=false,
breeding_months=false,birth_count=false,birth_count_unit="young_per_litter",reproduction="unknown",
diet=diet,movement=movement,source_url=url,confidence="partial_species_evidence",evidence={},
age_origin="birth_or_hatching",reproduction_enabled=false}
animals[id]=p
evidence(p,"diet",url,diet); evidence(p,"movement",url,movement)
if life_a then field(p,"lifespan_days",years(life_a,life_b,url,"Reported average lifespan, not maximum age or a death deadline.","reported_average")) end
return p
end
animal_record("rabbit","Oryctolagus cuniculus","mammals/rabbit","grass and other vegetation","hopping; underground warrens",3,3)
animal_record("red_squirrel","Sciurus vulgaris","mammals/red-squirrel","nuts and conifer seeds","tree climbing and branch leaping; no hibernation",6,6)
animal_record("bank_vole","Myodes glareolus","mammals/bank-vole","fruit, nuts and small insects","agile; shallow burrows; no hibernation",.5,1.5)
animal_record("wood_mouse","Apodemus sylvaticus","mammals/wood-mouse","berries and seeds","mostly nocturnal; climbing and food caching",1,1)
animal_record("hedgehog","Erinaceus europaeus","mammals/european-hedgehog","invertebrates including beetles and earthworms","nocturnal; winter hibernation",2,3)
animal_record("badger","Meles meles","mammals/european-badger","worms, fruit, plants, eggs and small animals","digging; family groups in setts",5,8)
animal_record("roe_deer","Capreolus capreolus","mammals/roe-deer","tree and shrub buds and leaves; herbs and grasses","solitary in summer; loose winter groups",7,7)
animal_record("stoat","Mustela erminea","mammals/stoat","small rodents and rabbits","bounding gait; active day and night",2,5)
animal_record("weasel","Mustela nivalis","mammals/weasel","voles, mice and small birds","low running gait; enters rodent burrows",2,2)
animal_record("otter","Lutra lutra","mammals/european-otter","fish, amphibians, waterbirds and crustaceans","swimming; sheltered holts",5,10)
local function bird(id,latin,diet,movement,life)
local p=animal_record(id,latin,"unused",diet,movement)
p.source_url=RSPB..id:gsub("_","-"); p.evidence={}
evidence(p,"diet",p.source_url,diet); evidence(p,"movement",p.source_url,movement)
p.reproduction="egg"; p.birth_count_unit="eggs_per_clutch"
if life then field(p,"lifespan_days",years(life,life,p.source_url,"Reported average, not maximum or mandatory death age.","reported_average")) end
end
bird("robin","Erithacus rubecula","worms, insects, other invertebrates, seeds and fruit","hopping; territorial; mostly UK resident")
bird("blackbird","Turdus merula","worms, insects, berries and fruit","ground hopping; some seasonal migration",3)
bird("blue_tit","Cyanistes caeruleus","insects, caterpillars, seeds and nuts","clinging and flight; winter feeding flocks",3)
bird("barn_owl","Tyto alba","mice, voles, shrews and small birds","low silent hunting flight, often dawn or dusk")
bird("mallard","Anas platyrhynchos","seeds, plants, insects and shellfish","swimming and flight; resident or migratory")
animal_record("common_frog","Rana temporaria","amphibians/common-frog","invertebrates and smaller amphibians","hopping and swimming; winter shelter",5,10)
animal_record("smooth_newt","Lissotriton vulgaris","amphibians/smooth-newt","land invertebrates; aquatic crustaceans, molluscs and tadpoles","aquatic breeding; nocturnal land activity; winter shelter",6,6)
animal_record("brown_trout","Salmo trutta","freshwater-fish/brown-trout","insect larvae, small fish and flying insects","river swimming; freshwater resident form",15,20)
animal_record("bumblebee","Bombus terrestris","invertebrates/bees-and-wasps/buff-tailed-bumblebee","floral nectar and pollen","flight; underground colony; some winter-active colonies")
local worm=animal_record("earthworm","Lumbricus terrestris","unused","dead leaf litter and mineral soil","vertical burrows; moist-surface movement")
worm.source_url=WIKI.."Lumbricus_terrestris"; worm.evidence={}
evidence(worm,"diet",worm.source_url,worm.diet); evidence(worm,"movement",worm.source_url,worm.movement)
evidence(worm,"lifespan_context",worm.source_url,"Natural lifespan unknown; six years in captivity is not a wild lifespan range.")
field(worm,"maturity_days",years(1,2,worm.source_url,"At least one year development; reproductive maturity in second year.","development_window"))
worm.reproduction="paired_hermaphrodite_cocoon"
animal_record("hare","Lepus europaeus","mammals/brown-hare","vegetation and young woody bark","bounding; surface forms, not rabbit warrens",2,4)
animal_record("fox","Vulpes vulpes","mammals/red-fox","small mammals, birds, amphibians, worms and fruit","mostly nocturnal; territorial scent marking",2,3)
-- Generic legacy deer is deliberately NOT silently reidentified as roe deer.
animals.deer={id="deer",kind="animal",latin=false,pending=false,confidence="unverified_identity",source_url=false,
evidence={},maturity_days=false,juvenile_days=false,gestation_days=false,incubation_days=false,
lifespan_days=false,breeding_months=false,birth_count=false,reproduction="unknown",reproduction_enabled=false}
local function count(id,a,b)
local p=animals[id]; field(p,"birth_count",range(a,b,p.source_url,"Source-reported litter or clutch count; not guaranteed survivors.","offspring_count"))
end
count("rabbit",3,7); count("bank_vole",3,5); count("wood_mouse",4,8); count("stoat",6,12)
count("weasel",3,6); count("roe_deer",2,3); count("hare",2,4)
count("robin",4,5); count("blackbird",3,4)
local function months(id,list,url,note)
local p=animals[id]; p.breeding_months=list; evidence(p,"breeding_months",url or p.source_url,note)
end
months("roe_deer",{7,8},nil,"Mating July-August; delayed implantation; births May-June, not immediate gestation completion.")
animals.roe_deer.reproduction="delayed_implantation"
animals.stoat.reproduction="delayed_implantation"
evidence(animals.stoat,"reproduction",animals.stoat.source_url,"Summer mating, implantation delayed until following spring; no unsupported numeric gestation.")
months("blackbird",{3,4,5,6,7,8},nil,"UK nesting usually March-August, not a universal physiological limit.")
months("blue_tit",{4,5,6},nil,"UK nesting April-June; normally one brood annually.")
local hare=animals.hare
field(hare,"maturity_days",range(236,365.2425,ADW.."Lepus_europaeus/","Sexual maturity reported from about eight months to one year; conservative runtime uses the upper endpoint.","reported_range"))
field(hare,"gestation_days",range(30,42,ADW.."Lepus_europaeus/","Reported gestation range; several litters can occur during the breeding season.","reported_range"))
field(hare,"juvenile_days",range(30,30,ADW.."Lepus_europaeus/","About one month to weaning/independence; leverets are precocial at birth.","approximate_duration"))
field(hare,"birth_count",range(1,8,ADW.."Lepus_europaeus/","Reported range is 1-8 leverets; runtime uses the conservative lower endpoint.","offspring_count"))
months("hare",{1,2,3,4,5,6,7},ADW.."Lepus_europaeus/","Midwinter (January/February) through midsummer in the cited northern-range account.")
hare.reproduction="live_birth"; hare.reproduction_enabled=true
hare.breeding_seasons={Winter=true,Spring=true,Summer=true}; hare.season_policy="sourced_midwinter_to_midsummer_coarse_seasons"
hare.postpartum_days=30; hare.care_confidence="source_reported"
local fox=animals.fox
field(fox,"maturity_days",range(304,304,ADW.."Vulpes_vulpes/","ADW reports maturity at about ten months (304-day entry); both sexes are reported similarly.","reported_average"))
field(fox,"gestation_days",range(51,53,ADW.."Vulpes_vulpes/","Typical gestation is 51-53 days; the account also notes a broader 49-56-day span.","typical_range"))
field(fox,"juvenile_days",range(56,70,ADW.."Vulpes_vulpes/","Nursing/weaning lasts about 56-70 days; leaving the den at 4-5 weeks is not independence.","weaning"))
field(fox,"birth_count",range(1,9,ADW.."Vulpes_vulpes/","ADW's summary table gives 1-9 pups, while its prose gives 1-13; use the narrower table range and lower endpoint.","source_disagreement_envelope"))
months("fox",{12,1,2},ADW.."Vulpes_vulpes/","Central-range mating is reported in January-February; winter is the conservative coarse runtime window.")
fox.reproduction="live_birth"; fox.reproduction_enabled=true
fox.breeding_seasons={Winter=true}; fox.season_policy="central_range_winter_policy"
fox.postpartum_days=300; fox.care_confidence="policy_fallback"
-- Winter dormancy is a coarse loaded-world behavior, distinct from death or
-- a measured metabolic rate. These taxa retain their saved residents.
animals.hedgehog.winter_dormant=true
animals.common_frog.winter_dormant=true
animals.smooth_newt.winter_dormant=true
local rabbit=animals.rabbit
field(rabbit,"gestation_days",range(30,37,ADW.."Oryctolagus_cuniculus/","ADW reproduction reports 30-37 days; approximately 30 typical.","reported_range"))
field(rabbit,"juvenile_days",range(22,31,ADW.."Oryctolagus_cuniculus/","Weaning age, not sexual maturity.","weaning"))
-- ADW rabbit maturity prose (8 months) conflicts with an embedded 730-day
-- value. Keep maturity unknown rather than laundering that discrepancy.
evidence(rabbit,"maturity_context",ADW.."Oryctolagus_cuniculus/","Conflicting maturity values on this page: not accepted as a verified threshold.")
months("rabbit",{1,2,3,4,5,6,7,8,9,10,11,12},ADW.."Oryctolagus_cuniculus/","Can breed year-round, mostly first half of year; habitat/health still required.")
local squirrel=animals.red_squirrel
field(squirrel,"gestation_days",range(38,39,ADW.."Sciurus_vulgaris/","Gestation 38-39 days.","reported_range"))
field(squirrel,"juvenile_days",range(56,70,ADW.."Sciurus_vulgaris/","Independence eight to ten weeks, not sexual maturity.","independence"))
field(squirrel,"maturity_days",range(296,365.2425,ADW.."Sciurus_vulgaris/","Composite envelope of female 296-day entry, male 320-day entry and approximately one-year narrative.","composite_envelope"))
-- Source gives birth/litter months, not conception months: no month override.
local vole=animals.bank_vole
field(vole,"maturity_days",range(42,56,WT.."animals/mammals/bank-vole/","Females six weeks, males eight weeks; use sex-specific values if available.","sex_envelope"))
field(vole,"juvenile_days",range(25,25,WT.."animals/mammals/bank-vole/","Weaning about 25 days.","weaning"))
local mouse=animals.wood_mouse
field(mouse,"gestation_days",range(21,26,ADW.."Apodemus_sylvaticus/","Gestation range 21-26 days.","reported_range"))
field(mouse,"juvenile_days",range(21,21,ADW.."Apodemus_sylvaticus/","Weaning at three weeks.","weaning"))
field(mouse,"maturity_days",range(65,71,ADW.."Apodemus_sylvaticus/","Male 65-day and female 71-day entries; narrative approximately two months.","sex_envelope"))
field(animals.common_frog,"larval_days",range(98,98,animals.common_frog.source_url,"About 14 weeks AFTER hatching to froglet; not maturity or egg incubation.","metamorphosis"))
animals.common_frog.reproduction="external_eggs"; animals.smooth_newt.reproduction="eggs"
animals.brown_trout.reproduction="external_eggs"; animals.bumblebee.reproduction="colony_caste_dependent"
-- Additional retrieved reproductive accounts, reviewed 2026-11-03. Registration
-- status is independent of evidence. A lifespan is NOT a breeding prerequisite.
field(vole,"gestation_days",range(17,24,ADW.."Myodes_glareolus/","17-24 days, mean 21; upper endpoint includes lactation delay.","reported_range"))
months("bank_vole",{4,5,6,7,8,9},ADW.."Myodes_glareolus/","Late April-September; runtime uses conservative Spring/Summer season policy.")
months("wood_mouse",{2,3,4,5,6,7,8,9,10},WIKI.."Wood_mouse","February-October in this account; ADW instead says March-early winter.")
local blackbird=animals.blackbird
field(blackbird,"maturity_days",years(1,1,ADW.."Turdus_merula/","Usually starts mating at one year.","approximate_onset"))
field(blackbird,"incubation_days",range(13,14,ADW.."Turdus_merula/","13-day prose and 14-day summary; conservative composite.","composite_envelope"))
field(blackbird,"juvenile_days",range(13,14,ADW.."Turdus_merula/","Leaving nest at 13-14 days; NOT full independence.","fledging"))
local mallard=animals.mallard
field(mallard,"maturity_days",range(365,365,ADW.."Anas_platyrhynchos/","Both sexes reported at 365 days; yearling breeding.","reported_average"))
field(mallard,"incubation_days",range(26,28,ADW.."Anas_platyrhynchos/","Time to hatching 26-28 days.","reported_range"))
field(mallard,"birth_count",range(9,13,ADW.."Anas_platyrhynchos/","Typical hen lays 9-13 eggs; not guaranteed survivors.","clutch_count"))
field(mallard,"juvenile_days",range(50,60,WIKI.."Mallard","50-60 days to flight; not reproductive maturity.","fledging"))
local blue=animals.blue_tit
local diary="https://www.bto.org/understanding-birds/articles/blue-tit-diary"
field(blue,"incubation_days",range(14,14,diary,"About two weeks; approximate, not an exact physiological constant.","approximate_duration"))
field(blue,"birth_count",range(8,10,diary,"Majority of clutches contain 8-10 eggs; larger clutches exist.","typical_clutch"))
field(blue,"juvenile_days",range(18,21,diary,"Fledging 18-21 days; parents feed for further weeks.","fledging"))
evidence(blue,"maturity_context",diary,"Usually breeds in spring after hatching; no exact day threshold accepted. Caterpillar provisioning absent, so disabled.")
field(animals.common_frog,"maturity_days",years(3,3,WIKI.."Common_frog","Sexual maturity after three years in this account; local variation exists.","approximate_onset"))
field(animals.common_frog,"incubation_days",range(14,21,WIKI.."Common_frog","Eggs hatch after 2-3 weeks; temperature-dependent, NOT froglet age.","reported_range"))
evidence(animals.brown_trout,"reproduction_context",WIKI.."Brown_trout","About 2000 eggs/kg; requires female mass, gravel redd and temperature-dependent development, not a fixed litter.")
evidence(animals.bumblebee,"reproduction_context",WIKI.."Bombus_terrestris","Queen-founded colonies, diploid females/haploid males, larvae and pupae; caste-specific care cannot use a mammalian litter timer.")
-- Explicit seasonal/care policies, NOT source-reported month precision or a
-- claim to model nests/physiology. Upper dependency endpoints avoid postpartum
-- immediate breeding; bird care lock is deliberately longer than fledging.
for _,id in ipairs({"bank_vole","wood_mouse","blackbird","mallard"}) do
local p=animals[id]
p.reproduction_enabled=true
p.reproduction=(id=="bank_vole" or id=="wood_mouse") and "live_birth" or "egg"
p.breeding_seasons={Spring=true,Summer=true}
p.season_policy="conservative_temperate_spring_summer"
p.postpartum_days=math.max(21,p.juvenile_days.max)+(p.reproduction=="egg" and 21 or 0)
p.care_confidence="policy_fallback"
end
local function id_ok(id) return type(id)=="string" and #id<=64 and id:match("^[a-z][a-z_]*$")~=nil end
function L.plant(id,group)
if not id_ok(id) then return nil,"id" end
if plants[id] then
if group~=nil and group~=plants[id].group then return nil,"group" end
return copy(plants[id])
end
return nil,"unknown_species" -- never fabricate an identity from a group hint
end
function L.animal(id)
if not id_ok(id) then return nil,"id" end
if not animals[id] then return nil,"unknown_species" end
return copy(animals[id])
end
function L.ids(kind)
local out={}; local src=kind=="plant" and plants or kind=="animal" and animals
if not src then return nil,"kind" end
for id in pairs(src) do out[#out+1]=id end; table.sort(out); return out
end
local function valid_range(r)
return type(r)=="table" and finite(r.min) and finite(r.max) and r.min>=0 and r.max>=r.min and r.max<=1000000
end
-- Stable numeric seed, field salt <=64 bytes, bounded floating-point arithmetic.
-- Reproducible selection only; NOT cryptography and never math.randomseed().
function L.duration(r,seed,salt)
if not valid_range(r) then return nil,"range" end
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
if type(salt)~="string" or #salt>64 then return nil,"salt" end
local h=seed+1.0
for i=1,#salt do h=(h*131.0+salt:byte(i))%2147483647.0 end
h=(h*48271.0)%2147483647.0
return r.min+(r.max-r.min)*(h/2147483647.0)
end
local durations={"germination_days","juvenile_days","maturity_days","lifespan_days","gestation_days","incubation_days","larval_days"}
function L.resolve(profile,seed)
if type(profile)~="table" or not id_ok(profile.id) then return nil,"profile" end
if not finite(seed) or seed<0 or seed>2147483646 or seed%1~=0 then return nil,"seed" end
local out=copy(profile); out.selected_days={}
for _,name in ipairs(durations) do
if profile[name] then
local n,err=L.duration(profile[name],seed,profile.id..":"..name)
if not n then return nil,err end
out.selected_days[name]=n
end
end
-- Overlapping stage ranges must not produce maturity before dependency or
-- germination. Clamp to the preceding selected endpoint, never outside a range.
local previous=0
for _,name in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local n=out.selected_days[name]
if n then
n=math.max(previous,n)
if n>profile[name].max then return nil,"profile_order" end
out.selected_days[name]=n; previous=n
end
end
out.selection_seed=seed; return out
end
function L.elapsed(previous,now,work_cap_seconds)
if not clock(previous) or not clock(now) then return nil,"clock" end
if now<previous then return nil,"clock_reversed" end
if not clock(work_cap_seconds) then return nil,"cap" end
local age_delta=now-previous; local work=math.min(age_delta,work_cap_seconds)
return {elapsed_seconds=age_delta,work_seconds=work,deferred_seconds=age_delta-work}
end
local function threshold(profile,name)
local r=profile[name]
if r==false or r==nil then return false end
if not valid_range(r) then return nil end
if profile.selected_days~=nil and type(profile.selected_days)~="table" then return nil end
local selected=profile.selected_days and profile.selected_days[name]
if selected~=nil then
if not finite(selected) or selected<r.min or selected>r.max then return nil end
return selected
end
return r.max -- conservative unsampled policy, not a random draw on each read
end
function L.phase(profile,born_at,now)
if type(profile)~="table" or (profile.kind~="plant" and profile.kind~="animal") then return nil,"profile" end
if not clock(born_at) or not clock(now) then return nil,"clock" end
if now<born_at then return nil,"clock_reversed" end
local age=(now-born_at)/DAY
local g,j,m,life=threshold(profile,"germination_days"),threshold(profile,"juvenile_days"),
threshold(profile,"maturity_days"),threshold(profile,"lifespan_days")
if g==nil or j==nil or m==nil or life==nil then return nil,"profile" end
if (g and j and g>j) or (j and m and j>m) or (m and life and m>life) then return nil,"profile_order" end
local phase="unknown"
if profile.kind=="plant" and g and age<g then phase="seed"
elseif j and age<j then phase=profile.kind=="plant" and "seedling" or "dependent"
elseif m and age<m then phase="juvenile"
elseif m then phase="mature" end
local beyond=life and age>=life or false
return {phase=phase,age_days=age,age_seconds=now-born_at,beyond_lifespan_range=beyond,
-- A population average or record age must NEVER be an automatic death.
death_due=false,reproductive_ready=false,confidence=profile.confidence}
end
function L.seasonal(profile,season)
if type(profile)~="table" or profile.kind~="plant" then return nil,"profile" end
if not ({Spring=true,Summer=true,Autumn=true,Winter=true})[season] then return nil,"season" end
return {dormant=season=="Winter" and profile.seasonal_policy=="dormant_not_dead",death_due=false,
confidence="policy_fallback"} -- presentation only; not a frost or survival model
end
function L.breeding_window(profile,month)
if type(profile)~="table" or profile.kind~="animal" then return nil,"profile" end
if not finite(month) or month%1~=0 or month<1 or month>12 then return nil,"month" end
local list=profile.breeding_months
if list==false or list==nil then return {known=false,within_window=false,eligible=false} end
if type(list)~="table" or #list>12 then return nil,"profile" end
local allowed=false
for _,m in ipairs(list) do
if not finite(m) or m%1~=0 or m<1 or m>12 then return nil,"profile" end
if m==month then allowed=true end
end
return {known=true,within_window=allowed,eligible=false} -- window alone never authorizes reproduction
end
L.VERSION=1; L.SECONDS_PER_DAY=DAY; L.DAYS_PER_YEAR=YEAR; L.MAX_CLOCK=MAX_CLOCK
return L
+2 -1
View File
@@ -1,5 +1,6 @@
name = hearth name = hearth
title = The Last Hearth title = The Last Hearth
description = Original survival-first eom.dev prototype. No external game dependencies. description = Survival-first woodland game with original art and a pinned Creatura wildlife AI API.
author = eom.dev author = eom.dev
min_minetest_version = 5.18 min_minetest_version = 5.18
depends = creatura
+11 -6
View File
@@ -32,7 +32,7 @@ return function(hearth, api)
local function record(value) local function record(value)
return B.get(type(value)=="table" and value.id or value) return B.get(type(value)=="table" and value.id or value)
end end
-- Identity references cover all 80 records. An identity link is not evidence -- Identity references cover all 100 records. An identity link is not evidence
-- that every tagged trait has been reviewed or implemented. -- that every tagged trait has been reviewed or implemented.
function E.source(value) function E.source(value)
local r=record(value); if not r then return nil end local r=record(value); if not r then return nil end
@@ -53,10 +53,10 @@ return function(hearth, api)
local out={}; for _,r in ipairs(B.list()) do out[r.id]=E.source(r) end; return out local out={}; for _,r in ipairs(B.list()) do out[r.id]=E.source(r) end; return out
end end
local tags={"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"} 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 nitrogen="After 90 real calendar days of root age, in warm active conditions: 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). Timing is game policy, not a sourced fixation rate."
local descriptions={ local descriptions={
browse="Active natural plants count as wildlife forage; grazing consumes eligible plants, not mature trees. Not human food permission.", browse="Active natural plants count as wildlife forage; grazing debits a shared partial-canopy ration with a seven-real-day rest, not the perennial root. Not human food permission.",
pollinator="Only natural plants in their catalogue flowering season add to pollination. This input does not itself change berry timers.", pollinator="Mature natural plants in their flowering season support pollination. Nectar visits reserve a shared one-hour nectar deadline and at most one pollination credit expiring after one real day. Human harvesting spends that credit for at most one extra berry from an existing fruit crop; it never resets annual fruit readiness or creates seeds.",
nursery="Each active natural non-component plant adds nursery cover; any cover reduces succession stress accumulation to 75%. No extra animal spawn is promised.", 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.", parasite="Not simulated: no infection or attack on living trees or player plants.",
allelopathy="Not simulated: no chemical suppression or plant killing.", allelopathy="Not simulated: no chemical suppression or plant killing.",
@@ -75,10 +75,10 @@ return function(hearth, api)
elseif tag=="browse" or tag=="pollinator" or tag=="nursery" then status="simulated" elseif tag=="browse" or tag=="pollinator" or tag=="nursery" then status="simulated"
elseif tag=="ectomycorrhiza" then elseif tag=="ectomycorrhiza" then
if r.group=="fungi" 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." status,game="simulated","Active compatible nearby fungus permits a finite nutrient exchange: successful calendar-eligible seedling growth debits at most 1 existing soil nutrient once, regardless of fungus count. No accelerated growth, nutrient creation or carbon/water ledger; lifecycle age thresholds are unchanged."
else game="Not simulated as a tree aura: only an active compatible nearby fungus can enable the finite seedling nutrient debit." end 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 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." status,game="simulated","On suitable natural deadwood/litter only: add at most 60 seconds per evaluation to its finite decay bonus, capped at one mean real year for deadwood or 90 real days for litter. Only substrate decay pays its remaining soil nutrients once; the fungus pays none. Soil-growing saprotrophs receive no special nutrient effect."
elseif tag=="engineer" then elseif tag=="engineer" then
if r.id=="sphagnum" 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." 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."
@@ -91,6 +91,11 @@ return function(hearth, api)
if r.group=="trees" then 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.") 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 end
local profile=hearth.lifecycle and hearth.lifecycle.plant(r.id)
add("lifecycle","simulated", "Calendar ages use real elapsed days, including unloaded time; reconciliation is bounded and loaded-only, with no missed-event replay. "
.. (profile and "Profile: "..profile.confidence..". Each timing field is separately source_reported, policy_fallback or UNKNOWN; an identity URL is not timing evidence."
or "Lifecycle profile UNKNOWN in this runtime; no invented maturity or lifespan.")
.. " Lifespan endpoints never authorize automatic death; fungal/lichen unknown maturity is not propagation permission.")
if r.toxic then add("toxicity","descriptive","Not simulated as an attack: this warning never grants a poison aura or human food permission.") 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 return out
end end
+336 -43
View File
@@ -1,4 +1,4 @@
-- Original loaded-only succession. No ABMs, map emergence, timers or world registry. -- Calendar age with bounded loaded reconciliation. No emergence or event replay.
return function(hearth, api) return function(hearth, api)
local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192, local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192,
MAX_TREE_CELLS=810, MAX_WATER_DEPTH=6} MAX_TREE_CELLS=810, MAX_WATER_DEPTH=6}
@@ -9,6 +9,16 @@ return function(hearth, api)
local trees = {tree_seedling=true, young_tree=true, mature_tree=true} local trees = {tree_seedling=true, young_tree=true, mature_tree=true}
local diversity = hearth.diversity local diversity = hearth.diversity
local botany = hearth.botany local botany = hearth.botany
local lifecycle = hearth.lifecycle
local DAY, YEAR = 86400, 365.2425*86400
local DECAY = {dung=600,litter=90*DAY,deadwood=YEAR,fungi=2400}
-- One synchronous, separately paid cache handoff, never a saved animal seed.
local cache_nut
local function resource_now()
local t=api.now and api.now() or os.time()
if type(t)~="number" or t~=t or t<0 or t>9007199254740991-YEAR then return nil end
return math.floor(t)
end
local effects = hearth.species_effects local effects = hearth.species_effects
-- Content may inject the factory before succession. Standalone integrations -- Content may inject the factory before succession. Standalone integrations
-- without it keep their old API; the engine loads only this fixed sibling. -- without it keep their old API; the engine loads only this fixed sibling.
@@ -56,6 +66,98 @@ return function(hearth, api)
if not n or n ~= n or n < 0 or n == math.huge then return 0 end if not n or n ~= n or n < 0 or n == math.huge then return 0 end
return math.min(maximum or 1000000000000, n) return math.min(maximum or 1000000000000, n)
end end
local function valid_clock(n)
return type(n)=="number" and n==n and n>=0 and n<=9007199254740991
end
local function stored(m,f,default,maximum)
local s=m:get_string(f)
if s=="" then return default end
local n=tonumber(s)
if not valid_clock(n) or n>(maximum or 9007199254740991) then return nil end
return n
end
local function profile_for(k,p)
local id=botany and botany.kind(k) or (diversity and diversity.tree_info(k))
local profile=id and lifecycle and lifecycle.plant(id)
if not profile and (trees[k] or (k=="log" and api.get_meta(p):get_string("succ_worldgen_stage")=="mature")) and not id then
-- Unidentified legacy tree: explicit conservative family policy, not
-- a silently assigned birch identity or an accelerated timber crop.
profile={id="generic_tree",kind="plant",confidence="policy_fallback",
juvenile_days={min=365.2425,max=365.2425},maturity_days={min=3652.425,max=3652.425},
lifespan_days=false,germination_days=false}
end
if not profile then return nil end
local seed=math.abs(p.x*73856093.0+p.y*19349663.0+p.z*83492791.0)%2147483647
return profile,seed
end
-- Persist one root anchor, never reinterpret stage-local legacy active seconds.
-- Inferred establishment age is separate from the observed adoption epoch.
local function calendar(p,k,now,origin)
if not valid_clock(now) then return nil end
local profile,seed=profile_for(k,p)
if not profile then return nil end
local m=api.get_meta(p)
local version=m:get_string("succ_lifecycle_version")
if version=="" then
-- A partly erased record is corruption, not permission to reroll age.
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return nil end
local offset=0
origin=origin or m:get_string("succ_pending_origin")
if origin=="" or not origin then origin="legacy_established" end
local stage=diversity and select(2,diversity.tree_info(k))
stage=stage or ({tree_seedling="seedling",young_tree="young",mature_tree="mature"})[k]
stage=stage or (m:get_string("succ_worldgen_stage")=="mature" and "mature")
if origin~="natural_seed" then
local threshold
if stage=="seedling" then threshold=profile.germination_days
elseif stage=="young" then threshold=profile.juvenile_days
else threshold=profile.maturity_days end
offset=threshold and threshold.max*DAY or 0
end
m:set_string("succ_birth_at",tostring(now))
m:set_string("succ_initial_age_seconds",tostring(offset))
m:set_string("succ_lifecycle_seed",tostring(seed))
m:set_string("succ_lifecycle_origin",origin)
m:set_string("succ_lifecycle_profile",profile.id)
m:set_string("succ_lifecycle_policy","calendar_v1_conservative_max")
m:set_string("succ_last_observed_at",tostring(now))
m:set_string("succ_seed_epoch",tostring(now))
m:set_string("succ_lifecycle_version","1")
m:set_string("succ_pending_origin","")
end
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=profile.id then return nil end
local born=tonumber(m:get_string("succ_birth_at"))
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
local previous=tonumber(m:get_string("succ_last_observed_at"))
local stored_seed=tonumber(m:get_string("succ_lifecycle_seed"))
local epoch=tonumber(m:get_string("succ_seed_epoch"))
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(previous) or not valid_clock(epoch)
or not stored_seed or stored_seed<0 or stored_seed>2147483646 or stored_seed%1~=0
or previous<born or now<previous or now<born then return nil end
local age=initial+(now-born)
if not valid_clock(age) then return nil end
local phase
if lifecycle then phase=lifecycle.phase(profile,0,age)
else
phase={phase=age<profile.juvenile_days.max*DAY and "seedling"
or (age<profile.maturity_days.max*DAY and "juvenile" or "mature"),age_seconds=age,death_due=false}
end
if not phase then return nil end
if previous~=now then m:set_string("succ_last_observed_at",tostring(now)) end
return phase,profile
end
local function flowering_ready(q,r,now)
local m=api.get_meta(q)
if m:get_string("succ_lifecycle_version")=="" then return true end -- established, not yet adopted
if m:get_string("succ_lifecycle_version")~="1" or not lifecycle then return false end
local born=tonumber(m:get_string("succ_birth_at"))
local initial=tonumber(m:get_string("succ_initial_age_seconds"))
local high=tonumber(m:get_string("succ_last_observed_at"))
if not valid_clock(born) or not valid_clock(initial) or not valid_clock(high) or now<born or now<high then return false end
local profile=lifecycle.plant(r.id)
local phase=profile and lifecycle.phase(profile,0,initial+now-born)
return phase~=nil and phase.phase=="mature"
end
local markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"} local markers = {"owner", "planter", "placed_by", "player_placed", "hearth_placed", "hearth_player_placed", "planted_at"}
local function marked(p) local function marked(p)
local meta = api.get_meta(p) local meta = api.get_meta(p)
@@ -165,6 +267,7 @@ return function(hearth, api)
h.nutrients=number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8) h.nutrients=number(api.get_meta(at(p,0,-1,0)),"succ_nutrients",8)
-- Leafless deciduous branches cast less shade than a summer canopy. -- Leafless deciduous branches cast less shade than a summer canopy.
local season=hearth.seasons and hearth.seasons.season() or "Summer" local season=hearth.seasons and hearth.seasons.season() or "Summer"
local habitat_now=os.time()
local foliage = season == "Winter" and 0.15 or 1 local foliage = season == "Winter" and 0.15 or 1
-- Fixed 5x5x5 stencil: includes river water, vegetation and overhead canopy. -- 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 for x=-2,2 do for z=-2,2 do for y=-1,3 do
@@ -203,10 +306,11 @@ return function(hearth, api)
end end
if not h.host and r.group=="trees" then h.host=r.id 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 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 local blooms=botany.pollinator(k,season) and flowering_ready(q,r,habitat_now)
if blooms 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 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.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 blooms 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 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 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
@@ -425,7 +529,25 @@ return function(hearth, api)
if species or origin then m:set_string("succ_species",species or origin) 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 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_natural", target and "1" or "")
m:set_string("succ_age", "0"); m:set_string("succ_stress", "0") local oldprofile=m:get_string("succ_lifecycle_profile")
local nextprofile=target and profile_for(target,p)
local same=nextprofile and oldprofile==nextprofile.id
if not same then
for _,field in ipairs({"succ_birth_at","succ_initial_age_seconds","succ_last_observed_at","succ_lifecycle_version",
"succ_lifecycle_seed","succ_lifecycle_origin","succ_lifecycle_profile","succ_lifecycle_policy","succ_seed_epoch","succ_litter_epoch"}) do
m:set_string(field,"")
end
m:set_string("succ_age","0")
if nextprofile then calendar(p,target,os.time(),"natural_seed") end
end
m:set_string("succ_stress", "0")
if target=="litter" or target=="deadwood" then
m:set_string("succ_decay_at",tostring(os.time()))
m:set_string("succ_decay_seen_at",tostring(os.time()))
m:set_string("succ_decay_initial", "0")
m:set_string("succ_decay_bonus", "0")
m:set_string("succ_litter_remaining",target=="litter" and "1" or "")
end
if hearth.invalidate_environment then hearth.invalidate_environment(p) end if hearth.invalidate_environment then hearth.invalidate_environment(p) end
return true return true
end end
@@ -480,21 +602,44 @@ return function(hearth, api)
if component~="" and component~=key(p) then return false end if component~="" and component~=key(p) then return false end
local dt = active(p,now) local dt = active(p,now)
local m, n = api.get_meta(p), node(p) local m, n = api.get_meta(p), node(p)
if dt == 0 then return false end
local h = S.habitat(p) local h = S.habitat(p)
if not h.loaded then seen[key(p)]=nil; return false end if not h.loaded then seen[key(p)]=nil; return false end
local k = kind(n) local k = kind(n)
local f=family(k) local f=family(k)
local species=diversity and diversity.tree_info(k) local species=diversity and diversity.tree_info(k)
if m:get_string("succ_worldgen_stage")=="mature" then
f="mature_tree"
local id=m:get_string("succ_species")
if id~="generic" then species=id end
end
local record=botany and botany.get(k) local record=botany and botany.get(k)
local calendar_profile=profile_for(k,p)
local phase=calendar_profile and calendar(p,k,now)
if calendar_profile and not phase then return false end
-- Dead organic matter also has a calendar, independent of visit count.
-- Legacy active seconds are retained as a small observed initial age.
local organic=k=="litter" or k=="deadwood"
local decay_age
if organic then
local born=stored(m,"succ_decay_at",now)
local high=stored(m,"succ_decay_seen_at",now)
local initial=stored(m,"succ_decay_initial",number(m,"succ_age",DECAY[k]),DECAY[k])
local bonus=stored(m,"succ_decay_bonus",0,DECAY[k])
if not born or not high or not initial or not bonus or born>now or high>now then return false end
decay_age=initial+now-born+bonus
m:set_string("succ_decay_at",tostring(born)); m:set_string("succ_decay_seen_at",tostring(now))
m:set_string("succ_decay_initial",tostring(initial))
end
if dt==0 then return false end -- one cheap baseline, no side effects replay
local season=hearth.seasons and hearth.seasons.season(now) or "Summer" local season=hearth.seasons and hearth.seasons.season(now) or "Summer"
local climate = hearth.climate_at and hearth.climate_at(now) local climate = hearth.climate_at and hearth.climate_at(now)
local warm = not climate or (climate.temperature or 12) >= 3 local warm = not climate or (climate.temperature or 12) >= 3
local decomposer = k=="dung" or k=="litter" or k=="deadwood" or k=="fungi" 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 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_dt = (not decomposer and (not warm or dormant)) and 0 or dt
local age = number(m,"succ_age",1000000)+age_dt local age = decay_age or (phase and phase.age_seconds) or number(m,"succ_age",1000000)+age_dt
m:set_string("succ_age", tostring(age)) -- Keep the old stage-local counter untouched on migrated living roots.
if not phase then m:set_string("succ_age", tostring(age)) end
if number(m,"succ_next") > now then return false end if number(m,"succ_next") > now then return false end
-- Commit the attempt BEFORE evaluating it, including failed competition/dispersal. -- Commit the attempt BEFORE evaluating it, including failed competition/dispersal.
m:set_string("succ_next", tostring(now+PERIOD)) m:set_string("succ_next", tostring(now+PERIOD))
@@ -546,24 +691,29 @@ return function(hearth, api)
local stress_dt=botany and h.nursery>0 and dt*0.75 or dt 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 local stress = harsh and number(m,"succ_stress",100000)+stress_dt or 0
m:set_string("succ_stress",tostring(stress)) m:set_string("succ_stress",tostring(stress))
if stress>=600 then if stress>=600 and not phase then
local remains=trees[k] and "deadwood" or "litter" 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 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 if diversity and diversity.is_rock(node(at(p,0,-1,0))) then remains=nil end
return change(p,remains) return change(p,remains)
end end
if decomposer then if decomposer then
local lifetime = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k] local lifetime = DECAY[k]
if age>=lifetime then if age>=lifetime then
local remaining=k=="litter" and stored(m,"succ_litter_remaining",1,1) or 1
if not remaining then return false end
local target = k=="deadwood" and "litter" or nil 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 if change(p,target) then
if k=="litter" then m:set_string("succ_litter_remaining","0") end
nutrients(p,k=="dung" and 4 or (k=="deadwood" and 3 or remaining)); return true
end
elseif k~="fungi" and h.moisture>=0.55 and h.light<=12 and age>=300 then elseif k~="fungi" and h.moisture>=0.55 and h.light<=12 and age>=300 then
disperse(p,"fungi",serial) disperse(p,"fungi",serial)
end end
return false return false
end end
if not warm then return false end if not warm then return false end
if record and age>=900 and record.tags.nitrogenfixer then if record and age>=90*DAY and record.tags.nitrogenfixer then
local below=at(p,0,-1,0) local below=at(p,0,-1,0)
local soil=api.get_meta(below) local soil=api.get_meta(below)
local fixed=number(soil,"succ_fixed_nitrogen",2) local fixed=number(soil,"succ_fixed_nitrogen",2)
@@ -572,11 +722,10 @@ return function(hearth, api)
soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta)) soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta))
end end
end end
local seed_age=21600 local seed_age=calendar_profile and calendar_profile.juvenile_days and calendar_profile.juvenile_days.max*DAY or YEAR
local exchange=0 local exchange=0
if botany and species and f=="tree_seedling" then 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 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 end
if f=="tree_seedling" and age>=seed_age and h.canopy<1.5 then 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 change(p,species and species.."_young" or "young_tree") then
@@ -585,13 +734,19 @@ return function(hearth, api)
end end
return false return false
end end
if f=="young_tree" and age>=86400 and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end local maturity=calendar_profile and calendar_profile.maturity_days and calendar_profile.maturity_days.max*DAY or 10*YEAR
if f=="young_tree" and age>=maturity and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end
if f=="mature_tree" then if f=="mature_tree" then
if age>=259200 then return change(p,"deadwood") end -- One present seed opportunity, never one offspring per missed year.
if age>=1800 and serial%10==0 then disperse(p,species and species.."_seedling" or "tree_seedling",serial) end if phase and phase.phase=="mature" and (not record or botany.seeding(k,season))
if serial%5==0 then disperse(p,"litter",serial) end and now>=number(m,"succ_seed_epoch",9007199254740991) then
m:set_string("succ_seed_epoch",tostring(now+YEAR))
disperse(p,species and species.."_seedling" or "tree_seedling",serial)
end
if now>=number(m,"succ_litter_epoch",9007199254740991) then
m:set_string("succ_litter_epoch",tostring(now+30*DAY)); disperse(p,"litter",serial)
end
elseif record and not trees[k] then 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") if record.tags.saprotroph and (h.substrate=="deadwood" or h.substrate=="litter")
and botany.suitable(k,h,season) then and botany.suitable(k,h,season) then
-- Advance only an existing natural substrate's finite clock; -- Advance only an existing natural substrate's finite clock;
@@ -599,24 +754,17 @@ return function(hearth, api)
local below=at(p,0,-1,0) local below=at(p,0,-1,0)
if natural(below,node(below)) and permitted(below) then if natural(below,node(below)) and permitted(below) then
local wood=api.get_meta(below) local wood=api.get_meta(below)
local limit=h.substrate=="deadwood" and 7200 or 1800 local limit=DECAY[h.substrate]
wood:set_string("succ_age",tostring(math.min(limit,number(wood,"succ_age",limit)+math.min(dt,PERIOD)))) local bonus=stored(wood,"succ_decay_bonus",0,limit)
if bonus then wood:set_string("succ_decay_bonus",tostring(math.min(limit,bonus+math.min(dt,PERIOD)))) end
end end
end end
if age>=lifetime then -- Unknown maturity (fungi/lichens) is not automatic propagation or
local remains="litter" -- death permission. A sprite persists as its root/colony observation.
if record.group=="fungi" or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter" if phase and phase.phase=="mature" and botany.seeding(k,season)
or (diversity and diversity.is_rock(node(at(p,0,-1,0)))) then remains=nil end and now>=number(m,"succ_seed_epoch",9007199254740991) then
return change(p,remains) m:set_string("succ_seed_epoch",tostring(now+30*DAY)); disperse(p,k,serial)
end 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 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 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) local recruit=diversity.choose_tree(p,h)
@@ -660,25 +808,170 @@ return function(hearth, api)
end end end end
return false return false
end end
function S.forage(raw) -- All animal resource probes are read-only until their complete preflight.
-- No bootstrap/adoption, calendar writes, callbacks or inventory grants here.
local function resource_site(q)
local n=node(q)
local below=at(q,0,-1,0)
if not n or not node(below) or not natural(q,n) or not permitted(q) or not permitted(below) then return nil end
local component=api.get_meta(q):get_string("succ_component")
if component~="" and component~=key(q) then return nil end
if not ground(below) or (diversity and not diversity.is_ground(node(below))) then return nil end
return n,api.get_meta(q),below
end
local function mature_resource(q,r,time)
local m=api.get_meta(q)
if not lifecycle or not r then return false end
local profile=lifecycle.plant(r.id)
if not profile or not profile.maturity_days then return false end
if m:get_string("succ_lifecycle_version")=="" then
-- Same established-stand assumption as calendar adoption, without
-- mutating a failed transaction or treating a seedling as mature.
if m:get_string("succ_birth_at")~="" or m:get_string("succ_lifecycle_profile")~="" then return false end
local k=kind(node(q))
return r.group~="trees" or family(k)=="mature_tree"
or (family(k)=="log" and m:get_string("succ_worldgen_stage")=="mature")
end
if m:get_string("succ_lifecycle_version")~="1" or m:get_string("succ_lifecycle_profile")~=r.id then return false end
local born=stored(m,"succ_birth_at",nil)
local initial=stored(m,"succ_initial_age_seconds",nil)
local high=stored(m,"succ_last_observed_at",nil)
if not born or not initial or not high or high<born or time<high or time<born then return false end
local phase=lifecycle.phase(profile,0,initial+time-born)
return phase and phase.phase=="mature" or false
end
local function resource_season(time)
local c=hearth.climate_at and hearth.climate_at(time)
return c and c.season or (hearth.seasons and hearth.seasons.season(time))
end
function S.pollinate(raw)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or not botany.active(n,season) or not botany.pollinator(n,season) or not mature_resource(p,r,time) then return false end
local ready=stored(m,"succ_nectar_ready_at",0)
local pollen=stored(m,"succ_pollinated_until",0)
if not ready or not pollen or ready>time or pollen>time+DAY then return false end
m:set_string("succ_nectar_ready_at",tostring(time+3600))
-- Boolean shared credit: not an accumulating yield counter or seeds.
if pollen<=time then m:set_string("succ_pollinated_until",tostring(time+DAY)) end
return true
end
function S.frugivore(raw,species_id)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or (species_id~=nil and species_id~=r.id) or r.group~="shrubs"
or not botany.active(n,season) or not botany.edible(n,season) or not mature_resource(p,r,time) then return false end
local ready=stored(m,"botany_ready_at",0)
if not ready or ready>time then return false end
-- Eat one unit, discard the rest of the existing two-fruit batch.
-- Share the human lock, never create a second animal-only reserve.
m:set_string("botany_ready_at",tostring(time+YEAR))
return true
end
function S.granivore(raw,species_id)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time or not botany then return false end
local n,m=resource_site(p)
local r=n and botany.get(n)
local season=resource_season(time)
if not r or (species_id~=nil and species_id~=r.id) or (r.id~="oak" and r.id~="hazel")
or season~="Autumn" or not botany.seeding(n,season) or not mature_resource(p,r,time) then return false end
local rootkind=family(kind(n))
if rootkind~="mature_tree" and not (rootkind=="log" and m:get_string("succ_worldgen_stage")=="mature") then return false end
local ok,date=pcall(os.date,"!*t",time)
local year=ok and type(date)=="table" and date.year or nil
if not valid_clock(year) then return false end
local previous=stored(m,"succ_nuts_year",0)
local remaining=stored(m,"succ_nuts_remaining",2,2)
if not previous or not remaining or previous%1~=0 or remaining%1~=0 or previous>year then return false end
if previous<year then remaining=2 end -- current crop only, no missed-year replay
if remaining<1 then return false end
local cache=remaining>=2
m:set_string("succ_nuts_year",tostring(year))
m:set_string("succ_nuts_remaining",tostring(remaining-(cache and 2 or 1)))
if cache then cache_nut={id=r.id,time=time,source=p}; return true,r.id end
return true
end
function S.disperse_seed(raw,species_id)
local paid=cache_nut; cache_nut=nil -- every attempt spends it, even failure
local p,time=position(raw),resource_now()
if not paid or not p or not time or paid.time~=time or paid.id~=species_id then return false end
-- Immediate local handoff only. Recheck the donor without replenishing it.
if math.abs(p.x-paid.source.x)>2 or p.y~=paid.source.y or math.abs(p.z-paid.source.z)>2 then return false end
local donor=resource_site(paid.source)
if not donor or botany.kind(donor)~=species_id then return false end
local n=node(p); local below=at(p,0,-1,0)
if not n or n.name~="air" or not node(below) or not ground(below)
or not permitted(p) or not permitted(below) then return false end
local m=api.get_meta(p)
local ready=stored(m,"succ_next",0)
if not ready or ready>time then return false end
if not change(p,species_id.."_seedling") then return false end
m:set_string("succ_next",tostring(time+DAY))
return true
end
function S.detritivore(raw)
cache_nut=nil
local p,time=position(raw),resource_now()
if not p or not time then return false end
local n,m,below=resource_site(p)
if not n or kind(n)~="litter" then return false end
local remaining=stored(m,"succ_litter_remaining",1,1)
local fertility=stored(api.get_meta(below),"succ_nutrients",0,8)
if not remaining or remaining<0.099999 or not fertility or fertility>7.9 then return false end
local last=remaining<0.100001
-- Final removal must preflight any dependent fungus BEFORE any debit.
if last and not change(p,nil) then return false end
m:set_string("succ_litter_remaining",tostring(last and 0 or remaining-0.1))
api.get_meta(below):set_string("succ_nutrients",tostring(fertility+0.1))
return true
end
function S.forage(raw,mode)
cache_nut=nil
local p = position(raw) local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end local time=resource_now()
if not p or not time or not node(p) or not permitted(p) then return false end
return nearby(p,function(q) return nearby(p,function(q)
local n = node(q) local n,m = resource_site(q)
local k=kind(n) local k=kind(n)
if not natural(q,n) then return false end if not n then return false end
if botany and botany.kind(k) then if botany and botany.kind(k) then
if not botany.browse(k,hearth.seasons and hearth.seasons.season() or "Summer") local season=hearth.seasons and hearth.seasons.season() or "Summer"
or (trees[k] and family(k)~="tree_seedling") then return false end local ordinary=botany.browse(k,season)
local seasonal=false
if (mode=="seasonal_browse" or mode=="winter_browse")
and (season=="Autumn" or season=="Winter") then
local profile=botany.get(k)
seasonal=profile and profile.tags.browse and not profile.toxic
and (profile.group=="trees" or profile.group=="shrubs" or profile.group=="grasses")
end
local stage=family(k)
local tree_identity=diversity and diversity.tree_info(k)
local living_tree=tree_identity~=nil or trees[k]
if (not ordinary and not seasonal) or (living_tree and stage~="tree_seedling") then return false end
elseif diversity then if not diversity.edible(k) 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 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 mode~="seasonal_browse" and mode~="winter_browse"
if not change(q,nil) then return false end and hearth.seasons and not hearth.seasons.available(k) then return false end
api.get_meta(q):set_string("succ_next",tostring(os.time()+300)) local ready=stored(m,"succ_browse_ready_at",0)
if not ready or ready>time then return false end
-- One partial canopy ration, not destruction of the whole perennial.
-- Seven real days is a conservative game fallback, not measured growth.
m:set_string("succ_browse_ready_at",tostring(time+7*DAY))
return true return true
end) end)
end end
function S.deposit(raw) function S.deposit(raw)
cache_nut=nil
local p = position(raw) local p = position(raw)
if not p or not node(p) or not permitted(p) then return false end if not p or not node(p) or not permitted(p) then return false end
return nearby(p,function(q) return nearby(p,function(q)
@@ -743,7 +1036,7 @@ return function(hearth, api)
if hearth.seasons then if hearth.seasons then
for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end
end end
api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading", api.register_lbm({name="hearth:succession_loaded", label="Reconcile calendar roots within the loaded work budget",
nodenames=reload_nodes, run_at_every_load=true, nodenames=reload_nodes, run_at_every_load=true,
action=function(p) action=function(p)
seen[key(p)]=nil seen[key(p)]=nil
File diff suppressed because one or more lines are too long
+924 -223
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -7,6 +7,7 @@ import { BODY_PROFILES, playerB3D, soundOGG, SOUND_PROFILES, TOOL_NAMES, toolOBJ
import { ANIMAL_NAMES, BOTANY_CATALOGUE, animalOBJ, contactSheet, legacyPixel, worldArt } from './world-art.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 { 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'; import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterArt, characterMesh, compositeCharacter, portraitSheet } from './character-art.mjs';
import { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, faunaB3D, faunaTexture, faunaPortraitSheet } from './fauna-art.mjs';
function crc32(data) { function crc32(data) {
let crc = 0xffffffff; let crc = 0xffffffff;
@@ -119,7 +120,9 @@ export async function generateAssets(destination, source) {
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height)); (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) { for (const name of SPECIES_ATLAS_NAMES) {
const [species, , season='summer'] = name.split('_'), trees=treeAtlas(art,season,species); const match=/^([a-z]+(?:_[a-z]+)*)_atlas(?:_(spring|autumn|winter))?$/.exec(name);
if(!match) throw new Error('Invalid species atlas name');
const [,species,season='summer']=match, trees=treeAtlas(art,season,species);
await writeAsset(textures, `hearth_${name}.png`, png(trees.width, 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)); (x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
} }
@@ -135,6 +138,11 @@ export async function generateAssets(destination, source) {
} }
const encode = image => png(image.width, const encode = image => png(image.width,
(x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height); (x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height);
for(const id of FAUNA_NAMES) await writeAsset(textures,`hearth_fauna_${id}.png`,encode(faunaTexture(id)));
await writeAsset(textures,'hearth_fauna_contact_sheet.png',encode(faunaPortraitSheet()));
await writeAsset(destination,'fauna-art.json',JSON.stringify({format:1,animations:FAUNA_ANIMATIONS,
animals:FAUNA_ART_MANIFEST,portraitSheet:{filename:'hearth_fauna_contact_sheet.png',width:512,height:768,
frame:9,columns:4,cellSize:128,order:FAUNA_NAMES}},null,2)+'\n');
for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image)); for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image));
await writeAsset(textures,'hearth_person.png',encode(compositeCharacter())); await writeAsset(textures,'hearth_person.png',encode(compositeCharacter()));
await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet())); await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet()));
@@ -146,6 +154,7 @@ export async function generateAssets(destination, source) {
await ensureDirectory(models); await ensureDirectory(models);
for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name)); 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)); for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name));
for (const id of FAUNA_NAMES) await writeAsset(models,`hearth_fauna_${id}.b3d`,faunaB3D(id));
let vertices = 0; let vertices = 0;
const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1']; 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) { function box(x0,y0,z0,x1,y1,z1) {
+42 -15
View File
@@ -5,16 +5,16 @@ import { ATLAS, BODY_PROFILES } from './media.mjs';
export const CHARACTER_OPTIONS = Object.freeze(Object.fromEntries(Object.entries({ export const CHARACTER_OPTIONS = Object.freeze(Object.fromEntries(Object.entries({
bodies: BODY_PROFILES, bodies: BODY_PROFILES,
skin: ['porcelain','sand','honey','olive','umber','ebony'], skin: ['porcelain','sand','honey','olive','umber','ebony'],
faces: ['calm','angular','soft','weathered'], faces: ['calm','angular','soft','weathered','lined'],
eyes: ['hazel','brown','green','blue','grey'], eyes: ['hazel','brown','green','blue','grey'],
hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none'], hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none','curly','topknot','undercut'],
hairColors: ['ash','flax','chestnut','auburn','black','silver'], hairColors: ['ash','flax','chestnut','auburn','black','silver'],
facialHair: ['none','stubble','moustache','short_beard','full_beard'], facialHair: ['none','stubble','moustache','short_beard','full_beard','goatee','braided_beard'],
garments: ['tunic','workshirt','jerkin','coat'], garments: ['tunic','workshirt','jerkin','coat','hooded_cloak','travel_coat','wool_mantle'],
clothColors: ['sage','teal','slate','mauve','ochre','rust','linen','charcoal'], clothColors: ['sage','teal','slate','mauve','ochre','rust','linen','charcoal'],
trousers: ['earth','slate','moss','charcoal'], boots: ['walnut','black','tan'], trousers: ['earth','slate','moss','charcoal'], boots: ['walnut','black','tan'],
accents: ['none','scarf','apron','sash','vest'], accents: ['none','scarf','apron','sash','vest','satchel','tool_belt','pauldrons','hood'],
details: ['none','freckles','scar','freckles_scar'], details: ['none','freckles','scar','freckles_scar','birthmark'],
}).map(([key, values])=>[key,Object.freeze([...values])]))); }).map(([key, values])=>[key,Object.freeze([...values])])));
const palette = entries => Object.freeze(Object.fromEntries(Object.entries(entries) const palette = entries => Object.freeze(Object.fromEntries(Object.entries(entries)
@@ -66,6 +66,7 @@ function faceLayer(id) {
angular: [[1,2],[2,2],[5,2],[6,1],[4,4],[4,5],[2,6],[3,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]], 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]], weathered: [[1,2],[2,2],[5,2],[6,2],[1,4],[6,4],[4,4],[2,5],[5,5],[3,6],[4,6]],
lined: [[1,1],[2,2],[5,2],[6,1],[1,3],[6,3],[2,4],[5,4],[4,4],[3,6],[4,6]],
}; };
for (const [x,y] of marks[id]) dot(out,'face',x,y,y===6 ? [112,70,58] : INK); for (const [x,y] of marks[id]) dot(out,'face',x,y,y===6 ? [112,70,58] : INK);
return out; return out;
@@ -85,12 +86,15 @@ function hairLayer(style, color) {
if (style==='sidepart') return y<1 || (y===1 && x<6) || (y===2 && x<2); 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==='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; if (style==='braids') return y<1 || (x===0 || x===7) && y<6;
if (style==='curly') return y===0 || (y===1 && (x+y)%3!==0) || (y===2 && x%3===0);
if (style==='topknot') return y===0 && x>=2 && x<=5 || y===1 && x>=3 && x<=4 || y===2 && x<2;
if (style==='undercut') return y===0 || y===1 && x>4 || y===2 && x===7;
return y<1 || (x<1 || x>6) && y<7; return y<1 || (x<1 || x>6) && y<7;
}; };
paint(out,'face',(x,y)=>fringe(x,y) ? strand(x,y) : null); 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,'hair',(x,y)=>style==='shaved' && (x+y)%2 ? null : strand(x,y));
paint(out,'side',(x,y)=> { paint(out,'side',(x,y)=> {
const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1}[style]; const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1,curly:3,topknot:2,undercut:3}[style];
if (y>=limit || (y>=3 && x>=3 && x<=5)) return null; // exposed ear if (y>=limit || (y>=3 && x>=3 && x<=5)) return null; // exposed ear
if (style==='shaved' && x%2) return null; if (style==='shaved' && x%2) return null;
return strand(x,y); return strand(x,y);
@@ -101,6 +105,7 @@ function hairLayer(style, color) {
paint(out,'sleeve',(x,y)=>y<2 && (x===0 || x===7) ? 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); if (style==='braids') for (const x of [0,7]) dot(out,'face',x,5,THREAD);
} }
if (style==='topknot') paint(out,'hair',(x,y)=>x>=2&&x<=5&&y<2?strand(x,y):null);
return out; return out;
} }
function beardLayer(style,color) { function beardLayer(style,color) {
@@ -108,10 +113,11 @@ function beardLayer(style,color) {
paint(out,'face',(x,y)=> { paint(out,'face',(x,y)=> {
const moustache = y===5 && x>=2 && x<=5; const moustache = y===5 && x>=2 && x<=5;
const lower = y>=6 && x>=1 && x<=6; const lower = y>=6 && x>=1 && x<=6;
const mask = style==='moustache' ? moustache : style==='stubble' ? const chin = (y===6 && x>=3 && x<=4) || (y===7 && x===3);
const mask = style==='moustache' ? moustache : style==='goatee' ? moustache || chin : style==='braided_beard' ? moustache || lower || y===4&&x>=2&&x<=5 : style==='stubble' ?
(lower && (x+y)%2===0) : style==='short_beard' ? moustache || (lower && x>=2 && x<=5) : (lower && (x+y)%2===0) : style==='short_beard' ? moustache || (lower && x>=2 && x<=5) :
moustache || lower || (y===4 && (x===1 || x===6)); moustache || lower || (y===4 && (x===1 || x===6));
return mask ? tint(HAIR[color],(x+y)%3===0 ? 9 : -4) : null; return mask ? tint(HAIR[color],style==='braided_beard'&&y>=6&&(y%2===0)?15:(x+y)%3===0 ? 9 : -4) : null;
}); });
if (style==='full_beard') paint(out,'side',(x,y)=>y>=5 && x<3 ? HAIR[color] : null); if (style==='full_beard') paint(out,'side',(x,y)=>y>=5 && x<3 ? HAIR[color] : null);
return out; return out;
@@ -128,11 +134,18 @@ function garmentLayer(style,color) {
if (style==='workshirt' && y===3 && x>=5 && x<=6) c=tint(base,-25); 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' && (x===1 || x===6 || y===5)) c=LEATHER;
if (style==='jerkin' && y===2 && (x===3 || x===4)) c=THREAD; 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' || style==='travel_coat' || style==='hooded_cloak') {
if (style==='coat' && x===5 && [2,4,6].includes(y)) c=THREAD; if (x===3 || x===4) c=tint(base,-28);
if (style==='coat' && y===0 && (x===1 || x===6)) c=tint(base,17); if (x===5 && [2,4,6].includes(y)) c=THREAD;
if (y===0 && (x===1 || x===6)) c=tint(base,17);
}
if (style==='hooded_cloak' && y>=3 && (x===0 || x===7)) c=tint(base,-32);
if (style==='travel_coat' && y>=3 && (x===1 || x===6)) c=LEATHER;
if (style==='wool_mantle' && y<3) c=tint(base,(x+y)%2?12:-14);
} }
if (tile==='back' && (style==='coat' && x===4 || style==='jerkin' && y===5)) c=tint(base,-23); if (tile==='back' && ((style==='coat'||style==='travel_coat'||style==='hooded_cloak') && (x===3||x===4) || style==='jerkin' && y===5)) c=tint(base,-23);
if (tile==='back' && style==='hooded_cloak' && y<6) c=tint(base,-35);
if (tile==='back' && style==='wool_mantle' && y<4) c=tint(base,(x+y)%2?8:-10);
if (tile==='sleeve' && style==='jerkin' && y>=3) c=fabric(CLOTH.linen,x,y); if (tile==='sleeve' && style==='jerkin' && y>=3) c=fabric(CLOTH.linen,x,y);
if (tile==='sleeve' && style==='workshirt' && y===5) c=THREAD; if (tile==='sleeve' && style==='workshirt' && y===5) c=THREAD;
return c; return c;
@@ -152,22 +165,35 @@ function bootsLayer(id) {
function accentLayer(style,color) { function accentLayer(style,color) {
const out=image(); if (style==='none') return out; const out=image(); if (style==='none') return out;
const base=CLOTH[color]; const base=CLOTH[color];
if (style==='hood') {
for (const tile of ['face','hair','side']) paint(out,tile,(x,y)=>
tile==='face' ? (y<2||x<2||x>5?fabric(base,x,y):null) :
tile==='hair' ? (y<5?fabric(base,x,y):null) : (x<2||x>5)&&y<5?fabric(base,x,y):null);
return out;
}
for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> { for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> {
let mask=false; let mask=false;
if (style==='scarf') mask=y===0 || tile==='tunic' && x===2 && y<4; 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==='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==='sash') mask=tile!=='sleeve' && (x===y || x===y+1);
if (style==='vest') mask=tile!=='sleeve' && (tile==='back' || x<=2 || x>=5); if (style==='vest') mask=tile!=='sleeve' && (tile==='back' || x<=2 || x>=5);
if (style==='tool_belt') mask=tile==='tunic' && (y===5 || y===6) || tile==='sleeve'&&y===6;
if (style==='pauldrons') mask=tile==='sleeve'&&y<3 || tile==='tunic'&&y<1;
if (style==='satchel') mask=tile==='back'&&x>=4&&y>=2 || tile==='sleeve'&&x>=5&&y>=2;
return mask ? fabric(base,x,y) : null; 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==='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); if (style==='vest') for (const y of [2,4]) dot(out,'tunic',5,y,THREAD);
if (style==='tool_belt') for(const x of [1,2,5,6]) dot(out,'tunic',x,5,LEATHER);
if (style==='satchel') for(const [x,y] of [[4,3],[5,3],[4,4],[5,4],[4,5]]) dot(out,'back',x,y,THREAD);
if (style==='pauldrons') for(const tile of ['sleeve']) for(const x of [1,6]) dot(out,tile,x,1,THREAD);
return out; return out;
} }
function detailLayer(id) { function detailLayer(id) {
const out=image(); 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('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]); if (id.includes('scar')) for (const [x,y] of [[6,2],[6,3],[7,4]]) dot(out,'face',x,y,[158,109,91]);
if (id==='birthmark') for(const [x,y] of [[6,4],[7,4],[6,5]]) dot(out,'face',x,y,[143,82,75]);
return out; return out;
} }
@@ -213,8 +239,9 @@ export function characterLayers(options = {}) {
// Clothing precedes long hair: shoulder locks must not be erased by tunics. // 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}`), 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(`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(`hair_${p.hair}_${p.hairColor}`),name(`beard_${p.beard}_${p.hairColor}`),
name(`beard_${p.beard}_${p.hairColor}`),name(`detail_${p.detail}`)]; name(`accent_${p.accent}_${p.accentColor}`),
name(`detail_${p.detail}`)];
} }
export function characterTexture(options = {}) { return characterLayers(options).join('^'); } export function characterTexture(options = {}) { return characterLayers(options).join('^'); }
export function characterMesh(body = 'balanced') { export function characterMesh(body = 'balanced') {
+318
View File
@@ -0,0 +1,318 @@
// Original articulated woodland sculptures. Pure offline geometry and pixel art;
// no imported assets, filesystem, fonts, random state or engine-side fake motion.
import { PALETTE } from './world-art.mjs';
export const FAUNA_NAMES = Object.freeze('hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm'.split(' '));
export const FAUNA_ANIMATIONS = Object.freeze(Object.fromEntries(
['idle','locomotion','feed','flight_swim','rest'].map((id,i)=>[id,Object.freeze([i*20,i*20+19,24])])));
export const FAUNA_MATERIALS = Object.freeze(['coat','belly','face','ear','foot','tail','wing','eye',
'nose','stripe','feather','beak','fin','spot','horn','membrane']);
// width, shoulder height, body length, head size; model units, +Z forward.
const FORMS = Object.freeze({
hare:[2.5,4.1,5,1.8], deer:[5,10,10,2.7], fox:[3,4.6,7,2],
rabbit:[2.8,2.8,3.8,1.7], red_squirrel:[1.5,2.5,3.2,1.2],
bank_vole:[1.4,1.1,2.2,.9], wood_mouse:[1.2,1.5,2,.85], hedgehog:[2.2,1.5,3.1,1],
badger:[3.8,3,6.6,1.9], roe_deer:[3.5,7.8,7.2,2.1],
stoat:[1.2,1.5,4.5,.95], weasel:[.85,1.1,3.1,.7], otter:[2.5,2.3,6.2,1.6],
robin:[1.4,2.2,2,1.15], blackbird:[1.6,2.5,2.8,1.2], blue_tit:[1,1.9,1.6,.9],
barn_owl:[2.6,3.8,2.5,2], mallard:[2.5,2.8,4,1.4],
common_frog:[1.8,1,2.2,1.4], smooth_newt:[.8,.65,2.5,.65],
brown_trout:[1.1,1.4,4.8,1], bumblebee:[.8,.7,1.3,.55], earthworm:[.4,.4,3.8,.35],
});
const BIRDS = new Set(['robin','blackbird','blue_tit','barn_owl','mallard']);
const AQUATIC = new Set(['otter','mallard','common_frog','smooth_newt','brown_trout']);
const COLORS = {
hare:['fur','cream','barkLight'],deer:['deer','cream','bark'],fox:['fox','cream','barkDark'],
rabbit:['earthLight','cream','fur'],red_squirrel:['copper','cream','rust'],
bank_vole:['earthLight','strawLight','bark'],wood_mouse:['fur','cream','rose'],
hedgehog:['barkDark','fur','wood'],badger:['slate','cream','ink'],
roe_deer:['rust','cream','bark'],stoat:['earthLight','cream','ink'],weasel:['barkLight','cream','earth'],
otter:['bark','cream','earthDark'],robin:['barkLight','rust','cream'],
blackbird:['ink','slate','ochre'],blue_tit:['slateBlue','gold','cream'],
barn_owl:['wood','cream','barkLight'],mallard:['earthLight','cream','pine'],
common_frog:['leaf','ochre','leafDark'],smooth_newt:['earth','ochre','barkDark'],
brown_trout:['willow','cream','rust'],bumblebee:['ochre','ink','cream'],earthworm:['rose','clay','wine'],
};
function valid(id) { if(!FAUNA_NAMES.includes(id)) throw new Error('Unknown fauna species'); }
const sub=(a,b)=>a.map((v,i)=>v-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 unit=a=>a.map(v=>v/Math.hypot(...a));
const faces=[[0,3,2,1],[4,5,6,7],[0,1,5,4],[3,7,6,2],[0,4,7,3],[1,2,6,5]];
class Rig {
bones=[]; vertices=[]; triangles=[];
bone(name,parent,pivot,role='still',phase=0) {
const b={name,parent,pivot,role,phase,weights:[]}; this.bones.push(b); return b;
}
box(b,center,size,material='coat',taper=1) {
const [x,y,z]=center, [w,h,l]=size.map(v=>v/2);
const corners=[[-w,-h,-l],[w,-h,-l],[w,h,-l],[-w,h,-l],[-w,-h,l],[w,-h,l],[w,h,l],[-w,h,l]]
.map(([a,c,d])=>[x+a*(c<0?taper:1),y+c,z+d*(c<0?taper:1)]);
const materials=Array.isArray(material)?material:Array(6).fill(material);
faces.forEach((f,j)=> {
const normal=unit(cross(sub(corners[f[1]],corners[f[0]]),sub(corners[f[2]],corners[f[0]])));
const tile=FAUNA_MATERIALS.indexOf(materials[j]);
if(tile<0) throw new Error('Unknown fauna material');
const u=(tile%4)*16,v=Math.floor(tile/4)*16, first=this.vertices.length;
f.forEach((index,k)=> {
const [a,c]=[[.5,15.5],[15.5,15.5],[15.5,.5],[.5,.5]][k];
this.vertices.push([...corners[index],...normal,(u+a)/64,(v+c)/64]); b.weights.push(first+k);
});
this.triangles.push([first,first+1,first+2],[first,first+2,first+3]);
});
}
}
function sculpture(id) {
valid(id);
const m=new Rig(), [w,h,l,hs]=FORMS[id];
const root=m.bone('Root',null,[0,0,0]);
const body=m.bone('Body',root,[0,h,0],'breathe');
const put=(b,c,s,mat='coat',t=1)=>m.box(b,c,s,mat,t);
if(id==='earthworm') {
let parent=body;
for(let i=0;i<12;i++) {
const z=-l/2+(i+.5)*l/12, b=m.bone(`Segment_${i}`,parent,[0,h/2,z],'segment',i*.65);
put(b,[0,h/2,z],[w*(i===0||i===11?.65:1),h,l/12*1.08],i===7||i===8?'belly':'coat',.85);
parent=b;
}
return m;
}
if(id==='brown_trout') {
put(body,[0,h,0],[w,h*1.6,l*.7],['coat','face','belly','coat','spot','spot'],.55);
const head=m.bone('Head',body,[0,h,l*.25],'head');
put(head,[0,h,l*.35],[w*.8,h*1.1,l*.3],'face',.65);
for(const s of [-1,1]) put(head,[s*w*.42,h*1.2,l*.41],[.09,.18,.18],'eye');
const tail=m.bone('Tail',body,[0,h,-l*.3],'swim_tail');
put(tail,[0,h,-l*.46],[w*.4,h*.7,l*.3],'coat',.8);
const tip=m.bone('Tail_Tip',tail,[0,h,-l*.55],'swim_tail',.8);
put(tip,[0,h,-l*.63],[w*.22,h*2,l*.22],'fin',.65);
put(body,[0,h*1.85,-l*.07],[w*.24,h*.65,l*.3],'fin',.5);
for(const s of [-1,1]) {
const fin=m.bone(`Pectoral_${s}`,body,[s*w*.4,h*.8,l*.16],'fin',s);
put(fin,[s*w*.65,h*.65,l*.06],[w*.6,h*.2,l*.27],'fin',.5);
}
return m;
}
if(id==='bumblebee') {
put(body,[0,h,0],[w,h,l],['coat','face','belly','stripe','stripe','stripe'],.8);
const head=m.bone('Head',body,[0,h,l*.45],'head');
put(head,[0,h,l*.55],[hs,hs,hs],'face');
for(const s of [-1,1]) {
put(head,[s*hs*.4,h+.1,l*.78],[.12,.18,.1],'eye');
put(head,[s*.18,h+.35,l*.64],[.07,.35,.08],'foot');
const wing=m.bone(`Wing_${s}`,body,[s*w*.35,h*1.5,0],'wing',s);
put(wing,[s*w,h*1.5,-.05],[w*1.6,.045,l*.65],'membrane',.65);
for(let j=0;j<3;j++) {
const leg=m.bone(`Leg_${s}_${j}`,body,[s*w*.4,h*.7,(j-1)*.35],'leg',s+j);
put(leg,[s*w*.6,h*.25,(j-1)*.35],[.08,h*.5,.1],'foot');
}
}
return m;
}
const bird=BIRDS.has(id), frog=id==='common_frog', newt=id==='smooth_newt';
const legHeight=bird?h*.6:h*.75;
put(body,[0,h,0],[w,h*(bird?1.05:.65),l],['coat','face','belly','coat','coat','coat'],.78);
const neckY=bird?h*1.43:h*1.16, neckZ=l*(bird?.28:.39);
const neck=m.bone('Neck',body,[0,h,neckZ],'neck');
if(['deer','roe_deer','mallard'].includes(id)) put(neck,[0,h*1.25,neckZ],[hs*.65,h*.65,hs*.8],'coat',.8);
const head=m.bone('Head',neck,[0,neckY,neckZ],'head');
put(head,[0,neckY,neckZ+hs*.3],[hs,hs*(frog?.45:bird?1:.8),hs],bird?['coat','face','belly','coat','face','face']:'face',.85);
const muzzle=bird?hs*.45:['fox','badger','hedgehog'].includes(id)?hs*.65:hs*.3;
put(head,[0,neckY-hs*.12,neckZ+hs*.8],[hs*(bird?.35:.6),hs*.3,muzzle],bird?'beak':'belly',.6);
if(!bird) put(head,[0,neckY-hs*.1,neckZ+hs*.8+muzzle*.51],[hs*.23,hs*.16,.07],'nose');
for(const s of [-1,1]) {
put(head,[s*hs*.5,neckY+hs*.13,neckZ+hs*.48],[hs*.08,hs*.16,hs*.16],'eye');
if(bird) continue;
if(!frog&&!newt) {
const long=id==='hare'?1.5:id==='rabbit'?.95:id==='red_squirrel'?.65:.38;
const ear=m.bone(`Ear_${s}`,head,[s*hs*.35,neckY+hs*.3,neckZ],'ear',s);
put(ear,[s*hs*.38,neckY+hs*(.38+long/2),neckZ],[hs*.28,hs*long,hs*.22],'ear',.65);
}
if(id==='deer'||id==='roe_deer') {
const antler=m.bone(`Antler_${s}`,head,[s*hs*.35,neckY+hs*.3,neckZ]);
for(let j=0;j<(id==='deer'?3:2);j++) {
put(antler,[s*(hs*.4+j*.38),neckY+hs*(.7+j*.28),neckZ-j*.27],[.22,hs*.7,.22],'horn');
put(antler,[s*(hs*.6+j*.4),neckY+hs*(.6+j*.28),neckZ-.3],[hs*.55,.2,.22],'horn');
}
}
}
if(bird) {
for(const s of [-1,1]) {
const wing=m.bone(`Wing_${s}`,body,[s*w*.42,h*1.2,l*.15],'wing',s);
put(wing,[s*w*.65,h,-l*.08],[w*.48,h*.65,l*.85],'wing',.6);
const tip=m.bone(`Wing_Tip_${s}`,wing,[s*w*.82,h*.9,-l*.25],'wing_tip',s);
for(let j=0;j<3;j++) put(tip,[s*(w*.88+j*.13),h*.75,-l*(.25+j*.09)],
[w*.22,h*.45,l*.35],'feather',.6);
}
if(id==='barn_owl') {
// Pale facial discs separated by a narrow brow; not a recoloured songbird.
for(const s of [-1,1]) put(head,[s*hs*.24,neckY,neckZ+hs*.83],[hs*.45,hs*.77,.08],'belly',.75);
for(const s of [-1,1]) put(head,[s*hs*.23,neckY+.08,neckZ+hs*.89],[.17,.23,.05],'eye');
}
}
for(const s of [-1,1]) for(const j of (bird?[1]:[-1,1])) {
const x=s*w*.35,z=j*l*.32, phase=s*(j===1?1:-1);
const upper=m.bone(`Leg_${s}_${j}`,body,[x,legHeight,z],'leg',phase);
const hind=(id==='hare'||id==='rabbit'||frog)&&j===-1;
put(upper,[x,legHeight*.72,z],[w*(hind?.4:.18),legHeight*.56,w*.23],'coat',.8);
const lower=m.bone(`Shin_${s}_${j}`,upper,[x,legHeight*.45,z],'shin',phase);
put(lower,[x,legHeight*.24,z],[w*.14,legHeight*.46,w*.17],'foot');
put(lower,[x,legHeight*.07,z+w*.15],[w*(id==='mallard'?.48:.23),legHeight*.14,w*(hind?.8:.52)],bird?'beak':'foot');
}
const tailLength={fox:l*.8,red_squirrel:l*1.05,wood_mouse:l*1.15,bank_vole:l*.3,
stoat:l*.55,weasel:l*.4,otter:l*.7,smooth_newt:l*.95}[id] || (bird?l*.65:l*.2);
const tail=m.bone('Tail',body,[0,h*.95,-l*.45],AQUATIC.has(id)?'swim_tail':'tail');
const bushy=['fox','red_squirrel'].includes(id), thick=bushy?w*.7:newt?w*.6:w*.3;
const squirrel=id==='red_squirrel', tailY=squirrel?h*1.4:h*.95;
put(tail,[0,tailY,-l*.5-tailLength*.22],[thick,squirrel?tailLength*.65:thick,tailLength*.58],bird?'feather':'tail',.7);
const tip=m.bone('Tail_Tip',tail,[0,tailY,-l*.5-tailLength*.45],AQUATIC.has(id)?'swim_tail':'tail',.8);
put(tip,[0,squirrel?h*2:tailY,-l*.5-tailLength*.68],[thick*.65,squirrel?tailLength*.55:thick*.6,tailLength*.47],id==='stoat'||id==='fox'?'stripe':bird?'feather':'tail',.6);
if(id==='hedgehog') for(let j=0;j<4;j++) for(const s of [-1,0,1])
put(body,[s*w*.3,h*1.4,-l*.3+j*l*.2],[w*.25,h*.5,l*.2],'stripe',.35);
return m;
}
function rotation(x,y,z) {
const [a,b,c]=[x,y,z].map(v=>Math.cos(v/2)),[d,e,f]=[x,y,z].map(v=>Math.sin(v/2));
return [a*b*c+d*e*f,d*b*c-a*e*f,a*e*c+d*b*f,a*b*f-d*e*c];
}
function pose(id,b,frame) {
const clip=Math.floor(frame/20),t=(frame%20)/19, wave=Math.sin(t*Math.PI*2), swell=1-Math.cos(t*Math.PI*2);
let x=0,y=0,z=0,lift=0;
const moving=clip===1, swimming=clip===3&&AQUATIC.has(id), flying=clip===3&&(BIRDS.has(id)||id==='bumblebee');
if(b.role==='breathe') lift=FORMS[id][1]*.008*swell;
if(b.role==='head') { y=.07*wave; if(clip===2) x=.42+.17*swell; if(clip===4) x=.16; }
if(b.role==='neck' && clip===2) x=.3+.13*swell;
if(b.role==='ear' && clip===0) z=b.phase*.08*wave;
if(b.role==='leg' && (moving||swimming)) x=.6*wave*b.phase*(swimming?.55:1);
if(b.role==='shin' && moving) x=.35*Math.max(0,-wave*b.phase);
if((b.role==='wing'||b.role==='wing_tip') && flying) {
z=b.phase*(b.role==='wing'?1.15:.5)*wave; x=.12*wave;
}
if(b.role==='tail') { y=(moving?.23:.07)*wave; if(clip===4) y=.25; }
if(b.role==='swim_tail') y=(moving||swimming?.42:.06)*Math.sin(t*Math.PI*2+b.phase);
if(b.role==='fin') z=.25*wave*b.phase;
if(b.role==='segment') { y=(moving?.23:clip===2?.07:.025)*Math.sin(t*Math.PI*2+b.phase); }
// Ground animals have an alert/paddle reserve clip, never fictitious wings.
if(clip===3&&!swimming&&!flying&&b.role==='head') y=.22*wave;
if(clip===4 && b.role==='leg') x=.12*b.phase;
return {lift,rotation:rotation(x,y,z)};
}
const i32=(...values)=>{ const b=Buffer.alloc(values.length*4);values.forEach((n,i)=>b.writeInt32LE(n,i*4));return b; };
const f32=(...values)=>{ const b=Buffer.alloc(values.length*4);values.forEach((n,i)=>b.writeFloatLE(n,i*4));return b; };
const str=s=>Buffer.from(s+'\0','ascii');
const chunk=(tag,...parts)=>{const b=Buffer.concat(parts);return Buffer.concat([Buffer.from(tag),i32(b.length),b]);};
export function faunaB3D(id) {
const m=sculpture(id);
const mesh=chunk('MESH',i32(0),chunk('VRTS',i32(1,1,2),...m.vertices.map(v=>f32(...v))),
chunk('TRIS',i32(0),...m.triangles.map(t=>i32(...t))));
function node(b) {
const local=b.parent?sub(b.pivot,b.parent.pivot):b.pivot, keys=[];
for(let frame=0;frame<100;frame++) {
const p=pose(id,b,frame);
// Irrlicht reads file keys 1..100 as runtime frames 0..99.
keys.push(i32(frame+1),f32(local[0],local[1]+p.lift,local[2],...p.rotation));
}
return chunk('NODE',str(b.name),f32(...local,1,1,1,1,0,0,0),
...(b.parent?[]:[mesh,chunk('ANIM',i32(0,100),f32(24))]),
...(b.weights.length?[chunk('BONE',...b.weights.map(v=>Buffer.concat([i32(v),f32(1)])))]:[]),
chunk('KEYS',i32(5),...keys),...m.bones.filter(c=>c.parent===b).map(node));
}
return chunk('BB3D',i32(1),chunk('TEXS',str(`hearth_fauna_${id}.png`),i32(1,2),f32(0,0,1,1,0)),
chunk('BRUS',i32(1),str('Original fauna semantic atlas'),f32(1,1,1,1,0),i32(1,0,0)),node(m.bones[0]));
}
export function faunaBounds(id) {
const {vertices}=sculpture(id);
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 faunaTexture(id) {
valid(id);
const data=new Uint8Array(64*64*4), [coat,belly,accent]=COLORS[id];
const bases=[coat,belly,coat,'rose',accent,coat,coat,'ink','barkDark',accent,accent,'ochre',accent,coat,'woodLight','moon'];
for(let tile=0;tile<16;tile++) for(let y=0;y<16;y++) for(let x=0;x<16;x++) {
const mat=FAUNA_MATERIALS[tile]; let color=bases[tile];
// Broad directional clusters, never hash noise. Material-specific landmarks.
if(y===3 && x>2 && x<13 && !['eye','nose','membrane'].includes(mat)) color=belly;
if(mat==='coat' && y>10 && x%5<2) color=accent;
if(mat==='face') {
if(id==='badger') color=x<4||x>11?'ink':'cream';
else if(id==='mallard') color=y>11?'cream':'pine';
else if(id==='blue_tit') color=y<5?'slateBlue':x<3||x>12?'ink':'cream';
else if(id==='barn_owl') color=x<2||x>13?'wood':'cream';
else if(id==='robin') color=y>5?'rust':coat;
else if(y>9) color=belly;
}
if(mat==='ear') color=x<3||x>12?coat:y>4?'rose':belly;
if(mat==='tail'&&id==='red_squirrel' && (x+y)%7<2) color='copper';
if(mat==='stripe'&&id==='bumblebee') color=y%8<4?'ink':'ochre';
if(mat==='stripe'&&id==='fox') color='cream';
if(mat==='stripe'&&id==='stoat') color='ink';
if(mat==='stripe'&&id==='hedgehog') color=x%4<2?'wood':'barkDark';
if(mat==='wing'||mat==='feather'||mat==='fin') {
if(x%4===0&&y>5) color=belly;
if(id==='mallard'&&mat==='wing'&&y>5&&y<11) color=x<3||x>12?'cream':'slateBlue';
if(id==='blue_tit'&&mat==='wing') color=y%6===0?'cream':'slateBlue';
}
if(mat==='spot'&&((x>=3&&x<=5&&y>=4&&y<=6)||(x>=10&&x<=12&&y>=11&&y<=13))) color='rust';
if(mat==='eye'&&x>=3&&x<6&&y>=2&&y<5) color='cream';
if(mat==='membrane') color=x%6===0||y===8?'slateBlue':'moon';
data.set([...PALETTE[color],255],((Math.floor(tile/4)*16+y)*64+(tile%4)*16+x)*4);
}
return {width:64,height:64,data};
}
export const FAUNA_ART_MANIFEST = Object.freeze(FAUNA_NAMES.map(id=>Object.freeze({
id,mesh:`hearth_fauna_${id}.b3d`,texture:`hearth_fauna_${id}.png`,width:64,height:64,
unitsPerNode:10,visualSize:1,bounds:Object.freeze(Object.fromEntries(Object.entries(faunaBounds(id)).map(([k,v])=>[k,Object.freeze(v)]))),
posture:BIRDS.has(id)?'perched; articulated folded wings':id==='brown_trout'?'horizontal swimming':
id==='bumblebee'?'six-legged; wings at thorax':id==='earthworm'?'grounded segmented body':'grounded quadruped',
clips:FAUNA_ANIMATIONS,license:'MIT',author:'The Last Hearth original procedural art',
})));
function rotate(q,v) {
const a=q.slice(1), t=cross(a,v).map(n=>n*2),c=cross(a,t);
return v.map((n,i)=>n+q[0]*t[i]+c[i]);
}
// Actual skinned mesh portrait, CPU triangle rasterisation, no font/engine. Each
// row-major 128px cell fits that animal's posed bounds, not a scale comparison.
export function faunaPortraitSheet(frame=9) {
if(!Number.isInteger(frame)||frame<0||frame>99) throw new Error('Unknown fauna frame');
const width=512,height=768,data=new Uint8Array(width*height*4);
for(let y=0;y<height;y++) for(let x=0;x<width;x++) data.set([...PALETTE[(Math.floor(x/8)+Math.floor(y/8))%2?'outline':'ink'],255],(y*width+x)*4);
FAUNA_NAMES.forEach((id,index)=> {
const m=sculpture(id),atlas=faunaTexture(id), posed=[];
// Apply each ancestor's local rotation around its bind pivot, leaf to root.
for(const b of m.bones) for(const vi of b.weights) {
let v=m.vertices[vi].slice(0,3), n=m.vertices[vi].slice(3,6);
for(let p=b;p;p=p.parent) {
const {rotation:q,lift}=pose(id,p,frame);
v=rotate(q,sub(v,p.pivot)).map((a,i)=>a+p.pivot[i]+(i===1?lift:0)); n=rotate(q,n);
}
posed[vi]={v,n};
}
// Front three-quarter view: +Z muzzle toward the camera. Use a matching
// depth basis, otherwise a perfectly valid depth buffer shows only backs.
const project=([x,y,z])=>[x*.8-z*.6,x*.18-y*.954+z*.24,x*.572+y*.3+z*.763];
const ps=posed.map(p=>project(p.v)), bounds=[0,1].map(i=>[Math.min(...ps.map(p=>p[i])),Math.max(...ps.map(p=>p[i]))]);
const scale=104/Math.max(...bounds.map(([a,b])=>b-a)), ox=index%4*128+64,oy=Math.floor(index/4)*128+64;
const points=ps.map(p=>[(p[0]-(bounds[0][0]+bounds[0][1])/2)*scale+ox,(p[1]-(bounds[1][0]+bounds[1][1])/2)*scale+oy,p[2]]);
const depth=new Float64Array(128*128).fill(-Infinity);
for(const ids of m.triangles) {
const [a,b,c]=ids.map(i=>points[i]), area=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]);
if(Math.abs(area)<1e-8) continue;
const normal=posed[ids[0]].n, light=.68+.32*Math.max(0,normal[0]*-.3+normal[1]*.8+normal[2]*.5);
const x0=Math.max(ox-64,Math.floor(Math.min(a[0],b[0],c[0]))),x1=Math.min(ox+63,Math.ceil(Math.max(a[0],b[0],c[0])));
const y0=Math.max(oy-64,Math.floor(Math.min(a[1],b[1],c[1]))),y1=Math.min(oy+63,Math.ceil(Math.max(a[1],b[1],c[1])));
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) {
const u=((b[0]-x-.5)*(c[1]-y-.5)-(b[1]-y-.5)*(c[0]-x-.5))/area;
const v=((c[0]-x-.5)*(a[1]-y-.5)-(c[1]-y-.5)*(a[0]-x-.5))/area,w=1-u-v;
if(Math.min(u,v,w)<-1e-7) continue;
const z=u*a[2]+v*b[2]+w*c[2],local=(y-(oy-64))*128+x-(ox-64);
if(z<depth[local]) continue; depth[local]=z;
const uv=[6,7].map(k=>ids.reduce((sum,id,j)=>sum+m.vertices[id][k]*[u,v,w][j],0));
const tex=(Math.min(63,Math.floor(uv[1]*64))*64+Math.min(63,Math.floor(uv[0]*64)))*4;
data.set([0,1,2].map(k=>Math.round(atlas.data[tex+k]*light)).concat(255),(y*width+x)*4);
}
}
});
return {width,height,data};
}
+2 -1
View File
@@ -234,7 +234,8 @@ export function treeAtlas(art=worldArt(), season='summer', species=null) {
const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`; const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`;
const underpaint=season==='summer' && species? const underpaint=season==='summer' && species?
{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark', {oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[species]: beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade',
crab_apple:'leafDark',wild_cherry:'leafDark',field_maple:'leafDark'}[species]:
{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]; {summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
for(const [half,name] of [`${prefix}log_side`,leaves].entries()) { for(const [half,name] of [`${prefix}log_side`,leaves].entries()) {
const image=art.get(`hearth_${name}.png`); const image=art.get(`hearth_${name}.png`);
+14 -2
View File
@@ -114,14 +114,26 @@ function pose(name, frame) {
cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz]}; cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz]};
} }
export const BODY_PROFILES = Object.freeze(['compact', 'balanced', 'broad']); export const BODY_PROFILES = Object.freeze(['compact', 'lean', 'balanced', 'broad', 'sturdy']);
export function playerB3D(profile = 'balanced') { export function playerB3D(profile = 'balanced') {
if (!BODY_PROFILES.includes(profile)) throw new Error('Unknown character body profile.'); if (!BODY_PROFILES.includes(profile)) throw new Error('Unknown character body profile.');
const width = {compact: .92, balanced: 1, broad: 1.08}[profile]; const width = {compact: .86, lean: .93, balanced: 1, broad: 1.08, sturdy: 1.16}[profile];
const vertices = [], triangles = [], weights = Object.fromEntries(skeleton.map(b => [b.name, []])); 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], 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]); ['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']); box(vertices, triangles, weights, 'Head', [-1.6,13.5,-1.4,1.6,PLAYER_HEIGHT,1.4], ['hair','face','skin','hair','side','side']);
// Original layered fieldwear: separate low-poly mantle, belt and satchel
// keep the survivor readable at a distance without changing their hitbox.
box(vertices, triangles, weights, 'Body', [-2.15*width,1.2,-1.58,2.15*width,11.8,-1.22],
['back','back','back','back','back','back'], [profile === 'broad' ? .94 : .88, .92]);
box(vertices, triangles, weights, 'Body', [-2.2*width,8.05,-1.25,2.2*width,9.05,1.25],
['wrap','wrap','wrap','wrap','wrap','wrap']);
box(vertices, triangles, weights, 'Body', [1.35*width,3.8,-1.9,3.35*width,7.9,-1.43],
['wood','wrap','wood','wood','wood','wrap'], [.9,.92]);
box(vertices, triangles, weights, 'Head', [-.32,14.2,1.2,.32,15.1,1.5],
['skin','skin','skin','skin','skin','skin'], [.68,.7]);
for (const side of [-1,1]) box(vertices, triangles, weights, 'Head',
[side<0?-1.6:1.34,14.6,-.15,side<0?-1.34:1.6,15.5,.75], 'skin', [.7,.8]);
for (const [side, sign] of [['Right', -1], ['Left', 1]]) { for (const [side, sign] of [['Right', -1], ['Left', 1]]) {
const x = sign * 3.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, `Arm_${side}`, [x-.78*width,8.6,-.84,x+.78*width,13,.84], 'sleeve', [.9,.95]);
+24 -2
View File
@@ -7,6 +7,9 @@ import { generateAssets } from './assets.mjs';
import { absolute, atomicDirectory, copyTree, disjoint, exists, json, relativeName, safePath, verifyManifest, writeManifest } from './files.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 const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WORLDGEN_REPO = path.resolve(SOURCE, '..', 'luanti-mapgenerator-epic');
const WORLDGEN_SOURCE = path.join(WORLDGEN_REPO, 'mods', 'epic_terrain');
const BUNDLED_WORLDGEN = path.basename(SOURCE) === 'last_hearth' && path.dirname(WORLDGEN_REPO) === path.dirname(SOURCE);
export async function readRelease(source = SOURCE) { export async function readRelease(source = SOURCE) {
await safePath(path.join(absolute(source), 'release.json')); await safePath(path.join(absolute(source), 'release.json'));
const release = JSON.parse(await fs.readFile(path.join(source, 'release.json'), 'utf8')); const release = JSON.parse(await fs.readFile(path.join(source, 'release.json'), 'utf8'));
@@ -21,6 +24,11 @@ export async function packageGame(destination, { source = SOURCE } = {}) {
// dist/ within the repository is supported; no payload source may overlap it. // 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); for (const entry of ['mods', 'fixtures', 'tests', 'scripts']) disjoint(path.join(source, entry), destination);
const release = await readRelease(source); const release = await readRelease(source);
if (source === SOURCE && BUNDLED_WORLDGEN) {
disjoint(WORLDGEN_SOURCE, destination);
const config = await safePath(path.join(WORLDGEN_SOURCE, 'mod.conf'));
if (!(await fs.lstat(config)).isFile()) throw new Error('Epic-scale mapgen mod is missing from the workspace.');
}
return atomicDirectory(destination, async stage => { return atomicDirectory(destination, async stage => {
for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) { for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) {
const filename = await safePath(path.join(source, name)); const filename = await safePath(path.join(source, name));
@@ -30,6 +38,9 @@ export async function packageGame(destination, { source = SOURCE } = {}) {
await copyTree(path.join(source, 'mods'), path.join(stage, 'mods'), { await copyTree(path.join(source, 'mods'), path.join(stage, 'mods'), {
exclude: new Set(['hearth/textures', 'hearth/models']), exclude: new Set(['hearth/textures', 'hearth/models']),
}); });
if (source === SOURCE && BUNDLED_WORLDGEN) {
await copyTree(WORLDGEN_SOURCE, path.join(stage, 'mods/epic_terrain'));
}
for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) { 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.'); if (!await exists(path.join(stage, 'mods/hearth', name + '.lua'))) throw new Error('Game payload is incomplete.');
} }
@@ -51,12 +62,23 @@ export async function packageGame(destination, { source = SOURCE } = {}) {
} }
await fs.writeFile(path.join(stage, 'release.json'), json(release), { flag: 'wx' }); await fs.writeFile(path.join(stage, 'release.json'), json(release), { flag: 'wx' });
await writeManifest(stage, 'game'); await writeManifest(stage, 'game');
await verifyPackage(stage); await verifyPackage(stage, {requireBundled: source === SOURCE && BUNDLED_WORLDGEN});
}); });
} }
export async function verifyPackage(directory) { export async function verifyPackage(directory, {requireBundled} = {}) {
const manifest = await verifyManifest(directory, 'game'); const manifest = await verifyManifest(directory, 'game');
await readRelease(directory); await readRelease(directory);
if (requireBundled === undefined) {
const configPath = await safePath(path.join(absolute(directory), 'mods/hearth/mod.conf'));
const config = await fs.readFile(configPath, 'utf8');
requireBundled = /^\s*depends\s*=.*(?:^|[,\s])creatura(?:$|[,\s])/m.test(config);
}
if (requireBundled && BUNDLED_WORLDGEN && !manifest.entries.some(entry => entry.path === 'mods/epic_terrain/init.lua')) {
throw new Error('Packaged Last Hearth is missing its bundled epic_terrain generator.');
}
if (requireBundled && !manifest.entries.some(entry => entry.path === 'mods/creatura/LICENSE')) {
throw new Error('Packaged Last Hearth is missing Creatura license attribution.');
}
return manifest; return manifest;
} }
+2 -1
View File
@@ -178,7 +178,8 @@ export async function writeWorld(world, config, location, extra = [], environmen
'movement_speed_jump = 6.5', 'secure.enable_security = true', 'movement_speed_jump = 6.5', 'secure.enable_security = true',
...graphics, ...graphics,
...terrainSettings(newWorld), ...terrainSettings(newWorld),
...settings.filter(line => newWorld || !isTerrainSetting(line)), '', ...settings.filter(line => newWorld || !isTerrainSetting(line)),
...(newWorld ? [] : ['epic_terrain_enable = false']), '',
].join('\n'), { flag: 'wx', mode: 0o600 }); ].join('\n'), { flag: 'wx', mode: 0o600 });
} }
+11 -9
View File
@@ -1,22 +1,24 @@
// Original v7 preset: creation-time inputs, never a saved-world migration. // Original epic_terrain mod uses v7 only as a cave/ore substrate. Its switch,
// Keep in sync with game defaults; map_meta.txt belongs exclusively to Luanti. // seed and geology are creation-time inputs, never a saved-world migration.
// Keep in sync with the game's bundled mapgen defaults.
const NEW_WORLD = Object.freeze([ const NEW_WORLD = Object.freeze([
'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 1', 'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 32',
'mapgen_limit = 4096', 'mapgen_limit = 4096',
'mg_flags = caves,nodungeons,light,decorations,biomes,ores', 'mg_flags = caves,nodungeons,light,biomes,ores',
'mgv7_spflags = mountains,ridges,nofloatlands,caverns', 'mgv7_spflags = mountains,ridges,nofloatlands,caverns',
'mgv7_mount_zero_level = 0', 'mgv7_mount_zero_level = 0',
'mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0', 'mgv7_np_terrain_base = 12, 45, (900, 384, 900), 82341, 5, 0.6, 2.0',
'mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0', 'mgv7_np_terrain_alt = -8, 24, (900, 384, 900), 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_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_height_select = -4, 14, (1400, 320, 1400), 4213, 6, 0.7, 2.0',
'mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 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_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_mountain = -0.6, 1, (320, 280, 320), 5333, 5, 0.63, 2.0',
'mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 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', '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_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', 'mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0',
'epic_terrain_enable = true',
]); ]);
export function terrainSettings(newWorld) { export function terrainSettings(newWorld) {
@@ -25,5 +27,5 @@ export function terrainSettings(newWorld) {
} }
export function isTerrainSetting(line) { export function isTerrainSetting(line) {
return /^\s*(?:mg\w*|fixed_map_seed|water_level|mapgen_limit)\s*=/.test(line); return /^\s*(?:mg\w*|fixed_map_seed|water_level|mapgen_limit|epic_terrain_enable)\s*=/.test(line);
} }
+6 -6
View File
@@ -8,7 +8,7 @@ import { safePath } from './files.mjs';
const ROOT = fileURLToPath(new URL('../', import.meta.url)); const ROOT = fileURLToPath(new URL('../', import.meta.url));
const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua'); const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua');
const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua'); const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua');
export const LIMITS = Object.freeze({ species: 80, batch: 10, concurrency: 2, export const LIMITS = Object.freeze({ species: 100, batch: 10, concurrency: 2,
responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024, responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024,
characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 }); characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 });
export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)'; export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)';
@@ -30,7 +30,7 @@ function latinName(value) {
return value; return value;
} }
function validateCatalogue(records) { function validateCatalogue(records) {
requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..80.'); requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..100.');
const ids = new Set(), names = new Set(); const ids = new Set(), names = new Set();
for (const r of records) { for (const r of records) {
requireValue(r && typeof r.id === 'string' && /^[a-z][a-z_]{0,39}$/.test(r.id) && requireValue(r && typeof r.id === 'string' && /^[a-z][a-z_]{0,39}$/.test(r.id) &&
@@ -51,7 +51,7 @@ export function parseCatalogue(source) {
requireValue(m, 'Unsupported catalogue literal.'); requireValue(m, 'Unsupported catalogue literal.');
return { id: m[1], latin: m[2] }; return { id: m[1], latin: m[2] };
}); });
requireValue(records.length === LIMITS.species, 'Expected exactly 80 botany catalogue rows.'); requireValue(records.length === LIMITS.species, 'Expected exactly 100 botany catalogue rows.');
return validateCatalogue(records); return validateCatalogue(records);
} }
@@ -312,7 +312,7 @@ export async function main(args = process.argv.slice(2)) {
requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]), requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]),
'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)'); 'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)');
if (args[0] === '--help') { 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.'); console.log('--refresh: query all 100 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; return;
} }
const records = await readCatalogue(); const records = await readCatalogue();
@@ -336,7 +336,7 @@ export async function main(args = process.argv.slice(2)) {
e.history_url === `${ORIGIN}/w/index.php?title=${wikiTitle(e.title)}&action=history` && 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.'); 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.`); console.log(`Verified ${entries.length}/${LIMITS.species} 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; return;
} }
const entries = await fetchEntries(records); const entries = await fetchEntries(records);
@@ -347,7 +347,7 @@ export async function main(args = process.argv.slice(2)) {
await safePath(OUTPUT); await safePath(OUTPUT);
const handle = await fs.open(OUTPUT, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), 0o644); 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(); } 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.`); console.log(`Cached ${entries.length}/${LIMITS.species} 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)) { if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+228 -8
View File
@@ -160,7 +160,8 @@ function planks(d,vertical=false,species='oak') {
pine:['straw','strawDark','strawLight'],willow:['willow','willowShade','willowGlow'], pine:['straw','strawDark','strawLight'],willow:['willow','willowShade','willowGlow'],
alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'], alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'],
rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'], rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'],
spruce:['strawLight','wood','cream']}[species]; spruce:['strawLight','wood','cream'],crab_apple:['wood','earthLight','cream'],
wild_cherry:['clay','bark','peach'],field_maple:['strawLight','barkLight','cream']}[species];
const put=(x,y,c)=>d.dot(vertical?y:x,vertical?x:y,c); const put=(x,y,c)=>d.dot(vertical?y:x,vertical?x:y,c);
for(let y=0;y<32;y++) for(let x=0;x<32;x++) { for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const board=Math.floor(y/8), grain=Math.sin(x*.31+board*2+y*1.6)+.45*Math.sin(x*.73-y); const board=Math.floor(y/8), grain=Math.sin(x*.31+board*2+y*1.6)+.45*Math.sin(x*.73-y);
@@ -174,8 +175,19 @@ function bark(d,species='oak') {
pine:['barkLight','bark','wood'],willow:['humus','barkDark','loamGlow'], pine:['barkLight','bark','wood'],willow:['humus','barkDark','loamGlow'],
alder:['loamShade','humus','earthLight'],beech:['mineralShade','slateShade','mineralGlow'], alder:['loamShade','humus','earthLight'],beech:['mineralShade','slateShade','mineralGlow'],
aspen:['birchShade','willowShade','birchGlow'],rowan:['earthLight','bark','birchShade'], aspen:['birchShade','willowShade','birchGlow'],rowan:['earthLight','bark','birchShade'],
hazel:['barkLight','loamShade','willow'],spruce:['earth','humus','rust']}[species]; hazel:['barkLight','loamShade','willow'],spruce:['earth','humus','rust'],
crab_apple:['earth','barkDark','willow'],wild_cherry:['rust','bark','woodLight'],
field_maple:['barkLight','earthDark','straw']}[species];
d.rect(0,0,32,32,base); d.rect(0,0,32,32,base);
if(['crab_apple','wild_cherry','field_maple'].includes(species)) {
for(const [i,[x,y]] of GRAINS.entries()) {
if(species==='wild_cherry') d.line(x,y,x+4+i%3,y,dark).line(x+1,y-1,x+4,y-1,light);
else if(species==='field_maple') d.poly([[x,y-3],[x+2,y],[x,y+5],[x-2,y+1]],dark)
.line(x,y-2,x,y+2,light);
else d.line(x,y,x+1,y+4,dark).line(x+2,y+1,x+2,y+3,light);
}
return;
}
if(['beech','aspen','rowan','hazel','spruce'].includes(species)) { if(['beech','aspen','rowan','hazel','spruce'].includes(species)) {
// Smooth beech, diamond-scarred aspen, lenticellate rowan/hazel and scaly spruce. // Smooth beech, diamond-scarred aspen, lenticellate rowan/hazel and scaly spruce.
for(const [i,[x,y]] of GRAINS.entries()) { for(const [i,[x,y]] of GRAINS.entries()) {
@@ -213,7 +225,8 @@ function endgrain(d,species='oak') {
pine:['straw','barkLight','strawLight'],willow:['willow','willowShade','willowGlow'], pine:['straw','barkLight','strawLight'],willow:['willow','willowShade','willowGlow'],
alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'], alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'],
rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'], rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'],
spruce:['strawLight','wood','cream']}[species]; spruce:['strawLight','wood','cream'],crab_apple:['wood','earthLight','cream'],
wild_cherry:['clay','bark','peach'],field_maple:['strawLight','barkLight','cream']}[species];
const phase=SPECIES_NAMES.indexOf(species)*.7; const phase=SPECIES_NAMES.indexOf(species)*.7;
for(let y=0;y<32;y++) for(let x=0;x<32;x++) { for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const a=Math.atan2(y-16,x-14), r=Math.hypot((x-14)*1.01,(y-16)*.96); const a=Math.atan2(y-16,x-14), r=Math.hypot((x-14)*1.01,(y-16)*.96);
@@ -392,7 +405,8 @@ function plant(d,name) {
export const SEASONAL_PLANT_NAMES = Object.freeze(['leaves_spring','leaves_autumn','leaves_winter', export const SEASONAL_PLANT_NAMES = Object.freeze(['leaves_spring','leaves_autumn','leaves_winter',
'meadow_grass_dormant','fern_dormant','wildflowers_dormant','spring_greens','spring_greens_dormant', 'meadow_grass_dormant','fern_dormant','wildflowers_dormant','spring_greens','spring_greens_dormant',
'autumn_mushrooms','autumn_mushrooms_dormant']); 'autumn_mushrooms','autumn_mushrooms_dormant']);
export const SPECIES_NAMES = Object.freeze(['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce']); export const SPECIES_NAMES = Object.freeze(['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce',
'crab_apple','wild_cherry','field_maple']);
export const EVERGREEN_NAMES = Object.freeze(['pine','spruce']); export const EVERGREEN_NAMES = Object.freeze(['pine','spruce']);
export const ROCK_NAMES = Object.freeze(['granite','basalt','limestone','slate','sandstone']); export const ROCK_NAMES = Object.freeze(['granite','basalt','limestone','slate','sandstone']);
export const UNDERSTORY_NAMES = Object.freeze(['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen']); export const UNDERSTORY_NAMES = Object.freeze(['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen']);
@@ -444,6 +458,16 @@ function speciesLeaves(d,species,season='summer') {
} else if(species==='hazel') { } else if(species==='hazel') {
d.poly([[x,y+4],[x-5,y],[x-4,y-4],[x-1,y-4],[x,y-2],[x+3,y-4],[x+5,y],[x,y+4]],dark) d.poly([[x,y+4],[x-5,y],[x-4,y-4],[x-1,y-4],[x,y-2],[x+3,y-4],[x+5,y],[x,y+4]],dark)
.line(x,y+2,x-2,y-2,base,2).line(x,y,x+3,y-2,light); .line(x,y+2,x-2,y-2,base,2).line(x,y,x+3,y-2,light);
} else if(species==='field_maple') {
d.poly([[x,y+4],[x-2,y+1],[x-5,y],[x-3,y-2],[x-4,y-5],[x-1,y-3],
[x,y-7],[x+2,y-3],[x+5,y-4],[x+3,y-1],[x+5,y+1],[x+1,y+2]],dark)
.line(x,y+2,x,y-4,base).line(x,y,x+3,y-2,light).line(x,y,x-3,y-2,base);
} else if(species==='crab_apple'||species==='wild_cherry') {
const cherry=species==='wild_cherry';
d.poly([[x,y+4],[x-4,y],[x-3,y-3],[x+(cherry?2:0),y-6],[x+4,y-2],[x+3,y+1]],dark)
.line(x,y+2,x+1,y-3,base,2).line(x,y,x-2,y-2,light);
if(spring&&i%2===0) flower(d,x+3,y-3,cherry?'cream':'bud',5,1.5);
if(autumn&&i===1) d.oval(x+3,y+3,1,1,cherry?'wine':'ochre');
} else { } else {
d.poly([[x-4,y+2],[x-3,y-2],[x,y-5],[x+4,y-2],[x+3,y+2],[x,y+4]],dark) d.poly([[x-4,y+2],[x-3,y-2],[x,y-5],[x+4,y-2],[x+3,y+2],[x,y+4]],dark)
.line(x-2,y+1,x+1,y-3,base,2).line(x-1,y,x+2,y-1,light); .line(x-2,y+1,x+1,y-3,base,2).line(x-1,y,x+2,y-1,light);
@@ -595,8 +619,8 @@ function habitatTile(d,name) {
function worldTile(name) { function worldTile(name) {
const d=new Drawing(); const d=new Drawing();
const speciesMatch=name.match(/^(oak|birch|pine|willow|alder|beech|aspen|rowan|hazel|spruce)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/); const speciesMatch=name.match(/^([a-z]+(?:_[a-z]+)*)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/);
if(speciesMatch) { if(speciesMatch && SPECIES_NAMES.includes(speciesMatch[1])) {
const [,species,part,season]=speciesMatch; const [,species,part,season]=speciesMatch;
if(part==='leaves') speciesLeaves(d,species,season); if(part==='leaves') speciesLeaves(d,species,season);
else if(part==='log_top') endgrain(d,species); else if(part==='log_top') endgrain(d,species);
@@ -989,6 +1013,9 @@ const BOTANY_GROUPS = {
['rowan','Sorbus aucuparia','pinnate leaflets; clustered red pomes'], ['rowan','Sorbus aucuparia','pinnate leaflets; clustered red pomes'],
['hazel','Corylus avellana','heart-shaped serrate leaves; husked nuts'], ['hazel','Corylus avellana','heart-shaped serrate leaves; husked nuts'],
['spruce','Picea abies','short radial needles; pendant long cones'], ['spruce','Picea abies','short radial needles; pendant long cones'],
['crab_apple','Malus sylvestris','oval serrate leaves; pink white blossom; small pomes'],
['wild_cherry','Prunus avium','pointed serrate leaves; paired cherries; horizontal lenticels'],
['field_maple','Acer campestre','five blunt leaf lobes; paired winged samaras'],
], ],
shrubs: [ shrubs: [
['bilberry','Vaccinium myrtillus','angular green twigs; small ovals; solitary blue berries'], ['bilberry','Vaccinium myrtillus','angular green twigs; small ovals; solitary blue berries'],
@@ -1001,6 +1028,10 @@ const BOTANY_GROUPS = {
['holly','Ilex aquifolium','spiny glossy evergreen leaves; red berries'], ['holly','Ilex aquifolium','spiny glossy evergreen leaves; red berries'],
['juniper','Juniperus communis','needle whorls of three; blue berry-like cones'], ['juniper','Juniperus communis','needle whorls of three; blue berry-like cones'],
['guelder_rose','Viburnum opulus','three-lobed opposite leaves; translucent red fruit cluster'], ['guelder_rose','Viburnum opulus','three-lobed opposite leaves; translucent red fruit cluster'],
['honeysuckle','Lonicera periclymenum','twining stems; paired leaves; long curved honey tubes'],
['ivy','Hedera helix','climbing rootlets; lobed leaves; globular autumn umbels'],
['gorse','Ulex europaeus','dense branching green spines; golden pea flowers'],
['broom','Cytisus scoparius','ridged green switches; sparse trifoliate leaves; pea flowers'],
], ],
grasses: [ grasses: [
['sedge','Carex paniculata','triangular culms; dense branched panicle; tussock'], ['sedge','Carex paniculata','triangular culms; dense branched panicle; tussock'],
@@ -1025,6 +1056,18 @@ const BOTANY_GROUPS = {
['wild_garlic','Allium ursinum','broad basal lance leaves; white six-tepal star umbel'], ['wild_garlic','Allium ursinum','broad basal lance leaves; white six-tepal star umbel'],
['cowslip','Primula veris','wrinkled basal rosette; nodding yellow tubular umbel'], ['cowslip','Primula veris','wrinkled basal rosette; nodding yellow tubular umbel'],
['wood_anemone','Anemone nemorosa','whorl of divided leaves; single pale six-tepal flower'], ['wood_anemone','Anemone nemorosa','whorl of divided leaves; single pale six-tepal flower'],
['dandelion','Taraxacum officinale','toothed basal rosette; hollow scape; golden ligulate head'],
['daisy','Bellis perennis','spatulate rosette; short scape; white rays and gold disc'],
['red_clover','Trifolium pratense','upright trifoliate leaves; pale chevrons; red globose head'],
['birdsfoot_trefoil','Lotus corniculatus','five leaflets; yellow pea-flower umbel; radiating pods'],
['ribwort_plantain','Plantago lanceolata','ribbed lance rosette; brown spike with pale anther ring'],
['selfheal','Prunella vulgaris','opposite oval leaves; square stem; violet stacked flower whorls'],
['knapweed','Centaurea nigra','dark involucral bracts; ragged purple floret head'],
['oxeye_daisy','Leucanthemum vulgare','tall branched stem; large white rays; golden disc'],
['field_scabious','Knautia arvensis','divided leaves; long peduncle; lilac pincushion head'],
['teasel','Dipsacus fullonum','prickly ovoid head; curved bracts; opposite clasping leaf cups'],
['cow_parsley','Anthriscus sylvestris','finely divided leaves; branched compound white umbels'],
['garlic_mustard','Alliaria petiolata','heart-shaped toothed leaves; four white petals; upright siliques'],
], ],
ferns_mosses: [ ferns_mosses: [
['bracken','Pteridium aquilinum','three-part triangular divided frond'], ['bracken','Pteridium aquilinum','three-part triangular divided frond'],
@@ -1037,6 +1080,7 @@ const BOTANY_GROUPS = {
['moss','Hypnum cupressiforme','creeping feather-like plaits; hooked tips'], ['moss','Hypnum cupressiforme','creeping feather-like plaits; hooked tips'],
['fork_moss','Dicranum scoparium','cushion of swept sickle-shaped leaves'], ['fork_moss','Dicranum scoparium','cushion of swept sickle-shaped leaves'],
['star_moss','Mnium hornum','upright shoots; broad oval leaves; nodding capsules'], ['star_moss','Mnium hornum','upright shoots; broad oval leaves; nodding capsules'],
['holly_fern','Polystichum setiferum','arching bipinnate fronds; bristle-tipped pinnules; forked pinnae'],
], ],
fungi: [ fungi: [
['fly_agaric','Amanita muscaria','red white-warted cap; ring; bulbous base'], ['fly_agaric','Amanita muscaria','red white-warted cap; ring; bulbous base'],
@@ -1113,6 +1157,166 @@ function compound(d,x,y,length=10,side=1) {
d.line(x+side*j,y-j*.7,x+side*(j+3),y-j*.7+1,'leaf',2); d.line(x+side*j,y-j*.7,x+side*(j+3),y-j*.7+1,'leaf',2);
} }
} }
// New catalogue specimens have authored diagnostic reproductive structures.
// Dormancy draws spent stems/pods, never a recoloured copy of an open flower.
function botanicalAddition(d,id,dormant) {
const stem=dormant?'bark':'leafDark',leafColor=dormant?'straw':'sage';
const stalk=(x,y,bx=16)=>d.line(bx,29,x,y,stem);
const rosette=(shape='lance')=> {
for(const [x,y,s] of [[6,29,-1],[11,28,-1],[19,29,1],[25,28,1]])
specimenLeaf(d,x,y,shape,s,leafColor);
};
const pea=(x,y)=>d.oval(x,y-2,3,2,'gold').poly([[x-3,y],[x,y+3],[x+3,y]],'ochre').dot(x,y,'strawLight');
if(id==='honeysuckle'||id==='ivy') {
d.line(13,29,12,4,'bark',2);
d.line(17,29,20,23,stem).line(20,23,13,16,stem).line(13,16,20,9,stem).line(20,9,17,3,stem);
for(const [x,y,s] of [[9,25,-1],[24,22,1],[8,16,-1],[25,12,1]]) {
if(!dormant) specimenLeaf(d,x,y,id==='ivy'?'lobe':'oval',s);
else d.line(x,y,x-s*3,y-3,'barkLight');
}
if(id==='ivy') {
for(let y=8;y<28;y+=4) d.line(12,y,9,y+2,'barkLight');
if(!dormant) for(const [x,y] of [[23,6],[24,26]]) {
for(let j=0;j<5;j++) {
const a=j*Math.PI*2/5;
d.line(x,y,x+Math.cos(a)*3,y+Math.sin(a)*3,'sage').oval(x+Math.cos(a)*3,y+Math.sin(a)*3,1,1,'ochre');
}
}
} else for(const [x,y,s] of [[20,6,1],[11,13,-1],[22,21,1]]) {
if(dormant) { d.oval(x,y,1,2,'barkLight'); continue; }
// Narrow curved tubes and splayed lips, not round flower icons.
d.line(x,y+3,x+s*3,y,'rose',2).line(x+s*3,y,x+s*5,y-1,'cream')
.line(x+s*5,y-1,x+s*6,y-3,'gold').line(x+s*5,y-1,x+s*7,y,'cream');
}
return;
}
if(id==='gorse'||id==='broom') {
for(let i=0;i<5;i++) {
const x=5+i*5,y=6+(i%3)*3; stalk(x,y,15+i%2);
for(let j=0;j<4;j++) {
const yy=13+j*4,xx=x+(16-x)*(yy-y)/(29-y);
if(id==='gorse') d.line(xx,yy,xx-3,yy-4,leafColor).line(xx,yy,xx+3,yy-3,stem);
else d.line(xx,yy,xx+(i%2?3:-3),yy-5,leafColor);
}
if(!dormant) pea(x,y+3);
else d.line(x,y+1,x+2,y+5,'barkDark',2);
}
if(id==='broom'&&!dormant) for(const x of [7,15,23]) {
d.oval(x,23,2,1,'leaf').oval(x-2,21,1,2,'sage').oval(x+2,21,1,2,'leafLight');
}
return;
}
if(id==='holly_fern') {
for(const [i,[tx,ty]] of [[4,17],[9,9],[18,4],[27,13]].entries()) {
stalk(tx,ty,15);
for(let j=1;j<=6;j++) {
const x=tx+(15-tx)*j/7,y=ty+(29-ty)*j/7,w=Math.min(j,7-j)+1;
for(const s of [-1,1]) {
d.line(x,y,x+s*w,y-2,leafColor,2).line(x+s*w,y-2,x+s*(w+1),y-4,stem);
d.line(x+s*(w-1),y-1,x+s*(w+1),y,'leafLight');
if(!dormant&&i%2===0) d.dot(x+s*2,y-1,'ochre');
}
}
}
return;
}
if(['dandelion','daisy','oxeye_daisy'].includes(id)) {
const tall=id==='oxeye_daisy', small=id==='daisy',x=small?12:tall?19:16,y=small?16:tall?7:10;
stalk(x,y,small?11:16); rosette(id==='dandelion'?'lobe':small?'oval':'lance');
if(tall) { d.line(18,19,25,14,stem); specimenLeaf(d,11,23,'diamond'); }
if(dormant) {
d.oval(x,y,small?2:3,2,'barkLight');
if(id==='dandelion') for(let j=-2;j<=2;j++) d.line(x,y,x+j*2,y-3,'strawLight');
} else if(id==='dandelion') {
flower(d,x,y,'ochre',12,4); flower(d,x,y,'gold',8,2); d.dot(x,y,'strawLight');
} else {
flower(d,x,y,'cream',small?8:12,small?3:5); d.oval(x,y,small?1:2,small?1:2,'gold');
if(tall) flower(d,25,13,'cream',8,3);
}
return;
}
if(id==='red_clover'||id==='birdsfoot_trefoil') {
const trefoil=id==='birdsfoot_trefoil';
for(const [i,[x,y]] of [[8,17],[19,8],[25,19]].entries()) {
stalk(x,y,14);
for(const s of [-1,1]) {
const xx=x+s*3,yy=24-i*3;
d.line(x,yy+3,xx,yy,stem).oval(xx,yy-2,2,3,leafColor).oval(xx+s*3,yy,2,2,'leaf');
if(!dormant) d.line(xx-1,yy-2,xx+1,yy-3,'leafLight');
}
if(dormant) {
if(trefoil) for(let j=-1;j<3;j++) d.line(x,y,x+j*2,y+5,'barkDark');
else d.oval(x,y,2,3,'barkLight');
} else if(trefoil) { pea(x-2,y); pea(x+3,y+2); }
else {
d.oval(x,y,3,4,'wine');
for(let j=0;j<4;j++) d.line(x-2,y-2+j*2,x+2,y-3+j*2,j%2?'rose':'mauve');
}
}
return;
}
if(id==='ribwort_plantain') {
rosette();
for(const [x,y] of [[10,8],[18,5],[25,13]]) {
stalk(x,y,16); d.oval(x,y,1.5,4,'bark');
if(!dormant) d.line(x-2,y+1,x+2,y+1,'cream').dot(x-2,y,'gold');
}
return;
}
if(id==='selfheal') {
for(const [i,[x,y]] of [[8,17],[16,8],[25,15]].entries()) {
stalk(x,y,14+i);
for(let j=0;j<3;j++) {
const yy=25-j*5;
d.oval(x-3,yy,3,1.5,leafColor).oval(x+3,yy,3,1.5,'leaf');
d.rect(x-2,y+j*3,5,2,dormant?'bark':'wine');
if(!dormant) d.line(x-2,y+j*3,x,y+j*3-1,'mauve');
}
}
return;
}
if(id==='cow_parsley'||id==='garlic_mustard') {
stalk(17,6);
if(id==='cow_parsley') {
for(const [x,y] of [[7,12],[17,6],[26,13]]) {
d.line(16,21,x,y+3,stem);
for(let j=-2;j<=2;j++) {
d.line(x,y+3,x+j*2,y,stem);
if(!dormant) flower(d,x+j*2,y,'cream',4,1);
else d.line(x+j*2,y,x+j*2,y+2,'barkLight');
}
}
compound(d,15,25,10,-1); compound(d,17,23,10,1);
} else {
for(const [x,y,s] of [[8,27,-1],[24,24,1],[10,19,-1],[22,15,1]]) specimenLeaf(d,x,y,'heart',s,leafColor);
for(let j=0;j<4;j++) {
const y=6+j*4,x=17+(j%2?3:-2); d.line(17,y+3,x,y,stem);
if(!dormant&&j<2) flower(d,x,y,'cream',4,1.5);
else d.line(x,y,x+(j%2?1:-1),y-4,'straw');
}
}
return;
}
// Ragged knapweed florets, a pincushion scabious, and prickly teasel cones.
const teasel=id==='teasel', scabious=id==='field_scabious';
for(const [i,[x,y]] of (teasel?[[16,9],[26,18]]:scabious?[[8,11],[22,7]]:[[12,9],[25,16]]).entries()) {
stalk(x,y+3,16);
if(teasel) {
d.oval(x,y,3,6,dormant?'bark':'strawDark');
for(let j=0;j<5;j++) d.line(x-3,y-4+j*2,x+3,y-5+j*2,'wood');
for(const s of [-1,1]) d.line(x,y+5,x+s*5,y+1,stem).line(x+s*5,y+1,x+s*4,y-2,stem);
if(!dormant) d.line(x-3,y,x+3,y,'mauve',2);
} else {
d.oval(x,y+2,3,2,'barkDark');
if(!dormant) for(let j=-3;j<=3;j++) d.line(x+j,y+1,x+j+(j%2),y-(scabious?2:4),scabious?'mauve':'wine')
.dot(x+j+(j%2),y-(scabious?3:4),'rose');
else d.line(x-3,y,x+3,y-1,'barkLight');
}
if(teasel) d.poly([[10,24],[16,26],[22,24],[18,28],[14,28]],leafColor);
else if(scabious) compound(d,16,27,8,i%2?-1:1);
else specimenLeaf(d,8+i*14,27,'lance',i%2?-1:1,leafColor);
}
}
function botanicalTree(d,id,dormant) { function botanicalTree(d,id,dormant) {
const k=SPECIES_NAMES.indexOf(id), conifer=EVERGREEN_NAMES.includes(id); const k=SPECIES_NAMES.indexOf(id), conifer=EVERGREEN_NAMES.includes(id);
const stem=id==='birch'?'birch':id==='beech'?'mineralGlow':id==='aspen'?'birchShade':'bark'; const stem=id==='birch'?'birch':id==='beech'?'mineralGlow':id==='aspen'?'birchShade':'bark';
@@ -1140,13 +1344,17 @@ function botanicalTree(d,id,dormant) {
aspen:[[7,14],[9,24],[23,12],[25,24],[17,8]], aspen:[[7,14],[9,24],[23,12],[25,24],[17,8]],
rowan:[[7,18],[10,26],[21,13],[26,23],[14,8]], rowan:[[7,18],[10,26],[21,13],[26,23],[14,8]],
hazel:[[5,17],[10,25],[22,16],[26,24],[12,9]], hazel:[[5,17],[10,25],[22,16],[26,24],[12,9]],
crab_apple:[[6,19],[11,26],[22,17],[26,25],[14,9]],
wild_cherry:[[7,15],[10,24],[22,12],[26,23],[16,8]],
field_maple:[[6,16],[9,25],[23,14],[26,24],[15,9]],
}[id]; }[id];
for(const [i,[x,y]] of positions.entries()) { for(const [i,[x,y]] of positions.entries()) {
d.line(16,24-i*3,x,y,'barkLight'); d.line(16,24-i*3,x,y,'barkLight');
if(dormant) { d.line(x,y,x+(i%2?2:-2),y-3,'bark').dot(x,y-3,'wood'); continue; } if(dormant) { d.line(x,y,x+(i%2?2:-2),y-3,'bark').dot(x,y-3,'wood'); continue; }
if(id==='rowan') compound(d,x-4,y+1,9,i%2?-1:1); if(id==='rowan') compound(d,x-4,y+1,9,i%2?-1:1);
else { else {
const shape={oak:'lobe',birch:'diamond',willow:'lance',alder:'round',beech:'oval',aspen:'round',hazel:'heart'}[id]; const shape={oak:'lobe',birch:'diamond',willow:'lance',alder:'round',beech:'oval',aspen:'round',hazel:'heart',
crab_apple:'oval',wild_cherry:'diamond',field_maple:'lobe'}[id];
specimenLeaf(d,x,y,shape,i%2?-1:1); specimenLeaf(d,x,y,shape,i%2?-1:1);
if(id==='beech') for(let j=1;j<4;j++) d.line(x,y-j,x+2,y-j-2,'woodLight'); if(id==='beech') for(let j=1;j<4;j++) d.line(x,y-j,x+2,y-j-2,'woodLight');
if(id==='alder') d.rect(x-1,y-7,2,2,'leafLight'); if(id==='alder') d.rect(x-1,y-7,2,2,'leafLight');
@@ -1155,7 +1363,12 @@ function botanicalTree(d,id,dormant) {
} }
} }
if(!dormant) { if(!dormant) {
if(id==='rowan') for(const [x,y] of [[22,6],[25,7],[23,9],[26,10]]) d.oval(x,y,1,1,'rust'); if(id==='crab_apple') { flower(d,23,9,'bud',5,2); d.oval(24,27,2,2,'ochre').dot(24,24,'bark'); }
else if(id==='wild_cherry') d.line(23,21,21,26,'bark').line(23,21,26,26,'bark')
.oval(21,27,1.5,1.5,'wine').oval(26,27,1.5,1.5,'rust');
else if(id==='field_maple') d.poly([[21,25],[17,22],[16,24],[21,27],[26,24],[27,22]],'wood')
.line(21,25,21,28,'barkLight');
else if(id==='rowan') for(const [x,y] of [[22,6],[25,7],[23,9],[26,10]]) d.oval(x,y,1,1,'rust');
else if(id==='oak' || id==='hazel') { else if(id==='oak' || id==='hazel') {
d.oval(23,26,2,3,'wood').line(21,24,24,24,id==='oak'?'bark':'sage'); d.oval(23,26,2,3,'wood').line(21,24,24,24,id==='oak'?'bark':'sage');
if(id==='hazel') d.oval(27,25,2,3,'straw').line(25,23,28,23,'leaf'); if(id==='hazel') d.oval(27,25,2,3,'straw').line(25,23,28,23,'leaf');
@@ -1165,6 +1378,9 @@ function botanicalTree(d,id,dormant) {
} }
} }
function botanicalShrub(d,id,dormant) { function botanicalShrub(d,id,dormant) {
if(['honeysuckle','ivy','gorse','broom'].includes(id)) {
botanicalAddition(d,id,dormant); return;
}
const k=BOTANY_GROUPS.shrubs.findIndex(r=>r[0]===id), arch=['blackberry','dog_rose'].includes(id); const k=BOTANY_GROUPS.shrubs.findIndex(r=>r[0]===id), arch=['blackberry','dog_rose'].includes(id);
const tips=arch?[[5,18],[10,id==='dog_rose'?12:9],[22,10],[28,id==='dog_rose'?22:18]]: const tips=arch?[[5,18],[10,id==='dog_rose'?12:9],[22,10],[28,id==='dog_rose'?22:18]]:
[[6,13+k%4],[14,6+k%3],[23,11+k%2],[27,20+k%5]]; [[6,13+k%4],[14,6+k%3],[23,11+k%2],[27,20+k%5]];
@@ -1225,6 +1441,9 @@ function botanicalGrass(d,id,dormant) {
} }
} }
function botanicalHerb(d,id,dormant) { function botanicalHerb(d,id,dormant) {
if(BOTANY_GROUPS.herbs.findIndex(r=>r[0]===id)>=10) {
botanicalAddition(d,id,dormant); return;
}
if(['clover','nettle','heather','bluebell'].includes(id)) { if(['clover','nettle','heather','bluebell'].includes(id)) {
understory(d,id+(dormant?'_dormant':'')); understory(d,id+(dormant?'_dormant':''));
// Catalogue specimen includes diagnostic stolon/flower strings/scale sprays. // Catalogue specimen includes diagnostic stolon/flower strings/scale sprays.
@@ -1266,6 +1485,7 @@ function botanicalHerb(d,id,dormant) {
if(dormant) d.line(x-1,7,x+2,10,'straw'); if(dormant) d.line(x-1,7,x+2,10,'straw');
} }
function botanicalFernMoss(d,id,dormant) { function botanicalFernMoss(d,id,dormant) {
if(id==='holly_fern') { botanicalAddition(d,id,dormant); return; }
const k=BOTANY_GROUPS.ferns_mosses.findIndex(r=>r[0]===id); const k=BOTANY_GROUPS.ferns_mosses.findIndex(r=>r[0]===id);
if(k<5) { if(k<5) {
if(id==='harts_tongue') { if(id==='harts_tongue') {
+13 -5
View File
@@ -9,6 +9,7 @@ import path from 'node:path';
import { inflateSync } from 'node:zlib'; import { inflateSync } from 'node:zlib';
import { generateAssets } from '../scripts/assets.mjs'; import { generateAssets } from '../scripts/assets.mjs';
import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterLayer, characterMesh, compositeCharacter } from '../scripts/character-art.mjs'; import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterLayer, characterMesh, compositeCharacter } from '../scripts/character-art.mjs';
import { FAUNA_ART_MANIFEST } from '../scripts/fauna-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 { 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 { inventory } from '../scripts/files.mjs';
import { packageGame, SOURCE, verifyPackage } from '../scripts/package.mjs'; import { packageGame, SOURCE, verifyPackage } from '../scripts/package.mjs';
@@ -142,7 +143,7 @@ for (const profile of CHARACTER_OPTIONS.bodies) test(`B3D ${profile} has bounded
const model = parseB3D(playerB3D(profile)); const model = parseB3D(playerB3D(profile));
assert.deepEqual(model.animation,[0,100,24]); assert.deepEqual(model.animation,[0,100,24]);
assert.equal(model.chunks.filter(c => c==='MESH').length,1); assert.equal(model.chunks.filter(c => c==='MESH').length,1);
assert.equal(model.vertices.length,240); assert.equal(model.triangles.length,120); assert.equal(model.vertices.length,384); assert.equal(model.triangles.length,192);
assert.equal(Math.max(...model.vertices.map(v => v[1])),PLAYER_HEIGHT); 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(Math.min(...model.vertices.map(v => v[1])),0);
assert.equal(PLAYER_HEIGHT/10,1.75); assert.equal(PLAYER_HEIGHT/10,1.75);
@@ -195,8 +196,10 @@ test('all body profiles share exact rig, rigid assignments, UVs and 100 keys; on
widths.push(Math.max(...torso.map(v=>v[0]))); 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(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(widths.length===CHARACTER_OPTIONS.bodies.length && widths.every((value,i)=>i===0||widths[i-1]<value));
assert.ok(Math.abs(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][0]))-1.6)<1e-6); assert.ok(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][0]))<=1.6);
assert.ok(Math.max(...balanced.bones.get('Head').weights.map(([id])=>balanced.vertices[id][2]))>1.5,
'added nose and ears stay within the existing broad head collision envelope');
for (const bad of ['toString','../broad','BALANCED',null,{},0]) assert.throws(()=>playerB3D(bad)); for (const bad of ['toString','../broad','BALANCED',null,{},0]) assert.throws(()=>playerB3D(bad));
}); });
@@ -396,7 +399,7 @@ test('all mod Lua colors are scanned recursively; scripts and non-Lua are not in
assert.deepEqual(manifest.images,CHARACTER_ART_MANIFEST); assert.deepEqual(manifest.images,CHARACTER_ART_MANIFEST);
assert.deepEqual(manifest.options,CHARACTER_OPTIONS); assert.deepEqual(manifest.options,CHARACTER_OPTIONS);
assert.deepEqual(manifest.meshes,CHARACTER_OPTIONS.bodies.map(body=>({body,filename:characterMesh(body)}))); assert.deepEqual(manifest.meshes,CHARACTER_OPTIONS.bodies.map(body=>({body,filename:characterMesh(body)})));
assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,170); assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,CHARACTER_ART_MANIFEST.length);
for (const {filename} of CHARACTER_ART_MANIFEST) { for (const {filename} of CHARACTER_ART_MANIFEST) {
const decoded=parsePNG(await fs.readFile(path.join(textures,filename))), layer=characterLayer(filename); const decoded=parsePNG(await fs.readFile(path.join(textures,filename))), layer=characterLayer(filename);
assert.equal(decoded.width,64); assert.equal(decoded.height,64); assert.equal(decoded.width,64); assert.equal(decoded.height,64);
@@ -428,7 +431,7 @@ test('original Ogg media is part of the verified package manifest without a runt
await fs.copyFile(path.join(SOURCE,name),path.join(source,name)); 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'); 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 destination = path.join(root,'package'); await packageGame(destination,{source});
const {entries} = await verifyPackage(destination); const {entries} = await verifyPackage(destination,{requireBundled:false});
for (const name of Object.keys(SOUND_PROFILES)) { for (const name of Object.keys(SOUND_PROFILES)) {
const entry = entries.find(e=>e.path===`mods/hearth/sounds/${name}.ogg`); const entry = entries.find(e=>e.path===`mods/hearth/sounds/${name}.ogg`);
assert.ok(entry,name); assert.equal(entry.sha256,SOUND_HASHES[name]); assert.ok(entry,name); assert.equal(entry.sha256,SOUND_HASHES[name]);
@@ -440,6 +443,11 @@ test('original Ogg media is part of the verified package manifest without a runt
assert.ok(entries.some(e=>e.path==='character-art.json')); assert.ok(entries.some(e=>e.path==='character-art.json'));
for (const {filename} of CHARACTER_ART_MANIFEST) for (const {filename} of CHARACTER_ART_MANIFEST)
assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${filename}`),filename); assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${filename}`),filename);
for (const {id,mesh,texture} of FAUNA_ART_MANIFEST) {
assert.ok(entries.some(e=>e.path===`mods/hearth/models/${mesh}`),`${id} model`);
assert.ok(entries.some(e=>e.path===`mods/hearth/textures/${texture}`),`${id} texture`);
}
assert.ok(entries.some(e=>e.path==='fauna-art.json'));
for (const body of CHARACTER_OPTIONS.bodies) for (const body of CHARACTER_OPTIONS.bodies)
assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`)); assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`));
}); });
+7 -7
View File
@@ -132,8 +132,8 @@ test("habitat helper resolves qualified nodes and rejects unknown surfaces", fun
assert(not m.supports_species("unknown", "grass")) assert(not m.supports_species("unknown", "grass"))
assert(not m.supports_species("oak", false)) assert(not m.supports_species("oak", false))
end) end)
test("ten exact catalogue species have real full-block trunks, leaves, and timber", function() test("thirteen 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") local f=loaded(); assert(table.concat(f.m.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce,crab_apple,wild_cherry,field_maple")
for _, species in ipairs(f.m.species_order) do for _, species in ipairs(f.m.species_order) do
local log=f.nodes["hearth:"..species.."_log"] local log=f.nodes["hearth:"..species.."_log"]
local leaf=f.nodes["hearth:"..species.."_leaves"] local leaf=f.nodes["hearth:"..species.."_leaves"]
@@ -169,8 +169,8 @@ test("geological stone remains useful masonry and canonical foundations", functi
assert(f.nodes[brick].after_place_node==f.construction.owner) assert(f.nodes[brick].after_place_node==f.construction.owner)
end end
end) end)
test("all 40 terrain recipes have unique shapeless inputs including multiplicity", function() test("all 49 terrain recipes have unique shapeless inputs including multiplicity", function()
local f=loaded(); assert(#f.crafts==40) local f=loaded(); assert(#f.crafts==49)
local seen={} local seen={}
for _, craft in ipairs(f.crafts) do for _, craft in ipairs(f.crafts) do
assert(craft.type=="shapeless") assert(craft.type=="shapeless")
@@ -356,8 +356,8 @@ test("world texture contract is explicit for every new node", function()
assert(image:match("^hearth_[a-z_]+%.png$"),image); textures[image]=true assert(image:match("^hearth_[a-z_]+%.png$"),image); textures[image]=true
end end
end end end end
-- Five extra woods add log top/side, leaves and planks (20 textures). -- Thirteen woods each have their own end grain, bark, foliage and planks.
assert(count(textures)==62, "unexpected world tile count "..count(textures)) assert(count(textures)==74, "unexpected world tile count "..count(textures))
assert(f.registrations()==51, "unexpected private node count "..f.registrations()) assert(f.registrations()==60, "unexpected private node count "..f.registrations())
end) end)
print("PASS " .. passed .. " biome/geology checks") print("PASS " .. passed .. " biome/geology checks")
+89 -16
View File
@@ -33,9 +33,9 @@ local function reset()
auth=auth+1; assert(q==p and player:is_player() and action=="use"); return allowed auth=auth+1; assert(q==p and player:is_player() and action=="use"); return allowed
end} end}
end end
local inv={room_for_item=function(_,list,item) assert(list=="main" and item=="hearth:berries 2"); return room end, local inv={room_for_item=function(_,list,item) assert(list=="main" and (item=="hearth:berries 2" or item=="hearth:berries 3")); return room end,
add_item=function(_,list,item) add_item=function(_,list,item)
assert(list=="main" and item=="hearth:berries 2"); added=added+2-leftover assert(list=="main" and (item=="hearth:berries 2" or item=="hearth:berries 3")); added=added+tonumber(item:match("(%d+)$"))-leftover
return {get_count=function() return leftover end} return {get_count=function() return leftover end}
end} end}
local player={is_player=function() return true end,get_inventory=function() return inv end} local player={is_player=function() return true end,get_inventory=function() return inv end}
@@ -56,12 +56,12 @@ local function register()
B.register_nodes(function(id,def) assert(not defs["hearth:"..id],id); defs["hearth:"..id]=def end) B.register_nodes(function(id,def) assert(not defs["hearth:"..id],id); defs["hearth:"..id]=def end)
end end
local function bush(id) world[key(p)]={name="hearth:"..(id or "bilberry")} 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() test("exactly one hundred disjoint real taxa in eight uneven groups",function()
assert(#B.groups==8 and #B.catalogue==80 and #B.list()==80) assert(#B.groups==8 and #B.catalogue==100 and #B.list()==100)
assert(table.concat(B.groups,",")=="trees,shrubs,grasses,herbs,ferns_mosses,fungi,aquatic,lichens") assert(table.concat(B.groups,",")=="trees,shrubs,grasses,herbs,ferns_mosses,fungi,aquatic,lichens")
local ids,latin={},{},{} local ids,latin={},{},{}
for _,g in ipairs(B.groups) do for _,g in ipairs(B.groups) do
assert(#B.list(g)==10,g) assert(#B.list(g)==({trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10})[g],g)
for _,r in ipairs(B.list(g)) do 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(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.common~="" and r.latin:match("^[A-Z][a-z]+ [a-z%-]+$"),r.latin)
@@ -69,6 +69,21 @@ test("exactly eighty disjoint real taxa in eight ordered groups of ten",function
end end
end end
end) end)
test("twenty expansion identities match the lifecycle contract with nitrogen and flower seasons",function()
local L=dofile(target:gsub("[^/\\]+$","lifecycle.lua"))
local ids={"dandelion","daisy","red_clover","birdsfoot_trefoil","ribwort_plantain","selfheal","knapweed",
"oxeye_daisy","field_scabious","teasel","cow_parsley","garlic_mustard","honeysuckle","ivy","gorse","broom",
"crab_apple","wild_cherry","field_maple","holly_fern"}
assert(#ids==20)
for _,id in ipairs(ids) do
local r=B.get(id); local p=L.plant(id)
assert(r and p and r.latin==p.latin and r.group==p.group and not r.food,id)
end
for _,id in ipairs({"red_clover","birdsfoot_trefoil","gorse","broom"}) do assert(B.nitrogen(id,0)==1) end
assert(B.pollinator("ivy","Autumn") and not B.pollinator("ivy","Spring"))
assert(B.pollinator("gorse","Winter") and not B.pollinator("ribwort_plantain","Summer"))
assert(B.seeding("garlic_mustard","Summer") and not B.seeding("garlic_mustard","Spring"))
end)
test("legacy IDs map exactly without counting generic bushes or patches",function() 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", 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", alder="Alnus glutinosa",clover="Trifolium repens",nettle="Urtica dioica",heather="Calluna vulgaris",
@@ -94,7 +109,7 @@ test("catalogue snapshots and getters cannot alter internal policy",function()
assert(B.get("oak").latin=="Quercus robur" and not B.has_tag("oak","pollinator")) assert(B.get("oak").latin=="Quercus robur" and not B.has_tag("oak","pollinator"))
assert(B.get("oak").niche.light[1]==4) assert(B.get("oak").niche.light[1]==4)
end) end)
test("all eighty species have a passing season niche vector",function() test("all one hundred 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 for _,r in ipairs(B.list()) do assert(B.suitable(r.id,habitat(r.id),next(r.seasons)),r.id) end
end) end)
test("each niche dimension rejects out-of-window values for all species",function() test("each niche dimension rejects out-of-window values for all species",function()
@@ -213,7 +228,7 @@ test("nitrogen is capped and fungi transfer only an existing positive budget",fu
assert(B.resin("spruce") and B.resin("pine_log") and not B.resin("oak")) assert(B.resin("spruce") and B.resin("pine_log") and not B.resin("oak"))
assert(writes==0) assert(writes==0)
end) end)
test("register exactly sixty-two missing understory nodes, never legacy or trees",function() test("register exactly seventy-nine missing understory nodes, never legacy or trees",function()
register(); local count=0 register(); local count=0
for name,def in pairs(defs) do for name,def in pairs(defs) do
count=count+1; local r=B.get(name) count=count+1; local r=B.get(name)
@@ -222,7 +237,7 @@ test("register exactly sixty-two missing understory nodes, never legacy or trees
assert(def.description==r.common.." ("..r.latin..")") assert(def.description==r.common.." ("..r.latin..")")
assert(not def.walkable and not def.buildable_to and def.groups.hearth_ecosystem==1) assert(not def.walkable and not def.buildable_to and def.groups.hearth_ecosystem==1)
end end
assert(count==62); register(); local again=0; for _ in pairs(defs) do again=again+1 end; assert(again==count) assert(count==79); register(); local again=0; for _ in pairs(defs) do again=again+1 end; assert(again==count)
end) end)
test("existing registered definitions are preserved and construction stays late-bound",function() test("existing registered definitions are preserved and construction stays late-bound",function()
local marker={description="external owner"}; defs["hearth:bilberry"]=marker local marker={description="external owner"}; defs["hearth:bilberry"]=marker
@@ -253,17 +268,19 @@ test("harvest checks permission before metadata or inventory and returns at most
bush(); allowed=false; local ok,reason=B.harvest(p,player) 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) assert(not ok and reason=="denied" and auth==1 and writes==0 and added==0)
allowed=true; ok,reason=B.harvest(p,player) 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) assert(ok and reason==2 and added==2 and tonumber(fields[key(p)].botany_ready_at)==time+365.2425*86400)
end) end)
test("cooldown has no catchup and survives a new module instance",function() 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) 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 policies={} -- a fresh factory may reinstall the same seasonal policy
B=factory(H,A); assert(not B.harvest(p,player)); time=time+86400*30 B=factory(H,A); assert(not B.harvest(p,player)); time=time+86400*30
assert(not B.harvest(p,player) and added==2)
time=time+86400*400
assert(B.harvest(p,player) and added==4); assert(not B.harvest(p,player) and added==4) assert(B.harvest(p,player) and added==4); assert(not B.harvest(p,player) and added==4)
end) end)
test("full inventory does not consume stock or start cooldown",function() test("full inventory does not consume stock or start cooldown",function()
bush(); room=false; local ok,reason=B.harvest(p,player) bush(); room=false; local ok,reason=B.harvest(p,player)
assert(not ok and reason=="inventory" and writes==0 and added==0) assert(not ok and reason=="inventory" and fields[key(p)].botany_ready_at=="" and added==0)
room=true; leftover=2; assert(not B.harvest(p,player) and fields[key(p)].botany_ready_at=="") 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) leftover=1; ok,reason=B.harvest(p,player); assert(ok and reason==1 and added==1)
assert(not B.harvest(p,player)) assert(not B.harvest(p,player))
@@ -296,11 +313,11 @@ test("rightclick returns item stack and reentrant inventory cannot duplicate",fu
local stack={}; assert(defs["hearth:bilberry"].on_rightclick(p,{},player,stack)==stack) local stack={}; assert(defs["hearth:bilberry"].on_rightclick(p,{},player,stack)==stack)
inv.add_item=original; assert(added==2) inv.add_item=original; assert(added==2)
end) end)
test("the actual position selector can return each of all eighty exact node IDs",function() test("the actual position selector can return each of all hundred exact node IDs",function()
for index,r in ipairs(B.list()) do for index,r in ipairs(B.list()) do
local h=habitat(r.id); h.include_trees=true local h=habitat(r.id); h.include_trees=true
local x=0 local x=0
while (x*17+7)%80~=index-1 do x=x+1; assert(x<80) end while (x*17+7)%100~=index-1 do x=x+1; assert(x<100) end
local k,selected=B.choose({x=x,y=1,z=0},h,next(r.seasons)) 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(k==r.node:sub(8) and selected.id==r.id,r.id)
assert(B.suitable(k,h,next(r.seasons))) assert(B.suitable(k,h,next(r.seasons)))
@@ -317,7 +334,7 @@ test("submerged candidates work as separate air-cell stands, never as liquid nod
assert(not def.buildable_to and not def.walkable and def.drawtype=="plantlike") assert(not def.buildable_to and not def.walkable and def.drawtype=="plantlike")
end end
end) end)
test("all sixty-two new understory policies match actual activity and dormant art",function() test("all seventy-nine new understory policies match actual activity and dormant art",function()
local count=0 local count=0
for _,r in ipairs(B.list()) do for _,r in ipairs(B.list()) do
local policy=policies[r.id] local policy=policies[r.id]
@@ -335,7 +352,7 @@ test("all sixty-two new understory policies match actual activity and dormant ar
end end
end end
end end
assert(count==62) assert(count==79)
policies.holly.active.Winter=false; assert(B.active("holly","Winter")) policies.holly.active.Winter=false; assert(B.active("holly","Winter"))
assert(next(policies.juniper.definitions)==nil and next(policies.holly.definitions)==nil) assert(next(policies.juniper.definitions)==nil and next(policies.holly.definitions)==nil)
end) end)
@@ -354,7 +371,7 @@ test("harvest samples one validated epoch before climate and uses the same coold
return {season="Summer"} return {season="Summer"}
end end
assert(B.harvest(p,player) and clock_reads==1 and climate_calls==1) assert(B.harvest(p,player) and clock_reads==1 and climate_calls==1)
assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+86400) assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+365.2425*86400)
end) end)
test("only exact owned seasonal aliases harvest in an active fruit season",function() test("only exact owned seasonal aliases harvest in an active fruit season",function()
bush("bilberry_winter"); season="Winter" bush("bilberry_winter"); season="Winter"
@@ -375,6 +392,62 @@ test("southern berry seasons use the solar model at the validated epoch",functio
bush(); time=1782043200 bush(); time=1782043200
local ok,reason=B.harvest(p,player); assert(not ok and reason=="season" and writes==0) 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) time=1797854400; assert(B.harvest(p,player) and added==2)
assert(tonumber(fields[key(p)].botany_ready_at)==time+86400) assert(tonumber(fields[key(p)].botany_ready_at)==time+365.2425*86400)
end) end)
test("shared annual fruit readiness rejects an animal debit and has no daily refill",function()
bush(); local m=A.get_meta(p)
m:set_string("botany_ready_at",tostring(time+365.2425*86400))
assert(not B.harvest(p,player) and added==0)
time=time+86400; assert(not B.harvest(p,player))
time=time+(365.2425-1)*86400; assert(B.harvest(p,player) and added==2)
end)
test("one unexpired pollination credit adds only one berry and is spent once",function()
bush(); local m=A.get_meta(p)
m:set_string("succ_pollinated_until",tostring(time+86400))
local ok,n=B.harvest(p,player); assert(ok and n==3 and added==3)
assert(m:get_string("succ_pollinated_until")=="0")
assert(not B.harvest(p,player) and added==3)
time=time+365.2425*86400; ok,n=B.harvest(p,player)
assert(ok and n==2 and added==5)
end)
test("expired pollination is not a bonus and invalid credits fail closed",function()
bush(); local m=A.get_meta(p)
for _,v in ipairs({"broken","-1",tostring(time+86401),"inf"}) do
m:set_string("succ_pollinated_until",v)
local before=writes; assert(not B.harvest(p,player) and added==0 and writes==before)
end
m:set_string("succ_pollinated_until",tostring(time))
local ok,n=B.harvest(p,player); assert(ok and n==2 and added==2)
end)
test("empty fruit cannot be manufactured by fresh pollination",function()
bush(); local m=A.get_meta(p)
m:set_string("botany_ready_at",tostring(time+86400))
m:set_string("succ_pollinated_until",tostring(time+86400))
local before=writes; assert(not B.harvest(p,player) and writes==before and added==0)
assert(tonumber(m:get_string("succ_pollinated_until"))==time+86400)
end)
test("inventory rejection restores fruit and credit; partial delivery spends both",function()
bush(); local m=A.get_meta(p); local pollen=tostring(time+86400)
m:set_string("succ_pollinated_until",pollen)
room=false; assert(not B.harvest(p,player))
assert(m:get_string("botany_ready_at")=="" and m:get_string("succ_pollinated_until")==pollen)
room=true; leftover=3; assert(not B.harvest(p,player))
assert(m:get_string("botany_ready_at")=="" and m:get_string("succ_pollinated_until")==pollen)
leftover=2; local ok,n=B.harvest(p,player); assert(ok and n==1 and added==1)
assert(m:get_string("succ_pollinated_until")=="0" and not B.harvest(p,player))
end)
test("both inventory callbacks observe reserved fruit and pollination credit",function()
bush(); local m=A.get_meta(p); m:set_string("succ_pollinated_until",tostring(time+86400))
local old_room,old_add=inv.room_for_item,inv.add_item
local function check()
assert(not B.harvest(p,player))
assert(m:get_string("succ_pollinated_until")=="0")
end
inv.room_for_item=function(...) check(); return old_room(...) end
inv.add_item=function(...) check(); return old_add(...) end
local ok,n=B.harvest(p,player)
inv.room_for_item,inv.add_item=old_room,old_add
assert(ok and n==3 and added==3)
end)
assert(passed==44,"Unexpected focused botany integration count")
print("BOTANY_SPEC_PASS "..passed) print("BOTANY_SPEC_PASS "..passed)
+13 -13
View File
@@ -14,23 +14,23 @@ const pixel = (im,tile,x,y)=> {
}; };
const alpha = im=>im.data.reduce((n,value,i)=>n+(i%4===3 && value===255 ? 1 : 0),0); 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',()=> { test('exact public option IDs and deterministic filenames include every Cartesian combination',()=> {
assert.deepEqual(O,{ assert.deepEqual(O,{
bodies:['compact','balanced','broad'],skin:['porcelain','sand','honey','olive','umber','ebony'], bodies:['compact','lean','balanced','broad','sturdy'],skin:['porcelain','sand','honey','olive','umber','ebony'],
faces:['calm','angular','soft','weathered'],eyes:['hazel','brown','green','blue','grey'], faces:['calm','angular','soft','weathered','lined'],eyes:['hazel','brown','green','blue','grey'],
hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none'], hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none','curly','topknot','undercut'],
hairColors:['ash','flax','chestnut','auburn','black','silver'], hairColors:['ash','flax','chestnut','auburn','black','silver'],
facialHair:['none','stubble','moustache','short_beard','full_beard'], facialHair:['none','stubble','moustache','short_beard','full_beard','goatee','braided_beard'],
garments:['tunic','workshirt','jerkin','coat'],clothColors:['sage','teal','slate','mauve','ochre','rust','linen','charcoal'], garments:['tunic','workshirt','jerkin','coat','hooded_cloak','travel_coat','wool_mantle'],clothColors:['sage','teal','slate','mauve','ochre','rust','linen','charcoal'],
trousers:['earth','slate','moss','charcoal'],boots:['walnut','black','tan'], trousers:['earth','slate','moss','charcoal'],boots:['walnut','black','tan'],
accents:['none','scarf','apron','sash','vest'],details:['none','freckles','scar','freckles_scar'], accents:['none','scarf','apron','sash','vest','satchel','tool_belt','pauldrons','hood'],details:['none','freckles','scar','freckles_scar','birthmark'],
}); });
const specs=[['skin',O.skin],['face',O.faces],['eyes',O.eyes],['hair',O.hairStyles,O.hairColors], 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], ['beard',O.facialHair,O.hairColors],['garment',O.garments,O.clothColors],['trousers',O.trousers],
['boots',O.boots],['accent',O.accents,O.clothColors],['detail',O.details]]; ['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=> const expected=specs.flatMap(([kind,ids,colors])=>ids.flatMap(id=>(colors||[null]).map(color=>
`hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`))); `hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`)));
assert.equal(M.length,170); assert.equal(new Set(expected).size,170); assert.equal(M.length,expected.length); assert.equal(new Set(expected).size,expected.length);
assert.deepEqual(M.map(r=>r.filename),expected); assert.deepEqual(M.map(r=>r.filename),expected);
assert.ok(Object.isFrozen(O) && Object.values(O).every(Object.isFrozen) && M.every(Object.isFrozen)); assert.ok(Object.isFrozen(O) && Object.values(O).every(Object.isFrozen) && M.every(Object.isFrozen));
assert.deepEqual([...characterArt().keys()],expected); assert.deepEqual([...characterArt().keys()],expected);
@@ -59,7 +59,7 @@ test('all layers are native 64 RGBA, crisp alpha, no hidden RGB; only named none
test('overlays paint only their semantic UV tiles, never held metal, wood, wrap or unused atlas space',()=> { 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'], const allowed={face:['face'],eyes:['face'],hair:['face','hair','side','back','sleeve'],
beard:['face','side'],garment:['tunic','back','sleeve'],trousers:['trousers'],boots:['boot'], beard:['face','side'],garment:['tunic','back','sleeve'],trousers:['trousers'],boots:['boot'],
accent:['tunic','back','sleeve'],detail:['face']}; accent:['tunic','back','sleeve','face','hair','side'],detail:['face']};
for (const r of M.filter(r=>r.kind!=='skin')) { 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(','))); 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]) for (let y=0;y<64;y++) for (let x=0;x<64;x++) if (im.data[(y*64+x)*4+3])
@@ -116,10 +116,10 @@ test('layer names and order match Luanti composition; garments cannot erase long
assert.deepEqual(names,[ assert.deepEqual(names,[
'hearth_char_skin_sand.png','hearth_char_face_calm.png','hearth_char_eyes_hazel.png', '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_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_hair_long_chestnut.png','hearth_char_beard_none_chestnut.png',
'hearth_char_beard_none_chestnut.png','hearth_char_detail_none.png']); 'hearth_char_accent_scarf_sage.png','hearth_char_detail_none.png']);
assert.equal(characterTexture({hair:'long',garment:'coat',cloth:'rust'}),names.join('^')); assert.equal(characterTexture({hair:'long',garment:'coat',cloth:'rust'}),names.join('^'));
const im=compositeCharacter({hair:'long',garment:'coat',cloth:'rust'}), hair=characterLayer(names[7]); const im=compositeCharacter({hair:'long',garment:'coat',cloth:'rust'}), hair=characterLayer(names[6]);
assert.deepEqual(pixel(im,'back',3,2),pixel(hair,'back',3,2)); 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.deepEqual(pixel(im,'sleeve',0,1),pixel(hair,'sleeve',0,1));
assert.equal(alpha(im),4096); assert.equal(alpha(im),4096);
@@ -150,7 +150,7 @@ test('clothing has tailored collars, rolled cuffs, leather seams, plackets, boot
assert.notDeepEqual(pixel(garment('jerkin'),'tunic',1,3),pixel(garment('jerkin'),'tunic',2,3),'leather panel seam'); 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'); 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)); 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); assert.equal(new Set(masks.map(bytes=>createHash('sha256').update(bytes).digest('hex'))).size,O.accents.length);
for (const id of O.boots) assert.deepEqual(pixel(characterLayer(`hearth_char_boots_${id}.png`),'boot',0,7),[30,28,26,255]); for (const id of O.boots) assert.deepEqual(pixel(characterLayer(`hearth_char_boots_${id}.png`),'boot',0,7),[30,28,26,255]);
}); });
+22 -5
View File
@@ -56,8 +56,8 @@ local function harness()
end end
test("factory exports fresh defaults and exact independent option lists", function() test("factory exports fresh defaults and exact independent option lists", function()
local h = harness(); local C = h.C 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, local counts = {body=5,skin=6,face=5,eyes=5,hair=10,hairColor=6,beard=7,garment=7,cloth=8,
trousers=4,boots=3,accent=5,accentColor=8,detail=4} trousers=4,boots=3,accent=9,accentColor=8,detail=5}
for k,n in pairs(counts) do assert(#C.options()[k]==n,k) end 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" 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) assert(C.defaults().skin=="sand" and C.options().skin[1]=="porcelain" and C.VERSION==1)
@@ -88,7 +88,7 @@ end)
test("preview exactly follows opaque skin face eyes clothing accent hair beard detail order", function() 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()) local C=harness().C; local mesh,texture=C.preview(C.defaults())
assert(mesh=="hearth_person.b3d") 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(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_hair_sidepart_chestnut.png^hearth_char_beard_none_chestnut.png^hearth_char_accent_scarf_sage.png^hearth_char_detail_none.png")
assert(not C.preview({})) assert(not C.preview({}))
end) end)
test("bounded silent JSON roundtrip refuses malformed oversize nested and invalid data", function() test("bounded silent JSON roundtrip refuses malformed oversize nested and invalid data", function()
@@ -157,6 +157,14 @@ test("draft tabs and rotations never publish player properties or metadata", fun
assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).draft.hair=="long") assert(h.C.state(p).draft.skin=="ebony" and h.C.state(p).draft.hair=="long")
assert(#h.props==before and m.writes==0) assert(#h.props==before and m.writes==0)
end) end)
test("two-digit dropdown indices select the expanded cosmetic catalogue safely", function()
local h=harness(); local p=h.player(); h.C.show(p)
assert(#h.C.options().hair==10)
assert(h.C.handle(p,{tab_2=true}))
assert(h.C.handle(p,{pick_hair="10",pick_hairColor="6",pick_beard="7"}))
assert(h.C.state(p).draft.hair=="undercut" and h.C.state(p).draft.beard=="braided_beard")
assert(not h.C.handle(p,{pick_hair="00"}))
end)
test("cancel and Escape discard draft preserving saved appearance and persistence", function() test("cancel and Escape discard draft preserving saved appearance and persistence", function()
for _,field in ipairs({"cancel","quit"}) do 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] local h=harness(); local p,m=h.player(); h.C.show(p); local old=p.properties.textures[1]
@@ -169,7 +177,7 @@ test("apply writes first then publishes and saved data survives a new session",
local h=harness(); local p,m=h.player(); h.C.show(p) local h=harness(); local p,m=h.player(); h.C.show(p)
local original=p.set_properties 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 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(h.C.handle(p,{pick_skin="6",pick_body="4",apply=true}))
assert(m.writes==1 and not h.C.state(p).draft and p.properties.mesh=="hearth_person_broad.b3d") 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) 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) assert(h.C.state(p).saved.skin=="ebony" and m.writes==1 and not h.C.state(p).warning)
@@ -212,5 +220,14 @@ test("stale sessions reconnects closed drafts and disconnected objects cannot sa
h.C.show(p); h.connected.alice=nil; assert(not h.C.handle(p,{apply=true})) 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})) h.connected.alice=p; h.C.show(p); h.C.handle(p,{cancel=true}); assert(not h.C.handle(p,{apply=true}))
end) end)
assert(total==18,"Unexpected character test count") test("creator tooltip text passes through escaping without changing model semantics",function()
local h=harness(); local p=h.player(); local seen={}
local escape=h.api.formspec_escape
h.api.formspec_escape=function(s) seen[s]=true; return escape(s) end
h.C.show(p)
local text="Drag to turn the preview. Only this model rotates."
assert(seen[text] and h.screens[#h.screens].spec:find("tooltip[traveler;"..escape(text).."]",1,true))
assert(h.screens[#h.screens].spec:find(";0,180;true;true;0,19;12]",1,true))
end)
assert(total==20,"Unexpected character test count")
print("PASS " .. total .. " character creator tests") print("PASS " .. total .. " character creator tests")
+74 -12
View File
@@ -266,7 +266,7 @@ test("biome and diversity integration is unique, complete and ordered before sea
assert(seen.hearth_temperate and seen.hearth_riverbank and seen.hearth_rocky_peaks) assert(seen.hearth_temperate and seen.hearth_riverbank and seen.hearth_rocky_peaks)
local index = {} local index = {}
for i, name in ipairs(registrations) do index[name] = i end for i, name in ipairs(registrations) do index[name] = i end
eq(#hearth.biomes.species_order,10) eq(#hearth.biomes.species_order,13)
eq(table.concat(hearth.biomes.species_order,","),table.concat(hearth.diversity.species_order,",")) eq(table.concat(hearth.biomes.species_order,","),table.concat(hearth.diversity.species_order,","))
for _, species in ipairs(hearth.biomes.species_order) do for _, species in ipairs(hearth.biomes.species_order) do
for _, part in ipairs({"log", "leaves", "planks", "seedling", "young", "mature"}) do for _, part in ipairs({"log", "leaves", "planks", "seedling", "young", "mature"}) do
@@ -286,7 +286,7 @@ test("biome and diversity integration is unique, complete and ordered before sea
end end
assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"]) assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"])
assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"]) assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"])
eq(#hearth.botany.catalogue,80) eq(#hearth.botany.catalogue,100)
local botany_nodes=0 local botany_nodes=0
for _,record in ipairs(hearth.botany.catalogue) do for _,record in ipairs(hearth.botany.catalogue) do
assert(nodes[record.node],record.node) assert(nodes[record.node],record.node)
@@ -297,7 +297,7 @@ test("biome and diversity integration is unique, complete and ordered before sea
eq(def.tiles[1],record.texture); eq(def.inventory_image,record.texture); eq(def.wield_image,record.texture) eq(def.tiles[1],record.texture); eq(def.inventory_image,record.texture); eq(def.wield_image,record.texture)
end end
end end
eq(botany_nodes,62) eq(botany_nodes,79)
for _, name in ipairs(hearth.diversity.plants) do assert(nodes["hearth:" .. name]) end 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("pine", "podzol"), true)
eq(hearth.diversity.supports_species("oak", "podzol"), false) eq(hearth.diversity.supports_species("oak", "podzol"), false)
@@ -372,12 +372,12 @@ test("all species and rocks keep single-item products and pair-only mass-preserv
exact("hearth:planks 4", {"hearth:log"}) exact("hearth:planks 4", {"hearth:log"})
exact("hearth:sticks 4", {"hearth:planks"}) exact("hearth:sticks 4", {"hearth:planks"})
end) end)
test("all 78 registered recipes close uniquely with exact output names and counts", function() test("all 87 registered recipes close uniquely with exact output names and counts", function()
-- Exact-name registrations only. Compare shaped bounding boxes, and compare -- Exact-name registrations only. Compare shaped bounding boxes, and compare
-- ingredient multisets whenever either recipe is shapeless. This catches -- ingredient multisets whenever either recipe is shapeless. This catches
-- single-input collisions even across craft types without pretending to be -- single-input collisions even across craft types without pretending to be
-- the native resolver (the native smoke still calls get_craft_result). -- the native resolver (the native smoke still calls get_craft_result).
eq(#crafts,78) eq(#crafts,87)
local signatures, outputs={},{} local signatures, outputs={},{}
for i, def in ipairs(crafts) do for i, def in ipairs(crafts) do
assert(def.type==nil or def.type=="shapeless", "unsupported craft type") assert(def.type==nil or def.type=="shapeless", "unsupported craft type")
@@ -405,7 +405,7 @@ test("all 78 registered recipes close uniquely with exact output names and count
shapeless=def.type=="shapeless", name=result:get_name(), count=result:get_count()} shapeless=def.type=="shapeless", name=result:get_name(), count=result:get_count()}
end end
local output_count=0; for _ in pairs(outputs) do output_count=output_count+1 end local output_count=0; for _ in pairs(outputs) do output_count=output_count+1 end
eq(output_count,52) eq(output_count,55)
for i, expected in ipairs(signatures) do for i, expected in ipairs(signatures) do
local matches=0 local matches=0
for j, candidate in ipairs(signatures) do for j, candidate in ipairs(signatures) do
@@ -965,14 +965,16 @@ test("botanical silhouettes have crisp alpha and waving without losing ecosystem
end end
eq(nodes["hearth:torch"].use_texture_alpha, "clip") eq(nodes["hearth:torch"].use_texture_alpha, "clip")
end) end)
test("fauna register the original species meshes at node scale", function() test("all 23 fauna register the actual animated catalogue assets at node scale", function()
for _, kind in ipairs({"hare", "fox", "deer"}) do for _, r in ipairs(hearth.fauna.list()) do
local def = assert(entities["hearth:" .. kind], kind) local def = assert(entities[r.entity], r.id)
local props = def.initial_properties local props = def.initial_properties
eq(props.visual, "mesh"); eq(props.mesh, "hearth_" .. kind .. ".obj") eq(props.visual, "mesh"); eq(props.mesh, r.mesh)
eq(#props.textures, 1); eq(props.textures[1], "hearth_" .. kind .. ".png") eq(#props.textures, 1); eq(props.textures[1], r.texture)
eq(props.visual_size.x, 1); eq(props.visual_size.y, 1) eq(props.visual_size.x, 1); eq(props.visual_size.y, 1)
eq(props.collisionbox[2], 0); eq(props.static_save, false) eq(props.collisionbox[2], 0); eq(props.static_save, true)
eq(r.frames,100); eq(r.fps,24)
assert(def.get_staticdata and def.on_step)
end end
end) end)
test("local art registrars do not wrap global APIs or require optional mock registries", function() test("local art registrars do not wrap global APIs or require optional mock registries", function()
@@ -1068,5 +1070,65 @@ test("drying racks require a shared claim and cloaks toggle persistent player pr
eq(player_meta:get_int("hearth:cloak"), 0) eq(player_meta:get_int("hearth:cloak"), 0)
end) end)
test("real factory wiring publishes calendar policy and the exact wildlife fauna catalogue",function()
local L=assert(hearth.lifecycle)
eq(L.SECONDS_PER_DAY,86400); eq(L.DAYS_PER_YEAR,365.2425)
eq(#L.ids("plant"),100); eq(#L.ids("animal"),23)
eq(hearth.fauna,hearth.wildlife.fauna)
eq(#hearth.fauna.list(),23)
local status=hearth.wildlife.status(); eq(status.species_count,23)
local breeding={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}
for _,r in ipairs(hearth.fauna.list()) do
assert(entities[r.entity] and status.counts[r.id]==0,r.id)
assert(r.profile and r.profile.reproduction_enabled==(breeding[r.id]==true),r.id)
assert(not hearth.fauna.can_breed(r.id,1782043200,1782043200,"female"),"Newborn must not breed: "..r.id)
end
-- UTC dates: 2026-06-21 and 2027-06-21, NOT dates built in local time.
local phase=L.phase(L.plant("oak"),1782043200,1813579200)
eq(phase.age_days,365); eq(phase.death_due,false)
local groups={trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10}
for _,r in ipairs(hearth.botany.list()) do
local profile=assert(L.plant(r.id,r.group)); eq(profile.id,r.id)
eq(profile.kind,"plant"); eq(profile.group,r.group)
assert(type(profile.confidence)=="string" and type(profile.evidence)=="table")
for _,field in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local range=profile[field]
assert(range==false or (type(range)=="table" and range.min>=0 and range.max>=range.min
and type(range.confidence)=="string" and range.source_url~=nil),r.id..":"..field)
end
local start=assert(L.phase(profile,1782043200,1782043200)); eq(start.age_days,0)
local after=assert(L.phase(profile,1782043200,1813579200)); eq(after.age_days,365)
eq(after.death_due,false); assert(L.resolve(profile,731104),r.id)
eq(#hearth.botany.list(r.group),groups[r.group])
assert(nodes[r.node],r.node)
if r.group=="trees" then
for _,part in ipairs({"seedling","young","mature","log","leaves","planks"}) do
eq(hearth.botany.kind("hearth:"..r.id.."_"..part),r.id)
end
end
end
end)
test("actual pollination and frugivore transactions share the human annual fruit budget",function()
local old_climate,old_allowed=hearth.climate_at,hearth.claim_allowed
hearth.climate_at=function() return {season="Summer"} end
hearth.claim_allowed=function() return true end
claim=false; now=1782043200
local p=pos(0,1,0); put(pos(),"hearth:grass"); put(p,"hearth:bilberry")
local m=core.get_meta(p); m:set_string("succ_natural","1")
assert(hearth.succession.pollinate(p)) -- requires lifecycle captured at construction
eq(tonumber(m:get_string("succ_pollinated_until")),now+86400)
local ok,n=hearth.botany.harvest(p,player); assert(ok); eq(n,3)
eq(m:get_string("succ_pollinated_until"),"0")
local deadline=now+365.2425*86400
eq(tonumber(m:get_string("botany_ready_at")),deadline)
assert(not hearth.succession.frugivore(p,"bilberry"))
now=deadline; assert(hearth.succession.frugivore(p,"bilberry"))
eq(tonumber(m:get_string("botany_ready_at")),now+365.2425*86400)
assert(not hearth.botany.harvest(p,player))
hearth.climate_at,hearth.claim_allowed=old_climate,old_allowed
end)
os.time = real_time os.time = real_time
assert(passed==40,"Unexpected focused content integration count")
print("1.." .. passed) print("1.." .. passed)
+2 -2
View File
@@ -40,9 +40,9 @@ local function register()
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 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() hearth.seasons.register()
end end
test("exact ten species and eight understory plants have cube trunks and sprite seedlings",function() test("exact thirteen species and eight legacy understory plants have cube trunks and sprite seedlings",function()
register() register()
assert(table.concat(D.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce") assert(table.concat(D.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce,crab_apple,wild_cherry,field_maple")
assert(#D.plants==8) assert(#D.plants==8)
for _,s in ipairs(D.species_order) do for _,stage in ipairs({"seedling","young","mature"}) do 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]) local k=s.."_"..stage; local def=assert(api.registered_nodes["hearth:"..k])
+72 -11
View File
@@ -67,14 +67,18 @@ local function test(name, fn)
end end
local planted_at = 100000 local planted_at = 100000
test("crop climate uses historical samples, freezing pauses growth, and catch-up is capped", function() test("grain needs 120 real growing days and samples the whole interval with bounded work", function()
local cold_days = 4 * 86400 local cold_days = 4 * 86400
assert(ecology.climate_progress(planted_at, planted_at + cold_days) == 0) assert(ecology.climate_progress(planted_at, planted_at + cold_days) == 0)
climate_temperature = 15 climate_temperature = 15
local warm_days = 6 * 86400 assert(ecology.crop_stage(planted_at,planted_at+119*86400)<4)
assert(ecology.crop_stage(planted_at, planted_at + warm_days) == 4) assert(ecology.crop_stage(planted_at,planted_at+120*86400)==4)
assert(ecology.climate_progress(planted_at, planted_at + 90 * 86400) assert(ecology.climate_progress(planted_at, planted_at + 90 * 86400)
== ecology.climate_progress(planted_at, planted_at + 30 * 86400)) > ecology.climate_progress(planted_at, planted_at + 30 * 86400))
local calls=0; local climate=hearth.climate_at
hearth.climate_at=function(epoch) calls=calls+1; return climate(epoch) end
assert(ecology.crop_stage(planted_at,planted_at+10000*86400)==4 and calls==366)
hearth.climate_at=climate
assert(ecology.climate_progress("nan", planted_at + 1) == 0) assert(ecology.climate_progress("nan", planted_at + 1) == 0)
assert(ecology.climate_progress(planted_at, math.huge) == 0) assert(ecology.climate_progress(planted_at, math.huge) == 0)
end) end)
@@ -83,16 +87,17 @@ test("crop stages derive from a durable planting timestamp independent of update
nodes[key({x=0,y=0,z=0})] = {name="hearth:grass"} nodes[key({x=0,y=0,z=0})] = {name="hearth:grass"}
nodes[key(p)] = {name="hearth:crop_1"} nodes[key(p)] = {name="hearth:crop_1"}
meta(p):set_string("planted_at", tostring(planted_at)) meta(p):set_string("planted_at", tostring(planted_at))
local stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400) local stage = ecology.crop_stage(planted_at, planted_at + 30 * 86400)
assert(stage >= 2 and stage <= 4) assert(stage >= 2 and stage <= 4)
local same_late_stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400) local same_late_stage = ecology.crop_stage(planted_at, planted_at + 30 * 86400)
assert(stage == same_late_stage) assert(stage == same_late_stage)
now = planted_at + 30 * 86400 now = planted_at + 120 * 86400
assert(ecology.update_crop(p) == false and nodes[key(p)].name == "hearth:crop_4") assert(ecology.update_crop(p) == false and nodes[key(p)].name == "hearth:crop_4")
end) end)
test("berry regrowth is a single absolute deadline and harvest does not destroy the bush", function() test("berry regrowth is annual, Summer gated and harvest does not destroy provenance", function()
local p = {x=2,y=1,z=0} local p = {x=2,y=1,z=0}
nodes[key(p)] = {name="hearth:berry_bush"} nodes[key(p)] = {name="hearth:berry_bush"}
meta(p):set_string("succ_natural","1")
inventory_room = false inventory_room = false
ecology.harvest_berries(p, player) ecology.harvest_berries(p, player)
assert(nodes[key(p)].name == "hearth:berry_bush" and delivered == 0) assert(nodes[key(p)].name == "hearth:berry_bush" and delivered == 0)
@@ -100,11 +105,13 @@ test("berry regrowth is a single absolute deadline and harvest does not destroy
ecology.harvest_berries(p, player) ecology.harvest_berries(p, player)
assert(nodes[key(p)].name == "hearth:berry_bush_bare" and delivered == 3) assert(nodes[key(p)].name == "hearth:berry_bush_bare" and delivered == 3)
local ready = tonumber(meta(p):get_string("ready_at")) local ready = tonumber(meta(p):get_string("ready_at"))
assert(ready == now + 86400) assert(ready == now + 365.2425*86400 and meta(p):get_string("succ_natural")=="1")
ecology.harvest_berries(p, player) ecology.harvest_berries(p, player)
assert(delivered == 3, "bare bush must not yield repeated berries") 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") assert(ecology.refresh_bush(p) == true and nodes[key(p)].name == "hearth:berry_bush_bare")
now = ready now = ready
season="Winter"; assert(ecology.refresh_bush(p)==true)
season="Summer"
assert(ecology.refresh_bush(p) == false and nodes[key(p)].name == "hearth:berry_bush") assert(ecology.refresh_bush(p) == false and nodes[key(p)].name == "hearth:berry_bush")
end) end)
test("partial inventory harvest cannot duplicate crops or berries", function() test("partial inventory harvest cannot duplicate crops or berries", function()
@@ -130,13 +137,15 @@ test("immature or unauthorized crops cannot be harvested", function()
assert(nodes[key(p)].name == "hearth:crop_4" and delivered == before) assert(nodes[key(p)].name == "hearth:crop_4" and delivered == before)
permitted = true permitted = true
end) end)
test("sapling growth waits, respects claims, and writes a bounded tree once", function() test("generic sapling waits one real year, respects claims, and writes a bounded young tree once", function()
now = 500000 now = 2000000000
local p = {x=8,y=1,z=8} local p = {x=8,y=1,z=8}
nodes[key({x=8,y=0,z=8})] = {name="hearth:grass"} nodes[key({x=8,y=0,z=8})] = {name="hearth:grass"}
nodes[key(p)] = {name="hearth:sapling"} nodes[key(p)] = {name="hearth:sapling"}
meta(p):set_string("planted_at", tostring(now - 3 * 86400)) meta(p):set_string("planted_at", tostring(now - 3 * 86400))
meta(p):set_string("planter", "gardener") meta(p):set_string("planter", "gardener")
assert(ecology.grow_sapling(p)==true and nodes[key(p)].name=="hearth:sapling")
now=now+365.2425*86400
permitted = false permitted = false
assert(ecology.grow_sapling(p) == true and nodes[key(p)].name == "hearth:sapling") assert(ecology.grow_sapling(p) == true and nodes[key(p)].name == "hearth:sapling")
permitted = true permitted = true
@@ -188,5 +197,57 @@ test("forage availability checks the calendar even before appearance has reconci
assert(not reload.harvest_seasonal(mushrooms,player) and delivered==1) assert(not reload.harvest_seasonal(mushrooms,player) and delivered==1)
end) end)
test("berry and seasonal reservations precede reentrant inventory callbacks",function()
local berry,patch={x=30,y=1,z=0},{x=31,y=1,z=0}
nodes[key(berry)]={name="hearth:berry_bush"}
nodes[key(patch)]={name="hearth:spring_greens_patch_spring"}
local original=inventory.add_item
local inside=false
inventory.add_item=function(self,list,item)
assert(not inside,"a nested harvest reached inventory")
inside=true
assert(not ecology.harvest_berries(berry,player))
assert(not ecology.harvest_seasonal(patch,player))
inside=false
return original(self,list,item)
end
season="Spring"
-- Lock the other resource so both recursive calls are unambiguously denied.
meta(patch):set_string("succ_harvest_ready_at",tostring(now+86400))
assert(ecology.harvest_berries(berry,player))
meta(patch):set_string("succ_harvest_ready_at","")
assert(ecology.harvest_seasonal(patch,player))
inventory.add_item=original
end)
test("failed delivery restores readiness and malformed fruit or patch clocks never refill",function()
local p={x=32,y=1,z=0}
local original=inventory.add_item
inventory.add_item=function(_,_,item) return make_stack(item:get_name().." "..item:get_count()) end
nodes[key(p)]={name="hearth:berry_bush"}
assert(not ecology.harvest_berries(p,player) and meta(p):get_string("ready_at")=="")
inventory.add_item=original
for _,bad in ipairs({"broken","-1","inf","nan"}) do
nodes[key(p)]={name="hearth:berry_bush"}; meta(p):set_string("ready_at",bad)
local before=delivered
assert(not ecology.harvest_berries(p,player) and delivered==before)
nodes[key(p)]={name="hearth:berry_bush_bare"}
assert(not ecology.refresh_bush(p) and nodes[key(p)].name=="hearth:berry_bush_bare")
nodes[key(p)]={name="hearth:spring_greens_patch_spring"}
meta(p):set_string("succ_harvest_ready_at",bad); season="Spring"
assert(not ecology.harvest_seasonal(p,player) and delivered==before)
end
end)
test("annual berry lock survives adapter restart, node variants and a one-day return",function()
local p={x=33,y=1,z=0}
nodes[key(p)]={name="hearth:berry_bush"}
assert(ecology.harvest_berries(p,player))
local due=meta(p):get_string("ready_at"); now=now+86400
local reload=dofile(target)(hearth,api,construction)
assert(reload.refresh_bush(p) and meta(p):get_string("ready_at")==due)
local before=delivered; reload.harvest_berries(p,player); assert(delivered==before)
-- Even a mistakenly restored full-looking node cannot reset its receipt.
nodes[key(p)]={name="hearth:berry_bush"}
assert(not reload.harvest_berries(p,player) and delivered==before)
end)
os.time = real_time os.time = real_time
print("PASS " .. passed .. " bounded ecology tests") print("PASS " .. passed .. " bounded ecology tests")
+11 -9
View File
@@ -133,7 +133,7 @@ local function registry_check()
and ItemStack(drops[1]):get_count() == 1, "Fallen logs must drop exactly one timber with an axe") and ItemStack(drops[1]):get_count() == 1, "Fallen logs must drop exactly one timber with an axe")
assert(deadwood.can_dig == hearth.construction.can_dig, "Fallen logs bypass construction permissions") assert(deadwood.can_dig == hearth.construction.can_dig, "Fallen logs bypass construction permissions")
local timber = {"log", "stone"} local timber = {"log", "stone"}
assert(#hearth.diversity.species_order == 10, "Expected ten timber species") assert(#hearth.diversity.species_order == 13, "Expected thirteen timber species")
for _, species in ipairs(hearth.diversity.species_order) do for _, species in ipairs(hearth.diversity.species_order) do
local name = species .. "_log" local name = species .. "_log"
timber[#timber + 1] = name timber[#timber + 1] = name
@@ -145,7 +145,7 @@ local function registry_check()
"Bare hands unexpectedly harvest " .. name) "Bare hands unexpectedly harvest " .. name)
end end
local catalogue = hearth.botany.list() local catalogue = hearth.botany.list()
assert(#catalogue == 80, "Expected all 80 botanical species") assert(#catalogue == 100, "Expected all 100 botanical species")
for _, record in ipairs(catalogue) do for _, record in ipairs(catalogue) do
assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node) assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node)
end end
@@ -168,7 +168,7 @@ local function registry_check()
end end
end end
assert(deadwood.drawtype == "normal" and deadwood.paramtype2 == "facedir", "Fallen log must be a full facedir cube") assert(deadwood.drawtype == "normal" and deadwood.paramtype2 == "facedir", "Fallen log must be a full facedir cube")
core.log("action", "Last Hearth native botany/wood: species=80, timber_species=10, nonmesh_tree_nodes=" core.log("action", "Last Hearth native botany/wood: species=100, timber_species=13, nonmesh_tree_nodes="
.. trees .. ", fullcube_timber_nodes=" .. cubes .. "; fallen hand=false, axe=true, drop=hearth:log 1") .. trees .. ", fullcube_timber_nodes=" .. cubes .. "; fallen hand=false, axe=true, drop=hearth:log 1")
for _, name in ipairs({"knife", "axe", "iron_axe", "pick", "spear", "firestarter", "vessel", "fire", for _, name in ipairs({"knife", "axe", "iron_axe", "pick", "spear", "firestarter", "vessel", "fire",
"wall", "roof", "foundation", "frame", "stairs", "door", "window", "vent", "storage", "pantry", "wall", "roof", "foundation", "frame", "stairs", "door", "window", "vent", "storage", "pantry",
@@ -325,13 +325,15 @@ local function worldgen_registry_check()
for setting, expected in pairs({mg_name="v7", mapgen_limit="4096", water_level="1", mgv7_mount_zero_level="0"}) do for setting, expected in pairs({mg_name="v7", mapgen_limit="4096", water_level="1", mgv7_mount_zero_level="0"}) do
assert(core.get_mapgen_setting(setting) == expected, "Saved mapgen setting mismatch: " .. setting) assert(core.get_mapgen_setting(setting) == expected, "Saved mapgen setting mismatch: " .. setting)
end end
for setting, expected in pairs({mgv7_np_terrain_base={12,45,384}, mgv7_np_terrain_alt={12,28,384}, for setting, expected in pairs({mgv7_np_terrain_base={12,45,900,384,900}, mgv7_np_terrain_alt={-8,24,900,384,900},
mgv7_np_mount_height={250,100,700}, mgv7_np_mountain={-0.6,1,180}}) do mgv7_np_height_select={-4,14,1400,320,1400}, mgv7_np_mount_height={250,100,700,700,700},
mgv7_np_mountain={-0.6,1,320,280,320}}) do
local actual = assert(core.get_mapgen_setting_noiseparams(setting), "Missing saved mapgen noise: " .. setting) local actual = assert(core.get_mapgen_setting_noiseparams(setting), "Missing saved mapgen noise: " .. setting)
assert(math.abs(actual.offset - expected[1]) < 0.0001 and math.abs(actual.scale - expected[2]) < 0.0001 assert(math.abs(actual.offset - expected[1]) < 0.0001 and math.abs(actual.scale - expected[2]) < 0.0001
and actual.spread.x == expected[3], "Saved terrain scale mismatch: " .. setting) and actual.spread.x == expected[3] and actual.spread.y == expected[4]
and actual.spread.z == expected[5], "Saved terrain scale mismatch: " .. setting)
end end
core.log("action", "Last Hearth worldgen registry: biomes=10, ores=9; saved v7 terrain base=12/45, alt=12/28, mount=250/100, mapgen_limit=4096") core.log("action", "Last Hearth worldgen registry: biomes=10, ores=9; saved Epic-inspired v7 terrain base=12/45/900x384x900, alt=-8/24, height_select=-4/14, mount=250/100, mapgen_limit=4096")
end end
local function unit_check() local function unit_check()
@@ -346,7 +348,7 @@ local function unit_check()
for _, module in ipairs({"model", "content", "schema", "persistence", "clock", "players", "runtime", for _, module in ipairs({"model", "content", "schema", "persistence", "clock", "players", "runtime",
"access", "environment", "init", "ecology", "seasons", "succession", "wildlife", "access", "environment", "init", "ecology", "seasons", "succession", "wildlife",
"movement", "combat", "construction", "course", "encounters", "presentation", "biomes", "diversity", "movement", "combat", "construction", "course", "encounters", "presentation", "biomes", "diversity",
"m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character"}) do "m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character", "lifecycle", "fauna"}) do
assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module) assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module)
end end
local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"} local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"}
@@ -391,7 +393,7 @@ local function unit_check()
count = count + 1 count = count + 1
end end
end end
assert(count == 29, "Expected all 29 regression suites; got " .. count) assert(count == 31, "Expected all 31 regression suites; got " .. count)
core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed") core.log("action", "Last Hearth: " .. count .. " isolated unit suites executed")
end end
+246
View File
@@ -0,0 +1,246 @@
// Independent little-endian B3D reader + forward kinematics. No encoder helpers,
// engine mocks or assumptions that a magic header implies a working animation.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { inflateSync } from 'node:zlib';
import { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, FAUNA_MATERIALS,
faunaB3D, faunaTexture, faunaBounds, faunaPortraitSheet } from '../scripts/fauna-art.mjs';
import { png } from '../scripts/assets.mjs';
import { worldArt, animalOBJ } from '../scripts/world-art.mjs';
const names='hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm'.split(' ');
const hash=b=>createHash('sha256').update(b).digest('hex');
const sub=(a,b)=>a.map((v,i)=>v-b[i]);
const dot=(a,b)=>a.reduce((s,n,i)=>s+n*b[i],0);
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 near=(a,b,epsilon=1e-5)=>assert.ok(Math.abs(a-b)<epsilon,`${a} != ${b}`);
function parse(bytes) {
const nodes=[],vertices=[],triangles=[]; let anim,texture,meshCount=0,brushCount=0;
const integer=p=>bytes.readInt32LE(p);
const floats=(p,n)=>Array.from({length:n},(_,i)=>bytes.readFloatLE(p+i*4));
function string(p,end) {
const stop=bytes.indexOf(0,p); assert.ok(stop>=p&&stop<end);
return [bytes.toString('ascii',p,stop),stop+1];
}
function chunks(start,end,parent=null) {
let p=start;
while(p<end) {
assert.ok(p+8<=end);
const tag=bytes.toString('ascii',p,p+4),size=integer(p+4),a=p+8,b=a+size;
assert.ok(size>=0&&b<=end); p=b;
if(tag==='BB3D') { assert.equal(integer(a),1); chunks(a+4,b); }
else if(tag==='TEXS') {
const [name,next]=string(a,b); texture=name;
assert.deepEqual([integer(next),integer(next+4)],[1,2]);
assert.deepEqual(floats(next+8,5),[0,0,1,1,0]); assert.equal(next+28,b);
} else if(tag==='BRUS') {
brushCount++; assert.equal(integer(a),1); const [,next]=string(a+4,b);
assert.deepEqual(floats(next,5),[1,1,1,1,0]);
assert.deepEqual([integer(next+20),integer(next+24),integer(next+28)],[1,0,0]);
assert.equal(next+32,b);
} else if(tag==='NODE') {
const [name,next]=string(a,b), transform=floats(next,10);
const node={name,parent,pos:transform.slice(0,3),weights:[],keys:[]}; nodes.push(node);
assert.deepEqual(transform.slice(3),[1,1,1,1,0,0,0]); chunks(next+40,b,node);
} else if(tag==='MESH') { meshCount++; assert.equal(integer(a),0); chunks(a+4,b,parent); }
else if(tag==='VRTS') {
assert.deepEqual([integer(a),integer(a+4),integer(a+8)],[1,1,2]);
assert.equal((size-12)%32,0);
for(let q=a+12;q<b;q+=32) vertices.push(floats(q,8));
} else if(tag==='TRIS') {
assert.equal(integer(a),0); assert.equal((size-4)%12,0);
for(let q=a+4;q<b;q+=12) triangles.push([integer(q),integer(q+4),integer(q+8)]);
} else if(tag==='BONE') {
assert.ok(parent); assert.equal(size%8,0);
for(let q=a;q<b;q+=8) parent.weights.push([integer(q),bytes.readFloatLE(q+4)]);
} else if(tag==='KEYS') {
assert.ok(parent); assert.equal(integer(a),5); assert.equal((size-4)%32,0);
for(let q=a+4;q<b;q+=32) parent.keys.push({frame:integer(q),pos:floats(q+4,3),rotation:floats(q+16,4)});
} else if(tag==='ANIM') {
assert.ok(!anim); assert.equal(size,12); anim=[integer(a),integer(a+4),bytes.readFloatLE(a+8)];
} else assert.fail(`Unexpected B3D chunk ${tag}`);
}
assert.equal(p,end);
}
chunks(0,bytes.length);
assert.equal(bytes.toString('ascii',0,4),'BB3D'); assert.equal(integer(4)+8,bytes.length);
assert.equal(meshCount,1); assert.equal(brushCount,1);
return {nodes,vertices,triangles,anim,texture};
}
function multiply([w,x,y,z],[a,b,c,d]) {
return [w*a-x*b-y*c-z*d,w*b+x*a+y*d-z*c,w*c-x*d+y*a+z*b,w*d+x*c-y*b+z*a];
}
function rotate(q,v) { return multiply(multiply(q,[0,...v]),[q[0],-q[1],-q[2],-q[3]]).slice(1); }
function skin(mesh,frame) {
const states=new Map(),output=[];
for(const node of mesh.nodes) {
const p=states.get(node.parent), key=node.keys[frame];
const bind=node.pos.map((v,i)=>v+(p?.bind[i]||0));
const q=p?multiply(p.q,key.rotation):key.rotation;
const pos=p?rotate(p.q,key.pos).map((v,i)=>v+p.pos[i]):key.pos;
states.set(node,{bind,q,pos});
for(const [id,weight] of node.weights) {
const point=rotate(q,sub(mesh.vertices[id].slice(0,3),bind)).map((v,i)=>v+pos[i]);
output[id]=point.map(v=>v*weight);
}
}
return output;
}
test('exact immutable 23-species/5-clip handoff, provenance, fresh deterministic outputs and fail-closed inputs',()=> {
assert.deepEqual(FAUNA_NAMES,names);
assert.deepEqual(FAUNA_ANIMATIONS,{idle:[0,19,24],locomotion:[20,39,24],feed:[40,59,24],flight_swim:[60,79,24],rest:[80,99,24]});
assert.equal(FAUNA_ART_MANIFEST.length,23); assert.equal(FAUNA_MATERIALS.length,16);
for(const entry of FAUNA_ART_MANIFEST) {
assert.ok(Object.isFrozen(entry)&&Object.isFrozen(entry.bounds.min));
assert.equal(entry.mesh,`hearth_fauna_${entry.id}.b3d`); assert.equal(entry.texture,`hearth_fauna_${entry.id}.png`);
assert.deepEqual(entry.bounds,faunaBounds(entry.id)); assert.equal(entry.unitsPerNode,10); assert.equal(entry.visualSize,1);
assert.equal(entry.license,'MIT'); assert.ok(entry.posture.length>15); assert.deepEqual(entry.clips,FAUNA_ANIMATIONS);
assert.deepEqual(faunaB3D(entry.id),faunaB3D(entry.id)); assert.deepEqual(faunaTexture(entry.id),faunaTexture(entry.id));
const altered=faunaTexture(entry.id); altered.data.fill(0); assert.ok(faunaTexture(entry.id).data.some(v=>v));
}
for(const bad of ['../hare','constructor','toString','Hare',null,{},'wolf']) {
for(const fn of [faunaB3D,faunaTexture,faunaBounds]) assert.throws(()=>fn(bad),/Unknown fauna species/);
}
for(const bad of [-1,100,.5,NaN,null,'0']) assert.throws(()=>faunaPortraitSheet(bad),/Unknown fauna frame/);
const art=worldArt(); assert.equal(art.size,421);
for(const id of names) assert.ok(!art.has(`hearth_fauna_${id}.png`),'64px fauna atlas must not enter the 512-tile world sheet');
for(const id of ['hare','fox','deer']) assert.match(animalOBJ(id),/^# Original/,'legacy OBJ API remains available');
});
test('independent B3D parse validates all vertices, outward winding, normals, inset semantic UVs, normalized weights and bind bounds',()=> {
const unique=new Set();
for(const id of names) {
const bytes=faunaB3D(id),m=parse(bytes); unique.add(hash(bytes));
assert.ok(bytes.length<180000&&bytes.length>20000); assert.deepEqual(m.anim,[0,100,24]);
assert.equal(m.texture,`hearth_fauna_${id}.png`);
assert.equal(m.nodes.filter(n=>!n.parent).length,1); assert.equal(m.nodes[0].name,'Root');
assert.equal(new Set(m.nodes.map(n=>n.name)).size,m.nodes.length);
assert.ok(m.nodes.length>=6&&m.nodes.length<=30); assert.ok(m.vertices.length>=144&&m.vertices.length<=1600);
assert.equal(m.vertices.length%24,0); assert.equal(m.triangles.length,m.vertices.length/2);
const weights=new Float64Array(m.vertices.length),references=new Set();
for(const n of m.nodes) for(const [v,w] of n.weights) {
assert.ok(v>=0&&v<m.vertices.length&&w>0&&w<=1); weights[v]+=w;
}
for(const w of weights) near(w,1);
assert.ok(m.vertices.flat().every(Number.isFinite));
for(const [a,b,c] of m.triangles) {
assert.equal(new Set([a,b,c]).size,3);
for(const v of [a,b,c]) { assert.ok(v>=0&&v<m.vertices.length); references.add(v); }
const vs=[a,b,c].map(i=>m.vertices[i]),normal=cross(sub(vs[1].slice(0,3),vs[0].slice(0,3)),sub(vs[2].slice(0,3),vs[0].slice(0,3)));
assert.ok(Math.hypot(...normal)>1e-6);
const first=Math.floor(a/24)*24,box=m.vertices.slice(first,first+24),center=[0,1,2].map(i=>box.reduce((sum,v)=>sum+v[i],0)/24);
assert.ok(dot(normal,sub(vs[0].slice(0,3),center))>0,`${id}: outward solid winding`);
const tiles=vs.map(v=>v.slice(6).map(n=>Math.floor(n*4)).join()); assert.equal(new Set(tiles).size,1);
for(const v of vs) {
near(Math.hypot(...v.slice(3,6)),1); near(dot(normal,v.slice(3,6))/Math.hypot(...normal),1);
for(const u of v.slice(6)) {
assert.ok(u>0&&u<1); const px=u*64%16; assert.ok(px>=.5&&px<=15.5);
}
}
}
assert.equal(references.size,m.vertices.length);
const bound=faunaBounds(id);
near(bound.min[1],0);
for(let i=0;i<3;i++) {
near(Math.min(...m.vertices.map(v=>v[i]))/10,bound.min[i]);
near(Math.max(...m.vertices.map(v=>v[i]))/10,bound.max[i]);
assert.ok(bound.max[i]>bound.min[i]&&bound.max[i]-bound.min[i]<2);
}
}
assert.equal(unique.size,23);
});
test('every bone contains all 100 real poses; independent skinning proves each clip moves weighted geometry with no root motion or scale keys',()=> {
for(const id of names) {
const m=parse(faunaB3D(id));
for(const n of m.nodes) {
assert.equal(n.keys.length,100);
n.keys.forEach((key,i)=> {
assert.equal(key.frame,i+1); assert.ok([...key.pos,...key.rotation].every(Number.isFinite));
near(Math.hypot(...key.rotation),1);
if(n.name==='Root') { assert.deepEqual(key.pos,[0,0,0]); assert.deepEqual(key.rotation,[1,0,0,0]); }
});
}
for(const [clip,[a,b]] of Object.entries(FAUNA_ANIMATIONS)) {
const poses=Array.from({length:20},(_,i)=>skin(m,a+i));
assert.ok(poses.flat(2).every(Number.isFinite));
let displacement=0;
for(let f=1;f<20;f++) for(let v=0;v<m.vertices.length;v++)
displacement=Math.max(displacement,Math.hypot(...sub(poses[f][v],poses[0][v])));
assert.ok(displacement>.001,`${id} ${clip}: genuine skeletal displacement`);
for(let v=0;v<m.vertices.length;v++) for(let i=0;i<3;i++) near(poses[0][v][i],poses[b-a][v][i],2e-5);
}
const body=m.nodes.find(n=>n.name==='Body');
for(const key of body.keys) near(key.pos[0],body.pos[0]);
}
});
test('authored anatomy and chained joint motion distinguish ears, antlers, wings, fins, spines, mustelids and a twelve-segment earthworm',()=> {
const models=Object.fromEntries(names.map(id=>[id,parse(faunaB3D(id))]));
const node=(id,name)=>models[id].nodes.find(n=>n.name===name);
const extent=(id,name,axis)=> {
const values=node(id,name).weights.map(([v])=>models[id].vertices[v][axis]); return Math.max(...values)-Math.min(...values);
};
assert.ok(extent('hare','Ear_-1',1)>extent('rabbit','Ear_-1',1)*1.5);
assert.ok(faunaBounds('deer').max[1]>faunaBounds('roe_deer').max[1]);
assert.ok(node('deer','Antler_-1').parent===node('deer','Head'));
assert.ok(extent('red_squirrel','Tail',1)>extent('red_squirrel','Tail',0)*2,'raised slender squirrel brush');
near(extent('fox','Tail',1),extent('fox','Tail',0));
assert.ok(extent('wood_mouse','Tail_Tip',2)>extent('bank_vole','Tail_Tip',2)*2);
assert.ok(extent('stoat','Body',2)>extent('weasel','Body',2));
for(const id of ['hare','deer','fox','badger','otter','common_frog','smooth_newt']) {
assert.equal(node(id,'Shin_-1_-1').parent,node(id,'Leg_-1_-1'));
assert.notDeepEqual(node(id,'Leg_-1_-1').keys[24].rotation,node(id,'Leg_1_-1').keys[24].rotation);
}
for(const id of ['robin','blackbird','blue_tit','barn_owl','mallard','bumblebee']) {
const wing=node(id,'Wing_-1'); assert.ok(wing.weights.length);
assert.notDeepEqual(wing.keys[60].rotation,wing.keys[65].rotation);
assert.notDeepEqual(wing.keys[65].rotation,node(id,'Wing_1').keys[65].rotation);
if(id!=='bumblebee') assert.equal(node(id,'Wing_Tip_-1').parent,wing);
}
for(const id of ['otter','smooth_newt','brown_trout']) {
const tail=node(id,'Tail'),tip=node(id,'Tail_Tip'); assert.equal(tip.parent,tail);
assert.notDeepEqual(tail.keys[60].rotation,tail.keys[65].rotation);
}
const segments=models.earthworm.nodes.filter(n=>n.name.startsWith('Segment_')); assert.equal(segments.length,12);
segments.slice(1).forEach((n,i)=>assert.equal(n.parent,segments[i]));
assert.notDeepEqual(segments[0].keys[24].rotation,segments[5].keys[24].rotation);
assert.ok(models.hedgehog.vertices.length>models.bank_vole.vertices.length,'spine geometry, not only a colour change');
});
test('64px semantic RGBA atlases encode exact opaque pixels, distinct species, badger stripes, otter cream and bee bands',()=> {
const distinct=new Set(),pixel=(image,x,y)=>[...image.data.slice((y*64+x)*4,(y*64+x+1)*4)];
const material=(id,name,x,y)=>pixel(faunaTexture(id),FAUNA_MATERIALS.indexOf(name)%4*16+x,Math.floor(FAUNA_MATERIALS.indexOf(name)/4)*16+y);
for(const id of names) {
const image=faunaTexture(id); assert.deepEqual([image.width,image.height,image.data.length],[64,64,16384]);
distinct.add(hash(image.data)); assert.ok(image.data.filter((_,i)=>i%4===3).every(v=>v===255));
const bytes=png(64,(x,y)=>pixel(image,x,y)),parts=[];
assert.deepEqual([...bytes.slice(16,24)],[0,0,0,64,0,0,0,64]);
for(let i=8;i<bytes.length;) { const size=bytes.readUInt32BE(i); if(bytes.toString('ascii',i+4,i+8)==='IDAT') parts.push(bytes.slice(i+8,i+8+size)); i+=size+12; }
const raw=inflateSync(Buffer.concat(parts),{maxOutputLength:16448});
assert.equal(raw.length,16448);
for(let y=0;y<64;y++) { assert.equal(raw[y*257],0); assert.deepEqual(raw.slice(y*257+1,y*257+257),Buffer.from(image.data.slice(y*256,y*256+256))); }
}
assert.equal(distinct.size,23);
assert.notDeepEqual(material('badger','face',1,8),material('badger','face',8,8));
assert.deepEqual(material('badger','face',1,8),material('badger','face',14,8));
assert.notDeepEqual(material('otter','belly',8,8),material('otter','coat',8,8));
assert.notDeepEqual(material('bumblebee','stripe',8,1),material('bumblebee','stripe',8,5));
assert.notDeepEqual(material('mallard','wing',8,8),material('mallard','wing',1,8));
});
test('portrait sheet rasterizes all 23 actual skinned meshes at the selected frame with deterministic padded cells',()=> {
const image=faunaPortraitSheet(); assert.deepEqual([image.width,image.height,image.data.length],[512,768,1572864]);
assert.deepEqual(image,faunaPortraitSheet()); assert.notEqual(hash(image.data),hash(faunaPortraitSheet(65).data));
const cells=new Set();
for(let i=0;i<23;i++) {
const cell=Buffer.alloc(128*128*4);
for(let y=0;y<128;y++) cell.set(image.data.slice(((Math.floor(i/4)*128+y)*512+i%4*128)*4,((Math.floor(i/4)*128+y)*512+i%4*128+128)*4),y*128*4);
cells.add(hash(cell));
const colors=new Set(); for(let j=0;j<cell.length;j+=4) { assert.equal(cell[j+3],255); colors.add(cell.slice(j,j+3).join()); }
assert.ok(colors.size>4,`${names[i]}: actual shaded mesh, not an empty paper tile`);
}
assert.equal(cells.size,23);
});
+109
View File
@@ -0,0 +1,109 @@
-- Standalone Lua, no engine. Pass the fixed catalogue path as arg[1].
local target=assert(arg[1],"fauna.lua path required")
local root=target:gsub("[^/\\]+$","")
local L=dofile(root.."lifecycle.lua")
local F=dofile(target)({lifecycle=L},{})
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("exact stable 23 ID catalogue and strict canonical lookup",function()
local expected="hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm"
local ids={}; for _,r in ipairs(F.list()) do ids[#ids+1]=r.id; assert(F.kind(r.entity)==r.id) end
assert(table.concat(ids," ")==expected and #F.catalog()==23)
for _,v in ipairs({"../fox","fox.b3d","hearth:fox_extra","FOX",string.rep("a",2049)}) do assert(not F.get(v)) end
assert(not F.kind({name=42}) and not F.kind(false))
end)
test("species sources and lifecycle evidence are copied, not invented",function()
for _,r in ipairs(F.list()) do
assert(r.source_url:match("^https://en.wikipedia.org/wiki/"))
assert(r.profile.id==r.id and r.profile.kind=="animal")
if r.id~="deer" then assert(r.latin and #F.source(r.id)>=2) end
end
assert(F.get("deer").latin==false and F.get("deer").profile.confidence=="unverified_identity")
local r=F.get("fox"); r.prey.deer=true; r.bounds.max[2]=999; r.profile.maturity_days={min=1,max=1}
assert(not F.get("fox").prey.deer and F.get("fox").bounds.max[2]<1)
local sourced=F.get("fox").profile.maturity_days
assert(sourced and sourced.min==304 and sourced.max==304,"fox maturity remains the sourced real-day profile")
end)
test("exact authored asset convention, five clips, 24fps and measured bounds",function()
for _,r in ipairs(F.list()) do
assert(r.mesh=="hearth_fauna_"..r.id..".b3d" and r.texture=="hearth_fauna_"..r.id..".png")
assert(r.frames==100 and r.fps==24 and r.eye_height>=.15)
for i,name in ipairs({"idle","locomotion","feed","flight_swim","rest"}) do
assert(r.clips[name][1]==(i-1)*20 and r.clips[name][2]==i*20-1)
end
assert(r.bounds.min[2]==0 and r.bounds.max[2]>0 and r.bounds.min[3]<0)
end
assert(F.get("deer").bounds.max[2]==1.5947 and F.get("fox").bounds.min[3]==-.8624)
assert(F.get("earthworm").speed==20/3600)
end)
test("per-species solar rest schedules and sourced winter dormancy are explicit",function()
for _,r in ipairs(F.list()) do
assert(type(r.sleep)=="table" and type(r.sleep.kind)=="string",r.id)
assert(({day=true,night=true,crepuscular=true,any=true})[r.activity],r.id)
end
assert(F.get("robin").sleep.sleep_below==-6 and F.get("barn_owl").sleep.sleep_above==6)
assert(F.get("deer").sleep.active_between[1]==-8 and F.get("bank_vole").sleep.rest_hours[1]==12)
assert(F.get("hedgehog").winter_dormant and F.get("common_frog").winter_dormant
and F.get("smooth_newt").winter_dormant)
assert(not F.get("hare").winter_dormant)
end)
test("calendar ages are real days, never 60-second maturity or 900-second death",function()
local p=assert(F.phase("bank_vole",100,100+60)); assert(p.phase=="dependent")
p=assert(F.phase("bank_vole",100,100+56*86400)); assert(p.phase=="mature" and p.age_days==56)
p=assert(F.phase("bank_vole",100,100+1000*86400)); assert(not p.death_due)
assert(not F.phase("hare",200,100))
assert(F.phase("earthworm",0,900).phase=="juvenile")
end)
test("only complete source-backed profiles authorize mature seasonal reproduction",function()
local enabled={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}
for _,r in ipairs(F.list()) do
local season=r.id=="fox" and "Winter" or "Summer"
assert(F.can_breed(r.id,0,1800000000,"female",season)== (enabled[r.id] or false),r.id)
assert(r.profile.reproduction_enabled==(enabled[r.id] or false))
assert(not F.can_breed(r.id,1800000000-60,1800000000,"female","Summer"))
end
assert(not F.can_breed("missing",0,1800000000,"male"))
assert(F.get("rabbit").profile.gestation_days.min==30)
assert(F.get("wood_mouse").profile.gestation_days.max==26)
assert(F.get("hare").profile.gestation_days.max==42 and F.get("fox").profile.gestation_days.max==53)
assert(not F.can_breed("rabbit",0,1800000000,"female","Spring"))
end)
test("hemisphere-aware seasons not northern months gate both sexes",function()
local south=dofile(target)({lifecycle=L,climate_at=function() return {season="Spring"} end},{})
assert(south.can_breed("wood_mouse",0,1794000000,"male")) -- November southern spring
assert(not south.can_breed("wood_mouse",0,1794000000,"male","Winter"))
assert(not south.can_breed("wood_mouse",0,1794000000,"other","Spring"))
assert(not F.can_breed("wood_mouse",0,1794000000,"female")) -- no climate, fail closed
assert(F.get("mallard").profile.lifespan_days==false)
assert(F.can_breed("mallard",0,1794000000,"female","Spring"))
end)
test("actual UTC breeding windows never turn into reproductive authority",function()
assert(F.breeding_window("roe_deer",7).within_window)
assert(not F.breeding_window("roe_deer",11).within_window)
local fox_july=F.breeding_window("fox",7)
assert(fox_july.known and not fox_july.within_window,
"fox breeding is sourced for winter months, not July")
assert(F.breeding_window("blackbird",3).within_window)
assert(not F.breeding_window("blackbird",1).within_window)
end)
test("food web has concrete registered prey and distinct ecological functions",function()
for _,r in ipairs(F.list()) do for id in pairs(r.prey) do assert(F.get(id),id) end end
assert(F.get("fox").prey.rabbit and F.get("fox").prey.hare)
assert(F.get("stoat").prey.rabbit and F.get("weasel").prey.bank_vole)
assert(F.get("barn_owl").prey.wood_mouse and not F.get("barn_owl").prey.rabbit)
assert(F.get("otter").prey.brown_trout and F.get("badger").prey.earthworm)
assert(F.get("bumblebee").foods.nectar and not F.get("bumblebee").foods.seed)
assert(F.get("earthworm").foods.litter and not next(F.get("brown_trout").prey))
end)
test("unknown starvation is not fabricated; explicit rabbit upper bound is days",function()
for _,r in ipairs(F.list()) do
assert(r.feed_seconds>=300 and r.reserve_seconds>=86400)
if r.id=="rabbit" then assert(r.starvation_days==8 and r.starvation_source:match("#Diet$"))
else assert(r.starvation_days==false) end
end
end)
assert(passed==10)
print("PASS "..passed.." fauna catalogue tests")
+31 -15
View File
@@ -11,6 +11,7 @@ import { PALETTE, SPECIES_NAMES, EVERGREEN_NAMES, worldArt } from '../scripts/wo
import { BOTANY_ART_MANIFEST, generateAssets, png } from '../scripts/assets.mjs'; import { BOTANY_ART_MANIFEST, generateAssets, png } from '../scripts/assets.mjs';
import { packageGame, verifyPackage } from '../scripts/package.mjs'; import { packageGame, verifyPackage } from '../scripts/package.mjs';
import { CHARACTER_ART_MANIFEST } from '../scripts/character-art.mjs'; import { CHARACTER_ART_MANIFEST } from '../scripts/character-art.mjs';
import { FAUNA_ART_MANIFEST, FAUNA_NAMES, FAUNA_ANIMATIONS, faunaB3D, faunaTexture, faunaPortraitSheet } from '../scripts/fauna-art.mjs';
const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]; const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]];
const sub=(a,b)=>a.map((v,i)=>v-b[i]); const sub=(a,b)=>a.map((v,i)=>v-b[i]);
@@ -165,14 +166,14 @@ test('all four seasonal atlases preserve byte-exact bark and opaque 64x32 materi
for(const season of TREE_SEASONS) assert.throws(()=>treeAtlas(new Map(),season),/requires/); for(const season of TREE_SEASONS) assert.throws(()=>treeAtlas(new Map(),season),/requires/);
}); });
test('deprecated 23-species-mesh inventory stays frozen; all ten cube species have 34 compatibility atlases',()=> { test('deprecated 23-species-mesh inventory stays frozen; all thirteen cube species have 46 compatibility atlases',()=> {
const species=['oak','birch','pine','willow','alder']; const species=['oak','birch','pine','willow','alder'];
const meshes=species.flatMap(s=>[`${s}_seedling`,`${s}_young`,`${s}_mature`, const meshes=species.flatMap(s=>[`${s}_seedling`,`${s}_young`,`${s}_mature`,
...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]); ...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]);
const atlases=SPECIES_NAMES.flatMap(s=>[`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(n=>`${s}_atlas_${n}`))]); const atlases=SPECIES_NAMES.flatMap(s=>[`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(n=>`${s}_atlas_${n}`))]);
assert.deepEqual(LEGACY_MESH_SPECIES,species); assert.deepEqual(LEGACY_MESH_SPECIES,species);
assert.deepEqual(SPECIES_MESH_NAMES,meshes); assert.deepEqual(SPECIES_ATLAS_NAMES,atlases); assert.deepEqual(SPECIES_MESH_NAMES,meshes); assert.deepEqual(SPECIES_ATLAS_NAMES,atlases);
assert.equal(meshes.length,23); assert.equal(atlases.length,34); assert.equal(FLORA_NAMES.length,29); assert.equal(meshes.length,23); assert.equal(atlases.length,46); assert.equal(FLORA_NAMES.length,29);
for(const s of SPECIES_NAMES.slice(5)) for(const stage of ['seedling','young','mature','young_winter','mature_winter']) for(const s of SPECIES_NAMES.slice(5)) for(const stage of ['seedling','young','mature','young_winter','mature_winter'])
assert.throws(()=>floraOBJ(`${s}_${stage}`),/Unknown flora/,'new species must not grow unused irregular meshes'); assert.throws(()=>floraOBJ(`${s}_${stage}`),/Unknown flora/,'new species must not grow unused irregular meshes');
for(const s of species) assert.ok(!FLORA_NAMES.includes(`${s}_deadwood`)); for(const s of species) assert.ok(!FLORA_NAMES.includes(`${s}_deadwood`));
@@ -249,14 +250,15 @@ test('all species atlases use exact own bark and own seasonal leaves, opaque und
if(leaf.data[from+3]) assert.deepEqual(image.data.slice(right,right+4),leaf.data.slice(from,from+4)); if(leaf.data[from+3]) assert.deepEqual(image.data.slice(right,right+4),leaf.data.slice(from,from+4));
else { else {
const color=season==='summer'?{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark', const color=season==='summer'?{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[s]: beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade',
crab_apple:'leafDark',wild_cherry:'leafDark',field_maple:'leafDark'}[s]:
{spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season]; {spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
assert.deepEqual([...image.data.slice(right,right+4)],[...PALETTE[color],255]); assert.deepEqual([...image.data.slice(right,right+4)],[...PALETTE[color],255]);
} }
} }
} }
} }
assert.equal(distinct.size,34); assert.equal(distinct.size,SPECIES_ATLAS_NAMES.length);
for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter']) for(const s of EVERGREEN_NAMES) for(const season of ['spring','autumn','winter'])
assert.throws(()=>treeAtlas(art,season,s),/evergreen/); assert.throws(()=>treeAtlas(art,season,s),/evergreen/);
for(const bad of ['constructor','../oak','maple','']) assert.throws(()=>treeAtlas(art,'summer',bad),/Unknown tree species/); for(const bad of ['constructor','../oak','maple','']) assert.throws(()=>treeAtlas(art,'summer',bad),/Unknown tree species/);
@@ -278,7 +280,7 @@ test('minimal fixture generation and integrity-verified package include every se
assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_tree_atlas${season==='summer'?'':`_${season}`}.png`)),expected); assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_tree_atlas${season==='summer'?'':`_${season}`}.png`)),expected);
} }
for(const name of SPECIES_ATLAS_NAMES) { for(const name of SPECIES_ATLAS_NAMES) {
const [s,,season='summer']=name.split('_'), atlas=treeAtlas(art,season,s); const [,s,season='summer']=name.match(/^([a-z]+(?:_[a-z]+)*)_atlas(?:_(spring|autumn|winter))?$/), atlas=treeAtlas(art,season,s);
const expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32); const expected=png(64,(x,y)=>atlas.data.subarray((y*64+x)*4,(y*64+x+1)*4),32);
assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_${name}.png`)),expected); assert.deepEqual(await fs.readFile(path.join(destination,`mods/hearth/textures/hearth_${name}.png`)),expected);
} }
@@ -288,15 +290,29 @@ test('minimal fixture generation and integrity-verified package include every se
...SPECIES_ATLAS_NAMES.map(n=>`hearth_${n}.png`), ...SPECIES_ATLAS_NAMES.map(n=>`hearth_${n}.png`),
...['knife','axe','pick','spear'].map(n=>`hearth_icon_${n}.png`), ...['knife','axe','pick','spear'].map(n=>`hearth_icon_${n}.png`),
...CHARACTER_ART_MANIFEST.map(entry=>entry.filename), ...CHARACTER_ART_MANIFEST.map(entry=>entry.filename),
...FAUNA_ART_MANIFEST.map(entry=>entry.texture),'hearth_fauna_contact_sheet.png',
'hearth_character_contact_sheet.png', 'hearth_character_contact_sheet.png',
'hearth_person.png','crosshair.png','hearth_art_contact_sheet.png','hearth_c7aa83.png','hearth_947853.png']; 'hearth_person.png','crosshair.png','hearth_art_contact_sheet.png','hearth_c7aa83.png','hearth_947853.png'];
assert.equal(expectedTextures.length,578,'includes all 170 character layers and their review sheet'); assert.equal(expectedTextures.length,675,'complete strict inventory: world + atlases + characters + fauna + two swatches');
assert.deepEqual(JSON.parse(await fs.readFile(path.join(destination,'botany-art.json'),'utf8')), assert.deepEqual(JSON.parse(await fs.readFile(path.join(destination,'botany-art.json'),'utf8')),
{format:1,images:BOTANY_ART_MANIFEST},'all 160 botanical image identities are packaged'); {format:1,images:BOTANY_ART_MANIFEST},'all 200 botanical image identities are packaged');
assert.deepEqual(JSON.parse(await fs.readFile(path.join(destination,'fauna-art.json'),'utf8')),
{format:1,animations:FAUNA_ANIMATIONS,animals:FAUNA_ART_MANIFEST,
portraitSheet:{filename:'hearth_fauna_contact_sheet.png',width:512,height:768,frame:9,columns:4,cellSize:128,order:FAUNA_NAMES}});
for(const entry of FAUNA_ART_MANIFEST) {
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/models',entry.mesh)),faunaB3D(entry.id));
const image=faunaTexture(entry.id);
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures',entry.texture)),
png(64,(x,y)=>image.data.subarray((y*64+x)*4,(y*64+x+1)*4)));
}
const portraits=faunaPortraitSheet();
assert.deepEqual(await fs.readFile(path.join(destination,'mods/hearth/textures/hearth_fauna_contact_sheet.png')),
png(portraits.width,(x,y)=>portraits.data.subarray((y*portraits.width+x)*4,(y*portraits.width+x+1)*4),portraits.height));
assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/textures'))).sort(),expectedTextures.sort()); assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/textures'))).sort(),expectedTextures.sort());
const expectedModels=[...FLORA_NAMES,...['hare','fox','deer','knife','axe','pick','spear','person']] const expectedModels=[...FLORA_NAMES,...['hare','fox','deer','knife','axe','pick','spear','person']]
.map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d'); .map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d',
assert.equal(expectedModels.length,40,'37 OBJ files plus three animated player builds'); ...FAUNA_ART_MANIFEST.map(entry=>entry.mesh));
assert.equal(expectedModels.length,63,'37 OBJ files plus three player builds plus 23 fauna rigs');
assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/models'))).sort(),expectedModels.sort()); assert.deepEqual((await fs.readdir(path.join(destination,'mods/hearth/models'))).sort(),expectedModels.sort());
} }
await check(out); await check(out);
@@ -305,7 +321,7 @@ test('minimal fixture generation and integrity-verified package include every se
engine:{name:'Luanti',version:'5.18.0'},node:'>=22',packageFormat:1,backupFormat:1})); engine:{name:'Luanti',version:'5.18.0'},node:'>=22',packageFormat:1,backupFormat:1}));
await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true}); await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true});
await fs.writeFile(path.join(source,'tests/fixtures/scenarios.json'),'{}'); await fs.writeFile(path.join(source,'tests/fixtures/scenarios.json'),'{}');
const packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged); const packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged,{requireBundled:false});
const outside=path.join(root,'outside'); await fs.mkdir(outside); const outside=path.join(root,'outside'); await fs.mkdir(outside);
await fs.rm(path.join(out,'mods/hearth/models'),{recursive:true}); await fs.rm(path.join(out,'mods/hearth/models'),{recursive:true});
await fs.symlink(outside,path.join(out,'mods/hearth/models'),process.platform==='win32'?'junction':'dir'); await fs.symlink(outside,path.join(out,'mods/hearth/models'),process.platform==='win32'?'junction':'dir');
@@ -313,9 +329,9 @@ test('minimal fixture generation and integrity-verified package include every se
assert.deepEqual(await fs.readdir(outside),[]); assert.deepEqual(await fs.readdir(outside),[]);
}); });
test('read-only cube diversity and biome contracts cover ten exact species; review layout is documented',async()=> { test('read-only cube diversity and biome contracts cover thirteen exact species; review layout is documented',async()=> {
const source=await fs.readFile(new URL('../mods/hearth/diversity.lua',import.meta.url),'utf8'); const source=await fs.readFile(new URL('../mods/hearth/diversity.lua',import.meta.url),'utf8');
const species=source.match(/species_order=\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1)); const species=source.match(/species_order=\{([^}]+)\}/)[1].match(/"[a-z_]+"/g).map(s=>s.slice(1,-1));
assert.deepEqual(species,SPECIES_NAMES); assert.deepEqual(species,SPECIES_NAMES);
assert.match(source,/drawtype=woody and "normal" or "plantlike"/); assert.match(source,/drawtype=woody and "normal" or "plantlike"/);
assert.match(source,/tiles=woody and \{"hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_side.png"\}/); assert.match(source,/tiles=woody and \{"hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_top.png","hearth_"\.\.s\.\."_log_side.png"\}/);
@@ -323,16 +339,16 @@ test('read-only cube diversity and biome contracts cover ten exact species; revi
assert.match(source,/evergreen=s=="pine" or s=="spruce"/); assert.match(source,/evergreen=s=="pine" or s=="spruce"/);
assert.match(source,/plant~="moss" and plant~="lichen"/,'persistent groundcover has no dormant alias'); assert.match(source,/plant~="moss" and plant~="lichen"/,'persistent groundcover has no dormant alias');
const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8'); const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8');
const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1)); const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z_]+"/g).map(s=>s.slice(1,-1));
assert.deepEqual(biomeSpecies,SPECIES_NAMES); assert.deepEqual(biomeSpecies,SPECIES_NAMES);
const art=worldArt(); const art=worldArt();
for(const s of species) for(const face of ['log_top','log_side','leaves','planks']) for(const s of species) for(const face of ['log_top','log_side','leaves','planks'])
assert.ok(art.has(`hearth_${s}_${face}.png`)); assert.ok(art.has(`hearth_${s}_${face}.png`));
const docs=await fs.readFile(new URL('../docs/art-direction.md',import.meta.url),'utf8'); const docs=await fs.readFile(new URL('../docs/art-direction.md',import.meta.url),'utf8');
const current=docs.slice(0,docs.indexOf('<details>')); const current=docs.slice(0,docs.indexOf('<details>'));
assert.match(current,/512×736/); assert.match(current,/1–512/); assert.match(current,/16 columns/); assert.match(current,/512×864/); assert.match(current,/1–512/); assert.match(current,/16 columns/);
assert.match(current,/ceil\(count\/16\)\*32/); assert.match(current,/ceil\(count\/16\)\*32/);
assert.match(current,/360/); assert.match(current,/160/); assert.match(current,/botanicalLatin/); assert.match(current,/421/); assert.match(current,/200/); assert.match(current,/botanicalLatin/);
for(const s of SPECIES_NAMES) assert.ok(current.includes(s)); for(const s of SPECIES_NAMES) assert.ok(current.includes(s));
}); });
+269
View File
@@ -0,0 +1,269 @@
-- Standalone Lua 5.1+; policy only, never starts Luanti.
local target=assert(arg[1],"lifecycle.lua path required")
local L=dofile(target)
local passed=0
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 eq(a,b) assert(a==b,tostring(a).." ~= "..tostring(b)) end
local function url(s) return type(s)=="string" and s:match("^https://[^/]+/.+") end
local DAY,YEAR=86400,365.2425
local function at(p,days) return assert(L.phase(p,0,days*DAY)) end
local new_plants={
dandelion="Taraxacum officinale",daisy="Bellis perennis",red_clover="Trifolium pratense",
birdsfoot_trefoil="Lotus corniculatus",ribwort_plantain="Plantago lanceolata",selfheal="Prunella vulgaris",
knapweed="Centaurea nigra",oxeye_daisy="Leucanthemum vulgare",field_scabious="Knautia arvensis",
teasel="Dipsacus fullonum",cow_parsley="Anthriscus sylvestris",garlic_mustard="Alliaria petiolata",
honeysuckle="Lonicera periclymenum",ivy="Hedera helix",gorse="Ulex europaeus",broom="Cytisus scoparius",
crab_apple="Malus sylvestris",wild_cherry="Prunus avium",field_maple="Acer campestre",holly_fern="Polystichum setiferum",
}
local new_animals="rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm"
test("module constants retain unaccelerated real seconds",function()
eq(L.VERSION,1); eq(L.SECONDS_PER_DAY,DAY); eq(L.DAYS_PER_YEAR,YEAR)
eq(at(L.plant("oak"),1).age_seconds,86400)
end)
test("all hundred actual catalogue records receive group baselines",function()
local path=target:gsub("[^/\\]+$","botany.lua")
local B=dofile(path)({},{}); local records=B.list(); eq(#records,100)
for _,r in ipairs(records) do
local p=assert(L.plant(r.id,r.group)); eq(p.group,r.group); eq(p.pending,false)
assert(p.germination_days~=nil and p.juvenile_days~=nil and p.maturity_days~=nil and p.lifespan_days~=nil)
assert(L.phase(p,0,0),r.id); assert(L.resolve(p,731104),r.id)
end
end)
test("twenty registered plant IDs preserve exact botanical contract",function()
local count=0
for id,latin in pairs(new_plants) do
local p=assert(L.plant(id)); eq(p.latin,latin); eq(p.pending,false); count=count+1
end
eq(count,20); eq(#L.ids("plant"),100)
end)
test("twenty registered animals plus three legacy identity slots",function()
local count=0
for id in new_animals:gmatch("%S+") do
local p=assert(L.animal(id)); eq(p.id,id); eq(p.pending,false)
assert(type(p.latin)=="string" and url(p.source_url)); count=count+1
end
eq(count,20); eq(#L.ids("animal"),23)
for _,id in ipairs({"hare","fox","deer"}) do eq(L.animal(id).pending,false) end
end)
test("ten trees expose species evidence without laundering fallback timings",function()
local count=0
for id in ("oak birch pine willow alder beech aspen rowan hazel spruce"):gmatch("%S+") do
local p=L.plant(id,"trees"); assert(p.latin and url(p.source_url))
assert(p.germination_days.confidence=="policy_fallback")
assert(p.juvenile_days.confidence=="policy_fallback")
assert(p.maturity_days.min>=3*YEAR); assert(p.lifespan_days.min>=60*YEAR)
count=count+1
end
eq(count,10); eq(L.plant("birch").maturity_days.confidence,"policy_fallback")
eq(L.plant("alder").maturity_days.confidence,"policy_fallback")
end)
test("verified oak onset is forty real years not forty seconds",function()
local p=L.plant("oak"); eq(p.maturity_days.min,40*YEAR)
assert(url(p.maturity_days.source_url)); eq(p.maturity_days.confidence,"source_reported")
eq(at(p,30).phase,"seed"); eq(at(p,40*YEAR-1).phase,"juvenile")
eq(at(p,40*YEAR).phase,"mature"); eq(at(p,40*YEAR).reproductive_ready,false)
end)
test("verified rowan and beech distinguish onset and heavy crops",function()
eq(L.plant("rowan").maturity_days.max,10*YEAR)
local p=L.plant("beech"); eq(p.maturity_days.min,10*YEAR); eq(p.maturity_days.max,30*YEAR)
eq(p.maturity_days.statistic,"onset_to_heavy_crop")
end)
test("alder composite longevity is not a universal hundred-year constant",function()
local p=L.plant("alder"); eq(p.lifespan_days.min,60*YEAR); eq(p.lifespan_days.max,160*YEAR)
eq(p.lifespan_days.statistic,"composite_envelope"); assert(url(p.evidence.lifespan_context.source_url))
end)
test("all source-reported numerical claims carry field-level references",function()
local fields={"germination_days","juvenile_days","maturity_days","lifespan_days","gestation_days","incubation_days","larval_days","birth_count"}
local claims=0
for _,kind in ipairs({"plant","animal"}) do for _,id in ipairs(L.ids(kind)) do
local p=kind=="plant" and L.plant(id) or L.animal(id)
for _,name in ipairs(fields) do
local r=p[name]
if r then
assert(r.min>=0 and r.max>=r.min and r.max<math.huge)
assert(type(r.note)=="string" and #r.note>0)
if r.confidence=="source_reported" then
assert(url(r.source_url)); assert(url(p.evidence[name].source_url)); claims=claims+1
else eq(r.confidence,"policy_fallback") end
end
end
for _,e in pairs(p.evidence) do assert(url(e.source_url) and #e.note>0); eq(e.reviewed,"2026-11-03") end
end end
assert(claims>=35)
end)
test("verified thirty-day rabbit value is gestation not maturity",function()
local p=L.animal("rabbit"); eq(p.gestation_days.min,30); eq(p.gestation_days.max,37)
eq(p.maturity_days,false); eq(at(p,30).phase,"dependent")
assert(p.evidence.maturity_context.note:find("Conflicting",1,true))
eq(at(p,30).reproductive_ready,false)
end)
test("bank vole maturity weeks differ from weaning days",function()
local p=L.animal("bank_vole"); eq(p.juvenile_days.max,25)
eq(p.maturity_days.min,42); eq(p.maturity_days.max,56)
eq(at(p,24).phase,"dependent"); eq(at(p,30).phase,"juvenile"); eq(at(p,56).phase,"mature")
end)
test("wood mouse and squirrel retain independently verified gestation",function()
eq(L.animal("wood_mouse").gestation_days.min,21); eq(L.animal("wood_mouse").gestation_days.max,26)
eq(L.animal("red_squirrel").gestation_days.min,38); eq(L.animal("red_squirrel").gestation_days.max,39)
eq(at(L.animal("wood_mouse"),30).phase,"juvenile")
end)
test("frogs are not sexually mature at metamorphosis",function()
local p=L.animal("common_frog"); eq(p.larval_days.max,98); eq(p.maturity_days.max,3*YEAR)
eq(p.incubation_days.min,14); eq(at(p,98).phase,"juvenile"); eq(at(p,98).reproductive_ready,false)
end)
test("earthworm natural lifespan stays unknown and maturity takes years",function()
local p=L.animal("earthworm"); eq(p.lifespan_days,false)
eq(p.maturity_days.min,YEAR); eq(p.maturity_days.max,2*YEAR)
eq(at(p,30).phase,"juvenile"); eq(p.reproduction,"paired_hermaphrodite_cocoon")
end)
test("generic deer cannot inherit a roe deer life history",function()
local p=L.animal("deer"); eq(p.latin,false); eq(p.source_url,false); eq(p.maturity_days,false)
eq(p.confidence,"unverified_identity"); assert(L.animal("roe_deer").latin)
end)
test("winter appearance never declares death including annual representation",function()
for _,id in ipairs({"dandelion","daisy","garlic_mustard","foxglove"}) do
local p=L.plant(id); local s=L.seasonal(p,"Winter"); eq(s.dormant,true); eq(s.death_due,false)
end
local p=L.plant("daisy"); p.life_cycle="annual"
eq(L.seasonal(p,"Winter").death_due,false)
eq(L.seasonal(L.plant("pine"),"Winter").dormant,false)
eq(L.seasonal(L.plant("daisy"),"Spring").dormant,false)
end)
test("biennial leaves survive their first winter",function()
local p=L.plant("garlic_mustard"); eq(p.life_cycle,"biennial")
eq(p.life_cycle_confidence,"source_reported"); eq(at(p,200).death_due,false)
eq(L.seasonal(p,"Winter").death_due,false)
eq(L.plant("teasel").life_cycle_confidence,"policy_fallback")
end)
test("averages and record ages are never mandatory death deadlines",function()
for _,p in ipairs({L.animal("rabbit"),L.plant("oak"),L.animal("brown_trout")}) do
local s=at(p,p.lifespan_days.max+1); eq(s.beyond_lifespan_range,true); eq(s.death_due,false)
end
end)
test("age retains unloaded calendar gaps while work is bounded separately",function()
local gap=40*YEAR*DAY; local e=L.elapsed(100,100+gap,8)
eq(e.elapsed_seconds,gap); eq(e.work_seconds,8); eq(e.deferred_seconds,gap-8)
eq(L.phase(L.plant("oak"),100,100+gap).phase,"mature")
eq(L.elapsed(100,100,0).elapsed_seconds,0)
end)
test("clock reversal is explicit and never silently rebaselines age",function()
local p=L.plant("oak"); local value,reason=L.phase(p,100,99)
eq(value,nil); eq(reason,"clock_reversed")
value,reason=L.elapsed(100,99,8); eq(value,nil); eq(reason,"clock_reversed")
eq(L.phase(p,100,100).age_seconds,0)
end)
test("NaN infinities negatives strings and beyond-safe clocks fail closed",function()
local p=L.plant("oak")
for _,v in ipairs({0/0,math.huge,-math.huge,-1,"100",L.MAX_CLOCK+2,1e300}) do
eq(L.phase(p,v,100),nil); eq(L.phase(p,0,v),nil)
eq(L.elapsed(0,v,8),nil); eq(L.elapsed(0,100,v),nil)
end
eq(L.phase(p,nil,100),nil); eq(L.elapsed(0,100,nil),nil)
end)
test("largest valid age is constant-work and is not truncated",function()
local s=assert(L.phase(L.plant("oak"),0,L.MAX_CLOCK))
eq(s.age_seconds,L.MAX_CLOCK); assert(s.age_days>1000000000); eq(s.death_due,false)
local e=assert(L.elapsed(0,L.MAX_CLOCK,1)); eq(e.work_seconds,1); eq(e.elapsed_seconds,L.MAX_CLOCK)
end)
test("duration selection is deterministic bounded and salt dependent",function()
local r={min=10,max=40}
local a=L.duration(r,731104,"oak:maturity_days"); eq(a,L.duration(r,731104,"oak:maturity_days"))
assert(a>=10 and a<=40); assert(a~=L.duration(r,731105,"oak:maturity_days"))
assert(a~=L.duration(r,731104,"birch:maturity_days")); eq(L.duration({min=30,max=30},0,""),30)
end)
test("invalid duration ranges and seeds are rejected",function()
for _,r in ipairs({{min=-1,max=2},{min=3,max=2},{min=0,max=math.huge},{min=0,max=1e300},{min=0/0,max=2},{}}) do
eq(L.duration(r,0,"x"),nil)
end
for _,seed in ipairs({-1,.5,2147483647,math.huge,0/0,"1"}) do
eq(L.duration({min=1,max=2},seed,"x"),nil); eq(L.resolve(L.animal("deer"),seed),nil)
end
eq(L.duration({min=1,max=2},0,string.rep("x",65)),nil)
end)
test("resolved profiles preserve range provenance and ordered stages",function()
for _,kind in ipairs({"plant","animal"}) do for _,id in ipairs(L.ids(kind)) do
local p=kind=="plant" and L.plant(id) or L.animal(id)
for _,seed in ipairs({0,1,731104,2147483646}) do
local r=assert(L.resolve(p,seed)); eq(r.selection_seed,seed)
local previous=0
for _,name in ipairs({"germination_days","juvenile_days","maturity_days","lifespan_days"}) do
local n=r.selected_days[name]
if n then assert(n>=p[name].min and n<=p[name].max and n>=previous); previous=n end
end
assert(L.phase(r,0,100)); eq(p.selected_days,nil)
end
end end
end)
test("profiles nested evidence lists and IDs are fresh defensive copies",function()
local p=L.plant("oak"); p.maturity_days.min=1; p.evidence.maturity_days.note="changed"
eq(L.plant("oak").maturity_days.min,40*YEAR)
assert(L.plant("oak").evidence.maturity_days.note~="changed")
local a=L.animal("roe_deer"); a.breeding_months[1]=1; eq(L.animal("roe_deer").breeding_months[1],7)
local ids=L.ids("plant"); ids[1]="changed"; assert(L.ids("plant")[1]~="changed")
end)
test("identity and group validation never invent profiles",function()
for _,id in ipairs({"hearth:oak","../oak","Oak","oak_mature","unknown",string.rep("a",65),true,1}) do
eq(L.plant(id,"trees"),nil); eq(L.animal(id),nil)
end
eq(L.plant("oak","herbs"),nil); eq(L.ids("other"),nil)
end)
test("malformed profile values and stage ordering fail safely",function()
local p=L.plant("oak"); p.maturity_days={min=1,max=2}; eq(L.phase(p,0,0),nil)
p=L.plant("oak"); p.selected_days=true; eq(L.phase(p,0,0),nil)
p=L.plant("oak"); p.selected_days={maturity_days=0/0}; eq(L.phase(p,0,0),nil)
p=L.plant("oak"); p.maturity_days={min=1,max=math.huge}; eq(L.phase(p,0,0),nil)
eq(L.phase({},0,0),nil); eq(L.seasonal(L.animal("rabbit"),"Winter"),nil)
eq(L.seasonal(L.plant("oak"),"winter"),nil)
end)
test("breeding windows describe mating or nesting not immediate births",function()
local p=L.animal("roe_deer"); eq(p.reproduction,"delayed_implantation"); eq(p.gestation_days,false)
eq(L.breeding_window(p,7).within_window,true); eq(L.breeding_window(p,1).within_window,false)
eq(L.breeding_window(p,7).eligible,false)
eq(L.breeding_window(L.animal("blue_tit"),4).within_window,true)
eq(L.breeding_window(L.animal("blue_tit"),12).within_window,false)
eq(L.breeding_window(L.animal("barn_owl"),4).known,false)
eq(L.breeding_window(p,0),nil); eq(L.breeding_window(p,13),nil); eq(L.breeding_window(p,math.huge),nil)
end)
test("six verified basic profiles enable reproduction without claiming all taxa complete",function()
local enabled={bank_vole=true,wood_mouse=true,blackbird=true,mallard=true,hare=true,fox=true}; local count=0
for _,id in ipairs(L.ids("animal")) do
local p=L.animal(id); eq(p.reproduction_enabled,enabled[id] or false)
if enabled[id] then
count=count+1; assert(p.maturity_days and p.birth_count)
assert(p.gestation_days or p.incubation_days); assert(p.postpartum_days>=21)
end
eq(p.feeding_interval_seconds,nil); eq(p.movement_speed,nil)
end
eq(count,6); eq(L.animal("bank_vole").gestation_days.max,24)
eq(L.animal("blue_tit").incubation_days.max,14); eq(L.animal("blue_tit").maturity_days,false)
eq(L.animal("bumblebee").reproduction,"colony_caste_dependent")
local hare,fox=L.animal("hare"),L.animal("fox")
eq(hare.maturity_days.min,236); eq(hare.gestation_days.max,42); eq(hare.juvenile_days.max,30)
eq(hare.birth_count.max,8); eq(hare.reproduction_enabled,true); eq(hare.postpartum_days,30)
eq(fox.maturity_days.min,304); eq(fox.gestation_days.max,53); eq(fox.juvenile_days.min,56)
eq(fox.birth_count.max,9); eq(fox.reproduction_enabled,true); eq(fox.breeding_seasons.Winter,true)
end)
test("hedgehog and amphibian winter dormancy is explicit and never a death rule",function()
for _,id in ipairs({"hedgehog","common_frog","smooth_newt"}) do
local p=L.animal(id); eq(p.winter_dormant,true)
assert(L.phase(p,0,L.MAX_CLOCK).death_due==false)
end
eq(L.animal("hare").winter_dormant,nil)
end)
test("loading and calling policy does not pollute globals or read the clock",function()
local before={}; for k,v in pairs(_G) do before[k]=v end
local saved_time,saved_random=os.time,math.random
os.time=function() error("unexpected clock read") end; math.random=function() error("unexpected random call") end
local ok,err=pcall(function()
local fresh=dofile(target); fresh.resolve(fresh.plant("oak"),123)
fresh.phase(fresh.animal("wood_mouse"),0,100); fresh.elapsed(0,100,8)
end)
os.time=saved_time; math.random=saved_random; assert(ok,err)
for k,v in pairs(_G) do eq(v,before[k]) end
for k,v in pairs(before) do eq(_G[k],v) end
end)
eq(passed,32)
print("PASS "..passed.." lifecycle checks")
+176 -13
View File
@@ -13,7 +13,7 @@ local items = {
["hearth:log"] = {description="Timber log"}, ["hearth:stone"] = {description="Weathered stone"}, ["hearth:log"] = {description="Timber log"}, ["hearth:stone"] = {description="Weathered stone"},
["hearth:alluvium"] = {description="Alluvium"}, ["hearth:alluvium"] = {description="Alluvium"},
} }
local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"} local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"}
local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"} local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"}
for _, s in ipairs(species) do for _, s in ipairs(species) do
items["hearth:"..s.."_log"] = {description=s.." log"} items["hearth:"..s.."_log"] = {description=s.." log"}
@@ -61,6 +61,11 @@ function core.show_formspec(name, form, spec) screens[#screens+1]={name=name,for
hearth = {sessions={alice={}}, model={location=function() end}} hearth = {sessions={alice={}}, model={location=function() end}}
dofile(target) dofile(target)
local player={get_player_name=function() return "alice" end} local player={get_player_name=function() return "alice" end}
local player_metadata={}
function player:get_meta()
return {get_string=function(_,key) return player_metadata[key] or "" end,
set_string=function(_,key,value) player_metadata[key]=value end}
end
function player:get_pos() return {x=10,y=4,z=-2} end function player:get_pos() return {x=10,y=4,z=-2} end
function player:set_inventory_formspec(spec) self.inventory_spec=spec end function player:set_inventory_formspec(spec) self.inventory_spec=spec end
local hud = {} local hud = {}
@@ -264,14 +269,16 @@ test("ecosystem command and guide button only read one habitat and status snapsh
end,step=forbidden,forage=forbidden,deposit=forbidden} end,step=forbidden,forage=forbidden,deposit=forbidden}
hearth.wildlife={status=function() hearth.wildlife={status=function()
snapshots=snapshots+1 snapshots=snapshots+1
return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6} return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6,
recruitment={attempts=8,successes=2,last_reason="admitted"}}
end} end}
assert(commands.ecosystem.func("alice")) assert(commands.ecosystem.func("alice"))
assert(reads==1 and snapshots==1 and latest().form=="hearth:ecosystem") assert(reads==1 and snapshots==1 and latest().form=="hearth:ecosystem")
for _,text in ipairs({"Natural grass cover: 3", "Flower-support cover: 2", "Shrub / fern / aquatic / tree cover: 4", for _,text in ipairs({"Natural grass cover: 3", "Flower-support cover: 2", "Shrub / fern / aquatic / tree cover: 4",
"Moisture: 65%", "Hares: 3", "Deer: 2", "Foxes: 1", "Active total: 6 / 32 globally", "Moisture: 65%", "Hares: 3", "Deer: 2", "Foxes: 1", "Active total: 6 / 32 globally",
"not a local census", "Loaded sample", "No terrain is loaded", "Ground: Alluvium", "not a local census", "Loaded sample", "No terrain is loaded", "Ground: Alluvium",
"Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins"}) do "Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins",
"Admissions: 2 / 8 attempts", "Latest outcome: admitted"}) do
assert(latest().spec:find(text,1,true),text) assert(latest().spec:find(text,1,true),text)
end end
registered.fields(player,"hearth:guide",{ecosystem_survey=true}) registered.fields(player,"hearth:guide",{ecosystem_survey=true})
@@ -323,14 +330,14 @@ end)
-- Actual catalogue factory/methods, not an invented list of test species. -- Actual catalogue factory/methods, not an invented list of test species.
local botany_path = target:match("^(.*[/\\])") or "" local botany_path = target:match("^(.*[/\\])") or ""
local actual_botany = dofile(botany_path .. "botany.lua")(hearth,{}) local actual_botany = dofile(botany_path .. "botany.lua")(hearth,{})
test("actual botanical catalogue displays eighty species in eight ten-row pages", function() test("actual botanical catalogue displays one hundred species in ten ten-row pages and eight uneven groups", function()
hearth.botany=actual_botany hearth.botany=actual_botany
local records, groups = actual_botany.list(), {} local records, groups = actual_botany.list(), {}
assert(#records==80) assert(#records==100)
for page=1,8 do for page=1,10 do
hearth.botanical_guide(player,"",page) hearth.botanical_guide(player,"",page)
local spec,rows=latest().spec,0 local spec,rows=latest().spec,0
assert(spec:find("Page "..page.." / 8 / 80 matches",1,true)) assert(spec:find("Page "..page.." / 10 / 100 matches",1,true))
for _ in spec:gmatch("Habitat:") do rows=rows+1 end for _ in spec:gmatch("Habitat:") do rows=rows+1 end
assert(rows==10,"each page must contain ten species rows") assert(rows==10,"each page must contain ten species rows")
for i=(page-1)*10+1,page*10 do for i=(page-1)*10+1,page*10 do
@@ -339,7 +346,8 @@ test("actual botanical catalogue displays eighty species in eight ten-row pages"
assert(spec:find("Active seasons:",1,true) and spec:find("Roles / conditional policy or descriptors:",1,true)) assert(spec:find("Active seasons:",1,true) and spec:find("Roles / conditional policy or descriptors:",1,true))
end end
end end
local count=0; for _,n in pairs(groups) do assert(n==10); count=count+1 end; assert(count==8) local sizes={trees=13,shrubs=14,grasses=10,herbs=22,ferns_mosses=11,fungi=10,aquatic=10,lichens=10}
local count=0; for g,n in pairs(groups) do assert(n==sizes[g]); count=count+1 end; assert(count==8)
hearth.guide(player,6) hearth.guide(player,6)
for _,r in ipairs(records) do assert(latest().spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) end for _,r in ipairs(records) do assert(latest().spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) end
end) end)
@@ -376,7 +384,7 @@ test("botanical query escaping literal search and finite pagination bounds", fun
hearth.botanical_guide(player,"\n\tlichen",-math.huge) hearth.botanical_guide(player,"\n\tlichen",-math.huge)
assert(hearth.sessions.alice.botany_query==" lichen" and hearth.sessions.alice.botany_page==1) assert(hearth.sessions.alice.botany_query==" lichen" and hearth.sessions.alice.botany_page==1)
for _,p in ipairs({0/0,-8,"bad",0}) do hearth.botanical_guide(player,"",p); assert(hearth.sessions.alice.botany_page==1) end for _,p in ipairs({0/0,-8,"bad",0}) do hearth.botanical_guide(player,"",p); assert(hearth.sessions.alice.botany_page==1) end
hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==8) hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==10)
local record=actual_botany.list()[1]; record.common=query local record=actual_botany.list()[1]; record.common=query
hearth.botany={list=function() return {record} end} hearth.botany={list=function() return {record} end}
hearth.botanical_guide(player,"",1) hearth.botanical_guide(player,"",1)
@@ -487,7 +495,7 @@ test("HUD accepts legacy mocks without optional hotbar image setters", function(
assert(hearth.sessions.alice.hud.needs) assert(hearth.sessions.alice.hud.needs)
player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=image,selected player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=image,selected
end) end)
assert(total==38,"The original 38 recipe/journal checks must remain intact") assert(total==41,"The original checks plus three new tree recipe searches must remain intact")
test("inspect command uses exact species and rejects absent session unknown and URL input",function() test("inspect command uses exact species and rejects absent session unknown and URL input",function()
hearth.botany=actual_botany hearth.botany=actual_botany
hearth.wiki_articles=dofile(sibling.."wiki_articles.lua") hearth.wiki_articles=dofile(sibling.."wiki_articles.lua")
@@ -501,9 +509,9 @@ test("inspect command uses exact species and rejects absent session unknown and
end) end)
test("botanical article selection uses trusted current-page numeric rows only",function() test("botanical article selection uses trusted current-page numeric rows only",function()
hearth.botanical_guide(player,"",2) hearth.botanical_guide(player,"",2)
assert(latest().spec:find("tooltip[species_choice;Species article]",1,true)) assert(latest().spec:find("tooltip[species_choice;Species article;#181825;#cdd6f4]",1,true))
registered.fields(player,"hearth:botany",{species_choice="2",species_article=true,id="oak"}) registered.fields(player,"hearth:botany",{species_choice="2",species_article=true,id="oak"})
assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id=="blackberry") assert(latest().form=="hearth:species" and hearth.sessions.alice.species_reader.id==actual_botany.list()[12].id)
for _,v in ipairs({"oak","0","11","2.5","1e0","999",{},string.rep("1",1000)}) do for _,v in ipairs({"oak","0","11","2.5","1e0","999",{},string.rep("1",1000)}) do
hearth.botanical_guide(player,"",2); local n=#screens hearth.botanical_guide(player,"",2); local n=#screens
registered.fields(player,"hearth:botany",{species_choice=v,species_article=true}) registered.fields(player,"hearth:botany",{species_choice=v,species_article=true})
@@ -566,5 +574,160 @@ test("inventory appearance entry opens only the requesting player's creator", fu
hearth.character = nil hearth.character = nil
registered.fields(player,"",{character_creator=true}); assert(called == 1) registered.fields(player,"",{character_creator=true}); assert(called == 1)
end) end)
assert(total==44,"Unexpected recipe/journal/character integration count") assert(total==47,"Unexpected recipe/journal/character integration count")
test("inventory and journal tooltips contain escaped text, never an invalid third argument",function()
local specs={}
hearth.inventory(player); specs[#specs+1]=player.inventory_spec
local text="Preview woodland clothing and features. Cosmetic only; Save / Apply to keep changes."
assert(player.inventory_spec:find("tooltip[character_creator;"..core.formspec_escape(text)..";#181825;#cdd6f4]",1,true))
hearth.guide(player,6); specs[#specs+1]=latest().spec
hearth.recipes(player,"",1); specs[#specs+1]=latest().spec
hearth.location_form(player); specs[#specs+1]=latest().spec
hearth.botanical_guide(player,"",1); specs[#specs+1]=latest().spec
hearth.ecosystem(player); specs[#specs+1]=latest().spec
local n=0
for _,spec in ipairs(specs) do for body in spec:gmatch("tooltip%[(.-)%]") do
local clean=body:gsub("\\.",""); local _,separators=clean:gsub(";","")
assert(separators==1 or separators==3,"invalid tooltip arity: "..body); n=n+1
end end
assert(n>=10)
end)
test("survey loops actual counts for all 23 animals without changing the world",function()
hearth.lifecycle=dofile(sibling.."lifecycle.lua")
hearth.fauna=dofile(sibling.."fauna.lua")(hearth,{})
local counts={}; for i,r in ipairs(hearth.fauna.list()) do counts[r.id]=i end
local calls=0
hearth.wildlife={status=function() calls=calls+1; return {counts=counts,total=64,global_cap=64,local_cap=12,player_cap=16} end}
hearth.ecosystem(player); assert(calls==1)
for id,n in pairs(counts) do
local title=({hare="Hares",deer="Deer",fox="Foxes"})[id] or id:gsub("_"," ")
assert(latest().spec:find(title..": "..n,1,true),id)
end
assert(latest().spec:find("Calendar age includes unloaded time",1,true))
end)
test("actual fauna guide shows all identities sources and conservative lifecycle status read-only",function()
assert(commands.fauna.func("alice",""))
local records=hearth.fauna.list()
assert(#records==23 and latest().form=="hearth:fauna")
for page=1,5 do
hearth.fauna_guide(player,"",page)
assert(latest().spec:find("Page "..page.." / 5 / 23 matches",1,true))
for i=(page-1)*5+1,math.min(#records,page*5) do
local r=records[i]
assert(latest().spec:find(core.formspec_escape(r.source_url),1,true),r.id)
assert(latest().spec:find(core.formspec_escape(r.latin or "UNKNOWN species identity (legacy deer)"),1,true))
end
assert(latest().spec:find("automatic breeding disabled",1,true))
assert(latest().spec:find("no lifespan-endpoint death",1,true))
end
hearth.fauna_guide(player,"rabbit",1)
assert(latest().spec:find("Maturity: UNKNOWN",1,true))
hearth.fauna_guide(player,"bank_vole",1)
assert(latest().spec:find(core.formspec_escape("42-56 real days [source_reported]"),1,true))
assert(not commands.fauna.func("offline",""))
end)
test("fauna search pagination routing and escaping stay bounded and session-owned",function()
local query="];button[0,0;1,1;attack;Injected]"
hearth.fauna_guide(player,query,0/0)
assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true))
assert(latest().spec:find("No animal matches",1,true))
hearth.fauna_guide(player,string.rep("x",80),1); assert(#hearth.sessions.alice.fauna_query==48)
hearth.fauna_guide(player,"",math.huge); assert(hearth.sessions.alice.fauna_page==5)
registered.fields(player,"hearth:guide",{fauna_index=true}); assert(hearth.sessions.alice.fauna_page==1)
registered.fields(player,"hearth:fauna",{next=true}); assert(hearth.sessions.alice.fauna_page==2)
registered.fields(player,"hearth:fauna",{previous=true}); assert(hearth.sessions.alice.fauna_page==1)
registered.fields(player,"hearth:fauna",{search=true,query="Lutra lutra"}); assert(latest().spec:find("otter",1,true))
registered.fields(player,"hearth:fauna",{botany_index=true}); assert(latest().form=="hearth:botany")
registered.fields(player,"hearth:botany",{fauna_index=true}); assert(latest().form=="hearth:fauna")
registered.fields(player,"hearth:fauna",{field_notes=true}); assert(latest().form=="hearth:guide")
registered.fields(player,"hearth:ecosystem",{fauna_index=true}); assert(latest().form=="hearth:fauna")
local original=hearth.fauna
local r=original.list()[1]; r.common=query; r.source_url=query
hearth.fauna={list=function() return {r} end}; hearth.fauna_guide(player,"",1)
assert(latest().spec:find(core.formspec_escape(query),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.fauna=nil; hearth.fauna_guide(player,"",1); assert(latest().spec:find("catalogue unavailable",1,true))
hearth.fauna=original
local session,n=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
assert(not commands.fauna.func("alice","")); hearth.fauna_guide(player,"",1)
registered.fields(player,"hearth:fauna",{next=true}); assert(#screens==n)
hearth.sessions.alice=session
end)
test("fauna species selection and live inspection show bounded read-only species details",function()
local records=hearth.fauna.list(); local before=hearth.wildlife.status()
hearth.fauna_guide(player,"",1)
assert(latest().spec:find("species_choice;hare,deer,fox,rabbit,red_squirrel",1,true))
registered.fields(player,"hearth:fauna",{species_choice="3",species_details=true})
assert(latest().form=="hearth:fauna_detail" and latest().spec:find("Vulpes vulpes",1,true))
assert(latest().spec:find("Modeled prey:",1,true) and latest().spec:find("Winter rest policy:",1,true))
registered.fields(player,"hearth:fauna_detail",{fauna_index=true})
assert(latest().form=="hearth:fauna" and hearth.sessions.alice.fauna_page==1)
local now=os.time()
assert(hearth.inspect_fauna(player,"hare",{born_at=now-500*86400,origin="born",foodreserve=.42,
resident=true,observation="rest"}))
local spec=latest().spec
for _,text in ipairs({"Lepus europaeus","Lifecycle: mature","model years","real calendar days",
"Coarse modeled food reserve (policy, not biology): 42%","Current animation observation: resting",
"persistent registered resident","Modeled diet: browse","Activity / sleep policy: night / solar_night",
"Winter rest policy:","Modeled behavior:","Modeled prey:","Reproduction: enabled only within the coarse model",
"Active loaded animals of this species:"}) do
assert(spec:find(core.formspec_escape(text),1,true),text)
end
local after=hearth.wildlife.status()
assert(after.total==before.total and after.counts.hare==before.counts.hare,"panel reads status without game changes")
local original_get=hearth.fauna.get; local injection="];button[0,0;1,1;attack;Injected]"
hearth.fauna.get=function(id)
local r=original_get(id)
if id=="hare" then r.common=injection; r.latin=injection; r.source_url=injection end
return r
end
hearth.inspect_fauna(player,"hare",nil)
assert(latest().spec:find(core.formspec_escape(injection),1,true) and not latest().spec:find(";attack;Injected]",1,true))
hearth.fauna.get=original_get
local session,count=hearth.sessions.alice,#screens; hearth.sessions.alice=nil
assert(not hearth.inspect_fauna(player,"hare",nil) and #screens==count)
hearth.sessions.alice=session
local impostor={get_player_name=function() return "alice" end}
assert(not hearth.inspect_fauna(impostor,"hare",nil) and #screens==count)
assert(not hearth.inspect_fauna(player,"hare",setmetatable({},{})) and #screens==count)
assert(not hearth.inspect_fauna(player,"unknown",nil) and #screens==count)
assert(#records==23)
end)
test("wildlife encounters add canonical per-player field-journal entries; catalogue browsing does not",function()
local key="hearth:fauna_observations_v1"
player_metadata[key]=""
hearth.fauna_guide(player,"",1)
assert(latest().spec:find("0 / 23 species recorded",1,true))
hearth.inspect_fauna(player,"hare",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"})
assert(player_metadata[key]=="hare")
assert(latest().spec:find("Field journal: first recorded encounter",1,true))
hearth.inspect_fauna(player,"fox",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"})
assert(player_metadata[key]=="fox,hare","journal storage is canonical and sorted")
hearth.inspect_fauna(player,"hare",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"})
assert(latest().spec:find("already recorded",1,true))
hearth.inspect_fauna(player,"deer",nil)
assert(player_metadata[key]=="fox,hare","catalogue profile selection is not a sighting")
hearth.fauna_guide(player,"",1)
assert(latest().spec:find("2 / 23 species recorded",1,true))
assert(latest().spec:find("recorded encounter",1,true))
player_metadata[key]="hare,unknown"
assert(hearth.inspect_fauna(player,"deer",{born_at=os.time()-86400,origin="established",foodreserve=.5,
resident=true,observation="awake"}))
assert(player_metadata[key]=="hare,unknown","malformed journal data fails closed without overwriting")
player_metadata[key]=nil
end)
test("calendar guidance never promises accelerated saplings daily berries or complete biology",function()
hearth.recipes(player,"Renewal",1)
for _,s in ipairs({"365.2425 days","no 24-hour refill","at most one berry","one real year","UNKNOWN"}) do
assert(latest().spec:find(s,1,true),s)
end
hearth.guide(player,6)
for _,s in ipairs({"100 named species","23 animal identities","eight uneven","real years to decades","fungi do not accelerate growth","disable breeding"}) do
assert(latest().spec:find(s,1,true),s)
end
assert(not latest().spec:find("eight categories of ten",1,true))
end)
assert(total==54,"Unexpected focused integration count")
print("PASS "..total.." recipe discovery tests") print("PASS "..total.." recipe discovery tests")
+4 -2
View File
@@ -95,11 +95,13 @@ test("callbacks, authorization, drops and geometry survive every variant", funct
end end
end) end)
test("claimed handplaced flora can recolor with no metadata access or harvest refill", function() test("claimed handplaced flora can recolor with no metadata access or harvest refill", function()
data={succ_age="8412",succ_natural="1",succ_harvest_ready_at=tostring(now+86400),player_placed="1",owner="builder"} data={succ_age="8412",succ_natural="1",succ_birth_at="1700000000",succ_lifecycle_version="1",
succ_seed_epoch=tostring(now+86400),succ_harvest_ready_at=tostring(now+86400),player_placed="1",owner="builder"}
local reference=data local reference=data
put("spring_greens_patch"); season="Spring"; assert(seasons.reconcile(p)) put("spring_greens_patch"); season="Spring"; assert(seasons.reconcile(p))
season="Winter"; assert(seasons.reconcile(p)) season="Winter"; assert(seasons.reconcile(p))
assert(data==reference and data.succ_age=="8412" and data.player_placed=="1") assert(data==reference and data.succ_age=="8412" and data.player_placed=="1")
assert(data.succ_birth_at=="1700000000" and data.succ_lifecycle_version=="1" and data.succ_seed_epoch==tostring(now+86400))
assert(data.succ_harvest_ready_at==tostring(now+86400) and meta_writes==0) assert(data.succ_harvest_ready_at==tostring(now+86400) and meta_writes==0)
end) end)
test("unloaded, ignore, protected and uncontrolled names cause no writes", function() test("unloaded, ignore, protected and uncontrolled names cause no writes", function()
@@ -182,7 +184,7 @@ test("actual botany factory installs all new policies before seasonal registrati
end end
end end
end end
assert(count==62) assert(count==79)
end) end)
test("botany leaves diversity-owned taxa and block tree policies untouched", function() test("botany leaves diversity-owned taxa and block tree policies untouched", function()
local diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api) local diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api)
+20 -4
View File
@@ -2,6 +2,7 @@
local target=assert(arg[1],"species_effects.lua path required") local target=assert(arg[1],"species_effects.lua path required")
local root=target:gsub("[^/\\]+$","") local root=target:gsub("[^/\\]+$","")
local hearth={} local hearth={}
hearth.lifecycle=dofile(root.."lifecycle.lua")
hearth.botany=dofile(root.."botany.lua")(hearth,{}) hearth.botany=dofile(root.."botany.lua")(hearth,{})
hearth.wiki_articles=dofile(root.."wiki_articles.lua") hearth.wiki_articles=dofile(root.."wiki_articles.lua")
local E=dofile(target)(hearth,setmetatable({}, {__index=function(_,k) error("unexpected API access: "..k) end})) local E=dofile(target)(hearth,setmetatable({}, {__index=function(_,k) error("unexpected API access: "..k) end}))
@@ -12,20 +13,21 @@ local function entry(value,title)
for _,row in ipairs(E.describe(value)) do if row.title==title then return row end end for _,row in ipairs(E.describe(value)) do if row.title==title then return row end end
error("missing effect "..title) error("missing effect "..title)
end end
test("all80 identity sources are mapped without claiming unreviewed trait evidence",function() test("all100 identity sources are mapped without claiming unreviewed trait evidence",function()
local sources=E.sources(); local total=0 local sources=E.sources(); local total=0
for _,r in ipairs(hearth.botany.list()) do for _,r in ipairs(hearth.botany.list()) do
local s=assert(sources[r.id]); total=total+1 local s=assert(sources[r.id]); total=total+1
assert(s.source_species==r.latin and s.source_url:match("^https://en%.wikipedia%.org/wiki/")) assert(s.source_species==r.latin and s.source_url:match("^https://en%.wikipedia%.org/wiki/"))
assert(s.identity_revision==hearth.wiki_articles[r.id].revision) local article=hearth.wiki_articles[r.id]
assert(s.identity_revision_url==hearth.wiki_articles[r.id].revision_url) assert(s.identity_revision==(article and article.revision))
assert(s.identity_revision_url==(article and article.revision_url))
if not s.reviewed then assert(s.note:find("no species%-effect evidence reviewed")) end if not s.reviewed then assert(s.note:find("no species%-effect evidence reviewed")) end
for _,row in ipairs(E.describe(r)) do for _,row in ipairs(E.describe(r)) do
assert(row.status=="simulated" or row.status=="descriptive") assert(row.status=="simulated" or row.status=="descriptive")
assert(row.game~="" and row.source_species==r.latin and row.source_url==s.source_url) assert(row.game~="" and row.source_species==r.latin and row.source_url==s.source_url)
end end
end end
assert(total==80) assert(total==100)
assert(E.source("unknown")==nil and #E.describe({id="unknown",tags={engineer=true}})==0) assert(E.source("unknown")==nil and #E.describe({id="unknown",tags={engineer=true}})==0)
end) end)
test("reviewed mechanisms cite species and explicitly identify broader evidence",function() test("reviewed mechanisms cite species and explicitly identify broader evidence",function()
@@ -86,4 +88,18 @@ test("source and description results cannot mutate the reviewed policy",function
local sources=E.sources(); sources.alder.note="wrong" local sources=E.sources(); sources.alder.note="wrong"
assert(E.source("alder").note~="wrong") assert(E.source("alder").note~="wrong")
end) end)
test("effect text matches calendar policy without laundering identity references into timing evidence",function()
assert(entry("clover","nitrogenfixer").game:find("90 real calendar days",1,true))
local fungus=entry("fly_agaric","ectomycorrhiza").game
assert(fungus:find("No accelerated growth",1,true) and not fungus:find("16200",1,true))
assert(entry("birch_polypore","saprotroph").game:find("90 real days",1,true))
assert(entry("bilberry","pollinator").game:find("at most one extra berry",1,true))
for _,r in ipairs(hearth.botany.list()) do
local text=entry(r.id,"lifecycle").game
assert(text:find("source_reported, policy_fallback or UNKNOWN",1,true))
assert(text:find("unloaded time",1,true) and text:find("never authorize automatic death",1,true))
assert(not text:find("15 minutes",1,true))
end
end)
assert(passed==8)
print("LAST_HEARTH_SPECIES_EFFECTS_PASS "..passed) print("LAST_HEARTH_SPECIES_EFFECTS_PASS "..passed)
+3 -3
View File
@@ -82,7 +82,7 @@ test("empty ray and invalid hit positions do not read map",function()
hits={hit(3)}; hits[1].under.x=0/0; assert(not R.look(player) and reads==0) hits={hit(3)}; hits[1].under.x=0/0; assert(not R.look(player) and reads==0)
eye_height=math.huge; local n=rays; assert(not R.look(player) and rays==n); eye_height=1.6 eye_height=math.huge; local n=rays; assert(not R.look(player) and rays==n); eye_height=1.6
end) end)
test("all eighty entries render with honest seventy-eight excerpt and two note headings",function() test("all hundred entries render with honest ninety-eight excerpt and two note headings",function()
local excerpts,notes=0,0 local excerpts,notes=0,0
for _,r in ipairs(H.botany.list()) do for _,r in ipairs(H.botany.list()) do
assert(R.show(player,r.id)); local a=H.wiki_articles[r.id] assert(R.show(player,r.id)); local a=H.wiki_articles[r.id]
@@ -98,7 +98,7 @@ test("all eighty entries render with honest seventy-eight excerpt and two note h
assert(not a.revision and latest():find(api.formspec_escape(a.text),1,true)) assert(not a.revision and latest():find(api.formspec_escape(a.text),1,true))
end end
end end
assert(excerpts==78 and notes==2) assert(excerpts==98 and notes==2)
end) end)
test("excerpt retains full shipped introduction and attribution revision history license",function() test("excerpt retains full shipped introduction and attribution revision history license",function()
assert(R.show(player,"oak")); local a=H.wiki_articles.oak assert(R.show(player,"oak")); local a=H.wiki_articles.oak
@@ -119,7 +119,7 @@ test("current effects use actual describe records with simulated descriptive sep
assert(latest():find("SIMULATED",1,true) and latest():find("DESCRIPTIVE (not simulated)",1,true)) assert(latest():find("SIMULATED",1,true) and latest():find("DESCRIPTIVE (not simulated)",1,true))
assert(latest():find("no infection or attack",1,true)) assert(latest():find("no infection or attack",1,true))
assert(R.show(player,"alder",2)) assert(R.show(player,"alder",2))
assert(latest():find("900 warm active seconds",1,true) and latest():find("Frankia",1,true)) assert(latest():find("90 real calendar days",1,true) and latest():find("Frankia",1,true))
assert(latest():find("not measured Wikipedia constants",1,true)) assert(latest():find("not measured Wikipedia constants",1,true))
end) end)
test("article textarea and provenance are unnamed read-only copyable fields",function() test("article textarea and provenance are unnamed read-only copyable fields",function()
+318 -87
View File
@@ -54,6 +54,7 @@ local function reset()
external_protected=function(q) return protected[key(q)] end, external_protected=function(q) return protected[key(q)] end,
climate_at=function() return {temperature=temperature,season=season} end} climate_at=function() return {temperature=temperature,season=season} end}
hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api) hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api)
hearth.lifecycle=dofile(target:gsub("[^/\\]+$","lifecycle.lua"))
os.time=function() return now end os.time=function() return now end
load() load()
end end
@@ -67,6 +68,13 @@ local function baseline(k,age)
if age then metadata(p):set_string("succ_age",age) end if age then metadata(p):set_string("succ_age",age) end
end end
local function field(q,f) return tonumber(metadata(q):get_string(f)) or 0 end local function field(q,f) return tonumber(metadata(q):get_string(f)) or 0 end
local DAY,YEAR=86400,365.2425*86400
local function aged(seconds,q)
q=q or p
metadata(q):set_string("succ_initial_age_seconds",tostring(seconds))
metadata(q):set_string("succ_birth_at",tostring(now))
metadata(q):set_string("succ_last_observed_at",tostring(now))
end
local function test(label,fn) local function test(label,fn)
reset() reset()
local ok,err=pcall(fn) local ok,err=pcall(fn)
@@ -116,15 +124,15 @@ test("dry exposed fern dies, while river moisture changes its niche",function()
put(pos(1,0,0),"water_source") put(pos(1,0,0),"water_source")
assert(sim.habitat(p).water==1 and sim.habitat(p).moisture>0.2) assert(sim.habitat(p).water==1 and sim.habitat(p).moisture>0.2)
end) end)
test("tree stages require active age, then mature trees become decomposing wood",function() test("generic tree needs a real year to establish and ten to mature, never dies at a short deadline",function()
baseline("tree_seedling",21500); advance(100) baseline("tree_seedling",21500); advance(100)
assert(name(p)=="hearth:young_tree" and field(p,"succ_age")==0) assert(name(p)=="hearth:tree_seedling" and field(p,"succ_age")==21500)
metadata(p):set_string("succ_age","86300"); advance(100) aged(YEAR); advance(100)
assert(name(p)=="hearth:young_tree" and field(p,"succ_age")==21500)
aged(10*YEAR); advance(100)
assert(name(p)=="hearth:mature_tree") assert(name(p)=="hearth:mature_tree")
metadata(p):set_string("succ_age","259100"); advance(100) aged(10000*YEAR); advance(100)
assert(name(p)=="hearth:deadwood") assert(name(p)=="hearth:mature_tree" and field(pos(0,0,0),"succ_nutrients")==0)
metadata(p):set_string("succ_age","7100"); advance(100)
assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3)
end) end)
test("established sparse meadow eventually recruits a native tree",function() test("established sparse meadow eventually recruits a native tree",function()
baseline("meadow_grass",7100); advance(100) baseline("meadow_grass",7100); advance(100)
@@ -159,9 +167,9 @@ test("grass spreads locally but never replaces occupied flower or crop cells",fu
end) end)
test("moist shaded litter seeds fungi, then returns finite nutrients",function() test("moist shaded litter seeds fungi, then returns finite nutrients",function()
light=10 light=10
baseline("litter",300); advance(100) baseline("litter",300); metadata(p):set_string("succ_decay_initial","300"); advance(100)
assert(name(pos(0,1,1))=="hearth:fungi") -- first attempt selects offset 2 assert(name(pos(0,1,1))=="hearth:fungi") -- first attempt selects offset 2
metadata(p):set_string("succ_age","1700"); advance(100) now=now+90*DAY; advance(100); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1)
advance(1) advance(1)
assert(field(pos(0,0,0),"succ_nutrients")==1) assert(field(pos(0,0,0),"succ_nutrients")==1)
@@ -174,23 +182,23 @@ test("dung decomposes, fertilizes soil and does not instantly sprout a forest",f
put(p,"dung",true); baseline(nil,500); advance(100) put(p,"dung",true); baseline(nil,500); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4)
end) end)
test("grazing consumes exactly one natural grass or fern, not flowers or planted nodes",function() test("grazing debits one partial canopy ration, not flowers or planted nodes",function()
put(p,"meadow_grass",true); put(pos(1),"fern",true) put(p,"meadow_grass",true); put(pos(1),"fern",true)
assert(sim.forage(p)) assert(sim.forage(p))
assert(name(p)=="air" and name(pos(1))=="hearth:fern") assert(name(p)=="hearth:meadow_grass" and name(pos(1))=="hearth:fern")
assert(field(p,"succ_next")==now+300) assert(field(p,"succ_browse_ready_at")==now+7*DAY)
put(pos(1),"wildflowers",true) put(pos(1),"wildflowers",true)
assert(not sim.forage(p)) assert(not sim.forage(p))
put(p,"meadow_grass",false); metadata(p):set_string("succ_natural","") put(p,"meadow_grass",false); metadata(p):set_string("succ_natural","")
assert(not sim.forage(p)) assert(not sim.forage(p))
end) end)
test("grazing cooldown prevents immediate seed recolonization",function() test("grazing retains the perennial and shares seven-day regrowth across animals",function()
baseline("meadow_grass") baseline("meadow_grass")
assert(sim.forage(p)) assert(sim.forage(p))
advance(100); advance(100) assert(not sim.forage(p))
assert(name(p)=="air") load(); assert(not sim.forage(p))
advance(100) now=now+7*DAY
assert(name(p)=="hearth:meadow_grass") assert(sim.forage(p) and name(p)=="hearth:meadow_grass")
end) end)
test("source claims and external protection block all simulation writes",function() test("source claims and external protection block all simulation writes",function()
baseline("mature_tree",259100) baseline("mature_tree",259100)
@@ -274,15 +282,18 @@ test("vegetation reload alone invalidates age even if its supporting block staye
lbm.action(p); advance(100) lbm.action(p); advance(100)
assert(name(p)=="hearth:mature_tree" and field(p,"succ_age")==259100) assert(name(p)=="hearth:mature_tree" and field(p,"succ_age")==259100)
end) end)
test("no catchup on long absence, disconnect, unload, restart or clock rewind",function() test("calendar anchor survives absence, disconnect, unload and restart; rollback pauses at high water",function()
baseline("tree_seedling",21500) baseline("tree_seedling",21500)
local born=field(p,"succ_birth_at")
advance(864000); assert(name(p)=="hearth:tree_seedling" and field(p,"succ_age")==21500) advance(864000); assert(name(p)=="hearth:tree_seedling" and field(p,"succ_age")==21500)
players=false; sim.step(1); players=true players=false; sim.step(1); players=true
advance(100); assert(field(p,"succ_age")==21500) advance(100); assert(field(p,"succ_age")==21500)
lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==21500) lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==21500)
load(); advance(100); assert(field(p,"succ_age")==21500) load(); advance(100); assert(field(p,"succ_age")==21500)
advance(-100); assert(field(p,"succ_age")==21500) advance(-100); assert(field(p,"succ_age")==21500)
advance(100); assert(name(p)=="hearth:young_tree") advance(100); assert(name(p)=="hearth:tree_seedling")
assert(field(p,"succ_birth_at")==born and field(p,"succ_last_observed_at")==now)
advance(YEAR); advance(100); assert(name(p)=="hearth:young_tree")
end) end)
test("failed dispersal attempts survive restart without reroll or advancing age",function() test("failed dispersal attempts survive restart without reroll or advancing age",function()
baseline("mature_tree",2000) baseline("mature_tree",2000)
@@ -294,7 +305,7 @@ test("failed dispersal attempts survive restart without reroll or advancing age"
assert(field(p,"succ_serial")==10 and field(p,"succ_next")==due) assert(field(p,"succ_serial")==10 and field(p,"succ_next")==due)
advance(30) advance(30)
assert(field(p,"succ_serial")==10 and name(pos(-1,1,0))=="air") assert(field(p,"succ_serial")==10 and name(pos(-1,1,0))=="air")
assert(field(p,"succ_age")==age+30) assert(field(p,"succ_age")==age and field(p,"succ_last_observed_at")==now)
end) end)
test("cold weather pauses recruitment and stage growth without catchup",function() test("cold weather pauses recruitment and stage growth without catchup",function()
temperature=-5; baseline(); advance(100); advance(100) temperature=-5; baseline(); advance(100); advance(100)
@@ -372,7 +383,7 @@ test("winter mature canopy still sheds litter and decomposition returns nutrient
metadata(p):set_string("succ_serial","4"); advance(100) metadata(p):set_string("succ_serial","4"); advance(100)
assert(name(pos(-2,1,-1))=="hearth:litter") assert(name(pos(-2,1,-1))=="hearth:litter")
put(p,"litter",true); metadata(p):set_string("succ_age","1700") put(p,"litter",true); metadata(p):set_string("succ_age","1700")
advance(100) advance(100); now=now+90*DAY; advance(100); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==1)
end) end)
test("seasonal probes reconcile old canopy and handplaced plants within twelve heights",function() test("seasonal probes reconcile old canopy and handplaced plants within twelve heights",function()
@@ -410,54 +421,56 @@ local function diverse()
hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api) hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api)
load() load()
end end
test("unmarked mapgen deadwood is adopted and decays after two active hours with finite capped nutrients",function() test("unmarked mapgen deadwood decays after a calendar year with finite capped nutrients",function()
diverse(); humidity=20; put(p,"deadwood"); baseline() diverse(); humidity=20; put(p,"deadwood"); baseline()
assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==0) assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==0)
for _=1,71 do advance(100) end for _=1,71 do advance(100) end
assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100) assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100)
assert(field(pos(0,0,0),"succ_nutrients")==0) assert(field(pos(0,0,0),"succ_nutrients")==0)
advance(100) now=now+YEAR; advance(100); advance(100)
assert(name(p)=="hearth:litter" and field(p,"succ_age")==0) assert(name(p)=="hearth:litter" and field(p,"succ_age")==0)
assert(field(pos(0,0,0),"succ_nutrients")==3) assert(field(pos(0,0,0),"succ_nutrients")==3)
local before=writes; sim.step(999999) local before=writes; sim.step(999999)
assert(writes==before and field(pos(0,0,0),"succ_nutrients")==3) assert(writes==before and field(pos(0,0,0),"succ_nutrients")==3)
for _=1,18 do advance(100) end now=now+90*DAY; advance(100); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4)
advance(1); assert(field(pos(0,0,0),"succ_nutrients")==4) advance(1); assert(field(pos(0,0,0),"succ_nutrients")==4)
reset(); diverse(); humidity=20 reset(); diverse(); humidity=20
metadata(pos(0,0,0)):set_string("succ_nutrients","7") metadata(pos(0,0,0)):set_string("succ_nutrients","7")
put(p,"deadwood"); baseline(nil,7100); advance(100) put(p,"deadwood"); baseline(nil,7100); now=now+YEAR; advance(100); advance(100)
assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==8) assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==8)
end) end)
test("adopted deadwood never catches up across absence, disconnect, reload, restart or clock rewind",function() test("deadwood calendar survives absence, disconnect, reload and restart; rollback pauses",function()
diverse(); humidity=20; put(p,"deadwood"); baseline(nil,7100) diverse(); humidity=20; put(p,"deadwood"); baseline(nil,7100)
advance(864000); assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100) advance(864000); assert(name(p)=="hearth:deadwood")
local born=field(p,"succ_decay_at")
players=false; sim.step(1); players=true; advance(100) players=false; sim.step(1); players=true; advance(100)
assert(field(p,"succ_age")==7100) lbm.action(p); advance(100)
lbm.action(p); advance(100); assert(field(p,"succ_age")==7100) lbm.action(pos(0,0,0)); advance(100)
lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==7100) load(); advance(100)
load(); advance(100); assert(field(p,"succ_age")==7100) local high=field(p,"succ_decay_seen_at")
advance(-100); assert(field(p,"succ_age")==7100) advance(-100); assert(field(p,"succ_decay_seen_at")==high)
assert(field(p,"succ_decay_at")==born)
assert(field(pos(0,0,0),"succ_nutrients")==0) assert(field(pos(0,0,0),"succ_nutrients")==0)
advance(100) now=now+YEAR; advance(100); advance(100)
assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3) assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3)
end) end)
test("species stages keep birch identity and deadwood records its originating species",function() test("species stages keep one birth anchor, real profile thresholds and never die at lifespan endpoints",function()
diverse(); baseline("birch_seedling",21500); advance(100) diverse(); baseline("birch_seedling",21500); aged(5*YEAR); advance(100)
assert(name(p)=="hearth:birch_young" and metadata(p):get_string("succ_species")=="birch") assert(name(p)=="hearth:birch_young" and metadata(p):get_string("succ_species")=="birch")
local born=field(p,"succ_birth_at")
metadata(p):set_string("succ_age","30000"); advance(100) metadata(p):set_string("succ_age","30000"); advance(100)
assert(name(p)=="hearth:birch_young","young trees must not take herb senescence") assert(name(p)=="hearth:birch_young","young trees must not take herb senescence")
metadata(p):set_string("succ_age","86300"); advance(100) aged(20*YEAR); advance(100)
assert(name(p)=="hearth:birch_mature") assert(name(p)=="hearth:birch_mature")
metadata(p):set_string("succ_age","259100"); advance(100) aged(1000*YEAR); advance(100)
assert(name(p)=="hearth:deadwood" and metadata(p):get_string("succ_species")=="birch") assert(name(p)=="hearth:birch_mature" and metadata(p):get_string("succ_species")=="birch")
metadata(p):set_string("succ_age","7100"); advance(100) assert(born>0 and metadata(p):get_string("succ_lifecycle_origin")=="legacy_established")
assert(name(p)=="hearth:litter" and metadata(p):get_string("succ_species")=="birch")
end) end)
test("species seeds retain parent identity and reject an incompatible destination soil",function() test("species seeds retain parent identity and reject an incompatible destination soil",function()
diverse(); baseline("birch_mature",2000) diverse(); season="Autumn"; baseline("birch_mature",2000)
metadata(p):set_string("succ_serial","9"); advance(100) metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(pos(-1,1,0))=="hearth:birch_seedling") assert(hearth.seasons.kind(name(pos(-1,1,0)))=="birch_seedling")
reset(); diverse(); put(pos(0,0,0),"podzol"); put(pos(-1,0,0),"peat") reset(); diverse(); put(pos(0,0,0),"podzol"); put(pos(-1,0,0),"peat")
baseline("pine_mature",2000); metadata(p):set_string("succ_serial","9"); advance(100) baseline("pine_mature",2000); metadata(p):set_string("succ_serial","9"); advance(100)
assert(name(pos(-1,1,0))~="hearth:pine_seedling") -- litter may share the attempt assert(name(pos(-1,1,0))~="hearth:pine_seedling") -- litter may share the attempt
@@ -555,8 +568,9 @@ test("species seeds respect source, destination and support authority without em
assert(name(seed)~="hearth:birch_seedling",mode) assert(name(seed)~="hearth:birch_seedling",mode)
end end
end) end)
test("species age pauses across winter unload and restart without losing identity",function() test("calendar age continues through winter while visible stage growth waits for spring",function()
diverse(); baseline("oak_seedling",21500) diverse(); baseline("oak_seedling",21500)
aged(5*YEAR)
season,temperature="Winter",-5; advance(100) season,temperature="Winter",-5; advance(100)
assert(name(p)=="hearth:oak_seedling_winter" and field(p,"succ_age")==21500) assert(name(p)=="hearth:oak_seedling_winter" and field(p,"succ_age")==21500)
load(); advance(100); assert(field(p,"succ_age")==21500) load(); advance(100); assert(field(p,"succ_age")==21500)
@@ -611,9 +625,10 @@ local function supplied_habitat(h)
return out return out
end end
end end
test("ecology80 recruits every real returned candidate, spreads it and completes its finite lifecycle",function() test("ecology100 recruits every exact candidate with calendar anchors and no automatic death",function()
catalogue() catalogue()
local records=hearth.botany.list() local records=hearth.botany.list()
assert(#records==100)
for index,r in ipairs(records) do for index,r in ipairs(records) do
reset(); catalogue() reset(); catalogue()
local h=vector(r) local h=vector(r)
@@ -621,40 +636,40 @@ test("ecology80 recruits every real returned candidate, spreads it and completes
-- Exact position-hash start at this record, also meeting the old -- Exact position-hash start at this record, also meeting the old
-- woody-gap gate. No fake choose(), random rerolls or ID aliases. -- woody-gap gate. No fake choose(), random rerolls or ID aliases.
local x=0 local x=0
while (x*17+7)%80~=index-1 or (x*7)%23~=0 do x=x+1; assert(x<1840) end while (x*17+7)%100~=index-1 or (x*7)%23~=0 do x=x+1; assert(x<2300) end
p=pos(x,1,0); support(p,h); supplied_habitat(h) p=pos(x,1,0); support(p,h); supplied_habitat(h)
baseline(nil,7100); advance(100) baseline(nil,7100); advance(100)
local candidate=r.group=="trees" and r.id.."_seedling" or r.id local candidate=r.group=="trees" and r.id.."_seedling" or r.id
assert(hearth.seasons.kind(name(p))==candidate,r.id.." recruit got "..name(p)) assert(hearth.seasons.kind(name(p))==candidate,r.id.." recruit got "..name(p))
assert(metadata(p):get_string("succ_natural")=="1",r.id) assert(metadata(p):get_string("succ_natural")=="1",r.id)
assert(metadata(p):get_string("succ_lifecycle_version")=="1" and field(p,"succ_birth_at")==now,r.id)
local profile=hearth.lifecycle.plant(r.id)
local q=pos(p.x,p.y,p.z-1); support(q,h) local q=pos(p.x,p.y,p.z-1); support(q,h)
metadata(p):set_string("succ_age","1000"); metadata(p):set_string("succ_serial","2") metadata(p):set_string("succ_age","1000"); metadata(p):set_string("succ_serial","2")
if r.group=="trees" then if r.group=="trees" then
-- A mature parent disperses the same species seed, not a -- A mature parent disperses the same species seed, not a
-- generic oak. The subsequent expansion uses real block plans. -- generic oak. The subsequent expansion uses real block plans.
put(p,r.id.."_mature",true); metadata(p):set_string("succ_age","2000"); metadata(p):set_string("succ_serial","9") put(p,r.id.."_mature",true); aged(profile.maturity_days.max*DAY); metadata(p):set_string("succ_serial","9")
season="Autumn"
q=pos(p.x-1,p.y,p.z); support(q,h); advance(100) q=pos(p.x-1,p.y,p.z); support(q,h); advance(100)
assert(hearth.seasons.kind(name(q))==candidate,r.id.." tree spread") assert(hearth.seasons.kind(name(q))==candidate,r.id.." tree spread")
put(q,"air"); metadata(q):set_string("succ_natural","") put(q,"air"); metadata(q):set_string("succ_natural","")
put(p,candidate,true); metadata(p):set_string("succ_age","21600"); advance(100) put(p,candidate,true); aged(profile.juvenile_days.max*DAY); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="young",r.id.." young got "..name(p).." age "..field(p,"succ_age").." stress "..field(p,"succ_stress")) assert(metadata(p):get_string("succ_tree_stage")=="young",r.id.." young got "..name(p).." age "..field(p,"succ_age").." stress "..field(p,"succ_stress"))
metadata(p):set_string("succ_age","86400"); advance(100) aged(profile.maturity_days.max*DAY); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="mature",r.id.." mature") assert(metadata(p):get_string("succ_tree_stage")=="mature",r.id.." mature")
metadata(p):set_string("succ_age","259200"); advance(100) aged(10000*YEAR); advance(100)
assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12,r.id.." fallen") assert(hearth.seasons.kind(name(p))==r.id.."_mature",r.id.." must not die")
for id,n in pairs(world) do
if id~=key(p) then assert(metadata((function()
local a,b,c=id:match("([^:]+):([^:]+):([^:]+)"); return pos(tonumber(a),tonumber(b),tonumber(c))
end)()):get_string("succ_component")=="",r.id.." orphan component "..n.name) end
end
else else
season=r.seeding.Autumn and "Autumn" or "Summer"
if not r.seasons[season] then season=next(r.seasons) end
aged(profile.maturity_days and profile.maturity_days.max*DAY or 10000*YEAR)
advance(100) advance(100)
assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q)) if profile.maturity_days and r.seeding[season] then
metadata(p):set_string("succ_age","1000000"); advance(100) assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q))
assert(name(p)=="air" or name(p)=="hearth:litter",r.id.." senescence") else assert(name(q)=="air",r.id.." unknown maturity must not spawn") end
if name(p)=="hearth:litter" then aged(10000*YEAR); advance(100)
metadata(p):set_string("succ_age","1800"); advance(100); assert(name(p)=="air",r.id.." recycling") assert(hearth.seasons.kind(name(p))==candidate,r.id.." no guessed senescence")
end
end end
end end
end) end)
@@ -674,15 +689,15 @@ test("real habitat distinguishes bank depth, source columns, pH, host and bark w
metadata(pos(0,0,0)):set_string("player_placed","1") metadata(pos(0,0,0)):set_string("player_placed","1")
assert(not sim.habitat(p).loaded and writes==0) assert(not sim.habitat(p).loaded and writes==0)
end) end)
test("real source-water recruitment and submerged stand decay never clear or refill water",function() test("real source-water recruitment and very old submerged stands never clear or refill water",function()
for _,mode in ipairs({"surface","submerged"}) do for _,mode in ipairs({"surface","submerged"}) do
reset(); catalogue(); p=pos(mode=="surface" and 0 or 1,1,0) reset(); catalogue(); p=pos(mode=="surface" and 0 or 1,1,0)
put(pos(p.x,0,0),"water_source"); put(pos(p.x,-1,0),"alluvium") put(pos(p.x,0,0),"water_source"); put(pos(p.x,-1,0),"alluvium")
baseline(); advance(100); advance(100) baseline(); advance(100); advance(100)
local r=hearth.botany.get(name(p)); assert(r and r.group=="aquatic" and r.niche.aquatic==mode) local r=hearth.botany.get(name(p)); assert(r and r.group=="aquatic" and r.niche.aquatic==mode)
assert(name(pos(p.x,0,0))=="hearth:water_source") assert(name(pos(p.x,0,0))=="hearth:water_source")
metadata(p):set_string("succ_age","21600"); advance(100) aged(10000*YEAR); advance(100)
assert(name(p)=="air" and name(pos(p.x,0,0))=="hearth:water_source") assert(hearth.botany.kind(name(p))==r.id and name(pos(p.x,0,0))=="hearth:water_source")
assert(field(pos(p.x,-1,0),"succ_nutrients")==0) assert(field(pos(p.x,-1,0),"succ_nutrients")==0)
end end
end) end)
@@ -701,9 +716,9 @@ test("water placement denies flowing, too deep, unloaded, marked, claimed and pr
assert(name(p)=="air" and writes==0,mode) assert(name(p)=="air" and writes==0,mode)
end end
end) end)
test("real block plans expand atomically, survive restart and collapse only owned cubes",function() test("real block plans expand atomically, survive restart and never collapse from old age",function()
catalogue(); humidity=45 catalogue(); humidity=45
baseline("birch_seedling",21600); advance(100) baseline("birch_seedling",21600); aged(5*YEAR); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="young") assert(metadata(p):get_string("succ_tree_stage")=="young")
local count=0 local count=0
for _,c in ipairs(hearth.diversity.tree_plan("birch","young")) do for _,c in ipairs(hearth.diversity.tree_plan("birch","young")) do
@@ -711,19 +726,20 @@ test("real block plans expand atomically, survive restart and collapse only owne
assert(metadata(q):get_string("succ_component")==key(p)); count=count+1 assert(metadata(q):get_string("succ_component")==key(p)); count=count+1
end end
assert(count>10) assert(count>10)
load(); sim.step(1); metadata(p):set_string("succ_age","86400"); advance(100) load(); sim.step(1); aged(20*YEAR); advance(100)
assert(metadata(p):get_string("succ_tree_stage")=="mature") assert(metadata(p):get_string("succ_tree_stage")=="mature")
local before=writes local before=writes
local q=pos(0,4,0); metadata(q):set_string("owner","builder") local q=pos(0,4,0); metadata(q):set_string("owner","builder")
metadata(p):set_string("succ_age","259200"); advance(100) metadata(p):set_string("succ_seed_epoch",tostring(now+YEAR))
metadata(p):set_string("succ_litter_epoch",tostring(now+YEAR))
aged(1000*YEAR); advance(100)
assert(name(p)=="hearth:birch_mature" and writes==before) assert(name(p)=="hearth:birch_mature" and writes==before)
metadata(q):set_string("owner",""); advance(100) metadata(q):set_string("owner",""); advance(100)
assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12) assert(name(p)=="hearth:birch_mature" and name(pos(0,7,0))~="air")
assert(name(pos(0,7,0))=="air")
end) end)
test("every tree destination and its support fail closed before any plan write",function() test("every tree destination and its support fail closed before any plan write",function()
for _,mode in ipairs({"foreign","natural_plant","marker","claim","external","unloaded","support"}) do for _,mode in ipairs({"foreign","natural_plant","marker","claim","external","unloaded","support"}) do
reset(); catalogue(); humidity=45; baseline("oak_seedling",21600) reset(); catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR)
local q=pos(0,4,0) local q=pos(0,4,0)
if mode=="foreign" then put(q,"wall") end if mode=="foreign" then put(q,"wall") end
if mode=="natural_plant" then put(q,"yarrow",true) end if mode=="natural_plant" then put(q,"yarrow",true) end
@@ -737,14 +753,14 @@ test("every tree destination and its support fail closed before any plan write",
end end
end) end)
test("chopped ecology root cleans matching components but never mapgen or foreign timber",function() test("chopped ecology root cleans matching components but never mapgen or foreign timber",function()
catalogue(); humidity=45; baseline("oak_seedling",21600); advance(100) catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR); advance(100)
local old={name=name(p)}; put(p,"air"); now=now+1 local old={name=name(p)}; put(p,"air"); now=now+1
assert(sim.tree_removed(p,old)); assert(name(pos(0,3,0))=="air") assert(sim.tree_removed(p,old)); assert(name(pos(0,3,0))=="air")
put(pos(0,3,0),"oak_log"); now=now+1 put(pos(0,3,0),"oak_log"); now=now+1
assert(not sim.tree_removed(p,{name="hearth:oak_young"})) assert(not sim.tree_removed(p,{name="hearth:oak_young"}))
assert(name(pos(0,3,0))=="hearth:oak_log") assert(name(pos(0,3,0))=="hearth:oak_log")
end) end)
test("catalogue nitrogen caps survive restart and root fungi debit existing soil on faster growth",function() test("catalogue nitrogen caps survive restart and root fungi never accelerate calendar growth",function()
catalogue(); humidity=45; baseline("clover",1000) catalogue(); humidity=45; baseline("clover",1000)
for _=1,5 do advance(100) end for _=1,5 do advance(100) end
assert(field(pos(0,0,0),"succ_fixed_nitrogen")==2 and field(pos(0,0,0),"succ_nutrients")==2) assert(field(pos(0,0,0),"succ_fixed_nitrogen")==2 and field(pos(0,0,0),"succ_nutrients")==2)
@@ -752,6 +768,8 @@ test("catalogue nitrogen caps survive restart and root fungi debit existing soil
reset(); catalogue(); humidity=45; put(pos(0,0,0),"forest_floor") reset(); catalogue(); humidity=45; put(pos(0,0,0),"forest_floor")
put(pos(1),"birch_bolete",true); metadata(pos(0,0,0)):set_string("succ_nutrients","1") put(pos(1),"birch_bolete",true); metadata(pos(0,0,0)):set_string("succ_nutrients","1")
baseline("birch_seedling",16100); advance(100) baseline("birch_seedling",16100); advance(100)
assert(name(p)=="hearth:birch_seedling" and field(pos(0,0,0),"succ_nutrients")==1)
aged(5*YEAR); advance(100)
assert(name(p)=="hearth:birch_young" and field(pos(0,0,0),"succ_nutrients")==0) assert(name(p)=="hearth:birch_young" and field(pos(0,0,0),"succ_nutrients")==0)
reset(); catalogue(); humidity=45; put(pos(1),"birch_bolete",true) reset(); catalogue(); humidity=45; put(pos(1),"birch_bolete",true)
metadata(pos(0,0,0)):set_string("succ_nutrients","1") metadata(pos(0,0,0)):set_string("succ_nutrients","1")
@@ -762,7 +780,7 @@ test("saprotrophs age existing deadwood without minting nutrients or attacking n
catalogue(); humidity=70; light=10 catalogue(); humidity=70; light=10
put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch") put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch")
baseline("birch_polypore",1000); advance(100) baseline("birch_polypore",1000); advance(100)
assert(field(pos(0,0,0),"succ_age")>0 and field(pos(0,0,0),"succ_nutrients")==0) assert(field(pos(0,0,0),"succ_decay_bonus")>0 and field(pos(0,0,0),"succ_nutrients")==0)
assert(field(p,"succ_stress")==0) assert(field(p,"succ_stress")==0)
metadata(pos(0,0,0)):set_string("owner","builder"); local age=field(pos(0,0,0),"succ_age") metadata(pos(0,0,0)):set_string("owner","builder"); local age=field(pos(0,0,0),"succ_age")
advance(100); assert(field(pos(0,0,0),"succ_age")==age) advance(100); assert(field(pos(0,0,0),"succ_age")==age)
@@ -775,8 +793,9 @@ test("flowering, browse and nursery use botanical tags while harvested bushes re
catalogue(); humidity=45; put(p,"blackberry",true); put(pos(1),"yarrow",true) catalogue(); humidity=45; put(p,"blackberry",true); put(pos(1),"yarrow",true)
local h=sim.habitat(p); assert(h.pollination==2 and h.edible==1 and h.nursery==1) local h=sim.habitat(p); assert(h.pollination==2 and h.edible==1 and h.nursery==1)
metadata(p):set_string("botany_ready_at",tostring(now+86400)) metadata(p):set_string("botany_ready_at",tostring(now+86400))
baseline(nil,1000); advance(100); assert(field(p,"succ_age")==1100 and field(p,"botany_ready_at")>now) baseline(nil,1000); metadata(p):set_string("succ_seed_epoch",tostring(now+YEAR))
assert(sim.forage(p)); assert(name(p)=="air" and field(p,"botany_ready_at")>now) advance(100); assert(field(p,"succ_age")==1000 and field(p,"botany_ready_at")>now)
assert(sim.forage(p)); assert(name(p)=="hearth:blackberry" and field(p,"botany_ready_at")>now)
put(p,"foxglove",true); assert(not sim.forage(p)) put(p,"foxglove",true); assert(not sim.forage(p))
season="Spring"; put(p,"rowan_mature",true) season="Spring"; put(p,"rowan_mature",true)
metadata(p):set_string("succ_component",key(p)); metadata(p):set_string("succ_tree_stage","mature") metadata(p):set_string("succ_component",key(p)); metadata(p):set_string("succ_tree_stage","mature")
@@ -784,6 +803,25 @@ test("flowering, browse and nursery use botanical tags while harvested bushes re
h=sim.habitat(p) h=sim.habitat(p)
assert(h.pollination==1 and h.nursery==1 and h.edible==1,"one managed root, not a benefit per leaf") assert(h.pollination==1 and h.nursery==1 and h.edible==1,"one managed root, not a benefit per leaf")
end) end)
test("autumn and winter browse debit a shared partial ration without human fruiting",function()
catalogue(); season="Winter"; put(p,"blackberry",true)
assert(not hearth.botany.edible("blackberry","Winter"))
local ready=now+7*86400
assert(sim.forage(p,"winter_browse"))
assert(name(p)=="hearth:blackberry" and field(p,"succ_browse_ready_at")==ready)
assert(not sim.forage(p,"winter_browse"),"one resident cannot eat the same plant twice during its rest")
season="Summer"; now=ready
assert(sim.forage(p,"herbivore"),"the same plant becomes browseable after its shared rest window")
assert(field(p,"succ_browse_ready_at")==now+7*86400)
end)
test("animal seasonal browse can never fell a mature tree or feed from toxic plants",function()
catalogue(); season="Winter"; put(p,"oak_mature",true)
local before=name(p); local age=field(p,"succ_age"); local cooldown=field(p,"succ_browse_ready_at")
assert(not sim.forage(p,"winter_browse"))
assert(name(p)==before and field(p,"succ_age")==age and field(p,"succ_browse_ready_at")==cooldown)
put(p,"foxglove",true); before=name(p)
assert(not sim.forage(p,"winter_browse") and name(p)==before)
end)
test("one global tree-plan reservation bounds eight concurrent roots and malformed geometry writes nothing",function() test("one global tree-plan reservation bounds eight concurrent roots and malformed geometry writes nothing",function()
catalogue(); humidity=45 catalogue(); humidity=45
@@ -799,7 +837,7 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform
end end
for _,root in ipairs(positions) do put(root,"oak_seedling",true) end for _,root in ipairs(positions) do put(root,"oak_seedling",true) end
sim.step(1) sim.step(1)
for _,root in ipairs(positions) do metadata(root):set_string("succ_age","21600") end for _,root in ipairs(positions) do aged(5*YEAR,root) end
local calls=0; local original=hearth.diversity.tree_plan local calls=0; local original=hearth.diversity.tree_plan
hearth.diversity.tree_plan=function(...) calls=calls+1; return original(...) end hearth.diversity.tree_plan=function(...) calls=calls+1; return original(...) end
advance(100); assert(calls==1) advance(100); assert(calls==1)
@@ -807,7 +845,7 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform
for _,root in ipairs(positions) do if metadata(root):get_string("succ_tree_stage")=="young" then grown=grown+1 end end for _,root in ipairs(positions) do if metadata(root):get_string("succ_tree_stage")=="young" then grown=grown+1 end end
assert(grown==1 and writes<=sim.MAX_TREE_CELLS) assert(grown==1 and writes<=sim.MAX_TREE_CELLS)
for _,mode in ipairs({"missing","oversize","foreign","duplicate","offset"}) do for _,mode in ipairs({"missing","oversize","foreign","duplicate","offset"}) do
reset(); catalogue(); humidity=45; baseline("oak_seedling",21600) reset(); catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR)
hearth.diversity.tree_plan=function() hearth.diversity.tree_plan=function()
if mode=="missing" then return nil end if mode=="missing" then return nil end
local plan={{x=0,y=0,z=0,name="hearth:oak_young"}} local plan={{x=0,y=0,z=0,name="hearth:oak_young"}}
@@ -820,14 +858,14 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform
advance(100); assert(name(p)=="hearth:oak_seedling" and writes==0,mode) advance(100); assert(name(p)=="hearth:oak_seedling" and writes==0,mode)
end end
end) end)
test("water stands and botanical understory retain loaded-only ages across restart and reload",function() test("water stands and botanical understory retain calendar anchors across restart and reload",function()
for _,plant in ipairs({"duckweed","yarrow"}) do for _,plant in ipairs({"duckweed","yarrow"}) do
reset(); catalogue(); humidity=45 reset(); catalogue(); humidity=45
if plant=="duckweed" then put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium") end if plant=="duckweed" then put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium") end
baseline(plant,1000); advance(864000); assert(field(p,"succ_age")==1000) baseline(plant,1000); advance(864000); assert(field(p,"succ_age")==1000)
load(); advance(100); assert(field(p,"succ_age")==1000) load(); advance(100); assert(field(p,"succ_age")==1000)
lbm.action(p); advance(100); assert(field(p,"succ_age")==1000) lbm.action(p); advance(100); assert(field(p,"succ_age")==1000)
advance(100); assert(field(p,"succ_age")==1100) advance(100); assert(field(p,"succ_age")==1000 and field(p,"succ_last_observed_at")==now)
end end
end) end)
test("real moss mat sampler changes local moisture and pH once without map or metadata writes",function() test("real moss mat sampler changes local moisture and pH once without map or metadata writes",function()
@@ -887,7 +925,7 @@ test("alder all living stages and dog lichen share the durable finite nitrogen b
for _,id in ipairs({"alder_seedling","alder_young","alder_mature","dog_lichen"}) do for _,id in ipairs({"alder_seedling","alder_young","alder_mature","dog_lichen"}) do
reset(); catalogue() reset(); catalogue()
local r=hearth.botany.get(id); local h=vector(r) local r=hearth.botany.get(id); local h=vector(r)
support(p,h); supplied_habitat(h); baseline(id,1000) support(p,h); supplied_habitat(h); baseline(id,1000); aged(90*DAY)
for _=1,5 do advance(100) end for _=1,5 do advance(100) end
local below=pos(0,0,0) local below=pos(0,0,0)
assert(field(below,"succ_fixed_nitrogen")==2 and field(below,"succ_nutrients")==2,id) assert(field(below,"succ_fixed_nitrogen")==2 and field(below,"succ_nutrients")==2,id)
@@ -912,27 +950,220 @@ test("nitrogen never exceeds local four ceiling or writes through source or supp
assert(field(below,"succ_fixed_nitrogen")== (mode=="fraction" and 0.25 or 0),mode) assert(field(below,"succ_fixed_nitrogen")== (mode=="fraction" and 0.25 or 0),mode)
end end
end) end)
test("multiple host fungi cannot copy nutrients and exhausted soil cannot fund early growth",function() test("multiple host fungi cannot copy nutrients or accelerate growth even with a resource budget",function()
for _,budget in ipairs({0,1}) do for _,budget in ipairs({0,1}) do
reset(); catalogue(); humidity=45 reset(); catalogue(); humidity=45
put(pos(0,0,0),"forest_floor"); metadata(pos(0,0,0)):set_string("succ_nutrients",budget) put(pos(0,0,0),"forest_floor"); metadata(pos(0,0,0)):set_string("succ_nutrients",budget)
put(pos(1),"fly_agaric",true); put(pos(-1),"birch_bolete",true) put(pos(1),"fly_agaric",true); put(pos(-1),"birch_bolete",true)
baseline("birch_seedling",16100); advance(100) baseline("birch_seedling",16100); advance(100)
assert(field(pos(0,0,0),"succ_nutrients")==0) assert(field(pos(0,0,0),"succ_nutrients")==budget)
assert(name(p)==(budget==1 and "hearth:birch_young" or "hearth:birch_seedling")) assert(name(p)=="hearth:birch_seedling")
advance(100); assert(field(pos(0,0,0),"succ_nutrients")==0) aged(5*YEAR); advance(100)
assert(field(pos(0,0,0),"succ_nutrients")==0 and name(p)=="hearth:birch_young")
end end
end) end)
test("birch wood fungus exhausts supporting deadwood then litter with exactly one conserved payout each",function() test("birch wood fungus exhausts supporting deadwood then litter with exactly one conserved payout each",function()
catalogue(); humidity=70; light=10 catalogue(); humidity=70; light=10
put(p,"deadwood",true); metadata(p):set_string("succ_species","birch") put(p,"deadwood",true); metadata(p):set_string("succ_species","birch")
put(pos(0,2,0),"birch_polypore",true) put(pos(0,2,0),"birch_polypore",true)
baseline(nil,7100); advance(100) baseline(nil,7100); now=now+YEAR; advance(100); advance(100)
assert(name(p)=="hearth:litter" and name(pos(0,2,0))=="air") assert(name(p)=="hearth:litter" and name(pos(0,2,0))=="air")
assert(field(pos(0,0,0),"succ_nutrients")==3) assert(field(pos(0,0,0),"succ_nutrients")==3)
metadata(p):set_string("succ_age","1800"); advance(100) now=now+90*DAY; advance(100); advance(100)
assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4)
advance(60); assert(field(pos(0,0,0),"succ_nutrients")==4) advance(60); assert(field(pos(0,0,0),"succ_nutrients")==4)
end) end)
test("new seedlings never reinterpret legacy seconds and stage swaps retain one calendar birthday",function()
catalogue(); humidity=45
baseline("crab_apple_seedling",999999)
local born=field(p,"succ_birth_at")
assert(metadata(p):get_string("succ_lifecycle_origin")=="legacy_established")
assert(field(p,"succ_age")==999999 and born==now)
aged(5*YEAR); advance(100)
local birthday=field(p,"succ_birth_at")
assert(hearth.seasons.kind(name(p))=="crab_apple_young")
advance(100); assert(field(p,"succ_birth_at")==birthday and field(p,"succ_age")==999999)
end)
test("seed-age biennials cannot supply flowers before calendar maturity",function()
catalogue(); season="Spring"; humidity=60; light=8
baseline("garlic_mustard",1000)
aged(100*DAY)
assert(sim.habitat(p).pollination==0)
aged(730*DAY)
assert(sim.habitat(p).pollination==1)
season="Winter"; assert(sim.habitat(p).pollination==0)
end)
test("calendar metadata corruption versions and rollback fail closed without losing the high water",function()
for _,fieldname in ipairs({"succ_birth_at","succ_initial_age_seconds","succ_last_observed_at","succ_lifecycle_seed","succ_seed_epoch"}) do
reset(); catalogue(); baseline("oak_seedling",30000)
metadata(p):set_string(fieldname,"broken")
local before=writes; advance(100)
assert(writes==before and field(p,"succ_age")==30000,fieldname)
end
reset(); catalogue(); baseline("oak_seedling",30000)
metadata(p):set_string("succ_lifecycle_version","2"); advance(100)
assert(metadata(p):get_string("succ_lifecycle_version")=="2" and field(p,"succ_age")==30000)
reset(); catalogue(); baseline("oak_seedling",30000); advance(100)
local high=field(p,"succ_last_observed_at"); advance(-50)
assert(field(p,"succ_last_observed_at")==high)
advance(100); assert(field(p,"succ_last_observed_at")==now)
end)
test("worldgen botanical flora and full block roots adopt established provenance without replacing canopies",function()
for _,id in ipairs({"dandelion","garlic_mustard","holly_fern","crab_apple_log","wild_cherry_log","field_maple_log","log"}) do
reset(); catalogue(); humidity=45; put(p,id)
baseline(); advance(100)
assert(name(p)=="hearth:"..id,id)
assert(metadata(p):get_string("succ_lifecycle_origin")=="worldgen_established",id)
assert(field(p,"succ_birth_at")==now-100 and metadata(p):get_string("succ_lifecycle_version")=="1",id)
assert(field(p,"succ_initial_age_seconds")>0,id)
end
end)
local function snapshot()
local out={}
for id,f in pairs(fields) do for k,v in pairs(f) do out[#out+1]=id..":"..k.."="..v end end
table.sort(out); return table.concat(out,"|").."/writes="..writes
end
test("nectar is a shared hourly debit and pollen is one expiring boolean, never a seed or fruit grant",function()
catalogue(); put(p,"blackberry",true)
assert(sim.pollinate(p))
assert(field(p,"succ_nectar_ready_at")==now+3600 and field(p,"succ_pollinated_until")==now+DAY)
local saved=snapshot()
for _=1,20 do assert(not sim.pollinate(p)) end
assert(snapshot()==saved and field(p,"botany_ready_at")==0 and writes==0)
load(); assert(not sim.pollinate(p))
now=now+3600; assert(sim.pollinate(p))
assert(field(p,"succ_pollinated_until")==now-3600+DAY,"no additive/extended pollen credit")
now=now+DAY; assert(sim.pollinate(p) and field(p,"succ_pollinated_until")==now+DAY)
assert(not sim.disperse_seed(pos(1),"oak"))
end)
test("nectar and fruit deny immature plants, out-of-season food, and malformed shared timestamps",function()
catalogue(); baseline("blackberry"); aged(DAY)
local saved=snapshot()
assert(not sim.pollinate(p) and not sim.frugivore(p) and snapshot()==saved)
aged(10*YEAR); season="Winter"; saved=snapshot()
assert(not sim.pollinate(p) and not sim.frugivore(p) and snapshot()==saved)
season="Summer"
for _,bad in ipairs({"broken","-1","inf","nan"}) do
metadata(p):set_string("succ_nectar_ready_at",bad)
metadata(p):set_string("botany_ready_at",bad)
saved=snapshot(); assert(not sim.pollinate(p) and not sim.frugivore(p) and snapshot()==saved,bad)
end
end)
test("frugivore spends one whole existing botanical fruit batch and shares its human lock across restart",function()
catalogue(); put(p,"blackberry",true)
assert(not sim.frugivore(p,"raspberry"))
assert(sim.frugivore(p,"blackberry") and field(p,"botany_ready_at")==now+YEAR)
assert(name(p)=="hearth:blackberry" and writes==0)
local saved=snapshot()
for _=1,20 do assert(not sim.frugivore(p)) end
load(); assert(not sim.frugivore(p) and snapshot()==saved)
now=now+DAY; assert(not sim.frugivore(p),"no daily fruit refill")
local B=hearth.botany
hearth.construction={allowed=function() return true end}
local human={is_player=function() return true end,get_inventory=function() error("spent fruit cannot reach inventory") end}
assert(not B.harvest(p,human))
now=now+YEAR; season="Winter"; assert(not sim.frugivore(p))
season="Summer"; assert(sim.frugivore(p))
end)
test("all resource hooks reject source and support claims protection markers and missing nodes without metadata writes",function()
for _,hook in ipairs({"pollinate","frugivore","granivore","detritivore","forage"}) do
for _,mode in ipairs({"source_claim","support_claim","source_protect","support_protect","source_mark","support_mark","source_missing","support_missing","ambiguous"}) do
reset(); catalogue()
put(p,hook=="granivore" and "oak_mature" or (hook=="detritivore" and "litter" or "blackberry"),true)
if hook=="granivore" then season="Autumn" end
local q=mode:match("^support") and pos(0,0,0) or p
if mode:find("claim") then claims[key(q)]=true end
if mode:find("protect") then protected[key(q)]=true end
if mode:find("mark") then metadata(q):set_string("third_party:placed","1") end
if mode:find("missing") then missing[key(q)]=true end
if mode=="ambiguous" then hearth.claim_owner_at=function(q) return nil,key(q)==key(p) end end
local saved=snapshot(); assert(not sim[hook](p) and snapshot()==saved,hook.." "..mode)
end
end
end)
test("two annual nuts fund one meal and a separate one-use local cache; species IDs alone authorize nothing",function()
catalogue(); season="Autumn"; put(p,"oak_mature",true)
assert(not sim.disperse_seed(pos(1),"oak"))
local fed,cache=sim.granivore(p,"oak")
assert(fed and cache=="oak" and field(p,"succ_nuts_remaining")==0)
assert(sim.disperse_seed(pos(1),cache))
assert(hearth.seasons.kind(name(pos(1)))=="oak_seedling")
assert(metadata(pos(1)):get_string("succ_lifecycle_origin")=="natural_seed")
assert(not sim.disperse_seed(pos(-1),cache) and not sim.granivore(p))
load(); assert(not sim.granivore(p) and not sim.disperse_seed(pos(-1),"oak"))
now=now+YEAR; local fed2,cache2=sim.granivore(p); assert(fed2 and cache2=="oak")
end)
test("one remaining nut grants a meal but no cache and immature or wrong-season roots have no seed budget",function()
catalogue(); season="Autumn"; put(p,"hazel_mature",true)
metadata(p):set_string("succ_nuts_year",os.date("!*t",now).year)
metadata(p):set_string("succ_nuts_remaining","1")
local fed,cache=sim.granivore(p); assert(fed and cache==nil and not sim.disperse_seed(pos(1),"hazel"))
reset(); catalogue(); season="Autumn"; baseline("oak_seedling"); aged(YEAR)
local saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved)
put(p,"oak_mature",true); aged(40*YEAR); season="Summer"
saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved)
end)
test("failed or delayed caching burns paid authority; donors and destinations cannot be cloned",function()
for _,mode in ipairs({"claim","support","marker","occupied","unloaded","donor","delay","wrong","far"}) do
reset(); catalogue(); season="Autumn"; put(p,"hazel_mature",true)
assert(sim.granivore(p)); local q=pos(1)
if mode=="claim" then claims[key(q)]=true end
if mode=="support" then claims[key(pos(1,0,0))]=true end
if mode=="marker" then metadata(q):set_string("owner","builder") end
if mode=="occupied" then put(q,"meadow_grass",true) end
if mode=="unloaded" then missing[key(q)]=true end
if mode=="donor" then metadata(p):set_string("player_placed","1") end
if mode=="delay" then now=now+1 end
if mode=="far" then q=pos(200) end
local saved=snapshot()
assert(not sim.disperse_seed(q,mode=="wrong" and "oak" or "hazel") and snapshot()==saved,mode)
assert(not sim.disperse_seed(pos(-1),"hazel") and not sim.granivore(p),mode)
end
reset(); catalogue(); season="Autumn"; put(p,"oak_mature",true); assert(sim.granivore(p))
load(); assert(not sim.disperse_seed(pos(1),"oak"),"restart discards authority, not debits")
end)
test("litter transfers ten finite tenths, retains its patch until exhausted and cannot pay again after restart",function()
put(p,"litter",true)
for i=1,10 do
assert(sim.detritivore(p),i)
assert(math.abs(field(pos(0,0,0),"succ_nutrients")-i/10)<0.000001)
if i<10 then assert(name(p)=="hearth:litter"); load() end
end
assert(name(p)=="air" and field(p,"succ_litter_remaining")==0)
local saved=snapshot(); assert(not sim.detritivore(p) and snapshot()==saved)
load(); assert(not sim.detritivore(p) and field(pos(0,0,0),"succ_nutrients")<1.000001)
end)
test("partial litter feeding and normal calendar decay share exactly one mineral payout",function()
put(p,"litter",true); assert(sim.detritivore(p)); baseline()
now=now+90*DAY; advance(100); advance(100)
assert(name(p)=="air" and math.abs(field(pos(0,0,0),"succ_nutrients")-1)<0.000001)
end)
test("last litter unit refuses a protected dependent fungus or full soil without a debit",function()
catalogue(); put(p,"litter",true); metadata(p):set_string("succ_litter_remaining","0.1")
put(pos(0,2,0),"wood_blewit",true); protected[key(pos(0,2,0))]=true
local saved=snapshot(); assert(not sim.detritivore(p) and snapshot()==saved)
protected={}; metadata(pos(0,0,0)):set_string("succ_nutrients","8")
saved=snapshot(); assert(not sim.detritivore(p) and snapshot()==saved)
end)
test("resource APIs fail closed on malformed positions clocks receipts and nonroot tree components",function()
catalogue(); put(p,"blackberry",true)
for _,hook in ipairs({"pollinate","frugivore","granivore","detritivore","forage","disperse_seed"}) do
for _,bad in ipairs({{}, {x=0/0,y=1,z=0}, {x=math.huge,y=1,z=0}, {x=40000,y=1,z=0}}) do
local saved=snapshot(); assert(not sim[hook](bad,"oak") and snapshot()==saved,hook)
end
end
api.now=function() return 0/0 end
for _,hook in ipairs({"pollinate","frugivore","granivore","detritivore","forage"}) do
local saved=snapshot(); assert(not sim[hook](p) and snapshot()==saved,hook)
end
api.now=nil; season="Autumn"; baseline("oak_mature"); aged(40*YEAR)
put(p,"oak_planks",true)
local saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved)
put(p,"oak_mature",true)
for _,bad in ipairs({"broken","-1","0.5","3"}) do
metadata(p):set_string("succ_nuts_remaining",bad)
saved=snapshot(); assert(not sim.granivore(p) and snapshot()==saved,bad)
end
end)
os.time=original_time os.time=original_time
print("LAST_HEARTH_SUCCESSION_PASS "..passed) print("LAST_HEARTH_SUCCESSION_PASS "..passed)
+28 -9
View File
@@ -64,7 +64,7 @@ test('package twice: identical full inventory and manifest hashes, original asse
assert.deepEqual(await inventory(a), await inventory(b)); assert.deepEqual(await inventory(a), await inventory(b));
const hash = data => createHash('sha256').update(data).digest('hex'); const hash = data => createHash('sha256').update(data).digest('hex');
assert.equal(hash(await fs.readFile(path.join(a, MANIFEST))), hash(await fs.readFile(path.join(b, MANIFEST)))); assert.equal(hash(await fs.readFile(path.join(a, MANIFEST))), hash(await fs.readFile(path.join(b, MANIFEST))));
const manifest = await verifyPackage(a); const manifest = await verifyPackage(a,{requireBundled:false});
assert.ok(manifest.entries.some(entry => entry.path === 'release.json')); assert.ok(manifest.entries.some(entry => entry.path === 'release.json'));
for (const absent of ['scripts', 'tests', '.git', 'README.md', 'secret.env', 'mods/hearth/textures/stale.png']) assert.equal(await exists(path.join(a, absent)), false); for (const absent of ['scripts', 'tests', '.git', 'README.md', 'secret.env', 'mods/hearth/textures/stale.png']) assert.equal(await exists(path.join(a, absent)), false);
assert.deepEqual(await fs.readFile(path.join(a, 'mods/hearth/data.png')), binary); assert.deepEqual(await fs.readFile(path.join(a, 'mods/hearth/data.png')), binary);
@@ -114,7 +114,7 @@ test('package manifest rejects missing, extra, corrupted files and extra empty d
if (kind === 'extra') await fs.writeFile(path.join(destination, 'extra'), binary); if (kind === 'extra') await fs.writeFile(path.join(destination, 'extra'), binary);
if (kind === 'corrupt') await fs.appendFile(path.join(destination, 'game.conf'), 'bad'); if (kind === 'corrupt') await fs.appendFile(path.join(destination, 'game.conf'), 'bad');
if (kind === 'directory') await fs.mkdir(path.join(destination, 'empty-extra')); if (kind === 'directory') await fs.mkdir(path.join(destination, 'empty-extra'));
await assert.rejects(verifyPackage(destination), /manifest mismatch/); await assert.rejects(verifyPackage(destination,{requireBundled:false}), /manifest mismatch/);
} }
}); });
@@ -370,19 +370,38 @@ test('CLI package/verify work with absent engine and invalid runtime env; backup
test('new-world terrain preset is pure, explicit, bounded and matches packaged game defaults', async () => { test('new-world terrain preset is pure, explicit, bounded and matches packaged game defaults', async () => {
const first = terrainSettings(true); const first = terrainSettings(true);
const expected = [
'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 32',
'mapgen_limit = 4096',
'mg_flags = caves,nodungeons,light,biomes,ores',
'mgv7_spflags = mountains,ridges,nofloatlands,caverns',
'mgv7_mount_zero_level = 0',
'mgv7_np_terrain_base = 12, 45, (900, 384, 900), 82341, 5, 0.6, 2.0',
'mgv7_np_terrain_alt = -8, 24, (900, 384, 900), 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 = -4, 14, (1400, 320, 1400), 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, (320, 280, 320), 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',
'epic_terrain_enable = true',
];
assert.deepEqual(first, expected);
assert.deepEqual(terrainSettings(false), []); assert.deepEqual(terrainSettings(false), []);
assert.throws(() => terrainSettings('true'), /boolean/); assert.throws(() => terrainSettings('true'), /boolean/);
assert.ok(first.includes('mapgen_limit = 4096')); assert.ok(first.includes('mapgen_limit = 4096'));
assert.ok(first.includes('mgv7_spflags = mountains,ridges,nofloatlands,caverns')); assert.ok(first.includes('mgv7_spflags = mountains,ridges,nofloatlands,caverns'));
assert.ok(first.includes('mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0'));
assert.ok(first.includes('mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0')); assert.ok(first.includes('mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0'));
const defaults = (await fs.readFile(path.join(SOURCE, 'minetest.conf'), 'utf8')) const defaults = (await fs.readFile(path.join(SOURCE, 'minetest.conf'), 'utf8'))
.split(/\r?\n/).filter(isTerrainSetting).sort(); .split(/\r?\n/).filter(isTerrainSetting).filter(line => !line.startsWith('epic_terrain_enable')).sort();
assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed')).sort(), defaults); assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed') && !line.startsWith('epic_terrain_enable')).sort(), defaults);
assert.equal(new Set(first.map(line => line.split('=')[0].trim())).size, first.length); assert.equal(new Set(first.map(line => line.split('=')[0].trim())).size, first.length);
first[0] = 'mg_name = flat'; first.push('untrusted = true'); first[0] = 'mg_name = flat'; first.push('untrusted = true');
assert.equal(terrainSettings(true)[0], 'mg_name = v7'); assert.equal(terrainSettings(true)[0], 'mg_name = v7');
for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1', for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1', 'epic_terrain_enable = true',
'fixed_map_seed = 42', 'mapgen_limit = 512', 'water_level = 1']) assert.ok(isTerrainSetting(line), line); 'fixed_map_seed = 42', 'mapgen_limit = 512', 'water_level = 1']) assert.ok(isTerrainSetting(line), line);
assert.equal(isTerrainSetting('hearth_latitude = 45'), false); assert.equal(isTerrainSetting('hearth_latitude = 45'), false);
}); });
@@ -405,7 +424,7 @@ test('world creation writes terrain once; subsequent ephemeral configs never ret
'mgv7_np_mountain = 1', 'water_level = 30', 'mapgen_limit = 4096', 'mgv7_np_mountain = 1', 'water_level = 30', 'mapgen_limit = 4096',
], 'local'); ], 'local');
const second = await fs.readFile(secondConfig, 'utf8'); const second = await fs.readFile(secondConfig, 'utf8');
assert.deepEqual(second.split(/\r?\n/).filter(isTerrainSetting), []); assert.deepEqual(second.split(/\r?\n/).filter(isTerrainSetting), ['epic_terrain_enable = false']);
assert.match(second, /hearth_latitude = 45/); assert.match(second, /hearth_latitude = 45/);
assert.match(second, /secure.enable_security = true/); assert.match(second, /secure.enable_security = true/);
assert.deepEqual(await fs.readFile(path.join(world, 'world.mt')), worldFile); assert.deepEqual(await fs.readFile(path.join(world, 'world.mt')), worldFile);
@@ -424,7 +443,7 @@ test('world.mt without map metadata and orphan map metadata each suppress creati
await fs.writeFile(path.join(world, name), bytes); await fs.writeFile(path.join(world, name), bytes);
const config = path.join(root, path.basename(world) + '.conf'); const config = path.join(root, path.basename(world) + '.conf');
await writeWorld(world, config, [], ['fixed_map_seed = 731104'], 'local'); await writeWorld(world, config, [], ['fixed_map_seed = 731104'], 'local');
assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), []); assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), ['epic_terrain_enable = false']);
assert.deepEqual(await fs.readFile(path.join(world, name)), bytes); assert.deepEqual(await fs.readFile(path.join(world, name)), bytes);
} }
}); });
@@ -497,7 +516,7 @@ test('play/course/test configs include unique graphics keys for new and existing
} }
assert.ok(lines.includes('secure.enable_security = true')); assert.ok(lines.includes('secure.enable_security = true'));
if (existing) { if (existing) {
assert.deepEqual(lines.filter(isTerrainSetting), []); assert.deepEqual(lines.filter(isTerrainSetting), ['epic_terrain_enable = false']);
assert.deepEqual(await inventory(world), before); assert.deepEqual(await inventory(world), before);
} else { } else {
for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting); for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting);
+47 -10
View File
@@ -8,6 +8,17 @@ import { LIMITS, USER_AGENT, parseCatalogue, apiURL, allowedURL, fetchBatch,
const catalogue = parseCatalogue(await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8')); const catalogue = parseCatalogue(await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8'));
const cached = parseBundle(await readFile(new URL('../mods/hearth/wiki_articles.lua', import.meta.url), 'utf8')); const cached = parseBundle(await readFile(new URL('../mods/hearth/wiki_articles.lua', import.meta.url), 'utf8'));
const additions = [
['crab_apple', 'Malus sylvestris'], ['wild_cherry', 'Prunus avium'], ['field_maple', 'Acer campestre'],
['honeysuckle', 'Lonicera periclymenum'], ['ivy', 'Hedera helix'], ['gorse', 'Ulex europaeus'],
['broom', 'Cytisus scoparius'], ['dandelion', 'Taraxacum officinale'], ['daisy', 'Bellis perennis'],
['red_clover', 'Trifolium pratense'], ['birdsfoot_trefoil', 'Lotus corniculatus'],
['ribwort_plantain', 'Plantago lanceolata'], ['selfheal', 'Prunella vulgaris'],
['knapweed', 'Centaurea nigra'], ['oxeye_daisy', 'Leucanthemum vulgare'],
['field_scabious', 'Knautia arvensis'], ['teasel', 'Dipsacus fullonum'],
['cow_parsley', 'Anthriscus sylvestris'], ['garlic_mustard', 'Alliaria petiolata'],
['holly_fern', 'Polystichum setiferum'],
].map(([id, latin]) => ({ id, latin }));
const retrieved = '2026-11-03T08:00:00.000Z'; const retrieved = '2026-11-03T08:00:00.000Z';
const record = catalogue[0]; const record = catalogue[0];
const intro = 'Quercus robur is a species of flowering plant. This fixture is original test text, not a cached Wikipedia quotation.'; const intro = 'Quercus robur is a species of flowering plant. This fixture is original test text, not a cached Wikipedia quotation.';
@@ -25,10 +36,13 @@ function jsonResponse(data, headers = {}) {
} }
function mock(fn) { return async (...args) => { mockedRequests++; return fn(...args); }; } function mock(fn) { return async (...args) => { mockedRequests++; return fn(...args); }; }
test('bounded non-executing Lua parser finds the actual 80 Latin names and IDs', async () => { test('bounded non-executing Lua parser finds the actual 100 Latin names and multi-underscore IDs', async () => {
assert.equal(catalogue.length, 80); assert.equal(LIMITS.species, 100);
assert.equal(new Set(catalogue.map(r => r.id)).size, 80); assert.equal(catalogue.length, 100);
assert.equal(new Set(catalogue.map(r => r.id)).size, 100);
assert.deepEqual(catalogue.find(r => r.id === 'wood_blewit'), { id: 'wood_blewit', latin: 'Collybia nuda' }); assert.deepEqual(catalogue.find(r => r.id === 'wood_blewit'), { id: 'wood_blewit', latin: 'Collybia nuda' });
assert.deepEqual(catalogue.find(r => r.id === 'sweet_vernal_grass'), { id: 'sweet_vernal_grass', latin: 'Anthoxanthum odoratum' });
assert.deepEqual(catalogue.filter(r => additions.some(a => a.id === r.id)), additions);
const source = await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8'); const source = await readFile(new URL('../mods/hearth/botany.lua', import.meta.url), 'utf8');
for (const bad of [source.replace('Quercus robur', 'https://127.0.0.1/'), for (const bad of [source.replace('Quercus robur', 'https://127.0.0.1/'),
source.replace('Quercus robur', 'Quercus robur|User:Private'), source.replace('Quercus robur', 'Quercus robur|User:Private'),
@@ -37,8 +51,9 @@ test('bounded non-executing Lua parser finds the actual 80 Latin names and IDs',
source.replace('add("birch"', 'add("oak"'), source.replace('add("birch"', 'add("oak"'),
source.replace('"Betula pendula"', '"Quercus robur"'), source.replace('"Betula pendula"', '"Quercus robur"'),
source.replace('add("oak",', 'add(variable,'), source + '\nadd("extra","Extra","Abies alba",', source.replace('add("oak",', 'add(variable,'), source + '\nadd("extra","Extra","Abies alba",',
source.replace(/^\s*add\("holly_fern"[^\r\n]*/m, ''),
source.repeat(10), 'os.execute("not executed")']) assert.throws(() => parseCatalogue(bad)); source.repeat(10), 'os.execute("not executed")']) assert.throws(() => parseCatalogue(bad));
assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 80); assert.equal(parseCatalogue(source + '\nos.execute("never evaluated")').length, 100);
}); });
test('URL construction rejects URL/query/title injection before any request', async () => { test('URL construction rejects URL/query/title injection before any request', async () => {
@@ -230,7 +245,7 @@ test('untrusted Wikipedia text is exclusively UTF-8 byte literals, never executa
assert.throws(() => serializeBundle([{ ...entry, revision: '12345' }])); assert.throws(() => serializeBundle([{ ...entry, revision: '12345' }]));
}); });
test('80 titles use exactly eight mocked batches at concurrency <=2 and stable catalogue order', async () => { test('100 titles use exactly ten mocked batches at concurrency <=2 and stable catalogue order', async () => {
let active = 0, peak = 0, calls = 0; let active = 0, peak = 0, calls = 0;
const seen = []; const seen = [];
const entries = await fetchEntries(catalogue, { now: () => new Date(retrieved), fetchImpl: mock(async url => { const entries = await fetchEntries(catalogue, { now: () => new Date(retrieved), fetchImpl: mock(async url => {
@@ -239,13 +254,18 @@ test('80 titles use exactly eight mocked batches at concurrency <=2 and stable c
assert.ok(names.length <= 10); await setImmediate(); active--; assert.ok(names.length <= 10); await setImmediate(); active--;
return jsonResponse(payload(names.map(name => page(name)))); return jsonResponse(payload(names.map(name => page(name))));
}) }); }) });
assert.equal(calls, 8); assert.equal(peak, 2); assert.equal(calls, 10); assert.equal(peak, 2);
assert.deepEqual(seen, catalogue.map(r => r.latin)); assert.deepEqual(seen, catalogue.map(r => r.latin));
assert.deepEqual(entries.map(r => r.id), catalogue.map(r => r.id)); assert.deepEqual(entries.map(r => r.id), catalogue.map(r => r.id));
assert.equal(entries.length, 80); assert.equal(entries.length, 100);
assert.deepEqual(parseBundle(serializeBundle(entries)), entries);
for (const records of [[], [...catalogue, { id: 'extra', latin: 'Abies alba' }]]) { for (const records of [[], [...catalogue, { id: 'extra', latin: 'Abies alba' }]]) {
await assert.rejects(fetchEntries(records, { fetchImpl: () => { throw new Error('must not fetch'); } }), /scope/); await assert.rejects(fetchEntries(records, { fetchImpl: () => { throw new Error('must not fetch'); } }), /scope/);
assert.throws(() => serializeBundle(records), /scope/);
} }
const extra = { ...entries[0], id: 'extra', latin: 'Abies alba' };
const oversized = serializeBundle(entries).replace(/}\n$/, serializeBundle([extra]).split('return {\n')[1]);
assert.throws(() => parseBundle(oversized), /scope/);
}); });
test('batch failure aborts other workers and never launches the remaining catalogue', async () => { test('batch failure aborts other workers and never launches the remaining catalogue', async () => {
@@ -258,10 +278,20 @@ test('batch failure aborts other workers and never launches the remaining catalo
assert.equal(calls, 2); assert.equal(otherSignal.aborted, true); assert.equal(calls, 2); assert.equal(otherSignal.aborted, true);
}); });
test('shipped bundle contains 78 real intros and two honest missing-page notes for all 80 species', () => { test('shipped bundle retains 78 old intros and two notes plus 20 real new intros for all 100 species', () => {
assert.equal(cached.length, 80); assert.equal(cached.length, 100);
assert.deepEqual(cached.map(({ id, latin }) => ({ id, latin })), catalogue); assert.deepEqual(cached.map(({ id, latin }) => ({ id, latin })), catalogue);
assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 78); assert.equal(cached.filter(e => e.kind === 'wikipedia_intro').length, 98);
const old = cached.filter(e => !additions.some(a => a.id === e.id));
assert.equal(old.length, 80);
assert.equal(old.filter(e => e.kind === 'wikipedia_intro').length, 78);
for (const addition of additions) {
const e = cached.find(e => e.id === addition.id);
assert.equal(e.latin, addition.latin);
assert.equal(e.kind, 'wikipedia_intro');
assert.equal(e.excerpt, true);
assert.ok(e.text.length > 30);
}
assert.deepEqual(cached.filter(e => e.kind === 'original_notes').map(e => e.latin), ['Usnea subfloridana', 'Cladonia chlorophaea']); assert.deepEqual(cached.filter(e => e.kind === 'original_notes').map(e => e.latin), ['Usnea subfloridana', 'Cladonia chlorophaea']);
for (const e of cached) { for (const e of cached) {
assert.ok(Array.from(e.text).length <= 8000); assert.ok(Array.from(e.text).length <= 8000);
@@ -273,6 +303,13 @@ test('shipped bundle contains 78 real intros and two honest missing-page notes f
assert.ok(e.history_url.endsWith('&action=history')); assert.ok(e.history_url.endsWith('&action=history'));
assert.ok(e.revision_url.endsWith('?oldid=' + e.revision)); assert.ok(e.revision_url.endsWith('?oldid=' + e.revision));
assert.match(e.attribution, /^Wikipedia contributors/); assert.match(e.attribution, /^Wikipedia contributors/);
assert.equal(e.license_url, 'https://creativecommons.org/licenses/by-sa/4.0/');
assert.ok(Date.parse(e.revision_timestamp) <= Date.parse(e.retrieved));
} else {
assert.equal(e.kind, 'original_notes');
assert.equal(e.license, 'CC0 1.0');
assert.equal(e.excerpt, false);
for (const field of ['pageid', 'revision', 'revision_url', 'revision_timestamp']) assert.equal(e[field], undefined);
} }
} }
assert.equal(cached.find(e => e.id === 'meadow_fescue').title, 'Lolium pratense'); assert.equal(cached.find(e => e.id === 'meadow_fescue').title, 'Lolium pratense');
+972 -256
View File
File diff suppressed because it is too large Load Diff
+43 -13
View File
@@ -61,7 +61,8 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an
const art=worldArt(); const art=worldArt();
const requiredWorld='stone soil grass_top grass_side sand log_top log_side leaves planks wall roof thatch foundation frame stairs door window vent storage pantry reeds berry_bush berry_bush_bare sapling grain_patch crop_1 crop_2 crop_3 crop_4 clay_bank iron_ore_vein water fire fire_lit torch sticks loose_stone bedroll drying_rack fish_trap weir_stone meadow_grass wildflowers fern litter fungi deadwood tree_seedling young_tree mature_tree dung leaves_spring leaves_autumn leaves_winter meadow_grass_dormant fern_dormant wildflowers_dormant spring_greens spring_greens_dormant autumn_mushrooms autumn_mushrooms_dormant'.split(' '); const requiredWorld='stone soil grass_top grass_side sand log_top log_side leaves planks wall roof thatch foundation frame stairs door window vent storage pantry reeds berry_bush berry_bush_bare sapling grain_patch crop_1 crop_2 crop_3 crop_4 clay_bank iron_ore_vein water fire fire_lit torch sticks loose_stone bedroll drying_rack fish_trap weir_stone meadow_grass wildflowers fern litter fungi deadwood tree_seedling young_tree mature_tree dung leaves_spring leaves_autumn leaves_winter meadow_grass_dormant fern_dormant wildflowers_dormant spring_greens spring_greens_dormant autumn_mushrooms autumn_mushrooms_dormant'.split(' ');
const requiredItems='berries grain grain_seed raw_food cooked_food fish_bait raw_fish cooked_fish fish_drying_charge dried_fish iron_ore iron_ingot charcoal charcoal_charge smelt_charge clay clay_jar clay_jar_charge dried_food dried_food_charge cloak vessel water_raw water_clean firestarter iron_axe spring_greens autumn_mushrooms'.split(' '); const requiredItems='berries grain grain_seed raw_food cooked_food fish_bait raw_fish cooked_fish fish_drying_charge dried_fish iron_ore iron_ingot charcoal charcoal_charge smelt_charge clay clay_jar clay_jar_charge dried_food dried_food_charge cloak vessel water_raw water_clean firestarter iron_axe spring_greens autumn_mushrooms'.split(' ');
const species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce'], rocks=['granite','basalt','limestone','slate','sandstone']; const species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce',
'crab_apple','wild_cherry','field_maple'], rocks=['granite','basalt','limestone','slate','sandstone'];
const understory=['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen']; const understory=['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen'];
const biome=[...rocks,...rocks.map(n=>`${n}_bricks`), const biome=[...rocks,...rocks.map(n=>`${n}_bricks`),
...'forest_floor podzol alluvium peat alpine_turf gravel scree snow quartz_vein copper_ore_vein coal_ore_vein'.split(' '), ...'forest_floor podzol alluvium peat alpine_turf gravel scree snow quartz_vein copper_ore_vein coal_ore_vein'.split(' '),
@@ -69,7 +70,7 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an
const seasonal=species.filter(s=>s!=='pine' && s!=='spruce').flatMap(s=>['spring','autumn','winter'].map(n=>`${s}_leaves_${n}`)); const seasonal=species.filter(s=>s!=='pine' && s!=='spruce').flatMap(s=>['spring','autumn','winter'].map(n=>`${s}_leaves_${n}`));
const dormant=understory.slice(0,6).map(n=>`${n}_dormant`); const dormant=understory.slice(0,6).map(n=>`${n}_dormant`);
assert.deepEqual(SPECIES_NAMES,species); assert.deepEqual(ROCK_NAMES,rocks); assert.deepEqual(SPECIES_NAMES,species); assert.deepEqual(ROCK_NAMES,rocks);
assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,61); assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,73);
assert.deepEqual(UNDERSTORY_NAMES,understory); assert.deepEqual(DORMANT_UNDERSTORY_NAMES,dormant); assert.deepEqual(UNDERSTORY_NAMES,understory); assert.deepEqual(DORMANT_UNDERSTORY_NAMES,dormant);
assert.deepEqual(SPECIES_SEASONAL_NAMES,seasonal); assert.deepEqual(SPECIES_SEASONAL_NAMES,seasonal);
assert.deepEqual([...WORLD_NAMES].sort(),[...requiredWorld,...biome,...seasonal,...understory,...dormant].sort()); assert.deepEqual([...WORLD_NAMES].sort(),[...requiredWorld,...biome,...seasonal,...understory,...dormant].sort());
@@ -78,24 +79,28 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an
'hearth_hotbar.png','hearth_selected.png','hearth_crosshair.png',...ANIMAL_NAMES.map(n=>`hearth_${n}.png`), 'hearth_hotbar.png','hearth_selected.png','hearth_crosshair.png',...ANIMAL_NAMES.map(n=>`hearth_${n}.png`),
...BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)]; ...BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)];
assert.equal(new Set(names).size,names.length); assert.deepEqual([...art.keys()],names); assert.equal(new Set(names).size,names.length); assert.deepEqual([...art.keys()],names);
assert.equal(art.size,360); assert.equal(WORLD_NAMES.length,160); assert.equal(ITEM_NAMES.length,34); assert.equal(art.size,421); assert.equal(WORLD_NAMES.length,181); assert.equal(ITEM_NAMES.length,34);
for(const [name,image] of art) { for(const [name,image] of art) {
assert.match(name,/^hearth_[a-z0-9_]+\.png$/); assert.equal(image.width,32); assert.equal(image.height,32); assert.match(name,/^hearth_[a-z0-9_]+\.png$/); assert.equal(image.width,32); assert.equal(image.height,32);
assert.equal(image.data.length,4096); assert.ok(image.data.some(n=>n>0)); assert.equal(image.data.length,4096); assert.ok(image.data.some(n=>n>0));
} }
}); });
test('all 80 exact catalogue IDs and Latin identities match the actual Lua records, with two manifest states each',async()=> { test('all 100 exact catalogue IDs and Latin identities match the actual Lua records, with two manifest states each',async()=> {
const source=await fs.readFile(new URL('../mods/hearth/botany.lua',import.meta.url),'utf8'); const source=await fs.readFile(new URL('../mods/hearth/botany.lua',import.meta.url),'utf8');
const rows=[...source.matchAll(/^\s*add\("([a-z_]+)","[^"]+","([^"]+)"/gm)].map(([,id,latin])=>({id,latin})); const rows=[...source.matchAll(/^\s*add\("([a-z_]+)","[^"]+","([^"]+)"/gm)].map(([,id,latin])=>({id,latin}));
assert.equal(rows.length,80); assert.equal(new Set(rows.map(r=>r.id)).size,80); assert.equal(rows.length,100); assert.equal(new Set(rows.map(r=>r.id)).size,100);
assert.equal(new Set(rows.map(r=>r.latin)).size,80); assert.equal(new Set(rows.map(r=>r.latin)).size,100);
assert.deepEqual(BOTANY_ART_NAMES,rows.map(r=>r.id)); assert.deepEqual(BOTANY_ART_NAMES,rows.map(r=>r.id));
assert.deepEqual(BOTANY_CATALOGUE.map(({id,latin})=>({id,latin})),rows); assert.deepEqual(BOTANY_CATALOGUE.map(({id,latin})=>({id,latin})),rows);
const groups=['trees','shrubs','grasses','herbs','ferns_mosses','fungi','aquatic','lichens']; const groups=['trees','shrubs','grasses','herbs','ferns_mosses','fungi','aquatic','lichens'];
for(const [i,g] of groups.entries()) assert.deepEqual(BOTANY_CATALOGUE.slice(i*10,i*10+10).map(r=>r.group),Array(10).fill(g)); const counts=[13,14,10,22,11,10,10,10]; let offset=0;
assert.deepEqual(BOTANY_ART_NAMES.slice(0,10),SPECIES_NAMES); for(const [i,g] of groups.entries()) {
assert.equal(BOTANY_ART_MANIFEST.length,160); assert.deepEqual(BOTANY_CATALOGUE.slice(offset,offset+counts[i]).map(r=>r.group),Array(counts[i]).fill(g));
offset+=counts[i];
}
assert.deepEqual(BOTANY_ART_NAMES.slice(0,13),SPECIES_NAMES);
assert.equal(BOTANY_ART_MANIFEST.length,200);
assert.deepEqual(BOTANY_ART_MANIFEST.map(r=>r.filename),BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)); assert.deepEqual(BOTANY_ART_MANIFEST.map(r=>r.filename),BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`));
for(const r of BOTANY_ART_MANIFEST) { for(const r of BOTANY_ART_MANIFEST) {
assert.equal(r.botanicalLatin,rows.find(row=>row.id===r.id).latin); assert.equal(r.botanicalLatin,rows.find(row=>row.id===r.id).latin);
@@ -104,7 +109,7 @@ test('all 80 exact catalogue IDs and Latin identities match the actual Lua recor
} }
}); });
test('80 recognizable botanical cutouts have unique silhouettes, crisp padded alpha, and 80 distinct bloom-free inactive drawings',()=> { test('100 recognizable botanical cutouts have unique silhouettes, crisp padded alpha, and 100 distinct bloom-free inactive drawings',()=> {
const art=worldArt(), activeMasks=new Set(), dormantMasks=new Set(), allBytes=new Set(); const art=worldArt(), activeMasks=new Set(), dormantMasks=new Set(), allBytes=new Set();
for(const id of BOTANY_ART_NAMES) { for(const id of BOTANY_ART_NAMES) {
const active=tile(art,`botany_${id}`), dormant=tile(art,`botany_${id}_dormant`); const active=tile(art,`botany_${id}`), dormant=tile(art,`botany_${id}_dormant`);
@@ -122,9 +127,9 @@ test('80 recognizable botanical cutouts have unique silhouettes, crisp padded al
if(state==='dormant') assert.ok(ps.every(p=>!p[3] || !(p[1]>p[0] && p[1]>p[2])),`${id}: dry inactive state`); if(state==='dormant') assert.ok(ps.every(p=>!p[3] || !(p[1]>p[0] && p[1]>p[2])),`${id}: dry inactive state`);
} }
} }
assert.equal(activeMasks.size,80,'no two taxa masquerade as the same recoloured silhouette'); assert.equal(activeMasks.size,100,'no two taxa masquerade as the same recoloured silhouette');
assert.equal(dormantMasks.size,80,'each inactive species retains its own structure'); assert.equal(dormantMasks.size,100,'each inactive species retains its own structure');
assert.equal(allBytes.size,160); assert.equal(allBytes.size,200);
}); });
test('botanical diagnostic landmarks distinguish flowers, pinnate ferns, moss cushions, fungi and true floating lily pads',()=> { test('botanical diagnostic landmarks distinguish flowers, pinnate ferns, moss cushions, fungi and true floating lily pads',()=> {
@@ -161,6 +166,31 @@ test('approved leaf PNG bytes survive the catalogue extension without rewriting
} }
}); });
test('twenty additions retain exact contract identities and diagnostic flowers, climbing structures, seedheads and fern forks',()=> {
const additions='crab_apple wild_cherry field_maple honeysuckle ivy gorse broom dandelion daisy red_clover birdsfoot_trefoil ribwort_plantain selfheal knapweed oxeye_daisy field_scabious teasel cow_parsley garlic_mustard holly_fern'.split(' ');
const art=worldArt(),has=(id,color)=>pixels(tile(art,`botany_${id}`)).some(p=>p[3]&&p.slice(0,3).join()===PALETTE[color].join());
assert.equal(additions.length,20);
for(const id of additions) {
assert.ok(BOTANY_ART_NAMES.includes(id));
assert.equal(BOTANY_ART_MANIFEST.filter(r=>r.id===id).length,2);
const active=tile(art,`botany_${id}`),dry=tile(art,`botany_${id}_dormant`);
assert.notDeepEqual(active.data,dry.data);
const upper=pixels(active).filter((p,i)=>p[3]&&Math.floor(i/32)<18).length;
assert.ok(upper>8,`${id}: diagnostic upper specimen, not only a generic basal tuft`);
}
for(const [id,colors] of Object.entries({honeysuckle:['cream','gold','rose'],ivy:['ochre','barkLight'],
gorse:['gold','ochre'],broom:['gold','leafLight'],dandelion:['gold','ochre'],daisy:['cream','gold'],
red_clover:['wine','mauve'],birdsfoot_trefoil:['gold','ochre'],ribwort_plantain:['cream','bark'],
selfheal:['wine','mauve'],knapweed:['wine','rose'],oxeye_daisy:['cream','gold'],
field_scabious:['mauve','rose'],teasel:['wood','mauve'],cow_parsley:['cream','sage'],
garlic_mustard:['cream','straw'],holly_fern:['ochre','leafLight']}))
for(const color of colors) assert.ok(has(id,color),`${id}: ${color} diagnostic landmark`);
// Ivy's climbing support/rootlets remain through dormancy; honey tubes are
// narrow at the mouth, unlike a daisy's radial head or a teasel's seed cone.
for(const y of [8,12,16,20,24]) assert.ok([12,13,14].some(x=>pixel(tile(art,'botany_ivy'),x,y)[3]===255));
assert.ok(pixels(tile(art,'botany_garlic_mustard_dormant')).filter(p=>p[3]).length>50);
});
test('every tile is deterministic original clustered palette art, not independent speckle noise or a recolored square',()=> { test('every tile is deterministic original clustered palette art, not independent speckle noise or a recolored square',()=> {
const first=worldArt(), second=worldArt(); const first=worldArt(), second=worldArt();
const allowed=new Set(Object.values(PALETTE).map(c=>c.join(','))), unique=new Set(); const allowed=new Set(Object.values(PALETTE).map(c=>c.join(','))), unique=new Set();