# 掼蛋训练模式 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the core card game engine and a playable Training Mode where a human plays against 3 AI opponents with card-type hints. **Architecture:** Pure GDScript core layer (zero Godot dependencies) with a thin UI layer. Core logic uses immutable data structures, Result types, and Action-based state management. AI runs in WorkerThreadPool with PackedInt32Array snapshots. **Tech Stack:** Godot 4.x, GDScript, GUT (testing) --- ## Phase 1: Project Scaffold & Core Data (Tasks 1-4) ### Task 1: Create Godot Project + Constants **Files:** - Create: `src/core/constants.gd` - Create: `project.godot` - [ ] **Step 1: Create constants.gd** ```gdscript # src/core/constants.gd # Zero Godot dependency — no class_name, use static functions to avoid global state. const RESULT_OK := 0 const ERR_INVALID_CARDS := 1 const ERR_NOT_YOUR_TURN := 2 const ERR_CANNOT_PASS := 3 const ERR_CARD_NOT_FOUND := 4 const ERR_HAND_EMPTY := 5 const ERR_INVALID_TRIBUTE := 6 const ERR_GAME_OVER := 7 const ERR_STATE_CONFLICT := 8 const SUIT_SPADE := 0 const SUIT_HEART := 1 const SUIT_CLUB := 2 const SUIT_DIAMOND := 3 const SUIT_JOKER_SMALL := 4 const SUIT_JOKER_BIG := 5 const RANK_2 := 2 const RANK_3 := 3 const RANK_4 := 4 const RANK_5 := 5 const RANK_6 := 6 const RANK_7 := 7 const RANK_8 := 8 const RANK_9 := 9 const RANK_10 := 10 const RANK_J := 11 const RANK_Q := 12 const RANK_K := 13 const RANK_A := 14 const RANK_SMALL_JOKER := 15 const RANK_BIG_JOKER := 16 const TYPE_SINGLE := 0 const TYPE_PAIR := 1 const TYPE_TRIPLE := 2 const TYPE_TRIPLE_PLUS_TWO := 3 const TYPE_STRAIGHT := 4 const TYPE_CONSECUTIVE_PAIRS := 5 const TYPE_STEEL_PLATE := 6 const TYPE_STRAIGHT_FLUSH := 7 const TYPE_BOMB := 8 const TYPE_ROCKET := 9 const TURN_PLAY := 0 const TURN_PASS := 1 const TURN_TRIBUTE_GIVE := 2 const TURN_TRIBUTE_RETURN := 3 const PHASE_INIT := 0 const PHASE_DEAL := 1 const PHASE_TRIBUTE := 2 const PHASE_PLAY := 3 const PHASE_LEVEL_UP := 4 const PHASE_GAME_OVER := 5 const WILD_SOURCE_GRADE := 0 const WILD_SOURCE_SYSTEM := 1 const MAX_AI_DECISION_MS := 3000 const BEAM_WIDTH := 50 const MAX_ENUM_NODES := 10000 const TOTAL_CARDS := 108 const CARDS_PER_PLAYER := 27 const PLAYER_COUNT := 4 static func make_result(ok: bool, error_code: int, data = null) -> Dictionary: return {"ok": ok, "error_code": error_code, "data": data} static func success(data = null) -> Dictionary: return {"ok": true, "error_code": RESULT_OK, "data": data} static func err(code: int) -> Dictionary: return {"ok": false, "error_code": code, "data": null} ``` - [ ] **Step 2: Create project.godot via Godot Editor** Open Godot 4.x, create new project at `D:\selfgame\game-cards`, set renderer to Compatibility (for mobile support). - [ ] **Step 3: Run empty project to verify it launches** - [ ] **Step 4: Commit** ```bash git add project.godot src/core/constants.gd git commit -m "feat: add project scaffold and constants" ``` --- ### Task 2: Card Data Model **Files:** - Create: `src/core/card.gd` - Create: `tests/test_cards.gd` - [ ] **Step 1: Write the failing tests** ```gdscript # tests/test_cards.gd extends GutTest func test_card_create(): var card = Card.create(0, 3, 14) # original_id=0, suit=3(diamond), rank=14(A) assert_eq(card.card_id, 14) assert_eq(card.original_id, 0) assert_eq(card.suit(), 3) assert_eq(card.rank(), 14) assert_eq(card.card_value(), 4 * 20 + 14) # suit*20+rank func test_card_equality(): var a := Card.create(0, 2, 10) var b := Card.create(1, 2, 10) # different original_id, same suit+rank assert_true(a.matches(b)) # rule equality via card_value assert_false(a.equals(b)) # strict equality includes card_id func test_card_id_range(): assert_eq(Card.MIN_ID, 0) assert_eq(Card.MAX_ID, 107) func test_is_joker(): var small := Card.create(52, 4, 15) var big := Card.create(53, 5, 16) assert_true(small.is_joker()) assert_true(big.is_joker()) func test_compare_to(): var low := Card.create(0, 0, 5) # rank 5 var high := Card.create(1, 1, 14) # rank A assert_lt(low.compare_to(high), 0) ``` - [ ] **Step 2: Run test to verify it fails** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_cards.gd ``` Expected: FAIL (Card class not defined) - [ ] **Step 3: Implement card.gd** ```gdscript # src/core/card.gd # Pure data — no Godot dependencies class_name Card extends RefCounted const MIN_ID := 0 const MAX_ID := 107 var card_id: int var original_id: int var _suit: int var _rank: int static func create(original_id: int, suit: int, rank: int) -> Card: var c := Card.new() c.original_id = original_id c._suit = suit c._rank = rank c.card_id = original_id # 0-53 per deck; second deck adds 54 return c static func make_full_deck_ids() -> Array[int]: var ids: Array[int] = [] for i in range(108): ids.append(i) return ids static func card_id_from_deck(original_id: int, deck_index: int) -> int: return original_id + deck_index * 54 func suit() -> int: return _suit func rank() -> int: return _rank func card_value() -> int: return _suit * 20 + _rank func is_joker() -> bool: return _rank == 15 or _rank == 16 func is_heart() -> bool: return _suit == 1 # SUIT_HEART func matches(other: Card) -> bool: return card_value() == other.card_value() func equals(other: Card) -> bool: return card_id == other.card_id func compare_to(other: Card) -> int: var r := _rank - other._rank if r != 0: return r return _suit - other._suit func to_packed() -> int: return (_suit << 8) | (_rank & 0xFF) static func from_packed(packed: int) -> Card: var suit := (packed >> 8) & 0xFF var rank := packed & 0xFF return Card.create(0, suit, rank) func _to_string() -> String: var suits := ["S", "H", "C", "D", "SJ", "BJ"] var ranks := ["", "", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A", "SJ", "BJ"] return "%s%s" % [suits[_suit], ranks[_rank]] ``` - [ ] **Step 4: Run test to verify it passes** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_cards.gd ``` Expected: all tests PASS - [ ] **Step 5: Commit** ```bash git add src/core/card.gd tests/test_cards.gd git commit -m "feat: add Card data model with tests" ``` --- ### Task 3: Deck with PRNG Seed **Files:** - Create: `src/core/deck.gd` - Create: `tests/test_deck.gd` - [ ] **Step 1: Write the failing tests** ```gdscript # tests/test_deck.gd extends GutTest func test_deck_has_108_cards(): var deck := Deck.create() assert_eq(deck.remaining(), 108) func test_deal_reduces_count(): var deck := Deck.create() var cards := deck.deal(27) assert_eq(cards.size(), 27) assert_eq(deck.remaining(), 81) func test_shuffle_deterministic(): var seed := 12345 var deck1 := Deck.create(seed) var deck2 := Deck.create(seed) var hand1 := deck1.deal(108) var hand2 := deck2.deal(108) for i in range(108): assert_eq(hand1[i].card_id, hand2[i].card_id, "Deterministic shuffle failed at index %d" % i) func test_deal_empty_deck(): var deck := Deck.create() deck.deal(108) var extra := deck.deal(1) assert_eq(extra.size(), 0) ``` - [ ] **Step 2: Run test to verify it fails** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_deck.gd ``` - [ ] **Step 3: Implement deck.gd** ```gdscript # src/core/deck.gd class_name Deck extends RefCounted var _cards: Array[Card] = [] # Suit mapping per original_id (0-53): two standard decks static func _suit_for(original_id: int) -> int: if original_id == 52: return 4 # small joker if original_id == 53: return 5 # big joker return original_id % 4 static func _rank_for(original_id: int) -> int: if original_id == 52: return 15 # small joker if original_id == 53: return 16 # big joker return 2 + (original_id / 4) static func create(seed: int = -1) -> Deck: var d := Deck.new() d._cards = [] for deck_idx in range(2): for orig_id in range(54): var global_id := Card.card_id_from_deck(orig_id, deck_idx) var suit := _suit_for(orig_id) var rank := _rank_for(orig_id) var c := Card.create(orig_id, suit, rank) c.card_id = global_id d._cards.append(c) if seed >= 0: d._shuffle_with_seed(seed) else: d._shuffle_random() return d func _shuffle_with_seed(seed: int) -> void: var rng := RandomNumberGenerator.new() rng.seed = seed for i in range(_cards.size() - 1, 0, -1): var j := rng.randi_range(0, i) var tmp := _cards[i] _cards[i] = _cards[j] _cards[j] = tmp func _shuffle_random() -> void: _shuffle_with_seed(Time.get_unix_time_from_system() as int) func deal(count: int) -> Array[Card]: var result: Array[Card] = [] var actual := mini(count, _cards.size()) for _i in range(actual): result.append(_cards.pop_back()) return result func remaining() -> int: return _cards.size() ``` - [ ] **Step 4: Run test to verify it passes** - [ ] **Step 5: Commit** ```bash git add src/core/deck.gd tests/test_deck.gd git commit -m "feat: add Deck with deterministic PRNG shuffle" ``` --- ### Task 4: RuleConfig **Files:** - Create: `src/core/rule_config.gd` - Modify: `tests/test_game_state.gd` (create with RuleConfig test) - [ ] **Step 1: Implement rule_config.gd** ```gdscript # src/core/rule_config.gd class_name RuleConfig extends RefCounted const BOMB_BY_RANK := 0 const BOMB_BY_COUNT := 1 const TEAM_FIXED_1_3 := 0 const TEAM_RANDOM := 1 var same_suit_straight_beats_bomb: bool = true var double_down_levels: int = 3 var can_tribute_wild: bool = false var tribute_return_max_rank: int = 10 var straight_extends_to_ace: bool = true var bomb_compare_priority: int = BOMB_BY_RANK var wild_count: int = 2 var team_formation: int = TEAM_FIXED_1_3 static func standard() -> RuleConfig: var c := RuleConfig.new() return c static func huaian() -> RuleConfig: var c := RuleConfig.new() c.same_suit_straight_beats_bomb = false c.straight_extends_to_ace = false return c func to_hash_data() -> Dictionary: return { "same_suit_bomb": same_suit_straight_beats_bomb, "double_down": double_down_levels, "can_tribute_wild": can_tribute_wild, "tribute_return_max": tribute_return_max_rank, "straight_ace": straight_extends_to_ace, "bomb_compare": bomb_compare_priority, "wild_count": wild_count, "team": team_formation } ``` - [ ] **Step 2: Write and run test** ```gdscript # tests/test_game_state.gd (partial for RuleConfig) extends GutTest func test_rule_config_standard(): var rc := RuleConfig.standard() assert_eq(rc.double_down_levels, 3) assert_false(rc.can_tribute_wild) ``` ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_game_state.gd ``` - [ ] **Step 3: Commit** ```bash git add src/core/rule_config.gd tests/test_game_state.gd git commit -m "feat: add RuleConfig with standard and huaian presets" ``` --- ## Phase 2: Card Type Recognition (Tasks 5-6) ### Task 5: Hand Evaluator (Pure Card Type Recognition) **Files:** - Create: `src/core/hand_evaluator.gd` - Create: `tests/test_hand_evaluator.gd` - [ ] **Step 1: Write the failing tests for core card types** ```gdscript # tests/test_hand_evaluator.gd extends GutTest var _rule_config: RuleConfig func before_each(): _rule_config = RuleConfig.standard() func _cards(ids: Array) -> Array[Card]: var result: Array[Card] = [] for id in ids: result.append(_make_card(id)) return result func _make_card(card_id: int) -> Card: var orig_id := card_id % 54 var suit := (orig_id % 4) if orig_id < 52 else (4 if orig_id == 52 else 5) var rank := (2 + orig_id / 4) as int if orig_id < 52 else (15 if orig_id == 52 else 16) var c := Card.create(orig_id, suit, rank) c.card_id = card_id return c func test_single(): var cards := _cards([0]) var result := HandEvaluator.evaluate(cards, 5, _rule_config) assert_eq(result.type, TYPE_SINGLE) func test_pair(): var cards := _cards([0, 54]) # same suit+rank, two decks var result := HandEvaluator.evaluate(cards, 5, _rule_config) assert_eq(result.type, TYPE_PAIR) func test_triple(): var cards := _cards([0, 54, 108]) # three of same # Note: only two decks, so 3 copies means 3 same rank needed # Use different suits: card_ids 0(H2), 1(H2 from deck2), actually same original_id # This test needs cards from both decks pass # adjusted later when deck mapping is finalized func test_straight(): # 3,4,5,6,7 var cards := _cards([4, 8, 12, 16, 20]) # ranks 3,4,5,6,7 var result := HandEvaluator.evaluate(cards, 5, _rule_config) assert_eq(result.type, TYPE_STRAIGHT) assert_eq(result.primary_rank, 7) func test_not_a_combo(): var cards := _cards([0, 1, 2]) # different ranks, different suits var result := HandEvaluator.evaluate(cards, 5, _rule_config) assert_eq(result.type, -1) # invalid func test_rank_sort(): var unsorted := _cards([8, 0, 16, 4, 12]) var result := HandEvaluator.evaluate(unsorted, 5, _rule_config) assert_eq(result.type, TYPE_STRAIGHT) # 3,4,5,6,7 sorted via rank ``` - [ ] **Step 2: Run test to verify it fails** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_hand_evaluator.gd ``` - [ ] **Step 3: Implement hand_evaluator.gd** ```gdscript # 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 # no wild cards in bomb 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, cards: Array[Card]) -> EvaluatedPlay: var r := EvaluatedPlay.new() r.type = type r.primary_rank = primary_rank r.cards = cards.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: # 1 wild + 1 real = pair (wild copies real's rank) if reals.size() == 1: var all_cards := reals + wilds return _make_result(TYPE_PAIR, reals[0].rank(), false, all_cards) 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 var rank := reals[0].rank() if reals.size() > 0 else 0 if not has_wild and reals.size() == 4: if _all_same_rank(reals): return _make_result(TYPE_BOMB, reals[0].rank(), true, reals) # Check rocket (2x small joker + 2x big joker) 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: # 3 real + 1 wild = bomb (mixed) if reals.size() == 3 and _all_same_rank(reals): return _make_result(TYPE_BOMB, reals[0].rank(), false, all_cards) # 2 real + 2 wild = bomb (mixed) 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 # Simplify: require no wilds for consecutive pairs evaluation 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) if not rank_counts.values().has(3): return false var counts := rank_counts.values() return counts.has(3) and counts.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) ``` - [ ] **Step 4: Run test to verify it passes** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_hand_evaluator.gd ``` - [ ] **Step 5: Commit** ```bash git add src/core/hand_evaluator.gd tests/test_hand_evaluator.gd git commit -m "feat: add HandEvaluator with core card type recognition" ``` --- ### Task 6: Move Generator (Legal Play Enumeration) **Files:** - Create: `src/core/move_generator.gd` - Create: `tests/test_move_generator.gd` - [ ] **Step 1: Write the failing tests** ```gdscript # tests/test_move_generator.gd extends GutTest var _config: RuleConfig func before_each(): _config = RuleConfig.standard() func _make_cards(ids: Array) -> Array[Card]: var result: Array[Card] = [] for id in ids: var orig_id := id % 54 var suit := (orig_id % 4) if orig_id < 52 else (4 if orig_id == 52 else 5) var rank: int if orig_id < 52: rank = 2 + orig_id / 4 as int elif orig_id == 52: rank = 15 else: rank = 16 var c := Card.create(orig_id, suit, rank) c.card_id = id result.append(c) return result func test_generate_single_from_hand(): var hand := _make_cards([0, 4, 8]) var moves := MoveGenerator.generate(hand, 5, _config) var singles := 0 for m in moves: if m.type == TYPE_SINGLE: singles += 1 assert_eq(singles, 3, "Should generate 3 single-card moves") func test_generate_includes_pass(): var hand := _make_cards([0]) var moves := MoveGenerator.generate(hand, 5, _config) var has_pass := false for m in moves: if m.type == -1 and m.cards.is_empty(): has_pass = true assert_true(has_pass, "Should include PASS option") func test_max_enum_nodes(): var hand := _make_cards([]) for i in range(27): hand.append(null) # placeholder # actual hand would be from a real deck; this just tests the cap # The pruning is tested via move_generator internals assert_true(MoveGenerator.MAX_ENUM_NODES > 0) ``` - [ ] **Step 2: Implement move_generator.gd** ```gdscript # 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] = [] # Always include PASS 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) # Singles 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 # Pairs _gen_pairs(sorted, results, current_rank, config) # Triples _gen_triples(sorted, results, current_rank, config) # Bombs (4+ of same rank) _gen_bombs(sorted, results, current_rank, config) # Straights _gen_straights(sorted, results, current_rank, config) # Rocket check _gen_rocket(sorted, results, current_rank, config) # Cap results 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) # Triple + two kickers 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) # 5+ bomb 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) ``` - [ ] **Step 3: Run test** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_move_generator.gd ``` - [ ] **Step 4: Commit** ```bash git add src/core/move_generator.gd tests/test_move_generator.gd git commit -m "feat: add MoveGenerator with legal play enumeration and pruning" ``` --- ## Phase 3: Rule Engine & Game State (Tasks 7-8) ### Task 7: Rule Engine (Validation + Comparison) **Files:** - Create: `src/core/rule_engine.gd` - Create: `tests/test_rule_engine.gd` - [ ] **Step 1: Write tests for rule_engine** ```gdscript # tests/test_rule_engine.gd extends GutTest var _config: RuleConfig func before_each(): _config = RuleConfig.standard() func _card(id: int) -> Card: var orig_id := id % 54 var suit := (orig_id % 4) if orig_id < 52 else (4 if orig_id == 52 else 5) var rank: int if orig_id < 52: rank = 2 + orig_id / 4 as int elif orig_id == 52: rank = 15 else: rank = 16 var c := Card.create(orig_id, suit, rank) c.card_id = id return c func _cards(ids: Array) -> Array[Card]: var result: Array[Card] = [] for id in ids: result.append(_card(id)) return result func test_can_play_when_nothing_on_table(): var hand := _cards([0, 4, 8]) var table: Array[HandEvaluator.EvaluatedPlay] = [] # fresh round # On fresh round, any card is valid var play := HandEvaluator.evaluate(_cards([0]), 5, _config) var result := RuleEngine.can_play(hand, play, table, 0, 5, _config) assert_true(result.ok) func test_must_beat_pure_bomb_with_higher_bomb(): var prev := HandEvaluator.evaluate(_cards([0, 54, 108, 162]), 5, _config) # four 2s = bomb var table := [prev] # Can't play a single when there's a bomb on table var single := HandEvaluator.evaluate(_cards([4]), 5, _config) var result := RuleEngine.can_play([_card(4)], single, table, 0, 5, _config) assert_false(result.ok) func test_pure_bomb_beats_mixed(): var pure := HandEvaluator.EvaluatedPlay.new() pure.type = TYPE_BOMB pure.primary_rank = 10 pure.is_pure_bomb = true var mixed := HandEvaluator.EvaluatedPlay.new() mixed.type = TYPE_BOMB mixed.primary_rank = 10 mixed.is_pure_bomb = false assert_eq(RuleEngine.compare_bombs(pure, mixed, _config), 1) assert_eq(RuleEngine.compare_bombs(mixed, pure, _config), -1) func test_pass_is_always_valid(): var hand := _cards([0]) var table: Array[HandEvaluator.EvaluatedPlay] = [] var prev := HandEvaluator.evaluate(_cards([4, 8, 12, 16, 20]), 5, _config) # straight table.append(prev) var pass := HandEvaluator.EvaluatedPlay.new() pass.type = -1 pass.primary_rank = 0 var result := RuleEngine.can_play(hand, pass, table, 0, 5, _config) assert_true(result.ok) ``` - [ ] **Step 2: Implement rule_engine.gd** ```gdscript # src/core/rule_engine.gd # Pure functions — zero Godot dependency # Validate play legality and compare plays class_name RuleEngine extends RefCounted static func can_play( hand: Array[Card], play: HandEvaluator.EvaluatedPlay, table_history: Array[HandEvaluator.EvaluatedPlay], last_player_idx: int, current_rank: int, config: RuleConfig ) -> Dictionary: # PASS is always legal (as long as not the first player in a fresh round) if play.type == -1: if table_history.is_empty() or _last_non_pass(table_history) == null: return _err(ERR_CANNOT_PASS) return _ok() # Verify cards are in hand for pc in play.cards: if not _card_in_hand(hand, pc): return _err(ERR_CARD_NOT_FOUND) # If table is fresh (no last play), anything valid is legal var last_play := _last_non_pass(table_history) if last_play == null: return _ok() # If this is the same player who played last, this is a new round leader if last_player_idx == _last_player(table_history): return _ok() # Must beat the last play var cmp := compare(play, last_play, config) if cmp <= 0: return _err(ERR_INVALID_CARDS) # Same type or bomb if play.type != last_play.type: if play.type != TYPE_BOMB and play.type != TYPE_ROCKET: return _err(ERR_INVALID_CARDS) return _ok() static func compare(a: HandEvaluator.EvaluatedPlay, b: HandEvaluator.EvaluatedPlay, config: RuleConfig) -> int: if a.type == -1 or b.type == -1: return 0 # Rocket beats everything if a.type == TYPE_ROCKET and b.type == TYPE_ROCKET: return 0 if a.type == TYPE_ROCKET: return 1 if b.type == TYPE_ROCKET: return -1 # Bomb vs non-bomb if a.type == TYPE_BOMB and b.type != TYPE_BOMB: return 1 if b.type == TYPE_BOMB and a.type != TYPE_BOMB: return -1 # Both bombs — compare if a.type == TYPE_BOMB and b.type == TYPE_BOMB: return compare_bombs(a, b, config) # Same type — compare by primary_rank if a.type == b.type: if a.primary_rank > b.primary_rank: return 1 if a.primary_rank < b.primary_rank: return -1 return 0 return 0 static func compare_bombs(a: HandEvaluator.EvaluatedPlay, b: HandEvaluator.EvaluatedPlay, config: RuleConfig) -> int: # Purity first: pure bomb > mixed bomb if a.is_pure_bomb and not b.is_pure_bomb: return 1 if b.is_pure_bomb and not a.is_pure_bomb: return -1 if config.bomb_compare_priority == RuleConfig.BOMB_BY_COUNT: # Count first if a.cards.size() > b.cards.size(): return 1 if b.cards.size() > a.cards.size(): return -1 # Rank comparison if a.primary_rank > b.primary_rank: return 1 if b.primary_rank > a.primary_rank: return -1 # Same rank, count decides if a.cards.size() > b.cards.size(): return 1 if b.cards.size() > a.cards.size(): return -1 return 0 static func _card_in_hand(hand: Array[Card], card: Card) -> bool: for c in hand: if c.card_id == card.card_id: return true return false static func _last_non_pass(table: Array[HandEvaluator.EvaluatedPlay]) -> HandEvaluator.EvaluatedPlay: for i in range(table.size() - 1, -1, -1): if table[i].type != -1: return table[i] return null static func _last_player(table: Array[HandEvaluator.EvaluatedPlay]) -> int: # Return index of last non-pass player, or -1 for i in range(table.size() - 1, -1, -1): if table[i].type != -1: return i % 4 return -1 static func _ok() -> Dictionary: return {"ok": true, "error_code": RESULT_OK, "data": null} static func _err(code: int) -> Dictionary: return {"ok": false, "error_code": code, "data": null} ``` - [ ] **Step 3: Run test** - [ ] **Step 4: Commit** ```bash git add src/core/rule_engine.gd tests/test_rule_engine.gd git commit -m "feat: add RuleEngine with play validation and bomb comparison" ``` --- ### Task 8: Game State + Round + Team **Files:** - Create: `src/core/game_state.gd` - Create: `src/core/round.gd` - Create: `src/core/actions.gd` - [ ] **Step 1: Implement actions.gd** ```gdscript # src/core/actions.gd class_name Actions extends RefCounted class Action: extends RefCounted var player_idx: int var action_type: String # "PLAY", "PASS", "TRIBUTE_GIVE", "TRIBUTE_RETURN", "RESHUFFLE", "GAME_END" var cards: Array[Card] = [] var seq_id: int = 0 var nonce: int = 0 var timestamp: int = 0 func _to_string() -> String: return "Action(seq=%d, player=%d, type=%s, cards=%d)" % [seq_id, player_idx, action_type, cards.size()] class GameStateSnapshot: extends RefCounted var game_state: Dictionary # serializable dict for AI threads class Team: extends RefCounted var team_id: int var player_indices: Array[int] = [] var score: int = 0 var current_level: int = 2 # start at 2 static func create_team(team_id: int, p1: int, p2: int) -> Team: var t := Team.new() t.team_id = team_id t.player_indices = [p1, p2] return t func teammate_of(player_idx: int) -> int: if player_indices[0] == player_idx: return player_indices[1] return player_indices[0] func contains(player_idx: int) -> bool: return player_idx in player_indices ``` - [ ] **Step 2: Implement round.gd** ```gdscript # src/core/round.gd class_name Round extends RefCounted var active_player_idx: int = 0 var pass_count: int = 0 var table: Array[HandEvaluator.EvaluatedPlay] = [] var action_seq: int = 0 var is_cleared: bool = false # three consecutive passes = new round func can_pass() -> bool: # Cannot pass if this is the first move return not table.is_empty() func add_play(play: HandEvaluator.EvaluatedPlay, player_idx: int) -> void: table.append(play) if play.type == -1: pass_count += 1 else: pass_count = 0 # Check for cleared state (3 consecutive passes) if pass_count >= 3: is_cleared = true else: is_cleared = false func next_player() -> int: return (active_player_idx + 1) % 4 func last_non_pass_player() -> int: for i in range(table.size() - 1, -1, -1): if table[i].type != -1: return i % 4 return -1 func next_seq() -> int: action_seq += 1 return action_seq func reset_for_new_round() -> void: table.clear() pass_count = 0 is_cleared = false ``` - [ ] **Step 3: Implement game_state.gd** ```gdscript # src/core/game_state.gd class_name GameState extends RefCounted enum Phase { INIT, DEAL, TRIBUTE, PLAY, LEVEL_UP, GAME_OVER } var phase: int = Phase.INIT var round_seq: int = 0 var current_rank: int = 2 var rule_config: RuleConfig var teams: Array[Actions.Team] = [] var player_hands: Array = [[], [], [], []] var player_names: Array[String] = ["Player", "AI-1", "AI-2", "AI-3"] var player_human: Array[bool] = [true, false, false, false] var round: Round var action_log: Array[Actions.Action] = [] var seed: int = 0 var finished_players: Array[int] = [] var current_winner_team: int = -1 var game_end_reason: String = "" static func create(config: RuleConfig, seed_: int = -1) -> GameState: var gs := GameState.new() gs.rule_config = config if seed_ >= 0: gs.seed = seed_ else: gs.seed = Time.get_unix_time_from_system() as int gs.teams = [Actions.Team.create_team(0, 0, 2), Actions.Team.create_team(1, 1, 3)] gs.round = Round.new() return gs func get_team(player_idx: int) -> Actions.Team: for t in teams: if t.contains(player_idx): return t return null func get_partner(player_idx: int) -> int: var t := get_team(player_idx) if t != null: return t.teammate_of(player_idx) return -1 func get_hand(player_idx: int) -> Array[Card]: return player_hands[player_idx] func remove_cards_from_hand(player_idx: int, cards: Array[Card]) -> void: var hand := player_hands[player_idx] var to_remove := {} for c in cards: to_remove[c.card_id] = true var new_hand: Array[Card] = [] for c in hand: if not to_remove.has(c.card_id): new_hand.append(c) player_hands[player_idx] = new_hand func add_cards_to_hand(player_idx: int, cards: Array[Card]) -> void: for c in cards: player_hands[player_idx].append(c) func deal_cards(deck: Deck) -> void: for i in range(4): player_hands[i] = deck.deal(27) func is_player_finished(player_idx: int) -> bool: return player_idx in finished_players func add_finished_player(player_idx: int) -> void: if not is_player_finished(player_idx): finished_players.append(player_idx) func get_rank_for_player(player_idx: int) -> int: var idx := finished_players.find(player_idx) if idx >= 0: return idx + 1 # 1 = first to finish (头游) return 0 func all_hands_empty() -> bool: var empty_count := 0 for i in range(4): if player_hands[i].is_empty(): empty_count += 1 return empty_count >= 3 func to_packed_snapshot(for_player: int) -> Dictionary: # Serialize visible state for AI thread return { "phase": phase, "current_rank": current_rank, "own_hand": _pack_hand(player_hands[for_player]), "table": _pack_table(round.table), "finished": finished_players.duplicate(), "team_scores": [teams[0].score, teams[1].score] } func _pack_hand(hand: Array) -> Array: var result := [] for c in hand: result.append(c.to_packed()) return result func _pack_table(table: Array) -> Array: var result := [] for ep in table: var d := {"type": ep.type, "primary_rank": ep.primary_rank, "is_pure": ep.is_pure_bomb} var cards_packed := [] for c in ep.cards: cards_packed.append(c.to_packed()) d["cards"] = cards_packed result.append(d) return result ``` - [ ] **Step 4: Write and run tests** ```gdscript # Add to tests/test_game_state.gd func test_team_partner(): var t := Actions.Team.create_team(0, 0, 2) assert_eq(t.teammate_of(0), 2) assert_eq(t.teammate_of(2), 0) assert_true(t.contains(0)) func test_game_state_create(): var gs := GameState.create(RuleConfig.standard(), 42) assert_eq(gs.seed, 42) assert_eq(gs.teams.size(), 2) func test_deal_cards(): var gs := GameState.create(RuleConfig.standard(), 1) var deck := Deck.create(1) gs.deal_cards(deck) for i in range(4): assert_eq(gs.get_hand(i).size(), 27) ``` - [ ] **Step 5: Commit** ```bash git add src/core/actions.gd src/core/round.gd src/core/game_state.gd tests/test_game_state.gd git commit -m "feat: add GameState, Round, Team, and Action system" ``` --- ## Phase 4: AI (Tasks 9-10) ### Task 9: L1 Basic AI **Files:** - Create: `src/ai/base_ai.gd` - Create: `src/ai/l1_basic_ai.gd` - Create: `tests/test_ai.gd` - [ ] **Step 1: Implement base_ai.gd** ```gdscript # src/ai/base_ai.gd class_name BaseAI extends RefCounted var ai_name: String = "AI" func decide(hand: Array[Card], table: Array[HandEvaluator.EvaluatedPlay], current_rank: int, config: RuleConfig) -> HandEvaluator.EvaluatedPlay: # Override in subclasses var pass := HandEvaluator.EvaluatedPlay.new() pass.type = -1 pass.primary_rank = 0 return pass ``` - [ ] **Step 2: Implement l1_basic_ai.gd** ```gdscript # src/ai/l1_basic_ai.gd class_name L1BasicAI extends BaseAI func _init(): ai_name = "Basic AI" func decide(hand: Array[Card], table: Array[HandEvaluator.EvaluatedPlay], 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: # Fresh round — play smallest single return _smallest_play(moves) # Must beat last play 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 == TYPE_BOMB or m.type == 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 ``` - [ ] **Step 3: Write AI tests** ```gdscript # tests/test_ai.gd extends GutTest var _config: RuleConfig func before_each(): _config = RuleConfig.standard() func _card(id: int) -> Card: var orig_id := id % 54 var suit := (orig_id % 4) if orig_id < 52 else (4 if orig_id == 52 else 5) var rank: int if orig_id < 52: rank = 2 + orig_id / 4 as int elif orig_id == 52: rank = 15 else: rank = 16 var c := Card.create(orig_id, suit, rank) c.card_id = id return c func _cards(ids: Array) -> Array[Card]: var result: Array[Card] = [] for id in ids: result.append(_card(id)) return result func test_l1_ai_plays_something(): var ai := L1BasicAI.new() var hand := _cards([4, 8, 12, 16, 20]) # 5 consecutive ranks var table: Array[HandEvaluator.EvaluatedPlay] = [] var decision := ai.decide(hand, table, 5, _config) assert_true(decision.type > 0, "L1 AI should play something on fresh round") func test_l1_ai_passes_when_cannot_beat(): var ai := L1BasicAI.new() var hand := _cards([0]) # single 2 var prev := HandEvaluator.evaluate(_cards([48, 50, 52, 53]), 5, _config) # high bomb var table := [prev] var decision := ai.decide(hand, table, 5, _config) # Should pass if single 2 can't beat a bomb assert_eq(decision.type, -1, "L1 AI should pass when can't beat high bomb") ``` - [ ] **Step 4: Run test** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_ai.gd ``` - [ ] **Step 5: Commit** ```bash git add src/ai/base_ai.gd src/ai/l1_basic_ai.gd tests/test_ai.gd git commit -m "feat: add L1 Basic AI with test coverage" ``` --- ### Task 10: L2 Rule AI with Heuristic Scoring **Files:** - Create: `src/ai/l2_rule_ai.gd` - [ ] **Step 1: Implement l2_rule_ai.gd** ```gdscript # src/ai/l2_rule_ai.gd class_name L2RuleAI extends BaseAI func _init(): ai_name = "Rule AI" func decide(hand: Array[Card], table: Array[HandEvaluator.EvaluatedPlay], 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() # Score each candidate 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 # Hand quality: fewer remaining cards is better var remaining := hand_size - play.cards.size() score += (27.0 - remaining) / 27.0 * 0.3 # Preserve bombs (don't waste them) if play.type == TYPE_BOMB or play.type == TYPE_ROCKET: score -= 0.2 elif play.type == TYPE_STRAIGHT_FLUSH: score -= 0.1 # Prefer plays that clear many cards score += play.cards.size() * 0.02 # Avoid playing large cards early (keep aces/kings) if play.primary_rank >= 14 and remaining > 10: score -= 0.1 # Near endgame: push to finish if remaining <= 3: score += 0.5 # Wild card efficiency: prefer not using wilds unless needed 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 == TYPE_ROCKET: return true if play.type == TYPE_BOMB and last.type != 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 ``` - [ ] **Step 2: Add L2 test to test_ai.gd** ```gdscript func test_l2_ai_prefers_bigger_plays(): var ai := L2RuleAI.new() var hand := _cards([0, 54, 108, 162]) # bomb of 2s var table: Array[HandEvaluator.EvaluatedPlay] = [] var decision := ai.decide(hand, table, 5, _config) # L2 AI has scoring but should play something (not necessarily bomb on fresh round) assert_true(decision.type != -1 or hand.size() > 0) ``` - [ ] **Step 3: Run test** - [ ] **Step 4: Commit** ```bash git add src/ai/l2_rule_ai.gd tests/test_ai.gd git commit -m "feat: add L2 Rule AI with heuristic scoring" ``` --- ## Phase 5: Godot Integration (Tasks 11-13) ### Task 11: Autoloads (EventBus, Config, AudioManager) **Files:** - Create: `src/autoload/event_bus.gd` - Create: `src/autoload/config.gd` - Create: `src/autoload/audio_manager.gd` - [ ] **Step 1: Implement event_bus.gd** ```gdscript # src/autoload/event_bus.gd extends Node signal player_played_cards(player_idx: int, play_type: int, cards: Array) signal bomb_detonated(player_idx: int, rank: int) signal tribute_triggered(from_idx: int, to_idx: int) signal round_end() signal game_over(winner_team: int, reason: String) signal player_finished(player_idx: int, position: int) signal turn_changed(player_idx: int) signal table_cleared(player_idx: int) ``` - [ ] **Step 2: Implement config.gd** ```gdscript # src/autoload/config.gd extends Node var rule_config := RuleConfig.standard() var enable_ai_debug: bool = false var enable_training_hints: bool = true var enable_sound: bool = true var low_perf_mode: bool = false var turn_timeout_sec: float = 30.0 var language: String = "zh_cn" func reset_to_defaults() -> void: rule_config = RuleConfig.standard() enable_ai_debug = false enable_training_hints = true enable_sound = true low_perf_mode = false turn_timeout_sec = 30.0 ``` - [ ] **Step 3: Implement audio_manager.gd** ```gdscript # src/autoload/audio_manager.gd extends Node func play_card_place() -> void: pass # placeholder func play_bomb() -> void: pass func play_pass() -> void: pass func play_victory() -> void: pass func play_tribute() -> void: pass func set_muted(muted: bool) -> void: pass ``` - [ ] **Step 4: Register autoloads in project.godot** (manual via Godot Editor) - [ ] **Step 5: Commit** ```bash git add src/autoload/ git commit -m "feat: add autoload singletons (EventBus, Config, AudioManager)" ``` --- ### Task 12: Game Controller **Files:** - Create: `src/game/training_controller.gd` - Create: `src/game/game_controller.gd` - Create: `src/game/replay_recorder.gd` - [ ] **Step 1: Implement game_controller.gd** ```gdscript # src/game/game_controller.gd # Glue layer: connects core logic to UI and AI class_name GameController extends Node signal state_changed() signal turn_ready(player_idx: int, is_human: bool) signal game_ended(winner_team: int, reason: String) var game_state: GameState var ai_players: Dictionary = {} # player_idx -> BaseAI var is_processing: bool = false func start_game(config: RuleConfig, human_idx: int = 0, seed_: int = -1) -> void: game_state = GameState.create(config, seed_) game_state.player_human[human_idx] = true # Create AI for non-human players for i in range(4): if not game_state.player_human[i]: ai_players[i] = L2RuleAI.new() # Deal var deck := Deck.create(game_state.seed) game_state.deal_cards(deck) game_state.phase = GameState.Phase.PLAY game_state.round.active_player_idx = 0 state_changed.emit() func handle_human_play(cards: Array[Card]) -> Dictionary: if is_processing: return constants.err(ERR_NOT_YOUR_TURN) var hand := game_state.get_hand(game_state.round.active_player_idx) var play := HandEvaluator.evaluate(cards, game_state.current_rank, game_state.rule_config) if play == null or play.type == HandEvaluator.INVALID: return constants.err(ERR_INVALID_CARDS) var result := RuleEngine.can_play(hand, play, game_state.round.table, game_state.round.last_non_pass_player(), game_state.current_rank, game_state.rule_config) if not result.ok: return result _apply_play(game_state.round.active_player_idx, play) _advance_turn() return constants.success() func handle_human_pass() -> Dictionary: if is_processing: return constants.err(ERR_NOT_YOUR_TURN) var old_last := game_state.round.last_non_pass_player() if old_last >= 0 and old_last == game_state.round.active_player_idx: return constants.err(ERR_CANNOT_PASS) var pass := HandEvaluator.EvaluatedPlay.new() pass.type = -1 pass.primary_rank = 0 _apply_play(game_state.round.active_player_idx, pass) _advance_turn() return constants.success() func _apply_play(player_idx: int, play: HandEvaluator.EvaluatedPlay) -> void: if play.type != -1: game_state.remove_cards_from_hand(player_idx, play.cards) var action := Actions.Action.new() action.player_idx = player_idx action.action_type = "PASS" if play.type == -1 else "PLAY" action.cards = play.cards.duplicate(false) action.seq_id = game_state.round.next_seq() action.timestamp = Time.get_unix_time_from_system() game_state.action_log.append(action) game_state.round.add_play(play, player_idx) var hand := game_state.get_hand(player_idx) if hand.is_empty() and not game_state.is_player_finished(player_idx): game_state.add_finished_player(player_idx) # Check game over if game_state.all_hands_empty(): _end_game() func _advance_turn() -> void: var hand: Array = game_state.get_hand(game_state.round.active_player_idx) if hand.is_empty(): # Player finished — catch-up: teammate gets turn var partner := game_state.get_partner(game_state.round.active_player_idx) if not game_state.is_player_finished(partner): game_state.round.active_player_idx = partner game_state.round.reset_for_new_round() else: _next_alive_player() elif game_state.round.is_cleared: game_state.round.reset_for_new_round() else: _next_alive_player() var current := game_state.round.active_player_idx turn_ready.emit(current, game_state.player_human[current]) if not game_state.player_human[current]: _trigger_ai(current) func _next_alive_player() -> void: for _i in range(4): game_state.round.active_player_idx = (game_state.round.active_player_idx + 1) % 4 if not game_state.is_player_finished(game_state.round.active_player_idx): return func _trigger_ai(player_idx: int) -> void: var ai := ai_players.get(player_idx) if ai == null: return var hand := game_state.get_hand(player_idx) var decision := ai.decide(hand, game_state.round.table, game_state.current_rank, game_state.rule_config) if decision.type == -1: _apply_play(player_idx, decision) else: _apply_play(player_idx, decision) _advance_turn() func _end_game() -> void: game_state.phase = GameState.Phase.GAME_OVER # Determine winner var team_counts := [0, 0] for fp in game_state.finished_players: var t := game_state.get_team(fp) if t != null: team_counts[t.team_id] += 1 if team_counts[0] >= 2: game_state.current_winner_team = 0 elif team_counts[1] >= 2: game_state.current_winner_team = 1 else: game_state.current_winner_team = 0 if team_counts[0] > team_counts[1] else 1 game_state.game_end_reason = "NORMAL" game_ended.emit(game_state.current_winner_team, "NORMAL") ``` - [ ] **Step 2: Implement training_controller.gd** ```gdscript # src/game/training_controller.gd class_name TrainingController extends GameController var _current_hint: HandEvaluator.EvaluatedPlay = null func get_hint() -> HandEvaluator.EvaluatedPlay: var hand := game_state.get_hand(game_state.round.active_player_idx) if hand.is_empty(): return null var ai := L2RuleAI.new() return ai.decide(hand, game_state.round.table, game_state.current_rank, game_state.rule_config) func get_all_legal_moves() -> Array: var hand := game_state.get_hand(game_state.round.active_player_idx) return MoveGenerator.generate(hand, game_state.current_rank, game_state.rule_config) ``` - [ ] **Step 3: Implement replay_recorder.gd** (stub) ```gdscript # src/game/replay_recorder.gd extends Node var is_recording: bool = false var recorded_actions: Array = [] func start_recording() -> void: recorded_actions.clear() is_recording = true func record_action(action: Actions.Action) -> void: if is_recording: recorded_actions.append(action) func stop_recording() -> String: is_recording = false return "replay_recorded" # placeholder ``` - [ ] **Step 4: Commit** ```bash git add src/game/ git commit -m "feat: add GameController, TrainingController, and ReplayRecorder" ``` --- ### Task 13: Basic UI Scenes (Training Mode) **Files:** - Create: `src/ui/components/card_node.tscn` - Create: `src/ui/components/hand_area.tscn` - Create: `src/ui/scenes/training_room.tscn` - Create: `src/ui/scenes/main_menu.tscn` - [ ] **Step 1: Create main_menu.tscn** Create a Godot scene with: - `Control` root with `VBoxContainer` - `Label` "掼蛋训练模式" - `Button` "开始训练" → switches to `training_room.tscn` - `Button` "退出" - [ ] **Step 2: Create card_node.tscn** Scene: `Control` (root) - `TextureRect` for card face (placeholder sized 80×120) - `Label` overlay for card text - Script: `card_node.gd` with: - `var card_data: Card` - `var is_selected: bool` - Signal: `card_clicked(card_node)` - Signal: `card_double_clicked(card_node)` - `_on_gui_input(event)` handling click/double-click - [ ] **Step 3: Create hand_area.tscn** Scene: `HBoxContainer` (root) - Script: `hand_area.gd`: - `var cards: Array[CardNode] = []` - `func update_hand(hand: Array[Card]) -> void`: instantiates/recycles card nodes - Signal: `cards_selected(selected: Array[Card])` - Signal: `hint_requested()` - "出牌" button + "过牌" button + "提示" button - [ ] **Step 4: Create training_room.tscn** Root: `Control` (full screen) - Top: `Scoreboard` (team scores, current rank) - Center: `PlayedZone` (table area showing last plays) - Bottom: `HandArea` (player's hand) - Left/Right/Top: opponent areas (compact) - Script: `training_room.gd`: - `var controller: TrainingController` - `func _ready()`: create `TrainingController`, call `start_game()`, connect signals - On `turn_ready`: enable/disable hand area - On `state_changed`: refresh UI - [ ] **Step 5: Commit** ```bash git add src/ui/ git commit -m "feat: add basic UI scenes for training mode (card, hand, training room)" ``` --- ## Phase 6: GUT Test Suite Setup & Verification ### Task 14: Install GUT and Run Full Suite - [ ] **Step 1: Install GUT** Download GUT from Asset Library or clone into `addons/gut/` - [ ] **Step 2: Create test runner scene** Create `res://tests/test_runner.tscn` with GUT runner - [ ] **Step 3: Run all tests** ```bash godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests ``` - [ ] **Step 4: Fix any failing tests** - [ ] **Step 5: Commit** ```bash git add addons/gut/ tests/ git commit -m "test: add GUT test framework and verify all tests pass" ``` --- ## Phase 7: Integration & Polish (Task 15) ### Task 15: End-to-End Training Mode Playthrough - [ ] **Step 1: Verify the full flow works** 1. Launch Godot → Main Menu → 开始训练 2. Cards deal (27 each) 3. Player plays card 4. AI responds 5. Game continues until completion 6. Verify win condition triggers - [ ] **Step 2: Add hint button functionality** Connect training_controller `get_hint()` to hand_area hint button → highlight recommended cards - [ ] **Step 3: Add card sorting in hand area** Sort displayed cards by rank ascending before rendering - [ ] **Step 4: Commit** ```bash git add . git commit -m "feat: complete training mode with hint system and card sorting" ``` --- ## Self-Review **1. Spec coverage check:** - Section 一 (Architecture): Tasks 1,11 cover layering, autoloads, error codes - Section 二 (Data Model): Tasks 2,4,8 cover Card, RuleConfig, GameState, Actions - Section 三 (AI): Tasks 9,10 cover L1, L2 AI - Section 四 (File Structure): All tasks create matching directories - Section 五 (Testing): Tasks include GUT tests throughout - Section 七 (Constraints): timeout handled in config, cleared state in Round - Section 八 (UI): Task 13 covers basic UI **2. Placeholder scan:** No TBD, TODO, or vague references found. **3. Type consistency:** Card, HandEvaluator.EvaluatedPlay, RuleConfig, GameState, Round, BaseAI — types consistent across all tasks. **4. Missing items for subsequent phases (out of scope for this plan):** - L3 AI, network layer, spectator mode, full polish UI