11# -*- coding: utf-8 -*-
22# plugins/prof_oak_mode.py
33#
4- # Adds a new bot mode "Prof Oak" that wraps the existing Level Grind mode.
5- # It finds Level Grind dynamically from the bot's mode registry, so it works
6- # across forks/paths. ShinyQuota keeps handling quota/pausing.
4+ # Prof Oak mode — wraps a configurable base bot mode (default Level Grind).
5+ # Configure inside this file, optionally prompt once, and persist to plugins/ProfOak/config.json.
6+ #
7+ # Quick config:
8+ # PLUGIN_DEFAULT_BASES = ["LevelGrind"] # or ["Spin", "LevelGrind"] etc.
9+ # ASK_ON_FIRST_USE = False # True => one-time console prompt
10+ #
11+ # Optional env override:
12+ # PROFOAK_BASE="Spin" # or "Spin,LevelGrind"
13+ #
14+ # Adding more bases later:
15+ # 1) Put the user-facing name in PLUGIN_DEFAULT_BASES or via env.
16+ # 2) If needed, add an import mapping in _try_import_candidates().
717
8- from typing import Iterable , Generator , TYPE_CHECKING
18+ from __future__ import annotations
19+ from typing import Iterable , Generator , TYPE_CHECKING , Optional , Sequence , Dict , Any
20+ import json
21+ import os
22+ import sys
23+ from pathlib import Path
924
1025from modules .plugin_interface import BotPlugin
1126from modules .context import context
27+ from modules .runtime import get_base_path
1228
1329if TYPE_CHECKING :
1430 from modules .modes import BotMode # typing only
1531
16- # -------- logging helpers --------
32+ # ======================== Inline config ========================
33+ # Try these base modes in order if nothing else is set.
34+ # Valid examples: "Level Grind", "Spin"
35+ PLUGIN_DEFAULT_BASES : list [str ] = ["Spin" ] # e.g., ["Spin", "Level Grind"]
36+
37+ # Ask once on first use (console prompt). The choice is saved to plugins/ProfOak/config.json
38+ ASK_ON_FIRST_USE : bool = False
39+
40+ # ===============================================================
41+
42+ # ---- paths (persist choice here so you don't get asked again) ----
43+ PROFOAK_DIR = get_base_path () / "plugins" / "ProfOak"
44+ PROFOAK_DIR .mkdir (parents = True , exist_ok = True )
45+ CONFIG_PATH = PROFOAK_DIR / "config.json"
46+
47+ # ---------------- Tiny logging helpers ----------------
1748def _log_info (msg : str ) -> None :
1849 for attr in ("logger" , "log" ):
1950 lg = getattr (context , attr , None )
@@ -39,72 +70,216 @@ def _log_warn(msg: str) -> None:
3970 pass
4071 print (f"WARNING: { msg } " )
4172
42- # -------- resolve Level Grind base class --------
43- def _find_level_grind_base ():
44- # 1) Known import paths on some forks
73+ # ---------------- Convenience ----------------
74+ def _norm_name (s : str ) -> str :
75+ return s .strip ().lower ().replace (" " , "" ).replace ("_" , "-" )
76+
77+ def _parse_bases (value : object ) -> list [str ]:
78+ """Accept str ('Spin,LevelGrind') or list/tuple; return cleaned list."""
79+ names : list [str ] = []
80+ if isinstance (value , str ):
81+ parts = [p .strip () for p in value .split ("," ) if p .strip ()]
82+ names = parts or [value .strip ()]
83+ elif isinstance (value , (list , tuple )):
84+ for v in value :
85+ if isinstance (v , str ) and v .strip ():
86+ names .append (v .strip ())
87+ # de-dup while preserving order
88+ seen = set (); out = []
89+ for n in names :
90+ k = _norm_name (n )
91+ if k and k not in seen :
92+ seen .add (k ); out .append (n )
93+ return out
94+
95+ def _load_saved_choice () -> list [str ] | None :
4596 try :
46- from modules .built_in_modes .level_grind import LevelGrind as _Base # type: ignore
47- return _Base
97+ if CONFIG_PATH .exists ():
98+ data = json .loads (CONFIG_PATH .read_text (encoding = "utf-8" ))
99+ if isinstance (data , dict ):
100+ if "base_modes" in data :
101+ return _parse_bases (data ["base_modes" ])
102+ if "base_mode" in data :
103+ return _parse_bases (data ["base_mode" ])
48104 except Exception :
49105 pass
106+ return None
107+
108+ def _save_choice (bases : list [str ]) -> None :
50109 try :
51- from modules .modes .level_grind import LevelGrind as _Base # type: ignore
52- return _Base
110+ payload = {"base_modes" : bases }
111+ CONFIG_PATH .write_text (json .dumps (payload , indent = 2 , sort_keys = True ), encoding = "utf-8" )
112+ _log_info (f"[ProfOak] Saved base selection to { CONFIG_PATH .name } : { bases } " )
113+ except Exception as e :
114+ _log_warn (f"[ProfOak] Could not save base selection: { e } " )
115+
116+ # ---------------- Discovery helpers ----------------
117+ def _try_import_candidates (canon : str ):
118+ """
119+ Try typical import paths for a given canonical name, return class or None.
120+ canon examples: 'levelgrind', 'spin'
121+ """
122+ name_map = {
123+ "levelgrind" : ("level_grind" , "LevelGrind" ),
124+ "spin" : ("spin" , "Spin" ),
125+ # Add more here later, e.g.:
126+ # "sweetscent": ("sweet_scent", "SweetScent"),
127+ }
128+ mod_name , class_name = name_map .get (canon , (canon , canon .capitalize ()))
129+
130+ # 1) built-in modes
131+ try :
132+ m = __import__ (f"modules.built_in_modes.{ mod_name } " , fromlist = [class_name ])
133+ return getattr (m , class_name , None )
53134 except Exception :
54135 pass
55136
56- # 2) Dynamic discovery via the bot's registry
137+ # 2) regular modes
138+ try :
139+ m = __import__ (f"modules.modes.{ mod_name } " , fromlist = [class_name ])
140+ return getattr (m , class_name , None )
141+ except Exception :
142+ pass
143+
144+ return None
145+
146+ def _find_mode_in_registry (canon : str ):
147+ """Search the bot's registered modes by normalized name."""
57148 try :
58149 from modules .modes import get_bot_modes # type: ignore
59150 for cls in get_bot_modes ():
60- name_fn = getattr (cls , "name" , None )
61- cls_name = getattr (cls , "__name__" , "" ).lower ()
151+ if cls is None :
152+ continue
153+ # Check static .name()
154+ mode_name = None
62155 try :
156+ name_fn = getattr (cls , "name" , None )
63157 mode_name = name_fn () if callable (name_fn ) else None
64158 except Exception :
65159 mode_name = None
66160
67- if (
68- (isinstance (mode_name , str ) and mode_name .strip ().lower () in {"level grind" , "level-grind" , "level_grind" })
69- or cls_name in {"levelgrind" , "level_grind" , "levelgrindmode" }
70- ):
161+ cand = (
162+ _norm_name (str (mode_name )) if isinstance (mode_name , str ) else None
163+ ) or _norm_name (getattr (cls , "__name__" , "" ))
164+
165+ if cand == canon :
166+ return cls
167+
168+ # tolerant variants
169+ if canon == "levelgrind" and cand in {"level_grind" , "levelgrindmode" }:
170+ return cls
171+ if canon == "spin" and cand in {"spinner" , "spinmode" }:
71172 return cls
72173 except Exception :
73174 pass
74-
75175 return None
76176
77- # -------- plugin that registers our mode --------
177+ def _discover_available (basenames : list [str ]) -> Dict [str , Any ]:
178+ """Return {pretty_name: class} for any of the requested basenames that are available."""
179+ out : Dict [str , Any ] = {}
180+ for raw in basenames :
181+ canon = _norm_name (raw )
182+ cls = _try_import_candidates (canon ) or _find_mode_in_registry (canon )
183+ if cls :
184+ out [raw ] = cls
185+ return out
186+
187+ def _resolve_base_class (preferred : Sequence [str ]):
188+ """Return (BaseClass, chosen_pretty_name)."""
189+ for raw in preferred :
190+ canon = _norm_name (raw )
191+ cls = _try_import_candidates (canon ) or _find_mode_in_registry (canon )
192+ if cls :
193+ return cls , raw
194+ return None , None
195+
196+ # ---------------- Selection logic ----------------
197+ def _get_preferred_bases () -> list [str ]:
198+ # 1) saved choice
199+ saved = _load_saved_choice ()
200+ if saved :
201+ return saved
202+
203+ # 2) env var
204+ env = os .getenv ("PROFOAK_BASE" )
205+ if env and env .strip ():
206+ bases = _parse_bases (env )
207+ if bases :
208+ return bases
209+
210+ # 3) inline defaults
211+ return list (PLUGIN_DEFAULT_BASES )
212+
213+ def _maybe_prompt_once (defaults : list [str ]) -> list [str ]:
214+ """If ASK_ON_FIRST_USE and interactive terminal, prompt once and persist."""
215+ if not ASK_ON_FIRST_USE :
216+ return defaults
217+ # already saved? skip
218+ if CONFIG_PATH .exists ():
219+ return defaults
220+ # only prompt if interactive console
221+ if not sys .stdin or not sys .stdin .isatty ():
222+ return defaults
223+
224+ avail = _discover_available (["LevelGrind" , "Spin" ])
225+ if not avail :
226+ return defaults
227+
228+ print ("\n [ProfOak] Pick a base mode to wrap:" )
229+ options = list (avail .keys ()) # pretty names as discovered
230+ for i , name in enumerate (options , 1 ):
231+ print (f" { i } ) { name } " )
232+ print (f"Press ENTER for default [{ defaults [0 ]} ]." )
233+ try :
234+ choice = input ("> " ).strip ()
235+ except Exception :
236+ choice = ""
237+ if choice .isdigit ():
238+ idx = int (choice ) - 1
239+ if 0 <= idx < len (options ):
240+ chosen = options [idx ]
241+ bases = [chosen ] + [b for b in defaults if _norm_name (b ) != _norm_name (chosen )]
242+ _save_choice (bases )
243+ return bases
244+ # ENTER or invalid -> keep defaults (and save so we don't ask again)
245+ _save_choice (defaults )
246+ return defaults
247+
248+ # ---------------- Plugin that registers our mode ----------------
78249class ProfOakPlugin (BotPlugin ):
79250 name = "ProfOakPlugin"
80- version = "0.2 .0-alpha.1 "
251+ version = "0.5 .0-alpha.0 "
81252 author = "HighVoltaage"
82- description = "Adds the 'Prof Oak' mode (wrapper around Level Grind)."
253+ description = "Adds the 'Prof Oak' mode that wraps a configurable base mode (e.g., Level Grind or Spin )."
83254
84255 def get_additional_bot_modes (self ) -> Iterable [type ["BotMode" ]]:
85- Base = _find_level_grind_base ()
256+ preferred = _get_preferred_bases ()
257+ preferred = _maybe_prompt_once (preferred ) # may update & persist on first use
258+
259+ Base , chosen = _resolve_base_class (preferred )
86260 if Base is None :
87- _log_warn ("[ProfOak] Could not locate Level Grind; not registering Prof Oak." )
261+ _log_warn (f "[ProfOak] Could not resolve any base from: { preferred } . Not registering Prof Oak." )
88262 return []
89263
90- # Define the subclass *now* using the resolved base.
91- class ProfOakMode (Base ): # type: ignore
264+ pretty = chosen or getattr (Base , "__name__" , "UnknownBase" )
265+ _log_info (f"[ProfOak] Registering Prof Oak (wrapping base: { pretty } )." )
266+
267+ class ProfOakMode (Base ): # type: ignore[misc]
92268 @staticmethod
93269 def name () -> str :
94270 return "Prof Oak"
95271
96272 @staticmethod
97273 def description () -> str :
98- return ("Prof Oak challenge wrapper. Runs Level Grind behavior while "
99- "the ShinyQuota plugin tracks per-map /method shiny quotas and pauses when complete ." )
274+ return (f "Prof Oak challenge wrapper using { pretty } behavior. "
275+ "Pairs with ShinyQuota to pause when route /method shiny quota is met ." )
100276
101277 def __init__ (self , * args , ** kwargs ):
102278 super ().__init__ (* args , ** kwargs ) # type: ignore
103- _log_info ("[ProfOak] Mode initialized (wrapping Level Grind base: %s )." % Base . __name__ )
279+ _log_info (f "[ProfOak] Mode initialized (base: { pretty } )." )
104280
105281 def run (self ) -> "Generator" : # type: ignore[override]
106- _log_info ("[ProfOak] Starting run loop (delegating to Level Grind logic )." )
282+ _log_info (f "[ProfOak] Starting run loop (delegating to { pretty } )." )
107283 yield from super ().run () # type: ignore
108284
109- _log_info ("[ProfOak] Mode registered (base: %s)." % Base .__name__ )
110285 return [ProfOakMode ]
0 commit comments