diff --git a/Dockerfile b/Dockerfile index 5e98856..ec9334b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ COPY realm_api /app/realm_api RUN <<-EOF mkdir -p /app/data - pip install --no-cache -Ue . + pip install --no-cache --no-warn-script-location -Ue . EOF ENTRYPOINT [ "/usr/local/bin/python", "-m", "realm_api" ] diff --git a/package-lock.json b/package-lock.json index 6eefa1c..66907bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.1", "commitlint": "^17.7.1", - "commitlint-config-gitmoji": "^2.3.1", "gitmoji-cli": "^8.5.0", "husky": "^9.1.7", "nano-staged": "^0.8.0", @@ -774,41 +773,6 @@ "node": ">=12" } }, - "node_modules/@gitmoji/commit-types": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@gitmoji/commit-types/-/commit-types-1.1.5.tgz", - "integrity": "sha512-8D3FZMRY+gtYpTcHG1SOGmm9CFqxNh6rI9xDoCydxHxnWgqInbdF3nk9gibW5gXA58Hf2cVcJaLEcGOKLRAtmw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@gitmoji/gitmoji-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@gitmoji/gitmoji-regex/-/gitmoji-regex-1.0.0.tgz", - "integrity": "sha512-+BFXxcWCxn0UIYuG1v5n9SfaCCS8tw95j1x3QsTJRdGGiihRyVLTHiu1wrHlzH3z4nYXKjCKiZFTdWwMjRI+gQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "emoji-regex": "^10", - "gitmojis": "^3" - } - }, - "node_modules/@gitmoji/gitmoji-regex/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@gitmoji/parser-opts": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@gitmoji/parser-opts/-/parser-opts-1.4.0.tgz", - "integrity": "sha512-zzmx/vtpdB/ijjUm7u9OzHNCXWKpSbzVEgVzOzhilMgoTBlUDyInZFUtiCTV+Wf4oCP9nxGa/kQGQFfN+XLH1g==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gitmoji/gitmoji-regex": "1.0.0" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2603,31 +2567,6 @@ "node": ">=v14" } }, - "node_modules/commitlint-config-gitmoji": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/commitlint-config-gitmoji/-/commitlint-config-gitmoji-2.3.1.tgz", - "integrity": "sha512-T15ssbsyNc6szHlnGWo0/xvIA1mObqM70E9TwKNVTpksxhm+OdFht8hvDdKJAVi4nlZX5tcfTeILOi7SHBGH3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^17", - "@gitmoji/commit-types": "1.1.5", - "@gitmoji/parser-opts": "1.4.0", - "commitlint-plugin-gitmoji": "2.2.6" - } - }, - "node_modules/commitlint-plugin-gitmoji": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/commitlint-plugin-gitmoji/-/commitlint-plugin-gitmoji-2.2.6.tgz", - "integrity": "sha512-oKHPHeNXby0Ix0ZbHVSK5ZyPx1V4fyBjLOy93cYwXhOEPXe36nkDc/HDPFfQpzx1vz39277TaP9LScbqTbscfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@commitlint/types": "^17", - "@gitmoji/gitmoji-regex": "1.0.0", - "gitmojis": "^3" - } - }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", diff --git a/package.json b/package.json index e05a9f9..312c18f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^11.0.1", "commitlint": "^17.7.1", - "commitlint-config-gitmoji": "^2.3.1", "gitmoji-cli": "^8.5.0", "husky": "^9.1.7", "nano-staged": "^0.8.0", diff --git a/realm_api/rpc/__init__.py b/realm_api/rpc/__init__.py index 1f6d046..b18469c 100644 --- a/realm_api/rpc/__init__.py +++ b/realm_api/rpc/__init__.py @@ -7,6 +7,7 @@ from uuid import uuid4 # 3rd party +from pydantic import BaseModel from redis.asyncio import StrictRedis # local @@ -26,21 +27,22 @@ async def init_pubsub() -> aio.Task: async def shutdown_pubsub(task: aio.Task): - pubsub.unsubscribe() + await pubsub.unsubscribe() await pubsub.close() task.cancel() -def handler(message: dict): +async def handler(message: dict): """Handle an incoming RPC operation and publish the result.""" data: dict = json.loads(message["data"]) logger.info(f"RPC op: {data['op']}") - result = handlers[data["op"]]( + result: BaseModel = await handlers[data["op"]]( *data.get("args", []), **data.get("kwargs", dict()), ) - redis_conn.publish(data["uuid"], json.dumps(result)) + response = result.model_dump_json() + await redis_conn.publish(data["uuid"], response) async def rpc_bot(op: str, *args, timeout=3, **kwargs): diff --git a/realm_api/rpc/roll.py b/realm_api/rpc/roll.py deleted file mode 100644 index 9a76ee2..0000000 --- a/realm_api/rpc/roll.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Dice roller RPC module""" - - -def roll_handler(data: dict): - """Roll them bones.""" - - return 0 diff --git a/realm_api/rpc/roll/__init__.py b/realm_api/rpc/roll/__init__.py new file mode 100644 index 0000000..02a2ab0 --- /dev/null +++ b/realm_api/rpc/roll/__init__.py @@ -0,0 +1,130 @@ +"""Dice roller RPC module""" + +# stdlib +from random import randint +from re import compile + +# api +from realm_schema import ( + BatchResults, + ConstantModifier, + DiceRoll, + RollResults, + RollSegment, + SegmentResult, +) + +# local +from realm_api.logging import logger +from .parse import parse_segments + + +explode_regex = compile(r"!(?P\d*)") +"""Regex for exploding die extra""" + +keep_hilow_regex = compile(r"(?Pk[hl])(?P\d*)") +"""Regex for keep highest/lowest extras""" + + +def roll_segment(segment: RollSegment) -> SegmentResult: + """Calculate the results of an individual RollSegment instance.""" + + if isinstance(segment, ConstantModifier): + return SegmentResult( + segment=segment, + total=segment.number * (-1 if segment.negative else 1), + ) + + if isinstance(segment, DiceRoll): + limit = 0 + exploding = False + result = SegmentResult(segment=segment, rolls=[], work="") + assert result.rolls is not None + assert result.work is not None + + if segment.extra and segment.extra[0] == "!": + exploding = True + + # explosion limit + if len(segment.extra) > 1: + explode_limit_match = explode_regex.match(segment.extra) + assert explode_limit_match + limit = int(explode_limit_match.groupdict()["num"]) + + # roll dice + for _ in range(segment.dice): + roll = randint(1, segment.faces) + exploded = 0 + + # exploding die + while ( + exploding + and roll == segment.faces + and (limit == 0 or exploded < limit) + ): + exploded += 1 + result.rolls.append(roll) + result.total += roll + roll = randint(1, segment.faces) + + result.rolls.append(roll) + result.total += roll + + # keep methods + if segment.extra and segment.extra.startswith("k"): + ord_rolls = sorted(result.rolls) + args_match = keep_hilow_regex.match(segment.extra) + assert args_match + args = args_match.groupdict() + fun = args["fun"] + keep = int(args["num"]) if args["num"] else 1 + + assert keep < segment.dice and keep > 0 + drop = range(segment.dice - keep) + + # keep highest + if fun == "kh": + for i in drop: + result.total -= ord_rolls[i] + + # keep lowest + elif fun == "kl": + for i in drop: + result.total -= ord_rolls[-(i + 1)] + + rolls = [ + f"**{roll}**" if roll == segment.faces else f"{roll}" + for roll in result.rolls + ] + result.work = "".join( + [ + f"[{', '.join(rolls)}] = ", + f"**{'-' if segment.negative else ''}{result.total}**", + ] + ) + + if segment.negative: + result.total *= -1 + + return result + + raise NotImplementedError() + + +async def roll_handler(formula: str) -> BatchResults: + """Roll them bones.""" + + results = BatchResults(results=[]) + + try: + batches = parse_segments(formula) + + for segments in batches: + results.results.append( + RollResults(results=[roll_segment(s) for s in segments]) + ) + except Exception as ex: + logger.warning(ex) + return BatchResults(results=[]) + + return results diff --git a/realm_api/rpc/roll/parse.py b/realm_api/rpc/roll/parse.py new file mode 100644 index 0000000..528b40f --- /dev/null +++ b/realm_api/rpc/roll/parse.py @@ -0,0 +1,86 @@ +"""Parse segments from roll command arguments""" + +# stdlib +from re import compile + +# local +from realm_schema import ConstantModifier, DiceRoll, RollSegment + +roll_regex = compile( + r"^(?P" + r"(?P\d*)" + r"d(?P\d+)" + r"(?P!\d*|k[hl]\d*)?" + r")" + r"(x(?P\d+))?" +) +"""Regex pattern for initial roll""" + +addl_roll_regex = compile( + r"(?P" + r"(?P[-+])" + r"(?P\d*)" + r"(d(?P\d+)(?P!\d*|k[hl]\d*)?)?" + r")" + r"(x(?P\d+))?" +) +"""Regex pattern for additional modifier rolls/constants""" + + +def parse_segments(roll: str) -> list[list[RollSegment]]: + """Parse a roll formula string into a list of RollSegment objects.""" + + segments = [] + + # strip all whitespace + roll = roll.replace(" ", "") + + # parse first roll + first = roll_regex.match(roll) + assert first + first = first.groupdict() + + # parse variable number of mods + mods = [r.groupdict() for r in addl_roll_regex.finditer(roll)] + + batch = int(first["batch"] or 1) + + for m in mods: + if m["batch"]: + batch = int(m["batch"]) + + batches = [] + + for _ in range(batch): + segments: list[RollSegment] = [ + DiceRoll( + raw=first["all"], + dice=int(first["num"] or "1"), + faces=int(first["die"]), + extra=first["fun"], + ) + ] + + for mod in mods: + if mod["die"]: + segments.append( + DiceRoll( + raw=mod["all"], + negative=mod["op"] == "-", + dice=int(mod["num"] or "1"), + faces=int(mod["die"]), + extra=mod["fun"], + ) + ) + else: + segments.append( + ConstantModifier( + raw=mod["all"], + negative=mod["op"] == "-", + number=int(mod["num"]), + ) + ) + + batches.append(segments) + + return batches