Skip to content

Instantly share code, notes, and snippets.

@apples
Last active September 26, 2023 05:19
Show Gist options
  • Save apples/cae938769cd31ac9a9979c1c0bdac5eb to your computer and use it in GitHub Desktop.
Save apples/cae938769cd31ac9a9979c1c0bdac5eb to your computer and use it in GitHub Desktop.
GDScript Dice Notation Parser and Calculator
class_name DiceRoll
extends RefCounted
## Usage
## [codeblock]
## var dice := DiceRoll.new("1d4 + 2 * X")
## if not dice.is_valid():
## print("Invalid dice.")
## print("Range: %s ~ %s" % [dice.min(), dice.max()])
## print("Sample roll: %s" % [dice.roll({ X = 2 })])
## [/codeblock]
# notation := term ( ("+" / "-") term )*
# term := factor ( ("*" / "/") factor )*
# factor := roll / value
# roll := value "d" value
# value := int / var / "(" notation ")"
# int := [0-9]+
# var := [A-Z]
var notation: String
var bytecode: PackedByteArray
enum {
BC_INT,
BC_VAR,
BC_ROLL,
BC_ADD,
BC_SUB,
BC_MUL,
BC_DIV,
}
func _init(p_notation: String):
notation = p_notation
bytecode = Parser.new(notation).parse()
func is_valid() -> bool:
return bytecode.size() > 0
func min(vars: Dictionary = {}) -> int:
if not is_valid():
return 0
return _execute(vars, FakeRng.new(FakeRng.MODE_MIN))
func max(vars: Dictionary = {}) -> int:
if not is_valid():
return 0
return _execute(vars, FakeRng.new(FakeRng.MODE_MAX))
func roll(vars: Dictionary = {}, rng: RandomNumberGenerator = null) -> int:
if not is_valid():
return 0
if rng == null:
rng = RandomNumberGenerator.new()
rng.randomize()
return _execute(vars, rng)
func _execute(vars: Dictionary, rng):
var stack: Array[int] = []
var i: int = 0
var ei: int = bytecode.size()
while i < ei:
match bytecode[i]:
BC_INT:
i += 1
assert(i < ei)
stack.push_back(bytecode[i])
BC_VAR:
i += 1
assert(i < ei)
var k := String.chr(bytecode[i])
if k in vars:
stack.push_back(vars[k])
else:
stack.push_back(0)
BC_ROLL:
assert(stack.size() >= 2)
var s: int = stack.pop_back()
var c: int = stack.pop_back()
var x := 0
for j in range(c):
x += rng.randi_range(1, s)
stack.push_back(x)
BC_ADD:
assert(stack.size() >= 2)
var r: int = stack.pop_back()
var l: int = stack.pop_back()
stack.push_back(l + r)
BC_SUB:
assert(stack.size() >= 2)
var r: int = stack.pop_back()
var l: int = stack.pop_back()
stack.push_back(l - r)
BC_MUL:
assert(stack.size() >= 2)
var r: int = stack.pop_back()
var l: int = stack.pop_back()
stack.push_back(l * r)
BC_DIV:
assert(stack.size() >= 2)
var r: int = stack.pop_back()
var l: int = stack.pop_back()
stack.push_back(l / r)
i += 1
assert(stack.size() == 1, "Bytecode left stack in invalid state")
return stack[-1]
class FakeRng:
var mode: int
enum {
MODE_MIN,
MODE_MAX,
}
func _init(p_mode: int):
mode = p_mode
func randi_range(a: int, b: int) -> int:
match mode:
MODE_MIN:
return a
MODE_MAX:
return b
_:
push_error("Invalid mode")
return a
class Parser:
var notation: String
var bytecode: PackedByteArray
var _pos: int = -1
var _end: int
var _ascii: PackedByteArray
var _errors: Array[String]
func _init(p_notation: String):
notation = p_notation
_end = notation.length()
_ascii = notation.to_ascii_buffer()
func _advance():
_pos += 1
while _pos < _end and notation[_pos] == " ":
_pos += 1
func _accept(char: String) -> bool:
if _pos < _end and notation[_pos] == char:
_advance()
return true
return false
func _error_unexpected_token(expected: String) -> void:
var tk := notation[_pos] if _pos < _end else "EOF"
_errors.append("Unexpected token (%s): found '%s', expected '%s'." % [_pos, tk, expected])
func parse() -> PackedByteArray:
_advance()
if not _parse_notation():
_errors.append("Failed to parse root notation.")
if _pos < _end:
_error_unexpected_token("EOF")
if _errors.size() > 0:
push_error("Invalid dice notation '%s':\n%s" % [notation, "\n".join(_errors)])
return PackedByteArray()
return bytecode
func _parse_notation() -> bool:
if not _parse_term():
return false
while true:
if _accept("+"):
if not _parse_term():
return false
bytecode.append(BC_ADD)
elif _accept("-"):
if not _parse_term():
return false
bytecode.append(BC_SUB)
else:
break
return true
func _parse_term() -> bool:
if not _parse_factor():
return false
while true:
if _accept("*"):
if not _parse_factor():
return false
bytecode.append(BC_MUL)
elif _accept("/"):
if not _parse_factor():
return false
bytecode.append(BC_DIV)
else:
break
return true
func _parse_factor() -> bool:
if not _parse_value():
return false
if _accept("d"):
if not _parse_value():
return false
bytecode.append(BC_ROLL)
return true
func _parse_value() -> bool:
if _accept("("):
if not _parse_notation():
_error_unexpected_token("notation")
return false
if not _accept(")"):
_error_unexpected_token(")")
return false
return true
var ci := _ascii[_pos]
if ci >= 48 and ci <= 57: # '0' - '9'
var iv := ci - 48
while _pos + 1 < _end:
ci = _ascii[_pos + 1]
if ci < 48 or ci > 57:
break
iv = iv * 10 + (ci - 48)
_pos += 1
bytecode.append_array([BC_INT, iv])
_advance()
return true
if ci >= 65 and ci <= 90: # 'A' - 'Z'
bytecode.append_array([BC_VAR, ci])
_advance()
return true
_error_unexpected_token("atomic")
return false
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment