#!/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()