shon commited on
Commit
ad0932c
·
1 Parent(s): 74221d3
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 or relationship_update:
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 relationship or strategy context."
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": "Roll the dice",
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
- Standard Catan win conditions:
2058
- 1. First player to reach 10 victory points wins
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 (10+ victory points)
2074
- if victory_points >= 5:
2075
- self._announce_winner(player_id, victory_points)
 
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 "Roll the dice to start your turn. Use: roll"
2665
 
2666
  elif phase == TurnPhase.ROLL_DICE:
2667
- return "Roll the dice to start your turn. Use: roll"
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."]