feat: add HandEvaluator and MoveGenerator with pruning
This commit is contained in:
219
src/core/hand_evaluator.gd
Normal file
219
src/core/hand_evaluator.gd
Normal file
@@ -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)
|
||||||
163
src/core/move_generator.gd
Normal file
163
src/core/move_generator.gd
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user