From 1b099c400a177127077b57c96db946f94b06ded5 Mon Sep 17 00:00:00 2001 From: Nyeogmi Date: Sun, 5 Oct 2025 14:31:44 -0700 Subject: [PATCH] Institute Inform-style rulebooks --- src/irc/globals.go | 32 ++-- src/irc/handlerTypes.go | 41 ++--- src/irc/main.go | 28 ++-- src/irc/notificationsSystem.go | 265 ++++++++++++++++----------------- src/irc/user.go | 36 +---- src/irc/usersSystem.go | 146 +++++++++--------- src/logic/rules.go | 115 ++++++++++++++ 7 files changed, 370 insertions(+), 293 deletions(-) create mode 100644 src/logic/rules.go diff --git a/src/irc/globals.go b/src/irc/globals.go index d48af08..0235e05 100644 --- a/src/irc/globals.go +++ b/src/irc/globals.go @@ -1,11 +1,13 @@ package irc import ( + "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/logic" "git.chromaticdragon.app/pyrex/minimal-irc-server/v2/src/transport" ) type Globals struct { Server *transport.Server + Rulebook *logic.Rulebook Users *UsersSystem Notifications *NotificationsSystem } @@ -15,9 +17,12 @@ var globals *Globals = nil func InitializeGlobals(server *transport.Server) { globals = &Globals{ Server: server, + Rulebook: logic.NewRulebook(), Users: NewUsersSystem(), Notifications: NewNotificationsSystem(), } + globals.Users.InitializeRules() + globals.Notifications.InitializeRules() } func GetGlobals() *Globals { @@ -27,29 +32,10 @@ func GetGlobals() *Globals { return globals } -func (g *Globals) getHandlers() []Handler { - return []Handler{ - g.Users, - g.Notifications, - } +func AddNewRule[T any](check func(T) error, carryOut func(T)) { + GetGlobals().Rulebook.Add(logic.NewRule(check, carryOut)) } -func Dispatch[T any](askPermission func(T) error, act func(T)) error { - globals := GetGlobals() - 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 +func Perform[T any](action T) error { + return GetGlobals().Rulebook.Perform(action) } diff --git a/src/irc/handlerTypes.go b/src/irc/handlerTypes.go index ace629f..8cc760d 100644 --- a/src/irc/handlerTypes.go +++ b/src/irc/handlerTypes.go @@ -4,16 +4,21 @@ type Handler interface { AssertTypes() } -type NickChangeHandler interface { - AskPermissionForNickChange(user *User, oldNick *Nick, newNick *Nick) error - HandleNickChange(user *User, oldNick *Nick, newNick *Nick) +type NickChangeAction struct { + User *User + OldNick *Nick + NewNick *Nick } -type PartJoinHandler interface { - AskPermissionForPart(user *User, channelName ChannelName, reason *string) error - HandlePart(user *User, channelName ChannelName, reason *string) - AskPermissionForJoin(user *User, channelName ChannelName) error - HandleJoin(user *User, channelName ChannelName) +type PartAction struct { + User *User + ChannelName ChannelName + Reason *string +} + +type JoinAction struct { + User *User + ChannelName ChannelName } type ChatMode string @@ -23,16 +28,16 @@ const ( ChatModeNotice ChatMode = "NOTICE" ) -type ChatHandler interface { - AskPermissionForUserMessage(user *User, mode ChatMode, destination *User, message string) error - HandleUserMessage(user *User, mode ChatMode, destination *User, message string) - - AskPermissionForChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) error - HandleChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) +type SendMessageToUserAction struct { + User *User + Mode ChatMode + Destination *User + Message string } -/* -type BroadcastHandler interface { - HandleBroadcastMessage(channel string) error +type SendMessageToChannelAction struct { + User *User + Mode ChatMode + Destination canonicalChannelName + Message string } -*/ diff --git a/src/irc/main.go b/src/irc/main.go index ad5c777..73645e3 100644 --- a/src/irc/main.go +++ b/src/irc/main.go @@ -179,23 +179,19 @@ func parseChannelList(arg string) []ChannelName { } func handleChannelMessage(sender *User, mode ChatMode, destination canonicalChannelName, message string) error { - return Dispatch( - func(ch ChatHandler) error { - return ch.AskPermissionForChannelMessage(sender, mode, destination, message) - }, - func(ch ChatHandler) { - ch.HandleChannelMessage(sender, mode, destination, message) - }, - ) + return Perform(SendMessageToChannelAction{ + User: sender, + Mode: mode, + Destination: destination, + Message: message, + }) } func handleUserMessage(sender *User, mode ChatMode, destination *User, message string) error { - return Dispatch( - func(ch ChatHandler) error { - return ch.AskPermissionForUserMessage(sender, mode, destination, message) - }, - func(ch ChatHandler) { - ch.HandleUserMessage(sender, mode, destination, message) - }, - ) + return Perform(SendMessageToUserAction{ + User: sender, + Mode: mode, + Destination: destination, + Message: message, + }) } diff --git a/src/irc/notificationsSystem.go b/src/irc/notificationsSystem.go index 022d5bc..40ea5a7 100644 --- a/src/irc/notificationsSystem.go +++ b/src/irc/notificationsSystem.go @@ -13,143 +13,138 @@ func NewNotificationsSystem() *NotificationsSystem { return &NotificationsSystem{} } -func (notifications *NotificationsSystem) AssertTypes() { - var _ NickChangeHandler = notifications - var _ PartJoinHandler = notifications - var _ ChatHandler = notifications -} +func (notifications *NotificationsSystem) InitializeRules() { + // nick changes + AddNewRule( + func(nc NickChangeAction) error { return nil }, + func(nc NickChangeAction) { + if nc.NewNick == nil { + return // not expressible in the IRC protocol; probably shouldn't be allowed? + } -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? - } - - src := user.GetSourceString() - content := transport.Content{ - Source: &src, - Command: "NICK", - Arguments: []string{newNick.Value}, - } - group := NewBroadcastGroup() - group.AddChannels(user.GetChannels()...) - group.AddUsers(user) - Broadcast(group, content) -} - -// TODO: Always present the channel name in its _established_ notation -func (notifications *NotificationsSystem) AskPermissionForPart(user *User, channelName ChannelName, reason *string) error { - return nil -} - -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{ - Source: &src, - Command: "JOIN", - Arguments: []string{string(channelName.Value)}, - } - group := NewBroadcastGroup() - group.AddChannels(channelName.canonical) - group.AddUsers(user) - Broadcast(group, content) - - // tell the user who is here - group = NewBroadcastGroup() - group.AddUsers(user) - src2 := "server" - - var nameList strings.Builder - var i = 0 - for user := range GetGlobals().Users.ByChannel(channelName) { - if i != 0 { - nameList.WriteString(" ") - } - nameList.WriteString(user.nick.Value) - i += 1 - } - - Broadcast(group, transport.Content{ - Source: &src2, - Command: "332", - Arguments: []string{ - user.nick.Value, channelName.Value, "TODO: topic!", + src := nc.User.GetSourceString() + content := transport.Content{ + Source: &src, + Command: "NICK", + Arguments: []string{nc.NewNick.Value}, + } + group := NewBroadcastGroup() + group.AddChannels(nc.User.GetChannels()...) + group.AddUsers(nc.User) + Broadcast(group, content) }, - }) - Broadcast(group, transport.Content{ - Source: &src2, - Command: "353", - Arguments: []string{ - user.nick.Value, "=", channelName.Value, nameList.String(), + ) + + // joins + AddNewRule( + func(j JoinAction) error { return nil }, + func(j JoinAction) { + src := j.User.GetSourceString() + + content := transport.Content{ + Source: &src, + Command: "JOIN", + Arguments: []string{string(j.ChannelName.Value)}, + } + group := NewBroadcastGroup() + group.AddChannels(j.ChannelName.canonical) + group.AddUsers(j.User) + Broadcast(group, content) + + // tell the user who is here + group = NewBroadcastGroup() + group.AddUsers(j.User) + src2 := "server" + + var nameList strings.Builder + var i = 0 + for user := range GetGlobals().Users.ByChannel(j.ChannelName) { + if i != 0 { + nameList.WriteString(" ") + } + nameList.WriteString(user.nick.Value) + i += 1 + } + + Broadcast(group, transport.Content{ + Source: &src2, + Command: "332", + Arguments: []string{ + j.User.nick.Value, j.ChannelName.Value, "TODO: topic!", + }, + }) + Broadcast(group, transport.Content{ + Source: &src2, + Command: "353", + Arguments: []string{ + j.User.nick.Value, "=", j.ChannelName.Value, nameList.String(), + }, + }) + Broadcast(group, transport.Content{ + Source: &src2, + Command: "366", + Arguments: []string{ + j.User.nick.Value, j.ChannelName.Value, "End of /NAMES list", + }, + }) }, - }) - Broadcast(group, transport.Content{ - Source: &src2, - Command: "366", - Arguments: []string{ - user.nick.Value, channelName.Value, "End of /NAMES list", + ) + + // parts + AddNewRule( + func(p PartAction) error { return nil }, + func(p PartAction) { + src := p.User.GetSourceString() + + var args []string + 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) }, - }) -} - -func (notifications *NotificationsSystem) AskPermissionForUserMessage(user *User, mode ChatMode, destination *User, message string) error { - return nil -} - -func (notifications *NotificationsSystem) HandleUserMessage(user *User, mode ChatMode, destination *User, message string) { - src := user.GetSourceString() - - content := transport.Content{ - Source: &src, - Command: string(mode), - Arguments: []string{destination.GetNick().Value, message}, - } - group := NewBroadcastGroup() - group.AddUsers(destination) - Broadcast(group, content) -} - -func (notifications *NotificationsSystem) AskPermissionForChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) error { - return nil -} - -func (notifications *NotificationsSystem) HandleChannelMessage(user *User, mode ChatMode, destination canonicalChannelName, message string) { - src := user.GetSourceString() - - content := transport.Content{ - Source: &src, - Command: string(mode), - Arguments: []string{string(destination), message}, - } - group := NewBroadcastGroup() - group.AddChannels(destination) - group.AddSpecificallyExcludedUsers(user) - Broadcast(group, content) + ) + + // messages (to user) + AddNewRule( + func(s SendMessageToUserAction) error { return nil }, + func(s SendMessageToUserAction) { + src := s.User.GetSourceString() + + content := transport.Content{ + Source: &src, + Command: string(s.Mode), + Arguments: []string{s.Destination.GetNick().Value, s.Message}, + } + group := NewBroadcastGroup() + group.AddUsers(s.Destination) + Broadcast(group, content) + }, + ) + + // messages (to channel) + AddNewRule( + func(s SendMessageToChannelAction) error { return nil }, + func(s SendMessageToChannelAction) { + src := s.User.GetSourceString() + + content := transport.Content{ + Source: &src, + Command: string(s.Mode), + Arguments: []string{string(s.Destination), s.Message}, + } + group := NewBroadcastGroup() + group.AddChannels(s.Destination) + group.AddSpecificallyExcludedUsers(s.User) + Broadcast(group, content) + }, + ) } diff --git a/src/irc/user.go b/src/irc/user.go index 0234935..e35625a 100644 --- a/src/irc/user.go +++ b/src/irc/user.go @@ -20,14 +20,6 @@ type User struct { 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() { nick := "unknown" if user.nick != nil { @@ -56,15 +48,7 @@ func (user *User) GetNick() *Nick { } func (user *User) SetNick(newNick *Nick) error { - oldNick := user.nick - return Dispatch( - func(nch NickChangeHandler) error { - return nch.AskPermissionForNickChange(user, oldNick, newNick) - }, - func(nch NickChangeHandler) { - nch.HandleNickChange(user, oldNick, newNick) - }, - ) + return Perform(NickChangeAction{User: user, OldNick: user.nick, NewNick: newNick}) } func (user *User) GetUsername() *string { @@ -103,23 +87,9 @@ func (user *User) GetChannels() []canonicalChannelName { } func (user *User) Join(channelName ChannelName) error { - return Dispatch( - func(pjh PartJoinHandler) error { - return pjh.AskPermissionForJoin(user, channelName) - }, - func(pjh PartJoinHandler) { - pjh.HandleJoin(user, channelName) - }, - ) + return Perform(JoinAction{User: user, ChannelName: channelName}) } func (user *User) Part(channelName ChannelName, reason *string) error { - return Dispatch( - func(pjh PartJoinHandler) error { - return pjh.AskPermissionForPart(user, channelName, reason) - }, - func(pjh PartJoinHandler) { - pjh.HandlePart(user, channelName, reason) - }, - ) + return Perform(PartAction{User: user, ChannelName: channelName, Reason: reason}) } diff --git a/src/irc/usersSystem.go b/src/irc/usersSystem.go index 362b8a0..a7b9216 100644 --- a/src/irc/usersSystem.go +++ b/src/irc/usersSystem.go @@ -13,6 +13,14 @@ type UsersSystem 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 { existing, ok := users.clientIdIndex[clientId] @@ -47,73 +55,75 @@ func (users *UsersSystem) ByCanonicalChannel(channelName canonicalChannelName) m return users.channelNameIndex[channelName] } -func (users *UsersSystem) AssertTypes() { - // statically assert that we implement the types we believe we do - var _ NickChangeHandler = users - var _ PartJoinHandler = users +func (users *UsersSystem) InitializeRules() { + // nick changes + AddNewRule( + 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 { + return nil + } -} - -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 - } - - // is this someone else's nick? - _, ok := users.nickIndex[newNick.canonical] - if ok { - return fmt.Errorf("%w: %s", ErrNickAlreadyInUse, newNick.Value) - } - - return nil -} - -func (users *UsersSystem) HandleNickChange(user *User, oldNick *Nick, newNick *Nick) { - if oldNick != nil { - delete(users.nickIndex, oldNick.canonical) - } - user.nick = newNick - user.recomputeSourceString() - - if newNick != nil { - users.nickIndex[newNick.canonical] = user - } -} - -func (users *UsersSystem) AskPermissionForPart(user *User, channelName ChannelName, reason *string) error { - if !slices.Contains(user.channels, channelName.canonical) { - return fmt.Errorf("%w: %s", ErrNotInChannel, channelName) - } - return nil -} - -func (users *UsersSystem) HandlePart(user *User, channelName ChannelName, reason *string) { - 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] - if !ok { - existing = make(map[*User]struct{}) - users.channelNameIndex[name] = existing - } - existing[user] = struct{}{} + // is this someone else's nick? + _, ok := users.nickIndex[nc.NewNick.canonical] + if ok { + return fmt.Errorf("%w: %s", ErrNickAlreadyInUse, nc.NewNick.Value) + } + + return nil + + }, + func(nc NickChangeAction) { + if nc.OldNick != nil { + delete(users.nickIndex, nc.OldNick.canonical) + } + nc.User.nick = nc.NewNick + nc.User.recomputeSourceString() + + if nc.NewNick != nil { + users.nickIndex[nc.NewNick.canonical] = nc.User + } + }, + ) + + // joining channel + AddNewRule( + func(j JoinAction) error { + if slices.Contains(j.User.channels, j.ChannelName.canonical) { + return fmt.Errorf("%w: %s", ErrAlreadyInChannel, j.ChannelName) + } + return nil + }, + func(j JoinAction) { + name := j.ChannelName.canonical + existing, ok := users.channelNameIndex[name] + if !ok { + existing = make(map[*User]struct{}) + users.channelNameIndex[name] = existing + } + 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) + } + }, + ) } diff --git a/src/logic/rules.go b/src/logic/rules.go new file mode 100644 index 0000000..2c50b4c --- /dev/null +++ b/src/logic/rules.go @@ -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 +}