hashiruAI / src /tools /user_tools /globle_tool.py
helloparthshah's picture
Some more refactor
0e58feb
raw
history blame
8.63 kB
import importlib
import random
import math
import difflib
import json
import os
from pathlib import Path
__all__ = ["GlobleDistanceTool"]
class GlobleDistanceTool:
"""Globle‑style geography guessing game.
*Persists* game state to a JSON file in the user’s home directory (`~/.Globle_distance_state.json`).
This survives process restarts, ensuring the hidden country stays the same
until the player guesses correctly, gives up, or passes `reset=True`.
"""
# ------------------------------------------------------------------
# Metadata for orchestration
# ------------------------------------------------------------------
dependencies = [
"geopandas==1.0.1",
"shapely==2.1.0",
]
inputSchema = {
"name": "GlobleDistanceTool",
"description": (
"Guess the hidden country. Tool replies with distance to the target's centroid, "
"or notifies if they share a border. Use '/GIVEUP' to surrender."
),
"parameters": {
"type": "object",
"properties": {
"geo_path": {
"type": "string",
"description": "Path to GeoJSON/Shapefile with country polygons. Default './tools/util/countries.geojson'.",
},
"guess": {
"type": "string",
"description": "The country you are guessing (case‑insensitive).",
},
"reset": {
"type": "boolean",
"description": "Force a new game and discard stored state.",
},
},
},
}
# ------------------------------------------------------------------
# Constants / state file location
# ------------------------------------------------------------------
_STATE_PATH = Path.home() / ".Globle_distance_state.json"
# Runtime caches (populated lazily)
_countries_cache = {} # geo_path → (countries, geoms)
# --------------------------- public entry ---------------------------
def run(self, **kwargs):
geo_path = kwargs.get("geo_path", "./tools/util/countries.geojson")
guess = kwargs.get("guess")
reset_flag = bool(kwargs.get("reset", False))
# -----------------------------------
# Load or reset persistent state file
# -----------------------------------
if reset_flag and self._STATE_PATH.exists():
self._STATE_PATH.unlink(missing_ok=True)
state = self._load_state()
if state is None: # no current game → start one
init_res = self._start_new_game(geo_path)
if init_res["status"] != "success":
return init_res
state = self._load_state() # reload the freshly written state
# ------------------
# Ensure guess given
# ------------------
if not guess:
return {
"status": "error",
"message": "Provide a 'guess' parameter, or '/GIVEUP' to give up.",
"output": None,
}
# --------------------------------------------------------------
# Play round
# --------------------------------------------------------------
countries, geoms = self._get_country_data(state["geo_path"])
target = state["target"]
tlat, tlon = countries[target]
raw_guess = guess.strip()
# Handle give‑up
if raw_guess.upper() == "/GIVEUP":
self._STATE_PATH.unlink(missing_ok=True)
return {
"status": "success",
"message": "Gave up.",
"output": {"result": "gave_up", "answer": target},
}
# Fuzzy match unknown country
if raw_guess not in countries:
match = difflib.get_close_matches(raw_guess, countries.keys(), n=1, cutoff=0.6)
if not match:
return {
"status": "error",
"message": f"Unknown country '{raw_guess}'.",
"output": None,
}
guess_name = match[0]
else:
guess_name = raw_guess
# Correct?
if guess_name == target:
self._STATE_PATH.unlink(missing_ok=True) # clear state for next game
return {
"status": "success",
"message": "Correct!",
"output": {"result": "correct", "country": target},
}
# Shared border?
if geoms[guess_name].touches(geoms[target]):
return {
"status": "success",
"message": "Bordering country.",
"output": {"result": "border", "country": guess_name},
}
# Distance feedback
glat, glon = countries[guess_name]
dist_km = self._haversine(glat, glon, tlat, tlon)
return {
"status": "success",
"message": "Distance computed.",
"output": {"result": "distance", "country": guess_name, "km": round(dist_km)},
}
# ------------------------------------------------------------------
# Helper: start new game and persist state
# ------------------------------------------------------------------
def _start_new_game(self, geo_path):
try:
countries, _ = self._get_country_data(geo_path)
except Exception as e:
return {
"status": "error",
"message": f"Failed to load '{geo_path}': {e}",
"output": None,
}
target = random.choice(list(countries))
state = {"geo_path": geo_path, "target": target}
try:
self._STATE_PATH.write_text(json.dumps(state))
except Exception as e:
return {
"status": "error",
"message": f"Cannot write state file: {e}",
"output": None,
}
return {"status": "success", "message": "New game started", "output": None}
# ------------------------------------------------------------------
# Helper: load state file (or None if missing)
# ------------------------------------------------------------------
def _load_state(self):
if not self._STATE_PATH.exists():
return None
try:
return json.loads(self._STATE_PATH.read_text())
except Exception:
# Corrupt state – delete and start fresh next time
self._STATE_PATH.unlink(missing_ok=True)
return None
# ------------------------------------------------------------------
# Country data cache (per geo_path) to avoid re‑reading file
# ------------------------------------------------------------------
def _get_country_data(self, geo_path):
if geo_path in self._countries_cache:
return self._countries_cache[geo_path]
countries, geoms = self._load_countries(geo_path)
self._countries_cache[geo_path] = (countries, geoms)
return countries, geoms
# ------------------------------------------------------------------
# Geometry / file utility
# ------------------------------------------------------------------
@staticmethod
def _haversine(lat1, lon1, lat2, lon2):
R = 6371.0
φ1, φ2 = math.radians(lat1), math.radians(lat2)
dφ = math.radians(lat2 - lat1)
dλ = math.radians(lon2 - lon1)
a = math.sin(dφ / 2) ** 2 + math.cos(φ1) * math.cos(φ2) * math.sin(dλ / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
@staticmethod
def _load_countries(geo_path):
gpd = importlib.import_module("geopandas")
Point = importlib.import_module("shapely.geometry").Point
gdf = gpd.read_file(geo_path)
name_field = next((c for c in ["ADMIN", "NAME", "NAME_EN", "NAME_LONG", "SOVEREIGN", "COUNTRY"] if c in gdf.columns), None)
if not name_field:
non_geom = [c for c in gdf.columns if c.lower() != "geometry"]
if not non_geom:
raise ValueError("No suitable name column in geo file")
name_field = non_geom[0]
centroids, geoms = {}, {}
for _, row in gdf.iterrows():
geom = row.geometry
if not geom or geom.is_empty:
continue
c = geom.centroid # type: Point
name = row[name_field]
centroids[name] = (c.y, c.x)
geoms[name] = geom
return centroids, geoms