Compare commits
7 Commits
642205529c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8915a4b074 | ||
|
|
93fed8b9ca | ||
|
|
07acfa5801 | ||
|
|
da49223685 | ||
|
|
9d248f2cdb | ||
|
|
5476260efa | ||
|
|
adfd197451 |
16
README.md
16
README.md
@@ -16,6 +16,8 @@ the-island/
|
|||||||
│ ├── llm.py # LLM 集成 (对话生成)
|
│ ├── llm.py # LLM 集成 (对话生成)
|
||||||
│ ├── memory_service.py # Agent 记忆管理服务
|
│ ├── memory_service.py # Agent 记忆管理服务
|
||||||
│ ├── twitch_service.py # Twitch 聊天机器人
|
│ ├── twitch_service.py # Twitch 聊天机器人
|
||||||
|
│ ├── director_service.py # AI 导演服务 (叙事生成)
|
||||||
|
│ ├── vote_manager.py # 投票管理器
|
||||||
│ └── database.py # 数据库配置
|
│ └── database.py # 数据库配置
|
||||||
├── frontend/ # Web 调试客户端
|
├── frontend/ # Web 调试客户端
|
||||||
│ ├── app.js # JavaScript 客户端
|
│ ├── app.js # JavaScript 客户端
|
||||||
@@ -43,6 +45,8 @@ the-island/
|
|||||||
- **社交角色**: 领导者、追随者、独行者动态关系
|
- **社交角色**: 领导者、追随者、独行者动态关系
|
||||||
- **记忆系统**: Agent 会记住重要的互动和事件
|
- **记忆系统**: Agent 会记住重要的互动和事件
|
||||||
- **随机事件**: 风暴破坏、发现宝藏、野兽袭击等
|
- **随机事件**: 风暴破坏、发现宝藏、野兽袭击等
|
||||||
|
- **AI 导演系统**: 自动生成剧情事件,观众投票决定剧情走向
|
||||||
|
- **叙事投票**: Twitch 观众通过 `!1` `!2` 命令参与剧情决策
|
||||||
|
|
||||||
### 玩家命令
|
### 玩家命令
|
||||||
| 命令 | 格式 | 金币消耗 | 效果 |
|
| 命令 | 格式 | 金币消耗 | 效果 |
|
||||||
@@ -54,6 +58,8 @@ the-island/
|
|||||||
| revive | `revive <角色名>` | 10g | 复活死亡角色 |
|
| revive | `revive <角色名>` | 10g | 复活死亡角色 |
|
||||||
| check | `check` | 0g | 查看所有状态 |
|
| check | `check` | 0g | 查看所有状态 |
|
||||||
| reset | `reset` | 0g | 重置游戏 |
|
| reset | `reset` | 0g | 重置游戏 |
|
||||||
|
| !1 / !A | `!1` 或 `!A` | 0g | 投票选择第一选项 |
|
||||||
|
| !2 / !B | `!2` 或 `!B` | 0g | 投票选择第二选项 |
|
||||||
|
|
||||||
### AI 角色
|
### AI 角色
|
||||||
- **Jack** (勇敢) - 蓝色
|
- **Jack** (勇敢) - 蓝色
|
||||||
@@ -123,6 +129,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
| `EnvironmentManager.cs` | 环境场景 (沙滩、海洋、天空) |
|
| `EnvironmentManager.cs` | 环境场景 (沙滩、海洋、天空) |
|
||||||
| `WeatherEffects.cs` | 天气粒子效果 (雨、雾、热浪) |
|
| `WeatherEffects.cs` | 天气粒子效果 (雨、雾、热浪) |
|
||||||
| `Models.cs` | 数据模型 (Agent、WorldState、事件数据) |
|
| `Models.cs` | 数据模型 (Agent、WorldState、事件数据) |
|
||||||
|
| `NarrativeUI.cs` | AI 导演叙事界面 (剧情卡片、投票进度条、倒计时) |
|
||||||
|
|
||||||
### 视觉特性
|
### 视觉特性
|
||||||
- 程序化生成的 2.5D 角色精灵
|
- 程序化生成的 2.5D 角色精灵
|
||||||
@@ -178,6 +185,15 @@ AGENT_ACTION # 角色执行行动 (采集/休息/社交等)
|
|||||||
CRAFT # 制作物品 (药品等)
|
CRAFT # 制作物品 (药品等)
|
||||||
USE_ITEM # 使用物品
|
USE_ITEM # 使用物品
|
||||||
RANDOM_EVENT # 随机事件 (风暴/宝藏/野兽等)
|
RANDOM_EVENT # 随机事件 (风暴/宝藏/野兽等)
|
||||||
|
|
||||||
|
# AI 导演与叙事投票 (Phase 9)
|
||||||
|
MODE_CHANGE # 游戏模式切换 (simulation/narrative/voting/resolution)
|
||||||
|
NARRATIVE_PLOT # 导演生成的剧情事件
|
||||||
|
VOTE_STARTED # 投票开始
|
||||||
|
VOTE_UPDATE # 实时投票进度更新
|
||||||
|
VOTE_ENDED # 投票结束
|
||||||
|
VOTE_RESULT # 投票结果
|
||||||
|
RESOLUTION_APPLIED # 剧情决议执行
|
||||||
```
|
```
|
||||||
|
|
||||||
## Twitch 直播集成
|
## Twitch 直播集成
|
||||||
|
|||||||
562
backend/app/director_service.py
Normal file
562
backend/app/director_service.py
Normal 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()
|
||||||
@@ -15,6 +15,8 @@ from .database import init_db, get_db_session
|
|||||||
from .models import User, Agent, WorldState, GameConfig, AgentRelationship
|
from .models import User, Agent, WorldState, GameConfig, AgentRelationship
|
||||||
from .llm import llm_service
|
from .llm import llm_service
|
||||||
from .memory_service import memory_service
|
from .memory_service import memory_service
|
||||||
|
from .director_service import DirectorService, GameMode, PlotPoint
|
||||||
|
from .vote_manager import VoteManager, VoteOption, VoteSnapshot
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .server import ConnectionManager
|
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)
|
HEAL_PATTERN = re.compile(r"heal\s+(\w+)", re.IGNORECASE)
|
||||||
TALK_PATTERN = re.compile(r"talk\s+(\w+)\s*(.*)?", re.IGNORECASE)
|
TALK_PATTERN = re.compile(r"talk\s+(\w+)\s*(.*)?", re.IGNORECASE)
|
||||||
ENCOURAGE_PATTERN = re.compile(r"encourage\s+(\w+)", 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)
|
REVIVE_PATTERN = re.compile(r"revive\s+(\w+)", re.IGNORECASE)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -47,12 +50,23 @@ FEED_ENERGY_RESTORE = 20
|
|||||||
HEAL_COST = 15
|
HEAL_COST = 15
|
||||||
HEAL_HP_RESTORE = 30
|
HEAL_HP_RESTORE = 30
|
||||||
ENCOURAGE_COST = 5
|
ENCOURAGE_COST = 5
|
||||||
|
ENCOURAGE_COST = 5
|
||||||
ENCOURAGE_MOOD_BOOST = 15
|
ENCOURAGE_MOOD_BOOST = 15
|
||||||
|
LOVE_COST = 5
|
||||||
|
LOVE_MOOD_BOOST = 20
|
||||||
REVIVE_COST = 10 # Casual mode cost
|
REVIVE_COST = 10 # Casual mode cost
|
||||||
|
|
||||||
INITIAL_USER_GOLD = 100
|
INITIAL_USER_GOLD = 100
|
||||||
IDLE_CHAT_PROBABILITY = 0.15
|
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
|
# Day/Night cycle
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -126,7 +140,25 @@ class GameEngine:
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._tick_count = 0
|
self._tick_count = 0
|
||||||
self._tick_interval = TICK_INTERVAL
|
self._tick_interval = TICK_INTERVAL
|
||||||
|
self._tick_interval = TICK_INTERVAL
|
||||||
self._config: Optional[GameConfig] = None
|
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
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
@@ -198,6 +230,14 @@ class GameEngine:
|
|||||||
event = GameEvent(event_type=event_type, timestamp=time.time(), data=data)
|
event = GameEvent(event_type=event_type, timestamp=time.time(), data=data)
|
||||||
await self._manager.broadcast(event)
|
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:
|
async def _broadcast_agents_status(self) -> None:
|
||||||
"""Broadcast all agents' current status."""
|
"""Broadcast all agents' current status."""
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
@@ -234,6 +274,222 @@ class GameEngine:
|
|||||||
if world:
|
if world:
|
||||||
await self._broadcast_event(EventType.WORLD_UPDATE, world.to_dict())
|
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)
|
# Day/Night cycle (Phase 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -666,12 +922,119 @@ class GameEngine:
|
|||||||
"dialogue": dialogue
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Error in social dialogue: {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:
|
async def _process_activity_tick(self) -> None:
|
||||||
"""Decide and execute autonomous agent actions."""
|
"""Decide and execute autonomous agent actions."""
|
||||||
# Only process activity every few ticks to avoid chaotic movement
|
# Only process activity every few ticks to avoid chaotic movement
|
||||||
@@ -689,10 +1052,44 @@ class GameEngine:
|
|||||||
new_action = agent.current_action
|
new_action = agent.current_action
|
||||||
new_location = agent.location
|
new_location = agent.location
|
||||||
target_name = None
|
target_name = None
|
||||||
|
target_name = None
|
||||||
should_update = False
|
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)
|
# 1. Critical Needs (Override everything)
|
||||||
if world.time_of_day == "night":
|
elif world.time_of_day == "night":
|
||||||
if agent.current_action != "Sleep":
|
if agent.current_action != "Sleep":
|
||||||
new_action = "Sleep"
|
new_action = "Sleep"
|
||||||
new_location = "campfire"
|
new_location = "campfire"
|
||||||
@@ -739,9 +1136,18 @@ class GameEngine:
|
|||||||
friend = random.choice(potential_friends)
|
friend = random.choice(potential_friends)
|
||||||
new_location = "agent"
|
new_location = "agent"
|
||||||
target_name = friend.name
|
target_name = friend.name
|
||||||
|
target_name = friend.name
|
||||||
should_update = True
|
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
|
# If Happy (>80) and near others, chance to start dancing
|
||||||
elif agent.mood > 80 and agent.current_action != "Dance":
|
elif agent.mood > 80 and agent.current_action != "Dance":
|
||||||
# Check for nearby agents (same location)
|
# Check for nearby agents (same location)
|
||||||
@@ -764,6 +1170,9 @@ class GameEngine:
|
|||||||
new_action = "Wander"
|
new_action = "Wander"
|
||||||
new_location = "nearby" # Will be randomized in Unity/GameManager mapping
|
new_location = "nearby" # Will be randomized in Unity/GameManager mapping
|
||||||
should_update = True
|
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:
|
elif random.random() < 0.1:
|
||||||
new_action = "Idle"
|
new_action = "Idle"
|
||||||
should_update = True
|
should_update = True
|
||||||
@@ -844,8 +1253,39 @@ class GameEngine:
|
|||||||
return random.choice(["Hmm...", "Nice weather.", "Taking a walk."])
|
return random.choice(["Hmm...", "Nice weather.", "Taking a walk."])
|
||||||
elif action == "Wake Up":
|
elif action == "Wake Up":
|
||||||
return "Good morning!"
|
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 ""
|
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)
|
# Inventory & Crafting (Phase 16)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -927,6 +1367,91 @@ class GameEngine:
|
|||||||
})
|
})
|
||||||
logger.info(f"Agent {agent.name} used medicine and is cured!")
|
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
|
# LLM-powered agent speech
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -938,7 +1463,8 @@ class GameEngine:
|
|||||||
"""Fire-and-forget LLM call to generate agent speech."""
|
"""Fire-and-forget LLM call to generate agent speech."""
|
||||||
try:
|
try:
|
||||||
class AgentSnapshot:
|
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.name = name
|
||||||
self.personality = personality
|
self.personality = personality
|
||||||
self.hp = hp
|
self.hp = hp
|
||||||
@@ -947,7 +1473,7 @@ class GameEngine:
|
|||||||
self.is_sheltered = is_sheltered
|
self.is_sheltered = is_sheltered
|
||||||
|
|
||||||
agent_snapshot = AgentSnapshot(
|
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)
|
text = await llm_service.generate_reaction(agent_snapshot, event_description, event_type)
|
||||||
@@ -981,7 +1507,8 @@ class GameEngine:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
class AgentSnapshot:
|
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.name = name
|
||||||
self.personality = personality
|
self.personality = personality
|
||||||
self.hp = hp
|
self.hp = hp
|
||||||
@@ -990,7 +1517,7 @@ class GameEngine:
|
|||||||
self.is_sheltered = is_sheltered
|
self.is_sheltered = is_sheltered
|
||||||
|
|
||||||
agent_snapshot = AgentSnapshot(
|
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["hp"], agent_data["energy"], agent_data["mood"],
|
||||||
agent_data.get("is_sheltered", False)
|
agent_data.get("is_sheltered", False)
|
||||||
)
|
)
|
||||||
@@ -1045,13 +1572,16 @@ class GameEngine:
|
|||||||
|
|
||||||
if feed_result:
|
if feed_result:
|
||||||
await self._broadcast_event(EventType.FEED, {
|
await self._broadcast_event(EventType.FEED, {
|
||||||
"user": username, "agent_name": feed_result["agent_name"],
|
"user": username, "agent_name": feed_result['agent_name'],
|
||||||
"energy_restored": feed_result["actual_restore"],
|
"energy_restored": feed_result['actual_restore'],
|
||||||
"agent_energy": feed_result["agent_energy"], "user_gold": feed_result["user_gold"],
|
"agent_energy": feed_result['agent_energy'], "user_gold": feed_result['user_gold'],
|
||||||
"message": f"{username} fed {feed_result['agent_name']}!"
|
"message": f"{username} fed {feed_result['agent_name']}!"
|
||||||
})
|
})
|
||||||
await self._broadcast_event(EventType.USER_UPDATE, {"user": username, "gold": feed_result["user_gold"]})
|
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(
|
asyncio.create_task(self._trigger_agent_speak(
|
||||||
feed_result["agent_id"], feed_result["agent_name"],
|
feed_result["agent_id"], feed_result["agent_name"],
|
||||||
feed_result["agent_personality"], feed_result["agent_hp"],
|
feed_result["agent_personality"], feed_result["agent_hp"],
|
||||||
@@ -1138,6 +1668,43 @@ class GameEngine:
|
|||||||
f"User {username} encouraged you!", "encourage"
|
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:
|
async def _handle_talk(self, username: str, agent_name: str, topic: str = "") -> None:
|
||||||
"""Handle talk command - free conversation with agent."""
|
"""Handle talk command - free conversation with agent."""
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
@@ -1276,6 +1843,10 @@ class GameEngine:
|
|||||||
await self._handle_encourage(user, match.group(1))
|
await self._handle_encourage(user, match.group(1))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if match := LOVE_PATTERN.search(message):
|
||||||
|
await self._handle_love(user, match.group(1))
|
||||||
|
return
|
||||||
|
|
||||||
if match := REVIVE_PATTERN.search(message):
|
if match := REVIVE_PATTERN.search(message):
|
||||||
await self._handle_revive(user, match.group(1))
|
await self._handle_revive(user, match.group(1))
|
||||||
return
|
return
|
||||||
@@ -1371,6 +1942,20 @@ class GameEngine:
|
|||||||
while self._running:
|
while self._running:
|
||||||
self._tick_count += 1
|
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)
|
# 1. Advance time (Phase 2)
|
||||||
phase_change = await self._advance_time()
|
phase_change = await self._advance_time()
|
||||||
if phase_change:
|
if phase_change:
|
||||||
@@ -1399,12 +1984,21 @@ class GameEngine:
|
|||||||
# 5. Update moods (Phase 3)
|
# 5. Update moods (Phase 3)
|
||||||
await self._update_moods()
|
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)
|
# 6. Autonomous Activity (Phase 13)
|
||||||
await self._process_activity_tick()
|
await self._process_activity_tick()
|
||||||
|
|
||||||
# 7. Social interactions (Phase 5)
|
# 7. Social interactions (Phase 5)
|
||||||
await self._process_social_tick()
|
await self._process_social_tick()
|
||||||
|
|
||||||
|
# Phase 23: Altruism (Item Exchange)
|
||||||
|
await self._process_altruism_tick()
|
||||||
|
|
||||||
# 8. Random Events (Phase 17-C)
|
# 8. Random Events (Phase 17-C)
|
||||||
await self._process_random_events()
|
await self._process_random_events()
|
||||||
|
|
||||||
@@ -1434,7 +2028,8 @@ class GameEngine:
|
|||||||
"day": day,
|
"day": day,
|
||||||
"time_of_day": time_of_day,
|
"time_of_day": time_of_day,
|
||||||
"weather": weather,
|
"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)
|
await asyncio.sleep(self._tick_interval)
|
||||||
@@ -1533,3 +2128,57 @@ class GameEngine:
|
|||||||
async def process_bits(self, user: str, amount: int) -> None:
|
async def process_bits(self, user: str, amount: int) -> None:
|
||||||
"""Deprecated: Use handle_gift instead."""
|
"""Deprecated: Use handle_gift instead."""
|
||||||
await self.handle_gift(user, amount, "bits")
|
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}")
|
||||||
|
|||||||
@@ -404,7 +404,8 @@ class LLMService:
|
|||||||
interaction_type: str,
|
interaction_type: str,
|
||||||
relationship_type: str,
|
relationship_type: str,
|
||||||
weather: str = "Sunny",
|
weather: str = "Sunny",
|
||||||
time_of_day: str = "day"
|
time_of_day: str = "day",
|
||||||
|
previous_dialogue: str = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate dialogue for social interaction between two agents.
|
Generate dialogue for social interaction between two agents.
|
||||||
@@ -467,7 +468,17 @@ class LLMService:
|
|||||||
f"{initiator_name} and {target_name} {relationship_desc}. "
|
f"{initiator_name} and {target_name} {relationship_desc}. "
|
||||||
f"It is {time_of_day} and the weather is {weather}. "
|
f"It is {time_of_day} and the weather is {weather}. "
|
||||||
f"{initiator_name} is {interaction_desc} {target_name}. "
|
f"{initiator_name} is {interaction_desc} {target_name}. "
|
||||||
f"Write a brief, natural dialogue exchange (2-3 lines total). "
|
)
|
||||||
|
|
||||||
|
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]'"
|
f"Format: '{initiator_name}: [line]\\n{target_name}: [response]'"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -494,6 +505,52 @@ class LLMService:
|
|||||||
logger.error(f"LLM API error for social interaction: {e}")
|
logger.error(f"LLM API error for social interaction: {e}")
|
||||||
return f"{initiator_name}: ...\n{target_name}: ..."
|
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(
|
async def generate_gratitude(
|
||||||
self,
|
self,
|
||||||
user: str,
|
user: str,
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ class EventType(str, Enum):
|
|||||||
# Random Events (Phase 17-C)
|
# Random Events (Phase 17-C)
|
||||||
RANDOM_EVENT = "random_event" # Random event occurred
|
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):
|
class GameEvent(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -74,6 +74,18 @@ class TwitchBot(commands.Bot):
|
|||||||
# Log the message for debugging
|
# Log the message for debugging
|
||||||
logger.info(f"Twitch chat [{username}]: {content}")
|
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
|
# Forward to game engine for command processing
|
||||||
try:
|
try:
|
||||||
await self._game_engine.process_command(username, content)
|
await self._game_engine.process_command(username, content)
|
||||||
|
|||||||
445
backend/app/vote_manager.py
Normal file
445
backend/app/vote_manager.py
Normal 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()
|
||||||
@@ -158,6 +158,9 @@ namespace TheIsland.Core
|
|||||||
network.OnGiftEffect += HandleGiftEffect; // Phase 8
|
network.OnGiftEffect += HandleGiftEffect; // Phase 8
|
||||||
network.OnAgentAction += HandleAgentAction; // Phase 13
|
network.OnAgentAction += HandleAgentAction; // Phase 13
|
||||||
network.OnRandomEvent += HandleRandomEvent; // Phase 17-C
|
network.OnRandomEvent += HandleRandomEvent; // Phase 17-C
|
||||||
|
network.OnGiveItem += HandleGiveItem; // Phase 23
|
||||||
|
network.OnGroupActivity += HandleGroupActivity; // Phase 24
|
||||||
|
network.OnVFXEvent += HandleVFXEvent; // Phase 8
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UnsubscribeFromNetworkEvents()
|
private void UnsubscribeFromNetworkEvents()
|
||||||
@@ -186,6 +189,9 @@ namespace TheIsland.Core
|
|||||||
network.OnSocialInteraction -= HandleSocialInteraction;
|
network.OnSocialInteraction -= HandleSocialInteraction;
|
||||||
network.OnGiftEffect -= HandleGiftEffect; // Phase 8
|
network.OnGiftEffect -= HandleGiftEffect; // Phase 8
|
||||||
network.OnRandomEvent -= HandleRandomEvent; // Phase 17-C
|
network.OnRandomEvent -= HandleRandomEvent; // Phase 17-C
|
||||||
|
network.OnGiveItem -= HandleGiveItem; // Phase 23
|
||||||
|
network.OnGroupActivity -= HandleGroupActivity; // Phase 24
|
||||||
|
network.OnVFXEvent -= HandleVFXEvent; // Phase 8
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -447,10 +453,84 @@ namespace TheIsland.Core
|
|||||||
_playerGold = data.user_gold;
|
_playerGold = data.user_gold;
|
||||||
UpdateGoldDisplay();
|
UpdateGoldDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
ShowNotification(data.message);
|
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)
|
private void HandleTalk(TalkEventData data)
|
||||||
{
|
{
|
||||||
Debug.Log($"[GameManager] Talk event: {data.agent_name} responds about '{data.topic}'");
|
Debug.Log($"[GameManager] Talk event: {data.agent_name} responds about '{data.topic}'");
|
||||||
@@ -553,7 +633,22 @@ namespace TheIsland.Core
|
|||||||
// Find agent and command movement
|
// Find agent and command movement
|
||||||
if (_agentVisuals.TryGetValue(data.agent_id, out AgentVisual agentVisual))
|
if (_agentVisuals.TryGetValue(data.agent_id, out AgentVisual agentVisual))
|
||||||
{
|
{
|
||||||
|
// 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);
|
agentVisual.MoveTo(targetPos);
|
||||||
|
}
|
||||||
|
|
||||||
// Optional: Show thought bubble or speech
|
// Optional: Show thought bubble or speech
|
||||||
if (!string.IsNullOrEmpty(data.dialogue))
|
if (!string.IsNullOrEmpty(data.dialogue))
|
||||||
|
|||||||
@@ -353,6 +353,24 @@ namespace TheIsland.Models
|
|||||||
|
|
||||||
// Random Events (Phase 17-C)
|
// Random Events (Phase 17-C)
|
||||||
public const string RANDOM_EVENT = "random_event";
|
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>
|
/// <summary>
|
||||||
@@ -403,4 +421,145 @@ namespace TheIsland.Models
|
|||||||
public string message;
|
public string message;
|
||||||
public string agent_name; // Optional: affected agent
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
588
unity-client/Assets/Scripts/NarrativeUI.cs
Normal file
588
unity-client/Assets/Scripts/NarrativeUI.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
2
unity-client/Assets/Scripts/NarrativeUI.cs.meta
Normal file
2
unity-client/Assets/Scripts/NarrativeUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7748b2f53bcb247f8a0e6707b1ecb1ce
|
||||||
@@ -72,6 +72,18 @@ namespace TheIsland.Network
|
|||||||
public event Action<CraftEventData> OnCraft; // Phase 16: Crafting
|
public event Action<CraftEventData> OnCraft; // Phase 16: Crafting
|
||||||
public event Action<UseItemEventData> OnUseItem; // Phase 16: Using items
|
public event Action<UseItemEventData> OnUseItem; // Phase 16: Using items
|
||||||
public event Action<RandomEventData> OnRandomEvent; // Phase 17-C: Random Events
|
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
|
#endregion
|
||||||
|
|
||||||
#region Private Fields
|
#region Private Fields
|
||||||
@@ -379,6 +391,57 @@ namespace TheIsland.Network
|
|||||||
Debug.Log($"[Random Event] {randomEventData.event_type}: {randomEventData.message}");
|
Debug.Log($"[Random Event] {randomEventData.event_type}: {randomEventData.message}");
|
||||||
break;
|
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:
|
default:
|
||||||
Debug.Log($"[NetworkManager] Unhandled event type: {baseMessage.event_type}");
|
Debug.Log($"[NetworkManager] Unhandled event type: {baseMessage.event_type}");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ namespace TheIsland.Visual
|
|||||||
[SerializeField] private int dustParticleCount = 8;
|
[SerializeField] private int dustParticleCount = 8;
|
||||||
[SerializeField] private float dustDuration = 0.5f;
|
[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")]
|
[Header("General Settings")]
|
||||||
[SerializeField] private float effectScale = 1f;
|
[SerializeField] private float effectScale = 1f;
|
||||||
#endregion
|
#endregion
|
||||||
@@ -96,6 +101,17 @@ namespace TheIsland.Visual
|
|||||||
Destroy(ps.gameObject, dustDuration + 0.2f);
|
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>
|
/// <summary>
|
||||||
/// Play an effect by type name.
|
/// Play an effect by type name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -114,6 +130,10 @@ namespace TheIsland.Visual
|
|||||||
case "subscription":
|
case "subscription":
|
||||||
PlayHeartExplosion(position);
|
PlayHeartExplosion(position);
|
||||||
break;
|
break;
|
||||||
|
case "food":
|
||||||
|
case "feed":
|
||||||
|
PlayFoodPoof(position);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Default to gold rain
|
// Default to gold rain
|
||||||
PlayGoldRain(position);
|
PlayGoldRain(position);
|
||||||
@@ -132,7 +152,10 @@ namespace TheIsland.Visual
|
|||||||
go.transform.position = position + Vector3.up * 3f; // Start above
|
go.transform.position = position + Vector3.up * 3f; // Start above
|
||||||
|
|
||||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||||
|
// Stop immediately to prevent "duration while playing" warning
|
||||||
|
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||||
var main = ps.main;
|
var main = ps.main;
|
||||||
|
main.playOnAwake = false;
|
||||||
main.loop = false;
|
main.loop = false;
|
||||||
main.duration = goldDuration;
|
main.duration = goldDuration;
|
||||||
main.startLifetime = 1.5f;
|
main.startLifetime = 1.5f;
|
||||||
@@ -205,7 +228,10 @@ namespace TheIsland.Visual
|
|||||||
go.transform.position = position + Vector3.up * 1.5f;
|
go.transform.position = position + Vector3.up * 1.5f;
|
||||||
|
|
||||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||||
|
// Stop immediately to prevent "duration while playing" warning
|
||||||
|
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||||
var main = ps.main;
|
var main = ps.main;
|
||||||
|
main.playOnAwake = false;
|
||||||
main.loop = false;
|
main.loop = false;
|
||||||
main.duration = heartDuration;
|
main.duration = heartDuration;
|
||||||
main.startLifetime = 1.2f;
|
main.startLifetime = 1.2f;
|
||||||
@@ -280,7 +306,10 @@ namespace TheIsland.Visual
|
|||||||
go.transform.position = position;
|
go.transform.position = position;
|
||||||
|
|
||||||
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
ParticleSystem ps = go.AddComponent<ParticleSystem>();
|
||||||
|
// Stop immediately to prevent "duration while playing" warning
|
||||||
|
ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
|
||||||
var main = ps.main;
|
var main = ps.main;
|
||||||
|
main.playOnAwake = false;
|
||||||
main.loop = false;
|
main.loop = false;
|
||||||
main.duration = dustDuration;
|
main.duration = dustDuration;
|
||||||
main.startLifetime = 0.4f;
|
main.startLifetime = 0.4f;
|
||||||
@@ -336,6 +365,61 @@ namespace TheIsland.Visual
|
|||||||
return ps;
|
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>
|
/// <summary>
|
||||||
/// Create a simple additive particle material.
|
/// Create a simple additive particle material.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user