2266 lines
66 KiB
Markdown
2266 lines
66 KiB
Markdown
|
|
# 掼蛋训练模式 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
|