A few gaming features added, e.g. rolling dice, flipping coin, eightball.

This commit is contained in:
Storm Dragon
2026-09-03 13:25:32 -04:00
parent d0f78b1295
commit 77c687b6fe
12 changed files with 871 additions and 11 deletions
+50
View File
@@ -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
}
+90
View File
@@ -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
View File
@@ -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
}
+223
View File
@@ -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)
}
}
+85
View File
@@ -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
}
+120
View File
@@ -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)
}
}
+32
View File
@@ -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
View File
@@ -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"))
+18 -1
View File
@@ -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:
+15
View File
@@ -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
+39
View File
@@ -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(' ');
+29
View File
@@ -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")