Compare commits

...
5 Commits
6 changed files with 77 additions and 22 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## v0.14.0 — 2026-10-06 12:50
- The NEXT TURN button now lives inside the right pane (full width of the pane, larger) instead of floating as a fixed bottom-right overlay — it can no longer be covered by the active-tasks strip (#52).
- The button grew from the SM to the MD size and spans the whole pane content width; it pins to the pane bottom when the content is short and scrolls with it when the cargo list is long. The `TURN <n>` counter, the EXEC styling and the in-flight behaviour are unchanged.
- E2E: new spec pins the button full-width in the right pane (≥ 280px, within ±8px of the CARGO HOLD panel) and asserts its bounding box does not intersect the active-tasks strip's while a task is queued.
## v0.13.0 — 2026-10-06 12:17 ## v0.13.0 — 2026-10-06 12:17
- Jump spread limit (#51): jumping into an undiscovered system now mints a seeded-random 1–4 new systems (was a fixed 4). Each leg can instead attach an already-known system (closing a loop) with probability 0.35, so the chart grows slower and more circular. A frontier guarantee ensures at least one new system is always minted. - Jump spread limit (#51): jumping into an undiscovered system now mints a seeded-random 1–4 new systems (was a fixed 4). Each leg can instead attach an already-known system (closing a loop) with probability 0.35, so the chart grows slower and more circular. A frontier guarantee ensures at least one new system is always minted.
- `ringEdges` small-n fix: n=1 no longer emits a `[1,1]` self-loop, n=2 no longer emits a duplicate edge. n≥3 output is byte-identical to the legacy wheel. - `ringEdges` small-n fix: n=1 no longer emits a `[1,1]` self-loop, n=2 no longer emits a duplicate edge. n≥3 output is byte-identical to the legacy wheel.
+11 -7
View File
@@ -529,6 +529,14 @@ export function MissionControl() {
)} )}
</PanelContent> </PanelContent>
</Panel> </Panel>
{/* #52 — the manual tick control (5b), moved in from the fixed bottom-right overlay:
last child of the pane's flex column — full width, size MD, pinned to the pane
bottom when the content is short (the wrapper's marginTop:auto) and scrolling
with the pane when the cargo list is long. In normal flow, the active-tasks strip
can no longer paint over it; the pane (unlike the tab content) is outside Radix's
unmount, so it stays visible on every tab. */}
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
</div> </div>
</div> </div>
</div> </div>
@@ -567,13 +575,9 @@ export function MissionControl() {
<Badge variant="ACTIVE">SYS ONLINE</Badge> <Badge variant="ACTIVE">SYS ONLINE</Badge>
</footer> </footer>
{/* #44 — the manual tick control (5b): fixed bottom-right, outside the tab grid so it {/* #44 — the active-tasks strip: fixed bottom-left, outside the tab grid so it stays
stays visible on every tab (Radix unmounts inactive tabs — the button only refreshes visible on every tab. (#52 moved the NEXT TURN button out of this fixed-overlay area
the snapshots this shell owns) */} and into the right pane, so the two can no longer overlap.) Data: the shell's system
<NextTurnButton turn={ship?.turn ?? null} onTicked={refreshAll} />
{/* #44 — the active-tasks strip: fixed bottom-left (same mount convention as the button —
outside the tab grid so it stays visible on every tab). Data: the shell's system
snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on snapshot (JC-15) — refreshAll re-runs /api/system on every turn, so it updates on
every shell refresh with no fetch of its own. */} every shell refresh with no fetch of its own. */}
<TaskStrip tasks={system?.tasks ?? []} /> <TaskStrip tasks={system?.tasks ?? []} />
+14 -13
View File
@@ -3,12 +3,16 @@
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
// NEXT TURN — the manual tick control (5b): a fixed bottom-right button + the current turn from // NEXT TURN — the manual tick control (5b): a normal-flow block in the right pane (#52) carrying
// the ship snapshot the shell holds. One click = POST /api/turn (no body); on success the shell // the current turn from the ship snapshot the shell holds. One click = POST /api/turn (no body);
// re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by game state — an // on success the shell re-fetches its snapshots. Review Focus 4: the button is NEVER disabled by
// empty queue is a FREE SKIP (the route always returns 200) — and the only transient is an // game state — an empty queue is a FREE SKIP (the route always returns 200) — and the only
// in-flight request, which the handler ignores. `disabled` is never set, so the cva // transient is an in-flight request, which the handler ignores. `disabled` is never set, so the
// disabled:opacity-40 styling never applies either. // cva disabled:opacity-40 styling never applies either.
// #52 — the fixed bottom-right overlay became the right pane's last flex child (mission-control):
// full width, size MD, pinned to the pane bottom when the content is short (marginTop:auto) and
// scrolling with the pane when the cargo list is long. No position/zIndex any more — so the
// active-tasks strip can no longer paint over it.
export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) { export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTicked: () => void }) {
const [inFlight, setInFlight] = useState(false) const [inFlight, setInFlight] = useState(false)
@@ -30,14 +34,11 @@ export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTick
return ( return (
<div <div
style={{ style={{
position: 'fixed',
right: '1rem',
bottom: '1rem',
zIndex: 60, // above the header/footer's 50
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'flex-end', gap: '0.5rem',
gap: '0.375rem', width: '100%',
marginTop: 'auto',
}} }}
> >
{error && ( {error && (
@@ -46,7 +47,7 @@ export function NextTurnButton({ turn, onTicked }: { turn: number | null; onTick
<span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}> <span style={{ fontSize: '0.62rem', color: 'var(--text-muted)', letterSpacing: '0.12em' }}>
TURN {turn ?? '—'} TURN {turn ?? '—'}
</span> </span>
<Button variant="EXEC" size="SM" onClick={tick}> <Button variant="EXEC" size="MD" className="w-full" onClick={tick}>
NEXT TURN NEXT TURN
</Button> </Button>
</div> </div>
+2 -1
View File
@@ -2,7 +2,8 @@
import type { QueueDto } from '@/lib/game/save' import type { QueueDto } from '@/lib/game/save'
// TASK STRIP — the save's task queue, fixed bottom-left next to the NEXT TURN button (#44). // TASK STRIP — the save's task queue, fixed bottom-left (#44); the NEXT TURN button moved into
// the right pane in #52, so the two can no longer overlap.
// The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING // The array arrives pre-ordered from the snapshot builder (Task 6: ACTIVE block, then PENDING
// block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows, // block, each by (queuedAtTurn, id)) — the strip renders it as-is, dimming the PENDING rows,
// and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on // and never fetches or re-sorts. Empty queue → null, no box at all. The shell re-renders it on
+44
View File
@@ -1046,3 +1046,47 @@ test('NEXT TURN with an empty queue is a free skip — +1 turn, the strip stays
expect(after.turn).toBe(T + 1); expect(after.turn).toBe(T + 1);
expect(after.tasks.length).toBe(0); expect(after.tasks.length).toBe(0);
}); });
test('NEXT TURN button is full-width in the right pane and not covered by the task strip (#52)', async ({ page }) => {
await drainQueue(page);
const world = (await (await page.request.get('/api/world')).json()) as WorldStateQ;
const currentId = world.systems.find((s) => s.current)!.id;
const edge = world.edges.find(([a, b]) => a === currentId || b === currentId)!;
const targetId = edge[0] === currentId ? edge[1] : edge[0];
const { crew } = (await (await page.request.get('/api/crew')).json()) as { crew: CrewDto[]; tasks: QueueTaskDto[] };
expect(crew.length).toBeGreaterThanOrEqual(1);
// Queue a one-person JUMP and deliberately do not tick it (the #44 jump scenarios' pattern,
// the in-transit 409 one leaves it that way on purpose): the strip renders from the shell's
// system snapshot — the same queue — so a non-empty queue keeps ACTIVE TASKS visible all test.
const res = await page.request.post('/api/jump', { data: { systemId: targetId, leadCrewId: crew[0].id, helperCrewIds: [] } });
expect(res.status()).toBe(200);
const queued = (await res.json()) as WorldStateQ;
expect(queued.tasks.find((t) => t.type === 'JUMP')!.status).toBe('ACTIVE');
await page.goto('/');
await expect(page.getByText('ACTIVE TASKS', { exact: true })).toBeVisible(); // the strip is up
// Two rects intersect when their x AND y ranges overlap (touching edges do not count).
type Box = { x: number; y: number; width: number; height: number };
const intersects = (a: Box, b: Box) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
const buttonBox = await page.getByRole('button', { name: 'NEXT TURN' }).boundingBox();
expect(buttonBox).not.toBeNull();
// The strip's root div is the parent of its 'ACTIVE TASKS' label (components/task-strip.tsx).
const stripBox = await page.getByText('ACTIVE TASKS', { exact: true }).locator('xpath=..').boundingBox();
expect(stripBox).not.toBeNull();
// The CARGO HOLD panel: the Panel root is the h3 heading's grandparent div (components/ui/panel.tsx).
const panelBox = await page.getByRole('heading', { name: 'CARGO HOLD // INVENTORY' }).locator('xpath=ancestor::div[2]').boundingBox();
expect(panelBox).not.toBeNull();
// (a) The button spans the right pane's content width — >= 280 on its own, and within ±8px of
// the CARGO HOLD panel, a sibling full-width element in the same pane column.
expect(buttonBox!.width).toBeGreaterThanOrEqual(280);
expect(Math.abs(buttonBox!.width - panelBox!.width)).toBeLessThanOrEqual(8);
// (b) The button's box does not intersect the active-tasks strip's box.
expect(intersects(buttonBox!, stripBox!)).toBe(false);
await drainQueue(page); // leave the queue clean for the next scenario
});
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "space-wanderer", "name": "space-wanderer",
"version": "0.13.0", "version": "0.14.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",