"""A deterministic computer for RLCard 1.2.0, using only the acting player's view. Plan a compact hand decomposition, keep control cards, cooperate with the other farmer and guard against opponents with one/two cards. No network, seed, weights, perfect-information deal or inference of the other seats' exact hands is used. """ from collections import Counter from functools import lru_cache from rlcard.games.doudizhu.judger import DoudizhuJudger from rlcard.games.doudizhu.utils import CARD_TYPE ORDER = '3456789TJQKA2BR' INDEX = {r: i for i, r in enumerate(ORDER)} HIGH = sum(8 << (4 * i) for i in range(15)) def mask(cards): return sum(n << (4 * INDEX[r]) for r, n in Counter(cards).items()) def contains(hand, play): # The high bit in each nibble prevents a borrow into the next card rank. return ((hand | HIGH) - play) & HIGH == HIGH def bomb(move): return move == 'BR' or (len(move) == 4 and len(set(move)) == 1) def rank(move): counts = Counter(move) multiplicity = max(counts.values()) return max(INDEX[r] for r, n in counts.items() if n == multiplicity) @lru_cache(maxsize=4096) def all_plays(hand): return tuple(sorted(DoudizhuJudger.playable_cards_from_hand(hand), key=lambda a: (-len(a), rank(a), a))) class Planner: """Minimum shedding-turn decomposition, with a deterministic work bound. Branch on a rank still held, not the ordering of the eventual plays. A shared memo lets candidate moves reuse the same residual-hand results. This is hand planning, not a claim to have solved the imperfect-information game. """ def __init__(self, hand): self.nodes = 0 self.groups = [[] for _ in ORDER] self.memo = {0: 0.0} for action in all_plays(hand): encoded = mask(action) # Weak orphan singles/pairs are costly; high groups can regain lead. cost = 1.0 + (0.12 if len(action) <= 2 and rank(action) < 8 else 0) if bomb(action): cost -= 0.18 elif rank(action) >= 11: cost -= 0.08 for r in set(action): self.groups[INDEX[r]].append((encoded, cost, len(action))) def cost(self, hand): if hand in self.memo: return self.memo[hand] self.nodes += 1 first = next(i for i in range(15) if (hand >> (4 * i)) & 7) options = [(m, c, n) for m, c, n in self.groups[first] if contains(hand, m)] # Once the shared search budget is spent, use a legal greedy decomposition. # A node cap keeps the choice reproducible across CPUs (unlike wall time). if self.nodes > 6000: action, cost, _ = options[0] result = cost + self.cost(hand - action) else: result = min(cost + self.cost(hand - action) for action, cost, _ in options) self.memo[hand] = result return result def target(trace): if len(trace) >= 2 and trace[-1][1] == trace[-2][1] == 'pass': return None return next(((int(p), a) for p, a in reversed(trace) if a != 'pass'), None) class Agent: use_raw = True def step(self, state): raw = state['raw_obs'] legal = sorted(state.get('raw_legal_actions', raw['actions'])) playable = [a for a in legal if a != 'pass'] if not playable: return 'pass' hand = raw['current_hand'] if hand in playable: return hand me, landlord = int(raw['self']), int(raw['landlord']) left = [int(n) for n in raw['num_cards_left']] opponents = [s for s in range(3) if (s == landlord) != (me == landlord)] partner = next((s for s in range(3) if s not in (me, landlord)), None) if me != landlord else None current = target(raw['trace']) leading = current is None or current[0] == me minimum = min(left[s] for s in opponents) # A farmer should normally let its teammate's winning play stand. # Exception: the landlord is next with one card and may cover a low single. guarding = (partner is not None and current is not None and current[0] == partner and (me + 1) % 3 == landlord and left[landlord] == 1 and len(current[1]) == 1) if partner is not None and current is not None and current[0] == partner and 'pass' in legal and not guarding: return 'pass' planner = Planner(hand) held, counts = mask(hand), Counter(hand) unseen = Counter(raw.get('others_hand', '')) base = planner.cost(held) def score(action): remainder = held - mask(action) value = 3.2 * (base - planner.cost(remainder)) + len(action) * 0.07 power = rank(action) # Spend the smallest adequate control; avoid fragmenting bombs/rocket. used = Counter(action) value -= power * 0.022 for r, n in used.items(): if counts[r] == 4 and n != 4: value -= 0.65 if counts['B'] and counts['R'] and action != 'BR' and ('B' in action or 'R' in action): value -= 0.8 if bomb(action): value -= 1.15 if minimum > 3 and len(hand) > 6 else 0.25 if len(action) <= 3 and len(used) == 1: # Unseen rank counts are public card counting, not seat information. higher = sum(n >= len(action) for r, n in unseen.items() if INDEX[r] > power) if higher == 0: value += 0.28 if leading: if minimum == 1 and len(action) == 1: value -= 2.0 value += power * 0.13 elif minimum == 2 and len(action) == 2: value -= 1.2 value += power * 0.07 # Feed a nearly-out partner a low group when landlord cannot end on it. if partner is not None and left[partner] <= 2 and len(action) == left[partner] and minimum > len(action): value += 1.3 - power * 0.1 elif minimum <= 2 or guarding: if len(action) <= 2 or bomb(action): value += power * (0.25 if minimum == 1 or guarding else 0.12) return value chosen = max(playable, key=lambda a: (score(a), -rank(a), len(a), a)) # It can be right to retain a bomb/rocket when the enemy still has many cards. if not leading and 'pass' in legal and minimum > 4 and bomb(chosen) and len(hand) > 6: remaining = planner.cost(held - mask(chosen)) if remaining > 1.2: return 'pass' return chosen def eval_step(self, state): return self.step(state), {}