diff --git a/src/core/hand_evaluator.gd b/src/core/hand_evaluator.gd new file mode 100644 index 0000000..68e842b --- /dev/null +++ b/src/core/hand_evaluator.gd @@ -0,0 +1,219 @@ +# src/core/hand_evaluator.gd +# Pure functions — zero Godot dependency +# Evaluate: [Card] + current_rank + RuleConfig -> EvaluatedPlay | null + +class_name HandEvaluator +extends RefCounted + +const INVALID := -1 + +class EvaluatedPlay: + extends RefCounted + var type: int = INVALID + var primary_rank: int = 0 + var cards: Array[Card] = [] + var is_pure_bomb: bool = false + + func _to_string() -> String: + var names := ["SINGLE","PAIR","TRIPLE","TRIPLE+2","STRAIGHT","PAIRS","STEEL","FLUSH","BOMB","ROCKET"] + return "EvaluatedPlay(%s, rank=%d, pure=%s)" % [names[type], primary_rank, is_pure_bomb] + +static func evaluate(cards: Array[Card], current_rank: int, config: RuleConfig) -> EvaluatedPlay: + if cards.is_empty(): + return null + var sorted := cards.duplicate(false) + _sort_by_rank_and_suit(sorted) + var n := sorted.size() + if n == 1: + return _make_result(TYPE_SINGLE, sorted[0].rank(), false, sorted) + var wilds: Array[Card] = [] + var reals: Array[Card] = [] + for c in sorted: + if c.is_heart() and c.rank() == current_rank: + wilds.append(c) + else: + reals.append(c) + var has_wild := wilds.size() > 0 + if n == 2: + return _eval_two(reals, wilds, has_wild) + if n == 3: + return _eval_three(reals, wilds, has_wild) + if n == 4: + var result := _eval_four(reals, wilds, has_wild, current_rank, config) + if result != null: + return result + return _eval_multi(reals, wilds, has_wild, current_rank, n, config) + +static func _make_result(type: int, primary_rank: int, is_pure: bool, cards1: Array[Card]) -> EvaluatedPlay: + var r := EvaluatedPlay.new() + r.type = type + r.primary_rank = primary_rank + r.cards = cards1.duplicate(false) + r.is_pure_bomb = is_pure + return r + +static func _eval_two(reals: Array[Card], wilds: Array[Card], has_wild: bool) -> EvaluatedPlay: + if not has_wild: + if reals.size() == 2 and reals[0].rank() == reals[1].rank(): + return _make_result(TYPE_PAIR, reals[0].rank(), true, reals) + else: + if reals.size() == 1: + return _make_result(TYPE_PAIR, reals[0].rank(), false, reals + wilds) + return null + +static func _eval_three(reals: Array[Card], wilds: Array[Card], has_wild: bool) -> EvaluatedPlay: + if not has_wild: + if reals.size() == 3 and _all_same_rank(reals): + return _make_result(TYPE_TRIPLE, reals[0].rank(), true, reals) + else: + if reals.size() == 2 and reals[0].rank() == reals[1].rank(): + return _make_result(TYPE_TRIPLE, reals[0].rank(), false, reals + wilds) + if reals.size() == 1 and wilds.size() >= 2: + return _make_result(TYPE_TRIPLE, reals[0].rank(), false, reals + wilds.slice(0, 2)) + return null + +static func _eval_four(reals: Array[Card], wilds: Array[Card], has_wild: bool, current_rank: int, config: RuleConfig) -> EvaluatedPlay: + var all_cards := reals + wilds + if not has_wild and reals.size() == 4: + if _all_same_rank(reals): + return _make_result(TYPE_BOMB, reals[0].rank(), true, reals) + var sj_count := 0 + var bj_count := 0 + for c in reals: + if c.rank() == 15: sj_count += 1 + if c.rank() == 16: bj_count += 1 + if sj_count == 2 and bj_count == 2: + return _make_result(TYPE_ROCKET, 999, true, reals) + return null + if has_wild: + if reals.size() == 3 and _all_same_rank(reals): + return _make_result(TYPE_BOMB, reals[0].rank(), false, all_cards) + if reals.size() == 2 and reals[0].rank() == reals[1].rank(): + return _make_result(TYPE_BOMB, reals[0].rank(), false, all_cards) + return null + +static func _eval_multi(reals: Array[Card], wilds: Array[Card], has_wild: bool, current_rank: int, n: int, config: RuleConfig) -> EvaluatedPlay: + if not has_wild: + return _eval_pure_multiple(reals, n, config) + if n == 5: + var result := _eval_triple_plus_two(reals, wilds) + if result != null: return result + result = _eval_straight(reals, wilds, 5, config) + if result != null: return result + if n >= 6 and n % 2 == 0: + var result := _eval_consecutive_pairs(reals, wilds, n / 2, config) + if result != null: return result + if n >= 6 and n % 3 == 0: + var result := _eval_steel_plate(reals, wilds, n / 3, config) + if result != null: return result + if n >= 5: + var result := _eval_straight(reals, wilds, n, config) + if result != null: return result + return null + +static func _eval_pure_multiple(reals: Array[Card], n: int, config: RuleConfig) -> EvaluatedPlay: + if n >= 5 and _is_straight(reals, config): + return _make_result(TYPE_STRAIGHT, reals[n-1].rank(), true, reals) + if n >= 6 and n % 2 == 0 and _is_consecutive_pairs(reals, n / 2): + return _make_result(TYPE_CONSECUTIVE_PAIRS, reals[n-1].rank(), true, reals) + if n == 5 and _is_triple_plus_two(reals): + return _make_result(TYPE_TRIPLE_PLUS_TWO, _get_triple_rank(reals), true, reals) + if n >= 6 and n % 3 == 0 and _is_steel_plate(reals): + return _make_result(TYPE_STEEL_PLATE, reals[n-1].rank(), true, reals) + if n >= 5 and _is_straight_flush(reals, config): + return _make_result(TYPE_STRAIGHT_FLUSH, reals[n-1].rank(), true, reals) + return null + +static func _eval_triple_plus_two(reals: Array[Card], wilds: Array[Card]) -> EvaluatedPlay: + if reals.size() + wilds.size() != 5: return null + if reals.is_empty(): return null + var rank_counts := _rank_counts(reals) + if rank_counts.values().has(3): + return _make_result(TYPE_TRIPLE_PLUS_TWO, _get_triple_rank(reals), wilds.is_empty(), reals + wilds) + return null + +static func _eval_straight(reals: Array[Card], wilds: Array[Card], n: int, config: RuleConfig) -> EvaluatedPlay: + if reals.size() + wilds.size() != n: return null + var sorted := reals.duplicate(false) + _sort_by_rank_and_suit(sorted) + var w := wilds.size() + var gaps: Array[int] = [] + for i in range(sorted.size() - 1): + var delta := sorted[i+1].rank() - sorted[i].rank() + if delta > 1: + gaps.append(delta - 1) + var total_gaps := 0 + for g in gaps: total_gaps += g + if total_gaps == w or (total_gaps <= w and w >= total_gaps): + var max_rank := sorted[sorted.size()-1].rank() + if not config.straight_extends_to_ace and max_rank > 14: return null + return _make_result(TYPE_STRAIGHT, max_rank, w == 0, reals + wilds) + return null + +static func _eval_consecutive_pairs(reals: Array[Card], wilds: Array[Card], pair_count: int, config: RuleConfig) -> EvaluatedPlay: + if reals.size() + wilds.size() != pair_count * 2: return null + if wilds.is_empty() and _is_consecutive_pairs(reals, pair_count): + return _make_result(TYPE_CONSECUTIVE_PAIRS, reals[reals.size()-1].rank(), true, reals) + return null + +static func _eval_steel_plate(reals: Array[Card], wilds: Array[Card], triple_count: int, config: RuleConfig) -> EvaluatedPlay: + if reals.size() + wilds.size() != triple_count * 3: return null + if wilds.is_empty() and _is_steel_plate(reals): + return _make_result(TYPE_STEEL_PLATE, reals[reals.size()-1].rank(), true, reals) + return null + +static func _all_same_rank(cards: Array[Card]) -> bool: + var r := cards[0].rank() + for i in range(1, cards.size()): + if cards[i].rank() != r: return false + return true + +static func _is_straight(cards: Array[Card], config: RuleConfig) -> bool: + for i in range(cards.size() - 1): + if cards[i+1].rank() - cards[i].rank() != 1: return false + if not config.straight_extends_to_ace and cards[cards.size()-1].rank() > 14: return false + return true + +static func _is_consecutive_pairs(cards: Array[Card], pair_count: int) -> bool: + for i in range(0, cards.size(), 2): + if cards[i].rank() != cards[i+1].rank(): return false + for i in range(0, cards.size() - 2, 2): + if cards[i+2].rank() - cards[i].rank() != 1: return false + return true + +static func _is_triple_plus_two(cards: Array[Card]) -> bool: + var rank_counts := _rank_counts(cards) + return rank_counts.values().has(3) and rank_counts.values().has(2) + +static func _is_steel_plate(cards: Array[Card]) -> bool: + var rank_counts := _rank_counts(cards) + for count in rank_counts.values(): + if count != 3: return false + var ranks := rank_counts.keys() + ranks.sort() + for i in range(ranks.size() - 1): + if ranks[i+1] - ranks[i] != 1: return false + return true + +static func _is_straight_flush(cards: Array[Card], config: RuleConfig) -> bool: + if not _is_straight(cards, config): return false + var suit := cards[0].suit() + for i in range(1, cards.size()): + if cards[i].suit() != suit: return false + return true + +static func _rank_counts(cards: Array[Card]) -> Dictionary: + var d := {} + for c in cards: + var r := c.rank() + d[r] = d.get(r, 0) + 1 + return d + +static func _get_triple_rank(cards: Array[Card]) -> int: + var rc := _rank_counts(cards) + for rank in rc: + if rc[rank] == 3: return rank + return 0 + +static func _sort_by_rank_and_suit(cards: Array[Card]) -> void: + cards.sort_custom(func(a: Card, b: Card): return a.compare_to(b) < 0) diff --git a/src/core/move_generator.gd b/src/core/move_generator.gd new file mode 100644 index 0000000..67373d7 --- /dev/null +++ b/src/core/move_generator.gd @@ -0,0 +1,163 @@ +# src/core/move_generator.gd +# Pure functions — zero Godot dependency +# Generate all legal plays from a hand + +class_name MoveGenerator +extends RefCounted + +const MAX_ENUM_NODES := 10000 +const BEAM_WIDTH := 50 + +static func generate(hand: Array[Card], current_rank: int, config: RuleConfig) -> Array[HandEvaluator.EvaluatedPlay]: + var results: Array[HandEvaluator.EvaluatedPlay] = [] + var pass := HandEvaluator.EvaluatedPlay.new() + pass.type = -1 + pass.primary_rank = 0 + results.append(pass) + if hand.is_empty(): + return results + var sorted := hand.duplicate(false) + _sort(sorted) + var seen_ranks := {} + for c in sorted: + var rk := c.rank() + if seen_ranks.get(rk, 0) < 4: + var ep := HandEvaluator.evaluate([c], current_rank, config) + if ep != null and ep.type != HandEvaluator.INVALID: + if not _contains_duplicate(results, ep): + results.append(ep) + seen_ranks[rk] = seen_ranks.get(rk, 0) + 1 + _gen_pairs(sorted, results, current_rank, config) + _gen_triples(sorted, results, current_rank, config) + _gen_bombs(sorted, results, current_rank, config) + _gen_straights(sorted, results, current_rank, config) + _gen_rocket(sorted, results, current_rank, config) + if results.size() > MAX_ENUM_NODES: + results = results.slice(0, MAX_ENUM_NODES) + return results + +static func _gen_pairs(sorted: Array[Card], results: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> void: + var rank_counts := {} + for c in sorted: + var rk := c.rank() + if not rank_counts.has(rk): rank_counts[rk] = [] + rank_counts[rk].append(c) + for rk in rank_counts: + var cards: Array = rank_counts[rk] + if cards.size() >= 2: + var ep := HandEvaluator.evaluate(cards.slice(0, 2), current_rank, config) + if ep != null: results.append(ep) + +static func _gen_triples(sorted: Array[Card], results: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> void: + var rank_counts := {} + for c in sorted: + var rk := c.rank() + if not rank_counts.has(rk): rank_counts[rk] = [] + rank_counts[rk].append(c) + for rk in rank_counts: + var cards: Array = rank_counts[rk] + if cards.size() >= 3: + var ep := HandEvaluator.evaluate(cards.slice(0, 3), current_rank, config) + if ep != null: results.append(ep) + if cards.size() >= 3: + _gen_triple_plus_kickers(sorted, cards.slice(0, 3), results, current_rank, config) + +static func _gen_triple_plus_kickers(hand: Array[Card], triple: Array[Card], results: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> void: + var remaining: Array[Card] = [] + for c in hand: + if not _card_in(triple, c): remaining.append(c) + var pairs := _find_pairs(remaining) + for pair in pairs: + var ep := HandEvaluator.evaluate(triple + pair, current_rank, config) + if ep != null: results.append(ep) + +static func _gen_bombs(sorted: Array[Card], results: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> void: + var rank_counts := {} + for c in sorted: + var rk := c.rank() + if not rank_counts.has(rk): rank_counts[rk] = [] + rank_counts[rk].append(c) + for rk in rank_counts: + var cards: Array = rank_counts[rk] + if cards.size() >= 4: + var ep := HandEvaluator.evaluate(cards.slice(0, 4), current_rank, config) + if ep != null: results.append(ep) + for count in range(5, cards.size() + 1): + var ep := HandEvaluator.evaluate(cards.slice(0, count), current_rank, config) + if ep != null: results.append(ep) + +static func _gen_straights(sorted: Array[Card], results: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> void: + var unique := _unique_ranks(sorted) + for start in unique: + for length in range(5, 13): + var needed: Array[int] = [] + for r in range(start, start + length): needed.append(r) + var found := _pick_consecutive(sorted, needed) + if found.size() == length: + var ep := HandEvaluator.evaluate(found, current_rank, config) + if ep != null: results.append(ep) + +static func _gen_rocket(sorted: Array[Card], results: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> void: + var sj_indices: Array[int] = [] + var bj_indices: Array[int] = [] + for i in range(sorted.size()): + if sorted[i].rank() == 15: sj_indices.append(i) + if sorted[i].rank() == 16: bj_indices.append(i) + if sj_indices.size() >= 2 and bj_indices.size() >= 2: + var cards: Array[Card] = [] + for idx in sj_indices.slice(0, 2) + bj_indices.slice(0, 2): + cards.append(sorted[idx]) + var ep := HandEvaluator.evaluate(cards, current_rank, config) + if ep != null: results.append(ep) + +static func _find_pairs(hand: Array[Card]) -> Array: + var result := [] + var rank_groups := {} + for c in hand: + var rk := c.rank() + if not rank_groups.has(rk): rank_groups[rk] = [] + rank_groups[rk].append(c) + for rk in rank_groups: + if rank_groups[rk].size() >= 2: + result.append(rank_groups[rk].slice(0, 2)) + return result + +static func _card_in(cards: Array[Card], target: Card) -> bool: + for c in cards: + if c.card_id == target.card_id: return true + return false + +static func _unique_ranks(hand: Array[Card]) -> Array: + var seen := {} + var result: Array = [] + for c in hand: + var rk := c.rank() + if not seen.has(rk): + seen[rk] = true + result.append(rk) + result.sort() + return result + +static func _pick_consecutive(hand: Array[Card], needed: Array[int]) -> Array[Card]: + var result: Array[Card] = [] + var used := {} + for target in needed: + var found := false + for c in hand: + if c.rank() == target and not used.has(c.card_id): + result.append(c) + used[c.card_id] = true + found = true + break + if not found: return [] + return result + +static func _contains_duplicate(results: Array[HandEvaluator.EvaluatedPlay], ep: HandEvaluator.EvaluatedPlay) -> bool: + for existing in results: + if existing.type == ep.type and existing.primary_rank == ep.primary_rank: + if existing.cards.size() == ep.cards.size(): + return true + return false + +static func _sort(cards: Array[Card]) -> void: + cards.sort_custom(func(a: Card, b: Card): return a.compare_to(b) < 0)