A few gaming features added, e.g. rolling dice, flipping coin, eightball.
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
var coinSides = []string{"heads", "tails"}
|
||||||
|
|
||||||
|
func chooseCoinSide(reader io.Reader) (string, error) {
|
||||||
|
index, err := rand.Int(reader, big.NewInt(int64(len(coinSides))))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return coinSides[index.Int64()], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCoinRequest(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
arguments, ok := value.(string)
|
||||||
|
if !ok || strings.TrimSpace(arguments) != "" {
|
||||||
|
return hall.UserError("/coin does not take any arguments")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleCoinFlip(c *webClient, g *hall.Hall, value any) error {
|
||||||
|
if !member("message", c.Permissions()) {
|
||||||
|
return c.error(hall.UserError("not authorised"))
|
||||||
|
}
|
||||||
|
if err := validateCoinRequest(value); err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
side, err := chooseCoinSide(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
announcement := fmt.Sprintf("%s flips a coin: %s.", c.Username(), side)
|
||||||
|
if err := broadcastServerChat(g, "Coin", "coin", announcement); err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChooseCoinSide(t *testing.T) {
|
||||||
|
for i, want := range []string{"heads", "tails"} {
|
||||||
|
got, err := chooseCoinSide(bytes.NewReader([]byte{byte(i)}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chooseCoinSide(%d): %v", i, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("chooseCoinSide(%d) = %q, want %q", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoinRejectsArguments(t *testing.T) {
|
||||||
|
for _, value := range []any{nil, "", " "} {
|
||||||
|
if err := validateCoinRequest(value); err != nil {
|
||||||
|
t.Fatalf("validateCoinRequest(%#v): %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, value := range []any{"twice", 2} {
|
||||||
|
if err := validateCoinRequest(value); err == nil {
|
||||||
|
t.Fatalf("validateCoinRequest(%#v) unexpectedly succeeded", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoinFlipIsServerGeneratedAndStoredInHistory(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
flipper := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
observer := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
|
||||||
|
if err := handleClientMessage(flipper, clientMessage{
|
||||||
|
Type: "hallaction", Kind: "coin", Value: "",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(coin): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, messages := range map[string][]clientMessage{
|
||||||
|
"flipper": drainMessages(flipper),
|
||||||
|
"observer": drainMessages(observer),
|
||||||
|
} {
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("%s received %d messages, want 1: %#v", name, len(messages), messages)
|
||||||
|
}
|
||||||
|
message := messages[0]
|
||||||
|
if message.Type != "chat" || message.Kind != "coin" || !message.Privileged {
|
||||||
|
t.Fatalf("%s received non-authoritative coin message: %#v", name, message)
|
||||||
|
}
|
||||||
|
if message.Source != "" || message.Username == nil || *message.Username != "Coin" {
|
||||||
|
t.Fatalf("%s received coin message with unexpected identity: %#v", name, message)
|
||||||
|
}
|
||||||
|
text, ok := message.Value.(string)
|
||||||
|
if !ok || (text != "Participant flips a coin: heads." &&
|
||||||
|
text != "Participant flips a coin: tails.") {
|
||||||
|
t.Fatalf("%s received malformed coin announcement %q", name, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history := flipper.hall.GetChatHistory()
|
||||||
|
if len(history) != 1 || history[0].Kind != "coin" {
|
||||||
|
t.Fatalf("coin history = %#v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoinFlipRequiresMessagePermission(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
flipper := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
flipper.permissions = remove("message", flipper.permissions)
|
||||||
|
|
||||||
|
if err := handleClientMessage(flipper, clientMessage{
|
||||||
|
Type: "hallaction", Kind: "coin", Value: "",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(coin): %v", err)
|
||||||
|
}
|
||||||
|
if got := flipper.hall.GetChatHistory(); len(got) != 0 {
|
||||||
|
t.Fatalf("unauthorised coin flip entered history: %#v", got)
|
||||||
|
}
|
||||||
|
messages := drainMessages(flipper)
|
||||||
|
if len(messages) != 1 || messages[0].Kind != "error" ||
|
||||||
|
!strings.Contains(messages[0].Value.(string), "not authorised") {
|
||||||
|
t.Fatalf("unauthorised coin response = %#v", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxDiceCount = 100
|
||||||
|
maxDiceSides = 1_000_000
|
||||||
|
maxDiceModifier = 1_000_000
|
||||||
|
dicePreviewSize = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
var diceExpressionRegexp = regexp.MustCompile(
|
||||||
|
`^\s*([0-9]+)\s*[dD]\s*([0-9]+)(?:\s*([+-])\s*([0-9]+))?\s*$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
type diceRoll struct {
|
||||||
|
Count int64
|
||||||
|
Sides int64
|
||||||
|
Modifier int64
|
||||||
|
Results []int64
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDiceExpression(expression string) (diceRoll, error) {
|
||||||
|
match := diceExpressionRegexp.FindStringSubmatch(expression)
|
||||||
|
if match == nil {
|
||||||
|
return diceRoll{}, hall.UserError(
|
||||||
|
"invalid dice roll; example: /2d6 + 3",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := strconv.ParseInt(match[1], 10, 64)
|
||||||
|
if err != nil || count < 1 || count > maxDiceCount {
|
||||||
|
return diceRoll{}, hall.UserError(
|
||||||
|
fmt.Sprintf("dice count must be between 1 and %d", maxDiceCount),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sides, err := strconv.ParseInt(match[2], 10, 64)
|
||||||
|
if err != nil || sides < 2 || sides > maxDiceSides {
|
||||||
|
return diceRoll{}, hall.UserError(
|
||||||
|
fmt.Sprintf("die sides must be between 2 and %d", maxDiceSides),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var modifier int64
|
||||||
|
if match[4] != "" {
|
||||||
|
modifier, err = strconv.ParseInt(match[4], 10, 64)
|
||||||
|
if err != nil || modifier > maxDiceModifier {
|
||||||
|
return diceRoll{}, hall.UserError(
|
||||||
|
fmt.Sprintf("dice modifier must be between -%d and %d", maxDiceModifier, maxDiceModifier),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if match[3] == "-" {
|
||||||
|
modifier = -modifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diceRoll{Count: count, Sides: sides, Modifier: modifier}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateDiceRoll(roll diceRoll) (diceRoll, error) {
|
||||||
|
maximum := big.NewInt(roll.Sides)
|
||||||
|
roll.Results = make([]int64, roll.Count)
|
||||||
|
roll.Total = roll.Modifier
|
||||||
|
for i := range roll.Results {
|
||||||
|
result, err := rand.Int(rand.Reader, maximum)
|
||||||
|
if err != nil {
|
||||||
|
return diceRoll{}, err
|
||||||
|
}
|
||||||
|
roll.Results[i] = result.Int64() + 1
|
||||||
|
roll.Total += roll.Results[i]
|
||||||
|
}
|
||||||
|
return roll, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDiceResults(results []int64) string {
|
||||||
|
shown := len(results)
|
||||||
|
if shown > dicePreviewSize {
|
||||||
|
shown = dicePreviewSize
|
||||||
|
}
|
||||||
|
parts := make([]string, shown)
|
||||||
|
for i := range parts {
|
||||||
|
parts[i] = strconv.FormatInt(results[i], 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > dicePreviewSize {
|
||||||
|
return strings.Join(parts, ", ") + fmt.Sprintf(", and %d more", len(results)-shown)
|
||||||
|
}
|
||||||
|
if shown == 1 {
|
||||||
|
return parts[0]
|
||||||
|
}
|
||||||
|
if shown == 2 {
|
||||||
|
return parts[0] + " and " + parts[1]
|
||||||
|
}
|
||||||
|
return strings.Join(parts[:shown-1], ", ") + ", and " + parts[shown-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDiceAnnouncement(username string, roll diceRoll) string {
|
||||||
|
dieWord := "dice"
|
||||||
|
if roll.Count == 1 {
|
||||||
|
dieWord = "die"
|
||||||
|
}
|
||||||
|
modifier := ""
|
||||||
|
if roll.Modifier > 0 {
|
||||||
|
modifier = fmt.Sprintf(", plus %d", roll.Modifier)
|
||||||
|
} else if roll.Modifier < 0 {
|
||||||
|
modifier = fmt.Sprintf(", minus %d", -roll.Modifier)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"%s rolls %d %d-sided %s: %s%s, for a total of %d.",
|
||||||
|
username, roll.Count, roll.Sides, dieWord,
|
||||||
|
formatDiceResults(roll.Results), modifier, roll.Total,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDiceRoll(c *webClient, g *hall.Hall, value any) error {
|
||||||
|
if !member("message", c.Permissions()) {
|
||||||
|
return c.error(hall.UserError("not authorised"))
|
||||||
|
}
|
||||||
|
expression, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return c.error(hall.UserError("invalid dice roll; example: /2d6 + 3"))
|
||||||
|
}
|
||||||
|
roll, err := parseDiceExpression(expression)
|
||||||
|
if err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
roll, err = generateDiceRoll(roll)
|
||||||
|
if err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
announcement := formatDiceAnnouncement(c.Username(), roll)
|
||||||
|
if err := broadcastServerChat(g, "Dice", "dice", announcement); err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseDiceExpression(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
expression string
|
||||||
|
count int64
|
||||||
|
sides int64
|
||||||
|
modifier int64
|
||||||
|
}{
|
||||||
|
{"2d4", 2, 4, 0},
|
||||||
|
{"10d8 + 5", 10, 8, 5},
|
||||||
|
{"3 d 2+5", 3, 2, 5},
|
||||||
|
{"2 D 20 - 1", 2, 20, -1},
|
||||||
|
{" 1d6 ", 1, 6, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.expression, func(t *testing.T) {
|
||||||
|
got, err := parseDiceExpression(test.expression)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseDiceExpression(%q): %v", test.expression, err)
|
||||||
|
}
|
||||||
|
if got.Count != test.count || got.Sides != test.sides || got.Modifier != test.modifier {
|
||||||
|
t.Fatalf("parseDiceExpression(%q) = %#v", test.expression, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDiceExpressionRejectsInvalidAndOversizedRolls(t *testing.T) {
|
||||||
|
tests := []string{
|
||||||
|
"", "d20", "2d", "2d1", "0d6", "101d6", "2d1000001",
|
||||||
|
"2d6 + 1000001", "2 0d6", "2d6 trailing",
|
||||||
|
}
|
||||||
|
for _, expression := range tests {
|
||||||
|
t.Run(expression, func(t *testing.T) {
|
||||||
|
if _, err := parseDiceExpression(expression); err == nil {
|
||||||
|
t.Fatalf("parseDiceExpression(%q) unexpectedly succeeded", expression)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDiceAnnouncement(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
roll diceRoll
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"two dice",
|
||||||
|
diceRoll{Count: 2, Sides: 4, Results: []int64{1, 2}, Total: 3},
|
||||||
|
"Storm rolls 2 4-sided dice: 1 and 2, for a total of 3.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"many dice with modifier",
|
||||||
|
diceRoll{Count: 10, Sides: 10, Modifier: 5, Results: []int64{2, 3, 4, 3, 4, 1, 2, 1, 2, 3}, Total: 30},
|
||||||
|
"Storm rolls 10 10-sided dice: 2, 3, 4, 3, 4, and 5 more, plus 5, for a total of 30.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"one die with negative modifier",
|
||||||
|
diceRoll{Count: 1, Sides: 20, Modifier: -1, Results: []int64{17}, Total: 16},
|
||||||
|
"Storm rolls 1 20-sided die: 17, minus 1, for a total of 16.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := formatDiceAnnouncement("Storm", test.roll); got != test.expected {
|
||||||
|
t.Fatalf("formatDiceAnnouncement() = %q, want %q", got, test.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDiceRollStaysWithinBounds(t *testing.T) {
|
||||||
|
roll, err := generateDiceRoll(diceRoll{Count: 100, Sides: 6, Modifier: -50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateDiceRoll: %v", err)
|
||||||
|
}
|
||||||
|
if len(roll.Results) != 100 {
|
||||||
|
t.Fatalf("generated %d results, want 100", len(roll.Results))
|
||||||
|
}
|
||||||
|
wantTotal := int64(-50)
|
||||||
|
for _, result := range roll.Results {
|
||||||
|
if result < 1 || result > 6 {
|
||||||
|
t.Fatalf("generated result %d outside 1..6", result)
|
||||||
|
}
|
||||||
|
wantTotal += result
|
||||||
|
}
|
||||||
|
if roll.Total != wantTotal {
|
||||||
|
t.Fatalf("total = %d, want %d", roll.Total, wantTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiceRollIsServerGeneratedAndStoredInHistory(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
roller := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
observer := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
|
||||||
|
if err := handleClientMessage(roller, clientMessage{
|
||||||
|
Type: "hallaction",
|
||||||
|
Kind: "roll",
|
||||||
|
Value: "2d4 + 5",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(roll): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, messages := range map[string][]clientMessage{
|
||||||
|
"roller": drainMessages(roller),
|
||||||
|
"observer": drainMessages(observer),
|
||||||
|
} {
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("%s received %d messages, want 1: %#v", name, len(messages), messages)
|
||||||
|
}
|
||||||
|
message := messages[0]
|
||||||
|
if message.Type != "chat" || message.Kind != "dice" || !message.Privileged {
|
||||||
|
t.Fatalf("%s received non-authoritative dice message: %#v", name, message)
|
||||||
|
}
|
||||||
|
if message.Source != "" || message.Username == nil || *message.Username != "Dice" {
|
||||||
|
t.Fatalf("%s received dice message with unexpected identity: %#v", name, message)
|
||||||
|
}
|
||||||
|
text, ok := message.Value.(string)
|
||||||
|
if !ok || !regexp.MustCompile(`^Participant rolls 2 4-sided dice: [1-4] and [1-4], plus 5, for a total of (7|8|9|10|11|12|13)\.$`).MatchString(text) {
|
||||||
|
t.Fatalf("%s received malformed dice announcement %q", name, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history := roller.hall.GetChatHistory()
|
||||||
|
if len(history) != 1 || history[0].Kind != "dice" {
|
||||||
|
t.Fatalf("dice history = %#v", history)
|
||||||
|
}
|
||||||
|
|
||||||
|
newcomer := testWebClient("admin-id")
|
||||||
|
adminName := "Admin"
|
||||||
|
if _, err := hall.AddClient(hallName, newcomer, hall.ClientCredentials{
|
||||||
|
Username: &adminName,
|
||||||
|
Password: "pw",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AddClient(Admin): %v", err)
|
||||||
|
}
|
||||||
|
newcomer.hall = hall.Get(hallName)
|
||||||
|
drainActions(t, newcomer)
|
||||||
|
var historicalDice *clientMessage
|
||||||
|
for _, message := range drainMessages(newcomer) {
|
||||||
|
if message.Type == "chathistory" && message.Kind == "dice" {
|
||||||
|
message := message
|
||||||
|
historicalDice = &message
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if historicalDice == nil || !historicalDice.Privileged {
|
||||||
|
t.Fatalf("new participant did not receive authoritative dice history: %#v", historicalDice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiceRollRequiresMessagePermission(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
roller := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
roller.permissions = remove("message", roller.permissions)
|
||||||
|
|
||||||
|
if err := handleClientMessage(roller, clientMessage{
|
||||||
|
Type: "hallaction", Kind: "roll", Value: "1d6",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(roll): %v", err)
|
||||||
|
}
|
||||||
|
if got := roller.hall.GetChatHistory(); len(got) != 0 {
|
||||||
|
t.Fatalf("unauthorised roll entered history: %#v", got)
|
||||||
|
}
|
||||||
|
messages := drainMessages(roller)
|
||||||
|
if len(messages) != 1 || messages[0].Kind != "error" ||
|
||||||
|
!strings.Contains(messages[0].Value.(string), "not authorised") {
|
||||||
|
t.Fatalf("unauthorised roll response = %#v", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientsCannotSubmitServerGeneratedMessages(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
roller := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
|
||||||
|
for _, messageKind := range []string{"dice", "eightball", "coin"} {
|
||||||
|
for _, messageType := range []string{"chat", "usermessage"} {
|
||||||
|
drainMessages(roller)
|
||||||
|
if err := handleClientMessage(roller, clientMessage{
|
||||||
|
Type: messageType, Kind: messageKind, Value: "whatever result I wanted",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(%s, %s): %v", messageType, messageKind, err)
|
||||||
|
}
|
||||||
|
messages := drainMessages(roller)
|
||||||
|
if len(messages) != 1 || messages[0].Kind != "error" {
|
||||||
|
t.Fatalf("reserved %s/%s response = %#v", messageType, messageKind, messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := roller.hall.GetChatHistory(); len(got) != 0 {
|
||||||
|
t.Fatalf("forged dice message entered history: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatIdentityComesFromConnection(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
sender := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
observer := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
|
||||||
|
if err := handleClientMessage(sender, clientMessage{
|
||||||
|
Type: "chat", Value: "hello",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(chat): %v", err)
|
||||||
|
}
|
||||||
|
messages := drainMessages(observer)
|
||||||
|
if len(messages) != 1 || messages[0].Source != sender.id ||
|
||||||
|
messages[0].Username == nil || *messages[0].Username != "Participant" {
|
||||||
|
t.Fatalf("chat did not use connection identity: %#v", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxEightBallQuestionRunes = 500
|
||||||
|
|
||||||
|
var eightBallResponses = []string{
|
||||||
|
"It is certain.",
|
||||||
|
"It is decidedly so.",
|
||||||
|
"Without a doubt.",
|
||||||
|
"Yes, definitely.",
|
||||||
|
"You may rely on it.",
|
||||||
|
"As I see it, yes.",
|
||||||
|
"Most likely.",
|
||||||
|
"Outlook good.",
|
||||||
|
"Yes.",
|
||||||
|
"Signs point to yes.",
|
||||||
|
"Reply hazy, try again.",
|
||||||
|
"Ask again later.",
|
||||||
|
"Better not tell you now.",
|
||||||
|
"Cannot predict now.",
|
||||||
|
"Concentrate and ask again.",
|
||||||
|
"Don't count on it.",
|
||||||
|
"My reply is no.",
|
||||||
|
"My sources say no.",
|
||||||
|
"Outlook not so good.",
|
||||||
|
"Very doubtful.",
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEightBallQuestion(value any) (string, error) {
|
||||||
|
question, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", hall.UserError("/8ball requires a question")
|
||||||
|
}
|
||||||
|
question = strings.Join(strings.Fields(question), " ")
|
||||||
|
if question == "" {
|
||||||
|
return "", hall.UserError("/8ball requires a question")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(question) > maxEightBallQuestionRunes {
|
||||||
|
return "", hall.UserError(fmt.Sprintf(
|
||||||
|
"Eight Ball questions may not exceed %d characters",
|
||||||
|
maxEightBallQuestionRunes,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return question, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseEightBallResponse(reader io.Reader) (string, error) {
|
||||||
|
index, err := rand.Int(reader, big.NewInt(int64(len(eightBallResponses))))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return eightBallResponses[index.Int64()], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleEightBall(c *webClient, g *hall.Hall, value any) error {
|
||||||
|
if !member("message", c.Permissions()) {
|
||||||
|
return c.error(hall.UserError("not authorised"))
|
||||||
|
}
|
||||||
|
question, err := normalizeEightBallQuestion(value)
|
||||||
|
if err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
response, err := chooseEightBallResponse(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
announcement := fmt.Sprintf(
|
||||||
|
"%s asks: %s\nThe Eight Ball says: %s",
|
||||||
|
c.Username(), question, response,
|
||||||
|
)
|
||||||
|
if err := broadcastServerChat(g, "Eight Ball", "eightball", announcement); err != nil {
|
||||||
|
return c.error(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEightBallHasClassicResponses(t *testing.T) {
|
||||||
|
want := []string{
|
||||||
|
"It is certain.",
|
||||||
|
"It is decidedly so.",
|
||||||
|
"Without a doubt.",
|
||||||
|
"Yes, definitely.",
|
||||||
|
"You may rely on it.",
|
||||||
|
"As I see it, yes.",
|
||||||
|
"Most likely.",
|
||||||
|
"Outlook good.",
|
||||||
|
"Yes.",
|
||||||
|
"Signs point to yes.",
|
||||||
|
"Reply hazy, try again.",
|
||||||
|
"Ask again later.",
|
||||||
|
"Better not tell you now.",
|
||||||
|
"Cannot predict now.",
|
||||||
|
"Concentrate and ask again.",
|
||||||
|
"Don't count on it.",
|
||||||
|
"My reply is no.",
|
||||||
|
"My sources say no.",
|
||||||
|
"Outlook not so good.",
|
||||||
|
"Very doubtful.",
|
||||||
|
}
|
||||||
|
if len(eightBallResponses) != len(want) {
|
||||||
|
t.Fatalf("eightBallResponses has %d entries, want %d", len(eightBallResponses), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if eightBallResponses[i] != want[i] {
|
||||||
|
t.Fatalf("eightBallResponses[%d] = %q, want %q", i, eightBallResponses[i], want[i])
|
||||||
|
}
|
||||||
|
response, err := chooseEightBallResponse(bytes.NewReader([]byte{byte(i)}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("chooseEightBallResponse(%d): %v", i, err)
|
||||||
|
}
|
||||||
|
if response != want[i] {
|
||||||
|
t.Fatalf("chooseEightBallResponse(%d) = %q, want %q", i, response, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeEightBallQuestion(t *testing.T) {
|
||||||
|
question, err := normalizeEightBallQuestion(" Will\nwe win tonight? ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeEightBallQuestion: %v", err)
|
||||||
|
}
|
||||||
|
if question != "Will we win tonight?" {
|
||||||
|
t.Fatalf("question = %q", question)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, value := range []any{" ", strings.Repeat("x", maxEightBallQuestionRunes+1), 42} {
|
||||||
|
if _, err := normalizeEightBallQuestion(value); err == nil {
|
||||||
|
t.Fatalf("normalizeEightBallQuestion(%#v) unexpectedly succeeded", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEightBallIsServerGeneratedAndStoredInHistory(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
asker := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
observer := addTestWebClient(t, hallName, "Operator", "op")
|
||||||
|
|
||||||
|
if err := handleClientMessage(asker, clientMessage{
|
||||||
|
Type: "hallaction", Kind: "eightball", Value: "Will we win tonight?",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(eightball): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, messages := range map[string][]clientMessage{
|
||||||
|
"asker": drainMessages(asker),
|
||||||
|
"observer": drainMessages(observer),
|
||||||
|
} {
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("%s received %d messages, want 1: %#v", name, len(messages), messages)
|
||||||
|
}
|
||||||
|
message := messages[0]
|
||||||
|
if message.Type != "chat" || message.Kind != "eightball" || !message.Privileged {
|
||||||
|
t.Fatalf("%s received non-authoritative Eight Ball message: %#v", name, message)
|
||||||
|
}
|
||||||
|
if message.Source != "" || message.Username == nil || *message.Username != "Eight Ball" {
|
||||||
|
t.Fatalf("%s received Eight Ball message with unexpected identity: %#v", name, message)
|
||||||
|
}
|
||||||
|
text, ok := message.Value.(string)
|
||||||
|
if !ok || !strings.HasPrefix(text, "Participant asks: Will we win tonight?\nThe Eight Ball says: ") {
|
||||||
|
t.Fatalf("%s received malformed Eight Ball announcement %q", name, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
history := asker.hall.GetChatHistory()
|
||||||
|
if len(history) != 1 || history[0].Kind != "eightball" {
|
||||||
|
t.Fatalf("Eight Ball history = %#v", history)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEightBallRequiresMessagePermission(t *testing.T) {
|
||||||
|
hallName := newChalkboardTestHall(t)
|
||||||
|
asker := addTestWebClient(t, hallName, "Participant", "message")
|
||||||
|
asker.permissions = remove("message", asker.permissions)
|
||||||
|
|
||||||
|
if err := handleClientMessage(asker, clientMessage{
|
||||||
|
Type: "hallaction", Kind: "eightball", Value: "Will this work?",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("handleClientMessage(eightball): %v", err)
|
||||||
|
}
|
||||||
|
if got := asker.hall.GetChatHistory(); len(got) != 0 {
|
||||||
|
t.Fatalf("unauthorised Eight Ball question entered history: %#v", got)
|
||||||
|
}
|
||||||
|
messages := drainMessages(asker)
|
||||||
|
if len(messages) != 1 || messages[0].Kind != "error" ||
|
||||||
|
!strings.Contains(messages[0].Value.(string), "not authorised") {
|
||||||
|
t.Fatalf("unauthorised Eight Ball response = %#v", messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package rtpconn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isServerGeneratedChatKind(kind string) bool {
|
||||||
|
return kind == "dice" || kind == "eightball" || kind == "coin"
|
||||||
|
}
|
||||||
|
|
||||||
|
func broadcastServerChat(g *hall.Hall, name, kind, message string) error {
|
||||||
|
idBytes := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(idBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := base64.RawURLEncoding.EncodeToString(idBytes)
|
||||||
|
now := time.Now()
|
||||||
|
g.AddToChatHistory(id, "", &name, now, kind, message)
|
||||||
|
return broadcast(g.GetClients(nil), clientMessage{
|
||||||
|
Type: "chat",
|
||||||
|
Id: id,
|
||||||
|
Username: &name,
|
||||||
|
Privileged: true,
|
||||||
|
Time: now.Format(time.RFC3339),
|
||||||
|
Kind: kind,
|
||||||
|
Value: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
+23
-10
@@ -1248,13 +1248,14 @@ func handleAction(c *webClient, a any) error {
|
|||||||
h := g.GetChatHistory()
|
h := g.GetChatHistory()
|
||||||
for _, m := range h {
|
for _, m := range h {
|
||||||
err := c.write(clientMessage{
|
err := c.write(clientMessage{
|
||||||
Type: "chathistory",
|
Type: "chathistory",
|
||||||
Id: m.Id,
|
Id: m.Id,
|
||||||
Source: m.Source,
|
Source: m.Source,
|
||||||
Username: m.User,
|
Username: m.User,
|
||||||
Time: m.Time.Format(time.RFC3339),
|
Privileged: isServerGeneratedChatKind(m.Kind),
|
||||||
Value: m.Value,
|
Time: m.Time.Format(time.RFC3339),
|
||||||
Kind: m.Kind,
|
Value: m.Value,
|
||||||
|
Kind: m.Kind,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1711,6 +1712,12 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
|||||||
if !member("message", c.permissions) {
|
if !member("message", c.permissions) {
|
||||||
return c.error(hall.UserError("not authorised"))
|
return c.error(hall.UserError("not authorised"))
|
||||||
}
|
}
|
||||||
|
if isServerGeneratedChatKind(m.Kind) {
|
||||||
|
return c.error(hall.UserError("this message kind is server generated"))
|
||||||
|
}
|
||||||
|
|
||||||
|
source := c.Id()
|
||||||
|
username := c.Username()
|
||||||
|
|
||||||
id := m.Id
|
id := m.Id
|
||||||
if m.Type == "chat" && m.Dest == "" && id == "" {
|
if m.Type == "chat" && m.Dest == "" && id == "" {
|
||||||
@@ -1723,7 +1730,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
|||||||
if m.Type == "chat" {
|
if m.Type == "chat" {
|
||||||
if m.Dest == "" {
|
if m.Dest == "" {
|
||||||
g.AddToChatHistory(
|
g.AddToChatHistory(
|
||||||
id, m.Source, m.Username,
|
id, source, &username,
|
||||||
now, m.Kind, m.Value,
|
now, m.Kind, m.Value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1731,9 +1738,9 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
|||||||
mm := clientMessage{
|
mm := clientMessage{
|
||||||
Type: m.Type,
|
Type: m.Type,
|
||||||
Id: id,
|
Id: id,
|
||||||
Source: m.Source,
|
Source: source,
|
||||||
Dest: m.Dest,
|
Dest: m.Dest,
|
||||||
Username: m.Username,
|
Username: &username,
|
||||||
Privileged: member("op", c.permissions),
|
Privileged: member("op", c.permissions),
|
||||||
Time: now.Format(time.RFC3339),
|
Time: now.Format(time.RFC3339),
|
||||||
Kind: m.Kind,
|
Kind: m.Kind,
|
||||||
@@ -1817,6 +1824,12 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
|||||||
}
|
}
|
||||||
state := g.UpdateChalkboard(text)
|
state := g.UpdateChalkboard(text)
|
||||||
broadcastChalkboard(g, state)
|
broadcastChalkboard(g, state)
|
||||||
|
case "roll":
|
||||||
|
return handleDiceRoll(c, g, m.Value)
|
||||||
|
case "eightball":
|
||||||
|
return handleEightBall(c, g, m.Value)
|
||||||
|
case "coin":
|
||||||
|
return handleCoinFlip(c, g, m.Value)
|
||||||
case "lock", "unlock":
|
case "lock", "unlock":
|
||||||
if !member("op", c.permissions) {
|
if !member("op", c.permissions) {
|
||||||
return c.error(hall.UserError("not authorised"))
|
return c.error(hall.UserError("not authorised"))
|
||||||
|
|||||||
+18
-1
@@ -453,7 +453,24 @@ Finally, a hall action requests that the server act on the current hall.
|
|||||||
|
|
||||||
Currently defined kinds include `clearchat` (not to be confused with the
|
Currently defined kinds include `clearchat` (not to be confused with the
|
||||||
`clearchat` user message), `chalkboard`, `lock`, `unlock`, `record`,
|
`clearchat` user message), `chalkboard`, `lock`, `unlock`, `record`,
|
||||||
`unrecord`, `subhalls` and `setdata`. A `chalkboard` hall action updates
|
`unrecord`, `roll`, `eightball`, `coin`, `subhalls` and `setdata`. A `roll`
|
||||||
|
hall action requests a server-generated dice roll and requires the `message`
|
||||||
|
permission. Its value is a dice expression such as `2d4`, `10d8 + 5`, or
|
||||||
|
`2 D 20 - 1`. The result is broadcast and stored in chat history as a
|
||||||
|
privileged `chat` message of kind `dice`.
|
||||||
|
|
||||||
|
An `eightball` hall action asks the Eight Ball a question and also requires
|
||||||
|
the `message` permission. Its value is the question as a string. The
|
||||||
|
server-selected response is broadcast and stored in chat history as a
|
||||||
|
privileged `chat` message of kind `eightball`. Clients must not submit
|
||||||
|
`chat` or `usermessage` messages of kind `dice` or `eightball` directly.
|
||||||
|
|
||||||
|
A `coin` hall action requests an unbiased, server-generated coin flip. It
|
||||||
|
requires the `message` permission and has no value or an empty string value.
|
||||||
|
The result is broadcast and stored in chat history as a privileged `chat`
|
||||||
|
message of kind `coin`. Clients must not submit that message kind directly.
|
||||||
|
|
||||||
|
A `chalkboard` hall action updates
|
||||||
the session-only virtual chalkboard and requires `op`, `admin`, or
|
the session-only virtual chalkboard and requires `op`, `admin`, or
|
||||||
temporary `chalkboard` permission:
|
temporary `chalkboard` permission:
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,21 @@ than navigating the user interface. Commands start with a slash character
|
|||||||
message to a given user. Type `/help` to display the list of available
|
message to a given user. Type `/help` to display the list of available
|
||||||
commands.
|
commands.
|
||||||
|
|
||||||
|
Dice may be rolled directly from the chat input with standard dice notation,
|
||||||
|
for example `/2d4`, `/10d8 + 5`, or `/2 D 20 - 1`. Spaces around the `d`
|
||||||
|
and modifier are optional, and the `d` is case-insensitive. Skald generates
|
||||||
|
rolls on the server and posts them to the hall as authoritative Dice messages.
|
||||||
|
Rolling requires permission to send chat messages.
|
||||||
|
|
||||||
|
The `/8ball question` command asks the Eight Ball a question and posts one of
|
||||||
|
its classic responses as an authoritative, server-generated message. The
|
||||||
|
longer spelling `/eightball question` is also accepted. Eight Ball questions,
|
||||||
|
like dice rolls, require permission to send chat messages.
|
||||||
|
|
||||||
|
The `/coin` command flips a coin and posts the server-generated result as
|
||||||
|
heads or tails. It takes no arguments and requires permission to send chat
|
||||||
|
messages.
|
||||||
|
|
||||||
Below the chat input is a session-only virtual chalkboard. It is a
|
Below the chat input is a session-only virtual chalkboard. It is a
|
||||||
multiline text area suitable for commands, code, and notes that should be
|
multiline text area suitable for commands, code, and notes that should be
|
||||||
visible to everyone in the hall. Operators and administrators may edit
|
visible to everyone in the hall. Operators and administrators may edit
|
||||||
|
|||||||
@@ -3106,6 +3106,12 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m
|
|||||||
footer.classList.add('message-footer');
|
footer.classList.add('message-footer');
|
||||||
if(!peerId)
|
if(!peerId)
|
||||||
container.classList.add('message-system');
|
container.classList.add('message-system');
|
||||||
|
if(kind === 'dice' && privileged)
|
||||||
|
container.classList.add('message-dice');
|
||||||
|
if(kind === 'eightball' && privileged)
|
||||||
|
container.classList.add('message-eightball');
|
||||||
|
if(kind === 'coin' && privileged)
|
||||||
|
container.classList.add('message-coin');
|
||||||
if(serverConnection && peerId === serverConnection.id)
|
if(serverConnection && peerId === serverConnection.id)
|
||||||
container.classList.add('message-sender');
|
container.classList.add('message-sender');
|
||||||
if(dest)
|
if(dest)
|
||||||
@@ -3314,6 +3320,9 @@ function clearChat(id, userId) {
|
|||||||
*/
|
*/
|
||||||
let commands = {};
|
let commands = {};
|
||||||
|
|
||||||
|
const diceCommandRegexp =
|
||||||
|
/^\/\s*[0-9]+\s*[dD]\s*[0-9]+(?:\s*[+-]\s*[0-9]+)?\s*$/;
|
||||||
|
|
||||||
function operatorPredicate() {
|
function operatorPredicate() {
|
||||||
if(serverConnection && serverConnection.permissions &&
|
if(serverConnection && serverConnection.permissions &&
|
||||||
serverConnection.permissions.indexOf('op') >= 0)
|
serverConnection.permissions.indexOf('op') >= 0)
|
||||||
@@ -3348,6 +3357,7 @@ commands.help = {
|
|||||||
continue;
|
continue;
|
||||||
cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`);
|
cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`);
|
||||||
}
|
}
|
||||||
|
cs.push('/NdS [ +|- modifier]: roll dice; spaces and uppercase D are allowed');
|
||||||
let shortcuts = '\n\nKeyboard shortcuts:\n' +
|
let shortcuts = '\n\nKeyboard shortcuts:\n' +
|
||||||
'Control+Alt+H or Alt+Shift+H: Raise or lower hand\n' +
|
'Control+Alt+H or Alt+Shift+H: Raise or lower hand\n' +
|
||||||
'Control+Alt+C or Alt+Shift+C: Expand or collapse chat\n' +
|
'Control+Alt+C or Alt+Shift+C: Expand or collapse chat\n' +
|
||||||
@@ -3356,6 +3366,27 @@ commands.help = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function eightBallCommand(c, r) {
|
||||||
|
serverConnection.hallAction('eightball', r);
|
||||||
|
}
|
||||||
|
|
||||||
|
commands['8ball'] = {
|
||||||
|
description: 'ask the Eight Ball a question',
|
||||||
|
parameters: 'question',
|
||||||
|
f: eightBallCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
commands.eightball = {
|
||||||
|
f: eightBallCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
commands.coin = {
|
||||||
|
description: 'flip a coin',
|
||||||
|
f: (c, r) => {
|
||||||
|
serverConnection.hallAction('coin', r);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
commands.me = {
|
commands.me = {
|
||||||
f: (c, r) => {
|
f: (c, r) => {
|
||||||
// handled as a special case
|
// handled as a special case
|
||||||
@@ -3968,6 +3999,14 @@ function handleInput() {
|
|||||||
if(data.length > 1 && data[1] === '/') {
|
if(data.length > 1 && data[1] === '/') {
|
||||||
message = data.slice(1);
|
message = data.slice(1);
|
||||||
me = false;
|
me = false;
|
||||||
|
} else if(diceCommandRegexp.test(data)) {
|
||||||
|
try {
|
||||||
|
serverConnection.hallAction('roll', data.slice(1));
|
||||||
|
} catch(e) {
|
||||||
|
console.error(e);
|
||||||
|
displayError(e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
let cmd, rest;
|
let cmd, rest;
|
||||||
let space = data.indexOf(' ');
|
let space = data.indexOf(' ');
|
||||||
|
|||||||
@@ -108,6 +108,35 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) {
|
|||||||
requireContains(t, js, "if(isGlobalShortcut(e, 't'))", "microphone shortcut")
|
requireContains(t, js, "if(isGlobalShortcut(e, 't'))", "microphone shortcut")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiceCommandStaysDiscoverableAndUsesServerRolls(t *testing.T) {
|
||||||
|
js := readStaticFile(t, "skald.js")
|
||||||
|
|
||||||
|
requireContains(t, js, "/NdS [ +|- modifier]: roll dice", "dice help")
|
||||||
|
requireContains(t, js, "const diceCommandRegexp", "dice notation parser")
|
||||||
|
requireFunctionContains(t, js, "handleInput", "diceCommandRegexp.test(data)")
|
||||||
|
requireFunctionContains(t, js, "handleInput", "serverConnection.hallAction('roll', data.slice(1))")
|
||||||
|
requireFunctionContains(t, js, "addToChatbox", "kind === 'dice' && privileged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEightBallCommandsUseServerResponses(t *testing.T) {
|
||||||
|
js := readStaticFile(t, "skald.js")
|
||||||
|
|
||||||
|
requireContains(t, js, "commands['8ball']", "Eight Ball primary command")
|
||||||
|
requireContains(t, js, "description: 'ask the Eight Ball a question'", "Eight Ball help")
|
||||||
|
requireContains(t, js, "commands.eightball", "Eight Ball spelling alias")
|
||||||
|
requireFunctionContains(t, js, "eightBallCommand", "serverConnection.hallAction('eightball', r)")
|
||||||
|
requireFunctionContains(t, js, "addToChatbox", "kind === 'eightball' && privileged")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoinCommandUsesServerFlip(t *testing.T) {
|
||||||
|
js := readStaticFile(t, "skald.js")
|
||||||
|
|
||||||
|
requireContains(t, js, "commands.coin", "coin command")
|
||||||
|
requireContains(t, js, "description: 'flip a coin'", "coin help")
|
||||||
|
requireContains(t, js, "serverConnection.hallAction('coin', r)", "coin request")
|
||||||
|
requireFunctionContains(t, js, "addToChatbox", "kind === 'coin' && privileged")
|
||||||
|
}
|
||||||
|
|
||||||
func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) {
|
func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) {
|
||||||
js := readStaticFile(t, "skald.js")
|
js := readStaticFile(t, "skald.js")
|
||||||
protocol := readStaticFile(t, "protocol.js")
|
protocol := readStaticFile(t, "protocol.js")
|
||||||
|
|||||||
Reference in New Issue
Block a user