feat: add RuleEngine, GameState, Round, and Action system
This commit is contained in:
40
src/core/actions.gd
Normal file
40
src/core/actions.gd
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# src/core/actions.gd
|
||||||
|
class_name Actions
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
class Action:
|
||||||
|
extends RefCounted
|
||||||
|
var player_idx: int
|
||||||
|
var action_type: String
|
||||||
|
var cards: Array = []
|
||||||
|
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
|
||||||
|
|
||||||
|
class Team:
|
||||||
|
extends RefCounted
|
||||||
|
var team_id: int
|
||||||
|
var player_indices: Array[int] = []
|
||||||
|
var score: int = 0
|
||||||
|
var current_level: int = 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
|
||||||
114
src/core/game_state.gd
Normal file
114
src/core/game_state.gd
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# src/core/game_state.gd
|
||||||
|
class_name GameState
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
enum Phase { INIT, DEAL, TRIBUTE, PLAY, LEVEL_UP, GAME_OVER }
|
||||||
|
|
||||||
|
const _Actions = preload("res://src/core/actions.gd")
|
||||||
|
|
||||||
|
var phase: int = Phase.INIT
|
||||||
|
var round_seq: int = 0
|
||||||
|
var current_rank: int = 2
|
||||||
|
var rule_config: RuleConfig
|
||||||
|
var teams: Array = []
|
||||||
|
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 = []
|
||||||
|
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:
|
||||||
|
return player_hands[player_idx]
|
||||||
|
|
||||||
|
func remove_cards_from_hand(player_idx: int, cards: Array) -> void:
|
||||||
|
var hand: Array = player_hands[player_idx]
|
||||||
|
var to_remove := {}
|
||||||
|
for c in cards:
|
||||||
|
to_remove[c.card_id] = true
|
||||||
|
var new_hand: Array = []
|
||||||
|
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) -> 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
|
||||||
|
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:
|
||||||
|
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
|
||||||
41
src/core/round.gd
Normal file
41
src/core/round.gd
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# src/core/round.gd
|
||||||
|
class_name Round
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
var active_player_idx: int = 0
|
||||||
|
var pass_count: int = 0
|
||||||
|
var table: Array = []
|
||||||
|
var action_seq: int = 0
|
||||||
|
var is_cleared: bool = false
|
||||||
|
|
||||||
|
func can_pass() -> bool:
|
||||||
|
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
|
||||||
|
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
|
||||||
108
src/core/rule_engine.gd
Normal file
108
src/core/rule_engine.gd
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# src/core/rule_engine.gd
|
||||||
|
# Pure functions — zero Godot dependency
|
||||||
|
# Validate play legality and compare plays
|
||||||
|
|
||||||
|
class_name RuleEngine
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const _C = preload("res://src/core/constants.gd")
|
||||||
|
|
||||||
|
static func can_play(
|
||||||
|
hand: Array,
|
||||||
|
play: HandEvaluator.EvaluatedPlay,
|
||||||
|
table_history: Array,
|
||||||
|
last_player_idx: int,
|
||||||
|
current_rank: int,
|
||||||
|
config: RuleConfig
|
||||||
|
) -> Dictionary:
|
||||||
|
if play.type == -1:
|
||||||
|
var last_non_pass := _last_non_pass(table_history)
|
||||||
|
if table_history.is_empty() or last_non_pass == null:
|
||||||
|
return _err(_C.ERR_CANNOT_PASS)
|
||||||
|
if last_player_idx == _last_player_idx(table_history):
|
||||||
|
return _err(_C.ERR_CANNOT_PASS)
|
||||||
|
return _ok()
|
||||||
|
for pc in play.cards:
|
||||||
|
if not _card_in_hand(hand, pc):
|
||||||
|
return _err(_C.ERR_CARD_NOT_FOUND)
|
||||||
|
var last_play := _last_non_pass(table_history)
|
||||||
|
if last_play == null:
|
||||||
|
return _ok()
|
||||||
|
if last_player_idx < 0:
|
||||||
|
return _ok()
|
||||||
|
if _last_player_idx(table_history) == last_player_idx:
|
||||||
|
return _ok()
|
||||||
|
var cmp := compare(play, last_play, config)
|
||||||
|
if cmp <= 0:
|
||||||
|
return _err(_C.ERR_INVALID_CARDS)
|
||||||
|
if play.type != last_play.type:
|
||||||
|
if play.type != _C.TYPE_BOMB and play.type != _C.TYPE_ROCKET:
|
||||||
|
return _err(_C.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
|
||||||
|
if a.type == _C.TYPE_ROCKET and b.type == _C.TYPE_ROCKET:
|
||||||
|
return 0
|
||||||
|
if a.type == _C.TYPE_ROCKET:
|
||||||
|
return 1
|
||||||
|
if b.type == _C.TYPE_ROCKET:
|
||||||
|
return -1
|
||||||
|
if a.type == _C.TYPE_BOMB and b.type != _C.TYPE_BOMB:
|
||||||
|
return 1
|
||||||
|
if b.type == _C.TYPE_BOMB and a.type != _C.TYPE_BOMB:
|
||||||
|
return -1
|
||||||
|
if a.type == _C.TYPE_BOMB and b.type == _C.TYPE_BOMB:
|
||||||
|
return compare_bombs(a, b, config)
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
if a.cards.size() > b.cards.size():
|
||||||
|
return 1
|
||||||
|
if b.cards.size() > a.cards.size():
|
||||||
|
return -1
|
||||||
|
if a.primary_rank > b.primary_rank:
|
||||||
|
return 1
|
||||||
|
if b.primary_rank > a.primary_rank:
|
||||||
|
return -1
|
||||||
|
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) -> 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:
|
||||||
|
for i in range(table.size() - 1, -1, -1):
|
||||||
|
if table[i].type != -1:
|
||||||
|
return table[i]
|
||||||
|
return null
|
||||||
|
|
||||||
|
static func _last_player_idx(table: Array) -> int:
|
||||||
|
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": _C.RESULT_OK, "data": null}
|
||||||
|
|
||||||
|
static func _err(code: int) -> Dictionary:
|
||||||
|
return {"ok": false, "error_code": code, "data": null}
|
||||||
Reference in New Issue
Block a user