Browse Source

Fix: set conf values non-redundantly (and change BASE_ENLIVEN_CONF_SETTINGS from list to OrderedDict). Fix: Raise exception instead of skipping mod if missing 'name', or 'repo' for git mods. Add prestibags. Fix some repo URLs. Put mods in alphabetical order under each category. Change from Protector Redo to protect_block_area.

Poikilos 1 week ago
parent
commit
00277b17bf
  1. 55
      EnlivenMinetest.code-workspace
  2. 467
      buildenliven.py
  3. 20
      changelog.md
  4. 11
      pyenliven/__init__.py
  5. 205
      pyenliven/gamebuilder.py
  6. 354
      pyenliven/metadata.py

55
EnlivenMinetest.code-workspace

@ -6,9 +6,62 @@
],
"settings": {
"cSpell.words": [
"advancedban",
"bakedclay",
"caverealms",
"compassgps",
"digilines",
"dmobs",
"Facedeer",
"gamebuilder",
"gamespec",
"glooptest",
"hbarmor",
"hbhunger",
"hbsprint",
"homedecor",
"hudbars",
"intllib",
"invhack",
"ircpack",
"luanti",
"mapgen",
"mesecons",
"metatools",
"minetest",
"Minetestforfun",
"minetestmapper",
"pyenliven"
"modlist",
"modpack",
"monoids",
"moreblocks",
"moreores",
"moretrees",
"mymasonhammer",
"mywalls",
"nftools",
"pipeworks",
"placecraft",
"plantlife",
"playeranim",
"playereffects",
"Poikilos",
"prestibags",
"privs",
"pyenliven",
"railcorridors",
"sfinv",
"skinsdb",
"slimenodes",
"subgame",
"travelnet",
"trmp",
"unifieddyes",
"worldedge",
"worldedit",
"wulfsdad",
"Wuzzy",
"xban"
]
}
}

467
buildenliven.py

@ -2,23 +2,35 @@
'''
ENLIVEN subgame builder - creates ENLIVEN based on minetest_game
Merges mods & settings from old bash installer script
mt_conf_by_mod settings should be placed in minetest.conf
but go in f"{destination}/minetest.conf" by default. See also:
- patches/subgame/minetest.conf
To define the game.
- patches/subgame/minetest.server-example.conf goes in the server only.
- Place the result in the game directory such as will result in
f"worlds/{world}/ENLIVEN/minetest.conf"
- patches/subgame/minetest.client-example.conf goes in clients only.
'''
from __future__ import print_function
from collections import OrderedDict
import sys
import os
import argparse
# import configparser
import shutil
from typing import List, Dict, Union
import logging
# Assuming these exist in your pyenliven module
from pyenliven import (
echo0,
# getSGPath, # not used here anymore
# profile,
MODS_STOPGAP_DIR,
)
from pyenliven.gamebuilder import GameBuilder
logger = logging.getLogger(os.path.split(__file__)[1])
# ──────────────────────────────────────────────────────────────
# M O D L I S T F R O M B A S H S C R I P T
# ──────────────────────────────────────────────────────────────
@ -29,386 +41,6 @@ from pyenliven import (
# 'branch': optional branch name
# 'stopgap_only': True → only use from MODS_STOPGAP_DIR, ignore repo
gamespec: Dict[str, any] = {}
gamespec['remove_mods'] = [
"facade", # no recipes
"placecraft", # interferes with eating
"more_chests", # https://github.com/poikilos/EnlivenMinetest/issues/446
"emeralds", # https://github.com/poikilos/EnlivenMinetest/issues/497
"give_initial_stuff",
"xban2",
"dynamic_liquid",
"stamina",
"hbarmor",
"hbhunger",
"hudbars",
"hbsprint",
"dungeon_loot", # replaced by treasurer + trm_*
"helicopter", # known crash issues in older versions
# add others from issue #310 if desired
]
gamespec['add_mods']: List[Dict[str, any]] = [
# ── Utility / Admin ────────────────────────────────────────
{'name': 'invhack', 'repo': "https://github.com/salahzar/minetest-invhack.git"},
{'name': 'worldedit', 'repo': "https://github.com/Uberi/MineTest-WorldEdit.git"},
{'name': 'metatools', 'repo': "https://github.com/poikilos/metatools.git"},
{'name': 'protector', 'repo': "https://notabug.org/TenPlus1/protector.git"},
{'name': 'advancedban', 'repo': "https://github.com/srifqi/advancedban"},
{'name': 'areas', 'repo': "https://github.com/ShadowNinja/areas.git"},
{'name': 'whitelist', 'repo': "https://github.com/ShadowNinja/whitelist.git"},
{'name': 'vote', 'repo': "https://github.com/minetest-mods/vote.git"},
# ── Mobs & Worldgen ────────────────────────────────────────
{'name': 'worldedge', 'repo': "https://github.com/minetest-mods/worldedge.git"},
{'name': 'mobs_redo', 'repo': "https://notabug.org/TenPlus1/mobs_redo.git"},
{'name': 'mobs_monster', 'repo': "https://notabug.org/TenPlus1/mobs_monster.git"},
{'name': 'mobs_animal', 'repo': "https://notabug.org/TenPlus1/mobs_animal.git"},
{'name': 'mob_horse', 'repo': "https://notabug.org/tenplus1/mob_horse.git"},
{'name': 'mobs_sky', 'repo': "https://github.com/poikilos/mobs_sky.git"},
{'name': 'spawners', 'repo': "https://bitbucket.org/minetest_gamers/spawners.git"},
{'name': 'tsm_pyramids', 'repo': "https://github.com/poikilos/tsm_pyramids.git"},
{'name': 'tsm_chests_dungeon', 'repo': "http://repo.or.cz/minetest_tsm_chests_dungeon.git"},
{'name': 'treasurer', 'repo': "http://repo.or.cz/minetest_treasurer.git"},
{'name': 'trm_pyramids'}, # special – files copied directly in bash → handle manually or stopgap
{'name': 'moreblocks', 'repo': "https://github.com/minetest-mods/moreblocks.git"},
{'name': 'plantlife_modpack','repo': "https://github.com/mt-mods/plantlife_modpack.git"},
{'name': 'bushes_soil', 'repo': "https://github.com/poikilos/bushes_soil.git"},
{'name': 'lapis', 'repo': "https://github.com/Napiophelios/LapisLazuli.git"},
{'name': 'biome_lib', 'repo': "https://github.com/mt-mods/biome_lib.git"},
{'name': 'moretrees', 'repo': "https://github.com/mt-mods/moretrees.git"},
{'name': 'mesecons', 'repo': "https://github.com/minetest-mods/mesecons"},
{'name': 'pipeworks', 'repo': "https://github.com/mt-mods/pipeworks.git"},
{'name': 'technic', 'repo': "https://github.com/minetest-mods/technic.git"},
{'name': 'technic_armor', 'repo': "https://github.com/stujones11/technic_armor.git"},
{'name': 'mapgen_helper', 'repo': "https://github.com/minetest-mods/mapgen_helper.git"},
{'name': 'subterrane', 'repo': "https://github.com/minetest-mods/subterrane.git"},
{'name': 'caverealms', 'repo': "https://github.com/FaceDeer/minetest-caverealms.git"},
{'name': 'moreores', 'repo': "https://github.com/minetest-mods/moreores.git"},
{'name': 'tsm_mines', 'repo': "http://repo.or.cz/tsm_mines.git"},
{'name': 'tsm_railcorridors','repo': "http://repo.or.cz/RailCorridors/tsm_railcorridors.git"},
{'name': 'birthstones', 'repo': "https://github.com/poikilos/birthstones.git"},
{'name': 'bakedclay', 'repo': "https://notabug.org/tenplus1/bakedclay.git"},
{'name': 'quartz', 'repo': "https://github.com/minetest-mods/quartz"},
{'name': 'magma_conduits', 'repo': "https://github.com/FaceDeer/magma_conduits.git"},
{'name': 'boost_cart', 'repo': "https://github.com/SmallJoker/boost_cart.git"},
# ── Gameplay / Items ───────────────────────────────────────
{'name': 'throwing', 'repo': "https://github.com/minetest-mods/throwing.git"},
{'name': 'throwing_arrows', 'repo': "https://github.com/minetest-mods/throwing_arrows.git"},
{'name': 'fishing', 'repo': "https://github.com/MinetestForFun/fishing.git",
'issues': ["Make sure fishing rods recipe works"]},
{'name': 'compassgps', 'repo': "https://github.com/poikilos/compassgps.git"},
{'name': 'sounding_line', 'repo': "https://github.com/minetest-mods/sounding_line.git"},
{'name': 'mywalls', 'repo': "https://github.com/minetest-mods/mywalls.git"},
{'name': 'mymasonhammer', 'repo': "https://github.com/minetest-mods/mymasonhammer.git"},
{'name': 'ts_furniture', 'repo': "https://github.com/minetest-mods/ts_furniture.git"},
{'name': '3d_armor', 'repo': "https://github.com/stujones11/minetest-3d_armor.git"},
{'name': 'basic_materials','repo': "https://github.com/mt-mods/basic_materials.git"},
{'name': 'homedecor_modpack','repo': "https://github.com/mt-mods/homedecor_modpack.git"},
{'name': 'homedecor_ua', 'repo': "https://github.com/poikilos/homedecor_ua.git"},
{'name': 'unifieddyes', 'repo': "https://github.com/mt-mods/unifieddyes.git"},
{'name': 'travelnet', 'repo': "https://github.com/Sokomine/travelnet.git"},
{'name': 'anvil', 'repo': "https://github.com/minetest-mods/anvil.git"},
{'name': 'sling', 'repo': "https://github.com/minetest-mods/sling.git"},
{'name': 'signs_lib', 'repo': "https://github.com/mt-mods/signs_lib.git"},
{'name': 'slimenodes', 'repo': "https://github.com/poikilos/slimenodes.git"},
{'name': 'ropes', 'repo': "https://github.com/minetest-mods/ropes.git"},
{'name': 'digilines', 'repo': "https://github.com/minetest-mods/digilines.git"},
{'name': 'trmp_minetest_game','repo': "https://github.com/poikilos/trmp_minetest_game.git"},
{'name': 'awards', 'repo': "https://gitlab.com/rubenwardy/awards.git"},
{'name': 'awards_board', 'repo': "https://framagit.org/xisd-minetest/awards_board.git"},
{'name': 'item_drop', 'repo': "https://github.com/minetest-mods/item_drop.git"},
{'name': 'sponge', 'repo': "https://github.com/BenjieFiftysix/sponge"},
# ── Player UX ──────────────────────────────────────────────
{'name': 'money', 'repo': "https://notabug.org/TenPlus1/money"},
{'name': 'lightning', 'repo': "https://github.com/minetest-mods/lightning.git"},
{'name': 'unified_inventory','repo': [
"https://github.com/minetest-mods/unified_inventory.git", # official
"https://github.com/MinetestForFun/unified_inventory" # old bash preference
]},
{'name': 'player_monoids', 'repo': "https://github.com/minetest-mods/player_monoids.git"},
{'name': 'sprint', 'repo': "https://github.com/GunshipPenguin/sprint.git"},
{'name': 'hunger_ng', 'repo': "https://gitlab.com/4w/hunger_ng.git"},
{'name': 'playereffects', 'repo': "https://github.com/sys4-fr/playereffects"},
{'name': 'ambience', 'repo': "https://notabug.org/tenplus1/ambience.git"},
{'name': 'playeranim', 'repo': "https://github.com/minetest-mods/playeranim.git"},
{'name': 'skinsdb', 'repo': "https://github.com/minetest-mods/skinsdb.git"},
{'name': 'woodcutting', 'repo': "https://github.com/minetest-mods/woodcutting.git"},
# ── Legacy / Special ───────────────────────────────────────
#{'name': 'animal_materials_legacy'},
#{'name': 'elk_legacy'},
#{'name': 'glooptest_missing'},
#{'name': 'nftools_legacy'},
]
'''
mt_conf_by_mod settings should be placed in minetest.conf such as
/opt/minebest/mtworlds/center/ENLIVEN/minetest.conf
but for now just use
- patches/subgame/minetest.conf
To define the game.
If minebest is present, combine minetest.conf and
minetest.server-example.conf
but maybe make an alternate version with stuff that isn't in
world.conf.
For other conf settings:
- patches/subgame/minetest.server-example.conf goes in the server only.
- Place the result in the game directory such as will result in
/opt/minebest/mtworlds/center/ENLIVEN/minetest.conf
- patches/subgame/minetest.client-example.conf goes in clients only.
'''
BASE_ENLIVEN_CONF_SETTINGS = [
# General / map
"enable_lapis_mod_columns = true",
"map_generation_limit = 5000",
# Protector
"protector_radius = 7",
"protector_flip = true",
"protector_pvp = true",
"protector_pvp_spawn = 10",
"protector_drop = false",
"protector_hurt = 3",
# Other gameplay
"world_edge = 5000",
"default_privs = interact,shout,home",
"max_users = 50",
"motd = \"Actions and chat messages are logged. Use inventory to see recipes (use web for live map if available).\"",
"disallow_empty_passwords = true",
"secure.trusted_mods = advanced_npc",
"server_dedicated = false",
"bones_position_message = true",
# Sprint (GunshipPenguin sprint settings)
"sprint_speed = 2.25",
"sprint_jump = 1.25",
"sprint_stamina_drain = .5",
]
# Per-mod overrides / extras
mt_conf_by_mod = {
'item_drop': {
'item_drop.pickup_radius': "1.425",
},
'throwing_arrows': {
'throwing.enable_arrow': "true",
},
}
why = {}
why["https://github.com/MinetestForFun/unified_inventory"] = '''
This fork makes a "nicer interface". The fork hasn't been tested yet.
'''
# deprecates https://github.com/poikilos/vines.git fork of Facedeer's:
why["https://github.com/FaceDeer/vines.git"] = '''
> I've finally done it, I've split this mod in twain. The new
> stand-alone ropes mod has no dependency on biome_lib and no vine
> content, though its crafting recipes remain compatible with the vines
> produced by this mod.
>
> My fork of this vines mod has had the rope-related content removed
> from it, leaving it as just a vines mod. Note that I haven't tested
> it extensively - I have to admit, I've mainly been in this for the
> ropes. :) I'll do what I can to maintain it, though, if anyone has
> bug reports or requests.
>
> I've added a node upgrade function to the new ropes mod that will
> convert the ropes from both my fork of the vines mod and the original
> version of the vines mod by bas080 to the new ropes mod's ropes. So
> if you wish to upgrade an existing world it should work.
- FaceDeer on [[Mod] Vines and Rope [2.3] [vines]]
(https://forums.minetest.org/viewtopic.php?f=11&t=2344&start=50
&sid=bf15c996963e891cd3f2460c2525044a)
Note that vines requires:
default
biome_lib
moretrees?
doc?
intllib?
mobs?
creatures?
'''
gamespec['disable_mobs'] = [
"old_lady",
]
"""
known_issues = {}
known_issues['fishing'] = "Make sure fishing rods recipe works"
server_only_mods = [
'ircpack',
'chat3', # debatable – was removed in some places
]
# ──────────────────────────────────────────────────────────────
class GameBuilder:
def __init__(self, minetest_game_path: str, minetest_version: str = "5"):
self.source_game = os.path.realpath(minetest_game_path)
self.target_parent = os.path.dirname(self.source_game)
self.target_game = os.path.join(self.target_parent, "ENLIVEN")
self.mods_target = os.path.join(self.target_game, "mods")
self.minetest_version = minetest_version # "5" or "0.4"
if minetest_version not in ("5", "0.4"):
raise ValueError("minetest_version must be '5' or '0.4'")
if not os.path.isdir(self.source_game):
raise FileNotFoundError(f"minetest_game not found: {self.source_game}")
echo0(f"Building ENLIVEN → {self.target_game}")
echo0(f" from base: {self.source_game}")
echo0(f" target Minetest compatibility: {self.minetest_version}")
def prepare_target(self):
"""Copy minetest_game → ENLIVEN if needed"""
if os.path.exists(self.target_game):
echo0(f"Target already exists: {self.target_game}")
echo0("→ delete it manually if you want fresh copy")
return
echo0("Copying minetest_game → ENLIVEN ...")
shutil.copytree(self.source_game, self.target_game)
def install_mod(self, entry: Dict[str, any]):
name = entry.get('name')
repo = entry.get('repo')
branch = entry.get('branch')
stopgap_only = entry.get('stopgap_only', False)
if not name:
echo0("Skipping entry without 'name'")
return
dest = os.path.join(self.mods_target, name)
# 1. Prefer stopgap if exists
stopgap_src = os.path.join(MODS_STOPGAP_DIR, name)
if os.path.isdir(stopgap_src):
echo0(f" [stopgap] {name}")
if os.path.exists(dest):
shutil.rmtree(dest)
shutil.copytree(stopgap_src, dest)
return
# 2. Git clone if we have repo URL(s)
if repo and not stopgap_only:
urls = [repo] if isinstance(repo, str) else repo
cloned = False
for url in urls:
echo0(f" trying → {url}")
# Here should be real git clone logic.
# For now just placeholder message.
# You can use subprocess.run(["git", "clone", ...])
echo0(f" [TODO git clone] {url}{dest}")
cloned = True # pretend success
break
if cloned:
return
echo0(f"→ WARNING: no source found for {name}")
def remove_mod(self, modname: str):
path = os.path.join(self.mods_target, modname)
if os.path.isdir(path):
echo0(f" removing {modname}")
shutil.rmtree(path)
def apply_remove_list(self):
for m in gamespec.get('remove_mods', []):
self.remove_mod(m)
def install_all_mods(self):
for entry in gamespec.get('add_mods', []):
self.install_mod(entry)
def write_game_conf(self):
path = os.path.join(self.target_game, "game.conf")
with open(path, "w", encoding="utf-8") as f:
f.write("name = ENLIVEN\n")
f.write("description = Enhanced minetest_game experience\n")
def update_conf(self, path: str):
"""Append settings only if not already present (line-based, stripped comparison)"""
os.makedirs(os.path.dirname(path), exist_ok=True)
# Collect base settings
desired_lines = list(BASE_ENLIVEN_CONF_SETTINGS)
# Add version-specific player animation setting
if self.minetest_version == "5":
desired_lines.append("playeranim.model_version = MTG_4_Nov_2017")
else: # "0.4"
desired_lines.append("playeranim.model_version = MTG_4_Jun_2017")
# Add per-mod settings
for modname, settings in mt_conf_by_mod.items():
desired_lines.append(f"# settings from {modname}")
for k, v in settings.items():
desired_lines.append(f"{k} = {v}")
desired_lines.append("")
if not desired_lines:
return
# Normalize for comparison (strip, remove empty)
desired_set = {line.strip() for line in desired_lines if line.strip()}
# Read existing content
existing_lines = []
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
existing_lines = [line.rstrip("\n") for line in f]
existing_set = {line.strip() for line in existing_lines if line.strip()}
else:
existing_set = set()
# Find lines to add
to_add = [line for line in desired_lines if line.strip() and line.strip() not in existing_set]
if not to_add:
echo0(f"minetest.conf already contains all desired ENLIVEN settings ({path})")
return
# Write mode
mode = "a" if os.path.isfile(path) else "w"
with open(path, mode, encoding="utf-8") as f:
if mode == "w":
f.write(f"# ENLIVEN subgame recommended settings (Minetest {self.minetest_version} compatibility)\n\n")
for line in to_add:
f.write(line + "\n")
echo0(f"Updated {path} — added {len(to_add)} new line(s)")
def build(self, conf_path: str = None):
self.prepare_target()
self.apply_remove_list()
self.install_all_mods()
self.write_game_conf()
if not conf_path:
conf_path = os.path.join(self.source_game, "minetest.conf.enliven")
self.update_conf(conf_path)
echo0("\nBuild finished.")
echo0(f"Game location: {self.target_game}")
echo0(f"Config : {conf_path}")
echo0("Next steps:")
echo0(" • review & edit the minetest.conf file")
echo0(" • test in Minetest")
def main():
parser = argparse.ArgumentParser(description="Build ENLIVEN subgame from minetest_game")
parser.add_argument("minetest_game_path", help="Path to minetest_game directory")
@ -418,20 +50,67 @@ def main():
parser.add_argument("--minetest-version", "-v", dest="minetest_version",
choices=["0.4", "5"], default="5",
help="Target Minetest version compatibility: '5' (default) or '0.4'")
parser.add_argument("--offline", "-o", action="store_true",
help="Do not pull in repos (fail if not cloned yet).")
parser.add_argument("--delete", "-d", action="store_true",
help="Erase target completely first")
parser.add_argument("--no-pull", "-n", action="store_true",
help="Skip pull command on existing repos")
args = parser.parse_args()
try:
builder = GameBuilder(
args.minetest_game_path,
minetest_version=args.minetest_version
)
builder.build(conf_path=args.conf_path)
return 0
except Exception as exc:
echo0(f"ERROR: {exc}", file=sys.stderr)
return 1
# try:
builder = GameBuilder(
args.minetest_game_path,
minetest_version=args.minetest_version,
offline=args.offline,
delete=args.delete,
pull=args.no_pull
)
builder.build(conf_path=args.conf_path)
return 0
# except Exception as exc:
# echo0(f"ERROR: {exc}")
# return 1
def detect_games(programs_dir, name="minetest_game"):
games = []
if not os.path.isdir(programs_dir):
print(f"No {repr(programs_dir)}")
return games
for engine in os.listdir(programs_dir):
engine_path = os.path.join(programs_dir, engine)
# such as "luanti"
for version in os.listdir(engine_path):
# such as "luanti-5.15.1"
version_path = os.path.join(engine_path, version)
games_path = os.path.join(version_path, "games")
game_path = os.path.join(games_path, name)
if os.path.isdir(game_path):
games.append(OrderedDict(
name=name,
path=game_path,
))
else:
print(f"No {repr(game_path)}")
return games
if __name__ == "__main__":
base_game_name = "minetest_game"
programs_dir = os.path.expanduser("~/.config/EnlivenMinetest/versions")
games = detect_games(programs_dir)
seqCount = 0
for argI in range(1, len(sys.argv)):
arg = sys.argv[argI]
if arg.startswith("-"):
continue
seqCount += 1
if seqCount < 1:
if len(games) < 1:
raise ValueError(f"No {base_game_name} in {programs_dir}/<<engine>-<version>>/games. Download it there first.")
if len(games) > 1:
print(f"Detected {games}")
print(f"No base game folder specified. Using {games[0]}")
sys.argv.insert(1, games[0]['path'])
sys.exit(main())

20
changelog.md

@ -4,10 +4,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a
Changelog](https://keepachangelog.com/en/1.0.0/).
## [git] - 2026-03-07
### Added
- loot (adds loot API and dungeon treasure)
- open_ai (adds open_ai mobs API and creatures)
- prestibags
- `update_conf` and `encode_cv` methods for editing conf files without adding duplicate settings
- sponge
### Changed
- Switch from Protector Redo to protect_block_area
### Removed
- treasurer (and trm_*, trmp_*, and tsm_* mods)
- mobs redo (and mobs_* mods)
## [git] - 2026-03-06
### Added
- buildenliven.py now has WIP version of most features and metadata (mod list) from install-ENLIVEN-minetest_game.sh
## [git] - 2024-01-06
### Removed
- Move mock_tnt to its own repo (https://github.com/Poikilos/mock_tnt).
- Usage of OldCoder code for ENLIVEN game.
## [git] - 2020-05-07

11
pyenliven/__init__.py

@ -20,7 +20,8 @@ max_verbosity = 2
def echo0(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
kwargs['file'] = sys.stderr
print(*args, **kwargs)
def echo1(*args, **kwargs):
@ -52,9 +53,10 @@ def set_verbosity(level):
"verbosity must be {} at maximum.".format(max_verbosity)
)
enable_mtanalyze = False
try:
import mtanalyze
enable_mtanalyze = True
except ModuleNotFoundError as ex:
# tryMTA = os.path.join(profile, "git", "mtanalyze")
moduleDir = os.path.dirname(os.path.realpath(__file__))
@ -68,16 +70,17 @@ except ModuleNotFoundError as ex:
import mtanalyze
# ^ import mtanalyze/mtanalyze purposely since the main
# mtanalyze/ directory is a setuptools package not a module.
enable_mtanalyze = True
else:
echo0("")
echo0("You must install mtanalyze alongside")
echo0("You can optionally install mtanalyze alongside")
echo0("EnlivenMinetest such that ../mtanalize/mtanalize exists")
echo0("such as via:")
echo0(" git clone https://github.com/poikilos/mtanalyze {}"
"".format(tryMTA))
echo0("")
# raise tryMTA
exit(1)
# exit(1)
# from mtanalyze import profile_path
MY_MODULE_DIR = os.path.dirname(os.path.realpath(__file__))

205
pyenliven/gamebuilder.py

@ -0,0 +1,205 @@
import shutil
import os
from collections import OrderedDict
from logging import getLogger
from typing import Dict
from git import Repo
from pyenliven import MODS_STOPGAP_DIR, echo0
from pyenliven.metadata import (
BASE_ENLIVEN_CONF_SETTINGS,
gamespec,
update_conf,
)
logger = getLogger(__name__)
class GameBuilder:
"""Manage creation of a game from scratch.
Attributes:
more_conf (OrderedDict): minetest.conf settings collected from
entry(ies) such as from install_mod calls.
"""
def __init__(self, minetest_game_path: str, minetest_version: str = "5",
offline: bool = False, delete: bool = False,
pull: bool = True):
self.source_game = os.path.realpath(minetest_game_path)
self.target_parent = os.path.dirname(self.source_game)
self.target_game = os.path.join(self.target_parent, "ENLIVEN")
self.pull = pull
if os.path.exists(self.target_game):
if delete:
print(f"rm -rf {repr(self.target_game)}")
shutil.rmtree(self.target_game)
self.mods_target = os.path.join(self.target_game, "mods")
self.minetest_version = minetest_version # "5" or "0.4"
self.offline = offline
self.more_conf = OrderedDict()
self.meta = OrderedDict()
self.meta['mods'] = OrderedDict()
if minetest_version not in ("5", "0.4"):
raise ValueError("minetest_version must be '5' or '0.4'")
if not os.path.isdir(self.source_game):
raise FileNotFoundError(f"minetest_game not found: {self.source_game}")
if os.path.realpath(self.source_game) == os.path.realpath(self.target_game):
raise ValueError("source game and target game are both"
f" {repr(os.path.realpath(self.source_game))}")
echo0(f"Building ENLIVEN → {self.target_game}")
echo0(f" from base: {self.source_game}")
echo0(f" target Minetest compatibility: {self.minetest_version}")
echo0(f" offline mode: {self.offline}")
def prepare_target(self):
"""Copy minetest_game → ENLIVEN if needed"""
if os.path.exists(self.target_game):
raise FileExistsError(f"Target already exists: {self.target_game}")
# return
echo0("Copying minetest_game → ENLIVEN ...")
shutil.copytree(self.source_game, self.target_game)
def install_mod(self, entry: Dict[str, any]):
name = entry.get('name')
repo = entry.get('repo')
branch = entry.get('branch')
stopgap_only = entry.get('stopgap_only', False)
settings = entry.get('settings')
if settings:
for k, v in settings.items():
self.more_conf[k] = v
if not name:
raise ValueError(f"Missing 'name' in {entry}")
if name in self.meta['mods']:
raise KeyError(f"Already installed a mod named {name}")
dest = os.path.join(self.mods_target, name)
# 1. Prefer stopgap if exists
stopgap_src = os.path.join(MODS_STOPGAP_DIR, name)
if os.path.isdir(stopgap_src):
echo0(f" [stopgap] {name}")
if os.path.exists(dest):
shutil.rmtree(dest)
shutil.copytree(stopgap_src, dest)
self.meta['mods'][name] = entry
return
# 2. Git clone if we have repo URL(s)
if not repo:
raise ValueError(f"Missing 'repo' for {entry}")
urls = [repo] if isinstance(repo, str) else repo
url = urls[-1] # prefer last one
del urls
distributor = entry.get('distributor')
if not distributor:
distributor = url.split("/")[-2]
repo_name = url.split("/")[-1].replace(".git", "")
git_local_path = os.path.expanduser(f"~/{repo_name}")
if os.path.isdir(git_local_path):
# Use the development copy on the computer
logger.warning(f"Using local git repo without update: {git_local_path}")
if os.path.exists(dest):
raise FileExistsError(f"Remove {dest} first.")
# shutil.rmtree(dest)
shutil.copytree(git_local_path, dest)
self.meta['mods'][name] = entry
return
downloads_path = os.path.expanduser(f"~/Downloads/git/{distributor}/{repo_name}")
if os.path.isdir(downloads_path):
if not self.offline:
if self.pull:
echo0(f" pulling {downloads_path}")
git_repo = Repo(downloads_path)
git_repo.remotes.origin.pull()
else:
echo0(f" using existing {downloads_path}")
if os.path.exists(dest):
raise FileExistsError(f"Remove {dest} first.")
# shutil.rmtree(dest)
shutil.copytree(downloads_path, dest)
self.meta['mods'][name] = entry
return
else:
if self.offline:
raise FileNotFoundError(f"Mod {name} not found in offline mode: {downloads_path}")
else:
echo0(f" cloning {url}{downloads_path}")
Repo.clone_from(url, downloads_path)
if os.path.exists(dest):
raise FileExistsError(f"Remove {dest} first.")
# shutil.rmtree(dest)
shutil.copytree(downloads_path, dest)
# dest_git = os.path.join(dest, ".git")
# if os.path.isdir(dest_git):
# shutil.rmtree(dest_git)
self.meta['mods'][name] = entry
return
def remove_mod(self, name: str):
path = os.path.join(self.mods_target, name)
if os.path.isdir(path):
echo0(f" removing {name}")
shutil.rmtree(path)
def apply_remove_list(self):
for m in gamespec.get('remove_mods', []):
self.remove_mod(m)
def install_all_mods(self):
for entry in gamespec.get('add_mods', []):
self.install_mod(entry)
def write_game_conf(self):
path = os.path.join(self.target_game, "game.conf")
with open(path, "w", encoding="utf-8") as f:
f.write("name = ENLIVEN\n")
f.write("description = For building immersive worlds using ramping, consistent style, and emphasizing world interaction over menus\n")
def update_conf(self, path: str):
"""Append settings only if not already present (line-based, stripped comparison)"""
os.makedirs(os.path.dirname(path), exist_ok=True)
# Collect base settings
new_settings = BASE_ENLIVEN_CONF_SETTINGS.copy()
new_settings.update(self.more_conf)
# Add version-specific player animation setting
if 'playeranim' in self.meta['mods']:
# TODO: Make version keys and values in gamespec
if self.minetest_version == "5":
new_settings['playeranim.model_version'] = "MTG_4_Nov_2017"
else: # "0.4"
new_settings['playeranim.model_version'] = "MTG_4_Jun_2017"
if not new_settings:
return
update_conf(path, new_settings)
# desired_set = {line.strip() for line in desired_lines if line.strip()}
def build(self, conf_path: str = None):
self.prepare_target()
self.apply_remove_list()
self.install_all_mods()
self.write_game_conf()
# Default conf path if not provided
if not conf_path:
conf_path = os.path.join(self.source_game, "minetest.conf.enliven")
self.update_conf(conf_path)
echo0("\nBuild finished.")
echo0(f"Game location: {self.target_game}")
echo0(f"Config : {conf_path}")
echo0("Next steps:")
echo0(" • review & edit the minetest.conf file")
echo0(" • test in Minetest")

354
pyenliven/metadata.py

@ -0,0 +1,354 @@
import shutil
import os
from collections import OrderedDict
from decimal import Decimal
from typing import Dict, List
gamespec: OrderedDict[str, any] = OrderedDict()
gamespec['remove_mods'] = [
"facade", # no recipes
"placecraft", # interferes with eating
"more_chests", # https://github.com/poikilos/EnlivenMinetest/issues/446
"emeralds", # https://github.com/poikilos/EnlivenMinetest/issues/497
"give_initial_stuff",
"xban2",
"dynamic_liquid",
# "stamina",
"hbarmor",
"hbhunger",
"hudbars",
"hbsprint",
# "dungeon_loot", # or treasurer + trm_*
"helicopter", # known crash issues in older versions
# add others from issue #310 if desired
]
gamespec['add_mods']: List[Dict[str, any]] = [
# ── Utility / Admin ────────────────────────────────────────
# ^ See also inventory_admin mod which is purely command-based
{'name': "advancedban", 'repo': "https://github.com/srifqi/advancedban"},
{'name': "areas", 'repo': "https://github.com/ShadowNinja/areas.git"},
{'name': "invhack", 'repo': "https://github.com/salahzar/minetest-invhack.git",
'privs': {'moderator': ['invhack']},
'items': {'moderator': ['invhack:tool']}},
{'name': "metatools", 'repo': "https://github.com/poikilos/metatools.git"},
{'name': "modlist", 'repo': "https://github.com/SkyBuilder1717/modlist.git"},
# {'name': "protector", 'repo': ["https://notabug.org/TenPlus1/protector.git",
# "https://codeberg.org/tenplus1/protector.git"]},
{'name': "protect_block_area", 'repo': "https://github.com/C-C-Minetest-Server/protect_block_area.git"},
{'name': "vote", 'repo': "https://github.com/minetest-mods/vote.git"},
{'name': "whitelist", 'repo': "https://github.com/ShadowNinja/whitelist.git"},
{'name': "worldedit", 'repo': "https://github.com/Uberi/MineTest-WorldEdit.git"},
# Mobs
#{'name': "mobs_redo", 'repo': ["https://notabug.org/TenPlus1/mobs_redo.git",
# "https://codeberg.org/tenplus1/mobs_redo.git"]},
#{'name': "mobs_monster", 'repo': ["https://notabug.org/TenPlus1/mobs_monster.git",
# "https://codeberg.org/tenplus1/mobs_monster.git"]},
#{'name': "mobs_animal", 'repo': ["https://notabug.org/TenPlus1/mobs_animal.git",
# "https://codeberg.org/tenplus1/mobs_animal.git"]},
#{'name': "mob_horse", 'repo': ["https://notabug.org/tenplus1/mob_horse.git",
# "https://codeberg.org/tenplus1/mob_horse.git"]},
#{'name': "mobs_sky", 'repo': "https://github.com/poikilos/mobs_sky.git"},
#{'name': "mobs_water", 'repo': "https://github.com/blert2112/mobs_water.git"},
#{'name': "dmobs", 'repo': ["https://github.com/minetest-mobs-mods/dmobs.git",
# "https://codeberg.org/tenplus1/dmobs"]},
# TODO: ^ Make open_ai mobs api bridge for these
{'name': "open_ai", 'repo': "https://github.com/Poikilos/open_ai.git"},
{'name': "spawners", 'repo': "https://bitbucket.org/minetest_gamers/spawners.git"},
# Worldgen
{'name': "bakedclay", 'repo': ["https://notabug.org/tenplus1/bakedclay.git",
"https://codeberg.org/tenplus1/bakedclay.git"]},
{'name': "biome_lib", 'repo': "https://github.com/mt-mods/biome_lib.git"},
# {'name': "birthstones", 'repo': "https://github.com/poikilos/birthstones.git"}, # commented to reduce inventory overload
{'name': "bushes_soil", 'repo': "https://github.com/poikilos/bushes_soil.git"},
{'name': "caverealms", 'repo': "https://github.com/FaceDeer/minetest-caverealms.git"},
{'name': "lapis", 'repo': "https://github.com/Napiophelios/LapisLazuli.git",
'settings': OrderedDict(enable_lapis_mod_columns=True),
'why': "Unlike, minetest-mods/lapis, LapisLazuli has more things you can make."},
{'name': "mapgen_helper", 'repo': "https://github.com/minetest-mods/mapgen_helper.git"},
{'name': "mesecons", 'repo': "https://github.com/minetest-mods/mesecons"},
{'name': "moreblocks", 'repo': "https://github.com/minetest-mods/moreblocks.git"},
{'name': "moreores", 'repo': "https://github.com/minetest-mods/moreores.git"},
{'name': "moretrees", 'repo': "https://github.com/mt-mods/moretrees.git"},
{'name': "plantlife_modpack", 'repo': "https://github.com/mt-mods/plantlife_modpack.git"},
{'name': "pipeworks", 'repo': "https://github.com/mt-mods/pipeworks.git"},
{'name': "subterrane", 'repo': "https://github.com/minetest-mods/subterrane.git"},
{'name': "technic", 'repo': "https://github.com/minetest-mods/technic.git"},
{'name': "technic_armor", 'repo': "https://github.com/stujones11/technic_armor.git"},
# {'name': "tsm_pyramids", 'repo': "https://github.com/poikilos/tsm_pyramids.git"},
# {'name': "tsm_chests_dungeon", 'repo': "http://repo.or.cz/minetest_tsm_chests_dungeon.git"}, # see "loot" mod instead
# {'name': "treasurer", 'repo': "http://repo.or.cz/minetest_treasurer.git"},
# {'name': "trm_pyramids"}, # special – files copied directly in bash → handle manually or stopgap
# {'name': "tsm_mines", 'repo': "http://repo.or.cz/tsm_mines.git"}, fork of BlockMen’s [Mines](https://forum.minetest.net/viewtopic.php?f=11&t=6307) replaced by tsm_railcorridors
# {'name': "tsm_railcorridors", 'repo': ["http://repo.or.cz/RailCorridors/tsm_railcorridors.git",
# "https://codeberg.org/Wuzzy/minetest_tsm_railcorridors.git"]},
# TODO: ^ Make loot version of these
{'name': "loot", 'repo': "https://github.com/minetest-mods/loot.git",
'why': "Defines loot API and adds loot to dungeons",
'settings': OrderedDict(loot_dungeons=True)},
# {'name': 'quartz', 'repo': "https://github.com/minetest-mods/quartz"}, # commented to reduce inventory overload
{'name': "magma_conduits", 'repo': "https://github.com/FaceDeer/magma_conduits.git"},
{'name': "worldedge", 'repo': "https://github.com/minetest-mods/worldedge.git"},
# ── Gameplay / Items ───────────────────────────────────────
{'name': "3d_armor", 'repo': "https://github.com/stujones11/minetest-3d_armor.git"},
{'name': "anvil", 'repo': "https://github.com/minetest-mods/anvil.git"},
{'name': "awards", 'repo': "https://gitlab.com/rubenwardy/awards.git"},
{'name': "awards_board", 'repo': "https://framagit.org/xisd-minetest/awards_board.git"},
{'name': "basic_materials",'repo': "https://github.com/mt-mods/basic_materials.git"},
{'name': "boost_cart", 'repo': "https://github.com/SmallJoker/boost_cart.git"},
{'name': "compassgps", 'repo': "https://github.com/poikilos/compassgps.git"},
{'name': "digilines", 'repo': "https://github.com/minetest-mods/digilines.git"},
{'name': "fishing", 'repo': "https://github.com/MinetestForFun/fishing.git",
'issues': ["Make sure fishing rods recipe works"],
'version-note': "Minetestforfun's (NOT wulfsdad's) fishing <https://forum.minetest.net/viewtopic.php?f=11&t=13659>"},
{'name': "homedecor_modpack", 'repo': "https://github.com/mt-mods/homedecor_modpack.git"},
{'name': "homedecor_ua", 'repo': "https://github.com/poikilos/homedecor_ua.git"},
{'name': "item_drop", 'repo': "https://github.com/minetest-mods/item_drop.git",
'settings': {'item_drop.pickup_radius': "1.425"}},
# {'name': 'mymasonhammer', 'repo': "https://github.com/minetest-mods/mymasonhammer.git",
# 'what': "A hammer that cuts stairs and ladders in blocks"},
{'name': "mywalls", 'repo': "https://github.com/minetest-mods/mywalls.git",
'what': "Adds more wall types for walls mod from minetest_game."},
{'name': "ropes", 'repo': "https://github.com/minetest-mods/ropes.git"},
{'name': "sling", 'repo': "https://github.com/minetest-mods/sling.git"},
{'name': "signs_lib", 'repo': "https://github.com/mt-mods/signs_lib.git"},
{'name': "slimenodes", 'repo': "https://github.com/poikilos/slimenodes.git"},
{'name': "sounding_line", 'repo': "https://github.com/minetest-mods/sounding_line.git"},
{'name': "stamina", 'repo': "https://codeberg.org/tenplus1/stamina.git",
'comment': "Patches item_eat to affect saturation instead of health.",
'help-dev': "Changes ItemStack before register_on_item_eat callbacks, but they can use 6th param for original ItemStack",
'privs': {'invincible':['no_hunger']}},
{'name': "sponge", 'repo': "https://github.com/BenjieFiftysix/sponge"},
# TODO: ^ Test, make sure is in mapgen. Add underwater/sealife mod & integrate
{'name': "throwing", 'repo': "https://github.com/minetest-mods/throwing.git"},
{'name': "throwing_arrows", 'repo': "https://github.com/minetest-mods/throwing_arrows.git",
'settings': {'throwing.enable_arrow': "true"}},
{'name': "travelnet", 'repo': "https://github.com/Sokomine/travelnet.git"},
{'name': "ts_furniture", 'repo': "https://github.com/minetest-mods/ts_furniture.git"},
# {'name': 'trmp_minetest_game','repo': "https://github.com/poikilos/trmp_minetest_game.git"},
# TODO: ^ Make a version for loot
# {'name': 'unifieddyes', 'repo': "https://github.com/mt-mods/unifieddyes.git"},
# TODO: ^ add Poikilos/dyed mod
# ── Player UX ──────────────────────────────────────────────
{'name': "ambience", 'repo': ["https://notabug.org/tenplus1/ambience.git",
"https://codeberg.org/tenplus1/ambience.git"]},
{'name': "hunger_ng", 'repo': "https://gitlab.com/4w/hunger_ng.git"},
{'name': "lightning", 'repo': "https://github.com/minetest-mods/lightning.git"},
# {'name': "money", 'repo': ["https://notabug.org/TenPlus1/money",
# "https://codeberg.org/tenplus1/money.git"],
# 'why-not': "This fork removes everything except barter stations"},
{'name': "player_monoids", 'repo': "https://github.com/minetest-mods/player_monoids.git"},
{'name': "playeranim", 'repo': "https://github.com/minetest-mods/playeranim.git",
'what': "Makes the head, and the right arm when you're mining, face the way you're facing"},
{'name': "playereffects", 'repo': "https://github.com/sys4-fr/playereffects"},
# {'name': 'skinsdb', 'repo': "https://github.com/minetest-mods/skinsdb.git"},
# {'name': "sprint", 'repo': "https://github.com/GunshipPenguin/sprint.git"},
# TODO: ^ Make sure stamina mod fully implements sprint
# {'name': 'unified_inventory','repo': [
# "https://github.com/minetest-mods/unified_inventory.git",
# "https://github.com/MinetestForFun/unified_inventory" # fork with "nicer interface"
# ]},
{'name': "sfinv", 'repo': "https://github.com/rubenwardy/sfinv.git",
'why': "Unified inventory is ugly and has a bloated API."},
# {'name': "bags", 'repo': "https://github.com/cornernote/minetest-bags.git"},
{'name': "prestibags", 'repo': "https://github.com/Poikilos/prestibags.git"},
{'name': "woodcutting", 'repo': "https://github.com/minetest-mods/woodcutting.git"},
# ── Legacy / Special ───────────────────────────────────────
# {'name': "animal_materials_legacy"},
# {'name': "elk_legacy"},
# {'name': "glooptest_missing"},
# {'name': "nftools_legacy"},
]
# Preprocess add_mods for repo.or.cz
for entry in gamespec['add_mods']:
repos = entry.get('repo')
if isinstance(repos, str):
repos = [repos]
for repo in repos:
if repo and "repo.or.cz" in repo:
entry['distributor'] = "Wuzzy"
break
# Per-mod overrides / extras
why = {}
why["https://github.com/MinetestForFun/unified_inventory"] = '''
This fork makes a "nicer interface". The fork hasn't been tested yet.
'''
# deprecates https://github.com/poikilos/vines.git fork of Facedeer's:
why["https://github.com/FaceDeer/vines.git"] = '''
> I've finally done it, I've split this mod in twain. The new
> stand-alone ropes mod has no dependency on biome_lib and no vine
> content, though its crafting recipes remain compatible with the vines
> produced by this mod.
>
> My fork of this vines mod has had the rope-related content removed
> from it, leaving it as just a vines mod. Note that I haven't tested
> it extensively - I have to admit, I've mainly been in this for the
> ropes. :) I'll do what I can to maintain it, though, if anyone has
> bug reports or requests.
>
> I've added a node upgrade function to the new ropes mod that will
> convert the ropes from both my fork of the vines mod and the original
> version of the vines mod by bas080 to the new ropes mod's ropes. So
> if you wish to upgrade an existing world it should work.
- FaceDeer on [[Mod] Vines and Rope [2.3] [vines]]
(https://forums.minetest.org/viewtopic.php?f=11&t=2344&start=50
&sid=bf15c996963e891cd3f2460c2525044a)
Note that vines requires:
default
biome_lib
moretrees?
doc?
intllib?
mobs?
creatures?
'''
gamespec['disable_mobs'] = [
"old_lady",
]
server_only_mods = [
'ircpack',
'chat3',
]
# ──────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────
# Collected minetest.conf settings from the bash script
# ──────────────────────────────────────────────────────────────
BASE_ENLIVEN_CONF_SETTINGS = OrderedDict(
# General / map
map_generation_limit=5000,
# HUD / Controls
# "stamina_disable_aux1 = true", # require double tap for run (Prevent stamina from taking up aux1 key)
# "stamina_hud_x =",
# "stamina_hud_y =",
# "stamina_double_tap_time =", # 0 to disable
# Protector
protector_radius=7,
protector_flip=True,
protector_pvp=True,
protector_pvp_spawn=10,
protector_drop=False,
protector_hurt=3,
# Other gameplay
world_edge=5000,
default_privs="interact,shout,home",
max_users=50,
motd="Actions and chat messages are logged. Use inventory to see recipes (use web for live map if available).",
disallow_empty_passwords=True,
server_dedicated=False,
bones_position_message=True,
# Sprint (GunshipPenguin sprint settings)
sprint_speed=2.25,
sprint_jump=1.25,
sprint_stamina_drain=.5,
)
BASE_ENLIVEN_CONF_SETTINGS['secure.trusted_mods'] = "advanced_npc"
def encode_cv(v):
"""Encode the value to minetest.conf syntax"""
if v is None:
return "" # results in "name =" which is valid syntax
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, (int, float, Decimal)):
return str(v)
if isinstance(v, str):
return f'"{v}"'
raise TypeError(
"{} type is not implemented in pyenliven Luanti conf encoder"
.format(type(v).__name__))
def update_conf(path, new_settings):
# Read existing content
existing_lines = []
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
existing_lines = [line.rstrip("\n") for line in f]
existing_set = {line.strip() for line in existing_lines if line.strip()}
else:
existing_set = set()
tmp_path = path + ".tmp"
if os.path.isfile(tmp_path):
os.remove(tmp_path)
changed = 0
added = 0
same = 0
if os.path.isfile(path):
lineN = 0
with open(path, 'r', encoding="utf-8") as src:
with open(tmp_path, 'w', encoding="utf-8") as dst:
for rawL in src:
lineN += 1
line = rawL.strip()
if line.startswith("#"):
dst.write(rawL)
continue
if not line:
dst.write(rawL)
continue
if "=" not in line:
logger.warning(
f"{path}, line {lineN}:"
f" No '=' in {rawL.rstrip()}")
dst.write(rawL)
continue
parts = line.split(line, 1)
parts[0] = parts[0].strip()
if not parts[0]:
logger.warning(
f"{path}, line {lineN}:"
f" No name before '=' in {rawL.rstrip()}")
dst.write(rawL)
continue
if len(parts) > 1:
parts[1] = parts[1].strip()
else:
# f"{name} =" is null value syntax for conf
parts[1] = None
if parts[0] in new_settings:
encoded = encode_cv(new_settings[parts[0]])
if parts[1] == encoded:
# no change
same += 1
dst.write(rawL)
continue
changed += 1
if new_settings[parts[0]] is not None:
dst.write(f"{parts[0]} = {encoded}\n")
else:
dst.write(f"{parts[0]} =\n")
del new_settings[parts[0]]
if new_settings:
mode = "a" if os.path.isfile(tmp_path) else "w"
with open(tmp_path, mode, encoding="utf-8") as dst:
for k, v in new_settings.items():
if v is not None:
dst.write(f"{k} = {encode_cv(v)}\n")
else:
dst.write(f"{k} =\n") # "{k} = " is null value syntax
if os.path.isfile(path):
os.remove(path)
shutil.move(tmp_path, path)
echo0(f"Updated {path}: added {added} new line(s), changed {changed}, {same} value(s) already matched")
Loading…
Cancel
Save