diff --git a/EnlivenMinetest.code-workspace b/EnlivenMinetest.code-workspace index 1dd405a..d3979b3 100644 --- a/EnlivenMinetest.code-workspace +++ b/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" ] } } \ No newline at end of file diff --git a/buildenliven.py b/buildenliven.py index ea23726..15dbc57 100755 --- a/buildenliven.py +++ b/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}/<->/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()) diff --git a/changelog.md b/changelog.md index 6e821ba..d824b1a 100644 --- a/changelog.md +++ b/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 diff --git a/pyenliven/__init__.py b/pyenliven/__init__.py index 7bdf9aa..0e9d332 100644 --- a/pyenliven/__init__.py +++ b/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__)) diff --git a/pyenliven/gamebuilder.py b/pyenliven/gamebuilder.py new file mode 100644 index 0000000..4f99f1f --- /dev/null +++ b/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") diff --git a/pyenliven/metadata.py b/pyenliven/metadata.py new file mode 100644 index 0000000..541c4e9 --- /dev/null +++ b/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 "}, + {'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")