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()
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user