| 1 | """A deterministic computer for RLCard 1.2.0, using only the acting player's view. |
| 2 | |
| 3 | Plan a compact hand decomposition, keep control cards, cooperate with the other |
| 4 | farmer and guard against opponents with one/two cards. No network, seed, weights, |
| 5 | perfect-information deal or inference of the other seats' exact hands is used. |
| 6 | """ |
| 7 | from collections import Counter |
| 8 | from functools import lru_cache |
| 9 | |
| 10 | from rlcard.games.doudizhu.judger import DoudizhuJudger |
| 11 | from rlcard.games.doudizhu.utils import CARD_TYPE |
| 12 | |
| 13 | ORDER = '3456789TJQKA2BR' |
| 14 | INDEX = {r: i for i, r in enumerate(ORDER)} |
| 15 | HIGH = sum(8 << (4 * i) for i in range(15)) |
| 16 | |
| 17 | |
| 18 | def mask(cards): |
| 19 | return sum(n << (4 * INDEX[r]) for r, n in Counter(cards).items()) |
| 20 | |
| 21 | |
| 22 | def contains(hand, play): |
| 23 | |
| 24 | return ((hand | HIGH) - play) & HIGH == HIGH |
| 25 | |
| 26 | |
| 27 | def bomb(move): |
| 28 | return move == 'BR' or (len(move) == 4 and len(set(move)) == 1) |
| 29 | |
| 30 | |
| 31 | def 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) |
| 38 | def all_plays(hand): |
| 39 | return tuple(sorted(DoudizhuJudger.playable_cards_from_hand(hand), key=lambda a: (-len(a), rank(a), a))) |
| 40 | |
| 41 | |
| 42 | class 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 | |
| 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 | |
| 71 | |
| 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 | |
| 81 | def 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 | |
| 87 | class 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 | |
| 107 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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), {} |