Initial Commit

This commit is contained in:
2026-06-23 20:44:47 +10:00
commit d2598631b0
5 changed files with 516 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
*.db
test.py
test.txt
*.__pycache__

104
init.py Normal file
View File

@@ -0,0 +1,104 @@
import sqlite3
cards = [
("Blank", "When this card is discarded (not destroyed), add Antimatter to 1 joker **(Persistent)**", "Joker", "Base Game"),
("Blueprint", "If the card immediately below this card is a joker, copy all of its effects", "Joker", "Base Game"),
("Chaos the Clown", "Allows the shop to be shuffled and redrawn 1 additional time (before making your selection)", "Joker", "Base Game"),
("Cloud Nine", "If your played hand contains a 9, draw a card from the shop deck (must have room)", "Joker", "Base Game"),
("DNA", "If your played hand has exactly 1 card, add a red seal to your played card (after scoring)", "Joker", "Base Game"),
("Encrypt", "Apply the effects of lagging to yourself", "Joker", "Base Game"),
("Four Fingers", "Flushes and straights can be made with 4 cards", "Joker", "Base Game"),
("Ghost", "When another player uses a spectral card, take it instead of destroying it (must have room)", "Joker", "Base Game"),
("Half Joker", "Increase the rank of played Poker hand by 3 if it contains 3 or less cards", "Joker", "Base Game"),
("Juggler", "+1 hand size", "Joker", "Base Game"),
("Madness", "At the start of every playing round, destroy a random joker in play (cannot select Eternal jokers)", "Joker", "Base Game"),
("Mime", "When another player discards playing cards, force 1 random discarded card to be kept in their hand", "Joker", "Base Game"),
("Oops! All Sixes", "When some of your card(s) are randomly selected, instead choose which card(s) are selected", "Joker", "Base Game"),
("Perkeo", "Add a red seal to a random consumable at the start of every round (cannot select a consumable with a red seal)", "Joker", "Base Game"),
("Recycle", "At the start of every playing round, shuffle and draw 1 card from the discarded shop cards (must have room)", "Joker", "Base Game"),
("Riff Raff", "At the start of every playing round, draw the top most joker from the shop deck, then shuffle the shop deck (must have room)", "Joker", "Base Game"),
("Shortcut", "Allows straights to be made with gaps of 1 rank (e.g. 10 8 6 5 3)", "Joker", "Base Game"),
("Sixth Sense", "If your played hand contains a 6, destroy a 6 from your played hand and draw the most recently discarded consumable (must have room)", "Joker", "Base Game"),
("Smeared Joker", "Hearts & Diamonds; Spades & Clubs are considered the same suit", "Joker", "Base Game"),
("Stuntman", "Increase the rank of played Poker hand by 3; -2 hand size", "Joker", "Base Game"),
("Thief", "At the end of every playing round, you may steal 1 playing card from another players played hand and add it to your deck", "Joker", "Base Game"),
("Wormhole", "All other players planet cards have no effect", "Joker", "Base Game"),
("Mercury", "Increase the rank of Pair by 3", "Planet", "Base Game"),
("Venus", "Increase the rank of Three of a Kind by 3", "Planet", "Base Game"),
("Earth", "Increase the rank of Full House by 2", "Planet", "Base Game"),
("Mars", "Increase the rank of Four of a Kind by 2", "Planet", "Base Game"),
("Jupiter", "Increase the rank of Flush by 2", "Planet", "Base Game"),
("Saturn", "Increase the rank of Straight by 2", "Planet", "Base Game"),
("Uranus", "Increase the rank of Two Pair by 3", "Planet", "Base Game"),
("Neptune", "Increase the rank of Straight Flush by 2 (Includes Royal Flush)", "Planet", "Base Game"),
("Pluto", "Increase the rank of High Card by 4", "Planet", "Base Game"),
("Abandoned", "Destroy another players played cards (they still score for this round)", "Tarot", "Base Game"),
("Death", "Convert 1 card in your hand into another card in your hand; destroy the converted card after this hand", "Tarot", "Base Game"),
("Erratic", "Swap your hand with another players hand at the start of a playing round; keep the swapped cards after this round", "Tarot", "Base Game"),
("Fool", "Copy the effects of the last used consumable", "Tarot", "Base Game"),
("Hanged Man", "Destroy up to 3 cards from your hand; do not draw new cards until your next discard", "Tarot", "Base Game"),
("Jimbo", "Add a Joker playing card to your deck, then shuffle your deck", "Tarot", "Base Game"),
("Judgement", "Swap 1 random joker owned by you with 1 random joker owned by a random other player", "Tarot", "Base Game"),
("Lovers", "Convert 2 cards into any suit for this hand", "Tarot", "Base Game"),
("Moon", "Convert up to 3 cards into clubs for this hand", "Tarot", "Base Game"),
("Star", "Convert up to 3 cards into diamonds for this hand", "Tarot", "Base Game"),
("Strength", "Increase the rank of up to 3 cards by 1 for this hand (Aces become Twos)", "Tarot", "Base Game"),
("Sun", "Convert up to 3 cards into hearts for this hand", "Tarot", "Base Game"),
("The Wheel", "Shuffle your hand into your deck, then draw a new hand", "Tarot", "Base Game"),
("Tower", "Convert 1 card into a stone card for this hand", "Tarot", "Base Game"),
("World", "Convert up to 3 cards into spades for this hand", "Tarot", "Base Game"),
("Ankh", "Add a red seal to a random joker, destroy all other jokers", "Spectral", "Base Game"),
("Cryptid", "Create 2 copies of 1 held card for this hand", "Spectral", "Base Game"),
("Deja Vu", "Add a red seal to 1 held playing card", "Spectral", "Base Game"),
("Ectoplasm", "Destroy a random joker not owned by you; -1 hand size permanently after use", "Spectral", "Base Game"),
("Mr. Bones", "+1 Life; -1 hand size permanently after use", "Spectral", "Base Game"),
("Trance", "Increase the rank of this Poker hand by 2", "Spectral", "Base Game"),
("Antimatter", "This card and the card it is attached to does not take up a joker/consumable slot", None, "Base Game"),
("Brainstorm", "Copy the effects of the top-most joker of any player", "Joker", "Eternal Destruction"),
("Burnt Joker", "Increase the rank of played Poker hand by 3 if your played Poker hand was discarded prior this round", "Joker", "Eternal Destruction"),
("Grave Digger", "Allows 1 discarded card to be used in your played hand", "Joker", "Eternal Destruction"),
("Idol", "Increase the rank of played Poker hand by 2 for each card played by other players that was also played by you", "Joker", "Eternal Destruction"),
("Invisible Joker", "When this card is discarded (not destroyed), add a red seal to a random joker **(Persistent)**", "Joker", "Eternal Destruction"),
("Joker Stencil", "Increase the rank of played Poker hand by 1 for each unused joker/consumable slot (includes this card)", "Joker", "Eternal Destruction"),
("Observatory", "Increase the rank of played Poker hand by 2 if you also have its respective planet card", "Joker", "Eternal Destruction"),
("Overstock", "Allows the shop to be shuffled and redrawn once (before making your selection) for each shop card discarded in this shop round", "Joker", "Eternal Destruction"),
("Persistence", "All shop cards held by all players become persistent", "Joker", "Eternal Destruction"),
("Splash", "All played cards are always ordered from highest to lowest rank when scoring", "Joker", "Eternal Destruction"),
("Wasteful", "+1 discard", "Joker", "Eternal Destruction"),
("Planet X", "Increase the rank of Five of a Kind by 2", "Planet", "Eternal Destruction"),
("Ceres", "Increase the rank of Flush House by 2", "Planet", "Eternal Destruction"),
("Chariot", "Increase the rank of played Poker hand by 1 for every 2 cards remaining in your hand (max. +4)", "Tarot", "Eternal Destruction"),
("High Priestess", "Draw the next Planet card from the shop deck (must have room)", "Tarot", "Eternal Destruction"),
("Refresh", "Remove all seals from 1 players jokers (can select yourself)", "Tarot", "Eternal Destruction"),
("Temperance", "Shuffle your shop cards into the shop deck (excluding jokers with an eternal seal), then draw cards equal to the amount removed (or until there is no room)", "Tarot", "Eternal Destruction"),
("The Emperor", "Draw the next Tarot card from the shop deck (must have room)", "Tarot", "Eternal Destruction"),
("Everlasting", "Add an eternal seal to any joker held by any player", "Spectral", "Eternal Destruction"),
("Hesitation", "Stop a random consumable held by any player from being used until the next shop", "Spectral", "Eternal Destruction"),
("Immolate", "Destroy all playing cards in your hand, then immediately draw a new hand", "Spectral", "Eternal Destruction"),
("Incantation", "Destroy up to 4 cards from your hand, then draw 4 more", "Spectral", "Eternal Destruction"),
("Ouija", "Shuffle your hand and discards back into your deck, then draw a new hand. All cards drawn have the rank of the first drawn card for this hand; -1 hand size permanently after use", "Spectral", "Eternal Destruction"),
("Talisman", "Allows 2 cards to be taken from this shop", "Spectral", "Eternal Destruction"),
("Unleash", "Shuffle all destroyed cards into the shop deck", "Spectral", "Eternal Destruction"),
("Wraith", "Draw the next 3 joker cards from the shop deck, then choose 1 (must have room); do not choose a card from the next shop.", "Spectral", "Eternal Destruction"),
]
with sqlite3.connect("cards.db") as conn:
cur = conn.cursor()
cur.execute("DROP TABLE IF EXISTS cards")
cur.execute("""
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
type TEXT,
dlc_name TEXT
)
""")
for card in cards:
cur.execute("INSERT INTO cards (name, description, type, dlc_name) VALUES (?, ?, ?, ?)", card)
conn.commit()

Binary file not shown.

213
src/main.py Normal file
View File

@@ -0,0 +1,213 @@
import os
import discord
from discord.ext import tasks
from dotenv import load_dotenv
from collections import Counter
import random
import twentyfour as tf
import asyncio
import re
import sqlite3
# 24
class TwentyFourSubmission:
def __init__(self, user: str, ast: tf.Node | None, result: int | float):
self.user: str = user
self.ast: tf.Node | None = ast
self.result: float = round(result, 2)
self.diff: float = round(abs(67 - result), 2)
class TwentyFourPlayer:
def __init__(self, name: str):
self.username: str = name
with sqlite3.connect("users.db") as conn:
cur = conn.cursor()
cur.execute(
'SELECT * FROM users WHERE username = ?',
(name,)
)
row = cur.fetchone()
if row is None:
# Create user
cur.execute(
'INSERT INTO users (username, rating) VALUES (?, ?)',
(name, 1500.0)
)
row = [name, 1500.0]
self.rating = row[1]
def update_user(self):
with sqlite3.connect("users.db") as conn:
cur = conn.cursor()
cur.execute(
'UPDATE users SET rating = ? WHERE username = ?',
(self.rating, self.username)
)
class TwentyFourGame:
def __init__(self, channel, running=False):
self.running = running
self.stopping = False
self.players: set[TwentyFourPlayer] = set([])
self.players_after_round: set[str] = set([])
self.submissions: list[TwentyFourSubmission] = []
self.channel: discord.Channel = channel
self.numbers: list[int] = []
self.target: int = 0
def end_round(self) -> list[TwentyFourSubmission]:
for player in self.players:
if player.username not in (player.user for player in self.submissions):
self.submissions.append(TwentyFourSubmission(player.username, None, float('inf')))
lb = sorted(self.submissions, key=lambda submission: submission.diff)
return lb
def start_round(self):
self.submissions = []
self.players = {TwentyFourPlayer(player) for player in self.players_after_round}
self.numbers = []
for i in range(5):
self.numbers.append(random.randint(1, 9))
def get_submission(self, user, ast) -> bool:
if user in [sub.user for sub in self.submissions]:
return False
self.submissions.append(TwentyFourSubmission(user, ast, tf.eval_ast(ast)))
return True
game: TwentyFourGame = TwentyFourGame(0)
# Setup
load_dotenv()
TOKEN = os.getenv('DISCORD_TOKEN')
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
@client.event
async def on_ready():
print(f'Logged in as {client.user} (ID: {client.user.id})')
print('Ready to parse incoming messages...')
print('------')
# Handle messages
@client.event
async def on_message(message):
global game
# Prevent infinite loops (even though it's fun)
if message.author == client.user:
return
content = str(message.content)
username = str(message.author)
channel = message.channel
# Check for [[card]]
cards = re.findall(r"\[\[(.*?)\]\]", content)
cards = [c.strip() for c in cards]
cards = list(set(cards))
if len(cards) > 0:
embed = discord.Embed()
with sqlite3.connect("cards.db") as conn:
cur = conn.cursor()
for card in cards:
cur.execute("SELECT * FROM cards WHERE LOWER(name)=LOWER(?)", (card,))
row = cur.fetchone()
if row:
embed.add_field(name=row[1], value=f"Description: {row[2]}\nType: {row[3]}\nDLC: {row[4]}", inline=False)
if len(embed.fields) > 0:
await channel.send(embed=embed)
return
# Check for 24++ game
if content == "!start":
if username == "citadel_941":
game = TwentyFourGame(channel.id)
asyncio.create_task(run_game(client, channel))
await channel.send("Starting game...")
else:
await channel.send("**Error**: Only admins can start a game!")
return
if game.running and channel.id == game.channel:
if content == "!join":
game.players_after_round.add(username)
await channel.send(f"@{username}: You will be added after this round")
elif content == "!leave" and username in [player for player in game.players_after_round]:
game.players_after_round.remove(username)
await channel.send(f"@{username}: You will be removed after this round")
return
elif content == "!stop":
if username == "citadel_941":
game.stopping = True
await channel.send("Stopping after this round")
else:
await channel.send("**Error**: Only admins can stop a game!")
return
elif username in [player.username for player in game.players]:
print("hi")
try:
tokens = tf.tokenise(content)
used_nums = (n.value for n in tokens if isinstance(n, tf.NumberToken))
if Counter(used_nums) != Counter(game.numbers):
raise ValueError
p = tf.Parser(tokens)
print(1)
ast = p.parseExpr(0)
if ast in [sub.ast for sub in game.submissions]:
await channel.send(f"@{username}: That solution is taken!")
return
res = tf.eval_ast(ast)
except (ValueError, ZeroDivisionError):
pass
else:
if username in [sub.user for sub in game.submissions]:
await channel.send(f"@{username}: Only one submission per round!")
return
game.submissions.append(TwentyFourSubmission(username, ast, res))
await channel.send(f"@{username} reached {round(res, 2)}")
return
if "unfortunate" in content.lower():
await channel.send("\"Unfortunate\" doesn't begin to describe my series, this game rewards blind luck and nothing else, I am beyond convinced at this point. After getting completely tooled by scheduling with my opponent changing times on me last minute and refusing to provide confirmation prior to the day of the match as to play times, losing this way somehow felt even worse than I had thought possible. My preparation was superior, my play was superior, and I lost, so I don't see a reason to continue engaging in an activity where what is within my control is overwhelmingly outweighed by what is not.")
await channel.send("I am done with competitive Pokemon, and you won't get a fond farewell. This community is infected to its roots with a degenerative disease that grows stronger over time but stops short of killing its host. Tournaments used to have a competitive spirit at their heart, this has been transplanted and replaced with an artificial organ that feeds on vitriol and mockery from insecure little boys that heckle by the sidelines and tear each other to shreds over scraps of attention. The environment we fostered has trapped us all like this in a vicious cycle, and escaping it requires acceptance of the harshest reality we all scramble to explain away, that none of the countless straining efforts we put ourselves through here will ever amount to one single shining glimmer of significance. I would make this the end, but World Cup is still ongoing, and I would never leave so many great friends out to dry, so I'll suffer through a few more games for them.")
await channel.send("One last thing before I leave you all to react with disdain, ridicule, and self-righteous fervor, before you do everything in your power to minimize my words and thoughts, box them up and shove them to some cobwebbed corner of your memory, and hope they disappear forever as a stain on your finite time ground to dust. From this moment on, nothing you say matters to me. The foulest insults you hurl with intent to wound will calmly settle at the earth before my feet, and the venom you spit will bring all the pain of a warm summer breeze. You are less than anything you can conceive, while I carry on, brimming with joy distilled from detachment.")
return
async def run_game(client, channel):
global game
game.running = True
game.stopping = False
while not game.stopping:
await asyncio.sleep(3)
game.start_round()
msg = "Numbers: "
for i in range(5):
msg += f"`{game.numbers[i]}` "
msg += "\nStart!"
await channel.send(msg)
await asyncio.sleep(35)
lb = game.end_round()
msg = ""
for sub in lb:
if sub.result < float('inf'):
msg += f"@{sub.user}: {sub.diff} off\n"
else:
msg += f"@{sub.user}: (No submission)\n"
if msg == "":
await channel.send("No players! Type \"!join\" to join!")
else:
await channel.send(msg)
game.running = False
await channel.send("Game has stopped")
if __name__ == "__main__":
client.run(TOKEN)

194
src/twentyfour.py Normal file
View File

@@ -0,0 +1,194 @@
from enum import Enum, auto
from collections.abc import Callable
from collections import Counter
class TokenType(Enum):
EOF = auto()
Number = auto()
OpenParen = auto()
CloseParen = auto()
Plus = auto()
Minus = auto()
Times = auto()
Divide = auto()
class Token:
def __init__(self, type: TokenType):
self.type: TokenType = type
class NumberToken(Token):
def __init__(self, type: TokenType, value: int):
super().__init__(type)
self.value: float = float(value)
class Node:
pass
class NumberNode(Node):
def __init__(self, value: float):
self.value: float = value
def __eq__(self, other):
if not isinstance(other, NumberNode):
return False
return self.value == other.value
class OperatorNode(Node):
def __init__(self, operator: TokenType, left: Node, right: Node):
self.operator: TokenType = operator
self.left: Node = left
self.right: Node = right
def __eq__(self, other):
if not isinstance(other, OperatorNode):
return False
if self.operator.value != other.operator.value:
return False
if self.operator == TokenType.Plus or self.operator == TokenType.Times:
if self.left == other.right and self.right == other.left:
return True
return self.left == other.left and self.right == other.right
class Parser:
def parseNumber(self, token: Token) -> Node:
if not isinstance(token, NumberToken):
raise TypeError("Cannot parse Node as NumberNode")
return NumberNode(token.value)
def parseGroupedExpr(self, token: Token) -> Node:
self.next()
expr = self.parseExpr(0)
if self.current is None:
raise ValueError
if self.current.type != TokenType.CloseParen:
raise ValueError
return expr
def parseInfixExpr(self, left: Node, operator: TokenType, right: Node) -> OperatorNode:
return OperatorNode(operator, left, right)
def __init__(self, tokens: list[Token]):
self.current: Token | None = tokens[0]
self.peek: Token | None = tokens[1]
self.tokens: list[Token] = tokens
self.idx: int = 1
self.precedence: dict[TokenType, int] = {
TokenType.EOF: 0,
TokenType.CloseParen: 0,
TokenType.Plus: 1,
TokenType.Minus: 1,
TokenType.Times: 2,
TokenType.Divide: 2
}
self.prefix: dict[TokenType, Callable[[Token], Node]] = {
TokenType.Number: self.parseNumber,
TokenType.OpenParen: self.parseGroupedExpr
}
self.infix: dict[TokenType, Callable[[Node, TokenType, Node], OperatorNode]] = {
TokenType.Plus: self.parseInfixExpr,
TokenType.Minus: self.parseInfixExpr,
TokenType.Times: self.parseInfixExpr,
TokenType.Divide: self.parseInfixExpr
}
def next(self):
if self.current is None:
return
if self.current.type == TokenType.EOF:
return
self.current = self.peek
self.idx += 1
try:
self.peek = self.tokens[self.idx]
except IndexError:
self.peek = None
def parseExpr(self, prec: int) -> Node:
if self.current is None:
raise ValueError
prefix = self.prefix.get(self.current.type)
if prefix is None:
raise ValueError
left: Node = prefix(self.current)
self.next()
cur_prec = self.precedence.get(self.current.type)
if cur_prec is None:
raise ValueError
while cur_prec > prec:
infix = self.infix.get(self.current.type)
if infix is None:
raise ValueError
operator = self.current
self.next()
right = self.parseExpr(cur_prec)
left = infix(left, operator.type, right)
cur_prec = self.precedence.get(self.current.type, -1)
return left
def tokenise(content: str):
currentNum: int | None = None
tokens: list[Token] = []
for char in content:
if char.isdigit():
if currentNum is None:
currentNum = 0
currentNum *= 10
currentNum += int(char)
else:
if currentNum is not None:
tokens.append(NumberToken(TokenType.Number, currentNum))
currentNum = None
match char:
case '*':
tokens.append(Token(TokenType.Times))
case '/':
tokens.append(Token(TokenType.Divide))
case '+':
tokens.append(Token(TokenType.Plus))
case '-':
tokens.append(Token(TokenType.Minus))
case '(':
tokens.append(Token(TokenType.OpenParen))
case ')':
tokens.append(Token(TokenType.CloseParen))
case ' ' | '\\':
pass
case _:
raise ValueError
if currentNum is not None:
tokens.append(NumberToken(TokenType.Number, currentNum))
tokens.append(Token(TokenType.EOF))
return tokens
def eval_ast(node: Node) -> float:
if isinstance(node, NumberNode):
return node.value
if isinstance(node, OperatorNode):
match node.operator:
case TokenType.Times:
return eval_ast(node.left) * eval_ast(node.right)
case TokenType.Divide:
return eval_ast(node.left) / eval_ast(node.right)
case TokenType.Plus:
return eval_ast(node.left) + eval_ast(node.right)
case TokenType.Minus:
return eval_ast(node.left) - eval_ast(node.right)
case _:
raise ValueError
raise ValueError
if __name__ == "__main__":
allowed_nums = [9, 9, 2, 1, 4]
try:
tokens = tokenise(input())
used_nums = (n.value for n in tokens if isinstance(n, NumberToken))
if Counter(used_nums) != Counter(allowed_nums):
raise ValueError
parser = Parser(tokens)
ast = parser.parseExpr(0)
print(round(eval_ast(ast), 2))
except ValueError, ZeroDivisionError:
print("Invalid Expression")