Compare commits

..

7 Commits

Author SHA1 Message Date
empty
8915a4b074 feat: implement AI Director & Narrative Voting System (Phase 9)
Add complete AI Director system that transforms the survival simulation
into a user-driven interactive story with audience voting.

Backend:
- Add DirectorService for LLM-powered plot generation with fallback templates
- Add VoteManager for dual-channel voting (Twitch + Unity)
- Integrate 4-phase game loop: Simulation → Narrative → Voting → Resolution
- Add vote command parsing (!1, !2, !A, !B) in Twitch service
- Add type-safe LLM output handling with _coerce_int() helper
- Normalize voter IDs for case-insensitive duplicate prevention

Unity Client:
- Add NarrativeUI for cinematic event cards and voting progress bars
- Add 7 new event types and data models for director/voting events
- Add delayed subscription coroutine for NetworkManager timing
- Sync client timer with server's remaining_seconds to prevent drift

Documentation:
- Update README.md with AI Director features, voting commands, and event types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 03:37:41 +08:00
empty
93fed8b9ca fix: add missing 'id' attribute to AgentSnapshot class
AgentSnapshot was missing the 'id' attribute which is required by
memory_service.get_relevant_memories() in llm.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 02:48:49 +08:00
empty
07acfa5801 fix: prevent ParticleSystem "duration while playing" warnings
- Stop ParticleSystem immediately after AddComponent to prevent auto-play
- Set playOnAwake=false on all procedural particle systems
- Also add missing EventType definitions for Phase 23/24 features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 02:46:23 +08:00
empty
da49223685 fix: remove duplicate }) causing Python syntax error
Remove extra }) at line 1338 that was causing unmatched brace error
on startup.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 02:39:40 +08:00
empty
9d248f2cdb fix: remove extra closing brace in GameManager.cs
Remove duplicate } at line 504 that was breaking the class structure
and causing CS1022 compilation error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 02:38:06 +08:00
empty
5476260efa fix: avoid detached SQLAlchemy session in conversation reply
Extract agent ids and names before creating async task to prevent
"Instance is not bound to a Session" error when accessing attributes
after the original session is closed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 02:31:06 +08:00
empty
adfd197451 fix: add missing self parameter to generate_social_interaction
The method was missing 'self' as first parameter, causing
"got multiple values for argument 'initiator_name'" error.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 02:22:25 +08:00
13 changed files with 2770 additions and 18 deletions

View File

@@ -16,6 +16,8 @@ the-island/
│ ├── llm.py # LLM 集成 (对话生成)
│ ├── memory_service.py # Agent 记忆管理服务
│ ├── twitch_service.py # Twitch 聊天机器人
│ ├── director_service.py # AI 导演服务 (叙事生成)
│ ├── vote_manager.py # 投票管理器
│ └── database.py # 数据库配置
├── frontend/ # Web 调试客户端
│ ├── app.js # JavaScript 客户端
@@ -43,6 +45,8 @@ the-island/
- **社交角色**: 领导者、追随者、独行者动态关系
- **记忆系统**: Agent 会记住重要的互动和事件
- **随机事件**: 风暴破坏、发现宝藏、野兽袭击等
- **AI 导演系统**: 自动生成剧情事件,观众投票决定剧情走向
- **叙事投票**: Twitch 观众通过 `!1` `!2` 命令参与剧情决策
### 玩家命令
| 命令 | 格式 | 金币消耗 | 效果 |
@@ -54,6 +58,8 @@ the-island/
| revive | `revive <角色名>` | 10g | 复活死亡角色 |
| check | `check` | 0g | 查看所有状态 |
| reset | `reset` | 0g | 重置游戏 |
| !1 / !A | `!1``!A` | 0g | 投票选择第一选项 |
| !2 / !B | `!2``!B` | 0g | 投票选择第二选项 |
### AI 角色
- **Jack** (勇敢) - 蓝色
@@ -123,6 +129,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
| `EnvironmentManager.cs` | 环境场景 (沙滩、海洋、天空) |
| `WeatherEffects.cs` | 天气粒子效果 (雨、雾、热浪) |
| `Models.cs` | 数据模型 (Agent、WorldState、事件数据) |
| `NarrativeUI.cs` | AI 导演叙事界面 (剧情卡片、投票进度条、倒计时) |
### 视觉特性
- 程序化生成的 2.5D 角色精灵
@@ -178,6 +185,15 @@ AGENT_ACTION # 角色执行行动 (采集/休息/社交等)
CRAFT # 制作物品 (药品等)
USE_ITEM # 使用物品
RANDOM_EVENT # 随机事件 (风暴/宝藏/野兽等)
# AI 导演与叙事投票 (Phase 9)
MODE_CHANGE # 游戏模式切换 (simulation/narrative/voting/resolution)
NARRATIVE_PLOT # 导演生成的剧情事件
VOTE_STARTED # 投票开始
VOTE_UPDATE # 实时投票进度更新
VOTE_ENDED # 投票结束
VOTE_RESULT # 投票结果
RESOLUTION_APPLIED # 剧情决议执行
```
## Twitch 直播集成

View File

@@ -0,0 +1,562 @@
"""
AI Director Service - Narrative Control Module (Phase 9).
The Director acts as the Dungeon Master for the survival drama,
generating dramatic plot points and resolving audience votes.
"""
from __future__ import annotations
import json
import logging
import os
import random
import time
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
logger = logging.getLogger(__name__)
class GameMode(str, Enum):
"""Game engine operating modes."""
SIMULATION = "simulation" # Normal agent behavior
NARRATIVE = "narrative" # Director presents plot point
VOTING = "voting" # Audience voting window
RESOLUTION = "resolution" # Applying vote consequences
@dataclass(frozen=True)
class PlotChoice:
"""A choice option in a plot point."""
choice_id: str
text: str
effects: dict[str, Any] = field(default_factory=dict)
@dataclass
class PlotPoint:
"""A narrative event generated by the Director."""
plot_id: str
title: str
description: str
choices: list[PlotChoice]
ttl_seconds: int = 60
created_at: float = field(default_factory=time.time)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for broadcasting."""
return {
"plot_id": self.plot_id,
"title": self.title,
"description": self.description,
"choices": [
{"choice_id": c.choice_id, "text": c.text}
for c in self.choices
],
"ttl_seconds": self.ttl_seconds,
}
@dataclass
class ResolutionResult:
"""Result of resolving a plot point vote."""
plot_id: str
choice_id: str
message: str
effects: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for broadcasting."""
return {
"plot_id": self.plot_id,
"choice_id": self.choice_id,
"message": self.message,
"effects_json": json.dumps(self.effects),
}
# Fallback templates when LLM is unavailable
FALLBACK_PLOT_TEMPLATES = [
{
"title": "Mysterious Footprints",
"description": "Strange footprints appear on the beach overnight. Someone - or something - has been watching the camp.",
"choices": [
PlotChoice("investigate", "Follow the tracks into the forest", {"risk": "medium", "reward": "discovery"}),
PlotChoice("fortify", "Strengthen camp defenses and wait", {"safety": "high", "mood_delta": -5}),
],
},
{
"title": "Supply Shortage",
"description": "The food stores are running dangerously low. Tension builds among the survivors.",
"choices": [
PlotChoice("ration", "Implement strict rationing for everyone", {"mood_delta": -10, "food_save": 2}),
PlotChoice("hunt", "Send a group on a risky hunting expedition", {"risk": "high", "food_gain": 3}),
],
},
{
"title": "Storm Warning",
"description": "Dark clouds gather on the horizon. A massive storm approaches the island.",
"choices": [
PlotChoice("shelter", "Everyone take shelter immediately", {"safety": "high", "mood_delta": 5}),
PlotChoice("salvage", "Quickly gather supplies before the storm hits", {"risk": "medium", "resource_gain": 2}),
],
},
{
"title": "Trust Crisis",
"description": "Accusations fly as valuable supplies go missing from the camp.",
"choices": [
PlotChoice("accuse", "Hold a trial to find the culprit", {"drama": "high", "relationship_delta": -5}),
PlotChoice("forgive", "Call for unity and move on together", {"mood_delta": 3, "trust": "restored"}),
],
},
{
"title": "Rescue Signal",
"description": "A faint light flickers on the distant horizon. Could it be a ship?",
"choices": [
PlotChoice("signal", "Build a massive signal fire on the beach", {"energy_delta": -15, "hope": "high"}),
PlotChoice("wait", "Wait and observe - it could be dangerous", {"safety": "medium", "mood_delta": -3}),
],
},
]
class DirectorService:
"""
AI Director for generating and resolving narrative events.
Uses LLM to create dramatic plot points based on world state.
"""
def __init__(self, llm_service=None) -> None:
"""
Initialize the Director service.
Args:
llm_service: Optional LLMService instance. If None, uses global instance.
"""
self._llm_service = llm_service
self._rng = random.Random()
self._current_plot: PlotPoint | None = None
self._plot_history: list[str] = [] # Recent plot titles to avoid repetition
@property
def llm(self):
"""Lazy-load LLM service to avoid circular imports."""
if self._llm_service is None:
from .llm import llm_service
self._llm_service = llm_service
return self._llm_service
@property
def current_plot(self) -> PlotPoint | None:
"""Get the current active plot point."""
return self._current_plot
def clear_current_plot(self) -> None:
"""Clear the current plot after resolution."""
if self._current_plot:
self._plot_history.append(self._current_plot.title)
# Keep only last 5 titles to avoid repetition
self._plot_history = self._plot_history[-5:]
self._current_plot = None
async def generate_plot_point(self, world_state: dict[str, Any]) -> PlotPoint:
"""
Generate a dramatic plot point based on current world state.
Args:
world_state: Dictionary containing:
- day: Current game day
- weather: Current weather condition
- time_of_day: dawn/day/dusk/night
- alive_agents: List of alive agent summaries
- recent_events: List of recent event descriptions
- tension_level: low/medium/high (derived from deaths, resources, etc.)
Returns:
PlotPoint with title, description, and 2 choices
"""
# Extract context
day = world_state.get("day", 1)
weather = world_state.get("weather", "Sunny")
alive_count = len(world_state.get("alive_agents", []))
recent_events = world_state.get("recent_events", [])
tension_level = world_state.get("tension_level", "medium")
mood_avg = world_state.get("mood_avg", 50)
# Build context summary
agents_summary = ", ".join([
f"{a.get('name', 'Unknown')} (HP:{a.get('hp', 0)})"
for a in world_state.get("alive_agents", [])[:5]
]) or "No agents alive"
events_summary = "; ".join(recent_events[-3:]) if recent_events else "Nothing notable recently"
# Try LLM generation first
if not self.llm.is_mock_mode:
try:
plot = await self._generate_llm_plot(
day=day,
weather=weather,
alive_count=alive_count,
agents_summary=agents_summary,
events_summary=events_summary,
tension_level=tension_level,
mood_avg=mood_avg,
)
if plot:
self._current_plot = plot
return plot
except Exception as e:
logger.error(f"LLM plot generation failed: {e}")
# Fallback to template-based generation
plot = self._generate_fallback_plot(weather, tension_level, mood_avg)
self._current_plot = plot
return plot
async def _generate_llm_plot(
self,
day: int,
weather: str,
alive_count: int,
agents_summary: str,
events_summary: str,
tension_level: str,
mood_avg: int,
) -> PlotPoint | None:
"""Generate plot point using LLM."""
# Build the prompt for the AI Director
system_prompt = f"""You are the AI Director for a survival drama on a deserted island.
Your role is to create dramatic narrative moments that engage the audience.
CURRENT SITUATION:
- Day {day} on the island
- Weather: {weather}
- Survivors: {alive_count} ({agents_summary})
- Recent events: {events_summary}
- Tension level: {tension_level}
- Average mood: {mood_avg}/100
RECENTLY USED PLOTS (avoid these):
{', '.join(self._plot_history) if self._plot_history else 'None yet'}
GUIDELINES:
1. Create dramatic tension appropriate to the tension level
2. Choices should have meaningful trade-offs
3. Consider weather and mood in your narrative
4. Keep descriptions cinematic but brief (under 50 words)
OUTPUT FORMAT (strict JSON):
{{
"title": "Brief dramatic title (3-5 words)",
"description": "Cinematic description of the situation (under 50 words)",
"choices": [
{{"id": "choice_a", "text": "First option (under 15 words)", "effects": {{"mood_delta": 5}}}},
{{"id": "choice_b", "text": "Second option (under 15 words)", "effects": {{"mood_delta": -5}}}}
]
}}"""
user_prompt = f"""The current tension is {tension_level}.
{"Create an intense, high-stakes event!" if tension_level == "high" else "Create an interesting event to raise the drama." if tension_level == "low" else "Create a moderately dramatic event."}
Generate a plot point now. Output ONLY valid JSON, no explanation."""
try:
# Use LLM service's internal acompletion
kwargs = {
"model": self.llm._model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"max_tokens": 300,
"temperature": 0.9,
}
if self.llm._api_base:
kwargs["api_base"] = self.llm._api_base
if self.llm._api_key and not self.llm._api_key_header:
kwargs["api_key"] = self.llm._api_key
if self.llm._extra_headers:
kwargs["extra_headers"] = self.llm._extra_headers
response = await self.llm._acompletion(**kwargs)
content = response.choices[0].message.content.strip()
# Parse JSON response
# Handle potential markdown code blocks
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
data = json.loads(content)
# Validate and construct PlotPoint
choices = [
PlotChoice(
choice_id=c.get("id", f"choice_{i}"),
text=c.get("text", "Unknown option"),
effects=c.get("effects", {}),
)
for i, c in enumerate(data.get("choices", []))
]
if len(choices) < 2:
logger.warning("LLM returned fewer than 2 choices, using fallback")
return None
return PlotPoint(
plot_id=uuid.uuid4().hex,
title=data.get("title", "Unexpected Event"),
description=data.get("description", "Something happens..."),
choices=choices[:2], # Limit to 2 choices
ttl_seconds=60,
)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse LLM JSON response: {e}")
return None
except Exception as e:
logger.error(f"LLM plot generation error: {e}")
return None
def _generate_fallback_plot(
self,
weather: str,
tension_level: str,
mood_avg: int,
) -> PlotPoint:
"""Generate plot point from templates when LLM is unavailable."""
# Filter templates based on context
available = [t for t in FALLBACK_PLOT_TEMPLATES if t["title"] not in self._plot_history]
if not available:
available = FALLBACK_PLOT_TEMPLATES
# Weight selection based on weather and tension
if weather.lower() in ("stormy", "rainy", "thunder"):
# Prefer storm-related plots
storm_plots = [t for t in available if "storm" in t["title"].lower()]
if storm_plots:
available = storm_plots
elif tension_level == "low" and mood_avg > 60:
# Prefer dramatic plots to shake things up
drama_plots = [t for t in available if "crisis" in t["title"].lower() or "trust" in t["title"].lower()]
if drama_plots:
available = drama_plots
template = self._rng.choice(available)
return PlotPoint(
plot_id=uuid.uuid4().hex,
title=template["title"],
description=template["description"],
choices=list(template["choices"]),
ttl_seconds=60,
)
async def resolve_vote(
self,
plot_point: PlotPoint,
winning_choice_id: str,
world_state: dict[str, Any],
) -> ResolutionResult:
"""
Resolve the vote and generate consequences.
Args:
plot_point: The PlotPoint that was voted on
winning_choice_id: The ID of the winning choice
world_state: Current world state for context
Returns:
ResolutionResult with message and effects to apply
"""
# Find the winning choice
winning_choice = next(
(c for c in plot_point.choices if c.choice_id == winning_choice_id),
plot_point.choices[0] # Fallback to first choice
)
# Try LLM resolution first
if not self.llm.is_mock_mode:
try:
result = await self._generate_llm_resolution(
plot_point=plot_point,
winning_choice=winning_choice,
world_state=world_state,
)
if result:
return result
except Exception as e:
logger.error(f"LLM resolution failed: {e}")
# Fallback resolution
return self._generate_fallback_resolution(plot_point, winning_choice)
async def _generate_llm_resolution(
self,
plot_point: PlotPoint,
winning_choice: PlotChoice,
world_state: dict[str, Any],
) -> ResolutionResult | None:
"""Generate resolution using LLM."""
agents_summary = ", ".join([
a.get("name", "Unknown")
for a in world_state.get("alive_agents", [])[:5]
]) or "the survivors"
system_prompt = f"""You are the AI Director narrating the consequences of an audience vote.
THE SITUATION:
{plot_point.description}
THE AUDIENCE VOTED FOR:
"{winning_choice.text}"
SURVIVORS INVOLVED:
{agents_summary}
GUIDELINES:
1. Describe the immediate consequence dramatically
2. Mention how the survivors react
3. Keep it brief but impactful (under 40 words)
4. The effects should feel meaningful
OUTPUT FORMAT (strict JSON):
{{
"message": "Dramatic description of what happens...",
"effects": {{
"mood_delta": -5,
"hp_delta": 0,
"energy_delta": -10,
"item_gained": null,
"item_lost": null,
"relationship_change": null
}}
}}"""
try:
kwargs = {
"model": self.llm._model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Narrate the consequence of choosing: {winning_choice.text}"}
],
"max_tokens": 200,
"temperature": 0.8,
}
if self.llm._api_base:
kwargs["api_base"] = self.llm._api_base
if self.llm._api_key and not self.llm._api_key_header:
kwargs["api_key"] = self.llm._api_key
if self.llm._extra_headers:
kwargs["extra_headers"] = self.llm._extra_headers
response = await self.llm._acompletion(**kwargs)
content = response.choices[0].message.content.strip()
# Handle markdown code blocks
if content.startswith("```"):
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
data = json.loads(content)
# Merge LLM effects with choice's predefined effects
effects = {**winning_choice.effects, **data.get("effects", {})}
return ResolutionResult(
plot_id=plot_point.plot_id,
choice_id=winning_choice.choice_id,
message=data.get("message", f"The survivors chose: {winning_choice.text}"),
effects=effects,
)
except Exception as e:
logger.error(f"LLM resolution error: {e}")
return None
def _generate_fallback_resolution(
self,
plot_point: PlotPoint,
winning_choice: PlotChoice,
) -> ResolutionResult:
"""Generate fallback resolution message."""
# Template-based resolution messages
messages = [
f"The decision is made! {winning_choice.text}",
f"The survivors act: {winning_choice.text}",
f"Following the audience's choice: {winning_choice.text}",
]
return ResolutionResult(
plot_id=plot_point.plot_id,
choice_id=winning_choice.choice_id,
message=self._rng.choice(messages),
effects=dict(winning_choice.effects),
)
def calculate_tension_level(self, world_state: dict[str, Any]) -> str:
"""
Calculate the current tension level based on world state.
Args:
world_state: Dictionary with game state information
Returns:
"low", "medium", or "high"
"""
score = 0
# Factor: Agent health
alive_agents = world_state.get("alive_agents", [])
if alive_agents:
avg_hp = sum(a.get("hp", 100) for a in alive_agents) / len(alive_agents)
if avg_hp < 30:
score += 3
elif avg_hp < 50:
score += 2
elif avg_hp < 70:
score += 1
# Factor: Weather severity
weather = world_state.get("weather", "").lower()
if weather in ("stormy", "thunder"):
score += 2
elif weather in ("rainy",):
score += 1
# Factor: Mood
mood_avg = world_state.get("mood_avg", 50)
if mood_avg < 30:
score += 2
elif mood_avg < 50:
score += 1
# Factor: Recent deaths
recent_deaths = world_state.get("recent_deaths", 0)
score += min(recent_deaths * 2, 4)
# Factor: Low resources
if world_state.get("resources_critical", False):
score += 2
# Determine level
if score >= 6:
return "high"
elif score >= 3:
return "medium"
return "low"
# Global instance
director_service = DirectorService()

View File

@@ -15,6 +15,8 @@ from .database import init_db, get_db_session
from .models import User, Agent, WorldState, GameConfig, AgentRelationship
from .llm import llm_service
from .memory_service import memory_service
from .director_service import DirectorService, GameMode, PlotPoint
from .vote_manager import VoteManager, VoteOption, VoteSnapshot
if TYPE_CHECKING:
from .server import ConnectionManager
@@ -30,6 +32,7 @@ RESET_PATTERN = re.compile(r"(reset|重新开始|重置)", re.IGNORECASE)
HEAL_PATTERN = re.compile(r"heal\s+(\w+)", re.IGNORECASE)
TALK_PATTERN = re.compile(r"talk\s+(\w+)\s*(.*)?", re.IGNORECASE)
ENCOURAGE_PATTERN = re.compile(r"encourage\s+(\w+)", re.IGNORECASE)
LOVE_PATTERN = re.compile(r"love\s+(\w+)", re.IGNORECASE)
REVIVE_PATTERN = re.compile(r"revive\s+(\w+)", re.IGNORECASE)
# =============================================================================
@@ -47,12 +50,23 @@ FEED_ENERGY_RESTORE = 20
HEAL_COST = 15
HEAL_HP_RESTORE = 30
ENCOURAGE_COST = 5
ENCOURAGE_COST = 5
ENCOURAGE_MOOD_BOOST = 15
LOVE_COST = 5
LOVE_MOOD_BOOST = 20
REVIVE_COST = 10 # Casual mode cost
INITIAL_USER_GOLD = 100
IDLE_CHAT_PROBABILITY = 0.15
# =============================================================================
# AI Director & Narrative Voting (Phase 9)
# =============================================================================
DIRECTOR_TRIGGER_INTERVAL = 60 # Ticks between narrative events (5 minutes at 5s/tick)
DIRECTOR_MIN_ALIVE_AGENTS = 2 # Minimum alive agents to trigger narrative
VOTING_DURATION_SECONDS = 60 # Duration of voting window
VOTE_BROADCAST_INTERVAL = 1.0 # How often to broadcast vote updates
# =============================================================================
# Day/Night cycle
# =============================================================================
@@ -126,7 +140,25 @@ class GameEngine:
self._running = False
self._tick_count = 0
self._tick_interval = TICK_INTERVAL
self._tick_interval = TICK_INTERVAL
self._config: Optional[GameConfig] = None
# Phase 22: Contextual Dialogue System
# Key: agent_id (who needs to respond), Value: {partner_id, last_text, topic, expires_at_tick}
self._active_conversations = {}
# Phase 9: AI Director & Narrative Voting
self._director = DirectorService()
self._vote_manager = VoteManager(
duration_seconds=VOTING_DURATION_SECONDS,
broadcast_interval=VOTE_BROADCAST_INTERVAL,
)
self._game_mode = GameMode.SIMULATION
self._last_narrative_tick = 0
self._current_plot: PlotPoint | None = None
self._mode_change_tick = 0 # Tick when mode changed
# Set up vote broadcast callback
self._vote_manager.set_broadcast_callback(self._on_vote_update)
@property
def is_running(self) -> bool:
@@ -198,6 +230,14 @@ class GameEngine:
event = GameEvent(event_type=event_type, timestamp=time.time(), data=data)
await self._manager.broadcast(event)
async def _broadcast_vfx(self, effect: str, target_id: int = 0, message: str = "") -> None:
"""Helper to broadcast visual effect events."""
await self._broadcast_event(EventType.VFX_EVENT, {
"effect": effect,
"target_id": target_id,
"message": message
})
async def _broadcast_agents_status(self) -> None:
"""Broadcast all agents' current status."""
with get_db_session() as db:
@@ -234,6 +274,222 @@ class GameEngine:
if world:
await self._broadcast_event(EventType.WORLD_UPDATE, world.to_dict())
# =========================================================================
# AI Director & Narrative Voting (Phase 9)
# =========================================================================
async def _on_vote_update(self, snapshot: VoteSnapshot) -> None:
"""Callback for broadcasting vote updates."""
await self._broadcast_event(EventType.VOTE_UPDATE, snapshot.to_dict())
async def _set_game_mode(self, new_mode: GameMode, message: str = "") -> None:
"""Switch game mode and broadcast the change."""
old_mode = self._game_mode
self._game_mode = new_mode
self._mode_change_tick = self._tick_count
ends_at = 0.0
if new_mode == GameMode.VOTING:
session = self._vote_manager.current_session
if session:
ends_at = session.end_ts
await self._broadcast_event(EventType.MODE_CHANGE, {
"mode": new_mode.value,
"old_mode": old_mode.value,
"message": message,
"ends_at": ends_at,
})
logger.info(f"Game mode changed: {old_mode.value} -> {new_mode.value}")
def _get_world_state_for_director(self) -> dict:
"""Build world state context for the Director."""
with get_db_session() as db:
world = db.query(WorldState).first()
agents = db.query(Agent).filter(Agent.status == "Alive").all()
alive_agents = [
{"name": a.name, "hp": a.hp, "energy": a.energy, "mood": a.mood}
for a in agents
]
mood_avg = sum(a.mood for a in agents) / len(agents) if agents else 50
return {
"day": world.day_count if world else 1,
"weather": world.weather if world else "Sunny",
"time_of_day": world.time_of_day if world else "day",
"alive_agents": alive_agents,
"mood_avg": mood_avg,
"recent_events": [], # Could be populated from event history
"tension_level": self._director.calculate_tension_level({
"alive_agents": alive_agents,
"weather": world.weather if world else "Sunny",
"mood_avg": mood_avg,
}),
}
async def _should_trigger_narrative(self) -> bool:
"""Check if conditions are met to trigger a narrative event."""
# Only trigger in simulation mode
if self._game_mode != GameMode.SIMULATION:
return False
# Check tick interval
ticks_since_last = self._tick_count - self._last_narrative_tick
if ticks_since_last < DIRECTOR_TRIGGER_INTERVAL:
return False
# Check minimum alive agents
with get_db_session() as db:
alive_count = db.query(Agent).filter(Agent.status == "Alive").count()
if alive_count < DIRECTOR_MIN_ALIVE_AGENTS:
return False
return True
async def _trigger_narrative_event(self) -> None:
"""Trigger a narrative event from the Director."""
logger.info("Director triggering narrative event...")
# Switch to narrative mode
await self._set_game_mode(GameMode.NARRATIVE, "The Director intervenes...")
# Generate plot point
world_state = self._get_world_state_for_director()
plot = await self._director.generate_plot_point(world_state)
self._current_plot = plot
self._last_narrative_tick = self._tick_count
# Broadcast narrative event
await self._broadcast_event(EventType.NARRATIVE_PLOT, plot.to_dict())
logger.info(f"Narrative event: {plot.title}")
# Start voting session
options = [
VoteOption(choice_id=c.choice_id, text=c.text)
for c in plot.choices
]
self._vote_manager.start_vote(options, duration_seconds=VOTING_DURATION_SECONDS)
# Broadcast vote started
vote_data = self._vote_manager.get_vote_started_data()
if vote_data:
await self._broadcast_event(EventType.VOTE_STARTED, vote_data)
# Switch to voting mode
await self._set_game_mode(
GameMode.VOTING,
f"Vote now! {plot.choices[0].text} or {plot.choices[1].text}"
)
async def _process_voting_tick(self) -> None:
"""Process voting phase - check if voting has ended."""
if self._game_mode != GameMode.VOTING:
return
result = self._vote_manager.maybe_finalize()
if result:
# Voting ended
await self._broadcast_event(EventType.VOTE_ENDED, {
"vote_id": result.vote_id,
"total_votes": result.total_votes,
})
await self._broadcast_event(EventType.VOTE_RESULT, result.to_dict())
# Switch to resolution mode
await self._set_game_mode(
GameMode.RESOLUTION,
f"The audience has spoken: {result.winning_choice_text}"
)
# Process resolution
await self._process_vote_result(result)
async def _process_vote_result(self, result) -> None:
"""Process the voting result and apply consequences."""
if not self._current_plot:
logger.error("No current plot for resolution")
await self._set_game_mode(GameMode.SIMULATION, "Returning to normal...")
return
# Get resolution from Director
world_state = self._get_world_state_for_director()
resolution = await self._director.resolve_vote(
plot_point=self._current_plot,
winning_choice_id=result.winning_choice_id,
world_state=world_state,
)
# Apply effects
await self._apply_resolution_effects(resolution.effects)
# Broadcast resolution
await self._broadcast_event(EventType.RESOLUTION_APPLIED, resolution.to_dict())
logger.info(f"Resolution applied: {resolution.message}")
# Clear current plot
self._director.clear_current_plot()
self._current_plot = None
# Return to simulation after a brief pause
await asyncio.sleep(3.0) # Let players read the resolution
await self._set_game_mode(GameMode.SIMULATION, "The story continues...")
async def _apply_resolution_effects(self, effects: dict) -> None:
"""Apply resolution effects to the game world."""
def _coerce_int(value) -> int:
"""Safely convert LLM output (string/float/int) to int."""
try:
return int(value)
except (TypeError, ValueError):
return 0
mood_delta = _coerce_int(effects.get("mood_delta", 0))
hp_delta = _coerce_int(effects.get("hp_delta", 0))
energy_delta = _coerce_int(effects.get("energy_delta", 0))
if not any([mood_delta, hp_delta, energy_delta]):
return
with get_db_session() as db:
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
if mood_delta:
agent.mood = max(0, min(100, agent.mood + mood_delta))
if hp_delta:
agent.hp = max(0, min(100, agent.hp + hp_delta))
if energy_delta:
agent.energy = max(0, min(100, agent.energy + energy_delta))
logger.info(
f"Applied resolution effects: mood={mood_delta}, "
f"hp={hp_delta}, energy={energy_delta}"
)
async def process_vote(self, voter_id: str, choice_index: int, source: str = "twitch") -> bool:
"""
Process a vote from Twitch or Unity.
Args:
voter_id: Unique identifier for the voter
choice_index: 0-indexed choice number
source: Vote source ("twitch" or "unity")
Returns:
True if vote was recorded
"""
if self._game_mode != GameMode.VOTING:
return False
return self._vote_manager.cast_vote(voter_id, choice_index, source)
def parse_vote_command(self, message: str) -> int | None:
"""Parse a message for vote commands. Returns choice index or None."""
return self._vote_manager.parse_twitch_message(message)
# =========================================================================
# Day/Night cycle (Phase 2)
# =========================================================================
@@ -666,12 +922,119 @@ class GameEngine:
"dialogue": dialogue
})
# Phase 22: Contextual Dialogue - Store context for responder
# Initiator just spoke. Target needs to respond next tick.
initiator_id = interaction_data["initiator_id"]
target_id = interaction_data["target_id"]
# 50% chance to continue the conversation (A -> B -> A)
should_continue = True # For the first response (A->B), almost always yes unless "argue" maybe?
if should_continue:
self._active_conversations[target_id] = {
"partner_id": initiator_id,
"last_text": dialogue,
"topic": interaction_data["interaction_type"], # Rough topic
"expires_at_tick": self._tick_count + 5 # Must respond within 5 ticks
}
except Exception as e:
logger.error(f"Error in social dialogue: {e}")
# =========================================================================
# Autonomous Agency (Phase 13)
# Economy / Altruism (Phase 23)
# =========================================================================
async def _process_altruism_tick(self) -> None:
"""Process altruistic item sharing based on need."""
if random.random() > 0.5: # 50% chance per tick to check
return
with get_db_session() as db:
agents = db.query(Agent).filter(Agent.status == "Alive").all()
# Shuffle to avoid priority bias
random.shuffle(agents)
for giver in agents:
giver_inv = self._get_inventory(giver)
# Check surplus
item_to_give = None
# Give Herb if have plenty
if giver_inv.get("herb", 0) >= 3:
item_to_give = "herb"
# Give Food if have plenty and energy is high
elif giver_inv.get("food", 0) >= 1 and giver.energy > 80:
item_to_give = "food"
if not item_to_give:
continue
# Find needy neighbor
for candidate in agents:
if candidate.id == giver.id: continue
cand_inv = self._get_inventory(candidate)
score = 0
if item_to_give == "herb":
# High priority: Sick and no herbs
if candidate.is_sick and cand_inv.get("herb", 0) == 0:
score = 100
elif item_to_give == "food":
# High priority: Starving and no food
if candidate.energy < 30 and cand_inv.get("food", 0) == 0:
score = 50
if score > 0:
# Check relationship (don't give to enemies)
rel = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == giver.id,
AgentRelationship.agent_to_id == candidate.id
).first()
type_ = rel.relationship_type if rel else "stranger"
if type_ in ["rival", "enemy"]:
continue
# Execute Give
giver_inv[item_to_give] -= 1
self._set_inventory(giver, giver_inv)
cand_inv[item_to_give] = cand_inv.get(item_to_give, 0) + 1
self._set_inventory(candidate, cand_inv)
# Update Relationship (Giver -> Receiver)
if not rel:
rel = AgentRelationship(agent_from_id=giver.id, agent_to_id=candidate.id)
db.add(rel)
rel.affection = min(100, rel.affection + 10)
rel.trust = min(100, rel.trust + 5)
rel.interaction_count += 1
rel.update_relationship_type()
# Update Relationship (Receiver -> Giver)
rel2 = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == candidate.id,
AgentRelationship.agent_to_id == giver.id
).first()
if not rel2:
rel2 = AgentRelationship(agent_from_id=candidate.id, agent_to_id=giver.id)
db.add(rel2)
rel2.affection = min(100, rel2.affection + 8)
rel2.trust = min(100, rel2.trust + 3)
rel2.update_relationship_type()
# Broadcast
await self._broadcast_event(EventType.GIVE_ITEM, {
"from_id": giver.id,
"to_id": candidate.id,
"item_type": item_to_give,
"message": f"{giver.name} gave 1 {item_to_give} to {candidate.name}."
})
logger.info(f"{giver.name} gave {item_to_give} to {candidate.name}")
# One action per agent per tick
break
async def _process_activity_tick(self) -> None:
"""Decide and execute autonomous agent actions."""
# Only process activity every few ticks to avoid chaotic movement
@@ -689,10 +1052,44 @@ class GameEngine:
new_action = agent.current_action
new_location = agent.location
target_name = None
target_name = None
should_update = False
# Phase 22: Handle Pending Conversations (High Priority)
if agent.id in self._active_conversations:
pending = self._active_conversations[agent.id]
# Check expiry
if self._tick_count > pending["expires_at_tick"]:
del self._active_conversations[agent.id]
else:
# Force response
new_action = "Chat"
new_location = agent.location # Stay put
should_update = True
# Generate Response Immediately
partner = db.query(Agent).filter(Agent.id == pending["partner_id"]).first()
if partner:
target_name = partner.name
# Generate reply
# We consume the pending state so we don't loop forever
previous_text = pending["last_text"]
del self._active_conversations[agent.id]
# Maybe add a chance for A to respond back to B (A-B-A)?
# For simplicity, let's just do A-B for now, or 50% chance for A-B-A
should_reply_back = random.random() < 0.5
# Extract values before async task (avoid detached session issues)
asyncio.create_task(self._process_conversation_reply(
agent.id, agent.name, partner.id, partner.name,
previous_text, pending["topic"], should_reply_back
))
else:
del self._active_conversations[agent.id]
# 1. Critical Needs (Override everything)
if world.time_of_day == "night":
elif world.time_of_day == "night":
if agent.current_action != "Sleep":
new_action = "Sleep"
new_location = "campfire"
@@ -739,9 +1136,18 @@ class GameEngine:
friend = random.choice(potential_friends)
new_location = "agent"
target_name = friend.name
target_name = friend.name
should_update = True
# Phase 21: Social Interaction (Group Dance)
# Phase 21-C: Advanced Social Locomotion (Follow)
# If "follower" role (or just feeling social), follow a friend/leader
elif agent.current_action not in ["Sleep", "Gather", "Dance", "Follow"] and random.random() < 0.15:
target = self._find_follow_target(db, agent)
if target:
new_action = "Follow"
new_location = "agent"
target_name = target.name
should_update = True
# If Happy (>80) and near others, chance to start dancing
elif agent.mood > 80 and agent.current_action != "Dance":
# Check for nearby agents (same location)
@@ -764,6 +1170,9 @@ class GameEngine:
new_action = "Wander"
new_location = "nearby" # Will be randomized in Unity/GameManager mapping
should_update = True
# Phase 23: Altruism - Give Item if needed (50% chance per tick to check)
if random.random() < 0.5:
await self._process_altruism_tick()
elif random.random() < 0.1:
new_action = "Idle"
should_update = True
@@ -844,8 +1253,39 @@ class GameEngine:
return random.choice(["Hmm...", "Nice weather.", "Taking a walk."])
elif action == "Wake Up":
return "Good morning!"
elif action == "Wake Up":
return "Good morning!"
elif action == "Dance":
return random.choice(["Party time!", "Let's dance!", "Woo!"])
elif action == "Follow":
return f"Wait for me, {target}!"
return ""
def _find_follow_target(self, db, agent: Agent) -> Optional[Agent]:
"""Find a suitable target to follow (Leader or Friend)."""
# 1. Prefer Leaders
leader = db.query(Agent).filter(
Agent.social_role == "leader",
Agent.status == "Alive",
Agent.id != agent.id
).first()
if leader and random.random() < 0.7:
return leader
# 2. Fallback to Close Friends
rels = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == agent.id,
AgentRelationship.relationship_type.in_(["close_friend", "friend"])
).all()
if rels:
r = random.choice(rels)
target = db.query(Agent).filter(Agent.id == r.agent_to_id, Agent.status == "Alive").first()
return target
return None
# =========================================================================
# Inventory & Crafting (Phase 16)
# =========================================================================
@@ -927,6 +1367,91 @@ class GameEngine:
})
logger.info(f"Agent {agent.name} used medicine and is cured!")
# =========================================================================
# Phase 24: Group Activities & Rituals
# =========================================================================
async def _process_campfire_gathering(self) -> None:
"""Encourage agents to gather at campfire at night."""
with get_db_session() as db:
world = db.query(WorldState).first()
if not world or world.time_of_day != "night":
return
# Only run check occasionally to avoid spamming decision logic every tick if not needed
if self._tick_count % 5 != 0:
return
agents = db.query(Agent).filter(Agent.status == "Alive").all()
for agent in agents:
# If agent is critical, they will prioritize self-preservation in _process_activity_tick
# But if they are just idle or wandering, we nudge them to campfire
if agent.hp < 30 or agent.energy < 20 or agent.is_sick:
continue
# If already there, stay
if agent.location == "campfire":
continue
# Force move to campfire "ritual"
# We update their "current_action" so the next tick they don't override it immediately
# But _process_activity_tick runs based on priorities.
# To make this sticky, we might need a "GroupActivity" state or just rely on
# tweaking the decision logic. For now, let's just forcefully set target if Idle.
if agent.current_action in ["Idle", "Wander"]:
agent.current_action = "Gathering"
agent.location = "campfire" # Teleport logic or Move logic?
# Actually, our decision logic sets location.
# Let's just update location for simplicity as 'walking' is handled by frontend interpolation
# if the distance is small, but massive jumps might look weird.
# Ideally we set a goal. But for this engine, setting location IS the action result usually.
pass
async def _process_group_activity(self) -> None:
"""Trigger storytelling if enough agents are at the campfire."""
# Only at night
with get_db_session() as db:
world = db.query(WorldState).first()
if not world or world.time_of_day != "night":
return
# Low probability check (don't spam stories)
if random.random() > 0.05:
return
# Check who is at campfire
agents_at_fire = db.query(Agent).filter(
Agent.status == "Alive",
Agent.location == "campfire"
).all()
if len(agents_at_fire) < 2:
return
# Select Storyteller (Highest Mood or Extrovert)
storyteller = max(agents_at_fire, key=lambda a: a.mood + (20 if a.social_tendency == 'extrovert' else 0))
listeners = [a for a in agents_at_fire if a.id != storyteller.id]
# Generate Story
topics = ["the ghost ship", "the ancient ruins", "a strange dream", "the day we arrived"]
topic = random.choice(topics)
story_content = await llm_service.generate_story(storyteller.name, topic)
# Broadcast Event
await self._broadcast_event(EventType.GROUP_ACTIVITY, {
"activity_type": "storytelling",
"storyteller_id": storyteller.id,
"storyteller_name": storyteller.name,
"listener_ids": [l.id for l in listeners],
"content": story_content,
"topic": topic
})
# Boost Mood for everyone involved
storyteller.mood = min(100, storyteller.mood + 10)
for listener in listeners:
listener.mood = min(100, listener.mood + 5)
# =========================================================================
# LLM-powered agent speech
# =========================================================================
@@ -938,7 +1463,8 @@ class GameEngine:
"""Fire-and-forget LLM call to generate agent speech."""
try:
class AgentSnapshot:
def __init__(self, name, personality, hp, energy, mood, is_sheltered=False):
def __init__(self, id, name, personality, hp, energy, mood, is_sheltered=False):
self.id = id
self.name = name
self.personality = personality
self.hp = hp
@@ -947,7 +1473,7 @@ class GameEngine:
self.is_sheltered = is_sheltered
agent_snapshot = AgentSnapshot(
agent_name, agent_personality, agent_hp, agent_energy, agent_mood
agent_id, agent_name, agent_personality, agent_hp, agent_energy, agent_mood
)
text = await llm_service.generate_reaction(agent_snapshot, event_description, event_type)
@@ -981,7 +1507,8 @@ class GameEngine:
try:
class AgentSnapshot:
def __init__(self, name, personality, hp, energy, mood, is_sheltered=False):
def __init__(self, id, name, personality, hp, energy, mood, is_sheltered=False):
self.id = id
self.name = name
self.personality = personality
self.hp = hp
@@ -990,7 +1517,7 @@ class GameEngine:
self.is_sheltered = is_sheltered
agent_snapshot = AgentSnapshot(
agent_data["name"], agent_data["personality"],
agent_data["id"], agent_data["name"], agent_data["personality"],
agent_data["hp"], agent_data["energy"], agent_data["mood"],
agent_data.get("is_sheltered", False)
)
@@ -1045,13 +1572,16 @@ class GameEngine:
if feed_result:
await self._broadcast_event(EventType.FEED, {
"user": username, "agent_name": feed_result["agent_name"],
"energy_restored": feed_result["actual_restore"],
"agent_energy": feed_result["agent_energy"], "user_gold": feed_result["user_gold"],
"user": username, "agent_name": feed_result['agent_name'],
"energy_restored": feed_result['actual_restore'],
"agent_energy": feed_result['agent_energy'], "user_gold": feed_result['user_gold'],
"message": f"{username} fed {feed_result['agent_name']}!"
})
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": feed_result["user_gold"]})
# VFX: Food Cloud
await self._broadcast_vfx("food", feed_result["agent_id"], "")
asyncio.create_task(self._trigger_agent_speak(
feed_result["agent_id"], feed_result["agent_name"],
feed_result["agent_personality"], feed_result["agent_hp"],
@@ -1138,6 +1668,43 @@ class GameEngine:
f"User {username} encouraged you!", "encourage"
))
async def _handle_love(self, username: str, agent_name: str) -> None:
"""Handle love command (hearts)."""
with get_db_session() as db:
user = self._get_or_create_user(db, username)
agent = db.query(Agent).filter(Agent.name.ilike(agent_name)).first()
if agent is None:
await self._broadcast_event(EventType.ERROR, {"message": f"Agent '{agent_name}' not found"})
return
if agent.status != "Alive":
await self._broadcast_event(EventType.ERROR, {"message": f"{agent.name} is dead"})
return
if user.gold < LOVE_COST:
await self._broadcast_event(EventType.ERROR, {
"user": username, "message": f"Not enough gold! Need {LOVE_COST}, have {user.gold}"
})
return
user.gold -= LOVE_COST
old_mood = agent.mood
agent.mood = min(100, agent.mood + LOVE_MOOD_BOOST)
# Update relationship (Phase 5 integration: could add affection)
# For now just simple mood boost and FX
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": user.gold})
# VFX: Hearts
await self._broadcast_vfx("heart", agent.id, f"{username} sends love to {agent.name}!")
asyncio.create_task(self._trigger_agent_speak(
agent.id, agent.name, agent.personality, agent.hp, agent.energy, agent.mood,
f"User {username} sent you love!", "love"
))
async def _handle_talk(self, username: str, agent_name: str, topic: str = "") -> None:
"""Handle talk command - free conversation with agent."""
with get_db_session() as db:
@@ -1276,6 +1843,10 @@ class GameEngine:
await self._handle_encourage(user, match.group(1))
return
if match := LOVE_PATTERN.search(message):
await self._handle_love(user, match.group(1))
return
if match := REVIVE_PATTERN.search(message):
await self._handle_revive(user, match.group(1))
return
@@ -1371,6 +1942,20 @@ class GameEngine:
while self._running:
self._tick_count += 1
# Phase 9: Check voting phase (always runs)
await self._process_voting_tick()
# Phase 9: Check if we should trigger a narrative event
if await self._should_trigger_narrative():
await self._trigger_narrative_event()
# Skip simulation processing during narrative/voting/resolution modes
if self._game_mode != GameMode.SIMULATION:
await asyncio.sleep(self._tick_interval)
continue
# ========== SIMULATION MODE PROCESSING ==========
# 1. Advance time (Phase 2)
phase_change = await self._advance_time()
if phase_change:
@@ -1399,12 +1984,21 @@ class GameEngine:
# 5. Update moods (Phase 3)
await self._update_moods()
# Phase 24: Group Activities
# Check for campfire time (Night)
await self._process_campfire_gathering()
# Check for storytelling events
await self._process_group_activity()
# 6. Autonomous Activity (Phase 13)
await self._process_activity_tick()
# 7. Social interactions (Phase 5)
await self._process_social_tick()
# Phase 23: Altruism (Item Exchange)
await self._process_altruism_tick()
# 8. Random Events (Phase 17-C)
await self._process_random_events()
@@ -1434,7 +2028,8 @@ class GameEngine:
"day": day,
"time_of_day": time_of_day,
"weather": weather,
"alive_agents": alive_count
"alive_agents": alive_count,
"game_mode": self._game_mode.value # Phase 9: Include game mode
})
await asyncio.sleep(self._tick_interval)
@@ -1533,3 +2128,57 @@ class GameEngine:
async def process_bits(self, user: str, amount: int) -> None:
"""Deprecated: Use handle_gift instead."""
await self.handle_gift(user, amount, "bits")
async def _process_conversation_reply(
self, responder_id: int, responder_name: str, partner_id: int, partner_name: str,
previous_text: str, topic: str, should_reply_back: bool
) -> None:
"""Handle the secondary turn of a conversation."""
try:
# Relationship
with get_db_session() as db:
rel = db.query(AgentRelationship).filter(
AgentRelationship.agent_from_id == responder_id,
AgentRelationship.agent_to_id == partner_id
).first()
rel_type = rel.relationship_type if rel else "acquaintance"
# Basic world info
world = db.query(WorldState).first()
weather = world.weather if world else "Sunny"
time_of_day = world.time_of_day if world else "day"
# Generate reply
# We use the same generate_social_interaction but with previous_dialogue set
# 'interaction_type' is reused as topic
reply = await llm_service.generate_social_interaction(
initiator_name=responder_name,
target_name=partner_name,
interaction_type=topic,
relationship_type=rel_type,
weather=weather,
time_of_day=time_of_day,
previous_dialogue=previous_text
)
# Broadcast response
await self._broadcast_event(EventType.SOCIAL_INTERACTION, {
"initiator_id": responder_id,
"initiator_name": responder_name,
"target_id": partner_id,
"target_name": partner_name,
"interaction_type": "reply",
"relationship_type": rel_type,
"dialogue": reply
})
# Chain next turn?
if should_reply_back:
self._active_conversations[partner_id] = {
"partner_id": responder_id,
"last_text": reply,
"topic": topic,
"expires_at_tick": self._tick_count + 5 # Must respond within 5 ticks
}
except Exception as e:
logger.error(f"Error in conversation reply: {e}")

View File

@@ -404,7 +404,8 @@ class LLMService:
interaction_type: str,
relationship_type: str,
weather: str = "Sunny",
time_of_day: str = "day"
time_of_day: str = "day",
previous_dialogue: str = None
) -> str:
"""
Generate dialogue for social interaction between two agents.
@@ -467,10 +468,20 @@ class LLMService:
f"{initiator_name} and {target_name} {relationship_desc}. "
f"It is {time_of_day} and the weather is {weather}. "
f"{initiator_name} is {interaction_desc} {target_name}. "
f"Write a brief, natural dialogue exchange (2-3 lines total). "
f"Format: '{initiator_name}: [line]\\n{target_name}: [response]'"
)
if previous_dialogue:
system_prompt += (
f"\nCONTEXT: {target_name} just said: '{previous_dialogue}'\n"
f"Write a response from {initiator_name} to {target_name}. "
f"Format: '{initiator_name}: [response]'"
)
else:
system_prompt += (
f"\nWrite a brief opening dialogue exchange (2-3 lines total). "
f"Format: '{initiator_name}: [line]\\n{target_name}: [response]'"
)
kwargs = {
"model": self._model,
"messages": [
@@ -494,6 +505,52 @@ class LLMService:
logger.error(f"LLM API error for social interaction: {e}")
return f"{initiator_name}: ...\n{target_name}: ..."
async def generate_story(
self,
storyteller_name: str,
topic: str = "ghost_story"
) -> str:
"""
Generate a short story for the campfire.
"""
if self._mock_mode:
stories = [
"Once upon a time, a ship crashed here...",
"The elders say this island is haunted...",
"I saw a strange light in the forest yesterday..."
]
return random.choice(stories)
try:
system_prompt = (
f"You are {storyteller_name}, a survivor telling a story at a campfire. "
f"Topic: {topic}. "
f"Keep it short (2-3 sentences), mysterious, and atmospheric."
)
kwargs = {
"model": self._model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": "Tell us a story."}
],
"max_tokens": 100,
"temperature": 1.0,
}
if self._api_base:
kwargs["api_base"] = self._api_base
if self._api_key and not self._api_key_header:
kwargs["api_key"] = self._api_key
if self._extra_headers:
kwargs["extra_headers"] = self._extra_headers
response = await self._acompletion(**kwargs)
return response.choices[0].message.content.strip()
except Exception as e:
logger.error(f"LLM API error for story: {e}")
return "It was a dark and stormy night..."
async def generate_gratitude(
self,
user: str,

View File

@@ -55,6 +55,26 @@ class EventType(str, Enum):
# Random Events (Phase 17-C)
RANDOM_EVENT = "random_event" # Random event occurred
# Economy (Phase 23)
GIVE_ITEM = "give_item" # Agent gives item to another
# Group Activities (Phase 24)
# Group Activities (Phase 24)
GROUP_ACTIVITY = "group_activity" # Storytelling, dancing, etc.
# VFX & Gifts (Phase 8)
VFX_EVENT = "vfx_event" # Visual effect trigger
GIFT_EFFECT = "gift_effect" # Twitch bits/sub effect
# AI Director & Narrative Voting (Phase 9)
MODE_CHANGE = "mode_change" # Game mode transition
NARRATIVE_PLOT = "narrative_plot" # Director generated plot point
VOTE_STARTED = "vote_started" # Voting session started
VOTE_UPDATE = "vote_update" # Real-time vote count update
VOTE_ENDED = "vote_ended" # Voting closed
VOTE_RESULT = "vote_result" # Final voting result
RESOLUTION_APPLIED = "resolution_applied" # Plot resolution executed
class GameEvent(BaseModel):
"""

View File

@@ -74,6 +74,18 @@ class TwitchBot(commands.Bot):
# Log the message for debugging
logger.info(f"Twitch chat [{username}]: {content}")
# Phase 9: Check for vote commands first (!1, !2, !A, !B)
vote_index = self._game_engine.parse_vote_command(content)
if vote_index is not None:
try:
voted = await self._game_engine.process_vote(username, vote_index, "twitch")
if voted:
logger.info(f"Vote recorded: {username} -> option {vote_index + 1}")
return # Don't process as regular command
except Exception as e:
logger.error(f"Error processing vote: {e}")
return
# Forward to game engine for command processing
try:
await self._game_engine.process_command(username, content)

445
backend/app/vote_manager.py Normal file
View File

@@ -0,0 +1,445 @@
"""
Vote Manager - Audience Voting System (Phase 9).
Manages voting sessions for narrative decisions,
supporting both Twitch chat commands and Unity client votes.
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Awaitable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class VoteOption:
"""A voting option."""
choice_id: str
text: str
@dataclass
class VoteSession:
"""An active voting session."""
vote_id: str
options: list[VoteOption]
start_ts: float
end_ts: float
duration_seconds: int = 60 # Store actual duration for this session
votes_by_user: dict[str, int] = field(default_factory=dict) # user_id -> choice_index
tallies: list[int] = field(default_factory=list) # vote count per option
@dataclass(frozen=True)
class VoteSnapshot:
"""Real-time voting statistics snapshot."""
vote_id: str
tallies: list[int]
percentages: list[float]
total_votes: int
remaining_seconds: float
ends_at: float
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for broadcasting."""
return {
"vote_id": self.vote_id,
"tallies": self.tallies,
"percentages": self.percentages,
"total_votes": self.total_votes,
"remaining_seconds": max(0, self.remaining_seconds),
"ends_at": self.ends_at,
}
@dataclass(frozen=True)
class VoteResult:
"""Final voting result after session ends."""
vote_id: str
winning_choice_id: str
winning_choice_text: str
winning_index: int
tallies: list[int]
percentages: list[float]
total_votes: int
is_tie: bool = False
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for broadcasting."""
return {
"vote_id": self.vote_id,
"winning_choice_id": self.winning_choice_id,
"winning_choice_text": self.winning_choice_text,
"winning_index": self.winning_index,
"tallies": self.tallies,
"percentages": self.percentages,
"total_votes": self.total_votes,
"is_tie": self.is_tie,
}
# Twitch command patterns
VOTE_PATTERN_NUMERIC = re.compile(r"^!([1-9])$") # !1, !2, etc.
VOTE_PATTERN_ALPHA = re.compile(r"^!([AaBb])$") # !A, !B, etc.
class VoteManager:
"""
Manages voting sessions with dual-channel support (Twitch + Unity).
Features:
- Real-time vote counting
- Vote changing (users can change their vote)
- Automatic session expiration
- Periodic snapshot broadcasting
"""
def __init__(
self,
duration_seconds: int = 60,
broadcast_interval: float = 1.0,
) -> None:
"""
Initialize the vote manager.
Args:
duration_seconds: Default voting window duration
broadcast_interval: How often to broadcast vote updates (seconds)
"""
self._duration_seconds = duration_seconds
self._broadcast_interval = broadcast_interval
self._current: VoteSession | None = None
self._broadcast_callback: Callable[[VoteSnapshot], Awaitable[None]] | None = None
self._broadcast_task: asyncio.Task | None = None
@property
def is_voting_active(self) -> bool:
"""Check if a voting session is currently active."""
if not self._current:
return False
return time.time() < self._current.end_ts
@property
def current_session(self) -> VoteSession | None:
"""Get the current voting session."""
return self._current
def set_broadcast_callback(
self,
callback: Callable[[VoteSnapshot], Awaitable[None]],
) -> None:
"""
Set the callback for broadcasting vote updates.
Args:
callback: Async function that receives VoteSnapshot and broadcasts it
"""
self._broadcast_callback = callback
def start_vote(
self,
options: list[VoteOption],
duration_seconds: int | None = None,
now: float | None = None,
) -> VoteSession:
"""
Start a new voting session.
Args:
options: List of voting options (minimum 2)
duration_seconds: Override default duration
now: Override current timestamp (for testing)
Returns:
The created VoteSession
"""
if len(options) < 2:
raise ValueError("Voting requires at least 2 options")
now = now or time.time()
duration = duration_seconds or self._duration_seconds
session = VoteSession(
vote_id=uuid.uuid4().hex,
options=options,
start_ts=now,
end_ts=now + duration,
duration_seconds=duration,
tallies=[0 for _ in options],
)
self._current = session
# Start broadcast loop
if self._broadcast_callback:
self._start_broadcast_loop()
logger.info(
f"Vote started: {session.vote_id} with {len(options)} options, "
f"duration={duration}s"
)
return session
def _start_broadcast_loop(self) -> None:
"""Start the periodic broadcast task."""
if self._broadcast_task and not self._broadcast_task.done():
self._broadcast_task.cancel()
async def broadcast_loop():
try:
while self.is_voting_active:
snapshot = self.snapshot()
if snapshot and self._broadcast_callback:
try:
await self._broadcast_callback(snapshot)
except Exception as e:
logger.error(f"Broadcast callback error: {e}")
await asyncio.sleep(self._broadcast_interval)
except asyncio.CancelledError:
pass
self._broadcast_task = asyncio.create_task(broadcast_loop())
def parse_twitch_message(self, content: str) -> int | None:
"""
Parse a Twitch chat message for vote commands.
Supported formats:
- !1, !2, !3, etc. (1-indexed, converted to 0-indexed)
- !A, !B (converted to 0, 1)
Args:
content: The chat message content
Returns:
Choice index (0-indexed) or None if not a vote command
"""
text = content.strip()
# Try numeric pattern first
match = VOTE_PATTERN_NUMERIC.match(text)
if match:
return int(match.group(1)) - 1 # Convert to 0-indexed
# Try alphabetic pattern
match = VOTE_PATTERN_ALPHA.match(text)
if match:
letter = match.group(1).upper()
return ord(letter) - ord('A') # A=0, B=1
return None
def cast_vote(
self,
voter_id: str,
choice_index: int,
source: str = "twitch",
) -> bool:
"""
Record a vote from a user.
Users can change their vote - the previous vote is subtracted
and the new vote is added.
Args:
voter_id: Unique identifier for the voter
choice_index: 0-indexed choice number
source: Vote source ("twitch" or "unity")
Returns:
True if vote was recorded, False if invalid or session ended
"""
if not self._current:
logger.debug(f"Vote rejected: no active session (voter={voter_id})")
return False
if time.time() > self._current.end_ts:
logger.debug(f"Vote rejected: session ended (voter={voter_id})")
return False
if choice_index < 0 or choice_index >= len(self._current.options):
logger.debug(
f"Vote rejected: invalid choice {choice_index} "
f"(voter={voter_id}, max={len(self._current.options)-1})"
)
return False
# Normalize voter ID (Twitch usernames are case-insensitive)
normalized_voter_id = voter_id.strip().lower()
if not normalized_voter_id:
logger.debug("Vote rejected: empty voter id")
return False
# Handle vote change - subtract previous vote
previous = self._current.votes_by_user.get(normalized_voter_id)
if previous is not None:
if previous == choice_index:
# Same vote, no change needed
return True
# Subtract old vote
self._current.tallies[previous] = max(
0, self._current.tallies[previous] - 1
)
logger.debug(f"Vote changed: {normalized_voter_id} from {previous} to {choice_index}")
# Record new vote
self._current.votes_by_user[normalized_voter_id] = choice_index
self._current.tallies[choice_index] += 1
logger.debug(
f"Vote cast: {voter_id} -> {choice_index} "
f"(source={source}, tallies={self._current.tallies})"
)
return True
def snapshot(self, now: float | None = None) -> VoteSnapshot | None:
"""
Generate a real-time snapshot of current voting status.
Args:
now: Override current timestamp (for testing)
Returns:
VoteSnapshot or None if no active session
"""
if not self._current:
return None
now = now or time.time()
tallies = list(self._current.tallies)
total = sum(tallies)
# Calculate percentages
if total > 0:
percentages = [round((t / total) * 100, 1) for t in tallies]
else:
percentages = [0.0 for _ in tallies]
return VoteSnapshot(
vote_id=self._current.vote_id,
tallies=tallies,
percentages=percentages,
total_votes=total,
remaining_seconds=self._current.end_ts - now,
ends_at=self._current.end_ts,
)
def maybe_finalize(self, now: float | None = None) -> VoteResult | None:
"""
Check if voting has ended and finalize results.
Args:
now: Override current timestamp (for testing)
Returns:
VoteResult if voting ended, None if still active
"""
if not self._current:
return None
now = now or time.time()
if now < self._current.end_ts:
return None
# Cancel broadcast task
if self._broadcast_task and not self._broadcast_task.done():
self._broadcast_task.cancel()
# Calculate final results
tallies = list(self._current.tallies)
total = sum(tallies)
# Calculate percentages
if total > 0:
percentages = [round((t / total) * 100, 1) for t in tallies]
else:
percentages = [0.0 for _ in tallies]
# Find winner
if tallies:
max_votes = max(tallies)
winners = [i for i, t in enumerate(tallies) if t == max_votes]
is_tie = len(winners) > 1
# In case of tie, choose randomly (or could defer to Director)
import random
winning_index = random.choice(winners) if is_tie else winners[0]
else:
winning_index = 0
is_tie = False
winning_option = self._current.options[winning_index]
result = VoteResult(
vote_id=self._current.vote_id,
winning_choice_id=winning_option.choice_id,
winning_choice_text=winning_option.text,
winning_index=winning_index,
tallies=tallies,
percentages=percentages,
total_votes=total,
is_tie=is_tie,
)
logger.info(
f"Vote finalized: {result.vote_id} "
f"winner={result.winning_choice_id} ({result.winning_choice_text}) "
f"votes={result.tallies} tie={result.is_tie}"
)
# Clear current session
self._current = None
return result
def cancel_vote(self) -> bool:
"""
Cancel the current voting session.
Returns:
True if a session was cancelled, False if no active session
"""
if not self._current:
return False
if self._broadcast_task and not self._broadcast_task.done():
self._broadcast_task.cancel()
vote_id = self._current.vote_id
self._current = None
logger.info(f"Vote cancelled: {vote_id}")
return True
def get_vote_started_data(self) -> dict[str, Any] | None:
"""
Get data for VOTE_STARTED event.
Returns:
Dictionary with vote session info, or None if no active session
"""
if not self._current:
return None
return {
"vote_id": self._current.vote_id,
"choices": [
{"choice_id": o.choice_id, "text": o.text}
for o in self._current.options
],
"duration_seconds": self._current.duration_seconds,
"ends_at": self._current.end_ts,
"source": "director",
}
# Global instance
vote_manager = VoteManager()

View File

@@ -158,6 +158,9 @@ namespace TheIsland.Core
network.OnGiftEffect += HandleGiftEffect; // Phase 8
network.OnAgentAction += HandleAgentAction; // Phase 13
network.OnRandomEvent += HandleRandomEvent; // Phase 17-C
network.OnGiveItem += HandleGiveItem; // Phase 23
network.OnGroupActivity += HandleGroupActivity; // Phase 24
network.OnVFXEvent += HandleVFXEvent; // Phase 8
}
private void UnsubscribeFromNetworkEvents()
@@ -186,6 +189,9 @@ namespace TheIsland.Core
network.OnSocialInteraction -= HandleSocialInteraction;
network.OnGiftEffect -= HandleGiftEffect; // Phase 8
network.OnRandomEvent -= HandleRandomEvent; // Phase 17-C
network.OnGiveItem -= HandleGiveItem; // Phase 23
network.OnGroupActivity -= HandleGroupActivity; // Phase 24
network.OnVFXEvent -= HandleVFXEvent; // Phase 8
}
#endregion
@@ -447,10 +453,84 @@ namespace TheIsland.Core
_playerGold = data.user_gold;
UpdateGoldDisplay();
}
ShowNotification(data.message);
}
private void HandleGiveItem(GiveItemEventData data)
{
Debug.Log($"[GameManager] Give Item: {data.message}");
ShowNotification(data.message);
if (_agentVisuals.TryGetValue(data.from_id, out AgentVisual fromVisual) &&
_agentVisuals.TryGetValue(data.to_id, out AgentVisual toVisual))
{
// Trigger Visual Effect (Phase 23)
// We assume AgentVisual has DoGiveItem method
fromVisual.DoGiveItem(toVisual.transform, data.item_type);
}
}
private void HandleGroupActivity(GroupActivityEventData data)
{
Debug.Log($"[GameManager] Group Activity: {data.activity_type} with {data.storyteller_name}");
if (data.activity_type == "storytelling")
{
ShowNotification($"🔥 {data.storyteller_name} tells a story about {data.topic}...");
// Storyteller Visuals
if (_agentVisuals.TryGetValue(data.storyteller_id, out AgentVisual storyteller))
{
storyteller.ShowSpeech(data.content, 8f); // Long duration for story
storyteller.DoStorytelling(); // Animation trigger
}
// Listener Visuals
if (data.listener_ids != null)
{
foreach (int listenerId in data.listener_ids)
{
if (_agentVisuals.TryGetValue(listenerId, out AgentVisual listener))
{
// Listeners face the storyteller and show interest
if (storyteller != null)
{
listener.DoListen(storyteller.transform);
}
}
}
}
}
}
private void HandleVFXEvent(VFXEventData data)
{
Debug.Log($"[GameManager] VFX: {data.effect}");
if (!string.IsNullOrEmpty(data.message))
{
ShowNotification(data.message);
}
Vector3 targetPos = new Vector3(0, 2, 0); // Default air center
if (data.target_id > 0)
{
if (_agentVisuals.TryGetValue(data.target_id, out AgentVisual visual))
{
targetPos = visual.transform.position;
}
else if (_agentUIs.TryGetValue(data.target_id, out AgentUI ui))
{
targetPos = ui.transform.position;
}
}
if (VFXManager.Instance != null)
{
VFXManager.Instance.PlayEffect(data.effect, targetPos);
}
}
private void HandleTalk(TalkEventData data)
{
Debug.Log($"[GameManager] Talk event: {data.agent_name} responds about '{data.topic}'");
@@ -553,7 +633,22 @@ namespace TheIsland.Core
// Find agent and command movement
if (_agentVisuals.TryGetValue(data.agent_id, out AgentVisual agentVisual))
{
agentVisual.MoveTo(targetPos);
// Phase 21-C: Handle Follow/Target Logic
if (data.action_type == "Follow" && !string.IsNullOrEmpty(data.target_name))
{
int targetId = GetAgentIdByName(data.target_name);
if (targetId >= 0 && _agentVisuals.TryGetValue(targetId, out AgentVisual targetVisual))
{
agentVisual.SetFollowTarget(targetVisual.transform);
Debug.Log($"[GameManager] {data.agent_name} is now following {data.target_name}");
}
}
else
{
// Clear follow target for non-follow actions
agentVisual.SetFollowTarget(null);
agentVisual.MoveTo(targetPos);
}
// Optional: Show thought bubble or speech
if (!string.IsNullOrEmpty(data.dialogue))

View File

@@ -353,6 +353,24 @@ namespace TheIsland.Models
// Random Events (Phase 17-C)
public const string RANDOM_EVENT = "random_event";
// Economy (Phase 23)
public const string GIVE_ITEM = "give_item";
// Group Activities (Phase 24)
public const string GROUP_ACTIVITY = "group_activity";
// Phase 8: VFX
public const string VFX_EVENT = "vfx_event";
// AI Director & Narrative Voting (Phase 9)
public const string MODE_CHANGE = "mode_change";
public const string NARRATIVE_PLOT = "narrative_plot";
public const string VOTE_STARTED = "vote_started";
public const string VOTE_UPDATE = "vote_update";
public const string VOTE_ENDED = "vote_ended";
public const string VOTE_RESULT = "vote_result";
public const string RESOLUTION_APPLIED = "resolution_applied";
}
/// <summary>
@@ -403,4 +421,145 @@ namespace TheIsland.Models
public string message;
public string agent_name; // Optional: affected agent
}
/// <summary>
/// Give item event data (Phase 23).
/// </summary>
[Serializable]
public class GiveItemEventData
{
public int from_id;
public int to_id;
public string item_type; // "herb", "food", "medicine"
public string message;
}
/// <summary>
/// Group Activity event data (Phase 24).
/// </summary>
[Serializable]
public class GroupActivityEventData
{
public string activity_type; // "storytelling"
public int storyteller_id;
public string storyteller_name;
public List<int> listener_ids;
public string content; // The story text
public string topic;
}
/// <summary>
/// VFX event data (Phase 8).
/// </summary>
[Serializable]
public class VFXEventData
{
public string effect; // "gold_rain", "heart", "food"
public int target_id; // Optional: if -1 or 0, might mean global or specific position logic
public string message;
}
// =========================================================================
// AI Director & Narrative Voting (Phase 9)
// =========================================================================
/// <summary>
/// Mode change event data.
/// </summary>
[Serializable]
public class ModeChangeData
{
public string mode; // "simulation", "narrative", "voting", "resolution"
public string old_mode;
public string message;
public double ends_at; // Timestamp when this mode ends (for voting)
}
/// <summary>
/// Choice option in a plot point.
/// </summary>
[Serializable]
public class PlotChoiceData
{
public string choice_id;
public string text;
}
/// <summary>
/// Narrative plot event data.
/// </summary>
[Serializable]
public class NarrativePlotData
{
public string plot_id;
public string title;
public string description;
public List<PlotChoiceData> choices;
public int ttl_seconds;
}
/// <summary>
/// Vote started event data.
/// </summary>
[Serializable]
public class VoteStartedData
{
public string vote_id;
public List<PlotChoiceData> choices;
public int duration_seconds;
public double ends_at;
public string source;
}
/// <summary>
/// Real-time vote update data.
/// </summary>
[Serializable]
public class VoteUpdateData
{
public string vote_id;
public List<int> tallies;
public List<float> percentages;
public int total_votes;
public float remaining_seconds;
public double ends_at;
}
/// <summary>
/// Vote ended event data.
/// </summary>
[Serializable]
public class VoteEndedData
{
public string vote_id;
public int total_votes;
}
/// <summary>
/// Final voting result data.
/// </summary>
[Serializable]
public class VoteResultData
{
public string vote_id;
public string winning_choice_id;
public string winning_choice_text;
public int winning_index;
public List<int> tallies;
public List<float> percentages;
public int total_votes;
public bool is_tie;
}
/// <summary>
/// Resolution applied event data.
/// </summary>
[Serializable]
public class ResolutionAppliedData
{
public string plot_id;
public string choice_id;
public string message;
public string effects_json;
}
}

View File

@@ -0,0 +1,588 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using TheIsland.Models;
using TheIsland.Network;
namespace TheIsland.UI
{
/// <summary>
/// Cinematic narrative UI overlay for AI Director events and voting.
/// Handles plot cards, voting bars, and resolution displays.
/// </summary>
public class NarrativeUI : MonoBehaviour
{
#region Singleton
private static NarrativeUI _instance;
public static NarrativeUI Instance => _instance;
#endregion
#region UI References
[Header("Main Panel")]
[SerializeField] private CanvasGroup mainPanel;
[SerializeField] private Image backgroundOverlay;
[Header("Event Card")]
[SerializeField] private RectTransform eventCard;
[SerializeField] private TextMeshProUGUI titleText;
[SerializeField] private TextMeshProUGUI descriptionText;
[Header("Voting Panel")]
[SerializeField] private RectTransform votingPanel;
[SerializeField] private TextMeshProUGUI timerText;
[SerializeField] private TextMeshProUGUI totalVotesText;
[Header("Choice A")]
[SerializeField] private RectTransform choiceAContainer;
[SerializeField] private TextMeshProUGUI choiceAText;
[SerializeField] private Image choiceABar;
[SerializeField] private TextMeshProUGUI choiceAPercentText;
[Header("Choice B")]
[SerializeField] private RectTransform choiceBContainer;
[SerializeField] private TextMeshProUGUI choiceBText;
[SerializeField] private Image choiceBBar;
[SerializeField] private TextMeshProUGUI choiceBPercentText;
[Header("Result Panel")]
[SerializeField] private RectTransform resultPanel;
[SerializeField] private TextMeshProUGUI resultTitleText;
[SerializeField] private TextMeshProUGUI resultMessageText;
[Header("Animation Settings")]
[SerializeField] private float fadeInDuration = 0.5f;
[SerializeField] private float fadeOutDuration = 0.3f;
[SerializeField] private float cardSlideDistance = 100f;
[SerializeField] private float barAnimationSpeed = 5f;
#endregion
#region State
private bool isActive = false;
private string currentPlotId;
private float targetChoiceAPercent = 0f;
private float targetChoiceBPercent = 0f;
private float currentChoiceAPercent = 0f;
private float currentChoiceBPercent = 0f;
private double votingEndsAt = 0;
private Coroutine timerCoroutine;
private bool isSubscribed = false;
private Coroutine subscribeCoroutine;
#endregion
#region Unity Lifecycle
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
// Initialize UI state
if (mainPanel != null) mainPanel.alpha = 0;
if (mainPanel != null) mainPanel.blocksRaycasts = false;
HideAllPanels();
}
private void OnEnable()
{
// Start coroutine to subscribe when NetworkManager is ready
subscribeCoroutine = StartCoroutine(SubscribeWhenReady());
}
private void OnDisable()
{
// Stop subscribe coroutine if running
if (subscribeCoroutine != null)
{
StopCoroutine(subscribeCoroutine);
subscribeCoroutine = null;
}
// Unsubscribe from network events
UnsubscribeFromNetwork();
}
private IEnumerator SubscribeWhenReady()
{
// Wait until NetworkManager is available
while (NetworkManager.Instance == null)
{
yield return null;
}
SubscribeToNetwork();
}
private void SubscribeToNetwork()
{
if (isSubscribed) return;
var network = NetworkManager.Instance;
if (network == null) return;
network.OnModeChange += HandleModeChange;
network.OnNarrativePlot += HandleNarrativePlot;
network.OnVoteStarted += HandleVoteStarted;
network.OnVoteUpdate += HandleVoteUpdate;
network.OnVoteResult += HandleVoteResult;
network.OnResolutionApplied += HandleResolutionApplied;
isSubscribed = true;
}
private void UnsubscribeFromNetwork()
{
if (!isSubscribed) return;
var network = NetworkManager.Instance;
if (network == null)
{
isSubscribed = false;
return;
}
network.OnModeChange -= HandleModeChange;
network.OnNarrativePlot -= HandleNarrativePlot;
network.OnVoteStarted -= HandleVoteStarted;
network.OnVoteUpdate -= HandleVoteUpdate;
network.OnVoteResult -= HandleVoteResult;
network.OnResolutionApplied -= HandleResolutionApplied;
isSubscribed = false;
}
private void Update()
{
// Smoothly animate voting bars
if (isActive && votingPanel != null && votingPanel.gameObject.activeSelf)
{
AnimateVotingBars();
}
}
#endregion
#region Event Handlers
private void HandleModeChange(ModeChangeData data)
{
Debug.Log($"[NarrativeUI] Mode changed: {data.old_mode} -> {data.mode}");
switch (data.mode)
{
case "simulation":
// Fade out UI when returning to simulation
if (isActive)
{
StartCoroutine(FadeOutUI());
}
break;
case "narrative":
case "voting":
// Ensure UI is visible
if (!isActive)
{
StartCoroutine(FadeInUI());
}
break;
case "resolution":
// Keep UI visible for resolution display
break;
}
}
private void HandleNarrativePlot(NarrativePlotData data)
{
Debug.Log($"[NarrativeUI] Narrative plot: {data.title}");
currentPlotId = data.plot_id;
// Show event card
ShowEventCard(data.title, data.description);
// Prepare voting choices
if (data.choices != null && data.choices.Count >= 2)
{
SetupVotingChoices(data.choices[0].text, data.choices[1].text);
}
}
private void HandleVoteStarted(VoteStartedData data)
{
Debug.Log($"[NarrativeUI] Vote started: {data.vote_id}");
votingEndsAt = data.ends_at;
// Setup choices if not already done
if (data.choices != null && data.choices.Count >= 2)
{
SetupVotingChoices(data.choices[0].text, data.choices[1].text);
}
// Show voting panel
ShowVotingPanel();
// Start countdown timer
if (timerCoroutine != null) StopCoroutine(timerCoroutine);
timerCoroutine = StartCoroutine(UpdateTimer());
}
private void HandleVoteUpdate(VoteUpdateData data)
{
// Update target percentages for smooth animation
if (data.percentages != null && data.percentages.Count >= 2)
{
targetChoiceAPercent = data.percentages[0];
targetChoiceBPercent = data.percentages[1];
}
// Sync timer with server's remaining_seconds to avoid clock drift
if (data.remaining_seconds > 0)
{
double now = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
votingEndsAt = now + data.remaining_seconds;
}
// Update total votes display
if (totalVotesText != null)
{
totalVotesText.text = $"{data.total_votes} votes";
}
}
private void HandleVoteResult(VoteResultData data)
{
Debug.Log($"[NarrativeUI] Vote result: {data.winning_choice_text}");
// Stop timer
if (timerCoroutine != null)
{
StopCoroutine(timerCoroutine);
timerCoroutine = null;
}
// Flash winning choice
StartCoroutine(FlashWinningChoice(data.winning_index));
// Show result briefly
ShowResult($"The Audience Has Spoken!", data.winning_choice_text);
}
private void HandleResolutionApplied(ResolutionAppliedData data)
{
Debug.Log($"[NarrativeUI] Resolution: {data.message}");
// Update result display with full resolution message
ShowResult("Consequence", data.message);
// Auto-hide after delay
StartCoroutine(HideAfterDelay(5f));
}
#endregion
#region UI Control Methods
private void HideAllPanels()
{
if (eventCard != null) eventCard.gameObject.SetActive(false);
if (votingPanel != null) votingPanel.gameObject.SetActive(false);
if (resultPanel != null) resultPanel.gameObject.SetActive(false);
}
private void ShowEventCard(string title, string description)
{
if (eventCard == null) return;
// Set content
if (titleText != null) titleText.text = title;
if (descriptionText != null) descriptionText.text = description;
// Show card with animation
eventCard.gameObject.SetActive(true);
StartCoroutine(SlideInCard(eventCard));
}
private void SetupVotingChoices(string choiceA, string choiceB)
{
if (choiceAText != null) choiceAText.text = $"!1 {choiceA}";
if (choiceBText != null) choiceBText.text = $"!2 {choiceB}";
// Reset percentages
targetChoiceAPercent = 50f;
targetChoiceBPercent = 50f;
currentChoiceAPercent = 50f;
currentChoiceBPercent = 50f;
UpdateVotingBarsImmediate();
}
private void ShowVotingPanel()
{
if (votingPanel == null) return;
votingPanel.gameObject.SetActive(true);
StartCoroutine(SlideInCard(votingPanel));
}
private void ShowResult(string title, string message)
{
if (resultPanel == null) return;
// Hide other panels
if (eventCard != null) eventCard.gameObject.SetActive(false);
if (votingPanel != null) votingPanel.gameObject.SetActive(false);
// Set content
if (resultTitleText != null) resultTitleText.text = title;
if (resultMessageText != null) resultMessageText.text = message;
// Show result
resultPanel.gameObject.SetActive(true);
StartCoroutine(SlideInCard(resultPanel));
}
private void AnimateVotingBars()
{
// Smoothly interpolate bar widths
currentChoiceAPercent = Mathf.Lerp(
currentChoiceAPercent,
targetChoiceAPercent,
Time.deltaTime * barAnimationSpeed
);
currentChoiceBPercent = Mathf.Lerp(
currentChoiceBPercent,
targetChoiceBPercent,
Time.deltaTime * barAnimationSpeed
);
UpdateVotingBarsImmediate();
}
private void UpdateVotingBarsImmediate()
{
// Update bar fill amounts (assuming horizontal fill)
if (choiceABar != null)
{
choiceABar.fillAmount = currentChoiceAPercent / 100f;
}
if (choiceBBar != null)
{
choiceBBar.fillAmount = currentChoiceBPercent / 100f;
}
// Update percentage texts
if (choiceAPercentText != null)
{
choiceAPercentText.text = $"{Mathf.RoundToInt(currentChoiceAPercent)}%";
}
if (choiceBPercentText != null)
{
choiceBPercentText.text = $"{Mathf.RoundToInt(currentChoiceBPercent)}%";
}
}
#endregion
#region Coroutines
private IEnumerator FadeInUI()
{
isActive = true;
if (mainPanel == null) yield break;
mainPanel.blocksRaycasts = true;
float elapsed = 0f;
while (elapsed < fadeInDuration)
{
elapsed += Time.deltaTime;
mainPanel.alpha = Mathf.Lerp(0f, 1f, elapsed / fadeInDuration);
yield return null;
}
mainPanel.alpha = 1f;
// Darken background
if (backgroundOverlay != null)
{
Color c = backgroundOverlay.color;
c.a = 0.6f;
backgroundOverlay.color = c;
}
}
private IEnumerator FadeOutUI()
{
if (mainPanel == null)
{
isActive = false;
yield break;
}
float elapsed = 0f;
float startAlpha = mainPanel.alpha;
while (elapsed < fadeOutDuration)
{
elapsed += Time.deltaTime;
mainPanel.alpha = Mathf.Lerp(startAlpha, 0f, elapsed / fadeOutDuration);
yield return null;
}
mainPanel.alpha = 0f;
mainPanel.blocksRaycasts = false;
isActive = false;
HideAllPanels();
}
private IEnumerator SlideInCard(RectTransform card)
{
if (card == null) yield break;
Vector2 startPos = card.anchoredPosition;
Vector2 targetPos = startPos;
startPos.y -= cardSlideDistance;
card.anchoredPosition = startPos;
float elapsed = 0f;
while (elapsed < fadeInDuration)
{
elapsed += Time.deltaTime;
float t = elapsed / fadeInDuration;
// Ease out cubic
t = 1f - Mathf.Pow(1f - t, 3f);
card.anchoredPosition = Vector2.Lerp(startPos, targetPos, t);
yield return null;
}
card.anchoredPosition = targetPos;
}
private IEnumerator UpdateTimer()
{
while (votingEndsAt > 0)
{
double now = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds();
double remaining = votingEndsAt - now;
if (remaining <= 0)
{
if (timerText != null) timerText.text = "0s";
break;
}
if (timerText != null)
{
timerText.text = $"{Mathf.CeilToInt((float)remaining)}s";
}
yield return new WaitForSeconds(0.1f);
}
}
private IEnumerator FlashWinningChoice(int winningIndex)
{
// Flash the winning choice bar
Image winningBar = winningIndex == 0 ? choiceABar : choiceBBar;
if (winningBar == null) yield break;
Color originalColor = winningBar.color;
Color flashColor = Color.yellow;
for (int i = 0; i < 3; i++)
{
winningBar.color = flashColor;
yield return new WaitForSeconds(0.15f);
winningBar.color = originalColor;
yield return new WaitForSeconds(0.15f);
}
}
private IEnumerator HideAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
// The mode will change to simulation, which will trigger fade out
// But we can also force it here as a fallback
if (isActive)
{
StartCoroutine(FadeOutUI());
}
}
#endregion
#region Public Methods
/// <summary>
/// Force show the narrative UI (for testing).
/// </summary>
public void ForceShow()
{
StartCoroutine(FadeInUI());
}
/// <summary>
/// Force hide the narrative UI.
/// </summary>
public void ForceHide()
{
StartCoroutine(FadeOutUI());
}
/// <summary>
/// Test the UI with sample data.
/// </summary>
[ContextMenu("Test Narrative UI")]
public void TestUI()
{
// Create test data
var plotData = new NarrativePlotData
{
plot_id = "test_001",
title = "Mysterious Footprints",
description = "Strange footprints appear on the beach. Someone has been watching...",
choices = new List<PlotChoiceData>
{
new PlotChoiceData { choice_id = "investigate", text = "Follow the tracks" },
new PlotChoiceData { choice_id = "fortify", text = "Strengthen defenses" }
},
ttl_seconds = 60
};
// Simulate events
HandleModeChange(new ModeChangeData { mode = "narrative", old_mode = "simulation", message = "Director intervenes..." });
HandleNarrativePlot(plotData);
// Simulate vote start after delay
StartCoroutine(SimulateVoting());
}
private IEnumerator SimulateVoting()
{
yield return new WaitForSeconds(2f);
HandleVoteStarted(new VoteStartedData
{
vote_id = "test_vote",
duration_seconds = 30,
ends_at = System.DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 30,
choices = new List<PlotChoiceData>
{
new PlotChoiceData { choice_id = "investigate", text = "Follow the tracks" },
new PlotChoiceData { choice_id = "fortify", text = "Strengthen defenses" }
}
});
// Simulate vote updates
for (int i = 0; i < 10; i++)
{
yield return new WaitForSeconds(1f);
HandleVoteUpdate(new VoteUpdateData
{
vote_id = "test_vote",
tallies = new List<int> { Random.Range(10, 50), Random.Range(10, 50) },
percentages = new List<float> { Random.Range(30f, 70f), Random.Range(30f, 70f) },
total_votes = Random.Range(20, 100),
remaining_seconds = 30 - i
});
}
}
#endregion
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7748b2f53bcb247f8a0e6707b1ecb1ce

View File

@@ -72,6 +72,18 @@ namespace TheIsland.Network
public event Action<CraftEventData> OnCraft; // Phase 16: Crafting
public event Action<UseItemEventData> OnUseItem; // Phase 16: Using items
public event Action<RandomEventData> OnRandomEvent; // Phase 17-C: Random Events
public event Action<GiveItemEventData> OnGiveItem; // Phase 23: Item Exchange
public event Action<GroupActivityEventData> OnGroupActivity; // Phase 24: Group Activities
public event Action<VFXEventData> OnVFXEvent; // Phase 8: VFX
// AI Director & Narrative Voting (Phase 9)
public event Action<ModeChangeData> OnModeChange;
public event Action<NarrativePlotData> OnNarrativePlot;
public event Action<VoteStartedData> OnVoteStarted;
public event Action<VoteUpdateData> OnVoteUpdate;
public event Action<VoteEndedData> OnVoteEnded;
public event Action<VoteResultData> OnVoteResult;
public event Action<ResolutionAppliedData> OnResolutionApplied;
#endregion
#region Private Fields
@@ -379,6 +391,57 @@ namespace TheIsland.Network
Debug.Log($"[Random Event] {randomEventData.event_type}: {randomEventData.message}");
break;
case EventTypes.GIVE_ITEM:
var giveData = JsonUtility.FromJson<GiveItemEventData>(dataJson);
OnGiveItem?.Invoke(giveData);
break;
case EventTypes.GROUP_ACTIVITY:
var groupData = JsonUtility.FromJson<GroupActivityEventData>(dataJson);
OnGroupActivity?.Invoke(groupData);
break;
case EventTypes.VFX_EVENT:
var vfxData = JsonUtility.FromJson<VFXEventData>(dataJson);
OnVFXEvent?.Invoke(vfxData);
break;
// AI Director & Narrative Voting (Phase 9)
case EventTypes.MODE_CHANGE:
var modeData = JsonUtility.FromJson<ModeChangeData>(dataJson);
OnModeChange?.Invoke(modeData);
break;
case EventTypes.NARRATIVE_PLOT:
var plotData = JsonUtility.FromJson<NarrativePlotData>(dataJson);
OnNarrativePlot?.Invoke(plotData);
break;
case EventTypes.VOTE_STARTED:
var voteStarted = JsonUtility.FromJson<VoteStartedData>(dataJson);
OnVoteStarted?.Invoke(voteStarted);
break;
case EventTypes.VOTE_UPDATE:
var voteUpdate = JsonUtility.FromJson<VoteUpdateData>(dataJson);
OnVoteUpdate?.Invoke(voteUpdate);
break;
case EventTypes.VOTE_ENDED:
var voteEnded = JsonUtility.FromJson<VoteEndedData>(dataJson);
OnVoteEnded?.Invoke(voteEnded);
break;
case EventTypes.VOTE_RESULT:
var voteResult = JsonUtility.FromJson<VoteResultData>(dataJson);
OnVoteResult?.Invoke(voteResult);
break;
case EventTypes.RESOLUTION_APPLIED:
var resolution = JsonUtility.FromJson<ResolutionAppliedData>(dataJson);
OnResolutionApplied?.Invoke(resolution);
break;
default:
Debug.Log($"[NetworkManager] Unhandled event type: {baseMessage.event_type}");
break;

View File

@@ -44,6 +44,11 @@ namespace TheIsland.Visual
[SerializeField] private int dustParticleCount = 8;
[SerializeField] private float dustDuration = 0.5f;
[Header("Food Poof Settings")]
[SerializeField] private Color foodColor = new Color(1f, 1f, 1f, 0.7f); // White smoke
[SerializeField] private int foodParticleCount = 15;
[SerializeField] private float foodDuration = 1.0f;
[Header("General Settings")]
[SerializeField] private float effectScale = 1f;
#endregion
@@ -96,6 +101,17 @@ namespace TheIsland.Visual
Destroy(ps.gameObject, dustDuration + 0.2f);
}
/// <summary>
/// Play food poof effect (white smoke).
/// Used for feeding.
/// </summary>
public void PlayFoodPoof(Vector3 position)
{
var ps = CreateFoodPoofSystem(position);
ps.Play();
Destroy(ps.gameObject, foodDuration + 0.5f);
}
/// <summary>
/// Play an effect by type name.
/// </summary>
@@ -114,6 +130,10 @@ namespace TheIsland.Visual
case "subscription":
PlayHeartExplosion(position);
break;
case "food":
case "feed":
PlayFoodPoof(position);
break;
default:
// Default to gold rain
PlayGoldRain(position);
@@ -132,7 +152,10 @@ namespace TheIsland.Visual
go.transform.position = position + Vector3.up * 3f; // Start above
ParticleSystem ps = go.AddComponent<ParticleSystem>();
// Stop immediately to prevent "duration while playing" warning
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
var main = ps.main;
main.playOnAwake = false;
main.loop = false;
main.duration = goldDuration;
main.startLifetime = 1.5f;
@@ -205,7 +228,10 @@ namespace TheIsland.Visual
go.transform.position = position + Vector3.up * 1.5f;
ParticleSystem ps = go.AddComponent<ParticleSystem>();
// Stop immediately to prevent "duration while playing" warning
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
var main = ps.main;
main.playOnAwake = false;
main.loop = false;
main.duration = heartDuration;
main.startLifetime = 1.2f;
@@ -280,7 +306,10 @@ namespace TheIsland.Visual
go.transform.position = position;
ParticleSystem ps = go.AddComponent<ParticleSystem>();
// Stop immediately to prevent "duration while playing" warning
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
var main = ps.main;
main.playOnAwake = false;
main.loop = false;
main.duration = dustDuration;
main.startLifetime = 0.4f;
@@ -336,6 +365,61 @@ namespace TheIsland.Visual
return ps;
}
private ParticleSystem CreateFoodPoofSystem(Vector3 position)
{
GameObject go = new GameObject("FoodPoof_VFX");
go.transform.position = position + Vector3.up * 1.5f;
ParticleSystem ps = go.AddComponent<ParticleSystem>();
// Stop immediately to prevent "duration while playing" warning
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
var main = ps.main;
main.playOnAwake = false;
main.loop = false;
main.duration = foodDuration;
main.startLifetime = 0.8f;
main.startSpeed = new ParticleSystem.MinMaxCurve(0.5f, 2f);
main.startSize = new ParticleSystem.MinMaxCurve(0.3f * effectScale, 0.6f * effectScale);
main.startColor = foodColor;
main.gravityModifier = -0.05f; // Slight float
main.maxParticles = foodParticleCount;
main.simulationSpace = ParticleSystemSimulationSpace.World;
var emission = ps.emission;
emission.enabled = true;
emission.rateOverTime = 0;
// Burst
emission.SetBursts(new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, (short)foodParticleCount) });
var shape = ps.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 0.4f * effectScale;
var sizeOverLifetime = ps.sizeOverLifetime;
sizeOverLifetime.enabled = true;
AnimationCurve sizeCurve = new AnimationCurve();
sizeCurve.AddKey(0f, 0.2f);
sizeCurve.AddKey(0.5f, 1f);
sizeCurve.AddKey(1f, 0f);
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(1f, sizeCurve);
var colorOverLifetime = ps.colorOverLifetime;
colorOverLifetime.enabled = true;
Gradient gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] { new GradientColorKey(foodColor, 0f), new GradientColorKey(foodColor, 1f) },
new GradientAlphaKey[] { new GradientAlphaKey(0.8f, 0f), new GradientAlphaKey(1f, 0.2f), new GradientAlphaKey(0f, 1f) }
);
colorOverLifetime.color = gradient;
var renderer = go.GetComponent<ParticleSystemRenderer>();
renderer.renderMode = ParticleSystemRenderMode.Billboard;
renderer.material = CreateParticleMaterial(foodColor);
return ps;
}
/// <summary>
/// Create a simple additive particle material.
/// </summary>