66 KiB
掼蛋训练模式 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
# 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
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
# 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
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
# 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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_cards.gd
Expected: all tests PASS
- Step 5: Commit
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
# 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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_deck.gd
- Step 3: Implement deck.gd
# 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
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
# 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
# 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)
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_game_state.gd
- Step 3: Commit
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
# 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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_hand_evaluator.gd
- Step 3: Implement hand_evaluator.gd
# 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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_hand_evaluator.gd
- Step 5: Commit
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
# 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
# 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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_move_generator.gd
- Step 4: Commit
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
# 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
# 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
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
# 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
# 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
# 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
# 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
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
# 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
# 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
# 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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests -gtest=res://tests/test_ai.gd
- Step 5: Commit
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
# 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
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
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
# 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
# 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
# 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
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
# 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
# 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)
# 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
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:
-
Controlroot withVBoxContainer -
Label"掼蛋训练模式" -
Button"开始训练" → switches totraining_room.tscn -
Button"退出" -
Step 2: Create card_node.tscn
Scene: Control (root)
-
TextureRectfor card face (placeholder sized 80×120) -
Labeloverlay for card text -
Script:
card_node.gdwith:var card_data: Cardvar 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: TrainingControllerfunc _ready(): createTrainingController, callstart_game(), connect signals- On
turn_ready: enable/disable hand area - On
state_changed: refresh UI
-
Step 5: Commit
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
godot --headless -s addons/gut/gut_cmdln.gd -gdir=res://tests
-
Step 4: Fix any failing tests
-
Step 5: Commit
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
- Launch Godot → Main Menu → 开始训练
- Cards deal (27 each)
- Player plays card
- AI responds
- Game continues until completion
- 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
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