Institute Inform-style rulebooks

This commit is contained in:
2025-10-05 14:31:44 -07:00
parent 5b0b6a48a9
commit 1b099c400a
7 changed files with 370 additions and 293 deletions

View File

@@ -1,11 +1,13 @@
package irc package irc
import ( import (
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/logic"
"git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport"
) )
type Globals struct { type Globals struct {
Server *transport.Server Server *transport.Server
Rulebook *logic.Rulebook
Users *UsersSystem Users *UsersSystem
Notifications *NotificationsSystem Notifications *NotificationsSystem
} }
@@ -15,9 +17,12 @@ var globals *Globals = nil
func InitializeGlobals(server *transport.Server) { func InitializeGlobals(server *transport.Server) {
globals = &Globals{ globals = &Globals{
Server: server, Server: server,
Rulebook: logic.NewRulebook(),
Users: NewUsersSystem(), Users: NewUsersSystem(),
Notifications: NewNotificationsSystem(), Notifications: NewNotificationsSystem(),
} }
globals.Users.InitializeRules()
globals.Notifications.InitializeRules()
} }
func GetGlobals() *Globals { func GetGlobals() *Globals {
@@ -27,29 +32,10 @@ func GetGlobals() *Globals {
return globals return globals
} }
func (g *Globals) getHandlers() []Handler { func AddNewRule[T any](check func(T) error, carryOut func(T)) {
return []Handler{ GetGlobals().Rulebook.Add(logic.NewRule(check, carryOut))
g.Users,
g.Notifications,
}
} }
func Dispatch[T any](askPermission func(T) error, act func(T)) error { func Perform[T any](action T) error {
globals := GetGlobals() return GetGlobals().Rulebook.Perform(action)
handlers := globals.getHandlers()
for _, handler := range handlers {
if h, ok := handler.(T); ok {
err := askPermission(h)
if err != nil {
return err
}
}
}
for _, handler := range handlers {
if h, ok := handler.(T); ok {
act(h)
}
}
return nil
} }

View File

@@ -4,16 +4,21 @@ type Handler interface {
AssertTypes() AssertTypes()
} }
type NickChangeHandler interface { type NickChangeAction struct {
AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error User *User
HandleNickChange(user *User, oldNick *Nick, newNick *Nick) OldNick *Nick
NewNick *Nick
} }
type PartJoinHandler interface { type PartAction struct {
AskPermissionForPart(user *User, channelName ChannelName, reason *string) error User *User
HandlePart(user *User, channelName ChannelName, reason *string) ChannelName ChannelName
AskPermissionForJoin(user *User, channelName ChannelName) error Reason *string
HandleJoin(user *User, channelName ChannelName) }
type JoinAction struct {
User *User
ChannelName ChannelName
} }
type ChatMode string type ChatMode string
@@ -23,16 +28,16 @@ const (
ChatModeNotice ChatMode = "NOTICE" ChatModeNotice ChatMode = "NOTICE"
) )
type ChatHandler interface { type SendMessageToUserAction struct {
AskPermissionForUserMessage(user *User, mode ChatMode, destination *User, message string) error User *User
HandleUserMessage(user *User, mode ChatMode, destination *User, message string) Mode ChatMode
Destination *User
AskPermissionForChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) error Message string
HandleChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string)
} }
/* type SendMessageToChannelAction struct {
type BroadcastHandler interface { User *User
HandleBroadcastMessage(channel string) error Mode ChatMode
Destination canonicalChannelName
Message string
} }
*/

View File

@@ -179,23 +179,19 @@ func parseChannelList(arg string) []ChannelName {
} }
func handleChannelMessage(sender *User, mode ChatMode, destination canonicalChannelName, message string) error { func handleChannelMessage(sender *User, mode ChatMode, destination canonicalChannelName, message string) error {
return Dispatch( return Perform(SendMessageToChannelAction{
func(ch ChatHandler) error { User: sender,
return ch.AskPermissionForChannelMessage(sender, mode, destination, message) Mode: mode,
}, Destination: destination,
func(ch ChatHandler) { Message: message,
ch.HandleChannelMessage(sender, mode, destination, message) })
},
)
} }
func handleUserMessage(sender *User, mode ChatMode, destination *User, message string) error { func handleUserMessage(sender *User, mode ChatMode, destination *User, message string) error {
return Dispatch( return Perform(SendMessageToUserAction{
func(ch ChatHandler) error { User: sender,
return ch.AskPermissionForUserMessage(sender, mode, destination, message) Mode: mode,
}, Destination: destination,
func(ch ChatHandler) { Message: message,
ch.HandleUserMessage(sender, mode, destination, message) })
},
)
} }

View File

@@ -13,82 +13,52 @@ func NewNotificationsSystem() *NotificationsSystem {
return &NotificationsSystem{} return &NotificationsSystem{}
} }
func (notifications *NotificationsSystem) AssertTypes() { func (notifications *NotificationsSystem) InitializeRules() {
var _ NickChangeHandler = notifications // nick changes
var _ PartJoinHandler = notifications AddNewRule(
var _ ChatHandler = notifications func(nc NickChangeAction) error { return nil },
} func(nc NickChangeAction) {
if nc.NewNick == nil {
func (notifications *NotificationsSystem) AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error {
return nil
}
func (notifications *NotificationsSystem) HandleNickChange(user *User, oldNick *Nick, newNick *Nick) {
if newNick == nil {
return // not expressible in the IRC protocol; probably shouldn't be allowed? return // not expressible in the IRC protocol; probably shouldn't be allowed?
} }
src := user.GetSourceString() src := nc.User.GetSourceString()
content := transport.Content{ content := transport.Content{
Source: &src, Source: &src,
Command: "NICK", Command: "NICK",
Arguments: []string{newNick.Value}, Arguments: []string{nc.NewNick.Value},
} }
group := NewBroadcastGroup() group := NewBroadcastGroup()
group.AddChannels(user.GetChannels()...) group.AddChannels(nc.User.GetChannels()...)
group.AddUsers(user) group.AddUsers(nc.User)
Broadcast(group, content) Broadcast(group, content)
} },
)
// TODO: Always present the channel name in its _established_ notation // joins
func (notifications *NotificationsSystem) AskPermissionForPart(user *User, channelName ChannelName, reason *string) error { AddNewRule(
return nil func(j JoinAction) error { return nil },
} func(j JoinAction) {
src := j.User.GetSourceString()
func (notifications *NotificationsSystem) HandlePart(user *User, channelName ChannelName, reason *string) {
src := user.GetSourceString()
var args []string
args = append(args, string(channelName.Value))
if reason != nil {
args = append(args, *reason)
}
content := transport.Content{
Source: &src,
Command: "PART",
Arguments: args,
}
group := NewBroadcastGroup()
group.AddChannels(channelName.canonical)
group.AddUsers(user)
Broadcast(group, content)
}
func (notifications *NotificationsSystem) AskPermissionForJoin(user *User, channelName ChannelName) error {
return nil
}
func (notifications *NotificationsSystem) HandleJoin(user *User, channelName ChannelName) {
src := user.GetSourceString()
content := transport.Content{ content := transport.Content{
Source: &src, Source: &src,
Command: "JOIN", Command: "JOIN",
Arguments: []string{string(channelName.Value)}, Arguments: []string{string(j.ChannelName.Value)},
} }
group := NewBroadcastGroup() group := NewBroadcastGroup()
group.AddChannels(channelName.canonical) group.AddChannels(j.ChannelName.canonical)
group.AddUsers(user) group.AddUsers(j.User)
Broadcast(group, content) Broadcast(group, content)
// tell the user who is here // tell the user who is here
group = NewBroadcastGroup() group = NewBroadcastGroup()
group.AddUsers(user) group.AddUsers(j.User)
src2 := "server" src2 := "server"
var nameList strings.Builder var nameList strings.Builder
var i = 0 var i = 0
for user := range GetGlobals().Users.ByChannel(channelName) { for user := range GetGlobals().Users.ByChannel(j.ChannelName) {
if i != 0 { if i != 0 {
nameList.WriteString(" ") nameList.WriteString(" ")
} }
@@ -100,56 +70,81 @@ func (notifications *NotificationsSystem) HandleJoin(user *User, channelName Cha
Source: &src2, Source: &src2,
Command: "332", Command: "332",
Arguments: []string{ Arguments: []string{
user.nick.Value, channelName.Value, "TODO: topic!", j.User.nick.Value, j.ChannelName.Value, "TODO: topic!",
}, },
}) })
Broadcast(group, transport.Content{ Broadcast(group, transport.Content{
Source: &src2, Source: &src2,
Command: "353", Command: "353",
Arguments: []string{ Arguments: []string{
user.nick.Value, "=", channelName.Value, nameList.String(), j.User.nick.Value, "=", j.ChannelName.Value, nameList.String(),
}, },
}) })
Broadcast(group, transport.Content{ Broadcast(group, transport.Content{
Source: &src2, Source: &src2,
Command: "366", Command: "366",
Arguments: []string{ Arguments: []string{
user.nick.Value, channelName.Value, "End of /NAMES list", j.User.nick.Value, j.ChannelName.Value, "End of /NAMES list",
}, },
}) })
} },
)
func (notifications *NotificationsSystem) AskPermissionForUserMessage(user *User, mode ChatMode, destination *User, message string) error { // parts
return nil AddNewRule(
} func(p PartAction) error { return nil },
func(p PartAction) {
src := p.User.GetSourceString()
func (notifications *NotificationsSystem) HandleUserMessage(user *User, mode ChatMode, destination *User, message string) { var args []string
src := user.GetSourceString() args = append(args, string(p.ChannelName.Value))
if p.Reason != nil {
args = append(args, *p.Reason)
}
content := transport.Content{
Source: &src,
Command: "PART",
Arguments: args,
}
group := NewBroadcastGroup()
group.AddChannels(p.ChannelName.canonical)
group.AddUsers(p.User)
Broadcast(group, content)
},
)
// messages (to user)
AddNewRule(
func(s SendMessageToUserAction) error { return nil },
func(s SendMessageToUserAction) {
src := s.User.GetSourceString()
content := transport.Content{ content := transport.Content{
Source: &src, Source: &src,
Command: string(mode), Command: string(s.Mode),
Arguments: []string{destination.GetNick().Value, message}, Arguments: []string{s.Destination.GetNick().Value, s.Message},
} }
group := NewBroadcastGroup() group := NewBroadcastGroup()
group.AddUsers(destination) group.AddUsers(s.Destination)
Broadcast(group, content) Broadcast(group, content)
} },
)
func (notifications *NotificationsSystem) AskPermissionForChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) error { // messages (to channel)
return nil AddNewRule(
} func(s SendMessageToChannelAction) error { return nil },
func(s SendMessageToChannelAction) {
func (notifications *NotificationsSystem) HandleChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) { src := s.User.GetSourceString()
src := user.GetSourceString()
content := transport.Content{ content := transport.Content{
Source: &src, Source: &src,
Command: string(mode), Command: string(s.Mode),
Arguments: []string{string(destination), message}, Arguments: []string{string(s.Destination), s.Message},
} }
group := NewBroadcastGroup() group := NewBroadcastGroup()
group.AddChannels(destination) group.AddChannels(s.Destination)
group.AddSpecificallyExcludedUsers(user) group.AddSpecificallyExcludedUsers(s.User)
Broadcast(group, content) Broadcast(group, content)
},
)
} }

View File

@@ -20,14 +20,6 @@ type User struct {
channels []canonicalChannelName channels []canonicalChannelName
} }
func NewUsersSystem() *UsersSystem {
return &UsersSystem{
clientIdIndex: make(map[transport.ClientId]*User),
nickIndex: make(map[canonicalNick]*User),
channelNameIndex: make(map[canonicalChannelName]map[*User]struct{}),
}
}
func (user *User) recomputeSourceString() { func (user *User) recomputeSourceString() {
nick := "unknown" nick := "unknown"
if user.nick != nil { if user.nick != nil {
@@ -56,15 +48,7 @@ func (user *User) GetNick() *Nick {
} }
func (user *User) SetNick(newNick *Nick) error { func (user *User) SetNick(newNick *Nick) error {
oldNick := user.nick return Perform(NickChangeAction{User: user, OldNick: user.nick, NewNick: newNick})
return Dispatch(
func(nch NickChangeHandler) error {
return nch.AskPermissionForNickChange(user, oldNick, newNick)
},
func(nch NickChangeHandler) {
nch.HandleNickChange(user, oldNick, newNick)
},
)
} }
func (user *User) GetUsername() *string { func (user *User) GetUsername() *string {
@@ -103,23 +87,9 @@ func (user *User) GetChannels() []canonicalChannelName {
} }
func (user *User) Join(channelName ChannelName) error { func (user *User) Join(channelName ChannelName) error {
return Dispatch( return Perform(JoinAction{User: user, ChannelName: channelName})
func(pjh PartJoinHandler) error {
return pjh.AskPermissionForJoin(user, channelName)
},
func(pjh PartJoinHandler) {
pjh.HandleJoin(user, channelName)
},
)
} }
func (user *User) Part(channelName ChannelName, reason *string) error { func (user *User) Part(channelName ChannelName, reason *string) error {
return Dispatch( return Perform(PartAction{User: user, ChannelName: channelName, Reason: reason})
func(pjh PartJoinHandler) error {
return pjh.AskPermissionForPart(user, channelName, reason)
},
func(pjh PartJoinHandler) {
pjh.HandlePart(user, channelName, reason)
},
)
} }

View File

@@ -13,6 +13,14 @@ type UsersSystem struct {
channelNameIndex map[canonicalChannelName](map[*User]struct{}) channelNameIndex map[canonicalChannelName](map[*User]struct{})
} }
func NewUsersSystem() *UsersSystem {
return &UsersSystem{
clientIdIndex: make(map[transport.ClientId]*User),
nickIndex: make(map[canonicalNick]*User),
channelNameIndex: make(map[canonicalChannelName]map[*User]struct{}),
}
}
func (users *UsersSystem) ByClientIdOrCreate(clientId transport.ClientId) *User { func (users *UsersSystem) ByClientIdOrCreate(clientId transport.ClientId) *User {
existing, ok := users.clientIdIndex[clientId] existing, ok := users.clientIdIndex[clientId]
@@ -47,73 +55,75 @@ func (users *UsersSystem) ByCanonicalChannel(channelName canonicalChannelName) m
return users.channelNameIndex[channelName] return users.channelNameIndex[channelName]
} }
func (users *UsersSystem) AssertTypes() { func (users *UsersSystem) InitializeRules() {
// statically assert that we implement the types we believe we do // nick changes
var _ NickChangeHandler = users AddNewRule(
var _ PartJoinHandler = users func(nc NickChangeAction) error {
// users can always change from one nick to the same nick
} if nc.OldNick != nil && nc.NewNick != nil && nc.OldNick.canonical == nc.NewNick.canonical {
func (users *UsersSystem) AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error {
if oldNick != nil && newNick != nil && oldNick.canonical == newNick.canonical {
// you're _always_ allowed to change your nick to the nick you currently have
return nil return nil
} }
// is this someone else's nick? // is this someone else's nick?
_, ok := users.nickIndex[newNick.canonical] _, ok := users.nickIndex[nc.NewNick.canonical]
if ok { if ok {
return fmt.Errorf("%w: %s", ErrNickAlreadyInUse, newNick.Value) return fmt.Errorf("%w: %s", ErrNickAlreadyInUse, nc.NewNick.Value)
} }
return nil return nil
}
func (users *UsersSystem) HandleNickChange(user *User, oldNick *Nick, newNick *Nick) { },
if oldNick != nil { func(nc NickChangeAction) {
delete(users.nickIndex, oldNick.canonical) if nc.OldNick != nil {
delete(users.nickIndex, nc.OldNick.canonical)
} }
user.nick = newNick nc.User.nick = nc.NewNick
user.recomputeSourceString() nc.User.recomputeSourceString()
if newNick != nil { if nc.NewNick != nil {
users.nickIndex[newNick.canonical] = user users.nickIndex[nc.NewNick.canonical] = nc.User
} }
} },
)
func (users *UsersSystem) AskPermissionForPart(user *User, channelName ChannelName, reason *string) error { // joining channel
if !slices.Contains(user.channels, channelName.canonical) { AddNewRule(
return fmt.Errorf("%w: %s", ErrNotInChannel, channelName) func(j JoinAction) error {
if slices.Contains(j.User.channels, j.ChannelName.canonical) {
return fmt.Errorf("%w: %s", ErrAlreadyInChannel, j.ChannelName)
} }
return nil return nil
} },
func(j JoinAction) {
func (users *UsersSystem) HandlePart(user *User, channelName ChannelName, reason *string) { name := j.ChannelName.canonical
name := channelName.canonical
user.channels = slices.DeleteFunc(user.channels, func(ccn canonicalChannelName) bool {
return ccn == name
})
channelUsers := users.channelNameIndex[name]
delete(channelUsers, user)
if len(channelUsers) == 0 {
delete(users.channelNameIndex, name)
}
}
func (users *UsersSystem) AskPermissionForJoin(user *User, channelName ChannelName) error {
if slices.Contains(user.channels, channelName.canonical) {
return fmt.Errorf("%w: %s", ErrAlreadyInChannel, channelName)
}
return nil
}
func (users *UsersSystem) HandleJoin(user *User, channelName ChannelName) {
name := channelName.canonical
existing, ok := users.channelNameIndex[name] existing, ok := users.channelNameIndex[name]
if !ok { if !ok {
existing = make(map[*User]struct{}) existing = make(map[*User]struct{})
users.channelNameIndex[name] = existing users.channelNameIndex[name] = existing
} }
existing[user] = struct{}{} existing[j.User] = struct{}{}
},
)
// parting channel
AddNewRule(
func(p PartAction) error {
if !slices.Contains(p.User.channels, p.ChannelName.canonical) {
return fmt.Errorf("%w: %s", ErrNotInChannel, p.ChannelName.Value)
}
return nil
},
func(p PartAction) {
name := p.ChannelName.canonical
p.User.channels = slices.DeleteFunc(p.User.channels, func(ccn canonicalChannelName) bool {
return ccn == name
})
channelUsers := users.channelNameIndex[name]
delete(channelUsers, p.User)
if len(channelUsers) == 0 {
delete(users.channelNameIndex, name)
}
},
)
} }

115
src/logic/rules.go Normal file
View File

@@ -0,0 +1,115 @@
package logic
/// An Inform 7-style rulebook.
///
/// This is pretty icky, but basically the concept here:
///
/// For any action type, you can make a series of rules to:
///
/// (1) check if it's permissible
/// (2) actually carry it out
///
/// These rules will be fired off in the order that they were added
/// with the rule that a failed check results in an error,
/// and a successful check results in all of the carryOut steps
/// actually being caused to occur. (They are not allowed to fail.)
type Rulebook struct {
chapters []anyChapter
}
type chapter[T any] struct {
check []func(T) error
carryOut []func(T)
}
type anyChapter interface {
dispatchOn(interface{}) (bool, error)
}
type rule[T any] struct {
check *func(T) error
carryOut *func(T)
}
type AnyRule interface {
addTo(rb *Rulebook)
}
func NewRulebook() *Rulebook {
return &Rulebook{}
}
func NewRule[T any](check func(T) error, carryOut func(T)) *rule[T] {
return &rule[T]{
check: &check,
carryOut: &carryOut,
}
}
// why is this done in the rule and not the rulebook?
// because go lacks parameterized types on methods, so we _have_ to
// flip the control flow to put this logic in the type with the most knowledge
// of the generic type
func (r *rule[T]) addTo(rb *Rulebook) {
for i, ch := range rb.chapters {
if chSpecific, ok := ch.(chapter[T]); ok {
r.addToChapter(&chSpecific)
rb.chapters[i] = chSpecific
return
}
}
newChapter := chapter[T]{}
r.addToChapter(&newChapter)
rb.chapters = append(rb.chapters, newChapter)
}
func (r *rule[T]) addToChapter(chapter *chapter[T]) {
if check := r.check; check != nil {
chapter.check = append(chapter.check, *check)
}
if carryOut := r.carryOut; carryOut != nil {
chapter.carryOut = append(chapter.carryOut, *carryOut)
}
}
func (rb *Rulebook) Add(r AnyRule) {
r.addTo(rb)
}
func (rb *Rulebook) Perform(action interface{}) error {
for _, ch := range rb.chapters {
handled, err := ch.dispatchOn(action)
if handled {
return err
}
}
// there were zero rules
return nil
}
func (ch chapter[T]) dispatchOn(action interface{}) (bool, error) {
if specializedAction, ok := action.(T); ok {
return true, ch.dispatchOnSpecific(specializedAction)
}
// false: wrong dispatcher
return false, nil
}
func (ch chapter[T]) dispatchOnSpecific(item T) error {
for _, checkRule := range ch.check {
err := checkRule(item)
if err != nil {
return err
}
}
for _, carryOutRule := range ch.carryOut {
carryOutRule(item)
}
return nil
}