From 016bb1a95e4486a9111515aef3c6ac4b9caa8c6f Mon Sep 17 00:00:00 2001 From: xiaji Date: Fri, 29 May 2026 09:11:43 +0800 Subject: [PATCH] feat: add L1 Basic AI and L2 Rule AI with heuristic scoring --- src/ai/base_ai.gd | 11 +++++++ src/ai/l1_basic_ai.gd | 53 ++++++++++++++++++++++++++++++++ src/ai/l2_rule_ai.gd | 70 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/ai/base_ai.gd create mode 100644 src/ai/l1_basic_ai.gd create mode 100644 src/ai/l2_rule_ai.gd diff --git a/src/ai/base_ai.gd b/src/ai/base_ai.gd new file mode 100644 index 0000000..98d5a90 --- /dev/null +++ b/src/ai/base_ai.gd @@ -0,0 +1,11 @@ +# src/ai/base_ai.gd +class_name BaseAI +extends RefCounted + +var ai_name: String = "AI" + +func decide(hand: Array, table: Array, current_rank: int, config: RuleConfig) -> HandEvaluator.EvaluatedPlay: + var pass := HandEvaluator.EvaluatedPlay.new() + pass.type = -1 + pass.primary_rank = 0 + return pass diff --git a/src/ai/l1_basic_ai.gd b/src/ai/l1_basic_ai.gd new file mode 100644 index 0000000..e5ddee4 --- /dev/null +++ b/src/ai/l1_basic_ai.gd @@ -0,0 +1,53 @@ +# src/ai/l1_basic_ai.gd +class_name L1BasicAI +extends BaseAI + +const _C = preload("res://src/core/constants.gd") + +func _init(): + ai_name = "Basic AI" + +func decide(hand: Array, table: Array, current_rank: int, config: RuleConfig) -> HandEvaluator.EvaluatedPlay: + var moves := MoveGenerator.generate(hand, current_rank, config) + var last_play := _last_non_pass(table) + if last_play == null: + return _smallest_play(moves) + var candidates: Array = [] + for m in moves: + if m.type == -1: + continue + if m.type == last_play.type and m.primary_rank > last_play.primary_rank: + candidates.append(m) + elif (m.type == _C.TYPE_BOMB or m.type == _C.TYPE_ROCKET) and _beats(m, last_play, config): + candidates.append(m) + if candidates.is_empty(): + return _pass_move() + return _smallest_play(candidates) + +func _smallest_play(moves: Array) -> HandEvaluator.EvaluatedPlay: + var best: HandEvaluator.EvaluatedPlay = null + for m in moves: + if m.type <= 0: + continue + if best == null or m.cards.size() < best.cards.size(): + best = m + elif m.cards.size() == best.cards.size() and m.primary_rank < best.primary_rank: + best = m + if best == null: + return _pass_move() + return best + +func _last_non_pass(table: Array) -> HandEvaluator.EvaluatedPlay: + for i in range(table.size() - 1, -1, -1): + if table[i].type != -1: + return table[i] + return null + +func _beats(a: HandEvaluator.EvaluatedPlay, b: HandEvaluator.EvaluatedPlay, config: RuleConfig) -> bool: + return RuleEngine.compare(a, b, config) > 0 + +func _pass_move() -> HandEvaluator.EvaluatedPlay: + var p := HandEvaluator.EvaluatedPlay.new() + p.type = -1 + p.primary_rank = 0 + return p diff --git a/src/ai/l2_rule_ai.gd b/src/ai/l2_rule_ai.gd new file mode 100644 index 0000000..4017a7d --- /dev/null +++ b/src/ai/l2_rule_ai.gd @@ -0,0 +1,70 @@ +# src/ai/l2_rule_ai.gd +class_name L2RuleAI +extends BaseAI + +const _C = preload("res://src/core/constants.gd") + +func _init(): + ai_name = "Rule AI" + +func decide(hand: Array, table: Array, current_rank: int, config: RuleConfig) -> HandEvaluator.EvaluatedPlay: + var moves := MoveGenerator.generate(hand, current_rank, config) + var last_play := _last_non_pass(table) + var candidates: Array = [] + for m in moves: + if m.type == -1: + continue + if last_play == null: + candidates.append(m) + elif _can_beat(m, last_play, config): + candidates.append(m) + if candidates.is_empty(): + return _pass_move() + var scored := _score_all(candidates, hand.size(), current_rank) + scored.sort_custom(func(a, b): return a["score"] > b["score"]) + return scored[0]["move"] + +func _score_all(moves: Array, hand_size: int, current_rank: int) -> Array: + var results := [] + for m in moves: + var score := _score_move(m, hand_size, current_rank) + results.append({"move": m, "score": score}) + return results + +func _score_move(play: HandEvaluator.EvaluatedPlay, hand_size: int, current_rank: int) -> float: + var score := 0.0 + var remaining := hand_size - play.cards.size() + score += (27.0 - remaining) / 27.0 * 0.3 + if play.type == _C.TYPE_BOMB or play.type == _C.TYPE_ROCKET: + score -= 0.2 + elif play.type == _C.TYPE_STRAIGHT_FLUSH: + score -= 0.1 + score += play.cards.size() * 0.02 + if play.primary_rank >= 14 and remaining > 10: + score -= 0.1 + if remaining <= 3: + score += 0.5 + if play.is_pure_bomb: + score += 0.05 + return score + +func _can_beat(play: HandEvaluator.EvaluatedPlay, last: HandEvaluator.EvaluatedPlay, config: RuleConfig) -> bool: + if play.type == _C.TYPE_ROCKET: + return true + if play.type == _C.TYPE_BOMB and last.type != _C.TYPE_ROCKET: + return RuleEngine.compare_bombs(play, last, config) > 0 + if play.type == last.type: + return play.primary_rank > last.primary_rank + return false + +func _last_non_pass(table: Array) -> HandEvaluator.EvaluatedPlay: + for i in range(table.size() - 1, -1, -1): + if table[i].type != -1: + return table[i] + return null + +func _pass_move() -> HandEvaluator.EvaluatedPlay: + var p := HandEvaluator.EvaluatedPlay.new() + p.type = -1 + p.primary_rank = 0 + return p