"""Slot game workflow helpers. This module turns a small set of form choices into the manifest format that the existing generation pipeline already understands. It is deterministic on purpose: the text-model step can be added later, but the UI should already have a stable "define game -> manifest -> generate" path. """ import copy import re import slot_math DEFAULT_STYLE_BY_THEME = { "jelly": "cute 3D-rendered translucent jelly creatures, glossy gummy texture, soft subsurface highlights, warm candy-land color palette, mobile slot game asset, centered, clean edges", "fruit": "bright juicy fruit slot game, glossy 3D fruit icons, fresh arcade colors, mobile game asset, centered, clean edges", "egypt": "golden ancient egypt treasure slot game, polished gems, warm sandstone and turquoise palette, premium mobile game asset, centered, clean edges", "pirate": "playful pirate treasure slot game, glossy gold coins, tropical ocean colors, stylized 3D mobile game asset, centered, clean edges", "cyber": "neon cyber arcade slot game, glowing glass panels, electric blue and magenta palette, premium mobile game asset, centered, clean edges", } THEME_LABELS = { "jelly": "果冻糖果", "fruit": "水果乐园", "egypt": "埃及宝藏", "pirate": "海盗金币", "cyber": "霓虹赛博", } VOLATILITY_RULES = { "low": { "hitFrequencyFeel": "high", "minMatch": 3, "payMultiplier": 0.82, "bonusChanceLabel": "frequent_small", "cascadeMultiplierStep": 1, "maxCascadeMultiplier": 4, }, "medium": { "hitFrequencyFeel": "medium", "minMatch": 4, "payMultiplier": 1.0, "bonusChanceLabel": "balanced", "cascadeMultiplierStep": 1, "maxCascadeMultiplier": 6, }, "high": { "hitFrequencyFeel": "low", "minMatch": 5, "payMultiplier": 1.35, "bonusChanceLabel": "rare_large", "cascadeMultiplierStep": 2, "maxCascadeMultiplier": 10, }, } FEEDBACK_RULES = { "quiet": { "spinDurationSec": 0.32, "dropTimeSec": 0.24, "clearPopScale": 1.18, "screenShake": False, "bigWinThresholdBet": 18, "vfxDensity": "light", }, "standard": { "spinDurationSec": 0.45, "dropTimeSec": 0.30, "clearPopScale": 1.30, "screenShake": False, "bigWinThresholdBet": 25, "vfxDensity": "medium", }, "loud": { "spinDurationSec": 0.58, "dropTimeSec": 0.36, "clearPopScale": 1.42, "screenShake": True, "bigWinThresholdBet": 35, "vfxDensity": "heavy", }, } BASE_SYMBOLS = { "jelly": [ ("jelly_blue", "a round blue blueberry jelly creature mascot with big friendly eyes and a happy open smile, tiny arms raised"), ("jelly_pink", "a pink strawberry jelly creature with sparkling eyes and a small leaf on top, cheerful pose"), ("jelly_green", "a green apple jelly creature with rosy cheeks and a cute grin"), ("jelly_orange", "an orange citrus jelly creature with bright eyes and little stubby hands"), ("jelly_purple", "a purple grape jelly creature, slightly wobbly, shy smile, sparkles"), ("jelly_lemon", "a yellow lemon jelly creature with a wide excited smile and star-shaped eyes"), ("jelly_choco", "a glossy chocolate jelly creature with caramel swirls and warm happy eyes"), ("jelly_rainbow", "a rainbow gradient jelly creature, extra glossy and translucent, joyful expression, premium special character"), ("symbol_coin", "a shiny golden coin symbol with a smiling jelly face embossed, glossy game icon"), ("symbol_seven", "a glossy candy-styled lucky number seven symbol, red and gold, jelly texture, game slot icon"), ], "fruit": [ ("symbol_cherry", "a glossy cherry pair slot symbol with cute eyes, premium 3D mobile game icon"), ("symbol_lemon", "a bright lemon slice slot symbol, glossy and juicy, cute arcade style"), ("symbol_grape", "a purple grape cluster slot symbol, shiny 3D fruit icon"), ("symbol_watermelon", "a watermelon wedge slot symbol with juicy shine and clean edges"), ("symbol_orange", "an orange citrus slot symbol, glossy mobile game icon"), ("symbol_bell", "a golden bell slot symbol with fruit stickers and soft glow"), ("symbol_coin", "a shiny golden coin fruit slot symbol"), ("symbol_seven", "a red lucky seven fruit slot symbol with gold trim"), ], } UI_ART = [ ("bg_main", False, "1024x1536", "vertical mobile slot game background, clear top area for logo, central reel area, bright themed world, no characters, no text"), ("logo", True, "1024x1024", "glossy mobile slot game logo lettering, playful premium game title, thick outline, sparkles, transparent background"), ("reel_frame", True, "1024x1024", "rounded rectangle slot machine reel frame, glowing border, hollow transparent center, clean mobile game UI element"), ("btn_spin", True, "1024x1024", "large round glossy spin button with circular arrows icon, premium 3D mobile game button, transparent background"), ("btn_round", True, "1024x1024", "small round glossy secondary mobile game UI button, blank center, transparent background"), ("hud_pill", True, "1024x1024", "horizontal rounded pill shaped mobile game HUD panel, glossy dark translucent material, blank, transparent background"), ("win_popup", True, "1024x1024", "big win popup frame, glossy gold and candy highlights, blank center, transparent background"), ("free_spin_badge", True, "1024x1024", "free spins bonus badge, glossy premium mobile slot UI, blank center, transparent background"), ] def slugify(value): value = (value or "slot-game").strip().lower() value = re.sub(r"[^a-z0-9]+", "-", value).strip("-") return value or "slot-game" def clamp_int(value, default, lo, hi): try: value = int(value) except (TypeError, ValueError): value = default return max(lo, min(hi, value)) def build_symbol_rules(symbols): count = max(1, len(symbols)) rules = [] for index, (sid, _prompt) in enumerate(symbols): if sid == "wild" or sid.startswith("wild"): role = "wild" tier = "special" weight = 3 elif sid == "scatter" or "scatter" in sid: role = "scatter" tier = "special" weight = 2 elif sid in ("coin_cash", "collect") or "coin" in sid: role = "bonus" tier = "bonus" weight = 3 else: role = "regular" pct = index / count if pct < 0.2: tier = "premium" elif pct < 0.55: tier = "mid" else: tier = "low" weight = max(6, 22 - index) rules.append({"id": sid, "role": role, "tier": tier, "weight": weight}) return rules def build_paytable(symbol_rules, volatility): vol = VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"]) mult = vol["payMultiplier"] tier_base = {"premium": 10, "mid": 6, "low": 3, "bonus": 2, "special": 0} paytable = {} for item in symbol_rules: base = tier_base.get(item["tier"], 3) if item["role"] == "wild": paytable[item["id"]] = {"3": 0, "4": 0, "5": 0, "6": 0, "note": "substitutes_regular_symbols"} elif item["role"] == "scatter": paytable[item["id"]] = {"3": 4, "4": 12, "5": 40, "6": 100, "note": "pays_anywhere_and_triggers_free_spins"} else: paytable[item["id"]] = { "3": round(base * mult, 2), "4": round(base * 2.4 * mult, 2), "5": round(base * 6.0 * mult, 2), "6": round(base * 14.0 * mult, 2), } return paytable def clamp_float(value, default, lo, hi): try: value = float(value) except (TypeError, ValueError): value = default if value > 1: value = value / 100.0 return max(lo, min(hi, value)) def build_slot_config(data): theme = data.get("theme", "jelly") reel_mode = data.get("reelMode", "ways") volatility = data.get("volatility", "medium") feature_names = set(data.get("features") or ["cascades", "free_spins", "wilds"]) columns = 6 if reel_mode == "megaways" else 5 rows = 5 if reel_mode == "cluster" else 3 return { "schemaVersion": "slot_game_config.v1", "creative": data.get("creative", {}), "gameDesign": data.get("gameDesign", {}), "game": { "id": slugify(data.get("gameId") or data.get("title") or "jelly-candy-slot"), "title": data.get("title") or "Jelly Candy Slot", "mode": "demo_only", "engine": "cocos_creator_3_8", "orientation": "portrait", "targetViewport": {"width": 798, "height": 1724}, }, "theme": { "key": theme, "world": THEME_LABELS.get(theme, theme), "visualStyle": data.get("style") or DEFAULT_STYLE_BY_THEME.get(theme, DEFAULT_STYLE_BY_THEME["jelly"]), }, "reels": { "mode": reel_mode, "columns": columns, "rows": rows, }, "layout": { "viewport": {"width": 798, "height": 1724, "orientation": "portrait"}, "logo": {"cy": 0.41, "maxW": 0.82, "maxH": 0.15}, "reel": { "cy": 0.02, "h": 0.60, "aspect": 0.652, "holeW": 0.86, "holeH": 0.92, "cols": columns, "rows": rows, }, "hud": {"cy": -0.33, "pillH": 0.40}, "controls": {"cy": -0.405, "spinR": 0.13, "smallR": 0.075, "xMinus": 0.24, "xTurbo": 0.40}, "symbols": { "fitMode": "contain", "targetCellFill": 0.92, "defaultScalePerCell": 0.00093, "defaultOriginYOffsetPerCell": 0.5, }, }, "mathProfile": { "volatility": volatility, "hitFrequencyFeel": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["hitFrequencyFeel"], "rtpTarget": clamp_float(data.get("targetRtp"), 0.96, 0.5, 0.995), "rtpTargetLabel": "math_model_candidate" if data.get("enableMathModel", True) else "configurable_demo_no_math_model", "enableMathModel": bool(data.get("enableMathModel", True)), "bonusChanceLabel": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["bonusChanceLabel"], }, "economy": { "startingBalance": clamp_int(data.get("startingBalance"), 5000, 100, 1000000), "defaultBet": clamp_int(data.get("defaultBet"), 50, 1, 100000), "minBet": 10, "maxBet": 1000, "betSteps": [10, 20, 50, 100, 200, 500, 1000], }, "winRules": { "evaluation": "cluster_count" if reel_mode == "cluster" else ("left_to_right_paylines" if reel_mode == "paylines" else "adjacent_ways_demo"), "minMatch": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["minMatch"], "paylines": 20 if reel_mode == "paylines" else 0, "ways": columns ** rows if reel_mode in ("ways", "megaways") else 0, }, "features": { "wilds": {"enabled": "wilds" in feature_names, "variant": data.get("wildVariant", "expanding"), "substitutes": "regular"}, "scatterFreeSpins": { "enabled": "free_spins" in feature_names, "triggerCount": 3, "awardSpins": clamp_int(data.get("freeSpinCount"), 8, 3, 30), "retriggers": True, }, "cascades": { "enabled": "cascades" in feature_names, "maxCascades": clamp_int(data.get("maxCascades"), 6, 1, 20), "multiplierStep": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["cascadeMultiplierStep"], "maxMultiplier": VOLATILITY_RULES.get(volatility, VOLATILITY_RULES["medium"])["maxCascadeMultiplier"], }, "holdAndWin": {"enabled": "hold_win" in feature_names, "triggerCount": 6, "respins": 3, "jackpots": ["mini", "minor", "major"]}, "multipliers": {"enabled": "multipliers" in feature_names}, }, "feedback": FEEDBACK_RULES.get(data.get("feedbackIntensity", "standard"), FEEDBACK_RULES["standard"]), "assetGeneration": { "characterCount": int(data.get("characterCount") or 10), "uiCompleteness": data.get("uiCompleteness", "full"), "feedbackIntensity": data.get("feedbackIntensity", "standard"), }, } def build_manifest(slot_config): slot_config = copy.deepcopy(slot_config) theme = slot_config["theme"]["key"] style = slot_config["theme"]["visualStyle"] count = slot_config["assetGeneration"]["characterCount"] base_symbols = copy.deepcopy(BASE_SYMBOLS.get(theme, BASE_SYMBOLS["jelly"])) special_symbols = [] if slot_config["features"]["wilds"]["enabled"]: special_symbols.append(("wild", "a glossy rainbow WILD slot symbol icon, premium mobile game asset, no small text")) if slot_config["features"]["scatterFreeSpins"]["enabled"]: special_symbols.append(("scatter", "a glowing scatter bonus candy star symbol, premium mobile slot game icon, no small text")) if slot_config["features"]["holdAndWin"]["enabled"]: special_symbols.extend([ ("coin_cash", "a shiny golden cash coin slot symbol with soft glow, premium mobile game icon"), ("collect", "a glossy collect symbol with magnet-like gold energy, premium mobile slot game icon"), ]) regular_count = max(1, count - len(special_symbols)) symbols = base_symbols[:regular_count] + special_symbols symbol_rules = build_symbol_rules(symbols) slot_config["symbols"] = symbol_rules slot_config["paytable"] = build_paytable(symbol_rules, slot_config["mathProfile"]["volatility"]) if slot_config.get("mathProfile", {}).get("enableMathModel", True): slot_config["mathModel"] = slot_math.build_math_model(slot_config) else: slot_config["mathModel"] = { "status": "disabled_by_user", "notes": ["Math model generation was disabled for this configurable demo."], } vfx = [ {"id": "win_burst", "type": "particle", "template": "burst", "color": [255, 180, 80]}, ] if slot_config["features"]["cascades"]["enabled"]: vfx.append({"id": "confetti_pop", "type": "particle", "template": "confetti", "color": [255, 120, 200]}) if slot_config["features"]["scatterFreeSpins"]["enabled"] or slot_config["features"]["holdAndWin"]["enabled"]: vfx.append({"id": "bigwin_glow", "type": "particle", "template": "glow", "color": [255, 240, 160]}) if slot_config["features"]["holdAndWin"]["enabled"] or slot_config["assetGeneration"]["feedbackIntensity"] == "loud": vfx.append({"id": "coin_rain", "type": "particle", "template": "rain", "color": [255, 215, 0]}) ui = [ {"id": "spin_btn_press", "type": "tween", "preset": "scale_bounce"}, {"id": "reward_popup_in", "type": "tween", "preset": "elastic_in"}, {"id": "panel_slide_in", "type": "tween", "preset": "fade_slide_in", "params": {"dy": 60}}, {"id": "balance_roll", "type": "tween", "preset": "number_roll", "params": {"from": 0, "to": 8888, "dur": 0.9}}, {"id": "win_icon_pulse", "type": "tween", "preset": "pulse"}, ] ui_art_ids = {"basic": 6, "full": len(UI_ART)} ui_art_count = ui_art_ids.get(slot_config["assetGeneration"]["uiCompleteness"], len(UI_ART)) return { "game": slot_config["game"]["id"], "style": style, "slot_config": slot_config, "characters": [ {"id": sid, "type": "spine", "animations": ["idle", "win"], "prompt": prompt} for sid, prompt in symbols ], "ui_art": [ {"id": aid, "transparent": transparent, "size": size, "prompt": prompt} for aid, transparent, size, prompt in UI_ART[:ui_art_count] ], "vfx": vfx, "ui": ui, } def build_workflow(data): slot_config = build_slot_config(data or {}) manifest = build_manifest(slot_config) return {"slot_config": manifest["slot_config"], "manifest": manifest}