From 0463da19ed0c53f6f477889afaf026523f113398 Mon Sep 17 00:00:00 2001 From: kayos Date: Fri, 6 Mar 2026 01:07:36 -0800 Subject: [PATCH] fulfillment: add RCON order fulfillment daemon (starter-kit, reactor-kit) --- fulfillment.py | 290 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 fulfillment.py diff --git a/fulfillment.py b/fulfillment.py new file mode 100644 index 0000000..d5a6719 --- /dev/null +++ b/fulfillment.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +fulfillment.py — Sulkta shop order fulfillment daemon + +Runs alongside the MC server inside the Docker container. +Polls the shop API for confirmed orders, delivers items via RCON, +marks orders fulfilled. Retries each poll cycle if the player is offline. +""" + +import os, socket, struct, time, json, logging, signal, sys +import urllib.request, urllib.parse, urllib.error + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [fulfillment] %(levelname)s %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + stream=sys.stdout, +) +log = logging.getLogger(__name__) + +SHOP_API_URL = os.environ.get('SHOP_API_URL', '').rstrip('/') +SHOP_API_KEY = os.environ.get('SHOP_API_KEY', '') +RCON_HOST = os.environ.get('RCON_HOST', '127.0.0.1') +RCON_PORT = int(os.environ.get('RCON_PORT', '25575')) +RCON_PASSWORD = os.environ.get('RCON_PASSWORD', '') +POLL_INTERVAL = int(os.environ.get('FULFILLMENT_POLL_INTERVAL', '30')) +STARTUP_DELAY = int(os.environ.get('FULFILLMENT_STARTUP_DELAY', '90')) + +# ── Item delivery definitions ───────────────────────────────────────────────── +# {player} is replaced with the Minecraft username at delivery time. +ITEM_COMMANDS = { + 'starter-kit': [ + '/give {player} minecraft:diamond_pickaxe 1', + '/give {player} minecraft:diamond_axe 1', + '/give {player} minecraft:diamond_shovel 1', + '/give {player} minecraft:diamond_sword 1', + '/give {player} minecraft:diamond_helmet 1', + '/give {player} minecraft:diamond_chestplate 1', + '/give {player} minecraft:diamond_leggings 1', + '/give {player} minecraft:diamond_boots 1', + '/give {player} minecraft:cooked_beef 64', + '/give {player} minecraft:iron_ingot 64', + '/give {player} minecraft:gold_ingot 32', + '/give {player} minecraft:diamond 16', + '/give {player} minecraft:torch 64', + ], + 'reactor-kit': [ + # Shell — 32 casings covers a 3×3×4 exterior comfortably + '/give {player} mekanism:fission_reactor_casing 32', + # Optional glass windows + '/give {player} mekanism:fission_reactor_glass 8', + # Ports: 2× coolant in/out + 1× heated coolant out + 1× waste drain + '/give {player} mekanism:fission_reactor_port 4', + # Logic adapter to arm/disarm the reactor + '/give {player} mekanism:fission_reactor_logic_adapter 1', + # Fuel assembly column + control rods + '/give {player} mekanism:fission_fuel_assembly 4', + '/give {player} mekanism:control_rod_assembly 4', + # Uranium — raw ore to process into fissile fuel + '/give {player} mekanism:yellow_cake_uranium 64', + ], +} + +# ── Minimal RCON client ─────────────────────────────────────────────────────── + +class RCONError(Exception): + pass + +class RCON: + AUTH = 3 + AUTH_RESPONSE = 2 + EXEC = 2 + RESPONSE = 0 + + def __init__(self, host, port, password, timeout=10): + self.host = host + self.port = port + self.password = password + self.timeout = timeout + self._sock = None + self._req_id = 0 + + def connect(self): + self._sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + self._sock.settimeout(self.timeout) + self._authenticate() + + def close(self): + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *_): + self.close() + + def _send(self, type_, payload): + self._req_id += 1 + body = payload.encode('utf-8') + b'\x00\x00' + pkt = struct.pack(' 0: + log.info('Waiting %ds for MC server to start…', STARTUP_DELAY) + time.sleep(STARTUP_DELAY) + + def shutdown(sig, frame): + log.info('Shutting down (signal %d)', sig) + sys.exit(0) + + signal.signal(signal.SIGTERM, shutdown) + signal.signal(signal.SIGINT, shutdown) + + while True: + try: + process_orders() + except Exception as e: + log.error('Unhandled error in poll cycle: %s', e, exc_info=True) + time.sleep(POLL_INTERVAL) + + +if __name__ == '__main__': + main()