feat(climate-sim): ✨ Add seed input validation props and climate simulation stats dashboard charts
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
20aefb9c36
commit
dab2755634
2 changed files with 9 additions and 203 deletions
|
|
@ -1,198 +0,0 @@
|
|||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { ReactElement, KeyboardEvent, ClipboardEvent } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import type { EasterEggSeed } from '@magic-civ/engine-ts'
|
||||
|
||||
const DEBOUNCE_MS = 700
|
||||
|
||||
// ── props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SeedInputProps {
|
||||
value: number
|
||||
onChange: (seed: number) => void
|
||||
easterEggs?: Record<string, EasterEggSeed>
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function seedToDigits(seed: number): string[] {
|
||||
return seed.toString().padStart(6, '0').slice(0, 6).split('')
|
||||
}
|
||||
|
||||
function digitsToSeed(digits: string[]): number {
|
||||
return parseInt(digits.join(''), 10) || 1
|
||||
}
|
||||
|
||||
function digitFromKey(e: KeyboardEvent<HTMLInputElement>): string | null {
|
||||
if (e.key.length === 1 && e.key >= '0' && e.key <= '9') return e.key
|
||||
const m = e.code.match(/^Numpad(\d)$/)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// ── component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SeedInput({ value, onChange, easterEggs }: SeedInputProps): ReactElement {
|
||||
const refs = useRef<Array<HTMLInputElement | null>>([null, null, null, null, null, null])
|
||||
const [localDigits, setLocalDigits] = useState<string[]>(() => seedToDigits(value))
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isEditing = useRef(false)
|
||||
|
||||
// Sync from parent only when not actively editing
|
||||
useEffect(() => {
|
||||
if (!isEditing.current) {
|
||||
setLocalDigits(seedToDigits(value))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||
}, [])
|
||||
|
||||
const localSeed = digitsToSeed(localDigits)
|
||||
const easterEgg = easterEggs?.[String(localSeed)]
|
||||
|
||||
const commit = useCallback((digits: string[]): void => {
|
||||
isEditing.current = true
|
||||
setLocalDigits(digits)
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
isEditing.current = false
|
||||
onChange(digitsToSeed(digits))
|
||||
}, DEBOUNCE_MS)
|
||||
}, [onChange])
|
||||
|
||||
const moveTo = useCallback((i: number): void => {
|
||||
const el = refs.current[i]
|
||||
if (!el) return
|
||||
el.focus()
|
||||
el.setSelectionRange(0, 1)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>, i: number): void => {
|
||||
const digit = digitFromKey(e)
|
||||
if (digit !== null) {
|
||||
e.preventDefault()
|
||||
const next = [...localDigits]
|
||||
next[i] = digit
|
||||
commit(next)
|
||||
} else if (e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
const next = [...localDigits]
|
||||
next[i] = '0'
|
||||
commit(next)
|
||||
} else if (e.key === 'Delete') {
|
||||
e.preventDefault()
|
||||
const next = [...localDigits]
|
||||
next[i] = '0'
|
||||
commit(next)
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
if (i > 0) moveTo(i - 1)
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
if (i < 5) moveTo(i + 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const next = [...localDigits]
|
||||
const d = parseInt(next[i], 10)
|
||||
next[i] = String(d === 9 ? 0 : d + 1)
|
||||
commit(next)
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const next = [...localDigits]
|
||||
const d = parseInt(next[i], 10)
|
||||
next[i] = String(d === 0 ? 9 : d - 1)
|
||||
commit(next)
|
||||
} else if (e.key !== 'Tab') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}, [localDigits, commit, moveTo])
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent<HTMLInputElement>): void => {
|
||||
e.preventDefault()
|
||||
const raw = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6)
|
||||
if (!raw) return
|
||||
const digits = raw.padStart(6, '0').split('')
|
||||
commit(digits)
|
||||
moveTo(Math.min(raw.length, 5))
|
||||
}, [commit, moveTo])
|
||||
|
||||
const handleRandom = useCallback((): void => {
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||
isEditing.current = false
|
||||
const seed = Math.floor(Math.random() * 999998) + 1
|
||||
onChange(seed)
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<Wrap title={easterEgg?.flavor}>
|
||||
<Cells>
|
||||
{localDigits.map((digit, i) => (
|
||||
<Cell
|
||||
key={i}
|
||||
ref={(el) => { refs.current[i] = el }}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
$active={!!easterEgg}
|
||||
aria-label={`Seed digit ${i + 1}`}
|
||||
onChange={() => { /* controlled via keydown */ }}
|
||||
onKeyDown={(e) => handleKeyDown(e, i)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
onPaste={handlePaste}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
))}
|
||||
</Cells>
|
||||
<RndmBtn onClick={handleRandom} title="Random seed" aria-label="Random seed">
|
||||
⟳
|
||||
</RndmBtn>
|
||||
</Wrap>
|
||||
)
|
||||
}
|
||||
|
||||
// ── styled components ────────────────────────────────────────────────────────
|
||||
|
||||
const Wrap = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
`
|
||||
|
||||
const Cells = styled.div`
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
`
|
||||
|
||||
const Cell = styled.input<{ $active: boolean }>`
|
||||
width: 22px;
|
||||
height: 28px;
|
||||
background: ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.07)' : 'rgba(255, 255, 255, 0.05)'};
|
||||
border: 1px solid ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.35)' : 'rgba(255, 255, 255, 0.12)'};
|
||||
border-radius: 2px;
|
||||
color: ${({ $active }) => $active ? '#c9a84c' : 'rgba(255, 255, 255, 0.8)'};
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
caret-color: transparent;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.8)' : 'rgba(255, 255, 255, 0.5)'};
|
||||
background: ${({ $active }) => $active ? 'rgba(201, 168, 76, 0.14)' : 'rgba(255, 255, 255, 0.1)'};
|
||||
}
|
||||
`
|
||||
|
||||
const RndmBtn = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
cursor: pointer;
|
||||
font-size: 0.7rem;
|
||||
padding: 0 1px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
&:hover { color: rgba(255, 255, 255, 0.65); }
|
||||
`
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
import type { TurnStats, LeySchool, OceanState, EasterEggSeed } from '@magic-civ/engine-ts'
|
||||
import { computeBiosphereHealth, getBreathabilityState, getOceanState } from '@magic-civ/engine-ts'
|
||||
import { SeedInput } from './SeedInput'
|
||||
import { SeedInput } from '../ui/SeedInput'
|
||||
|
||||
// ── constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -235,17 +235,21 @@ export function StatsDashboard({
|
|||
extendTurns, isSimulating, onExtend,
|
||||
children,
|
||||
}: StatsDashboardProps): ReactElement {
|
||||
const [liveSeed, setLiveSeed] = useState<number | undefined>(seed)
|
||||
useEffect(() => { setLiveSeed(seed) }, [seed])
|
||||
|
||||
const hasLey = stats.some((s) => s.total_ley_strength > 0)
|
||||
const currentStats = stats[currentTurn]
|
||||
const baseStats = stats[0]
|
||||
const resolvedTotal = totalTurns ?? stats.length
|
||||
const activeSeed = liveSeed ?? seed
|
||||
return (
|
||||
<Wrapper>
|
||||
{/* Scrubber + event timeline — aligned with sparklines */}
|
||||
<ScrubberRow>
|
||||
<ScrubberMeta>
|
||||
{seed != null && onSeedChange && (
|
||||
<SeedInput value={seed} onChange={onSeedChange} easterEggs={easterEggs} />
|
||||
<SeedInput value={seed} onChange={onSeedChange} onLiveChange={setLiveSeed} easterEggs={easterEggs} />
|
||||
)}
|
||||
{onExtend && extendTurns && (
|
||||
<ExtendBtn onClick={onExtend} disabled={isSimulating} title={`Extend by ${extendTurns} turns`}>
|
||||
|
|
@ -265,8 +269,8 @@ export function StatsDashboard({
|
|||
/>
|
||||
</ScrubberFlex>
|
||||
</ScrubberRow>
|
||||
{seed != null && easterEggs?.[String(seed)] && (() => {
|
||||
const egg = easterEggs[String(seed)]
|
||||
{activeSeed != null && easterEggs?.[String(activeSeed)] && (() => {
|
||||
const egg = easterEggs[String(activeSeed)]
|
||||
return (
|
||||
<EggRevealRow>
|
||||
<EggName>✦ {egg.name}</EggName>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue