9
\$\begingroup\$

This is a two-player card game which resembles a vastly simplified game of Texas Hold'em, combined with RPS (rock-paper-scissors) and the most basic mechanic of commercial turn-based card games.

The deck

A single full deck consists of 30 unique cards, where each card has one symbol out of RPS and one number between 0 and 9 inclusive. Each card is referred to as a 2-char string like R0 or S3 (symbol, then number).

Since the 30-card deck is divided into three smaller decks during the game, let's call the 30-card deck a full deck (as opposed to simply deck).

The match

A single match consists of two phases: Draft phase and Main phase. In the Draft phase, the two players' decks (10 cards each) are decided. In the Main phase, actual games of Poker are played, and each player gets some score based on the number of games won.

Draft phase

  1. The full deck of 30 cards is shuffled.
  2. Repeat five times:
    1. Each player is given two cards from the top of the full deck.
    2. Each player selects one of them, which will go into their own deck.
    3. Each player gives the unselected card to their opponent.

At the end of Draft phase, players A and B have a deck of 10 cards each, and the main deck has 10 cards remaining. Additionally, each player gets to know 5 of the 10 cards in the opponent's deck during the process.

Main phase

  1. Each deck (player's and the remaining) is shuffled.
  2. Each player draws three cards from their own deck.
  3. Repeat 10 times:
    1. A card (called base card) is shown from the remaining deck.
    2. Each player plays a card from their own hand.
    3. A winner is determined by comparing (A's play + base) to (B's play + base). The winner gains 1 point.
    4. Each player draws a card from their own deck (unless there's nothing to draw).

Deciding the winner of a single game

The winner is decided by comparing the two-card combinations in the following order:

  1. If one combination is a pair (two cards sharing a number) and the other is not, a pair always wins against a non-pair.
  2. If the two cards played (excluding the base card) have different numbers, the player who played a higher-numbered card wins.
  3. Otherwise, the two cards are compared under the RPS rules. R wins against S, S wins against P, and P wins against R.

Note that a draw is not possible since every card is unique in a deck.

Bots and the controller

An answer should include a single bot that can handle the Draft phase and the Main phase.

The controller code is here, with three example bots.

A bot has the following structure:

{
  name: 'MinimalValid',
  draftPick(cards) { return 0; },
  draftReceive(card) {},
  mainPlay(cards, base) { return 0; },
  mainPostprocess(card) {},
},
  • name: The name of the bot, a string.
  • draftPick: Called when the bot is given two cards to choose from during the draft phase (2-2).
    • Input: an array of cards to choose from
    • Output: a valid index in the array of cards
  • draftReceive: Called to inform the "unselected-by-opponent" card to the bot (2-3).
    • Input: a single card
    • Output: nothing
  • mainPlay: Called when the bot plays a card during the main phase (3-2).
    • Input: the hand (an array of cards), the base card
    • Output: a valid index in the hand
  • mainPostprocess: Called to inform the card played by the opponent.
    • Input: a single card
    • Output: nothing

A slightly better bot to illustrate how to keep state in this:

{
  name: 'Simple',
  myDeck: new Set(),
  draftPick(cards) {
    let cardId = cards[0][1] < cards[1][1] ? 1 : 0;
    this.myDeck.add(cards[cardId]);
    return cardId;
  },
  draftReceive(card) {
    this.myDeck.add(card);
  },
  mainPlay(cards, base) {
    this.myDeck.delete(cards[0]);
    return 0;
  },
  mainPostprocess(card) {
  },
},

A bot is immediately disqualified by the controller if it throws an exception or returns an invalid index. A submission is also invalid if it tries to cheat the controller in any way.

Given multiple bots, the controller will run round-robin matches. The entire cycle will be run multiple times (exact number depends on the number of submissions) and all scores will be accumulated to decide the overall winner.

The deadline is 00:00 UTC on June 18, 2021. If we don't have 10 submissions (excluding the two example bots) until then, the deadline will be extended by a week until we do. The winning answer will be accepted at that point.

The winner is Basic Odds by MegaTom. Congratulations!

You can still submit new bots even though the competition is officially over. (assuming I am active) I will run a new round and update the leaderboard. The accepted answer will not change even if the winner changes after the deadline.

The two bots presented here will be included when running the matches.

Leaderboard

The following is the total scores after 100 rounds.

1. BasicOdds     2500
2. ObviousStrats 2336
3. tsh           2016
4. Simple        1916
5. MinimalValid  1232
\$\endgroup\$
10
  • 3
    \$\begingroup\$ What's the point in having a deadline if you're still going to update the winner afterwards? What will change once the deadline has passed? \$\endgroup\$
    – pxeger
    May 28, 2021 at 8:25
  • 1
    \$\begingroup\$ The Object.freeze on bots introduce some confusion behaviors (you cannot assign its properties). Remove it would make implementation easier. \$\endgroup\$
    – tsh
    May 28, 2021 at 9:32
  • 1
    \$\begingroup\$ You say excluding X with no prior mention of X (take it it's the base card but that's not clarified). \$\endgroup\$
    – Noodle9
    May 29, 2021 at 9:33
  • 1
    \$\begingroup\$ Controller code updated to addess Arnauld's and tsh's comments. Also fixed the description about X (yes, it's the base card). \$\endgroup\$
    – Bubbler
    May 30, 2021 at 23:09
  • 1
    \$\begingroup\$ @FZs The final score of a bot is the total number of turns won across all games played. \$\endgroup\$
    – Bubbler
    May 30, 2021 at 23:14

3 Answers 3

3
\$\begingroup\$

Basic Odds

This bot takes high cards, then, using its information on the game state, will calculate the chance of winning now, and on a random future round. Whichever card has the highest difference between those numbers will be played.

{
  name: 'BasicOdds',
  selfCards: new Set(),
  opponentCards: new Set(),
  otherCards: new Set(),
  isWinner(self, opponent, base = '') {
    if (self[1] === base[1] && opponent[1] !== base[1]) return true;
    if (self[1] !== base[1] && opponent[1] === base[1]) return false;
    if (self[1] > opponent[1]) return true;
    if (self[1] < opponent[1]) return false;
    if ('RSPR'.includes(self[0] + opponent[0])) return true;
    return false;
  },
  draftPick(cards) {
    if (this.otherCards.size === 0) {
      fullDeck.forEach(card => { this.otherCards.add(card); });
      this.selfCards.clear();
      this.opponentCards.clear();
      this.start = false;
    }
    this.otherCards.delete(cards[0]);
    this.otherCards.delete(cards[1]);
    if (this.isWinner(cards[0], cards[1])){
      this.selfCards.add(cards[0]);
      this.opponentCards.add(cards[1]);
      return 0;
    } else {
      this.opponentCards.add(cards[0]);
      this.selfCards.add(cards[1]);
      return 1;
    }
  },
  draftReceive(card) {
    this.selfCards.add(card);
    this.otherCards.delete(card);
  },
  mainPlay(cards, base) {
    this.otherCards.delete(base);
    let values = cards.map((card,i)=>{
      let winnings = 0;
      this.otherCards.forEach(oc=>{
        winnings += +this.isWinner(card, oc, base);
      })
      winnings *= (this.selfCards.size - this.opponentCards.size) / this.otherCards.size;
      this.opponentCards.forEach(oc=>{
        winnings += +this.isWinner(card, oc, base);
      })
      let futureWinnings = 0;
      let m1 = (this.otherCards.size - this.selfCards.size + this.opponentCards.size) / this.otherCards.size;
      let m2 = m1 * (this.selfCards.size - this.opponentCards.size) / (this.otherCards.size - 1);
      this.otherCards.forEach(newBase=>{
        this.opponentCards.forEach(oc=>{
          futureWinnings += (+this.isWinner(card, oc, newBase)) * m1;
        })
        this.otherCards.forEach(oc=>{
          if(oc !== newBase){
            futureWinnings += (+this.isWinner(card, oc, newBase)) * m2;
          }
        })
      });
      return [winnings - futureWinnings/(this.selfCards.size-1), i];
    })
    let bestIndex = values.reduce((r, a) => (a[0] > r[0] ? a : r))[1];

    this.selfCards.delete(cards[bestIndex]);
    return bestIndex;
  },
  mainPostprocess(card) {
    this.otherCards.delete(card);
    this.opponentCards.delete(card);
  },
}

Credit to Spitemaster for the code he got from tsh, that I based this off of.

\$\endgroup\$
2
\$\begingroup\$

ObviousStrats

{
  name: 'ObviousStrats',
  selfCards: new Set(),
  opponentCards: new Set(),
  otherCards: new Set(),
  isWinner(self, opponent, base = '') {
    if (self[1] === base[1] && opponent[1] !== base[1]) return true;
    if (self[1] !== base[1] && opponent[1] === base[1]) return false;
    if (self[1] > opponent[1]) return true;
    if (self[1] < opponent[1]) return false;
    if ('RSPR'.includes(self[0] + opponent[0])) return true;
    return false;
  },
  draftPick(cards) {
    if (this.otherCards.size != 30) {
      fullDeck.forEach(card => { this.otherCards.add(card); });
      this.selfCards.clear();
      this.opponentCards.clear();
    }
    if (this.isWinner(cards[0], cards[1])){
      this.selfCards.add(cards[0]);
      this.opponentCards.add(cards[1]);
      return 0;
    } else {
      this.opponentCards.add(cards[0]);
      this.selfCards.add(cards[1]);
      return 1;
    }
  },
  draftReceive(card) {
    this.selfCards.add(card);
  },
  mainPlay(cards, base) {
    this.otherCards.delete(base);
    let match = cards.findIndex(card => card[1] == base[1]);
    if (match > -1){
        this.selfCards.delete(cards[match]);
        return match;
    }
    const opponentMatch = [...this.opponentCards].findIndex(card => card[1] == base[1]);
    if (opponentMatch > -1){
        const minValue = Math.min(...cards.map(card => parseInt(card[1])));
        match = cards.findIndex(card => +card[1] == minValue);
        this.selfCards.delete(cards[match]);
        return match;
    }
    const maxValue = Math.max(...cards.map(card => +card[1]));
    match = cards.findIndex(card => card[1] == maxValue);
    this.selfCards.delete(cards[match]);
    return match;
  },
  mainPostprocess(card) {
    this.otherCards.delete(card);
    this.opponentCards.delete(card);
  },
}

ObviousStrats doesn't over-complicate things. When drafting, it picks the card that would win if played against the other. When playing, it plays a pair, its lowest card if it knows its opponent has a pair, and otherwise its highest card.

Thanks tsh for the base code!

\$\endgroup\$
1
\$\begingroup\$
{
  name: 'tsh',
  /** @typedef {'R0'|'R1'|'R2'|'R3'|'R4'|'R5'|'R6'|'R7'|'R8'|'R9'|'P0'|'P1'|'P2'|'P3'|'P4'|'P5'|'P6'|'P7'|'P8'|'P9'|'S0'|'S1'|'S2'|'S3'|'S4'|'S5'|'S6'|'S7'|'S8'|'S9'} Card */
  /** @type {Set<Card>} */
  selfCards: new Set(),
  /** @type {Set<Card>} */
  opponentCards: new Set(),
  /** @type {Set<Card>} */
  otherCards: new Set(),
  /**
   * @param {Card} self
   * @param {Card} opponent
   * @param {Card} base
   */
  isWinner(self, opponent, base = '') {
    if (self[1] === base[1] && opponent[1] !== base[1]) return true;
    if (self[1] !== base[1] && opponent[1] === base[1]) return false;
    if (self[1] > opponent[1]) return true;
    if (self[1] < opponent[1]) return false;
    if ('RSPR'.includes(self[0] + opponent[0])) return true;
    return false;
  },
  /**
   * @param {Card} card
   * @returns number
   */
  scoreCard(card, deckRatio) {
    const sameNum = [...this.selfCards].filter(c => c[1] === card[1]);
    const otherSameNum = [...this.otherCards].filter(c => c[1] === card[1]);
    const largeThanOpponent = [...this.opponentCards].filter(c => c[1] < card[1]).length * 0.1;
    const cardValue = parseInt(card[1], 10) + largeThanOpponent + 0.5;
    if (otherSameNum.length === 0) {
      return cardValue - 1 + largeThanOpponent;
    } else if (otherSameNum.length === 1) {
      const cardWin = !this.isWinner(card, otherSameNum[0]);
      if (cardWin) {
        return 10 * deckRatio + cardValue * (1 - deckRatio);
      } else if (sameNum.length === 1) {
        return 0 * deckRatio + (cardValue - 1) * (1 - deckRatio);
      } else {
        return cardValue - 1;
      }
    } else {
      return (9 + cardValue) * deckRatio * (1 - deckRatio) / 2 +
        10 * deckRatio ** 2 +
        cardValue * (1 - deckRatio) ** 2;
    }
  },
  /**
   * @param {Card} card
   * @returns number
   */
  scorePickCard(card) {
    return this.scoreCard(card, 10 / this.otherCards.size);
  },
  /**
   * @param {Card} card
   * @param {Card} base
   * @returns number
   */
  scorePlayCard(card, base) {
    const selfWin = [...this.opponentCards, ...this.otherCards]
      .every(c => this.isWinner(card, c, base));
    if (selfWin) return Infinity;
    const knownWin = [...this.opponentCards]
      .filter(c => this.isWinner(card, c, base)).length /
      (this.selfCards.size + 1);
    const deckRatio = this.selfCards.size / this.otherCards.size;
    return this.scoreCard(card, deckRatio) + knownWin;
  },
  /**
   * @param {[Card, Card]} cards
   * @returns 0|1
   */
  draftPick(cards) {
    if (!this.otherCards.size) {
      fullDeck.forEach(card => { this.otherCards.add(card); });
      this.selfCards.clear();
      this.opponentCards.clear();
    }
    cards.forEach(card => this.otherCards.delete(card));
    const scores = cards.map(c => this.scorePickCard(c));
    if (scores[0] > scores[1]) {
      this.selfCards.add(cards[0]);
      this.opponentCards.add(cards[1]);
      return 0;
    } else {
      this.opponentCards.add(cards[0]);
      this.selfCards.add(cards[1]);
      return 1;
    }
  },
  /**
   * @param {Card} card
   */
  draftReceive(card) {
    this.selfCards.add(card);
  },
  /**
   * @param {[Card, Card]} cards
   * @param {Card} base
   * @returns number
   */
  mainPlay(cards, base) {
    this.otherCards.delete(base);
    const opponentWin = [...this.opponentCards].some(o => (
      [...this.selfCards].every(m => !this.isWinner(m, o, base))
    ));
    const scores = cards.map(card => {
      const score = this.scorePlayCard(card, base);
      if (opponentWin) return -score;
      return score + 10 * (card[1] === base[1]);
    });
    const index = scores.indexOf(Math.max(...scores));
    this.selfCards.delete(cards[index]);
    return index;
  },
  /**
   * @param {Card} card
   */
  mainPostprocess(card) {
    this.otherCards.delete(card);
    this.opponentCards.delete(card);
  },
}

Try it online!

It cost so many bytes, but only somehow better than Simple... Don't know how to name it, so, whatever...

Anyone may try to build your own bot based on this one if it could be helpful.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.