From 3fe1d7e498c46a15c5710d4226774a1f42b81426 Mon Sep 17 00:00:00 2001 From: Daniel Migwi Date: Tue, 28 Jul 2026 11:48:53 +0200 Subject: [PATCH 01/18] Apply corridor lengths control by using least neighbors count selection bias --- frontend/app/__snapshots__/maze.test.ts.snap | 18 ++-- frontend/app/agent/context.ts | 2 +- frontend/app/agent/request.test.ts | 2 +- frontend/app/config.ts | 14 ++- frontend/app/game.test.ts | 25 +++--- frontend/app/maze.test.ts | 59 +++++++++--- frontend/app/maze.ts | 74 +++++++-------- frontend/app/types.ts | 13 ++- maze/constants.go | 58 ++++++------ maze/levels_test.go | 94 +++++++++++++++++--- maze/maze.go | 74 ++++++++------- 11 files changed, 266 insertions(+), 167 deletions(-) diff --git a/frontend/app/__snapshots__/maze.test.ts.snap b/frontend/app/__snapshots__/maze.test.ts.snap index cefe493..a65aa67 100644 --- a/frontend/app/__snapshots__/maze.test.ts.snap +++ b/frontend/app/__snapshots__/maze.test.ts.snap @@ -3,7 +3,7 @@ exports[`maze > generates a deterministic maze layout for a fixed random source 1`] = ` { "finalPosition": { - "x": 9, + "x": 5, "y": 1, }, "maze": [ @@ -14,7 +14,7 @@ exports[`maze > generates a deterministic maze layout for a fixed random source "---", "-", "---", - "-", + "|", "---", "-", "---", @@ -27,7 +27,7 @@ exports[`maze > generates a deterministic maze layout for a fixed random source " ", " ", " ", - " ", + "|", " ", " ", " ", @@ -40,10 +40,10 @@ exports[`maze > generates a deterministic maze layout for a fixed random source " ", "-", "---", - "-", - "---", - "-", - "---", + "|", + " ", + "|", + " ", "|", ], [ @@ -55,7 +55,7 @@ exports[`maze > generates a deterministic maze layout for a fixed random source " ", " ", " ", - " ", + "|", " ", "|", ], @@ -68,7 +68,7 @@ exports[`maze > generates a deterministic maze layout for a fixed random source "---", "-", "---", - "-", + "|", " ", "|", ], diff --git a/frontend/app/agent/context.ts b/frontend/app/agent/context.ts index b506341..049b90b 100644 --- a/frontend/app/agent/context.ts +++ b/frontend/app/agent/context.ts @@ -265,7 +265,7 @@ export function buildAgentToolHandlers( // The hard corridor limit governs DFS maze carving, not player path planning. // AGENT_MOVES_PER_TURN_CAP (p95 of actual run lengths) is the tighter bound for predictions. const suggestedMovesPerTurn = state.mazeDimensions - ? Math.min(getNavigationProfile(state.mazeDimensions).__hardCorridorLimit, AGENT_MOVES_PER_TURN_CAP) + ? Math.min(getNavigationProfile(state.mazeDimensions).__maxCorridorLength, AGENT_MOVES_PER_TURN_CAP) : 0 return { diff --git a/frontend/app/agent/request.test.ts b/frontend/app/agent/request.test.ts index 05da350..6ec35f3 100644 --- a/frontend/app/agent/request.test.ts +++ b/frontend/app/agent/request.test.ts @@ -484,7 +484,7 @@ describe("agent request service", () => { chargedMovesCount: 0, }, { - suggestedMovesPerTurn: Math.min(getNavigationProfile(state.mazeDimensions).__hardCorridorLimit, 4), + suggestedMovesPerTurn: Math.min(getNavigationProfile(state.mazeDimensions).__maxCorridorLength, 4), uniqueCellsVisited: 0, requestsMade: 0, batchEfficiencyRank: "trailblazer", diff --git a/frontend/app/config.ts b/frontend/app/config.ts index 949b322..1f6a80e 100644 --- a/frontend/app/config.ts +++ b/frontend/app/config.ts @@ -6,18 +6,16 @@ import type { declare const __TAPOO_BUILD_YEAR__: number -// NAVIGATION_FRIENDLY_PROFILE defines the easiest corridor settings for small mazes. +// NAVIGATION_FRIENDLY_PROFILE defines the easiest, least-branching settings for small mazes. const NAVIGATION_FRIENDLY_PROFILE: NavigationProfile = { - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, } -// NAVIGATION_HARDEST_PROFILE defines the tightest supported corridor settings. +// NAVIGATION_HARDEST_PROFILE defines the tightest, most-branching supported profile. const NAVIGATION_HARDEST_PROFILE: NavigationProfile = { - __softCorridorLimit: 2, - __hardCorridorLimit: 3, - __preferTurnPercent: 55, + __maxCorridorLength: 3, + __leastNeighborsBias: 0, } // VERSION_MAJOR is the semantic major version for the browser SPA runtime. diff --git a/frontend/app/game.test.ts b/frontend/app/game.test.ts index bc64ef7..1f74d4b 100644 --- a/frontend/app/game.test.ts +++ b/frontend/app/game.test.ts @@ -440,9 +440,8 @@ async function bootstrapHarness({ generateMaze, getMazeDimensions, getNavigationProfile: vi.fn(() => ({ - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, })), })) vi.doMock("./traversal", () => createTraversalMock({ isSpaceFound, reweightMaze })) @@ -533,9 +532,8 @@ describe("bootstrapGame", () => { generateMaze, getMazeDimensions, getNavigationProfile: vi.fn(() => ({ - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, })), })) vi.doMock("./traversal", () => createTraversalMock()) @@ -605,9 +603,8 @@ describe("bootstrapGame", () => { numRows: 1, })), getNavigationProfile: vi.fn(() => ({ - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, })), })) vi.doMock("./traversal", () => createTraversalMock()) @@ -662,9 +659,8 @@ describe("bootstrapGame", () => { generateMaze, getMazeDimensions, getNavigationProfile: vi.fn(() => ({ - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, })), })) vi.doMock("./traversal", () => createTraversalMock()) @@ -721,9 +717,8 @@ describe("bootstrapGame", () => { generateMaze: vi.fn(() => createRound()), getMazeDimensions: vi.fn(() => ({ level: 1, numCols: 1, numRows: 1 })), getNavigationProfile: vi.fn(() => ({ - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, })), })) vi.doMock("./traversal", () => diff --git a/frontend/app/maze.test.ts b/frontend/app/maze.test.ts index fa2484d..a937f9d 100644 --- a/frontend/app/maze.test.ts +++ b/frontend/app/maze.test.ts @@ -5,6 +5,8 @@ import { getNavigationProfile, getMazeDimensions, } from "./maze" +import { openMovesFromCell } from "./traversal" +import type { BaseDimensions } from "./types" // This modeled terminal room approximates a 14-inch MacBook-class browser viewport: // physical panel 3024x1964px, CSS viewport about 1512x982px after device scaling, @@ -77,30 +79,63 @@ describe("maze", () => { it("tightens the navigation profile as maze area grows", () => { expect(getNavigationProfile({ numCols: 10, numRows: 11 })).toEqual({ - __softCorridorLimit: 8, - __hardCorridorLimit: 10, - __preferTurnPercent: 90, + __maxCorridorLength: 10, + __leastNeighborsBias: 100, }) expect(getNavigationProfile({ numCols: 20, numRows: 20 })).toEqual({ - __softCorridorLimit: 5, - __hardCorridorLimit: 7, - __preferTurnPercent: 75, + __maxCorridorLength: 7, + __leastNeighborsBias: 57, }) expect(getNavigationProfile({ numCols: 30, numRows: 30 })).toEqual({ - __softCorridorLimit: 4, - __hardCorridorLimit: 5, - __preferTurnPercent: 65, + __maxCorridorLength: 5, + __leastNeighborsBias: 28, }) expect(getNavigationProfile({ numCols: 60, numRows: 60 })).toEqual({ - __softCorridorLimit: 2, - __hardCorridorLimit: 3, - __preferTurnPercent: 55, + __maxCorridorLength: 3, + __leastNeighborsBias: 0, }) }) + it("uses __leastNeighborsBias to cut junction density for small mazes versus large ones", () => { + // junctionFraction generates one maze and returns the share of cells with 3+ open exits. + // Verifies the real chooseNextCell wiring, not just the simulation the bias was derived from. + const junctionFraction = (dimensions: BaseDimensions): number => { + const { maze } = generateMaze(dimensions, 1) + let junctions = 0 + let total = 0 + + for (let row = 0; row < dimensions.numRows; row += 1) { + for (let col = 0; col < dimensions.numCols; col += 1) { + total += 1 + if (openMovesFromCell(maze, { row, col }).length >= 3) { + junctions += 1 + } + } + } + + return junctions / total + } + + const average = (values: number[]): number => + values.reduce((sum, value) => sum + value, 0) / values.length + + // Small maze: area well below friendlyMaxArea, so __leastNeighborsBias resolves to 100. + const smallMazeJunctionFraction = average( + Array.from({ length: 20 }, () => junctionFraction({ numCols: 10, numRows: 7 })), + ) + // Large maze: area at/above hardestArea, so __leastNeighborsBias resolves to 0. + const largeMazeJunctionFraction = average( + Array.from({ length: 20 }, () => junctionFraction({ numCols: 40, numRows: 40 })), + ) + + // The bias should cut junction density substantially, not just nudge it — the simulation + // this was derived from measured roughly a 10x reduction at full bias strength. + expect(smallMazeJunctionFraction).toBeLessThan(largeMazeJunctionFraction / 2) + }) + it("generates a deterministic maze layout for a fixed random source", () => { vi.spyOn(Math, "random").mockReturnValue(0) diff --git a/frontend/app/maze.ts b/frontend/app/maze.ts index efd95dd..882e621 100644 --- a/frontend/app/maze.ts +++ b/frontend/app/maze.ts @@ -349,19 +349,14 @@ export function getNavigationProfile( const difficultyFactor = navigationDifficultyFactor(area) return { - __softCorridorLimit: interpolateNavigationValue( - generation.navigation.friendlyProfile.__softCorridorLimit, - generation.navigation.hardestProfile.__softCorridorLimit, + __maxCorridorLength: interpolateNavigationValue( + generation.navigation.friendlyProfile.__maxCorridorLength, + generation.navigation.hardestProfile.__maxCorridorLength, difficultyFactor, ), - __hardCorridorLimit: interpolateNavigationValue( - generation.navigation.friendlyProfile.__hardCorridorLimit, - generation.navigation.hardestProfile.__hardCorridorLimit, - difficultyFactor, - ), - __preferTurnPercent: interpolateNavigationValue( - generation.navigation.friendlyProfile.__preferTurnPercent, - generation.navigation.hardestProfile.__preferTurnPercent, + __leastNeighborsBias: interpolateNavigationValue( + generation.navigation.friendlyProfile.__leastNeighborsBias, + generation.navigation.hardestProfile.__leastNeighborsBias, difficultyFactor, ), } @@ -432,7 +427,7 @@ function backtrackToBranch( path.pop() } - throw new Error("failed to backtrack to a maze branch") + throw new Error("maze generation failed: no branch with unvisited neighbors found") } // chooseNextCell applies the navigation profile to the next branch decision. @@ -441,19 +436,15 @@ function chooseNextCell( neighbors: number[], currentState: PathStep, profile: NavigationProfile, + visited: boolean[], ): PathStep { const allChoices: PathStep[] = [] - const turnChoices: PathStep[] = [] - const withinHardLimit: PathStep[] = [] + const withinLengthLimit: PathStep[] = [] for (const neighbor of neighbors) { const choice: PathStep = { __cellNo: neighbor, - __moveDirection: directionBetween( - dimensions, - currentState.__cellNo, - neighbor, - ), + __moveDirection: directionBetween(dimensions, currentState.__cellNo, neighbor), __corridorLength: 1, } @@ -463,32 +454,32 @@ function chooseNextCell( allChoices.push(choice) - if (choice.__moveDirection !== currentState.__moveDirection) { - turnChoices.push(choice) - } - - if (choice.__corridorLength <= profile.__hardCorridorLimit) { - withinHardLimit.push(choice) + // Caps how long a straight run can go before being forced to bend. + if (choice.__corridorLength <= profile.__maxCorridorLength) { + withinLengthLimit.push(choice) } } - let choices = allChoices - - if (withinHardLimit.length > 0) { - choices = withinHardLimit - } - - if (currentState.__moveDirection !== "none" && turnChoices.length > 0) { - const turnPreferenceRoll = getRandomNo(scoring.percentScale) - - if (currentState.__corridorLength >= profile.__hardCorridorLimit) { - choices = turnChoices - } else if ( - currentState.__corridorLength >= profile.__softCorridorLimit && - turnPreferenceRoll < profile.__preferTurnPercent - ) { - choices = turnChoices + const choices = withinLengthLimit.length > 0 ? withinLengthLimit : allChoices + if (choices.length > 1 && getRandomNo(scoring.percentScale) < profile.__leastNeighborsBias) { + // Prefer the candidate with the fewest remaining unvisited neighbors of its own. A + // low-neighbor-count cell gets "used up" cleanly by visiting it now, leaving nothing behind + // for some later, unrelated branch to claim and retroactively turn this cell into a + // junction. This is the mechanism that actually controls branching — unlike corridor length + // or turn direction, neighbor count directly predicts whether a cell will be orphaned. + let leastPopulated: PathStep[] = [] + let fewestRemaining = Infinity + + for (const choice of choices) { + const remaining = getPresentNeighbors(dimensions, choice.__cellNo, visited).length + if (remaining < fewestRemaining) { + fewestRemaining = remaining + leastPopulated = [choice] + } else if (remaining === fewestRemaining) { + leastPopulated.push(choice) + } } + return leastPopulated[getRandomNo(leastPopulated.length)] } return choices[getRandomNo(choices.length)] @@ -629,6 +620,7 @@ export function generateMaze( backtrackedState.neighbors, path[path.length - 1], navigationProfile, + visited, ) if (visited[nextChoice.__cellNo]) { diff --git a/frontend/app/types.ts b/frontend/app/types.ts index 68121ac..d414797 100644 --- a/frontend/app/types.ts +++ b/frontend/app/types.ts @@ -115,11 +115,16 @@ export type CellNeighbors = { __top: number } -// NavigationProfile shapes corridor and turning behavior during maze generation. +// NavigationProfile shapes corridor length and branching behavior during maze generation. export type NavigationProfile = { - __softCorridorLimit: number - __hardCorridorLimit: number - __preferTurnPercent: number + // __maxCorridorLength caps how many cells a straight run can span before being forced to bend. + __maxCorridorLength: number + // __leastNeighborsBias (0-100) is the percent chance, at any decision point with more than + // one unvisited neighbor, of preferring the candidate with the fewest unvisited neighbors of + // its own — this is what actually controls junction density. 100 minimizes branching (long, + // predictable corridors, bounded by __maxCorridorLength); 0 restores fully random neighbor + // selection (the original branching rate, ~10% junctions regardless of area). + __leastNeighborsBias: number } // PathStep tracks one generation step and its corridor history. diff --git a/maze/constants.go b/maze/constants.go index 7874df6..0bfb70a 100644 --- a/maze/constants.go +++ b/maze/constants.go @@ -226,34 +226,33 @@ const ( navigationFriendlyMaxArea = 130 navigationHardestArea = 1600 - // These fallback values keep very large mazes on the tightest corridor profile. - navigationFallbackSoftCorridorLimit = 2 - navigationFallbackHardCorridorLimit = 3 - navigationFallbackPreferTurnPercent = 55 - - navigationFriendlySoftCorridorLimit = 8 - navigationFriendlyHardCorridorLimit = 10 - navigationFriendlyPreferTurnPercent = 90 + // These fallback values keep very large mazes on the tightest supported profile. + navigationFallbackMaxCorridorLength = 3 + navigationFallbackLeastNeighborsBias = 0 + + navigationFriendlyMaxCorridorLength = 10 + navigationFriendlyLeastNeighborsBias = 100 ) -// NavigationProfile tunes how maze generation manages corridor length as the maze grows. -// Early levels stay more welcoming by allowing longer straight corridors, while later levels -// tighten those limits so navigation becomes denser and harder to read at a glance. +// NavigationProfile tunes corridor length and branching behavior as the maze grows. +// Early levels stay more welcoming by allowing longer straight corridors and minimizing +// branching, while later levels tighten those limits so navigation becomes denser. type NavigationProfile struct { - // SoftCorridorLimit is the straight-run length after which turns should become preferred. - SoftCorridorLimit int - - // HardCorridorLimit is the straight-run length that should rarely be exceeded when a turn exists. - HardCorridorLimit int - - // PreferTurnPercent controls how often a turn should win over continuing straight when both are valid. - PreferTurnPercent int + // MaxCorridorLength caps how many cells a straight run can span before being forced to bend. + MaxCorridorLength int + + // LeastNeighborsBias (0-100) is the percent chance, at any decision point with more than + // one unvisited neighbor, of preferring the candidate with the fewest unvisited neighbors of + // its own — this is what actually controls junction density. 100 minimizes branching (long, + // predictable corridors, bounded by MaxCorridorLength); 0 restores fully random neighbor + // selection (the original branching rate, ~10% junctions regardless of area). + LeastNeighborsBias int } // GetNavigationProfile returns the corridor-management profile derived from the // provided maze dimensions. The first few levels intentionally allow longer -// straights so the maze feels approachable, then the profile gradually clamps -// corridor length until the largest mazes use the hardest supported settings. +// straights and minimal branching so the maze feels approachable, then the +// profile gradually tightens both until the largest mazes use the hardest settings. func GetNavigationProfile(config Dimensions) NavigationProfile { area := config.NumCols * config.NumRows @@ -261,19 +260,14 @@ func GetNavigationProfile(config Dimensions) NavigationProfile { // corridors faster than a plain linear interpolation while still staying smooth. difficultyFactor := navigationDifficultyFactor(area) return NavigationProfile{ - SoftCorridorLimit: interpolateNavigationValue( - navigationFriendlySoftCorridorLimit, - navigationFallbackSoftCorridorLimit, - difficultyFactor, - ), - HardCorridorLimit: interpolateNavigationValue( - navigationFriendlyHardCorridorLimit, - navigationFallbackHardCorridorLimit, + MaxCorridorLength: interpolateNavigationValue( + navigationFriendlyMaxCorridorLength, + navigationFallbackMaxCorridorLength, difficultyFactor, ), - PreferTurnPercent: interpolateNavigationValue( - navigationFriendlyPreferTurnPercent, - navigationFallbackPreferTurnPercent, + LeastNeighborsBias: interpolateNavigationValue( + navigationFriendlyLeastNeighborsBias, + navigationFallbackLeastNeighborsBias, difficultyFactor, ), } diff --git a/maze/levels_test.go b/maze/levels_test.go index 94bdeb1..07c8c95 100644 --- a/maze/levels_test.go +++ b/maze/levels_test.go @@ -1,6 +1,7 @@ package maze_test import ( + "strings" "testing" "github.com/dmigwi/tapoo/maze" @@ -41,22 +42,22 @@ func TestGetNavigationProfile(t *testing.T) { { name: "welcoming early profile", config: maze.Dimensions{NumCols: 10, NumRows: 11}, - want: maze.NavigationProfile{SoftCorridorLimit: 8, HardCorridorLimit: 10, PreferTurnPercent: 90}, + want: maze.NavigationProfile{MaxCorridorLength: 10, LeastNeighborsBias: 100}, }, { name: "mid area profile", config: maze.Dimensions{NumCols: 20, NumRows: 20}, - want: maze.NavigationProfile{SoftCorridorLimit: 5, HardCorridorLimit: 7, PreferTurnPercent: 75}, + want: maze.NavigationProfile{MaxCorridorLength: 7, LeastNeighborsBias: 57}, }, { name: "late game profile", config: maze.Dimensions{NumCols: 30, NumRows: 30}, - want: maze.NavigationProfile{SoftCorridorLimit: 4, HardCorridorLimit: 5, PreferTurnPercent: 65}, + want: maze.NavigationProfile{MaxCorridorLength: 5, LeastNeighborsBias: 28}, }, { name: "max area fallback profile", config: maze.Dimensions{NumCols: 60, NumRows: 60}, - want: maze.NavigationProfile{SoftCorridorLimit: 2, HardCorridorLimit: 3, PreferTurnPercent: 55}, + want: maze.NavigationProfile{MaxCorridorLength: 3, LeastNeighborsBias: 0}, }, } @@ -86,29 +87,94 @@ func TestGetNavigationProfileTightensAsAreaGrows(t *testing.T) { previous := profiles[index-1] current := profiles[index] - if current.SoftCorridorLimit > previous.SoftCorridorLimit { + if current.MaxCorridorLength > previous.MaxCorridorLength { t.Fatalf( - "expected soft corridor limit to tighten with maze area: previous=%+v current=%+v", + "expected max corridor length to tighten with maze area: previous=%+v current=%+v", previous, current, ) } - if current.HardCorridorLimit > previous.HardCorridorLimit { + if current.LeastNeighborsBias > previous.LeastNeighborsBias { t.Fatalf( - "expected hard corridor limit to tighten with maze area: previous=%+v current=%+v", + "expected least-neighbors bias to stay the same or tighten with maze area: previous=%+v current=%+v", previous, current, ) } + } +} - if current.PreferTurnPercent > previous.PreferTurnPercent { - t.Fatalf( - "expected turn preference to stay the same or tighten with maze area: previous=%+v current=%+v", - previous, - current, - ) +// countOpenExits reports how many of a cell's grid-adjacent neighbors are reachable +// through an open (space-filled) wall segment in the generated maze grid. +func countOpenExits(config maze.Dimensions, grid [][]string, cellNo int) int { + address := config.GetCellAddress(cellNo) + neighbors := config.GetCellNeighbors(cellNo) + open := 0 + + isOpen := func(point [2]int) bool { + return strings.TrimSpace(grid[point[0]][point[1]]) == "" + } + + if neighbors.Bottom != 0 && isOpen(address.BottomCenter) { + open++ + } + if neighbors.Left != 0 && isOpen(address.MiddleLeft) { + open++ + } + if neighbors.Right != 0 && isOpen(address.MiddleRight) { + open++ + } + if neighbors.Top != 0 && isOpen(address.TopCenter) { + open++ + } + + return open +} + +// TestLeastNeighborsBiasCutsJunctionDensity verifies the real GenerateMaze wiring, not +// just the simulation the bias was derived from: small mazes (LeastNeighborsBias=100) +// should produce far fewer junction cells (3+ open exits) than large ones (bias=0). +func TestLeastNeighborsBiasCutsJunctionDensity(t *testing.T) { + t.Parallel() + + junctionFraction := func(config maze.Dimensions) float64 { + grid, err := config.GenerateMaze(maze.WallWeightRegular) + if err != nil { + t.Fatalf("GenerateMaze returned error: %v", err) + } + + junctions, total := 0, config.NumCols*config.NumRows + for cellNo := 1; cellNo <= total; cellNo++ { + if countOpenExits(config, grid, cellNo) >= 3 { + junctions++ + } } + + return float64(junctions) / float64(total) + } + + average := func(config maze.Dimensions, samples int) float64 { + var sum float64 + for range samples { + sum += junctionFraction(config) + } + return sum / float64(samples) + } + + // Small maze: area well below friendlyMaxArea, so LeastNeighborsBias resolves to 100. + smallMazeJunctionFraction := average(maze.Dimensions{NumCols: 10, NumRows: 7}, 20) + // Large maze: area at/above hardestArea, so LeastNeighborsBias resolves to 0. + largeMazeJunctionFraction := average(maze.Dimensions{NumCols: 40, NumRows: 40}, 20) + + // The bias should cut junction density substantially, not just nudge it — the simulation + // this was derived from measured roughly a 10x reduction at full bias strength. + if smallMazeJunctionFraction >= largeMazeJunctionFraction/2 { + t.Fatalf( + "expected small-maze junction density to be less than half of large-maze density: small=%v large=%v", + smallMazeJunctionFraction, + largeMazeJunctionFraction, + ) } } diff --git a/maze/maze.go b/maze/maze.go index ca24dd5..622d095 100644 --- a/maze/maze.go +++ b/maze/maze.go @@ -72,7 +72,9 @@ func (config *Dimensions) GenerateMaze(weight WallWeight) ([][]string, error) { } // Corridor shaping happens here; the returned cell still preserves DFS behavior. - nextChoice, nextChoiceErr := config.chooseNextCell(neighbors, cellsPath[len(cellsPath)-1], navigationProfile) + nextChoice, nextChoiceErr := config.chooseNextCell( + neighbors, cellsPath[len(cellsPath)-1], navigationProfile, visitedCells, + ) if nextChoiceErr != nil { config.resetPositions() return nil, fmt.Errorf("select next maze cell: %w", nextChoiceErr) @@ -106,21 +108,19 @@ func (config *Dimensions) GenerateMaze(weight WallWeight) ([][]string, error) { return maze, nil } -// chooseNextCell selects the next unvisited neighbor while applying the configured corridor limits. -// The returned choice preserves the DFS spanning-tree behavior but can bias turns once a straight -// run becomes long enough to make navigation feel too corridor-heavy. +// chooseNextCell selects the next unvisited neighbor while applying the configured corridor +// length cap and least-neighbors bias. The returned choice preserves the DFS spanning-tree +// behavior. func (config *Dimensions) chooseNextCell( - neighbors []int, currentState pathStep, profile NavigationProfile, + neighbors []int, currentState pathStep, profile NavigationProfile, visitedCells []bool, ) (pathStep, error) { var ( - allCount int - turnCount int - hardCount int + allCount int + lengthCount int // These fixed-size arrays avoid per-step heap growth while we score up to four neighbors. - allChoices [mazeEdgeNeighborCount]pathStep - turnChoices [mazeEdgeNeighborCount]pathStep - withinHardLimit [mazeEdgeNeighborCount]pathStep + allChoices [mazeEdgeNeighborCount]pathStep + withinLengthLimit [mazeEdgeNeighborCount]pathStep ) for _, neighbor := range neighbors { @@ -139,36 +139,50 @@ func (config *Dimensions) chooseNextCell( allChoices[allCount] = choice allCount++ - if nextDirection != currentState.moveDirection { - turnChoices[turnCount] = choice - turnCount++ - } - - if straightLength <= profile.HardCorridorLimit { - withinHardLimit[hardCount] = choice - hardCount++ + // Caps how long a straight run can go before being forced to bend. + if straightLength <= profile.MaxCorridorLength { + withinLengthLimit[lengthCount] = choice + lengthCount++ } } // Start with every valid neighbor, then narrow down only if the profile says this corridor is too long. choices := allChoices[:allCount] - - if hardCount > 0 { - choices = withinHardLimit[:hardCount] + if lengthCount > 0 { + choices = withinLengthLimit[:lengthCount] } - if currentState.moveDirection != directionNone && turnCount > 0 { - // Soft limits bias the choice toward a turn, while hard limits force one when available. - turnPreferenceRoll, err := secureRandomIndex(percentScale) + if len(choices) > 1 { + biasRoll, err := secureRandomIndex(percentScale) if err != nil { return pathStep{}, err } - if currentState.corridorLength >= profile.HardCorridorLimit { - choices = turnChoices[:turnCount] - } else if currentState.corridorLength >= profile.SoftCorridorLimit && - turnPreferenceRoll < profile.PreferTurnPercent { - choices = turnChoices[:turnCount] + if biasRoll < profile.LeastNeighborsBias { + // Prefer the candidate with the fewest remaining unvisited neighbors of its own. A + // low-neighbor-count cell gets "used up" cleanly by visiting it now, leaving nothing + // behind for some later, unrelated branch to claim and retroactively turn this cell + // into a junction. This is the mechanism that actually controls branching — unlike + // corridor length, neighbor count directly predicts whether a cell will be orphaned. + var ( + leastPopulatedCount int + fewestRemaining = mazeEdgeNeighborCount + 1 + leastPopulated [mazeEdgeNeighborCount]pathStep + ) + + for _, choice := range choices { + remaining := len(config.getPresentNeighbors(choice.cellNo, visitedCells)) + switch { + case remaining < fewestRemaining: + fewestRemaining = remaining + leastPopulated[0] = choice + leastPopulatedCount = 1 + case remaining == fewestRemaining: + leastPopulated[leastPopulatedCount] = choice + leastPopulatedCount++ + } + } + choices = leastPopulated[:leastPopulatedCount] } } From 6dd4eaed50a7dca663cbaff8e99c3671bb6ee64f Mon Sep 17 00:00:00 2001 From: Daniel Migwi Date: Wed, 29 Jul 2026 01:12:10 +0200 Subject: [PATCH 02/18] Truncate the regularly repeated long prompts and descriptions in the logs persistence --- frontend/app/agent/request.test.ts | 179 +++++++++++++++++++++++++++++ frontend/app/agent/request.ts | 67 +++++++++-- frontend/app/control/agent.test.ts | 39 +++++++ frontend/app/control/agent.ts | 21 ++-- 4 files changed, 286 insertions(+), 20 deletions(-) diff --git a/frontend/app/agent/request.test.ts b/frontend/app/agent/request.test.ts index 6ec35f3..0438d65 100644 --- a/frontend/app/agent/request.test.ts +++ b/frontend/app/agent/request.test.ts @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { EXPECTED_RESPONSE_SCHEMA, PREDICTION_FORMAT } from "./context" import { requestPredictionWithAbort } from "./request" import { CONFIG } from "../config" +import { tapooResetLogs } from "../logs" import { getNavigationProfile } from "../maze" +import { loadTapooLog } from "../storage" import type { AgentApiConfig, MazeActionResult, @@ -91,6 +93,23 @@ function compactedTools(calledNames: string[]) { ) } +// expectedLoggedTools mirrors previewLoggedTool: same name, description full or truncated +// depending on keepFull, and absent altogether for already-compacted (called) tools. +function expectedLoggedTools( + wireTools: ReturnType, + keepFull: boolean, +): { name: string; description?: string }[] { + return wireTools.map(({ function: fn }) => ({ + name: fn.name, + description: + "description" in fn + ? keepFull + ? fn.description + : `${fn.description.slice(0, 50)}.....` + : undefined, + })) +} + const agent: AgentApiConfig = { id: 1, playerName: "Blue", @@ -290,6 +309,166 @@ describe("agent request service", () => { expect(JSON.parse(secondRequest.body as string)).toEqual(expectedJsonInput) }) + it("logs full tools and the full accumulated messages every round, in full for the level's first request", async () => { + tapooResetLogs(CONFIG.runtime.controlModes.agentApi) + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + toolCallResponse([ + { + id: "call_positions", + function: { index: 0, name: "get_maze_positions", arguments: {} }, + }, + ]), + ) + .mockResolvedValueOnce( + successfulResponse(JSON.stringify({ moves: ["MoveRight", "MoveDown"] })), + ) + vi.stubGlobal("fetch", fetchMock) + + await requestPrediction(requestInput()) + + const requestEntries = loadTapooLog<{ + payload: string + details?: { + requestTurn: number + mode: "predict" | "tools" + tools: { name: string; description?: string }[] + newMessages: unknown[] + } + }>(CONFIG.runtime.controlModes.agentApi).filter( + (entry) => entry.payload === "Agent request.", + ) + + expect(requestEntries).toHaveLength(2) + + // agentRequestCount defaults to 0, so this is the level's first request: everything logs + // in full, including the system/user prompt and tool descriptions. + expect(requestEntries[0].details).toEqual({ + endpoint, + requestTurn: 1, + mode: "tools", + tools: expectedLoggedTools(compactedTools([]), true), + newMessages: [ + { role: "system", content: developerMessage }, + { role: "user", content: userMessage }, + ], + }) + + // Round 2 logs the full accumulated history (system+user+assistant+tool), not just the new + // assistant/tool-result messages — no delta tracking, every entry stands on its own. Not + // every tool has been called yet, so compactToolsPayload keeps all 5 names (one now + // name-only), and full descriptions still log since this is still the level's first request. + expect(requestEntries[1].details).toEqual({ + endpoint, + requestTurn: 2, + mode: "tools", + tools: expectedLoggedTools(compactedTools(["get_maze_positions"]), true), + newMessages: [ + { role: "system", content: developerMessage }, + { role: "user", content: userMessage }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_positions", + function: { index: 0, name: "get_maze_positions", arguments: {} }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_positions", + tool_name: "get_maze_positions", + content: + "{\"currentCell\":{\"row\":0,\"col\":0},\"destinationCell\":{\"row\":8,\"col\":7}}", + }, + ], + }) + }) + + it("previews the repeated system/user prompt and tool descriptions in a later turn, every round", async () => { + tapooResetLogs(CONFIG.runtime.controlModes.agentApi) + + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + toolCallResponse([ + { + id: "call_positions", + function: { index: 0, name: "get_maze_positions", arguments: {} }, + }, + ]), + ) + .mockResolvedValueOnce( + successfulResponse(JSON.stringify({ moves: ["MoveRight", "MoveDown"] })), + ) + vi.stubGlobal("fetch", fetchMock) + + // agentRequestCount > 0 means this is not the level's first agent-api request. + await requestPrediction(requestInput({ agentRequestCount: 1 })) + + const requestEntries = loadTapooLog<{ + payload: string + details?: { + requestTurn: number + mode: "predict" | "tools" + tools: { name: string; description?: string }[] + newMessages: unknown[] + } + }>(CONFIG.runtime.controlModes.agentApi).filter( + (entry) => entry.payload === "Agent request.", + ) + + expect(requestEntries).toHaveLength(2) + + // Round 1: the static system/user prompt is previewed (role intact, content shortened), + // and tool descriptions are previewed too, since both repeat verbatim every turn. + expect(requestEntries[0].details).toEqual({ + endpoint, + requestTurn: 1, + mode: "tools", + tools: expectedLoggedTools(compactedTools([]), false), + newMessages: [ + { role: "system", content: `${developerMessage.slice(0, 50)}.....` }, + { role: "user", content: `${userMessage.slice(0, 50)}.....` }, + ], + }) + + // Round 2 logs the full accumulated history too — the previewed system/user messages stay + // previewed (gated by role, not round, so this stays consistent with round 1), while the + // assistant/tool-call messages are turn-unique and always log in full. + expect(requestEntries[1].details).toEqual({ + endpoint, + requestTurn: 2, + mode: "tools", + tools: expectedLoggedTools(compactedTools(["get_maze_positions"]), false), + newMessages: [ + { role: "system", content: `${developerMessage.slice(0, 50)}.....` }, + { role: "user", content: `${userMessage.slice(0, 50)}.....` }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_positions", + function: { index: 0, name: "get_maze_positions", arguments: {} }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_positions", + tool_name: "get_maze_positions", + content: + "{\"currentCell\":{\"row\":0,\"col\":0},\"destinationCell\":{\"row\":8,\"col\":7}}", + }, + ], + }) + }) + it("sends the initial chat payload and returns final movement predictions", async () => { const fetchMock = vi.fn().mockResolvedValue( successfulResponse("{\"moves\":[\"MoveRight\",\"MoveDown\"]}"), diff --git a/frontend/app/agent/request.ts b/frontend/app/agent/request.ts index f246800..5557ee5 100644 --- a/frontend/app/agent/request.ts +++ b/frontend/app/agent/request.ts @@ -173,6 +173,47 @@ function serializeToolResult(result: unknown): string { return typeof result === "string" ? result : JSON.stringify(result) } +// loggedDescriptionPreviewLength caps how much of a known long/repeated description field +// survives into the log when the full text isn't needed. +const loggedDescriptionPreviewLength = 50 + +// trimLoggedDescription is the single place every long, repeated description field goes through +// before being logged. Passing keepFull lets each call site decide once whether this entry needs +// the real text (e.g. the level's first request) or just a short, recognizable preview. +function trimLoggedDescription( + description: string | undefined, + keepFull: boolean, +): string | undefined { + if (keepFull || !description || description.length <= loggedDescriptionPreviewLength) { + return description + } + + return `${description.slice(0, loggedDescriptionPreviewLength)}.....` +} + +// previewLoggedMessage trims content only for the system/user roles, the two known static, +// repeated-every-turn prompt messages. Assistant/tool messages are always turn-unique content +// (tool-call results), so they're left untouched regardless of keepFull — gating by role here, +// not by round, keeps every message's treatment consistent across every round of a turn. +function previewLoggedMessage(message: AgentChatMessage, keepFull: boolean): AgentChatMessage { + if (message.role !== "system" && message.role !== "user") { + return message + } + + return { ...message, content: trimLoggedDescription(message.content, keepFull) } +} + +// previewLoggedTool applies trimLoggedDescription to a tool's function.description, the other +// field known to carry long, repeated text (the same 5 static schemas resent every round). +// Name is kept as-is regardless — it's short and still tells you which tools were on offer. +function previewLoggedTool( + tool: object, + keepFull: boolean, +): { name: string; description: string | undefined } { + const { function: fn } = tool as AgentToolDefinition + return { name: fn.name, description: trimLoggedDescription(fn.description, keepFull) } +} + // buildToolResultMessages executes requested tools and converts their values into chat messages. async function buildToolResultMessages( toolCalls: AgentToolCall[], @@ -242,8 +283,10 @@ async function requestChatTurn( messages: AgentChatMessage[], tools: object[], signal: AbortSignal, - mazeArea?: number, - format?: Record, + mazeArea: number | undefined, + format: Record | undefined, + reqTurn: number, + isFirstRequestOfLevel: boolean, ): Promise { const msgBody = { @@ -261,13 +304,13 @@ async function requestChatTurn( } const endpointLabel = `${endpoint.origin}${endpoint.pathname}` - // Snapshot messages at log time: chatMessages is mutated by push() after each tool round, - // so storing the live reference would cause all log entries to reflect the final state. const agentApiModeName = CONFIG.runtime.controlModes.agentApi - logTapooDiagnostic(agentApiModeName, "info", "Agent request.", { endpoint: endpointLabel, - payload: { ...msgBody, messages: [...messages] }, + requestTurn: reqTurn, + mode: format !== undefined ? "predict" : "tools", + tools: tools.map((tool) => previewLoggedTool(tool, isFirstRequestOfLevel)), + newMessages: messages.map((msg) => previewLoggedMessage(msg, isFirstRequestOfLevel)), }) const response = await fetch(endpoint, { @@ -311,11 +354,16 @@ export function requestPredictionWithAbort({ let activeController: AbortController | null = null let wasAborted = false + // A level's first agent-api request is the only one that needs the full system/user prompt + // and tool descriptions logged; every later turn repeats that same static content. + const isFirstRequestOfLevel = state.agentRequestCount === 0 + // requestChatTurnWithTimeout gives each provider HTTP request its own timeout window. const requestChatTurnWithTimeout = async ( messages: AgentChatMessage[], tools: object[], - format?: Record, + format: Record | undefined, + reqTurn: number, ): Promise => { const controller = new AbortController() activeController = controller @@ -325,7 +373,8 @@ export function requestPredictionWithAbort({ try { return await requestChatTurn( - agent.endpoint, agent.model, messages, tools, controller.signal, state.mazeDimensions?.area, format, + agent.endpoint, agent.model, messages, tools, controller.signal, + state.mazeDimensions?.area, format, reqTurn, isFirstRequestOfLevel, ) } finally { window.clearTimeout(requestTimeout) @@ -389,7 +438,7 @@ export function requestPredictionWithAbort({ // or availableTools was explicitly cleared (max-rounds), switching to prediction mode. const toolsToSend = compactToolsPayload(availableTools, calledToolNames, toolRounds) const format = toolsToSend.length === 0 ? PREDICTION_FORMAT : undefined - const response = await requestChatTurnWithTimeout(messages, toolsToSend, format) + const response = await requestChatTurnWithTimeout(messages, toolsToSend, format, requestTurns) if (!response?.message) { return fail("network-error") } diff --git a/frontend/app/control/agent.test.ts b/frontend/app/control/agent.test.ts index 254ca95..a918e1c 100644 --- a/frontend/app/control/agent.test.ts +++ b/frontend/app/control/agent.test.ts @@ -1284,6 +1284,45 @@ describe("agent control mode", () => { elements.app.remove() }) + it("closes the manage/delete dialog with Escape without dispatching pause", () => { + // The delete dialog focuses a