feat: implement execute_turn functionality
Browse files- app.py +79 -2
- src/unpredictable_lord/game_state.py +207 -0
app.py
CHANGED
|
@@ -15,6 +15,7 @@ from unpredictable_lord.game_state import (
|
|
| 15 |
ADVICE_DESCRIPTIONS,
|
| 16 |
PERSONALITY_DESCRIPTIONS,
|
| 17 |
create_session,
|
|
|
|
| 18 |
get_available_advice,
|
| 19 |
get_session,
|
| 20 |
)
|
|
@@ -108,6 +109,50 @@ def list_available_advice() -> dict:
|
|
| 108 |
}
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
# Gradio UI
|
| 112 |
with gr.Blocks(title="Unpredictable Lord") as demo:
|
| 113 |
gr.Markdown("# Unpredictable Lord\nLord Advisor AI Simulation")
|
|
@@ -194,12 +239,15 @@ Add the following to your MCP settings configuration:
|
|
| 194 |
| `init_game` | Initialize a new game session. Returns a session_id and available advice options. |
|
| 195 |
| `get_game_state` | Get the current game state for a session. |
|
| 196 |
| `list_available_advice` | Get all available advice options for execute_turn. |
|
|
|
|
| 197 |
|
| 198 |
### Usage Flow
|
| 199 |
|
| 200 |
1. Call `init_game(personality)` to start a new game session
|
| 201 |
-
2.
|
| 202 |
-
3.
|
|
|
|
|
|
|
| 203 |
"""
|
| 204 |
)
|
| 205 |
|
|
@@ -238,6 +286,35 @@ Add the following to your MCP settings configuration:
|
|
| 238 |
fn=list_available_advice, inputs=[], outputs=advice_output
|
| 239 |
)
|
| 240 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
if __name__ == "__main__":
|
| 243 |
demo.launch(mcp_server=True)
|
|
|
|
| 15 |
ADVICE_DESCRIPTIONS,
|
| 16 |
PERSONALITY_DESCRIPTIONS,
|
| 17 |
create_session,
|
| 18 |
+
execute_turn_logic,
|
| 19 |
get_available_advice,
|
| 20 |
get_session,
|
| 21 |
)
|
|
|
|
| 109 |
}
|
| 110 |
|
| 111 |
|
| 112 |
+
def execute_turn(session_id: str, advice: str) -> dict:
|
| 113 |
+
"""
|
| 114 |
+
Execute a turn with the given advice.
|
| 115 |
+
|
| 116 |
+
This MCP tool is called by the Lord AI after interpreting the user's suggestion.
|
| 117 |
+
The lord will decide whether to follow the advice based on personality and trust.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
session_id: The session ID returned from init_game.
|
| 121 |
+
advice: The advice type to execute. Must be one of:
|
| 122 |
+
- "increase_tax": Raise taxes (Treasury ↑, Satisfaction ↓)
|
| 123 |
+
- "decrease_tax": Lower taxes (Treasury ↓, Satisfaction ↑)
|
| 124 |
+
- "expand_territory": Military expansion (Territory ↑, Treasury ↓, risky)
|
| 125 |
+
- "improve_diplomacy": Diplomatic efforts (Royal Trust ↑, Treasury ↓)
|
| 126 |
+
- "public_festival": Hold festival (Satisfaction ↑, Treasury ↓)
|
| 127 |
+
- "build_infrastructure": Build infrastructure (Population ↑, Treasury ↓)
|
| 128 |
+
- "do_nothing": Maintain current state
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
dict: Result containing:
|
| 132 |
+
- adopted: Whether the lord followed the advice
|
| 133 |
+
- action_taken: The actual action the lord took
|
| 134 |
+
- parameter_changes: Changes to game parameters
|
| 135 |
+
- game_over: Whether the game has ended
|
| 136 |
+
- new_state: Updated game state
|
| 137 |
+
"""
|
| 138 |
+
state = get_session(session_id)
|
| 139 |
+
|
| 140 |
+
if state is None:
|
| 141 |
+
return {
|
| 142 |
+
"error": "Session not found",
|
| 143 |
+
"message": f"No game session found with ID: {session_id}. Please call init_game first.",
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
result = execute_turn_logic(state, advice)
|
| 147 |
+
|
| 148 |
+
logger.info(
|
| 149 |
+
f"Turn executed for session {session_id}: advice={advice}, "
|
| 150 |
+
f"adopted={result.get('adopted')}, action={result.get('action_taken')}"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
return result
|
| 154 |
+
|
| 155 |
+
|
| 156 |
# Gradio UI
|
| 157 |
with gr.Blocks(title="Unpredictable Lord") as demo:
|
| 158 |
gr.Markdown("# Unpredictable Lord\nLord Advisor AI Simulation")
|
|
|
|
| 239 |
| `init_game` | Initialize a new game session. Returns a session_id and available advice options. |
|
| 240 |
| `get_game_state` | Get the current game state for a session. |
|
| 241 |
| `list_available_advice` | Get all available advice options for execute_turn. |
|
| 242 |
+
| `execute_turn` | Execute a turn with the given advice. Returns whether advice was adopted and results. |
|
| 243 |
|
| 244 |
### Usage Flow
|
| 245 |
|
| 246 |
1. Call `init_game(personality)` to start a new game session
|
| 247 |
+
2. User gives free-form advice to the Lord AI
|
| 248 |
+
3. Lord AI interprets advice and calls `execute_turn(session_id, advice)`
|
| 249 |
+
4. Lord AI explains the result to the user (adopted/rejected, action taken, effects)
|
| 250 |
+
5. Repeat from step 2 until game over
|
| 251 |
"""
|
| 252 |
)
|
| 253 |
|
|
|
|
| 286 |
fn=list_available_advice, inputs=[], outputs=advice_output
|
| 287 |
)
|
| 288 |
|
| 289 |
+
gr.Markdown("### Test: Execute Turn")
|
| 290 |
+
with gr.Row():
|
| 291 |
+
exec_session_id = gr.Textbox(
|
| 292 |
+
label="Session ID",
|
| 293 |
+
placeholder="Enter session_id",
|
| 294 |
+
)
|
| 295 |
+
exec_advice = gr.Dropdown(
|
| 296 |
+
choices=[
|
| 297 |
+
"increase_tax",
|
| 298 |
+
"decrease_tax",
|
| 299 |
+
"expand_territory",
|
| 300 |
+
"improve_diplomacy",
|
| 301 |
+
"public_festival",
|
| 302 |
+
"build_infrastructure",
|
| 303 |
+
"do_nothing",
|
| 304 |
+
],
|
| 305 |
+
value="do_nothing",
|
| 306 |
+
label="Advice",
|
| 307 |
+
)
|
| 308 |
+
exec_btn = gr.Button("Execute Turn")
|
| 309 |
+
|
| 310 |
+
exec_output = gr.JSON(label="Turn Result")
|
| 311 |
+
|
| 312 |
+
exec_btn.click(
|
| 313 |
+
fn=execute_turn,
|
| 314 |
+
inputs=[exec_session_id, exec_advice],
|
| 315 |
+
outputs=exec_output,
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
|
| 319 |
if __name__ == "__main__":
|
| 320 |
demo.launch(mcp_server=True)
|
src/unpredictable_lord/game_state.py
CHANGED
|
@@ -5,6 +5,7 @@ This module defines the game state data structure and provides
|
|
| 5 |
functions for managing game sessions.
|
| 6 |
"""
|
| 7 |
|
|
|
|
| 8 |
from dataclasses import asdict, dataclass, field
|
| 9 |
from typing import Literal
|
| 10 |
|
|
@@ -157,6 +158,212 @@ class GameState:
|
|
| 157 |
|
| 158 |
return False, None
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
# Global session storage
|
| 162 |
game_sessions: dict[str, GameState] = {}
|
|
|
|
| 5 |
functions for managing game sessions.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import random
|
| 9 |
from dataclasses import asdict, dataclass, field
|
| 10 |
from typing import Literal
|
| 11 |
|
|
|
|
| 158 |
|
| 159 |
return False, None
|
| 160 |
|
| 161 |
+
def clamp(self, value: int, min_val: int = 0, max_val: int = 100) -> int:
|
| 162 |
+
"""Clamp a value to the valid range."""
|
| 163 |
+
return max(min_val, min(max_val, value))
|
| 164 |
+
|
| 165 |
+
def apply_changes(self, changes: dict[str, int]) -> dict[str, int]:
|
| 166 |
+
"""
|
| 167 |
+
Apply parameter changes and return the actual changes made.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
changes: Dictionary of parameter changes (can be positive or negative).
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
Dictionary of actual changes applied (after clamping).
|
| 174 |
+
"""
|
| 175 |
+
actual_changes = {}
|
| 176 |
+
for param, delta in changes.items():
|
| 177 |
+
if hasattr(self, param):
|
| 178 |
+
old_value = getattr(self, param)
|
| 179 |
+
new_value = self.clamp(old_value + delta)
|
| 180 |
+
setattr(self, param, new_value)
|
| 181 |
+
actual_changes[param] = new_value - old_value
|
| 182 |
+
return actual_changes
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# Personality-based acceptance rates and biases
|
| 186 |
+
PERSONALITY_TRAITS = {
|
| 187 |
+
"cautious": {
|
| 188 |
+
"base_acceptance": 0.4, # Low base acceptance
|
| 189 |
+
"preferred": ["do_nothing", "improve_diplomacy"],
|
| 190 |
+
"disliked": ["expand_territory", "decrease_tax"],
|
| 191 |
+
"trust_weight": 0.6, # How much advisor_trust affects acceptance
|
| 192 |
+
},
|
| 193 |
+
"idealist": {
|
| 194 |
+
"base_acceptance": 0.5,
|
| 195 |
+
"preferred": ["build_infrastructure", "public_festival", "decrease_tax"],
|
| 196 |
+
"disliked": ["increase_tax", "do_nothing"],
|
| 197 |
+
"trust_weight": 0.4, # Less influenced by trust, more by ideals
|
| 198 |
+
},
|
| 199 |
+
"populist": {
|
| 200 |
+
"base_acceptance": 0.6, # Higher base acceptance
|
| 201 |
+
"preferred": ["decrease_tax", "public_festival"],
|
| 202 |
+
"disliked": ["increase_tax", "expand_territory"],
|
| 203 |
+
"trust_weight": 0.5,
|
| 204 |
+
},
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
# Effects of each action when adopted
|
| 208 |
+
ACTION_EFFECTS: dict[str, dict[str, int]] = {
|
| 209 |
+
"increase_tax": {
|
| 210 |
+
"treasury": 15,
|
| 211 |
+
"satisfaction": -10,
|
| 212 |
+
},
|
| 213 |
+
"decrease_tax": {
|
| 214 |
+
"treasury": -10,
|
| 215 |
+
"satisfaction": 15,
|
| 216 |
+
},
|
| 217 |
+
"expand_territory": {
|
| 218 |
+
"territory": 10,
|
| 219 |
+
"treasury": -15,
|
| 220 |
+
"population": 5,
|
| 221 |
+
# royal_trust is handled specially (can go up or down)
|
| 222 |
+
},
|
| 223 |
+
"improve_diplomacy": {
|
| 224 |
+
"royal_trust": 12,
|
| 225 |
+
"treasury": -8,
|
| 226 |
+
},
|
| 227 |
+
"public_festival": {
|
| 228 |
+
"satisfaction": 15,
|
| 229 |
+
"treasury": -12,
|
| 230 |
+
},
|
| 231 |
+
"build_infrastructure": {
|
| 232 |
+
"population": 10,
|
| 233 |
+
"treasury": -10,
|
| 234 |
+
"satisfaction": 5,
|
| 235 |
+
},
|
| 236 |
+
"do_nothing": {},
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
# Alternative actions when lord rejects advice
|
| 240 |
+
REJECTION_ALTERNATIVES = {
|
| 241 |
+
"increase_tax": ["do_nothing", "improve_diplomacy"],
|
| 242 |
+
"decrease_tax": ["do_nothing", "increase_tax"],
|
| 243 |
+
"expand_territory": ["do_nothing", "improve_diplomacy"],
|
| 244 |
+
"improve_diplomacy": ["do_nothing", "public_festival"],
|
| 245 |
+
"public_festival": ["do_nothing", "improve_diplomacy"],
|
| 246 |
+
"build_infrastructure": ["do_nothing", "improve_diplomacy"],
|
| 247 |
+
"do_nothing": ["do_nothing"], # Always accept do_nothing
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def execute_turn_logic(state: "GameState", advice: str) -> dict:
|
| 252 |
+
"""
|
| 253 |
+
Execute a turn based on the given advice.
|
| 254 |
+
|
| 255 |
+
This function determines whether the lord accepts the advice based on
|
| 256 |
+
personality and trust, applies the effects, and returns the result.
|
| 257 |
+
|
| 258 |
+
Args:
|
| 259 |
+
state: The current game state.
|
| 260 |
+
advice: The advice type chosen by the Lord AI.
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
dict: Result containing adoption status, action taken, effects, and messages.
|
| 264 |
+
"""
|
| 265 |
+
if advice not in ADVICE_DESCRIPTIONS:
|
| 266 |
+
return {
|
| 267 |
+
"error": "Invalid advice",
|
| 268 |
+
"message": f"Unknown advice type: {advice}. Use list_available_advice to see options.",
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if state.game_over:
|
| 272 |
+
return {
|
| 273 |
+
"error": "Game over",
|
| 274 |
+
"message": f"This game has ended with result: {state.result}",
|
| 275 |
+
"game_state": state.to_dict(),
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
personality = state.lord_personality
|
| 279 |
+
traits = PERSONALITY_TRAITS[personality]
|
| 280 |
+
|
| 281 |
+
# Calculate acceptance probability
|
| 282 |
+
base_prob = traits["base_acceptance"]
|
| 283 |
+
trust_bonus = (state.advisor_trust / 100) * traits["trust_weight"]
|
| 284 |
+
|
| 285 |
+
if advice in traits["preferred"]:
|
| 286 |
+
preference_bonus = 0.2
|
| 287 |
+
elif advice in traits["disliked"]:
|
| 288 |
+
preference_bonus = -0.25
|
| 289 |
+
else:
|
| 290 |
+
preference_bonus = 0
|
| 291 |
+
|
| 292 |
+
acceptance_prob = min(0.95, max(0.1, base_prob + trust_bonus + preference_bonus))
|
| 293 |
+
|
| 294 |
+
# Determine if advice is adopted
|
| 295 |
+
adopted = random.random() < acceptance_prob
|
| 296 |
+
|
| 297 |
+
if adopted:
|
| 298 |
+
action_taken = advice
|
| 299 |
+
adoption_message = f"The lord has decided to follow your advice: {ADVICE_DESCRIPTIONS[advice]['name']}."
|
| 300 |
+
else:
|
| 301 |
+
# Lord rejects and takes alternative action
|
| 302 |
+
alternatives = REJECTION_ALTERNATIVES.get(advice, ["do_nothing"])
|
| 303 |
+
action_taken = random.choice(alternatives)
|
| 304 |
+
if action_taken == advice:
|
| 305 |
+
action_taken = "do_nothing"
|
| 306 |
+
adoption_message = (
|
| 307 |
+
f"The lord has rejected your advice ({ADVICE_DESCRIPTIONS[advice]['name']}) "
|
| 308 |
+
f"and instead chose: {ADVICE_DESCRIPTIONS[action_taken]['name']}."
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
# Apply effects of the action taken
|
| 312 |
+
effects = ACTION_EFFECTS.get(action_taken, {}).copy()
|
| 313 |
+
|
| 314 |
+
# Special handling for expand_territory (risky)
|
| 315 |
+
if action_taken == "expand_territory":
|
| 316 |
+
if random.random() < 0.6: # 60% success
|
| 317 |
+
effects["royal_trust"] = 8
|
| 318 |
+
outcome_message = "The military campaign was successful!"
|
| 319 |
+
else:
|
| 320 |
+
effects["royal_trust"] = -10
|
| 321 |
+
effects["territory"] = 0 # Failed expansion
|
| 322 |
+
effects["satisfaction"] = -5
|
| 323 |
+
outcome_message = "The military campaign failed, damaging our reputation."
|
| 324 |
+
else:
|
| 325 |
+
outcome_message = None
|
| 326 |
+
|
| 327 |
+
# Apply changes to state
|
| 328 |
+
actual_changes = state.apply_changes(effects)
|
| 329 |
+
|
| 330 |
+
# Adjust advisor trust based on whether advice was followed
|
| 331 |
+
if adopted:
|
| 332 |
+
trust_change = random.randint(2, 5)
|
| 333 |
+
else:
|
| 334 |
+
trust_change = random.randint(-5, -1)
|
| 335 |
+
state.advisor_trust = state.clamp(state.advisor_trust + trust_change)
|
| 336 |
+
actual_changes["advisor_trust"] = trust_change
|
| 337 |
+
|
| 338 |
+
# Advance turn
|
| 339 |
+
state.turn += 1
|
| 340 |
+
|
| 341 |
+
# Check for game over
|
| 342 |
+
game_over, result = state.check_game_over()
|
| 343 |
+
if game_over:
|
| 344 |
+
state.game_over = True
|
| 345 |
+
state.result = result
|
| 346 |
+
|
| 347 |
+
# Build result message
|
| 348 |
+
changes_str = ", ".join(
|
| 349 |
+
f"{k}: {'+' if v > 0 else ''}{v}" for k, v in actual_changes.items() if v != 0
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
return {
|
| 353 |
+
"adopted": adopted,
|
| 354 |
+
"advice_given": advice,
|
| 355 |
+
"action_taken": action_taken,
|
| 356 |
+
"action_name": ADVICE_DESCRIPTIONS[action_taken]["name"],
|
| 357 |
+
"adoption_message": adoption_message,
|
| 358 |
+
"outcome_message": outcome_message,
|
| 359 |
+
"parameter_changes": actual_changes,
|
| 360 |
+
"changes_summary": changes_str or "No changes",
|
| 361 |
+
"game_over": state.game_over,
|
| 362 |
+
"game_result": state.result,
|
| 363 |
+
"new_state": state.to_dict(),
|
| 364 |
+
"status_summary": state.get_status_summary(),
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
|
| 368 |
# Global session storage
|
| 369 |
game_sessions: dict[str, GameState] = {}
|