from __future__ import annotations import logging from app.models.world import WorldBlueprint, Location, TimeConfig from app.services.llm import LLMClient, parse_json from app.services.i18n import language_directive logger = logging.getLogger(__name__) SYSTEM_PROMPT = """You are a world-builder for MiroSociety, an AI simulation engine that simulates both societies and markets. Given the user's input, design a world that will be interesting to simulate. Your job is to create CONFLICT POTENTIAL — the world should have built-in tensions that produce surprising emergent behavior. The input may describe: - A SOCIETY with rules (e.g. "A town where lying is impossible") - A MARKET/PRODUCT scenario (e.g. "Tesla changes its logo to appeal to mainstream buyers") - A mix of both For MARKET scenarios, think of the market as a society: - Resources include: money, influence, satisfaction, information, loyalty_points - Rules are market dynamics: brand positioning, pricing norms, customer expectations, competitive landscape - Tensions are market conflicts: brand identity vs mass appeal, loyalty vs value, innovation vs familiarity Generate a JSON world blueprint with these exact fields: { "name": "Evocative Name (2-3 words)", "description": "One sentence capturing the essence of this world", "rules": ["Rule/dynamic 1 as stated clearly", "Rule/dynamic 2", "..."], "resources": ["resource1", "resource2", "influence", "knowledge"], "initial_tensions": ["Tension 1: who benefits vs who suffers", "Tension 2: what conflicts exist"], "time_config": { "total_days": , "rounds_per_day": 3, "active_agents_per_round_min": 3, "active_agents_per_round_max": } } Rules for world-building: - Always include "influence" and "knowledge" in resources. Add 2-4 others relevant to the scenario. - For market worlds, also include "money" and "satisfaction" as resources. - Generate 2-4 initial tensions. For societies: who benefits from these rules? Who suffers? For markets: who wins and loses from this change? What identities are threatened? - The name should be evocative and memorable, not generic. Return ONLY valid JSON. No markdown, no explanation.""" class WorldGenerator: def __init__(self, llm: LLMClient, locale: str = "en"): self.llm = llm self._locale = locale def set_locale(self, locale: str) -> None: self._locale = locale async def generate( self, rules_text: str, population: int = 25, duration_days: int = 365, proposed_change: str | None = None, ) -> WorldBlueprint: is_market = proposed_change is not None head, tail = language_directive(self._locale) system = head + "\n\n" + SYSTEM_PROMPT + "\n\n" + tail user_prompt = f"""Create a simulation world based on this context: "{rules_text}" Population: {population} citizens Simulation duration: {duration_days} days active_agents_per_round_max should be about {max(3, int(population * 0.4))}""" if proposed_change: user_prompt += f'\n\nPROPOSED CHANGE being introduced into this world:\n"{proposed_change}"' response = await self.llm.generate( system=system, user=user_prompt, json_mode=True, max_tokens=3000, ) data = parse_json(response) if not data: logger.error("World generation produced empty result, using fallback") return self._fallback_blueprint(rules_text, population, duration_days, proposed_change) # Some LLMs occasionally return a list (e.g. citizens array) instead of # a blueprint object. Wrap it defensively so the rest of the code keeps # working. if isinstance(data, list): logger.warning("World blueprint came back as a list, treating as fallback") return self._fallback_blueprint(rules_text, population, duration_days, proposed_change) try: locations = self._default_locations(self._locale) tc = data.get("time_config", {}) time_config = TimeConfig( total_days=tc.get("total_days", duration_days), rounds_per_day=tc.get("rounds_per_day", 3), active_agents_per_round_min=tc.get("active_agents_per_round_min", 3), active_agents_per_round_max=tc.get("active_agents_per_round_max", max(3, int(population * 0.4))), ) resources = data.get("resources", ["food", "goods", "influence", "knowledge"]) if "influence" not in resources: resources.append("influence") if "knowledge" not in resources: resources.append("knowledge") if is_market: for r in ["money", "satisfaction"]: if r not in resources: resources.append(r) # The LLM may still return English names when the user picked a # Chinese locale — fall back to localized defaults if its output # doesn't look like Chinese. name = data.get("name", "") description = data.get("description", "") if self._locale == "zh": name = self._ensure_zh(name, fallback="无名之邦") description = self._ensure_zh(description, fallback=f"一个这样的社会:{rules_text[:80]}") fallback_rule = f"一个这样的社会:{rules_text[:80]}" rules_list = [self._ensure_zh(r, fallback=fallback_rule) for r in data.get("rules", [rules_text])] else: rules_list = data.get("rules", [rules_text]) return WorldBlueprint( name=name or "Unnamed Society", description=description or f"A society where: {rules_text[:100]}", rules=rules_list, locations=locations, resources=resources, initial_tensions=data.get("initial_tensions", ["Order vs Freedom", "Individual vs Collective"]), time_config=time_config, ) except Exception as e: logger.error("Failed to parse world blueprint: %s", e) return self._fallback_blueprint(rules_text, population, duration_days, proposed_change) @staticmethod def _has_cjk(text: str) -> bool: return any("\u4e00" <= ch <= "\u9fff" for ch in text) def _ensure_zh(self, value: str, fallback: str) -> str: """In zh locale: require CJK content, otherwise fall back. In any other locale: always pass through. """ if self._locale == "zh": if value and self._has_cjk(value): return value return fallback return value or fallback def _default_locations(self, locale: str) -> list[Location]: if locale == "zh": return [ Location( id="community", name="公共空间", type="public", description="居民日常互动、聚集、议事的共享场所。", ), ] return [ Location( id="community", name="Community", type="public", description="The shared social space", ), ] def _fallback_blueprint( self, rules_text: str, population: int, duration_days: int, proposed_change: str | None = None, ) -> WorldBlueprint: resources = ["food", "goods", "influence", "knowledge"] if proposed_change: for r in ["money", "satisfaction"]: if r not in resources: resources.append(r) if self._locale == "zh": return WorldBlueprint( name="无名之邦", description=f"一个这样的社会:{rules_text[:80]}", rules=[rules_text], locations=self._default_locations("zh"), resources=resources, initial_tensions=["秩序 vs 自由", "个体 vs 集体"], time_config=TimeConfig( total_days=duration_days, rounds_per_day=3, active_agents_per_round_min=3, active_agents_per_round_max=max(3, int(population * 0.4)), ), ) return WorldBlueprint( name="The Settlement", description=f"A society where: {rules_text[:100]}", rules=[rules_text], locations=self._default_locations("en"), resources=resources, initial_tensions=["Order vs Freedom", "Individual vs Collective"], time_config=TimeConfig( total_days=duration_days, rounds_per_day=3, active_agents_per_round_min=3, active_agents_per_round_max=max(3, int(population * 0.4)), ), )