diff --git a/components/mission-control.tsx b/components/mission-control.tsx
index 18e2944..a579712 100644
--- a/components/mission-control.tsx
+++ b/components/mission-control.tsx
@@ -13,6 +13,7 @@ import { CrewPanel } from '@/components/crew-panel'
import { EventLogPanel } from '@/components/event-log'
import { LocalSystemPanel } from '@/components/local-system'
import { MissionPanel } from '@/components/missions'
+import { NextTurnButton } from '@/components/next-turn-button'
import { ShipSystemsPanel } from '@/components/ship-systems'
import { SkillTreePanel } from '@/components/skill-tree'
import { WorldMapPanel } from '@/components/world-map'
@@ -62,7 +63,12 @@ export function MissionControl() {
// #38 — admin DB CRUD overlay, opened from the header.
const [adminOpen, setAdminOpen] = useState(false)
- useEffect(() => {
+ // #44 — the four mount fetches, extracted into a callback so the NEXT TURN button (5b) can
+ // re-run them after /api/turn: a tick can change all four snapshots (the turn always; inventory
+ // on an explore resolve, system on a jump resolve, the roster on a recruit, tech on a research
+ // completion) — a strict superset of refreshSystem, which stays for the jump callback. Same
+ // failure policy as the mount fetch — keep the old state on error.
+ const refreshAll = useCallback(() => {
let cancelled = false
fetch('/api/ship')
.then(async (res) => {
@@ -105,6 +111,9 @@ export function MissionControl() {
}
}, [])
+ // The effect returns refreshAll's cleanup, so unmount still cancels the in-flight setStates.
+ useEffect(() => refreshAll(), [refreshAll])
+
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(id)
@@ -548,6 +557,11 @@ export function MissionControl() {
SYS ONLINE
+
+ {/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it
+ stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes
+ the snapshots this shell owns) */}
+
)
}
diff --git a/components/next-turn-button.tsx b/components/next-turn-button.tsx
new file mode 100644
index 0000000..9d5d10e
--- /dev/null
+++ b/components/next-turn-button.tsx
@@ -0,0 +1,54 @@
+'use client'
+
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+
+// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from
+// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell
+// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an
+// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an
+// in-flight request, which the handler ignores. `disabled` is never set, so the cva
+// disabled:opacity-40 styling never applies either.
+
+export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
+ const [inFlight, setInFlight] = useState(false)
+ const [error, setError] = useState(null)
+
+ const tick = () => {
+ if (inFlight) return // the only guard — a transient, never a game-state disable
+ setInFlight(true)
+ setError(null)
+ fetch('/api/turn', { method: 'POST' })
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ onTicked() // the same app-wide snapshot refresh the shell uses after every other action
+ })
+ .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
+ .finally(() => setInFlight(false))
+ }
+
+ return (
+