aboutsummaryrefslogtreecommitdiff
path: root/internal/game/core_startup.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/core_startup.go')
-rw-r--r--internal/game/core_startup.go770
1 files changed, 770 insertions, 0 deletions
diff --git a/internal/game/core_startup.go b/internal/game/core_startup.go
new file mode 100644
index 0000000..ab52762
--- /dev/null
+++ b/internal/game/core_startup.go
@@ -0,0 +1,770 @@
+package game
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "thehouseoficarus/internal/action"
+)
+
+type StartupIssue struct {
+ Level string
+ Type string
+ Message string
+}
+
+func (g *Game) ValidateStartup() []StartupIssue {
+ var issues []StartupIssue
+
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "items", "item")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "rooms", "room")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "mobs", "mob")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "objects", "object")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "drops", "drop table")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "recipes", "recipe")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "courses", "course")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "modules", "module")...)
+ issues = append(issues, validateDuplicateIDs(g.DataDir, "help", "help topic")...)
+
+ issues = append(issues, validateRoomReferences(g)...)
+ issues = append(issues, validateMobReferences(g)...)
+ issues = append(issues, validateObjectReferences(g)...)
+ issues = append(issues, validateItemReferences(g)...)
+ issues = append(issues, validateRecipeReferences(g)...)
+ issues = append(issues, validateDropTableReferences(g)...)
+ issues = append(issues, validateCourseReferences(g)...)
+ issues = append(issues, validateRoomWiring(g)...)
+
+ return issues
+}
+
+func validateDuplicateIDs(dataDir, subDir, label string) []StartupIssue {
+ dir := filepath.Join(dataDir, subDir)
+ dups := action.CheckDuplicateIDs(dir)
+ var issues []StartupIssue
+ for _, d := range dups {
+ short := make([]string, len(d.Paths))
+ for i, p := range d.Paths {
+ short[i] = strings.TrimPrefix(p, dir+"/")
+ }
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "duplicate",
+ Message: fmt.Sprintf("Duplicate %s ID %q found in: %s",
+ label, d.ID, strings.Join(short, ", ")),
+ })
+ }
+ return issues
+}
+
+func validateRoomReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ roomIndex := g.World.RoomIndex()
+ itemIDs := g.ItemStore.IDSet()
+ mobIDs := g.MobStore.AllDefIDs()
+ objIDs := g.ObjectStore.IDSet()
+
+ for id := range roomIndex {
+ room, err := g.World.LoadRoom(id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if room.Name == "" {
+ issues = append(issues, StartupIssue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: has no name", id),
+ })
+ }
+
+ for dir, exit := range room.Exits {
+ if exit.Room <= 0 {
+ continue
+ }
+ if _, ok := roomIndex[exit.Room]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: exit %q targets nonexistent room %d",
+ id, dir, exit.Room),
+ })
+ }
+ }
+
+ for _, rm := range room.Mobs {
+ if rm.ID == "" {
+ continue
+ }
+ if _, ok := mobIDs[rm.ID]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: references nonexistent mob %q",
+ id, rm.ID),
+ })
+ }
+ for _, wr := range rm.WanderRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: mob %q wander_rooms references nonexistent room %d",
+ id, rm.ID, wr),
+ })
+ }
+ }
+ }
+
+ for _, s := range room.ItemSpawns {
+ if s.ID != "" && !itemIDs[s.ID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: spawns nonexistent item %q",
+ id, s.ID),
+ })
+ }
+ }
+
+ for _, robj := range room.Objects {
+ if robj.ID != "" && !objIDs[robj.ID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: references nonexistent object %q",
+ id, robj.ID),
+ })
+ }
+ for _, wr := range robj.WanderRooms {
+ if _, ok := roomIndex[wr]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Room %d: object %q wander_rooms references nonexistent room %d",
+ id, robj.ID, wr),
+ })
+ }
+ }
+ }
+ }
+
+ return issues
+}
+
+func validateMobReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ itemIDs := g.ItemStore.IDSet()
+ dropIDs := dropTableIDSet(g.DataDir)
+ mobIDs := g.MobStore.AllDefIDs()
+
+ for id := range mobIDs {
+ def, err := g.MobStore.LoadDef(id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.Name == "" {
+ issues = append(issues, StartupIssue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: has no name", id),
+ })
+ }
+
+ if def.Drops.Remains != "" && !itemIDs[def.Drops.Remains] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: drops remains (item %q) does not exist",
+ id, def.Drops.Remains),
+ })
+ }
+
+ for _, d := range def.Drops.Loot {
+ if d.ItemID != "" && !itemIDs[d.ItemID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: loot item %q does not exist",
+ id, d.ItemID),
+ })
+ }
+ if d.Table != "" && !dropIDs[d.Table] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: loot table %q does not exist",
+ id, d.Table),
+ })
+ }
+ }
+
+ if def.StealTable != "" && !dropIDs[def.StealTable] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: steal_table %q does not exist",
+ id, def.StealTable),
+ })
+ }
+
+ if def.FinishingBlow != "" && !itemIDs[def.FinishingBlow] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Mob %q: finishing_blow item %q does not exist",
+ id, def.FinishingBlow),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateObjectReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ objIDs := g.ObjectStore.IDSet()
+ itemIDs := g.ItemStore.IDSet()
+ mobIDs := g.MobStore.AllDefIDs()
+ dropIDs := dropTableIDSet(g.DataDir)
+ roomIndex := g.World.RoomIndex()
+
+ for id := range objIDs {
+ obj, err := g.ObjectStore.Load(id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if obj.Name == "" {
+ issues = append(issues, StartupIssue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: has no name", id),
+ })
+ }
+
+ if obj.RemovalItem != "" && !itemIDs[obj.RemovalItem] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: removal_item %q does not exist",
+ id, obj.RemovalItem),
+ })
+ }
+
+ if obj.StealTable != "" && !dropIDs[obj.StealTable] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: steal_table %q does not exist",
+ id, obj.StealTable),
+ })
+ }
+
+ if obj.GuardMob != "" && !mobIDs[obj.GuardMob] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: guard_mob %q does not exist",
+ id, obj.GuardMob),
+ })
+ }
+
+ for _, ui := range obj.UseInteractions {
+ if ui.Item != "" && !itemIDs[ui.Item] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Object %q: use_interaction item %q does not exist",
+ id, ui.Item),
+ })
+ }
+ if ui.Action != nil {
+ issues = append(issues, validateNodeAction(fmt.Sprintf("Object %q: use_interaction", id), ui.Action, itemIDs, roomIndex)...)
+ }
+ }
+
+ if obj.Gather != nil {
+ issues = append(issues, validateGatherConfig(fmt.Sprintf("Object %q: gather", id), obj.Gather, itemIDs, dropIDs)...)
+ }
+
+ if obj.Talk != nil {
+ issues = append(issues, validateTalkConfig(fmt.Sprintf("Object %q: talk", id), obj.Talk, itemIDs, roomIndex)...)
+ }
+
+ if obj.Use != nil {
+ issues = append(issues, validateUseConfig(fmt.Sprintf("Object %q: use", id), obj.Use, itemIDs)...)
+ }
+ }
+
+ return issues
+}
+
+func validateItemReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ itemIDs := g.ItemStore.IDSet()
+ dropIDs := dropTableIDSet(g.DataDir)
+
+ for id := range itemIDs {
+ def, err := g.ItemStore.Load(id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ if def.Name == "" {
+ issues = append(issues, StartupIssue{
+ Level: "WARN",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: has no name", id),
+ })
+ }
+
+ if def.SearchTable != "" && !dropIDs[def.SearchTable] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: search_table %q does not exist",
+ id, def.SearchTable),
+ })
+ }
+
+ if def.SearchMiscTable != "" && !dropIDs[def.SearchMiscTable] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: search_misc_table %q does not exist",
+ id, def.SearchMiscTable),
+ })
+ }
+
+ for _, mf := range def.MadeFrom {
+ for _, item := range mf.Items {
+ if item != "" && !itemIDs[item] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: made_from item %q does not exist",
+ id, item),
+ })
+ }
+ }
+ for _, bp := range mf.Byproducts {
+ if bp != "" && !itemIDs[bp] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q: made_from byproduct %q does not exist",
+ id, bp),
+ })
+ }
+ }
+ }
+
+ if def.FarmProduct != "" && !itemIDs[def.FarmProduct] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Item %q (seed): farm_product %q does not exist",
+ id, def.FarmProduct),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateRecipeReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ itemIDs := g.ItemStore.IDSet()
+
+ allRecipes, err := g.RecipeStore.LoadAll()
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Failed to load recipes: %v", err),
+ })
+ return issues
+ }
+
+ recipeIDs := make(map[string]bool)
+ for _, r := range allRecipes {
+ if r.ID != "" {
+ if recipeIDs[r.ID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "duplicate",
+ Message: fmt.Sprintf("Recipe %q: duplicate ID field (two recipe files with same ID value)",
+ r.ID),
+ })
+ }
+ recipeIDs[r.ID] = true
+ }
+
+ for _, e := range r.Consume {
+ for _, item := range e.Items {
+ if item != "" && !itemIDs[item] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Recipe %q (%s): consumes nonexistent item %q",
+ r.ID, r.Type, item),
+ })
+ }
+ }
+ }
+
+ if r.Output != "" && !itemIDs[r.Output] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Recipe %q (%s): output item %q does not exist",
+ r.ID, r.Type, r.Output),
+ })
+ }
+
+ if r.Fail != "" && !itemIDs[r.Fail] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Recipe %q (%s): fail product %q does not exist",
+ r.ID, r.Type, r.Fail),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateDropTableReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ dropIDs := dropTableIDSet(g.DataDir)
+ itemIDs := g.ItemStore.IDSet()
+
+ for id := range dropIDs {
+ dt, err := action.LoadDropTable(g.DataDir, id)
+ if err != nil {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Drop table %q: failed to load: %v", id, err),
+ })
+ continue
+ }
+ for _, d := range dt.Drops {
+ if d.ItemID != "" && !itemIDs[d.ItemID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Drop table %q: drops nonexistent item %q",
+ id, d.ItemID),
+ })
+ }
+ if d.Table != "" && !dropIDs[d.Table] && d.Table != id {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Drop table %q: references nonexistent sub-table %q",
+ id, d.Table),
+ })
+ }
+ }
+ }
+
+ return issues
+}
+
+func validateCourseReferences(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ roomIndex := g.World.RoomIndex()
+
+ courses := g.CourseStore.AllCourses()
+ for id, cfg := range courses {
+ if cfg.StartRoom > 0 {
+ if _, ok := roomIndex[cfg.StartRoom]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Course %q: start_room %d does not exist",
+ id, cfg.StartRoom),
+ })
+ }
+ }
+ for _, obs := range cfg.Obstacles {
+ if _, ok := roomIndex[obs.RoomID]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("Course %q: obstacle room %d does not exist",
+ id, obs.RoomID),
+ })
+ }
+ }
+ }
+
+ return issues
+}
+
+func validateRoomWiring(g *Game) []StartupIssue {
+ var issues []StartupIssue
+ roomIndex := g.World.RoomIndex()
+ cfg := g.ValidationConfig
+
+ sourceSet := make(map[int]bool)
+ for _, s := range cfg.CheckSources {
+ sourceSet[s] = true
+ }
+
+ ignoreTo := make(map[int]bool)
+ for _, r := range cfg.IgnoreUnreachable {
+ ignoreTo[r] = true
+ }
+
+ hasExitTo := make(map[int]bool)
+ for id := range roomIndex {
+ room, err := g.World.LoadRoom(id)
+ if err != nil {
+ continue
+ }
+ for _, exit := range room.Exits {
+ if exit.Room > 0 {
+ hasExitTo[exit.Room] = true
+ }
+ }
+ }
+
+ var orphans []int
+ for id := range roomIndex {
+ if ignoreTo[id] {
+ continue
+ }
+ if sourceSet[id] {
+ continue
+ }
+ if !hasExitTo[id] {
+ orphans = append(orphans, id)
+ }
+ }
+ if len(orphans) > 0 {
+ sort.Ints(orphans)
+ var strs []string
+ for _, id := range orphans {
+ strs = append(strs, fmt.Sprintf("%d", id))
+ }
+ issues = append(issues, StartupIssue{
+ Level: "WARN",
+ Type: "integrity",
+ Message: fmt.Sprintf("Orphan rooms (no exit leads to them): %s",
+ strings.Join(strs, ", ")),
+ })
+ }
+
+ return issues
+}
+
+func validateGatherConfig(prefix string, cfg *action.GatherConfig, itemIDs map[string]bool, dropIDs map[string]bool) []StartupIssue {
+ var issues []StartupIssue
+
+ if cfg.Bait != "" && !itemIDs[cfg.Bait] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: bait item %q does not exist",
+ prefix, cfg.Bait),
+ })
+ }
+
+ for _, d := range cfg.Drops {
+ if d.ItemID != "" && !itemIDs[d.ItemID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: drop item %q does not exist",
+ prefix, d.ItemID),
+ })
+ }
+ if d.Table != "" && !dropIDs[d.Table] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: drop table %q does not exist",
+ prefix, d.Table),
+ })
+ }
+ }
+
+ return issues
+}
+
+func validateTalkConfig(prefix string, cfg *action.TalkConfig, itemIDs map[string]bool, roomIndex map[int]bool) []StartupIssue {
+ var issues []StartupIssue
+ if cfg == nil {
+ return issues
+ }
+ for nodeID, node := range cfg.Nodes {
+ nodePrefix := fmt.Sprintf("%s: node %q", prefix, nodeID)
+ if node.Action != nil {
+ issues = append(issues, validateNodeAction(nodePrefix, node.Action, itemIDs, roomIndex)...)
+ }
+ }
+ return issues
+}
+
+func validateUseConfig(prefix string, cfg *action.UseConfig, itemIDs map[string]bool) []StartupIssue {
+ var issues []StartupIssue
+ if cfg == nil {
+ return issues
+ }
+
+ for itemID := range cfg.Consume {
+ if itemID != "" && !itemIDs[itemID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: consumes nonexistent item %q",
+ prefix, itemID),
+ })
+ }
+ }
+
+ if cfg.Reward.ItemID != "" && !itemIDs[cfg.Reward.ItemID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: reward item %q does not exist",
+ prefix, cfg.Reward.ItemID),
+ })
+ }
+
+ return issues
+}
+
+func validateNodeAction(prefix string, na *action.NodeAction, itemIDs map[string]bool, roomIndex map[int]bool) []StartupIssue {
+ var issues []StartupIssue
+ if na == nil {
+ return issues
+ }
+
+ if na.GiveItem != "" && !itemIDs[na.GiveItem] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: give_item %q does not exist",
+ prefix, na.GiveItem),
+ })
+ }
+
+ if na.TakeItem != "" && !itemIDs[na.TakeItem] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: take_item %q does not exist",
+ prefix, na.TakeItem),
+ })
+ }
+
+ if na.Teleport > 0 {
+ if _, ok := roomIndex[na.Teleport]; !ok {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: teleport to nonexistent room %d",
+ prefix, na.Teleport),
+ })
+ }
+ }
+
+ if na.Shop != nil {
+ for _, si := range na.Shop.Items {
+ if si.ItemID != "" && !itemIDs[si.ItemID] {
+ issues = append(issues, StartupIssue{
+ Level: "ERROR",
+ Type: "reference",
+ Message: fmt.Sprintf("%s: shop item %q does not exist",
+ prefix, si.ItemID),
+ })
+ }
+ }
+ }
+
+ return issues
+}
+
+func dropTableIDSet(dataDir string) map[string]bool {
+ dir := filepath.Join(dataDir, "drops")
+ ids := make(map[string]bool)
+ action.WalkYAMLDir(dir, func(path, id string, data []byte) error {
+ ids[id] = true
+ return nil
+ })
+ return ids
+}
+
+func (cs *CourseStore) AllCourses() map[string]*CourseConfig {
+ cs.mu.Lock()
+ defer cs.mu.Unlock()
+ if !cs.loaded {
+ cs.loadAllLocked()
+ }
+ return cs.courses
+}
+
+func (g *Game) ValidateAndLog() {
+ LogStartupIssues(g.ValidateStartup())
+}
+
+func LogStartupIssues(issues []StartupIssue) {
+ if len(issues) == 0 {
+ fmt.Fprintf(os.Stderr, "Startup validation: OK\n")
+ return
+ }
+
+ errors := 0
+ warns := 0
+ for _, issue := range issues {
+ if issue.Level == "ERROR" {
+ errors++
+ } else {
+ warns++
+ }
+ }
+
+ fmt.Fprintf(os.Stderr, "\n=== Startup Validation: %d error(s), %d warning(s) ===\n\n", errors, warns)
+
+ sort.Slice(issues, func(i, j int) bool {
+ if issues[i].Level != issues[j].Level {
+ return issues[i].Level == "ERROR"
+ }
+ return issues[i].Message < issues[j].Message
+ })
+
+ for _, issue := range issues {
+ tag := fmt.Sprintf("[%s]", issue.Level)
+ fmt.Fprintf(os.Stderr, " %-7s %s\n", tag, issue.Message)
+ }
+ fmt.Fprintln(os.Stderr)
+
+ if errors > 0 {
+ fmt.Fprintf(os.Stderr, "*** %d startup errors detected. The server may behave unexpectedly. ***\n", errors)
+ }
+} \ No newline at end of file