Skip to content

for 2026 frc game#260

Open
Li-Kai-Huang wants to merge 125 commits intoTeam254:mainfrom
Li-Kai-Huang:2026frcgame
Open

for 2026 frc game#260
Li-Kai-Huang wants to merge 125 commits intoTeam254:mainfrom
Li-Kai-Huang:2026frcgame

Conversation

@Li-Kai-Huang
Copy link

@Li-Kai-Huang Li-Kai-Huang commented Feb 12, 2026

This PR makes some modifications to the 2026 rules:

  1. Added offensive and defensive rotation to the time scoring system
  2. Modified the referee, score, and audience screens
  3. Implemented telnet communication for our low-cost equipment and Fortinet's L3 switch.

Summary by CodeRabbit

  • New Features

    • Fuel-based scoring (Auto/Teleop) and Tower endgame (Levels 1–3).
    • Hub activation gating with per-match game-specific messages and hub visuals.
    • Win/tie video playback, shift timing UI, and enhanced coop/shift visuals.
    • Modbus PLC & Arduino bridge for arena I/O integration.
  • Updates

    • Ranking points now derived from fuel/tower thresholds; event settings renamed to fuel thresholds.
    • Major real-time UI refresh across audience, scoring, and referee panels.
    • Match timing and warning durations adjusted for 2026.

Li-Kai-Huang and others added 30 commits January 15, 2026 16:56
…o support the 2026 REBUILT game, the old Reef structure removed, and automatic tower and fuel counting functions added.
- Refactor score_test.go to include new scoring logic and tests for Auto, Teleop, and Endgame points.
- Introduce new scoring thresholds and ranking points in event_settings.go for Energized and Traversal bonuses.
- Modify reports.go to reflect changes in scoring metrics, removing outdated Barge points.
- Update scoring_panel.go to streamline scoring interface, removing Barge and Processor flags, and implementing new climb and fuel adjustments.
- Ensure all changes are aligned with the 2026 game rules and scoring structure.
- Updated match review tests to reflect new scoring formats and removed obsolete match checks.
- Modified scoring panel to remove deprecated fields and adjust to new scoring logic for 2026.
- Enhanced websocket handling for scoring commands, including new commands for fuel and climbing.
- Adjusted event settings to accommodate new thresholds for energized and supercharged fuel.
- Improved test coverage for scoring panel websocket interactions and match state changes.
- Modified scoring_panel.html to enhance the real-time scoring interface, including new UI elements for autonomous and teleop periods, and improved button feedback.
- Updated setup_settings.html to reflect new game-specific thresholds for energized and supercharged fuel, and removed obsolete fields.
- Refactored referee_panel.go to streamline foul handling and card assignment logic, ensuring proper integration with the updated scoring system.
- Adjusted websocket communication to support new game rules and real-time updates for match state and scoring.
…ror logging, unpack score command data, and add debug logs for state changes.
…c message fields, and corrected the scoring check in automatic and manual phases.
…ecific messages, and added automatic stage victory determination logic.
… AJAX calls, enhanced WebSocket command handling, and improved error logging.
…dler, remove old foul_list routes, and ensure correct rendering of HTML fragments.
…case of a tie during the match and updates the relevant logic to reflect this change.
…gic for auto fuel wins, enhance hub light controls, and streamline stringer-generated code for registers and coils.
…d last fuel values ​​and calculate increments during the match; updated testing to check fuel registration and Hub lights.
…d electronically controlled phases to improve the game flow.
…ure the end status option is displayed correctly.
… when the Hub is inactive and that the last value is updated correctly.
Li-Kai-Huang and others added 18 commits February 12, 2026 15:37
…and 2 outputs, and background thread management has been improved.
Refactor switch configuration methods for Fortinet support, including login and command execution adjustments.
…atus judgment, and simplify the command processing logic of the Fortinet switch.
…g constant definition to reflect the correct address.
@coderabbitai
Copy link

coderabbitai bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces 2025 coral/barge scoring with a 2026 fuel/tower/hub model, adds Jetson GPIO Modbus PLC support and Arduino Mudbus sketch, migrates switch logic to Fortinet, and overhauls web UI/JS/CSS, templates, PLC API, many tests, and ranking/tiebreaker logic.

Changes

Cohort / File(s) Summary
Arduino PLC & Service
arduino/driverStation.py, arduino/modbus_plc.py, arduino/modbus-plc.service, arduino/red_field_hub.ino
Add Jetson GPIO-driven Modbus TCP server and Arduino Mudbus sketch; background GPIO polling threads, Modbus memory mapping (coils/discretes/registers), optional coil-driven outputs, and systemd service unit for auto-start.
Field / Arena integration
field/arena.go, field/arena_notifiers.go, field/realtime_score.go, field/team_sign.go, field/arena_test.go, field/team_sign_test.go
Introduce PLC fuel counts, HubActive/shift logic, per-match auto tie-breaker, GameSpecificMessage propagation, expanded MatchTimeMessage fields, ScoreMu synchronization, and focused PLC-related tests.
Game core: scoring & summaries
game/score.go, game/score_summary.go, game/score_test.go, game/score_summary_test.go, game/test_helpers.go
Replace coral/barge model with AutoTower/AutoFuel/TeleopFuel/HubActive; new RP thresholds (Energized/Supercharged/Traversal); rewrite Summarize/ScoreSummary and tests to 2026 model.
Ranking & partner/TBA
game/ranking_fields.go, game/ranking_fields_test.go, partner/tba.go
Remove Coopertition/BargePoints, add TowerPoints, reorder tiebreaker cascade, update AddScoreSummary signature and TBA breakdown/ranking exports.
Reef removal & related tests
game/reef.go, game/reef_test.go
Remove Reef types and coral scoring logic and associated tests.
Rules, fouls & timing
game/rule.go, game/foul.go, game/match_timing.go
Replace rules corpus with 2026 manual text, increase foul point values (major/non-major), and update match timing defaults (Auto 20s, Teleop 140s, Warning 30s).
PLC API, stringers & tests
plc/plc.go, plc/plc_test.go, plc/coil_string.go, plc/input_string.go, plc/register_string.go, plc/armorblock_string.go, plc/fake_modbus_client_test.go
API reshaping: add GetFuelCounts/SetHubLights, remove processor/truss methods; rename registers/coils to fuel/hub; update generated stringers and tests, expand fake client buffers.
Network switch (Fortinet)
network/switch.go, network/switch_test.go
Replace Cisco-style CLI flow with Fortinet-style interactive telnet commands, add username field and runCommand, adjust telnet negotiation and tests.
Web backend & scoring panel
web/scoring_panel.go, web/scoring_panel_test.go, web/referee_panel.go, web/match_review.go, web/match_review_test.go, web/web.go, web/reports.go, web/setup_settings.go
Refactor scoring panel websocket commands (fuel/auto_tower/climb), add foul handling with RuleId/IsMajor, MatchTimingNotifier wiring, route/handler renames, and broad test rewrites to 2026 flow.
Templates & UI HTML
templates/*.html (scoring_panel, audience_display, referee_panel, edit_match_result, match_play, rankings_display, base, match_review, setup_settings)
Major template redesigns exposing AutoFuel/TeleopFuel/AutoTower/EndgameTower, rename Barge→Tower, update settings bindings, hide legacy far-score badges, and add win video UI.
JS & CSS frontend
static/js/*.js, static/css/*.css (audience_display.js, scoring_panel.js, referee_panel.js, match_review.js, audience_display.css, referee_panel.css, ...)
Add websocket-driven handlers (handleMatchTime, handleRealtimeScore, handleMatchLoad), hub-active and shift UI, playWinVideo flow, transitions to fuel/tower metrics, numerous UI/animation constants, and significant CSS typography/layout updates.
Scoring UI client features
static/js/scoring_panel.js, static/js/audience_display.js, static/js/referee_panel.js
Add client functions for fuel/climb/auto-tower updates, commit/reset flows, hub UI, and audience/referee visuals for HubActive and RP indicators.
Misc tests & helpers across repo
field/*_test.go, game/*_test.go, model/*_test.go, web/*_test.go, plc/*_test.go
Extensive test rewrites to align codebase with 2026 scoring model, PLC I/O, Fortinet switch changes, and updated UI/websocket behavior; many legacy tests removed or consolidated.
Model settings & UI binding
model/event_settings.go, model/event_settings_test.go, templates/setup_settings.html
Replace coral/barge thresholds with EnergizedFuel/SuperchargedFuel/TraversalPoint thresholds, update defaults and persistence tests, and rename settings form fields.
Minor / other
static/js/audience_display.js, templates/audience_display.html, templates/referee_panel_foul_list.html, various small files
UI refinements: win video handling, shift timer, final score layout changes, small template and CSS tweaks across displays.

Sequence Diagram(s)

sequenceDiagram
    participant Arduino as Arduino (Mudbus Client)
    participant Jetson as Jetson (Modbus TCP Server)
    participant Background as Background Thread
    participant GPIO as Jetson GPIO

    Arduino->>Jetson: TCP connect / Modbus transactions
    Jetson->>Background: provide/register Modbus data store
    Background->>GPIO: poll input pins (BOARD 29,31,32,33,35,37)
    GPIO-->>Background: return active-low states
    Background->>Jetson: write discrete inputs 0..5 (1 if active)
    Arduino->>Jetson: write/read coils (control outputs)
    Background->>Jetson: read coil states
    Background->>GPIO: drive outputs based on coils
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I nibble wires and hop on boards,

Hubs now hum and towers score,
Modbus beeps and webpages sway,
New rules, new tests — I dance and play,
A little rabbit, cheering for more! 🥕

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title "for 2026 frc game" is extremely vague and generic, using non-descriptive phrasing that fails to convey meaningful information about the substantial changeset. Replace with a specific, clear title that highlights the primary change, such as: "Implement 2026 REBUILT game scoring with fuel-based PLC integration" or "Refactor scoring system for 2026 REBUILT game mechanics".
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Li-Kai-Huang Li-Kai-Huang changed the title 2026frcgame for 2026 frc game Feb 12, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
templates/match_play.html (1)

158-166: ⚠️ Potential issue | 🟡 Minor

Duplicate HTML id="fieldResetRadio" on two different elements.

Line 159 and line 165 both use id="fieldResetRadio". HTML IDs must be unique per page; the Signal Count radio button should have its own distinct ID (e.g., signalCountRadio).

Proposed fix
             <label>
                <input type="radio" name="allianceStationDisplay" value="signalCount"
-                 onclick="setAllianceStationDisplay();" id="fieldResetRadio"> Signal Count
+                 onclick="setAllianceStationDisplay();" id="signalCountRadio"> Signal Count
              </label>
web/reports.go (1)

61-108: ⚠️ Potential issue | 🔴 Critical

PDF rankings report is broken: missing "Tower" key in colWidths map and header/data column mismatch.

Several issues in the rankings PDF report:

  1. Line 100 uses colWidths["Tower"] but the map (lines 61–72) only has "Coop" and "Barge" — no "Tower" key. In Go, this returns 0.0, resulting in a zero-width column.
  2. The "Barge" header is still rendered (line 88) but its data row was removed (line 104), creating a column count mismatch between header (10 cols) and data rows (9 cols).
  3. The "Coop" header (line 85) now displays TowerPoints data — the naming is inconsistent.

The colWidths map, headers, and data cells all need to be updated consistently for the Barge→Tower rename.

Proposed fix
 	colWidths := map[string]float64{
 		"Rank":   13,
 		"Team":   20,
 		"RP":     20,
-		"Coop":   20,
+		"Tower":  20,
 		"Match":  20,
 		"Auto":   20,
-		"Barge":  20,
 		"W-L-T":  22,
 		"DQ":     20,
 		"Played": 20,
 	}

And update headers accordingly:

 	pdf.CellFormat(colWidths["RP"], rowHeight, "RP", "1", 0, "C", true, 0, "")
-	pdf.CellFormat(colWidths["Coop"], rowHeight, "Coop", "1", 0, "C", true, 0, "")
+	pdf.CellFormat(colWidths["Tower"], rowHeight, "Tower", "1", 0, "C", true, 0, "")
 	pdf.CellFormat(colWidths["Match"], rowHeight, "Match", "1", 0, "C", true, 0, "")
 	pdf.CellFormat(colWidths["Auto"], rowHeight, "Auto", "1", 0, "C", true, 0, "")
-	pdf.CellFormat(colWidths["Barge"], rowHeight, "Barge", "1", 0, "C", true, 0, "")
model/event_settings.go (1)

122-159: ⚠️ Potential issue | 🟠 Major

Existing databases will get zero-valued thresholds instead of game defaults.

When GetEventSettings finds an existing record (Line 127-129), it returns it as-is. For databases created before these new fields were added, EnergizedFuelThreshold, SuperchargedFuelThreshold, and TraversalPointThreshold will deserialize as 0 (Go's zero value) instead of the intended defaults (100, 360, 50). This could silently break scoring logic that depends on these thresholds.

Consider adding a migration or post-load fixup, e.g.:

🛠️ Proposed fix: apply defaults for zero-valued new fields
 if len(allEventSettings) == 1 {
-    return &allEventSettings[0], nil
+    settings := &allEventSettings[0]
+    // Backfill 2026 defaults for databases created before these fields existed.
+    if settings.EnergizedFuelThreshold == 0 {
+        settings.EnergizedFuelThreshold = game.EnergizedFuelThreshold
+    }
+    if settings.SuperchargedFuelThreshold == 0 {
+        settings.SuperchargedFuelThreshold = game.SuperchargedFuelThreshold
+    }
+    if settings.TraversalPointThreshold == 0 {
+        settings.TraversalPointThreshold = game.TraversalPointThreshold
+    }
+    return settings, nil
 }

Note: this approach assumes 0 is never a valid user-configured value for these thresholds. If 0 is a legitimate value, a version/migration field would be needed instead.

web/referee_panel.go (1)

138-176: ⚠️ Potential issue | 🟡 Minor

Missing FoulsCommitted flag update for foul edit operations.

The addFoul command (Line 134-135) sets FoulsCommitted = true for both alliances, but the foul edit commands (deleteFoul, toggleFoulType, updateFoulTeam, updateFoulRule) only call RealtimeScoreNotifier.Notify() without updating FoulsCommitted. If this flag is used to track whether fouls have been modified since last commit, the edit operations should also set it.

templates/setup_settings.html (1)

248-251: ⚠️ Potential issue | 🟡 Minor

Stale description still references Cisco switch.

Line 249 says "Vivid-Hosting VH-113 access point and Cisco Catalyst 3500-series switch" but the codebase has been migrated to Fortinet switch support. Update this text to reflect the correct hardware.

plc/plc.go (1)

349-366: ⚠️ Potential issue | 🔴 Critical

readInputs panics due to oversized inputCount (pipeline failure).

This is the downstream effect of the input constant misconfiguration above. ReadDiscreteInputs(0, 41) requests 41 inputs. If the Modbus device (or fake test client) returns fewer bytes than ceil(41/8) = 6, the check at line 359 fails. Even if it returns enough bytes, byteToBool(inputs, 41) and the subsequent copy into plc.inputs[:] (a 41-element array) will attempt to map hardware addresses that may not exist, causing the reported runtime panic.

Fixing the input constants (above) to produce a contiguous, correctly-sized inputCount will resolve this.

plc/plc_test.go (1)

61-88: ⚠️ Potential issue | 🔴 Critical

These name expectations confirm the input constants in plc.go must be contiguous.

The test expects exactly 19 input names (fieldEStop through blueConnected3), indexed 0–18. This is consistent with a contiguous iota block starting at 0. The current plc.go produces inputCount=41 with gaps, so this test (and TestPlcInputs) will fail until the input constants are fixed as noted in the plc.go review.

static/js/audience_display.js (1)

293-307: ⚠️ Potential issue | 🟠 Major

Red-side data-checked attributes use stale field names CoralBonusRankingPoint and BargeBonusRankingPoint.

Lines 300 and 306 reference data.RedScoreSummary.CoralBonusRankingPoint and data.RedScoreSummary.BargeBonusRankingPoint, which no longer exist. While the html() calls on lines 297 and 303 correctly use SuperchargedRankingPoint and TraversalRankingPoint, the data-checked attributes will always be undefined. If CSS or JS relies on these attributes for styling, this is a visual bug.

Proposed fix
   $(`#${redSide}FinalCoralBonusRankingPoint`).attr(
-    "data-checked", data.RedScoreSummary.CoralBonusRankingPoint
+    "data-checked", data.RedScoreSummary.SuperchargedRankingPoint
   );
   ...
   $(`#${redSide}FinalBargeBonusRankingPoint`).attr(
-    "data-checked", data.RedScoreSummary.BargeBonusRankingPoint
+    "data-checked", data.RedScoreSummary.TraversalRankingPoint
   );
🤖 Fix all issues with AI agents
In `@arduino/driverStation.py`:
- Around line 1-2: The file driverStation.py contains an unintended heredoc
wrapper that references /home/jetson/modbus_plc.py and a dangling EOF (e.g., the
top line "#cat << 'EOF' > /home/jetson/modbus_plc.py" and the trailing "EOF" at
line 77); remove both the opening heredoc line and the trailing EOF so the
Python source is not wrapped in shell heredoc, or if this was meant for
deployment, move the heredoc into a separate shell script and update the target
path to the correct file for the driver station instead of modbus_plc.py.

In `@arduino/modbus_plc.py`:
- Around line 1-2: Remove the leftover shell heredoc wrapper from the top and
bottom of modbus_plc.py: delete the initial line "#cat << 'EOF' >
/home/jetson/modbus_plc.py" so the shebang "#!/usr/bin/env python3" is the very
first line, and delete the trailing "EOF" line near the end that is an undefined
name; this will make the module valid Python and eliminate the Ruff EXE005/F821
issues in the module (modbus_plc.py).

In `@field/arena_test.go`:
- Around line 48-67: createTestArena constructs an Arena without initializing
its notifiers, causing a nil dereference when handlePlcInputOutput calls
arena.RealtimeScoreNotifier.Notify; fix by initializing the ArenaNotifiers the
same way configureNotifiers does (or call configureNotifiers on the test Arena)
so RealtimeScoreNotifier and any other notifiers are non-nil before tests run —
update createTestArena to call configureNotifiers(arena) or to populate
ArenaNotifiers/RealtimeScoreNotifier explicitly.

In `@field/arena.go`:
- Around line 1287-1327: The code has a data race on the Fouls slice because
handlers append to arena.RedRealtimeScore.CurrentScore.Fouls concurrently with
the arena loop reading/copying scores in handlePlcInputOutput; add a mutex on
the score state (e.g., add sync.RWMutex ScoreMu to the Arena struct) and use it
to guard all accesses and mutations of RedRealtimeScore.CurrentScore and
BlueRealtimeScore.CurrentScore: acquire ScoreMu.RLock() when reading/copying
scores (including where oldRedScore := *redScore and before calling Equals) and
ScoreMu.Lock() when mutating (all increments in handlePlcInputOutput and the
append in the websocket scoring_panel handler), then release appropriately;
ensure the equality check oldRedScore.Equals(redScore) and any Notify() that
relies on consistent score snapshots happen while holding a read lock or use a
locked snapshot to avoid races.

In `@network/switch_test.go`:
- Around line 68-98: The mockTelnet helper currently never writes any prompts,
so runCommand in network/switch.go blocks waiting for login/password prompts;
update mockTelnet to emulate the Fortinet telnet handshake by writing the
expected login banner and password prompt to each accepted connection (e.g.,
send "login:" and then "Password:" or whatever runCommand expects), then read
credentials sent by the client and respond with the command prompt before
allowing the test reads to capture the sent commands; keep the existing
Accept/read structure (conn1/conn2) and use conn.Write with appropriate
timing/deadlines so runCommand advances instead of timing out.

In `@partner/tba.go`:
- Around line 398-412: The code currently divides by ranking.Played when
populating TbaRanking (fields RP, Match, Auto, Tower), causing a
division-by-zero panic if Played == 0; fix by guarding those calculations:
before assigning to tbaRankings[i], check if ranking.Played == 0 and set RP,
Match, Auto, Tower to 0.0 (or other safe default), otherwise compute them using
float32(ranking.Played); update the TbaRanking construction in the loop that
fills tbaRankings to use this guard for RP, Match, Auto, and Tower.

In `@plc/plc.go`:
- Around line 71-101: The constants for input indices (e.g., red1EStop,
red1AStop, redConnected1, blue1EStop, inputCount) are non-contiguous due to
resetting iota and inline arithmetic, causing plc.inputs size misalignment and
ReadDiscreteInputs/ GetInputNames failures; fix by creating a single contiguous
index scheme for the plc input array (either put all inputs in one const block
using iota in order or assign explicit sequential integer indices) and if you
must preserve Modbus hardware addresses keep them in a separate mapping table
(e.g., hardwareAddressByInput) so plc.inputs uses contiguous indices and
inputCount reflects the true array length used by ReadDiscreteInputs and
GetInputNames.

In `@static/js/audience_display.js`:
- Around line 203-219: The playoff branch currently reads deprecated fields
(data.Red.ScoreSummary.NumCoral / NumAlgae) causing undefined UI; change the
playoff path that checks currentMatch.Type === matchTypePlayoff to use the 2026
fields (e.g., use data.Red.ScoreSummary.TotalFuelPoints and
data.Blue.ScoreSummary.TotalFuelPoints for redCoral/blueCoral) and remove or
replace uses of data.*.ScoreSummary.NumAlgae when setting $(`#${redSide}Algae`)
and $(`#${blueSide}Algae`) (either map to the new algae field name in
ScoreSummary or omit the algae element update if the field no longer exists),
keeping the element IDs redSideCoral, blueSideCoral, redSideAlgae, blueSideAlgae
unchanged.

In `@templates/audience_display.html`:
- Around line 24-27: The img tags for the audience display (classes "Dive" and
"Reescape" in templates/audience_display.html) use Windows backslashes in their
src attributes; update the src values for the <img class="Dive" src=...> and
<img class="Reescape" src=...> to use forward slashes (e.g., /static/img/...) so
the browser can resolve the image URLs correctly.

In `@templates/match_review.html`:
- Around line 71-88: Remove the unconditional JSON.parse call that assigns
matchResult (the JSON.parse({{.MatchResultJson}}) at the top) and instead assign
rawJson = {{.MatchResultJson}} only, then rely on the existing guarded parsing
block that checks rawJson, uses try/catch and sets the default matchResult;
update references to matchResult so the guarded branch initializes it (symbols:
matchResult, rawJson, JSON.parse, the try/catch block and the $(document).ready
handler).
🟠 Major comments (13)
arduino/red_field_hub.ino-47-56 (1)

47-56: ⚠️ Potential issue | 🟠 Major

Mb.C[16] contradicts the documented coil layout — likely should be Mb.C[4].

The comment on line 49 says "Modbus address 00005" which corresponds to Mb.C[4] (zero-indexed). Lines 59-64 describe Mb.C[5]Mb.C[15] as reserved virtual coils, totaling 16 coils. Reading from Mb.C[16] is out of the described range and inconsistent with the comments. If the PLC writes to coil address 5 (index 4), this code would never see it.

🐛 Proposed fix
-  bool plc_command = Mb.C[16];
+  bool plc_command = Mb.C[4];
web/match_play_test.go-146-167 (1)

146-167: ⚠️ Potential issue | 🟠 Major

TestCommitCards is incomplete — reset and escalation logic tests are missing.

The function ends at line 167 with only a placeholder comment on line 166 ("Reset logic tests..."). The current test only verifies that yellow cards are assigned to teams; it does not test card escalation (yellow→red), reset behavior on replays, or red card handling. If these mechanics are still valid in 2026, add test cases for:

  • Yellow card escalation to red
  • Card reset/clearing on match replay
  • Red card behavior and constraints
web/setup_settings.go-142-144 (1)

142-144: 🛠️ Refactor suggestion | 🟠 Major

Inconsistent form field naming: snake_case vs. camelCase.

The new fields use snake_case (energized_fuel_threshold, supercharged_fuel_threshold, traversal_point_threshold), while every other field in this handler uses camelCase (warmupDurationSec, autoDurationSec, etc.). This inconsistency makes the API surface harder to maintain and could lead to bugs if the template inadvertently uses the wrong convention.

♻️ Suggested: align with existing camelCase convention
-	eventSettings.EnergizedFuelThreshold, _ = strconv.Atoi(r.PostFormValue("energized_fuel_threshold"))
-	eventSettings.SuperchargedFuelThreshold, _ = strconv.Atoi(r.PostFormValue("supercharged_fuel_threshold"))
-	eventSettings.TraversalPointThreshold, _ = strconv.Atoi(r.PostFormValue("traversal_point_threshold"))
+	eventSettings.EnergizedFuelThreshold, _ = strconv.Atoi(r.PostFormValue("energizedFuelThreshold"))
+	eventSettings.SuperchargedFuelThreshold, _ = strconv.Atoi(r.PostFormValue("superchargedFuelThreshold"))
+	eventSettings.TraversalPointThreshold, _ = strconv.Atoi(r.PostFormValue("traversalPointThreshold"))

Remember to update the corresponding name attributes in templates/setup_settings.html to match.

network/switch.go-150-167 (1)

150-167: ⚠️ Potential issue | 🟠 Major

Telnet negotiation handler is fragile with partial reads.

c.Read(buf) can return fewer than 3 bytes per call (TCP doesn't guarantee message framing). If a 3-byte IAC sequence arrives as two separate reads (e.g., 2 + 1 bytes), the check n < 3 on Line 157 causes an early return, leaving negotiation incomplete. This can cause the switch to stall or refuse to enter interactive mode.

Consider buffering reads to ensure you always process complete 3-byte IAC sequences:

🐛 Proposed fix
 handleNegotiation := func(c net.Conn) {
-	buf := make([]byte, 3)
 	for {
 		c.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
-		n, err := c.Read(buf)
-		if err != nil || n < 3 || buf[0] != 255 {
+		buf := make([]byte, 1)
+		_, err := c.Read(buf)
+		if err != nil || buf[0] != 255 {
 			return
 		}
-		if buf[1] == 253 {
-			c.Write([]byte{255, 252, buf[2]})
-		} else if buf[1] == 251 {
-			c.Write([]byte{255, 254, buf[2]})
+		cmdBuf := make([]byte, 2)
+		if _, err := io.ReadFull(c, cmdBuf); err != nil {
+			return
+		}
+		if cmdBuf[0] == 253 { // DO -> WON'T
+			c.Write([]byte{255, 252, cmdBuf[1]})
+		} else if cmdBuf[0] == 251 { // WILL -> DON'T
+			c.Write([]byte{255, 254, cmdBuf[1]})
 		}
 	}
 }
network/switch.go-176-197 (1)

176-197: ⚠️ Potential issue | 🟠 Major

Write errors silently ignored throughout the command execution flow.

writer.WriteString and writer.Flush return errors that are discarded. If the connection drops mid-command (e.g., switch reboots, network interruption), the function will continue sending subsequent commands into a broken pipe and ultimately return whatever partial output was read — without surfacing the failure.

At minimum, propagate errors from Flush:

🛡️ Proposed fix
-send := func(s string, delay time.Duration) {
-	writer.WriteString(s + "\r")
-	writer.Flush()
+send := func(s string, delay time.Duration) error {
+	if _, err := writer.WriteString(s + "\r"); err != nil {
+		return err
+	}
+	if err := writer.Flush(); err != nil {
+		return err
+	}
 	time.Sleep(delay)
+	return nil
 }
field/arena_notifiers.go-218-236 (1)

218-236: ⚠️ Potential issue | 🟠 Major

Hardcoded shift timing boundaries are fragile and have a boundary gap at time == 0.

The values 33, 58, 83, 108, 133, 163 are magic numbers that should be derived from game timing configuration (e.g., game.MatchTiming or EventSettings duration fields) rather than hardcoded. This will break if phase durations are adjusted.

Also note: when time == 0, the first condition (time > 0) is false, so it falls through to else if time < 58currentShift = 2. If time == 0 represents the start of teleop, this incorrectly skips shift 1.

game/score_test.go-92-116 (1)

92-116: ⚠️ Potential issue | 🟠 Major

TestG420PenaltyBonus can never fail — the conditional assertion is a no-op.

The if summary.EndgameTowerPoints == 30 guard on line 112 means: if the feature works, assert something tautologically true (30 == 30); if it doesn't work, silently pass. This test provides zero value and false confidence.

Either assert the expected behavior unconditionally or remove the test until the rule lookup infrastructure is in place.

Proposed fix (assert unconditionally)
-	// If your score.go logic includes foul.Rule() check,
-	// a more complete Mock might be needed. But if it's just checking RuleNumber:
-	if summary.EndgameTowerPoints == 30 {
-		// Successfully received 30 points compensation
-		assert.Equal(t, 30, summary.EndgameTowerPoints)
-	}
+	// Assert that the G420 penalty awards 30 endgame tower points.
+	// If the rule lookup infrastructure isn't ready, this test should
+	// be skipped with t.Skip(), not silently passed.
+	assert.Equal(t, 30, summary.EndgameTowerPoints)
field/arena_notifiers.go-216-216 (1)

216-216: ⚠️ Potential issue | 🟠 Major

Replace magic number 5 with named constant TeleopPeriod.

Line 216 uses arena.MatchState == 5, but the MatchState type has named constants defined in field/arena.go. The value 5 represents TeleopPeriod. Use the named constant instead:

if arena.MatchState == TeleopPeriod {

This improves code clarity and prevents silent breakage if the constant values are ever reordered.

web/match_review.go-87-94 (1)

87-94: ⚠️ Potential issue | 🟠 Major

Use model.NewMatchResult() instead of a bare struct literal to properly initialize Score fields.

The placeholder &model.MatchResult{MatchId: match.Id} leaves RedScore and BlueScore as nil *game.Score pointers. NewMatchResult() (in model/match_result.go) properly initializes these as non-nil pointers to empty game.Score objects. The same function is already used in getMatchResultFromRequest (line 197–202) for the identical scenario, so this guard should be consistent.

Note: This nil guard may be unreachable since getMatchResultFromRequest already handles nil results from the database, but if present, it should be done correctly.

Proposed fix
 	if matchResult == nil {
 		log.Printf("Warning: MatchResult is nil for match %d", match.Id)
-		matchResult = &model.MatchResult{
-			MatchId: match.Id,
-			// 這裡可以根據 2026 年的結構初始化空的紅藍方分數
-		}
+		matchResult = model.NewMatchResult()
+		matchResult.MatchId = match.Id
+		matchResult.MatchType = match.Type
 	}
templates/scoring_panel.html-148-161 (1)

148-161: ⚠️ Potential issue | 🟠 Major

Climb "Level 1" option (value=1) maps to EndgameLevel2 in the backend enum.

The EndgameStatus enum in game/score.go defines: EndgameNone=0, EndgameLevel2=1, EndgameLevel3=2 — there is no EndgameLevel1. The radio button for "Level 1" sends updateClimb(0, 1), which the backend would store as EndgameLevel2 (value 1). This is a semantic mismatch between what the UI displays and what the backend records.

Either:

  1. Add EndgameLevel1 to the enum (before EndgameLevel2), shifting the other values, OR
  2. Remove the Level 1 option from the UI if it's not a valid game state, OR
  3. Have the handler map UI level numbers to the correct enum values.

This is the same concern flagged in the game/test_helpers.go and web/scoring_panel_test.go reviews.

field/arena_test.go-1-16 (1)

1-16: ⚠️ Potential issue | 🟠 Major

Significant test coverage regression: all prior arena tests appear to have been removed.

Per the AI summary, this file replaces extensive prior tests covering team assignments, match flow, state enforcement, Nexus loading, timeouts, substitutions, E-stops/A-stops, and PLC interactions. The new tests only cover 2026-specific logic. Missing this test coverage is a risk—regressions in the core arena lifecycle won't be caught.

Consider restoring the previous test suite and adding the new 2026 tests alongside it, or add a follow-up issue to restore coverage.

Would you like me to open an issue to track restoring the removed arena test coverage?

game/score.go-47-52 (1)

47-52: ⚠️ Potential issue | 🟠 Major

Comment says "10 pts per Robot" but code awards 15 points per AutoTowerLevel1.

Line 47 states // Tower Level 1: 10 pts per Robot but line 50 adds 15. Either the comment or the point value is wrong.

If 15 is correct, fix the comment
-	// Tower Level 1: 10 pts per Robot
+	// Tower Level 1: 15 pts per Robot (Auto bonus)
 	for _, reachedL1 := range score.AutoTowerLevel1 {
 		if reachedL1 {
 			summary.AutoTowerPoints += 15
field/arena.go-1283-1286 (1)

1283-1286: ⚠️ Potential issue | 🟠 Major

Duplicate calls: updateHubStatus() and updateGameSpecificMessage() are invoked twice per loop iteration.

These are already called in Update() at lines 774-776, which is the caller of handlePlcInputOutput() at line 799. This means every arena loop tick executes these functions twice when PLC is enabled. Remove the duplicate calls here.

Proposed fix
-	// 2026 REBUILT: Update Hub Status and Game Data
-	arena.updateHubStatus()
-	arena.updateGameSpecificMessage()
-
 	// 2026 REBUILT: Read PLC Fuel Counts and update Score
 	redPlcFuel, bluePlcFuel := arena.Plc.GetFuelCounts()
🟡 Minor comments (16)
static/css/referee_panel.css-209-237 (1)

209-237: ⚠️ Potential issue | 🟡 Minor

Fix Stylelint errors and translate comments to English.

  1. Static analysis flags several issues on line 236: use modern rgb() with percentage alpha instead of legacy rgba() with decimal alpha.
  2. Line 209 needs an empty line before the comment (per comment-empty-line-before rule).
  3. All comments in this block are in Chinese, but the rest of the codebase uses English. Consider translating for consistency — the PR objectives also mention translating to English.
Proposed fix
+
-/* 通用 Endgame 文字樣式 */
+/* Generic Endgame text styles */
 [class*="-endgame"] {
     font-weight: normal;
-    transition: color 0.3s ease; /* 變色時增加平滑過渡 */
+    transition: color 0.3s ease;
 }
 
-/* 狀態 0: None - 灰色 */
+/* Status 0: None - gray */
 [class*="-endgame"][data-status="0"] {
     color: `#6c757d`;
 }
 
-/* 狀態 1: Level 1 - 淺藍色 */
+/* Status 1: Level 1 - light blue */
 [class*="-endgame"][data-status="1"] {
     color: `#17a2b8`;
     font-weight: bold;
 }
 
-/* 狀態 2: Level 2 - 深藍色 (或自定義顏色) */
+/* Status 2: Level 2 - blue */
 [class*="-endgame"][data-status="2"] {
     color: `#007bff`;
     font-weight: bold;
 }
 
-/* 狀態 3: Level 3 - 金黃色 (最醒目) */
+/* Status 3: Level 3 - gold (most prominent) */
 [class*="-endgame"][data-status="3"] {
     color: `#ffc107`;
     font-weight: bold;
-    text-shadow: 0 0 5px rgba(255, 193, 7, 0.3);
+    text-shadow: 0 0 5px rgb(255 193 7 / 30%);
 }
game/foul.go-25-28 (1)

25-28: ⚠️ Potential issue | 🟡 Minor

Update comment to remove stale year reference.

Line 26 comment mentions "Special case in 2025 for G206". Since G206 remains active in 2026 (confirmed by rule definition in game/rule.go and active handling in game/score.go), remove the year reference. Update to: "Special case for G206, which is not actually a foul but does make the alliance ineligible for some bonus RPs."

arduino/modbus-plc.service-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Incomplete heredoc instruction in the comment.

Line 1 contains a shell heredoc command as a comment but the corresponding EOF terminator is missing at the end of the file, which could confuse anyone trying to use this as an install instruction.

Proposed fix — add EOF and closing quote

Add after line 20:

EOF
'
arduino/red_field_hub.ino-6-6 (1)

6-6: ⚠️ Potential issue | 🟡 Minor

Hardcoded example MAC address DE:AD:BE:EF:FE:ED.

This is a commonly used placeholder MAC. If multiple Arduino devices are on the same network, they'll conflict. Ensure each device has a unique MAC.

game/rule.go-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor

Do not change the original copyright year.

The original copyright is 2014 Team 254. Changing it to 2026 misrepresents when the file was originally authored. The modification note on line 3 (Modified for 2026 REBUILT Game Manual) is the correct way to indicate the update.

Proposed fix
-// Copyright 2026 Team 254. All Rights Reserved.
+// Copyright 2014 Team 254. All Rights Reserved.
arduino/modbus_plc.py-65-68 (1)

65-68: ⚠️ Potential issue | 🟡 Minor

Comment says "print once per second" but the loop sleeps 0.1s (prints 10x/sec).

Line 66 comment: 每 1 秒印一次就好,比較不會洗版 ("print once per second to avoid flooding"). But time.sleep(0.1) on Line 68 means this prints every 100ms. Either increase the sleep to 1.0s or use a counter/timer to throttle log output.

model/event_settings_test.go-38-38 (1)

38-38: ⚠️ Potential issue | 🟡 Minor

Ignored error from GetEventSettings.

Line 38 discards the error: settings, _ := db.GetEventSettings(). Same on Line 49. If either call fails, subsequent assertions will pass on nil/zero values, masking real failures. Use assert.Nil(t, err) consistently as done on Line 24.

🛠️ Proposed fix
-	settings, _ := db.GetEventSettings()
+	settings, err := db.GetEventSettings()
+	assert.Nil(t, err)
-	newSettings, _ := db.GetEventSettings()
+	newSettings, err := db.GetEventSettings()
+	assert.Nil(t, err)
model/event_settings_test.go-15-30 (1)

15-30: ⚠️ Potential issue | 🟡 Minor

Missing db.Close() — database handle leaked.

Neither TestEventSettingsDefaults nor TestUpdateEventSettings closes the database. This leaks file handles and may prevent os.Remove from succeeding on some platforms (e.g., Windows). Add defer db.Close() after opening.

🛠️ Proposed fix
 	db, err := OpenDatabase("test_settings.db")
 	assert.Nil(t, err)
 	defer os.Remove("test_settings.db")
+	defer db.Close()

Apply the same pattern in TestUpdateEventSettings.

field/team_sign_test.go-16-18 (1)

16-18: ⚠️ Potential issue | 🟡 Minor

Remove redundant os.Remove("test.db") calls on lines 18 and 44.

SetupTestArena(t) creates the database in a temporary directory via t.TempDir(), not in the current working directory. The hardcoded defer os.Remove("test.db") attempts to remove a non-existent file from the current directory and is redundant—the temp directory is automatically cleaned up by Go's testing framework. Additionally, SetupTestArena already registers proper cleanup via t.Cleanup() to close the database connection.

arduino/modbus_plc.py-56-63 (1)

56-63: ⚠️ Potential issue | 🟡 Minor

Add explanatory comment for active-low relay logic on output coils.

The output inversion is intentional for active-low relay hardware (matching the input pattern documented at line 77), but the code lacks an explanatory comment. Unlike the input handling (which includes an inline comment at line 43 clarifying the active-low behavior), the output logic on lines 56-63 provides no such explanation, making it appear unintentional.

Add a comment similar to the input logic to clarify:

# 輸出: Active-low relay (Modbus 0 -> GPIO HIGH [activate], Modbus 1 -> GPIO LOW [deactivate])
network/switch.go-65-77 (1)

65-77: ⚠️ Potential issue | 🟡 Minor

Duplicate step numbering in comments and silent failure on non-existent VLANs.

Line 65 is labeled "1. Remove the old DHCP Server settings", Line 72 is labeled "2. Remove Interface VLANs", but Line 86 is also labeled "2. Create new configuration". The second "2" should be "3".

Additionally, if this is a fresh switch (no VLANs exist), the delete commands will return errors from Fortinet CLI. The runCommand error return on Line 79 may cause ConfigureTeamEthernet to fail on first use. Consider whether these deletion errors should be treated as non-fatal.

templates/referee_panel.html-89-92 (1)

89-92: ⚠️ Potential issue | 🟡 Minor

Duplicate element IDs in reusable scoreSummary template.

The tower elements use hardcoded IDs like id="red-tower-0", id="red-tower-1", id="red-tower-2". Since scoreSummary is instantiated twice (once for blueScoreSummary and once for redScoreSummary on lines 33-34), both instances will have the same red-tower-* IDs in the DOM. Duplicate IDs violate the HTML spec and cause getElementById / jQuery #id selectors to only find the first match.

The JS (referee_panel.js lines 136-148) already uses container-scoped class selectors like $(${containerId} .team-${i+1}-tower), so these IDs appear unused. Either remove the id attributes or parameterize them using the template's .id field.

Proposed fix: remove unused hardcoded IDs
-  <div class="tower-symbol team-1-tower" id="red-tower-0">❌</div>
-  <div class="tower-symbol team-2-tower" id="red-tower-1">❌</div>
-  <div class="tower-symbol team-3-tower" id="red-tower-2">❌</div>
+  <div class="tower-symbol team-1-tower">❌</div>
+  <div class="tower-symbol team-2-tower">❌</div>
+  <div class="tower-symbol team-3-tower">❌</div>
static/js/referee_panel.js-97-105 (1)

97-105: ⚠️ Potential issue | 🟡 Minor

Remove unnecessary fallbacks that don't match the backend's actual message structure.

Lines 101-105 have fallback chains that don't correspond to what the backend sends. The realtimeScore websocket message (generated in Go at field/arena_notifiers.go:252) sends Red and Blue properties (not RedRealtimeScore/BlueRealtimeScore), each with a Score field (not CurrentScore). Simplify to:

const redScore = data.Red.Score || {};
const blueScore = data.Blue.Score || {};

Additionally, lines 205-208 reference #redFarScoreStatus and #blueFarScoreStatus, which are commented out in the HTML template. Remove these dead calls or uncomment the elements if still needed.

static/js/scoring_panel.js-4-7 (1)

4-7: ⚠️ Potential issue | 🟡 Minor

Default timing values are inconsistent with the server-side defaults.

The client defaults AutoDurationSec: 15 and TeleopDurationSec: 135, but game/match_timing.go defines AutoDurationSec: 20 and TeleopDurationSec: 140. While these will be overwritten when matchTiming arrives from the server, any logic that runs before the first websocket message will use incorrect timing.

web/scoring_panel.go-148-166 (1)

148-166: ⚠️ Potential issue | 🟡 Minor

Edge case: if the "score" envelope lacks an inner "command", the outer command remains "score" and silently falls through with no action.

If the client sends {"type": "score", ...} without a nested command field, the code keeps command = "score" which doesn't match any handler — the message is silently ignored. Consider logging a warning or returning an error for malformed envelopes.

game/score.go-60-71 (1)

60-71: ⚠️ Potential issue | 🟡 Minor

Incomplete comment: says "Level 2 (20pts), Level 3 (30pts)" but omits Level 1 (10pts) which is handled in the code.

Fix the comment
-	// Endgame Tower: Level 2 (20pts), Level 3 (30pts)
+	// Endgame Tower: Level 1 (10pts), Level 2 (20pts), Level 3 (30pts)
🧹 Nitpick comments (46)
templates/match_review.html (1)

89-105: Implicit global matchResult and allianceResults.

matchResult and allianceResults are not declared with var/let/const in this scope — they rely on being globals defined in match_review.js. This works but is fragile. Consider adding a brief comment or an explicit var declaration for clarity.

web/web.go (1)

1-2: Consider removing the personal attribution comment above the copyright.

Line 1 adds a date/name comment (//2026 1/22 Cindy) before the existing copyright header. This is inconsistent with the project's attribution style (copyright + author in the original header). Prefer using git blame for tracking authorship.

arduino/modbus-plc.service (1)

7-9: Service runs as root — consider using a least-privilege user.

Running the Modbus PLC service as root is a security risk, especially for a network-facing service. If GPIO access requires elevated privileges, consider using a dedicated service account added to the appropriate groups (e.g., gpio) instead.

arduino/red_field_hub.ino (1)

29-31: Use computed array size instead of magic number 4.

If PIN_SENSORS is ever modified, the loop bound won't automatically adjust.

Proposed fix
-  for (int i = 0; i < 4; i++) {
+  for (int i = 0; i < sizeof(PIN_SENSORS)/sizeof(PIN_SENSORS[0]); i++) {

Apply the same change on line 42.

network/switch_test.go (1)

60-65: Weakened assertions lose test value.

Replacing exact string matching with assert.Contains for the all-teams test case (Lines 63-65) only verifies that a few substrings appear, without validating the complete command structure. If the ordering, IP addresses, or other VLAN parameters are wrong, this test won't catch it. Consider restoring full command assertion once the mock is fixed.

arduino/modbus_plc.py (2)

86-88: GPIO.cleanup() is unreachable — StartTcpServer blocks indefinitely.

StartTcpServer on Line 87 is a blocking call that only returns on server shutdown (e.g., signal). GPIO.cleanup() on Line 88 will never execute during normal operation. Use a signal handler or try/finally to ensure cleanup:

🛠️ Proposed fix
-    StartTcpServer(context, address=("0.0.0.0", 502))
-    GPIO.cleanup()
+    try:
+        StartTcpServer(context, address=("0.0.0.0", 502))
+    finally:
+        GPIO.cleanup()

19-26: Module-level GPIO initialization makes this file un-importable on non-Jetson hosts.

GPIO setup (Lines 20-26) and Modbus store creation (Lines 29-32) execute at import time. This prevents importing the module for testing or reuse on machines without Jetson GPIO hardware. Consider moving initialization into a function or guarding it with if __name__ == "__main__".

arduino/driverStation.py (3)

1-77: Significant code duplication with arduino/modbus_plc.py.

Both files share nearly identical structure: GPIO init, Modbus store setup, background_loop, daemon thread + TCP server. The main differences are the pin mappings (6 inputs vs 4 inputs + 2 outputs) and polling rate (0.05s vs 0.1s). Consider extracting the shared scaffolding into a common base module and parameterizing the pin configuration and loop behavior.


8-8: import sys is unused.

sys is imported on Line 8 but never used in this file. Same applies to modbus_plc.py.


65-76: Same GPIO.cleanup() unreachability issue as modbus_plc.py.

StartTcpServer on Line 75 blocks, making GPIO.cleanup() on Line 76 unreachable. Apply the same try/finally fix suggested for the other file.

templates/edit_match_result.html (1)

138-150: Defensive JSON parsing — good, but consider the rawJson type more carefully.

The fallback logic is solid. However, on Line 139, {{.MatchResultJson}} is rendered by Go's template/html package. If the server outputs a JSON object literal directly (not a string), then rawJson is already a JS object, and typeof rawJson === "string" correctly handles both cases. If the server outputs null or an empty string, the guard on Line 141 catches it.

One edge case: if MatchResultJson is undefined (template field missing), Line 139 would cause a JS error before the guard. Ensure the server always provides this field.

web/referee_panel.go (1)

178-227: Card command: no validation of args.TeamId against actual match teams in qualification mode.

In qualification mode (Line 219-226), the card is applied to whichever TeamId is sent by the client without verifying that the team actually belongs to the specified alliance or is part of the current match. A malformed or malicious WebSocket message could associate a card with an arbitrary team ID.

Since this is an admin-only endpoint (Line 77-79), the risk is low, but consider adding a check that args.TeamId is one of the current match's team IDs.

static/css/audience_display.css (3)

38-47: Class names .Dive, .Reescape, and .Wrapper use PascalCase instead of kebab-case.

Stylelint flags these (lines 38, 43, 83). While functional, PascalCase class names are unconventional in CSS and inconsistent with the rest of the selectors in this file (e.g., .score-field, .final-team-row). Consider renaming to .dive, .reescape, .wrapper (or more descriptive names like .event-brand-left, .event-brand-right).


472-490: Duplicate #leftFinalScore and #rightFinalScore selectors.

#leftFinalScore is defined at Line 472 and again at Line 485; #rightFinalScore at Line 479 and Line 488. While the rules are additive (background-color vs. padding), splitting the same selector creates maintenance confusion. Merge them into single rule blocks.

♻️ Suggested merge
-#leftFinalScore{
-  background-color: red;
-  
-}
...
-#leftFinalScore {
-  padding-right: 150px;
-}
+#leftFinalScore {
+  background-color: red;
+  padding-right: 150px;
+}

-#rightFinalScore{
-  background-color: `#2080ff`;
-}
...
-#rightFinalScore {
-  padding-left: 150px;
-}
+#rightFinalScore {
+  background-color: `#2080ff`;
+  padding-left: 150px;
+}

155-162: Commented-out .avatars block — remove dead CSS.

This block is fully commented out. If it's no longer needed with the 2026 layout, remove it to reduce file noise.

network/switch.go (1)

38-38: Hardcoded Fortinet username "admin" — not configurable.

The username is set to "admin" in NewSwitch and is not exposed through EventSettings. If a deployment uses a different Fortinet username, there's no way to configure it without code changes. Consider adding it as a parameter or settings field.

Also applies to: 48-52

static/js/match_review.js (2)

139-146: CSS attribute selectors lack value quoting — fragile with certain values.

In getInputElement (Line 141) and getSelectElement (Line 146), the [value=${value}] selector inserts the value directly without CSS quotes. While team IDs are numeric and this works in practice, any value containing spaces, brackets, or special CSS characters would break the selector silently (returning an empty jQuery set).

♻️ Safer selector construction
 const getInputElement = function (alliance, name, value) {
   let selector = `input[name=${alliance}${name}]`;
-  if (value !== undefined) selector += `[value=${value}]`;
+  if (value !== undefined) selector += `[value="${value}"]`;
   return $(selector);
 };

50-58: EndgameStatuses fallback path bypasses jQuery safety.

Line 54 constructs a raw jQuery selector $(\input[name=EndgameStatuses${htmlIdx}][value=${...}]`)as a fallback when the alliance-prefixed input isn't found. This fallback queries inputs without the alliance prefix, which could match inputs from the *other* alliance's section if both are rendered on the same page. Since the template uses{{"{{alliance}}"}}` prefix for all input names, this fallback path may never match anything useful and could be dead code.

templates/audience_display.html (1)

121-124: Inline styles on #shiftCounter duplicate CSS rules.

The style attribute on Line 121 (font-family: 'Arboto Bold'; font-size: 24px; color: #222; text-align: center;) duplicates rules already defined in audience_display.css Lines 252-258 for #shiftCounter. Inline styles override CSS and make maintenance harder. Remove the inline styles and rely on the stylesheet.

web/match_review.go (1)

87-94: Comments are in Chinese — consider translating to English for consistency.

The rest of the codebase uses English comments. Lines 87, 92, 102, 109, and 128 contain Chinese comments that should be translated for maintainability and consistency.

Also applies to: 102-103, 109-109, 128-129

plc/register_string.go (2)

1-1: Same concern: generated file edited manually.

See the same note as for plc/input_string.go — this file's header says DO NOT EDIT, but it has been manually modified.


22-22: - 0 is a no-op.

idx := int(i) - 0 is equivalent to idx := int(i). This is a harmless artifact of the stringer template, but if you're maintaining this file manually now, you could simplify it.

field/arena_notifiers.go (1)

238-245: Prefer named fields in struct literal to avoid positional fragility.

MatchTimeMessage now has 6 fields. A positional literal silently assigns wrong values if fields are reordered. Use named fields for clarity and safety.

Proposed fix
-	return MatchTimeMessage{
-		arena.MatchState,
-		int(arena.MatchTimeSec()),
-		arena.RedRealtimeScore.CurrentScore.HubActive,
-		arena.BlueRealtimeScore.CurrentScore.HubActive,
-		currentShift,
-		shiftTimeSec,
-	}
+	return MatchTimeMessage{
+		MatchState:    arena.MatchState,
+		MatchTimeSec:  int(arena.MatchTimeSec()),
+		HubActiveRed:  arena.RedRealtimeScore.CurrentScore.HubActive,
+		HubActiveBlue: arena.BlueRealtimeScore.CurrentScore.HubActive,
+		CurrentShift:  currentShift,
+		ShiftTimeSec:  shiftTimeSec,
+	}
game/ranking_fields_test.go (2)

15-69: Tests don't assert Played count or AutoPoints.

Each scenario resets rankingFields but never asserts Played increments or AutoPoints accumulation. Consider adding at least:

assert.Equal(t, 1, rankingFields.Played)
assert.Equal(t, 25, rankingFields.AutoPoints) // for the win case

This would ensure AddScoreSummary correctly tracks all ranking fields, not just the ones currently tested.


55-61: Tie test uses the same pointer for both arguments.

rankingFields.AddScoreSummary(tieScore, tieScore, false) passes the identical pointer as both "our" and "opponent" summary. This works for a tie scenario but doesn't verify the method handles distinct objects with equal scores. Consider using two separate ScoreSummary instances with the same values to avoid masking pointer-aliasing bugs in AddScoreSummary.

web/scoring_panel_test.go (1)

196-196: time.Sleep for synchronization is flaky.

Using time.Sleep(time.Millisecond * 10) to wait for async processing is a common source of test flakiness. Consider using a polling/retry loop or a channel-based synchronization mechanism if available.

templates/referee_panel.html (2)

36-44: Misleading indentation in the fouls section — easy to misread nesting.

The navigation-buttons div (line 36) and the </div> at line 44 are indented inconsistently relative to the containing <div id="fouls"> (line 31). This makes it look like the </div> at line 44 closes fouls, when it actually closes navigation-buttons. The subsequent <h3>Fouls</h3> (line 45) and foul buttons are still inside fouls but appear as if they're outside.

Fix indentation to clarify nesting
-      <div class="navigation-buttons" style="margin: 10px; display: flex; gap: 10px;">
-      <button class="btn btn-primary" onclick="window.location.href='/panels/scoring/blue_near'">
-        Blue Scoring Panel
-      </button>
-      <button class="btn btn-danger" onclick="window.location.href='/panels/scoring/red_near'">
-        Red Scoring Panel
-      </button>
-
-  </div>
+    <div class="navigation-buttons" style="margin: 10px; display: flex; gap: 10px;">
+      <button class="btn btn-primary" onclick="window.location.href='/panels/scoring/blue_near'">
+        Blue Scoring Panel
+      </button>
+      <button class="btn btn-danger" onclick="window.location.href='/panels/scoring/red_near'">
+        Red Scoring Panel
+      </button>
+    </div>

108-113: Inconsistent CSS class casing: Energized-status vs supercharged-status / traversal-status.

Line 110 uses Energized-status (capital E) while lines 111-112 use supercharged-status and traversal-status (lowercase). This inconsistency must match the selectors in referee_panel.js (line 177: .Energized-status, line 183: .supercharged-status, line 189: .traversal-status). Currently they do match, but the mixed casing is error-prone for maintenance. Consider normalizing to lowercase.

static/js/referee_panel.js (3)

124-133: Missing .catch() on fetch — network errors are silently swallowed.

The fetch("/panels/referee/foul_list") chain has no .catch() handler. If the request fails (e.g., network issue, server error), the promise rejection will go unhandled, potentially triggering an unhandledrejection event in modern browsers.

Proposed fix
     fetch("/panels/referee/foul_list")
       .then(response => response.text())
       .then(html => {
         if (html.indexOf("<!DOCTYPE") === -1 && html.indexOf("<html") === -1) {
             $("#foulList").html(html);
         } else {
             console.error("Foul list error: Received full page instead of snippet.");
         }
-      });
+      })
+      .catch(err => console.error("Failed to fetch foul list:", err));

205-208: handleScoringStatus references commented-out DOM elements.

Lines 206 and 208 update #redFarScoreStatus and #blueFarScoreStatus, but these elements are commented out in referee_panel.html (lines 25, 27). jQuery will silently no-op, but this is dead code that should be cleaned up or the HTML elements should be uncommented if still needed.


172-193: Remove camelCase property checks — only PascalCase is present in the JSON payload.

Lines 178, 184, 190 each check two casing variants (e.g., summary.EnergizedRankingPoint || summary.energizedRankingPoint). The Go backend serializes ScoreSummary fields in PascalCase by default; only EnergizedRankingPoint, SuperchargedRankingPoint, and TraversalRankingPoint will ever be present in the websocket message. The camelCase checks are dead code.

game/test_helpers.go (1)

53-58: Positional struct literals are fragile — use named fields.

RankingFields{20, 625, 90, 100, 0.254, 3, 2, 1, 0, 10} relies on exact field ordering. If RankingFields gains, removes, or reorders a field, these will silently compile with wrong values. Named fields are safer and self-documenting.

Proposed refactor
 func TestRanking1() *Ranking {
-	return &Ranking{254, 1, 0, RankingFields{20, 625, 90, 100, 0.254, 3, 2, 1, 0, 10}}
+	return &Ranking{
+		TeamId: 254, Rank: 1, PreviousRank: 0,
+		RankingFields: RankingFields{
+			RankingPoints: 20, MatchPoints: 625, AutoPoints: 90, TowerPoints: 100,
+			Random: 0.254, Wins: 3, Losses: 2, Ties: 1, Disqualifications: 0, Played: 10,
+		},
+	}
 }
plc/plc.go (1)

62-63: Typo in constant name: plcRetryIntevalSecplcRetryIntervalSec.

Fix
-	plcRetryIntevalSec = 3
+	plcRetryIntervalSec = 3

Also update the usage on line 178.

plc/plc_test.go (1)

292-300: Empty test body — consider removing or adding a t.Skip with a reason.

TestPlcInputsGameSpecific has no assertions. If it's intentionally empty for 2026, use t.Skip("No game-specific inputs in 2026") to make the intent clear and show up in test output.

templates/scoring_panel.html (3)

59-76: Large block of commented-out code should be removed or tracked.

The entire Fouls card (lines 59-76) is commented out with <!-- -->. If this is intentionally disabled for 2026, add a TODO or link to an issue. Leaving large commented-out blocks degrades readability.


131-131: transform: scale(1.5) may cause layout overflow on small screens.

Scaling the teleop fuel input group to 150% via inline CSS could cause it to overflow its container on mobile devices, despite the viewport meta tag. Consider using larger Bootstrap sizing classes (btn-xl or custom padding) instead of a CSS transform, which doesn't affect the element's layout box.


215-224: Global position and alliance variables declared with var in a <script> tag.

These are implicitly global. While scoring_panel.js likely expects them, this coupling is fragile. Consider passing them via a data-* attribute on the container div or via a config object to make the dependency explicit.

web/scoring_panel.go (2)

10-16: Remove commented-out log import and scattered debug log statements.

The commented-out //log import (line 13) and numerous //log.Printf(...) statements throughout the file are debug artifacts. They add noise and should be removed before merging.


301-306: Remove custom max function — it shadows the Go builtin.

Go 1.21 introduced max as a builtin function. This project requires Go 1.23.0, so the builtin is available. The custom max function (lines 301-306) shadows it, making code less clear and tools less useful. Replace all calls to max() with the builtin.

static/js/scoring_panel.js (3)

3-9: matchTiming is assigned without var/let/const, creating an implicit global.

This works because match_timing.js likely expects a global matchTiming, but implicit globals are error-prone and fail in strict mode. Declare it explicitly.

Suggested fix
-matchTiming = {
+var matchTiming = {
     AutoDurationSec: 15,
     WarmupDurationSec: 0,
     TeleopDurationSec: 135,

19-25: Sending a zero-delta fuel command as a "refresh" is a side-effectful workaround.

Line 23 sends { Adjustment: 0, Autonomous: true } which triggers server-side score processing and a RealtimeScoreNotifier.Notify() (since scoreChanged will be set to true on the server). While functional, this is fragile—if the server changes to skip no-ops, the refresh breaks. A dedicated "refresh" or "getScore" command would be cleaner.


77-93: alliance variable is referenced but never declared in this file.

alliance (line 78) must be injected as a global from the HTML template. This implicit dependency is fragile. Consider documenting expected globals at the top of the file, or passing them explicitly during initialization.

field/arena.go (2)

1179-1192: Sending game data packet on every DS packet cycle may be excessive.

sendGameDataPacket is called every ~500ms (the DS packet period) with the same static message for the entire teleop period. If this creates a new TCP message each time, consider sending it only once or when the message changes.


129-131: Remove deprecated rand.Seed call.

rand.Seed is deprecated since Go 1.20. The top-level rand functions are automatically seeded at program startup, so this call is unnecessary and can be removed to suppress deprecation warnings.

Proposed fix
-	// Seed the random number generator for tie-breakers
-	rand.Seed(time.Now().UnixNano())
-
 	arena.AllianceStations = make(map[string]*AllianceStation)
static/js/audience_display.js (2)

174-184: Magic number 5 for TeleopPeriod match state.

data.MatchState === 5 relies on the Go enum's integer value. If the enum order changes, this breaks silently. Define a named constant (e.g., const MATCH_STATE_TELEOP = 5;) for clarity and maintainability.


193-196: Remove commented-out old scoring code.

Lines 193-194 contain the old BargePoints-based score rendering. These are dead code artifacts.

Comment on lines 68 to +98
func mockTelnet(t *testing.T, port int, command1 *string, command2 *string) {
go func() {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
assert.Nil(t, err)
if err != nil {
return // Avoid errors during parallel tests
}
defer ln.Close()
*command1 = ""
*command2 = ""

// Fake the first connection.
// Simulate first connection (Reset)
conn1, err := ln.Accept()
assert.Nil(t, err)
conn1.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
var reader bytes.Buffer
reader.ReadFrom(conn1)
*command1 = reader.String()
conn1.Close()
if err == nil {
conn1.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
var reader bytes.Buffer
reader.ReadFrom(conn1)
*command1 = reader.String()
conn1.Close()
}

// Fake the second connection.
// Simulate second connection (Config)
conn2, err := ln.Accept()
assert.Nil(t, err)
conn2.SetReadDeadline(time.Now().Add(10 * time.Millisecond))
reader.Reset()
reader.ReadFrom(conn2)
*command2 = reader.String()
conn2.Close()
if err == nil {
conn2.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
var reader bytes.Buffer
reader.ReadFrom(conn2)
*command2 = reader.String()
conn2.Close()
}
}()
time.Sleep(100 * time.Millisecond) // Give it some time to open the socket.
time.Sleep(100 * time.Millisecond)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Mock telnet server doesn't simulate Fortinet login prompts — root cause of all pipeline failures.

The mockTelnet helper opens a plain TCP socket and immediately tries to read, but the Fortinet switch implementation in network/switch.go likely expects an interactive login prompt (e.g., a login: or Password: banner) before sending credentials and commands. Since the mock never writes anything to the connection, runCommand in the switch code is probably blocking on a prompt read, causing the 50ms read deadline in the mock to expire with empty data.

This explains all six pipeline failures — command1 and command2 are always empty strings.

To fix this, the mock needs to simulate the Fortinet telnet handshake:

🛠️ Sketch of a prompt-aware mock
-	// Simulate first connection (Reset)
 	conn1, err := ln.Accept()
 	if err == nil {
-		conn1.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
-		var reader bytes.Buffer
-		reader.ReadFrom(conn1)
-		*command1 = reader.String()
-		conn1.Close()
+		// Send login prompt and read the full command exchange
+		conn1.Write([]byte("login: "))
+		// ... read username, send password prompt, read password, then read commands
+		conn1.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
+		var reader bytes.Buffer
+		reader.ReadFrom(conn1)
+		*command1 = reader.String()
+		conn1.Close()
 	}

You'll need to inspect the actual telnet flow in switch.go's runCommand to determine the exact prompt/response sequence the mock must emulate.

🤖 Prompt for AI Agents
In `@network/switch_test.go` around lines 68 - 98, The mockTelnet helper currently
never writes any prompts, so runCommand in network/switch.go blocks waiting for
login/password prompts; update mockTelnet to emulate the Fortinet telnet
handshake by writing the expected login banner and password prompt to each
accepted connection (e.g., send "login:" and then "Password:" or whatever
runCommand expects), then read credentials sent by the client and respond with
the command prompt before allowing the test reads to capture the sent commands;
keep the existing Accept/read structure (conn1/conn2) and use conn.Write with
appropriate timing/deadlines so runCommand advances instead of timing out.

Comment on lines 398 to 412
for i, ranking := range rankings {
tbaRankings[i] = TbaRanking{
TeamKey: getTbaTeam(ranking.TeamId),
Rank: ranking.Rank,
RP: float32(ranking.RankingPoints) / float32(ranking.Played),
Coop: float32(ranking.CoopertitionPoints) / float32(ranking.Played),
Match: float32(ranking.MatchPoints) / float32(ranking.Played),
Auto: float32(ranking.AutoPoints) / float32(ranking.Played),
Barge: float32(ranking.BargePoints) / float32(ranking.Played),
Wins: ranking.Wins,
Losses: ranking.Losses,
Ties: ranking.Ties,
Dqs: ranking.Disqualifications,
Played: ranking.Played,
// 修正點 1: 使用 TowerPoints 取代 TotalTowerPoints
Tower: float32(ranking.TowerPoints) / float32(ranking.Played),
Wins: ranking.Wins,
Losses: ranking.Losses,
Ties: ranking.Ties,
Dqs: ranking.Disqualifications,
Played: ranking.Played,
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Potential division by zero if ranking.Played is 0.

Lines 402-406 divide by ranking.Played to compute per-match averages. If a team has Played == 0 (e.g., no completed matches yet), this causes a division-by-zero panic. Add a guard:

🐛 Proposed fix
 for i, ranking := range rankings {
+	played := ranking.Played
+	if played == 0 {
+		played = 1 // Avoid division by zero; averages will be 0
+	}
 	tbaRankings[i] = TbaRanking{
 		TeamKey: getTbaTeam(ranking.TeamId),
 		Rank:    ranking.Rank,
-		RP:      float32(ranking.RankingPoints) / float32(ranking.Played),
-		Match:   float32(ranking.MatchPoints) / float32(ranking.Played),
-		Auto:    float32(ranking.AutoPoints) / float32(ranking.Played),
-		Tower:  float32(ranking.TowerPoints) / float32(ranking.Played),
+		RP:      float32(ranking.RankingPoints) / float32(played),
+		Match:   float32(ranking.MatchPoints) / float32(played),
+		Auto:    float32(ranking.AutoPoints) / float32(played),
+		Tower:   float32(ranking.TowerPoints) / float32(played),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for i, ranking := range rankings {
tbaRankings[i] = TbaRanking{
TeamKey: getTbaTeam(ranking.TeamId),
Rank: ranking.Rank,
RP: float32(ranking.RankingPoints) / float32(ranking.Played),
Coop: float32(ranking.CoopertitionPoints) / float32(ranking.Played),
Match: float32(ranking.MatchPoints) / float32(ranking.Played),
Auto: float32(ranking.AutoPoints) / float32(ranking.Played),
Barge: float32(ranking.BargePoints) / float32(ranking.Played),
Wins: ranking.Wins,
Losses: ranking.Losses,
Ties: ranking.Ties,
Dqs: ranking.Disqualifications,
Played: ranking.Played,
// 修正點 1: 使用 TowerPoints 取代 TotalTowerPoints
Tower: float32(ranking.TowerPoints) / float32(ranking.Played),
Wins: ranking.Wins,
Losses: ranking.Losses,
Ties: ranking.Ties,
Dqs: ranking.Disqualifications,
Played: ranking.Played,
}
for i, ranking := range rankings {
played := ranking.Played
if played == 0 {
played = 1 // Avoid division by zero; averages will be 0
}
tbaRankings[i] = TbaRanking{
TeamKey: getTbaTeam(ranking.TeamId),
Rank: ranking.Rank,
RP: float32(ranking.RankingPoints) / float32(played),
Match: float32(ranking.MatchPoints) / float32(played),
Auto: float32(ranking.AutoPoints) / float32(played),
// 修正點 1: 使用 TowerPoints 取代 TotalTowerPoints
Tower: float32(ranking.TowerPoints) / float32(played),
Wins: ranking.Wins,
Losses: ranking.Losses,
Ties: ranking.Ties,
Dqs: ranking.Disqualifications,
Played: ranking.Played,
}
🤖 Prompt for AI Agents
In `@partner/tba.go` around lines 398 - 412, The code currently divides by
ranking.Played when populating TbaRanking (fields RP, Match, Auto, Tower),
causing a division-by-zero panic if Played == 0; fix by guarding those
calculations: before assigning to tbaRankings[i], check if ranking.Played == 0
and set RP, Match, Auto, Tower to 0.0 (or other safe default), otherwise compute
them using float32(ranking.Played); update the TbaRanking construction in the
loop that fills tbaRankings to use this guard for RP, Match, Auto, and Tower.

Comment on lines +71 to 101
// First block: Define Address 0
const (
fieldEStop input = iota
red1EStop
red1AStop
red2EStop
red2AStop
red3EStop
red3AStop
blue1EStop
blue1AStop
blue2EStop
blue2AStop
blue3EStop
blue3AStop
redConnected1
redConnected2
redConnected3
blueConnected1
blueConnected2
blueConnected3
fieldEStop input = iota // iota = 0, so fieldEStop = 0
)

// Second block: Define inputs starting at Address 20
// In a new const block, iota resets to 0
const (
red1EStop input = iota + 16 // iota = 0, value = 16
red1AStop // iota = 1, value = 21 (automatically follows +20 rule)
red2EStop // 22
red2AStop // 23
red3EStop // 24
red3AStop // 25
redConnected1 // 32
redConnected2 // 33
redConnected3 // 34
blue1EStop input = iota + 23 // 40
blue1AStop // 41
blue2EStop // 42
blue2AStop // 43
blue3EStop // 44
blue3AStop // 45
blueConnected1 // 46
blueConnected2 // 47
blueConnected3 // 48

// inputCount will automatically become the last value + 1
// Here it will be 49, used to define the array size
inputCount
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Input constant values are incorrect — causes the pipeline panic at Line 354.

The comments claim values like "21", "32", etc., but the actual iota arithmetic produces different values, AND creates large gaps in the index space:

Constant Comment claims Actual value
red1AStop 21 17
redConnected1 32 22
blue1EStop 40 32
inputCount 49 41

The gaps (indices 1–15, 25–31) mean inputCount = 41, causing ReadDiscreteInputs(0, 41) to request more inputs than the device provides, triggering the pipeline slice bounds panic at Line 354. Additionally, GetInputNames() returns 41 entries (with unnamed gap indices), failing the test that expects 19 names.

If the intent is a contiguous mapping, use explicit values or a single iota block:

Proposed fix: use explicit constants to match actual hardware addresses
-// First block: Define Address 0
-const (
-	fieldEStop input = iota // iota = 0, so fieldEStop = 0
-)
-
-// Second block: Define inputs starting at Address 20
-// In a new const block, iota resets to 0
-const (
-	red1EStop      input = iota + 16 // iota = 0, value = 16
-	red1AStop                        // iota = 1, value = 21 (automatically follows +20 rule)
-	red2EStop                        // 22
-	red2AStop                        // 23
-	red3EStop                        // 24
-	red3AStop                        // 25
-	redConnected1                    // 32
-	redConnected2                    // 33
-	redConnected3                    // 34
-	blue1EStop     input = iota + 23 // 40
-	blue1AStop                       // 41
-	blue2EStop                       // 42
-	blue2AStop                       // 43
-	blue3EStop                       // 44
-	blue3AStop                       // 45
-	blueConnected1                   // 46
-	blueConnected2                   // 47
-	blueConnected3                   // 48
-
-	// inputCount will automatically become the last value + 1
-	// Here it will be 49, used to define the array size
-	inputCount
-)
+const (
+	fieldEStop    input = iota
+	red1EStop
+	red1AStop
+	red2EStop
+	red2AStop
+	red3EStop
+	red3AStop
+	redConnected1
+	redConnected2
+	redConnected3
+	blue1EStop
+	blue1AStop
+	blue2EStop
+	blue2AStop
+	blue3EStop
+	blue3AStop
+	blueConnected1
+	blueConnected2
+	blueConnected3
+	inputCount
+)

If specific Modbus addresses are required (e.g., fieldEStop at address 0 and red1EStop at address 16), then a separate address-mapping table should be used instead of overloading the array index with the hardware address. The array index for plc.inputs must be contiguous.

🤖 Prompt for AI Agents
In `@plc/plc.go` around lines 71 - 101, The constants for input indices (e.g.,
red1EStop, red1AStop, redConnected1, blue1EStop, inputCount) are non-contiguous
due to resetting iota and inline arithmetic, causing plc.inputs size
misalignment and ReadDiscreteInputs/ GetInputNames failures; fix by creating a
single contiguous index scheme for the plc input array (either put all inputs in
one const block using iota in order or assign explicit sequential integer
indices) and if you must preserve Modbus hardware addresses keep them in a
separate mapping table (e.g., hardwareAddressByInput) so plc.inputs uses
contiguous indices and inputCount reflects the true array length used by
ReadDiscreteInputs and GetInputNames.

Comment on lines 203 to 219
if (currentMatch.Type === matchTypePlayoff) {
redCoral = data.Red.ScoreSummary.NumCoral;
blueCoral = data.Blue.ScoreSummary.NumCoral;
redCoral = data.Red.ScoreSummary.NumCoral;
blueCoral = data.Blue.ScoreSummary.NumCoral;
} else {
redCoral = `${data.Red.ScoreSummary.NumCoralLevels}/${data.Red.ScoreSummary.NumCoralLevelsGoal}`;
blueCoral = `${data.Blue.ScoreSummary.NumCoralLevels}/${data.Blue.ScoreSummary.NumCoralLevelsGoal}`;
// 1. 計算紅藍兩隊的目標值
const redFuelGoal = getFuelGoal(data.Red.ScoreSummary);
const blueFuelGoal = getFuelGoal(data.Blue.ScoreSummary);

// 2. 使用樣板字面值 (Template Literals) 正確帶入變數
// 修正了你紅藍兩隊後綴不統一的問題(原本藍隊用 NumCoralLevelsGoal)
redCoral = `${data.Red.ScoreSummary.TotalFuelPoints}/${redFuelGoal}`;
blueCoral = `${data.Blue.ScoreSummary.TotalFuelPoints}/${blueFuelGoal}`;
}
$(`#${redSide}Coral`).text(redCoral);
$(`#${redSide}Algae`).text(data.Red.ScoreSummary.NumAlgae);
$(`#${blueSide}Coral`).text(blueCoral);
$(`#${blueSide}Algae`).text(data.Blue.ScoreSummary.NumAlgae);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

References to removed fields: NumCoral and NumAlgae will be undefined.

ScoreSummary no longer has NumCoral (line 204-205) or NumAlgae (lines 217-219) in the 2026 model. These will render as undefined in the UI. The playoff path on line 204-205 should use the appropriate 2026 field (e.g., TotalFuelPoints), and the NumAlgae display should be updated or removed.

🤖 Prompt for AI Agents
In `@static/js/audience_display.js` around lines 203 - 219, The playoff branch
currently reads deprecated fields (data.Red.ScoreSummary.NumCoral / NumAlgae)
causing undefined UI; change the playoff path that checks currentMatch.Type ===
matchTypePlayoff to use the 2026 fields (e.g., use
data.Red.ScoreSummary.TotalFuelPoints and data.Blue.ScoreSummary.TotalFuelPoints
for redCoral/blueCoral) and remove or replace uses of
data.*.ScoreSummary.NumAlgae when setting $(`#${redSide}Algae`) and
$(`#${blueSide}Algae`) (either map to the new algae field name in ScoreSummary
or omit the algae element update if the field no longer exists), keeping the
element IDs redSideCoral, blueSideCoral, redSideAlgae, blueSideAlgae unchanged.

Comment on lines +24 to +27
<img class="Dive" src="/static\img\frc_rebuilt.png" />
<!--<span>{{.EventSettings.Name}}</span>-->
<span id="matchName"></span>
<img class="Reescape" src="/static\img\frc_age.png" />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Backslashes in image src paths will cause 404 errors.

Lines 24 and 27 use Windows-style backslashes in URLs:

  • src="/static\img\frc_rebuilt.png"
  • src="/static\img\frc_age.png"

Web URLs require forward slashes. Most browsers and web servers will not resolve these paths, resulting in broken images on the audience display.

🐛 Fix path separators
-<img class="Dive" src="/static\img\frc_rebuilt.png" />
+<img class="Dive" src="/static/img/frc_rebuilt.png" />
 <!--<span>{{.EventSettings.Name}}</span>-->
 <span id="matchName"></span>
-<img class="Reescape" src="/static\img\frc_age.png" />
+<img class="Reescape" src="/static/img/frc_age.png" />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<img class="Dive" src="/static\img\frc_rebuilt.png" />
<!--<span>{{.EventSettings.Name}}</span>-->
<span id="matchName"></span>
<img class="Reescape" src="/static\img\frc_age.png" />
<img class="Dive" src="/static/img/frc_rebuilt.png" />
<!--<span>{{.EventSettings.Name}}</span>-->
<span id="matchName"></span>
<img class="Reescape" src="/static/img/frc_age.png" />
🤖 Prompt for AI Agents
In `@templates/audience_display.html` around lines 24 - 27, The img tags for the
audience display (classes "Dive" and "Reescape" in
templates/audience_display.html) use Windows backslashes in their src
attributes; update the src values for the <img class="Dive" src=...> and <img
class="Reescape" src=...> to use forward slashes (e.g., /static/img/...) so the
browser can resolve the image URLs correctly.

Comment on lines +71 to +88
$(document).ready(function() {
// 從 Go 後端傳入的 JSON 字串解析出原始資料
matchResult = JSON.parse({{.MatchResultJson}});
var rawJson = {{.MatchResultJson}};

if (!rawJson || rawJson === "") {
matchResult = {
RedScore: { Fouls: [], RobotsBypassed: [false,false,false], AutoTowerLevel1: [false,false,false], EndgameStatuses: [0,0,0] },
BlueScore: { Fouls: [], RobotsBypassed: [false,false,false], AutoTowerLevel1: [false,false,false], EndgameStatuses: [0,0,0] }
};
} else {
try {
matchResult = (typeof rawJson === "string") ? JSON.parse(rawJson) : rawJson;
} catch (e) {
console.error("MatchResultJson parse error:", e);
matchResult = { RedScore: {Fouls:[]}, BlueScore: {Fouls:[]} };
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

JSON.parse on line 73 will throw before the guarded parse on lines 82-87 is reached.

Line 73 unconditionally calls JSON.parse({{.MatchResultJson}}). If MatchResultJson is empty or malformed, this throws an uncaught exception and the try/catch block below never executes. Line 73 should be removed — the guarded block starting at line 76 already handles all cases.

🐛 Proposed fix
  $(document).ready(function() {
-    // 從 Go 後端傳入的 JSON 字串解析出原始資料
-    matchResult = JSON.parse({{.MatchResultJson}});
     var rawJson = {{.MatchResultJson}};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$(document).ready(function() {
// 從 Go 後端傳入的 JSON 字串解析出原始資料
matchResult = JSON.parse({{.MatchResultJson}});
var rawJson = {{.MatchResultJson}};
if (!rawJson || rawJson === "") {
matchResult = {
RedScore: { Fouls: [], RobotsBypassed: [false,false,false], AutoTowerLevel1: [false,false,false], EndgameStatuses: [0,0,0] },
BlueScore: { Fouls: [], RobotsBypassed: [false,false,false], AutoTowerLevel1: [false,false,false], EndgameStatuses: [0,0,0] }
};
} else {
try {
matchResult = (typeof rawJson === "string") ? JSON.parse(rawJson) : rawJson;
} catch (e) {
console.error("MatchResultJson parse error:", e);
matchResult = { RedScore: {Fouls:[]}, BlueScore: {Fouls:[]} };
}
}
$(document).ready(function() {
var rawJson = {{.MatchResultJson}};
if (!rawJson || rawJson === "") {
matchResult = {
RedScore: { Fouls: [], RobotsBypassed: [false,false,false], AutoTowerLevel1: [false,false,false], EndgameStatuses: [0,0,0] },
BlueScore: { Fouls: [], RobotsBypassed: [false,false,false], AutoTowerLevel1: [false,false,false], EndgameStatuses: [0,0,0] }
};
} else {
try {
matchResult = (typeof rawJson === "string") ? JSON.parse(rawJson) : rawJson;
} catch (e) {
console.error("MatchResultJson parse error:", e);
matchResult = { RedScore: {Fouls:[]}, BlueScore: {Fouls:[]} };
}
}
🤖 Prompt for AI Agents
In `@templates/match_review.html` around lines 71 - 88, Remove the unconditional
JSON.parse call that assigns matchResult (the JSON.parse({{.MatchResultJson}})
at the top) and instead assign rawJson = {{.MatchResultJson}} only, then rely on
the existing guarded parsing block that checks rawJson, uses try/catch and sets
the default matchResult; update references to matchResult so the guarded branch
initializes it (symbols: matchResult, rawJson, JSON.parse, the try/catch block
and the $(document).ready handler).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
field/team_sign.go (1)

53-53: ⚠️ Potential issue | 🟡 Minor

teamSignYear is still set to 2025 for a 2026 game update.

This constant is displayed on the physical team signs (Lines 254, 291). It should be updated to 2026 to match the game season.

-	teamSignYear                     = 2025
+	teamSignYear                     = 2026
🧹 Nitpick comments (1)
field/team_sign.go (1)

204-204: Nit: Remove the change-log comment.

// Removed BargePoints logic reads as a commit note rather than code documentation. The git history captures what was removed; inline comments should describe what the code does now, not what it used to do.

…lity, including fixing scoring logic, adding locking to prevent data races, and adjusting test cases to reflect the latest rules of the game.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
field/arena_test.go (1)

250-262: ⚠️ Potential issue | 🟡 Minor

Manually set HubActive = false is silently overwritten by updateHubStatus() inside handlePlcInputOutput().

handlePlcInputOutput() calls updateHubStatus() under the ScoreMu lock, which resets HubActive based on match time. With MatchStartTime at its zero value and MatchState = TeleopPeriod, MatchTimeSec() returns a very large number, making timeLeft negative — so the shift logic is skipped and both hubs default to active = true. The manual HubActive = false setup at line 252 is a no-op.

The test still passes (Blue PLC count is zero), but the Blue HubActive = false precondition is never actually exercised. Consider using time-based simulation (as done in TestPlcFuelScoring_InactiveHub) to reliably test hub-inactive behaviour.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/arena_test.go` around lines 250 - 262, The test is silently overwritten
because updateHubStatus() (called inside handlePlcInputOutput() under ScoreMu)
recalculates HubActive based on MatchTimeSec() from MatchStartTime/MatchState;
to fix, make the test drive match time like TestPlcFuelScoring_InactiveHub
instead of manually flipping HubActive: set MatchStartTime and MatchState so
MatchTimeSec() returns the intended elapsed time (or use the same time-based
helper used in TestPlcFuelScoring_InactiveHub) before calling
handlePlcInputOutput(), then assert Blue.HubActive is false; reference
handlePlcInputOutput, updateHubStatus, HubActive, MatchStartTime, MatchState,
and MatchTimeSec when locating the code to change.
🧹 Nitpick comments (6)
plc/fake_modbus_client_test.go (1)

28-29: Optional: add bounds guard before slicing to prevent panic on oversized quantity.

client.inputs[0:quantity], client.registers[0:quantity] (Line 55), and the coil loop in WriteMultipleCoils (Lines 41–43) will all panic at runtime if quantity > 64. This was a latent issue at size 32 too, so it isn't new, and test callers currently stay within bounds — but a cheap guard would make the fake more robust against accidental future misuse.

🛡️ Proposed guard for ReadDiscreteInputs (apply similarly to the other two sites)
 func (client *FakeModbusClient) ReadDiscreteInputs(address, quantity uint16) (results []byte, err error) {
 	if address != 0 {
 		return nil, errors.New("unexpected address")
 	}
+	if int(quantity) > len(client.inputs) {
+		return nil, errors.New("quantity exceeds input array size")
+	}
 	if client.returnError {
 		return nil, errors.New("dummy error")
 	}
 	inputsToRead := client.inputs[0:quantity]
 	return boolToByte(inputsToRead), nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plc/fake_modbus_client_test.go` around lines 28 - 29, The slicing operations
in ReadDiscreteInputs (client.inputs[0:quantity]), the
ReadHoldingRegisters/ReadInputRegisters use of client.registers[0:quantity], and
the coil loop in WriteMultipleCoils can panic when quantity > slice length; add
a bounds guard in each of these functions (e.g., in ReadDiscreteInputs,
ReadHoldingRegisters/ReadInputRegisters, and WriteMultipleCoils) that checks if
quantity is larger than len(client.inputs) or len(client.registers) and either
return a clear error (preferred) or clamp quantity to the available length
before slicing/looping to prevent runtime panics.
field/arena.go (2)

1302-1303: Remove the commented-out arena.ScoreMu.Unlock() — dead code artifact.

The defer arena.ScoreMu.Unlock() at line 1251 already handles the unlock. The commented-out manual call at line 1303 is a leftover that misleads readers into thinking an explicit unlock is still needed here.

♻️ Suggested fix
-	// Release lock after all score modifications and notifications are triggered.
-	//arena.ScoreMu.Unlock()
-
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/arena.go` around lines 1302 - 1303, Remove the dead commented-out
unlock call — the leftover "//arena.ScoreMu.Unlock()" should be deleted because
the mutex is already released by the earlier defer arena.ScoreMu.Unlock() in the
same function; locate the commented line referencing arena.ScoreMu.Unlock() and
simply remove that comment so readers aren't misled about needing an explicit
unlock.

848-857: isOddShift is true for even-numbered shift indices — misleading name and incorrect comment.

shiftIndex%2 == 0 is true when shiftIndex is 0, 2, 4 (mathematically even indices). Naming it isOddShift and commenting "Index 0 & 2 are Odd" is the opposite of the mathematical truth, making the XOR logic below it confusing to reason about.

♻️ Suggested rename
-			// Logic toggle: Index 0 & 2 are Odd, Index 1 & 3 are Even.
-			isOddShift := (shiftIndex%2 == 0)
+			// Even shift indices (0, 2, 4…) → loser alliance is active first.
+			isEvenShiftIndex := (shiftIndex % 2 == 0)

-			redActive = redWinsAuto != isOddShift
+			redActive = redWinsAuto != isEvenShiftIndex
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/arena.go` around lines 848 - 857, The variable isOddShift and its
comment are inverted: change isOddShift to isEvenShift (or invert the boolean)
where shiftIndex is computed (shiftIndex := int((startTime - timeLeft) /
shiftDuration)), update the comment to correctly state "Index 0, 2, 4 are even",
and then adjust the XOR logic that sets redActive and blueActive to use
isEvenShift (or flip the comparison) so the intent remains the same (e.g.,
redActive = redWinsAuto != isEvenShift) while keeping redActive, blueActive,
shiftIndex, startTime, timeLeft, and shiftDuration names intact.
game/score.go (2)

43-54: AutoFuelPoints assignment multiplies by 1 — redundant.

score.AutoFuelCount * 1 is a no-op; assign the value directly.

♻️ Suggested fix
-	summary.AutoFuelPoints = score.AutoFuelCount * 1
+	summary.AutoFuelPoints = score.AutoFuelCount
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@game/score.go` around lines 43 - 54, Replace the redundant multiplication by
1 when setting the autonomous fuel points: change the assignment to set
summary.AutoFuelPoints directly from score.AutoFuelCount (i.e., remove the "*
1"), leaving the rest of the Auto points calculation (the loop over
score.AutoTowerLevel1 contributing to summary.AutoTowerPoints and the final
summary.AutoPoints aggregation) unchanged.

100-113: Redundant = false assignments in RP else-branches.

summary is new(ScoreSummary), so all bool fields default to false. The explicit else { ... = false } assignments are unnecessary, and the style is inconsistent with TraversalRankingPoint (lines 117-119) which has no else branch.

♻️ Suggested cleanup
 	if totalFuel >= EnergizedFuelThreshold {
 		summary.EnergizedRankingPoint = true
-	} else {
-		summary.EnergizedRankingPoint = false
 	}

 	if totalFuel >= SuperchargedFuelThreshold {
 		summary.SuperchargedRankingPoint = true
-	} else {
-		summary.SuperchargedRankingPoint = false
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@game/score.go` around lines 100 - 113, Remove the redundant else-branches
that assign false to summary boolean fields: when computing totalFuel and
setting summary.EnergizedRankingPoint and summary.SuperchargedRankingPoint,
delete the else { ... = false } blocks and only set the field to true when the
threshold is met (leave the default false otherwise) so behavior matches the
pattern used by TraversalRankingPoint; update the code around totalFuel,
EnergizedFuelThreshold, SuperchargedFuelThreshold, summary.EnergizedRankingPoint
and summary.SuperchargedRankingPoint accordingly.
field/arena_test.go (1)

272-274: TestPlcFieldEStop is an empty placeholder that tests nothing.

Remove it or implement the intended field E-stop coverage. An empty test silently passes and adds false confidence in coverage reports.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/arena_test.go` around lines 272 - 274, TestPlcFieldEStop is an empty
test that gives false confidence; locate the TestPlcFieldEStop function and
either remove it or implement real assertions: set up the minimal PlcField
instance or fixture the test suite uses, invoke the E-stop behavior (call the
method/function that triggers field E-stop on the PlcField instance), and assert
expected outcomes (e.g., error return, changed state/flag, emitted event or
side-effect) and any cleanup; ensure you use the existing constructors/helpers
used elsewhere in arena_test.go so the test compiles and verifies actual E-stop
behavior instead of being a no-op.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@arduino/driverStation.py`:
- Line 75: The Modbus server is currently bound to all interfaces via
StartTcpServer(..., address=("0.0.0.0", 502)); change this to bind to a specific
interface used by the FRC field network (e.g., replace "0.0.0.0" with a
configured FIELD_NETWORK_IP or a constant like FIELD_INTERFACE_IP) or read the
bind address from configuration/environment so the server only listens on the
intended interface rather than 0.0.0.0.
- Around line 46-48: The code currently writes button state into Modbus coils by
calling store.setValues(1, i, [val]) after computing val = 1 if raw_val == 0
else 0; change this to write into discrete inputs (FC=2) instead of coils so
sensor readings are read-only from the network—i.e., replace the store.setValues
call to use function code 2 (store.setValues(2, i, [val])) for the same index i
and value.
- Line 77: Remove the stray undefined expression "EOF" in driverStation.py (it
appears after GPIO.cleanup()), which causes NameError on clean shutdown; locate
the EOF near the shutdown path used by StartTcpServer/cleanup logic and delete
it (or replace it with a no-op like "return" if needed to make control flow
explicit) so GPIO.cleanup() can complete without raising an exception.
- Line 6: The import of StartTcpServer is using the removed pymodbus.server.sync
path; replace "from pymodbus.server.sync import StartTcpServer" with "from
pymodbus.server import StartTcpServer" (or the equivalent direct import exposed
by pymodbus 3.x) in arduino/driverStation.py and make the same change in
modbus_plc.py so the StartTcpServer symbol is imported from pymodbus.server to
avoid ModuleNotFoundError with pymodbus ≥ 3.0.

In `@arduino/modbus_plc.py`:
- Around line 55-62: The loop handling OUTPUT_PINS in the block that reads
target_states currently inverts Modbus coil logic (treats 0 as ON); change the
conditional in that loop so a Modbus coil value of 1 drives GPIO.output(pin,
GPIO.HIGH) and appends "ON" to output_status, and any non-1 value drives
GPIO.output(pin, GPIO.LOW) and appends "OFF". Update the code that uses
OUTPUT_PINS, target_states, GPIO.output and output_status accordingly; if the
hardware is truly active-low, document that in comments rather than keeping
inverted logic in the loop.
- Line 5: The import of StartTcpServer is using the removed submodule path
`pymodbus.server.sync`; update the import to use the new location
`StartTcpServer` from `pymodbus.server` (same change applied as in
driverStation.py) so the module loads correctly on pymodbus 3.x; search for the
symbol StartTcpServer in arduino/modbus_plc.py and replace the old import source
accordingly.

In `@field/arena.go`:
- Around line 775-778: Remove the duplicate unprotected calls to
updateHubStatus() and updateGameSpecificMessage() from Update() so those writes
to CurrentScore.HubActive and GameSpecificMessage only occur once under lock;
ensure the single protected invocation remains in handlePlcInputOutput() inside
the arena.ScoreMu.Lock()/Unlock() region (keep the calls where you currently
have them there). Also handle the Plc.IsEnabled() == false path by adding an
explicit guarded block before the early return in handlePlcInputOutput() that
acquires ScoreMu and calls updateHubStatus() and updateGameSpecificMessage() so
the updates are always performed under ScoreMu even when the PLC is disabled.
- Around line 130-131: Remove the deprecated rand.Seed call: delete the
rand.Seed(time.Now().UnixNano()) invocation (symbol: rand.Seed and the use of
time.Now) since Go auto-seeds the global source; keep the "math/rand" import
because rand.Intn is used elsewhere (symbol: rand.Intn), and also remove the
"time" import if it becomes unused after deleting the seed call.

In `@field/team_sign_test.go`:
- Line 18: Remove the hard-coded defer os.Remove("test.db") in team_sign_test.go
and instead rely on the test helper or t.Cleanup for teardown: locate the test
that calls setupTestArena(t) and either let setupTestArena register cleanup (if
it already does) or create a unique DB path using t.TempDir() and register
t.Cleanup(func(){ os.Remove(dbPath) }) so you don’t double-delete or remove the
wrong file; update both occurrences that currently call os.Remove("test.db") to
use the safer pattern tied to the test’s lifecycle (refer to setupTestArena and
the test functions in team_sign_test.go).
- Around line 27-39: The inline comment block showing expected scores is stale
and contradicts the assertions for generateInMatchTeamRearText; update the
comments to match the asserted expected outputs (or change the assertions if
those are wrong). Specifically, replace the R095/B130 totals and their breakdown
with the actual expected totals used in the assertions (e.g., expect "R105-B220
25" for red perspective and "B220-R105 50" for blue perspective) so the comment
block accurately documents the assertions around generateInMatchTeamRearText.

In `@game/score.go`:
- Around line 60-71: The comment above the loop over score.EndgameStatuses is
inaccurate: it only mentions Level 2 and Level 3 but the switch also handles
EndgameLevel1 (10 pts). Update the comment to list all three levels and their
points (Level 1 = 10 pts, Level 2 = 20 pts, Level 3 = 30 pts) so it matches the
switch handling (EndgameLevel1, EndgameLevel2, EndgameLevel3) and the fact that
summary.EndgameTowerPoints is incremented accordingly.
- Around line 82-88: The current loop in game/score.go adds 30 to
summary.EndgameTowerPoints for every opponent foul with rule.RuleNumber ==
"G420", causing unbounded stacking; change the handling so the G420 compensation
is applied at most once per match: when encountering G420, either set a one-time
flag (e.g., endgameProtectionApplied) or set summary.EndgameTowerPoints =
max(summary.EndgameTowerPoints, 30) instead of +=, so subsequent G420
occurrences do not add additional points. Ensure the fix references the existing
rule.RuleNumber check and summary.EndgameTowerPoints update.

---

Outside diff comments:
In `@field/arena_test.go`:
- Around line 250-262: The test is silently overwritten because
updateHubStatus() (called inside handlePlcInputOutput() under ScoreMu)
recalculates HubActive based on MatchTimeSec() from MatchStartTime/MatchState;
to fix, make the test drive match time like TestPlcFuelScoring_InactiveHub
instead of manually flipping HubActive: set MatchStartTime and MatchState so
MatchTimeSec() returns the intended elapsed time (or use the same time-based
helper used in TestPlcFuelScoring_InactiveHub) before calling
handlePlcInputOutput(), then assert Blue.HubActive is false; reference
handlePlcInputOutput, updateHubStatus, HubActive, MatchStartTime, MatchState,
and MatchTimeSec when locating the code to change.

---

Duplicate comments:
In `@arduino/modbus_plc.py`:
- Line 88: There is a stray undefined identifier EOF in arduino/modbus_plc.py
that will raise NameError during shutdown after GPIO.cleanup(); remove the bare
EOF token (or, if it was intended as a literal, replace it with a proper quoted
string) so the shutdown/cleanup code that calls GPIO.cleanup() no longer
references an undefined name; search for the EOF token near the GPIO.cleanup()
or shutdown handler and delete or properly quote it to fix the Ruff F821 error.

---

Nitpick comments:
In `@field/arena_test.go`:
- Around line 272-274: TestPlcFieldEStop is an empty test that gives false
confidence; locate the TestPlcFieldEStop function and either remove it or
implement real assertions: set up the minimal PlcField instance or fixture the
test suite uses, invoke the E-stop behavior (call the method/function that
triggers field E-stop on the PlcField instance), and assert expected outcomes
(e.g., error return, changed state/flag, emitted event or side-effect) and any
cleanup; ensure you use the existing constructors/helpers used elsewhere in
arena_test.go so the test compiles and verifies actual E-stop behavior instead
of being a no-op.

In `@field/arena.go`:
- Around line 1302-1303: Remove the dead commented-out unlock call — the
leftover "//arena.ScoreMu.Unlock()" should be deleted because the mutex is
already released by the earlier defer arena.ScoreMu.Unlock() in the same
function; locate the commented line referencing arena.ScoreMu.Unlock() and
simply remove that comment so readers aren't misled about needing an explicit
unlock.
- Around line 848-857: The variable isOddShift and its comment are inverted:
change isOddShift to isEvenShift (or invert the boolean) where shiftIndex is
computed (shiftIndex := int((startTime - timeLeft) / shiftDuration)), update the
comment to correctly state "Index 0, 2, 4 are even", and then adjust the XOR
logic that sets redActive and blueActive to use isEvenShift (or flip the
comparison) so the intent remains the same (e.g., redActive = redWinsAuto !=
isEvenShift) while keeping redActive, blueActive, shiftIndex, startTime,
timeLeft, and shiftDuration names intact.

In `@game/score.go`:
- Around line 43-54: Replace the redundant multiplication by 1 when setting the
autonomous fuel points: change the assignment to set summary.AutoFuelPoints
directly from score.AutoFuelCount (i.e., remove the "* 1"), leaving the rest of
the Auto points calculation (the loop over score.AutoTowerLevel1 contributing to
summary.AutoTowerPoints and the final summary.AutoPoints aggregation) unchanged.
- Around line 100-113: Remove the redundant else-branches that assign false to
summary boolean fields: when computing totalFuel and setting
summary.EnergizedRankingPoint and summary.SuperchargedRankingPoint, delete the
else { ... = false } blocks and only set the field to true when the threshold is
met (leave the default false otherwise) so behavior matches the pattern used by
TraversalRankingPoint; update the code around totalFuel, EnergizedFuelThreshold,
SuperchargedFuelThreshold, summary.EnergizedRankingPoint and
summary.SuperchargedRankingPoint accordingly.

In `@plc/fake_modbus_client_test.go`:
- Around line 28-29: The slicing operations in ReadDiscreteInputs
(client.inputs[0:quantity]), the ReadHoldingRegisters/ReadInputRegisters use of
client.registers[0:quantity], and the coil loop in WriteMultipleCoils can panic
when quantity > slice length; add a bounds guard in each of these functions
(e.g., in ReadDiscreteInputs, ReadHoldingRegisters/ReadInputRegisters, and
WriteMultipleCoils) that checks if quantity is larger than len(client.inputs) or
len(client.registers) and either return a clear error (preferred) or clamp
quantity to the available length before slicing/looping to prevent runtime
panics.

import time
import threading
import Jetson.GPIO as GPIO
from pymodbus.server.sync import StartTcpServer
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for requirements files and pymodbus version pins
find . -type f \( -name "requirements*.txt" -o -name "setup.py" -o -name "setup.cfg" -o -name "pyproject.toml" -o -name "Pipfile" -o -name "poetry.lock" \) | head -20

Repository: Team254/cheesy-arena

Length of output: 46


🏁 Script executed:

#!/bin/bash
# Search for pymodbus version specifications
rg "pymodbus" --type-add "config:*.{txt,cfg,toml,py}" -t config -B1 -A1

Repository: Team254/cheesy-arena

Length of output: 622


🏁 Script executed:

#!/bin/bash
# Check for other pymodbus imports in the codebase to see version compatibility context
rg "from pymodbus" --type py -A1 -B1

Repository: Team254/cheesy-arena

Length of output: 622


🏁 Script executed:

#!/bin/bash
# List all files in the repo root and arduino directory to find dependency files
ls -la | head -30
echo "---"
ls -la arduino/ | head -30

Repository: Team254/cheesy-arena

Length of output: 2205


🏁 Script executed:

#!/bin/bash
# Search for any file mentioning version constraints
rg "pymodbus" --type-add "all:*" -i -B2 -A2 | head -100

Repository: Team254/cheesy-arena

Length of output: 123


🌐 Web query:

pymodbus 3.x import StartTcpServer API change sync submodule

💡 Result:

In pymodbus 3.x, the old “split” import paths (notably ...sync and ...asynchronous) were removed/reshuffled. Use the top-level server (and client) packages instead.

✅ Correct imports (pymodbus 3.x)

Sync-style (blocking) helper:

from pymodbus.server import StartTcpServer

PyModbus 3.x documents pymodbus.server.StartTcpServer(...) as the supported entry point. [1]

Asyncio server (if you want non-blocking / async app integration):
Use the asyncio server APIs documented under the 3.x server docs (all servers are implemented with asyncio; the “sync” server is an interface layer). [1]

What broke (common old imports)

These were used in older versions and commonly fail on 3.x:

  • from pymodbus.server.asynchronous import StartTcpServer (Twisted-era / older async layout; removed around 3.0 development) [2]
  • from pymodbus.client.sync import ModbusTcpClient (the client.sync submodule no longer exists; import from pymodbus.client) [3]
  • from pymodbus.server.sync import StartTcpServer (seen in older docs/branches; not the current 3.x public import path) [4]

Replacement pattern (client side)

from pymodbus.client import ModbusTcpClient  # or ModbusSerialClient, etc.

This is the documented replacement for pymodbus.client.sync. [3]

Sources:
[1] PyModbus 3.x server docs (pymodbus.server.StartTcpServer) (pymodbus.readthedocs.io)
[2] Discussion of removed pymodbus.server.asynchronous imports / Twisted removal (stackoverflow.com)
[3] pymodbus.client.sync no longer exists; use pymodbus.client (python-forum.io)
[4] Older docs showing pymodbus.server.sync import path (pymodbus-n.readthedocs.io)

Citations:


pymodbus.server.sync was removed in pymodbus 3.x — this import will fail.

from pymodbus.server.sync import StartTcpServer is incompatible with pymodbus ≥ 3.0. The sync submodule was removed; StartTcpServer is now exposed directly from pymodbus.server. This will throw ModuleNotFoundError at startup if pymodbus 3.x is installed.

Proposed fix
-from pymodbus.server.sync import StartTcpServer
+from pymodbus.server import StartTcpServer

Note: modbus_plc.py has the same import issue and needs the same fix.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from pymodbus.server.sync import StartTcpServer
from pymodbus.server import StartTcpServer
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@arduino/driverStation.py` at line 6, The import of StartTcpServer is using
the removed pymodbus.server.sync path; replace "from pymodbus.server.sync import
StartTcpServer" with "from pymodbus.server import StartTcpServer" (or the
equivalent direct import exposed by pymodbus 3.x) in arduino/driverStation.py
and make the same change in modbus_plc.py so the StartTcpServer symbol is
imported from pymodbus.server to avoid ModuleNotFoundError with pymodbus ≥ 3.0.

Comment on lines +46 to +48
val = 1 if raw_val == 0 else 0

store.setValues(1, i, [val])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Button state written to coils (FC=1) instead of discrete inputs (FC=2).

store.setValues(1, i, [val]) uses function code 1 (coils), which is writable by any remote Modbus client. Sensor/button readings should use FC=2 (discrete inputs) which is read-only from the network, preventing any external client from inadvertently overwriting live GPIO readings.

🛠️ Proposed fix
-                store.setValues(1, i, [val])
+                store.setValues(2, i, [val])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val = 1 if raw_val == 0 else 0
store.setValues(1, i, [val])
val = 1 if raw_val == 0 else 0
store.setValues(2, i, [val])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@arduino/driverStation.py` around lines 46 - 48, The code currently writes
button state into Modbus coils by calling store.setValues(1, i, [val]) after
computing val = 1 if raw_val == 0 else 0; change this to write into discrete
inputs (FC=2) instead of coils so sensor readings are read-only from the
network—i.e., replace the store.setValues call to use function code 2
(store.setValues(2, i, [val])) for the same index i and value.

t.daemon = True
t.start()

StartTcpServer(context, address=("0.0.0.0", 502))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Modbus server bound to all interfaces (0.0.0.0:502).

If the Jetson has multiple network interfaces, this exposes the server beyond the intended FRC field network. Bind to a specific interface address if only the field network should have access.

🧰 Tools
🪛 Ruff (0.15.1)

[error] 75-75: Possible binding to all interfaces

(S104)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@arduino/driverStation.py` at line 75, The Modbus server is currently bound to
all interfaces via StartTcpServer(..., address=("0.0.0.0", 502)); change this to
bind to a specific interface used by the FRC field network (e.g., replace
"0.0.0.0" with a configured FIELD_NETWORK_IP or a constant like
FIELD_INTERFACE_IP) or read the bind address from configuration/environment so
the server only listens on the intended interface rather than 0.0.0.0.


StartTcpServer(context, address=("0.0.0.0", 502))
GPIO.cleanup()
EOF No newline at end of file
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

EOF is still present — the fix from commit 50da2b3 was incomplete.

The prior review noted this exact line and was marked resolved, but EOF remains. It's an undefined name (Ruff F821) and a useless expression (Ruff B018). While StartTcpServer blocks and this line is rarely reached, any clean server shutdown will execute GPIO.cleanup() and then evaluate EOF, raising a NameError instead of exiting cleanly.

🛠️ Proposed fix
     StartTcpServer(context, address=("0.0.0.0", 502))
     GPIO.cleanup()
-EOF
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
EOF
🧰 Tools
🪛 Ruff (0.15.1)

[warning] 77-77: Found useless expression. Either assign it to a variable or remove it.

(B018)


[error] 77-77: Undefined name EOF

(F821)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@arduino/driverStation.py` at line 77, Remove the stray undefined expression
"EOF" in driverStation.py (it appears after GPIO.cleanup()), which causes
NameError on clean shutdown; locate the EOF near the shutdown path used by
StartTcpServer/cleanup logic and delete it (or replace it with a no-op like
"return" if needed to make control flow explicit) so GPIO.cleanup() can complete
without raising an exception.

import time
import threading
import Jetson.GPIO as GPIO
from pymodbus.server.sync import StartTcpServer
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and examine the import
find . -name "modbus_plc.py" -type f 2>/dev/null | head -20

Repository: Team254/cheesy-arena

Length of output: 87


🏁 Script executed:

# Search for pymodbus version constraints in the project
find . -name "requirements*.txt" -o -name "setup.py" -o -name "pyproject.toml" -o -name "Pipfile" 2>/dev/null | head -20

Repository: Team254/cheesy-arena

Length of output: 46


🏁 Script executed:

# Check for any driverStation.py mentioned in the review to understand the context
find . -name "driverStation.py" -type f 2>/dev/null

Repository: Team254/cheesy-arena

Length of output: 90


🏁 Script executed:

# Read the modbus_plc.py file to see the actual import
cat -n ./arduino/modbus_plc.py | head -30

Repository: Team254/cheesy-arena

Length of output: 1181


🏁 Script executed:

# Read driverStation.py to check if it has the same import
cat -n ./arduino/driverStation.py | head -30

Repository: Team254/cheesy-arena

Length of output: 1112


🏁 Script executed:

# Search for pymodbus version mentions anywhere in the project
rg "pymodbus" -i --type python | head -20

Repository: Team254/cheesy-arena

Length of output: 94


🏁 Script executed:

# Search for all pymodbus references without type filter
rg "pymodbus" | head -30

Repository: Team254/cheesy-arena

Length of output: 449


🌐 Web query:

pymodbus 3.0 StartTcpServer import path documentation

💡 Result:

In pymodbus 3.0.x, StartTcpServer is exported from the top-level server module:

from pymodbus.server import StartTcpServer

The asyncio variant is:

from pymodbus.server import StartAsyncTcpServer

These functions are documented under the pymodbus.server “Server” API (where pymodbus.server.StartTcpServer and pymodbus.server.StartAsyncTcpServer are listed). [1]

If you’re upgrading from older examples that use paths like pymodbus.server.sync, pymodbus.server.async, or pymodbus.server.asynchronous (Twisted-era), those are from older versions and will not work in 3.x. [2]

Sources:
[1] PyModbus Server API docs (pymodbus.server.StartTcpServer, StartAsyncTcpServer) https://pymodbus.readthedocs.io/en/v3.8.0/source/server.html
[2] Discussion of removed older async/Twisted imports in 3.0+ (example of import errors) https://stackoverflow.com/questions/75398565/pymodbus-package-import-error-modulenotfound


Same broken pymodbus.server.sync import as driverStation.py.

In pymodbus 3.x, StartTcpServer is at pymodbus.server, not pymodbus.server.sync. The .sync submodule no longer exists, so this import fails at startup on pymodbus ≥ 3.0.

🛠️ Proposed fix
-from pymodbus.server.sync import StartTcpServer
+from pymodbus.server import StartTcpServer
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from pymodbus.server.sync import StartTcpServer
from pymodbus.server import StartTcpServer
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@arduino/modbus_plc.py` at line 5, The import of StartTcpServer is using the
removed submodule path `pymodbus.server.sync`; update the import to use the new
location `StartTcpServer` from `pymodbus.server` (same change applied as in
driverStation.py) so the module loads correctly on pymodbus 3.x; search for the
symbol StartTcpServer in arduino/modbus_plc.py and replace the old import source
accordingly.

Comment on lines +775 to +778
// 2026 REBUILT: Update Hub Status and Game Data
arena.updateHubStatus()
arena.updateGameSpecificMessage()

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

updateHubStatus and updateGameSpecificMessage are called unprotected in Update() and again under ScoreMu in handlePlcInputOutput() — data race + redundant work.

Two distinct problems arise from this double invocation:

  1. Data race: The calls at lines 775-777 write to CurrentScore.HubActive (and GameSpecificMessage) from the arena loop goroutine without holding ScoreMu. Concurrent websocket handler goroutines that read or mutate CurrentScore can race against these unprotected writes. The ScoreMu lock added to handlePlcInputOutput() only covers the second invocation.

  2. Unprotected path when PLC is disabled: When arena.Plc.IsEnabled() returns false, handlePlcInputOutput() returns immediately at line 1187 — the protected calls at lines 1252-1254 are never reached. Only the unprotected calls in Update() execute, leaving HubActive and GameSpecificMessage written without any lock.

  3. Redundant work: Both call sites execute on every arena loop tick; the first call's results are immediately overwritten by the second.

The fix is to remove the duplicate calls from Update() and ensure that the hub-status / game-message update always runs under ScoreMu, with explicit handling for the PLC-disabled case if needed.

🔒 Suggested fix
-	// 2026 REBUILT: Update Hub Status and Game Data
-	arena.updateHubStatus()
-	arena.updateGameSpecificMessage()
-
 	// Send a match tick notification if passing an integer second threshold...

And in handlePlcInputOutput(), keep the protected calls as-is:

arena.ScoreMu.Lock()
defer arena.ScoreMu.Unlock()
arena.updateHubStatus()           // single, protected invocation
arena.updateGameSpecificMessage() // single, protected invocation

For the PLC-disabled path, add an explicit block before the early return:

 func (arena *Arena) handlePlcInputOutput() {
+	arena.ScoreMu.Lock()
+	arena.updateHubStatus()
+	arena.updateGameSpecificMessage()
+	arena.ScoreMu.Unlock()
+
 	if !arena.Plc.IsEnabled() {
 		return
 	}

Also applies to: 1249-1254

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/arena.go` around lines 775 - 778, Remove the duplicate unprotected
calls to updateHubStatus() and updateGameSpecificMessage() from Update() so
those writes to CurrentScore.HubActive and GameSpecificMessage only occur once
under lock; ensure the single protected invocation remains in
handlePlcInputOutput() inside the arena.ScoreMu.Lock()/Unlock() region (keep the
calls where you currently have them there). Also handle the Plc.IsEnabled() ==
false path by adding an explicit guarded block before the early return in
handlePlcInputOutput() that acquires ScoreMu and calls updateHubStatus() and
updateGameSpecificMessage() so the updates are always performed under ScoreMu
even when the PLC is disabled.


func TestTeamSign_GenerateInMatchRearText(t *testing.T) {
arena := setupTestArena(t)
defer os.Remove("test.db")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded "test.db" cleanup is fragile.

os.Remove("test.db") hard-codes the temporary database path. If setupTestArena(t) uses a different path or already registers a t.Cleanup for the database, this removes the wrong file (silently no-ops) or double-deletes. Prefer relying on the cleanup registered by the test helper, or use t.TempDir() for isolation.

Also applies to: 44-44

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/team_sign_test.go` at line 18, Remove the hard-coded defer
os.Remove("test.db") in team_sign_test.go and instead rely on the test helper or
t.Cleanup for teardown: locate the test that calls setupTestArena(t) and either
let setupTestArena register cleanup (if it already does) or create a unique DB
path using t.TempDir() and register t.Cleanup(func(){ os.Remove(dbPath) }) so
you don’t double-delete or remove the wrong file; update both occurrences that
currently call os.Remove("test.db") to use the safer pattern tied to the test’s
lifecycle (refer to setupTestArena and the test functions in team_sign_test.go).

Comment on lines +27 to +39
// 預期分數計算:
// Red Score: 95 (Auto:25 + Teleop:20 + End:50)
// Blue Score: 130 (Auto:20 + Teleop:40 + End:70)
// Red Fuel: 25
// Blue Fuel: 50

// Check timeout mode.
arena.FieldReset = true
arena.MatchState = TimeoutActive
assertSign("23:45", whiteColor, "Field Break: 23:45")
// 測試紅隊視角: "倒數時間 R分數-B分數 R球數"
// R095-B130 25
assert.Equal(t, "01:23 R105-B220 25", generateInMatchTeamRearText(arena, true, "01:23"))

// Check blank mode.
arena.AllianceStationDisplayMode = "blank"
assertSign(" ", whiteColor, "")

// Check alliance selection.
arena.AllianceStationDisplayMode = "logo"
arena.AudienceDisplayMode = "allianceSelection"
arena.AllianceSelectionShowTimer = false
assertSign(" ", whiteColor, "")
arena.AllianceSelectionShowTimer = true
assertSign("23:45", whiteColor, "")
arena.AllianceStationDisplayMode = "blank"
assertSign(" ", whiteColor, "")
// 測試藍隊視角: "倒數時間 B分數-R分數 B球數"
// B130-R095 50
assert.Equal(t, "01:23 B220-R105 50", generateInMatchTeamRearText(arena, false, "01:23"))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale score comments contradict the actual assertions.

Lines 28-31 document expected totals of R095 and B130, but the assertions at lines 35 and 39 check "R105-B220". Either the expected values or the comments are wrong, which makes the test misleading.

Update the comments to reflect the actual expected values (or correct the assertion if the comments capture the intended behavior):

-	// Red Score: 95 (Auto:25 + Teleop:20 + End:50)
-	// Blue Score: 130 (Auto:20 + Teleop:40 + End:70)
-	// Red Fuel: 25
-	// Blue Fuel: 50
-	// 測試紅隊視角: "倒數時間 R分數-B分數 R球數"
-	// R095-B130 25
+	// Red Score: 105 | Blue Score: 220
+	// Red Fuel: 25   | Blue Fuel: 50
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/team_sign_test.go` around lines 27 - 39, The inline comment block
showing expected scores is stale and contradicts the assertions for
generateInMatchTeamRearText; update the comments to match the asserted expected
outputs (or change the assertions if those are wrong). Specifically, replace the
R095/B130 totals and their breakdown with the actual expected totals used in the
assertions (e.g., expect "R105-B220 25" for red perspective and "B220-R105 50"
for blue perspective) so the comment block accurately documents the assertions
around generateInMatchTeamRearText.

Comment on lines +60 to 71
// Endgame Tower: Level 2 (20pts), Level 3 (30pts)
for _, status := range score.EndgameStatuses {
switch status {
case EndgameParked:
summary.BargePoints += 2
case EndgameShallowCage:
summary.BargePoints += 6
case EndgameDeepCage:
summary.BargePoints += 12
case EndgameLevel1:
summary.EndgameTowerPoints += 10
case EndgameLevel2:
summary.EndgameTowerPoints += 20
case EndgameLevel3:
summary.EndgameTowerPoints += 30
default:
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Incomplete comment omits Level 1 from endgame tower point description.

The comment on line 60 only lists Level 2 and Level 3, but EndgameLevel1 (10 pts) is also handled in the switch below.

📝 Suggested fix
-	// Endgame Tower: Level 2 (20pts), Level 3 (30pts)
+	// Endgame Tower: Level 1 (10pts), Level 2 (20pts), Level 3 (30pts)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Endgame Tower: Level 2 (20pts), Level 3 (30pts)
for _, status := range score.EndgameStatuses {
switch status {
case EndgameParked:
summary.BargePoints += 2
case EndgameShallowCage:
summary.BargePoints += 6
case EndgameDeepCage:
summary.BargePoints += 12
case EndgameLevel1:
summary.EndgameTowerPoints += 10
case EndgameLevel2:
summary.EndgameTowerPoints += 20
case EndgameLevel3:
summary.EndgameTowerPoints += 30
default:
}
}
// Endgame Tower: Level 1 (10pts), Level 2 (20pts), Level 3 (30pts)
for _, status := range score.EndgameStatuses {
switch status {
case EndgameLevel1:
summary.EndgameTowerPoints += 10
case EndgameLevel2:
summary.EndgameTowerPoints += 20
case EndgameLevel3:
summary.EndgameTowerPoints += 30
default:
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@game/score.go` around lines 60 - 71, The comment above the loop over
score.EndgameStatuses is inaccurate: it only mentions Level 2 and Level 3 but
the switch also handles EndgameLevel1 (10 pts). Update the comment to list all
three levels and their points (Level 1 = 10 pts, Level 2 = 20 pts, Level 3 = 30
pts) so it matches the switch handling (EndgameLevel1, EndgameLevel2,
EndgameLevel3) and the fact that summary.EndgameTowerPoints is incremented
accordingly.

Comment on lines 82 to 88
if rule != nil {
// Check for the opponent fouls that automatically trigger a ranking point.
if rule.IsRankingPoint {
switch rule.RuleNumber {
case "G410":
summary.CoralBonusRankingPoint = true
case "G418":
summary.BargeBonusRankingPoint = true
case "G428":
summary.BargeBonusRankingPoint = true
}
// Handle special rule G420 (Endgame Protection)
// Rule: If the opponent commits G420, our team gets Level 3 Climb points (30 points)
if rule.RuleNumber == "G420" {
summary.EndgameTowerPoints += 30
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

G420 bonus stacks unboundedly — 30 pts added per foul occurrence.

The loop applies EndgameTowerPoints += 30 for every individual G420 foul committed by the opponent. If the opponent accumulates multiple G420 calls, the compensation points multiply accordingly (e.g., 3 fouls × 30 pts = 90 extra tower points) — on top of whatever climb points the alliance already earned. This appears unintentional; the intent seems to be a one-time maximum-level-climb compensation.

🐛 Suggested fix — apply G420 compensation at most once
+	g420Applied := false
 	for _, foul := range opponentScore.Fouls {
 		summary.FoulPoints += foul.PointValue()
 		if foul.IsMajor {
 			summary.NumOpponentMajorFouls++
 		}
 		rule := foul.Rule()
 		if rule != nil {
-			if rule.RuleNumber == "G420" {
-				summary.EndgameTowerPoints += 30
+			if rule.RuleNumber == "G420" && !g420Applied {
+				summary.EndgameTowerPoints += 30
+				g420Applied = true
 			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@game/score.go` around lines 82 - 88, The current loop in game/score.go adds
30 to summary.EndgameTowerPoints for every opponent foul with rule.RuleNumber ==
"G420", causing unbounded stacking; change the handling so the G420 compensation
is applied at most once per match: when encountering G420, either set a one-time
flag (e.g., endgameProtectionApplied) or set summary.EndgameTowerPoints =
max(summary.EndgameTowerPoints, 30) instead of +=, so subsequent G420
occurrences do not add additional points. Ensure the fix references the existing
rule.RuleNumber check and summary.EndgameTowerPoints update.

…s ​​in the conforming states to conform to the latest logic.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
field/driver_station_connection_test.go (1)

31-31: ⚠️ Potential issue | 🔴 Critical

Update four assertions — AutoDurationSec changed from 15 → 20.

The constant was updated to 20 in game/match_timing.go. With the test timing of Auto = 20 s, Pause = 2 s, Teleop = 140 s:

  • Line 31 (PreMatch): Change byte(15) to byte(20)
  • Line 122 (AutoPeriod, 4 s elapsed): Change byte(11) to byte(16)
  • Line 130 (TeleopPeriod, 33 s elapsed): Change byte(124) to byte(129) (20 + 140 + 2 − 33)
  • Line 133 (TeleopPeriod, 150 s elapsed): Change byte(7) to byte(12) (20 + 140 + 2 − 150)

Line 126 is correct as-is (returns TeleopDurationSec = 140, independent of Auto duration).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/driver_station_connection_test.go` at line 31, Update four assertions
in the driver station connection test to reflect the new AutoDurationSec = 20:
in the PreMatch assertion that checks data[21] replace byte(15) with byte(20);
in the AutoPeriod case (4 s elapsed) replace byte(11) with byte(16); in the
TeleopPeriod case (33 s elapsed) replace byte(124) with byte(129); and in the
TeleopPeriod case (150 s elapsed) replace byte(7) with byte(12). Leave the
assertion that returns TeleopDurationSec (byte matching TeleopDurationSec = 140)
unchanged; these changes align the test with the updated AutoDurationSec in
game/match_timing.go.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@field/driver_station_connection_test.go`:
- Line 27: Remove the debug print statement fmt.Printf("Full Packet: %x\n",
data) which floods test output; either delete it or replace it with t.Logf("Full
Packet: %x", data) so the packet hex is only shown on test failure, and ensure
the test has access to *testing.T (e.g., use t.Logf inside the test function
that defines data).

---

Outside diff comments:
In `@field/driver_station_connection_test.go`:
- Line 31: Update four assertions in the driver station connection test to
reflect the new AutoDurationSec = 20: in the PreMatch assertion that checks
data[21] replace byte(15) with byte(20); in the AutoPeriod case (4 s elapsed)
replace byte(11) with byte(16); in the TeleopPeriod case (33 s elapsed) replace
byte(124) with byte(129); and in the TeleopPeriod case (150 s elapsed) replace
byte(7) with byte(12). Leave the assertion that returns TeleopDurationSec (byte
matching TeleopDurationSec = 140) unchanged; these changes align the test with
the updated AutoDurationSec in game/match_timing.go.

defer dsConn.close()

data := dsConn.encodeControlPacket(arena)
fmt.Printf("Full Packet: %x\n", data)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove debug print before merging.

fmt.Printf("Full Packet: %x\n", data) is a debug artifact that will produce noisy output on every test run / CI pipeline.

🗑️ Proposed fix
-	fmt.Printf("Full Packet: %x\n", data)

If full-packet tracing is desired for ongoing diagnostics, consider using t.Logf instead, which only surfaces output on test failure:

+	t.Logf("Full Packet: %x", data)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fmt.Printf("Full Packet: %x\n", data)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@field/driver_station_connection_test.go` at line 27, Remove the debug print
statement fmt.Printf("Full Packet: %x\n", data) which floods test output; either
delete it or replace it with t.Logf("Full Packet: %x", data) so the packet hex
is only shown on test failure, and ensure the test has access to *testing.T
(e.g., use t.Logf inside the test function that defines data).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
templates/announcer_display_score_posted.html (2)

32-49: ⚠️ Potential issue | 🟠 Major

Score breakdown section is now empty — add 2026 scoring rows.

The <!--/--> block removes all breakdown rows, leaving the "Score" <h4> followed immediately by only Foul Points. Announcers see no meaningful point breakdown. The PR's stated intent is to modify the announcer display to reflect 2026 scoring (AutoFuelPoints, TeleopFuelPoints, TotalFuelPoints, AutoTowerPoints, EndgameTowerPoints, TotalTowerPoints, etc.), but none of those replacement rows have been added here.

The commented-out block should be replaced with the new 2026 score category rows, e.g.:

🔧 Suggested replacement (adjust field names to match actual score_summary.go fields)
-<!--
-<div class="row justify-content-center">
-  <div class="col-sm-6">Auto Leave Points</div>
-  <div class="col-sm-4">{{.summary.LeavePoints}}</div>
-</div>
-<div class="row justify-content-center">
-  <div class="col-sm-6">Coral Points</div>
-  <div class="col-sm-4">{{.summary.CoralPoints}}</div>
-</div>
-<div class="row justify-content-center">
-  <div class="col-sm-6">Algae Points</div>
-  <div class="col-sm-4">{{.summary.AlgaePoints}}</div>
-</div>
-<div class="row justify-content-center">
-  <div class="col-sm-6">Barge Points</div>
-  <div class="col-sm-4">{{.summary.BargePoints}}</div>
-</div>
--->
+<div class="row justify-content-center">
+  <div class="col-sm-6">Auto Fuel Points</div>
+  <div class="col-sm-4">{{.summary.AutoFuelPoints}}</div>
+</div>
+<div class="row justify-content-center">
+  <div class="col-sm-6">Teleop Fuel Points</div>
+  <div class="col-sm-4">{{.summary.TeleopFuelPoints}}</div>
+</div>
+<div class="row justify-content-center">
+  <div class="col-sm-6">Auto Tower Points</div>
+  <div class="col-sm-4">{{.summary.AutoTowerPoints}}</div>
+</div>
+<div class="row justify-content-center">
+  <div class="col-sm-6">Endgame Tower Points</div>
+  <div class="col-sm-4">{{.summary.EndgameTowerPoints}}</div>
+</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@templates/announcer_display_score_posted.html` around lines 32 - 49, Remove
the HTML comment block and replace it with the 2026 scoring rows so the
announcer shows the new breakdown; add Bootstrap rows using the template fields
(e.g., .summary.AutoFuelPoints, .summary.TeleopFuelPoints,
.summary.TotalFuelPoints, .summary.AutoTowerPoints, .summary.EndgameTowerPoints,
.summary.TotalTowerPoints, plus existing .summary.FoulPoints and any other
fields from score_summary.go such as .summary.TotalScore) so each category has a
label column and a value column similar to the original pattern (two divs per
row: "col-sm-6" label and "col-sm-4" value). Ensure field names match the actual
struct in score_summary.go.

54-66: ⚠️ Potential issue | 🟠 Major

Update template to use 2026 RP field names.

The template references AutoBonusRankingPoint, CoralBonusRankingPoint, and BargeBonusRankingPoint—2025 field names that no longer exist in the ScoreSummary struct. These should be replaced with the 2026 fields: EnergizedRankingPoint, SuperchargedRankingPoint, and TraversalRankingPoint. Missing fields will silently render as "No" regardless of actual match results.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@templates/announcer_display_score_posted.html` around lines 54 - 66, The
template is using old 2025 ScoreSummary fields (AutoBonusRankingPoint,
CoralBonusRankingPoint, BargeBonusRankingPoint) which no longer exist; update
the three conditional checks in announcer_display_score_posted.html to use the
2026 fields EnergizedRankingPoint, SuperchargedRankingPoint, and
TraversalRankingPoint respectively (keep the surrounding divs and the existing
if/else rendering logic and the playoffMatch guard unchanged so the display
still shows "Yes"/"No" based on the new fields).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@templates/announcer_display_score_posted.html`:
- Around line 32-49: Remove the HTML comment block and replace it with the 2026
scoring rows so the announcer shows the new breakdown; add Bootstrap rows using
the template fields (e.g., .summary.AutoFuelPoints, .summary.TeleopFuelPoints,
.summary.TotalFuelPoints, .summary.AutoTowerPoints, .summary.EndgameTowerPoints,
.summary.TotalTowerPoints, plus existing .summary.FoulPoints and any other
fields from score_summary.go such as .summary.TotalScore) so each category has a
label column and a value column similar to the original pattern (two divs per
row: "col-sm-6" label and "col-sm-4" value). Ensure field names match the actual
struct in score_summary.go.
- Around line 54-66: The template is using old 2025 ScoreSummary fields
(AutoBonusRankingPoint, CoralBonusRankingPoint, BargeBonusRankingPoint) which no
longer exist; update the three conditional checks in
announcer_display_score_posted.html to use the 2026 fields
EnergizedRankingPoint, SuperchargedRankingPoint, and TraversalRankingPoint
respectively (keep the surrounding divs and the existing if/else rendering logic
and the playoffMatch guard unchanged so the display still shows "Yes"/"No" based
on the new fields).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants