Spaces:
Configuration error
Configuration error
shon commited on
Commit ·
ad0932c
1
Parent(s): 74221d3
- pycatan/ai/ai_logger.py +5 -3
- pycatan/ai/ai_manager.py +25 -15
- pycatan/ai/ai_user.py +2 -5
- pycatan/ai/memory_compactor.py +45 -0
- pycatan/ai/prompt_manager.py +15 -1
- pycatan/ai/prompt_templates.py +3 -0
- pycatan/ai/schemas.py +4 -28
- pycatan/ai/state_optimizer.py +3 -1
- pycatan/management/actions.py +3 -1
- pycatan/management/game_manager.py +117 -9
- pycatan/players/human_user.py +5 -1
- tests/unit/test_actions.py +2 -2
- tests/unit/test_game_manager.py +23 -0
- tests/unit/test_prompt_manager.py +20 -0
- tests/unit/test_relationship_compaction.py +84 -0
pycatan/ai/ai_logger.py
CHANGED
|
@@ -753,7 +753,6 @@ See: [prompt_{original_prompt_number}_iter{iteration}.json](prompts/iterations/p
|
|
| 753 |
# Extract key fields
|
| 754 |
thinking = parsed.get("internal_thinking", "N/A")
|
| 755 |
note = parsed.get("note_to_self", "")
|
| 756 |
-
relationship_update = parsed.get("relationship_update", "")
|
| 757 |
say = parsed.get("say_outloud", "")
|
| 758 |
|
| 759 |
# Handle both action formats: old (action object) and new (action_type + parameters)
|
|
@@ -780,8 +779,6 @@ See: [prompt_{original_prompt_number}_iter{iteration}.json](prompts/iterations/p
|
|
| 780 |
"""
|
| 781 |
if note:
|
| 782 |
section += f"**Note to Self:** {note}\n\n"
|
| 783 |
-
if relationship_update:
|
| 784 |
-
section += f"**Relationship Update:** {relationship_update}\n\n"
|
| 785 |
if say:
|
| 786 |
section += f'**Says:** "{say}"\n\n'
|
| 787 |
if action_str:
|
|
@@ -907,6 +904,7 @@ See: [prompt_{original_prompt_number}_iter{iteration}.json](prompts/iterations/p
|
|
| 907 |
"timestamp": datetime.now().isoformat(),
|
| 908 |
"before": {
|
| 909 |
"existing_compacted_memory": result.get("existing_compacted_memory"),
|
|
|
|
| 910 |
"old_notes_to_compact": old_entries,
|
| 911 |
"recent_notes_kept_verbatim": recent_entries,
|
| 912 |
"relevant_chat": result.get("relevant_chat", []),
|
|
@@ -914,6 +912,7 @@ See: [prompt_{original_prompt_number}_iter{iteration}.json](prompts/iterations/p
|
|
| 914 |
"after": {
|
| 915 |
"long_term_summary": result.get("compacted_memory"),
|
| 916 |
"recent_notes": recent_entries,
|
|
|
|
| 917 |
"discarded_as_irrelevant": result.get("discarded_as_irrelevant", []),
|
| 918 |
},
|
| 919 |
"llm": response_data,
|
|
@@ -960,6 +959,9 @@ Time: {datetime.now().isoformat()}
|
|
| 960 |
=== AFTER: New Long-Term Summary ===
|
| 961 |
{result.get("compacted_memory") or "(none)"}
|
| 962 |
|
|
|
|
|
|
|
|
|
|
| 963 |
=== AFTER: Discarded As Irrelevant ===
|
| 964 |
{json.dumps(result.get("discarded_as_irrelevant", []), ensure_ascii=False)}
|
| 965 |
|
|
|
|
| 753 |
# Extract key fields
|
| 754 |
thinking = parsed.get("internal_thinking", "N/A")
|
| 755 |
note = parsed.get("note_to_self", "")
|
|
|
|
| 756 |
say = parsed.get("say_outloud", "")
|
| 757 |
|
| 758 |
# Handle both action formats: old (action object) and new (action_type + parameters)
|
|
|
|
| 779 |
"""
|
| 780 |
if note:
|
| 781 |
section += f"**Note to Self:** {note}\n\n"
|
|
|
|
|
|
|
| 782 |
if say:
|
| 783 |
section += f'**Says:** "{say}"\n\n'
|
| 784 |
if action_str:
|
|
|
|
| 904 |
"timestamp": datetime.now().isoformat(),
|
| 905 |
"before": {
|
| 906 |
"existing_compacted_memory": result.get("existing_compacted_memory"),
|
| 907 |
+
"existing_relationship_updates": result.get("existing_relationship_updates", []),
|
| 908 |
"old_notes_to_compact": old_entries,
|
| 909 |
"recent_notes_kept_verbatim": recent_entries,
|
| 910 |
"relevant_chat": result.get("relevant_chat", []),
|
|
|
|
| 912 |
"after": {
|
| 913 |
"long_term_summary": result.get("compacted_memory"),
|
| 914 |
"recent_notes": recent_entries,
|
| 915 |
+
"relationship_updates": result.get("relationship_updates", []),
|
| 916 |
"discarded_as_irrelevant": result.get("discarded_as_irrelevant", []),
|
| 917 |
},
|
| 918 |
"llm": response_data,
|
|
|
|
| 959 |
=== AFTER: New Long-Term Summary ===
|
| 960 |
{result.get("compacted_memory") or "(none)"}
|
| 961 |
|
| 962 |
+
=== AFTER: Relationship Updates ===
|
| 963 |
+
{json.dumps(result.get("relationship_updates", []), ensure_ascii=False)}
|
| 964 |
+
|
| 965 |
=== AFTER: Discarded As Irrelevant ===
|
| 966 |
{json.dumps(result.get("discarded_as_irrelevant", []), ensure_ascii=False)}
|
| 967 |
|
pycatan/ai/ai_manager.py
CHANGED
|
@@ -474,8 +474,6 @@ class AIManager:
|
|
| 474 |
# If parsed doesn't have say_outloud, use LLM's
|
| 475 |
if not parsed.get("say_outloud") and llm_suggestion.get("say_outloud"):
|
| 476 |
parsed["say_outloud"] = llm_suggestion["say_outloud"]
|
| 477 |
-
if not parsed.get("relationship_update") and llm_suggestion.get("relationship_update"):
|
| 478 |
-
parsed["relationship_update"] = llm_suggestion["relationship_update"]
|
| 479 |
|
| 480 |
if parsed:
|
| 481 |
# Update memory
|
|
@@ -483,13 +481,9 @@ class AIManager:
|
|
| 483 |
agent.update_memory(note_to_self)
|
| 484 |
if note_to_self:
|
| 485 |
self._maybe_compact_agent_memory(agent, game_state)
|
| 486 |
-
|
| 487 |
-
relationship_update = parsed.get("relationship_update")
|
| 488 |
-
if relationship_update:
|
| 489 |
-
agent.update_relationship_context(relationship_update)
|
| 490 |
|
| 491 |
# Save memories to file for web viewer (real-time update)
|
| 492 |
-
if note_to_self
|
| 493 |
self.logger.save_agent_memories(self.agents)
|
| 494 |
|
| 495 |
# Clear events since they've been processed
|
|
@@ -790,11 +784,6 @@ class AIManager:
|
|
| 790 |
self._maybe_compact_agent_memory(agent, game_state)
|
| 791 |
self.logger.save_agent_memories(self.agents)
|
| 792 |
|
| 793 |
-
relationship_update = parsed.get("relationship_update")
|
| 794 |
-
if relationship_update:
|
| 795 |
-
agent.update_relationship_context(relationship_update)
|
| 796 |
-
self.logger.save_agent_memories(self.agents)
|
| 797 |
-
|
| 798 |
say_outloud = (parsed.get("say_outloud") or "").strip()
|
| 799 |
if say_outloud:
|
| 800 |
self._broadcast_chat(player_name, say_outloud)
|
|
@@ -848,6 +837,7 @@ class AIManager:
|
|
| 848 |
"ROLL_DICE",
|
| 849 |
"BUY_DEV_CARD",
|
| 850 |
"END_TURN",
|
|
|
|
| 851 |
"TRADE_ACCEPT",
|
| 852 |
"TRADE_REJECT",
|
| 853 |
}
|
|
@@ -1039,6 +1029,8 @@ class AIManager:
|
|
| 1039 |
"roll": ("roll_dice", None),
|
| 1040 |
"e": ("end_turn", None),
|
| 1041 |
"end": ("end_turn", None),
|
|
|
|
|
|
|
| 1042 |
"pass": ("end_turn", None),
|
| 1043 |
"dev": ("buy_dev_card", None),
|
| 1044 |
"buy": ("buy_dev_card", None),
|
|
@@ -1155,6 +1147,12 @@ class AIManager:
|
|
| 1155 |
compacted_memory=result["compacted_memory"],
|
| 1156 |
recent_notes_to_keep=result["recent_entries"],
|
| 1157 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1158 |
artifact_paths = self.logger.log_memory_compaction(
|
| 1159 |
agent.player_name,
|
| 1160 |
agent.compaction_count,
|
|
@@ -1314,12 +1312,17 @@ class AIManager:
|
|
| 1314 |
"Do not answer every message. If you do speak, write natural "
|
| 1315 |
f"{language_name} only, keep it brief, human, and non-technical. "
|
| 1316 |
f"{resource_terms}"
|
| 1317 |
-
"You may update note_to_self with useful
|
| 1318 |
)
|
| 1319 |
|
| 1320 |
def _format_allowed_actions(self, allowed_actions: List[str]) -> List[Dict[str, Any]]:
|
| 1321 |
"""Convert action type strings to formatted action dicts."""
|
| 1322 |
# Map action type names to example parameters
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1323 |
action_templates = {
|
| 1324 |
"BUILD_SETTLEMENT": {
|
| 1325 |
"type": "build_settlement",
|
|
@@ -1338,7 +1341,7 @@ class AIManager:
|
|
| 1338 |
},
|
| 1339 |
"ROLL_DICE": {
|
| 1340 |
"type": "roll_dice",
|
| 1341 |
-
"description": "
|
| 1342 |
"example_parameters": "{}"
|
| 1343 |
},
|
| 1344 |
"END_TURN": {
|
|
@@ -1346,6 +1349,14 @@ class AIManager:
|
|
| 1346 |
"description": "End your turn",
|
| 1347 |
"example_parameters": "{}"
|
| 1348 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1349 |
"BUY_DEV_CARD": {
|
| 1350 |
"type": "buy_dev_card",
|
| 1351 |
"description": "Buy a development card",
|
|
@@ -2092,7 +2103,6 @@ class AIManager:
|
|
| 2092 |
"internal_thinking": data.get("internal_thinking", ""),
|
| 2093 |
"note_to_self": data.get("note_to_self"),
|
| 2094 |
"say_outloud": data.get("say_outloud"),
|
| 2095 |
-
"relationship_update": data.get("relationship_update"),
|
| 2096 |
}
|
| 2097 |
|
| 2098 |
if response_type == ResponseType.OBSERVING:
|
|
|
|
| 474 |
# If parsed doesn't have say_outloud, use LLM's
|
| 475 |
if not parsed.get("say_outloud") and llm_suggestion.get("say_outloud"):
|
| 476 |
parsed["say_outloud"] = llm_suggestion["say_outloud"]
|
|
|
|
|
|
|
| 477 |
|
| 478 |
if parsed:
|
| 479 |
# Update memory
|
|
|
|
| 481 |
agent.update_memory(note_to_self)
|
| 482 |
if note_to_self:
|
| 483 |
self._maybe_compact_agent_memory(agent, game_state)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
|
| 485 |
# Save memories to file for web viewer (real-time update)
|
| 486 |
+
if note_to_self:
|
| 487 |
self.logger.save_agent_memories(self.agents)
|
| 488 |
|
| 489 |
# Clear events since they've been processed
|
|
|
|
| 784 |
self._maybe_compact_agent_memory(agent, game_state)
|
| 785 |
self.logger.save_agent_memories(self.agents)
|
| 786 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
say_outloud = (parsed.get("say_outloud") or "").strip()
|
| 788 |
if say_outloud:
|
| 789 |
self._broadcast_chat(player_name, say_outloud)
|
|
|
|
| 837 |
"ROLL_DICE",
|
| 838 |
"BUY_DEV_CARD",
|
| 839 |
"END_TURN",
|
| 840 |
+
"END_GAME",
|
| 841 |
"TRADE_ACCEPT",
|
| 842 |
"TRADE_REJECT",
|
| 843 |
}
|
|
|
|
| 1029 |
"roll": ("roll_dice", None),
|
| 1030 |
"e": ("end_turn", None),
|
| 1031 |
"end": ("end_turn", None),
|
| 1032 |
+
"end_game": ("end_game", None),
|
| 1033 |
+
"game_end": ("end_game", None),
|
| 1034 |
"pass": ("end_turn", None),
|
| 1035 |
"dev": ("buy_dev_card", None),
|
| 1036 |
"buy": ("buy_dev_card", None),
|
|
|
|
| 1147 |
compacted_memory=result["compacted_memory"],
|
| 1148 |
recent_notes_to_keep=result["recent_entries"],
|
| 1149 |
)
|
| 1150 |
+
relationship_updates = result.get("relationship_updates") or []
|
| 1151 |
+
for relationship_update in relationship_updates:
|
| 1152 |
+
agent.update_relationship_context(relationship_update)
|
| 1153 |
+
if relationship_updates:
|
| 1154 |
+
self.logger.save_agent_memories(self.agents)
|
| 1155 |
+
|
| 1156 |
artifact_paths = self.logger.log_memory_compaction(
|
| 1157 |
agent.player_name,
|
| 1158 |
agent.compaction_count,
|
|
|
|
| 1312 |
"Do not answer every message. If you do speak, write natural "
|
| 1313 |
f"{language_name} only, keep it brief, human, and non-technical. "
|
| 1314 |
f"{resource_terms}"
|
| 1315 |
+
"You may update note_to_self with useful strategy context."
|
| 1316 |
)
|
| 1317 |
|
| 1318 |
def _format_allowed_actions(self, allowed_actions: List[str]) -> List[Dict[str, Any]]:
|
| 1319 |
"""Convert action type strings to formatted action dicts."""
|
| 1320 |
# Map action type names to example parameters
|
| 1321 |
+
try:
|
| 1322 |
+
vp_to_win = int((self._current_game_state or {}).get("meta", {}).get("vp_to_win", 5))
|
| 1323 |
+
except (TypeError, ValueError):
|
| 1324 |
+
vp_to_win = 5
|
| 1325 |
+
|
| 1326 |
action_templates = {
|
| 1327 |
"BUILD_SETTLEMENT": {
|
| 1328 |
"type": "build_settlement",
|
|
|
|
| 1341 |
},
|
| 1342 |
"ROLL_DICE": {
|
| 1343 |
"type": "roll_dice",
|
| 1344 |
+
"description": f"Start your Catan turn by rolling the dice. Context: this game is played to {vp_to_win} victory points.",
|
| 1345 |
"example_parameters": "{}"
|
| 1346 |
},
|
| 1347 |
"END_TURN": {
|
|
|
|
| 1349 |
"description": "End your turn",
|
| 1350 |
"example_parameters": "{}"
|
| 1351 |
},
|
| 1352 |
+
"END_GAME": {
|
| 1353 |
+
"type": "end_game",
|
| 1354 |
+
"description": (
|
| 1355 |
+
"Post-game only. Say any final table talk in say_outloud, "
|
| 1356 |
+
"then choose this when you are done and have nothing more to say or hear."
|
| 1357 |
+
),
|
| 1358 |
+
"example_parameters": "{}"
|
| 1359 |
+
},
|
| 1360 |
"BUY_DEV_CARD": {
|
| 1361 |
"type": "buy_dev_card",
|
| 1362 |
"description": "Buy a development card",
|
|
|
|
| 2103 |
"internal_thinking": data.get("internal_thinking", ""),
|
| 2104 |
"note_to_self": data.get("note_to_self"),
|
| 2105 |
"say_outloud": data.get("say_outloud"),
|
|
|
|
| 2106 |
}
|
| 2107 |
|
| 2108 |
if response_type == ResponseType.OBSERVING:
|
pycatan/ai/ai_user.py
CHANGED
|
@@ -172,11 +172,6 @@ class AIUser(User):
|
|
| 172 |
# Save memories to file for web viewer
|
| 173 |
self.ai_manager.logger.save_agent_memories(self.ai_manager.agents)
|
| 174 |
|
| 175 |
-
relationship_update = llm_response.get("relationship_update")
|
| 176 |
-
if relationship_update:
|
| 177 |
-
agent.update_relationship_context(relationship_update)
|
| 178 |
-
self.ai_manager.logger.save_agent_memories(self.ai_manager.agents)
|
| 179 |
-
|
| 180 |
# Broadcast say_outloud to chat
|
| 181 |
say_outloud = llm_response.get("say_outloud")
|
| 182 |
if say_outloud:
|
|
@@ -192,6 +187,7 @@ class AIUser(User):
|
|
| 192 |
Examples:
|
| 193 |
roll_dice - Roll the dice
|
| 194 |
end_turn - End your turn
|
|
|
|
| 195 |
build_settlement {"node": 14} - Build settlement at node 14
|
| 196 |
build_road {"from": 14, "to": 15} - Build road from node 14 to 15
|
| 197 |
build_city {"node": 14} - Upgrade settlement to city
|
|
@@ -229,6 +225,7 @@ class AIUser(User):
|
|
| 229 |
"build_road": ActionType.BUILD_ROAD,
|
| 230 |
"roll_dice": ActionType.ROLL_DICE,
|
| 231 |
"end_turn": ActionType.END_TURN,
|
|
|
|
| 232 |
"wait_for_response": ActionType.END_TURN,
|
| 233 |
"buy_dev_card": ActionType.BUY_DEV_CARD,
|
| 234 |
"use_dev_card": ActionType.USE_DEV_CARD,
|
|
|
|
| 172 |
# Save memories to file for web viewer
|
| 173 |
self.ai_manager.logger.save_agent_memories(self.ai_manager.agents)
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
# Broadcast say_outloud to chat
|
| 176 |
say_outloud = llm_response.get("say_outloud")
|
| 177 |
if say_outloud:
|
|
|
|
| 187 |
Examples:
|
| 188 |
roll_dice - Roll the dice
|
| 189 |
end_turn - End your turn
|
| 190 |
+
end_game - Leave the post-game conversation
|
| 191 |
build_settlement {"node": 14} - Build settlement at node 14
|
| 192 |
build_road {"from": 14, "to": 15} - Build road from node 14 to 15
|
| 193 |
build_city {"node": 14} - Upgrade settlement to city
|
|
|
|
| 225 |
"build_road": ActionType.BUILD_ROAD,
|
| 226 |
"roll_dice": ActionType.ROLL_DICE,
|
| 227 |
"end_turn": ActionType.END_TURN,
|
| 228 |
+
"end_game": ActionType.END_GAME,
|
| 229 |
"wait_for_response": ActionType.END_TURN,
|
| 230 |
"buy_dev_card": ActionType.BUY_DEV_CARD,
|
| 231 |
"use_dev_card": ActionType.USE_DEV_CARD,
|
pycatan/ai/memory_compactor.py
CHANGED
|
@@ -33,10 +33,16 @@ COMPACTION_RESPONSE_SCHEMA: Dict[str, Any] = {
|
|
| 33 |
"description": "Short categories of information removed.",
|
| 34 |
"items": {"type": "string"},
|
| 35 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
},
|
| 37 |
"propertyOrdering": [
|
| 38 |
"compacted_memory",
|
| 39 |
"recent_notes_to_keep",
|
|
|
|
| 40 |
"discarded_as_irrelevant",
|
| 41 |
],
|
| 42 |
}
|
|
@@ -107,9 +113,14 @@ class MemoryCompactor:
|
|
| 107 |
return {
|
| 108 |
"compacted_memory": compacted_memory,
|
| 109 |
"existing_compacted_memory": agent.compacted_memory,
|
|
|
|
| 110 |
"old_entries": old_entries,
|
| 111 |
"recent_entries": recent_entries,
|
| 112 |
"recent_notes_to_keep": parsed.get("recent_notes_to_keep", []),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
"discarded_as_irrelevant": parsed.get("discarded_as_irrelevant", []),
|
| 114 |
"relevant_chat": self._relevant_chat(agent.player_name, chat_history, chat_limit),
|
| 115 |
"prompt": prompt,
|
|
@@ -144,6 +155,9 @@ class MemoryCompactor:
|
|
| 144 |
"known or likely opponent plans/resources/dev cards/trade tendencies, active negotiations, "
|
| 145 |
"social commitments, and mistakes to avoid. Discard repeated, completed, impossible, vague, "
|
| 146 |
"or superseded details. Do not invent facts; mark uncertainty clearly. "
|
|
|
|
|
|
|
|
|
|
| 147 |
"Target about 50% or less of the combined old memory length. "
|
| 148 |
"Keep recent_notes_to_keep copied verbatim from the provided recent notes."
|
| 149 |
)
|
|
@@ -151,6 +165,7 @@ class MemoryCompactor:
|
|
| 151 |
"game_state": self.prompt_builder._build_game_state_section(game_state),
|
| 152 |
"memory_input": {
|
| 153 |
"existing_compacted_memory": agent.compacted_memory,
|
|
|
|
| 154 |
"old_notes_to_compact": old_note_texts,
|
| 155 |
"recent_notes_to_keep": recent_note_texts,
|
| 156 |
"relevant_chat": chat_history,
|
|
@@ -160,11 +175,41 @@ class MemoryCompactor:
|
|
| 160 |
"schema": {
|
| 161 |
"compacted_memory": "string",
|
| 162 |
"recent_notes_to_keep": ["string"],
|
|
|
|
| 163 |
"discarded_as_irrelevant": ["string"],
|
| 164 |
},
|
| 165 |
},
|
| 166 |
}
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def _relevant_chat(
|
| 169 |
self,
|
| 170 |
player_name: str,
|
|
|
|
| 33 |
"description": "Short categories of information removed.",
|
| 34 |
"items": {"type": "string"},
|
| 35 |
},
|
| 36 |
+
"relationship_updates": {
|
| 37 |
+
"type": "array",
|
| 38 |
+
"description": "New concise relationship shifts for future table talk, trust, trades, and tie-breakers. Empty if nothing changed.",
|
| 39 |
+
"items": {"type": "string", "maxLength": 120},
|
| 40 |
+
},
|
| 41 |
},
|
| 42 |
"propertyOrdering": [
|
| 43 |
"compacted_memory",
|
| 44 |
"recent_notes_to_keep",
|
| 45 |
+
"relationship_updates",
|
| 46 |
"discarded_as_irrelevant",
|
| 47 |
],
|
| 48 |
}
|
|
|
|
| 113 |
return {
|
| 114 |
"compacted_memory": compacted_memory,
|
| 115 |
"existing_compacted_memory": agent.compacted_memory,
|
| 116 |
+
"existing_relationship_updates": agent.relationship_context_updates,
|
| 117 |
"old_entries": old_entries,
|
| 118 |
"recent_entries": recent_entries,
|
| 119 |
"recent_notes_to_keep": parsed.get("recent_notes_to_keep", []),
|
| 120 |
+
"relationship_updates": self._clean_relationship_updates(
|
| 121 |
+
parsed.get("relationship_updates", []),
|
| 122 |
+
agent.relationship_context_updates,
|
| 123 |
+
),
|
| 124 |
"discarded_as_irrelevant": parsed.get("discarded_as_irrelevant", []),
|
| 125 |
"relevant_chat": self._relevant_chat(agent.player_name, chat_history, chat_limit),
|
| 126 |
"prompt": prompt,
|
|
|
|
| 155 |
"known or likely opponent plans/resources/dev cards/trade tendencies, active negotiations, "
|
| 156 |
"social commitments, and mistakes to avoid. Discard repeated, completed, impossible, vague, "
|
| 157 |
"or superseded details. Do not invent facts; mark uncertainty clearly. "
|
| 158 |
+
"Also extract only new meaningful relationship shifts from the old notes and relevant chat: "
|
| 159 |
+
"trust changes, grudges, favors, threats, betrayals, promises, or emotional tension. "
|
| 160 |
+
"Do not repeat existing relationship updates; leave relationship_updates empty if nothing changed. "
|
| 161 |
"Target about 50% or less of the combined old memory length. "
|
| 162 |
"Keep recent_notes_to_keep copied verbatim from the provided recent notes."
|
| 163 |
)
|
|
|
|
| 165 |
"game_state": self.prompt_builder._build_game_state_section(game_state),
|
| 166 |
"memory_input": {
|
| 167 |
"existing_compacted_memory": agent.compacted_memory,
|
| 168 |
+
"existing_relationship_updates": agent.relationship_context_updates,
|
| 169 |
"old_notes_to_compact": old_note_texts,
|
| 170 |
"recent_notes_to_keep": recent_note_texts,
|
| 171 |
"relevant_chat": chat_history,
|
|
|
|
| 175 |
"schema": {
|
| 176 |
"compacted_memory": "string",
|
| 177 |
"recent_notes_to_keep": ["string"],
|
| 178 |
+
"relationship_updates": ["string"],
|
| 179 |
"discarded_as_irrelevant": ["string"],
|
| 180 |
},
|
| 181 |
},
|
| 182 |
}
|
| 183 |
|
| 184 |
+
def _clean_relationship_updates(
|
| 185 |
+
self,
|
| 186 |
+
updates: Any,
|
| 187 |
+
existing_updates: Optional[List[Dict[str, Any]]] = None,
|
| 188 |
+
) -> List[str]:
|
| 189 |
+
"""Return compact unique relationship updates from a model response."""
|
| 190 |
+
if not isinstance(updates, list):
|
| 191 |
+
return []
|
| 192 |
+
|
| 193 |
+
result = []
|
| 194 |
+
seen = {
|
| 195 |
+
str(update.get("note", "")).strip().lower()
|
| 196 |
+
for update in existing_updates or []
|
| 197 |
+
if isinstance(update, dict) and update.get("note")
|
| 198 |
+
}
|
| 199 |
+
for update in updates:
|
| 200 |
+
text = str(update).strip()
|
| 201 |
+
if not text:
|
| 202 |
+
continue
|
| 203 |
+
text = re.sub(r"\s+", " ", text)[:120].strip()
|
| 204 |
+
key = text.lower()
|
| 205 |
+
if key in seen:
|
| 206 |
+
continue
|
| 207 |
+
result.append(text)
|
| 208 |
+
seen.add(key)
|
| 209 |
+
if len(result) >= 3:
|
| 210 |
+
break
|
| 211 |
+
return result
|
| 212 |
+
|
| 213 |
def _relevant_chat(
|
| 214 |
self,
|
| 215 |
player_name: str,
|
pycatan/ai/prompt_manager.py
CHANGED
|
@@ -89,9 +89,14 @@ class PromptManager:
|
|
| 89 |
filtered_state = state_filter.filter_game_state(game_state)
|
| 90 |
|
| 91 |
# Build meta data section
|
|
|
|
| 92 |
meta_data = {
|
| 93 |
"agent_name": player_name,
|
| 94 |
-
"role": custom_instructions or self.config.agent.custom_instructions
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
}
|
| 96 |
relationship_context = self._build_relationship_context(
|
| 97 |
player_name,
|
|
@@ -388,6 +393,15 @@ class PromptManager:
|
|
| 388 |
)
|
| 389 |
return "Any say_outloud chat message must be written in natural English only."
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
def _build_relationship_context(
|
| 392 |
self,
|
| 393 |
player_name: str,
|
|
|
|
| 89 |
filtered_state = state_filter.filter_game_state(game_state)
|
| 90 |
|
| 91 |
# Build meta data section
|
| 92 |
+
victory_points_to_win = self._get_victory_points_to_win(game_state)
|
| 93 |
meta_data = {
|
| 94 |
"agent_name": player_name,
|
| 95 |
+
"role": custom_instructions or self.config.agent.custom_instructions,
|
| 96 |
+
"game_context": (
|
| 97 |
+
f"CONTEXT: You are playing Catan to {victory_points_to_win} victory points. "
|
| 98 |
+
f"The first player to reach {victory_points_to_win} VP wins."
|
| 99 |
+
)
|
| 100 |
}
|
| 101 |
relationship_context = self._build_relationship_context(
|
| 102 |
player_name,
|
|
|
|
| 393 |
)
|
| 394 |
return "Any say_outloud chat message must be written in natural English only."
|
| 395 |
|
| 396 |
+
def _get_victory_points_to_win(self, game_state: Dict[str, Any]) -> int:
|
| 397 |
+
"""Read the configured victory point target from compact state."""
|
| 398 |
+
meta = game_state.get("meta", {}) if isinstance(game_state, dict) else {}
|
| 399 |
+
value = meta.get("vp_to_win", 5)
|
| 400 |
+
try:
|
| 401 |
+
return int(value)
|
| 402 |
+
except (TypeError, ValueError):
|
| 403 |
+
return 5
|
| 404 |
+
|
| 405 |
def _build_relationship_context(
|
| 406 |
self,
|
| 407 |
player_name: str,
|
pycatan/ai/prompt_templates.py
CHANGED
|
@@ -173,6 +173,9 @@ JSON:
|
|
| 173 |
result = {
|
| 174 |
"agent_name": meta_data.get("agent_name", "AI Agent"),
|
| 175 |
}
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
# Add role/instructions
|
| 178 |
if custom_instructions:
|
|
|
|
| 173 |
result = {
|
| 174 |
"agent_name": meta_data.get("agent_name", "AI Agent"),
|
| 175 |
}
|
| 176 |
+
|
| 177 |
+
if meta_data.get("game_context"):
|
| 178 |
+
result["game_context"] = meta_data["game_context"]
|
| 179 |
|
| 180 |
# Add role/instructions
|
| 181 |
if custom_instructions:
|
pycatan/ai/schemas.py
CHANGED
|
@@ -51,11 +51,6 @@ ACTIVE_TURN_RESPONSE_SCHEMA_V1 = {
|
|
| 51 |
"description": "Save important observations and plans for future turns. Examples: opponent strategies, key nodes to claim, resource priorities, or threats to watch. This helps maintain continuity between turns.",
|
| 52 |
"maxLength": 100
|
| 53 |
},
|
| 54 |
-
"relationship_update": {
|
| 55 |
-
"type": "string",
|
| 56 |
-
"description": "Optional. Only if a relationship meaningfully changed, save one short update about trust, grudges, favors, threats, or emotional tension.",
|
| 57 |
-
"maxLength": 120
|
| 58 |
-
},
|
| 59 |
"say_outloud": {
|
| 60 |
"type": "string",
|
| 61 |
"description": "Communicate with other players in natural Hebrew only. You have emotions and may express them when it matters. Use for: trade proposals, warnings, bluffs, alliance hints, or strategic banter. Makes the game more interesting and can influence opponents.",
|
|
@@ -80,7 +75,6 @@ ACTIVE_TURN_RESPONSE_SCHEMA_V1 = {
|
|
| 80 |
"propertyOrdering": [
|
| 81 |
"internal_thinking",
|
| 82 |
"note_to_self",
|
| 83 |
-
"relationship_update",
|
| 84 |
"say_outloud",
|
| 85 |
"action"
|
| 86 |
]
|
|
@@ -100,11 +94,6 @@ OBSERVING_RESPONSE_SCHEMA_V1 = {
|
|
| 100 |
"description": "Track key developments! Note opponent positions, resource imbalances, or strategic opportunities you noticed. This memory persists between turns.",
|
| 101 |
"maxLength": 100
|
| 102 |
},
|
| 103 |
-
"relationship_update": {
|
| 104 |
-
"type": "string",
|
| 105 |
-
"description": "Optional. Only if a relationship meaningfully changed, save one short update about trust, grudges, favors, threats, or emotional tension.",
|
| 106 |
-
"maxLength": 120
|
| 107 |
-
},
|
| 108 |
"say_outloud": {
|
| 109 |
"type": "string",
|
| 110 |
"description": "Even when observing, you can negotiate in natural Hebrew only. You have emotions and may express them when it matters. Propose trades, form alliances, or send strategic messages.",
|
|
@@ -114,7 +103,6 @@ OBSERVING_RESPONSE_SCHEMA_V1 = {
|
|
| 114 |
"propertyOrdering": [
|
| 115 |
"internal_thinking",
|
| 116 |
"note_to_self",
|
| 117 |
-
"relationship_update",
|
| 118 |
"say_outloud"
|
| 119 |
]
|
| 120 |
}
|
|
@@ -138,11 +126,6 @@ ACTIVE_TURN_RESPONSE_SCHEMA_V2 = {
|
|
| 138 |
"description": "Save important observations for future turns (e.g., 'Player 3 is hoarding ore').",
|
| 139 |
"maxLength": 100
|
| 140 |
},
|
| 141 |
-
"relationship_update": {
|
| 142 |
-
"type": "string",
|
| 143 |
-
"description": "Optional. Only if a relationship meaningfully changed, save one short update about trust, grudges, favors, threats, or emotional tension.",
|
| 144 |
-
"maxLength": 120
|
| 145 |
-
},
|
| 146 |
"say_outloud": {
|
| 147 |
"type": "string",
|
| 148 |
"description": "Table talk in natural Hebrew only. You have emotions and may express them when it matters. If nothing interesting happened, leave empty. Catan manners are loose: be blunt, annoyed, smug, suspicious, or emotional when it matters. Keep it human and non-technical.",
|
|
@@ -167,7 +150,6 @@ ACTIVE_TURN_RESPONSE_SCHEMA_V2 = {
|
|
| 167 |
"propertyOrdering": [
|
| 168 |
"internal_thinking",
|
| 169 |
"note_to_self",
|
| 170 |
-
"relationship_update",
|
| 171 |
"say_outloud",
|
| 172 |
"action"
|
| 173 |
]
|
|
@@ -187,11 +169,6 @@ OBSERVING_RESPONSE_SCHEMA_V2 = {
|
|
| 187 |
"description": "Save important observations (e.g., 'Blue is going for longest road').",
|
| 188 |
"maxLength": 100
|
| 189 |
},
|
| 190 |
-
"relationship_update": {
|
| 191 |
-
"type": "string",
|
| 192 |
-
"description": "Optional. Only if a relationship meaningfully changed, save one short update about trust, grudges, favors, threats, or emotional tension.",
|
| 193 |
-
"maxLength": 120
|
| 194 |
-
},
|
| 195 |
"say_outloud": {
|
| 196 |
"type": "string",
|
| 197 |
"description": "React naturally in Hebrew only. You have emotions and may express them when it matters. Can be empty if nothing notable. Catan manners are loose: be blunt or emotional when it matters. Keep it non-technical.",
|
|
@@ -201,7 +178,6 @@ OBSERVING_RESPONSE_SCHEMA_V2 = {
|
|
| 201 |
"propertyOrdering": [
|
| 202 |
"internal_thinking",
|
| 203 |
"note_to_self",
|
| 204 |
-
"relationship_update",
|
| 205 |
"say_outloud"
|
| 206 |
]
|
| 207 |
}
|
|
@@ -320,7 +296,6 @@ def get_schema_description(response_type: ResponseType, version: SchemaVersion =
|
|
| 320 |
"- action: {type: action_name, parameters: {...}}\n"
|
| 321 |
"Encouraged (use frequently!):\n"
|
| 322 |
"- note_to_self: Save key observations for future turns (max 100 chars)\n"
|
| 323 |
-
"- relationship_update: Optional relationship shift, only when meaningful (max 120 chars)\n"
|
| 324 |
"- say_outloud: Communicate with other players (max 100 chars)"
|
| 325 |
)
|
| 326 |
else: # V2
|
|
@@ -330,7 +305,6 @@ def get_schema_description(response_type: ResponseType, version: SchemaVersion =
|
|
| 330 |
"- action: {type: action_name, parameters: {...}}\n"
|
| 331 |
"Optional:\n"
|
| 332 |
"- note_to_self: Save observations for later (max 100 chars)\n"
|
| 333 |
-
"- relationship_update: Relationship shift, only when meaningful (max 120 chars)\n"
|
| 334 |
"- say_outloud: Natural table talk - casual, not technical (max 120 chars)"
|
| 335 |
)
|
| 336 |
elif response_type == ResponseType.OBSERVING:
|
|
@@ -340,7 +314,6 @@ def get_schema_description(response_type: ResponseType, version: SchemaVersion =
|
|
| 340 |
"- internal_thinking: Track opponent moves, verify positions in Arrays N/H (min 30 chars)\n"
|
| 341 |
"Encouraged (use frequently!):\n"
|
| 342 |
"- note_to_self: Track key developments for later (max 100 chars)\n"
|
| 343 |
-
"- relationship_update: Optional relationship shift, only when meaningful (max 120 chars)\n"
|
| 344 |
"- say_outloud: Negotiate or send messages (max 100 chars)"
|
| 345 |
)
|
| 346 |
else: # V2
|
|
@@ -349,7 +322,6 @@ def get_schema_description(response_type: ResponseType, version: SchemaVersion =
|
|
| 349 |
"- internal_thinking: Private thoughts while watching (min 30 chars)\n"
|
| 350 |
"Optional:\n"
|
| 351 |
"- note_to_self: Save important observations (max 100 chars)\n"
|
| 352 |
-
"- relationship_update: Relationship shift, only when meaningful (max 120 chars)\n"
|
| 353 |
"- say_outloud: React naturally - keep it casual (max 120 chars)"
|
| 354 |
)
|
| 355 |
else:
|
|
@@ -446,6 +418,10 @@ ACTION_PARAMETER_SCHEMAS = {
|
|
| 446 |
"end_turn": {
|
| 447 |
"required": [],
|
| 448 |
"properties": {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
}
|
| 450 |
}
|
| 451 |
|
|
|
|
| 51 |
"description": "Save important observations and plans for future turns. Examples: opponent strategies, key nodes to claim, resource priorities, or threats to watch. This helps maintain continuity between turns.",
|
| 52 |
"maxLength": 100
|
| 53 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
"say_outloud": {
|
| 55 |
"type": "string",
|
| 56 |
"description": "Communicate with other players in natural Hebrew only. You have emotions and may express them when it matters. Use for: trade proposals, warnings, bluffs, alliance hints, or strategic banter. Makes the game more interesting and can influence opponents.",
|
|
|
|
| 75 |
"propertyOrdering": [
|
| 76 |
"internal_thinking",
|
| 77 |
"note_to_self",
|
|
|
|
| 78 |
"say_outloud",
|
| 79 |
"action"
|
| 80 |
]
|
|
|
|
| 94 |
"description": "Track key developments! Note opponent positions, resource imbalances, or strategic opportunities you noticed. This memory persists between turns.",
|
| 95 |
"maxLength": 100
|
| 96 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
"say_outloud": {
|
| 98 |
"type": "string",
|
| 99 |
"description": "Even when observing, you can negotiate in natural Hebrew only. You have emotions and may express them when it matters. Propose trades, form alliances, or send strategic messages.",
|
|
|
|
| 103 |
"propertyOrdering": [
|
| 104 |
"internal_thinking",
|
| 105 |
"note_to_self",
|
|
|
|
| 106 |
"say_outloud"
|
| 107 |
]
|
| 108 |
}
|
|
|
|
| 126 |
"description": "Save important observations for future turns (e.g., 'Player 3 is hoarding ore').",
|
| 127 |
"maxLength": 100
|
| 128 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
"say_outloud": {
|
| 130 |
"type": "string",
|
| 131 |
"description": "Table talk in natural Hebrew only. You have emotions and may express them when it matters. If nothing interesting happened, leave empty. Catan manners are loose: be blunt, annoyed, smug, suspicious, or emotional when it matters. Keep it human and non-technical.",
|
|
|
|
| 150 |
"propertyOrdering": [
|
| 151 |
"internal_thinking",
|
| 152 |
"note_to_self",
|
|
|
|
| 153 |
"say_outloud",
|
| 154 |
"action"
|
| 155 |
]
|
|
|
|
| 169 |
"description": "Save important observations (e.g., 'Blue is going for longest road').",
|
| 170 |
"maxLength": 100
|
| 171 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
"say_outloud": {
|
| 173 |
"type": "string",
|
| 174 |
"description": "React naturally in Hebrew only. You have emotions and may express them when it matters. Can be empty if nothing notable. Catan manners are loose: be blunt or emotional when it matters. Keep it non-technical.",
|
|
|
|
| 178 |
"propertyOrdering": [
|
| 179 |
"internal_thinking",
|
| 180 |
"note_to_self",
|
|
|
|
| 181 |
"say_outloud"
|
| 182 |
]
|
| 183 |
}
|
|
|
|
| 296 |
"- action: {type: action_name, parameters: {...}}\n"
|
| 297 |
"Encouraged (use frequently!):\n"
|
| 298 |
"- note_to_self: Save key observations for future turns (max 100 chars)\n"
|
|
|
|
| 299 |
"- say_outloud: Communicate with other players (max 100 chars)"
|
| 300 |
)
|
| 301 |
else: # V2
|
|
|
|
| 305 |
"- action: {type: action_name, parameters: {...}}\n"
|
| 306 |
"Optional:\n"
|
| 307 |
"- note_to_self: Save observations for later (max 100 chars)\n"
|
|
|
|
| 308 |
"- say_outloud: Natural table talk - casual, not technical (max 120 chars)"
|
| 309 |
)
|
| 310 |
elif response_type == ResponseType.OBSERVING:
|
|
|
|
| 314 |
"- internal_thinking: Track opponent moves, verify positions in Arrays N/H (min 30 chars)\n"
|
| 315 |
"Encouraged (use frequently!):\n"
|
| 316 |
"- note_to_self: Track key developments for later (max 100 chars)\n"
|
|
|
|
| 317 |
"- say_outloud: Negotiate or send messages (max 100 chars)"
|
| 318 |
)
|
| 319 |
else: # V2
|
|
|
|
| 322 |
"- internal_thinking: Private thoughts while watching (min 30 chars)\n"
|
| 323 |
"Optional:\n"
|
| 324 |
"- note_to_self: Save important observations (max 100 chars)\n"
|
|
|
|
| 325 |
"- say_outloud: React naturally - keep it casual (max 120 chars)"
|
| 326 |
)
|
| 327 |
else:
|
|
|
|
| 418 |
"end_turn": {
|
| 419 |
"required": [],
|
| 420 |
"properties": {}
|
| 421 |
+
},
|
| 422 |
+
"end_game": {
|
| 423 |
+
"required": [],
|
| 424 |
+
"properties": {}
|
| 425 |
}
|
| 426 |
}
|
| 427 |
|
pycatan/ai/state_optimizer.py
CHANGED
|
@@ -57,6 +57,7 @@ def game_state_to_dict(game_state) -> Dict[str, Any]:
|
|
| 57 |
'current_player': getattr(game_state, 'current_player', 0),
|
| 58 |
'current_phase': game_state.game_phase.name if hasattr(game_state.game_phase, 'name') else str(game_state.game_phase),
|
| 59 |
'dice_result': getattr(game_state, 'dice_rolled', None),
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
# Convert board data
|
|
@@ -299,7 +300,8 @@ def optimize_state_for_ai(input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 299 |
"curr": curr_name,
|
| 300 |
"phase": data.get('current_phase'),
|
| 301 |
"robber": robber_hex,
|
| 302 |
-
"dice": dice_result
|
|
|
|
| 303 |
}
|
| 304 |
if dice_total is not None:
|
| 305 |
meta["dice_total"] = dice_total
|
|
|
|
| 57 |
'current_player': getattr(game_state, 'current_player', 0),
|
| 58 |
'current_phase': game_state.game_phase.name if hasattr(game_state.game_phase, 'name') else str(game_state.game_phase),
|
| 59 |
'dice_result': getattr(game_state, 'dice_rolled', None),
|
| 60 |
+
'victory_points_to_win': getattr(game_state, 'victory_points_to_win', 5),
|
| 61 |
}
|
| 62 |
|
| 63 |
# Convert board data
|
|
|
|
| 300 |
"curr": curr_name,
|
| 301 |
"phase": data.get('current_phase'),
|
| 302 |
"robber": robber_hex,
|
| 303 |
+
"dice": dice_result,
|
| 304 |
+
"vp_to_win": data.get('victory_points_to_win', 5)
|
| 305 |
}
|
| 306 |
if dice_total is not None:
|
| 307 |
meta["dice_total"] = dice_total
|
pycatan/management/actions.py
CHANGED
|
@@ -33,6 +33,7 @@ class ActionType(Enum):
|
|
| 33 |
# Turn management
|
| 34 |
ROLL_DICE = auto()
|
| 35 |
END_TURN = auto()
|
|
|
|
| 36 |
|
| 37 |
# Special actions
|
| 38 |
ROBBER_MOVE = auto()
|
|
@@ -143,6 +144,7 @@ class GameState:
|
|
| 143 |
"""
|
| 144 |
# Game metadata
|
| 145 |
game_id: str = ""
|
|
|
|
| 146 |
turn_number: int = 0
|
| 147 |
current_player: int = 0
|
| 148 |
game_phase: GamePhase = GamePhase.SETUP_FIRST_ROUND
|
|
@@ -252,4 +254,4 @@ def create_trade_action(player_id: int, offer: Dict[str, int], request: Dict[str
|
|
| 252 |
action_type=action_type,
|
| 253 |
player_id=player_id,
|
| 254 |
parameters=parameters
|
| 255 |
-
)
|
|
|
|
| 33 |
# Turn management
|
| 34 |
ROLL_DICE = auto()
|
| 35 |
END_TURN = auto()
|
| 36 |
+
END_GAME = auto()
|
| 37 |
|
| 38 |
# Special actions
|
| 39 |
ROBBER_MOVE = auto()
|
|
|
|
| 144 |
"""
|
| 145 |
# Game metadata
|
| 146 |
game_id: str = ""
|
| 147 |
+
victory_points_to_win: int = 5
|
| 148 |
turn_number: int = 0
|
| 149 |
current_player: int = 0
|
| 150 |
game_phase: GamePhase = GamePhase.SETUP_FIRST_ROUND
|
|
|
|
| 254 |
action_type=action_type,
|
| 255 |
player_id=player_id,
|
| 256 |
parameters=parameters
|
| 257 |
+
)
|
pycatan/management/game_manager.py
CHANGED
|
@@ -57,6 +57,7 @@ class GameManager:
|
|
| 57 |
|
| 58 |
# Initialize game configuration
|
| 59 |
self.config = game_config or {}
|
|
|
|
| 60 |
|
| 61 |
# Visualization manager (can be set later)
|
| 62 |
self.visualization_manager = None
|
|
@@ -82,6 +83,10 @@ class GameManager:
|
|
| 82 |
self._pending_actions: List[Action] = []
|
| 83 |
self._trade_counter = 0
|
| 84 |
self._processed_reaction_keys: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# Error tracking per player to prevent infinite loops
|
| 87 |
self._player_error_count = [0] * self.num_players
|
|
@@ -129,6 +134,7 @@ class GameManager:
|
|
| 129 |
|
| 130 |
# Update with GameManager-specific information
|
| 131 |
game_state.game_id = self.game_id
|
|
|
|
| 132 |
game_state.turn_number = self._current_game_state.turn_number
|
| 133 |
game_state.current_player = self._current_game_state.current_player
|
| 134 |
game_state.game_phase = self._current_game_state.game_phase
|
|
@@ -193,6 +199,8 @@ class GameManager:
|
|
| 193 |
ActionType.USE_DEV_CARD.name,
|
| 194 |
ActionType.END_TURN.name
|
| 195 |
])
|
|
|
|
|
|
|
| 196 |
|
| 197 |
return actions
|
| 198 |
|
|
@@ -237,6 +245,8 @@ class GameManager:
|
|
| 237 |
# Route to appropriate handler based on action type
|
| 238 |
if action.action_type == ActionType.END_TURN:
|
| 239 |
return self._handle_end_turn(action)
|
|
|
|
|
|
|
| 240 |
elif action.action_type in [ActionType.BUILD_SETTLEMENT, ActionType.BUILD_CITY, ActionType.BUILD_ROAD,
|
| 241 |
ActionType.PLACE_STARTING_SETTLEMENT, ActionType.PLACE_STARTING_ROAD]:
|
| 242 |
return self._handle_building_action(action)
|
|
@@ -279,6 +289,16 @@ class GameManager:
|
|
| 279 |
self.get_full_state(),
|
| 280 |
affected_players=[action.player_id]
|
| 281 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
def _handle_building_action(self, action: Action) -> ActionResult:
|
| 284 |
"""Handle building actions (settlements, cities, roads)."""
|
|
@@ -1443,7 +1463,8 @@ class GameManager:
|
|
| 1443 |
f"Player {self.current_player_id} can try again."
|
| 1444 |
)
|
| 1445 |
|
| 1446 |
-
# Game has ended - handle cleanup
|
|
|
|
| 1447 |
self._handle_game_end()
|
| 1448 |
|
| 1449 |
def _handle_single_turn(self) -> bool:
|
|
@@ -1808,6 +1829,9 @@ class GameManager:
|
|
| 1808 |
direct hostile events. The prompt is sent after the board state has
|
| 1809 |
already been updated, so observers see the real current state.
|
| 1810 |
"""
|
|
|
|
|
|
|
|
|
|
| 1811 |
reaction_prompts: Dict[int, List[str]] = {}
|
| 1812 |
source_name = self.users[action.player_id].name if hasattr(self.users[action.player_id], 'name') else f"Player {action.player_id}"
|
| 1813 |
say_outloud = ""
|
|
@@ -1948,6 +1972,8 @@ class GameManager:
|
|
| 1948 |
return "built a road"
|
| 1949 |
elif action.action_type == ActionType.END_TURN:
|
| 1950 |
return "ended their turn"
|
|
|
|
|
|
|
| 1951 |
elif action.action_type == ActionType.TRADE_PROPOSE:
|
| 1952 |
return "proposed a trade"
|
| 1953 |
else:
|
|
@@ -2046,6 +2072,81 @@ class GameManager:
|
|
| 2046 |
self._notify_all_users("game_end", "Game has ended.")
|
| 2047 |
|
| 2048 |
# TODO: Cleanup resources, save game state, etc.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2049 |
|
| 2050 |
def _check_game_end_conditions(self) -> bool:
|
| 2051 |
"""
|
|
@@ -2054,8 +2155,8 @@ class GameManager:
|
|
| 2054 |
This function examines the current game state to determine
|
| 2055 |
if any player has achieved victory conditions.
|
| 2056 |
|
| 2057 |
-
|
| 2058 |
-
1. First player to reach
|
| 2059 |
2. Victory points come from: settlements (1), cities (2),
|
| 2060 |
development cards (1 each), longest road (2), largest army (2)
|
| 2061 |
|
|
@@ -2070,9 +2171,10 @@ class GameManager:
|
|
| 2070 |
# We include dev cards because we want to know if they actually won
|
| 2071 |
victory_points = player.get_VP(include_dev=True)
|
| 2072 |
|
| 2073 |
-
# Check if this player has won
|
| 2074 |
-
if victory_points >=
|
| 2075 |
-
self.
|
|
|
|
| 2076 |
return True
|
| 2077 |
|
| 2078 |
# No player has won yet
|
|
@@ -2087,6 +2189,9 @@ class GameManager:
|
|
| 2087 |
victory_points: Number of victory points the winner achieved
|
| 2088 |
"""
|
| 2089 |
winner_name = self.users[player_id].name if hasattr(self.users[player_id], 'name') else f"Player {player_id}"
|
|
|
|
|
|
|
|
|
|
| 2090 |
|
| 2091 |
self._notify_all_users(
|
| 2092 |
"game_winner",
|
|
@@ -2646,6 +2751,9 @@ class GameManager:
|
|
| 2646 |
|
| 2647 |
elif phase == TurnPhase.ROBBER_MOVE:
|
| 2648 |
return "Move the robber to a tile. Use: robber <tile_id> (click tiles in web view to see IDs)"
|
|
|
|
|
|
|
|
|
|
| 2649 |
|
| 2650 |
elif (
|
| 2651 |
ActionType.ROLL_DICE.name in allowed_set
|
|
@@ -2660,11 +2768,11 @@ class GameManager:
|
|
| 2660 |
])
|
| 2661 |
):
|
| 2662 |
if ActionType.USE_DEV_CARD.name in allowed_set:
|
| 2663 |
-
return "Start your turn by rolling the dice. You may use a development card before rolling if it is useful."
|
| 2664 |
-
return "
|
| 2665 |
|
| 2666 |
elif phase == TurnPhase.ROLL_DICE:
|
| 2667 |
-
return "
|
| 2668 |
|
| 2669 |
elif phase == TurnPhase.PLAYER_ACTIONS:
|
| 2670 |
return "Your turn - build, trade, or end turn. Type 'help' for commands."
|
|
|
|
| 57 |
|
| 58 |
# Initialize game configuration
|
| 59 |
self.config = game_config or {}
|
| 60 |
+
self.victory_points_to_win = int(self.config.get("victory_points", 5))
|
| 61 |
|
| 62 |
# Visualization manager (can be set later)
|
| 63 |
self.visualization_manager = None
|
|
|
|
| 83 |
self._pending_actions: List[Action] = []
|
| 84 |
self._trade_counter = 0
|
| 85 |
self._processed_reaction_keys: List[str] = []
|
| 86 |
+
self._winner_announced = False
|
| 87 |
+
self._winner_player_id: Optional[int] = None
|
| 88 |
+
self._winner_victory_points: Optional[int] = None
|
| 89 |
+
self._post_game_enders: set[int] = set()
|
| 90 |
|
| 91 |
# Error tracking per player to prevent infinite loops
|
| 92 |
self._player_error_count = [0] * self.num_players
|
|
|
|
| 134 |
|
| 135 |
# Update with GameManager-specific information
|
| 136 |
game_state.game_id = self.game_id
|
| 137 |
+
game_state.victory_points_to_win = self.victory_points_to_win
|
| 138 |
game_state.turn_number = self._current_game_state.turn_number
|
| 139 |
game_state.current_player = self._current_game_state.current_player
|
| 140 |
game_state.game_phase = self._current_game_state.game_phase
|
|
|
|
| 199 |
ActionType.USE_DEV_CARD.name,
|
| 200 |
ActionType.END_TURN.name
|
| 201 |
])
|
| 202 |
+
elif phase == GamePhase.ENDED:
|
| 203 |
+
actions.append(ActionType.END_GAME.name)
|
| 204 |
|
| 205 |
return actions
|
| 206 |
|
|
|
|
| 245 |
# Route to appropriate handler based on action type
|
| 246 |
if action.action_type == ActionType.END_TURN:
|
| 247 |
return self._handle_end_turn(action)
|
| 248 |
+
elif action.action_type == ActionType.END_GAME:
|
| 249 |
+
return self._handle_end_game_action(action)
|
| 250 |
elif action.action_type in [ActionType.BUILD_SETTLEMENT, ActionType.BUILD_CITY, ActionType.BUILD_ROAD,
|
| 251 |
ActionType.PLACE_STARTING_SETTLEMENT, ActionType.PLACE_STARTING_ROAD]:
|
| 252 |
return self._handle_building_action(action)
|
|
|
|
| 289 |
self.get_full_state(),
|
| 290 |
affected_players=[action.player_id]
|
| 291 |
)
|
| 292 |
+
|
| 293 |
+
def _handle_end_game_action(self, action: Action) -> ActionResult:
|
| 294 |
+
"""Handle a player's post-game exit action."""
|
| 295 |
+
self._post_game_enders.add(action.player_id)
|
| 296 |
+
player_name = self.users[action.player_id].name if hasattr(self.users[action.player_id], "name") else f"Player {action.player_id}"
|
| 297 |
+
self._notify_all_users("end_game", f"{player_name} chose END_GAME.")
|
| 298 |
+
return ActionResult.success_result(
|
| 299 |
+
self.get_full_state(),
|
| 300 |
+
affected_players=[action.player_id]
|
| 301 |
+
)
|
| 302 |
|
| 303 |
def _handle_building_action(self, action: Action) -> ActionResult:
|
| 304 |
"""Handle building actions (settlements, cities, roads)."""
|
|
|
|
| 1463 |
f"Player {self.current_player_id} can try again."
|
| 1464 |
)
|
| 1465 |
|
| 1466 |
+
# Game has ended - allow final table-talk exits, then handle cleanup.
|
| 1467 |
+
self._handle_post_game_reactions()
|
| 1468 |
self._handle_game_end()
|
| 1469 |
|
| 1470 |
def _handle_single_turn(self) -> bool:
|
|
|
|
| 1829 |
direct hostile events. The prompt is sent after the board state has
|
| 1830 |
already been updated, so observers see the real current state.
|
| 1831 |
"""
|
| 1832 |
+
if action.action_type == ActionType.END_GAME:
|
| 1833 |
+
return
|
| 1834 |
+
|
| 1835 |
reaction_prompts: Dict[int, List[str]] = {}
|
| 1836 |
source_name = self.users[action.player_id].name if hasattr(self.users[action.player_id], 'name') else f"Player {action.player_id}"
|
| 1837 |
say_outloud = ""
|
|
|
|
| 1972 |
return "built a road"
|
| 1973 |
elif action.action_type == ActionType.END_TURN:
|
| 1974 |
return "ended their turn"
|
| 1975 |
+
elif action.action_type == ActionType.END_GAME:
|
| 1976 |
+
return "chose END_GAME"
|
| 1977 |
elif action.action_type == ActionType.TRADE_PROPOSE:
|
| 1978 |
return "proposed a trade"
|
| 1979 |
else:
|
|
|
|
| 2072 |
self._notify_all_users("game_end", "Game has ended.")
|
| 2073 |
|
| 2074 |
# TODO: Cleanup resources, save game state, etc.
|
| 2075 |
+
|
| 2076 |
+
def _handle_post_game_reactions(self) -> None:
|
| 2077 |
+
"""
|
| 2078 |
+
Give each active player one final prompt after a winner is known.
|
| 2079 |
+
|
| 2080 |
+
This is intentionally offered only after victory is announced. Each
|
| 2081 |
+
player may say a last public message and must finish with END_GAME.
|
| 2082 |
+
Once every active player has chosen END_GAME, normal game cleanup runs.
|
| 2083 |
+
"""
|
| 2084 |
+
if not self._winner_announced:
|
| 2085 |
+
return
|
| 2086 |
+
|
| 2087 |
+
previous_player = self._current_game_state.current_player
|
| 2088 |
+
self._current_game_state.game_phase = GamePhase.ENDED
|
| 2089 |
+
self._current_game_state.turn_phase = TurnPhase.END_TURN
|
| 2090 |
+
self._current_game_state.dice_rolled = None
|
| 2091 |
+
|
| 2092 |
+
for user_id, user in enumerate(self.users):
|
| 2093 |
+
if not user.is_active or user_id in self._post_game_enders:
|
| 2094 |
+
continue
|
| 2095 |
+
|
| 2096 |
+
self._current_game_state.current_player = user_id
|
| 2097 |
+
prompt = self._get_post_game_prompt(user_id)
|
| 2098 |
+
allowed_actions = [ActionType.END_GAME.name]
|
| 2099 |
+
|
| 2100 |
+
try:
|
| 2101 |
+
action = user.get_input(
|
| 2102 |
+
self.get_full_state(),
|
| 2103 |
+
prompt,
|
| 2104 |
+
allowed_actions,
|
| 2105 |
+
)
|
| 2106 |
+
except Exception as exc:
|
| 2107 |
+
self._notify_all_users(
|
| 2108 |
+
"post_game_error",
|
| 2109 |
+
f"Error during post-game response for Player {user_id}: {exc}.",
|
| 2110 |
+
)
|
| 2111 |
+
action = Action(ActionType.END_GAME, user_id, {"forced": True})
|
| 2112 |
+
|
| 2113 |
+
if action.player_id != user_id:
|
| 2114 |
+
action.player_id = user_id
|
| 2115 |
+
|
| 2116 |
+
if action.action_type != ActionType.END_GAME:
|
| 2117 |
+
action = Action(
|
| 2118 |
+
ActionType.END_GAME,
|
| 2119 |
+
user_id,
|
| 2120 |
+
{
|
| 2121 |
+
"forced": True,
|
| 2122 |
+
"original_action": action.action_type.name,
|
| 2123 |
+
},
|
| 2124 |
+
)
|
| 2125 |
+
|
| 2126 |
+
result = self.execute_action(action)
|
| 2127 |
+
setattr(result, "action", action)
|
| 2128 |
+
self._update_all_systems(action, result)
|
| 2129 |
+
|
| 2130 |
+
self._current_game_state.current_player = previous_player
|
| 2131 |
+
|
| 2132 |
+
def _get_post_game_prompt(self, user_id: int) -> str:
|
| 2133 |
+
"""Build the final prompt shown only after the game has a winner."""
|
| 2134 |
+
winner_id = self._winner_player_id
|
| 2135 |
+
winner_name = (
|
| 2136 |
+
self.users[winner_id].name
|
| 2137 |
+
if winner_id is not None and hasattr(self.users[winner_id], "name")
|
| 2138 |
+
else f"Player {winner_id}"
|
| 2139 |
+
)
|
| 2140 |
+
outcome = "You won" if user_id == winner_id else "You lost"
|
| 2141 |
+
points = self._winner_victory_points or self.victory_points_to_win
|
| 2142 |
+
|
| 2143 |
+
return (
|
| 2144 |
+
f"CONTEXT: You are playing Catan to {self.victory_points_to_win} victory points.\n"
|
| 2145 |
+
f"The game is over: {winner_name} won with {points} victory points. {outcome}.\n"
|
| 2146 |
+
"You may react to the win/loss with final table talk or update memory. "
|
| 2147 |
+
"When you are done with the game and have nothing more to say or hear, "
|
| 2148 |
+
"choose END_GAME."
|
| 2149 |
+
)
|
| 2150 |
|
| 2151 |
def _check_game_end_conditions(self) -> bool:
|
| 2152 |
"""
|
|
|
|
| 2155 |
This function examines the current game state to determine
|
| 2156 |
if any player has achieved victory conditions.
|
| 2157 |
|
| 2158 |
+
Configured short-game win conditions:
|
| 2159 |
+
1. First player to reach the configured victory point target wins
|
| 2160 |
2. Victory points come from: settlements (1), cities (2),
|
| 2161 |
development cards (1 each), longest road (2), largest army (2)
|
| 2162 |
|
|
|
|
| 2171 |
# We include dev cards because we want to know if they actually won
|
| 2172 |
victory_points = player.get_VP(include_dev=True)
|
| 2173 |
|
| 2174 |
+
# Check if this player has won.
|
| 2175 |
+
if victory_points >= self.victory_points_to_win:
|
| 2176 |
+
if not self._winner_announced:
|
| 2177 |
+
self._announce_winner(player_id, victory_points)
|
| 2178 |
return True
|
| 2179 |
|
| 2180 |
# No player has won yet
|
|
|
|
| 2189 |
victory_points: Number of victory points the winner achieved
|
| 2190 |
"""
|
| 2191 |
winner_name = self.users[player_id].name if hasattr(self.users[player_id], 'name') else f"Player {player_id}"
|
| 2192 |
+
self._winner_announced = True
|
| 2193 |
+
self._winner_player_id = player_id
|
| 2194 |
+
self._winner_victory_points = victory_points
|
| 2195 |
|
| 2196 |
self._notify_all_users(
|
| 2197 |
"game_winner",
|
|
|
|
| 2751 |
|
| 2752 |
elif phase == TurnPhase.ROBBER_MOVE:
|
| 2753 |
return "Move the robber to a tile. Use: robber <tile_id> (click tiles in web view to see IDs)"
|
| 2754 |
+
|
| 2755 |
+
elif self._current_game_state.game_phase == GamePhase.ENDED:
|
| 2756 |
+
return self._get_post_game_prompt(self.current_player_id)
|
| 2757 |
|
| 2758 |
elif (
|
| 2759 |
ActionType.ROLL_DICE.name in allowed_set
|
|
|
|
| 2768 |
])
|
| 2769 |
):
|
| 2770 |
if ActionType.USE_DEV_CARD.name in allowed_set:
|
| 2771 |
+
return f"CONTEXT: You are playing Catan to {self.victory_points_to_win} victory points. Start your turn by rolling the dice. You may use a development card before rolling if it is useful."
|
| 2772 |
+
return f"CONTEXT: You are playing Catan to {self.victory_points_to_win} victory points. Start your turn by rolling the dice."
|
| 2773 |
|
| 2774 |
elif phase == TurnPhase.ROLL_DICE:
|
| 2775 |
+
return f"CONTEXT: You are playing Catan to {self.victory_points_to_win} victory points. Start your turn by rolling the dice."
|
| 2776 |
|
| 2777 |
elif phase == TurnPhase.PLAYER_ACTIONS:
|
| 2778 |
return "Your turn - build, trade, or end turn. Type 'help' for commands."
|
pycatan/players/human_user.py
CHANGED
|
@@ -173,6 +173,9 @@ class HumanUser(User):
|
|
| 173 |
|
| 174 |
elif command in ['end', 'pass', 'done']:
|
| 175 |
return Action(ActionType.END_TURN, self.user_id)
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
elif command in ['roll', 'dice', 'r']:
|
| 178 |
return Action(ActionType.ROLL_DICE, self.user_id)
|
|
@@ -1011,6 +1014,7 @@ class HumanUser(User):
|
|
| 1011 |
print(" help - Show this help (short: h, ?)")
|
| 1012 |
print(" status - Show all players' status (short: info, i)")
|
| 1013 |
print(" points - Show all valid points (short: p)")
|
|
|
|
| 1014 |
print()
|
| 1015 |
print("📦 RESOURCES: wood, brick, sheep, wheat, ore")
|
| 1016 |
print("🎯 POINTS: Use numbers 1-54. Example: 's 12' builds settlement at point 12")
|
|
@@ -1042,4 +1046,4 @@ class HumanUser(User):
|
|
| 1042 |
|
| 1043 |
# For phase changes, show them clearly
|
| 1044 |
if event_type == 'phase_change':
|
| 1045 |
-
print(f"\n ✨ {message}\n")
|
|
|
|
| 173 |
|
| 174 |
elif command in ['end', 'pass', 'done']:
|
| 175 |
return Action(ActionType.END_TURN, self.user_id)
|
| 176 |
+
|
| 177 |
+
elif command in ['end_game', 'game_end', 'leave']:
|
| 178 |
+
return Action(ActionType.END_GAME, self.user_id)
|
| 179 |
|
| 180 |
elif command in ['roll', 'dice', 'r']:
|
| 181 |
return Action(ActionType.ROLL_DICE, self.user_id)
|
|
|
|
| 1014 |
print(" help - Show this help (short: h, ?)")
|
| 1015 |
print(" status - Show all players' status (short: info, i)")
|
| 1016 |
print(" points - Show all valid points (short: p)")
|
| 1017 |
+
print(" end_game - Leave the post-game conversation")
|
| 1018 |
print()
|
| 1019 |
print("📦 RESOURCES: wood, brick, sheep, wheat, ore")
|
| 1020 |
print("🎯 POINTS: Use numbers 1-54. Example: 's 12' builds settlement at point 12")
|
|
|
|
| 1046 |
|
| 1047 |
# For phase changes, show them clearly
|
| 1048 |
if event_type == 'phase_change':
|
| 1049 |
+
print(f"\n ✨ {message}\n")
|
tests/unit/test_actions.py
CHANGED
|
@@ -24,7 +24,7 @@ class TestActionType:
|
|
| 24 |
'BUILD_SETTLEMENT', 'BUILD_CITY', 'BUILD_ROAD',
|
| 25 |
'TRADE_PROPOSE', 'TRADE_ACCEPT', 'TRADE_REJECT', 'TRADE_COUNTER', 'TRADE_BANK',
|
| 26 |
'USE_DEV_CARD', 'BUY_DEV_CARD',
|
| 27 |
-
'ROLL_DICE', 'END_TURN',
|
| 28 |
'ROBBER_MOVE', 'DISCARD_CARDS', 'STEAL_CARD',
|
| 29 |
'PLACE_STARTING_SETTLEMENT', 'PLACE_STARTING_ROAD'
|
| 30 |
]
|
|
@@ -359,4 +359,4 @@ class TestDataStructureIntegrity:
|
|
| 359 |
|
| 360 |
|
| 361 |
if __name__ == '__main__':
|
| 362 |
-
pytest.main([__file__, '-v'])
|
|
|
|
| 24 |
'BUILD_SETTLEMENT', 'BUILD_CITY', 'BUILD_ROAD',
|
| 25 |
'TRADE_PROPOSE', 'TRADE_ACCEPT', 'TRADE_REJECT', 'TRADE_COUNTER', 'TRADE_BANK',
|
| 26 |
'USE_DEV_CARD', 'BUY_DEV_CARD',
|
| 27 |
+
'ROLL_DICE', 'END_TURN', 'END_GAME',
|
| 28 |
'ROBBER_MOVE', 'DISCARD_CARDS', 'STEAL_CARD',
|
| 29 |
'PLACE_STARTING_SETTLEMENT', 'PLACE_STARTING_ROAD'
|
| 30 |
]
|
|
|
|
| 359 |
|
| 360 |
|
| 361 |
if __name__ == '__main__':
|
| 362 |
+
pytest.main([__file__, '-v'])
|
tests/unit/test_game_manager.py
CHANGED
|
@@ -127,6 +127,29 @@ class TestGameManagerFlow:
|
|
| 127 |
assert not self.gm.is_running
|
| 128 |
assert not self.gm.is_paused
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
class TestGameManagerActions:
|
| 132 |
"""Test action execution and handling."""
|
|
|
|
| 127 |
assert not self.gm.is_running
|
| 128 |
assert not self.gm.is_paused
|
| 129 |
|
| 130 |
+
def test_post_game_prompts_require_end_game_after_winner(self):
|
| 131 |
+
"""After a win, each player gets one final END_GAME prompt."""
|
| 132 |
+
for user in self.users:
|
| 133 |
+
user.set_next_action(Action(ActionType.END_GAME, user.user_id, {}))
|
| 134 |
+
|
| 135 |
+
events = []
|
| 136 |
+
for user in self.users:
|
| 137 |
+
user.notify_game_event = lambda event_type, message, affected_players=None: events.append(
|
| 138 |
+
(event_type, message)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
self.gm.start_game()
|
| 142 |
+
self.gm.game.players[0].victory_points = 5
|
| 143 |
+
|
| 144 |
+
assert self.gm._check_game_end_conditions()
|
| 145 |
+
self.gm._handle_post_game_reactions()
|
| 146 |
+
|
| 147 |
+
assert self.gm._post_game_enders == {0, 1}
|
| 148 |
+
assert self.users[0].last_input_call["allowed_actions"] == ["END_GAME"]
|
| 149 |
+
assert "You won" in self.users[0].last_input_call["prompt_message"]
|
| 150 |
+
assert "You lost" in self.users[1].last_input_call["prompt_message"]
|
| 151 |
+
assert any(event_type == "end_game" for event_type, _message in events)
|
| 152 |
+
|
| 153 |
|
| 154 |
class TestGameManagerActions:
|
| 155 |
"""Test action execution and handling."""
|
tests/unit/test_prompt_manager.py
CHANGED
|
@@ -121,6 +121,26 @@ def test_relationship_context_includes_recent_updates():
|
|
| 121 |
assert "Bob mocked my blocked ore" in context
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def test_trade_context_summarizes_resolved_trades_and_keeps_open_trades_structured():
|
| 125 |
manager = PromptManager()
|
| 126 |
state = _game_state("Shon", "Ziv")
|
|
|
|
| 121 |
assert "Bob mocked my blocked ore" in context
|
| 122 |
|
| 123 |
|
| 124 |
+
def test_prompt_includes_five_point_game_context():
|
| 125 |
+
manager = PromptManager()
|
| 126 |
+
state = _game_state("Hadar", "Shon")
|
| 127 |
+
state["meta"]["vp_to_win"] = 5
|
| 128 |
+
|
| 129 |
+
prompt = manager.create_prompt(
|
| 130 |
+
player_num=0,
|
| 131 |
+
player_name="Hadar",
|
| 132 |
+
player_color="Red",
|
| 133 |
+
game_state=state,
|
| 134 |
+
what_happened="Game start",
|
| 135 |
+
available_actions=[],
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
assert prompt["meta_data"]["game_context"] == (
|
| 139 |
+
"CONTEXT: You are playing Catan to 5 victory points. "
|
| 140 |
+
"The first player to reach 5 VP wins."
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
def test_trade_context_summarizes_resolved_trades_and_keeps_open_trades_structured():
|
| 145 |
manager = PromptManager()
|
| 146 |
state = _game_state("Shon", "Ziv")
|
tests/unit/test_relationship_compaction.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Relationship context compaction tests."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
from pycatan.ai.agent_state import AgentState
|
| 6 |
+
from pycatan.ai.config import AIConfig
|
| 7 |
+
from pycatan.ai.llm_client import LLMResponse
|
| 8 |
+
from pycatan.ai.memory_compactor import MemoryCompactor
|
| 9 |
+
from pycatan.ai.schemas import (
|
| 10 |
+
ResponseType,
|
| 11 |
+
SchemaVersion,
|
| 12 |
+
get_schema_description,
|
| 13 |
+
get_schema_for_response_type,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class _FakeLLMClient:
|
| 18 |
+
def generate(self, *args, **kwargs):
|
| 19 |
+
return LLMResponse(
|
| 20 |
+
success=True,
|
| 21 |
+
content=json.dumps({
|
| 22 |
+
"compacted_memory": "Prefer ore expansion; Shon may be racing city upgrades.",
|
| 23 |
+
"recent_notes_to_keep": ["Recent note A", "Recent note B"],
|
| 24 |
+
"relationship_updates": [
|
| 25 |
+
"Shon refused a fair trade, making future promises less credible.",
|
| 26 |
+
],
|
| 27 |
+
"discarded_as_irrelevant": [],
|
| 28 |
+
}),
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_player_response_schemas_do_not_ask_for_relationship_update():
|
| 33 |
+
for version in [SchemaVersion.V1, SchemaVersion.V2]:
|
| 34 |
+
for response_type in [ResponseType.ACTIVE_TURN, ResponseType.OBSERVING]:
|
| 35 |
+
schema = get_schema_for_response_type(response_type, version)
|
| 36 |
+
assert "relationship_update" not in schema["properties"]
|
| 37 |
+
assert "relationship_update" not in schema["propertyOrdering"]
|
| 38 |
+
assert "relationship_update" not in get_schema_description(response_type, version)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_memory_compactor_extracts_relationship_updates():
|
| 42 |
+
agent = AgentState(player_name="Hadar", player_id=0, player_color="Red")
|
| 43 |
+
agent.memory_history = [
|
| 44 |
+
{"note": "Shon refused my fair trade after promising to help."},
|
| 45 |
+
{"note": "Recent note A"},
|
| 46 |
+
{"note": "Recent note B"},
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
result = MemoryCompactor(AIConfig()).compact(
|
| 50 |
+
agent=agent,
|
| 51 |
+
game_state={
|
| 52 |
+
"meta": {"curr": "Hadar", "phase": "NORMAL_PLAY"},
|
| 53 |
+
"H": [],
|
| 54 |
+
"N": [],
|
| 55 |
+
"state": {"bld": [], "rds": []},
|
| 56 |
+
"players": {"Hadar": {"vp": 0, "res": {}}, "Shon": {"vp": 0, "res": {}}},
|
| 57 |
+
},
|
| 58 |
+
chat_history=[
|
| 59 |
+
{"from": "Shon", "message": "I'll help next time, promise."},
|
| 60 |
+
],
|
| 61 |
+
llm_client=_FakeLLMClient(),
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
assert result is not None
|
| 65 |
+
assert result["relationship_updates"] == [
|
| 66 |
+
"Shon refused a fair trade, making future promises less credible.",
|
| 67 |
+
]
|
| 68 |
+
assert "relationship_updates" in result["prompt"]["output_requirements"]["schema"]
|
| 69 |
+
assert "existing_relationship_updates" in result["prompt"]["memory_input"]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_memory_compactor_filters_repeated_relationship_updates():
|
| 73 |
+
compactor = MemoryCompactor(AIConfig())
|
| 74 |
+
|
| 75 |
+
updates = compactor._clean_relationship_updates(
|
| 76 |
+
[
|
| 77 |
+
"Shon refused a fair trade.",
|
| 78 |
+
"Shon refused a fair trade.",
|
| 79 |
+
"Hadar backed my warning.",
|
| 80 |
+
],
|
| 81 |
+
[{"note": "Shon refused a fair trade."}],
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
assert updates == ["Hadar backed my warning."]
|