use std::sync::{Arc, Mutex};
use rand::seq::SliceRandom;
use rand_chacha::ChaCha8Rng;
use rand::SeedableRng;
use tcg_core::{Action, GameView, Prompt};
use crate::traits::AiController;
use super::render::render_game_view;
use super::action_parser::{parse_response, ParsedResponse};
#[derive(Debug, Clone)]
pub struct ReactAiConfig {
pub system_prompt: String,
pub temperature: f32,
pub max_tokens: u32,
pub include_reasoning: bool,
pub fallback_to_random: bool,
}
impl Default for ReactAiConfig {
fn default() -> Self {
Self {
system_prompt: DEFAULT_SYSTEM_PROMPT.to_string(),
temperature: 0.3,
max_tokens: 256,
include_reasoning: true,
fallback_to_random: true,
}
}
}
pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are an expert Pokemon TCG player. Analyze the game state and choose the best action.
IMPORTANT: Respond with ONLY a JSON object in this format:
{"action": "<action_type>", "card_id": <id>, "target_id": <id>, "reason": "<brief reasoning>"}
Action types and their required fields:
- PlayBasic: card_id (play a basic Pokemon to bench)
- AttachEnergy: energy_id, target_id (attach energy to Pokemon)
- EvolveFromHand: card_id, target_id (evolve a Pokemon)
- PlayTrainer: card_id (play a trainer card)
- DeclareAttack: attack (name of the attack)
- Retreat: to_bench_id (switch active with benched Pokemon)
- EndTurn: (end your turn)
- ChooseActive: card_id (choose starting active)
- ChooseBench: card_ids (comma-separated IDs for bench)
- ChooseNewActive: card_id (choose new active after KO)
Strategy tips:
1. Prioritize knocking out opponent Pokemon for prizes
2. Attach energy to power up attacks
3. Evolve Pokemon when possible for higher HP and better attacks
4. Consider type matchups (weakness = 2x damage)
5. Manage your bench for backup attackers
6. Use trainer cards for card advantage
Respond with the single best action based on the current game state."#;
pub trait LlmProvider: Send + Sync {
fn generate(&self, system: &str, user: &str, config: &ReactAiConfig) -> Result<String, String>;
}
#[derive(Clone)]
pub struct PlaceholderLlmProvider;
impl LlmProvider for PlaceholderLlmProvider {
fn generate(&self, _system: &str, _user: &str, _config: &ReactAiConfig) -> Result<String, String> {
Err("LLM provider not implemented. Implement LlmProvider trait with your API.".to_string())
}
}
pub struct SyncLlmWrapper<F>
where
F: Fn(&str, &str, &ReactAiConfig) -> Result<String, String> + Send + Sync,
{
pub call_fn: F,
}
impl<F> LlmProvider for SyncLlmWrapper<F>
where
F: Fn(&str, &str, &ReactAiConfig) -> Result<String, String> + Send + Sync,
{
fn generate(&self, system: &str, user: &str, config: &ReactAiConfig) -> Result<String, String> {
(self.call_fn)(system, user, config)
}
}
pub struct ReactAi {
config: ReactAiConfig,
llm: Arc<dyn LlmProvider>,
rng: Mutex<ChaCha8Rng>,
history: Mutex<Vec<ActionHistoryEntry>>,
todo_list: Mutex<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct ActionHistoryEntry {
pub game_state_summary: String,
pub llm_response: Option<String>,
pub parsed_action: Option<Action>,
pub error: Option<String>,
}
impl ReactAi {
pub fn new(seed: u64) -> Self {
Self::with_config(seed, ReactAiConfig::default(), Arc::new(PlaceholderLlmProvider))
}
pub fn with_config(seed: u64, config: ReactAiConfig, llm: Arc<dyn LlmProvider>) -> Self {
Self {
config,
llm,
rng: Mutex::new(ChaCha8Rng::seed_from_u64(seed)),
history: Mutex::new(Vec::new()),
todo_list: Mutex::new(vec![
"Assess board state".to_string(),
"Build up attackers".to_string(),
"Take prize cards".to_string(),
]),
}
}
pub fn with_llm_fn<F>(seed: u64, call_fn: F) -> Self
where
F: Fn(&str, &str, &ReactAiConfig) -> Result<String, String> + Send + Sync + 'static,
{
Self::with_config(
seed,
ReactAiConfig::default(),
Arc::new(SyncLlmWrapper { call_fn }),
)
}
pub fn get_history(&self) -> Vec<ActionHistoryEntry> {
self.history.lock().unwrap().clone()
}
pub fn get_todo_list(&self) -> Vec<String> {
self.todo_list.lock().unwrap().clone()
}
fn build_user_prompt(&self, view: &GameView) -> String {
let mut parts = Vec::new();
parts.push(render_game_view(view));
let todos = self.todo_list.lock().unwrap();
if !todos.is_empty() {
parts.push(String::new());
parts.push("=== YOUR TODO LIST ===".to_string());
for (i, todo) in todos.iter().enumerate() {
parts.push(format!("{}. {}", i + 1, todo));
}
}
parts.push(String::new());
parts.push("Choose your action and respond with JSON.".to_string());
parts.join("\n")
}
fn query_llm(&self, view: &GameView) -> Result<ParsedResponse, String> {
let user_prompt = self.build_user_prompt(view);
let response = self.llm.generate(&self.config.system_prompt, &user_prompt, &self.config)?;
parse_response(&response, view)
.map_err(|e| format!("Parse error: {}", e))
}
fn log_action(&self, summary: String, response: Option<String>, action: Option<Action>, error: Option<String>) {
let entry = ActionHistoryEntry {
game_state_summary: summary,
llm_response: response,
parsed_action: action,
error,
};
self.history.lock().unwrap().push(entry);
}
fn update_todos(&self, new_todos: Vec<String>) {
if !new_todos.is_empty() {
let mut todos = self.todo_list.lock().unwrap();
for todo in new_todos {
if !todos.contains(&todo) {
todos.push(todo);
}
}
while todos.len() > 10 {
todos.remove(0);
}
}
}
fn random_fallback_action(&self, view: &GameView) -> Option<Action> {
let hints = &view.action_hints;
let mut rng = self.rng.lock().unwrap();
let mut actions = Vec::new();
for id in &hints.playable_basic_ids {
actions.push(Action::PlayBasic { card_id: *id });
}
for energy_id in &hints.playable_energy_ids {
for target_id in &hints.attach_targets {
actions.push(Action::AttachEnergy {
energy_id: *energy_id,
target_id: *target_id,
});
}
}
for (evo_id, targets) in &hints.evolve_targets_by_card_id {
for target_id in targets {
actions.push(Action::EvolveFromHand {
card_id: *evo_id,
target_id: *target_id,
});
}
}
for id in &hints.playable_trainer_ids {
actions.push(Action::PlayTrainer { card_id: *id });
}
if hints.can_declare_attack {
for attack in &hints.usable_attacks {
actions.push(Action::DeclareAttack { attack: attack.clone() });
}
}
if hints.can_end_turn {
actions.push(Action::EndTurn);
}
actions.choose(&mut *rng).cloned()
}
fn random_prompt_response(&self, view: &GameView, prompt: &Prompt) -> Vec<Action> {
let mut rng = self.rng.lock().unwrap();
match prompt {
Prompt::ChooseStartingActive { options } => {
if let Some(id) = options.choose(&mut *rng) {
vec![Action::ChooseActive { card_id: *id }]
} else {
vec![]
}
}
Prompt::ChooseBenchBasics { options, min, max } => {
let count = (*min).max(1).min(*max).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(count);
vec![Action::ChooseBench { card_ids: ids }]
}
Prompt::ChooseAttack { attacks } => {
if let Some(attack) = attacks.choose(&mut *rng) {
vec![Action::DeclareAttack { attack: attack.clone() }]
} else {
vec![Action::CancelPrompt]
}
}
Prompt::ChooseCardsFromDeck { options, count, min, max, .. } => {
let min_val = min.unwrap_or(*count);
let max_val = max.unwrap_or(*count);
let target = min_val.max(1).min(max_val).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(target);
vec![Action::TakeCardsFromDeck { card_ids: ids }]
}
Prompt::ChooseCardsFromDiscard { options, count, min, max, .. } => {
let min_val = min.unwrap_or(*count);
let max_val = max.unwrap_or(*count);
let target = min_val.max(1).min(max_val).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(target);
vec![Action::TakeCardsFromDiscard { card_ids: ids }]
}
Prompt::ChoosePokemonInPlay { options, min, max, .. } => {
let target = (*min).max(1).min(*max).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(target);
vec![Action::ChoosePokemonTargets { target_ids: ids }]
}
Prompt::ReorderDeckTop { options, .. } => {
let mut ids = options.clone();
ids.shuffle(&mut *rng);
vec![Action::ReorderDeckTop { card_ids: ids }]
}
Prompt::ChooseAttachedEnergy { count, min, pokemon_id, .. } => {
let target = min.unwrap_or(*count);
let pokemon = view.my_active.as_ref()
.filter(|p| p.card.id == *pokemon_id)
.or_else(|| view.my_bench.iter().find(|p| p.card.id == *pokemon_id));
if let Some(p) = pokemon {
let mut energy_ids: Vec<_> = p.attached_energy.iter().map(|e| e.id).collect();
energy_ids.shuffle(&mut *rng);
energy_ids.truncate(target);
vec![Action::ChooseAttachedEnergy { energy_ids }]
} else {
vec![]
}
}
Prompt::ChooseCardsFromHand { options, count, min, max, return_to_deck, .. } => {
let min_val = min.unwrap_or(*count);
let max_val = max.unwrap_or(*count);
let target = min_val.max(1).min(max_val).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(target);
if *return_to_deck {
vec![Action::ReturnCardsFromHandToDeck { card_ids: ids }]
} else {
vec![Action::DiscardCardsFromHand { card_ids: ids }]
}
}
Prompt::ChooseCardsInPlay { options, min, max, .. } => {
let target = (*min).max(1).min(*max).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(target);
vec![Action::ChooseCardsInPlay { card_ids: ids }]
}
Prompt::ChooseDefenderAttack { attacks, .. } => {
if let Some(attack_name) = attacks.choose(&mut *rng) {
vec![Action::ChooseDefenderAttack { attack_name: attack_name.clone() }]
} else {
vec![]
}
}
Prompt::ChoosePokemonAttack { attacks, .. } => {
if let Some(attack_name) = attacks.choose(&mut *rng) {
vec![Action::ChoosePokemonAttack { attack_name: attack_name.clone() }]
} else {
vec![]
}
}
Prompt::ChooseSpecialCondition { options, .. } => {
if let Some(cond) = options.choose(&mut *rng) {
vec![Action::ChooseSpecialCondition { condition: *cond }]
} else {
vec![]
}
}
Prompt::ChoosePrizeCards { options, min, max, .. } => {
let target = (*min).max(1).min(*max).min(options.len());
let mut ids: Vec<_> = options.clone();
ids.shuffle(&mut *rng);
ids.truncate(target);
vec![Action::ChoosePrizeCards { card_ids: ids }]
}
Prompt::ChooseNewActive { options, .. } => {
if let Some(id) = options.choose(&mut *rng) {
vec![Action::ChooseNewActive { card_id: *id }]
} else {
vec![]
}
}
}
}
}
impl AiController for ReactAi {
fn propose_prompt_response(&mut self, view: &GameView, prompt: &Prompt) -> Vec<Action> {
let summary = format!("Prompt: {:?}", prompt);
match self.query_llm(view) {
Ok(parsed) => {
self.log_action(
summary,
Some(format!("{:?}", parsed.action)),
Some(parsed.action.clone()),
None,
);
self.update_todos(parsed.todo_add);
vec![parsed.action]
}
Err(e) => {
if self.config.fallback_to_random {
let actions = self.random_prompt_response(view, prompt);
self.log_action(
summary,
None,
actions.first().cloned(),
Some(format!("LLM failed: {}, using random", e)),
);
actions
} else {
self.log_action(summary, None, None, Some(e));
vec![]
}
}
}
}
fn propose_free_actions(&mut self, view: &GameView) -> Vec<Action> {
let summary = format!("Phase: {:?}", view.phase);
match self.query_llm(view) {
Ok(parsed) => {
self.log_action(
summary,
Some(format!("{:?}", parsed.action)),
Some(parsed.action.clone()),
None,
);
self.update_todos(parsed.todo_add);
vec![parsed.action]
}
Err(e) => {
if self.config.fallback_to_random {
if let Some(action) = self.random_fallback_action(view) {
self.log_action(
summary,
None,
Some(action.clone()),
Some(format!("LLM failed: {}, using random", e)),
);
vec![action]
} else {
if view.action_hints.can_end_turn {
vec![Action::EndTurn]
} else {
vec![]
}
}
} else {
self.log_action(summary, None, None, Some(e));
vec![]
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_react_ai_creation() {
let ai = ReactAi::new(42);
assert!(!ai.get_todo_list().is_empty());
}
#[test]
fn test_config_default() {
let config = ReactAiConfig::default();
assert!(config.temperature > 0.0);
assert!(config.fallback_to_random);
}
}