diff --git a/README.md b/README.md index 85d6e80..3646c19 100755 --- a/README.md +++ b/README.md @@ -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** 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, -contributor attribution and **CC BY-SA 4.0** licensing. The other two species have -explicit original CC0 availability notes, not counterfeit articles. **Current +contributor attribution and **CC BY-SA 4.0** licensing. Missing snapshots have +explicit availability notes, not counterfeit articles; cache expansion is a separate task. **Current effects** distinguishes simulated game policy from descriptive natural history. This is not a full wiki, embedded browser, or real-world identification/foraging guide. Nothing is fetched or automatically opened; inspection writes no world or @@ -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. The [landscape/content expansion](docs/content-expansion.md) now wires ten -communities, ten tree species, an 80-species botanical catalogue (eight categories -of ten) and ordered geology into the seasonal ecosystem. The trees are oak, -birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce. All ten logs +communities, thirteen tree species, a 100-species botanical catalogue and ordered +geology into the seasonal ecosystem. Its eight ecological groups are deliberately +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 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 @@ -230,14 +233,19 @@ recovery playtest or public-service disaster recovery certification. See the - **I** opens crafting and field notes; `/guide` reopens help. - **Ecosystem survey** in the field journal, or `/ecosystem`, observes one small - loaded habitat patch and reports global active hare/deer/fox totals. It does not + 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. - **Botanical guide** in the journal/survey, or `/botany [search]`, browses the actual catalogue, ten species per page. Search is literal, escaped and limited to 48 characters; it includes common/scientific names, category, habitat, seasons and ecological roles. Toxicity flags are game labels, **not real-world food-safety advice**; unflagged does not mean edible. Missing catalogue APIs - display an unavailable message. See the [category list and role limits](docs/content-expansion.md#eighty-species-botanical-field-guide). + 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 supported walls/roof and fuel a stone hearth with sticks/logs. - 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. Light, shade, moisture and competition shape meadow grass, wildflowers, ferns and slow tree succession. Litter, fungi and dung recycle finite nutrients; grazing -opens gaps and wildlife links habitat to the food web. Wild succession is bounded, -loaded-only, pauses across unloading/restart and never replays an offline backlog. +debits finite forage and wildlife links habitat to the food web. Calendar organism +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; existing crop and planted-sapling clocks keep their separate rules. Survey counts cover a fixed 5×5×5 patch at your feet, not the whole landscape; unloaded cells are explicitly unknown. Wildlife totals describe all currently active animals, not a local census or saved population. There is no compulsory ecological quest. +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 materials with the eom.dev Mocha interface: base `#1e1e2e`, mantle `#181825`, raised `#292940`, border `#3c3d56`, pale `#cdd6f4` text and `#a6adc8` annotations. Mauve diff --git a/docs/art-direction.md b/docs/art-direction.md index 1fe666b..16ff35d 100755 --- a/docs/art-direction.md +++ b/docs/art-direction.md @@ -1,7 +1,203 @@ # 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) +**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 below; botanical art itself is unchanged. Character work owns only [scripts/media.mjs](../scripts/media.mjs), diff --git a/docs/botany.md b/docs/botany.md index 657aa79..68d5932 100755 --- a/docs/botany.md +++ b/docs/botany.md @@ -1,8 +1,8 @@ -# Botany: 80 temperate taxa and integration contract +# Botany: 100 temperate taxa and calendar integration ## 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: @@ -19,11 +19,38 @@ The numeric light/moisture/pH/depth windows below are **explicit game approximat ## Exact ID manifest (art, trees and ecology handoff) -`id` is the canonical catalogue key. Every non-tree node is exactly `hearth:`. Every tree's initial node is `hearth:_seedling`; tree stages/resources belong to the diversity/resources owner. **Do not create a second generic berry bush or alias a generic patch into this count.** Existing generic `berry_bush`, `fern`, `wildflowers` and `meadow_grass` remain outside the 80. +`id` is the canonical catalogue key. Every non-tree node is exactly `hearth:`. Every tree's initial node is `hearth:_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. -Art contract for **all 80**: `hearth_botany_.png`. This module uses that exact tile for each of its **62 new understory nodes**. Trees use species bark cubes and full-block leaf crowns supplied by diversity/terrain; no seasonal tree mesh is introduced on a cube. Seasonal copying preserves bark/end-grain faces, `facedir`, callbacks and drops; foliage gets its own species-season texture. Assets and real-engine visual validation remain the art/main integration owners' responsibility. +Art contract for **all 100**: `hearth_botany_.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) @@ -152,7 +179,7 @@ Loading the file returns **`factory(hearth, api) -> B`**. The factory has no reg ### Catalogue/query APIs - `B.groups`, `B.group_order`: ordered arrays `trees, shrubs, grasses, herbs, ferns_mosses, fungi, aquatic, lichens`. -- `B.catalogue`: ordered 80-record snapshot, group order then manifest row order. +- `B.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`. - `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. @@ -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. -`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 @@ -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.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.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.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. @@ -196,7 +224,7 @@ Surface depth ranges/anchors are intentionally coarse: even floating duckweed us ### 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. @@ -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. -## 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. -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 -`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. -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. ## 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. diff --git a/docs/character-creator.md b/docs/character-creator.md index 32b2e34..6047f91 100755 --- a/docs/character-creator.md +++ b/docs/character-creator.md @@ -8,18 +8,19 @@ forms retain priority. ## A quiet traveler, not a costume catalogue All choices are inclusive and independent, with no gender, identity or stat -fields. Compact, balanced and broad silhouettes have identical mechanics, -height, camera, collision, reach, movement and attachments. Natural hair, muted -mineral cloth, undyed linen and worn leather fit the woodland world. +fields. Compact, lean, balanced, broad and sturdy silhouettes have identical +mechanics, height, camera, collision, reach, movement and held-tool anchors. +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: | Tab | Choices | | --- | --- | -| Face | 3 body shapes, 6 skin tones, 4 face-line patterns, 5 eye colors | -| Hair | 7 styles, 6 shared hair/facial-hair colors, 5 facial-hair styles | -| Clothing | 4 garments, 8 cloth colors, 4 trousers, 3 boot leathers | -| Finishing | 5 accents, 8 accent colors, 4 face-detail patterns | +| Face | 5 body silhouettes, 6 skin tones, 5 face-line patterns, 5 eye colors | +| Hair | 10 styles, 6 shared hair/facial-hair colors, 7 facial-hair styles | +| Clothing | 7 garments, 8 cloth colors, 4 trousers, 3 boot leathers | +| Finishing | 9 accents, 8 accent colors, 5 face-detail patterns | Dropdowns use readable names, not gender categories or free-form colors. Cloth and accent palettes are sage, teal, slate, mauve, ochre, rust, linen and charcoal. @@ -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, custom path, uploaded texture or free-form hexadecimal color. Every mesh and layer filename is built solely from fixed IDs. Compositing order is skin, face, -eyes, garment, trousers, boots, accent, hair, beard, detail: clothing must stay -below long hair. Face overlays are skin-neutral; there is no global color tint. +eyes, garment, trousers, boots, hair, beard, accent, detail; accents layer above +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. Apply serializes and checks the roundtrip, then writes metadata **before** diff --git a/docs/ecosystems.md b/docs/ecosystems.md index d5da4c3..52184f9 100755 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -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 moisture; prolonged shade kills sun-loving ground cover. Rivers and biome humidity also influence moisture. Established sparse meadow can recruit a tree seedling; -seedlings become young trees, then mature seed-bearing trees and finally deadwood. +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 trees shed litter and disperse seedlings. Competition and occupation reject seeds; seedlings never replace flowers, crops, another tree, or even existing meadow grass. Wildlife can consume active natural grass, ferns, clover, sedge and nettles and -leave dung. Dormant winter plants cannot supply food or breeding habitat. Grazing opens gaps -but imposes a five-minute recolonization cooldown. Dung, litter and deadwood +leave dung. Dormant winter plants cannot supply browse. Grazing debits one partial +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 nutrients into the supporting soil (capped at eight). Fertile soil recruits ground 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 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 | | --- | --- | @@ -55,17 +57,22 @@ other timber, rewrite surrounding nodes, or regenerate existing terrain. | Grass/flower/fern dispersal eligibility | 15 minutes; attempt every third evaluation | | Severe niche stress to litter/deadwood | 10 minutes | | Old meadow recruits a seedling | 2 hours; 4 in dense unfertilized grass, only suitable spatial niches | -| Seedling to young tree | 6 hours | -| Young to mature tree | 24 additional hours | -| Mature tree death | 72 additional hours | -| Mature seed dispersal | After 30 minutes; every tenth evaluation | -| Wildflower / grass and fern senescence | 4 / 6 hours | -| Dung / litter / fungi / deadwood decay | 10 / 30 / 40 / 120 minutes | +| Named seedling to young tree | Absolute juvenile-age endpoint from its lifecycle profile (default 5 real years) | +| Named young to mature tree | Absolute species maturity endpoint, not an additional stage-local duration | +| Generic unidentified tree | Explicit fallback: young after 1 real year, mature after 10; lifespan unknown | +| Living calendar plant death | Never inferred from lifespan endpoints or unknown age | +| Mature tree seed opportunity | At most one present eligible attempt per mean real year; no missed-event replay | +| 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 -own age. Cold weather below 3°C suppresses establishment and living reproduction/ -stage transitions. Decomposition and stress remain possible. This is a practical -niche model, not a botanical or hydrological solver. Young/mature identities are +New tree generations take actual calendar years, not compressed sessions. One +day is 86,400 seconds, a mean year 365.2425 days. Every named plant uses +[the lifecycle policy](lifecycles.md), including explicitly unverified group +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 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 @@ -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; the plan's omitted air cells are never permission to clear or replace anything. Existing mapgen log/leaves trees contribute shade but are not aged or destroyed. -Player-planted saplings, crops and berry bushes retain their existing time contract. +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 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:_seedling`, `hearth:_young`, and -`hearth:_mature` nodes. The six-hour/24-hour/72-hour stage durations above -remain unchanged. Growth and seed dispersal preserve the parent's species, and -death records `succ_species` on generic deadwood (retained through its litter -transition). Each young/mature root drops **one** of its species logs, never three +`hearth:_mature` nodes. Growth and seed dispersal preserve the parent's +species and calendar anchor; old accelerated stage/death durations are removed. +Each young/mature root drops **one** of its species logs, never three logs from one cube. Additional generated trunk cubes are separately harvestable. Roots are hidden from creative inventory, use species log top/end and bark side textures, `drawtype="normal"`, `tree=1`, `choppy=2`, and default cube collision. @@ -101,7 +145,9 @@ Recruitment is deterministic, not an all-oak fallback: - Oak favors stable forest floor with an existing canopy. - Beech has a broad domed crown and avoids saturation; aspen is tall and columnar; rowan has an open crown; hazel is low coppice; spruce has a narrow evergreen spire. - All ten have distinct mature block silhouettes and deterministic recruitment. + 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. Riparian recruitment requires moisture ≥0.65. Species soil support is delegated 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 the read-only `habitat(pos).pollination` count. This exposes a bounded habitat input -for berry-regrowth consumers; this module does **not** change berry timers or grant -extra berries. `edible` is a separate active-natural-forage count. Pine and spruce +for consumers; habitat reads never change berry timers or grant extra berries. +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 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 is no general adoption of arbitrary flora, generic litter or fungi, and no new scanning scheduler. Adopted deadwood needs loaded natural ecological soil and -decays to litter after **two observed active hours**, releasing three nutrients +decays to litter after **one mean calendar year**, releasing three nutrients (soil cap eight); subsequent litter decay releases one more, once. Adoption is -idempotent and grants no age or absence catch-up. As with +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 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 | | Autumn mushroom patch | Dormant | Dormant | Pickable | Dormant | -Frost and seasonal dormancy pause living age, stress and reproduction; they do not -kill perennials at first frost. Spring resumes the same ecological age. Deadwood, +Frost and seasonal dormancy pause visible growth, loaded stress and reproduction, +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 -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. 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 +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 | | --- | --- | --- | | Common alder (*Alnus glutinosa*), white clover (*Trifolium repens*) | After 900 warm active seconds, add at most one nutrient per eligible evaluation, never above local fertility four. **Two units total per supporting soil node**, recorded in `succ_fixed_nitrogen`, shared across species, recolonization and restart; soil's universal cap remains eight. Alder seedling, young and mature roots resolve through the same species ID; generated logs/leaves do not earn another contribution. | [Alder, Ecology](https://en.wikipedia.org/wiki/Alnus_glutinosa#Ecology) describes *Frankia* nodules and fertility; [white clover, Forage](https://en.wikipedia.org/wiki/Trifolium_repens#Forage) describes symbiotic fixation. The finite lifetime cap is game balance, not an annual nitrogen flux or physiological exhaustion claim. | | Dog lichen (*Peltigera canina*) | Uses the same existing finite nitrogen budget, never a fertilizer item or independent annual allowance. The pre-existing lungwort-lichen nitrogen tag retains that shared policy; its species-effect evidence is not independently reviewed here. | [Dog lichen, Description](https://en.wikipedia.org/wiki/Peltigera_canina#Description) identifies *Nostoc*; [Peltigera, Description](https://en.wikipedia.org/wiki/Peltigera#Description) explains nitrogen fixation. Explicit species identity plus genus-level mechanism, not a measured species rate. | | Blunt-leaved bog-moss (*Sphagnum palustre*) | A natural, active mat on loaded, permitted peat/alluvium at the sample's ground level adds **0.10 moisture** and subtracts **0.20 pH** from the local **land/bank habitat estimate**, capped at **two mats** (+0.20 / -0.40); moisture clamps to 0..1, pH to 3.5..8.5. This changes actual recruitment/stress niche tests. | [S. palustre](https://en.wikipedia.org/wiki/Sphagnum_palustre) describes water absorption; [Sphagnum](https://en.wikipedia.org/wiki/Sphagnum) explains water retention and acidification by cation exchange. Acidification evidence is genus-level. Coefficients are game approximations, not measured pH changes per plant. No permanent chemistry, peat creation, terrain conversion or global hydrology. | | Common duckweed (*Lemna minor*) | Each natural active mat immediately above conserved source water at the sample's stand level reduces nearby **submerged freshwater** habitat light by **1.5**, capped at four mats / six light units. It never brightens a dark site, and never lowers light below two. Surface plants and terrestrial sites are not shaded by this rule. | [L. minor, Cultivation](https://en.wikipedia.org/wiki/Lemna_minor#Cultivation) describes floating carpets; [Lemnoideae, Duckweed in natural environments](https://en.wikipedia.org/wiki/Lemnoideae#Duckweed_in_natural_environments) describes shade. This is a local group-level shading approximation, not an algae, oxygen, contaminant-removal or nutrient-purification simulation. | -| Host-associated fungi, including fly agaric (*Amanita muscaria*) | An active compatible nearby fungus permits a tree seedling's young-stage transition at **16,200 rather than 21,600 active seconds**. Only a successful transition consumes **at most one existing soil nutrient**. Multiple fungi select the maximum allowance, not a sum; zero nutrients means no early growth. | [Fly agaric, Distribution and ecology](https://en.wikipedia.org/wiki/Amanita_muscaria#Distribution_and_ecology) describes ectomycorrhizal associations. Game host lists are narrower than the article. The existing growth debit is not nutrient creation, a full carbon-for-mineral ledger, or simulated water transfer. | -| Saprotrophic wood fungi, including birch polypore (*Fomitopsis betulina*) | On suitable natural deadwood/litter only, advance its existing decay age by at most **60 seconds per eligible evaluation**, capped at 7,200 / 1,800. The ordinary substrate transition consumes deadwood to litter to air, paying **three then one** soil nutrients once, subject to cap eight. Removing the support removes its natural fungal occupant. The fungus itself has no extra nutrient payout. Soil-growing saprotrophs receive no special nutrient effect. | [Birch polypore, Distribution, habitat and ecology](https://en.wikipedia.org/wiki/Fomitopsis_betulina) describes birch-specific brown rot. Existing host suitability remains authoritative. Natural parasitism on weakened living birches is **not simulated**. No attacks on living/player trees. | +| 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 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 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. - Only `get_node_or_nil` reads are used. Missing/ignore nodes fail closed. There is no emerge, force-load, mapgen edit, ABM, per-node timer or offline replay. -- Ages accrue from consecutive observations, not from absolute planting dates. +- 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, 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 @@ -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 metadata; soil stores only its nutrient count. There is no global save schema. Durable attempt timestamps/serials prevent repeated evaluation or rerolling - a blocked seed by reconnecting/restarting. Restarting pauses growth; it cannot - accelerate it. Ages are sampled approximations between visits, not per-tick - accounting; very busy/many-player worlds may deliberately progress more slowly. + a blocked seed by reconnecting/restarting. Restarting cannot accelerate or + reset birthdays, organic clocks or resource receipts. Calendar eligibility + 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 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. @@ -316,9 +378,52 @@ Lua tests, not a claim of full-engine or multiplayer validation. `hearth.succession` is installed by the content registrar: -- `forage(pos) -> boolean`: consume exactly one nearby active natural - `meadow_grass`, `fern`, `clover`, `sedge` or `nettle` on safe natural support. - Does not eat dormant flora, flowers, crops or player plants. +- `forage(pos) -> boolean`: debit one partial canopy ration from one nearby + active natural browse plant on safe support. The plant remains rooted; + `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 **air** over safe natural support. Never replaces existing plants or water. 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 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 ### 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 -The blockshape change owns only diversity, content, biomes, their three focused -tests and this document. Succession, ecology, seasons, botany and generated assets -remain other owners' responsibility. Exact integrated order: +The calendar expansion owns only the six requested flora/ecology modules, their +six focused tests and the two botany/ecosystem documents. Bootstrap, lifecycle +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)`. 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)`. This factory immediately extends `seasons.add_variants` with species policy. 4. Instantiate succession using its factory and assign `hearth.succession`. -5. After normal content, register/install biomes (ten species' logs/leaves/planks). +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: - 30 species stage nodes, eight existing understory nodes and the bootstrap LBM. -7. Call `hearth.botany.register_nodes(register_node)`: exactly 62 new non-tree taxa, - with all explicit tiles and inventory/wield images preserved. The ten trees + 39 species stage nodes, eight existing understory nodes and the bootstrap LBM. +7. Call `hearth.botany.register_nodes(register_node)`: exactly 79 new non-tree taxa, + with all explicit tiles and inventory/wield images preserved. The thirteen trees and eight existing understory nodes are not registered twice. 8. Call `seasons.register()` after every canonical node, then instantiate wildlife. The returned diversity table exposes: -- `species_order`, `plants`: ordered ten/eight canonical short names. +- `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)`, `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; @@ -409,7 +544,7 @@ The returned diversity table exposes: ### Geometry handoff to ecological growth `diversity.tree_plan(species, stage?) -> plan | nil` is read-only. Species is one -of the ten IDs or `"generic"` for legacy trees; stage is `"young"` or `"mature"` +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. `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 -For **all ten** species in `species_order`: +For **all thirteen** species in `species_order`: - Seedlings use `hearth_botany_.png` on a plantlike sprite. Young/mature roots and species timber use `hearth__log_top.png` on both ends and @@ -470,10 +605,10 @@ heather, bluebell, sedge and bulrush additionally use has `_summer` and `_autumn` node variants using its same dormant texture. Moss and lichen have no dormant art. No art is generated or modified here. -With all biome leaves installed, diversity adds **142 nodes**: 38 canonical nodes, -72 deciduous stage variants, 24 deciduous full-block leaf variants and eight +With all biome leaves installed, diversity adds **187 nodes**: 47 canonical nodes, +99 deciduous stage variants, 33 deciduous full-block leaf variants and eight understory variants. Pine and spruce add no seasonal variants. Botany independently -adds 62 nodes. Species provenance on generic deadwood remains metadata. +adds 79 nodes. Species provenance on generic deadwood remains metadata. The standalone succession fixture tests growth, niches, shade, competition, seedlings, seeds, nutrient feedback, dung/fungi/decay, grazing, source/destination/ @@ -484,10 +619,10 @@ all four seasons, northern/southern calendars, seasonal foliage/restoration, metadata/age conservation, forage cooldowns/restart, niches/occupation, protected and unloaded no-write cases, target-volume clearance and the 96-swap bound. The blockshape-focused standalone suites are diversity, biomes and content; -botany has its separate catalogue suite. They check exact ten-species ordering, +botany has its separate catalogue suite. They check exact thirteen-species ordering, distinct mature schematics, bounded young canopies, shared rooted geometry, -metadata-safe deadwood migration, axe-only harvesting, all 78 unique recipes, -62 additional botany nodes and preserved aquatic icons/placement guards. +metadata-safe deadwood migration, axe-only harvesting, unique material recipes, +79 additional botany nodes and preserved aquatic icons/placement guards. Engine visuals, multiplayer ecology balance and long-session woodland density still require in-engine playtesting; unit fixtures are not a performance benchmark. diff --git a/docs/fauna.md b/docs/fauna.md new file mode 100755 index 0000000..bb9ff87 --- /dev/null +++ b/docs/fauna.md @@ -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__` 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_.b3d` and `hearth_fauna_.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. diff --git a/docs/lifecycles.md b/docs/lifecycles.md new file mode 100755 index 0000000..bec4711 --- /dev/null +++ b/docs/lifecycles.md @@ -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. diff --git a/docs/species-references.md b/docs/species-references.md index 153099c..230dea1 100755 --- a/docs/species-references.md +++ b/docs/species-references.md @@ -2,16 +2,43 @@ ## 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 | | --- | --- | --- | | `old_mans_beard` | *Usnea subfloridana* | [Exact species page](https://en.wikipedia.org/wiki/Usnea_subfloridana) missing | | `cup_lichen` | *Cladonia chlorophaea* | [Exact species page](https://en.wikipedia.org/wiki/Cladonia_chlorophaea) missing | -For both missing species, the API explicitly returned `missing: true`; separate exact-page and REST-summary checks returned HTTP 404. No genus, species complex, similarly named lichen, invented revision, or synthetic Wikipedia prose was substituted. Consequently **80 actual species article excerpts are blocked by source availability**. The original notes make this visible to offline players. Refresh can pick up an article if Wikipedia creates one later. +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: @@ -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 oak` or `/inspect Quercus robur` opens an exact catalogue ID or canonical Latin name. Matching is exact and case-sensitive (surrounding whitespace is allowed), not fuzzy, common-name search or an arbitrary URL. `/botany [search]` remains the literal-search catalogue browser. - Field notes offer **Inspect target**. The Botanical guide keeps its ten-row pages and adds a **Species article** dropdown/button for those rows. Selections are bounded numeric indices into the server session's current-page IDs, never submitted IDs or URLs. A HUD hint on known botanical targets advertises `/inspect species` (replace “species” with an ID, or use bare `/inspect` for the target). No harvesting or building callbacks are hijacked. -- The **Article** tab displays **Wikipedia excerpt (offline)** for the 78 real introductions. The two missing species use **No Wikipedia article cached (offline)** and show their original CC0 availability notes, never fake Wikipedia prose or revisions. No HTML, embedded browser or full-article availability is claimed. +- 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. - 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. @@ -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. - 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 @@ -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: -- `--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`. - `--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 - 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. - 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. @@ -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. -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. diff --git a/docs/world-generation.md b/docs/world-generation.md index b91a363..2f89e7e 100755 --- a/docs/world-generation.md +++ b/docs/world-generation.md @@ -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 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 -engine-playtested preset. Main should place it in game defaults and its -launcher **new-world creation** path, preserving existing saved mapgen params. -The current launcher writes `mg_name=v7` and seed 731104, but does not yet emit -these tuned noise values. Merely changing game defaults is not sufficient to -prove that managed-launcher new worlds received them. +New Last Hearth worlds use stock v7 as a cave/ore substrate and then the original +`epic_terrain` Lua mapgen mod reshapes each freshly generated mapchunk with +seeded, world-coordinate noise. The mod lives in the separate +`luanti-mapgenerator-epic` workspace repository and is bundled into the managed +Last Hearth runtime package. It does not copy Epic Terrain code, JSON density +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 mg_name = v7 -water_level = 1 -mapgen_limit = 512 -mg_flags = caves,nodungeons,light,decorations,biomes,ores +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, 65, (384, 384, 384), 82341, 5, 0.6, 2.0 -mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0 +mgv7_np_terrain_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 = -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_mount_height = 230, 100, (700, 700, 700), 72449, 3, 0.6, 2.0 -mgv7_np_mountain = -0.6, 1, (180, 280, 180), 5333, 5, 0.63, 2.0 +mgv7_np_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 ``` -Why these parameters: +Design and source-grounded limits: -- Base/alternate terrain is lifted from stock offset 4 to 12, while retaining - an accessible low-relief alternate surface. This reduces ocean dominance - without pretending noise can guarantee a dry, safe spawn. -- Broad 384-node hills and 700-node mountain/river structure fit the current - small bounded world. Mountain density retains stock −0.6 bias; its longer - vertical than horizontal spread favors grounded massifs over spiky rubble. - Mount-height noise controls a density gradient, **not** an absolute peak cap. -- Ridges retain v7's native water-level channels and canyon walls. Neither - arbitrary block decorations nor exaggerated height offsets manufacture - cliffs. The river-width threshold remains the stock C++ value. -- 256-node climate fields make multiple coherent communities plausible inside - the ±512 limit; stock 1000-node climate fields can hide most variety there. - Existing small-scale heat/humidity blend noises can remain stock. -- Floatlands are explicitly disabled; native mountain noise can still produce - small overhangs/disconnected remnants. This is stock v7, not a guarantee of - mathematically erosion-correct terrain. Dungeons are disabled so unknown - mapgen dungeon aliases do not create unrelated structures. +- Continentalness is shaped by 5100-node plate and 1750-node warped-shelf fields; + a protected 620-node radius around world origin gives first arrival a dry + meadow, while distant islands, peninsulas and ocean shelves continue to vary + by seed. +- Broad uplift plus ridged mountain fields creates plains, alpine massifs and + high snow surfaces. A separate coast band uses a sharper elevation step for + sea cliffs and indented shore profiles. +- Major and minor river corridors are carved using separate warped ridge fields. + Their water levels descend in short quantized steps, creating waterfalls; + lakes form in closed noise basins. This is intentionally stylized flow, not a + global watershed/drainage solver, so a river need not reach the ocean in every + seed. +- `epic_terrain` keeps native v7 cave voids and ore nodes where possible, then + writes its own soil, water, block-cube trees and understory. Native v7 + decorations are disabled for newly created worlds so flora does not float + above the reshaped land. +- 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 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 extrude surface rock unchanged to the bottom of the map. -No full engine was run for this owner task. Main's remaining native gate is a -fresh disposable world with explicit saved preset verification, multiple seed -starts (including 731104), resource reachability, actual tree anchoring/media -loading and representative valley/forest/alpine views. Do not test by opening -the user's persistent world. Neither performance nor safe spawning is certified -by the standalone checks. +The native `test:seeds` gate creates eight disposable worlds, including seed +731104, and checks startup resources plus safe arrival; it is not a terrain +silhouette or biome-traversal test, and it does not require every seed to start +on dry land at the origin. A representative coastal, valley, river and alpine +visual survey is still needed before claiming those aesthetics. Never test by +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, filesystem writes, deserialization or player-supplied executable input are diff --git a/game.conf b/game.conf index 51713b8..54b6f01 100755 --- a/game.conf +++ b/game.conf @@ -3,5 +3,5 @@ author = eom.dev description = Make shelter. Stay warm and fed. Build a life that can endure. allowed_mapgens = v7 mapgen = v7 -first_mod = hearth +first_mod = creatura min_minetest_version = 5.18 diff --git a/minetest.conf b/minetest.conf index 49972da..a34fda3 100755 --- a/minetest.conf +++ b/minetest.conf @@ -6,21 +6,22 @@ movement_speed_walk = 4 movement_speed_jump = 6.5 movement_acceleration_default = 4 movement_acceleration_air = 2 -# Creation defaults only: Luanti persists mapgen parameters in map_meta.txt. -# No runtime retuning or regeneration; managed existing worlds receive no overrides. +# Stock v7 is the substrate for the separately bundled original epic_terrain +# generator. Managed launcher enables that callback only when creating a world. +# Saved mapgen parameters remain engine-owned; no runtime retuning/regeneration. mg_name = v7 -water_level = 1 +water_level = 32 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_mount_zero_level = 0 -mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0 -mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0 +mgv7_np_terrain_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 = -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_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 = 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 diff --git a/mods/creatura/.github/workflows/build.yml b/mods/creatura/.github/workflows/build.yml new file mode 100755 index 0000000..817e409 --- /dev/null +++ b/mods/creatura/.github/workflows/build.yml @@ -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: "" diff --git a/mods/creatura/.luacheckrc b/mods/creatura/.luacheckrc new file mode 100755 index 0000000..f8c5ab3 --- /dev/null +++ b/mods/creatura/.luacheckrc @@ -0,0 +1,15 @@ +max_line_length = 120 + +globals = { + "minetest", + "VoxelArea", + "creatura", +} + +read_globals = { + "vector", + "ItemStack", + table = {fields = {"copy"}} +} + +ignore = {"212/self", "212/this"} \ No newline at end of file diff --git a/mods/creatura/LICENSE b/mods/creatura/LICENSE new file mode 100755 index 0000000..ed8c3a0 --- /dev/null +++ b/mods/creatura/LICENSE @@ -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. diff --git a/mods/creatura/UPSTREAM.md b/mods/creatura/UPSTREAM.md new file mode 100755 index 0000000..1ce9875 --- /dev/null +++ b/mods/creatura/UPSTREAM.md @@ -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. diff --git a/mods/creatura/api.lua b/mods/creatura/api.lua new file mode 100755 index 0000000..1c5499e --- /dev/null +++ b/mods/creatura/api.lua @@ -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") diff --git a/mods/creatura/boids.lua b/mods/creatura/boids.lua new file mode 100755 index 0000000..a7f338d --- /dev/null +++ b/mods/creatura/boids.lua @@ -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 diff --git a/mods/creatura/doc.txt b/mods/creatura/doc.txt new file mode 100755 index 0000000..89b8b02 --- /dev/null +++ b/mods/creatura/doc.txt @@ -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 \ No newline at end of file diff --git a/mods/creatura/init.lua b/mods/creatura/init.lua new file mode 100755 index 0000000..8970300 --- /dev/null +++ b/mods/creatura/init.lua @@ -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") \ No newline at end of file diff --git a/mods/creatura/methods.lua b/mods/creatura/methods.lua new file mode 100755 index 0000000..1f50ff2 --- /dev/null +++ b/mods/creatura/methods.lua @@ -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) + + diff --git a/mods/creatura/mob_meta.lua b/mods/creatura/mob_meta.lua new file mode 100755 index 0000000..c458508 --- /dev/null +++ b/mods/creatura/mob_meta.lua @@ -0,0 +1,1248 @@ +-------------- +-- Mob Meta -- +-------------- + +-- Math -- + +local pi = math.pi +local pi2 = pi * 2 +local abs = math.abs +local floor = math.floor +local random = math.random + +local sin = math.sin +local cos = math.cos +local atan2 = math.atan2 + +local function diff(a, b) -- Get difference between 2 angles + return atan2(sin(b - a), cos(b - a)) +end + +local vec_dir = vector.direction +local vec_dist = vector.distance +local vec_multi = vector.multiply +local vec_sub = vector.subtract +local vec_add = vector.add +local vec_normal = vector.normalize + +local function vec_center(v) + return {x = floor(v.x + 0.5), y = floor(v.y + 0.5), z = floor(v.z + 0.5)} +end + +local function vec_raise(v, n) + return {x = v.x, y = v.y + n, z = v.z} +end + +local function get_sightline(pos1, pos2) + local dir = vec_dir(pos1, pos2) + local dist = vec_dist(pos1, pos2) + local pos + for i = 0, dist do + if dist > 0 then + pos = { + x = pos1.x + dir.x * (i / dist), + y = pos1.y + dir.y * (i / dist), + z = pos1.z + dir.z * (i / dist) + } + else + pos = pos1 + end + if creatura.get_node_def(pos).walkable then + return false + end + end + return true +end + +-- Local Utilities -- + +local function is_value_in_table(tbl, val) + for _, v in pairs(tbl) do + if v == val then + return true + end + end + return false +end + +------------------------- +-- Physics/Vitals Tick -- +------------------------- + +local mob = { + max_health = 20, + max_breath = 30, + fire_resistance = 0.5, + fall_resistance = 0, + armor_groups = {fleshy = 100}, + damage = 2, + speed = 4, + tracking_range = 16, + despawn_after = nil, + max_fall = 3, + stepheight = 1.1, + hitbox = { + width = 0.5, + height = 1 + }, + follow = {}, + fancy_collide = false, + liquid_submergence = 0.25, + liquid_drag = 1 + +} + +local mob_meta = {__index = mob} + +function mob:indicate_damage() + self._original_texture_mod = self._original_texture_mod or self.object:get_texture_mod() + self.object:set_texture_mod(self._original_texture_mod .. "^[colorize:#FF000040") + minetest.after(0.2, function() + if creatura.is_alive(self) then + self.object:set_texture_mod(self._original_texture_mod) + end + end) +end + +-- Set Movement Data + +function mob:move(pos, method, speed_factor, anim) + self._movement_data.goal = pos + self._movement_data.method = method + self._movement_data.last_neighbor = nil + self._movement_data.gravity = self._movement_data.gravity or -9.8 + self._movement_data.speed = (self.speed or 2) * (speed_factor or 1) + if anim then + self._movement_data.anim = anim + end +end + +-- Clear Movement Data + +function mob:halt() + self._movement_data = { + goal = nil, + method = nil, + func = nil, + last_neighbor = nil, + gravity = self._movement_data.gravity or -9.8, + speed = 0 + } + --self.object:set_velocity({x = 0, y = 0, z = 0}) + self._path_data = {} + self._tyaw = self.object:get_yaw() or 0 +end + +-- Turn to specified yaw + +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 function turn(self, tyaw, rate) + rate = rate or 5 + local rot = self.object:get_rotation() + local yaw = self.object:get_yaw() + if not yaw then return end + local step = math.min(self.dtime * rate, abs(diff(yaw, tyaw)) % (pi2)) + rot.y = interp_rad(yaw, tyaw, step) + if rot.y ~= rot.y then return end + self.object:set_rotation(rot) +end + +function mob:turn_to(tyaw, rate) + if self.step_delay then + self._tyaw = tyaw + self._movement_data.turn_rate = rate or 5 + return + end + turn(self, tyaw, rate) +end + +function mob:do_turn() + if not self.step_delay then return end + local tyaw = self._tyaw + local rate = self._movement_data.turn_rate or 5 + if not tyaw then return end + turn(self, self._tyaw, rate) +end + +-- Set Gravity (default of -9.8) + +function mob:set_gravity(gravity) + self._movement_data.gravity = gravity or -9.8 +end + +-- Sets Velocity to desired speed in mobs current look direction + +function mob:set_forward_velocity(speed) + if self.step_delay then + self._movement_data.horz_vel = speed + else + local yaw = self.object:get_yaw() + local vel = self.object:get_velocity() + vel.x = sin(yaw) * -speed + vel.z = cos(yaw) * speed + self.object:set_velocity(vel) + end +end + +-- Sets Velocity on y axis + +function mob:set_vertical_velocity(speed) + if self.step_delay then + self._movement_data.vert_vel = speed + else + local vel = self.object:get_velocity() + vel.y = speed + self.object:set_velocity(vel) + end +end + +function mob:do_velocity() + if not self.step_delay then return end + local data = self._movement_data or {} + local vel = self.object:get_velocity() + local yaw = self.object:get_yaw() + if not vel or not yaw then return end + local horz_vel = data.horz_vel --or (data.gravity >= 0 and 0) + local vert_vel = data.vert_vel --or (data.gravity >= 0 and 0) + vel.x = (horz_vel and (sin(yaw) * -horz_vel)) or vel.x + vel.y = vert_vel or vel.y + vel.z = (horz_vel and (cos(yaw) * horz_vel)) or vel.z + self.object:set_velocity(vel) +end + +-- Applies knockback in 'dir' + +function mob:apply_knockback(dir, power) + if not dir then return end + power = power or 6 + local knockback = vec_multi(dir, power) + self.object:add_velocity(knockback) +end + +-- Punch 'target' + +function mob:punch_target(target) -- + target:punch(self.object, 1.0, { + full_punch_interval = 1.0, + damage_groups = {fleshy = self.damage or 5}, + }) +end + +-- Apply damage to mob + +function mob:hurt(health) + if self.protected then return end + self.hp = self.hp - math.ceil(health) + if self.hp < 0 then self.hp = 0 end +end + +-- Add HP to mob + +function mob:heal(health) + if self.protected then return end + self.hp = self.hp + math.ceil(health) + if self.hp > self.max_health then + self.hp = self.max_health + end +end + +-- Return position at center of mobs hitbox + +function mob:get_center_pos() + local pos = self.object:get_pos() + if not pos then return end + return vec_raise(pos, self.height * 0.5 or 0.5) +end + +-- Return true if position is within box + +function mob:pos_in_box(pos, size) + if not pos then return false end + local center = self:get_center_pos() + if not center then return false end + local width = size or self.width + local height = size or (self.height * 0.5) + if not size + and self.width < 0.5 then + width = 0.5 + end + local edge_a = { + x = center.x - width, + y = center.y - height, + z = center.z - width + } + local edge_b = { + x = center.x + width, + y = center.y + height, + z = center.z + width + } + local minp, maxp = vector.sort(edge_a, edge_b) + if pos.x >= minp.x + and pos.y >= minp.y + and pos.z >= minp.z + and pos.x <= maxp.x + and pos.y <= maxp.y + and pos.z <= maxp.z then + return true + end + return false +end + +-- Terrain Navigation -- + +function mob:get_wander_pos(min_range, max_range, dir) + local pos = vec_center(self.object:get_pos()) + pos.y = floor(pos.y + 0.5) + if creatura.get_node_def(pos).walkable then -- Occurs if small mob is touching a fence + local offset = vector.add(pos, vec_multi(vec_dir(pos, self.object:get_pos()), 1.5)) + pos.x = floor(offset.x + 0.5) + pos.z = floor(offset.z + 0.5) + pos = creatura.get_ground_level(pos, 1) + end + local width = self.width + local outset = random(min_range, max_range) + if width < 0.6 then width = 0.6 end + local move_dir = vec_normal({ + x = random(-10, 10) * 0.1, + y = 0, + z = random(-10, 10) * 0.1 + }) + local pos2 = vec_add(pos, vec_multi(move_dir, width)) + if creatura.get_node_def(pos2).walkable + and not dir then + for _ = 1, 3 do + move_dir = { + x = move_dir.z, + y = 0, + z = move_dir.x * -1 + } + pos2 = vec_add(pos, vec_multi(move_dir, width)) + if not creatura.get_node_def(pos2).walkable then + break + end + end + elseif dir then + move_dir = dir + end + for i = 1, outset do + local a_pos = vec_add(pos2, vec_multi(move_dir, i)) + local b_pos = {x = a_pos.x, y = a_pos.y - 1, z = a_pos.z} + if creatura.get_node_def(a_pos).walkable + or not creatura.get_node_def(b_pos).walkable then + a_pos = creatura.get_ground_level(a_pos, floor(self.stepheight or 1)) + end + if not creatura.get_node_def(a_pos).walkable then + pos2 = a_pos + else + break + end + end + return pos2 +end + +function mob:get_wander_pos_3d(min_range, max_range, dir, vert_bias) + local pos = vec_center(self.object:get_pos()) + if creatura.get_node_def(pos).walkable then -- Occurs if small mob is touching a fence + local offset = vector.add(pos, vec_multi(vec_dir(pos, self.object:get_pos()), 1.5)) + pos.x = floor(offset.x + 0.5) + pos.z = floor(offset.z + 0.5) + pos = creatura.get_ground_level(pos, 1) + end + local width = self.width + local outset = random(min_range, max_range) + if width < 0.6 then width = 0.6 end + local move_dir = vec_normal({ + x = random(-10, 10) * 0.1, + y = vert_bias or random(-10, 10) * 0.1, + z = random(-10, 10) * 0.1 + }) + local pos2 = vec_add(pos, vec_multi(move_dir, width)) + if creatura.get_node_def(pos2).walkable + and not dir then + for _ = 1, 3 do + move_dir = { + x = move_dir.z, + y = move_dir.y, + z = move_dir.x * -1 + } + pos2 = vec_add(pos, vec_multi(move_dir, width)) + if not creatura.get_node_def(pos2).walkable then + break + end + end + elseif dir then + move_dir = dir + end + for i = 1, outset do + local a_pos = vec_add(pos2, vec_multi(move_dir, i)) + if creatura.get_node_def(a_pos).walkable then + a_pos = creatura.get_ground_level(a_pos, floor(self.stepheight or 1)) + end + if not creatura.get_node_def(a_pos).walkable then + pos2 = a_pos + else + break + end + end + return pos2 +end + +function mob:is_pos_safe(pos, ignore_liquid) + if not pos then return end + local n_def = creatura.get_node_def(pos) + if minetest.get_item_group(n_def.name, "igniter") > 0 + or (not ignore_liquid + and (n_def.drawtype == "liquid" + or creatura.get_node_def(vec_raise(pos, -1)).drawtype == "liquid")) then return false end + local fall_safe = false + local fall_pos = {x = pos.x, y = floor(pos.y + 0.5), z = pos.z} + if self.max_fall ~= 0 then + for _ = 1, self.max_fall or 3 do + fall_pos.y = fall_pos.y - 1 + if creatura.get_node_def(fall_pos).walkable then + fall_safe = true + break + end + end + else + fall_safe = true + end + return fall_safe +end + +-- Set mobs animation (if specified animation isn't already playing) + +function mob:animate(animation, transition) + if not animation + or not self.animations[animation] then + return + end + + -- Handle Transition Data + local transition_data = self._anim_transition or {} + local parent_anim = transition_data.parent + local child_anim = transition_data.child + if child_anim + and animation == parent_anim + and transition == child_anim then + local timer = transition_data.timer + transition_data.timer = (timer > 0 and timer - self.dtime) or 0 + if timer <= 0 then + animation = child_anim + end + else + transition_data = {} + end + self._anim_transition = transition_data + + -- Set Animation + if not self._anim + or self._anim ~= animation + or (transition + and not transition_data.timer) then + local anim = self.animations[animation] + if anim[2] then anim = anim[random(#anim)] end + self.object:set_animation(anim.range, anim.speed, anim.frame_blend, anim.loop) + self._anim = animation + -- Set Transition Data + if transition + and (not transition_data.timer + or transition_data.timer > 0) then + local anim_length = (anim.range.y - anim.range.x) / anim.speed + self._anim_transition = { + parent = animation, + child = transition, + timer = anim_length + } + end + end + return animation +end + +-- Set texture to variable at 'id' index in 'tbl' or 'textures' + +function mob:set_texture(id, tbl) + local _table = self.textures + if tbl then + _table = tbl + end + if not _table + or not _table[id] then + return + end + local tex = _table[id] + if type(tex) == "table" then + self.object:set_properties({ + textures = {unpack(tex)} + }) + else + self.object:set_properties({ + textures = {tex} + }) + end + return _table[id] +end + +-- Set/reset mesh + +function mob:set_mesh(id) + local mesh = self.mesh + if mesh + and not self.meshes then + self.object:set_properties({ + mesh = mesh + }) + return mesh + end + local meshes = self.meshes or {} + if #meshes > 0 then + local mesh_no = id or self.mesh_no or random(#meshes) + self.object:set_properties({ + mesh = meshes[mesh_no] + }) + self.mesh_no = mesh_no + if self.mesh_textures then + self.textures = self.mesh_textures[mesh_no] + self.texture_no = random(#self.textures) + self:set_texture(self.texture_no, self.textures) + end + return meshes[mesh_no] + end +end + +-- Set scale to base scale times 'x' and update bordering positions + +function mob:set_scale(x) + local def = minetest.registered_entities[self.name] + local scale = def.visual_size or {x = 1, y = 1} + local box = def.collisionbox + local new_box = {} + for k, v in ipairs(box) do + new_box[k] = v * x + end + self.object:set_properties({ + visual_size = { + x = scale.x * x, + y = scale.y * x + }, + collisionbox = new_box + }) + --self._border = index_box_border(self) +end + +-- Fixes mob scale being changed when attached to a parent + +function mob:fix_attached_scale(parent) + local scale = self:get_visual_size() + local parent_size = parent:get_properties().visual_size + self.object:set_properties({ + visual_size = { + x = scale.x / parent_size.x, + y = scale.y / parent_size.y + }, + }) +end + +-- Add sets 'id' to 'val' in permanent data + +function mob:memorize(id, val) + self.perm_data[id] = val + return self.perm_data[id] +end + +-- Remove 'id' from permanent data + +function mob:forget(id) + self.perm_data[id] = nil +end + +-- Return value from 'id' in permanent data + +function mob:recall(id) + return self.perm_data[id] +end + +-- Return true on interval specified by 'n' + +function mob:timer(n) + local t1 = floor(self.active_time) + local t2 = floor(self.active_time + self.dtime) + if t2 > t1 and t2%n == 0 then return true end +end + +-- Play 'sound' from self.sounds + +function mob:play_sound(sound) + local spec = self.sounds and self.sounds[sound] + local parameters = {object = self.object} + + if type(spec) == "table" then + local name = spec.name + local pitch = 1.0 + + pitch = pitch - (random(-10, 10) * 0.005) + + parameters.gain = spec.gain or 1 + parameters.max_hear_distance = spec.distance or 8 + parameters.fade = spec.fade or 1 + parameters.pitch = pitch + return minetest.sound_play(name, parameters) + end + return minetest.sound_play(spec, parameters) +end + +-- Return current collisionbox + +function mob:get_hitbox() + if not self:get_props() then return self.collisionbox end + return self:get_props().collisionbox +end + +-- Return height of current collisionbox + +function mob:get_height() + local hitbox = self:get_hitbox() + return hitbox[5] - hitbox[2] +end + +-- Return current visual size + +function mob:get_visual_size() + if not self:get_props() then return end + return self:get_props().visual_size +end + +local function is_group_in_table(tbl, name) + for _, v in pairs(tbl) do + if type(v) == "string" + and minetest.get_item_group(name, v:split(":")[2]) > 0 then + return true + end + end + return false +end + +function mob:follow_wielded_item(player) + if not player + or not self.follow then return end + local item = player:get_wielded_item() + local name = item:get_name() + if type(self.follow) == "string" + and (name == self.follow + or minetest.get_item_group(name, self.follow:split(":")[2]) > 0) then + return item, name + end + if type(self.follow) == "table" + and (is_value_in_table(self.follow, name) + or is_group_in_table(self.follow, name)) then + return item, name + end +end + +function mob:follow_item(stack) + if not stack + or not self.follow then return end + local name = stack:get_name() + if type(self.follow) == "string" + and (name == self.follow + or minetest.get_item_group(name, self.follow:split(":")[2]) > 0) then + return stack, name + end + if type(self.follow) == "table" + and (is_value_in_table(self.follow, name) + or is_group_in_table(self.follow, name)) then + return stack, name + end +end + +function mob:get_target(target) + local alive = creatura.is_alive(target) + if not alive then + return false, false, nil + end + if type(target) == "table" then + target = target.object + end + local pos = self:get_center_pos() + if not pos then return false, false, nil end + local tpos = target:get_pos() + tpos.y = floor(tpos.y + 0.5) + local line_of_sight = get_sightline(pos, tpos) + return true, line_of_sight, tpos +end + +function mob:store_nearby_objects(radius) + local pos = self.object:get_pos() + if not pos then return end + local track_radius = self.tracking_range or 8 + if track_radius < 8 then track_radius = 8 end + local objects = minetest.get_objects_inside_radius(pos, radius or track_radius) + if #objects < 1 then return end + local objs = {} + for _, object in ipairs(objects) do + if creatura.is_alive(object) + and object ~= self.object then + local ent = object:get_luaentity() + local player = object:is_player() + if (ent + and not ent._ignore) + or player then + table.insert(objs, object) + end + end + end + self._nearby_objs = objs + return objs +end + +function mob:get_props() + local props = self.properties or self.object and self.object:get_properties() + self.properties = props + return props +end + +-- Actions + +function mob:set_action(func) + self._action = func +end + +function mob:get_action() + if type(self._action) ~= "table" then + return self._action + end + return nil +end + +function mob:clear_action() + self._action = {} + self._movement_data.goal = nil + self._movement_data.func = nil +end + +function mob:set_utility(func) + if not self._utility_data then return end + self._utility_data.func = func +end + +function mob:get_utility() + if not self._utility_data then return end + return self._utility_data.utility +end + +function mob:initiate_utility(utility, ...) + local func = creatura.registered_utilities[utility] + if not func or not self._utility_data then return end + self._utility_data.utility = utility + self:clear_action() + func(...) +end + +function mob:set_utility_score(n) + if not self._utility_data then return end + self._utility_data.score = n or 0 +end + +function mob:get_utility_score() + return (self._utility_data and self._utility_data.score) or 0 +end + +function mob:try_initiate_utility(utility, score, ...) + if self._utility_data + and score >= self._utility_data.score then + self:initiate_utility(utility, ...) + self:set_utility_score(score) + end +end + +function mob:clear_utility() + self._utility_data = { + utility = nil, + func = nil, + score = 0 + } +end + +-- Functions + +function mob:activate(staticdata, dtime) + self:get_props() + self.width = self:get_hitbox()[4] or 0.5 + self.height = self:get_height() or 1 + self._tyaw = self.object:get_yaw() + self.last_yaw = self.object:get_yaw() + self.in_liquid = false + self.is_falling = false + self.touching_ground = false + + -- Backend Data (Should not be modified unless modder knows what they're doing) + self._movement_data = { + goal = nil, + method = nil, + func = nil, + last_neighbor = nil, + gravity = -9.8, + speed = 0 + } + self._path_data = {} + self._path = {} + self._task = {} + self._action = {} + + local pos = self.object:get_pos() + local node = minetest.get_node(pos) + + if node + and minetest.get_item_group(node.name, "liquid") > 0 then + self.in_liquid = node.name + end + + -- Staticdata + local data = minetest.deserialize(staticdata) + + if data then + local tp + for k, v in pairs(data) do + tp = type(v) + if tp ~= "function" + and tp ~= "nil" + and tp ~= "userdata" then + self[k] = v + end + end + end + + -- Initialize Stats and Visuals + if self.meshes + and #self.meshes > 0 then + if not self.mesh_no + or not self.meshes[self.mesh_no] then + self.mesh_no = random(#self.meshes) + end + self:set_mesh(self.mesh_no) + end + + if not self.textures then + local textures = self:get_props().textures + if textures then self.textures = textures end + end + + if not self.perm_data then + if self.memory then + self.perm_data = self.memory + else + self.perm_data = {} + end + if #self.textures > 0 then self.texture_no = random(#self.textures) end + end + + self.active_time = self:recall("active_time") or 0 + + if self:recall("despawn_after") ~= nil then + self.despawn_after = self:recall("despawn_after") + end + self._despawn = self:recall("_despawn") or nil + + if self._despawn + and self.despawn_after + and self.object then + self.object:remove() + return + end + + self._breath = self:recall("_breath") or (self.max_breath or 30) + --self._border = index_box_border(self) + + if self.textures + and self.texture_no then + if not self.textures[self.texture_no] then + self.texture_no = random(#self.textures) + end + self:set_texture(self.texture_no, self.textures) + end + + self.max_health = self.max_health or 10 + self.hp = self.hp or self.max_health + + if type(self.armor_groups) ~= "table" then + self.armor_groups = {} + end + self.armor_groups.immortal = 1 + self.object:set_armor_groups(self.armor_groups) + + if self.timer + and type(self.timer) == "number" then -- fix crash for converted mobs_redo mobs + self.timer = function(_self, n) + local t1 = floor(_self.active_time) + local t2 = floor(_self.active_time + _self.dtime) + if t2 > t1 and t2%n == 0 then return true end + end + end + + self:store_nearby_objects() + + if self.activate_func then + self:activate_func(self, staticdata, dtime) + end +end + +function mob:staticdata() + local data = {} + data.perm_data = self.perm_data + data.hp = self.hp or self.max_health + data.texture_no = self.texture_no or random(#self.textures) + data.mesh_no = self.mesh_no or (self.meshes and random(#self.meshes)) + return minetest.serialize(data) +end + +function mob:on_step(dtime, moveresult) + if not self.hp then return end + self.last_yaw = self.object:get_yaw() + self.stand_pos = self.object:get_pos() + if not self.stand_pos then return end + self.stand_pos.y = self.stand_pos.y + 0.01 + self.stand_node = self.stand_node or minetest.get_node(self.stand_pos) + self.dtime = dtime or 0.09 + self.moveresult = moveresult or {} + self.touching_ground = false + if moveresult then + self.touching_ground = moveresult.touching_ground + end + local prop_tick = self._prop_tick or 0 + prop_tick = prop_tick - 1 + if prop_tick <= 0 then + self.stand_node = minetest.get_node(self.stand_pos) + prop_tick = 6 + end + if self:timer(1) then + self.width = self:get_hitbox()[4] or 0.5 + self.height = self:get_height() or 1 + end + if self._vitals then + self:_vitals() + end + if self._physics then + self:_physics(moveresult) + end + self._prop_tick = prop_tick + if self:timer(10) then self:store_nearby_objects() end -- Reduce expensive calls + self:do_velocity() + self:do_turn() + if self.utility_stack + and self._execute_utilities then + self:_execute_utilities() + end + -- Die + if self.hp <= 0 + and self.death_func then + self:death_func() + self:halt() + return + end + if self.step_func + and self.perm_data then + self:step_func(dtime, moveresult) + end + self.properties = nil + self.active_time = self.active_time + dtime + self:memorize("active_time", self.active_time) + if self.despawn_after then + local despawn = math.floor(self.active_time / self.despawn_after) + if despawn > 1 then self.object:remove() return end + if despawn > 0 + and not self._despawn then + self._despawn = self:memorize("_despawn", true) + end + end +end + +function mob:on_deactivate(removal) + self._task = {} + self._action = {} + if self.deactivate_func then + self:deactivate_func(removal) + end +end + +---------------- +-- Object API -- +---------------- + +-- Physics + +local function collision_detection(self) + if not creatura.is_alive(self) + or self.fancy_collide == false then return end + local pos = self.stand_pos + local width = self.width + 0.25 + local objects = minetest.get_objects_in_area(vec_sub(pos, width), vec_add(pos, width)) + if #objects < 2 then return end + local pos2 + local dir + local vel, vel2 + for i = 2, #objects do + local object = objects[i] + if creatura.is_alive(object) + and not self.object:get_attach() + and not object:get_attach() then + if i > 5 then break end + pos2 = object:get_pos() + dir = vec_dir(pos, pos2) + dir.y = 0 + if dir.x == 0 and dir.z == 0 then + dir = vector.new(random(-1, 1) * random(), 0, + random(-1, 1) * random()) + end + vel = vec_multi(dir, 1.5) + vel2 = vec_multi(dir, -2) -- multiplying by -2 accounts for friction + self.object:add_velocity(vel2) + object:add_velocity(vel) + end + end +end + +local mob_friction = 7 + +function mob:_physics() + -- Physics + creatura.default_water_physics(self) + collision_detection(self) + + -- Cache Environment Info + local in_liquid = self.in_liquid + local on_ground = self.touching_ground + if not in_liquid + and not on_ground then + self.is_falling = true + else + self.is_falling = false + end + local move_data = self._movement_data + if not in_liquid + --and not move_data.func + and move_data.gravity ~= 0 then + local vel = self.object:get_velocity() + local friction = math.min(self.dtime * mob_friction, 0.5) + local nvel = {x = vel.x * (1 - friction), y = vel.y, z = vel.z * (1 - friction)} + self.object:set_velocity(nvel) + end +end + +-- Movement Control + +function mob:move_to(goal, method, speed_factor) + local get_method = creatura.registered_movement_methods[method] + local data = self._movement_data + if get_method + and not data.func then + self._movement_data.func = get_method(self, goal, speed_factor) + return self._movement_data.func(self, goal, speed_factor) + end + if data.func then + local move = data.func + return move(self, goal, speed_factor) + end +end + +-- Execute Actions + +local function tbl_equals(tbl1, tbl2) + local match = true + for k, v in pairs(tbl1) do + if not tbl2[k] + and tbl2[k] ~= v then + match = false + break + end + end + return match +end + +function mob:_execute_utilities() + local is_alive = self.hp > 0 + if not self._utility_data then + self._utility_data = { + utility = nil, + func = nil, + step_delay = nil, + score = 0 + } + end + if not self._util_cooldown then + self._util_cooldown = {} + end + local loop_data = { + utility = nil, + func = nil, + step_delay = nil, + score = 0 + } + if (self:timer(self.util_timer or 1) + or not self._utility_data.func) + and is_alive then + local util_data = self._utility_data + local util_stack = self.utility_stack + local utility + local get_score + local cooldown + local step_delay + local score, args + for i = 1, #util_stack do + utility = util_stack[i].utility + get_score = util_stack[i].get_score + cooldown = self._util_cooldown[i] or 0 + step_delay = util_stack[i].step_delay + score, args = get_score(self) + + if cooldown > 0 then + cooldown = cooldown - (self.util_timer or 1) + end + + if util_data.utility + and utility == util_data.utility + and util_data.score > 0 + and score <= 0 then + self._utility_data = { + utility = nil, + func = nil, + step_delay = nil, + score = 0 + } + util_data = self._utility_data + end + + if score > 0 + and score >= util_data.score + and score >= loop_data.score + and cooldown <= 0 then + loop_data = { + utility = utility, + score = score, + util_no = i, + step_delay = step_delay, + args = args + } + end + + self._util_cooldown[i] = cooldown + end + end + + if loop_data.utility + and loop_data.args then + if not self._utility_data + or not self._utility_data.args then + self._utility_data = loop_data + else + local no_data = not self._utility_data.utility and not self._utility_data.args + local same_args = tbl_equals(self._utility_data.args, loop_data.args) + local new_util = self._utility_data.utility ~= loop_data.utility or not same_args + if no_data + or new_util then -- if utilities are different or utilities are the same and args are different set new data + self._utility_data = loop_data + end + end + end + + if self._utility_data.utility then -- If a utility is currently selected + local util_data = self._utility_data + if not util_data.func then + self:initiate_utility(util_data.utility, unpack(util_data.args)) + end + local func = util_data.func + if util_data.step_delay + and self.hp > 0 then + self.step_delay = util_data.step_delay + else + self.step_delay = nil + self._step_delay = 0 + end + local step_delay = self.step_delay and (self._step_delay or 0) + if not func then return end + if step_delay then + if step_delay > 0 then + self._step_delay = step_delay - self.dtime + return + else + self._step_delay = self.step_delay + end + end + local dtime = self.dtime + self.dtime = dtime + (self.step_delay or 0) + if self.horz_vel + and self.horz_vel ~= 0 then + self:set_forward_velocity(nil) + end + if self.vert_vel + and self.vert_vel ~= 0 then + self:set_vertical_velocity(nil) + end + local func_complete, func_cooldown = func(self) + if func_complete then + if util_data.util_no then + self._util_cooldown[util_data.util_no] = func_cooldown + end + self._utility_data = { + utility = nil, + func = nil, + score = 0 + } + self:clear_action() + end + local action = self._action + if action + and type(action) ~= "table" then + if action(self) then + self:clear_action() + end + end + self.dtime = dtime + end +end + +-- Vitals + +function creatura.register_mob(name, def) + local box_width = def.hitbox and def.hitbox.width or 0.5 + local box_height = def.hitbox and def.hitbox.height or 1 + local hitbox = {-box_width, 0, -box_width, box_width, box_height, box_width} + + def.physical = def.physical or true + def.collide_with_objects = def.collide_with_objects or false + def.visual = "mesh" + def.mesh = def.mesh or (def.meshes and def.meshes[1]) + def.makes_footstep_sound = def.makes_footstep_sound or false + if def.static_save ~= false then + def.static_save = true + end + def.collisionbox = def.collisionbox or hitbox + def._creatura_mob = true + + def.sounds = def.sounds or {} + + if not def.sounds.hit then + def.sounds.hit = { + name = "creatura_hit", + gain = 0.5, + distance = 16, + variations = 3 + } + end + + def._vitals = def._vitals or creatura.default_vitals + + def.on_activate = function(self, staticdata, dtime) + return self:activate(staticdata, dtime) + end + + def.get_staticdata = function(self) + return self:staticdata(self) + end + + minetest.register_entity(name, setmetatable(def, mob_meta)) +end diff --git a/mods/creatura/mod.conf b/mods/creatura/mod.conf new file mode 100755 index 0000000..e126b90 --- /dev/null +++ b/mods/creatura/mod.conf @@ -0,0 +1,2 @@ +name = creatura +description = A performant, semi-modular mob API \ No newline at end of file diff --git a/mods/creatura/pathfinder_deprecated.lua b/mods/creatura/pathfinder_deprecated.lua new file mode 100755 index 0000000..6c7aa64 --- /dev/null +++ b/mods/creatura/pathfinder_deprecated.lua @@ -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 diff --git a/mods/creatura/pathfinding.lua b/mods/creatura/pathfinding.lua new file mode 100755 index 0000000..83480e5 --- /dev/null +++ b/mods/creatura/pathfinding.lua @@ -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 \ No newline at end of file diff --git a/mods/creatura/settingtypes.txt b/mods/creatura/settingtypes.txt new file mode 100755 index 0000000..090be0d --- /dev/null +++ b/mods/creatura/settingtypes.txt @@ -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 \ No newline at end of file diff --git a/mods/creatura/sounds/creatura_hit_1.ogg b/mods/creatura/sounds/creatura_hit_1.ogg new file mode 100755 index 0000000..b41e505 Binary files /dev/null and b/mods/creatura/sounds/creatura_hit_1.ogg differ diff --git a/mods/creatura/sounds/creatura_hit_2.ogg b/mods/creatura/sounds/creatura_hit_2.ogg new file mode 100755 index 0000000..89192b1 Binary files /dev/null and b/mods/creatura/sounds/creatura_hit_2.ogg differ diff --git a/mods/creatura/sounds/creatura_hit_3.ogg b/mods/creatura/sounds/creatura_hit_3.ogg new file mode 100755 index 0000000..df31c8d Binary files /dev/null and b/mods/creatura/sounds/creatura_hit_3.ogg differ diff --git a/mods/creatura/spawning.lua b/mods/creatura/spawning.lua new file mode 100755 index 0000000..5e38352 --- /dev/null +++ b/mods/creatura/spawning.lua @@ -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 diff --git a/mods/creatura/textures/creatura_particle_green.png b/mods/creatura/textures/creatura_particle_green.png new file mode 100755 index 0000000..2693cc3 Binary files /dev/null and b/mods/creatura/textures/creatura_particle_green.png differ diff --git a/mods/creatura/textures/creatura_particle_red.png b/mods/creatura/textures/creatura_particle_red.png new file mode 100755 index 0000000..019d9a2 Binary files /dev/null and b/mods/creatura/textures/creatura_particle_red.png differ diff --git a/mods/creatura/textures/creatura_smoke_particle.png b/mods/creatura/textures/creatura_smoke_particle.png new file mode 100755 index 0000000..4b77207 Binary files /dev/null and b/mods/creatura/textures/creatura_smoke_particle.png differ diff --git a/mods/creatura/textures/creatura_spawning_crystal_outline.png b/mods/creatura/textures/creatura_spawning_crystal_outline.png new file mode 100755 index 0000000..f839e52 Binary files /dev/null and b/mods/creatura/textures/creatura_spawning_crystal_outline.png differ diff --git a/mods/creatura/textures/creatura_spawning_crystal_primary.png b/mods/creatura/textures/creatura_spawning_crystal_primary.png new file mode 100755 index 0000000..242671c Binary files /dev/null and b/mods/creatura/textures/creatura_spawning_crystal_primary.png differ diff --git a/mods/creatura/textures/creatura_spawning_crystal_secondary.png b/mods/creatura/textures/creatura_spawning_crystal_secondary.png new file mode 100755 index 0000000..ddfc5ac Binary files /dev/null and b/mods/creatura/textures/creatura_spawning_crystal_secondary.png differ diff --git a/mods/hearth/biomes.lua b/mods/hearth/biomes.lua index a8510f3..3406ea6 100755 --- a/mods/hearth/biomes.lua +++ b/mods/hearth/biomes.lua @@ -11,7 +11,7 @@ return function(hearth, api, register_node, construction) for i, name in ipairs(list) do out[i] = node(name) end return out end - M.species_order = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"} + M.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"} -- Moisture/fertility/drainage are normalized habitat preferences, NOT player -- 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}, 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}, + 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) local p = M.profiles[species] @@ -122,6 +131,16 @@ return function(hearth, api, register_node, construction) elseif species == "spruce" then local radius = y >= 1 and math.min(2,math.floor((h-1-y)/3)) or -1 leaf = r <= radius and dx+dz <= radius+1 + 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 local stem = canonical and "log" or species .. "_log" local leaves = canonical and "leaves" or species .. "_leaves" diff --git a/mods/hearth/botany.lua b/mods/hearth/botany.lua index abc4e22..f3d0717 100755 --- a/mods/hearth/botany.lua +++ b/mods/hearth/botany.lua @@ -54,6 +54,9 @@ return function(hearth, api) add("rowan","Rowan","Sorbus aucuparia","acid",grow,"pollinator,browse,nursery") add("hazel","Common hazel","Corylus avellana","wood",grow,"browse,ectomycorrhiza,nursery") add("spruce","Norway spruce","Picea abies","acid",year,"ectomycorrhiza,nursery,resin") + 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" 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}) @@ -65,6 +68,10 @@ return function(hearth, api) add("holly","European holly","Ilex aquifolium","wood",year,"pollinator,browse,nursery",{toxic=true}) add("juniper","Common juniper","Juniperus communis","acid",year,"browse,nursery,resin") add("guelder_rose","Guelder rose","Viburnum opulus","wetwood",grow,"pollinator,browse,nursery",{toxic=true}) + 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" 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}) @@ -87,6 +94,18 @@ return function(hearth, api) add("wild_garlic","Wild garlic","Allium ursinum","shade","Spring","pollinator") add("cowslip","Cowslip","Primula veris","meadow","Spring","pollinator") add("wood_anemone","Wood anemone","Anemone nemorosa","shade","Spring","pollinator",{toxic=true}) + 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" add("bracken","Bracken","Pteridium aquilinum","acid",grow,"nursery,allelopathy",{toxic=true}) 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("fork_moss","Broom fork-moss","Dicranum scoparium","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" 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") @@ -137,6 +157,16 @@ return function(hearth, api) niche("rowan",{light={5,15},moisture={.3,.85}}) niche("spruce",{moisture={.4,.85},light={4,15}}) 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("nettle",{light={5,15},nutrients=1}) 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", wild_garlic="Spring",cowslip="Spring",wood_anemone="Spring",yellow_waterlily="Summer", white_waterlily="Summer",water_crowfoot="Spring,Summer",water_mint="Summer,Autumn", - water_forgetmenot="Spring,Summer",brooklime="Spring,Summer"} + 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 + -- 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 index[id].fruiting=set(id=="blackberry" and "Summer,Autumn" or "Summer") end @@ -199,7 +242,7 @@ return function(hearth, api) function B.kind(value) local k=raw_kind(value) if index[k] then return k end - local id,part=(k or ""):match("^([a-z]+)_([a-z]+)$") + local id,part=(k or ""):match("^([a-z_]+)_([a-z]+)$") if index[id] and index[id].group=="trees" and ({seedling=true,young=true,mature=true,leaves=true,log=true,planks=true})[part] then return id end end @@ -235,8 +278,9 @@ return function(hearth, api) end end 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 + function B.seeding(value,season) local r=record(value); return r~=nil and r.seeding[season]==true end function B.edible(value,season) local r=record(value); return r~=nil and r.food==true and not r.toxic and r.fruiting[season]==true end @@ -285,10 +329,11 @@ return function(hearth, api) end end local DAY=86400 + local YEAR=365.2425*DAY -- shared with succession.frugivore; resource policy, not lifespan local function now() local n if api.now then n=api.now() else n=os.time() end - if not finite(n) or n<0 or n>9007199254740991-DAY then return nil end + if not finite(n) or n<0 or n>9007199254740991-YEAR then return nil end return math.floor(n) end function B.harvest(pos,player) @@ -309,17 +354,32 @@ return function(hearth, api) local stored=meta:get_string("botany_ready_at") local ready=0 if stored~="" then ready=tonumber(stored) end - if not finite(ready) or ready<0 then return false,"metadata" end + if not finite(ready) or ready<0 or ready>9007199254740991 then return false,"metadata" 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() - if not inv or not inv:room_for_item("main","hearth:berries 2") then return false,"inventory" end - -- Lock before inventory callbacks: reentrant harvest cannot duplicate fruit. - meta:set_string("botany_ready_at",tostring(time+DAY)) - local leftover=inv:add_item("main","hearth:berries 2") + if not inv then return false,"inventory" end + -- Reserve both budgets before even room_for_item: callbacks may reenter. + -- Pollination modifies an existing standing crop; it never creates fruit + -- 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() - if not finite(count) or count<0 or count>2 then return false,"inventory" end - if count==2 then meta:set_string("botany_ready_at",stored); return false,"inventory" end - return true,2-count + -- An invalid delivery result cannot safely be retried: keep the lock. + if not finite(count) or count%1~=0 or count<0 or count>amount then return false,"inventory" end + if count==amount then rollback(); return false,"inventory" end + return true,amount-count end local registered=false function B.register_nodes(register_node) diff --git a/mods/hearth/character.lua b/mods/hearth/character.lua index c8afc0c..49ebfdf 100755 --- a/mods/hearth/character.lua +++ b/mods/hearth/character.lua @@ -3,19 +3,19 @@ return function(hearth, api) local C = {VERSION = 1, MAX_JSON = 2048, FORM = "hearth:character"} local key = "hearth:appearance" local options = { - body = {"compact", "balanced", "broad"}, + body = {"compact", "lean", "balanced", "broad", "sturdy"}, skin = {"porcelain", "sand", "honey", "olive", "umber", "ebony"}, - face = {"calm", "angular", "soft", "weathered"}, + face = {"calm", "angular", "soft", "weathered", "lined"}, 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"}, - beard = {"none", "stubble", "moustache", "short_beard", "full_beard"}, - garment = {"tunic", "workshirt", "jerkin", "coat"}, + beard = {"none", "stubble", "moustache", "short_beard", "full_beard", "goatee", "braided_beard"}, + garment = {"tunic", "workshirt", "jerkin", "coat", "hooded_cloak", "travel_coat", "wool_mantle"}, cloth = {"sage", "teal", "slate", "mauve", "ochre", "rust", "linen", "charcoal"}, 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"}, - detail = {"none", "freckles", "scar", "freckles_scar"}, + detail = {"none", "freckles", "scar", "freckles_scar", "birthmark"}, } local order = {"body", "skin", "face", "eyes", "hair", "hairColor", "beard", "garment", "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 texture = table.concat({name("skin_" .. p.skin), name("face_" .. p.face), name("eyes_" .. p.eyes), name("garment_" .. p.garment .. "_" .. p.cloth), name("trousers_" .. p.trousers), name("boots_" .. p.boots), - name("accent_" .. p.accent .. "_" .. p.accentColor), name("hair_" .. p.hair .. "_" .. p.hairColor), - name("beard_" .. p.beard .. "_" .. p.hairColor), name("detail_" .. p.detail)}, "^") + name("hair_" .. p.hair .. "_" .. p.hairColor), name("beard_" .. p.beard .. "_" .. p.hairColor), + name("accent_" .. p.accent .. "_" .. p.accentColor), name("detail_" .. p.detail)}, "^") return "hearth_person" .. (p.body == "balanced" and "" or "_" .. p.body) .. ".b3d", texture end 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", cloth = "Cloth color", trousers = "Trousers", boots = "Boot leather", accent = "Accent", accentColor = "Accent color", detail = "Face details"} - local names = {sidepart = "Side part", workshirt = "Work shirt", freckles_scar = "Freckles and scar"} + local 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) return names[id] or (id:gsub("_", " "):gsub("^%l", string.upper)) end @@ -146,7 +151,7 @@ return function(hearth, api) "model[0.65,1.65;5.1,6.4;traveler;" .. esc(mesh) .. ";" .. esc(texture) .. ";0," .. st.yaw .. ";true;true;0,19;12]", "button[0.7,8.25;1.55,0.6;front;Front]button[2.4,8.25;1.55,0.6;profile;Profile]button[4.1,8.25;1.55,0.6;back;Back]", - "tooltip[traveler;Drag to turn the preview. Only this model rotates.]"} + "tooltip[traveler;" .. esc("Drag to turn the preview. Only this model rotates.") .. "]"} for i, tab in ipairs(tabs) do f[#f+1] = "button[" .. (6.2 + (i-1)*1.8) .. ",1.65;1.7,0.65;tab_" .. i .. ";" .. tab[1] .. "]" end @@ -184,7 +189,7 @@ return function(hearth, api) for k, v in pairs(fields) do count = count + 1; if count > 20 then return false end if visible[k] then - local n = type(v) == "string" and #v <= 1 and v:match("^[1-9]$") and tonumber(v) + 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 draft[visible[k]] = options[visible[k]][n] elseif not controls[k] or (type(v) ~= "string" and type(v) ~= "boolean") diff --git a/mods/hearth/content.lua b/mods/hearth/content.lua index 0fb204c..79e95e5 100755 --- a/mods/hearth/content.lua +++ b/mods/hearth/content.lua @@ -25,6 +25,8 @@ local api = setmetatable({ end, }, {__index = core}) 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 seasons = dofile(modpath .. "/seasons.lua")(hearth, api) hearth.seasons = seasons @@ -796,4 +798,9 @@ botany.register_nodes(register_node) -- Species leaves and canonical ecological cubes/sprites must precede variants. 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) +-- Wildlife owns factory loading; publish that exact catalogue, not a second one. +hearth.fauna = hearth.wildlife.fauna diff --git a/mods/hearth/diversity.lua b/mods/hearth/diversity.lua index e9c0773..1068e7a 100755 --- a/mods/hearth/diversity.lua +++ b/mods/hearth/diversity.lua @@ -1,6 +1,6 @@ -- Species/niche policy only. Succession owns the one fixed-eight scheduler. return function(hearth, api) - local D = {species_order={"oak","birch","pine","willow","alder","beech","aspen","rowan","hazel","spruce"}, + 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"}, 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}} @@ -14,19 +14,23 @@ return function(hearth, api) for _, plant in ipairs(D.plants) do flora[plant]=true end function D.tree_info(value) local k=kind(value) - local s,stage=(k or ""):match("^([a-z]+)_([a-z]+)$") + local s,stage=(k or ""):match("^([a-z_]+)_([a-z]+)$") if species[s] and ({seedling=true,young=true,mature=true})[stage] then return s,stage end end function D.family(value) local k=kind(value) local s,stage=D.tree_info(k) if s then return ({seedling="tree_seedling",young="young_tree",mature="mature_tree"})[stage] end - local wood,part=(k or ""):match("^([a-z]+)_(leaves)$") + local wood,part=(k or ""):match("^([a-z_]+)_(leaves)$") 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 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_rock(value) return D.rocks[kind(value)]==true end function D.supports_ground(value, support) @@ -39,7 +43,9 @@ return function(hearth, api) alder={alluvium=true,peat=true,forest_floor=true}, beech={grass=true,soil=true,forest_floor=true}, aspen={grass=true,forest_floor=true,alluvium=true}, rowan={grass=true,forest_floor=true,podzol=true,alpine_turf=true}, - hazel={grass=true,soil=true,forest_floor=true}, spruce={podzol=true,forest_floor=true,alpine_turf=true}} + 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) if not species[s] then return false end 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"} elseif h.ground=="forest_floor" and (h.canopy or 0)>=0.5 then choices={"oak","birch"} else choices={"birch","oak"} end - -- Retain the old pioneer niches at the origin while admitting all ten + -- Retain the old pioneer niches at the origin while admitting all thirteen -- 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 for _,s in ipairs(choices) do if D.suitable(s.."_seedling",h) then return s.."_seedling" end end end @@ -150,15 +156,24 @@ return function(hearth, api) local current=api.get_node_or_nil(p) if not current or (n and current.name~=n.name) then return false end n=current - -- Only fallen logs join the existing flora adoption policy; generic - -- litter/fungi and standing log/leaves trees are not newly adopted. - if not n or not (D.is_worldgen_flora(n) or n.name=="hearth:deadwood") or not unmarked(p) then return false end + local root_species=kind(n) and kind(n):match("^([a-z_]+)_log$") + 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 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 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 block_root and not D.is_ground(support) then return false end local m=api.get_meta(p) if m:get_string("succ_natural")=="1" then return false end m:set_string("succ_natural","1") + 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 end 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}) end 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 + 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 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", diff --git a/mods/hearth/ecology.lua b/mods/hearth/ecology.lua index 02f6dc3..f91d9b9 100755 --- a/mods/hearth/ecology.lua +++ b/mods/hearth/ecology.lua @@ -1,7 +1,8 @@ -- Bounded, restart-safe ecology for perennial forage, saplings and seasonal crops. return function(hearth, api, construction) - local DAY, MAX_CATCHUP = 86400, 30 * 86400 - local CROP_STEP, CROP_MATURE_HOURS = 21600, 60 + local DAY, YEAR = 86400, 365.2425*86400 + -- 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) value = tonumber(value) 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) if not planted or not now or planted < 0 or now < 0 then return 0 end planted, now = math.floor(planted), math.floor(now) - now = math.min(math.floor(now), planted + MAX_CATCHUP) if planted <= 0 or now <= planted then return 0 end - local progress, cursor = 0, planted - while cursor < now and progress < CROP_MATURE_HOURS do - local finish = math.min(now, cursor + CROP_STEP) - local sample = cursor + math.floor((finish - cursor) / 2) + -- Bounded midpoint quadrature covers the ENTIRE calendar interval. Long + -- gaps coarsen samples, never silently discard all age after day thirty. + local duration=now-planted + 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 temperature = finite(climate and climate.temperature) or 12 local rate = temperature < 3 and 0 or (temperature < 10 and 0.5 or 1) - progress = progress + (finish - cursor) / 3600 * rate - cursor = finish + progress = progress + duration/samples/3600*rate end - return math.min(CROP_MATURE_HOURS, progress) + return math.min(CROP_MATURE_HOURS,progress,duration/3600) end local function crop_stage(planted, now) local growth = climate_progress(planted, now) if growth >= CROP_MATURE_HOURS then return 4 end - if growth >= 36 then return 3 end - if growth >= 12 then return 2 end + if growth >= 72*24 then return 3 end + if growth >= 24*24 then return 2 end return 1 end 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 local meta, now = api.get_meta(pos), safe_now() local planted = tonumber(meta:get_string("planted_at")) or 0 - if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end + 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) + -- 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 if current.name ~= target then api.swap_node(pos, {name = target, param2 = current.param2}) end return stage < 4 @@ -98,28 +106,37 @@ return function(hearth, api, construction) api.get_meta(pos):set_string("player_placed", "1") end local function harvest_berries(pos, player) - if not construction.allowed(pos, player, "use") then return end + if not construction.allowed(pos, player, "use") then return false end 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 - hearth.notify(player, "The berry bush is regrowing; return after one real day.") - return + hearth.notify(player, "The berry bush has spent this crop; check a future Summer after its annual rest.") + return false 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 output = ItemStack("hearth:berries 3") if not inv or not inv:room_for_item("main", output) then hearth.notify(player, "Make inventory space before picking these berries.") - return + return false end 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) - if not leftover or leftover:get_count() >= requested then return end - api.set_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2}) - local ready = safe_now() + DAY - api.get_meta(pos):set_string("ready_at", tostring(ready)) - api.get_meta(pos):set_string("infotext", "Berry bush: regrowing (one real day)") + local count=leftover and finite(leftover:get_count()) + if not count or count<0 or count>requested then return false end -- uncertain delivery stays locked + if count==requested then meta:set_string("ready_at",previous); return false end + api.swap_node(pos, {name = "hearth:berry_bush_bare", param2 = n.param2}) + meta:set_string("infotext", "Berry bush: annual rest; next eligible Summer") api.get_node_timer(pos):start(DAY) if hearth.invalidate_environment then hearth.invalidate_environment(pos) end + return true end local function harvest_seasonal(pos, player) 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] if not item then return false end local now, meta = safe_now(), api.get_meta(pos) - local ready = finite(meta:get_string("succ_harvest_ready_at")) or 0 - if not seasons.available(kind, now) or ready > now then + local previous=meta:get_string("succ_harvest_ready_at") + 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.") return false end local inv, output = player:get_inventory(), ItemStack(item) if not inv or not inv:room_for_item("main", output) then return false end -- One item only: no partial multi-item harvest can lose or duplicate food. - local leftover = inv:add_item("main", output) - if not leftover or leftover:get_count() ~= 0 then return false end meta:set_string("succ_harvest_ready_at", tostring(now + DAY)) + 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 end local function refresh_bush(pos) local n = node(pos) if not n or n.name ~= "hearth:berry_bush_bare" then return false end local meta, now = api.get_meta(pos), safe_now() - local ready = tonumber(meta:get_string("ready_at")) or 0 - if ready <= now then + local ready = finite(meta:get_string("ready_at")) + 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}) meta:set_string("ready_at", "0"); meta:set_string("infotext", "") api.get_node_timer(pos):stop() @@ -158,8 +180,7 @@ return function(hearth, api, construction) local grow_sapling local function reconcile_sapling(pos) if grow_sapling(pos) then - local planted = tonumber(api.get_meta(pos):get_string("planted_at")) or safe_now() - api.get_node_timer(pos):start(math.max(60, math.min(DAY, planted + 3 * DAY - safe_now()))) + api.get_node_timer(pos):start(DAY) end end 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 local meta, now = api.get_meta(pos), safe_now() local planted = tonumber(meta:get_string("planted_at")) or 0 - if planted <= 0 or planted > now then meta:set_string("planted_at", tostring(now)); planted = now end + 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") - if now - planted < 3 * DAY or not tree_clear(pos, owner) then return true end - for y = 0, 3 do api.set_node({x=pos.x,y=pos.y+y,z=pos.z},{name="hearth:log"}) end + if now - planted < YEAR or not tree_clear(pos, owner) then return true 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 local radius = y == 5 and 1 or 2 if math.abs(x) + math.abs(z) <= radius and not (x == 0 and z == 0 and y < 5) then local p = {x=pos.x+x,y=pos.y+y,z=pos.z+z} - if api.get_node(p).name == "air" then api.set_node(p, {name="hearth:leaves"}) end + if api.get_node(p).name == "air" then plant_wood(p,"hearth:leaves") 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 end local function plant_sapling(stack, player, pointed) @@ -207,7 +247,7 @@ return function(hearth, api, construction) api.set_node(pos, {name = "hearth:sapling"}) local meta = api.get_meta(pos) meta:set_string("planted_at", tostring(safe_now())); meta:set_string("planter", player:get_player_name()) - api.get_node_timer(pos):start(3 * DAY) + api.get_node_timer(pos):start(DAY) stack:take_item(1) return stack end @@ -226,5 +266,5 @@ return function(hearth, api, construction) refresh_bush = refresh_bush, reconcile_bush = reconcile_bush, reconcile_crop = reconcile_crop, plant_sapling = plant_sapling, grow_sapling = grow_sapling, reconcile_sapling = reconcile_sapling, - MAX_CATCHUP = MAX_CATCHUP, CROP_MATURE_HOURS = CROP_MATURE_HOURS} + CROP_SAMPLE_LIMIT = CROP_SAMPLES, CROP_MATURE_DAYS = 120, CROP_MATURE_HOURS = CROP_MATURE_HOURS} end diff --git a/mods/hearth/fauna.lua b/mods/hearth/fauna.lua new file mode 100755 index 0000000..a496a69 --- /dev/null +++ b/mods/hearth/fauna.lua @@ -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 diff --git a/mods/hearth/interface.lua b/mods/hearth/interface.lua index 309f656..2cb0a33 100755 --- a/mods/hearth/interface.lua +++ b/mods/hearth/interface.lua @@ -1,5 +1,10 @@ local C = core 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. local ink = {base="#1e1e2e", mantle="#181825", raised="#292940", border="#3c3d56", 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.", "Use /claim at your homestead to protect a 25 x 25 area, extending 24 blocks up/down. Use /claim show for temporary boundary markers. Share with /claim share resident (build and household use) or /claim share storekeeper (storage only). /claim unshare revokes access; /claim list shows members; /claim remove releases the claim and grants.\n\nPlace homestead storage inside a claim. Its inventory stays private to the owner, residents and storekeepers; non-empty storage cannot be removed. Workstations and construction require resident permissions.\n\nUse /sethome in your own safe claim; /home rechecks that position before moving you and safely falls back to the world arrival if the home is blocked or hazardous. Your inventory, food, water and wetness persist through death.\n\nLogged-out player needs pause. Hearth processes and fuel use real elapsed time; completed output remains stored until claimed. Food does not spoil in this slice. This remains a prototype, not a hardened public service."}, {"Living ecosystems", "NOTHING GROWS ALONE", "Watch a meadow change; there is no quest to complete.", - "An original succession model inspired by Dynamic Life connects light, moisture, plants and animals. The botanical catalogue has 80 named species: eight categories of ten each. Use the Botanical guide button or /botany [search] for common and scientific names, habitat, seasons, toxicity labels and roles. These are game-only notes, not a real-world forage guide.\n\nBirch pioneers open gaps; oak favors meadow/forest soils. Evergreen pine and spruce keep winter shade; deciduous oak, birch, willow, alder, beech, aspen, rowan and hazel thin in Winter. Willow favors wet alluvium/peat. Nitrogen-fixer policy can add at most two nutrient units per support across replacements/restarts while reserves are low. The legacy alder terrain profile alone is not a separate fertilizer process. Litter, fungi, deadwood and dung recycle finite nutrients. Fallen logs are ordinary sideways cubes: an axe yields one log, not hand-gathered sticks.\n\nSpring/Summer clover and flowers support pollinators; spring bluebells and autumn heather have seasonal windows. Wet sedge/bulrush, shade moss/ferns, rock lichen and nutrient-needing nettle have distinct niches. The survey counts supporting plants, not insects. Hares and deer graze natural cover; foxes pursue hares. Use /ecosystem for actual ground, light, moisture, canopy, nutrient reserve and active wildlife totals. Moisture is a bounded habitat estimate, not fully simulated hydrology, erosion or flooding.\n\nRoles are conditional policies or descriptors, not effects guaranteed for every species: parasitism and allelopathy cause no plant damage, and a resin tag is not an extraction recipe. Wild succession advances only around connected players in loaded areas. It pauses across unloading and restart, with no offline catch-up. Claims, placed plants, crops and construction are excluded from autonomous changes. Stages take active sessions, not a daily login. Crops and planted saplings keep separate growth rules."}, + "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.", "Local play asks you to choose approximate coordinates. No location service, IP lookup or external request is used. Use /location to change them. Coordinates stay in local world storage.\n\nA community server must set its shared latitude and longitude in configuration. Remote players cannot override them. The world follows the host's real UTC clock.\n\nSun height and day length follow latitude, longitude and date. The engine's fixed sky orbit is approximated; it is not an exact astronomical renderer. The HUD uses UTC and local solar time, not civil timezone labels.\n\nClimate and rain are synthetic. Calendar-season labels use meteorological months; southern latitudes reverse them."}, {"Geology & timber", "READ THE REGIONAL MATERIALS", "Ten biomes; shared sky, different ground.", - "Temperate meadow and oak woodland favor limestone; birch forest and pine taiga favor granite. Wet forest has peat over slate; floodplain has alluvium over sandstone. Dry scrub has sandstone, alpine meadow has turf/scree over slate, rocky peaks have snow/scree over granite, and riverbank has sand/gravel over sandstone. These are ten terrain communities, not ten independent weather simulations. The climate HUD remains shared; altitude still affects exposure temperature.\n\nQuartz seams occur in granite/slate; copper in basalt/limestone/slate up to Y=32; coal in sandstone/slate/limestone up to Y=70. Deeper beds and basalt intrusions differ from surface rock. Mine with a pick; each seam drops two resource items. Three quartz craft one existing window, without a glass furnace.\n\nOne copper ore + one charcoal craft one copper charge. At a lit hearth it takes 45 real seconds and reserves 30 fuel seconds, yielding one ingot. Three copper ingots across the top row, with two sticks down the center, make one faster, longer-lasting copper pick. Iron remains a separate 60-second smelt and axe route.\n\nOak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each burn for 180 seconds, or craft four matching planks. Convert two matching species logs to two canonical logs for log + clay charcoal charges; convert species planks 1:1 to canonical planks for exact-name building recipes. One regional rock crafts four matching bricks; two matching rocks convert to two canonical stones. Put each input in separate crafting slots (shapeless); there is no single-log or single-rock canonical conversion. Fallen logs are sideways full cubes; an axe yields one canonical log, never two hand-gathered sticks. Coal burns 240 seconds; charcoal 90; sticks 45. All fuel is capped at 600 seconds. Coal is geological and nonrenewable, not a renewable fuel loop.\n\nThe broad-relief terrain preset is for fresh worlds only. Existing chunks stay untouched; new biome registrations can change unexplored old-world boundaries. Operators must explicitly choose a new world or accept those seams; never delete map metadata to force a migration."}, + "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 = { {"trees", "Trees"}, {"shrubs", "Shrubs"}, {"grasses", "Grasses, sedges & rushes"}, @@ -68,7 +73,7 @@ end local function botanical_summary() local records, lines = catalogue(), {} if #records == 0 then return "Botanical catalogue unavailable in this runtime. Use /botany when installed." end - lines[1] = "BOTANICAL CATALOGUE / " .. #records .. " named species; eight categories of ten in the full catalogue.\nUse /botany [search] for scientific names, habitat windows, seasons and ecological roles." + 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 local names = {} 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 "") 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[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]" - .. "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[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]" C.show_formspec(player:get_player_name(), "hearth:guide", table.concat(f)) 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: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: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:firestarter", "hearth:vessel", "hearth:torch", "hearth:fire", "hearth:wall", "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."}, {"Copper smelting", "One hearth:copper_charge (one hearth:copper_ore + one hearth:charcoal) + lit hearth: 45 real seconds; 30 fuel seconds reserved at start; one hearth:copper_ingot output. Requires at least 75 fuel seconds to queue. Collect empty-handed; a full inventory leaves the ingot waiting. Copper pick: three ingots across the top and two central sticks."}, {"Regional geology", "Quartz in granite/slate: three hearth:quartz craft one window; the reed/plank route remains. Copper ore in basalt/limestone/slate; coal in sandstone/slate/limestone. Each seam drops two items. One granite, basalt, limestone, slate or sandstone crafts four matching bricks. Convert two matching rocks to two canonical stones in separate crafting slots (shapeless); no single-rock canonical conversion."}, - {"Species timber", "One oak, birch, pine, willow, alder, beech, aspen, rowan, hazel or spruce log crafts four matching planks. Convert two matching species logs in separate crafting slots (shapeless) to two canonical hearth:log; no single-log canonical conversion. Species planks convert 1:1 to canonical hearth:planks. Charcoal charges accept only canonical log + clay: convert species logs in matching pairs first, not four planks back into logs."}, - {"Finite nitrogen", "Nitrogen-fixer policy, including clover and alder in the botanical runtime, can add at most two nutrient units per support across replacements and restarts while the reserve is low. The legacy alder terrain profile alone is not a fertilizer process. Nutrient reserve is bounded at eight; litter, deadwood, fungi and dung recycle finite material."}, + {"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", "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."}, {"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."}, @@ -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."}, {"Spring fish run", "A trap finishing during spring returns two fish; other seasons return one. The catch is determined by its scheduled deadline, even if you are away."}, {"Light a hearth", "Fuel first, then use a flint firestarter. Each starter has 20 successful ignitions."}, - {"Fire fuel", "Each stick adds 45 real seconds; canonical, oak, birch, pine, willow, alder, beech, aspen, rowan, hazel and spruce logs each add 180; charcoal adds 90; coal adds 240 seconds. Stored reserve is capped at 600; a fuel item that would exceed it is not consumed. Planks and buildings are not direct fuel. Coal is geological and nonrenewable; it cannot be crafted or grown."}, + {"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."}, {"Bedroll rest", "Craft from three planks and three reeds. Right-click beneath a roof; remain still and safe for five seconds."}, {"Crop growth", "Plant grain seed on grass or soil. Growth samples historical local solar climate; cold weather pauses it. Harvest ripe crops for grain, then reserve one kernel to replant."}, - {"Renewal", "Berry bushes return after one real day. A planted sapling grows after three days when its bounded tree volume is clear. No resource can regrow above map limits or through claims."}, + {"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."}, {"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."}, @@ -207,8 +214,8 @@ function hearth.recipes(player, query, page) .. "box[0.65,2.2;9.3,0.8;" .. ink.mantle .. "]" .. "field[0.7,2.25;9.2,0.7;query;Result or ingredient (plain text);" .. esc(query) .. "]field_close_on_enter[query;false]" .. "button[10.25,2.25;3.1,0.7;search;Search]" - .. "tooltip[query;Up to 48 characters. Search matches literal text, not patterns.;" .. ink.mantle .. ";" .. ink.text .. "]" - .. "tooltip[search;Search crafting recipes and real-time hearth processes.;" .. ink.mantle .. ";" .. ink.text .. "]" + .. tooltip("query","Up to 48 characters. Search matches literal text, not patterns.") + .. tooltip("search","Search crafting recipes and real-time hearth processes.") .. label(0.7,3.3,"Page " .. page .. " / " .. pages_count .. " / " .. #all .. " matches",ink.muted) .. "textarea[0.65,3.7;12.7,4.6;;;" .. esc(table.concat(lines, "\n\n")) .. "]" .. (page > 1 and "button[0.7,8.85;2.8,0.65;previous;Previous]" or "") @@ -284,7 +291,7 @@ function hearth.botanical_guide(player, query, page) session.botany_query, session.botany_page = query, page C.show_formspec(name,"hearth:botany",journal(14,10) .. label(0.65,0.55,"eom.dev / BOTANICAL FIELD GUIDE",ink.mauve) - .. label(0.65,1.15,#records .. " named species / eight categories of ten in the full catalogue",ink.yellow) + .. label(0.65,1.15,#records .. " named species / eight ecological groups of uneven sizes",ink.yellow) .. divider(0.65,1.65,12.7) .. "field[0.7,1.95;9.2,0.7;query;Species, scientific name, category, habitat or role;" .. esc(query) .. "]field_close_on_enter[query;false]" .. "button[10.25,1.95;3.1,0.7;search;Search]" @@ -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,7.7;12.7,0.55;;;" .. esc("Game-only notes, NOT a real-world forage guide. Unflagged does not mean safe to eat. Roles are conditional, not universal effects.") .. "]" .. (#choices > 0 and "dropdown[0.7,8.3;5,0.45;species_choice;" .. table.concat(choices,",") .. ";1;true]" - .. "tooltip[species_choice;Species article]button[6,8.3;3.3,0.45;species_article;Species article]" or "") + .. 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 < count and "button[3.05,8.85;2.2,0.65;next;Next]" or "") .. "button[6,8.85;3.3,0.65;ecosystem_survey;Ecosystem survey]" .. "button[10.05,8.85;3.3,0.65;field_notes;Back to field notes]") end +-- 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)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=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 local session = hearth.sessions[player:get_player_name()] 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 session.botany_selected = n 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.species_article then 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 elseif form == "hearth:ecosystem" then if not hearth.sessions[player:get_player_name()] then return true end - if fields.field_notes then hearth.guide(player,6) + 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.refresh then hearth.ecosystem(player) end 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.ecosystem_survey then hearth.ecosystem(player); 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 local ok, message = inspect(player, "") 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) return true 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]", description = "Read a targeted species' offline Wikipedia excerpt and current game effects", func = function(name, param) diff --git a/mods/hearth/lifecycle.lua b/mods/hearth/lifecycle.lua new file mode 100755 index 0000000..61b3e91 --- /dev/null +++ b/mods/hearth/lifecycle.lua @@ -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)=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 nowr.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 nowj) 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=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 diff --git a/mods/hearth/mod.conf b/mods/hearth/mod.conf index 891a954..4fcb58f 100755 --- a/mods/hearth/mod.conf +++ b/mods/hearth/mod.conf @@ -1,5 +1,6 @@ name = 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 min_minetest_version = 5.18 +depends = creatura diff --git a/mods/hearth/species_effects.lua b/mods/hearth/species_effects.lua index 778ec76..4da55d9 100755 --- a/mods/hearth/species_effects.lua +++ b/mods/hearth/species_effects.lua @@ -32,7 +32,7 @@ return function(hearth, api) local function record(value) return B.get(type(value)=="table" and value.id or value) end - -- Identity references cover all 80 records. An identity link is not evidence + -- Identity references cover all 100 records. An identity link is not evidence -- that every tagged trait has been reviewed or implemented. function E.source(value) local r=record(value); if not r then return nil end @@ -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 end local tags={"nitrogenfixer","browse","pollinator","ectomycorrhiza","saprotroph","parasite","engineer","nursery","allelopathy","resin"} - local nitrogen="After 900 warm active seconds: at most 1 nutrient per eligible evaluation, 2 total per support via succ_fixed_nitrogen shared across species/restarts; stops at fertility 4 (soil cap 8)." + local 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={ - browse="Active natural plants count as wildlife forage; grazing consumes eligible plants, not mature trees. Not human food permission.", - pollinator="Only natural plants in their catalogue flowering season add to pollination. This input does not itself change berry timers.", + 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="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.", parasite="Not simulated: no infection or attack on living trees or player plants.", 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=="ectomycorrhiza" then if r.group=="fungi" then - status,game="simulated","Active compatible nearby fungus permits seedling growth at 16200 instead of 21600 active seconds; successful growth debits at most 1 existing soil nutrient once, regardless of fungus count. No nutrient creation or carbon/water ledger." + 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 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 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." @@ -91,6 +91,11 @@ return function(hearth, api) if r.group=="trees" then add("canopy","simulated","Trunks/crowns reduce local habitat light and contribute moisture through existing canopy sampling; pine/spruce retain full Winter influence, deciduous foliage retains 15%. Not a hydrological solver.") end + 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 return out end diff --git a/mods/hearth/succession.lua b/mods/hearth/succession.lua index fcb4393..a046788 100755 --- a/mods/hearth/succession.lua +++ b/mods/hearth/succession.lua @@ -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) local S = {BUDGET = 8, RADIUS = 12, INTERVAL = 1, MAX_GAP = 1200, CACHE_LIMIT = 8192, 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 diversity = hearth.diversity 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 -- Content may inject the factory before succession. Standalone integrations -- 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 return math.min(maximum or 1000000000000, n) 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 previousnow 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 climate = hearth.climate_at and hearth.climate_at(now) local warm = not climate or (climate.temperature or 12) >= 3 local decomposer = k=="dung" or k=="litter" or k=="deadwood" or k=="fungi" local dormant = (record and not botany.active(k,season)) or (hearth.seasons and not hearth.seasons.available(k,now)) local age_dt = (not decomposer and (not warm or dormant)) and 0 or dt - local age = number(m,"succ_age",1000000)+age_dt - m:set_string("succ_age", tostring(age)) + local age = decay_age or (phase and phase.age_seconds) or number(m,"succ_age",1000000)+age_dt + -- 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 -- Commit the attempt BEFORE evaluating it, including failed competition/dispersal. 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 = harsh and number(m,"succ_stress",100000)+stress_dt or 0 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" if botany and ((record and record.group=="fungi") or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter") then remains=nil end if diversity and diversity.is_rock(node(at(p,0,-1,0))) then remains=nil end return change(p,remains) end if decomposer then - local lifetime = ({dung=600,litter=1800,deadwood=7200,fungi=2400})[k] + local lifetime = DECAY[k] 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 - 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 disperse(p,"fungi",serial) end return false end if not warm then return false end - if record and age>=900 and record.tags.nitrogenfixer then + if record and age>=90*DAY and record.tags.nitrogenfixer then local below=at(p,0,-1,0) local soil=api.get_meta(below) local fixed=number(soil,"succ_fixed_nitrogen",2) @@ -572,11 +722,10 @@ return function(hearth, api) soil:set_string("succ_fixed_nitrogen",tostring(fixed+delta)) 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 if botany and species and f=="tree_seedling" then for _,fungus in ipairs(h.root_fungi) do exchange=math.max(exchange,botany.root_fungi(fungus,species,fertility)) end - if exchange>0 then seed_age=16200 end end if f=="tree_seedling" and age>=seed_age and h.canopy<1.5 then if change(p,species and species.."_young" or "young_tree") then @@ -585,13 +734,19 @@ return function(hearth, api) end return false end - if f=="young_tree" and age>=86400 and h.canopy<2 then return change(p,species and species.."_mature" or "mature_tree") end + 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 age>=259200 then return change(p,"deadwood") end - if age>=1800 and serial%10==0 then disperse(p,species and species.."_seedling" or "tree_seedling",serial) end - if serial%5==0 then disperse(p,"litter",serial) end + -- One present seed opportunity, never one offspring per missed year. + if phase and phase.phase=="mature" and (not record or botany.seeding(k,season)) + 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 - local lifetime=({shrubs=86400,grasses=21600,herbs=14400,ferns_mosses=43200,fungi=2400,aquatic=21600,lichens=86400})[record.group] if record.tags.saprotroph and (h.substrate=="deadwood" or h.substrate=="litter") and botany.suitable(k,h,season) then -- Advance only an existing natural substrate's finite clock; @@ -599,24 +754,17 @@ return function(hearth, api) local below=at(p,0,-1,0) if natural(below,node(below)) and permitted(below) then local wood=api.get_meta(below) - local limit=h.substrate=="deadwood" and 7200 or 1800 - wood:set_string("succ_age",tostring(math.min(limit,number(wood,"succ_age",limit)+math.min(dt,PERIOD)))) + local limit=DECAY[h.substrate] + 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 - if age>=lifetime then - local remains="litter" - if record.group=="fungi" or h.water>0 or h.substrate=="bark" or h.substrate=="deadwood" or h.substrate=="litter" - or (diversity and diversity.is_rock(node(at(p,0,-1,0)))) then remains=nil end - return change(p,remains) + -- Unknown maturity (fungi/lichens) is not automatic propagation or + -- death permission. A sprite persists as its root/colony observation. + if phase and phase.phase=="mature" and botany.seeding(k,season) + and now>=number(m,"succ_seed_epoch",9007199254740991) then + m:set_string("succ_seed_epoch",tostring(now+30*DAY)); disperse(p,k,serial) end - if age>=7200 and h.water==0 and ground(at(p,0,-1,0)) and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then - local th={}; for field,value in pairs(h) do th[field]=value end; th.include_trees=true - -- The same catalogue selector is the source of all ten trees; - -- skip non-tree results without changing its deterministic order. - local recruit=botany.choose(p,th,season) - if recruit and trees[recruit] then return change(p,recruit) end - end - if age>=900 and serial%3==0 then disperse(p,k,serial) end elseif diversity and diversity.is_worldgen_flora(k) and not trees[k] then if age>=7200 and h.canopy<1.5 and (p.x*7+p.z*11)%23==0 then local recruit=diversity.choose_tree(p,h) @@ -660,25 +808,170 @@ return function(hearth, api) end end return false 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 hightime 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=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) - 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) - local n = node(q) + local n,m = resource_site(q) 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 not botany.browse(k,hearth.seasons and hearth.seasons.season() or "Summer") - or (trees[k] and family(k)~="tree_seedling") then return false end + local season=hearth.seasons and hearth.seasons.season() or "Summer" + 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 k~="meadow_grass" and k~="fern" then return false end - if hearth.seasons and not hearth.seasons.available(k) then return false end - if not change(q,nil) then return false end - api.get_meta(q):set_string("succ_next",tostring(os.time()+300)) + if mode~="seasonal_browse" and mode~="winter_browse" + and hearth.seasons and not hearth.seasons.available(k) then return false end + 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 end) end function S.deposit(raw) + cache_nut=nil local p = position(raw) if not p or not node(p) or not permitted(p) then return false end return nearby(p,function(q) @@ -743,7 +1036,7 @@ return function(hearth, api) if hearth.seasons then for _, name in ipairs(hearth.seasons.names()) do reload_nodes[#reload_nodes+1]=name end end - api.register_lbm({name="hearth:succession_loaded", label="Pause natural succession across unloading", + api.register_lbm({name="hearth:succession_loaded", label="Reconcile calendar roots within the loaded work budget", nodenames=reload_nodes, run_at_every_load=true, action=function(p) seen[key(p)]=nil diff --git a/mods/hearth/wiki_articles.lua b/mods/hearth/wiki_articles.lua index 96dc3ae..1c30675 100755 --- a/mods/hearth/wiki_articles.lua +++ b/mods/hearth/wiki_articles.lua @@ -10,7 +10,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\055\052\053\050\057\049\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\081\117\101\114\099\117\115\095\114\111\098\117\114\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\057\084\049\051\058\049\054\058\051\050\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\081\117\101\114\099\117\115\032\114\111\098\117\114\044\032\112\101\100\117\110\099\117\108\097\116\101\032\111\097\107\044\032\069\117\114\111\112\101\097\110\032\111\097\107\044\032\111\114\032\069\110\103\108\105\115\104\032\111\097\107\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\098\101\101\099\104\032\097\110\100\032\111\097\107\032\102\097\109\105\108\121\044\032\070\097\103\097\099\101\097\101\046\032\073\116\032\105\115\032\097\032\108\097\114\103\101\032\116\114\101\101\044\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\032\097\110\100\032\119\101\115\116\101\114\110\032\065\115\105\097\044\032\097\110\100\032\105\115\032\119\105\100\101\108\121\032\099\117\108\116\105\118\097\116\101\100\032\105\110\032\111\116\104\101\114\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\046\032\073\116\032\103\114\111\119\115\032\111\110\032\115\111\105\108\115\032\111\102\032\110\101\097\114\032\110\101\117\116\114\097\108\032\097\099\105\100\105\116\121\032\105\110\032\116\104\101\032\108\111\119\108\097\110\100\115\032\097\110\100\032\105\115\032\110\111\116\097\098\108\101\032\102\111\114\032\105\116\115\032\118\097\108\117\101\032\116\111\032\110\097\116\117\114\097\108\032\101\099\111\115\121\115\116\101\109\115\044\032\115\117\112\112\111\114\116\105\110\103\032\097\032\100\105\118\101\114\115\105\116\121\032\111\102\032\104\101\114\098\105\118\111\114\111\117\115\032\105\110\115\101\099\116\115\044\032\097\099\111\114\110\032\101\097\116\105\110\103\032\109\097\109\109\097\108\115\032\097\110\100\032\098\105\114\100\115\044\032\097\110\100\032\102\117\110\103\105\046\010\084\104\101\032\099\111\109\109\111\110\032\110\097\109\101\032\112\101\100\117\110\099\117\108\097\116\101\032\111\097\107\032\114\101\102\101\114\115\032\116\111\032\116\104\101\032\097\099\111\114\110\115\032\098\101\105\110\103\032\098\111\114\110\101\032\111\110\032\097\032\112\101\100\117\110\099\117\108\101\044\032\111\114\032\115\116\097\108\107\046\010\069\110\103\108\105\115\104\032\111\097\107\115\032\099\097\110\032\108\105\118\101\032\116\111\032\118\101\114\121\032\111\108\100\032\097\103\101\115\044\032\111\102\116\101\110\032\103\114\111\119\105\110\103\032\102\111\114\032\111\118\101\114\032\053\048\048\032\121\101\097\114\115\044\032\119\105\116\104\032\115\111\109\101\032\115\112\101\099\105\109\101\110\115\032\098\101\108\105\101\118\101\100\032\116\111\032\098\101\032\111\118\101\114\032\049\044\048\048\048\032\121\101\097\114\115\032\111\108\100\046\032\084\104\101\121\032\097\114\101\032\099\104\097\114\097\099\116\101\114\105\115\101\100\032\098\121\032\116\104\105\099\107\032\097\110\100\032\104\101\097\118\105\108\121\032\102\105\115\115\117\114\101\100\032\098\097\114\107\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -29,7 +29,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\055\050\050\049\053\055\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\066\101\116\117\108\097\095\112\101\110\100\117\108\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\056\084\049\048\058\051\057\058\053\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\066\101\116\117\108\097\032\112\101\110\100\117\108\097\044\032\116\104\101\032\115\105\108\118\101\114\032\098\105\114\099\104\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\116\114\101\101\032\105\110\032\116\104\101\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\102\097\109\105\108\121\044\032\066\101\116\117\108\097\099\101\097\101\046\032\084\104\101\032\116\114\101\101\115\032\099\097\110\032\114\101\097\099\104\032\097\032\104\101\105\103\104\116\032\111\102\032\051\048\032\109\044\032\097\110\100\032\104\097\118\101\032\119\104\105\116\101\032\098\097\114\107\032\116\104\097\116\032\115\104\101\100\115\032\114\101\103\117\108\097\114\108\121\046\032\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\077\111\114\111\099\099\111\044\032\116\101\109\112\101\114\097\116\101\032\065\115\105\097\044\032\067\097\110\097\100\097\044\032\097\110\100\032\065\108\097\115\107\097\044\032\097\108\116\104\111\117\103\104\032\105\110\032\115\111\117\116\104\101\114\110\032\069\117\114\111\112\101\032\097\110\100\032\077\111\114\111\099\099\111\032\105\116\032\105\115\032\111\110\108\121\032\102\111\117\110\100\032\097\116\032\104\105\103\104\101\114\032\101\108\101\118\097\116\105\111\110\115\046\032\073\116\115\032\114\097\110\103\101\032\101\120\116\101\110\100\115\032\105\110\116\111\032\083\105\098\101\114\105\097\044\032\067\104\105\110\097\044\032\097\110\100\032\115\111\117\116\104\119\101\115\116\032\065\115\105\097\032\105\110\032\116\104\101\032\109\111\117\110\116\097\105\110\115\032\111\102\032\110\111\114\116\104\101\114\110\032\084\117\114\107\101\121\044\032\116\104\101\032\067\097\117\099\097\115\117\115\044\032\097\110\100\032\110\111\114\116\104\101\114\110\032\073\114\097\110\046\010\073\116\032\104\097\115\032\097\108\115\111\032\098\101\101\110\032\099\097\108\108\101\100\032\119\097\114\116\121\032\098\105\114\099\104\032\102\114\111\109\032\116\104\101\032\119\097\114\116\121\032\116\119\105\103\115\044\032\097\110\100\032\040\112\097\114\116\105\099\117\108\097\114\108\121\032\102\111\114\032\115\101\108\101\099\116\101\100\032\115\116\114\111\110\103\108\121\032\112\101\110\100\117\108\111\117\115\032\099\117\108\116\105\118\097\114\115\041\032\119\101\101\112\105\110\103\032\098\105\114\099\104\059\032\105\110\032\075\111\114\101\097\032\105\116\032\104\097\115\032\098\101\101\110\032\099\105\116\101\100\032\097\115\032\069\097\115\116\032\065\115\105\097\110\032\119\104\105\116\101\032\098\105\114\099\104\044\032\116\104\111\117\103\104\032\116\104\105\115\032\099\097\110\032\097\108\115\111\032\114\101\102\101\114\032\116\111\032\066\101\116\117\108\097\032\112\108\097\116\121\112\104\121\108\108\097\046\032\073\116\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\105\110\116\111\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\119\104\101\114\101\032\105\116\032\105\115\032\115\111\109\101\116\105\109\101\115\032\107\110\111\119\110\032\097\115\032\069\117\114\111\112\101\097\110\032\119\104\105\116\101\032\098\105\114\099\104\044\032\097\110\100\032\105\115\032\099\111\110\115\105\100\101\114\101\100\032\105\110\118\097\115\105\118\101\032\105\110\032\115\111\109\101\032\115\116\097\116\101\115\032\105\110\032\116\104\101\032\085\110\105\116\101\100\032\083\116\097\116\101\115\032\097\110\100\032\112\097\114\116\115\032\111\102\032\067\097\110\097\100\097\046\010\084\104\101\032\115\105\108\118\101\114\032\098\105\114\099\104\032\105\115\032\097\032\109\101\100\105\117\109\045\115\105\122\101\100\032\100\101\099\105\100\117\111\117\115\032\116\114\101\101\032\116\104\097\116\032\111\119\101\115\032\105\116\115\032\099\111\109\109\111\110\032\110\097\109\101\032\116\111\032\116\104\101\032\119\104\105\116\101\032\112\101\101\108\105\110\103\032\098\097\114\107\032\111\110\032\116\104\101\032\116\114\117\110\107\046\032\084\104\101\032\116\119\105\103\115\032\097\114\101\032\115\108\101\110\100\101\114\032\097\110\100\032\111\102\116\101\110\032\112\101\110\100\117\108\111\117\115\032\097\110\100\032\116\104\101\032\108\101\097\118\101\115\032\097\114\101\032\114\111\117\103\104\108\121\032\116\114\105\097\110\103\117\108\097\114\032\119\105\116\104\032\100\111\117\098\108\121\032\115\101\114\114\097\116\101\032\109\097\114\103\105\110\115\032\097\110\100\032\116\117\114\110\032\121\101\108\108\111\119\032\097\110\100\032\098\114\111\119\110\032\105\110\032\097\117\116\117\109\110\032\098\101\102\111\114\101\032\116\104\101\121\032\102\097\108\108\046\032\084\104\101\032\102\108\111\119\101\114\115\032\097\114\101\032\099\097\116\107\105\110\115\032\097\110\100\032\116\104\101\032\108\105\103\104\116\044\032\119\105\110\103\101\100\032\115\101\101\100\115\032\103\101\116\032\119\105\100\101\108\121\032\115\099\097\116\116\101\114\101\100\032\098\121\032\116\104\101\032\119\105\110\100\046\032\084\104\101\032\115\105\108\118\101\114\032\098\105\114\099\104\032\105\115\032\097\032\104\097\114\100\121\032\116\114\101\101\044\032\097\032\112\105\111\110\101\101\114\032\115\112\101\099\105\101\115\044\032\097\110\100\032\111\110\101\032\111\102\032\116\104\101\032\102\105\114\115\116\032\116\114\101\101\115\032\116\111\032\097\112\112\101\097\114\032\111\110\032\098\097\114\101\032\111\114\032\102\105\114\101\045\115\119\101\112\116\032\108\097\110\100\046\032\077\097\110\121\032\115\112\101\099\105\101\115\032\111\102\032\098\105\114\100\115\032\097\110\100\032\097\110\105\109\097\108\115\032\097\114\101\032\102\111\117\110\100\032\105\110\032\098\105\114\099\104\032\119\111\111\100\108\097\110\100\044\032\116\104\101\032\116\114\101\101\032\115\117\112\112\111\114\116\115\032\097\032\119\105\100\101\032\114\097\110\103\101\032\111\102\032\105\110\115\101\099\116\115\032\097\110\100\032\116\104\101\032\108\105\103\104\116\032\115\104\097\100\101\032\105\116\032\099\097\115\116\115\032\097\108\108\111\119\115\032\115\104\114\117\098\098\121\032\097\110\100\032\111\116\104\101\114\032\112\108\097\110\116\115\032\116\111\032\103\114\111\119\032\098\101\110\101\097\116\104\032\105\116\115\032\099\097\110\111\112\121\046\032\073\116\032\105\115\032\112\108\097\110\116\101\100\032\097\115\032\097\032\100\101\099\111\114\097\116\105\118\101\032\116\114\101\101\032\105\110\032\112\097\114\107\115\032\097\110\100\032\103\097\114\100\101\110\115\044\032\097\110\100\032\105\115\032\117\115\101\100\032\102\111\114\032\102\111\114\101\115\116\032\112\114\111\100\117\099\116\115\032\115\117\099\104\032\097\115\032\106\111\105\110\101\114\121\032\116\105\109\098\101\114\044\032\112\108\121\119\111\111\100\044\032\102\105\114\101\119\111\111\100\044\032\116\097\110\110\105\110\103\044\032\114\097\099\101\099\111\117\114\115\101\032\106\117\109\112\115\044\032\097\110\100\032\098\114\111\111\109\115\046\032\086\097\114\105\111\117\115\032\112\097\114\116\115\032\111\102\032\116\104\101\032\116\114\101\101\032\097\114\101\032\117\115\101\100\032\105\110\032\116\114\097\100\105\116\105\111\110\097\108\032\109\101\100\105\099\105\110\101\032\097\110\100\032\116\104\101\032\098\097\114\107\032\099\111\110\116\097\105\110\115\032\116\114\105\116\101\114\112\101\110\101\115\044\032\119\104\105\099\104\032\104\097\118\101\032\098\101\101\110\032\115\104\111\119\110\032\116\111\032\104\097\118\101\032\109\101\100\105\099\105\110\097\108\032\112\114\111\112\101\114\116\105\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -48,7 +48,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\053\053\056\052\050\055\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\105\110\117\115\095\115\121\108\118\101\115\116\114\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\049\056\084\049\055\058\053\053\058\051\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\080\105\110\117\115\032\115\121\108\118\101\115\116\114\105\115\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\099\111\110\105\102\101\114\044\032\097\032\112\105\110\101\044\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\080\105\110\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\046\032\073\116\032\105\115\032\107\110\111\119\110\032\097\115\032\116\104\101\032\083\099\111\116\115\032\112\105\110\101\032\105\110\032\069\110\103\108\105\115\104\044\032\097\108\116\101\114\110\097\116\105\118\101\108\121\032\097\115\032\116\104\101\032\083\099\111\116\099\104\032\112\105\110\101\032\105\110\032\116\104\101\032\085\110\105\116\101\100\032\083\116\097\116\101\115\044\032\097\110\100\032\111\099\099\097\115\105\111\110\097\108\108\121\032\116\104\101\032\066\097\108\116\105\099\032\112\105\110\101\032\111\114\032\069\117\114\111\112\101\097\110\032\114\101\100\032\112\105\110\101\046\032\032\073\116\032\099\097\110\032\114\101\097\100\105\108\121\032\098\101\032\105\100\101\110\116\105\102\105\101\100\032\098\121\032\105\116\115\032\099\111\109\098\105\110\097\116\105\111\110\032\111\102\032\102\097\105\114\108\121\032\115\104\111\114\116\044\032\098\108\117\101\045\103\114\101\101\110\032\108\101\097\118\101\115\032\097\110\100\032\111\114\097\110\103\101\045\114\101\100\032\098\097\114\107\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -67,7 +67,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\049\050\050\048\053\051\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\083\097\108\105\120\095\097\108\098\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\048\084\049\057\058\052\054\058\049\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\083\097\108\105\120\032\097\108\098\097\044\032\116\104\101\032\119\104\105\116\101\032\119\105\108\108\111\119\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\119\105\108\108\111\119\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\032\097\110\100\032\119\101\115\116\101\114\110\032\097\110\100\032\067\101\110\116\114\097\108\032\065\115\105\097\046\032\084\104\101\032\110\097\109\101\032\100\101\114\105\118\101\115\032\102\114\111\109\032\116\104\101\032\119\104\105\116\101\032\116\111\110\101\032\116\111\032\116\104\101\032\117\110\100\101\114\115\105\100\101\115\032\111\102\032\116\104\101\032\108\101\097\118\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -86,7 +86,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\049\049\052\050\050\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\108\110\117\115\095\103\108\117\116\105\110\111\115\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\048\056\084\048\050\058\048\051\058\050\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\065\108\110\117\115\032\103\108\117\116\105\110\111\115\097\044\032\116\104\101\032\099\111\109\109\111\110\032\097\108\100\101\114\044\032\098\108\097\099\107\032\097\108\100\101\114\044\032\069\117\114\111\112\101\097\110\032\097\108\100\101\114\044\032\069\117\114\111\112\101\097\110\032\098\108\097\099\107\032\097\108\100\101\114\044\032\111\114\032\106\117\115\116\032\097\108\100\101\114\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\116\114\101\101\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\066\101\116\117\108\097\099\101\097\101\044\032\110\097\116\105\118\101\032\116\111\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\044\032\115\111\117\116\104\119\101\115\116\032\065\115\105\097\032\097\110\100\032\110\111\114\116\104\101\114\110\032\065\102\114\105\099\097\046\032\073\116\032\116\104\114\105\118\101\115\032\105\110\032\119\101\116\032\108\111\099\097\116\105\111\110\115\032\119\104\101\114\101\032\105\116\115\032\097\115\115\111\099\105\097\116\105\111\110\032\119\105\116\104\032\116\104\101\032\098\097\099\116\101\114\105\117\109\032\070\114\097\110\107\105\097\032\097\108\110\105\032\101\110\097\098\108\101\115\032\105\116\032\116\111\032\103\114\111\119\032\105\110\032\112\111\111\114\032\113\117\097\108\105\116\121\032\115\111\105\108\115\046\032\073\116\032\105\115\032\097\032\109\101\100\105\117\109\045\115\105\122\101\100\044\032\115\104\111\114\116\045\108\105\118\101\100\032\116\114\101\101\032\103\114\111\119\105\110\103\032\116\111\032\097\032\104\101\105\103\104\116\032\111\102\032\117\112\032\116\111\032\051\048\032\109\101\116\114\101\115\032\040\057\056\032\102\101\101\116\041\046\032\073\116\032\104\097\115\032\115\104\111\114\116\045\115\116\097\108\107\101\100\032\114\111\117\110\100\101\100\032\108\101\097\118\101\115\032\097\110\100\032\115\101\112\097\114\097\116\101\032\109\097\108\101\032\097\110\100\032\102\101\109\097\108\101\032\102\108\111\119\101\114\115\032\105\110\032\116\104\101\032\102\111\114\109\032\111\102\032\099\097\116\107\105\110\115\046\032\084\104\101\032\115\109\097\108\108\044\032\114\111\117\110\100\101\100\032\102\114\117\105\116\115\032\097\114\101\032\099\111\110\101\045\108\105\107\101\032\097\110\100\032\116\104\101\032\115\101\101\100\115\032\097\114\101\032\100\105\115\112\101\114\115\101\100\032\098\121\032\119\105\110\100\032\097\110\100\032\119\097\116\101\114\046\010\084\104\101\032\099\111\109\109\111\110\032\097\108\100\101\114\032\112\114\111\118\105\100\101\115\032\102\111\111\100\032\097\110\100\032\115\104\101\108\116\101\114\032\102\111\114\032\119\105\108\100\108\105\102\101\044\032\119\105\116\104\032\097\032\110\117\109\098\101\114\032\111\102\032\105\110\115\101\099\116\115\044\032\108\105\099\104\101\110\115\032\097\110\100\032\102\117\110\103\105\032\098\101\105\110\103\032\099\111\109\112\108\101\116\101\108\121\032\100\101\112\101\110\100\101\110\116\032\111\110\032\116\104\101\032\116\114\101\101\046\032\073\116\032\105\115\032\097\032\112\105\111\110\101\101\114\032\115\112\101\099\105\101\115\044\032\099\111\108\111\110\105\115\105\110\103\032\118\097\099\097\110\116\032\108\097\110\100\032\097\110\100\032\102\111\114\109\105\110\103\032\109\105\120\101\100\032\102\111\114\101\115\116\115\032\097\115\032\111\116\104\101\114\032\116\114\101\101\115\032\097\112\112\101\097\114\032\105\110\032\105\116\115\032\119\097\107\101\046\032\069\118\101\110\116\117\097\108\108\121\032\099\111\109\109\111\110\032\097\108\100\101\114\032\100\105\101\115\032\111\117\116\032\111\102\032\119\111\111\100\108\097\110\100\115\032\098\101\099\097\117\115\101\032\116\104\101\032\115\101\101\100\108\105\110\103\115\032\110\101\101\100\032\109\111\114\101\032\108\105\103\104\116\032\116\104\097\110\032\105\115\032\097\118\097\105\108\097\098\108\101\032\111\110\032\116\104\101\032\102\111\114\101\115\116\032\102\108\111\111\114\046\032\073\116\115\032\109\111\114\101\032\117\115\117\097\108\032\104\097\098\105\116\097\116\032\105\115\032\102\111\114\101\115\116\032\101\100\103\101\115\044\032\115\119\097\109\112\115\032\097\110\100\032\114\105\118\101\114\115\105\100\101\032\099\111\114\114\105\100\111\114\115\046\032\084\104\101\032\116\105\109\098\101\114\032\104\097\115\032\098\101\101\110\032\117\115\101\100\032\105\110\032\117\110\100\101\114\119\097\116\101\114\032\102\111\117\110\100\097\116\105\111\110\115\032\097\110\100\032\102\111\114\032\109\097\110\117\102\097\099\116\117\114\101\032\111\102\032\112\097\112\101\114\032\097\110\100\032\102\105\098\114\101\098\111\097\114\100\044\032\102\111\114\032\115\109\111\107\105\110\103\032\102\111\111\100\115\044\032\102\111\114\032\106\111\105\110\101\114\121\044\032\116\117\114\110\101\114\121\032\097\110\100\032\099\097\114\118\105\110\103\046\032\080\114\111\100\117\099\116\115\032\111\102\032\116\104\101\032\116\114\101\101\032\104\097\118\101\032\098\101\101\110\032\117\115\101\100\032\105\110\032\101\116\104\110\111\098\111\116\097\110\121\044\032\112\114\111\118\105\100\105\110\103\032\102\111\108\107\032\114\101\109\101\100\105\101\115\032\102\111\114\032\118\097\114\105\111\117\115\032\097\105\108\109\101\110\116\115\044\032\097\110\100\032\114\101\115\101\097\114\099\104\032\104\097\115\032\115\104\111\119\110\032\116\104\097\116\032\101\120\116\114\097\099\116\115\032\111\102\032\116\104\101\032\115\101\101\100\115\032\097\114\101\032\097\099\116\105\118\101\032\097\103\097\105\110\115\116\032\112\097\116\104\111\103\101\110\105\099\032\098\097\099\116\101\114\105\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -105,7 +105,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\055\050\050\048\055\055\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\070\097\103\117\115\095\115\121\108\118\097\116\105\099\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\056\084\049\048\058\051\051\058\049\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\070\097\103\117\115\032\115\121\108\118\097\116\105\099\097\044\032\116\104\101\032\069\117\114\111\112\101\097\110\032\098\101\101\099\104\032\111\114\032\099\111\109\109\111\110\032\098\101\101\099\104\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\116\114\101\101\032\105\110\032\116\104\101\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\102\097\109\105\108\121\032\070\097\103\097\099\101\097\101\046\032\073\116\032\099\097\110\032\103\114\111\119\032\116\111\032\097\032\104\101\105\103\104\116\032\111\102\032\109\111\114\101\032\116\104\097\110\032\052\048\032\109\032\097\110\100\032\104\097\115\032\115\109\111\111\116\104\032\115\105\108\118\101\114\121\045\103\114\101\121\032\098\097\114\107\044\032\108\097\114\103\101\032\108\101\097\102\032\097\114\101\097\044\032\097\110\100\032\097\032\115\104\111\114\116\032\116\114\117\110\107\032\119\105\116\104\032\108\111\119\032\098\114\097\110\099\104\101\115\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\109\117\099\104\032\111\102\032\069\117\114\111\112\101\044\032\103\114\111\119\105\110\103\032\105\110\032\104\117\109\105\100\032\099\108\105\109\097\116\101\115\046\010\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\099\117\108\116\105\118\097\116\101\100\032\097\115\032\097\110\032\111\114\110\097\109\101\110\116\097\108\044\032\097\110\100\032\104\097\115\032\098\101\101\110\032\115\112\114\101\097\100\032\098\101\121\111\110\100\032\105\116\115\032\111\114\105\103\105\110\097\108\032\114\097\110\103\101\044\032\105\110\099\108\117\100\105\110\103\032\105\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046\032\032\078\117\109\101\114\111\117\115\032\099\117\108\116\105\118\097\114\115\032\104\097\118\101\032\098\101\101\110\032\100\101\118\101\108\111\112\101\100\044\032\105\110\099\108\117\100\105\110\103\032\116\104\101\032\115\104\111\119\121\032\099\111\112\112\101\114\032\098\101\101\099\104\044\032\070\097\103\117\115\032\115\121\108\118\097\116\105\099\097\032\112\117\114\112\117\114\101\097\046\032\065\108\116\104\111\117\103\104\032\115\108\105\103\104\116\108\121\032\116\111\120\105\099\032\100\117\101\032\116\111\032\116\104\101\032\116\097\110\110\105\110\115\032\097\110\100\032\097\108\107\097\108\111\105\100\115\032\116\104\101\121\032\099\111\110\116\097\105\110\044\032\116\104\101\032\110\117\116\115\032\097\114\101\032\099\111\110\115\117\109\101\100\032\098\121\032\097\110\105\109\097\108\115\032\097\110\100\032\104\117\109\097\110\115\046\032\084\104\101\032\116\114\101\101\115\032\097\114\101\032\117\115\101\100\032\102\111\114\032\116\105\109\098\101\114\044\032\115\117\099\104\032\097\115\032\116\104\101\032\109\097\107\105\110\103\032\111\102\032\102\117\114\110\105\116\117\114\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -124,7 +124,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\056\050\052\048\051\054\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\111\112\117\108\117\115\095\116\114\101\109\117\108\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\052\045\049\049\084\049\052\058\051\051\058\048\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\080\111\112\117\108\117\115\032\116\114\101\109\117\108\097\032\040\099\111\109\109\111\110\108\121\032\099\097\108\108\101\100\032\097\115\112\101\110\044\032\099\111\109\109\111\110\032\097\115\112\101\110\044\032\069\117\114\097\115\105\097\110\032\097\115\112\101\110\044\032\069\117\114\111\112\101\097\110\032\097\115\112\101\110\044\032\111\114\032\113\117\097\107\105\110\103\032\097\115\112\101\110\041\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\112\111\112\108\097\114\032\110\097\116\105\118\101\032\116\111\032\099\111\111\108\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\032\111\102\032\116\104\101\032\079\108\100\032\087\111\114\108\100\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -143,7 +143,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\055\056\051\051\050\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\083\111\114\098\117\115\095\097\117\099\117\112\097\114\105\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\051\048\084\048\055\058\052\048\058\048\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\083\111\114\098\117\115\032\097\117\099\117\112\097\114\105\097\044\032\099\111\109\109\111\110\108\121\032\099\097\108\108\101\100\032\114\111\119\097\110\032\040\044\032\097\108\115\111\032\085\075\058\032\041\032\097\110\100\032\109\111\117\110\116\097\105\110\045\097\115\104\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\100\101\099\105\100\117\111\117\115\032\116\114\101\101\032\111\114\032\115\104\114\117\098\032\105\110\032\116\104\101\032\114\111\115\101\032\102\097\109\105\108\121\046\010\084\104\101\032\116\114\101\101\032\104\097\115\032\097\032\115\108\101\110\100\101\114\032\116\114\117\110\107\032\119\105\116\104\032\115\109\111\111\116\104\032\098\097\114\107\044\032\097\032\108\111\111\115\101\032\097\110\100\032\114\111\117\110\100\105\115\104\032\099\114\111\119\110\044\032\097\110\100\032\105\116\115\032\108\101\097\118\101\115\032\097\114\101\032\112\105\110\110\097\116\101\032\105\110\032\112\097\105\114\115\032\111\102\032\108\101\097\102\108\101\116\115\032\111\110\032\097\032\099\101\110\116\114\097\108\032\118\101\105\110\032\119\105\116\104\032\097\032\116\101\114\109\105\110\097\108\032\108\101\097\102\108\101\116\046\032\073\116\032\102\108\111\119\101\114\115\032\102\114\111\109\032\077\097\121\032\116\111\032\074\117\110\101\032\105\110\032\100\101\110\115\101\032\099\111\114\121\109\098\115\032\111\102\032\115\109\097\108\108\032\099\114\101\097\109\121\045\119\104\105\116\101\032\102\108\111\119\101\114\115\032\097\110\100\032\100\101\118\101\108\111\112\115\032\115\109\097\108\108\032\114\101\100\032\112\111\109\101\115\032\097\115\032\102\114\117\105\116\032\116\104\097\116\032\114\105\112\101\110\032\102\114\111\109\032\065\117\103\117\115\116\032\116\111\032\079\099\116\111\098\101\114\032\097\110\100\032\097\114\101\032\101\097\116\101\110\032\098\121\032\109\097\110\121\032\098\105\114\100\032\115\112\101\099\105\101\115\046\010\073\116\032\105\115\032\097\032\104\105\103\104\108\121\032\118\097\114\105\097\098\108\101\032\115\112\101\099\105\101\115\044\032\097\110\100\032\098\111\116\097\110\105\115\116\115\032\104\097\118\101\032\117\115\101\100\032\100\105\102\102\101\114\101\110\116\032\100\101\102\105\110\105\116\105\111\110\115\032\111\102\032\116\104\101\032\115\112\101\099\105\101\115\032\116\111\032\105\110\099\108\117\100\101\032\111\114\032\101\120\099\108\117\100\101\032\116\114\101\101\115\032\110\097\116\105\118\101\032\116\111\032\099\101\114\116\097\105\110\032\097\114\101\097\115\046\032\065\032\114\101\099\101\110\116\032\100\101\102\105\110\105\116\105\111\110\032\105\110\099\108\117\100\101\115\032\116\114\101\101\115\032\110\097\116\105\118\101\032\116\111\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\032\097\110\100\032\112\097\114\116\115\032\111\102\032\065\115\105\097\044\032\097\115\032\119\101\108\108\032\097\115\032\110\111\114\116\104\101\114\110\032\065\102\114\105\099\097\046\032\084\104\101\032\114\097\110\103\101\032\101\120\116\101\110\100\115\032\102\114\111\109\032\077\097\100\101\105\114\097\044\032\116\104\101\032\066\114\105\116\105\115\104\032\073\115\108\101\115\032\097\110\100\032\073\099\101\108\097\110\100\032\116\111\032\082\117\115\115\105\097\032\097\110\100\032\110\111\114\116\104\101\114\110\032\067\104\105\110\097\046\032\085\110\108\105\107\101\032\109\097\110\121\032\112\108\097\110\116\115\032\119\105\116\104\032\115\105\109\105\108\097\114\032\100\105\115\116\114\105\098\117\116\105\111\110\115\044\032\105\116\032\105\115\032\110\111\116\032\110\097\116\105\118\101\032\116\111\032\074\097\112\097\110\046\032\084\104\101\032\112\108\097\110\116\032\105\115\032\102\114\111\115\116\032\104\097\114\100\121\032\097\110\100\032\099\111\108\111\110\105\115\101\115\032\100\105\115\114\117\112\116\101\100\032\097\110\100\032\105\110\097\099\099\101\115\115\105\098\108\101\032\112\108\097\099\101\115\032\097\115\032\097\032\115\104\111\114\116\045\108\105\118\101\100\032\112\105\111\110\101\101\114\032\115\112\101\099\105\101\115\046\010\084\104\101\032\102\114\117\105\116\032\097\110\100\032\102\111\108\105\097\103\101\032\104\097\118\101\032\098\101\101\110\032\117\115\101\100\032\105\110\032\116\104\101\032\099\114\101\097\116\105\111\110\032\111\102\032\100\105\115\104\101\115\032\097\110\100\032\098\101\118\101\114\097\103\101\115\044\032\097\115\032\097\032\102\111\108\107\032\109\101\100\105\099\105\110\101\044\032\097\110\100\032\097\115\032\102\111\100\100\101\114\032\102\111\114\032\108\105\118\101\115\116\111\099\107\046\032\073\116\115\032\116\111\117\103\104\032\097\110\100\032\102\108\101\120\105\098\108\101\032\119\111\111\100\032\104\097\115\032\116\114\097\100\105\116\105\111\110\097\108\108\121\032\098\101\101\110\032\117\115\101\100\032\102\111\114\032\119\111\111\100\119\111\114\107\105\110\103\046\032\073\116\032\105\115\032\112\108\097\110\116\101\100\032\116\111\032\102\111\114\116\105\102\121\032\115\111\105\108\032\105\110\032\109\111\117\110\116\097\105\110\032\114\101\103\105\111\110\115\032\111\114\032\097\115\032\097\110\032\111\114\110\097\109\101\110\116\097\108\032\116\114\101\101\032\097\110\100\032\104\097\115\032\115\101\118\101\114\097\108\032\099\117\108\116\105\118\097\114\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -162,7 +162,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\049\049\054\057\049\057\050", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\111\114\121\108\117\115\095\097\118\101\108\108\097\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\048\084\049\050\058\050\050\058\050\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\067\111\114\121\108\117\115\032\097\118\101\108\108\097\110\097\044\032\116\104\101\032\099\111\109\109\111\110\032\104\097\122\101\108\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\098\105\114\099\104\032\102\097\109\105\108\121\032\066\101\116\117\108\097\099\101\097\101\046\032\084\104\101\032\115\104\114\117\098\115\032\117\115\117\097\108\108\121\032\103\114\111\119\032\051\226\128\147\056\032\109\101\116\114\101\115\032\040\049\048\226\128\147\050\054\032\102\101\101\116\041\032\116\097\108\108\046\032\084\104\101\032\110\117\116\032\105\115\032\114\111\117\110\100\044\032\105\110\032\099\111\110\116\114\097\115\116\032\116\111\032\116\104\101\032\108\111\110\103\101\114\032\102\105\108\098\101\114\116\032\110\117\116\046\032\067\111\109\109\111\110\032\104\097\122\101\108\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\032\097\110\100\032\087\101\115\116\101\114\110\032\065\115\105\097\046\010\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\109\097\105\110\108\121\032\099\117\108\116\105\118\097\116\101\100\032\102\111\114\032\105\116\115\032\110\117\116\115\046\032\084\104\101\032\110\097\109\101\032\039\104\097\122\101\108\110\117\116\039\032\097\112\112\108\105\101\115\032\116\111\032\116\104\101\032\110\117\116\115\032\111\102\032\097\110\121\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\103\101\110\117\115\032\067\111\114\121\108\117\115\044\032\098\117\116\032\105\110\032\099\111\109\109\101\114\099\105\097\108\032\099\111\110\116\101\120\116\115\032\117\115\117\097\108\108\121\032\100\101\115\099\114\105\098\101\115\032\067\046\032\097\118\101\108\108\097\110\097\046\032\084\104\105\115\032\104\097\122\101\108\110\117\116\032\111\114\032\099\111\098\032\110\117\116\032\040\115\111\109\101\116\105\109\101\115\032\099\111\098\110\117\116\041\044\032\116\104\101\032\107\101\114\110\101\108\032\111\102\032\116\104\101\032\115\101\101\100\044\032\105\115\032\101\100\105\098\108\101\032\097\110\100\032\117\115\101\100\032\114\097\119\044\032\114\111\097\115\116\101\100\044\032\111\114\032\103\114\111\117\110\100\032\105\110\116\111\032\097\032\112\097\115\116\101\046\032\072\105\115\116\111\114\105\099\097\108\108\121\044\032\116\104\101\032\115\104\114\117\098\032\119\097\115\032\097\110\032\105\109\112\111\114\116\097\110\116\032\099\111\109\112\111\110\101\110\116\032\111\102\032\116\104\101\032\104\101\100\103\101\114\111\119\115\032\117\115\101\100\032\097\115\032\102\105\101\108\100\032\098\111\117\110\100\097\114\105\101\115\032\105\110\032\108\111\119\108\097\110\100\032\069\110\103\108\097\110\100\046\032\084\104\101\032\119\111\111\100\032\119\097\115\032\103\114\111\119\110\032\097\115\032\099\111\112\112\105\099\101\044\032\119\105\116\104\032\116\104\101\032\112\111\108\101\115\032\117\115\101\100\032\102\111\114\032\119\097\116\116\108\101\045\097\110\100\045\100\097\117\098\032\098\117\105\108\100\105\110\103\032\097\110\100\032\097\103\114\105\099\117\108\116\117\114\097\108\032\102\101\110\099\105\110\103\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -181,7 +181,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\054\049\056\053\055\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\105\099\101\097\095\097\098\105\101\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\057\084\049\052\058\048\056\058\053\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\055\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\057\056\090", text = "\080\105\099\101\097\032\097\098\105\101\115\044\032\116\104\101\032\078\111\114\119\097\121\032\115\112\114\117\099\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\115\112\114\117\099\101\032\110\097\116\105\118\101\032\116\111\032\078\111\114\116\104\101\114\110\044\032\067\101\110\116\114\097\108\032\097\110\100\032\069\097\115\116\101\114\110\032\069\117\114\111\112\101\046\032\073\116\032\104\097\115\032\098\114\097\110\099\104\108\101\116\115\032\116\104\097\116\032\116\121\112\105\099\097\108\108\121\032\104\097\110\103\032\100\111\119\110\119\097\114\100\115\044\032\097\110\100\032\116\104\101\032\108\111\110\103\101\115\116\032\099\111\110\101\115\032\111\102\032\097\110\121\032\115\112\114\117\099\101\044\032\057\226\128\147\050\048\032\099\109\032\108\111\110\103\046\032\073\116\032\105\115\032\117\115\117\097\108\108\121\032\099\111\110\115\105\100\101\114\101\100\032\109\111\115\116\032\099\108\111\115\101\108\121\032\114\101\108\097\116\101\100\032\116\111\032\116\104\101\032\083\105\098\101\114\105\097\110\032\115\112\114\117\099\101\032\040\080\105\099\101\097\032\111\098\111\118\097\116\097\041\044\032\119\104\105\099\104\032\114\101\112\108\097\099\101\115\032\105\116\032\101\097\115\116\032\111\102\032\116\104\101\032\085\114\097\108\032\077\111\117\110\116\097\105\110\115\044\032\097\110\100\032\119\105\116\104\032\119\104\105\099\104\032\105\116\032\104\121\098\114\105\100\105\115\101\115\032\102\114\101\101\108\121\044\032\098\117\116\032\105\116\115\032\112\104\121\108\111\103\101\110\121\032\104\097\115\032\098\101\101\110\032\100\105\115\112\117\116\101\100\046\010\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\111\110\101\032\111\102\032\116\104\101\032\109\111\115\116\032\119\105\100\101\108\121\032\112\108\097\110\116\101\100\032\115\112\114\117\099\101\115\044\032\098\111\116\104\032\105\110\032\097\110\100\032\111\117\116\115\105\100\101\032\111\102\032\105\116\115\032\110\097\116\105\118\101\032\114\097\110\103\101\044\032\097\110\100\032\105\115\032\111\110\101\032\111\102\032\116\104\101\032\109\111\115\116\032\101\099\111\110\111\109\105\099\097\108\108\121\032\105\109\112\111\114\116\097\110\116\032\099\111\110\105\102\101\114\115\032\105\110\032\069\117\114\111\112\101\046\032\077\097\106\111\114\032\102\111\114\101\115\116\114\121\032\112\101\115\116\115\032\116\104\097\116\032\097\102\102\101\099\116\032\116\104\101\032\115\112\101\099\105\101\115\032\105\110\099\108\117\100\101\032\116\104\101\032\069\117\114\111\112\101\097\110\032\115\112\114\117\099\101\032\098\097\114\107\032\098\101\101\116\108\101\044\032\116\104\101\032\108\097\114\103\101\032\112\105\110\101\032\119\101\101\118\105\108\044\032\097\110\100\032\116\104\101\032\032\098\108\097\099\107\032\097\114\099\104\101\115\032\109\111\116\104\046\032\070\117\110\103\097\108\032\100\105\115\101\097\115\101\115\032\116\104\097\116\032\099\097\110\032\107\105\108\108\032\116\104\101\032\116\114\101\101\032\105\110\099\108\117\100\101\032\065\114\109\105\108\108\097\114\105\097\032\114\111\111\116\032\114\111\116\032\097\110\100\032\114\101\100\032\114\111\116\032\099\097\117\115\101\100\032\098\121\032\072\101\116\101\114\111\098\097\115\105\100\105\111\110\032\097\110\110\111\115\117\109\046\032\084\104\101\032\115\112\101\099\105\101\115\032\104\097\115\032\097\032\119\105\100\101\032\100\105\115\116\114\105\098\117\116\105\111\110\044\032\105\115\032\119\105\100\101\108\121\032\112\108\097\110\116\101\100\032\102\111\114\032\105\116\115\032\119\111\111\100\044\032\097\110\100\032\105\115\032\117\115\101\100\032\097\115\032\097\032\067\104\114\105\115\116\109\097\115\032\116\114\101\101\032\105\110\032\115\101\118\101\114\097\108\032\099\111\117\110\116\114\105\101\115\032\097\114\111\117\110\100\032\116\104\101\032\119\111\114\108\100\046\032\073\116\032\119\097\115\032\116\104\101\032\102\105\114\115\116\032\103\121\109\110\111\115\112\101\114\109\032\116\111\032\104\097\118\101\032\105\116\115\032\103\101\110\111\109\101\032\115\101\113\117\101\110\099\101\100\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -191,6 +191,63 @@ return { license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", }, + ["\099\114\097\098\095\097\112\112\108\101"] = { + latin = "\077\097\108\117\115\032\115\121\108\118\101\115\116\114\105\115", + title = "\077\097\108\117\115\032\115\121\108\118\101\115\116\114\105\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\077\097\108\117\115\095\115\121\108\118\101\115\116\114\105\115", + pageid = 5958504, + revision = 1380105044, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\049\048\053\048\052\052", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\077\097\108\117\115\095\115\121\108\118\101\115\116\114\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\049\051\084\050\048\058\051\050\058\051\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", + text = "\077\097\108\117\115\032\115\121\108\118\101\115\116\114\105\115\044\032\116\104\101\032\069\117\114\111\112\101\097\110\032\099\114\097\098\032\097\112\112\108\101\044\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\116\104\101\032\069\117\114\111\112\101\097\110\032\119\105\108\100\032\097\112\112\108\101\032\111\114\032\115\105\109\112\108\121\032\116\104\101\032\099\114\097\098\032\097\112\112\108\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\116\104\101\032\103\101\110\117\115\032\077\097\108\117\115\046\032\073\116\115\032\115\099\105\101\110\116\105\102\105\099\032\110\097\109\101\032\109\101\097\110\115\032\034\102\111\114\101\115\116\032\097\112\112\108\101\034\044\032\114\101\102\108\101\099\116\105\110\103\032\105\116\115\032\104\097\098\105\116\097\116\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\102\114\111\109\032\099\101\110\116\114\097\108\032\069\117\114\111\112\101\032\116\111\032\119\101\115\116\101\114\110\032\065\115\105\097\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\077\097\108\117\115\032\115\121\108\118\101\115\116\114\105\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\119\105\108\100\095\099\104\101\114\114\121"] = { + latin = "\080\114\117\110\117\115\032\097\118\105\117\109", + title = "\080\114\117\110\117\115\032\097\118\105\117\109", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\080\114\117\110\117\115\095\097\118\105\117\109", + pageid = 28331736, + revision = 1380998001, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\057\057\056\048\048\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\114\117\110\117\115\095\097\118\105\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\049\057\084\049\048\058\048\051\058\053\054\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", + text = "\080\114\117\110\117\115\032\097\118\105\117\109\044\032\099\111\109\109\111\110\108\121\032\099\097\108\108\101\100\032\119\105\108\100\032\099\104\101\114\114\121\044\032\115\119\101\101\116\032\099\104\101\114\114\121\032\111\114\032\103\101\097\110\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\099\104\101\114\114\121\044\032\097\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\114\111\115\101\032\102\097\109\105\108\121\044\032\082\111\115\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\119\101\115\116\101\114\110\032\069\117\114\097\115\105\097\032\097\110\100\032\110\097\116\117\114\097\108\105\122\101\100\032\101\108\115\101\119\104\101\114\101\046\032\073\116\032\105\115\032\097\110\032\097\110\099\101\115\116\111\114\032\111\102\032\080\046\032\099\101\114\097\115\117\115\032\040\115\111\117\114\032\099\104\101\114\114\121\041\046\010\065\108\108\032\112\097\114\116\115\032\111\102\032\116\104\101\032\112\108\097\110\116\032\101\120\099\101\112\116\032\102\111\114\032\116\104\101\032\114\105\112\101\032\102\114\117\105\116\032\097\114\101\032\115\108\105\103\104\116\108\121\032\116\111\120\105\099\044\032\099\111\110\116\097\105\110\105\110\103\032\099\121\097\110\111\103\101\110\105\099\032\103\108\121\099\111\115\105\100\101\115\046\032\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\111\102\116\101\110\032\099\117\108\116\105\118\097\116\101\100\032\097\115\032\097\110\032\111\114\110\097\109\101\110\116\097\108\032\116\114\101\101\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\080\114\117\110\117\115\032\097\118\105\117\109\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\102\105\101\108\100\095\109\097\112\108\101"] = { + latin = "\065\099\101\114\032\099\097\109\112\101\115\116\114\101", + title = "\065\099\101\114\032\099\097\109\112\101\115\116\114\101", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\065\099\101\114\095\099\097\109\112\101\115\116\114\101", + pageid = 437923, + revision = 1366038360, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\054\048\051\056\051\054\048", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\099\101\114\095\099\097\109\112\101\115\116\114\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\055\045\050\053\084\050\050\058\051\057\058\052\048\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", + text = "\065\099\101\114\032\099\097\109\112\101\115\116\114\101\044\032\107\110\111\119\110\032\097\115\032\116\104\101\032\102\105\101\108\100\032\109\097\112\108\101\044\032\105\115\032\097\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\083\097\112\105\110\100\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\109\117\099\104\032\111\102\032\099\111\110\116\105\110\101\110\116\097\108\032\069\117\114\111\112\101\044\032\066\114\105\116\097\105\110\044\032\115\111\117\116\104\119\101\115\116\032\065\115\105\097\032\102\114\111\109\032\084\117\114\107\101\121\032\116\111\032\116\104\101\032\067\097\117\099\097\115\117\115\044\032\097\110\100\032\110\111\114\116\104\032\065\102\114\105\099\097\032\105\110\032\116\104\101\032\065\116\108\097\115\032\077\111\117\110\116\097\105\110\115\046\032\073\116\032\104\097\115\032\098\101\101\110\032\119\105\100\101\108\121\032\112\108\097\110\116\101\100\044\032\097\110\100\032\105\115\032\105\110\116\114\111\100\117\099\101\100\032\111\117\116\115\105\100\101\032\105\116\115\032\110\097\116\105\118\101\032\114\097\110\103\101\032\105\110\032\069\117\114\111\112\101\032\097\110\100\032\097\114\101\097\115\032\111\102\032\116\104\101\032\085\083\032\097\110\100\032\087\101\115\116\101\114\110\032\065\117\115\116\114\097\108\105\097\032\119\105\116\104\032\115\117\105\116\097\098\108\101\032\099\108\105\109\097\116\101\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\065\099\101\114\032\099\097\109\112\101\115\116\114\101\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, ["\098\105\108\098\101\114\114\121"] = { latin = "\086\097\099\099\105\110\105\117\109\032\109\121\114\116\105\108\108\117\115", title = "\086\097\099\099\105\110\105\117\109\032\109\121\114\116\105\108\108\117\115", @@ -200,7 +257,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\055\052\056\048\056\056\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\086\097\099\099\105\110\105\117\109\095\109\121\114\116\105\108\108\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\057\084\049\054\058\050\057\058\051\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\086\097\099\099\105\110\105\117\109\032\109\121\114\116\105\108\108\117\115\032\105\115\032\097\032\104\111\108\097\114\099\116\105\099\032\115\112\101\099\105\101\115\032\111\102\032\115\104\114\117\098\032\119\105\116\104\032\101\100\105\098\108\101\032\102\114\117\105\116\032\111\102\032\098\108\117\101\032\099\111\108\111\114\044\032\107\110\111\119\110\032\098\121\032\116\104\101\032\099\111\109\109\111\110\032\110\097\109\101\115\032\098\105\108\098\101\114\114\121\044\032\098\108\097\101\098\101\114\114\121\044\032\097\110\100\032\119\104\111\114\116\108\101\098\101\114\114\121\046\032\073\116\032\105\115\032\109\111\114\101\032\112\114\101\099\105\115\101\108\121\032\099\097\108\108\101\100\032\099\111\109\109\111\110\032\098\105\108\098\101\114\114\121\032\111\114\032\098\108\117\101\032\119\104\111\114\116\108\101\098\101\114\114\121\032\116\111\032\100\105\115\116\105\110\103\117\105\115\104\032\105\116\032\102\114\111\109\032\111\116\104\101\114\032\086\097\099\099\105\110\105\117\109\032\114\101\108\097\116\105\118\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -219,7 +276,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\050\057\053\054\050\057\055\053\057", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\082\117\098\117\115\095\112\108\105\099\097\116\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\048\054\045\049\052\084\050\051\058\052\055\058\051\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\082\117\098\117\115\032\112\108\105\099\097\116\117\115\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\098\108\097\099\107\098\101\114\114\121\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -238,7 +295,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\053\051\053\051\051\057\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\082\117\098\117\115\095\105\100\097\101\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\055\045\050\049\084\050\049\058\052\055\058\053\048\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\082\117\098\117\115\032\105\100\097\101\117\115\032\040\114\097\115\112\098\101\114\114\121\044\032\097\108\115\111\032\099\097\108\108\101\100\032\114\101\100\032\114\097\115\112\098\101\114\114\121\032\111\114\032\111\099\099\097\115\105\111\110\097\108\108\121\032\069\117\114\111\112\101\097\110\032\114\101\100\032\114\097\115\112\098\101\114\114\121\032\116\111\032\100\105\115\116\105\110\103\117\105\115\104\032\105\116\032\102\114\111\109\032\111\116\104\101\114\032\114\097\115\112\098\101\114\114\121\032\115\112\101\099\105\101\115\041\032\105\115\032\097\032\114\101\100\045\102\114\117\105\116\101\100\032\115\112\101\099\105\101\115\032\111\102\032\082\117\098\117\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\032\097\110\100\032\099\111\109\109\111\110\108\121\032\099\117\108\116\105\118\097\116\101\100\032\105\110\032\111\116\104\101\114\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -257,7 +314,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\051\050\050\057\050\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\083\097\109\098\117\099\117\115\095\110\105\103\114\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\053\084\048\049\058\052\057\058\048\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\083\097\109\098\117\099\117\115\032\110\105\103\114\097\032\105\115\032\097\032\116\101\109\112\101\114\097\116\101\032\115\112\101\099\105\101\115\032\111\102\032\116\114\101\101\032\111\114\032\115\104\114\117\098\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\086\105\098\117\114\110\097\099\101\097\101\032\110\097\116\105\118\101\032\116\111\032\116\104\101\032\065\122\111\114\101\115\044\032\069\117\114\111\112\101\044\032\097\110\100\032\116\104\101\032\077\105\100\100\108\101\032\069\097\115\116\046\032\067\111\109\109\111\110\032\110\097\109\101\115\032\105\110\099\108\117\100\101\032\101\108\100\101\114\044\032\099\111\109\109\111\110\032\101\108\100\101\114\044\032\098\108\097\099\107\032\101\108\100\101\114\044\032\069\117\114\111\112\101\097\110\032\101\108\100\101\114\044\032\097\110\100\032\097\108\115\111\032\101\108\100\101\114\098\101\114\114\121\032\102\111\114\032\116\104\101\032\102\114\117\105\116\044\032\097\110\100\032\101\108\100\101\114\102\108\111\119\101\114\032\102\111\114\032\116\104\101\032\102\108\111\119\101\114\115\046\032\073\116\032\103\114\111\119\115\032\105\110\032\097\032\118\097\114\105\101\116\121\032\111\102\032\099\111\110\100\105\116\105\111\110\115\032\105\110\099\108\117\100\105\110\103\032\098\111\116\104\032\119\101\116\032\097\110\100\032\100\114\121\032\102\101\114\116\105\108\101\032\115\111\105\108\115\044\032\112\114\105\109\097\114\105\108\121\032\105\110\032\115\117\110\110\121\032\108\111\099\097\116\105\111\110\115\046\032\084\104\101\032\112\108\097\110\116\032\105\115\032\119\105\100\101\108\121\032\103\114\111\119\110\032\097\115\032\097\110\032\111\114\110\097\109\101\110\116\097\108\032\115\104\114\117\098\032\111\114\032\115\109\097\108\108\032\116\114\101\101\046\032\010\066\111\116\104\032\116\104\101\032\102\108\111\119\101\114\115\032\097\110\100\032\116\104\101\032\098\101\114\114\105\101\115\032\104\097\118\101\032\097\032\108\111\110\103\032\116\114\097\100\105\116\105\111\110\032\111\102\032\099\117\108\105\110\097\114\121\032\117\115\101\044\032\112\114\105\109\097\114\105\108\121\032\102\111\114\032\099\111\114\100\105\097\108\032\097\110\100\032\119\105\110\101\046\032\065\108\116\104\111\117\103\104\032\116\104\101\032\112\108\097\110\116\032\105\115\032\099\111\109\109\111\110\108\121\032\117\115\101\100\032\105\110\032\100\105\101\116\097\114\121\032\115\117\112\112\108\101\109\101\110\116\115\032\097\110\100\032\116\114\097\100\105\116\105\111\110\097\108\032\109\101\100\105\099\105\110\101\044\032\116\104\101\114\101\032\105\115\032\110\111\032\099\108\105\110\105\099\097\108\032\101\118\105\100\101\110\099\101\032\116\104\097\116\032\105\116\032\112\114\111\118\105\100\101\115\032\097\110\121\032\104\101\097\108\116\104\032\101\102\102\101\099\116\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -276,7 +333,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\056\052\057\055\050\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\114\117\110\117\115\095\115\112\105\110\111\115\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\050\084\048\055\058\048\051\058\053\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\080\114\117\110\117\115\032\115\112\105\110\111\115\097\044\032\099\097\108\108\101\100\032\098\108\097\099\107\116\104\111\114\110\032\111\114\032\115\108\111\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\114\111\115\101\032\102\097\109\105\108\121\044\032\082\111\115\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\032\097\110\100\032\087\101\115\116\032\065\115\105\097\044\032\097\110\100\032\104\097\115\032\098\101\101\110\032\110\097\116\117\114\097\108\105\115\101\100\032\105\110\032\112\097\114\116\115\032\111\102\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046\010\084\104\101\032\102\114\117\105\116\115\032\097\114\101\032\117\115\101\100\032\116\111\032\109\097\107\101\032\115\108\111\101\032\103\105\110\032\105\110\032\071\114\101\097\116\032\066\114\105\116\097\105\110\032\097\110\100\032\112\097\116\120\097\114\097\110\032\105\110\032\116\104\101\032\066\097\115\113\117\101\032\067\111\117\110\116\114\121\046\032\084\104\101\032\119\111\111\100\032\105\115\032\117\115\101\100\032\116\111\032\109\097\107\101\032\119\097\108\107\105\110\103\032\115\116\105\099\107\115\044\032\105\110\099\108\117\100\105\110\103\032\116\104\101\032\073\114\105\115\104\032\115\104\105\108\108\101\108\097\103\104\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -295,7 +352,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\049\054\051\053\054\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\114\097\116\097\101\103\117\115\095\109\111\110\111\103\121\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\048\056\084\048\056\058\051\048\058\048\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\067\114\097\116\097\101\103\117\115\032\109\111\110\111\103\121\110\097\044\032\107\110\111\119\110\032\097\115\032\099\111\109\109\111\110\032\104\097\119\116\104\111\114\110\044\032\109\097\121\032\116\114\101\101\044\032\119\104\105\116\101\116\104\111\114\110\044\032\111\110\101\045\115\101\101\100\032\104\097\119\116\104\111\114\110\044\032\111\114\032\115\105\110\103\108\101\045\115\101\101\100\101\100\032\104\097\119\116\104\111\114\110\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\114\111\115\101\032\102\097\109\105\108\121\044\032\082\111\115\097\099\101\097\101\046\032\073\116\032\103\114\111\119\115\032\116\111\032\097\098\111\117\116\032\049\048\032\109\101\116\114\101\115\032\040\051\051\032\102\101\101\116\041\032\116\097\108\108\044\032\112\114\111\100\117\099\105\110\103\032\104\101\114\109\097\112\104\114\111\100\105\116\101\032\102\108\111\119\101\114\115\032\105\110\032\108\097\116\101\032\115\112\114\105\110\103\046\032\084\104\101\032\098\101\114\114\121\045\108\105\107\101\032\112\111\109\101\115\032\040\107\110\111\119\110\032\097\115\032\104\097\119\115\041\032\099\111\110\116\097\105\110\032\097\032\115\116\111\110\101\045\101\110\099\097\115\101\100\032\115\101\101\100\046\010\084\104\101\032\112\108\097\110\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\098\117\116\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\105\110\032\109\097\110\121\032\111\116\104\101\114\032\112\097\114\116\115\032\111\102\032\116\104\101\032\119\111\114\108\100\046\032\084\104\101\032\112\111\109\101\032\102\108\101\115\104\032\105\115\032\111\102\032\108\105\116\116\108\101\032\099\117\108\105\110\097\114\121\032\105\110\116\101\114\101\115\116\032\100\117\101\032\116\111\032\105\116\115\032\100\114\121\110\101\115\115\044\032\098\117\116\032\105\115\032\117\115\101\100\032\116\111\032\109\097\107\101\032\106\101\108\108\105\101\115\046\032\084\104\101\032\121\111\117\110\103\032\108\101\097\118\101\115\032\097\110\100\032\112\101\116\097\108\115\032\097\114\101\032\097\108\115\111\032\101\100\105\098\108\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -314,7 +371,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\049\053\052\052\049\050\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\082\111\115\097\095\099\097\110\105\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\051\084\048\053\058\053\051\058\052\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\049\046\057\055\056\090", text = "\082\111\115\097\032\099\097\110\105\110\097\044\032\116\104\101\032\100\111\103\032\114\111\115\101\044\032\105\115\032\097\032\118\097\114\105\097\098\108\101\032\099\108\105\109\098\105\110\103\044\032\119\105\108\100\032\114\111\115\101\032\115\112\101\099\105\101\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\110\111\114\116\104\119\101\115\116\032\065\102\114\105\099\097\044\032\097\110\100\032\119\101\115\116\101\114\110\032\065\115\105\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -333,7 +390,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\054\056\053\049\049\055\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\073\108\101\120\095\097\113\117\105\102\111\108\105\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\054\084\049\054\058\050\056\058\051\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", text = "\073\108\101\120\032\097\113\117\105\102\111\108\105\117\109\044\032\116\104\101\032\104\111\108\108\121\044\032\099\111\109\109\111\110\032\104\111\108\108\121\044\032\069\110\103\108\105\115\104\032\104\111\108\108\121\044\032\069\117\114\111\112\101\097\110\032\104\111\108\108\121\044\032\111\114\032\111\099\099\097\115\105\111\110\097\108\108\121\032\067\104\114\105\115\116\109\097\115\032\104\111\108\108\121\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\065\113\117\105\102\111\108\105\097\099\101\097\101\044\032\110\097\116\105\118\101\032\116\111\032\119\101\115\116\101\114\110\032\097\110\100\032\115\111\117\116\104\101\114\110\032\069\117\114\111\112\101\044\032\110\111\114\116\104\119\101\115\116\032\065\102\114\105\099\097\044\032\097\110\100\032\115\111\117\116\104\119\101\115\116\032\065\115\105\097\046\032\073\116\032\105\115\032\114\101\103\097\114\100\101\100\032\097\115\032\116\104\101\032\116\121\112\101\032\115\112\101\099\105\101\115\032\111\102\032\116\104\101\032\103\101\110\117\115\032\073\108\101\120\044\032\119\104\105\099\104\032\098\121\032\097\115\115\111\099\105\097\116\105\111\110\032\105\115\032\097\108\115\111\032\099\097\108\108\101\100\032\104\111\108\108\121\046\032\073\116\032\105\115\032\097\110\032\101\118\101\114\103\114\101\101\110\032\116\114\101\101\032\111\114\032\115\104\114\117\098\032\102\111\117\110\100\044\032\102\111\114\032\101\120\097\109\112\108\101\044\032\105\110\032\115\104\097\100\121\032\097\114\101\097\115\032\111\102\032\102\111\114\101\115\116\115\032\111\102\032\111\097\107\032\097\110\100\032\105\110\032\098\101\101\099\104\032\104\101\100\103\101\115\046\032\073\110\032\116\104\101\032\066\114\105\116\105\115\104\032\073\115\108\101\115\032\105\116\032\105\115\032\111\110\101\032\111\102\032\118\101\114\121\032\102\101\119\032\110\097\116\105\118\101\032\104\097\114\100\119\111\111\100\032\101\118\101\114\103\114\101\101\110\032\116\114\101\101\115\046\032\073\116\032\104\097\115\032\097\032\103\114\101\097\116\032\099\097\112\097\099\105\116\121\032\116\111\032\097\100\097\112\116\032\116\111\032\100\105\102\102\101\114\101\110\116\032\099\111\110\100\105\116\105\111\110\115\032\097\110\100\032\105\115\032\097\032\112\105\111\110\101\101\114\032\115\112\101\099\105\101\115\032\116\104\097\116\032\114\101\112\111\112\117\108\097\116\101\115\032\116\104\101\032\109\097\114\103\105\110\115\032\111\102\032\102\111\114\101\115\116\115\032\111\114\032\099\108\101\097\114\099\117\116\115\046\010\069\117\114\111\112\101\097\110\032\104\111\108\108\121\032\099\097\110\032\101\120\099\101\101\100\032\049\053\032\109\032\105\110\032\104\101\105\103\104\116\044\032\098\117\116\032\105\115\032\111\102\116\101\110\032\102\111\117\110\100\032\097\116\032\109\117\099\104\032\115\109\097\108\108\101\114\032\104\101\105\103\104\116\115\044\032\116\121\112\105\099\097\108\108\121\032\050\226\128\147\051\032\109\032\040\054\046\054\226\128\147\057\046\056\032\102\116\041\032\116\097\108\108\032\097\110\100\032\098\114\111\097\100\044\032\119\105\116\104\032\097\032\115\116\114\097\105\103\104\116\032\116\114\117\110\107\032\097\110\100\032\112\121\114\097\109\105\100\097\108\032\099\114\111\119\110\044\032\098\114\097\110\099\104\105\110\103\032\102\114\111\109\032\116\104\101\032\098\097\115\101\046\032\073\116\032\103\114\111\119\115\032\115\108\111\119\108\121\032\097\110\100\032\100\111\101\115\032\110\111\116\032\117\115\117\097\108\108\121\032\102\117\108\108\121\032\109\097\116\117\114\101\032\100\117\101\032\116\111\032\099\117\116\116\105\110\103\032\111\114\032\102\105\114\101\046\032\073\116\032\105\115\032\101\115\116\105\109\097\116\101\100\032\116\111\032\108\105\118\101\032\097\115\032\108\111\110\103\032\097\115\032\051\048\048\032\121\101\097\114\115\046\010\069\117\114\111\112\101\097\110\032\104\111\108\108\121\032\105\115\032\116\104\101\032\115\112\101\099\105\101\115\032\111\102\032\104\111\108\108\121\032\108\111\110\103\032\097\115\115\111\099\105\097\116\101\100\032\119\105\116\104\032\067\104\114\105\115\116\109\097\115\044\032\097\110\100\032\112\114\101\118\105\111\117\115\108\121\032\116\104\101\032\082\111\109\097\110\032\102\101\115\116\105\118\097\108\032\111\102\032\083\097\116\117\114\110\097\108\105\097\046\032\073\116\115\032\103\108\111\115\115\121\032\103\114\101\101\110\032\112\114\105\099\107\108\121\032\108\101\097\118\101\115\032\097\110\100\032\098\114\105\103\104\116\032\114\101\100\032\098\101\114\114\105\101\115\032\040\112\114\111\100\117\099\101\100\032\111\110\108\121\032\098\121\032\116\104\101\032\102\101\109\097\108\101\032\112\108\097\110\116\041\032\097\114\101\032\114\101\112\114\101\115\101\110\116\101\100\032\105\110\032\119\114\101\097\116\104\115\044\032\103\097\114\108\097\110\100\115\032\097\110\100\032\099\097\114\100\115\032\119\104\101\114\101\118\101\114\032\067\104\114\105\115\116\109\097\115\032\105\115\032\099\101\108\101\098\114\097\116\101\100\046\032\073\116\032\105\115\032\097\032\115\117\098\106\101\099\116\032\111\102\032\109\117\115\105\099\032\097\110\100\032\102\111\108\107\108\111\114\101\044\032\101\115\112\101\099\105\097\108\108\121\032\105\110\032\116\104\101\032\066\114\105\116\105\115\104\032\116\114\097\100\105\116\105\111\110\046\032\073\116\032\105\115\032\097\108\115\111\032\097\032\112\111\112\117\108\097\114\032\111\114\110\097\109\101\110\116\097\108\032\115\104\114\117\098\032\111\114\032\104\101\100\103\101\044\032\119\105\116\104\032\110\117\109\101\114\111\117\115\032\099\117\108\116\105\118\097\114\115\032\105\110\032\097\032\114\097\110\103\101\032\111\102\032\099\111\108\111\117\114\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -352,7 +409,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\054\056\057\052\052\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\074\117\110\105\112\101\114\117\115\095\099\111\109\109\117\110\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\057\084\050\048\058\050\056\058\048\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", text = "\074\117\110\105\112\101\114\117\115\032\099\111\109\109\117\110\105\115\044\032\116\104\101\032\099\111\109\109\111\110\032\106\117\110\105\112\101\114\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\115\109\097\108\108\032\116\114\101\101\032\111\114\032\115\104\114\117\098\032\105\110\032\116\104\101\032\099\121\112\114\101\115\115\032\102\097\109\105\108\121\032\067\117\112\114\101\115\115\097\099\101\097\101\046\032\065\110\032\101\118\101\114\103\114\101\101\110\032\099\111\110\105\102\101\114\044\032\105\116\032\104\097\115\032\116\104\101\032\108\097\114\103\101\115\116\032\103\101\111\103\114\097\112\104\105\099\097\108\032\114\097\110\103\101\032\111\102\032\097\110\121\032\119\111\111\100\121\032\112\108\097\110\116\044\032\119\105\116\104\032\097\032\072\111\108\097\114\099\116\105\099\032\100\105\115\116\114\105\098\117\116\105\111\110\032\116\104\114\111\117\103\104\111\117\116\032\116\104\101\032\099\111\111\108\032\116\101\109\112\101\114\097\116\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -371,7 +428,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\057\050\053\054\057\051", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\086\105\098\117\114\110\117\109\095\111\112\117\108\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\051\049\084\048\049\058\048\053\058\053\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\056\053\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", text = "\086\105\098\117\114\110\117\109\032\111\112\117\108\117\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\103\117\101\108\100\101\114\045\114\111\115\101\044\032\111\114\032\103\117\101\108\100\101\114\032\114\111\115\101\032\040\041\032\097\110\100\032\104\105\103\104\098\117\115\104\032\099\114\097\110\098\101\114\114\121\032\105\115\032\097\032\069\117\114\097\115\105\097\110\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\110\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\086\105\098\117\114\110\097\099\101\097\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -381,6 +438,82 @@ return { license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", }, + ["\104\111\110\101\121\115\117\099\107\108\101"] = { + latin = "\076\111\110\105\099\101\114\097\032\112\101\114\105\099\108\121\109\101\110\117\109", + title = "\076\111\110\105\099\101\114\097\032\112\101\114\105\099\108\121\109\101\110\117\109", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\076\111\110\105\099\101\114\097\095\112\101\114\105\099\108\121\109\101\110\117\109", + pageid = 811057, + revision = 1354922261, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\052\057\050\050\050\054\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\111\110\105\099\101\114\097\095\112\101\114\105\099\108\121\109\101\110\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\053\045\049\057\084\048\048\058\049\054\058\049\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", + text = "\076\111\110\105\099\101\114\097\032\112\101\114\105\099\108\121\109\101\110\117\109\044\032\099\111\109\109\111\110\032\110\097\109\101\115\032\104\111\110\101\121\115\117\099\107\108\101\044\032\099\111\109\109\111\110\032\104\111\110\101\121\115\117\099\107\108\101\044\032\069\117\114\111\112\101\097\110\032\104\111\110\101\121\115\117\099\107\108\101\044\032\111\114\032\119\111\111\100\098\105\110\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\067\097\112\114\105\102\111\108\105\097\099\101\097\101\032\110\097\116\105\118\101\032\116\111\032\109\117\099\104\032\111\102\032\069\117\114\111\112\101\044\032\078\111\114\116\104\032\065\102\114\105\099\097\044\032\084\117\114\107\101\121\032\097\110\100\032\116\104\101\032\067\097\117\099\097\115\117\115\046\032\073\116\032\105\115\032\102\111\117\110\100\032\097\115\032\102\097\114\032\110\111\114\116\104\032\097\115\032\115\111\117\116\104\101\114\110\032\078\111\114\119\097\121\044\032\083\119\101\100\101\110\032\097\110\100\032\070\105\110\108\097\110\100\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\076\111\110\105\099\101\114\097\032\112\101\114\105\099\108\121\109\101\110\117\109\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\105\118\121"] = { + latin = "\072\101\100\101\114\097\032\104\101\108\105\120", + title = "\072\101\100\101\114\097\032\104\101\108\105\120", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\072\101\100\101\114\097\095\104\101\108\105\120", + pageid = 5372245, + revision = 1382798111, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\055\057\056\049\049\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\072\101\100\101\114\097\095\104\101\108\105\120\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\051\048\084\049\048\058\050\050\058\048\056\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", + text = "\072\101\100\101\114\097\032\104\101\108\105\120\044\032\115\105\109\112\108\121\032\105\118\121\044\032\111\114\032\099\111\109\109\111\110\032\105\118\121\044\032\069\110\103\108\105\115\104\032\105\118\121\044\032\069\117\114\111\112\101\097\110\032\105\118\121\044\032\111\114\032\075\105\110\103\039\115\032\099\104\111\105\099\101\032\105\118\121\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\065\114\097\108\105\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\032\097\110\100\032\112\097\114\116\115\032\111\102\032\119\101\115\116\101\114\110\032\065\115\105\097\046\032\073\118\121\032\105\115\032\097\032\099\108\105\110\103\105\110\103\032\101\118\101\114\103\114\101\101\110\032\118\105\110\101\032\116\104\097\116\032\103\114\111\119\115\032\111\110\032\116\114\101\101\032\116\114\117\110\107\115\044\032\119\097\108\108\115\044\032\097\110\100\032\102\101\110\099\101\115\032\105\110\032\103\097\114\100\101\110\115\044\032\119\097\115\116\101\032\115\112\097\099\101\115\044\032\097\110\100\032\119\105\108\100\032\104\097\098\105\116\097\116\115\046\032\073\118\121\032\105\115\032\099\111\109\109\111\110\032\097\115\032\097\110\032\111\114\110\097\109\101\110\116\097\108\032\112\108\097\110\116\044\032\098\117\116\032\101\115\099\097\112\101\100\032\112\108\097\110\116\115\032\098\101\099\111\109\101\032\110\097\116\117\114\097\108\105\115\101\100\032\111\117\116\115\105\100\101\032\105\116\115\032\110\097\116\105\118\101\032\114\097\110\103\101\046\032\073\118\121\032\104\097\115\032\099\111\110\115\105\100\101\114\097\098\108\101\032\099\117\108\116\117\114\097\108\032\115\105\103\110\105\102\105\099\097\110\099\101\032\097\110\100\032\115\121\109\098\111\108\105\115\109\046\010\083\121\110\111\110\121\109\115\032\105\110\099\108\117\100\101\032\072\101\100\101\114\097\032\097\099\117\116\097\044\032\072\101\100\101\114\097\032\097\114\098\111\114\101\097\032\040\039\116\114\101\101\032\105\118\121\039\041\044\032\072\101\100\101\114\097\032\098\097\099\099\105\102\101\114\097\044\032\097\110\100\032\072\101\100\101\114\097\032\103\114\097\110\100\105\102\111\108\105\097\046\032\079\116\104\101\114\032\099\111\109\109\111\110\032\110\097\109\101\115\032\097\114\101\032\098\105\110\100\119\111\111\100\032\097\110\100\032\108\111\118\101\115\116\111\110\101\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\072\101\100\101\114\097\032\104\101\108\105\120\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\103\111\114\115\101"] = { + latin = "\085\108\101\120\032\101\117\114\111\112\097\101\117\115", + title = "\085\108\101\120\032\101\117\114\111\112\097\101\117\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\085\108\101\120\095\101\117\114\111\112\097\101\117\115", + pageid = 2482094, + revision = 1382611269, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\054\049\049\050\054\057", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\085\108\101\120\095\101\117\114\111\112\097\101\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\050\057\084\049\051\058\050\052\058\049\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", + text = "\085\108\101\120\032\101\117\114\111\112\097\101\117\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\103\111\114\115\101\044\032\099\111\109\109\111\110\032\103\111\114\115\101\044\032\102\117\114\122\101\032\111\114\032\032\119\104\105\110\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\110\097\116\105\118\101\032\116\111\032\087\101\115\116\101\114\110\032\069\117\114\111\112\101\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\085\108\101\120\032\101\117\114\111\112\097\101\117\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\098\114\111\111\109"] = { + latin = "\067\121\116\105\115\117\115\032\115\099\111\112\097\114\105\117\115", + title = "\067\121\116\105\115\117\115\032\115\099\111\112\097\114\105\117\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\067\121\116\105\115\117\115\095\115\099\111\112\097\114\105\117\115", + pageid = 510118, + revision = 1351345770, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\049\051\052\053\055\055\048", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\121\116\105\115\117\115\095\115\099\111\112\097\114\105\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\052\045\050\055\084\049\049\058\049\052\058\050\048\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", + text = "\067\121\116\105\115\117\115\032\115\099\111\112\097\114\105\117\115\032\040\115\121\110\046\032\083\097\114\111\116\104\097\109\110\117\115\032\115\099\111\112\097\114\105\117\115\041\044\032\116\104\101\032\099\111\109\109\111\110\032\098\114\111\111\109\032\111\114\032\083\099\111\116\099\104\032\098\114\111\111\109\044\032\105\115\032\097\032\100\101\099\105\100\117\111\117\115\032\108\101\103\117\109\105\110\111\117\115\032\115\104\114\117\098\032\110\097\116\105\118\101\032\116\111\032\119\101\115\116\101\114\110\032\097\110\100\032\099\101\110\116\114\097\108\032\069\117\114\111\112\101\046\032\073\110\032\071\114\101\097\116\032\066\114\105\116\097\105\110\032\097\110\100\032\073\114\101\108\097\110\100\044\032\116\104\101\032\115\116\097\110\100\097\114\100\032\110\097\109\101\032\105\115\032\098\114\111\111\109\059\032\116\104\105\115\032\110\097\109\101\032\105\115\032\097\108\115\111\032\117\115\101\100\032\102\111\114\032\111\116\104\101\114\032\109\101\109\098\101\114\115\032\111\102\032\116\104\101\032\071\101\110\105\115\116\101\097\101\032\116\114\105\098\101\044\032\115\117\099\104\032\097\115\032\070\114\101\110\099\104\032\098\114\111\111\109\032\111\114\032\083\112\097\110\105\115\104\032\098\114\111\111\109\059\032\097\110\100\032\116\104\101\032\116\101\114\109\032\099\111\109\109\111\110\032\098\114\111\111\109\032\105\115\032\115\111\109\101\116\105\109\101\115\032\117\115\101\100\032\102\111\114\032\099\108\097\114\105\102\105\099\097\116\105\111\110\046\032\073\110\032\111\116\104\101\114\032\069\110\103\108\105\115\104\045\115\112\101\097\107\105\110\103\032\099\111\117\110\116\114\105\101\115\044\032\116\104\101\032\109\111\115\116\032\099\111\109\109\111\110\032\110\097\109\101\032\105\115\032\034\083\099\111\116\099\104\032\098\114\111\111\109\034\032\040\111\114\032\083\099\111\116\115\032\098\114\111\111\109\041\059\032\104\111\119\101\118\101\114\044\032\105\116\032\105\115\032\107\110\111\119\110\032\097\115\032\069\110\103\108\105\115\104\032\098\114\111\111\109\032\105\110\032\065\117\115\116\114\097\108\105\097\046\010\084\104\111\117\103\104\032\116\104\105\115\032\112\108\097\110\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\105\116\032\104\097\115\032\115\112\114\101\097\100\032\116\111\032\109\097\110\121\032\111\116\104\101\114\032\112\097\114\116\115\032\111\102\032\116\104\101\032\119\111\114\108\100\032\119\105\116\104\032\104\117\109\097\110\032\105\110\116\114\111\100\117\099\116\105\111\110\046\032\083\099\111\116\099\104\032\098\114\111\111\109\032\105\115\032\110\111\119\032\099\111\109\109\111\110\032\105\110\032\099\101\114\116\097\105\110\032\097\114\101\097\115\032\111\102\032\078\111\114\116\104\032\065\109\101\114\105\099\097\032\097\110\100\032\083\111\117\116\104\032\065\109\101\114\105\099\097\044\032\097\110\100\032\105\115\032\099\111\110\115\105\100\101\114\101\100\032\097\110\032\105\110\118\097\115\105\118\101\032\115\112\101\099\105\101\115\046\032\073\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\083\099\111\116\099\104\032\098\114\111\111\109\032\119\097\115\032\102\114\101\113\117\101\110\116\108\121\032\112\108\097\110\116\101\100\032\105\110\032\103\097\114\100\101\110\115\044\032\097\110\100\032\119\097\115\032\108\097\116\101\114\032\117\115\101\100\032\102\111\114\032\101\114\111\115\105\111\110\032\099\111\110\116\114\111\108\032\097\108\111\110\103\032\104\105\103\104\119\097\121\032\099\117\116\115\032\097\110\100\032\102\105\108\108\115\046\032\083\099\111\116\099\104\032\098\114\111\111\109\032\105\115\032\115\108\105\103\104\116\108\121\032\116\111\120\105\099\032\097\110\100\032\117\110\112\097\108\097\116\097\098\108\101\032\116\111\032\108\105\118\101\115\116\111\099\107\044\032\097\110\100\032\105\116\115\032\115\101\101\100\115\032\097\114\101\032\118\105\097\098\108\101\032\102\111\114\032\117\112\032\116\111\032\116\101\110\032\121\101\097\114\115\044\032\097\108\108\111\119\105\110\103\032\116\104\101\109\032\116\111\032\114\101\103\114\111\119\032\109\097\110\121\032\121\101\097\114\115\032\108\097\116\101\114\044\032\097\102\116\101\114\032\101\120\116\101\114\109\105\110\097\116\105\111\110\032\111\102\032\116\104\101\032\112\108\097\110\116\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\067\121\116\105\115\117\115\032\115\099\111\112\097\114\105\117\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, ["\115\101\100\103\101"] = { latin = "\067\097\114\101\120\032\112\097\110\105\099\117\108\097\116\097", title = "\067\097\114\101\120\032\112\097\110\105\099\117\108\097\116\097", @@ -390,7 +523,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\048\054\050\050\049\050\053\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\097\114\101\120\095\112\097\110\105\099\117\108\097\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\048\056\045\049\054\084\049\053\058\053\052\058\049\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", text = "\067\097\114\101\120\032\112\097\110\105\099\117\108\097\116\097\044\032\116\104\101\032\103\114\101\097\116\101\114\032\116\117\115\115\111\099\107\045\115\101\100\103\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\115\101\100\103\101\032\102\097\109\105\108\121\044\032\067\121\112\101\114\097\099\101\097\101\046\032\073\116\032\103\114\111\119\115\032\049\046\053\032\109\101\116\114\101\115\032\040\052\032\102\116\032\049\049\032\105\110\041\032\104\105\103\104\032\097\110\100\032\099\097\110\032\098\101\032\102\111\117\110\100\032\105\110\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\032\040\105\110\099\108\117\100\105\110\103\032\066\114\105\116\097\105\110\041\044\032\078\111\114\116\104\119\101\115\116\032\065\115\105\097\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -409,7 +542,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\053\057\052\057\056\050\051", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\084\121\112\104\097\095\108\097\116\105\102\111\108\105\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\053\045\050\052\084\050\049\058\052\052\058\050\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", text = "\084\121\112\104\097\032\108\097\116\105\102\111\108\105\097\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\104\101\114\098\097\099\101\111\117\115\032\119\101\116\108\097\110\100\032\112\108\097\110\116\032\105\110\032\116\104\101\032\103\101\110\117\115\032\084\121\112\104\097\046\032\073\116\032\105\115\032\107\110\111\119\110\032\105\110\032\069\110\103\108\105\115\104\032\097\115\032\098\117\108\114\117\115\104\032\040\115\111\109\101\116\105\109\101\115\032\097\115\032\099\111\109\109\111\110\032\098\117\108\114\117\115\104\032\116\111\032\100\105\115\116\105\110\103\117\105\115\104\032\102\114\111\109\032\111\116\104\101\114\032\115\112\101\099\105\101\115\032\111\102\032\084\121\112\104\097\041\044\032\097\110\100\032\105\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\032\097\115\032\098\114\111\097\100\108\101\097\102\032\099\097\116\116\097\105\108\046\032\073\116\032\105\115\032\102\111\117\110\100\032\097\115\032\097\032\110\097\116\105\118\101\032\112\108\097\110\116\032\115\112\101\099\105\101\115\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\069\117\114\097\115\105\097\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\097\110\100\032\109\111\114\101\032\108\111\099\097\108\108\121\032\105\110\032\065\102\114\105\099\097\032\097\110\100\032\083\111\117\116\104\032\065\109\101\114\105\099\097\046\032\084\104\101\032\103\101\110\111\109\101\032\111\102\032\084\046\032\108\097\116\105\102\111\108\105\097\032\119\097\115\032\112\117\098\108\105\115\104\101\100\032\105\110\032\050\048\050\050\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -428,7 +561,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\052\055\055\052\048\053\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\111\108\105\117\109\095\112\114\097\116\101\110\115\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\053\045\049\056\084\048\052\058\053\052\058\048\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\052\050\090", text = "\076\111\108\105\117\109\032\112\114\097\116\101\110\115\101\044\032\109\101\097\100\111\119\032\102\101\115\099\117\101\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\115\112\101\099\105\101\115\032\111\102\032\103\114\097\115\115\044\032\119\104\105\099\104\032\105\115\032\111\102\116\101\110\032\117\115\101\100\032\097\115\032\097\110\032\111\114\110\097\109\101\110\116\097\108\032\105\110\032\103\097\114\100\101\110\115\044\032\097\110\100\032\105\115\032\097\108\115\111\032\097\110\032\105\109\112\111\114\116\097\110\116\032\102\111\114\097\103\101\032\099\114\111\112\046\010\073\116\032\103\114\111\119\115\032\105\110\032\109\101\097\100\111\119\115\044\032\114\111\097\100\115\105\100\101\115\044\032\111\108\100\032\112\097\115\116\117\114\101\115\044\032\097\110\100\032\114\105\118\101\114\115\105\100\101\115\032\111\110\032\109\111\105\115\116\044\032\114\105\099\104\032\115\111\105\108\115\044\032\101\115\112\101\099\105\097\108\108\121\032\111\110\032\108\111\097\109\121\032\097\110\100\032\104\101\097\118\121\032\115\111\105\108\115\046\010\077\111\115\116\032\112\117\098\108\105\099\097\116\105\111\110\115\032\104\097\118\101\032\117\115\101\100\032\116\104\101\032\110\097\109\101\115\032\070\101\115\116\117\099\097\032\112\114\097\116\101\110\115\105\115\032\111\114\044\032\109\111\114\101\032\114\101\099\101\110\116\108\121\044\032\083\099\104\101\100\111\110\111\114\117\115\032\112\114\097\116\101\110\115\105\115\032\102\111\114\032\116\104\105\115\032\115\112\101\099\105\101\115\044\032\098\117\116\032\068\078\065\032\115\116\117\100\105\101\115\032\097\112\112\101\097\114\032\116\111\032\104\097\118\101\032\115\101\116\116\108\101\100\032\097\032\108\111\110\103\032\100\101\098\097\116\101\032\116\104\097\116\032\105\116\032\115\104\111\117\108\100\032\098\101\032\105\110\099\108\117\100\101\100\032\119\105\116\104\105\110\032\116\104\101\032\103\101\110\117\115\032\076\111\108\105\117\109\032\105\110\115\116\101\097\100\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -447,7 +580,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\055\050\055\053\055\053\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\068\097\099\116\121\108\105\115\095\103\108\111\109\101\114\097\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\048\050\084\048\050\058\053\052\058\048\049\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\068\097\099\116\121\108\105\115\032\103\108\111\109\101\114\097\116\097\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\103\114\097\115\115\032\102\097\109\105\108\121\032\080\111\097\099\101\097\101\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\098\097\114\110\121\097\114\100\032\103\114\097\115\115\044\032\099\111\099\107\115\102\111\111\116\032\103\114\097\115\115\044\032\099\111\099\107\115\112\117\114\044\032\111\114\099\104\097\114\100\032\103\114\097\115\115\044\032\111\114\032\099\097\116\032\103\114\097\115\115\032\040\100\117\101\032\116\111\032\105\116\115\032\112\111\112\117\108\097\114\105\116\121\032\102\111\114\032\117\115\101\032\119\105\116\104\032\100\111\109\101\115\116\105\099\032\099\097\116\115\041\046\032\073\116\032\105\115\032\097\032\099\111\111\108\045\115\101\097\115\111\110\032\112\101\114\101\110\110\105\097\108\032\067\051\032\116\117\102\116\101\100\032\103\114\097\115\115\032\110\097\116\105\118\101\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\044\032\116\101\109\112\101\114\097\116\101\032\065\115\105\097\044\032\097\110\100\032\110\111\114\116\104\101\114\110\032\065\102\114\105\099\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -466,7 +599,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\056\048\048\049\052\050\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\084\105\109\111\116\104\121\095\040\103\114\097\115\115\041\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\048\054\084\049\051\058\048\056\058\048\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\084\105\109\111\116\104\121\032\040\080\104\108\101\117\109\032\112\114\097\116\101\110\115\101\041\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\112\101\114\101\110\110\105\097\108\032\103\114\097\115\115\032\110\097\116\105\118\101\032\116\111\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\032\097\110\100\032\110\111\114\116\104\101\114\110\032\097\110\100\032\119\101\115\116\101\114\110\032\065\115\105\097\046\032\073\116\032\105\115\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\116\105\109\111\116\104\121\045\103\114\097\115\115\044\032\032\109\101\097\100\111\119\032\099\097\116\039\115\045\116\097\105\108\032\111\114\032\099\111\109\109\111\110\032\099\097\116\039\115\045\116\097\105\108\046\032\073\116\032\105\115\032\097\032\109\101\109\098\101\114\032\111\102\032\116\104\101\032\103\101\110\117\115\032\080\104\108\101\117\109\044\032\119\104\105\099\104\032\099\111\110\115\105\115\116\115\032\111\102\032\049\055\032\115\112\101\099\105\101\115\032\111\102\032\097\110\110\117\097\108\032\097\110\100\032\112\101\114\101\110\110\105\097\108\032\103\114\097\115\115\101\115\046\010\073\116\032\105\115\032\112\114\111\098\097\098\108\121\032\110\097\109\101\100\032\097\102\116\101\114\032\084\105\109\111\116\104\121\032\072\097\110\115\111\110\044\032\097\032\066\114\105\116\105\115\104\032\102\097\114\109\101\114\032\097\110\100\032\097\103\114\105\099\117\108\116\117\114\097\108\105\115\116\032\115\097\105\100\032\116\111\032\104\097\118\101\032\105\110\116\114\111\100\117\099\101\100\032\105\116\032\102\114\111\109\032\078\101\119\032\069\110\103\108\097\110\100\032\116\111\032\116\104\101\032\115\111\117\116\104\101\114\110\032\085\110\105\116\101\100\032\083\116\097\116\101\115\032\105\110\032\116\104\101\032\101\097\114\108\121\032\049\056\116\104\032\099\101\110\116\117\114\121\046\032\085\112\111\110\032\104\105\115\032\114\101\099\111\109\109\101\110\100\097\116\105\111\110\032\105\116\032\098\101\099\097\109\101\032\097\032\109\097\106\111\114\032\115\111\117\114\099\101\032\111\102\032\104\097\121\032\097\110\100\032\099\097\116\116\108\101\032\102\111\100\100\101\114\032\116\111\032\066\114\105\116\105\115\104\032\102\097\114\109\101\114\115\032\105\110\032\116\104\101\032\109\105\100\045\049\056\116\104\032\099\101\110\116\117\114\121\046\010\084\105\109\111\116\104\121\032\104\097\115\032\110\111\119\032\098\101\099\111\109\101\032\110\097\116\117\114\097\108\105\115\101\100\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\101\097\115\116\101\114\110\032\065\115\105\097\044\032\115\111\117\116\104\101\114\110\032\083\111\117\116\104\032\065\109\101\114\105\099\097\044\032\097\110\100\032\065\117\115\116\114\097\108\097\115\105\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -485,7 +618,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\053\055\051\049\053\049\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\110\116\104\111\120\097\110\116\104\117\109\095\111\100\111\114\097\116\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\053\045\050\051\084\049\054\058\048\049\058\049\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\065\110\116\104\111\120\097\110\116\104\117\109\032\111\100\111\114\097\116\117\109\032\105\115\032\097\032\115\104\111\114\116\045\108\105\118\101\100\032\112\101\114\101\110\110\105\097\108\032\103\114\097\115\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\115\119\101\101\116\032\118\101\114\110\097\108\032\103\114\097\115\115\044\032\116\104\097\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\097\099\105\100\105\099\032\103\114\097\115\115\108\097\110\100\032\105\110\032\069\117\114\097\115\105\097\032\097\110\100\032\110\111\114\116\104\101\114\110\032\065\102\114\105\099\097\046\032\073\116\032\105\115\032\103\114\111\119\110\032\097\115\032\097\032\108\097\119\110\032\103\114\097\115\115\032\097\110\100\032\097\032\104\111\117\115\101\112\108\097\110\116\044\032\100\117\101\032\116\111\032\105\116\115\032\115\119\101\101\116\032\115\099\101\110\116\044\032\097\110\100\032\099\097\110\032\097\108\115\111\032\098\101\032\102\111\117\110\100\032\111\110\032\117\110\105\109\112\114\111\118\101\100\032\112\097\115\116\117\114\101\115\032\097\110\100\032\109\101\097\100\111\119\115\046\032\084\104\101\032\115\112\101\099\105\102\105\099\032\101\112\105\116\104\101\116\032\111\100\111\114\097\116\117\109\032\105\115\032\076\097\116\105\110\032\102\111\114\032\039\111\100\111\114\111\117\115\039\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -504,7 +637,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\056\057\048\055\052\055\057", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\072\111\108\099\117\115\095\108\097\110\097\116\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\054\045\049\049\084\049\057\058\049\055\058\048\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\072\111\108\099\117\115\032\108\097\110\097\116\117\115\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\103\114\097\115\115\032\102\097\109\105\108\121\032\080\111\097\099\101\097\101\046\032\084\104\101\032\115\112\101\099\105\102\105\099\032\101\112\105\116\104\101\116\032\108\097\110\097\116\117\115\032\105\115\032\076\097\116\105\110\032\102\111\114\032\039\119\111\111\108\108\121\039\032\119\104\105\099\104\032\100\101\115\099\114\105\098\101\115\032\116\104\101\032\112\108\097\110\116\039\115\032\104\097\105\114\121\032\116\101\120\116\117\114\101\046\032\067\111\109\109\111\110\032\110\097\109\101\115\032\105\110\099\108\117\100\101\032\089\111\114\107\115\104\105\114\101\032\102\111\103\044\032\116\117\102\116\101\100\032\103\114\097\115\115\044\032\097\110\100\032\109\101\097\100\111\119\032\115\111\102\116\032\103\114\097\115\115\046\032\073\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\119\104\101\114\101\032\105\116\032\105\115\032\097\110\032\105\110\118\097\115\105\118\101\032\115\112\101\099\105\101\115\044\032\110\097\109\101\115\032\105\110\099\108\117\100\101\032\118\101\108\118\101\116\032\103\114\097\115\115\032\097\110\100\032\099\111\109\109\111\110\032\118\101\108\118\101\116\032\103\114\097\115\115\046\010\073\110\032\112\097\114\116\115\032\111\102\032\110\111\114\116\104\101\114\110\032\069\117\114\111\112\101\032\116\104\101\032\103\114\097\115\115\032\105\115\032\097\032\099\111\109\109\111\110\032\110\097\116\105\118\101\032\115\112\101\099\105\101\115\032\097\110\100\032\097\032\104\097\114\100\121\032\112\097\115\116\117\114\101\032\103\114\097\115\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -523,7 +656,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\049\050\050\049\052\052\049\057", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\068\101\115\099\104\097\109\112\115\105\097\095\099\101\115\112\105\116\111\115\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\048\057\045\049\057\084\049\048\058\048\054\058\053\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\068\101\115\099\104\097\109\112\115\105\097\032\099\101\115\112\105\116\111\115\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\117\102\116\101\100\032\104\097\105\114\103\114\097\115\115\032\111\114\032\116\117\115\115\111\099\107\032\103\114\097\115\115\044\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\116\117\102\116\101\100\032\112\108\097\110\116\032\105\110\032\116\104\101\032\103\114\097\115\115\032\102\097\109\105\108\121\032\080\111\097\099\101\097\101\046\032\084\104\101\032\100\105\115\116\114\105\098\117\116\105\111\110\032\111\102\032\116\104\105\115\032\115\112\101\099\105\101\115\032\105\115\032\119\105\100\101\115\112\114\101\097\100\044\032\119\105\116\104\032\105\116\032\098\101\105\110\103\032\110\097\116\105\118\101\032\116\111\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\067\101\110\116\114\097\108\032\065\102\114\105\099\097\044\032\101\097\115\116\101\114\110\032\065\117\115\116\114\097\108\105\097\044\032\078\101\119\032\090\101\097\108\097\110\100\044\032\097\110\100\032\069\117\114\097\115\105\097\032\097\110\100\032\098\101\105\110\103\032\105\110\116\114\111\100\117\099\101\100\032\116\111\032\083\111\117\116\104\032\065\102\114\105\099\097\044\032\083\111\117\116\104\032\065\117\115\116\114\097\108\105\097\032\097\110\100\032\083\111\117\116\104\032\065\109\101\114\105\099\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -542,7 +675,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\056\053\049\054\049\048\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\104\114\097\103\109\105\116\101\115\095\097\117\115\116\114\097\108\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\048\052\084\050\049\058\049\054\058\049\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\080\104\114\097\103\109\105\116\101\115\032\097\117\115\116\114\097\108\105\115\044\032\116\104\101\032\099\111\109\109\111\110\032\114\101\101\100\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\103\114\097\115\115\032\102\097\109\105\108\121\032\080\111\097\099\101\097\101\046\032\073\116\032\105\115\032\097\032\119\101\116\108\097\110\100\032\103\114\097\115\115\032\116\104\097\116\032\099\097\110\032\103\114\111\119\032\117\112\032\116\111\032\050\226\128\147\054\032\109\101\116\114\101\115\032\040\055\226\128\147\050\048\032\102\116\041\032\116\097\108\108\032\097\110\100\032\104\097\115\032\097\032\099\111\115\109\111\112\111\108\105\116\097\110\032\100\105\115\116\114\105\098\117\116\105\111\110\032\119\111\114\108\100\119\105\100\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -561,7 +694,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\051\057\055\055\051\049\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\074\117\110\099\117\115\095\101\102\102\117\115\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\048\057\084\048\050\058\051\049\058\050\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\052\046\057\055\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\074\117\110\099\117\115\032\101\102\102\117\115\117\115\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\104\101\114\098\097\099\101\111\117\115\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\114\117\115\104\032\102\097\109\105\108\121\032\074\117\110\099\097\099\101\097\101\044\032\119\105\116\104\032\116\104\101\032\099\111\109\109\111\110\032\110\097\109\101\115\032\099\111\109\109\111\110\032\114\117\115\104\032\111\114\032\115\111\102\116\032\114\117\115\104\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -580,7 +713,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\054\053\053\049\049\057\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\084\114\105\102\111\108\105\117\109\095\114\101\112\101\110\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\052\084\050\049\058\052\052\058\048\050\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\084\114\105\102\111\108\105\117\109\032\114\101\112\101\110\115\044\032\116\104\101\032\119\104\105\116\101\032\099\108\111\118\101\114\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\112\108\097\110\116\032\105\110\032\116\104\101\032\098\101\097\110\032\102\097\109\105\108\121\044\032\070\097\098\097\099\101\097\101\032\040\097\108\115\111\032\107\110\111\119\110\032\097\115\032\076\101\103\117\109\105\110\111\115\097\101\041\046\010\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\032\097\110\100\032\105\115\032\111\110\101\032\111\102\032\116\104\101\032\109\111\115\116\032\119\105\100\101\108\121\032\099\117\108\116\105\118\097\116\101\100\032\116\121\112\101\115\032\111\102\032\099\108\111\118\101\114\046\032\073\116\032\104\097\115\032\098\101\101\110\032\119\105\100\101\108\121\032\105\110\116\114\111\100\117\099\101\100\032\119\111\114\108\100\119\105\100\101\032\097\115\032\097\032\102\111\114\097\103\101\032\099\114\111\112\044\032\097\110\100\032\105\115\032\110\111\119\032\097\108\115\111\032\099\111\109\109\111\110\032\105\110\032\109\111\115\116\032\103\114\097\115\115\121\032\097\114\101\097\115\032\111\102\032\078\111\114\116\104\032\097\110\100\032\083\111\117\116\104\032\065\109\101\114\105\099\097\044\032\065\117\115\116\114\097\108\105\097\032\097\110\100\032\078\101\119\032\090\101\097\108\097\110\100\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -599,7 +732,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\049\053\053\054\057\052\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\085\114\116\105\099\097\095\100\105\111\105\099\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\051\084\048\056\058\053\048\058\053\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\085\114\116\105\099\097\032\100\105\111\105\099\097\044\032\111\102\116\101\110\032\107\110\111\119\110\032\097\115\032\099\111\109\109\111\110\032\110\101\116\116\108\101\044\032\098\117\114\110\032\110\101\116\116\108\101\044\032\115\116\105\110\103\105\110\103\032\110\101\116\116\108\101\044\032\110\101\116\116\108\101\032\108\101\097\102\044\032\111\114\032\106\117\115\116\032\097\032\110\101\116\116\108\101\032\111\114\032\115\116\105\110\103\101\114\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\085\114\116\105\099\097\099\101\097\101\046\032\079\114\105\103\105\110\097\108\108\121\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\109\117\099\104\032\111\102\032\116\101\109\112\101\114\097\116\101\032\065\115\105\097\032\097\110\100\032\119\101\115\116\101\114\110\032\078\111\114\116\104\032\065\102\114\105\099\097\044\032\105\116\032\105\115\032\110\111\119\032\102\111\117\110\100\032\119\111\114\108\100\119\105\100\101\046\032\010\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\100\105\118\105\100\101\100\032\105\110\116\111\032\115\105\120\032\115\117\098\115\112\101\099\105\101\115\044\032\102\105\118\101\032\111\102\032\119\104\105\099\104\032\104\097\118\101\032\109\097\110\121\032\104\111\108\108\111\119\032\115\116\105\110\103\105\110\103\032\104\097\105\114\115\032\099\097\108\108\101\100\032\116\114\105\099\104\111\109\101\115\032\111\110\032\116\104\101\032\108\101\097\118\101\115\032\097\110\100\032\115\116\101\109\115\044\032\119\104\105\099\104\032\097\099\116\032\108\105\107\101\032\104\121\112\111\100\101\114\109\105\099\032\110\101\101\100\108\101\115\044\032\105\110\106\101\099\116\105\110\103\032\104\105\115\116\097\109\105\110\101\032\097\110\100\032\111\116\104\101\114\032\099\104\101\109\105\099\097\108\115\032\116\104\097\116\032\112\114\111\100\117\099\101\032\097\032\115\116\105\110\103\105\110\103\032\115\101\110\115\097\116\105\111\110\032\117\112\111\110\032\099\111\110\116\097\099\116\032\040\034\099\111\110\116\097\099\116\032\117\114\116\105\099\097\114\105\097\034\044\032\097\032\102\111\114\109\032\111\102\032\099\111\110\116\097\099\116\032\100\101\114\109\097\116\105\116\105\115\041\046\010\084\104\101\032\112\108\097\110\116\032\104\097\115\032\097\032\108\111\110\103\032\104\105\115\116\111\114\121\032\111\102\032\117\115\101\032\097\115\032\097\032\115\111\117\114\099\101\032\102\111\114\032\116\114\097\100\105\116\105\111\110\097\108\032\109\101\100\105\099\105\110\101\044\032\102\111\111\100\044\032\116\101\097\044\032\097\110\100\032\116\101\120\116\105\108\101\032\114\097\119\032\109\097\116\101\114\105\097\108\032\105\110\032\097\110\099\105\101\110\116\032\040\115\117\099\104\032\097\115\032\083\097\120\111\110\041\032\097\110\100\032\109\111\100\101\114\110\032\115\111\099\105\101\116\105\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -618,7 +751,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\049\048\048\053\057\048\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\097\108\108\117\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\057\084\049\049\058\050\053\058\048\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\049\053\052\090", text = "\067\097\108\108\117\110\097\032\118\117\108\103\097\114\105\115\044\032\099\111\109\109\111\110\032\104\101\097\116\104\101\114\044\032\108\105\110\103\044\032\111\114\032\115\105\109\112\108\121\032\104\101\097\116\104\101\114\044\032\105\115\032\116\104\101\032\111\110\108\121\032\097\099\099\101\112\116\101\100\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\103\101\110\117\115\032\067\097\108\108\117\110\097\032\105\110\032\116\104\101\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\102\097\109\105\108\121\032\069\114\105\099\097\099\101\097\101\046\032\073\116\032\105\115\032\097\032\108\111\119\045\103\114\111\119\105\110\103\032\101\118\101\114\103\114\101\101\110\032\115\104\114\117\098\032\103\114\111\119\105\110\103\032\116\111\032\050\048\032\116\111\032\053\048\032\099\101\110\116\105\109\101\116\114\101\115\032\040\056\032\116\111\032\050\048\032\105\110\041\032\116\097\108\108\044\032\111\114\032\114\097\114\101\108\121\032\116\111\032\049\032\109\101\116\114\101\032\040\052\048\032\105\110\041\032\097\110\100\032\116\097\108\108\101\114\044\032\097\110\100\032\105\115\032\102\111\117\110\100\032\119\105\100\101\108\121\032\105\110\032\069\117\114\111\112\101\032\097\110\100\032\065\115\105\097\032\077\105\110\111\114\032\111\110\032\097\099\105\100\105\099\032\115\111\105\108\115\032\105\110\032\111\112\101\110\032\115\117\110\110\121\032\115\105\116\117\097\116\105\111\110\115\032\097\110\100\032\105\110\032\109\111\100\101\114\097\116\101\032\115\104\097\100\101\046\010\073\116\032\105\115\032\116\104\101\032\100\111\109\105\110\097\110\116\032\112\108\097\110\116\032\105\110\032\109\111\115\116\032\104\101\097\116\104\108\097\110\100\032\097\110\100\032\109\111\111\114\108\097\110\100\032\105\110\032\069\117\114\111\112\101\044\032\097\110\100\032\105\110\032\115\111\109\101\032\098\111\103\032\118\101\103\101\116\097\116\105\111\110\032\097\110\100\032\097\099\105\100\105\099\032\112\105\110\101\032\097\110\100\032\111\097\107\032\119\111\111\100\108\097\110\100\046\032\032\073\116\032\105\115\032\116\111\108\101\114\097\110\116\032\111\102\032\103\114\097\122\105\110\103\032\097\110\100\032\114\101\103\101\110\101\114\097\116\101\115\032\102\111\108\108\111\119\105\110\103\032\111\099\099\097\115\105\111\110\097\108\032\098\117\114\110\105\110\103\044\032\097\110\100\032\105\115\032\111\102\116\101\110\032\109\097\110\097\103\101\100\032\105\110\032\110\097\116\117\114\101\032\114\101\115\101\114\118\101\115\032\097\110\100\032\103\114\111\117\115\101\032\109\111\111\114\115\032\098\121\032\115\104\101\101\112\032\111\114\032\099\097\116\116\108\101\032\103\114\097\122\105\110\103\044\032\097\110\100\032\097\108\115\111\032\098\121\032\108\105\103\104\116\032\098\117\114\110\105\110\103\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -637,7 +770,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\056\054\048\055\052\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\072\121\097\099\105\110\116\104\111\105\100\101\115\095\110\111\110\045\115\099\114\105\112\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\056\084\049\049\058\053\051\058\048\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\072\121\097\099\105\110\116\104\111\105\100\101\115\032\110\111\110\045\115\099\114\105\112\116\097\032\032\040\102\111\114\109\101\114\108\121\032\069\110\100\121\109\105\111\110\032\110\111\110\045\115\099\114\105\112\116\117\115\032\111\114\032\083\099\105\108\108\097\032\110\111\110\045\115\099\114\105\112\116\097\041\032\105\115\032\097\032\098\117\108\098\111\117\115\032\112\101\114\101\110\110\105\097\108\032\112\108\097\110\116\032\102\111\117\110\100\032\105\110\032\065\116\108\097\110\116\105\099\032\097\114\101\097\115\032\102\114\111\109\032\116\104\101\032\110\111\114\116\104\045\119\101\115\116\101\114\110\032\112\097\114\116\032\111\102\032\116\104\101\032\073\098\101\114\105\097\110\032\080\101\110\105\110\115\117\108\097\032\116\111\032\116\104\101\032\066\114\105\116\105\115\104\032\073\115\108\101\115\044\032\097\110\100\032\097\108\115\111\032\102\114\101\113\117\101\110\116\108\121\032\117\115\101\100\032\097\115\032\097\032\103\097\114\100\101\110\032\112\108\097\110\116\046\032\073\116\032\105\115\032\107\110\111\119\110\032\105\110\032\069\110\103\108\105\115\104\032\097\115\032\116\104\101\032\099\111\109\109\111\110\032\098\108\117\101\098\101\108\108\044\032\069\110\103\108\105\115\104\032\098\108\117\101\098\101\108\108\032\111\114\032\115\105\109\112\108\121\032\098\108\117\101\098\101\108\108\044\032\097\032\110\097\109\101\032\119\104\105\099\104\032\105\115\032\117\115\101\100\032\105\110\032\083\099\111\116\108\097\110\100\032\116\111\032\114\101\102\101\114\032\116\111\032\116\104\101\032\104\097\114\101\098\101\108\108\044\032\067\097\109\112\097\110\117\108\097\032\114\111\116\117\110\100\105\102\111\108\105\097\046\032\073\110\032\115\112\114\105\110\103\044\032\072\046\032\110\111\110\045\115\099\114\105\112\116\097\032\112\114\111\100\117\099\101\115\032\097\032\110\111\100\100\105\110\103\044\032\111\110\101\045\115\105\100\101\100\032\105\110\102\108\111\114\101\115\099\101\110\099\101\032\111\102\032\053\226\128\147\049\050\032\116\117\098\117\108\097\114\044\032\115\119\101\101\116\045\115\099\101\110\116\101\100\032\118\105\111\108\101\116\226\128\147\098\108\117\101\032\102\108\111\119\101\114\115\044\032\119\105\116\104\032\115\116\114\111\110\103\108\121\032\114\101\099\117\114\118\101\100\032\116\101\112\097\108\115\044\032\097\110\100\032\051\226\128\147\054\032\108\111\110\103\044\032\108\105\110\101\097\114\044\032\098\097\115\097\108\032\108\101\097\118\101\115\046\010\072\046\032\110\111\110\045\115\099\114\105\112\116\097\032\105\115\032\112\097\114\116\105\099\117\108\097\114\108\121\032\097\115\115\111\099\105\097\116\101\100\032\119\105\116\104\032\097\110\099\105\101\110\116\032\119\111\111\100\108\097\110\100\032\119\104\101\114\101\032\105\116\032\109\097\121\032\100\111\109\105\110\097\116\101\032\116\104\101\032\117\110\100\101\114\115\116\111\114\121\032\116\111\032\112\114\111\100\117\099\101\032\099\097\114\112\101\116\115\032\111\102\032\118\105\111\108\101\116\226\128\147\098\108\117\101\032\102\108\111\119\101\114\115\032\105\110\032\034\098\108\117\101\098\101\108\108\032\119\111\111\100\115\034\044\032\098\117\116\032\097\108\115\111\032\111\099\099\117\114\115\032\105\110\032\109\111\114\101\032\111\112\101\110\032\104\097\098\105\116\097\116\115\032\105\110\032\119\101\115\116\101\114\110\032\114\101\103\105\111\110\115\046\032\073\116\032\105\115\032\112\114\111\116\101\099\116\101\100\032\117\110\100\101\114\032\085\075\032\108\097\119\044\032\097\110\100\032\105\110\032\115\111\109\101\032\111\116\104\101\114\032\112\097\114\116\115\032\111\102\032\105\116\115\032\114\097\110\103\101\046\032\065\032\114\101\108\097\116\101\100\032\115\112\101\099\105\101\115\044\032\072\046\032\104\105\115\112\097\110\105\099\097\044\032\104\097\115\032\097\108\115\111\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\116\111\032\066\114\105\116\097\105\110\032\097\110\100\032\073\114\101\108\097\110\100\032\097\110\100\032\104\121\098\114\105\100\105\115\101\115\032\119\105\116\104\032\072\046\032\110\111\110\045\115\099\114\105\112\116\097\032\116\111\032\112\114\111\100\117\099\101\032\105\110\116\101\114\109\101\100\105\097\116\101\115\032\107\110\111\119\110\032\097\115\032\072\046\032\195\151\032\109\097\115\115\097\114\116\105\097\110\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -656,7 +789,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\051\049\049\053\052\055\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\099\104\105\108\108\101\097\095\109\105\108\108\101\102\111\108\105\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\049\045\048\049\084\048\049\058\052\057\058\050\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\065\099\104\105\108\108\101\097\032\109\105\108\108\101\102\111\108\105\117\109\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\121\097\114\114\111\119\032\040\041\032\111\114\032\099\111\109\109\111\110\032\121\097\114\114\111\119\044\032\105\115\032\097\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\065\115\116\101\114\097\099\101\097\101\046\032\071\114\111\119\105\110\103\032\116\111\032\049\032\109\101\116\114\101\032\040\051\043\049\226\129\132\051\032\102\101\101\116\041\032\116\097\108\108\044\032\105\116\032\105\115\032\099\104\097\114\097\099\116\101\114\105\122\101\100\032\098\121\032\115\109\097\108\108\032\119\104\105\116\105\115\104\032\102\108\111\119\101\114\115\044\032\097\032\116\097\108\108\032\115\116\101\109\032\111\102\032\102\101\114\110\108\105\107\101\032\108\101\097\118\101\115\044\032\097\110\100\032\097\032\112\117\110\103\101\110\116\032\111\100\111\114\046\010\084\104\101\032\112\108\097\110\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\032\111\102\032\069\117\114\097\115\105\097\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046\032\073\116\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\097\115\032\102\101\101\100\032\102\111\114\032\108\105\118\101\115\116\111\099\107\032\105\110\032\078\101\119\032\090\101\097\108\097\110\100\032\097\110\100\032\065\117\115\116\114\097\108\105\097\046\032\073\116\032\104\097\115\032\115\111\109\101\032\112\111\116\101\110\116\105\097\108\032\117\115\101\115\044\032\105\110\099\108\117\100\105\110\103\032\105\110\032\116\114\097\100\105\116\105\111\110\097\108\032\109\101\100\105\099\105\110\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -675,7 +808,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\053\053\053\053\057\050", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\068\105\103\105\116\097\108\105\115\095\112\117\114\112\117\114\101\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\048\084\049\056\058\048\050\058\049\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\068\105\103\105\116\097\108\105\115\032\112\117\114\112\117\114\101\097\044\032\116\104\101\032\102\111\120\103\108\111\118\101\032\111\114\032\099\111\109\109\111\110\032\102\111\120\103\108\111\118\101\044\032\105\115\032\097\032\116\111\120\105\099\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\112\108\097\110\116\097\105\110\032\102\097\109\105\108\121\032\080\108\097\110\116\097\103\105\110\097\099\101\097\101\044\032\110\097\116\105\118\101\032\116\111\032\097\110\100\032\119\105\100\101\115\112\114\101\097\100\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\116\101\109\112\101\114\097\116\101\032\069\117\114\111\112\101\046\032\073\116\032\104\097\115\032\097\108\115\111\032\110\097\116\117\114\097\108\105\122\101\100\032\105\110\032\112\097\114\116\115\032\111\102\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\097\115\032\119\101\108\108\032\097\115\032\115\111\109\101\032\111\116\104\101\114\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\046\032\084\104\101\032\112\108\097\110\116\032\105\115\032\097\032\112\111\112\117\108\097\114\032\103\097\114\100\101\110\032\115\117\098\106\101\099\116\044\032\119\105\116\104\032\109\097\110\121\032\099\117\108\116\105\118\097\114\115\032\097\118\097\105\108\097\098\108\101\046\032\073\116\032\105\115\032\116\104\101\032\111\114\105\103\105\110\097\108\032\115\111\117\114\099\101\032\111\102\032\116\104\101\032\104\101\097\114\116\032\109\101\100\105\099\105\110\101\032\100\105\103\111\120\105\110\032\040\097\108\115\111\032\099\097\108\108\101\100\032\100\105\103\105\116\097\108\105\115\032\111\114\032\100\105\103\105\116\097\108\105\110\041\046\032\084\104\105\115\032\098\105\101\110\110\105\097\108\032\112\108\097\110\116\032\103\114\111\119\115\032\097\115\032\097\032\114\111\115\101\116\116\101\032\111\102\032\108\101\097\118\101\115\032\105\110\032\116\104\101\032\102\105\114\115\116\032\121\101\097\114\032\097\102\116\101\114\032\115\111\119\105\110\103\044\032\098\101\102\111\114\101\032\102\108\111\119\101\114\105\110\103\032\097\110\100\032\116\104\101\110\032\100\121\105\110\103\032\105\110\032\116\104\101\032\115\101\099\111\110\100\032\121\101\097\114\032\040\105\046\101\046\044\032\105\116\032\105\115\032\109\111\110\111\099\097\114\112\105\099\041\046\032\073\116\032\103\101\110\101\114\097\108\108\121\032\112\114\111\100\117\099\101\115\032\101\110\111\117\103\104\032\115\101\101\100\115\032\115\111\032\116\104\097\116\032\110\101\119\032\112\108\097\110\116\115\032\119\105\108\108\032\099\111\110\116\105\110\117\101\032\116\111\032\103\114\111\119\032\105\110\032\097\032\103\097\114\100\101\110\032\115\101\116\116\105\110\103\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -694,7 +827,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\052\057\049\053\052\055\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\070\105\108\105\112\101\110\100\117\108\097\095\117\108\109\097\114\105\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\051\045\050\051\084\048\055\058\050\052\058\051\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\070\105\108\105\112\101\110\100\117\108\097\032\117\108\109\097\114\105\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\109\101\097\100\111\119\115\119\101\101\116\032\111\114\032\109\101\097\100\032\119\111\114\116\044\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\104\101\114\098\097\099\101\111\117\115\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\082\111\115\097\099\101\097\101\032\116\104\097\116\032\103\114\111\119\115\032\105\110\032\100\097\109\112\032\109\101\097\100\111\119\115\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\032\097\110\100\032\087\101\115\116\101\114\110\032\065\115\105\097\032\040\078\101\097\114\032\069\097\115\116\032\097\110\100\032\077\105\100\100\108\101\032\069\097\115\116\041\046\032\073\116\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\097\110\100\032\110\097\116\117\114\097\108\105\115\101\100\032\105\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046\010\077\101\097\100\111\119\115\119\101\101\116\032\104\097\115\032\097\108\115\111\032\098\101\101\110\032\114\101\102\101\114\114\101\100\032\116\111\032\097\115\032\113\117\101\101\110\032\111\102\032\116\104\101\032\109\101\097\100\111\119\044\032\112\114\105\100\101\032\111\102\032\116\104\101\032\109\101\097\100\111\119\044\032\109\101\097\100\111\119\045\119\111\114\116\044\032\109\101\097\100\111\119\032\113\117\101\101\110\044\032\108\097\100\121\032\111\102\032\116\104\101\032\109\101\097\100\111\119\044\032\100\111\108\108\111\102\044\032\109\101\097\100\115\119\101\101\116\044\032\097\110\100\032\098\114\105\100\101\119\111\114\116\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -713,7 +846,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\053\048\051\054\048\048\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\108\108\105\117\109\095\117\114\115\105\110\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\049\053\084\049\051\058\053\057\058\050\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\065\108\108\105\117\109\032\117\114\115\105\110\117\109\044\032\107\110\111\119\110\032\097\115\032\119\105\108\100\032\103\097\114\108\105\099\044\032\114\097\109\115\111\110\115\044\032\099\111\119\108\101\101\107\101\115\044\032\099\111\119\115\039\115\032\108\101\101\107\044\032\099\111\119\108\101\101\107\044\032\098\117\099\107\114\097\109\115\044\032\098\114\111\097\100\045\108\101\097\118\101\100\032\103\097\114\108\105\099\044\032\119\111\111\100\032\103\097\114\108\105\099\044\032\098\101\097\114\032\108\101\101\107\044\032\069\117\114\097\115\105\097\110\032\119\105\108\100\032\103\097\114\108\105\099\044\032\111\110\105\111\110\032\103\114\097\115\115\044\032\111\114\032\098\101\097\114\039\115\032\103\097\114\108\105\099\044\032\105\115\032\097\032\098\117\108\098\111\117\115\032\112\101\114\101\110\110\105\097\108\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\097\109\097\114\121\108\108\105\115\032\102\097\109\105\108\121\044\032\065\109\097\114\121\108\108\105\100\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\044\032\119\104\101\114\101\032\105\116\032\103\114\111\119\115\032\105\110\032\109\111\105\115\116\032\119\111\111\100\108\097\110\100\046\032\073\116\032\105\115\032\097\032\119\105\108\100\032\114\101\108\097\116\105\118\101\032\111\102\032\111\110\105\111\110\032\097\110\100\032\103\097\114\108\105\099\044\032\097\108\108\032\098\101\108\111\110\103\105\110\103\032\116\111\032\116\104\101\032\115\097\109\101\032\103\101\110\117\115\044\032\065\108\108\105\117\109\046\032\084\104\101\114\101\032\097\114\101\032\116\119\111\032\114\101\099\111\103\110\105\122\101\100\032\115\117\098\115\112\101\099\105\101\115\058\032\065\046\032\117\114\115\105\110\117\109\032\115\117\098\115\112\046\032\117\114\115\105\110\117\109\032\097\110\100\032\065\046\032\117\114\115\105\110\117\109\032\115\117\098\115\112\046\032\117\099\114\097\105\110\105\099\117\109\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -732,7 +865,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\051\053\051\050\050\049\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\114\105\109\117\108\097\095\118\101\114\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\049\045\048\051\084\048\053\058\049\049\058\048\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\080\114\105\109\117\108\097\032\118\101\114\105\115\044\032\116\104\101\032\099\111\119\115\108\105\112\044\032\099\111\109\109\111\110\032\099\111\119\115\108\105\112\044\032\111\114\032\099\111\119\115\108\105\112\032\112\114\105\109\114\111\115\101\032\040\115\121\110\046\032\080\114\105\109\117\108\097\032\111\102\102\105\099\105\110\097\108\105\115\032\072\105\108\108\041\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\112\114\105\109\114\111\115\101\032\102\097\109\105\108\121\032\080\114\105\109\117\108\097\099\101\097\101\046\032\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\110\097\116\105\118\101\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\116\101\109\112\101\114\097\116\101\032\069\117\114\111\112\101\032\097\110\100\032\119\101\115\116\101\114\110\032\065\115\105\097\044\032\097\110\100\032\097\108\116\104\111\117\103\104\032\097\098\115\101\110\116\032\102\114\111\109\032\109\111\114\101\032\110\111\114\116\104\101\114\108\121\032\097\114\101\097\115\032\105\110\099\108\117\100\105\110\103\032\109\117\099\104\032\111\102\032\110\111\114\116\104\119\101\115\116\032\083\099\111\116\108\097\110\100\044\032\105\116\032\114\101\097\112\112\101\097\114\115\032\105\110\032\110\111\114\116\104\101\114\110\109\111\115\116\032\083\117\116\104\101\114\108\097\110\100\032\097\110\100\032\079\114\107\110\101\121\032\097\110\100\032\105\110\032\083\099\097\110\100\105\110\097\118\105\097\046\032\084\104\105\115\032\115\112\101\099\105\101\115\032\102\114\101\113\117\101\110\116\108\121\032\104\121\098\114\105\100\105\122\101\115\032\119\105\116\104\032\111\116\104\101\114\032\080\114\105\109\117\108\097\115\032\115\117\099\104\032\097\115\032\116\104\101\032\099\111\109\109\111\110\032\112\114\105\109\114\111\115\101\032\080\114\105\109\117\108\097\032\118\117\108\103\097\114\105\115\032\116\111\032\102\111\114\109\032\102\097\108\115\101\032\111\120\108\105\112\032\040\080\114\105\109\117\108\097\032\195\151\032\112\111\108\121\097\110\116\104\097\041\032\119\104\105\099\104\032\105\115\032\111\102\116\101\110\032\099\111\110\102\117\115\101\100\032\119\105\116\104\032\116\114\117\101\032\111\120\108\105\112\032\040\080\114\105\109\117\108\097\032\101\108\097\116\105\111\114\041\044\032\097\032\109\117\099\104\032\114\097\114\101\114\032\112\108\097\110\116\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -751,7 +884,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\054\052\051\049\051\051\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\110\101\109\111\110\111\105\100\101\115\095\110\101\109\111\114\111\115\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\055\045\050\056\084\048\048\058\051\053\058\052\049\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\048\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", text = "\065\110\101\109\111\110\111\105\100\101\115\032\110\101\109\111\114\111\115\097\032\040\115\121\110\046\032\065\110\101\109\111\110\101\032\110\101\109\111\114\111\115\097\041\044\032\116\104\101\032\119\111\111\100\032\097\110\101\109\111\110\101\044\032\097\108\115\111\032\104\105\115\116\111\114\105\099\097\108\108\121\032\107\110\111\119\110\032\097\115\032\119\105\110\100\102\108\111\119\101\114\032\098\101\099\097\117\115\101\032\111\102\032\087\105\108\108\105\097\109\032\084\117\114\110\101\114\039\115\032\105\110\116\101\114\112\114\101\116\097\116\105\111\110\032\111\102\032\105\116\115\032\076\097\116\105\110\032\110\097\109\101\032\097\110\101\109\111\110\101\032\105\110\032\104\105\115\032\049\053\053\049\032\078\101\119\032\072\101\114\098\097\108\108\044\032\105\115\032\097\110\032\101\097\114\108\121\032\115\112\114\105\110\103\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\098\117\116\116\101\114\099\117\112\032\102\097\109\105\108\121\032\082\097\110\117\110\099\117\108\097\099\101\097\101\044\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\046\032\073\116\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\104\101\114\098\097\099\101\111\117\115\032\112\108\097\110\116\032\103\114\111\119\105\110\103\032\053\226\128\147\049\053\032\099\109\032\040\050\226\128\147\054\032\105\110\041\032\116\097\108\108\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -761,6 +894,234 @@ return { license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", }, + ["\100\097\110\100\101\108\105\111\110"] = { + latin = "\084\097\114\097\120\097\099\117\109\032\111\102\102\105\099\105\110\097\108\101", + title = "\084\097\114\097\120\097\099\117\109\032\111\102\102\105\099\105\110\097\108\101", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\084\097\114\097\120\097\099\117\109\095\111\102\102\105\099\105\110\097\108\101", + pageid = 10783290, + revision = 1371025491, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\049\048\050\053\052\057\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\084\097\114\097\120\097\099\117\109\095\111\102\102\105\099\105\110\097\108\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\056\045\050\052\084\048\052\058\053\050\058\048\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", + text = "\084\097\114\097\120\097\099\117\109\032\111\102\102\105\099\105\110\097\108\101\044\032\116\104\101\032\100\097\110\100\101\108\105\111\110\032\111\114\032\099\111\109\109\111\110\032\100\097\110\100\101\108\105\111\110\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\100\097\105\115\121\032\102\097\109\105\108\121\044\032\065\115\116\101\114\097\099\101\097\101\046\032\084\104\101\032\099\111\109\109\111\110\032\100\097\110\100\101\108\105\111\110\032\105\115\032\119\101\108\108\045\107\110\111\119\110\032\102\111\114\032\105\116\115\032\121\101\108\108\111\119\032\102\108\111\119\101\114\032\104\101\097\100\115\032\116\104\097\116\032\116\117\114\110\032\105\110\116\111\032\114\111\117\110\100\032\098\097\108\108\115\032\111\102\032\109\097\110\121\032\115\105\108\118\101\114\045\116\117\102\116\101\100\032\102\114\117\105\116\044\032\119\104\105\099\104\032\100\105\115\112\101\114\115\101\032\105\110\032\116\104\101\032\119\105\110\100\046\032\084\104\101\115\101\032\098\097\108\108\115\032\097\114\101\032\115\111\109\101\116\105\109\101\115\032\099\097\108\108\101\100\032\034\099\108\111\099\107\115\034\032\111\114\032\034\098\108\111\119\098\097\108\108\115\034\046\010\079\114\105\103\105\110\097\108\108\121\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\044\032\097\115\032\097\032\114\101\115\117\108\116\032\111\102\032\105\116\115\032\104\097\114\100\105\110\101\115\115\032\097\110\100\032\101\097\115\121\032\112\114\111\112\097\103\097\116\105\111\110\044\032\116\104\101\032\100\097\110\100\101\108\105\111\110\032\104\097\115\032\098\101\099\111\109\101\032\119\105\100\101\108\121\032\101\115\116\097\098\108\105\115\104\101\100\032\097\099\114\111\115\115\032\115\101\118\101\114\097\108\032\099\111\110\116\105\110\101\110\116\115\046\032\073\116\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\116\111\032\115\111\117\116\104\101\114\110\032\065\102\114\105\099\097\044\032\116\104\101\032\065\109\101\114\105\099\097\115\044\032\065\117\115\116\114\097\108\105\097\044\032\097\110\100\032\078\101\119\032\090\101\097\108\097\110\100\046\032\073\116\032\103\114\111\119\115\032\105\110\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\032\111\102\032\116\104\101\032\119\111\114\108\100\032\105\110\032\097\114\101\097\115\032\119\105\116\104\032\109\111\105\115\116\032\115\111\105\108\115\046\032\084\104\101\121\032\097\114\101\032\097\098\108\101\032\116\111\032\103\114\111\119\032\105\110\032\097\032\118\097\114\105\101\116\121\032\111\102\032\101\110\118\105\114\111\110\109\101\110\116\115\032\097\110\100\032\097\114\101\032\116\111\108\101\114\097\110\116\032\111\102\032\099\114\111\119\100\105\110\103\044\032\101\120\116\114\101\109\101\032\116\101\109\112\101\114\097\116\117\114\101\115\044\032\097\110\100\032\108\111\119\032\109\111\105\115\116\117\114\101\046\010\084\104\101\032\100\097\110\100\101\108\105\111\110\032\105\115\032\111\102\116\101\110\032\099\111\110\115\105\100\101\114\101\100\032\097\032\119\101\101\100\044\032\101\115\112\101\099\105\097\108\108\121\032\105\110\032\108\097\119\110\115\044\032\098\117\116\032\105\116\032\105\115\032\105\110\099\114\101\097\115\105\110\103\108\121\032\098\101\105\110\103\032\114\101\099\111\103\110\105\115\101\100\032\105\110\032\105\116\115\032\110\097\116\105\118\101\032\114\101\103\105\111\110\115\032\097\115\032\117\115\101\102\117\108\032\102\111\114\032\097\116\116\114\097\099\116\105\110\103\032\098\105\114\100\115\032\097\110\100\032\112\111\108\108\105\110\097\116\105\110\103\032\105\110\115\101\099\116\115\046\032\073\110\032\111\110\101\032\115\116\117\100\121\044\032\105\116\032\114\097\110\107\101\100\032\097\115\032\116\104\101\032\102\111\117\114\116\104\032\109\111\115\116\032\105\109\112\111\114\116\097\110\116\032\115\111\117\114\099\101\032\111\102\032\112\111\108\108\101\110\046\032\065\108\116\104\111\117\103\104\032\100\097\110\100\101\108\105\111\110\032\112\111\108\108\101\110\032\105\115\032\099\111\110\115\105\100\101\114\101\100\032\116\111\032\098\101\032\097\110\032\101\097\114\108\121\032\102\111\111\100\032\114\101\115\111\117\114\099\101\032\102\111\114\032\115\111\109\101\032\101\109\101\114\103\105\110\103\032\112\111\108\108\105\110\097\116\111\114\115\032\105\110\032\115\112\114\105\110\103\044\032\105\116\032\104\097\115\032\097\108\115\111\032\098\101\101\110\032\102\111\117\110\100\032\116\111\032\108\097\099\107\032\099\101\114\116\097\105\110\032\101\115\115\101\110\116\105\097\108\032\110\117\116\114\105\101\110\116\115\032\097\110\100\032\099\097\110\032\108\101\097\100\032\116\111\032\100\101\099\108\105\110\105\110\103\032\098\114\111\111\100\032\104\101\097\108\116\104\032\102\111\114\032\098\101\101\115\046\010\084\104\101\032\108\101\097\118\101\115\044\032\102\108\111\119\101\114\115\044\032\097\110\100\032\114\111\111\116\115\032\097\114\101\032\115\111\109\101\116\105\109\101\115\032\117\115\101\100\032\097\115\032\102\111\111\100\032\097\110\100\032\105\110\032\104\101\114\098\097\108\032\109\101\100\105\099\105\110\101\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\084\097\114\097\120\097\099\117\109\032\111\102\102\105\099\105\110\097\108\101\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\100\097\105\115\121"] = { + latin = "\066\101\108\108\105\115\032\112\101\114\101\110\110\105\115", + title = "\066\101\108\108\105\115\032\112\101\114\101\110\110\105\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\066\101\108\108\105\115\095\112\101\114\101\110\110\105\115", + pageid = 1157853, + revision = 1375045201, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\053\048\052\053\050\048\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\066\101\108\108\105\115\095\112\101\114\101\110\110\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\057\045\049\053\084\049\052\058\053\051\058\051\056\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", + text = "\066\101\108\108\105\115\032\112\101\114\101\110\110\105\115\032\040\032\066\069\076\045\105\115\115\032\112\201\153\045\082\069\078\045\105\115\115\041\044\032\116\104\101\032\100\097\105\115\121\044\032\105\115\032\097\032\069\117\114\111\112\101\097\110\032\115\112\101\099\105\101\115\032\111\102\032\116\104\101\032\102\097\109\105\108\121\032\065\115\116\101\114\097\099\101\097\101\044\032\111\102\116\101\110\032\099\111\110\115\105\100\101\114\101\100\032\116\104\101\032\097\114\099\104\101\116\121\112\097\108\032\115\112\101\099\105\101\115\032\111\102\032\116\104\101\032\110\097\109\101\032\100\097\105\115\121\046\032\084\111\032\100\105\115\116\105\110\103\117\105\115\104\032\116\104\105\115\032\115\112\101\099\105\101\115\032\102\114\111\109\032\111\116\104\101\114\032\112\108\097\110\116\115\032\107\110\111\119\110\032\097\115\032\100\097\105\115\105\101\115\044\032\105\116\032\105\115\032\115\111\109\101\116\105\109\101\115\032\113\117\097\108\105\102\105\101\100\032\111\114\032\107\110\111\119\110\032\097\115\032\099\111\109\109\111\110\032\100\097\105\115\121\044\032\108\097\119\110\032\100\097\105\115\121\032\111\114\032\069\110\103\108\105\115\104\032\100\097\105\115\121\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\066\101\108\108\105\115\032\112\101\114\101\110\110\105\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\114\101\100\095\099\108\111\118\101\114"] = { + latin = "\084\114\105\102\111\108\105\117\109\032\112\114\097\116\101\110\115\101", + title = "\084\114\105\102\111\108\105\117\109\032\112\114\097\116\101\110\115\101", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\084\114\105\102\111\108\105\117\109\095\112\114\097\116\101\110\115\101", + pageid = 1900302, + revision = 1371055360, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\049\048\053\053\051\054\048", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\084\114\105\102\111\108\105\117\109\095\112\114\097\116\101\110\115\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\056\045\050\052\084\048\057\058\050\054\058\050\053\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\057\054\090", + text = "\084\114\105\102\111\108\105\117\109\032\112\114\097\116\101\110\115\101\032\040\102\114\111\109\032\076\097\116\105\110\032\112\114\196\129\116\117\109\044\032\109\101\097\110\105\110\103\032\109\101\097\100\111\119\041\044\032\114\101\100\032\099\108\111\118\101\114\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\098\101\097\110\032\102\097\109\105\108\121\044\032\070\097\098\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\116\104\101\032\079\108\100\032\087\111\114\108\100\044\032\098\117\116\032\112\108\097\110\116\101\100\032\097\110\100\032\110\097\116\117\114\097\108\105\115\101\100\032\105\110\032\109\097\110\121\032\111\116\104\101\114\032\114\101\103\105\111\110\115\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\084\114\105\102\111\108\105\117\109\032\112\114\097\116\101\110\115\101\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\098\105\114\100\115\102\111\111\116\095\116\114\101\102\111\105\108"] = { + latin = "\076\111\116\117\115\032\099\111\114\110\105\099\117\108\097\116\117\115", + title = "\076\111\116\117\115\032\099\111\114\110\105\099\117\108\097\116\117\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\076\111\116\117\115\095\099\111\114\110\105\099\117\108\097\116\117\115", + pageid = 1117322, + revision = 1379332042, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\051\051\050\048\052\050", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\111\116\117\115\095\099\111\114\110\105\099\117\108\097\116\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\048\057\084\048\056\058\051\051\058\050\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\076\111\116\117\115\032\099\111\114\110\105\099\117\108\097\116\117\115\032\105\115\032\097\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\112\101\097\032\102\097\109\105\108\121\032\070\097\098\097\099\101\097\101\046\032\067\111\109\109\111\110\032\110\097\109\101\115\032\105\110\099\108\117\100\101\032\099\111\109\109\111\110\032\098\105\114\100\039\115\045\102\111\111\116\032\116\114\101\102\111\105\108\044\032\101\103\103\115\032\097\110\100\032\098\097\099\111\110\044\032\098\105\114\100\115\102\111\111\116\032\100\101\101\114\118\101\116\099\104\044\032\097\110\100\032\106\117\115\116\032\098\105\114\100\039\115\045\102\111\111\116\032\116\114\101\102\111\105\108\032\040\097\032\110\097\109\101\032\097\108\115\111\032\111\102\116\101\110\032\097\112\112\108\105\101\100\032\116\111\032\111\116\104\101\114\032\076\111\116\117\115\032\115\112\112\046\041\046\032\073\116\032\104\097\115\032\097\032\119\105\100\101\032\100\105\115\116\114\105\098\117\116\105\111\110\032\097\110\100\032\105\115\032\097\032\102\097\118\111\114\101\100\032\102\111\114\097\103\101\032\102\111\114\032\108\105\118\101\115\116\111\099\107\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\076\111\116\117\115\032\099\111\114\110\105\099\117\108\097\116\117\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\114\105\098\119\111\114\116\095\112\108\097\110\116\097\105\110"] = { + latin = "\080\108\097\110\116\097\103\111\032\108\097\110\099\101\111\108\097\116\097", + title = "\080\108\097\110\116\097\103\111\032\108\097\110\099\101\111\108\097\116\097", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\080\108\097\110\116\097\103\111\095\108\097\110\099\101\111\108\097\116\097", + pageid = 1250244, + revision = 1377190915, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\055\049\057\048\057\049\053", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\108\097\110\116\097\103\111\095\108\097\110\099\101\111\108\097\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\057\045\050\056\084\048\054\058\050\048\058\052\051\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\080\108\097\110\116\097\103\111\032\108\097\110\099\101\111\108\097\116\097\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\112\108\097\110\116\097\105\110\032\102\097\109\105\108\121\032\080\108\097\110\116\097\103\105\110\097\099\101\097\101\046\032\073\116\032\105\115\032\107\110\111\119\110\032\098\121\032\116\104\101\032\099\111\109\109\111\110\032\110\097\109\101\115\032\114\105\098\119\111\114\116\032\112\108\097\110\116\097\105\110\044\032\110\097\114\114\111\119\108\101\097\102\032\112\108\097\110\116\097\105\110\044\032\069\110\103\108\105\115\104\032\112\108\097\110\116\097\105\110\044\032\114\105\098\108\101\097\102\044\032\108\097\109\098\039\115\032\116\111\110\103\117\101\044\032\097\110\100\032\098\117\099\107\104\111\114\110\046\032\073\116\032\105\115\032\097\032\099\111\109\109\111\110\032\119\101\101\100\032\111\110\032\099\117\108\116\105\118\097\116\101\100\032\111\114\032\100\105\115\116\117\114\098\101\100\032\108\097\110\100\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\080\108\097\110\116\097\103\111\032\108\097\110\099\101\111\108\097\116\097\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\115\101\108\102\104\101\097\108"] = { + latin = "\080\114\117\110\101\108\108\097\032\118\117\108\103\097\114\105\115", + title = "\080\114\117\110\101\108\108\097\032\118\117\108\103\097\114\105\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\080\114\117\110\101\108\108\097\095\118\117\108\103\097\114\105\115", + pageid = 12058348, + revision = 1380124722, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\049\050\052\055\050\050", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\114\117\110\101\108\108\097\095\118\117\108\103\097\114\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\049\051\084\050\051\058\048\049\058\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\080\114\117\110\101\108\108\097\032\118\117\108\103\097\114\105\115\044\032\116\104\101\032\099\111\109\109\111\110\032\115\101\108\102\045\104\101\097\108\044\032\104\101\097\108\045\097\108\108\044\032\119\111\117\110\100\119\111\114\116\044\032\104\101\097\114\116\045\111\102\045\116\104\101\045\101\097\114\116\104\044\032\099\097\114\112\101\110\116\101\114\039\115\032\104\101\114\098\044\032\098\114\111\119\110\119\111\114\116\032\111\114\032\098\108\117\101\032\099\117\114\108\115\044\032\105\115\032\097\110\032\104\101\114\098\097\099\101\111\117\115\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\109\105\110\116\032\102\097\109\105\108\121\032\040\076\097\109\105\097\099\101\097\101\041\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\080\114\117\110\101\108\108\097\032\118\117\108\103\097\114\105\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\107\110\097\112\119\101\101\100"] = { + latin = "\067\101\110\116\097\117\114\101\097\032\110\105\103\114\097", + title = "\067\101\110\116\097\117\114\101\097\032\110\105\103\114\097", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\067\101\110\116\097\117\114\101\097\095\110\105\103\114\097", + pageid = 21631605, + revision = 1366977479, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\054\057\055\055\052\055\057", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\101\110\116\097\117\114\101\097\095\110\105\103\114\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\055\045\051\049\084\048\053\058\048\053\058\049\052\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\067\101\110\116\097\117\114\101\097\032\110\105\103\114\097\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\065\115\116\101\114\097\099\101\097\101\044\032\097\110\100\032\105\115\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\108\101\115\115\101\114\032\107\110\097\112\119\101\101\100\044\032\099\111\109\109\111\110\032\107\110\097\112\119\101\101\100\044\032\098\108\097\099\107\032\107\110\097\112\119\101\101\100\032\097\110\100\032\098\101\108\108\032\119\101\101\100\046\010\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\099\101\110\116\114\097\108\032\069\117\114\111\112\101\044\032\097\110\100\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\032\116\111\032\116\101\109\112\101\114\097\116\101\032\078\111\114\116\104\032\065\109\101\114\105\099\097\032\097\110\100\032\065\117\115\116\114\097\108\097\115\105\097\044\032\119\104\101\114\101\032\105\116\032\105\115\032\117\115\117\097\108\108\121\032\114\101\103\097\114\100\101\100\032\097\115\032\097\032\110\111\120\105\111\117\115\032\119\101\101\100\032\100\117\101\032\116\111\032\105\116\115\032\102\097\115\116\032\115\112\114\101\097\100\044\032\104\097\114\100\105\110\101\115\115\032\097\110\100\032\099\111\109\112\101\116\105\116\105\118\101\032\112\111\116\101\110\116\105\097\108\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\067\101\110\116\097\117\114\101\097\032\110\105\103\114\097\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\111\120\101\121\101\095\100\097\105\115\121"] = { + latin = "\076\101\117\099\097\110\116\104\101\109\117\109\032\118\117\108\103\097\114\101", + title = "\076\101\117\099\097\110\116\104\101\109\117\109\032\118\117\108\103\097\114\101", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\076\101\117\099\097\110\116\104\101\109\117\109\095\118\117\108\103\097\114\101", + pageid = 567709, + revision = 1381123379, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\049\049\050\051\051\055\057", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\101\117\099\097\110\116\104\101\109\117\109\095\118\117\108\103\097\114\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\049\048\045\050\048\084\048\051\058\053\057\058\048\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\076\101\117\099\097\110\116\104\101\109\117\109\032\118\117\108\103\097\114\101\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\111\120\045\101\121\101\032\100\097\105\115\121\044\032\111\120\101\121\101\032\100\097\105\115\121\044\032\100\111\103\032\100\097\105\115\121\044\032\109\097\114\103\117\101\114\105\116\101\032\040\070\114\101\110\099\104\058\032\077\097\114\103\117\101\114\105\116\101\032\099\111\109\109\117\110\101\044\032\034\099\111\109\109\111\110\032\109\097\114\103\117\101\114\105\116\101\034\041\032\097\110\100\032\111\116\104\101\114\032\099\111\109\109\111\110\032\110\097\109\101\115\044\032\105\115\032\097\032\119\105\100\101\115\112\114\101\097\100\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\032\097\110\100\032\116\104\101\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\032\111\102\032\065\115\105\097\044\032\097\110\100\032\097\110\032\105\110\116\114\111\100\117\099\101\100\032\112\108\097\110\116\032\116\111\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\065\117\115\116\114\097\108\105\097\032\097\110\100\032\078\101\119\032\090\101\097\108\097\110\100\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\076\101\117\099\097\110\116\104\101\109\117\109\032\118\117\108\103\097\114\101\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\102\105\101\108\100\095\115\099\097\098\105\111\117\115"] = { + latin = "\075\110\097\117\116\105\097\032\097\114\118\101\110\115\105\115", + title = "\075\110\097\117\116\105\097\032\097\114\118\101\110\115\105\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\075\110\097\117\116\105\097\095\097\114\118\101\110\115\105\115", + pageid = 12057909, + revision = 1371147715, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\049\049\052\055\055\049\053", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\075\110\097\117\116\105\097\095\097\114\118\101\110\115\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\056\045\050\052\084\050\049\058\052\050\058\049\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\075\110\097\117\116\105\097\032\097\114\118\101\110\115\105\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\102\105\101\108\100\032\115\099\097\098\105\111\117\115\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\104\111\110\101\121\115\117\099\107\108\101\032\102\097\109\105\108\121\032\067\097\112\114\105\102\111\108\105\097\099\101\097\101\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\075\110\097\117\116\105\097\032\097\114\118\101\110\115\105\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\116\101\097\115\101\108"] = { + latin = "\068\105\112\115\097\099\117\115\032\102\117\108\108\111\110\117\109", + title = "\068\105\112\115\097\099\117\115\032\102\117\108\108\111\110\117\109", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\068\105\112\115\097\099\117\115\095\102\117\108\108\111\110\117\109", + pageid = 4298215, + revision = 1375022541, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\053\048\050\050\053\052\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\068\105\112\115\097\099\117\115\095\102\117\108\108\111\110\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\057\045\049\053\084\049\050\058\051\048\058\052\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\068\105\112\115\097\099\117\115\032\102\117\108\108\111\110\117\109\044\032\115\121\110\046\032\068\105\112\115\097\099\117\115\032\115\121\108\118\101\115\116\114\105\115\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\107\110\111\119\110\032\098\121\032\116\104\101\032\099\111\109\109\111\110\032\110\097\109\101\115\032\119\105\108\100\032\116\101\097\115\101\108\032\111\114\032\102\117\108\108\101\114\039\115\032\116\101\097\115\101\108\044\032\097\108\116\104\111\117\103\104\032\116\104\101\032\108\097\116\116\101\114\032\110\097\109\101\032\105\115\032\117\115\117\097\108\108\121\032\097\112\112\108\105\101\100\032\116\111\032\116\104\101\032\099\117\108\116\105\118\097\116\101\100\032\118\097\114\105\101\116\121\032\068\046\032\102\117\108\108\111\110\117\109\032\118\097\114\046\032\115\097\116\105\118\117\115\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\032\097\110\100\032\078\111\114\116\104\032\065\102\114\105\099\097\044\032\098\117\116\032\105\116\032\105\115\032\107\110\111\119\110\032\105\110\032\116\104\101\032\065\109\101\114\105\099\097\115\044\032\115\111\117\116\104\101\114\110\032\065\102\114\105\099\097\044\032\065\117\115\116\114\097\108\105\097\032\097\110\100\032\078\101\119\032\090\101\097\108\097\110\100\032\097\115\032\097\110\032\105\110\116\114\111\100\117\099\101\100\032\115\112\101\099\105\101\115\046\010\073\116\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\098\105\101\110\110\105\097\108\032\112\108\097\110\116\032\040\114\097\114\101\108\121\032\097\032\115\104\111\114\116\045\108\105\118\101\100\032\112\101\114\101\110\110\105\097\108\032\112\108\097\110\116\041\032\103\114\111\119\105\110\103\032\116\111\032\049\226\128\147\050\046\053\032\109\101\116\114\101\115\032\040\051\046\051\226\128\147\056\046\050\032\102\116\041\032\116\097\108\108\046\032\084\104\101\032\105\110\102\108\111\114\101\115\099\101\110\099\101\032\105\115\032\097\032\099\121\108\105\110\100\114\105\099\097\108\032\097\114\114\097\121\032\111\102\032\108\097\118\101\110\100\101\114\032\102\108\111\119\101\114\115\032\119\104\105\099\104\032\100\114\105\101\115\032\116\111\032\097\032\099\111\110\101\032\111\102\032\115\112\105\110\101\045\116\105\112\112\101\100\032\104\097\114\100\032\098\114\097\099\116\115\046\032\073\116\032\109\097\121\032\098\101\032\049\048\032\099\101\110\116\105\109\101\116\114\101\115\032\040\051\046\057\032\105\110\041\032\108\111\110\103\046\032\032\084\104\101\032\100\114\105\101\100\032\105\110\102\108\111\114\101\115\099\101\110\099\101\032\111\102\032\097\032\099\117\108\116\105\118\097\114\032\119\097\115\032\104\105\115\116\111\114\105\099\097\108\108\121\032\117\115\101\100\032\105\110\032\116\101\120\116\105\108\101\032\109\097\110\117\102\097\099\116\117\114\105\110\103\032\097\115\032\097\032\116\111\111\108\032\102\111\114\032\102\117\108\108\105\110\103\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\068\105\112\115\097\099\117\115\032\102\117\108\108\111\110\117\109\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\099\111\119\095\112\097\114\115\108\101\121"] = { + latin = "\065\110\116\104\114\105\115\099\117\115\032\115\121\108\118\101\115\116\114\105\115", + title = "\065\110\116\104\114\105\115\099\117\115\032\115\121\108\118\101\115\116\114\105\115", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\065\110\116\104\114\105\115\099\117\115\095\115\121\108\118\101\115\116\114\105\115", + pageid = 2649495, + revision = 1375096601, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\053\048\057\054\054\048\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\110\116\104\114\105\115\099\117\115\095\115\121\108\118\101\115\116\114\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\057\045\049\053\084\050\049\058\050\050\058\048\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\065\110\116\104\114\105\115\099\117\115\032\115\121\108\118\101\115\116\114\105\115\044\032\107\110\111\119\110\032\097\115\032\099\111\119\032\112\097\114\115\108\101\121\044\032\119\105\108\100\032\099\104\101\114\118\105\108\044\032\081\117\101\101\110\032\065\110\110\101\039\115\032\108\097\099\101\032\111\114\032\107\101\099\107\044\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\098\105\101\110\110\105\097\108\032\111\114\032\115\104\111\114\116\045\108\105\118\101\100\032\112\101\114\101\110\110\105\097\108\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\065\112\105\097\099\101\097\101\032\040\085\109\098\101\108\108\105\102\101\114\097\101\041\046\032\073\116\032\105\115\032\097\108\115\111\032\115\111\109\101\116\105\109\101\115\032\099\097\108\108\101\100\032\109\111\116\104\101\114\045\100\105\101\032\040\101\115\112\101\099\105\097\108\108\121\032\105\110\032\116\104\101\032\085\075\041\044\032\097\032\110\097\109\101\032\116\104\097\116\032\105\115\032\097\108\115\111\032\097\112\112\108\105\101\100\032\116\111\032\067\114\097\116\097\101\103\117\115\032\109\111\110\111\103\121\110\097\032\040\116\104\101\032\099\111\109\109\111\110\032\104\097\119\116\104\111\114\110\041\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\119\101\115\116\101\114\110\032\065\115\105\097\032\097\110\100\032\110\111\114\116\104\119\101\115\116\101\114\110\032\065\102\114\105\099\097\046\032\073\116\032\105\115\032\114\101\108\097\116\101\100\032\116\111\032\111\116\104\101\114\032\100\105\118\101\114\115\101\032\109\101\109\098\101\114\115\032\111\102\032\065\112\105\097\099\101\097\101\044\032\115\117\099\104\032\097\115\032\112\097\114\115\108\101\121\044\032\099\097\114\114\111\116\044\032\104\101\109\108\111\099\107\032\097\110\100\032\104\111\103\119\101\101\100\046\032\073\116\032\105\115\032\111\102\116\101\110\032\099\111\110\102\117\115\101\100\032\119\105\116\104\032\068\097\117\099\117\115\032\099\097\114\111\116\097\044\032\097\110\111\116\104\101\114\032\109\101\109\098\101\114\032\111\102\032\116\104\101\032\065\112\105\097\099\101\097\101\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\034\081\117\101\101\110\032\065\110\110\101\039\115\032\108\097\099\101\034\032\111\114\032\034\119\105\108\100\032\099\097\114\114\111\116\034\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\065\110\116\104\114\105\115\099\117\115\032\115\121\108\118\101\115\116\114\105\115\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, + ["\103\097\114\108\105\099\095\109\117\115\116\097\114\100"] = { + latin = "\065\108\108\105\097\114\105\097\032\112\101\116\105\111\108\097\116\097", + title = "\065\108\108\105\097\114\105\097\032\112\101\116\105\111\108\097\116\097", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\065\108\108\105\097\114\105\097\095\112\101\116\105\111\108\097\116\097", + pageid = 509457, + revision = 1375930171, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\053\057\051\048\049\055\049", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\108\108\105\097\114\105\097\095\112\101\116\105\111\108\097\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\054\045\048\057\045\050\048\084\050\051\058\051\050\058\051\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", + text = "\065\108\108\105\097\114\105\097\032\112\101\116\105\111\108\097\116\097\044\032\111\114\032\032\103\097\114\108\105\099\032\109\117\115\116\097\114\100\044\032\105\115\032\097\032\098\105\101\110\110\105\097\108\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\109\117\115\116\097\114\100\032\102\097\109\105\108\121\032\040\066\114\097\115\115\105\099\097\099\101\097\101\041\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\044\032\119\101\115\116\101\114\110\032\097\110\100\032\099\101\110\116\114\097\108\032\065\115\105\097\044\032\110\111\114\116\104\045\119\101\115\116\101\114\110\032\065\102\114\105\099\097\044\032\077\111\114\111\099\099\111\044\032\073\098\101\114\105\097\032\097\110\100\032\116\104\101\032\066\114\105\116\105\115\104\032\073\115\108\101\115\044\032\110\111\114\116\104\032\116\111\032\110\111\114\116\104\101\114\110\032\083\099\097\110\100\105\110\097\118\105\097\044\032\097\110\100\032\101\097\115\116\032\116\111\032\110\111\114\116\104\101\114\110\032\080\097\107\105\115\116\097\110\032\097\110\100\032\088\105\110\106\105\097\110\103\032\105\110\032\119\101\115\116\101\114\110\032\067\104\105\110\097\046\032\073\116\032\104\097\115\032\110\111\119\032\098\101\099\111\109\101\032\097\032\116\101\110\097\099\105\111\117\115\032\105\110\118\097\115\105\118\101\032\112\108\097\110\116\032\097\099\114\111\115\115\032\116\104\101\032\110\111\114\116\104\101\114\110\032\085\046\083\046\044\032\105\110\032\112\097\114\116\105\099\117\108\097\114\032\098\101\099\097\117\115\101\032\111\102\032\105\116\115\032\101\097\114\108\105\101\114\032\115\112\114\105\110\103\116\105\109\101\032\101\109\101\114\103\101\110\099\101\032\116\104\097\110\032\109\097\110\121\032\110\097\116\105\118\101\032\115\112\101\099\105\101\115\044\032\111\102\116\101\110\032\105\110\032\116\104\101\032\102\111\114\101\115\116\032\117\110\100\101\114\115\116\111\114\121\046\010\073\110\032\116\104\101\032\102\105\114\115\116\032\121\101\097\114\032\111\102\032\103\114\111\119\116\104\044\032\112\108\097\110\116\115\032\102\111\114\109\032\099\108\117\109\112\115\032\111\102\032\114\111\117\110\100\044\032\115\108\105\103\104\116\108\121\032\119\114\105\110\107\108\101\100\032\108\101\097\118\101\115\044\032\116\104\097\116\032\119\104\101\110\032\099\114\117\115\104\101\100\032\115\109\101\108\108\032\108\105\107\101\032\103\097\114\108\105\099\046\032\084\104\101\032\112\108\097\110\116\115\032\102\108\111\119\101\114\032\105\110\032\115\112\114\105\110\103\032\111\102\032\116\104\101\032\110\101\120\116\032\121\101\097\114\044\032\112\114\111\100\117\099\105\110\103\032\099\114\111\115\115\045\115\104\097\112\101\100\032\119\104\105\116\101\032\102\108\111\119\101\114\115\032\105\110\032\100\101\110\115\101\032\099\108\117\115\116\101\114\115\046\032\065\115\032\116\104\101\032\102\108\111\119\101\114\105\110\103\032\115\116\101\109\115\032\098\108\111\111\109\032\116\104\101\121\032\101\108\111\110\103\097\116\101\032\105\110\116\111\032\097\032\115\112\105\107\101\045\108\105\107\101\032\115\104\097\112\101\046\032\087\104\101\110\032\102\108\111\119\101\114\105\110\103\032\105\115\032\099\111\109\112\108\101\116\101\044\032\112\108\097\110\116\115\032\112\114\111\100\117\099\101\032\117\112\114\105\103\104\116\032\102\114\117\105\116\115\032\116\104\097\116\032\114\101\108\101\097\115\101\032\115\101\101\100\115\032\105\110\032\109\105\100\045\115\117\109\109\101\114\046\032\080\108\097\110\116\115\032\097\114\101\032\111\102\116\101\110\032\102\111\117\110\100\032\103\114\111\119\105\110\103\032\097\108\111\110\103\032\116\104\101\032\109\097\114\103\105\110\115\032\111\102\032\104\101\100\103\101\115\044\032\103\105\118\105\110\103\032\114\105\115\101\032\116\111\032\116\104\101\032\111\108\100\032\066\114\105\116\105\115\104\032\102\111\108\107\032\110\097\109\101\032\111\102\032\106\097\099\107\045\098\121\045\116\104\101\045\104\101\100\103\101\046\032\079\116\104\101\114\032\099\111\109\109\111\110\032\110\097\109\101\115\032\105\110\099\108\117\100\101\032\103\097\114\108\105\099\032\114\111\111\116\044\032\104\101\100\103\101\032\103\097\114\108\105\099\044\032\115\097\117\099\101\045\097\108\111\110\101\044\032\106\097\099\107\045\105\110\045\116\104\101\045\098\117\115\104\044\032\112\101\110\110\121\032\104\101\100\103\101\032\097\110\100\032\112\111\111\114\032\109\097\110\039\115\032\109\117\115\116\097\114\100\046\032\084\104\101\032\103\101\110\117\115\032\110\097\109\101\032\065\108\108\105\097\114\105\097\044\032\034\114\101\115\101\109\098\108\105\110\103\032\065\108\108\105\117\109\034\044\032\114\101\102\101\114\115\032\116\111\032\116\104\101\032\103\097\114\108\105\099\045\108\105\107\101\032\111\100\111\117\114\032\111\102\032\116\104\101\032\099\114\117\115\104\101\100\032\102\111\108\105\097\103\101\046\032\065\108\108\032\112\097\114\116\115\032\111\102\032\116\104\101\032\112\108\097\110\116\044\032\105\110\099\108\117\100\105\110\103\032\116\104\101\032\114\111\111\116\115\044\032\104\097\118\101\032\116\104\105\115\032\115\109\101\108\108\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\065\108\108\105\097\114\105\097\032\112\101\116\105\111\108\097\116\097\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, ["\098\114\097\099\107\101\110"] = { latin = "\080\116\101\114\105\100\105\117\109\032\097\113\117\105\108\105\110\117\109", title = "\080\116\101\114\105\100\105\117\109\032\097\113\117\105\108\105\110\117\109", @@ -770,7 +1131,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\054\049\053\051\050\053\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\116\101\114\105\100\105\117\109\095\097\113\117\105\108\105\110\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\050\084\049\049\058\050\054\058\051\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\050\055\052\090", text = "\080\116\101\114\105\100\105\117\109\032\097\113\117\105\108\105\110\117\109\044\032\099\111\109\109\111\110\108\121\032\099\097\108\108\101\100\032\098\114\097\099\107\101\110\044\032\098\114\097\107\101\044\032\112\097\115\116\117\114\101\032\098\114\097\107\101\044\032\099\111\109\109\111\110\032\098\114\097\099\107\101\110\044\032\097\110\100\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\101\097\103\108\101\032\102\101\114\110\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\101\114\110\032\111\099\099\117\114\114\105\110\103\032\105\110\032\116\101\109\112\101\114\097\116\101\032\097\110\100\032\115\117\098\116\114\111\112\105\099\097\108\032\114\101\103\105\111\110\115\032\105\110\032\098\111\116\104\032\104\101\109\105\115\112\104\101\114\101\115\046\032\079\114\105\103\105\110\097\108\108\121\032\110\097\116\105\118\101\032\116\111\032\069\117\114\097\115\105\097\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\116\104\101\032\101\120\116\114\101\109\101\032\108\105\103\104\116\110\101\115\115\032\111\102\032\105\116\115\032\115\112\111\114\101\115\032\104\097\115\032\108\101\100\032\116\111\032\105\116\032\097\099\104\105\101\118\105\110\103\032\097\032\099\111\115\109\111\112\111\108\105\116\097\110\032\100\105\115\116\114\105\098\117\116\105\111\110\046\032\073\116\032\105\115\032\097\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\112\108\097\110\116\032\119\105\116\104\032\108\097\114\103\101\044\032\116\114\105\097\110\103\117\108\097\114\032\102\114\111\110\100\115\032\116\104\097\116\032\103\114\111\119\032\102\114\111\109\032\097\110\032\117\110\100\101\114\103\114\111\117\110\100\032\114\104\105\122\111\109\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -789,7 +1150,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\051\053\049\056\056\049\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\116\104\121\114\105\117\109\095\102\105\108\105\120\045\102\101\109\105\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\053\045\049\048\084\049\057\058\048\048\058\052\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\065\116\104\121\114\105\117\109\032\102\105\108\105\120\045\102\101\109\105\110\097\044\032\116\104\101\032\108\097\100\121\032\102\101\114\110\032\111\114\032\099\111\109\109\111\110\032\108\097\100\121\045\102\101\114\110\044\032\105\115\032\097\032\108\097\114\103\101\044\032\102\101\097\116\104\101\114\121\032\115\112\101\099\105\101\115\032\111\102\032\102\101\114\110\032\110\097\116\105\118\101\032\116\111\032\116\101\109\112\101\114\097\116\101\032\065\115\105\097\044\032\069\117\114\111\112\101\044\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\097\110\100\032\078\111\114\116\104\032\065\102\114\105\099\097\046\032\073\116\032\105\115\032\111\102\116\101\110\032\097\098\117\110\100\097\110\116\032\040\111\110\101\032\111\102\032\116\104\101\032\109\111\114\101\032\099\111\109\109\111\110\032\102\101\114\110\115\041\032\105\110\032\100\097\109\112\044\032\115\104\097\100\121\032\119\111\111\100\108\097\110\100\032\101\110\118\105\114\111\110\109\101\110\116\115\032\097\110\100\032\105\115\032\111\102\116\101\110\032\103\114\111\119\110\032\102\111\114\032\100\101\099\111\114\097\116\105\111\110\046\010\073\116\115\032\099\111\109\109\111\110\032\110\097\109\101\115\032\034\108\097\100\121\032\102\101\114\110\034\032\097\110\100\032\034\102\101\109\097\108\101\032\102\101\114\110\034\032\114\101\102\101\114\032\116\111\032\104\111\119\032\105\116\115\032\114\101\112\114\111\100\117\099\116\105\118\101\032\115\116\114\117\099\116\117\114\101\115\032\040\115\111\114\105\041\032\097\114\101\032\099\111\110\099\101\097\108\101\100\032\105\110\032\097\110\032\105\110\099\111\110\115\112\105\099\117\111\117\115\032\226\128\147\032\100\101\101\109\101\100\032\034\102\101\109\097\108\101\034\032\226\128\147\032\109\097\110\110\101\114\032\111\110\032\116\104\101\032\102\114\111\110\100\046\032\065\108\116\101\114\110\097\116\105\118\101\108\121\044\032\105\116\032\105\115\032\115\097\105\100\032\116\111\032\098\101\032\102\101\109\105\110\105\110\101\032\098\101\099\097\117\115\101\032\111\102\032\105\116\115\032\101\108\101\103\097\110\116\032\097\110\100\032\103\114\097\099\101\102\117\108\032\097\112\112\101\097\114\097\110\099\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -808,7 +1169,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\049\053\055\056\052\056\048\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\068\114\121\111\112\116\101\114\105\115\095\102\105\108\105\120\045\109\097\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\049\048\045\048\056\084\049\054\058\053\048\058\053\048\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\068\114\121\111\112\116\101\114\105\115\032\102\105\108\105\120\045\109\097\115\044\032\116\104\101\032\109\097\108\101\032\102\101\114\110\044\032\105\115\032\097\032\099\111\109\109\111\110\032\102\101\114\110\032\111\102\032\116\104\101\032\116\101\109\112\101\114\097\116\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\044\032\110\097\116\105\118\101\032\116\111\032\109\117\099\104\032\111\102\032\069\117\114\111\112\101\044\032\065\115\105\097\044\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046\032\073\116\032\102\097\118\111\117\114\115\032\100\097\109\112\032\115\104\097\100\101\100\032\097\114\101\097\115\032\105\110\032\116\104\101\032\117\110\100\101\114\115\116\111\114\121\032\111\102\032\119\111\111\100\108\097\110\100\115\044\032\098\117\116\032\097\108\115\111\032\115\104\097\100\121\032\112\108\097\099\101\115\032\111\110\032\104\101\100\103\101\045\098\097\110\107\115\044\032\097\110\100\032\114\111\099\107\115\044\032\097\110\100\032\115\099\114\101\101\115\046\032\078\101\097\114\032\116\104\101\032\110\111\114\116\104\101\114\110\032\108\105\109\105\116\032\111\102\032\105\116\115\032\100\105\115\116\114\105\098\117\116\105\111\110\032\105\116\032\112\114\101\102\101\114\115\032\115\117\110\110\121\044\032\119\101\108\108\045\100\114\097\105\110\101\100\032\115\105\116\101\115\046\032\073\116\032\105\115\032\109\117\099\104\032\108\101\115\115\032\097\098\117\110\100\097\110\116\032\105\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\032\116\104\097\110\032\105\110\032\069\117\114\111\112\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -827,7 +1188,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\053\054\055\049\052\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\115\112\108\101\110\105\117\109\095\115\099\111\108\111\112\101\110\100\114\105\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\048\084\049\057\058\050\057\058\050\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\065\115\112\108\101\110\105\117\109\032\115\099\111\108\111\112\101\110\100\114\105\117\109\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\104\097\114\116\039\115\045\116\111\110\103\117\101\032\102\101\114\110\044\032\105\115\032\097\110\032\101\118\101\114\103\114\101\101\110\032\102\101\114\110\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\065\115\112\108\101\110\105\097\099\101\097\101\032\110\097\116\105\118\101\032\116\111\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -846,7 +1207,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\056\053\048\050\056\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\111\108\121\112\111\100\105\117\109\095\118\117\108\103\097\114\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\056\084\048\057\058\052\053\058\051\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\080\111\108\121\112\111\100\105\117\109\032\118\117\108\103\097\114\101\044\032\116\104\101\032\099\111\109\109\111\110\032\112\111\108\121\112\111\100\121\044\032\105\115\032\097\110\032\101\118\101\114\103\114\101\101\110\032\102\101\114\110\032\111\102\032\116\104\101\032\102\097\109\105\108\121\032\080\111\108\121\112\111\100\105\097\099\101\097\101\046\032\084\104\101\032\110\097\109\101\032\105\115\032\100\101\114\105\118\101\100\032\102\114\111\109\032\071\114\101\101\107\032\112\111\108\121\045\032\040\034\109\097\110\121\034\041\032\097\110\100\032\112\111\117\115\044\032\112\111\100\111\115\032\040\034\102\111\111\116\034\041\046\032\080\111\108\121\112\111\100\121\032\104\097\115\032\116\114\097\100\105\116\105\111\110\097\108\032\117\115\101\115\032\105\110\032\099\111\111\107\105\110\103\032\102\111\114\032\105\116\115\032\097\114\111\109\097\032\097\110\100\032\115\119\101\101\116\032\116\097\115\116\101\044\032\097\110\100\032\105\110\032\104\101\114\098\097\108\032\109\101\100\105\099\105\110\101\032\097\115\032\097\032\112\117\114\103\097\116\105\118\101\032\097\110\100\032\118\101\114\109\105\102\117\103\101\046\032\084\104\105\115\032\115\112\101\099\105\101\115\032\105\115\032\101\120\112\097\110\115\105\118\101\032\097\110\100\032\104\105\103\104\108\121\032\097\100\097\112\116\101\100\032\116\111\032\109\097\110\121\032\101\110\118\105\114\111\110\109\101\110\116\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -865,7 +1226,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\050\052\049\054\051\048\055\050\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\083\112\104\097\103\110\117\109\095\112\097\108\117\115\116\114\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\052\045\048\056\045\050\050\084\048\057\058\049\049\058\048\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\083\112\104\097\103\110\117\109\032\112\097\108\117\115\116\114\101\032\040\083\121\110\046\032\083\112\104\097\103\110\117\109\032\099\121\109\098\105\102\111\108\105\117\109\041\044\032\116\104\101\032\112\114\097\105\114\105\101\032\115\112\104\097\103\110\117\109\032\111\114\032\098\108\117\110\116\045\108\101\097\118\101\100\032\098\111\103\109\111\115\115\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\112\101\097\116\032\109\111\115\115\032\102\114\111\109\032\116\104\101\032\103\101\110\117\115\032\083\112\104\097\103\110\117\109\044\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\083\112\104\097\103\110\097\099\101\097\101\046\032\076\105\107\101\032\111\116\104\101\114\032\109\111\115\115\101\115\032\111\102\032\116\104\105\115\032\116\121\112\101\032\105\116\032\099\097\110\032\115\111\097\107\032\117\112\032\119\097\116\101\114\032\117\112\032\116\111\032\116\104\101\032\051\048\045\102\111\108\100\032\097\109\111\117\110\116\032\111\102\032\105\116\115\032\111\119\110\032\100\114\121\032\119\101\105\103\104\116\032\116\104\097\110\107\115\032\116\111\032\105\116\115\032\101\108\097\115\116\105\099\032\115\112\105\114\097\108\032\102\105\098\101\114\115\046\032\083\046\032\112\097\108\117\115\116\114\101\032\105\115\032\114\097\116\104\101\114\032\102\114\101\113\117\101\110\116\032\097\110\100\032\105\115\032\115\112\114\101\097\100\032\097\108\109\111\115\116\032\097\108\108\032\111\118\101\114\032\116\104\101\032\119\111\114\108\100\046\032\073\116\032\109\097\105\110\108\121\032\103\114\111\119\115\032\105\110\032\119\101\116\032\102\111\114\101\115\116\115\032\097\110\100\226\128\148\099\111\109\112\097\114\101\100\032\116\111\032\111\116\104\101\114\032\115\112\101\099\105\109\101\110\115\032\111\102\032\116\104\105\115\032\103\101\110\117\115\226\128\148\114\097\114\101\108\121\032\103\114\111\119\115\032\105\110\032\109\111\111\114\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -884,7 +1245,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\055\056\051\057\051\050\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\111\108\121\116\114\105\099\104\117\109\095\099\111\109\109\117\110\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\048\053\084\049\052\058\049\049\058\051\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\080\111\108\121\116\114\105\099\104\117\109\032\099\111\109\109\117\110\101\032\040\097\108\115\111\032\107\110\111\119\110\032\097\115\032\099\111\109\109\111\110\032\104\097\105\114\099\097\112\044\032\103\114\101\097\116\032\103\111\108\100\101\110\032\109\097\105\100\101\110\104\097\105\114\044\032\103\114\101\097\116\032\103\111\108\100\105\108\111\099\107\115\044\032\099\111\109\109\111\110\032\104\097\105\114\099\097\112\032\109\111\115\115\044\032\111\114\032\099\111\109\109\111\110\032\104\097\105\114\032\109\111\115\115\041\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\109\111\115\115\032\102\111\117\110\100\032\105\110\032\109\097\110\121\032\114\101\103\105\111\110\115\032\119\105\116\104\032\104\105\103\104\032\104\117\109\105\100\105\116\121\032\097\110\100\032\114\097\105\110\102\097\108\108\046\032\084\104\101\032\115\112\101\099\105\101\115\032\099\097\110\032\098\101\032\101\120\099\101\112\116\105\111\110\097\108\108\121\032\116\097\108\108\032\102\111\114\032\097\032\109\111\115\115\032\119\105\116\104\032\115\116\101\109\115\032\111\102\116\101\110\032\101\120\099\101\101\100\105\110\103\032\051\048\032\099\109\032\040\049\050\032\105\110\041\032\097\110\100\032\114\097\114\101\108\121\032\114\101\097\099\104\105\110\103\032\055\048\032\099\109\032\040\050\055\046\053\032\105\110\041\044\032\098\117\116\032\105\116\032\105\115\032\109\111\115\116\032\099\111\109\109\111\110\108\121\032\102\111\117\110\100\032\097\116\032\115\104\111\114\116\101\114\032\108\101\110\103\116\104\115\032\111\102\032\053\032\116\111\032\049\048\032\099\109\032\040\050\032\116\111\032\052\032\105\110\041\046\032\073\116\032\105\115\032\119\105\100\101\108\121\032\100\105\115\116\114\105\098\117\116\101\100\032\116\104\114\111\117\103\104\111\117\116\032\116\101\109\112\101\114\097\116\101\032\097\110\100\032\098\111\114\101\097\108\032\108\097\116\105\116\117\100\101\115\032\105\110\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\032\097\110\100\032\097\108\115\111\032\102\111\117\110\100\032\105\110\032\077\101\120\105\099\111\044\032\115\101\118\101\114\097\108\032\080\097\099\105\102\105\099\032\073\115\108\097\110\100\115\032\105\110\099\108\117\100\105\110\103\032\078\101\119\032\090\101\097\108\097\110\100\044\032\097\110\100\032\097\108\115\111\032\105\110\032\065\117\115\116\114\097\108\105\097\046\032\073\116\032\116\121\112\105\099\097\108\108\121\032\103\114\111\119\115\032\105\110\032\098\111\103\115\044\032\119\101\116\032\104\101\097\116\104\108\097\110\100\032\097\110\100\032\097\108\111\110\103\032\102\111\114\101\115\116\032\115\116\114\101\097\109\115\046\032\065\100\100\105\116\105\111\110\097\108\108\121\044\032\099\108\097\115\115\032\080\111\108\121\116\114\105\099\104\111\112\115\105\100\097\032\104\097\115\032\098\101\101\110\032\115\104\111\119\110\032\116\111\032\116\104\114\105\118\101\032\105\110\032\112\097\114\116\105\097\108\108\121\032\111\112\101\110\032\104\097\098\105\116\097\116\115\032\116\104\097\116\032\104\097\118\101\032\098\101\101\110\032\114\101\099\101\110\116\108\121\032\100\105\115\116\117\114\098\101\100\032\098\121\032\104\117\109\097\110\032\097\099\116\105\118\105\116\105\101\115\032\111\114\032\101\118\101\110\032\108\105\118\101\115\116\111\099\107\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -903,7 +1264,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\053\051\051\054\052\056\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\072\121\112\110\117\109\095\099\117\112\114\101\115\115\105\102\111\114\109\101\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\051\045\050\053\084\049\053\058\052\055\058\050\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\072\121\112\110\117\109\032\099\117\112\114\101\115\115\105\102\111\114\109\101\044\032\116\104\101\032\099\121\112\114\101\115\115\045\108\101\097\118\101\100\032\112\108\097\105\116\109\111\115\115\032\111\114\032\104\121\112\110\117\109\032\109\111\115\115\044\032\105\115\032\097\032\099\111\109\109\111\110\032\097\110\100\032\119\105\100\101\115\112\114\101\097\100\032\115\112\101\099\105\101\115\032\111\102\032\109\111\115\115\032\098\101\108\111\110\103\105\110\103\032\116\111\032\116\104\101\032\103\101\110\117\115\032\072\121\112\110\117\109\046\032\073\116\032\105\115\032\102\111\117\110\100\032\105\110\032\097\108\108\032\099\111\110\116\105\110\101\110\116\115\032\101\120\099\101\112\116\032\065\110\116\097\114\099\116\105\099\097\032\097\110\100\032\111\099\099\117\114\115\032\105\110\032\097\032\119\105\100\101\032\118\097\114\105\101\116\121\032\111\102\032\104\097\098\105\116\097\116\115\032\097\110\100\032\099\108\105\109\097\116\105\099\032\122\111\110\101\115\046\032\073\116\032\116\121\112\105\099\097\108\108\121\032\103\114\111\119\115\032\111\110\032\116\114\101\101\032\116\114\117\110\107\115\044\032\108\111\103\115\044\032\119\097\108\108\115\044\032\114\111\099\107\115\032\097\110\100\032\111\116\104\101\114\032\115\117\114\102\097\099\101\115\046\032\073\116\032\112\114\101\102\101\114\115\032\097\099\105\100\105\099\032\101\110\118\105\114\111\110\109\101\110\116\115\032\097\110\100\032\105\115\032\102\097\105\114\108\121\032\116\111\108\101\114\097\110\116\032\111\102\032\112\111\108\108\117\116\105\111\110\046\032\073\116\032\119\097\115\032\102\111\114\109\101\114\108\121\032\117\115\101\100\032\097\115\032\097\032\102\105\108\108\105\110\103\032\102\111\114\032\112\105\108\108\111\119\115\032\097\110\100\032\109\097\116\116\114\101\115\115\101\115\059\032\116\104\101\032\097\115\115\111\099\105\097\116\105\111\110\032\119\105\116\104\032\115\108\101\101\112\032\105\115\032\116\104\101\032\111\114\105\103\105\110\032\111\102\032\116\104\101\032\103\101\110\117\115\032\110\097\109\101\032\072\121\112\110\117\109\032\040\102\114\111\109\032\071\114\101\101\107\032\072\121\112\110\111\115\041\046\010\010\084\104\105\115\032\115\109\097\108\108\032\116\111\032\109\101\100\105\117\109\045\115\105\122\101\100\032\109\111\115\115\032\105\115\032\097\098\111\117\116\032\050\226\128\147\049\048\032\099\109\032\108\111\110\103\046\032\073\116\032\105\115\032\112\108\101\117\114\111\099\097\114\112\111\117\115\044\032\119\105\116\104\032\112\114\111\115\116\114\097\116\101\044\032\099\114\101\101\112\105\110\103\032\115\116\101\109\115\032\116\104\097\116\032\102\111\114\109\032\115\109\111\111\116\104\044\032\100\101\110\115\101\032\109\097\116\115\046\032\084\104\101\032\098\114\097\110\099\104\101\100\032\115\116\101\109\115\032\097\114\101\032\099\111\118\101\114\101\100\032\105\110\032\111\118\101\114\108\097\112\112\105\110\103\032\108\101\097\118\101\115\044\032\103\105\118\105\110\103\032\116\104\101\032\105\109\112\114\101\115\115\105\111\110\032\111\102\032\097\032\099\121\112\114\101\115\115\032\116\114\101\101\046\032\084\104\101\032\115\116\101\109\032\108\101\097\118\101\115\032\097\114\101\032\108\111\110\103\032\097\110\100\032\116\104\105\110\044\032\109\101\097\115\117\114\105\110\103\032\049\046\048\045\050\046\049\032\109\109\032\098\121\032\048\046\051\045\048\046\054\032\109\109\046\032\084\104\101\121\032\097\114\101\032\099\111\110\099\097\118\101\032\097\110\100\032\115\105\099\107\108\101\045\115\104\097\112\101\100\044\032\116\097\112\101\114\105\110\103\032\116\111\119\097\114\100\115\032\116\104\101\032\116\105\112\046\032\084\104\101\032\098\114\097\110\099\104\032\108\101\097\118\101\115\032\097\114\101\032\115\109\097\108\108\101\114\032\097\110\100\032\110\097\114\114\111\119\101\114\032\116\104\097\110\032\116\104\101\032\115\116\101\109\032\108\101\097\118\101\115\046\032\032\084\104\101\032\109\111\115\115\032\112\114\111\100\117\099\101\115\032\115\104\111\114\116\044\032\099\121\108\105\110\100\114\105\099\097\108\032\097\110\100\032\115\108\105\103\104\116\108\121\032\099\117\114\118\101\100\032\099\097\112\115\117\108\101\115\032\119\104\105\099\104\032\099\111\110\116\097\105\110\032\116\104\101\032\115\112\111\114\101\115\046\032\084\104\101\115\101\032\099\097\112\115\117\108\101\115\032\109\101\097\115\117\114\101\032\049\046\055\045\050\046\052\032\109\109\032\105\110\032\108\101\110\103\116\104\032\097\110\100\032\104\097\118\101\032\097\032\108\105\100\045\108\105\107\101\032\111\112\101\114\099\117\108\117\109\032\109\101\097\115\117\114\105\110\103\032\048\046\054\045\048\046\057\032\109\109\046\032\084\104\101\121\032\097\114\101\032\098\111\114\110\101\032\111\110\032\114\101\100\100\105\115\104\045\098\114\111\119\110\032\115\116\097\108\107\115\032\119\104\105\099\104\032\097\114\101\032\049\226\128\147\050\046\053\032\099\109\032\108\111\110\103\046\032\084\104\101\032\109\111\115\115\032\105\115\032\100\105\111\105\099\111\117\115\044\032\109\101\097\110\105\110\103\032\116\104\097\116\032\105\116\032\104\097\115\032\115\101\112\097\114\097\116\101\032\109\097\108\101\032\097\110\100\032\102\101\109\097\108\101\032\112\108\097\110\116\115\046\032\078\117\109\101\114\111\117\115\032\118\097\114\105\101\116\105\101\115\032\111\102\032\072\121\112\110\117\109\032\099\117\112\114\101\115\115\105\102\111\114\109\101\032\104\097\118\101\032\098\101\101\110\032\100\101\115\099\114\105\098\101\100\044\032\109\097\107\105\110\103\032\105\116\032\097\032\104\105\103\104\108\121\032\118\097\114\105\097\098\108\101\032\115\112\101\099\105\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -922,7 +1283,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\054\055\054\052\050\048\051", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\068\105\099\114\097\110\117\109\095\115\099\111\112\097\114\105\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\052\045\048\050\084\049\057\058\050\051\058\053\049\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\068\105\099\114\097\110\117\109\032\115\099\111\112\097\114\105\117\109\044\032\116\104\101\032\098\114\111\111\109\032\102\111\114\107\109\111\115\115\044\032\105\115\032\097\032\100\105\099\114\097\110\097\099\101\111\117\115\032\109\111\115\115\044\032\110\097\116\105\118\101\032\116\111\032\109\111\115\116\032\111\102\032\116\104\101\032\110\111\114\116\104\101\114\110\032\104\101\109\105\115\112\104\101\114\101\032\097\115\032\119\101\108\108\032\097\115\032\079\099\101\097\110\105\097\046\032\073\116\032\117\115\117\097\108\108\121\032\102\111\114\109\115\032\097\110\100\032\103\114\111\119\115\032\105\110\032\114\111\117\110\100\032\109\097\115\115\032\099\108\117\109\112\115\032\111\114\032\109\097\116\115\032\111\110\032\115\111\105\108\032\105\110\032\100\114\121\032\116\111\032\109\111\105\115\116\032\102\111\114\101\115\116\101\100\032\097\114\101\097\115\046\032\065\115\032\119\105\116\104\032\109\097\110\121\032\116\121\112\101\115\032\111\102\032\109\111\115\115\032\066\114\111\111\109\032\109\111\115\115\032\103\114\111\119\115\032\105\110\032\099\108\117\109\112\115\032\119\105\116\104\032\066\114\111\111\109\032\109\111\115\115\101\115\032\097\115\032\119\101\108\108\032\097\115\032\111\116\104\101\114\032\109\111\115\115\101\115\046\032\073\116\032\099\097\110\032\098\101\032\100\105\115\116\105\110\103\117\105\115\104\101\100\032\098\121\032\105\116\115\032\108\101\097\118\101\115\044\032\119\104\105\099\104\032\115\116\114\111\110\103\108\121\032\099\117\114\118\101\032\116\111\032\111\110\101\032\115\105\100\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -941,7 +1302,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\057\048\048\052\054\050\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\077\110\105\117\109\095\104\111\114\110\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\049\050\084\048\056\058\051\055\058\053\050\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", text = "\077\110\105\117\109\032\104\111\114\110\117\109\044\032\097\108\115\111\032\107\110\111\119\110\032\098\121\032\116\104\101\032\099\111\109\109\111\110\032\110\097\109\101\115\032\104\111\114\110\032\099\097\108\099\097\114\101\111\117\115\032\109\111\115\115\032\111\114\032\115\119\097\110\039\115\045\110\101\099\107\032\116\104\121\109\101\045\109\111\115\115\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\109\111\115\115\032\105\110\032\116\104\101\032\103\101\110\117\115\032\077\110\105\117\109\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -951,6 +1312,25 @@ return { license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", }, + ["\104\111\108\108\121\095\102\101\114\110"] = { + latin = "\080\111\108\121\115\116\105\099\104\117\109\032\115\101\116\105\102\101\114\117\109", + title = "\080\111\108\121\115\116\105\099\104\117\109\032\115\101\116\105\102\101\114\117\109", + url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\080\111\108\121\115\116\105\099\104\117\109\095\115\101\116\105\102\101\114\117\109", + pageid = 5025872, + revision = 1296456929, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\050\057\054\052\053\054\057\050\057", + history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\111\108\121\115\116\105\099\104\117\109\095\115\101\116\105\102\101\114\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", + revision_timestamp = "\050\048\050\053\045\048\054\045\050\048\084\048\051\058\050\055\058\051\057\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\048\048\090", + text = "\080\111\108\121\115\116\105\099\104\117\109\032\115\101\116\105\102\101\114\117\109\044\032\111\114\032\116\104\101\032\115\111\102\116\032\115\104\105\101\108\100\032\102\101\114\110\044\032\105\115\032\097\110\032\101\118\101\114\103\114\101\101\110\032\111\114\032\115\101\109\105\045\101\118\101\114\103\114\101\101\110\032\102\101\114\110\032\110\097\116\105\118\101\032\116\111\032\115\111\117\116\104\101\114\110\032\097\110\100\032\119\101\115\116\101\114\110\032\069\117\114\111\112\101\046\032\084\104\101\032\115\116\097\108\107\115\032\097\110\100\032\109\111\115\116\032\109\105\100\114\105\098\115\032\097\114\101\032\099\111\097\116\101\100\032\119\105\116\104\032\099\105\110\110\097\109\111\110\045\098\114\111\119\110\032\115\099\097\108\101\115\046\032\084\104\101\032\076\097\116\105\110\032\115\112\101\099\105\102\105\099\032\101\112\105\116\104\101\116\032\115\101\116\105\102\101\114\117\109\032\109\101\097\110\115\032\034\119\105\116\104\032\098\114\105\115\116\108\101\115\034\046", + kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", + excerpt = true, + truncated = false, + attribution = "\087\105\107\105\112\101\100\105\097\032\099\111\110\116\114\105\098\117\116\111\114\115\044\032\034\080\111\108\121\115\116\105\099\104\117\109\032\115\101\116\105\102\101\114\117\109\034", + license = "\067\067\032\066\089\045\083\065\032\052\046\048", + license_url = "\104\116\116\112\115\058\047\047\099\114\101\097\116\105\118\101\099\111\109\109\111\110\115\046\111\114\103\047\108\105\099\101\110\115\101\115\047\098\121\045\115\097\047\052\046\048\047", + changes = "\080\108\097\105\110\116\101\120\116\032\105\110\116\114\111\100\117\099\116\105\111\110\032\111\110\108\121\059\032\097\114\116\105\099\108\101\032\098\111\100\121\032\111\109\105\116\116\101\100\046\032\078\111\032\116\114\097\110\115\108\097\116\105\111\110\046", + }, ["\102\108\121\095\097\103\097\114\105\099"] = { latin = "\065\109\097\110\105\116\097\032\109\117\115\099\097\114\105\097", title = "\065\109\097\110\105\116\097\032\109\117\115\099\097\114\105\097", @@ -960,7 +1340,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\048\057\057\052\052\048\051", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\109\097\110\105\116\097\095\109\117\115\099\097\114\105\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\057\084\048\057\058\049\052\058\050\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\065\109\097\110\105\116\097\032\109\117\115\099\097\114\105\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\102\108\121\032\097\103\097\114\105\099\032\111\114\032\102\108\121\032\097\109\097\110\105\116\097\032\111\114\032\115\105\109\112\108\121\032\114\101\100\032\116\111\097\100\115\116\111\111\108\044\032\105\115\032\097\032\098\097\115\105\100\105\111\109\121\099\101\116\101\032\102\117\110\103\117\115\032\111\102\032\116\104\101\032\103\101\110\117\115\032\065\109\097\110\105\116\097\046\032\073\116\115\032\110\097\109\101\032\109\097\121\032\099\111\109\101\032\102\114\111\109\032\105\116\115\032\104\105\115\116\111\114\105\099\097\108\032\117\115\101\032\116\111\032\107\105\108\108\032\102\108\105\101\115\032\111\114\032\102\114\111\109\032\116\104\101\032\101\102\102\101\099\116\115\032\105\116\032\099\097\117\115\101\115\032\040\097\032\109\101\100\105\101\118\097\108\032\098\101\108\105\101\102\032\104\101\108\100\032\116\104\097\116\032\102\108\105\101\115\032\099\111\117\108\100\032\101\110\116\101\114\032\116\104\101\032\104\101\097\100\032\097\110\100\032\099\097\117\115\101\032\109\097\100\110\101\115\115\041\046\032\073\116\032\105\115\032\097\032\100\105\115\116\105\110\099\116\105\118\101\044\032\108\097\114\103\101\044\032\119\104\105\116\101\045\103\105\108\108\101\100\032\109\117\115\104\114\111\111\109\032\116\121\112\105\099\097\108\108\121\032\102\101\097\116\117\114\105\110\103\032\097\032\098\114\105\103\104\116\032\114\101\100\032\099\097\112\032\099\111\118\101\114\101\100\032\119\105\116\104\032\119\104\105\116\101\032\119\097\114\116\115\046\032\084\104\101\032\099\111\109\112\108\101\120\032\103\101\110\101\116\105\099\032\100\105\118\101\114\115\105\116\121\032\111\102\032\065\046\032\109\117\115\099\097\114\105\097\032\115\117\103\103\101\115\116\115\032\116\104\097\116\032\105\116\032\105\115\032\097\032\115\112\101\099\105\101\115\032\099\111\109\112\108\101\120\046\032\073\116\032\105\115\032\097\032\119\105\100\101\108\121\032\100\105\115\116\114\105\098\117\116\101\100\032\109\117\115\104\114\111\111\109\032\110\097\116\105\118\101\032\116\111\032\116\101\109\112\101\114\097\116\101\032\097\110\100\032\098\111\114\101\097\108\032\102\111\114\101\115\116\115\032\111\102\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\044\032\110\111\119\032\097\108\115\111\032\110\097\116\117\114\097\108\105\115\101\100\032\105\110\032\116\104\101\032\083\111\117\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\044\032\102\111\114\109\105\110\103\032\115\121\109\098\105\111\116\105\099\032\114\101\108\097\116\105\111\110\115\104\105\112\115\032\119\105\116\104\032\118\097\114\105\111\117\115\032\116\114\101\101\115\032\097\110\100\032\115\112\114\101\097\100\105\110\103\032\105\110\118\097\115\105\118\101\108\121\032\105\110\032\115\111\109\101\032\114\101\103\105\111\110\115\046\010\073\110\103\101\115\116\105\111\110\032\111\102\032\116\104\101\032\109\117\115\104\114\111\111\109\032\099\097\110\032\099\097\117\115\101\032\112\111\105\115\111\110\105\110\103\044\032\101\115\112\101\099\105\097\108\108\121\032\105\110\032\099\104\105\108\100\114\101\110\032\097\110\100\032\116\104\111\115\101\032\115\101\101\107\105\110\103\032\105\116\115\032\104\097\108\108\117\099\105\110\111\103\101\110\105\099\032\101\102\102\101\099\116\115\044\032\100\117\101\032\116\111\032\112\115\121\099\104\111\097\099\116\105\118\101\032\099\111\109\112\111\117\110\100\115\032\108\105\107\101\032\109\117\115\099\105\109\111\108\032\097\110\100\032\105\098\111\116\101\110\105\099\032\097\099\105\100\059\032\104\111\119\101\118\101\114\044\032\102\097\116\097\108\032\112\111\105\115\111\110\105\110\103\115\032\097\114\101\032\101\120\116\114\101\109\101\108\121\032\114\097\114\101\046\032\080\097\114\098\111\105\108\105\110\103\032\114\101\100\117\099\101\115\032\116\111\120\105\099\105\116\121\059\032\100\114\121\105\110\103\032\099\111\110\118\101\114\116\115\032\105\098\111\116\101\110\105\099\032\097\099\105\100\032\105\110\116\111\032\109\117\115\099\105\109\111\108\032\119\104\105\108\101\032\114\101\116\097\105\110\105\110\103\032\112\115\121\099\104\111\097\099\116\105\118\101\032\101\102\102\101\099\116\115\046\032\083\111\109\101\032\099\117\108\116\117\114\101\115\032\117\115\101\032\105\116\032\097\115\032\102\111\111\100\032\097\102\116\101\114\032\112\114\101\112\097\114\097\116\105\111\110\046\032\073\110\100\105\103\101\110\111\117\115\032\112\101\111\112\108\101\115\032\111\102\032\083\105\098\101\114\105\097\032\117\115\101\100\032\065\046\032\109\117\115\099\097\114\105\097\032\097\110\100\032\111\116\104\101\114\032\065\109\097\110\105\116\097\032\115\112\101\099\105\101\115\032\097\115\032\097\110\032\101\110\116\104\101\111\103\101\110\046\032\073\116\032\104\097\115\032\098\101\101\110\032\099\111\110\116\114\111\118\101\114\115\105\097\108\108\121\032\108\105\110\107\101\100\032\116\111\032\083\097\110\116\097\032\067\108\097\117\115\044\032\086\105\107\105\110\103\032\098\101\114\115\101\114\107\101\114\115\044\032\086\101\100\105\099\032\115\111\109\097\044\032\097\110\100\032\101\097\114\108\121\032\067\104\114\105\115\116\105\097\110\105\116\121\044\032\116\104\111\117\103\104\032\101\118\105\100\101\110\099\101\032\105\115\032\115\112\097\114\115\101\032\097\110\100\032\100\105\115\112\117\116\101\100\046\032\073\116\115\032\114\105\115\101\032\105\110\032\116\104\101\032\050\048\050\048\115\032\097\115\032\097\032\104\097\108\108\117\099\105\110\111\103\101\110\032\104\097\115\032\108\101\100\032\116\111\032\103\111\118\101\114\110\109\101\110\116\097\108\032\115\099\114\117\116\105\110\121\046\010\065\046\032\109\117\115\099\097\114\105\097\032\104\097\115\032\097\112\112\101\097\114\101\100\032\105\110\032\097\114\116\032\097\110\100\032\108\105\116\101\114\097\116\117\114\101\032\115\105\110\099\101\032\116\104\101\032\082\101\110\097\105\115\115\097\110\099\101\044\032\098\101\099\111\109\105\110\103\032\105\099\111\110\105\099\032\105\110\032\102\097\105\114\121\032\116\097\108\101\115\044\032\099\104\105\108\100\114\101\110\039\115\032\098\111\111\107\115\044\032\097\110\100\032\109\101\100\105\097\032\108\105\107\101\032\068\105\115\110\101\121\039\115\032\070\097\110\116\097\115\105\097\032\040\049\057\052\048\041\032\097\110\100\032\116\104\101\032\083\117\112\101\114\032\077\097\114\105\111\032\118\105\100\101\111\032\103\097\109\101\115\046\032\073\116\032\104\097\115\032\097\108\115\111\032\105\110\102\108\117\101\110\099\101\100\032\108\105\116\101\114\097\114\121\032\100\101\112\105\099\116\105\111\110\115\032\111\102\032\097\108\116\101\114\101\100\032\112\101\114\099\101\112\116\105\111\110\226\128\148\109\111\115\116\032\110\111\116\097\098\108\121\032\105\110\032\065\108\105\099\101\039\115\032\065\100\118\101\110\116\117\114\101\115\032\105\110\032\087\111\110\100\101\114\108\097\110\100\226\128\148\097\110\100\032\104\097\115\032\098\101\101\110\032\114\101\102\101\114\101\110\099\101\100\032\105\110\032\110\111\118\101\108\115\032\098\121\032\119\114\105\116\101\114\115\032\105\110\099\108\117\100\105\110\103\032\079\108\105\118\101\114\032\071\111\108\100\115\109\105\116\104\044\032\084\104\111\109\097\115\032\080\121\110\099\104\111\110\044\032\097\110\100\032\065\108\097\110\032\071\097\114\110\101\114\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -979,7 +1359,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\056\053\057\048\052\049\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\101\099\099\105\110\117\109\095\115\099\097\098\114\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\049\048\084\048\048\058\050\051\058\050\049\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\076\101\099\099\105\110\117\109\032\115\099\097\098\114\117\109\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\098\105\114\099\104\032\098\111\108\101\116\101\044\032\114\111\117\103\104\045\115\116\101\109\109\101\100\032\098\111\108\101\116\101\044\032\111\114\032\115\099\097\098\101\114\032\115\116\097\108\107\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\117\110\103\117\115\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\066\111\108\101\116\097\099\101\097\101\046\032\073\116\032\119\097\115\032\102\111\114\109\101\114\108\121\032\099\108\097\115\115\105\102\105\101\100\032\097\115\032\066\111\108\101\116\117\115\032\115\099\097\098\101\114\046\010\084\104\101\032\098\105\114\099\104\032\098\111\108\101\116\101\032\105\115\032\119\105\100\101\115\112\114\101\097\100\032\105\110\032\069\117\114\111\112\101\044\032\105\110\032\116\104\101\032\072\105\109\097\108\097\121\097\115\032\105\110\032\065\115\105\097\044\032\097\110\100\032\101\108\115\101\119\104\101\114\101\032\105\110\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\044\032\111\099\099\117\114\114\105\110\103\032\111\110\108\121\032\105\110\032\109\121\099\111\114\114\104\105\122\097\108\032\097\115\115\111\099\105\097\116\105\111\110\032\119\105\116\104\032\098\105\114\099\104\032\116\114\101\101\115\046\032\084\104\105\115\032\109\117\115\104\114\111\111\109\032\105\115\032\097\108\115\111\032\098\101\099\111\109\105\110\103\032\105\110\099\114\101\097\115\105\110\103\108\121\032\099\111\109\109\111\110\032\105\110\032\065\117\115\116\114\097\108\105\097\032\097\110\100\032\078\101\119\032\090\101\097\108\097\110\100\032\119\104\101\114\101\032\105\116\032\105\115\032\108\105\107\101\108\121\032\105\110\116\114\111\100\117\099\101\100\046\032\073\116\032\105\115\032\097\110\032\101\100\105\098\108\101\032\109\117\115\104\114\111\111\109\032\105\102\032\111\110\108\121\032\102\105\114\109\032\115\112\101\099\105\109\101\110\115\032\097\114\101\032\117\115\101\100\032\097\110\100\032\099\111\111\107\101\100\032\116\104\111\114\111\117\103\104\108\121\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -998,7 +1378,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\049\051\049\049\055\051\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\073\109\108\101\114\105\097\095\098\097\100\105\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\050\053\084\050\048\058\053\056\058\050\048\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\073\109\108\101\114\105\097\032\098\097\100\105\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\098\097\121\032\098\111\108\101\116\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\112\111\114\101\100\032\109\117\115\104\114\111\111\109\046\032\070\105\114\115\116\032\100\101\115\099\114\105\098\101\100\032\115\099\105\101\110\116\105\102\105\099\097\108\108\121\032\098\121\032\069\108\105\097\115\032\070\114\105\101\115\032\105\110\032\049\056\049\056\044\032\116\104\101\032\098\097\121\032\098\111\108\101\116\101\032\119\097\115\032\114\101\099\108\097\115\115\105\102\105\101\100\032\097\115\032\088\101\114\111\099\111\109\117\115\032\098\097\100\105\117\115\032\105\110\032\049\057\051\049\044\032\097\110\100\032\105\116\032\105\115\032\115\116\105\108\108\032\108\105\115\116\101\100\032\116\104\117\115\032\105\110\032\115\101\118\101\114\097\108\032\115\111\117\114\099\101\115\046\032\077\111\100\101\114\110\032\109\111\108\101\099\117\108\097\114\032\112\104\121\108\111\103\101\110\101\116\105\099\032\115\116\117\100\105\101\115\032\115\104\111\119\032\088\101\114\111\099\111\109\117\115\032\116\111\032\098\101\032\112\111\108\121\112\104\121\108\101\116\105\099\032\040\110\111\116\032\100\101\115\099\101\110\100\101\100\032\102\114\111\109\032\116\104\101\032\115\097\109\101\032\099\111\109\109\111\110\032\097\110\099\101\115\116\111\114\041\044\032\097\110\100\032\116\104\101\032\098\097\121\032\098\111\108\101\116\101\032\105\115\032\110\111\116\032\112\097\114\116\105\099\117\108\097\114\108\121\032\099\108\111\115\101\108\121\032\114\101\108\097\116\101\100\032\116\111\032\115\112\101\099\105\101\115\032\105\110\032\116\104\097\116\032\103\101\110\117\115\046\010\066\111\116\104\032\116\104\101\032\099\111\109\109\111\110\032\097\110\100\032\115\099\105\101\110\116\105\102\105\099\032\110\097\109\101\115\032\114\101\102\101\114\032\116\111\032\116\104\101\032\098\097\121\045\032\111\114\032\099\104\101\115\116\110\117\116\045\099\111\108\111\117\114\101\100\032\099\097\112\044\032\119\104\105\099\104\032\105\115\032\097\108\109\111\115\116\032\115\112\104\101\114\105\099\097\108\032\105\110\032\121\111\117\110\103\032\115\112\101\099\105\109\101\110\115\032\098\101\102\111\114\101\032\098\114\111\097\100\101\110\105\110\103\032\097\110\100\032\102\108\097\116\116\101\110\105\110\103\032\111\117\116\032\116\111\032\097\032\100\105\097\109\101\116\101\114\032\117\112\032\116\111\032\049\053\032\099\109\032\040\054\032\105\110\041\046\032\079\110\032\116\104\101\032\099\097\112\032\117\110\100\101\114\115\105\100\101\032\097\114\101\032\115\109\097\108\108\032\121\101\108\108\111\119\105\115\104\032\112\111\114\101\115\032\116\104\097\116\032\116\117\114\110\032\100\117\108\108\032\098\108\117\101\045\103\114\101\121\032\119\104\101\110\032\098\114\117\105\115\101\100\046\032\084\104\101\032\115\109\111\111\116\104\044\032\099\121\108\105\110\100\114\105\099\097\108\032\115\116\105\112\101\044\032\109\101\097\115\117\114\105\110\103\032\052\226\128\147\057\032\099\109\032\040\049\043\049\226\129\132\050\226\128\147\051\043\049\226\129\132\050\032\105\110\041\032\108\111\110\103\032\098\121\032\049\226\128\147\050\032\099\109\032\040\049\226\129\132\050\226\128\147\051\226\129\132\052\032\105\110\041\032\116\104\105\099\107\044\032\105\115\032\099\111\108\111\117\114\101\100\032\108\105\107\101\032\116\104\101\032\099\097\112\044\032\098\117\116\032\112\097\108\101\114\046\032\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\102\111\117\110\100\032\105\110\032\069\117\114\097\115\105\097\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\103\114\111\119\105\110\103\032\105\110\032\099\111\110\105\102\101\114\111\117\115\032\111\114\032\109\105\120\101\100\032\119\111\111\100\115\032\111\110\032\116\104\101\032\103\114\111\117\110\100\032\111\114\032\111\110\032\100\101\099\097\121\105\110\103\032\116\114\101\101\032\115\116\117\109\112\115\044\032\115\111\109\101\116\105\109\101\115\032\105\110\032\112\114\111\108\105\102\105\099\032\110\117\109\098\101\114\115\046\032\083\111\109\101\032\118\097\114\105\101\116\105\101\115\032\104\097\118\101\032\097\108\115\111\032\098\101\101\110\032\100\101\115\099\114\105\098\101\100\032\102\114\111\109\032\101\097\115\116\101\114\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\100\105\102\102\101\114\105\110\103\032\102\114\111\109\032\116\104\101\032\109\097\105\110\032\116\121\112\101\032\105\110\032\098\111\116\104\032\109\097\099\114\111\115\099\111\112\105\099\032\097\110\100\032\109\105\099\114\111\115\099\111\112\105\099\032\109\111\114\112\104\111\108\111\103\121\046\010\079\102\116\101\110\032\099\111\110\115\105\100\101\114\101\100\032\097\032\112\111\111\114\032\114\101\108\097\116\105\111\110\032\111\102\032\116\104\101\032\099\101\112\032\040\066\111\108\101\116\117\115\032\101\100\117\108\105\115\041\044\032\073\046\032\098\097\100\105\097\032\105\115\032\110\101\118\101\114\116\104\101\108\101\115\115\032\114\101\103\097\114\100\101\100\032\097\115\032\097\032\099\104\111\105\099\101\032\101\100\105\098\108\101\032\109\117\115\104\114\111\111\109\032\098\121\032\115\111\109\101\032\097\117\116\104\111\114\115\044\032\115\117\099\104\032\097\115\032\102\111\111\100\032\101\120\112\101\114\116\032\065\110\116\111\110\105\111\032\067\097\114\108\117\099\099\105\111\044\032\097\110\100\032\105\115\032\115\111\108\100\032\105\110\032\109\097\114\107\101\116\115\032\105\110\032\069\117\114\111\112\101\032\097\110\100\032\099\101\110\116\114\097\108\032\077\101\120\105\099\111\046\032\073\116\115\032\109\117\115\104\114\111\111\109\115\032\097\114\101\032\108\101\115\115\032\111\102\116\101\110\032\105\110\102\101\115\116\101\100\032\098\121\032\109\097\103\103\111\116\115\032\116\104\097\110\032\111\116\104\101\114\032\098\111\108\101\116\101\115\046\032\083\101\118\101\114\097\108\032\069\117\114\111\112\101\097\110\032\115\116\117\100\105\101\115\032\104\097\118\101\032\100\101\109\111\110\115\116\114\097\116\101\100\032\116\104\097\116\032\116\104\101\032\109\117\115\104\114\111\111\109\032\099\097\110\032\098\105\111\097\099\099\117\109\117\108\097\116\101\032\115\111\109\101\032\116\114\097\099\101\032\109\101\116\097\108\115\032\102\114\111\109\032\116\104\101\032\115\111\105\108\044\032\115\117\099\104\032\097\115\032\109\101\114\099\117\114\121\044\032\099\111\098\097\108\116\044\032\097\110\100\032\110\105\099\107\101\108\046\032\065\100\100\105\116\105\111\110\097\108\108\121\044\032\116\104\101\032\109\117\115\104\114\111\111\109\032\099\111\110\116\097\105\110\115\032\097\032\112\105\103\109\101\110\116\032\116\104\097\116\032\099\111\110\099\101\110\116\114\097\116\101\115\032\114\097\100\105\111\097\099\116\105\118\101\032\099\097\101\115\105\117\109\059\032\115\112\101\099\105\109\101\110\115\032\099\111\108\108\101\099\116\101\100\032\105\110\032\069\117\114\111\112\101\032\102\111\108\108\111\119\105\110\103\032\116\104\101\032\049\057\056\054\032\067\104\101\114\110\111\098\121\108\032\100\105\115\097\115\116\101\114\032\099\111\110\116\097\105\110\101\100\032\115\101\118\101\114\097\108\032\116\105\109\101\115\032\109\111\114\101\032\099\097\101\115\105\117\109\045\049\051\055\032\116\104\097\110\032\116\104\111\115\101\032\099\111\108\108\101\099\116\101\100\032\098\101\102\111\114\101\032\116\104\101\032\105\110\099\105\100\101\110\116\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1017,7 +1397,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\050\057\057\056\052\052\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\097\110\116\104\097\114\101\108\108\117\115\095\099\105\098\097\114\105\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\051\045\049\049\084\049\055\058\053\052\058\050\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\067\097\110\116\104\097\114\101\108\108\117\115\032\099\105\098\097\114\105\117\115\032\040\076\097\116\105\110\058\032\099\097\110\116\104\097\114\101\108\108\117\115\044\032\034\099\104\097\110\116\101\114\101\108\108\101\034\059\032\099\105\098\097\114\105\117\115\044\032\034\099\117\108\105\110\097\114\121\034\041\032\105\115\032\116\104\101\032\103\111\108\100\101\110\032\099\104\097\110\116\101\114\101\108\108\101\044\032\116\104\101\032\116\121\112\101\032\115\112\101\099\105\101\115\032\111\102\032\116\104\101\032\099\104\097\110\116\101\114\101\108\108\101\032\103\101\110\117\115\032\067\097\110\116\104\097\114\101\108\108\117\115\046\032\073\116\032\105\115\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\103\105\114\111\108\108\101\032\040\111\114\032\103\105\114\111\108\101\041\046\010\068\101\115\112\105\116\101\032\105\116\115\032\099\104\097\114\097\099\116\101\114\105\115\116\105\099\032\102\101\097\116\117\114\101\115\044\032\067\046\032\099\105\098\097\114\105\117\115\032\099\097\110\032\098\101\032\099\111\110\102\117\115\101\100\032\119\105\116\104\032\115\112\101\099\105\101\115\032\115\117\099\104\032\097\115\032\116\104\101\032\112\111\105\115\111\110\111\117\115\032\079\109\112\104\097\108\111\116\117\115\032\105\108\108\117\100\101\110\115\046\032\084\104\101\032\103\111\108\100\101\110\032\099\104\097\110\116\101\114\101\108\108\101\032\105\115\032\097\032\099\111\109\109\111\110\108\121\032\099\111\110\115\117\109\101\100\032\097\110\100\032\099\104\111\105\099\101\032\101\100\105\098\108\101\032\115\112\101\099\105\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1036,7 +1416,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\053\056\049\055\054\053\055", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\111\108\108\121\098\105\097\095\110\117\100\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\051\045\050\056\084\049\048\058\053\052\058\050\050\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\067\111\108\108\121\098\105\097\032\110\117\100\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\098\108\101\119\105\116\044\032\119\111\111\100\032\098\108\101\119\105\116\032\032\111\114\032\098\108\117\101\032\102\111\111\116\032\097\110\100\032\112\114\101\118\105\111\117\115\108\121\032\100\101\115\099\114\105\098\101\100\032\097\115\032\076\101\112\105\115\116\097\032\110\117\100\097\032\097\110\100\032\067\108\105\116\111\099\121\098\101\032\110\117\100\097\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\117\110\103\105\046\032\068\101\115\099\114\105\098\101\100\032\098\121\032\080\105\101\114\114\101\032\066\117\108\108\105\097\114\100\032\105\110\032\049\055\057\048\044\032\105\116\032\119\097\115\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\084\114\105\099\104\111\108\111\109\097\032\110\117\100\117\109\032\102\111\114\032\109\097\110\121\032\121\101\097\114\115\046\032\073\116\032\105\115\032\109\111\114\112\104\111\108\111\103\105\099\097\108\108\121\032\115\105\109\105\108\097\114\032\097\110\100\032\114\101\108\097\116\101\100\032\116\111\032\116\104\101\032\102\105\101\108\100\032\098\108\101\119\105\116\044\032\067\111\108\108\121\098\105\097\032\112\101\114\115\111\110\097\116\097\059\032\098\111\116\104\032\109\117\115\104\114\111\111\109\115\032\119\101\114\101\032\114\101\097\115\115\105\103\110\101\100\032\116\111\032\116\104\101\032\103\101\110\117\115\032\067\111\108\108\121\098\105\097\032\105\110\032\050\048\050\051\046\010\084\104\101\032\115\112\101\099\105\101\115\032\105\115\032\110\097\116\105\118\101\032\116\111\032\069\117\114\111\112\101\032\097\110\100\032\078\111\114\116\104\032\065\109\101\114\105\099\097\046\032\073\116\032\105\115\032\102\111\117\110\100\032\105\110\032\098\111\116\104\032\099\111\110\105\102\101\114\111\117\115\032\097\110\100\032\100\101\099\105\100\117\111\117\115\032\119\111\111\100\108\097\110\100\115\046\032\073\116\032\104\097\115\032\098\101\101\110\032\099\117\108\116\105\118\097\116\101\100\032\105\110\032\066\114\105\116\097\105\110\044\032\116\104\101\032\078\101\116\104\101\114\108\097\110\100\115\032\097\110\100\032\070\114\097\110\099\101\046\032\073\116\032\105\115\032\097\032\119\105\100\101\108\121\032\099\111\110\115\117\109\101\100\032\101\100\105\098\108\101\032\109\117\115\104\114\111\111\109\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1055,7 +1435,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\049\054\052\048\055\057\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\111\112\114\105\110\117\115\095\099\111\109\097\116\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\050\055\084\049\053\058\052\057\058\049\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\067\111\112\114\105\110\117\115\032\099\111\109\097\116\117\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\115\104\097\103\103\121\032\105\110\107\032\099\097\112\044\032\108\097\119\121\101\114\039\115\032\119\105\103\044\032\111\114\032\115\104\097\103\103\121\032\109\097\110\101\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\117\110\103\117\115\046\032\084\104\101\032\121\111\117\110\103\032\102\114\117\105\116\032\098\111\100\105\101\115\032\102\105\114\115\116\032\097\112\112\101\097\114\032\097\115\032\119\104\105\116\101\032\099\121\108\105\110\100\101\114\115\032\101\109\101\114\103\105\110\103\032\102\114\111\109\032\116\104\101\032\103\114\111\117\110\100\044\032\116\104\101\110\032\116\104\101\032\098\101\108\108\045\115\104\097\112\101\100\032\099\097\112\115\032\111\112\101\110\032\111\117\116\046\032\084\104\101\032\119\104\105\116\101\032\099\097\112\115\032\097\114\101\032\099\111\118\101\114\101\100\032\119\105\116\104\032\115\099\097\108\101\115\044\032\116\104\101\032\111\114\105\103\105\110\032\111\102\032\105\116\115\032\099\111\109\109\111\110\032\110\097\109\101\115\046\032\084\104\101\032\103\105\108\108\115\032\098\101\110\101\097\116\104\032\116\104\101\032\099\097\112\032\097\114\101\032\119\104\105\116\101\044\032\116\104\101\110\032\112\105\110\107\044\032\116\104\101\110\032\116\117\114\110\032\098\108\097\099\107\046\032\084\104\105\115\032\109\117\115\104\114\111\111\109\032\105\115\032\117\110\117\115\117\097\108\032\098\101\099\097\117\115\101\032\105\116\032\119\105\108\108\032\116\117\114\110\032\098\108\097\099\107\032\097\110\100\032\100\105\115\115\111\108\118\101\032\105\116\115\101\108\102\032\105\110\032\097\032\109\097\116\116\101\114\032\111\102\032\104\111\117\114\115\032\097\102\116\101\114\032\098\101\105\110\103\032\112\105\099\107\101\100\032\111\114\032\100\101\112\111\115\105\116\105\110\103\032\115\112\111\114\101\115\046\010\084\104\101\032\109\117\115\104\114\111\111\109\032\105\115\032\111\102\116\101\110\032\115\101\101\110\032\103\114\111\119\105\110\103\032\097\098\111\117\116\032\108\097\119\110\115\044\032\103\114\097\118\101\108\032\114\111\097\100\115\044\032\097\110\100\032\119\097\115\116\101\032\097\114\101\097\115\032\105\110\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\032\097\110\100\032\109\111\114\101\032\108\111\099\097\108\108\121\032\105\110\032\116\104\101\032\083\111\117\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\046\032\087\104\101\110\032\121\111\117\110\103\032\105\116\032\105\115\032\097\110\032\101\120\099\101\108\108\101\110\116\032\101\100\105\098\108\101\032\109\117\115\104\114\111\111\109\032\098\117\116\032\105\116\032\115\112\111\105\108\115\032\113\117\105\099\107\108\121\032\097\110\100\032\114\101\115\101\109\098\108\101\115\032\115\111\109\101\032\112\111\105\115\111\110\111\117\115\032\115\112\101\099\105\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1074,7 +1454,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\050\048\054\055\056\055\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\084\114\097\109\101\116\101\115\095\118\101\114\115\105\099\111\108\111\114\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\050\054\084\049\049\058\049\057\058\049\050\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\084\114\097\109\101\116\101\115\032\118\101\114\115\105\099\111\108\111\114\032\226\128\147\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\067\111\114\105\111\108\117\115\032\118\101\114\115\105\099\111\108\111\114\032\097\110\100\032\080\111\108\121\112\111\114\117\115\032\118\101\114\115\105\099\111\108\111\114\032\226\128\147\032\105\115\032\097\032\099\111\109\109\111\110\032\112\111\108\121\112\111\114\101\032\109\117\115\104\114\111\111\109\032\102\111\117\110\100\032\116\104\114\111\117\103\104\111\117\116\032\116\104\101\032\119\111\114\108\100\046\032\066\101\099\097\117\115\101\032\105\116\115\032\115\104\097\112\101\032\105\115\032\115\105\109\105\108\097\114\032\116\111\032\116\104\097\116\032\111\102\032\097\032\119\105\108\100\032\116\117\114\107\101\121\039\115\032\116\097\105\108\032\102\101\097\116\104\101\114\115\032\119\104\101\110\032\115\112\114\101\097\100\044\032\084\046\032\118\101\114\115\105\099\111\108\111\114\032\105\115\032\109\111\115\116\032\099\111\109\109\111\110\108\121\032\114\101\102\101\114\114\101\100\032\116\111\032\097\115\032\116\117\114\107\101\121\032\116\097\105\108\046\010\010\065\108\116\104\111\117\103\104\032\112\111\108\121\115\097\099\099\104\097\114\105\100\101\045\075\044\032\097\110\032\101\120\116\114\097\099\116\032\111\102\032\084\046\032\118\101\114\115\105\099\111\108\111\114\044\032\105\115\032\097\112\112\114\111\118\101\100\032\105\110\032\074\097\112\097\110\032\097\115\032\097\110\032\097\100\106\117\118\097\110\116\032\116\104\101\114\097\112\121\032\105\110\032\099\097\110\099\101\114\032\116\114\101\097\116\109\101\110\116\044\032\105\116\032\105\115\032\110\111\116\032\097\112\112\114\111\118\101\100\032\105\110\032\116\104\101\032\085\110\105\116\101\100\032\083\116\097\116\101\115\032\102\111\114\032\116\114\101\097\116\109\101\110\116\032\111\102\032\099\097\110\099\101\114\032\111\114\032\097\110\121\032\099\108\105\110\105\099\097\108\032\099\111\110\100\105\116\105\111\110\046\032\069\120\116\114\097\099\116\115\032\111\102\032\116\117\114\107\101\121\032\116\097\105\108\032\111\114\032\116\104\101\032\109\117\115\104\114\111\111\109\032\105\116\115\101\108\102\032\097\114\101\032\099\111\109\109\111\110\108\121\032\109\097\114\107\101\116\101\100\032\097\115\032\097\032\100\105\101\116\097\114\121\032\115\117\112\112\108\101\109\101\110\116\032\102\111\114\032\118\097\114\105\111\117\115\032\104\101\097\108\116\104\032\098\101\110\101\102\105\116\115\044\032\097\110\100\032\113\117\097\108\105\116\121\032\099\097\110\032\118\097\114\121\032\100\117\101\032\116\111\032\105\110\099\111\110\115\105\115\116\101\110\116\032\112\114\111\099\101\115\115\105\110\103\032\097\110\100\032\108\097\098\101\108\105\110\103\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1093,7 +1473,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\054\055\054\051\054\056\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\108\101\117\114\111\116\117\115\095\111\115\116\114\101\097\116\117\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\055\045\051\048\084\048\049\058\053\051\058\048\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\080\108\101\117\114\111\116\117\115\032\111\115\116\114\101\097\116\117\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\111\121\115\116\101\114\032\109\117\115\104\114\111\111\109\044\032\103\114\101\121\032\111\121\115\116\101\114\032\109\117\115\104\114\111\111\109\044\032\111\121\115\116\101\114\032\102\117\110\103\117\115\044\032\104\105\114\097\116\097\107\101\044\032\111\114\032\112\101\097\114\108\032\111\121\115\116\101\114\032\109\117\115\104\114\111\111\109\044\032\105\115\032\097\032\112\111\112\117\108\097\114\032\101\100\105\098\108\101\032\109\117\115\104\114\111\111\109\032\102\111\117\110\100\032\105\110\032\116\101\109\112\101\114\097\116\101\032\097\110\100\032\115\117\098\116\114\111\112\105\099\097\108\032\102\111\114\101\115\116\115\032\097\114\111\117\110\100\032\116\104\101\032\119\111\114\108\100\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1112,7 +1492,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\057\054\056\053\055\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\065\114\109\105\108\108\097\114\105\097\095\109\101\108\108\101\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\049\051\084\048\048\058\050\052\058\049\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\065\114\109\105\108\108\097\114\105\097\032\109\101\108\108\101\097\032\105\115\032\097\110\032\101\100\105\098\108\101\032\098\097\115\105\100\105\111\109\121\099\101\116\101\032\102\117\110\103\117\115\032\105\110\032\116\104\101\032\104\111\110\101\121\032\102\117\110\103\117\115\032\103\101\110\117\115\032\065\114\109\105\108\108\097\114\105\097\046\032\073\116\032\105\115\032\097\032\112\108\097\110\116\032\112\097\116\104\111\103\101\110\032\097\110\100\032\112\097\114\116\032\111\102\032\097\032\099\114\121\112\116\105\099\032\115\112\101\099\105\101\115\032\099\111\109\112\108\101\120\032\111\102\032\099\108\111\115\101\108\121\032\114\101\108\097\116\101\100\032\097\110\100\032\109\111\114\112\104\111\108\111\103\105\099\097\108\108\121\032\115\105\109\105\108\097\114\032\115\112\101\099\105\101\115\046\032\073\116\032\099\097\117\115\101\115\032\065\114\109\105\108\108\097\114\105\097\032\114\111\111\116\032\114\111\116\032\105\110\032\109\097\110\121\032\112\108\097\110\116\032\115\112\101\099\105\101\115\032\097\110\100\032\112\114\111\100\117\099\101\115\032\109\117\115\104\114\111\111\109\115\032\097\114\111\117\110\100\032\116\104\101\032\098\097\115\101\032\111\102\032\116\114\101\101\115\032\105\116\032\104\097\115\032\105\110\102\101\099\116\101\100\046\032\084\104\101\032\115\121\109\112\116\111\109\115\032\111\102\032\105\110\102\101\099\116\105\111\110\032\097\112\112\101\097\114\032\105\110\032\116\104\101\032\099\114\111\119\110\115\032\111\102\032\105\110\102\101\099\116\101\100\032\116\114\101\101\115\032\097\115\032\100\105\115\099\111\108\111\117\114\101\100\032\102\111\108\105\097\103\101\044\032\114\101\100\117\099\101\100\032\103\114\111\119\116\104\044\032\100\105\101\098\097\099\107\032\111\102\032\116\104\101\032\098\114\097\110\099\104\101\115\032\097\110\100\032\100\101\097\116\104\046\032\084\104\101\032\109\121\099\101\108\105\117\109\032\105\115\032\099\097\112\097\098\108\101\032\111\102\032\112\114\111\100\117\099\105\110\103\032\108\105\103\104\116\032\118\105\097\032\098\105\111\108\117\109\105\110\101\115\099\101\110\099\101\046\010\084\104\101\032\109\117\115\104\114\111\111\109\032\105\115\032\119\105\100\101\108\121\032\100\105\115\116\114\105\098\117\116\101\100\032\105\110\032\116\101\109\112\101\114\097\116\101\032\114\101\103\105\111\110\115\032\111\102\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\044\032\117\115\117\097\108\108\121\032\105\110\032\104\101\097\118\121\032\115\111\105\108\115\032\119\105\116\104\032\116\101\109\112\101\114\097\116\117\114\101\115\032\117\110\100\101\114\032\055\057\032\194\176\070\032\040\050\054\032\194\176\067\041\046\032\073\116\032\116\121\112\105\099\097\108\108\121\032\103\114\111\119\115\032\111\110\032\104\097\114\100\119\111\111\100\115\032\098\117\116\032\109\097\121\032\098\101\032\102\111\117\110\100\032\097\114\111\117\110\100\032\097\110\100\032\111\110\032\108\105\118\105\110\103\032\097\110\100\032\100\101\097\100\032\119\111\111\100\032\111\114\032\105\110\032\111\112\101\110\032\097\114\101\097\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1131,7 +1511,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\051\050\049\054\056\055\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\070\111\109\105\116\111\112\115\105\115\095\098\101\116\117\108\105\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\055\045\048\056\084\049\057\058\053\054\058\051\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\049\053\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\049\050\090", text = "\070\111\109\105\116\111\112\115\105\115\032\098\101\116\117\108\105\110\097\032\040\112\114\101\118\105\111\117\115\108\121\032\080\105\112\116\111\112\111\114\117\115\032\098\101\116\117\108\105\110\117\115\041\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\098\105\114\099\104\032\112\111\108\121\112\111\114\101\044\032\098\105\114\099\104\032\098\114\097\099\107\101\116\044\032\111\114\032\114\097\122\111\114\032\115\116\114\111\112\044\032\105\115\032\097\032\099\111\109\109\111\110\032\098\114\097\099\107\101\116\032\102\117\110\103\117\115\032\097\110\100\044\032\097\115\032\116\104\101\032\110\097\109\101\032\115\117\103\103\101\115\116\115\044\032\103\114\111\119\115\032\097\108\109\111\115\116\032\101\120\099\108\117\115\105\118\101\108\121\032\111\110\032\098\105\114\099\104\032\116\114\101\101\115\046\032\084\104\101\032\098\114\097\099\107\101\116\115\032\098\117\114\115\116\032\111\117\116\032\102\114\111\109\032\116\104\101\032\098\097\114\107\032\111\102\032\116\104\101\032\116\114\101\101\044\032\097\110\100\032\116\104\101\115\101\032\102\114\117\105\116\032\098\111\100\105\101\115\032\099\097\110\032\108\097\115\116\032\102\111\114\032\109\111\114\101\032\116\104\097\110\032\097\032\121\101\097\114\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1150,7 +1530,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\051\055\057\054\054\053\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\101\109\110\097\095\109\105\110\111\114\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\048\056\084\048\048\058\050\051\058\049\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\076\101\109\110\097\032\109\105\110\111\114\044\032\116\104\101\032\099\111\109\109\111\110\032\100\117\099\107\119\101\101\100\032\111\114\032\108\101\115\115\101\114\032\100\117\099\107\119\101\101\100\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\097\113\117\097\116\105\099\032\102\114\101\115\104\119\097\116\101\114\032\112\108\097\110\116\032\105\110\032\116\104\101\032\115\117\098\102\097\109\105\108\121\032\076\101\109\110\111\105\100\101\097\101\032\111\102\032\116\104\101\032\097\114\117\109\032\102\097\109\105\108\121\032\065\114\097\099\101\097\101\046\032\076\046\032\109\105\110\111\114\032\105\115\032\117\115\101\100\032\097\115\032\097\110\105\109\097\108\032\102\111\100\100\101\114\044\032\098\105\111\114\101\109\101\100\105\097\116\111\114\044\032\102\111\114\032\119\097\115\116\101\119\097\116\101\114\032\110\117\116\114\105\101\110\116\032\114\101\099\111\118\101\114\121\044\032\097\110\100\032\111\116\104\101\114\032\097\112\112\108\105\099\097\116\105\111\110\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1169,7 +1549,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\049\053\055\053\052\051\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\078\117\112\104\097\114\095\108\117\116\101\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\054\045\050\056\084\050\048\058\052\053\058\050\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\078\117\112\104\097\114\032\108\117\116\101\097\044\032\116\104\101\032\121\101\108\108\111\119\032\119\097\116\101\114\045\108\105\108\121\044\032\105\115\032\097\110\032\097\113\117\097\116\105\099\032\112\108\097\110\116\032\111\102\032\116\104\101\032\102\097\109\105\108\121\032\078\121\109\112\104\097\101\097\099\101\097\101\044\032\110\097\116\105\118\101\032\116\111\032\110\111\114\116\104\101\114\110\032\116\101\109\112\101\114\097\116\101\032\097\110\100\032\115\111\109\101\032\115\117\098\116\114\111\112\105\099\097\108\032\114\101\103\105\111\110\115\032\111\102\032\069\117\114\111\112\101\044\032\110\111\114\116\104\119\101\115\116\032\065\102\114\105\099\097\044\032\097\110\100\032\119\101\115\116\101\114\110\032\065\115\105\097\046\032\084\104\105\115\032\115\112\101\099\105\101\115\032\119\097\115\032\117\115\101\100\032\097\115\032\097\032\102\111\111\100\032\115\111\117\114\099\101\032\097\110\100\032\105\110\032\109\101\100\105\099\105\110\097\108\032\112\114\097\099\116\105\099\101\115\032\102\114\111\109\032\112\114\101\104\105\115\116\111\114\105\099\032\116\105\109\101\115\032\119\105\116\104\032\112\111\116\101\110\116\105\097\108\032\114\101\115\101\097\114\099\104\032\097\110\100\032\109\101\100\105\099\097\108\032\097\112\112\108\105\099\097\116\105\111\110\115\032\103\111\105\110\103\032\102\111\114\119\097\114\100\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1184,11 +1564,11 @@ return { title = "\078\121\109\112\104\097\101\097\032\097\108\098\097", url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\105\107\105\047\078\121\109\112\104\097\101\097\095\097\108\098\097", pageid = 963217, - revision = 1371991733, - revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\049\057\057\049\055\051\051", + revision = 1383612328, + revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\056\051\054\049\050\051\050\056", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\078\121\109\112\104\097\101\097\095\097\108\098\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", - revision_timestamp = "\050\048\050\054\045\048\056\045\050\057\084\049\057\058\049\051\058\050\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + revision_timestamp = "\050\048\050\054\045\049\049\045\048\051\084\049\056\058\048\055\058\049\051\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\078\121\109\112\104\097\101\097\032\097\108\098\097\032\226\128\147\032\099\111\109\109\111\110\108\121\032\099\097\108\108\101\100\032\116\104\101\032\119\104\105\116\101\032\119\097\116\101\114\032\108\105\108\121\044\032\069\117\114\111\112\101\097\110\032\119\104\105\116\101\032\119\097\116\101\114\032\108\105\108\121\044\032\111\114\032\119\104\105\116\101\032\110\101\110\117\112\104\097\114\032\040\032\078\069\078\045\121\117\117\045\102\097\114\041\032\226\128\147\032\105\115\032\097\110\032\097\113\117\097\116\105\099\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\078\121\109\112\104\097\101\097\099\101\097\101\046\032\073\116\032\105\115\032\110\097\116\105\118\101\032\116\111\032\078\111\114\116\104\032\065\102\114\105\099\097\044\032\116\101\109\112\101\114\097\116\101\032\065\115\105\097\044\032\069\117\114\111\112\101\044\032\097\110\100\032\116\114\111\112\105\099\097\108\032\065\115\105\097\032\040\074\097\109\109\117\032\097\110\100\032\075\097\115\104\109\105\114\041\046\010\083\105\110\099\101\032\078\121\109\112\104\097\101\097\032\097\108\098\097\032\105\115\032\097\110\032\097\113\117\097\116\105\099\032\112\108\097\110\116\044\032\105\116\115\032\115\112\101\099\105\097\108\105\122\101\100\032\116\114\105\099\104\111\109\101\115\032\097\114\101\032\104\121\100\114\111\112\111\116\101\115\044\032\102\111\114\109\101\100\032\097\116\032\097\110\032\097\098\097\120\105\097\108\032\115\117\114\102\097\099\101\032\111\102\032\116\104\101\032\121\111\117\110\103\032\108\101\097\102\032\097\110\100\032\112\097\099\107\101\100\032\116\105\103\104\116\108\121\032\105\110\032\116\104\101\032\114\111\115\101\116\116\101\032\097\116\032\116\104\101\032\114\104\105\122\111\109\101\039\115\032\102\108\097\116\116\101\110\101\100\032\097\112\101\120\046\032\084\104\101\032\114\104\105\122\111\109\101\115\032\099\111\110\116\097\105\110\032\104\105\103\104\032\097\109\111\117\110\116\115\032\111\102\032\099\097\114\098\111\104\121\100\114\097\116\101\032\097\110\100\032\112\114\111\116\101\105\110\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1207,7 +1587,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\049\052\055\051\050\050\049\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\082\097\110\117\110\099\117\108\117\115\095\097\113\117\097\116\105\108\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\049\048\045\048\050\084\050\051\058\053\053\058\053\054\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\082\097\110\117\110\099\117\108\117\115\032\097\113\117\097\116\105\108\105\115\044\032\116\104\101\032\099\111\109\109\111\110\032\119\097\116\101\114\045\099\114\111\119\102\111\111\116\032\111\114\032\119\104\105\116\101\032\119\097\116\101\114\045\099\114\111\119\102\111\111\116\044\032\105\115\032\097\032\112\108\097\110\116\032\115\112\101\099\105\101\115\032\111\102\032\116\104\101\032\103\101\110\117\115\032\082\097\110\117\110\099\117\108\117\115\044\032\110\097\116\105\118\101\032\116\104\114\111\117\103\104\111\117\116\032\109\111\115\116\032\111\102\032\069\117\114\111\112\101\032\097\110\100\032\119\101\115\116\101\114\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\097\110\100\032\097\108\115\111\032\110\111\114\116\104\119\101\115\116\032\065\102\114\105\099\097\046\010\084\104\105\115\032\105\115\032\097\110\032\097\113\117\097\116\105\099\032\112\108\097\110\116\044\032\103\114\111\119\105\110\103\032\105\110\032\109\097\116\115\032\111\110\032\116\104\101\032\115\117\114\102\097\099\101\032\111\102\032\119\097\116\101\114\046\032\073\116\032\104\097\115\032\098\114\097\110\099\104\105\110\103\032\116\104\114\101\097\100\045\108\105\107\101\032\117\110\100\101\114\119\097\116\101\114\032\108\101\097\118\101\115\032\097\110\100\032\116\111\111\116\104\101\100\032\102\108\111\097\116\101\114\032\108\101\097\118\101\115\046\032\073\110\032\102\097\115\116\032\102\108\111\119\105\110\103\032\119\097\116\101\114\032\116\104\101\032\102\108\111\097\116\101\114\115\032\109\097\121\032\110\111\116\032\098\101\032\103\114\111\119\110\046\032\084\104\101\032\102\108\111\119\101\114\115\032\097\114\101\032\119\104\105\116\101\032\112\101\116\097\108\101\100\032\119\105\116\104\032\121\101\108\108\111\119\032\099\101\110\116\114\101\115\032\097\110\100\032\097\114\101\032\104\101\108\100\032\097\032\099\101\110\116\105\109\101\116\114\101\032\111\114\032\116\119\111\032\097\098\111\118\101\032\116\104\101\032\119\097\116\101\114\046\032\084\104\101\032\102\108\111\097\116\101\114\032\108\101\097\118\101\115\032\097\114\101\032\117\115\101\100\032\097\115\032\112\114\111\112\115\032\102\111\114\032\116\104\101\032\102\108\111\119\101\114\115\032\097\110\100\032\097\114\101\032\103\114\111\119\110\032\097\116\032\116\104\101\032\115\097\109\101\032\116\105\109\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1226,7 +1606,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\049\051\049\055\051\053\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\077\101\110\116\104\097\095\097\113\117\097\116\105\099\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\052\045\050\055\084\048\054\058\051\056\058\050\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\077\101\110\116\104\097\032\097\113\117\097\116\105\099\097\032\040\119\097\116\101\114\032\109\105\110\116\059\032\115\121\110\046\032\077\101\110\116\104\097\032\104\105\114\115\117\116\097\032\072\117\100\115\046\041\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\109\105\110\116\032\102\097\109\105\108\121\044\032\076\097\109\105\097\099\101\097\101\046\032\032\073\116\032\103\114\111\119\115\032\105\110\032\109\111\105\115\116\032\112\108\097\099\101\115\032\097\110\100\032\105\115\032\110\097\116\105\118\101\032\116\111\032\109\117\099\104\032\111\102\032\069\117\114\111\112\101\044\032\110\111\114\116\104\119\101\115\116\032\065\102\114\105\099\097\032\097\110\100\032\115\111\117\116\104\119\101\115\116\032\065\115\105\097\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1245,7 +1625,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\053\053\055\049\050\048\050\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\077\121\111\115\111\116\105\115\095\115\099\111\114\112\105\111\105\100\101\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\053\045\050\051\084\049\051\058\049\053\058\049\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\077\121\111\115\111\116\105\115\032\115\099\111\114\112\105\111\105\100\101\115\032\040\115\121\110\046\032\077\121\111\115\111\116\105\115\032\112\097\108\117\115\116\114\105\115\041\044\032\116\104\101\032\116\114\117\101\032\102\111\114\103\101\116\045\109\101\045\110\111\116\032\111\114\032\119\097\116\101\114\032\102\111\114\103\101\116\045\109\101\045\110\111\116\044\032\105\115\032\097\032\102\111\114\103\101\116\045\109\101\045\110\111\116\032\112\108\097\110\116\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\098\111\114\097\103\101\032\102\097\109\105\108\121\044\032\066\111\114\097\103\105\110\097\099\101\097\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1264,7 +1644,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\054\053\052\048\052\050\052", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\086\101\114\111\110\105\099\097\095\098\101\099\099\097\098\117\110\103\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\050\052\084\050\048\058\048\057\058\050\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\086\101\114\111\110\105\099\097\032\098\101\099\099\097\098\117\110\103\097\044\032\116\104\101\032\069\117\114\111\112\101\097\110\032\115\112\101\101\100\119\101\108\108\032\111\114\032\098\114\111\111\107\108\105\109\101\044\032\105\115\032\097\032\115\117\099\099\117\108\101\110\116\032\104\101\114\098\097\099\101\111\117\115\032\112\101\114\101\110\110\105\097\108\032\112\108\097\110\116\032\098\101\108\111\110\103\105\110\103\032\116\111\032\116\104\101\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\102\097\109\105\108\121\032\080\108\097\110\116\097\103\105\110\097\099\101\097\101\046\032\073\116\032\103\114\111\119\115\032\111\110\032\116\104\101\032\032\109\097\114\103\105\110\115\032\111\102\032\098\114\111\111\107\115\032\097\110\100\032\100\105\116\099\104\101\115\032\105\110\032\069\117\114\111\112\101\044\032\078\111\114\116\104\032\065\102\114\105\099\097\044\032\097\110\100\032\110\111\114\116\104\032\097\110\100\032\119\101\115\116\101\114\110\032\065\115\105\097\046\032\073\116\032\099\097\110\032\098\101\032\102\111\117\110\100\032\111\110\032\111\116\104\101\114\032\099\111\110\116\105\110\101\110\116\115\032\097\115\032\097\110\032\105\110\116\114\111\100\117\099\101\100\032\115\112\101\099\105\101\115\046\032\073\116\032\104\097\115\032\115\109\111\111\116\104\032\115\112\114\101\097\100\105\110\103\032\115\117\099\099\117\108\101\110\116\032\098\114\097\110\099\104\101\115\032\116\104\097\116\032\097\114\101\032\111\102\116\101\110\032\114\101\100\100\105\115\104\044\032\098\108\117\110\116\032\111\098\108\111\110\103\032\102\105\110\101\108\121\032\115\101\114\114\097\116\101\032\108\101\097\118\101\115\032\105\110\032\111\112\112\111\115\105\116\101\032\112\097\105\114\115\032\099\108\111\115\101\032\116\111\032\116\104\101\032\115\116\101\109\044\032\097\110\100\032\115\109\097\108\108\032\098\114\105\103\104\116\032\098\108\117\101\032\111\114\032\112\105\110\107\032\102\108\111\119\101\114\115\032\119\105\116\104\032\102\111\117\114\032\112\101\116\097\108\115\046\010\084\104\101\032\115\112\101\099\105\102\105\099\032\110\097\109\101\032\098\101\099\099\097\098\117\110\103\097\032\104\097\115\032\097\032\099\111\109\109\111\110\032\111\114\105\103\105\110\032\119\105\116\104\032\098\195\164\099\107\101\098\117\110\103\097\044\032\097\110\032\097\114\099\104\097\105\099\032\083\119\101\100\105\115\104\032\110\097\109\101\032\102\111\114\032\116\104\101\032\112\108\097\110\116\032\117\115\101\100\032\097\116\032\116\104\101\032\116\105\109\101\032\119\104\101\110\032\105\116\032\119\097\115\032\100\101\115\099\114\105\098\101\100\032\098\121\032\067\097\114\108\032\076\105\110\110\097\101\117\115\046\032\078\097\109\101\115\032\119\105\116\104\032\116\104\101\032\115\097\109\101\032\111\114\105\103\105\110\032\097\114\101\032\115\116\105\108\108\032\105\110\032\117\115\101\032\105\110\032\111\116\104\101\114\032\069\117\114\111\112\101\097\110\032\108\097\110\103\117\097\103\101\115\044\032\105\110\099\108\117\100\105\110\103\032\098\101\101\107\112\117\110\103\101\032\105\110\032\068\117\116\099\104\044\032\097\110\100\032\098\097\099\104\098\117\110\103\101\032\105\110\032\071\101\114\109\097\110\032\040\109\101\097\110\105\110\103\032\039\098\114\111\111\107\032\098\117\110\099\104\039\044\032\111\114\032\039\098\114\111\111\107\032\112\111\117\099\104\039\041\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1283,7 +1663,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\050\057\057\049\049\055\053\049\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\097\108\108\105\116\114\105\099\104\101\095\115\116\097\103\110\097\108\105\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\048\055\045\048\054\084\049\055\058\050\057\058\049\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\067\097\108\108\105\116\114\105\099\104\101\032\115\116\097\103\110\097\108\105\115\032\105\115\032\097\032\112\101\114\101\110\110\105\097\108\032\097\113\117\097\116\105\099\032\118\097\115\099\117\108\097\114\032\112\108\097\110\116\032\115\112\101\099\105\101\115\046\032\065\108\115\111\032\107\110\111\119\110\032\097\115\032\112\111\110\100\032\119\097\116\101\114\045\115\116\097\114\119\111\114\116\044\032\067\046\032\115\116\097\103\110\097\108\105\115\044\032\109\097\121\032\116\104\114\105\118\101\032\105\110\032\097\032\118\097\114\105\101\116\121\032\111\102\032\097\113\117\097\116\105\099\032\097\110\100\032\115\117\098\097\113\117\097\116\105\099\032\104\097\098\105\116\097\116\115\044\032\115\112\101\099\105\097\108\108\121\032\116\104\111\115\101\032\101\120\104\105\098\105\116\105\110\103\032\115\108\111\119\108\121\032\109\111\118\105\110\103\032\116\111\032\110\111\110\045\109\111\118\105\110\103\032\119\097\116\101\114\046\032\065\108\116\104\111\117\103\104\032\067\046\032\115\116\097\103\110\097\108\105\115\032\100\111\101\115\032\110\111\116\032\112\111\115\101\032\097\032\116\104\114\101\097\116\032\116\111\032\104\117\109\097\110\115\044\032\105\116\115\032\114\101\112\114\111\100\117\099\116\105\118\101\032\114\097\116\101\032\109\097\121\032\112\111\115\101\032\097\032\116\104\114\101\097\116\032\116\111\032\110\097\116\105\118\101\032\118\101\103\101\116\097\116\105\111\110\032\032\105\110\032\097\114\101\097\115\032\119\104\101\114\101\032\105\116\032\104\097\115\032\098\101\101\110\032\105\110\116\114\111\100\117\099\101\100\044\032\097\115\032\116\104\101\032\114\101\115\117\108\116\105\110\103\032\100\101\110\115\101\032\118\101\103\101\116\097\116\105\118\101\032\109\097\116\115\032\102\114\101\113\117\101\110\116\108\121\032\111\117\116\045\099\111\109\112\101\116\101\032\110\097\116\105\118\101\032\115\112\101\099\105\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1302,7 +1682,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\054\051\049\055\055\055\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\101\114\097\116\111\112\104\121\108\108\117\109\095\100\101\109\101\114\115\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\055\045\050\055\084\048\057\058\049\057\058\053\049\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\067\101\114\097\116\111\112\104\121\108\108\117\109\032\100\101\109\101\114\115\117\109\044\032\099\111\109\109\111\110\108\121\032\099\097\108\108\101\100\032\104\111\114\110\119\111\114\116\032\111\114\032\099\111\111\110\116\097\105\108\044\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\102\108\111\119\101\114\105\110\103\032\112\108\097\110\116\032\105\110\032\116\104\101\032\103\101\110\117\115\032\067\101\114\097\116\111\112\104\121\108\108\117\109\046\032\067\101\114\097\116\111\112\104\121\108\108\117\109\032\100\101\109\101\114\115\117\109\032\105\115\032\097\032\115\117\098\109\101\114\103\101\100\044\032\102\114\101\101\045\102\108\111\097\116\105\110\103\032\097\113\117\097\116\105\099\032\112\108\097\110\116\044\032\119\105\116\104\032\097\032\099\111\115\109\111\112\111\108\105\116\097\110\032\100\105\115\116\114\105\098\117\116\105\111\110\044\032\110\097\116\105\118\101\032\116\111\032\097\108\108\032\099\111\110\116\105\110\101\110\116\115\032\101\120\099\101\112\116\032\065\110\116\097\114\099\116\105\099\097\046\032\073\116\032\105\115\032\097\032\104\097\114\109\102\117\108\032\119\101\101\100\032\105\110\116\114\111\100\117\099\101\100\032\105\110\032\078\101\119\032\090\101\097\108\097\110\100\046\032\073\116\032\105\115\032\097\108\115\111\032\097\032\112\111\112\117\108\097\114\032\097\113\117\097\114\105\117\109\032\112\108\097\110\116\046\032\073\116\115\032\103\101\110\111\109\101\032\104\097\115\032\098\101\101\110\032\115\101\113\117\101\110\099\101\100\032\116\111\032\115\116\117\100\121\032\097\110\103\105\111\115\112\101\114\109\032\101\118\111\108\117\116\105\111\110\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1321,7 +1701,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\049\053\048\053\048\055\048\051", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\111\116\097\109\111\103\101\116\111\110\095\110\097\116\097\110\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\049\048\045\048\052\084\049\054\058\049\049\058\052\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\050\055\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\052\055\057\090", text = "\080\111\116\097\109\111\103\101\116\111\110\032\110\097\116\097\110\115\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\098\114\111\097\100\045\108\101\097\118\101\100\032\112\111\110\100\119\101\101\100\044\032\102\108\111\097\116\105\110\103\032\112\111\110\100\119\101\101\100\044\032\111\114\032\102\108\111\097\116\105\110\103\045\108\101\097\102\032\112\111\110\100\119\101\101\100\044\032\105\115\032\097\110\032\097\113\117\097\116\105\099\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\103\101\110\117\115\032\080\111\116\097\109\111\103\101\116\111\110\032\110\097\116\105\118\101\032\116\111\032\113\117\105\101\116\032\111\114\032\115\108\111\119\045\102\108\111\119\105\110\103\032\102\114\101\115\104\119\097\116\101\114\032\104\097\098\105\116\097\116\115\032\116\104\114\111\117\103\104\111\117\116\032\116\104\101\032\072\111\108\097\114\099\116\105\099\032\075\105\110\103\100\111\109\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1340,7 +1720,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\051\057\050\048\056\053\050", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\108\097\100\111\110\105\097\095\114\097\110\103\105\102\101\114\105\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\057\045\048\056\084\049\057\058\049\054\058\048\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\067\108\097\100\111\110\105\097\032\114\097\110\103\105\102\101\114\105\110\097\044\032\097\108\115\111\032\107\110\111\119\110\032\097\115\032\114\101\105\110\100\101\101\114\032\099\117\112\032\108\105\099\104\101\110\044\032\114\101\105\110\100\101\101\114\032\108\105\099\104\101\110\032\040\099\102\046\032\083\119\046\032\114\101\110\108\097\118\041\032\111\114\032\103\114\101\121\032\114\101\105\110\100\101\101\114\032\108\105\099\104\101\110\044\032\105\115\032\097\032\108\105\103\104\116\045\099\111\108\111\117\114\101\100\032\102\114\117\116\105\099\111\115\101\044\032\099\117\112\032\108\105\099\104\101\110\032\115\112\101\099\105\101\115\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\067\108\097\100\111\110\105\097\099\101\097\101\046\032\073\116\032\103\114\111\119\115\032\105\110\032\098\111\116\104\032\104\111\116\032\097\110\100\032\099\111\108\100\032\099\108\105\109\097\116\101\115\032\105\110\032\119\101\108\108\045\100\114\097\105\110\101\100\044\032\111\112\101\110\032\101\110\118\105\114\111\110\109\101\110\116\115\046\032\070\111\117\110\100\032\112\114\105\109\097\114\105\108\121\032\105\110\032\097\114\101\097\115\032\111\102\032\097\108\112\105\110\101\032\116\117\110\100\114\097\044\032\105\116\032\105\115\032\101\120\116\114\101\109\101\108\121\032\099\111\108\100\045\104\097\114\100\121\046\010\079\116\104\101\114\032\099\111\109\109\111\110\032\110\097\109\101\115\032\105\110\099\108\117\100\101\032\114\101\105\110\100\101\101\114\032\109\111\115\115\044\032\100\101\101\114\032\109\111\115\115\044\032\097\110\100\032\099\097\114\105\098\111\117\032\109\111\115\115\044\032\098\117\116\032\116\104\101\115\101\032\110\097\109\101\115\032\099\097\110\032\098\101\032\109\105\115\108\101\097\100\105\110\103\032\115\105\110\099\101\032\105\116\032\105\115\044\032\116\104\111\117\103\104\032\115\111\109\101\119\104\097\116\032\109\111\115\115\045\108\105\107\101\032\105\110\032\097\112\112\101\097\114\097\110\099\101\044\032\110\111\116\032\097\032\109\111\115\115\046\032\065\115\032\116\104\101\032\099\111\109\109\111\110\032\110\097\109\101\115\032\115\117\103\103\101\115\116\044\032\114\101\105\110\100\101\101\114\032\108\105\099\104\101\110\032\105\115\032\097\110\032\105\109\112\111\114\116\097\110\116\032\102\111\111\100\032\102\111\114\032\114\101\105\110\100\101\101\114\032\040\099\097\114\105\098\111\117\041\044\032\097\110\100\032\104\097\115\032\101\099\111\110\111\109\105\099\032\105\109\112\111\114\116\097\110\099\101\032\097\115\032\097\032\114\101\115\117\108\116\046\032\083\121\110\111\110\121\109\115\032\105\110\099\108\117\100\101\032\067\108\097\100\105\110\097\032\114\097\110\103\105\102\101\114\105\110\097\032\097\110\100\032\076\105\099\104\101\110\032\114\097\110\103\105\102\101\114\105\110\117\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1359,7 +1739,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\054\056\052\049\049\048\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\079\097\107\109\111\115\115\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\052\045\048\051\084\048\055\058\049\055\058\049\051\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\079\097\107\109\111\115\115\032\040\115\099\105\101\110\116\105\102\105\099\032\110\097\109\101\032\069\118\101\114\110\105\097\032\112\114\117\110\097\115\116\114\105\041\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\108\105\099\104\101\110\046\032\073\116\032\099\097\110\032\098\101\032\102\111\117\110\100\032\105\110\032\109\097\110\121\032\109\111\117\110\116\097\105\110\111\117\115\032\116\101\109\112\101\114\097\116\101\032\102\111\114\101\115\116\115\032\116\104\114\111\117\103\104\111\117\116\032\116\104\101\032\078\111\114\116\104\101\114\110\032\072\101\109\105\115\112\104\101\114\101\046\032\079\097\107\109\111\115\115\032\103\114\111\119\115\032\112\114\105\109\097\114\105\108\121\032\111\110\032\116\104\101\032\116\114\117\110\107\032\097\110\100\032\098\114\097\110\099\104\101\115\032\111\102\032\111\097\107\032\116\114\101\101\115\044\032\098\117\116\032\105\115\032\097\108\115\111\032\099\111\109\109\111\110\108\121\032\102\111\117\110\100\032\111\110\032\116\104\101\032\098\097\114\107\032\111\102\032\111\116\104\101\114\032\100\101\099\105\100\117\111\117\115\032\116\114\101\101\115\032\097\110\100\032\099\111\110\105\102\101\114\115\032\115\117\099\104\032\097\115\032\102\105\114\032\097\110\100\032\112\105\110\101\046\032\084\104\101\032\116\104\097\108\108\105\032\111\102\032\111\097\107\109\111\115\115\032\097\114\101\032\115\104\111\114\116\032\097\110\100\032\098\117\115\104\121\044\032\097\110\100\032\103\114\111\119\032\116\111\103\101\116\104\101\114\032\111\110\032\098\097\114\107\032\116\111\032\102\111\114\109\032\108\097\114\103\101\032\099\108\117\109\112\115\046\032\079\097\107\109\111\115\115\032\116\104\097\108\108\117\115\032\105\115\032\102\108\097\116\032\097\110\100\032\115\116\114\097\112\045\108\105\107\101\046\032\084\104\101\121\032\097\114\101\032\097\108\115\111\032\104\105\103\104\108\121\032\098\114\097\110\099\104\101\100\044\032\114\101\115\101\109\098\108\105\110\103\032\116\104\101\032\102\111\114\109\032\111\102\032\097\110\116\108\101\114\115\046\032\084\104\101\032\099\111\108\111\117\114\032\111\102\032\111\097\107\109\111\115\115\032\114\097\110\103\101\115\032\102\114\111\109\032\103\114\101\101\110\032\116\111\032\097\032\103\114\101\101\110\105\115\104\045\119\104\105\116\101\032\119\104\101\110\032\100\114\121\044\032\097\110\100\032\100\097\114\107\032\111\108\105\118\101\045\103\114\101\101\110\032\116\111\032\121\101\108\108\111\119\045\103\114\101\101\110\032\119\104\101\110\032\119\101\116\046\032\084\104\101\032\116\101\120\116\117\114\101\032\111\102\032\116\104\101\032\116\104\097\108\108\105\032\105\115\032\114\111\117\103\104\032\119\104\101\110\032\100\114\121\032\097\110\100\032\114\117\098\098\101\114\121\032\119\104\101\110\032\119\101\116\046\032\073\116\032\105\115\032\117\115\101\100\032\101\120\116\101\110\115\105\118\101\108\121\032\105\110\032\109\111\100\101\114\110\032\112\101\114\102\117\109\101\114\121\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1378,7 +1758,7 @@ return { revision_url = nil, history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\085\115\110\101\097\095\115\117\098\102\108\111\114\105\100\097\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = nil, - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\078\111\032\069\110\103\108\105\115\104\032\087\105\107\105\112\101\100\105\097\032\115\112\101\099\105\101\115\032\097\114\116\105\099\108\101\032\119\097\115\032\097\118\097\105\108\097\098\108\101\032\102\111\114\032\085\115\110\101\097\032\115\117\098\102\108\111\114\105\100\097\110\097\032\119\104\101\110\032\116\104\105\115\032\098\117\110\100\108\101\032\119\097\115\032\114\101\116\114\105\101\118\101\100\046\032\084\104\105\115\032\105\115\032\097\110\032\111\114\105\103\105\110\097\108\032\097\118\097\105\108\097\098\105\108\105\116\121\032\110\111\116\101\044\032\110\111\116\032\097\032\087\105\107\105\112\101\100\105\097\032\101\120\099\101\114\112\116\046\032\078\111\032\098\114\111\097\100\101\114\032\116\097\120\111\110\032\111\114\032\115\105\109\105\108\097\114\108\121\032\110\097\109\101\100\032\115\112\101\099\105\101\115\032\104\097\115\032\098\101\101\110\032\115\117\098\115\116\105\116\117\116\101\100\046\032\084\104\101\032\103\097\109\101\032\099\097\116\097\108\111\103\117\101\032\105\115\032\110\111\116\032\101\118\105\100\101\110\099\101\032\102\111\114\032\114\101\097\108\045\119\111\114\108\100\032\101\099\111\108\111\103\105\099\097\108\044\032\102\111\111\100\032\111\114\032\109\101\100\105\099\105\110\097\108\032\101\102\102\101\099\116\115\046", kind = "\111\114\105\103\105\110\097\108\095\110\111\116\101\115", excerpt = false, @@ -1397,7 +1777,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\057\054\050\053\055\053\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\082\104\105\122\111\099\097\114\112\111\110\095\103\101\111\103\114\097\112\104\105\099\117\109\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\049\054\084\048\051\058\050\055\058\051\055\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\082\104\105\122\111\099\097\114\112\111\110\032\103\101\111\103\114\097\112\104\105\099\117\109\032\040\116\104\101\032\109\097\112\032\108\105\099\104\101\110\032\111\114\032\121\101\108\108\111\119\032\109\097\112\032\108\105\099\104\101\110\041\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\108\105\099\104\101\110\044\032\119\104\105\099\104\032\103\114\111\119\115\032\111\110\032\114\111\099\107\115\032\105\110\032\109\111\117\110\116\097\105\110\111\117\115\032\097\114\101\097\115\032\111\102\032\108\111\119\032\097\105\114\032\112\111\108\108\117\116\105\111\110\046\032\069\097\099\104\032\108\105\099\104\101\110\032\105\115\032\097\032\102\108\097\116\032\112\097\116\099\104\032\098\111\114\100\101\114\101\100\032\098\121\032\097\032\098\108\097\099\107\032\108\105\110\101\032\111\102\032\102\117\110\103\097\108\032\104\121\112\104\097\101\046\032\084\104\101\115\101\032\112\097\116\099\104\101\115\032\103\114\111\119\032\097\100\106\097\099\101\110\116\032\116\111\032\101\097\099\104\032\111\116\104\101\114\044\032\108\101\097\100\105\110\103\032\116\111\032\116\104\101\032\097\112\112\101\097\114\097\110\099\101\032\111\102\032\097\032\109\097\112\032\111\114\032\097\032\112\097\116\099\104\119\111\114\107\032\102\105\101\108\100\046\010\087\104\101\110\032\099\105\114\099\117\108\097\114\044\032\111\114\032\114\111\117\103\104\108\121\032\099\105\114\099\117\108\097\114\044\032\116\104\101\032\100\105\097\109\101\116\101\114\032\111\102\032\116\104\105\115\032\108\105\099\104\101\110\032\115\112\101\099\105\101\115\032\104\097\115\032\098\101\101\110\032\119\105\100\101\108\121\032\117\115\101\100\032\116\111\032\104\101\108\112\032\100\101\116\101\114\109\105\110\105\110\103\032\116\104\101\032\114\101\108\097\116\105\118\101\032\097\103\101\032\111\102\032\100\101\112\111\115\105\116\115\044\032\101\046\103\046\032\109\111\114\097\105\110\101\032\115\121\115\116\101\109\115\044\032\116\104\117\115\032\114\101\118\101\097\108\105\110\103\032\101\118\105\100\101\110\099\101\032\111\102\032\103\108\097\099\105\097\108\032\097\100\118\097\110\099\101\115\046\032\084\104\101\032\112\114\111\099\101\115\115\032\105\115\032\116\101\114\109\101\100\032\108\105\099\104\101\110\111\109\101\116\114\121\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1416,7 +1796,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\051\048\052\055\053\056\053\054", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\101\108\116\105\103\101\114\097\095\099\097\110\105\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\053\045\049\050\045\051\049\084\049\055\058\048\057\058\048\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\080\101\108\116\105\103\101\114\097\032\099\097\110\105\110\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\100\111\103\032\108\105\099\104\101\110\044\032\105\115\032\097\032\119\105\100\101\108\121\032\100\105\115\116\114\105\098\117\116\101\100\032\115\112\101\099\105\101\115\032\111\102\032\102\111\108\105\111\115\101\032\108\105\099\104\101\110\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\080\101\108\116\105\103\101\114\097\099\101\097\101\046\032\073\116\032\119\097\115\032\111\114\105\103\105\110\097\108\108\121\032\100\101\115\099\114\105\098\101\100\032\098\121\032\067\097\114\108\032\076\105\110\110\097\101\117\115\032\105\110\032\104\105\115\032\049\055\053\051\032\119\111\114\107\032\083\112\101\099\105\101\115\032\080\108\097\110\116\097\114\117\109\046\032\071\101\114\109\097\110\032\098\111\116\097\110\105\115\116\032\067\097\114\108\032\076\117\100\119\105\103\032\087\105\108\108\100\101\110\111\119\032\116\114\097\110\115\102\101\114\114\101\100\032\105\116\032\116\111\032\116\104\101\032\103\101\110\117\115\032\080\101\108\116\105\103\101\114\097\032\105\110\032\049\055\056\055\046\032\084\104\105\115\032\115\112\101\099\105\101\115\032\105\115\032\099\117\114\114\101\110\116\108\121\032\117\110\100\101\114\103\111\105\110\103\032\114\101\115\101\097\114\099\104\032\097\115\032\105\116\032\105\115\032\108\105\107\101\108\121\032\109\117\108\116\105\112\108\101\032\115\112\101\099\105\101\115\032\117\110\100\101\114\032\111\110\101\032\117\110\105\116\101\100\032\110\097\109\101\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1435,7 +1815,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\055\057\048\054\051\050\053\048", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\076\111\098\097\114\105\097\095\112\117\108\109\111\110\097\114\105\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\049\048\045\048\055\084\049\056\058\051\048\058\049\057\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\076\111\098\097\114\105\097\032\112\117\108\109\111\110\097\114\105\097\032\105\115\032\097\032\108\097\114\103\101\032\101\112\105\112\104\121\116\105\099\032\108\105\099\104\101\110\032\099\111\110\115\105\115\116\105\110\103\032\111\102\032\097\110\032\097\115\099\111\109\121\099\101\116\101\032\102\117\110\103\117\115\032\097\110\100\032\097\032\103\114\101\101\110\032\097\108\103\097\108\032\112\097\114\116\110\101\114\032\108\105\118\105\110\103\032\116\111\103\101\116\104\101\114\032\105\110\032\097\032\115\121\109\098\105\111\116\105\099\032\114\101\108\097\116\105\111\110\115\104\105\112\032\119\105\116\104\032\097\032\099\121\097\110\111\098\097\099\116\101\114\105\117\109\226\128\148\097\032\115\121\109\098\105\111\115\105\115\032\105\110\118\111\108\118\105\110\103\032\109\101\109\098\101\114\115\032\111\102\032\116\104\114\101\101\032\107\105\110\103\100\111\109\115\032\111\102\032\111\114\103\097\110\105\115\109\115\046\032\067\111\109\109\111\110\108\121\032\107\110\111\119\110\032\098\121\032\118\097\114\105\111\117\115\032\110\097\109\101\115\032\108\105\107\101\032\116\114\101\101\032\108\117\110\103\119\111\114\116\044\032\108\117\110\103\032\108\105\099\104\101\110\044\032\108\117\110\103\032\109\111\115\115\044\032\108\117\110\103\119\111\114\116\032\108\105\099\104\101\110\044\032\111\097\107\032\108\117\110\103\115\032\111\114\032\111\097\107\032\108\117\110\103\119\111\114\116\044\032\105\116\032\105\115\032\115\101\110\115\105\116\105\118\101\032\116\111\032\097\105\114\032\112\111\108\108\117\116\105\111\110\032\097\110\100\032\105\115\032\097\108\115\111\032\104\097\114\109\101\100\032\098\121\032\104\097\098\105\116\097\116\032\108\111\115\115\032\097\110\100\032\099\104\097\110\103\101\115\032\105\110\032\102\111\114\101\115\116\114\121\032\112\114\097\099\116\105\099\101\115\046\032\073\116\115\032\112\111\112\117\108\097\116\105\111\110\032\104\097\115\032\100\101\099\108\105\110\101\100\032\097\099\114\111\115\115\032\069\117\114\111\112\101\032\097\110\100\032\076\046\032\112\117\108\109\111\110\097\114\105\097\032\105\115\032\099\111\110\115\105\100\101\114\101\100\032\101\110\100\097\110\103\101\114\101\100\032\105\110\032\109\097\110\121\032\108\111\119\108\097\110\100\032\097\114\101\097\115\046\032\084\104\101\032\115\112\101\099\105\101\115\032\104\097\115\032\097\032\104\105\115\116\111\114\121\032\111\102\032\117\115\101\032\105\110\032\104\101\114\098\097\108\032\109\101\100\105\099\105\110\101\115\044\032\097\110\100\032\114\101\099\101\110\116\032\114\101\115\101\097\114\099\104\032\104\097\115\032\099\111\114\114\111\098\111\114\097\116\101\100\032\115\111\109\101\032\109\101\100\105\099\105\110\097\108\032\112\114\111\112\101\114\116\105\101\115\032\111\102\032\108\105\099\104\101\110\032\101\120\116\114\097\099\116\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1454,7 +1834,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\057\056\050\050\051\057\053", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\088\097\110\116\104\111\114\105\097\095\112\097\114\105\101\116\105\110\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\056\045\049\055\084\049\049\058\049\051\058\053\056\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\088\097\110\116\104\111\114\105\097\032\112\097\114\105\101\116\105\110\097\032\105\115\032\097\032\099\111\109\109\111\110\032\097\110\100\032\119\105\100\101\115\112\114\101\097\100\032\108\105\099\104\101\110\045\102\111\114\109\105\110\103\032\102\117\110\103\117\115\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\084\101\108\111\115\099\104\105\115\116\097\099\101\097\101\046\032\067\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\121\101\108\108\111\119\032\119\097\108\108\032\108\105\099\104\101\110\044\032\099\111\109\109\111\110\032\111\114\097\110\103\101\032\108\105\099\104\101\110\044\032\111\114\032\109\097\114\105\116\105\109\101\032\115\117\110\098\117\114\115\116\032\108\105\099\104\101\110\044\032\116\104\105\115\032\108\101\097\102\121\032\108\105\099\104\101\110\032\105\115\032\107\110\111\119\110\032\102\111\114\032\105\116\115\032\118\105\098\114\097\110\116\032\121\101\108\108\111\119\032\116\111\032\111\114\097\110\103\101\032\099\111\108\111\114\097\116\105\111\110\032\097\110\100\032\101\110\118\105\114\111\110\109\101\110\116\097\108\032\097\100\097\112\116\097\098\105\108\105\116\121\046\032\070\105\114\115\116\032\100\101\115\099\114\105\098\101\100\032\098\121\032\067\097\114\108\032\076\105\110\110\097\101\117\115\032\105\110\032\049\055\053\051\044\032\105\116\032\104\097\115\032\098\101\099\111\109\101\032\111\110\101\032\111\102\032\116\104\101\032\109\111\115\116\032\116\104\111\114\111\117\103\104\108\121\032\115\116\117\100\105\101\100\032\108\105\099\104\101\110\115\044\032\099\111\110\116\114\105\098\117\116\105\110\103\032\115\105\103\110\105\102\105\099\097\110\116\108\121\032\116\111\032\115\099\105\101\110\116\105\102\105\099\032\117\110\100\101\114\115\116\097\110\100\105\110\103\032\111\102\032\108\105\099\104\101\110\032\098\105\111\108\111\103\121\046\032\085\110\108\105\107\101\032\109\097\110\121\032\108\105\099\104\101\110\115\032\116\104\097\116\032\097\114\101\032\115\101\110\115\105\116\105\118\101\032\116\111\032\112\111\108\108\117\116\105\111\110\044\032\088\046\032\112\097\114\105\101\116\105\110\097\032\103\114\111\119\115\032\105\110\032\100\105\118\101\114\115\101\032\104\097\098\105\116\097\116\115\226\128\148\105\110\099\108\117\100\105\110\103\032\099\111\097\115\116\097\108\032\114\111\099\107\115\044\032\117\114\098\097\110\032\119\097\108\108\115\044\032\097\110\100\032\116\114\101\101\032\098\097\114\107\226\128\148\101\118\101\110\032\105\110\032\097\114\101\097\115\032\119\105\116\104\032\104\105\103\104\032\108\101\118\101\108\115\032\111\102\032\097\105\114\032\112\111\108\108\117\116\105\111\110\032\097\110\100\032\101\120\099\101\115\115\032\110\105\116\114\111\103\101\110\046\032\073\116\115\032\115\116\114\117\099\116\117\114\101\032\099\111\110\115\105\115\116\115\032\111\102\032\115\109\097\108\108\044\032\111\118\101\114\108\097\112\112\105\110\103\032\108\111\098\101\115\032\116\104\097\116\032\116\121\112\105\099\097\108\108\121\032\109\101\097\115\117\114\101\032\108\101\115\115\032\116\104\097\110\032\056\032\099\109\032\040\051\043\049\226\129\132\056\032\105\110\041\032\097\099\114\111\115\115\044\032\119\105\116\104\032\099\111\108\111\114\097\116\105\111\110\032\116\104\097\116\032\118\097\114\105\101\115\032\102\114\111\109\032\098\114\105\103\104\116\032\111\114\097\110\103\101\032\105\110\032\115\117\110\045\101\120\112\111\115\101\100\032\108\111\099\097\116\105\111\110\115\032\116\111\032\103\114\101\101\110\105\115\104\045\121\101\108\108\111\119\032\105\110\032\115\104\097\100\101\100\032\101\110\118\105\114\111\110\109\101\110\116\115\046\010\084\104\101\032\108\105\099\104\101\110\032\114\101\112\114\101\115\101\110\116\115\032\097\032\115\121\109\098\105\111\116\105\099\032\112\097\114\116\110\101\114\115\104\105\112\032\098\101\116\119\101\101\110\032\097\032\102\117\110\103\117\115\032\097\110\100\032\103\114\101\101\110\032\097\108\103\097\101\032\111\102\032\116\104\101\032\103\101\110\117\115\032\084\114\101\098\111\117\120\105\097\046\032\073\116\115\032\100\105\115\116\105\110\099\116\105\118\101\032\111\114\097\110\103\101\045\121\101\108\108\111\119\032\099\111\108\111\114\032\099\111\109\101\115\032\102\114\111\109\032\112\097\114\105\101\116\105\110\044\032\097\110\032\097\110\116\104\114\097\113\117\105\110\111\110\101\032\112\105\103\109\101\110\116\032\116\104\097\116\032\097\099\099\117\109\117\108\097\116\101\115\032\105\110\032\116\104\101\032\111\117\116\101\114\032\099\111\114\116\101\120\032\097\110\100\032\115\101\114\118\101\115\032\097\115\032\097\032\110\097\116\117\114\097\108\032\115\117\110\115\099\114\101\101\110\044\032\112\114\111\116\101\099\116\105\110\103\032\116\104\101\032\097\108\103\097\108\032\112\097\114\116\110\101\114\032\102\114\111\109\032\101\120\099\101\115\115\105\118\101\032\108\105\103\104\116\032\097\110\100\032\117\108\116\114\097\118\105\111\108\101\116\032\114\097\100\105\097\116\105\111\110\046\032\085\110\108\105\107\101\032\109\097\110\121\032\108\105\099\104\101\110\115\032\116\104\097\116\032\114\101\112\114\111\100\117\099\101\032\116\104\114\111\117\103\104\032\115\112\101\099\105\097\108\105\122\101\100\032\118\101\103\101\116\097\116\105\118\101\032\115\116\114\117\099\116\117\114\101\115\044\032\088\046\032\112\097\114\105\101\116\105\110\097\032\112\114\105\109\097\114\105\108\121\032\114\101\108\105\101\115\032\111\110\032\115\101\120\117\097\108\032\114\101\112\114\111\100\117\099\116\105\111\110\032\116\104\114\111\117\103\104\032\099\117\112\045\115\104\097\112\101\100\032\102\114\117\105\116\105\110\103\032\098\111\100\105\101\115\032\040\097\112\111\116\104\101\099\105\097\041\044\032\101\097\099\104\032\111\102\032\119\104\105\099\104\032\099\097\110\032\114\101\108\101\097\115\101\032\117\112\032\116\111\032\053\048\032\115\112\111\114\101\115\032\112\101\114\032\109\105\110\117\116\101\032\117\110\100\101\114\032\104\117\109\105\100\032\099\111\110\100\105\116\105\111\110\115\046\032\087\104\101\110\032\102\117\110\103\097\108\032\115\112\111\114\101\115\032\103\101\114\109\105\110\097\116\101\044\032\116\104\101\121\032\105\110\105\116\105\097\108\108\121\032\102\111\114\109\032\112\114\101\108\105\109\105\110\097\114\121\032\097\115\115\111\099\105\097\116\105\111\110\115\032\119\105\116\104\032\099\111\109\109\111\110\032\102\114\101\101\045\108\105\118\105\110\103\032\097\108\103\097\101\032\105\110\032\116\104\101\105\114\032\118\105\099\105\110\105\116\121\046\032\065\100\100\105\116\105\111\110\097\108\108\121\044\032\116\104\101\032\102\117\110\103\117\115\032\099\097\110\032\114\101\099\114\117\105\116\032\099\111\109\112\097\116\105\098\108\101\032\097\108\103\097\108\032\099\101\108\108\115\032\102\114\111\109\032\110\101\105\103\104\098\111\114\105\110\103\032\108\105\099\104\101\110\032\116\104\097\108\108\105\226\128\148\101\115\115\101\110\116\105\097\108\108\121\032\101\120\116\114\097\099\116\105\110\103\032\116\104\101\115\101\032\112\097\114\116\110\101\114\115\226\128\148\116\111\032\104\101\108\112\032\101\115\116\097\098\108\105\115\104\032\097\032\099\111\109\112\108\101\116\101\032\115\121\109\098\105\111\116\105\099\032\114\101\108\097\116\105\111\110\115\104\105\112\046\010\078\097\116\105\118\101\032\097\099\114\111\115\115\032\069\117\114\111\112\101\044\032\112\097\114\116\115\032\111\102\032\065\115\105\097\044\032\097\110\100\032\099\111\097\115\116\097\108\032\078\111\114\116\104\032\065\102\114\105\099\097\044\032\088\046\032\112\097\114\105\101\116\105\110\097\032\104\097\115\032\097\032\109\111\114\101\032\108\105\109\105\116\101\100\032\097\110\100\032\112\114\105\109\097\114\105\108\121\032\099\111\097\115\116\097\108\032\100\105\115\116\114\105\098\117\116\105\111\110\032\105\110\032\078\111\114\116\104\032\065\109\101\114\105\099\097\032\097\110\100\032\065\117\115\116\114\097\108\105\097\044\032\119\104\101\114\101\032\103\101\110\101\116\105\099\032\101\118\105\100\101\110\099\101\032\115\117\103\103\101\115\116\115\032\104\117\109\097\110\045\109\101\100\105\097\116\101\100\032\105\110\116\114\111\100\117\099\116\105\111\110\046\032\073\110\032\114\101\099\101\110\116\032\100\101\099\097\100\101\115\044\032\105\116\032\104\097\115\032\101\120\112\097\110\100\101\100\032\105\110\108\097\110\100\032\105\110\032\116\104\101\115\101\032\114\101\103\105\111\110\115\044\032\112\097\114\116\105\099\117\108\097\114\108\121\032\105\110\032\117\114\098\097\110\032\101\110\118\105\114\111\110\109\101\110\116\115\032\097\110\100\032\097\114\101\097\115\032\097\102\102\101\099\116\101\100\032\098\121\032\097\103\114\105\099\117\108\116\117\114\097\108\032\114\117\110\111\102\102\044\032\114\111\097\100\032\115\097\108\116\032\097\112\112\108\105\099\097\116\105\111\110\044\032\097\110\100\032\110\105\116\114\111\103\101\110\032\100\101\112\111\115\105\116\105\111\110\046\032\084\104\101\032\108\105\099\104\101\110\032\103\114\111\119\115\032\115\108\111\119\108\121\032\040\097\118\101\114\097\103\105\110\103\032\050\046\054\032\109\109\032\040\049\226\129\132\056\032\105\110\041\032\112\101\114\032\121\101\097\114\041\032\098\117\116\032\112\111\115\115\101\115\115\101\115\032\099\111\110\115\105\100\101\114\097\098\108\101\032\114\101\103\101\110\101\114\097\116\105\118\101\032\097\098\105\108\105\116\105\101\115\044\032\119\105\116\104\032\102\114\097\103\109\101\110\116\115\032\099\097\112\097\098\108\101\032\111\102\032\100\101\118\101\108\111\112\105\110\103\032\105\110\116\111\032\110\101\119\032\116\104\097\108\108\105\046\032\073\116\032\112\097\114\116\105\099\105\112\097\116\101\115\032\105\110\032\097\032\099\111\109\112\108\101\120\032\119\101\098\032\111\102\032\101\099\111\108\111\103\105\099\097\108\032\105\110\116\101\114\097\099\116\105\111\110\115\044\032\104\111\115\116\105\110\103\032\097\116\032\108\101\097\115\116\032\052\049\032\115\112\101\099\105\101\115\032\111\102\032\108\105\099\104\101\110\045\100\119\101\108\108\105\110\103\032\102\117\110\103\105\044\032\119\104\105\108\101\032\099\101\114\116\097\105\110\032\103\097\115\116\114\111\112\111\100\115\032\097\110\100\032\109\105\099\114\111\115\099\111\112\105\099\032\114\111\116\105\102\101\114\115\032\099\111\110\116\114\105\098\117\116\101\032\116\111\032\105\116\115\032\100\105\115\112\101\114\115\097\108\032\098\121\032\099\111\110\115\117\109\105\110\103\032\097\110\100\032\101\120\099\114\101\116\105\110\103\032\118\105\097\098\108\101\032\115\112\111\114\101\115\046\010\084\104\101\032\115\112\101\099\105\101\115\032\104\097\115\032\104\105\103\104\032\100\105\118\101\114\115\105\116\121\032\101\118\101\110\032\119\105\116\104\105\110\032\108\111\099\097\108\032\112\111\112\117\108\097\116\105\111\110\115\044\032\119\105\116\104\032\100\105\115\116\105\110\099\116\032\112\097\116\116\101\114\110\115\032\108\105\110\107\101\100\032\116\111\032\098\111\116\104\032\103\101\111\103\114\097\112\104\105\099\032\108\111\099\097\116\105\111\110\032\097\110\100\032\115\117\098\115\116\114\097\116\101\032\116\121\112\101\046\032\084\104\105\115\032\103\101\110\101\116\105\099\032\118\097\114\105\097\098\105\108\105\116\121\044\032\099\111\109\098\105\110\101\100\032\119\105\116\104\032\116\104\101\032\108\105\099\104\101\110\039\115\032\102\108\101\120\105\098\108\101\032\097\115\115\111\099\105\097\116\105\111\110\115\032\119\105\116\104\032\100\105\102\102\101\114\101\110\116\032\112\104\111\116\111\098\105\111\110\116\032\115\116\114\097\105\110\115\044\032\099\111\110\116\114\105\098\117\116\101\115\032\116\111\032\105\116\115\032\101\099\111\108\111\103\105\099\097\108\032\115\117\099\099\101\115\115\046\032\088\046\032\112\097\114\105\101\116\105\110\097\032\115\101\114\118\101\115\032\097\115\032\097\032\098\105\111\105\110\100\105\099\097\116\111\114\032\102\111\114\032\109\111\110\105\116\111\114\105\110\103\032\097\105\114\032\113\117\097\108\105\116\121\032\100\117\101\032\116\111\032\105\116\115\032\099\097\112\097\099\105\116\121\032\116\111\032\097\099\099\117\109\117\108\097\116\101\032\101\110\118\105\114\111\110\109\101\110\116\097\108\032\099\111\110\116\097\109\105\110\097\110\116\115\046\032\072\105\115\116\111\114\105\099\097\108\108\121\044\032\105\116\032\119\097\115\032\117\115\101\100\032\105\110\032\102\111\108\107\032\109\101\100\105\099\105\110\101\032\116\111\032\116\114\101\097\116\032\106\097\117\110\100\105\099\101\032\097\110\100\032\097\115\032\097\032\110\097\116\117\114\097\108\032\100\121\101\032\115\111\117\114\099\101\032\102\111\114\032\116\101\120\116\105\108\101\115\046\032\077\111\114\101\032\114\101\099\101\110\116\108\121\044\032\105\116\032\104\097\115\032\098\101\099\111\109\101\032\097\032\115\117\098\106\101\099\116\032\111\102\032\097\115\116\114\111\098\105\111\108\111\103\121\032\114\101\115\101\097\114\099\104\044\032\119\104\101\114\101\032\105\116\032\115\117\114\118\105\118\101\115\032\077\097\114\115\045\108\105\107\101\032\101\110\118\105\114\111\110\109\101\110\116\115\044\032\115\112\097\099\101\032\118\097\099\117\117\109\044\032\099\111\115\109\105\099\032\114\097\100\105\097\116\105\111\110\044\032\097\110\100\032\101\120\116\114\101\109\101\032\099\111\108\100\046\032\084\104\105\115\032\114\101\115\105\108\105\101\110\099\101\032\104\097\115\032\101\115\116\097\098\108\105\115\104\101\100\032\088\046\032\112\097\114\105\101\116\105\110\097\032\097\115\032\097\032\109\111\100\101\108\032\111\114\103\097\110\105\115\109\032\105\110\032\098\111\116\104\032\101\110\118\105\114\111\110\109\101\110\116\097\108\032\109\111\110\105\116\111\114\105\110\103\032\097\110\100\032\115\112\097\099\101\032\101\120\112\108\111\114\097\116\105\111\110\032\114\101\115\101\097\114\099\104\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1473,7 +1853,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\054\048\051\056\050\056\051\049", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\080\097\114\109\101\108\105\097\095\115\117\108\099\097\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\054\045\050\049\084\048\052\058\048\049\058\048\052\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\080\097\114\109\101\108\105\097\032\115\117\108\099\097\116\097\044\032\099\111\109\109\111\110\108\121\032\107\110\111\119\110\032\097\115\032\116\104\101\032\104\097\109\109\101\114\101\100\032\115\104\105\101\108\100\032\108\105\099\104\101\110\032\111\114\032\099\114\097\099\107\101\100\045\115\104\105\101\108\100\032\108\105\099\104\101\110\044\032\105\115\032\097\032\102\111\108\105\111\115\101\032\108\105\099\104\101\110\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\080\097\114\109\101\108\105\097\099\101\097\101\046\032\070\105\114\115\116\032\100\101\115\099\114\105\098\101\100\032\098\121\032\084\104\111\109\097\115\032\084\097\121\108\111\114\032\105\110\032\049\056\051\054\044\032\105\116\032\105\115\032\111\110\101\032\111\102\032\116\104\101\032\109\111\115\116\032\112\114\101\118\097\108\101\110\116\032\108\105\099\104\101\110\032\115\112\101\099\105\101\115\032\103\108\111\098\097\108\108\121\044\032\107\110\111\119\110\032\102\111\114\032\105\116\115\032\114\101\115\105\108\105\101\110\099\101\032\116\111\032\112\111\108\108\117\116\105\111\110\032\097\110\100\032\099\111\115\109\111\112\111\108\105\116\097\110\032\100\105\115\116\114\105\098\117\116\105\111\110\032\097\099\114\111\115\115\032\116\101\109\112\101\114\097\116\101\032\097\110\100\032\099\111\108\100\032\114\101\103\105\111\110\115\032\111\102\032\098\111\116\104\032\104\101\109\105\115\112\104\101\114\101\115\046\032\080\046\032\115\117\108\099\097\116\097\032\102\111\114\109\115\032\097\032\099\105\114\099\117\108\097\114\032\116\104\097\108\108\117\115\032\117\112\032\116\111\032\049\048\032\099\109\032\040\052\032\105\110\041\032\105\110\032\100\105\097\109\101\116\101\114\044\032\119\105\116\104\032\097\032\103\108\097\117\099\111\117\115\032\119\104\105\116\101\032\116\111\032\103\114\101\121\032\117\112\112\101\114\032\115\117\114\102\097\099\101\032\097\110\100\032\097\032\098\108\097\099\107\032\108\111\119\101\114\032\115\117\114\102\097\099\101\044\032\102\101\097\116\117\114\105\110\103\032\098\114\111\097\100\108\121\032\108\111\098\101\100\032\115\116\114\117\099\116\117\114\101\115\032\119\105\116\104\032\098\111\116\104\032\109\097\114\103\105\110\097\108\032\097\110\100\032\108\097\109\105\110\097\108\032\115\111\114\097\108\105\097\032\097\110\100\032\097\032\100\105\115\116\105\110\099\116\105\118\101\032\114\101\116\105\099\117\108\097\116\101\032\112\097\116\116\101\114\110\032\111\102\032\112\115\101\117\100\111\099\121\112\104\101\108\108\097\101\046\010\084\097\120\111\110\111\109\105\099\097\108\108\121\044\032\080\097\114\109\101\108\105\097\032\115\117\108\099\097\116\097\032\104\097\115\032\097\032\099\111\109\112\108\101\120\032\104\105\115\116\111\114\121\046\032\077\111\108\101\099\117\108\097\114\032\112\104\121\108\111\103\101\110\101\116\105\099\115\032\115\116\117\100\105\101\115\032\104\097\118\101\032\114\101\118\101\097\108\101\100\032\115\105\103\110\105\102\105\099\097\110\116\032\103\101\110\101\116\105\099\032\118\097\114\105\097\098\105\108\105\116\121\032\119\105\116\104\105\110\032\119\104\097\116\032\119\097\115\032\116\114\097\100\105\116\105\111\110\097\108\108\121\032\099\111\110\115\105\100\101\114\101\100\032\097\032\115\105\110\103\108\101\032\115\112\101\099\105\101\115\044\032\105\100\101\110\116\105\102\121\105\110\103\032\097\032\099\111\109\112\108\101\120\032\111\102\032\099\114\121\112\116\105\099\032\115\112\101\099\105\101\115\044\032\105\110\099\108\117\100\105\110\103\032\080\097\114\109\101\108\105\097\032\101\110\099\114\121\112\116\097\116\097\032\097\110\100\032\080\046\032\098\097\114\114\101\110\111\097\101\044\032\097\110\100\032\100\101\109\111\110\115\116\114\097\116\101\032\116\104\101\032\110\101\099\101\115\115\105\116\121\032\111\102\032\109\111\108\101\099\117\108\097\114\032\100\097\116\097\032\102\111\114\032\097\099\099\117\114\097\116\101\032\115\112\101\099\105\101\115\032\105\100\101\110\116\105\102\105\099\097\116\105\111\110\046\032\080\046\032\115\117\108\099\097\116\097\032\109\097\105\110\116\097\105\110\115\032\097\032\104\105\103\104\108\121\032\115\112\101\099\105\102\105\099\032\115\121\109\098\105\111\116\105\099\032\114\101\108\097\116\105\111\110\115\104\105\112\032\119\105\116\104\032\103\114\101\101\110\032\097\108\103\097\101\032\111\102\032\116\104\101\032\103\101\110\117\115\032\084\114\101\098\111\117\120\105\097\044\032\112\097\114\116\105\099\117\108\097\114\108\121\032\115\112\101\099\105\101\115\032\119\105\116\104\105\110\032\084\114\101\098\111\117\120\105\097\032\099\108\097\100\101\032\073\044\032\097\115\032\105\116\115\032\112\104\111\116\111\098\105\111\110\116\032\112\097\114\116\110\101\114\046\010\069\099\111\108\111\103\105\099\097\108\108\121\044\032\080\097\114\109\101\108\105\097\032\115\117\108\099\097\116\097\032\103\114\111\119\115\032\111\110\032\118\097\114\105\111\117\115\032\115\117\098\115\116\114\097\116\101\115\044\032\105\110\099\108\117\100\105\110\103\032\098\097\114\107\044\032\119\111\111\100\044\032\097\110\100\032\114\111\099\107\115\046\032\073\116\032\097\099\099\117\109\117\108\097\116\101\115\032\112\111\108\108\117\116\097\110\116\115\032\115\117\099\104\032\097\115\032\104\101\097\118\121\032\109\101\116\097\108\115\032\097\110\100\032\114\097\100\105\111\110\117\099\108\105\100\101\115\044\032\114\101\102\108\101\099\116\105\110\103\032\097\105\114\032\113\117\097\108\105\116\121\032\097\110\100\032\099\111\110\116\097\109\105\110\097\116\105\111\110\032\108\101\118\101\108\115\044\032\109\097\107\105\110\103\032\105\116\032\097\032\117\115\101\102\117\108\032\098\105\111\105\110\100\105\099\097\116\111\114\032\105\110\032\112\111\108\108\117\116\105\111\110\032\115\116\117\100\105\101\115\032\097\099\114\111\115\115\032\069\117\114\111\112\101\044\032\078\111\114\116\104\032\065\109\101\114\105\099\097\044\032\097\110\100\032\111\116\104\101\114\032\114\101\103\105\111\110\115\046\032\066\101\121\111\110\100\032\105\116\115\032\101\099\111\108\111\103\105\099\097\108\032\105\109\112\111\114\116\097\110\099\101\044\032\080\046\032\115\117\108\099\097\116\097\032\104\097\115\032\098\101\101\110\032\117\115\101\100\032\105\110\032\116\114\097\100\105\116\105\111\110\097\108\032\109\101\100\105\099\105\110\101\032\097\110\100\032\097\115\032\097\032\115\111\117\114\099\101\032\111\102\032\110\097\116\117\114\097\108\032\100\121\101\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, @@ -1492,7 +1872,7 @@ return { revision_url = nil, history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\108\097\100\111\110\105\097\095\099\104\108\111\114\111\112\104\097\101\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = nil, - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\078\111\032\069\110\103\108\105\115\104\032\087\105\107\105\112\101\100\105\097\032\115\112\101\099\105\101\115\032\097\114\116\105\099\108\101\032\119\097\115\032\097\118\097\105\108\097\098\108\101\032\102\111\114\032\067\108\097\100\111\110\105\097\032\099\104\108\111\114\111\112\104\097\101\097\032\119\104\101\110\032\116\104\105\115\032\098\117\110\100\108\101\032\119\097\115\032\114\101\116\114\105\101\118\101\100\046\032\084\104\105\115\032\105\115\032\097\110\032\111\114\105\103\105\110\097\108\032\097\118\097\105\108\097\098\105\108\105\116\121\032\110\111\116\101\044\032\110\111\116\032\097\032\087\105\107\105\112\101\100\105\097\032\101\120\099\101\114\112\116\046\032\078\111\032\098\114\111\097\100\101\114\032\116\097\120\111\110\032\111\114\032\115\105\109\105\108\097\114\108\121\032\110\097\109\101\100\032\115\112\101\099\105\101\115\032\104\097\115\032\098\101\101\110\032\115\117\098\115\116\105\116\117\116\101\100\046\032\084\104\101\032\103\097\109\101\032\099\097\116\097\108\111\103\117\101\032\105\115\032\110\111\116\032\101\118\105\100\101\110\099\101\032\102\111\114\032\114\101\097\108\045\119\111\114\108\100\032\101\099\111\108\111\103\105\099\097\108\044\032\102\111\111\100\032\111\114\032\109\101\100\105\099\105\110\097\108\032\101\102\102\101\099\116\115\046", kind = "\111\114\105\103\105\110\097\108\095\110\111\116\101\115", excerpt = false, @@ -1511,7 +1891,7 @@ return { revision_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\111\108\100\105\100\061\049\051\052\050\055\056\048\057\052\057", history_url = "\104\116\116\112\115\058\047\047\101\110\046\119\105\107\105\112\101\100\105\097\046\111\114\103\047\119\047\105\110\100\101\120\046\112\104\112\063\116\105\116\108\101\061\067\108\097\100\111\110\105\097\095\112\121\120\105\100\097\116\097\038\097\099\116\105\111\110\061\104\105\115\116\111\114\121", revision_timestamp = "\050\048\050\054\045\048\051\045\049\048\084\049\055\058\050\053\058\048\053\090", - retrieved = "\050\048\050\054\045\049\049\045\048\051\084\048\055\058\053\053\058\052\053\046\050\055\049\090", + retrieved = "\050\048\050\054\045\049\049\045\048\051\084\049\057\058\053\052\058\053\050\046\053\056\055\090", text = "\067\108\097\100\111\110\105\097\032\112\121\120\105\100\097\116\097\032\111\114\032\116\104\101\032\112\101\098\098\108\101\100\032\099\117\112\032\108\105\099\104\101\110\032\105\115\032\097\032\115\112\101\099\105\101\115\032\111\102\032\099\117\112\032\108\105\099\104\101\110\032\105\110\032\116\104\101\032\102\097\109\105\108\121\032\067\108\097\100\111\110\105\097\099\101\097\101\046\032\073\116\032\105\115\032\104\111\115\116\032\116\111\032\116\104\101\032\108\105\099\104\101\110\105\099\111\108\111\117\115\032\102\117\110\103\117\115\032\076\105\099\104\101\110\111\099\111\110\105\117\109\032\112\121\120\105\100\097\116\097\101\046\010\073\110\032\078\101\112\097\108\044\032\067\108\097\100\111\110\105\097\032\112\121\120\105\100\097\116\097\032\104\097\115\032\098\101\101\110\032\114\101\112\111\114\116\101\100\032\102\114\111\109\032\052\044\048\048\048\032\116\111\032\052\044\049\048\048\032\109\032\101\108\101\118\097\116\105\111\110\032\105\110\032\097\032\099\111\109\112\105\108\097\116\105\111\110\032\111\102\032\112\117\098\108\105\115\104\101\100\032\114\101\099\111\114\100\115\046", kind = "\119\105\107\105\112\101\100\105\097\095\105\110\116\114\111", excerpt = true, diff --git a/mods/hearth/wildlife.lua b/mods/hearth/wildlife.lua index 40314df..f116461 100755 --- a/mods/hearth/wildlife.lua +++ b/mods/hearth/wildlife.lua @@ -1,301 +1,1002 @@ --- Loaded-only fauna: bounded work, no drops, combat, restores or catch-up. +-- Loaded ecology, persisted calendar ages. No emergence, items or player combat. return function(hearth, api) - local GLOBAL, LOCAL, PLAYER, RADIUS, BUDGET = 32, 6, 6, 48, 12 - local kinds = {"hare", "deer", "fox"} - local species = { - hare={cap=16,radius=0.23,height=0.85,body=0.48,length=0.32}, - deer={cap=10,radius=0.32,height=1.52,body=1.12,length=0.49}, - fox={cap=6,radius=0.23,height=0.80,body=0.57,length=0.43}, - } - local active, counts, total, serial = {}, {hare=0,deer=0,fox=0}, 0, 0 - local clock, credit, recruitment, player_cursor, recruit_kind, cursor = 0, 0, 0, 0, 0, 0 - local actions, births, meals, grazed, dung = 0, 0, 0, 0, 0 + local modpath=api.get_modpath and api.get_modpath("hearth") + local F=hearth.fauna or dofile(assert(modpath,"wildlife needs fauna or get_modpath").."/fauna.lua")(hearth,api) + local GLOBAL,PLAYER,LOCAL,RADIUS,BUDGET=64,16,12,48,24 + local DAY,MAX=86400,9007199254740991 + local REGION,FOUNDERS,REGION_LIMIT,REGISTRY_BYTES=48,4,4096,1048576 + local STORAGE_KEY="wildlife_residents_v1" + local records,order={},{} + for _,r in ipairs(F.list()) do records[r.id]=r; order[#order+1]=r.id end + local active,counts={},{} + for _,id in ipairs(order) do counts[id]=0 end + local total,serial,actions,births,meals,grazed,dung,deaths,pollinations,dispersals,detritus=0,0,0,0,0,0,0,0,0,0,0 + local clock,credit,recruitment,cursor,recruit_kind,player_cursor=0,0,0,0,0,0 + local candidate_cursor,patch_cursor=0,0 + local recruiting={attempts=0,successes=0,scans=0,last_reason="waiting",reasons={}} local directions={{1,0},{1,1},{0,1},{-1,1},{-1,0},{-1,-1},{0,-1},{1,-1}} - local offsets={{8,0},{-8,0},{0,8},{0,-8},{16,8},{-16,-8},{24,0},{0,24}} - local function delta(dt) - if type(dt)~="number" or dt~=dt or dt<0 or dt==math.huge then return 0 end - return math.min(dt,1) + local offsets={{16,0},{0,16},{-16,0},{0,-16},{20,8},{-20,-8},{8,-20},{-8,20}} + local function finite(n) return type(n)=="number" and n==n and math.abs(n)=0 and n<=MAX and n%1==0 end + local function decimal(n) return string.format("%.0f",n) end + local function now() + local t=api.now and api.now() or os.time() + return epoch(t) and t<=MAX-366*DAY and t or nil end + local function delta(t) return finite(t) and t>0 and math.min(t,1) or 0 end local function valid(p) if type(p)~="table" then return false end - for _, axis in ipairs({"x","y","z"}) do - local n=p[axis] - if type(n)~="number" or n~=n or math.abs(n)>30900 then return false end - end + for _,a in ipairs({"x","y","z"}) do if not finite(p[a]) or math.abs(p[a])>30900 then return false end end return true end + local function players() + return api.get_connected_players and api.get_connected_players() or {} + end + local function log(reason) + if api.log then api.log("warning","[hearth wildlife] "..reason) end + end + -- Flat, explicitly versioned JSON. Region values are a bounded canonical + -- journal: attempts/last-attempt/id=species;... . Never evict or truncate. + local storage=hearth.storage + if not storage and api.get_mod_storage then + local ok,s=pcall(api.get_mod_storage); if ok then storage=s end + end + local ledger={version=1,counter=0}; local regions,region_count={},0 + local storage_ok=storage and type(storage.get_string)=="function" and type(storage.set_string)=="function" or false + local function fail_storage() + if storage_ok then log("resident registry unavailable or invalid; new admissions disabled") end + storage_ok=false; return false + end + local function region_key(p) return "r_"..math.floor(p.x/REGION).."_"..math.floor(p.z/REGION) end + local function valid_region(k) + if type(k)~="string" then return false end + local x,z=k:match("^r_(-?%d+)_(-?%d+)$") + x,z=tonumber(x),tonumber(z) + return x and z and math.abs(x)<=644 and math.abs(z)<=644 + and k=="r_"..x.."_"..z + end + local function flat(text,limit) + return type(text)=="string" and #text>0 and #text<=limit + and text:sub(1,1)=="{" and text:sub(-1)=="}" + and not text:sub(2,-2):find("[{}%[%]]") + end + local function read_registry() + if not storage_ok or not api.parse_json or not api.write_json then return fail_storage() end + local ok,text=pcall(storage.get_string,storage,STORAGE_KEY) + if not ok or type(text)~="string" then return fail_storage() end + if text=="" then return true end + if not flat(text,REGISTRY_BYTES) then return fail_storage() end + local parsed; ok,parsed=pcall(api.parse_json,text,nil,true) + if not ok or type(parsed)~="table" or getmetatable(parsed) or parsed.version~=1 + or not epoch(parsed.counter) then return fail_storage() end + local used={} + for k,v in pairs(parsed) do + if k~="version" and k~="counter" then + if not valid_region(k) or type(v)~="string" or #v>256 or region_count>=REGION_LIMIT then return fail_storage() end + local attempts,time,members=v:match("^(%d+)/(%d+)/(.*)$") + attempts,time=tonumber(attempts),tonumber(time) + if not epoch(attempts) or attempts>1000000000 or not epoch(time) then return fail_storage() end + local r={attempts=attempts,time=time,members={}}; local rebuilt="" + for n,id in members:gmatch("(%d+)=([%w_]+);") do + local individual=tonumber(n) + if not epoch(individual) or individual<1 or individual>parsed.counter or used[individual] + or not records[id] or #r.members>=FOUNDERS or decimal(individual)~=n then return fail_storage() end + used[individual]=true; r.members[#r.members+1]={id=individual,species=id} + rebuilt=rebuilt..n.."="..id..";" + end + if rebuilt~=members then return fail_storage() end + regions[k]=r; region_count=region_count+1 + end + end + ledger=parsed; return true + end + local function persist() + if not storage_ok then return false end + local ok,text=pcall(api.write_json,ledger) + if not ok or not flat(text,REGISTRY_BYTES) then return fail_storage() end + local written; ok,written=pcall(storage.set_string,storage,STORAGE_KEY,text) + if not ok or written==false then return fail_storage() end + local verified; ok,verified=pcall(storage.get_string,storage,STORAGE_KEY) + if not ok or verified~=text then return fail_storage() end + return true + end + local function journal(k,r) + local v=decimal(r.attempts).."/"..decimal(r.time).."/" + for _,m in ipairs(r.members) do v=v..decimal(m.id).."="..m.species..";" end + ledger[k]=v + end + local function region(k,time,attempt) + if not storage_ok then return nil end + local r=regions[k] + if not r then + if region_count>=REGION_LIMIT then return nil end + r={attempts=0,time=time,members={}}; regions[k]=r; region_count=region_count+1 + end + if attempt then r.attempts=math.min(1000000000,r.attempts+1); r.time=time end + journal(k,r); return persist() and r or nil + end + local function identity(id,p,time,founder) + if not storage_ok or ledger.counter>=MAX then return nil end + local k=founder and region_key(p) or "" + local r=founder and region(k,time,false) or nil + if founder and (not r or #r.members>=FOUNDERS) then return nil end + ledger.counter=ledger.counter+1.0 -- avoid narrow-integer wrap; exact through MAX + if r then r.members[#r.members+1]={id=ledger.counter,species=id}; journal(k,r) end + if not persist() then return nil end + return ledger.counter,k + end + read_registry() + if not storage_ok then log("new wildlife needs a healthy resident registry; saved animals are retained") end local function at(p,x,y,z) return {x=p.x+x,y=p.y+y,z=p.z+z} end - local function cell(p) return {x=math.floor(p.x+0.5),y=math.floor(p.y+0.5),z=math.floor(p.z+0.5)} end - local function distance(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end + local function cell(p) return {x=math.floor(p.x+.5),y=math.floor(p.y+.5),z=math.floor(p.z+.5)} end + local function dist(a,b) return (a.x-b.x)^2+(a.y-b.y)^2+(a.z-b.z)^2 end local function permitted(p) + if not valid(p) then return false end if hearth.claim_owner_at then - local owner, ambiguous=hearth.claim_owner_at(p) + local owner,ambiguous=hearth.claim_owner_at(p) if owner or ambiguous then return false end end local protect=hearth.external_protected or api.is_protected return type(protect)=="function" and not protect(p,"") end local function node(p) - if not valid(p) or not permitted(p) then return nil end - local n=api.get_node_or_nil(p) - if n and n.name~="ignore" then return n end + if not permitted(p) then return nil end + local n=api.get_node_or_nil(p); return n and n.name~="ignore" and n or nil + end + local function season(time) + local climate=hearth.climate_at and hearth.climate_at(time) + return climate and climate.season or (hearth.seasons and hearth.seasons.season and hearth.seasons.season()) or nil + end + local markers={"owner","planter","placed_by","player_placed","hearth_placed","hearth_player_placed","planted_at"} + local function unmarked(p) + local m=api.get_meta(p) + for _,k in ipairs(markers) do local v=m:get_string(k); if v~="" and v~="0" then return false end end + if m.to_table then + for k,v in pairs((m:to_table() or {}).fields or {}) do + local harvest=k=="botany_ready_at" and tonumber(v) + and finite(tonumber(v)) and tonumber(v)>=0 + if k:sub(1,5)~="succ_" and not harvest and v~="" and tostring(v)~="0" then return false end + end + end + return true end local function natural(p,n) - if not n or api.get_meta(p):get_string("succ_natural")~="1" then return false end - local k=hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$") - if hearth.seasons and not hearth.seasons.available(k) then return false end - if hearth.diversity then return hearth.diversity.edible(k) end - return k=="meadow_grass" or k=="fern" + return n~=nil and api.get_meta(p):get_string("succ_natural")=="1" and unmarked(p) + end + local function water(n) return n and (n.name=="hearth:water_source" or n.name=="hearth:water_flowing") end + local function ground(n) + return n and (n.name=="hearth:grass" or n.name=="hearth:soil" + or (hearth.diversity and hearth.diversity.is_ground(n))) + end + local function legacy_flora(n) + local k=n and (hearth.seasons and hearth.seasons.kind(n) or n.name:match("^hearth:(.+)$")) + return k=="meadow_grass" or k=="fern" or k=="wildflowers" end local function clear(p) local n=node(p) - return n and (n.name=="air" or natural(p,n) or n.name=="hearth:dung") + if not n then return false end + if n.name=="air" then return true end + local d=(api.registered_nodes or {})[n.name] + local D=hearth.diversity + -- Collision observation is not a meal or adoption: recognizable unmarked + -- mapgen understory is passable even outside succession's adoption ring. + local below=at(p,0,-1,0) + local B=hearth.botany + local plant=B and B.get and B.get(n.name) + local flora=((D and D.is_worldgen_flora and D.is_worldgen_flora(n)) or legacy_flora(n) + or (plant and not plant.existing)) + and unmarked(p) and unmarked(below) and ground(node(below)) + return (natural(p,n) or flora) and d~=nil and d.walkable==false and not water(n) end - local function safe(p,kind) - if not valid(p) or not permitted(p) then return false end - local s=species[kind] - -- A yaw-independent square body envelope; only flat natural terrain. - local r=math.max(s.radius,s.length) - for _, x in ipairs({-r,0,r}) do for _, z in ipairs({-r,0,r}) do - local n=node(cell(at(p,x,-0.05,z))) - if not n or not (n.name=="hearth:grass" or n.name=="hearth:soil" - or (hearth.diversity and hearth.diversity.is_ground(n))) then return false end - for _, y in ipairs({0.05,1.1,s.height,2.1}) do - if not clear(cell(at(p,x,y,z))) then return false end + local function medium(p,r) + if water(node(cell(at(p,0,.05,0)))) then + return (r.medium=="swim" or r.medium=="amphibian") and "swim" or nil + end + if r.medium=="swim" then return nil end + return r.medium=="fly" and "fly" or "land" + end + local function safe(p,id) + local r=records[id]; if not r or not valid(p) then return false end + local mode=medium(p,r); if not mode then return false end + local b=r.bounds; local radius=math.max(-b.min[1],b.max[1],-b.min[3],b.max[3])+.04 + -- Full yaw-independent body envelope. Flight is a low corridor above + -- natural terrain, never a shortcut across cliffs or unobserved water. + for _,x in ipairs({-radius,0,radius}) do for _,z in ipairs({-radius,0,radius}) do + for _,y in ipairs({.03,b.max[2]*.5,b.max[2]+.08}) do + local q=cell(at(p,x,y,z)) + if mode=="swim" then if not water(node(q)) then return false end + elseif not clear(q) then return false end + end + if mode~="swim" then + local supported=false + for depth=0,(mode=="fly" and 3 or 0) do + -- A Creatura spawn may start at integer ground-level while our + -- scanner starts at the half-node surface center. Sample below + -- either valid feet convention; the rest of the swept body uses + -- the same collision envelope and never teleports/creates terrain. + if ground(node(cell(at(p,x,-.6-depth,z)))) then supported=true; break end + end + if not supported then return false end end end end - return true + return true,mode end - local function path(a,b,kind) - if not valid(a) or not valid(b) or distance(a,b)>2.26 then return false end - for i=0,6 do - local t=i/6 - if not safe({x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t},kind) then return false end + local function path(a,b,id) + if not valid(a) or not valid(b) or dist(a,b)>4 then return false end + local _,start=safe(a,id); if not start then return false end + local length=math.sqrt(dist(a,b)) + local steps=math.max(1,math.ceil(length/.2)) + for i=0,steps do + local t=i/steps; local q={x=a.x+(b.x-a.x)*t,y=a.y+(b.y-a.y)*t,z=a.z+(b.z-a.z)*t} + local ok,m=safe(q,id) + -- Refuse shore transitions until a fully swept ramp exists. + if not ok or m~=start then return false end end return true end - local function habitat(p) - local s=hearth.succession - if not s or not s.habitat then return false end - local c=cell(at(p,0,0.1,0)) - local h=s.habitat(c) - if not h or h.loaded~=true or ((h.grass or 0)<=0 and (h.cover or 0)<=0 and (h.edible or 0)<=0) then return false end - -- cover also counts trees: a forest without edible meadow/fern is not - -- a recruitment or breeding site. Read at most nine loaded cells. - for x=-1,1 do for z=-1,1 do - local q=at(c,x,0,z) - if natural(q,node(q)) then return true end - end end - return false - end - local function nearby(p,r,kind,exclude) + local function nearby(p,r,id,exclude) local n=0 for e in pairs(active) do local q=e.object:get_pos() - if e~=exclude and valid(q) and (not kind or e.kind==kind) and distance(p,q)<=r*r then n=n+1 end + if e~=exclude and valid(q) and (not id or e.kind==id) and dist(p,q)<=r*r then n=n+1 end end return n end - local function room(p,kind) - if total>=GLOBAL or counts[kind]>=species[kind].cap or nearby(p,16)>=LOCAL then return false end + local function room(p,id,restored,exclude) + -- Caps are admission limits, never a reason to erase an existing animal + -- (including movement and engine activation before players join). + if restored then return true end + if not exclude and (total>=GLOBAL or counts[id]>=records[id].cap) then return false end + if nearby(p,16,nil,exclude)>=LOCAL then return false end local close=false - for _, player in ipairs(api.get_connected_players()) do + for _,player in ipairs(players()) do local q=player:get_pos() - if valid(q) and distance(p,q)<=RADIUS*RADIUS then - close=true - if nearby(q,RADIUS)>=PLAYER then return false end + if valid(q) and dist(p,q)<=RADIUS*RADIUS then + close=true; if nearby(q,RADIUS,nil,exclude)>=PLAYER then return false end end end + -- Saved creatures activate before the first player joins. Proximity gates + -- new immigration only, not persistent restoration. return close end local function forget(e) if active[e] then active[e]=nil; total=total-1; counts[e.kind]=counts[e.kind]-1 end end - local function remove(e) - forget(e) -- release before remove/on_deactivate: each prey counts once - e.object:remove() + local function remove(e,death) + if not active[e] then return false end + forget(e); if death then deaths=deaths+1 end + e.object:remove(); return true end - local function spawn(p,kind) - if not safe(p,kind) or not habitat(p) or not room(p,kind) then return false end - local obj=api.add_entity(p,"hearth:"..kind) - local e=obj and obj:get_luaentity() - return e and active[e] or false + local function animation(e,name) + if e.clip==name then return end + e.clip=name; local c=records[e.kind].clips[name] + e.object:set_animation({x=c[1],y=c[2]},24,.15,true) end - local function callback_site(e) - local p=e.object:get_pos() - if not safe(p,e.kind) then return nil end - local c=cell(at(p,0,0.1,0)) - -- forage/deposit may mutate ANY cell of their 3x3 stencil. - for x=-1,1 do for z=-1,1 do - if not safe({x=c.x+x,y=c.y-0.5,z=c.z+z},e.kind) then return nil end - end end - return c + local function stop(e,clip) + e.target={x=0,y=0,z=0}; e.velocity={x=0,y=0,z=0}; e.lease=0 + e.object:set_velocity(e.velocity); animation(e,clip or "idle") end - local function move(e,dx,dz) - local a=e.object:get_pos() - local len=math.sqrt(dx*dx+dz*dz) - if len<0.001 then return false end - local stride=e.kind=="fox" and 1.2 or 0.9 - local b=at(a,dx/len*stride,0,dz/len*stride) - if not path(a,b,e.kind) then return false end - if nearby(b,16,nil,e)>=LOCAL then return false end - for _, player in ipairs(api.get_connected_players()) do - local p=player:get_pos() - if valid(p) and distance(b,p)<=RADIUS*RADIUS and nearby(p,RADIUS,nil,e)>=PLAYER then return false end + local allowed={version=true,species=true,born_at=true,origin=true,sex=true,foodreserve=true, + resident_id=true,founder_region=true,hunger_seconds=true, + nextfeed=true,pregnancy_until=true,last_seen=true,seed=true,last_meal=true,dung_pending=true, + conception_at=true,litter_remaining=true,litter_delivered=true,litter_total=true, + nextbreed=true,last_birth_at=true,parent_seed=true,parent_born_at=true} + local reproductive={"conception_at","litter_remaining","litter_delivered","litter_total","nextbreed","last_birth_at"} + local function development(p) + return p and (p.reproduction=="egg" and p.incubation_days or p.gestation_days) + end + local function validate(s,id,time) + if type(s)~="table" or getmetatable(s)~=nil then return nil end + for k in pairs(s) do if not allowed[k] then return nil end end + if s.version~=1 or s.species~=id or not records[s.species] then return nil end + if s.resident_id~=nil or s.founder_region~=nil then + if not epoch(s.resident_id) or s.resident_id<1 or + (s.founder_region~="" and not valid_region(s.founder_region)) then return nil end end - e.object:set_velocity({x=0,y=0,z=0}) - e.object:set_pos(b) -- short swept ground stride; gravity remains enabled - local atan2=math.atan2 or function(y,x) return math.atan(y,x) end - e.object:set_yaw(atan2(-dx,dz)) -- authored forward +Z - return true - end - local function threat(e,p) - local closest,best=nil,64 - for _, player in ipairs(api.get_connected_players()) do - local q=player:get_pos() - if valid(q) and distance(p,q)MAX then return nil end + if not epoch(s.born_at) or not epoch(s.last_seen) or not epoch(s.last_meal) + or s.born_at>time or s.last_seen>time or s.last_seens.last_seen or s.last_meal1 then return nil end + if not epoch(s.seed) or s.seed>2147483646 or not epoch(s.nextfeed) or s.nextfeed>time+DAY then return nil end + if type(s.dung_pending)~="boolean" or not epoch(s.pregnancy_until) then return nil end + -- Migrate only the old empty reproductive state, never an old pregnancy. + local missing=0; for _,k in ipairs(reproductive) do if s[k]==nil then missing=missing+1 end end + if missing==#reproductive and s.pregnancy_until==0 then + for _,k in ipairs(reproductive) do s[k]=0 end + elseif missing>0 then return nil end + if s.parent_seed==nil and s.parent_born_at==nil then s.parent_seed=0; s.parent_born_at=0 end + if not epoch(s.parent_seed) or s.parent_seed>2147483646 or not epoch(s.parent_born_at) + or s.parent_born_at>s.born_at then return nil end + for _,k in ipairs(reproductive) do if not epoch(s[k]) then return nil end end + if s.last_birth_at>s.last_seen then return nil end + local p=records[id].profile; local d=development(p) + local enabled=p and p.reproduction_enabled==true + local care=enabled and p.postpartum_days*DAY or 0 + if s.nextbreed>time+care+(enabled and d.max*DAY or 0) then return nil end + if s.last_birth_at>0 and s.nextbreeds.last_seen + or (s.last_birth_at>s.conception_at and s.litter_delivered==0) + or s.pregnancy_until~=s.conception_at+math.ceil(d.max*DAY) + or s.pregnancy_until>time+math.ceil(d.max*DAY) + or s.litter_totalp.birth_count.max + or s.litter_remaining<1 or s.litter_delivered+s.litter_remaining~=s.litter_total + or (s.litter_delivered>0 and s.last_birth_at35 or not habitat(p) then return false end - for mate in pairs(active) do - local q=mate.object:get_pos() - if mate~=e and mate.kind==e.kind and mate.age>=60 and mate.food>=needed - and mate.hunger<=35 and clock>=mate.breed_at and valid(q) and path(p,q,e.kind) then - for _, d in ipairs(directions) do - local b=at(p,d[1]*0.8,0,d[2]*0.8) - if path(p,b,e.kind) and spawn(b,e.kind) then - e.food=e.food-needed; mate.food=mate.food-needed - e.breed_at=clock+90; mate.breed_at=clock+90 - births=births+1; return true + local function resources(e,p,time,foods) + foods=foods or records[e.kind].foods + local c=cell(at(p,0,.1,0)); local candidates={} + -- A fixed 5x5 search finds nearby food without scaling with population or + -- walking the map; path validation still decides whether it is reachable. + for x=-2,2 do for z=-2,2 do + local q=at(c,x,0,z); local n=node(q) + if natural(q,n) then + for _,food in ipairs({"nectar","litter","fruit","seed","browse"}) do + if foods[food] then + local id,browse_mode + if food=="litter" and n.name=="hearth:litter" then id="litter" + else id,browse_mode=edible(q,n,time,food) end + if id then candidates[#candidates+1]={pos=q,id=id,food=food,browse_mode=browse_mode} end + end + end + end + end end + return candidates + end + local function observe_habitat(p) + local S=hearth.succession + if not S or not S.habitat then return nil end + local q=cell(at(p,0,.1,0)) + -- Succession observes the stand ABOVE a water substrate, not its bottom. + if water(node(q)) then q=at(q,0,1,0) end + local h=S.habitat(q) + return h and h.loaded==true and h or nil + end + local function habitat(p,id,time,observed,food,allow_ambient) + local h=observed or observe_habitat(p) + if not h then return false end + local r=records[id] + if r.medium=="amphibian" or r.medium=="swim" then + if (h.water or 0)+(h.near_water or 0)<=0 then return false end + end + if id=="earthworm" or id=="common_frog" or id=="smooth_newt" then + if (h.moisture or 0)<.5 then return false end + end + if id=="red_squirrel" and (h.canopy or 0)<=0 then return false end + if (id=="bank_vole" or id=="wood_mouse") and (h.cover or 0)<=0 then return false end + local probe={kind=id} + local ambient={robin=true,blackbird=true,blue_tit=true} + local function ambient_patch() + if not allow_ambient or not ambient[id] then return false end + local vegetation=(h.grass or 0)+(h.flowers or 0)+(h.cover or 0)+(h.canopy or 0) + local water=(h.water or 0)+(h.near_water or 0) + return vegetation>0 and (r.medium~="amphibian" or water>0) + end + if food then + for _,v in ipairs(food) do if r.foods[v.food] then return true end end + if prey_for(probe,p,16) then return true end + -- A small set of common ambient species may establish in a genuinely + -- vegetated patch before a modeled feeding transaction is available. + -- This grants no food or meal credit; all later feeding still requires + -- the normal shared succession/prey transaction. + return ambient_patch() + end + return #resources(probe,p,time)>0 or prey_for(probe,p,16)~=nil or ambient_patch() + end + local function stencil(p) + -- Existing forage/deposit may mutate the entire 3x3 neighbourhood. + for x=-1,1 do for z=-1,1 do for y=-1,1 do + if not node(at(p,x,y,z)) then return false end + end end end + return true + end + local function move(e,dx,dy,dz) + local r=records[e.kind]; local p=e.object:get_pos() + local len=math.sqrt(dx*dx+dy*dy+dz*dz); if len<.001 then stop(e); return false end + local speed=math.min(r.speed,1.3) + local b=at(p,dx/len*speed,dy/len*speed,dz/len*speed) + if not path(p,b,e.kind) or not room(b,e.kind,true,e) then stop(e); return false end + -- AI thinks every two seconds; retain motion through that interval and + -- leave a short grace margin so scheduling jitter does not cancel travel. + e.target={x=dx/len*speed,y=dy/len*speed,z=dz/len*speed}; e.lease=2.5 + local _,mode=safe(p,e.kind) + animation(e,(mode=="swim" or mode=="fly") and "flight_swim" or "locomotion") + return true + end + -- Songbirds loosely gather around nearby members of their own species. + -- This is derived from the bounded active-resident set, not a persistent + -- flock/group assignment; unloaded wildlife retains the same save schema. + local flocking={robin=true,blackbird=true,blue_tit=true,mallard=true} + local function flock_direction(e,p,dx,dz) + if not flocking[e.kind] then return dx,dz end + local C=hearth.creatura + if C and type(C.get_boid_dir)=="function" then + local ok,direction=pcall(C.get_boid_dir,e) + if ok and valid(direction) then return dx+direction.x*1.35,dz+direction.z*1.35 end + end + local count,sx,sz,separate_x,separate_z=0,0,0,0,0 + for other in pairs(active) do + if other~=e and other.kind==e.kind then + local q=other.object:get_pos() + if valid(q) then + local ox,oz=q.x-p.x,q.z-p.z; local d2=ox*ox+oz*oz + if d2>0 and d2<=100 then + count=count+1; sx=sx+q.x; sz=sz+q.z + if d2<4 then + separate_x=separate_x-ox/d2; separate_z=separate_z-oz/d2 + end end end - return false end end + if count==0 then return dx,dz end + local cx,cz=sx/count-p.x,sz/count-p.z + local distance=math.sqrt(cx*cx+cz*cz) + if distance>.001 then + local strength=1.25*math.min(1,distance/6) + dx=dx*.35+cx/distance*strength; dz=dz*.35+cz/distance*strength + end + local separation=math.sqrt(separate_x*separate_x+separate_z*separate_z) + if separation>.001 then + dx=dx+separate_x/separation*1.5; dz=dz+separate_z/separation*1.5 + end + return dx,dz + end + local function interaction_player(clicker) + if type(api.get_connected_players)~="function" or type(hearth.sessions)~="table" then return nil end + local ok,name=pcall(function() return clicker:get_player_name() end) + if not ok or type(name)~="string" or #name<1 or #name>64 or not hearth.sessions[name] then return nil end + local connected_ok,list=pcall(api.get_connected_players) + if not connected_ok or type(list)~="table" then return nil end + for _,player in pairs(list) do if player==clicker then return name end end + return nil + end + local function interaction_snapshot(self) + -- Scalar-only copy: the UI receives no live entity/state table reference. + local source=self.state + return {born_at=source.born_at,origin=source.origin,foodreserve=source.foodreserve, + resident=source.resident_id~=nil,observation=self.clip=="rest" and "rest" or "awake"} + end + local function activity_phase(e,time) + local r=records[e.kind] + local schedule=r.sleep or {} + if r.winter_dormant and season(time)=="Winter" then return "sleep" end + local climate=hearth.climate_at and hearth.climate_at(time) + local date=os.date("!*t",time) + local hour=climate and finite(climate.hour) and climate.hour%24 or date.hour + local elevation=climate and finite(climate.elevation) and climate.elevation + or (hour>=6 and hour<18 and 1 or -20) + local activity=r.activity + if activity=="day" then + if elevation<=(schedule.sleep_below or -6) then return "sleep" end + if elevation<(schedule.winddown_below or 6) then return "winddown" end + return "active" + elseif activity=="night" then + if elevation>=(schedule.sleep_above or 6) then return "sleep" end + if elevation>(schedule.winddown_above or -6) then return "winddown" end + return "active" + elseif activity=="crepuscular" then + local light=schedule.active_between or {-8,8} + local rest=schedule.rest_hours or {11,15} + if elevation>=light[1] and elevation<=light[2] and (hour=rest[2]) then return "active" end + if elevation>=light[1]-4 and elevation<=light[2]+4 then return "winddown" end + return "sleep" + elseif activity=="any" then + local rest=schedule.rest_hours or {12,14} + local winddown=schedule.winddown_hours or {11.5,14.5} + if hour>=rest[1] and hour=winddown[1] and hour=rest[2] and hour=light[1] and elevation<=light[2] and (hour=rest[2]) then return "active" end + if elevation>=light[1]-4 and elevation<=light[2]+4 then return "winddown" end + return "sleep" + end + local function awake(e,time) return activity_phase(e,time)=="active" end + -- One bounded 9x9x8 (648-node) terrain query per five active seconds. + -- Never emerge blocks, scan every species, or retain stale terrain results. + local function candidate(center) + patch_cursor=patch_cursor%#offsets+1 + local d=offsets[patch_cursor]; local c=cell(at(center,d[1],0,d[2])) + if not valid(at(c,-4,-4,-4)) or not valid(at(c,4,3,4)) then return nil end + local names={"hearth:grass","hearth:soil","hearth:water_source","hearth:water_flowing"} + local D=hearth.diversity + for name in pairs(api.registered_nodes or {}) do + if (D and ((D.is_ground and D.is_ground(name)) + or (D.is_worldgen_flora and D.is_worldgen_flora(name)))) + or legacy_flora({name=name}) then names[#names+1]=name end + end + if not api.find_nodes_in_area_under_air then return nil,c end + recruiting.scans=recruiting.scans+1 + local found=api.find_nodes_in_area_under_air(at(c,-4,-4,-4),at(c,4,3,4),names) + local candidates={} + for i=1,math.min(#found,648) do + local q=found[i] + if valid(q) then + local n=node(q) + local p=n and {x=q.x,y=q.y+(ground(n) and .5 or (water(n) and 0 or -.5)),z=q.z} + local dx,dz=q.x-center.x,q.z-center.z + if p and dx*dx+dz*dz>=144 and dx*dx+dz*dz<=576 then candidates[#candidates+1]=p end + end + end + table.sort(candidates,function(a,b) + if a.x~=b.x then return a.x=GLOBAL then result("global_cap"); return end + player_cursor=player_cursor%#observers+1 + local center=observers[player_cursor]:get_pos() + if not valid(center) then result("invalid_player"); return end + local p,patch=candidate(center) + if not p and not patch then result("no_surface"); return end + local k=region_key(p or patch) + local r=region(k,time,true) + if not r then result(storage_ok and "region_budget" or "registry_unavailable"); return end + if #r.members>=FOUNDERS then result("region_seeded"); return end + if not p then result("no_surface"); return end + for _,player in ipairs(observers) do + local q=player:get_pos() + if valid(q) and dist(p,q)<64 then result("observer_near"); return end + end + local anchor=cell(at(p,0,.1,0)) + if not stencil(anchor) then result("blocked_or_unloaded"); return end + -- Guarded natural-worldgen adoption only; never grant food, edit crops, + -- or fabricate botanical ages. Succession owns all subsequent debits. + local D=hearth.diversity + if D and type(D.bootstrap)=="function" then + for x=-1,1 do for z=-1,1 do + local q=at(anchor,x,0,z); local n=node(q) + local below=at(q,0,-1,0) + if n and unmarked(q) and unmarked(below) and ground(node(below)) then + D.bootstrap(q,n) + -- The legacy three ground sprites predate diversity's catalogue. + -- Adopt only provenance, never an age/profile or a ration. + if legacy_flora(n) then api.get_meta(q):set_string("succ_natural","1") end + end + end end + end + local h=observe_habitat(p) + if not h then result("habitat_unloaded"); return end + -- Collect represented plant foods once, then match the catalogue in + -- rotating order. Failed species do not trigger 23 terrain/food scans. + local food={}; local seen={} + for _,id in ipairs(order) do + for k in pairs(records[id].foods) do seen[k]=true end + end + food=resources(nil,p,time,seen) + local reason="no_active_food_match" + for i=1,#order do + local index=(recruit_kind+i-1)%#order+1; local id=order[index] + if counts[id]before then recruiting.successes=recruiting.successes+1; result("admitted") + else result("activation_rejected") end + return + end + reason="unsafe_body" + end + end + -- When no species has a represented food item, admit at most one small + -- songbird founder on a loaded natural vegetated patch. This ambient + -- observation grants no reserve or meal and never bypasses later feeding. + if reason=="no_active_food_match" then + for _,id in ipairs({"robin","blackbird","blue_tit"}) do + local index + for i,value in ipairs(order) do if value==id then index=i; break end end + if index and counts[id]before then recruiting.successes=recruiting.successes+1; result("admitted_ambient") + else result("activation_rejected") end + return + end + reason="unsafe_body" + end + end + end + recruit_kind=recruit_kind%#order+1; result(reason) + end + local function earned(e,time) + local s=e.state; s.foodreserve=math.min(1,s.foodreserve+.25); s.last_meal=time + s.hunger_seconds=0 + s.nextfeed=time+records[e.kind].feed_seconds + s.dung_pending=e.kind~="bumblebee" and e.kind~="earthworm" + stop(e,"feed") + end + local function feed(e,p,time) + local s=e.state; if time2.25 then pursuit,pursuit_distance=q,d end + end + if pursuit then return move(e,pursuit.x-p.x,0,pursuit.z-p.z) end return false end - local function think(e) - local p=e.object:get_pos() - if not safe(p,e.kind) then remove(e); return end - local danger=threat(e,p) - if danger then - if not move(e,p.x-danger.x,p.z-danger.z) then move(e,-(p.z-danger.z),p.x-danger.x) end - return - end - local site=callback_site(e) - if e.dung_at and clock>=e.dung_at and site then - local s=hearth.succession - if s and s.deposit and s.deposit(site) then dung=dung+1 end - e.dung_at=nil -- one attempt per meal, never a growing queue - return - end - if breed(e,p) then return end - if e.kind=="fox" then - local prey,best=nil,144 - for other in pairs(active) do - local q=other.object:get_pos() - if other.kind=="hare" and valid(q) and distance(p,q)=15 then - local q=prey.object:get_pos() - if best<=1.44 and clock>=e.feed_at and path(p,q,"fox") and safe(q,"hare") then - remove(prey); meals=meals+1; e.food=math.min(6,e.food+1) - e.hunger=math.max(0,e.hunger-35); e.feed_at=clock+20; e.dung_at=clock+8 - else move(e,q.x-p.x,q.z-p.z) end - return - end - elseif e.hunger>=15 and clock>=e.feed_at and site and habitat(p) then - local edible=false - for x=-1,1 do for z=-1,1 do - local q=at(site,x,0,z) - if natural(q,node(q)) then edible=true end - end end - local s=hearth.succession - if edible and s and s.forage and s.forage(site) then - e.food=math.min(6,e.food+1); e.hunger=math.max(0,e.hunger-30) - e.feed_at=clock+12; e.dung_at=clock+8; grazed=grazed+1; return - end - end - for i=1,3 do - local d=directions[(e.id+e.turn+i)%#directions+1] - local q=at(p,d[1]*0.9,0,d[2]*0.9) - if habitat(q) and move(e,d[1],d[2]) then e.turn=e.turn+1; return end - end - e.turn=e.turn+1 + local spawning + local function healthy(e,time) + local s=e.state + return active[e] and time>=s.last_seen and time>=s.last_meal and s.foodreserve>=.7 + and time-s.last_meal<=records[e.kind].feed_seconds*2 end - for _, kind in ipairs(kinds) do - local s=species[kind] - api.register_entity("hearth:"..kind, { - initial_properties={physical=true,collide_with_objects=true, - collisionbox={-s.radius,0,-s.length,s.radius,s.body,s.length}, - visual="mesh",mesh="hearth_"..kind..".obj",textures={"hearth_"..kind..".png"}, - visual_size={x=1,y=1},static_save=false,pointable=false,makes_footstep_sound=false}, - on_activate=function(self,staticdata,dtime_s) + local function reproduce(e,p,time) + local s=e.state; local profile=records[e.kind].profile + if not profile or profile.reproduction_enabled~=true or s.sex~="female" or e.delivering or spawning then return false end + if s.pregnancy_until>0 then + if time=o.nextbreed + and safe(q,other.kind) and habitat(q,other.kind,time) + and F.can_breed(other.kind,o.born_at,time,o.sex,season(time)) + and (not mate or other.id=profile.juvenile_days.max*DAY then return false end + -- Dependent young do not hunt or browse like adults. Bounded maternal + -- reserve transfer is coarse care accounting, not simulated milk/nests. + if time>=s.nextfeed then + for mother in pairs(active) do + local m=mother.state; local q=mother.object:get_pos() + if mother.kind==e.kind and m.sex=="female" and m.seed==s.parent_seed and m.born_at==s.parent_born_at + and valid(q) and dist(p,q)<=4 and safe(q,mother.kind) and m.foodreserve>=.3 then + m.foodreserve=m.foodreserve-.05; s.foodreserve=math.min(1,s.foodreserve+.05) + s.last_meal=time; s.hunger_seconds=0; s.nextfeed=time+records[e.kind].feed_seconds; break + end + end + end + stop(e,"rest"); return true + end + local function think(e,time) + local p=e.object:get_pos() + local s=e.state + if time=r.starvation_days*DAY then remove(e,true); return end + -- Threat response precedes all resting, including winter dormancy. A + -- sleeping prey animal can still flee/curl when danger enters its radius. + local danger,best=nil,64 -- eight-node observer/predator flight radius + for _,player in ipairs(players()) do + local q=player:get_pos(); if valid(q) and dist(p,q)=3600 then + s.dung_pending=false; local q=cell(at(p,0,.1,0)); local S=hearth.succession + if stencil(q) and S and S.deposit and S.deposit(q)==true then dung=dung+1 end + return + end + if feed(e,p,time) then return end + e.turn=e.turn+1 + if e.turn%3==0 then stop(e); return end + local d=directions[(e.id+e.turn)%#directions+1] + local dx,dz=flock_direction(e,p,d[1],d[2]) + local vertical=(r.medium=="fly" and e.turn%5==0) and .25 or 0 + move(e,dx,vertical,dz) + end + for _,id in ipairs(order) do + local r=records[id]; local b=r.bounds + api.register_entity("hearth:"..id,{ + collisionbox={b.min[1],0,b.min[3],b.max[1],b.max[2],b.max[3]}, + initial_properties={physical=true,collide_with_objects=false,collisionbox={b.min[1],0,b.min[3],b.max[1],b.max[2],b.max[3]}, + visual="mesh",mesh=r.mesh,textures={r.texture},visual_size={x=1,y=1},static_save=true, + pointable=true,makes_footstep_sound=false,eye_height=r.eye_height}, + on_activate=function(self,data,dtime_s) if active[self] then return end - local p=self.object:get_pos() - -- add_entity may activate synchronously; enforce caps centrally here. - if (staticdata and staticdata~="") or (dtime_s and dtime_s~=0) - or not safe(p,kind) or not habitat(p) or not room(p,kind) then self.object:remove(); return end - serial=serial+1; self.id=serial; self.kind=kind; self.age=0; self.turn=0 - self.hunger=20; self.food=0; self.feed_at=clock+8; self.breed_at=clock+60 - self.next_ai=clock+2; active[self]=true; total=total+1; counts[kind]=counts[kind]+1 + local time=now(); local p=self.object:get_pos(); local restored=type(data)=="string" and #data>0 + local state=time and restored and decode(data,id,time) or nil + local newborn=spawning and spawning.id==id and spawning.text==data + if (data~=nil and type(data)~="string") or not time or (restored and not state) or (not restored and dtime_s and dtime_s~=0) + or not valid(p) then log("rejected invalid wildlife snapshot or position"); self.object:remove(); return end + if (not restored or newborn) and (not safe(p,id) or not room(p,id,false) + or not habitat(p,id,time,nil,nil,not restored)) then + self.object:remove(); return + end + state=state or initial(id,time,(serial+1)%2147483647) + if restored and state.resident_id and storage_ok and state.resident_id>ledger.counter then + -- A missing/rolled-back ledger is not permission to reuse IDs. + fail_storage() + end + if not state.resident_id then + state.resident_id,state.founder_region=identity(id,p,time,not restored) + -- A storage fault must not erase a validated legacy resident. + if not restored and not state.resident_id then self.object:remove(); return end + end + serial=serial+1; self.id=serial; self.kind=id; self.turn=0 + -- Creatura's bounded boid cache expects these standard entity fields. + self.name="hearth:"..id; self.max_boids=5; self.tracking_range=10 + self.width=math.max(b.max[1],-b.min[1]); self._movement_data={} + self.state=state + if restored then self.state.last_seen=time; self.state.dung_pending=false end + self.next_ai=clock; self.velocity={x=0,y=0,z=0}; self.target={x=0,y=0,z=0}; self.lease=0 + active[self]=true; counts[id]=counts[id]+1; total=total+1 + if spawning and spawning.id==id and spawning.text==data then spawning.ack=true end self.object:set_armor_groups({immortal=1}) - self.object:set_acceleration({x=0,y=-9.81,z=0}) - self.object:set_velocity({x=0,y=0,z=0}) + self.object:set_acceleration({x=0,y=0,z=0}) -- guarded horizontal corridors + stop(self) + end, + get_staticdata=function(self) + local time=now(); if not time or not self.state then return "" end + return encode(self.state,id,time) or "invalid" end, on_step=function(self,dt) if not active[self] then return end - local t=delta(dt) - self.age=self.age+t; self.hunger=math.min(100,self.hunger+t*0.6) - if self.age>=900 or self.hunger>=100 then remove(self); return end + local t=delta(dt); if t==0 then return end local p=self.object:get_pos() - if not valid(p) or not permitted(p) or not node(cell(p)) then remove(self) end + if not safe(p,id) or self.lease<=0 then + stop(self,(self.clip=="feed" or self.clip=="rest") and self.clip or "idle"); return + end + local v=self.velocity; local target=self.target; local limit=t*2 + local dx,dy,dz=target.x-v.x,target.y-v.y,target.z-v.z + local len=math.sqrt(dx*dx+dy*dy+dz*dz); local ratio=len>limit and limit/len or 1 + local nextv={x=v.x+dx*ratio,y=v.y+dy*ratio,z=v.z+dz*ratio} + local horizon=math.max(.15,t) + if not path(p,at(p,nextv.x*horizon,nextv.y*horizon,nextv.z*horizon),id) then stop(self); return end + self.velocity=nextv; self.lease=math.max(0,self.lease-t); self.object:set_velocity(nextv) + if math.abs(nextv.x)+math.abs(nextv.z)>.001 then + local yaw=(math.atan2 or math.atan)(-nextv.x,nextv.z); self.object:set_yaw(yaw) + end end, - on_punch=function() return true end, - on_deactivate=forget, + on_rightclick=function(self,clicker) + if not active[self] or not records[self.kind] or not self.state or not interaction_player(clicker) then return end + local inspect=hearth.inspect_fauna + if type(inspect)=="function" then pcall(inspect,clicker,self.kind,interaction_snapshot(self)) end + end, + on_punch=function(self,puncher) + -- A hit only triggers the existing flee movement; immortal armor and + -- the absence of drops/death handlers keep this noncombat. + if active[self] and puncher then + local ok,source=pcall(function() return puncher:get_pos() end) + local p=self.object:get_pos() + if ok and valid(source) and valid(p) then move(self,p.x-source.x,0,p.z-source.z) end + end + return true + end,on_deactivate=forget, }) end + -- Use Creatura's well-tested ambient spawn API for small, visible groups. + -- Every callback still passes Last Hearth's registry, claims, protection, + -- collision, habitat and per-player/global caps in on_activate. + local C=hearth.creatura + if C and type(C.register_abm_spawn)=="function" then + local group={robin={"hearth:grass","hearth:forest_floor","hearth:podzol"}, + blackbird={"hearth:grass","hearth:forest_floor","hearth:podzol","hearth:alluvium"}, + blue_tit={"hearth:grass","hearth:forest_floor","hearth:podzol"}, + hare={"hearth:grass","hearth:alpine_turf","hearth:alluvium"}, + rabbit={"hearth:grass","hearth:forest_floor","hearth:alluvium"}, + deer={"hearth:grass","hearth:forest_floor","hearth:podzol"}, + roe_deer={"hearth:grass","hearth:forest_floor","hearth:podzol"}, + red_squirrel={"hearth:forest_floor","hearth:podzol"}, + bank_vole={"hearth:grass","hearth:forest_floor","hearth:podzol","hearth:peat"}, + wood_mouse={"hearth:grass","hearth:forest_floor","hearth:podzol","hearth:peat"}} + for id,nodes in pairs(group) do + local ok,err=pcall(C.register_abm_spawn,"hearth:"..id,{ + chance=1800,interval=45,min_height=-1,max_height=240,min_light=0,max_light=15, + min_group=1,max_group=2,spawn_cap=records[id].cap,nodes=nodes,neighbors={"air"},spawn_active=true}) + if not ok then log("Creatura spawn registration failed for "..id..": "..tostring(err)) end + end + end api.register_globalstep(function(dt) - local t=delta(dt) - clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET); recruitment=recruitment+t - local list={} - for e in pairs(active) do list[#list+1]=e end + local t=delta(dt); if t==0 then return end + clock=clock+t; credit=math.min(BUDGET,credit+t*BUDGET) + -- No recruitment credit banks while nobody is observing this world. + recruitment=#players()>0 and math.min(5,recruitment+t) or 0 + local time=now(); if not time then return end + local list={}; for e in pairs(active) do list[#list+1]=e end table.sort(list,function(a,b) return a.id=5 and credit>=1 then + recruitment=0; credit=credit-1; actions=actions+1 + recruit(time) + end for _=1,#list do if credit<1 then break end - cursor=cursor%#list+1 - local e=list[cursor] + cursor=cursor%#list+1; local e=list[cursor] if active[e] and clock>=e.next_ai then - credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e) + credit=credit-1; actions=actions+1; e.next_ai=clock+2; think(e,time) end end - if recruitment<30 or credit<1 then return end - recruitment=0; credit=credit-1; actions=actions+1 - local players=api.get_connected_players() - if #players==0 then return end - player_cursor=player_cursor%#players+1; recruit_kind=recruit_kind%#kinds+1 - local center=players[player_cursor]:get_pos() - if not valid(center) then return end - local kind=kinds[recruit_kind] - if kind=="fox" and nearby(center,RADIUS,"hare")<2 then return end - for _, d in ipairs(offsets) do for y=2,-3,-1 do - local p={x=math.floor(center.x+d[1]+0.5),y=math.floor(center.y+0.5)+y-0.5,z=math.floor(center.z+d[2]+0.5)} - if spawn(p,kind) then return end - end end end) - return {species="hearth:hare",global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET, + return {species="hearth:hare",fauna=F,global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,ai_budget=BUDGET, status=function() - return {total=total,hare=counts.hare,deer=counts.deer,fox=counts.fox, - global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL, - actions=actions,births=births,meals=meals,grazed=grazed,dung=dung} + local c={}; for id,n in pairs(counts) do c[id]=n end + local diagnostics={interval=5,scan_nodes=648,attempts=recruiting.attempts, + successes=recruiting.successes,scans=recruiting.scans,last_reason=recruiting.last_reason,reasons={}} + for k,v in pairs(recruiting.reasons) do diagnostics.reasons[k]=v end + return {total=total,hare=c.hare,deer=c.deer,fox=c.fox,counts=c,species_count=23, + residents={storage_ok=storage_ok,regions=region_count,region_limit=REGION_LIMIT, + region_size=REGION,founders_per_region=FOUNDERS,counter=ledger.counter}, + recruitment=diagnostics, + global_cap=GLOBAL,player_cap=PLAYER,local_cap=LOCAL,actions=actions,births=births, + meals=meals,grazed=grazed,dung=dung,deaths=deaths,pollinations=pollinations,dispersals=dispersals,detritus=detritus} end} end diff --git a/scripts/assets.mjs b/scripts/assets.mjs index b8c6043..2d48c61 100755 --- a/scripts/assets.mjs +++ b/scripts/assets.mjs @@ -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 { 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 { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, faunaB3D, faunaTexture, faunaPortraitSheet } from './fauna-art.mjs'; function crc32(data) { 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)); } 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, (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, (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)); await writeAsset(textures,'hearth_person.png',encode(compositeCharacter())); await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet())); @@ -146,6 +154,7 @@ export async function generateAssets(destination, source) { await ensureDirectory(models); for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name)); for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name)); + for (const id of FAUNA_NAMES) await writeAsset(models,`hearth_fauna_${id}.b3d`,faunaB3D(id)); let vertices = 0; const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1']; function box(x0,y0,z0,x1,y1,z1) { diff --git a/scripts/character-art.mjs b/scripts/character-art.mjs index fcea841..7c63886 100755 --- a/scripts/character-art.mjs +++ b/scripts/character-art.mjs @@ -5,16 +5,16 @@ import { ATLAS, BODY_PROFILES } from './media.mjs'; export const CHARACTER_OPTIONS = Object.freeze(Object.fromEntries(Object.entries({ bodies: BODY_PROFILES, skin: ['porcelain','sand','honey','olive','umber','ebony'], - faces: ['calm','angular','soft','weathered'], + 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'], - facialHair: ['none','stubble','moustache','short_beard','full_beard'], - garments: ['tunic','workshirt','jerkin','coat'], + facialHair: ['none','stubble','moustache','short_beard','full_beard','goatee','braided_beard'], + 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'], - 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'], }).map(([key, values])=>[key,Object.freeze([...values])]))); 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]], 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]], + 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); 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==='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==='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; }; paint(out,'face',(x,y)=>fringe(x,y) ? strand(x,y) : null); paint(out,'hair',(x,y)=>style==='shaved' && (x+y)%2 ? null : strand(x,y)); paint(out,'side',(x,y)=> { - const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1}[style]; + 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 (style==='shaved' && x%2) return null; 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); 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; } function beardLayer(style,color) { @@ -108,10 +113,11 @@ function beardLayer(style,color) { paint(out,'face',(x,y)=> { const moustache = y===5 && x>=2 && x<=5; const lower = y>=6 && x>=1 && x<=6; - const mask = style==='moustache' ? moustache : style==='stubble' ? + 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) : 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); 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==='jerkin' && (x===1 || x===6 || y===5)) c=LEATHER; if (style==='jerkin' && y===2 && (x===3 || x===4)) c=THREAD; - if (style==='coat' && (x===3 || x===4)) c=tint(base,-28); - if (style==='coat' && x===5 && [2,4,6].includes(y)) c=THREAD; - if (style==='coat' && y===0 && (x===1 || x===6)) c=tint(base,17); + if (style==='coat' || style==='travel_coat' || style==='hooded_cloak') { + if (x===3 || x===4) c=tint(base,-28); + 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==='workshirt' && y===5) c=THREAD; return c; @@ -152,22 +165,35 @@ function bootsLayer(id) { function accentLayer(style,color) { const out=image(); if (style==='none') return out; 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)=> { let mask=false; if (style==='scarf') mask=y===0 || tile==='tunic' && x===2 && y<4; if (style==='apron') mask=tile==='tunic' ? x>=1 && x<=6 && y>=2 : tile==='back' && y===3; if (style==='sash') mask=tile!=='sleeve' && (x===y || x===y+1); if (style==='vest') mask=tile!=='sleeve' && (tile==='back' || x<=2 || x>=5); + 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; }); 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==='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; } function detailLayer(id) { const out=image(); if (id.includes('freckles')) for (const [x,y] of [[1,4],[2,5],[5,5],[6,4]]) dot(out,'face',x,y,[119,76,51]); if (id.includes('scar')) for (const [x,y] of [[6,2],[6,3],[7,4]]) dot(out,'face',x,y,[158,109,91]); + if (id==='birthmark') for(const [x,y] of [[6,4],[7,4],[6,5]]) dot(out,'face',x,y,[143,82,75]); return out; } @@ -213,8 +239,9 @@ export function characterLayers(options = {}) { // Clothing precedes long hair: shoulder locks must not be erased by tunics. return [name(`skin_${p.skin}`),name(`face_${p.face}`),name(`eyes_${p.eyes}`), name(`garment_${p.garment}_${p.cloth}`),name(`trousers_${p.trousers}`),name(`boots_${p.boots}`), - name(`accent_${p.accent}_${p.accentColor}`),name(`hair_${p.hair}_${p.hairColor}`), - name(`beard_${p.beard}_${p.hairColor}`),name(`detail_${p.detail}`)]; + name(`hair_${p.hair}_${p.hairColor}`),name(`beard_${p.beard}_${p.hairColor}`), + name(`accent_${p.accent}_${p.accentColor}`), + name(`detail_${p.detail}`)]; } export function characterTexture(options = {}) { return characterLayers(options).join('^'); } export function characterMesh(body = 'balanced') { diff --git a/scripts/fauna-art.mjs b/scripts/fauna-art.mjs new file mode 100755 index 0000000..f83cbc9 --- /dev/null +++ b/scripts/fauna-art.mjs @@ -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 { + 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(zids.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}; +} diff --git a/scripts/flora-art.mjs b/scripts/flora-art.mjs index 418d0ce..2639ba5 100755 --- a/scripts/flora-art.mjs +++ b/scripts/flora-art.mjs @@ -234,7 +234,8 @@ export function treeAtlas(art=worldArt(), season='summer', species=null) { const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`; const underpaint=season==='summer' && species? {oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark', - beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[species]: + 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]; for(const [half,name] of [`${prefix}log_side`,leaves].entries()) { const image=art.get(`hearth_${name}.png`); diff --git a/scripts/media.mjs b/scripts/media.mjs index 63be8d9..c59d029 100755 --- a/scripts/media.mjs +++ b/scripts/media.mjs @@ -114,14 +114,26 @@ function pose(name, frame) { 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') { 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, []])); box(vertices, triangles, weights, 'Body', [-2.15*width,8,-1.18,2.15*width,13.5,1.18], ['back','tunic','tunic','tunic','tunic','tunic'], [profile === 'broad' ? .9 : .86, .86]); box(vertices, triangles, weights, 'Head', [-1.6,13.5,-1.4,1.6,PLAYER_HEIGHT,1.4], ['hair','face','skin','hair','side','side']); + // 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]]) { 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]); diff --git a/scripts/package.mjs b/scripts/package.mjs index 8c559af..c44b5c6 100755 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -7,6 +7,9 @@ import { generateAssets } from './assets.mjs'; import { absolute, atomicDirectory, copyTree, disjoint, exists, json, relativeName, safePath, verifyManifest, writeManifest } from './files.mjs'; export const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +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) { await safePath(path.join(absolute(source), 'release.json')); 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. for (const entry of ['mods', 'fixtures', 'tests', 'scripts']) disjoint(path.join(source, entry), destination); 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 => { for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) { 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'), { 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']) { 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 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'); 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; } diff --git a/scripts/run.mjs b/scripts/run.mjs index e6b7fcf..6c3438f 100755 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -178,7 +178,8 @@ export async function writeWorld(world, config, location, extra = [], environmen 'movement_speed_jump = 6.5', 'secure.enable_security = true', ...graphics, ...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 }); } diff --git a/scripts/terrain.mjs b/scripts/terrain.mjs index 920890b..4012679 100755 --- a/scripts/terrain.mjs +++ b/scripts/terrain.mjs @@ -1,22 +1,24 @@ -// Original v7 preset: creation-time inputs, never a saved-world migration. -// Keep in sync with game defaults; map_meta.txt belongs exclusively to Luanti. +// Original epic_terrain mod uses v7 only as a cave/ore substrate. Its switch, +// 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([ - 'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 1', + 'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 32', '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_mount_zero_level = 0', - 'mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0', - 'mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0', + 'mgv7_np_terrain_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 = -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_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 = 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', ]); export function terrainSettings(newWorld) { @@ -25,5 +27,5 @@ export function terrainSettings(newWorld) { } 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); } diff --git a/scripts/wiki-species.mjs b/scripts/wiki-species.mjs index 90322dd..2bd6b17 100755 --- a/scripts/wiki-species.mjs +++ b/scripts/wiki-species.mjs @@ -8,7 +8,7 @@ import { safePath } from './files.mjs'; const ROOT = fileURLToPath(new URL('../', import.meta.url)); const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua'); const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua'); -export const LIMITS = Object.freeze({ species: 80, batch: 10, concurrency: 2, +export const LIMITS = Object.freeze({ species: 100, batch: 10, concurrency: 2, responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024, characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 }); export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)'; @@ -30,7 +30,7 @@ function latinName(value) { return value; } 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(); for (const r of records) { 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.'); 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); } @@ -312,7 +312,7 @@ export async function main(args = process.argv.slice(2)) { requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]), 'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)'); if (args[0] === '--help') { - console.log('--refresh: query all 80 species; replace the fixed Lua cache with verified intros or explicit missing-page notes after full validation.\n--check: verify fixed offline cache/catalogue without network.'); + 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; } 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.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; } const entries = await fetchEntries(records); @@ -347,7 +347,7 @@ export async function main(args = process.argv.slice(2)) { await safePath(OUTPUT); const handle = await fs.open(OUTPUT, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), 0o644); try { await handle.writeFile(source, 'utf8'); } finally { await handle.close(); } - console.log(`Cached ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes (${entries.filter(e => e.truncated).length} shortened); ${Math.ceil(records.length / LIMITS.batch)} API batches, concurrency <=2.`); + 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)) { diff --git a/scripts/world-art.mjs b/scripts/world-art.mjs index b31fa0e..3da66a5 100755 --- a/scripts/world-art.mjs +++ b/scripts/world-art.mjs @@ -160,7 +160,8 @@ function planks(d,vertical=false,species='oak') { pine:['straw','strawDark','strawLight'],willow:['willow','willowShade','willowGlow'], alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'], rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'], - spruce:['strawLight','wood','cream']}[species]; + 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); 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); @@ -174,8 +175,19 @@ function bark(d,species='oak') { pine:['barkLight','bark','wood'],willow:['humus','barkDark','loamGlow'], alder:['loamShade','humus','earthLight'],beech:['mineralShade','slateShade','mineralGlow'], aspen:['birchShade','willowShade','birchGlow'],rowan:['earthLight','bark','birchShade'], - hazel:['barkLight','loamShade','willow'],spruce:['earth','humus','rust']}[species]; + 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); + 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)) { // Smooth beech, diamond-scarred aspen, lenticellate rowan/hazel and scaly spruce. 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'], alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'], rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'], - spruce:['strawLight','wood','cream']}[species]; + 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; 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); @@ -392,7 +405,8 @@ function plant(d,name) { export const SEASONAL_PLANT_NAMES = Object.freeze(['leaves_spring','leaves_autumn','leaves_winter', 'meadow_grass_dormant','fern_dormant','wildflowers_dormant','spring_greens','spring_greens_dormant', 'autumn_mushrooms','autumn_mushrooms_dormant']); -export const SPECIES_NAMES = Object.freeze(['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce']); +export const 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 ROCK_NAMES = Object.freeze(['granite','basalt','limestone','slate','sandstone']); 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') { d.poly([[x,y+4],[x-5,y],[x-4,y-4],[x-1,y-4],[x,y-2],[x+3,y-4],[x+5,y],[x,y+4]],dark) .line(x,y+2,x-2,y-2,base,2).line(x,y,x+3,y-2,light); + } else 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 { 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); @@ -595,8 +619,8 @@ function habitatTile(d,name) { function worldTile(name) { const d=new Drawing(); - const speciesMatch=name.match(/^(oak|birch|pine|willow|alder|beech|aspen|rowan|hazel|spruce)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/); - if(speciesMatch) { + const speciesMatch=name.match(/^([a-z]+(?:_[a-z]+)*)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/); + if(speciesMatch && SPECIES_NAMES.includes(speciesMatch[1])) { const [,species,part,season]=speciesMatch; if(part==='leaves') speciesLeaves(d,species,season); else if(part==='log_top') endgrain(d,species); @@ -989,6 +1013,9 @@ const BOTANY_GROUPS = { ['rowan','Sorbus aucuparia','pinnate leaflets; clustered red pomes'], ['hazel','Corylus avellana','heart-shaped serrate leaves; husked nuts'], ['spruce','Picea abies','short radial needles; pendant long cones'], + ['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: [ ['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'], ['juniper','Juniperus communis','needle whorls of three; blue berry-like cones'], ['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: [ ['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'], ['cowslip','Primula veris','wrinkled basal rosette; nodding yellow tubular umbel'], ['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: [ ['bracken','Pteridium aquilinum','three-part triangular divided frond'], @@ -1037,6 +1080,7 @@ const BOTANY_GROUPS = { ['moss','Hypnum cupressiforme','creeping feather-like plaits; hooked tips'], ['fork_moss','Dicranum scoparium','cushion of swept sickle-shaped leaves'], ['star_moss','Mnium hornum','upright shoots; broad oval leaves; nodding capsules'], + ['holly_fern','Polystichum setiferum','arching bipinnate fronds; bristle-tipped pinnules; forked pinnae'], ], fungi: [ ['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); } } +// 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) { const k=SPECIES_NAMES.indexOf(id), conifer=EVERGREEN_NAMES.includes(id); 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]], rowan:[[7,18],[10,26],[21,13],[26,23],[14,8]], 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]; for(const [i,[x,y]] of positions.entries()) { 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(id==='rowan') compound(d,x-4,y+1,9,i%2?-1:1); 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); 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'); @@ -1155,7 +1363,12 @@ function botanicalTree(d,id,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') { 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'); @@ -1165,6 +1378,9 @@ function botanicalTree(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 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]]; @@ -1225,6 +1441,9 @@ function botanicalGrass(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)) { understory(d,id+(dormant?'_dormant':'')); // 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'); } 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); if(k<5) { if(id==='harts_tongue') { diff --git a/tests/assets.test.mjs b/tests/assets.test.mjs index 6835324..d097855 100755 --- a/tests/assets.test.mjs +++ b/tests/assets.test.mjs @@ -9,6 +9,7 @@ import path from 'node:path'; import { inflateSync } from 'node:zlib'; import { generateAssets } from '../scripts/assets.mjs'; import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterLayer, characterMesh, compositeCharacter } from '../scripts/character-art.mjs'; +import { 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 { inventory } from '../scripts/files.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)); assert.deepEqual(model.animation,[0,100,24]); assert.equal(model.chunks.filter(c => c==='MESH').length,1); - assert.equal(model.vertices.length,240); assert.equal(model.triangles.length,120); + assert.equal(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.min(...model.vertices.map(v => v[1])),0); 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]))); assert.ok(Math.max(...torso.filter(v=>v[1]===8).map(v=>v[0]))balanced.vertices[id][0]))-1.6)<1e-6); + assert.ok(widths.length===CHARACTER_OPTIONS.bodies.length && widths.every((value,i)=>i===0||widths[i-1]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)); }); @@ -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.options,CHARACTER_OPTIONS); assert.deepEqual(manifest.meshes,CHARACTER_OPTIONS.bodies.map(body=>({body,filename:characterMesh(body)}))); - assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,170); + assert.equal(names.filter(n=>n.startsWith('hearth_char_')).length,CHARACTER_ART_MANIFEST.length); for (const {filename} of CHARACTER_ART_MANIFEST) { const decoded=parsePNG(await fs.readFile(path.join(textures,filename))), layer=characterLayer(filename); assert.equal(decoded.width,64); assert.equal(decoded.height,64); @@ -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)); for (const name of ['init','model','runtime','content','encounters']) await fs.writeFile(path.join(source,'mods/hearth',name+'.lua'),'-- fixture\n'); const destination = path.join(root,'package'); await packageGame(destination,{source}); - const {entries} = await verifyPackage(destination); + const {entries} = await verifyPackage(destination,{requireBundled:false}); for (const name of Object.keys(SOUND_PROFILES)) { const entry = entries.find(e=>e.path===`mods/hearth/sounds/${name}.ogg`); assert.ok(entry,name); assert.equal(entry.sha256,SOUND_HASHES[name]); @@ -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')); for (const {filename} of CHARACTER_ART_MANIFEST) 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) assert.ok(entries.some(e=>e.path===`mods/hearth/models/${characterMesh(body)}`)); }); diff --git a/tests/biomes_spec.lua b/tests/biomes_spec.lua index 6fa3050..f025adc 100755 --- a/tests/biomes_spec.lua +++ b/tests/biomes_spec.lua @@ -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("oak", false)) end) -test("ten exact catalogue species have real full-block trunks, leaves, and timber", function() - local f=loaded(); assert(table.concat(f.m.species_order,",")=="oak,birch,pine,willow,alder,beech,aspen,rowan,hazel,spruce") +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,crab_apple,wild_cherry,field_maple") for _, species in ipairs(f.m.species_order) do local log=f.nodes["hearth:"..species.."_log"] 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) end end) -test("all 40 terrain recipes have unique shapeless inputs including multiplicity", function() - local f=loaded(); assert(#f.crafts==40) +test("all 49 terrain recipes have unique shapeless inputs including multiplicity", function() + local f=loaded(); assert(#f.crafts==49) local seen={} for _, craft in ipairs(f.crafts) do 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 end end end - -- Five extra woods add log top/side, leaves and planks (20 textures). - assert(count(textures)==62, "unexpected world tile count "..count(textures)) - assert(f.registrations()==51, "unexpected private node count "..f.registrations()) + -- Thirteen woods each have their own end grain, bark, foliage and planks. + assert(count(textures)==74, "unexpected world tile count "..count(textures)) + assert(f.registrations()==60, "unexpected private node count "..f.registrations()) end) print("PASS " .. passed .. " biome/geology checks") diff --git a/tests/botany_spec.lua b/tests/botany_spec.lua index 346f822..eebde1f 100755 --- a/tests/botany_spec.lua +++ b/tests/botany_spec.lua @@ -33,9 +33,9 @@ local function reset() auth=auth+1; assert(q==p and player:is_player() and action=="use"); return allowed end} end -local inv={room_for_item=function(_,list,item) assert(list=="main" and item=="hearth:berries 2"); return room end, +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) - 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} 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) end local function bush(id) world[key(p)]={name="hearth:"..(id or "bilberry")} end -test("exactly eighty disjoint real taxa in eight ordered groups of ten",function() - assert(#B.groups==8 and #B.catalogue==80 and #B.list()==80) +test("exactly one hundred disjoint real taxa in eight uneven groups",function() + 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") local ids,latin={},{},{} 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 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) @@ -69,6 +69,21 @@ test("exactly eighty disjoint real taxa in eight ordered groups of ten",function 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() 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", @@ -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").niche.light[1]==4) 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 end) 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(writes==0) 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 for name,def in pairs(defs) do 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(not def.walkable and not def.buildable_to and def.groups.hearth_ecosystem==1) end - assert(count==62); register(); local again=0; for _ in pairs(defs) do again=again+1 end; assert(again==count) + assert(count==79); register(); local again=0; for _ in pairs(defs) do again=again+1 end; assert(again==count) end) test("existing registered definitions are preserved and construction stays late-bound",function() local marker={description="external owner"}; defs["hearth:bilberry"]=marker @@ -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) assert(not ok and reason=="denied" and auth==1 and writes==0 and added==0) allowed=true; ok,reason=B.harvest(p,player) - assert(ok and reason==2 and added==2 and tonumber(fields[key(p)].botany_ready_at)==time+86400) + assert(ok and reason==2 and added==2 and tonumber(fields[key(p)].botany_ready_at)==time+365.2425*86400) end) test("cooldown has no catchup and survives a new module instance",function() bush(); assert(B.harvest(p,player)); assert(not B.harvest(p,player) and added==2) policies={} -- a fresh factory may reinstall the same seasonal policy B=factory(H,A); assert(not B.harvest(p,player)); time=time+86400*30 + assert(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) end) test("full inventory does not consume stock or start cooldown",function() bush(); room=false; local ok,reason=B.harvest(p,player) - assert(not ok and reason=="inventory" and writes==0 and added==0) + 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=="") leftover=1; ok,reason=B.harvest(p,player); assert(ok and reason==1 and added==1) 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) inv.add_item=original; assert(added==2) 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 local h=habitat(r.id); h.include_trees=true local x=0 - while (x*17+7)%80~=index-1 do x=x+1; assert(x<80) end + 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)) assert(k==r.node:sub(8) and selected.id==r.id,r.id) 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") 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 for _,r in ipairs(B.list()) do 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 - assert(count==62) + assert(count==79) policies.holly.active.Winter=false; assert(B.active("holly","Winter")) assert(next(policies.juniper.definitions)==nil and next(policies.holly.definitions)==nil) end) @@ -354,7 +371,7 @@ test("harvest samples one validated epoch before climate and uses the same coold return {season="Summer"} end assert(B.harvest(p,player) and clock_reads==1 and climate_calls==1) - assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+86400) + assert(tonumber(fields[key(p)].botany_ready_at)==1782043200+365.2425*86400) end) test("only exact owned seasonal aliases harvest in an active fruit season",function() 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 local ok,reason=B.harvest(p,player); assert(not ok and reason=="season" and writes==0) time=1797854400; assert(B.harvest(p,player) and added==2) - assert(tonumber(fields[key(p)].botany_ready_at)==time+86400) + assert(tonumber(fields[key(p)].botany_ready_at)==time+365.2425*86400) 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) diff --git a/tests/character-art.test.mjs b/tests/character-art.test.mjs index ba94c9e..d3f92b6 100755 --- a/tests/character-art.test.mjs +++ b/tests/character-art.test.mjs @@ -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); -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,{ - bodies:['compact','balanced','broad'],skin:['porcelain','sand','honey','olive','umber','ebony'], - faces:['calm','angular','soft','weathered'],eyes:['hazel','brown','green','blue','grey'], - hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none'], + bodies:['compact','lean','balanced','broad','sturdy'],skin:['porcelain','sand','honey','olive','umber','ebony'], + faces:['calm','angular','soft','weathered','lined'],eyes:['hazel','brown','green','blue','grey'], + hairStyles:['cropped','sidepart','wavy','braids','long','shaved','none','curly','topknot','undercut'], hairColors:['ash','flax','chestnut','auburn','black','silver'], - facialHair:['none','stubble','moustache','short_beard','full_beard'], - garments:['tunic','workshirt','jerkin','coat'],clothColors:['sage','teal','slate','mauve','ochre','rust','linen','charcoal'], + facialHair:['none','stubble','moustache','short_beard','full_beard','goatee','braided_beard'], + 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'], - 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], ['beard',O.facialHair,O.hairColors],['garment',O.garments,O.clothColors],['trousers',O.trousers], ['boots',O.boots],['accent',O.accents,O.clothColors],['detail',O.details]]; const expected=specs.flatMap(([kind,ids,colors])=>ids.flatMap(id=>(colors||[null]).map(color=> `hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`))); - assert.equal(M.length,170); assert.equal(new Set(expected).size,170); + assert.equal(M.length,expected.length); assert.equal(new Set(expected).size,expected.length); assert.deepEqual(M.map(r=>r.filename),expected); assert.ok(Object.isFrozen(O) && Object.values(O).every(Object.isFrozen) && M.every(Object.isFrozen)); assert.deepEqual([...characterArt().keys()],expected); @@ -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',()=> { const allowed={face:['face'],eyes:['face'],hair:['face','hair','side','back','sleeve'], beard:['face','side'],garment:['tunic','back','sleeve'],trousers:['trousers'],boots:['boot'], - accent:['tunic','back','sleeve'],detail:['face']}; + accent:['tunic','back','sleeve','face','hair','side'],detail:['face']}; for (const r of M.filter(r=>r.kind!=='skin')) { const im=characterLayer(r.filename), tiles=new Set(allowed[r.kind].map(t=>ATLAS[t].join(','))); for (let y=0;y<64;y++) for (let x=0;x<64;x++) if (im.data[(y*64+x)*4+3]) @@ -116,10 +116,10 @@ test('layer names and order match Luanti composition; garments cannot erase long assert.deepEqual(names,[ 'hearth_char_skin_sand.png','hearth_char_face_calm.png','hearth_char_eyes_hazel.png', 'hearth_char_garment_coat_rust.png','hearth_char_trousers_earth.png','hearth_char_boots_walnut.png', - 'hearth_char_accent_scarf_sage.png','hearth_char_hair_long_chestnut.png', - 'hearth_char_beard_none_chestnut.png','hearth_char_detail_none.png']); + 'hearth_char_hair_long_chestnut.png','hearth_char_beard_none_chestnut.png', + 'hearth_char_accent_scarf_sage.png','hearth_char_detail_none.png']); assert.equal(characterTexture({hair:'long',garment:'coat',cloth:'rust'}),names.join('^')); - const im=compositeCharacter({hair:'long',garment:'coat',cloth:'rust'}), hair=characterLayer(names[7]); + 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,'sleeve',0,1),pixel(hair,'sleeve',0,1)); 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('coat'),'tunic',3,3),pixel(garment('coat'),'tunic',2,3),'coat opening'); const masks=O.accents.map(style=>characterLayer(`hearth_char_accent_${style}_sage.png`).data.filter((_,i)=>i%4===3)); - assert.equal(new Set(masks.map(bytes=>createHash('sha256').update(bytes).digest('hex'))).size,5); + 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]); }); diff --git a/tests/character_spec.lua b/tests/character_spec.lua index 06cce90..a41c13d 100755 --- a/tests/character_spec.lua +++ b/tests/character_spec.lua @@ -56,8 +56,8 @@ local function harness() end test("factory exports fresh defaults and exact independent option lists", function() local h = harness(); local C = h.C - local counts = {body=3,skin=6,face=4,eyes=5,hair=7,hairColor=6,beard=5,garment=4,cloth=8, - trousers=4,boots=3,accent=5,accentColor=8,detail=4} + 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=9,accentColor=8,detail=5} for k,n in pairs(counts) do assert(#C.options()[k]==n,k) end local d, o = C.defaults(), C.options(); d.skin="BAD"; o.skin[1]="BAD" assert(C.defaults().skin=="sand" and C.options().skin[1]=="porcelain" and C.VERSION==1) @@ -88,7 +88,7 @@ end) test("preview exactly follows opaque skin face eyes clothing accent hair beard detail order", function() local C=harness().C; local mesh,texture=C.preview(C.defaults()) assert(mesh=="hearth_person.b3d") - assert(texture=="hearth_char_skin_sand.png^hearth_char_face_calm.png^hearth_char_eyes_hazel.png^hearth_char_garment_tunic_teal.png^hearth_char_trousers_earth.png^hearth_char_boots_walnut.png^hearth_char_accent_scarf_sage.png^hearth_char_hair_sidepart_chestnut.png^hearth_char_beard_none_chestnut.png^hearth_char_detail_none.png") + assert(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({})) end) 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.props==before and m.writes==0) 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() 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] @@ -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 original=p.set_properties p.set_properties=function(self,props) assert(m.writes==1 and h.C.decode(m.raw).skin=="ebony"); original(self,props) end - assert(h.C.handle(p,{pick_skin="6",pick_body="3",apply=true})) + assert(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") 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) @@ -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.connected.alice=p; h.C.show(p); h.C.handle(p,{cancel=true}); assert(not h.C.handle(p,{apply=true})) end) -assert(total==18,"Unexpected character test count") +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") diff --git a/tests/content_spec.lua b/tests/content_spec.lua index 64ed38c..72a1a3c 100755 --- a/tests/content_spec.lua +++ b/tests/content_spec.lua @@ -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) local index = {} 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,",")) for _, species in ipairs(hearth.biomes.species_order) 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 assert(index["hearth:oak_leaves"] < index["hearth:oak_seedling"]) assert(index["hearth:lichen"] < index["hearth:oak_leaves_winter"]) - eq(#hearth.botany.catalogue,80) + eq(#hearth.botany.catalogue,100) local botany_nodes=0 for _,record in ipairs(hearth.botany.catalogue) do 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) end end - eq(botany_nodes,62) + eq(botany_nodes,79) for _, name in ipairs(hearth.diversity.plants) do assert(nodes["hearth:" .. name]) end eq(hearth.diversity.supports_species("pine", "podzol"), true) eq(hearth.diversity.supports_species("oak", "podzol"), false) @@ -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:sticks 4", {"hearth:planks"}) 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 -- ingredient multisets whenever either recipe is shapeless. This catches -- single-input collisions even across craft types without pretending to be -- the native resolver (the native smoke still calls get_craft_result). - eq(#crafts,78) + eq(#crafts,87) local signatures, outputs={},{} for i, def in ipairs(crafts) do 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()} 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 local matches=0 for j, candidate in ipairs(signatures) do @@ -965,14 +965,16 @@ test("botanical silhouettes have crisp alpha and waving without losing ecosystem end eq(nodes["hearth:torch"].use_texture_alpha, "clip") end) -test("fauna register the original species meshes at node scale", function() - for _, kind in ipairs({"hare", "fox", "deer"}) do - local def = assert(entities["hearth:" .. kind], kind) +test("all 23 fauna register the actual animated catalogue assets at node scale", function() + for _, r in ipairs(hearth.fauna.list()) do + local def = assert(entities[r.entity], r.id) local props = def.initial_properties - eq(props.visual, "mesh"); eq(props.mesh, "hearth_" .. kind .. ".obj") - eq(#props.textures, 1); eq(props.textures[1], "hearth_" .. kind .. ".png") + eq(props.visual, "mesh"); eq(props.mesh, r.mesh) + eq(#props.textures, 1); eq(props.textures[1], r.texture) 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) 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) 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 +assert(passed==40,"Unexpected focused content integration count") print("1.." .. passed) diff --git a/tests/diversity_spec.lua b/tests/diversity_spec.lua index 0ce1932..e589b42 100755 --- a/tests/diversity_spec.lua +++ b/tests/diversity_spec.lua @@ -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 hearth.seasons.register() 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() - 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) 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]) diff --git a/tests/ecology_spec.lua b/tests/ecology_spec.lua index a7fcdd8..778e86c 100755 --- a/tests/ecology_spec.lua +++ b/tests/ecology_spec.lua @@ -67,14 +67,18 @@ local function test(name, fn) end 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 assert(ecology.climate_progress(planted_at, planted_at + cold_days) == 0) climate_temperature = 15 - local warm_days = 6 * 86400 - assert(ecology.crop_stage(planted_at, planted_at + warm_days) == 4) + assert(ecology.crop_stage(planted_at,planted_at+119*86400)<4) + assert(ecology.crop_stage(planted_at,planted_at+120*86400)==4) 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(planted_at, math.huge) == 0) 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(p)] = {name="hearth:crop_1"} meta(p):set_string("planted_at", tostring(planted_at)) - local stage = ecology.crop_stage(planted_at, planted_at + 2 * 86400) + local stage = ecology.crop_stage(planted_at, planted_at + 30 * 86400) 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) - now = planted_at + 30 * 86400 + now = planted_at + 120 * 86400 assert(ecology.update_crop(p) == false and nodes[key(p)].name == "hearth:crop_4") end) -test("berry regrowth is a single absolute deadline and harvest does not destroy the bush", function() +test("berry regrowth is annual, Summer gated and harvest does not destroy provenance", function() local p = {x=2,y=1,z=0} nodes[key(p)] = {name="hearth:berry_bush"} + meta(p):set_string("succ_natural","1") inventory_room = false ecology.harvest_berries(p, player) 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) assert(nodes[key(p)].name == "hearth:berry_bush_bare" and delivered == 3) local ready = tonumber(meta(p):get_string("ready_at")) - assert(ready == now + 86400) + assert(ready == now + 365.2425*86400 and meta(p):get_string("succ_natural")=="1") ecology.harvest_berries(p, player) assert(delivered == 3, "bare bush must not yield repeated berries") assert(ecology.refresh_bush(p) == true and nodes[key(p)].name == "hearth:berry_bush_bare") now = ready + season="Winter"; assert(ecology.refresh_bush(p)==true) + season="Summer" assert(ecology.refresh_bush(p) == false and nodes[key(p)].name == "hearth:berry_bush") end) test("partial inventory harvest cannot duplicate crops or berries", function() @@ -130,13 +137,15 @@ test("immature or unauthorized crops cannot be harvested", function() assert(nodes[key(p)].name == "hearth:crop_4" and delivered == before) permitted = true end) -test("sapling growth waits, respects claims, and writes a bounded tree once", function() - now = 500000 +test("generic sapling waits one real year, respects claims, and writes a bounded young tree once", function() + now = 2000000000 local p = {x=8,y=1,z=8} nodes[key({x=8,y=0,z=8})] = {name="hearth:grass"} nodes[key(p)] = {name="hearth:sapling"} meta(p):set_string("planted_at", tostring(now - 3 * 86400)) meta(p):set_string("planter", "gardener") + assert(ecology.grow_sapling(p)==true and nodes[key(p)].name=="hearth:sapling") + now=now+365.2425*86400 permitted = false assert(ecology.grow_sapling(p) == true and nodes[key(p)].name == "hearth:sapling") 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) 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 print("PASS " .. passed .. " bounded ecology tests") diff --git a/tests/engine_smoke.lua b/tests/engine_smoke.lua index 236cd83..797a895 100755 --- a/tests/engine_smoke.lua +++ b/tests/engine_smoke.lua @@ -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") assert(deadwood.can_dig == hearth.construction.can_dig, "Fallen logs bypass construction permissions") local timber = {"log", "stone"} - assert(#hearth.diversity.species_order == 10, "Expected ten timber species") + assert(#hearth.diversity.species_order == 13, "Expected thirteen timber species") for _, species in ipairs(hearth.diversity.species_order) do local name = species .. "_log" timber[#timber + 1] = name @@ -145,7 +145,7 @@ local function registry_check() "Bare hands unexpectedly harvest " .. name) end 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 assert(core.registered_nodes[record.node], "Missing native botanical species: " .. record.node) end @@ -168,7 +168,7 @@ local function registry_check() end end assert(deadwood.drawtype == "normal" and deadwood.paramtype2 == "facedir", "Fallen log must be a full facedir cube") - core.log("action", "Last Hearth native botany/wood: species=80, timber_species=10, nonmesh_tree_nodes=" + 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") for _, name in ipairs({"knife", "axe", "iron_axe", "pick", "spear", "firestarter", "vessel", "fire", "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 assert(core.get_mapgen_setting(setting) == expected, "Saved mapgen setting mismatch: " .. setting) end - for setting, expected in pairs({mgv7_np_terrain_base={12,45,384}, mgv7_np_terrain_alt={12,28,384}, - mgv7_np_mount_height={250,100,700}, mgv7_np_mountain={-0.6,1,180}}) do + 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_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) 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 - 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 local function unit_check() @@ -346,7 +348,7 @@ local function unit_check() for _, module in ipairs({"model", "content", "schema", "persistence", "clock", "players", "runtime", "access", "environment", "init", "ecology", "seasons", "succession", "wildlife", "movement", "combat", "construction", "course", "encounters", "presentation", "biomes", "diversity", - "m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character"}) do + "m2_processes", "m2_rest", "recipe", "botany", "species_effects", "species_reader", "character", "lifecycle", "fauna"}) do assert(lookup["tests/" .. module .. "_spec.lua"], "Missing regression suite: " .. module) end local test_modules = {m2_processes = "processes", m2_rest = "runtime", recipe = "interface"} @@ -391,7 +393,7 @@ local function unit_check() count = count + 1 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") end diff --git a/tests/fauna-art.test.mjs b/tests/fauna-art.test.mjs new file mode 100755 index 0000000..32b63b9 --- /dev/null +++ b/tests/fauna-art.test.mjs @@ -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)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=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;qv+(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&&v0&&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&&vm.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.001,`${id} ${clip}: genuine skeletal displacement`); + for(let v=0;vn.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 { + 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;j4,`${names[i]}: actual shaded mesh, not an empty paper tile`); + } + assert.equal(cells.size,23); +}); diff --git a/tests/fauna_spec.lua b/tests/fauna_spec.lua new file mode 100755 index 0000000..7b406b7 --- /dev/null +++ b/tests/fauna_spec.lua @@ -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") diff --git a/tests/flora-art.test.mjs b/tests/flora-art.test.mjs index 5e91d1b..019cf49 100755 --- a/tests/flora-art.test.mjs +++ b/tests/flora-art.test.mjs @@ -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 { packageGame, verifyPackage } from '../scripts/package.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 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/); }); -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 meshes=species.flatMap(s=>[`${s}_seedling`,`${s}_young`,`${s}_mature`, ...(s==='pine'?[]:[`${s}_young_winter`,`${s}_mature_winter`])]); const atlases=SPECIES_NAMES.flatMap(s=>[`${s}_atlas`,...(EVERGREEN_NAMES.includes(s)?[]:['spring','autumn','winter'].map(n=>`${s}_atlas_${n}`))]); assert.deepEqual(LEGACY_MESH_SPECIES,species); assert.deepEqual(SPECIES_MESH_NAMES,meshes); assert.deepEqual(SPECIES_ATLAS_NAMES,atlases); - assert.equal(meshes.length,23); assert.equal(atlases.length,34); assert.equal(FLORA_NAMES.length,29); + 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']) 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`)); @@ -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)); else { const color=season==='summer'?{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark', - beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[s]: + 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]; 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']) assert.throws(()=>treeAtlas(art,season,s),/evergreen/); 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); } 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); 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`), ...['knife','axe','pick','spear'].map(n=>`hearth_icon_${n}.png`), ...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_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')), - {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()); const expectedModels=[...FLORA_NAMES,...['hare','fox','deer','knife','axe','pick','spear','person']] - .map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d'); - assert.equal(expectedModels.length,40,'37 OBJ files plus three animated player builds'); + .map(n=>`hearth_${n}.obj`).concat('hearth_person.b3d','hearth_person_compact.b3d','hearth_person_broad.b3d', + ...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()); } 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})); await fs.mkdir(path.join(source,'tests/fixtures'),{recursive:true}); await fs.writeFile(path.join(source,'tests/fixtures/scenarios.json'),'{}'); - const packaged=path.join(root,'package'); await packageGame(packaged,{source}); await check(packaged); await verifyPackage(packaged); + const 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); 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'); @@ -313,9 +329,9 @@ test('minimal fixture generation and integrity-verified package include every se 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 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.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"\}/); @@ -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,/plant~="moss" and plant~="lichen"/,'persistent groundcover has no dormant alias'); const biomes=await fs.readFile(new URL('../mods/hearth/biomes.lua',import.meta.url),'utf8'); - const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z]+"/g).map(s=>s.slice(1,-1)); + const biomeSpecies=biomes.match(/species_order\s*=\s*\{([^}]+)\}/)[1].match(/"[a-z_]+"/g).map(s=>s.slice(1,-1)); assert.deepEqual(biomeSpecies,SPECIES_NAMES); const art=worldArt(); for(const s of species) for(const face of ['log_top','log_side','leaves','planks']) assert.ok(art.has(`hearth_${s}_${face}.png`)); const docs=await fs.readFile(new URL('../docs/art-direction.md',import.meta.url),'utf8'); const current=docs.slice(0,docs.indexOf('
')); - assert.match(current,/512×736/); assert.match(current,/1–512/); assert.match(current,/16 columns/); + assert.match(current,/512×864/); assert.match(current,/1–512/); assert.match(current,/16 columns/); assert.match(current,/ceil\(count\/16\)\*32/); - assert.match(current,/360/); assert.match(current,/160/); assert.match(current,/botanicalLatin/); + assert.match(current,/421/); assert.match(current,/200/); assert.match(current,/botanicalLatin/); for(const s of SPECIES_NAMES) assert.ok(current.includes(s)); }); diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua new file mode 100755 index 0000000..d215a9d --- /dev/null +++ b/tests/lifecycle_spec.lua @@ -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.max0) + 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") diff --git a/tests/recipe_spec.lua b/tests/recipe_spec.lua index 40a46a6..c026ff0 100755 --- a/tests/recipe_spec.lua +++ b/tests/recipe_spec.lua @@ -13,7 +13,7 @@ local items = { ["hearth:log"] = {description="Timber log"}, ["hearth:stone"] = {description="Weathered stone"}, ["hearth:alluvium"] = {description="Alluvium"}, } -local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce"} +local species = {"oak", "birch", "pine", "willow", "alder", "beech", "aspen", "rowan", "hazel", "spruce", "crab_apple", "wild_cherry", "field_maple"} local rocks = {"granite", "basalt", "limestone", "slate", "sandstone"} for _, s in ipairs(species) do 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}} dofile(target) 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:set_inventory_formspec(spec) self.inventory_spec=spec end 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} hearth.wildlife={status=function() snapshots=snapshots+1 - return {hare=3,deer=2,fox=1,total=6,global_cap=32,local_cap=6,player_cap=6} + 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} assert(commands.ecosystem.func("alice")) assert(reads==1 and snapshots==1 and latest().form=="hearth:ecosystem") for _,text in ipairs({"Natural grass cover: 3", "Flower-support cover: 2", "Shrub / fern / aquatic / tree cover: 4", "Moisture: 65%", "Hares: 3", "Deer: 2", "Foxes: 1", "Active total: 6 / 32 globally", "not a local census", "Loaded sample", "No terrain is loaded", "Ground: Alluvium", - "Pollinator-support plants: 7", "Nutrient reserve: 2 / 8", "pine and spruce stay evergreen", "deciduous canopy thins"}) do + "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) end registered.fields(player,"hearth:guide",{ecosystem_survey=true}) @@ -323,14 +330,14 @@ end) -- Actual catalogue factory/methods, not an invented list of test species. local botany_path = target:match("^(.*[/\\])") or "" local actual_botany = dofile(botany_path .. "botany.lua")(hearth,{}) -test("actual botanical catalogue displays eighty species in eight ten-row pages", function() +test("actual botanical catalogue displays one hundred species in ten ten-row pages and eight uneven groups", function() hearth.botany=actual_botany local records, groups = actual_botany.list(), {} - assert(#records==80) - for page=1,8 do + assert(#records==100) + for page=1,10 do hearth.botanical_guide(player,"",page) 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 assert(rows==10,"each page must contain ten species rows") 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)) 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) for _,r in ipairs(records) do assert(latest().spec:find(core.formspec_escape(r.common.." ("..r.latin..")"),1,true),r.id) 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) assert(hearth.sessions.alice.botany_query==" lichen" and hearth.sessions.alice.botany_page==1) for _,p in ipairs({0/0,-8,"bad",0}) do hearth.botanical_guide(player,"",p); assert(hearth.sessions.alice.botany_page==1) end - hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==8) + hearth.botanical_guide(player,"",math.huge); assert(hearth.sessions.alice.botany_page==10) local record=actual_botany.list()[1]; record.common=query hearth.botany={list=function() return {record} end} 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) player.hud_set_hotbar_image,player.hud_set_hotbar_selected_image=image,selected end) -assert(total==38,"The original 38 recipe/journal checks must remain intact") +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() hearth.botany=actual_botany 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) test("botanical article selection uses trusted current-page numeric rows only",function() hearth.botanical_guide(player,"",2) - assert(latest().spec:find("tooltip[species_choice;Species article]",1,true)) + 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"}) - 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 hearth.botanical_guide(player,"",2); local n=#screens 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 registered.fields(player,"",{character_creator=true}); assert(called == 1) 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") diff --git a/tests/seasons_spec.lua b/tests/seasons_spec.lua index ddd74ff..7d5b1f5 100755 --- a/tests/seasons_spec.lua +++ b/tests/seasons_spec.lua @@ -95,11 +95,13 @@ test("callbacks, authorization, drops and geometry survive every variant", funct end end) test("claimed handplaced flora can recolor with no metadata access or harvest refill", function() - data={succ_age="8412",succ_natural="1",succ_harvest_ready_at=tostring(now+86400),player_placed="1",owner="builder"} + 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 put("spring_greens_patch"); season="Spring"; assert(seasons.reconcile(p)) season="Winter"; assert(seasons.reconcile(p)) assert(data==reference and data.succ_age=="8412" and data.player_placed=="1") + assert(data.succ_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) end) 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 - assert(count==62) + assert(count==79) end) test("botany leaves diversity-owned taxa and block tree policies untouched", function() local diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(hearth,api) diff --git a/tests/species_effects_spec.lua b/tests/species_effects_spec.lua index d081b22..ca9f19c 100755 --- a/tests/species_effects_spec.lua +++ b/tests/species_effects_spec.lua @@ -2,6 +2,7 @@ local target=assert(arg[1],"species_effects.lua path required") local root=target:gsub("[^/\\]+$","") local hearth={} +hearth.lifecycle=dofile(root.."lifecycle.lua") hearth.botany=dofile(root.."botany.lua")(hearth,{}) hearth.wiki_articles=dofile(root.."wiki_articles.lua") local E=dofile(target)(hearth,setmetatable({}, {__index=function(_,k) error("unexpected API access: "..k) end})) @@ -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 error("missing effect "..title) 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 for _,r in ipairs(hearth.botany.list()) do local s=assert(sources[r.id]); total=total+1 assert(s.source_species==r.latin and s.source_url:match("^https://en%.wikipedia%.org/wiki/")) - assert(s.identity_revision==hearth.wiki_articles[r.id].revision) - assert(s.identity_revision_url==hearth.wiki_articles[r.id].revision_url) + local article=hearth.wiki_articles[r.id] + 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 for _,row in ipairs(E.describe(r)) do assert(row.status=="simulated" or row.status=="descriptive") assert(row.game~="" and row.source_species==r.latin and row.source_url==s.source_url) end end - assert(total==80) + assert(total==100) assert(E.source("unknown")==nil and #E.describe({id="unknown",tags={engineer=true}})==0) end) test("reviewed mechanisms cite species and explicitly identify broader evidence",function() @@ -86,4 +88,18 @@ test("source and description results cannot mutate the reviewed policy",function local sources=E.sources(); sources.alder.note="wrong" assert(E.source("alder").note~="wrong") 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) diff --git a/tests/species_reader_spec.lua b/tests/species_reader_spec.lua index 18b6ce9..c66f3db 100755 --- a/tests/species_reader_spec.lua +++ b/tests/species_reader_spec.lua @@ -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) eye_height=math.huge; local n=rays; assert(not R.look(player) and rays==n); eye_height=1.6 end) -test("all eighty entries render with honest seventy-eight excerpt and two note headings",function() +test("all hundred entries render with honest ninety-eight excerpt and two note headings",function() local excerpts,notes=0,0 for _,r in ipairs(H.botany.list()) do assert(R.show(player,r.id)); local a=H.wiki_articles[r.id] @@ -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)) end end - assert(excerpts==78 and notes==2) + assert(excerpts==98 and notes==2) end) test("excerpt retains full shipped introduction and attribution revision history license",function() assert(R.show(player,"oak")); local a=H.wiki_articles.oak @@ -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("no infection or attack",1,true)) assert(R.show(player,"alder",2)) - assert(latest():find("900 warm active seconds",1,true) and latest():find("Frankia",1,true)) + assert(latest():find("90 real calendar days",1,true) and latest():find("Frankia",1,true)) assert(latest():find("not measured Wikipedia constants",1,true)) end) test("article textarea and provenance are unnamed read-only copyable fields",function() diff --git a/tests/succession_spec.lua b/tests/succession_spec.lua index 95106c2..28b0b5c 100755 --- a/tests/succession_spec.lua +++ b/tests/succession_spec.lua @@ -54,6 +54,7 @@ local function reset() external_protected=function(q) return protected[key(q)] end, climate_at=function() return {temperature=temperature,season=season} end} hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(hearth,api) + hearth.lifecycle=dofile(target:gsub("[^/\\]+$","lifecycle.lua")) os.time=function() return now end load() end @@ -67,6 +68,13 @@ local function baseline(k,age) if age then metadata(p):set_string("succ_age",age) end end local function field(q,f) return tonumber(metadata(q):get_string(f)) or 0 end +local 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) reset() 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") assert(sim.habitat(p).water==1 and sim.habitat(p).moisture>0.2) end) -test("tree stages require active age, then mature trees become decomposing wood",function() +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) - assert(name(p)=="hearth:young_tree" and field(p,"succ_age")==0) - metadata(p):set_string("succ_age","86300"); advance(100) + assert(name(p)=="hearth:tree_seedling" and field(p,"succ_age")==21500) + 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") - metadata(p):set_string("succ_age","259100"); advance(100) - assert(name(p)=="hearth:deadwood") - metadata(p):set_string("succ_age","7100"); advance(100) - assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3) + aged(10000*YEAR); advance(100) + assert(name(p)=="hearth:mature_tree" and field(pos(0,0,0),"succ_nutrients")==0) end) test("established sparse meadow eventually recruits a native tree",function() baseline("meadow_grass",7100); advance(100) @@ -159,9 +167,9 @@ test("grass spreads locally but never replaces occupied flower or crop cells",fu end) test("moist shaded litter seeds fungi, then returns finite nutrients",function() 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 - 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) advance(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) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) end) -test("grazing consumes exactly one natural grass or fern, not flowers or planted nodes",function() +test("grazing debits one partial canopy ration, not flowers or planted nodes",function() put(p,"meadow_grass",true); put(pos(1),"fern",true) assert(sim.forage(p)) - assert(name(p)=="air" and name(pos(1))=="hearth:fern") - assert(field(p,"succ_next")==now+300) + assert(name(p)=="hearth:meadow_grass" and name(pos(1))=="hearth:fern") + assert(field(p,"succ_browse_ready_at")==now+7*DAY) put(pos(1),"wildflowers",true) assert(not sim.forage(p)) put(p,"meadow_grass",false); metadata(p):set_string("succ_natural","") assert(not sim.forage(p)) end) -test("grazing cooldown prevents immediate seed recolonization",function() +test("grazing retains the perennial and shares seven-day regrowth across animals",function() baseline("meadow_grass") assert(sim.forage(p)) - advance(100); advance(100) - assert(name(p)=="air") - advance(100) - assert(name(p)=="hearth:meadow_grass") + assert(not sim.forage(p)) + load(); assert(not sim.forage(p)) + now=now+7*DAY + assert(sim.forage(p) and name(p)=="hearth:meadow_grass") end) test("source claims and external protection block all simulation writes",function() 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) assert(name(p)=="hearth:mature_tree" and field(p,"succ_age")==259100) end) -test("no catchup on long absence, disconnect, unload, restart or clock rewind",function() +test("calendar anchor survives absence, disconnect, unload and restart; rollback pauses at high water",function() 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) players=false; sim.step(1); players=true advance(100); assert(field(p,"succ_age")==21500) lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==21500) load(); advance(100); assert(field(p,"succ_age")==21500) advance(-100); assert(field(p,"succ_age")==21500) - advance(100); assert(name(p)=="hearth:young_tree") + 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) test("failed dispersal attempts survive restart without reroll or advancing age",function() 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) advance(30) 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) test("cold weather pauses recruitment and stage growth without catchup",function() 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) assert(name(pos(-2,1,-1))=="hearth:litter") 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) end) 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) load() 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() assert(metadata(p):get_string("succ_natural")=="1" and field(p,"succ_age")==0) for _=1,71 do advance(100) end assert(name(p)=="hearth:deadwood" and field(p,"succ_age")==7100) assert(field(pos(0,0,0),"succ_nutrients")==0) - advance(100) + now=now+YEAR; advance(100); advance(100) assert(name(p)=="hearth:litter" and field(p,"succ_age")==0) assert(field(pos(0,0,0),"succ_nutrients")==3) local before=writes; sim.step(999999) assert(writes==before and field(pos(0,0,0),"succ_nutrients")==3) - for _=1,18 do advance(100) end + now=now+90*DAY; advance(100); advance(100) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) advance(1); assert(field(pos(0,0,0),"succ_nutrients")==4) reset(); diverse(); humidity=20 metadata(pos(0,0,0)):set_string("succ_nutrients","7") - put(p,"deadwood"); baseline(nil,7100); advance(100) + 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) 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) - 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) - assert(field(p,"succ_age")==7100) - lbm.action(p); advance(100); assert(field(p,"succ_age")==7100) - lbm.action(pos(0,0,0)); advance(100); assert(field(p,"succ_age")==7100) - load(); advance(100); assert(field(p,"succ_age")==7100) - advance(-100); assert(field(p,"succ_age")==7100) + lbm.action(p); advance(100) + lbm.action(pos(0,0,0)); advance(100) + load(); advance(100) + local high=field(p,"succ_decay_seen_at") + 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) - advance(100) + now=now+YEAR; advance(100); advance(100) assert(name(p)=="hearth:litter" and field(pos(0,0,0),"succ_nutrients")==3) end) -test("species stages keep birch identity and deadwood records its originating species",function() - diverse(); baseline("birch_seedling",21500); advance(100) +test("species stages keep one birth anchor, real profile thresholds and never die at lifespan endpoints",function() + diverse(); baseline("birch_seedling",21500); aged(5*YEAR); advance(100) 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) 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") - metadata(p):set_string("succ_age","259100"); advance(100) - assert(name(p)=="hearth:deadwood" and metadata(p):get_string("succ_species")=="birch") - metadata(p):set_string("succ_age","7100"); advance(100) - assert(name(p)=="hearth:litter" and metadata(p):get_string("succ_species")=="birch") + aged(1000*YEAR); advance(100) + assert(name(p)=="hearth:birch_mature" and metadata(p):get_string("succ_species")=="birch") + assert(born>0 and metadata(p):get_string("succ_lifecycle_origin")=="legacy_established") end) 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) - 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") 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 @@ -555,8 +568,9 @@ test("species seeds respect source, destination and support authority without em assert(name(seed)~="hearth:birch_seedling",mode) 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) + aged(5*YEAR) season,temperature="Winter",-5; advance(100) assert(name(p)=="hearth:oak_seedling_winter" and field(p,"succ_age")==21500) load(); advance(100); assert(field(p,"succ_age")==21500) @@ -611,9 +625,10 @@ local function supplied_habitat(h) return out 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() local records=hearth.botany.list() + assert(#records==100) for index,r in ipairs(records) do reset(); catalogue() 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 -- woody-gap gate. No fake choose(), random rerolls or ID aliases. local x=0 - while (x*17+7)%80~=index-1 or (x*7)%23~=0 do x=x+1; assert(x<1840) end + 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) baseline(nil,7100); advance(100) local candidate=r.group=="trees" and r.id.."_seedling" or r.id assert(hearth.seasons.kind(name(p))==candidate,r.id.." recruit got "..name(p)) assert(metadata(p):get_string("succ_natural")=="1",r.id) + 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) metadata(p):set_string("succ_age","1000"); metadata(p):set_string("succ_serial","2") if r.group=="trees" then -- A mature parent disperses the same species seed, not a -- generic oak. The subsequent expansion uses real block plans. - put(p,r.id.."_mature",true); metadata(p):set_string("succ_age","2000"); metadata(p):set_string("succ_serial","9") + 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) assert(hearth.seasons.kind(name(q))==candidate,r.id.." tree spread") put(q,"air"); metadata(q):set_string("succ_natural","") - put(p,candidate,true); metadata(p):set_string("succ_age","21600"); advance(100) + 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")) - 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") - metadata(p):set_string("succ_age","259200"); advance(100) - assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12,r.id.." fallen") - for id,n in pairs(world) do - if id~=key(p) then assert(metadata((function() - local a,b,c=id:match("([^:]+):([^:]+):([^:]+)"); return pos(tonumber(a),tonumber(b),tonumber(c)) - end)()):get_string("succ_component")=="",r.id.." orphan component "..n.name) end - end + aged(10000*YEAR); advance(100) + assert(hearth.seasons.kind(name(p))==r.id.."_mature",r.id.." must not die") 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) - assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q)) - metadata(p):set_string("succ_age","1000000"); advance(100) - assert(name(p)=="air" or name(p)=="hearth:litter",r.id.." senescence") - if name(p)=="hearth:litter" then - metadata(p):set_string("succ_age","1800"); advance(100); assert(name(p)=="air",r.id.." recycling") - end + if profile.maturity_days and r.seeding[season] then + assert(hearth.seasons.kind(name(q))==candidate,r.id.." spread got "..name(q)) + else assert(name(q)=="air",r.id.." unknown maturity must not spawn") end + aged(10000*YEAR); advance(100) + assert(hearth.seasons.kind(name(p))==candidate,r.id.." no guessed senescence") 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") assert(not sim.habitat(p).loaded and writes==0) end) -test("real source-water recruitment and submerged stand decay never clear or refill water",function() +test("real source-water recruitment and very old submerged stands never clear or refill water",function() for _,mode in ipairs({"surface","submerged"}) do reset(); catalogue(); p=pos(mode=="surface" and 0 or 1,1,0) put(pos(p.x,0,0),"water_source"); put(pos(p.x,-1,0),"alluvium") baseline(); advance(100); advance(100) local r=hearth.botany.get(name(p)); assert(r and r.group=="aquatic" and r.niche.aquatic==mode) assert(name(pos(p.x,0,0))=="hearth:water_source") - metadata(p):set_string("succ_age","21600"); advance(100) - assert(name(p)=="air" and name(pos(p.x,0,0))=="hearth:water_source") + aged(10000*YEAR); advance(100) + 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) 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) 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 - baseline("birch_seedling",21600); advance(100) + baseline("birch_seedling",21600); aged(5*YEAR); advance(100) assert(metadata(p):get_string("succ_tree_stage")=="young") local count=0 for _,c in ipairs(hearth.diversity.tree_plan("birch","young")) do @@ -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 end 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") local before=writes 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) metadata(q):set_string("owner",""); advance(100) - assert(name(p)=="hearth:deadwood" and world[key(p)].param2==12) - assert(name(pos(0,7,0))=="air") + assert(name(p)=="hearth:birch_mature" and name(pos(0,7,0))~="air") end) test("every tree destination and its support fail closed before any plan write",function() for _,mode in ipairs({"foreign","natural_plant","marker","claim","external","unloaded","support"}) do - reset(); catalogue(); humidity=45; baseline("oak_seedling",21600) + reset(); catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR) local q=pos(0,4,0) if mode=="foreign" then put(q,"wall") 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) 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 assert(sim.tree_removed(p,old)); assert(name(pos(0,3,0))=="air") put(pos(0,3,0),"oak_log"); now=now+1 assert(not sim.tree_removed(p,{name="hearth:oak_young"})) assert(name(pos(0,3,0))=="hearth:oak_log") end) -test("catalogue nitrogen caps survive restart and root fungi debit existing soil on faster growth",function() +test("catalogue nitrogen caps survive restart and root fungi never accelerate calendar growth",function() catalogue(); humidity=45; baseline("clover",1000) for _=1,5 do advance(100) end assert(field(pos(0,0,0),"succ_fixed_nitrogen")==2 and field(pos(0,0,0),"succ_nutrients")==2) @@ -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") put(pos(1),"birch_bolete",true); metadata(pos(0,0,0)):set_string("succ_nutrients","1") baseline("birch_seedling",16100); advance(100) + assert(name(p)=="hearth:birch_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) reset(); catalogue(); humidity=45; put(pos(1),"birch_bolete",true) 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 put(pos(0,0,0),"deadwood",true); metadata(pos(0,0,0)):set_string("succ_species","birch") baseline("birch_polypore",1000); advance(100) - assert(field(pos(0,0,0),"succ_age")>0 and field(pos(0,0,0),"succ_nutrients")==0) + assert(field(pos(0,0,0),"succ_decay_bonus")>0 and field(pos(0,0,0),"succ_nutrients")==0) assert(field(p,"succ_stress")==0) metadata(pos(0,0,0)):set_string("owner","builder"); local age=field(pos(0,0,0),"succ_age") advance(100); assert(field(pos(0,0,0),"succ_age")==age) @@ -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) local h=sim.habitat(p); assert(h.pollination==2 and h.edible==1 and h.nursery==1) metadata(p):set_string("botany_ready_at",tostring(now+86400)) - baseline(nil,1000); advance(100); assert(field(p,"succ_age")==1100 and field(p,"botany_ready_at")>now) - assert(sim.forage(p)); assert(name(p)=="air" and field(p,"botany_ready_at")>now) + baseline(nil,1000); metadata(p):set_string("succ_seed_epoch",tostring(now+YEAR)) + 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)) season="Spring"; put(p,"rowan_mature",true) 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) assert(h.pollination==1 and h.nursery==1 and h.edible==1,"one managed root, not a benefit per leaf") 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() catalogue(); humidity=45 @@ -799,7 +837,7 @@ test("one global tree-plan reservation bounds eight concurrent roots and malform end for _,root in ipairs(positions) do put(root,"oak_seedling",true) end sim.step(1) - for _,root in ipairs(positions) do metadata(root):set_string("succ_age","21600") end + for _,root in ipairs(positions) do aged(5*YEAR,root) end local calls=0; local original=hearth.diversity.tree_plan hearth.diversity.tree_plan=function(...) calls=calls+1; return original(...) end advance(100); assert(calls==1) @@ -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 assert(grown==1 and writes<=sim.MAX_TREE_CELLS) for _,mode in ipairs({"missing","oversize","foreign","duplicate","offset"}) do - reset(); catalogue(); humidity=45; baseline("oak_seedling",21600) + reset(); catalogue(); humidity=45; baseline("oak_seedling",21600); aged(5*YEAR) hearth.diversity.tree_plan=function() if mode=="missing" then return nil end 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) 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 reset(); catalogue(); humidity=45 if plant=="duckweed" then put(pos(0,0,0),"water_source"); put(pos(0,-1,0),"alluvium") end baseline(plant,1000); advance(864000); assert(field(p,"succ_age")==1000) load(); advance(100); assert(field(p,"succ_age")==1000) lbm.action(p); advance(100); assert(field(p,"succ_age")==1000) - advance(100); assert(field(p,"succ_age")==1100) + advance(100); assert(field(p,"succ_age")==1000 and field(p,"succ_last_observed_at")==now) end end) 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 reset(); catalogue() 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 local below=pos(0,0,0) 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) 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 reset(); catalogue(); humidity=45 put(pos(0,0,0),"forest_floor"); metadata(pos(0,0,0)):set_string("succ_nutrients",budget) put(pos(1),"fly_agaric",true); put(pos(-1),"birch_bolete",true) baseline("birch_seedling",16100); advance(100) - assert(field(pos(0,0,0),"succ_nutrients")==0) - assert(name(p)==(budget==1 and "hearth:birch_young" or "hearth:birch_seedling")) - advance(100); assert(field(pos(0,0,0),"succ_nutrients")==0) + assert(field(pos(0,0,0),"succ_nutrients")==budget) + assert(name(p)=="hearth:birch_seedling") + aged(5*YEAR); advance(100) + assert(field(pos(0,0,0),"succ_nutrients")==0 and name(p)=="hearth:birch_young") end end) test("birch wood fungus exhausts supporting deadwood then litter with exactly one conserved payout each",function() catalogue(); humidity=70; light=10 put(p,"deadwood",true); metadata(p):set_string("succ_species","birch") put(pos(0,2,0),"birch_polypore",true) - baseline(nil,7100); advance(100) + baseline(nil,7100); now=now+YEAR; advance(100); advance(100) assert(name(p)=="hearth:litter" and name(pos(0,2,0))=="air") assert(field(pos(0,0,0),"succ_nutrients")==3) - metadata(p):set_string("succ_age","1800"); advance(100) + now=now+90*DAY; advance(100); advance(100) assert(name(p)=="air" and field(pos(0,0,0),"succ_nutrients")==4) advance(60); assert(field(pos(0,0,0),"succ_nutrients")==4) end) +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 print("LAST_HEARTH_SUCCESSION_PASS "..passed) diff --git a/tests/tooling.test.mjs b/tests/tooling.test.mjs index 75024dc..66072eb 100755 --- a/tests/tooling.test.mjs +++ b/tests/tooling.test.mjs @@ -64,7 +64,7 @@ test('package twice: identical full inventory and manifest hashes, original asse assert.deepEqual(await inventory(a), await inventory(b)); const hash = data => createHash('sha256').update(data).digest('hex'); assert.equal(hash(await fs.readFile(path.join(a, MANIFEST))), hash(await fs.readFile(path.join(b, MANIFEST)))); - const manifest = await verifyPackage(a); + const manifest = await verifyPackage(a,{requireBundled:false}); assert.ok(manifest.entries.some(entry => entry.path === 'release.json')); for (const absent of ['scripts', 'tests', '.git', 'README.md', 'secret.env', 'mods/hearth/textures/stale.png']) assert.equal(await exists(path.join(a, absent)), false); assert.deepEqual(await fs.readFile(path.join(a, 'mods/hearth/data.png')), binary); @@ -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 === 'corrupt') await fs.appendFile(path.join(destination, 'game.conf'), 'bad'); if (kind === 'directory') await fs.mkdir(path.join(destination, 'empty-extra')); - await assert.rejects(verifyPackage(destination), /manifest mismatch/); + 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 () => { 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.throws(() => terrainSettings('true'), /boolean/); assert.ok(first.includes('mapgen_limit = 4096')); assert.ok(first.includes('mgv7_spflags = mountains,ridges,nofloatlands,caverns')); - assert.ok(first.includes('mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0')); assert.ok(first.includes('mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0')); const defaults = (await fs.readFile(path.join(SOURCE, 'minetest.conf'), 'utf8')) - .split(/\r?\n/).filter(isTerrainSetting).sort(); - assert.deepEqual(first.filter(line => !line.startsWith('fixed_map_seed')).sort(), defaults); + .split(/\r?\n/).filter(isTerrainSetting).filter(line => !line.startsWith('epic_terrain_enable')).sort(); + 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); first[0] = 'mg_name = flat'; first.push('untrusted = true'); assert.equal(terrainSettings(true)[0], 'mg_name = v7'); - for (const line of ['mg_name = v7', ' mgv7_np_terrain_base = 1', 'mg_biome_np_heat = 1', + 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); 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', ], 'local'); 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, /secure.enable_security = true/); 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); const config = path.join(root, path.basename(world) + '.conf'); await writeWorld(world, config, [], ['fixed_map_seed = 731104'], 'local'); - assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), []); + assert.deepEqual((await fs.readFile(config, 'utf8')).split(/\r?\n/).filter(isTerrainSetting), ['epic_terrain_enable = false']); 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')); if (existing) { - assert.deepEqual(lines.filter(isTerrainSetting), []); + assert.deepEqual(lines.filter(isTerrainSetting), ['epic_terrain_enable = false']); assert.deepEqual(await inventory(world), before); } else { for (const setting of terrainSettings(true)) assert.ok(lines.includes(setting), setting); diff --git a/tests/wiki-species.test.mjs b/tests/wiki-species.test.mjs index 20f697b..57e7318 100755 --- a/tests/wiki-species.test.mjs +++ b/tests/wiki-species.test.mjs @@ -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 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 record = catalogue[0]; 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); }; } -test('bounded non-executing Lua parser finds the actual 80 Latin names and IDs', async () => { - assert.equal(catalogue.length, 80); - assert.equal(new Set(catalogue.map(r => r.id)).size, 80); +test('bounded non-executing Lua parser finds the actual 100 Latin names and multi-underscore IDs', async () => { + assert.equal(LIMITS.species, 100); + 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 === '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'); for (const bad of [source.replace('Quercus robur', 'https://127.0.0.1/'), 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('"Betula pendula"', '"Quercus robur"'), 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)); - 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 () => { @@ -230,7 +245,7 @@ test('untrusted Wikipedia text is exclusively UTF-8 byte literals, never executa 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; const seen = []; 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--; 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(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' }]]) { 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 () => { @@ -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); }); -test('shipped bundle contains 78 real intros and two honest missing-page notes for all 80 species', () => { - assert.equal(cached.length, 80); +test('shipped bundle retains 78 old intros and two notes plus 20 real new intros for all 100 species', () => { + assert.equal(cached.length, 100); 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']); for (const e of cached) { 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.revision_url.endsWith('?oldid=' + e.revision)); 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'); diff --git a/tests/wildlife_spec.lua b/tests/wildlife_spec.lua index 8585365..f0126cf 100755 --- a/tests/wildlife_spec.lua +++ b/tests/wildlife_spec.lua @@ -1,298 +1,1014 @@ --- Standalone Lua 5.1/LuaJIT (also Lua 5.3) fauna contract tests. +-- Standalone focused runtime contracts. No engine or external dependencies. local target=assert(arg[1],"wildlife.lua path required") +local root=target:gsub("[^/\\]+$","") +local L=dofile(root.."lifecycle.lua") +local factory=dofile(root.."fauna.lua") +local wildlife=dofile(target) local passed=0 +local only=arg and arg[2] local function test(name,fn) - local ok,err=pcall(fn) - assert(ok,name..": "..tostring(err)); passed=passed+1 - print("ok "..passed.." - "..name) + if only and name~=only then return end + local ok,err=pcall(fn); assert(ok,name..": "..tostring(err)) + passed=passed+1; print("ok "..passed.." - "..name) end -local function fixture() - local h={defs={},objects={},players={},steps={},habitat=true,plants=true,natural=true,forages=0,deposits=0, - blocked=function() return false end,terrain=function(p) return p.y<=3 and "hearth:grass" or "air" end} - local api={} - function api.register_entity(n,d) h.defs[n]=d end +local function copy(t) if type(t)~="table" then return t end; local o={}; for k,v in pairs(t) do o[k]=copy(v) end; return o end +-- Small flat JSON codec ONLY for controlled mock records; never evaluates Lua. +local function json(t) + local out={}; for k,v in pairs(t) do + local text=type(v)=="string" and ('"'..v..'"') or tostring(v) + out[#out+1]='"'..k..'":'..text + end; table.sort(out); return "{"..table.concat(out,",").."}" +end +local function parse(text) + if text:sub(1,1)~="{" or text:sub(-1)~="}" then return nil end + local out={}; for k,v in text:gmatch('"([%w_%-]+)":([^,}]+)') do + if v:sub(1,1)=='"' then out[k]=v:sub(2,-2) + elseif v=="true" then out[k]=true elseif v=="false" then out[k]=false + else out[k]=tonumber(v) end + end; return out +end +local function fixture(injected,saved_storage,creatura_api) + local h={defs={},objects={},players={},steps={},metas={},time=1782072000,season="Summer",plants="meadow_grass", + natural=true,forages=0,deposits=0,habitat=true,water=false,reads=0,blocked=function() return false end} + local api={registered_nodes={}} + h.storage_data=saved_storage or {}; h.logs={} + local storage={get_string=function(_,key) + if h.storage_read_error then error("storage unavailable") end + return h.storage_data[key] or "" + end,set_string=function(_,key,value) + if h.storage_write_error then error("storage unavailable") end + if not h.storage_drop_write then h.storage_data[key]=value end + end} + function api.get_mod_storage() return storage end + function api.log(level,message) h.logs[#h.logs+1]=level..":"..message end + for _,id in ipairs({"meadow_grass","fern","clover","blackberry","hazel","litter"}) do api.registered_nodes["hearth:"..id]={walkable=false} end + function api.now() return h.time end + function api.get_modpath() return root:gsub("[/\\]$","") end + function api.register_entity(n,d) assert(not h.defs[n]); h.defs[n]=d end function api.register_globalstep(fn) h.steps[#h.steps+1]=fn end function api.get_connected_players() return h.players end - function api.get_node_or_nil(p) - if h.unloaded and h.unloaded(p) then return nil end - local n=h.terrain(p) - if n=="air" and p.y==4 and h.plants then n="hearth:meadow_grass" end - return {name=n} - end - function api.get_meta() return {get_string=function(_,key) return key=="succ_natural" and h.natural and "1" or "" end} end function api.is_protected(p) return h.blocked(p) end - function api.add_item() error("wildlife must never drop items") end - function h.player(x,z) - local p={pos={x=x or 0,y=4,z=z or 0}} + function api.find_nodes_in_area_under_air(a,b,names) + h.scans=(h.scans or 0)+1 + assert((b.x-a.x+1)*(b.y-a.y+1)*(b.z-a.z+1)==648) + if not h.search then return {} end + local accept={}; for _,name in ipairs(names) do accept[name]=true end + local out={} + for x=a.x,b.x do for z=a.z,b.z do for y=a.y,b.y do + local p={x=x,y=y,z=z}; local n=api.get_node_or_nil(p) + local above=api.get_node_or_nil({x=x,y=y+1,z=z}) + if n and accept[n.name] and above and above.name=="air" then out[#out+1]=p end + end end end + return out + end + function api.get_node_or_nil(p) + h.reads=h.reads+1 + if h.unloaded and h.unloaded(p) then return nil end + if h.terrain then return {name=h.terrain(p)} end + return {name=p.y<=3 and "hearth:grass" or (h.water and p.y<=6 and "hearth:water_source") + or (p.y==4 and h.plants and "hearth:"..h.plants or "air")} + end + function api.get_meta(p) + local k=p.x..":"..p.y..":"..p.z; h.metas[k]=h.metas[k] or {}; local m=h.metas[k] + return {get_string=function(_,key) + if key=="succ_natural" and not h.real_metadata then return h.natural and "1" or "" end + return m[key] or "" + end,set_string=function(_,key,v) m[key]=v end,to_table=function() return {fields=m} end} + end + api.parse_json=parse; api.write_json=json + function api.add_item() error("no item source") end + function h.player(x,name) + local p={name=name or ("observer_"..tostring(#h.players+1)),pos={x=x or 0,y=4,z=0}} + function p:get_player_name() return self.name end function p:get_pos() return self.pos end - function p:punch() error("animals must never punch players") end + function p:punch() error("no player attacks") end h.players[#h.players+1]=p; return p end - function h.add(kind,x,z,payload,restore) - local def=assert(h.defs["hearth:"..kind]) - local e={name="hearth:"..kind} - local obj={pos={x=x or 12,y=3.5,z=z or 0},entity=e} - e.object=obj - function obj:get_pos() return not self.removed and self.pos or nil end - function obj:get_luaentity() return self.entity end - function obj:set_pos(p) self.pos=p; self.moves=(self.moves or 0)+1 end - function obj:set_velocity(v) self.velocity=v end - function obj:set_acceleration(v) self.acceleration=v end - function obj:set_armor_groups(v) self.armor=v end - function obj:set_yaw(y) self.yaw=y end - function obj:remove() self.removed=true; def.on_deactivate(e) end - h.objects[#h.objects+1]=obj - def.on_activate(e,payload or "",restore or 0) - return e,obj + function h.add(id,x,z,payload,dtime,y) + local def=assert(h.defs["hearth:"..id]); local e={name="hearth:"..id} + local o={entity=e,pos={x=x or 12,y=y or (h.water and 4 or 3.5),z=z or 0},animations={}} + e.object=o + function o:get_pos() return not self.removed and self.pos or nil end + function o:get_luaentity() return self.entity end + function o:set_pos() error("no teleport movement") end + function o:set_velocity(v) self.velocity=copy(v) end + function o:set_acceleration(v) self.acceleration=v end + function o:set_armor_groups(v) self.armor=v end + function o:set_yaw(v) self.yaw=v end + function o:set_animation(frames,fps,blend,loop) + self.animations[#self.animations+1]={frames=frames,fps=fps,blend=blend,loop=loop} + end + function o:remove() self.removed=true; def.on_deactivate(e) end + h.objects[#h.objects+1]=o; def.on_activate(e,payload or "",dtime or 0) + return e,o end - function api.add_entity(p,name) - local _,obj=h.add(name:match(":(.+)$"),p.x,p.z) - return obj + function api.add_entity(p,name,data) + local _,o=h.add(name:match(":(.+)$"),p.x,p.z,data,0,p.y); return o end - local hearth={claim_owner_at=function(p) - if h.ambiguous and h.ambiguous(p) then return nil,true end - if h.claim and h.claim(p) then return "owner",false end - return nil,false - end,claim_allowed=function() error("empty-name claim_allowed must never be used") end} - hearth.succession={habitat=function(p) - local available=h.habitat and (not h.habitat_at or h.habitat_at(p)) - return {loaded=not h.habitat_unloaded,grass=available and 8 or 0,cover=0} + local hearth={lifecycle=L,sessions={},climate_at=function(time) + local hour=h.solar_hour or (time%86400)/3600 + local elevation=h.solar_elevation or (hour>=6 and hour<18 and 30 or -20) + return {season=h.season,hour=hour,elevation=elevation,daylight=elevation>-.833} end, - forage=function(p) - assert(not h.blocked(p)); h.forages=h.forages+1 - return h.feed_ok~=false - end, - deposit=function(p) assert(not h.blocked(p)); h.deposits=h.deposits+1; return true end} - h.hearth=hearth; h.api=api; h.result=dofile(target)(hearth,api) - function h.tick(n,dt) - for _=1,n do - local snapshot={} - for _,o in ipairs(h.objects) do snapshot[#snapshot+1]=o end - for _,o in ipairs(snapshot) do if not o.removed then h.defs[o.entity.name].on_step(o.entity,dt or 1) end end - for _,fn in ipairs(h.steps) do fn(dt or 1) end + claim_owner_at=function(p) + return h.claim and h.claim(p) and "owner" or nil,h.ambiguous and h.ambiguous(p) or false + end} + hearth.creatura=creatura_api + hearth.fauna=factory(hearth,api) + hearth.succession={habitat=function() + return {loaded=h.habitat,grass=8,cover=8,canopy=1,moisture=.8,near_water=h.water and 8 or 0,water=h.water and 3 or 0} + end,forage=function(p) + assert(not h.blocked(p)); h.forages=h.forages+1; return h.feed_ok~=false + end,deposit=function(p) assert(not h.blocked(p)); h.deposits=h.deposits+1; return true end} + if not injected then hearth.fauna=nil end -- exercise self-contained fixed dofile path + h.hearth=hearth; h.api=api; h.result=wildlife(hearth,api); h.F=h.result.fauna + function h.stats() return h.result.status() end + function h.ai(n) + for _=1,(n or 1) do h.time=h.time+1; h.steps[1](1) end + end + function h.simulate(seconds) + for _=1,seconds do + h.time=h.time+1; h.steps[1](1) + for _=1,5 do for _,o in ipairs(h.objects) do if not o.removed then + local def=h.defs[o.entity.name]; def.on_step(o.entity,.2) + if not o.removed then + o.pos.x=o.pos.x+o.velocity.x*.2; o.pos.y=o.pos.y+o.velocity.y*.2; o.pos.z=o.pos.z+o.velocity.z*.2 + end + end end end end end - function h.stats() return h.result.status() end + function h.tick(n,dt) + for _=1,n do + h.time=h.time+1; h.steps[1](dt or 1) + for _,o in ipairs(h.objects) do if not o.removed then h.defs[o.entity.name].on_step(o.entity,dt or 1) end end + end + end + function h.saved(e) return h.defs[e.name].get_staticdata(e) end + function h.restore(id,x,changes,y) + local s={version=1,species=id,born_at=h.time-86400,origin="established",sex="female",foodreserve=.5, + nextfeed=h.time,pregnancy_until=0,last_seen=h.time,seed=12,last_meal=h.time,dung_pending=false} + if id=="earthworm" then s.sex="hermaphrodite" end + for k,v in pairs(changes or {}) do s[k]=v end + return h.add(id,x,0,json(s),60,y) + end + function h.botany() + hearth.botany={kind=function(n) return (type(n)=="table" and n.name or n):gsub("^hearth:","") end, + get=function(n) return {group="grasses",toxic=false,existing=false} end, + pollinator=function(n,s) return n.name=="hearth:clover" and s=="Summer" end, + edible=function(n,s) return n.name=="hearth:blackberry" and s=="Summer" end, + browse=function(n,s) return n.name=="hearth:meadow_grass" and s=="Summer" end, + has_tag=function(_,tag) return tag=="browse" end, + seeding=function(n,s) return n.name=="hearth:hazel" and s=="Autumn" end} + end return h end - -test("three original grounded meshes, body collision, gravity, immortal and nonpersistent",function() - local h=fixture(); h.player() - for _,kind in ipairs({"hare","deer","fox"}) do - local d=h.defs["hearth:"..kind]; local p=d.initial_properties - assert(p.visual=="mesh" and p.mesh=="hearth_"..kind..".obj" and p.textures[1]=="hearth_"..kind..".png") - assert(p.visual_size.x==1 and p.visual_size.y==1 and p.collisionbox[2]==0) - assert(p.physical and p.collide_with_objects and p.static_save==false and p.pointable==false) - local e,o=h.add(kind,12) - assert(not o.removed and o.acceleration.y<0 and o.armor.immortal==1) - assert(d.on_punch(e,h.players[1])==true and not o.removed) +test("registers exactly 23 persistent articulated meshes and animation calls",function() + local h=fixture(); local n=0 + for name,d in pairs(h.defs) do + n=n+1; local id=name:sub(8); local p=d.initial_properties + assert(p.mesh=="hearth_fauna_"..id..".b3d" and p.textures[1]=="hearth_fauna_"..id..".png") + assert(p.static_save and p.physical and p.pointable and p.eye_height>=.15) + local bounds=h.F.get(id).bounds + assert(p.collisionbox[1]==bounds.min[1] and p.collisionbox[2]==0 and p.collisionbox[3]==bounds.min[3] + and p.collisionbox[4]==bounds.max[1] and p.collisionbox[5]==bounds.max[2] and p.collisionbox[6]==bounds.max[3]) end + assert(n==23); local e,o=h.restore("hare",12) + assert(not o.removed and o.armor.immortal==1 and h.defs[e.name].on_punch(e)==true) + assert(o.animations[1].frames.x==0 and o.animations[1].frames.y==19 and o.animations[1].fps==24) + assert(fixture(true).stats().species_count==23) end) -test("activation rejects payloads, restores, invalid positions and duplicate accounting",function() - local h=fixture(); h.player() - local _,a=h.add("hare",12,0,"forged"); assert(a.removed) - local _,b=h.add("hare",12,0,"",1); assert(b.removed) - local _,c=h.add("hare",0/0); assert(c.removed) - local e=h.add("hare",12); h.defs[e.name].on_activate(e,"") - assert(h.stats().total==1) +test("pointable right-click dispatches only an online session a detached exact-species snapshot",function() + local h=fixture(); local player=h.player(40,"alice"); h.hearth.sessions.alice={} + local e=h.restore("hare",12,{foodreserve=.63}); local before=h.stats(); local calls=0 + h.hearth.inspect_fauna=function(clicker,id,snapshot) + calls=calls+1; assert(clicker==player and id=="hare") + assert(snapshot.origin=="established" and snapshot.resident==true and snapshot.foodreserve==.63) + assert(snapshot.observation=="awake" and snapshot.born_at==e.state.born_at) + snapshot.foodreserve=0; snapshot.origin="born"; snapshot.resident=false + end + h.defs[e.name].on_rightclick(e,player) + assert(calls==1 and e.state.foodreserve==.63 and e.state.origin=="established") + assert(h.stats().total==before.total and h.stats().actions==before.actions and h.stats().deaths==0) + assert(h.forages==0 and h.deposits==0 and h.stats().meals==0,"inspection has no ecological transaction") + local outsider=h.player(40,"outsider"); h.defs[e.name].on_rightclick(e,outsider) + h.defs[e.name].on_rightclick(e,{}) + local impostor={get_player_name=function() return "alice" end} + h.defs[e.name].on_rightclick(e,impostor) + assert(calls==1,"missing session, non-player, and unconnected impostor are ignored") + h.defs[e.name].on_deactivate(e); h.defs[e.name].on_rightclick(e,player) + assert(calls==1,"stale entity callbacks are ignored") end) -test("synchronous native activation enforces player and local caps",function() - local h=fixture(); h.player() - for _=1,20 do h.add("hare",12) end - assert(h.stats().total==6) - -- No player nearby is not an unlimited admission path. - local _,o=h.add("deer",1000); assert(o.removed) - local snap=h.stats(); snap.hare=999; assert(h.stats().hare==6) - local j=fixture(); j.player(-30); j.player(30) - for _=1,10 do j.add("deer",0) end - assert(j.stats().deer==6,"local cap spans overlapping player neighborhoods") +test("punching triggers flee motion without damage, death, drops, or state loss",function() + local h=fixture(); local puncher=h.player(14,"puncher"); local e,o=h.restore("hare",12,{foodreserve=.4}) + local before=h.stats(); h.defs[e.name].on_punch(e,puncher) + assert(not o.removed and e.target.x<0 and e.state.foodreserve==.4) + assert(h.stats().total==before.total and h.stats().deaths==0 and h.stats().births==0 and h.stats().meals==0) + assert(#h.objects==1 and o.armor.immortal==1) end) -test("global and species caps include distant active creatures until deactivation",function() - local h=fixture() - for i=1,12 do h.player(i*120) end - for i=1,30 do h.add("hare",math.ceil(i/3)*120+12) end - assert(h.stats().hare==16) - for i=1,20 do h.add("deer",math.ceil(i/3)*120+12) end - assert(h.stats().deer==10) - for i=1,12 do h.add("fox",(i+5)*120+12) end - assert(h.stats().total==32 and h.stats().fox==6) - for _,o in ipairs(h.objects) do if not o.removed then o.pos.x=o.pos.x+10000 end end - h.add("fox",132); assert(h.stats().total==32) - local e=h.objects[1].entity +test("songbirds loosely gather by species and keep separation without global flock state",function() + local h=fixture(); h.solar_hour=12; h.player() + local first,one=h.restore("robin",12) + local second,two=h.restore("robin",20) + h.ai(2) + assert(first.target.x>0 and second.target.x<0,"nearby robins should steer toward one another") + assert(h.saved(first):find('"resident_id":',1,true) and h.saved(second):find('"resident_id":',1,true)) + local mixed=fixture(); mixed.solar_hour=12; mixed.player() + local songbird=mixed.restore("robin",12) + local owl=mixed.restore("barn_owl",20) + mixed.ai(2) + assert(not (songbird.target.x>0 and owl.target.x<0),"different species must not be assigned to one flock") +end) +test("Creatura API registers bounded visible fauna groups and accepts its integer ground position",function() + local registrations={} + local C={register_abm_spawn=function(name,def) registrations[name]=copy(def) end, + get_boid_dir=function(self) return {x=self.kind=="robin" and .5 or 0,y=0,z=0} end} + local h=fixture(true,nil,C) + local registration_count=0; for _ in pairs(registrations) do registration_count=registration_count+1 end + assert(registration_count==10) + for name,def in pairs(registrations) do + assert(name:match("^hearth:") and def.interval==45 and def.chance==1800) + assert(def.spawn_cap==h.F.get(name).cap and def.min_group==1 and def.max_group==2) + end + local e,o=h.restore("robin",12,nil,4) + assert(not o.removed and e.object:get_pos().y==4,"Creatura's integer feet coordinate is supported above node y=3") +end) +test("persistent roundtrip before first player joins, unload is not death",function() + local h=fixture(); local e,o=h.restore("wood_mouse",12) + assert(not o.removed and h.stats().total==1) + local text=h.saved(e); assert(#text<2048 and text:sub(1,1)=="{") h.defs[e.name].on_deactivate(e); h.defs[e.name].on_deactivate(e) - assert(h.stats().total==31) + assert(h.stats().total==0 and h.stats().deaths==0) + h.time=h.time+86400; local b,obj=h.add("wood_mouse",12,0,text,86400) + assert(not obj.removed and b.state.born_at==e.state.born_at and b.state.sex==e.state.sex) + assert(b.state.foodreserve==e.state.foodreserve and h.stats().births==0) end) -test("habitat recruitment is slow, empty or unloaded habitat admits nothing",function() - local h=fixture(); h.player(); h.tick(29); assert(h.stats().total==0) - h.tick(1); assert(h.stats().hare==1) - local j=fixture(); j.player(); j.habitat=false; j.tick(120); assert(j.stats().total==0) - j.habitat=true; j.habitat_unloaded=true; j.tick(60); assert(j.stats().total==0) - local k=fixture(); k.player(); k.plants=false - k.hearth.succession.habitat=function() return {loaded=true,grass=0,cover=8} end - k.tick(60); assert(k.stats().total==0,"tree cover without edible plants must not recruit") -end) -test("natural grazing earns food then delayed dung; planted lookalikes never graze",function() - local h=fixture(); h.player(); local e=h.add("deer",12) - h.tick(7); assert(h.forages==0 and h.deposits==0) - h.tick(1); assert(h.forages==1 and e.food==1 and e.hunger<15) - h.tick(7); assert(h.deposits==0) - h.tick(1); assert(h.deposits==1 and h.stats().dung==1) - local j=fixture(); j.player(); j.add("hare",12); j.natural=false; j.tick(20) - assert(j.forages==0 and j.deposits==0) -end) -test("two fed mature animals breed once, consume both parents' food, and cool down",function() - local h=fixture(); h.player(); local a=h.add("deer",12); local b=h.add("deer",13) - -- Time and meals are established explicitly; no instant birth on activation. - h.tick(2); assert(h.stats().births==0) - for _,e in ipairs({a,b}) do e.age=60; e.food=3; e.hunger=0; e.breed_at=0 end - h.tick(2); assert(h.stats().births==1 and h.stats().deer==3) - assert(a.food==0 and b.food==0) - h.tick(2); assert(h.stats().births==1) -end) -test("empty habitat and unsuccessful forage cannot fund breeding",function() - local h=fixture(); h.player(); local a=h.add("hare",12); local b=h.add("hare",13) - for _,e in ipairs({a,b}) do e.age=60;e.food=3;e.hunger=0;e.breed_at=0 end - h.habitat=false; h.tick(10); assert(h.stats().births==0 and h.forages==0) - local j=fixture(); j.player(); j.feed_ok=false - local c=j.add("deer",12); local d=j.add("deer",13) - c.age=60;d.age=60; j.tick(25) - assert(c.food==0 and d.food==0 and j.stats().births==0 and j.deposits==0) -end) -test("predation consumes finite registered hares once and has a meal cooldown",function() - local h=fixture(); h.player(); local fox=h.add("fox",12); local prey,o=h.add("hare",12.4) - -- Hold prey until the predator's turn to isolate accounting from flight. - prey.next_ai=999; fox.feed_at=0 - h.tick(2); assert(o.removed and h.stats().meals==1 and h.stats().hare==0 and fox.food==1) - h.defs[prey.name].on_deactivate(prey); assert(h.stats().hare==0) - h.tick(10); assert(h.stats().meals==1 and h.stats().births==0) - local prey2,o2=h.add("hare",fox.object.pos.x+0.4,fox.object.pos.z); prey2.next_ai=999 - fox.hunger=30; h.tick(2); assert(not o2.removed and h.stats().meals==1) -end) -test("predator reproduction requires two meals from each mature parent",function() - local h=fixture(); h.player(); local a=h.add("fox",12); local b=h.add("fox",13) - for _,e in ipairs({a,b}) do e.age=60;e.breed_at=0;e.hunger=0 end - h.tick(2); assert(h.stats().births==0) - a.food=2;b.food=1; h.tick(2); assert(h.stats().births==0) - b.food=2; h.tick(2); assert(h.stats().births==1 and a.food==0 and b.food==0) -end) -test("players and predators trigger safe flight with correct +Z mesh yaw",function() - local h=fixture(); h.player(10); local _,o=h.add("hare",12) - h.tick(2); assert(o.pos.x>12 and math.abs(o.yaw+math.pi/2)<0.001) - assert(o.velocity.x==0 and o.velocity.y==0) - local j=fixture(); j.player(); local _,p=j.add("deer",12); j.add("fox",10) - j.tick(2); assert(p.pos.x>12) -end) -test("all callback neighbors deny claims, ambiguous overlaps and external protection",function() - for _,mode in ipairs({"claim","ambiguous","blocked"}) do - local h=fixture(); h.player(); local e=h.add("hare",12) - h[mode]=function(p) return p.x>=13 end - e.feed_at=0; e.dung_at=0; h.tick(2) - assert(h.forages==0 and h.deposits==0,"callback crossed "..mode) - local _,o=h.add("fox",14); assert(o.removed) +test("hostile static payloads reject types fields clocks ids versions pregnancy and size",function() + local h=fixture() + for _,change in ipairs({{species="unknown"},{species="fox"},{version=2},{born_at=h.time+1}, + {last_seen=h.time+1},{last_meal=h.time+1},{seed=-1},{foodreserve=2},{foodreserve="1"}, + {nextfeed=h.time+86401},{sex="other"},{mesh="evil.b3d"},{pregnancy_until=h.time+100},{origin="unknown"}}) do + local _,o=h.restore("hare",12,change); assert(o.removed,json(change)) end - local h=fixture(); h.player(); h.hearth.external_protected=function() return false end - h.api.is_protected=function() error("must prefer external protection over wrapped claim API") end - local _,o=h.add("hare",12); assert(not o.removed) + for _,payload in ipairs({"return os.execute('bad')","[]",string.rep("x",2049),"{}"}) do + local _,o=h.add("hare",12,0,payload); assert(o.removed) + end + assert(h.stats().total==0) + h.api.parse_json=function() error("malformed") end + local _,o=h.add("hare",12,0,"{}"); assert(o.removed) end) -test("water, cliffs, buildings, unloaded cells and newly claimed sources stop movement",function() - for _,mode in ipairs({"water","cliff","building","unloaded"}) do - local h=fixture(); h.player(10); local _,o=h.add("hare",12) - local original=h.terrain - if mode=="unloaded" then h.unloaded=function(p) return p.x>=13 end - else h.terrain=function(p) +test("new activation requires a nearby player and habitat, restores do not require food",function() + local h=fixture(); local _,o=h.add("hare",12); assert(o.removed) + h.player(); h.habitat=false; local _,a=h.add("hare",12); assert(a.removed) + local _,b=h.restore("fox",12); assert(not b.removed) + local j=fixture(); j.player(); j.plants=false + local _,c=j.add("deer",12); assert(c.removed,"cover is not food") +end) +test("seeded established cohort uses sourced maturity days and marks estimated origin",function() + local h=fixture(); h.player(); local e,o=h.add("bank_vole",12) + assert(not o.removed); local days=(h.time-e.state.born_at)/86400 + assert(days>=42 and days<=56 and e.state.origin=="established") + local before=e.state.born_at; h.ai(65) + assert(e.state.born_at==before and not o.removed and h.stats().births==0) +end) +test("no 900-second vanish or dt-based calendar age and no unknown old-age death",function() + local h=fixture(); h.player(); local e,o=h.restore("deer",12,{born_at=100}) + local born=e.state.born_at + for _,dt in ipairs({-1,0/0,math.huge,"bad",100000}) do h.defs[e.name].on_step(e,dt) end + h.time=h.time+100000; h.ai(2) + assert(not o.removed and e.state.born_at==born and h.stats().births==0) +end) +test("restores exceed species local player and global admission caps without deletion",function() + local h=fixture() + for i=1,20 do h.restore("hare",i*120) end + assert(h.stats().hare==20) + for _,r in ipairs(h.F.list()) do for i=1,r.cap do h.restore(r.id,(#h.objects+1)*120) end end + assert(h.stats().total>64) + h.player(); local _,blocked=h.add("hare",12); assert(blocked.removed) + local j=fixture() + for _,r in ipairs(j.F.list()) do j.restore(r.id,12) end + assert(j.stats().total==23) + local k=fixture(); k.player() + for _,r in ipairs(k.F.list()) do k.restore(r.id,#k.objects%2==0 and -20 or 20) end + assert(k.stats().total==23) + assert(h.stats().deaths+j.stats().deaths+k.stats().deaths==0) +end) +test("finite food web consumes actual registered prey exactly once with hours cooldown",function() + for _,pair in ipairs({{"fox","rabbit"},{"fox","hare"},{"stoat","bank_vole"},{"stoat","rabbit"}, + {"weasel","wood_mouse"},{"barn_owl","bank_vole"},{"badger","earthworm"}, + {"hedgehog","earthworm"},{"robin","earthworm"},{"blackbird","earthworm"}, + {"common_frog","earthworm"},{"smooth_newt","earthworm"}}) do + local h=fixture(); h.player(); local predator=h.restore(pair[1],12) + local prey,o=h.restore(pair[2],12.2); prey.next_ai=9999 + -- Diurnal and nocturnal tests use actual UTC, not an accelerated day. + if h.F.get(pair[1]).activity=="day" then h.time=h.time-12*3600; predator.state.last_seen=h.time; predator.state.nextfeed=h.time; predator.state.last_meal=h.time end + h.ai(2); assert(o.removed and h.stats().meals==1,pair[1]) + assert(predator.state.foodreserve>.7) + h.defs[prey.name].on_deactivate(prey); assert(h.stats().meals==1) + local prey2,o2=h.restore(pair[2],12.2); prey2.next_ai=9999; h.ai(4) + assert(not o2.removed and h.stats().meals==1,pair[1].." cooldown") + end +end) +test("aquatic otter consumes actual trout, fish never spawns food from absent insects",function() + local h=fixture(); h.water=true; h.plants=false; h.player() + local e,o=h.restore("brown_trout",12); e.next_ai=9999 + local otter,obj=h.add("otter",12.2); assert(not obj.removed and not o.removed) + h.ai(2); assert(o.removed and h.stats().meals==1 and otter.state.foodreserve>.7) + local j=fixture(); j.water=true; j.player(); local _,fish=j.add("brown_trout",12) + assert(fish.removed,"no fictional insect biomass recruitment") +end) +test("protected prey and callback stencil cannot mutate claims or ambiguous areas",function() + for _,mode in ipairs({"claim","ambiguous","blocked"}) do + local h=fixture(); h.player(); local e=h.restore("fox",12); local prey,o=h.restore("hare",12.2) + prey.next_ai=9999; h[mode]=function(p) return p.x>=12.1 end + h.ai(2); assert(not o.removed and h.stats().meals==0 and e.state.foodreserve<=.5) + local j=fixture(); j.player(); j.restore("hare",12) + j[mode]=function(p) return p.x==13 end; j.ai(2) + assert(j.forages==0 and j.deposits==0) + end +end) +test("natural grazing and one delayed metadata-only deposit, no inventory source",function() + local h=fixture(); h.player(); local e=h.restore("hare",12); h.ai(2) + assert(h.forages==1 and e.state.dung_pending and h.stats().grazed==1) + e.state.nextfeed=h.time+86400; h.time=h.time+3600; h.ai(2) + assert(h.deposits==1 and not e.state.dung_pending) + h.ai(2); assert(h.deposits==1) + local j=fixture(); j.player(); j.restore("hare",12); j.natural=false; j.ai(2); assert(j.forages==0) +end) +test("missing ecological hooks grant no nectar fruit litter seed or fake effects",function() + for _,row in ipairs({{"bumblebee","clover"},{"blackbird","blackberry"},{"earthworm","litter"},{"red_squirrel","hazel"}}) do + local h=fixture(); h.botany(); h.plants=row[2]; h.season=row[2]=="hazel" and "Autumn" or "Summer" + h.player(); local e=h.restore(row[1],12); h.time=h.time-12*3600 + e.state.last_seen=h.time;e.state.nextfeed=h.time;e.state.last_meal=h.time + h.ai(2); assert(e.state.foodreserve<=.5 and h.stats().pollinations==0 and h.stats().detritus==0) + end +end) +test("two bees share active flower nectar lock, false results never earn nutrition",function() + local h=fixture(); h.player(); h.botany(); h.plants=false + h.terrain=function(p) return p.y<=3 and "hearth:grass" or (p.y==4 and p.x==12 and p.z==0 and "hearth:clover" or "air") end + h.time=h.time-12*3600 + local visits=0 + h.hearth.succession.pollinate=function(p) + visits=visits+1; local m=h.api.get_meta(p); local ready=tonumber(m:get_string("succ_nectar_ready_at")) or 0 + if ready>h.time then return false end + m:set_string("succ_nectar_ready_at",tostring(h.time+300)); return true + end + local a=h.restore("bumblebee",12); local b=h.restore("bumblebee",12) + h.ai(2); assert(h.stats().pollinations==1 and visits>=2) + assert((a.state.foodreserve>.7)~=(b.state.foodreserve>.7)) + assert(h.stats().dispersals==0) +end) +test("seasonal fruit debits shared human harvest budget; no free winter fruit",function() + local h=fixture(); h.player(); h.botany(); h.plants="blackberry"; h.time=h.time-12*3600 + local calls=0 + h.hearth.succession.frugivore=function(p,id) + assert(id=="blackberry"); local m=h.api.get_meta(p) + if (tonumber(m:get_string("botany_ready_at")) or 0)>h.time then return false end + m:set_string("botany_ready_at",tostring(h.time+86400)); calls=calls+1; return true + end + local e=h.restore("blackbird",12); h.ai(2); assert(calls==1 and e.state.foodreserve>.7) + e.state.nextfeed=h.time; h.season="Winter"; h.ai(2); assert(calls==1) +end) +test("finite litter consumption and earned hazel caching use distinct hooks",function() + local h=fixture(); h.player(); h.plants="litter"; local supply=1 + h.hearth.succession.detritivore=function() if supply==0 then return false end; supply=supply-1; return true end + local e=h.restore("earthworm",12); h.ai(2); assert(supply==0 and h.stats().detritus==1) + e.state.nextfeed=h.time; h.ai(2); assert(h.stats().detritus==1) + local j=fixture(); j.player(); j.botany(); j.plants="hazel"; j.season="Autumn"; j.time=j.time-12*3600 + local nuts=2; local cache=0 + j.hearth.succession.granivore=function(_,id) assert(id=="hazel"); if nuts==0 then return false end; nuts=0; return true,"hazel" end + j.hearth.succession.disperse_seed=function(_,id) assert(id=="hazel" and nuts==0); cache=cache+1; return true end + local squirrel=j.restore("red_squirrel",12); j.ai(2); assert(cache==1 and j.stats().dispersals==1) + squirrel.state.nextfeed=j.time; j.ai(2); assert(cache==1) + -- Eating a single remaining nut does not authorize planting that same nut. + j.hearth.succession.granivore=function() return true end + squirrel.state.nextfeed=j.time; j.ai(2); assert(cache==1) +end) +test("smooth bounded velocity, animation transitions and correct authored forward yaw",function() + local h=fixture(); h.player(10); local e,o=h.restore("hare",12) + h.ai(1); assert(e.clip=="locomotion" and o.pos.x==12) + h.defs[e.name].on_step(e,.1) + assert(o.velocity.x>0 and o.velocity.x<=.2 and o.pos.x==12) + assert(math.abs(o.yaw+math.pi/2)<.001) + assert(o.animations[#o.animations].frames.x==20) + h.defs[e.name].on_step(e,1000); assert(math.abs(o.velocity.x)<=1.3 and e.lease>1) + h.defs[e.name].on_step(e,1); assert(o.velocity.x>0 and e.lease>0) + h.defs[e.name].on_step(e,1); h.defs[e.name].on_step(e,.1); assert(o.velocity.x==0) +end) +test("animals keep moving across the two-second AI interval and traverse toward safe food",function() + -- Keep the observer outside the eight-node flight radius so this fixture + -- isolates food-seeking instead of correctly prioritizing predator flight. + local h=fixture(); h.player(0); h.botany(); h.plants=false + h.terrain=function(p) + if p.y<=3 then return "hearth:grass" end + if p.y==4 and p.x==14 and p.z==0 then return "hearth:meadow_grass" end + return "air" + end + local e,o=h.restore("hare",12) + h.ai(1); assert(e.target.x>0 and e.lease>2) + h.simulate(3) + assert(o.pos.x>12.5,"physics steps must carry the animal toward the food node") + assert(h.forages==1 and e.state.foodreserve>.5,"reached browse is debited once") +end) +test("collision sweep catches a fractional-cell obstruction without teleporting",function() + local h=fixture(); h.player(10); local e,o=h.restore("hare",12) + h.plants=false + h.terrain=function(p) + if p.x>=13 and p.y>=4 then return "hearth:wall" end + return p.y<=3 and "hearth:grass" or "air" + end + h.ai(1); assert(o.velocity.x==0 and o.pos.x==12 and not o.removed) +end) +test("land water cliff building unloaded and protected-source crossings stop safely",function() + for _,mode in ipairs({"water","cliff","building","unloaded","claimed"}) do + local h=fixture(); h.player(10); local e,o=h.restore("hare",12) + h.terrain=function(p) if p.x>=13 then if mode=="water" then return "hearth:water_source" end if mode=="cliff" then return "air" end if mode=="building" and p.y>=4 then return "hearth:wall" end end - return original(p) - end end - h.tick(4); assert(o.pos.x<12.5,"unsafe crossing: "..mode) + return p.y<=3 and "hearth:grass" or "air" + end + if mode=="unloaded" then h.unloaded=function(p) return p.x>=13 end end + if mode=="claimed" then h.claim=function() return true end end + h.tick(2,.1); assert(o.velocity.x==0 and not o.removed and o.pos.x==12,mode) end - local h=fixture(); h.player(); local _,o=h.add("deer",12) - h.claim=function() return true end; h.tick(1); assert(o.removed and h.forages==0) end) -test("protected prey cannot be consumed through a boundary",function() - local h=fixture(); h.player(); local fox=h.add("fox",12); local prey,o=h.add("hare",13) - prey.next_ai=999; fox.feed_at=0; h.claim=function(p) return p.x>=13 end - -- Predator AI only: the prey's own guard would despawn it, not feed the fox. - for _=1,2 do h.steps[1](1) end - assert(not o.removed and h.stats().meals==0 and fox.food==0) +test("flight swim feed rest clips are selected by actual runtime behaviors",function() + local h=fixture(); h.player(); h.time=h.time-12*3600 + local e=h.restore("robin",12); h.ai(1); assert(e.clip=="flight_swim") + h.time=h.time+12*3600; h.ai(2); assert(e.clip=="rest") + local j=fixture(); j.water=true; j.player(); local f=j.restore("brown_trout",12); j.ai(1) + assert(f.clip=="flight_swim") + local k=fixture(); k.player(); local a=k.restore("hare",12); k.ai(1); assert(a.clip=="feed") end) -test("loaded lifetime, gradual hunger and deactivation release slots without drops",function() - local h=fixture(); h.player(); local e,o=h.add("hare",12) - for _,dt in ipairs({-1,0/0,math.huge,"bad"}) do h.defs[e.name].on_step(e,dt) end - assert(e.age==0 and e.hunger==20) - h.defs[e.name].on_step(e,100000); assert(e.age==1 and not o.removed) - e.age=899; h.defs[e.name].on_step(e,1); assert(o.removed and h.stats().total==0) - local a,b=h.add("deer",12); a.hunger=99 - h.defs[a.name].on_step(a,1); assert(not b.removed) - h.defs[a.name].on_step(a,1); assert(b.removed and h.stats().total==0) +test("solar activity schedules rest at night, twilight and winter dormancy",function() + local h=fixture(); h.player() + local robin,ro=h.restore("robin",12); h.ai(1) + assert(robin.clip=="rest" and not ro.removed,"diurnal robin rests after sunset") + local n=fixture(); n.player(); local hare,ho=n.restore("hare",12); n.ai(1) + assert(hare.clip~="rest" and not ho.removed,"nocturnal hare remains active at night") + local d=fixture(); d.player(); d.solar_hour=18; d.solar_elevation=0 + local deer,do_=d.restore("deer",12); d.ai(1) + assert(deer.clip~="rest" and not do_.removed,"crepuscular deer uses dusk light") + local w=fixture(); w.player(); w.season="Winter" + local hedgehog,wo=w.restore("hedgehog",12); w.ai(1) + assert(hedgehog.clip=="rest" and not wo.removed and w.stats().deaths==0) + assert(w.F.get("hedgehog").winter_dormant and w.F.get("common_frog").winter_dormant + and w.F.get("smooth_newt").winter_dormant) end) -test("shared AI plus recruitment never exceeds 12 actions per second or catches up",function() - local h=fixture() - for i=1,8 do h.player(i*120) end - for i=1,16 do h.add("hare",math.ceil(i/3)*120+12) end - for i=1,10 do h.add("deer",math.ceil(i/3)*120+12) end - for _=1,10 do - local before=h.stats().actions; h.tick(1); assert(h.stats().actions-before<=12) +test("sleep does not freeze loaded hunger",function() + local h=fixture(); h.player(100); local e,o=h.restore("robin",12) + local _,prey=h.restore("earthworm",12.2); prey.next_ai=9999 + local loaded_at=h.time + e.state.last_meal=loaded_at-3600; e.state.hunger_seconds=100 + local hunger_before=e.state.hunger_seconds + -- A daytime species rests at this controlled night elevation. Advance one + -- loaded minute, then let its scheduled think observe that interval; hunger + -- accounting is capped at 60 seconds per production think. + h.solar_hour=2; h.solar_elevation=-20; h.time=loaded_at+60 + h.ai(1) + assert(not o.removed and e.clip=="rest","sleeping robin remains alive and resting") + assert(not prey.removed and h.stats().meals==0 and h.forages==0,"resting wildlife does not feed") + assert(e.state.hunger_seconds==hunger_before+60,"loaded sleeping time accrues the capped 60-second hunger interval") + assert(e.state.last_seen==h.time,"the loaded interval snapshot advances to the observed time") +end) +test("winter woody browse requires a botanical browse tag and shared winter transaction mode",function() + local h=fixture(); h.player(); h.botany(); h.season="Winter"; h.solar_hour=2; h.solar_elevation=-20; h.plants="oak_winter" + h.api.registered_nodes["hearth:oak_winter"]={walkable=false} + local B=h.hearth.botany + B.kind=function(n) local name=type(n)=="table" and n.name or n; return name:find("oak",1,true) and "oak" or nil end + B.has_tag=function(_,tag) return tag=="browse" end + B.browse=function() return false end + B.get=function() return {group="trees",toxic=false} end + local mode + h.hearth.succession.forage=function(_,browse_mode) mode=browse_mode; return browse_mode=="winter_browse" end + local e=h.restore("hare",12); h.ai(1) + assert(mode=="winter_browse" and e.state.foodreserve>.5 and h.stats().grazed==1) + local denied=fixture(); denied.player(); denied.botany(); denied.season="Winter"; denied.solar_hour=2; denied.solar_elevation=-20; denied.plants="oak_winter" + denied.api.registered_nodes["hearth:oak_winter"]={walkable=false} + local D=denied.hearth.botany + D.kind=B.kind; D.has_tag=B.has_tag; D.browse=B.browse; D.get=function() return {group="trees",toxic=true} end + local x=denied.restore("hare",12); denied.ai(1) + assert(x.state.foodreserve<=.5 and denied.stats().grazed==0,"toxic woody plants are not winter browse") +end) +test("legacy perennial grass remains seasonal wildlife browse without human edibility",function() + local h=fixture(); h.player(0); h.botany(); h.season="Winter"; h.solar_hour=2; h.solar_elevation=-20 + -- Mirror the production season adapter: climate_at alone does not provide + -- the `available()` plant-phenology query used by botany browse. + h.hearth.seasons={kind=function(n) + local value=type(n)=="table" and n.name or n + return type(value)=="string" and value:gsub("^hearth:","") or nil + end,season=function() return h.season end, + available=function(id) return id~="meadow_grass" or h.season~="Winter" end} + local _,o=h.restore("hare",12); local mode + local botanical=h.hearth.botany.kind + h.hearth.botany.kind=function(n) + local id=botanical(n) + if id=="meadow_grass" then return nil end -- legacy grass is outside the 100-taxon catalogue + return id end + h.hearth.botany.browse=function() return false end + h.hearth.succession.forage=function(_,browse_mode) + mode=browse_mode; h.forages=h.forages+1; return true + end + h.ai(3) -- first think schedules its next turn two seconds later + assert(mode=="winter_browse" and h.forages==1 and not o.removed) +end) +test("unmarked registered botanical ground cover does not block swept terrestrial movement",function() + local h=fixture(); h.player(0); h.botany(); h.plants=false + h.api.registered_nodes["hearth:dandelion"]={walkable=false} + h.terrain=function(p) + if p.y<=3 then return "hearth:grass" end + if p.x==13 and p.y==4 and p.z==0 then return "hearth:dandelion" end + if p.x==14 and p.y==4 and p.z==0 then return "hearth:meadow_grass" end + return "air" + end + h.hearth.botany.get=function(n) + local value=type(n)=="table" and n.name or n + if value=="hearth:dandelion" then return {group="herbs",toxic=false,existing=false} end + if value=="hearth:meadow_grass" then return {group="grasses",toxic=false,existing=true} end + end + h.hearth.botany.browse=function(n,s) + return n.name=="hearth:meadow_grass" and (s=="Spring" or s=="Summer") + end + local e,o=h.restore("hare",12) + h.ai(1); h.simulate(3) + assert(not o.removed,"animal should remain present while crossing native botanical cover") + assert(o.pos.x>12.5,"animal should pass through the unmarked dandelion cell before feeding") + assert(h.forages==1 and e.state.foodreserve>.5,"reached browse is debited once after crossing plant cover") +end) +test("shared scheduler caps 24 actions per second including recruitment and no dt catchup",function() + local h=fixture(); h.player(-1000) + for _,r in ipairs(h.F.list()) do for _=1,r.cap do h.restore(r.id,(#h.objects+1)*120) end end + assert(h.stats().total>64) + for _=1,4 do local before=h.stats().actions; h.ai(1); assert(h.stats().actions-before<=24) end local before=h.stats().actions - h.steps[1](0/0); h.steps[1](math.huge); h.steps[1](-2) - assert(h.stats().actions==before) - h.steps[1](100000); assert(h.stats().actions-before<=12) - assert(h.stats().actions>20,"shared scheduler must make progress") + for _,dt in ipairs({-1,0/0,math.huge,"bad"}) do h.steps[1](dt) end + assert(h.stats().actions==before); h.steps[1](100000); assert(h.stats().actions-before<=24) + assert(h.stats().actions>20) end) -test("earned forage and four finite prey meals fund births rather than free reproduction",function() - local h=fixture(); h.player(); local a=h.add("hare",12); local b=h.add("hare",13) - -- Keep the mating pair in one patch; the AI still earns every meal itself. - for _,e in ipairs({a,b}) do - e.object.set_pos=function() end +test("no unloaded or player-absent recruitment, no all-species burst or offline birth queue",function() + local h=fixture(); h.ai(60); assert(h.stats().total==0) + h.player(); h.ai(4); assert(h.stats().total==0 and not h.scans) + h.ai(1); assert(h.stats().total<=1 and h.scans==1) + local j=fixture(); j.player(); j.unloaded=function() return true end; j.ai(60); assert(j.stats().total==0) + local k=fixture(); k.player(); local e=k.restore("deer",12) + k.time=k.time+365*86400; k.ai(2); assert(k.stats().births==0 and k.stats().deer==1) + assert(e.state.pregnancy_until==0) +end) +test("unknown predator starvation never invents deadlines, rabbit uses loaded hungry days",function() + local h=fixture(); h.player(); h.plants=false + local fox,o=h.restore("fox",12,{last_meal=h.time-86400,born_at=h.time-2*86400}) + h.time=h.time+10*86400; h.ai(2); assert(not o.removed and fox.state.foodreserve<=.5) + o:remove() -- isolate the starvation probe from legitimate rabbit predation + local paused,alive=h.restore("rabbit",12,{last_meal=h.time-8*86400,born_at=h.time-9*86400}) + h.ai(2); assert(not alive.removed and paused.state.hunger_seconds<=2,"offline gap is not starvation") + local e,obj=h.restore("rabbit",12,{last_meal=h.time-8*86400,born_at=h.time-9*86400,hunger_seconds=8*86400}) + h.ai(2); assert(obj.removed and h.stats().deaths==1 and e.state.last_meal=6 and h.stats().births==1 and h.stats().hare>=3) - assert(a.food==0 and b.food==0,"both parents must pay from earned forage") - local j=fixture(); j.player(); local f=j.add("fox",12); local g=j.add("fox",13) - for _,e in ipairs({f,g}) do e.age=60;e.breed_at=0;e.next_ai=999 end - for _,hunter in ipairs({f,f,g,g}) do - local p=hunter.object.pos - local prey=j.add("hare",p.x+0.2,p.z); prey.next_ai=999 - hunter.next_ai=0; hunter.feed_at=0; hunter.hunger=30 - j.tick(2); hunter.next_ai=999 + local a=h.restore(id,12,{born_at=h.time-400*DAY,foodreserve=.9,sex="female"}) + local b=h.restore(id,12.5,{born_at=h.time-400*DAY,foodreserve=.9,sex="male",seed=13}) + if id=="mallard" then + -- A loaded vegetated bank beside water, not an empty water column + -- falsely treated as represented food. + h.water=true + h.terrain=function(p) return p.y<=3 and "hearth:grass" or (p.y==4 and "hearth:meadow_grass" or "air") end end - assert(j.stats().meals==4 and j.stats().hare==0 and f.food==2 and g.food==2) - f.next_ai=0; f.hunger=0; g.hunger=0; j.tick(2) - assert(j.stats().births==1 and j.stats().fox==3 and f.food==0 and g.food==0) -end) -test("wandering selects loaded habitat and refused capped births conserve parent food",function() - local h=fixture(); h.player(); local e,o=h.add("deer",12) - h.habitat_at=function(p) return p.z>=1 end - h.tick(2); assert(o.pos.z>0 and o.moves==1) - local j=fixture(); j.player(); local a=j.add("hare",12); local b=j.add("hare",13) - for _=1,4 do j.add("deer",15) end - for _,parent in ipairs({a,b}) do parent.age=60;parent.food=3;parent.hunger=0;parent.breed_at=0 end - j.tick(2) - assert(j.stats().births==0 and j.stats().total==6 and a.food==3 and b.food==3) -end) -test("canonical species ecosystem herbs feed on expanded ground but not in dormant winter",function() - for _,ground in ipairs({"forest_floor","podzol","alluvium","peat","alpine_turf"}) do - local h=fixture(); local season="Summer" - h.hearth.climate_at=function() return {season=season} end - h.hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(h.hearth,h.api) - h.hearth.diversity=dofile(target:gsub("[^/\\]+$","diversity.lua"))(h.hearth,h.api) - h.terrain=function(p) return p.y<=3 and "hearth:"..ground or (p.y==4 and "hearth:clover" or "air") end - h.hearth.succession.habitat=function() return {loaded=true,grass=0,cover=8,edible=8} end - h.player(); local _,o=h.add("hare",12); assert(not o.removed,ground) - h.tick(8); assert(h.forages==1,ground) - season="Winter" - h.terrain=function(p) return p.y<=3 and "hearth:"..ground or (p.y==4 and "hearth:clover_winter" or "air") end - h.tick(20); assert(h.forages==1,"dormant clover grazed on "..ground) + assert(a.state and b.state); return h,a,b +end +test("paired rodents schedule real UTC gestation and never instant adult births",function() + for _,id in ipairs({"bank_vole","wood_mouse"}) do + local h,a=pair(id); h.ai(1) + local due=a.state.pregnancy_until; local days=h.F.get(id).profile.gestation_days.max + assert(due==a.state.conception_at+days*DAY and h.stats().births==0) + h.time=due-2; h.ai(1); assert(h.stats().births==0) + h.ai(2); assert(h.stats().births>=1) + for _,o in ipairs(h.objects) do if o.entity.state and o.entity.state.origin=="born" then + assert(h.time-o.entity.state.born_at<60 and h.F.phase(id,o.entity.state.born_at,h.time).phase=="dependent") + end end end end) -test("winter canonical grass cannot be admitted as a free meal or breeding habitat",function() - local h=fixture(); h.hearth.climate_at=function() return {season="Winter"} end - h.hearth.seasons=dofile(target:gsub("[^/\\]+$","seasons.lua"))(h.hearth,h.api) - h.player(); local _,o=h.add("hare",12); assert(o.removed) - h.tick(60); assert(h.forages==0 and h.stats().total==0) +test("sourced hare and fox profiles breed only after maturity with bounded real-time litters",function() + for _,row in ipairs({{"hare",42,"gestation_days"},{"fox",53,"gestation_days"}}) do + local id,days,field=row[1],row[2],row[3] + local h,a,mate=pair(id) + if id=="fox" then h.season="Winter"; h.plants="blackberry"; h.botany() end + if id=="fox" then + -- Keep the mating male from consuming the fixture's only hare before + -- the due-date habitat check; this test is about the litter schedule. + mate.next_ai=99999 + local prey=h.restore("hare",13,{sex="male",foodreserve=1}); prey.next_ai=99999 + end + h.ai(1) + local profile=h.F.get(id).profile + assert(a.state.pregnancy_until==a.state.conception_at+profile[field].max*DAY) + assert(profile.birth_count.min<=a.state.litter_total and a.state.litter_total<=profile.birth_count.max) + assert(h.stats().births==0 and a.state.pregnancy_until>h.time+30*DAY) + h.time=a.state.pregnancy_until; h.ai(2) + assert(h.stats().births>=1 and h.stats().births<=profile.birth_count.min) + end end) -print("PASS "..passed.." bounded wildlife tests") +test("pairing requires two mature healthy nearby compatible animals and seasonal habitat",function() + for _,mode in ipairs({"missing","same_sex","young","hungry","winter","habitat","far","species"}) do + local h,a,b=pair("bank_vole") + if mode=="missing" then b.object:remove() + elseif mode=="same_sex" then b.state.sex="female" + elseif mode=="young" then b.state.born_at=h.time-60 + elseif mode=="hungry" then b.state.foodreserve=.1 + elseif mode=="winter" then h.season="Winter" + elseif mode=="habitat" then h.habitat=false + elseif mode=="far" then b.object.pos.x=16 + elseif mode=="species" then b.object:remove(); h.restore("hare",12.5,{sex="male",foodreserve=1}) end + h.ai(1); assert(a.state.pregnancy_until==0,mode) + end +end) +test("actual successful forage debit funds reproduction but false forage cannot",function() + for _,success in ipairs({true,false}) do + local h,a,b=pair("bank_vole"); a.state.foodreserve=.5; b.state.foodreserve=.5 + local supply=2 + h.hearth.succession.forage=function() + if not success or supply==0 then return false end; supply=supply-1; return true + end + h.ai(4) + assert((a.state.pregnancy_until>0)==success) + assert(supply==(success and 0 or 2)) + end +end) +test("overdue pregnancy restores once with one bounded litter not offline catchup",function() + local h,a,b=pair("bank_vole"); h.ai(1) + local due=a.state.pregnancy_until; local count=a.state.litter_total + local text=h.saved(a); a.object:remove(); b.object:remove(); h.players={} + h.time=due+365*DAY; local restored,o=h.add("bank_vole",12,0,text,365*DAY) + assert(not o.removed and restored.state.pregnancy_until==due) + h.ai(2); assert(h.stats().births==0) + h.player(); h.ai(8) + assert(h.stats().births==count and count<=h.F.get("bank_vole").profile.birth_count.max) + assert(restored.state.pregnancy_until==0 and restored.state.litter_remaining==0) + local saved=h.saved(restored); restored.object:remove() + local again,obj=h.add("bank_vole",12,0,saved,60); assert(not obj.removed) + h.ai(4); assert(h.stats().births==count and again.state.nextbreed>h.time) +end) +test("partial birth caps retain original due and remaining across unload and restart",function() + local h,a=pair("bank_vole"); h.ai(1); local due=a.state.pregnancy_until + local fillers={}; for i=1,3 do fillers[i]=h.restore("bank_vole",12+i*.1,{sex="male",foodreserve=.1}) end + h.time=due; h.ai(2) + assert(h.stats().births==1 and a.state.litter_remaining==2 and a.state.litter_delivered==1) + local saved=h.saved(a); assert(saved~="invalid"); a.object:remove() + local restored,o=h.add("bank_vole",12,0,saved,60); assert(not o.removed) + h.ai(4); assert(h.stats().births==1 and restored.state.pregnancy_until==due) + for _,e in ipairs(fillers) do e.object:remove() end + h.ai(8); assert(h.stats().births==3 and restored.state.pregnancy_until==0) +end) +test("blocked unloaded foodless and absent-player births defer without redating",function() + for _,mode in ipairs({"claimed","unloaded","foodless","players"}) do + local h,a=pair("bank_vole"); h.ai(1); local due=a.state.pregnancy_until + h.time=due + if mode=="claimed" then h.claim=function() return true end + elseif mode=="unloaded" then h.unloaded=function() return true end + elseif mode=="foodless" then h.plants=false; h.habitat=false + else h.players={} end + h.ai(4); assert(h.stats().births==0 and a.state.pregnancy_until==due) + h.claim=nil; h.unloaded=nil; h.plants="meadow_grass"; h.habitat=true + if #h.players==0 then h.player() end + h.ai(2); assert(h.stats().births>=1 and (a.state.pregnancy_until==due or a.state.pregnancy_until==0)) + end +end) +test("pregnancy schema rejects giant counts forged deadlines males and incomplete authority",function() + local h,a=pair("bank_vole"); h.ai(1); local valid=parse(h.saved(a)); a.object:remove() + for _,change in ipairs({{litter_total=1000000,litter_remaining=1000000},{litter_remaining=-1}, + {pregnancy_until=h.time+1000*DAY},{pregnancy_until=h.time+1},{conception_at=h.time+1}, + {sex="male"},{born_at=h.time-60},{litter_delivered=999},{litter_remaining=1.5}, + {nextbreed=h.time+1000*DAY},{parent_seed=-1},{extra=true}}) do + local s=copy(valid); for k,v in pairs(change) do s[k]=v end + local _,o=h.add("bank_vole",12,0,json(s),60); assert(o.removed,json(change)) + end + local _,obj=h.add("bank_vole",12,0,json(valid),60); assert(not obj.removed) + local _,nested=h.add("bank_vole",12,0,'{"version":'..string.rep('[',1000)); assert(nested.removed) +end) +test("blackbird and mallard use sourced incubation independent of missing lifespan",function() + for _,id in ipairs({"blackbird","mallard"}) do + local h,a=pair(id); h.ai(1) + local p=h.F.get(id).profile; local due=a.state.pregnancy_until + assert(due==a.state.conception_at+p.incubation_days.max*DAY and due>h.time) + h.time=due-2; h.ai(1); assert(h.stats().births==0) + h.ai(2); assert(h.stats().births>0 and h.stats().counts[id]<=h.F.get(id).cap) + assert(a.state.nextbreed>=h.time-2+p.postpartum_days*DAY) + end +end) +test("failed spawn retries same child and reentrant callbacks cannot duplicate delivery",function() + local h,a=pair("bank_vole"); h.ai(1); h.time=a.state.pregnancy_until + local add=h.api.add_entity; local remaining=a.state.litter_remaining + h.api.add_entity=function() return nil end + h.ai(2); assert(h.stats().births==0 and a.state.litter_remaining==remaining) + h.api.add_entity=function(p,id,data) + local o=add(p,id,data); h.steps[1](1); return o + end + h.ai(2); assert(h.stats().births==1 and a.state.litter_remaining==remaining-1) + assert(h.stats().actions<=24*4) +end) +test("postpartum lock and dependent care consume reserve without juvenile hunting",function() + local h,a=pair("bank_vole"); h.ai(1); h.time=a.state.pregnancy_until; h.ai(8) + assert(h.stats().births==3 and a.state.nextbreed>=h.time-8+25*DAY) + local child + for _,o in ipairs(h.objects) do if o.entity.state and o.entity.state.origin=="born" then child=o.entity; break end end + assert(child and child.state.sex and child.state.parent_seed==a.state.seed) + local feeds=h.forages; local reserve=a.state.foodreserve + a.next_ai=99999; child.state.nextfeed=h.time; h.ai(2) + assert(h.forages==feeds and a.state.foodreserve=1 and s.total<=6,json(s.counts)) + assert(s.recruitment.attempts==6 and s.recruitment.scans==6 and s.recruitment.successes>=1) + assert(s.counts.hare==0 and s.counts.fox==0 and s.births==0) + assert(s.counts.bank_vole+s.counts.bumblebee>0,"represented daylight herbivores/pollinators") + local fed=false + for _,o in ipairs(h.objects) do if not o.removed then + local p=o:get_pos(); assert(p.y==h.height(p.x,p.z)+.5,"actual surface, not player Y") + assert(p.x*p.x+p.z*p.z>=144 and p.x*p.x+p.z*p.z<=576) + assert(o.entity.state.origin=="established") + end end + -- The final admission at second 30 is not in that tick's prebuilt AI list. + h.ai(2) + for _,o in ipairs(h.objects) do if not o.removed and o.entity.state.foodreserve>.5 then fed=true end end + assert(fed and h.stats().grazed+h.stats().pollinations>0,"actual finite succession transaction must feed") +end) +test("worldgen passability is independent of botanical adoption but never consumes player plants",function() + local h=landscape(); h.height=function() return 3 end + local e,o=h.restore("rabbit",12) + assert(not o.removed,"unadopted worldgen sprite must not reject safe persistent activation") + assert(h.api.get_meta({x=12,y=4,z=0}):get_string("succ_natural")=="") + local j=landscape(); j.height=function() return 3 end + for x=-30,30 do for z=-30,30 do j.api.get_meta({x=x,y=4,z=z}):set_string("planter","gardener") end end + j.ai(30); assert(j.stats().total==0 and j.stats().grazed==0) + for _,m in pairs(j.metas) do assert(m.succ_natural==nil and m.succ_browse_ready_at==nil) end +end) +test("natural worldgen adoption respects unknown metadata support claims and protection",function() + for _,mode in ipairs({"unknown","support","claim","ambiguous","protected","unloaded"}) do + local h=landscape(); h.height=function() return 3 end + if mode=="unknown" then + for x=-30,30 do for z=-30,30 do h.api.get_meta({x=x,y=4,z=z}):set_string("thirdparty_owner","someone") end end + elseif mode=="support" then + for x=-30,30 do for z=-30,30 do h.api.get_meta({x=x,y=3,z=z}):set_string("player_placed","1") end end + elseif mode=="claim" then h.claim=function() return true end + elseif mode=="ambiguous" then h.ambiguous=function() return true end + elseif mode=="protected" then h.blocked=function() return true end + else h.unloaded=function() return true end end + h.ai(30); assert(h.stats().total==0,mode) + for _,m in pairs(h.metas) do assert(m.succ_natural==nil,mode) end + end +end) +test("habitat matching skips unsupported species without a 23 interval delay",function() + local h=landscape(); h.height=function() return 3 end + h.terrain=function(p) return p.y<=3 and "hearth:grass" or (p.y==4 and "hearth:yarrow" or "air") end + h.api.registered_nodes["hearth:yarrow"]={walkable=false} + h.ai(5) + assert(h.stats().total==1 and h.stats().counts.bumblebee==1) + local s=h.stats().recruitment; assert(s.attempts==1 and s.scans==1 and s.last_reason=="admitted") + s.reasons.admitted=999; assert(h.stats().recruitment.reasons.admitted==1,"defensive status copy") +end) +test("hare immigration joins as a sourced mature resident and still needs a mate",function() + local h=landscape(); h.height=function() return 3 end + -- Hares are nocturnal; exercise immigration during the configured solar night + -- instead of expecting a sleeping species to appear in daylight. + h.time=math.floor(h.time/86400)*86400+2*3600 + h.terrain=function(p) return p.y<=3 and "hearth:grass" or (p.y==4 and "hearth:meadow_grass" or "air") end + h.ai(5); assert(h.stats().counts.hare==1) + local e=h.objects[1].entity + local profile=h.F.get("hare").profile + assert(e.state.origin=="established") + assert(e.state.born_at==h.time-math.ceil(profile.maturity_days.max*DAY),"wild immigrant gets an estimated mature, not newborn, calendar age") + local phase=h.F.phase("hare",e.state.born_at,h.time) + assert(phase and phase.phase=="mature" and h.F.can_breed("hare",e.state.born_at,h.time,"female","Summer")) + h.ai(2); assert(h.stats().births==0,"one mature resident cannot breed without a mate") +end) +test("eight-node observer danger flees but 12-node recruits do not flee immediately",function() + local h=fixture(); h.player(5); local e=h.restore("hare",12); h.ai(1) + assert(e.clip=="locomotion" and e.target.x>0) + local j=fixture(); j.player(); local a=j.restore("hare",12); j.ai(1) + assert(a.clip=="feed" and j.forages==1) +end) +test("candidate search refuses empty water foodless and dormant surfaces",function() + for _,mode in ipairs({"empty","water","winter"}) do + local h=landscape(); h.height=function() return 3 end + if mode=="winter" then h.season="Winter" end + h.terrain=function(p) + if p.y<=3 then return "hearth:grass" end + if p.y==4 then return mode=="water" and "hearth:water_source" + or (mode=="winter" and "hearth:clover_winter" or "air") end + return "air" + end + h.api.registered_nodes["hearth:clover_winter"]={walkable=false} + h.ai(30); assert(h.stats().total==0,mode) + end +end) +test("recruitment queries bounded terrain once per five seconds and shares AI credit",function() + local h=landscape(); h.height=function() return 3 end + for _=1,60 do local before=h.stats().actions; h.ai(1); assert(h.stats().actions-before<=24) end + assert(h.scans<=12 and h.stats().recruitment.attempts==12) + assert(h.stats().total<=12 and h.stats().total<=h.stats().player_cap) + local scans=h.scans; h.players={}; h.ai(100); assert(h.scans==scans) + h.player(); h.ai(4); assert(h.scans==scans) + h.ai(1); assert(h.scans==scans+1) +end) +test("seeded region unloads ten times and walking away back keeps IDs without immigrants",function() + local h=landscape(); h.height=function() return 3 end + local observer=h.players[1]; observer.pos={x=24,y=4,z=24} + h.ai(80); assert(h.stats().total==4 and h.stats().recruitment.successes==4) + local residents={}; local ids={} + for _,o in ipairs(h.objects) do if not o.removed then + local e=o.entity; assert(e.state.resident_id and e.state.founder_region=="r_0_0") + assert(not ids[e.state.resident_id]); ids[e.state.resident_id]=true + residents[#residents+1]=e + end end + for cycle=1,10 do + local saved={} + for _,e in ipairs(residents) do + saved[#saved+1]={id=e.kind,pos=copy(e.object.pos),text=h.saved(e)} + h.defs[e.name].on_deactivate(e); e.object.removed=true + end + observer.pos={x=2400,y=4,z=2400}; h.unloaded=function() return true end + h.ai(10); assert(h.stats().total==0) + observer.pos={x=24,y=4,z=24}; h.unloaded=nil; residents={} + for _,s in ipairs(saved) do + local e,o=h.add(s.id,s.pos.x,s.pos.z,s.text,600,s.pos.y) + assert(not o.removed and ids[e.state.resident_id]); residents[#residents+1]=e + end + h.ai(10); assert(h.stats().total==4 and h.stats().recruitment.successes==4) + end + assert(h.stats().deaths==0 and h.stats().births==0) +end) +test("full founder journal survives restart even with zero active residents",function() + local h=landscape(); h.height=function() return 3 end; h.players[1].pos={x=24,y=4,z=24} + h.ai(80); assert(h.stats().recruitment.successes==4) + local saved={}; for _,o in ipairs(h.objects) do if not o.removed then + saved[#saved+1]={id=o.entity.kind,p=copy(o.pos),text=h.saved(o.entity),resident_id=o.entity.state.resident_id} + end end + local j=landscape(copy(h.storage_data)); j.height=function() return 3 end + j.players[1].pos={x=24,y=4,z=24}; j.ai(80) + assert(j.stats().total==0 and j.stats().recruitment.successes==0) + j.api.get_connected_players=nil + for _,s in ipairs(saved) do + local e,o=j.add(s.id,s.p.x,s.p.z,s.text,86400,s.p.y) + assert(not o.removed and e.state.resident_id==s.resident_id) + end + j.ai(2); assert(j.stats().total==4 and j.stats().births==0) +end) +test("invalid candidate unloaded and foodless searches retain retryable quota",function() + local h=landscape(); h.height=function() return 3 end; h.players[1].pos={x=24,y=4,z=24} + h.unloaded=function() return true end; h.ai(20) + assert(h.stats().total==0 and h.stats().residents.regions==1) + local journal=parse(h.storage_data.wildlife_residents_v1) + assert(journal.r_0_0:match("^4/%d+/$"),"failed attempts have no founder membership") + h.unloaded=nil; h.ai(80); assert(h.stats().total==4) + local j=landscape(copy(h.storage_data)); j.height=function() return 3 end + j.players[1].pos={x=24,y=4,z=24}; j.ai(80); assert(j.stats().total==0) +end) +test("predation spends a real resident but never refunds its founder slot",function() + local h=fixture(); h.player() + for i=1,4 do local e,o=h.add("hare",12+i*.1); assert(not o.removed); e.next_ai=99999 end + local predator=h.restore("fox",12); h.ai(2) + assert(h.stats().deaths==1 and h.stats().meals==1 and h.stats().hare==3) + local _,replacement=h.add("hare",13); assert(replacement.removed) + assert(predator.state.foodreserve>.7) + local journal=parse(h.storage_data.wildlife_residents_v1) + local n=0; for _ in journal.r_0_0:gmatch("=hare;") do n=n+1 end; assert(n==4) +end) +test("changed claims habitat and nil ignore terrain freeze residents without erasing saves",function() + for _,mode in ipairs({"claim","ambiguous","protected","nil","ignore","building","foodless"}) do + local h=fixture(); h.player(); local e,o=h.restore("hare",12); local id=e.state.resident_id + local text=h.saved(e); h.defs[e.name].on_deactivate(e); o.removed=true + if mode=="claim" then h.claim=function() return true end + elseif mode=="ambiguous" then h.ambiguous=function() return true end + elseif mode=="protected" then h.blocked=function() return true end + elseif mode=="nil" then h.unloaded=function() return true end + elseif mode=="ignore" then h.terrain=function() return "ignore" end + elseif mode=="building" then h.terrain=function() return "hearth:wall" end + else h.plants=false; h.habitat=false end + local restored,obj=h.add("hare",12,0,text,86400) + assert(not obj.removed and restored.state.resident_id==id,mode) + h.tick(10); assert(not obj.removed and h.stats().deaths==0 and h.forages==0 and h.deposits==0,mode) + assert(parse(h.saved(restored)).resident_id==id) + if mode=="foodless" then assert(restored.state.foodreserve<.5,"food loss causes hunger, not deletion") + else assert(obj.velocity.x==0 and obj.velocity.z==0,mode) end + end +end) +test("corrupt version size nesting and membership registry fail closed without deleting restores",function() + for _,text in ipairs({"broken","[]",string.rep("x",1048577),json({version=2,counter=0}), + json({version=1,counter=-1}),'{"version":1,"counter":0,"bad":{}}', + json({version=1,counter=1,r_0_0="0/0/1=unknown;"}), + json({version=1,counter=1,r_0_0="0/0/1=hare;1=hare;"}), + json({version=1,counter=1,r_0_0="0/0/1=hare;garbage"})}) do + local h=fixture(true,{wildlife_residents_v1=text}); h.player(); h.search=true + local e,o=h.restore("hare",12); assert(not o.removed) + local _,fresh=h.add("hare",14); assert(fresh.removed) + h.ai(10); assert(h.stats().total==1 and not o.removed and not h.stats().residents.storage_ok) + assert(h.saved(e)~="invalid" and h.storage_data.wildlife_residents_v1==text and #h.logs>0) + end +end) +test("storage read write and silent-write faults stop admissions but not existing residents",function() + for _,fault in ipairs({"storage_read_error","storage_write_error","storage_drop_write"}) do + local h=fixture(); h.player(); local e,o=h.restore("hare",12); local id=e.state.resident_id + h[fault]=true; local _,fresh=h.add("hare",14) + assert(fresh.removed and not h.stats().residents.storage_ok) + local restored,obj=h.add("hare",12,0,h.saved(e),60) + assert(not o.removed and not obj.removed and restored.state.resident_id==id) + end +end) +test("registry budget refuses new regions without eviction truncation or reseeding",function() + local journal={version=1,counter=0}; local n=0 + for x=-32,31 do for z=-32,31 do journal["r_"..x.."_"..z]="0/0/"; n=n+1 end end + assert(n==4096) + local h=fixture(true,{wildlife_residents_v1=json(journal)}); h.player(4000) + assert(h.stats().residents.storage_ok and h.stats().residents.regions==4096) + local _,fresh=h.add("hare",4012); assert(fresh.removed) + local _,restored=h.restore("hare",4012); assert(not restored.removed) + assert(h.stats().residents.regions==4096 and parse(h.storage_data.wildlife_residents_v1).r_0_0=="0/0/") + journal.r_32_32="0/0/" + local j=fixture(true,{wildlife_residents_v1=json(journal)}) + assert(not j.stats().residents.storage_ok,"oversize record count rejects whole ledger") +end) +test("stable individual IDs and food origin age brood state serialize publicly across restarts",function() + local h,a=pair("bank_vole"); h.ai(1) + local state=copy(a.state); local text=h.saved(a); local public=parse(text) + assert(public.resident_id==state.resident_id and public.origin=="established" and math.abs(public.foodreserve-state.foodreserve)<1e-12) + local j=fixture(true,copy(h.storage_data)); j.time=h.time+86400; j.players={} + local e,o=j.add("bank_vole",12,0,text,86400) + assert(not o.removed and e.state.resident_id==state.resident_id and e.state.born_at==state.born_at) + for _,k in ipairs({"sex","origin","foodreserve","seed","pregnancy_until","conception_at","litter_remaining","nextbreed"}) do + assert(e.state[k]==state[k] or (k=="foodreserve" and math.abs(e.state[k]-state[k])<1e-12),k) + end + local before=e.state.resident_id; j.player(); local fresh,obj=j.add("bank_vole",14) + assert(not obj.removed and fresh.state.resident_id>before) +end) +test("born children obtain durable distinct IDs without spending founder quota",function() + local h,a=pair("bank_vole"); h.ai(1); h.time=a.state.pregnancy_until; h.ai(8) + assert(h.stats().births==3) + local seen={[a.state.resident_id]=true}; local child + for _,o in ipairs(h.objects) do if o.entity.state and o.entity.state.origin=="born" then + child=o.entity; assert(child.state.founder_region=="" and not seen[child.state.resident_id]); seen[child.state.resident_id]=true + end end + assert(child) + local id=child.state.resident_id; local text=h.saved(child); child.object:remove() + local restored,obj=h.add(child.kind,12,0,text,60); assert(not obj.removed and restored.state.resident_id==id) + local journal=parse(h.storage_data.wildlife_residents_v1) + assert(not journal.r_0_0 or not journal.r_0_0:find("="),"attempt-only journal has no founder membership") +end) +test("unsafe static fields still reject while valid native dtime restoration is retained",function() + local h=fixture(); local e=h.restore("hare",12); local s=parse(h.saved(e)) + for _,changes in ipairs({{resident_id=0},{resident_id="1"},{founder_region="r_9999_0"}, + {founder_region="../../world"},{hunger_seconds=-1},{hunger_seconds="8"}}) do + local bad=copy(s); for k,v in pairs(changes) do bad[k]=v end + local _,o=h.add("hare",12,0,json(bad),86400); assert(o.removed) + end + local _,o=h.add("hare",12,0,json(s),86400); assert(not o.removed) + assert(#h.logs>=6,"strict snapshot rejection is logged without dumping raw input") +end) +test("missing rolled back registry never reuses saved IDs or erases those residents",function() + local h=fixture(); local e=h.restore("hare",12); local text=h.saved(e) + local j=fixture(); local restored,o=j.add("hare",12,0,text,60) + assert(not o.removed and restored.state.resident_id==e.state.resident_id and not j.stats().residents.storage_ok) + j.player(); local _,fresh=j.add("hare",14); assert(fresh.removed) +end) +if only then + assert(passed==1,"selected wildlife test was not found: "..only) + print("PASS 1 selected wildlife test: "..only) +else + assert(passed==62) + print("PASS "..passed.." persistent wildlife tests") +end diff --git a/tests/world-art.test.mjs b/tests/world-art.test.mjs index 1541675..80c59b6 100755 --- a/tests/world-art.test.mjs +++ b/tests/world-art.test.mjs @@ -61,7 +61,8 @@ test('named manifest covers all requested world, ecology, craftitem, wildlife an const art=worldArt(); const requiredWorld='stone soil grass_top grass_side sand log_top log_side leaves planks wall roof thatch foundation frame stairs door window vent storage pantry reeds berry_bush berry_bush_bare sapling grain_patch crop_1 crop_2 crop_3 crop_4 clay_bank iron_ore_vein water fire fire_lit torch sticks loose_stone bedroll drying_rack fish_trap weir_stone meadow_grass wildflowers fern litter fungi deadwood tree_seedling young_tree mature_tree dung leaves_spring leaves_autumn leaves_winter meadow_grass_dormant fern_dormant wildflowers_dormant spring_greens spring_greens_dormant autumn_mushrooms autumn_mushrooms_dormant'.split(' '); const requiredItems='berries grain grain_seed raw_food cooked_food fish_bait raw_fish cooked_fish fish_drying_charge dried_fish iron_ore iron_ingot charcoal charcoal_charge smelt_charge clay clay_jar clay_jar_charge dried_food dried_food_charge cloak vessel water_raw water_clean firestarter iron_axe spring_greens autumn_mushrooms'.split(' '); - const species=['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce'], rocks=['granite','basalt','limestone','slate','sandstone']; + const 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 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(' '), @@ -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 dormant=understory.slice(0,6).map(n=>`${n}_dormant`); assert.deepEqual(SPECIES_NAMES,species); assert.deepEqual(ROCK_NAMES,rocks); - assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,61); + assert.deepEqual(BIOME_WORLD_NAMES,biome); assert.equal(biome.length,73); assert.deepEqual(UNDERSTORY_NAMES,understory); assert.deepEqual(DORMANT_UNDERSTORY_NAMES,dormant); assert.deepEqual(SPECIES_SEASONAL_NAMES,seasonal); assert.deepEqual([...WORLD_NAMES].sort(),[...requiredWorld,...biome,...seasonal,...understory,...dormant].sort()); @@ -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`), ...BOTANY_SPRITE_NAMES.map(n=>`hearth_${n}.png`)]; assert.equal(new Set(names).size,names.length); assert.deepEqual([...art.keys()],names); - assert.equal(art.size,360); assert.equal(WORLD_NAMES.length,160); assert.equal(ITEM_NAMES.length,34); + assert.equal(art.size,421); assert.equal(WORLD_NAMES.length,181); assert.equal(ITEM_NAMES.length,34); for(const [name,image] of art) { assert.match(name,/^hearth_[a-z0-9_]+\.png$/); assert.equal(image.width,32); assert.equal(image.height,32); assert.equal(image.data.length,4096); assert.ok(image.data.some(n=>n>0)); } }); -test('all 80 exact catalogue IDs and Latin identities match the actual Lua records, with two manifest states each',async()=> { +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 rows=[...source.matchAll(/^\s*add\("([a-z_]+)","[^"]+","([^"]+)"/gm)].map(([,id,latin])=>({id,latin})); - assert.equal(rows.length,80); assert.equal(new Set(rows.map(r=>r.id)).size,80); - assert.equal(new Set(rows.map(r=>r.latin)).size,80); + assert.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,100); assert.deepEqual(BOTANY_ART_NAMES,rows.map(r=>r.id)); assert.deepEqual(BOTANY_CATALOGUE.map(({id,latin})=>({id,latin})),rows); const groups=['trees','shrubs','grasses','herbs','ferns_mosses','fungi','aquatic','lichens']; - for(const [i,g] of groups.entries()) assert.deepEqual(BOTANY_CATALOGUE.slice(i*10,i*10+10).map(r=>r.group),Array(10).fill(g)); - assert.deepEqual(BOTANY_ART_NAMES.slice(0,10),SPECIES_NAMES); - assert.equal(BOTANY_ART_MANIFEST.length,160); + const counts=[13,14,10,22,11,10,10,10]; let offset=0; + for(const [i,g] of groups.entries()) { + 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`)); for(const r of BOTANY_ART_MANIFEST) { 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(); for(const id of BOTANY_ART_NAMES) { 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`); } } - assert.equal(activeMasks.size,80,'no two taxa masquerade as the same recoloured silhouette'); - assert.equal(dormantMasks.size,80,'each inactive species retains its own structure'); - assert.equal(allBytes.size,160); + assert.equal(activeMasks.size,100,'no two taxa masquerade as the same recoloured silhouette'); + assert.equal(dormantMasks.size,100,'each inactive species retains its own structure'); + assert.equal(allBytes.size,200); }); 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',()=> { const first=worldArt(), second=worldArt(); const allowed=new Set(Object.values(PALETTE).map(c=>c.join(','))), unique=new Set();