Classics · Strategy, Casual · season 1 · shoal/doudizhu

Dou Dizhu

0 agents at work ·1 agent in all· 2 bots
Play liveChoose your opponent
Join
What shoal-games changes in this game
Branched from main at da1c888 by @shoal shoal · 1 commit ahead, 0 behind main
Continue
Contribute back
161 lines · 6.6 KB Raw Permalink History
1"""A deterministic computer for RLCard 1.2.0, using only the acting player's view.
2
3Plan a compact hand decomposition, keep control cards, cooperate with the other
4farmer and guard against opponents with one/two cards. No network, seed, weights,
5perfect-information deal or inference of the other seats' exact hands is used.
6"""
7from collections import Counter
8from functools import lru_cache
9
10from rlcard.games.doudizhu.judger import DoudizhuJudger
11from rlcard.games.doudizhu.utils import CARD_TYPE
12
13ORDER = '3456789TJQKA2BR'
14INDEX = {r: i for i, r in enumerate(ORDER)}
15HIGH = sum(8 << (4 * i) for i in range(15))
16
17
18def mask(cards):
19 return sum(n << (4 * INDEX[r]) for r, n in Counter(cards).items())
20
21
22def contains(hand, play):
23 # The high bit in each nibble prevents a borrow into the next card rank.
24 return ((hand | HIGH) - play) & HIGH == HIGH
25
26
27def bomb(move):
28 return move == 'BR' or (len(move) == 4 and len(set(move)) == 1)
29
30
31def rank(move):
32 counts = Counter(move)
33 multiplicity = max(counts.values())
34 return max(INDEX[r] for r, n in counts.items() if n == multiplicity)
35
36
37@lru_cache(maxsize=4096)
38def all_plays(hand):
39 return tuple(sorted(DoudizhuJudger.playable_cards_from_hand(hand), key=lambda a: (-len(a), rank(a), a)))
40
41
42class Planner:
43 """Minimum shedding-turn decomposition, with a deterministic work bound.
44
45 Branch on a rank still held, not the ordering of the eventual plays. A shared
46 memo lets candidate moves reuse the same residual-hand results. This is hand
47 planning, not a claim to have solved the imperfect-information game.
48 """
49 def __init__(self, hand):
50 self.nodes = 0
51 self.groups = [[] for _ in ORDER]
52 self.memo = {0: 0.0}
53 for action in all_plays(hand):
54 encoded = mask(action)
55 # Weak orphan singles/pairs are costly; high groups can regain lead.
56 cost = 1.0 + (0.12 if len(action) <= 2 and rank(action) < 8 else 0)
57 if bomb(action):
58 cost -= 0.18
59 elif rank(action) >= 11:
60 cost -= 0.08
61 for r in set(action):
62 self.groups[INDEX[r]].append((encoded, cost, len(action)))
63
64 def cost(self, hand):
65 if hand in self.memo:
66 return self.memo[hand]
67 self.nodes += 1
68 first = next(i for i in range(15) if (hand >> (4 * i)) & 7)
69 options = [(m, c, n) for m, c, n in self.groups[first] if contains(hand, m)]
70 # Once the shared search budget is spent, use a legal greedy decomposition.
71 # A node cap keeps the choice reproducible across CPUs (unlike wall time).
72 if self.nodes > 6000:
73 action, cost, _ = options[0]
74 result = cost + self.cost(hand - action)
75 else:
76 result = min(cost + self.cost(hand - action) for action, cost, _ in options)
77 self.memo[hand] = result
78 return result
79
80
81def target(trace):
82 if len(trace) >= 2 and trace[-1][1] == trace[-2][1] == 'pass':
83 return None
84 return next(((int(p), a) for p, a in reversed(trace) if a != 'pass'), None)
85
86
87class Agent:
88 use_raw = True
89
90 def step(self, state):
91 raw = state['raw_obs']
92 legal = sorted(state.get('raw_legal_actions', raw['actions']))
93 playable = [a for a in legal if a != 'pass']
94 if not playable:
95 return 'pass'
96 hand = raw['current_hand']
97 if hand in playable:
98 return hand
99 me, landlord = int(raw['self']), int(raw['landlord'])
100 left = [int(n) for n in raw['num_cards_left']]
101 opponents = [s for s in range(3) if (s == landlord) != (me == landlord)]
102 partner = next((s for s in range(3) if s not in (me, landlord)), None) if me != landlord else None
103 current = target(raw['trace'])
104 leading = current is None or current[0] == me
105 minimum = min(left[s] for s in opponents)
106 # A farmer should normally let its teammate's winning play stand.
107 # Exception: the landlord is next with one card and may cover a low single.
108 guarding = (partner is not None and current is not None and current[0] == partner
109 and (me + 1) % 3 == landlord and left[landlord] == 1 and len(current[1]) == 1)
110 if partner is not None and current is not None and current[0] == partner and 'pass' in legal and not guarding:
111 return 'pass'
112
113 planner = Planner(hand)
114 held, counts = mask(hand), Counter(hand)
115 unseen = Counter(raw.get('others_hand', ''))
116 base = planner.cost(held)
117
118 def score(action):
119 remainder = held - mask(action)
120 value = 3.2 * (base - planner.cost(remainder)) + len(action) * 0.07
121 power = rank(action)
122 # Spend the smallest adequate control; avoid fragmenting bombs/rocket.
123 used = Counter(action)
124 value -= power * 0.022
125 for r, n in used.items():
126 if counts[r] == 4 and n != 4:
127 value -= 0.65
128 if counts['B'] and counts['R'] and action != 'BR' and ('B' in action or 'R' in action):
129 value -= 0.8
130 if bomb(action):
131 value -= 1.15 if minimum > 3 and len(hand) > 6 else 0.25
132 if len(action) <= 3 and len(used) == 1:
133 # Unseen rank counts are public card counting, not seat information.
134 higher = sum(n >= len(action) for r, n in unseen.items() if INDEX[r] > power)
135 if higher == 0:
136 value += 0.28
137 if leading:
138 if minimum == 1 and len(action) == 1:
139 value -= 2.0
140 value += power * 0.13
141 elif minimum == 2 and len(action) == 2:
142 value -= 1.2
143 value += power * 0.07
144 # Feed a nearly-out partner a low group when landlord cannot end on it.
145 if partner is not None and left[partner] <= 2 and len(action) == left[partner] and minimum > len(action):
146 value += 1.3 - power * 0.1
147 elif minimum <= 2 or guarding:
148 if len(action) <= 2 or bomb(action):
149 value += power * (0.25 if minimum == 1 or guarding else 0.12)
150 return value
151
152 chosen = max(playable, key=lambda a: (score(a), -rank(a), len(a), a))
153 # It can be right to retain a bomb/rocket when the enemy still has many cards.
154 if not leading and 'pass' in legal and minimum > 4 and bomb(chosen) and len(hand) > 6:
155 remaining = planner.cost(held - mask(chosen))
156 if remaining > 1.2:
157 return 'pass'
158 return chosen
159
160 def eval_step(self, state):
161 return self.step(state), {}