// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // Package rogue interprets classic Rogue 5.4's visible terminal display. // Layout and glyphs follow RogueForge's io.c, command.c, and rogue.h: // https://github.com/rogueforge/rogue5.4/tree/d4ff90dc41053f1962efbf32c7b71b12503b9b62 package rogue import ( "regexp" "slices" "strconv" "strings" "git.stormux.org/storm/fenrir/internal/applicationoverrides" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" ) // Classic Rogue uses a 24 by 80 playfield even in a larger terminal. const mapBottom = 22 var statusPattern = regexp.MustCompile(`^Level:\s+(\d+)\s+Gold:\s+(\d+)\s+Hp:\s+(-?\d+)\(\s*(\d+)\)\s+Str:\s+(\d+)\(\s*(\d+)\)\s+Arm:\s+(-?\d+)\s+Exp:\s+(\d+)/(\d+)\s*(Hungry|Weak|Faint)?\s*$`) type entity struct { x, y int glyph rune } type Adapter struct { previous screen.Snapshot stats []string message, latest string entities []entity regions []applicationoverrides.Region dialog bool } func (*Adapter) Name() string { return "Rogue" } func (*Adapter) Configure(settings *config.Settings) { settings.Reading.Mode = "cursor" settings.Review.LeaveReviewOnCursorChange = true } func (a *Adapter) Reset() { *a = Adapter{} } func (a *Adapter) Close() { a.Reset() } func (a *Adapter) Command(id command.ID) (presentation.CommandResult, bool) { ids := []command.ID{command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3, command.ReadLiveRegion4, command.ReadLiveRegion5, command.ReadLiveRegion6} if slot := slices.Index(ids, id); slot >= 0 { regions := a.regions if len(regions) == 0 { regions = region_names() } return presentation.CommandResult{Text: regions[slot].Read()}, true } if id == command.ListReadingRegions { var entries []string for slot, region := range region_names() { entries = append(entries, i18n.Format("Live region {0}: {1}", strconv.Itoa(slot+1), region.Name)) } return presentation.CommandResult{Text: strings.Join(entries, ". ")}, true } return presentation.CommandResult{}, false } func region_names() []applicationoverrides.Region { return []applicationoverrides.Region{ {Name: i18n.T("Health")}, {Name: i18n.T("Status")}, {Name: i18n.T("Latest message")}, {Name: i18n.T("Surroundings")}, {Name: i18n.T("Visible monsters")}, {Name: i18n.T("Visible objects and landmarks")}, } } func (a *Adapter) Observe(next screen.Snapshot) applicationoverrides.Frame { if !next.Active || next.Rows < 24 || next.Cols < 80 || len(next.Cells) != next.Rows*next.Cols { a.Reset() return applicationoverrides.Frame{} } stable := a.previous.Active && a.previous.Terminal == next.Terminal && a.previous.Generation == next.Generation && a.previous.Rows == next.Rows && a.previous.Cols == next.Cols if !stable { a.Reset() } stats := statusPattern.FindStringSubmatch(strings.TrimSpace(next.Text(23))) // Options and full-screen help/inventory use a separate curses window. // Let ordinary reading handle their text; never interpret it as map glyphs. options := strings.Contains(next.Text(0), `Terse output ("terse"):`) && strings.Contains(string(next.Cells), `Inventory style ("inven"):`) page := strings.Contains(string(next.Cells), "--Press space to continue--") if options || page && (stable || stats != nil) { a.regions = region_names() a.regions[2].Text = a.latest a.previous, a.dialog = next, true return applicationoverrides.Frame{Recognized: true, Regions: slices.Clone(a.regions)} } x, y, found := player_position(next) if stats == nil || !found { a.Reset() return applicationoverrides.Frame{} } frame := applicationoverrides.Frame{Recognized: true, OwnsPresentation: true} message := strings.TrimSpace(next.Text(0)) if strings.HasSuffix(message, "--More--") { message = strings.TrimSpace(strings.TrimSuffix(message, "--More--")) + ". " + i18n.T("More; press Space") } if message != "" && message != a.message { a.latest = message frame.Updates = append(frame.Updates, presentation.Message{Text: message}) } // A visible clear permits the same message to be announced on a later turn. // Messages replaced between captures cannot be recovered from screenshots. a.message = message regions := region_names() regions[0].Text = i18n.Format("{0} of {1}", stats[3], stats[4]) regions[1].Text = strings.TrimSpace(next.Text(23)) regions[2].Text = a.latest regions[3].Text = surroundings(next, x, y) entities := visible_entities(next) var monsters, objects []string for _, item := range entities { text := describe(item, x, y) if is_monster(item.glyph) { monsters = append(monsters, text) } else { objects = append(objects, text) } } regions[4].Text, regions[5].Text = join_entities(monsters), join_entities(objects) if !stable || len(a.stats) == 0 { frame.Updates = append(frame.Updates, presentation.Message{Text: regions[0].Read()}) if stats[10] != "" { frame.Updates = append(frame.Updates, presentation.Message{Text: stats[10]}) } } else { if stats[3] != a.stats[3] || stats[4] != a.stats[4] { frame.Updates = append(frame.Updates, presentation.Message{Text: regions[0].Read()}) } for _, field := range []struct { index int label string }{ {1, i18n.T("Dungeon level")}, {5, i18n.T("Strength")}, {7, i18n.T("Armor")}, {8, i18n.T("Experience level")}, } { if stats[field.index] != a.stats[field.index] { frame.Updates = append(frame.Updates, presentation.Message{Text: (applicationoverrides.Region{Name: field.label, Text: stats[field.index]}).Read()}) } } if stats[10] != a.stats[10] { hunger := stats[10] if hunger == "" { hunger = i18n.T("Not hungry") } frame.Updates = append(frame.Updates, presentation.Message{Text: hunger}) } } // Compare monsters by glyph count so a moving monster does not repeatedly // sound like a new arrival. Objects and landmarks have fixed map positions. if stable && !a.dialog && len(a.stats) != 0 && stats[1] == a.stats[1] { counts := map[rune]int{} for _, item := range a.entities { if is_monster(item.glyph) { counts[item.glyph]++ } } for _, item := range entities { if is_monster(item.glyph) && counts[item.glyph] > 0 { counts[item.glyph]-- continue } if !is_monster(item.glyph) && slices.Contains(a.entities, item) { continue } frame.Updates = append(frame.Updates, presentation.Message{Text: describe(item, x, y)}) } } a.regions, a.stats, a.entities = regions, stats, entities a.previous, a.dialog = next, false frame.Regions = slices.Clone(regions) return frame } func player_position(s screen.Snapshot) (int, int, bool) { x, y := -1, -1 for row := 1; row <= mapBottom; row++ { for col, glyph := range s.Line(row)[:80] { if glyph == '@' { if x >= 0 { return 0, 0, false } x, y = col, row } } } return x, y, x >= 0 } func is_monster(glyph rune) bool { return glyph >= 'A' && glyph <= 'Z' } func visible_entities(s screen.Snapshot) []entity { var result []entity for y := 1; y <= mapBottom; y++ { for x, glyph := range s.Line(y)[:80] { if is_monster(glyph) || strings.ContainsRune("+^%*!?$:)],=/", glyph) { result = append(result, entity{x, y, glyph}) } } } return result } func join_entities(items []string) string { if len(items) == 0 { return i18n.T("None visible") } return strings.Join(items, "; ") } func surroundings(s screen.Snapshot, x, y int) string { var items []string for _, delta := range [][2]int{{0, -1}, {1, -1}, {1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}} { col, row := x+delta[0], y+delta[1] if col < 0 || col >= 80 || row < 1 || row > mapBottom { continue } items = append(items, describe(entity{col, row, s.Line(row)[col]}, x, y)) } return strings.Join(items, "; ") } func describe(item entity, x, y int) string { var offsets []string if item.y < y { offsets = append(offsets, i18n.Format("north {0}", strconv.Itoa(y-item.y))) } if item.y > y { offsets = append(offsets, i18n.Format("south {0}", strconv.Itoa(item.y-y))) } if item.x < x { offsets = append(offsets, i18n.Format("west {0}", strconv.Itoa(x-item.x))) } if item.x > x { offsets = append(offsets, i18n.Format("east {0}", strconv.Itoa(item.x-x))) } return i18n.Format("{0}, {1}", glyph_name(item.glyph), strings.Join(offsets, ", ")) } func glyph_name(glyph rune) string { // These are Rogue's own English monster names, rather than Fenrir labels. if is_monster(glyph) { return []string{"aquator", "bat", "centaur", "dragon", "emu", "venus flytrap", "griffin", "hobgoblin", "ice monster", "jabberwock", "kestrel", "leprechaun", "medusa", "nymph", "orc", "phantom", "quagga", "rattlesnake", "snake", "troll", "black unicorn", "vampire", "wraith", "xeroc", "yeti", "zombie"}[glyph-'A'] } switch glyph { case ' ', 0: return i18n.T("Unseen") case '|', '-': return i18n.T("Wall") case '.': return i18n.T("Floor") case '#': return i18n.T("Passage") case '+': return i18n.T("Door") case '^': return i18n.T("Trap") case '%': return i18n.T("Stairs") case '*': return i18n.T("Gold") case '!': return i18n.T("Potion") case '?': return i18n.T("Scroll") case '$': return i18n.T("Magic") case ':': return i18n.T("Food") case ')': return i18n.T("Weapon") case ']': return i18n.T("Armor") case ',': return i18n.T("Amulet") case '=': return i18n.T("Ring") case '/': return i18n.T("Wand or staff") } return string(glyph) }