From 77c687b6fe8e0a7a8e6bd9b3ef7b2fa0c1153fb7 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 3 Sep 2026 13:25:32 -0400 Subject: [PATCH] A few gaming features added, e.g. rolling dice, flipping coin, eightball. --- rtpconn/coin.go | 50 ++++++ rtpconn/coin_test.go | 90 ++++++++++ rtpconn/dice.go | 147 ++++++++++++++++ rtpconn/dice_test.go | 223 +++++++++++++++++++++++++ rtpconn/eightball.go | 85 ++++++++++ rtpconn/eightball_test.go | 120 +++++++++++++ rtpconn/serverchat.go | 32 ++++ rtpconn/webclient.go | 33 ++-- skald-protocol.md | 19 ++- skald.md | 15 ++ static/skald.js | 39 +++++ webserver/static_accessibility_test.go | 29 ++++ 12 files changed, 871 insertions(+), 11 deletions(-) create mode 100644 rtpconn/coin.go create mode 100644 rtpconn/coin_test.go create mode 100644 rtpconn/dice.go create mode 100644 rtpconn/dice_test.go create mode 100644 rtpconn/eightball.go create mode 100644 rtpconn/eightball_test.go create mode 100644 rtpconn/serverchat.go diff --git a/rtpconn/coin.go b/rtpconn/coin.go new file mode 100644 index 0000000..1d9762a --- /dev/null +++ b/rtpconn/coin.go @@ -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 +} diff --git a/rtpconn/coin_test.go b/rtpconn/coin_test.go new file mode 100644 index 0000000..48ec081 --- /dev/null +++ b/rtpconn/coin_test.go @@ -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) + } +} diff --git a/rtpconn/dice.go b/rtpconn/dice.go new file mode 100644 index 0000000..94b73dc --- /dev/null +++ b/rtpconn/dice.go @@ -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 +} diff --git a/rtpconn/dice_test.go b/rtpconn/dice_test.go new file mode 100644 index 0000000..3b78f56 --- /dev/null +++ b/rtpconn/dice_test.go @@ -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) + } +} diff --git a/rtpconn/eightball.go b/rtpconn/eightball.go new file mode 100644 index 0000000..3b3d5db --- /dev/null +++ b/rtpconn/eightball.go @@ -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 +} diff --git a/rtpconn/eightball_test.go b/rtpconn/eightball_test.go new file mode 100644 index 0000000..827d474 --- /dev/null +++ b/rtpconn/eightball_test.go @@ -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) + } +} diff --git a/rtpconn/serverchat.go b/rtpconn/serverchat.go new file mode 100644 index 0000000..0114649 --- /dev/null +++ b/rtpconn/serverchat.go @@ -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, + }) +} diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index 2117c9b..7c4f395 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -1248,13 +1248,14 @@ func handleAction(c *webClient, a any) error { h := g.GetChatHistory() for _, m := range h { err := c.write(clientMessage{ - Type: "chathistory", - Id: m.Id, - Source: m.Source, - Username: m.User, - Time: m.Time.Format(time.RFC3339), - Value: m.Value, - Kind: m.Kind, + Type: "chathistory", + Id: m.Id, + Source: m.Source, + Username: m.User, + Privileged: isServerGeneratedChatKind(m.Kind), + Time: m.Time.Format(time.RFC3339), + Value: m.Value, + Kind: m.Kind, }) if err != nil { return err @@ -1711,6 +1712,12 @@ func handleClientMessage(c *webClient, m clientMessage) error { if !member("message", c.permissions) { 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 if m.Type == "chat" && m.Dest == "" && id == "" { @@ -1723,7 +1730,7 @@ func handleClientMessage(c *webClient, m clientMessage) error { if m.Type == "chat" { if m.Dest == "" { g.AddToChatHistory( - id, m.Source, m.Username, + id, source, &username, now, m.Kind, m.Value, ) } @@ -1731,9 +1738,9 @@ func handleClientMessage(c *webClient, m clientMessage) error { mm := clientMessage{ Type: m.Type, Id: id, - Source: m.Source, + Source: source, Dest: m.Dest, - Username: m.Username, + Username: &username, Privileged: member("op", c.permissions), Time: now.Format(time.RFC3339), Kind: m.Kind, @@ -1817,6 +1824,12 @@ func handleClientMessage(c *webClient, m clientMessage) error { } state := g.UpdateChalkboard(text) 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": if !member("op", c.permissions) { return c.error(hall.UserError("not authorised")) diff --git a/skald-protocol.md b/skald-protocol.md index b6354af..f92bc10 100644 --- a/skald-protocol.md +++ b/skald-protocol.md @@ -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 `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 temporary `chalkboard` permission: diff --git a/skald.md b/skald.md index ca00db9..751fc9f 100644 --- a/skald.md +++ b/skald.md @@ -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 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 multiline text area suitable for commands, code, and notes that should be visible to everyone in the hall. Operators and administrators may edit diff --git a/static/skald.js b/static/skald.js index 9a22bd2..2afcaa8 100644 --- a/static/skald.js +++ b/static/skald.js @@ -3106,6 +3106,12 @@ function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, m footer.classList.add('message-footer'); if(!peerId) 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) container.classList.add('message-sender'); if(dest) @@ -3314,6 +3320,9 @@ function clearChat(id, userId) { */ let commands = {}; +const diceCommandRegexp = + /^\/\s*[0-9]+\s*[dD]\s*[0-9]+(?:\s*[+-]\s*[0-9]+)?\s*$/; + function operatorPredicate() { if(serverConnection && serverConnection.permissions && serverConnection.permissions.indexOf('op') >= 0) @@ -3348,6 +3357,7 @@ commands.help = { continue; 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' + 'Control+Alt+H or Alt+Shift+H: Raise or lower hand\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 = { f: (c, r) => { // handled as a special case @@ -3968,6 +3999,14 @@ function handleInput() { if(data.length > 1 && data[1] === '/') { message = data.slice(1); me = false; + } else if(diceCommandRegexp.test(data)) { + try { + serverConnection.hallAction('roll', data.slice(1)); + } catch(e) { + console.error(e); + displayError(e); + } + return; } else { let cmd, rest; let space = data.indexOf(' '); diff --git a/webserver/static_accessibility_test.go b/webserver/static_accessibility_test.go index 8d9750e..cb3d2f3 100644 --- a/webserver/static_accessibility_test.go +++ b/webserver/static_accessibility_test.go @@ -108,6 +108,35 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) { 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) { js := readStaticFile(t, "skald.js") protocol := readStaticFile(t, "protocol.js")