Files
game-cards/docs/superpowers/plans/2026-05-28-guandan-training-mode.md

2266 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 掼蛋训练模式 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